Skip to main content
Sigvex

Stale Off-Chain Aggregated Price Feed

Detects contracts that consume off-chain aggregated oracle data without verifying that the price is recent, allowing protocols to operate on outdated prices during network disruptions.

Stale Off-Chain Aggregated Price Feed

Overview

Remediation Guide: How to Fix Stale Off-Chain Aggregated Price Feed

The oracle staleness detector identifies contracts that call latestRoundData() on an off-chain aggregated price-feed aggregator without validating that the returned timestamp is recent. Off-chain aggregated oracles update prices in response to on-chain triggers — either when the price deviates by more than a configured threshold (deviation trigger) or when a heartbeat interval elapses. During network outages, gas price spikes, or oracle node failures, updates may be delayed or halted entirely, causing latestRoundData() to return prices that are hours or days old.

Sigvex detects this pattern by identifying calls to the AggregatorV3Interface.latestRoundData() selector and checking whether the returned updatedAt timestamp is compared against block.timestamp on any execution path before the price is used in a financial calculation. The detector also checks for answeredInRound >= roundId validation, which detects incomplete rounds.

Why This Is an Issue

Financial protocols that accept stale oracle prices can be exploited during oracle outages. An attacker who anticipates or causes an oracle disruption can front-run the outage to take positions based on artificially stale prices, then settle those positions when normal prices resume.

Notable incidents:

  • Venus Protocol (March 2023): During the LUNA collapse, an off-chain aggregator temporarily suspended its LUNA price feed. Venus continued to accept stale prices, enabling exploits worth approximately $11.2M.
  • Multiple DeFi protocols during Ethereum network congestion events have suffered stale price acceptance.

Off-chain aggregator documentation conventionally states that updatedAt should be checked and that prices where updatedAt == 0 or answer <= 0 should be rejected.

How to Resolve

// Before: Vulnerable — no staleness check
import "@audited/interfaces/AggregatorV3Interface.sol";

contract VulnerableLending {
    AggregatorV3Interface public priceFeed;

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

// After: Validate price freshness before use
contract SecureLending {
    AggregatorV3Interface public priceFeed;
    uint256 public constant MAX_PRICE_AGE = 3600; // 1 hour — adjust per feed heartbeat

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

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

        return price;
    }
}

Examples

Vulnerable Code

import "@audited/interfaces/AggregatorV3Interface.sol";

contract VulnerableCollateralVault {
    AggregatorV3Interface public immutable ethPriceFeed;
    mapping(address => uint256) public collateral; // in ETH

    constructor(address _priceFeed) {
        ethPriceFeed = AggregatorV3Interface(_priceFeed);
    }

    // Calculates collateral value without checking price freshness
    function getCollateralValue(address user) public view returns (uint256) {
        (, int256 ethPrice, , ,) = ethPriceFeed.latestRoundData();
        // VULNERABLE: stale price accepted — during an oracle outage this
        // returns the last known price, which could be wildly inaccurate
        return (collateral[user] * uint256(ethPrice)) / 1e8;
    }

    function borrow(uint256 amount) external {
        uint256 collateralValue = getCollateralValue(msg.sender);
        require(collateralValue >= amount * 2, "Insufficient collateral");
        // Loan issued based on potentially stale collateral value
        _issueLoan(msg.sender, amount);
    }
}

Fixed Code

import "@audited/interfaces/AggregatorV3Interface.sol";

contract SecureCollateralVault {
    AggregatorV3Interface public immutable ethPriceFeed;
    mapping(address => uint256) public collateral;

    // ETH/USD aggregator heartbeat is 1 hour on mainnet, use a slightly
    // larger window to account for minor delays
    uint256 public constant PRICE_FRESHNESS_THRESHOLD = 3900; // 65 minutes

    constructor(address _priceFeed) {
        ethPriceFeed = AggregatorV3Interface(_priceFeed);
    }

    function getCollateralValue(address user) public view returns (uint256) {
        (
            uint80 roundId,
            int256 ethPrice,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = ethPriceFeed.latestRoundData();

        // Validate the price is usable
        require(ethPrice > 0, "Invalid ETH price");
        require(updatedAt != 0, "Round is not complete");
        require(
            block.timestamp - updatedAt <= PRICE_FRESHNESS_THRESHOLD,
            "ETH price is stale"
        );
        require(
            answeredInRound >= roundId,
            "Stale aggregator round"
        );

        return (collateral[user] * uint256(ethPrice)) / 1e8;
    }

    function borrow(uint256 amount) external {
        uint256 collateralValue = getCollateralValue(msg.sender);
        require(collateralValue >= amount * 2, "Insufficient collateral");
        _issueLoan(msg.sender, amount);
    }
}

Sample Sigvex Output

{
  "detector_id": "chainlink-staleness",
  "severity": "high",
  "confidence": 0.88,
  "description": "Function getCollateralValue() calls latestRoundData() and uses the returned price without comparing updatedAt against block.timestamp. An oracle outage could cause this protocol to accept prices that are hours or days old.",
  "location": { "function": "getCollateralValue(address)", "offset": 64 }
}

Detection Methodology

Sigvex detects oracle staleness issues through the following steps:

  1. Oracle call identification: Identifies calls matching the latestRoundData() selector (4-byte: 0xfeaf968c) or latestAnswer() (deprecated, always flagged).
  2. Return value tracking: Tracks the data-flow of the five return values — roundId, answer, startedAt, updatedAt, answeredInRound — to determine which are used.
  3. Staleness check detection: Checks whether updatedAt is subtracted from block.timestamp (or equivalent) and compared against a constant threshold on any execution path before answer is used in arithmetic.
  4. Answer validity checks: Checks whether answer > 0 and updatedAt > 0 are verified.
  5. Round freshness: Checks whether answeredInRound >= roundId comparison is present.

High confidence when none of the staleness checks are present. Medium confidence when some but not all checks are present.

Limitations

False positives:

  • Contracts that implement a circuit breaker that pauses operations when the oracle timestamp is stale (checked via a separate function or modifier) may appear to use stale prices if the circuit breaker logic is not on the same execution path.
  • Contracts that use the price for informational display (not financial calculations) may be flagged.

False negatives:

  • Staleness checks that delegate to a wrapper contract (e.g., a price oracle aggregator that performs its own freshness check) are not detected without cross-contract analysis.
  • Contracts using latestAnswer() (deprecated) instead of latestRoundData() are always flagged since latestAnswer() provides no timestamp at all.