BloxwapHyperliquid SDK

Utilities

Helpers from @bloxwap/hyperliquid/utils that keep order payloads compatible with Hyperliquid's tick, lot, and asset rules.

Three invariants you have to honor (by Hyperliquid)

InvariantWhat it meansSDK helper
Tick sizePrices must fit a bounded number of significant figures and decimalsformatPrice
Lot sizeSizes must not exceed the asset's szDecimalsformatSize
Asset IDThe a field in an order is a numeric index, not a symbolSymbolConverter

Tick size → formatPrice

Hyperliquid's tick and lot size rules 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.

Use formatPrice — it applies both rules with exact decimal arithmetic, so arbitrary-precision inputs survive intact:

import { formatPrice } from "@bloxwap/hyperliquid/utils";

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

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.

[!NOTE]

Don't rely on toFixed(n): it rounds instead of truncating, ignores the significant-figures ceiling and has issues with floating-point precision.

[!WARNING]

formatPrice truncates, it does not round. If truncation collapses a very small price to 0, it throws FormatError.

Lot size → formatSize

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. Use formatSize to truncate the string to fit:

import { formatSize } from "@bloxwap/hyperliquid/utils";

formatSize("1.23456789", 5);  // "1.23456"
formatSize("0.123456789", 2); // "0.12"
formatSize("100", 0);         // "100"

[!WARNING]

Hyperliquid treats a literal "0" size on a reduce-only order as "close the whole position". formatSize refuses to return "0" (it throws FormatError), so if you actually want that behavior, pass "0" directly into the order payload instead of routing it through formatSize.

Wire floats → floatToWire

formatPrice/formatSize enforce the exchange's tick and lot rules. When you instead need the exact wire format the Python SDK signs with — e.g. to reproduce a hash or compare payloads byte-for-byte — use floatToWire, a port of Python's float_to_wire: round to 8 decimals (half-even), refuse precision loss beyond 1e-12, strip trailing zeros, never emit scientific notation.

import { floatToWire } from "@bloxwap/hyperliquid/utils";

floatToWire(1e-8);                // "0.00000001"
floatToWire(1e20);                // "100000000000000000000"
floatToWire(0.30000000000000004); // "0.3"
floatToWire(0.000012345678);      // throws FormatError — rounding would change the value

Unlike formatPrice/formatSize (which truncate and throw on zero), floatToWire rounds, accepts zero and negative values, and mirrors Python exactly.

Asset ID → SymbolConverter

A signed order carries a: number, not a symbol. Hyperliquid assigns asset IDs across four disjoint ranges, each driven by a different info endpoint:

Market typeAsset IDDriven by
Perpetuals0, 1, 2, …meta().universe
Spot markets10000 + market.indexspotMeta()
Builder DEX (HIP-3 perps)100000 + dexIndex * 10000 + asset.indexperpDexs() + meta({ dex })
Outcome markets100000000 + outcomeId * 10 + sideIndexoutcomeMeta()

For reliability, prefer fetching these mappings at runtime over hardcoding them. Use SymbolConverter to do that — it fetches the metadata once and exposes the lookups as plain methods.

Create

SymbolConverter.create() pulls meta, spotMeta, and outcomeMeta (plus builder-DEX metadata when enabled) and resolves into a ready-to-use instance:

import { HttpTransport } from "@bloxwap/hyperliquid";
import { SymbolConverter } from "@bloxwap/hyperliquid/utils";

const transport = new HttpTransport();
const converter = await SymbolConverter.create({ transport });

If you need a synchronous constructor — e.g., 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 takes the symbol in whichever format its market uses:

converter.getAssetId("BTC");       // 0       — perpetual
converter.getAssetId("HYPE/USDC"); // 10107   — spot market
converter.getAssetId("test:ABC");  // 110000  — builder DEX (if enabled)
converter.getAssetId("btc-above-61720-yes-jun-08-0600"); // 100002200 — outcome market

The accepted formats follow the asset-ID ranges one-for-one:

Market typeName formatExample
Perpetual<COIN>"BTC"
Spot<BASE>/<QUOTE>"HYPE/USDC"
Builder DEX<DEX>:<ASSET>"test:ABC"
Outcome market<url-slug>"btc-above-61720-yes-jun-08-0600"

getAssetId returns undefined for an unknown symbol.

Read szDecimals

getSzDecimals returns the same precision that formatPrice and formatSize need.

import { formatPrice, formatSize } from "@bloxwap/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.

Outcome markets carry no szDecimals metadata, and empirically their sizes are whole integers — every observed resting book size is integer-valued and HIP-4 guides report fractional sizes rejected (no funded accept/reject probe was possible) — so getSzDecimals returns 0 for outcome slugs. This is observed current behavior, not a protocol guarantee.

Round a price to a valid tick

formatPrice always truncates. When the rounding direction matters — a maker order that must not cross the spread, or a taker order that should — use roundPrice, which picks the direction from the order side:

converter.roundPrice("BTC", "buy", "97123.456");  // "97123" — never pays more than requested
converter.roundPrice("BTC", "sell", "97123.456"); // "97124" — never sells for less than requested
converter.roundPrice("BTC", "buy", "97123.456", { aggressive: true }); // "97124" — taker: flips both directions

The default is maker-safe: a buy rounds down, a sell rounds up, so the rounded price is never more aggressive than the one you asked for. aggressive: true inverts both for taker-style orders that trade up to one tick for a better fill probability. A price already on the tick grid is returned unchanged, and all math is exact decimal arithmetic — no floating-point artifacts.

getTickSize returns the tick itself at a given price level (it steps with the price magnitude, so the price argument matters):

converter.getTickSize("BTC", "97123.4");      // "1"          — perp, szDecimals=5
converter.getTickSize("PURR/USDC", "0.0001"); // "0.00000001" — spot, 8-decimal ceiling

Outcome markets share the spot implementation (per the official Asset IDs docs), so both helpers apply the spot price rules to outcome slugs — with their integer lot (szDecimals 0, empirically observed, not protocol-guaranteed) the tick follows the price magnitude, max(10^(floor(log10 px) − 4), 1e-8), and sizes are whole integers:

converter.getTickSize("btc-above-64570-yes-jul-27-0300", "0.56667"); // "0.00001"  — tick 1e-5 at this magnitude
converter.getTickSize("btc-above-64570-yes-jul-27-0300", "0.05");    // "0.000001" — finer below 0.1
converter.getTickSize("btc-above-64570-yes-jul-27-0300", "0.00001"); // "0.00000001" — clamped by the 8-decimal ceiling
converter.roundPrice("btc-above-64570-yes-jul-27-0300", "buy", "0.56667"); // "0.56667" — already on the grid

(Validated against every live outcome book level at the time of writing; outcomeMeta itself exposes no precision fields.)

Both accept every name format getSzDecimals accepts (perp, BASE/QUOTE, DEX:ASSET, outcome slug), return undefined for unknown coins, and throw FormatError for a non-positive or unparsable price.

Spot pair IDs

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 "@<n>" alias, with a handful of legacy pairs that kept their "BASE/QUOTE" form. getSpotPairId gives you the identifier the info side expects:

converter.getSpotPairId("HYPE/USDC"); // "@107"
converter.getSpotPairId("PURR/USDC"); // "PURR/USDC"  — legacy pair

Round-trip back to the symbol with getSymbolBySpotPairId, useful when you receive a subscription event and want a human-readable label:

converter.getSymbolBySpotPairId("@107");      // "HYPE/USDC"
converter.getSymbolBySpotPairId("PURR/USDC"); // "PURR/USDC"

Both methods return undefined for unknown pairs.

Refresh after a new listing

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:

await converter.reload();

Builder DEXs

HIP-3 builder-deployed perpetuals live outside the default universe. SymbolConverter ignores them unless you opt in through the dexs option:

All builder DEXs
const converter = await SymbolConverter.create({
  transport,
  dexs: true,
});
Selected DEXs
const converter = await SymbolConverter.create({
  transport,
  dexs: ["test", "other"],
});

Builder DEX assets use the "DEX:ASSET" naming convention:

converter.getAssetId("test:ABC");    // 110000
converter.getSzDecimals("test:ABC"); // 0

[!NOTE]

With dexs: true, the converter fetches allPerpMetas, perpDexs, spotMeta, and outcomeMeta in parallel: four requests regardless of the number of builder DEXs. Selected DEX arrays retain one meta({ dex }) request per selected registered venue; the default uses three parallel requests (meta, spotMeta, outcomeMeta).

All-DEX loading checks registry/metadata alignment before publishing asset IDs. A failed request, mismatched array length or mismatched venue prefix rejects the reload and preserves the previous complete snapshot. It does not silently fall back to per-DEX requests. If you cache metadata, clear the cache before retrying a mismatch so the registry and bulk metadata can refresh together. Empty/null registry slots keep their indices. Selected-DEX loading keeps its existing behavior of skipping individual failed venue requests.

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:

import { ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid";
import { formatPrice, formatSize, SymbolConverter } from "@bloxwap/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 });

const coin = "BTC";
const rawPrice = "97123.456789";
const rawSize = "0.00123456789";

// `!` 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,                           // "BTC" → 0
    b: true,
    p: formatPrice(rawPrice, szDecimals), // "97123.456789" → "97123"
    s: formatSize(rawSize, szDecimals),   // "0.00123456789" → "0.00123"
    r: false,
    t: { limit: { tif: "Gtc" } },
  }],
  grouping: "na",
});
Edit this page on GitHub

On this page