---
title: "Consuming Price Feeds"
description: "Third-party integration guide for the BTR oracle: getFeed, isFeedFresh, and the four ways a naive read hurts you"
audience: tech
type: guide
status: live
lang: en
updated: "2026-09-02"
publish: true
---
# Consuming price feeds

You want a price. This page is the whole of what you call, what comes back, and
the four things that will hurt you if you read `mark1e18` and stop.

Nothing here is producer-side. How marks get on chain - the signed blob, the
lane packing, the cyclic clock, the keeper's push trigger - is
[Oracles](/docs/3-4-oracles) and you do not need any of it to integrate. The
read surface below is the entire consumer ABI.

---

## 1. The read surface

Three functions. That is all of it.

```solidity
interface IOracle {
  struct FeedData {
    uint256 mark1e18;      // mark, 1e18 WAD
    uint32  sigmaPbps;     // realized vol, PBPS (1e6 = 100%)
    uint32  updatedAtSecs; // observation time, plain unix seconds
    uint16  ttlSecs;       // freshness window
    uint16  confidenceBps; // 1-sigma CI on the mark
    uint16  flags;         // bit0 = paused (guardian freeze)
    uint16  maxDeviationBps;
    uint48  sourceTsMs;    // NXR-signed source time, ms
  }

  function getFeed(bytes32 feedId) external view returns (FeedData memory);
  function isFeedFresh(bytes32 feedId, uint32 maxAge) external view returns (bool);
  function isFeedFresh(bytes32 feedId) external view returns (bool);
}
```

Source: `src/interfaces/IOracle.sol`.

| Field | Unit | Note |
|---|---|---|
| `mark1e18` | 1e18 WAD | The price. **`0` is a sentinel, never a price** |
| `sigmaPbps` | PBPS | Realized volatility, for haircuts, buffers and spreads |
| `updatedAtSecs` | unix seconds | Observation time of the last accepted push |
| `ttlSecs` | seconds | The feed's own declared freshness window |
| `confidenceBps` | bps | 1-sigma CI on the mark, decoupled from `sigmaPbps` |
| `flags` | bitfield | **bit0 = paused**; bits 1-15 unused and reserved |
| `maxDeviationBps` | bps | Per-push deviation band enforced on the value read |
| `sourceTsMs` | ms | Signed source time; on V4 `updatedAtSecs * 1000` |

Field semantics, on-chain layouts and push-side guards are
[Oracle system §2](/docs/3-4-oracles#2-feeddata-and-the-two-storage-layouts);
the table above is the consumer cut. `updatedAtSecs` reads as plain unix
seconds; under it V4 keeps a cyclic count reconstructed against
`block.timestamp`, and an out-of-bounds reconstruction reports a failing age
rather than a fresh timestamp
([Oracle system §11](/docs/3-4-oracles#11-external-oracle-v4-29-bit-lanes-cyclic-clock)).

### 1.1. What is not on the surface

- **No `getMark`.** The struct is the read; take `mark1e18` from it.
- **No enumeration.** `getFeedIds()` reverts. Take feed ids from §3.2.
- **No `exists`.** The idiomatic probe is `isFeedFresh(feedId)`, which returns
  `false` for an unknown feed rather than reverting.
- `getFeed` on an unknown `feedId` **reverts** `FeedNotFound(bytes32)`
  (selector `0x4e054b40`). Verified on chain against the live primary.

---

## 2. Addresses

Every chain carries two instances: a **primary** and a **reference**. Both are
`ExternalOracleV4`, both carry the same feed set, both expose the identical
`IOracle` surface. They differ only in who pushes to them.

**The addresses are deterministic.** Both tiers deploy through a CREATE3
factory, so an address is a pure function of the factory, the deployer key and
a mined salt, independent of the contract's bytecode. Two consequences:

- **Chain-generic within a fleet.** Every mainnet chain resolves the primary to
  one address and the reference to another. Every testnet chain resolves them
  to a second, different pair. Deploy a new chain and the addresses are already
  known.
- **Fleet-specific, not universal.** The deployer EOA is part of the salt
  preimage ($\texttt{effSalt} = \texttt{keccak256}(\texttt{eoa} \Vert
  \texttt{salt})$), and the two fleets deploy under different keys. A mainnet
  address will never hold code on a testnet chain and the reverse, so do not
  read "same address everywhere" as spanning both.

Resolve the pair for the chain you are on rather than pasting a constant:
`GET /v1/venues` returns the oracle addresses per chain, and
`GET /v1/abis/{name}` the ABIs, both documented in
[API & SDK Reference](/docs/5-2-1-api-sdk-reference).

### 2.1. What the reference tier is for

The reference is **a second, independently pushed instance of the same feeds**,
running its own keeper tier and its own relay set. It exists so that no single
oracle instance can move a price unchallenged.

Inside BTR, every non-base pool leg arms a symmetric band against it. Before a
swap prices, `PoolIOLib.priceBandGuard` reads the asset's mark from the
primary, reads the reference mark from a **contractually distinct** oracle
address (`validateOracleConfig` rejects `refPrimary == primary`), and reverts
`PriceOutsideRefBand` when

$$\lvert m_{\text{primary}} - m_{\text{ref}} \rvert \cdot 10^{4} > m_{\text{ref}} \cdot \texttt{refBandBps}$$

Arming it is mandatory for every external spoke; only the base may run
disarmed.

A cautious integrator should do the same thing: **read both and require
agreement.** §6.3 is that, in nine lines. A stalled primary, a stalled
reference, or a divergence between them all surface as one check.

> **Scope of the guarantee.** The two tiers are independent in keeper, relay
> set and contract, but they currently share the same 2-of-3 attester keys
> (documented in-source at `PoolIOLib.sol`). The band therefore protects
> against one tier stalling, one relay misbehaving, or one instance being
> repointed - **not** against a full signer-quorum compromise. Size your
> trust accordingly.

### 2.2. Mainnet addresses are reserved, not live

There is no mainnet deployment yet. Both mainnet tiers already have their
CREATE3 address **reserved**:

| Tier | Reserved mainnet address |
|---|---|
| **Primary** | `0xbbbbbbbb76433322889ddbb7a1d1eb45a135ca4d` |
| **Reference** | `0xbbbbbbbbd25e9fe06b53cd32803e38f5fbbf8c70` |

Nothing is deployed at either address on any mainnet today, and neither holds
code until the launch ceremony runs. They are published so that a lookalike
address surfacing before launch is recognisably not ours.

---

## 3. Feed ids

A feed id names an **instrument**, not an asset pair. The canonical identity is
a [MITCH ticker id](https://github.com/nxrates/mitch/blob/main/model/ticker.md):
a 64-bit integer that carries the instrument type alongside both legs.

| Bits | Field | Width |
|---|---|---|
| 63–60 | Instrument type | 4 |
| 59–56 | Base asset class | 4 |
| 55–40 | Base asset id | 16 |
| 39–36 | Quote asset class | 4 |
| 35–20 | Quote asset id | 16 |
| 19–0 | Sub-type (expiry, strike; reserved) | 20 |

$$\texttt{id} = (\texttt{type} \ll 60) \mid (\texttt{baseClass} \ll 56) \mid (\texttt{baseId} \ll 40) \mid (\texttt{quoteClass} \ll 36) \mid (\texttt{quoteId} \ll 20) \mid \texttt{subType}$$

Instrument types: `0x0` Spot, `0x1` Future, `0x2` Forward, `0x3` Swap, `0x4`
Perpetual, `0x5` CFD, `0x6` Call, `0x7` Put, `0xC` Fund or Trust. Asset classes:
`0x0` Equities, `0x3` Forex, `0x4` Commodities, `0x6` Crypto, `0xA` Indices.
The full enumerations are in the MITCH spec linked above.

**Why the instrument type has to be in the id.** Hashing a symbol pair, or a
pair of token addresses, encodes no instrument type: spot, perpetual, quarterly
future and an option on the same underlying all collapse onto one identifier.
Those instruments do not carry the same price, and a consumer that reads one
believing it holds another is mispriced with no error to catch. MITCH makes the
distinction part of the identity, and the id stays decodable on chain: given a
`tickerId` you can read the type and both legs out of it with shifts, no
registry lookup and no preimage.

The intended on-chain form is the ticker id widened, not hashed:

$$\texttt{feedId} = \texttt{bytes32(uint256(tickerId))}$$

> ⚠ **Not deployed yet.** The live oracle still keys feeds on
> $\texttt{keccak256(abi.encodePacked(asset, quote))}$, a 40-byte packing of
> two token addresses. That migration is pending. **Use the on-chain `feedId`
> column in [§3.2](#32-the-feed-set) today** - calling `getFeed` with a
> MITCH-derived id reverts `FeedNotFound`. The MITCH column is published so
> that integrators can key their own systems on the identity that will survive
> the migration.

V2 and later take `feedId` as an opaque `bytes32` at `registerFeed`, so the
derivation is a deployment convention rather than something the contract
enforces. For anything load-bearing take the id from §3.2 rather than
recomputing it.

### 3.2. The feed set

All 26 feeds, generated from the keeper manifest. Every one is Spot, sub-type
`0`: no perpetual, future or option feed is registered, which is exactly the
distinction the MITCH id will keep honest once more than one instrument on the
same underlying exists.

| `idx` | Feed | MITCH `tickerId` (dec) | MITCH `tickerId` (hex) | Type | Base | Quote | On-chain `feedId` (live today) |
|---|---|---|---|---|---|---|---|
| 0 | USDT-USDC | `451698500104617984` | `0x0644c16484500000` | Spot | Crypto / 17601 | Crypto / 18501 | `0xfa722ae80d6181ca931f45c80582c173b9c19cd30c1632e864e8f48ea62a6548` |
| 1 | USDS-USDC | `448399965221289984` | `0x0639096484500000` | Spot | Crypto / 14601 | Crypto / 18501 | `0x4d9df04bbf62ab0e8418c56a2fea063a7956bc08674600862a95970c583f3be5` |
| 2 | USD1-USDC | `442022797780189184` | `0x0622616484500000` | Spot | Crypto / 8801 | Crypto / 18501 | `0x4c7fec22c40835f297ef183fc68a20f5a965997cddedf9fcf5bd18b3d0898d85` |
| 3 | PYUSD-USDC | `445981039640182784` | `0x0630716484500000` | Spot | Crypto / 12401 | Crypto / 18501 | `0xd4ebce1baf00f6124f7a0bd347ef8170aaa7ce6e5dcf595879e3c61676921e99` |
| 4 | EURC-USDC | `439219043129360384` | `0x06186b6484500000` | Spot | Crypto / 6251 | Crypto / 18501 | `0x9af29d8ae5269a47d972f6e5a188878d8e45cb2c7f7ce637492bdaef05a980be` |
| 5 | QCAD-USDC | `456096546615721984` | `0x0654616484500000` | Spot | Crypto / 21601 | Crypto / 18501 | `0x1c9b7f4cc3a7295cb420362f039e13565a7f80f9d0b7023405023d1db74735ea` |
| 6 | AUDF-USDC | `456206497778499584` | `0x0654c56484500000` | Spot | Crypto / 21701 | Crypto / 18501 | `0x20dc0d8625fb1ae982d08763105cdce98e9a95ca59c4903190119c9fe88a47cd` |
| 7 | JPYC-USDC | `456426400104054784` | `0x06558d6484500000` | Spot | Crypto / 21901 | Crypto / 18501 | `0xf3570f02e056765d6c42a5b3624067f3b80c80b9d22355c71b534c24eb8ae1e9` |
| 8 | KRW1-USDC | `456536351266832384` | `0x0655f16484500000` | Spot | Crypto / 22001 | Crypto / 18501 | `0x2642c5e9691dbb5c5674a2a24ebb68e4df723f0656e3d4e2e45fa028c6caf650` |
| 9 | WETH-USDC | `438724262896861184` | `0x0616a96484500000` | Spot | Crypto / 5801 | Crypto / 18501 | `0xacb54d59d0c847602722bfdec8aaaf92ead1385b3402cad482b3c6484a6efc1c` |
| 10 | WBTC-USDC | `453457718709059584` | `0x064b016484500000` | Spot | Crypto / 19201 | Crypto / 18501 | `0x63b49bbd6b259a4c3500020453ad775a8df2ff9e423fe5228644f18f495262bf` |
| 11 | CBBTC-USDC | `436635190804086784` | `0x060f3d6484500000` | Spot | Crypto / 3901 | Crypto / 18501 | `0x27f98ddc32af5a6272f676d428fd004af1718c18541118dc044c0ac3a2b612fd` |
| 12 | BNB-USDC | `434436167548534784` | `0x06076d6484500000` | Spot | Crypto / 1901 | Crypto / 18501 | `0x5398b2b4caab86e6c562e30f6ecb15c4b87c20ed7595ad48b4048b62005b9888` |
| 13 | XAUT-USDC | `454557230336835584` | `0x064ee96484500000` | Spot | Crypto / 20201 | Crypto / 18501 | `0xaf63e9c459822846879d75246ea10ee933d53a8f206c51c5150270aadd42625f` |
| 14 | PAXG-USDC | `454447279174057984` | `0x064e856484500000` | Spot | Crypto / 20101 | Crypto / 18501 | `0xb8b495d5826591b537a47841255ffbca7f1f0f56afff8f5e7bc565f1e20b338c` |
| 15 | USDC-USD | `452687840255410176` | `0x0648453138900000` | Spot | Crypto / 18501 | Forex / 5001 | `0x0189091eac3c33dc88b48c58f75a1d978253e7fb2d4a1711b5701172b083c487` |
| 16 | INTC-USDC | `7687117506347008` | `0x001b4f6484500000` | Spot | Equities / 6991 | Crypto / 18501 | `0x058dc9a04c0ebce2bb560948628013d466bdc2bfb8042e33d4a7a0dde2045fba` |
| 17 | AMD-USDC | `166457972359168` | `0x0000976484500000` | Spot | Equities / 151 | Crypto / 18501 | `0xa6c9396c58cb1c50e6f2a6139404b148ecc7f86489d9d4faa01e3eb5228fc652` |
| 18 | NVDA-USDC | `11205554715230208` | `0x0027cf6484500000` | Spot | Equities / 10191 | Crypto / 18501 | `0x9a0882814ace331414f8c2c7cb34e815e4c7092f8de7df39bba1fc92968deb69` |
| 19 | ASML-USDC | `1134028204802048` | `0x0004076484500000` | Spot | Equities / 1031 | Crypto / 18501 | `0x6b434d49a41b22478b4b1d3fca59c90e5dbfc185cc1b5e2a9a3efd2f901e0c23` |
| 20 | SPCX-USDC | `14102767854419968` | `0x00321a6484500000` | Spot | Equities / 12826 | Crypto / 18501 | `0x1729d32f332780e7a939b7ca2a73ceb60cf507d64ed28e15023dad847f50fe47` |
| 21 | AVGO-USDC | `1947666809356288` | `0x0006eb6484500000` | Spot | Equities / 1771 | Crypto / 18501 | `0x7d9c97fe812e6df01e2604baeade55e60503c8d793300e69d6da8efa52618b54` |
| 22 | TSLA-USDC | `15075835645001728` | `0x00358f6484500000` | Spot | Equities / 13711 | Crypto / 18501 | `0x91a8aa38cb67e3a567c5b58b125e69e218c5a97677de4b4ce327a1bd281e0952` |
| 23 | MSFT-USDC | `10007087040954368` | `0x00238d6484500000` | Spot | Equities / 9101 | Crypto / 18501 | `0x778b165de0bfe24e8806a0334dabd237c8f5844fb3d60a8d5b623d352120e784` |
| 24 | ORCL-USDC | `11425457040785408` | `0x0028976484500000` | Spot | Equities / 10391 | Crypto / 18501 | `0x83c6210ed83e6bbd8db6d6dd43893d46cbc0a20efce89c30f6be2ce536ea74db` |
| 25 | META-USDC | `9930121227010048` | `0x0023476484500000` | Spot | Equities / 9031 | Crypto / 18501 | `0x2d2201820627f4019b4ddd9d9742fc14a750d4df2f5413961e91c03e8af9581a` |

The three ids used in the examples below: EURC-USDC (FX), NVDA-USDC (equity),
WETH-USDC (crypto).

### 3.3. Live values

Read from the primary at 2026-09-01T17:10:18Z via `cast call`:

| Feed | `mark1e18` | `sigmaPbps` | `ttlSecs` | `confidenceBps` | `maxDeviationBps` | `flags` | `isFeedFresh` |
|---|---|---|---|---|---|---|---|
| EURC-USDC | `1.159098904529076224` | 400 | 3600 | 2 | 75 | 0 | `true` |
| NVDA-USDC | `218.509991990714695680` | 2320 | 600 | 3 | 100 | 0 | `true` |
| WETH-USDC | `2432.481978935538614272` | 2816 | 600 | 4 | 100 | 0 | `true` |

Same three feeds on the reference, same sweep:

| Feed | `mark1e18` | `sigmaPbps` | cross-tier deviation |
|---|---|---|---|
| EURC-USDC | `1.159073134725300224` | 400 | 0.22 bps |
| NVDA-USDC | `218.604989795354542080` | 2304 | 4.35 bps |
| WETH-USDC | `2433.225354349031456768` | 2832 | 3.06 bps |

Reproduce any row:

```bash
# ORACLE: the primary for your chain, from GET /v1/venues
# RPC:    any endpoint for that chain
cast call "$ORACLE" \
  "getFeed(bytes32)((uint256,uint32,uint32,uint16,uint16,uint16,uint16,uint48))" \
  0xacb54d59d0c847602722bfdec8aaaf92ead1385b3402cad482b3c6484a6efc1c \
  --rpc-url "$RPC"
```

---

## 4. Safety

This is the section that matters. An integrator who reads `mark1e18` and stops
has built a liquidation engine that fires on a stale price.

```mermaid
flowchart TB
  A["getFeed(feedId)"] --> B{"pause bit set?"}
  B -->|yes| X["REJECT: guardian freeze"]
  B -->|no| C{"mark1e18 == 0?"}
  C -->|yes| X2["REJECT: never pushed / rebiased"]
  C -->|no| D{"isFeedFresh(feedId, myMaxAge)?"}
  D -->|false| X3["REJECT or degrade: stale"]
  D -->|true| E["widen by confidenceBps, size by sigmaPbps"]
  E --> F["use the price"]
```

### 4.1. Always gate on freshness

`isFeedFresh(feedId)` gates against **the feed's own `ttlSecs`**. That is the
loosest bound the protocol will ever accept, not a bound tuned to your product.
The two-argument form lets you impose a stricter one:

```solidity
require(oracle.isFeedFresh(feedId, 60), "stale");
```

A perpetuals venue liquidating at 10× leverage should demand 60 s on a 600 s
feed. The arithmetic is direct: WETH's live $\sigma$ is 2,816 pbps (28.16 %
annualized). Over 540 s of extra permitted age - the difference between the
feed's TTL and a 60 s bound - the one-sigma move is

$$\sigma\sqrt{\tau} = 0.2816 \cdot \sqrt{\tfrac{540}{31{,}536{,}000}} \approx 0.117\% \approx 11.7\ \text{bps}$$

so a two-sigma adverse move inside the TTL window is ~23 bps. That is more than
the maintenance margin of a 10× position can absorb. The feed is not wrong; the
TTL is a protocol-wide floor and you are the one who knows your leverage.

Two mechanics worth knowing:

- The clock is $t_{\text{obs}} = \min(\lfloor \texttt{sourceTsMs}/1000 \rfloor, \texttt{updatedAtSecs})$, not
  `updatedAtSecs` alone. Computing age off `updatedAtSecs` under-states it by
  the relay lag, which is how a feed reads fresh off chain and still fails on
  chain.
- `isFeedFresh` returns `false` for an unknown feed and `false` for a paused
  feed regardless of age. It never reverts. `getFeed` on an unknown feed does
  revert.

### 4.2. Always fail closed on `flags & 1`

Bit 0 is the guardian freeze. It is a fast-freeze lever pulled by a role that
does not need a quorum, and it is pulled when something is wrong with the
underlying data - not as routine maintenance.

```solidity
if (f.flags & 1 != 0) revert FeedPaused(feedId);
```

Fail **closed**. Do not fall back to a cached mark, do not fall back to the
last good value, and above all do not fall back to a spot AMM read, which is
what the freeze exists to protect you from. BTR's own consumer path reverts
`FeatureDisabled(FEED)` here and does nothing else.

Also treat `mark1e18 == 0` as dead. It is the sentinel for a feed that is
registered but has never received an accepted push, or one whose lane was
zeroed by an exponent rebias. `0` is not a price and no arithmetic on it is
meaningful.

### 4.3. Use `confidenceBps` and `sigmaPbps`

The struct hands you a price **and its error bars**. Discarding them is
discarding most of what distinguishes this feed from a number in a mapping.

| Field | Answers | Use it to |
|---|---|---|
| `confidenceBps` | how sure are we of *this mark, right now* | widen a spread, add a settlement buffer |
| `sigmaPbps` | how much does this asset *move* | size a haircut, an LTV, a margin requirement |

They are decoupled deliberately: a thin-book asset can be volatile and
confidently marked, or calm and poorly marked, and those call for different
responses. BTR's own pools halt outright above 1,000 bps of confidence
(`MAX_CONFIDENCE_HALT_BPS`); a sane ceiling of your own is cheap insurance.

Widening a two-sided quote by confidence:

```solidity
uint256 half = (mark * f.confidenceBps) / 10_000;
uint256 bid  = mark - half;
uint256 ask  = mark + half;
```

Sizing a liquidation buffer from volatility over your own horizon is §5.3.

### 4.4. Stale is normal for some assets

TTL is a per-risk-class constant, set at `registerFeed` and only ever
tightenable afterwards. Three tiers exist:

| Risk class | `ttlSecs` | `maxDeviationBps` | Feeds |
|---|---|---|---|
| stable | 7200 | 50 | USDT, USDS, USD1, PYUSD, USDC-USD |
| fx | 3600 | 75 | EURC, QCAD, AUDF, JPYC, KRW1 |
| volatile | 600 | 100 | WETH, WBTC, CBBTC, BNB, XAUT, PAXG, and all 10 equities |

There is no separate equities risk tier - equities are `volatile`, TTL 600.
"Equity" is a slot-packing class only, which matters to the producer and not to
you.

**And that is the trap.** NVDA's feed has a 600 s TTL and the US equity market
is closed roughly 70 % of the week. Outside the session the feed goes stale
because there is nothing to mark it against, and the keeper deliberately does
**not** heartbeat a frozen record - republishing a dead price on a clock is
worse than saying nothing.

So an equity feed reading stale at 03:00 UTC on a Sunday is the system working.
An integrator who pages on it will page every weekend and eventually stop
reading the pages. Classify staleness before acting on it:

- **Stale, market closed** → expected. Freeze new risk, do not liquidate, do
  not alert.
- **Stale, market open** → an incident. Alert.
- **Stale, crypto or FX** → always an incident; those feeds trade continuously.

§5.2 and §6.2 implement exactly this split.

---

## 5. On-chain examples

Three consumers, one per asset class, each illustrating a different failure
mode. All three compile against the `IOracle` block in §1 and nothing else.

### 5.1. FX: a settled payment that demands tight freshness

An invoice denominated in EUR, settled in USDC. Settlement is atomic and
irreversible, so a stale rate is a direct, unrecoverable loss to whichever side
the drift favours. EURC's TTL is 3600 s - fine for a pool that charges a
staleness premium, far too loose for a payment that hands over funds.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @notice Settles a EUR-denominated invoice in USDC at the BTR EURC-USDC mark.
contract EurInvoiceRail {
    /// @dev Primary oracle for this chain (§2) and the EURC-USDC id (§3.2).
    IOracle public immutable ORACLE;
    bytes32 public constant EURC_USDC =
        0x9af29d8ae5269a47d972f6e5a188878d8e45cb2c7f7ce637492bdaef05a980be;

    IERC20 public immutable usdc;

    /// @dev EURC's own ttl is 3600s. Settlement is irreversible, so we take 60s.
    uint32 public constant MAX_AGE = 60;

    /// @dev Reject a mark we are not confident in, however fresh it is.
    uint16 public constant MAX_CONF_BPS = 25;

    error FeedPaused();
    error FeedStale();
    error FeedUncertain(uint16 confidenceBps);
    error NoPrice();
    error QuoteExceeded(uint256 owed, uint256 maxPay);

    constructor(IOracle _oracle, IERC20 _usdc) { ORACLE = _oracle; usdc = _usdc; }

    /// @param eurCents invoice amount in EUR cents
    /// @param maxPay   payer's slippage bound, USDC base units (6 decimals)
    function settle(address payee, uint256 eurCents, uint256 maxPay) external {
        // 1. pause bit, before anything else
        IOracle.FeedData memory f = ORACLE.getFeed(EURC_USDC);
        if (f.flags & 1 != 0) revert FeedPaused();

        // 2. our bound, not the feed's
        if (!ORACLE.isFeedFresh(EURC_USDC, MAX_AGE)) revert FeedStale();

        // 3. the sentinel is not a price
        if (f.mark1e18 == 0) revert NoPrice();

        // 4. an uncertain mark is not a settlement rate
        if (f.confidenceBps > MAX_CONF_BPS) revert FeedUncertain(f.confidenceBps);

        // EUR cents -> USDC base units. mark1e18 is USDC per EURC, 1e18 WAD.
        // eurCents * 1e4 == EUR in 1e6 units; * mark / 1e18 -> USDC 1e6 units.
        uint256 owed = (eurCents * 1e4 * f.mark1e18) / 1e18;
        if (owed > maxPay) revert QuoteExceeded(owed, maxPay);

        usdc.transferFrom(msg.sender, payee, owed);
    }
}
```

What this one illustrates: **the two-argument freshness form, and a confidence
ceiling**. At the live EURC confidence of 2 bps the ceiling never binds; it
binds exactly when the mark stops being worth settling against. `MAX_AGE = 60`
against a 3600 s TTL is a 60× tightening, and the read for it returns `true`
today - verified on chain.

### 5.2. Equity: collateral that survives a closed market

NVDA posted as collateral. The market is shut most of the week, so the feed is
legitimately stale most of the week. A consumer that reverts on stale is a
consumer that bricks itself every Friday evening; one that ignores staleness
liquidates people on Monday's gap using Friday's price. Neither is acceptable.

The resolution is **asymmetry**: a stale mark may only be used in the direction
that is conservative for the protocol.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @notice Equity collateral valuation that treats a closed market as normal.
contract EquityCollateral {
    IOracle public immutable ORACLE;
    bytes32 public constant NVDA_USDC =
        0x9a0882814ace331414f8c2c7cb34e815e4c7092f8de7df39bba1fc92968deb69;

    constructor(IOracle _oracle) { ORACLE = _oracle; }

    /// @dev Applied to a stale mark. An equity gaps at the open; 20% is the
    ///      price of pretending Friday's close is Monday's.
    uint256 public constant CLOSED_HAIRCUT_BPS = 2_000;

    /// @dev Past this the last mark carries no information at all.
    uint32 public constant MAX_CLOSED_AGE = 4 days;

    error FeedPaused();
    error NoPrice();
    error MarkAbandoned(uint256 age);
    error MarketClosed();

    enum State { LIVE, CLOSED }

    /// @notice Collateral value, and whether the mark behind it is live.
    function valuation(uint256 shares1e18)
        public
        view
        returns (uint256 valueUsdc1e18, State state)
    {
        IOracle.FeedData memory f = ORACLE.getFeed(NVDA_USDC);

        // Pause is never "normal". Closed markets do not set this bit.
        if (f.flags & 1 != 0) revert FeedPaused();
        if (f.mark1e18 == 0) revert NoPrice();

        if (ORACLE.isFeedFresh(NVDA_USDC)) {
            return ((shares1e18 * f.mark1e18) / 1e18, State.LIVE);
        }

        // Stale. For an equity that is a closed session, not an outage --
        // but only up to a point.
        uint256 obs = _observedAt(f);
        uint256 age = block.timestamp > obs ? block.timestamp - obs : 0;
        if (age > MAX_CLOSED_AGE) revert MarkAbandoned(age);

        uint256 haircut = (shares1e18 * f.mark1e18 * (10_000 - CLOSED_HAIRCUT_BPS))
            / 1e18 / 10_000;
        return (haircut, State.CLOSED);
    }

    /// @notice Borrowing needs a live mark. Refusing here is safe: the user
    ///         waits for the open. It never bricks an existing position.
    function borrow(uint256 shares1e18, uint256 amount) external view {
        (uint256 value, State state) = valuation(shares1e18);
        if (state != State.LIVE) revert MarketClosed();
        require(amount * 2 <= value, "ltv");
    }

    /// @notice Liquidation is allowed on a haircut mark, because refusing to
    ///         liquidate over a weekend is how a book goes underwater. The
    ///         haircut makes the stale path strictly conservative for the
    ///         borrower's counterparty and never triggers on drift alone.
    function liquidatable(uint256 shares1e18, uint256 debt)
        external
        view
        returns (bool)
    {
        (uint256 value,) = valuation(shares1e18);
        return debt * 10_000 > value * 8_000;
    }

    /// @dev The contract's own clock: min(sourceTs, updatedAt).
    function _observedAt(IOracle.FeedData memory f)
        private
        pure
        returns (uint256)
    {
        uint256 src = uint256(f.sourceTsMs) / 1000;
        if (src == 0) return f.updatedAtSecs;
        return src < f.updatedAtSecs ? src : f.updatedAtSecs;
    }
}
```

What this one illustrates: **stale is a state, not an error**. The pause bit
still reverts unconditionally - a guardian freeze is never a closed market -
but the freshness verdict routes to a degraded, conservative path with its own
absolute cutoff, and the two entry points make opposite choices about whether
that path is good enough.

### 5.3. Crypto: an LTV sized from `sigmaPbps`

A WETH borrow. Volatility is the whole risk, and the feed reports it every
push. A fixed 80 % LTV is a bet that today looks like the backtest; deriving it
from the live $\sigma$ is not.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @notice Borrow limits that shrink as the collateral's realized vol rises.
contract VolAwareLtv {
    IOracle public immutable ORACLE;
    bytes32 public constant WETH_USDC =
        0xacb54d59d0c847602722bfdec8aaaf92ead1385b3402cad482b3c6484a6efc1c;

    constructor(IOracle _oracle) { ORACLE = _oracle; }

    uint256 public constant PBPS = 1e6;   // sigmaPbps scale: 1e6 == 100%
    uint256 public constant BPS  = 1e4;

    /// @dev Ceiling before any vol adjustment.
    uint256 public constant BASE_LTV_BPS = 8_000;
    /// @dev Never lend past this however calm the tape looks.
    uint256 public constant FLOOR_LTV_BPS = 3_000;
    /// @dev Liquidator's window, in units of the horizon-scaled sigma.
    uint256 public constant Z = 3;
    /// @dev How long a liquidation realistically takes to land.
    uint256 public constant HORIZON_SECS = 3600;
    uint256 public constant YEAR_SECS = 31_536_000;

    error FeedPaused();
    error FeedStale();
    error NoPrice();

    function feed() public view returns (IOracle.FeedData memory f) {
        f = ORACLE.getFeed(WETH_USDC);
        if (f.flags & 1 != 0) revert FeedPaused();
        if (f.mark1e18 == 0) revert NoPrice();
        // 600s ttl on a volatile leg; 120s is what a liquidation engine needs.
        if (!ORACLE.isFeedFresh(WETH_USDC, 120)) revert FeedStale();
    }

    /// @notice sigma scaled to the liquidation horizon, in bps.
    ///         sigma_h = sigma_annual * sqrt(horizon / year)
    function horizonSigmaBps(uint32 sigmaPbps) public pure returns (uint256) {
        // sqrt in 1e18 fixed point to keep the ratio out of integer floor.
        uint256 ratio1e18 = (HORIZON_SECS * 1e18) / YEAR_SECS;
        uint256 sqrt1e9 = _sqrt(ratio1e18 * 1e18) / 1e9; // sqrt(x) in 1e9
        return (uint256(sigmaPbps) * sqrt1e9 * BPS) / (PBPS * 1e9);
    }

    /// @notice LTV = base - Z*sigma_h - confidence, floored.
    function maxLtvBps() public view returns (uint256) {
        IOracle.FeedData memory f = feed();
        uint256 buffer = Z * horizonSigmaBps(f.sigmaPbps) + f.confidenceBps;
        if (buffer >= BASE_LTV_BPS - FLOOR_LTV_BPS) return FLOOR_LTV_BPS;
        return BASE_LTV_BPS - buffer;
    }

    /// @notice Max USDC borrowable against `collateral1e18` of WETH.
    function borrowLimit(uint256 collateral1e18) external view returns (uint256) {
        IOracle.FeedData memory f = feed();
        uint256 value1e18 = (collateral1e18 * f.mark1e18) / 1e18;
        return (value1e18 * maxLtvBps()) / BPS / 1e12; // -> USDC 6dp
    }

    function _sqrt(uint256 x) private pure returns (uint256 y) {
        if (x == 0) return 0;
        uint256 z = (x + 1) / 2;
        y = x;
        while (z < y) { y = z; z = (x / z + z) / 2; }
    }
}
```

At the live WETH values - $\sigma$ 2,816 pbps, confidence 4 bps - the one-hour
horizon sigma is $0.2816 \cdot \sqrt{3600/31{,}536{,}000} \approx 0.301\%$, so
$3\sigma_h + \text{conf} \approx 90 + 4 = 94$ bps and the LTV lands at ~79.1 %
against a base of 80 %. Double the volatility and it moves to ~78.1 % on its
own, with nobody filing a governance proposal.

What this one illustrates: **`sigmaPbps` is a control input, not telemetry.**
Note also the 120 s bound against a 600 s TTL - the feed's TTL is what the pool
tolerates, and a liquidation engine is not a pool.

---

## 6. Off-chain examples

viem is the house stack. `@btr-protocol/sdk` is worth pulling in for two
specific things noted in §6.3; for a bare read it is not needed and a 12-line
inline ABI is clearer.

```ts
// oracle.ts - shared setup for all three examples
import { createPublicClient, http } from 'viem';

// Point at whichever chain you are integrating on.
export const client = createPublicClient({ transport: http(process.env.RPC_URL!) });

// Both tiers for this chain, from GET /v1/venues. Same pair across every chain
// in a fleet; the mainnet and testnet fleets have different pairs (§2).
export const ORACLE_PRIMARY = process.env.ORACLE_PRIMARY as `0x${string}`;
export const ORACLE_REFERENCE = process.env.ORACLE_REFERENCE as `0x${string}`;

export const FEEDS = {
  EURC: '0x9af29d8ae5269a47d972f6e5a188878d8e45cb2c7f7ce637492bdaef05a980be',
  NVDA: '0x9a0882814ace331414f8c2c7cb34e815e4c7092f8de7df39bba1fc92968deb69',
  WETH: '0xacb54d59d0c847602722bfdec8aaaf92ead1385b3402cad482b3c6484a6efc1c',
} as const;

export const ORACLE_ABI = [
  {
    type: 'function', name: 'getFeed', stateMutability: 'view',
    inputs: [{ name: 'feedId', type: 'bytes32' }],
    outputs: [{
      name: '', type: 'tuple', components: [
        { name: 'mark1e18', type: 'uint256' },
        { name: 'sigmaPbps', type: 'uint32' },
        { name: 'updatedAtSecs', type: 'uint32' },
        { name: 'ttlSecs', type: 'uint16' },
        { name: 'confidenceBps', type: 'uint16' },
        { name: 'flags', type: 'uint16' },
        { name: 'maxDeviationBps', type: 'uint16' },
        { name: 'sourceTsMs', type: 'uint48' },
      ],
    }],
  },
  {
    type: 'function', name: 'isFeedFresh', stateMutability: 'view',
    inputs: [{ name: 'feedId', type: 'bytes32' }, { name: 'maxAge', type: 'uint32' }],
    outputs: [{ name: '', type: 'bool' }],
  },
  {
    type: 'function', name: 'isFeedFresh', stateMutability: 'view',
    inputs: [{ name: 'feedId', type: 'bytes32' }],
    outputs: [{ name: '', type: 'bool' }],
  },
] as const;

/** The clock the contract gates on: min(sourceTs, updatedAt). */
export function observedAtSecs(f: { sourceTsMs: bigint; updatedAtSecs: number }) {
  if (f.sourceTsMs === 0n) return f.updatedAtSecs;
  const src = Number(f.sourceTsMs / 1000n);
  return Math.min(src, f.updatedAtSecs);
}
```

Note the two `isFeedFresh` overloads. viem resolves them by argument count, so
both entries can live in the same ABI array without ambiguity.

### 6.1. FX: a direct read with a strict bound

```ts
import { formatUnits } from 'viem';
import { client, ORACLE_PRIMARY, ORACLE_ABI, FEEDS, observedAtSecs } from './oracle';

export async function eurUsdcRate(maxAgeSecs = 60) {
  const [feed, fresh] = await Promise.all([
    client.readContract({
      address: ORACLE_PRIMARY, abi: ORACLE_ABI,
      functionName: 'getFeed', args: [FEEDS.EURC],
    }),
    client.readContract({
      address: ORACLE_PRIMARY, abi: ORACLE_ABI,
      functionName: 'isFeedFresh', args: [FEEDS.EURC, maxAgeSecs],
    }),
  ]);

  if (feed.flags & 1) throw new Error('EURC-USDC paused by guardian');
  if (feed.mark1e18 === 0n) throw new Error('EURC-USDC never pushed');
  if (!fresh) throw new Error(`EURC-USDC older than ${maxAgeSecs}s`);

  const rate = Number(formatUnits(feed.mark1e18, 18));
  const halfSpread = rate * (feed.confidenceBps / 10_000);

  return {
    rate,                                  // 1.1590989045290763
    bid: rate - halfSpread,
    ask: rate + halfSpread,
    ageSecs: Math.floor(Date.now() / 1000) - observedAtSecs(feed),
    ttlSecs: feed.ttlSecs,                 // 3600
    sigmaPct: feed.sigmaPbps / 10_000,     // 0.04
  };
}
```

Expected output against the live feed (2026-09-01T17:10:18Z): `rate`
1.1590989045290763, `ttlSecs` 3600, `sigmaPct` 0.04, `confidenceBps` 2 giving a
±0.02 bp band. `isFeedFresh(EURC, 60)` returned `true` - verified.

### 6.2. Equity: telling a closed market apart from an outage

The point of an off-chain monitor is deciding whether to wake someone. This
one does not wake anyone for a Sunday.

```ts
import { client, ORACLE_PRIMARY, ORACLE_ABI, FEEDS, observedAtSecs } from './oracle';

type Verdict = 'live' | 'closed' | 'incident';

/** Rough US cash session in UTC. Holidays are not modelled; widen to taste. */
function usMarketOpen(d = new Date()): boolean {
  const day = d.getUTCDay();
  if (day === 0 || day === 6) return false;
  const mins = d.getUTCHours() * 60 + d.getUTCMinutes();
  return mins >= 13 * 60 + 30 && mins < 20 * 60; // 13:30-20:00Z
}

export async function nvdaStatus(): Promise<{
  verdict: Verdict; mark: number; ageSecs: number; page: boolean;
}> {
  const [feed, fresh] = await Promise.all([
    client.readContract({
      address: ORACLE_PRIMARY, abi: ORACLE_ABI,
      functionName: 'getFeed', args: [FEEDS.NVDA],
    }),
    client.readContract({
      address: ORACLE_PRIMARY, abi: ORACLE_ABI,
      functionName: 'isFeedFresh', args: [FEEDS.NVDA],
    }),
  ]);

  // A pause is an incident in every timezone.
  if (feed.flags & 1) {
    return { verdict: 'incident', mark: 0, ageSecs: 0, page: true };
  }

  const mark = Number(feed.mark1e18) / 1e18;
  const ageSecs = Math.floor(Date.now() / 1000) - observedAtSecs(feed);

  if (fresh) return { verdict: 'live', mark, ageSecs, page: false };

  // Stale. Only now does the calendar matter.
  const open = usMarketOpen();
  return {
    verdict: open ? 'incident' : 'closed',
    mark, ageSecs,
    page: open,
  };
}
```

Read during the 2026-09-01 US session: `verdict: 'live'`, `mark`
218.5099919907147, `isFeedFresh` `true`, `page: false` - verified on chain.
Run the same code at 03:00 UTC on a Saturday and it returns `closed` with
`page: false`, which is the entire reason it exists.

Apply the same shape to WETH or EURC and delete the calendar branch: those
feeds trade continuously, so stale is unconditionally an incident.

### 6.3. Crypto: read both tiers and require agreement

The strongest thing an off-chain consumer can do cheaply. A single compromised,
misconfigured or stalled instance cannot move your price past the tolerance
without the other agreeing.

```ts
import {
  client, ORACLE_PRIMARY, ORACLE_REFERENCE, ORACLE_ABI, FEEDS, observedAtSecs,
} from './oracle';

const BPS = 10_000n;

export async function agreedMark(
  feedId: `0x${string}`,
  toleranceBps = 50n,
  maxAgeSecs = 600,
) {
  const call = (address: `0x${string}`) => ({
    address, abi: ORACLE_ABI, functionName: 'getFeed' as const, args: [feedId] as const,
  });

  const [primary, reference] = await client.multicall({
    contracts: [call(ORACLE_PRIMARY), call(ORACLE_REFERENCE)],
    allowFailure: false,
  });

  for (const [tier, f] of [['primary', primary], ['reference', reference]] as const) {
    if (f.flags & 1) throw new Error(`${tier} paused`);
    if (f.mark1e18 === 0n) throw new Error(`${tier} never pushed`);
    const age = Math.floor(Date.now() / 1000) - observedAtSecs(f);
    if (age > maxAgeSecs) throw new Error(`${tier} stale: ${age}s`);
  }

  const [lo, hi] = primary.mark1e18 < reference.mark1e18
    ? [primary.mark1e18, reference.mark1e18]
    : [reference.mark1e18, primary.mark1e18];
  const devBps = ((hi - lo) * BPS) / lo;
  if (devBps > toleranceBps) {
    throw new Error(`tiers disagree by ${devBps}bps (tolerance ${toleranceBps})`);
  }

  // Conservative: take the worse of the two for whichever side you are on.
  return { mark1e18: lo, devBps, primary, reference };
}
```

Verified against the live pair on 2026-09-01T17:10Z: WETH primary
2432.481978935539, reference 2433.2253543490315, deviation **3.06 bps**; NVDA
**4.35 bps**; EURC **0.22 bps**. A 50 bps tolerance is comfortable and a 10 bps
one is not - the two tiers push on independent triggers and are not expected to
be bit-identical.

**Do not impose a tight `maxAgeSecs` on the reference.** The reference tier
runs 2 relays against the primary's 3 and a lower push cadence; at the same
instant that `isFeedFresh(feedId, 60)` returned `true` on the primary for all
three feeds, it returned **`false` on the reference for all three**. Gate the
reference on its own TTL and reserve your strict bound for the tier you price
off.

Where `@btr-protocol/sdk` genuinely helps:

- `observedAtSecs(feed)` and the `FeedDataV2` type are exported from
  `@btr-protocol/sdk/oracle`, with a compile-time assertion that the field
  names still match the on-chain struct. A renamed field silently reading
  `undefined` - and every feed then gating as stale - is a bug that has
  actually shipped; the SDK type is what catches it.
- `GET /v1/venues` and `GET /v1/abis/{name}` give you live addresses and ABIs
  per chain, which is strictly better than the constants pasted above. See
  [API & SDK Reference](/docs/5-2-1-api-sdk-reference).

---

## 7. Checklist

Before you ship:

1. `flags & 1` checked, **fails closed**, no fallback price.
2. `mark1e18 == 0` rejected.
3. Freshness gated with **your** bound via `isFeedFresh(feedId, maxAge)`, not
   only the feed's TTL.
4. Age computed from $\min(\texttt{sourceTsMs}/1000, \texttt{updatedAtSecs})$
   if you compute it yourself.
5. `confidenceBps` widens something, or caps something.
6. `sigmaPbps` sizes something - a haircut, an LTV, a buffer.
7. Equity feeds have a closed-market path that is not an alert.
8. Feed ids taken from §3.2, not derived: `getFeed` on an unknown feed reverts
   `FeedNotFound`.
9. Both tiers read and agreement required, if the value at risk justifies the
   second call.

## 8. See also

- [Integration examples](https://github.com/btr-protocol/examples): this whole page, runnable and
  tested. [`OracleConsumer.sol`](https://github.com/btr-protocol/examples/blob/main/foundry/src/OracleConsumer.sol) is the on-chain gate with
  every check in §4 wired in, [`01_OracleConsumption.t.sol`](https://github.com/btr-protocol/examples/blob/main/foundry/test/01_OracleConsumption.t.sol)
  exercises it against a live fork including the stale, paused and zero-mark paths,
  [`sdk/01-oracle.ts`](https://github.com/btr-protocol/examples/blob/main/typescript/sdk/01-oracle.ts) is the SDK read and
  [`rpc/01-oracle.ts`](https://github.com/btr-protocol/examples/blob/main/typescript/rpc/01-oracle.ts) the same thing with no SDK at all
- [Oracles](/docs/3-4-oracles) - the producer side: push path, quorum, storage
  layouts, deviation bands
- [Oracle Price-Push Security](/docs/3-6-oracle-price-push-security)
- [Depeg Halt](/docs/3-5-depeg-halt) - how BTR itself acts on a reference-band
  breach
- [Contract Addresses](/docs/2-1-contract-addresses) - the deployed pool and
  token fleet per chain
- [API & SDK Reference](/docs/5-2-1-api-sdk-reference)
