The cheapest signed oracle on the EVM

Every BTR pool prices against a pushed mark, so the cost of pushing marks is a line item on every quote we serve. This is what that line item cost across four generations of the contract, what each generation bought, and where the mainstream designs sit.

The short version: the whole field stores one price per storage slot. We store eight to ten, verify the quorum once an hour instead of once a push, and fire a push when the mark crosses a share of the edge the pool is currently quoting rather than a hand-set threshold.

generationstorageverificationgas per feed
V11 slot per feedk-of-n per push7,422
V28 feeds per slotk-of-n per push11,208
V310 feeds per slotk-of-n per session5,158
V48 feeds per slot, 128x finerk-of-n per session5,828

V3 is cheapest measured; Arc forum table’s “V4 5,158” is this V3 measurement (10 feeds, 2-of-3). V4 trades density for 128x finer lanes and a clock with no end date: 5,828 at 10 feeds, 3,006 at 66.

Full-transaction gas at 10 feeds, from test/unit/OracleGasBench{,V3,V4}.t.sol. Two rows go the wrong way and both are the same effect: packing pays only once slots fill, so V2 loses to V1 at ten feeds and V4 loses to V3 wherever eight lanes cross a slot boundary that ten did not. V4 buys 128x the price resolution and a clock with no end date for that. On the 26-feed production manifest the gap is +9.9%, 3,441 against 3,783.

1. Where the field puts a price

Four designs carry most of the EVM’s oracle traffic. They differ in who submits, what gets verified, and what a price costs to store.

Chainlink OCR2PythRedStoneChronicle Scribe
Modelpush, permissioned transmitterpull, anyonecalldata injection + push relayerpush, validator
Triggerdeviation + heartbeaton demanddeviation + heartbeatdeviation + heartbeat
Signatures verifiedf+1 ECDSAECDSA on a Merkle root3-of-5 ECDSA per package1 Schnorr over k point-adds
Slots per feed1 (+ config, + payment)211
Price encodingfixed decimalsmantissa + exponentfixed 8 dpfixed 18 dp
Feeds sharing a slotnononono
One signature covers N feedsnoyescapable, unusedno

Two things in that table set up everything after it.

Nobody packs across feeds. All four pack within a feed, value beside timestamps, and stop there:

  • Chainlink Transmission: int192 answer | uint32 observationsTimestamp | uint32 transmissionTimestamp, exactly 256 bits, exactly one price.
  • Chronicle PokeData: uint128 val | uint32 age, 160 bits, one price, with bar declared last specifically so a downstream contract can pack the remainder.
  • RedStone DataFeedDetails: carries a comment reading “this struct uses exactly one storage slot”, again for one feed.
  • Pyth: two slots, 32 bits of the first left unused rather than lent to a neighbour.

Only Pyth uses a floating point. int64 price with an int32 expo, documented as x·10expo. The other three fix decimals at deploy (8, 8 and 18), exponent either immutable per contract or carried out of band in config.

A price does not need 256 bits. How few it needs is a real question with a defensible range: the packed venues in §3 chose 22 and 27 bits of mantissa, we shipped 18 in §5 and 25 in §7. Nobody who has looked at it seriously lands anywhere near 256. The four vendors above are unanimous about spending them anyway.

What the field costs

Published gas figures in this category are mostly stale, vendor-supplied, or absent, so we measured them ourselves. Everything below is full-transaction gasUsed from a mainnet receipt, pulled over HyperSync on 2026-09-01, with the feed count per transaction read from the contract’s own events. One row per provider; the transaction hashes and block ranges are in the working notes.

providermodelmedian gas/txfeeds/txgas per feedbasis
BTR V3push, k-of-n per session51,588 / 178,57910 / 665,158 / 2,705OracleGasBenchV3.t.sol, 2-of-3
Chainlink OCR2push, permissioned182,1211182,121ETH/USD mainnet, n=309
API3push, 7 signed beacons175,8531175,853Base Api3ServerV1, n=206
Chronicle Scribepush, Schnorr, bar 13126,8591126,859ETH/USD mainnet, n=366
Pythpull, batched115,535 → 301,2511 → 8115,535 → 37,656Base, n=639
RedStonecalldata injection + relayer65,558 → 4,394,1691 → 8865,558 → 38,504Ethereum multi-feed, n=28,563
ElfomoFitrusted-writer blind store28,74964,792Base price store, n=5,995
Metricnamespaced blind store29,767 / 35,4234 / 87,442 / 4,428Base CompressedOracleV1, n=2,231
HumidiFi / Obric / SolFi / TesseraSolananot comparablecompute units do not convert to gas

Five things about that table, because these numbers are easy to abuse.

  • Per-transaction is not per-feed. Chainlink, Chronicle and API3 run one contract per feed, so an update is a feed and the two columns collapse. Pyth and RedStone batch, and a batched number is only meaningful next to its width. The 21,000 intrinsic alone is 21,000 per feed at N=1 and 318 at N=66.
  • Batch width is not a free parameter. RedStone’s 88-feed transaction is real (0x60f5afbf34d3f0fd9889733fc29c52b4cce142e2559bca7a1f14734664c689ca, 4,394,169 gas), but it is 4 transactions out of 28,563. The modal batch is one feed, 66% of the time, because only feeds whose deviation or heartbeat fired go in. Weighted across the whole 150,000-block window, RedStone’s Ethereum adapter spent 2,433,854,363 gas on 44,432 feed updates: 54,777 gas per feed is the honest fleet number. Its per-feed cost also bottoms out at ~38,500 around N=9 and climbs back to ~49,900 at N=88, because the parsing loop is O(points×feeds).
  • Chronicle’s headline numbers describe a path mainnet is not using, and the instance usually cited is dead. 0x64DE91F5…C7C6cE410C85 (wat = ETHUSD, bar = 13) has zero transactions and zero logs across 900,000 Ethereum blocks. The live feed is 0x46ef0071…3Ae5917f4E (wat = ETH/USD, bar = 13), and across 500,000 blocks all 617 of its events are Poked and none are OpPoked: the widely cited 51k-65k figures are for an optimistic path that has a 600-second challenge period configured and no traffic. The vendor’s own published curve for the non-optimistic path (81,025 / 134,342 / 585,993 gas at bar 5 / 15 / 100) puts the measured 126,859 at bar 13 exactly where it should be. 338 of its 366 successful pokes use a mined vanity selector, 0x00000082, three zero bytes bought to shave calldata cost.
  • The bottom two rows are not oracles and do not push on deviation. ElfomoFi and Metric are proprietary market makers writing their own book, and both rewrite the whole word every block whether or not a price moved: 5,995 pushes in 6,000 Base blocks for one, 2,231 pushes at a 2-second median for the other. Their gas per feed is therefore a capacity number, not a cost-per-informative-update. An oracle that pushes only when a mark crosses a threshold spends more per push and far fewer pushes. Both are in the table because they are the live floor of the category, and §3 is about what they do with the word.
  • Pyth is measurable. The contracts are public and so is the traffic. On Base its cost is well fit by a fixed ~89,000 plus ~26,530 per feed, i.e. one router signature amortised over the batch, which is the same structural argument we make for our own blob. Its economics did change on 2026-08-26, when the EVM path moved from Wormhole guardians to a 5-router 3-of-5 quorum; everything above is post-change.

2. V1: a signed batch, one slot per feed

Our first oracle took the field’s shape and fixed the two things that bothered us about it.

Price authority is a signature, not a sender. batchPushSigned takes a blob and k concatenated ECDSA signatures over the EIP-712 digest of keccak256(blob), recovered in strictly ascending address order so the count is a genuine k-of-n proof rather than one signer repeated. The relayer is unpermissioned: anyone can land a blob, and landing it grants no authority over its contents. Chainlink’s transmitter check and RedStone’s whitelist both bind authority to the sender; this does not.

One transaction carries many feeds. All k signatures cover the same digest, so a batch of N feeds pays the quorum once rather than N times. That single decision is why our per-feed numbers are in the thousands while a per-contract design’s are in the hundreds of thousands.

The feed itself is one 256-bit word:

lastPriceB6464bsigmaPbps32bupdatedAtSecs32bttlSecs16bconfidenceBps16bflags16bmaxDeviationBps16bsourceTsMs48b16b
BitsFieldNotes
0..63lastPriceB64B64 float, 52/5/7
64..95sigmaPbpsu32
96..127updatedAtSecsu32
128..143ttlSecsu16
144..159confidenceBpsu16
160..175flagsu16, bit0 = paused
176..191maxDeviationBpsu16
192..239sourceTsMsu48, monotonic replay nonce
240..255free16 bits unused

The mark is a B64 float: 52-bit mantissa, 5-bit decimals, 7-bit exponent, biased by 64. Worst-case relative step is about 2.2×10-15, exact for all practical purposes. That buys a whole feed in one SSTORE, where the field spends a slot on the price alone and puts σ, confidence and TTL somewhere else or nowhere at all.

Note the 16 free bits at the end. V1 packs a whole feed into one word and still does not fill it, which is the observation the next generation is built on.

Measured, full transaction:

shapegas per feed
66 feeds, cold7,998
10 feeds, warm7,422
10 feeds, cold11,421

The field’s cheapest measured one-feed path is ~65.6k and its median vendor sits near 127k. 7.4k was not the frontier either, so we went and measured the frontier.

3. What the prop AMMs were doing, and what we took from them

The proprietary AMMs taking share through 2026 live or die on mark freshness, so their oracle plumbing is where their engineering went. They got to packed multi-price storage before we did, and V2 and V3 are built on their idea: several prices and one shared timestamp in a single 32-byte word, written blind. Read off the chain, they kept more precision than we did, not less.

venuechainmechanismprice encodingauth on the hot pathcadencegas per push
MetricBase (also deployed ETH, Arbitrum, HyperEVM)selectorless fallback; word = uint8 slot index + uint56 ms timestamp + 4 × 48-bit lanes27-bit mantissa + 5-bit base-2 exponent, uint64 at 8 decimals, plus two 8-bit bid/ask codes indexing a 256-entry bps tablenone, namespaced blind write; ecrecover exists only on the one-off delegation registration2 s median, every Base block, via 3 round-robined senders29,767 (4 feeds) / 35,423 (8 feeds)
ElfomoFiBase, BSCselectorless fallback on Base, updatePrices(uint256) on BSC; one word = uint32 timestamp + N × 28-bit lanes22-bit mantissa, fixed decimals per pair, plus a 6-bit signed micro-skew worth ±3.2 bp on the midnone, hardcoded PUSH20 sender allowlist, revert string "pub"one push per Base block, one per 3 BSC blocks28,749 (6 feeds) / 28,407 (8 feeds, BSC)
HumidiFi, SolFi, Obric, Tessera VSolanatrusted-signer instruction, either to the AMM program itself or to a satellite oracle programnot publicly documented for any of them; SolFi’s 168-byte oracle account is actively padded with constant fillernone, the only signature is the Ed25519 check Solana runs on every transaction, which authenticates the writer, not the price3-16 pushes/s per feed measured, higher in bursts197-740 compute units; not convertible to EVM gas

Five things fall out of that table.

The word layout converged independently. ElfomoFi’s BSC feed is eight 28-bit lanes plus a 32-bit timestamp, 8×28+32=256, one full word. That is, bit for bit, the shape of our V2 slot in §4, arrived at by a desk that has never seen our contract. It is the strongest evidence we have that the layout is the right answer and not a local optimum.

They kept more mantissa than we did, not less.

designmantissarelative stepin bps
Metric27 bits + 5-bit binary exponent1.5×10-8~0.00015
ElfomoFi22 bits fixed-point~0.04 (top of a decade)
BTR V223 bits2-220.0024
BTR V318 bits2-170.076
BTR V425 bits2-240.000596

The precision cut was ours, and §5 defends it on its own terms: 18 bits is lossless against the grid our producer actually quantises to, verified by sweeping 28 live marks with a worst round-trip error of 0.049 bps. It stopped being the right cut when the target spread narrowed, and §7 is what replaced it: 25 bits, finer than everything in this table but Metric.

What they spend the spare bits on is instructive. Both EVM venues put quoting information in the word next to the price. Metric carries two 8-bit codes per lane that index a 256-entry lookup table of bps spreads, with 0xff as a no-quote sentinel; ElfomoFi carries a 6-bit signed skew that shifts the mid by up to 3.2 bp. Both are a desk pushing its own quote. Our lanes carry a mark and nothing else, because the quote is computed on chain from it, which is the whole architectural difference and the reason our σ and confidence live in separate words that are only written when they move.

Nothing in the category verifies anything on the hot path. This is the axis we kept and every one of them gave up, and it is now measured rather than asserted.

  • Metric’s push is a bare fallback with no require. Authority is namespacing: you may write only slots keyed by your own address, or by one that delegated to you, and replay protection is a monotonic millisecond timestamp.
  • ElfomoFi’s push clears a hardcoded sender allowlist and nothing else. Every CALL in its store, pricing implementation and router was enumerated and none of them is precompile 0x01.
  • The Solana cohort is the same position with a different runtime, including Obric, which reads what look like external feed accounts but which are owned by bespoke trusted-writer programs and not by Pyth, so it inherits no attestation from the resemblance.
  • The gas arithmetic corroborates it: ElfomoFi’s 28,749 is 21,000 intrinsic plus 25 bytes of calldata plus one SSTORE and one LOG1, with no room left for an ecrecover.

The floor is a ~29,000-gas transaction, and what it costs per feed is entirely a question of how many lanes you fit in the word. The ~2,800 gas per feed we quote for an unsigned, unguarded push at ten lanes is 21,000 intrinsic plus one unconditional store, and it now has a live comparable:

constructionlanes/wordgas/txgas per feed
ElfomoFi, live blind push628,7494,792
Metric, live blind push429,767 (1 slot), 35,423 (2 slots)7,442
the same blind write at V3’s width10~2,900
V3, signed1051,5885,158

Metric’s marginal cost per additional slot is 5,656. The ~2,300 gas per feed between the blind floor at ten lanes and V3 is the entire product: the monotonic replay read, the session authorisation, the per-lane fail-soft, the deviation bands, and a quorum signature that anybody can check without asking us or the chain. Whether that is worth roughly doubling the floor is the only question this article is really about, and §9 is our answer.

And the Solana speed does not port. HumidiFi and Tessera push at rates no EVM chain will absorb, for three Solana-specific reasons at once:

  1. programs written in Pinocchio or hand-rolled sBPF cost 143-740 compute units;
  2. the base fee is charged per signature rather than per byte of state written;
  3. Jito’s auction ranks by tip per compute unit, so a tiny transaction buys priority for a trivial tip.

None of the three has an EVM analogue. What is structural, and does port, is the oracle-LVR advantage that motivates the cadence in the first place, which is why the correct EVM adaptation is a θ-gated push sized to the edge the pool is quoting, as in §8, rather than an imitation of 1 Hz.

The venue-design side of this, what these desks put on chain, what they keep off it, and what that costs a taker, is the subject of its own piece. This one is about the word.

4. V2: eight feeds in one slot

If the cost is SSTORE count, and a mark quantised to a few hundredths of a basis point is indistinguishable from an exact one at the sizes anyone trades, a 256-bit word holds eight prices and a timestamp.

lane028blane128blane228blane328blane428blane528blane628blane728btsDs32b
BitsFieldNotes
0..27lane028b
28..55lane128b
56..83lane228b
84..111lane328b
112..139lane428b
140..167lane528b
168..195lane628b
196..223lane728b
224..255tsDsu32, slot timestamp

8×28+32=256, exactly. Each lane is a small binary float:

mantissa23bexp5b
BitsFieldNotes
0..22mantissau23, MSB always set for a live price
23..27expu5, 32 octaves

Decode is mark=mantissa(exp+expBias), with expBias a per-feed int8 in the cold registry positioning the octave window over the asset’s range. A 23-bit mantissa normalized to [222,223) steps by 2-22, about 0.0024 bps.

Three consequences, each a design property rather than a saving:

  • Feed identity becomes positional. globalIndex = slotId * 8 + laneIdx. V1 spent 8 bytes per feed naming what a lane position now says for free.
  • The all-zero lane is a free stale sentinel. The mantissa MSB is always set for a live price, so an unset feed decodes to nothing and the consumer gate fails closed. No flag bit spent.
  • Slots must be class-pure. Our first lane map assigned feeds sequentially, then a weekend arrived: the producer emits a slot record only when every feed in it is live, so an equity sharing a word with a stable froze the whole word. One class per slot, and a closed market darkens only its own.

σ and confidence live in their own words, written only when the packed word changes. On a typical push, zero extra SSTOREs.

And V2 is slower than V1 at ten feeds: 11,208 against 7,422. Ten feeds spread across two slots pay two full slot records, and V2’s wire is a fixed 100 bytes per slot touched, carrying price, σ and confidence whether or not the last two moved. The packing only pays once the slots fill: at 16 feeds V2 falls to 8,899. That is what V3 was built to fix.

5. V3: verify once per session, send only what changed

The quorum was not the problem. Running the same signed push twice, once with the quorum check and once with it stubbed, puts the two ecrecovers at 13,377 gas of execution (37,269 against 23,892). Three other things were:

  1. a per-lane config SLOAD on the hot path;
  2. a fixed 100-byte record per slot;
  3. σ/confidence words rewritten whether or not they had moved.

Consumer

Per push

Authorisation, once per session

Off chain

producer quantises to the mark grid

encode lanes

diff: only lanes that moved

k-of-n signs SessionGrant
relay, expiry <= 1h, maxSeq, nonce

contract verifies the quorum once

relay submits blob
header + one entry per moved feed

auth: msg.sender == relay

per-slot timestamp: skip if not newer

per-lane validation: fail-soft

one config word per slot

sigma-root-tau staleness premium

Session grants

The quorum signs a SessionGrant{relay, expiresAt, maxSeq, nonce}; the contract verifies those signatures once, then gates pushes on msg.sender == relay for the life of the session. The bounds are what keep that from being a downgrade:

boundvaluewhat it stops
expiresAt 1 hourstanding authority cannot accumulate; a grant self-terminates
maxSeqceiling on the blob sequencecaps a hijacked relay’s blast radius
noncemust be exactly stored + 1grants cannot be replayed or reordered
revocationany granted signer, guardian or admin, single txno quorum needed to shut a session down

Every push still emits SlotsPushed(seq, tsDs, acceptedMask, keccak256(blob)), so anyone can check the batch off chain from the event, and pushSignedV3 keeps the permissionless per-blob quorum path: a dead session degrades to the V2 model, not to an outage.

Ten lanes and one config word

lane022blane122blane222blane322blane422blane522blane622blane722blane822blane922btsDs32b4b
BitsFieldNotes
0..21lane022b
22..43lane122b
44..65lane222b
66..87lane322b
88..109lane422b
110..131lane522b
132..153lane622b
154..175lane722b
176..197lane822b
198..219lane922b
220..251tsDsu32, slot timestamp
252..255free4 bits unused
mantissa18bexp4b
BitsFieldNotes
0..17mantissau18, MSB set = live, all-zero = STALE
18..21expu4, 16 octaves

An 18-bit mantissa steps by 2-170.076 bps, below the producer’s own quantisation grid of roughly 0.15 to 0.3 bps: lossless at the grid it is actually fed, verified by sweeping 28 live marks, worst round-trip error 0.049 bps.

Per-slot config collapses into one word of ten 25-bit lanes, which is what removes V2’s per-lane SLOAD:

maxDevBps16bexpBias8bpaused1b
BitsFieldNotes
0..15maxDevBpsu16
16..23expBiasu8
24..24paused1b

The diff wire

version8bseq32bsourceTsDs32bnP8bnS8bnC8b
BitsFieldNotes
0..7versionu8, = 4
8..39sequ32
40..71sourceTsDsu32
72..79nPu8, price entries
80..87nSu8, sigma entries
88..95nCu8, conf entries
sectionbytes per entrysent
price4 (gi:u8 | lane:u24)when the lane moves
σ5only when it changes
confidence3only when it changes

With no σ entries in the blob the σ word is never even SLOADed.

blobbytes=12+4nP+5nS+3nC

Against V2’s 9+100nslots, a ten-feed push carrying only prices is 52 bytes instead of 209.

6. Measured

test/unit/OracleGasBenchV3.t.sol, EIP-7623-aware, full-transaction gas per feed, 2-of-3 quorum. Reproduce with forge test --mp test/unit/OracleGasBenchV3.t.sol -vv.

shapeV1V2V3 sessionV3 signed fallback
66 feeds3,9992,705
16 feeds8,8994,202
10 feeds7,42211,2085,1586,499
8 feeds, 1 slot11,5196,038
3 feeds, narrow22,31313,321
10 feeds, no lane changed3,966

Read across a row, not down a column. The 66-feed row is the one shape where V1 looks competitive, and the reason is amortisation: the fixed 21,000 intrinsic is 318 gas per feed at 66 against 2,100 at ten. At equal width V3 is 32% cheaper than V1 and 62% cheaper than V2. There is no V2 arm at 66: the bench’s lane helper builds two slots and V2 needs nine at that width.

5,158 gas per feed at ten feeds, against 11,208 for V2 and 7,422 for V1; 2,705 against V1’s 3,999 at 66; against a field whose cheapest single-feed update we could measure is RedStone’s 65,558, with Chronicle at 126,859 and Chainlink at 182,121.

7. V4: a finer lane and a clock with no end date

V3 changed when the quorum is verified. V4 changes two fields and nothing else, the price word and the timestamp, and both changes were forced by measurement rather than review. Sessions, the diff wire, the σ/confidence words, fail-soft, the deviation bands and the quorum model are V3’s, carried over untouched.

The lane is too coarse for the spread we now intend to quote

V3’s 0.076 bps step was sized against ~5 bp quotes. Against the 0.2 bp one-way stable spread we are targeting it is ±19% of the half-spread, no longer noise. V4 spends the width:

mantissa25bexp4b
BitsFieldNotes
0..24mantissau25, MSB set = live, all-zero = STALE
25..28expu4, 16 octaves

A 25-bit mantissa normalized to [224,225) steps by 2-24=5.96×10-8, 0.000596 bps: 128× finer than V3. Eight of those lanes leave 24 bits of the word for the clock.

lanes232bts20bdayMod2bunused2b
BitsFieldNotes
0..231lanes8 x 29b
232..251tsu20, deciseconds since midnight UTC
252..253dayModu2, source day mod 4
254..255unused2b

On the wire this is version 5:

version8bseq32btsDs24bnP8bnS8bnC8b
BitsFieldNotes
0..7versionu8, = 5
8..39sequ32
40..63tsDsu24, u20 value zero-padded
64..71nPu8, price entries
72..79nSu8, sigma entries
80..87nCu8, conf entries

An 11-byte header and a 5-byte price entry, gi:u8 | lane:u32 with the top three bits zero, against V3’s 12 and 4. σ and confidence entries are unchanged at 5 B and 3 B, and still travel only when they move.

exp:u4 is kept deliberately. Sixteen binary steps around a per-feed bias is ~65,000× of dynamic range for one asset, which covers JPYC through WBTC with room to spare; a fifth exponent bit would cost a mantissa bit and buy range no feed uses.

Where this lands against the packed venues of §3: Metric holds 27 mantissa bits at 4 lanes per slot, ElfomoFi 22 at 6–8. V4 is within ~4× of Metric’s precision at twice its density, and unlike either it verifies a k-of-n quorum on chain.

The exponent window, and where it was sitting

The encoder rescans the exponent on every push, renormalising the mantissa into its window with the MSB always set, so precision is always maximal for the current magnitude. That is unchanged since V2. expBias does not touch precision; it only positions the 16-step window over an asset’s range, so an asset travelling from 0.001 to 1,000,000 stays encodable without a config change.

The bias was meant to track the asset and keep the window centred. Two things were true of V3 on the live fleet:

One bias per encode class, not per feed. Four constants (stable 34, FX 30, volatile 47, equity 43) covered 26 feeds, so each feed sat wherever its own magnitude happened to land inside its class window:

feedexponentheadroom
EURCe=13 of 15, two steps from the ceiling4× up
AUDF, QCAD, WBTCe=128× up
KRW1e=24× from the floor

The bias moved by hand. setFeedExpBias is a guardian call, so re-centring a feed took an operator noticing that it needed re-centring.

Neither cost a basis point of precision on any ordinary day. Both put a config change on the critical path of an event that does not wait for one: a redenomination, a de-peg, an equity split.

V4 derives the bias per feed from the asset’s live magnitude,

expBias=bit_length(mark1e18)-(mant_bits+7)=bit_length(mark1e18)-32

which pins e=7 for every feed: eight exponent steps of upward headroom (256×) and seven down (128×) before a rebias is needed at all.

That is the production encoding, not a projection. Piping a live signed quote from the signer through the SDK’s own decodeBlobV5 reports every price entry in the blob at exponent 7, and the script exits non-zero if they are not:

$ curl -s '.../v1/quote/signed?version=5' | bun scripts/decode-live-v5.ts version 5 seq 1788269141 prices 22 sigmas 0 confs 0 gi 0,1,2,3,4,8,9,10,11,12,16,17,18,19,20,21,24,26,27,32,33,36 exponents 7 <- all centred at 7

The entry count is not the claim and moves between runs: a feed whose mark is unavailable is excluded from the blob, an earlier sample minutes before carried 24. The exponent is the invariant. sdk/scripts/decode-live-v5.ts is committed so the check is re-derivable rather than quoted.

The contract exposes the same property directly, as a read of the deployed instance:

expHeadroom(EURC) -> (8, 7) // V4, read 2026-09-01 (2, 13) // the same feed under V3's class bias

The re-centring keeper is built, and it is quorum-signed rather than roled:

BiasUpdate(bytes32 feedId, int8 newBias, uint48 expiresAt, uint16 nonce) function setFeedExpBiasSigned(bytes32 feedId, int8 newBias, uint48 expiresAt, uint16 nonce, bytes calldata sigs)

It is now armed on the primary tier. The keeper watches per-feed headroom, requests a quorum-signed BiasUpdate when a feed drifts toward its window edge, and relays setFeedExpBiasSigned itself. The reference tier is deliberately still off: one tier moves at a time.

Arming it took one change on the producer side, and that change is the whole reason this did not ship with V4. A rebias zeroes the lane. The mantissa MSB is the live sentinel, so the moment a feed’s exponent window moves the old lane value stops decoding: the getter reads STALE and every read derived from it goes dark. A config-learned bias let the producer’s view and the chain’s diverge, and that divergence is not slow drift, it is an instantaneous blackout, in the one window where every source you might cross-check against is also unavailable.

The producer now reads FeedExpBiasUpdated logs from chain at 12 confirmations and takes the chain as the sole authority on the current bias. Chain state is the one view every replica and every cosigner can reach independently, so a replica that fell behind converges instead of diverging, and the lane_map_hash cosign commitment no longer carries a value the chain can change underneath it.

A role looked like the smaller grant and was not. Decode is mark=mant(exp+bias), so a bias write is a price write: a “minimal” steward role would have carried full price authority under a name implying routine maintenance, a larger grant than the guardian lever it was meant to avoid. Rebias therefore clears the push bar: the same k-of-n, feedId and newBias inside the signed struct so a signature for one feed cannot be redirected at another, expiry 1 h, nonce spent on use.

The clock has a hard end date, and the end date bricks

V3 stores ts:u32 as deciseconds since a fixed EPOCH immutable. It wraps in 2038, and that field is the per-slot monotonic replay guard, so the wrap does not degrade the oracle: it rejects every subsequent push.

V4 removes the epoch. ts is deciseconds since midnight UTC, range [0,864,000), which fits u20; absolute time is always derived from block.timestamp, so there is no end date to reach. Deciseconds rather than milliseconds because ms-since-midnight needs 27 bits and would drag the lane back to 28 (0.0024 bps) for resolution nothing needs: 100 ms already beats a sub-second block time and is what makes two pushes inside the same second orderable.

Reconstruction is a nearest-candidate-day pick, unambiguous for any true age under ±12 h:

Δds=storedDs-nowDs,Δds-=864,000ifΔds>432,000,Δds+=864,000ifΔds<-432,000

absSecs=block.timestamp+Δds/10

A cyclic field breaks a raw comparison, so the replay guard becomes reconstruct, bound, then compare:

no

yes

no

yes

no

yes

ts:u20 from the slot word

reconstruct absSecs
nearest candidate day

absSecs in
[now - MAX_RECON_AGE,
now + SOURCE_TS_FUTURE_SKEW]?

fail closed
StaleTimestamp / FutureTimestamp

dayMod(absSecs)
== stored dayMod:u2?

absSecs newer than
the slot's previous absSecs?

accept

Step C is the whole security argument. Without it, a stale timestamp that has wrapped reconstructs as newer than it is, which is exactly the direction that admits a replayed blob. With it, every wrap misread lands outside the window and fails closed.

boundvaluerelation
MAX_RECON_AGE6 htwo orders of magnitude above every deployed ttl ( 7,200 s) and MAX_HEARTBEAT_S (3,600 s); a clean 2× inside the 12 h ambiguity bound
SOURCE_TS_FUTURE_SKEW_SECS5 sfuture-dated pushes rejected
dayMod alias period96 h16× MAX_RECON_AGE, ~48× the longest deployed ttl

The read side applies the same bound and reports the feed as older than MAX_RECON_AGE rather than handing a consumer a wrongly-reconstructed, falsely fresh observation time.

The ±12 h window alone leaves one fail-open case, and it is the interesting one: a feed dark for exactly ~24 h reconstructs to now and reads fresh, serving a day-old mark with no staleness premium and no revert. dayMod:u2 closes it. Every write tags the source day mod 4; every read recomputes the candidate day from the reconstruction and rejects on mismatch, never searching for another candidate. The two spare bits at [254,256) would push the alias to 16 days if that is ever wanted.

The tag must be derived from the reconstructed source day, never from block.timestamp. A push landing at 00:00:03 carrying a 23:59:58 mark is day D under a block.timestamp rule while the reader reconstructs it to day D-1: the tags mismatch and a perfectly live feed reads falsely stale at every midnight. test_dayTag_writtenFromSourceDayNotBlockTimestamp fails under the other rule.

What it costs

test/unit/OracleGasBenchV4.t.sol, same methodology as §6: EIP-7623-aware full-transaction gas per feed, 2-of-3 quorum, both designs benched side by side in the same test.

shapeV3 sessionV4 sessiondelta
26 feeds (production manifest)3,4413,783+9.9%
66 feeds2,7053,006+11.1%
16 feeds4,2024,291+2.1%
10 feeds5,1585,828+13.0%
8 feeds, 1 slot6,0386,090+0.9%

The delta is a storage slot, and only a storage slot: eight lanes per word instead of ten means a 26-feed manifest spans four slots in the bench where V3 spanned three, and the live class-pure map spans five where V3 spanned four. The 10 equities are the whole story, filling a V3 slot exactly and overflowing a V4 slot by two. Intrinsic and calldata amortise identically; where the feed count does not cross a slot boundary the cost is inside 1% (8 feeds, +0.9%).

The non-finding is the more useful measurement. We expected the wider mantissa to cost calldata: more bits per lane means more lanes crossing their diff threshold, and the diff wire is what makes V3 cheap. On a real 26-minute tape, 1,905 snapshots at ~1.2 Hz of live NX Rates marks across all 26 feeds, widening the mantissa costs +1.9% entries per push (20.56 → 20.94 at a 10 s gap). The diff is already saturated at any realistic push gap: 21 of 26 feeds move within 10 s and 24 of 26 within 40 s, so lane width barely moves it. Almost all of the +9.9% is the extra slot plus one byte per entry, not the diff.

The grid can quantise the whole upgrade away

grid_mask_bits on the producer side is relative, not absolute, so a flat mark grid masks a fixed fraction of the mantissa and eats every bit a wider lane adds. Measured under the old flat 0.5 bps default, entries per push are identical at every mantissa width from 18 to 25 bits (19.01, +0.0%) and so is the delivered precision: the quantiser simply gives back what the lane gained. Shipping the V4 lane against a flat grid buys exactly nothing, and that is a measurement rather than an argument.

The grid is now wire-aware and derived from measured 30-minute realized volatility instead of from a fee, with the divisor scaled 128× on the v5 wire. That only works if the σ feeding it is a measurement, which for most of this year it was not: the class floors were priors set high enough to bind almost always. They were recalibrated against 17.6 days of tape:

classfloor, before → after (pbps)share of observations the old floor bound
FX2000 → 25099.7%
crypto4000 → 80084.5%
commodity2500 → 1400

Every feed on chain now carries a measured σ. The single exception is USDC-USD at 208, sitting on its floor because it is a genuinely pegged pair, which is what a backstop is for.

Delivered precision is max(lanestep,grid): on USDT-USDC at its live σ of 288 pbps that is 0.000596 bps at wire v5 against 0.0763 bps at v4, with an identical mask profile across every class, so the elision the diff wire depends on is unchanged. A v4-era divisor applied at 25 mantissa bits would have masked 9 bits on volatiles and 5 on stables, handing back all but 4× of the 128×. Where σ is a degraded class prior rather than a measurement the grid refuses it and falls back to the flat default, counted.

Status

V4 is authoritative on both tiers, primary and reference, since 2026-09-01: 26 feeds each, five class-pure slots on the primary, wire v5, a 3-relay rotation on the primary and 2 on the reference, and the rebias keeper armed. Every pool leg bands and fail-closes against the V4 reference. V3 stays as the rollback and its primary keeper still runs, because a rollback that is not fresh is not a rollback; the V3 reference keepers are retired, because nothing bands against that instance any more.

Every leg reads fresh and all four pools quote off it: USDT to USDCB at 0.815 bp one-way, WETH 2,446.95, WBTC 78,011, NVDA 219.37, EURC 1.1587. All 26 of 26 feeds decode and render on the transparency page. Addresses are deterministic per fleet and resolve per chain: see Consuming Price Feeds §2.

Reading the feeds from your own contract is three functions and has its own guide.

8. The other lever: when to push

Everything above makes one push cheap. The other lever is deciding which pushes to send at all, because a push that carries no price information costs exactly what an informative one does. Measured over a 45-minute tape on 2026-08-31, half the pushes were heartbeats and the deviation threshold that fired the rest was a hand-set constant per feed.

The trigger is now a share of the edge the pool is quoting. The keeper mirrors the pool’s own Pricing._pathSpread per leg:

Ei(t)=minFeei+σi·vegai100·BPS+Z·σimax(0,τi-gi)BPSpushwhen|mnow-mchain|κEi(t)

termmeaningvalue
τiage since the last pushmeasured per feed
gigrace windowmin(ttli/2,30s)
Zstaleness coefficient, mirrored from Pricing100
κshare of the edge that fires a push0.5

A stable quoting 1 bp pushes at 0.5 bp; an equity quoting 15 bp pushes at 7.5 bp. One constant replaces a table of per-feed thresholds, and when a fee floor or a risk parameter moves the boundary moves with it. As a leg ages past the grace window the contract is already charging Zστ for that age, so the boundary widens by exactly what the quote has already sold.

The pool’s quote also carries a confidence term, and the trigger deliberately leaves it out. Our confidence input is a freshness proxy, not a dispersion measure, and folding one into a push boundary inverts the trigger: a feed whose data has gone stale reports a higher confidence interval, quotes a wider edge, and would therefore push less, precisely when it should push more. Excluding it makes the keeper claim less protection than the pool charges, so the error is over-pushing rather than under-defending. It goes back in when confidence is rebuilt on cross-venue dispersion, which it is not yet.

One heartbeat refreshes every seeded leg. Twenty-six feeds each running their own liveness clock forced a blob every ~11.5 s no matter how still the market was. Coalescing restarts all of them in one blob, and on the diff wire the marginal leg is ~4.2k gas, cheaper than the second blob it avoids. A frozen record gets no heartbeat at all: when mark, σ and confidence are bit-identical to what was last relayed, the source has said nothing, and re-publishing a dead price on a clock is pure cost. The feed ages out and the pool fails closed on staleness, which is the right outcome for a market that is not trading.

The rate limit is a budget, not a refractory. The old gate spaced every push of a feed 36 s apart to honour a 100-per-hour per-asset cap, which meant a fast mover could not be re-marked however far it ran: an extractable-value gap dressed as a rate limit. The cap is now a trailing-hour token bucket:

  • a feed holding budget may re-push after a one-second burst floor (the contract admits one mark per feed per block);
  • a feed out of tokens falls back to the full gap;
  • a scarcity multiplier widens the boundary as the budget drains, so the last tokens of an hour go to the largest edges;
  • worst-case hourly spend is unchanged by construction.

The fleet-wide manifest cap, 360 blobs/hour, is an average, not a spacing rule.

Relays rotate. Five relay EOAs are live, three on the primary oracle and two on the reference. The leader for a push is keeper_set[keccak(slot) % N] over a 10-second wall-clock slot, not over the blob’s own timestamp: replicas poll independently and therefore hold different blobs, so a leader elected on blob contents was routinely a replica that had never fetched that blob and could not land it. Standbys arm at 8 s, staggered 4 s per index, and relay only if the leader’s push has not landed. The contract holds one session at a time, so a relay that is not the session holder simply lands on the fully signed path instead.

Fleet cadence, measured from relay nonce deltas: 260 transactions/hour, against 188/h before this work. Up, not down, and that is the intended direction: the old limiter spent its budget on evenly spaced heartbeats, the new one spends a larger budget on moves that cross the edge a taker would otherwise pick off. For scale, a design that pushes bid and ask directly at ~1 Hz runs ~3,600/hour; because BTR computes both sides on chain from a single mark, the mark only has to be right to within the spread quoted around it.

9. Why we still sign

The cheapest oracles in this class drop on-chain verification entirely. RedStone’s own Bolt path checks msg.sender against five authorised updaters with no signatures at all. That is defensible: if you assume the operator’s key custody is competent, the marginal risk looks small.

Two things make it not the trade we want.

Liveness becomes key custody. With an operator-key oracle the only party who can update the price is the party holding the key. Key ceremony breaks, ops box dies, operator gets rate limited, and the feed stops. A quorum-signed blob can be relayed by anyone, so liveness is not one team’s uptime.

Verifiability does not stop at the chain. The blob is a self-contained EIP-712 artifact, so anything can check it: an integrator, a monitoring service, an off-chain risk engine. All of them get the same cryptographic answer to “did the quorum really say this”, with no chain access and no trust in our relay.

And when the quorum itself fails, the damage is bounded. Suppose a full signer-set compromise and valid signatures on a hostile price: the on-chain deviation band still caps how far a push can move the mark per interval, scaled to the feed’s own volatility, and any single granted signer can revoke the session in one transaction without needing a quorum to agree.

That is the division of labour in one sentence: the signature authorises authenticity, the band bounds magnitude, and the session bounds duration. The unsigned designs have the third and neither of the first two.

10. What is next

The wire is frozen and pinned by a golden vector shared byte-exact between the contract, the producer and the keeper: the same test vector runs in Solidity and Rust.

The upgrade we are sizing is Schnorr aggregation, which is the one place a competitor is ahead of us on design rather than on precision. Chronicle verifies k signers with k elliptic-curve point additions and a single signature check, rather than k ecrecovers. At the session cadence the quorum is already amortised to near nothing, so this matters less than it did at V2’s per-push cadence, which is exactly why it is next rather than now.

Automatic exponent re-centring (§7) is armed on the primary tier, gated on the producer reading the bias from FeedExpBiasUpdated on chain rather than learning it from config. What remains is arming it on the reference tier too.

The oracle is on chain: V4 authoritative on both tiers since 2026-09-01, all 26 feeds rendering live on the transparency page, every pool leg banding against the V4 reference, and the mechanism written up in Oracles. To consume the feeds rather than read about them, the integration guide is the short version. Every gas number above reproduces from one forge test command; every competitor number is either a cited vendor figure or our own measurement with the transaction hash in the source. Break it if you can.

Appendix: measurement notes

Method, so the table can be checked rather than believed. Receipts and logs pulled over HyperSync on 2026-09-01; feeds per transaction counted from each contract’s own events, or from the calldata array length where the contract emits none. Every figure is a median over the stated n, full-transaction gasUsed.

providercontractentrypointwindownexample tx
Chainlinkagg 0x7d4E7420…B21de6Fb5 via per-transmitter forwardersforward(address,bytes) 0x6fadcf72ETH 25,820,587-25,880,5873090x772a03641fc3d7d8a639d22290dd72a16bc867325403798958d8a3df87528ca1
API30x709944a4…905FB3920atryMulticall(bytes[]) 0x437b9116Base 50,687,327-50,727,3272060xb98f6f0c832ed7c9195f882e9a914b531bd334b58a690c248b943ca16e2d5d80
Chronicle0x46ef0071…3Ae5917f4Epoke_optimized_7136211 0x00000082ETH 25,380,626-25,880,6263660x4bb30bffced8e95f51ffbde077966f31f31be70b577e7ada8604ab81c2b0d999
Pyth0x8250f4aF…DeB1487aupdatePriceFeedsIfNecessary 0xb9256d28Base 50,667,010-50,727,0106390x0814d27e5e82b555074e693ae35ff5440a1a19c953909224b69863a8a97271df (8 feeds)
RedStone0xd72a6BA4…0911fC6CupdateDataFeedsValuesPartial(bytes32[]) 0xb7a16251ETH 25,730,614-25,880,61428,5630x60f5afbf34d3f0fd9889733fc29c52b4cce142e2559bca7a1f14734664c689ca (88 feeds)
ElfomoFi0x099097bF…F75ee289none: raw fallback, 25-byte calldataBase 50,721,135-50,727,1355,9950x22ba1e8f909a637716abfff66bc23e10a36f058b421416ee63705a4cb1c54bcf

Two traps worth naming, because both would have produced a wrong table.

Chainlink transmissions do not arrive at the aggregator. Filtering on to == aggregator returns only requestNewRound() and misses every update. Each transmitter EOA calls its own forwarder (31 distinct addresses in this window) which then calls transmit. The correct filter is the NewTransmission log.

The Chronicle instance in most write-ups is dormant. It has a live bar and a live wat, and nothing pokes it. Reading a contract’s configuration tells you nothing about whether it is used; only the transaction history does.

And one that nearly cost us the Metric row: a 200,000-block lookback on Base found no traffic at all on CompressedOracleV1, which is true and misleading. Its push window sits about 1.8 million blocks behind the tip. A recent-window scan answers “is it active now”, never “has it ever been”; the second question needs from_block: 0.