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 —
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:
| Tier | Methods look like | Backed by | Cost & freshness |
|---|---|---|---|
| Live store | getLive* (synchronous) | in-memory materialization of watched markets | zero round-trips, current to the last block |
| Chain | get*Onchain, getBinaryOrderBook, balances | eth_call over the socket | one round-trip, current to head |
| Indexer | list*, 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:
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;
}
| Class | Means | Your move |
|---|---|---|
InvalidInputError | Bad argument, unknown symbol, wrong market kind (thrown before any I/O) | Fix the call |
NotConfiguredError | A needed address/URL was never passed (what names it) | Fix the config |
SignerRequiredError | The method writes; this client has no signer | Construct with a signer |
IndexerError | An indexer read did not complete | Retry / degrade |
RpcError | A node request did not complete | Retry / switch RPC |
ContractRevertError | The chain rejected the call — errorName + args | Branch 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
MarketNotSettledrather than hex. When the data matches nothing known you still get aContractRevertError, with the rawreason/datapreserved. - A mined-but-reverted transaction throws. You don't need to check
receipt.statusyourself; the SDK replays the call to recover the reason first. causeis 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:
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: truealso watches the creation events (theMarketCreatorfactory ANDBinaryMarketsModule.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:
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:
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:
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'sgetOutcomeBalances).getErc20Metadata(token)/getErc20Allowance(token, owner, spender)— token name/symbol/decimals and a spender allowance (gate anapproveon it).getContractMeta(address, { proxy })— contract/proxy introspection; andgetMaxVenueFeeBps()— the protocol's hard fee-rate cap (bps).- Perp margin health —
getMarginAccount(now includingimReq/mmReq/cmReq/marginStatus),getAccountHealth, andgetLiquidationPrice(allMarginBankreads); see perps. getVaultBalance({ vault, owner, token })— the LIVE claimable balance behind the append-onlygetVaultPayoutFallbackscredit 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-narrowedlistBinaryMarkets/listLiveBinaryMarkets/listPastBinaryMarkets/getBinaryMarket. - History:
getCandles(OHLCV, chart-ready),getFills, and the account-scopedgetOrders(owner, opts)(an owner's order history →OrderRow[]),getUserFills(account, { market })(an account's fills →FillRow[]; scope bymarketrather thanpoolon binary, where one pool is recycled across successive markets), andgetMarketStatusHistory(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), andgetOrderFills(pool, orderId)— every fill one order participated in, either side. All three embed aMarketRef(symbols + decimals + routing identity) so a detail view renders from one read. - ONE transaction in full:
getTransactionActivity. The same event union asgetMarketActivity, 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.getFillabove 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. Returnsnullfor an unknown id. Use it behind a trade detail page that shows the counterparties; usegetFillwhen rendering the trade alone. - One market's whole transaction history:
getMarketActivity. It reads whatgetFillsreads 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; usegetFillswhen only trades matter. Every row names its transaction hash. A spot or perp market yieldsTRADErows 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 withdecodeBinaryVenueFeeParams). - Fee / resolution / router history (binary):
getMarketResolution,getRouterActions,listProtocolFees/listBuilderFees/listSettlementFees(per-fill streams behindgetMarketFees, filterable bypayer),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) andlistMarketsByPool(pool)(every market the pool has HOSTED, newest first — a binary pool is recycled across successive markets, so this is its full history; alsogetSeries(creator, seriesId)for one rolling series' current spec, the single-row sibling oflistSeries),countOrders/countUserFills(history-page totals), and the board totalscountMarkets/countBinaryMarkets/countVenues/countOperators(each takes the same filter as itslist*sibling — use for pagination headers).CANDLE_INTERVALSis the exported bucket listgetCandlesaccepts. - Ops:
getSyncStatus— how far behind the indexer itself is.
Two of these have faster live twins a trading loop should prefer:
getOpenOrders → getLiveUserOrders, getFills → getLiveFills.
The network tape — every order on the network, live
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
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.