From e4221573a3b673873f59329c560fd54223fde885 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 07:15:11 +0800 Subject: [PATCH 01/21] feat(perps): implement open interest cap --- .../PerpsMarketDetailsView.tsx | 67 +++++---- .../Views/PerpsOrderView/PerpsOrderView.tsx | 11 +- .../PerpsMarketHoursBanner.tsx | 46 +++---- .../PerpsOICapWarning/PerpsOICapWarning.tsx | 128 ++++++++++++++++++ .../components/PerpsOICapWarning/index.ts | 2 + .../UI/Perps/controllers/PerpsController.ts | 31 ++++- .../providers/HyperLiquidProvider.ts | 11 ++ .../UI/Perps/controllers/types/index.ts | 4 + .../UI/Perps/hooks/usePerpsOICap.ts | 94 +++++++++++++ .../HyperLiquidSubscriptionService.ts | 70 +++++++++- .../hyperLiquidOrderBookProcessor.test.ts | 6 +- .../utils/hyperLiquidOrderBookProcessor.ts | 2 +- docs/perps/hyperliquid/fees.md | 70 ++++++++++ locales/languages/en.json | 2 + package.json | 2 +- yarn.lock | 10 +- 16 files changed, 494 insertions(+), 62 deletions(-) create mode 100644 app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx create mode 100644 app/components/UI/Perps/components/PerpsOICapWarning/index.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsOICap.ts create mode 100644 docs/perps/hyperliquid/fees.md diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 08b99debba8a..b25ee5d19d09 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -58,6 +58,7 @@ import { usePerpsNetworkManagement, usePerpsNavigation, } from '../../hooks'; +import { usePerpsOICap } from '../../hooks/usePerpsOICap'; import { usePerpsDataMonitor, type DataMonitorParams, @@ -66,6 +67,7 @@ import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsLiveOrders, usePerpsLiveAccount } from '../../hooks/stream'; import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs'; import type { PerpsTabId } from '../../components/PerpsMarketTabs/PerpsMarketTabs.types'; +import PerpsOICapWarning from '../../components/PerpsOICapWarning'; import PerpsNotificationTooltip from '../../components/PerpsNotificationTooltip'; import PerpsNavigationCard, { type NavigationItem, @@ -183,6 +185,9 @@ const PerpsMarketDetailsView: React.FC = () => { const { depositWithConfirmation } = usePerpsTrading(); const { ensureArbitrumNetworkExists } = usePerpsNetworkManagement(); + // Check if market is at open interest cap + const { isAtCap: isAtOICap } = usePerpsOICap(market?.symbol); + // Programmatic tab control state for data-driven navigation const [programmaticActiveTab, setProgrammaticActiveTab] = useState< string | null @@ -681,6 +686,11 @@ const PerpsMarketDetailsView: React.FC = () => { testID={PerpsMarketDetailsViewSelectorsIDs.MARKET_HOURS_BANNER} /> + {/* OI Cap Warning - Shows when market is at capacity */} + {market?.symbol && ( + + )} + {/* Market Tabs Section */} = () => { )} {hasLongShortButtons && ( - - - - {strings('perps.market.long')} - + <> + {/* OI Cap Warning - Shows when market is at capacity */} + {market?.symbol && ( + + )} + + + + + {strings('perps.market.long')} + + + + + + {strings('perps.market.short')} + + - - - - {strings('perps.market.short')} - - - + )} )} diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 4bfa9d60b5ed..c7ffa973eee5 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -51,6 +51,7 @@ import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip'; import { PerpsTooltipContentKey } from '../../components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.types'; import PerpsLeverageBottomSheet from '../../components/PerpsLeverageBottomSheet'; import PerpsLimitPriceBottomSheet from '../../components/PerpsLimitPriceBottomSheet'; +import PerpsOICapWarning from '../../components/PerpsOICapWarning'; import PerpsOrderHeader from '../../components/PerpsOrderHeader'; import PerpsOrderTypeBottomSheet from '../../components/PerpsOrderTypeBottomSheet'; import PerpsSlider from '../../components/PerpsSlider'; @@ -88,6 +89,7 @@ import { import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; +import { usePerpsOICap } from '../../hooks/usePerpsOICap'; import { formatPerpsFiat, PRICE_RANGES_MINIMAL_VIEW, @@ -220,6 +222,9 @@ const PerpsOrderViewContentBase: React.FC = () => { loadOnMount: true, }); + // Check if market is at OI cap (zero network overhead - uses existing webData2 subscription) + const { isAtCap: isAtOICap } = usePerpsOICap(orderForm.asset); + // Markets data for navigation const { markets } = usePerpsMarkets(); @@ -1219,6 +1224,9 @@ const PerpsOrderViewContentBase: React.FC = () => { )} + {/* OI Cap Warning - Only shows when market is at capacity */} + + { isDisabled={ !orderValidation.isValid || isPlacingOrder || - doesStopLossRiskLiquidation + doesStopLossRiskLiquidation || + isAtOICap } isLoading={isPlacingOrder} testID={PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON} diff --git a/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx b/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx index 383f02a92c68..b5efb323815f 100644 --- a/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx @@ -69,44 +69,40 @@ const PerpsMarketHoursBanner: React.FC = ({ return ( - + - - - {titleText} - - {subtitleText} - + + + + {titleText} + + {subtitleText} + + - - - + - + ); }; diff --git a/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx new file mode 100644 index 000000000000..566fde83ebb7 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx @@ -0,0 +1,128 @@ +import React, { memo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; +import { useStyles } from '../../../../../component-library/hooks'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../../component-library/components/Icons/Icon'; +import { usePerpsOICap } from '../../hooks/usePerpsOICap'; + +export interface PerpsOICapWarningProps { + /** Market symbol to check OI cap status for */ + symbol: string; + /** Variant determines the display style */ + variant?: 'inline' | 'banner'; + /** Optional test ID for testing */ + testID?: string; +} + +const styleSheet = (params: { theme: Theme }) => { + const { colors } = params.theme; + + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + }, + bannerContainer: { + backgroundColor: colors.warning.muted, + borderRadius: 8, + padding: 12, + borderWidth: 1, + borderColor: colors.warning.default, + }, + inlineContainer: { + paddingVertical: 8, + }, + icon: { + marginTop: 2, + }, + textContainer: { + flex: 1, + gap: 4, + }, + title: { + fontWeight: '600', + }, + description: { + lineHeight: 18, + }, + }); +}; + +/** + * Reusable component that displays a warning when a market is at its open interest cap + * + * **Performance:** + * - Zero network overhead (uses existing webData2 WebSocket) + * - Memoized to prevent unnecessary re-renders + * - Multiple instances share the same subscription + * - Returns null immediately if not at cap (no DOM overhead) + * + * @example + * ```tsx + * // Inline warning in order form + * + * + * // Banner warning in market details + * + * ``` + */ +const PerpsOICapWarning: React.FC = memo( + ({ symbol, variant = 'inline', testID = 'perps-oi-cap-warning' }) => { + const { styles } = useStyles(styleSheet, {}); + const { isAtCap, isLoading } = usePerpsOICap(symbol); + + // Early return for performance - don't render anything if not at cap + if (!isAtCap || isLoading) { + return null; + } + + const isBanner = variant === 'banner'; + + return ( + + + + + Open Interest Cap Reached + + + This market is at capacity. New positions cannot be opened until + open interest decreases. + + + + ); + }, +); + +PerpsOICapWarning.displayName = 'PerpsOICapWarning'; + +export default PerpsOICapWarning; diff --git a/app/components/UI/Perps/components/PerpsOICapWarning/index.ts b/app/components/UI/Perps/components/PerpsOICapWarning/index.ts new file mode 100644 index 000000000000..d919a03b823b --- /dev/null +++ b/app/components/UI/Perps/components/PerpsOICapWarning/index.ts @@ -0,0 +1,2 @@ +export { default } from './PerpsOICapWarning'; +export type { PerpsOICapWarningProps } from './PerpsOICapWarning'; diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 2c9e3e6ecd54..5f28241cab63 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -15,7 +15,11 @@ import { TransactionParams, TransactionType, } from '@metamask/transaction-controller'; -import { parseCaipAssetId, type Hex } from '@metamask/utils'; +import { + parseCaipAssetId, + type CaipAccountId, + type Hex, +} from '@metamask/utils'; import performance from 'react-native-performance'; import { setMeasurement } from '@sentry/react-native'; import type { Span } from '@sentry/core'; @@ -3665,6 +3669,31 @@ export class PerpsController extends BaseController< } } + /** + * Subscribe to open interest cap updates + * Zero additional network overhead - data comes from existing webData2 subscription + */ + subscribeToOICaps(params: { + accountId?: CaipAccountId; + callback: (caps: string[]) => void; + }): () => void { + try { + const provider = this.getActiveProvider(); + return provider.subscribeToOICaps(params); + } catch (error) { + Logger.error( + ensureError(error), + this.getErrorContext('subscribeToOICaps', { + accountId: params.accountId, + }), + ); + // Return a no-op unsubscribe function + return () => { + // No-op: Provider not initialized + }; + } + } + /** * Configure live data throttling */ diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 29d7333cdbae..1392e09b6933 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -4101,6 +4101,17 @@ export class HyperLiquidProvider implements IPerpsProvider { return this.subscriptionService.subscribeToAccount(params); } + /** + * Subscribe to open interest cap updates + * Zero additional overhead - data extracted from existing webData2 subscription + */ + subscribeToOICaps(params: { + accountId?: CaipAccountId; + callback: (caps: string[]) => void; + }): () => void { + return this.subscriptionService.subscribeToOICaps(params); + } + /** * Configure live data settings */ diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index 3772c1eb4579..5423fcab85bf 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -767,6 +767,10 @@ export interface IPerpsProvider { subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void; subscribeToOrders(params: SubscribeOrdersParams): () => void; subscribeToAccount(params: SubscribeAccountParams): () => void; + subscribeToOICaps(params: { + accountId?: CaipAccountId; + callback: (caps: string[]) => void; + }): () => void; // Live data configuration setLiveDataConfig(config: Partial): void; diff --git a/app/components/UI/Perps/hooks/usePerpsOICap.ts b/app/components/UI/Perps/hooks/usePerpsOICap.ts new file mode 100644 index 000000000000..ef7efa894317 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsOICap.ts @@ -0,0 +1,94 @@ +import { useState, useEffect, useMemo } from 'react'; +import Engine from '../../../../core/Engine'; + +/** + * Developer-only override for OI cap state testing + * + * - Set to `true` to force isAtCap=true (test warning UI and disabled buttons) + * - Set to `false` to force isAtCap=false (test normal state even if actually at cap) + * - Set to `null` for normal behavior (use real WebSocket data) + * + * ⚠️ Only active in __DEV__ builds - automatically disabled in production + * + * @example + * ```typescript + * // Test OI cap warning UI: + * const FORCE_OI_CAP_STATE: boolean | null = true; + * + * // Test normal state: + * const FORCE_OI_CAP_STATE: boolean | null = false; + * + * // Production/normal (default): + * const FORCE_OI_CAP_STATE: boolean | null = null; + * ``` + */ +const FORCE_OI_CAP_STATE: boolean | null = null; + +/** + * Return type for usePerpsOICap hook + */ +export interface UsePerpsOICapReturn { + /** + * Whether the specified symbol is currently at its open interest cap + */ + isAtCap: boolean; + /** + * Loading state - true until first WebSocket data is received + */ + isLoading: boolean; +} + +/** + * Hook to check if a market is at its open interest cap + * + * Leverages the existing webData2 WebSocket subscription which includes + * `perpsAtOpenInterestCap` field - zero additional network overhead. + * + * @param symbol - Market symbol to check (e.g., 'BTC', 'xyz:TSLA') + * @returns Object with isAtCap and isLoading flags + * + * @example + * ```typescript + * const { isAtCap, isLoading } = usePerpsOICap(market?.symbol); + * + * if (isAtCap) { + * // Show warning banner, disable trading buttons + * } + * ``` + */ +export const usePerpsOICap = (symbol?: string): UsePerpsOICapReturn => { + const [oiCaps, setOICaps] = useState([]); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + if (!symbol) return; + + const controller = Engine.context.PerpsController; + + // Subscribe to OI cap updates from the controller + const unsubscribe = controller.subscribeToOICaps({ + callback: (caps: string[]) => { + setOICaps(caps); + setIsInitialized(true); + }, + }); + + return unsubscribe; + }, [symbol]); + + // Check if the current symbol is in the OI caps list + const isAtCap = useMemo(() => { + // Developer override (only in __DEV__ builds) + if (__DEV__ && FORCE_OI_CAP_STATE !== null) { + return FORCE_OI_CAP_STATE; + } + + if (!symbol || !isInitialized) return false; + return oiCaps.includes(symbol); + }, [symbol, oiCaps, isInitialized]); + + return { + isAtCap, + isLoading: !isInitialized, + }; +}; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 03d6e654b8e9..da8f95cdf599 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -99,6 +99,13 @@ export class HyperLiquidSubscriptionService { private cachedAccount: AccountState | null = null; // Aggregated account private ordersCacheInitialized = false; // Track if orders cache has received WebSocket data private positionsCacheInitialized = false; // Track if positions cache has received WebSocket data + + // OI Cap tracking (from webData2.perpsAtOpenInterestCap) + private readonly oiCapSubscribers = new Set<(caps: string[]) => void>(); + private cachedOICaps: string[] = []; + private cachedOICapsHash = ''; + private oiCapsCacheInitialized = false; + // Global price data cache private cachedPriceData: Map | null = null; @@ -587,7 +594,7 @@ export class HyperLiquidSubscriptionService { ]); if (perpsMeta?.universe && assetCtxs?.[1]) { - // Cache funding rates directly from assetCtxs + // Cache funding rates directly from assetCtxs and meta perpsMeta.universe.forEach((asset, index) => { const assetCtx = assetCtxs[1][index]; if (assetCtx && 'funding' in assetCtx) { @@ -772,6 +779,27 @@ export class HyperLiquidSubscriptionService { data.spotState, ); + // Extract OI caps from webData2 (only available on main DEX, dexName === '') + // perpsAtOpenInterestCap is an array of asset symbols that are at capacity + if (dexName === '') { + const oiCaps = data.perpsAtOpenInterestCap || []; + const oiCapsHash = JSON.stringify(oiCaps.sort()); + + if (oiCapsHash !== this.cachedOICapsHash) { + this.cachedOICaps = oiCaps; + this.cachedOICapsHash = oiCapsHash; + this.oiCapsCacheInitialized = true; + + DevLogger.log('OI Caps Updated', { + oiCaps, + count: oiCaps.length, + }); + + // Notify all subscribers + this.oiCapSubscribers.forEach((callback) => callback(oiCaps)); + } + } + // Store per-DEX data in caches this.dexPositionsCache.set(dexName, positionsWithTPSL); this.dexOrdersCache.set(dexName, orders); @@ -942,6 +970,46 @@ export class HyperLiquidSubscriptionService { }; } + /** + * Subscribe to open interest cap updates + * OI caps are extracted from webData2 subscription (zero additional overhead) + */ + public subscribeToOICaps(params: { + callback: (caps: string[]) => void; + accountId?: CaipAccountId; + }): () => void { + const { callback, accountId } = params; + + // Create subscription + const unsubscribe = this.createSubscription( + this.oiCapSubscribers, + callback, + ); + + // Immediately provide cached data if available + if (this.cachedOICaps) { + callback(this.cachedOICaps); + } + + // Ensure webData2 subscription is active (OI caps come from webData2) + this.ensureSharedWebData2Subscription(accountId).catch((error) => { + Logger.error( + ensureError(error), + this.getErrorContext('subscribeToOICaps'), + ); + }); + + return unsubscribe; + } + + /** + * Check if OI caps cache has been initialized + * Useful for preventing UI flashing before first data arrives + */ + public isOICapsCacheInitialized(): boolean { + return this.oiCapsCacheInitialized; + } + /** * Subscribe to live order fill updates */ diff --git a/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.test.ts b/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.test.ts index 600c4431ab5e..334f79d2e869 100644 --- a/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.test.ts @@ -94,11 +94,11 @@ describe('hyperLiquidOrderBookProcessor', () => { }); it('returns early when levels data is missing', () => { - const data: L2BookResponse = { + const data = { coin: 'BTC', time: Date.now(), - levels: undefined as unknown as L2BookResponse['levels'], - }; + levels: undefined, + } as unknown as L2BookResponse; const params: ProcessL2BookDataParams = { symbol: 'BTC', diff --git a/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts b/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts index 5339f1f5098a..b9d67ad478c8 100644 --- a/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts +++ b/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts @@ -48,7 +48,7 @@ export function processL2BookData(params: ProcessL2BookDataParams): void { notifySubscribers, } = params; - if (data.coin !== symbol || !data.levels) { + if (!data || data.coin !== symbol || !data.levels) { return; } diff --git a/docs/perps/hyperliquid/fees.md b/docs/perps/hyperliquid/fees.md new file mode 100644 index 000000000000..719b00ec2b4e --- /dev/null +++ b/docs/perps/hyperliquid/fees.md @@ -0,0 +1,70 @@ +# Fees + +Fees are based on your rolling 14 day volume and are assessed at the end of each day in UTC. Sub-account volume counts toward the master account and all sub-accounts share the same fee tier. Vault volume is treated separately from the master account. Referral rewards apply for a user's first $1B in volume and referral discounts apply for a user's first $25M in volume. + +Maker rebates are paid out continuously on each trade directly to the trading wallet. Users can claim referral rewards from the Referrals page. + +There are separate fee schedules for perps vs spot. Perps and spot volume will be counted together to determine your fee tier, and spot volume will count double toward your fee tier. i.e., `(14d weighted volume) = (14d perps volume) + 2 * (14d spot volume)`. + +There is one fee tier across all assets, including perps, HIP-3 perps, and spot. HIP-3 perps have 2x fees and the same rebates. + +Spot pairs between two spot quote assets have 80% lower taker fees, maker rebates, and user volume contribution. + +[aligned-quote-assets](https://hyperliquid.gitbook.io/hyperliquid-docs/hypercore/aligned-quote-assets 'mention') benefit from 20% lower taker fees, 50% better maker rebates, and 20% more volume contribution toward fee tiers. + +### Perps fee tiers + +| | | Base rate | | Diamond | | Platinum | | Gold | | Silver | | Bronze | | Wood | | +| ---- | ----------------------- | --------- | ------ | ------- | ------- | -------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | +| Tier | 14d weighted volume ($) | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | +| 0 | | 0.045% | 0.015% | 0.0270% | 0.0090% | 0.0315% | 0.0105% | 0.0360% | 0.0120% | 0.0383% | 0.0128% | 0.0405% | 0.0135% | 0.0428% | 0.0143% | +| 1 | >5M | 0.040% | 0.012% | 0.0240% | 0.0072% | 0.0280% | 0.0084% | 0.0320% | 0.0096% | 0.0340% | 0.0102% | 0.0360% | 0.0108% | 0.0380% | 0.0114% | +| 2 | >25M | 0.035% | 0.008% | 0.0210% | 0.0048% | 0.0245% | 0.0056% | 0.0280% | 0.0064% | 0.0298% | 0.0068% | 0.0315% | 0.0072% | 0.0333% | 0.0076% | +| 3 | >100M | 0.030% | 0.004% | 0.0180% | 0.0024% | 0.0210% | 0.0028% | 0.0240% | 0.0032% | 0.0255% | 0.0034% | 0.0270% | 0.0036% | 0.0285% | 0.0038% | +| 4 | >500M | 0.028% | 0.000% | 0.0168% | 0.0000% | 0.0196% | 0.0000% | 0.0224% | 0.0000% | 0.0238% | 0.0000% | 0.0252% | 0.0000% | 0.0266% | 0.0000% | +| 5 | >2B | 0.026% | 0.000% | 0.0156% | 0.0000% | 0.0182% | 0.0000% | 0.0208% | 0.0000% | 0.0221% | 0.0000% | 0.0234% | 0.0000% | 0.0247% | 0.0000% | +| 6 | >7B | 0.024% | 0.000% | 0.0144% | 0.0000% | 0.0168% | 0.0000% | 0.0192% | 0.0000% | 0.0204% | 0.0000% | 0.0216% | 0.0000% | 0.0228% | 0.0000% | + +### Spot fee tiers + +| Spot | | Base rate | | Diamond | | Platinum | | Gold | | Silver | | Bronze | | Wood | | +| ---- | ----------------------- | --------- | ------ | ------- | ------- | -------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | +| Tier | 14d weighted volume ($) | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | Taker | Maker | +| 0 | | 0.070% | 0.040% | 0.0420% | 0.0240% | 0.0490% | 0.0280% | 0.0560% | 0.0320% | 0.0595% | 0.0340% | 0.0630% | 0.0360% | 0.0665% | 0.0380% | +| 1 | >5M | 0.060% | 0.030% | 0.0360% | 0.0180% | 0.0420% | 0.0210% | 0.0480% | 0.0240% | 0.0510% | 0.0255% | 0.0540% | 0.0270% | 0.0570% | 0.0285% | +| 2 | >25M | 0.050% | 0.020% | 0.0300% | 0.0120% | 0.0350% | 0.0140% | 0.0400% | 0.0160% | 0.0425% | 0.0170% | 0.0450% | 0.0180% | 0.0475% | 0.0190% | +| 3 | >100M | 0.040% | 0.010% | 0.0240% | 0.0060% | 0.0280% | 0.0070% | 0.0320% | 0.0080% | 0.0340% | 0.0085% | 0.0360% | 0.0090% | 0.0380% | 0.0095% | +| 4 | >500M | 0.035% | 0.000% | 0.0210% | 0.0000% | 0.0245% | 0.0000% | 0.0280% | 0.0000% | 0.0298% | 0.0000% | 0.0315% | 0.0000% | 0.0333% | 0.0000% | +| 5 | >2B | 0.030% | 0.000% | 0.0180% | 0.0000% | 0.0210% | 0.0000% | 0.0240% | 0.0000% | 0.0255% | 0.0000% | 0.0270% | 0.0000% | 0.0285% | 0.0000% | +| 6 | >7B | 0.025% | 0.000% | 0.0150% | 0.0000% | 0.0175% | 0.0000% | 0.0200% | 0.0000% | 0.0213% | 0.0000% | 0.0225% | 0.0000% | 0.0238% | 0.0000% | + +### Staking tiers + +| Tier | HYPE staked | Trading fee discount | +| -------- | ----------- | -------------------- | +| Wood | >10 | 5% | +| Bronze | >100 | 10% | +| Silver | >1,000 | 15% | +| Gold | >10,000 | 20% | +| Platinum | >100,000 | 30% | +| Diamond | >500,000 | 40% | + +### Maker rebates + +| Tier | 14d weighted maker volume | Maker fee | +| ---- | ------------------------- | --------- | +| 1 | >0.5% | -0.001% | +| 2 | >1.5% | -0.002% | +| 3 | >3.0% | -0.003% | + +On most other protocols, the team or insiders are the main beneficiaries of fees. On Hyperliquid, fees are entirely directed to the community (HLP, the assistance fund, and spot deployers). Spot deployers may choose to keep up to 50% of trading fees generated by their token. For security, the assistance fund holds a majority of its assets in HYPE, which is the most liquid native asset on the Hyperliquid L1. The assistance fund uses the system address `0xfefefefefefefefefefefefefefefefefefefefe` which operates entirely onchain as part of the L1 execution. The assistance fund requires validator quorum to use in special situations. + +### Staking linking + +A "staking user" and a "trading user" can be linked so that the staking user's HYPE staked can be attributed to the trading user's fees. A few important points to note: + +- The staking user will be able to unilaterally control the trading user. In particular, linking to a specific staking user essentially gives them full control of funds in the trading account. +- Linking is permanent. Unlinking is not supported. +- The staking user will not receive any staking-related fee discount after being linked. +- Linking requires the trading user to send an action first, and then the staking user to finalize the link. See "Link Staking" at app.hyperliquid.xyz/portfolio for details. +- No action is required if you plan to trade and stake from the same address. diff --git a/locales/languages/en.json b/locales/languages/en.json index 720be8004ae3..27bc443d6926 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1407,6 +1407,8 @@ "expect_more_volatility": "Expect more volatility outside of regular market hours", "after_hours_trading_banner": "After-hours trading", "pay_attention_to_volatility": "Pay attention to volatility and slippage", + "oi_cap_warning": "Market At Capacity", + "oi_cap_description": "This market has reached its open interest cap. New positions cannot be opened at this time.", "badge": { "experimental": "EXPERIMENTAL", "equity": "STOCK", diff --git a/package.json b/package.json index 39c673fd52a5..558178a0b286 100644 --- a/package.json +++ b/package.json @@ -292,7 +292,7 @@ "@metamask/tron-wallet-snap": "^1.5.1", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", - "@nktkas/hyperliquid": "^0.25.4", + "@nktkas/hyperliquid": "^0.25.7", "@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 d4156207fbfc..90a33d56a1d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9061,9 +9061,9 @@ __metadata: languageName: node linkType: hard -"@nktkas/hyperliquid@npm:^0.25.4": - version: 0.25.4 - resolution: "@nktkas/hyperliquid@npm:0.25.4" +"@nktkas/hyperliquid@npm:^0.25.7": + version: 0.25.7 + resolution: "@nktkas/hyperliquid@npm:0.25.7" dependencies: "@msgpack/msgpack": "npm:^3.1.2" "@noble/hashes": "npm:^2.0.0" @@ -9072,7 +9072,7 @@ __metadata: valibot: "npm:1.1.0" bin: hyperliquid: esm/bin/cli.js - checksum: 10/8006850f8772038f6e22ec2aafe1c7284d4af4ec68393bbb78cb993299beab65e1b0e3c75f42b2b537e7c4d5caea506b047a0731435a8955a7e5ee1f74a84dec + checksum: 10/673e004d406cd774fc7af215598503cd5e70fe08c4b54e9e1297bde8eb85a15a68dbe67d1d3e47d068ff7266390ad6fe67107950986c98a26b3f08028b308e3a languageName: node linkType: hard @@ -34381,7 +34381,7 @@ __metadata: "@metamask/tron-wallet-snap": "npm:^1.5.1" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6" - "@nktkas/hyperliquid": "npm:^0.25.4" + "@nktkas/hyperliquid": "npm:^0.25.7" "@noble/curves": "npm:1.9.6" "@notifee/react-native": "npm:^9.0.0" "@octokit/rest": "npm:^21.0.0" From 24a5ab23f5e0ca958d7e9a1f0e4384c98b14dc7c Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 07:46:53 +0800 Subject: [PATCH 02/21] cleanup --- .../PerpsMarketDetailsView.tsx | 5 - .../PerpsOICapWarning.styles.ts | 39 +++++ .../PerpsOICapWarning.test.tsx | 134 ++++++++++++++++++ .../PerpsOICapWarning/PerpsOICapWarning.tsx | 49 +------ .../PerpsOICapWarning.types.ts | 18 +++ .../components/PerpsOICapWarning/index.ts | 2 +- .../UI/Perps/hooks/usePerpsOICap.ts | 2 +- 7 files changed, 196 insertions(+), 53 deletions(-) create mode 100644 app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.styles.ts create mode 100644 app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.test.tsx create mode 100644 app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.types.ts diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index b25ee5d19d09..d7c2367d638b 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -686,11 +686,6 @@ const PerpsMarketDetailsView: React.FC = () => { testID={PerpsMarketDetailsViewSelectorsIDs.MARKET_HOURS_BANNER} /> - {/* OI Cap Warning - Shows when market is at capacity */} - {market?.symbol && ( - - )} - {/* Market Tabs Section */} { + const { colors } = params.theme; + + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + }, + bannerContainer: { + backgroundColor: colors.warning.muted, + borderRadius: 8, + padding: 12, + borderWidth: 1, + borderColor: colors.warning.default, + }, + inlineContainer: { + paddingVertical: 8, + }, + icon: { + marginTop: 2, + }, + textContainer: { + flex: 1, + gap: 4, + }, + title: { + fontWeight: '600', + }, + description: { + lineHeight: 18, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.test.tsx b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.test.tsx new file mode 100644 index 000000000000..e5c91b16ee20 --- /dev/null +++ b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PerpsOICapWarning from './PerpsOICapWarning'; +import { usePerpsOICap } from '../../hooks/usePerpsOICap'; + +// Mock the usePerpsOICap hook +jest.mock('../../hooks/usePerpsOICap'); + +describe('PerpsOICapWarning', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('should not render when not at cap', () => { + (usePerpsOICap as jest.Mock).mockReturnValue({ + isAtCap: false, + isLoading: false, + }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('perps-oi-cap-warning')).toBeNull(); + }); + + it('should not render when loading', () => { + (usePerpsOICap as jest.Mock).mockReturnValue({ + isAtCap: false, + isLoading: true, + }); + + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('perps-oi-cap-warning')).toBeNull(); + }); + + it('should render when at cap', () => { + (usePerpsOICap as jest.Mock).mockReturnValue({ + isAtCap: true, + isLoading: false, + }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId('perps-oi-cap-warning')).toBeTruthy(); + expect(getByText('Open Interest Cap Reached')).toBeTruthy(); + expect( + getByText( + 'This market is at capacity. New positions cannot be opened until open interest decreases.', + ), + ).toBeTruthy(); + }); + + it('should use custom testID when provided', () => { + (usePerpsOICap as jest.Mock).mockReturnValue({ + isAtCap: true, + isLoading: false, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-test-id')).toBeTruthy(); + }); + }); + + describe('variants', () => { + beforeEach(() => { + (usePerpsOICap as jest.Mock).mockReturnValue({ + isAtCap: true, + isLoading: false, + }); + }); + + it('should render inline variant by default', () => { + const { getByTestId } = render(); + + expect(getByTestId('perps-oi-cap-warning')).toBeTruthy(); + }); + + it('should render banner variant when specified', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('perps-oi-cap-warning')).toBeTruthy(); + }); + + it('should render inline variant when specified', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('perps-oi-cap-warning')).toBeTruthy(); + }); + }); + + describe('symbol handling', () => { + it('should work with standard symbols', () => { + (usePerpsOICap as jest.Mock).mockReturnValue({ + isAtCap: true, + isLoading: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('perps-oi-cap-warning')).toBeTruthy(); + expect(usePerpsOICap).toHaveBeenCalledWith('BTC'); + }); + + it('should work with HIP-3 symbols', () => { + (usePerpsOICap as jest.Mock).mockReturnValue({ + isAtCap: true, + isLoading: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('perps-oi-cap-warning')).toBeTruthy(); + expect(usePerpsOICap).toHaveBeenCalledWith('xyz:TSLA'); + }); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx index 566fde83ebb7..9adcdf93c0bc 100644 --- a/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx +++ b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx @@ -1,6 +1,5 @@ import React, { memo } from 'react'; -import { View, StyleSheet } from 'react-native'; -import type { Theme } from '../../../../../util/theme/models'; +import { View } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import Text, { TextVariant, @@ -12,50 +11,8 @@ import Icon, { IconColor, } from '../../../../../component-library/components/Icons/Icon'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; - -export interface PerpsOICapWarningProps { - /** Market symbol to check OI cap status for */ - symbol: string; - /** Variant determines the display style */ - variant?: 'inline' | 'banner'; - /** Optional test ID for testing */ - testID?: string; -} - -const styleSheet = (params: { theme: Theme }) => { - const { colors } = params.theme; - - return StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: 8, - }, - bannerContainer: { - backgroundColor: colors.warning.muted, - borderRadius: 8, - padding: 12, - borderWidth: 1, - borderColor: colors.warning.default, - }, - inlineContainer: { - paddingVertical: 8, - }, - icon: { - marginTop: 2, - }, - textContainer: { - flex: 1, - gap: 4, - }, - title: { - fontWeight: '600', - }, - description: { - lineHeight: 18, - }, - }); -}; +import type { PerpsOICapWarningProps } from './PerpsOICapWarning.types'; +import styleSheet from './PerpsOICapWarning.styles'; /** * Reusable component that displays a warning when a market is at its open interest cap diff --git a/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.types.ts b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.types.ts new file mode 100644 index 000000000000..2ca107b7ca7a --- /dev/null +++ b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.types.ts @@ -0,0 +1,18 @@ +export interface PerpsOICapWarningProps { + /** + * Market symbol to check OI cap status for + */ + symbol: string; + + /** + * Variant determines the display style + * - 'banner': Full-width prominent warning with background color + * - 'inline': Compact warning without background + */ + variant?: 'inline' | 'banner'; + + /** + * Optional test ID for testing + */ + testID?: string; +} diff --git a/app/components/UI/Perps/components/PerpsOICapWarning/index.ts b/app/components/UI/Perps/components/PerpsOICapWarning/index.ts index d919a03b823b..24808be7f0e9 100644 --- a/app/components/UI/Perps/components/PerpsOICapWarning/index.ts +++ b/app/components/UI/Perps/components/PerpsOICapWarning/index.ts @@ -1,2 +1,2 @@ export { default } from './PerpsOICapWarning'; -export type { PerpsOICapWarningProps } from './PerpsOICapWarning'; +export type { PerpsOICapWarningProps } from './PerpsOICapWarning.types'; diff --git a/app/components/UI/Perps/hooks/usePerpsOICap.ts b/app/components/UI/Perps/hooks/usePerpsOICap.ts index ef7efa894317..f6ec2cfc0bf2 100644 --- a/app/components/UI/Perps/hooks/usePerpsOICap.ts +++ b/app/components/UI/Perps/hooks/usePerpsOICap.ts @@ -22,7 +22,7 @@ import Engine from '../../../../core/Engine'; * const FORCE_OI_CAP_STATE: boolean | null = null; * ``` */ -const FORCE_OI_CAP_STATE: boolean | null = null; +const FORCE_OI_CAP_STATE: boolean | null = true; /** * Return type for usePerpsOICap hook From ac808a6bcee036a1fb83254e8a461e9fda2ca0fb Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 08:13:48 +0800 Subject: [PATCH 03/21] cleanup --- app/components/UI/Perps/hooks/usePerpsOICap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/UI/Perps/hooks/usePerpsOICap.ts b/app/components/UI/Perps/hooks/usePerpsOICap.ts index f6ec2cfc0bf2..ef7efa894317 100644 --- a/app/components/UI/Perps/hooks/usePerpsOICap.ts +++ b/app/components/UI/Perps/hooks/usePerpsOICap.ts @@ -22,7 +22,7 @@ import Engine from '../../../../core/Engine'; * const FORCE_OI_CAP_STATE: boolean | null = null; * ``` */ -const FORCE_OI_CAP_STATE: boolean | null = true; +const FORCE_OI_CAP_STATE: boolean | null = null; /** * Return type for usePerpsOICap hook From 485badf6b89ca19cb3aeb66ebb0d5a85e92187fa Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 08:18:32 +0800 Subject: [PATCH 04/21] revert pressable banner --- .../PerpsMarketHoursBanner.tsx | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx b/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx index b5efb323815f..383f02a92c68 100644 --- a/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHoursBanner/PerpsMarketHoursBanner.tsx @@ -69,40 +69,44 @@ const PerpsMarketHoursBanner: React.FC = ({ return ( - + - - - - {titleText} - - {subtitleText} - - + + + {titleText} + + {subtitleText} + + + - + - + ); }; From 4101e2cfae54a8d549fc642c2cbd01a83a4e3182 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 17:30:57 +0800 Subject: [PATCH 05/21] feat: hip-3 subscriptions support --- .../providers/HyperLiquidProvider.ts | 37 ++- .../UI/Perps/hooks/usePerpsOICap.ts | 19 +- .../UI/Perps/providers/PerpsStreamManager.tsx | 92 ++++++ .../HyperLiquidSubscriptionService.ts | 301 ++++++++---------- .../services/HyperLiquidWalletService.ts | 35 +- .../Perps/services/PerpsConnectionManager.ts | 2 + shim.js | 18 ++ 7 files changed, 308 insertions(+), 196 deletions(-) diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 1392e09b6933..867bbe31553d 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -176,6 +176,9 @@ export class HyperLiquidProvider implements IPerpsProvider { PERPS_ERROR_CODES.ORDER_LEVERAGE_REDUCTION_FAILED, }; + // Track whether clients have been initialized (lazy initialization) + private clientsInitialized = false; + constructor( options: { isTestnet?: boolean; @@ -203,8 +206,8 @@ export class HyperLiquidProvider implements IPerpsProvider { this.enabledDexs, ); - // Initialize clients - this.initializeClients(); + // NOTE: Clients are NOT initialized here - they'll be initialized lazily + // when first needed. This avoids accessing Engine.context before it's ready. // Debug: Confirm batch methods exist DevLogger.log('[HyperLiquidProvider] Constructor complete', { @@ -215,11 +218,24 @@ export class HyperLiquidProvider implements IPerpsProvider { } /** - * Initialize HyperLiquid SDK clients + * Initialize HyperLiquid SDK clients (lazy initialization) + * + * This is called on first API operation to ensure Engine.context is ready. + * Creating the wallet adapter requires accessing Engine.context.AccountTreeController, + * which may not be available during early app initialization. */ - private initializeClients(): void { + private ensureClientsInitialized(): void { + if (this.clientsInitialized) { + return; // Already initialized + } + const wallet = this.walletService.createWalletAdapter(); this.clientService.initialize(wallet); + this.clientsInitialized = true; + + DevLogger.log('[HyperLiquidProvider] Clients initialized lazily', { + timestamp: new Date().toISOString(), + }); } /** @@ -283,6 +299,10 @@ export class HyperLiquidProvider implements IPerpsProvider { * since HIP-3 configuration is immutable after construction */ private async ensureReady(): Promise { + // Lazy initialization: ensure clients are created (safe after Engine.context is ready) + this.ensureClientsInitialized(); + + // Verify clients are properly initialized this.clientService.ensureInitialized(); // Build asset mapping on first call only (flags are immutable) @@ -4130,8 +4150,8 @@ export class HyperLiquidProvider implements IPerpsProvider { this.clientService.setTestnetMode(newIsTestnet); this.walletService.setTestnetMode(newIsTestnet); - // Reinitialize clients - this.initializeClients(); + // Reset initialization flag so clients will be recreated on next use + this.clientsInitialized = false; return { success: true, @@ -4146,11 +4166,12 @@ export class HyperLiquidProvider implements IPerpsProvider { } /** - * Initialize provider + * Initialize provider (ensures clients are ready) */ async initialize(): Promise { try { - this.initializeClients(); + // Ensure clients are initialized (lazy initialization) + this.ensureClientsInitialized(); return { success: true, chainId: getChainId(this.clientService.isTestnetMode()), diff --git a/app/components/UI/Perps/hooks/usePerpsOICap.ts b/app/components/UI/Perps/hooks/usePerpsOICap.ts index ef7efa894317..dc421426f745 100644 --- a/app/components/UI/Perps/hooks/usePerpsOICap.ts +++ b/app/components/UI/Perps/hooks/usePerpsOICap.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import Engine from '../../../../core/Engine'; +import { usePerpsStream } from '../providers/PerpsStreamManager'; /** * Developer-only override for OI cap state testing @@ -41,9 +41,16 @@ export interface UsePerpsOICapReturn { /** * Hook to check if a market is at its open interest cap * + * Uses PerpsStreamManager for centralized subscription management. * Leverages the existing webData2 WebSocket subscription which includes * `perpsAtOpenInterestCap` field - zero additional network overhead. * + * **Architecture:** + * - Single shared WebSocket subscription for all component instances + * - Built-in caching for instant subsequent access + * - Automatic account switch handling via clearCache() + * - Consistent with other live data hooks (usePerpsLiveOrders, usePerpsLiveAccount) + * * @param symbol - Market symbol to check (e.g., 'BTC', 'xyz:TSLA') * @returns Object with isAtCap and isLoading flags * @@ -57,24 +64,24 @@ export interface UsePerpsOICapReturn { * ``` */ export const usePerpsOICap = (symbol?: string): UsePerpsOICapReturn => { + const streamManager = usePerpsStream(); const [oiCaps, setOICaps] = useState([]); const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { if (!symbol) return; - const controller = Engine.context.PerpsController; - - // Subscribe to OI cap updates from the controller - const unsubscribe = controller.subscribeToOICaps({ + // Subscribe through stream manager (single shared subscription) + const unsubscribe = streamManager.oiCaps.subscribe({ callback: (caps: string[]) => { setOICaps(caps); setIsInitialized(true); }, + throttleMs: 0, // No throttle for OI caps (low frequency updates) }); return unsubscribe; - }, [symbol]); + }, [streamManager, symbol]); // Check if the current symbol is in the OI caps list const isAtCap = useMemo(() => { diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index ca68c2462811..5e77b0e89e60 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -831,6 +831,97 @@ class AccountStreamChannel extends StreamChannel { } } +// Open Interest Cap channel for tracking markets at capacity +class OICapStreamChannel extends StreamChannel { + private prewarmUnsubscribe?: () => void; + + protected connect() { + if (this.wsSubscription) return; + + // Check if controller is reinitializing - wait before attempting connection + if (Engine.context.PerpsController.isCurrentlyReinitializing()) { + setTimeout( + () => this.connect(), + PERPS_CONSTANTS.RECONNECTION_CLEANUP_DELAY_MS, + ); + return; + } + + // Subscribe to OI cap updates (zero overhead - extracted from existing webData2) + this.wsSubscription = Engine.context.PerpsController.subscribeToOICaps({ + callback: (caps: string[]) => { + // Validate account context + const currentAccount = + getEvmAccountFromSelectedAccountGroup()?.address || null; + if (this.accountAddress && this.accountAddress !== currentAccount) { + Logger.error(new Error('OICapStreamChannel: Wrong account context'), { + expected: currentAccount, + received: this.accountAddress, + }); + return; + } + this.accountAddress = currentAccount; + + this.cache.set('oiCaps', caps); + this.notifySubscribers(caps); + }, + }); + } + + protected getCachedData(): string[] | null { + // Return null if no cache exists to distinguish from empty array + const cached = this.cache.get('oiCaps'); + return cached !== undefined ? cached : null; + } + + protected getClearedData(): string[] { + return []; + } + + /** + * Pre-warm the channel by creating a persistent subscription + * This keeps the WebSocket connection alive and caches data continuously + * @returns Cleanup function to call when leaving Perps environment + */ + public prewarm(): () => void { + if (this.prewarmUnsubscribe) { + DevLogger.log('OICapStreamChannel: Already pre-warmed'); + return this.prewarmUnsubscribe; + } + + // Create a real subscription with no-op callback to keep connection alive + this.prewarmUnsubscribe = this.subscribe({ + callback: () => { + // No-op callback - just keeps the connection alive for caching + }, + throttleMs: 0, // No throttle for pre-warm + }); + + // Return cleanup function that clears internal state + return () => { + DevLogger.log('OICapStreamChannel: Cleaning up prewarm subscription'); + this.cleanupPrewarm(); + }; + } + + /** + * Cleanup pre-warm subscription + */ + public cleanupPrewarm(): void { + if (this.prewarmUnsubscribe) { + this.prewarmUnsubscribe(); + this.prewarmUnsubscribe = undefined; + } + } + + public clearCache(): void { + // Cleanup pre-warm subscription + this.cleanupPrewarm(); + // Call parent clearCache + super.clearCache(); + } +} + // Market data channel for caching market list data class MarketDataChannel extends StreamChannel { private lastFetchTime = 0; @@ -1001,6 +1092,7 @@ export class PerpsStreamManager { public readonly fills = new FillStreamChannel(); public readonly account = new AccountStreamChannel(); public readonly marketData = new MarketDataChannel(); + public readonly oiCaps = new OICapStreamChannel(); // Future channels can be added here: // public readonly funding = new FundingStreamChannel(); diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index da8f95cdf599..e0daa312ddb4 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -1,7 +1,7 @@ import { type Subscription, type WsAllMidsEvent, - type WsWebData2Event, + type WsWebData3Event, type WsUserFillsEvent, type WsActiveAssetCtxEvent, type WsActiveSpotAssetCtxEvent, @@ -83,12 +83,13 @@ export class HyperLiquidSubscriptionService { private readonly symbolSubscriberCounts = new Map(); private readonly dexSubscriberCounts = new Map(); // Track subscribers per DEX for assetCtxs - // Multi-DEX webData2 subscriptions for positions and orders (HIP-3 support) - private readonly webData2Subscriptions = new Map(); // Key: dex name ('' for main) - private webData2SubscriptionPromise?: Promise; + // Multi-DEX webData3 subscription for all user data (positions, orders, account, OI caps) + private readonly webData3Subscriptions = new Map(); // Key: dex name ('' for main) + private webData3SubscriptionPromise?: Promise; private positionSubscriberCount = 0; private orderSubscriberCount = 0; private accountSubscriberCount = 0; + private oiCapSubscriberCount = 0; // Multi-DEX data caches private readonly dexPositionsCache = new Map(); // Per-DEX positions @@ -100,7 +101,7 @@ export class HyperLiquidSubscriptionService { private ordersCacheInitialized = false; // Track if orders cache has received WebSocket data private positionsCacheInitialized = false; // Track if positions cache has received WebSocket data - // OI Cap tracking (from webData2.perpsAtOpenInterestCap) + // OI Cap tracking (from webData3.perpDexStates[].perpsAtOpenInterestCap) private readonly oiCapSubscribers = new Set<(caps: string[]) => void>(); private cachedOICaps: string[] = []; private cachedOICapsHash = ''; @@ -257,40 +258,8 @@ export class HyperLiquidSubscriptionService { ); } - // Establish clearinghouseState subscriptions for new DEXs (for positions/account) - const hasPositionOrAccountSubscribers = - this.positionSubscriberCount > 0 || this.accountSubscriberCount > 0; - if (hasPositionOrAccountSubscribers) { - try { - const userAddress = - await this.walletService.getUserAddressWithDefault(); - await Promise.all( - newDexs.map(async (dex) => { - try { - await this.ensureClearinghouseStateSubscription( - userAddress, - dex, - ); - } catch (error) { - Logger.error( - ensureError(error), - this.getErrorContext( - 'updateFeatureFlags.ensureClearinghouseStateSubscription', - { - dex, - }, - ), - ); - } - }), - ); - } catch (error) { - Logger.error( - ensureError(error), - this.getErrorContext('updateFeatureFlags.getUserAddress'), - ); - } - } + // Note: webData3 automatically includes all DEX data, so no separate + // subscription setup needed for positions/orders/account data } } @@ -672,63 +641,43 @@ export class HyperLiquidSubscriptionService { } /** - * Ensure shared webData2 subscription is active (singleton pattern with multi-DEX support) - * For main DEX: uses webData2 (richer data with orders) - * For HIP-3 DEXs: uses clearinghouseState (positions and account state only) + * Ensure shared webData3 subscription is active (singleton pattern with multi-DEX support) + * webData3 provides data for all DEXs (main + HIP-3) in a single subscription */ - private async ensureSharedWebData2Subscription( + private async ensureSharedWebData3Subscription( accountId?: CaipAccountId, ): Promise { - const enabledDexs = this.getEnabledDexs(); - const userAddress = - await this.walletService.getUserAddressWithDefault(accountId); - - // Establish webData2 subscription for main DEX (if not exists) - if (!this.webData2Subscriptions.has('')) { - if (!this.webData2SubscriptionPromise) { - this.webData2SubscriptionPromise = - this.createWebData2Subscription(accountId); + // Establish webData3 subscription (if not exists) + if (!this.webData3Subscriptions.has('')) { + if (!this.webData3SubscriptionPromise) { + this.webData3SubscriptionPromise = + this.createWebData3Subscription(accountId); try { - await this.webData2SubscriptionPromise; + await this.webData3SubscriptionPromise; } catch (error) { - this.webData2SubscriptionPromise = undefined; + this.webData3SubscriptionPromise = undefined; throw error; } } else { - await this.webData2SubscriptionPromise; + await this.webData3SubscriptionPromise; } } - - // HIP-3: Establish clearinghouseState subscriptions for HIP-3 DEXs - const hip3Dexs = enabledDexs.filter((dex): dex is string => dex !== null); - await Promise.all( - hip3Dexs.map(async (dex) => { - const dexName = dex; - try { - await this.ensureClearinghouseStateSubscription(userAddress, dexName); - } catch (error) { - Logger.error( - ensureError(error), - this.getErrorContext( - 'ensureSharedWebData2Subscription.clearinghouseState', - { - dex: dexName, - }, - ), - ); - } - }), - ); + // Note: webData3 includes all DEX data, so no separate HIP-3 subscriptions needed } /** - * Create webData2 subscription for the main DEX only + * Create webData3 subscription for all DEXs (main + HIP-3) + * + * webData3 provides perpDexStates[] array containing data for all DEXs: + * - Index 0: Main DEX (dexName = '') + * - Index 1+: HIP-3 DEXs in order of enabledDexs array * - * NOTE: HyperLiquid SDK's webData2() only supports the main DEX (no dex parameter). - * For HIP-3 DEX position/order data, use REST API polling via getAccountState(). + * This replaces both webData2 and clearinghouseState subscriptions, + * providing positions, orders, account states, and OI caps for all DEXs + * in a single subscription. */ - private async createWebData2Subscription( + private async createWebData3Subscription( accountId?: CaipAccountId, ): Promise { this.clientService.ensureSubscriptionClient( @@ -743,67 +692,93 @@ export class HyperLiquidSubscriptionService { const userAddress = await this.walletService.getUserAddressWithDefault(accountId); - // Only subscribe to main DEX (webData2 doesn't support dex parameter) - const dexName = ''; // Main DEX + const dexName = ''; // Use empty string as key for single webData3 subscription // Skip if subscription already exists - if (this.webData2Subscriptions.has(dexName)) { + if (this.webData3Subscriptions.has(dexName)) { return; } return new Promise((resolve, reject) => { subscriptionClient - .webData2({ user: userAddress }, (data: WsWebData2Event) => { - // Extract and process positions for this DEX - const positions = data.clearinghouseState.assetPositions - .filter((assetPos) => assetPos.position.szi !== '0') - .map((assetPos) => adaptPositionFromSDK(assetPos)); - - // Extract TP/SL from orders and process orders using shared helper - const { - tpslMap, - tpslCountMap, - processedOrders: orders, - } = this.extractTPSLFromOrders(data.openOrders || [], positions); - - // Merge TP/SL data into positions using shared helper - const positionsWithTPSL = this.mergeTPSLIntoPositions( - positions, - tpslMap, - tpslCountMap, - ); + .webData3({ user: userAddress }, (data: WsWebData3Event) => { + const enabledDexs = this.getEnabledDexs(); - // Extract account data for this DEX - const accountState: AccountState = adaptAccountStateFromSDK( - data.clearinghouseState, - data.spotState, - ); + // Process data from each DEX in perpDexStates array + data.perpDexStates.forEach((dexState, index) => { + const currentDexName = enabledDexs[index] || ''; // null -> '' - // Extract OI caps from webData2 (only available on main DEX, dexName === '') - // perpsAtOpenInterestCap is an array of asset symbols that are at capacity - if (dexName === '') { - const oiCaps = data.perpsAtOpenInterestCap || []; - const oiCapsHash = JSON.stringify(oiCaps.sort()); + // Extract and process positions for this DEX + const positions = dexState.clearinghouseState.assetPositions + .filter((assetPos) => assetPos.position.szi !== '0') + .map((assetPos) => adaptPositionFromSDK(assetPos)); + + // Extract TP/SL from orders and process orders using shared helper + const { + tpslMap, + tpslCountMap, + processedOrders: orders, + } = this.extractTPSLFromOrders( + dexState.openOrders || [], + positions, + ); - if (oiCapsHash !== this.cachedOICapsHash) { - this.cachedOICaps = oiCaps; - this.cachedOICapsHash = oiCapsHash; - this.oiCapsCacheInitialized = true; + // Merge TP/SL data into positions using shared helper + const positionsWithTPSL = this.mergeTPSLIntoPositions( + positions, + tpslMap, + tpslCountMap, + ); - DevLogger.log('OI Caps Updated', { - oiCaps, - count: oiCaps.length, - }); + // Extract account data for this DEX + // Note: spotState is not included in webData3 + const accountState: AccountState = adaptAccountStateFromSDK( + dexState.clearinghouseState, + undefined, // webData3 doesn't include spotState + ); + + // Store per-DEX data in caches + this.dexPositionsCache.set(currentDexName, positionsWithTPSL); + this.dexOrdersCache.set(currentDexName, orders); + this.dexAccountCache.set(currentDexName, accountState); + }); + + // Extract OI caps from all DEXs (main + HIP-3) + const allOICaps: string[] = []; + data.perpDexStates.forEach((dexState, index) => { + const currentDexName = enabledDexs[index]; + const oiCaps = dexState.perpsAtOpenInterestCap || []; - // Notify all subscribers - this.oiCapSubscribers.forEach((callback) => callback(oiCaps)); + // Add DEX prefix for HIP-3 symbols (e.g., "xyz:TSLA") + if (currentDexName) { + allOICaps.push( + ...oiCaps.map((symbol) => `${currentDexName}:${symbol}`), + ); + } else { + // Main DEX - no prefix needed + allOICaps.push(...oiCaps); } - } + }); - // Store per-DEX data in caches - this.dexPositionsCache.set(dexName, positionsWithTPSL); - this.dexOrdersCache.set(dexName, orders); - this.dexAccountCache.set(dexName, accountState); + // Update OI caps cache and notify if changed + const oiCapsHash = allOICaps.sort().join(','); + if (oiCapsHash !== this.cachedOICapsHash) { + this.cachedOICaps = allOICaps; + this.cachedOICapsHash = oiCapsHash; + this.oiCapsCacheInitialized = true; + + DevLogger.log('OI Caps Updated (all DEXs)', { + oiCaps: allOICaps, + count: allOICaps.length, + perDex: data.perpDexStates.map((dexState, index) => ({ + dex: enabledDexs[index] || 'main', + caps: dexState.perpsAtOpenInterestCap || [], + })), + }); + + // Notify all subscribers + this.oiCapSubscribers.forEach((callback) => callback(allOICaps)); + } // Aggregate data from all DEX caches const aggregatedPositions = Array.from( @@ -851,16 +826,16 @@ export class HyperLiquidSubscriptionService { } }) .then((sub) => { - this.webData2Subscriptions.set(dexName, sub); + this.webData3Subscriptions.set(dexName, sub); DevLogger.log( - `webData2 subscription established for main DEX (HIP-3 DEXs use REST API polling)`, + `webData3 subscription established for all DEXs (main + HIP-3)`, ); resolve(); }) .catch((error) => { Logger.error( ensureError(error), - this.getErrorContext('createWebData2Subscription', { + this.getErrorContext('createWebData3Subscription', { dex: dexName, }), ); @@ -870,23 +845,24 @@ export class HyperLiquidSubscriptionService { } /** - * Clean up all webData2 and clearinghouseState subscriptions when no longer needed (multi-DEX support) + * Clean up webData3 subscription when no longer needed */ - private cleanupSharedWebData2Subscription(): void { + private cleanupSharedWebData3Subscription(): void { const totalSubscribers = this.positionSubscriberCount + this.orderSubscriberCount + - this.accountSubscriberCount; + this.accountSubscriberCount + + this.oiCapSubscriberCount; if (totalSubscribers <= 0) { - // Cleanup webData2 subscriptions (main DEX) - if (this.webData2Subscriptions.size > 0) { - this.webData2Subscriptions.forEach((subscription, dexName) => { + // Cleanup webData3 subscription (covers all DEXs) + if (this.webData3Subscriptions.size > 0) { + this.webData3Subscriptions.forEach((subscription, dexName) => { subscription.unsubscribe().catch((error: Error) => { Logger.error( ensureError(error), this.getErrorContext( - 'cleanupSharedWebData2Subscription.webData2', + 'cleanupSharedWebData3Subscription.webData3', { dex: dexName, }, @@ -894,25 +870,17 @@ export class HyperLiquidSubscriptionService { ); }); }); - this.webData2Subscriptions.clear(); - this.webData2SubscriptionPromise = undefined; + this.webData3Subscriptions.clear(); + this.webData3SubscriptionPromise = undefined; } - // HIP-3: Cleanup clearinghouseState subscriptions (HIP-3 DEXs) - if (this.clearinghouseStateSubscriptions.size > 0) { - const enabledDexs = this.getEnabledDexs(); - const hip3Dexs = enabledDexs.filter( - (dex): dex is string => dex !== null, - ); - hip3Dexs.forEach((dex) => { - this.cleanupClearinghouseStateSubscription(dex); - }); - } + // Note: No separate clearinghouseState cleanup needed (webData3 handles all DEXs) // Clear subscriber counts this.positionSubscriberCount = 0; this.orderSubscriberCount = 0; this.accountSubscriberCount = 0; + this.oiCapSubscriberCount = 0; // Clear per-DEX caches this.dexPositionsCache.clear(); @@ -956,7 +924,7 @@ export class HyperLiquidSubscriptionService { } // Ensure shared subscription is active - this.ensureSharedWebData2Subscription(accountId).catch((error) => { + this.ensureSharedWebData3Subscription(accountId).catch((error) => { Logger.error( ensureError(error), this.getErrorContext('subscribeToPositions'), @@ -966,7 +934,7 @@ export class HyperLiquidSubscriptionService { return () => { unsubscribe(); this.positionSubscriberCount--; - this.cleanupSharedWebData2Subscription(); + this.cleanupSharedWebData3Subscription(); }; } @@ -986,20 +954,27 @@ export class HyperLiquidSubscriptionService { callback, ); + // Increment OI cap subscriber count + this.oiCapSubscriberCount++; + // Immediately provide cached data if available if (this.cachedOICaps) { callback(this.cachedOICaps); } - // Ensure webData2 subscription is active (OI caps come from webData2) - this.ensureSharedWebData2Subscription(accountId).catch((error) => { + // Ensure webData3 subscription is active (OI caps come from webData3) + this.ensureSharedWebData3Subscription(accountId).catch((error) => { Logger.error( ensureError(error), this.getErrorContext('subscribeToOICaps'), ); }); - return unsubscribe; + return () => { + unsubscribe(); + this.oiCapSubscriberCount--; + this.cleanupSharedWebData3Subscription(); + }; } /** @@ -1116,7 +1091,7 @@ export class HyperLiquidSubscriptionService { } // Ensure shared subscription is active - this.ensureSharedWebData2Subscription(accountId).catch((error) => { + this.ensureSharedWebData3Subscription(accountId).catch((error) => { Logger.error( ensureError(error), this.getErrorContext('subscribeToOrders'), @@ -1126,7 +1101,7 @@ export class HyperLiquidSubscriptionService { return () => { unsubscribe(); this.orderSubscriberCount--; - this.cleanupSharedWebData2Subscription(); + this.cleanupSharedWebData3Subscription(); }; } @@ -1150,7 +1125,7 @@ export class HyperLiquidSubscriptionService { } // Ensure shared subscription is active (reuses existing connection) - this.ensureSharedWebData2Subscription(accountId).catch((error) => { + this.ensureSharedWebData3Subscription(accountId).catch((error) => { Logger.error( ensureError(error), this.getErrorContext('subscribeToAccount'), @@ -1160,7 +1135,7 @@ export class HyperLiquidSubscriptionService { return () => { unsubscribe(); this.accountSubscriberCount--; - this.cleanupSharedWebData2Subscription(); + this.cleanupSharedWebData3Subscription(); }; } @@ -2013,17 +1988,15 @@ export class HyperLiquidSubscriptionService { this.globalAllMidsSubscription = undefined; this.globalActiveAssetSubscriptions.clear(); this.globalL2BookSubscriptions.clear(); - this.webData2Subscriptions.clear(); - this.webData2SubscriptionPromise = undefined; + this.webData3Subscriptions.clear(); + this.webData3SubscriptionPromise = undefined; - // HIP-3: Clear new subscription types + // HIP-3: Clear assetCtxs subscriptions (clearinghouseState no longer needed with webData3) this.assetCtxsSubscriptions.clear(); this.assetCtxsSubscriptionPromises.clear(); - this.clearinghouseStateSubscriptions.clear(); - this.clearinghouseStateSubscriptionPromises.clear(); DevLogger.log( - 'HyperLiquid: Subscription service cleared (multi-DEX + HIP-3)', + 'HyperLiquid: Subscription service cleared (multi-DEX with webData3)', { timestamp: new Date().toISOString(), }, diff --git a/app/components/UI/Perps/services/HyperLiquidWalletService.ts b/app/components/UI/Perps/services/HyperLiquidWalletService.ts index cc74d594d748..fe18845e657d 100644 --- a/app/components/UI/Perps/services/HyperLiquidWalletService.ts +++ b/app/components/UI/Perps/services/HyperLiquidWalletService.ts @@ -4,13 +4,12 @@ import { type Hex, isValidHexAddress, } from '@metamask/utils'; -import { store } from '../../../../store'; -import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; import Engine from '../../../../core/Engine'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import { getChainId } from '../constants/hyperLiquidConfig'; import { strings } from '../../../../../locales/i18n'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import { getEvmAccountFromSelectedAccountGroup } from '../utils/accountUtils'; /** * Service for MetaMask wallet integration with HyperLiquid SDK @@ -28,6 +27,7 @@ export class HyperLiquidWalletService { * Required by @nktkas/hyperliquid SDK for signing transactions */ public createWalletAdapter(): { + address: Hex; signTypedData: (params: { domain: { name: string; @@ -43,7 +43,17 @@ export class HyperLiquidWalletService { }) => Promise; getChainId?: () => Promise; } { + // Get current EVM account using the standardized utility + const evmAccount = getEvmAccountFromSelectedAccountGroup(); + + if (!evmAccount?.address) { + throw new Error(strings('perps.errors.noAccountSelected')); + } + + const address = evmAccount.address as Hex; + return { + address, signTypedData: async (params: { domain: { name: string; @@ -57,16 +67,7 @@ export class HyperLiquidWalletService { primaryType: string; message: Record; }): Promise => { - const selectedEvmAccount = selectSelectedInternalAccountByScope( - store.getState(), - )('eip155:1'); - - if (!selectedEvmAccount?.address) { - throw new Error(strings('perps.errors.noAccountSelected')); - } - - const address = selectedEvmAccount.address; - + // Use address from outer scope (already validated when adapter was created) // Construct EIP-712 typed data const typedData = { domain: params.domain, @@ -99,19 +100,17 @@ export class HyperLiquidWalletService { } /** - * Get current account ID from Redux store + * Get current account ID using the standardized account utility */ public async getCurrentAccountId(): Promise { - const selectedEvmAccount = selectSelectedInternalAccountByScope( - store.getState(), - )('eip155:1'); + const evmAccount = getEvmAccountFromSelectedAccountGroup(); - if (!selectedEvmAccount?.address) { + if (!evmAccount?.address) { throw new Error(strings('perps.errors.noAccountSelected')); } const chainId = getChainId(this.isTestnet); - const caipAccountId: CaipAccountId = `eip155:${chainId}:${selectedEvmAccount.address}`; + const caipAccountId: CaipAccountId = `eip155:${chainId}:${evmAccount.address}`; return caipAccountId; } diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index 97922dffc917..d7d2e4751600 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -857,6 +857,7 @@ class PerpsConnectionManagerClass { const orderCleanup = streamManager.orders.prewarm(); const accountCleanup = streamManager.account.prewarm(); const marketDataCleanup = streamManager.marketData.prewarm(); + const oiCapCleanup = streamManager.oiCaps.prewarm(); // Portfolio balance updates are now handled by usePerpsPortfolioBalance via usePerpsLiveAccount @@ -869,6 +870,7 @@ class PerpsConnectionManagerClass { orderCleanup, accountCleanup, marketDataCleanup, + oiCapCleanup, priceCleanup, ); diff --git a/shim.js b/shim.js index 9f3266778e8f..85f4e7d221a0 100644 --- a/shim.js +++ b/shim.js @@ -131,6 +131,24 @@ if (typeof global.Promise.withResolvers === 'undefined') { }; } +// FinalizationRegistry polyfill for Hyperliquid SDK +// The SDK uses this for automatic cleanup of request queues when they're garbage collected +// In React Native, we provide a no-op implementation since the GC behavior differs +if (typeof global.FinalizationRegistry === 'undefined') { + global.FinalizationRegistry = class FinalizationRegistry { + constructor(callback) { + this.callback = callback; + } + register() { + // No-op: React Native doesn't need GC-based cleanup + // Request queues are short-lived and will be cleaned up naturally + } + unregister() { + // No-op + } + }; +} + // global.location = global.location || { port: 80 } const isDev = typeof __DEV__ === 'boolean' && __DEV__; Object.assign(process.env, { NODE_ENV: isDev ? 'development' : 'production' }); From aa52c83f6c592857e09676487557a849c71261db Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 21:28:39 +0800 Subject: [PATCH 06/21] feat: cleanup --- .../UI/Perps/controllers/PerpsController.ts | 2 +- .../providers/HyperLiquidProvider.ts | 25 ++++++++++++++----- .../UI/Perps/hooks/usePerpsOICap.ts | 2 +- .../UI/Perps/providers/PerpsStreamManager.tsx | 6 ++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 5f28241cab63..e22bbe757483 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -3671,7 +3671,7 @@ export class PerpsController extends BaseController< /** * Subscribe to open interest cap updates - * Zero additional network overhead - data comes from existing webData2 subscription + * Zero additional network overhead - data comes from existing webData3 subscription */ subscribeToOICaps(params: { accountId?: CaipAccountId; diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 867bbe31553d..fe77a20d2705 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -229,13 +229,26 @@ export class HyperLiquidProvider implements IPerpsProvider { return; // Already initialized } - const wallet = this.walletService.createWalletAdapter(); - this.clientService.initialize(wallet); - this.clientsInitialized = true; + try { + const wallet = this.walletService.createWalletAdapter(); + this.clientService.initialize(wallet); + // Only set flag AFTER successful initialization + this.clientsInitialized = true; - DevLogger.log('[HyperLiquidProvider] Clients initialized lazily', { - timestamp: new Date().toISOString(), - }); + DevLogger.log('[HyperLiquidProvider] Clients initialized lazily', { + timestamp: new Date().toISOString(), + }); + } catch (error) { + // Reset flag to allow retry on next call + this.clientsInitialized = false; + // Log error with context + Logger.error( + ensureError(error), + this.getErrorContext('ensureClientsInitialized'), + ); + // Rethrow to propagate to caller + throw error; + } } /** diff --git a/app/components/UI/Perps/hooks/usePerpsOICap.ts b/app/components/UI/Perps/hooks/usePerpsOICap.ts index dc421426f745..37077fdf312f 100644 --- a/app/components/UI/Perps/hooks/usePerpsOICap.ts +++ b/app/components/UI/Perps/hooks/usePerpsOICap.ts @@ -42,7 +42,7 @@ export interface UsePerpsOICapReturn { * Hook to check if a market is at its open interest cap * * Uses PerpsStreamManager for centralized subscription management. - * Leverages the existing webData2 WebSocket subscription which includes + * Leverages the existing webData3 WebSocket subscription which includes * `perpsAtOpenInterestCap` field - zero additional network overhead. * * **Architecture:** diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 5e77b0e89e60..4b3cdab51c26 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -420,7 +420,7 @@ class OrderStreamChannel extends StreamChannel { // Track WebSocket connection start time for duration calculation this.wsConnectionStartTime = performance.now(); - // This calls HyperLiquidSubscriptionService.subscribeToOrders which uses shared webData2 + // This calls HyperLiquidSubscriptionService.subscribeToOrders which uses shared webData3 this.wsSubscription = Engine.context.PerpsController.subscribeToOrders({ callback: (orders: Order[]) => { // Validate account context @@ -556,7 +556,7 @@ class PositionStreamChannel extends StreamChannel { // Track WebSocket connection start time for duration calculation this.wsConnectionStartTime = performance.now(); - // This calls HyperLiquidSubscriptionService.subscribeToPositions which uses shared webData2 + // This calls HyperLiquidSubscriptionService.subscribeToPositions which uses shared webData3 this.wsSubscription = Engine.context.PerpsController.subscribeToPositions({ callback: (positions: Position[]) => { // Validate account context @@ -847,7 +847,7 @@ class OICapStreamChannel extends StreamChannel { return; } - // Subscribe to OI cap updates (zero overhead - extracted from existing webData2) + // Subscribe to OI cap updates (zero overhead - extracted from existing webData3) this.wsSubscription = Engine.context.PerpsController.subscribeToOICaps({ callback: (caps: string[]) => { // Validate account context From 9fc68aaba9101502732c5476ade4ed5772011ba4 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 21:42:33 +0800 Subject: [PATCH 07/21] refactor: streamline client initialization in HyperLiquidProvider and enhance cache management in PerpsConnectionManager - Simplified the client initialization process in HyperLiquidProvider by removing error handling and logging, ensuring the clientsInitialized flag is set only after successful initialization. - Added cache clearing for oiCaps in PerpsConnectionManager to improve data management during reconnections. --- .../providers/HyperLiquidProvider.ts | 25 +++++-------------- .../Perps/services/PerpsConnectionManager.ts | 2 ++ 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index fe77a20d2705..741668466cc8 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -229,26 +229,13 @@ export class HyperLiquidProvider implements IPerpsProvider { return; // Already initialized } - try { - const wallet = this.walletService.createWalletAdapter(); - this.clientService.initialize(wallet); - // Only set flag AFTER successful initialization - this.clientsInitialized = true; + const wallet = this.walletService.createWalletAdapter(); + this.clientService.initialize(wallet); - DevLogger.log('[HyperLiquidProvider] Clients initialized lazily', { - timestamp: new Date().toISOString(), - }); - } catch (error) { - // Reset flag to allow retry on next call - this.clientsInitialized = false; - // Log error with context - Logger.error( - ensureError(error), - this.getErrorContext('ensureClientsInitialized'), - ); - // Rethrow to propagate to caller - throw error; - } + // Only set flag AFTER successful initialization + this.clientsInitialized = true; + + DevLogger.log('[HyperLiquidProvider] Clients initialized lazily'); } /** diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index d7d2e4751600..b6d3971b895a 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -110,6 +110,7 @@ class PerpsConnectionManagerClass { streamManager.account.clearCache(); streamManager.prices.clearCache(); streamManager.marketData.clearCache(); + streamManager.oiCaps.clearCache(); // Force the controller to reconnect with new account // This ensures proper WebSocket reconnection at the controller level @@ -663,6 +664,7 @@ class PerpsConnectionManagerClass { streamManager.orders.clearCache(); streamManager.account.clearCache(); streamManager.marketData.clearCache(); + streamManager.oiCaps.clearCache(); setMeasurement( PerpsMeasurementName.PERPS_RECONNECTION_CLEANUP, performance.now() - cleanupStart, From 4bad1aafdf6cda54be4fdc2e6e213ae6da73cd3e Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 21:58:35 +0800 Subject: [PATCH 08/21] dev warning --- app/components/UI/Perps/hooks/usePerpsOICap.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/components/UI/Perps/hooks/usePerpsOICap.ts b/app/components/UI/Perps/hooks/usePerpsOICap.ts index 37077fdf312f..481a04b7b648 100644 --- a/app/components/UI/Perps/hooks/usePerpsOICap.ts +++ b/app/components/UI/Perps/hooks/usePerpsOICap.ts @@ -87,6 +87,9 @@ export const usePerpsOICap = (symbol?: string): UsePerpsOICapReturn => { const isAtCap = useMemo(() => { // Developer override (only in __DEV__ builds) if (__DEV__ && FORCE_OI_CAP_STATE !== null) { + console.warn( + `[usePerpsOICap] Developer override active: isAtCap=${FORCE_OI_CAP_STATE}`, + ); return FORCE_OI_CAP_STATE; } From 6b73705caa03a93c967013da326b7df1cd6b1eb1 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sun, 2 Nov 2025 22:43:11 +0800 Subject: [PATCH 09/21] fix: unit tests --- .../PerpsMarketDetailsView.test.tsx | 14 +- .../PerpsOrderView/PerpsOrderView.test.tsx | 3 + .../HyperLiquidSubscriptionService.test.ts | 823 ++++++++---------- .../services/HyperLiquidWalletService.test.ts | 45 +- .../services/PerpsConnectionManager.test.ts | 1 + 5 files changed, 424 insertions(+), 462 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 7c9a3377f596..8714eb092f66 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -37,7 +37,19 @@ jest.mock('react-native/Libraries/Linking/Linking', () => ({ })); // Mock PerpsStreamManager -jest.mock('../../providers/PerpsStreamManager'); +jest.mock('../../providers/PerpsStreamManager', () => ({ + usePerpsStream: jest.fn(() => ({ + prices: { subscribeToSymbols: jest.fn(() => jest.fn()) }, + positions: { subscribe: jest.fn(() => jest.fn()) }, + orders: { subscribe: jest.fn(() => jest.fn()) }, + fills: { subscribe: jest.fn(() => jest.fn()) }, + account: { subscribe: jest.fn(() => jest.fn()) }, + marketData: { subscribe: jest.fn(() => jest.fn()), getMarkets: jest.fn() }, + oiCaps: { subscribe: jest.fn(() => jest.fn()) }, + })), + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, +})); // Mock Redux selectors for chart preferences jest.mock('../../selectors/chartPreferences', () => ({ diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 24fe83a168b5..5975f6f0ca12 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -575,6 +575,9 @@ const createMockStreamManager = () => { subscribe: jest.fn(() => jest.fn()), getMarkets: jest.fn(), }, + oiCaps: { + subscribe: jest.fn(() => jest.fn()), + }, }; }; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts index f4714d1f5d14..621b5aff824a 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts @@ -134,31 +134,36 @@ describe('HyperLiquidSubscriptionService', () => { }, 0); return Promise.resolve(mockSubscription); }), - webData2: jest.fn((_params: any, callback: any) => { - // Simulate position and order data + webData3: jest.fn((_params: any, callback: any) => { + // Simulate webData3 data with perpDexStates structure // First callback immediately setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '0.1' }, - coin: 'BTC', - }, - ], - }, - openOrders: [ + perpDexStates: [ { - oid: 12345, - coin: 'BTC', - side: 'B', - sz: '0.5', - origSz: '1.0', - limitPx: '50000', - orderType: 'Limit', - timestamp: 1234567890000, - isTrigger: false, - reduceOnly: false, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.1' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12345, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '1.0', + limitPx: '50000', + orderType: 'Limit', + timestamp: 1234567890000, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -167,26 +172,31 @@ describe('HyperLiquidSubscriptionService', () => { // Second callback with changed data to ensure updates are triggered setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '0.2' }, // Changed position size - coin: 'BTC', - }, - ], - }, - openOrders: [ + perpDexStates: [ { - oid: 12346, // Changed order ID - coin: 'BTC', - side: 'S', - sz: '0.3', - origSz: '0.5', - limitPx: '51000', - orderType: 'Limit', - timestamp: 1234567890001, - isTrigger: false, - reduceOnly: false, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '0.2' }, // Changed position size + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 12346, // Changed order ID + coin: 'BTC', + side: 'S', + sz: '0.3', + origSz: '0.5', + limitPx: '51000', + orderType: 'Limit', + timestamp: 1234567890001, + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -214,6 +224,8 @@ describe('HyperLiquidSubscriptionService', () => { return Promise.resolve(mockSubscription); }), l2Book: jest.fn().mockResolvedValue(mockSubscription), + clearinghouseState: jest.fn(() => Promise.resolve(mockSubscription)), + assetCtxs: jest.fn(() => Promise.resolve(mockSubscription)), }; mockWalletAdapter = { @@ -353,10 +365,10 @@ describe('HyperLiquidSubscriptionService', () => { params.accountId, ); - // Wait for async operations - await new Promise((resolve) => setTimeout(resolve, 10)); + // Wait for async operations (webData3 subscription setup) + await new Promise((resolve) => setTimeout(resolve, 50)); - expect(mockSubscriptionClient.webData2).toHaveBeenCalledWith( + expect(mockSubscriptionClient.webData3).toHaveBeenCalledWith( { user: '0x123' }, expect.any(Function), ); @@ -380,7 +392,7 @@ describe('HyperLiquidSubscriptionService', () => { // Wait for async operations await new Promise((resolve) => setTimeout(resolve, 10)); - expect(mockSubscriptionClient.webData2).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); expect(typeof unsubscribe).toBe('function'); }); @@ -398,23 +410,29 @@ describe('HyperLiquidSubscriptionService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(typeof unsubscribe).toBe('function'); - expect(mockSubscriptionClient.webData2).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); }); it('should filter out zero-size positions', async () => { const mockCallback = jest.fn(); - // Mock webData2 with mixed positions - mockSubscriptionClient.webData2.mockImplementation( + // Mock webData3 with mixed positions + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { position: { szi: '0.1' }, coin: 'BTC' }, // Should be included - { position: { szi: '0' }, coin: 'ETH' }, // Should be filtered out - ], - }, + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { position: { szi: '0.1' }, coin: 'BTC' }, // Should be included + { position: { szi: '0' }, coin: 'ETH' }, // Should be filtered out + ], + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], }); }, 0); return Promise.resolve({ @@ -562,8 +580,8 @@ describe('HyperLiquidSubscriptionService', () => { }); }); - describe('Shared WebData2 Subscription', () => { - it('should share webData2 subscription between positions and orders', async () => { + describe('Shared WebData3 Subscription', () => { + it('should share webData3 subscription between positions and orders', async () => { const positionCallback = jest.fn(); const orderCallback = jest.fn(); @@ -578,24 +596,24 @@ describe('HyperLiquidSubscriptionService', () => { }); // Wait for subscription to be established and initial callback - // This will trigger the first webData2 callback which caches both positions and orders + // This will trigger the first webData3 callback which caches both positions and orders await new Promise((resolve) => setTimeout(resolve, 20)); // Verify position callback was called expect(positionCallback).toHaveBeenCalled(); - // Subscribe to orders - should reuse same webData2 subscription + // Subscribe to orders - should reuse same webData3 subscription // and immediately get cached data const unsubscribeOrders = service.subscribeToOrders({ callback: orderCallback, }); // Orders should get cached data immediately (synchronously) - // or after the second webData2 update with changed data + // or after the second webData3 update with changed data await new Promise((resolve) => setTimeout(resolve, 20)); - // Should only call webData2 once for shared subscription - expect(mockSubscriptionClient.webData2).toHaveBeenCalledTimes(1); + // Should only call webData3 once for shared subscription + expect(mockSubscriptionClient.webData3).toHaveBeenCalledTimes(1); // Both callbacks should be called with their respective data expect(positionCallback).toHaveBeenCalled(); @@ -625,15 +643,20 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe1(); // Second callback should still receive updates - mockSubscriptionClient.webData2.mock.calls[0][1]({ - clearinghouseState: { - assetPositions: [ - { - position: { coin: 'BTC', szi: '1.0' }, + mockSubscriptionClient.webData3.mock.calls[0][1]({ + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { coin: 'BTC', szi: '1.0' }, + }, + ], }, - ], - }, - openOrders: [], + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], }); expect(positionCallback2).toHaveBeenCalled(); @@ -644,31 +667,36 @@ describe('HyperLiquidSubscriptionService', () => { it('should cache positions and orders data', async () => { const positionCallback = jest.fn(); - // Setup webData2 mock to call callback with data - mockSubscriptionClient.webData2.mockImplementation( + // Setup webData3 mock to call callback with data + mockSubscriptionClient.webData3.mockImplementation( (_addr: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '1.0' }, - coin: 'BTC', - }, - ], - }, - openOrders: [ + perpDexStates: [ { - oid: 123, - coin: 'BTC', - side: 'B', - sz: '0.5', - origSz: '0.5', - limitPx: '50000', - orderType: 'Limit', - timestamp: Date.now(), - isTrigger: false, - reduceOnly: false, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 123, + coin: 'BTC', + side: 'B', + sz: '0.5', + origSz: '0.5', + limitPx: '50000', + orderType: 'Limit', + timestamp: Date.now(), + isTrigger: false, + reduceOnly: false, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -708,7 +736,7 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe: jest.fn().mockResolvedValue(undefined), }; - mockSubscriptionClient.webData2.mockResolvedValue(mockSubscription); + mockSubscriptionClient.webData3.mockResolvedValue(mockSubscription); const unsubscribe = service.subscribeToPositions({ callback: mockCallback, @@ -758,7 +786,7 @@ describe('HyperLiquidSubscriptionService', () => { .mockRejectedValue(new Error('Unsubscribe failed')), }; - mockSubscriptionClient.webData2.mockResolvedValue(mockSubscription); + mockSubscriptionClient.webData3.mockResolvedValue(mockSubscription); const unsubscribe = service.subscribeToPositions({ callback: mockCallback, @@ -879,7 +907,7 @@ describe('HyperLiquidSubscriptionService', () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(typeof unsubscribe).toBe('function'); - expect(mockSubscriptionClient.webData2).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); }); it('should handle missing subscription client in order fill subscription', () => { @@ -936,15 +964,20 @@ describe('HyperLiquidSubscriptionService', () => { it('should handle missing position data gracefully', async () => { const mockCallback = jest.fn(); - // Mock webData2 with no position data - mockSubscriptionClient.webData2.mockImplementation( + // Mock webData3 with no position data + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [], // Empty array instead of undefined - }, - openOrders: [], // Also need openOrders array + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [], // Empty array instead of undefined + }, + openOrders: [], // Also need openOrders array + perpsAtOpenInterestCap: [], + }, + ], }); }, 0); return Promise.resolve({ @@ -1288,29 +1321,34 @@ describe('HyperLiquidSubscriptionService', () => { it('should process Take Profit orders correctly', async () => { const mockCallback = jest.fn(); - // Mock webData2 with TP/SL trigger orders - mockSubscriptionClient.webData2.mockImplementation( + // Mock webData3 with TP/SL trigger orders + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '1.0', coin: 'BTC' }, - coin: 'BTC', - }, - ], - }, - openOrders: [ + perpDexStates: [ { - oid: 123, - coin: 'BTC', - side: 'S', // Sell order (opposite of long position) - sz: '1.0', - triggerPx: '55000', // Take profit trigger price - orderType: 'Take Profit', - reduceOnly: true, - isPositionTpsl: true, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 123, + coin: 'BTC', + side: 'S', // Sell order (opposite of long position) + sz: '1.0', + triggerPx: '55000', // Take profit trigger price + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -1343,28 +1381,33 @@ describe('HyperLiquidSubscriptionService', () => { it('should process Stop Loss orders correctly', async () => { const mockCallback = jest.fn(); - mockSubscriptionClient.webData2.mockImplementation( + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '1.0', coin: 'BTC' }, - coin: 'BTC', - }, - ], - }, - openOrders: [ + perpDexStates: [ { - oid: 124, - coin: 'BTC', - side: 'S', - sz: '1.0', - triggerPx: '45000', // Stop loss trigger price - orderType: 'Stop', - reduceOnly: true, - isPositionTpsl: true, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 124, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Stop loss trigger price + orderType: 'Stop', + reduceOnly: true, + isPositionTpsl: true, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -1397,48 +1440,53 @@ describe('HyperLiquidSubscriptionService', () => { it('should handle multiple TP/SL orders for same position', async () => { const mockCallback = jest.fn(); - mockSubscriptionClient.webData2.mockImplementation( + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '2.0', coin: 'BTC' }, - coin: 'BTC', - }, - ], - }, - openOrders: [ - { - oid: 125, - coin: 'BTC', - side: 'S', - sz: '1.0', - triggerPx: '55000', - orderType: 'Take Profit', - reduceOnly: true, - isPositionTpsl: true, - }, - { - oid: 126, - coin: 'BTC', - side: 'S', - sz: '1.0', - triggerPx: '56000', - orderType: 'Take Profit', - reduceOnly: true, - isPositionTpsl: true, - }, + perpDexStates: [ { - oid: 127, - coin: 'BTC', - side: 'S', - sz: '0.5', - triggerPx: '45000', - orderType: 'Stop', - reduceOnly: true, - isPositionTpsl: true, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '2.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 125, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + }, + { + oid: 126, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '56000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + }, + { + oid: 127, + coin: 'BTC', + side: 'S', + sz: '0.5', + triggerPx: '45000', + orderType: 'Stop', + reduceOnly: true, + isPositionTpsl: true, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -1473,7 +1521,7 @@ describe('HyperLiquidSubscriptionService', () => { it('should fallback to price-based TP/SL detection when orderType is ambiguous', async () => { const mockCallback = jest.fn(); - // Mock the adapter to include entryPrice before setting up webData2 mock + // Mock the adapter to include entryPrice before setting up webData3 mock const mockAdapter = jest.requireMock('../utils/hyperLiquidAdapter'); mockAdapter.adaptPositionFromSDK.mockImplementationOnce(() => ({ coin: 'BTC', @@ -1491,42 +1539,47 @@ describe('HyperLiquidSubscriptionService', () => { stopLossCount: 0, })); - mockSubscriptionClient.webData2.mockImplementation( + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { - szi: '1.0', + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '1.0', + coin: 'BTC', + entryPrice: '50000', // Entry price for comparison + }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 128, coin: 'BTC', - entryPrice: '50000', // Entry price for comparison + side: 'S', + sz: '1.0', + triggerPx: '55000', // Above entry price = Take Profit for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, }, - coin: 'BTC', - }, - ], - }, - openOrders: [ - { - oid: 128, - coin: 'BTC', - side: 'S', - sz: '1.0', - triggerPx: '55000', // Above entry price = Take Profit for long - orderType: 'Trigger', // Ambiguous order type - reduceOnly: true, - isPositionTpsl: true, - }, - { - oid: 129, - coin: 'BTC', - side: 'S', - sz: '1.0', - triggerPx: '45000', // Below entry price = Stop Loss for long - orderType: 'Trigger', // Ambiguous order type - reduceOnly: true, - isPositionTpsl: true, + { + oid: 129, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '45000', // Below entry price = Stop Loss for long + orderType: 'Trigger', // Ambiguous order type + reduceOnly: true, + isPositionTpsl: true, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -1560,42 +1613,47 @@ describe('HyperLiquidSubscriptionService', () => { it('should handle short position TP/SL logic correctly', async () => { const mockCallback = jest.fn(); - mockSubscriptionClient.webData2.mockImplementation( + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { - szi: '-1.0', // Short position (negative size) + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [ + { + position: { + szi: '-1.0', // Short position (negative size) + coin: 'BTC', + entryPrice: '50000', + }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 130, coin: 'BTC', - entryPrice: '50000', + side: 'B', // Buy order (opposite of short position) + sz: '1.0', + triggerPx: '45000', // Below entry price = Take Profit for short + orderType: 'Trigger', + reduceOnly: true, + isPositionTpsl: true, }, - coin: 'BTC', - }, - ], - }, - openOrders: [ - { - oid: 130, - coin: 'BTC', - side: 'B', // Buy order (opposite of short position) - sz: '1.0', - triggerPx: '45000', // Below entry price = Take Profit for short - orderType: 'Trigger', - reduceOnly: true, - isPositionTpsl: true, - }, - { - oid: 131, - coin: 'BTC', - side: 'B', - sz: '1.0', - triggerPx: '55000', // Above entry price = Stop Loss for short - orderType: 'Trigger', - reduceOnly: true, - isPositionTpsl: true, + { + oid: 131, + coin: 'BTC', + side: 'B', + sz: '1.0', + triggerPx: '55000', // Above entry price = Stop Loss for short + orderType: 'Trigger', + reduceOnly: true, + isPositionTpsl: true, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -1647,38 +1705,43 @@ describe('HyperLiquidSubscriptionService', () => { it('should include TP/SL orders in the orders list', async () => { const mockCallback = jest.fn(); - mockSubscriptionClient.webData2.mockImplementation( + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '1.0', coin: 'BTC' }, - coin: 'BTC', - }, - ], - }, - openOrders: [ + perpDexStates: [ { - oid: 132, - coin: 'BTC', - side: 'S', - sz: '1.0', - triggerPx: '55000', - orderType: 'Take Profit', - reduceOnly: true, - isPositionTpsl: true, - }, - { - oid: 133, - coin: 'BTC', - side: 'B', - sz: '0.5', - limitPx: '49000', - orderType: 'Limit', - reduceOnly: false, - isPositionTpsl: false, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + ], + }, + openOrders: [ + { + oid: 132, + coin: 'BTC', + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + }, + { + oid: 133, + coin: 'BTC', + side: 'B', + sz: '0.5', + limitPx: '49000', + orderType: 'Limit', + reduceOnly: false, + isPositionTpsl: false, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -1749,32 +1812,37 @@ describe('HyperLiquidSubscriptionService', () => { stopLossCount: 0, })); - mockSubscriptionClient.webData2.mockImplementation( + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [ - { - position: { szi: '1.0', coin: 'BTC' }, - coin: 'BTC', - }, - { - position: { szi: '2.0', coin: 'ETH' }, - coin: 'ETH', - }, - ], - }, - openOrders: [ + perpDexStates: [ { - oid: 134, - coin: 'BTC', // Only BTC has TP/SL orders - side: 'S', - sz: '1.0', - triggerPx: '55000', - orderType: 'Take Profit', - reduceOnly: true, - isPositionTpsl: true, + clearinghouseState: { + assetPositions: [ + { + position: { szi: '1.0', coin: 'BTC' }, + coin: 'BTC', + }, + { + position: { szi: '2.0', coin: 'ETH' }, + coin: 'ETH', + }, + ], + }, + openOrders: [ + { + oid: 134, + coin: 'BTC', // Only BTC has TP/SL orders + side: 'S', + sz: '1.0', + triggerPx: '55000', + orderType: 'Take Profit', + reduceOnly: true, + isPositionTpsl: true, + }, + ], + perpsAtOpenInterestCap: [], }, ], }); @@ -1897,26 +1965,36 @@ describe('HyperLiquidSubscriptionService', () => { it('should not repeatedly notify subscribers with empty positions', async () => { const mockCallback = jest.fn(); - // Mock webData2 to send multiple empty updates - mockSubscriptionClient.webData2.mockImplementation( + // Mock webData3 to send multiple empty updates + mockSubscriptionClient.webData3.mockImplementation( (_params: any, callback: any) => { // Send first update setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [], - }, - openOrders: [], + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [], + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], }); }, 0); // Send second update (still empty) setTimeout(() => { callback({ - clearinghouseState: { - assetPositions: [], - }, - openOrders: [], + perpDexStates: [ + { + clearinghouseState: { + assetPositions: [], + }, + openOrders: [], + perpsAtOpenInterestCap: [], + }, + ], }); }, 20); @@ -2038,46 +2116,6 @@ describe('HyperLiquidSubscriptionService', () => { expect(mockInfoClient.meta).toHaveBeenCalledWith({ dex: 'newdex2' }); }); - it('updates feature flags with position subscribers', async () => { - const mockPositionCallback = jest.fn(); - const mockInfoClient = { - frontendOpenOrders: jest.fn().mockResolvedValue([]), - }; - mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); - - const clearinghouseStateSubscription = { - unsubscribe: jest.fn().mockResolvedValue(undefined), - }; - mockSubscriptionClient.clearinghouseState = jest.fn( - (_params: any, callback: any) => { - setTimeout(() => { - callback({ - user: '0x123', - clearinghouseState: { - assetPositions: [], - }, - }); - }, 0); - return Promise.resolve(clearinghouseStateSubscription); - }, - ); - - // Subscribe to positions first - service.subscribeToPositions({ - callback: mockPositionCallback, - }); - - await new Promise((resolve) => setTimeout(resolve, 20)); - - // Update feature flags with new DEXs - await service.updateFeatureFlags(true, ['dex3']); - - expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( - { user: '0x123', dex: 'dex3' }, - expect.any(Function), - ); - }); - it('handles errors when establishing assetCtxs subscriptions for new DEXs', async () => { const mockCallback = jest.fn(); @@ -2177,113 +2215,6 @@ describe('HyperLiquidSubscriptionService', () => { : 0; expect(finalCallCount).toBe(initialCallCount); }); - - it('subscribes to positions with HIP-3 DEXs enabled', async () => { - const mockPositionCallback = jest.fn(); - const mockInfoClient = { - frontendOpenOrders: jest.fn().mockResolvedValue([]), - }; - mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); - - const clearinghouseStateSubscription = { - unsubscribe: jest.fn().mockResolvedValue(undefined), - }; - mockSubscriptionClient.clearinghouseState = jest.fn( - (_params: any, callback: any) => { - setTimeout(() => { - callback({ - user: '0x123', - clearinghouseState: { - assetPositions: [ - { - position: { szi: '1.0', coin: 'BTC' }, - coin: 'BTC', - }, - ], - }, - }); - }, 0); - return Promise.resolve(clearinghouseStateSubscription); - }, - ); - - // Create service with HIP-3 enabled - const hip3Service = new HyperLiquidSubscriptionService( - mockClientService, - mockWalletService, - true, - ['dex1', 'dex2'], - ); - - const unsubscribe = hip3Service.subscribeToPositions({ - callback: mockPositionCallback, - }); - - await new Promise((resolve) => setTimeout(resolve, 30)); - - // Should create clearinghouseState subscriptions for HIP-3 DEXs - expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( - { user: '0x123', dex: 'dex1' }, - expect.any(Function), - ); - expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( - { user: '0x123', dex: 'dex2' }, - expect.any(Function), - ); - - unsubscribe(); - }); - - it('cleans up HIP-3 DEX subscriptions when last subscriber unsubscribes', async () => { - const mockPositionCallback = jest.fn(); - const mockInfoClient = { - frontendOpenOrders: jest.fn().mockResolvedValue([]), - }; - mockClientService.getInfoClient = jest.fn(() => mockInfoClient as any); - - const clearinghouseStateUnsubscribe = jest - .fn() - .mockResolvedValue(undefined); - const clearinghouseStateSubscription = { - unsubscribe: clearinghouseStateUnsubscribe, - }; - - mockSubscriptionClient.clearinghouseState = jest.fn( - (_params: any, callback: any) => { - setTimeout(() => { - callback({ - user: '0x123', - clearinghouseState: { - assetPositions: [], - }, - }); - }, 0); - return Promise.resolve(clearinghouseStateSubscription); - }, - ); - - // Create service with HIP-3 enabled - const hip3Service = new HyperLiquidSubscriptionService( - mockClientService, - mockWalletService, - true, - ['testdex'], - ); - - const unsubscribe = hip3Service.subscribeToPositions({ - callback: mockPositionCallback, - }); - - await new Promise((resolve) => setTimeout(resolve, 30)); - - // Unsubscribe and trigger cleanup - unsubscribe(); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Should have called unsubscribe on clearinghouseState subscription - expect(clearinghouseStateUnsubscribe).toHaveBeenCalled(); - }); }); describe('Market Data Cache Initialization', () => { @@ -2426,11 +2357,11 @@ describe('HyperLiquidSubscriptionService', () => { }); describe('Multi-DEX Error Handling', () => { - it('handles webData2 subscription errors gracefully', async () => { + it('handles webData3 subscription errors gracefully', async () => { const mockCallback = jest.fn(); - mockSubscriptionClient.webData2 = jest + mockSubscriptionClient.webData3 = jest .fn() - .mockRejectedValue(new Error('WebData2 subscription failed')); + .mockRejectedValue(new Error('WebData3 subscription failed')); const unsubscribe = service.subscribeToPositions({ callback: mockCallback, diff --git a/app/components/UI/Perps/services/HyperLiquidWalletService.test.ts b/app/components/UI/Perps/services/HyperLiquidWalletService.test.ts index edc0fbb49270..4ce1b1fad6bb 100644 --- a/app/components/UI/Perps/services/HyperLiquidWalletService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidWalletService.test.ts @@ -81,11 +81,21 @@ jest.mock('../../../../core/Engine', () => { signTypedMessage: jest.fn().mockResolvedValue('0xSignatureResult'), }; + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:1', + }, + ]), + }; + return { __esModule: true, default: { context: { KeyringController: mockKeyringController, + AccountTreeController: mockAccountTreeController, }, }, }; @@ -103,12 +113,10 @@ jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ }, })); -import { HyperLiquidWalletService } from './HyperLiquidWalletService'; import type { CaipAccountId } from '@metamask/utils'; -import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; -import { store } from '../../../../store'; import Engine from '../../../../core/Engine'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import { HyperLiquidWalletService } from './HyperLiquidWalletService'; describe('HyperLiquidWalletService', () => { let service: HyperLiquidWalletService; @@ -248,13 +256,16 @@ describe('HyperLiquidWalletService', () => { }); it('should throw error when no account selected', async () => { - jest - .mocked(selectSelectedInternalAccountByScope) - .mockReturnValueOnce(() => undefined); + // Mock AccountTreeController to return empty array (no accounts) + ( + Engine.context.AccountTreeController + .getAccountsFromSelectedAccountGroup as jest.Mock + ).mockReturnValueOnce([]); - await expect( - walletAdapter.signTypedData(mockTypedDataParams), - ).rejects.toThrow('No account selected'); + // Creating wallet adapter should throw when no account + expect(() => service.createWalletAdapter()).toThrow( + 'No account selected', + ); }); it('should handle keyring controller errors', async () => { @@ -289,10 +300,11 @@ describe('HyperLiquidWalletService', () => { }); it('should throw error when getting account ID with no selected account', async () => { - // Mock selector to return null for this test - jest - .mocked(selectSelectedInternalAccountByScope) - .mockReturnValueOnce(() => undefined); + // Mock AccountTreeController to return empty array (no accounts) + ( + Engine.context.AccountTreeController + .getAccountsFromSelectedAccountGroup as jest.Mock + ).mockReturnValueOnce([]); await expect(service.getCurrentAccountId()).rejects.toThrow( 'No account selected', @@ -362,8 +374,11 @@ describe('HyperLiquidWalletService', () => { describe('Error Handling', () => { it('should handle store state errors gracefully', async () => { - // Mock store.getState to throw an error - (store.getState as jest.Mock).mockImplementationOnce(() => { + // Mock AccountTreeController to throw an error + ( + Engine.context.AccountTreeController + .getAccountsFromSelectedAccountGroup as jest.Mock + ).mockImplementationOnce(() => { throw new Error('Store error'); }); diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index 4e81ed6bb75f..86df1cbc541d 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -51,6 +51,7 @@ const mockStreamManagerInstance = { account: { clearCache: jest.fn(), prewarm: jest.fn(() => jest.fn()) }, marketData: { clearCache: jest.fn(), prewarm: jest.fn(() => jest.fn()) }, prices: { clearCache: jest.fn(), prewarm: jest.fn(async () => jest.fn()) }, + oiCaps: { clearCache: jest.fn(), prewarm: jest.fn(() => jest.fn()) }, }; jest.mock('../providers/PerpsStreamManager', () => ({ From fa3a237a3ee5044cbc7755a656dd5cd9e2987f48 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 07:56:39 +0800 Subject: [PATCH 10/21] coverage --- .../UI/Perps/hooks/usePerpsOICap.test.tsx | 341 +++++++++++ .../providers/PerpsStreamManager.test.tsx | 528 +++++++++++++++++- 2 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 app/components/UI/Perps/hooks/usePerpsOICap.test.tsx diff --git a/app/components/UI/Perps/hooks/usePerpsOICap.test.tsx b/app/components/UI/Perps/hooks/usePerpsOICap.test.tsx new file mode 100644 index 000000000000..4b72f1c9ab5a --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsOICap.test.tsx @@ -0,0 +1,341 @@ +/** + * Unit tests for usePerpsOICap hook + */ + +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import React from 'react'; +import { usePerpsOICap } from './usePerpsOICap'; +import { + PerpsStreamProvider, + PerpsStreamManager, +} from '../providers/PerpsStreamManager'; +import Engine from '../../../../core/Engine'; + +jest.mock('../../../../core/Engine'); +jest.mock('../../../../core/SDKConnect/utils/DevLogger'); +jest.mock('../../../../util/Logger'); +jest.mock('../utils/accountUtils', () => ({ + getEvmAccountFromSelectedAccountGroup: jest.fn().mockReturnValue({ + address: '0x123456789', + }), +})); + +const mockEngine = Engine as jest.Mocked; + +// Wrapper component for hook tests +const createWrapper = + (testStreamManager: PerpsStreamManager) => + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + +describe('usePerpsOICap', () => { + let testStreamManager: PerpsStreamManager; + let mockSubscribeToOICaps: jest.Mock; + let mockUnsubscribeFromOICaps: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create fresh stream manager for each test + testStreamManager = new PerpsStreamManager(); + + // Setup mocks + mockSubscribeToOICaps = jest.fn(); + mockUnsubscribeFromOICaps = jest.fn(); + + mockEngine.context.PerpsController = { + subscribeToOICaps: mockSubscribeToOICaps, + isCurrentlyReinitializing: jest.fn().mockReturnValue(false), + } as unknown as typeof mockEngine.context.PerpsController; + }); + + it('returns isAtCap true when symbol in caps list', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const { result } = renderHook(() => usePerpsOICap('BTC'), { + wrapper: createWrapper(testStreamManager), + }); + + // Initial state - loading + expect(result.current.isLoading).toBe(true); + expect(result.current.isAtCap).toBe(false); + + // Send OI cap update with BTC at cap + await act(async () => { + oiCapCallback?.(['BTC', 'ETH']); + }); + + // Should indicate BTC is at cap + await waitFor(() => { + expect(result.current.isAtCap).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + }); + + it('returns isAtCap false when symbol not in caps list', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const { result } = renderHook(() => usePerpsOICap('SOL'), { + wrapper: createWrapper(testStreamManager), + }); + + // Send OI cap update without SOL + await act(async () => { + oiCapCallback?.(['BTC', 'ETH']); + }); + + // Should indicate SOL is NOT at cap + await waitFor(() => { + expect(result.current.isAtCap).toBe(false); + expect(result.current.isLoading).toBe(false); + }); + }); + + it('returns isLoading true until first data received', () => { + // Don't call callback - simulates waiting for first data + mockSubscribeToOICaps.mockImplementation(() => mockUnsubscribeFromOICaps); + + const { result } = renderHook(() => usePerpsOICap('BTC'), { + wrapper: createWrapper(testStreamManager), + }); + + // Should be loading before first data + expect(result.current.isLoading).toBe(true); + expect(result.current.isAtCap).toBe(false); + }); + + it('returns isLoading false after first data received', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const { result } = renderHook(() => usePerpsOICap('BTC'), { + wrapper: createWrapper(testStreamManager), + }); + + // Initial loading state + expect(result.current.isLoading).toBe(true); + + // Send first data (empty caps list) + await act(async () => { + oiCapCallback?.([]); + }); + + // Should not be loading anymore + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('handles undefined symbol gracefully', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const { result } = renderHook(() => usePerpsOICap(undefined), { + wrapper: createWrapper(testStreamManager), + }); + + // Should not crash with undefined symbol + expect(result.current.isLoading).toBe(true); + expect(result.current.isAtCap).toBe(false); + + // Send data + await act(async () => { + oiCapCallback?.(['BTC']); + }); + + // Should still handle data but return false for isAtCap + await waitFor(() => { + expect(result.current.isAtCap).toBe(false); + }); + }); + + it('unsubscribes when component unmounts', async () => { + mockSubscribeToOICaps.mockReturnValue(mockUnsubscribeFromOICaps); + + const { unmount } = renderHook(() => usePerpsOICap('BTC'), { + wrapper: createWrapper(testStreamManager), + }); + + // Subscription should be created + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + }); + + // Unmount component + unmount(); + + // Should have called unsubscribe + await waitFor(() => { + expect(mockUnsubscribeFromOICaps).toHaveBeenCalled(); + }); + }); + + it('updates isAtCap when caps list changes', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const { result } = renderHook(() => usePerpsOICap('BTC'), { + wrapper: createWrapper(testStreamManager), + }); + + // First update - BTC not at cap + await act(async () => { + oiCapCallback?.(['ETH', 'SOL']); + }); + + await waitFor(() => { + expect(result.current.isAtCap).toBe(false); + }); + + // Second update - BTC now at cap + await act(async () => { + oiCapCallback?.(['BTC', 'ETH', 'SOL']); + }); + + await waitFor(() => { + expect(result.current.isAtCap).toBe(true); + }); + + // Third update - BTC no longer at cap + await act(async () => { + oiCapCallback?.(['ETH', 'SOL']); + }); + + await waitFor(() => { + expect(result.current.isAtCap).toBe(false); + }); + }); + + it('handles empty caps list correctly', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const { result } = renderHook(() => usePerpsOICap('BTC'), { + wrapper: createWrapper(testStreamManager), + }); + + // Send empty caps list + await act(async () => { + oiCapCallback?.([]); + }); + + await waitFor(() => { + expect(result.current.isAtCap).toBe(false); + expect(result.current.isLoading).toBe(false); + }); + }); + + it('resubscribes when symbol changes', async () => { + mockSubscribeToOICaps.mockReturnValue(mockUnsubscribeFromOICaps); + + const { rerender } = renderHook(({ symbol }) => usePerpsOICap(symbol), { + initialProps: { symbol: 'BTC' as string | undefined }, + wrapper: createWrapper(testStreamManager), + }); + + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalledTimes(1); + }); + + // Change symbol + rerender({ symbol: 'ETH' }); + + // Should have unsubscribed from old and subscribed to new + await waitFor(() => { + expect(mockUnsubscribeFromOICaps).toHaveBeenCalledTimes(1); + expect(mockSubscribeToOICaps).toHaveBeenCalledTimes(2); + }); + }); + + it('does not subscribe when symbol is undefined', () => { + mockSubscribeToOICaps.mockReturnValue(mockUnsubscribeFromOICaps); + + renderHook(() => usePerpsOICap(undefined), { + wrapper: createWrapper(testStreamManager), + }); + + // Should not have subscribed + expect(mockSubscribeToOICaps).not.toHaveBeenCalled(); + }); + + it('uses memoized isAtCap value to avoid unnecessary re-renders', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const renderSpy = jest.fn(); + const { result } = renderHook( + () => { + const hookResult = usePerpsOICap('BTC'); + renderSpy(); + return hookResult; + }, + { + wrapper: createWrapper(testStreamManager), + }, + ); + + const initialRenderCount = renderSpy.mock.calls.length; + + // Send same caps list multiple times + await act(async () => { + oiCapCallback?.(['BTC']); + }); + + await act(async () => { + oiCapCallback?.(['BTC']); + }); + + await act(async () => { + oiCapCallback?.(['BTC']); + }); + + // isAtCap should remain true + await waitFor(() => { + expect(result.current.isAtCap).toBe(true); + }); + + // Should have minimal re-renders due to memoization + // (Initial + data update, not one per callback) + expect(renderSpy.mock.calls.length).toBeLessThan(initialRenderCount + 5); + }); +}); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 2235d25e5bc9..93ff103cff70 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -9,13 +9,18 @@ import { import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; -import type { PriceUpdate, PerpsMarketData } from '../controllers/types'; +import type { PriceUpdate, PerpsMarketData, Order } from '../controllers/types'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; jest.mock('../../../../core/Engine'); jest.mock('../../../../core/SDKConnect/utils/DevLogger'); jest.mock('../../../../util/Logger'); jest.mock('../services/PerpsConnectionManager'); +jest.mock('../utils/accountUtils', () => ({ + getEvmAccountFromSelectedAccountGroup: jest.fn().mockReturnValue({ + address: '0x123456789', + }), +})); const mockEngine = Engine as jest.Mocked; const mockDevLogger = DevLogger as jest.Mocked; @@ -1415,4 +1420,525 @@ describe('PerpsStreamManager', () => { expect(mockLogger.error).toBeDefined(); }); }); + + describe('OICapStreamChannel', () => { + let mockSubscribeToOICaps: jest.Mock; + let mockUnsubscribeFromOICaps: jest.Mock; + + beforeEach(() => { + mockSubscribeToOICaps = jest.fn(); + mockUnsubscribeFromOICaps = jest.fn(); + + mockEngine.context.PerpsController.subscribeToOICaps = + mockSubscribeToOICaps; + mockEngine.context.PerpsController.isCurrentlyReinitializing = jest + .fn() + .mockReturnValue(false); + }); + + it('subscribes to OI caps and receives updates', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const callback = jest.fn(); + + const unsubscribe = testStreamManager.oiCaps.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + }); + + // Send OI cap update + act(() => { + oiCapCallback?.(['BTC', 'ETH']); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(['BTC', 'ETH']); + }); + + unsubscribe(); + }); + + it('notifies subscribers when markets reach OI cap', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const callback = jest.fn(); + + const unsubscribe = testStreamManager.oiCaps.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + }); + + // First update - empty caps + act(() => { + oiCapCallback?.([]); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith([]); + }); + + // Second update - BTC at cap + act(() => { + oiCapCallback?.(['BTC']); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(['BTC']); + }); + + // Third update - multiple markets at cap + act(() => { + oiCapCallback?.(['BTC', 'ETH', 'SOL']); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(['BTC', 'ETH', 'SOL']); + }); + + unsubscribe(); + }); + + it('clears cache and notifies with empty array', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const callback = jest.fn(); + + const unsubscribe = testStreamManager.oiCaps.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + }); + + // Send initial data + act(() => { + oiCapCallback?.(['BTC', 'ETH']); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(['BTC', 'ETH']); + }); + + callback.mockClear(); + + // Clear cache + act(() => { + testStreamManager.oiCaps.clearCache(); + }); + + // Should disconnect and notify with empty array + expect(mockUnsubscribeFromOICaps).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith([]); + + unsubscribe(); + }); + + it('prewarm creates persistent subscription', () => { + mockSubscribeToOICaps.mockReturnValue(mockUnsubscribeFromOICaps); + + const cleanup = testStreamManager.oiCaps.prewarm(); + + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + expect(typeof cleanup).toBe('function'); + + cleanup(); + }); + + it('cleanup prewarm removes subscription', () => { + mockSubscribeToOICaps.mockReturnValue(mockUnsubscribeFromOICaps); + + const cleanup = testStreamManager.oiCaps.prewarm(); + + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + + // Call cleanup + cleanup(); + + // Verify cleanup was called + expect(mockUnsubscribeFromOICaps).toHaveBeenCalled(); + }); + + it('validates account context on updates', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + // Mock getEvmAccountFromSelectedAccountGroup + const mockGetEvmAccount = jest.fn().mockReturnValue({ + address: '0x123', + }); + jest.mock('../utils/accountUtils', () => ({ + getEvmAccountFromSelectedAccountGroup: mockGetEvmAccount, + })); + + const callback = jest.fn(); + + const unsubscribe = testStreamManager.oiCaps.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + }); + + // Send update with matching account + act(() => { + oiCapCallback?.(['BTC']); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith(['BTC']); + }); + + unsubscribe(); + }); + + it('retries connection when controller is reinitializing', async () => { + // Mock controller as reinitializing + mockEngine.context.PerpsController.isCurrentlyReinitializing = jest + .fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + mockSubscribeToOICaps.mockReturnValue(mockUnsubscribeFromOICaps); + + const callback = jest.fn(); + + const unsubscribe = testStreamManager.oiCaps.subscribe({ + callback, + throttleMs: 0, + }); + + // Should not subscribe immediately + expect(mockSubscribeToOICaps).not.toHaveBeenCalled(); + + // Fast-forward time to trigger retry + act(() => { + jest.advanceTimersByTime(300); + }); + + // Should subscribe after retry + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + }); + + unsubscribe(); + }); + + it('returns null for cached data when cache is empty', () => { + const callback = jest.fn(); + + mockSubscribeToOICaps.mockReturnValue(mockUnsubscribeFromOICaps); + + const unsubscribe = testStreamManager.oiCaps.subscribe({ + callback, + throttleMs: 0, + }); + + // Should not have cached data initially (will be called with null/empty from connect) + unsubscribe(); + }); + + it('provides cached data to new subscribers', async () => { + let oiCapCallback: ((caps: string[]) => void) | null = null; + mockSubscribeToOICaps.mockImplementation( + (params: { callback: (caps: string[]) => void }) => { + oiCapCallback = params.callback; + return mockUnsubscribeFromOICaps; + }, + ); + + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + // First subscriber + const unsubscribe1 = testStreamManager.oiCaps.subscribe({ + callback: callback1, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockSubscribeToOICaps).toHaveBeenCalled(); + }); + + // Send data + act(() => { + oiCapCallback?.(['BTC', 'ETH']); + }); + + await waitFor(() => { + expect(callback1).toHaveBeenCalledWith(['BTC', 'ETH']); + }); + + // Second subscriber should get cached data + const unsubscribe2 = testStreamManager.oiCaps.subscribe({ + callback: callback2, + throttleMs: 0, + }); + + await waitFor(() => { + expect(callback2).toHaveBeenCalledWith(['BTC', 'ETH']); + }); + + unsubscribe1(); + unsubscribe2(); + }); + }); + + describe('StreamChannel pause/resume', () => { + let mockOrdersSubscribe: jest.Mock; + let mockOrdersUnsubscribe: jest.Mock; + + beforeEach(() => { + mockOrdersUnsubscribe = jest.fn(); + mockOrdersSubscribe = jest.fn().mockReturnValue(mockOrdersUnsubscribe); + mockEngine.context.PerpsController.subscribeToOrders = + mockOrdersSubscribe; + mockEngine.context.PerpsController.isCurrentlyReinitializing = jest + .fn() + .mockReturnValue(false); + }); + + it('pause blocks emission while keeping WebSocket alive', async () => { + let orderCallback: ((orders: Order[]) => void) | null = null; + mockOrdersSubscribe.mockImplementation( + (params: { callback: (orders: Order[]) => void }) => { + orderCallback = params.callback; + return mockOrdersUnsubscribe; + }, + ); + + const callback = jest.fn(); + + const unsubscribe = testStreamManager.orders.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockOrdersSubscribe).toHaveBeenCalled(); + }); + + // Send initial order + act(() => { + orderCallback?.([ + { + orderId: '1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '1.0', + originalSize: '1.0', + price: '50000', + filledSize: '0', + remainingSize: '1.0', + status: 'open', + timestamp: Date.now(), + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }, + ]); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledTimes(1); + }); + + callback.mockClear(); + + // Pause channel + act(() => { + testStreamManager.orders.pause(); + }); + + // Send update while paused + act(() => { + orderCallback?.([ + { + orderId: '2', + symbol: 'ETH', + side: 'sell', + orderType: 'limit', + size: '5.0', + originalSize: '5.0', + price: '3000', + filledSize: '0', + remainingSize: '5.0', + status: 'open', + timestamp: Date.now(), + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }, + ]); + }); + + // Should not receive update while paused + await act(async () => { + await Promise.resolve(); + }); + + expect(callback).not.toHaveBeenCalled(); + + // WebSocket should still be active (subscription not disconnected) + expect(mockOrdersUnsubscribe).not.toHaveBeenCalled(); + + unsubscribe(); + }); + + it('resume allows emission to continue', async () => { + let orderCallback: ((orders: Order[]) => void) | null = null; + mockOrdersSubscribe.mockImplementation( + (params: { callback: (orders: Order[]) => void }) => { + orderCallback = params.callback; + return mockOrdersUnsubscribe; + }, + ); + + const callback = jest.fn(); + + const unsubscribe = testStreamManager.orders.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => { + expect(mockOrdersSubscribe).toHaveBeenCalled(); + }); + + // Send initial order + act(() => { + orderCallback?.([ + { + orderId: '1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '1.0', + originalSize: '1.0', + price: '50000', + filledSize: '0', + remainingSize: '1.0', + status: 'open', + timestamp: Date.now(), + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }, + ]); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledTimes(1); + }); + + // Pause channel + act(() => { + testStreamManager.orders.pause(); + }); + + callback.mockClear(); + + // Send update while paused (should be blocked) + act(() => { + orderCallback?.([ + { + orderId: '2', + symbol: 'ETH', + side: 'sell', + orderType: 'limit', + size: '5.0', + originalSize: '5.0', + price: '3000', + filledSize: '0', + remainingSize: '5.0', + status: 'open', + timestamp: Date.now(), + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }, + ]); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(callback).not.toHaveBeenCalled(); + + // Resume channel + act(() => { + testStreamManager.orders.resume(); + }); + + // Send another update (should be received) + act(() => { + orderCallback?.([ + { + orderId: '3', + symbol: 'SOL', + side: 'buy', + orderType: 'limit', + size: '10.0', + originalSize: '10.0', + price: '100', + filledSize: '0', + remainingSize: '10.0', + status: 'open', + timestamp: Date.now(), + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }, + ]); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith([ + expect.objectContaining({ + orderId: '3', + symbol: 'SOL', + }), + ]); + }); + + unsubscribe(); + }); + }); }); From af889fd52752c24a56535795ab9653b1d1f8639e Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 08:11:16 +0800 Subject: [PATCH 11/21] fix: add defensive validation for perpDexStates array bounds in HyperLiquidSubscriptionService --- .../HyperLiquidSubscriptionService.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index e0daa312ddb4..c64f81549c63 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -706,6 +706,20 @@ export class HyperLiquidSubscriptionService { // Process data from each DEX in perpDexStates array data.perpDexStates.forEach((dexState, index) => { + // Defensive validation: Ensure perpDexStates array doesn't exceed enabledDexs + if (index >= enabledDexs.length) { + Logger.error( + new Error('perpDexStates array length exceeds enabledDexs'), + this.getErrorContext('subscribeToWebData3', { + perpDexStatesLength: data.perpDexStates.length, + enabledDexsLength: enabledDexs.length, + index, + issue: 'Array index out of bounds - skipping unknown DEX', + }), + ); + return; // Skip this DEX state to prevent data corruption + } + const currentDexName = enabledDexs[index] || ''; // null -> '' // Extract and process positions for this DEX @@ -746,6 +760,21 @@ export class HyperLiquidSubscriptionService { // Extract OI caps from all DEXs (main + HIP-3) const allOICaps: string[] = []; data.perpDexStates.forEach((dexState, index) => { + // Defensive validation: Ensure perpDexStates array doesn't exceed enabledDexs + if (index >= enabledDexs.length) { + Logger.error( + new Error('perpDexStates array length exceeds enabledDexs'), + this.getErrorContext('subscribeToWebData3:OICaps', { + perpDexStatesLength: data.perpDexStates.length, + enabledDexsLength: enabledDexs.length, + index, + issue: + 'Array index out of bounds - skipping OI caps for unknown DEX', + }), + ); + return; // Skip this DEX state to prevent incorrect OI cap attribution + } + const currentDexName = enabledDexs[index]; const oiCaps = dexState.perpsAtOpenInterestCap || []; From 1cf81a6705313c4c9eb5ca1984bcc11f7074f3a5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 09:10:08 +0800 Subject: [PATCH 12/21] fix: unit test coverage --- .../HyperLiquidSubscriptionService.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts index 621b5aff824a..a8781400bd27 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts @@ -2444,4 +2444,125 @@ describe('HyperLiquidSubscriptionService', () => { expect(() => unsubscribe()).not.toThrow(); }); }); + + describe('Cache Initialization Checks', () => { + it('returns false for OI caps cache before initialization', () => { + const result = service.isOICapsCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns false for orders cache before initialization', () => { + const result = service.isOrdersCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns false for positions cache before initialization', () => { + const result = service.isPositionsCacheInitialized(); + + expect(result).toBe(false); + }); + + it('returns null for cached positions before initialization', () => { + const result = service.getCachedPositions(); + + expect(result).toBeNull(); + }); + + it('returns null for cached orders before initialization', () => { + const result = service.getCachedOrders(); + + expect(result).toBeNull(); + }); + }); + + describe('OI Cap Subscriptions', () => { + it('subscribes to OI cap updates successfully', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToOICaps({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockSubscriptionClient.webData3).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('immediately provides cached OI caps if available', async () => { + const mockCallback = jest.fn(); + + // Mock webData3 to provide OI caps data + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: ['BTC', 'ETH'], + }, + ], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // First subscription to populate cache + const unsubscribe1 = service.subscribeToOICaps({ callback: jest.fn() }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Second subscription should get cached data immediately + const unsubscribe2 = service.subscribeToOICaps({ + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalledWith(['BTC', 'ETH']); + + unsubscribe1(); + unsubscribe2(); + }); + }); + + describe('Account Subscriptions', () => { + it('subscribes to account updates successfully', async () => { + const mockCallback = jest.fn(); + + const unsubscribe = service.subscribeToAccount({ + callback: mockCallback, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockSubscriptionClient.webData3).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + }); + + it('immediately provides cached account state if available', async () => { + const mockCallback = jest.fn(); + + // First subscription to populate cache + const unsubscribe1 = service.subscribeToAccount({ + callback: jest.fn(), + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Second subscription should get cached data immediately + const unsubscribe2 = service.subscribeToAccount({ + callback: mockCallback, + }); + + expect(mockCallback).toHaveBeenCalled(); + + unsubscribe1(); + unsubscribe2(); + }); + }); }); From 5864bae53324689ed2903d6caa4f2356722c6d55 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 09:23:58 +0800 Subject: [PATCH 13/21] fix: tpsl assignment --- .../Perps/controllers/providers/HyperLiquidProvider.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 741668466cc8..1cee5bc0e033 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -1944,7 +1944,8 @@ export class HyperLiquidProvider implements IPerpsProvider { await this.ensureReady(); // Get all current positions - const positions = await this.getPositions(); + // Force fresh API data (not WebSocket cache) since we're about to mutate positions + const positions = await this.getPositions({ skipCache: true }); // Filter positions based on params positionsToClose = @@ -2163,9 +2164,10 @@ export class HyperLiquidProvider implements IPerpsProvider { const { coin, takeProfitPrice, stopLossPrice } = params; // Get current position to validate it exists + // Force fresh API data (not WebSocket cache) since we're about to mutate the position let positions: Position[]; try { - positions = await this.getPositions(); + positions = await this.getPositions({ skipCache: true }); } catch (error) { Logger.error( ensureError(error), @@ -2403,7 +2405,8 @@ export class HyperLiquidProvider implements IPerpsProvider { try { DevLogger.log('Closing position:', params); - const positions = await this.getPositions(); + // Force fresh API data (not WebSocket cache) since we're about to mutate the position + const positions = await this.getPositions({ skipCache: true }); const position = positions.find((p) => p.coin === params.coin); if (!position) { From 896359fcc544d62cfe07c59c01a31d7d82c189fd Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 10:06:52 +0800 Subject: [PATCH 14/21] fix: improve error handling in PerpsOrderView and HyperLiquidProvider - Updated PerpsOrderView to prevent error toast during initial market data load. - Enhanced HyperLiquidProvider to avoid disabling DEX abstraction on network errors, ensuring the flag remains intact for future order verification. - Added checks to handle transfer errors more gracefully, allowing for better management of DEX abstraction status. --- .../Views/PerpsOrderView/PerpsOrderView.tsx | 4 +- .../providers/HyperLiquidProvider.ts | 39 ++++++++++++++----- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 41e5e3e3fb48..50c636515579 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -354,7 +354,8 @@ const PerpsOrderViewContentBase: React.FC = () => { // Show error toast if market data is not available useEffect(() => { - if (marketDataError) { + // Don't show error during initial load - only for persistent failures + if (marketDataError && !isLoadingMarketData) { showToast( PerpsToastOptions.dataFetching.market.error.marketDataUnavailable( orderForm.asset, @@ -363,6 +364,7 @@ const PerpsOrderViewContentBase: React.FC = () => { } }, [ marketDataError, + isLoadingMarketData, orderForm.asset, navigation, showToast, diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 1cee5bc0e033..6d6388babd59 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -280,16 +280,15 @@ export class HyperLiquidProvider implements IPerpsProvider { '✅ HyperLiquidProvider: DEX abstraction enabled successfully', ); } catch (error) { - // Disable DEX abstraction flag to trigger automatic fallback to manual transfer - // This handles cases where the backend restricts DEX abstraction (e.g., gradual rollout) - this.useDexAbstraction = false; - + // Don't blindly disable the flag on any error + // Network errors or unknown issues shouldn't trigger fallback to manual transfer Logger.error( ensureError(error), this.getErrorContext('ensureDexAbstractionEnabled', { - note: 'Disabled DEX abstraction, will use manual auto-transfer', + note: 'Could not enable DEX abstraction (may already be enabled or network error), will verify on first order', }), ); + // Keep useDexAbstraction flag as-is, let placeOrder() verify actual status if needed } } @@ -1491,10 +1490,32 @@ export class HyperLiquidProvider implements IPerpsProvider { // Transfer funds to reach required TOTAL margin in available balance // autoTransferForHip3Order checks current balance and only transfers shortfall - transferInfo = await this.autoTransferForHip3Order({ - targetDex: dexName, - requiredMargin: requiredMarginWithBuffer, - }); + try { + transferInfo = await this.autoTransferForHip3Order({ + targetDex: dexName, + requiredMargin: requiredMarginWithBuffer, + }); + } catch (transferError) { + // Reactive fix: Check if transfer failed because DEX abstraction is actually enabled + const errorMsg = (transferError as Error)?.message || ''; + + if ( + errorMsg.includes('Cannot transfer with DEX abstraction enabled') + ) { + DevLogger.log( + 'HyperLiquidProvider: Detected DEX abstraction is enabled, switching to abstraction mode', + ); + + // Update flag to prevent this issue on future orders + this.useDexAbstraction = true; + + // Continue without manual transfer - let DEX abstraction handle it + transferInfo = null; + } else { + // Different error - rethrow + throw transferError; + } + } } else if (isHip3Order && this.useDexAbstraction) { DevLogger.log( 'HyperLiquidProvider: Using DEX abstraction (no manual transfer)', From b5150e9669a74f844d1550469b08d8aa3b8d2aca Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 10:12:30 +0800 Subject: [PATCH 15/21] fix: sonarcloud --- .../UI/Perps/services/HyperLiquidSubscriptionService.ts | 4 +++- .../UI/Perps/utils/hyperLiquidOrderBookProcessor.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 02000b520667..55934d021ae8 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -790,7 +790,9 @@ export class HyperLiquidSubscriptionService { }); // Update OI caps cache and notify if changed - const oiCapsHash = allOICaps.sort().join(','); + const oiCapsHash = [...allOICaps] + .sort((a: string, b: string) => a.localeCompare(b)) + .join(','); if (oiCapsHash !== this.cachedOICapsHash) { this.cachedOICaps = allOICaps; this.cachedOICapsHash = oiCapsHash; diff --git a/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts b/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts index b9d67ad478c8..ee930e52ac9a 100644 --- a/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts +++ b/app/components/UI/Perps/utils/hyperLiquidOrderBookProcessor.ts @@ -48,7 +48,7 @@ export function processL2BookData(params: ProcessL2BookDataParams): void { notifySubscribers, } = params; - if (!data || data.coin !== symbol || !data.levels) { + if (data?.coin !== symbol || !data?.levels) { return; } From 09c3e504ccf07407a6d4ba00df259fdb342ebb59 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 11:18:31 +0800 Subject: [PATCH 16/21] feat: improve order validation --- .../Views/PerpsOrderView/PerpsOrderView.tsx | 36 ++++++++++--------- .../UI/Perps/constants/perpsConfig.ts | 2 +- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 50c636515579..db5039912354 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -170,7 +170,7 @@ const PerpsOrderViewContentBase: React.FC = () => { const orderStartTimeRef = useRef(0); const inputMethodRef = useRef('default'); - const { account } = usePerpsLiveAccount(); + const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount(); // Get real HyperLiquid USDC balance const availableBalance = parseFloat( @@ -843,7 +843,8 @@ const PerpsOrderViewContentBase: React.FC = () => { orderForm.direction === 'long' ? 'perps.order.button.long' : 'perps.order.button.short'; - const isInsufficientFunds = amountTimesLeverage < minimumOrderAmount; + const isInsufficientFunds = + !isLoadingAccount && amountTimesLeverage < minimumOrderAmount; const placeOrderLabel = isInsufficientFunds ? strings('perps.order.validation.insufficient_funds') : strings(orderButtonKey, { asset: orderForm.asset }); @@ -883,7 +884,7 @@ const PerpsOrderViewContentBase: React.FC = () => { {/* Amount Display */} { {/* Fixed Place Order Button - Hide when keypad is active */} {!isInputFocused && ( - {filteredErrors.length > 0 && ( - - {filteredErrors.map((error) => ( - - {error} - - ))} - - )} + {filteredErrors.length > 0 && + !isLoadingMarketData && + currentPrice !== null && + !orderValidation.isValidating && ( + + {filteredErrors.map((error) => ( + + {error} + + ))} + + )} {/* OI Cap Warning - Only shows when market is at capacity */} diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 5f851fac8f93..0fe6ce3b6cd1 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -82,7 +82,7 @@ export const PERFORMANCE_CONFIG = { // Order validation debounce delay (milliseconds) // Prevents excessive validation calls during rapid form input changes - VALIDATION_DEBOUNCE_MS: 1000, + VALIDATION_DEBOUNCE_MS: 300, // Liquidation price debounce delay (milliseconds) // Prevents excessive liquidation price calls during rapid form input changes From 6fcf22e118e2a9aac2137b506fca0a5509fbf1aa Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 14:18:26 +0800 Subject: [PATCH 17/21] fix: unit tests --- .../PerpsOrderView/PerpsOrderView.test.tsx | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 244da4f3529b..311474d420e1 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -456,14 +456,14 @@ const defaultMockRoute = { const defaultMockHooks = { usePerpsLiveAccount: { - balance: '1000', - availableBalance: '1000', - accountInfo: { - marginSummary: { - accountValue: 1000, - totalMarginUsed: 0, - }, + account: { + availableBalance: '1000', + marginUsed: '0', + unrealizedPnl: '0', + returnOnEquity: '0', + totalBalance: '1000', }, + isInitialLoading: false, }, usePerpsTrading: { placeOrder: jest.fn(), @@ -2838,25 +2838,21 @@ describe('PerpsOrderView', () => { }); describe('Insufficient funds handling', () => { - it('should show insufficient funds button when amount times leverage is below minimum', () => { - // Arrange - Mock low balance and high minimum + it('should not show balance warning when account is still loading', async () => { + // This test verifies our loading guard fix - balance warnings shouldn't + // appear while account data is still loading (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: '1', // Very low balance + availableBalance: '0', // Zero balance marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', - totalBalance: '1', + totalBalance: '0', }, - isInitialLoading: false, + isInitialLoading: true, // Still loading - warning should NOT appear }); - (useMinimumOrderAmount as jest.Mock).mockReturnValue({ - minimumOrderAmount: 1000, // High minimum - isLoading: false, - }); - - const { getByText } = render( + const { queryByText } = render( @@ -2864,8 +2860,10 @@ describe('PerpsOrderView', () => { , ); - // Assert - Should show insufficient funds message - expect(getByText('Insufficient funds')).toBeOnTheScreen(); + // Assert - Should NOT show "No funds available" warning while loading + expect( + queryByText('No funds available. Please deposit first.'), + ).toBeNull(); }); it('should show normal place order button when amount is sufficient', () => { @@ -2940,7 +2938,7 @@ describe('PerpsOrderView', () => { }, }); - const { getByText } = render( + const { getByTestId, getByText } = render( @@ -2948,8 +2946,9 @@ describe('PerpsOrderView', () => { , ); - // Assert - Should display asset in header title and price - expect(getByText('Long BTC')).toBeOnTheScreen(); + // Assert - Should display asset in header title using testID to avoid duplicate text matches + const headerTitle = getByTestId('perps-order-header-asset-title'); + expect(headerTitle).toHaveTextContent('Long BTC'); expect(getByText('$3,000')).toBeOnTheScreen(); // Price from mock data }); }); From e71504f3c92d5e3b2fd7c0a20cbf68d0618c58c1 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 14:35:48 +0800 Subject: [PATCH 18/21] feat: implement open interest cap localization and refactor subscription parameters - Updated PerpsOICapWarning component to use localized strings for open interest cap messages. - Refactored subscribeToOICaps method signatures in PerpsController, HyperLiquidProvider, and HyperLiquidSubscriptionService to utilize a new SubscribeOICapsParams type for better clarity and maintainability. - Added new localization entries for open interest cap reached and description in en.json. --- .../PerpsOICapWarning/PerpsOICapWarning.tsx | 6 +++--- .../UI/Perps/controllers/PerpsController.ts | 12 +++--------- .../controllers/providers/HyperLiquidProvider.ts | 6 ++---- app/components/UI/Perps/controllers/types/index.ts | 11 +++++++---- .../Perps/services/HyperLiquidSubscriptionService.ts | 6 ++---- locales/languages/en.json | 6 ++++-- 6 files changed, 21 insertions(+), 26 deletions(-) diff --git a/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx index 9adcdf93c0bc..00ff44d5e5c7 100644 --- a/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx +++ b/app/components/UI/Perps/components/PerpsOICapWarning/PerpsOICapWarning.tsx @@ -10,6 +10,7 @@ import Icon, { IconSize, IconColor, } from '../../../../../component-library/components/Icons/Icon'; +import { strings } from '../../../../../../locales/i18n'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; import type { PerpsOICapWarningProps } from './PerpsOICapWarning.types'; import styleSheet from './PerpsOICapWarning.styles'; @@ -64,15 +65,14 @@ const PerpsOICapWarning: React.FC = memo( color={TextColor.Warning} style={styles.title} > - Open Interest Cap Reached + {strings('perps.order.validation.oi_cap_reached')} - This market is at capacity. New positions cannot be opened until - open interest decreases. + {strings('perps.order.validation.oi_cap_description')} diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index e22bbe757483..3bb923e8b639 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -15,11 +15,7 @@ import { TransactionParams, TransactionType, } from '@metamask/transaction-controller'; -import { - parseCaipAssetId, - type CaipAccountId, - type Hex, -} from '@metamask/utils'; +import { parseCaipAssetId, type Hex } from '@metamask/utils'; import performance from 'react-native-performance'; import { setMeasurement } from '@sentry/react-native'; import type { Span } from '@sentry/core'; @@ -96,6 +92,7 @@ import type { PerpsControllerConfig, Position, SubscribeAccountParams, + SubscribeOICapsParams, SubscribeOrderFillsParams, SubscribeOrdersParams, SubscribePositionsParams, @@ -3673,10 +3670,7 @@ export class PerpsController extends BaseController< * Subscribe to open interest cap updates * Zero additional network overhead - data comes from existing webData3 subscription */ - subscribeToOICaps(params: { - accountId?: CaipAccountId; - callback: (caps: string[]) => void; - }): () => void { + subscribeToOICaps(params: SubscribeOICapsParams): () => void { try { const provider = this.getActiveProvider(); return provider.subscribeToOICaps(params); diff --git a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts index 6d6388babd59..2d62cde11051 100644 --- a/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts +++ b/app/components/UI/Perps/controllers/providers/HyperLiquidProvider.ts @@ -96,6 +96,7 @@ import type { Position, ReadyToTradeResult, SubscribeAccountParams, + SubscribeOICapsParams, SubscribeOrderFillsParams, SubscribeOrdersParams, SubscribePositionsParams, @@ -4149,10 +4150,7 @@ export class HyperLiquidProvider implements IPerpsProvider { * Subscribe to open interest cap updates * Zero additional overhead - data extracted from existing webData2 subscription */ - subscribeToOICaps(params: { - accountId?: CaipAccountId; - callback: (caps: string[]) => void; - }): () => void { + subscribeToOICaps(params: SubscribeOICapsParams): () => void { return this.subscriptionService.subscribeToOICaps(params); } diff --git a/app/components/UI/Perps/controllers/types/index.ts b/app/components/UI/Perps/controllers/types/index.ts index 5423fcab85bf..67f047804538 100644 --- a/app/components/UI/Perps/controllers/types/index.ts +++ b/app/components/UI/Perps/controllers/types/index.ts @@ -581,6 +581,12 @@ export interface SubscribeAccountParams { callback: (account: AccountState) => void; accountId?: CaipAccountId; // Optional: defaults to selected account } + +export interface SubscribeOICapsParams { + callback: (caps: string[]) => void; + accountId?: CaipAccountId; // Optional: defaults to selected account +} + export interface LiquidationPriceParams { entryPrice: number; leverage: number; @@ -767,10 +773,7 @@ export interface IPerpsProvider { subscribeToOrderFills(params: SubscribeOrderFillsParams): () => void; subscribeToOrders(params: SubscribeOrdersParams): () => void; subscribeToAccount(params: SubscribeAccountParams): () => void; - subscribeToOICaps(params: { - accountId?: CaipAccountId; - callback: (caps: string[]) => void; - }): () => void; + subscribeToOICaps(params: SubscribeOICapsParams): () => void; // Live data configuration setLiveDataConfig(config: Partial): void; diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 55934d021ae8..52fab8960546 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -23,6 +23,7 @@ import type { SubscribeOrderFillsParams, SubscribeOrdersParams, SubscribeAccountParams, + SubscribeOICapsParams, } from '../controllers/types'; import { adaptPositionFromSDK, @@ -973,10 +974,7 @@ export class HyperLiquidSubscriptionService { * Subscribe to open interest cap updates * OI caps are extracted from webData2 subscription (zero additional overhead) */ - public subscribeToOICaps(params: { - callback: (caps: string[]) => void; - accountId?: CaipAccountId; - }): () => void { + public subscribeToOICaps(params: SubscribeOICapsParams): () => void { const { callback, accountId } = params; // Create subscription diff --git a/locales/languages/en.json b/locales/languages/en.json index 27bc443d6926..1a04e0a2622c 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1094,7 +1094,9 @@ "please_set_a_limit_price": "Please set a limit price", "limit_price_must_be_set_before_configuring_tpsl": "Limit price must be set before configuring TP/SL", "only_hyperliquid_usdc": "Only USDC on Hyperliquid is currently supported for payment", - "limit_price_far_warning": "Limit price is far from current market price" + "limit_price_far_warning": "Limit price is far from current market price", + "oi_cap_reached": "Open Interest Cap Reached", + "oi_cap_description": "This market is at capacity. New positions cannot be opened until open interest decreases." }, "error": { "placement_failed": "Order placement failed", @@ -2811,7 +2813,7 @@ "navigate_to_sample_feature": "Navigate to Sample Feature", "sample_feature_desc": "A sample feature as a template for developers." }, - "feature_flag_override": { + "feature_flag_override": { "title": "Feature Flag Override", "description": "Override the feature flags for the app locally." } From 67e6ed06d3acbd99dd8b54b2e12b16c88b6e9e3d Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 14:40:48 +0800 Subject: [PATCH 19/21] chore: cleanup --- app/components/UI/Perps/providers/PerpsStreamManager.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 041fa7fe7f0d..5655c11efb0c 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -436,7 +436,6 @@ class OrderStreamChannel extends StreamChannel { // Track WebSocket connection start time for duration calculation this.wsConnectionStartTime = performance.now(); - // This calls HyperLiquidSubscriptionService.subscribeToOrders which uses shared webData3 this.wsSubscription = Engine.context.PerpsController.subscribeToOrders({ callback: (orders: Order[]) => { // Validate account context @@ -572,7 +571,6 @@ class PositionStreamChannel extends StreamChannel { // Track WebSocket connection start time for duration calculation this.wsConnectionStartTime = performance.now(); - // This calls HyperLiquidSubscriptionService.subscribeToPositions which uses shared webData3 this.wsSubscription = Engine.context.PerpsController.subscribeToPositions({ callback: (positions: Position[]) => { // Validate account context From 97a632754eea3b320bb8e3cfb9add9de5aca8244 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 16:40:28 +0800 Subject: [PATCH 20/21] cleanup --- .../HyperLiquidSubscriptionService.ts | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts index 52fab8960546..d39502cd22d7 100644 --- a/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts +++ b/app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts @@ -799,15 +799,6 @@ export class HyperLiquidSubscriptionService { this.cachedOICapsHash = oiCapsHash; this.oiCapsCacheInitialized = true; - DevLogger.log('OI Caps Updated (all DEXs)', { - oiCaps: allOICaps, - count: allOICaps.length, - perDex: data.perpDexStates.map((dexState, index) => ({ - dex: enabledDexs[index] || 'main', - caps: dexState.perpsAtOpenInterestCap || [], - })), - }); - // Notify all subscribers this.oiCapSubscribers.forEach((callback) => callback(allOICaps)); } @@ -1562,12 +1553,6 @@ export class HyperLiquidSubscriptionService { // Cache asset contexts for this DEX this.dexAssetCtxsCache.set(dexKey, data.ctxs); - const callbackLogMessage = `assetCtxs callback fired for ${dexIdentifier}`; - DevLogger.log(callbackLogMessage, { - dex, - ctxsCount: data.ctxs?.length ?? 0, - }); - // Use cached meta to map ctxs array indices to symbols (no REST API call!) perpsMeta.universe.forEach((asset, index) => { const ctx = data.ctxs[index]; @@ -1606,11 +1591,6 @@ export class HyperLiquidSubscriptionService { }); // Notify price subscribers with updated market data - DevLogger.log(`Notifying price subscribers after assetCtxs update`, { - dex: dex || 'main', - cachedPriceCount: this.cachedPriceData?.size ?? 0, - subscriberCount: this.priceSubscribers.size, - }); this.notifyAllPriceSubscribers(); }) .then((sub) => { @@ -1730,19 +1710,6 @@ export class HyperLiquidSubscriptionService { .clearinghouseState( subscriptionParams, async (data: WsClearinghouseStateEvent) => { - DevLogger.log( - `clearinghouseState callback fired for ${ - dex ? `DEX: ${dex}` : 'main DEX' - }`, - { - dex, - positionsCount: data.clearinghouseState.assetPositions.filter( - (assetPos: { position: { szi: string } }) => - assetPos.position.szi !== '0', - ).length, - }, - ); - // Extract and process positions for this DEX const positions = data.clearinghouseState.assetPositions .filter( From e7c79734114df792a83719937f1438443c54c2d9 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Mon, 3 Nov 2025 22:06:56 +0800 Subject: [PATCH 21/21] fix: bugbot --- app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index db5039912354..2415f022aa45 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -1224,7 +1224,7 @@ const PerpsOrderViewContentBase: React.FC = () => { {filteredErrors.length > 0 && !isLoadingMarketData && - currentPrice !== null && + currentPrice != null && !orderValidation.isValidating && ( {filteredErrors.map((error) => (