diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index f4ed9f0c52..33d9eb95d2 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- On `subscribeToPrices` calls with `includeMarketData: true` (i.e. focused single-symbol screens), the `price` field in each `PriceUpdate` is now driven by the per-symbol `activeAssetCtx` WebSocket stream (`midPx`, falling back to `markPx`) rather than the main-DEX `allMids` snapshot, which Hyperliquid throttles to a ~5 s push cadence ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + - The fast-stream price is preferred only while it is fresh (within a 10 s staleness window); `allMids` remains the fallback if the `activeAssetCtx` stream goes silent. + - Subscriptions with `includeMarketData: false` (list/overview screens) are unaffected and continue to use `allMids` exclusively. + - No new WebSocket subscriptions are created; `activeAssetCtx` was already established for `includeMarketData: true` subscriptions. ## [8.1.0] diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index 97cde840cc..f6fb867e67 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -303,9 +303,19 @@ export class HyperLiquidSubscriptionService { volume24h?: number; oraclePrice?: number; lastUpdated: number; + // Fast-stream price from activeAssetCtx (midPx preferred, markPx fallback). + // Populated only for symbols with includeMarketData subscriptions. + // Used in #createPriceUpdate to prefer this over the 5s-cadence allMids snapshot. + activeAssetCtxPrice?: number; + // Timestamp of the last activeAssetCtx price update, used for staleness checks. + priceLastUpdated?: number; } >(); + // Stale threshold for activeAssetCtx price preference. If the last fast-stream + // price update is older than this, #createPriceUpdate falls back to allMids. + static readonly #activeAssetCtxPriceTtlMs = 10_000; + // Flag to suppress error logging during intentional disconnect // Set in clearAll() and never reset (service instance is discarded after disconnect) #isClearing = false; @@ -2831,7 +2841,23 @@ export class HyperLiquidSubscriptionService { #createPriceUpdate(symbol: string, price: string): PriceUpdate { const marketData = this.#marketDataCache.get(symbol); const orderBookData = this.#orderBookCache.get(symbol); - const currentPrice = parseFloat(price); + + // Prefer the fast-stream price from activeAssetCtx over the passed-in + // allMids price when one is cached and fresh enough. This absorbs the + // upcoming 5s allMids push cadence on detail/ticket screens without + // affecting list/overview screens (which never populate activeAssetCtxPrice). + const now = Date.now(); + const hasFreshActiveAssetCtxPrice = + marketData?.activeAssetCtxPrice !== undefined && + marketData.priceLastUpdated !== undefined && + now - marketData.priceLastUpdated <= + HyperLiquidSubscriptionService.#activeAssetCtxPriceTtlMs; + + const effectivePrice = hasFreshActiveAssetCtxPrice + ? (marketData.activeAssetCtxPrice as number).toString() + : price; + + const currentPrice = parseFloat(effectivePrice); let percentChange24h: string | undefined; if (marketData?.prevDayPx !== undefined) { @@ -2847,8 +2873,8 @@ export class HyperLiquidSubscriptionService { const priceUpdate = { symbol, - price, // This is the mid price from allMids - timestamp: Date.now(), + price: effectivePrice, + timestamp: now, percentChange24h, // Add mark price from activeAssetCtx markPrice: marketData?.oraclePrice @@ -2872,6 +2898,18 @@ export class HyperLiquidSubscriptionService { /** * Ensure global allMids subscription is active (singleton pattern) + * + * NOTE ON PUSH CADENCE: Hyperliquid throttles the main-DEX allMids stream to + * push every ~5 seconds. This cadence is acceptable for list/overview screens + * that show many symbols simultaneously, but would make a focused single-symbol + * view (trade detail, order ticket) feel noticeably stale. + * + * Mitigation: when a subscription is created with `includeMarketData: true`, + * #ensureActiveAssetSubscription also establishes a per-symbol activeAssetCtx + * WebSocket that ticks at a faster cadence. #createPriceUpdate prefers that + * fast-stream price over this allMids snapshot (with a 10s staleness gate via + * #ACTIVE_ASSET_CTX_PRICE_TTL_MS), so detail screens stay responsive. Screens + * that use only allMids (includeMarketData: false) are unaffected. */ #ensureGlobalAllMidsSubscription(): void { // Check both the subscription AND the promise to prevent race conditions @@ -3020,6 +3058,7 @@ export class HyperLiquidSubscriptionService { // Cache market data for consolidation with price updates const ctxPrice = ctx.midPx ?? ctx.markPx; + const now = Date.now(); const openInterestUSD = isPerpsContext(data) && ctxPrice ? calculateOpenInterestUSD(data.ctx.openInterest, ctxPrice) @@ -3042,23 +3081,35 @@ export class HyperLiquidSubscriptionService { oraclePrice: isPerpsContext(data) ? parseFloat(data.ctx.oraclePx.toString()) : undefined, - lastUpdated: Date.now(), + lastUpdated: now, + // Store fast-stream price so #createPriceUpdate can prefer it over + // the 5s-cadence allMids snapshot on detail/ticket screens. + activeAssetCtxPrice: ctxPrice + ? parseFloat(ctxPrice.toString()) + : undefined, + priceLastUpdated: ctxPrice ? now : undefined, }; this.#marketDataCache.set(symbol, marketData); - // Update cached price data with new 24h change if we have current price - const currentCachedPrice = this.#cachedPriceData?.get(symbol); - if (currentCachedPrice) { - const updatedPrice = this.#createPriceUpdate( - symbol, - currentCachedPrice.price, - ); - - this.#cachedPriceData ??= new Map(); - this.#cachedPriceData.set(symbol, updatedPrice); - this.#notifyAllPriceSubscribers(); - } + // Drive a price update from the fast-stream price. Use the allMids + // fallback string if ctxPrice is absent so #createPriceUpdate still + // has something to work with. The preference logic inside + // #createPriceUpdate will pick up the cached activeAssetCtxPrice. + const fallbackPrice = + this.#cachedPriceData?.get(symbol)?.price ?? '0'; + const priceForUpdate = ctxPrice + ? ctxPrice.toString() + : fallbackPrice; + + // Notify unconditionally so the first activeAssetCtx tick surfaces a + // price even if no allMids tick has arrived yet. + this.#cachedPriceData ??= new Map(); + this.#cachedPriceData.set( + symbol, + this.#createPriceUpdate(symbol, priceForUpdate), + ); + this.#notifyAllPriceSubscribers(); } }, ) diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts index d87a1380ce..d9dcf6d64e 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts @@ -2559,4 +2559,252 @@ describe('HyperLiquidSubscriptionService', () => { expect(result2).toBeNull(); }); }); + + describe('activeAssetCtx price preference', () => { + it('uses activeAssetCtx midPx as the displayed price when both streams are active', async () => { + const mockCallback = jest.fn(); + + // allMids will push 50000, activeAssetCtx will push 50500 (faster stream) + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + setTimeout(() => { + callback({ mids: { BTC: '50000' } }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + setTimeout(() => { + callback({ + coin: params.coin, + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', // faster-stream price – should win + }, + }); + }, 5); // fire after allMids to exercise preference logic + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // The last notification should carry the activeAssetCtx price, not allMids + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + price: '50500', + }), + ]), + ); + + unsubscribe(); + }); + + it('does not replace a fresher activeAssetCtx price when allMids ticks again', async () => { + let allMidsCallback: ((data: any) => void) | undefined; + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + allMidsCallback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Fire activeAssetCtx first with a fresh price + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', + }, + }); + + mockCallback.mockClear(); + + // allMids fires again with the older/stale price – should NOT win + allMidsCallback?.({ mids: { BTC: '50000' } }); + + await jest.runAllTimersAsync(); + + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + price: '50500', // still the activeAssetCtx price + }), + ]), + ); + + unsubscribe(); + }); + + it('uses allMids price when includeMarketData is false (list/overview screens)', async () => { + const mockCallback = jest.fn(); + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + const callback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + setTimeout(() => { + callback({ mids: { BTC: '50000' } }); + }, 0); + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: false, + }); + + await jest.runAllTimersAsync(); + + // activeAssetCtx should not have been called at all + expect(mockSubscriptionClient.activeAssetCtx).not.toHaveBeenCalled(); + + // Price should come from allMids + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + price: '50000', + }), + ]), + ); + + unsubscribe(); + }); + + it('falls back to allMids price when activeAssetCtx price is stale (beyond TTL)', async () => { + let allMidsCallback: ((data: any) => void) | undefined; + let activeAssetCallback: ((data: any) => void) | undefined; + + mockSubscriptionClient.allMids.mockImplementation( + (paramsOrCallback: any, maybeCallback?: any) => { + allMidsCallback = + typeof paramsOrCallback === 'function' + ? paramsOrCallback + : maybeCallback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + mockSubscriptionClient.activeAssetCtx.mockImplementation( + (params: any, callback: any) => { + activeAssetCallback = callback; + return Promise.resolve({ + unsubscribe: jest.fn().mockResolvedValue(undefined), + }); + }, + ); + + const mockCallback = jest.fn(); + const unsubscribe = await service.subscribeToPrices({ + symbols: ['BTC'], + callback: mockCallback, + includeMarketData: true, + }); + + await jest.runAllTimersAsync(); + + // Fire activeAssetCtx with a price + activeAssetCallback?.({ + coin: 'BTC', + ctx: { + prevDayPx: '49000', + funding: '0.01', + openInterest: '1000000', + dayNtlVlm: '50000000', + oraclePx: '50100', + midPx: '50500', + }, + }); + + // Advance time beyond the 10s TTL so the cached fast-stream price is stale + jest.advanceTimersByTime(11_000); + + mockCallback.mockClear(); + + // allMids fires – should now win because fast-stream price is stale + allMidsCallback?.({ mids: { BTC: '50000' } }); + + await jest.runAllTimersAsync(); + + const lastCall = + mockCallback.mock.calls[mockCallback.mock.calls.length - 1][0]; + expect(lastCall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'BTC', + price: '50000', // allMids wins after TTL expires + }), + ]), + ); + + unsubscribe(); + }); + }); });