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.
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:
| Kind | Market symbol | Tradables |
|---|---|---|
| Spot | SOMI/USDC | SOMI/USDC |
| Binary | BTC-95000-31DEC26/USDC | …#YES, …#NO |
| Perp | BTC/USDSO:USDSO | BTC/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
| Method | Backed by | Notes |
|---|---|---|
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?) | chain | head-fresh one-shot |
fetchTrades(symbol, since?, limit?) | indexer | public tape |
fetchOHLCV(symbol, "5m", since?, limit?) | indexer | 1m 5m 15m 1h 4h 1d |
fetchOpenOrders(symbol?, limit?) | indexer | authenticated; limit is per venue (default 200) |
fetchMyTrades(symbol?, since?, limit?) | indexer | authenticated; scoped + windowed at the query, newest-first across venues |
fetchBalance() | chain | wallet balances by currency code (incl. binary YES/NO ERC-6909 holdings, keyed by tradable symbol) |
fetchStatus() | local | watch/socket health |
watchOrderBook(symbol, limit?) | local store | zero RTT, current to last block |
watchTrades(symbol) · watchOrders(symbol) · watchMyTrades(symbol) | local store | streaming |
createOrder(symbol, type, side, amount, price?, params?) | chain write | 1-RTT confirm, fills decoded |
cancelOrder(id, symbol) | chain write | |
mintSet / burnSet / redeem (symbol, amount) | chain write | outcome markets only |
fetchFundingRate(symbol) | chain | perps: mark/index + funding rate |
fetchPositions(symbols?) | chain | perps: open MarginBank positions |
depositMargin / withdrawMargin (symbol, amount) | chain write | perps: cross-margin collateral |
watchPrice(asset) · fetchPrice(asset) | price feed | index price (EMA oracle) — see Price feeds |
fetchPriceOHLCV(asset, "1m") | price feed | index candles: 1m/1h/1d |
fetchTicker(symbol) | indexer; best-effort chain read on perps | last, 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? }) | indexer | authenticated; order history as one offset-paged set |
getOrderHistoryPage({ ref?, status?, limit?, offset? }) | indexer | authenticated; preferred indexed-history name; partial raw-row page with immutable identity and continuation |
getOrdersPage({ ref?, status?, limit?, offset? }) | indexer | compatibility name for getOrderHistoryPage |
fetchPortfolioAnalytics(timeframe, params?) | indexer | authenticated; PnL, equity and holdings series |
createStopOrder(symbol, type, side, amount, triggerPrice, price?, params?) | chain write | spot only; see Spot markets |
fetchOpenStopOrders(symbol?) · cancelStopOrder(id, symbol) | indexer · chain write | spot stop orders |
fetchFundingRateHistory(symbol, since?, limit?) | indexer | perps: funding-rate history |
setSigner({ privateKey | account | walletClient }) | local | bind or replace the signer after construction |
priceToPrecision(ref, price) · amountToPrecision(ref, amount) | local | snap a value down onto the market's tick or lot grid |
market(ref) | local | resolve any handle to its tradable |
close() | local | release 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.
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:
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:
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'squoteBinaryStake/quoteBinarySellhelpers default to 3% with a 10-tick floor) and send IOC; they throwInvalidInputErrorif the opposite side is empty. When the chain rejects an order you get aContractRevertErrorwhoseerrorNameis the contract's own error — see ENGINE.md § Errors.params.timeInForce: "GTC" | "IOC" | "FOK" | "PO"(orpostOnly: 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-timeexchange.trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k })opt-in on the pool, else a non-zero fee reverts (BuilderFeeExceedsCapon binary,BuilderNotApprovedon spot,BuilderFeeExceedsApprovalon 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"throwsCollateralOnWrongChainErrorwith a ready bridge plan and sends nothing;"auto"bridges the shortfall throughexchange.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/remainingsay how much), or"canceled"(IOC remainder).infocarries the receipt + raw fills, andchainIdnames 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.
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 }— aChainMarketRef— wherever a symbol is accepted, and{ chainId, pool, id }tocancelOrder. exchange.clientis 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.bridgeplans and executes transfers between the exchange's chains through the account's executors, andexchange.ensureFunds({ code, chainId, amount })is the helper behindcreateOrder(…, { funding: "auto" }). - The account:
{ address, executors: { [chainId]: Executor } }. Build an executor withengine.createExecutor(spec)— a private key, a wallet, a Somnia session seed, or a safe with the executor that sends its outer transaction. Any object implementingExecutorworks too: the SDK decodes the order from the receipt it returns and never fetches it again. - A factory smart account from
@somnia-chain/accounts-sdkis 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 throughthrough. The whole lifecycle — derive, create on every chain with one signature, manage owners, move funds — isclient.accountsandexchange.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;
sideis 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/redeemoperate at market level. - Perps —
type: "swap", settle = quote (linear, collateral-settled);createOrderunchanged (margin locks from your MarginBank balance —depositMarginfirst);fetchPositions/fetchFundingRateread 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 inwatchMyTradeswith a shared match id ininfo.
The design intent: the verbs never change again — every future market kind
is new data (a type, an outcomes[] list), not new API.