// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IAggregatorV3, ISequencerUptimeFeed, IAggregatorProxy} from "./IAggregatorV3.sol"; /// @title OracleFreshness /// @notice Stateless safe-read for Chainlink price feeds with explicit staleness /// + L2 sequencer-uptime guards. Most integrations forget at least one /// of the four checks below. Composing them in one place — and reverting /// on each failure mode — turns a class of silent vulnerabilities into /// clear errors. /// /// Why this exists: in our work auditing lending protocols and building /// liquidation bots, we've repeatedly seen integrations that: /// (a) trust `latestAnswer()` without a staleness check /// (b) check staleness against a hardcoded heartbeat instead of the /// max-acceptable age for THEIR system (different from Chainlink's) /// (c) call the AggregatorProxy assuming it emits events (it doesn't — /// the underlying impl does — so any event-driven freshness check /// built on the proxy is silently broken) /// (d) skip the L2 sequencer-uptime feed entirely, accepting prices /// that were frozen during an outage /// /// All four are handled here. /// /// @dev Contract (not library) so it has a single verifiable on-chain address. /// Calls are `view`. No storage. contract OracleFreshness { /// @notice Thrown when the price feed has not updated within `maxAge` seconds. error StaleOracle(address feed, uint256 updatedAt, uint256 nowTime, uint256 maxAge); /// @notice Thrown when the L2 sequencer is currently reported down (uptime feed answer != 0). error SequencerDown(address sequencerFeed, uint256 downSince); /// @notice Thrown when the L2 sequencer recently came back online and we're /// still within the configured grace period (price feed may be stale). error SequencerGracePeriod(uint256 secondsRemaining); /// @notice Thrown when the Chainlink feed returns a non-positive answer /// (negative or zero). Lending protocols MUST not accept these. error NonPositivePrice(int256 answer); /// @notice Thrown when `freshPrice` is called with a zero feed address. error ZeroFeed(); /// @notice Resolve `proxy.aggregator()` if the address is a Chainlink AggregatorProxy. /// Falls back to the original address if the call reverts (i.e. it isn't /// a proxy). Useful when subscribing to `AnswerUpdated` over WSS: only the /// impl emits, not the proxy. /// @param feedOrProxy the address that might be a Chainlink AggregatorProxy /// @return impl the underlying impl if proxy, else the input address function resolveImpl(address feedOrProxy) external view returns (address impl) { return _resolveImpl(feedOrProxy); } /// @notice Read a Chainlink price with full freshness guards. /// @param feed Chainlink AggregatorProxy or direct impl address /// @param maxAge max acceptable age of the price answer in seconds. /// Set this based on YOUR system's tolerance, /// NOT just the Chainlink feed's heartbeat. /// @param sequencerUptimeFeed L2 sequencer-uptime feed address (Base, OP, Arb). /// Pass `address(0)` on L1 to skip the check. /// @param sequencerGracePeriod seconds to wait after sequencer comes back up /// before trusting price data. Chainlink's docs /// recommend 3600 (1 hour). /// @return price the latest price answer, as uint256 (guaranteed > 0) /// @return decimals the feed's decimals (typically 8 for USD pairs) /// @return updatedAt unix timestamp of the price's last update function freshPrice( address feed, uint256 maxAge, address sequencerUptimeFeed, uint256 sequencerGracePeriod ) external view returns (uint256 price, uint8 decimals, uint256 updatedAt) { if (feed == address(0)) revert ZeroFeed(); // (1) Sequencer-uptime check. Skip on L1 (sequencerUptimeFeed == 0). if (sequencerUptimeFeed != address(0)) { (, int256 seqAnswer, uint256 seqStartedAt,,) = ISequencerUptimeFeed(sequencerUptimeFeed).latestRoundData(); // answer != 0 means sequencer is DOWN (1 = down, 0 = up). if (seqAnswer != 0) { revert SequencerDown(sequencerUptimeFeed, seqStartedAt); } // If just came back online, wait out the grace period. uint256 sinceUp = block.timestamp - seqStartedAt; if (sinceUp <= sequencerGracePeriod) { revert SequencerGracePeriod(sequencerGracePeriod - sinceUp); } } // (2) Resolve proxy → impl. We READ through the proxy (it works), but // anyone using this for event subscriptions should call resolveImpl // separately. The freshness check itself operates on whichever // address you pass in. IAggregatorV3 agg = IAggregatorV3(feed); // (3) Pull the answer + freshness fields. (, int256 answer,, uint256 _updatedAt,) = agg.latestRoundData(); // (4) Reject non-positive prices. if (answer <= 0) revert NonPositivePrice(answer); // (5) Staleness check against caller-provided max age. if (block.timestamp - _updatedAt > maxAge) { revert StaleOracle(feed, _updatedAt, block.timestamp, maxAge); } return (uint256(answer), agg.decimals(), _updatedAt); } /// @notice Pure-view version that returns a status code instead of reverting, /// for callers that want to make their own decision (e.g. a UI that /// shows "stale" rather than reverting the whole tx). /// @return status 0 = ok, 1 = stale, 2 = sequencer down, 3 = grace period, /// 4 = non-positive price, 5 = zero feed /// @return price the latest price (0 if status != 0) /// @return updatedAt unix timestamp of the price's last update (0 if status != 0) function checkPrice( address feed, uint256 maxAge, address sequencerUptimeFeed, uint256 sequencerGracePeriod ) external view returns (uint8 status, uint256 price, uint256 updatedAt) { if (feed == address(0)) return (5, 0, 0); if (sequencerUptimeFeed != address(0)) { (, int256 seqAnswer, uint256 seqStartedAt,,) = ISequencerUptimeFeed(sequencerUptimeFeed).latestRoundData(); if (seqAnswer != 0) return (2, 0, 0); if (block.timestamp - seqStartedAt <= sequencerGracePeriod) return (3, 0, 0); } (, int256 answer,, uint256 _updatedAt,) = IAggregatorV3(feed).latestRoundData(); if (answer <= 0) return (4, 0, 0); if (block.timestamp - _updatedAt > maxAge) return (1, 0, _updatedAt); return (0, uint256(answer), _updatedAt); } // --- internal --- function _resolveImpl(address feedOrProxy) internal view returns (address) { // Try proxy.aggregator(). If it reverts (not a proxy), keep the original. // Using low-level staticcall to suppress reverts cleanly. (bool ok, bytes memory ret) = feedOrProxy.staticcall( abi.encodeWithSelector(IAggregatorProxy.aggregator.selector) ); if (ok && ret.length >= 32) { address impl = abi.decode(ret, (address)); if (impl != address(0)) return impl; } return feedOrProxy; } }