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)
| Invariant | What it means | SDK helper |
|---|---|---|
| Tick size | Prices must fit a bounded number of significant figures and decimals | formatPrice |
| Lot size | Sizes must not exceed the asset's szDecimals | formatSize |
| Asset ID | The a field in an order is a numeric index, not a symbol | SymbolConverter |
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 − szDecimalsdecimals for perpetuals,8 − szDecimalsfor 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 ceilingThe 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]
formatPricetruncates, it does not round. If truncation collapses a very small price to0, it throwsFormatError.
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".formatSizerefuses to return"0"(it throwsFormatError), so if you actually want that behavior, pass"0"directly into the order payload instead of routing it throughformatSize.
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 valueUnlike 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 type | Asset ID | Driven by |
|---|---|---|
| Perpetuals | 0, 1, 2, … | meta().universe |
| Spot markets | 10000 + market.index | spotMeta() |
| Builder DEX (HIP-3 perps) | 100000 + dexIndex * 10000 + asset.index | perpDexs() + meta({ dex }) |
| Outcome markets | 100000000 + outcomeId * 10 + sideIndex | outcomeMeta() |
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 marketThe accepted formats follow the asset-ID ranges one-for-one:
| Market type | Name format | Example |
|---|---|---|
| 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 directionsThe 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 ceilingOutcome 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 pairRound-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 fetchesallPerpMetas,perpDexs,spotMeta, andoutcomeMetain parallel: four requests regardless of the number of builder DEXs. Selected DEX arrays retain onemeta({ 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",
});