diff --git a/.dev/verify_webdata3_abstraction.ts b/.dev/verify_webdata3_abstraction.ts
new file mode 100644
index 00000000..e9aa6b76
--- /dev/null
+++ b/.dev/verify_webdata3_abstraction.ts
@@ -0,0 +1,101 @@
+/**
+ * Live verification for bloxwap/hyperliquid#82: does the `webData3` WS channel carry the same
+ * abstraction state as the REST `userAbstraction` info request?
+ *
+ * For a sample of active mainnet traders (taken from `recentTrades` — no address list is
+ * hardcoded), this script reads REST `userAbstraction` and the first `webData3` frame, then
+ * prints a side-by-side comparison. It answers three questions:
+ *
+ * 1. Does the server populate `userState.abstraction` on `webData3` at all?
+ * 2. Does its value match REST `userAbstraction` for the same account?
+ * 3. What happens for accounts where REST reports `"default"` — a value the SDK's
+ * `WebData3Event` type does not model (its union lacks `"default"`)?
+ *
+ * A match on all three means the monorepo's `use-live-account-summary.ts` REST backstop is a
+ * candidate for retirement; a mismatch or an absent field means it stays.
+ *
+ * Note: this verifies steady-state equivalence. The issue's second requirement — observing the
+ * channel across an account abstraction *migration* — is a soak test and cannot be run on demand.
+ *
+ * Usage: bun run .dev/verify_webdata3_abstraction.ts
+ *
+ * @module
+ */
+
+import { HttpTransport, InfoClient, SubscriptionClient, WebSocketTransport } from "../src/mod.ts";
+import type { WebData3Event } from "../src/api/subscription/_methods/webData3.ts";
+
+/** Active traders to sample (bounded by the 15-unique-users per-connection limit). */
+const SAMPLE_SIZE = 12;
+/** How long to wait for a user's first webData3 frame before declaring it absent. */
+const FRAME_TIMEOUT_MS = 15_000;
+
+const http = new HttpTransport();
+const info = new InfoClient({ transport: http });
+
+// --- 1. Sample active traders from public market data -------------------------------
+const trades = await info.recentTrades({ coin: "BTC" });
+const users = [...new Set(trades.flatMap((t) => t.users))].slice(0, SAMPLE_SIZE);
+console.log(`Sampling ${users.length} active traders from recent BTC trades\n`);
+
+// --- 2. REST reads -------------------------------------------------------------------
+const rest = new Map(users.map((user) => [user, info.userAbstraction({ user })]));
+
+// --- 3. First webData3 frame per user -------------------------------------------------
+const ws = new WebSocketTransport();
+await ws.ready();
+const subs = new SubscriptionClient({ transport: ws });
+
+interface WsObservation {
+ frameReceived: boolean;
+ abstractionPresent: boolean;
+ abstraction?: string;
+ agentAddress?: string | null;
+ cumLedgerPresent?: boolean;
+}
+
+function observe(user: `0x${string}`): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve({ frameReceived: false, abstractionPresent: false }), FRAME_TIMEOUT_MS);
+ subs
+ .webData3({ user }, (event: WebData3Event) => {
+ clearTimeout(timer);
+ const state = event.userState;
+ resolve({
+ frameReceived: true,
+ abstractionPresent: "abstraction" in state,
+ abstraction: state.abstraction,
+ agentAddress: state.agentAddress,
+ cumLedgerPresent: typeof state.cumLedger === "string",
+ });
+ })
+ .catch((error) => {
+ clearTimeout(timer);
+ console.error(` webData3 subscribe failed for ${user}: ${error}`);
+ resolve({ frameReceived: false, abstractionPresent: false });
+ });
+ });
+}
+
+// --- 4. Side-by-side comparison --------------------------------------------------------
+console.log("address REST userAbstraction WS abstraction match");
+console.log("─".repeat(100));
+let mismatches = 0;
+for (const user of users) {
+ const [restValue, wsObs] = await Promise.all([rest.get(user)!, observe(user)]);
+ const wsValue = !wsObs.frameReceived ? "(no frame)" : wsObs.abstractionPresent ? wsObs.abstraction : "(field absent)";
+ const match = wsObs.frameReceived && wsObs.abstractionPresent && wsObs.abstraction === restValue;
+ if (!match) mismatches++;
+ console.log(`${user} ${String(restValue).padEnd(19)} ${String(wsValue).padEnd(15)} ${match ? "yes" : "NO"}`);
+}
+
+await ws.close();
+
+console.log(`\n${"─".repeat(100)}`);
+if (mismatches === 0) {
+ console.log("RESULT: webData3.userState.abstraction matched REST userAbstraction for every sampled account.");
+ console.log("The REST backstop is a retirement candidate (migration soak test still outstanding).");
+} else {
+ console.log(`RESULT: ${mismatches}/${users.length} accounts diverged — the REST backstop must stay.`);
+ process.exit(1);
+}
diff --git a/.dev/verify_webdata3_abstraction_migration.ts b/.dev/verify_webdata3_abstraction_migration.ts
new file mode 100644
index 00000000..5aef00b2
--- /dev/null
+++ b/.dev/verify_webdata3_abstraction_migration.ts
@@ -0,0 +1,104 @@
+/**
+ * Live migration soak test for the webData3 abstraction lane (bloxwap/hyperliquid#82 follow-up).
+ *
+ * The steady-state check (`.dev/verify_webdata3_abstraction.ts`) proved webData3's
+ * `userState.abstraction` matches REST `userAbstraction` value-for-value, with the field ABSENT
+ * in the default state. What it could not prove is the migration case: does the channel push the
+ * NEW model when an account flips abstraction, and how fast?
+ *
+ * This script answers it on testnet with a throwaway wallet:
+ * 1. fresh account → REST says "default", first webData3 frame omits the field;
+ * 2. `userSetAbstraction("unifiedAccount")` → expect a webData3 frame carrying
+ * `abstraction: "unifiedAccount"`, and REST agreeing;
+ * 3. flip back to "disabled" → expect `abstraction: "disabled"` on both.
+ *
+ * A pass means the monorepo's REST `userAbstraction` read can be retired outright (no soak
+ * caveat left): the channel delivers the initial state, steady-state changes, and migrations.
+ *
+ * Usage: bun run .dev/verify_webdata3_abstraction_migration.ts
+ *
+ * @module
+ */
+
+import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
+import { ExchangeClient, HttpTransport, InfoClient, SubscriptionClient, WebSocketTransport } from "../src/mod.ts";
+import type { WebData3Event } from "../src/api/subscription/_methods/webData3.ts";
+
+const FRAME_TIMEOUT_MS = 30_000;
+
+const wallet = privateKeyToAccount(generatePrivateKey());
+const user = wallet.address;
+console.log(`Throwaway testnet wallet: ${user}\n`);
+
+const http = new HttpTransport({ isTestnet: true });
+const info = new InfoClient({ transport: http });
+const exchange = new ExchangeClient({ transport: http, wallet });
+
+const ws = new WebSocketTransport({ isTestnet: true });
+await ws.ready();
+const subs = new SubscriptionClient({ transport: ws });
+
+/** Resolves with the next webData3 frame whose `abstraction` equals `want` ("absent" matches a missing field). */
+function awaitAbstraction(want: string | "absent"): Promise<{ event: WebData3Event; latencyMs: number }> {
+ return new Promise((resolve, reject) => {
+ const started = performance.now();
+ const timer = setTimeout(() => reject(new Error(`timed out waiting for abstraction=${want}`)), FRAME_TIMEOUT_MS);
+ subs
+ .webData3({ user }, (event) => {
+ const value = "abstraction" in event.userState ? event.userState.abstraction : "absent";
+ console.log(` frame: abstraction=${value} (t+${((performance.now() - started) / 1000).toFixed(1)}s)`);
+ if (value === want) {
+ clearTimeout(timer);
+ resolve({ event, latencyMs: performance.now() - started });
+ }
+ })
+ .catch(reject);
+ });
+}
+
+let failures = 0;
+function check(label: string, ok: boolean): void {
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}`);
+ if (!ok) failures++;
+}
+
+// --- 1. Fresh account: REST "default", WS field absent --------------------------------
+const initialRest = await info.userAbstraction({ user });
+check(`fresh account: REST userAbstraction = "default" (got "${initialRest}")`, initialRest === "default");
+
+const firstFrame = await awaitAbstraction("absent");
+check("fresh account: first webData3 frame omits `abstraction`", true);
+
+// --- 2. Migrate to unifiedAccount --------------------------------------------------------
+console.log('\nuserSetAbstraction("unifiedAccount")…');
+const flip1 = await exchange.userSetAbstraction({ user, abstraction: "unifiedAccount" });
+check(`action accepted (status "${flip1.status}")`, flip1.status === "ok");
+
+const [migrated] = await Promise.all([
+ awaitAbstraction("unifiedAccount"),
+ // REST read after the WS frame lands keeps the comparison honest without racing the action.
+]);
+check("webData3 pushed the migration (abstraction = unifiedAccount)", true);
+console.log(` migration latency (action → WS frame): ${(migrated.latencyMs / 1000).toFixed(2)}s`);
+
+const restAfterFlip = await info.userAbstraction({ user });
+check(`REST agrees after migration (got "${restAfterFlip}")`, restAfterFlip === "unifiedAccount");
+
+// --- 3. Flip back to disabled --------------------------------------------------------------
+console.log('\nuserSetAbstraction("disabled")…');
+const flip2 = await exchange.userSetAbstraction({ user, abstraction: "disabled" });
+check(`action accepted (status "${flip2.status}")`, flip2.status === "ok");
+
+await awaitAbstraction("disabled");
+check("webData3 pushed the second migration (abstraction = disabled)", true);
+
+const restFinal = await info.userAbstraction({ user });
+check(`REST agrees after second migration (got "${restFinal}")`, restFinal === "disabled");
+
+await ws.close();
+console.log(
+ failures === 0
+ ? "\nRESULT: webData3 covers initial state, steady state, and migrations."
+ : `\nRESULT: ${failures} check(s) failed.`,
+);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/README.md b/README.md
index 70ac17b4..9ee9af92 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Blazing fast typescript
- Hyperliquid SDK
+ Hyperliquid SDK
@@ -26,7 +26,8 @@
## Documentation
-Browse the [SDK documentation](docs/README.md) for installation, clients, transports, signing, utilities, and guides.
+Browse the [SDK documentation](https://bloxwap.gitbook.io/hyperliquid) for installation, clients, transports, signing,
+utilities, and guides.
## Installation
@@ -55,7 +56,7 @@ yarn add @bloxwap/hyperliquid
```
> React Native needs polyfills for the `fastAssetCtxs` subscription and for versions below 0.86 — see the
-> [documentation](https://nktkas.gitbook.io/hyperliquid).
+> [documentation](https://bloxwap.gitbook.io/hyperliquid).
## Quick Example
@@ -116,6 +117,12 @@ await exchange.updateLeverage({ asset: 0, isCross: true, leverage: 5 });
await exchange.withdraw3({ destination: "0x...", amount: "1" });
```
+For low-latency bots, prefer
+[`createFastLocalWallet`](https://bloxwap.gitbook.io/hyperliquid/docs/signing#fast-local-wallet-wasm-secp256k1) (WASM
+secp256k1) and install the optional `hash-wasm` package for ambient keccak acceleration. Trusted callers can also pass
+`{ skipValidation: true }` — see the
+[low-latency recipe](https://bloxwap.gitbook.io/hyperliquid/docs/signing#low-latency-recipe-bots--hft).
+
### Subscribe
```ts
@@ -150,4 +157,4 @@ await subs.l2Book({ coin: "ETH" }, (data) => {
> store (Bun auto-loads a local `.env`, which is gitignored in this repo).
> - For trading bots, prefer a Hyperliquid **agent wallet** (API wallet) over the master account key: an agent key can
> trade but cannot withdraw, and it can be revoked without rotating the master key.
-> - See [Signing](docs/signing.md) for how wallets, signatures, and nonces work.
+> - See [Signing](https://bloxwap.gitbook.io/hyperliquid/docs/signing) for how wallets, signatures, and nonces work.
diff --git a/docs/signing.md b/docs/signing.md
index a77fa930..409f8332 100644
--- a/docs/signing.md
+++ b/docs/signing.md
@@ -636,6 +636,36 @@ The acceleration needs no code changes:
Unlike `createFastLocalWallet`, the dispatch is ambient: every signing entry point benefits, including wallets you
already create today.
+## Low-latency recipe (bots / HFT)
+
+Stack the accelerators when signature latency is on the critical path:
+
+1. **`createFastLocalWallet`** — halves ECDSA (~55 µs vs ~85 µs). ECDSA is ~90% of a single-order `signL1Action`.
+2. **`hash-wasm`** — ambient keccak speedup on every L1 hash and Agent digest (install the optional dep; no code change).
+3. **`skipValidation: true`** — skip the valibot parse + key canonicalization on trusted, already-canonical wire input
+ (~3× less non-ECDSA CPU). See [ExchangeClient](clients.md#skipping-validation-unsafe) for the contract.
+
+```ts
+import { ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid";
+import { createFastLocalWallet } from "@bloxwap/hyperliquid/signing";
+
+// npm i tiny-secp256k1 hash-wasm # optional deps; install explicitly if your package manager skips them
+const wallet = await createFastLocalWallet("0x...");
+const exchange = new ExchangeClient({ transport: new HttpTransport(), wallet });
+
+// Action must already be in canonical wire form (schema key order, normalized decimals, lowercase hex, defaults filled).
+await exchange.order(
+ {
+ orders: [{ a: 0, b: true, p: "95000", s: "0.01", r: false, t: { limit: { tif: "Gtc" } } }],
+ grouping: "na",
+ },
+ { skipValidation: true },
+);
+```
+
+Without step 3 the first two still apply and are safe for any input. Step 3 is an escape hatch: invalid input is no
+longer a client-side `ValidationError` — the server rejects it instead.
+
## Helpers
These functions work with any supported wallet type:
diff --git a/package.json b/package.json
index 91ed9a1f..810818e6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@bloxwap/hyperliquid",
- "version": "0.1.3",
+ "version": "0.1.4",
"description": "Blazing fast TypeScript Hyperliquid SDK.",
"license": "MIT",
"type": "module",
diff --git a/src/api/exchange/_methods/_base/_semaphore.ts b/src/api/exchange/_methods/_base/_semaphore.ts
index 42ca039d..9d12c59d 100644
--- a/src/api/exchange/_methods/_base/_semaphore.ts
+++ b/src/api/exchange/_methods/_base/_semaphore.ts
@@ -8,10 +8,24 @@
*
* Replaces `@jsr/std__async`'s `Semaphore(1)`, which was only ever used as a
* single-permit (and itself FIFO) lock.
+ *
+ * Waiters sit in a grow-only array with a head index rather than `Array.shift()`:
+ * under a contended wallet (hundreds of concurrent orders) each release would
+ * otherwise copy the remaining queue — O(n) per wake-up, O(n²) for a burst.
+ * Compaction runs only when the head has walked past half the storage, so the
+ * amortized cost of enqueue/dequeue stays O(1).
*/
class Mutex {
- private _locked = false;
- private _waiters: (() => void)[] = [];
+ private _locked: boolean;
+ private _waiters: (() => void)[];
+ /** Index of the next waiter to wake; advanced on release, never decremented mid-burst. */
+ private _head: number;
+
+ constructor() {
+ this._locked = false;
+ this._waiters = [];
+ this._head = 0;
+ }
/**
* Acquires the lock, waiting until it is free.
@@ -28,9 +42,25 @@ class Mutex {
/** Releases the lock, waking the longest-waiting waiter if any. */
release(): void {
- const next = this._waiters.shift();
- if (next) next();
- else this._locked = false;
+ if (this._head < this._waiters.length) {
+ const next = this._waiters[this._head];
+ // Drop the reference so a long-lived mutex does not pin resolved closures.
+ this._waiters[this._head++] = undefined as unknown as () => void;
+ // Compact when half the storage is dead so the array cannot grow without bound
+ // across many contended bursts on the same key.
+ if (this._head > 16 && this._head * 2 >= this._waiters.length) {
+ this._waiters = this._waiters.slice(this._head);
+ this._head = 0;
+ }
+ next();
+ } else {
+ this._locked = false;
+ // Idle: drop any residual storage so a quiet wallet costs nothing.
+ if (this._waiters.length > 0) {
+ this._waiters = [];
+ this._head = 0;
+ }
+ }
}
}
@@ -41,7 +71,7 @@ class Mutex {
* @template V Stored value type.
*/
class RefCountedRegistry {
- private _map = new Map();
+ private _map: Map;
private _factory: () => V;
/**
@@ -50,6 +80,7 @@ class RefCountedRegistry {
* @param factory Factory function used to create a new value when a key is first referenced.
*/
constructor(factory: () => V) {
+ this._map = new Map();
this._factory = factory;
}
diff --git a/src/api/subscription/_methods/activeAssetCtx.ts b/src/api/subscription/_methods/activeAssetCtx.ts
index 1a53be45..5e43f6b7 100644
--- a/src/api/subscription/_methods/activeAssetCtx.ts
+++ b/src/api/subscription/_methods/activeAssetCtx.ts
@@ -80,11 +80,8 @@ export function activeAssetCtx(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.coin === payload.coin) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this coin's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/activeAssetData.ts b/src/api/subscription/_methods/activeAssetData.ts
index 2e451987..bd543530 100644
--- a/src/api/subscription/_methods/activeAssetData.ts
+++ b/src/api/subscription/_methods/activeAssetData.ts
@@ -78,10 +78,9 @@ export function activeAssetData(
return config.transport.subscribe(
payload.type,
payload,
+ // Routing keys only by coin; user still needs a local filter (shared coin costs at most one discard).
(e) => {
- if (e.detail.coin === payload.coin && e.detail.user === payload.user) {
- listener(e.detail);
- }
+ if (e.detail.user === payload.user) listener(e.detail);
},
options,
);
diff --git a/src/api/subscription/_methods/activeSpotAssetCtx.ts b/src/api/subscription/_methods/activeSpotAssetCtx.ts
index 2abb97c1..c3b907af 100644
--- a/src/api/subscription/_methods/activeSpotAssetCtx.ts
+++ b/src/api/subscription/_methods/activeSpotAssetCtx.ts
@@ -80,11 +80,8 @@ export function activeSpotAssetCtx(
return config.transport.subscribe(
"activeSpotAssetCtx",
payload,
- (e) => {
- if (e.detail.coin === payload.coin) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this coin's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/allDexsClearinghouseState.ts b/src/api/subscription/_methods/allDexsClearinghouseState.ts
index 228ebc80..34b1c937 100644
--- a/src/api/subscription/_methods/allDexsClearinghouseState.ts
+++ b/src/api/subscription/_methods/allDexsClearinghouseState.ts
@@ -92,11 +92,8 @@ export function allDexsClearinghouseState(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/bbo.ts b/src/api/subscription/_methods/bbo.ts
index 84013613..724d8b1f 100644
--- a/src/api/subscription/_methods/bbo.ts
+++ b/src/api/subscription/_methods/bbo.ts
@@ -111,11 +111,8 @@ export function bbo(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.coin === payload.coin) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this coin's frames (`bbo\0{coin}`); no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/candle.ts b/src/api/subscription/_methods/candle.ts
index 6dcd8f73..f7723b80 100644
--- a/src/api/subscription/_methods/candle.ts
+++ b/src/api/subscription/_methods/candle.ts
@@ -111,10 +111,9 @@ export function candle(
return config.transport.subscribe(
payload.type,
payload,
+ // Routing keys only by coin; interval still needs a local filter (two intervals share a route).
(e) => {
- if (e.detail.s === payload.coin && e.detail.i === payload.interval) {
- listener(e.detail);
- }
+ if (e.detail.i === payload.interval) listener(e.detail);
},
options,
);
diff --git a/src/api/subscription/_methods/clearinghouseState.ts b/src/api/subscription/_methods/clearinghouseState.ts
index 190cf8ea..4dc2fdec 100644
--- a/src/api/subscription/_methods/clearinghouseState.ts
+++ b/src/api/subscription/_methods/clearinghouseState.ts
@@ -92,10 +92,9 @@ export function clearinghouseState(
return config.transport.subscribe(
payload.type,
payload,
+ // Routing keys only by user; dex still needs a local filter.
(e) => {
- if (e.detail.user === payload.user && e.detail.dex === payload.dex) {
- listener(e.detail);
- }
+ if (e.detail.dex === payload.dex) listener(e.detail);
},
options,
);
diff --git a/src/api/subscription/_methods/fastAssetCtxs.ts b/src/api/subscription/_methods/fastAssetCtxs.ts
index f3f412f0..0544b569 100644
--- a/src/api/subscription/_methods/fastAssetCtxs.ts
+++ b/src/api/subscription/_methods/fastAssetCtxs.ts
@@ -81,19 +81,44 @@ export function fastAssetCtxs(
// The server pushes each update as a base64 + raw DEFLATE (RFC 1951) compressed JSON string (assumed to be valid).
// Decompress sequentially so events reach the listener in arrival order.
let queue = Promise.resolve();
+ /** Frames still waiting on the async queue; the synchronous path may only run at zero. */
+ let queued = 0;
+ const released = (): void => {
+ queued--;
+ };
return config.transport.subscribe(
payload.type,
payload,
(e) => {
+ // Read synchronously: the event is a recycled shell whose `detail` is only valid during this
+ // call, and the queued continuation runs after later frames have already overwritten it.
+ const data = e.detail;
+
+ // With a native inflater there is nothing to await, so the frame is delivered in this tick
+ // and never touches the queue. Guarded on an empty queue: if the stream path ever ran, its
+ // frames are still pending and jumping them would break arrival order.
+ if (INFLATE_RAW_SYNC !== undefined && !forceStreamDecompressForTests && queued === 0) {
+ try {
+ listener(decompressSync(data));
+ } catch (error) {
+ console.error(DELIVERY_FAILED, error);
+ }
+ return;
+ }
+
// `deliver` never rejects, so a failing event cannot poison the chain. Chaining a rejected
// promise would skip every subsequent `.then` callback, silently dropping all later updates
// for the life of the subscription.
- queue = queue.then(() => deliver(e.detail, listener));
+ queued++;
+ queue = queue.then(() => deliver(data, listener)).then(released);
},
options,
);
}
+/** Logged when one frame cannot be delivered; the subscription continues with the next. */
+const DELIVERY_FAILED = "fastAssetCtxs: failed to deliver an event, continuing with the next one:";
+
/**
* Decompresses one frame and hands it to the listener, absorbing any failure.
*
@@ -108,33 +133,202 @@ async function deliver(data: string, listener: (data: FastAssetCtxsEvent) => voi
try {
listener(await decompress(data));
} catch (error) {
- console.error("fastAssetCtxs: failed to deliver an event, continuing with the next one:", error);
+ console.error(DELIVERY_FAILED, error);
}
}
+// --- Decompress hot path ----------------------------------------------------
+
+/** Reused across frames so UTF-8 decode allocates no decoder state. */
+const TEXT_DECODER = new TextDecoder();
+
+/**
+ * Standard base64 alphabet lookup: index is the char code, value is the 6-bit sextet (or 255 for
+ * padding / invalid). Built once so decoding never walks a string table per character.
+ */
+const BASE64_LUT = /* @__PURE__ */ (() => {
+ const table = new Uint8Array(128).fill(255);
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ for (let i = 0; i < alphabet.length; i++) table[alphabet.charCodeAt(i)] = i;
+ table["=".charCodeAt(0)] = 0; // padding contributes zero bits; length logic drops them
+ return table;
+})();
+
+/**
+ * Grows to the largest decoded base64 body seen and is retained between frames, so steady-state
+ * base64 decode allocates nothing beyond the inflated output.
+ */
+let base64Scratch: Uint8Array = new Uint8Array(0);
+
+/**
+ * Grows to the largest inflated JSON body seen and is retained between frames, so multi-chunk
+ * stream merges allocate nothing in steady state.
+ */
+let inflateScratch: Uint8Array = new Uint8Array(0);
+
+/**
+ * Decode a standard base64 string into binary.
+ *
+ * Prefers the platform `Buffer` when available (Node, Bun — SIMD-backed and far faster than a
+ * per-character JS loop). Falls back to a table-driven decoder for environments without `Buffer`
+ * (e.g. some browser / RN builds), writing into the module-level scratch buffer.
+ *
+ * Invalid input throws, matching `atob`: `Buffer.from(..., "base64")` is lenient and would
+ * otherwise feed garbage into the inflater, which rejects asynchronously and can surface as an
+ * unhandled rejection on the write side of the stream.
+ */
+/**
+ * Bytes a well-formed standard-base64 string of this length decodes to: each 4 characters carry 3
+ * bytes, and each trailing `=` drops one. `Buffer`'s decoder silently skips anything outside the
+ * alphabet, so comparing its output length against this is an O(1) validity check — it catches
+ * exactly the inputs a full-string alphabet scan would, without walking the string a second time.
+ */
+function expectedBase64Bytes(data: string): number {
+ let padding = 0;
+ if (data.charCodeAt(data.length - 1) === 0x3d) padding++;
+ if (data.charCodeAt(data.length - 2) === 0x3d) padding++;
+ return (data.length >> 2) * 3 - padding;
+}
+
+function decodeBase64(data: string): Uint8Array {
+ // Length must be a non-zero multiple of 4 (standard base64 with padding).
+ if (data.length === 0 || data.length % 4 !== 0) {
+ throw new Error("Invalid base64");
+ }
+
+ // `Buffer` is a global on Node and Bun; avoid a bare identifier so browser/RN type-check stays clean.
+ const Buf = (globalThis as { Buffer?: { from(data: string, enc: string): Uint8Array } }).Buffer;
+ if (Buf !== undefined) {
+ // Reject non-alphabet input before the lenient Buffer decoder can accept it.
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data)) {
+ throw new Error("Invalid base64");
+ }
+ // Own the bytes in a fresh `Uint8Array` so the result is `ArrayBuffer`-backed (not
+ // `ArrayBufferLike` / SharedArrayBuffer) and is accepted by `DecompressionStream.write`
+ // without a cast. The copy is dwarfed by inflate; Buffer's base64 decode is still far faster
+ // than a JS loop.
+ const buf = Buf.from(data, "base64");
+ const out: Uint8Array = new Uint8Array(buf.byteLength);
+ out.set(buf);
+ return out;
+ }
+
+ // Table path (no Buffer): alphabet is validated via the LUT so invalid characters throw here.
+ // Length without padding: each 4 chars → 3 bytes; trailing `=` reduce the last group.
+ let padding = 0;
+ if (data.charCodeAt(data.length - 1) === 0x3d) padding++;
+ if (data.charCodeAt(data.length - 2) === 0x3d) padding++;
+ const outLen = (data.length >> 2) * 3 - padding;
+ if (base64Scratch.length < outLen) base64Scratch = new Uint8Array(outLen);
+ const out = base64Scratch;
+ const lut = BASE64_LUT;
+
+ let o = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ const a = lut[data.charCodeAt(i)!] ?? 255;
+ const b = lut[data.charCodeAt(i + 1)!] ?? 255;
+ const c = lut[data.charCodeAt(i + 2)!] ?? 255;
+ const d = lut[data.charCodeAt(i + 3)!] ?? 255;
+ // Codes outside ASCII or outside the alphabet leave 255 in the LUT (and non-ASCII
+ // `charCodeAt` yields `undefined` → 255 via `??` above).
+ if (a === 255 || b === 255 || c === 255 || d === 255) throw new Error("Invalid base64");
+ const triple = (a << 18) | (b << 12) | (c << 6) | d;
+ if (o < outLen) out[o++] = (triple >> 16) & 0xff;
+ if (o < outLen) out[o++] = (triple >> 8) & 0xff;
+ if (o < outLen) out[o++] = triple & 0xff;
+ }
+ // subarray keeps the ArrayBuffer generic parameter.
+ return out.subarray(0, outLen);
+}
+
+/**
+ * Native synchronous raw-inflate, when the runtime has one.
+ *
+ * `node:zlib` is reachable through `process.getBuiltinModule` on Node >= 22.3 and Bun. Using it
+ * collapses the whole per-frame stream pipeline — a `DecompressionStream`, a writer, a reader and
+ * four-plus promises — into one native call, which also removes the cross-task window that made a
+ * frame's payload observable to a later frame. Browser / RN builds have no `process`, so they keep
+ * the `DecompressionStream` path below. Resolved once at module load: the answer cannot change.
+ */
+const INFLATE_RAW_SYNC: ((data: Uint8Array) => Uint8Array) | undefined = /* @__PURE__ */ (() => {
+ const proc = (globalThis as { process?: { getBuiltinModule?: (id: string) => unknown } }).process;
+ const zlib = proc?.getBuiltinModule?.("node:zlib") as { inflateRawSync?: (d: Uint8Array) => Uint8Array } | undefined;
+ return typeof zlib?.inflateRawSync === "function" ? zlib.inflateRawSync.bind(zlib) : undefined;
+})();
+
+/**
+ * When `true`, {@linkcode decompress} skips the native sync inflater and uses `DecompressionStream`.
+ * Package-internal test hook so Bun/Node coverage still exercises the browser stream path.
+ */
+let forceStreamDecompressForTests = false;
+
+/** Package-internal: force the `DecompressionStream` path for the next decompress calls. */
+export function _setForceStreamDecompressForTests(force: boolean): void {
+ forceStreamDecompressForTests = force;
+}
+
+/**
+ * Native decode of a base64 + raw DEFLATE payload. Only valid when {@linkcode INFLATE_RAW_SYNC} is
+ * available; callers check that first.
+ *
+ * The pooled `Buffer` feeds the inflater directly — it is fully consumed before this returns, so
+ * the defensive copy `decodeBase64` makes for `DecompressionStream.write`'s typing buys nothing
+ * here, and the decoded length stands in for that path's full-string alphabet scan.
+ */
+function decompressSync(data: string): FastAssetCtxsEvent {
+ const inflate = INFLATE_RAW_SYNC as (data: Uint8Array) => Uint8Array;
+ const Buf = (globalThis as { Buffer?: { from(data: string, enc: string): Uint8Array } }).Buffer;
+ if (Buf === undefined) {
+ // No `Buffer` to decode through: fall back to the table decoder, but keep the sync inflater.
+ return JSON.parse(TEXT_DECODER.decode(inflate(decodeBase64(data))));
+ }
+ if (data.length === 0 || data.length % 4 !== 0) throw new Error("Invalid base64");
+ const pooled = Buf.from(data, "base64");
+ if (pooled.byteLength !== expectedBase64Bytes(data)) throw new Error("Invalid base64");
+ return JSON.parse(TEXT_DECODER.decode(inflate(pooled)));
+}
+
/** Decode a base64 + raw DEFLATE (RFC 1951) payload into a {@linkcode FastAssetCtxsEvent}. */
async function decompress(data: string): Promise {
- const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
+ // Native path (Node / Bun): one synchronous call, no stream objects, no async hops. The pooled
+ // `Buffer` feeds the inflater directly — it is fully consumed before this returns, so the
+ // defensive copy `decodeBase64` makes for `DecompressionStream.write`'s typing buys nothing here,
+ // and the decoded length stands in for that path's full-string alphabet scan.
+ if (INFLATE_RAW_SYNC !== undefined && !forceStreamDecompressForTests) return decompressSync(data);
+ const bytes = decodeBase64(data);
const stream = new DecompressionStream("deflate-raw");
const writer = stream.writable.getWriter();
// Do not await write/close before draining: backpressure on multi-chunk output would deadlock.
- writer.write(bytes);
- writer.close();
+ // Absorb write-side rejections so a corrupt payload that fails inflate cannot surface as an
+ // unhandled rejection after the reader has already thrown into the caller's catch.
+ const writeSide = writer.write(bytes).then(() => writer.close());
+ writeSide.catch(() => {});
const reader = stream.readable.getReader();
- const chunks: Uint8Array[] = [];
+ // Fast path: the common small update fits in one stream chunk — no merge, no scratch grow.
+ const first = await reader.read();
+ if (first.done) return JSON.parse(TEXT_DECODER.decode(new Uint8Array(0)));
+ const second = await reader.read();
+ if (second.done) {
+ // Single chunk: decode in place. `first.value` is owned by the stream and is not retained.
+ return JSON.parse(TEXT_DECODER.decode(first.value));
+ }
+
+ // Multi-chunk: merge into the retained scratch, growing only when a frame exceeds the previous max.
+ let total = first.value.length + second.value.length;
+ const chunks: Uint8Array[] = [first.value, second.value];
let result = await reader.read();
while (!result.done) {
chunks.push(result.value);
+ total += result.value.length;
result = await reader.read();
}
-
- const merged = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
+ if (inflateScratch.length < total) inflateScratch = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
- merged.set(chunk, offset);
+ inflateScratch.set(chunk, offset);
offset += chunk.length;
}
- return JSON.parse(new TextDecoder().decode(merged));
+ return JSON.parse(TEXT_DECODER.decode(inflateScratch.subarray(0, total)));
}
diff --git a/src/api/subscription/_methods/l2Book.ts b/src/api/subscription/_methods/l2Book.ts
index 1f10f73b..96473e17 100644
--- a/src/api/subscription/_methods/l2Book.ts
+++ b/src/api/subscription/_methods/l2Book.ts
@@ -122,11 +122,8 @@ export function l2Book(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.coin === payload.coin) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this coin's frames (`l2Book\0{coin}`); no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/openOrders.ts b/src/api/subscription/_methods/openOrders.ts
index c7e476b3..63324bd9 100644
--- a/src/api/subscription/_methods/openOrders.ts
+++ b/src/api/subscription/_methods/openOrders.ts
@@ -92,10 +92,9 @@ export function openOrders(
return config.transport.subscribe(
payload.type,
payload,
+ // Routing keys only by user; dex still needs a local filter.
(e) => {
- if (e.detail.user === payload.user && e.detail.dex === payload.dex) {
- listener(e.detail);
- }
+ if (e.detail.dex === payload.dex) listener(e.detail);
},
options,
);
diff --git a/src/api/subscription/_methods/spotState.ts b/src/api/subscription/_methods/spotState.ts
index eede6d08..382f64c2 100644
--- a/src/api/subscription/_methods/spotState.ts
+++ b/src/api/subscription/_methods/spotState.ts
@@ -86,11 +86,8 @@ export function spotState(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/trades.ts b/src/api/subscription/_methods/trades.ts
index 28c7b0ca..05bb2660 100644
--- a/src/api/subscription/_methods/trades.ts
+++ b/src/api/subscription/_methods/trades.ts
@@ -75,11 +75,8 @@ export function trades(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail[0]?.coin === payload.coin) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this coin's frames (`trades\0{coin}`); no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/twapStates.ts b/src/api/subscription/_methods/twapStates.ts
index 73de582a..68749fac 100644
--- a/src/api/subscription/_methods/twapStates.ts
+++ b/src/api/subscription/_methods/twapStates.ts
@@ -97,10 +97,9 @@ export function twapStates(
return config.transport.subscribe(
payload.type,
payload,
+ // Routing keys only by user; dex still needs a local filter.
(e) => {
- if (e.detail.user === payload.user && e.detail.dex === payload.dex) {
- listener(e.detail);
- }
+ if (e.detail.dex === payload.dex) listener(e.detail);
},
options,
);
diff --git a/src/api/subscription/_methods/userFills.ts b/src/api/subscription/_methods/userFills.ts
index ca564e0d..b4f4d20f 100644
--- a/src/api/subscription/_methods/userFills.ts
+++ b/src/api/subscription/_methods/userFills.ts
@@ -92,11 +92,8 @@ export function userFills(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames (case-folded address key); no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/userFundings.ts b/src/api/subscription/_methods/userFundings.ts
index 05279d19..702f8e63 100644
--- a/src/api/subscription/_methods/userFundings.ts
+++ b/src/api/subscription/_methods/userFundings.ts
@@ -107,11 +107,8 @@ export function userFundings(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/userHistoricalOrders.ts b/src/api/subscription/_methods/userHistoricalOrders.ts
index 9e480567..6b130bb4 100644
--- a/src/api/subscription/_methods/userHistoricalOrders.ts
+++ b/src/api/subscription/_methods/userHistoricalOrders.ts
@@ -86,11 +86,8 @@ export function userHistoricalOrders(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/userNonFundingLedgerUpdates.ts b/src/api/subscription/_methods/userNonFundingLedgerUpdates.ts
index e0715b03..c5845a18 100644
--- a/src/api/subscription/_methods/userNonFundingLedgerUpdates.ts
+++ b/src/api/subscription/_methods/userNonFundingLedgerUpdates.ts
@@ -89,11 +89,8 @@ export function userNonFundingLedgerUpdates(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/userTwapHistory.ts b/src/api/subscription/_methods/userTwapHistory.ts
index 1491e4e3..4efde32c 100644
--- a/src/api/subscription/_methods/userTwapHistory.ts
+++ b/src/api/subscription/_methods/userTwapHistory.ts
@@ -86,11 +86,8 @@ export function userTwapHistory(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/userTwapSliceFills.ts b/src/api/subscription/_methods/userTwapSliceFills.ts
index 34387a0a..7b1b70ba 100644
--- a/src/api/subscription/_methods/userTwapSliceFills.ts
+++ b/src/api/subscription/_methods/userTwapSliceFills.ts
@@ -86,11 +86,8 @@ export function userTwapSliceFills(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames; no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/api/subscription/_methods/webData3.ts b/src/api/subscription/_methods/webData3.ts
index ffd38e98..af0701b4 100644
--- a/src/api/subscription/_methods/webData3.ts
+++ b/src/api/subscription/_methods/webData3.ts
@@ -54,7 +54,14 @@ export type WebData3Event = {
optOutOfSpotDusting?: true;
/** Whether DEX abstraction is enabled. */
dexAbstractionEnabled?: boolean;
- /** Abstraction mode for the user account. */
+ /**
+ * Abstraction mode for the user account.
+ *
+ * Omitted by the server when the account is in the default state: the REST
+ * `userAbstraction` request reports `"default"` for exactly the accounts whose
+ * `webData3` frames lack this field, and matches it value-for-value otherwise
+ * (verified live against mainnet — `.dev/verify_webdata3_abstraction.ts`).
+ */
abstraction?: "unifiedAccount" | "portfolioMargin" | "disabled" | "dexAbstraction";
};
/** Array of perpetual DEX states. */
@@ -120,11 +127,8 @@ export function webData3(
return config.transport.subscribe(
payload.type,
payload,
- (e) => {
- if (e.detail.userState.user === payload.user) {
- listener(e.detail);
- }
- },
+ // Routing delivers only this user's frames (`userState.user` key); no post-filter needed.
+ (e) => listener(e.detail),
options,
);
}
diff --git a/src/signing/_fastDigest.ts b/src/signing/_fastDigest.ts
index 452d51c1..2909b7d5 100644
--- a/src/signing/_fastDigest.ts
+++ b/src/signing/_fastDigest.ts
@@ -70,6 +70,18 @@ export function createL1AgentDigest(actionHash: `0x${string}`, isTestnet: boolea
return `0x${bytesToHex(createL1AgentDigestBytes(hexToBytes(actionHash.slice(2)), isTestnet))}`;
}
+/**
+ * Reused struct/digest preimage buffers for {@linkcode createL1AgentDigestBytes}. Safe because the
+ * function is synchronous; {@linkcode AGENT_SCRATCH_BUSY} covers the one way it can still overlap
+ * with itself (a getter on a caller-supplied array buffer is impossible here — the inputs are
+ * plain `Uint8Array`s — but a re-entrant call through a mocked `keccak256` is not).
+ */
+const AGENT_STRUCT = new Uint8Array(32 * 3);
+const AGENT_DIGEST = new Uint8Array(2 + 32 + 32);
+AGENT_DIGEST[0] = 0x19;
+AGENT_DIGEST[1] = 0x01;
+let AGENT_SCRATCH_BUSY = false;
+
/**
* Bytes-level variant of {@linkcode createL1AgentDigest}: takes the action hash and returns the
* digest as `Uint8Array`, so the L1 signing path passes bytes end-to-end instead of round-tripping
@@ -80,18 +92,24 @@ export function createL1AgentDigest(actionHash: `0x${string}`, isTestnet: boolea
* @return The 32-byte digest, byte-identical to viem's `hashTypedData`.
*/
export function createL1AgentDigestBytes(actionHash: Uint8Array, isTestnet: boolean): Uint8Array {
- const struct = new Uint8Array(32 * 3);
- struct.set(AGENT_TYPEHASH, 0);
- struct.set(isTestnet ? SOURCE_HASH_TESTNET : SOURCE_HASH_MAINNET, 32);
- struct.set(actionHash, 64);
- const structHash = keccak256(struct);
-
- const digest = new Uint8Array(2 + 32 + 32);
- digest[0] = 0x19;
- digest[1] = 0x01;
- digest.set(L1_DOMAIN_SEPARATOR, 2);
- digest.set(structHash, 34);
- return keccak256(digest);
+ const nested = AGENT_SCRATCH_BUSY;
+ const struct = nested ? new Uint8Array(32 * 3) : AGENT_STRUCT;
+ const digest = nested ? new Uint8Array(2 + 32 + 32) : AGENT_DIGEST;
+ AGENT_SCRATCH_BUSY = true;
+ try {
+ struct.set(AGENT_TYPEHASH, 0);
+ struct.set(isTestnet ? SOURCE_HASH_TESTNET : SOURCE_HASH_MAINNET, 32);
+ struct.set(actionHash, 64);
+ const structHash = keccak256(struct);
+
+ digest[0] = 0x19;
+ digest[1] = 0x01;
+ digest.set(L1_DOMAIN_SEPARATOR, 2);
+ digest.set(structHash, 34);
+ return keccak256(digest);
+ } finally {
+ AGENT_SCRATCH_BUSY = nested;
+ }
}
// --- Multi-sig outer digest --------------------------------------------------
@@ -159,6 +177,17 @@ export function createMultiSigDigest(
return `0x${bytesToHex(createMultiSigDigestBytes(hexToBytes(multiSigActionHash.slice(2)), nonce, signatureChainId, isTestnet))}`;
}
+/**
+ * Reused struct/digest preimage buffers for {@linkcode createMultiSigDigestBytes}. Same reentrancy
+ * contract as {@linkcode AGENT_STRUCT}: the function is synchronous, and a nested call gets its own
+ * buffers so two digests never share storage mid-write.
+ */
+const MULTI_SIG_STRUCT = new Uint8Array(32 * 4);
+const MULTI_SIG_DIGEST = new Uint8Array(2 + 32 + 32);
+MULTI_SIG_DIGEST[0] = 0x19;
+MULTI_SIG_DIGEST[1] = 0x01;
+let MULTI_SIG_SCRATCH_BUSY = false;
+
/**
* Bytes-level variant of {@linkcode createMultiSigDigest}: takes the wrapper hash and returns the
* digest as `Uint8Array`, so the multi-sig signing path passes bytes end-to-end instead of
@@ -176,22 +205,30 @@ export function createMultiSigDigestBytes(
signatureChainId: `0x${string}`,
isTestnet: boolean,
): Uint8Array {
- const struct = new Uint8Array(32 * 4);
- struct.set(SEND_MULTI_SIG_TYPEHASH, 0);
- struct.set(isTestnet ? HYPERLIQUID_CHAIN_HASH_TESTNET : HYPERLIQUID_CHAIN_HASH_MAINNET, 32);
- struct.set(multiSigActionHash, 64);
- // uint64(nonce) zero-padded to a 32-byte word, big-endian
- for (let i = 96 + 31, remaining = nonce; remaining > 0; i--, remaining = Math.floor(remaining / 256)) {
- struct[i] = remaining % 256;
+ const nested = MULTI_SIG_SCRATCH_BUSY;
+ const struct = nested ? new Uint8Array(32 * 4) : MULTI_SIG_STRUCT;
+ const digest = nested ? new Uint8Array(2 + 32 + 32) : MULTI_SIG_DIGEST;
+ MULTI_SIG_SCRATCH_BUSY = true;
+ try {
+ // Clear the nonce word: a previous call may have left high bytes set for a larger nonce.
+ struct.fill(0, 96, 128);
+ struct.set(SEND_MULTI_SIG_TYPEHASH, 0);
+ struct.set(isTestnet ? HYPERLIQUID_CHAIN_HASH_TESTNET : HYPERLIQUID_CHAIN_HASH_MAINNET, 32);
+ struct.set(multiSigActionHash, 64);
+ // uint64(nonce) zero-padded to a 32-byte word, big-endian
+ for (let i = 96 + 31, remaining = nonce; remaining > 0; i--, remaining = Math.floor(remaining / 256)) {
+ struct[i] = remaining % 256;
+ }
+ const structHash = keccak256(struct);
+
+ digest[0] = 0x19;
+ digest[1] = 0x01;
+ // `signatureChainId` is a `0x`-prefixed hex string, so radix 16 is the only correct base here:
+ // radix 10 would parse it as `0` and silently sign under the wrong EIP-712 domain.
+ digest.set(multiSigDomainSeparator(parseInt(signatureChainId, 16)), 2);
+ digest.set(structHash, 34);
+ return keccak256(digest);
+ } finally {
+ MULTI_SIG_SCRATCH_BUSY = nested;
}
- const structHash = keccak256(struct);
-
- const digest = new Uint8Array(2 + 32 + 32);
- digest[0] = 0x19;
- digest[1] = 0x01;
- // `signatureChainId` is a `0x`-prefixed hex string, so radix 16 is the only correct base here:
- // radix 10 would parse it as `0` and silently sign under the wrong EIP-712 domain.
- digest.set(multiSigDomainSeparator(parseInt(signatureChainId, 16)), 2);
- digest.set(structHash, 34);
- return keccak256(digest);
}
diff --git a/src/signing/_l1.ts b/src/signing/_l1.ts
index dd1bbbb6..1466b134 100644
--- a/src/signing/_l1.ts
+++ b/src/signing/_l1.ts
@@ -10,6 +10,49 @@ import { keccak256 } from "./_keccak.ts";
import { Adjusted, type L1Value, type MsgpackValue, MsgpackWriter } from "./_msgpack.ts";
import { trimSignature } from "./_multiSig.ts";
+/**
+ * Scratch for decoding a 20-byte vault/sub-account address into the L1 hash preimage without a
+ * per-call `hexToBytes` allocation. Safe: {@linkcode createL1ActionHashBytes} is synchronous and
+ * the bytes are copied into the msgpack buffer before the next call.
+ */
+const VAULT_ADDR_BYTES = new Uint8Array(20);
+
+/** Decode one ASCII hex nibble; assumes the caller already validated the address shape. */
+function hexNibble(code: number): number {
+ // '0'-'9' → 0-9; 'a'-'f' / 'A'-'F' → 10-15
+ return code < 58 ? code - 48 : (code | 32) - 87;
+}
+
+/** Whether `code` is an ASCII hex digit. */
+function isHexCode(code: number): boolean {
+ return (code >= 48 && code <= 57) || (code >= 97 && code <= 102) || (code >= 65 && code <= 70);
+}
+
+/**
+ * Decode a `0x`-prefixed 20-byte address into {@linkcode VAULT_ADDR_BYTES} and return that scratch.
+ *
+ * The shape is checked here rather than trusted from the schema: `createL1ActionHash` is public and
+ * takes `vaultAddress` straight from the caller, and a `0x${string}` template type constrains
+ * neither length nor charset at runtime (and constrains nothing at all for JavaScript callers).
+ * Without this, a malformed address would silently hash to a different action than the caller
+ * described — and a 32-byte address would hash identically to its 20-byte truncation. The scan is
+ * 42 character compares against a keccak, so it does not register on the signing path.
+ */
+function decodeAddressIntoScratch(address: `0x${string}`): Uint8Array {
+ if (address.length !== 42 || address.charCodeAt(0) !== 48 || (address.charCodeAt(1) | 32) !== 120) {
+ throw new Error("Invalid vault address: expected a 0x-prefixed 20-byte hex string");
+ }
+ for (let i = 0; i < 20; i++) {
+ const hiCode = address.charCodeAt(2 + i * 2);
+ const loCode = address.charCodeAt(3 + i * 2);
+ if (!isHexCode(hiCode) || !isHexCode(loCode)) {
+ throw new Error("Invalid vault address: expected a 0x-prefixed 20-byte hex string");
+ }
+ VAULT_ADDR_BYTES[i] = (hexNibble(hiCode) << 4) | hexNibble(loCode);
+ }
+ return VAULT_ADDR_BYTES;
+}
+
/**
* Input shape of {@linkcode adjust}. Mirrors {@linkcode MsgpackValue}, except that the `Uint8Array` arm is
* intersected with a string index signature so `adjust`'s rebuild of exotic objects type-checks
@@ -116,7 +159,8 @@ export function createL1ActionHashBytes(args: {
if (vaultAddress) {
writer.byte(1);
- writer.raw(hexToBytes(vaultAddress.slice(2)));
+ // Inline hex decode into a retained scratch — avoids a 20-byte alloc per vault order.
+ writer.raw(decodeAddressIntoScratch(vaultAddress));
} else {
writer.byte(0);
}
diff --git a/src/signing/_msgpack.ts b/src/signing/_msgpack.ts
index 1ff86a21..42f96661 100644
--- a/src/signing/_msgpack.ts
+++ b/src/signing/_msgpack.ts
@@ -47,7 +47,11 @@ export type MsgpackValue =
* Only meaningful inside L1 action-hash preimages — never place it in a wire payload.
*/
export class Adjusted {
- constructor(/** The adjusted subtree. */ readonly value: MsgpackValue) {}
+ readonly value: MsgpackValue;
+ constructor(/** The adjusted subtree. */ value: MsgpackValue) {
+ // Marker only — the wrapped subtree is already L1-normalized.
+ this.value = value;
+ }
}
/**
@@ -89,9 +93,15 @@ const BIGINT_UINT64_MAX = 2n ** 64n;
*/
export class MsgpackWriter {
/** 1 KiB covers a single-order action without a grow; batches double up from there. */
- private buffer: Uint8Array = new Uint8Array(1024);
- private dataView: DataView = new DataView(this.buffer.buffer);
- private offset = 0;
+ private buffer: Uint8Array;
+ private dataView: DataView;
+ private offset: number;
+
+ constructor() {
+ this.buffer = new Uint8Array(1024);
+ this.dataView = new DataView(this.buffer.buffer);
+ this.offset = 0;
+ }
/** Rewinds to an empty payload, retaining the allocated storage. */
reset(): void {
@@ -368,29 +378,50 @@ export class MsgpackWriter {
* the UTF-8 length equals `.length` and the bytes are the char codes — so the header can be written before
* the body with no `TextEncoder` round trip. Anything else defers to `TextEncoder` rather than hand-rolling
* UTF-8, which also keeps lone-surrogate replacement identical to the reference implementation.
+ *
+ * The ASCII path is a single pass: header bytes are reserved, the body is written while scanning, and on
+ * the first non-ASCII code unit the write is rewound and the `TextEncoder` path takes over. A separate
+ * "is this ASCII?" scan before the write used to walk every character twice on the hot path.
*/
private string(value: string): void {
- let ascii = true;
- for (let i = 0; i < value.length; i++) {
- if (value.charCodeAt(i) > 0x7f) {
- ascii = false;
- break;
- }
+ const length = value.length;
+ if (length >= 4294967296) {
+ throw new Error("Cannot safely encode string with size larger than 32 bits");
}
-
- if (!ascii) {
- const bytes = TEXT_ENCODER.encode(value);
- this.stringHeader(bytes.length);
- this.raw(bytes);
- return;
+ // Header sizes match {@linkcode stringHeader}: fixstr / str8 / str16 / str32.
+ const headerSize = length < 32 ? 1 : length < 256 ? 2 : length < 65536 ? 3 : 5;
+ this.ensure(headerSize + length);
+ const start = this.offset;
+ const bodyAt = start + headerSize;
+
+ for (let i = 0; i < length; i++) {
+ const c = value.charCodeAt(i);
+ if (c > 0x7f) {
+ // Non-ASCII: discard the reserved slot and encode via TextEncoder (handles multi-byte UTF-8
+ // and lone-surrogate replacement identically to the reference implementation).
+ this.offset = start;
+ const bytes = TEXT_ENCODER.encode(value);
+ this.stringHeader(bytes.length);
+ this.raw(bytes);
+ return;
+ }
+ this.buffer[bodyAt + i] = c;
}
- this.stringHeader(value.length);
- this.ensure(value.length);
- for (let i = 0; i < value.length; i++) {
- this.buffer[this.offset + i] = value.charCodeAt(i);
+ // All ASCII: backpatch the header and commit the body.
+ if (headerSize === 1) {
+ this.buffer[start] = 0xa0 | length;
+ } else if (headerSize === 2) {
+ this.buffer[start] = 0xd9;
+ this.buffer[start + 1] = length;
+ } else if (headerSize === 3) {
+ this.buffer[start] = 0xda;
+ this.dataView.setUint16(start + 1, length);
+ } else {
+ this.buffer[start] = 0xdb;
+ this.dataView.setUint32(start + 1, length);
}
- this.offset += value.length;
+ this.offset = bodyAt + length;
}
private stringHeader(length: number): void {
diff --git a/src/transport/_abort.ts b/src/transport/_abort.ts
index 9303ecc7..561a74f9 100644
--- a/src/transport/_abort.ts
+++ b/src/transport/_abort.ts
@@ -6,7 +6,10 @@
import { DOMException_, Promise_ } from "./_polyfills.ts";
/** Shared detach function for relays that need no cleanup. */
-function noop(): void {}
+function noop(): void {
+ // Intentionally empty; a statement keeps coverage tooling from treating the call as unhit.
+ return;
+}
/** Aborts `target` with a `TimeoutError` after `ms`; `cancel` clears the timer, `reason` identifies the abort. */
export function scheduleTimeout(target: AbortController, ms: number | null): { reason: Error; cancel: () => void } {
@@ -66,11 +69,17 @@ interface TimeoutEntry {
*/
export class TimeoutWheel {
/** Live and spent entries, sorted by deadline ascending; equal deadlines keep insertion order. */
- private readonly _entries: TimeoutEntry[] = [];
+ private readonly _entries: TimeoutEntry[];
/** The single armed native timer, or `undefined` when nothing is armed. */
private _timer: ReturnType | undefined;
/** The deadline {@linkcode _timer} is armed for; never later than the head entry's deadline. */
- private _timerDeadline = 0;
+ private _timerDeadline: number;
+
+ constructor() {
+ this._entries = [];
+ this._timer = undefined;
+ this._timerDeadline = 0;
+ }
/**
* Arms a timeout for `target`, returning the same handle shape as {@linkcode scheduleTimeout}:
diff --git a/src/transport/_polyfills.ts b/src/transport/_polyfills.ts
index 4a733cfe..79a8eb4f 100644
--- a/src/transport/_polyfills.ts
+++ b/src/transport/_polyfills.ts
@@ -5,20 +5,31 @@
* @module
*/
+/** Fallback for {@link Promise.withResolvers} on platforms that lack it. */
+function withResolversFallback(): {
+ promise: Promise;
+ resolve: (value: T | PromiseLike) => void;
+ reject: (reason?: any) => void;
+} {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: any) => void;
+ const promise = new Promise((res, rej) => ((resolve = res), (reject = rej)));
+ return { promise, resolve, reject };
+}
+
/** @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise */
-export const Promise_ = /* @__PURE__ */ (() => {
- return {
- /** @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers */
- withResolvers: Promise.withResolvers
- ? () => Promise.withResolvers()
- : () => {
- let resolve!: (value: T | PromiseLike) => void;
- let reject!: (reason?: any) => void;
- const promise = new Promise((res, rej) => ((resolve = res), (reject = rej)));
- return { promise, resolve, reject };
- },
- };
-})();
+export const Promise_ = {
+ /** @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers */
+ withResolvers: (): {
+ promise: Promise;
+ resolve: (value: T | PromiseLike) => void;
+ reject: (reason?: any) => void;
+ } => {
+ // Call-time dispatch so deleting `Promise.withResolvers` in tests exercises the fallback
+ // on the same module instance.
+ return typeof Promise.withResolvers === "function" ? Promise.withResolvers() : withResolversFallback();
+ },
+};
/** @see https://developer.mozilla.org/en-US/docs/Web/API/DOMException */
export const DOMException_ = /* @__PURE__ */ (() => {
@@ -43,7 +54,10 @@ export const CustomEvent_ = /* @__PURE__ */ (() => {
super(type, eventInitDict);
this.detail = eventInitDict?.detail ?? null;
}
- initCustomEvent(): void {}
+ initCustomEvent(): void {
+ // Deprecated DOM API stub; kept for interface parity with native CustomEvent.
+ return;
+ }
}
);
})();
diff --git a/src/transport/http/mod.ts b/src/transport/http/mod.ts
index ba9cbb79..cb0f2a49 100644
--- a/src/transport/http/mod.ts
+++ b/src/transport/http/mod.ts
@@ -266,9 +266,9 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e
/** Opt-in token-bucket rate limiter; `null` keeps requests unthrottled (the default). */
private readonly _rateLimit: TokenBucketRateLimiter | null;
/** Shared request-timeout scheduler: at most one armed native timer, however many requests are in flight. */
- private readonly _timeouts = new abort.TimeoutWheel();
+ private readonly _timeouts: abort.TimeoutWheel;
/** Memoized endpoint URLs, keyed by base and endpoint; mutating `apiUrl`/`rpcUrl` simply misses the cache. */
- private readonly _urlCache = new Map();
+ private readonly _urlCache: Map;
constructor(options?: HttpTransportOptions) {
this.isTestnet = options?.isTestnet ?? false;
@@ -281,6 +281,8 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e
options?.rateLimit === undefined
? null
: new TokenBucketRateLimiter(options.rateLimit.capacity ?? 1200, options.rateLimit.refillPerMinute ?? 1200);
+ this._timeouts = new abort.TimeoutWheel();
+ this._urlCache = new Map();
}
/**
@@ -736,7 +738,10 @@ function recreateResponse(original: Response, text: string): Response {
}
/** Shared no-op used when no abort relay is needed. */
-function noop(): void {}
+function noop(): void {
+ // Intentionally empty; a statement keeps coverage tooling from treating the call as unhit.
+ return;
+}
/** True when `init` has no own enumerable properties, i.e. merging it would change nothing. */
function isEmptyRequestInit(init: RequestInit): boolean {
diff --git a/src/transport/websocket/_dispatcher.ts b/src/transport/websocket/_dispatcher.ts
index 3f6bf0b1..e1a56403 100644
--- a/src/transport/websocket/_dispatcher.ts
+++ b/src/transport/websocket/_dispatcher.ts
@@ -249,6 +249,11 @@ export class WebSocketDispatcher {
// and its id plain concatenation — no re-normalize of the subscription subtree.
id = `{"method":"${request.method}","subscription":${hint.subscriptionId}}`;
echo = echoData({ method: request.method, subscription: payload });
+ // That concatenation is character-for-character what `JSON.stringify(request)` produces:
+ // the envelope is `{method, subscription}` in that order and the hint's id is the
+ // stringified snapshot `payload` points at. Reusing it skips a second serialization of the
+ // whole subscription subtree on every subscribe, unsubscribe and reconnect resubscribe.
+ frame = id;
} else {
const normalized = normalize(request);
id = JSON.stringify(normalized);
@@ -256,7 +261,7 @@ export class WebSocketDispatcher {
}
// --- Send or queue -----------------------------------------------------
- frame = JSON.stringify(request);
+ frame ??= JSON.stringify(request);
const sent = this._socket.readyState === ReconnectingWebSocket.OPEN;
if (sent) this._socket.send(frame);
diff --git a/src/transport/websocket/_events.ts b/src/transport/websocket/_events.ts
index 72b3cd29..196d4a4f 100644
--- a/src/transport/websocket/_events.ts
+++ b/src/transport/websocket/_events.ts
@@ -7,7 +7,6 @@
* @module
*/
-import { CustomEvent_ } from "../_polyfills.ts";
import { frameEventType, isBareChannel } from "./_routing.ts";
/**
@@ -149,6 +148,23 @@ export interface HyperliquidEventTarget {
): void;
}
+/**
+ * Lightweight event shell handed to listeners. Only `type` and `detail` are populated — every
+ * Hyperliquid consumer in this package (and every public subscription listener) reads those two
+ * fields and nothing else. Reusing one shell per target avoids a `CustomEvent` allocation on every
+ * frame; listeners must not retain the object across turns (treat it as valid only during the call).
+ */
+interface EventShell {
+ type: string;
+ detail: unknown;
+}
+
+/**
+ * Listeners registered for one event type: the listener itself while there is exactly one, a
+ * copy-on-write array once there are more.
+ */
+type Listeners = EventListenerOrEventListenerObject | EventListenerOrEventListenerObject[];
+
/**
* Re-dispatches every frame as a typed event.
*
@@ -156,96 +172,231 @@ export interface HyperliquidEventTarget {
* only runs for the frames it asked for; it additionally goes out on the bare channel whenever
* something is listening there, which keeps broadcast semantics for unroutable frames, unrouted
* channels and unkeyed subscriptions.
+ *
+ * Dispatch is implemented with a direct listener map rather than `EventTarget.dispatchEvent`: a
+ * `CustomEvent` per frame was the dominant cost of multi-subscription fan-out after routing already
+ * cut invocations to one. Listeners still receive an object with `type` and `detail` (the only
+ * fields they read); throws are swallowed per listener so one bad callback cannot starve the rest
+ * or kill the process (Bun/Node raise uncaught listener errors, unlike the DOM).
*/
-export class HyperliquidEventTarget extends EventTarget {
+export class HyperliquidEventTarget {
/**
* Channels that have ever had a listener on their bare name, so a routed frame knows it still
* owes them a broadcast.
*
- * Entries are never removed: the set exists only to skip an event allocation nobody would
- * observe, so a stale entry costs one such dispatch, while a missing entry would silently drop
- * frames. Growth is bounded by the number of channels.
+ * Entries are never removed: the set exists only to skip a dispatch nobody would observe, so a
+ * stale entry costs one such dispatch, while a missing entry would silently drop frames. Growth
+ * is bounded by the number of channels.
*/
private readonly _bareChannels: Set = new Set();
/**
- * Guarded form of every registered listener, so a listener's synchronous throw cannot escape
- * a dispatch.
+ * Live listeners per event type. The original listener reference is stored so
+ * `removeEventListener` can match by identity the way `EventTarget` does.
*
- * The DOM spec reports a listener exception and continues dispatching; Bun and Node instead
- * raise it as an uncaught error, killing the process and skipping the remaining listeners.
- * One malformed frame reaching a frame-parsing consumer (or one throwing listener) would
- * otherwise take down every subscription on the socket, so each listener runs behind a guard
- * that swallows the throw — the frame is lost only to that listener.
+ * A lone listener is stored unboxed and only promoted to an array on the second registration —
+ * one listener per routed type is the common case after `_routing.ts` keys the channel, and the
+ * unboxed form dispatches with a `typeof` check instead of a `Set` iterator. The arrays are
+ * copy-on-write (see {@linkcode _addTo} / {@linkcode _removeFrom}), which is what lets `_emit`
+ * iterate one by index with no defensive snapshot: a registration change during dispatch installs
+ * a new array and leaves the one being walked untouched.
*/
- private readonly _listenerGuards = new WeakMap();
+ private readonly _listeners = new Map();
+
+ /**
+ * Single recycled event shell. Filled in immediately before each listener call and valid only for
+ * the duration of that call — see {@linkcode EventShell}.
+ */
+ private readonly _shell: EventShell = { type: "", detail: undefined };
+
+ /**
+ * Bumped by every registration change. `_emit` snapshots it and re-checks liveness only when it
+ * moves, so the common dispatch (nobody subscribes or unsubscribes from inside a callback) pays
+ * one integer compare per listener instead of a lookup.
+ */
+ private _generation = 0;
addEventListener(
type: K,
listener: ((event: HyperliquidEventMap[K]) => void) | EventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
- /** Records bare-channel registrations, then registers the listener as `EventTarget` does. */
+ /** Records bare-channel registrations and stores the listener for direct dispatch. */
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void {
+ if (listener === null) return;
if (isBareChannel(type)) this._bareChannels.add(type);
- super.addEventListener(type, listener === null ? null : this._guarded(listener), options);
+
+ // `once` is rare on this path (the package never uses it for Hyperliquid frames) but the
+ // EventTarget contract still requires it: wrap so the first firing removes the wrapper.
+ let registered: EventListenerOrEventListenerObject = listener;
+ if (typeof options === "object" && options !== null && options.once === true) {
+ const onceWrapper: EventListener = (event: Event) => {
+ this.removeEventListener(type, onceWrapper);
+ if (typeof listener === "function") listener.call(this, event);
+ else listener.handleEvent(event);
+ };
+ registered = onceWrapper;
+ }
+
+ this._addTo(type, registered);
+
+ // AbortSignal (when provided) detaches the same way EventTarget would.
+ if (typeof options === "object" && options !== null && options.signal !== undefined) {
+ const signal = options.signal;
+ if (signal.aborted) {
+ this.removeEventListener(type, registered);
+ return;
+ }
+ signal.addEventListener("abort", () => this.removeEventListener(type, registered), { once: true });
+ }
+ }
+
+ /**
+ * Registers `listener` for `type`, ignoring an exact duplicate the way `EventTarget` does.
+ *
+ * Writes a fresh array rather than mutating in place so an in-flight `_emit` keeps walking the
+ * registration list it started with.
+ */
+ private _addTo(type: string, listener: EventListenerOrEventListenerObject): void {
+ const entry = this._listeners.get(type);
+ if (entry === undefined) {
+ this._listeners.set(type, listener);
+ this._generation++;
+ return;
+ }
+ if (!Array.isArray(entry)) {
+ if (entry === listener) return; // duplicate registration is a no-op
+ this._listeners.set(type, [entry, listener]);
+ this._generation++;
+ return;
+ }
+ if (entry.indexOf(listener) !== -1) return;
+ this._listeners.set(type, [...entry, listener]);
+ this._generation++;
+ }
+
+ /** Whether `listener` is still registered for `type` right now. */
+ private _isLive(type: string, listener: EventListenerOrEventListenerObject): boolean {
+ const entry = this._listeners.get(type);
+ if (entry === undefined) return false;
+ return Array.isArray(entry) ? entry.indexOf(listener) !== -1 : entry === listener;
}
- /** Removes the guarded wrapper registered for `listener` (see {@linkcode _listenerGuards}). */
+ /** Removes a previously registered listener by identity. */
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
- options?: boolean | EventListenerOptions,
+ _options?: boolean | EventListenerOptions,
): void {
- const guarded = listener !== null ? this._listenerGuards.get(listener) : undefined;
- super.removeEventListener(type, guarded ?? listener, options);
- }
+ if (listener === null) return;
+ const entry = this._listeners.get(type);
+ if (entry === undefined) return;
- /** Returns the guarded form of `listener`, creating and caching it on first use. */
- private _guarded(listener: EventListenerOrEventListenerObject): EventListener {
- let guarded = this._listenerGuards.get(listener);
- if (guarded === undefined) {
- guarded = (event: Event): void => {
- try {
- if (typeof listener === "function") listener.call(this, event);
- else listener.handleEvent(event);
- } catch {
- // Swallowed: one bad listener (or one bad frame) must not abort the dispatch.
- }
- };
- this._listenerGuards.set(listener, guarded);
+ if (!Array.isArray(entry)) {
+ if (entry === listener) {
+ this._listeners.delete(type);
+ this._generation++;
+ }
+ return;
}
- return guarded;
+
+ const i = entry.indexOf(listener);
+ if (i === -1) return;
+ // Copy-on-write: an `_emit` walking the old array must not see it shrink mid-dispatch.
+ if (entry.length === 2) this._listeners.set(type, entry[i === 0 ? 1 : 0]!);
+ else this._listeners.set(type, [...entry.slice(0, i), ...entry.slice(i + 1)]);
+ this._generation++;
}
- constructor(socket: WebSocket) {
- super();
- socket.addEventListener("message", (event) => {
- let msg: unknown;
+ /**
+ * Invokes every listener registered for `type` with a recycled shell carrying `detail`.
+ *
+ * The lone-listener case (the common one after per-coin routing) dispatches straight off the map
+ * entry. Multi-listener types walk their array by index: the array is copy-on-write, so a listener
+ * that adds or removes a registration mid-dispatch swaps in a new array and cannot make this loop
+ * skip or double-fire a sibling.
+ */
+ private _emit(type: string, detail: unknown): void {
+ const entry = this._listeners.get(type);
+ if (entry === undefined) return;
+
+ const shell = this._shell;
+ shell.type = type;
+ shell.detail = detail;
+ // Cast: listeners are typed as CustomEvent handlers but only ever read `type`/`detail`.
+ const event = shell as unknown as Event;
+
+ if (typeof entry === "function") {
+ try {
+ entry.call(this, event);
+ } catch {
+ // Swallowed: one bad listener (or one bad frame) must not abort the dispatch.
+ }
+ return;
+ }
+ if (!Array.isArray(entry)) {
try {
- msg = JSON.parse(event.data);
+ entry.handleEvent(event);
} catch {
- return; // Ignore non-JSON frames
+ // Swallowed: see above.
}
+ return;
+ }
+
+ const generation = this._generation;
+ for (let i = 0; i < entry.length; i++) {
+ const listener = entry[i]!;
+ // An earlier callback in this same dispatch may have unsubscribed this one; `EventTarget`
+ // would not deliver to it. The generation check keeps the common case (no registration
+ // change mid-dispatch) at one integer compare.
+ if (this._generation !== generation && !this._isLive(type, listener)) continue;
+ try {
+ if (typeof listener === "function") listener.call(this, event);
+ else listener.handleEvent(event);
+ } catch {
+ // Swallowed: one bad listener must not starve the remaining ones.
+ }
+ }
+ }
- if (isHyperliquidEvent(msg)) {
- // Routed first, then the channel itself unless nothing has ever listened there. A routing
- // mistake can therefore only ever cost an extra discarded call, never a missed frame.
- const routed = frameEventType(msg.channel, msg.data);
- if (routed !== undefined) {
- this.dispatchEvent(new CustomEvent_(routed, { detail: msg.data }));
- if (!this._bareChannels.has(msg.channel)) return;
- }
- this.dispatchEvent(new CustomEvent_(msg.channel, { detail: msg.data }));
- } else if (isExplorerBlockEvent(msg)) {
- this.dispatchEvent(new CustomEvent_("explorerBlock_", { detail: msg }));
- } else if (isExplorerTxsEvent(msg)) {
- this.dispatchEvent(new CustomEvent_("explorerTxs_", { detail: msg }));
+ constructor(socket: WebSocket) {
+ const internal = socket as { _onFrame?: ((data: unknown) => void) | undefined };
+ if ("_onFrame" in internal) {
+ // `ReconnectingWebSocket` exposes a direct frame hook: taking it skips a `MessageEvent`
+ // allocation and an `EventTarget.dispatchEvent` per frame, on the hottest path in the SDK.
+ internal._onFrame = (data: unknown): void => this._handleFrame(data);
+ } else {
+ // Any other `WebSocket` (including test doubles) still works through the standard event.
+ socket.addEventListener("message", (event) => this._handleFrame(event.data));
+ }
+ }
+
+ /** Parses one raw inbound frame and dispatches it to the listeners that asked for it. */
+ private _handleFrame(data: unknown): void {
+ let msg: unknown;
+ try {
+ msg = JSON.parse(data as string);
+ } catch {
+ return; // Ignore non-JSON frames
+ }
+
+ if (isHyperliquidEvent(msg)) {
+ // Routed first, then the channel itself unless nothing has ever listened there. A routing
+ // mistake can therefore only ever cost an extra discarded call, never a missed frame.
+ const routed = frameEventType(msg.channel, msg.data);
+ if (routed !== undefined) {
+ this._emit(routed, msg.data);
+ if (!this._bareChannels.has(msg.channel)) return;
}
- });
+ this._emit(msg.channel, msg.data);
+ } else if (isExplorerBlockEvent(msg)) {
+ this._emit("explorerBlock_", msg);
+ } else if (isExplorerTxsEvent(msg)) {
+ this._emit("explorerTxs_", msg);
+ }
}
}
diff --git a/src/transport/websocket/_reconnectingSocket.ts b/src/transport/websocket/_reconnectingSocket.ts
index 77fa34fd..3a3cd48a 100644
--- a/src/transport/websocket/_reconnectingSocket.ts
+++ b/src/transport/websocket/_reconnectingSocket.ts
@@ -184,29 +184,6 @@ function assertValidCloseParams(code?: number, reason?: string): void {
}
}
-export interface ReconnectingWebSocket {
- addEventListener(
- type: K,
- listener: (this: ReconnectingWebSocket, ev: WebSocketEventMap[K]) => any,
- options?: boolean | AddEventListenerOptions,
- ): void;
- addEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject,
- options?: boolean | AddEventListenerOptions,
- ): void;
- removeEventListener(
- type: K,
- listener: (this: ReconnectingWebSocket, ev: WebSocketEventMap[K]) => any,
- options?: boolean | EventListenerOptions,
- ): void;
- removeEventListener(
- type: string,
- listener: EventListenerOrEventListenerObject,
- options?: boolean | EventListenerOptions,
- ): void;
-}
-
/**
* Drop-in replacement for [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) that automatically reconnects.
*
@@ -226,6 +203,27 @@ export interface ReconnectingWebSocket {
* ```
*/
export class ReconnectingWebSocket extends EventTarget implements WebSocket {
+ /**
+ * Package-internal frame hook, called with the raw `data` of every inbound frame.
+ *
+ * The SDK's own frame consumer reads every frame but needs nothing an `Event` carries beyond
+ * `data`, so it attaches here instead of through `addEventListener("message")`. That skips a
+ * `MessageEvent` allocation and a full `EventTarget.dispatchEvent` on the single hottest path in
+ * the library. The public `message` event is still dispatched whenever anything is listening for
+ * it — see {@linkcode ReconnectingWebSocket._messageListeners}.
+ */
+ _onFrame: ((data: unknown) => void) | undefined = undefined;
+
+ /**
+ * Registrations seen for the `message` event through the public API.
+ *
+ * Deliberately an upper bound rather than an exact count: `EventTarget` collapses duplicate
+ * registrations and drops `once` listeners without a `removeEventListener` call, so this can sit
+ * above the true number. Erring high only costs an event nobody reads; erring low would drop
+ * frames from a live listener.
+ */
+ private _messageListeners = 0;
+
/** URL provider for creating new connections. */
private readonly _urlProvider: UrlProvider;
/** Protocols provider for creating new connections. */
@@ -367,9 +365,12 @@ export class ReconnectingWebSocket extends EventTarget implements WebSocket {
});
socket.addEventListener("message", (event) => {
if (this._socket !== socket) return;
- // A fresh event: the incoming one is mid-dispatch on the underlying socket and
- // cannot be redispatched, and consumers only read `data`.
- this.dispatchEvent(new MessageEvent("message", { data: event.data }));
+ const data = event.data;
+ this._onFrame?.(data);
+ // A fresh event: the incoming one is mid-dispatch on the underlying socket and cannot be
+ // redispatched, and consumers only read `data`. Built only when someone is actually
+ // listening — the SDK's own consumer takes the hook above.
+ if (this._messageListeners > 0) this.dispatchEvent(new MessageEvent("message", { data }));
});
socket.addEventListener("error", () => {
if (this._socket !== socket) return;
@@ -659,6 +660,49 @@ export class ReconnectingWebSocket extends EventTarget implements WebSocket {
this._setAttributeListener("open", handler);
}
+ /**
+ * Tracks `message` registrations so inbound frames only pay for a `MessageEvent` when something
+ * outside the SDK reads one, then defers to `EventTarget`.
+ */
+ addEventListener(
+ type: K,
+ listener: (this: ReconnectingWebSocket, ev: WebSocketEventMap[K]) => any,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ addEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ addEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | AddEventListenerOptions,
+ ): void {
+ if (type === "message" && listener !== null) this._messageListeners++;
+ super.addEventListener(type, listener, options);
+ }
+
+ /** Counterpart to {@linkcode ReconnectingWebSocket.addEventListener}'s `message` bookkeeping. */
+ removeEventListener(
+ type: K,
+ listener: (this: ReconnectingWebSocket, ev: WebSocketEventMap[K]) => any,
+ options?: boolean | EventListenerOptions,
+ ): void;
+ removeEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | EventListenerOptions,
+ ): void;
+ removeEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | EventListenerOptions,
+ ): void {
+ if (type === "message" && listener !== null && this._messageListeners > 0) this._messageListeners--;
+ super.removeEventListener(type, listener, options);
+ }
+
/** Attaches or detaches the dispatcher for an attribute-style event handler. */
private _setAttributeListener(type: string, handler: ((event: any) => any) | null): void {
const existing = this._attributeListeners.get(type);
diff --git a/src/utils/_symbolConverter.ts b/src/utils/_symbolConverter.ts
index 70f8dce6..ef909f67 100644
--- a/src/utils/_symbolConverter.ts
+++ b/src/utils/_symbolConverter.ts
@@ -40,19 +40,25 @@ function tickExponent(d: DecimalParts, maxDecimals: number): number {
return Math.min(Math.max(d.exp - 5, -maxDecimals), 0);
}
-/** Increment a string of ASCII digits by one; the result may grow a digit ("999" → "1000"). */
+/**
+ * Increment a string of ASCII digits by one; the result may grow a digit ("999" → "1000").
+ *
+ * Walks char codes in a single pass without `split`/`join` allocations: the common no-carry-past-end
+ * path builds the result with one `slice` + one char write via string concatenation of the head and
+ * the bumped digit and the zero tail, which is cheaper than an intermediate char array for the short
+ * digit strings price rounding produces.
+ */
function incrementDigits(digits: string): string {
- const chars = digits.split("");
- let i = chars.length;
+ let i = digits.length;
while (i-- > 0) {
- if (chars[i] === "9") {
- chars[i] = "0";
- } else {
- chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1);
- return chars.join("");
+ const c = digits.charCodeAt(i);
+ if (c !== 0x39 /* '9' */) {
+ // digits[0..i) + bumped digit + zeros for the trailing nines already walked.
+ return digits.slice(0, i) + String.fromCharCode(c + 1) + "0".repeat(digits.length - 1 - i);
}
}
- return `1${chars.join("")}`;
+ // Every digit was 9: grow by one place ("999" → "1000").
+ return `1${"0".repeat(digits.length)}`;
}
/**
diff --git a/tests/api/exchange/_client.test.ts b/tests/api/exchange/_client.test.ts
index 299ba253..10dfcc68 100644
--- a/tests/api/exchange/_client.test.ts
+++ b/tests/api/exchange/_client.test.ts
@@ -121,6 +121,57 @@ const METHOD_CASES: Record = {
action: { type: "batchModify", modifies: [{ oid: 123, order: LIMIT_ORDER }] },
invalid: (c) => c.batchModify({} as never),
},
+ // Trigger branch exercises the second `v.check` on `triggerPx` (limit-only path only hits `p`).
+ batchModifyTrigger: {
+ run: (c) =>
+ c.batchModify({
+ modifies: [
+ {
+ oid: 123,
+ order: {
+ a: 0,
+ b: true,
+ p: "30000",
+ s: "0.1",
+ r: false,
+ t: { trigger: { isMarket: true, triggerPx: "29000", tpsl: "sl" } },
+ },
+ },
+ ],
+ }),
+ action: {
+ type: "batchModify",
+ modifies: [
+ {
+ oid: 123,
+ order: {
+ a: 0,
+ b: true,
+ p: "30000",
+ s: "0.1",
+ r: false,
+ t: { trigger: { isMarket: true, triggerPx: "29000", tpsl: "sl" } },
+ },
+ },
+ ],
+ },
+ invalid: (c) =>
+ c.batchModify({
+ modifies: [
+ {
+ oid: 123,
+ order: {
+ a: 0,
+ b: true,
+ p: "30000",
+ s: "0.1",
+ r: false,
+ t: { trigger: { isMarket: true, triggerPx: "0", tpsl: "sl" } },
+ },
+ },
+ ],
+ } as never),
+ },
borrowLend: {
run: (c) => c.borrowLend({ operation: "supply", token: 0, amount: "30" }),
action: { type: "borrowLend", operation: "supply", token: 0, amount: "30" },
@@ -230,6 +281,45 @@ const METHOD_CASES: Record = {
action: { type: "modify", oid: 123, order: LIMIT_ORDER },
invalid: (c) => c.modify({} as never),
},
+ // Trigger branch exercises the second `v.check` on `triggerPx` (limit-only path only hits `p`).
+ modifyTrigger: {
+ run: (c) =>
+ c.modify({
+ oid: 123,
+ order: {
+ a: 0,
+ b: true,
+ p: "30000",
+ s: "0.1",
+ r: false,
+ t: { trigger: { isMarket: true, triggerPx: "29000", tpsl: "tp" } },
+ },
+ }),
+ action: {
+ type: "modify",
+ oid: 123,
+ order: {
+ a: 0,
+ b: true,
+ p: "30000",
+ s: "0.1",
+ r: false,
+ t: { trigger: { isMarket: true, triggerPx: "29000", tpsl: "tp" } },
+ },
+ },
+ invalid: (c) =>
+ c.modify({
+ oid: 123,
+ order: {
+ a: 0,
+ b: true,
+ p: "30000",
+ s: "0.1",
+ r: false,
+ t: { trigger: { isMarket: true, triggerPx: "0", tpsl: "tp" } },
+ },
+ } as never),
+ },
noop: {
run: (c) => c.noop({ nonce: 12345 }),
action: { type: "noop" },
diff --git a/tests/api/exchange/_semaphore.test.ts b/tests/api/exchange/_semaphore.test.ts
index 8644df24..80acd90e 100644
--- a/tests/api/exchange/_semaphore.test.ts
+++ b/tests/api/exchange/_semaphore.test.ts
@@ -118,4 +118,26 @@ describe("withLock", () => {
await Promise.all([first, second]);
assert(events.length === 2);
});
+
+ test("a long waiter queue compacts without dropping FIFO order", async () => {
+ // Drive head past 16 so release() slices the dead prefix off the waiters array.
+ const order: number[] = [];
+ const gate = createGate();
+ const holder = withLock("compact-key", async () => {
+ await gate.promise;
+ order.push(0);
+ });
+ const waiters = Array.from({ length: 20 }, (_, i) =>
+ withLock("compact-key", async () => {
+ order.push(i + 1);
+ }),
+ );
+ await flush();
+ gate.open();
+ await Promise.all([holder, ...waiters]);
+ assertEquals(
+ order,
+ Array.from({ length: 21 }, (_, i) => i),
+ );
+ });
});
diff --git a/tests/api/subscription/_mockTransport.ts b/tests/api/subscription/_mockTransport.ts
index 2dbbde66..f8cdcf9f 100644
--- a/tests/api/subscription/_mockTransport.ts
+++ b/tests/api/subscription/_mockTransport.ts
@@ -5,10 +5,16 @@
* options — and resolves with a stub {@linkcode ISubscription}, so tests can assert exactly
* what the API layer sent and replay synthetic events through the captured listener.
*
+ * Event delivery mirrors the production WebSocket path: frames are filtered by the same
+ * {@linkcode payloadEventType} / {@linkcode frameEventType} routing table, so a subscription
+ * only sees frames that would reach it on a real socket. Residual method-level filters (interval,
+ * dex, user when the route is coarser) still run inside the captured listener.
+ *
* @module
*/
import type { ISubscription, ISubscriptionTransport, TransportError } from "@bloxwap/hyperliquid";
+import { frameEventType, payloadEventType } from "../../../src/transport/websocket/_routing.ts";
/** Subscription options as seen by the transport. */
export interface MockSubscribeOptions {
@@ -22,6 +28,11 @@ export interface MockSubscribeCall {
payload: unknown;
listener: (data: CustomEvent) => void;
options?: MockSubscribeOptions;
+ /**
+ * Event type the production transport would attach this listener to — the routed type when the
+ * channel is keyable, the bare channel otherwise. Computed once at subscribe time.
+ */
+ eventType: string;
}
/** An {@linkcode ISubscriptionTransport} that records subscriptions instead of opening a socket. */
@@ -34,12 +45,30 @@ export class MockSubscriptionTransport implements ISubscriptionTransport {
listener: (data: CustomEvent) => void,
options?: MockSubscribeOptions,
): Promise {
- this.calls.push({ channel, payload, listener: listener as (data: CustomEvent) => void, options });
+ this.calls.push({
+ channel,
+ payload,
+ listener: listener as (data: CustomEvent) => void,
+ options,
+ eventType: payloadEventType(channel, payload),
+ });
return Promise.resolve({ unsubscribe: () => Promise.resolve() });
}
- /** Replays a server event to the listener captured by the `index`-th subscribe call (default: last). */
+ /**
+ * Replays a server event to the listener captured by the `index`-th subscribe call (default: last).
+ *
+ * When the subscription is on a routed type, a frame whose route key does not match is dropped —
+ * the same filter {@linkcode HyperliquidEventTarget} applies on a live socket.
+ */
emit(detail: unknown, index: number = this.calls.length - 1): void {
- this.calls[index].listener({ detail } as CustomEvent);
+ const call = this.calls[index];
+ // Bare-channel subscriptions (unrouted channels, or payloads missing the key) receive every
+ // frame on the channel. Routed subscriptions only receive frames that hash to the same type.
+ if (call.eventType !== call.channel) {
+ const frameType = frameEventType(call.channel, detail);
+ if (frameType !== call.eventType) return;
+ }
+ call.listener({ detail } as CustomEvent);
}
}
diff --git a/tests/api/subscription/client.test.ts b/tests/api/subscription/client.test.ts
index f798dcae..9e15df80 100644
--- a/tests/api/subscription/client.test.ts
+++ b/tests/api/subscription/client.test.ts
@@ -615,4 +615,200 @@ describe("fastAssetCtxs", () => {
errorSpy.mockRestore();
}
});
+
+ test("table-driven base64 path runs when Buffer is unavailable", async () => {
+ const transport = new MockSubscriptionTransport();
+ const client = new SubscriptionClient({ transport });
+ const received: unknown[] = [];
+ await client.fastAssetCtxs((data) => received.push(data));
+
+ const frame = await compressToBase64({ ETH: { markPx: "1" } });
+ const originalBuffer = globalThis.Buffer;
+ // Hide Buffer so decodeBase64 takes the pure-JS LUT path (browser / RN without a polyfill).
+ delete (globalThis as { Buffer?: unknown }).Buffer;
+ try {
+ transport.emit(frame);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ } finally {
+ globalThis.Buffer = originalBuffer;
+ }
+ expect(received).toEqual([{ ETH: { markPx: "1" } }]);
+ });
+
+ test("table-driven base64 rejects invalid alphabet and odd padding shapes", async () => {
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
+ const transport = new MockSubscriptionTransport();
+ const client = new SubscriptionClient({ transport });
+ const received: unknown[] = [];
+ await client.fastAssetCtxs((data) => received.push(data));
+
+ const originalBuffer = globalThis.Buffer;
+ delete (globalThis as { Buffer?: unknown }).Buffer;
+ try {
+ // Valid length, invalid character → LUT miss (255).
+ transport.emit("!!!!");
+ // Empty / non-multiple-of-4 lengths.
+ transport.emit("");
+ transport.emit("abc");
+ // One- and two-byte padding groups (still valid alphabet, table path).
+ transport.emit("YQ=="); // "a"
+ transport.emit(await compressToBase64({ OK: { markPx: "2" } }));
+ await new Promise((resolve) => setTimeout(resolve, 30));
+
+ // Only the final valid compressed frame is delivered; bad frames are logged.
+ expect(received).toEqual([{ OK: { markPx: "2" } }]);
+ expect(errorSpy.mock.calls.length).toBeGreaterThanOrEqual(3);
+ } finally {
+ globalThis.Buffer = originalBuffer;
+ errorSpy.mockRestore();
+ }
+ });
+
+ test("multi-chunk inflate merges into the retained scratch buffer", async () => {
+ const { _setForceStreamDecompressForTests } = await import(
+ "../../../src/api/subscription/_methods/fastAssetCtxs.ts"
+ );
+ const transport = new MockSubscriptionTransport();
+ const client = new SubscriptionClient({ transport });
+ const received: unknown[] = [];
+ await client.fastAssetCtxs((data) => received.push(data));
+
+ const payload = new TextEncoder().encode(JSON.stringify({ BTC: { markPx: "100", midPx: "101" } }));
+ // Force three stream chunks so the multi-chunk merge + while-loop arms all run.
+ const parts = [payload.subarray(0, 4), payload.subarray(4, 10), payload.subarray(10)];
+
+ const RealDS = globalThis.DecompressionStream;
+ globalThis.DecompressionStream = class {
+ readable: ReadableStream;
+ writable: WritableStream;
+ constructor(_format: CompressionFormat) {
+ this.writable = new WritableStream({
+ write() {},
+ close() {},
+ });
+ let i = 0;
+ this.readable = new ReadableStream({
+ pull(controller) {
+ if (i < parts.length) controller.enqueue(parts[i++]);
+ else controller.close();
+ },
+ });
+ }
+ } as unknown as typeof DecompressionStream;
+
+ _setForceStreamDecompressForTests(true);
+ try {
+ // Wire payload is unused by the fake inflater; any valid base64 string is fine.
+ transport.emit("AAAA");
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(received).toEqual([{ BTC: { markPx: "100", midPx: "101" } }]);
+ } finally {
+ _setForceStreamDecompressForTests(false);
+ globalThis.DecompressionStream = RealDS;
+ }
+ });
+
+ test("stream write failures are absorbed without poisoning the queue", async () => {
+ const { _setForceStreamDecompressForTests } = await import(
+ "../../../src/api/subscription/_methods/fastAssetCtxs.ts"
+ );
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
+ const transport = new MockSubscriptionTransport();
+ const client = new SubscriptionClient({ transport });
+ const received: unknown[] = [];
+ await client.fastAssetCtxs((data) => received.push(data));
+
+ const RealDS = globalThis.DecompressionStream;
+ let rejectWrite = true;
+ globalThis.DecompressionStream = class {
+ readable: ReadableStream;
+ writable: WritableStream;
+ constructor(format: CompressionFormat) {
+ if (!rejectWrite) {
+ const real = new RealDS(format);
+ this.readable = real.readable as ReadableStream;
+ this.writable = real.writable;
+ return;
+ }
+ this.writable = new WritableStream({
+ write() {
+ return Promise.reject(new Error("write failed"));
+ },
+ close() {},
+ });
+ // Reader still needs to settle so decompress can fail via empty/error read.
+ this.readable = new ReadableStream({
+ start(controller) {
+ controller.error(new Error("inflate failed"));
+ },
+ });
+ }
+ } as unknown as typeof DecompressionStream;
+
+ _setForceStreamDecompressForTests(true);
+ try {
+ transport.emit("AAAA");
+ await new Promise((resolve) => setTimeout(resolve, 15));
+ rejectWrite = false;
+ transport.emit(await compressToBase64({ ETH: { markPx: "3" } }));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(received).toEqual([{ ETH: { markPx: "3" } }]);
+ expect(errorSpy).toHaveBeenCalled();
+ } finally {
+ _setForceStreamDecompressForTests(false);
+ globalThis.DecompressionStream = RealDS;
+ errorSpy.mockRestore();
+ }
+ });
+
+ test("empty inflate stream is logged and does not poison the queue", async () => {
+ const { _setForceStreamDecompressForTests } = await import(
+ "../../../src/api/subscription/_methods/fastAssetCtxs.ts"
+ );
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
+ const transport = new MockSubscriptionTransport();
+ const client = new SubscriptionClient({ transport });
+ const received: unknown[] = [];
+ await client.fastAssetCtxs((data) => received.push(data));
+
+ const RealDS = globalThis.DecompressionStream;
+ let useEmpty = true;
+ globalThis.DecompressionStream = class {
+ readable: ReadableStream;
+ writable: WritableStream;
+ constructor(format: CompressionFormat) {
+ if (!useEmpty) {
+ const real = new RealDS(format);
+ this.readable = real.readable as ReadableStream;
+ this.writable = real.writable;
+ return;
+ }
+ this.writable = new WritableStream({
+ write() {},
+ close() {},
+ });
+ this.readable = new ReadableStream({
+ start(controller) {
+ controller.close(); // first read is immediately done
+ },
+ });
+ }
+ } as unknown as typeof DecompressionStream;
+
+ _setForceStreamDecompressForTests(true);
+ try {
+ transport.emit("AAAA");
+ await new Promise((resolve) => setTimeout(resolve, 15));
+ useEmpty = false;
+ transport.emit(await compressToBase64({ ETH: { markPx: "9" } }));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ expect(received).toEqual([{ ETH: { markPx: "9" } }]);
+ expect(errorSpy).toHaveBeenCalled();
+ } finally {
+ _setForceStreamDecompressForTests(false);
+ globalThis.DecompressionStream = RealDS;
+ errorSpy.mockRestore();
+ }
+ });
});
diff --git a/tests/perf/README.md b/tests/perf/README.md
index 33792ff7..0ffe4d93 100644
--- a/tests/perf/README.md
+++ b/tests/perf/README.md
@@ -81,6 +81,13 @@ reports **`invocationsPerTick`**: how many listeners a single frame runs.
| Pre-fix (this tree) | 50 (every listener runs, 49 discard) |
| Post-fix (expected) | 1 (routed to the matching subscription) |
+`subscription/*_frame_dispatch_e2e` times a raw frame's full path — socket JSON text through parse and routing to the
+subscribed listener — for `l2Book` and for the three user-account channels a balance feed lives on:
+`clearinghouseState`, `spotState`, and `webData3` (`scenarios/user_account_channels.ts`). `subscription/user_dispatch_15_users`
+crowds the maximum allowed 15 users onto one channel and asserts a frame for one user still runs exactly one listener
+(the `BY_USER` route); `subscription/subscribe_user_trio` measures establishing the three account subscriptions a feed
+makes at session start and on every reconnect.
+
### `data` — inbound Info-response cost
`InfoClient` reads (`l2Book`, `allMids`, `clearinghouseState`, `metaAndAssetCtxs`) over a transport that answers from a
diff --git a/tests/perf/fast_asset_ctxs.test.ts b/tests/perf/fast_asset_ctxs.test.ts
index 46e6ed05..a0b40b9a 100644
--- a/tests/perf/fast_asset_ctxs.test.ts
+++ b/tests/perf/fast_asset_ctxs.test.ts
@@ -93,3 +93,40 @@ test("fastAssetCtxs: deliveries continue after a listener throws", async () => {
restoreWebSocket();
}
});
+
+test("fastAssetCtxs: frames stay in arrival order when sync and queued decodes interleave", async () => {
+ // Where a native inflater exists, a frame is decoded synchronously and delivered in the
+ // dispatch tick; the `DecompressionStream` path still goes through the promise queue. A frame
+ // taking the fast path must never overtake one still queued ahead of it.
+ const { _setForceStreamDecompressForTests } = await import("../../src/api/subscription/_methods/fastAssetCtxs.ts");
+
+ installMockWebSocket();
+ try {
+ const transport = new WebSocketTransport({ url: "wss://perf.local/ws" });
+ await transport.ready();
+ const socket = lastMockWebSocket();
+ const client = new SubscriptionClient({ transport });
+
+ const delivered: number[] = [];
+ await client.fastAssetCtxs((data) => {
+ delivered.push(Number(Object.keys(data)[0]!.slice(4)));
+ });
+
+ // Alternate the two decode paths frame by frame.
+ for (let i = 1; i <= 6; i++) {
+ const data = await compressToBase64({ [`COIN${i}`]: { markPx: String(i) } });
+ _setForceStreamDecompressForTests(i % 2 === 1);
+ socket.serverSend({ channel: "fastAssetCtxs", data });
+ }
+ _setForceStreamDecompressForTests(false);
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ transport.close();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assertEquals(delivered, [1, 2, 3, 4, 5, 6], "a synchronously decoded frame overtook a queued one");
+ } finally {
+ _setForceStreamDecompressForTests(false);
+ restoreWebSocket();
+ }
+});
diff --git a/tests/perf/results/baseline.json b/tests/perf/results/baseline.json
index 11d4f796..ff05fc7e 100644
--- a/tests/perf/results/baseline.json
+++ b/tests/perf/results/baseline.json
@@ -1,13 +1,13 @@
{
"schema": 1,
"meta": {
- "commit": "cbb347a814ba79a264fc70addba95bfe81e807e3",
- "dirty": false,
+ "commit": "35cddacecd6c52a351ec841fc5692969823b4245",
+ "dirty": true,
"runtime": "Bun 1.4.0 (webkit 5491700)",
"cpu": "Apple M3 Max",
"os": "darwin arm64",
- "date": "2026-07-27T03:20:45.434Z",
- "suiteFingerprint": "a40e49dd4637595b",
+ "date": "2026-07-27T23:52:21.623Z",
+ "suiteFingerprint": "f259cdaa8dc3c4e0",
"label": "baseline"
},
"scenarios": [
@@ -19,15 +19,15 @@
"samples": 15,
"iterations": 200,
"unitsPerIteration": 40,
- "nsPerUnit": 163.75000000000028,
- "unitsPerSec": 6106870.229007623,
- "min": 160.5105000000009,
- "p50": 163.75000000000028,
- "p75": 165.51562500000117,
- "p99": 172.80212500000047,
- "max": 172.80212500000047,
- "stddev": 3.4331519350849735,
- "rme": 1.1574478440283351,
+ "nsPerUnit": 138.8542499999996,
+ "unitsPerSec": 7201796.127954332,
+ "min": 132.12500000000026,
+ "p50": 138.8542499999996,
+ "p75": 146.22400000000013,
+ "p99": 155.5156249999996,
+ "max": 155.5156249999996,
+ "stddev": 7.752872548959874,
+ "rme": 3.052692712194986,
"fingerprint": "a0e52b09c67a0898"
},
{
@@ -38,15 +38,15 @@
"samples": 15,
"iterations": 100,
"unitsPerIteration": 350,
- "nsPerUnit": 102.05000000000025,
- "unitsPerSec": 9799118.079372833,
- "min": 97.11308571428553,
- "p50": 102.05000000000025,
- "p75": 107.55474285714318,
- "p99": 118.28214285714255,
- "max": 118.28214285714255,
- "stddev": 6.254588037377184,
- "rme": 3.3565568753340833,
+ "nsPerUnit": 86.80237142857145,
+ "unitsPerSec": 11520422.582266511,
+ "min": 77.70239999999994,
+ "p50": 86.80237142857145,
+ "p75": 93.05831428571392,
+ "p99": 107.14168571428624,
+ "max": 107.14168571428624,
+ "stddev": 7.908304813195068,
+ "rme": 4.912208950537846,
"fingerprint": "421d1b5d06bde6d5"
},
{
@@ -57,15 +57,15 @@
"samples": 15,
"iterations": 100,
"unitsPerIteration": 20,
- "nsPerUnit": 942.6039999999887,
- "unitsPerSec": 1060890.8937369373,
- "min": 908.0830000000049,
- "p50": 942.6039999999887,
- "p75": 956.0000000000031,
- "p99": 1301.9795000000017,
- "max": 1301.9795000000017,
- "stddev": 95.88550983487004,
- "rme": 5.476669792256147,
+ "nsPerUnit": 767.9164999999983,
+ "unitsPerSec": 1302224.9163808853,
+ "min": 737.5419999999906,
+ "p50": 767.9164999999983,
+ "p75": 853.5830000000004,
+ "p99": 1239.4795000000017,
+ "max": 1239.4795000000017,
+ "stddev": 126.02188584676095,
+ "rme": 8.538767806589727,
"fingerprint": "f5c147d5054be4b9"
},
{
@@ -76,15 +76,15 @@
"samples": 15,
"iterations": 50,
"unitsPerIteration": 200,
- "nsPerUnit": 995.799999999997,
- "unitsPerSec": 1004217.714400485,
- "min": 941.3792,
- "p50": 995.799999999997,
- "p75": 1054.7583000000031,
- "p99": 1387.337500000001,
- "max": 1387.337500000001,
- "stddev": 106.20184238194639,
- "rme": 5.724617148796876,
+ "nsPerUnit": 787.0083000000022,
+ "unitsPerSec": 1270634.6299016124,
+ "min": 734.6582999999953,
+ "p50": 787.0083000000022,
+ "p75": 808.0917,
+ "p99": 888.5457999999973,
+ "max": 888.5457999999973,
+ "stddev": 38.5029145321159,
+ "rme": 2.67822113897681,
"fingerprint": "cff19bf00ec1c5ac"
},
{
@@ -95,15 +95,15 @@
"samples": 15,
"iterations": 2000,
"unitsPerIteration": 1,
- "nsPerUnit": 284.1459999999927,
- "unitsPerSec": 3519317.5339439083,
- "min": 261.1039999999889,
- "p50": 284.1459999999927,
- "p75": 286.3749999999925,
- "p99": 457.9794999999933,
- "max": 457.9794999999933,
- "stddev": 50.788784738574236,
- "rme": 9.539549872916231,
+ "nsPerUnit": 212.41650000001755,
+ "unitsPerSec": 4707732.21477577,
+ "min": 198.332999999991,
+ "p50": 212.41650000001755,
+ "p75": 240.08349999999723,
+ "p99": 357.3959999999943,
+ "max": 357.3959999999943,
+ "stddev": 42.1546216771678,
+ "rme": 10.23688239133553,
"fingerprint": "28054c7c52395052"
},
{
@@ -114,15 +114,15 @@
"samples": 15,
"iterations": 100,
"unitsPerIteration": 100,
- "nsPerUnit": 173.74580000000037,
- "unitsPerSec": 5755534.810050072,
- "min": 161.78330000000187,
- "p50": 173.74580000000037,
- "p75": 175.1374999999996,
- "p99": 176.3083999999992,
- "max": 176.3083999999992,
- "stddev": 5.701874816884353,
- "rme": 1.8452845618513525,
+ "nsPerUnit": 124.87499999999727,
+ "unitsPerSec": 8008008.008008183,
+ "min": 120.17500000000042,
+ "p50": 124.87499999999727,
+ "p75": 128.4084000000007,
+ "p99": 136.78339999999594,
+ "max": 136.78339999999594,
+ "stddev": 5.100836547245626,
+ "rme": 2.2569091561684855,
"fingerprint": "ccec3754d0b8a0a9"
},
{
@@ -133,15 +133,15 @@
"samples": 15,
"iterations": 50,
"unitsPerIteration": 100,
- "nsPerUnit": 896.9584000000168,
- "unitsPerSec": 1114878.906312691,
- "min": 843.4083999999986,
- "p50": 896.9584000000168,
- "p75": 974.5000000000003,
- "p99": 1133.683400000018,
- "max": 1133.683400000018,
- "stddev": 78.98381773438548,
- "rme": 4.739559319685154,
+ "nsPerUnit": 711.7165999999997,
+ "unitsPerSec": 1405053.6407328427,
+ "min": 627.0832000000041,
+ "p50": 711.7165999999997,
+ "p75": 758.2666000000017,
+ "p99": 888.0417999999963,
+ "max": 888.0417999999963,
+ "stddev": 70.48179298026636,
+ "rme": 5.411745089086885,
"fingerprint": "b22d0ad022636746"
},
{
@@ -152,15 +152,15 @@
"samples": 15,
"iterations": 2000,
"unitsPerIteration": 1,
- "nsPerUnit": 3266.187500000001,
- "unitsPerSec": 306167.35873246705,
- "min": 3043.7499999999886,
- "p50": 3266.187500000001,
- "p75": 3484.375,
- "p99": 5092.895999999996,
- "max": 5092.895999999996,
- "stddev": 620.067864995574,
- "rme": 9.865039870731017,
+ "nsPerUnit": 2458.520999999962,
+ "unitsPerSec": 406748.6102416922,
+ "min": 2303.625000000011,
+ "p50": 2458.520999999962,
+ "p75": 2581.3545000000317,
+ "p99": 4070.021000000054,
+ "max": 4070.021000000054,
+ "stddev": 571.3766660932387,
+ "rme": 11.844043374453088,
"fingerprint": "d54cbf619371389c"
},
{
@@ -171,15 +171,15 @@
"samples": 15,
"iterations": 100,
"unitsPerIteration": 100,
- "nsPerUnit": 999.5916999999963,
- "unitsPerSec": 1000408.4667769887,
- "min": 979.0292000000022,
- "p50": 999.5916999999963,
- "p75": 1018.6917000000108,
- "p99": 1067.7958999999987,
- "max": 1067.7958999999987,
- "stddev": 25.673969672877902,
- "rme": 1.4084241551094965,
+ "nsPerUnit": 722.5624999999923,
+ "unitsPerSec": 1383963.324971903,
+ "min": 689.8000000000025,
+ "p50": 722.5624999999923,
+ "p75": 739.6833000000015,
+ "p99": 759.7707999999898,
+ "max": 759.7707999999898,
+ "stddev": 22.419421008126946,
+ "rme": 1.711407198710142,
"fingerprint": "e04111996808eebf"
},
{
@@ -190,15 +190,15 @@
"samples": 15,
"iterations": 2000,
"unitsPerIteration": 1,
- "nsPerUnit": 3462.6665000000116,
- "unitsPerSec": 288794.7770886964,
- "min": 3281.499999999994,
- "p50": 3462.6665000000116,
- "p75": 3542.6669999999945,
- "p99": 5069.458499999996,
- "max": 5069.458499999996,
- "stddev": 498.5502611969312,
- "rme": 7.597937927016281,
+ "nsPerUnit": 2609.0414999999894,
+ "unitsPerSec": 383282.51965329185,
+ "min": 2328.395999999998,
+ "p50": 2609.0414999999894,
+ "p75": 2765.3329999999983,
+ "p99": 3831.0204999999655,
+ "max": 3831.0204999999655,
+ "stddev": 449.8488129994487,
+ "rme": 9.095355373090547,
"fingerprint": "7f66885275690ea4"
},
{
@@ -209,15 +209,15 @@
"samples": 10,
"iterations": 50,
"unitsPerIteration": 1,
- "nsPerUnit": 136883.32000000172,
- "unitsPerSec": 7305.492005892225,
- "min": 128408.34000000086,
- "p50": 136883.32000000172,
- "p75": 156911.66000000067,
- "p99": 173556.67999999694,
- "max": 173556.67999999694,
- "stddev": 15570.19941729611,
- "rme": 7.688113906056227,
+ "nsPerUnit": 106529.16000000006,
+ "unitsPerSec": 9387.101146765819,
+ "min": 92471.6599999988,
+ "p50": 106529.16000000006,
+ "p75": 122576.65999999973,
+ "p99": 128264.99999999895,
+ "max": 128264.99999999895,
+ "stddev": 13389.850052433298,
+ "rme": 8.67967606899351,
"fingerprint": "b7da711e90fee6ec"
},
{
@@ -228,15 +228,15 @@
"samples": 10,
"iterations": 10,
"unitsPerIteration": 100,
- "nsPerUnit": 2334.916000000021,
- "unitsPerSec": 428280.93173372874,
- "min": 2284.791999999925,
- "p50": 2334.916000000021,
- "p75": 2374.166999999943,
- "p99": 2508.624999999938,
- "max": 2508.624999999938,
- "stddev": 66.99745829537358,
- "rme": 2.036737994975377,
+ "nsPerUnit": 1603.1670000000986,
+ "unitsPerSec": 623765.3344910034,
+ "min": 1517.9170000000113,
+ "p50": 1603.1670000000986,
+ "p75": 1687.6250000000255,
+ "p99": 2035.7910000000174,
+ "max": 2035.7910000000174,
+ "stddev": 164.78414658629362,
+ "rme": 7.09284708044353,
"fingerprint": "28281ef0ef0057b9"
},
{
@@ -247,15 +247,15 @@
"samples": 10,
"iterations": 10,
"unitsPerIteration": 1,
- "nsPerUnit": 508895.80000000476,
- "unitsPerSec": 1965.0388154117024,
- "min": 478187.49999999005,
- "p50": 508895.80000000476,
- "p75": 615483.3999999936,
- "p99": 705845.8000000201,
- "max": 705845.8000000201,
- "stddev": 79494.93157857218,
- "rme": 10.438516829070378,
+ "nsPerUnit": 419912.499999998,
+ "unitsPerSec": 2381.4485160599047,
+ "min": 351150.00000000687,
+ "p50": 419912.499999998,
+ "p75": 469358.3999999987,
+ "p99": 544350.0000000086,
+ "max": 544350.0000000086,
+ "stddev": 59263.76035732599,
+ "rme": 9.517468154768379,
"fingerprint": "5121c76f766827ff"
},
{
@@ -266,15 +266,15 @@
"samples": 10,
"iterations": 10,
"unitsPerIteration": 1,
- "nsPerUnit": 793983.3000000136,
- "unitsPerSec": 1259.4723339898749,
- "min": 720499.9999999928,
- "p50": 793983.3000000136,
- "p75": 860591.599999998,
- "p99": 996483.400000011,
- "max": 996483.400000011,
- "stddev": 101144.61280727977,
- "rme": 8.80729051578016,
+ "nsPerUnit": 602795.8000000012,
+ "unitsPerSec": 1658.9365752050662,
+ "min": 511504.1999999903,
+ "p50": 602795.8000000012,
+ "p75": 635091.6999999981,
+ "p99": 648454.0999999922,
+ "max": 648454.0999999922,
+ "stddev": 55491.61507738265,
+ "rme": 6.75637817243165,
"fingerprint": "f7e1c759be5541c9"
},
{
@@ -285,15 +285,15 @@
"samples": 15,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 24442.08499999945,
- "unitsPerSec": 40913.03994728856,
- "min": 21848.7499999992,
- "p50": 24442.08499999945,
- "p75": 24871.875000000047,
- "p99": 28524.79000000017,
- "max": 28524.79000000017,
- "stddev": 1809.5280086679516,
- "rme": 4.18178536974715,
+ "nsPerUnit": 17373.959999999897,
+ "unitsPerSec": 57557.40199701196,
+ "min": 15505.624999999556,
+ "p50": 17373.959999999897,
+ "p75": 17883.124999999607,
+ "p99": 25027.499999999916,
+ "max": 25027.499999999916,
+ "stddev": 2246.1375673395637,
+ "rme": 7.100277110169037,
"fingerprint": "326bc35b91b0fb53"
},
{
@@ -304,15 +304,15 @@
"samples": 15,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 89697.08500000024,
- "unitsPerSec": 11148.634317380518,
- "min": 87610.6249999998,
- "p50": 89697.08500000024,
- "p75": 91701.66499999937,
- "p99": 113023.74999999983,
- "max": 113023.74999999983,
- "stddev": 6566.518250947508,
- "rme": 3.9563682005501435,
+ "nsPerUnit": 67884.58500000047,
+ "unitsPerSec": 14730.884780395918,
+ "min": 64966.040000000474,
+ "p50": 67884.58500000047,
+ "p75": 69300.21000000011,
+ "p99": 98957.71000000082,
+ "max": 98957.71000000082,
+ "stddev": 8957.796668131,
+ "rme": 7.027967977606934,
"fingerprint": "f79908712fee1795"
},
{
@@ -323,15 +323,15 @@
"samples": 15,
"iterations": 5000,
"unitsPerIteration": 1,
- "nsPerUnit": 5435.050000000001,
- "unitsPerSec": 183990.9476453758,
- "min": 5113.31680000003,
- "p50": 5435.050000000001,
- "p75": 5705.391799999961,
- "p99": 6329.333399999996,
- "max": 6329.333399999996,
- "stddev": 348.627767523229,
- "rme": 3.4953883167004984,
+ "nsPerUnit": 4534.0416000000005,
+ "unitsPerSec": 220553.77700989772,
+ "min": 4234.516600000006,
+ "p50": 4534.0416000000005,
+ "p75": 4883.783200000016,
+ "p99": 5362.558200000012,
+ "max": 5362.558200000012,
+ "stddev": 363.43167863921394,
+ "rme": 4.30177805537626,
"fingerprint": "b3be40641fa0fa67"
},
{
@@ -342,15 +342,15 @@
"samples": 15,
"iterations": 2000,
"unitsPerIteration": 1,
- "nsPerUnit": 48606.541999999994,
- "unitsPerSec": 20573.36232641277,
- "min": 47009.95850000004,
- "p50": 48606.541999999994,
- "p75": 51184.47900000001,
- "p99": 53116.43750000007,
- "max": 53116.43750000007,
- "stddev": 1982.690755654155,
- "rme": 2.2242752266828267,
+ "nsPerUnit": 43523.56250000002,
+ "unitsPerSec": 22976.06038108667,
+ "min": 41827.43750000009,
+ "p50": 43523.56250000002,
+ "p75": 43912.27099999992,
+ "p99": 46297.08349999988,
+ "max": 46297.08349999988,
+ "stddev": 1210.8764962472496,
+ "rme": 1.545222099107133,
"fingerprint": "e93558ba4ce1c999"
},
{
@@ -361,15 +361,15 @@
"samples": 15,
"iterations": 5000,
"unitsPerIteration": 1,
- "nsPerUnit": 5310.649999999987,
- "unitsPerSec": 188300.8671254936,
- "min": 4965.07500000007,
- "p50": 5310.649999999987,
- "p75": 5862.241799999902,
- "p99": 6004.691599999933,
- "max": 6004.691599999933,
- "stddev": 353.5615976864224,
- "rme": 3.57671082414478,
+ "nsPerUnit": 4523.858400000063,
+ "unitsPerSec": 221050.24330557874,
+ "min": 4355.7081999999355,
+ "p50": 4523.858400000063,
+ "p75": 4927.275000000009,
+ "p99": 5648.599999999988,
+ "max": 5648.599999999988,
+ "stddev": 383.6289887383652,
+ "rme": 4.476030826617185,
"fingerprint": "664b3dfce511ba41"
},
{
@@ -380,15 +380,15 @@
"samples": 15,
"iterations": 2000,
"unitsPerIteration": 1,
- "nsPerUnit": 53977.58299999987,
- "unitsPerSec": 18526.209296922436,
- "min": 51700.91649999995,
- "p50": 53977.58299999987,
- "p75": 54290.22899999972,
- "p99": 58196.54149999997,
- "max": 58196.54149999997,
- "stddev": 1866.196952163944,
- "rme": 1.9084377368636058,
+ "nsPerUnit": 45724.91649999984,
+ "unitsPerSec": 21869.91418562795,
+ "min": 43947.354000000356,
+ "p50": 45724.91649999984,
+ "p75": 46698.35400000011,
+ "p99": 47641.37500000016,
+ "max": 47641.37500000016,
+ "stddev": 1031.3073991383114,
+ "rme": 1.2442569810434325,
"fingerprint": "94bccde4ebba62f9"
},
{
@@ -399,15 +399,15 @@
"samples": 15,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 11876.250000000255,
- "unitsPerSec": 84201.6629828421,
- "min": 11092.085000000223,
- "p50": 11876.250000000255,
- "p75": 12118.540000001303,
- "p99": 24881.669999999758,
- "max": 24881.669999999758,
- "stddev": 3411.602587692723,
- "rme": 14.692948276536614,
+ "nsPerUnit": 9136.250000001382,
+ "unitsPerSec": 109454.09768776562,
+ "min": 8702.079999998205,
+ "p50": 9136.250000001382,
+ "p75": 10607.915000000503,
+ "p99": 19309.579999999187,
+ "max": 19309.579999999187,
+ "stddev": 2670.3056540954876,
+ "rme": 14.695184225706,
"fingerprint": "35f3d1be84f65d55"
},
{
@@ -418,15 +418,15 @@
"samples": 10,
"iterations": 1,
"unitsPerIteration": 500,
- "nsPerUnit": 1971.8339999999444,
- "unitsPerSec": 507142.0819399748,
- "min": 1912.9179999999908,
- "p50": 1971.8339999999444,
- "p75": 2028.0840000013995,
- "p99": 2096.9160000004194,
- "max": 2096.9160000004194,
- "stddev": 59.26241417394609,
- "rme": 2.134375292184958,
+ "nsPerUnit": 1338.6659999996482,
+ "unitsPerSec": 747012.3242095212,
+ "min": 1265.49999999952,
+ "p50": 1338.6659999996482,
+ "p75": 1418.6659999995752,
+ "p99": 1808.9999999992872,
+ "max": 1808.9999999992872,
+ "stddev": 158.9621435643438,
+ "rme": 8.165657681878976,
"fingerprint": "da414bed7beb2751",
"extra": {
"invocationsPerTick": 1,
@@ -441,15 +441,15 @@
"samples": 25,
"iterations": 1,
"unitsPerIteration": 200,
- "nsPerUnit": 8446.669999998448,
- "unitsPerSec": 118389.8506749031,
- "min": 7301.455000001624,
- "p50": 8446.669999998448,
- "p75": 8958.959999999934,
- "p99": 20824.585000000297,
- "max": 20824.585000000297,
- "stddev": 3126.9547843842947,
- "rme": 13.798484742460944,
+ "nsPerUnit": 6200.210000001789,
+ "unitsPerSec": 161284.85970631824,
+ "min": 5013.32999999704,
+ "p50": 6200.210000001789,
+ "p75": 6837.709999999788,
+ "p99": 14782.289999998284,
+ "max": 14782.289999998284,
+ "stddev": 1897.3336533691825,
+ "rme": 11.7426381325029,
"fingerprint": "492e45c50f28b829"
},
{
@@ -460,18 +460,18 @@
"samples": 10,
"iterations": 1,
"unitsPerIteration": 500,
- "nsPerUnit": 10040.581999999631,
- "unitsPerSec": 99595.82024229638,
- "min": 9639.415999999983,
- "p50": 10040.581999999631,
- "p75": 11089.168000000427,
- "p99": 19742.582000000766,
- "max": 19742.582000000766,
- "stddev": 3469.1877730562624,
- "rme": 20.928213652152785,
+ "nsPerUnit": 7109.50000000048,
+ "unitsPerSec": 140656.8675715497,
+ "min": 6388.417999998637,
+ "p50": 7109.50000000048,
+ "p75": 8348.7499999992,
+ "p99": 13643.500000000131,
+ "max": 13643.500000000131,
+ "stddev": 2694.254498966069,
+ "rme": 22.66786178912699,
"fingerprint": "a55a49f02f489524",
"extra": {
- "echoNsPerFrame": 3204.9999999999272,
+ "echoNsPerFrame": 2702.3339999996097,
"echoes": 500
}
},
@@ -483,15 +483,15 @@
"samples": 10,
"iterations": 1,
"unitsPerIteration": 500,
- "nsPerUnit": 6952.583999998751,
- "unitsPerSec": 143831.41577292408,
- "min": 6744.666000000507,
- "p50": 6952.583999998751,
- "p75": 7296.249999999418,
- "p99": 11745.416000001569,
- "max": 11745.416000001569,
- "stddev": 1518.4212144345695,
- "rme": 14.543678378105685,
+ "nsPerUnit": 5777.831999999761,
+ "unitsPerSec": 173075.29883181813,
+ "min": 5095.416000000114,
+ "p50": 5777.831999999761,
+ "p75": 6101.915999999619,
+ "p99": 9648.334000001341,
+ "max": 9648.334000001341,
+ "stddev": 1305.8687059115032,
+ "rme": 15.283778194640554,
"fingerprint": "bfc7cdd6eb4a4307",
"extra": {
"deliveredPerTick": 1
@@ -505,15 +505,15 @@
"samples": 15,
"iterations": 500,
"unitsPerIteration": 202,
- "nsPerUnit": 12.1844059405955,
- "unitsPerSec": 82072117.82629807,
- "min": 11.448851485141912,
- "p50": 12.1844059405955,
- "p75": 12.224009900990604,
- "p99": 12.790425742567473,
- "max": 12.790425742567473,
- "stddev": 0.40052177977443176,
- "rme": 1.8423991396598913,
+ "nsPerUnit": 9.364693069307965,
+ "unitsPerSec": 106784065.70284939,
+ "min": 9.061881188118956,
+ "p50": 9.364693069307965,
+ "p75": 9.462455445539643,
+ "p99": 9.862623762372923,
+ "max": 9.862623762372923,
+ "stddev": 0.23546123904046737,
+ "rme": 1.39809192303038,
"fingerprint": "7c9a76dd107ba450"
},
{
@@ -524,15 +524,15 @@
"samples": 15,
"iterations": 500,
"unitsPerIteration": 202,
- "nsPerUnit": 11.43358415841852,
- "unitsPerSec": 87461638.11316353,
- "min": 11.328386138617619,
- "p50": 11.43358415841852,
- "p75": 11.502891089107699,
- "p99": 11.690188118813923,
- "max": 11.690188118813923,
- "stddev": 0.104632140240842,
- "rme": 0.5062784629111124,
+ "nsPerUnit": 9.249584158412587,
+ "unitsPerSec": 108112968.41820616,
+ "min": 9.10272277228119,
+ "p50": 9.249584158412587,
+ "p75": 9.49174257425286,
+ "p99": 9.951732673273161,
+ "max": 9.951732673273161,
+ "stddev": 0.23106639953695585,
+ "rme": 1.3681762933832087,
"fingerprint": "280bace6f155be2c"
},
{
@@ -543,15 +543,15 @@
"samples": 15,
"iterations": 500,
"unitsPerIteration": 100,
- "nsPerUnit": 11.993320000001404,
- "unitsPerSec": 83379748.05974351,
- "min": 11.936660000010304,
- "p50": 11.993320000001404,
- "p75": 12.069160000010017,
- "p99": 17.122499999986758,
- "max": 17.122499999986758,
- "stddev": 1.3266067596912274,
- "rme": 5.912817584537055,
+ "nsPerUnit": 9.776660000006814,
+ "unitsPerSec": 102284420.24160634,
+ "min": 9.6349999999984,
+ "p50": 9.776660000006814,
+ "p75": 10.004160000007687,
+ "p99": 14.390000000003054,
+ "max": 14.390000000003054,
+ "stddev": 1.3108665204968772,
+ "rme": 7.059490221681017,
"fingerprint": "b69071333d297bb1"
},
{
@@ -562,15 +562,15 @@
"samples": 15,
"iterations": 5000,
"unitsPerIteration": 100,
- "nsPerUnit": 154.12791599999946,
- "unitsPerSec": 6488117.311597229,
- "min": 146.55958399999872,
- "p50": 154.12791599999946,
- "p75": 158.77941600000122,
- "p99": 165.52999999999884,
- "max": 165.52999999999884,
- "stddev": 5.168619999495879,
- "rme": 1.8445513498793993,
+ "nsPerUnit": 140.67883400000028,
+ "unitsPerSec": 7108389.880456345,
+ "min": 135.55808399999842,
+ "p50": 140.67883400000028,
+ "p75": 143.3590839999997,
+ "p99": 152.5570000000007,
+ "max": 152.5570000000007,
+ "stddev": 4.454989542911766,
+ "rme": 1.7438898403047238,
"fingerprint": "676d88d0bfb4ad41"
},
{
@@ -581,15 +581,15 @@
"samples": 15,
"iterations": 5000,
"unitsPerIteration": 100,
- "nsPerUnit": 149.46583200000168,
- "unitsPerSec": 6690492.31265102,
- "min": 143.65191799999957,
- "p50": 149.46583200000168,
- "p75": 152.9722499999989,
- "p99": 165.90750000000116,
- "max": 165.90750000000116,
- "stddev": 5.6112197417787275,
- "rme": 2.0597914907649906,
+ "nsPerUnit": 120.1755000000012,
+ "unitsPerSec": 8321163.63152215,
+ "min": 118.58216600000014,
+ "p50": 120.1755000000012,
+ "p75": 122.0997499999994,
+ "p99": 123.5669999999991,
+ "max": 123.5669999999991,
+ "stddev": 1.652281308012248,
+ "rme": 0.7583380445626672,
"fingerprint": "b337cd1a8dc7167c"
},
{
@@ -600,15 +600,15 @@
"samples": 15,
"iterations": 5000,
"unitsPerIteration": 100,
- "nsPerUnit": 165.20583400000032,
- "unitsPerSec": 6053055.0028880825,
- "min": 150.3346679999995,
- "p50": 165.20583400000032,
- "p75": 166.94049999999697,
- "p99": 171.78383399999802,
- "max": 171.78383399999802,
- "stddev": 5.4653637538624,
- "rme": 1.8510389709829977,
+ "nsPerUnit": 149.39058400000067,
+ "unitsPerSec": 6693862.311964692,
+ "min": 147.78999999999724,
+ "p50": 149.39058400000067,
+ "p75": 150.11708400000134,
+ "p99": 151.05033399999957,
+ "max": 151.05033399999957,
+ "stddev": 1.0079738821222621,
+ "rme": 0.3738669307485422,
"fingerprint": "20f135a5bea53308"
},
{
@@ -619,15 +619,15 @@
"samples": 10,
"iterations": 50,
"unitsPerIteration": 1,
- "nsPerUnit": 115580.84000000235,
- "unitsPerSec": 8651.953039967348,
- "min": 105200.00000000437,
- "p50": 115580.84000000235,
- "p75": 139878.33999999566,
- "p99": 159452.5000000067,
- "max": 159452.5000000067,
- "stddev": 19157.147425139436,
- "rme": 10.746582614724142,
+ "nsPerUnit": 108374.99999997817,
+ "unitsPerSec": 9227.220299886518,
+ "min": 95580.00000000902,
+ "p50": 108374.99999997817,
+ "p75": 152255.00000000466,
+ "p99": 154151.6799999954,
+ "max": 154151.6799999954,
+ "stddev": 24932.519962439186,
+ "rme": 14.751150268799465,
"fingerprint": "a3c403e8cf3d2362"
},
{
@@ -638,15 +638,15 @@
"samples": 10,
"iterations": 10,
"unitsPerIteration": 100,
- "nsPerUnit": 2841.292000000976,
- "unitsPerSec": 351952.56242570514,
- "min": 2827.7920000000445,
- "p50": 2841.292000000976,
- "p75": 3026.4589999987948,
- "p99": 4419.417000000976,
- "max": 4419.417000000976,
- "stddev": 494.3932024287442,
- "rme": 11.536257270551028,
+ "nsPerUnit": 2744.6250000011787,
+ "unitsPerSec": 364348.49933946185,
+ "min": 2374.1659999996045,
+ "p50": 2744.6250000011787,
+ "p75": 2849.1249999988213,
+ "p99": 5954.04199999939,
+ "max": 5954.04199999939,
+ "stddev": 1178.4850727028777,
+ "rme": 26.639875240350523,
"fingerprint": "cdbb3097d2620387"
},
{
@@ -657,15 +657,15 @@
"samples": 5,
"iterations": 1,
"unitsPerIteration": 100,
- "nsPerUnit": 313122.0899999971,
- "unitsPerSec": 3193.642454289984,
- "min": 312245.0000000026,
- "p50": 313122.0899999971,
- "p75": 313992.9200000006,
- "p99": 314757.9199999927,
- "max": 314757.9199999927,
- "stddev": 1037.8471741750318,
- "rme": 0.41120761422965757,
+ "nsPerUnit": 309440.0000000132,
+ "unitsPerSec": 3231.6442605996554,
+ "min": 296660.40999998583,
+ "p50": 309440.0000000132,
+ "p75": 310119.5899999948,
+ "p99": 316198.7500000032,
+ "max": 316198.7500000032,
+ "stddev": 8424.92974826301,
+ "rme": 3.417108902473781,
"fingerprint": "b5ea99d41379703a",
"extra": {
"maxInFlight": 100,
@@ -680,15 +680,15 @@
"samples": 10,
"iterations": 500,
"unitsPerIteration": 1,
- "nsPerUnit": 59.5820000016829,
- "unitsPerSec": 16783592.359634705,
- "min": 54.166000001714565,
- "p50": 59.5820000016829,
- "p75": 80.33400000203983,
- "p99": 196.8319999978121,
- "max": 196.8319999978121,
- "stddev": 50.3828465874903,
- "rme": 41.91424982929565,
+ "nsPerUnit": 53.832000001420965,
+ "unitsPerSec": 18576311.48710068,
+ "min": 46.16799999712384,
+ "p50": 53.832000001420965,
+ "p75": 77.75000000037835,
+ "p99": 191.8340000011085,
+ "max": 191.8340000011085,
+ "stddev": 54.687356864877586,
+ "rme": 46.904389067766864,
"fingerprint": "9ff5c5438ad91c58"
},
{
@@ -699,15 +699,15 @@
"samples": 10,
"iterations": 50,
"unitsPerIteration": 1,
- "nsPerUnit": 110059.15999998251,
- "unitsPerSec": 9086.022462829616,
- "min": 101506.67999998404,
- "p50": 110059.15999998251,
- "p75": 134608.31999997026,
- "p99": 141611.66000001685,
- "max": 141611.66000001685,
- "stddev": 16027.272132430357,
- "rme": 9.704586055644473,
+ "nsPerUnit": 108015.00000001397,
+ "unitsPerSec": 9257.97342961506,
+ "min": 84652.50000001106,
+ "p50": 108015.00000001397,
+ "p75": 118660.00000001804,
+ "p99": 129098.32000001188,
+ "max": 129098.32000001188,
+ "stddev": 15121.620477012259,
+ "rme": 10.147003353355622,
"fingerprint": "7f94b9715b2feae5"
},
{
@@ -718,15 +718,15 @@
"samples": 15,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 6023.960000002262,
- "unitsPerSec": 166003.75832502614,
- "min": 5835.624999999709,
- "p50": 6023.960000002262,
- "p75": 6141.665000004649,
- "p99": 6401.875000001383,
- "max": 6401.875000001383,
- "stddev": 172.0851554641179,
- "rme": 1.5746149558214655,
+ "nsPerUnit": 4823.544999999285,
+ "unitsPerSec": 207316.40318482532,
+ "min": 4450.834999997824,
+ "p50": 4823.544999999285,
+ "p75": 5703.334999998333,
+ "p99": 11530.624999995782,
+ "max": 11530.624999995782,
+ "stddev": 1762.887503943029,
+ "rme": 17.79264409146672,
"fingerprint": "b9c386a54222793e"
},
{
@@ -737,15 +737,15 @@
"samples": 25,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 1270.4149999990477,
- "unitsPerSec": 787144.3583401877,
- "min": 1127.9149999973015,
- "p50": 1270.4149999990477,
- "p75": 1542.5000000050204,
- "p99": 2423.9550000038435,
- "max": 2423.9550000038435,
- "stddev": 341.89151460436716,
- "rme": 9.951776993021912,
+ "nsPerUnit": 960.8349999962229,
+ "unitsPerSec": 1040761.4210597356,
+ "min": 865.0000000034197,
+ "p50": 960.8349999962229,
+ "p75": 1110.2050000044983,
+ "p99": 1717.7100000026257,
+ "max": 1717.7100000026257,
+ "stddev": 245.79811265733707,
+ "rme": 9.480316825580308,
"fingerprint": "933240fc2231b44f"
},
{
@@ -756,15 +756,15 @@
"samples": 25,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 1575.8349999941856,
- "unitsPerSec": 634584.2045669056,
- "min": 1491.875000001528,
- "p50": 1575.8349999941856,
- "p75": 1620.4150000066875,
- "p99": 2167.5000000050204,
- "max": 2167.5000000050204,
- "stddev": 168.0384660702305,
- "rme": 4.22866359830096,
+ "nsPerUnit": 1354.17000000416,
+ "unitsPerSec": 738459.720712265,
+ "min": 1198.1250000008004,
+ "p50": 1354.17000000416,
+ "p75": 1654.1649999999208,
+ "p99": 2711.039999994682,
+ "max": 2711.039999994682,
+ "stddev": 363.2171470033063,
+ "rme": 9.88845146101705,
"fingerprint": "801510758c8d4211"
},
{
@@ -775,17 +775,124 @@
"samples": 25,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 3864.1649999954097,
- "unitsPerSec": 258788.12110797234,
- "min": 3473.3300000061718,
- "p50": 3864.1649999954097,
- "p75": 4041.0399999927904,
- "p99": 15699.375000003783,
- "max": 15699.375000003783,
- "stddev": 2435.5650720590793,
- "rme": 22.41472453702982,
+ "nsPerUnit": 2708.7500000016007,
+ "unitsPerSec": 369173.9732346688,
+ "min": 2428.954999995767,
+ "p50": 2708.7500000016007,
+ "p75": 3280.2100000026257,
+ "p99": 11275.210000003428,
+ "max": 11275.210000003428,
+ "stddev": 1742.5865610982764,
+ "rme": 22.182617288967,
"fingerprint": "b6263c6c4e3cf548"
},
+ {
+ "name": "subscription/clearinghouseState_frame_dispatch_e2e",
+ "group": "subscription",
+ "description": "End-to-end dispatch of a raw clearinghouseState frame (mainnet-sized payload) from socket JSON text to the subscribed listener",
+ "unit": "frame",
+ "samples": 10,
+ "iterations": 1,
+ "unitsPerIteration": 500,
+ "nsPerUnit": 7438.249999999244,
+ "unitsPerSec": 134440.2245151886,
+ "min": 6718.250000001717,
+ "p50": 7438.249999999244,
+ "p75": 7671.99999999866,
+ "p99": 11050.834000001487,
+ "max": 11050.834000001487,
+ "stddev": 1192.2875398545914,
+ "rme": 10.97995931369781,
+ "fingerprint": "d20b08c4484d84d6",
+ "extra": {
+ "deliveredPerTick": 1
+ }
+ },
+ {
+ "name": "subscription/spotState_frame_dispatch_e2e",
+ "group": "subscription",
+ "description": "End-to-end dispatch of a raw spotState frame (mainnet-sized payload) from socket JSON text to the subscribed listener",
+ "unit": "frame",
+ "samples": 10,
+ "iterations": 1,
+ "unitsPerIteration": 500,
+ "nsPerUnit": 4878.500000002532,
+ "unitsPerSec": 204981.03925376263,
+ "min": 4541.08400000041,
+ "p50": 4878.500000002532,
+ "p75": 5122.667999999976,
+ "p99": 7619.750000001659,
+ "max": 7619.750000001659,
+ "stddev": 912.253158591081,
+ "rme": 12.70833503648206,
+ "fingerprint": "50d26b6e8352c5ca",
+ "extra": {
+ "deliveredPerTick": 1
+ }
+ },
+ {
+ "name": "subscription/webData3_frame_dispatch_e2e",
+ "group": "subscription",
+ "description": "End-to-end dispatch of a raw webData3 frame (mainnet-sized payload) from socket JSON text to the subscribed listener",
+ "unit": "frame",
+ "samples": 10,
+ "iterations": 1,
+ "unitsPerIteration": 500,
+ "nsPerUnit": 1301.4159999984258,
+ "unitsPerSec": 768393.8110498177,
+ "min": 1232.7499999992142,
+ "p50": 1301.4159999984258,
+ "p75": 1361.165999998775,
+ "p99": 1372.166000001016,
+ "max": 1372.166000001016,
+ "stddev": 47.072295463296435,
+ "rme": 2.5594251411808657,
+ "fingerprint": "9ec3492b4bbaf905",
+ "extra": {
+ "deliveredPerTick": 1
+ }
+ },
+ {
+ "name": "subscription/user_dispatch_15_users",
+ "group": "subscription",
+ "description": "clearinghouseState dispatch with 14 users on one channel; a frame for one user must run exactly one listener (BY_USER route)",
+ "unit": "frame",
+ "samples": 10,
+ "iterations": 1,
+ "unitsPerIteration": 500,
+ "nsPerUnit": 7516.4999999979045,
+ "unitsPerSec": 133040.64391675364,
+ "min": 6748.834000001807,
+ "p50": 7516.4999999979045,
+ "p75": 7665.750000000116,
+ "p99": 10238.084000000526,
+ "max": 10238.084000000526,
+ "stddev": 981.1233506382131,
+ "rme": 9.14454005744354,
+ "fingerprint": "14c24cd88274c2dd",
+ "extra": {
+ "deliveredPerTick": 1
+ }
+ },
+ {
+ "name": "subscription/subscribe_user_trio",
+ "group": "subscription",
+ "description": "Cost of establishing the three account subscriptions (clearinghouseState + spotState + webData3) for one user — the calls a feed makes at session start and on every reconnect",
+ "unit": "subscription",
+ "samples": 25,
+ "iterations": 1,
+ "unitsPerIteration": 3,
+ "nsPerUnit": 14458.333333095652,
+ "unitsPerSec": 69164.26513081999,
+ "min": 11235.999999674581,
+ "p50": 14458.333333095652,
+ "p75": 16416.666666676367,
+ "p99": 33930.33333365262,
+ "max": 33930.33333365262,
+ "stddev": 4974.277832924477,
+ "rme": 12.878367672687286,
+ "fingerprint": "32cc935cab71f6c2"
+ },
{
"name": "signing/sign_l1_action_order_1_wasm",
"group": "signing",
@@ -794,15 +901,15 @@
"samples": 10,
"iterations": 50,
"unitsPerIteration": 1,
- "nsPerUnit": 74134.15999999415,
- "unitsPerSec": 13489.058215538947,
- "min": 72819.99999999243,
- "p50": 74134.15999999415,
- "p75": 75254.99999999738,
- "p99": 76576.65999999153,
- "max": 76576.65999999153,
- "stddev": 1101.2580524547807,
- "rme": 1.058303789338951,
+ "nsPerUnit": 60912.5000000131,
+ "unitsPerSec": 16416.99158628828,
+ "min": 57422.499999993306,
+ "p50": 60912.5000000131,
+ "p75": 62724.99999999126,
+ "p99": 63354.160000017146,
+ "max": 63354.160000017146,
+ "stddev": 1969.7820877379763,
+ "rme": 2.309674035901903,
"fingerprint": "8fb87a5afd711771"
},
{
@@ -813,15 +920,15 @@
"samples": 15,
"iterations": 200,
"unitsPerIteration": 1,
- "nsPerUnit": 3905.000000004293,
- "unitsPerSec": 256081.94622250978,
- "min": 3785.4150000021036,
- "p50": 3905.000000004293,
- "p75": 4017.2900000015943,
- "p99": 17088.544999996884,
- "max": 17088.544999996884,
- "stddev": 3401.5252488604833,
- "rme": 39.22681242792038,
+ "nsPerUnit": 2910.0000000016735,
+ "unitsPerSec": 343642.61168365116,
+ "min": 2813.125000002401,
+ "p50": 2910.0000000016735,
+ "p75": 2994.584999996732,
+ "p99": 3281.25,
+ "max": 3281.25,
+ "stddev": 129.98729388610943,
+ "rme": 2.4508746813869045,
"fingerprint": "d6f28468a96f8253"
},
{
@@ -832,15 +939,15 @@
"samples": 15,
"iterations": 2000,
"unitsPerIteration": 1,
- "nsPerUnit": 982.8335000001971,
- "unitsPerSec": 1017466.3358542413,
- "min": 963.9794999993684,
- "p50": 982.8335000001971,
- "p75": 999.1664999997738,
- "p99": 1032.749999999396,
- "max": 1032.749999999396,
- "stddev": 19.972882830251695,
- "rme": 1.1182720617328907,
+ "nsPerUnit": 764.4169999994118,
+ "unitsPerSec": 1308186.5003012354,
+ "min": 714.125000000422,
+ "p50": 764.4169999994118,
+ "p75": 773.50000000024,
+ "p99": 1848.9584999997533,
+ "max": 1848.9584999997533,
+ "stddev": 283.4271115341867,
+ "rme": 18.959535354559005,
"fingerprint": "6f8c9d3fb30f33bb"
},
{
@@ -851,15 +958,15 @@
"samples": 15,
"iterations": 100,
"unitsPerIteration": 100,
- "nsPerUnit": 335.0249999999505,
- "unitsPerSec": 2984851.8767260583,
- "min": 327.92090000002645,
- "p50": 335.0249999999505,
- "p75": 338.2332999999562,
- "p99": 573.4709000000294,
- "max": 573.4709000000294,
- "stddev": 61.89677670624819,
- "rme": 9.786020212318455,
+ "nsPerUnit": 249.1250000000946,
+ "unitsPerSec": 4014049.172100834,
+ "min": 232.61250000014115,
+ "p50": 249.1250000000946,
+ "p75": 252.0915999999488,
+ "p99": 286.9957999999315,
+ "max": 286.9957999999315,
+ "stddev": 12.823740296962635,
+ "rme": 2.8572783425183594,
"fingerprint": "00eb2dd7c8a228b1"
},
{
@@ -870,15 +977,15 @@
"samples": 15,
"iterations": 5000,
"unitsPerIteration": 1,
- "nsPerUnit": 1415.3416000001016,
- "unitsPerSec": 706543.2119001718,
- "min": 1350.4750000000058,
- "p50": 1415.3416000001016,
- "p75": 2093.491599999834,
- "p99": 3096.599999999671,
- "max": 3096.599999999671,
- "stddev": 506.0853080023928,
- "rme": 16.82715303946655,
+ "nsPerUnit": 913.0582000001597,
+ "unitsPerSec": 1095220.4361122053,
+ "min": 847.399999999834,
+ "p50": 913.0582000001597,
+ "p75": 932.9583999999159,
+ "p99": 1285.533199999918,
+ "max": 1285.533199999918,
+ "stddev": 100.98934347170832,
+ "rme": 5.971451159971633,
"fingerprint": "d2b32bb77a5788e5"
},
{
@@ -889,15 +996,15 @@
"samples": 15,
"iterations": 5000,
"unitsPerIteration": 1,
- "nsPerUnit": 1547.899999999936,
- "unitsPerSec": 646036.5656696436,
- "min": 1444.1834000001109,
- "p50": 1547.899999999936,
- "p75": 2152.6584000002913,
- "p99": 2327.8166000000056,
- "max": 2327.8166000000056,
- "stddev": 331.10024613463827,
- "rme": 10.66149705091614,
+ "nsPerUnit": 1032.574999999997,
+ "unitsPerSec": 968452.6547708426,
+ "min": 979.0918000002422,
+ "p50": 1032.574999999997,
+ "p75": 1063.1915999998455,
+ "p99": 1372.0915999998397,
+ "max": 1372.0915999998397,
+ "stddev": 91.91567553022611,
+ "rme": 4.814397855156273,
"fingerprint": "afdcbc4159893d3c"
}
]
diff --git a/tests/perf/run.ts b/tests/perf/run.ts
index 92324f5e..50a5c50e 100644
--- a/tests/perf/run.ts
+++ b/tests/perf/run.ts
@@ -28,11 +28,13 @@ import { formatNs, type PerfReport, registeredScenarios, runScenario, type Scena
// --- Scenario registration -------------------------------------------------
// Importing a module registers its scenarios. Keep this list alphabetical.
import "./scenarios/data_parse.ts";
+import "./scenarios/fast_asset_ctxs.ts";
import "./scenarios/signing.ts";
import "./scenarios/subscription.ts";
import "./scenarios/symbol_converter.ts";
import "./scenarios/transaction.ts";
import "./scenarios/transport.ts";
+import "./scenarios/user_account_channels.ts";
/** Reads a `--flag value` pair from the argument list. */
function flag(args: readonly string[], name: string): string | undefined {
diff --git a/tests/perf/scenarios/fast_asset_ctxs.ts b/tests/perf/scenarios/fast_asset_ctxs.ts
new file mode 100644
index 00000000..15857179
--- /dev/null
+++ b/tests/perf/scenarios/fast_asset_ctxs.ts
@@ -0,0 +1,113 @@
+/**
+ * `fastAssetCtxs` decompression throughput: the heaviest per-frame work in the SDK.
+ *
+ * Every frame on this channel arrives as base64 + raw DEFLATE (RFC 1951) and must be decoded,
+ * inflated and JSON-parsed before the listener sees it. Nothing else in the receive path does
+ * per-frame work on that scale, and until this scenario existed the whole pipeline was unmeasured —
+ * a rework of the decode path could regress throughput without any gate noticing.
+ *
+ * Two shapes are measured because they stress different halves of the pipeline. The snapshot
+ * (the first frame after subscribing, every coin present) is dominated by inflate and JSON.parse
+ * throughput. The delta (later frames, only the coins that moved) is dominated by fixed per-frame
+ * overhead, so it is the one that shows any per-frame machinery the decode path allocates.
+ *
+ * These run against {@linkcode MockWebSocket} with pre-compressed frame text, so the measurement
+ * starts at the socket and no network or compression cost is billed to the scenario.
+ * @module
+ */
+
+import { deflateRawSync } from "node:zlib";
+import { SubscriptionClient, WebSocketTransport } from "@bloxwap/hyperliquid";
+import { scenario } from "../_harness.ts";
+import { installMockWebSocket, lastMockWebSocket, type MockWebSocket, restoreWebSocket } from "../_helpers.ts";
+
+/** Coins in a full snapshot frame — approximately a live perp universe. */
+const SNAPSHOT_COINS = 320;
+/** Coins in a delta frame — only the assets whose price moved since the last push. */
+const DELTA_COINS = 6;
+
+/**
+ * Frames per measured sample, sized per shape so both samples take comparable wall-clock: the
+ * snapshot frame costs over an order of magnitude more than the delta, and too few delta frames
+ * per sample leaves the measurement dominated by scheduling variance rather than decode cost.
+ */
+const SNAPSHOT_FRAMES = 100;
+const DELTA_FRAMES = 2000;
+
+interface DecompressContext {
+ transport: WebSocketTransport;
+ socket: MockWebSocket;
+ /** `onComplete` is armed per sample and fired by the listener on the final frame. */
+ counter: { delivered: number; onComplete: (() => void) | undefined };
+ frameText: string;
+}
+
+/** Builds the wire frame the server would push: base64 of raw-DEFLATE'd JSON, wrapped in a channel envelope. */
+function buildFrame(coinCount: number): string {
+ const ctxs: Record = {};
+ for (let i = 0; i < coinCount; i++) {
+ ctxs[`COIN${i}`] = { markPx: (1000 + i * 1.37).toFixed(4), midPx: (1000 + i * 1.36).toFixed(4) };
+ }
+ const compressed = Buffer.from(deflateRawSync(Buffer.from(JSON.stringify(ctxs)))).toString("base64");
+ return JSON.stringify({ channel: "fastAssetCtxs", data: compressed });
+}
+
+/**
+ * The listener chain is asynchronous (decompression is queued so frames stay in arrival order),
+ * so a sample is only complete once every injected frame has been delivered.
+ */
+function makeScenario(name: string, coinCount: number, frames: number, description: string): void {
+ scenario({
+ name,
+ group: "subscription",
+ description,
+ unit: "frame",
+ unitsPerIteration: frames,
+ iterations: 1,
+ setup: async (): Promise => {
+ installMockWebSocket();
+ const transport = new WebSocketTransport({ url: "wss://perf.local/ws" });
+ await transport.ready();
+ const socket = lastMockWebSocket();
+
+ const counter: DecompressContext["counter"] = { delivered: 0, onComplete: undefined };
+ const client = new SubscriptionClient({ transport });
+ await client.fastAssetCtxs(() => {
+ if (++counter.delivered === frames) counter.onComplete?.();
+ });
+
+ return { transport, socket, counter, frameText: buildFrame(coinCount) };
+ },
+ run: async ({ socket, counter, frameText }: DecompressContext) => {
+ counter.delivered = 0;
+ // Settles on the last delivery. Timer-based draining would bill a macrotask hop to every
+ // sample, which on the small delta frame is larger than the work being measured.
+ const drained = new Promise((resolve) => {
+ counter.onComplete = resolve;
+ });
+ for (let i = 0; i < frames; i++) {
+ socket.dispatchEvent(new MessageEvent("message", { data: frameText }));
+ }
+ await drained;
+ return { deliveredPerTick: counter.delivered / frames };
+ },
+ teardown: ({ transport }: DecompressContext) => {
+ transport.close();
+ restoreWebSocket();
+ },
+ });
+}
+
+makeScenario(
+ "fast_asset_ctxs_snapshot_decompress",
+ SNAPSHOT_COINS,
+ SNAPSHOT_FRAMES,
+ `End-to-end fastAssetCtxs delivery of a full ${SNAPSHOT_COINS}-coin snapshot frame: base64 decode, raw inflate and JSON parse`,
+);
+
+makeScenario(
+ "fast_asset_ctxs_delta_decompress",
+ DELTA_COINS,
+ DELTA_FRAMES,
+ `End-to-end fastAssetCtxs delivery of a ${DELTA_COINS}-coin delta frame: dominated by fixed per-frame decode overhead`,
+);
diff --git a/tests/perf/scenarios/user_account_channels.ts b/tests/perf/scenarios/user_account_channels.ts
new file mode 100644
index 00000000..904a8600
--- /dev/null
+++ b/tests/perf/scenarios/user_account_channels.ts
@@ -0,0 +1,249 @@
+/**
+ * User-account channel dispatch: the frames a balance/position feed lives on.
+ *
+ * The channels that replaced the retired aggregate `webData2` — `clearinghouseState`
+ * (main-perp clearing), `spotState` (spot balances), and `webData3` (account metadata incl.
+ * abstraction mode) — are the hot path of every consumer that shows a user their money, so
+ * their end-to-end dispatch cost is benchmarked directly: raw JSON text on the socket through
+ * parse, route-key derivation, and user-filtered delivery to the subscribed listener.
+ *
+ * `user_dispatch_50_users` proves the `BY_USER` route: with K users subscribed on one channel,
+ * a frame for one user runs exactly one listener (`deliveredPerTick` = 1) — the property that
+ * lets consumers delete their hand-rolled stale-frame guards.
+ *
+ * `subscribe_user_trio` measures establishing the three account subscriptions for one user —
+ * the calls a feed makes at session start and on every reconnect.
+ * @module
+ */
+
+import { SubscriptionClient, WebSocketTransport } from "@bloxwap/hyperliquid";
+import { scenario } from "../_harness.ts";
+import { installMockWebSocket, lastMockWebSocket, type MockWebSocket, restoreWebSocket } from "../_helpers.ts";
+
+/** Frames injected per measured sample. */
+const FRAMES = 500;
+/** Open positions in the clearinghouseState frame. */
+const POSITIONS = 10;
+/** Token balances in the spotState frame. */
+const BALANCES = 20;
+/**
+ * Users sharing the channel in the BY_USER routing scenario, in addition to the target user.
+ * The server tracks at most 15 unique users per connection (mirrored client-side by
+ * `MAX_UNIQUE_USERS`), so 14 + the target saturates the allowed crowd exactly.
+ */
+const USER_SUBSCRIPTIONS = 14;
+
+const USER = "0x1111111111111111111111111111111111111111" as const;
+
+interface FrameDispatchContext {
+ transport: WebSocketTransport;
+ socket: MockWebSocket;
+ counter: { delivered: number };
+ /** The raw frame text, serialized once in setup. */
+ frameText: string;
+}
+
+/** One open position, shaped like the mainnet `clearinghouseState` payload. */
+function position(i: number): unknown {
+ return {
+ type: "oneWay",
+ position: {
+ coin: `PERF${i}`,
+ szi: i % 2 === 0 ? "1.5" : "-0.75",
+ leverage: { type: "cross", value: 20 },
+ entryPx: `${30_000 + i}`,
+ positionValue: `${45_000 + i * 100}`,
+ unrealizedPnl: `${(i - POSITIONS / 2) * 12.5}`,
+ returnOnEquity: "0.0313",
+ liquidationPx: null,
+ marginUsed: `${2_250 + i * 5}`,
+ maxLeverage: 25,
+ cumFunding: { allTime: "18.44", sinceOpen: "-2.11", sinceChange: "0.03" },
+ },
+ };
+}
+
+/** The three account frames, serialized once so a sample measures dispatch, not stringify. */
+const FRAME_TEXTS = {
+ clearinghouseState: JSON.stringify({
+ channel: "clearinghouseState",
+ data: {
+ dex: "",
+ user: USER,
+ clearinghouseState: {
+ marginSummary: {
+ accountValue: "152340.77",
+ totalNtlPos: "98450.12",
+ totalRawUsd: "121100.55",
+ totalMarginUsed: "4922.51",
+ },
+ crossMarginSummary: {
+ accountValue: "152340.77",
+ totalNtlPos: "98450.12",
+ totalRawUsd: "121100.55",
+ totalMarginUsed: "4922.51",
+ },
+ crossMaintenanceMarginUsed: "1230.63",
+ withdrawable: "147418.26",
+ assetPositions: Array.from({ length: POSITIONS }, (_, i) => position(i)),
+ time: 1_700_000_000_000,
+ },
+ },
+ }),
+ spotState: JSON.stringify({
+ channel: "spotState",
+ data: {
+ user: USER,
+ spotState: {
+ balances: Array.from({ length: BALANCES }, (_, i) => ({
+ coin: i === 0 ? "USDC" : `TOKEN${i}`,
+ token: i,
+ total: `${1_000 + i * 7.25}`,
+ hold: `${i % 3}`,
+ entryNtl: `${900 + i * 6.5}`,
+ })),
+ },
+ },
+ }),
+ webData3: JSON.stringify({
+ channel: "webData3",
+ data: {
+ userState: {
+ agentAddress: null,
+ agentValidUntil: null,
+ cumLedger: "152340.77",
+ serverTime: 1_700_000_000_000,
+ isVault: false,
+ user: USER,
+ abstraction: "disabled",
+ },
+ perpDexStates: [{ totalVaultEquity: "0.0" }],
+ },
+ }),
+} as const;
+
+type AccountChannel = keyof typeof FRAME_TEXTS;
+
+/**
+ * Registers an end-to-end dispatch scenario for one account channel: subscribes one user,
+ * then times raw frame text through the socket's parse and routing to the listener.
+ * `deliveredPerTick` must stay 1 — below 1 means the route or the method's own filter
+ * dropped a frame meant for us.
+ */
+function frameDispatchScenario(
+ channel: AccountChannel,
+ subscribe: (client: SubscriptionClient, listener: () => void) => Promise,
+): void {
+ scenario({
+ name: `subscription/${channel}_frame_dispatch_e2e`,
+ group: "subscription",
+ description:
+ `End-to-end dispatch of a raw ${channel} frame ` +
+ `(mainnet-sized payload) from socket JSON text to the subscribed listener`,
+ unit: "frame",
+ unitsPerIteration: FRAMES,
+ iterations: 1,
+ samples: 10,
+ setup: async (): Promise => {
+ installMockWebSocket();
+ const transport = new WebSocketTransport({ url: "wss://perf.local/ws" });
+ await transport.ready();
+ const socket = lastMockWebSocket();
+
+ const counter = { delivered: 0 };
+ const client = new SubscriptionClient({ transport });
+ await subscribe(client, () => counter.delivered++);
+
+ return { transport, socket, counter, frameText: FRAME_TEXTS[channel] };
+ },
+ run: ({ socket, counter, frameText }: FrameDispatchContext) => {
+ counter.delivered = 0;
+ for (let i = 0; i < FRAMES; i++) {
+ socket.dispatchEvent(new MessageEvent("message", { data: frameText }));
+ }
+ return { deliveredPerTick: counter.delivered / FRAMES };
+ },
+ teardown: ({ transport }: FrameDispatchContext) => {
+ transport.close();
+ restoreWebSocket();
+ },
+ });
+}
+
+frameDispatchScenario("clearinghouseState", (client, listener) => client.clearinghouseState({ user: USER }, listener));
+
+frameDispatchScenario("spotState", (client, listener) => client.spotState({ user: USER }, listener));
+
+frameDispatchScenario("webData3", (client, listener) => client.webData3({ user: USER }, listener));
+
+scenario({
+ name: "subscription/user_dispatch_15_users",
+ group: "subscription",
+ description:
+ `clearinghouseState dispatch with ${USER_SUBSCRIPTIONS} users on one channel; ` +
+ `a frame for one user must run exactly one listener (BY_USER route)`,
+ unit: "frame",
+ unitsPerIteration: FRAMES,
+ iterations: 1,
+ samples: 10,
+ setup: async (): Promise => {
+ installMockWebSocket();
+ const transport = new WebSocketTransport({ url: "wss://perf.local/ws" });
+ await transport.ready();
+ const socket = lastMockWebSocket();
+
+ const counter = { delivered: 0 };
+ const client = new SubscriptionClient({ transport });
+ for (let i = 0; i < USER_SUBSCRIPTIONS; i++) {
+ const user = `0x${String(i + 2).padStart(40, "0")}` as `0x${string}`;
+ await client.clearinghouseState({ user }, () => counter.delivered++);
+ }
+ // The frame targets USER, distinct from all of the above: exactly one subscription is
+ // genuinely interested, so `deliveredPerTick` must be 1 despite the crowd on the channel.
+ await client.clearinghouseState({ user: USER }, () => counter.delivered++);
+
+ return { transport, socket, counter, frameText: FRAME_TEXTS.clearinghouseState };
+ },
+ run: ({ socket, counter, frameText }: FrameDispatchContext) => {
+ counter.delivered = 0;
+ for (let i = 0; i < FRAMES; i++) {
+ socket.dispatchEvent(new MessageEvent("message", { data: frameText }));
+ }
+ return { deliveredPerTick: counter.delivered / FRAMES };
+ },
+ teardown: ({ transport }: FrameDispatchContext) => {
+ transport.close();
+ restoreWebSocket();
+ },
+});
+
+scenario({
+ name: "subscription/subscribe_user_trio",
+ group: "subscription",
+ description:
+ "Cost of establishing the three account subscriptions (clearinghouseState + spotState + webData3) " +
+ "for one user — the calls a feed makes at session start and on every reconnect",
+ unit: "subscription",
+ unitsPerIteration: 3,
+ iterations: 1,
+ samples: 25,
+ warmupSamples: 3,
+ setup: () => {
+ installMockWebSocket();
+ },
+ run: async () => {
+ // A fresh transport per sample: subscription state accumulates, and the per-subscribe
+ // cost this scenario measures is a function of how many already exist.
+ const transport = new WebSocketTransport({ url: "wss://perf.local/ws" });
+ await transport.ready();
+ const client = new SubscriptionClient({ transport });
+
+ await client.clearinghouseState({ user: USER }, () => {});
+ await client.spotState({ user: USER }, () => {});
+ await client.webData3({ user: USER }, () => {});
+ transport.close();
+ },
+ teardown: () => {
+ restoreWebSocket();
+ },
+});
diff --git a/tests/transport/_polyfills.test.ts b/tests/transport/_polyfills.test.ts
index 1ec79c20..e71425d4 100644
--- a/tests/transport/_polyfills.test.ts
+++ b/tests/transport/_polyfills.test.ts
@@ -2,11 +2,11 @@
* Tests for the platform shims: the native pass-throughs used on Node/Bun/browser, and the
* fallback implementations selected on platforms missing the API (mainly React Native).
*
- * The fallbacks are chosen once at module evaluation, so covering them takes a fresh module
- * instance: the platform globals are deleted and the module is re-imported through a
- * cache-busting query string (`_polyfills.ts?…`), which Bun treats as a distinct module record.
- * The globals are restored before the test ends, and the re-import never touches the instance
- * the rest of the SDK already holds, so no other test observes the simulated platform.
+ * The fallbacks for DOMException/CustomEvent are chosen once at module evaluation, so covering
+ * them takes a fresh module instance: the platform globals are deleted and the module is
+ * re-imported through a cache-busting query string (`_polyfills.ts?…`), which Bun treats as a
+ * distinct module record. `Promise_.withResolvers` dispatches at call time, so its fallback can
+ * be exercised on the primary module by temporarily deleting `Promise.withResolvers`.
* @module
*/
@@ -27,6 +27,19 @@ describe("platform shims on a full platform", () => {
await expect(promise).rejects.toThrow("nope");
});
+ test("Promise_.withResolvers() falls back when Promise.withResolvers is missing", async () => {
+ const original = Promise.withResolvers;
+ delete (Promise as unknown as Record).withResolvers;
+ try {
+ const { promise, resolve, reject } = Promise_.withResolvers();
+ expect(typeof reject).toBe("function");
+ resolve(7);
+ expect(await promise).toBe(7);
+ } finally {
+ Promise.withResolvers = original;
+ }
+ });
+
test("DOMException_ and CustomEvent_ are the native classes", () => {
expect(DOMException_).toBe(globalThis.DOMException);
expect(CustomEvent_).toBe(globalThis.CustomEvent);
diff --git a/tests/transport/http/mod.test.ts b/tests/transport/http/mod.test.ts
index 19181c37..6e26fc2b 100644
--- a/tests/transport/http/mod.test.ts
+++ b/tests/transport/http/mod.test.ts
@@ -172,6 +172,25 @@ describe("HttpTransport", () => {
await assertRejects(() => transport.request("info", {}), HttpRequestError);
});
+ test("non-200 status tolerates a body that fails to read", async () => {
+ // Covers `response.text().catch(() => undefined)` on the error path.
+ mockFetch(
+ () =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.error(new Error("body gone"));
+ },
+ }),
+ { status: 500, headers: { "Content-Type": "text/plain" } },
+ ),
+ );
+
+ const transport = new HttpTransport();
+ const error = await assertRejects(() => transport.request("info", {}), HttpRequestError);
+ assertEquals(error.status, 500);
+ });
+
test("invalid Content-Type throws HttpRequestError", async () => {
mockFetch(() => new Response("", { status: 200, headers: { "Content-Type": "text/html" } }));
diff --git a/tests/transport/websocket/_events.test.ts b/tests/transport/websocket/_events.test.ts
index 23dfc47f..956df174 100644
--- a/tests/transport/websocket/_events.test.ts
+++ b/tests/transport/websocket/_events.test.ts
@@ -265,5 +265,241 @@ describe("HyperliquidEventTarget", () => {
assertEquals(calls, 1);
});
+
+ test("addEventListener({ once: true }) fires once then detaches (function listener)", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+
+ let calls = 0;
+ target.addEventListener("testChannel", () => calls++, { once: true });
+
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: { n: 1 } }));
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: { n: 2 } }));
+ assertEquals(calls, 1);
+ });
+
+ test("addEventListener({ once: true }) fires once then detaches (EventListenerObject)", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+
+ let calls = 0;
+ const listener: EventListenerObject = {
+ handleEvent() {
+ calls++;
+ },
+ };
+ target.addEventListener("testChannel", listener, { once: true });
+
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: {} }));
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: {} }));
+ assertEquals(calls, 1);
+ });
+
+ test("addEventListener with an already-aborted signal never delivers", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ const signal = AbortSignal.abort();
+
+ let calls = 0;
+ target.addEventListener("testChannel", () => calls++, { signal });
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: {} }));
+ assertEquals(calls, 0);
+ });
+
+ test("addEventListener signal abort detaches a live listener", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ const controller = new AbortController();
+
+ let calls = 0;
+ target.addEventListener("testChannel", () => calls++, { signal: controller.signal });
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: {} }));
+ assertEquals(calls, 1);
+
+ controller.abort();
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: {} }));
+ assertEquals(calls, 1);
+ });
+
+ test("null listener is a no-op for add and remove", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ target.addEventListener("testChannel", null);
+ target.removeEventListener("testChannel", null);
+ // No throw, and no listener to fire.
+ dispatchMessage(socket, JSON.stringify({ channel: "testChannel", data: {} }));
+ });
+
+ test("EventListenerObject is invoked via handleEvent on single- and multi-listener channels", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+
+ let a = 0;
+ let b = 0;
+ target.addEventListener("solo", {
+ handleEvent() {
+ a++;
+ },
+ });
+ target.addEventListener("multi", {
+ handleEvent() {
+ b++;
+ },
+ });
+ target.addEventListener("multi", () => b++);
+
+ dispatchMessage(socket, JSON.stringify({ channel: "solo", data: {} }));
+ dispatchMessage(socket, JSON.stringify({ channel: "multi", data: {} }));
+ assertEquals(a, 1);
+ assertEquals(b, 2);
+ });
+ });
+
+ describe("listener registry", () => {
+ /** Dispatches on `channel` and returns the labels the listeners recorded. */
+ function emit(socket: WebSocket, channel: string): void {
+ dispatchMessage(socket, JSON.stringify({ channel, data: {} }));
+ }
+
+ test("fan-out follows registration order", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ const seen: string[] = [];
+ for (const name of ["a", "b", "c"]) target.addEventListener("chan", () => seen.push(name));
+
+ emit(socket, "chan");
+ assertEquals(seen, ["a", "b", "c"]);
+ });
+
+ test("registering the same listener twice fires it once", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ let calls = 0;
+ const listener = (): void => {
+ calls++;
+ };
+ target.addEventListener("chan", listener);
+ target.addEventListener("chan", listener);
+
+ emit(socket, "chan");
+ assertEquals(calls, 1);
+ });
+
+ test("a listener unsubscribed by an earlier listener does not receive the in-flight frame", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ const seen: string[] = [];
+ const second = (): void => {
+ seen.push("second");
+ };
+ target.addEventListener("chan", () => {
+ seen.push("first");
+ target.removeEventListener("chan", second);
+ });
+ target.addEventListener("chan", second);
+
+ emit(socket, "chan");
+ // Matches EventTarget: removal during dispatch takes effect immediately.
+ assertEquals(seen, ["first"]);
+ });
+
+ test("a listener added during dispatch skips the in-flight frame and runs on the next", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ const seen: string[] = [];
+ let added = false;
+ target.addEventListener("chan", () => {
+ seen.push("first");
+ if (added) return;
+ added = true;
+ target.addEventListener("chan", () => seen.push("late"));
+ });
+
+ emit(socket, "chan");
+ assertEquals(seen, ["first"]);
+ emit(socket, "chan");
+ assertEquals(seen, ["first", "first", "late"]);
+ });
+
+ test("a listener that removes itself stops receiving frames", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ let calls = 0;
+ const self = (): void => {
+ calls++;
+ target.removeEventListener("chan", self);
+ };
+ target.addEventListener("chan", self);
+
+ emit(socket, "chan");
+ emit(socket, "chan");
+ assertEquals(calls, 1);
+ });
+
+ test("removing listeners down to one and back up keeps the right set live", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ const seen: string[] = [];
+ const a = (): void => void seen.push("a");
+ const b = (): void => void seen.push("b");
+ const c = (): void => void seen.push("c");
+
+ target.addEventListener("chan", a);
+ target.addEventListener("chan", b);
+ target.addEventListener("chan", c);
+ target.removeEventListener("chan", b); // 3 listeners -> 2
+ emit(socket, "chan");
+ target.removeEventListener("chan", a); // 2 -> 1, back to the unboxed form
+ emit(socket, "chan");
+ target.removeEventListener("chan", c); // 1 -> 0, entry dropped
+ emit(socket, "chan");
+ target.addEventListener("chan", a); // re-registering from empty
+ emit(socket, "chan");
+
+ assertEquals(seen, ["a", "c", "c", "a"]);
+ });
+
+ test("removing a listener that was never registered leaves the live one alone", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ const seen: string[] = [];
+ const live = (): void => void seen.push("live");
+ target.addEventListener("chan", live);
+
+ target.removeEventListener("chan", () => {});
+ target.removeEventListener("otherChan", live);
+
+ emit(socket, "chan");
+ assertEquals(seen, ["live"]);
+ });
+
+ test("`once` fires exactly one frame", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+ let calls = 0;
+ target.addEventListener("chan", () => calls++, { once: true });
+
+ emit(socket, "chan");
+ emit(socket, "chan");
+ assertEquals(calls, 1);
+ });
+
+ test("an AbortSignal detaches the listener, and an already-aborted one never attaches", () => {
+ const socket = createFakeSocket();
+ const target = new HyperliquidEventTarget(socket);
+
+ const controller = new AbortController();
+ let live = 0;
+ target.addEventListener("chan", () => live++, { signal: controller.signal });
+ emit(socket, "chan");
+ controller.abort();
+ emit(socket, "chan");
+ assertEquals(live, 1);
+
+ let never = 0;
+ target.addEventListener("chan", () => never++, { signal: AbortSignal.abort() });
+ emit(socket, "chan");
+ assertEquals(never, 0);
+ });
});
});
diff --git a/tests/transport/websocket/_keepAlive.test.ts b/tests/transport/websocket/_keepAlive.test.ts
index 4c5a7743..3f3e6d2e 100644
--- a/tests/transport/websocket/_keepAlive.test.ts
+++ b/tests/transport/websocket/_keepAlive.test.ts
@@ -68,6 +68,29 @@ describe("WebSocketKeepAlive", () => {
assertEquals(socket.sentMessages.length, sentBeforeTick);
});
+ test("a socket error also clears the watchdog", () => {
+ const { socket } = createKeepAlive();
+
+ socket.open();
+ time.tick(5_000);
+ // The error listener is a distinct arrow from the close handler — both call `_stop`.
+ socket.dispatchEvent(new Event("error"));
+
+ const sentBeforeTick = socket.sentMessages.length;
+ time.tick(60_000);
+ assertEquals(socket.reconnectCalls, 0);
+ assertEquals(socket.sentMessages.length, sentBeforeTick);
+ });
+
+ test("a second open while the interval is armed is a no-op", () => {
+ const { socket } = createKeepAlive();
+ socket.open();
+ // Re-fire open: `_start` early-returns when the interval is already set.
+ socket.dispatchEvent(new Event("open"));
+ time.tick(5_000);
+ assertEquals(getLastSent(socket).method, "ping");
+ });
+
test("honors custom interval and timeout", () => {
const { socket } = createKeepAlive({ interval: 5_000, timeout: 1_000 });
diff --git a/tests/transport/websocket/_reconnectingSocket.test.ts b/tests/transport/websocket/_reconnectingSocket.test.ts
index c0cab3a2..794ae009 100644
--- a/tests/transport/websocket/_reconnectingSocket.test.ts
+++ b/tests/transport/websocket/_reconnectingSocket.test.ts
@@ -148,6 +148,66 @@ describe("ReconnectingWebSocket", () => {
ws.close();
});
+ test("delivers frames to the internal hook and to public listeners independently", () => {
+ const ws = createSocket();
+ lastSocket().serverOpen();
+
+ // The internal hook is how the SDK's own frame consumer reads every frame.
+ const hooked: unknown[] = [];
+ (ws as unknown as { _onFrame: (data: unknown) => void })._onFrame = (data) => hooked.push(data);
+
+ // With no public `message` listener, the hook still sees every frame.
+ lastSocket().serverMessage("only-hook");
+ assertEquals(hooked, ["only-hook"]);
+
+ // Adding a public listener must not disturb the hook, and must receive frames itself.
+ const seen: unknown[] = [];
+ const listener = (event: MessageEvent): void => void seen.push(event.data);
+ ws.addEventListener("message", listener);
+ lastSocket().serverMessage("both");
+ assertEquals(hooked, ["only-hook", "both"]);
+ assertEquals(seen, ["both"]);
+
+ // Removing it stops its deliveries while the hook keeps receiving.
+ ws.removeEventListener("message", listener);
+ lastSocket().serverMessage("hook-again");
+ assertEquals(seen, ["both"]);
+ assertEquals(hooked, ["only-hook", "both", "hook-again"]);
+
+ ws.close();
+ });
+
+ test("the onmessage attribute handler still receives frames", () => {
+ const ws = createSocket();
+ lastSocket().serverOpen();
+
+ const seen: unknown[] = [];
+ ws.onmessage = (event) => void seen.push(event.data);
+ lastSocket().serverMessage("via-attribute");
+ assertEquals(seen, ["via-attribute"]);
+
+ ws.onmessage = null;
+ lastSocket().serverMessage("after-clear");
+ assertEquals(seen, ["via-attribute"]);
+
+ ws.close();
+ });
+
+ test("every public message listener receives the frame", () => {
+ const ws = createSocket();
+ lastSocket().serverOpen();
+
+ const a: unknown[] = [];
+ const b: unknown[] = [];
+ ws.addEventListener("message", (event) => void a.push(event.data));
+ ws.addEventListener("message", (event) => void b.push(event.data));
+ lastSocket().serverMessage("fan-out");
+
+ assertEquals(a, ["fan-out"]);
+ assertEquals(b, ["fan-out"]);
+ ws.close();
+ });
+
test("resolves the url through an async factory before connecting", async () => {
const ws = createSocket(() => Promise.resolve("ws://localhost/from-factory"));
assertEquals(ws.url, "");