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 %}