diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 176e1e7ebe0..5cf6362c70c 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -13,7 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Deliver HyperLiquid positions, orders, and account/spot balance via per-DEX `clearinghouseState` and `openOrders` subscriptions on all paths, removing the dependency on the deprecated `webData2` snapshot channel ([#9078](https://github.com/MetaMask/core/pull/9078)) + - The non-HIP-3 (main-DEX-only) user data path previously used `webData2`, which HyperLiquid is throttling to a 15s push interval and deprecating. It now uses the same sub-second per-DEX subscriptions as the HIP-3 path, with `webData3` retained only for open-interest caps (not latency-sensitive). - Surface late order completions via trace `reason: 'late_success' | 'late_error'` ([#21217](https://github.com/MetaMask/core/pull/21217)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) ### Removed @@ -23,8 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix `late_error` never being emitted in the `placeOrder` catch path when a provider call succeeded past `PlaceOrderTimeoutMs` but a subsequent step threw; the trace `reason` now correctly reflects `'late_error'` whenever the submission threshold was exceeded, regardless of where the exception originated ([#8994](https://github.com/MetaMask/core/pull/8994)) -- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) - ## [8.0.0] ### Added diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 95954e852c3..410e57a7192 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -7537,7 +7537,7 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Subscribe to open interest cap updates - * Zero additional overhead - data extracted from existing webData2 subscription + * Zero additional overhead - data extracted from existing webData3 subscription * * @param params - The operation parameters. * @returns A cleanup function to remove the subscription. diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index f0cd24a171b..97cde840cc0 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -3,7 +3,6 @@ import { hasProperty } from '@metamask/utils'; import type { ISubscription, AllMidsWsEvent, - WebData2WsEvent, WebData3WsEvent, UserFillsWsEvent, ActiveAssetCtxWsEvent, @@ -788,7 +787,7 @@ export class HyperLiquidSubscriptionService { /** * Extract TP/SL from orders and optionally convert raw SDK orders to Order format. - * DRY helper used by both webData2 and clearinghouseState callbacks. + * DRY helper used by the clearinghouseState and openOrders callbacks. * * @param orders - Raw SDK orders from WebSocket event * @param positions - Current positions for TP/SL matching @@ -994,7 +993,7 @@ export class HyperLiquidSubscriptionService { /** * Merge TP/SL data into positions - * DRY helper used by both webData2 and clearinghouseState callbacks + * DRY helper used by the clearinghouseState and openOrders callbacks * * @param positions - Base positions without TP/SL * @param tpslMap - Map of coin -> TP/SL prices @@ -1660,12 +1659,18 @@ export class HyperLiquidSubscriptionService { } /** - * Create WebSocket subscription for user data (positions, orders, account) - * - Uses webData2 when HIP-3 disabled (main DEX only) - * - Uses webData3 when HIP-3 enabled (main + HIP-3 DEXs) + * Create WebSocket subscription for user data (positions, orders, account). * - * webData2 provides data for main DEX only - * webData3 provides perpDexStates[] array containing data for all DEXs: + * Positions, orders, and account/spot balance are always delivered via + * per-DEX `clearinghouseState` + `openOrders` subscriptions (sub-second + * updates). webData3 is used only for OI caps extraction (not + * latency-sensitive). The deprecated webData2 snapshot channel is no longer + * used (TAT-3332). + * + * - HIP-3 disabled: subscribe to the main DEX only (`dexsToSubscribe = ['']`). + * - HIP-3 enabled: subscribe to the main DEX plus each enabled HIP-3 DEX. + * + * webData3 provides perpDexStates[] array containing OI caps for all DEXs: * - Index 0: Main DEX (dexName = '') * - Index 1+: HIP-3 DEXs in order of enabledDexs array * @@ -1708,287 +1713,155 @@ export class HyperLiquidSubscriptionService { } return new Promise((resolve, reject) => { - // Choose channel based on HIP-3 master switch - if (this.#hip3Enabled) { - // HIP-3 enabled: Use individual subscriptions for positions/orders/account - // webData3 is only used for OI caps extraction - - // Determine which DEXs to subscribe to - const dexsToSubscribe = [ - '', // Main DEX - ...this.#enabledDexs.filter((dexId) => this.#isDexEnabled(dexId)), - ]; - - // Track expected DEXs for synchronized notifications - // Clear previous tracking and set new expected DEXs - this.#expectedDexs = new Set(dexsToSubscribe); - this.#initializedDexs = new Set(); - - // Set up individual subscriptions for each DEX - const subscriptionPromises: Promise[] = []; - - for (const currentDexName of dexsToSubscribe) { - // Set up clearinghouseState subscription for positions + account - subscriptionPromises.push( - this.#ensureClearinghouseStateSubscription( - userAddress, - currentDexName, - ), - ); - - // Set up openOrders subscription for orders - subscriptionPromises.push( - this.#ensureOpenOrdersSubscription(userAddress, currentDexName), - ); - } - - // Also set up webData3 for OI caps only - const webData3Promise = subscriptionClient - .webData3({ user: userAddress }, (data: WebData3WsEvent) => { - try { - // webData3 is ONLY used for OI caps extraction - // Positions, orders, and account data come from individual subscriptions - const allOICaps: string[] = []; - data.perpDexStates.forEach((dexState, index) => { - // Map webData3 index to DEX name - // Index 0 = main DEX (null), Index 1+ = HIP-3 DEXs from discoveredDexNames - const dexIdentifier = - index === 0 ? null : this.#discoveredDexNames[index - 1]; - - // Skip unknown DEXs (not in discoveredDexNames) to prevent main DEX cache corruption - if (index > 0 && dexIdentifier === undefined) { - return; // Unknown DEX - skip to prevent misidentifying as main DEX - } - - // Only process DEXs we care about (skip others silently) - if (!this.#isDexEnabled(dexIdentifier ?? null)) { - return; // Skip this DEX - not enabled in our configuration - } - - const currentDexName = dexIdentifier ?? ''; + // Use per-DEX clearinghouseState + openOrders subscriptions for + // positions/orders/account on every path. webData3 is used only for OI + // caps extraction. The deprecated webData2 channel is no longer used. + + // Determine which DEXs to subscribe to: + // - HIP-3 enabled: main DEX + each enabled HIP-3 DEX. + // - HIP-3 disabled: main DEX only. + const dexsToSubscribe = this.#hip3Enabled + ? [ + '', + ...this.#enabledDexs.filter((dexId) => this.#isDexEnabled(dexId)), + ] + : ['']; + + // Track expected DEXs for synchronized notifications + // Clear previous tracking and set new expected DEXs + this.#expectedDexs = new Set(dexsToSubscribe); + this.#initializedDexs = new Set(); + + // Set up individual subscriptions for each DEX + const subscriptionPromises: Promise[] = []; + + for (const currentDexName of dexsToSubscribe) { + // Set up clearinghouseState subscription for positions + account + subscriptionPromises.push( + this.#ensureClearinghouseStateSubscription( + userAddress, + currentDexName, + ), + ); - const oiCaps = dexState.perpsAtOpenInterestCap ?? []; + // Set up openOrders subscription for orders + subscriptionPromises.push( + this.#ensureOpenOrdersSubscription(userAddress, currentDexName), + ); + } - // 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); - } - }); - - // Update OI caps cache and notify if changed - const oiCapsHash = [...allOICaps] - .sort((a: string, b: string) => a.localeCompare(b)) - .join(','); - if (oiCapsHash !== this.#cachedOICapsHash) { - this.#cachedOICaps = allOICaps; - this.#cachedOICapsHash = oiCapsHash; - this.#oiCapsCacheInitialized = true; - - // Notify all subscribers - this.#oiCapSubscribers.forEach((callback) => - callback(allOICaps), - ); + // Also set up webData3 for OI caps only + const webData3Promise = subscriptionClient + .webData3({ user: userAddress }, (data: WebData3WsEvent) => { + try { + // webData3 is ONLY used for OI caps extraction + // Positions, orders, and account data come from individual subscriptions + const allOICaps: string[] = []; + data.perpDexStates.forEach((dexState, index) => { + // Map webData3 index to DEX name + // Index 0 = main DEX (null), Index 1+ = HIP-3 DEXs from discoveredDexNames + const dexIdentifier = + index === 0 ? null : this.#discoveredDexNames[index - 1]; + + // Skip unknown DEXs (not in discoveredDexNames) to prevent main DEX cache corruption + if (index > 0 && dexIdentifier === undefined) { + return; // Unknown DEX - skip to prevent misidentifying as main DEX } - } catch (error) { - this.#logErrorUnlessClearing( - ensureError( - error, - 'HyperLiquidSubscriptionService.createUserDataSubscription', - ), - this.#getErrorContext('webData3 callback error', { - user: userAddress, - hasPerpDexStates: data?.perpDexStates !== undefined, - perpDexStatesLength: data?.perpDexStates?.length ?? 0, - }), - ); - } - }) - .then((sub) => { - this.#webData3Subscriptions.set(dexName, sub); - this.#deps.debugLogger.log( - `webData3 subscription established for OI caps (main + HIP-3)`, - ); - return undefined; - }) - .catch((error) => { - this.#logErrorUnlessClearing( - ensureError( - error, - 'HyperLiquidSubscriptionService.createUserDataSubscription', - ), - this.#getErrorContext('createUserDataSubscription (webData3)', { - dex: dexName, - }), - ); - throw error; - }); - subscriptionPromises.push(webData3Promise); - - // Wait for all subscriptions to be established - Promise.all(subscriptionPromises) - .then(() => { - this.#deps.debugLogger.log( - `HIP-3 user data subscriptions established for ${dexsToSubscribe.length} DEXs`, - ); - resolve(); - return undefined; - }) - .catch((error) => { - this.#logErrorUnlessClearing( - ensureError( - error, - 'HyperLiquidSubscriptionService.createUserDataSubscription', - ), - this.#getErrorContext('createUserDataSubscription (HIP-3)', { - dexs: dexsToSubscribe, - }), - ); - reject( - ensureError( - error, - 'HyperLiquidSubscriptionService.createUserDataSubscription', - ), - ); - }); - } else { - // HIP-3 disabled: Use webData2 (main DEX only) - subscriptionClient - .webData2({ user: userAddress }, (data: WebData2WsEvent) => { - try { - // webData2 returns clearinghouseState for main DEX only - const currentDexName = ''; // Main DEX - - // Check for removed fields before accessing - if (!data.clearinghouseState) { - return; + // Only process DEXs we care about (skip others silently) + if (!this.#isDexEnabled(dexIdentifier ?? null)) { + return; // Skip this DEX - not enabled in our configuration } - // Extract and process positions from clearinghouseState - const positions = data.clearinghouseState.assetPositions - .filter((assetPos) => assetPos.position.szi !== '0') - .map((assetPos) => adaptPositionFromSDK(assetPos)); - - // Extract TP/SL from orders - const { - tpslMap, - tpslCountMap, - processedOrders: orders, - } = this.#extractTPSLFromOrders(data.openOrders || [], positions); - - // Merge TP/SL data into positions - const positionsWithTPSL = this.#mergeTPSLIntoPositions( - positions, - tpslMap, - tpslCountMap, - ); + const currentDexName = dexIdentifier ?? ''; - // Extract account data (webData2 provides clearinghouseState) - const accountState: AccountState = adaptAccountStateFromSDK( - data.clearinghouseState, - ); + const oiCaps = dexState.perpsAtOpenInterestCap ?? []; - // Store in caches (main DEX only) - this.#dexPositionsCache.set(currentDexName, positionsWithTPSL); - this.#dexOrdersCache.set(currentDexName, orders); - this.#dexAccountCache.set(currentDexName, accountState); - - // OI caps (main DEX only) - const oiCaps = data.perpsAtOpenInterestCap ?? []; - const oiCapsHash = [...oiCaps] - .sort((a: string, b: string) => a.localeCompare(b)) - .join(','); - if (oiCapsHash !== this.#cachedOICapsHash) { - this.#cachedOICaps = oiCaps; - this.#cachedOICapsHash = oiCapsHash; - this.#oiCapsCacheInitialized = true; - this.#oiCapSubscribers.forEach((callback) => callback(oiCaps)); - } - - // Notify subscribers (no aggregation needed - only main DEX). - // Apply spot balance so single-DEX accounts see the same - // spot-inclusive totalBalance as the HIP-3 aggregation path. - const spotAdjustedAccount = addSpotBalanceToAccountState( - accountState, - this.#cachedSpotState, - this.#getSpotBalanceOptions(), - ); - - const positionsHash = this.#hashPositions(positionsWithTPSL); - const ordersHash = this.#hashOrders(orders); - const accountHash = this.#hashAccountState(spotAdjustedAccount); - - if (positionsHash !== this.#cachedPositionsHash) { - this.#cachedPositions = positionsWithTPSL; - this.#cachedPositionsHash = positionsHash; - this.#positionsCacheInitialized = true; - this.#positionSubscribers.forEach((callback) => - callback(positionsWithTPSL), + // 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); } + }); - if (ordersHash !== this.#cachedOrdersHash) { - this.#cachedOrders = orders; - this.#cachedOrdersHash = ordersHash; - this.#ordersCacheInitialized = true; - this.#orderSubscribers.forEach((callback) => callback(orders)); - } - - if (accountHash !== this.#cachedAccountHash) { - this.#cachedAccount = spotAdjustedAccount; - this.#cachedAccountHash = accountHash; - this.#accountSubscribers.forEach((callback) => - callback(spotAdjustedAccount), - ); - } - } catch (error) { - this.#logErrorUnlessClearing( - ensureError( - error, - 'HyperLiquidSubscriptionService.createUserDataSubscription', - ), - this.#getErrorContext('webData2 callback error', { - user: userAddress, - dataKeys: data ? Object.keys(data) : 'data is null/undefined', - hasClearinghouseState: data?.clearinghouseState !== undefined, - hasOpenOrders: data?.openOrders !== undefined, - hasPerpsAtOpenInterestCap: - data?.perpsAtOpenInterestCap !== undefined, - }), - ); + // Update OI caps cache and notify if changed + const oiCapsHash = [...allOICaps] + .sort((a: string, b: string) => a.localeCompare(b)) + .join(','); + if (oiCapsHash !== this.#cachedOICapsHash) { + this.#cachedOICaps = allOICaps; + this.#cachedOICapsHash = oiCapsHash; + this.#oiCapsCacheInitialized = true; + + // Notify all subscribers + this.#oiCapSubscribers.forEach((callback) => callback(allOICaps)); } - }) - .then((subscription) => { - this.#webData3Subscriptions.set(dexName, subscription); - this.#deps.debugLogger.log( - 'webData2 subscription established for main DEX only', - ); - resolve(); - return undefined; - }) - .catch((error) => { + } catch (error) { this.#logErrorUnlessClearing( ensureError( error, 'HyperLiquidSubscriptionService.createUserDataSubscription', ), - this.#getErrorContext('createUserDataSubscription (webData2)', { - dex: dexName, + this.#getErrorContext('webData3 callback error', { + user: userAddress, + hasPerpDexStates: data?.perpDexStates !== undefined, + perpDexStatesLength: data?.perpDexStates?.length ?? 0, }), ); - reject( - ensureError( - error, - 'HyperLiquidSubscriptionService.createUserDataSubscription', - ), - ); - }); - } + } + }) + .then((sub) => { + this.#webData3Subscriptions.set(dexName, sub); + this.#deps.debugLogger.log( + `webData3 subscription established for OI caps (main + HIP-3)`, + ); + return undefined; + }) + .catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createUserDataSubscription', + ), + this.#getErrorContext('createUserDataSubscription (webData3)', { + dex: dexName, + }), + ); + throw error; + }); + + subscriptionPromises.push(webData3Promise); + + // Wait for all subscriptions to be established + Promise.all(subscriptionPromises) + .then(() => { + this.#deps.debugLogger.log( + `User data subscriptions established for ${dexsToSubscribe.length} DEX(s)`, + ); + resolve(); + return undefined; + }) + .catch((error) => { + this.#logErrorUnlessClearing( + ensureError( + error, + 'HyperLiquidSubscriptionService.createUserDataSubscription', + ), + this.#getErrorContext('createUserDataSubscription', { + dexs: dexsToSubscribe, + }), + ); + reject( + ensureError( + error, + 'HyperLiquidSubscriptionService.createUserDataSubscription', + ), + ); + }); }); } @@ -2480,7 +2353,7 @@ export class HyperLiquidSubscriptionService { this.#cachedAccountHash = ''; this.#deps.debugLogger.log( - 'All multi-DEX subscriptions cleaned up (webData2/3 + individual subscriptions)', + 'All multi-DEX subscriptions cleaned up (webData3 + individual subscriptions)', ); } } @@ -2526,7 +2399,7 @@ export class HyperLiquidSubscriptionService { /** * Subscribe to open interest cap updates - * OI caps are extracted from webData2 subscription (zero additional overhead) + * OI caps are extracted from the shared webData3 subscription (zero additional overhead) * * @param params - The subscription parameters including callback and account ID. * @returns A cleanup function to unsubscribe from OI cap updates. @@ -2728,7 +2601,7 @@ export class HyperLiquidSubscriptionService { /** * Subscribe to live order updates - * Uses the shared webData2 subscription to avoid duplicate connections + * Uses the shared per-DEX subscriptions to avoid duplicate connections * * @param params - The subscription parameters including callback and account ID. * @returns A cleanup function to unsubscribe from order updates. @@ -2765,7 +2638,7 @@ export class HyperLiquidSubscriptionService { /** * Subscribe to live account updates - * Uses the shared webData2 subscription to avoid duplicate connections + * Uses the shared per-DEX subscriptions to avoid duplicate connections * * @param params - The subscription parameters including callback and account ID. * @returns A cleanup function to unsubscribe from account updates. @@ -4026,12 +3899,12 @@ export class HyperLiquidSubscriptionService { this.#webData3Subscriptions.clear(); this.#webData3SubscriptionPromise = undefined; - // Clear individual subscriptions (clearinghouseState + openOrders) for HIP-3 mode + // Clear individual subscriptions (clearinghouseState + openOrders) this.#clearinghouseStateSubscriptions.clear(); this.#openOrdersSubscriptions.clear(); // Re-establish the subscription (will use current account) - // This will set up webData2 for non-HIP-3, or individual subscriptions + webData3 (OI caps only) for HIP-3 + // This sets up per-DEX clearinghouseState + openOrders subscriptions plus webData3 (OI caps only) await this.#ensureSharedWebData3Subscription(); } diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts index 22fabe1e93a..895bdf12c9c 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -1192,7 +1192,7 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe(); }); - it('includes spot balance in webData2 (single-DEX) account updates without flickering', async () => { + it('includes spot balance in single-DEX account updates without flickering', async () => { jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({ spendableBalance: '50', withdrawableBalance: '50', @@ -1202,24 +1202,25 @@ describe('HyperLiquidSubscriptionService', () => { returnOnEquity: '0.05', })); - let webData2Callback: ((data: any) => void) | undefined; - mockSubscriptionClient.webData2.mockImplementation( + // HIP-3 disabled now uses the per-DEX clearinghouseState subscription + // (not the deprecated webData2 channel) for account updates. + const clearinghouseData = { + dex: '', + clearinghouseState: { + assetPositions: [], + marginSummary: { + accountValue: '200', + totalMarginUsed: '10', + }, + withdrawable: '50', + }, + }; + + let clearinghouseCallback: ((data: any) => void) | undefined; + mockSubscriptionClient.clearinghouseState.mockImplementation( (_params: any, callback: any) => { - webData2Callback = callback; - setTimeout(() => { - callback({ - clearinghouseState: { - assetPositions: [], - marginSummary: { - accountValue: '200', - totalMarginUsed: '10', - }, - withdrawable: '50', - }, - openOrders: [], - perpsAtOpenInterestCap: [], - }); - }, 0); + clearinghouseCallback = callback; + setTimeout(() => callback(clearinghouseData), 0); return Promise.resolve({ unsubscribe: jest.fn().mockResolvedValue(undefined), }); @@ -1240,6 +1241,7 @@ describe('HyperLiquidSubscriptionService', () => { await jest.runAllTimersAsync(); + expect(mockSubscriptionClient.webData2).not.toHaveBeenCalled(); expect(mockCallback).toHaveBeenCalled(); const firstUpdate = mockCallback.mock.calls.at(-1)[0]; // Unified-mode default: freeSpot ($100.77) folds into spendable and @@ -1254,20 +1256,9 @@ describe('HyperLiquidSubscriptionService', () => { // Simulate a second WebSocket tick — should still include spot balance, // not revert to perps-only 200. mockCallback.mockClear(); - expect(webData2Callback).toBeDefined(); + expect(clearinghouseCallback).toBeDefined(); - webData2Callback!({ - clearinghouseState: { - assetPositions: [], - marginSummary: { - accountValue: '200', - totalMarginUsed: '10', - }, - withdrawable: '50', - }, - openOrders: [], - perpsAtOpenInterestCap: [], - }); + clearinghouseCallback!(clearinghouseData); await jest.runAllTimersAsync(); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts index c18fd2e5413..439114ff477 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.streams.test.ts @@ -1077,7 +1077,7 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe2(); }); - it('uses webData2 subscription when HIP-3 is disabled', async () => { + it('uses per-DEX subscriptions (not webData2) when HIP-3 is disabled', async () => { // Arrange const positionCallback = jest.fn(); const orderCallback = jest.fn(); @@ -1097,43 +1097,42 @@ describe('HyperLiquidSubscriptionService', () => { '0x123' as Hex, ); - // Mock webData2 to call callback with clearinghouseState data - mockSubscriptionClient.webData2.mockImplementation( - (_addr: any, callback: any) => { + // Positions + account come from the main-DEX clearinghouseState subscription + mockSubscriptionClient.clearinghouseState.mockImplementation( + (_params: any, callback: any) => { setTimeout(() => { callback({ + dex: '', clearinghouseState: { assetPositions: [ { position: { coin: 'BTC', szi: '1.5', - entryPx: '50000', - positionValue: '75000', - unrealizedPnl: '5000', - returnOnEquity: '0.1', - leverage: { type: 'cross', value: 10 }, - liquidationPx: '45000', - marginUsed: '7500', }, }, ], marginSummary: { accountValue: '100000', totalMarginUsed: '7500', - totalNtlPos: '75000', - totalRawUsd: '100000', }, withdrawable: '92500', - crossMarginSummary: { - accountValue: '100000', - totalMarginUsed: '7500', - totalNtlPos: '75000', - totalRawUsd: '100000', - }, - time: Date.now(), }, - openOrders: [ + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // Orders come from the main-DEX openOrders subscription + mockSubscriptionClient.openOrders.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + dex: '', + orders: [ { oid: 456, coin: 'ETH', @@ -1142,12 +1141,31 @@ describe('HyperLiquidSubscriptionService', () => { origSz: '2.0', limitPx: '3000', orderType: 'Limit', - timestamp: Date.now(), + timestamp: 1234567890000, isTrigger: false, reduceOnly: false, }, ], - perpsAtOpenInterestCap: ['BTC', 'DOGE'], + }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + // OI caps still come from webData3 (acceptable latency) + mockSubscriptionClient.webData3.mockImplementation( + (_params: any, callback: any) => { + setTimeout(() => { + callback({ + perpDexStates: [ + { + clearinghouseState: { assetPositions: [] }, + openOrders: [], + perpsAtOpenInterestCap: ['BTC', 'DOGE'], + }, + ], }); }, 0); return Promise.resolve({ @@ -1172,13 +1190,21 @@ describe('HyperLiquidSubscriptionService', () => { await jest.runAllTimersAsync(); - // Assert - expect(mockSubscriptionClient.webData2).toHaveBeenCalledTimes(1); - expect(mockSubscriptionClient.webData2).toHaveBeenCalledWith( + // Assert: webData2 is never used; positions/orders/account come from the + // per-DEX subscriptions and OI caps from webData3. + expect(mockSubscriptionClient.webData2).not.toHaveBeenCalled(); + expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( + expect.objectContaining({ user: '0x123' }), + expect.any(Function), + ); + expect(mockSubscriptionClient.openOrders).toHaveBeenCalledWith( + expect.objectContaining({ user: '0x123' }), + expect.any(Function), + ); + expect(mockSubscriptionClient.webData3).toHaveBeenCalledWith( { user: '0x123' }, expect.any(Function), ); - expect(mockSubscriptionClient.webData3).not.toHaveBeenCalled(); expect(positionCallback).toHaveBeenCalledWith( expect.arrayContaining([