import { getMarketRegistry, isActiveMarket, isActiveOrderStatus, type MarketRegistry, PerpsError, type PerpsSDKClient, type ProviderGetQuoteParams, type QuoteListener, ReconnectingWebSocket, resolveSubscribeQuote, type SubscriptionListener, summarizeAccount, toPerpsMarketDisplay, WsProviderBase, type WsProviderFactory, type WsProviderFactoryParams, type WsStatusListener, wsLog, } from '@lifi/perps-sdk' import type { AccountResponse, Balance, MarketContext, Order, OrderbookLevel, OrderbookResponse, Position, Subscription, } from '@lifi/perps-types' import { PerpsErrorCode } from '@lifi/perps-types' import Big from 'big.js' import { isAddress } from 'viem' import { DEFAULT_HYPERLIQUID_API_URL, HYPERLIQUID_FEE_TIER_FALLBACK, SPOT_MARKET_ID, } from '../constants.js' import type { HlAssetPosition, HlOrderDetail, HlOrderStatusResponse, HlUserFill, HlWsActiveAssetCtxData, HlWsActiveSpotAssetCtxData, HlWsAllDexsAssetCtxsData, HlWsAllDexsClearinghouseStateData, HlWsCandleData, HlWsCompressedL2Data, HlWsFastAssetCtx, HlWsL2BookData, HlWsL2Data, HlWsMessage, HlWsOrder, HlWsPacData, HlWsPerpAssetCtx, HlWsPerpAssetCtxPayload, HlWsSacData, HlWsSpotAssetCtx, HlWsSpotStateData, HlWsTrade, HlWsUserFillsData, } from '../types/index.js' import { HlAbstractionMode } from '../types/index.js' import { toWireBig } from '../utils/decimal.js' import { assetIsOutcome, decodeCompressedJson, decodeFastAssetCtxs, hlInfoOptions, infoRequest, isOpenAssetPosition, mapFill, mapMarketContext, mapOrder, mapPosition, partitionSpotBalances, priceStepToAggregation, spotAssetFromToken, spotBalance, spotPriceById, } from '../utils/index.js' import { DecodeChain } from './decodeChain.js' /** HL's compact `l2` snapshot carries 20 levels per side. */ const HL_L2_BOOK_MAX_LEVELS_PER_SIDE = 20 const normalizeHlAddress = (address: string): string => isAddress(address, { strict: false }) ? address.toLowerCase() : address /** * `WsProviderFactory` constructor for Hyperliquid — pass to * `new PerpsWsClient(client, { wsProviders: { hyperliquid: hyperliquidWsProvider() } })`. * * Higher-order shape mirrors `lighterWsProvider(options)` so the two factories * register identically. * * @public */ export const hyperliquidWsProvider = (): WsProviderFactory => Object.assign( ({ provider, wsUrl, client }: WsProviderFactoryParams) => new HyperliquidWsProvider(wsUrl, provider, client), { streamsCandles: true } ) /** * Hyperliquid WebSocket {@link WsProvider}: multiplexes markets context, * positions, orders, fills and spot balances over a single * {@link ReconnectingWebSocket}. * Construct via {@link hyperliquidWsProvider}. * * `orderUpdates` supports a single address per provider instance: HL delivers * those frames without a user field, so frames from two concurrent address * subscriptions on one socket cannot be attributed. Subscribing a second * address rejects while the first still has listeners; once the first is * released, an address switch reclaims the channel immediately. * * @public */ export class HyperliquidWsProvider extends WsProviderBase { private orderUpdatesKey: string | undefined private orderUpdatesEpoch = 0 private readonly orderApiUrl: string private readonly clearinghouseRefs = new Map() private readonly client: PerpsSDKClient | undefined private readonly registry: MarketRegistry | undefined // The clearinghouse stream covers perps equity only, so its summary is // honest solely for modes whose collateral lives per-dex. The abstraction // mode is read per subscribed user (null = never set = standard) and // selects the summary source: standard/dexAbstraction emit the equity // summary straight from clearinghouse frames, unified/portfolio run the // spot-fed pipeline below. Frames arriving before the first read are held // and released on resolution (delayed, never wrong). The read refreshes on // fresh summary subscribes and once older than the TTL, and drops with // the user's last clearinghouse subscription. private readonly abstractionByUser = new Map< string, { mode: HlAbstractionMode | null; readAt: number } | 'pending' >() private readonly spotRefs = new Map() // Unified/portfolio summary pipeline: collateral lives in spot, margin and // uPnL on the positions — the summary recomputes from the latest of both // envelopes with the same gross calculator the REST getAccountSummary uses. private readonly unifiedSummaryByUser = new Map< string, { releaseSpot: Promise<() => void> spot?: { collateralBalances: Balance[]; balances: Balance[] } } >() // Kept outside the pipeline entry: the first clearinghouse frame lands // while the mode read is still pending, before the pipeline exists. private readonly latestPositionsByUser = new Map() private readonly heldSummaryByUser = new Map< string, { portfolioValue: string availableMargin: string marginUsed: string unrealizedPnl: string } >() private perpCtxBySubDex = new Map>() private spotCtxByMarketId: Record = {} private marketsContextByMarketId: Record = {} private orderbookKeysByMarketId = new Map>() private latestOrderbookByMarketId = new Map() // Latest mid/mark per `Market.id` from the high-frequency `fastAssetCtxs` // feed (all dexes, incl. builder/sub-dex coins). Merged incrementally — // frames carry only changed coins — and overlaid onto asset contexts that // carry oracle/funding/OI/metadata. private fastCtxByMarketId: Record = {} private readonly reportDecodeFailure = (error: unknown) => wsLog.handlerFailure(this.providerKey, error) // Compressed payloads (base64 + raw-DEFLATE) decode async, so each channel // chains decodes to apply frames in arrival order. Only the fast feed // coalesces a stall backlog to its newest frame: a coin's dropped tick is // refreshed by its next one. pac/sac field-merges and l2 deltas depend on // every frame, so they must not coalesce. private readonly fastDecodeChain = new DecodeChain( 'latest', this.reportDecodeFailure ) private readonly pacDecodeChain = new DecodeChain( 'every', this.reportDecodeFailure ) private readonly sacDecodeChain = new DecodeChain( 'every', this.reportDecodeFailure ) private readonly orderbookDecodeChain = new DecodeChain( 'every', this.reportDecodeFailure ) private readonly orderUpdateChain = new DecodeChain( 'every', this.reportDecodeFailure ) constructor(wsUrl: string, providerKey: string, client?: PerpsSDKClient) { super( new ReconnectingWebSocket(wsUrl, { pingPayload: '{"method":"ping"}' }), providerKey ) this.client = client this.registry = client && getMarketRegistry(client, providerKey) const orderApiUrl = new URL(wsUrl) orderApiUrl.protocol = orderApiUrl.protocol === 'wss:' ? 'https:' : 'http:' this.orderApiUrl = orderApiUrl.origin } override async subscribe( sub: Subscription, listener: SubscriptionListener, onStatus?: WsStatusListener ): Promise<() => void> { const unsubscribe = await super.subscribe(sub, listener, onStatus) // The summary source is decided by the abstraction mode, so every // summary subscribe (re-)reads it — consumers resubscribe when they // observe an account-config change, and the wire channel is usually // reused (multiplexed listeners, teardown linger), so openChannel cannot // carry this trigger. Coalesced so one React commit's burst of // resubscribing hooks fires a single read. if (sub.channel === 'accountSummary') { const user = sub.address.toLowerCase() const entry = this.abstractionByUser.get(user) if (entry === undefined) { this.fetchAbstractionMode(sub.address, user) } else if ( entry !== 'pending' && Date.now() - entry.readAt > HyperliquidWsProvider.REFRESH_COALESCE_MS ) { this.fetchAbstractionMode(sub.address, user, entry) } } return unsubscribe } async subscribeQuote( params: ProviderGetQuoteParams, onQuote: QuoteListener ): Promise<() => void> { const client = this.client if (client === undefined) { throw new Error( 'HyperliquidWsProvider: PerpsSDKClient not provided; cannot stream quotes. ' + 'Construct via `hyperliquidWsProvider()` and register with PerpsWsClient.' ) } return resolveSubscribeQuote( client, this.providerKey, this, params, HYPERLIQUID_FEE_TIER_FALLBACK, onQuote ) } protected async openChannel(sub: Subscription): Promise<() => void> { // Must run synchronously, before any await, so two concurrent opens // cannot both pass the exclusivity check. if (sub.channel === 'orderUpdates') { this.claimOrderUpdatesKey(this.toKey(sub)) } await this.registry?.sync() if ( this.registry !== undefined && (sub.channel === 'marketContext' || sub.channel === 'orderbook' || sub.channel === 'candle' || sub.channel === 'trades') ) { this.registry.requireActive(sub.marketId) } // Markets context aggregates slower asset-context feeds with fast mid/mark // ticks from `fastAssetCtxs`. if (sub.channel === 'marketsContext') { const entries = this.getMarketsContextSubEntries() for (const { subKey, payload } of entries) { await this.registerSub(subKey, payload) } await this.rws.ready() return () => { for (const { subKey, payload } of entries) { this.unregisterSub(subKey) this.rws.send( JSON.stringify({ method: 'unsubscribe', subscription: payload }) ) } this.perpCtxBySubDex.clear() this.spotCtxByMarketId = {} this.fastCtxByMarketId = {} this.marketsContextByMarketId = {} } } // The spot wire is shared between the public spotBalances channel and // the unified-summary pipeline; refcounted like the clearinghouse sub. if (sub.channel === 'spotBalances') { const release = await this.acquireSpotWire(sub.address) await this.rws.ready() return release } // `positions` and `accountSummary` are two views over the same wire // subscription; refcount it so neither's teardown starves the other. if (sub.channel === 'positions' || sub.channel === 'accountSummary') { const user = sub.address.toLowerCase() const wireKey = `clearinghouse:${user}` const payload = this.toHlPayload(sub) const count = this.clearinghouseRefs.get(user) ?? 0 this.clearinghouseRefs.set(user, count + 1) if (count === 0) { await this.registerSub(wireKey, payload) } await this.rws.ready() return () => { const remaining = (this.clearinghouseRefs.get(user) ?? 1) - 1 if (remaining <= 0) { this.clearinghouseRefs.delete(user) // Drop the summary gate's mode read (and its pipeline) with the // subscription so a resubscribe reflects an account-mode change. this.abstractionByUser.delete(user) this.heldSummaryByUser.delete(user) this.latestPositionsByUser.delete(user) this.syncUnifiedPipeline(user, user, null) this.unregisterSub(wireKey) this.rws.send( JSON.stringify({ method: 'unsubscribe', subscription: payload }) ) } else { this.clearinghouseRefs.set(user, remaining) } } } // All other channels: single WS subscription per key const key = this.toKey(sub) if (sub.channel === 'orderbook') { this.claimOrderbookKey(sub.marketId, key) } const payload = this.toHlPayload(sub) await this.registerSub(key, payload) if (sub.channel === 'orderbook') { this.registerOrderbook(sub.marketId, key) } await this.rws.ready() return () => { this.unregisterSub(key) if (this.orderUpdatesKey === key) { this.orderUpdatesKey = undefined this.orderUpdatesEpoch++ } this.rws.send( JSON.stringify({ method: 'unsubscribe', subscription: payload, }) ) if (sub.channel === 'orderbook') { this.releaseOrderbook(sub.marketId, key) } } } /** * Enforce the single-address `orderUpdates` invariant (HL frames carry no * user field, so concurrent address subscriptions are unattributable). A * listener-free lingering channel for another address is torn down eagerly * so a wallet switch needn't wait out the teardown linger; a live one throws. */ private claimOrderUpdatesKey(key: string): void { const active = this.orderUpdatesKey if ( active !== undefined && active !== key && !this.closeChannelIfIdle(active) ) { throw new Error( `Hyperliquid supports one orderUpdates address per provider instance ` + `(frames carry no user field). Unsubscribe ` + `${active.slice('orderUpdates:'.length)} before subscribing ` + `${key.slice('orderUpdates:'.length)}.` ) } if (active !== key) { this.orderUpdatesEpoch++ } this.orderUpdatesKey = key } private claimOrderbookKey(marketId: string, key: string): void { const activeKeys = this.orderbookKeysByMarketId.get(marketId) if (activeKeys === undefined) { return } for (const activeKey of [...activeKeys]) { if (activeKey === key) { continue } if (!this.closeChannelIfIdle(activeKey)) { throw new Error( `Hyperliquid supports one orderbook aggregation per market per ` + `provider instance. Unsubscribe ${activeKey} before subscribing ${key}.` ) } } } /** * Sub-key + payload pairs for the markets-context aggregation: compressed * all-perp (`pac`) and all-spot (`sac`) asset contexts plus the documented * `fastAssetCtxs` feed (high-frequency mid + mark across every dex). */ private getMarketsContextSubEntries(): Array<{ subKey: string payload: object }> { return [ { subKey: 'pac', payload: { type: 'pac' } }, { subKey: 'sac', payload: { type: 'sac' } }, { subKey: 'fastAssetCtxs', payload: { type: 'fastAssetCtxs' } }, ] } private registerOrderbook(marketId: string, orderbookKey: string): void { let activeKeys = this.orderbookKeysByMarketId.get(marketId) if (activeKeys === undefined) { activeKeys = new Set() this.orderbookKeysByMarketId.set(marketId, activeKeys) } activeKeys.add(orderbookKey) } private releaseOrderbook(marketId: string, orderbookKey: string): void { const activeKeys = this.orderbookKeysByMarketId.get(marketId) if (activeKeys === undefined) { return } activeKeys.delete(orderbookKey) if (activeKeys.size > 0) { return } this.orderbookKeysByMarketId.delete(marketId) } protected override onClose(): void { this.perpCtxBySubDex.clear() this.spotCtxByMarketId = {} this.fastCtxByMarketId = {} this.marketsContextByMarketId = {} this.orderbookKeysByMarketId.clear() this.latestOrderbookByMarketId.clear() this.fastDecodeChain.reset() this.pacDecodeChain.reset() this.sacDecodeChain.reset() this.orderbookDecodeChain.reset() this.orderUpdateChain.reset() this.orderUpdatesEpoch++ this.orderUpdatesKey = undefined } protected sendSubscribe(payload: object): void { this.rws.send( JSON.stringify({ method: 'subscribe', subscription: payload }) ) } protected toKey(sub: Subscription): string { switch (sub.channel) { case 'marketsContext': return 'marketsContext' case 'marketContext': return `marketContext:${sub.marketId}` case 'orderbook': return `l2:${sub.marketId}:${this.orderbookAggregationKey( sub.marketId, sub.priceStep )}` case 'candle': return `candle:${sub.marketId}:${sub.interval}` case 'trades': return `trades:${sub.marketId}` case 'orderUpdates': return `orderUpdates:${sub.address.toLowerCase()}` case 'fills': return `userFills:${sub.address.toLowerCase()}` case 'positions': return `positions:${sub.address.toLowerCase()}` case 'accountSummary': return `accountSummary:${sub.address.toLowerCase()}` case 'spotBalances': return `spotState:${sub.address.toLowerCase()}` } } private orderbookAggregation( marketId: string, priceStep: number | undefined ): { nSigFigs?: number; mantissa?: number } { if (priceStep === undefined) { return {} } return priceStepToAggregation( priceStep, this.orderbookReferencePrice(marketId) ) } private orderbookAggregationKey( marketId: string, priceStep: number | undefined ): string { const aggregation = this.orderbookAggregation(marketId, priceStep) const nSigFigs = aggregation.nSigFigs ?? null const mantissa = aggregation.mantissa ?? null if (nSigFigs === null && mantissa === null) { return 'full' } return `s:${nSigFigs}:m:${mantissa}` } private toHlPayload(sub: Subscription): object { switch (sub.channel) { case 'marketsContext': // Handled via getMarketsContextSubEntries in openChannel; never reaches // toHlPayload, but TS requires an exhaustive switch. return { type: 'allDexsAssetCtxs' } case 'marketContext': return { type: 'activeAssetCtx', coin: sub.marketId, } case 'orderbook': { const aggregation = this.orderbookAggregation( sub.marketId, sub.priceStep ) return { type: 'l2', c: sub.marketId, s: aggregation.nSigFigs ?? null, m: aggregation.mantissa ?? null, } } case 'candle': return { type: 'candle', coin: sub.marketId, interval: sub.interval, } case 'trades': return { type: 'trades', coin: sub.marketId, } case 'orderUpdates': return { type: 'orderUpdates', user: normalizeHlAddress(sub.address) } case 'fills': return { type: 'userFills', user: normalizeHlAddress(sub.address) } case 'positions': case 'accountSummary': return { type: 'allDexsClearinghouseState', user: normalizeHlAddress(sub.address), } case 'spotBalances': return { type: 'spotState', user: normalizeHlAddress(sub.address) } } } protected handleMessage(raw: string) { let msg: HlWsMessage try { msg = JSON.parse(raw) } catch { wsLog.parseFailure(this.providerKey, raw) return } if ( !msg.channel || msg.channel === 'pong' || msg.channel === 'subscriptionResponse' ) { return } if (msg.channel === 'error') { wsLog.serverError(this.providerKey, String(msg.data)) return } if (!isValidHlFrame(msg.channel, msg.data)) { wsLog.parseFailure(this.providerKey, raw) return } try { switch (msg.channel) { case 'allDexsAssetCtxs': this.handleAllDexsAssetCtxs(msg.data as HlWsAllDexsAssetCtxsData) break case 'pac': this.handlePac(msg.data as string) break case 'sac': this.handleSac(msg.data as string) break case 'fastAssetCtxs': this.handleFastAssetCtxs(msg.data as string) break case 'activeAssetCtx': this.handleActiveAssetCtx(msg.data as HlWsActiveAssetCtxData, raw) break case 'activeSpotAssetCtx': this.handleActiveSpotAssetCtx(msg.data as HlWsActiveSpotAssetCtxData) break case 'l2': this.handleL2(msg.data as HlWsL2Data) break case 'l2Book': this.handleL2Book(msg.data as HlWsL2BookData) break case 'candle': this.handleCandle(msg.data as HlWsCandleData) break case 'trades': this.handleTrades(msg.data as HlWsTrade[]) break case 'orderUpdates': this.handleOrderUpdates(msg.data as HlWsOrder[]) break case 'userFills': this.handleUserFills(msg.data as HlWsUserFillsData) break case 'allDexsClearinghouseState': this.handleAllDexsClearinghouseState( msg.data as HlWsAllDexsClearinghouseStateData ) break case 'spotState': this.handleSpotState(msg.data as HlWsSpotStateData) break } } catch (error) { wsLog.handlerFailure(this.providerKey, error) } } private handleAllDexsAssetCtxs(data: HlWsAllDexsAssetCtxsData) { this.emitMarketsContext( this.mergePerpAssetCtxEntries(allDexsAssetCtxEntries(data)) ) } private handlePac(base64: string) { this.pacDecodeChain.push(async () => { this.emitMarketsContext( this.mergePerpAssetCtxEntries( await decodeCompressedJson(base64) ) ) }) } /** Merge perp asset contexts in place; returns the marketIds this frame touched. */ private mergePerpAssetCtxEntries( entries: [string, HlWsPerpAssetCtxPayload[]][] ): Set { const touched = new Set() for (const [dex, ctxs] of entries) { let byMarketId = this.perpCtxBySubDex.get(dex || 'default') if (byMarketId === undefined) { byMarketId = {} this.perpCtxBySubDex.set(dex || 'default', byMarketId) } for (const ctx of ctxs) { const marketId = ctx.coin if (marketId === undefined || assetIsOutcome(marketId)) { continue } const merged = mergePerpAssetCtx(byMarketId[marketId], marketId, ctx) if (merged !== undefined) { byMarketId[marketId] = merged touched.add(marketId) } } } return touched } private handleSac(base64: string) { this.sacDecodeChain.push(async () => { const spotMarketIdBySacKey = this.spotMarketIdBySacKey() const ctxs = await decodeCompressedJson(base64) const touched = new Set() for (const [sacKey, ctx] of Object.entries(ctxs)) { if (assetIsOutcome(sacKey)) { continue } const marketId = spotMarketIdBySacKey.get(sacKey) if (marketId === undefined) { continue } this.spotCtxByMarketId[marketId] = { ...this.spotCtxByMarketId[marketId], ...ctx, } touched.add(marketId) } this.emitMarketsContext(touched) }) } private spotMarketIdBySacKey(): Map { const map = new Map() for (const market of this.registry?.activeMarkets ?? []) { if (market.categoryId !== SPOT_MARKET_ID) { continue } map.set(market.id, market.id) map.set( `${market.baseAsset.displaySymbol}/${market.quoteAsset.displaySymbol}`, market.id ) } return map } /** * Decode a `fastAssetCtxs` frame (base64 + raw-DEFLATE) and merge its changed * coins into the per-market fast-context store. Frames are incremental, so a * field absent from this frame keeps its prior value. Decodes are chained to * preserve arrival order across the async boundary; a backlog coalesces to * its newest frame (see the chain's mode). */ private handleFastAssetCtxs(base64: string) { this.fastDecodeChain.push(async () => { const ctxs = await decodeFastAssetCtxs(base64) const touched: string[] = [] for (const [marketId, ctx] of Object.entries(ctxs)) { if (assetIsOutcome(marketId)) { continue } const prev = this.fastCtxByMarketId[marketId] this.fastCtxByMarketId[marketId] = { markPx: 'markPx' in ctx ? ctx.markPx : prev?.markPx, midPx: 'midPx' in ctx ? ctx.midPx : prev?.midPx, } touched.push(marketId) } this.emitMarketsContext(touched) }) } /** * Emit the all-markets context map, re-mapping only the markets the incoming * frame touched. Each emission is a fresh snapshot object that carries * untouched entries over by reference, so a listener holding an earlier * snapshot never observes later-frame mutation. */ private emitMarketsContext(touchedMarketIds: Iterable) { const data: Record = { ...this.marketsContextByMarketId, } for (const marketId of touchedMarketIds) { const context = this.computeMarketContext(marketId) if (context === undefined) { delete data[marketId] } else { data[marketId] = context } } this.marketsContextByMarketId = data this.emit('marketsContext', { channel: 'marketsContext', data }) } /** * Context for one market from the cached feeds: spot asset context when it * maps, else perp asset context with mid + mark overlaid from * `fastAssetCtxs`, else the fast feed alone — each price standing in for the * other where it carries only one of mid/mark. An attached registry gates the * market id: an id it does not list, or lists as delisted, yields no context. */ private computeMarketContext(marketId: string): MarketContext | undefined { if (this.registry !== undefined) { const market = this.registry.get(marketId) if (market === undefined || !isActiveMarket(market)) { return undefined } } const fast = this.fastCtxByMarketId[marketId] const spotCtx = this.spotCtxByMarketId[marketId] if (spotCtx !== undefined) { const context = mapSpotMarketContext(marketId, spotCtx, fast) if (context !== undefined) { return context } } let perpCtx: HlWsPerpAssetCtx | undefined for (const byMarketId of this.perpCtxBySubDex.values()) { perpCtx = byMarketId[marketId] ?? perpCtx } if (perpCtx !== undefined) { return mapMarketContext(marketId, perpCtx, fast) } const midPrice = fast?.midPx ?? fast?.markPx const markPrice = fast?.markPx ?? fast?.midPx if (midPrice != null && markPrice != null) { return { marketId, midPrice, markPrice } } return undefined } private handleActiveAssetCtx(data: HlWsActiveAssetCtxData, raw: string) { if (assetIsOutcome(data.coin)) { return } const ctx = activePerpAssetCtx(data.coin, data.ctx) if (ctx === undefined) { wsLog.parseFailure(this.providerKey, raw) return } this.emit(`marketContext:${data.coin}`, { channel: 'marketContext', data: mapMarketContext(data.coin, ctx), }) } private handleActiveSpotAssetCtx(data: HlWsActiveSpotAssetCtxData) { if (assetIsOutcome(data.coin)) { return } const context = mapSpotMarketContext(data.coin, data.ctx) if (context === undefined) { return } this.emit(`marketContext:${data.coin}`, { channel: 'marketContext', data: context, }) } /** Latest mid per `Market.id` across asset-context and fast feeds. */ private mergedMids(): Map { const map = new Map() for (const byMarketId of this.perpCtxBySubDex.values()) { for (const [id, ctx] of Object.entries(byMarketId)) { const mid = ctx.midPx ?? ctx.markPx map.set(id, Number(mid)) } } for (const [id, ctx] of Object.entries(this.spotCtxByMarketId)) { const mid = ctx.midPx ?? ctx.markPx if (mid != null) { map.set(id, Number(mid)) } } for (const [id, fast] of Object.entries(this.fastCtxByMarketId)) { const mid = fast.midPx ?? fast.markPx if (mid != null) { map.set(id, Number(mid)) } } return map } private orderbookReferencePrice(marketId: string): number { const mid = this.mergedMids().get(marketId) if (mid !== undefined) { return mid } const book = this.latestOrderbookByMarketId.get(marketId) const bid = book?.bids[0]?.price const ask = book?.asks[0]?.price if (bid !== undefined && ask !== undefined) { return (Number(bid) + Number(ask)) / 2 } return Number.NaN } private handleL2Book(data: HlWsL2BookData) { const book = { provider: this.providerKey, marketId: data.coin, bids: data.levels[0] .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE) .map((l) => ({ price: l.px, size: l.sz })), asks: data.levels[1] .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE) .map((l) => ({ price: l.px, size: l.sz })), timestamp: data.time, } this.latestOrderbookByMarketId.set(data.coin, book) this.emitOrderbook(data.coin, book) } private handleL2(data: HlWsL2Data) { if (data.s !== undefined) { this.handleL2Book(data.s) } if (data.u !== undefined) { this.handleCompactL2Delta(data.u) } if (data.c === undefined) { return } const compressed = data.c this.orderbookDecodeChain.push(async () => { this.handleCompactL2Delta( await decodeCompressedJson(compressed) ) }) } private handleCompactL2Delta(delta: HlWsCompressedL2Data) { const previous = this.latestOrderbookByMarketId.get(delta.c) if (previous === undefined) { return } // Compact deltas decode asynchronously via `orderbookDecodeChain`, so one // can resolve after a newer frame (sync snapshot/update, or a later delta) // has already been applied. A delta no newer than the maintained book is // stale; applying it would overwrite fresher state with older levels. if (delta.t <= previous.timestamp) { return } const book: OrderbookResponse = { provider: this.providerKey, marketId: delta.c, bids: applyCompressedL2Side( previous.bids, delta.l[0], delta.r?.[0] ?? [], 'bid' ), asks: applyCompressedL2Side( previous.asks, delta.l[1], delta.r?.[1] ?? [], 'ask' ), timestamp: delta.t, } this.latestOrderbookByMarketId.set(delta.c, book) this.emitOrderbook(delta.c, book) } private emitOrderbook(marketId: string, book: OrderbookResponse) { const keys = this.orderbookKeysByMarketId.get(marketId) if (keys === undefined) { return } for (const key of keys) { this.emit(key, { channel: 'orderbook', data: book }) } } private handleCandle(data: HlWsCandleData) { if (assetIsOutcome(data.s)) { return } this.emit(`candle:${data.s}:${data.i}`, { channel: 'candle', data: { t: data.t, o: data.o, h: data.h, l: data.l, c: data.c, v: data.v, }, }) } private handleTrades(data: HlWsTrade[]) { for (const trade of data) { if (assetIsOutcome(trade.coin)) { continue } this.emit(`trades:${trade.coin}`, { channel: 'trades', data: [ { provider: this.providerKey, marketId: trade.coin, price: trade.px, size: trade.sz, timestamp: trade.time, side: trade.side === 'B' ? 'buy' : 'sell', id: trade.tid !== undefined ? String(trade.tid) : trade.hash, }, ], }) } } private handleOrderUpdates(data: HlWsOrder[]) { const key = this.orderUpdatesKey const client = this.client const epoch = this.orderUpdatesEpoch if (key === undefined || client === undefined) { return } this.orderUpdateChain.push(async () => { if (epoch !== this.orderUpdatesEpoch || key !== this.orderUpdatesKey) { return } const mapped = await Promise.all( data.map(async (update) => { const basic = update.order if (assetIsOutcome(basic.coin)) { return undefined } const market = this.registry?.get(basic.coin) if (market === undefined) { return undefined } const response = await infoRequest( this.orderApiUrl, { type: 'orderStatus', user: key.slice('orderUpdates:'.length), oid: basic.oid, }, hlInfoOptions(client) ) if (response.status !== 'order') { throw new PerpsError( PerpsErrorCode.OrderNotFound, `Hyperliquid order metadata not found: ${basic.oid}` ) } // REST supplies execution metadata; the stream owns this event's lifecycle and quantities. const detail: HlOrderDetail = { status: update.status, statusTimestamp: update.statusTimestamp, order: { ...response.order.order, coin: basic.coin, side: basic.side, limitPx: basic.limitPx, sz: basic.sz, oid: basic.oid, timestamp: basic.timestamp, origSz: basic.origSz, cloid: basic.cloid ?? response.order.order.cloid, }, } return mapOrder(detail, market) }) ) if (epoch !== this.orderUpdatesEpoch || key !== this.orderUpdatesKey) { return } const orders: Order[] = [] const terminated: string[] = [] for (const order of mapped) { if (order === undefined) { continue } orders.push(order) if (!isActiveOrderStatus(order.status)) { terminated.push(order.orderId) } } this.emit(key, { channel: 'orderUpdates', data: { orders, terminated } }) }) } private handleUserFills(data: HlWsUserFillsData) { // The snapshot frame carries up to ~2000 historical fills and is replayed // on every reconnect via `replaySubs`; emitting them would surface old // fills as live events on each network blip. Suppress it to an empty event // (fill history is available via REST `getFills`) so only live fills are // delivered as events. const items = data.isSnapshot ? [] : data.fills.flatMap((f) => { if (assetIsOutcome(f.coin)) { return [] } const market = this.registry?.get(f.coin) return market ? [mapFill(f as HlUserFill, market)] : [] }) this.emit(`userFills:${data.user.toLowerCase()}`, { channel: 'fills', data: items, }) } private handleAllDexsClearinghouseState( data: HlWsAllDexsClearinghouseStateData ) { const positions = data.clearinghouseStates.flatMap(([, state]) => state.assetPositions.flatMap((ap) => { if ( assetIsOutcome(ap.position.coin) || !isOpenAssetPosition(ap as HlAssetPosition) ) { return [] } const market = this.registry?.get(ap.position.coin) return market ? [mapPosition(ap as HlAssetPosition, toPerpsMarketDisplay(market))] : [] }) ) this.emit(`positions:${data.user.toLowerCase()}`, { channel: 'positions', data: positions, }) this.latestPositionsByUser.set(data.user.toLowerCase(), positions) this.emitUnifiedSummary(data.user.toLowerCase()) let accountValue = 0 let marginUsed = 0 for (const [, state] of data.clearinghouseStates) { accountValue += Number.parseFloat(state.marginSummary?.accountValue ?? '') || 0 marginUsed += Number.parseFloat(state.marginSummary?.totalMarginUsed ?? '') || 0 } const unrealizedPnl = positions.reduce( (sum, p) => sum + (Number.parseFloat(p.unrealizedPnl) || 0), 0 ) // Equity semantics, matching the REST summary: `accountValue` already // carries locked margin and unrealized PnL. Spot balances stream apart, // so this portfolio value covers perps equity only — which is why the // frame is gated on the abstraction mode below. this.emitSummaryIfModeAllows(data.user, { portfolioValue: accountValue.toString(), availableMargin: (accountValue - marginUsed).toString(), marginUsed: marginUsed.toString(), unrealizedPnl: unrealizedPnl.toString(), }) } /** Whether the mode's collateral lives per-dex, making the perps-only * summary frame honest. Unified/portfolio hold collateral in spot. */ private static summaryComputableFor(mode: HlAbstractionMode | null): boolean { return ( mode !== HlAbstractionMode.UNIFIED_ACCOUNT && mode !== HlAbstractionMode.PORTFOLIO_MARGIN ) } private async acquireSpotWire(address: string): Promise<() => void> { // Lowercased in the payload too, so releases from either holder (public // channel or pipeline) unsubscribe with an identical payload. const user = address.toLowerCase() const wireKey = `spot:${user}` const payload = { type: 'spotState', user } const count = this.spotRefs.get(user) ?? 0 this.spotRefs.set(user, count + 1) if (count === 0) { await this.registerSub(wireKey, payload) } let released = false return () => { if (released) { return } released = true const remaining = (this.spotRefs.get(user) ?? 1) - 1 if (remaining <= 0) { this.spotRefs.delete(user) this.unregisterSub(wireKey) this.rws.send( JSON.stringify({ method: 'unsubscribe', subscription: payload }) ) } else { this.spotRefs.set(user, remaining) } } } /** Start or stop the spot-fed unified pipeline to match the resolved mode. */ private syncUnifiedPipeline( key: string, address: string, mode: HlAbstractionMode | null ) { const unified = !HyperliquidWsProvider.summaryComputableFor(mode) const existing = this.unifiedSummaryByUser.get(key) if (unified && existing === undefined) { this.unifiedSummaryByUser.set(key, { releaseSpot: this.acquireSpotWire(address).catch((error) => { wsLog.handlerFailure(this.providerKey, error) return () => {} }), }) } else if (!unified && existing !== undefined) { this.unifiedSummaryByUser.delete(key) void existing.releaseSpot.then((release) => release()) } } private emitUnifiedSummary(key: string) { const pipeline = this.unifiedSummaryByUser.get(key) const positions = this.latestPositionsByUser.get(key) // Both envelopes must have arrived: a spot-only summary would report // zero margin used against real positions. if (pipeline?.spot === undefined || positions === undefined) { return } const summary = summarizeAccount( // summarizeAccount reads only the two balance lists. { collateralBalances: pipeline.spot.collateralBalances, balances: pipeline.spot.balances, } as AccountResponse, positions, 'gross' ) this.emit(`accountSummary:${key}`, { channel: 'accountSummary', data: summary, }) } /** Re-read the abstraction mode this long after the previous read, so an * in-session account-mode switch flips the gate within one refresh. */ private static readonly ABSTRACTION_TTL_MS = 15_000 /** Subscribe-triggered re-reads younger than this are skipped: a config * change resubscribes every consumer hook in one React commit, and one * read serves them all. */ private static readonly REFRESH_COALESCE_MS = 200 private emitSummaryIfModeAllows( user: string, summary: { portfolioValue: string availableMargin: string marginUsed: string unrealizedPnl: string } ) { const key = user.toLowerCase() // Without a client there is no way to read the mode; keep the historic // always-emit behavior rather than silencing every standalone consumer. if (this.client === undefined) { this.emit(`accountSummary:${key}`, { channel: 'accountSummary', data: summary, }) return } const entry = this.abstractionByUser.get(key) if (entry === undefined || entry === 'pending') { this.heldSummaryByUser.set(key, summary) if (entry === undefined) { this.fetchAbstractionMode(user, key) } return } if (Date.now() - entry.readAt > HyperliquidWsProvider.ABSTRACTION_TTL_MS) { // Refresh in the background; the current mode keeps gating meanwhile, // so frames neither stall nor mis-emit while the read is in flight. this.fetchAbstractionMode(user, key, entry) } if (!HyperliquidWsProvider.summaryComputableFor(entry.mode)) { return } this.emit(`accountSummary:${key}`, { channel: 'accountSummary', data: summary, }) } private fetchAbstractionMode( user: string, key: string, refreshing?: { mode: HlAbstractionMode | null; readAt: number } ) { const client = this.client if (client === undefined) { return } // A background refresh keeps the current entry live (stamped now so // frames don't re-kick the read); the first read holds frames instead. this.abstractionByUser.set( key, refreshing ? { ...refreshing, readAt: Date.now() } : 'pending' ) // "Never set abstraction" is a successful 200 `null` body — only a fetch // failure clears a first read so the next frame retries it. A failed // refresh keeps the previous mode until the TTL passes again. infoRequest( DEFAULT_HYPERLIQUID_API_URL, { type: 'userAbstraction', user }, hlInfoOptions(client) ).then( (mode) => { this.abstractionByUser.set(key, { mode, readAt: Date.now() }) this.syncUnifiedPipeline(key, user, mode) const held = this.heldSummaryByUser.get(key) this.heldSummaryByUser.delete(key) if (held && HyperliquidWsProvider.summaryComputableFor(mode)) { this.emit(`accountSummary:${key}`, { channel: 'accountSummary', data: held, }) } }, (error) => { if (!refreshing) { this.abstractionByUser.delete(key) } wsLog.handlerFailure(this.providerKey, error) } ) } private handleSpotState(data: HlWsSpotStateData) { const user = data.user.toLowerCase() const markets = this.registry?.activeMarkets ?? [] const priceById = spotPriceById(markets, this.mergedMids()) const rows = data.spotState.balances .filter( (balance) => !assetIsOutcome(balance.coin) && toWireBig(balance.total, 'spotState.balances.total').gt(0) ) .map((balance) => ({ balance: spotBalance( spotAssetFromToken(balance), balance.total, priceById ), hold: balance.hold, })) this.emit(`spotState:${user}`, { channel: 'spotBalances', data: rows.map(({ balance, hold }) => ({ ...balance, locked: hold })), }) const pipeline = this.unifiedSummaryByUser.get(user) if (pipeline !== undefined) { const quoteAssetIds = new Set(markets.map((m) => m.quoteAsset.id)) const entry = this.abstractionByUser.get(user) const portfolioMargin = entry !== undefined && entry !== 'pending' && entry.mode === HlAbstractionMode.PORTFOLIO_MARGIN // Same partition as getAccount so REST and WS agree on collateral. pipeline.spot = partitionSpotBalances( rows.map(({ balance }) => balance), quoteAssetIds, portfolioMargin ) this.emitUnifiedSummary(user) } } } const isObject = (v: unknown): v is Record => typeof v === 'object' && v !== null const toMarketContextString = (value: unknown): string | undefined => typeof value === 'string' || typeof value === 'number' ? String(value) : undefined const toMarketCapString = ( price: unknown, circulatingSupply: unknown ): string | undefined => { const priceString = toMarketContextString(price) const supplyString = toMarketContextString(circulatingSupply) if (priceString === undefined || supplyString === undefined) { return undefined } try { const parsedPrice = new Big(priceString) const parsedSupply = new Big(supplyString) if (parsedPrice.lte(0) || parsedSupply.lte(0)) { return undefined } return parsedPrice.times(parsedSupply).toFixed() } catch { return undefined } } function mergePerpAssetCtx( previous: HlWsPerpAssetCtx | undefined, marketId: string, update: HlWsPerpAssetCtxPayload ): HlWsPerpAssetCtx | undefined { const funding = update.funding ?? previous?.funding const openInterest = update.openInterest ?? previous?.openInterest const dayNtlVlm = update.dayNtlVlm ?? previous?.dayNtlVlm const prevDayPx = update.prevDayPx ?? previous?.prevDayPx const markPx = update.markPx ?? previous?.markPx const midPx = 'midPx' in update ? update.midPx : previous?.midPx const oraclePx = update.oraclePx ?? previous?.oraclePx if ( funding === undefined || openInterest === undefined || dayNtlVlm === undefined || prevDayPx === undefined || markPx === undefined || midPx === undefined || oraclePx === undefined ) { return undefined } return { coin: marketId, funding, openInterest, dayNtlVlm, prevDayPx, markPx, midPx, oraclePx, } } function allDexsAssetCtxEntries( data: HlWsAllDexsAssetCtxsData ): [string, HlWsPerpAssetCtxPayload[]][] { return data.assetCtxs ?? data.ctxs ?? [] } function activePerpAssetCtx( marketId: string, ctx: HlWsActiveAssetCtxData['ctx'] ): HlWsPerpAssetCtx | undefined { const funding = toMarketContextString(ctx.funding) const openInterest = toMarketContextString(ctx.openInterest) const dayNtlVlm = toMarketContextString(ctx.dayNtlVlm) const prevDayPx = toMarketContextString(ctx.prevDayPx) const markPx = toMarketContextString(ctx.markPx) const midPx = ctx.midPx === undefined || ctx.midPx === null ? null : (toMarketContextString(ctx.midPx) ?? null) const oraclePx = toMarketContextString(ctx.oraclePx) if ( funding === undefined || openInterest === undefined || dayNtlVlm === undefined || prevDayPx === undefined || markPx === undefined || oraclePx === undefined ) { return undefined } return { coin: marketId, funding, openInterest, dayNtlVlm, prevDayPx, markPx, midPx, oraclePx, } } function mapSpotMarketContext( marketId: string, ctx: HlWsSpotAssetCtx, fast?: HlWsFastAssetCtx ): MarketContext | undefined { const midPrice = toMarketContextString(ctx.midPx ?? ctx.markPx) const markPrice = toMarketContextString(ctx.markPx) if (midPrice === undefined || markPrice === undefined) { return undefined } const emittedMarkPrice = fast?.markPx != null ? fast.markPx : markPrice return { marketId, midPrice: fast?.midPx != null ? fast.midPx : midPrice, markPrice: emittedMarkPrice, prevDayPrice: toMarketContextString(ctx.prevDayPx), volume24h: toMarketContextString(ctx.dayNtlVlm), marketCap: toMarketCapString(emittedMarkPrice, ctx.circulatingSupply), } } function compactRemovalPrice( levels: OrderbookLevel[], value: number | string | { p: string } ): string | undefined { if (typeof value === 'number') { return levels[value]?.price } return typeof value === 'string' ? value : value.p } function applyCompressedL2Side( previous: OrderbookLevel[], updates: HlWsCompressedL2Data['l'][number], removals: NonNullable[number], side: 'bid' | 'ask' ): OrderbookLevel[] { const byPrice = new Map(previous.map((level) => [level.price, level.size])) for (const removal of removals) { const price = compactRemovalPrice(previous, removal) if (price !== undefined) { byPrice.delete(price) } } for (const update of updates) { if (Number(update.s) === 0) { byPrice.delete(update.p) } else { byPrice.set(update.p, update.s) } } return [...byPrice.entries()] .map(([price, size]) => ({ price, size })) .sort((a, b) => side === 'bid' ? Number(b.price) - Number(a.price) : Number(a.price) - Number(b.price) ) .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE) } /** * Minimal presence/type check of the channel-discriminating and required * fields the matching handler dereferences without its own guard. A frame * that parses but fails this is a bad frame (log + skip), distinct from a * handler that throws on otherwise-shaped data. Unknown channels pass through * to the dispatch switch, which silently ignores them. */ function isValidHlFrame(channel: string, data: unknown): boolean { // `fastAssetCtxs` carries a base64 string payload, not an object. if (channel === 'fastAssetCtxs' || channel === 'pac' || channel === 'sac') { return typeof data === 'string' } if (!isObject(data)) { return false } switch (channel) { case 'allDexsAssetCtxs': return Array.isArray(data.assetCtxs) || Array.isArray(data.ctxs) case 'activeAssetCtx': case 'activeSpotAssetCtx': return typeof data.coin === 'string' && isObject(data.ctx) case 'l2Book': return ( typeof data.coin === 'string' && Array.isArray(data.levels) && Array.isArray(data.levels[0]) && Array.isArray(data.levels[1]) && typeof data.time === 'number' ) case 'l2': return typeof data.c === 'string' || isObject(data.s) || isObject(data.u) case 'candle': return typeof data.s === 'string' && typeof data.i === 'string' case 'trades': return Array.isArray(data) case 'orderUpdates': return Array.isArray(data) case 'userFills': return typeof data.user === 'string' && Array.isArray(data.fills) case 'allDexsClearinghouseState': return ( typeof data.user === 'string' && Array.isArray(data.clearinghouseStates) ) case 'spotState': return ( typeof data.user === 'string' && isObject(data.spotState) && Array.isArray(data.spotState.balances) ) default: return true } }