Skip to content

feat(perps-controller): add dedicated aggregated order book connection#9549

Open
michalconsensys wants to merge 17 commits into
mainfrom
feat/perps-aggregated-order-book-connection
Open

feat(perps-controller): add dedicated aggregated order book connection#9549
michalconsensys wants to merge 17 commits into
mainfrom
feat/perps-aggregated-order-book-connection

Conversation

@michalconsensys

@michalconsensys michalconsensys commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Explanation

The order-book panel needs a server-aggregated (nSigFigs) l2Book stream 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 dispatches l2Book events by coin only. 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 aggregated l2Book subscription. Because that socket only ever carries a single l2Book stream, the collision is removed entirely and the main socket is never touched by the panel's grouping.

Key details:

  • 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.
  • Network is a global setting, so the transport is recreated when isTestnet changes between (re)subscriptions.
  • Connection health is surfaced via OrderBookConnectionStatus (connecting / connected / error) so the UI can show a manual reconnect affordance. error is reported only once the SDK's automatic reconnection is exhausted (terminate), which is unrecoverable and requires a fresh transport on the next subscribe.
  • The subscription is sent through the transport's raw payload so fast: true reaches the server (the SDK's typed l2Book method drops unknown fields and cannot request fast mode).
  • processAggregatedOrderBook transforms the raw snapshot into the OrderBookData shape the UI consumes, mirroring the subscription service's internal processing so this connection is a drop-in replacement on the aggregated channel.
  • Unsubscribe follows the subscription service's synchronous-unsubscribe contract: the returned function can be called before the async subscribe resolves and will cancel the pending subscription.

The service and its processAggregatedOrderBook helper plus public types are exported from the package index.

References

Fixes https://consensyssoftware.atlassian.net/browse/TAT-3309

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

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) l2Book streams so the order-book panel no longer shares the main multiplexed socket with the raw book (which would cross-contaminate events keyed only by coin).

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 one l2Book payload per symbol (conflicting params throw). Snapshots are normalized via exported processAggregatedOrderBook into existing OrderBookData.

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.

Add AggregatedOrderBookConnection service with processAggregatedOrderBook
helper and export its public types from the package index.
@michalconsensys
michalconsensys requested a review from a team as a code owner July 20, 2026 07:44
Comment thread packages/perps-controller/src/services/AggregatedOrderBookConnection.ts Outdated
…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.
@michalconsensys
michalconsensys requested a review from a team as a code owner July 20, 2026 09:46
@michalconsensys

Copy link
Copy Markdown
Contributor Author

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 geositta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

@geositta geositta Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*/
subscribe(params: SubscribeAggregatedOrderBookParams): () => void {
const levels = params.levels ?? DEFAULT_LEVELS;
const transport = this.#ensureTransport(this.#isTestnet());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// First use, the network changed, or the previous socket was terminated —
// (re)create the dedicated transport.
this.#closeTransport();
const transport = new WebSocketTransport({ isTestnet });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
type: 'l2Book',
coin: params.symbol,
nSigFigs: params.nSigFigs ?? null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@michalconsensys michalconsensys Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 geositta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…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
geositta previously approved these changes Jul 21, 2026

@geositta geositta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@michalconsensys
michalconsensys added this pull request to the merge queue Jul 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 21, 2026
…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.
Comment thread packages/perps-controller/src/services/AggregatedOrderBookConnection.ts Outdated
…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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5bba037. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants