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 reference

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

FieldUnitNote
mark1e181e18 WADThe price. 0 is a sentinel, never a price
sigmaPbpsPBPS per 1800 s30-minute Parkinson volatility, not annualized. Scale to a horizon τ with √(τ/1800)
updatedAtSecsunix secondsObservation 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
ttlSecssecondsThe lane’s class freshness window, 1 to 4089 s
confidenceBpsbps1-sigma CI on the mark, decoupled from sigmaPbps
flagsbitfieldbit0 = FEED_HALT_BIT, the guardian fast-freeze; bits 1-15 unused and reserved
maxDevBpsbpsPer-push deviation band enforced on the value read
sourceTsMsmsupdatedAtSecs * 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, take mark1e18 from it.
  • No getFeedIds(). On the store, a feed id maps to a lane through PoolFactory.laneOf(feedId) (lane + 1, 0 = unmapped) and the FeedLaneUpdated log. Ids with their names: §3.2 or GET /v1/oracle/feeds.
  • No exists: probe PoolFactory.laneOf(feedId).
  • getFeed on an unknown feedId reverts FeedNotFound(bytes32) (selector 0x4e054b40). 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 PoolFactory to 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

|mprimary-mref|·104>mref·refBandBps

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

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=(type≪60)∣(baseClass≪56)∣(baseId≪40)∣(quoteClass≪36)∣(quoteId≪20)∣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 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:

feedId=bytes32(uint256(tickerId))

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 with PoolFactory.laneOf before relying on it: 0 means the id is not registered, and getFeed on it reverts FeedNotFound.

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:

FeedMITCH tickerId (hex)BaseQuoteOn-chain feedId
EURC-USDC (FX)0x06186b6484500000Crypto / 6251Crypto / 185010x00000000000000000000000000000000000000000000000006186b6484500000
NVDA-USDC (equity)0x0027cf6484500000Equities / 10191Crypto / 185010x0000000000000000000000000000000000000000000000000027cf6484500000
WETH-USDC (crypto)0x0616a96484500000Crypto / 5801Crypto / 185010x0000000000000000000000000000000000000000000000000616a96484500000

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:

yes

no

yes

no

false

true

getFeed(feedId)

halt bit set?

REJECT: guardian freeze

mark1e18 == 0?

REJECT: dark lane, never attested

now - updatedAtSecs <= myMaxAge?

REJECT or degrade: stale

widen by confidenceBps, size by sigmaPbps

use the price

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's

A 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

στ/1800=0.256%·540/1800≈0.140%≈14bps

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 store sourceTsMs is updatedAtSecs * 1000, so a min of 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.
  • getFeed reverts FeedNotFound on an unnamed feed, an unregistered lane, and a registered lane whose stored word is still empty (never pushed). A returned mark1e18 = 0 is 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.

FieldAnswersUse it to
confidenceBpsuncertainty of the current markwiden a spread, add a settlement buffer
sigmaPbpsrealized volatility of the assetsize 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 FeedData type 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 to updatedAtSecs on the mark store). A renamed field silently reading undefined, and every feed then gating as stale, has shipped as a bug; the SDK type catches it.
  • GET /v1/venues and GET /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:

  1. flags & 1 checked, fails closed, no fallback price.
  2. mark1e18 == 0 rejected.
  3. Freshness gated with your own bound, computed off the struct, not only the feed’s own TTL.
  4. That bound kept well under ~4090 s: past it the lane’s age is saturated and unreliable.
  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