From 87b4499d83ccc8c3b383de1f3f7534ad0328d848 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Sat, 6 Dec 2025 00:04:36 +0000 Subject: [PATCH 1/2] refactor: modify trending browser navigators (#23598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes issue where custom trending browser uses a different design compared to the original. This PR removes our custom browser wrapper in favour of the original browser + behaviour -- the mobile platform team will spearhead the IAB changes. For visibility: Navigation Structure Before: ``` (TAB) REMOVED Browser Tab -- removing this caused issues for areas using old browser navigation (TAB) Trending Tab (ExploreFlow) (STACK) Trending (STACK) Trending - TRENDING_FEED - EXPLORE_SEARCH - Custom Browser ``` Navigation Structure After: ``` (TAB) Browser Tab (BrowserFlow) -- Keeping original browser tab, but it is hidden - existing navigations will still work - BROWSER.VIEW - BROWSER.ASSET_LOADER - BROWSER.ASSET_VIEW (TAB) Trending Tab (ExploreFlow) - TRENDING_FEED - EXPLORE_SEARCH - Dedicated BrowserFlow screen -- Allows trending page to use existing browser (no custom browser), and retains trending navigation back behaviour (search queries and navigation stack is retained) ``` ## **Changelog** CHANGELOG entry: refactor: modify trending/explore page navigation stacks ## **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** https://www.loom.com/share/bb1343b7a7ed4b1abb841f8c6d1284f3 ## **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] > Refactors Explore/Trending to use the main Browser flow (with a hidden Browser tab), adds `TRENDING_FEED`, and enhances TabBar to support custom selection/hidden states, updating routes and tests accordingly. > > - **Navigation / Explore-Trending**: > - Replace custom Trending browser with existing `BrowserFlow`; remove `BrowserWrapper` and related screens. > - Introduce `ExploreHome` stack with `Routes.TRENDING_FEED`, `Routes.EXPLORE_SEARCH`, `Routes.SITES_FULL_VIEW`, and nested `Routes.BROWSER.HOME`. > - Keep `Routes.BROWSER.HOME` as a hidden tab when trending is enabled; Trending tab considers both `TRENDING_VIEW` and `BROWSER.HOME` as selected. > - Update `BrowserTab` back behavior to return to `TRENDING_VIEW -> TRENDING_FEED` when not launched from Trending. > - Add `Routes.TRENDING_FEED`. > - **TabBar**: > - Add `options.isSelected(rootScreenName)` and `options.isHidden` to `ExtendedBottomTabDescriptor`. > - Change Trending tab press to `navigation.reset({ routes: [{ name: Routes.TRENDING_VIEW }] })`. > - **Sites/Explore interactions**: > - `SiteRowItemWrapper` and `SitesSearchFooter` now navigate to `Routes.BROWSER.HOME` with `screen: Routes.BROWSER.VIEW` and `{ fromTrending: true, newTabUrl, timestamp }`. > - **Tests / Snapshots**: > - Update unit tests and snapshots for new navigation targets and removal of deprecated screens (`TrendingBrowser`, `BrowserWrapper`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ec056126204de16f99411bd68e0fa616ddf63aac. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Navigation/TabBar/TabBar.test.tsx | 5 +- .../components/Navigation/TabBar/TabBar.tsx | 13 +- .../Navigation/TabBar/TabBar.types.ts | 2 + app/components/Nav/Main/MainNavigator.js | 166 ++++++++++-------- .../__snapshots__/MainNavigator.test.tsx.snap | 21 --- .../SiteRowItemWrapper.test.tsx | 63 ++----- .../SiteRowItemWrapper/SiteRowItemWrapper.tsx | 18 +- .../SitesSearchFooter.test.tsx | 34 ++-- .../SitesSearchFooter/SitesSearchFooter.tsx | 12 +- .../Views/BrowserTab/BrowserTab.tsx | 4 +- .../Views/TrendingView/TrendingView.test.tsx | 25 ++- .../Views/TrendingView/TrendingView.tsx | 52 +----- .../BrowserWrapper/BrowserWrapper.tsx | 37 ---- app/constants/navigation/Routes.ts | 1 + 14 files changed, 193 insertions(+), 260 deletions(-) delete mode 100644 app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx diff --git a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx index 2680330faab8..ef2ef0e021e6 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx @@ -222,7 +222,10 @@ describe('TabBar', () => { ); fireEvent.press(getByTestId(`tab-bar-item-${TabBarIconKey.Trending}`)); - expect(navigation.navigate).toHaveBeenCalledWith(Routes.TRENDING_VIEW); + expect(navigation.reset).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: Routes.TRENDING_VIEW }], + }); }); it('does not navigate to trending when trending feature flag is disabled', () => { diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx index a620ca4ab10e..5f4e089180f4 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx @@ -47,7 +47,9 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { const callback = options.callback; const rootScreenName = options.rootScreenName; const key = `tab-bar-item-${tabBarIconKey}`; // this key is also used to identify elements for e2e testing - const isSelected = state.index === index; + const isSelected = options?.isSelected + ? options.isSelected(state.routeNames[state.index]) + : state.index === index; const icon = ICON_BY_TAB_BAR_ICON_KEY[tabBarIconKey]; const labelKey = LABEL_BY_TAB_BAR_ICON_KEY[tabBarIconKey]; const labelText = labelKey ? strings(labelKey) : ''; @@ -93,7 +95,10 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { break; case Routes.TRENDING_VIEW: if (isAssetsTrendingTokensEnabled) { - navigation.navigate(Routes.TRENDING_VIEW); + navigation.reset({ + index: 0, + routes: [{ name: Routes.TRENDING_VIEW }], + }); } break; } @@ -102,6 +107,10 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { const isWalletAction = rootScreenName === Routes.MODAL.TRADE_WALLET_ACTIONS; + if (options?.isHidden) { + return null; + } + return ( void; rootScreenName: string; + isSelected?: (rootScreenName: string) => boolean; + isHidden?: boolean; }; } diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 2ea2e0cb4cbe..894a30c27eb2 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -51,7 +51,8 @@ import { Confirm as RedesignedConfirm } from '../../Views/confirmations/componen import ContactForm from '../../Views/Settings/Contacts/ContactForm'; import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; -import TrendingView from '../../Views/TrendingView/TrendingView'; +import { ExploreFeed } from '../../Views/TrendingView/TrendingView'; +import ExploreSearchScreen from '../../Views/TrendingView/ExploreSearchScreen/ExploreSearchScreen'; import SwapsAmountView from '../../UI/Swaps'; import SwapsQuotesView from '../../UI/Swaps/QuotesView'; import CollectiblesDetails from '../../UI/CollectibleModal'; @@ -133,7 +134,6 @@ import { } from '../../Views/AddAsset/AddAsset.constants'; import { strings } from '../../../../locales/i18n'; import SitesFullView from '../../Views/SitesFullView/SitesFullView'; -import BrowserWrapper from '../../Views/TrendingView/components/BrowserWrapper/BrowserWrapper'; import BridgeView from '../../UI/Bridge/Views/BridgeView'; const Stack = createStackNavigator(); @@ -278,25 +278,6 @@ const RewardsHome = () => ( ); -// Persist the last trending screen across unmounts -export const lastTrendingScreenRef = { current: 'TrendingFeed' }; - -// Callback to update the last trending screen (outside component to persist) -export const updateLastTrendingScreen = (screenName) => { - // eslint-disable-next-line react-compiler/react-compiler - lastTrendingScreenRef.current = screenName; -}; - -const TrendingHome = () => ( - - - -); - /* eslint-disable react/prop-types */ const BrowserFlow = (props) => ( ( ); +const ExploreHome = () => ( + + + ({ + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + }, + }), + }} + /> + ({ + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + }, + }), + }} + /> + + {/* Trending Browser Stack (uses existing browser flow) */} + + +); + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) const SnapsSettingsStack = () => ( @@ -642,10 +680,12 @@ const HomeTabs = () => { } // Hide tab bar when browser is in fullscreen mode - if ( - isBrowserFullscreen && - currentRoute.name?.startsWith(Routes.BROWSER.HOME) - ) { + const currentStackRouteName = + currentRoute?.state?.routes?.[currentRoute?.state?.index]?.name; + const isInBrowser = + currentRoute.name?.startsWith(Routes.BROWSER.HOME) || + currentStackRouteName?.startsWith(Routes.BROWSER.HOME); + if (isBrowserFullscreen && isInBrowser) { return null; } @@ -669,21 +709,33 @@ const HomeTabs = () => { component={WalletTabModalFlow} /> {isAssetsTrendingTokensEnabled ? ( - UnmountOnBlurComponent(children)} - /> + <> + + [Routes.TRENDING_VIEW, Routes.BROWSER.HOME].includes( + rootScreenName, + ), + }} + component={ExploreHome} + layout={({ children }) => UnmountOnBlurComponent(children)} + /> + {children}} + /> + ) : ( null - : undefined, - }} + options={options.browser} component={BrowserFlow} layout={({ children }) => {children}} /> @@ -950,26 +1002,6 @@ const MainNavigator = () => { }} /> - ({ - cardStyle: { - transform: [ - { - translateX: current.progress.interpolate({ - inputRange: [0, 1], - outputRange: [layouts.screen.width, 0], - }), - }, - ], - }, - }), - }} - /> { }), }} /> - ({ - cardStyle: { - transform: [ - { - translateX: current.progress.interpolate({ - inputRange: [0, 1], - outputRange: [layouts.screen.width, 0], - }), - }, - ], - }, - }), - }} - /> + - - ({ @@ -167,6 +167,19 @@ describe('SiteRowItemWrapper', () => { }); }); + const assertBrowserNavigation = (siteUrl?: string) => { + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + ...(siteUrl ? { newTabUrl: siteUrl } : {}), + fromTrending: true, + }), + }), + ); + }; + describe('Navigation and Press Handling', () => { it('should call updateLastTrendingScreen when pressed', () => { const { getByTestId } = render( @@ -174,9 +187,6 @@ describe('SiteRowItemWrapper', () => { ); fireEvent.press(getByTestId('site-row-item')); - - expect(updateLastTrendingScreen).toHaveBeenCalledWith('TrendingBrowser'); - expect(updateLastTrendingScreen).toHaveBeenCalledTimes(1); }); it('should navigate to TrendingBrowser with correct params when pressed', () => { @@ -186,11 +196,7 @@ describe('SiteRowItemWrapper', () => { fireEvent.press(getByTestId('site-row-item')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'https://example.com', - timestamp: 1234567890, - fromTrending: true, - }); + assertBrowserNavigation('https://example.com'); expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); }); @@ -208,31 +214,7 @@ describe('SiteRowItemWrapper', () => { fireEvent.press(getByTestId('site-row-item')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'https://custom-url.com/page', - timestamp: 1234567890, - fromTrending: true, - }); - }); - - it('should update screen before navigating', () => { - const { getByTestId } = render( - , - ); - - const callOrder: string[] = []; - - (updateLastTrendingScreen as jest.Mock).mockImplementation(() => { - callOrder.push('update'); - }); - - (mockNavigation.navigate as jest.Mock).mockImplementation(() => { - callOrder.push('navigate'); - }); - - fireEvent.press(getByTestId('site-row-item')); - - expect(callOrder).toEqual(['update', 'navigate']); + assertBrowserNavigation('https://custom-url.com/page'); }); it('should handle multiple presses correctly', () => { @@ -245,8 +227,6 @@ describe('SiteRowItemWrapper', () => { fireEvent.press(siteRowItem); fireEvent.press(siteRowItem); fireEvent.press(siteRowItem); - - expect(updateLastTrendingScreen).toHaveBeenCalledTimes(3); expect(mockNavigation.navigate).toHaveBeenCalledTimes(3); }); @@ -257,10 +237,7 @@ describe('SiteRowItemWrapper', () => { fireEvent.press(getByTestId('site-row-item')); - expect(mockNavigation.navigate).toHaveBeenCalledWith( - 'TrendingBrowser', - expect.objectContaining({ fromTrending: true }), - ); + assertBrowserNavigation(); }); }); @@ -282,11 +259,7 @@ describe('SiteRowItemWrapper', () => { fireEvent.press(getByTestId('site-row-item')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'https://minimal.com', - timestamp: 1234567890, - fromTrending: true, - }); + assertBrowserNavigation('https://minimal.com'); }); }); }); diff --git a/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx index 38ce335a0573..9d7fe97eb8a6 100644 --- a/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx +++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx @@ -1,8 +1,7 @@ import React from 'react'; import type { NavigationProp, ParamListBase } from '@react-navigation/native'; import SiteRowItem, { type SiteData } from '../SiteRowItem/SiteRowItem'; -import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator'; - +import Routes from '../../../../../constants/navigation/Routes'; interface SiteRowItemWrapperProps { site: SiteData; navigation: NavigationProp; @@ -13,14 +12,13 @@ const SiteRowItemWrapper: React.FC = ({ navigation, }) => { const handlePress = () => { - // Update last trending screen state - updateLastTrendingScreen('TrendingBrowser'); - - // Navigate to TrendingBrowser (within TrendingView stack) - navigation.navigate('TrendingBrowser', { - newTabUrl: site.url, - timestamp: Date.now(), - fromTrending: true, + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: site.url, + timestamp: Date.now(), + fromTrending: true, + }, }); }; diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx index 434ffbc62ade..dd18518e3d7e 100644 --- a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.test.tsx @@ -4,6 +4,7 @@ import { useNavigation } from '@react-navigation/native'; // eslint-disable-next-line no-duplicate-imports import type { NavigationProp, ParamListBase } from '@react-navigation/native'; import SitesSearchFooter from './SitesSearchFooter'; +import Routes from '../../../../../constants/navigation/Routes'; // Mock dependencies jest.mock('@react-navigation/native', () => ({ @@ -130,6 +131,19 @@ describe('SitesSearchFooter', () => { }); describe('navigation', () => { + const assertBrowserNavigation = (siteUrl?: string) => { + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + ...(siteUrl ? { newTabUrl: siteUrl } : {}), + fromTrending: true, + }), + }), + ); + }; + it('navigates to URL when URL link is pressed', () => { const { getByTestId } = render( , @@ -137,11 +151,7 @@ describe('SitesSearchFooter', () => { fireEvent.press(getByTestId('trending-search-footer-url-link')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'metamask.io', - timestamp: 1234567890, - fromTrending: true, - }); + assertBrowserNavigation('metamask.io'); expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); }); @@ -152,11 +162,7 @@ describe('SitesSearchFooter', () => { fireEvent.press(getByTestId('trending-search-footer-google-link')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'https://www.google.com/search?q=ethereum', - timestamp: 1234567890, - fromTrending: true, - }); + assertBrowserNavigation('https://www.google.com/search?q=ethereum'); expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); }); @@ -167,11 +173,9 @@ describe('SitesSearchFooter', () => { fireEvent.press(getByTestId('trending-search-footer-google-link')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('TrendingBrowser', { - newTabUrl: 'https://www.google.com/search?q=ethereum%20%26%20bitcoin', - timestamp: 1234567890, - fromTrending: true, - }); + assertBrowserNavigation( + 'https://www.google.com/search?q=ethereum%20%26%20bitcoin', + ); }); }); diff --git a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx index d773c31e4c48..0807b7d7e561 100644 --- a/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx +++ b/app/components/UI/Sites/components/SitesSearchFooter/SitesSearchFooter.tsx @@ -14,6 +14,7 @@ import { ParamListBase, useNavigation, } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; export interface SitesSearchFooterProps { searchQuery: string; @@ -34,10 +35,13 @@ const SitesSearchFooter: React.FC = ({ const onPressLink = useCallback( (url: string) => { - navigation.navigate('TrendingBrowser', { - newTabUrl: url, - timestamp: Date.now(), - fromTrending: true, + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: url, + timestamp: Date.now(), + fromTrending: true, + }, }); }, [navigation], diff --git a/app/components/Views/BrowserTab/BrowserTab.tsx b/app/components/Views/BrowserTab/BrowserTab.tsx index ea1befdb373d..b70c77e0bcef 100644 --- a/app/components/Views/BrowserTab/BrowserTab.tsx +++ b/app/components/Views/BrowserTab/BrowserTab.tsx @@ -1320,7 +1320,9 @@ export const BrowserTab: React.FC = React.memo( navigation.goBack(); } else { // By default go to trending - navigation.navigate('TrendingFeed'); + navigation.navigate(Routes.TRENDING_VIEW, { + screen: Routes.TRENDING_FEED, + }); } }, [navigation, fromTrending]); diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index 7146ddb1bbe7..f9bf637a5e16 100644 --- a/app/components/Views/TrendingView/TrendingView.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; +import { createStackNavigator } from '@react-navigation/stack'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -26,7 +27,7 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -import TrendingView from './TrendingView'; +import { ExploreFeed } from './TrendingView'; import { selectChainId, selectPopularNetworkConfigurationsByCaipChainId, @@ -39,6 +40,19 @@ import { selectMultichainAccountsState2Enabled } from '../../../selectors/featur import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; import { useSelector } from 'react-redux'; +import Routes from '../../../constants/navigation/Routes'; + +const Stack = createStackNavigator(); + +const TrendingView: React.FC = () => ( + + + +); jest.mock('../../../components/hooks/useMetrics', () => ({ useMetrics: () => ({ @@ -248,10 +262,13 @@ describe('TrendingView', () => { fireEvent.press(browserButton); expect(mockNavigate).toHaveBeenCalledWith( - 'TrendingBrowser', + Routes.BROWSER.HOME, expect.objectContaining({ - newTabUrl: expect.stringContaining('?metamaskEntry=mobile'), - fromTrending: true, + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: expect.stringContaining('?metamaskEntry=mobile'), + fromTrending: true, + }), }), ); }); diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index c24d1a2c7b45..b0dcea43d9b1 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; -import { createStackNavigator } from '@react-navigation/stack'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { @@ -18,11 +17,6 @@ import AppConstants from '../../../core/AppConstants'; import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl'; import { useTheme } from '../../../util/theme'; import Routes from '../../../constants/navigation/Routes'; -import { - lastTrendingScreenRef, - updateLastTrendingScreen, -} from '../../Nav/Main/MainNavigator'; -import ExploreSearchScreen from './ExploreSearchScreen/ExploreSearchScreen'; import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar'; import QuickActions from './components/QuickActions/QuickActions'; import SectionHeader from './components/SectionHeader/SectionHeader'; @@ -30,9 +24,7 @@ import { HOME_SECTIONS_ARRAY, SectionId } from './config/sections.config'; import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; import BasicFunctionalityEmptyState from './components/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState'; -const Stack = createStackNavigator(); - -const TrendingFeed: React.FC = () => { +export const ExploreFeed: React.FC = () => { const tw = useTailwind(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -44,15 +36,6 @@ const TrendingFeed: React.FC = () => { // Track which sections have empty data const [emptySections, setEmptySections] = useState>(new Set()); - // Update state when returning to TrendingFeed - useEffect(() => { - const unsubscribe = navigation.addListener('focus', () => { - updateLastTrendingScreen('TrendingFeed'); - }); - - return unsubscribe; - }, [navigation]); - const portfolioUrl = buildPortfolioUrlWithMetrics(AppConstants.PORTFOLIO.URL); const browserTabsCount = useSelector( @@ -80,11 +63,13 @@ const TrendingFeed: React.FC = () => { return callbacks; }, []); const handleBrowserPress = useCallback(() => { - updateLastTrendingScreen('TrendingBrowser'); - navigation.navigate('TrendingBrowser', { - newTabUrl: portfolioUrl.href, - timestamp: Date.now(), - fromTrending: true, + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: portfolioUrl.href, + timestamp: Date.now(), + fromTrending: true, + }, }); }, [navigation, portfolioUrl.href]); @@ -185,24 +170,3 @@ const TrendingFeed: React.FC = () => { ); }; - -const TrendingView: React.FC = () => { - const initialRoot = lastTrendingScreenRef.current || 'TrendingFeed'; - - return ( - - - - - ); -}; - -export default TrendingView; diff --git a/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx b/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx deleted file mode 100644 index ab877cef7edf..000000000000 --- a/app/components/Views/TrendingView/components/BrowserWrapper/BrowserWrapper.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useMemo } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Browser from '../../../Browser'; -import Routes from '../../../../../constants/navigation/Routes'; - -// Wrapper component to intercept navigation -const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => { - const navigation = useNavigation(); - const tw = useTailwind(); - - // Create a custom navigation object that intercepts navigate calls - const customNavigation = useMemo(() => { - const originalNavigate = navigation.navigate.bind(navigation); - - return { - ...navigation, - navigate: (routeName: string, params?: object) => { - // If trying to navigate to TRENDING_VIEW, go back in stack instead - if (routeName === Routes.TRENDING_VIEW) { - navigation.goBack(); - } else { - originalNavigate(routeName, params); - } - }, - }; - }, [navigation]); - - return ( - - - - ); -}; - -export default BrowserWrapper; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 617f5677be7c..1c0c1b677bfc 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -79,6 +79,7 @@ const Routes = { REWARDS_SETTINGS_VIEW: 'RewardsSettingsView', REWARDS_DASHBOARD: 'RewardsDashboard', TRENDING_VIEW: 'TrendingView', + TRENDING_FEED: 'TrendingFeed', SITES_FULL_VIEW: 'SitesFullView', EXPLORE_SEARCH: 'ExploreSearch', REWARDS_ONBOARDING_FLOW: 'RewardsOnboardingFlow', From d9d239cc30ae930e02de40913912df242a280e75 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:14:38 +0800 Subject: [PATCH 2/2] perf(perps): hip3 connection optimization (#23747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Optimize HyperLiquid/HIP-3 connection initialization to reduce API calls and improve Perps loading time. **Problem:** - Perps initialization made 19+ redundant API calls during HIP-3 connection setup - Race conditions caused duplicate `metaAndAssetCtxs()` and `perpDexs()` calls - No cache sharing between Provider and SubscriptionService **Solution:** - Replace separate `meta()` calls with combined `metaAndAssetCtxs()` in `buildAssetMapping` (-5 calls) - Add promise deduplication in `ensureReady()` and `getValidatedDexs()` to prevent concurrent duplicate calls - Implement cache sharing between Provider and SubscriptionService via `setDexMetaCache()`, `setDexAssetCtxsCache()` **Result:** API calls reduced from **19 → 15** during core initialization ### Core Init (All Entry Points) - 15 Calls | Category | Calls | Details | |----------|-------|---------| | DEX Discovery | 1 | `perpDexs` | | Metadata | 5 | `metaAndAssetCtxs` × 5 DEXes | | User Setup | 4 | `referral` × 2, `maxBuilderFee`, `userDexAbstraction` | | Prices | 5 | `allMids` × 5 DEXes | | **Total** | **15** | | ### View-Specific: PerpsHomeView Only - 2 Additional Calls | Category | Calls | Details | |----------|-------|---------| | Activity | 2 | `userFills`, `userNonFundingLedgerUpdates` | ### Total API Calls by Entry Point | Entry Point | Core Init | View-Specific | Total | |-------------|-----------|---------------|-------| | PerpsTabView (wallet tab) | 15 | 0 | **15** | | PerpsHomeView (full screen) | 15 | 2 | **17** | ## **Changelog** CHANGELOG entry: null ## **Related issues** - https://consensyssoftware.atlassian.net/browse/TAT-2211 ## **Manual testing steps** ```gherkin Feature: Perps HIP-3 Loading Performance Scenario: Faster initialization with fewer API calls Given user has MetaMask mobile app with Perps feature enabled When user navigates to Perps tab Then Perps markets should load successfully And network console should show ~15 API calls (not 19+) And HIP-3 markets (stocks like TSLA, NVDA) should appear alongside crypto ``` ## **Screenshots/Recordings** N/A - Performance optimization, no UI changes ### **Before** N/A ### **After** N/A ## **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. --- .../providers/HyperLiquidProvider.test.ts | 84 +++- .../providers/HyperLiquidProvider.ts | 190 +++++++-- .../HyperLiquidSubscriptionService.test.ts | 55 +-- .../HyperLiquidSubscriptionService.ts | 146 ++++--- docs/perps/hyperliquid/init-flow.md | 372 ++++++++++++++++++ docs/perps/hyperliquid/subscriptions.md | 117 ++++-- 6 files changed, 800 insertions(+), 164 deletions(-) create mode 100644 docs/perps/hyperliquid/init-flow.md diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index 9ccd96933c7d..426c03ec4d21 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -183,6 +183,34 @@ const createMockInfoClient = (overrides: Record = {}) => ({ { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, ], }), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + ], + }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + { + funding: '0.0001', + openInterest: '500', + prevDayPx: '2900', + dayNtlVlm: '500000', + markPx: '3000', + midPx: '3000', + oraclePx: '3000', + }, + ], + ]), perpDexs: jest.fn().mockResolvedValue([null]), allMids: jest.fn().mockResolvedValue({ BTC: '50000', ETH: '3000' }), frontendOpenOrders: jest.fn().mockResolvedValue([]), @@ -324,6 +352,10 @@ describe('HyperLiquidProvider', () => { isPositionsCacheInitialized: jest.fn().mockReturnValue(false), getCachedPositions: jest.fn().mockReturnValue([]), updateFeatureFlags: jest.fn().mockResolvedValue(undefined), + // Cache methods used by buildAssetMapping optimization + setDexMetaCache: jest.fn(), + setDexAssetCtxsCache: jest.fn(), + getDexAssetCtxsCache: jest.fn().mockReturnValue(undefined), } as Partial as jest.Mocked; // Mock constructors @@ -2764,6 +2796,9 @@ describe('HyperLiquidProvider', () => { mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ meta: jest.fn().mockRejectedValue(new Error('Network timeout')), + metaAndAssetCtxs: jest + .fn() + .mockRejectedValue(new Error('Network timeout')), }), ); @@ -2956,6 +2991,11 @@ describe('HyperLiquidProvider', () => { describe('updatePositionTPSL error scenarios', () => { it('should handle WebSocket error in getPositions', async () => { + // Set up mock BEFORE creating fresh provider (provider calls metaAndAssetCtxs on init) + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + // Create a fresh provider to test WebSocket errors const freshProvider = new HyperLiquidProvider(); @@ -2966,10 +3006,6 @@ describe('HyperLiquidProvider', () => { throw new Error('WebSocket connection failed'); }); - MockedHyperLiquidClientService.mockImplementation( - () => mockClientService, - ); - const updateParams = { coin: 'BTC', takeProfitPrice: '55000', @@ -2982,6 +3018,11 @@ describe('HyperLiquidProvider', () => { }); it('should handle non-WebSocket error in getPositions', async () => { + // Set up mock BEFORE creating fresh provider (provider calls metaAndAssetCtxs on init) + MockedHyperLiquidClientService.mockImplementation( + () => mockClientService, + ); + // Create a fresh provider to test non-WebSocket errors const freshProvider = new HyperLiquidProvider(); @@ -2992,10 +3033,6 @@ describe('HyperLiquidProvider', () => { throw new Error('Generic API error'); }); - MockedHyperLiquidClientService.mockImplementation( - () => mockClientService, - ); - const updateParams = { coin: 'BTC', takeProfitPrice: '55000', @@ -3110,6 +3147,7 @@ describe('HyperLiquidProvider', () => { }); it('should handle missing allMids', async () => { + // Set up mock BEFORE creating fresh provider mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ meta: jest.fn().mockResolvedValue({ @@ -3117,17 +3155,34 @@ describe('HyperLiquidProvider', () => { }), allMids: jest.fn().mockResolvedValue(null), predictedFundings: jest.fn().mockResolvedValue([]), - metaAndAssetCtxs: jest.fn().mockResolvedValue([null, []]), + metaAndAssetCtxs: jest.fn().mockResolvedValue([ + { universe: [{ name: 'BTC', szDecimals: 3, maxLeverage: 50 }] }, + [ + { + funding: '0.0001', + openInterest: '1000', + prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', + }, + ], + ]), }), ); + // Create fresh provider to avoid cached state from other tests + const freshProvider = new HyperLiquidProvider(); + // Should gracefully handle missing price data with fallback - const result = await provider.getMarketDataWithPrices(); + const result = await freshProvider.getMarketDataWithPrices(); expect(Array.isArray(result)).toBe(true); expect(result[0].price).toBe('$---'); // Fallback when allMids is null }); it('should handle meta and predictedFundings calls successfully', async () => { + // Set up mock BEFORE creating fresh provider mockClientService.getInfoClient = jest.fn().mockReturnValue( createMockInfoClient({ meta: jest.fn().mockResolvedValue({ @@ -3142,13 +3197,20 @@ describe('HyperLiquidProvider', () => { funding: '0.001', openInterest: '1000000', prevDayPx: '49000', + dayNtlVlm: '1000000', + markPx: '50000', + midPx: '50000', + oraclePx: '50000', }, ], ]), }), ); - const result = await provider.getMarketDataWithPrices(); + // Create fresh provider to avoid cached state from other tests + const freshProvider = new HyperLiquidProvider(); + + const result = await freshProvider.getMarketDataWithPrices(); // Verify successful call with proper data structure expect(Array.isArray(result)).toBe(true); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 4972b22f7de7..8fc72ca402d3 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -269,6 +269,8 @@ export class HyperLiquidProvider implements IPerpsProvider { private cachedAllPerpDexs: Awaited< ReturnType['perpDexs']> > | null = null; + // Pending promise to deduplicate concurrent getValidatedDexs() calls + private pendingValidatedDexsPromise: Promise<(string | null)[]> | null = null; // Cache for USDC token ID from spot metadata private cachedUsdcTokenId?: string; @@ -420,12 +422,15 @@ export class HyperLiquidProvider implements IPerpsProvider { * since HIP-3 configuration is immutable after construction */ private async ensureReady(): Promise { - // If already initializing, wait for that to complete + // If already initializing or completed, wait for/return that promise // This prevents duplicate initialization flows when multiple methods called concurrently if (this.ensureReadyPromise) { + DevLogger.log('[ensureReady] Reusing existing initialization promise'); return this.ensureReadyPromise; } + DevLogger.log('[ensureReady] Starting new initialization'); + // Create and track initialization promise this.ensureReadyPromise = (async () => { // Lazy initialization: ensure clients are created (safe after Engine.context is ready) @@ -466,12 +471,10 @@ export class HyperLiquidProvider implements IPerpsProvider { await this.ensureReferralSet(); })(); - try { - await this.ensureReadyPromise; - } finally { - // Clean up tracking after completion - this.ensureReadyPromise = null; - } + // Await initialization - keep the promise so subsequent calls resolve immediately + // The promise is only reset in disconnect() for clean reconnection + await this.ensureReadyPromise; + DevLogger.log('[ensureReady] Initialization complete'); } /** @@ -539,6 +542,32 @@ export class HyperLiquidProvider implements IPerpsProvider { return this.cachedValidatedDexs; } + // If a fetch is already in progress, reuse the pending promise + // This prevents duplicate perpDexs() API calls from concurrent callers + if (this.pendingValidatedDexsPromise !== null) { + DevLogger.log( + '[getValidatedDexs] Reusing pending promise for perpDexs fetch', + ); + return this.pendingValidatedDexsPromise; + } + + // Create and cache the pending promise for deduplication + this.pendingValidatedDexsPromise = this.fetchValidatedDexsInternal(); + + try { + const result = await this.pendingValidatedDexsPromise; + return result; + } finally { + // Clear the pending promise when done (success or error) + this.pendingValidatedDexsPromise = null; + } + } + + /** + * Internal method that performs the actual perpDexs fetch and caching + * Separated from getValidatedDexs to enable promise deduplication + */ + private async fetchValidatedDexsInternal(): Promise<(string | null)[]> { // Kill switch: HIP-3 disabled, return main DEX only if (!this.hip3Enabled) { DevLogger.log('HyperLiquidProvider: HIP-3 disabled via hip3Enabled flag'); @@ -619,14 +648,16 @@ export class HyperLiquidProvider implements IPerpsProvider { skipCache?: boolean; }): Promise { const { dexName, skipCache } = params; - const dexKey = dexName || 'main'; + // Use empty string for main DEX key (consistent with buildAssetMapping cache population) + const dexKey = dexName ?? ''; + const dexDisplayName = dexKey || 'main'; // Skip cache if requested (forces fresh fetch) if (!skipCache) { const cached = this.cachedMetaByDex.get(dexKey); if (cached) { DevLogger.log('[getCachedMeta] Using cached meta response', { - dex: dexKey, + dex: dexDisplayName, universeSize: cached.universe.length, }); return cached; @@ -635,14 +666,12 @@ export class HyperLiquidProvider implements IPerpsProvider { // Cache miss or skipCache=true - fetch from API const infoClient = this.clientService.getInfoClient(); - const meta = await infoClient.meta({ dex: dexName ?? '' }); + const meta = await infoClient.meta({ dex: dexKey }); // Defensive validation before caching if (!meta?.universe || !Array.isArray(meta.universe)) { throw new Error( - `[HyperLiquidProvider] Invalid meta response for DEX ${ - dexName || 'main' - }: universe is ${meta?.universe ? 'not an array' : 'missing'}`, + `[HyperLiquidProvider] Invalid meta response for DEX ${dexDisplayName}: universe is ${meta?.universe ? 'not an array' : 'missing'}`, ); } @@ -650,7 +679,7 @@ export class HyperLiquidProvider implements IPerpsProvider { this.cachedMetaByDex.set(dexKey, meta); DevLogger.log('[getCachedMeta] Fetched and cached meta response', { - dex: dexKey, + dex: dexDisplayName, universeSize: meta.universe.length, skipCache, }); @@ -1248,21 +1277,55 @@ export class HyperLiquidProvider implements IPerpsProvider { this.blocklistMarkets, ); - // Fetch metadata for each DEX in parallel with skipCache (feature flags changed, need fresh data) + // Fetch metadata for each DEX in parallel using metaAndAssetCtxs + // Optimization: Check cache first - getMarketDataWithPrices may have already fetched + // If not cached, fetch via metaAndAssetCtxs and populate cache for other methods + const infoClient = this.clientService.getInfoClient(); const allMetas = await Promise.allSettled( - dexsToMap.map((dex) => - this.getCachedMeta({ dexName: dex, skipCache: true }) - .then((meta) => ({ dex, meta, success: true as const })) + dexsToMap.map((dex) => { + const dexKey = dex ?? ''; + + // Check if already cached (e.g., by getMarketDataWithPrices running in parallel) + const cachedMeta = this.cachedMetaByDex.get(dexKey); + if (cachedMeta) { + DevLogger.log( + `[buildAssetMapping] Using cached meta for ${dex || 'main'}`, + { universeSize: cachedMeta.universe.length }, + ); + return Promise.resolve({ + dex, + meta: cachedMeta, + success: true as const, + }); + } + + // Not cached, fetch and populate cache + const dexParam = dex || undefined; + return infoClient + .metaAndAssetCtxs(dexParam ? { dex: dexParam } : undefined) + .then((result) => { + const meta = result?.[0] || null; + const assetCtxs = result?.[1] || []; + // Cache meta for later use by getCachedMeta + if (meta?.universe) { + this.cachedMetaByDex.set(dexKey, meta); + // Also populate subscription service cache to avoid redundant API calls + this.subscriptionService.setDexMetaCache(dexKey, meta); + // Cache assetCtxs for getMarketDataWithPrices (avoids duplicate metaAndAssetCtxs calls) + this.subscriptionService.setDexAssetCtxsCache(dexKey, assetCtxs); + } + return { dex, meta, success: true as const }; + }) .catch((error) => { DevLogger.log( - `HyperLiquidProvider: Failed to fetch meta for DEX ${ + `HyperLiquidProvider: Failed to fetch metaAndAssetCtxs for DEX ${ dex || 'main' }`, { error }, ); return { dex, meta: null, success: false as const }; - }), - ), + }); + }), ); // Build mapping with DEX prefixes for HIP-3 DEXs using the utility function @@ -4322,16 +4385,9 @@ export class HyperLiquidProvider implements IPerpsProvider { */ async getMarkets(params?: GetMarketsParams): Promise { try { - // Read-only operation: only need client initialization - this.ensureClientsInitialized(); - this.clientService.ensureInitialized(); - - // CRITICAL: Build asset mapping on first call to ensure DEX discovery - // This must happen BEFORE any WebSocket subscriptions receive data - // Otherwise HIP-3 positions will be filtered out due to empty discoveredDexNames - if (this.coinToAssetId.size === 0) { - await this.buildAssetMapping(); - } + // Ensure full initialization including asset mapping + // This is deduplicated - concurrent calls wait for the same promise + await this.ensureReady(); // Path 1: Symbol filtering - group by DEX and fetch in parallel if (params?.symbols && params.symbols.length > 0) { @@ -4515,32 +4571,82 @@ export class HyperLiquidProvider implements IPerpsProvider { async getMarketDataWithPrices(): Promise { DevLogger.log('Getting market data with prices via HyperLiquid SDK'); - // Read-only operation: only need client initialization - this.ensureClientsInitialized(); - this.clientService.ensureInitialized(); + // Ensure asset mapping is built first (populates meta cache) + // This guarantees buildAssetMapping has run before we check cache, + // eliminating duplicate metaAndAssetCtxs API calls from race conditions + await this.ensureReady(); const infoClient = this.clientService.getInfoClient(); - // Get enabled DEXs respecting feature flags + // Get enabled DEXs respecting feature flags (uses cached perpDexs) const enabledDexs = await this.getValidatedDexs(); // Fetch meta, assetCtxs, and allMids for each enabled DEX in parallel + // Optimization: Check cache first to avoid redundant API calls when buildAssetMapping + // has already fetched, or populate cache for buildAssetMapping to reuse const dexDataResults = await Promise.all( enabledDexs.map(async (dex) => { + const dexKey = dex ?? ''; const dexParam = dex ?? ''; try { - const [meta, metaAndCtxs, dexAllMids] = await Promise.all([ - infoClient.meta(dexParam ? { dex: dexParam } : undefined), - infoClient.metaAndAssetCtxs( + let meta: MetaResponse | null = null; + let assetCtxs: PerpsAssetCtx[] = []; + + // Check if meta is already cached (e.g., from previous fetch or buildAssetMapping) + const cachedMeta = this.cachedMetaByDex.get(dexKey); + if (cachedMeta) { + DevLogger.log( + `[getMarketDataWithPrices] Using cached meta for ${dex || 'main'}`, + { universeSize: cachedMeta.universe.length }, + ); + meta = cachedMeta; + // Try to get cached assetCtxs from subscription service + const cachedCtxs = + this.subscriptionService.getDexAssetCtxsCache(dexKey); + if (cachedCtxs) { + assetCtxs = cachedCtxs; + } else { + // Need fresh assetCtxs, fetch via metaAndAssetCtxs (meta will be same) + const metaAndCtxs = await infoClient.metaAndAssetCtxs( + dexParam ? { dex: dexParam } : undefined, + ); + assetCtxs = metaAndCtxs?.[1] || []; + // Cache assetCtxs for future calls + this.subscriptionService.setDexAssetCtxsCache(dexKey, assetCtxs); + } + } else { + // Cache miss - fetch and populate cache for buildAssetMapping to reuse + DevLogger.log( + `[getMarketDataWithPrices] Cache miss for ${dex || 'main'}, fetching`, + ); + const metaAndCtxs = await infoClient.metaAndAssetCtxs( dexParam ? { dex: dexParam } : undefined, - ), - infoClient.allMids(dexParam ? { dex: dexParam } : undefined), - ]); + ); + meta = metaAndCtxs?.[0] || null; + assetCtxs = metaAndCtxs?.[1] || []; + + // IMPORTANT: Populate cache for buildAssetMapping and other methods to reuse + if (meta?.universe) { + this.cachedMetaByDex.set(dexKey, meta); + this.subscriptionService.setDexMetaCache(dexKey, meta); + // Also cache assetCtxs for consistency with buildAssetMapping + this.subscriptionService.setDexAssetCtxsCache(dexKey, assetCtxs); + DevLogger.log( + `[getMarketDataWithPrices] Cached meta for ${dex || 'main'}`, + { universeSize: meta.universe.length }, + ); + } + } + + // Always fetch fresh allMids for current prices + const dexAllMids = await infoClient.allMids( + dexParam ? { dex: dexParam } : undefined, + ); return { dex, meta, - assetCtxs: metaAndCtxs?.[1] || [], + assetCtxs, allMids: dexAllMids || {}, success: true, }; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts index 62d4c59b74fe..b885b936414e 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts @@ -2440,39 +2440,25 @@ describe('HyperLiquidSubscriptionService', () => { }); describe('Market Data Cache Initialization', () => { - it('caches funding rates from initial market data', async () => { + it('uses setDexMetaCache to pre-populate meta cache instead of API call', async () => { + // Test that setDexMetaCache can be used to pre-populate the cache + // This is how Provider shares cached meta with SubscriptionService + const mockMeta = { + universe: [ + { name: 'BTC', szDecimals: 3, maxLeverage: 50 }, + { name: 'ETH', szDecimals: 4, maxLeverage: 50 }, + { name: 'SOL', szDecimals: 2, maxLeverage: 20 }, + ], + }; + + // Pre-populate cache via setDexMetaCache (simulating what Provider does) + service.setDexMetaCache('', mockMeta); + const mockCallback = jest.fn(); const mockInfoClient = { - meta: jest.fn().mockResolvedValue({ - universe: [{ name: 'BTC' }, { name: 'ETH' }, { name: 'SOL' }], - }), - metaAndAssetCtxs: jest.fn().mockResolvedValue([ - {}, // meta object (first element) - [ - // assetCtxs array (second element) - { - funding: '0.0001', - prevDayPx: '49000', - openInterest: '1000000', - dayNtlVlm: '50000000', - oraclePx: '50100', - }, - { - funding: '0.0002', - prevDayPx: '2900', - openInterest: '500000', - dayNtlVlm: '10000000', - oraclePx: '3010', - }, - { - funding: '0.00015', - prevDayPx: '95', - openInterest: '200000', - dayNtlVlm: '5000000', - oraclePx: '98', - }, - ], - ]), + // These should NOT be called since cache is populated + meta: jest.fn().mockResolvedValue(mockMeta), + metaAndAssetCtxs: jest.fn().mockResolvedValue([mockMeta, []]), }; mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); @@ -2485,9 +2471,10 @@ describe('HyperLiquidSubscriptionService', () => { await new Promise((resolve) => setTimeout(resolve, 20)); - // Verify meta was called to cache funding rates - expect(mockInfoClient.meta).toHaveBeenCalled(); - expect(mockInfoClient.metaAndAssetCtxs).toHaveBeenCalled(); + // Verify that metaAndAssetCtxs was NOT called (cache was used) + // Note: meta() may still be called by createAssetCtxsSubscription fallback if cache miss, + // but with proper cache population, it should hit the cache + expect(mockInfoClient.metaAndAssetCtxs).not.toHaveBeenCalled(); unsubscribe(); }); diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 77f93acac060..cad148b8b8bb 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -127,6 +127,19 @@ export class HyperLiquidSubscriptionService { >(); // Per-DEX asset contexts private assetCtxsSubscriptionPromises = new Map>(); // Track in-progress subscriptions + // Meta cache per DEX - populated by metaAndAssetCtxs, used by createAssetCtxsSubscription + // This avoids redundant meta() API calls since metaAndAssetCtxs already returns meta data + private readonly dexMetaCache = new Map< + string, + { + universe: { + name: string; + szDecimals: number; + maxLeverage: number; + }[]; + } + >(); + // Order book data cache private readonly orderBookCache = new Map< string, @@ -213,6 +226,59 @@ export class HyperLiquidSubscriptionService { return this.enabledDexs.includes(dex); } + /** + * Populate DEX meta cache with pre-fetched meta data + * Called by Provider after buildAssetMapping to share cached meta, + * avoiding redundant metaAndAssetCtxs/meta API calls during subscription setup + * @param dex - DEX key ('' for main DEX, 'xyz'/'flx'/etc for HIP-3) + * @param meta - Meta response containing universe data + */ + public setDexMetaCache( + dex: string, + meta: { + universe: { + name: string; + szDecimals: number; + maxLeverage: number; + }[]; + }, + ): void { + this.dexMetaCache.set(dex, meta); + DevLogger.log('[SubscriptionService] DEX meta cache populated', { + dex: dex || 'main', + universeSize: meta.universe.length, + }); + } + + /** + * Cache asset contexts for a specific DEX from API response + * This allows buildAssetMapping() to populate cache for getMarketDataWithPrices() to use + * @param dex - DEX name ('' for main perps) + * @param assetCtxs - Asset contexts from metaAndAssetCtxs response + */ + public setDexAssetCtxsCache( + dex: string, + assetCtxs: WsAssetCtxsEvent['ctxs'], + ): void { + this.dexAssetCtxsCache.set(dex, assetCtxs); + DevLogger.log('[SubscriptionService] DEX assetCtxs cache populated', { + dex: dex || 'main', + ctxsCount: assetCtxs.length, + }); + } + + /** + * Get cached assetCtxs for a DEX + * Returns the cached asset contexts from WebSocket subscription if available + * @param dex - DEX key ('' for main DEX, 'xyz'/'flx'/etc for HIP-3) + * @returns Array of asset contexts or undefined if not cached + */ + public getDexAssetCtxsCache( + dex: string, + ): WsAssetCtxsEvent['ctxs'] | undefined { + return this.dexAssetCtxsCache.get(dex); + } + /** * Update feature flags for HIP-3 support * Called when provider configuration changes at runtime @@ -590,45 +656,10 @@ export class HyperLiquidSubscriptionService { }); } - // Cache funding rates from initial market data fetch if available (legacy fallback) - if (includeMarketData) { - // Get initial market data to cache funding rates - try { - // Get the provider through the clientService instead of Engine directly - const infoClient = this.clientService.getInfoClient(); - const [perpsMeta, assetCtxs] = await Promise.all([ - infoClient.meta(), - infoClient.metaAndAssetCtxs(), - ]); - - if (perpsMeta?.universe && assetCtxs?.[1]) { - // Cache funding rates directly from assetCtxs and meta - perpsMeta.universe.forEach((asset, index) => { - const assetCtx = assetCtxs[1][index]; - if (assetCtx && 'funding' in assetCtx) { - const existing = this.marketDataCache.get(asset.name) || { - lastUpdated: 0, - }; - this.marketDataCache.set(asset.name, { - ...existing, - funding: parseFloat(assetCtx.funding), - lastUpdated: Date.now(), - }); - } - }); - - DevLogger.log('Cached funding rates from initial market data:', { - cachedCount: perpsMeta.universe.filter((_asset, index) => { - const assetCtx = assetCtxs[1][index]; - return assetCtx && 'funding' in assetCtx; - }).length, - totalMarkets: perpsMeta.universe.length, - }); - } - } catch (error) { - DevLogger.log('Failed to cache initial funding rates:', error); - } - } + // Note: Funding rates are now cached via assetCtxs WebSocket subscription + // (ensureAssetCtxsSubscription above), eliminating the need for a separate + // metaAndAssetCtxs API call here. The WebSocket callback in createAssetCtxsSubscription + // populates marketDataCache with funding rates as they arrive. symbols.forEach((symbol) => { // Subscribe to activeAssetCtx only when market data is requested @@ -1655,7 +1686,8 @@ export class HyperLiquidSubscriptionService { * Create assetCtxs subscription for specific DEX * Provides real-time market data for all assets on the DEX * - * Performance: Fetches meta() ONCE during setup to avoid REST API spam on every WebSocket update + * Performance: Uses cached meta from dexMetaCache (populated by metaAndAssetCtxs) + * to avoid redundant meta() API calls during subscription setup */ private async createAssetCtxsSubscription(dex: string): Promise { this.clientService.ensureSubscriptionClient( @@ -1668,25 +1700,35 @@ export class HyperLiquidSubscriptionService { } const dexKey = dex || ''; - - // Fetch meta ONCE during setup to cache symbol mapping - // This prevents REST API call on every WebSocket update (critical performance fix) - const infoClient = this.clientService.getInfoClient(); - const perpsMeta = await infoClient.meta({ dex: dex || undefined }); - const dexIdentifier = dex ?? 'main DEX'; + // Check cache first - populated by metaAndAssetCtxs in ensureAssetCtxsSubscription + let perpsMeta = this.dexMetaCache.get(dexKey); + + if (!perpsMeta) { + // Fallback: fetch meta if not in cache (shouldn't happen in normal flow) + DevLogger.log(`Meta cache miss for ${dexIdentifier}, fetching from API`); + const infoClient = this.clientService.getInfoClient(); + const fetchedMeta = await infoClient.meta({ dex: dex || undefined }); + if (fetchedMeta?.universe) { + perpsMeta = fetchedMeta; + this.dexMetaCache.set(dexKey, fetchedMeta); + } + } + if (!perpsMeta?.universe) { const errorMessage = `No universe data available for ${dexIdentifier}`; throw new Error(errorMessage); } - const metaLogMessage = `Cached meta for ${dexIdentifier}`; - DevLogger.log(metaLogMessage, { - dex, - universeCount: perpsMeta.universe.length, - firstAssetSample: perpsMeta.universe[0]?.name, - }); + DevLogger.log( + `Using ${this.dexMetaCache.has(dexKey) ? 'cached' : 'fetched'} meta for ${dexIdentifier}`, + { + dex, + universeCount: perpsMeta.universe.length, + firstAssetSample: perpsMeta.universe[0]?.name, + }, + ); return new Promise((resolve, reject) => { const subscriptionParams = dex ? { dex } : {}; diff --git a/docs/perps/hyperliquid/init-flow.md b/docs/perps/hyperliquid/init-flow.md new file mode 100644 index 000000000000..54693c681314 --- /dev/null +++ b/docs/perps/hyperliquid/init-flow.md @@ -0,0 +1,372 @@ +# HyperLiquid Initialization Flow + +## Overview + +This document describes the API calls made during Perps initialization and the optimization strategies applied to minimize redundant network requests. + +## Entry Points + +Users can enter the Perps environment through different entry points, each with different API call patterns: + +### 1. PerpsTabView (Wallet Homepage Tab) + +**File**: `app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx` + +- Lightweight view showing open positions and orders +- Uses WebSocket streams only (after core init) +- **Total API calls: 15** (core init only) + +**Hooks used:** + +- `usePerpsLiveAccount()` - Account balance via WebSocket +- `usePerpsLivePositions()` - Positions via WebSocket +- `usePerpsLiveOrders()` - Orders via WebSocket + +### 2. PerpsHomeView (Full Perps Screen) + +**File**: `app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx` + +- Rich UI with markets, watchlist, and recent activity +- Adds REST API calls for historical activity data +- **Total API calls: 17** (core init + 2 view-specific) + +**Hooks used:** + +- `usePerpsLiveAccount()` - Account balance via WebSocket +- `usePerpsHomeData()` - Aggregator hook that includes: + - WebSocket streams for positions/orders + - REST API calls for historical activity (`userFills`, `userNonFundingLedgerUpdates`) + - Market data fetching + +## Initialization Sequence Diagram + +```mermaid +sequenceDiagram + participant UI as Perps Screen + participant CM as ConnectionManager + participant HLP as HyperLiquidProvider + participant SS as SubscriptionService + participant API as HyperLiquid API + participant WS as WebSocket + + UI->>CM: connect() + CM->>HLP: ensureReady() + + Note over HLP: Phase 1: Asset Mapping + HLP->>API: perpDexs() + API-->>HLP: [null, {name: "hyna"}, ...] + + par For each DEX (main + HIP-3) + HLP->>API: metaAndAssetCtxs(dex) + API-->>HLP: [meta, assetCtxs] + end + + Note over HLP: Build coinToAssetId mapping + + Note over HLP: Phase 2: User Setup + HLP->>API: referral (user) + HLP->>API: referral (builder) + HLP->>API: maxBuilderFee + HLP->>API: userDexAbstraction + + Note over CM: Phase 3: Market Data + CM->>HLP: getMarketDataWithPrices() + + par For each DEX + HLP->>API: allMids(dex) + API-->>HLP: { BTC: "50000", ... } + end + + Note over CM: Subscription Setup + CM->>SS: subscribeToPrices() + SS->>WS: subscribe allMids, assetCtxs + + rect rgb(255, 245, 238) + Note over UI,API: View-Specific (PerpsHomeView only) + UI->>HLP: getOrderFills() + HLP->>API: userFills + API-->>HLP: [fill1, fill2, ...] + UI->>HLP: getLedgerUpdates() + HLP->>API: userNonFundingLedgerUpdates + API-->>HLP: [update1, update2, ...] + end +``` + +## API Calls During Initialization + +### Core Init (All Entry Points) - 15 Calls + +These calls are made regardless of which entry point is used. + +#### Phase 1: Provider Initialization (`ensureReady`) + +| Call | Endpoint | Purpose | Count | +| ---- | ------------------ | ----------------------------------------- | ------------------ | +| 1 | `perpDexs` | Discover available HIP-3 DEXes | 1 | +| 2-6 | `metaAndAssetCtxs` | Get asset metadata + contexts for mapping | 5 (main + 4 HIP-3) | + +#### Phase 2: User Setup (first connection only) + +| Call | Endpoint | Purpose | Count | +| ---- | -------------------- | ------------------------------- | ----- | +| 7 | `referral` | Check user's referral status | 1 | +| 8 | `referral` | Check builder's referral status | 1 | +| 9 | `maxBuilderFee` | Get max builder fee for user | 1 | +| 10 | `userDexAbstraction` | Check DEX abstraction status | 1 | + +#### Phase 3: Market Data (`getMarketDataWithPrices`) + +| Call | Endpoint | Purpose | Count | +| ----- | --------- | ------------------------------------ | ------------------ | +| 11-15 | `allMids` | Get current mid prices for all DEXes | 5 (main + 4 HIP-3) | + +#### Core Init Summary + +| Category | Calls | Details | +| ------------- | ------ | ----------------------------------------------------- | +| DEX Discovery | 1 | `perpDexs` | +| Metadata | 5 | `metaAndAssetCtxs` × 5 DEXes | +| User Setup | 4 | `referral` × 2, `maxBuilderFee`, `userDexAbstraction` | +| Prices | 5 | `allMids` × 5 DEXes | +| **Total** | **15** | | + +### View-Specific: PerpsHomeView Only - 2 Additional Calls + +These calls are only made when entering via PerpsHomeView (full Perps screen), not from PerpsTabView. + +Called via `usePerpsHomeData()` hook for the Recent Activity section: + +| Call | Endpoint | Purpose | Count | +| ---- | ----------------------------- | -------------------------------------- | ----- | +| 16 | `userFills` | Historical trade fills for activity | 1 | +| 17 | `userNonFundingLedgerUpdates` | Ledger history (deposits, withdrawals) | 1 | + +### Total API Calls by Entry Point + +| Entry Point | Core Init | View-Specific | Total | +| ------------- | --------- | ------------- | ------ | +| PerpsTabView | 15 | 0 | **15** | +| PerpsHomeView | 15 | 2 | **17** | + +## Call Flow Detail + +```mermaid +flowchart TD + subgraph Phase1["Phase 1: Provider Initialization"] + A[ensureReady] --> B[getValidatedDexs] + B --> C[perpDexs API] + C --> D[buildAssetMapping] + D --> E["metaAndAssetCtxs × 5 DEXes"] + E --> F[Cache meta + assetCtxs] + F --> G[Build coinToAssetId map] + end + + subgraph Phase2["Phase 2: User Setup"] + H[ensureReferralSet] --> I["referral × 2"] + J[ensureBuilderFeeApproval] --> K[maxBuilderFee] + L[enableDexAbstraction] --> M[userDexAbstraction] + end + + subgraph Phase3["Phase 3: Market Data"] + N[getMarketDataWithPrices] --> O{Meta cached?} + O -->|Yes| P[Use cached meta + assetCtxs] + O -->|No| Q[metaAndAssetCtxs API] + P --> R["allMids × 5 DEXes"] + Q --> R + end + + Phase1 --> Phase2 + Phase2 --> Phase3 +``` + +## Optimization History + +### Before Optimization: 19+ API Calls + +The original initialization made redundant calls: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ REDUNDANT CALLS (before optimization) │ +├─────────────────────────────────────────────────────────────┤ +│ 1. meta() × 5 DEXes in buildAssetMapping ← REMOVED│ +│ 2. Duplicate metaAndAssetCtxs() from race conditions │ +│ - ensureReady() called multiple times ← FIXED │ +│ - getMarketDataWithPrices() before cache ready ← FIXED │ +│ 3. Duplicate perpDexs() calls ← CACHED │ +│ 4. assetCtxs not cached from buildAssetMapping ← FIXED │ +└─────────────────────────────────────────────────────────────┘ +``` + +### After Optimization: 15 API Calls + +| Optimization | Calls Saved | Implementation | +| ----------------------------------------------------------------- | ----------- | -------------------------------------------- | +| Replace `meta()` with `metaAndAssetCtxs()` in `buildAssetMapping` | 5 | `HyperLiquidProvider.ts:buildAssetMapping()` | +| Promise deduplication in `ensureReady()` | Variable | `ensureReadyPromise` singleton | +| Cache meta from `metaAndAssetCtxs` calls | Variable | `cachedMetaByDex` + `setDexMetaCache()` | +| Cache assetCtxs from `buildAssetMapping` | 4 | `setDexAssetCtxsCache()` | +| Cache `perpDexs` response | 1+ | `cachedAllPerpDexs` | + +## Data Flow: metaAndAssetCtxs Response + +The `metaAndAssetCtxs` endpoint returns both meta and asset contexts in a single call: + +```typescript +// Response structure +type MetaAndAssetCtxsResponse = [ + Meta, // [0] - Same as meta() response + AssetCtx[], // [1] - Asset contexts with prices, funding, etc. +]; + +// Meta contains: +interface Meta { + universe: Array<{ + name: string; // e.g., "BTC" + szDecimals: number; // Size decimals + maxLeverage: number; // Max leverage + // ... other fields + }>; +} + +// AssetCtx contains: +interface AssetCtx { + funding: string; // Current funding rate + openInterest: string; // Open interest in contracts + prevDayPx: string; // Previous day price + dayNtlVlm: string; // Daily notional volume + markPx: string; // Mark price + midPx?: string; // Mid price (optional) + oraclePx: string; // Oracle price +} +``` + +## Caching Strategy + +### Three-Level Cache Sharing + +The Provider and SubscriptionService share cached data to avoid redundant API calls: + +```mermaid +flowchart LR + subgraph Provider["HyperLiquidProvider"] + A[buildAssetMapping] --> B[metaAndAssetCtxs API] + B --> C[cachedMetaByDex] + end + + subgraph Service["SubscriptionService"] + D[dexMetaCache] + E[dexAssetCtxsCache] + F[createAssetCtxsSubscription] --> G{Cache hit?} + G -->|Yes| H[Use cached meta] + G -->|No| I[meta API fallback] + end + + C -->|setDexMetaCache| D + B -->|setDexAssetCtxsCache| E +``` + +### Level 1: Provider Meta Cache + +```typescript +// HyperLiquidProvider.ts +private cachedMetaByDex: Map = new Map(); + +// Populated during buildAssetMapping() via metaAndAssetCtxs +// Cache key: '' for main DEX, 'xyz'/'hyna'/etc for HIP-3 DEXes +// Used by getCachedMeta() for subsequent calls +``` + +### Level 2: Subscription Service DEX Meta Cache + +```typescript +// HyperLiquidSubscriptionService.ts +private dexMetaCache: Map = new Map(); + +// Populated via setDexMetaCache() from Provider during buildAssetMapping +// Used by createAssetCtxsSubscription() to avoid API calls +public setDexMetaCache(dex: string, meta: Meta): void { + this.dexMetaCache.set(dex, meta); +} +``` + +### Level 3: Subscription Service AssetCtxs Cache + +```typescript +// HyperLiquidSubscriptionService.ts +private dexAssetCtxsCache: Map = new Map(); + +// Populated via setDexAssetCtxsCache() from Provider during buildAssetMapping +// Used by getMarketDataWithPrices() to avoid duplicate metaAndAssetCtxs calls +public setDexAssetCtxsCache(dex: string, assetCtxs: AssetCtx[]): void { + this.dexAssetCtxsCache.set(dex, assetCtxs); +} +``` + +### Cache Key Convention + +**Important:** Both caches use empty string `''` for main DEX (not `'main'`): + +- Main DEX: key = `''` +- HIP-3 DEXes: key = dex name (e.g., `'xyz'`, `'hyna'`, `'flx'`, `'vntl'`) + +## WebSocket Subscriptions + +After initialization, the following WebSocket subscriptions are active: + +| Subscription | Channel | Purpose | +| ------------ | ------- | ------------------------------------ | +| `allMids` | Per DEX | Real-time mid price updates | +| `assetCtxs` | Per DEX | Funding rates, OI, mark prices | +| `webData3` | Main | User account data, positions, orders | + +## Troubleshooting + +### Identifying Redundant Calls + +Enable network logging to track API calls: + +```typescript +// In HyperLiquidClientService or similar +console.log(`API Call: ${type}`, { dex, timestamp: Date.now() }); +``` + +### Common Issues + +1. **Multiple `metaAndAssetCtxs()` calls**: Check if `ensureReady()` is being called before cache is ready +2. **Duplicate `perpDexs()` calls**: Ensure `cachedAllPerpDexs` is populated before subsequent calls +3. **Race conditions**: Ensure `ensureReadyPromise` is properly reused across concurrent calls +4. **Slow initialization**: Check if HIP-3 DEXes are enabled but failing (adds timeout delays) + +### Verifying Optimization + +Check network logs for expected call counts: + +**Core Init (all entry points):** + +- `perpDexs`: 1× +- `metaAndAssetCtxs`: 5× (one per DEX) +- `referral`: 2× (user + builder) +- `maxBuilderFee`: 1× +- `userDexAbstraction`: 1× +- `allMids`: 5× (one per DEX) +- **Subtotal**: 15× + +**View-Specific (PerpsHomeView only):** + +- `userFills`: 1× +- `userNonFundingLedgerUpdates`: 1× +- **Subtotal**: 2× + +**Expected Totals:** + +| Entry Point | Expected Calls | +| ------------- | -------------- | +| PerpsTabView | 15 | +| PerpsHomeView | 17 | + +## Related Documentation + +- [Perps Connection Architecture](../perps-connection-architecture.md) - Overall connection flow +- [HIP-3 Implementation](./HIP-3.md) - HIP-3 DEX support details +- [Subscriptions](./subscriptions.md) - WebSocket subscription formats diff --git a/docs/perps/hyperliquid/subscriptions.md b/docs/perps/hyperliquid/subscriptions.md index b122a2ed3803..884416bd53e1 100644 --- a/docs/perps/hyperliquid/subscriptions.md +++ b/docs/perps/hyperliquid/subscriptions.md @@ -23,49 +23,58 @@ The subscription object contains the details of the specific feed you want to su 2. `notification`: - Subscription message: `{ "type": "notification", "user": "
" }` - Data format: `Notification` -3. `webData2` - - Subscription message: `{ "type": "webData2", "user": "
" }` - - Data format: `WebData2` -4. `candle`: +3. `webData3` : + - Subscription message: `{ "type": "webData3", "user": "
" }` + - Data format: `WebData3` +4. `twapStates` : + - Subscription message: `{ "type": "twapStates", "user": "
" }` + - Data format: `TwapStates` +5. `clearinghouseState:` + - Subscription message: `{ "type": "clearinghouseState", "user": "
" }` + - Data format: `ClearinghouseState` +6. `openOrders`: + - Subscription message: `{ "type": "openOrders", "user": "
" }` + - Data format: `OpenOrders` +7. `candle`: - Subscription message: `{ "type": "candle", "coin": "", "interval": "" }` - Supported intervals: "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w", "1M" - Data format: `Candle[]` -5. `l2Book`: +8. `l2Book`: - Subscription message: `{ "type": "l2Book", "coin": "" }` - Optional parameters: nSigFigs: int, mantissa: int - Data format: `WsBook` -6. `trades`: +9. `trades`: - Subscription message: `{ "type": "trades", "coin": "" }` - Data format: `WsTrade[]` -7. `orderUpdates`: - - Subscription message: `{ "type": "orderUpdates", "user": "
" }` - - Data format: `WsOrder[]` -8. `userEvents`: - - Subscription message: `{ "type": "userEvents", "user": "
" }` - - Data format: `WsUserEvent` -9. `userFills`: - - Subscription message: `{ "type": "userFills", "user": "
" }` - - Optional parameter: `aggregateByTime: bool` - - Data format: `WsUserFills` -10. `userFundings`: +10. `orderUpdates`: + - Subscription message: `{ "type": "orderUpdates", "user": "
" }` + - Data format: `WsOrder[]` +11. `userEvents`: + - Subscription message: `{ "type": "userEvents", "user": "
" }` + - Data format: `WsUserEvent` +12. `userFills`: + - Subscription message: `{ "type": "userFills", "user": "
" }` + - Optional parameter: `aggregateByTime: bool` + - Data format: `WsUserFills` +13. `userFundings`: - Subscription message: `{ "type": "userFundings", "user": "
" }` - Data format: `WsUserFundings` -11. `userNonFundingLedgerUpdates`: +14. `userNonFundingLedgerUpdates`: - Subscription message: `{ "type": "userNonFundingLedgerUpdates", "user": "
" }` - Data format: `WsUserNonFundingLedgerUpdates` -12. `activeAssetCtx`: - - Subscription message: `{ "type": "activeAssetCtx", "coin": "coin_symbol>" }` +15. `activeAssetCtx`: + - Subscription message: `{ "type": "activeAssetCtx", "coin": "" }` - Data format: `WsActiveAssetCtx` or `WsActiveSpotAssetCtx` -13. `activeAssetData`: (only supports Perps) - - Subscription message: `{ "type": "activeAssetData", "user": "
", "coin": "coin_symbol>" }` +16. `activeAssetData`: (only supports Perps) + - Subscription message: `{ "type": "activeAssetData", "user": "
", "coin": "" }` - Data format: `WsActiveAssetData` -14. `userTwapSliceFills`: +17. `userTwapSliceFills`: - Subscription message: `{ "type": "userTwapSliceFills", "user": "
" }` - Data format: `WsUserTwapSliceFills` -15. `userTwapHistory`: +18. `userTwapHistory`: - Subscription message: `{ "type": "userTwapHistory", "user": "
" }` - Data format: `WsUserTwapHistory` -16. `bbo` : +19. `bbo` : - Subscription message: `{ "type": "bbo", "coin": "" }` - Data format: `WsBbo` @@ -303,6 +312,64 @@ interface WsUserTwapHistory { user: string; history: Array; } + +// Additional undocumented fields in WebData3 will be removed on a future update +interface WebData3 { + userState: { + agentAddress: string | null; + agentValidUntil: number | null; + serverTime: number; + cumLedger: number; + isVault: boolean; + user: string; + optOutOfSpotDusting?: boolean; + dexAbstractionEnabled?: boolean; + }; + perpDexStates: Array; +} + +interface PerpDexState { + totalVaultEquity: number; + perpsAtOpenInterestCap?: Array; + leadingVaults?: Array; +} + +interface LeadingVault { + address: string; + name: string; +} + +interface ClearinghouseState { + assetPositions: Array; + marginSummary: MarginSummary; + crossMarginSummary: MarginSummary; + crossMaintenanceMarginUsed: number; + withdrawable: number; +} + +interface MarginSummary { + accountValue: number; + totalNtlPos: number; + totalRawUsd: number; + totalMarginUsed: number; +} + +interface AssetPosition { + type: 'oneWay'; + position: Position; +} + +interface OpenOrders { + dex: string; + user: string; + orders: Array; +} + +interface TwapStates { + dex: string; + user: string; + states: Array<[number, TwapState]>; +} ```