feat(perps-controller): add dedicated aggregated order book connection#9549
feat(perps-controller): add dedicated aggregated order book connection#9549michalconsensys wants to merge 17 commits into
Conversation
Add AggregatedOrderBookConnection service with processAggregatedOrderBook helper and export its public types from the package index.
…dling Release the subscription refcount when a subscribe request fails so a failed subscribe no longer leaks the dedicated socket, and scope teardown to the transport a subscription was created on so a stale unsubscribe after a network-driven transport recreate cannot tear down the live socket while other subscriptions remain active.
|
Currently if you subscribe twice to l2Book with 2 different parameters, then you don't know from which subscription you are getting from the response. This can affect how we for example compute mid price, if there are 2 concurrent subscriptions. This PR addresses this issue by creating a new WS connection for the orderbook |
geositta
left a comment
There was a problem hiding this comment.
Requesting changes. Creating a separate socket isolates the panel's aggregated book from the raw book on the main connection, but the implementation does not yet enforce the intended guarantee for concurrent l2Book subscriptions with different parameters. The new service reuses its transport, so two same symbol aggregation configurations remain indistinguishable.
The outage recovery contract is also nonfunctional: the transport retries indefinitely and listens for a terminate event that its underlying ReconnectingWebSocket never emits. During a sustained outage, the panel remains connecting, never exposes manual reconnect, and may reuse the terminated transport.
Please enforce subscription isolation for distinct same symbol payloads, configure finite retries, observe terminationSignal, and test these production behaviors. Aligning the optional nSigFigs default with the service’s aggregated contract remains a recommended nonblocking improvement.
| }; | ||
| socket.addEventListener('open', handleOpen); | ||
| socket.addEventListener('close', handleClose); | ||
| socket.addEventListener('terminate', handleTerminate); |
There was a problem hiding this comment.
WebSocketTransport.socket is an @nktkas/rews@4.0.1 ReconnectingWebSocket, reached transitively through @nktkas/hyperliquid. That socket emits only open, close, error, and message; permanent termination aborts socket.terminationSignal instead of dispatching terminate.
addEventListener accepts arbitrary event names, so this compiles without warning, but handleTerminate is dead code. During a sustained outage, rews emits a final close, the panel reports connecting indefinitely, #terminated remains false, and #ensureTransport can reuse the dead transport. The documented error state and manual reconnect affordance therefore cannot execute.
Please listen for terminationSignal’s abort event or inspect terminationSignal.aborted in handleClose, and update the tests to exercise that production signal rather than manually dispatching a nonexistent terminate event.
HyperLiquidClientService.ts:266 contains the same pre-existing pattern, which explains how this was copied but should be tracked separately from this PR.
| */ | ||
| subscribe(params: SubscribeAggregatedOrderBookParams): () => void { | ||
| const levels = params.levels ?? DEFAULT_LEVELS; | ||
| const transport = this.#ensureTransport(this.#isTestnet()); |
There was a problem hiding this comment.
The dedicated socket fixes the current raw versus aggregated collision, but this service still reuses that socket for every active subscription. If the same symbol is subscribed with different nSigFigs or mantissa values, every l2Book listener receives both responses, and the payload contains no aggregation identifier that can demultiplex them.
This leaves the concurrent different-parameters case unresolved within the new public API. Please enforce one payload per transport, use separate transports for distinct same-symbol payloads, or reject unsupported concurrent subscriptions. Add a same-symbol/different-parameters test; the current BTC/ETH test cannot reproduce the ambiguity.
| // First use, the network changed, or the previous socket was terminated — | ||
| // (re)create the dedicated transport. | ||
| this.#closeTransport(); | ||
| const transport = new WebSocketTransport({ isTestnet }); |
There was a problem hiding this comment.
Please configure finite reconnection attempts here. WebSocketTransport defaults maxRetries to Infinity, so an ordinary sustained outage never exhausts reconnection and never reaches the documented manual-reconnect state. Reusing HYPERLIQUID_TRANSPORT_CONFIG would apply the package’s existing five-attempt policy and keep both transports consistent.
| { | ||
| type: 'l2Book', | ||
| coin: params.symbol, | ||
| nSigFigs: params.nSigFigs ?? null, |
There was a problem hiding this comment.
Omitting nSigFigs sends null, which requests the raw full precision book rather than an aggregated book. That makes the default behavior contradict the service contract. Please require nSigFigs or provide an aggregation default such as the existing service's value of 5, then cover omission in a test.
Omitting `nSigFigs` sent `null`, which requested the raw full-precision book instead of an aggregated one, contradicting the service contract. Make `nSigFigs` required on `SubscribeAggregatedOrderBookParams`.
The dedicated aggregated `l2Book` transport was created with the SDK's default `maxRetries` of Infinity, so a sustained outage never exhausted reconnection and never reached the documented `error`/manual-reconnect state. Reuse `HYPERLIQUID_TRANSPORT_CONFIG` so this socket shares the package's five-attempt policy and stays consistent with the main WebSocket transport.
The dedicated socket dispatches `l2Book` events by `coin` only, so two subscriptions for the same asset with different params (e.g. `nSigFigs`) would cross-contaminate — the exact collision this connection exists to avoid. Track the active payload per asset and reject a conflicting subscribe, while still allowing identical repeat subscriptions.
The reconnecting socket never emits a `terminate` event, so the previous listener could never fire and the documented `error`/manual-reconnect state was unreachable. Permanent termination aborts the socket's `terminationSignal` right before the final `close`, so inspect that signal in the close handler (ignoring `TERMINATED_BY_USER` for our own `close()`). Tests now drive the real signal instead of dispatching a nonexistent `terminate` event.
geositta
left a comment
There was a problem hiding this comment.
equesting changes. The latest commits resolve the previous isolation, retry, termination, and aggregation contract findings. One post-reconnect failure path remains: if the SDK rejects a confirmed subscription’s resubscription, it removes the listener and reports the failure only through onError. The service currently remains connected while displaying a frozen order book.
Please handle that callback with the existing error-and-teardown behavior and add coverage for the reconnect/rejection sequence. The outdated typed-API comment is non-blocking.
| // validation) so `fast: true` reaches the server. The listener receives the | ||
| // SDK's `CustomEvent`, whose `detail` is the `l2Book` snapshot. | ||
| transport | ||
| .subscribe<HyperliquidL2BookEvent>( |
There was a problem hiding this comment.
The SDK distinguishes initial subscription failures from post-confirmation failures. The existing .catch handles the initial request, but after reconnecting, a rejected resubscription removes the listener and reports the failure only through options.onError.
Without that callback, the service reports connected on socket open and then silently stops receiving updates. The panel displays a frozen order book as connected, while #activeCount and #payloads still represent a live subscription.
Please pass an idempotent onError handler that reports error and calls teardown, reuse it from the existing .catch, and test an initially confirmed subscription followed by a rejected resubscription.
|
|
||
| reportStatus('connecting'); | ||
|
|
||
| // The SDK's typed `l2Book` subscription drops unknown fields, so it can't |
There was a problem hiding this comment.
Nonblocking:
@nktkas/hyperliquid@0.33.1 includes fast in its typed l2Book schema and forwards subscription options, including onError. Please correct this outdated comment. Switching to the typed API would restore payload validation, but that refactor does not need to block the lifecycle fix above.
…esolve
Once the aggregated order book socket terminates (reconnection exhausted),
the error status is terminal until teardown/resubscribe. Previously a
subscribe promise resolving after termination could call
reportStatus('connected'), flipping the UI back to a healthy state on a
dead socket and hiding the manual-reconnect affordance. Suppress any late
connected/connecting reports once terminated.
The SDK reports failures of an already-confirmed subscription (e.g. the server rejecting a re-subscription after a reconnect) only through the subscribe options.onError callback, removing the listener without further events. The aggregated order book connection never passed onError, so such a failure left the panel showing connected with a frozen book while the refcount still tracked a live subscription. Pass an idempotent onError handler that reports error and tears down, reused from the initial-failure .catch path.
@nktkas/hyperliquid@0.33.1 added `fast` to the typed l2Book schema and forwards subscription options (including onError), so the raw transport.subscribe workaround is no longer needed. Switch the aggregated order book connection to SubscriptionClient.l2Book to restore payload validation and drop the outdated comment about the typed API dropping unknown fields.
When the dedicated transport is replaced (network flip / recreate) before an in-flight l2Book subscribe settles, the promise success path stored the subscription and reported connected on the now-dead socket. Gate reportStatus and the success path on transport === this.#transport (mirroring teardown), unsubscribing the stale subscription instead of announcing connected.
geositta
left a comment
There was a problem hiding this comment.
Approved with one nonblocking request to tweak a code comment to be more accurate.
| * (`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` |
There was a problem hiding this comment.
Nonblocking: Could we clarify this as "at most one l2Book payload per asset"? The service intentionally supports different assets on the same transport, while preventing ambiguous same asset configurations. "A single stream" does not match that behavior.
…-order-book-connection
…release main released perps-controller 9.3.0, moving the previously-unreleased entries into a versioned section. The merge auto-placed this PR's new AggregatedOrderBookConnection entry into 9.3.0; move it back under [Unreleased] so the merge-queue changelog check passes.
…rebuild When #closeTransport rebuilt the socket (network flip, post-termination resubscribe, or close()) it cleared refcounts without ending subscriptions still live on the old transport, leaking their SDK subscription/listeners and leaving callers frozen with no status update. Track a force-terminate callback per subscription and run them before detaching the transport so each orphaned caller is notified (error) and cleaned up.
…ardown forceTerminate notifies via onStatusChange, which can synchronously re-enter subscribe. #closeTransport ran callbacks while #transport still pointed at the dying socket, so a reentrant subscribe reused it and leaked its listeners and SDK subscription past close. Detach all shared state (set, #transport, refcounts) before invoking callbacks so reentrant subscribes build a fresh transport, and reuse it in #ensureTransport instead of orphaning it.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5bba037. Configure here.
| } | ||
| for (const forceTerminate of subscriptions) { | ||
| forceTerminate(); | ||
| } |
There was a problem hiding this comment.
Stale close sets terminated flag
High Severity
#closeTransport clears #terminated and then calls transport.close() before forceTerminate removes socket listeners. A stale handleClose on an exhausted socket still sets #terminated to true, so later #ensureTransport treats a healthy dedicated socket as dead and tears it down on the next subscribe.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5bba037. Configure here.


Explanation
The order-book panel needs a server-aggregated (
nSigFigs)l2Bookstream in addition to the raw, full-precision stream used for spread/slippage. The subscription service multiplexes every subscription onto a single Hyperliquid WebSocket, and the SDK dispatchesl2Bookevents bycoinonly. Running both the raw and the aggregated subscription for the same coin on that shared socket cross-contaminates them, so the coarse ladder and the precise spread clobber each other.This PR adds
AggregatedOrderBookConnection, a service that owns a dedicated Hyperliquid WebSocket used solely for the panel's aggregatedl2Booksubscription. Because that socket only ever carries a singlel2Bookstream, the collision is removed entirely and the main socket is never touched by the panel's grouping.Key details:
isTestnetchanges between (re)subscriptions.OrderBookConnectionStatus(connecting/connected/error) so the UI can show a manual reconnect affordance.erroris reported only once the SDK's automatic reconnection is exhausted (terminate), which is unrecoverable and requires a fresh transport on the next subscribe.fast: truereaches the server (the SDK's typedl2Bookmethod drops unknown fields and cannot request fast mode).processAggregatedOrderBooktransforms the raw snapshot into theOrderBookDatashape the UI consumes, mirroring the subscription service's internal processing so this connection is a drop-in replacement on the aggregated channel.The service and its
processAggregatedOrderBookhelper plus public types are exported from the package index.References
Fixes https://consensyssoftware.atlassian.net/browse/TAT-3309
Checklist
Note
Medium Risk
New real-time trading UI path with complex WebSocket lifecycle (reconnect, network flip, reentrancy); additive public API with no changes to existing subscribeToOrderBook behavior.
Overview
Adds
AggregatedOrderBookConnection, a reference-counted service that opens a separate Hyperliquid WebSocket for server-aggregated (nSigFigs)l2Bookstreams so the order-book panel no longer shares the main multiplexed socket with the raw book (which would cross-contaminate events keyed only bycoin).The connection is created on first subscribe and closed when the last subscriber leaves; it recreates the transport on testnet/mainnet changes or after reconnection exhaustion. Subscribers get
OrderBookConnectionStatus(connecting/connected/error) for UI reconnect flows, synchronous unsubscribe (including before async subscribe settles), and at most onel2Bookpayload per symbol (conflicting params throw). Snapshots are normalized via exportedprocessAggregatedOrderBookinto existingOrderBookData.The class, helper, and related types are exported from the package index; changelog and a large Jest suite cover lifecycle, refcounting, network flips, and status edge cases.
Reviewed by Cursor Bugbot for commit 5bba037. Bugbot is set up for automated code reviews on this repo. Configure here.