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

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.

FieldUnitNote
mark1e181e18 WADThe price. 0 is a sentinel, never a price
sigmaPbpsPBPSRealized volatility, for haircuts, buffers and spreads
updatedAtSecsunix secondsObservation time of the last accepted push
ttlSecssecondsThe feed’s own declared freshness window
confidenceBpsbps1-sigma CI on the mark, decoupled from sigmaPbps
flagsbitfieldbit0 = paused; bits 1-15 unused and reserved
maxDeviationBpsbpsPer-push deviation band enforced on the value read
sourceTsMsmsSigned source time; on V4 updatedAtSecs * 1000

Field semantics, on-chain layouts and push-side guards are Oracle system §2; 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).

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.

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

|mprimary-mref|·104>mref·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:

TierReserved mainnet address
Primary0xbbbbbbbb76433322889ddbb7a1d1eb45a135ca4d
Reference0xbbbbbbbbd25e9fe06b53cd32803e38f5fbbf8c70

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: a 64-bit integer that carries the instrument type alongside both legs.

BitsFieldWidth
63–60Instrument type4
59–56Base asset class4
55–40Base asset id16
39–36Quote asset class4
35–20Quote asset id16
19–0Sub-type (expiry, strike; reserved)20

id=(type60)(baseClass56)(baseId40)(quoteClass36)(quoteId20)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:

feedId=bytes32(uint256(tickerId))

Not deployed yet. The live oracle still keys feeds on 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 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.

idxFeedMITCH tickerId (dec)MITCH tickerId (hex)TypeBaseQuoteOn-chain feedId (live today)
0USDT-USDC4516985001046179840x0644c16484500000SpotCrypto / 17601Crypto / 185010xfa722ae80d6181ca931f45c80582c173b9c19cd30c1632e864e8f48ea62a6548
1USDS-USDC4483999652212899840x0639096484500000SpotCrypto / 14601Crypto / 185010x4d9df04bbf62ab0e8418c56a2fea063a7956bc08674600862a95970c583f3be5
2USD1-USDC4420227977801891840x0622616484500000SpotCrypto / 8801Crypto / 185010x4c7fec22c40835f297ef183fc68a20f5a965997cddedf9fcf5bd18b3d0898d85
3PYUSD-USDC4459810396401827840x0630716484500000SpotCrypto / 12401Crypto / 185010xd4ebce1baf00f6124f7a0bd347ef8170aaa7ce6e5dcf595879e3c61676921e99
4EURC-USDC4392190431293603840x06186b6484500000SpotCrypto / 6251Crypto / 185010x9af29d8ae5269a47d972f6e5a188878d8e45cb2c7f7ce637492bdaef05a980be
5QCAD-USDC4560965466157219840x0654616484500000SpotCrypto / 21601Crypto / 185010x1c9b7f4cc3a7295cb420362f039e13565a7f80f9d0b7023405023d1db74735ea
6AUDF-USDC4562064977784995840x0654c56484500000SpotCrypto / 21701Crypto / 185010x20dc0d8625fb1ae982d08763105cdce98e9a95ca59c4903190119c9fe88a47cd
7JPYC-USDC4564264001040547840x06558d6484500000SpotCrypto / 21901Crypto / 185010xf3570f02e056765d6c42a5b3624067f3b80c80b9d22355c71b534c24eb8ae1e9
8KRW1-USDC4565363512668323840x0655f16484500000SpotCrypto / 22001Crypto / 185010x2642c5e9691dbb5c5674a2a24ebb68e4df723f0656e3d4e2e45fa028c6caf650
9WETH-USDC4387242628968611840x0616a96484500000SpotCrypto / 5801Crypto / 185010xacb54d59d0c847602722bfdec8aaaf92ead1385b3402cad482b3c6484a6efc1c
10WBTC-USDC4534577187090595840x064b016484500000SpotCrypto / 19201Crypto / 185010x63b49bbd6b259a4c3500020453ad775a8df2ff9e423fe5228644f18f495262bf
11CBBTC-USDC4366351908040867840x060f3d6484500000SpotCrypto / 3901Crypto / 185010x27f98ddc32af5a6272f676d428fd004af1718c18541118dc044c0ac3a2b612fd
12BNB-USDC4344361675485347840x06076d6484500000SpotCrypto / 1901Crypto / 185010x5398b2b4caab86e6c562e30f6ecb15c4b87c20ed7595ad48b4048b62005b9888
13XAUT-USDC4545572303368355840x064ee96484500000SpotCrypto / 20201Crypto / 185010xaf63e9c459822846879d75246ea10ee933d53a8f206c51c5150270aadd42625f
14PAXG-USDC4544472791740579840x064e856484500000SpotCrypto / 20101Crypto / 185010xb8b495d5826591b537a47841255ffbca7f1f0f56afff8f5e7bc565f1e20b338c
15USDC-USD4526878402554101760x0648453138900000SpotCrypto / 18501Forex / 50010x0189091eac3c33dc88b48c58f75a1d978253e7fb2d4a1711b5701172b083c487
16INTC-USDC76871175063470080x001b4f6484500000SpotEquities / 6991Crypto / 185010x058dc9a04c0ebce2bb560948628013d466bdc2bfb8042e33d4a7a0dde2045fba
17AMD-USDC1664579723591680x0000976484500000SpotEquities / 151Crypto / 185010xa6c9396c58cb1c50e6f2a6139404b148ecc7f86489d9d4faa01e3eb5228fc652
18NVDA-USDC112055547152302080x0027cf6484500000SpotEquities / 10191Crypto / 185010x9a0882814ace331414f8c2c7cb34e815e4c7092f8de7df39bba1fc92968deb69
19ASML-USDC11340282048020480x0004076484500000SpotEquities / 1031Crypto / 185010x6b434d49a41b22478b4b1d3fca59c90e5dbfc185cc1b5e2a9a3efd2f901e0c23
20SPCX-USDC141027678544199680x00321a6484500000SpotEquities / 12826Crypto / 185010x1729d32f332780e7a939b7ca2a73ceb60cf507d64ed28e15023dad847f50fe47
21AVGO-USDC19476668093562880x0006eb6484500000SpotEquities / 1771Crypto / 185010x7d9c97fe812e6df01e2604baeade55e60503c8d793300e69d6da8efa52618b54
22TSLA-USDC150758356450017280x00358f6484500000SpotEquities / 13711Crypto / 185010x91a8aa38cb67e3a567c5b58b125e69e218c5a97677de4b4ce327a1bd281e0952
23MSFT-USDC100070870409543680x00238d6484500000SpotEquities / 9101Crypto / 185010x778b165de0bfe24e8806a0334dabd237c8f5844fb3d60a8d5b623d352120e784
24ORCL-USDC114254570407854080x0028976484500000SpotEquities / 10391Crypto / 185010x83c6210ed83e6bbd8db6d6dd43893d46cbc0a20efce89c30f6be2ce536ea74db
25META-USDC99301212270100480x0023476484500000SpotEquities / 9031Crypto / 185010x2d2201820627f4019b4ddd9d9742fc14a750d4df2f5413961e91c03e8af9581a

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:

Feedmark1e18sigmaPbpsttlSecsconfidenceBpsmaxDeviationBpsflagsisFeedFresh
EURC-USDC1.15909890452907622440036002750true
NVDA-USDC218.509991990714695680232060031000true
WETH-USDC2432.481978935538614272281660041000true

Same three feeds on the reference, same sweep:

Feedmark1e18sigmaPbpscross-tier deviation
EURC-USDC1.1590731347253002244000.22 bps
NVDA-USDC218.60498979535454208023044.35 bps
WETH-USDC2433.22535434903145676828323.06 bps

Reproduce any row:

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

yes

no

yes

no

false

true

getFeed(feedId)

pause bit set?

REJECT: guardian freeze

mark1e18 == 0?

REJECT: never pushed / rebiased

isFeedFresh(feedId, myMaxAge)?

REJECT or degrade: stale

widen by confidenceBps, size by sigmaPbps

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:

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 σ 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

στ=0.2816·54031,536,0000.117%11.7bps

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 tobs=min(sourceTsMs/1000,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.

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.

FieldAnswersUse it to
confidenceBpshow sure are we of this mark, right nowwiden a spread, add a settlement buffer
sigmaPbpshow much does this asset movesize 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:

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 classttlSecsmaxDeviationBpsFeeds
stable720050USDT, USDS, USD1, PYUSD, USDC-USD
fx360075EURC, QCAD, AUDF, JPYC, KRW1
volatile600100WETH, 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.

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

// 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 σ is not.

// 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 - σ 2,816 pbps, confidence 4 bps - the one-hour horizon sigma is 0.2816·3600/31,536,0000.301%, so 3σh+conf90+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.

// 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

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.

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.

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.

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(sourceTsMs/1000,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