Oracle model
Every market trades on a single oracle price: the tracked asset valued in the market’s collateral token, normalized to a 1e18 scale (collateral-denominated). There is no separate “mark” vs “index” price — the same price is used to open, value, close, compute health, check liquidation, and settle. The pool reads it through theIPriceOracle.getPrice() interface.
Each oracle is deployed per market + collateral pair, so settings like staleDuration and maxReasonablePrice are isolated per pair.
Oracle types
ChainlinkPriceOraclereads a Chainlink aggregator’slatestRoundData(), scales the answer to 1e18, and rejects non-positive answers, stale rounds (answeredInRound < roundId), prices older thanstaleDuration, and prices outside its configured bounds.PythPriceOraclereads a Pyth price feed. It stores an immutablebytes32 feedIdand reads the cached on-chain value viagetPriceNoOlderThan(feedId, staleDuration)(the data source is the Pyth contract, not a “price account”). See the pull-model notes below.DexPriceOraclereads anyIDexPriceSourceadapter (Uniswap V2 / V3 TWAP, or custom). It validates price bounds only — freshness and manipulation resistance are the adapter’s responsibility (see below).
minReasonablePrice, or above maxReasonablePrice reverts with BadPrice. Defaults are maxReasonablePrice = 1_000_000 * 1e18 and minReasonablePrice = 0 (floor disabled). The minReasonablePrice floor (enabled by the owner) rejects a feed clamped to its aggregator min-answer during a flash crash, so positions can’t settle against a wrong, pinned-low price.
The staleDuration default of 600 (10 min) applies only to Chainlink and DEX oracles (BasePriceOracle); both staleDuration and the price bounds are tunable per oracle by the owner. Pyth is different — its staleness is hard-capped at 60s (see below).
Pyth specifics (pull model)
Pyth is a pull oracle: a fresh price update must be pushed to the Pyth contract (paying the ETH update fee) in the same flow before reading.getPrice() is view-only and reads the cached value, reverting if it is older than staleDuration.
Even after a fresh update, a read can still revert with BadConfidence — the confidence interval is too wide relative to the price (conf × 10000 > price × maxConfidenceBps, i.e. conf / price exceeds maxConfidenceBps expressed in basis points). Each oracle sets maxConfidenceBps, so a successful price update does not guarantee a successful read.
Pyth staleness is hard-capped at 60s: DEFAULT_PYTH_STALE_DURATION = MAX_PYTH_STALE_DURATION = 60, and setStaleDuration reverts with PythStaleDurationTooLong for any value above 60s. The 10-min BasePriceOracle default does not apply to Pyth. As a result a Pyth-backed market needs a price update no older than 60s, so refresh the feed in (or near) the same transaction as the trade.
DEX specifics (no staleness check)
DexPriceOracle does not apply a time-based staleness check the way Chainlink and Pyth do — it reads the current adapter output and validates only price bounds. Any freshness or anti-manipulation guarantee (such as a TWAP window) must come from the approved adapter. DEX price sources must also be on the factory’s approved list before an oracle can use them.
Factory and registry
OracleFactory is the owner-gated factory and registry. It deploys a dedicated oracle per market/collateral pair, keying it on marketKey => collateralToken, and owns the deployed oracles. DEX sources must first be approved on the factory before an oracle can be created against them.
Oracle changes: two 24h timelocks
There are two separate, independent 24-hour propose-then-accept flows. No one can set an arbitrary price directly.- Swapping a market’s oracle is controlled by
RISK_ROLEon the pool (proposePriceOracle→ waitORACLE_CHANGE_DELAY= 24h →acceptPriceOracle). The candidate must match the market and collateral (OracleMismatch) and, when a registry is configured, be the registry-authorized oracle (UntrustedOracle). Acceptance re-validates both, so a proposal revoked or replaced during the delay cannot install a stale candidate, and it unconditionally requires a readable, non-zero price (ZeroPrice). When positions are open, acceptance also compares old and new prices: a deviation above 2% (MAX_ORACLE_ROTATION_DEVIATION = 0.02e18) revertsOracleRotationDeviationTooHighunless the pool is already reduce-only, and an unreadable or zero old price revertsOracleRecoveryRequiresReduceOnlyunder the same condition. Recovering from a broken oracle is therefore an explicit two-role action:GUARDIAN_ROLEsets reduce-only first (setReduceOnly), thenRISK_ROLEaccepts, and reduce-only stays on until governance clears it. - Changing an oracle’s underlying data source is controlled by the oracle’s owner — the factory (
updateDataSource→ waitDATA_SOURCE_CHANGE_DELAY= 24h →acceptDataSourceUpdate), exposed via the factory’s convenience methods. The new source must be pre-approved (setDataSourceApproval, otherwiseDataSourceNotApproved), and acceptance re-checks the approval and compares both sources’ normalized prices in the same block, revertingDataSourceDeviationTooHighabove 2% (MAX_DATA_SOURCE_DEVIATION = 0.02e18). This path never skips the comparison, so it only supports healthy-to-healthy rotation. Oracles useOwnable2Stepfor ownership handover.
Market schedule and risk phases
An oracle can be bound to aTradingCalendar: a weekly session template (including overnight sessions and weekends) plus holidays, half-days, and DST transitions. IPriceOracle.marketRiskPhase(reopenGraceDuration) then reports one of three phases: Active, Closed (returning the next scheduled open), or ReopenGrace (within the fixed grace after a scheduled open). Oracles without a calendar always report Active, so 24/7 markets are unaffected.
The pool gates new risk on the phase (_checkNewRiskAllowed, exposed as marketRiskStatus()): every openPosition / openCrossMarginPosition overload, addLiquidity, and addLiquidityTo revert NewRiskMarketClosed while closed and NewRiskReopenGrace(availableAt) during the grace. Closing, reducing leverage, adding position collateral, liquidation, removing liquidity, funding accrual, and claimSettlement stay available in every phase. The grace duration is set per pool (setClosureCooldownParams, RISK_ROLE), and the manual pause and reduce-only flags still apply on top.
On the price side, a Pyth oracle in a scheduled closure serves a frozen price: a stale read is tolerated while the calendar reports closed and the price age stays within maxClosureStaleness (isClosure() is true only in that state). Past that cap the read reverts Stale, and while the market is scheduled open a stale read reverts as before. Funding accrual has no closure exemption: it keeps accruing over closed intervals against the frozen price. Only the Pyth oracle implements closure handling; Chainlink and DEX oracles are closure-unaware.
Sequencer guard
BasePriceOracle.getPrice() first runs a sequencer liveness check through a SequencerGuard bound to the chain’s uptime feed. It reverts when the feed is unreadable (SequencerFeedUnavailable), when the sequencer reports down (SequencerDown), or when the sequencer restarted within the grace period (GracePeriodNotOver; default 1 hour, owner-tunable). Because every price consumer goes through getPrice(), a failed check blocks opens, closes, margin additions, leverage reductions, liquidations, and all LP actions; claimSettlement does not read the price and stays available. The guard is mandatory on Base mainnet (chain id 8453, reverting SequencerGuardRequired when unset) and optional on other chains.
Operational notes
- Keep off-chain tooling and deployment config aligned with the oracle type and feed/feed-id chosen for the pool.
- Treat stale or rejected prices as a risk to health checks — when a price is rejected, opening, closing, and liquidation are all unavailable until it refreshes.
- Local setups usually use mocks so you can test the full lifecycle without a live feed; Base Sepolia defaults to a Pyth-based setup in the deployment runbook.
