diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts index 11bba24d2350..18b3e2da349b 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.test.ts @@ -2616,7 +2616,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.updatePositionTPSL(updateParams); expect(result.success).toBe(false); - expect(result.error).toContain('Failed to fetch market metadata'); + expect(result.error).toContain('Invalid meta response'); }); it('should handle meta response without universe property', async () => { @@ -2634,7 +2634,7 @@ describe('HyperLiquidProvider', () => { const result = await provider.updatePositionTPSL(updateParams); expect(result.success).toBe(false); - expect(result.error).toContain('Failed to fetch market metadata'); + expect(result.error).toContain('Invalid meta response'); }); }); @@ -4266,7 +4266,8 @@ describe('HyperLiquidProvider', () => { expect(result.success).toBe(true); - // Verify builder fee approval was called + // Builder fee approval is set once during ensureReady() initialization + // With session caching, it should be called once (during first ensureReady) expect( mockClientService.getExchangeClient().approveBuilderFee, ).toHaveBeenCalledWith({ @@ -4274,12 +4275,18 @@ describe('HyperLiquidProvider', () => { maxFeeRate: expect.stringContaining('%'), }); - // Verify referral code was set - expect( - mockClientService.getExchangeClient().setReferrer, - ).toHaveBeenCalledWith({ - code: expect.any(String), - }); + // Note: Referral setup is fire-and-forget (non-blocking), so we can't reliably + // test it synchronously. It's tested separately in dedicated referral tests. + + // Place a second order to verify caching (should NOT call builder fee approval again) + const mockExchangeClient = mockClientService.getExchangeClient(); + (mockExchangeClient.approveBuilderFee as jest.Mock).mockClear(); + + const result2 = await provider.placeOrder(orderParams); + + expect(result2.success).toBe(true); + // Session cache prevents redundant builder fee approval calls + expect(mockExchangeClient.approveBuilderFee).not.toHaveBeenCalled(); // Verify order was placed with builder fee expect(mockClientService.getExchangeClient().order).toHaveBeenCalledWith( @@ -4450,7 +4457,7 @@ describe('HyperLiquidProvider', () => { expect(result.error).toContain('Builder fee approval failed'); }); - it('should handle referral code setup failure', async () => { + it('should handle referral code setup failure (non-blocking)', async () => { // Mock builder fee already approved mockClientService.getInfoClient = jest .fn() @@ -4483,8 +4490,9 @@ describe('HyperLiquidProvider', () => { const result = await provider.placeOrder(orderParams); - expect(result.success).toBe(false); - expect(result.error).toContain('Error ensuring referral code is set'); + // Referral setup is now non-blocking (fire-and-forget), so order should succeed + expect(result.success).toBe(true); + expect(result.orderId).toBeDefined(); }); it('should skip referral setup when referral code is not ready', async () => { @@ -4574,6 +4582,11 @@ describe('HyperLiquidProvider', () => { it('should properly transform getOrders with reduceOnly and isTrigger fields', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), historicalOrders: jest.fn().mockResolvedValue([ { order: { @@ -4675,6 +4688,11 @@ describe('HyperLiquidProvider', () => { it('should properly transform getOpenOrders with reduceOnly and isTrigger fields', async () => { mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), clearinghouseState: jest.fn().mockResolvedValue({ marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, withdrawable: '9500', @@ -4993,6 +5011,11 @@ describe('HyperLiquidProvider', () => { }, ]); mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), frontendOpenOrders: mockFrontendOpenOrders, clearinghouseState: jest.fn().mockResolvedValue({ marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, @@ -5073,6 +5096,11 @@ describe('HyperLiquidProvider', () => { ]); }); mockClientService.getInfoClient = jest.fn().mockReturnValue({ + maxBuilderFee: jest.fn().mockResolvedValue(1), + referral: jest.fn().mockResolvedValue({ + referrerState: { stage: 'ready', data: { code: 'MMCSI' } }, + referredBy: { code: 'MMCSI' }, + }), frontendOpenOrders: mockFrontendOpenOrders, clearinghouseState: jest.fn().mockResolvedValue({ marginSummary: { totalMarginUsed: '0', accountValue: '1000' }, diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index d5ee1baddc34..cddf760ba8b7 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -159,11 +159,17 @@ export class HyperLiquidProvider implements IPerpsProvider { { value: number; timestamp: number } >(); - // Cache for market data (meta() API responses) to reduce redundant calls - private marketCache = new Map< - string, // DEX name (empty string for main DEX) - { data: MarketInfo[]; timestamp: number } - >(); + // Cache for raw meta responses (shared across methods to avoid redundant API calls) + // Filtering is applied on-demand (cheap array operations) - no need for separate processed cache + private cachedMetaByDex = new Map(); + + // Session cache for referral state (cleared on disconnect/reconnect) + // Key: `network:userAddress`, Value: true if referral is set + private referralCheckCache = new Map(); + + // Session cache for builder fee approval state (cleared on disconnect/reconnect) + // Key: `network:userAddress`, Value: true if builder fee is approved + private builderFeeCheckCache = new Map(); // Pre-compiled patterns for fast filtering private compiledAllowlistPatterns: CompiledMarketPattern[] = []; @@ -351,6 +357,15 @@ export class HyperLiquidProvider implements IPerpsProvider { // Attempt to enable native balance abstraction await this.ensureDexAbstractionEnabled(); + + // Set up builder fee approval (blocking, required for trading) + // This happens once per session and is cached until disconnect/reconnect + await this.ensureBuilderFeeApproval(); + + // Set up referral code (blocks initialization to ensure attribution attempt) + // Non-throwing: errors caught internally, logged to Sentry, retry next session + // User can trade immediately after init even if referral setup failed + await this.ensureReferralSet(); } /** @@ -486,69 +501,85 @@ export class HyperLiquidProvider implements IPerpsProvider { } /** - * Clear market cache (called when feature flags change) - */ - private clearMarketCache(): void { - this.marketCache.clear(); - DevLogger.log('HyperLiquidProvider: Market cache cleared'); - } - - /** - * Check if cached market data is still valid (not expired) - * @param dex - DEX name (empty string for main DEX) - * @returns true if cache exists and is not expired + * Get cached meta response for a DEX, fetching from API if not cached + * This helper consolidates cache logic to avoid redundant API calls across the provider + * @param params.dexName - DEX name (null for main DEX) + * @param params.skipCache - If true, bypass cache and fetch fresh data + * @returns MetaResponse with universe data + * @throws Error if API returns invalid data */ - private isCachedMarketDataValid(dex: string): boolean { - const cached = this.marketCache.get(dex); - if (!cached) { - return false; + private async getCachedMeta(params: { + dexName: string | null; + skipCache?: boolean; + }): Promise { + const { dexName, skipCache } = params; + const dexKey = dexName || '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, + universeSize: cached.universe.length, + }); + return cached; + } } - const age = Date.now() - cached.timestamp; - const isValid = age < PERFORMANCE_CONFIG.MARKET_DATA_CACHE_DURATION_MS; + // Cache miss or skipCache=true - fetch from API + const infoClient = this.clientService.getInfoClient(); + const meta = await infoClient.meta({ dex: dexName ?? '' }); - if (!isValid) { - DevLogger.log('HyperLiquidProvider: Market cache expired', { - dex: dex || 'main', - ageMs: age, - ttlMs: PERFORMANCE_CONFIG.MARKET_DATA_CACHE_DURATION_MS, - }); + // 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'}`, + ); } - return isValid; + // Store raw meta response for reuse + this.cachedMetaByDex.set(dexKey, meta); + + DevLogger.log('[getCachedMeta] Fetched and cached meta response', { + dex: dexKey, + universeSize: meta.universe.length, + skipCache, + }); + + return meta; } /** - * Fetch markets for a specific DEX with caching - * @param dex - DEX name (null for main DEX) - * @param skipFilters - If true, skip market filtering (default: false) - * @returns Array of market info + * Generate session cache key for user-specific caches + * Format: "network:userAddress" (address normalized to lowercase) + * @param network - 'mainnet' or 'testnet' + * @param userAddress - User's Ethereum address + * @returns Cache key for session-based caches */ - private async fetchMarketsForDex( - dex: string | null, - skipFilters = false, - ): Promise { - // Cache key includes skipFilters flag to separate filtered/unfiltered data - const cacheKey = `${dex ?? ''}_${skipFilters ? 'raw' : 'filtered'}`; - - // Check cache first - if (this.isCachedMarketDataValid(cacheKey)) { - const cached = this.marketCache.get(cacheKey); - if (cached) { - DevLogger.log('HyperLiquidProvider: Using cached market data', { - dex: dex || 'main', - marketCount: cached.data.length, - }); - return cached.data; - } - } + private getCacheKey(network: string, userAddress: string): string { + return `${network}:${userAddress.toLowerCase()}`; + } - // Fetch from API - const infoClient = this.clientService.getInfoClient(); - const dexParam = dex ?? ''; - const meta = await infoClient.meta( - dexParam ? { dex: dexParam } : undefined, - ); + /** + * Fetch markets for a specific DEX with optional filtering + * Uses session-based caching via getCachedMeta() - no TTL, cleared on disconnect + * @param params.dex - DEX name (null for main DEX) + * @param params.skipFilters - If true, skip HIP-3 filtering (return all markets) + * @param params.skipCache - If true, bypass cache and fetch fresh data + * @returns Array of MarketInfo objects + */ + private async fetchMarketsForDex(params: { + dex: string | null; + skipFilters?: boolean; + skipCache?: boolean; + }): Promise { + const { dex, skipFilters = false, skipCache = false } = params; + + // Get raw meta response (uses session cache unless skipCache=true) + const meta = await this.getCachedMeta({ dexName: dex, skipCache }); if (!meta.universe || !Array.isArray(meta.universe)) { DevLogger.log( @@ -557,12 +588,14 @@ export class HyperLiquidProvider implements IPerpsProvider { return []; } + // Transform to MarketInfo format const markets = meta.universe.map((asset) => adaptMarketFromSDK(asset)); - // Apply market filtering for HIP-3 DEXs only (main DEX or skipFilters returns all markets) + // Apply HIP-3 filtering on-demand (cheap array operation) + // Skip filtering for main DEX (null) or if explicitly requested const filteredMarkets = skipFilters || dex === null - ? markets // Skip filtering if requested or for main DEX + ? markets : markets.filter((market) => shouldIncludeMarket( market.name, @@ -573,16 +606,11 @@ export class HyperLiquidProvider implements IPerpsProvider { ), ); - // Store filtered markets in cache - this.marketCache.set(cacheKey, { - data: filteredMarkets, - timestamp: Date.now(), - }); - - DevLogger.log('HyperLiquidProvider: Cached market data', { + DevLogger.log('HyperLiquidProvider: Fetched markets for DEX', { dex: dex || 'main', marketCount: filteredMarkets.length, - ttlMs: PERFORMANCE_CONFIG.MARKET_DATA_CACHE_DURATION_MS, + skipFilters, + skipCache, }); return filteredMarkets; @@ -627,8 +655,6 @@ export class HyperLiquidProvider implements IPerpsProvider { * the asset ID lookup succeeds and the order routes to the correct DEX. */ private async buildAssetMapping(): Promise { - const infoClient = this.clientService.getInfoClient(); - // Get feature-flag-validated DEXs to map (respects hip3Enabled and enabledDexs) const dexsToMap = await this.getValidatedDexs(); @@ -660,14 +686,10 @@ export class HyperLiquidProvider implements IPerpsProvider { this.blocklistMarkets, ); - // Clear market cache when rebuilding asset mapping (feature flags changed) - this.clearMarketCache(); - - // Fetch metadata for each DEX in parallel + // Fetch metadata for each DEX in parallel with skipCache (feature flags changed, need fresh data) const allMetas = await Promise.allSettled( dexsToMap.map((dex) => - infoClient - .meta({ dex: dex ?? '' }) + this.getCachedMeta({ dexName: dex, skipCache: true }) .then((meta) => ({ dex, meta, success: true as const })) .catch((error) => { DevLogger.log( @@ -914,16 +936,37 @@ export class HyperLiquidProvider implements IPerpsProvider { } /** - * Ensure builder fee approval before placing orders + * Ensure builder fee is approved for MetaMask + * Called once during initialization (ensureReady) to set up builder fee for the session + * Uses session cache to avoid redundant API calls until disconnect/reconnect + * + * Cache semantics: Only caches successful approvals (never caches "not approved" state) + * This allows detection of external approvals between retries while avoiding redundant checks + * + * Note: This is network-specific - testnet and mainnet have separate builder fee states */ private async ensureBuilderFeeApproval(): Promise { + const isTestnet = this.clientService.isTestnetMode(); + const network = isTestnet ? 'testnet' : 'mainnet'; + const builderAddress = this.getBuilderAddress(isTestnet); + const userAddress = await this.walletService.getUserAddressWithDefault(); + + // Check session cache first to avoid redundant API calls + // Cache only stores true (approval confirmed), never false + const cacheKey = this.getCacheKey(network, userAddress); + const cached = this.builderFeeCheckCache.get(cacheKey); + + if (cached === true) { + DevLogger.log('[ensureBuilderFeeApproval] Using session cache', { + network, + }); + return; // Already approved this session, skip + } + const { isApproved, requiredDecimal } = await this.checkBuilderFeeStatus(); - const builderAddress = this.getBuilderAddress( - this.clientService.isTestnetMode(), - ); if (!isApproved) { - DevLogger.log('Builder fee approval required', { + DevLogger.log('[ensureBuilderFeeApproval] Approval required', { builder: builderAddress, currentApproval: isApproved, requiredDecimal, @@ -940,20 +983,31 @@ export class HyperLiquidProvider implements IPerpsProvider { // Verify approval was successful const afterApprovalDecimal = await this.checkBuilderFeeApproval(); - // this throw will block the order from being placed - // this should ideally never happen if ( afterApprovalDecimal === null || afterApprovalDecimal < requiredDecimal ) { - throw new Error('Builder fee approval failed or insufficient'); + throw new Error( + '[HyperLiquidProvider] Builder fee approval verification failed', + ); } - DevLogger.log('Builder fee approval successful', { + // Update cache to reflect successful approval + this.builderFeeCheckCache.set(cacheKey, true); + + DevLogger.log('[ensureBuilderFeeApproval] Approval successful', { builder: builderAddress, approvedDecimal: afterApprovalDecimal, maxFeeRate, }); + } else { + // User already has approval (possibly from external approval or previous session) + // Cache success to avoid redundant checks + this.builderFeeCheckCache.set(cacheKey, true); + + DevLogger.log('[ensureBuilderFeeApproval] Already approved', { + network, + }); } } @@ -1506,26 +1560,14 @@ export class HyperLiquidProvider implements IPerpsProvider { blocklistMarkets: this.blocklistMarkets, }); - // Ensure builder fee approval and referral code are set before placing any order - await Promise.all([ - this.ensureBuilderFeeApproval(), - this.ensureReferralSet(), - ]); + // See ensureReady() - builder fee and referral are session-cached // Extract DEX name for API calls (main DEX = null) const { dex: dexName } = parseAssetName(params.coin); - // Get asset info from the correct DEX + // Get asset info from the correct DEX (uses cache to avoid redundant API calls) const infoClient = this.clientService.getInfoClient(); - const meta = await infoClient.meta({ dex: dexName ?? '' }); - - if (!meta.universe || !Array.isArray(meta.universe)) { - throw new Error( - `Invalid universe data for DEX ${ - dexName || 'main' - } when placing order for ${params.coin}`, - ); - } + const meta = await this.getCachedMeta({ dexName }); // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3 const assetInfo = meta.universe.find( @@ -1886,19 +1928,11 @@ export class HyperLiquidProvider implements IPerpsProvider { // Extract DEX name for API calls (main DEX = null) const { dex: dexName } = parseAssetName(params.newOrder.coin); - // Get asset info and prices + // Get asset info and prices (uses cache to avoid redundant API calls) const infoClient = this.clientService.getInfoClient(); - const meta = await infoClient.meta({ dex: dexName ?? '' }); + const meta = await this.getCachedMeta({ dexName }); const mids = await infoClient.allMids({ dex: dexName ?? '' }); - if (!meta.universe || !Array.isArray(meta.universe)) { - throw new Error( - `Invalid universe data for DEX ${ - dexName || 'main' - } when editing order for ${params.newOrder.coin}`, - ); - } - // asset.name format: "BTC" for main DEX, "xyz:XYZ100" for HIP-3 const assetInfo = meta.universe.find( (asset) => asset.name === params.newOrder.coin, @@ -2168,6 +2202,18 @@ export class HyperLiquidProvider implements IPerpsProvider { const exchangeClient = this.clientService.getExchangeClient(); const infoClient = this.clientService.getInfoClient(); + // Pre-fetch meta for all unique DEXs to avoid N API calls in loop + const uniqueDexs = [ + ...new Set( + positionsToClose.map((p) => parseAssetName(p.coin).dex || 'main'), + ), + ]; + await Promise.all( + uniqueDexs.map((dex) => + this.getCachedMeta({ dexName: dex === 'main' ? null : dex }), + ), + ); + // Track HIP-3 positions and freed margins for post-close transfers const hip3Transfers: { sourceDex: string; @@ -2182,11 +2228,8 @@ export class HyperLiquidProvider implements IPerpsProvider { const { dex: dexName } = parseAssetName(position.coin); const isHip3Position = position.coin.includes(':'); - // Get asset info for formatting - const meta = await infoClient.meta({ dex: dexName ?? '' }); - if (!meta.universe || !Array.isArray(meta.universe)) { - throw new Error(`Invalid universe data for ${position.coin}`); - } + // Get asset info for formatting (uses cache populated above) + const meta = await this.getCachedMeta({ dexName }); const assetInfo = meta.universe.find( (asset) => asset.name === position.coin, @@ -2385,10 +2428,7 @@ export class HyperLiquidProvider implements IPerpsProvider { await this.ensureReady(); - await Promise.all([ - this.ensureBuilderFeeApproval(), - this.ensureReferralSet(), - ]); + // See ensureReady() - builder fee and referral are session-cached // Get current price for the asset const infoClient = this.clientService.getInfoClient(); @@ -2447,8 +2487,8 @@ export class HyperLiquidProvider implements IPerpsProvider { DevLogger.log('Cancel result:', cancelResult); } - // Get asset info (dexName already extracted above) - const meta = await infoClient.meta({ dex: dexName ?? '' }); + // Get asset info (dexName already extracted above) - uses cache + const meta = await this.getCachedMeta({ dexName }); // Check if meta is an error response (string) or doesn't have universe property if ( @@ -3436,7 +3476,10 @@ export class HyperLiquidProvider implements IPerpsProvider { // Query each unique DEX in parallel (with caching) const marketArrays = await Promise.all( Array.from(symbolsByDex.keys()).map(async (dex) => - this.fetchMarketsForDex(dex, params?.skipFilters), + this.fetchMarketsForDex({ + dex, + skipFilters: params?.skipFilters, + }), ), ); @@ -3466,7 +3509,10 @@ export class HyperLiquidProvider implements IPerpsProvider { const marketArrays = await Promise.all( dexsToQuery.map(async (dex) => { try { - return await this.fetchMarketsForDex(dex, params?.skipFilters); + return await this.fetchMarketsForDex({ + dex, + skipFilters: params?.skipFilters, + }); } catch (error) { Logger.error( ensureError(error), @@ -3488,10 +3534,10 @@ export class HyperLiquidProvider implements IPerpsProvider { dex: params?.dex || 'main', }); - return await this.fetchMarketsForDex( - params?.dex ?? null, - params?.skipFilters, - ); + return await this.fetchMarketsForDex({ + dex: params?.dex ?? null, + skipFilters: params?.skipFilters, + }); } catch (error) { Logger.error( ensureError(error), @@ -3545,7 +3591,7 @@ export class HyperLiquidProvider implements IPerpsProvider { await Promise.all( hip3DexNames.map(async (dexName) => { try { - const meta = await infoClient.meta({ dex: dexName }); + const meta = await this.getCachedMeta({ dexName }); if ( meta.universe && Array.isArray(meta.universe) && @@ -4527,16 +4573,21 @@ export class HyperLiquidProvider implements IPerpsProvider { // Extract DEX name for API calls (main DEX = null) const { dex: dexName } = parseAssetName(asset); - // Get asset info - const infoClient = this.clientService.getInfoClient(); - const meta = await infoClient.meta({ dex: dexName ?? '' }); + // Get asset info (uses cache to avoid redundant API calls) + const meta = await this.getCachedMeta({ dexName }); // Check if meta and universe exist and is valid + // This should never happen since getCachedMeta validates, but defensive check if (!meta?.universe || !Array.isArray(meta.universe)) { - console.warn( - `Meta or universe not available for DEX ${ - dexName || 'main' - }, using default max leverage`, + Logger.error( + new Error( + '[HyperLiquidProvider] Invalid meta response in getMaxLeverage', + ), + this.getErrorContext('getMaxLeverage', { + asset, + dexName: dexName || 'main', + note: 'Meta or universe not available, using default max leverage', + }), ); return PERPS_CONSTANTS.DEFAULT_MAX_LEVERAGE; } @@ -4896,6 +4947,11 @@ export class HyperLiquidProvider implements IPerpsProvider { // Clear fee cache this.clearFeeCache(); + // Clear session caches (ensures fresh state on reconnect/account switch) + this.referralCheckCache.clear(); + this.builderFeeCheckCache.clear(); + this.cachedMetaByDex.clear(); + // Disconnect client service await this.clientService.disconnect(); @@ -5017,13 +5073,17 @@ export class HyperLiquidProvider implements IPerpsProvider { /** * Ensure user has a MetaMask referral code set - * If user doesn't have a referral set, set MetaMask as referrer - * This is called before every order to maximize referral capture + * Called once during initialization (ensureReady) to set up referral for the session + * Uses session cache to avoid redundant API calls until disconnect/reconnect + * + * Cache semantics: Only caches successful referral sets (never caches "not set" state) + * This allows detection of external referral changes between retries while avoiding redundant checks * * Note: This is network-specific - testnet and mainnet have separate referral states + * Note: Non-blocking - failures are logged to Sentry but don't prevent trading + * Note: Will automatically retry on next session if failed (cache cleared on disconnect) */ private async ensureReferralSet(): Promise { - const errorMessage = 'Error ensuring referral code is set'; try { const isTestnet = this.clientService.isTestnetMode(); const network = isTestnet ? 'testnet' : 'mainnet'; @@ -5032,40 +5092,76 @@ export class HyperLiquidProvider implements IPerpsProvider { const userAddress = await this.walletService.getUserAddressWithDefault(); if (userAddress.toLowerCase() === referrerAddress.toLowerCase()) { - // if the user is the builder, we don't need to set a referral code + DevLogger.log('[ensureReferralSet] User is builder, skipping', { + network, + }); return; } + // Check session cache first to avoid redundant API calls + // Cache only stores true (referral confirmed), never false + const cacheKey = this.getCacheKey(network, userAddress); + const cached = this.referralCheckCache.get(cacheKey); + + if (cached === true) { + DevLogger.log('[ensureReferralSet] Using session cache', { + network, + }); + return; // Already has referral set this session, skip + } + const isReady = await this.isReferralCodeReady(); if (!isReady) { - // if the referrer code is not ready, we can't set the referral code on the user - // so we just return and the error will be logged - // we may want to block this completely, but for now we just log the error - // as the referrer may need to address an issue first and we may not want to completely - // block orders for this + DevLogger.log( + '[ensureReferralSet] Builder referral not ready yet, skipping', + { network }, + ); return; } + // Check if user already has a referral set on this network const hasReferral = await this.checkReferralSet(); if (!hasReferral) { - DevLogger.log('No referral set - setting MetaMask as referrer', { - network, - referralCode: expectedReferralCode, - }); + DevLogger.log( + '[ensureReferralSet] No referral set - setting MetaMask', + { + network, + referralCode: expectedReferralCode, + }, + ); const result = await this.setReferralCode(); if (result === true) { - DevLogger.log('Referral code set', { + DevLogger.log('[ensureReferralSet] Referral code set successfully', { network, referralCode: expectedReferralCode, }); + // Update cache to reflect successful set + this.referralCheckCache.set(cacheKey, true); } else { - throw new Error('Failed to set referral code'); + DevLogger.log( + '[ensureReferralSet] Failed to set referral code (will retry next session)', + { network }, + ); } + } else { + // User already has referral set (possibly from external setup or previous session) + // Cache success to avoid redundant checks + this.referralCheckCache.set(cacheKey, true); + + DevLogger.log('[ensureReferralSet] User already has referral set', { + network, + }); } } catch (error) { - console.error(errorMessage, error); - throw new Error(errorMessage); + // Non-blocking: Log to Sentry but don't throw + // Will retry automatically on next session (cache cleared on disconnect) + Logger.error( + ensureError(error), + this.getErrorContext('ensureReferralSet', { + note: 'Referral setup failed (non-blocking, will retry next session)', + }), + ); } } @@ -5074,7 +5170,6 @@ export class HyperLiquidProvider implements IPerpsProvider { * @returns Promise resolving to true if referral code is ready */ private async isReferralCodeReady(): Promise { - const errorMessage = 'Error checking if referral code is ready'; try { const infoClient = this.clientService.getInfoClient(); const isTestnet = this.clientService.isTestnetMode(); @@ -5094,15 +5189,24 @@ export class HyperLiquidProvider implements IPerpsProvider { } return true; } - console.error('Referral code not ready', { + + // Not ready yet - log as DevLogger since this is expected during setup phase + DevLogger.log('[isReferralCodeReady] Referral code not ready', { stage, code, referrerAddr, - referral, }); return false; } catch (error) { - console.error(errorMessage, error); + Logger.error( + ensureError(error), + this.getErrorContext('isReferralCodeReady', { + code: this.getReferralCode(this.clientService.isTestnetMode()), + referrerAddress: this.getBuilderAddress( + this.clientService.isTestnetMode(), + ), + }), + ); return false; } } @@ -5128,7 +5232,12 @@ export class HyperLiquidProvider implements IPerpsProvider { return !!referralData?.referredBy?.code; } catch (error) { - DevLogger.log('Error checking referral status:', error); + Logger.error( + ensureError(error), + this.getErrorContext('checkReferralSet', { + note: 'Error checking referral status, will retry', + }), + ); // do not throw here, return false as we can try to set it again return false; } @@ -5138,14 +5247,13 @@ export class HyperLiquidProvider implements IPerpsProvider { * Set MetaMask as the user's referrer on HyperLiquid */ private async setReferralCode(): Promise { - const errorMessage = 'Error setting referral code'; try { const exchangeClient = this.clientService.getExchangeClient(); const referralCode = this.getReferralCode( this.clientService.isTestnetMode(), ); - DevLogger.log('Setting referral code:', { + DevLogger.log('[setReferralCode] Setting referral code', { code: referralCode, network: this.clientService.isTestnetMode() ? 'testnet' : 'mainnet', }); @@ -5155,12 +5263,18 @@ export class HyperLiquidProvider implements IPerpsProvider { code: referralCode, }); - DevLogger.log('Referral code set result:', result); + DevLogger.log('[setReferralCode] Referral code set result', result); return result?.status === 'ok'; } catch (error) { - console.error(errorMessage, error); - throw new Error(errorMessage); + Logger.error( + ensureError(error), + this.getErrorContext('setReferralCode', { + code: this.getReferralCode(this.clientService.isTestnetMode()), + }), + ); + // Rethrow to be caught by retry logic in ensureReferralSet + throw error; } } } diff --git a/app/components/UI/Perps/services/HyperLiquidClientService.test.ts b/app/components/UI/Perps/services/HyperLiquidClientService.test.ts index 7e8434c937b3..bc2f43bef0b4 100644 --- a/app/components/UI/Perps/services/HyperLiquidClientService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidClientService.test.ts @@ -24,28 +24,31 @@ const mockInfoClient = { const mockSubscriptionClient = { initialized: true, }; -const mockTransport = { +const mockWsTransport = { url: 'ws://mock', close: jest.fn().mockResolvedValue(undefined), }; +const mockHttpTransport = { + url: 'http://mock', +}; jest.mock('@nktkas/hyperliquid', () => ({ ExchangeClient: jest.fn(() => mockExchangeClient), InfoClient: jest.fn(() => mockInfoClient), SubscriptionClient: jest.fn(() => mockSubscriptionClient), - WebSocketTransport: jest.fn(() => mockTransport), + WebSocketTransport: jest.fn(() => mockWsTransport), + HttpTransport: jest.fn(() => mockHttpTransport), })); // Mock configuration jest.mock('../constants/hyperLiquidConfig', () => ({ - getWebSocketEndpoint: jest.fn((isTestnet: boolean) => - isTestnet - ? 'wss://api.hyperliquid-testnet.xyz/ws' - : 'wss://api.hyperliquid.xyz/ws', - ), HYPERLIQUID_TRANSPORT_CONFIG: { - reconnectAttempts: 5, - reconnectInterval: 1000, + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: { + maxRetries: 5, + connectionTimeout: 10_000, + }, }, })); @@ -92,7 +95,7 @@ describe('HyperLiquidClientService', () => { }); describe('Client Initialization', () => { - it('should initialize clients successfully', () => { + it('should initialize clients successfully with dual transports', () => { service.initialize(mockWallet); expect(service.isInitialized()).toBe(true); @@ -102,23 +105,39 @@ describe('HyperLiquidClientService', () => { InfoClient, SubscriptionClient, WebSocketTransport, + HttpTransport, } = require('@nktkas/hyperliquid'); + // Verify HTTP transport uses isTestnet flag (SDK handles endpoint selection) + expect(HttpTransport).toHaveBeenCalledWith({ + isTestnet: false, + timeout: 10_000, + }); + + // Verify WebSocket transport uses isTestnet flag (SDK handles endpoint selection) expect(WebSocketTransport).toHaveBeenCalledWith({ - url: 'wss://api.hyperliquid.xyz/ws', - reconnectAttempts: 5, - reconnectInterval: 1000, - reconnect: { + isTestnet: false, + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: expect.objectContaining({ WebSocket: expect.any(Function), - }, + maxRetries: 5, + connectionTimeout: 10_000, + }), }); + + // ExchangeClient uses HTTP transport expect(ExchangeClient).toHaveBeenCalledWith({ wallet: mockWallet, - transport: mockTransport, + transport: mockHttpTransport, }); - expect(InfoClient).toHaveBeenCalledWith({ transport: mockTransport }); + + // InfoClient uses HTTP transport + expect(InfoClient).toHaveBeenCalledWith({ transport: mockHttpTransport }); + + // SubscriptionClient uses WebSocket transport expect(SubscriptionClient).toHaveBeenCalledWith({ - transport: mockTransport, + transport: mockWsTransport, }); }); @@ -140,19 +159,28 @@ describe('HyperLiquidClientService', () => { const { ExchangeClient, WebSocketTransport, + HttpTransport, } = require('@nktkas/hyperliquid'); + // Verify testnet flag is passed (SDK auto-selects testnet endpoints) + expect(HttpTransport).toHaveBeenCalledWith({ + isTestnet: true, + timeout: 10_000, + }); + expect(WebSocketTransport).toHaveBeenCalledWith({ - url: 'wss://api.hyperliquid-testnet.xyz/ws', - reconnectAttempts: 5, - reconnectInterval: 1000, - reconnect: { + isTestnet: true, + timeout: 10_000, + keepAlive: { interval: 30_000 }, + reconnect: expect.objectContaining({ WebSocket: expect.any(Function), - }, + }), }); + + // ExchangeClient uses HTTP transport expect(ExchangeClient).toHaveBeenCalledWith({ wallet: mockWallet, - transport: mockTransport, + transport: mockHttpTransport, }); }); }); @@ -269,21 +297,24 @@ describe('HyperLiquidClientService', () => { service.initialize(mockWallet); }); - it('should disconnect successfully', async () => { + it('should disconnect successfully and close only WebSocket transport', async () => { await service.disconnect(); - expect(mockTransport.close).toHaveBeenCalled(); + // Only WebSocket transport should be closed (HTTP is stateless) + expect(mockWsTransport.close).toHaveBeenCalled(); expect(service.getSubscriptionClient()).toBeUndefined(); }); it('should handle disconnect errors gracefully', async () => { - mockTransport.close.mockRejectedValueOnce(new Error('Disconnect failed')); + mockWsTransport.close.mockRejectedValueOnce( + new Error('Disconnect failed'), + ); // Should not throw, error is caught and logged await expect(service.disconnect()).resolves.not.toThrow(); // Verify the error was attempted to be handled - expect(mockTransport.close).toHaveBeenCalled(); + expect(mockWsTransport.close).toHaveBeenCalled(); }); it('should clear all client references after disconnect', async () => { @@ -348,8 +379,8 @@ describe('HyperLiquidClientService', () => { 'HyperLiquid SDK clients initialized', expect.objectContaining({ testnet: false, - endpoint: 'wss://api.hyperliquid.xyz/ws', timestamp: expect.any(String), + connectionState: 'connected', }), ); }); @@ -366,7 +397,6 @@ describe('HyperLiquidClientService', () => { 'HyperLiquid: Disconnecting SDK clients', expect.objectContaining({ isTestnet: false, - endpoint: 'wss://api.hyperliquid.xyz/ws', timestamp: expect.any(String), }), ); @@ -380,9 +410,8 @@ describe('HyperLiquidClientService', () => { service.initialize(mockWallet); expect(DevLogger.log).toHaveBeenCalledWith( - 'HyperLiquid: Creating WebSocket transport', + 'HyperLiquid: Creating transports', expect.objectContaining({ - endpoint: 'wss://api.hyperliquid.xyz/ws', isTestnet: false, timestamp: expect.any(String), }), diff --git a/app/components/UI/Perps/services/HyperLiquidClientService.ts b/app/components/UI/Perps/services/HyperLiquidClientService.ts index d1b425896ca8..cca32dcdaab7 100644 --- a/app/components/UI/Perps/services/HyperLiquidClientService.ts +++ b/app/components/UI/Perps/services/HyperLiquidClientService.ts @@ -1,14 +1,12 @@ import { ExchangeClient, + HttpTransport, InfoClient, SubscriptionClient, WebSocketTransport, } from '@nktkas/hyperliquid'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; -import { - getWebSocketEndpoint, - HYPERLIQUID_TRANSPORT_CONFIG, -} from '../constants/hyperLiquidConfig'; +import { HYPERLIQUID_TRANSPORT_CONFIG } from '../constants/hyperLiquidConfig'; import type { HyperLiquidNetwork } from '../types/config'; import { strings } from '../../../../../locales/i18n'; import type { CandleData } from '../types/perps-types'; @@ -42,7 +40,8 @@ export class HyperLiquidClientService { private exchangeClient?: ExchangeClient; private infoClient?: InfoClient; private subscriptionClient?: SubscriptionClient; - private transport?: WebSocketTransport; + private wsTransport?: WebSocketTransport; + private httpTransport?: HttpTransport; private isTestnet: boolean; private connectionState: WebSocketConnectionState = WebSocketConnectionState.DISCONNECTED; @@ -73,26 +72,35 @@ export class HyperLiquidClientService { }): void { try { this.connectionState = WebSocketConnectionState.CONNECTING; - this.transport = this.createTransport(); + this.createTransports(); + + // Ensure transports are created + if (!this.httpTransport || !this.wsTransport) { + throw new Error('Failed to create transports'); + } // Wallet adapter implements AbstractViemJsonRpcAccount interface with signTypedData method + // ExchangeClient uses HTTP transport for write operations (orders, approvals, etc.) this.exchangeClient = new ExchangeClient({ wallet: wallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility - transport: this.transport, + transport: this.httpTransport, }); - this.infoClient = new InfoClient({ transport: this.transport }); + // InfoClient uses HTTP transport for read operations (queries, metadata, etc.) + this.infoClient = new InfoClient({ transport: this.httpTransport }); + + // SubscriptionClient uses WebSocket transport for real-time pub/sub (price feeds, position updates) this.subscriptionClient = new SubscriptionClient({ - transport: this.transport, + transport: this.wsTransport, }); this.connectionState = WebSocketConnectionState.CONNECTED; DevLogger.log('HyperLiquid SDK clients initialized', { testnet: this.isTestnet, - endpoint: getWebSocketEndpoint(this.isTestnet), timestamp: new Date().toISOString(), connectionState: this.connectionState, + note: 'Using HTTP for InfoClient/ExchangeClient, WebSocket for SubscriptionClient', }); } catch (error) { this.connectionState = WebSocketConnectionState.DISCONNECTED; @@ -102,21 +110,33 @@ export class HyperLiquidClientService { } /** - * Create WebSocket transport with configuration + * Create HTTP and WebSocket transports + * - HTTP for InfoClient and ExchangeClient (request/response operations) + * - WebSocket for SubscriptionClient (real-time pub/sub) + * + * Both transports use SDK's built-in endpoint resolution via isTestnet flag */ - private createTransport(): WebSocketTransport { - const wsUrl = getWebSocketEndpoint(this.isTestnet); - - DevLogger.log('HyperLiquid: Creating WebSocket transport', { - endpoint: wsUrl, + private createTransports(): void { + DevLogger.log('HyperLiquid: Creating transports', { isTestnet: this.isTestnet, timestamp: new Date().toISOString(), + note: 'SDK will auto-select endpoints based on isTestnet flag', }); - return new WebSocketTransport({ - url: wsUrl, + // HTTP transport for request/response operations (InfoClient, ExchangeClient) + // SDK automatically selects: mainnet (https://api.hyperliquid.xyz) or testnet (https://api.hyperliquid-testnet.xyz) + this.httpTransport = new HttpTransport({ + isTestnet: this.isTestnet, + timeout: HYPERLIQUID_TRANSPORT_CONFIG.timeout, + }); + + // WebSocket transport for real-time subscriptions (SubscriptionClient) + // SDK automatically selects: mainnet (wss://api.hyperliquid.xyz/ws) or testnet (wss://api.hyperliquid-testnet.xyz/ws) + this.wsTransport = new WebSocketTransport({ + isTestnet: this.isTestnet, ...HYPERLIQUID_TRANSPORT_CONFIG, reconnect: { + ...HYPERLIQUID_TRANSPORT_CONFIG.reconnect, WebSocket, // Use React Native's global WebSocket }, }); @@ -361,15 +381,14 @@ export class HyperLiquidClientService { DevLogger.log('HyperLiquid: Disconnecting SDK clients', { isTestnet: this.isTestnet, - endpoint: getWebSocketEndpoint(this.isTestnet), timestamp: new Date().toISOString(), connectionState: this.connectionState, }); - // Close the WebSocket connection via transport - if (this.transport) { + // Close WebSocket transport only (HTTP is stateless) + if (this.wsTransport) { try { - await this.transport.close(); + await this.wsTransport.close(); DevLogger.log('HyperLiquid: Closed WebSocket transport', { timestamp: new Date().toISOString(), }); @@ -384,7 +403,8 @@ export class HyperLiquidClientService { this.subscriptionClient = undefined; this.exchangeClient = undefined; this.infoClient = undefined; - this.transport = undefined; + this.wsTransport = undefined; + this.httpTransport = undefined; this.connectionState = WebSocketConnectionState.DISCONNECTED; diff --git a/package.json b/package.json index aa2e64309b79..0e2c6228a036 100644 --- a/package.json +++ b/package.json @@ -291,7 +291,7 @@ "@metamask/tron-wallet-snap": "^1.6.1", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", - "@nktkas/hyperliquid": "^0.25.7", + "@nktkas/hyperliquid": "^0.25.9", "@noble/curves": "1.9.6", "@notifee/react-native": "^9.0.0", "@react-native-async-storage/async-storage": "^1.23.1", diff --git a/yarn.lock b/yarn.lock index 34ca1507ce1b..284ea1978fe3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5375,6 +5375,13 @@ __metadata: languageName: node linkType: hard +"@henrygd/semaphore@npm:0.1.0": + version: 0.1.0 + resolution: "@henrygd/semaphore@npm:0.1.0" + checksum: 10/bbc8832162be81d1c6d5782f635b53dd916bcc86dd366f161ad1f0216110f7a490a1a4579af16b3759563f11c517e73328412efec07fa1443d370df98c0cbd25 + languageName: node + linkType: hard + "@httptoolkit/httpolyglot@npm:^2.2.1": version: 2.2.1 resolution: "@httptoolkit/httpolyglot@npm:2.2.1" @@ -9099,18 +9106,19 @@ __metadata: languageName: node linkType: hard -"@nktkas/hyperliquid@npm:^0.25.7": - version: 0.25.7 - resolution: "@nktkas/hyperliquid@npm:0.25.7" +"@nktkas/hyperliquid@npm:^0.25.9": + version: 0.25.9 + resolution: "@nktkas/hyperliquid@npm:0.25.9" dependencies: + "@henrygd/semaphore": "npm:0.1.0" "@msgpack/msgpack": "npm:^3.1.2" - "@noble/hashes": "npm:^2.0.0" + "@noble/hashes": "npm:^2.0.1" "@noble/secp256k1": "npm:^3.0.0" typescript-event-target: "npm:1.1.1" valibot: "npm:1.1.0" bin: hyperliquid: esm/bin/cli.js - checksum: 10/673e004d406cd774fc7af215598503cd5e70fe08c4b54e9e1297bde8eb85a15a68dbe67d1d3e47d068ff7266390ad6fe67107950986c98a26b3f08028b308e3a + checksum: 10/b62cf956449342d0ef777d62a7ca03515ad7cbc7b54bb6907bd5f426ec3b54d82320e4d5fd80a489a98dea817fae34ec885fc9a9a598625425c45d103ae1974f languageName: node linkType: hard @@ -9210,7 +9218,7 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:^2.0.0": +"@noble/hashes@npm:^2.0.1": version: 2.0.1 resolution: "@noble/hashes@npm:2.0.1" checksum: 10/f4d00e7564eb4ff4e6d16be151dd0e404aede35f91e4372b0a8a6ec888379c1dd1e02c721b480af8e7853bea9637185b5cb9533970c5b77d60c254ead0cfd8f7 @@ -34355,7 +34363,7 @@ __metadata: "@metamask/tron-wallet-snap": "npm:^1.6.1" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" - "@nktkas/hyperliquid": "npm:^0.25.7" + "@nktkas/hyperliquid": "npm:^0.25.9" "@noble/curves": "npm:1.9.6" "@notifee/react-native": "npm:^9.0.0" "@octokit/rest": "npm:^21.0.0"