The exchange API

The SomniaMarkets class is the SDK's primary surface: markets keyed by symbols, fetch*/watch*/createOrder verbs, human-unit structs — the idioms every exchange bot already speaks (if you've driven a venue through ccxt, nothing here will surprise you, down to the field names). Under the hood it runs on the native engine (scoped watches, local books, one-round-trip writes), so the watch* channels are zero-round-trip and createOrder confirms in a single round-trip.

ts
import { SomniaMarkets } from "@somnia-chain/markets-sdk";

const exchange = new SomniaMarkets({ indexerUrl, chain, wsRpcUrl, privateKey }); // one per chain
await exchange.loadMarkets();

const symbol = "BTC-95000-31DEC26/USDC#YES";
const book = await exchange.watchOrderBook(symbol, 10); // live, zero RTT
const order = await exchange.createOrder(symbol, "limit", "buy", 10, 0.62);
// … later:
await exchange.cancelOrder(order.id, symbol);

The raw engine stays available at exchange.client — bigint-exact, address-keyed — for anything the exchange verbs don't cover; see the engine guide.

Symbols

A symbol names a market; a tradable symbol names something you place orders on. For spot they coincide; for outcome markets each outcome is its own tradable:

KindMarket symbolTradables
SpotSOMI/USDCSOMI/USDC
BinaryBTC-95000-31DEC26/USDC…#YES, …#NO
PerpBTC/USDSO:USDSOBTC/USDSO:USDSO
Categorical (soon)US-ELECTION-28/USDC…#TRUMP, …#NEWSOM, …

Binary symbols are synthesized as ASSET-STRIKE-DDMONYY/QUOTE (quote = the collateral's ERC-20 symbol), with an -HHMM expiry component for intraday series markets (ETH-171780-03JUL26-0930/TUSDC); any residual collision gets a deterministic 4-hex market-id tiebreaker on the base side of the slash. Everything before # follows standard exchange symbol conventions; #OUTCOME is our extension (binary markets need one). Any raw ref works wherever a symbol does — a pool address, market id, or BinaryMarket address resolves to the same tradable (outcome markets default to #YES).

Numbers are human units in the tradable's own terms: a #NO price of 0.38 is the NO probability — the YES-terms complement, raw integers, and escrow math are all internal. Raw payloads ride on every struct's info.

Verbs

MethodBacked byNotes
loadMarkets() / fetchMarkets()indexer (+ one-time symbol() reads)builds the symbol registry
fetchOrderBook(symbol, limit?)chainhead-fresh one-shot
fetchTrades(symbol, since?, limit?)indexerpublic tape
fetchOHLCV(symbol, "5m", since?, limit?)indexer1m 5m 15m 1h 4h 1d
fetchOpenOrders(symbol?, limit?)indexerauthenticated; limit is per venue (default 200)
fetchMyTrades(symbol?, since?, limit?)indexerauthenticated; scoped + windowed at the query, newest-first across venues
fetchBalance()chainwallet balances by currency code (incl. binary YES/NO ERC-6909 holdings, keyed by tradable symbol)
fetchStatus()localwatch/socket health
watchOrderBook(symbol, limit?)local storezero RTT, current to last block
watchTrades(symbol) · watchOrders(symbol) · watchMyTrades(symbol)local storestreaming
createOrder(symbol, type, side, amount, price?, params?)chain write1-RTT confirm, fills decoded
cancelOrder(id, symbol)chain write
mintSet / burnSet / redeem (symbol, amount)chain writeoutcome markets only
fetchFundingRate(symbol)chainperps: mark/index + funding rate
fetchPositions(symbols?)chainperps: open MarginBank positions
depositMargin / withdrawMargin (symbol, amount)chain writeperps: cross-margin collateral
watchPrice(asset) · fetchPrice(asset)price feedindex price (EMA oracle) — see Price feeds
fetchPriceOHLCV(asset, "1m")price feedindex candles: 1m/1h/1d
fetchTicker(symbol)indexer; best-effort chain read on perpslast, 24h change and volume; perps add mark/index/funding/open interest when getPerpState succeeds and degrade to candle data when that chain read fails
fetchOrders(symbol?, since?, limit?, { offset? })indexerauthenticated; order history as one offset-paged set
getOrderHistoryPage({ ref?, status?, limit?, offset? })indexerauthenticated; preferred indexed-history name; partial raw-row page with immutable identity and continuation
getOrdersPage({ ref?, status?, limit?, offset? })indexercompatibility name for getOrderHistoryPage
fetchPortfolioAnalytics(timeframe, params?)indexerauthenticated; PnL, equity and holdings series
createStopOrder(symbol, type, side, amount, triggerPrice, price?, params?)chain writespot only; see Spot markets
fetchOpenStopOrders(symbol?) · cancelStopOrder(id, symbol)indexer · chain writespot stop orders
fetchFundingRateHistory(symbol, since?, limit?)indexerperps: funding-rate history
setSigner({ privateKey | account | walletClient })localbind or replace the signer after construction
priceToPrecision(ref, price) · amountToPrecision(ref, amount)localsnap a value down onto the market's tick or lot grid
market(ref)localresolve any handle to its tradable
close()localrelease all watches, channels, and sockets; a Node script exits on its own afterwards

Still reserved: watchPositions, setLeverage (the raw trader.setPerpLeverage exists for the latter).

Paginated order identity

getOrderHistoryPage reads one bounded page for the configured account. Use it when the caller must retain the chain, account, market, pool, and order id together. getOrdersPage remains available as a compatibility name. The default limit is 100 and the default offset is 0. A status filter runs in the indexer. A ref also scopes the query to its pool, then verifies each raw market id so a recycled binary pool cannot cross market lifetimes.

rowsRead counts raw indexer rows. nextOffset advances after a full raw page, even when outcome filtering removes every display order or every identity is unresolved. The unresolved list keeps rows whose immutable market id is absent or inconsistent in the loaded registry. A null binary side remains on the YES book. A NO scope excludes that row.

Every result has coverage: "partial" and source: "indexer-offset". These are indexed mutable records. They are not an immutable lifecycle log or a current Open Orders snapshot. The indexer orders rows by placement time without a frozen revision or a unique tie-break. Concurrent inserts or status changes can make pages skip or repeat rows. A short page does not prove complete account state.

The status option filters indexed history. status: "Open" cannot establish completeness, removals, continuity, or action freshness. Use a separately supported current-state flow when the caller needs the SDK's current open-order view. Each flow retains its documented freshness limits.

ts
let offset = 0;
while (true) {
  const page = await exchange.getOrderHistoryPage({ limit: 100, offset });
  for (const order of page.orders) console.log(order.identity, order.status);
  if (page.nextOffset === null) break;
  offset = page.nextOffset;
}

The watch* idiom

Each await resolves when the channel next changes; the first call resolves immediately after the (ref-counted) market watch hydrates:

ts
while (running) {
  const book = await exchange.watchOrderBook(symbol, 5); // resolves per change
  requote(book.bids[0], book.asks[0]);
}

"Did my resting order fill?" — watch your orders and look for the status flip:

ts
const orders = await exchange.watchOrders(symbol);          // resolves per change
const mine = orders.find((o) => o.id === orderId);
if (mine?.status === "closed") …                            // filled

createOrder params

  • type: "limit" | "market" — market orders compute a crossing limit from the best opposite level ± params.slippage (default 1% on this surface; the engine's quoteBinaryStake / quoteBinarySell helpers default to 3% with a 10-tick floor) and send IOC; they throw InvalidInputError if the opposite side is empty. When the chain rejects an order you get a ContractRevertError whose errorName is the contract's own error — see ENGINE.md § Errors.
  • params.timeInForce: "GTC" | "IOC" | "FOK" | "PO" (or postOnly: true) — maps to the on-chain order types.
  • params.builder / params.builderFeeBpsTimes1k — attribute the order to a routing/builder frontend and pay it a per-order fee (pool bps×1000 unit). Requires a prior one-time exchange.trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k }) opt-in on the pool, else a non-zero fee reverts (BuilderFeeExceedsCap on binary, BuilderNotApproved on spot, BuilderFeeExceedsApproval on perp). Works on binary, spot and perp — but the approval is stored per pool, so opt in on each pool you attribute orders on.
  • The returned order: status: "open" (rested), "closed" (fully filled in the same round-trip — filled/remaining say how much), or "canceled" (IOC remainder). info carries the receipt + raw fills.

Structs

One set of shapes throughout: markets { id, symbol, type, base, quote, active, precision, limits, outcomes?, info }; books as [price, amount] pairs, best first; orders { id, symbol, type?, side, price, amount, filled, remaining, status, timestamp, info } (type is absent on orders read back from the indexer — the pools do not emit it); trades { id, symbol, price, amount, cost, side?, timestamp, info }; balances { [code]: { free, used, total } } (escrowed funds live in the pools, so wallet free === total); OHLCV rows [ms, open, high, low, close, baseVolume].

How each market kind maps

  • Spot — symbol is the tradable; side is base-buy/base-sell; done.
  • Binary — two tradables per market. The engine keeps one YES-terms book; the resolver presents each outcome's own view (prices, sides, candles all outcome-relative). mintSet/burnSet/redeem operate at market level.
  • Perpstype: "swap", settle = quote (linear, collateral-settled); createOrder unchanged (margin locks from your MarginBank balance — depositMargin first); fetchPositions/fetchFundingRate read the MarginBank/pool live.
  • Categorical / multi-outcome (teed up) — N tradables on one pool (outcome index = the order's userData, exactly as binary uses 0/1 today); same verbs, and cross-book complete-set fills will surface in watchMyTrades with a shared match id in info.

The design intent: the verbs never change again — every future market kind is new data (a type, an outcomes[] list), not new API.