Architecture
How @somnia-chain/markets-sdk is put together, and why. The design goals:
minimum latency — zero polling anywhere, one WebSocket as the only realtime
transport, writes that confirm in a single round-trip — and scoped cost:
live materialization is per-watch, so a client pays for the markets it actually
renders or trades, not for the whole protocol, and the indexer is touched
exactly once per watch (cold-start hydration) — after that, the chain itself is
the data source.
- System overview
- The client
- Watches: the snapshot seam + chain materialization
- Event routing and the reducer
- The local order book
- Connection lifecycle
- The write path
- Choosing a read: the three tiers
System overview
One SomniaMarketsClient owns all protocol I/O. It talks to exactly two backends —
the Envio/Hasura indexer (history + cold-start snapshot, HTTP GraphQL) and
the chain WebSocket (everything realtime: log/head subscriptions, contract
reads, transaction sends). All state lives in a per-client MaterializerStore;
React hooks and the getLive* methods are synchronous views over it.
flowchart LR
subgraph app["Your app"]
hooks["React hooks<br/><i>useLiveFills, useLiveBinaryOrderBook, …</i>"]
node["Node / server code<br/><i>bots, scripts, RSC</i>"]
end
subgraph client["the engine (exchange.client)"]
direction TB
query["query.ts<br/><b>one-shot indexer reads</b><br/>listMarkets · getCandles · getPortfolio ·<br/>getFills · getMarketResolution · getRouterActions ·<br/>listProtocolFees · getFundingPayments · …"]
reads["reads.ts / system.ts<br/><b>one-shot chain reads</b><br/>getBinaryOrderBook · getMarketOnchain ·<br/>getAccountHealth · getLiquidationPrice ·<br/>getVaultBalance · getErc20Balance · …"]
subgraph live["live watches"]
tail["liveTail.ts<br/><b>watch registry + ingestion</b><br/>ref-counted scopes · subscribe ·<br/>backfill · seam"]
reducer["reducer.ts<br/><b>event → state</b><br/>mirror of the indexer handlers"]
store["store.ts<br/><b>MaterializerStore</b><br/>markets · orders · fills ·<br/>book levels · status<br/><i>versioned, memoized selectors</i>"]
end
trader["trade.ts<br/><b>writes</b><br/>sign local · fixed fees ·<br/>realtime send"]
end
subgraph backends["Backends"]
indexer["Envio / Hasura indexer<br/><i>HTTP GraphQL</i><br/>history · aggregates ·<br/>cold-start snapshot"]
chain["Somnia chain<br/><i>one WebSocket</i><br/>eth_subscribe logs+heads ·<br/>eth_call · getLogs ·<br/>realtime_sendRawTransaction"]
end
hooks -- "useSyncExternalStore" --> store
node -- "getLive* (sync)" --> store
node -- "await client.*" --> query
node -- "await client.*" --> reads
node -- "createTrader(…)" --> trader
query -- "POST /v1/graphql" --> indexer
tail -- "one scope snapshot per<br/>NEW watch — never again" --> indexer
tail -- "watchEvent · watchBlocks ·<br/>getLogs (seam backfill)" --> chain
reads -- "eth_call (pipelined)" --> chain
trader -- "realtime_sendRawTransaction<br/>(send + receipt, 1 RTT)" --> chain
tail --> reducer --> store
Key properties:
- Scoped. Live materialization is opt-in per market via ref-counted watches. Cost — snapshot size, subscription filter width, event volume, reducer work — scales with what you watch, not with the protocol. Multi-chain is multiple clients, one per chain.
- Isolation. Every exchange owns its own config, store, watches, and (lazily opened) socket. Two clients never share state — per-request watches, multi-chain bots, and parallel tests all just work.
- One socket. Subscriptions, reads, backfills, and sends multiplex over the
same WebSocket. Concurrent reads pipeline on the wire (
Promise.all≈ one round-trip), so there is no request batching layer and none is needed. - Indexer only when necessary. The indexer serves history and aggregates (candles, portfolios, old fills) and one consistent snapshot when a NEW watch scope opens. Everything that gates a trading decision — the book, market status, new-market discovery — is materialized from chain events, and even reconnects heal from chain alone.
The client
flowchart TB
cc["new SomniaMarkets(config)"] --> ex["SomniaMarkets (exchange)"] -- ".client" --> pc["SomniaMarketsClient (engine)"]
pc --> l1["<b>live watches</b><br/>watchMarket · watchMarkets · watchUser ·<br/>getWatchStatus · stopLive · subscribeLive<br/>getLiveStatus · getLiveMarkets · getLiveMarketByPool/ByAddress<br/>getLiveFills · getLiveUserFills · getLiveUserOrders<br/>getLiveBinaryOrderBook · getLiveSpotOrderBook"]
pc --> l2["<b>indexer reads</b> (Promise, throw on failure)<br/>listMarkets · getMarket · listBinaryMarkets · getBinaryMarket<br/>getCandles · getFills · getOpenOrders · getOutcomeBalances<br/>getPortfolio · getSpotPortfolio · getSpotStopOrders · getSyncStatus"]
pc --> l3["<b>chain reads</b> (Promise)<br/>getBinaryOrderBook · getSpotOrderBook · getMarketOnchain<br/>getErc20Balance · getNativeBalance · getHeadBlock<br/>getStopOrderSomiPayment · getSystemInfo"]
pc --> l4["<b>writes</b><br/>createTrader({privateKey | account | walletClient})<br/>→ placeOrder · cancelOrder · placeSpotOrder ·<br/>placeSpotStopOrder · mintSet · burnSet · redeem · …"]
The socket opens lazily on first chain I/O, so an indexer-only client (e.g. server-side GraphQL reads) never opens one.
Watches
A watch turns the client into a local indexer of its scope: it hydrates a consistent snapshot of that scope from Envio up to block X, then materializes X+1.. directly from chain logs. Scopes are ref-counted (two watchers of one pool share everything) and torn down after a short linger when the last handle stops. The seam is gap-free and double-count-free:
sequenceDiagram
autonumber
participant App
participant Tail as LiveTail (watch registry)
participant Idx as Indexer (GraphQL)
participant WS as Chain WebSocket
participant Store as MaterializerStore
App->>Tail: watchMarket(pool)
Tail->>WS: eth_subscribe logs (+ pool) · newHeads
Note over Tail,WS: the scope's logs BUFFER from this instant —<br/>nothing past the head can be missed
Tail->>Idx: scope snapshot (ONE request)
Note over Idx: the market row · recent fills ·<br/>its full resting order set ·<br/>chain_metadata.latest_processed_block = X
Idx-->>Tail: rows consistent to block X
Tail->>Store: mergeSnapshot (other scopes untouched)
Tail->>WS: widen subscription (+ the market's<br/>BinaryMarket address, now known)
Tail->>WS: eth_getLogs [X+1 … head] for the scope
WS-->>Tail: backfill logs
Tail->>Store: decode → reduce → commit
Tail->>Tail: replay the scope's buffered logs<br/>(deduped by (block, logIndex))
Tail-->>App: watchMarket resolves — reads are live
loop live — every block
WS-->>Tail: pushed logs + head
Tail->>Store: decode → reduce → commit (one notify per batch)
Store-->>App: subscribers re-read (hooks re-render)
end
watchMarkets({ discover }) is the same seam with scope = every market (plus
the creation-event sources — the MarketCreator factory AND the
BinaryMarketsModule — when discovering); watchUser(user) is snapshot-only
(the account's past orders/fills — live attribution needs no subscription of
its own). Logs for a scope that is mid-hydration buffer in an inbox while other
scopes keep applying live — one scope's seam never stalls another.
Why this is safe on Somnia specifically: blocks have instant BFT finality —
a delivered log is final, so there is no reorg handling. The dedupe set keyed by
(blockNumber, logIndex) makes the buffer/backfill overlap harmless.
Somnia logs carry no timestamp, so the newHeads stream doubles as the
timestamp source (block number → timestamp map, pruned as the head advances).
Event routing and the reducer
One combined ABI (liveEventsAbi) decodes every watched log; the reducer routes
by event name + source address and mutates the store exactly the way the
Envio handlers mutate Postgres (reducer.ts mirrors
indexer/src/handlers/orderbook.ts — keep them in lockstep):
flowchart TB
log["raw log from WS / backfill"] --> dec{"decodeEventLog<br/>(liveEventsAbi)"}
dec -- "source = a pool<br/>(spot OR binary — byte-identical events)" --> ob["<b>OrderBook events</b>"]
dec -- "source = a SpotPool" --> spot["<b>spot-only events</b>"]
dec -- "source = MarketCreator /<br/>BinaryMarketsModule" --> fac["<b>MarketCreated</b>"]
dec -- "source = a BinaryMarket" --> bm["<b>lifecycle events</b>"]
ob --> op["OrderPlaced → upsert order<br/>(binary: side from isBid+userData, born Open;<br/>spot: no side, born Closed)"]
ob --> orst["OrderRested → rested=true<br/>(spot: Closed → Open)"]
ob --> ofill["OrderFilled → write LiveFill ·<br/>patch maker order · bump market stats<br/>(lastPrice, volumes, tradeCount)"]
ob --> oc["OrderCancelled / OrderExpired /<br/>OrderCancelledSelfMatch /<br/>OrderReduced → patch order"]
ob --> bk["SetMinted / SetBurned / Redeemed /<br/>SettlementFeeCharged → market backing"]
spot --> mp["MarkPriceUpdated → market.markPrice"]
spot --> bp["OrderBookParametersUpdated →<br/>tickSize · lotSize · minQuantity"]
fac --> nm["build BinaryMarket row →<br/>indexMarket → GROW the watch set →<br/>catch-up getLogs for the creation range"]
bm --> st["StatusChanged → status enum"]
bm --> res["Resolved → status + winningOutcome"]
bm --> vd["Voided → voided + status"]
Two ordering subtleties the reducer encodes:
OrderFilledfires before the taker'sOrderPlacedin the same tx, so the taker is unknown at fill time. The fill storestakerOrder_id/makerOrder_idforeign keys; read-time selectors back-join owner + side from the order map (hydrated by the snapshot's open orders and live events).MarketCreatedgrows the discovery watch live. Two creation events are watched. TheMarketCreatorfactory rolls each series autonomously (a Somnia reactivitySchedulesub drains the due boundary's series in a gas-bounded loop, spilling to the next block when a batch is too large), emitting its 13-fieldMarketCreated(marketId, market, pool, yesId, noId, collateral, asset, strike, tradingStart, expiry, oracleQuestionId, question, intervalSec)— the trailingintervalSecis the series cadence (900 / 3600 / 14400 / 86400). TheBinaryMarketsModuleemits its own 19-fieldMarketCreatedfor EVERY market (rolled or created directly viacreateMarket) — the only creation event carrying the(operatorId, venueId)origin attribution — so module-created markets that never pass through aMarketCreatorare discovered too. When an all-markets watch withdiscoveris active, the tail re-subscribes with the new pool + market addresses and sweeps the creation range with a one-shotgetLogs, so a market created seconds ago is fully tailed — the indexer is never asked again.
The local order book
The store is the book. Every resting order is tracked through its lifecycle, so depth is an in-memory aggregation — reading the book costs zero round-trips and is current to the last delivered block:
flowchart LR
subgraph store["MaterializerStore.orders"]
o1["order: Open + rested<br/>price 620000 · qty 50"]
o2["order: Open + rested<br/>price 620000 · qty 25"]
o3["order: Open + rested<br/>price 610000 · qty 10"]
o4["order: Filled / Cancelled /<br/>not rested → ignored"]
end
store --> agg["bookLevels(pool, depth)<br/>group by (isBid, price) · sum qty ·<br/>sort best-first · slice depth"]
agg --> spotbook["getLiveSpotOrderBook<br/>{ bids, asks }"]
agg --> yes["YES-terms levels"]
yes --> inv["toBinaryBook( )<br/>NO side = 1 − yesPrice<br/>(quantities carry over)"]
inv --> binbook["getLiveBinaryOrderBook<br/>{ yesBids, yesAsks, noBids, noAsks }"]
This matches the on-chain getBookLevels exactly because binary pools keep a
single book in YES terms — a NO order is expressed as isBid/userData with
a YES-terms price, which is also how orders arrive in OrderPlaced events. The
one-shot chain reads (getBinaryOrderBook/getSpotOrderBook) remain available as a
checksum, but nothing in a render or quoting path needs them.
Completeness note: live-witnessed orders are always present; orders that rested before the watch opened come from the scope snapshot's open-order set — and because snapshots are per-scope, that set is the market's whole resting book, not a global cap's share of it.
Connection lifecycle
There is no polling fallback and no HTTP transport. With no active watches the client holds no subscriptions at all; with watches, it is either live or reconnecting:
stateDiagram-v2
[*] --> idle
idle --> hydrating: first watchMarket / watchMarkets
hydrating --> live: scope snapshot + backfill + replay OK
hydrating --> reconnect_wait: snapshot / backfill failed
live --> hydrating: new watch opens (its scope only —<br/>existing scopes keep streaming)
live --> reconnect_wait: WS error (logs or heads)
reconnect_wait --> live: resubscribe + getLogs<br/>[lastBlock+1 … head] — CHAIN ONLY,<br/>the indexer is not consulted
note right of reconnect_wait
exponential backoff
500ms → 1s → 2s → 4s → 8s (cap)
reset to 500ms once live
end note
live --> idle: last watch released<br/>(after a ~30s linger)
live --> live: blocks stream in
Reconnects never re-snapshot: the store already holds state to block L, so the
seam is simply getLogs [L+1, head] deduped against what's processed — chain
truth heals chain gaps. The indexer is only ever consulted to hydrate a scope
it hasn't seen.
The write path
Two signer modes, one rule: nothing is estimated, nothing is polled.
sequenceDiagram
autonumber
participant Bot as Caller
participant T as Trader (trade.ts)
participant WS as Chain WebSocket
rect rgb(235, 245, 235)
Note over Bot,WS: LOCAL SIGNER (privateKey / account) — the fast path
Bot->>T: placeOrder({pool, side, price, quantity})
Note over T: escrow token+amount computed locally<br/>approval cache hit → no allowance read<br/>pool tokens from the trader's own cache<br/>(one-time chain read — no live-store dependency)
Note over T: build tx with ZERO RPCs:<br/>· fixed fees (config.fees, default 60 gwei / 0 tip)<br/>· fixed gas ceiling (unused gas refunded)<br/>· nonce from local NonceManager<br/>then sign locally
T->>WS: realtime_sendRawTransaction(signedTx)
Note over WS: node executes, blocks server-side<br/>until the receipt exists
WS-->>T: receipt WITH logs — one round-trip (~480ms)
Note over T: decode OrderPlaced → orderId<br/>decode OrderFilled → fills[]
T-->>Bot: { hash, receipt, orderId, fills }
end
rect rgb(240, 240, 250)
Note over Bot,WS: BROWSER SIGNER (injected walletClient)
Bot->>T: placeOrder(…)
T->>WS: eth_sendTransaction (wallet signs, fixed fees pinned)
WS-->>T: tx hash
T->>WS: getTransactionReceipt (immediate check)
Note over T,WS: on miss: eth_subscribe newHeads —<br/>re-check on each pushed head, first hit wins.<br/>No poll, no timeout — socket errors reject.
WS-->>T: receipt
T-->>Bot: { hash, receipt, orderId, fills }
end
The first write of a (token, spender) pair does one allowance read and — if
needed — one approve(maxUint256) tx; the pair is then cached for the trader's
lifetime, so steady-state orders are exactly one round-trip. The one-time nonce
fetch happens on the first write; broadcast errors reset() the local nonce so
a failed send can't leave a gap.
Measured on Somnia testnet (see sdk-e2e/latency-report.md): placeOrder
confirm p50 ≈ 503ms, within ~25ms of the raw transport floor.
Choosing a read
| You want | Use | Backed by | Freshness |
|---|---|---|---|
| The book, to render or quote against | getLiveBinaryOrderBook / getLiveSpotOrderBook / hooks | local store | last block, 0 RTT |
| Trade tape / user orders / market stats, live | getLive* / hooks | local store | last block, 0 RTT |
| Is this market tradable right now | getLiveMarketByAddress(...).status or getMarketOnchain | chain events / eth_call | last block |
| Candles, portfolios, historical fills | getCandles / getPortfolio / getFills … | indexer | indexer lag (~200ms) |
| A market list without opening watches | listMarkets / listBinaryMarkets | indexer | indexer lag |
| Raw balances / wiring | getErc20Balance / getMarketOnchain … | eth_call | head |
Rule of thumb: anything that gates an action reads the store or the chain; the indexer is for history. Indexer reads throw on failure — an empty result always means "no rows", never "request failed".