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