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.
| 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;
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; takemark1e18from it. - No enumeration.
getFeedIds()reverts. Take feed ids from §3.2. - No
exists. The idiomatic probe isisFeedFresh(feedId), which returnsfalsefor an unknown feed rather than reverting. getFeedon an unknownfeedIdrevertsFeedNotFound(bytes32)(selector0x4e054b40). 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
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: 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 |
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:
⚠ Not deployed yet. The live oracle still keys feeds on , a 40-byte packing of two token addresses. That migration is pending. Use the on-chain
feedIdcolumn in §3.2 today - callinggetFeedwith a MITCH-derived id revertsFeedNotFound. 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:
# 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.
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
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 , not
updatedAtSecsalone. Computing age offupdatedAtSecsunder-states it by the relay lag, which is how a feed reads fresh off chain and still fails on chain. isFeedFreshreturnsfalsefor an unknown feed andfalsefor a paused feed regardless of age. It never reverts.getFeedon 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.
| 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:
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.
// 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 , so 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 theFeedDataV2type 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 readingundefined- and every feed then gating as stale - is a bug that has actually shipped; the SDK type is what catches it.GET /v1/venuesandGET /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:
flags & 1checked, fails closed, no fallback price.mark1e18 == 0rejected.- Freshness gated with your bound via
isFeedFresh(feedId, maxAge), not only the feed’s TTL. - Age computed from if you compute it yourself.
confidenceBpswidens something, or caps something.sigmaPbpssizes something - a haircut, an LTV, a buffer.- Equity feeds have a closed-market path that is not an alert.
- Feed ids taken from §3.2, not derived:
getFeedon an unknown feed revertsFeedNotFound. - Both tiers read and agreement required, if the value at risk justifies the second call.
8. See also
- Integration examples: this whole page, runnable and
tested.
OracleConsumer.solis the on-chain gate with every check in §4 wired in,01_OracleConsumption.t.solexercises it against a live fork including the stale, paused and zero-mark paths,sdk/01-oracle.tsis the SDK read andrpc/01-oracle.tsthe same thing with no SDK at all - Oracles - the producer side: push path, quorum, storage layouts, deviation bands
- Oracle Price-Push Security
- Depeg Halt - how BTR itself acts on a reference-band breach
- Contract Addresses - the deployed pool and token fleet per chain
- API & SDK Reference