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 }); // Use `chains` to configure multiple chains.
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; a failed registry read is contained and reported by registryReadError, not thrown
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.
  • params.funding: "explicit" | "auto" | "skip" — what to do when the order's collateral is not on the market's chain (a multi-chain exchange only; see below). "explicit" throws CollateralOnWrongChainError with a ready bridge plan and sends nothing; "auto" bridges the shortfall through exchange.ensureFunds, waits for delivery, then places; "skip" runs no balance check. The default is "explicit" once the exchange spans a second chain and "skip" on a single-chain exchange.
  • 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, and chainId names the chain it was sent on.

One exchange, several chains

A market is unique to one chain, so a symbol names exactly one (chainId, market). Configure every chain the account trades on and the exchange builds one engine per binding: reads merge, writes route to the market's chain, and every struct carries its chainId.

ts
const exchange = new SomniaMarkets({
  chains: [
    { chain: somniaMainnet, indexerUrl: l1Indexer, addresses: L1_ADDRESSES },
    { chain: hidekiMainnet, indexerUrl: l2Indexer, addresses: L2_ADDRESSES },
  ],
  account: safeAccount, // one address on every chain, one executor per chain
});
await exchange.loadMarkets();

const bal = await exchange.fetchBalance();
bal.USDso.total; // summed across chains
bal.USDso.byChain[hidekiMainnet.id].total;

const order = await exchange.createOrder("BTC/USDso:USDso", "limit", "buy", 1, 60_000);
order.chainId; // the chain the market lives on
  • The single-chain config is the one-element case and behaves exactly as before.
  • A bare pool address or market id that exists on two chains (the same CREATE2 pool on L1 and Hideki) is ambiguous: pass { chainId, id } — a ChainMarketRef — wherever a symbol is accepted, and { chainId, pool, id } to cancelOrder.
  • exchange.client is the primary (first) chain's engine; exchange.engineFor(chainId) reaches any other.
  • exchange.watchUser() hydrates the account's history on every chain behind one handle.
  • Collateral must sit on the market's chain. exchange.bridge plans and executes transfers between the exchange's chains through the account's executors, and exchange.ensureFunds({ code, chainId, amount }) is the helper behind createOrder(…, { funding: "auto" }).
  • The account: { address, executors: { [chainId]: Executor } }. Build an executor with engine.createExecutor(spec) — a private key, a wallet, a Somnia session seed, or a safe with the executor that sends its outer transaction. Any object implementing Executor works too: the SDK decodes the order from the receipt it returns and never fetches it again.
  • A factory smart account from @somnia-chain/accounts-sdk is a first-class actor: engine.createExecutor({ smartAccount, inner }) batches through the accounts SDK's own MultiSend batcher with no configuration, signs nothing when the inner actor is a direct owner at threshold 1, and reaches an account owned by other accounts through through. The whole lifecycle — derive, create on every chain with one signature, manage owners, move funds — is client.accounts and exchange.accounts; exchange.accounts.accountFor(accountId, index, { sessionSeed }) builds the multi-chain account above in one call. See the Smart accounts guide and Trade through a smart account.

Supported safe accounts

The built-in executor supports Gnosis-style safes in two modes:

  • "owner" (default): the inner executor's actor is a direct owner of a threshold-1 safe. The transaction uses that actor's pre-validated signature.
  • "module": the inner executor's actor is an enabled module on the safe.

A nested Safe can act as the owner or module if its address (inner.actor) has the required authorization. Module mode does not depend on the threshold.

The SDK does not check the owner set or threshold. Other authorization schemes, such as owner mode with a threshold above one, need a custom Executor. Failed Safe calls can revert or return a success-status receipt with failure. Trader writes throw that failure. The SDK decodes orders from the returned receipt.

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.
  • Perps — type: "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.