The engine (advanced)

This guide tours the engine tier — the raw SomniaMarketsClient: bigint-exact, address-keyed. It is not a second entry point: you reach it through the exchange —

ts
const exchange = new SomniaMarkets({ indexerUrl, chain, wsRpcUrl });
const client = exchange.client; // the engine: exact raw-unit reads + watches
const trader = exchange.trader; // the raw write tier (needs a signer)

Most applications stay on the exchange API itself; the engine is the tier for exact escrow math, custom reads, and anything the exchange verbs don't cover. Both surfaces share one config, one socket, one watch/store. Multi-chain deployments are multiple exchanges: one per chain. For market-kind specifics, see the binary markets and spot guides.

The three read tiers

Every read on the client belongs to one of three tiers. The getLive* prefix marks the first; for the other two the method's API reference names its source (getMarket, getFills, getOpenOrders are indexer reads; getMarginAccount, getPerpState are chain reads). The method name tells you which — and the tier tells you the freshness and the cost:

TierMethods look likeBacked byCost & freshness
Live storegetLive* (synchronous)in-memory materialization of watched marketszero round-trips, current to the last block
Chainget*Onchain, getBinaryOrderBook, balanceseth_call over the socketone round-trip, current to head
Indexerlist*, getCandles, getPortfolio, …GraphQL (HTTP)one round-trip, lags the chain slightly

Rule of thumb: anything that gates an action reads the live store or the chain; the indexer is for history and aggregates. Indexer reads throw on request failure — an empty result always means "no rows", never "it broke".

Errors — branch on the type, never the message

Every failure the SDK raises is a typed class exported from the barrel, all extending SomniaMarketsError. Catch the family, or branch on the member:

ts
import { ContractRevertError, IndexerError, NotConfiguredError } from "@somnia-chain/markets-sdk";

try {
  await exchange.createOrder("BTC-95000-31DEC26/USDC#YES", "limit", "buy", 25, 0.62);
} catch (e) {
  if (e instanceof ContractRevertError) {
    // The chain rejected it. `errorName` is the contract's OWN error.
    if (e.errorName === "ExpiredOrderMustBeCancelled") await cancelFirst();
    else if (e.errorName === "InsufficientBalance") await topUp();
    else throw e;
  } else if (e instanceof IndexerError) {
    retryLater(); // the read didn't complete — never "no rows"
  } else if (e instanceof NotConfiguredError) {
    throw new Error(`missing config: ${e.what}`); // a deploy/config bug, not runtime
  } else throw e;
}
ClassMeansYour move
InvalidInputErrorBad argument, unknown symbol, wrong market kind (thrown before any I/O)Fix the call
NotConfiguredErrorA needed address/URL was never passed (what names it)Fix the config
SignerRequiredErrorThe method writes; this client has no signerConstruct with a signer
IndexerErrorAn indexer read did not completeRetry / degrade
RpcErrorA node request did not completeRetry / switch RPC
ContractRevertErrorThe chain rejected the call — errorName + argsBranch on errorName

Three things worth knowing:

  • Revert names come from the contracts. The SDK bundles every custom error the protocol declares and decodes revert data against it, so a failed write reports MarketNotSettled rather than hex. When the data matches nothing known you still get a ContractRevertError, with the raw reason/data preserved.
  • A mined-but-reverted transaction throws. You don't need to check receipt.status yourself; the SDK replays the call to recover the reason first.
  • cause is always chained. The original viem/fetch error is there for logs.
  • Never match on error.message — messages are not a stable API. Branch on the class and its fields (errorName, what, operation).

Watches — scoped, ref-counted live data

Nothing is streamed by default. You open a watch on what you care about, and the client becomes a local indexer of exactly that scope:

ts
const watch = await client.watchMarket(pool); // one market: book, fills, orders, status
const book = client.getLiveBinaryOrderBook(pool); // synchronous, zero round-trips, last-block fresh
// ... trade against it ...
watch.stop(); // release (ref-counted, brief linger)
  • watchMarket(pool) — hydrates a consistent snapshot of that market (its row, recent fills, full resting book) and streams its events from the chain WebSocket. Resolves once reads are live. This is the right default: cost scales with what you watch, not with the protocol.
  • watchMarkets({ discover? }) — everything the indexer knows, for list views and multi-market bots; discover: true also watches the creation events (the MarketCreator factory AND BinaryMarketsModule.createMarket) so newly created markets — rolling-series or module-created — join live.
  • watchUser(user) — hydrates one account's order/fill history (live events are attributed to every account automatically within watched markets).
  • getWatchStatus(pool)"unwatched" | "hydrating" | "live"; this is how you distinguish "the book is empty" from "I'm not watching this pool".

Watches are ref-counted: two watchers of the same pool share one snapshot and one subscription, and a released scope lingers ~30s before teardown so quick re-watches don't re-snapshot. If the socket drops, watches heal themselves — resubscribe with backoff, then backfill the missed blocks straight from chain (the indexer is only ever touched to hydrate a new scope).

While a market is watched, these answer synchronously with zero round-trips: getLiveBinaryOrderBook / getLiveSpotOrderBook (the resting book — the one to render or quote against), getLiveFills (tape), getLiveUserFills / getLiveUserOrders (one account's activity), getLiveMarkets / getLiveMarketByPool / getLiveMarketByAddress (market rows with live status + stats), and getLiveStatus (global health).

Reactivity without React: subscribeLive fires after every batch of changes — re-read whatever you need inside the callback:

ts
const watch = await client.watchMarket(pool);
client.subscribeLive(() => {
  const { yesBids, yesAsks } = client.getLiveBinaryOrderBook(pool, { depth: 5 });
  requote(yesBids, yesAsks); // runs within ms of the on-chain event
});

React

Provide the client once; the hooks read it from context and watch automatically — rendering a market's book is what subscribes to it, and unmounting releases it:

tsx
import {
  SomniaMarketsProvider,
  useLiveBinaryOrderBook,
  useLiveFills,
  useWatchUser,
} from "@somnia-chain/markets-sdk/react";

function App({ children }) {
  return <SomniaMarketsProvider client={client}>{children}</SomniaMarketsProvider>;
}

function Terminal({ pool, account }: { pool: string; account?: string }) {
  useWatchUser(account); // hydrate the user's history once
  const book = useLiveBinaryOrderBook(pool, 11); // auto-watches `pool` while mounted
  const tape = useLiveFills(pool, 40); // shares the same watch (ref-counted)
  // ...
}

The live-store hooks: useLiveBinaryOrderBook, useLiveBinaryOrderBookByMarket, useLiveSpotOrderBook, useLiveFills, useLiveUserFills, useLiveUserOrders, useLiveFundingUpdates, useLiveMarketByPool, useLiveMarketByAddress, useLiveMarkets, useLiveStatus, useIsTailing, plus the explicit useWatchMarket / useWatchUser and useSomniaMarketsClient. The price (useWatchPrice, useLivePrice, useLivePriceTicks, useLivePriceFeedInfo), funding (useFundingRateSeries), lend (useLendReserves, useLendAccount) and directory (useMarketCreators, useOracleAdapters) hooks are listed in the API reference. Every pool-keyed data hook holds a ref-counted watch on its pool while mounted; useWatchMarket also returns the pool's WatchStatus for loading UI.

For the indexer tier (history + directories, not the live store) there is a generic engine hook — useIndexerQuery(fn, deps) runs fn(client) on mount and whenever deps change, tracks { data, loading, error, refetch }, and discards stale responses:

tsx
const { data: markets } = useIndexerQuery((c) => c.listBinaryMarkets({ limit: 20 }), []);

Concrete wrappers over the common reads: usePortfolio, useMarkets, useCandles, useMarketFees, useOperators. Separately, useLiveMarkets is the zero-round-trip live-store view of every watched market (pair it with a discovery watch), not an indexer fetch.

Chain reads — head-fresh, no watch required

One eth_call round-trip over the client's socket (concurrent calls pipeline):

  • getMarketOnchain — a BinaryMarket's full wiring + state. Authoritative for write eligibility (status, resolution), and works before the indexer has ever seen the market.
  • getBinaryOrderBook / getSpotOrderBook — the book from the contract; the one-shot fallback (or checksum) for the live variants.
  • getErc20Balance / getNativeBalance — raw balances (use these, not indexer balances, to gate a write).
  • getBalances(tokens, account) — batch multi-token balance read (one round-trip for a whole wallet); getOutcomeBalance({ outcomeToken, account, id }) — a single ERC-6909 outcome-id balance (the on-chain point read behind the indexer's getOutcomeBalances).
  • getErc20Metadata(token) / getErc20Allowance(token, owner, spender) — token name/symbol/decimals and a spender allowance (gate an approve on it).
  • getContractMeta(address, { proxy }) — contract/proxy introspection; and getMaxVenueFeeBps() — the protocol's hard fee-rate cap (bps).
  • Perp margin health — getMarginAccount (now including imReq/mmReq/cmReq/ marginStatus), getAccountHealth, and getLiquidationPrice (all MarginBank reads); see perps.
  • getVaultBalance({ vault, owner, token }) — the LIVE claimable balance behind the append-only getVaultPayoutFallbacks credit log (vault credits emit no event, so the current balance must be read from chain).
  • getHeadBlock, getStopOrderSomiPayment, getSystemInfo.

Indexer reads — history and aggregates

One GraphQL round-trip; these are the only methods that touch the indexer (besides a watch's initial snapshot):

  • Markets: listMarkets, getMarket, and the binary-narrowed listBinaryMarkets / listLiveBinaryMarkets / listPastBinaryMarkets / getBinaryMarket.
  • History: getCandles (OHLCV, chart-ready), getFills, and the account-scoped getOrders(owner, opts) (an owner's order history → OrderRow[]), getUserFills(account, { market }) (an account's fills → FillRow[]; scope by market rather than pool on binary, where one pool is recycled across successive markets), and getMarketStatusHistory(marketId) (a market's lifecycle transitions).
  • Single-entity detail (the explorer's order/fill pages run on exactly these): getOrder(pool, orderId) — one order with owner + full lifecycle attribution (OrderDetail; ids never reuse, so the pair is a permanent name), getFill(id) — one fill by its ${block}_${logIndex} id with both parties' order linkage (FillDetail), and getOrderFills(pool, orderId) — every fill one order participated in, either side. All three embed a MarketRef (symbols + decimals + routing identity) so a detail view renders from one read.
  • ONE transaction in full: getTransactionActivity. The same event union as getMarketActivity, selected by transaction hash and ordered forwards, plus the orders placed, the fees paid and the markets touched. Empty for a hash that touched no protocol contract.
  • ONE trade IN CONTEXT: getTradeContext. getFill above names one fill and its market in a single query; this adds the surrounding context — both orders resolved, the fees its transaction charged, and that transaction's other fills — for a second round-trip. Returns null for an unknown id. Use it behind a trade detail page that shows the counterparties; use getFill when rendering the trade alone.
  • One market's whole transaction history: getMarketActivity. It reads what getFills reads and four more streams — complete-set mints and merges, redemptions, oracle resolution, lifecycle transitions — and returns them interleaved by time as one discriminated union, in one round-trip. Use it for a market page's activity panel; use getFills when only trades matter. Every row names its transaction hash. A spot or perp market yields TRADE rows only, because the other four streams read binary-only entities.
  • Wallet views: getPortfolio (binary positions + orders + trades in one round-trip), getSpotPortfolio, getSpotStopOrders, getOpenOrders, getOutcomeBalances.
  • Control plane: listOperators / getOperator / listVenues / getVenue — the indexed MarketsCore operator/venue directory (venue ids are opaque bytes32; decode a BINARY_V1 venue's fee bytes with decodeBinaryVenueFeeParams).
  • Fee / resolution / router history (binary): getMarketResolution, getRouterActions, listProtocolFees / listBuilderFees / listSettlementFees (per-fill streams behind getMarketFees, filterable by payer), listBuilderApprovals, getVaultPayoutFallbacks — see binary markets.
  • Perp account plane: getFundingPayments, getMarginEvents, listLiquidations, listFundingRateHistory, listFundingRateCandles, getOpenInterestHistory — the append-only history the chain doesn't expose; see perps.
  • Lookups + totals: getMarketByPool (resolve a market by pool address) and listMarketsByPool(pool) (every market the pool has HOSTED, newest first — a binary pool is recycled across successive markets, so this is its full history; also getSeries(creator, seriesId) for one rolling series' current spec, the single-row sibling of listSeries), countOrders / countUserFills (history-page totals), and the board totals countMarkets / countBinaryMarkets / countVenues / countOperators (each takes the same filter as its list* sibling — use for pagination headers). CANDLE_INTERVALS is the exported bucket list getCandles accepts.
  • Ops: getSyncStatus — how far behind the indexer itself is.

Two of these have faster live twins a trading loop should prefer: getOpenOrdersgetLiveUserOrders, getFillsgetLiveFills.

The network tape — every order on the network, live

ts
const tape = client.createNetworkTape();
const stop = tape.subscribe(() => {
  render(tape.bids, tape.fills, tape.asks, tape.getStatus());
});
// … stop() closes the socket when the last listener unsubscribes.

createNetworkTape returns a NetworkTape — a network-WIDE order-flow firehose: one topics-only chain-log subscription that sees OrderPlaced / OrderFilled from every pool (including pools created after it starts), no indexer on the hot path. It is not the live tail: no books, no snapshots — three newest-first ring buffers (bids / fills / asks) plus a session (pool, orderId) → owner map that attributes fills (takers always resolve — their placement follows the fill in the same tx; makers resolve when quoted while the tape ran, else null). Rows carry RAW units; join to listMarkets for symbols/decimals. Nothing connects until the first subscribe, and reconnect/stall handling (Somnia's silently-dying WS subs) is built in.

For enriching a tape row's transaction, getTransactionSummary(hash) is the chain-direct companion: sender, gas used/limit, effective gas price, fee paid, status. It resolves to null for a malformed hash and for a transaction the node reports as not found. A read that does not complete throws RpcError, so an outage stays distinct from a transaction that never landed.

Writes — createTrader

ts
const trader = client.createTrader({ privateKey }); // or { walletClient } in the browser
const { orderId, fills } = await trader.placeOrder({ pool, side: "BUY_YES", price, quantity });

createTrader binds a signer to this client's chain, fees, and socket and returns a Trader. The trader is independent of the watches — a pool's escrow tokens resolve from the pool contract itself (one cached read) when you don't pass them. With a local key the SDK signs locally with fixed fees and a fixed gas ceiling. Before each local send, it reconciles the pending nonce and serializes nonce consumption, signing, and broadcast acceptance. Some orders also need prerequisite funding reads or an approval transaction. realtime_sendRawTransaction returns the mined receipt; every write resolves only once mined, with that receipt (and, for orders, the decoded orderId + fills). Market-kind specifics: binary markets, spot.

For the MarketsCore control plane there is a second, low-frequency write tier: client.createOperatorAdmin({ privateKey }) returns an OperatorAdmin with registerOperator, updateOperator, createVenue (venues are typed by a bytes4 market-type id — MARKET_TYPE_BINARY_V1 — and get a contract-generated bytes32 venue id back), updateVenue, setVenueEnabled, and the two-step operator-ownership transfer. Build a BINARY_V1 venue's fee bytes with client.encodeBinaryVenueFeeParams({...}).

Escape hatch

getViemClient() returns the underlying viem WebSocket client for any read the SDK doesn't cover — your own contracts, or plain calls like getBalance / getCode / waitForTransactionReceipt. Same socket, same pipelining: a client of your own would open a second connection, and one-socket-per-client is deliberate here.

It is undecorated, and that is the point. Every read reachable from the client interface goes through a wrapped viem client whose readContract / call rethrow as typed SDK errors — a revert arrives decoded, as its Solidity name. The client this method hands you does not do that: reads keep viem's own error contract, so e instanceof ContractFunctionRevertedError and the rest of your existing viem error handling still work.

Pick accordingly. For a protocol contract, prefer the SDK's own methods — the decoding is the value, and on a contract the SDK doesn't bundle the decoder can only report errorName: undefined anyway. For anything else, this is the door.


Next: binary markets, spot, perps, and the architecture guide for how the machine works inside.