diff --git a/docs/error-handling.md b/docs/error-handling.md
index d56f04a9..c75d067d 100644
--- a/docs/error-handling.md
+++ b/docs/error-handling.md
@@ -1,148 +1,217 @@
# Error handling
-The SDK throws `HyperliquidError` and its subclasses. Each subclass represents a specific failure category, such as
-validation, transport, API, or wallet errors.
+The SDK throws a small, typed set of exceptions so you can route error handling by **class**. Read this page when you
+are writing a `try/catch` around a client call, wiring observability, or deciding what to retry.
-## Error hierarchy
+## Class hierarchy
+
+Every exception the SDK itself throws extends `HyperliquidError`. One `instanceof` check is enough to separate
+"something in the SDK threw" from "something else threw".
```
Error
└─ HyperliquidError
├─ ValidationError
├─ AbstractWalletError
- ├─ TransportError
- │ ├─ HttpRequestError
- │ └─ WebSocketRequestError
- └─ ApiRequestError
+ ├─ CanonicalizeError
+ ├─ ApiRequestError
+ └─ TransportError
+ ├─ HttpRequestError
+ └─ WebSocketRequestError
```
-## Transport errors
+| Class | Thrown from | Inspect |
+| ----------------------- | ------------------------------------------------ | ------------------------- |
+| `ValidationError` | Schema parsing, before any network I/O | `message`, `cause.issues` |
+| `AbstractWalletError` | Signing layer (viem / ethers / custom adapter) | `cause` |
+| `CanonicalizeError` | `canonicalize()` helper during low-level signing | `message` |
+| `ApiRequestError` | Hyperliquid API returned an error response | `message`, `response` |
+| `HttpRequestError` | `fetch` failed or returned non-2xx / non-JSON | `response`, `cause` |
+| `WebSocketRequestError` | WebSocket operation failed | `message`, `cause` |
+
+## `ValidationError`
+
+Thrown when a client method's valibot schema rejects your payload — so it fires **before** any network I/O. The `cause`
+is always a [valibot `ValiError`](https://valibot.dev/api/ValiError/), and its `issues` array gives the exact path of
+every problem.
-These errors are thrown when the request itself fails, such as network issues, timeouts, or non-JSON responses.
+
+```ts
+import { ValidationError } from "@nktkas/hyperliquid";
+
+try {
+ await client.order({ orders: [/* ... */], grouping: "na" });
+} catch (error) {
+ if (error instanceof ValidationError) {
+ console.error(error.message); // human-readable summary
+ console.error(error.cause.issues); // path + expected + received per issue
+ }
+}
+```
-### HttpRequestError
+Defined in [`src/_base.ts`](../src/_base.ts).
-This error is thrown when an HTTP request fails, such as non-200 status codes, non-JSON responses, or network-level
-failures.
+## `ApiRequestError`
-Has `response` (the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object) when the server
-responded, `undefined` for network-level failures.
+Thrown when Hyperliquid's API processed the request and returned an error response. The raw payload is attached as
+`response`, and `message` is its error text. Server messages have no stability guarantee, so avoid branching on their
+exact text.
+
```ts
-import { HttpRequestError } from "@nktkas/hyperliquid";
+import { ApiRequestError } from "@nktkas/hyperliquid";
try {
- await client.allMids();
+ await client.order({ orders: [/* ... */], grouping: "na" });
} catch (error) {
- if (error instanceof HttpRequestError) {
- console.error(error.message); // "429 Too Many Requests - ..."
- console.error(error.response?.status); // 429
- console.error(await error.response?.text()); // response body
+ if (error instanceof ApiRequestError) {
+ console.error(error.message); // server-owned text
+ console.error(error.response); // full raw API response
}
}
```
-### WebSocketRequestError
+Defined in [`src/api/exchange/_methods/_base/errors.ts`](../src/api/exchange/_methods/_base/errors.ts).
-This error is thrown when a WebSocket request or subscription fails, such as connection failures, server errors, or
-exceeded subscription limits.
+## `AbstractWalletError`
+Thrown from the signing layer when a wallet operation fails: signing EIP-712 typed data, reading the wallet address, or
+reading the chain id. The underlying wallet's own error (from viem, ethers, or a custom adapter) is attached as `cause`.
+
+
```ts
-import { WebSocketRequestError } from "@nktkas/hyperliquid";
+import { AbstractWalletError } from "@nktkas/hyperliquid";
try {
- await client.allMids();
+ await client.order({ orders: [/* ... */], grouping: "na" });
} catch (error) {
- if (error instanceof WebSocketRequestError) {
- console.error(error.message); // for example, "WebSocket connection closed"
+ if (error instanceof AbstractWalletError) {
+ console.error(error.message); // SDK-constructed summary
+ console.error(error.cause); // original wallet error
}
}
```
-## API errors
+Defined in [`src/signing/_abstractWallet.ts`](../src/signing/_abstractWallet.ts).
-`ApiRequestError` is thrown when the Hyperliquid API processes the request but returns an error, such as insufficient
-margin, invalid price, or unknown wallet address.
+## `CanonicalizeError`
-Has `response` with the full API response object:
+Thrown by the [`canonicalize`](signing.md#canonicalize) helper when the payload does not match the schema — an extra
+field or a missing required field. You only hit this when you are building your own signed action; the built-in
+`ExchangeClient` methods never reach this path with their own payloads.
```ts
-import { ApiRequestError } from "@nktkas/hyperliquid";
+import { CancelRequest } from "@nktkas/hyperliquid/api/exchange";
+import { canonicalize, CanonicalizeError } from "@nktkas/hyperliquid/signing";
try {
- await client.order({ orders: [/* ... */], grouping: "na" });
+ const action = canonicalize(CancelRequest.entries.action, {
+ type: "cancel",
+ cancels: [{ a: 0, o: 12345 }],
+ });
+ // ... continue building the signed payload with `action`
} catch (error) {
- if (error instanceof ApiRequestError) {
- console.error(error.message); // for example, "Insufficient margin to place order"
- console.error(error.response); // full API response
+ if (error instanceof CanonicalizeError) {
+ console.error(error.message); // which key was unexpected or missing
}
}
```
-## Wallet errors
+Defined in [`src/signing/_canonicalize.ts`](../src/signing/_canonicalize.ts).
-`AbstractWalletError` is thrown when a wallet operation fails, such as signature creation, address retrieval, or chain
-ID detection. The original error is in `cause`.
+## Transport errors
+
+`TransportError` is the common base for every failure that happens at the transport layer. Catch it directly when you
+want a single branch that covers both `HttpRequestError` and `WebSocketRequestError`.
```ts
-import { AbstractWalletError } from "@nktkas/hyperliquid";
+import { TransportError } from "@nktkas/hyperliquid";
try {
- await client.order({ orders: [/* ... */], grouping: "na" });
+ await client.allMids();
} catch (error) {
- if (error instanceof AbstractWalletError) {
- console.error(error.message); // for example, "Failed to sign typed data with viem wallet"
- console.error(error.cause); // original wallet error
+ if (error instanceof TransportError) {
+ // both HttpRequestError and WebSocketRequestError land here
+ }
+}
+```
+
+### `HttpRequestError`
+
+Thrown by `HttpTransport` when `fetch` itself rejects, or when the server returns a non-2xx / non-JSON response. When
+the server did respond, `response` is a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object —
+you can read its status and body. For network-level failures (DNS, connection reset, offline), `response` is `undefined`
+and the underlying cause is in `cause`.
+
+
+```ts
+import { HttpRequestError } from "@nktkas/hyperliquid";
+
+try {
+ await client.allMids();
+} catch (error) {
+ if (error instanceof HttpRequestError) {
+ if (error.response) {
+ console.error(error.response.status); // HTTP status
+ console.error(await error.response.text()); // response body
+ } else {
+ console.error(error.cause); // network-level reason
+ }
}
}
```
-## Validation errors
+Defined in [`src/transport/http/mod.ts`](../src/transport/http/mod.ts).
-`ValidationError` is thrown when request parameters fail schema validation before sending.
+### `WebSocketRequestError`
-The `cause` property contains the original [`ValiError`](https://valibot.dev/api/ValiError/) with detailed issue
-information:
+Thrown by `WebSocketTransport` when the WebSocket connection cannot be used, or when a request or subscription receives
+an error response. The underlying cause (if any) is in `cause`.
+
```ts
-import { ValidationError } from "@nktkas/hyperliquid";
+import { WebSocketRequestError } from "@nktkas/hyperliquid";
try {
- await client.order({ orders: [/* ... */], grouping: "na" });
+ await client.allMids();
} catch (error) {
- if (error instanceof ValidationError) {
- console.error(error.message); // human-readable validation message
- console.error(error.cause.issues); // detailed list of validation issues
+ if (error instanceof WebSocketRequestError) {
+ console.error(error.message); // SDK-constructed summary
+ console.error(error.cause); // underlying reason, if any
}
}
```
+Defined in [`src/transport/websocket/_postRequest.ts`](../src/transport/websocket/_postRequest.ts).
+
## Timeouts and cancellation
-Both transports use [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) internally, so
-timeouts and user-initiated cancellations produce a
-[`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) in `cause`:
+Both transports use [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) under the hood. The
+default request timeout (10s) is built with `AbortSignal.timeout()`, and any `signal` you pass into a
+[client](clients.md) method is merged with it — whichever aborts first wins. The resulting
+[`DOMException`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) is wrapped as `cause` on a
+`TransportError`.
```ts
import { TransportError } from "@nktkas/hyperliquid";
-// ^^^^^^^^^^^^^^
-// universal transport error superclass for both HTTP and WebSocket transports
try {
await client.allMids();
} catch (error) {
if (error instanceof TransportError && error.cause instanceof DOMException) {
if (error.cause.name === "TimeoutError") {
- // Transport timeout (default 10 s)
+ // transport hit the configured timeout
}
if (error.cause.name === "AbortError") {
- // Cancelled via AbortSignal
+ // caller aborted via their own AbortSignal
}
}
}
```
-## Catch all errors
+## Catch-all pattern
+
+One pattern that covers every SDK-thrown error, routes by class, and re-throws anything foreign.
```ts
import {
@@ -160,21 +229,20 @@ try {
} catch (error) {
if (error instanceof HyperliquidError) {
if (error instanceof ValidationError) {
- // Invalid parameters - check error.message, error.cause
+ // invalid parameters — inspect error.message
} else if (error instanceof ApiRequestError) {
- // API rejected the action - check error.message
+ // API rejected the action — inspect error.message
} else if (error instanceof AbstractWalletError) {
- // Wallet failed - check error.cause
+ // wallet failed — inspect error.cause
} else if (error instanceof TransportError) {
if (error instanceof HttpRequestError) {
- // HTTP failure - check error.response, error.cause
+ // HTTP transport failed — inspect error.response, error.cause
} else if (error instanceof WebSocketRequestError) {
- // WebSocket failure - check error.message
+ // WebSocket transport failed — inspect error.cause
}
- } else {
- // Unexpected HyperliquidError subclass - check error.message
}
+ } else {
+ throw error; // not ours — let it propagate
}
- throw error; // re-throw unexpected errors
}
```
diff --git a/docs/utilities.md b/docs/utilities.md
index f9e3a2ec..0eeafdda 100644
--- a/docs/utilities.md
+++ b/docs/utilities.md
@@ -1,38 +1,103 @@
# Utilities
-Helper functions for formatting orders and resolving asset symbols, imported from `@nktkas/hyperliquid/utils`.
+Helpers that keep your order payloads compatible with Hyperliquid's on-chain rules. Read this page when you are building
+an order, resolving a symbol, or wondering why the server rejected a number that looks fine. Everything here is imported
+from `@nktkas/hyperliquid/utils` and lives in [`../src/utils/`](../src/utils/).
-## Format prices
+## Three invariants you have to honor
-`formatPrice` truncates a price to the Hyperliquid
-[tick size](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size) precision.
+Hyperliquid's exchange rejects requests that break its price, size, and asset rules before they even reach the matching
+engine. Three of those rules touch every order you build:
+| Invariant | What it means | SDK helper |
+| ------------- | -------------------------------------------------------------------- | ---------------------------------------------- |
+| **Tick size** | Prices must fit a bounded number of significant figures and decimals | [`formatPrice`](#tick-size-formatprice) |
+| **Lot size** | Sizes must not exceed the asset's `szDecimals` | [`formatSize`](#lot-size-formatsize) |
+| **Asset ID** | The `a` field in an order is a numeric index, not `"BTC"` | [`SymbolConverter`](#asset-id-symbolconverter) |
+
+The rest of this page is those three invariants, each followed by the helper that resolves it, and an end-to-end example
+that composes all three.
+
+
+
+## Tick size → `formatPrice`
+
+Hyperliquid's
+[tick and lot size rules](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size)
+constrain every order price to three conditions at once:
+
+- At most **5 significant figures**.
+- At most **`6 − szDecimals`** decimals for perpetuals, **`8 − szDecimals`** for spot.
+- Integer prices are always valid, regardless of significant figures.
+
+A plain `toFixed(n)` is not enough: it rounds instead of truncating and ignores the significant-figures ceiling.
+[`formatPrice`](../src/utils/_format.ts) applies both rules at once, as a string operation so arbitrary-precision inputs
+survive intact:
+
+
```ts
import { formatPrice } from "@nktkas/hyperliquid/utils";
-formatPrice("97123.456789", 0); // "97123"
-formatPrice("1.23456789", 5); // "1.2"
-formatPrice("0.0000123456789", 0, "spot"); // "0.00001234"
+formatPrice("97123.456789", 0); // "97123" — perp, szDecimals=0
+formatPrice("1.23456789", 5); // "1.2" — perp, szDecimals=5
+formatPrice("0.0000123456789", 0, "spot"); // "0.00001234" — spot, 8-decimal ceiling
```
-## Format sizes
+The third argument selects the market type and defaults to `"perp"`. Pass `"spot"` when the price belongs to a spot
+market — the decimal ceiling differs.
+
+{% hint style="warning" %} `formatPrice` **truncates**, it does not round. If truncation collapses a very small price to
+`0`, it throws `RangeError` instead of silently sending a zero-valued order. Treat that error as a signal to rescale the
+input, not as something to catch and ignore. {% endhint %}
+
+
+
+## Lot size → `formatSize`
-`formatSize` truncates a size to the asset
-[lot size](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size) precision.
+The lot-size rule is the simpler of the two: an order's size may not carry more decimal places than the asset's
+`szDecimals`. [`formatSize`](../src/utils/_format.ts) truncates the string to fit:
+
```ts
import { formatSize } from "@nktkas/hyperliquid/utils";
-formatSize("1.23456789", 5); // "1.23456"
+formatSize("1.23456789", 5); // "1.23456"
formatSize("0.123456789", 2); // "0.12"
-formatSize("100", 0); // "100"
+formatSize("100", 0); // "100"
```
-## Resolve symbols
+Like `formatPrice`, it throws `RangeError` if truncation produces `0` — so an accidental 8-decimal size against a
+`szDecimals = 3` asset fails at the call site rather than at the server.
-`SymbolConverter` maps human-readable symbols to Hyperliquid
-[asset IDs](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids) and formatting metadata. It
-fetches data from the API on creation.
+{% hint style="warning" %} Hyperliquid treats a literal `"0"` size on a reduce-only order as "close the whole position".
+`formatSize` refuses to return `"0"`, so if you actually want that behavior, pass `"0"` directly into the order payload
+instead of routing it through `formatSize`. {% endhint %}
+
+
+
+## Asset ID → `SymbolConverter`
+
+A signed order carries `a: number`, not `"BTC"`. Hyperliquid assigns asset IDs across three disjoint ranges, each driven
+by a different info endpoint:
+
+```
+┌───────────────────┬──────────────────────────────┬─────────────────────┐
+│ Perpetuals │ 0, 1, 2, … │ meta().universe │
+├───────────────────┼──────────────────────────────┼─────────────────────┤
+│ Spot markets │ 10000 + market.index │ spotMeta() │
+├───────────────────┼──────────────────────────────┼─────────────────────┤
+│ Builder DEX │ 100000 + dexIndex * 10000 │ perpDexs() │
+│ (HIP-3 perps) │ + asset.index │ + meta({dex}) │
+└───────────────────┴──────────────────────────────┴─────────────────────┘
+```
+
+For reliability, prefer fetching these mappings at runtime over hardcoding them.
+[`SymbolConverter`](../src/utils/_symbolConverter.ts) does that and exposes the lookups as plain methods.
+
+### Create
+
+`SymbolConverter.create()` pulls `meta` + `spotMeta` (and optionally builder-DEX metadata) and resolves into a
+ready-to-use instance:
```ts
import { HttpTransport } from "@nktkas/hyperliquid";
@@ -42,110 +107,161 @@ const transport = new HttpTransport();
const converter = await SymbolConverter.create({ transport });
```
-### Asset IDs
+If you need a synchronous constructor — for example, to keep `SymbolConverter` as a field of a class built before any
+network I/O — use `new SymbolConverter({ transport })` and call `await converter.reload()` explicitly. `create` is the
+shortcut for the common case.
+
+### Resolve an asset ID
-`getAssetId` returns the numeric ID used in order parameters:
+`getAssetId` takes the symbol in whichever format its market uses:
+
```ts
-converter.getAssetId("BTC"); // 0
-converter.getAssetId("HYPE/USDC"); // 10107
+converter.getAssetId("BTC"); // 0 — perpetual
+converter.getAssetId("HYPE/USDC"); // 10107 — spot market
+converter.getAssetId("test:ABC"); // 110000 — builder DEX (if enabled)
```
-Perpetuals use the coin name. Spot markets use `"BASE/QUOTE"` format. Returns `undefined` if the symbol is not found.
+The accepted formats follow the asset-ID ranges one-for-one:
-### Size decimals
+| Market type | Name format | Example |
+| ----------- | ---------------- | ------------- |
+| Perpetual | `` | `"BTC"` |
+| Spot | `/` | `"HYPE/USDC"` |
+| Builder DEX | `:` | `"test:ABC"` |
-`getSzDecimals` returns the lot size precision for an asset:
+`getAssetId` returns `undefined` for an unknown symbol.
+
+### Read `szDecimals`
+
+`getSzDecimals` returns the same precision that `formatPrice` and `formatSize` need. This is the coupling the three
+invariants share: you ask the converter once, then feed the same value into both formatters.
```ts
-converter.getSzDecimals("BTC"); // 5
-converter.getSzDecimals("HYPE/USDC"); // 2
+import { formatPrice, formatSize } from "@nktkas/hyperliquid/utils";
+
+const szDecimals = converter.getSzDecimals("BTC")!; // 5
+
+formatPrice("97123.456789", szDecimals); // "97123"
+formatSize("0.00123456789", szDecimals); // "0.00123"
```
+For spot markets, `getSzDecimals` returns the `szDecimals` of the **base** token — which is what both formatters expect
+for an order on that pair.
+
### Spot pair IDs
-`getSpotPairId` returns the identifier used in info requests and subscriptions for spot markets:
+The `a` field on an order is one identifier. Info endpoints and subscriptions (`l2Book`, `trades`, `candleSnapshot`, …)
+use a different one for spot markets — usually a `"@"` alias, with a handful of legacy pairs that kept their
+`"BASE/QUOTE"` form. `getSpotPairId` gives you the identifier the info side expects:
```ts
converter.getSpotPairId("HYPE/USDC"); // "@107"
-converter.getSpotPairId("PURR/USDC"); // "PURR/USDC"
+converter.getSpotPairId("PURR/USDC"); // "PURR/USDC" — legacy pair
```
-### Symbol from spot pair ID
-
-`getSymbolBySpotPairId` resolves a spot pair ID back to its `"BASE/QUOTE"` symbol:
+Round-trip back to the symbol with `getSymbolBySpotPairId`, useful when you receive a subscription event and want a
+human-readable label:
+
```ts
-converter.getSymbolBySpotPairId("@107"); // "HYPE/USDC"
+converter.getSymbolBySpotPairId("@107"); // "HYPE/USDC"
converter.getSymbolBySpotPairId("PURR/USDC"); // "PURR/USDC"
```
-### Refresh data
+Both methods return `undefined` for unknown pairs.
+
+### Refresh after a new listing
-Call `reload` to update the symbol mappings when new assets are listed:
+A converter is a snapshot of the universe at creation time. When Hyperliquid lists a new asset — or when your process
+has been running long enough to race against one — call `reload` to re-fetch the metadata and rebuild the lookups in
+place:
```ts
await converter.reload();
```
-### Builder DEX support
+`reload` reuses the transport and `dexs` option the instance was created with, so you do not need to pass them again.
+
+### Builder DEXs
+
+[HIP-3 builder-deployed perpetuals](https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals)
+live outside the default universe. `SymbolConverter` ignores them unless you opt in through the `dexs` option:
+
+{% tabs %}
-Pass `dexs` to include assets from
-[builder DEXs](https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals):
+{% tab title="All builder DEXs" %}
```ts
const converter = await SymbolConverter.create({
transport,
- dexs: true, // all DEXs
+ dexs: true,
});
```
+{% endtab %}
+
+{% tab title="Selected DEXs" %}
+
```ts
const converter = await SymbolConverter.create({
transport,
- dexs: ["test", "other"], // specific DEXs
+ dexs: ["test", "other"],
});
```
-Builder DEX assets use `"DEX:ASSET"` format:
+{% endtab %}
+
+{% endtabs %}
+Builder DEX assets use the `"DEX:ASSET"` naming convention and slot into the 100000+ ID range:
+
+
```ts
-converter.getAssetId("test:ABC"); // 110000
+converter.getAssetId("test:ABC"); // 110000
converter.getSzDecimals("test:ABC"); // 0
```
-## Format and place an order
+{% hint style="info" %} Enabling `dexs` adds a `perpDexs()` call plus one `meta({ dex })` request per builder DEX. Only
+enable it if you actually trade there — the default `SymbolConverter.create()` is one round-trip each to `meta` and
+`spotMeta` in parallel. {% endhint %}
+
+## End-to-end: resolve, format, place
+All three invariants in one flow — resolve the asset ID, read `szDecimals`, format price and size, submit the order:
+
+{% code title="place-order.ts" %}
+
+
```ts
import { ExchangeClient, HttpTransport } from "@nktkas/hyperliquid";
import { formatPrice, formatSize, SymbolConverter } from "@nktkas/hyperliquid/utils";
import { privateKeyToAccount } from "viem/accounts";
const wallet = privateKeyToAccount("0x...");
-
const transport = new HttpTransport();
const converter = await SymbolConverter.create({ transport });
const client = new ExchangeClient({ transport, wallet });
-// Parameters
const coin = "BTC";
-const price = "97123.456789";
-const size = "0.00123456789";
-const isBuy = true;
+const rawPrice = "97123.456789";
+const rawSize = "0.00123456789";
-// ! asserts the symbol exists - handle undefined in production
+// `!` asserts the symbol exists — in production, handle `undefined` explicitly
const assetId = converter.getAssetId(coin)!;
const szDecimals = converter.getSzDecimals(coin)!;
await client.order({
orders: [{
- a: assetId, // asset index
- b: isBuy, // buy
- p: formatPrice(price, szDecimals), // price
- s: formatSize(size, szDecimals), // size
- r: false, // not reduce-only
- t: { limit: { tif: "Gtc" } }, // Good-Til-Cancelled
+ a: assetId, // asset-ID invariant
+ b: true, // buy
+ p: formatPrice(rawPrice, szDecimals), // tick-size invariant → "97123"
+ s: formatSize(rawSize, szDecimals), // lot-size invariant → "0.00123"
+ r: false, // not reduce-only
+ t: { limit: { tif: "Gtc" } }, // Good-Til-Cancelled
}],
grouping: "na",
});
```
+
+{% endcode %}