Oracle System

A pool reads each asset’s mark from a contract, not from a hardcoded source. Any address implementing IOracle will do: a signed keeper feed, another on-chain pool, a vault’s share price, a custom discovery contract. Cash collateral is the exception and uses an internal par helper pinned at 1.0.

This page covers what makes a mark trustworthy: who may write one, how a stale or manipulated value is caught, and what a compromised source can and cannot reach.

To only read a BTR mark from your own protocol, see Consuming Price Feeds. The consumer surface is three functions.


1. Overview

ModeValueQuote sourceWhen to use
EXTERNAL0IOracle(primary).getFeed(feedId)Almost everything. NX Rates push, Chainlink adapter, Uniswap reader, vault NAV, custom on-chain logic
INTERNAL1FeedMathLib.getPegFeed (mark =1.0)Cash-collateralized 1:1 units only (tight stablecoin peg). Ref feed = depeg breaker, not the mid

EXTERNAL names the source shape, not a dependency: the mark comes from an IOracle address, so a fully autonomous on-chain discovery contract is EXTERNAL exactly as a signed keeper feed is.

Where the marks come from today. NX Rates and Pyth supply the price indices BTR quotes against. Both aggregate venue tape off chain at high frequency and attest the result, and the oracle keeper relays either through the same signed path. They are chosen per feed on coverage and cadence rather than by tier:

  • A liquid CEX-backed leg declares a sub-second freshness bound.
  • A Pyth-cadence leg declares around a second.
  • A thin metal token declares as much as a minute.

Each leg carries its own sourceTs, so a slow leg loosens only its own staleness gate.

Neither is privileged by the contracts: both arrive through the same IOracle read shape, a pool points at whichever primary its config names, and the chain checks the same things either way. The provider set is configuration, not an assumption. A new provider must supply a mark, a volatility estimate, a confidence interval and a source timestamp, landing inside the feed’s TTL and surviving the per-push deviation band. INTERNAL freezes the mid at par: it reads no TWAP, VWAP or reserve mid, so it is correct only for cash-collateralized units whose mid you intend to pin at 1.0.

Every non-base leg carries a mandatory feed-relative depeg band, and the base carries a parity halt instead; both are specified in Depeg Halt §2. Integrators: Curation §3.2.

Four implementations. They differ in on-chain memory layout, push cadence and push authorization; the IOracle read shape and the quorum trust anchor are identical.

  • V1 (ExternalOracle.sol) is paused and retired on Arc (2026-08-31).
  • V2 (ExternalOracleV2.sol, §9) held authority briefly on the cutover day and is now the rollback for V3.
  • V3 (session-grant + diff wire, §10) held primary authority from 2026-08-31 and reference authority until 2026-09-01. It is now the rollback only (§11.9): the V3 primary keeper still runs so the rollback stays fresh, the V3 reference keepers are retired.
  • V4 (29-bit lanes + cyclic clock, §11) is authoritative on both tiers on Arc since 2026-09-01: 37 primary repoints and 37 reference repoints executed.

All four are documented here, and §2 puts the V1/V2 memory layouts side by side; V3’s layout is in §10.2 and V4’s in §11.2.

See Feed Oracle for the data-flow view of FeedData and the NX Rates push API.


2. FeedData and the two storage layouts

IOracle.FeedData is the read shape: what getFeed returns from either version. Consumers see a plain 1e18 WAD mark plus metadata and never touch the on-chain packing:

FieldRole
mark1e18Fresh keeper mark (1e18 WAD), quote source. A memory/return value; the on-chain encoding differs per version (below).
sigmaPbpsStored σ (PBPS), pricing input: the NXR-signed σ stored directly, floored at the realized |Δp|/p each push (compromised-signer backstop). Not an on-chain EMA.
updatedAtPush timestamp (s)
ttlFreshness window τ (s)
confidenceMark 1σ CI (bps), decoupled from σ
flagsFeed flags; bit0 = paused (guardian fast-freeze, fail-closed)
maxDeviationPer-push deviation-band floor (bps), mandatory non-zero
sourceTsNXR-signed source time (ms, uint48): monotonic replay guard + true data-age

The two versions pack this differently on-chain. Everything else is shared: k-of-n quorum, monotonic replay guard, the volatility-adaptive band, the στ premium, guardian freeze.

V1 (ExternalOracle.sol, deployed)V2 (ExternalOracleV2.sol, §9)
Storage modelOne 256-bit slot per feed8 feeds per price slot + separate σ-slots + a cold registry
Mark encodingB64 float, 52/5/7 (§5)23-bit normalized mantissa + 5-bit binary exponent, per-feed expBias (§9.3)
Feed identitytickerId (u64) on every recordpositional (slotId, laneIdx), no id on the wire
Timestamp scopeone sourceTs per push (blob header)one ts per slot (per-slot monotonic guard)
Push triggerθ-cross + heartbeatθ-cross + heartbeat, narrower batches
Gas / feed13.7k-14.4k full-tx on Arc10.3k-11.6k full-tx on Arc (§9.11)
Statusretired on Arc (feeds paused 2026-08-31; keepers scaled down)rollback for V3 (§9.10)

V3 (§10) keeps V2’s read shape and guards, moves per-push signature verification into a quorum-signed session grant, and replaces the record wire with a diff wire (22-bit lanes, 10 per slot, one config word per slot). Measured 5,158 gas/feed full-tx at 10 feeds, 4,202 at 16, against V2’s 11,208/8,899 on the same bench. Authoritative on Arc since 2026-08-31. Arc forum table’s “V4 5,158” is this V3 measurement; V4 measured is 5,828 at 10 feeds (see §11.7).

2.1. V1 memory layout: one slot per feed

Every field packs into a single 256-bit word (ExternalOracle.sol), the B64 mark in the low 64 bits:

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

One feed = one SSTORE. The B64 mark is decoded to mark1e18 at the getFeed boundary.

2.2. V2 memory layout: eight feeds per slot

V2 splits hot data from cold and packs by slot, not by feed:

  • A price slot holds 8 feeds + a per-slot timestamp.
  • σ lives in its own dirty-written slots.
  • Identity and expBias move to a cold registry.

Full layout, encoding and rationale in §9.3.

Reading guide. §3-§8 below detail V1 (the deployed version): its push API, B64 encoding, consumer reads, caching and security. V2 is self-contained in §9. The consumer surface (getFeedFeedData), the k-of-n quorum and the risk guards are common to both.

3. On-chain derived state

None on the price path. There is no on-chain price EMA and no on-chain σ-EMA fold. The push path stores the NXR-signed σ directly into the σ lane; all smoothing lives at the source (NX Rates), not on-chain.

The realized-move floor on σ (a compromised-signer backstop, see §8.3) is conditional on V4, not applied on every push: σ/conf elision is total, so a push carrying no σ entry for a slot does not load the σ word at all. The floor runs only where the word is already in memory — the deviation-band slow path (a move past maxDeviation), or a slot for which the blob carries σ entries. A sub-maxDeviation move in a σ-less blob leaves the stored σ untouched (ExternalOracleV4.sol).


4. Feed management (push API)

V1 only. This section documents ExternalOracle.sol, paused and retired on Arc since 2026-08-31. The live surface is V4: registration is registerFeed(feedId, globalIndex, expBias, maxDeviationBps, ttlSecs), the push entry points are pushSignedV4 / pushV4 / openSession, and the wire is v5 (§11.5). V4’s instant config lever is updateFeed, tighten-or-equal on band and ttl, guardian-or-admin; its inverse is the owner-timelocked requestFeedWidenexecuteFeedWiden with a guardian-or-owner cancelFeedWiden, shipping in the next release and described at §8.3. narrowMaxDeviation and maxRelayLagSecs do not exist on V4 in any release (§11.9).

function addFeed(uint64 tickerId, address base, address quote, uint64 price, uint32 sigmaSamplePbps, uint16 confidenceBps, uint16 maxDeviationBps, uint16 ttlSecs) external; // owner-only function batchPushSigned(bytes calldata blob, bytes calldata sigs) external; // k-of-n signed, relayer unpermissioned

tickerId is the NXR/MITCH instrument id the feed’s signed records key on (resolved through the append-only feedIdOf[tickerId], never remapped); registering it here also binds and validates the push-path bounds (maxDeviation, ttl, σ seed) once at ExternalOracle.sol.

A MITCH ticker id is the canonical instrument identity: 64 bits carrying the instrument type, both asset classes and both asset ids, so spot, perpetual, future and option on the same underlying are distinct ids rather than one colliding symbol hash. The on-chain feedId is still the legacy keccak256(abi.encodePacked(asset,quote)); migrating it to bytes32(uint256(tickerId)) is pending. The full 26-feed map, MITCH id against the feedId an integrator must use today, is in Consuming Price Feeds §3.2.

The only push path is batchPushSigned (§4.2); there is no signature-less / msg.sender-trusting push.

4.1. Access control

RoleCapabilities
Owner / AdminaddFeed; tighten a band or ttl (updateFeed, immediate); widen a band or ttl (requestFeedWidenexecuteFeedWiden, timelocked); queue signer additions (requestSignerGrantexecuteSignerGrant, timelocked) and quorum decreases (requestSignerThresholdDecrease, timelocked); raise the quorum (setSignerThreshold, immediate); unpauseFeed
Guardian or OwnerFast-freeze safe-direction only: revokeSigner (immediate), pauseFeed, narrowMaxDeviation (tighten-only), cancelFeedWiden, cancelSignerGrant, cancelSignerThresholdDecrease
SignerOne of the k-of-n granted NXR attester keys; authorizes a batch by signature over the EIP-712 digest (§4.2). Not a caller role: the relayer (msg.sender) is unpermissioned.

maxRelayLagSecs is immutable (set once at construction). There is no Oracle role. The signer-set ceremony (genesis bounds, grant/revoke asymmetry, quorum changes and their delays) is specified once in Oracle Price-Push Security §4.

updateFeed is tighten-only. It reverts unless maxDeviation <= f.maxDeviation and ttl <= f.ttl (ExternalOracle.sol). Loosening is the direction that enables a single-tx drain (a wide band on a fresh push) or a stale-mark quote, so it is the direction that carries the delay; the tightening twins (narrowMaxDeviation, pauseFeed) stay instant and guardian-able. Widening routes through requestFeedWiden → the BASE tier delay → executeFeedWiden, guardian-vetoable at any point via cancelFeedWiden (ExternalOracle.sol).

executeFeedWiden additionally requires the live config to still equal the request-time snapshot (ExternalOracle.sol). Any tightening taken during the delay, whether a guardian narrowMaxDeviation or an owner updateFeed, voids the pending widen rather than being silently reverted by it.

4.2. Signed push path (batchPushSigned): k-of-n quorum

Price authority moves off-chain to a set of independently keyed NX Rates signer replicas. The oracle accepts a signed batch of quotes and verifies k distinct signatures over the same digest on-chain; the relayer (keeper) that submits the transaction is unpermissioned, because authority is in the signatures, not msg.sender. This decouples price authority (the signer set) from push liveness (any relayer) and removes the single-signer failure mode: one stolen key can push nothing on its own.

function batchPushSigned(bytes calldata blob, bytes calldata sigs) external;

blob is an 8-byte header (version u8 | shared sourceTsMs u48 | reserved u8) followed by fixed-width 22-byte records, one per feed, keyed by ticker: tickerId (u64), mark (B64 u64), σ (sigmaPbps, u32, PBPS), confidence (u16, bps). There is no per-record index and no per-record timestamp - the source timestamp is shared header-wide. sigs is k concatenated 65-byte recoverable ECDSA signatures over one EIP-712 digest, sorted by recovered address ascending.

The digest, its chainId / verifyingContract domain binding, and the strict-ascending recovery check that makes the signature count a k-of-n proof are specified in Oracle Price-Push Security §3. Records key on a compact tickerId(u64) resolved through the append-only feedIdOf[tickerId] map, which never remaps (ExternalOracle.sol); an unregistered ticker resolves to a zero feed and reverts, which is the bounds check.

Byte-exact wire layout. The 8-byte header, shared by every record in the blob:

version8bsourceTsMs48breserved8b
BitsFieldNotes
0..7versionu8
8..55sourceTsMsu48, shared across the blob
56..63reservedu8

Then one fixed 22-byte record per feed, keyed by ticker:

tickerId64bmarkB6464bsigmaPbps32bconfBps16b
BitsFieldNotes
0..63tickerIdu64, NXR/MITCH instrument id
64..127markB64u64, B64 float
128..159sigmaPbpsu32
160..175confBpsu16

Reference decoder decodeBlob in @btr-protocol/sdk; on-chain counterpart ExternalOracle.batchPushSigned.

Verification and guards, all fail-closed:

GuardRulePurpose
Signer quorumk=|sigs|/65signerThreshold, fixed 65-byte stride, no EIP-2098 compact form, because the count is the quorum claim and must be unambiguous. Every signature recovers to a granted signer and recovered addresses must be strictly increasing. All verified before any state write. Ceremony and bounds: 3.6 §4.Strict increase is the k-of-n deduplication check, so k accepted signatures prove k distinct granted keys. Revoking below the threshold deliberately halts pushing: the fail-safe response to a suspected compromise (feeds go stale, pools fail closed).
Monotonic sourceTsPer feed, sourceTs must strictly exceed the stored value and fit 48 bits.Replay defense: the timestamp is the nonce. A resubmitted or reordered blob reverts. Never keyed on the signature bytes (a malleable s still recovers).
Freshness bound (past)maxRelayLagSecs (immutable): reject a blob whose sourceTs lags wall-clock by more than the bound; feed τ must exceed the lag bound (validated at config).Monotonicity alone lets a withheld older-but-valid blob land and read fresh downstream (updatedAt = block.timestamp); the absolute floor closes that.
Future-dated boundSOURCE_TS_FUTURE_SKEW_SECS = 5: reject a blob whose sourceTs leads block.timestamp by more than 5 s.A far-future sourceTs would clear the monotonic guard once and then permanently freeze the feed (no honest near-now push could ever exceed it again, no reset path): the far-future-stamped-report vector. 5 s absorbs NTP drift, scheduler/network jitter, and block-timestamp granularity (NXR’s quorum target is < 50 ms).
One per blockΔt=block.timestamp-updatedAt0, else CooldownActive.Bounds the per-block move to one deviation-band step; a duplicate idx in a batch fails closed.
Deviation band (volatility-adaptive)Full formula, terms and units: §8.3. σ is the stored prior, never the incoming push’s own, and the source-time gap Δtsrc comes from the attested sourceTs.Chain-agnostic: a legitimate Brownian move over Δtsrc at per-interval volatility σ is ZσΔtsrc/T. The signatures authorize authenticity, not magnitude; this backstops a compromised quorum per push. The cumulative bound is the independent reference feed below.
Independent reference feedPer asset, OracleConfig.refPrimary points the depeg band at a separate oracle instance; the pool halts when mark and reference diverge past refBandBps (Depeg Halt §2.4). Signer-set independence is a deployment property, not an enforced one: 3.6 §4.6.A compromised push quorum cannot walk the mark past refBandBps of an independent reference without halting swaps: push-rate-independent, magnitude-based.

The signed path stores the NXR-signed σ directly (no on-chain σ-EMA) and emits no event; observability is getFeed() state polling. batchPushSigned is the sole writer. V4 is indexable: both push entry points emit SlotsPushed(uint32 indexed seq, uint32 sourceTsDs, uint256 acceptedMask, bytes32 blobHash), and a record that fail-softs any lane also emits LanesSkipped(uint32 indexed slotId, uint16 laneMask). The one V4 outcome with no event is a whole slot stepped over for failing the source-second monotonicity check.

Where the k signatures come from. Each signature comes from an independently keyed NX Rates replica that re-validated the record against its own market view before countersigning, so k accepted signatures mean k independent agreements on that price at that time. Provenance chain: Oracle Price-Push Security §2.

Quotes are produced and signed by NX Rates. The signing scheme, the /v1/quote/signed endpoint, blob field semantics, key management and plan tier are NXR’s, documented in its signed-quote specification (provided to integrators on request); BTR docs cover only what lands on-chain.


5. B64 float encoding

V1 only. B64 is ExternalOracle.sol’s internal mark-storage encoding. It never appears in the IOracle interface; getFeed returns a plain 1e18 WAD (mark1e18), and no consumer contract references it. V2 replaces it with a packed normalized-float lane (§9.3).

5.1. Format (52/5/7)

exponent7bdecimals5bmantissa52b
BitsFieldNotes
0..6exponentu7, biased by 64: -64 to +63
7..11decimalsu5, 0-31
12..63mantissau52, normalized
FieldBitsRangeDescription
Mantissa520 to 4.5×1015Normalized value
Decimals50-31Token decimal places
Exponent7-64 to +63 (biased by 64)Scale factor

Packed as (mantissa << 12) | (decimals << 7) | (exponent + 64), 64 bits exactly.

5.2. Encoding

function encodeB64(uint256 value, uint8 decimals) internal pure returns (uint64)
  1. Normalize mantissa to 52 bits
  2. Compute exponent from normalization shift
  3. Pack: (mantissa << 12) | (decimals << 7) | (exponent + 64)

5.3. Decoding

function b64To1e18(uint64 b64) internal pure returns (uint256)
  1. Extract mantissa, decimals, exponent
  2. Compute total shift =e-bias+d
  3. Return m·10e-bias+d, normalized to 1e18

5.4. Example

Price: 2500.00 USDC (6 decimals)

  • Value: 2,500,000,000 (2500×106)
  • Mantissa: normalized to 52 bits
  • Decimals: 6
  • Exponent: from the normalization shift

5.5. Where B64 is used, and where it is not

The rule is not “packed is cheaper”. B64 pays where the 64-bit width buys a storage slot or wire bytes, and loses where the value is computed and immediately discarded.

It pays in IOracle.FeedData. The whole feed is one 256-bit slot: price, σ, updatedAt, ttl, confidence, flags, maxDeviation and sourceTs in 240 bits. A storage-inclusive audit measured -1,767 gas on a cold read, with break-even at 0.43 cold reads per write: feeds are read far more often than they are pushed. No exact-WAD alternative fits. A WAD-wide price at BTC scale needs at least 96 bits, which overflows the slot and forces sourceTs out, and sourceTs is the signed path’s monotonic replay nonce. It pays for the same reason in the signed wire record, where the 22-byte layout is what a batch is charged calldata for, and in TransientCacheLib.

It loses in SwapQuote and in Swapped. markPrice and midPrice are computed and handed straight out as a struct field and a log arg, never packed into a slot, so there was no slot to buy. ABI encoding pads every non-indexed event arg to a full 32-byte word, so the 64-bit encode saved zero log bytes while costing roughly 950 gas per swap on the hot path (917 to 1,557 across leg counts) plus the 52-bit mantissa truncation. Both fields are exact WAD (1e18), cheaper and more precise. hopPrices is uint256[] WAD for the same reason.


6. Consumer reads

Integrating from another protocol? Start at Consuming Price Feeds instead. It is the consumer-facing guide: addresses, feed ids, live values, the freshness/pause/confidence safety rules, and six worked examples.

NeedReadNotes
Spot / quotemark1e18fresh mark (1e18 WAD); the only quote source (removes classical curve LVR; residual push-latency LVR + OEV remain; see §8.4)
Freshnesstobs vs τtobs=min(tsrc,tupd), not tupd alone; see §8.2
Uncertainty (1σ CI)confidence (bps)widens the spread; halts the swap past MAX_CONFIDENCE_HALT_BPS
Realized volsigmaPbps (PBPS)vol band + staleness surcharge
Liveness (external view)isFeedFresh(feedId[, maxAge])same tobs clock; returns false for a paused feed regardless of age (ExternalOracle.sol)

The base token is priced through its own OracleConfig like every asset, and the base must be EXTERNAL because the halt compares the base to the outside world. Spoke assets pick EXTERNAL or INTERNAL at addAsset / setOracleConfig, independently, including mixed pools. Reader, thresholds and failure modes: Depeg Halt §2.


7. Transient caching (EIP-1153)

A feed read is cached in transient storage (EIP-1153) for the duration of the transaction (TransientCacheLib), so a multi-hop swap reads each feed once. The cache holds the whole FeedData.

A cache hit is not re-gated. Pricing._readOracle returns a hit directly (Pricing.sol); only a miss runs _fetchFeedFeedMathLib.gate (Pricing.sol). The verdict is identical because block.timestamp is constant within a transaction and the swap entry pre-warms the cache before any leg runs (Pricing._primePath, Pricing.sol). Both legs of a cross therefore read one mark, which is also what makes the atomic relay-then-extract sequence unreachable (§8.2 item 4). The safety rests on _primePath, not on a per-block push limit: V4’s replay guard is a per-slot strictly increasing reconstructed source second, so several pushes can land in one block if their source seconds differ (§11.4).

Three separate type keys exist so the roles never collide (TransientCacheLib.sol, consumed as TCache.TYPE_*):

  • TYPE_ORACLE_FEED: the quote source.
  • TYPE_BREAKER_FEED: EXTERNAL depeg breaker when INTERNAL uses a peg helper.
  • TYPE_REF_FEED: the independent reference.

Caching a breaker under the quote key would let a depeg breaker be answered by the mark it is supposed to police. Every entry is cached post-gate only.

Savings: approximately 2,100 gas per cache hit. Scope: single transaction, clears automatically.


8. Security considerations

8.1. Manipulation resistance

  1. External-mark quoting: the quote source is a mark aggregated off-venue (NX Rates), not pool-internal reserve state, so a single-block, flash-loan or sandwich move of the reserves cannot move the quote.

  2. Volatility-adaptive per-push band: the band bounds each push to at most 10dmax (§8.3), so a single compromised push cannot one-shot the mark (the LUNA/Venus minAnswer lesson: track a real crash, never brick, never one-shot manipulate). On V4 an out-of-band lane is skipped, not reverted — it is dropped from the accepted mask and reported in LanesSkipped, and the rest of the blob lands.

  3. Signer-quorum governance: every signed batch needs signerThreshold distinct granted signers; the guardian or owner can revokeSigner instantly to retire a suspect key; drops below threshold halt pushes (fail-safe).

  4. Guardian fast-freeze: guardian or owner can pauseFeed, setting the lane’s paused flag; only the owner can unpauseFeed. A paused feed reverts in FeedMathLib.gate() and reads not-fresh in isFeedFresh, fail-closed regardless of freshness. On V4 the tightening twin is updateFeed(feedId, maxDeviationBps, ttlSecs), guardian-or-admin, which reverts unless both new values are less than or equal to the live ones — it ratchets the ttl down as well as the band, and its only inverse is the owner-timelocked widen (§8.3). Both are safe-direction levers: halting or tightening never loosens.

    Shipping in the next release, the pause is also fail-closed on release, and it does more than set the bit. pauseFeed clears the lane’s price and confidence and anchors the mark it froze in _bandAnchor1e18, stamped with the observation second the slot then held — the same three lines _rebias and executeFeedWiden run for a lane they invalidate. Two consequences. The whole pause window reads DEAD, not merely halt-bit-gated, so a consumer reading getFeed without gate gets the same answer as one that gates. And the re-entry is banded over the real gap since the anchored mark was observed, so the allowance grows with the pause (dmax+ZσΔt) instead of collapsing to one cadence. σ is kept, because the re-entry band is built from it. The slot clock is deliberately not stamped: a pause changes neither the encoding nor the admissibility of a pre-pause blob, so the slot-mates keep their own Δt and lose nothing. A pause on an already-paused feed is inert — the lane is already dead, so the anchor is not overwritten. unpauseFeed needs no change: it lifts the bit onto an already-dark lane. Past the Δt-independent 10dmax ceiling the release is still requestFeedWiden / executeFeedWiden.

    What the bit alone did, and why this is a correction rather than a refinement: _applySlot counts a paused lane’s entries as accepted while never writing the lane, and there is one clock per slot, so the slot’s timestamp advanced all through the pause — from the paused lane’s own entries and from every live slot-mate. At unpauseFeed the feed therefore reported the frozen pre-pause mark at age ~0: the gate passed, the στ staleness premium was identically 0 inside the grace window, and the entire pause-window move was unpriced until the next accepted push landed. Worse, that correcting push was banded over one cadence rather than over the pause, so a move past dmax+Zσcadence was refused and the release itself could wedge the feed it was meant to hand back.

8.2. Staleness protection

  1. One fail-closed gate for every feed: FeedMathLib.gate is the single safety triad, called by Pricing._fetchFeed on the quote path, by Pricing._readBasePriceOrHalt on the base mark, and by PoolIOLib.priceBandGuard on the breaker and reference feeds. It reverts, in this order, on PAUSED, STALE, DEAD (mark 0) and UNCERTAIN. One gate means an uncertain safety feed can never silently permit execution on one path while halting on another.

  2. Freshness is measured from tobs, not tupd: with tsrc the attested source time and tupd the relay landing time,

    tobs=min(tsrc,tupd),a=t-tobs,a>τStaleData(a,τ)

    Taking the minimum is what closes withheld-blob relabeling: a relay landing an old signed quote stamps tupd=t, and using tupd alone would read it as fresh. Signed-path clock skew is deliberately not capped to block.timestamp, which would push the observation forward every block and silently extend the TTL. Halting beats bleeding: a stale asset cannot be swapped until re-pushed. τ is per feed, short for followed flagships, longer for slow-moving stables.

    On V4 the two fields are the same number. V4 stores one reconstructed source time per slot and no landing time, so getFeed returns updatedAtSecs == sourceTsMs/1000 and tobs=tsrc identically. Relabeling is closed by the acceptance window instead: a push whose reconstruction falls outside [t-MAX_RECON_AGE,t+5] is rejected outright, and the read side applies the same bound and fails closed (§11.4). There is no maxRelayLagSecs on V4; the past bound is the constant MAX_RECON_AGE = 6 h, which exceeds every deployed ttl, so no ttl-versus-lag validation exists or is needed.

  3. Confidence halt: confidence > MAX_CONFIDENCE_HALT_BPS = 1,000 bps reverts ThresholdViolation (PoolConstantsLib.sol). The comparison is strict: exactly 1,000 bps still quotes. A feed reporting more than 10% uncertainty cannot be quoted at all, fail-closed, like the depeg band. Below the ceiling, confidence still widens the spread (see Spread & Fees §3.5).

  4. A mark landed this block is deliberately not gated (FeedMathLib.gate). That mark has already cleared quorum, monotonic sourceTs, the relay-lag floor and the σ-adaptive band, so it is the best price the pool holds. Rejecting it would hand any address a pool-wide outage switch, because batchPushSigned takes authority from the signatures rather than from msg.sender, so relaying one published blob per block would revert every priced call. The atomic relay-then-extract this would defend against is already unreachable: Pricing._cacheFeed pins the feed in transient storage for the whole transaction, so both legs read one mark.

  5. Staleness surcharge (graceful degradation below the hard TTL revert): the spread ramps with the unobserved expected drift Zsσmax(0,a-g), with the premium-free grace g=min(τ/2,STALE_GRACE_CAP_SECS=30s) (Pricing.sol). The cap matters: at τ=600 s, τ/2 alone would quote no staleness premium for the first five minutes. See Spread & Fees §3.4.

  6. Base-token depeg halt: Pricing._readBasePriceOrHalt reverts BaseDepegged when the base-token mark leaves unit-of-account parity by more than the halt band. A stable base losing its peg halts the hub rather than mispricing every spoke. Threshold, reader and call sites: Depeg Halt §2.2.

8.3. Deviation bounds

Canonical statement of the per-push band. The band is mandatory: maxDeviation is packed in the feed’s config lane, and maxDeviation == 0 reverts at both registration and updateFeed. On V4 it is enforced per lane inside _applySlot, and a lane that breaches it is skipped, not reverted: the lane keeps its previous value, its bit is reported in LanesSkipped, and every other lane in the blob lands (ExternalOracleV4.sol).

Two exemptions. The band runs only when EXPRESS is false and the lane holds a previous mark to measure against. EXPRESS is false on every deploy path (§11.8). The second is reachable by design: registerFeed seeds no mark, so a freshly registered feed’s first push is unbanded — there is nothing to compare it to. Registration is onlyAdmin and the seed is a single push, but size a listing ceremony knowing the first mark carries no magnitude bound. See the seeded-feed regime below.

Write p for the mark, dmax for the per-feed maxDeviation in bps, σ for the stored prior in PBPS, Δtsrc for the attested source-time gap in seconds, T for the σ sampling interval and Z for the sanity multiple. A push reverts ThresholdViolation when δ>δmax, where

δ=|pnew-pprev|·104pprev,δmax=dmax+min(Z·σ100·ΔtsrcT,X·dmax)

so the whole band is capped at (X+1)dmax=10dmax. Every term:

TermValueSiteRationale
ZDEV_SIGMA_Z = 6ExternalOracle.sol6σ authenticity sanity cap on a Brownian step.
TSIGMA_INTERVAL_SECS = 1800ExternalOracle.solMatches NXR’s 30-min Parkinson σ window.
XDEV_BAND_MAX_X = 9ExternalOracle.solCaps the whole band at 10dmax.
σthe stored prior, never the incoming push’s ownExternalOracle.solA signer must not be able to widen the band it is about to cross.
dmaxmandatory, 1 to MAX_DEV_THRESHOLD =2,000 bpsExternalOracle.solMicrostructure/discretization floor, not the primary bound. Deployed on Arc: 50 bps stables, 75 bps FX, 100 bps crypto / metals / equities.
Δtsrcattested sourceTs delta in secondsExternalOracle.solSource-time, not block time, so the bound is identical percent-per-wall-second on a 400 ms chain and a 12 s chain.

Units. σ is stored in PBPS and enters in bps as σ/100. The code’s 105 divisor is 103 for the integer-sqrt scaling times 102 for the PBPS→bps conversion (ExternalOracle.sol). Substituting σ in PBPS overstates the adaptive term by 100×: at σ=104 PBPS (1%) over a full interval Δtsrc=T the real term is 6·100·1=600 bps, not 60,000.

Why the σ cap and the σ floor are decoupled. The stored σ is floored each push at the realized |Δp|/p (item 5 below). Without X a compromised quorum pushing max-band moves would ratchet its own future band by roughly Z-fold per push, and the band would run away. Capping the σ term at Xdmax lets σ keep its economic/spread role while the band stays bounded by per-feed config no matter how far σ has been walked.

The band grows with staleness, so a wedged feed self-clears. Two regimes, both monotone in the gap:

  • Registered feed, never pushed: V4’s registerFeed seeds no mark and no σ — it writes the config lane, stamps the slot clock and leaves the price lane at the STALE sentinel. The first push therefore finds pl == 0 and skips the band entirely rather than clearing it. V1 seeded a mark and a mandatory σ and fell back to Δtsrc=t-tupd; V4 does not need the fallback because it does not enter the band.
  • Live feed, gone quiet: Δtsrc is the gap between reconstructed source seconds and grows as the honest source time advances past a frozen predecessor. When the slot’s stored predecessor reconstructs outside the 6 h acceptance window it is treated as absent and Δtsrc falls back to the constant MAX_RECON_AGE (ExternalOracleV4.sol), which is stored-state-derived and not caller input. The 10dmax cap applies throughout.

Recovery when the market moved further than 10dmax. Self-clearing only reaches the hard ceiling. Past it the band rejects the very push that would close the gap, updateFeed is tighten-or-equal for the owner as well as the guardian, and registerFeed refuses an id that already exists. Until now the only escape was a fresh oracle instance plus a BASE-tier UPDATE_ORACLE repoint on every leg reading it — 37 on Arc, with the legs dark throughout.

Shipping in the next release: requestFeedWidenexecuteFeedWiden, and the release is magnitude-independent. Both entry points are onlyAdmin; cancelFeedWiden is guardian-or-owner and vetoes a live or an expired request. Widening the band is loosening price authority in the same sense a signer grant is, so it takes the same shape as one: BASE tier, one packed pending slot per feed (pendingFeedWiden, a public read), a GRACE_PERIOD window, a guardian veto — while the tighten twin and pauseFeed stay instant. Loosening waits, tightening does not.

  • Both arguments are loosen-or-equal, newMaxDeviationBps capped at MAX_DEV_THRESHOLD = 2,000 bps and newTtlSecs at MAX_RECON_AGE = 6 h. TTL rides the same op because updateFeed ratchets it down too and is guardian-reachable: a lever that closed only the band half would leave one key able to clamp every feed to ttlSecs = 1 with no owner path back.
  • The magnitude is optional. Executing clears the lane’s price and confidence and the rebias band anchor, so the next push is the seed-equivalent unbanded push regardless of how far the mark ran. Passing the live band and the live ttl back verbatim is therefore a pure release that loosens nothing — and at dmax=2,000 it is the only admissible argument, so the lever stays reachable on exactly the feeds most likely to need it. σ is kept across the release, as it is across a rebias: it is bias-independent and the re-entry band is built from it.
  • Compare-and-swap on both fields. The request snapshots the live band and the live ttl; executeFeedWiden reverts InvalidState unless both still equal the snapshot. Any tighten during the wait — guardian or owner, either field — voids the request rather than being silently reverted by a matured absolute write. Read pendingFeedWiden(feedId) to tell a request that will still execute from one a defensive move has already killed, before waiting out the delay.
  • The event is its own. FeedWidenExecuted(feedId, oldBand, newBand, oldTtl, newTtl), not FeedUpdated: this write halts a leg and arms an unbanded seed push, and oldBand == newBand is exactly how a pure release announces itself.
  • The feed reads DEAD from the execute until the next push lands. That is the fail-closed direction and the point: the release must not depend on how far the mark ran.

The execute stamps the slot clock, so release a stalled slot in one batch. Clearing the lane makes the next push the seed-equivalent one, and the slot clock is then the only thing deciding which push that is. On a stalled slot the clock is hours old, every push blob is public calldata, and an unprivileged relayer could otherwise land the oldest admissible blob as the seed and re-wedge the feed with the widen already spent. Stamping makes only blobs sourced strictly after the execute eligible. The cost is _rebias’s, verbatim, because there is one clock per slot: release every wedged lane of a stalled slot in ONE batch, and pauseFeed any mate you cannot — an unreleased mate reads age ~0 again on the next accepted push and is fail-open on its own leg until its own push or release lands. The slot resumes only once a blob sourced after the execute’s block second arrives: the producer’s observe-sign-relay lag plus one cadence, about 30 s at the live keeper rate, for all eight lanes. Running a second ceremony on the same slot reads a clock the first already stamped, so the anchor it writes is that much too new and the recovery push it bands is refused for that much longer — fail-closed and self-healing, but a reason to release or rebias every wedged lane of a slot before their recovery pushes rather than interleaved with them.

A release leaves the band where the request set it. If the ceremony widened to clear a gap, re-tighten with updateFeed once the feed is quoting again — guardian or owner, instant, and the same block is fine. The off-chain path is script/OracleV4Unwedge.s.sol: preview() and verify() take no key and broadcast nothing, preview() groups the selection by slot and prints UNRELEASED MATE for any wedged lane a skip leaves behind, and execute() re-reads the compare-and-swap snapshot so a request a tighten already voided is skipped instead of reverting the batch.

A compromised or faulty signer quorum is therefore bounded to a 6σ move per push and to 10dmax in the worst case, never an unbounded jump: a gradual, monitorable walk rather than a one-tx drain. The cumulative bound across many in-band pushes is the independent reference feed (item 4).

Remaining bounds on the same path:

  1. σ ceiling: MAX_SIGMA_PBPS = 100,000,000 PBPS (10,000%) on every stored σ, checked on the signed sample and applied again inside FeedMathLib.markMovePbps, so the floor below cannot push σ past the cap.
  2. Confidence: hard-halts past MAX_CONFIDENCE_HALT_BPS in FeedMathLib.gate (§8.2 item 3).
  3. Cumulative bound: a per-push band alone does not bound the total excursion: a compromised producer can walk the mark in many small in-band steps, each individually reasonable, the sum catastrophic. Two independent layers close it: the signer quorum means walking the mark requires k simultaneously compromised keys on independent nodes, and the independent reference feed halts swaps once the mark diverges from a separately-keyed reference by refBandBps, a magnitude bound no number of pushes can dodge. Full argument: 3.6 §5.1.
  4. σ floor (volatility-understatement backstop): the stored σ is max(σsigned,|Δp|/pprev) in PBPS. A signature authorizes the authenticity of a mark, not its volatility: a signer signing σ=0 would collapse the spread to the minFee floor and make a mark-then-self-swap round trip spread-free. Flooring at the realized move forces a proportional spread on any mark move, so the round trip is spread-negative. This floor is economic and spread-side only, and by the X cap in item 1 it can never ratchet the deviation band itself. V4 applies it conditionally, not on every push: only where the σ word is already loaded — the band slow path (a move past dmax), or a slot the blob carries σ entries for (§3).
  5. One accepted write per slot per source-second: V4’s replay guard is per-slot strictly increasing reconstructed source seconds, not a per-block rule. Several pushes may land in one block if their source seconds differ, and none may land across blocks that share a source second. A slot whose incoming source second is not strictly newer is skipped silently — the whole slot is stepped over with no event. V1’s CooldownActive per-block rule does not exist on V4; a duplicate or descending gi inside one section still fails the blob closed with BadBlobHeader.

8.4. Known risks: LVR & OEV

AIMM’s external-mark design changes which oracle risks matter. It does not make LP capital immune to adverse selection.

LVR (Loss Versus Rebalancing): push-latency form

Definition: LP loss vs a continuously rebalanced strategy at the true external price. Classical CFMM LVR comes from a reserve-implied mid that only moves when someone trades. AIMM quotes FeedMathLib.mark() = mark1e18, so that channel is closed, but only that channel: the residual below is real and unrebated.

Residual risk: between the moment the true price crosses the keeper’s θ (or a heartbeat / CI-spike fires) and the correcting batchPushSigned confirming on-chain, informed flow can still trade against a frozen mark. Call this push-latency LVR. (Oracle-anchored venue literature - Metric/OMM-style active pools - bundles this whole family under “residual lag risk”; here it is split into the continuous form below and the discrete OEV on the push transaction itself.) Pushes fire on three triggers:

  • A θ crossing.
  • Heartbeat expiry.
  • A CI-spike (ci_spike_bps, keeper-side trigger config): the feed’s confidence interval widening past the threshold covers depeg/dispersion onset between θ crossings.

Primary controls:

ControlRole
Keeper theta_bps (θ)Bounds intended stale gap before a push
heartbeat_s τ/2Liveness ceiling; ops hard-fail if violated
Pool minFeePbpsTrader pays Spath/2; size so one-way cost covers typical stale gap (hard worst case wants 2θ on the spread floor; launch configs may use a mean-gate softer than that)
NXR freshness + poll_interval_sDetection latency before the keeper even builds a tx

Secondary / defense-in-depth:

ControlRoleCaveat
vegaBpsSvolWidens spread when σ is already highWeak on the first jump of a new regime (σ updates on push)
confidenceUconfTaxes uncertain marksHalt past MAX_CONFIDENCE_HALT_BPS
STALE_ZUstaleσa-τ/2 after graceZero while aτ/2: does not price healthy intra-θ drift
Coverage skew (fixed protocol law)Taxes one-way inventory movesZero at coverage 1; can subsidize flow that improves coverage. The arms are protocol constants, deliberately asymmetric: slope 200 draining, 100 filling
kappaCovBps coverage wallConvex toll on coverage-declining output, only lever that scales the inventory defenseRequired >0 on every listed asset including the hub; requires haircutSuppressorBps == 0
minDispersionPbps / preset curveDepth near midTighter / more center-bumped = better UX, more size extractable per bp of gap
refBandBps / TTL haltCircuit breakersAvailability vs bleed

OEV (Oracle Extractable Value): update MEV at the push

Definition: MEV from ordering around the oracle update itself, not from slow between-push drift. Not “observed extractable value”. Glossary: OEV.

On any chain with a public mempool, the keeper push is a publicly visible transaction before it lands. Searchers can frontrun (swap before push), backrun (swap after), or pre-position on a predictable heartbeat schedule.

Primary controls:

ControlRole
minFeePbpsOnly pool param that reliably taxes both frontrun and backrun of an otherwise-honest push
Private / MEV-protected RPC for batchPushSignedMost direct OEV mitigation. Not part of the shipped keeper config: without a wired private-relay path, pushes reach the public mempool. Open infra gap, tracked as a launch prerequisite
Atomic push + solver auction returning bid to LPsStronger capture (not shipped)

Important trade-offs:

  • Lower θ → smaller jumps per event, but more events → OEV frequency can rise even as LVR per gap falls.
  • heartbeat_s is a primary LVR control and a net OEV aggravant (pure wall-clock predictability).
  • The deviation band and the signer quorum defend compromised-key abuse, not third-party reordering around a legitimate push.
  • STALE_Z does not protect the immediate post-push backrun (a resets to 0).

Parameter cheat-sheet (LVR vs OEV)

Parameter↓ LVR↓ OEVNotes
theta_bpsyes, primaryworse: more eventsPair with fee floor
heartbeat_syesworse: more predictableKeep τ/2
minFeePbpsyes, primaryyes, primary pool leverCompetitiveness cost
vegaBpsyes, if σ already uppartial post-pushNot a first-jump shield
kappaCovBpsconditionalconditionalInventory first; forces haircutSuppressorBps = 0
Tighter dispersion / center bumpworse: more extractable sizeworse: sameUX vs pick-off capacity
STALE_Zyes, past grace only0 on the healthy pathSecondary
Private push relayneutralyes, the strongest leverOps, not setAssetParams

Keeper θ and heartbeat are set per asset class, tighter θ and longer heartbeat for pegged assets, wider θ and shorter heartbeat for volatiles. They are off-chain keeper configuration, not on-chain parameters: the on-chain feed exposes maxDeviation and τ, which are readable by anyone. Align pool minFee with the mean- or hard-gate policy chosen for each class.


9. External Oracle V2 (packed-slot, next generation)

Status: superseded on Arc by V3 (§10) on 2026-08-31; V2 is the rollback. V1 (ExternalOracle.sol, the system above) is paused and retired on Arc. See §9.10. V2 keeps V1’s entire trust model (k-of-n EIP-712 quorum, monotonic replay guard, the volatility-adaptive deviation band, the στ staleness premium and guardian fast-freeze) and changes only the representation and cadence to cut per-feed gas and, with a tighter trigger, quote more competitively (less OEV). Interface IExternalOracleV2.sol.

9.1. Why V2

Push cost on an L2 reduces to

G=F+ΔFquorum+S·csstore

where F is the base transaction fee, ΔFquorum the signature-verification cost, S the number of storage slots written and csstore the per-slot cost. F and ΔFquorum are fixed per transaction, only S scales with feed count, and the L1 data fee is ~0.4% of the total, so calldata size is nearly irrelevant and the SSTORE count is the whole game. V2 attacks it on two axes: pack many feeds per slot (fewer SSTOREs per push), and push only feeds that actually moved (fewer pushes). The saving is spent on cadence: a tighter θ-cross trigger means fresher marks, a smaller staleness window, a smaller στ premium quoted, and therefore less OEV left on the table.

9.2. Mark representation: mark1e18, not B64

V2 drops B64 from the interface: IOracle.FeedData.mark1e18 is a plain 1e18 WAD. B64 remains only as V1’s internal storage encoding (§5).

On the wire and in storage, V2 uses a compact normalized binary float per feed (§9.3), decoded to a WAD by getFeed.

9.3. Storage model: the packed price slot

A price slot is one self-contained 256-bit word, no global header:

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

Each lane is itself packed:

mantissa23bexp5b
BitsFieldNotes
0..22mantissau23; MSB set = live, all-zero = STALE sentinel
23..27expu5

price = mantissa << (exp + expBias[feed]), where expBias is a static per-feed int8 held in the cold registry.

  • 8 feeds per slot, one SSTORE. 8×28+32=256 exactly.
  • 23-bit normalized mantissa + 5-bit binary exponent. Worst-case relative quantum is 2-22, about 0.0024 bps at the octave floor, a clean 2× under the 0.005 bps precision floor derived from the measured p50 inter-push move (0.011 bps). The mantissa MSB is always set for a live price, so an all-zero lane is a free STALE sentinel.
  • expBias (cold registry) places each feed’s ±16-octave dynamic window at its price scale, so a 10-6 alt and BTC each spend their mantissa bits efficiently. The 5 dynamic bits absorb a 231-fold price move before the bias needs changing.
  • The per-slot timestamp is the per-slot monotonic guard. Sharded senders pushing disjoint slots never contend, staleness is per-slot (FX doesn’t pay a premium because BTC hasn’t moved), and a narrow θ-push writes one slot, not slot + header.

σ and confidence live in separate σ-slots (32 log-σ codes per word), written only when a feed’s σ-code actually changes (dirty-write). On a typical push that is zero extra SSTOREs; σ is read at quote time to drive the premium and spread.

9.4. Positional identity: no tickerId on the wire

A feed is addressed by its position: globalIndex=8slotId+laneIdx. A cold registry maps the canonical feed id → globalIndex once at registration (registerFeed), and readers resolve identity there, never on the hot path. V1’s 8-byte tickerId per record is gone.

9.5. Lane assignment is a free parameter: cluster by heat and correlation

Because identity is positional, the feed→lane map is chosen, not incidental. Two objectives point the same way and are set together at registration:

  1. Fewer push SSTOREs. Co-moving feeds (BTC-beta, G10 FX, stables) share a slot, so a θ-cross dirties whole slots rather than scattered lanes: the common narrow push collapses from several SSTOREs to one. Derive the clustering from the tape’s return-correlation matrix in the same replay that tunes θ.
  2. Fewer swap SLOADs. Co-traded feeds share a slot, so a swap that reads two of them reads the second one warm. Measured on the reference implementation: a second read of a co-located feed costs ~2,000 gas less than a non-co-located one (one cold→warm slot delta), and ~2× that once the shared σ-slot is counted. This stacks on top of the transient cache (§7), which only dedups re-reads of the same feed; co-location warms different feeds sharing a slot, which the cache cannot.

For the majors these are the same set, so slot 0 is the “hot basket” (USDC, USDT, WETH, WBTC, cbBTC) and the most common swaps read both marks from one warm slot and push them in one SSTORE.

9.6. Lane 0 = the numeraire (per-slot convention)

Lane 0 of the hot slot is reserved for the numeraire. The numeraire’s mark (or its depeg reference) is on the hot path of essentially every swap, so keeping it in a fixed, always-first position means the slot that carries it is warmed early and stays warm for the rest of the transaction.

The stronger form, lane 0 of every slot dedicated to the numeraire, lets a swap on an asset in any slot read its asset and the numeraire from that one slot, saving a cold SLOAD on every off-slot-0 swap. This pays cleanly when the numeraire is a constant or near-constant peg (a USD unit at exactly 1e18, or a tight stablecoin): replicating it into each slot’s lane 0 is then nearly free, because it rarely takes a push and can carry a fixed value. When the numeraire is itself a live-priced asset, replication costs a lane and a per-slot write, so it becomes a per-deployment trade-off.

9.7. Signed push: same quorum, variable width

function batchPushSignedV2(bytes calldata blob, bytes calldata sigs) external returns (uint256 acceptedMask);

The blob is a 9-byte header followed by one fixed 100-byte record per slot touched:

version8bseq32bsourceTsDs32b
BitsFieldNotes
0..7versionu8
8..39sequ32
40..71sourceTsDsu32, deciseconds since the immutable epoch
slotId32bpriceWord256bsigmaWord256bconfWord256b
BitsFieldNotes
0..31slotIdu32
32..287priceWordbytes32, 8 lanes + slot ts
288..543sigmaWordbytes32, 8 x u32 pbps
544..799confWordbytes32, 8 x u16 bps

Records are variable in NUMBER, not in width, so a θ-cross narrow push carries only the slots that changed. There is no separate σ blob: σ and confidence ride the same records and land in their own slots only when the σ-code changes. The quorum signs keccak(blob) under the same EIP-712 domain construction as V1, and sigs stays opaque bytes over that digest, so a future aggregate scheme is a drop-in. Authenticity is carried by the payload, so any EOA may relay, which is what makes a nonce-sharded sender pool safe: shard senders by feed-group (never round-robin into a shared slot), each with zero nonce contention.

Per-slot monotonic guard, skip not revert. A record whose sourceTs is not newer than the stored slot ts is skipped (its bit stays clear in acceptedMask), never reverted. A losing race in a sharded fleet costs base gas, not a revert storm.

Every risk guard from V1 is retained on this path: the volatility-adaptive deviation band (§8.3), the σ-floor economic breaker, the future-timestamp and deadline rejects, and the guardian fast-freeze.

9.8. Config surface and the rebias flow

V2’s runtime config lane is tighten-only, guardian-or-owner, matching V1’s asymmetry:

  • updateFeed(feedId, maxDeviationBps, ttlSecs) reverts unless both values decrease.
  • pauseFeed is guardian-or-owner.
  • unpauseFeed is owner-only.

V2 carries no requestFeedWiden path at all: a deliberate widen is a re-registration, which is an owner action, so the recovery route of §8.3 reads differently on V2 and should be re-stated here before it deploys.

The expBias is cold config, set at registration and effectively never touched (a rebias needs a 231-fold price move: a redenomination or a near-zero de-peg). A dedicated setter exists for that rare event:

function setFeedExpBias(bytes32 feedId, int8 newBias) external; // guardian or owner

Because the bias governs decode, changing it would silently re-scale the value already in storage. So the setter invalidates that one lane atomically (it writes the all-zero STALE sentinel) and the feed reads fail-closed (swaps halt) until the keeper’s next push re-encodes it at the new bias. Only that lane is touched; co-resident feeds in the slot keep flowing, and the slot timestamp is preserved. The keeper gains no new job: it already reads the bias from the registry on each config sync, so the re-encode rides its normal push.

Open design question, not a documented policy. setFeedExpBias is _onlyGuardianOrAdmin in the reference implementation, which makes it the one guardian lever that writes a parameter rather than halting or tightening, contrary to the guardian invariant stated in Access Control & Roles §1. Its immediate effect is fail-closed (the lane goes STALE), so it is not a value-moving power, but the invariant and the code disagree and one of them must move before V2 deploys. Flagged for an owner decision; not resolved here.

9.9. Consumer reads: unchanged surface

getFeed(feedId) returns the same IOracle.FeedData (now with mark1e18), assembled from the packed price slot, the σ-slot and the cold config. Every consumer path (FeedMathLib.gate, Pricing, the depeg breaker, isFeedFresh) is unchanged, so the migration is a coordinated release of the oracle and the pool logic, not a rewrite of either.

9.10. Migration status

V2 is a redeployment, not an upgrade. Neither ExternalOracle nor ExternalOracleV2 sits behind a proxy and neither carries an upgrade path; the property is stated in-source as the reason the signer cap is 16 rather than 6 (Deployment & Upgrades §4.3). Cutover therefore deploys a new instance at a new address and re-points every consumer at it, per pool and per asset.

Where Arc stands, from the deploy artifacts under dex-evm/broadcast/ (Arc only; no other chain has a V2 instance):

StepState on Arc
1. Deploy ExternalOracleV2 with its own signer setDone (2026-08-29). Deployed via CREATE3; 26 registerFeed calls landed in the same broadcast, all receipts 0x1
2. Shadow period: keeper dual-pushes V1 and V2, marks are diffed, θ is tunedDone (2026-08-30/31)
3. Re-point each asset’s OracleConfig.primary through the timelocked UPDATE_ORACLE op at the BASE tierQueued, not executed (2026-08-29). 37 requestOp calls carrying the V2 address; each still needs its named executeOracleUpdate inside the grace window
4. Keep V1 pushed until every consumer has movedOngoing. V1 is the rollback
5. V3 cutoverDone (2026-08-31). Primary 0x0bef57B54631004Efc83636678cd95884C772ad4, reference 0x8523ce6EBc563b1C69aAE7558Eb775DfEE89Fbd0; repoints executed, session pushes live (§10.6)

Step 3 executed 2026-08-31: the beacon-wide pool upgrade and all 37 UPDATE_ORACLE repoints ran in one atomic window (both V1 instances paused for the crossing), landing every quote, gate and depeg breaker on the V2 pair: primary 0xcd7d5d0fCd08f08570D95bdd159eB148e453aB37, reference 0xebc298A8d2d98114C5b448EC9e1f96e176aBF0d5. The V3 cutover (§10) then executed the same day; V2 is now the rollback, fed in parallel per step 4’s discipline.

Step 4 is why the two oracles must be fed in parallel across the whole window: reverting a repointed asset means another UPDATE_ORACLE at the same delay, not an instant switch. There is no shared storage between the two instances, so no layout-collision concern arises.

9.11. Measured gas (reference implementation, 2-of-3 signed)

Execution gas only, from dex-evm/test/unit/OracleGasBench.t.sol. Reproduce with forge test --mp test/unit/OracleGasBench.t.sol -vv.

Push shapeGasGas/feed
Narrow: 1 slot, 3 co-moving feeds, slots warm24,2998,100
Narrow: same, slots cold42,30314,101
Full 8-lane slot, warm39,0804,885
Full 8-lane slot, cold67,0848,385
Cold first push, 11 feeds189,17117,197
Marginal changed lane in a slot already being written-4,959 cold, 2,960 warm
Marginal unchanged lane-0

V1 in the same bench costs 7,403 gas/feed at a 66-feed batch, cold. On Arc, where the deployed V1 is an older 24-byte-record revision, full transactions cost 13.7k-14.4k gas/feed at the batch sizes actually pushed, against 10.3k-11.6k for V2: roughly a 20-25% saving, not an order of magnitude.

10. External Oracle V3 (session-grant, diff wire)

Status: authoritative on Arc since 2026-08-31. Primary 0x0bef57B54631004Efc83636678cd95884C772ad4, reference 0x8523ce6EBc563b1C69aAE7558Eb775DfEE89Fbd0. Session pushes are live and all 37 repoints executed; V2 (§9) is the rollback. Superseded on BOTH tiers by V4 (§11) on 2026-09-01, where V3 is now the rollback and nothing else. The V3 primary keeper and its signer domain keep running so the rollback stays fed; the V3 reference keepers are retired (scaled to 0) now that no pool leg bands against 0x8523ce6EBc563b1C69aAE7558Eb775DfEE89Fbd0. V3 keeps V2’s consumer surface and every risk guard, and changes push authorization cadence and the wire/storage layout.

10.1. Why V3

Profiling a full V2 push transaction (~123k gas) put the two ecrecovers at only ~13.4k of execution gas: a small share. The dominant costs were per-lane config SLOADs on the hot path, the fixed 100-byte record regardless of what changed, and σ/confidence words rewritten when unchanged. V3 removes each:

  • Session grants amortize signature verification from per-push to per-session (§10.3).
  • Diff wire: 12-byte header + 4 bytes per feed price entries; σ/confidence travel only when changed (§10.4).
version8bseq32bsourceTsDs32bnP8bnS8bnC8b
BitsFieldNotes
0..7versionu8, = 4
8..39sequ32
40..71sourceTsDsu32
72..79nPu8, price entries
80..87nSu8, sigma entries
88..95nCu8, conf entries
gi8blane24b
BitsFieldNotes
0..7giu8, global feed index
8..31laneu24, top 2 bits zero

blob.length == 12 + nP*4 + nS*5 + nC*3. Sections carry strictly ascending gi, and a gi of u8 caps the instance at 256 feeds. When no σ entry is present the σ word is never even SLOADed, so the elision is total rather than just a calldata saving.

  • 22-bit lanes, 10 per slot, class-pure, and one config word per slot instead of per-lane config reads (§10.2).

10.2. Storage: 22-bit lanes, 10 per slot, 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

A V3 lane is narrower than V2’s, and re-split:

mantissa18bexp4b
BitsFieldNotes
0..17mantissau18; MSB set = live, all-zero = STALE sentinel
18..21expu4, 16 octaves

An 18-bit mantissa normalized to [217,218) gives a worst-case relative step of 2-170.076 bps, below the producer’s own ~0.15 to 0.3 bps quantisation grid, so the encoding is lossless at the grid it is fed.

Per-slot config collapses into ONE word, ten 25-bit lanes, which is what removes V2’s per-lane SLOAD from the hot path:

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

Slots stay class-pure, as in §9: a market-closed class darkens only its own slots. σ and confidence remain dirty-written in their own slots.

10.3. Session grants: trust model and bounds

The k-of-n quorum signs a SessionGrant{relay, expiry, maxSeq}; the contract verifies the grant’s signatures once, then gates pushes on msg.sender == relay for the session. Bounds:

  • Expiry ≤ 1 hour. A grant self-terminates; standing authority cannot accumulate.
  • maxSeq bounds the seq label a session may carry, not the number of pushes it lands. seq is a relay-chosen header field and is never stored or incremented on chain, so a session’s push count is bounded by its expiry alone. A hijacked relay’s blast radius is the ≤ 1 h window, the per-push deviation band and the reference band — not a push count.
  • Any-signer instant revoke. One signer kills a session in one transaction; no quorum needed to tighten, matching the tighten-fast asymmetry of §9.8.
  • Every blob is still quorum-signed. The contract emits the blobHash, so any party can verify the k-of-n signatures off chain from the event alone, with no chain trust; the session moves where verification happens, not whether.
  • Fully-signed push retained as a permissionless fallback: any address can land a quorum-signed blob with on-chain verification, exactly as in V2, so liveness never depends on the granted relay.

The monotonic prev-read replay guard, the σ-adaptive deviation bands, per-lane fail-soft (skip, not revert) and guardian fast-freeze are all retained on the session path. An “express” mode that moves the deviation bands to an off-chain watch-tower exists in code as an owner-decision artifact; it is not deployed, and the bands are live on every push.

10.4. Wire v4 summary

header(12) || priceEntry(4)×n [|| σ/conf entries, only-when-changed]. Feed identity stays positional; a θ-cross narrow push carries only changed entries. The keeper/NXR v4 wire arming on the producer side is the remaining cutover gate.

10.5. Consumer reads: unchanged

getFeed(feedId) returns the same IOracle.FeedData; every consumer path (FeedMathLib.gate, Pricing, the depeg breaker, isFeedFresh, the στ premium) is unchanged from §9.9. Cutover is again a repoint, not a rewrite.

10.6. Migration status

StepState on Arc
1. Deploy V3 pair (primary + reference)Done (2026-08-31)
2. Queue UPDATE_ORACLE repointsDone (2026-08-31)
3. Arm keeper/NXR wire v4, shadow-pushDone (2026-08-31); session pushes live
4. Execute repoints; V2 becomes the rollbackDone (2026-08-31); all 37 executed. stocksPool consumed a stale-target queue entry on the first pass, so its 12 repoints were cancelled, re-queued against V3 and executed after the timelock the same day. Verified on chain: all 11 stock legs quote live off the V3 pair

The serving stack (gateway, indexer) hot-reloads deployment records: an oracle redeploy propagates to every consumer service within 60 s with no rebuilds. The transparency page decodes V2, V3 and V4 blobs in the browser, and flags session pushes (§10.3) as such, so anyone can verify the k-of-n signatures on any push without trusting us or the chain explorer.

10.7. Measured gas

Foundry, EIP-7623-aware full-transaction gas per feed, 2-of-3 quorum, same bench methodology as §9.11:

design10 feeds16 feeds
V211,2088,899
V3 (session)5,1584,202
blind-SSTORE floor (unsigned, no guards)~2,800-

The floor row is the category’s physics: 21k intrinsic + one unconditional store + zero guards. It is a derived bound with an empirical check: two live prop-AMM price stores run exactly that construction on Base. ElfomoFi (0x099097bF1034B51C9eb8363c3f415C79F75ee289) pushes one word of six 28-bit lanes for a median 28,749 gas (n=5,995), and Metric’s CompressedOracleV1 (0x11502776659Da840EA5Fcbe88441C51f9e7B9dB4) pushes one slot of four 48-bit lanes for 29,767 (n=158) and two slots for 35,423 (n=2,073). Neither verifies a signature on the push path. A ~29k transaction spread over ten lanes is ~2.9k/feed, which is where the ~2,800 estimate comes from. The ~2.3k/feed delta above it buys the monotonic replay prev-read, session auth, per-lane fail-soft and the deviation bands; matching unsigned designs at n=10 means shedding replay protection, which is out of scope by policy.

10.8. Relay rotation & liveness

With the gas curve done (V1 ~11.5k → V3 ~5.2k/feed at 10, guards live), the binding constraint is liveness. Two relay-dry incidents over the 2026-08-29/31 weekend (~6 h of combined stale windows; post-mortem in the dex-evm runbook) both traced to the same shape: a single relay EOA as the only path on chain. Both failed closed: pools priced the staleness, then gated (§8.2), but a dark pool earns nothing.

Rotation, live since 2026-08-31. Five relay EOAs run on Arc: three on the primary oracle, two on the reference. Each replica holds its own key and opens its own session grant (§10.3). The leader for a push is deterministic, keeper_set[keccak(slot) % N], where slot is a 10 s wall-clock slot (ELECTION_SLOT_MS), not the blob’s sourceTs. Keying on sourceTs was the first design and it failed in production: replicas poll NXR independently and therefore hold different blobs (measured over 8 minutes, the three primary relays saw 39 / 54 / 70 due batches), so the leader elected for a blob was routinely a replica that had never fetched it, could never land it, and every standby failed over seconds later. A wall-clock slot lets every replica agree on who leads now without agreeing on what data it holds. Standbys arm at relay_fallback_ms = 8 s, staggered relay_jitter_ms = 4 s per index, and relay only if the leader’s push has not landed by then, so steady state emits exactly one transaction. That removes the single-relay-dry outage class.

Trust note. Leadership is a liveness mechanism only: every blob remains k-of-n quorum-signed and every guard of §10.3 applies to every relay identically. The contract stores one session at a time (_session: relay, expiry, maxSeq, nonce), so replicas do not hold concurrent grants: a relay that is not the current session holder relays on the fully signed pushSignedV3 path instead, which is the same fallback that covers expiry and revocation. Instant revoke by any granted signer, the guardian or the admin is unchanged.

Relay wallets are keeper-funded gas rows: self-refilling, no manual top-ups.

10.9. Push triggers: deviation measured against the spread it defends

Sending fewer pushes is the larger lever: a push that carries no price information costs the same as one that does.

What we measured (Arc, 2026-08-31, 45-minute tape, 68 batches).

triggershare of pushesavg feeds carried
heartbeat50%11.4
theta_cross35%9.4
ci_spike13%11.7
cold_start1%26

Half the fleet’s gas bought pure liveness. The cause was structural, not a mis-set number: 26 feeds each ran an independent 240-300 s heartbeat clock, so between them they forced a blob roughly every 11.5 s no matter how still the market was.

The threshold was not the thing it defended. A live read of getSwapQuote(USDT→USDCB) returned spreadPbps = 601, which decomposes exactly:

minFee 61 PBPS (0.61 bp) sigma x vega 40 PBPS (0.40 bp) confidence CI 500 PBPS (5.00 bp) ------- 601 PBPS (6.01 bp)

Against that, the feed’s configured deviation threshold was a static 0.257 bp: it fired at roughly 4% of the edge a taker actually has to cross. At the other end, PAXG quoted a 20 bp fee floor and fired at 5 bp. One hand-maintained number per feed cannot track a quantity that varies per asset and per market state.

The rule. Push when the mark moves past a fixed share of the edge the pool is currently quoting for that leg, so the keeper mirrors Pricing._pathSpread per leg:

Ei(t)=minFeei+σi·vegai100·BPS+CIi·PBPSBPS+Z·σimax(0,τi-gi)BPS

and pushes feed i when |mnow-mchain|κ·Ei(t), with τi the age since last push, gi=min(ttli/2,30s) the keeper grace, and κ=0.5 on Arc.

Three properties follow, none of which needs tuning:

  • Per-asset by construction. A stable quoting 1.0 bp pushes at 0.5 bp; an equity quoting 15 bp pushes at 7.5 bp. The 26-row threshold table collapses to one constant.
  • Self-correcting. When a fee floor or a risk parameter changes, the boundary moves with it. There is no second place to update, and no way for the two to drift apart: the trigger reads the same Asset row the pool prices from.
  • Staleness-aware in the right direction. As a leg ages past the grace window the contract already charges Zστ for that age. The boundary widens by exactly that amount, so the keeper does not pay for a second push to defend ground the quote has already sold.

The confidence term is deliberately excluded from the trigger basis, though it is included in the pool’s quote. NXR’s confidence is currently a freshness proxy rather than a dispersion measure, and folding a freshness proxy into a push boundary inverts the trigger: a feed whose data has gone stale reports higher CI, quotes a wider edge, and would therefore push less, precisely when it should push more. Measured: including it moved USDT’s boundary from 0.5 bp to 3.0 bp on a CI reading no observed dispersion justified. Excluding it makes the keeper claim less protection than the pool charges, so the error is over-pushing rather than under-defending. It is switched back on when confidence is redesigned onto cross-venue dispersion.

Heartbeat coalescing. The liveness ceiling stays, but one heartbeat now refreshes every seeded leg in the same blob, so all clocks restart together. The fleet costs one blob per shortest-heartbeat period instead of one per feed per period; on the V3 diff wire the marginal leg is ~4.2k gas, which is cheaper than the second blob it avoids. A price cross does not coalesce: it carries the movers and their riders only.

A frozen record gets no heartbeat. When mark, σ and confidence are all bit-identical to what was last relayed, the source has given us nothing and the push would write a timestamp only. It is suppressed: the feed ages out and consumers fail closed on the staleness gate (§8.2), which is the correct outcome for a market that is not trading. Bit-identity is a deliberately strict test; any live tape moves the 22-bit lane mantissa within a heartbeat (USDT-USDC crossed 0.99974 → 0.99979 inside minutes), so only a genuinely frozen feed, a closed equity over a weekend, goes dark.

Rate limiting: budget, not refractory. The old gate was a fixed 36 s per-feed refractory, derived from a 100-pushes-per-hour cap. It enforced the cap by spacing pushes evenly, which meant a fast-moving feed could not be re-marked however far it ran, which is the source of the extractable-value gap this work started from. The same cap is now a trailing-hour token bucket: a feed holding budget may re-push after burst_gap_s (1 s on Arc; the floor exists because V4 admits one accepted write per slot per source second, so two pushes closer than that would have the second silently skipped), and falls back to the full min_push_gap_s once its tokens are spent. Worst-case hourly spend is unchanged by construction. A scarcity multiplier widens the boundary as the budget drains (1.0 while more than half the budget is left, rising to 2.0 at empty), so the last tokens of an hour are spent on the largest edges rather than on whatever happened to move first.

Two caps, and they are different objects. CADENCE_CAP_PER_H = 100 is per asset: each fitted per-asset θ was bisected to land at 100/h on its own tape, so it bounds one feed, not the fleet. manifest_cap_per_h = 360 on Arc bounds whole blobs and is an average over a trailing hour, not a minimum spacing: nothing between individual blobs is enforced except burst_gap_s.

Selection. A push carries the feeds that fired plus riders: feeds already within 60% of their own boundary, which will fire within a tick or two anyway. Riders were previously selected by proximity to maxDeviationBps, which is a revert guard roughly 200× the boundary on a stable and carried no information about whether a feed was about to fire. σ-seeded and σ-unseeded feeds are still never mixed in one blob (§9).

Cadence in context. Measured across the rotation fleet from relay nonce deltas: 260 transactions/hour with the trigger, coalescing and corrected manifest cap live, against 188/h beforehand (the first pass, run with the manifest cap still mis-set, sat at 123/h). The count rises rather than falls, which is the intended direction: the fixed refractory spent its budget on evenly spaced heartbeats, and the bucket spends a larger budget on the moves that cross the edge. The bound is hourly spend, not cadence. A competitor pushing bid and ask directly at ~1 Hz runs ~3600/hour. Because BTR computes both sides on-chain from a mark, the mark only has to be right to within the spread quoted around it, hence the 5% relative push rate.


11. External Oracle V4 (29-bit lanes, cyclic clock)

Status: authoritative on BOTH tiers on Arc since 2026-09-01. Primary 0x842c2736F072A8A7b523D23bd3Ef21F21AC24d5C, reference 0xC17920b2cC4Ac028c7F8bdB46E952Fb2d2a172a6, 26 feeds each, wire v5. All 37 primary UPDATE_ORACLE repoints executed, none skipped; V3 (§10) is the rollback.

The REFERENCE tier moved the same day. refPrimary is the V4 reference on all 37 spoke legs (§11.9). The V3 reference keepers are retired; the V3 primary keeper keeps running as the rollback feed.

V4 changes exactly two things about V3 and touches nothing else: the price word (fewer, wider lanes) and the timestamp field (cyclic, no epoch). Sessions, the diff wire, the σ/confidence words and their elision, fail-soft, deviation bands and the k-of-n quorum model are V3’s, carried over unchanged. The consumer surface is byte-identical, which is why the cutover is a BASE-tier repoint and not the 6 h UPGRADE tier a read-ABI change would force.

11.1. Why V4

Two defects in V3’s price word, both surfaced by measurement:

  1. The lane is too coarse for the spread we now intend to quote. V3’s step is 2-170.076 bps, sized against ~5 bp quotes. Against the 0.2 bp one-way stable spread it is ±19% of the half-spread.
  2. The timestamp has a hard end date. V3 stores ts:u32 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.

11.2. Storage: 29-bit lanes, 8 per slot

lanes232bts20bdayMod2bunused2b
BitsFieldNotes
0..231lanes8 x 29b
232..251tsu20, deciseconds since midnight UTC
252..253dayModu2, source day mod 4
254..255unused2b
mantissa25bexp4b
BitsFieldNotes
0..24mantissau25, MSB set = live, all-zero = STALE sentinel
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. exp:u4 is retained deliberately: 16 binary steps around a per-feed bias is ~65,000× of dynamic range for one asset, and a fifth exponent bit would cost a mantissa bit for range no feed uses.

LANES_PER_SLOT 10 → 8; slots stay class-pure. The 26-feed Arc manifest therefore needs five slots where V3 needed four: the 10 equities fill a V3 slot exactly and overflow a V4 slot by two. The equity split is 5/5 rather than 8/2: both equity slots are written by every equity push either way (one market session, one cadence), so an even split costs nothing and leaves head-room in both.

Per-slot config keeps V3’s shape at 8 × 25-bit lanes (maxDevBps u16, expBias u8, paused 1b). σ is stored 8 × u24 at 16-pbps granularity (stored = ceil(pbps/16), read = q << 4; the ceiling means stored σ never understates the attested one) and confidence 8 × u16. Wire σ stays u32 pbps.

11.3. The exponent window and expBias

The encoder rescans the exponent on every push and renormalises the mantissa into its window with the MSB always set, so delivered precision is always maximal for the feed’s current magnitude. expBias does not affect precision; it only positions the 16-step window over the asset’s range.

  • V3 used one bias per encode class (stable 34, FX 30, volatile 47, equity 43), so each feed sat wherever its magnitude landed inside its class window. Measured on the live fleet: EURC at e=13 of 15 (4× upward head-room), AUDF/QCAD/WBTC at e=12 (8×), KRW1 at e=2 (4× down). Re-centring ran through setFeedExpBias, a manual guardian call.
  • V4 derives the bias per feed, expBias = bit_length(mark1e18) - 32, which pins e=7 for every feed: 8 exponent steps up (256×) and 7 down (128×) before a rebias is needed. Verified against production, not asserted: piping a live signed quote (/v1/quote/signed?domain=arc-v4&version=5) through the SDK’s decodeBlobV5 reports every price entry at exponent 7, and sdk/scripts/decode-live-v5.ts exits non-zero otherwise. Do not pin the entry count: a feed with an unavailable mark is excluded from the blob, so consecutive samples carried 24 then 22 entries; the exponent is the invariant. On the deployed contract expHeadroom(EURC) reads (8, 7) against (2, 13) under V3’s class bias.

expHeadroom(bytes32) returns (uint8 stepsUp, uint8 stepsDown) is the decide-read for rebias upkeep: no state and no producer-side inference. A lane at the STALE sentinel returns (0, 0).

Quorum-signed rebias, and why not a role

BiasUpdate(bytes32 feedId, int8 newBias, uint48 expiresAt, uint16 nonce) function setFeedExpBiasSigned(bytes32 feedId, int8 newBias, uint48 expiresAt, uint16 nonce, bytes calldata sigs) external; function setFeedExpBias(bytes32 feedId, int8 newBias) external; // break-glass: guardian or admin

Decode is mark = mant << (exp + bias), so a bias write is a price write: whoever sets the bias moves the published mark by a power of two. A dedicated “minimal” steward role was considered and rejected: it 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 can never be redirected at another feed or have its bias substituted, expiresAt capped at MAX_BIAS_GRANT_SECS = 3600 s (a signed bias change must not be holdable for later use), and a strictly-incrementing biasNonce spent on use.

_rebias stamps the slot clock, so a blob built under the old bias but relayed after the change loses the monotonicity comparison rather than landing an old-bias mantissa to be decoded under the new one. This resolves the V2-era open question of §9.8: the routine path is the quorum, not the guardian, and the guardian call survives only as break-glass.

Automatic re-centring is built and still deliberately NOT enabled (rebias_autosend = false on both keeper tiers; the keeper detects head-room loss and pages rather than relaying). The k-of-n signing path is complete end to end, and the producer-side blocker that used to hold it is gone: expBias has been lifted OUT of NX Rates’ lane_map_hash cosign commitment. That hash now covers (globalIndex u16, symbol) only, the per-domain lane map is keyed by idx, and the bias travels as a per-gi declared_bias that each replica checks against its own chain-read value at cosign time. A disagreement now costs the one lane it names, not the domain: only blobs carrying that feed are refused, every other feed still reaches quorum, and both replicas re-read the chain immediately. See core/src/server/signed.rs (lane_map_hash, declared_bias, lanes_by_gi) and the log-sourced BiasStore in core/src/server/chain_bias.rs.

The flag stays false for two current reasons, neither of them the old commitment:

  1. The bias poller is not yet reading chain reliably. The producer folds FeedExpBiasUpdated(bytes32,int8) and FeedRegistered logs at DEFAULT_LOG_CONFIRMATIONS = 12 confirmations. The signer pods hold keys and are deliberately denied direct internet egress, so they reach the RPC through a newly deployed in-cluster egress proxy. That proxy is deployed and verified, but the poller is currently rate-limited (HTTP 429) by the upstream and is holding last-known values.
  2. A cold-start fail-open remains. A feed whose bias has never been read successfully from chain is signed under the CONFIG bias, and the staleness metric filters on the from_chain flag, so it does not count such a feed at all. A fix is in progress. Until it lands, arming would risk a silent one-exponent-step (2×) mispricing on a signer that restarts after a rebias has landed.

11.4. Cyclic clock, no epoch

ts is deciseconds since midnight UTC, range [0,864,000), u20. There is no EPOCH immutable and therefore no end date; absolute time is always derived from block.timestamp. Deciseconds rather than milliseconds because ms-since-midnight needs 27 bits and would drag the lane back to 28 (0.0024 bps); 100 ms already beats Arc’s ~540 ms block time and is what makes two pushes inside the same second orderable under burst_gap_s = 1.

Replay guard: reconstruct, bound, then compare. V3 compared the raw stored ds field, which is invalid on a wrapping field: it bricks at midnight and, worse, lets a wrapped-stale value read as newer. V4:

  1. reconstructs absolute seconds by picking the nearest candidate day (unambiguous for any true age under ±12 h);
  2. rejects if the reconstruction falls outside [now-MAX_RECON_AGE,now+SOURCE_TS_FUTURE_SKEW_SECS], raising StaleTimestamp / FutureTimestamp;
  3. only then compares reconstructed values for per-slot monotonicity.

Step 2 is the whole security argument. Without it a stale ts that has wrapped reconstructs as newer than it is, precisely the direction that admits a replayed blob. MAX_RECON_AGE = 6 h sits 3× above the longest deployed ttl (7,200 s) and 6× above MAX_HEARTBEAT_S (3,600 s), and a clean 2× inside the 12 h ambiguity bound. It is a constant, not a per-feed bound: registerFeed checks only ttlSecs != 0, so a ttl above 21,600 s would put the fail-closed sentinel back inside its own window. Every deployed ttl is 600 / 3,600 / 7,200 s. The stored-side predecessor is reconstructed under the same bound; one that falls outside it is treated as absent rather than allowed to brick the slot, which cannot admit a replay because only blobs ≤ 6 h old reach the comparison at all. Read side (getFeed, isFeedFresh) applies the same bound and fails closed, reporting the feed as older than MAX_RECON_AGE rather than handing a consumer a falsely fresh observation time.

dayMod:u2 closes a fail-OPEN. The ±12 h window alone leaves one case: 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. Every write tags the source day mod 4 and every read recomputes the candidate day from the reconstruction and rejects on mismatch; it never searches for another candidate day. An alias then requires the stale value to be an exact multiple of 96 h old (16× MAX_RECON_AGE, ~48× the longest deployed ttl); the two spare bits at [254,256) would push it to 16 days if wanted.

The tag MUST come 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 D-1: the tags mismatch and a live feed reads falsely stale at every midnight. test_dayTag_writtenFromSourceDayNotBlockTimestamp asserts it.

dayMod is derived on chain and is not on the wire; wire v5 is unchanged by it.

11.5. Wire v5

Header is 11 bytes: ver:u8(=5) | seq:u32 | sourceTsDs:u24 (u20 value, zero-padded) | nP:u8 | nS:u8 | nC:u8. Price entry becomes 5 bytes, gi:u8 | lane:u32 with the top 3 bits zero. σ (5 B) and confidence (3 B) entries are unchanged, sections stay strictly-ascending gi, and gi:u8 still caps the instance at 256 feeds.

blobbytes=11+5nP+5nS+3nC

Golden vectors are pinned byte-exact in all four codecs: Solidity, keeper Rust, NX Rates Rust, SDK TypeScript.

11.6. Producer grid: the upgrade nullifier

grid_mask_bits is relative, so a flat mark grid masks a fixed fraction of the mantissa and gives back 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. Shipping the V4 lane against a flat grid buys exactly nothing.

The grid is now wire-aware and derived from measured 30-minute realized volatility rather than from a fee: grid_bps = clamp(quantized_sigma_pbps / (K · scale), 0, mark_grid_ceil_bps), with K = 100 and a v5 divisor scale of 128. Delivered precision is max(lanestep,grid): on USDT-USDC at its live σ of 288 pbps, 0.000596 bps at wire v5 against 0.0763 bps at v4, with an identical mask profile across every class (stables 0 bits, NVDA 1, EURC/WETH 2), so the diff-wire elision of §10.4 is unchanged. Applying a v4-era divisor at 25 mantissa bits would have masked 9 bits on volatiles and 5 on stables, all but 4× of the 128× handed straight back.

The floored-σ dependency, and what closed it. A σ-derived grid must never consume the class prior: a floored σ would quantise stables as if they were BTC. Two things stand between it and that. Structurally, where the σ view is degraded the grid refuses it and falls back to the flat DEFAULT_MARK_GRID_BPS, incrementing nxr_signed_mark_grid_fallback_total; a persistently non-zero counter means V4’s lane is buying nothing for the affected feeds. Empirically, the class floors were recalibrated against 17.6 days of tape (FX 2000 → 250 pbps, binding on 99.7% of observations before; crypto 4000 → 800, 84.5%; commodity 2500 → 1400), and every feed on chain now carries a measured σ. The one exception is USDC-USD at 208, on its floor because it is a genuinely pegged pair, the case a backstop exists for.

11.7. Measured gas

dex-evm/test/unit/OracleGasBenchV4.t.sol, EIP-7623-aware full-transaction gas per feed, 2-of-3 quorum, both designs benched side by side. Reproduce with forge test --mp test/unit/OracleGasBenchV4.t.sol -vv.

shapeV3 sessionV4 sessiondelta
26 feeds (Arc 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%

Arc forum table’s “V4 5,158” is the V3 row here (10 feeds, 2-of-3). The delta is a storage slot and only a storage slot: intrinsic and calldata amortise identically, and where the feed count does not cross a slot boundary the cost is inside 1%. V4’s signed fallback is 7,260 at 10 feeds and 4,338 at 26; the quorum share of a V4 signed push is 13,382 gas of execution (38,475 against 25,093 with the check stubbed).

Calldata: the fear was wrong, and it was measured. On a 26-minute tape of 1,905 snapshots at ~1.2 Hz of live NX Rates marks across all 26 Arc feeds, widening the mantissa costs +1.9% entries per push (20.56 → 20.94 at a 10 s gap, per-asset grid). The diff is already saturated at any realistic push gap: 21 of 26 feeds move within 10 s, 24 of 26 within 40 s. Almost all of the +9.9% is the extra slot plus one byte per entry, not the diff.

11.8. Express mode

An immutable per-chain constructor flag removes the on-chain deviation band and the σ economic floor from the push path, moving the safety bound to off-chain watch latency: a compromised session relay or quorum could then move any feed to any value in one push. Express keeps the reconstructed-and-bounded monotonic ts, the STALE-sentinel ban, unregistered/paused fail-soft, session expiry ≤ 1 h and any-signer revoke. Every deploy path sets it false, guarded. It exists in code as a recorded owner decision, not an option offered to ceremonies.

11.9. Migration status

StepState on Arc
1. Deploy ExternalOracleV4 + register 26 feeds across 5 class-pure slotsDone (2026-09-01), CREATE3, 0x842c2736F072A8A7b523D23bd3Ef21F21AC24d5C
2. Read-surface parity pre-flight (readSurfaceParity()) against the outgoing V3 primaryDone, required to keep the repoint on the BASE tier
3. Arm keeper/NX Rates wire v5, shadow-pushDone (2026-09-01); pushes live, every price entry in a decoded live blob at exponent 7 (§11.3)
4. Queue 37 UPDATE_ORACLE repoints (BASE tier, 2 h delay, 7 d grace)Done (2026-09-01), 37/37 requestOp landed
5. Execute repoints; V3 becomes the primary rollbackDone (2026-09-01), executed: 37, skipped: 0. Verified: status() reports the V4 primary, every leg reads fresh, and all four pools quote off it: USDT→USDCB 0.815 bp one-way (spreadPbps 163), WETH 2,446.95, WBTC 78,011, NVDA 219.37, EURC 1.1587
6. Reference tier cutover to a V4 ref instanceDone (2026-09-01). Contract deployed (0xC17920b2cC4Ac028c7F8bdB46E952Fb2d2a172a6, 26 feeds); the 37 REF_ORACLE repoints were queued at 16:51:52Z-16:52:58Z, matured 2 h later and executed. Verified by counting 37 OracleUpdated(address,address) events on the Admin 0x35BB3BBeB86c7caee532083DAC639400912C8f00, not by trusting the script’s own report. V3 (0x8523ce6EBc563b1C69aAE7558Eb775DfEE89Fbd0) is the rollback and its reference keepers are scaled to 0
7. Transparency page v5 armDone. sdk/scripts/gen-oracle-lanes.py emits the v5 arm for ExternalOracleV4, once per instance with role: 'primary' | 'reference', and /oracle decodes wire v5: it resolves the wire from the generated lane map, skips the EPOCH read that wire has no field for, and dates v5 records off the cyclic clock via decodeBlobV5

Execution is deliberately per-leg rather than atomic: executeOracleUpdate validates only that the new primary answers getFeed, not that the feed is fresh, so a stale leg executes fine and the pool then fails closed at swap until the keeper pushes. The runbook gate is operational, not on-chain. A leg never pushed on V4, or whose last V4 push is over 6 h old, reads stale, correctly, under the cyclic clock of §11.4, and must not be force-executed past unless the V3 leg is equally dark.

Round two proved that per-leg design in the least comfortable way. The first executeAll broadcast landed 15 of the 37 legs and aborted on a nonce collision, the deployer key being shared with another process; the remainder were executed per pool, and the final four (cryptoPool USDC, USDT, WETH, WBTC) by direct executeOracleUpdate calls. Under vm.startBroadcast, forge queues every external call as a transaction, including already-executed legs whose reverts the script catches internally, which poisons the batch: a batch runner over a timelocked op set must therefore filter executed legs before broadcasting, not swallow their reverts. The half-applied state was harmless because both reference instances were fresh at the time, so every leg priced identically whichever one it pointed at. That property is what makes a per-leg cutover safe, and it is a precondition to check, not an accident to rely on.