From 66334fb642586049e16ba9fd9d5d729acecb0ff0 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:44:05 +0800 Subject: [PATCH 1/4] fix(perps): order book behavior matching hyperliquid cp-7.61.0 (#23464) ## **Description** Improves the Perps order book view with several UX fixes: 1. **Consistent row count across groupings** - Uses Hyperliquid API's `nSigFigs` and `mantissa` parameters for server-side price aggregation, ensuring ~20 rows display regardless of selected price grouping 2. **Persisted price grouping** - Order book price grouping selection is now persisted per asset in the PerpsController state 3. **Improved header** - Replaced generic "Order Book" title with `PerpsMarketHeader` component showing asset icon, live price, and leverage badge (consistent with other perps views) 4. **Spread tooltip** - Added info icon next to spread value with explanation tooltip ### How nSigFigs + mantissa Works (Hyperliquid API) The API's `nSigFigs` (2-5) and `mantissa` (2 or 5, when nSigFigs=5) parameters control price aggregation granularity. For BTC at ~$90k: | Grouping | API Parameters | Price Increment | |----------|---------------|-----------------| | $1-2 | `nSigFigs: 5, mantissa: 2` | ~$1-2 | | $5 | `nSigFigs: 5, mantissa: 5` | ~$5 | | $10 | `nSigFigs: 4` | ~$10 | | $100 | `nSigFigs: 3` | ~$100 | | $1000 | `nSigFigs: 2` | ~$1000 | The `mantissa` parameter is only applicable when `nSigFigs` is 5, enabling finer control at the finest aggregation levels. By dynamically mapping the user's selected grouping to the appropriate parameters, each grouping level displays a consistent ~20 rows. ## **Changelog** CHANGELOG entry: Fixed order book display to show consistent number of rows across all price groupings ## **Related issues** Fixes: TAT-2164, TAT-2174, TAT-2175 ## **Manual testing steps** ```gherkin Feature: Order Book Price Grouping Scenario: User changes price grouping Given the user is on the order book view for BTC When user taps the grouping dropdown and selects "$100" Then the order book displays ~20 rows at $100 increments And the grouping selection persists when navigating away and back Scenario: Consistent row count Given the user is on the order book view for BTC When user cycles through different grouping options ($1, $10, $100, $1000) Then each grouping displays approximately 20 rows Scenario: User taps spread info icon Given the user is on the order book view When user taps the info icon next to the spread value Then a tooltip appears explaining what spread means ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Switches order book to server-side aggregation (dynamic nSigFigs/mantissa), persists grouping per asset, adds market header/controls and spread tooltip, and updates services/types/tests. > > - **Order Book Data & Aggregation**: > - Replace client-side aggregation with server-side via `nSigFigs` + `mantissa`; request `MAX_ORDER_BOOK_LEVELS` (`20`). > - Add `calculateAggregationParams` and use dynamic params from market price/grouping. > - Update `HyperLiquidSubscriptionService.subscribeToOrderBook` and types to accept `mantissa` and stricter `nSigFigs`. > - **State & Persistence**: > - Persist per-asset grouping in `PerpsController` (`getOrderBookGrouping`/`saveOrderBookGrouping`) and selectors (`selectOrderBookGrouping`). > - New hook `usePerpsOrderBookGrouping` to read/save grouping. > - **UI/UX**: > - `PerpsOrderBookView`: add `PerpsMarketHeader`, controls row (unit toggle + grouping), tooltip modal for spread, and server-side data wiring. > - `PerpsBottomSheetTooltip`: add `spread` content key and locale strings. > - `PerpsOrderBookDepthChart`: remove mid-price line/label; show only min/max labels; style cleanup. > - `PerpsOrderBookTable`: compact large USD totals (`K/M`), smaller text, padding tweaks. > - Add E2E selectors for spread info and tooltip. > - **Tests**: > - Update/expand tests across view, chart, table, hooks, controller, selectors, and utils to reflect new aggregation, params, UI, and persistence. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 43b0cadbd7470e89936ff12661c96a83bcb5e0e5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude --- .../PerpsOrderBookView.styles.ts | 9 + .../PerpsOrderBookView.test.tsx | 86 ++++++- .../PerpsOrderBookView/PerpsOrderBookView.tsx | 243 +++++++++++------- .../PerpsBottomSheetTooltip.types.ts | 3 +- .../content/contentRegistry.ts | 1 + .../PerpsOrderBookDepthChart.styles.ts | 18 -- .../PerpsOrderBookDepthChart.test.tsx | 38 ++- .../PerpsOrderBookDepthChart.tsx | 8 +- .../PerpsOrderBookTable.styles.ts | 2 - .../PerpsOrderBookTable.test.tsx | 9 + .../PerpsOrderBookTable.tsx | 23 +- .../Perps/controllers/PerpsController.test.ts | 48 ++++ .../UI/Perps/controllers/PerpsController.ts | 59 +++++ .../UI/Perps/controllers/selectors.test.ts | 65 +++++ .../UI/Perps/controllers/selectors.ts | 18 ++ .../UI/Perps/controllers/types/index.ts | 6 +- .../stream/usePerpsLiveOrderBook.test.ts | 8 +- .../hooks/stream/usePerpsLiveOrderBook.ts | 11 +- .../hooks/usePerpsOrderBookGrouping.test.ts | 70 +++++ .../Perps/hooks/usePerpsOrderBookGrouping.ts | 34 +++ .../HyperLiquidSubscriptionService.ts | 11 +- .../UI/Perps/utils/orderBookGrouping.test.ts | 102 ++++++++ .../UI/Perps/utils/orderBookGrouping.ts | 55 ++++ e2e/selectors/Perps/Perps.selectors.ts | 2 + locales/languages/en.json | 4 + 25 files changed, 764 insertions(+), 169 deletions(-) create mode 100644 app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.test.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsOrderBookGrouping.ts 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/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/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": { From ffee33a0b9c8553717945367892e4723ac66efba Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:01:20 +0800 Subject: [PATCH 2/4] feat(perps): update hyperliquid sdk 0.27.1 (#23356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** update hyperliquid sdk 0.27.1 ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Update Hyperliquid SDK to 0.27.1 and refresh lockfile dependencies. > > - **Dependencies**: > - Bump `@nktkas/hyperliquid` to `^0.27.1` in `package.json` and `yarn.lock`. > - Lockfile updates: > - Add `@nktkas/rews`; upgrade `valibot` to `1.2.0`. > - Remove transitive deps `@msgpack/msgpack` and `typescript-event-target`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0f4182c212f9efbec2201ce6ba9388b30e04e2b7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- yarn.lock | 44 ++++++++++++++++++-------------------------- 2 files changed, 19 insertions(+), 27 deletions(-) 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 From 435690e87c6939130b8309354270660ba267906a Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:24:51 -0500 Subject: [PATCH 3/4] fix: rewards smoke e2e extend season end date (#23508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Extend season end date to make e2e pass ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Extends mocked rewards season end dates to 2026-03-31 across e2e rewards mocks to keep tests valid. > > - **e2e rewards mocks**: > - Update `endDate` to `2026-03-31` in `SEASON_STATUS_RESPONSE_ONBOARDING`, `SEASON_STATUS_RESPONSE`, and season metadata mock (`/public/seasons/:id/meta`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6ef8f31307309bb795a1f22f634b28911a3fe2db. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- e2e/specs/rewards/rewards.mocks.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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', From a9cb80139395cc488b89ef1816faf7a113787436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Tue, 2 Dec 2025 00:42:38 -0300 Subject: [PATCH 4/4] feat(predict): cp-7.60.2 add auto-refresh polling when country is missing (#23513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add automatic polling mechanism to usePredictEligibility hook that refreshes eligibility when no country is returned in the response. Changes: - Add sequential polling pattern (wait for response → wait interval → poll again) - Poll every 2 seconds until country is returned or component unmounts - Continue polling even if individual requests fail - Stop polling automatically when country becomes available - Add comprehensive tests for the new polling behavior This ensures users get their country data even if the initial eligibility check doesn't return it, improving the reliability of the Predict feature's geo-restriction handling. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds sequential auto-refresh polling to `usePredictEligibility` when `country` is missing, reduces debounce to 100ms, and updates tests accordingly. > > - **Predict Eligibility Hook (`usePredictEligibility`)**: > - Adds sequential auto-refresh polling when `eligibility[providerId].country` is missing. > - Polls every `2000ms` up to `3` retries; stops on country availability or unmount; logs failures and retry limits. > - Introduces `useRef` to stabilize `refreshEligibility` in effects. > - Reduces debounce interval to `100ms` for automatic refreshes. > - **Tests (`usePredictEligibility.test.ts`)**: > - Update debounce expectations to `100ms` and related timings. > - Add comprehensive tests for missing-country polling: start/interval, max retries, error handling, cleanup on unmount, and sequential polling behavior. > - Ensure scenarios for app state transitions, race prevention, manual refresh, and error logging reflect new timings and polling logic. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9ad7a9c91430f79446a6531af27efc2268e79836. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/usePredictEligibility.test.ts | 318 +++++++++++++++++- .../UI/Predict/hooks/usePredictEligibility.ts | 83 ++++- 2 files changed, 385 insertions(+), 16 deletions(-) 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, }; };