Consuming Price Feeds
Three read functions, one struct. Reading mark1e18 alone is not enough;
§4 is the gating contract.
Producer side is out of scope: the signed blob, lane packing, quorum and push trigger are in Mark Relayer.
1. The read surface
interface IOracle {
struct FeedData {
uint256 mark1e18; // mark, 1e18 WAD
uint32 sigmaPbps; // realized vol per 1800 s, PBPS (1e6 = 100%)
uint32 updatedAtSecs; // observation second; a figure over ~4090 s old is unreliable (see below)
uint16 ttlSecs; // freshness window
uint16 confidenceBps; // 1-sigma CI on the mark
uint16 flags; // bit0 = paused (guardian freeze)
uint16 maxDevBps;
uint48 sourceTsMs; // always updatedAtSecs * 1000 on the mark store
}
function getFeed(bytes32 feedId) external view returns (FeedData memory);
}
// PoolFactory is the oracle address on every deployed chain:
// getFeed(feedId) primary tier (IOracle)
// feedOf(feedId, t) t = 1 primary, 2 referencegetFeed is the whole IOracle surface. On chains running the mark store, PoolFactory implements it over the chain’s one store (both tiers, feedOf for the reference) and the writer side (publishMarks, setAuth, register) lives on the Pool implementation (Mark Relayer). There is no isFeedFresh, feedIdAt or feedCount: compute freshness from the struct as §4.1 does. ABIs: reads in sdk/src/abis/PoolFactory.ts (POOL_FACTORY_ABI), writes in sdk/src/abis/MarkStore.ts (MARK_STORE_ABI), also served at GET /v1/abis/PoolFactory and GET /v1/abis/MarkStore.
| Field | Unit | Note |
|---|---|---|
mark1e18 | 1e18 WAD | The price. 0 is a sentinel, never a price |
sigmaPbps | PBPS per 1800 s | 30-minute Parkinson volatility, not annualized. Scale to a horizon τ with √(τ/1800) |
updatedAtSecs | unix seconds | Observation second of the last accepted mark. The lane’s age field saturates at 4095 s, so once now - updatedAtSecs reaches about 4090 the figure is only a bound: treat it as stale, not as a precise age |
ttlSecs | seconds | The lane’s class freshness window, 1 to 4089 s |
confidenceBps | bps | 1-sigma CI on the mark, decoupled from sigmaPbps |
flags | bitfield | bit0 = FEED_HALT_BIT, the guardian fast-freeze; bits 1-15 unused and reserved |
maxDevBps | bps | Per-push deviation band enforced on the value read |
sourceTsMs | ms | updatedAtSecs * 1000; carries no information beyond updatedAtSecs |
Field semantics, on-chain layouts and push-side guards: Mark Relayer.
1.1. What is not on the surface
- No
getMark: the struct is the read, takemark1e18from it. - No
getFeedIds(). On the store, a feed id maps to a lane throughPoolFactory.laneOf(feedId)(lane + 1, 0 = unmapped) and theFeedLaneUpdatedlog. Ids with their names: §3.2 orGET /v1/oracle/feeds. - No
exists: probePoolFactory.laneOf(feedId). getFeedon an unknownfeedIdrevertsFeedNotFound(bytes32)(selector0x4e054b40). Verified on chain against the live primary.
2. Addresses
Every deployed chain has one oracle address to read: PoolFactory, which serves both tiers of the one store (getFeed primary, feedOf(id, 2) reference) (Contract Addresses).
Addresses are deterministic: PoolFactory deploys through a CREATE3 factory, so an
address is a pure function of the factory, the deployer key and a mined salt,
independent of bytecode. Two consequences:
- Chain-generic within a fleet. Every chain deployed with the same key
resolves
PoolFactoryto one address. - Fleet-specific, not universal. The deployer EOA is part of the salt preimage
(
\texttt{effSalt} = \texttt{keccak256}(\texttt{eoa} \Vert \texttt{salt})), so the same salt under another key lands elsewhere.
Resolve the address for the chain you are on rather than pasting a constant:
GET /v1/venues returns it per chain (poolFactory), and
GET /v1/abis/{name} the ABIs, both documented in
API & SDK Reference.
2.1. What the reference tier is for
A second, independently signed attestation of the same feeds, with its own roster and EIP-712 domain, so no single quorum moves a price unchallenged. On the store it is tier 2 of the same lane.
BTR arms a symmetric band against it on every non-base pool leg.
PoolIOLib.priceBandGuard reads the primary and reference tiers of the leg’s
lane from one mark word and reverts PriceOutsideRefBand when
Arming it is mandatory for every external spoke; only the base may run disarmed. Halt semantics: Flow Guards.
Do the same: read both and require agreement. §6.3 implements it; a stalled primary, a stalled reference or a divergence between them all surface as one check.
Scope: the two tiers are independent in keeper process and EIP-712 domain. Whether their signer rosters are disjoint is a per-deployment property the chain cannot check (compare the
AuthUpdatedlogs of the two tiers); the relayer list is shared. The band covers one tier stalling or one keeper misbehaving, not a full signer-quorum compromise where the rosters overlap.
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 follow the MITCH ticker model.
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 collapse
onto one identifier and do not carry the same price. MITCH makes the
distinction part of the identity and stays decodable on chain: given a
tickerId, shifts recover the type and both legs, no registry lookup and no
preimage.
The intended on-chain form is the ticker id widened, not hashed:
Every deployed chain keys its lanes this way: the ceremony maps
feedId = bytes32(uint256(tickerId))to its lane (PoolFactory.setFeedLane) straight from the MITCH ticker. Confirm an id withPoolFactory.laneOfbefore relying on it: 0 means the id is not registered, andgetFeedon it revertsFeedNotFound.
The store and every generation since V2 take feedId as an opaque bytes32, so the
derivation is a deployment convention, not a contract-enforced one. Take
load-bearing ids from §3.2 rather than recomputing them.
3.2. The feed set
The roster is per chain: GET /v1/oracle/feeds?chainId=<id> lists it (index is the oracle lane), and deployed instances records what each chain runs. A lane can be registered and dark: getFeed returns mark1e18 = 0 (§4).
The three ids used in the examples below:
| Feed | MITCH tickerId (hex) | Base | Quote | On-chain feedId |
|---|---|---|---|---|
| EURC-USDC (FX) | 0x06186b6484500000 | Crypto / 6251 | Crypto / 18501 | 0x00000000000000000000000000000000000000000000000006186b6484500000 |
| NVDA-USDC (equity) | 0x0027cf6484500000 | Equities / 10191 | Crypto / 18501 | 0x0000000000000000000000000000000000000000000000000027cf6484500000 |
| WETH-USDC (crypto) | 0x0616a96484500000 | Crypto / 5801 | Crypto / 18501 | 0x0000000000000000000000000000000000000000000000000616a96484500000 |
3.3. Reading a feed by hand
Read any feed from the primary tier (PoolFactory.getFeed), and the reference tier with feedOf(id, 2):
# ORACLE: PoolFactory 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))" \
0x0000000000000000000000000000000000000000000000000616a96484500000 \
--rpc-url "$RPC"4. Safety
A consumer that reads mark1e18 and nothing else acts on stale prices. The
gate, in order:
4.1. Always gate on freshness
The feed’s own ttlSecs is the loosest bound the protocol accepts, not one
tuned to your product. Nothing on chain gates a read on it for you: compute age
from the struct.
IOracle.FeedData memory f = oracle.getFeed(feedId);
uint256 age = block.timestamp - f.updatedAtSecs;
require(age <= 60, "stale"); // your bound, not the feed'sA perpetuals venue liquidating at 10× leverage should demand 60 s on a feed whose TTL is 600 s. At σ = 2,560 pbps, WETH moves 0.256 % per 30 minutes (≈ 34 % annualized). Over the 540 s between a 60 s bound and that TTL, the one-sigma move is
so a two-sigma move inside that window is ~28 bps. The TTL is the loosest bound the protocol accepts; the consumer sets the bound for its own leverage.
Two mechanics:
- The clock is
updatedAtSecs, the lane’s observation second. On the mark storesourceTsMsisupdatedAtSecs * 1000, so aminof the two changes nothing. Past about 4090 s of age the lane’s age field has saturated and the figure is only a bound, so keep every bound you set below that and read anything older as stale. A longer cutoff has to come from your own record of the last live read. getFeedrevertsFeedNotFoundon an unnamed feed, an unregistered lane, and a registered lane whose stored word is still empty (never pushed). A returnedmark1e18 = 0is a dark lane: treat it as unusable.
4.2. Always fail closed on flags & 1
Bit 0 is the guardian freeze, a quorum-free fast-freeze lever pulled when something is wrong with the underlying data, not for routine maintenance (Mark Relayer).
if (f.flags & 1 != 0) revert FeedHalted(feedId);Fail closed: no cached mark, no last good value, no spot AMM fallback. BTR’s own
consumer path reverts FeatureDisabled(FEED) and does nothing else.
Treat mark1e18 == 0 as dead: it is the sentinel for a lane registered but
never given an accepted push, or one halted or gone dark. No arithmetic on it
is meaningful.
4.3. Use confidenceBps and sigmaPbps
The struct carries a price and its error bars. Use both.
| Field | Answers | Use it to |
|---|---|---|
confidenceBps | uncertainty of the current mark | widen a spread, add a settlement buffer |
sigmaPbps | realized volatility of the asset | size a haircut, an LTV, a margin requirement |
They are decoupled: 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 above 1,000 bps of confidence (MAX_CONFIDENCE_HALT_BPS); set a
ceiling of your own.
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
ttlSecs is a per-lane class constant, fixed in the mark store (1 to 4089 s)
and read from the struct, not hard-coded. Classes group assets by how fast
they move: tighter ttlSecs and maxDevBps for volatile assets, looser for
stables and FX. Take the values from getFeed on your chain; there is no
registration call or per-feed TTL write to rely on.
An equity feed with a short TTL goes stale whenever the US equity market is closed, roughly 70 % of the week: there is nothing to mark it against, and the keeper does not heartbeat a frozen record.
An equity feed reading stale at 03:00 UTC on a Sunday is expected. An integrator who pages on it pages every weekend. 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 this split.
5. On-chain examples
Three consumers, one per asset class, each illustrating a different failure
mode, written against PoolFactory (§2) and the feed ids of §3.2. The store
serves getFeed only, so freshness is the struct check of §4.1 (flags & 1 == 0,
mark1e18 != 0, age within a bound).
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 an unrecoverable loss to one side. An FX lane’s TTL is sized for a pool that charges a staleness premium: too loose for an irreversible payment.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IOracle} from "./IOracle.sol"; // the interface of §1
/// @notice Settles a EUR-denominated invoice in USDC at the BTR EURC-USDC mark.
contract EurInvoiceRail {
/// @dev PoolFactory for this chain (§2) and the EURC-USDC id (§3.2).
IOracle public immutable ORACLE;
bytes32 public constant EURC_USDC =
0x00000000000000000000000000000000000000000000000006186b6484500000;
IERC20 public immutable usdc;
/// @dev The lane's ttl is a pool's tolerance. 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 FeedHalted();
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. halt bit, before anything else
IOracle.FeedData memory f = ORACLE.getFeed(EURC_USDC);
if (f.flags & 1 != 0) revert FeedHalted();
// 2. our bound, not the feed's
if (block.timestamp - f.updatedAtSecs > 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);
}
}Shows a caller-side freshness bound and a confidence ceiling. The
confidence ceiling binds when the mark is too uncertain to settle against.
MAX_AGE = 60 is far tighter than an FX lane’s TTL, and on a quiet FX tape it
rejects: the mark can be many minutes old, because the lane pushes on deviation
and heartbeat, not every minute. Size the bound against the lane’s push cadence, or
settle only when it holds.
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 bricks itself every Friday evening; one that ignores staleness liquidates on Monday’s gap using Friday’s price.
The resolution is asymmetric: a stale mark may only be used in the direction conservative for the protocol.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IOracle} from "./IOracle.sol"; // the interface of §1
/// @notice Equity collateral valuation that treats a closed market as normal.
contract EquityCollateral {
IOracle public immutable ORACLE;
bytes32 public constant NVDA_USDC =
0x0000000000000000000000000000000000000000000000000027cf6484500000;
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;
error FeedHalted();
error NoPrice();
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 FeedHalted();
if (f.mark1e18 == 0) revert NoPrice();
uint256 age = block.timestamp > f.updatedAtSecs ? block.timestamp - f.updatedAtSecs : 0;
if (age <= f.ttlSecs) {
return ((shares1e18 * f.mark1e18) / 1e18, State.LIVE);
}
// Stale. For an equity that is a closed session, not an outage. The
// lane's age saturates near 4095 s, so an "abandoned after N days"
// cutoff cannot be read here; track the last live read yourself.
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;
}
}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, and the two entry points make opposite choices about whether that path is good enough. A real abandonment cutoff (days) needs the consumer’s own record of when it last saw a live read, because the feed’s age field cannot express it.
5.3. Crypto: an LTV sized from sigmaPbps
A WETH borrow. Volatility is the main risk and the feed reports it on every push. A fixed 80 % LTV assumes current volatility matches the backtest; an LTV derived from the live does not.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IOracle} from "./IOracle.sol"; // the interface of §1
/// @notice Borrow limits that shrink as the collateral's realized vol rises.
contract VolAwareLtv {
IOracle public immutable ORACLE;
bytes32 public constant WETH_USDC =
0x0000000000000000000000000000000000000000000000000616a96484500000;
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 SIGMA_INTERVAL_SECS = 1800; // sigmaPbps is per 1800 s
error FeedHalted();
error FeedStale();
error NoPrice();
function feed() public view returns (IOracle.FeedData memory f) {
f = ORACLE.getFeed(WETH_USDC);
if (f.flags & 1 != 0) revert FeedHalted();
if (f.mark1e18 == 0) revert NoPrice();
// The lane's ttl is a pool's tolerance; 120s is what a liquidation engine needs.
if (block.timestamp - f.updatedAtSecs > 120) revert FeedStale();
}
/// @notice sigma scaled to the liquidation horizon, in bps.
/// sigma_h = sigma * sqrt(horizon / SIGMA_INTERVAL_SECS)
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) / SIGMA_INTERVAL_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 σ = 2,560 pbps and confidence 8 bps the one-hour horizon σ is 0.256 %·√(3600/1800) ≈ 0.362 %, which horizonSigmaBps floors to 36 bps, so 3σ_h + conf = 116 bps and the LTV is 78.84 % against a base of 80 %. Double σ and it is 77.76 %, with no governance change.
sigmaPbps is a control input, not telemetry. The 120 s bound is the same point as §5.1: the feed’s TTL is the pool’s tolerance; a
liquidation engine needs its own.
6. Off-chain examples
The examples use viem. @btr-protocol/sdk is needed only for the two items
listed after §6.3; a bare read needs a 12-line inline ABI.
// 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!) });
// PoolFactory for this chain, from GET /v1/venues (§2). It serves both tiers.
export const ORACLE = process.env.ORACLE as `0x${string}`;
export const FEEDS = {
EURC: '0x00000000000000000000000000000000000000000000000006186b6484500000',
NVDA: '0x0000000000000000000000000000000000000000000000000027cf6484500000',
WETH: '0x0000000000000000000000000000000000000000000000000616a96484500000',
} 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: 'maxDevBps', type: 'uint16' },
{ name: 'sourceTsMs', type: 'uint48' },
],
}],
},
] as const;
/** Reference tier: same struct, read with `feedOf(feedId, 2)`. */
export const ORACLE_ABI_REF = [
{ ...ORACLE_ABI[0], name: 'feedOf',
inputs: [{ name: 'feedId', type: 'bytes32' }, { name: 'tier', type: 'uint256' }] },
] as const;
// viem decodes uint256 as bigint and uint16/uint32/uint48 as number, so
// `mark1e18` is a bigint and every other field is a plain number.There is no freshness getter: compute the age off getFeed as §6.1 does.
6.1. FX: a direct read with a strict bound
import { formatUnits } from 'viem';
import { client, ORACLE, ORACLE_ABI, FEEDS } from './oracle';
export async function eurUsdcRate(maxAgeSecs = 60) {
const feed = await client.readContract({
address: ORACLE, abi: ORACLE_ABI,
functionName: 'getFeed', args: [FEEDS.EURC],
});
if (feed.flags & 1) throw new Error('EURC-USDC paused by guardian');
if (feed.mark1e18 === 0n) throw new Error('EURC-USDC never pushed');
// The feed's TTL is looser than an FX consumer wants.
// Age it yourself off the observation second.
const ageSecs = Math.floor(Date.now() / 1000) - feed.updatedAtSecs;
if (ageSecs > feed.ttlSecs) throw new Error('EURC-USDC past its own ttlSecs');
if (ageSecs > maxAgeSecs) 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,
bid: rate - halfSpread,
ask: rate + halfSpread,
ageSecs,
ttlSecs: feed.ttlSecs,
sigmaPct: feed.sigmaPbps / 10_000,
};
}On a quiet tape the mark can be older than the default 60 s bound, which then
throws (§5.1); pass a larger maxAgeSecs sized to the lane’s push cadence.
6.2. Equity: telling a closed market apart from an outage
An off-chain monitor that decides whether to page. It does not page for a closed market.
import { client, ORACLE, ORACLE_ABI, FEEDS } 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 = await client.readContract({
address: ORACLE, abi: ORACLE_ABI,
functionName: 'getFeed', args: [FEEDS.NVDA],
});
if (feed.mark1e18 === 0n) throw new Error('NVDA-USDC never pushed');
// 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) - feed.updatedAtSecs;
if (ageSecs <= feed.ttlSecs) 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,
};
}On a chain where the NVDA lane is unregistered the read reverts
FeedNotFound. Once it is live, the code returns live during the US session
and closed with page: false at 03:00 UTC on a Saturday.
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 cheapest strong check off chain. A single compromised, misconfigured or stalled instance cannot move your price past the tolerance without the other agreeing.
import { client, ORACLE, ORACLE_ABI, ORACLE_ABI_REF } from './oracle';
const BPS = 10_000n;
export async function agreedMark(
feedId: `0x${string}`,
toleranceBps = 50n,
maxAgeSecs = 600,
) {
const [primary, reference] = await client.multicall({
contracts: [
{ address: ORACLE, abi: ORACLE_ABI, functionName: 'getFeed', args: [feedId] },
{ address: ORACLE, abi: ORACLE_ABI_REF, functionName: 'feedOf', args: [feedId, 2n] },
],
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) - f.updatedAtSecs;
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 };
}A 50 bps tolerance leaves margin; a 1 bps one does not: the two tiers push on independent triggers and are not bit-identical.
Do not impose a tight age bound on the reference: it pushes on its own trigger, so its age differs from the primary’s at any instant. Gate the reference on its own TTL and reserve your strict bound for the tier you price off.
Where @btr-protocol/sdk helps:
- The
FeedDatatype is exported from@btr-protocol/sdk/oracle, with a compile-time assertion that the field names still match the on-chain struct (observedAtSecs(feed)there reduces toupdatedAtSecson the mark store). A renamed field silently readingundefined, and every feed then gating as stale, has shipped as a bug; the SDK type catches it. GET /v1/venuesandGET /v1/abis/{name}give live addresses and ABIs per chain; use them instead of the constants above. See API & SDK Reference.
7. Checklist
Before you ship:
flags & 1checked, fails closed, no fallback price.mark1e18 == 0rejected.- Freshness gated with your own bound, computed off the struct, not only the feed’s own TTL.
- That bound kept well under ~4090 s: past it the lane’s age is saturated and unreliable.
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 - Mark Relayer: producer side, push path, quorum, storage layouts, deviation bands
- Flow Guards: how BTR itself acts on a reference-band breach
- Internal Oracle: the pool-side mark consumer
- Contract Addresses: the deployed pool and token fleet per chain
- API & SDK Reference