diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 2b61c13148f..a6aa8db8a89 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `AggregatedOrderBookConnection` service (with the `processAggregatedOrderBook` helper and the `OrderBookConnectionStatus`, `SubscribeAggregatedOrderBookParams`, and `AggregatedOrderBookConnectionOptions` types) for managing a dedicated, reference-counted aggregated order book subscription ([#9549](https://github.com/MetaMask/core/pull/9549)) - Add `BOTTOM_NAV_BAR` to `PERPS_EVENT_VALUE.SOURCE` for bottom navigation bar analytics attribution ([#9551](https://github.com/MetaMask/core/pull/9551)) ### Changed diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 72f1b8b9c16..93b3eedd029 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -602,6 +602,15 @@ export { // Services (only externally consumed items) export { TradingReadinessCache } from './services/TradingReadinessCache'; export type { ServiceContext } from './services/ServiceContext'; +export { + AggregatedOrderBookConnection, + processAggregatedOrderBook, +} from './services/AggregatedOrderBookConnection'; +export type { + OrderBookConnectionStatus, + SubscribeAggregatedOrderBookParams, + AggregatedOrderBookConnectionOptions, +} from './services/AggregatedOrderBookConnection'; // Removed with Live Market Prices component: // - usePerpsPrices diff --git a/packages/perps-controller/src/services/AggregatedOrderBookConnection.ts b/packages/perps-controller/src/services/AggregatedOrderBookConnection.ts new file mode 100644 index 00000000000..7ff4dc0ec52 --- /dev/null +++ b/packages/perps-controller/src/services/AggregatedOrderBookConnection.ts @@ -0,0 +1,491 @@ +import { SubscriptionClient, WebSocketTransport } from '@nktkas/hyperliquid'; +import type { ISubscription } from '@nktkas/hyperliquid'; + +import { HYPERLIQUID_TRANSPORT_CONFIG } from '../constants/hyperLiquidConfig'; +import type { OrderBookData } from '../types'; + +/** + * A single L2 book price level as delivered by Hyperliquid's `l2Book` + * subscription. Declared locally to avoid coupling to the SDK's exported type + * names (and to keep this the only file that references the SDK's shapes). + */ +type HyperliquidL2BookLevel = { + /** Price. */ + px: string; + /** Total size resting at this price. */ + sz: string; + /** Number of individual orders. */ + n: number; +}; + +/** `l2Book` snapshot event (index 0 = bids, index 1 = asks). */ +type HyperliquidL2BookEvent = { + coin: string; + time: number; + levels: [bids: HyperliquidL2BookLevel[], asks: HyperliquidL2BookLevel[]]; + spread?: string; +}; + +/** + * Health of the dedicated order-book socket, surfaced to the UI so the panel + * can show a reconnect affordance. + * + * - `connecting`: socket opening or reconnecting after a transient drop. + * - `connected`: subscription is live. + * - `error`: dropped and automatic reconnection was exhausted; needs a manual reconnect. + */ +export type OrderBookConnectionStatus = 'connecting' | 'connected' | 'error'; + +export type SubscribeAggregatedOrderBookParams = { + /** Market symbol (e.g. 'BTC'). */ + symbol: string; + /** Number of levels per side to keep. */ + levels?: number; + /** + * Server-side aggregation significant figures. Required: omitting it would + * request the raw, full-precision book instead of an aggregated one, which + * contradicts this service's contract. + */ + nSigFigs: 2 | 3 | 4 | 5; + /** Mantissa refinement when `nSigFigs` is 5. */ + mantissa?: 2 | 5; + /** Invoked with each processed snapshot. */ + callback: (data: OrderBookData) => void; + /** Invoked when the underlying socket's health changes. */ + onStatusChange?: (status: OrderBookConnectionStatus) => void; +}; + +export type AggregatedOrderBookConnectionOptions = { + /** Resolves the current network at subscribe time. */ + isTestnet: () => boolean; +}; + +// Fast mode streams 5 levels per side (slow mode streams 20). We run fast mode +// for lower-latency ladder updates, so the book never carries more than this. +const DEFAULT_LEVELS = 5; + +/** + * Transforms a raw Hyperliquid `l2Book` snapshot into the `OrderBookData` shape + * the UI consumes. Mirrors the subscription service's internal + * `processOrderBookData` so this dedicated connection is a drop-in replacement + * for `subscribeToOrderBook` on the aggregated channel. + * + * @param data - Raw `l2Book` event. + * @param levels - Number of levels per side to keep. + * @returns Processed order-book snapshot. + */ +export function processAggregatedOrderBook( + data: HyperliquidL2BookEvent, + levels: number, +): OrderBookData { + const bidsRaw = data?.levels?.[0] ?? []; + const asksRaw = data?.levels?.[1] ?? []; + + let bidCumulativeSize = 0; + let bidCumulativeNotional = 0; + const bids = bidsRaw.slice(0, levels).map((level) => { + const price = Number.parseFloat(level.px); + const size = Number.parseFloat(level.sz); + const notional = price * size; + bidCumulativeSize += size; + bidCumulativeNotional += notional; + return { + price: level.px, + size: level.sz, + total: bidCumulativeSize.toString(), + notional: notional.toFixed(2), + totalNotional: bidCumulativeNotional.toFixed(2), + }; + }); + + let askCumulativeSize = 0; + let askCumulativeNotional = 0; + const asks = asksRaw.slice(0, levels).map((level) => { + const price = Number.parseFloat(level.px); + const size = Number.parseFloat(level.sz); + const notional = price * size; + askCumulativeSize += size; + askCumulativeNotional += notional; + return { + price: level.px, + size: level.sz, + total: askCumulativeSize.toString(), + notional: notional.toFixed(2), + totalNotional: askCumulativeNotional.toFixed(2), + }; + }); + + const bestBid = bids[0]; + const bestAsk = asks[0]; + const bidPrice = bestBid ? Number.parseFloat(bestBid.price) : 0; + const askPrice = bestAsk ? Number.parseFloat(bestAsk.price) : 0; + const spread = askPrice > 0 && bidPrice > 0 ? askPrice - bidPrice : 0; + const midPrice = askPrice > 0 && bidPrice > 0 ? (askPrice + bidPrice) / 2 : 0; + const spreadPercentage = + midPrice > 0 ? ((spread / midPrice) * 100).toFixed(4) : '0'; + const maxTotal = Math.max(bidCumulativeSize, askCumulativeSize).toString(); + + return { + bids, + asks, + spread: spread.toFixed(5), + spreadPercentage, + midPrice: midPrice.toFixed(5), + lastUpdated: Date.now(), + maxTotal, + }; +} + +/** + * Owns a dedicated Hyperliquid WebSocket connection used solely for the + * order-book panel's server-aggregated `l2Book` subscription. + * + * The main connection (managed by the subscription service) multiplexes every + * subscription onto a single socket. The Hyperliquid SDK dispatches `l2Book` + * events by `coin` only, so running the raw (full-precision) and the aggregated + * (`nSigFigs`) subscriptions for the same coin on that shared socket + * cross-contaminates them — the coarse ladder and the precise spread/slippage + * clobber each other. Giving the aggregated subscription its own socket removes + * the collision entirely: this socket only ever carries a single `l2Book` + * stream, and the main socket is never touched by the panel's grouping. + * + * The socket is created lazily on the first subscription and torn down once the + * last subscription is removed, so it exists only while an order-book panel is + * open. Because network is a global setting, the transport is recreated if + * `isTestnet` changes between (re)subscriptions. + */ +export class AggregatedOrderBookConnection { + readonly #isTestnet: () => boolean; + + #transport: WebSocketTransport | null = null; + + #transportIsTestnet = false; + + #activeCount = 0; + + // Tracks the single `l2Book` payload the dedicated socket carries per asset, + // keyed by symbol. The SDK dispatches `l2Book` events by `coin` only, so two + // subscriptions for the same asset with different params (e.g. `nSigFigs`) + // would cross-contaminate on this shared socket — exactly the collision this + // connection exists to avoid. `count` refcounts the (identical) subscriptions + // sharing a payload so the entry is dropped once the last one unsubscribes. + readonly #payloads = new Map(); + + // Force-terminate callback for every currently-active subscription. When a + // transport rebuild (`#closeTransport`) shuts the socket down out from under + // live subscriptions, these tear each one down — notifying the caller and + // releasing its SDK subscription / socket listeners — instead of orphaning + // them (stale handle, no more updates, and no further status because + // reporting is suppressed once `transport !== this.#transport`). + readonly #activeSubscriptions = new Set<() => void>(); + + // Set when the socket's auto-reconnection is exhausted (its + // `terminationSignal` aborts). A terminated socket cannot recover, so the next + // subscribe must build a fresh transport instead of reusing the dead one. + #terminated = false; + + constructor({ isTestnet }: AggregatedOrderBookConnectionOptions) { + this.#isTestnet = isTestnet; + } + + /** + * Opens an aggregated `l2Book` subscription on the dedicated socket. + * + * Mirrors the subscription service's synchronous-unsubscribe contract: the + * returned function can be called before the async subscribe resolves and + * will cancel the pending subscription. + * + * Only one `l2Book` payload per asset may be active at a time. Subscribing to + * an asset that already has a live subscription with different params (e.g. a + * different `nSigFigs` or `mantissa`) throws, because the shared socket + * dispatches by `coin` and the conflicting streams would clobber each other. + * + * @param params - Subscription parameters. + * @returns An unsubscribe function. + * @throws If the asset already has an active subscription with different params. + */ + subscribe(params: SubscribeAggregatedOrderBookParams): () => void { + const levels = params.levels ?? DEFAULT_LEVELS; + // The `l2Book` subscription params the socket carries. `levels` is + // client-side only (it slices each snapshot), so it is deliberately excluded + // from the params and their signature. + const l2BookParams = { + coin: params.symbol, + nSigFigs: params.nSigFigs, + mantissa: params.mantissa ?? null, + fast: true as const, + }; + const signature = JSON.stringify(l2BookParams); + + const transport = this.#ensureTransport(this.#isTestnet()); + + // Reject a conflicting payload for an asset already on this socket. A + // recreated transport (first use, network change, or terminate) starts with + // an empty payload map, so this can only trip on the reuse path — the shared + // socket that would actually suffer the collision. + const existingPayload = this.#payloads.get(params.symbol); + if (existingPayload && existingPayload.signature !== signature) { + throw new Error( + `AggregatedOrderBookConnection: "${params.symbol}" is already subscribed with different params; only one l2Book payload per asset is allowed on the dedicated socket.`, + ); + } + + const { socket } = transport; + + let cancelled = false; + let subscription: ISubscription | null = null; + this.#activeCount += 1; + if (existingPayload) { + existingPayload.count += 1; + } else { + this.#payloads.set(params.symbol, { signature, count: 1 }); + } + + // Set once this subscription's socket terminates (reconnection exhausted). + // The `error` state is terminal until teardown/resubscribe, so once set we + // suppress any late `connected`/`connecting` — e.g. from a subscribe promise + // that resolves *after* the socket died — which would otherwise flip the UI + // back to a healthy state on a dead socket and hide the manual-reconnect + // affordance. + let terminated = false; + const reportStatus = (status: OrderBookConnectionStatus): void => { + // Suppress reports from a subscription that no longer drives the UI: it + // was unsubscribed (`cancelled`), its transport was replaced by a network + // flip or recreate (`transport !== this.#transport`, so its socket is + // dead), or its socket permanently terminated and `error` is now sticky + // until teardown (`terminated`). + if ( + cancelled || + transport !== this.#transport || + (terminated && status !== 'error') + ) { + return; + } + params.onStatusChange?.(status); + }; + + // Reflect the socket's live health. Every drop dispatches a `close` event; + // the reconnecting socket only exposes permanent termination through its + // `terminationSignal` (an `AbortSignal`), which it aborts *before* the final + // close. So an aborted signal on close — unless it was our own `close()` + // (`TERMINATED_BY_USER`) — means automatic reconnection is exhausted: the + // unrecoverable state the UI surfaces with a manual reconnect button. A + // still-live signal means a transient drop the socket will auto-reconnect. + const handleOpen = (): void => reportStatus('connected'); + const handleClose = (): void => { + // A torn-down subscription must not mutate shared connection state. Its + // listeners are normally detached before the socket closes, but guard + // anyway so a late `close` (e.g. from `transport.close()` racing listener + // removal) can't wrongly flip `#terminated`. + if (cancelled) { + return; + } + const { terminationSignal } = socket; + const terminatedByUser = + (terminationSignal.reason as { code?: string } | undefined)?.code === + 'TERMINATED_BY_USER'; + if (terminationSignal.aborted && !terminatedByUser) { + this.#terminated = true; + terminated = true; + reportStatus('error'); + return; + } + reportStatus('connecting'); + }; + socket.addEventListener('open', handleOpen); + socket.addEventListener('close', handleClose); + + const removeSocketListeners = (): void => { + socket.removeEventListener('open', handleOpen); + socket.removeEventListener('close', handleClose); + }; + + // Ends this subscription when its transport is torn down beneath it (network + // flip, post-termination resubscribe, or `close()`). Unlike `teardown` it + // leaves the shared refcount/payload state alone — `#closeTransport` clears + // those wholesale — but still notifies the caller and releases this + // subscription's resources so nothing leaks on the dead socket. + const forceTerminate = (): void => { + if (cancelled) { + return; + } + // Notify directly rather than via `reportStatus`: `#closeTransport` + // detaches `#transport` before invoking these callbacks (so a reentrant + // subscribe from this handler builds a fresh transport instead of binding + // to the dying one), which would otherwise trip `reportStatus`'s + // stale-transport guard. A subscription whose socket already terminated + // has reported `error`; a still-live one (abandoned by a network flip or + // `close()`) needs the terminal signal so the caller stops trusting a + // now-dead book. + if (!terminated) { + params.onStatusChange?.('error'); + } + cancelled = true; + removeSocketListeners(); + this.#activeSubscriptions.delete(forceTerminate); + if (subscription) { + subscription.unsubscribe().catch(() => undefined); + subscription = null; + } + }; + this.#activeSubscriptions.add(forceTerminate); + + // Releases this subscription's refcount and tears down the socket once no + // subscriptions remain. Idempotent via `cancelled`, so it's safe whether it + // runs from the returned unsubscribe or from a failed subscribe. + const teardown = (): void => { + if (cancelled) { + return; + } + cancelled = true; + removeSocketListeners(); + this.#activeSubscriptions.delete(forceTerminate); + if (subscription) { + subscription.unsubscribe().catch(() => undefined); + subscription = null; + } + // Only touch the refcount/current socket if this subscription still + // belongs to the active transport. If the transport was recreated (network + // change or terminate), this subscription's socket is already dead and + // `#activeCount` now tracks only the new transport's subscriptions — so an + // older unsubscribe must not decrement it and tear down the live socket. + if (transport === this.#transport) { + this.#activeCount = Math.max(0, this.#activeCount - 1); + const entry = this.#payloads.get(params.symbol); + if (entry) { + entry.count -= 1; + if (entry.count <= 0) { + this.#payloads.delete(params.symbol); + } + } + if (this.#activeCount === 0) { + this.#closeTransport(); + } + } + }; + + // Surfaces a subscription failure the same way regardless of when it + // happens: report `error` (before teardown flips `cancelled`, which gates + // status updates) then release the refcount so the dead subscription doesn't + // keep the dedicated socket open. Used for both the initial subscribe + // rejection (`.catch`) and post-confirmation failures the SDK reports only + // through `onError` — e.g. the server rejecting the re-subscription after a + // reconnect, which removes the listener and stops all further events (a + // frozen order book that would otherwise still read as `connected`). + // Idempotent via `teardown`'s `cancelled` guard. + const handleSubscriptionError = (): void => { + reportStatus('error'); + teardown(); + }; + + reportStatus('connecting'); + + // Subscribe through the typed `l2Book` client so the params are validated + // before they reach the wire (`fast: true` requests fast mode — 5 levels at + // ~0.5s). The listener receives the decoded snapshot directly. + new SubscriptionClient({ transport }) + .l2Book( + l2BookParams, + (data: HyperliquidL2BookEvent) => { + if (cancelled || data?.coin !== params.symbol || !data?.levels) { + return; + } + params.callback(processAggregatedOrderBook(data, levels)); + }, + // `onError` fires at most once for an *already confirmed* subscription + // that later fails (rejected re-subscription after reconnect, permanent + // termination, or a drop while re-subscription is disabled). The SDK + // removes the listener and emits nothing further, so treat it exactly + // like an initial failure. + { onError: handleSubscriptionError }, + ) + .then(async (sub) => { + // Stale if this subscription was unsubscribed (`cancelled`) or its + // transport was replaced (network flip / recreate) before the subscribe + // settled — either way the captured socket is dead, so clean up the SDK + // subscription instead of storing it or announcing `connected`. + if (cancelled || transport !== this.#transport) { + try { + await sub.unsubscribe(); + } catch { + // Ignore cleanup errors on an already-cancelled/stale subscription. + } + return undefined; + } + subscription = sub; + reportStatus('connected'); + return undefined; + }) + .catch(handleSubscriptionError); + + return teardown; + } + + /** Closes the dedicated socket and drops all subscriptions. */ + close(): void { + this.#closeTransport(); + } + + #ensureTransport(isTestnet: boolean): WebSocketTransport { + if ( + this.#transport && + this.#transportIsTestnet === isTestnet && + !this.#terminated + ) { + return this.#transport; + } + // First use, the network changed, or the previous socket was terminated — + // (re)create the dedicated transport. Reuse the package's transport config + // so this socket shares the finite five-attempt reconnection policy; without + // it the SDK defaults `maxRetries` to Infinity and a sustained outage would + // never exhaust reconnection to reach the `error`/manual-reconnect state. + this.#closeTransport(); + // `#closeTransport` notifies subscribers, which may synchronously re-enter + // `subscribe` and build a matching transport. Reuse it instead of orphaning + // it (which would leak the reentrant subscription on an unreferenced socket). + if ( + this.#transport && + this.#transportIsTestnet === isTestnet && + !this.#terminated + ) { + return this.#transport; + } + const transport = new WebSocketTransport({ + isTestnet, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + this.#transport = transport; + this.#transportIsTestnet = isTestnet; + return transport; + } + + #closeTransport(): void { + const transport = this.#transport; + // Snapshot the subscriptions to force-terminate, then detach ALL shared + // state (the set, `#transport`, refcounts) *before* invoking any callback. + // Those callbacks notify subscribers via `onStatusChange`, which can + // synchronously re-enter `subscribe`; detaching first guarantees a reentrant + // subscribe builds a fresh transport (rather than reusing this dying one) + // and registers itself in a clean set (rather than being swept up by, or + // lingering past, this teardown). Subscriptions torn down normally have + // already removed themselves, so their entry is a no-op here. + const subscriptions = [...this.#activeSubscriptions]; + this.#activeSubscriptions.clear(); + this.#transport = null; + this.#activeCount = 0; + this.#payloads.clear(); + this.#terminated = false; + // Force-terminate (which detaches each subscription's socket listeners) + // BEFORE closing the transport. `close()` on an already-exhausted socket + // dispatches a final `close`; if a stale `handleClose` were still attached + // it would re-set `#terminated` right after we cleared it, making the next + // `#ensureTransport` tear down the healthy replacement socket. + for (const forceTerminate of subscriptions) { + forceTerminate(); + } + if (transport) { + transport.close(); + } + } +} diff --git a/packages/perps-controller/tests/src/services/AggregatedOrderBookConnection.test.ts b/packages/perps-controller/tests/src/services/AggregatedOrderBookConnection.test.ts new file mode 100644 index 00000000000..84815b22ecc --- /dev/null +++ b/packages/perps-controller/tests/src/services/AggregatedOrderBookConnection.test.ts @@ -0,0 +1,846 @@ +import * as hl from '@nktkas/hyperliquid'; + +import { HYPERLIQUID_TRANSPORT_CONFIG } from '../../../src/constants/hyperLiquidConfig'; +import { + AggregatedOrderBookConnection, + processAggregatedOrderBook, +} from '../../../src/services/AggregatedOrderBookConnection'; + +/** + * Mirrors the reconnecting socket: a `terminationSignal` plus a helper that + * reproduces permanent termination (abort the signal, then a final `close`). + */ +type MockSocket = EventTarget & { + terminationSignal: AbortSignal; + /** Simulate permanent termination with the given `ReconnectingWebSocketError` code. */ + terminate: (code?: string) => void; +}; + +type MockTransport = { + options: Record; + close: jest.Mock; + socket: MockSocket; + subscribe: jest.Mock; +}; +/** Invokes the connection's listener with the raw snapshot (wrapped as `detail`). */ +type L2Emit = (data: unknown) => void; + +type MockState = { + transports: MockTransport[]; + listeners: { + channel: string; + params: unknown; + listener: L2Emit; + /** The SDK's post-confirmation failure callback, if the caller passed one. */ + onError?: (error: Error) => void; + }[]; + unsubscribe: jest.Mock; + resolveSubscribe: boolean; + rejectSubscribe: boolean; +}; + +jest.mock('@nktkas/hyperliquid', () => { + const state: MockState = { + transports: [], + listeners: [], + unsubscribe: jest.fn().mockResolvedValue(undefined), + resolveSubscribe: true, + rejectSubscribe: false, + }; + + // Reproduces the reconnecting socket's termination model: permanent + // termination aborts `terminationSignal` (reason carries a `code`) *before* + // the final `close` event fires — never a standalone `terminate` event. + class MockSocket extends EventTarget { + readonly #abortController = new AbortController(); + + get terminationSignal(): AbortSignal { + return this.#abortController.signal; + } + + terminate(code = 'RECONNECTION_LIMIT'): void { + if (!this.#abortController.signal.aborted) { + this.#abortController.abort({ code }); + } + this.dispatchEvent(new Event('close')); + } + } + + class WebSocketTransport { + options: Record; + + socket = new MockSocket(); + + // Mirror the SDK: closing aborts the termination signal (as + // `TERMINATED_BY_USER` if not already aborted) and dispatches a final + // `close` event. + close = jest.fn(() => { + this.socket.terminate('TERMINATED_BY_USER'); + }); + + subscribe = jest.fn( + ( + channel: string, + params: unknown, + listener: (event: { detail: unknown }) => void, + options?: { onError?: (error: Error) => void }, + ) => { + // Store an emitter that mirrors the SDK's CustomEvent delivery so tests + // can push a raw snapshot via `listeners[i].listener(rawData)`, plus the + // SDK's post-confirmation `onError` callback so tests can simulate a + // rejected re-subscription via `listeners[i].onError(err)`. + state.listeners.push({ + channel, + params, + listener: (detail: unknown) => listener({ detail }), + onError: options?.onError, + }); + if (state.rejectSubscribe) { + return Promise.reject(new Error('subscribe failed')); + } + return state.resolveSubscribe + ? Promise.resolve({ unsubscribe: state.unsubscribe }) + : new Promise(() => undefined); + }, + ); + + constructor(options: Record) { + this.options = options; + state.transports.push(this as unknown as MockTransport); + } + } + + type SubscribingTransport = { + subscribe: ( + channel: string, + params: unknown, + listener: (event: { detail: unknown }) => void, + options?: { onError?: (error: Error) => void }, + ) => Promise; + }; + + // Mirrors the typed subscription client: `l2Book` validates/normalizes the + // params, prepends `type: 'l2Book'`, and delegates to `transport.subscribe`, + // unwrapping the CustomEvent so the caller's listener receives the snapshot + // directly. Forwards `options` (`onError`) unchanged. + class SubscriptionClient { + readonly #transport: SubscribingTransport; + + constructor({ transport }: { transport: SubscribingTransport }) { + this.#transport = transport; + } + + async l2Book( + params: Record, + listener: (data: unknown) => void, + options?: { onError?: (error: Error) => void }, + ): Promise { + return this.#transport.subscribe( + 'l2Book', + { type: 'l2Book', ...params }, + (event) => listener(event.detail), + options, + ); + } + } + + return { WebSocketTransport, SubscriptionClient, mockState: state }; +}); + +const { mockState } = hl as unknown as { mockState: MockState }; + +const flush = async (): Promise => { + // Drain a few microtasks so both the resolve (.then) and reject (.then skip + // → .catch) branches of the subscribe promise chain settle. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +const l2Level = ( + px: string, + sz: string, + count = 1, +): { px: string; sz: string; n: number } => ({ px, sz, n: count }); + +describe('processAggregatedOrderBook', () => { + it('builds cumulative totals, spread, and mid price from an l2Book snapshot', () => { + const result = processAggregatedOrderBook( + { + coin: 'BTC', + time: 1, + levels: [ + [l2Level('64000', '2'), l2Level('63000', '1')], + [l2Level('65000', '1'), l2Level('66000', '3')], + ], + }, + 10, + ); + + expect(result.bids).toStrictEqual([ + { + price: '64000', + size: '2', + total: '2', + notional: '128000.00', + totalNotional: '128000.00', + }, + { + price: '63000', + size: '1', + total: '3', + notional: '63000.00', + totalNotional: '191000.00', + }, + ]); + expect(result.asks[0]).toStrictEqual({ + price: '65000', + size: '1', + total: '1', + notional: '65000.00', + totalNotional: '65000.00', + }); + // spread = 65000 - 64000, mid = (65000 + 64000) / 2 + expect(result.spread).toBe('1000.00000'); + expect(result.midPrice).toBe('64500.00000'); + expect(result.maxTotal).toBe('4'); + }); + + it('trims each side to the requested level count', () => { + const result = processAggregatedOrderBook( + { + coin: 'BTC', + time: 1, + levels: [ + [l2Level('3', '1'), l2Level('2', '1'), l2Level('1', '1')], + [l2Level('4', '1'), l2Level('5', '1'), l2Level('6', '1')], + ], + }, + 2, + ); + + expect(result.bids).toHaveLength(2); + expect(result.asks).toHaveLength(2); + }); + + it('reports a zero spread when a side is empty', () => { + const result = processAggregatedOrderBook( + { coin: 'BTC', time: 1, levels: [[], []] }, + 10, + ); + expect(result.spread).toBe('0.00000'); + expect(result.spreadPercentage).toBe('0'); + expect(result.midPrice).toBe('0.00000'); + }); +}); + +describe('AggregatedOrderBookConnection', () => { + beforeEach(() => { + mockState.transports.length = 0; + mockState.listeners.length = 0; + // `resetMocks: true` (repo jest config) clears the shared mock's + // implementation before each test, so re-apply the resolved value. + mockState.unsubscribe.mockReset().mockResolvedValue(undefined); + mockState.resolveSubscribe = true; + mockState.rejectSubscribe = false; + }); + + it('creates a mainnet transport and subscribes with the requested params', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + + connection.subscribe({ + symbol: 'BTC', + levels: 20, + nSigFigs: 2, + callback, + }); + + expect(mockState.transports).toHaveLength(1); + // The dedicated transport reuses the package's transport config so it shares + // the finite five-attempt reconnection policy (not the SDK's Infinity). + expect(mockState.transports[0].options).toStrictEqual({ + isTestnet: false, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + expect(mockState.listeners[0].channel).toBe('l2Book'); + // Runs in fast mode (5 levels) via the raw subscription payload. + expect(mockState.listeners[0].params).toStrictEqual({ + type: 'l2Book', + coin: 'BTC', + nSigFigs: 2, + mantissa: null, + fast: true, + }); + }); + + it('creates a testnet transport when isTestnet resolves true', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => true, + }); + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports[0].options).toStrictEqual({ + isTestnet: true, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + }); + + it('transforms snapshots and forwards them to the callback', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback }); + + mockState.listeners[0].listener({ + coin: 'BTC', + time: 1, + levels: [[l2Level('64000', '1')], [l2Level('65000', '1')]], + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0][0].midPrice).toBe('64500.00000'); + }); + + it('ignores snapshots for a different coin', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback }); + + mockState.listeners[0].listener({ + coin: 'ETH', + time: 1, + levels: [[l2Level('1', '1')], [l2Level('2', '1')]], + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('reuses the same transport for a second subscription on the same network', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + + expect(mockState.transports).toHaveLength(1); + expect(mockState.transports[0].subscribe).toHaveBeenCalledTimes(2); + }); + + it('rejects a second subscription for the same asset with different params', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + + expect(() => + connection.subscribe({ symbol: 'BTC', nSigFigs: 5, callback: jest.fn() }), + ).toThrow( + 'AggregatedOrderBookConnection: "BTC" is already subscribed with different params', + ); + // The conflicting subscribe must not reach the transport. + expect(mockState.transports[0].subscribe).toHaveBeenCalledTimes(1); + }); + + it('allows a repeat subscription for the same asset with identical params', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + + expect(() => + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }), + ).not.toThrow(); + expect(mockState.transports).toHaveLength(1); + expect(mockState.transports[0].subscribe).toHaveBeenCalledTimes(2); + }); + + it('treats a different levels value as identical (levels is client-side only)', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + levels: 5, + callback: jest.fn(), + }); + + expect(() => + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + levels: 20, + callback: jest.fn(), + }), + ).not.toThrow(); + }); + + it('allows different params for the same asset once the prior subscription is removed', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + unsub(); + + expect(() => + connection.subscribe({ symbol: 'BTC', nSigFigs: 5, callback: jest.fn() }), + ).not.toThrow(); + }); + + it('closes the transport once the last subscription is removed', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsubA = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + const unsubB = connection.subscribe({ + symbol: 'ETH', + nSigFigs: 2, + callback: jest.fn(), + }); + await flush(); + + const [transport] = mockState.transports; + + unsubA(); + expect(transport.close).not.toHaveBeenCalled(); + + unsubB(); + expect(transport.close).toHaveBeenCalledTimes(1); + // The pending SDK subscriptions are also cancelled. + expect(mockState.unsubscribe).toHaveBeenCalledTimes(2); + }); + + it('recreates the transport when the network changes between subscriptions', () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(1); + + testnet = true; + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(2); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + expect(mockState.transports[1].options).toStrictEqual({ + isTestnet: true, + ...HYPERLIQUID_TRANSPORT_CONFIG, + reconnect: HYPERLIQUID_TRANSPORT_CONFIG.reconnect, + }); + }); + + it('does not tear down the new transport when an old subscription unsubscribes after a network change', () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + const unsubOld = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + + // Network flips, so the next subscribe recreates the transport. + testnet = true; + const unsubNew = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + expect(mockState.transports).toHaveLength(2); + const [, newTransport] = mockState.transports; + + // The stale subscription's unsubscribe must not touch the live socket. + unsubOld(); + expect(newTransport.close).not.toHaveBeenCalled(); + + // The remaining live subscription still owns the socket and closes it. + unsubNew(); + expect(newTransport.close).toHaveBeenCalledTimes(1); + }); + + it('cancels a subscription that resolves after unsubscribe', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const callback = jest.fn(); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback, + }); + + // Unsubscribe before the snapshot listener is invoked. + unsub(); + + mockState.listeners[0].listener({ + coin: 'BTC', + time: 1, + levels: [[l2Level('1', '1')], [l2Level('2', '1')]], + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('closes the active transport on close()', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + connection.close(); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + + describe('connection status', () => { + it('reports connecting immediately then connected once the subscription resolves', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + expect(onStatusChange).toHaveBeenNthCalledWith(1, 'connecting'); + await flush(); + expect(onStatusChange).toHaveBeenCalledWith('connected'); + }); + + it('reports connected on socket open and connecting on socket close', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + const { socket } = mockState.transports[0]; + + socket.dispatchEvent(new Event('open')); + expect(onStatusChange).toHaveBeenCalledWith('connected'); + + socket.dispatchEvent(new Event('close')); + expect(onStatusChange).toHaveBeenLastCalledWith('connecting'); + }); + + it('reports error when the socket terminates (reconnection exhausted)', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // Reconnection exhausted: the socket aborts its `terminationSignal` then + // dispatches a final `close`. + mockState.transports[0].socket.terminate(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + }); + + it('reports connecting (not error) when close fires from an intentional close()', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // A user-triggered close aborts the signal with `TERMINATED_BY_USER`; that + // must not be surfaced as the unrecoverable `error` state. + mockState.transports[0].socket.terminate('TERMINATED_BY_USER'); + expect(onStatusChange).toHaveBeenLastCalledWith('connecting'); + expect(onStatusChange).not.toHaveBeenCalledWith('error'); + }); + + it('stays in error (not connected) when the subscribe promise resolves after the socket terminates', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // Reconnection is exhausted before the pending subscribe promise settles. + mockState.transports[0].socket.terminate(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + + // The pending subscribe promise now resolves; it must not flip the UI back + // to `connected` on the dead socket. + await flush(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + expect(onStatusChange).not.toHaveBeenCalledWith('connected'); + }); + + it('errors and tears down when a confirmed subscription is rejected on resubscribe', async () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // The subscription is confirmed first. + await flush(); + expect(onStatusChange).toHaveBeenLastCalledWith('connected'); + + // After a reconnect the server rejects the re-subscription: the SDK + // invokes `onError` and removes the listener (no further events follow). + // The service must surface `error` and tear down rather than keep + // reporting `connected` with a frozen order book. + mockState.listeners[0].onError?.(new Error('resubscribe rejected')); + + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + + it('does not report connected when the transport is replaced before an in-flight subscribe resolves', async () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + const onStatusChangeOld = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange: onStatusChangeOld, + }); + + // Flip the network before the first subscribe settles: this recreates the + // transport, so the first (still-pending) subscription is now stale. + testnet = true; + const onStatusChangeNew = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange: onStatusChangeNew, + }); + expect(mockState.transports).toHaveLength(2); + + await flush(); + + // The stale subscription must not announce `connected` on the dead socket, + // and its SDK subscription is cleaned up rather than stored. + expect(onStatusChangeOld).not.toHaveBeenCalledWith('connected'); + expect(mockState.unsubscribe).toHaveBeenCalled(); + // The live subscription on the new transport still reports connected. + expect(onStatusChangeNew).toHaveBeenLastCalledWith('connected'); + }); + + it('terminates orphaned subscriptions when the transport is rebuilt on a network flip', async () => { + let testnet = false; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => testnet, + }); + const onStatusChangeOld = jest.fn(); + const callbackOld = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: callbackOld, + onStatusChange: onStatusChangeOld, + }); + await flush(); + expect(onStatusChangeOld).toHaveBeenLastCalledWith('connected'); + + // Flip the network and subscribe again: this rebuilds the transport, + // orphaning the first subscription that was live on the old socket. + testnet = true; + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + + // The orphaned subscription is notified with `error` and its SDK + // subscription is cleaned up rather than left dangling on the dead socket. + expect(onStatusChangeOld).toHaveBeenLastCalledWith('error'); + expect(mockState.unsubscribe).toHaveBeenCalled(); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + + // A late snapshot on the orphaned listener must be ignored. + mockState.listeners[0].listener({ + coin: 'BTC', + time: 1, + levels: [[l2Level('1', '1')], [l2Level('2', '1')]], + }); + expect(callbackOld).not.toHaveBeenCalled(); + }); + + it('binds a reentrant subscribe from an onStatusChange handler to a fresh transport', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + let reentered = false; + const onStatusChange = jest.fn((status: string) => { + // Re-subscribe synchronously from within the terminal `error` + // notification emitted while the transport is being torn down. + if (status === 'error' && !reentered) { + reentered = true; + connection.subscribe({ + symbol: 'ETH', + nSigFigs: 2, + callback: jest.fn(), + }); + } + }); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + const [firstTransport] = mockState.transports; + + // Closing force-terminates BTC, whose `error` handler re-subscribes. The + // reentrant subscription must bind to a NEW transport, not the dying one. + connection.close(); + + expect(reentered).toBe(true); + expect(firstTransport.close).toHaveBeenCalledTimes(1); + expect(mockState.transports).toHaveLength(2); + const newTransport = mockState.transports[1]; + expect(newTransport).not.toBe(firstTransport); + // The reentrant subscription's transport is left live (not orphaned). + expect(newTransport.close).not.toHaveBeenCalled(); + }); + + it('does not leave the connection flagged terminated after closing an exhausted socket', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + // Reconnection is exhausted on the dedicated socket. + mockState.transports[0].socket.terminate(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + + // Resubscribe rebuilds the transport. Closing the exhausted socket + // dispatches a final `close`; that must not re-flag the connection as + // terminated after it was cleared. + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(2); + + // A further subscribe on the same network must REUSE the healthy new + // transport rather than tearing it down as if it were dead. + connection.subscribe({ symbol: 'ETH', nSigFigs: 2, callback: jest.fn() }); + expect(mockState.transports).toHaveLength(2); + }); + + it('reports error when the subscription request rejects', async () => { + mockState.rejectSubscribe = true; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + + await flush(); + expect(onStatusChange).toHaveBeenLastCalledWith('error'); + }); + + it('releases the refcount and closes the transport when the subscription request rejects', async () => { + mockState.rejectSubscribe = true; + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + + await flush(); + + // The failed subscribe must not leave the dedicated socket open. + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + + // A subsequent unsubscribe is a no-op and must not double-close. + unsub(); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + + it('stops reporting status after unsubscribe', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const onStatusChange = jest.fn(); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + onStatusChange, + }); + const { socket } = mockState.transports[0]; + unsub(); + onStatusChange.mockClear(); + + socket.terminate(); + expect(onStatusChange).not.toHaveBeenCalled(); + }); + + it('builds a fresh transport when resubscribing after a terminated socket', () => { + const connection = new AggregatedOrderBookConnection({ + isTestnet: (): boolean => false, + }); + const unsub = connection.subscribe({ + symbol: 'BTC', + nSigFigs: 2, + callback: jest.fn(), + }); + mockState.transports[0].socket.terminate(); + + // Reconnect flow: tear the dead subscription down, then resubscribe. + unsub(); + connection.subscribe({ symbol: 'BTC', nSigFigs: 2, callback: jest.fn() }); + + expect(mockState.transports).toHaveLength(2); + expect(mockState.transports[0].close).toHaveBeenCalledTimes(1); + }); + }); +});