diff --git a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx index 2680330faab..ef2ef0e021e 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 a620ca4ab10..5f4e089180f 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 2ea2e0cb4cb..894a30c27eb 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], - }), - }, - ], - }, - }), - }} - /> + - - = {}) => ({ { 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 4972b22f7de..8fc72ca402d 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 62d4c59b74f..b885b936414 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 77f93acac06..cad148b8b8b 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/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx index 90762de9a58..e9cdf6533a3 100644 --- a/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx +++ b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import SiteRowItemWrapper from './SiteRowItemWrapper'; -import { updateLastTrendingScreen } from '../../../../Nav/Main/MainNavigator'; import type { NavigationProp, ParamListBase } from '@react-navigation/native'; import type { SiteData } from '../SiteRowItem/SiteRowItem'; +import Routes from '../../../../../constants/navigation/Routes'; // Mock the dependencies jest.mock('../../../../Nav/Main/MainNavigator', () => ({ @@ -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 38ce335a057..9d7fe97eb8a 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 434ffbc62ad..dd18518e3d7 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 d773c31e4c4..0807b7d7e56 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 ea1befdb373..b70c77e0bce 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 7146ddb1bbe..f9bf637a5e1 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 c24d1a2c7b4..b0dcea43d9b 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 ab877cef7ed..00000000000 --- 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 617f5677be7..1c0c1b677bf 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', diff --git a/docs/perps/hyperliquid/init-flow.md b/docs/perps/hyperliquid/init-flow.md new file mode 100644 index 00000000000..54693c68131 --- /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 b122a2ed380..884416bd53e 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]>; +} ```