Two facts shape the data model:
- The wire protocol carries scaled integers. A price is ticks. A quantity is an integer in per-asset units. Exact and compact, but only useful if you know each asset's scale.
- JavaScript
numbercannot represent money safely.
The SDK answers both with one design: a catalog of reference data (every scale), and a public surface of exact decimal strings (no 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". - Service inputs use strict decimal conversion.
"0.1234567"for a 6-decimal field throwsCatalogConversionErrorfrom@polyester/sdk/catalogs; the SDK never rounds an order silently. Zero padding beyond the scale is exact, so"1.5000000"is accepted for that same field. SpotOrderConstraintsexposesmaxPrice,maxQtyBase,maxNotionalQuote, andmaxQuoteSlippageas decimal strings. These are protobuf wire-format ceilings, not exchange limits. Order and trigger inputs above them throwCatalogConversionErrorbefore network I/O.normalizePriceInputandnormalizeQuantityInputare different: UI helpers that truncate excess fractional digits to catalog scale. They do not round, snap to a tick grid, or validate tick, step, or minimum rules.- Trading prices, price deltas, and slippage always use 9 decimal places (
PRICE_SCALE = 9). Quantity scales vary per asset and come from the catalog. Public market-data volume and composite reference candle prices have their own catalog scales; see Catalog.
Do arithmetic with a decimal library or plain string math. The strings parse cleanly into either.
What the catalog holds
client.catalog is a venue reference-data snapshot with typed readers:
| Reader | Contents |
|---|---|
catalog.market | Trading assets and pairs: symbols to ids, listing status, precision, price/amount converts |
catalog.ledger | Ledger assets: id/symbol lookups, amount converts and display formatting |
catalog.orders | Per-pair tick, step, minimum, and wire constraints plus 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-USDT");
const constraints = client.catalog.orders.getSpotOrderConstraints("BTC-USDT");
const route = client.catalog.zipper.requireAssetChain("ETH", "ethereum-sepolia");
// conversions and display helpers
client.catalog.market.normalizePriceInput("64250.5123456789", "BTC-USDT"); // "64250.512345678"
client.catalog.market.decimalPriceToTicks("64250.5123456789", "BTC-USDT"); // throws CatalogConversionError
client.catalog.ledger.formatAmount("1234.5", "USDC"); // display-roundedget* returns null on a miss. require* throws CatalogLookupError.
The catalog order validator checks decimal parsing, tick, step, and minimum rules. It does not enforce the four wire ceilings.
Lifecycle: ready, refresh, states
The catalog starts empty. It fills 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(): wait for the current snapshot or in-flight refresh. Never starts a fetch.refresh(): force a refetch (deduped while in flight).state():empty,refreshing,fresh, orstale(keeps serving the old snapshot after a failed refresh).
Realtime subscriptions gate event delivery behind readiness. Events that arrive before scales are
known are buffered and flushed in order. The buffer is bounded, and a readiness failure ends the
subscription through onError rather than leaving it connected and silent.
isUnknownLedgerAsset / isUnknownZipperAsset) instead of throwing
mid-stream. Orders on unknown symbols are filtered out of list responses.Owning the snapshot
By default each client fetches and holds its own snapshot. Three options change that:
catalogSnapshot: seed with a snapshot you already have. Typical SSR path: server callscatalog.snapshot(), bakes it into the page, browser client starts warm.catalogCell: back the snapshot with storage you control. A reactive cell (store or signal) makes every catalog read reactive, one snapshot shared across clients and UI.catalog: inject a fully managedClientCatalog(advanced; mutually exclusive with the other two).
The @polyester/sdk/catalogs subpath exports the builders
(createPolyesterCatalog, buildCatalogSnapshot, createCatalogSnapshotReader) and the
reader/config types for these patterns.