Chainlink Stale Price Feed
Detects contracts that consume Chainlink oracle data without verifying that the price is recent, allowing protocols to operate on outdated prices during network disruptions.
Chainlink Stale Price Feed
Overview
Remediation Guide: How to Fix Chainlink Stale Price Feed
The Chainlink staleness detector identifies contracts that call latestRoundData() on a Chainlink price feed aggregator without validating that the returned timestamp is recent. Chainlink 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 Chainlink 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, Chainlink 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.
Chainlink’s documentation explicitly 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 "@chainlink/contracts/src/v0.8/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 "@chainlink/contracts/src/v0.8/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 "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract SecureCollateralVault {
AggregatorV3Interface public immutable ethPriceFeed;
mapping(address => uint256) public collateral;
// ETH/USD Chainlink 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 Chainlink 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 Chainlink staleness issues through the following steps:
- Oracle call identification: Identifies calls matching the
latestRoundData()selector (4-byte:0xfeaf968c) orlatestAnswer()(deprecated, always flagged). - Return value tracking: Tracks the data-flow of the five return values —
roundId,answer,startedAt,updatedAt,answeredInRound— to determine which are used. - Staleness check detection: Checks whether
updatedAtis subtracted fromblock.timestamp(or equivalent) and compared against a constant threshold on any execution path beforeansweris used in arithmetic. - Answer validity checks: Checks whether
answer > 0andupdatedAt > 0are verified. - Round freshness: Checks whether
answeredInRound >= roundIdcomparison 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 oflatestRoundData()are always flagged sincelatestAnswer()provides no timestamp at all.
Related Detectors
- Oracle Manipulation — broader oracle manipulation patterns beyond Chainlink
- Flash Loan — stale prices combined with flash loans amplify the attack
- Timestamp Dependence — related to time-based assumptions in contracts