Skip to content

Releases: bloxwap/hyperliquid

v0.3.0

Choose a tag to compare

@joeblau joeblau released this 24 Aug 18:20
51af4c3

Syncs the SDK with the Aug-2026 Hyperliquid API drop (HIP-4 outcome templates) and lands two opt-in transport features. All schema changes were verified against upstream nktkas/hyperliquid v0.33.3, live testnet probes, and the full offline gate (format/lint/docs/types/jsdoc/export/imports + 2034 tests, 0 fail).

Added

HIP-4 outcome templates and usdcRouting (#98#111). New info.outcomeTemplates and info.usdcRouting endpoints, exchange.activateOutcomeDeployer, and the outcome sub-actions of spotDeploy. outcomeTemplates is deliberately widened beyond upstream after live testnet probes: it accepts the questionOutcome / "question" roles and the uDecimal, uInt, and shortString keywords. New optional parameters: twapOrder.details, reserveRequestWeight.destination, and marginTable.dex.

InfoCacheTransport. An opt-in TTL cache in front of eight slow-changing info endpoints, with param-distinct cache keys and in-flight request dedup — repeated metadata lookups no longer cost a round trip each.

retryOnRateLimit transport option. Opt-in 429 retry that honors the server's Retry-After header. Off by default; no existing behavior changes unless you enable it.

Changed

Schemas widened to the current API surface. Order grouping cap raised from 80,000 to 1e8; enum widenings across OrderProcessingStatus, twapHistory, and FrontendOpenOrder; new borrowLendUserState health states; the userFees stakingLink union; and drift fixes in UserFill, spotClearinghouseState, outcomeMeta, settledOutcome, subAccounts2, and legalCheck.

Exchange weight billing now bills the longest array at any depth (#49). Previously only top-level arrays were counted, so nested batch actions under-reported their weight against the rate limiter.

Upgrading from 0.2.1

No API breaks and no changed defaults — the new transport features are opt-in. Schema widenings only accept more of what the live API already returns, so payloads that validated before still validate.

The new outcome-template shapes are documented in docs/reference/known-drift.md (entry 11); the official GitBook docs have not caught up yet and will be reconciled when they publish.

v0.2.1

Choose a tag to compare

@joeblau joeblau released this 04 Aug 05:40
2121e18

Four fixes. Every one was implemented, then independently cross-reviewed by three agents — Claude (Opus/Fable), Kimi K3 Max, and Codex gpt-5.6-sol at maximum reasoning effort — and iterated until all three approved the final diff. Two review-round findings hardened the fixes beyond their original scope.

Fixed

A dying feed is now loud, for every subscriber (#89, #93). When a confirmed subscription fails — the server refuses a re-subscribe after a reconnect, the connection is permanently terminated, or it closes with resubscribe: false — every subscriber's onError now fires (previously: exactly one, and only if that caller passed onError; everyone else's feed died silently while unsubscribe() still "worked"). New: every subscription handle from WebSocketTransport carries a failureSignal: AbortSignal that aborts with the failure as its reason — and never on a voluntary unsubscribe() — so a dead feed is observable without registering any callback:

const sub = await client.allMids((data) => render(data.mids));
sub.failureSignal?.addEventListener("abort", () => resubscribe(sub.failureSignal?.reason));

Notification runs off a snapshot taken before any user code executes, so an onError that unsubscribes a sibling cannot rob it of its notification (a cross-review finding).

WebSocket messages are paced by default (#90, #96). The shared default quota now runs a token bucket sized to Hyperliquid's documented budget (2000 messages/minute per IP, burst 2000). The default transport was exactly the one that tripped the limit: at the 1000-subscription cap one reconnect re-sent every subscribe frame instantly — half the minute's budget in a burst a flapping socket repeated until the server refused. Pacing only ever delays subscribe/unsubscribe frames; post requests (orders) and keep-alive pings never wait. The uncontended path stays synchronous via a new tryAcquire fast path, requests flushed after a reconnect debit the budget exactly once, aborted requests no longer spend tokens, and a terminated transport abandons its paced waits instead of blocking other transports sharing the quota. Opt out with an accounting-only quota:

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

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

200-OK error envelopes throw instead of masquerading as data (#91, #94). Hyperliquid reports some failures inside a 200 OK as a top-level { "type": "error", "message": "..." } envelope. HttpTransport returned those as data — unvalidated info methods yielded the envelope as a "result", schema-validated ones failed with a confusing ValidationError. They now throw HttpRequestError carrying the server's own message. Arrays, nested type fields, and the exchange endpoint's { status: "err" } envelope are unaffected.

Wallet detection no longer inspects signTypedData arity (#92, #95). Function.length counts only parameters declared before the first default/rest parameter, so wrapped or adapted wallets (a Privy-style adapter declaring signTypedData(...args)) reported length 0, failed both shape guards, and died with an opaque unknown wallet type error at first signing. Detection is now by member presence alone. Positional ethers-style signTypedData(domain, types, value) is rejected up front with an explanatory error, and a wallet matching neither shape gets a diagnostic enumerating every missing member of both shapes — with what was found instead — so one iteration fixes the adapter.

Upgrading from 0.2.0

No API breaks. One behavioral default changed: outbound WebSocket subscribe/unsubscribe frames now pace against the server's own 2000/minute budget instead of silently overrunning it (orders are never delayed). If you deliberately want unpaced sends, pass new WebSocketQuota() as shown above.

v0.2.0

Choose a tag to compare

@joeblau joeblau released this 02 Aug 23:32
dd4bbbc

Upgrading from 0.1.x — read this first

Two changes alter the behaviour of working 0.1.6 programs. Both convert a silent server-side failure into an immediate local one: the server already refused this traffic, as a 10 s timeout carrying no echoed request to match it to.

1. WebSocket budgets are now shared per IP. Hyperliquid scopes every documented WebSocket limit to the client IP, not the connection. Subscription and unique-user counts were tracked per transport, so N transports admitted N×1000 subscriptions against a limit of 1000. They now share one WebSocketQuota per network by default. If you deliberately want isolated budgets:

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

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

2. The unique-user cap is 14, not 15. A live mainnet probe subscribed distinct users one at a time with the guard disabled, twice on two independent connections: both accepted exactly 14 and had the 15th refused by an error frame reading Cannot track more than 15 total users. — the server enforces one fewer than its own message states. The old value let the 15th subscription through to be dropped without an echo, producing the unmatched timeout the guard exists to prevent.

The same probe settled the scope: with one connection holding 14 users, a second connection from the same host was refused a 15th distinct user while still being allowed one the first already held. Per IP, not per connection — sharding user channels across sockets buys nothing.

Faster

WebSocket requests under load. Every request relayed the socket's single shared terminationSignal, putting one listener per in-flight request on one AbortSignal. EventTarget scans that list linearly on add and remove, making a burst O(n²) — 195 ns per add/remove pair with the list empty, 13.1 µs with 5000 resident. Now O(1):

before after
2000 in-flight requests 9.2–10.3 ms 4.4–5.0 ms (−50%)
1000-subscription reconnect −18.9%

Cold start. @bloxwap/hyperliquid/utils pulled 80+ Info method modules through a single barrel import — 91 modules, now 10: 23.0–31.5 → 6.2–6.3 ms on Node, 6.2–8.2 → 3.5–3.7 ms on Bun.

New

Narrow entrypoints. The root barrel evaluates all four clients plus both transports. Seven additive exports keys let a read-only consumer skip that — ./transport, ./transport/http, ./transport/websocket, and ./api/{info,exchange,subscription,explorer}/client. An info-only process: 69.5 → 41.0 ms on Node, 22.0 → 8.3 ms on Bun.

import { InfoClient } from "@bloxwap/hyperliquid/api/info/client";
import { HttpTransport } from "@bloxwap/hyperliquid/transport";

Opt-in WebSocket rate limiting for the documented 2000 messages/minute per-IP budget. It paces subscribe/unsubscribe only — post frames and keep-alive pings debit the budget but never wait, so per-wallet nonce ordering and half-open-socket detection are untouched.

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

Also

  • ExchangeClient's constructor now documents createFastLocalWallet71.3 µs vs 106.6 µs per order (−33.6%), byte-identical signatures.
  • New docs on placing many orders: one batched action costs 3 rate-limit weight versus 100 for the same orders fanned out, and grouping: "na" is not atomic.
  • New check:imports gate budgets each entry point's runtime module graph.
  • Documented WebSocket limits, and two new known-drift entries (outcomeMeta.deployer, validatorL1Votes.registerTemplate).

Full changelog: v0.1.6...v0.2.0

v0.1.6

Choose a tag to compare

@joeblau joeblau released this 28 Jul 13:20
bf2d59f

What's changed

A single fix, for a bug that made one of the most-used subscriptions unusable.

allMids() never resolved

client.allMids() — with no arguments, or with dex: "" — never resolved. It rejected with WebSocketRequestError: Request timed out after the configured timeout, or hung forever when timeout was null. Only a non-empty dex worked, so the main-dex mid-price feed was unusable.

The chain:

  1. allMids builds its payload as { type: "allMids", dex: params.dex || undefined }, so dex exists as an own key holding undefined.
  2. The request normalizer walks Object.keys() and faithfully recreates that key, so the subscription's normalized form carries dex: undefined.
  3. JSON.stringify drops it on the way out — the server receives {"type":"allMids"} and echoes back exactly that.
  4. Echo matching requires every key of the pending request to be present in the response. It looks for a dex the server was never told about, finds nothing, and the subscription is never matched to its own confirmation.

Fixed in the normalizer rather than in allMids: a key whose value is undefined cannot survive serialization, so keeping it leaves the in-memory identity describing a request that was never sent. Dropping it makes the id, the echo and the wire frame agree, and immunizes any future payload built with the same x || undefined shape.

Note on coverage

No test caught this because the only allMids test needs the live network and is skipped in the offline suite. The regression test added here covers the root cause and runs offline. If you subscribe to allMids, upgrading from 0.1.5 is the difference between the channel working and not.

v0.1.5

Choose a tag to compare

@joeblau joeblau released this 28 Jul 05:30
3930596

What's changed

Two wallet fixes reported from downstream use, plus a round of signing and transport performance work.

Fixed

  • A viem WalletClient over a local account lost the raw-digest fast path. createWalletClient({ account: privateKeyToAccount(key), … }) — what wagmi and viem hand around when the key is in process — always satisfies the JSON-RPC guard, so it was adapted as a remote wallet and every L1 action went through generic typed-data encoding. Measured downstream at 2.17× on every order. The JSON-RPC adapter now sources its raw-digest signer from the embedded account; typed data, address and chain ID still go through the client, so signatureChainId is unchanged.
  • createFastLocalWallet hard-failed where dynamic import is unavailable. Its docstring promised the tiny-secp256k1 fallback was "never a hard failure", but the fallback does await import("viem/accounts"), which throws under Jest without --experimental-vm-modules and some React Native bundlers. viem is not a dependency of this package, so the import must stay dynamic — callers in those hosts now pass options.privateKeyToAccount, used on both viem-dependent paths. When viem is genuinely unreachable the error names the cause and the remedy instead of surfacing an opaque host message.
  • createL1ActionHash validates vaultAddress again. It is publicly exported and was taking the address with no runtime check — a 0x${string} type constrains neither charset nor length at runtime. A malformed address silently hashed to different bytes than the caller described, and a 32-byte address hashed identically to its 20-byte truncation.

Faster

User-signed actions no longer pay viem's generic hashTypedData. Typehash plans compile once per types identity and domain separators once per chainId. Digests are byte-identical to viem's across all 17 types × 5 chain IDs. 2.5 µs vs 56 µs per digest; multisig_user_signed_3_signers −22%.

The order path releases its lock before signing. The per-wallet nonce lock now covers only nonce issuance and a dispatch-chain claim; signing happens outside it, so concurrent callers on one wallet sign simultaneously. Wire order is preserved by a per-(wallet × network) dispatch chain instead of by the lock, so the server still sees strictly increasing nonces.

With a remote wallet, concurrent approveAgent:

latency eth_accounts eth_chainId signTypedData
N=1 116 ms 1 1 1
N=8 116 ms 1 1 8
N=20 116 ms 1 1 20

Latency is now constant in N rather than linear, bounded by the data-dependency floor. Because signing moved out of the lock, the redundant per-action eth_chainId and multi-sig leader-address round trips also collapse — their dedupe caches can finally fire.

Websocket and HTTP. Dispatcher timeouts moved to the shared TimeoutWheel (ws_request_round_trip −10%). Routing stops allocating per frame: toLowerCase() is gated behind an uppercase check and routed event-type strings are interned per channel+key — webData3_frame_dispatch_e2e −38%, l2book_dispatch_50_coins −28%.

Notes

Ordering under the new dispatch chain is covered by _dispatchOrder tests: signatures completing in reverse order, jittered latency, and signing failures at the first, middle and last position — a burned nonce must leave a gap the server tolerates without stalling later requests or letting them overtake an earlier nonce still being signed.

perf:gate passes at 56 scenarios compared, 0 regressed.

v0.1.4

Choose a tag to compare

@joeblau joeblau released this 28 Jul 02:59
7b5d0bb

What's changed

Performance work on the websocket receive path, plus two behaviours restored from 0.1.3.

Faster

Measured on CI, base vs head, three paired rounds on the same runner — 9 faster, 0 regressed, 37 unchanged:

  • l2book_dispatch_50_coins −40.0%
  • eip712_agent_digest_wasm −26.7%, eip712_multisig_digest_wasm −24.5%
  • ws_request_round_trip −23.3%
  • subscribe_200_coins −18.8%
  • prepare_request −17.6%
  • reconnect_resubscribe_burst −15.3%
  • l1_action_hash_order_100_wasm −10.8%

What changed under those numbers:

  • The socket no longer re-boxes every inbound frame. ReconnectingWebSocket was allocating a MessageEvent and paying a full EventTarget.dispatchEvent per frame purely to hand it to the SDK's own consumer, which then ran its own dispatch. That consumer now takes a direct frame hook; the public message event is still dispatched whenever anything outside the package listens. 1.42×, 305 ns/frame.
  • fastAssetCtxs decodes natively. Where the runtime exposes node:zlib, a per-frame DecompressionStream + writer + reader + four promises collapses into one inflateRawSync, delivered in the dispatch tick rather than through a promise queue. 7.6× on delta frames, 1.8× on full snapshots. DecompressionStream remains the browser / React Native fallback, and both paths are differentially tested to agree — including multi-chunk payloads and every malformed input.
  • Cheaper listener dispatch. A lone listener is stored unboxed and promoted to a copy-on-write array on the second registration, so multi-listener dispatch no longer copies into a snapshot buffer and clears it. 2.6× at 8 listeners, 1.18× at 1.
  • One less serialization per subscribe. The dispatcher reuses the subscription id it already built by concatenation as the wire frame.

Large frames move least: JSON.parse dominates them and the SDK cannot touch that.

Fixed

  • fastAssetCtxs could deliver the wrong frame. The payload was read from the recycled event shell inside a queued continuation, so once frames arrived faster than decompression completed, the continuation read a later frame's data — delta updates silently dropped and others delivered twice, corrupting downstream price state. The payload is now captured synchronously.
  • A listener could receive a frame after unsubscribing. Dispatch skipped the liveness check for a listener unsubscribed by an earlier listener mid-dispatch. 0.1.3 matched EventTarget here; the behaviour is restored.
  • createL1ActionHash validates vaultAddress again. It is publicly exported and was taking the address with no runtime check — a 0x${string} type constrains neither charset nor length at runtime, and nothing at all for JavaScript callers. A malformed address silently hashed to different bytes than the caller described, and a 32-byte address hashed identically to its 20-byte truncation. Both now throw, as they did at 0.1.3.

Behavioural change

HyperliquidEventTarget is no longer a subclass of EventTarget. Dispatch is now a hand-rolled listener map, which is where most of the per-frame win above comes from. addEventListener / removeEventListener keep their semantics — including duplicate-registration dedup, once, AbortSignal, and not delivering to a listener removed mid-dispatch — but instanceof EventTarget is now false and dispatchEvent is not available. Reaching for either was never part of the documented surface; if you depended on it, that access needs replacing. Flagged here because it landed in a patch release.

Also

New perf scenarios cover the fastAssetCtxs decode path and the user-account channels, neither of which was measured before.

v0.1.3

Choose a tag to compare

@joeblau joeblau released this 27 Jul 19:40

What's changed

  • dexAbstraction support (#79): the abstraction enum value is now accepted on all three surfaces — UserAbstractionResponse (info), webData3.abstraction (subscription), and the userSetAbstraction picklist (exchange). The exchange value is verified accepted by the live deserializer; it has no single-letter wire form, so multi-sig payloads pass the full name through unchanged.

Note: alignedQuoteTokenInfo is deliberately not implemented — Hyperliquid removed that request type server-side (~June 2026) on both networks, so a method would always fail at runtime.

v0.1.2

Choose a tag to compare

@joeblau joeblau released this 27 Jul 17:55

Patch release proving the OIDC trusted-publishing path end-to-end; no API changes.

v0.1.1

Choose a tag to compare

@joeblau joeblau released this 27 Jul 17:07

Patch release exercising the OIDC trusted-publishing path; no API changes. Ships the publishConfig inheritance fix.

v0.1.0

Choose a tag to compare

@joeblau joeblau released this 27 Jul 10:39
b00b460

First public release of @bloxwap/hyperliquid — a Bun/TypeScript Hyperliquid SDK.

  • Complete Info / Exchange / Explorer / Subscription API surface with strict request validation
  • WASM-accelerated signing path (optional tiny-secp256k1 + hash-wasm, auto-installed): signed order ~70 µs, batch hashing 2.5x faster
  • Pre-signed payloads (prepareRequest/submitPrepared), opt-in skipValidation, orders over WebSocket post
  • Forward-compatible wire types, token-bucket rate limiter with 429 surfacing, WS reconnect hardening
  • 100% line coverage (1,854 tests), CI-gated perf suite with zero-regression policy