Skip to main content
Sigvex

Remediating Stale Off-Chain Price Feed

How to validate off-chain aggregated oracle data freshness by checking the updatedAt timestamp, answer validity, and round completeness before consuming price data.

Remediating Stale Off-Chain Price Feed

Overview

Related Detector: Stale Off-Chain Price Feed

Off-chain aggregated price feeds update in response to two triggers: a price deviation threshold being crossed, or a heartbeat interval elapsing. During network outages, gas price spikes, or oracle node failures, neither trigger may fire for extended periods. The latestRoundData() function will return the last successfully published price with no indication that it is stale. Protocols that do not validate the updatedAt timestamp against block.timestamp will silently accept prices that are hours or days old.

The recommended fix is to add four validation checks after calling latestRoundData(): confirm the answer is positive, confirm the round completed, confirm the price age is within an acceptable threshold, and confirm the round answered is not older than the round ID.

Before (Vulnerable)

import "./interfaces/AggregatorV3Interface.sol";

contract VulnerableLending {
    AggregatorV3Interface public immutable priceFeed;

    constructor(address _feed) {
        priceFeed = AggregatorV3Interface(_feed);
    }

    function getPrice() internal view returns (int256) {
        (, int256 price, , ,) = priceFeed.latestRoundData();
        // VULNERABLE: price could be hours or days old with no indication
        return price;
    }
}

After (Fixed)

import "./interfaces/AggregatorV3Interface.sol";

contract SecureLending {
    AggregatorV3Interface public immutable priceFeed;

    // Set this per-feed based on the documented heartbeat for that feed.
    // ETH/USD on mainnet typically has a 3600s heartbeat; use slightly more
    // to account for network delays.
    uint256 public constant MAX_PRICE_AGE = 3900; // 65 minutes

    constructor(address _feed) {
        priceFeed = AggregatorV3Interface(_feed);
    }

    function getPrice() internal view returns (int256) {
        (
            uint80 roundId,
            int256 price,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();

        // 1. Price must be positive — negative and zero prices indicate errors
        require(price > 0, "Invalid price: non-positive answer");

        // 2. updatedAt must be non-zero — zero means the round never completed
        require(updatedAt != 0, "Invalid price: round not complete");

        // 3. Price must be recent — reject anything older than the heartbeat window
        require(
            block.timestamp - updatedAt <= MAX_PRICE_AGE,
            "Price feed is stale"
        );

        // 4. The round that answered must not be older than the current round
        require(answeredInRound >= roundId, "Feed round stale");

        return price;
    }
}

Configuring the Staleness Threshold

The MAX_PRICE_AGE constant must be set per-feed based on that feed’s documented heartbeat. Consult the feed operator’s published heartbeat interval. Common values:

Feed Network Heartbeat
ETH/USD Ethereum mainnet 3600s (1 hour)
BTC/USD Ethereum mainnet 3600s (1 hour)
ETH/USD major L2 networks 86400s (24 hours)
Mid-cap token Ethereum mainnet 3600s (1 hour)

Add a small buffer (5–10 minutes) to the heartbeat to account for minor network delays. Do not add more than 20% buffer, as this defeats the purpose of the check.

// For feeds with a 24-hour heartbeat (common on L2s)
uint256 public constant MAX_PRICE_AGE_L2 = 87300; // 24h + 15min buffer

// For feeds with a 1-hour heartbeat
uint256 public constant MAX_PRICE_AGE_MAINNET = 3900; // 1h + 5min buffer

Alternative Mitigations

Circuit Breaker Pattern

If your protocol cannot reject all operations during a stale oracle period (e.g., it still needs to process liquidations), implement a degraded-mode circuit breaker that pauses new debt issuance but still allows repayment and liquidation:

contract CircuitBreakerLending {
    AggregatorV3Interface public immutable priceFeed;
    uint256 public constant MAX_PRICE_AGE = 3900;

    enum OracleState { Healthy, Stale }

    modifier requireFreshOracle() {
        require(getOracleState() == OracleState.Healthy, "Oracle is stale: operation paused");
        _;
    }

    function getOracleState() public view returns (OracleState) {
        (, int256 price, , uint256 updatedAt,) = priceFeed.latestRoundData();
        if (price <= 0 || updatedAt == 0 || block.timestamp - updatedAt > MAX_PRICE_AGE) {
            return OracleState.Stale;
        }
        return OracleState.Healthy;
    }

    // Blocked during stale oracle — relies on fresh price
    function borrow(uint256 amount) external requireFreshOracle {
        // ...
    }

    // Allowed during stale oracle — no new price needed
    function repay(uint256 amount) external {
        // ...
    }
}

Multiple Oracle Aggregation

For critical price paths, aggregate multiple independent oracle sources and take the median. This provides redundancy if one feed becomes stale:

contract MultiOracleLending {
    AggregatorV3Interface public immutable primaryFeed;
    AggregatorV3Interface public immutable backupFeed;
    uint256 public constant MAX_PRICE_AGE = 3900;
    uint256 public constant MAX_DEVIATION_BPS = 200; // 2% max deviation between feeds

    function getPrice() internal view returns (int256) {
        int256 primaryPrice = _getFeedPrice(primaryFeed);
        int256 backupPrice = _getFeedPrice(backupFeed);

        // Sanity check: ensure the two feeds agree within tolerance
        uint256 deviation = _bpsDeviation(primaryPrice, backupPrice);
        require(deviation <= MAX_DEVIATION_BPS, "Oracle feeds disagree");

        // Use the primary price if both feeds are in agreement
        return primaryPrice;
    }

    function _getFeedPrice(AggregatorV3Interface feed) internal view returns (int256) {
        (
            uint80 roundId,
            int256 price,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = feed.latestRoundData();

        require(price > 0, "Invalid price");
        require(updatedAt != 0, "Round not complete");
        require(block.timestamp - updatedAt <= MAX_PRICE_AGE, "Feed is stale");
        require(answeredInRound >= roundId, "Stale round");

        return price;
    }

    function _bpsDeviation(int256 a, int256 b) internal pure returns (uint256) {
        if (a == b) return 0;
        int256 diff = a > b ? a - b : b - a;
        return uint256(diff * 10000 / (a > b ? a : b));
    }
}

Common Mistakes

Mistake: Checking Only updatedAt > 0

// INSUFFICIENT: only checks round completed, not how old the price is
require(updatedAt > 0, "Round not complete");
// Missing: require(block.timestamp - updatedAt <= MAX_PRICE_AGE, "Stale");

Both checks are necessary. A completed round from 48 hours ago has updatedAt > 0 but is dangerously stale.

Mistake: Using a Single Large Threshold for All Feeds

// WRONG: a 24-hour threshold applied to a 1-hour heartbeat feed is too lenient
uint256 public constant MAX_PRICE_AGE = 86400; // Never use this for 1h heartbeat feeds

A threshold significantly larger than the feed’s heartbeat defeats the purpose. Set MAX_PRICE_AGE per-feed at deployment time or via a governance-controlled parameter.

Mistake: Checking latestAnswer() Instead of latestRoundData()

// WRONG: latestAnswer() is deprecated and provides no timestamp
int256 price = priceFeed.latestAnswer();
// No timestamp is available — cannot perform staleness check

Always use latestRoundData() which returns the updatedAt timestamp. The deprecated latestAnswer() function provides no mechanism to check data freshness.

Mistake: Applying the Staleness Check After Use

// WRONG: price is used before being validated
uint256 collateralValue = collateral * uint256(price) / 1e8;

// Validation after use is too late
require(block.timestamp - updatedAt <= MAX_PRICE_AGE, "Stale");

All validations must happen before the price is used in any calculation.