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 · getMarketActivity · 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 (60 / 300 / 900 / 3600 / 14400 / 86400). The BinaryMarketsModule emits its own 20-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: gas is not estimated and receipts are not 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: resolve funding requirements<br/>uncached ERC-20 approvals may read + send;<br/>native spot sells read the current vault shortfall
    Note over T: reconcile pending nonce;<br/>serialize nonce consumption, signing,<br/>and broadcast acceptance;<br/>use fixed fees + fixed gas ceiling
    T->>WS: realtime_sendRawTransaction(signedTx)
    Note over WS: node executes, blocks server-side<br/>until the receipt exists
    WS-->>T: receipt WITH logs
    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

An uncached (token, spender) pair does one allowance read. A short allowance causes one approve(maxUint256) transaction and then enters the cache. An existing effectively unlimited allowance also enters the cache. A finite allowance remains uncached because an order can consume it. The next order then reads the pool requirement and allowance again.

Before each local write, viem reconciles the node's pending nonce with its in-memory offset. The trader serializes local nonce consumption, signing, and broadcast acceptance. One failed write therefore cannot invalidate a concurrent write's nonce reservation. A signer or broadcast failure resets the manager only after every earlier local write has reached a terminal send result.

Measured on Somnia testnet with the sdk-e2e write suite (E2E_WRITE=1, which writes a gitignored latency-report.md): placeOrder confirm p50 ≈ 503ms, within ~25ms of the raw transport floor.

Spot funding preconditions

The approval cache records only effectively unlimited allowances. A finite allowance is never cached, so the next order reads it again after the current order may have consumed it. Spot orders ask the pool for its exact fee-aware funding requirement when an approval check is needed. A native-base sell does that read every time so it can send only the current vault shortfall.

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 fills, market activitygetCandles / getPortfolio / getFills / getMarketActivityindexerindexer 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".