Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .dev/import_graph_check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Import Graph Checker
*
* Budgets the number of modules each public entry point pulls in at RUNTIME, so a barrel import
* cannot quietly reattach a large subgraph to a small entry point again.
*
* This exists because of a measured regression: one value import of `../api/info/mod.ts` in
* `src/utils/_symbolConverter.ts` — four functions from a barrel that re-exports every Info
* method — made `@bloxwap/hyperliquid/utils` load **91 modules instead of 10**, costing 22.7 ms
* on Node and 6.9 ms on Bun per process. Nothing in the test suite, the type gates or the export
* gate noticed, because the import is perfectly valid and the package still behaves identically.
* Only a budget catches it.
*
* Runtime edges are resolved syntactically, which is exact here because `verbatimModuleSyntax`
* is on: a whole-statement `import type` / `export type` is erased and carries no runtime edge,
* and anything else does — including `import { foo, type Bar }`, which keeps the statement. That
* makes this check cheap enough to run on every `bun run check`, with no build step.
*
* Usage: bun run .dev/import_graph_check.ts
*
* @module
*/

import { readFileSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import process from "node:process";
import ts from "typescript";

// =============================================================================
// CONFIGURATION
// =============================================================================

/** Repository root, derived from this file's location so the script runs from any directory. */
const ROOT_DIR: string = resolve(fileURLToPath(import.meta.url), "../..");

/**
* Runtime module budget per entry point.
*
* `limit` is the ceiling, set a little above the measured closure so ordinary growth does not
* trip it. `why` explains what the budget protects, and is printed on failure — a budget whose
* rationale is not obvious gets raised by the next person who trips it.
*/
const BUDGETS: readonly { entry: string; limit: number; why: string }[] = [
{
entry: "src/utils/mod.ts",
limit: 20,
why: "Formatting and symbol helpers must not drag in the Info API surface. Importing four functions from `api/info/mod.ts` instead of their `_methods/*` modules took this from 10 to 91 and cost 22.7 ms on Node.",
},
{
entry: "src/transport/mod.ts",
limit: 30,
why: "The transports are the entry point for a consumer that wants no API surface at all.",
},
{
entry: "src/signing/mod.ts",
limit: 20,
why: "The signing helpers stand alone; they must not reach into the API or transport layers.",
},
{
entry: "src/api/info/client.ts",
limit: 110,
why: "The narrow read-only entry point. It legitimately pulls the Info methods it wraps, but must not also pull the exchange, subscription or signing graphs.",
},
];

// =============================================================================
// GRAPH
// =============================================================================

/**
* Module specifiers of `file` that survive compilation.
*
* Skips whole-statement `import type` / `export type` — erased under `verbatimModuleSyntax` —
* and keeps everything else, including a mixed `import { foo, type Bar }` whose statement is
* emitted. Bare specifiers (`valibot`, `@noble/hashes`) are ignored: the budget is about this
* package's own graph, and a dependency's internals are not ours to police.
*/
function runtimeEdges(file: string): string[] {
const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.ESNext, true);
const out: string[] = [];
for (const statement of source.statements) {
let specifier: ts.Expression | undefined;
if (ts.isImportDeclaration(statement)) {
if (statement.importClause?.isTypeOnly) continue;
specifier = statement.moduleSpecifier;
} else if (ts.isExportDeclaration(statement)) {
if (statement.isTypeOnly || statement.moduleSpecifier === undefined) continue;
specifier = statement.moduleSpecifier;
}
if (specifier === undefined || !ts.isStringLiteral(specifier)) continue;
if (!specifier.text.startsWith(".")) continue;
out.push(specifier.text);
}
return out;
}

/** Every module reachable from `entry` through runtime edges, including `entry` itself. */
function closure(entry: string): Set<string> {
const seen = new Set<string>();
const stack = [entry];
while (stack.length > 0) {
const file = stack.pop()!;
if (seen.has(file)) continue;
seen.add(file);
for (const specifier of runtimeEdges(file)) {
stack.push(resolve(dirname(file), specifier));
}
}
return seen;
}

// =============================================================================
// MAIN
// =============================================================================

let failed = false;
for (const { entry, limit, why } of BUDGETS) {
const size = closure(resolve(ROOT_DIR, entry)).size;
const status = size <= limit ? "ok" : "OVER";
console.log(`${status.padEnd(5)} ${entry.padEnd(28)} ${String(size).padStart(4)} / ${limit}`);
if (size > limit) {
failed = true;
console.error(`\n ${entry} now loads ${size} modules at runtime, over its budget of ${limit}.`);
console.error(` ${why}`);
console.error(
" Import the specific modules you need rather than a `mod.ts` barrel, or raise the budget deliberately.\n",
);
}
}

if (failed) {
console.error("Import graph budgets exceeded.");
process.exit(1);
}
console.log(`All ${BUDGETS.length} import graph budgets are within limits.`);
31 changes: 31 additions & 0 deletions .dev/perf/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,44 @@ function flag(args: readonly string[], name: string): string | undefined {
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}

/** Sampling noise above which a recorded entry is not worth comparing against, in percent. */
const NOISY_RME_PCT = 15;

/**
* Warns about entries recorded with too much sampling noise to be a useful baseline.
*
* A high-`rme` entry is worse than no entry: the gate compares against it for months, and
* anyone reading the report treats it as the truth. The recorded
* `signing/order_e2e_no_ecdsa_unchecked` sat at 3031.9 ns with **41.0% rme** — a figure that
* does not reproduce (the same scenario measures 8–12 µs across a dozen fresh processes) — and
* three separate performance audits each spent effort explaining a 7.7 µs "validation cost"
* that only existed because that one number was noise.
*
* This warns rather than fails: a noisy machine is a reason to re-record, not to block the
* person doing it, and some scenarios are legitimately jittery.
*/
export async function warnOnNoisyEntries(path: string): Promise<void> {
const report = (await Bun.file(path).json()) as { scenarios?: { name: string; nsPerUnit: number; rme: number }[] };
const noisy = (report.scenarios ?? []).filter((s) => s.rme > NOISY_RME_PCT).sort((a, b) => b.rme - a.rme);
if (noisy.length === 0) return;

console.warn(
`\n${noisy.length} scenario(s) recorded above ${NOISY_RME_PCT}% rme. A baseline entry this noisy will produce` +
` false regressions and false all-clears for as long as it is committed:`,
);
for (const s of noisy)
console.warn(` ${s.rme.toFixed(1).padStart(5)}% ${s.nsPerUnit.toFixed(1).padStart(10)} ns ${s.name}`);
console.warn("Re-record on an idle machine before committing, or accept these entries deliberately.\n");
}

if (import.meta.main) {
const args = Bun.argv.slice(2);

// --- Baseline recording --------------------------------------------------
if (args.includes("--record")) {
await runSuite(BASELINE, "baseline");
console.log(`\nRecorded baseline: ${BASELINE}`);
await warnOnNoisyEntries(BASELINE);
process.exit(0);
}

Expand Down
59 changes: 54 additions & 5 deletions docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,54 @@ Beyond callback settle, enforcement is **best-effort**:
> later nonce does NOT invalidate it. The payload goes **stale** only once 100 newer nonces have been consumed.
> Prepare immediately before use anyway.

### Placing many orders at once

An `order` action carries an **array** of orders, and the whole array is covered by **one signature** and costs
**one request**. Fanning the same orders out into N concurrent `order()` calls pays N signatures and N requests for
the same result, and secp256k1 is ~90% of the cost of an order — so this is by a wide margin the largest performance
decision available to a caller.

```ts
// One action, one signature, one request.
await client.order({
orders: [
{ a: 0, b: true, p: "30000", s: "0.1", r: false, t: { limit: { tif: "Gtc" } } },
{ a: 1, b: false, p: "2000", s: "1.5", r: false, t: { limit: { tif: "Gtc" } } },
// ... up to the venue's per-action limit
],
grouping: "na",
});
```

Measured on this tree for 100 orders (Bun 1.4.0, Apple M3 Max, zero-latency in-memory transport;
median of 9, so the numbers isolate SDK CPU from the network):

| Approach | Wall time | Rate-limit weight |
| -------------------------------------------- | -------------- | ----------------- |
| One batched action (either wallet) | **0.3–0.4 ms** | **3** |
| 100 concurrent `order()` calls, fast wallet | 7.2 ms | 100 |
| 100 concurrent `order()` calls, viem account | 12.1 ms | 100 |

Batching is ~20–35× less CPU, and it makes the wallet choice stop mattering: one signature
amortized over 100 orders leaves the curve implementation contributing nothing measurable
(0.43 ms fast vs 0.32 ms viem, ranges overlapping). Choosing
[`createFastLocalWallet`](signing.md#fast-local-wallet-wasm-secp256k1) matters most for orders you *cannot* batch.

The weight column is the part that bites first in production: the exchange endpoint charges
`1 + floor(batchLength / 40)`, so 100 orders in one action cost **3** of your
[1200 weight/minute per IP](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits),
while 100 separate actions cost **100**. Batching raises the ceiling on how often you can act by ~33×, independently
of any CPU saving.

> [!NOTE]
>
> `grouping: "na"` is **not** atomic — it is documented as "standard order without grouping", and the orders in the
> array are accepted or rejected individually. You do not need separate actions to avoid all-or-nothing behavior.
> Use `"normalTpsl"` or `"positionTpsl"` only when you actually want the take-profit/stop-loss grouping semantics.

Separate actions are genuinely required only when the orders differ in a field the action carries once rather than
per order — `vaultAddress`, `expiresAfter`, `builder`, or `grouping` itself.

### Orders over WebSocket (low latency)

Every `ExchangeClient` method also works over [`WebSocketTransport`](transports.md#websocket) — the server accepts
Expand Down Expand Up @@ -372,11 +420,12 @@ const subscription = await client.allMids(

### Unsubscribe

A single connection supports up to
[1000 active subscriptions and 15 unique users](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits)
(the official docs, updated ~17 days earlier, say 10 — but a live mainnet probe on 2026-07-26 observed the server
accepting 15 users and rejecting the 16th with "Cannot track more than 15 total users."; the server is the authority
here, the docs lag).
Hyperliquid allows
[1000 active subscriptions and 14 unique users](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits)
**per IP address** — not per connection, so every `WebSocketTransport` on a network shares one budget (see
[WebSocket limits](transports.md#websocket-limits)). The official docs say 10 unique users and the server's own
refusal says 15; a live mainnet probe on 2026-08-02 measured the enforced ceiling at 14 on two independent
connections, which is what the SDK guards against — see [known drift](reference/known-drift.md).
Call `unsubscribe()` to remove a listener and free these slots:

```ts
Expand Down
45 changes: 37 additions & 8 deletions docs/reference/known-drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,23 @@ When an entry is resolved upstream (docs fixed, or server aligned with docs), mo
listeners separate even though they share one server-side subscription
(`src/transport/websocket/_subscriptionManager.ts`).

### 6. Unique users per connection — docs say 10, server allows 15
### 6. Unique users — docs say 10, the server's error says 15, the server enforces 14

- **Observed:** 2026-07-26 (live mainnet).
- **Observed:** 2026-08-02 (live mainnet), superseding a 2026-07-26 observation.
- **Docs claim:** maximum of 10 unique users across user-specific WebSocket subscriptions (updated ~2026-07;
[rate limits](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits)).
- **Server reality:** the 15th unique user was acknowledged; the 16th was rejected with
`Cannot track more than 15 total users.`
- **SDK behavior:** matches the server — the subscription manager enforces 15 unique users per connection
client-side (`MAX_UNIQUE_USERS = 15`, `src/transport/websocket/_subscriptionManager.ts`) and throws the same
message before wasting a round trip. Note [Clients → Unsubscribe](../clients.md#unsubscribe) still quotes the
docs' "10 unique users".
- **Server reality:** **14** distinct users are accepted; the **15th** is refused with an `error` frame reading
`Cannot track more than 15 total users.` — the message is off by one from the enforcement. Measured by subscribing
distinct users one at a time with the client-side guard disabled, twice, on two independent connections; both runs
stopped at 14.
- **Scope:** **per IP, not per connection.** With one connection holding 14 users, a second connection from the same
host was refused a 15th distinct user, while still being allowed to subscribe a user the first connection already
held. Sharding user channels across sockets therefore buys no additional user slots.
- **SDK behavior:** enforces the measured 14 (`MAX_UNIQUE_USERS = 14`, `src/transport/websocket/_quota.ts`), counted
against a per-IP [`WebSocketQuota`](../transports.md#websocket-limits) shared by every transport on the network.
The earlier value of 15 was taken from the server's error text and was one too high — the 15th subscription passed
the client guard, and because the server's refusal carries no echoed request, it could not be matched to the
pending subscribe and surfaced only as a request timeout ~10 s later.

### 7. `TwapState` frames gained `trigger` / `stopPx`

Expand All @@ -88,6 +94,29 @@ When an entry is resolved upstream (docs fixed, or server aligned with docs), mo
resolves outcome asset IDs (`100000000 + outcomeId * 10 + sideIndex`) from `outcomeMeta` alone. Format prices and
sizes for outcome markets with the spot-like model above, at your own risk.

### 9. `outcomeMeta` outcomes gained a `deployer` field

- **Observed:** 2026-08-02 (live mainnet), via the `outcomeMeta` schema-coverage test.
- **Docs claim:** each entry of `outcomes` carries no `deployer`.
- **Server reality:** every outcome in the response now includes `deployer`; the schema-coverage check reports
`additionalProperty: "deployer"` across the whole `outcomes` array (observed at indices 0 through 157+).
- **SDK behavior:** runtime unaffected — Info responses are delivered to callers as received, so the field is present
on the objects you get. The `OutcomeMetaResponse` **type** in `src/api/info/_methods/outcomeMeta.ts` does not
declare it yet, so it is invisible to TypeScript and `tests/api/info/outcomeMeta.test.ts` fails online until the
type is widened. Per [Versioning](../README.md#versioning) that type change ships in a patch release.

### 10. `validatorL1Votes` actions gained `registerTemplate`

- **Observed:** 2026-08-02 (live mainnet), via the `validatorL1Votes` schema-coverage test.
- **Docs claim:** the validator action union covers `registerTokensAndStandaloneOutcome` among its variants.
- **Server reality:** a live vote carried an action whose `O` object holds `registerTemplate` and omits
`registerTokensAndStandaloneOutcome`, so it matches no variant of the documented union — the check reports both
`missingProperty: "registerTokensAndStandaloneOutcome"` and `additionalProperty: "registerTemplate"` for the same
sample.
- **SDK behavior:** runtime unaffected for the same reason as #9; the union in
`src/api/info/_methods/validatorL1Votes.ts` needs a `registerTemplate` variant, and
`tests/api/info/validatorL1Votes.test.ts` fails online until it has one.

## Resolved

_None yet._
53 changes: 53 additions & 0 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,56 @@ const transport = new WebSocketTransport({ resubscribe: false });

If a subscription then fails to re-establish, its `onError` callback is invoked. Handle it as shown under
[subscription errors](clients.md#errors).

### WebSocket limits

Hyperliquid scopes every documented WebSocket limit to your **IP address**, not to the connection — two of them say so
in their own text ("across all websocket connections"):

| Limit | Value | Scope |
| -------------------------------------- | ----------- | ------------------------------ |
| Connections | 10 | per IP |
| New connections | 30/minute | per IP |
| Subscriptions | 1000 | per IP |
| Unique users across user-specific subs | 14 | per IP |
| Messages sent to Hyperliquid | 2000/minute | per IP, across all connections |
| Simultaneous inflight post requests | 100 | per IP, across all connections |

Because that scope is the IP and not the socket, every `WebSocketTransport` on a network **shares one budget** by
default, and the subscription and unique-user guards count what the server counts. Two transports no longer admit 2000
subscriptions against a limit of 1000 — the excess is refused locally with a clear
[`WebSocketRequestError`](error-handling.md) instead of by the server, whose refusal carries no echoed request and
therefore surfaces only as a request timeout ten seconds later.

Reservations are released when a subscription is unsubscribed or when its connection is permanently closed, which is
when the server frees them too. Call `transport.close()` on a transport you are done with.

Pass your own `quota` when the default's assumption does not hold — a process behind several egress IPs needs one per
IP, and tests usually want isolation:

```ts
import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";

const transport = new WebSocketTransport({ quota: new WebSocketQuota() });
```

### WebSocket rate limiting

Outbound messages are budgeted but **not paced by default**. Opt into pacing when you approach the 2000/minute ceiling
— most easily by holding many subscriptions, since one reconnect re-subscribes all of them at once and 1000
subscriptions spend half the minute's budget instantly:

```ts
import { WebSocketQuota, WebSocketTransport } from "@bloxwap/hyperliquid";

const quota = new WebSocketQuota({ rateLimit: { capacity: 2000, refillPerMinute: 2000 } });
const transport = new WebSocketTransport({ quota });
```

Pacing only ever delays `subscribe` and `unsubscribe` frames. **`post` requests and keep-alive pings never wait**: an
exchange action's wire order — and therefore per-wallet nonce ordering — depends on reaching the socket synchronously,
and delaying the keep-alive watchdog is how a half-open connection goes unnoticed. Both still *debit* the budget, so a
burst of orders correctly slows subscription traffic rather than silently overrunning the shared limit.

As with [HTTP rate limiting](#rate-limiting), the budget is client-side bookkeeping: other processes, other machines
behind the same IP, and traffic the SDK cannot see all draw on the same server-side bucket.
Loading
Loading