Skip to content
38 changes: 38 additions & 0 deletions docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,44 @@ and cannot reach older history. `historicalOrders` (at most 2000 most recent ord
paginated. `userFillsByTimeAll` rejects `reversed: true`: the walk moves forward from `startTime` and needs ascending
pages.

### Caching slow-changing metadata

Info responses are never cached by default. If your app polls metadata endpoints (`meta`, `spotMeta`, …) in a loop,
wrap the transport in `InfoCacheTransport` — an opt-in TTL cache that works over both HTTP and WebSocket transports and
leaves every non-allowlisted request untouched:

```ts
import { HttpTransport, InfoCacheTransport, InfoClient } from "@bloxwap/hyperliquid";

const transport = new InfoCacheTransport(new HttpTransport(), {
ttl: 60_000, // default TTL for every cached endpoint (1 minute)
ttlByType: { marginTable: 600_000 }, // per-endpoint overrides
});
const client = new InfoClient({ transport });

await client.meta(); // hits the network
await client.meta(); // served from cache until the TTL expires
```

Only a conservative allowlist of listing- or deployment-driven endpoints is cached — `meta`, `spotMeta`,
`allPerpMetas`, `perpDexs`, `marginTable`, `tokenDetails`, `outcomeMeta`, `outcomeTemplates`. Responses with live
market data (`metaAndAssetCtxs`, `spotMetaAndAssetCtxs`), exchange status, user state, and order books always pass
through uncached. Cache keys incorporate the request params, so e.g. `marginTable` with different `id`/`dex` values
never collide, and concurrent identical calls share one in-flight request.

Metadata changes are rare but unannounced (new listings, new DEXs), so the useful TTL band is seconds to minutes:
30 s – 5 min for the `meta` family and `outcomeMeta`; 5 – 10 min or more for the near-static `marginTable`,
`perpDexs`, `tokenDetails`, and `outcomeTemplates`. The default is 60 s. Call `transport.clear()` to force a refetch
of everything.

Two interactions to be aware of:

- `SymbolConverter` fetches `meta`/`spotMeta`/`perpDexs`/`outcomeMeta` through whatever transport it is given. With a
caching transport, its `reload()` serves cached data within the TTL — give the converter its own unwrapped transport,
or call `clear()` first, when a reload must see fresh listings.
- `InfoCacheTransport` implements only the request interface. When wrapping a `WebSocketTransport`, pass the raw
WebSocket transport to `SubscriptionClient` and the wrapped one to `InfoClient`.

## Exchange endpoint

`ExchangeClient` requires a wallet for [signing](signing.md#wallet-compatibility) and works with any transport. See all
Expand Down
29 changes: 22 additions & 7 deletions docs/reference/known-drift.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,9 @@ When an entry is resolved upstream (docs fixed, or server aligned with docs), mo
- **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.
- **SDK behavior:** fixed — `OutcomeMetaResponse` in `src/api/info/_methods/outcomeMeta.ts` declares
`deployer` as an optional field, so it is typed and the schema-coverage test accepts it. The docs still don't
mention the field, so this entry stays open until they do.

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

Expand All @@ -113,9 +112,25 @@ When an entry is resolved upstream (docs fixed, or server aligned with docs), mo
`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.
- **SDK behavior:** fixed — the union in `src/api/info/_methods/validatorL1Votes.ts` includes the
`registerTemplate` variant (and the `settleQuestion2` variant added alongside it), so live votes validate. The
docs still don't show the variant, so this entry stays open until they do.

### 11. Aug-2026 outcome-template surface is undocumented

- **Observed:** 2026-08-23.
- **Docs claim:** the info-endpoint and exchange-endpoint pages have no entries for `outcomeTemplates`,
`usdcRouting`, `activateOutcomeDeployer`, the `spotDeploy` outcome sub-actions
(`registerStandaloneOutcomeFromTemplate`, `registerQuestionFromTemplate`, `settleOutcome`, `settleQuestion2`),
`twapOrder`'s `details` (trigger/stop), `reserveRequestWeight`'s `destination`, or `marginTable`'s `dex`
parameter.
- **Server reality:** all of the above are live — they shipped in the Aug-2026 "HIP-4 outcome templates" API drop.
- **SDK behavior:** supported — schemas were implemented against the reference TypeScript SDK
([nktkas/hyperliquid](https://github.com/nktkas/hyperliquid) v0.33.3), which tracks the deployed API, then
widened where live testnet responses went further: `outcomeTemplates` serves keyword formats `uDecimal`, `uInt`,
and `shortString` and a `role` union of `standaloneOutcome` / `questionOutcome` / `"question"` that the upstream
schema doesn't cover (observed 2026-08-24). If the official docs publish different shapes when they catch up,
reconcile the schemas then.

## Resolved

Expand Down
57 changes: 51 additions & 6 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,13 @@ The limiter bills the documented weights:
| `explorer` | 40 |
| `exchange` | `1 + floor(batchLength / 40)` |

The exchange batch length is read from the action's `orders`/`cancels`/`modifies` array, unwrapping multi-sig
actions (the batch lives inside `action.payload.action`). Those three keys are the documented batch subset — other
actions carry arrays that are not batch-billed (`spotDeploy`/`perpDeploy` payloads, the multi-sig `signatures`
array), so the limiter deliberately does not bill by a generic "first array" rule; whether the protocol's
`batch_length` covers anything more is tracked in [issue #49](https://github.com/bloxwap/hyperliquid/issues/49).
The exchange batch length is the longest array found anywhere in the action at any depth, unwrapping multi-sig
actions (the batch lives inside `action.payload.action`; the wrapper's `signatures` array is auth material, never
billed). That covers the documented batch keys (`orders`/`cancels`/`modifies`) and also bills deployer arrays
(`spotDeploy` genesis tuples, `perpDeploy` setter lists) and any future batch action — the docs define
`batch_length` as "the length of the array in the action" without closing the set, and over-billing is the safe
side. Arrays shorter than 40 entries (fixed tuples), `twapOrder`'s single `twap` object, and
`convertToMultiSigUser`'s wire-stringified `signers` keep the minimum weight of 1.

Response-size surcharges can only be known once the response arrives, so they are debited from the bucket **after**
the response: 1 extra weight per 20 returned items on the documented list endpoints (`recentTrades`, `userFills`,
Expand All @@ -135,7 +137,8 @@ then wait off the real cost rather than the estimate the request was sent with.
that older `blockList` blocks "may be weighted more heavily" server-side, so the +1-per-block debit is exact only
for recent blocks; and the item-count rule is an interpretation — the docs do not say whether the count is exactly
the top-level response array length (what the limiter bills) nor whether partial chunks round up or down (the
limiter rounds up, the conservative choice). Both are tracked in [issue #49](https://github.com/bloxwap/hyperliquid/issues/49).
limiter rounds up). Both are settled client-side as deliberate conservative over-estimates; only the server-side
truth remains outstanding ([issue #49](https://github.com/bloxwap/hyperliquid/issues/49)).

- The wait happens before the request timeout is armed, so throttling never trips `timeout` / `exchangeTimeout`;
aborting the request's signal cancels the wait instead — an aborted request never reaches the wire.
Expand All @@ -151,8 +154,44 @@ exceed what one instance can see still hit the server limit, and there is no end
state. Handle [`HttpRateLimitError`](error-handling.md#httpratelimiterror) (which carries `status` and, when the
server sends a `Retry-After` header, a `retryAfter` hint in seconds) as the backstop.

### Automatic retry on 429

For hands-off 429 handling, opt into `retryOnRateLimit`:

```ts
const transport = new HttpTransport({
retryOnRateLimit: true, // or { maxRetries: 3, maxDelayMs: 30_000 } — the defaults
});
```

When the server answers 429, the transport waits and retries instead of throwing: if the response carried a
`Retry-After` header it waits exactly that long (plus up to 1 s of jitter to spread herds); otherwise it falls back
to full-jitter exponential backoff. A `Retry-After` longer than `maxDelayMs` surfaces the `HttpRateLimitError`
rather than retrying sooner than the server allowed, and after `maxRetries` attempts the error propagates. The
overall `timeout` / `exchangeTimeout` spans every attempt and wait, caller aborts interrupt the wait, and when
`rateLimit` is also enabled each retry re-debits the bucket (the server bills attempts, not logical requests). The
two options complement each other: the limiter prevents 429s, the retry absorbs the rest. Off by default.

The separate **address-based** limits (requests allowed per user, growing with cumulative trading volume) are what
the [`userRateLimit`](clients.md) info method reports — it has no view of the shared per-IP weight budget either.
Per the official docs, an address gets 1 request per 1 USDC traded cumulatively since inception, on top of an
initial buffer of 10,000 requests; once limited, it is allowed one request every 10 seconds. Sub-accounts count as
separate users, and the limit applies to actions only, not info requests. Cancels get their own cumulative limit of
`min(limit + 100000, limit * 2)`, so hitting the address-based limit still leaves room to cancel open orders.
Batching interacts differently with the two budgets: a batch of `n` orders (or cancels) counts as one request
against the per-IP weight budget but as `n` requests against the address-based one.

Three adjacent rules from the exchange-endpoint docs matter to anyone pacing orders:

- **Open-order limit** — 1000 open orders per user plus one more per 5M USDC of trading volume, capped at 5000
total. An order placed while the user already has at least 1000 open orders is rejected if it is reduce-only or a
trigger order.
- **High-congestion throttling** — during high congestion an address is limited to 2x its previous-day maker-share
percentage of the block space; the maker share is scaled by the asset's fee-tier volume contribution (HIP-3 assets
under growth mode count less) and computed once per UTC day. During high traffic it therefore helps not to resend
cancels whose results the API already returned.
- **Stale `expiresAfter`** — an action canceled because its `expiresAfter` timestamp went stale consumes 5x the
usual address-based rate limit.

## WebSocket

Expand Down Expand Up @@ -270,6 +309,12 @@ in their own text ("across all websocket connections"):
| Messages sent to Hyperliquid | 2000/minute | per IP, across all connections |
| Simultaneous inflight post requests | 100 | per IP, across all connections |

The unique-user value needs a caveat: the official docs still say **10**, while the server's own refusal message
says 15 (`Cannot track more than 15 total users.`) — and neither number is what the server enforces. A live mainnet
probe found the 15th distinct user refused, so the SDK guards at **14**, one below the message's claim
(`src/transport/websocket/_quota.ts`). Erring low is the safe direction: refusing one subscription the server might
have taken costs a slot, while admitting one it refuses costs a 10 s request timeout.

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
Expand Down
123 changes: 123 additions & 0 deletions src/api/exchange/_methods/activateOutcomeDeployer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import * as v from "valibot";

// ============================================================
// API Schemas
// ============================================================

import { Hex, UnsignedInteger } from "../../_schemas.ts";

/**
* Activate or deactivate the signer as an outcome deployer.
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#activation
*/
export const ActivateOutcomeDeployerRequest = /* @__PURE__ */ (() => {
return v.object({
/** Action to perform. */
action: v.object({
/** Type of action. */
type: v.literal("activateOutcomeDeployer"),
/** Deactivate instead of activate. */
isDeactivate: v.boolean(),
}),
/** Nonce (timestamp in ms) used to prevent replay attacks. */
nonce: UnsignedInteger,
/** ECDSA signature components. */
signature: v.object({
/** First 32-byte component. */
r: v.pipe(Hex, v.length(66)),
/** Second 32-byte component. */
s: v.pipe(Hex, v.length(66)),
/** Recovery identifier. */
v: v.picklist([27, 28]),
}),
/** Expiration time of the action. */
expiresAfter: v.optional(UnsignedInteger),
});
})();
export type ActivateOutcomeDeployerRequest = v.InferOutput<typeof ActivateOutcomeDeployerRequest>;

/**
* Successful response without specific data or error response.
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#activation
*/
export type ActivateOutcomeDeployerResponse =
| {
/** Successful status. */
status: "ok";
/** Response details. */
response: {
/** Type of response. */
type: "default";
};
}
| {
/** Error status. */
status: "err";
/** Error message. */
response: string;
};

// ============================================================
// Execution Logic
// ============================================================

import {
type ExchangeConfig,
type ExcludeErrorResponse,
buildAction,
executeL1Action,
type ExtractRequestOptions,
} from "./_base/mod.ts";

/** Schema for action fields (excludes request-level system fields). */
const ActivateOutcomeDeployerActionSchema = /* @__PURE__ */ (() => {
return v.object(ActivateOutcomeDeployerRequest.entries.action.entries);
})();

/** Action parameters for the {@linkcode activateOutcomeDeployer} function. */
export type ActivateOutcomeDeployerParameters = Omit<v.InferInput<typeof ActivateOutcomeDeployerActionSchema>, "type">;

/** Request options for the {@linkcode activateOutcomeDeployer} function. */
export type ActivateOutcomeDeployerOptions = ExtractRequestOptions<v.InferInput<typeof ActivateOutcomeDeployerRequest>>;

/** Successful variant of {@linkcode ActivateOutcomeDeployerResponse} without errors. */
export type ActivateOutcomeDeployerSuccessResponse = ExcludeErrorResponse<ActivateOutcomeDeployerResponse>;

/**
* Activate or deactivate the signer as an outcome deployer.
*
* Signing: L1 Action.
*
* @param config General configuration for Exchange API requests.
* @param params Parameters specific to the API request.
* @param opts Request execution options.
* @return Successful response without specific data.
*
* @throws {ValidationError} When the request parameters fail validation (before sending).
* @throws {TransportError} When the transport layer throws an error.
* @throws {ApiRequestError} When the API returns an unsuccessful response.
*
* @example
* ```ts
* import { HttpTransport } from "@bloxwap/hyperliquid";
* import { activateOutcomeDeployer } from "@bloxwap/hyperliquid/api/exchange";
* import { privateKeyToAccount } from "viem/accounts";
*
* const wallet = privateKeyToAccount("0x...");
* const transport = new HttpTransport(); // or `WebSocketTransport`
*
* await activateOutcomeDeployer({ transport, wallet }, {
* isDeactivate: false,
* });
* ```
*
* @see https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-4-deployer-actions#activation
*/
export function activateOutcomeDeployer(
config: ExchangeConfig,
params: ActivateOutcomeDeployerParameters,
opts?: ActivateOutcomeDeployerOptions,
): Promise<ActivateOutcomeDeployerSuccessResponse> {
const action = buildAction(ActivateOutcomeDeployerActionSchema, { type: "activateOutcomeDeployer", ...params }, opts);
return executeL1Action(config, action, opts);
}
9 changes: 5 additions & 4 deletions src/api/exchange/_methods/order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,16 @@ export const OrderRequest = /* @__PURE__ */ (() => {
* - `"na"`: Standard order without grouping.
* - `"normalTpsl"`: TP/SL order with fixed size that doesn't adjust with position changes.
* - `"positionTpsl"`: TP/SL order that adjusts proportionally with the position size.
* - `{ p: number }`: Order priority rate as a fraction `p / 1e8` (max `p = 80000`, i.e. 8 bps).
* Only valid when every order is IOC on a perp asset.
* - `{ p: number }`: Order priority rate as a fraction `p / 1e8` (max `p = 1e8`, i.e. 100%).
* Only valid when every order is on a non-outcome asset and either every order is IOC
* or every order is a non-reduce-only ALO.
*/
grouping: v.optional(
v.union([
v.picklist(["na", "normalTpsl", "positionTpsl"]),
v.object({
/** Priority rate as a fraction `p / 1e8` (max `80000`, i.e. 8 bps). */
p: v.pipe(UnsignedInteger, v.maxValue(80000)),
/** Priority rate as a fraction `p / 1e8` (max `100_000_000`, i.e. 100%). */
p: v.pipe(UnsignedInteger, v.maxValue(100_000_000)),
}),
]),
"na",
Expand Down
4 changes: 3 additions & 1 deletion src/api/exchange/_methods/reserveRequestWeight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as v from "valibot";
// API Schemas
// ============================================================

import { Hex, UnsignedInteger } from "../../_schemas.ts";
import { Address, Hex, UnsignedInteger } from "../../_schemas.ts";

/**
* Reserve additional rate-limited actions for a fee.
Expand All @@ -18,6 +18,8 @@ export const ReserveRequestWeightRequest = /* @__PURE__ */ (() => {
type: v.literal("reserveRequestWeight"),
/** Amount of request weight to reserve. */
weight: v.pipe(UnsignedInteger, v.maxValue(1844674407370955)), // Truncated max uint64 / 10000
/** Address of an existing user to reserve the weight for. */
destination: v.optional(Address),
}),
/** Nonce (timestamp in ms) used to prevent replay attacks. */
nonce: UnsignedInteger,
Expand Down
Loading
Loading