---
title: "Oracle Keeper"
description: "The mark-pushing role end to end: what the relay key is and is not, the k-of-n signer quorum and its ceremony, push guards, TTL and bands, the reference tier, plus the feed and wire reference the role depends on."
audience: tech
type: reference
status: live
lang: en
updated: "2026-09-17"
publish: true
aliases: [3-4-oracles, 3-6-oracle-price-push-security, 3-10-oracle-keeper-operations]
---
# Oracle keeper

One page for everything the mark-pushing role touches: the relay key's authority, where prices come from, the on-chain k-of-n quorum that is the consensus, the whitelisting ceremony, the per-push guard chain, the operator runbook for `btr-keeper oracle`, and the feed, wire and generation reference the role depends on. It is long by design: the trust chain, the guards and the wire cannot be reasoned about separately.

| Section | Covers |
|---|---|
| [1. The role](#1-the-role) | What the relay key can and cannot do; where marks come from |
| [2. Threat model](#2-threat-model) | Oracle-manipulation classes and what resists them |
| [3. Quorum](#3-quorum) | The on-chain signature check, the digest, access control, scheme rationale |
| [4. The whitelisting ceremony](#4-the-whitelisting-ceremony) | Genesis bounds, grant/revoke asymmetry, quorum changes, reference disjointness |
| [5. Push guards, staleness and bands](#5-push-guards-staleness-and-bands) | The guard chain, per-push vs cumulative bounds, TTL, deviation bounds, LVR and OEV |
| [6. Keeper operations](#6-keeper-operations) | Triggers, both tiers, startup gates, reverts, liveness guard, change discipline, escalation, checklist |
| [7. Feed data reference](#7-feed-data-reference) | Oracle modes, `FeedData`, storage layouts, push API, B64, consumer reads, transient cache |
| [8. Oracle generations](#8-oracle-generations) | V2, V3, V4 storage, wire, gas and migration state |
| [9. Transparency: verify it yourself](#9-transparency-verify-it-yourself) | Re-verifying the whole chain client-side |
| [10. Related](#10-related) | Neighbouring pages |

---

## 1. The role

For the operator running `btr-keeper oracle`, the process relaying NX Rates-signed marks into the oracle instance (`ExternalOracleV5`, or V4 where still deployed). Metrics: [Observability](/docs/3-3-observability).

### 1.1. What the key is, and what it is not

The signed push path (`push(blob, sigs)` on V5, `pushSignedV4(blob, sigs)` on V4, `batchPushSigned` on V1) has **no sender check**. Authority is the k-of-n signature set recovered from `sigs` against the granted `signers` mapping; `msg.sender` is unpermissioned and pays gas.

V4's session path `pushV4(blob)` pins `msg.sender` to the relay named in a quorum-signed `SessionGrant` and verifies no signature beyond that: while a session is open the relay authors marks in-band, bounded by the per-lane deviation band. The grant is revocable by any one signer, but the honest keeper re-grants within ~30 s, so the keeper's key is not only a funding key. It must not be the deployer, the owner, a guardian, or an NX Rates attester.

**Nothing enforces that separation.** No startup gate compares the relay address against `AccessControl.owner()`, the guardian set or the signer set. It is an operator obligation, and on Arc today it is violated: the owner EOA is also the live push sender. A keeper-host compromise there is an owner-key compromise. Rotate the relay key to an address that holds no role.

| The relay key can | The relay key cannot |
|---|---|
| Land a validly co-signed blob, paying gas. With an open session it also authors a mark: `pushV4` verifies no signature, only `msg.sender == relay` | Author a mark without an open session. It holds no attester key and the `pushSignedV4` path verifies signatures, not the sender |
| Choose when to push and which feeds ride along | Land a lane outside the deviation band, or a slot at a source second the slot has already written. Both fail per lane or per slot rather than reverting the blob |
| Stop pushing, which halts nothing but ages every feed toward its TTL | Pause, halt, widen, or un-halt anything. Guardian and owner levers ([Guardian](/docs/3-1-4-guardian)) |

Losing the relay key is a liveness incident only while no session is open. With one open it is also a price incident, bounded only by the per-lane deviation band. The keeper re-grants a revoked session within ~30 s, so the durable cutoff is `revokeSigner` dropping the set below `signerThreshold`, not `revokeSession`. A compromised attester quorum is bounded by the reference feed and the guardian, not by relay-side care.

### 1.2. Where prices come from

BTR does not acquire or aggregate prices. It consumes attested marks from high-frequency off-chain providers and re-verifies them on chain. Two supply the roster today, **NX Rates** and **Pyth**: both aggregate venue tape off chain at high frequency and attest the result, both relay through the same signed path, and they are used interchangeably, chosen per feed on coverage and cadence rather than rank. Neither is privileged by the contracts: same `IOracle` read shape, same chain-side checks, and a pool points at whichever `primary` its config names. A new provider must supply a mark, a volatility estimate, a confidence interval and a source timestamp, land inside the feed's TTL and survive the per-push deviation band.

Freshness bounds are declared per leg: sub-second for a liquid CEX-backed leg, around a second at Pyth cadence, as much as a minute for a thin metal token. Each leg carries its own `sourceTs`, so a slow leg loosens only its own staleness gate.

NX Rates is detailed here because BTR governs its quorum on chain. It is a generic multi-exchange market-data provider: it owns every venue connection, aggregates a multi-venue mark plus Parkinson volatility and a [confidence](/docs/glossary#confidence) interval, and co-signs the result. BTR is a **client** that consumes signed quotes and re-verifies them on-chain.

The [quorum](/docs/glossary#quorum) that signs a quote lives inside NX Rates. Each signer replica holds its own key on its own node. The replica serving a quote builds the blob from its live aggregated view, self-signs, and proposes it to its peers. **A peer countersigns only after re-validating every record against its own independent market view** (price tolerance, timestamp skew, volatility/confidence understatement). Below quorum, no quote is served at all. A signed batch reaching the chain therefore means k independent processes, holding k independent keys, each independently agreed this price was correct at this time.

Price acquisition, the aggregation method, the signing scheme, the `/v1/quote/signed` endpoint, blob field semantics, key management and plan tier are **NX Rates internals**, documented by NX Rates (NXR) and not reproduced here: see NXR's signed-quote specification (provided to integrators on request). That citation is one-way: BTR points to NX Rates, NX Rates does not depend on BTR. What follows is on-chain BTR only.

---

## 2. Threat model

### 2.1. Oracle-manipulation classes

`EXTERNAL` (recommended) quotes any `IOracle` mark, on-chain adapter or push. `INTERNAL` is the cash-collateral $1.0$ peg helper only ([§7.1](#71-overview)). The ceremony below covers the NX Rates off-chain push: one EXTERNAL option, not a protocol-wide prerequisite. A pool on EXTERNAL with a fully on-chain adapter carries none of this trust.

Failure modes that have repeatedly drained [oracle](/docs/glossary#oracle)-priced venues:

| Vector | Mechanism |
|---|---|
| Single-key compromise | One stolen producer key pushes an arbitrary mark, the pool prices off it, an attacker swaps the drained side. |
| Gradual walk | No single push looks unreasonable; a compromised producer walks the mark in many small steps until collateral is gone. A per-push sanity band leaves the sum unbounded. |
| Future-dated report | A far-future timestamp clears the replay guard once, then freezes the feed permanently at a stale price to be picked off. |
| Volatility understatement | A producer signs $\sigma = 0$ to collapse the pricing spread, then round-trips against a spread-free mark. |
| Source manipulation | No key stolen, no producer misbehaving: an attacker moves a majority of the venues the composite is built from, and the quorum faithfully signs an aggregate of manipulated inputs. Nothing downstream can tell it from a real move. The guards below bound how fast that mark walks the pool, never whether it is right; the defenses are off-chain and economic ([Security Overview](/docs/3-overview)). |

Design goal: track a real crash faithfully, never brick, and never let one or few compromised keys one-shot or slow-walk the mark.

### 2.2. 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 $10\,d_{max}$ ([§5.4](#54-deviation-bounds)), 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 ([§5.4](#54-deviation-bounds)). Both are safe-direction levers: halting or tightening never loosens.

   **The pause does more than set the bit** (source since 2026-09-03; the Arc V4 pair predates it). `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. So the whole pause window reads DEAD, not merely halt-bit-gated, and a consumer reading `getFeed` without `gate` gets the gated answer. Re-entry is banded over the real gap since the anchored mark was observed, so the allowance grows with the pause ($d_{max} + Z\sigma\sqrt{\Delta t}$) instead of collapsing to one cadence. σ is kept: the re-entry band is built from it.

   The slot clock is deliberately not stamped, since a pause changes neither the encoding nor the admissibility of a pre-pause blob, so slot-mates keep their own $\Delta t$. A pause on an already-paused feed is inert: the lane is already dead, so the anchor is not overwritten. `unpauseFeed` lifts the bit onto an already-dark lane. Past the $\Delta t$-independent $10\,d_{max}$ ceiling the release is still `requestFeedWiden` / `executeFeedWiden`.

   **What the bit alone did.** `_applySlot` counts a paused lane's entries as accepted while never writing the lane, and there is one clock per slot, so the slot timestamp advanced all through the pause: from the paused lane's own entries and from every live slot-mate. At `unpauseFeed` the feed then reported the frozen pre-pause mark at age ~0: gate passed, $\sigma\sqrt{\tau}$ staleness premium identically 0 inside the grace window, the whole pause-window move unpriced until the next accepted push landed. That correcting push was banded over one cadence rather than over the pause, so a move past $d_{max} + Z\sigma\sqrt{\text{cadence}}$ was refused and the release itself could wedge the feed it was meant to hand back.

---

## 3. Quorum

### 3.1. The on-chain quorum is the consensus

There is no separate consensus protocol between BTR and NX Rates. **The consensus is the on-chain signature check.** The oracle instance (`ExternalOracleV5`; V4 and the retired V1 hold the same fields) holds the authoritative registry:

| State | Role |
|---|---|
| `mapping(address => bool) signers` | The NX Rates attester whitelist. Only granted addresses count toward a quorum. |
| `uint8 signerThreshold` | $k$: distinct granted signatures a batch must carry (2 in current deployments). |
| `uint8 signerCount` | $n$: live granted-signer count (`executeSignerGrant` / `revokeSigner` bookkeeping). |

`push(bytes blob, bytes sigs)` is the one push path on V5 (`pushSignedV4` on V4, `batchPushSigned` on the retired V1; same authorization on all three). Authorization:

1. `sigs` is $k$ concatenated 65-byte recoverable [ECDSA](/docs/glossary#ecdsa-elliptic-curve-digital-signature-algorithm) signatures over one [EIP-712](/docs/glossary#eip-712-typed-structured-data-hashing-and-signing) digest (the digest of $\text{keccak256}(\texttt{blob})$), with $k = \lvert\texttt{sigs}\rvert/65 \ge \texttt{signerThreshold}$.
2. Each signature is recovered against the same digest. Every recovered address must be a granted signer **and strictly greater than the previous recovered address**:

   ```solidity
   address rec = ECDSA.recoverCalldata(digest, sigs[off:off + 65]);
   if (rec <= prev || !signers[rec]) revert Err.NotAuth();
   prev = rec;
   ```

   `ExternalOracleV5.sol`. **Strict increase is the k-of-n deduplication check.** A repeated signature fails `rec <= prev` on its second appearance and an unsorted set fails at the first inversion, both from the same comparison, with no set structure and no second pass. $k$ accepted signatures therefore prove $k$ **distinct** granted keys.
3. The stride is a fixed 65 bytes with no EIP-2098 compact form (`ExternalOracleV5.sol`). The signature count **is** the quorum claim, so its encoding must be unambiguous.
4. All signatures are verified **before any state write** (checks-effects).

The [relayer](/docs/glossary#relayer) (`msg.sender`, the keeper landing the transaction) is **unpermissioned on the signed path**. Authority is in the signatures, not the sender. That decouples price authority (the NX Rates key set) from push liveness (any relayer can land the freshest signed blob) and removes the single-signer failure mode: **one stolen key can push nothing on its own.**

V4 carries that path as `pushSignedV4(blob, sigs)` and adds a cheaper one: `pushV4(blob)` pins `msg.sender` to the single relay named in a quorum-signed `SessionGrant`, valid for at most one hour and revocable by any one signer ([§8.2](#82-external-oracle-v3-session-grant-diff-wire)). V5 drops the session path: every push is quorum-signed, and an open session is no longer in-band mark authorship.

Replay is defended by the per-lane monotonic source second, never by signature uniqueness: a malleable $s$ still recovers the same signer, and no on-chain state is keyed on signature bytes ([§3.3](#33-signed-push-path-k-of-n-quorum)).

### 3.2. What the digest binds

The digest is `_hashTypedData(keccak256(BATCH_TYPEHASH || keccak256(blob)))`. Solady's EIP-712 domain separator commits to a name, a version, `chainId` and `verifyingContract = address(this)`. **Name and typehash are per generation**: V1 signed `BatchQuote(bytes32 blobHash)` under `BTR ExternalOracle` / `1`; V4 and V5 both sign `BatchQuoteV4(bytes32 blobHash)` under `BTR ExternalOracleV4` / `1`, so the NX Rates signing code did not move between them, only the blob encoder. Anything re-recovering a signature off-chain must use the domain of the instance that accepted the push ([§9](#9-transparency-verify-it-yourself)).

| Field | What it prevents |
|---|---|
| `chainId` | Cross-chain and post-fork replay. A batch signed for one chain's oracle is not valid on another deployment of the same contract, and solady recomputes the separator when `chainId` changes rather than serving a cached one. Load-bearing the moment the same contract is live on more than one chain. |
| `verifyingContract` | Cross-instance replay, the one that matters here. A blob signed for the primary oracle cannot verify on the reference oracle, or the reverse, even when the two share a signer set. Without it the independent-reference cumulative bound ([§5.2](#52-per-push-band-vs-cumulative-band)) would fall to pure message reuse rather than key compromise: the same signatures would move both feeds in lockstep and the ref band would never open. |

The signature covers $\text{keccak256}(\texttt{blob})$, the exact packed calldata, so no record can be substituted, reordered or truncated after signing.

Three wire formats exist:

- V1: an 8-byte header plus 22-byte ticker-keyed records, resolved through the append-only `feedIdOf[tickerId]` map.
- Wire v5 (V4): an 11-byte header with a cyclic deci-second clock, over the positional layout below.
- Wire v6 (V5): a 12-byte header (`ver | seq:u32 | srcSecs:u32 | nP | nS | nC`) plus positional entries, 5 B price, 5 B σ, 3 B confidence, each keyed by a `gi:u8` global index rather than a ticker, with sections in strictly-ascending `gi` and `nC == nP` enforced ([§8.3](#83-external-oracle-v4-29-bit-lanes-cyclic-clock)).

An entry naming an unregistered lane is skipped fail-soft, not reverted. A descending or repeated `gi` fails the whole blob with `BadBlobHeader`.

### 3.3. Signed push path: k-of-n quorum

Price authority sits off chain, with a set of independently keyed NX Rates signer replicas. The oracle verifies **k distinct signatures over one digest** on-chain; the relayer (keeper) submitting the transaction is unpermissioned, because authority is in the signatures, not `msg.sender`.

```solidity
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), $\sigma$ (`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 [§3.1](#31-the-on-chain-quorum-is-the-consensus). 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:

```bitfield 64
0..7    version     (u8)
8..55   sourceTsMs  (u48, shared across the blob)
56..63  reserved    (u8)
```

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

```bitfield 176
0..63     tickerId    (u64, NXR/MITCH instrument id)
64..127   markB64     (u64, B64 float)
128..159  sigmaPbps   (u32)
160..175  confBps     (u16)
```

On-chain counterpart `ExternalOracle.batchPushSigned` (V1); the SDK decodes only the live wires (`decodeBlobV5` / `decodeBlobV6`).

Verification and guards, all fail-closed:

| Guard | Rule | Purpose |
|---|---|---|
| Signer quorum | $k = \lvert\texttt{sigs}\rvert/65 \ge \texttt{signerThreshold}$, 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: [§4](#4-the-whitelisting-ceremony). | 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 `sourceTs` | Per 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 $\tau$ 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 bound | `SOURCE_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 | $\Delta t = \texttt{block.timestamp} - \texttt{updatedAt} \ne 0$, 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: [§5.4](#54-deviation-bounds). $\sigma$ is the stored prior, never the incoming push's own, and the source-time gap $\Delta t_{src}$ comes from the attested `sourceTs`. | Chain-agnostic: a legitimate Brownian move over $\Delta t_{src}$ at per-interval volatility $\sigma$ is $\sim Z\sigma\sqrt{\Delta t_{src}/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 feed | Per asset, `OracleConfig.refPrimary` points the depeg band at a separate oracle instance; the pool halts when mark and reference diverge past `refBandBps` ([Flow Guards](/docs/3-2-1-flow-guards)). Signer-set independence is a deployment property, not an enforced one: [§4.6](#46-independent-reference-the-deploy-disjointness-preflight). | 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 $\sigma$ directly (no on-chain σ-EMA) and, on V1, emits no event; observability there is `getFeed()` state polling and `batchPushSigned` was 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.

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 are k independent agreements on that price at that time. Provenance chain: [§1.2](#12-where-prices-come-from).

### 3.4. Access control

| Role | Capabilities |
|---|---|
| Owner / Admin | `addFeed`; tighten a band or ttl (`updateFeed`, immediate); widen a band or ttl (`requestFeedWiden` → `executeFeedWiden`, timelocked); queue signer additions (`requestSignerGrant` → `executeSignerGrant`, timelocked) and quorum decreases (`requestSignerThresholdDecrease`, timelocked); raise the quorum (`setSignerThreshold`, immediate); `unpauseFeed` |
| Guardian or Owner | Fast-freeze safe-direction only: `revokeSigner` (immediate), `pauseFeed`, `narrowMaxDeviation` (tighten-only), `cancelFeedWiden`, `cancelSignerGrant`, `cancelSignerThresholdDecrease` |
| Signer | One of the k-of-n granted NXR attester keys; authorizes a batch by signature over the EIP-712 digest ([§3.3](#33-signed-push-path-k-of-n-quorum)). 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 [§4](#4-the-whitelisting-ceremony).

**`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 `LISTING` 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.

### 3.5. Signature scheme rationale (k ECDSA, not aggregation)

The quorum is $k$ **concatenated** 65-byte ECDSA signatures over one shared digest, not a BLS or Schnorr aggregate. On BSC this is the correct trade:

- **Aggregation is a gas regression on BSC.** BLS verification needs a BN254 pairing; Schnorr (Scribe-style) verification does an on-chain per-signer public-key SLOAD (~5,100 gas/signer) plus an off-chain nonce round, exceeding independent ECDSA recovery (~4,040 gas/signer). At the sizes this contract can express (`MAX_SIGNERS = 16`, 2-of-3 on the live fleet), $k$ plain `ecrecover` calls are cheaper and simpler than any aggregate.
- **It keeps add-a-signer trivial.** Adding a signer is a registry write ([§4.2](#42-adding-a-signer-timelocked-loosening)). Schnorr/BLS aggregation with a fixed committee key would turn every membership change into a key-ceremony re-share, the flow the timelocked-registry design avoids.
- **It keeps the sub-50 ms quorum path.** Concatenated ECDSA needs no interactive nonce round between signers. Each replica signs the shared digest independently and the relayer sorts and concatenates, matching the NX Rates quorum latency target.
- **The quorum cost amortizes once per batch.** All $k$ signatures cover the same digest (the hash of the whole blob), so a batch of $N$ feeds pays the k-signature verification once, not per feed. Positional 5/5/3-byte entries, with the header carrying the shared source second once, keep the marginal per-feed cost minimal.

The gas comparison is measured on BSC; the conclusion is expected to hold on the other EVM targets but has not been re-measured per chain. The scheme stays swappable: the contract treats `sigs` as opaque bytes over an EIP-712 digest, so a future move to aggregation (should a large fixed signer set ever justify it) changes neither the feed layout nor the consumer path. Byte-exact wire format: the wire v6 golden vector is `oracle-v6-wire-golden.json`, pinned by `ExternalOracleV5.t.sol` and the keeper's `signed_v6.rs`; `ExternalOracleV5.push` is its on-chain counterpart and `decodeBlobV6` in `@btr-protocol/sdk` `wire.ts` the TS decoder.

---

## 4. The whitelisting ceremony

Signer-set governance is asymmetric: **tighten now, loosen later.** Loosening price authority (adding a signer, lowering the quorum) is timelocked and vetoable. Hardening or halting (revoking a signer, raising the quorum, pausing a feed) is immediate. A rogue owner cannot fast-add a malicious signer; a guardian can instantly retire a leaked one.

### 4.1. Genesis: no 1-of-1 bootstrap

The constructor installs the quorum atomically. It requires **3 to 16 distinct non-zero signers with `signerThreshold` between 2 and n** (`MAX_SIGNERS = 16`). A deployment can never pass through a single-signer or single-key state: there is no post-deploy "add the second signer" window during which one key is authoritative.

```solidity
// V5: the proxy initializer (V4 took the same bounds in its constructor, plus `bool express_`).
function initialize(address[] initialSigners_, uint8 signerThreshold_) external onlyAdmin
// reverts unless: 3 <= initialSigners_.length <= 16, all distinct + nonzero, 2 <= signerThreshold_ <= length
```

There is no `maxRelayLagSecs` on V4. The absolute past bound is the contract constant `MAX_RECON_AGE` = 6 h, shared by every feed and applied on both the push and the read side; `express_` is false on every deploy path ([§8.3](#83-external-oracle-v4-29-bit-lanes-cyclic-clock)).

`ac_` is an existing `AccessControl` address, so which governance root an oracle answers to is a deploy-script decision. The primary oracle on a chain shares that chain's `AccessControl` with the pool fleet; a **reference** oracle should not ([§4.6](#46-independent-reference-the-deploy-disjointness-preflight)).

### 4.2. Adding a signer (timelocked, loosening)

Adding price authority cannot take effect in the same transaction or block as the request:

1. Off-chain: generate a **standalone key** on its own node (independent failure domain, not derived from an existing signer).
2. `requestSignerGrant(signer)` (owner-only) queues one pending grant. Only one grant may be pending at a time, so a compromised owner cannot queue-farm candidates that guardians must veto one by one.
3. Wait out the `LISTING` tier delay.
4. `executeSignerGrant()` (owner-only) inside the grace window. Execution revalidates membership and the hard cap, because emergency revocations may have changed the live set while the request waited.
5. Any time before execution, **guardian or owner** may `cancelSignerGrant()` (veto, including an expired request).

### 4.3. Removing a signer (immediate, hardening)

`revokeSigner(signer)` is callable by **guardian or owner** and takes effect immediately: the fast removal of a leaked NX Rates key. Revoking below `signerThreshold` is allowed by design. Dropping under $k$ halts pushing (no quorum can form), the fail-safe response to a suspected compromise: feeds go stale and the pool fail-closes. A halt is never a loosening, so it needs no timelock.

### 4.4. Changing the quorum

| Direction | Path | Timing |
|---|---|---|
| Raise $k$ (`setSignerThreshold`, $t >$ current, $t \le$ `signerCount`) | Owner-only | Immediate (hardening) |
| Lower $k$ (`requestSignerThresholdDecrease` → `executeSignerThresholdDecrease`, floored at 2) | Owner-only, one pending at a time | Timelocked, guardian/owner cancellable |

A decrease can never go below 2. Execution revalidates that the target is still a strict decrease and reachable by the live set.

### 4.5. Ceremony parameters

| Constant | Value | Meaning |
|---|---|---|
| `MAX_SIGNERS` | 16 | Hard cap on $n$. |
| min signers / min $k$ at genesis | 3 / 2 | No 1-of-1 or 2-of-2 bootstrap. |
| Signer-governance delay | The `LISTING` tier of `AccessControl.GOV_DELAYS()`, read into the oracle's `DELAY_BASE` at construction ([schedule](/docs/3-1-overview)) | Delay on grant and quorum decrease. |
| `SIGNER_GOV_GRACE` | 7 days | Execution window after maturity; then the request must be cancelled before re-queuing. |

### 4.6. Independent reference: the deploy disjointness preflight

The cumulative bound ([§5.2](#52-per-push-band-vs-cumulative-band)) works only if the reference oracle is **operationally independent** of the primary. Address inequality is the on-chain floor (`PoolConfig.validateOracleConfig` requires `refPrimary != primary`).

**Signer disjointness is not implemented.** The only shipped preflight checks that the reference address differs from the primary and holds code. Nothing checks that the two instances have disjoint keys, admins or owners.

**Required configuration.** The reference oracle takes a signer set disjoint from the primary's and its own `AccessControl`. That is the one place a second governance root on a chain is correct, since the reference exists to police the primary. Sharing either is configurable and advised against: it collapses the two quorums into one.

**Per-deployment status.** This is a property of a deployment, not of the protocol, so check it per chain. **Arc shares a signer set today** for operational simplicity, and the cumulative bound does not hold there. Chains added later inherit nothing from that: verify each one before relying on the bound, by comparing `SignerGranted` logs on both oracle addresses. Live addresses per chain: [Contract Addresses](/docs/2-1-contract-addresses).

**Operating obligation.** Every armed spoke band fails closed once the reference goes stale, so a keeper instance must relay to the reference oracle on the same $\theta$/[heartbeat](/docs/glossary#heartbeat) discipline as the primary: separate `oracle-reference.*.toml` manifest, its own address-bound blob (the EIP-712 domain means one oracle's blob can never verify on the other). A parked reference oracle bricks spoke swaps after TTL. Operator detail: [§6.3](#63-both-oracles-or-neither).

**Consequence.** Where a deployment shares a signer set, one compromised k-of-n quorum signs both the mark and the reference, so the `refBand` walks in lockstep and never trips.

The `verifyingContract` binding ([§3.2](#32-what-the-digest-binds)) still prevents the *same signed blob* from moving both feeds: the attacker must produce a second, separately signed batch for the reference oracle. A shared signer set means they can, at the same key-compromise cost. Disjointness is what makes the second batch require a **second, independent** compromise. Address inequality buys nothing here; key and admin separation is the whole property.

`PoolConfig.validateOracleConfig` (`PoolConfig.sol`) enforces what a contract can: an armed `refBandBps` requires a non-zero `refFeedId`, a non-zero `refPrimary`, `refPrimary != primary`, and a successful `getFeed` on both. It cannot inspect signer sets, so operational independence stays a deployment obligation and belongs in the launch checklist, not in the contract.

---

## 5. Push guards, staleness and bands

### 5.1. How a push is guarded (defense in depth)

A verified quorum is necessary, not sufficient. Even a legitimately-signed batch, or one from a compromised quorum, passes a fail-closed guard chain before it writes a mark. Each guard maps to a [§2.1](#21-oracle-manipulation-classes) vector. Exact rules and formulas: [§3.3](#33-signed-push-path-k-of-n-quorum) and [§5.4](#54-deviation-bounds).

| Guard | Bounds which vector | One-line rule |
|---|---|---|
| k-of-n quorum ([§3.1](#31-the-on-chain-quorum-is-the-consensus)) | Single-key compromise | $k$ distinct granted signatures per batch, or the push reverts. |
| Monotonic `sourceTs` | Replay / reorder | The reconstructed source second must strictly advance, per slot on V4 (8 lanes share one clock); the timestamp is the [nonce](/docs/glossary#nonce). A slot that fails it is stepped over silently, with no event. |
| Future-dated bound (`SOURCE_TS_FUTURE_SKEW_SECS = 5`) | Future-dated freeze | Reject a `sourceTs` leading wall-clock by more than 5 s, which would clear the monotonic guard once and then freeze the feed forever. |
| Absolute freshness (`MAX_RECON_AGE` = 6 h, constant) | Withheld stale blob | Reject a blob whose reconstruction is older than the bound, on the read side as well as the push side. V1's per-instance `maxRelayLagSecs` does not exist on V4. |
| One write per slot per source-second | Batched walk | A slot accepts at most one write per source second, not per block. Several pushes may land in one block if their source seconds differ. A repeated or descending `gi` inside a section fails the blob closed. |
| Volatility-adaptive band | Per-push spike | A push may move the mark at most $10\,d_{max}$, and normally far less: $Z = 6$ standard deviations of Brownian motion over the attested source-time gap. Formula and terms: [§5.4](#54-deviation-bounds). |
| $\sigma$ floor | Volatility understatement | Stored $\sigma$ is floored at the realized $\lvert\Delta p\rvert/p$, so any move forces a proportional spread and a signed $\sigma = 0$ cannot buy a spread-free round trip. On V4 the floor runs only where the σ word is already loaded: the band slow path, or a slot the blob carries σ entries for. |
| Independent reference band (`refPrimary` + `refBandBps`) | Gradual walk (cumulative) | Halt swaps once the mark diverges from a separately-keyed reference by the band ([Flow Guards](/docs/3-2-1-flow-guards)). |
| Base-token depeg halt | Numeraire depeg | Halt the hub when the base mark leaves parity by the halt band ([Flow Guards](/docs/3-2-1-flow-guards)). |
| Guardian fast-freeze | Any suspected incident | Guardian or owner `pauseFeed` or `updateFeed` (tighten-or-equal on band and ttl), both immediate ([Guardian](/docs/3-1-4-guardian)). Off the Arc V4 pair, `pauseFeed` is fail-closed on release too: it clears the lane and anchors the frozen mark, so the feed reads DEAD through the pause and the re-entry push is banded over the real gap instead of one cadence ([§2.2](#22-manipulation-resistance)). `updateFeed`'s only inverse is the owner-timelocked widen ([§5.2](#52-per-push-band-vs-cumulative-band)). |
| $\sigma$ ceiling | Overflow / absurd vol | Every stored $\sigma$ is capped at `MAX_SIGMA_PBPS`, on both the signed sample and the realized-move floor. |

### 5.2. Per-push band vs cumulative band

The signatures authorize **authenticity, not magnitude.** Two independent magnitude bounds run in series.

**Per-push (sanity).** The volatility-adaptive band lets a push move at most a $6\sigma$ Brownian step over the attested source-time gap, and never more than $10\,d_{max}$ whatever $\sigma$ says. A single compromised push is bounded to a monitorable step, never a one-tx drain.

**`maxDeviation` names the floor, not the maximum.** The band's real ceiling is exactly $10\,d_{max}$, so at the shipped stable floor of $d_{max} = 50$ bps a single push may move a stable's mark by up to **500 bps**, the entire width of the base parity band ([Flow Guards](/docs/3-2-1-flow-guards)). Read the field as "the band's base term" and size it against the 10× ceiling. The two numbers must be chosen together.

**A wedged feed self-clears, up to that ceiling.** The source-time gap grows while a feed is quiet, so the band widens with it and a feed that fell behind during an outage clears itself once the true move fits. Past $10\,d_{max}$ it does not: 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 existing id.

**That wedge has an in-contract release.** `requestFeedWiden(feedId, maxDeviationBps, ttlSecs)` → `LISTING` delay → `executeFeedWiden(feedId)`, both owner-only, with a guardian-or-owner `cancelFeedWiden` veto. Two properties matter here:

- It loosens nothing by default. The execute clears the lane and the rebias band anchor, so the next push is unbanded whatever the gap was, and passing the live band and ttl back verbatim is a valid pure release.
- A tighten always wins. The request snapshots both fields and the execute reverts `InvalidState` unless both still match, so a guardian tighten taken during the delay voids the pending widen instead of being undone by it.

The Arc V4 pair, deployed 2026-09-01, predates it. There the fallback is the V5 implementation upgrade through `OracleBeacon` (`LISTING` tier, 1 day); there is no per-leg repoint. Mechanics and the one-batch-per-slot operator rule: [§5.4](#54-deviation-bounds).

**Cumulative bound.** A per-push band does not bound the total excursion: a compromised producer can walk the mark in many in-band steps. The **independent reference feed** closes that. `refPrimary` points the depeg band at a separately-keyed oracle instance ([§4.6](#46-independent-reference-the-deploy-disjointness-preflight)), and the pool halts once the mark diverges from the reference past `refBandBps`. This is magnitude-based and push-rate-independent: no number of small in-band steps evades it, and a reference co-signed by the same quorum would bound nothing.

Given disjoint signer sets, walking the mark to drain a pool takes **two simultaneously-compromised independent quorums** (primary and reference), not one, and even then only within the reference band before swaps halt. Where a deployment shares a set (Arc today), that reduces to one quorum.

### 5.3. 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 $t_{obs}$, not $t_{upd}$**: with $t_{src}$ the attested source time and $t_{upd}$ the relay landing time,

   $$t_{obs} = \min(t_{src},\, t_{upd}), \qquad a = t - t_{obs}, \qquad a > \tau \;\Rightarrow\; \texttt{StaleData}(a, \tau)$$

   Taking the **minimum** is what closes withheld-blob relabeling: a relay landing an old signed quote stamps $t_{upd} = t$, and using $t_{upd}$ 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. $\tau$ 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 $t_{obs} = t_{src}$ identically. Relabeling is closed by the acceptance window instead: a push whose reconstruction falls outside $[\,t - \texttt{MAX\_RECON\_AGE},\ t + 5\,]$ is rejected outright, and the read side applies the same bound and fails closed ([§8.3](#83-external-oracle-v4-29-bit-lanes-cyclic-clock)). 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](/docs/1-1-4-spread-fees#35-confidence-surcharge)).
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 ([§7.7](#77-transient-caching-eip-1153)).
5. **Staleness surcharge** (graceful degradation *below* the hard TTL revert): the spread ramps with the unobserved expected drift $Z_s\,\sigma\sqrt{\max(0,\,a - g)}$, with the premium-free grace $g = \min(\tau/2,\ \texttt{STALE\_GRACE\_CAP\_SECS} = 30\text{ s})$ (`Pricing.sol`). The cap matters: at $\tau = 600$ s, $\tau/2$ alone would quote no staleness premium for the first five minutes. See [Spread & Fees §3.4](/docs/1-1-4-spread-fees#34-staleness-surcharge).
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: [Flow Guards](/docs/3-2-1-flow-guards).

### 5.4. 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 ([§8.3](#83-external-oracle-v4-29-bit-lanes-cyclic-clock)). 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, $d_{max}$ for the per-feed `maxDeviation` in bps, $\sigma$ for the stored prior in PBPS, $\Delta t_{src}$ for the attested source-time gap in seconds, $T$ for the σ sampling interval and $Z$ for the sanity multiple. A push reverts `ThresholdViolation` when $\delta > \delta_{max}$, where

$$\delta = \frac{\lvert p_{new} - p_{prev}\rvert \cdot 10^{4}}{p_{prev}}, \qquad \delta_{max} = d_{max} + \min\!\left(Z \cdot \frac{\sigma}{100} \cdot \sqrt{\frac{\Delta t_{src}}{T}},\; X \cdot d_{max}\right)$$

so the whole band is capped at $(X+1)\,d_{max} = 10\,d_{max}$. Every term:

| Term | Value | Site | Rationale |
|---|---|---|---|
| $Z$ | `DEV_SIGMA_Z = 6` | `ExternalOracle.sol` | $6\sigma$ authenticity sanity cap on a Brownian step. |
| $T$ | `SIGMA_INTERVAL_SECS = 1800` | `ExternalOracle.sol` | Matches NXR's 30-min Parkinson σ window. |
| $X$ | `DEV_BAND_MAX_X = 9` | `ExternalOracle.sol` | Caps the whole band at $10\,d_{max}$. |
| $\sigma$ | the stored prior, never the incoming push's own | `ExternalOracle.sol` | A signer must not be able to widen the band it is about to cross. |
| $d_{max}$ | mandatory, $1$ to `MAX_DEV_THRESHOLD` $= 2{,}000$ bps | `ExternalOracle.sol` | Microstructure/discretization floor, not the primary bound. Deployed on Arc: 50 bps stables, 75 bps FX, 100 bps crypto / metals / equities. |
| $\Delta t_{src}$ | attested `sourceTs` delta in seconds | `ExternalOracle.sol` | Source-time, not block time, so the bound is identical percent-per-wall-second on a 400 ms chain and a 12 s chain. |

**Units.** $\sigma$ is stored in PBPS and enters in bps as $\sigma/100$. The code's $10^{5}$ divisor is $10^{3}$ for the integer-sqrt scaling times $10^{2}$ for the PBPS→bps conversion (`ExternalOracle.sol`). Substituting $\sigma$ in PBPS overstates the adaptive term by $100\times$: at $\sigma = 10^{4}$ PBPS (1%) over a full interval $\Delta t_{src} = T$ the real term is $6 \cdot 100 \cdot 1 = 600$ bps, not 60,000.

**Why the σ cap and the σ floor are decoupled.** The stored $\sigma$ is floored each push at the realized $\lvert\Delta p\rvert/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 $X d_{max}$ lets $\sigma$ keep its economic/spread role while the band stays bounded by per-feed config no matter how far $\sigma$ 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 $\Delta t_{src} = t - t_{upd}$; V4 does not need the fallback because it does not enter the band.
- **Live feed, gone quiet**: $\Delta t_{src}$ 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 $\Delta t_{src}$ falls back to the constant `MAX_RECON_AGE` (`ExternalOracleV4.sol`), which is stored-state-derived and not caller input. The $10\,d_{max}$ cap applies throughout.

**Recovery when the market moved further than $10\,d_{max}$.** 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. Without the widen, as on the Arc V4 pair (deployed 2026-09-01), the only escape is the V5 implementation upgrade through `OracleBeacon` (`LISTING` tier, 1 day); there is no per-leg repoint, with the legs dark throughout.

**`requestFeedWiden` → `executeFeedWiden`, 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: `LISTING` 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 $d_{max} = 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 `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\sigma$ move per push and to $10\,d_{max}$ 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:

2. **σ ceiling**: `MAX_SIGMA_PBPS = 100,000,000` PBPS (10,000%) on every stored $\sigma$, checked on the signed sample and applied again inside `FeedMathLib.markMovePbps`, so the floor below cannot push $\sigma$ past the cap.
3. **Confidence**: hard-halts past `MAX_CONFIDENCE_HALT_BPS` in `FeedMathLib.gate` ([§5.3](#53-staleness-protection) item 3).
4. **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: [§5.2](#52-per-push-band-vs-cumulative-band).
5. **σ floor (volatility-understatement backstop)**: the stored $\sigma$ is $\max(\sigma_{signed},\, \lvert\Delta p\rvert/p_{prev})$ in PBPS. A signature authorizes the authenticity of a mark, not its volatility: a signer signing $\sigma = 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 $d_{max}$), or a slot the blob carries σ entries for ([§7.3](#73-on-chain-derived-state)).
6. **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`.

### 5.5. Known risks: LVR and 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 $\theta$ (or a heartbeat / CI-spike fires) and the correcting push 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](/docs/glossary#oev-oracle-extractable-value) on the push transaction itself.) Pushes fire on three triggers:

- A **$\theta$ 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 $\theta$ crossings.

**Primary controls**:
| Control | Role |
|---|---|
| Keeper `theta_bps` ($\theta$) | Bounds intended stale gap before a push |
| `heartbeat_s` $\le \tau/2$ | Liveness ceiling; ops hard-fail if violated |
| Pool `minFeePbps` | Trader pays $\approx S_{path}/2$; size so one-way cost covers typical stale gap (hard worst case wants $\approx 2\theta$ on the spread floor; launch configs may use a mean-gate softer than that) |
| NXR freshness + `poll_interval_s` | Detection latency before the keeper even builds a tx |

**Secondary / defense-in-depth**:
| Control | Role | Caveat |
|---|---|---|
| `vegaBps` → $S_{vol}$ | Widens spread when $\sigma$ is already high | Weak on the first jump of a new regime ($\sigma$ updates on push) |
| `confidence` → $U_{conf}$ | Taxes uncertain marks | Halt past `MAX_CONFIDENCE_HALT_BPS` |
| `STALE_Z` → $U_{stale}$ | $\sigma\sqrt{a - \tau/2}$ after grace | Zero while $a \le \tau/2$: does not price healthy intra-θ drift |
| Coverage skew (fixed protocol law) | Taxes one-way inventory moves | Zero at coverage $\approx 1$; can subsidize flow that improves coverage. The arms are protocol constants, deliberately asymmetric: slope 200 draining, 100 filling |
| `kappaCovBps` coverage wall | Convex toll on coverage-declining output, only lever that scales the inventory defense | Required in `[50, 10000]` on every listed asset including the hub; a spoke never above its hub |
| `minDispersionPbps` / preset curve | Depth near mid | Tighter / more center-bumped = better UX, more size extractable per bp of gap |
| `refBandBps` / TTL halt | Circuit breakers | Availability vs bleed |

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

**Definition**: [MEV](/docs/glossary#mev-maximal-extractable-value) from **ordering around the oracle update itself**, not from slow between-push drift. **Not** "observed extractable value". Glossary: [OEV](/docs/glossary#oev-oracle-extractable-value).

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**:
| Control | Role |
|---|---|
| `minFeePbps` | Only pool param that reliably taxes both frontrun and backrun of an otherwise-honest push |
| Private / MEV-protected RPC for the push tx | Most 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 LPs | Stronger capture (not shipped) |

**Important trade-offs**:
- Lower $\theta$ → 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 | ↓ OEV | Notes |
|---|---|---|---|
| ↓ `theta_bps` | yes, primary | worse: more events | Pair with fee floor |
| ↓ `heartbeat_s` | yes | worse: more predictable | Keep $\le \tau/2$ |
| ↑ `minFeePbps` | yes, primary | yes, primary pool lever | Competitiveness cost |
| ↑ `vegaBps` | yes, if $\sigma$ already up | partial post-push | Not a first-jump shield |
| ↑ `kappaCovBps` | conditional | conditional | Inventory first; instant via `raiseKappa` |
| Tighter dispersion / center bump | worse: more extractable size | worse: same | UX vs pick-off capacity |
| `STALE_Z` | yes, past grace only | $\approx 0$ on the healthy path | Secondary |
| Private push relay | neutral | yes, the strongest lever | Ops, not `setAssetParams` |

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

---

## 6. Keeper operations

### 6.1. When the keeper pushes

Three triggers, evaluated per feed against the last landed on-chain push:

| Trigger | Rule | Configured at |
|---|---|---|
| Deviation | $\lvert m - p_{\text{last}}\rvert / p_{\text{last}} > \kappa \cdot E_i(t)$, a fixed share of the edge the pool is currently quoting for that leg | `edge_kappa_pct` (50 on Arc); `feeds[].theta_bps` is the fallback when the pool's `minFeePbps` is unknown |
| Heartbeat | the per-feed maximum time between on-chain pushes has elapsed | `feeds[].heartbeat_s` |
| CI spike | the NX confidence interval has widened by at least `ci_spike_bps` since the last push | `nxr.ci_spike_bps` (required, 1-100) |

Deviation is edge-relative, not a static θ. Rationale, the formula for $E_i(t)$ and the measured cadence: [§6.2](#62-push-triggers-deviation-measured-against-the-spread-it-defends). `theta_bps` stays in the config as the fallback path, selected by `edge_kappa_pct = 0`.

CI spike covers the pinned mark: a depeg onset that holds the price flat while dispersion explodes trips neither the boundary nor the heartbeat.

A feed carrying no trigger of its own rides along on an already-due blob when its own deviation sits within `rider_boundary_pct` percent of its own push boundary (60 on Arc). The legacy `rider_band_pct` test, proximity to the feed's on-chain `maxDeviation`, is the `edge_kappa_pct = 0` branch only; `maxDeviation` is roughly 200× the boundary on a stable, so it carried no information about whether a feed was about to fire.

`heartbeat_s` is a staleness bound the keeper enforces on itself, not a liveness watchdog. The ops rule coupling it to the contract is `ttl ≈ 2·heartbeat`, and the keeper hard-fails at startup on any feed with `heartbeat_s > ttl/2` (override `KEEPER_ALLOW_LONG_HEARTBEAT=1`, a bring-up tool, not a fix).

Push rate is not capped. One object in the cadence machine is a spacing rule; the other two are forecasts:

- `feeds[].min_push_gap_s`, at least the fleet floor `min_push_gap_floor_s` = 1 s on Arc and strictly below `heartbeat_s`: the anti-spam refractory after a push. The floor is 1 s because V4 accepts one write per slot per source second, a chain constraint rather than a gas budget.
- `feeds[].target_per_h` (default 60): a forecast used for gas budgeting and alerting, never a refusal. A trailing hour above `2 × target` for a quarter-hour pages, naming the offending feeds and their `burst_reason` (the mark travelled, so the forecast missed the regime, or the source flapped). It never throttles.
- `manifest_target_per_h` = 300 on Arc: the fleet-wide blob forecast, the sum of the per-feed targets. Same forecast-not-cap semantics.

The binding ceiling is the spend breaker `gas.daily_spend_cap_native`, which degrades gracefully and is observable where a hard rate cap would silently mis-price the exact hour the mark moves most.

θ, heartbeat, `min_push_gap_s`, `ttl` and `maxDeviationBps` are per-deployment and per-feed, with no protocol-wide number to quote. The first three live in the keeper's `oracle-v<wire>.<slug>.toml` (the file each chain row in `chains.rs` names; `fleets.json` is the gate-side roster). The last two are written on chain by `registerFeed` and can only be tightened afterwards by `updateFeed`.

### 6.2. Push triggers: deviation measured against the spread it defends

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

**Measured (Arc, 2026-08-31, 45-minute tape, 68 batches).**

| trigger | share of pushes | avg feeds carried |
|---|---|---|
| `heartbeat` | 50% | 11.4 |
| `theta_cross` | 35% | 9.4 |
| `ci_spike` | 13% | 11.7 |
| `cold_start` | 1% | 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:

$$E_i(t) = \text{minFee}_i + \frac{\sigma_i \cdot \text{vega}_i}{100 \cdot \text{BPS}} + \text{CI}_i \cdot \frac{\text{PBPS}}{\text{BPS}} + \frac{Z \cdot \sigma_i \sqrt{\max(0,\; \tau_i - g_i)}}{\text{BPS}}$$

and pushes feed $i$ when $|m_{\text{now}} - m_{\text{chain}}| \ge \kappa \cdot E_i(t)$, with $\tau_i$ the age since last push, $g_i = \min(\text{ttl}_i/2, 30\text{s})$ the keeper grace, and $\kappa = 0.5$ on Arc.

Three properties follow, none of which needs tuning:

- **Per-asset.** 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\sigma\sqrt{\tau}$ 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 ([§5.3](#53-staleness-protection)), 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 is forecast, not cap.** The old gate was a fixed 36 s per-feed refractory, derived from a 100-pushes-per-hour cap (`3600 / CADENCE_CAP_PER_H`). Spacing pushes evenly 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 budget then propagated into the fitted θ ladder and the fee floor. Both caps are deleted. What replaced them, and the spend breaker that binds instead: [§6.1](#61-when-the-keeper-pushes).

**Selection.** A push carries the feeds that fired plus *riders* ([§6.1](#61-when-the-keeper-pushes)). σ-seeded and σ-unseeded feeds are still never mixed in one blob ([§8.1](#81-external-oracle-v2-packed-slot)).

**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, manifest cap still mis-set, sat at 123/h). The rise is the intended direction: the fixed refractory spent its budget on evenly spaced heartbeats, the bucket spends a larger budget on moves that cross the edge. The bound is hourly spend, not cadence.

A competitor pushing bid and ask directly at ~1 Hz runs ~3,600/hour. BTR computes both sides on-chain from a mark, so the mark only has to be right to within the spread quoted around it: hence the 5% relative push rate.

### 6.3. Both oracles, or neither

A deployment running the per-asset reference band carries two `ExternalOracle` instances at two addresses: the primary that prices, and the independent reference that bounds cumulative drift ([§5.2](#52-per-push-band-vs-cumulative-band)). Every non-base spoke bands against the reference and fail-closes when the reference goes stale, so refreshing only the primary bricks every spoke swap once the reference passes its TTL.

The two cannot share a blob. The signed-push EIP-712 domain binds `verifyingContract`, so a blob signed for the primary never verifies on the reference: each needs its own NX Rates-signed blob bound to its own address. That means a second keeper instance with its own config (`oracle-reference.<chain>.toml`) and its own manifest. Fill and arm both in the same session.

A parked reference oracle strands every spoke once it passes its `ttl`: the gate reverts on age above `ttlSecs`, not above `ttl/2`. `min(ttl/2, 30 s)` is only the premium-free grace before the staleness surcharge starts widening the quote. It fails silently: the 2026-07 incident ran 4.5 days with 13 dead feeds before anyone noticed, which is why the feed-liveness guard in [§6.6](#66-the-feed-liveness-guard) exists.

### 6.4. Startup gates

The keeper refuses to arm rather than push into a misconfiguration. Each gate turns a boot failure into a one-line diagnosis.

**Config load** (`deny_unknown_fields` throughout): zero or duplicate `feed_id`, a zero `oracle` address, an unset `chain_id`, a missing `[gas]` policy or a missing `[alert]` block are all rejected. `ci_spike_bps` (1-100), `rider_band_pct` (1-100) and `max_age_ms` (1-60000) are bounds-checked. The `mainnet` profile additionally requires `chain_id == 1`.

**Chain**: `eth_chainId` is fetched and hard-compared against the configured `chain_id`. Both are explicitly required; neither has a silent default.

**Feeds**: every configured `feed_id` is `getFeed()`-ed before the loop starts. A missing feed aborts. On the V1 wire, `feedIdOf(tickerId)` is cross-checked against the configured `feed_id` so a config typo cannot relay a mark into the wrong feed. On the V2 wire, `EPOCH()` must match the resolved config epoch and each feed's on-chain `globalIndex` and `expBias` must match its lane record, or marks would decode at the wrong scale.

On the live wire v5 the arm is a lane reconcile only: `feedIdAt(globalIndex)` must equal the configured `feed_id` for every bound feed. There is no `EPOCH` gate, deliberately: V4 has no `EPOCH` immutable, the clock is cyclic.

There is also no on-chain `expBias` cross-check, because V4 exposes no per-feed config getter. A producer running a bias the chain has since changed is caught by the deviation band, and only where the lane already holds a mark to band against. Reconcile the declared bias against the `FeedRegistered` / `FeedExpBiasUpdated` logs, or against `expHeadroom`, after any rebias.

**Quorum**: on-chain `signerThreshold` must equal the configured `signer_threshold`, on-chain `signerCount` must equal the number of pinned `signers`, and every pinned signer must read granted. The keeper pins the expected attester set explicitly; a silently added or removed key is a boot failure, not a runtime surprise.

**Upstream**: the NX Rates signed catalog must cover every subscribed feed (a superset is fine), must be bound to the configured oracle address and chain id, and must serve a quorum at least as large as `signer_threshold`.

**Relay set**: when `keeper_set` holds more than one address, a deterministic soft leader relays each push and the rest arm a jittered fallback (`relay_fallback_ms` + `relay_jitter_ms` × index), so a stalled leader is covered without an O(N) reverting-transaction storm. The running keeper's own address must appear in `keeper_set`; startup fails loudly otherwise.

**Anti-pick-off**: pools listed in `pools` have every asset's deployed `minFeePbps` checked against 2θ. Leaving `pools` empty turns the gate off, which is why the same invariant is also evaluated by the risk keeper ([Risk Steward](/docs/3-1-3-risk-steward)).

Live pushing needs both `--execute` and `KEEPER_EXECUTE=1`. Without both, the keeper runs the full loop and broadcasts nothing, which is the correct way to validate a config change.

### 6.5. Revert taxonomy

V4 is not all-or-nothing. Only framing, quorum, session and acceptance-window failures revert the whole push; everything price-shaped fails soft, per lane or per slot, and the rest of the blob lands. Read the revert and the accepted mask, not the gas.

Reverts. The whole blob is discarded:

| Revert | Cause | Operator action |
|---|---|---|
| `BadBlobHeader()` | Wrong version byte, a length that does not match `11 + 5·nP + 5·nS + 3·nC`, a `gi` not strictly ascending inside a section, or a lane with a non-zero reserved top bit | Wire-format mismatch between NX Rates and the deployed contract. Stop and reconcile `wire_version` |
| `NotAuth()` | A recovered signer is not granted, the signatures are unsorted / duplicated, or a session push came from an address that is not the granted relay (or carried `seq > maxSeq`) | Compare `signers()` and `session()` on chain against the pinned set. Signatures must be sorted by recovered address |
| `SessionExpired()` | The session's `expiresAt` has passed | Open a new session; grants cap at 1 h |
| `StaleTimestamp()` | The header's reconstructed source time is older than `MAX_RECON_AGE` = 6 h | The blob was withheld or the fetch path is badly behind. Check `/latency`, not the chain |
| `FutureTimestamp()` | The header's source time leads `block.timestamp` by more than 5 s | Clock skew on the producer or the sequencer |
| `InvalidInput()` | `sigs.length % 65 != 0`, or fewer signatures than `signerThreshold` | A relay or quorum-assembly bug |
| `FeedNotFound(feedId)` | An admin call named a feed the instance does not carry | Config points at an instance that does not carry it |

Fail-soft. The push lands, the affected lane or slot does not:

| Outcome | Cause | How you see it |
|---|---|---|
| Lane skipped | Unregistered lane, a write of the STALE sentinel, a deviation-band breach, or σ above `MAX_SIGMA_PBPS` | Bit clear in `acceptedMask`, bit set in `LanesSkipped(slotId, laneMask)` |
| Lane accepted but not written | The feed is paused | Counts as accepted in `acceptedMask` while the lane is never written. Check `getFeed(feedId)` flags before suspecting the relay. Off the Arc V4 pair, `pauseFeed` also clears the lane, so the feed reads DEAD for the pause and until a push lands after the unpause, and that push is banded over the whole pause rather than over one cadence |
| Whole slot skipped, silently | The blob's source second is not strictly newer than the slot's stored one | No event at all. The only signal is that the slot's eight lanes did not advance. This is what a re-broadcast, a reordered relay, or two pushes inside one source second look like |

The band case self-heals up to a ceiling. The allowance is `maxDeviationBps + min(6·σ·√(dt/1800), 9·maxDeviationBps)`, widening with the attested source-time gap: a feed that fell behind during an outage clears itself once the true move fits inside the widened band. Past `10·maxDeviationBps` it does not, and nothing the relay key holds moves it.

The release is the owner's, in-contract: `requestFeedWiden(feedId, maxDeviationBps, ttlSecs)` → `LISTING` delay → `executeFeedWiden(feedId)`, guardian-or-owner `cancelFeedWiden` veto ([§6.9](#69-escalate)). On the Arc V4 pair, which predates it, recovery is the V5 implementation upgrade through `OracleBeacon` (`LISTING` tier, 1 day); there is no per-leg repoint. Formula and terms: [§5.4](#54-deviation-bounds).

V1's `CooldownActive()` (one mark per feed per block) and its `StaleData(age, bound)` against an immutable `maxRelayLagSecs` do not exist on V4.

V4 pushes are indexable. Both entry points emit `SlotsPushed(seq, sourceTsDs, acceptedMask, blobHash)`, and a record that fail-softed any lane also emits `LanesSkipped(slotId, laneMask)`. Alert on a rising skip count and on a flat `acceptedMask` bit. The silent whole-slot skip above is the one outcome no event covers, so watch slot age directly.

### 6.6. The feed-liveness guard

`btr-keeper guards` is a separate role with a separate key: it sweeps the full feed catalog of both `ExternalOracle` instances, pages a human, then pauses what stays dead. It is the only component that checks the reference oracle, which carries no pools and is therefore invisible to every pool-scoped check.

Its heartbeat is read from the pusher's `oracle.*.toml` via `--oracle-config`, not configured in its own file, so the heartbeat a guard checks is the heartbeat the pusher promises. Four gates bound a pause, each reporting distinctly, because "would have paused" and "paused" must never read the same:

- The rolling `limits.max_auto_actions_per_hour` budget, consumed only by a landed broadcast.
- `--execute` and `GUARDS_EXECUTE=1`.
- An `AccessControl.isGuardian(signer)` read at startup.
- The broadcast itself.

Alerts are never bounded by the budget.

Load-time rules refuse a config that could go dark:

- At least one `alert` stage must exist.
- The earliest alert stage must fire strictly before the earliest pause stage.
- An armed load rejects zero addresses and rejects `reference_oracle == oracle`.
- `--run` requires one `--oracle-config` per address in `[contracts]`.

The pause leg signs as a guardian, so the guard's key is a guardian key and inherits [Guardian](/docs/3-1-4-guardian), including owner-only `unpauseFeed`: a guard-driven pause always ends with a human.

### 6.7. Before a change

- **Never on a laptop.** A long-lived live process belongs to the cluster Deployment that owns pushes for that chain; a second live relay signing from a workstation is a duplicate-push source.
- **Dry-run first.** `--once` without `--execute` runs the full tick, exercises every startup gate, and broadcasts nothing. A failed or timed-out tick exits non-zero, so it is usable as a gate.
- **Reconcile ordinals against chain, not against the repo.** Offline tests recompute feed ordinals from the same deploy scripts that generated the config, so they agree with the config no matter what and stay silent when the *contract* disagrees. Only a chain read catches that.
- **Change both instances together** when a change touches the wire format, the epoch, the signer set or the feed roster ([§6.3](#63-both-oracles-or-neither)).
- **Check the fences you are about to load against**: `getFeed(feedId)` for each feed's live `ttlSecs` and `maxDeviationBps`. There is no per-feed lag bound to reconcile on V4: the past bound is the contract constant `MAX_RECON_AGE` = 6 h, above every deployed ttl (600 / 3,600 / 7,200 s), and nothing validates ttl against it. Both live fields are tighten-only from every instant lever; the only inverse is the owner's timelocked wedge release ([§6.9](#69-escalate)), so treat a tighten as a decision, not a setting.

### 6.8. After a change

Watch the push path end to end rather than the process:

- **Liveness**: the keeper marks a heartbeat after every successful tick and its probe restarts the process when that heartbeat goes stale. A total upstream outage returns an error rather than a silent green tick, so the probe fires before the on-chain TTL halts pools.
- **Latency**: the health surface serves p50/p99 per leg. Quote a number only from there; the budgets are split per leg because the legs have different physics (quote fetch, pre-submission, submit).
- **Triggers**: one line per feed per relay decision, the effective per-feed θ and heartbeat actually in force, gas spend and the low-balance gate.
- **On chain**: `getFeed(feedId).updatedAtSecs` advancing on every feed, on both oracles.
- **Downstream**: `feeds.tradableRatio` and `feeds.worstAgeRatio`, the fraction of the book that will revert on the next swap ([Observability](/docs/3-3-observability)).

A newly registered feed deserves one extra look, on V4 for the opposite reason. `registerFeed` seeds no mark and no σ: it writes the config lane, stamps the slot clock and leaves the price lane at the STALE sentinel, so the feed reads stale and its first push carries no deviation band at all, having no previous mark to band against. Get that first push in under supervision and verify the landed mark against the source before the leg is listed.

Every later push is banded normally. One that drifts past `10·maxDeviationBps` is back to the same unbanded first push, reachable only through the owner's wedge release ([§6.9](#69-escalate)), never from this key.

### 6.9. Escalate

| Symptom | Escalate to |
|---|---|
| Signature verification failing against a set that should be granted, or an attester key believed leaked | Guardian: `revokeSigner` is immediate and halts pushing, which is the fail-safe ([Guardian](/docs/3-1-4-guardian)) |
| Relay or session key believed leaked | Guardian: `revokeSession` alone is not durable (NXR re-grants the same address within ~30 s); `revokeSigner` below `signerThreshold` makes every future `openSession` revert `NotAuth`, after which `revokeSession` holds. Owner, same session: rotate the roster and the keeper Secret |
| A feed pushing authentic marks you believe are wrong | Guardian: `pauseFeed` |
| Band permanently too tight after an outage (past `10·maxDeviationBps`) | Owner: `requestFeedWiden` → `LISTING` delay → `executeFeedWiden`, which clears the lane and the band anchor so the next push lands unbanded; the magnitude is optional, so a pure release passes the live band and ttl back verbatim. On the Arc V4 pair, which predates it, the V5 implementation upgrade through `OracleBeacon` (`LISTING` tier, 1 day); there is no per-leg repoint. Plan a day, not minutes |
| `ttlSecs` or `maxDeviationBps` structurally wrong for the deployment | Owner: `updateFeed` tightens either instantly; loosening either is the timelocked widen above, which carries both fields on one op. Too-loose is a live-with-it until the `LISTING` delay clears |
| Both oracles stale simultaneously | Treat as an incident: every spoke is fail-closed. `security@btr.markets` |

Nothing on this list is reachable from the relay key.

Hand over a wedge grouped by slot. `executeFeedWiden` stamps the one clock the slot's eight lanes share, so every wedged lane of a stalled slot must be released in the same batch; a lane left out 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 then skips until a blob sourced strictly after the execute arrives: observe-sign-relay lag plus one cadence, about 30 s at the live rate, for all eight lanes. Expect one dark cycle across the slot and do not read it as a relay fault.

The off-chain ceremony is `OracleV4Unwedge.s.sol`; its `preview()` and `verify()` take no key, broadcast nothing, and print `UNRELEASED MATE` for any wedged lane the selection leaves behind. After the feed quotes again, re-tighten with `updateFeed` if the ceremony widened: the release leaves the band where the request set it.

### 6.10. Checklist

**Before arming an instance**

- [ ] `chain_id` in the config matches the RPC and the target deployment.
- [ ] `oracle` address matches the instance this tier is meant to feed, and the NX Rates catalog is bound to that same address.
- [ ] `signers` and `signer_threshold` match `signers()` / `signerThreshold()` on chain.
- [ ] Every `feed_id` resolves via `getFeed()`, and the lane reconcile agrees with the config: V1 `feedIdOf(tickerId)`, V2/V4-wire the lane record, wire v5 `feedIdAt(globalIndex)`.
- [ ] On wire v5, the declared `expBias` per feed matches the chain: the arm does **not** check it, so read it off `FeedRegistered` / `FeedExpBiasUpdated` logs or `expHeadroom`.
- [ ] `heartbeat_s ≤ ttl/2` on every feed, with no `KEEPER_ALLOW_LONG_HEARTBEAT` override in the manifest.
- [ ] `min_push_gap_s ≥ min_push_gap_floor_s` (1 s on Arc) and `< heartbeat_s` on every feed.
- [ ] `[gas]` and `[alert]` are present, and the pager has been tested to a real inbox.
- [ ] The signing address appears in `keeper_set`, and is not the deployer, owner, guardian, or an attester. Nothing on the keeper checks this: read `AccessControl.owner()`, `isGuardian` and `signers()` against it by hand.
- [ ] The **reference** instance is filled and armed in the same session as the primary.
- [ ] `--once` dry-run exits zero.

**Before switching to live**

- [ ] `--execute` and `KEEPER_EXECUTE=1` are both set, and set nowhere else.
- [ ] No second live relay for the same tier is running anywhere, workstation included.
- [ ] The liveness probe and the heartbeat the keeper writes agree on the same path.

**After the first live tick**

- [ ] `getFeed().updatedAtSecs` advancing on every feed, on both oracles.
- [ ] Per-leg p99 within budget, and the trigger mix on the health surface as expected.
- [ ] Gas spend and the low-balance gate sane for the cadence.
- [ ] `feeds.tradableRatio` at 1.0 and `feeds.sigmaZero` at 0.
- [ ] Any feed not advancing checked for flags bit 0 (paused) before the relay is blamed.

---

## 7. Feed data reference

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.

To only *read* a BTR mark from your own protocol, go to [Consuming Price Feeds](/docs/5-1-5-consuming-price-feeds): a three-function surface. This page covers write authority, the staleness and manipulation guards, and the blast radius of a compromised source.

### 7.1. Overview

| Mode | Value | Quote source | When to use |
|---|---|---|---|
| `EXTERNAL` | `0` | `IOracle(primary).getFeed(feedId)` | Almost everything. NX Rates push, Chainlink adapter, Uniswap reader, vault NAV, custom on-chain logic |
| `INTERNAL` | `1` | `FeedMathLib.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. `INTERNAL` freezes the mid at par and 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.

Which providers supply the marks today, and what a new one must supply: [§1.2](#12-where-prices-come-from).

Every non-base leg carries a mandatory feed-relative depeg band, and the base carries a parity halt instead; both are specified in [Flow Guards](/docs/3-2-1-flow-guards). Integrators: [Curation §3.2](/docs/5-1-2-pool-deployment-curation#32-oracle-configuration).

See [Feed Oracle](/docs/1-2-2-internal-oracle) for the data-flow view of `FeedData` and the NX Rates push API.

### 7.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:

| Field | Role |
|---|---|
| `mark1e18` | Fresh keeper mark (1e18 WAD), quote source. A memory/return value; the on-chain encoding differs per version (below). |
| `sigmaPbps` | Stored $\sigma$ (PBPS), pricing input: the NXR-signed $\sigma$ stored directly, floored at the realized $\lvert\Delta p\rvert/p$ each push (compromised-signer backstop). Not an on-chain EMA. |
| `updatedAt` | Push timestamp (s) |
| `ttl` | Freshness window $\tau$ (s) |
| `confidence` | Mark $1\sigma$ CI (bps), decoupled from $\sigma$ |
| `flags` | Feed flags; bit0 = paused (guardian fast-freeze, fail-closed) |
| `maxDeviation` | Per-push deviation-band floor (bps), mandatory non-zero |
| `sourceTs` | NXR-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 $\sigma\sqrt{\tau}$ premium, guardian freeze.

| | V1 (`ExternalOracle.sol`, deployed) | V2 (`ExternalOracleV2.sol`, [§8.1](#81-external-oracle-v2-packed-slot)) |
|---|---|---|
| Storage model | One 256-bit slot per feed | 8 feeds per price slot + separate σ-slots + a cold registry |
| Mark encoding | B64 float, 52/5/7 ([§7.5](#75-b64-float-encoding)) | 23-bit normalized mantissa + 5-bit binary exponent, per-feed `expBias` ([V2 storage model](#v2-storage-model-the-packed-price-slot)) |
| Feed identity | `tickerId` (u64) on every record | positional `(slotId, laneIdx)`, no id on the wire |
| Timestamp scope | one `sourceTs` per push (blob header) | one ts per slot (per-slot monotonic guard) |
| Push trigger | θ-cross + heartbeat | θ-cross + heartbeat, narrower batches |
| Gas / feed | 13.7k-14.4k full-tx on Arc | 10.3k-11.6k full-tx on Arc ([V2 measured gas](#v2-measured-gas-reference-implementation-2-of-3-signed)) |
| Status | retired on Arc (feeds paused 2026-08-31; keepers scaled down) | rollback for V3 ([V2 migration status](#v2-migration-status)) |

**V3** ([§8.2](#82-external-oracle-v3-session-grant-diff-wire)) 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 [V4 measured gas](#v4-measured-gas)).

#### 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:

```bitfield 256
bits  0..63    lastPriceB64   (B64 float, §7.5)
bits 64..95    sigmaPbps      (u32, PBPS)
bits 96..127   updatedAtSecs  (u32)
bits 128..143  ttlSecs        (u16)
bits 144..159  confidenceBps  (u16)
bits 160..175  flags          (u16; bit0 = paused)
bits 176..191  maxDeviationBps (u16)
bits 192..239  sourceTsMs     (u48)
```

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

#### 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 [V2 storage model](#v2-storage-model-the-packed-price-slot).

> **Reading guide.** [§7.3](#73-on-chain-derived-state) to [§7.7](#77-transient-caching-eip-1153) detail **V1** (retired): push API, B64 encoding, consumer reads, caching. It is the reference the later generations are described against. **V2** is self-contained in [§8.1](#81-external-oracle-v2-packed-slot), V3 in [§8.2](#82-external-oracle-v3-session-grant-diff-wire), V4 and V5 in [§8.3](#83-external-oracle-v4-29-bit-lanes-cyclic-clock). The consumer surface (`getFeed` → `FeedData`), the k-of-n quorum and the risk guards are common to all.

### 7.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 $\sigma$ **directly** into the σ lane; all smoothing lives at the source (NX Rates), not on-chain.

The realized-move floor on $\sigma$ (a compromised-signer backstop, see [§5.4](#54-deviation-bounds)) 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`).

### 7.4. Feed management (push API)

> **V1 only.** This section documents `ExternalOracle.sol`, paused and retired on Arc since
> 2026-08-31. On V5 the push entry point is `push(blob, sigs)` on wire v6 and registration is
> `registerFeed(RegisterParams)`; on V4 registration is `registerFeed(feedId, globalIndex, expBias,
> maxDeviationBps, ttlSecs)`, the push entry points are `pushSignedV4` / `pushV4` / `openSession`,
> and the wire is v5 ([wire v5 and v6](#wire-v5-and-v6)). V4's instant config lever is `updateFeed`,
> tighten-or-equal on band **and** ttl, guardian-or-admin; its inverse is the owner-timelocked
> `requestFeedWiden` → `executeFeedWiden` with a guardian-or-owner `cancelFeedWiden`, in source
> since 2026-09-03 (the Arc V4 pair predates it) and described at [§5.4](#54-deviation-bounds). `narrowMaxDeviation` and
> `maxRelayLagSecs` do not exist on V4 in any release ([V4 migration status](#v4-migration-status)).

```solidity
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](https://github.com/nxrates/mitch/blob/main/model/ticker.md) 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 $\texttt{keccak256(abi.encodePacked(asset, quote))}$; migrating it to $\texttt{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](/docs/5-1-5-consuming-price-feeds#32-the-feed-set).

V1's only push path was `batchPushSigned` ([§3.3](#33-signed-push-path-k-of-n-quorum)); there was no signature-less / `msg.sender`-trusting push, and V5 returns to that shape. Access control for the admin surface: [§3.4](#34-access-control).

### 7.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 ([V2 storage model](#v2-storage-model-the-packed-price-slot)).

#### B64 format (52/5/7)

```bitfield 64
0..6    exponent  (u7, biased by 64: -64 to +63)
7..11   decimals  (u5, 0-31)
12..63  mantissa  (u52, normalized)
```

| Field | Bits | Range | Description |
|---|---|---|---|
| Mantissa | 52 | $0$ to $4.5 \times 10^{15}$ | Normalized value |
| Decimals | 5 | 0-31 | Token decimal places |
| Exponent | 7 | $-64$ to $+63$ (biased by 64) | Scale factor |

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

#### B64 encoding

```solidity
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)`

#### B64 decoding

```solidity
function b64To1e18(uint64 b64) internal pure returns (uint256)
```

1. Extract mantissa, decimals, exponent
2. Compute total shift $= e - \text{bias} + d$
3. Return $m \cdot 10^{e - \text{bias} + d}$, normalized to 1e18

#### B64 example

Price: 2500.00 USDC (6 decimals)
- Value: 2,500,000,000 ($2500 \times 10^{6}$)
- Mantissa: normalized to 52 bits
- Decimals: 6
- Exponent: from the normalization shift

#### Where B64 is used, and where it is not

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, $\sigma$, `updatedAt`, `ttl`, `confidence`, `flags`, `maxDeviation` and `sourceTs` in 240 bits. A storage-inclusive audit measured **-1,767 gas on a cold read**, break-even at **0.43 cold reads per write**, and feeds are read far more often than pushed. No exact-WAD alternative fits: a WAD-wide price at BTC scale needs at least 96 bits, which overflows the slot and forces out `sourceTs`, the signed path's monotonic replay nonce. Same reason in the signed wire record, where the 22-byte layout is what a batch pays 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.

### 7.6. Consumer reads

> **Integrating from another protocol:** start at [Consuming Price Feeds](/docs/5-1-5-consuming-price-feeds) instead. Addresses, feed ids, live values, the freshness/pause/confidence safety rules, six worked examples.

| Need | Read | Notes |
|---|---|---|
| Spot / quote | `mark1e18` | fresh mark (1e18 WAD); the only quote source (removes classical curve LVR; residual push-latency LVR + [OEV](/docs/glossary#oev-oracle-extractable-value) remain; see [§5.5](#55-known-risks-lvr-and-oev)) |
| Freshness | $t_{obs}$ vs $\tau$ | $t_{obs} = \min(t_{src}, t_{upd})$, not $t_{upd}$ alone; see [§5.3](#53-staleness-protection) |
| Uncertainty ($1\sigma$ CI) | `confidence` (bps) | widens the spread; halts the swap past `MAX_CONFIDENCE_HALT_BPS` |
| Realized vol | `sigmaPbps` (PBPS) | vol band + staleness surcharge |
| Liveness (external view) | `isFeedFresh(feedId[, maxAge])` | same $t_{obs}$ 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: [Flow Guards](/docs/3-2-1-flow-guards).

### 7.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 `_fetchFeed` → `FeedMathLib.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 ([§5.3](#53-staleness-protection) 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 ([cyclic clock](#cyclic-clock-no-epoch)).

**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. Oracle generations

**Five 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`, [§8.1](#81-external-oracle-v2-packed-slot)) held authority briefly on the cutover day and is now the **rollback** for V3.
- **V3** (session-grant + diff wire, [§8.2](#82-external-oracle-v3-session-grant-diff-wire)) held primary authority from 2026-08-31 and reference authority until 2026-09-01. It is now the **rollback only** ([V4 migration status](#v4-migration-status)): the V3 primary keeper still runs so the rollback stays fresh, the V3 reference keepers are retired.
- **V4** (29-bit lanes + cyclic clock, [§8.3](#83-external-oracle-v4-29-bit-lanes-cyclic-clock)) is **authoritative on both tiers on Arc since 2026-09-01**: 37 primary repoints and 37 reference repoints executed.
- **V5** (`ExternalOracleV5.sol`, wire v6 in [wire v5 and v6](#wire-v5-and-v6)) is the current generation and the implementation behind `OracleBeacon`: quorum-only `push(blob, sigs)` (no session path), one absolute source-second clock per lane, a mandatory confidence entry per price entry, 4 lanes per slot. Which generation an instance runs: [deployed instances](/docs/2-1-contract-addresses).

V1's memory layout and push API are in [§7](#7-feed-data-reference).

### 8.1. External Oracle V2 (packed-slot)

> **Status: superseded on Arc by V3 ([§8.2](#82-external-oracle-v3-session-grant-diff-wire)) on 2026-08-31; V2 is the rollback.** V1 (`ExternalOracle.sol`) is paused and retired on Arc. See [V2 migration status](#v2-migration-status). V2 keeps V1's entire trust model (k-of-n EIP-712 quorum, monotonic replay guard, the volatility-adaptive deviation band, the $\sigma\sqrt{\tau}$ 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](/docs/glossary#oev-oracle-extractable-value)). Interface `IExternalOracleV2.sol`.

#### Why V2

Push cost on an L2 reduces to

$$G = F + \Delta F_{quorum} + S \cdot c_{sstore}$$

where $F$ is the base transaction fee, $\Delta F_{quorum}$ the signature-verification cost, $S$ the number of storage slots written and $c_{sstore}$ the per-slot cost. $F$ and $\Delta F_{quorum}$ 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 dominates.

V2 attacks it on two axes: pack many feeds per slot (fewer `SSTORE`s 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 $\sigma\sqrt{\tau}$ premium quoted, and therefore less OEV left on the table.

#### V2 mark representation: mark1e18, not B64

V2 drops [B64](#75-b64-float-encoding) from the interface: `IOracle.FeedData.mark1e18` is a plain 1e18 WAD, and B64 remains only as V1's internal storage encoding ([§7.5](#75-b64-float-encoding)). On the wire and in storage V2 uses a compact **normalized binary float per feed** (below), decoded to a WAD by `getFeed`.

#### V2 storage model: the packed price slot

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

```bitfield 256
0..27     lane0    (28b)
28..55    lane1    (28b)
56..83    lane2    (28b)
84..111   lane3    (28b)
112..139  lane4    (28b)
140..167  lane5    (28b)
168..195  lane6    (28b)
196..223  lane7    (28b)
224..255  tsDs     (u32, decisecond slot timestamp)
```

Each lane is itself packed:

```bitfield 28
0..22   mantissa  (u23; MSB set = live, all-zero = STALE sentinel)
23..27  exp       (u5)
```

`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 \times 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\times$ 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 $\pm 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 $2^{31}$-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.

$\sigma$ 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 `SSTORE`s; $\sigma$ is read at quote time to drive the premium and spread.

#### V2 positional identity: no `tickerId` on the wire

A feed is addressed by its position: $\text{globalIndex} = 8\,\text{slotId} + \text{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.

#### V2 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 `SSTORE`s.** 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 `SSTORE`s to one. Derive the clustering from the tape's return-correlation matrix in the same replay that tunes $\theta$.
2. **Fewer swap `SLOAD`s.** 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.7](#77-transient-caching-eip-1153)), 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`.

#### V2 lane 0 = the numeraire (per-slot convention)

**Lane 0 of the hot slot is reserved for the numeraire.** Its mark (or depeg reference) is on the hot path of nearly every swap, so a fixed always-first position warms that slot early and keeps it 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. It 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, since it rarely takes a push and can carry a fixed value. When the numeraire is itself live-priced, replication costs a lane and a per-slot write, so it becomes a per-deployment trade-off.

#### V2 signed push: same quorum, variable width

```solidity
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:

```bitfield 72
0..7    version     (u8)
8..39   seq         (u32)
40..71  sourceTsDs  (u32, deciseconds since the immutable epoch)
```

```bitfield 800
0..31     slotId     (u32)
32..287   priceWord  (bytes32, 8 lanes + slot ts)
288..543  sigmaWord  (bytes32, 8 x u32 pbps)
544..799  confWord   (bytes32, 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 ([§5.4](#54-deviation-bounds)), the σ-floor economic breaker, the future-timestamp and deadline rejects, and the guardian fast-freeze.

#### V2 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 [§5.4](#54-deviation-bounds) 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 $2^{31}$-fold price move: a redenomination or a near-zero de-peg). A dedicated setter exists for that rare event:

```solidity
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](/docs/3-1-overview). 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. V4 resolves the routine path onto the quorum ([quorum-signed rebias](#quorum-signed-rebias-and-why-not-a-role)).

#### V2 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 oracle and pool logic, not a rewrite of either.

#### V2 migration status

**V2 was a redeployment, not an upgrade.** Neither `ExternalOracle` nor `ExternalOracleV2` sat behind a proxy, so each cutover deployed a new instance at a new address and re-pointed every consumer at it, per pool and per asset. That lane is gone: from V5 the oracle is one immutable `OracleProxy` behind `OracleBeacon`, and an implementation change is a `LISTING`-tier beacon upgrade ([Admin](/docs/3-1-2-admin)).

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

| Step | State on Arc |
|---|---|
| 1. Deploy `ExternalOracleV2` with its own signer set | Done (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, $\theta$ is tuned | Done (2026-08-30/31) |
| 3. Cutover | Done (2026-08-31): all 37 legs repointed V1→V2→V3 the same day via the since-deleted per-leg repoint op, both V1 instances paused for the crossing. V2 primary `0xcd7d5d0fCd08f08570D95bdd159eB148e453aB37`, reference `0xebc298A8d2d98114C5b448EC9e1f96e176aBF0d5`; V3 ([V3 migration status](#v3-migration-status)) is the live pair, V2 the rollback |

#### V2 measured gas (reference implementation, 2-of-3 signed)

Execution gas only, from `OracleGasBench.t.sol`. Reproduce with `forge test --mp OracleGasBench.t.sol -vv`.

| Push shape | Gas | Gas/feed |
|---|---|---|
| Narrow: 1 slot, 3 co-moving feeds, slots warm | 24,299 | 8,100 |
| Narrow: same, slots cold | 42,303 | 14,101 |
| Full 8-lane slot, warm | 39,080 | 4,885 |
| Full 8-lane slot, cold | 67,084 | 8,385 |
| Cold first push, 11 feeds | 189,171 | 17,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**.

### 8.2. 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 ([§8.1](#81-external-oracle-v2-packed-slot)) is the rollback.
>
> **Superseded on BOTH tiers by V4 ([§8.3](#83-external-oracle-v4-29-bit-lanes-cyclic-clock)) 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.

#### Why V3

Profiling a full V2 push transaction (~123k gas) put the two `ecrecover`s at only **~13.4k of execution gas**: a small share. The dominant costs were per-lane config `SLOAD`s 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 ([session grants](#session-grants-trust-model-and-bounds)).
- **Diff wire**: 12-byte header + **4 bytes per feed** price entries; σ/confidence travel only when changed ([wire v4 summary](#wire-v4-summary)).

```bitfield 96
0..7    version     (u8, = 4)
8..39   seq         (u32)
40..71  sourceTsDs  (u32)
72..79  nP          (u8, price entries)
80..87  nS          (u8, sigma entries)
88..95  nC          (u8, conf entries)
```

```bitfield 32
0..7    gi    (u8, global feed index)
8..31   lane  (u24, 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 `SLOAD`ed, 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 (below).

#### V3 storage: 22-bit lanes, 10 per slot, one config word

```bitfield 256
0..21     lane0   (22b)
22..43    lane1   (22b)
44..65    lane2   (22b)
66..87    lane3   (22b)
88..109   lane4   (22b)
110..131  lane5   (22b)
132..153  lane6   (22b)
154..175  lane7   (22b)
176..197  lane8   (22b)
198..219  lane9   (22b)
220..251  tsDs    (u32, slot timestamp)
```

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

```bitfield 22
0..17   mantissa  (u18; MSB set = live, all-zero = STALE sentinel)
18..21  exp       (u4, 16 octaves)
```

An 18-bit mantissa normalized to $[2^{17}, 2^{18})$ gives a worst-case relative step of $2^{-17} \approx 0.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:

```bitfield 25
0..15   maxDevBps  (u16)
16..23  expBias    (u8)
24..24  paused     (1b)
```

Slots stay **class-pure**, as in [§8.1](#81-external-oracle-v2-packed-slot): a market-closed class darkens only its own slots. σ and confidence remain dirty-written in their own slots.

#### 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 [V2 config surface](#v2-config-surface-and-the-rebias-flow).
- **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.

#### 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.

#### V3 consumer reads: unchanged

`getFeed(feedId)` returns the same `IOracle.FeedData`; every consumer path (`FeedMathLib.gate`, `Pricing`, the depeg breaker, `isFeedFresh`, the $\sigma\sqrt{\tau}$ premium) is unchanged from [V2 consumer reads](#v2-consumer-reads-unchanged-surface). The cutover was again a repoint, not a rewrite.

#### V3 migration status

| Step | State on Arc |
|---|---|
| 1. Deploy V3 pair (primary + reference) | Done (2026-08-31) |
| 2. Queue the 37 per-leg repoints | Done (2026-08-31) |
| 3. Arm keeper/NXR wire v4, shadow-push | Done (2026-08-31); session pushes live |
| 4. Execute repoints; V2 becomes the rollback | Done (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](/oracle) decodes V2, V3 and V4 blobs in the browser, and flags session pushes ([session grants](#session-grants-trust-model-and-bounds)) as such, so anyone can verify the k-of-n signatures on any push without trusting us or the chain explorer.

#### V3 measured gas

Foundry, EIP-7623-aware **full-transaction** gas per feed, 2-of-3 quorum, same bench methodology as [V2 measured gas](#v2-measured-gas-reference-implementation-2-of-3-signed):

| design | 10 feeds | 16 feeds |
|---|---|---|
| V2 | 11,208 | 8,899 |
| V3 (session) | 5,158 | 4,202 |
| blind-SSTORE floor (unsigned, no guards) | ~2,800 | - |

The floor row is the category's lower bound: 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).
- 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.

#### V3 relay rotation and 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 ([§5.3](#53-staleness-protection)), 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 ([session grants](#session-grants-trust-model-and-bounds)). 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 failed in production: replicas poll NXR independently and hold different blobs (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 and 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 the session model 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.

Push triggers and the cadence work done on this generation: [§6.2](#62-push-triggers-deviation-measured-against-the-spread-it-defends).

### 8.3. 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 repoints executed, none skipped; V3 ([§8.2](#82-external-oracle-v3-session-grant-diff-wire)) is the rollback.
>
> **The REFERENCE tier moved the same day.** `refPrimary` is the V4 reference on all 37 spoke legs ([V4 migration status](#v4-migration-status)). 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 was a then-`BASE`-tier per-leg repoint (lane since deleted; V5+ moves via `OracleBeacon` at `LISTING`) rather than the upgrade tier a read-ABI change would have forced.

#### 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^{-17} \approx 0.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.

#### V4 storage: 29-bit lanes, 8 per slot

```bitfield 256
0..231    lanes    (8 x 29b)
232..251  ts       (u20, deciseconds since midnight UTC)
252..253  dayMod   (u2, source day mod 4)
254..255  unused   (2b)
```

```bitfield 29
0..24   mantissa  (u25, MSB set = live, all-zero = STALE sentinel)
25..28  exp       (u4, 16 octaves)
```

A 25-bit mantissa normalized to $[2^{24}, 2^{25})$ steps by $2^{-24} = 5.96 \times 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.

#### 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\times$) and 7 down ($128\times$) 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 `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

```solidity
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 [V2 config surface](#v2-config-surface-and-the-rebias-flow): 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 `signed.rs` (`lane_map_hash`, `declared_bias`, `lanes_by_gi`) and the log-sourced `BiasStore` in `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.

#### 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 the 1 s `min_push_gap_s` floor.

**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 $[\,\text{now} - \texttt{MAX\_RECON\_AGE},\ \text{now} + \texttt{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.

#### Wire v5 and v6

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.

$$\text{blob bytes} = 11 + 5n_P + 5n_S + 3n_C$$

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

**Wire v6 (`ExternalOracleV5`, the beacon implementation).** Header grows to 12 bytes: `ver:u8(=6) | seq:u32 | srcSecs:u32 | nP:u8 | nS:u8 | nC:u8`. `srcSecs` is the absolute source second: the u24 cyclic deci-second aliased every 24 h, so a captured blob could re-arm a stalled lane a day later.

The header now requires `nC == nP`, and price and confidence entries are walked in lockstep on the same `gi` sequence, so every accepted mark carries the confidence it was attested with. Entry sizes stay 5 / 5 / 3 B; the price lane payload is `mant:u25 | exp7:u7` with an absolute exponent (`mark = mant << (exp7 - 16)`), so `expBias` and `setFeedExpBias` are gone.

The EIP-712 domain and `BatchQuoteV4(bytes32 blobHash)` typehash are unchanged. A lane's clock advances only on an accepted price entry; a blob with no price entry is refused.

#### 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 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(\text{lane step},\ \text{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 [wire v4](#wire-v4-summary) 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.

#### V4 measured gas

`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 OracleGasBenchV4.t.sol -vv`.

| shape | V3 session | V4 session | delta |
|---|---|---|---|
| 26 feeds (Arc manifest) | 3,441 | 3,783 | +9.9% |
| 66 feeds | 2,705 | 3,006 | +11.1% |
| 16 feeds | 4,202 | 4,291 | +2.1% |
| 10 feeds | 5,158 | 5,828 | +13.0% |
| 8 feeds, 1 slot | 6,038 | 6,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.

#### 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.

#### V4 migration status

| Step | State on Arc |
|---|---|
| 1. Deploy `ExternalOracleV4` + register 26 feeds across 5 class-pure slots | Done (2026-09-01), CREATE3, `0x842c2736F072A8A7b523D23bd3Ef21F21AC24d5C` |
| 2. Read-surface parity pre-flight (`readSurfaceParity()`) against the outgoing V3 primary | Done, required to keep the repoint on the then-`BASE` tier |
| 3. Arm keeper/NX Rates wire v5, shadow-push | Done (2026-09-01); pushes live, every price entry in a decoded live blob at exponent 7 ([the exponent window](#the-exponent-window-and-expbias)) |
| 4. Queue 37 per-leg repoints (then-`BASE` tier, 2 h delay, 7 d grace; lane since deleted) | Done (2026-09-01), 37/37 `requestOp` landed |
| 5. Execute repoints; V3 becomes the primary rollback | Done (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 instance | Done (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 per-leg repoint execution 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 arm | Done. `gen-oracle-lanes.py` emits the v5 arm for `ExternalOracleV4`, once per instance with `role: 'primary' \| 'reference'`, and the [transparency page](/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 was deliberately per-leg rather than atomic: the repoint execute (lane since deleted) validated 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.

**A step-1 mistake is discarded by re-mining the salt, never repaired in place.** The constructor installs the signer set atomically and there is no `grantSigner`, so a wrong set deployed into a fresh instance cannot be edited. Nothing live reads the instance until step 4, so the whole instance is thrown away: re-mine its CREATE3 salt and redeploy with the correct quorum. Never repoint a live leg at a bad instance and then correct it in place: consuming a reserved CREATE3 address that way is the one unrecoverable ceremony mistake ([Contract Addresses §7](/docs/2-1-contract-addresses)).

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 per-leg execute 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.

---

## 9. Transparency: verify it yourself

No part of the trust chain has to be taken on faith from a BTR server. The Oracle Transparency page in the BTR front-end reconstructs and re-verifies the whole model client-side:

- **Signer roster.** Rebuilt from `SignerGranted` / `SignerRevoked` logs, then cross-checked against `signers(addr)` RPC reads before display. Pending timelocked governance (a queued grant or quorum decrease) is shown with its ETA, so a loosening is visible before it lands.
- **Quorum badge.** The live `signerThreshold` of `signerCount`, read on-chain.
- **Feed table.** Mark, $\sigma$, confidence, age, `maxDeviation`, and freshness/paused status, straight from `getFeed()`.
- **Push log.** Recent push transactions fetched from a public block explorer, decoded from calldata, and their signatures re-recovered in the browser against the on-chain granted-signer set, under the domain of the instance that accepted them (`BTR ExternalOracleV4` / `1` and `BatchQuoteV4` on V4 and V5, [§3.2](#32-what-the-digest-binds)). The green "k/n verified" badge is computed locally, so a lying explorer cannot forge a passing proof.

  V1 emitted nothing, so transparency there is calldata plus `getFeed()` state plus governance logs. V4 and V5 emit `SlotsPushed(seq, sourceTs, acceptedMask, blobHash)` on every push path (deci-seconds on V4, absolute seconds on V5), plus V4 `LanesSkipped(slotId, laneMask)` and V5 `LaneQuarantined` on a band failure only. A monotonic-skip lane (`srcSecs <= obs`, dropped silently in `_applyLane`) shows up solely as a bit in `SlotApplied(slotId, accMask, skipMask, srcSecs)`.

  A push log can therefore be indexed from events and the accepted mask read directly. Only a session push carries a `msg.sender` bound to the granted relay; a signed push carries none.
- **Relayers seen.** Listed as informational only, labelled "relayer, not price authority": any address may land a signed batch.
- **Per-price redirect into NX Rates.** Each decoded record deep-links to the NX Rates public verifier for the same `(symbol, sourceTs)` attestation, so an auditor can re-check the price against NX Rates' own signed-quote view. Same one-way citation as [§1.2](#12-where-prices-come-from): BTR shows what it verified on-chain, NX Rates shows what it attested off-chain, and the two are checked to agree.

---

## 10. Related

| Page | Content |
|---|---|
| [Feed Oracle](/docs/1-2-2-internal-oracle) | Oracle modes, full `FeedData` struct, signed push API, data-flow view |
| [Spread & Fees](/docs/1-1-4-spread-fees) | How $\sigma$, confidence and staleness affect fees |
| [Parametrization](/docs/1-1-7-parametrization) | Feed configuration reference |
| [Flow Guards](/docs/3-2-1-flow-guards) | Base-token and spoke depeg circuit breakers, and what a stale reference feed blocks |
| [Security Overview](/docs/3-overview) | Defense-in-depth, threat model, trust assumptions |
| [Access control](/docs/3-1-overview) | Roles matrix, timelock tiers, halt authority |
| [Guardian](/docs/3-1-4-guardian) | The levers this role escalates to |
| [Risk Steward](/docs/3-1-3-risk-steward) | The other bounded key, and the shared 2θ invariant |
| [Observability](/docs/3-3-observability) | Every metric named above, with healthy ranges |
| [Consuming Price Feeds](/docs/5-1-5-consuming-price-feeds) | Read-side integration: addresses, feed ids, safety rules, worked examples |
| NX Rates (NXR) | Price acquisition, aggregation and signing are documented by NX Rates and not reproduced here (one-way citation); see NXR's signed-quote specification (provided to integrators on request) |
