> For the complete documentation index, see [llms.txt](https://bloxwap.gitbook.io/hyperliquid/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://bloxwap.gitbook.io/hyperliquid/docs/utilities.md).

# 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**](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size) | Prices must fit a bounded number of significant figures and decimals | [`formatPrice`](#tick-size-formatprice)        |
| [**Lot size**](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size)  | Sizes must not exceed the asset's `szDecimals`                       | [`formatSize`](#lot-size-formatsize)           |
| [**Asset ID**](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids)          | The `a` field in an order is a numeric index, not a symbol           | [`SymbolConverter`](#asset-id-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 − 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:

```ts
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)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed): it rounds instead of truncating, ignores the significant-figures ceiling and has issues with [floating-point precision](https://floating-point-gui.de/).

> \[!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:

```ts
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.

```ts
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 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:

```ts
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:

```ts
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 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.

```ts
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:

```ts
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):

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
await converter.reload();
```

### Builder DEXs

[HIP-3 builder-deployed perpetuals](https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals) live outside the default universe. `SymbolConverter` ignores them unless you opt in through the `dexs` option:

<details>

<summary>All builder DEXs</summary>

```ts
const converter = await SymbolConverter.create({
  transport,
  dexs: true,
});
```

</details>

<details>

<summary>Selected DEXs</summary>

```ts
const converter = await SymbolConverter.create({
  transport,
  dexs: ["test", "other"],
});
```

</details>

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

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

> \[!NOTE]
>
> Enabling `dexs` adds a `perpDexs()` call plus one `meta({ dex })` request per builder DEX. Only enable it if you actually trade there — the default `SymbolConverter.create()` is one round-trip each to `meta`, `spotMeta`, and `outcomeMeta` in parallel.

## 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:

```ts
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",
});
```
