# Hyperliquid — the @nktkas/hyperliquid SDK, viem, and the basic API

## TL;DR

1. **There are two endpoints for everything.** `POST https://api.hyperliquid.xyz/info` (reads, body `{type, ...}`) and `POST https://api.hyperliquid.xyz/exchange` (signed actions). WS: `wss://api.hyperliquid.xyz/ws`. Testnet: the same paths at `api.hyperliquid-testnet.xyz`. Header: `Content-Type: application/json`. CORS permits calls directly from a browser (exact headers in §3.6).
2. **Known-working stack (verified through 2026-09):** `@nktkas/hyperliquid` **0.27.1** (prefer an exact pin, without `^`), `viem` 2.50–2.52 (`privateKeyToAccount` from `viem/accounts`), `ws` 8.x, Node ≥ 20, TypeScript 5.x.
3. **A common design:** reads use raw `fetch` against `/info` (known weights, controlled timeout), while the SDK is needed only for signed exchange actions. Public info requests do not require the SDK.
4. **`ExchangeClient` signs with an AGENT wallet (API wallet) key, not the master-account key.** Agent address = `privateKeyToAccount(pk).address.toLowerCase()`. Verify the association through info `extraAgents`, including `validUntil`.
5. **SDK 0.27.1 THROWS `ApiRequestError` even when some orders in a batch entered the book.** The oids of placed orders exist only in `err.response.response.data.statuses`. You cannot interpret “the SDK threw = nothing was placed”: that creates orphaned stops and loses accounting for live orders.
6. **`ApiRequestError` is not exported from the package root.** Identify it with `err.name === 'ApiRequestError'`; `instanceof` will not work.
7. **Transport errors.** `HttpRequestError` with HTTP 4xx (except 408) or 429 means the exchange did NOT apply the request. With 5xx, a timeout, or a connection drop, the outcome is UNKNOWN (the order may have entered the book), so retry only after reconciliation. The SDK wraps HTTP 200 with malformed JSON in `HttpRequestError` and stores the original `SyntaxError` in `.cause`.
8. **The SDK runs every exchange request for one wallet strictly in sequence** (semaphore, nonce = `Date.now()` with monotonic increment). Parallel `order()` calls do not speed up placement.
9. **Info requests need a hard 8–10 s timeout** (`AbortSignal.timeout`). Without it, a stalled connection waits ~300 s (the undici default) on every retry attempt.
10. **HIP-3 dex (for example, `xyz`).** Every read is made separately for each dex (`dex:'xyz'` in the body). An xyz order's asset id has an offset: observed `xyz:TSLA = 110001`, `xyz:SP500 = 110052`. Trading requires `agentEnableDexAbstraction` first.

---

## 1. Versions and dependencies

| Package | Verified version | Purpose |
|---|---|---|
| `@nktkas/hyperliquid` | `0.27.1` (prefer an exact pin, without `^`) | `ExchangeClient`, `InfoClient`, `HttpTransport`; includes `agentEnableDexAbstraction`, `sendAsset`, `usdClassTransfer`, `reserveRequestWeight` |
| `viem` | `^2.50.4` … `2.52.2` | `privateKeyToAccount`: a viem account is passed to the SDK as `wallet` |
| `ws` | `^8.18.0` … `^8.21.0` | custom WS client |
| `undici` | `6.21.2` (exact pin) | needed only for your own `Agent` (egress-IP binding), see §9 |
| Node | ≥ 20 | built-in fetch (using undici 6.x internally) |
| TypeScript / tsx | 5.6–5.9 / 4.19–4.22 | |

All facts below about SDK response parsing apply to version **0.27.1** (package error logic: `esm/src/api/exchange/_base/_errors.js`). Recheck them after upgrading the SDK.

---

## 2. Endpoints: mainnet / testnet

| | Mainnet | Testnet |
|---|---|---|
| Base | `https://api.hyperliquid.xyz` | `https://api.hyperliquid-testnet.xyz` |
| Info | `https://api.hyperliquid.xyz/info` | `https://api.hyperliquid-testnet.xyz/info` |
| Exchange | `https://api.hyperliquid.xyz/exchange` | `https://api.hyperliquid-testnet.xyz/exchange` |
| WS | `wss://api.hyperliquid.xyz/ws` | `wss://api.hyperliquid-testnet.xyz/ws` |
| SDK | `new HttpTransport()` (mainnet by default) | `new HttpTransport({ isTestnet: true })` |

```ts
export function hlUrls(testnet: boolean): { info: string; ws: string } {
  return testnet
    ? { info: 'https://api.hyperliquid-testnet.xyz/info', ws: 'wss://api.hyperliquid-testnet.xyz/ws' }
    : { info: 'https://api.hyperliquid.xyz/info', ws: 'wss://api.hyperliquid.xyz/ws' };
}
// The WS URL can be derived from base: base.replace(/^http/, 'ws') + '/ws'
// The SDK's HttpTransport receives the SAME isTestnet flag.
```

**Protection against mixing networks (recommended pattern).** If the info URL, exchange URL, and network flag point to different networks (for example, the official testnet info host with a mainnet signer), the process refuses to start and logs an explicit network-mismatch error. Otherwise testnet data could reach a mainnet signer. The info URL may point to a read-only proxy. In that case, specify the network with an explicit flag, which must match the transport's `isTestnet`.

---

## 3. Info API: request and response format

### 3.1 General format

- Method `POST`, path `/info`, header `Content-Type: application/json`.
- Body: `{ "type": "<type>", "user"?: "0x...", "dex"?: "xyz", ... }`.
- The response is JSON whose shape depends on `type`.
- Pass addresses in **lowercase**, as every verified client does. Compare addresses in lowercase too.
- Each HIP-3 dex requires a separate request with the `dex` field. If the open-order count differs from the exchange UI, a dex was almost certainly omitted.

### 3.2 Raw fetch (TypeScript): reference wrapper

```ts
const INFO_URL = 'https://api.hyperliquid.xyz/info';
const INFO_FETCH_TIMEOUT_MS = 10_000;

export async function hlInfo<T>(body: Record<string, unknown>): Promise<T> {
  const res = await fetch(INFO_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(INFO_FETCH_TIMEOUT_MS),
  });
  if (!res.ok) {
    await res.body?.cancel().catch(() => {});      // best effort: release the undici connection
    const err = new Error(`HL info HTTP ${res.status} for ${JSON.stringify(body)}`) as Error & { status?: number };
    err.status = res.status;                       // 429 → retry(rateLimit), 5xx → retry(transient)
    throw err;
  }
  try {
    return (await res.json()) as T;
  } catch (e) {
    // 200, but JSON does not parse (the proxy/load balancer returned truncated JSON or HTML) — transient, retry
    throw Object.assign(new Error('HL info: unparsable 200 body'), { transientResponseBody: true, cause: e });
  }
}
```

One-liner for scripts:

```ts
const q = async (b: object) =>
  (await fetch('https://api.hyperliquid.xyz/info', {
    method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b),
  })).json();

await q({ type: 'frontendOpenOrders', user: '0xYOUR_ADDRESS' });
await q({ type: 'clearinghouseState', user: '0xYOUR_ADDRESS', dex: 'xyz' });
```

Variant with an `AbortController` (8 s timeout) and a separate 429 branch:

```ts
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 8000);
try {
  const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: ctl.signal });
  if (r.status === 429) throw new Error('HL 429 rate limit');
  if (!r.ok) throw new Error(`HL info HTTP ${r.status}`);
  return await r.json();
} finally { clearTimeout(t); }
```

Put the info endpoint URL in configuration (default `https://api.hyperliquid.xyz/info`) so a proxy or testnet can be substituted.

### 3.3 Python without dependencies

```python
import json, subprocess

def hl(body):
    out = subprocess.run(
        ["curl", "-s", "--max-time", "12", "-X", "POST",
         "https://api.hyperliquid.xyz/info",
         "-H", "Content-Type: application/json", "-d", json.dumps(body)],
        capture_output=True, text=True).stdout
    try: return json.loads(out)
    except Exception: return None   # timeout or non-JSON (for example, 429 text)

perp = hl({"type": "clearinghouseState", "user": "0xyour_address_lowercase"})
# calling code guards against None: (perp or {}).get(...)
```

Variant using `urllib`:

```python
import json, urllib.request
req = urllib.request.Request(API, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=20) as r:
    data = json.loads(r.read())
```

### 3.4 Main info-request types

Weight is given for the IP budget. Values match the HL weight table: light requests weigh 2, most others 20, and `userRole` 60. “—” means the weight is not recorded here. Whether the weight of `userFills`/`userFillsByTime`/`candleSnapshot` grows with response size was not verified (a fixed weight of 20 is assumed here; see rate-limits.md), so the table has no “20+” labels.

| `type` | Additional fields | Weight | What it returns / purpose |
|---|---|---|---|
| `meta` | `dex?` | 20 | `{ universe: [{ name, szDecimals, maxLeverage, onlyIsolated?, isDelisted? }] }`. Asset id = index in `universe`. Same shape as SDK `info.meta()`. Static and cacheable |
| `l2Book` | `coin` | 2 | `{ levels: [bids[], asks[]], time? }`, level `{ px, sz }` (strings) |
| `allMids` | `dex?` | 2 | mid prices by coin |
| `clearinghouseState` | `user`, `dex?` | 2 | positions, `accountValue`, `totalMarginUsed`, per-position leverage |
| `spotClearinghouseState` | `user` | 2 | `{ balances: [{ coin, total, hold, spotHold?, entryNtl, borrowed?, ltv? }], portfolioMarginEnabled? }`. Add ONLY free stables to perp equity: `max(0, total − reserve)`, where `reserve = spotHold` when present, otherwise `hold`. Do not add all spot: on Unified Account, `hold` mirrors perp margin (×2); on portfolio margin, `hold` < 0. Details in balance-and-equity.md §2–3 |
| `openOrders` | `user`, `dex?` | 20 | `[{ coin, oid, side: 'B'\|'A', limitPx, sz }]` |
| `frontendOpenOrders` | `user`, `dex?` | 20 | live orders plus `reduceOnly`, time, and more |
| `portfolio` | `user` | 20 | aggregate perp equity and history by DEX |
| `candleSnapshot` | `req: {...}` | 20 | candles (for backtesting) |
| `userFills` | `user` | 20 | latest fills |
| `userFillsByTime` | `user`, `startTime`, `aggregateByTime: false` | 20 | fills over a period |
| `userNonFundingLedgerUpdates` | `user`, `startTime` | 20 | `[{ time, hash, delta }]`: deposits, withdrawals, transfers |
| `userRateLimit` | `user` | 20 | `{ cumVlm, nRequestsUsed, nRequestsCap, nRequestsSurplus? }` |
| `userFees` | `user` | 20 | `{ userCrossRate, userAddRate, activeReferralDiscount }` |
| `extraAgents` | `user` | 20 | `[{ address, name, validUntil: number \| null }]`, approved agent wallets |
| `userRole` | `user` | 60 | `{ role: 'missing'\|'user'\|'vault'\|'agent'\|'subAccount', data?: { user?, master? } }`. For `agent`, the master is in `data.user`; for `subAccount`, in `data.master`. Expensive: call once at startup |
| `userDexAbstraction` | `user` | 2 | `true` only for legacy dex abstraction. Returns `false` for Unified Account; collateral mode is reliably visible only in WS `webData3` (details in accounts.md §5.2) |
| `webData2` | `user` | — | aggregate “frontend” account snapshot |
| `maxBuilderFee` | `user`, `builder` | 20 | number: approved builder-fee ceiling in **tenths of a basis point** (`40` = 0.04%) |

Useful derived values:

- `pxDecimals` for a perp = `Math.max(0, 6 - szDecimals)`.
- Approved agents: `extraAgents.filter(a => !a.validUntil || Number(a.validUntil) > Date.now()).map(a => a.address.toLowerCase())`.

### 3.5 Asset id and HIP-3 dex

- Main perp dex: asset id = the coin's index in `meta.universe`.
- Builder-deployed dex (`xyz` and others): request meta using `{type:'meta', dex:'xyz'}`; asset id = index in that dex's universe plus the dex offset. A smoke test produced `xyz:SP500 = 110052` and `xyz:TSLA = 110001`, so the xyz offset is 110000.
- In the dex universe, the coin name is prefixed: `xyz:TSLA`.

### 3.6 `/info` response codes and CORS (live read-only requests on 2026-09-22)

| Case | Status | `Content-Type` | Body |
|---|---|---|---|
| Unknown `type`, body of the wrong shape, or required field missing or of the wrong type | 422 | `text/plain; charset=utf-8` | `Failed to deserialize the JSON body into the target type` |
| Entity not found: unknown `dex`, unknown coin for `candleSnapshot` / `fundingHistory` / `recentTrades`, invalid `l2Book` aggregation | 500 | `application/json` | `null` (`Content-Length: 4`) |
| Unknown coin for `l2Book`, unknown `dex` for `allMids` | 200 | `application/json` | `null` |

`500 null` is an “unable to serve this request” response, not a transient failure: retrying produces the same result (`@markpaper/hl-kit` represents it as `HlHttpError.invalidRequest`, and retries do not repeat it). Type-specific details are in market-data.md §6–§8.

**The same requests through WS `post`** (`wss://api.hyperliquid.xyz/ws`, read-only, 2026-09-23 06:05Z) receive different answers than over HTTP, with a distinct WS shape for each of the three HTTP responses:

```
HTTP 500 null → {"channel":"post","data":{"id":1,"response":{"type":"error","payload":"500 Internal Server Error"}}}
               (candleSnapshot / recentTrades / fundingHistory with an unknown coin, clearinghouseState and meta
               with an unknown dex, l2Book nSigFigs 1)
HTTP 200 null → {"channel":"post","data":{"id":2,"response":{"type":"info","payload":{"type":"l2Book","data":null}}}}
               (l2Book with an unknown coin, allMids with an unknown dex)
HTTP 422      → {"channel":"error","data":"Error parsing JSON into valid websocket request: {\"method\":\"post\",\"id\":3,\"request\":{…}}"}
               (l2Book without coin, unknown type, fundingHistory without startTime): this is not a post response,
               but an error-channel frame that echoes the envelope
```

SDK 0.33.3 rejects the first and third shapes as `WebSocketRequestError` with the `payload` / `data` text; for the third, the id is found in the echoed envelope (`_dispatcher.js` `_handleErrorEvent`). It returns the second shape as `null`.

**Parameterized reference requests** (HTTP, read-only, 2026-09-23) use the same three response classes:

| Request | Response |
|---|---|
| `perpAnnotation` with an unknown coin | 500 `null` |
| `perpAnnotation` `BTC` | 200 `null` (a main-dex coin has no annotation) |
| `perpAnnotation` `xyz:TSLA` | 200, object `{ category, description, … }` |
| `perpAnnotation` without `coin`; `tokenDetails` with a `tokenId` that is not 34 hex characters or is a number; `marginTable` `id: "x"`; `borrowLendReserveState` `token: "x"`; `settledOutcome` `outcome: "x"` | 422 |
| `marginTable` `id: 999999` | 500 `null` |
| `settledOutcome` `outcome: 999999` | 200 `null` |

Longest names on the same day (`perpDexs` + `allPerpMetas`, 10 dexes, 524 coins): dex — 4 characters, coin — 14 (`xyz:ALUMINIUM`).

**CORS.** `OPTIONS /info` and `OPTIONS /exchange` (with and without `Origin`) → 200, `Content-Length: 0`, and headers `access-control-allow-origin: *`, `access-control-allow-methods: *`, `access-control-allow-headers: *`, `allow: POST`, `vary: origin`, `vary: access-control-request-method`, `vary: access-control-request-headers`. `POST /info` responses (identical for 200, 422, and 500) include `access-control-allow-origin: *`, `access-control-expose-headers: *`, and the same three `vary` headers. HL does not send `access-control-allow-credentials`; browser requests are made without cookies.

---

## 4. SDK: clients and transport

### 4.1 Imports and setup

```ts
import { ExchangeClient, InfoClient, HttpTransport } from '@nktkas/hyperliquid';
import { privateKeyToAccount } from 'viem/accounts';

// AGENT (API wallet) key. Normalize the 0x prefix:
const pk = (agentPrivKey.startsWith('0x') ? agentPrivKey : `0x${agentPrivKey}`) as `0x${string}`;
const account = privateKeyToAccount(pk);                // throws for a malformed key
const agentAddress = account.address.toLowerCase();

const exchange = new ExchangeClient({
  wallet: account,
  transport: new HttpTransport({ isTestnet: false, timeout: 10_000 }),
});
const info = new InfoClient({ transport: new HttpTransport() });   // no parameters = mainnet
```

### 4.2 `HttpTransport` options (0.27.x)

| Option | Example | Meaning |
|---|---|---|
| `isTestnet` | `true` / `false` (mainnet by default) | selects the URL **and signing domain** |
| `timeout` | `10_000` | request timeout, ms |
| `server` | `{ mainnet: { api: url }, testnet: { api: url } }` | overrides the API base URL (for example, with a proxy) |
| `fetchOptions` | `{ dispatcher: new Agent({ localAddress: ip }) }` | additional `fetch` init fields (see §9 for egress IP) |

```ts
const transport = new HttpTransport({
  isTestnet,
  server: { mainnet: { api: exchangeApiUrl }, testnet: { api: exchangeApiUrl } },
});
const exchange = new ExchangeClient({ wallet: account, transport });
```

`server` controls only the destination address, while `isTestnet` controls the signing network. Both must point to the same network.

### 4.3 Subaccount / vault

Actions on behalf of a subaccount (or vault) are signed by the **main** account's agent, with the subaccount address added to the request:

```ts
const exchange = new ExchangeClient({
  wallet: account,
  transport,
  ...(vaultAddress ? { defaultVaultAddress: vaultAddress } : {}),   // '0xSUBACCOUNT_ADDRESS'
});
```

### 4.4 Client lifecycle

- **One `InfoClient` per process.** `/info` requires neither a signature nor an agent address, so the client can be shared. A separate client per user is unnecessary.
- **One `ExchangeClient` per (agent key, master address) pair.** Cache it in `Map<accountId, client>` under `${pk}:${address}` and recreate it when the key or address changes. Do not recreate the viem account and SDK client for every request.
- Initialization is lazy and idempotent.
- SDK exchange calls should also pass through the common per-IP budget throttle at high priority, so they precede background info reads.
- Often `InfoClient` is barely used and reads go through raw fetch, which makes weights and timeouts easier to control.

### 4.5 `@nktkas/hyperliquid` 0.33.3 and the official Python SDK 0.24.0: verified by reading source (2026-09-22)

- **0.33.3 validates only requests with valibot schemas** (`parse(XRequest, …)` in `esm/api/*/_methods`); response types exist only in TypeScript. The SDK does not reject a differently shaped response—the field is simply `undefined`. Validate response shapes yourself.
- **The address option in 0.33.3 is `apiUrl`** (`new HttpTransport({ isTestnet, apiUrl })`), not `server` from 0.27.x (§4.2). The SDK silently ignores an unknown option and sends requests to real HL. An `apiUrl` with a path preserves the path: `…/proxy` becomes `…/proxy/info`. The WS transport uses `url` as-is.
- **`SymbolConverter.create()`** (`@nktkas/hyperliquid/utils`) loads `meta`, `spotMeta`, and `outcomeMeta` in one `Promise.all` (plus `perpDexs` when `dexs` is used). A server that does not answer `outcomeMeta` makes converter creation fail entirely (`HttpRequestError`).
- **User-signed (EIP-712) actions in 0.33.3** are those routed through `executeUserSignedAction`: `approveAgent`, `approveBuilderFee`, `usdSend`, `spotSend`, `withdraw3`, `usdClassTransfer`, `sendAsset`, `sendToEvmWithData`, `cDeposit`, `cWithdraw`, `tokenDelegate`, `userDexAbstraction`, `userSetAbstraction`, `userPortfolioMargin`, `linkStakingUser`, `stakingLinkDisableTradingUser`, `convertToMultiSigUser` (17). All other actions are L1 (signed by the agent key).
- **Python SDK 0.24.0:** HTTP address is `base_url + url_path` (`"/info"`, `"/exchange"`, `hyperliquid/api.py`), WS is `"ws" + base_url[len("http"):] + "/ws"` (`websocket_manager.py`), and the signing network is selected by exact comparison `base_url == MAINNET_API_URL` (`exchange.py`): with any other URL, the SDK signs as testnet. Its `websocket-client` 1.9.2 sends the header `Origin: http(s)://<host:port>` by default (`_handshake.py`; disabled by `suppress_origin`)—a server that allows WS only without `Origin` returns 403 to the Python SDK. Not verified with a live run.

---

## 5. Agent wallets in the SDK

- `ExchangeClient.wallet` is a viem account made from the private key of the **agent (API) wallet**. A bot does not need the master key.
- Store the key outside the repository in storage intended for secrets; the application chooses the exact storage and rotation design.
- If trading is enabled (not dry-run) and the key is missing, the process must fail at startup with an explicit error rather than silently enter dry-run.
- **Association check before trading:**
  1. `derived = privateKeyToAccount(pk).address.toLowerCase()`. If the constructor throws, the key is malformed: set `agentApproved = false` and log an explicit reason (malformed key, trading disabled).
  2. The master address's `extraAgents` list, filtered by `validUntil`, must contain `derived`.
  3. Optionally, `userRole(derived)` must return `role === 'agent'` with the master in `data.user`.

---

## 6. Exchange actions through the SDK

### 6.1 Orders and cancellations

```ts
// Batch of limit orders. tif: 'Gtc' | 'Alo' (post-only: an order that crosses the book is
// rejected by the exchange rather than executed as taker) | 'Ioc'.
const res = await exchange.order({
  orders: specs.map((o) => ({
    a: assetIndex,              // asset id (§3.5)
    b: o.side === 'B',          // true = buy
    p: o.pxStr,                 // price as a string, already quantized
    s: o.szStr,                 // size as a string
    r: o.reduceOnly === true,   // reduce-only
    t: { limit: { tif: o.tif } },   // 'Gtc' | 'Alo' | 'Ioc'
  })),
  grouping: 'na',
});

// Cancel by oid
await exchange.cancel({ cancels: oids.map((o) => ({ a: assetIndex, o })) });
```

Exchange response (the same body is stored inside `ApiRequestError`):

```jsonc
// order
{ "status": "ok", "response": { "data": { "statuses": [
  { "resting": { "oid": 123 } },
  { "filled":  { "oid": 124, "totalSz": "0.5", "avgPx": "100.1" } },
  { "error":   "rejection text" }
] } } }
// cancel: statuses = ["success" | { "error": "..." }]
```

Statuses appear **in the same order** in which the orders were sent. A cancellation rejection such as “never placed / already canceled / filled” usually means “the order is not in the book” for application logic; the caller makes that decision.

IP weights: an order batch weighs 1 plus 1 for every 40 orders. Under the address limit, every order counts as a separate request.

### 6.2 Other actions

| Action (SDK method) | Parameters | Idempotent? | Notes |
|---|---|---|---|
| `updateLeverage` | `{ asset, isCross: true, leverage }` | yes | repeating the same value is safe; transient errors can be retried broadly |
| `agentEnableDexAbstraction` / `enableDexAbstraction` | — | yes (medium confidence) | enables dex abstraction before trading on a HIP-3 dex (`xyz`). A call error means execution failed |
| `reserveRequestWeight` | `{ weight }` | no (paid) | purchases additional address request capacity at **0.0005 USDC per request** |
| `usdClassTransfer` | `{ amount: '12.34', toPerp: true }` | no | spot → perp transfer; `amount` is a string with 2 decimal places. On a unified account it fails with an error matching `/unified\|disabled/i`: remember this and do not try again |
| `sendAsset` | `{ destination, sourceDex: 'spot', destinationDex: 'spot', token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054', amount }` | no | gasless USDC transfer to another address within HL. Working option for a unified account where USDC is in the spot wallet. `amount` is a string |
| `usdSend` | — | no | gasless USDC transfer within HL Core (classic action) |
| `approveBuilderFee` | `{ maxFeeRate: '0.04%', builder }` | — | user-signed; signed by the MASTER wallet, not the agent (§8) |

```ts
await exchange.sendAsset({
  destination: '0xRECIPIENT_ADDRESS',
  sourceDex: 'spot',
  destinationDex: 'spot',
  token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054',
  amount: '12.34',
});

await exchange.usdClassTransfer({ amount: (1.5).toFixed(2), toPerp: true });
```

Recommendations for automated transfers:

- a separate hot wallet with the minimum operating balance, so its compromise does not affect trading keys;
- dry-run by default;
- a minimum transfer amount;
- do not transfer dust (configure the threshold explicitly);
- a “transfer in progress” flag so a second transfer cannot start in parallel.

### 6.3 Request queue and nonce

- SDK 0.27.1 passes all exchange requests for **one wallet** through a one-at-a-time semaphore. Nonce = `Date.now()` with monotonic increment.
- Therefore, `Promise.all([ex.order(...), ex.order(...)])` is no faster than sequential calls. Put more orders in one batch to increase throughput.
- Start independent **reads** on the critical path (for example, account state and meta) in parallel: with cold caches, this materially reduces latency. A promise that may not be needed because of an early return must not reject (`catch → 0/null` internally), or it creates an unhandled rejection.

---

## 7. SDK errors and their classification

### 7.1 What the SDK throws and when (0.27.1)

| Situation | What the SDK throws | Did the exchange apply it? | Action |
|---|---|---|---|
| `status: 'err'`: the entire action was rejected | `ApiRequestError`, `err.response = { status: 'err', response: 'text' }` | no | `batchError = String(response)` |
| `status: 'ok'`, but at least one `statuses[i]` contains `{error}` | `ApiRequestError`, `err.response = { status: 'ok', response: { data: { statuses } } }` | **partially**: adjacent orders may have entered the book | parse `statuses` element by element and collect oids |
| HTTP 429 | `HttpRequestError`, `.response.status === 429` | no | IP limit: do not immediately repeat; backoff is required |
| HTTP 4xx except 408 | `HttpRequestError` with `.response` | no | known outcome |
| HTTP 5xx, 408 | `HttpRequestError` with `.response` | **unknown** | do not place again before reconciling with the book |
| timeout / disconnect | `HttpRequestError` **without** `.response` | **unknown** | same |
| HTTP 200, but body is not JSON | `HttpRequestError`, original `SyntaxError` in `.cause` | unknown | treat as transient |

Every exchange order rejection (insufficient margin, unmatched IoC, price band, reduceOnly violation) arrives as an **exception**. The SDK does not return an object with rejected status.

### 7.2 Snippets

```ts
/** Exchange response body from ApiRequestError (the class is not exported, so identify it by name).
 *  {status:'ok', response:{...}} for a partial rejection, {status:'err', response:'text'} for rejection of the whole action. */
export function apiErrorBody(e: unknown): { status: string; response: unknown } | null {
  const x = e as { name?: string; response?: { status?: string; response?: unknown } };
  if (!x || x.name !== 'ApiRequestError' || !x.response || typeof x.response.status !== 'string') return null;
  return { status: x.response.status, response: x.response.response };
}

/** Classify an SDK transport error. 4xx (except 408) and 429 mean not applied; 5xx/timeout/disconnect have an unknown outcome. */
export function transportOutcome(e: unknown): { batchError: string; outcomeUnknown: boolean; rateLimited: boolean } {
  const x = e as { name?: string; message?: string; response?: { status?: number } };
  const status = x?.name === 'HttpRequestError' && x.response && typeof x.response.status === 'number' ? x.response.status : 0;
  const msg = String(x?.message ?? e).slice(0, 200);
  if (status === 429) return { batchError: `HTTP 429 (IP rate limit): ${msg}`, outcomeUnknown: false, rateLimited: true };
  if (status >= 400 && status < 500 && status !== 408) return { batchError: `HTTP ${status}: ${msg}`, outcomeUnknown: false, rateLimited: false };
  return { batchError: `transport: ${msg}`, outcomeUnknown: true, rateLimited: false };
}

type OrderStatus =
  | { kind: 'resting'; oid: number }
  | { kind: 'filled'; oid: number; totalSz: number; avgPx: number }
  | { kind: 'error'; error: string };

/** Parse an exchange response (or ApiRequestError body) into per-item statuses. */
export function parseOrderStatuses(raw: unknown): OrderStatus[] {
  const statuses = (raw as { response?: { data?: { statuses?: unknown[] } } })?.response?.data?.statuses;
  if (!Array.isArray(statuses)) return [];
  return statuses.map((s): OrderStatus => {
    const x = s as { resting?: { oid: number }; filled?: { oid: number; totalSz: string; avgPx: string }; error?: string };
    if (x?.resting) return { kind: 'resting', oid: Number(x.resting.oid) };
    if (x?.filled) return { kind: 'filled', oid: Number(x.filled.oid), totalSz: Number(x.filled.totalSz), avgPx: Number(x.filled.avgPx) };
    return { kind: 'error', error: String(x?.error ?? JSON.stringify(s)).slice(0, 200) };
  });
}

export async function place(exchange: ExchangeClient, assetIndex: number, orders: PlaceSpec[]) {
  if (!orders.length) return { statuses: [], batchError: null, outcomeUnknown: false };
  try {
    const res = await exchange.order({ orders: toWire(assetIndex, orders), grouping: 'na' });
    return { statuses: parseOrderStatuses(res), batchError: null, outcomeUnknown: false };
  } catch (e) {
    const body = apiErrorBody(e);
    if (body && body.status === 'ok') {
      // partial rejection: adjacent orders may have ENTERED THE BOOK — parse element by element
      return { statuses: parseOrderStatuses(body), batchError: null, outcomeUnknown: false };
    }
    if (body) return { statuses: [], batchError: String(body.response).slice(0, 200), outcomeUnknown: false };
    return { statuses: [], ...transportOutcome(e) };
  }
}
```

Cancellation parsing is the same, except `statuses[i]` is `'success'` or `{error}`.

Placement result: `{ statuses, batchError, outcomeUnknown, rateLimited? }`. With `outcomeUnknown: true`, do not place the orders again until reconciliation shows what is on the exchange. For `status: 'err'` and HTTP 4xx/429, `outcomeUnknown` is false: the exchange rejected the action and nothing was placed.

### 7.3 Transient detector that traverses `.cause`

```ts
export function isTransientError(e: unknown, depth = 0): boolean {
  if (!e || depth > 6) return false;
  const x = e as { name?: string; transientResponseBody?: boolean; cause?: unknown };
  if (e instanceof SyntaxError || x.name === 'SyntaxError' || x.transientResponseBody === true) return true;
  // + application logic: 5xx, network codes, timeouts
  return isTransientError(x.cause, depth + 1);
}
```

Without traversing `.cause`, a “200 + HTML from the load balancer” response bypasses the retry layer entirely.

---

## 8. Signing requests

### 8.1 L1 actions (orders, cancellations, leverage)

The SDK signs these: `ExchangeClient.wallet` contains the agent's viem account, and the transport's `isTestnet` sets the signing domain. There is no need to build anything manually.

### 8.2 User-signed actions (EIP-712), using `approveBuilderFee` as an example

Protocol fields verified against the `@nktkas/hyperliquid` SDK:

- domain: `name: 'HyperliquidSignTransaction'`, `version: '1'`, current `chainId`, zero `verifyingContract`;
- primary type: `HyperliquidTransaction:ApproveBuilderFee`;
- message: `hyperliquidChain`, `maxFeeRate`, `builder`, `nonce`;
- in the action, the same chain id is passed as a hex string in `signatureChainId`, and `nonce` must match both the message and the outer request field;
- for mainnet, `hyperliquidChain: 'Mainnet'`; the testnet value was not verified (§12);
- `/exchange` body: `{ action, signature: { r, s, v }, nonce }`;
- success is confirmed only by a response with `status === 'ok'`; the current builder-fee ceiling is read with the separate `maxBuilderFee` info request.

The calling application chooses the builder address and `maxFeeRate` values. No UI, fee constants, or signing-page deployment design is fixed here.

---

## 9. Network: an SDK transport limitation

`HttpTransport` does not accept an undici `dispatcher` as a dedicated typed option. The transport builds a standard `Request` and calls global `fetch`; only `fetchOptions` is exposed. Passing a nonstandard `dispatcher` through this path was not confirmed with a live request (§12), so a local configuration is not proof of a distinct egress IP or IP budget.

---

## 10. Architectural patterns

- **Separate read and write modules.** The info module only reads (raw fetch), while the executor only writes (SDK). This simplifies auditing and tests.
- **Put network access behind an injectable interface.** Unit tests can supply response fixtures without sending signed actions or requiring an exchange account. Cover partial batch success, explicit rejection, and unknown outcomes independently.
- **Before executing a batch**, fetch coin meta. If meta is missing, log the reason and do not execute the batch (status “dropped”). For a HIP-3 coin, first ensure that dex abstraction is enabled (§6.2).

---

## 11. Pitfalls

| # | What breaks | Why | Correct approach |
|---|---|---|---|
| 1 | After a batch error, untracked orders and orphaned stops (a TP/SL pair) remain in the book | SDK 0.27.1 throws `ApiRequestError` if any `statuses[i]` contains `{error}`, even though adjacent orders entered the book | Catch the error, call `apiErrorBody(e)`, and when `status === 'ok'`, parse `statuses` element by element and retain the oids |
| 2 | `e instanceof ApiRequestError` is unavailable | The class is not exported from the package root | Check `e.name === 'ApiRequestError'` and `typeof e.response?.status === 'string'` |
| 3 | One rejected order aborts the rest of processing even though previous actions already occurred | Exchange rejection arrives as an exception, not an object | Wrap every call: convert `ApiRequestError` to `{ status: 'REJECTED', rawError }`, and rethrow transport and signature errors. For batches, account for pitfall #1 |
| 4 | Duplicate orders after a timeout | With 5xx, timeout, or disconnect, the exchange may have accepted the request | Set `outcomeUnknown = true`, reconcile against `openOrders`/`frontendOpenOrders`, and decide only then |
| 5 | “200 OK” with HTML or truncated JSON is not retried | The SDK stores the `SyntaxError` in `.cause` of `HttpRequestError`; there is no error HTTP status | Traverse `.cause` recursively to depth 6 in `isTransientError` |
| 6 | The bot stalls for minutes: order management, including protective closes, freezes | No timeout: a stalled connection waits ~300 s (undici default) on every retry attempt | `AbortSignal.timeout(8–10 s)` on every info request and `timeout` in `HttpTransport` |
| 7 | Connections leak on non-2xx | An unread body holds the undici socket | `await res.body?.cancel()` before throwing |
| 8 | Parallel `order()` calls do not speed up placement | The SDK passes one wallet's exchange requests through a one-at-a-time semaphore | Put more orders in one batch; parallelize only reads |
| 9 | The bot does not see all orders and positions | A request without `dex` returns only the main perp dex | Poll every dex separately and reconcile the order count with the UI |
| 10 | Testnet data reaches a mainnet signer (or vice versa) | `server` or info URL disagrees with `isTestnet` | Validate the URL and network flag at startup; refuse to start when they are mixed |
| 11 | SDK requests use the wrong IP and limits are not sharded | The SDK calls global fetch and drops nonstandard fields, while `Agent({connect:{localAddress}})` is silently ignored | `new Agent({ localAddress })` through `fetchOptions` or a global dispatcher, plus a mandatory IP echo check at startup |
| 12 | 429 storm after “sharding” | Two throttle buckets use the same physical IP | Separate buckets only for genuinely distinct addresses |
| 13 | `approveBuilderFee` is rejected | The builder address uses mixed case | Use a lowercase builder address in both action and message |
| 14 | `usdClassTransfer` emits warnings every N minutes | A unified account cannot transfer spot → perp (error matches `/unified\|disabled/i`) | Remember an “unsupported” flag and stop retrying. For transfers, use `sendAsset` with `spot`/`spot` |
| 15 | The agent key does not parse | Missing `0x` or junk such as a trailing newline from an env file | Normalize (trim, add `0x`). A malformed key means refusing to trade with an explicit log |
| 16 | The `dispatcher` field breaks `tsc` compilation | `RequestInit` from `lib.dom` does not know the field | Use a local cast at the call boundary |
| 17 | Egress binding stops after an upgrade | undici 7.x changed the handler API | Pin `undici@6.21.2` without `^` |
| 18 | An unused promise terminates the process via unhandled rejection | The promise started in parallel, but the code returned early | “Just in case” promises must not reject (`catch → null`) |

---

## 12. Open questions / not verified

- **`fetchOptions: { dispatcher }` or `setGlobalDispatcher`.** The SDK accepts `fetchOptions` with `dispatcher`, but it builds `new Request(url, init)` (dropping nonstandard fields) and calls global fetch, so replacing the global dispatcher is more reliable for the SDK. The IP self-check confirmed lanes for raw fetch; the SDK path was not checked separately. How to verify: send a request through the SDK transport to an echo service and compare the IP. If it does not match, use `setGlobalDispatcher`.
- **Interpreting a batch error.** Treating every `ApiRequestError` as full rejection (`REJECTED`) is wrong: inspection of SDK 0.27.1 (2026-09) showed that when `response.status === 'ok'`, some orders may have entered the book. This conclusion (§7) takes precedence.
- **`enableDexAbstraction` / `agentEnableDexAbstraction`.** Idempotency is marked with medium confidence. The exact signature and the distinction between user and agent variants are not described here.
- **`usdSend` or `sendAsset`.** `usdSend` as a transfer mechanism is described with medium confidence and without its parameter shape. `sendAsset` was verified for a unified account (2026-06). `usdSend` was not separately rechecked on a regular (non-unified) account.
- **HIP-3 asset id.** Only `xyz` (offset 110000) was verified. The general formula for other dexes (according to HL documentation: `100000 + perpDexIndex * 10000 + index`) was not verified live.
- **Testnet for user-signed actions.** Only the mainnet signature (`hyperliquidChain: 'Mainnet'`) was verified live. The testnet value (expected to be `'Testnet'`) was not verified.
- **The weight of `webData2`** is not recorded here. Read it from the current HL table. Other weights in §3.4 were reconciled with rate-limits.md §2.1.
- **A Python sidecar for signing** (Python SDK for L1 signing next to a TS bot) is not described here. Only dependency-free Python reads from `/info` are included.
- **Shapes of `candleSnapshot.req`, `portfolio`, `webData2`.** These requests are used, but their exact request and response fields are not recorded here.
- **Builder fee on an order** is described in fees.md §3: field `builder: { b, f }` at the top level of the action next to `orders`/`grouping`, lowercase `b`, `f` in tenths of a bp (perp ceiling 100), not above `maxBuilderFee`. Whether `builder` can be attached to native TP/SL is not verified (fees.md §8); see fees.md §3.7 for the risk of stale `f` during close retries.
- **Gtc and trigger orders** are described in orders.md §2 and §4 and risk-and-margin.md §9.2 (`grouping:'positionTpsl'`, `s:'0'`, string statuses without oid).

---

Knowledge snapshot: 2026-09; dates of individual checks are in the text. The HL API changes—recheck limits and response shapes.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Hyperliquid knowledge base” and link to the original and the license._
