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 ownsubscribePricessignal, 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 withlistPriceFeeds(). With nopriceFeedconfigured, 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 inraw— never round-trip money through thenumber. Timestamps are unix seconds (blockTimestampis chain time, monotonic — use it as a series x-axis;observedAtis the oracle's own source time and can drift).
Configuration
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
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:
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:
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:
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):
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.
| Cadence | 1 s |
| Scale | 18 decimals |
| Sources | 7 centralised exchanges, top-of-book mid |
| On-chain aggregation | 3-validator subcommittee, 2-of-3 threshold |
| Networks | Somnia 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.
| Venue | Weight | Transport | Why |
|---|---|---|---|
| binance | 3 | ws stream | |
| okx | 2 | ws stream | |
| bybit | 2 | rest poll | wss endpoint geoblocked from the runners' datacentre IPs |
| kucoin | 1 | ws stream | |
| kraken | 1 | ws stream | |
| gateio | 1 | rest poll | stream unimplemented in the fork |
| mexc | 1 | rest poll | stream 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_INTERVAL1 s ·PER_CALL_TIMEOUT8 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.
| Gate | When | Rule | Effect |
|---|---|---|---|
| Book sanity | ingest | both sides present, bid > 0, ask > 0, not crossed (ask ≥ bid) | quote discarded, never cached |
| Spread | ingest | (ask − bid) / mid ≤ 0.02 | quote discarded, never cached |
| Dwell | aggregation | now − UpdatedAt > STALE_AFTER (3 s) | exclude, excludedReason: "stale" |
| Never seen | aggregation | configured exchange has never returned this pair | exclude, 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:
| Venue | Price | Weight | Cumulative |
|---|---|---|---|
| kraken | 107,373.90 | 1 | 1 |
| mexc | 107,374.05 | 1 | 2 |
| okx | 107,374.18 | 2 | 4 |
| binance | 107,374.21 | 3 | 7 ← first to cross 5.5 |
| bybit | 107,374.44 | 2 | 9 |
| kucoin | 107,374.60 | 1 | 10 |
| gateio | 107,375.02 | 1 | 11 |
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:
- Scale-up. The price is a
float64the whole way through the agent, so multiplying by10^18in abig.Floatpinned at a 53-bit mantissa re-rounds rather than shifting exactly. This is why BTC arrives on-chain looking like63420.750000000000327680— that tail isfloat64residue, not market data. - 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:
| Field | Bits | Range | Example — BTC at 107,374.21 |
|---|---|---|---|
mantissa | 30 | 0 … 2^30 − 1 | 107374210 |
exp | 5 | 0 … 31 | 15, so the value is 107374210 × 10^15 |
sourceAgeMs | 13 | 0 … 8191 | 120 |
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 parameter | Value | Consequence |
|---|---|---|
subcommitteeSize | 3 | three independent agent runs per tick |
threshold | 2 | two contributors is the ordinary steady state; one is reachable |
consensusType | 1 (Threshold) | non-identical results are expected, not a fault |
timeout | 15 s | must exceed 10 s — the runner cancels at deadline − 10s |
decimals | 18 | the scale the agent is asked to pre-apply |
Four gates on the response, before a single cell moves.
| Rejection | Reason code | Why |
|---|---|---|
| not in the inflight ring | not-inflight | ring lookup and consume, so a duplicate callback is idempotent |
status ≠ Success | AgentRequestFailed | no cells written |
block.timestamp − kickedAt > 3 s | stale-response | the 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 moved | symbols-version-mismatch | responses 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.
| Symbol | Validator A | Validator B | Validator C | Cell written |
|---|---|---|---|---|
| BTC/USDC | 107,374.18 | 107,374.21 | 107,374.30 | 107,374.21 · vc 3 |
| ETH/USDC | 3,142.07 | slot = 0 | 3,142.11 | 3,142.11 · vc 2 |
| SOL/USDC | slot = 0 | mantissa = 0 | 184.62 | 184.62 · vc 1 |
| FARTCOIN/USDC | slot = 0 | slot = 0 | slot = 0 | untouched — 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:
| Ticks | 1 | 2 | 4 | 6 | 8 (= τ) | 16 | 24 |
|---|---|---|---|---|---|---|---|
| Captured | 12.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.
| Field | Bits | Encoding, and what 0 means |
|---|---|---|
price | 86 | spot median at 18 dp. Ceiling ≈ 77 M per unit. |
quality | 14 | packed sub-fields, below |
mark | 86 | EMA mark; always between the previous mark and this spot |
| reserved | 14 | |
updatedAtMs | 42 | block.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. |
sourceAgeMs | 14 | a 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-field | Bits | Meaning |
|---|---|---|
validatorCount | 3 | contributors to the median, saturating at 7. 0 = unknown (a cell written before the field shipped), never "none" — a written cell always had ≥ 1. |
medianControlCount | 3 | reserved. Always reads 0. No room on the wire. Do not branch on it. |
flags | 2 | bit 0 FLAG_MARK_RESYNCED; bit 1 FLAG_DEVIATION reserved and never set. |
| reserved | 6 |
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.
| Interface | Keyed by | Gives you |
|---|---|---|
IPriceFeed | symbol string | spot, cell age, source age, tracked-symbol registry |
IMarkPriceFeed | symbol string | EMA mark (separate so IPriceFeed's id never moved) |
IIndexedPriceFeed | stable uint256 | the same data, one SLOAD cheaper |
IQualifiedPriceFeed | either | quorum, 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.
| Leg | Time |
|---|---|
| 1 — exchange → agent | ~0.1 s (ws) |
| 2 + 4 + 5 — cache dwell + kick→response + cell write | ~0.8 s |
| 6 — wait for your next read | 0–~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.solsmart-contracts/contracts/feed/lib/PriceCell.solsmart-contracts/contracts/feed/lib/SlotResponse.solsmart-contracts/contracts/feed/lib/FeedHealth.solsmart-contracts/docs/price-feed-scheduler.md,price-feed-latency.md,price-feed-design-decisions.mdsmart-contracts/script/config/feed-testnet-development.jsondocs/price-feed-integration.mdprice-feed/indexer/schema.graphql
Agent:
somnia-agents/agents/price-oracle/internal/store/store.gosomnia-agents/agents/price-oracle/internal/server/handlers.gosomnia-agents/agents/price-oracle/internal/exchange/ccxt.gosomnia-agents/agents/price-oracle/oracle.json,agent.json