Two facts shape the SDK's data model:
- The wire protocol carries scaled integers — a price is
64250500000nticks, a quantity is an integer in per-asset units. Exact, compact, and unambiguous — but only meaningful if you know each asset's scale. - JavaScript's
numbercannot represent money safely.
The SDK resolves both with one design: a catalog of reference data (which knows every scale), and a public surface of exact decimal strings (which never touches floating point).
The decimal-string contract
- Every price/quantity output is a decimal string, converted exactly from the wire integer —
no rounding, trailing zeros trimmed.
1500000nat scale 6 becomes"1.5". - Every input is converted strictly.
"0.1234567"for a 6-decimal field throws aCatalogConversionError— the SDK never rounds your order silently. - Prices are always quoted at 6 decimal places (
PRICE_SCALE = 6); quantity scales vary per asset and come from the catalog.
Use a decimal library (or plain string math) for arithmetic; the strings parse cleanly into anything.
What the catalog holds
client.catalog is a snapshot of venue reference data with typed readers on top:
| Reader | Contents |
|---|---|
catalog.market | Trading assets and pairs: symbols ⇄ ids, listing status, precision, price/amount conversions |
catalog.ledger | Ledger assets: id/symbol lookups, amount conversions and display formatting |
catalog.orders | Per-pair order constraints (tick, step, minimums) + order input validation |
catalog.zipper | Deposit/withdraw chains, unified assets, per-chain routes, contracts |
await client.catalog.ensureReady();
const pair = client.catalog.market.requirePairBySymbol("BTC-USDC");
const constraints = client.catalog.orders.getSpotOrderConstraints("BTC-USDC");
const route = client.catalog.zipper.requireAssetChain("USDC", "ethereum");
// conversions & display helpers
client.catalog.market.normalizePriceInput("64250.512345678", "BTC-USDC"); // truncate to precision
client.catalog.ledger.formatAmount("1234.5", "USDC"); // display-roundedget* variants return null on a miss; require* variants throw a CatalogLookupError.
Lifecycle: ready, refresh, states
The catalog starts empty and fills itself from two public endpoints (spot config + zipper config) the first time something needs it:
ensureReady()— resolve the current snapshot, fetching if empty. Service methods await this internally; call it yourself before direct catalog reads.ready()— passive: resolves the current snapshot or in-flight refresh, never starts one.refresh()— force a refetch (deduped while in flight).state()—empty,refreshing,fresh, orstale(kept serving the old snapshot after a failed refresh).
Realtime subscriptions gate event delivery behind readiness: publications that arrive before scales are known are buffered and flushed in order.
isUnknownLedgerAsset / isUnknownZipperAsset) rather than throwing
mid-stream, and orders on unknown symbols are filtered out of list responses.Owning the snapshot
By default each client fetches and holds its own snapshot. Three config options change that:
catalogSnapshot— seed the client with a snapshot you already have (typically from SSR: the server callscatalog.snapshot(), bakes it into the page, the browser client starts warm).catalogCell— back the snapshot with external storage you control. A reactive cell (a store, a signal) makes every catalog read reactive — one snapshot shared across clients and UI.catalog— inject a fully managedClientCataloginstance (advanced; mutually exclusive with the other two).
The @polyester/sdk/catalogs subpath exports the builders (createPolyesterCatalog, buildCatalogSnapshot, createCatalogSnapshotReader) and all reader/config types for these
integration patterns.