{s}omniamarkets

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

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:

  • OrderFilled fires before the taker's OrderPlaced in the same tx, so the taker is unknown at fill time. The fill stores takerOrder_id / makerOrder_id foreign keys; read-time selectors back-join owner + side from the order map (hydrated by the snapshot's open orders and live events).
  • MarketCreated grows the discovery watch live. Two creation events are watched. The MarketCreator factory rolls each series autonomously (a Somnia reactivity Schedule sub 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-field MarketCreated(marketId, market, pool, yesId, noId, collateral, asset, strike, tradingStart, expiry, oracleQuestionId, question, intervalSec) — the trailing intervalSec is the series cadence (900 / 3600 / 14400 / 86400). The BinaryMarketsModule emits its own 19-field MarketCreated for EVERY market (rolled or created directly via createMarket) — the only creation event carrying the (operatorId, venueId) origin attribution — so module-created markets that never pass through a MarketCreator are discovered too. When an all-markets watch with discover is active, the tail re-subscribes with the new pool + market addresses and sweeps the creation range with a one-shot getLogs, 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 wantUseBacked byFreshness
The book, to render or quote againstgetLiveBinaryOrderBook / getLiveSpotOrderBook / hookslocal storelast block, 0 RTT
Trade tape / user orders / market stats, livegetLive* / hookslocal storelast block, 0 RTT
Is this market tradable right nowgetLiveMarketByAddress(...).status or getMarketOnchainchain events / eth_calllast block
Candles, portfolios, historical fillsgetCandles / getPortfolio / getFillsindexerindexer lag (~200ms)
A market list without opening watcheslistMarkets / listBinaryMarketsindexerindexer lag
Raw balances / wiringgetErc20Balance / getMarketOnchaineth_callhead

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".