Stale Oracle Data Remediation
How to eliminate stale Chainlink oracle vulnerabilities by validating all latestRoundData return fields, enforcing heartbeat-based freshness thresholds, and adding L2 sequencer uptime checks.
Stale Oracle Data Remediation
Overview
Stale oracle vulnerabilities arise when a contract reads a Chainlink price feed using latestRoundData() but ignores the staleness fields returned alongside the price. When a Chainlink feed stops updating — due to an L2 sequencer outage, a price deviation below the heartbeat threshold, or a network disruption — the last reported price remains on-chain indefinitely. A protocol that accepts this stale price continues to make financial decisions (lending, liquidation, collateral valuation) using data that may be minutes, hours, or days old.
The Venus Protocol suffered over $100M in losses when stale oracle prices were accepted during a price feed disruption. Multiple L2 protocols have lost funds by failing to check the Chainlink sequencer uptime feed before reading prices during sequencer downtime.
The fix is to validate all four meaningful return values from latestRoundData() and, on L2 networks, to additionally check the sequencer uptime feed before any price read.
Related Detector: Oracle Manipulation Detector
Recommended Fix
Before (Vulnerable)
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract VulnerablePricing {
AggregatorV3Interface public priceFeed;
function getPrice() public view returns (uint256) {
// VULNERABLE: ignores updatedAt, answeredInRound, and roundId
(, int256 price,,,) = priceFeed.latestRoundData();
require(price > 0, "Invalid price");
return uint256(price);
}
function getCollateralValue(address user) external view returns (uint256) {
uint256 price = getPrice(); // Could be 24+ hours old
return collateral[user] * price / 1e8;
}
}
After (Fixed)
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract SafePricing {
AggregatorV3Interface public priceFeed;
// Heartbeat per feed: ETH/USD = 1 hour, less liquid pairs = 24 hours
// Set this to the official Chainlink heartbeat for your specific feed
uint256 public constant MAX_STALENESS = 1 hours;
function getPrice() public view returns (uint256) {
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// 1. Price must be positive (negative or zero means feed error)
require(price > 0, "Invalid price: zero or negative");
// 2. Data must be fresh (within heartbeat window)
require(
block.timestamp - updatedAt <= MAX_STALENESS,
"Stale price: exceeds heartbeat"
);
// 3. Round must be complete (answeredInRound < roundId means incomplete)
require(answeredInRound >= roundId, "Incomplete round");
return uint256(price);
}
}
Alternative Mitigations
L2 sequencer uptime check — on Arbitrum, Optimism, and Base, the sequencer controls transaction ordering. When the sequencer is offline, Chainlink feeds stop updating. Always check the sequencer uptime feed before reading any price on an L2:
AggregatorV3Interface public sequencerFeed;
// Arbitrum: 0xFdB631F5EE196F0ed6FAa767959853A9F217697D
// Optimism: 0x371EAD81c9102C9BF4874A9075FFFf170F2Ee389
// Base: 0xBCF85224fc0756B9Fa45aA7892530B47e10b6433
uint256 public constant GRACE_PERIOD = 1 hours;
function checkSequencer() internal view {
(, int256 answer, uint256 startedAt,,) = sequencerFeed.latestRoundData();
// answer == 0: sequencer online; answer == 1: sequencer offline
require(answer == 0, "Sequencer offline");
// Even after restart, prices may still be stale — wait for grace period
require(
block.timestamp - startedAt > GRACE_PERIOD,
"Sequencer recently restarted"
);
}
function getPrice() public view returns (uint256) {
checkSequencer(); // Must pass before reading any price on L2
// ... standard latestRoundData validation
}
Dual-oracle cross-validation — for high-value operations, combine a Chainlink feed with a Uniswap V3 TWAP. Reject the price if the two sources diverge by more than a configured threshold:
uint256 public constant MAX_DEVIATION_BPS = 200; // 2%
function getValidatedPrice() external view returns (uint256) {
uint256 chainlinkPrice = getChainlinkPrice();
uint256 twapPrice = getTwapPrice();
uint256 diff = chainlinkPrice > twapPrice
? chainlinkPrice - twapPrice
: twapPrice - chainlinkPrice;
uint256 maxDiff = chainlinkPrice * MAX_DEVIATION_BPS / 10000;
require(diff <= maxDiff, "Oracle sources diverge: possible manipulation");
return chainlinkPrice; // Use the more reliable source as canonical
}
Circuit breaker on staleness — rather than reverting all operations, some protocols prefer to pause new borrows and liquidations while allowing withdrawals during a staleness period:
bool public oraclePaused;
modifier whenOracleHealthy() {
require(!oraclePaused, "Oracle paused: protocol in safe mode");
_;
}
function checkAndUpdateOracleHealth() external {
try this.getPrice() returns (uint256) {
oraclePaused = false;
} catch {
oraclePaused = true;
emit OraclePaused(block.timestamp);
}
}
Common Mistakes
Using latestAnswer() instead of latestRoundData() — latestAnswer() is a deprecated Chainlink function that returns only the price with no metadata. It provides no way to check staleness or round completeness. Always use latestRoundData().
Heartbeat mismatch — different Chainlink feeds have different heartbeats. ETH/USD on mainnet updates every hour or on 0.5% deviation. Less liquid pairs may only update every 24 hours. Setting MAX_STALENESS = 1 hours for a 24-hour heartbeat feed will cause constant reverts. Verify the heartbeat for every feed you integrate at data.chain.link.
Skipping the sequencer check on L2 deployments — many teams copy mainnet oracle code to L2 deployments without adding the sequencer uptime feed. This is a critical omission on Arbitrum, Optimism, and Base.
Not handling the grace period — even after the sequencer comes back online, there is a window where oracle prices are still propagating. Enforce a grace period of at least one hour after sequencer restart before accepting prices.
References
- Chainlink: latestRoundData API Reference
- Chainlink: L2 Sequencer Uptime Feeds
- Chainlink: Data Feed Heartbeats
- SWC-116: Block Values as a Proxy for Time
- Venus Protocol Oracle Manipulation Post-Mortem ($100M+ losses)