Price feeds

A realtime index price for an asset (BTC, ETH) from the on-chain EMA price oracle — streamed the same way the order books are: hydrate a snapshot to get roughly up to speed, then tail live. It's a read-only feed (an index price, not a tradable market), so there are no orders here — just watch/fetch. The shared client mechanics (read tiers, the reactive store, React wiring) are in the engine guide; how the number itself is produced — venue sourcing, weighted median, quantization, validator consensus, the EMA mark — is Methodology at the bottom of this page.

The mental model

  • A separate service. Prices come from a standalone EMA price-feed indexer (Hasura GraphQL), not the markets indexer — its own endpoint, its own WebSocket. So price watches are independent of watchMarket: their own store, their own subscribePrices signal, their own health.
  • One endpoint, every asset. A single endpoint (config.priceFeed) serves every tracked asset ("BTC", "ETH", …); callers select an asset by symbol (case-insensitive). Discover what's tracked with listPriceFeeds(). With no priceFeed configured, the price methods throw with guidance.
  • Snapshot, then live tail. A watch first pulls an HTTP snapshot (current price + recent ticks), then a Hasura subscription streams updates. Unlike the order-book tail there's no seam to stitch — a price subscription is a full-state stream, so a reconnect just re-delivers current state (handled with backoff internally).
  • Human numbers + exact raw. Every price is a 1e18-scaled integer on the wire; the structs carry a display number (price, ema) and the exact 1e18 string in raw — never round-trip money through the number. Timestamps are unix seconds (blockTimestamp is chain time, monotonic — use it as a series x-axis; observedAt is the oracle's own source time and can drift).

Configuration

ts
import { SomniaMarkets, SOMNIA_TESTNET_PRICE_FEED } from "@somnia-chain/markets-sdk";

const exchange = new SomniaMarkets({
  indexerUrl, chain, wsRpcUrl,
  priceFeed: SOMNIA_TESTNET_PRICE_FEED,     // { url } — the deployed dev feed, all assets
  // or your own: priceFeed: { url: "https://…/v1/graphql" }
});

priceFeed is { url, wsUrl? }; wsUrl is derived from url (http→ws, https→wss) when omitted. Live subscriptions use the global WebSocket (browsers, Node ≥ 21).

Reading — live

ts
const watch = await client.watchPrice("BTC");     // snapshot + subscribe; ref-counted

const now   = client.getLivePrice("BTC");         // LivePrice | null — { price, ema, blockTimestamp, raw, … }
const tape  = client.getLivePriceTicks("BTC", { limit: 120 }); // recent ticks, newest first
const info  = client.getLivePriceFeedInfo("BTC"); // decimals, symbol, description, latest
const state = client.getPriceStatus("BTC");       // "unwatched" | "hydrating" | "live"

watch.stop();                                     // release (the last release tears the socket down)

Batch variants take/return arrays and are the ergonomic choice for a multi-asset ticker — one watch, one read:

ts
const handle = await client.watchPrices(["BTC", "ETH", "SOL"]); // snapshot + subscribe all; ref-counted
const rows   = client.getLivePrices(["BTC", "ETH", "SOL"]);     // (LivePrice | null)[], index-aligned
const tailing = client.isTailing();                             // true while the price socket is live-tailing
handle.stop();                                                  // release all in one call

Reads are synchronous, zero-round-trip, and current to the last pushed tick; subscribe to changes with client.subscribePrices(listener) (independent of the order-book subscribeLive). In React the hooks auto-watch while mounted:

tsx
import { useLivePrice, useLivePriceTicks, useWatchPrice } from "@somnia-chain/markets-sdk/react";

function Ticker() {
  const price  = useLivePrice("BTC");            // updates the moment a tick lands
  const status = useWatchPrice("BTC");           // "hydrating" | "live" — render loading off this
  const ticks  = useLivePriceTicks("BTC", 120);  // for a sparkline
  return <span>{price ? `$${price.price.toLocaleString()}` : "…"}</span>;
}

Reading — one-shot

No watch, one HTTP round-trip each — for history, charts, or a server render:

ts
const info    = await client.fetchPriceFeedInfo("BTC");                 // metadata + current price
const price   = await client.fetchPrice("BTC");                         // LivePrice | null
const history = await client.fetchPriceHistory("BTC", { limit: 500, from, to }); // ticks, newest first
const candles = await client.fetchPriceCandles("BTC", "M1", { from, to });       // OHLC, oldest first

Candle resolutions are "M1" / "H1" / "D1" (60s / 3600s / 86400s). count on a candle is the number of oracle updates in the bucket (update density), not trade volume. Like every indexer read these throw on request failure — an empty result means "no rows", never "request failed".

From the exchange

The unified SomniaMarkets surface exposes prices by asset (these don't need loadMarkets — a price feed isn't a symbol-keyed market):

ts
const px = await exchange.fetchPrice("BTC");                    // { symbol, price, ema, timestamp, … } | null
for await (const _ of forever) {
  const tick = await exchange.watchPrice("BTC");                // resolves on each new tick
}
const ohlcv = await exchange.fetchPriceOHLCV("BTC", "1m");      // [ms, o, h, l, c, count][] — "1m"/"1h"/"1d"

The native LivePrice (raw strings + block metadata) rides on each struct's info.

Methodology

Everything above is the read surface. This section is how the number itself is produced: exactly what is measured, what is discarded, how it is aggregated, how it is encoded, and what a consumer is guaranteed when it reads a cell. Every constant is the value in the shipped code (impl v9).

One-line version. A weighted median across seven venue mids, quantized to nine significant figures, medianed again across three independent validators, packed with its own provenance into one 256-bit storage word — every second, paid for by the contract itself.

Cadence1 s
Scale18 decimals
Sources7 centralised exchanges, top-of-book mid
On-chain aggregation3-validator subcommittee, 2-of-3 threshold
NetworksSomnia mainnet (5031) 0x1998C88D54a240b8C671493f27f58eC416b81D7F · testnet (50312) 0x7ccAE31E45693475be1e4BAa2360D6997eB2D32B
flowchart TB
  V["7 venues<br/>top-of-book mid"] --> G["4 freshness gates<br/>+ per-symbol quorum floor"]
  G --> M["weighted median<br/>over the fresh set"]
  M --> Q["quantize<br/>30-bit mantissa + 5-bit exponent"]
  Q --> B["48-bit slot per symbol<br/>packed blob"]
  B --> C["3 validators submit<br/>independently"]
  C --> M2["second median<br/>per symbol, on-chain"]
  M2 --> E["EMA mark<br/>alpha = 0.125"]
  E --> S["one 256-bit cell<br/>spot + mark + provenance"]
  S --> R["consumer read<br/>pull, never reverts"]

01 — Source: seven venue mids, streamed

A Go process — the price-oracle agent — connects to seven centralised exchanges through a pruned fork of ccxt. It does not fetch on demand. One goroutine per venue runs continuously, writing the latest quote for every tracked pair into an in-memory store; a price request is a read of that store, so it answers in about a millisecond with zero network I/O on the request path.

The quantity collected is the top-of-book mid, (bid + ask) / 2, spot markets only. Four venues stream over WebSocket; three fall back to bulk REST polling on a 1-second ticker.

VenueWeightTransportWhy
binance3ws stream
okx2ws stream
bybit2rest pollwss endpoint geoblocked from the runners' datacentre IPs
kucoin1ws stream
kraken1ws stream
gateio1rest pollstream unimplemented in the fork
mexc1rest pollstream connects, never delivers

Total weight 11. Weights are static configuration — not measured volume or depth. They are placeholders intended to be tuned against real per-venue liquidity. Measured source freshness: 70–220 ms on the streaming venues, 225 ms–3.3 s on REST.

internal/exchange/ccxt.go, oracle.json · 61 tracked pairs · POLL_INTERVAL 1 s · PER_CALL_TIMEOUT 8 s · reconnect backoff doubles to 30 s, jittered

02 — Admission: four gates, then a quorum floor

Staleness is the first and strictest gate. A quote that sat in the cache longer than three seconds is not aggregated — it is not even reported as a number.

GateWhenRuleEffect
Book sanityingestboth sides present, bid > 0, ask > 0, not crossed (ask ≥ bid)quote discarded, never cached
Spreadingest(ask − bid) / mid ≤ 0.02quote discarded, never cached
Dwellaggregationnow − UpdatedAt > STALE_AFTER (3 s)exclude, excludedReason: "stale"
Never seenaggregationconfigured exchange has never returned this pairexclude, excludedReason: "no-cell"

The first two run in midPrices as each venue's ticker batch is reduced to a mid, so a bad book never reaches the store at all — there is no exclusion reason for it, and a venue that only ever produces bad books is indistinguishable from one that has never returned the pair. "stale" and "no-cell" are the only two exclusion reasons that exist.

  • The spread bound is loose on purpose. Liquid USDT pairs run around 1 bp, so 2% only catches a book wide enough to be a manipulation surface.
  • Dwell is measured on the agent's own ingest clock (now − UpdatedAt), not the exchange's timestamp, so it is immune to venue clock skew. The comparison is strictly greater: exactly 3.000 s still counts.

Then a per-symbol quorum floor. With fewer than minSources fresh venues the agent refuses to publish. The default floor is 3, with a per-token override table (SOMI/USDC: 1, because it does not list on three of these venues). Below the floor the symbol's slot is written as all zeroes — not a small number, not a stale number, nothing.

internal/store/store.go:136,198 · internal/server/handlers.go:98–112, 406–437 · maxMidSpreadFrac = 0.02 · STALE_AFTER = 3s

03 — Aggregation: a weighted median, not an average

Sort the fresh quotes by price. Walk the weights. The first price whose cumulative weight crosses half the total is the index. One venue can be the whole answer.

All seven venues fresh, total weight 11, half is 5.5:

VenuePriceWeightCumulative
kraken107,373.9011
mexc107,374.0512
okx107,374.1824
binance107,374.2137 ← first to cross 5.5
bybit107,374.4429
kucoin107,374.60110
gateio107,375.02111

The index is 107,374.21. Drop binance (stale) and the total falls to 8, half is 4, and bybit at 107,374.44 becomes the index — a 23-cent step with no bad data anywhere.

There is no outlier or deviation rejection anywhere in the agent. Robustness comes entirely from the choice of median over mean.

04 — Quantization: nine significant figures in six bytes

The float becomes an 18-decimal integer, then is re-encoded as a 30-bit mantissa and a 5-bit exponent. Everything past the ninth digit was never real.

Two lossy steps, in order:

  1. Scale-up. The price is a float64 the whole way through the agent, so multiplying by 10^18 in a big.Float pinned at a 53-bit mantissa re-rounds rather than shifting exactly. This is why BTC arrives on-chain looking like 63420.750000000000327680 — that tail is float64 residue, not market data.
  2. Wire encoding. Pick the smallest exponent whose mantissa still fits 30 bits, rounding half up.

One symbol's wire slot is exactly 48 bits and exactly full:

FieldBitsRangeExample — BTC at 107,374.21
mantissa300 … 2^30 − 1107374210
exp50 … 3115, so the value is 107374210 × 10^15
sourceAgeMs130 … 8191120

05 — Transport: the packed blob

The contract calls getPricesSlots(string[],uint8) and gets back a single bytes value of exactly 6 × nActive bytes — no ABI element padding at all. The symbol at active position p lives at bytes [6p, 6p+6), big-endian. Slots straddle 32-byte word boundaries by design.

  • 30 active symbols → blob length must equal 180 bytes, exactly.
  • A whole-zero slot means "this validator did not price this symbol".
  • A nonzero slot with a zero mantissa is malformed and is dropped the same way.
  • Any other length drops the validator entirely — a stale cell, never a wrong price.

06 — Consensus: three validators, then a second median

Nobody trusts one agent. A subcommittee of three runs the same code in three sandboxes against three separate caches, and the contract medians them again.

Each elected subcommittee member detects the request, runs the agent in its own Docker sandbox, and submits its own blob on-chain. Because the agent answers from a continuously-updated cache, the three answers are legitimately different — which is why the platform finalises on Threshold consensus (2-of-3), not Majority, which would require identical result hashes.

Live parameterValueConsequence
subcommitteeSize3three independent agent runs per tick
threshold2two contributors is the ordinary steady state; one is reachable
consensusType1 (Threshold)non-identical results are expected, not a fault
timeout15 smust exceed 10 s — the runner cancels at deadline − 10s
decimals18the scale the agent is asked to pre-apply

Four gates on the response, before a single cell moves.

RejectionReason codeWhy
not in the inflight ringnot-inflightring lookup and consume, so a duplicate callback is idempotent
status ≠ SuccessAgentRequestFailedno cells written
block.timestamp − kickedAt > 3 sstale-responsethe platform's timeout upkeep can deliver a quorum-met partial as Success hours late; ingesting it would sawtooth the feed and poison the EMA
symbolsVersion movedsymbols-version-mismatchresponses carry no symbol identity, only array position — if the symbol list changed since the kick, position i may be a different asset

Any symbol-list mutation invalidates every inflight request; the next tick refills everything within one interval.

The second median — per validator, per symbol.

For each symbol the fill loop does one calldataload per validator, gates it, and medians the survivors. The gate is per-validator, which is the whole point: one garbage validator drops out instead of shifting the median.

SymbolValidator AValidator BValidator CCell written
BTC/USDC107,374.18107,374.21107,374.30107,374.21 · vc 3
ETH/USDC3,142.07slot = 03,142.113,142.11 · vc 2
SOL/USDCslot = 0mantissa = 0184.62184.62 · vc 1
FARTCOIN/USDCslot = 0slot = 0slot = 0untouched — omitted from the batch

Insertion sort, then values[n / 2] — the upper middle on even counts, which at the ordinary 2-of-3 threshold means the higher of the two prices. On a 2-validator tick the median is not a blend; it is a pick. vc is validatorCount, the surviving contributor count, saturating at 7, with 0 meaning unknown — never "none".

07 — Mark price: the same number, smoothed

Mark is not a different data source. It is an EMA over the spot series, computed from the previous cell, and it costs no extra storage.

Each tick the mark moves emaAlpha = 0.125e18 — one eighth — of the way from the previous mark toward the new spot. At the 1 s cadence that is a smoothing horizon of τ ≈ 8 s.

Response to a genuine move.

After n ticks the fraction of a step captured is 1 − 0.875^n. The conventional time constant τ is where that crosses 63%, and it lands exactly where interval / alpha predicts — 8 ticks:

Ticks12468 (= τ)1624
Captured12.5%23.4%41.4%55.1%65.6%88.2%95.9%

Alpha is per tick, not per second. τ ≈ 8 s here, but τ ≈ 80 s in the DEX's oracles — same emaAlpha = 0.125e18, copied from their production values, but their cadence is 10 s. Rescale alpha if the cadence changes and the smoothing horizon should stay put. emaAlpha = 1e18 disables smoothing entirely — mark becomes spot.

08 — Storage: everything in one slot

Spot, mark, timestamp, source age and provenance share a single 256-bit word. One cold SSTORE per symbol per tick, one SLOAD to read all of it.

FieldBitsEncoding, and what 0 means
price86spot median at 18 dp. Ceiling ≈ 77 M per unit.
quality14packed sub-fields, below
mark86EMA mark; always between the previous mark and this spot
reserved14
updatedAtMs42block.timestamp × 1000 — second-granular, shared by every symbol written in the same tick. A maxAgeMs bound below the tick cadence is not meaningful. Good to year 2109.
sourceAgeMs14a delta below updatedAtMs, not an absolute stamp — capped at 16.383 s. The top value is the sentinel and reads back as 0 = unknown, never "current", so a real age clamps one below it.

The 14-bit quality field expands to:

Sub-fieldBitsMeaning
validatorCount3contributors to the median, saturating at 7. 0 = unknown (a cell written before the field shipped), never "none" — a written cell always had ≥ 1.
medianControlCount3reserved. Always reads 0. No room on the wire. Do not branch on it.
flags2bit 0 FLAG_MARK_RESYNCED; bit 1 FLAG_DEVIATION reserved and never set.
reserved6

Removing a symbol deletes its cell. Indices are append-only and never reused, so an on-chain consumer can resolve index → symbol once off SymbolAdded and read by index forever — one SLOAD cheaper than the string-keyed path, with no risk that a removal elsewhere silently shifts the mapping.

contracts/feed/lib/PriceCell.sol · lib/FeedHealth.sol · lib/ActiveIndexSet.sol

09 — Consumption: pull, and decide staleness yourself

Reads never revert. Staleness is the caller's decision, always — the feed will not decide on your behalf what is too old.

InterfaceKeyed byGives you
IPriceFeedsymbol stringspot, cell age, source age, tracked-symbol registry
IMarkPriceFeedsymbol stringEMA mark (separate so IPriceFeed's id never moved)
IIndexedPriceFeedstable uint256the same data, one SLOAD cheaper
IQualifiedPriceFeedeitherquorum, flags, feed liveness, and the safe reads

Off-chain: one batched event, and history.

Each tick emits a single PricesUpdated(requestId, updatedAtMs, indices[], prices[], marks[], sourceUpdatedAtsMs[], resyncedBits) — one event for the whole batch, carrying only stable indices. An Envio + Hasura stack indexes it into Feed (latest per symbol, a Chainlink-ish latestRoundData analogue), PricePoint (the per-tick firehose), Candle (OHLC at M1 / H1 / D1) and Symbol (the index → pair registry). That GraphQL surface is what the price methods above read.

GraphQL is for humans, not for settlement. Testnet only, and observability only. It lags the chain, can be resynced, and is not part of the support contract. Never settle, liquidate, or price anything off it.

End-to-end latency.

LegTime
1 — exchange → agent~0.1 s (ws)
2 + 4 + 5 — cache dwell + kick→response + cell write~0.8 s
6 — wait for your next read0–~1 s

Measured on testnet over 150 consecutive BTC/USDC ticks: cell cadence median 1 s (max 2 s), kick→cell median ~0.8 s ≈ 7 blocks at ~116 ms. A consumer acts on a price 1–2 s old; at the instant of the cell write it is ~0.9 s. The dominant and most actionable leg is kick→cell. REST-only venues are the other lever — they inflate leg 1 and can leave source age unmeasurable entirely.


Sources

Contracts and scheduler:

  • smart-contracts/contracts/feed/PriceFeedScheduler.sol
  • smart-contracts/contracts/feed/lib/PriceCell.sol
  • smart-contracts/contracts/feed/lib/SlotResponse.sol
  • smart-contracts/contracts/feed/lib/FeedHealth.sol
  • smart-contracts/docs/price-feed-scheduler.md, price-feed-latency.md, price-feed-design-decisions.md
  • smart-contracts/script/config/feed-testnet-development.json
  • docs/price-feed-integration.md
  • price-feed/indexer/schema.graphql

Agent:

  • somnia-agents/agents/price-oracle/internal/store/store.go
  • somnia-agents/agents/price-oracle/internal/server/handlers.go
  • somnia-agents/agents/price-oracle/internal/exchange/ccxt.go
  • somnia-agents/agents/price-oracle/oracle.json, agent.json