diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.styles.ts b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.styles.ts
index 9ac49b64dfb..d0a761fc6ad 100644
--- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.styles.ts
+++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.styles.ts
@@ -40,6 +40,15 @@ const styleSheet = (params: { theme: Theme }) => {
headerUnitButtonActive: {
backgroundColor: colors.primary.default,
},
+ controlsRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.border.muted,
+ },
scrollView: {
flex: 1,
},
diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx
index a4db55cfdf4..b32fcb23656 100644
--- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx
@@ -121,13 +121,34 @@ jest.mock('../../hooks/usePerpsMeasurement', () => ({
usePerpsMeasurement: jest.fn(),
}));
-// Mock usePerpsNavigation
+// Mock usePerpsNavigation and usePerpsMarkets
const mockNavigateToOrder = jest.fn();
jest.mock('../../hooks', () => ({
usePerpsNavigation: jest.fn(() => ({
navigateToOrder: mockNavigateToOrder,
})),
+ usePerpsMarkets: jest.fn(() => ({
+ markets: [
+ {
+ symbol: 'BTC',
+ price: '$50,000.00',
+ leverage: 50,
+ },
+ ],
+ isLoading: false,
+ error: null,
+ })),
+}));
+
+// Mock usePerpsOrderBookGrouping
+const mockSaveGrouping = jest.fn();
+
+jest.mock('../../hooks/usePerpsOrderBookGrouping', () => ({
+ usePerpsOrderBookGrouping: jest.fn(() => ({
+ savedGrouping: undefined,
+ saveGrouping: mockSaveGrouping,
+ })),
}));
// Mock usePerpsEventTracking
@@ -157,6 +178,35 @@ jest.mock('../../components/PerpsOrderBookDepthChart', () => {
);
});
+// Mock PerpsMarketHeader to avoid PerpsStreamProvider dependency
+jest.mock('../../components/PerpsMarketHeader', () => {
+ const { View, Text, TouchableOpacity } = jest.requireActual('react-native');
+ const selectors = jest.requireActual<
+ typeof import('../../../../../../e2e/selectors/Perps/Perps.selectors')
+ >('../../../../../../e2e/selectors/Perps/Perps.selectors');
+ return {
+ __esModule: true,
+ default: ({
+ market,
+ onBackPress,
+ }: {
+ market?: { symbol: string };
+ onBackPress?: () => void;
+ }) => (
+
+
+ Back
+
+ Order Book
+ {market && {market.symbol}}
+
+ ),
+ };
+});
+
// Mock BottomSheet components to avoid SafeAreaProvider requirement
jest.mock(
'../../../../../component-library/components/BottomSheets/BottomSheet',
@@ -330,11 +380,17 @@ describe('PerpsOrderBookView', () => {
describe('unit toggle', () => {
it('displays BTC symbol on base unit toggle', () => {
- const { getByText } = renderWithProvider(, {
+ const { getByTestId } = renderWithProvider(, {
state: initialState,
});
- expect(getByText('BTC')).toBeOnTheScreen();
+ // Find the base unit toggle button by testID
+ const baseToggle = getByTestId(
+ PerpsOrderBookViewSelectorsIDs.UNIT_TOGGLE_BASE,
+ );
+ expect(baseToggle).toBeOnTheScreen();
+ // The button should contain "BTC" text
+ expect(baseToggle).toHaveTextContent('BTC');
});
it('displays USD on USD unit toggle', () => {
@@ -462,15 +518,23 @@ describe('PerpsOrderBookView', () => {
expect(mockTrack).toHaveBeenCalled();
});
- it('always uses nSigFigs: 5 for API (client-side aggregation)', () => {
+ it('uses dynamic nSigFigs based on grouping and price (server-side aggregation)', () => {
renderWithProvider(, { state: initialState });
- // nSigFigs should always be 5 (finest granularity) regardless of grouping selection
+ // nSigFigs is dynamically calculated based on grouping and price
+ // For BTC at ~$50k with default grouping of 10, nSigFigs should be 4
expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith(
expect.objectContaining({
- nSigFigs: 5,
+ nSigFigs: expect.any(Number),
}),
);
+ // Verify nSigFigs is within valid API range (2-5)
+ const calls = mockUsePerpsLiveOrderBook.mock.calls as [
+ { nSigFigs: number },
+ ][];
+ const lastCall = calls[calls.length - 1];
+ expect(lastCall[0].nSigFigs).toBeGreaterThanOrEqual(2);
+ expect(lastCall[0].nSigFigs).toBeLessThanOrEqual(5);
});
});
@@ -628,12 +692,13 @@ describe('PerpsOrderBookView', () => {
);
});
- it('subscribes to live order book with 50 levels for client-side aggregation', () => {
+ it('subscribes to live order book with MAX_ORDER_BOOK_LEVELS (20) for server-side aggregation', () => {
renderWithProvider(, { state: initialState });
+ // Uses MAX_ORDER_BOOK_LEVELS (20) - API returns at most ~20 levels per side with nSigFigs
expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith(
expect.objectContaining({
- levels: 50,
+ levels: 20,
}),
);
});
@@ -648,12 +713,13 @@ describe('PerpsOrderBookView', () => {
);
});
- it('subscribes to live order book with finest nSigFigs (5) for aggregation', () => {
+ it('subscribes to live order book with valid nSigFigs for aggregation', () => {
renderWithProvider(, { state: initialState });
+ // nSigFigs is calculated dynamically based on price and grouping
expect(mockUsePerpsLiveOrderBook).toHaveBeenCalledWith(
expect.objectContaining({
- nSigFigs: 5,
+ nSigFigs: expect.any(Number),
}),
);
});
diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
index 1d602207fdd..db4135a8ccd 100644
--- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx
@@ -1,5 +1,17 @@
-import React, { useCallback, useState, useRef, useMemo } from 'react';
-import { View, ScrollView, Pressable, TouchableOpacity } from 'react-native';
+import React, {
+ useCallback,
+ useState,
+ useRef,
+ useMemo,
+ useEffect,
+} from 'react';
+import {
+ View,
+ ScrollView,
+ Pressable,
+ TouchableOpacity,
+ Modal,
+} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { useStyles } from '../../../../../component-library/hooks';
@@ -27,8 +39,12 @@ import BottomSheetHeader from '../../../../../component-library/components/Botto
import { strings } from '../../../../../../locales/i18n';
import { usePerpsLiveOrderBook } from '../../hooks/stream/usePerpsLiveOrderBook';
import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
-import { usePerpsNavigation } from '../../hooks';
+import { usePerpsNavigation, usePerpsMarkets } from '../../hooks';
import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
+import { usePerpsOrderBookGrouping } from '../../hooks/usePerpsOrderBookGrouping';
+import PerpsMarketHeader from '../../components/PerpsMarketHeader';
+import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip';
+import type { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types';
import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
import {
PerpsEventProperties,
@@ -49,7 +65,8 @@ import {
calculateGroupingOptions,
formatGroupingLabel,
selectDefaultGrouping,
- aggregateOrderBookLevels,
+ calculateAggregationParams,
+ MAX_ORDER_BOOK_LEVELS,
} from '../../utils/orderBookGrouping';
const PerpsOrderBookView: React.FC = ({
@@ -63,42 +80,55 @@ const PerpsOrderBookView: React.FC = ({
const { navigateToOrder } = usePerpsNavigation();
const { track } = usePerpsEventTracking();
+ // Get market data for the header
+ const { markets } = usePerpsMarkets();
+ const market = useMemo(
+ () => markets.find((m) => m.symbol === symbol),
+ [markets, symbol],
+ );
+
// Unit display state (base currency or USD)
const [unitDisplay, setUnitDisplay] = useState('usd');
+ // Persisted order book grouping per asset
+ const { savedGrouping, saveGrouping } = usePerpsOrderBookGrouping(
+ symbol || '',
+ );
+
// Price grouping state (actual price value, e.g., 10 for $10 grouping)
- const [selectedGrouping, setSelectedGrouping] = useState(null);
+ // Initialize from saved grouping if available
+ const [selectedGrouping, setSelectedGrouping] = useState(
+ savedGrouping ?? null,
+ );
const [isDepthBandSheetVisible, setIsDepthBandSheetVisible] = useState(false);
const depthBandSheetRef = useRef(null);
- // Subscribe to live order book data with finest granularity (nSigFigs: 5)
- // We'll aggregate client-side based on selected grouping
- const {
- orderBook: rawOrderBook,
- isLoading,
- error,
- } = usePerpsLiveOrderBook({
- symbol: symbol || '',
- levels: 50, // Request more levels for aggregation
- nSigFigs: 5, // Always use finest granularity
- throttleMs: 100,
- });
+ // Tooltip state
+ const [selectedTooltip, setSelectedTooltip] =
+ useState(null);
- // Calculate mid price from order book
- const midPrice = useMemo(() => {
- if (!rawOrderBook?.bids?.length || !rawOrderBook?.asks?.length) {
- return null;
+ // Sync selectedGrouping when savedGrouping loads (on mount)
+ useEffect(() => {
+ if (savedGrouping !== undefined && selectedGrouping === null) {
+ setSelectedGrouping(savedGrouping);
}
- const bestBid = parseFloat(rawOrderBook.bids[0].price);
- const bestAsk = parseFloat(rawOrderBook.asks[0].price);
- return (bestBid + bestAsk) / 2;
- }, [rawOrderBook]);
-
- // Calculate dynamic grouping options based on mid price
+ }, [savedGrouping, selectedGrouping]);
+
+ // Get market price for grouping calculations (available immediately from markets data)
+ // market.price is formatted like '$90,000.00' so we need to parse it
+ const marketPrice = useMemo(() => {
+ if (!market?.price) return null;
+ // Remove $ and commas, then parse
+ const cleaned = market.price.replace(/[$,]/g, '');
+ const parsed = parseFloat(cleaned);
+ return isNaN(parsed) ? null : parsed;
+ }, [market]);
+
+ // Calculate dynamic grouping options based on market price
const groupingOptions = useMemo(() => {
- if (!midPrice) return [];
- return calculateGroupingOptions(midPrice);
- }, [midPrice]);
+ if (!marketPrice) return [];
+ return calculateGroupingOptions(marketPrice);
+ }, [marketPrice]);
// Current grouping value (use selected or auto-select default)
const currentGrouping = useMemo(() => {
@@ -114,45 +144,38 @@ const PerpsOrderBookView: React.FC = ({
return null;
}, [selectedGrouping, groupingOptions]);
- // Maximum levels to display per side
- const MAX_DISPLAY_LEVELS = 15;
+ // Calculate aggregation params (nSigFigs + mantissa) based on grouping
+ const aggregationParams = useMemo(() => {
+ if (!marketPrice || !currentGrouping) return { nSigFigs: 5 as const };
+ return calculateAggregationParams(currentGrouping, marketPrice);
+ }, [currentGrouping, marketPrice]);
+
+ // Subscribe to live order book data with dynamic nSigFigs and mantissa
+ // These parameters match Hyperliquid's API for consistent price aggregation
+ const {
+ orderBook: rawOrderBook,
+ isLoading,
+ error,
+ } = usePerpsLiveOrderBook({
+ symbol: symbol || '',
+ levels: MAX_ORDER_BOOK_LEVELS,
+ nSigFigs: aggregationParams.nSigFigs,
+ mantissa: aggregationParams.mantissa,
+ throttleMs: 100,
+ });
- // Aggregate order book based on current grouping
+ // Process order book data
+ // The API's nSigFigs parameter handles aggregation at the server level,
+ // so we don't need client-side aggregation. Just pass through the raw data.
const orderBook = useMemo(() => {
- if (!rawOrderBook || !currentGrouping) {
+ if (!rawOrderBook) {
return rawOrderBook;
}
- const aggregatedBids = aggregateOrderBookLevels(
- rawOrderBook.bids,
- currentGrouping,
- 'bid',
- ).slice(0, MAX_DISPLAY_LEVELS);
-
- const aggregatedAsks = aggregateOrderBookLevels(
- rawOrderBook.asks,
- currentGrouping,
- 'ask',
- ).slice(0, MAX_DISPLAY_LEVELS);
-
- // Calculate new max total for depth bars
- const maxBidTotal =
- aggregatedBids.length > 0
- ? parseFloat(aggregatedBids[aggregatedBids.length - 1].total)
- : 0;
- const maxAskTotal =
- aggregatedAsks.length > 0
- ? parseFloat(aggregatedAsks[aggregatedAsks.length - 1].total)
- : 0;
- const maxTotal = Math.max(maxBidTotal, maxAskTotal).toString();
-
- return {
- ...rawOrderBook,
- bids: aggregatedBids,
- asks: aggregatedAsks,
- maxTotal,
- };
- }, [rawOrderBook, currentGrouping]);
+ // No client-side aggregation needed - API handles it via nSigFigs
+ // Just return the raw order book data directly
+ return rawOrderBook;
+ }, [rawOrderBook]);
// Performance measurement
usePerpsMeasurement({
@@ -191,6 +214,7 @@ const PerpsOrderBookView: React.FC = ({
const handleGroupingSelect = useCallback(
(value: number) => {
setSelectedGrouping(value);
+ saveGrouping(value); // Persist to controller
setIsDepthBandSheetVisible(false);
track(MetaMetricsEvents.PERPS_UI_INTERACTION, {
@@ -199,7 +223,7 @@ const PerpsOrderBookView: React.FC = ({
[PerpsEventProperties.ASSET]: symbol || '',
});
},
- [symbol, track],
+ [symbol, track, saveGrouping],
);
// Handle grouping sheet close
@@ -207,6 +231,19 @@ const PerpsOrderBookView: React.FC = ({
setIsDepthBandSheetVisible(false);
}, []);
+ // Handle tooltip press
+ const handleTooltipPress = useCallback(
+ (contentKey: PerpsTooltipContentKey) => {
+ setSelectedTooltip(contentKey);
+ },
+ [],
+ );
+
+ // Handle tooltip close
+ const handleTooltipClose = useCallback(() => {
+ setSelectedTooltip(null);
+ }, []);
+
// Handle unit toggle
const handleUnitChange = useCallback(
(unit: UnitDisplay) => {
@@ -257,20 +294,24 @@ const PerpsOrderBookView: React.FC = ({
if (error) {
return (
-
-
-
-
- {strings('perps.order_book.title')}
-
+ {market ? (
+
+ ) : (
+
+
+
+
+ {strings('perps.order_book.title')}
+
+
-
+ )}
{strings('perps.order_book.error')}
@@ -282,21 +323,11 @@ const PerpsOrderBookView: React.FC = ({
return (
- {/* Header */}
-
-
-
-
- {strings('perps.order_book.title')}
-
-
+ {/* Market Header */}
+ {market && }
+
+ {/* Controls Row - Unit Toggle and Grouping */}
+
{/* Unit Toggle (BTC/USD) */}
= ({
+
{/* Price Grouping Dropdown */}
[
@@ -396,6 +428,17 @@ const PerpsOrderBookView: React.FC = ({
({orderBook.spreadPercentage}%)
+ handleTooltipPress('spread')}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ testID={PerpsOrderBookViewSelectorsIDs.SPREAD_INFO_BUTTON}
+ >
+
+
)}
@@ -463,6 +506,20 @@ const PerpsOrderBookView: React.FC = ({
)}
+
+ {/* Tooltip Bottom Sheet */}
+ {selectedTooltip && (
+
+
+
+
+
+ )}
);
};
diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
index 929b1bbca54..441f69b7f3a 100644
--- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
+++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types.ts
@@ -54,4 +54,5 @@ export type PerpsTooltipContentKey =
| 'points'
| 'market_hours'
| 'after_hours_trading'
- | 'oracle_price';
+ | 'oracle_price'
+ | 'spread';
diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
index 6c88443ad74..2b8abf1d97a 100644
--- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
+++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/content/contentRegistry.ts
@@ -36,4 +36,5 @@ export const tooltipContentRegistry: ContentRegistry = {
market_hours: MarketHoursContent,
after_hours_trading: MarketHoursContent,
oracle_price: undefined,
+ spread: undefined,
};
diff --git a/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.styles.ts b/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.styles.ts
index df8c6f8c934..224e0503c1a 100644
--- a/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.styles.ts
+++ b/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.styles.ts
@@ -18,24 +18,6 @@ const styleSheet = (params: { theme: Theme }) =>
paddingHorizontal: 16,
paddingVertical: 8,
},
- midPriceContainer: {
- position: 'absolute',
- left: '50%',
- top: 0,
- bottom: 0,
- width: 1,
- backgroundColor: params.theme.colors.border.default,
- },
- midPriceLabel: {
- position: 'absolute',
- left: '50%',
- top: 8,
- transform: [{ translateX: -30 }],
- backgroundColor: params.theme.colors.background.default,
- paddingHorizontal: 8,
- paddingVertical: 2,
- borderRadius: 4,
- },
legendContainer: {
flexDirection: 'row',
justifyContent: 'center',
diff --git a/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.test.tsx b/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.test.tsx
index 7aea0fd40a2..1dfc637ab81 100644
--- a/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.test.tsx
+++ b/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.test.tsx
@@ -34,14 +34,6 @@ jest.mock('../../../../hooks/useStyles', () => ({
paddingHorizontal: 16,
paddingVertical: 8,
},
- midPriceContainer: {
- position: 'absolute',
- left: '50%',
- top: 0,
- bottom: 0,
- width: 1,
- backgroundColor: '#e0e0e0',
- },
legendContainer: {
flexDirection: 'row',
justifyContent: 'center',
@@ -249,21 +241,22 @@ describe('PerpsOrderBookDepthChart', () => {
expect(getByText('$49,800')).toBeOnTheScreen();
});
- it('displays mid price label', () => {
+ it('displays max price label', () => {
const { getByText } = render(
,
);
- expect(getByText('$50,050')).toBeOnTheScreen();
+ // Max price is from highest ask
+ expect(getByText('$50,300')).toBeOnTheScreen();
});
- it('displays max price label', () => {
- const { getByText } = render(
+ it('does not display mid price label (removed for cleaner UI)', () => {
+ const { queryByText } = render(
,
);
- // Max price is from highest ask
- expect(getByText('$50,300')).toBeOnTheScreen();
+ // Mid price label was removed to reduce visual clutter
+ expect(queryByText('$50,050')).toBeNull();
});
});
@@ -305,15 +298,13 @@ describe('PerpsOrderBookDepthChart', () => {
asks: [],
};
- const { getByTestId, getByText } = render(
+ const { getByTestId } = render(
,
);
expect(
getByTestId(PerpsOrderBookDepthChartSelectorsIDs.CONTAINER),
).toBeOnTheScreen();
- // Mid price should still be displayed
- expect(getByText('$50,050')).toBeOnTheScreen();
});
it('handles single bid level', () => {
@@ -699,11 +690,13 @@ describe('PerpsOrderBookDepthChart', () => {
describe('re-rendering behavior', () => {
it('re-renders when orderBook data changes', () => {
- const { rerender, getByText } = render(
+ const { rerender, getByTestId } = render(
,
);
- expect(getByText('$50,050')).toBeOnTheScreen();
+ expect(
+ getByTestId(PerpsOrderBookDepthChartSelectorsIDs.CONTAINER),
+ ).toBeOnTheScreen();
const updatedOrderBook: OrderBookData = {
...mockOrderBookData,
@@ -712,7 +705,9 @@ describe('PerpsOrderBookDepthChart', () => {
rerender();
- expect(getByText('$51,000')).toBeOnTheScreen();
+ expect(
+ getByTestId(PerpsOrderBookDepthChartSelectorsIDs.CONTAINER),
+ ).toBeOnTheScreen();
});
it('re-renders when height changes', () => {
@@ -786,9 +781,8 @@ describe('PerpsOrderBookDepthChart', () => {
expect(getByText('Bids')).toBeOnTheScreen();
expect(getByText('Asks')).toBeOnTheScreen();
- // Price labels
+ // Price labels (min and max only, no mid-price)
expect(getByText('$49,800')).toBeOnTheScreen();
- expect(getByText('$50,050')).toBeOnTheScreen();
expect(getByText('$50,300')).toBeOnTheScreen();
});
});
diff --git a/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.tsx b/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.tsx
index 300f1c53686..7312ccbdced 100644
--- a/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.tsx
+++ b/app/components/UI/Perps/components/PerpsOrderBookDepthChart/PerpsOrderBookDepthChart.tsx
@@ -204,19 +204,13 @@ const PerpsOrderBookDepthChart: React.FC = ({
/>
)}
-
- {/* Mid price line */}
-
- {/* Price labels */}
+ {/* Price labels - min and max only, no mid-price */}
{formatPrice(priceRange.min)}
-
- {formatPrice(parseFloat(orderBook.midPrice))}
-
{formatPrice(priceRange.max)}
diff --git a/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.styles.ts b/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.styles.ts
index 11ad66b1a4e..c575316d3e1 100644
--- a/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.styles.ts
+++ b/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.styles.ts
@@ -8,7 +8,6 @@ const styleSheet = (params: { theme: Theme }) =>
},
header: {
flexDirection: 'row',
- paddingHorizontal: 16,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: params.theme.colors.border.muted,
@@ -35,7 +34,6 @@ const styleSheet = (params: { theme: Theme }) =>
},
row: {
flexDirection: 'row',
- paddingHorizontal: 16,
paddingVertical: 2,
position: 'relative',
},
diff --git a/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.test.tsx b/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.test.tsx
index 19e3edeffe7..b0295992635 100644
--- a/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.test.tsx
+++ b/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.test.tsx
@@ -92,6 +92,15 @@ jest.mock('../../../../hooks/useStyles', () => ({
// Mock formatUtils
jest.mock('../../utils/formatUtils', () => ({
formatPerpsFiat: jest.fn((value: number) => `$${value.toLocaleString()}`),
+ formatLargeNumber: jest.fn(
+ (value: number, options?: { decimals?: number }) => {
+ const decimals = options?.decimals ?? 0;
+ if (value >= 1_000_000)
+ return `${(value / 1_000_000).toFixed(decimals)}M`;
+ if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`;
+ return value.toFixed(decimals);
+ },
+ ),
PRICE_RANGES_UNIVERSAL: [],
}));
diff --git a/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.tsx b/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.tsx
index e555e3bb6a0..4ece62211c6 100644
--- a/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.tsx
+++ b/app/components/UI/Perps/components/PerpsOrderBookTable/PerpsOrderBookTable.tsx
@@ -14,6 +14,7 @@ import styleSheet from './PerpsOrderBookTable.styles';
import { PerpsOrderBookTableSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors';
import {
formatPerpsFiat,
+ formatLargeNumber,
PRICE_RANGES_UNIVERSAL,
} from '../../utils/formatUtils';
@@ -61,12 +62,20 @@ const PerpsOrderBookTable: React.FC = ({
);
// Format total based on unit preference
+ // Use compact notation for large USD values to prevent text wrapping
const formatTotal = useCallback(
(level: OrderBookLevel): string => {
if (unit === 'usd') {
- return formatPerpsFiat(parseFloat(level.totalNotional), {
- ranges: PRICE_RANGES_UNIVERSAL,
- });
+ const value = parseFloat(level.totalNotional);
+ // Use compact notation for large values to prevent text wrapping
+ if (value >= 1_000_000) {
+ return '$' + formatLargeNumber(value, { decimals: 1 }); // "$55.4M"
+ }
+ if (value >= 10_000) {
+ return '$' + formatLargeNumber(value, { decimals: 0 }); // "$121K"
+ }
+ // Standard formatting for smaller values
+ return formatPerpsFiat(value, { ranges: PRICE_RANGES_UNIVERSAL });
}
// Base currency
const total = parseFloat(level.total);
@@ -110,14 +119,14 @@ const PerpsOrderBookTable: React.FC = ({
{/* Total (left for bids) */}
-
+
{formatTotal(level)}
{/* Price (right for bids, colored green) */}
-
+
{formatPrice(level.price)}
@@ -151,14 +160,14 @@ const PerpsOrderBookTable: React.FC = ({
{/* Price (left for asks, colored red) */}
-
+
{formatPrice(level.price)}
{/* Total (right for asks) */}
-
+
{formatTotal(level)}
diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts
index 8f7f23b5f3b..0110e85cf79 100644
--- a/app/components/UI/Perps/controllers/PerpsController.test.ts
+++ b/app/components/UI/Perps/controllers/PerpsController.test.ts
@@ -2993,4 +2993,52 @@ describe('PerpsController', () => {
expect(pending).toEqual(pendingConfig);
});
});
+
+ describe('order book grouping', () => {
+ it('saves order book grouping for mainnet', () => {
+ controller.testUpdate((state) => {
+ state.isTestnet = false;
+ });
+
+ controller.saveOrderBookGrouping('BTC', 10);
+
+ const result = controller.getOrderBookGrouping('BTC');
+ expect(result).toBe(10);
+ });
+
+ it('saves order book grouping for testnet', () => {
+ controller.testUpdate((state) => {
+ state.isTestnet = true;
+ });
+
+ controller.saveOrderBookGrouping('ETH', 0.01);
+
+ const result = controller.getOrderBookGrouping('ETH');
+ expect(result).toBe(0.01);
+ });
+
+ it('returns undefined when no grouping is saved', () => {
+ const result = controller.getOrderBookGrouping('SOL');
+ expect(result).toBeUndefined();
+ });
+
+ it('preserves existing config when saving grouping', () => {
+ controller.testUpdate((state) => {
+ state.isTestnet = false;
+ });
+
+ // First save leverage
+ controller.saveTradeConfiguration('BTC', 5);
+
+ // Then save grouping
+ controller.saveOrderBookGrouping('BTC', 100);
+
+ // Both should be preserved
+ const savedConfig = controller.getTradeConfiguration('BTC');
+ expect(savedConfig?.leverage).toBe(5);
+
+ const savedGrouping = controller.getOrderBookGrouping('BTC');
+ expect(savedGrouping).toBe(100);
+ });
+ });
});
diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts
index 337775d4e8a..00587eae072 100644
--- a/app/components/UI/Perps/controllers/PerpsController.ts
+++ b/app/components/UI/Perps/controllers/PerpsController.ts
@@ -222,6 +222,7 @@ export type PerpsControllerState = {
testnet: {
[marketSymbol: string]: {
leverage?: number; // Last used leverage for this market
+ orderBookGrouping?: number; // Persisted price grouping for order book
// Pending trade configuration (temporary, expires after 5 minutes)
pendingConfig?: {
amount?: string; // Order size in USD
@@ -237,6 +238,7 @@ export type PerpsControllerState = {
mainnet: {
[marketSymbol: string]: {
leverage?: number;
+ orderBookGrouping?: number; // Persisted price grouping for order book
// Pending trade configuration (temporary, expires after 5 minutes)
pendingConfig?: {
amount?: string; // Order size in USD
@@ -601,6 +603,14 @@ export type PerpsControllerActions =
| {
type: 'PerpsController:clearPendingTradeConfiguration';
handler: PerpsController['clearPendingTradeConfiguration'];
+ }
+ | {
+ type: 'PerpsController:getOrderBookGrouping';
+ handler: PerpsController['getOrderBookGrouping'];
+ }
+ | {
+ type: 'PerpsController:saveOrderBookGrouping';
+ handler: PerpsController['saveOrderBookGrouping'];
};
/**
@@ -2422,6 +2432,55 @@ export class PerpsController extends BaseController<
});
}
+ /**
+ * Get saved order book grouping for a market
+ * @param coin - Market symbol
+ * @returns The saved grouping value or undefined if not set
+ */
+ getOrderBookGrouping(coin: string): number | undefined {
+ const network = this.state.isTestnet ? 'testnet' : 'mainnet';
+ const grouping =
+ this.state.tradeConfigurations[network]?.[coin]?.orderBookGrouping;
+
+ if (grouping !== undefined) {
+ DevLogger.log('PerpsController: Retrieved order book grouping', {
+ coin,
+ network,
+ grouping,
+ });
+ }
+
+ return grouping;
+ }
+
+ /**
+ * Save order book grouping for a market
+ * @param coin - Market symbol
+ * @param grouping - Price grouping value
+ */
+ saveOrderBookGrouping(coin: string, grouping: number): void {
+ const network = this.state.isTestnet ? 'testnet' : 'mainnet';
+
+ DevLogger.log('PerpsController: Saving order book grouping', {
+ coin,
+ network,
+ grouping,
+ timestamp: new Date().toISOString(),
+ });
+
+ this.update((state) => {
+ if (!state.tradeConfigurations[network]) {
+ state.tradeConfigurations[network] = {};
+ }
+
+ const existingConfig = state.tradeConfigurations[network][coin] || {};
+ state.tradeConfigurations[network][coin] = {
+ ...existingConfig,
+ orderBookGrouping: grouping,
+ };
+ });
+ }
+
/**
* Toggle watchlist status for a market
* Watchlist markets are stored per network (testnet/mainnet)
diff --git a/app/components/UI/Perps/controllers/selectors.test.ts b/app/components/UI/Perps/controllers/selectors.test.ts
index 22c439ef7af..db39c0d757a 100644
--- a/app/components/UI/Perps/controllers/selectors.test.ts
+++ b/app/components/UI/Perps/controllers/selectors.test.ts
@@ -6,6 +6,7 @@ import {
selectIsWatchlistMarket,
selectHasPlacedFirstOrder,
selectMarketFilterPreferences,
+ selectOrderBookGrouping,
} from './selectors';
import type { PerpsControllerState } from './PerpsController';
import { MARKET_SORTING_CONFIG } from '../constants/perpsConfig';
@@ -410,4 +411,68 @@ describe('PerpsController selectors', () => {
expect(result).toBeUndefined();
});
});
+
+ describe('selectOrderBookGrouping', () => {
+ it('returns mainnet order book grouping when not on testnet', () => {
+ const state = {
+ isTestnet: false,
+ tradeConfigurations: {
+ mainnet: {
+ BTC: { orderBookGrouping: 10 },
+ },
+ testnet: {},
+ },
+ } as unknown as PerpsControllerState;
+
+ const result = selectOrderBookGrouping(state, 'BTC');
+
+ expect(result).toBe(10);
+ });
+
+ it('returns testnet order book grouping when on testnet', () => {
+ const state = {
+ isTestnet: true,
+ tradeConfigurations: {
+ mainnet: {},
+ testnet: {
+ ETH: { orderBookGrouping: 0.01 },
+ },
+ },
+ } as unknown as PerpsControllerState;
+
+ const result = selectOrderBookGrouping(state, 'ETH');
+
+ expect(result).toBe(0.01);
+ });
+
+ it('returns undefined when no config exists for asset', () => {
+ const state = {
+ isTestnet: false,
+ tradeConfigurations: {
+ mainnet: {},
+ testnet: {},
+ },
+ } as PerpsControllerState;
+
+ const result = selectOrderBookGrouping(state, 'SOL');
+
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined when orderBookGrouping is not set', () => {
+ const state = {
+ isTestnet: false,
+ tradeConfigurations: {
+ mainnet: {
+ BTC: { leverage: 10 },
+ },
+ testnet: {},
+ },
+ } as unknown as PerpsControllerState;
+
+ const result = selectOrderBookGrouping(state, 'BTC');
+
+ expect(result).toBeUndefined();
+ });
+ });
});
diff --git a/app/components/UI/Perps/controllers/selectors.ts b/app/components/UI/Perps/controllers/selectors.ts
index d5c741c0f9e..9cdd65f22a0 100644
--- a/app/components/UI/Perps/controllers/selectors.ts
+++ b/app/components/UI/Perps/controllers/selectors.ts
@@ -146,3 +146,21 @@ export const selectMarketFilterPreferences = (
): SortOptionId =>
state?.marketFilterPreferences ??
MARKET_SORTING_CONFIG.DEFAULT_SORT_OPTION_ID;
+
+/**
+ * Select order book grouping for a specific market on the current network
+ * @param state - PerpsController state
+ * @param coin - Market symbol (e.g., 'BTC', 'ETH')
+ * @returns Order book grouping value or undefined
+ */
+export const selectOrderBookGrouping = createSelector(
+ [
+ (state: PerpsControllerState) => state?.isTestnet,
+ (state: PerpsControllerState, _coin: string) => state?.tradeConfigurations,
+ (_state: PerpsControllerState, coin: string) => coin,
+ ],
+ (isTestnet, configs, coin): number | undefined => {
+ const network = isTestnet ? 'testnet' : 'mainnet';
+ return configs?.[network]?.[coin]?.orderBookGrouping;
+ },
+);
diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts
index c901911864a..5a02b7e8d9f 100644
--- a/app/components/UI/Perps/controllers/types/index.ts
+++ b/app/components/UI/Perps/controllers/types/index.ts
@@ -688,8 +688,10 @@ export interface SubscribeOrderBookParams {
symbol: string;
/** Number of levels to return per side (default: 10) */
levels?: number;
- /** Price aggregation significant figures (default: 5). Higher = finer granularity */
- nSigFigs?: number;
+ /** Price aggregation significant figures (2-5, default: 5). Higher = finer granularity */
+ nSigFigs?: 2 | 3 | 4 | 5;
+ /** Mantissa for aggregation when nSigFigs is 5 (2 or 5). Controls finest price increments */
+ mantissa?: 2 | 5;
/** Callback function receiving order book updates */
callback: (orderBook: OrderBookData) => void;
/** Callback for errors */
diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.test.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.test.ts
index f985f791dcf..31480b88596 100644
--- a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.test.ts
+++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.test.ts
@@ -547,6 +547,7 @@ describe('usePerpsLiveOrderBook', () => {
symbol: 'BTC',
levels: 10,
nSigFigs: 5,
+ mantissa: undefined,
callback: expect.any(Function),
onError: expect.any(Function),
});
@@ -559,6 +560,7 @@ describe('usePerpsLiveOrderBook', () => {
symbol: 'ETH',
levels: 10,
nSigFigs: 5,
+ mantissa: undefined,
callback: expect.any(Function),
onError: expect.any(Function),
});
@@ -587,6 +589,7 @@ describe('usePerpsLiveOrderBook', () => {
symbol: 'BTC',
levels: 20,
nSigFigs: 5,
+ mantissa: undefined,
callback: expect.any(Function),
onError: expect.any(Function),
});
@@ -597,12 +600,12 @@ describe('usePerpsLiveOrderBook', () => {
mockSubscribeToOrderBook.mockReturnValue(mockUnsubscribe);
const { rerender } = renderHook(
- ({ nSigFigs }) =>
+ ({ nSigFigs }: { nSigFigs: 2 | 3 | 4 | 5 }) =>
usePerpsLiveOrderBook({
symbol: 'BTC',
nSigFigs,
}),
- { initialProps: { nSigFigs: 5 } },
+ { initialProps: { nSigFigs: 5 as 2 | 3 | 4 | 5 } },
);
expect(mockSubscribeToOrderBook).toHaveBeenCalledTimes(1);
@@ -615,6 +618,7 @@ describe('usePerpsLiveOrderBook', () => {
symbol: 'BTC',
levels: 10,
nSigFigs: 3,
+ mantissa: undefined,
callback: expect.any(Function),
onError: expect.any(Function),
});
diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.ts
index ac1572e96e5..9f99505a43b 100644
--- a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.ts
+++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrderBook.ts
@@ -11,8 +11,10 @@ export interface UsePerpsLiveOrderBookOptions {
symbol: string;
/** Number of levels to display (default: 10) */
levels?: number;
- /** Price aggregation significant figures (default: 5). Higher = finer granularity */
- nSigFigs?: number;
+ /** Price aggregation significant figures (2-5, default: 5). Higher = finer granularity */
+ nSigFigs?: 2 | 3 | 4 | 5;
+ /** Mantissa for aggregation when nSigFigs is 5 (2 or 5). Controls finest price increments */
+ mantissa?: 2 | 5;
/** Throttle updates in milliseconds (default: 100ms for real-time feel) */
throttleMs?: number;
/** Whether to enable the subscription (default: true) */
@@ -62,6 +64,7 @@ export function usePerpsLiveOrderBook(
symbol,
levels = 10,
nSigFigs = 5,
+ mantissa,
throttleMs = 100,
enabled = true,
} = options;
@@ -120,6 +123,7 @@ export function usePerpsLiveOrderBook(
DevLogger.log(`usePerpsLiveOrderBook: Subscribing to ${symbol}`, {
levels,
nSigFigs,
+ mantissa,
throttleMs,
});
@@ -134,6 +138,7 @@ export function usePerpsLiveOrderBook(
symbol,
levels,
nSigFigs,
+ mantissa,
callback: (data: OrderBookData) => {
applyUpdate(data);
},
@@ -167,7 +172,7 @@ export function usePerpsLiveOrderBook(
unsubscribe();
}
};
- }, [symbol, levels, nSigFigs, enabled, applyUpdate, throttleMs]);
+ }, [symbol, levels, nSigFigs, mantissa, enabled, applyUpdate, throttleMs]);
return useMemo(
() => ({
diff --git a/app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.test.ts
new file mode 100644
index 00000000000..c519e10fc76
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.test.ts
@@ -0,0 +1,70 @@
+import { renderHook, act } from '@testing-library/react-native';
+import Engine from '../../../../core/Engine';
+import { usePerpsOrderBookGrouping } from './usePerpsOrderBookGrouping';
+import { usePerpsSelector } from './usePerpsSelector';
+
+// Mock dependencies
+jest.mock('./usePerpsSelector');
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ PerpsController: {
+ saveOrderBookGrouping: jest.fn(),
+ },
+ },
+}));
+
+const mockUsePerpsSelector = usePerpsSelector as jest.MockedFunction<
+ typeof usePerpsSelector
+>;
+
+describe('usePerpsOrderBookGrouping', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns savedGrouping from selector', () => {
+ mockUsePerpsSelector.mockReturnValue(10);
+
+ const { result } = renderHook(() => usePerpsOrderBookGrouping('BTC'));
+
+ expect(result.current.savedGrouping).toBe(10);
+ expect(mockUsePerpsSelector).toHaveBeenCalled();
+ });
+
+ it('returns undefined when no saved grouping exists', () => {
+ mockUsePerpsSelector.mockReturnValue(undefined);
+
+ const { result } = renderHook(() => usePerpsOrderBookGrouping('ETH'));
+
+ expect(result.current.savedGrouping).toBeUndefined();
+ });
+
+ it('saves grouping via PerpsController', () => {
+ mockUsePerpsSelector.mockReturnValue(undefined);
+
+ const { result } = renderHook(() => usePerpsOrderBookGrouping('BTC'));
+
+ act(() => {
+ result.current.saveGrouping(50);
+ });
+
+ expect(
+ Engine.context.PerpsController?.saveOrderBookGrouping,
+ ).toHaveBeenCalledWith('BTC', 50);
+ });
+
+ it('memoizes saveGrouping callback by symbol', () => {
+ mockUsePerpsSelector.mockReturnValue(undefined);
+
+ const { result, rerender } = renderHook(
+ ({ symbol }) => usePerpsOrderBookGrouping(symbol),
+ { initialProps: { symbol: 'BTC' } },
+ );
+
+ const firstCallback = result.current.saveGrouping;
+ rerender({ symbol: 'BTC' });
+ const secondCallback = result.current.saveGrouping;
+
+ expect(firstCallback).toBe(secondCallback);
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.ts b/app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.ts
new file mode 100644
index 00000000000..7c6579c9302
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.ts
@@ -0,0 +1,34 @@
+import { useCallback } from 'react';
+import Engine from '../../../../core/Engine';
+import { selectOrderBookGrouping } from '../controllers/selectors';
+import { usePerpsSelector } from './usePerpsSelector';
+
+/**
+ * Hook for persisting order book price grouping per asset
+ * The grouping preference is stored per market and per network (testnet/mainnet)
+ *
+ * @param symbol - Market symbol (e.g., 'BTC', 'ETH')
+ * @returns Object with savedGrouping value and saveGrouping function
+ */
+export function usePerpsOrderBookGrouping(symbol: string): {
+ savedGrouping: number | undefined;
+ saveGrouping: (grouping: number) => void;
+} {
+ // Get saved grouping from controller state via selector
+ const savedGrouping = usePerpsSelector((state) =>
+ selectOrderBookGrouping(state, symbol),
+ );
+
+ // Save grouping to controller state
+ const saveGrouping = useCallback(
+ (grouping: number) => {
+ Engine.context.PerpsController?.saveOrderBookGrouping(symbol, grouping);
+ },
+ [symbol],
+ );
+
+ return {
+ savedGrouping,
+ saveGrouping,
+ };
+}
diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts
index e3718976498..77f93acac06 100644
--- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts
+++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts
@@ -1865,7 +1865,14 @@ export class HyperLiquidSubscriptionService {
* @returns Cleanup function to unsubscribe
*/
public subscribeToOrderBook(params: SubscribeOrderBookParams): () => void {
- const { symbol, levels = 10, nSigFigs = 5, callback, onError } = params;
+ const {
+ symbol,
+ levels = 10,
+ nSigFigs = 5,
+ mantissa,
+ callback,
+ onError,
+ } = params;
this.clientService.ensureSubscriptionClient(
this.walletService.createWalletAdapter(),
@@ -1885,7 +1892,7 @@ export class HyperLiquidSubscriptionService {
let cancelled = false;
subscriptionClient
- .l2Book({ coin: symbol, nSigFigs }, (data: L2BookResponse) => {
+ .l2Book({ coin: symbol, nSigFigs, mantissa }, (data: L2BookResponse) => {
if (cancelled || data?.coin !== symbol || !data?.levels) {
return;
}
diff --git a/app/components/UI/Perps/utils/orderBookGrouping.test.ts b/app/components/UI/Perps/utils/orderBookGrouping.test.ts
index 3db9edd7ced..a616398cfe5 100644
--- a/app/components/UI/Perps/utils/orderBookGrouping.test.ts
+++ b/app/components/UI/Perps/utils/orderBookGrouping.test.ts
@@ -3,6 +3,7 @@ import {
formatGroupingLabel,
selectDefaultGrouping,
aggregateOrderBookLevels,
+ calculateAggregationParams,
} from './orderBookGrouping';
import type { OrderBookLevel } from '../hooks/stream/usePerpsLiveOrderBook';
@@ -204,4 +205,105 @@ describe('orderBookGrouping', () => {
expect(result).toEqual([]);
});
});
+
+ describe('calculateAggregationParams', () => {
+ describe('guard clause for invalid inputs', () => {
+ it('returns nSigFigs: 5 for price <= 0', () => {
+ expect(calculateAggregationParams(10, 0)).toEqual({ nSigFigs: 5 });
+ expect(calculateAggregationParams(10, -100)).toEqual({ nSigFigs: 5 });
+ });
+
+ it('returns nSigFigs: 5 for grouping <= 0', () => {
+ expect(calculateAggregationParams(0, 50000)).toEqual({ nSigFigs: 5 });
+ expect(calculateAggregationParams(-10, 50000)).toEqual({ nSigFigs: 5 });
+ });
+ });
+
+ describe('BTC at ~$90,000', () => {
+ const btcPrice = 90000;
+
+ it('returns nSigFigs: 5 with mantissa: 2 for grouping 1', () => {
+ const result = calculateAggregationParams(1, btcPrice);
+ expect(result).toEqual({ nSigFigs: 5, mantissa: 2 });
+ });
+
+ it('returns nSigFigs: 5 with mantissa: 2 for grouping 2', () => {
+ const result = calculateAggregationParams(2, btcPrice);
+ expect(result).toEqual({ nSigFigs: 5, mantissa: 2 });
+ });
+
+ it('returns nSigFigs: 5 with mantissa: 5 for grouping 5', () => {
+ const result = calculateAggregationParams(5, btcPrice);
+ expect(result).toEqual({ nSigFigs: 5, mantissa: 5 });
+ });
+
+ it('returns nSigFigs: 4 for grouping 10', () => {
+ const result = calculateAggregationParams(10, btcPrice);
+ expect(result).toEqual({ nSigFigs: 4 });
+ });
+
+ it('returns nSigFigs: 3 for grouping 100', () => {
+ const result = calculateAggregationParams(100, btcPrice);
+ expect(result).toEqual({ nSigFigs: 3 });
+ });
+
+ it('returns nSigFigs: 2 for grouping 1000', () => {
+ const result = calculateAggregationParams(1000, btcPrice);
+ expect(result).toEqual({ nSigFigs: 2 });
+ });
+ });
+
+ describe('ETH at ~$3,000', () => {
+ const ethPrice = 3000;
+
+ it('returns nSigFigs: 5 with mantissa for grouping 0.1', () => {
+ const result = calculateAggregationParams(0.1, ethPrice);
+ expect(result).toEqual({ nSigFigs: 5, mantissa: 2 });
+ });
+
+ it('returns nSigFigs: 4 for grouping 1', () => {
+ const result = calculateAggregationParams(1, ethPrice);
+ expect(result).toEqual({ nSigFigs: 4 });
+ });
+
+ it('returns nSigFigs: 3 for grouping 10', () => {
+ const result = calculateAggregationParams(10, ethPrice);
+ expect(result).toEqual({ nSigFigs: 3 });
+ });
+ });
+
+ describe('very small prices (PUMP at ~$0.002)', () => {
+ const pumpPrice = 0.002;
+
+ it('handles very small price with appropriate nSigFigs', () => {
+ const result = calculateAggregationParams(0.000001, pumpPrice);
+ // magnitude = floor(log10(0.002)) = -3
+ // groupingMagnitude = floor(log10(0.000001)) = -6
+ // baseNSigFigs = -3 - (-6) + 1 = 4
+ expect(result).toEqual({ nSigFigs: 4 });
+ });
+
+ it('returns finest granularity for smallest groupings', () => {
+ const result = calculateAggregationParams(0.0000001, pumpPrice);
+ expect(result.nSigFigs).toBe(5);
+ expect(result.mantissa).toBeDefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('clamps nSigFigs to minimum of 2', () => {
+ // Very large grouping relative to price
+ const result = calculateAggregationParams(10000, 90000);
+ // baseNSigFigs = 4 - 4 + 1 = 1, clamped to 2
+ expect(result.nSigFigs).toBe(2);
+ });
+
+ it('handles fractional grouping values correctly', () => {
+ const result = calculateAggregationParams(0.5, 3000);
+ // Should derive mantissa from first digit (5)
+ expect(result.nSigFigs).toBe(5);
+ expect(result.mantissa).toBe(5);
+ });
+ });
+ });
});
diff --git a/app/components/UI/Perps/utils/orderBookGrouping.ts b/app/components/UI/Perps/utils/orderBookGrouping.ts
index 0526e183e6d..33ead2202cc 100644
--- a/app/components/UI/Perps/utils/orderBookGrouping.ts
+++ b/app/components/UI/Perps/utils/orderBookGrouping.ts
@@ -1,5 +1,60 @@
import type { OrderBookLevel } from '../hooks/stream/usePerpsLiveOrderBook';
+/**
+ * Maximum API levels to request from Hyperliquid L2Book API.
+ * The API returns at most ~20 levels per side when using nSigFigs aggregation.
+ */
+export const MAX_ORDER_BOOK_LEVELS = 20;
+
+/**
+ * Parameters for Hyperliquid L2Book API aggregation.
+ */
+export interface AggregationParams {
+ nSigFigs: 2 | 3 | 4 | 5;
+ mantissa?: 2 | 5;
+}
+
+/**
+ * Calculate nSigFigs and mantissa based on grouping and price.
+ * These parameters match Hyperliquid's L2Book API aggregation:
+ * - nSigFigs: 5, mantissa: 2 → finest granularity (~$1-2 for BTC)
+ * - nSigFigs: 5, mantissa: 5 → ~$5 increments for BTC
+ * - nSigFigs: 4 → ~$10 increments for BTC
+ * - nSigFigs: 3 → ~$100 increments for BTC
+ * - nSigFigs: 2 → ~$1000 increments for BTC (widest range)
+ *
+ * mantissa is only applicable when nSigFigs is 5.
+ */
+export function calculateAggregationParams(
+ grouping: number,
+ price: number,
+): AggregationParams {
+ // Guard against invalid inputs that would cause Math.log10 to return -Infinity or NaN
+ if (price <= 0 || grouping <= 0) {
+ return { nSigFigs: 5 };
+ }
+
+ const magnitude = Math.floor(Math.log10(price));
+ const groupingMagnitude = Math.floor(Math.log10(grouping));
+ const baseNSigFigs = magnitude - groupingMagnitude + 1;
+
+ if (baseNSigFigs >= 5) {
+ // Finest granularity needs mantissa
+ // Derive mantissa from the first digit of grouping
+ const firstDigit = Math.floor(grouping / Math.pow(10, groupingMagnitude));
+ const mantissa = firstDigit <= 2 ? 2 : 5;
+ return { nSigFigs: 5, mantissa };
+ }
+
+ // Clamp nSigFigs between 2 and 5 (API only supports these values)
+ const clampedNSigFigs = Math.max(2, Math.min(5, baseNSigFigs)) as
+ | 2
+ | 3
+ | 4
+ | 5;
+ return { nSigFigs: clampedNSigFigs };
+}
+
/**
* Calculate dynamic grouping options based on asset's mid price.
* Uses "1-2-5 per decade" scale anchored to price magnitude.
diff --git a/app/components/UI/Predict/hooks/usePredictEligibility.test.ts b/app/components/UI/Predict/hooks/usePredictEligibility.test.ts
index d0432d4e609..e8879f1b246 100644
--- a/app/components/UI/Predict/hooks/usePredictEligibility.test.ts
+++ b/app/components/UI/Predict/hooks/usePredictEligibility.test.ts
@@ -232,6 +232,10 @@ describe('usePredictEligibility', () => {
});
it('ignores transition from active to background', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
const handleAppStateChange = mockAppStateAddEventListener.mock
@@ -246,6 +250,10 @@ describe('usePredictEligibility', () => {
});
it('ignores transition from active to inactive', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
const handleAppStateChange = mockAppStateAddEventListener.mock
@@ -260,6 +268,10 @@ describe('usePredictEligibility', () => {
});
it('ignores transition from background to inactive', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
const handleAppStateChange = mockAppStateAddEventListener.mock
@@ -275,7 +287,11 @@ describe('usePredictEligibility', () => {
});
describe('debouncing', () => {
- it('skips refresh when less than 1 minute has passed', async () => {
+ it('skips refresh when less than debounce interval has passed', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
const handleAppStateChange = mockAppStateAddEventListener.mock
@@ -288,7 +304,7 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(30000);
+ jest.advanceTimersByTime(50);
await act(async () => {
handleAppStateChange('background');
@@ -300,12 +316,16 @@ describe('usePredictEligibility', () => {
'PredictController: Skipping refresh due to debounce',
expect.objectContaining({
timeSinceLastRefresh: expect.any(Number),
- minimumInterval: 60000,
+ minimumInterval: 100,
}),
);
});
- it('allows refresh when 1 minute has elapsed', async () => {
+ it('allows refresh when debounce interval has elapsed', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
const handleAppStateChange = mockAppStateAddEventListener.mock
@@ -318,7 +338,7 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(60000);
+ jest.advanceTimersByTime(100);
await act(async () => {
handleAppStateChange('background');
@@ -328,7 +348,11 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
});
- it('allows refresh when more than 1 minute has elapsed', async () => {
+ it('allows refresh when more than debounce interval has elapsed', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
const handleAppStateChange = mockAppStateAddEventListener.mock
@@ -341,7 +365,7 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(120000);
+ jest.advanceTimersByTime(200);
await act(async () => {
handleAppStateChange('background');
@@ -352,6 +376,10 @@ describe('usePredictEligibility', () => {
});
it('resets debounce timer after successful refresh', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
const handleAppStateChange = mockAppStateAddEventListener.mock
@@ -364,7 +392,7 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(60000);
+ jest.advanceTimersByTime(100);
await act(async () => {
handleAppStateChange('background');
@@ -373,7 +401,7 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
- jest.advanceTimersByTime(30000);
+ jest.advanceTimersByTime(50);
await act(async () => {
handleAppStateChange('background');
@@ -418,6 +446,10 @@ describe('usePredictEligibility', () => {
});
it('updates debounce timer after manual refresh', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
const { result } = renderHook(() =>
usePredictEligibility({ providerId: 'polymarket' }),
);
@@ -431,7 +463,7 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(30000);
+ jest.advanceTimersByTime(50);
await act(async () => {
handleAppStateChange('background');
@@ -491,6 +523,10 @@ describe('usePredictEligibility', () => {
});
it('continues operation after failed refresh', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
mockRefreshEligibility.mockRejectedValueOnce(new Error('Network error'));
renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
@@ -506,7 +542,7 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
mockRefreshEligibility.mockResolvedValueOnce(undefined);
- jest.advanceTimersByTime(60000);
+ jest.advanceTimersByTime(100);
await act(async () => {
handleAppStateChange('background');
@@ -607,6 +643,10 @@ describe('usePredictEligibility', () => {
});
it('allows new refresh after previous one completes', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
let resolveFirstRefresh: (() => void) | undefined;
const firstRefreshPromise = new Promise((resolve) => {
resolveFirstRefresh = resolve;
@@ -631,7 +671,7 @@ describe('usePredictEligibility', () => {
});
mockRefreshEligibility.mockResolvedValueOnce(undefined);
- jest.advanceTimersByTime(60000);
+ jest.advanceTimersByTime(100);
await act(async () => {
handleAppStateChange('background');
@@ -642,6 +682,10 @@ describe('usePredictEligibility', () => {
});
it('clears in-flight promise after error', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
let rejectRefresh: ((error: Error) => void) | undefined;
const refreshPromise = new Promise((_resolve, reject) => {
rejectRefresh = reject;
@@ -670,7 +714,7 @@ describe('usePredictEligibility', () => {
});
mockRefreshEligibility.mockResolvedValueOnce(undefined);
- jest.advanceTimersByTime(60000);
+ jest.advanceTimersByTime(100);
await act(async () => {
handleAppStateChange('background');
@@ -680,4 +724,252 @@ describe('usePredictEligibility', () => {
expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
});
});
+
+ describe('auto-refresh when country is missing', () => {
+ it('starts polling when country is not returned', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ 'PredictController: Country missing, auto-refreshing eligibility',
+ { providerId: 'polymarket', retryCount: 1, maxRetries: 3 },
+ );
+ });
+
+ it('polls again after polling interval when country is still missing', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(3);
+ });
+
+ it('continues polling while country is missing up to max retries', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ // Wait for initial poll to complete (retry 1)
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
+
+ // Advance timer to trigger second poll (retry 2)
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
+
+ // Advance timer to trigger third poll (retry 3 - max)
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(3);
+ });
+
+ it('stops polling after reaching max retries', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ // Complete all 3 retries
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(3);
+
+ // Advance timer again - no more polls should happen
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ // Still 3 calls - no additional polling after max retries
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(3);
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ 'PredictController: Max retries reached for missing country',
+ expect.objectContaining({
+ providerId: 'polymarket',
+ retryCount: 3,
+ }),
+ );
+ });
+
+ it('does not start polling when country is already available', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true, country: 'US' },
+ };
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockDevLogger).not.toHaveBeenCalledWith(
+ 'PredictController: Country missing, auto-refreshing eligibility',
+ expect.any(Object),
+ );
+ });
+
+ it('continues polling after failed refresh', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ mockRefreshEligibility.mockRejectedValueOnce(new Error('Network error'));
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ 'PredictController: Auto-refresh for missing country failed',
+ expect.objectContaining({
+ error: 'Network error',
+ retryCount: 1,
+ }),
+ );
+
+ mockRefreshEligibility.mockResolvedValueOnce(undefined);
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
+ });
+
+ it('logs unknown error when auto-refresh fails with non-Error', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ mockRefreshEligibility.mockRejectedValueOnce('string error');
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockDevLogger).toHaveBeenCalledWith(
+ 'PredictController: Auto-refresh for missing country failed',
+ expect.objectContaining({
+ error: 'Unknown',
+ retryCount: 1,
+ }),
+ );
+ });
+
+ it('clears timeout on unmount', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ const { unmount } = renderHook(() =>
+ usePredictEligibility({ providerId: 'polymarket' }),
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
+
+ unmount();
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses sequential polling pattern - waits for response before scheduling next poll', async () => {
+ mockState.engine.backgroundState.PredictController.eligibility = {
+ polymarket: { eligible: true },
+ };
+
+ let resolveRefresh: (() => void) | undefined;
+ const refreshPromise = new Promise((resolve) => {
+ resolveRefresh = resolve;
+ });
+ mockRefreshEligibility.mockReturnValueOnce(refreshPromise);
+
+ renderHook(() => usePredictEligibility({ providerId: 'polymarket' }));
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(1);
+
+ resolveRefresh?.();
+ await act(async () => {
+ await refreshPromise;
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockRefreshEligibility).toHaveBeenCalledTimes(2);
+ });
+ });
});
diff --git a/app/components/UI/Predict/hooks/usePredictEligibility.ts b/app/components/UI/Predict/hooks/usePredictEligibility.ts
index f251f1625d6..84e36964d5b 100644
--- a/app/components/UI/Predict/hooks/usePredictEligibility.ts
+++ b/app/components/UI/Predict/hooks/usePredictEligibility.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { createSelector } from 'reselect';
import { useSelector } from 'react-redux';
@@ -16,7 +16,13 @@ export type PredictEligibilityState = ReturnType<
>;
// Minimum time between automatic eligibility refreshes (1 minute)
-const DEBOUNCE_INTERVAL_MS = 60000;
+const DEBOUNCE_INTERVAL_MS = 100;
+
+// Polling interval for auto-refresh when country is missing (2 seconds)
+const MISSING_COUNTRY_POLLING_INTERVAL_MS = 2000;
+
+// Maximum number of retry attempts when country is missing
+const MISSING_COUNTRY_MAX_RETRIES = 3;
/**
* Singleton manager to coordinate eligibility refreshes across multiple hook instances.
@@ -182,6 +188,10 @@ export const getRefreshManagerForTesting = (): EligibilityRefreshManager =>
* Hook to access Predict eligibility state and trigger refreshes via the controller.
* Automatically refreshes eligibility when the app comes to foreground.
* Multiple components can safely use this hook without causing duplicate refreshes.
+ *
+ * When no country is returned in the eligibility response, the hook will automatically
+ * poll for updates using a sequential loading pattern (wait for response → wait interval → poll again)
+ * until a country is returned or the component unmounts.
*/
export const usePredictEligibility = ({
providerId,
@@ -189,12 +199,17 @@ export const usePredictEligibility = ({
providerId: string;
}) => {
const eligibility = useSelector(selectPredictEligibility);
+ const country = eligibility[providerId]?.country;
// Manual refresh - bypasses debounce (force = true)
const refreshEligibility = useCallback(async () => {
await refreshManager.refresh(true);
}, []);
+ // Store refreshEligibility in a ref to avoid effect restarts when its identity changes
+ const refreshEligibilityRef = useRef(refreshEligibility);
+ refreshEligibilityRef.current = refreshEligibility;
+
// Register this hook instance with the singleton manager
useEffect(() => {
DevLogger.log('PredictController: Mounting eligibility hook', {
@@ -211,9 +226,71 @@ export const usePredictEligibility = ({
};
}, [providerId]);
+ // Auto-refresh when country is missing - sequential loading pattern
+ // Similar to usePredictOptimisticPositionRefresh
+ // Retries up to MISSING_COUNTRY_MAX_RETRIES times, resets on unmount
+ useEffect(() => {
+ // Skip if we already have a country
+ if (country) return;
+
+ let shouldContinue = true;
+ let timeoutId: NodeJS.Timeout | null = null;
+ let retryCount = 0;
+
+ const pollForCountry = async () => {
+ if (!shouldContinue) return;
+
+ retryCount += 1;
+
+ DevLogger.log(
+ 'PredictController: Country missing, auto-refreshing eligibility',
+ { providerId, retryCount, maxRetries: MISSING_COUNTRY_MAX_RETRIES },
+ );
+
+ try {
+ await refreshEligibilityRef.current();
+ } catch (error) {
+ // Continue polling even if an individual request fails
+ // This ensures we keep trying to get country data
+ DevLogger.log(
+ 'PredictController: Auto-refresh for missing country failed',
+ {
+ error: error instanceof Error ? error.message : 'Unknown',
+ retryCount,
+ },
+ );
+ }
+
+ // After the response (or error), schedule next poll if still active
+ // and we haven't reached max retries
+ // Note: The effect will re-run if country becomes available,
+ // which will stop the polling due to the early return
+ if (shouldContinue && retryCount < MISSING_COUNTRY_MAX_RETRIES) {
+ timeoutId = setTimeout(() => {
+ pollForCountry();
+ }, MISSING_COUNTRY_POLLING_INTERVAL_MS);
+ } else if (shouldContinue && retryCount >= MISSING_COUNTRY_MAX_RETRIES) {
+ DevLogger.log(
+ 'PredictController: Max retries reached for missing country',
+ { providerId, retryCount },
+ );
+ }
+ };
+
+ pollForCountry();
+
+ return () => {
+ // Reset retry count on unmount (retryCount is local to this effect instance)
+ shouldContinue = false;
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ };
+ }, [country, providerId]);
+
return {
isEligible: eligibility[providerId]?.eligible ?? false,
- country: eligibility[providerId]?.country,
+ country,
refreshEligibility,
};
};
diff --git a/e2e/selectors/Perps/Perps.selectors.ts b/e2e/selectors/Perps/Perps.selectors.ts
index 0acc3c0def3..656813b533f 100644
--- a/e2e/selectors/Perps/Perps.selectors.ts
+++ b/e2e/selectors/Perps/Perps.selectors.ts
@@ -633,6 +633,8 @@ export const PerpsOrderBookViewSelectorsIDs = {
DEPTH_BAND_OPTION: 'perps-order-book-depth-band-option',
UNIT_TOGGLE_BASE: 'perps-order-book-unit-toggle-base',
UNIT_TOGGLE_USD: 'perps-order-book-unit-toggle-usd',
+ SPREAD_INFO_BUTTON: 'perps-order-book-spread-info-button',
+ BOTTOM_SHEET_TOOLTIP: 'perps-order-book-tooltip',
} as const;
// ========================================
diff --git a/e2e/specs/rewards/rewards.mocks.ts b/e2e/specs/rewards/rewards.mocks.ts
index ffb418997a7..605d5fe7769 100644
--- a/e2e/specs/rewards/rewards.mocks.ts
+++ b/e2e/specs/rewards/rewards.mocks.ts
@@ -191,7 +191,7 @@ const SEASON_STATUS_RESPONSE_ONBOARDING = {
current: {
id: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5',
startDate: '2025-09-01T00:00:00.000Z',
- endDate: '2025-11-30T00:00:00.000Z',
+ endDate: '2026-03-31T00:00:00.000Z',
},
next: null,
};
@@ -201,7 +201,7 @@ const SEASON_STATUS_RESPONSE = {
id: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5',
name: 'Season 1',
startDate: '2025-09-01T04:00:00.000Z',
- endDate: '2025-11-30T04:00:00.000Z',
+ endDate: '2026-03-31T04:00:00.000Z',
tiers: [
{
id: 'deb27e87-ed2c-4602-a4ba-3ed3de7fbe2b',
@@ -599,7 +599,7 @@ const setupSeasonMetadataMock = async (mockServer: Mockttp) => {
id: 'f3f0ccae-33e9-4256-b5d9-c42105570fa5',
name: 'Season 1',
startDate: '2025-09-01T00:00:00.000Z',
- endDate: '2025-11-30T00:00:00.000Z',
+ endDate: '2026-03-31T00:00:00.000Z',
tiers: [
{
id: '92d8b6a6-747c-45bf-8a91-6cdd5ac9f3bf',
diff --git a/locales/languages/en.json b/locales/languages/en.json
index ae1e2f8cd16..86fa434985c 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -1678,6 +1678,10 @@
"title": "After-hours trading",
"reopens_in": "Reopens in {{time}}",
"content": "You're trading outside of regular market hours (9:30 am to 4pm ET). When markets are closed, there's risk for wider spreads, price moves, and higher funding rates."
+ },
+ "spread": {
+ "title": "Spread",
+ "content": "The spread is the difference between the best bid (highest buy price) and best ask (lowest sell price). A smaller spread indicates higher liquidity."
}
},
"connection": {
diff --git a/package.json b/package.json
index aa35855c7a3..65277c6c33a 100644
--- a/package.json
+++ b/package.json
@@ -292,7 +292,7 @@
"@metamask/tron-wallet-snap": "^1.13.0",
"@metamask/utils": "^11.8.1",
"@ngraveio/bc-ur": "^1.1.6",
- "@nktkas/hyperliquid": "^0.25.9",
+ "@nktkas/hyperliquid": "^0.27.1",
"@noble/curves": "1.9.6",
"@notifee/react-native": "^9.0.0",
"@react-native-async-storage/async-storage": "^1.23.1",
diff --git a/yarn.lock b/yarn.lock
index 56efc2ede01..5bdebfff865 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10096,13 +10096,6 @@ __metadata:
languageName: node
linkType: hard
-"@msgpack/msgpack@npm:^3.1.2":
- version: 3.1.2
- resolution: "@msgpack/msgpack@npm:3.1.2"
- checksum: 10/e04ff37d7c89ffdd6b4fbcd1770af60b16c98afdf1c3c16190170dfe34764048eb45e3654016ac62cc616c7e4b09e611f8863317ca5f18b3a72974fb131e562e
- languageName: node
- linkType: hard
-
"@native-html/css-processor@npm:1.11.0":
version: 1.11.0
resolution: "@native-html/css-processor@npm:1.11.0"
@@ -10159,19 +10152,25 @@ __metadata:
languageName: node
linkType: hard
-"@nktkas/hyperliquid@npm:^0.25.9":
- version: 0.25.9
- resolution: "@nktkas/hyperliquid@npm:0.25.9"
+"@nktkas/hyperliquid@npm:^0.27.1":
+ version: 0.27.1
+ resolution: "@nktkas/hyperliquid@npm:0.27.1"
dependencies:
"@henrygd/semaphore": "npm:0.1.0"
- "@msgpack/msgpack": "npm:^3.1.2"
+ "@nktkas/rews": "npm:^1.2.1"
"@noble/hashes": "npm:^2.0.1"
"@noble/secp256k1": "npm:^3.0.0"
- typescript-event-target: "npm:1.1.1"
- valibot: "npm:1.1.0"
+ valibot: "npm:1.2.0"
bin:
hyperliquid: esm/bin/cli.js
- checksum: 10/b62cf956449342d0ef777d62a7ca03515ad7cbc7b54bb6907bd5f426ec3b54d82320e4d5fd80a489a98dea817fae34ec885fc9a9a598625425c45d103ae1974f
+ checksum: 10/362e312d571053eabcd841ffdd8bc4798d8d0a9c9a27f080962fb6646d3c8903a183f0355c24f32f09f6d90c2aa9746934cd67712a9dd047950790999ea9fde8
+ languageName: node
+ linkType: hard
+
+"@nktkas/rews@npm:^1.2.1":
+ version: 1.2.1
+ resolution: "@nktkas/rews@npm:1.2.1"
+ checksum: 10/b24a5fabf3bbb3607d780bebfb309278babf71aa1bc594b5e966f6b7cf81cd0a364132e54e681a4281669838224b132f45765e24a0b9e7daae752607e9028902
languageName: node
linkType: hard
@@ -36051,7 +36050,7 @@ __metadata:
"@metamask/tron-wallet-snap": "npm:^1.13.0"
"@metamask/utils": "npm:^11.8.1"
"@ngraveio/bc-ur": "npm:^1.1.6"
- "@nktkas/hyperliquid": "npm:^0.25.9"
+ "@nktkas/hyperliquid": "npm:^0.27.1"
"@noble/curves": "npm:1.9.6"
"@notifee/react-native": "npm:^9.0.0"
"@octokit/rest": "npm:^21.0.0"
@@ -45939,13 +45938,6 @@ __metadata:
languageName: node
linkType: hard
-"typescript-event-target@npm:1.1.1":
- version: 1.1.1
- resolution: "typescript-event-target@npm:1.1.1"
- checksum: 10/199c5f761cfedab59ac6a1198c597ffd03723002b0171f03fc060b4b9312cab40ac6eea117786ecd2c6d3d6a25bb7a007695ad4398de9dccfe60b7da782b893f
- languageName: node
- linkType: hard
-
"typescript-logic@npm:^0.0.0":
version: 0.0.0
resolution: "typescript-logic@npm:0.0.0"
@@ -46831,15 +46823,15 @@ __metadata:
languageName: node
linkType: hard
-"valibot@npm:1.1.0":
- version: 1.1.0
- resolution: "valibot@npm:1.1.0"
+"valibot@npm:1.2.0":
+ version: 1.2.0
+ resolution: "valibot@npm:1.2.0"
peerDependencies:
typescript: ">=5"
peerDependenciesMeta:
typescript:
optional: true
- checksum: 10/667c99e3ec2825ea97c20f37add7fe7eb9d15fb338c5f49c166ffba13a6f4516c77c060083e1c6bda77a2275c4d37400b711d284bbea03fd4ae160bbca34112c
+ checksum: 10/5f9c15e6f5a2b8eae75332a3317e46e995a1763efe1b91e57bc5064e36f0feba734367c88013d53255bdf09fb9204bf3598d2ca0c3f468c8726095b1c3551926
languageName: node
linkType: hard