Price feeds
How assets are priced — one primary feed per asset, divergence monitors, feed types, and the custom price feeds.
Every asset registered in a NAV Calculator has exactly one primary price feed and zero or more monitor feeds. The primary feed converts a raw token balance into a value in the quote currency (USD by default, 8 decimals) — it is the only feed that prices NAV. Monitor feeds are read only to detect divergence; they never price the asset. An asset is marked stale when its primary feed is stale (or the L2 sequencer is down).
Source: PriceFeedLib.sol
How prices are resolved
When pricing an asset, the NAV Calculator reads its primary feed and, in the same call, compares that price against the asset's monitor feeds to compute a divergence signal. Consumers can inspect both with getPriceData:
The key fields of the returned PriceFeedData:
price
Price from the primary feed (0 if the primary is stale)
decimals
Decimal places in price (pinned per asset; all feeds for an asset share decimals)
stale
true if the primary feed is stale, or the L2 sequencer is down
healthyFeedCount
How many pricing feeds passed the staleness gates this read (1 in the intended one-primary configuration)
irregular
true if the primary diverged from the monitors beyond tolerance, or (stablecoins) strayed from the $1 peg
divergenceBps
Worst-case deviation between the primary and any healthy monitor, in basis points (0 when no healthy monitor)
monitorFeedCount
How many monitor feeds passed the staleness gates this read
irregular is a pricing-quality signal, not a validity flag. Even when irregular is true, price is still populated from the primary feed — the NAV Calculator is a read-only view that never reverts on divergence. It is the offchain subscription/redemption processor that decides how to react (pause, widen a band, alert). See Primary feed and divergence monitors.
The value of a (non-debt) position is:
normalised into the quote asset's decimals.
Example (illustrative values; WETH = 0xC02a…6Cc2, a Chainlink primary with one Redstone monitor):
Primary feed and divergence monitors
Each asset is priced by exactly one deterministic primary feed. Any additional trustworthy source is registered as a monitor feed — read on every NAV call to check that the primary is still telling the truth, but never used to value the asset.
Why one primary, not the freshest feed
Pricing from whichever of several redundant feeds is freshest tracks market noise instead of redeemable value. When an asset has both a fundamental exchange-rate feed (its redeemable value) and a market composite (Redstone/API3), the market feed updates more often and therefore wins. On a leveraged fund the resulting NAV swings are amplified by gross leverage, with no position change behind them.
One deterministic primary as the sole pricing feed removes that noise, and the redundant sources serve as divergence-only monitors — so a market wobble raises an alert instead of moving the share price.
The divergence and irregular signal
On each read, over the asset's healthy monitor feeds (each an independent <asset>/USD, 8-decimal feed, gated on its own heartbeat), the NAV Calculator computes:
divergenceBps— the worst-case (furthest) single-monitor deviation from the primary:max |primary − monitorᵢ| / primary, in basis points. Using the furthest monitor (not an average) means two monitors straddling the primary can't mask a genuine one-sided split.irregular— settruewhendivergenceBpsexceeds the asset'sdivergenceToleranceBps, or (for USD-pegged stablecoins only) when the primary strays from$1beyond the asset'spegToleranceBps.
The median monitor price and a primaryAboveMonitors direction flag are exposed separately (via getPriceDivergence) for directionality — neither feeds the irregular flag itself. Both divergence and peg checks are suppressed while the primary is stale (a stale primary is already flagged via stale).
A monitor past its own heartbeat self-excludes rather than contributing a stale rate to the comparison — and because monitorFeedCount reports how many passed, that degradation is observable rather than silent: an asset whose count drops has lost a cross-check, even though irregular stays false. This is what makes it safe to keep a monitor whose upstream feed may be retired: the worst case is losing the signal, never a wrong one.
Tolerances are per-asset config, default 0 (= that check disabled). The values in use:
Kind
divergenceToleranceBps
pegToleranceBps
Fundamental-vs-market (Custom exchange-rate primary + market monitor) — wstETH, weETH, ezETH, rETH, ETHx, osETH, stETH …
250 (2.5%)
—
Market-vs-market (Chainlink primary + a second market feed) — ETH/WETH, USDe …
50 (0.5%)
—
USD-$1 stablecoins, monitored (peg guard plus a market-divergence monitor) — mainnet USDC & USDT, Base USDC …
50
100 (1%)
USD-$1 stablecoins, peg-only (no second market feed on that chain, so divergence is disabled) — Base USDT, all Gnosis stablecoins …
0
100 (1%)
A stablecoin's $1 peg guard is independent of its divergence monitor: where a chain has no second market feed for it — e.g. RedStone publishes no USDT/USD on Base, and the Gnosis stablecoins carry no market monitor — the asset runs peg-only (divergenceToleranceBps = 0, pegToleranceBps = 100). It still raises irregular when the Chainlink primary strays past 1% from $1, just without a market cross-check.
pegToleranceBps is the system's definition of "pegged", not merely a setting on pegged assets. An asset is pegged exactly when it carries a non-zero pegToleranceBps, and that value is readable on-chain per asset — so peggedness is derived from one authority rather than recorded a second time somewhere else and kept in step. There is deliberately no separate stored peg flag: a second copy would be able to disagree with this one, and the only defence against that would be a cross-check that exists solely because the copy does.
The practical consequence for a consumer classifying an asset: read the tolerance, do not maintain your own list. A yield-bearing wrapper is not pegged for exactly this reason — its redemption rate carries its price off $1, so it holds no peg tolerance, so it does not read as pegged.
irregular never changes price or value, and the NAV Calculator never reverts on divergence — it is a read-only view. A true value tells the offchain subscription/redemption processor to withhold or widen pricing and alert operators. See Stale prices & sequencer for how it sits alongside stale, sequencerDown, and quoteAssetStale.
Transitional fallback. The primary lives in the asset's priceFeeds list (priceFeeds[0]); monitors live in a separate monitorFeeds list. The legacy freshest-wins selector is retained only for the migration window where an asset might still carry a second pricing feed — with the intended one-primary configuration the selector runs once and simply returns the primary. Monitor feeds must report 8 decimals (MonitorFeedDecimalsMismatch); pricing feeds for one asset must share decimals (enforced on addPriceFeed).
Supported feed types
PriceType enum:
0
Chainlink
Chainlink AggregatorV3Interface push feed
1
Redstone
Redstone Classic Push — AggregatorV3-compatible, read like Chainlink
2
API3
API3 dAPI via Api3ReaderProxyV1 (18-dec proxy scaled to 8-dec at read)
3
Custom
A custom ICustomPriceFeed adapter with internal staleness logic
AggregatorV3-shaped feeds. Each is gated on a configurable heartbeat (block.timestamp − updatedAt ≥ heartbeat → stale). Redstone and API3 are read through the same latestRoundData path as Chainlink.
Custom composite adapters (ICustomPriceFeed) are deployed for assets without a direct USD feed. Each prices its asset as rate × base/USD — a fundamental on-chain exchange rate (e.g. wstETH's stEthPerToken()) times the base asset's USD price (ETH/USD for the LSTs, a stablecoin/USD for the ERC4626 vaults). These Custom feeds are the intended primary for their asset; any market-based source is registered as a monitor, not folded into the price.
The
rateleg is a live on-chain exchange rate pinned in the adapter (a protocolgetRate()/convertToAssets()/stEthPerToken()call), with no market oracle in the pricing path.The
base/USDleg is not a hardcoded oracle — it is read from the NAV's own primary feed for the base asset, viagetPriceDataNoDivergence(baseAsset), so the whole system uses one price source per base asset (see Base/USD from the NAV's own selector).getLatestPrice()returns(price, stale)and is the primary staleness gate, encoding the adapter's own logic (OR of its rate-leg heartbeats and the NAV's stale bit for the base asset).latestRoundData()suppliesupdatedAtfor the secondary heartbeat gate.
See the per-asset pages below for each adapter's composition.
Choosing a heartbeat: the provider's cadence is a floor, not a target
A heartbeat registered at exactly the provider's declared cadence has zero margin, and will go stale at the end of essentially every publish cycle. A "24-hour" feed does not publish at 86,400 s; it publishes tens of seconds past the nominal target. Registered at 86400, the asset is therefore priced $0 and listed in stalePriceAssets in the gap before each update lands — not as a rare event but once per cycle, indefinitely.
The margins in use:
~24 h (86,400 s)
90000
~23 h (82,800 s)
86400
Zero margin only bites heartbeat-driven feeds. Where an asset's deviation threshold fires first, the feed updates long before its heartbeat and the heartbeat never binds — which is why the rule applies to pegged assets (stablecoins, pegged rates, forex pairs) and not to volatile ones. A zero-margin registration on a volatile asset is not by itself a defect, so this is not a pattern to apply across every feed uniformly.
Both heartbeat axes are checked in preflight, off-chain: this one, comparing each feed to the cadence its publisher declares, and the derived-vs-base rule below. Because a provider's declared cadence is off-chain metadata, the check reports a zero-margin feed that has not been measured as a warning to measure, and reserves a hard failure for a recorded exemption that its own evidence contradicts.
This is an absolute rule about a feed and its provider, and it composes with the relative rule in the next section (a derived feed's heartbeat may never be tighter than its base's). Raising a base asset's heartbeat for margin therefore forces its derived feeds up too, or the relative invariant breaks — which is exactly why raising the GHO and WXDAI primaries pulled sGHO and Gnosis sDAI up with them.
Base/USD from the NAV's own selector
A custom feed prices its asset as rate × base/USD. The base/USD term — ETH/USD for the LSTs, a stablecoin/USD for the ERC4626 vaults — is read from the NAV Calculator's own price selector, getPriceData(baseAsset), rather than from a hardcoded Chainlink oracle embedded in the feed.
Why. The NAV prices the base asset itself (e.g. WETH) through its primary feed. If a custom feed embedded its own Chainlink ETH/USD while the NAV valued WETH off a different oracle, the two ETH/USD terms would not cancel in ETH-quoted NAV: a divergence between the two ETH/USD sources could move NAV by ≈ spread × grossLeverage with zero position change. Sourcing the base term from the NAV's own primary makes the cancellation structural — the derived asset and the base asset's own leg always use the identical base/USD value in the same block.
The base term is read via getPriceDataNoDivergence(baseAsset) — the base asset's primary price, skipping the monitor/divergence computation on this hot path. The base asset keeps its own divergence monitors for alerting; the derived assets simply follow the base primary. A derived asset reads stale whenever the NAV reports its base asset's primary feed stale (past its heartbeat).
Never register a derived feed with a heartbeat tighter than its own base asset's. Because the derived feed inherits the base's staleness verdict regardless, a tighter heartbeat cannot prevent a stale price — it can only manufacture a stale verdict while the base is still fresh, zeroing the asset's price and listing it in stalePriceAssets for no reason. A single-primary asset has no second feed to rescue that read.
The invariant is guarded pre-deploy. Matching the base is the floor, not the whole answer — the base's own value must itself carry margin over its provider's cadence, since matching a base that is registered at zero margin reproduces the base's own blackout in the derived feed.
The reverse direction is safe and intentional: several derived feeds sit at 86400 over a WETH base at 3600. A looser derived heartbeat cannot over-report, precisely because the base's own verdict is inherited either way — so tightening those would only add a needless blackout window.
An L2 sequencer outage does not zero the base leg. computeNav prices every direct-fed position at its last value during a sequencer outage and surfaces the outage only through the top-level NAV.sequencerDown flag, not per position. If custom feeds zeroed on sequencer-down, custom-priced positions would crater to $0 while direct ones stayed live — an inconsistent, understated NAV. So the base leg is marked stale only on genuine base-feed staleness, matching the direct-feed path; the L2 sequencer stays a single top-level signal.
Circularity invariant. A baseAsset (WETH, GNO, the stablecoins) MUST be priced by direct feeds (Chainlink / API3 / Redstone) — never by a NAV-sourced custom feed — otherwise getPriceData(baseAsset) would recurse. The shared helper is NavBaseUsdLib.
The one-hop case is rejected on-chain at registration. Registering a Custom feed makes the NAV probe the feed's BASE_ASSET(); if that base is the asset being registered, registration reverts CustomFeedBaseCircular. A feed that does not answer BASE_ASSET() at all — or answers with something that is not a clean address word — is rejected too (CustomFeedBaseUnreadable), so the check fails closed rather than waving through what it cannot read. It runs on the same validation path as the asset-binding check, so it covers registration, adding a feed, adding a monitor, and the read-only pre-flight query.
Multi-hop cycles are not caught on-chain, deliberately. A feed for A based on B and a feed for B based on A both register cleanly, in either order: the check is handed only (asset, feed) and never reads the registry. That is not an oversight — the only on-chain alternative is an ordering-dependent registry scan, and such a scan cannot answer "no", because a base with no custom feed today can acquire one tomorrow. A check that cannot fail is worse than no check.
Instead the cycle is made unconstructible in configuration, and that is now asserted rather than assumed: of the 41 configured Custom (asset, feed) pairs across all seven chains — 31 primaries and 10 monitors — none has a base priced by a Custom primary. Every base resolves to a Chainlink-only asset: WETH, USDC, GHO, USDS, USDe, GNO, WXDAI. The pre-deploy config check treats a violation as an error, not a warning.
A wrong base is worse than a cyclic one, and nothing on-chain catches it. A cycle at least announces itself in gas — a poisoned read has been measured at ~153× the cost of a healthy one. A base that is merely the wrong asset has no such signature: no gas anomaly, no stale flag, no irregular flag. It returns a plausible, wrong number at an ordinary read.
The registration guard rejects a base that is the asset itself; it cannot tell whether a base is the right asset. 35 of the 41 pairs depend on the configured base being correct. The exception is the six ERC-4626 vault-rate feeds — sUSDe, sUSDS, syrupUSDC, RWIV, sGHO, sXDAI — whose constructor pins the base to IERC4626(vault).asset() and reverts BaseAssetMismatch on anything else.
The shared helper is NavBaseUsdLib.baseUsd(nav, baseAsset): it reads getPriceDataNoDivergence(baseAsset), normalises to 8 decimals, marks the base stale on genuine base-feed staleness (a non-positive price or a reverting read; not on sequencer-down alone — see above), and fails to stale (never reverts the calling feed). ETH+ (a basket RToken, no base oracle) is unaffected.
Custom price feeds
Custom composite feeds (PriceType.Custom, implementing ICustomPriceFeed) are deployed for assets that lack a direct USD feed. They are grouped into four families below; each links to its own page with the full composition, constructor, and staleness rules.
A Custom feed is bound on-chain to the asset it prices. Registration asks the feed which asset it prices (underlyingAssetSupported()) and reverts CustomFeedAssetMismatch(asset, feed, feedAsset) if it is not the asset being registered. This covers all three entry points — registerAsset, addPriceFeed and addMonitorFeed — and the read-only checkPriceOracleSupport, so tooling can pre-flight the check without a transaction.
Without it, registering feed A for asset B would succeed silently and every B position would be priced at A's price indefinitely: a plausible number, with stale = false, irregular = false, and no on-chain trace. The exposure is real because feeds are resolved from config by string key, across near-identical names — ezETH / ezETHCrossRate, osETH / osETHCrossRate, ETHx / ETHxCrossRate, rETH / rETHCrossRate, NXM / wNXM, weETH_L2 / weETHCrossRate.
The check covers the asset axis only. A feed's baseAsset is not bound the same way, so a feed deployed against the wrong base (the osToken feed is built against WETH on mainnet and GNO on Gnosis) would still pass. Verify the base at deploy time.
The rate-feed axis is guarded pre-deploy. A feed constructed with an external rate oracle — the Y/X leg of a cross-rate feed, for instance — has a third way to be wrong: the right asset, the right base, and the wrong oracle. Confirming the address is an aggregator never establishes which pair it reports, so each such instance is checked against its declared config before deployment on three axes: pair identity from description(), a plausibility band on the current rate, and contract-readability.
The band is not redundant with the pair check. It catches what description() cannot — a feed with no description, a renamed one, or one whose description is itself wrong — and rules out cross-class confusion outright, since feeds for different asset classes do not trade in overlapping ranges. The readability check exists because an access-controlled aggregator reverts when read, which would otherwise surface as an opaque constructor failure.
The guard fails closed. An instance that takes a rate-feed-shaped constructor parameter but declares no guard block reverts the deploy script rather than being silently skipped, so the guard cannot quietly stop covering the next feed anyone adds.
Coverage is deliberately uneven by provider, and declared rather than assumed. Where a provider's description() names the pair, identity is asserted exactly. Where it cannot — RedStone Classic-Push returns the constant string "Redstone Price Feed" on every feed for every pair, so an exact-match assertion is unimplementable for that family rather than merely weak — the config sets description: "$UNVERIFIABLE" with a mandatory written reason, and the deploy reverts if the reason is missing. The band and readability checks still apply in full; only the pair assertion is dropped, and it is dropped visibly.
LSTs & LRTs
stETH
1.0 × ETH/USD (stETH/ETH ≡ 1.0)
Ethereum
These LST/LRT feeds are fundamental primaries — they read only the protocol's on-chain exchange rate (no embedded market oracle). The market leg lives in a separate monitor: wstETH/weETH watch market <asset>/USD feeds, while ezETH, ETHx, osETH, rETH and stETH are watched by a cross-rate monitor (ETH/USD × <asset>/ETH). Where that <asset>/ETH leg is an independent market feed, a depeg raises irregular instead of moving NAV.
ezETH carries two cross-rate monitors, and only one of them is a market feed. Its original monitor's ezETH/ETH leg became fundamental (RedStone's multi-feed ezETH_FUNDAMENTAL) when RedStone sunset the Classic-Push market feed — leaving a check that compared one fundamental rate against another, which cannot see the market price diverge. A second monitor with a genuine market leg was added alongside it rather than replacing it, so the depeg check fires again.
Keeping both is safe because divergenceBps is measured against the worst single monitor, not the median: the market monitor dominates instead of being averaged away by the fundamental one sitting near 0 bps. Their heartbeats differ deliberately — each is derived from its own feed's measured cadence, not copied from the other.
stETH is priced fundamentally, at a flat 1.0 against ETH — the same claim wstETH's stEthPerToken() and eETH's constant rate encode, and it uses the same unit-rate contract as eETH. It carries two monitors: the market stETH/ETH cross-rate and a direct Chainlink stETH/USD feed. Pricing stETH off a market feed while wstETH was fundamental meant the two disagreed by the full discount during a depeg — 1,000 stETH and the economically identical ~870 wstETH valued 6% apart at a 0.94 print — so legs that should net inside one portfolio (Aave wstETH collateral against a Curve stETH/ETH LP) showed phantom P&L with no position change. It also made the divergence signal read backwards: wstETH flagged irregular during a depeg while stETH, agreeing with itself, flagged nothing.
Yield-bearing stables
These float; they are not $1 assets. Each prices as a growing redemption rate times its base stablecoin, so its USD price rises above $1 as the rate accrues — which is what makes it not pegged, and why none of them carries a pegToleranceBps. The $1 guard belongs to the base stablecoin (USDS, USDe, GHO, USDC, xDAI) that each of these feeds reads through the NAV. The "stables" grouping here describes the feed shape, not a peg.
ERC4626 rate legs must be manipulation-resistant. The convertToAssets() feeds (ERC4626VaultRatePriceFeed) read the vault's share→asset rate live, so the vault's totalAssets accounting must not be balanceOf-based — a balanceOf vault can be inflated by a donation, skewing the rate. The six shipped vaults use manipulation-resistant accounting; any new ERC4626 rate feed must reject balanceOf-based vaults before wiring, and its baseAsset_ must equal the vault's asset() (enforced on-chain — BaseAssetMismatch).
Derived & cross-rate
L2 variants
Governing-leg freshness
A composite has no single honest (updatedAt, heartbeat): it depends on two Chainlink legs — the rate leg with its own heartbeat, plus the base/USD leg with a different one — and the oldest leg and the tightest-heartbeat leg are usually different feeds. Reporting the oldest timestamp against the tightest heartbeat produces a permanent false warning for feeds with a slow leg; pairing it with the loosest heartbeat hides a genuinely stale fast leg.
So getPriceData(asset) reports the governing leg: the underlying Chainlink leg with the greatest age / heartbeat ratio — the input closest to, or furthest past, its own staleness deadline — and surfaces that leg's (updatedAt, chainlinkHeartbeat). The pair is a real, matched pair from one actual oracle, and reduces to the obvious answer for a single-leg feed. Each custom feed exposes it as governingFeedFreshness() → (updatedAt, heartbeat):
Single Chainlink leg (BaseCustomPriceFeed, ERC4626VaultRatePriceFeed, and the bespoke ankrETH)
The base/USD leg, read from the base asset's own NAV selector via NavBaseUsdLib.baseFreshness
Two Chainlink legs (TwoFeedChainlinkPriceFeed, cross-rate feeds)
Whichever of the rate leg or the base leg has the larger age / heartbeat ratio; falls back to the base leg if the rate feed is unreadable
ETH+ (Reserve basket — built, not deployed)
min(lastSave()) across the active basket, against the Reserve-protocol heartbeat
No trackable Chainlink leg
(0, 0) — reported as no timestamp
Because the reported heartbeat is the governing leg's, it overrides the registered config heartbeat for custom feeds — so a custom-priced asset's reported heartbeat is typically tighter than its registration value.
The pair is advisory; stale is the authoritative verdict. (updatedAt, chainlinkHeartbeat) describe only the governing leg's push freshness. stale additionally folds in the rate answer's validity, the L2 sequencer state, and the feed's own configured heartbeat gate — so the pair can read fresh (age < heartbeat) while stale is true. Consumers must gate on stale / sequencerDown and never recompute staleness from the pair alone.
Two further consequences worth knowing: which leg governs changes between reads as the ratios cycle, so a two-leg feed's reported heartbeat can alternate between its legs' values; and the feed's own latestRoundData().updatedAt is unchanged — it still returns the conservative oldest-leg timestamp, which is what feed selection, ranking and the outer heartbeat gate use. The staleness rule itself is unchanged: a custom feed backed by n Chainlink legs is stale iff any leg is stale.
The helpers are NavBaseUsdLib.baseFreshness and ICustomPriceFeed.
L2 sequencer uptime check
On Layer 2 chains the NAV Calculator also checks the Chainlink L2 Sequencer Uptime Feed; if the sequencer is down or within its grace period, the chain's reading is flagged via NAV.sequencerDown. See Stale prices & sequencer for the full staleness and sequencer model.
Inspecting an asset's feeds
Pricing (primary) feeds, monitor feeds, and the per-asset tolerances are each introspectable, and getPriceDivergence returns the live divergence read:
Example (illustrative values; WETH = 0xC02a…6Cc2 — one Chainlink primary, one Redstone monitor):
Admin operations
Feed configuration is privileged (governance Safe, MANAGER role). An asset carries one pricing feed, plus optional monitor feeds and tolerances:
Register an asset with its (primary) feed
registerAsset(asset, priceFeed, priceType, heartbeat)
Add / remove a pricing feed
addPriceFeed(...) · removePriceFeed(asset, priceFeed) · removePriceFeedAt(asset, index)
Set the divergence / peg tolerances
setDivergenceTolerance(asset, bps) · setPegTolerance(asset, bps)
Add / remove a monitor feed
addMonitorFeed(...) · removeMonitorFeed(asset, priceFeed) · removeMonitorFeedAt(asset, index)
Remove the asset and all its feeds
unregisterAsset(asset)
addPriceFeed requires the new feed's decimals to match the asset's existing pricing feeds (PriceFeedDecimalsMismatch) and rejects a feed already registered (PriceFeedAlreadyRegistered). addMonitorFeed requires a divergence tolerance to be set first (DivergenceToleranceRequired), the monitor to report 8 decimals (MonitorFeedDecimalsMismatch), and rejects duplicates (MonitorFeedAlreadyRegistered). Full per-function detail, roles, and reverts are on the NAV Calculator's Admin / Manager API.
An asset must always keep at least one pricing feed: removePriceFeed reverts with CannotRemoveLastFeed. Monitor feeds are optional — the last one can be removed. Fully removing pricing for an asset is done via unregisterAsset, which should only happen after the asset has been withdrawn from every portfolio.
Last updated