Remediating Chainlink Stale Price Feed
How to validate Chainlink oracle data freshness by checking the updatedAt timestamp, answer validity, and round completeness before consuming price data.
Remediating Chainlink Stale Price Feed
Overview
Related Detector: Chainlink Stale Price Feed
Chainlink 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.
Recommended Fix
Before (Vulnerable)
import "@chainlink/contracts/src/v0.8/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 "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract SecureLending {
AggregatorV3Interface public immutable priceFeed;
// Set this per-feed based on the heartbeat documented at data.chain.link
// ETH/USD mainnet heartbeat is 3600s; use slightly more to account for 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, "Chainlink 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. Chainlink publishes heartbeat intervals at data.chain.link. Common values:
| Feed | Network | Heartbeat |
|---|---|---|
| ETH/USD | Ethereum mainnet | 3600s (1 hour) |
| BTC/USD | Ethereum mainnet | 3600s (1 hour) |
| ETH/USD | Arbitrum, Optimism, Base | 86400s (24 hours) |
| LINK/USD | 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.