Stale Oracle Data Remediation
Overview
Stale oracle vulnerabilities arise when a contract reads a off-chain aggregated price feed using latestRoundData() but ignores the staleness fields returned alongside the price. When a off-chain aggregated 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 L2 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 "./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 "./interfaces/AggregatorV3Interface.sol";
contract SafePricing {
AggregatorV3Interface public priceFeed;
// Heartbeat per feed: ETH/USD = 1 hour, less liquid pairs = 24 hours
// Set this to the operator's published 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 major optimistic rollups, a sequencer controls transaction ordering. When the sequencer is offline, off-chain aggregated feeds stop updating. Always check the sequencer uptime feed before reading any price on an L2:
AggregatorV3Interface public sequencerFeed;
// Sequencer uptime feeds on major L2 networks (addresses documented by the
// feed operator; consult their L2 sequencer uptime docs for the exact
// address per network).
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 an off-chain aggregated feed with an on-chain concentrated-liquidity AMM 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 feedPrice = getFeedPrice();
uint256 twapPrice = getTwapPrice();
uint256 diff = feedPrice > twapPrice
? feedPrice - twapPrice
: twapPrice - feedPrice;
uint256 maxDiff = feedPrice * MAX_DEVIATION_BPS / 10000;
require(diff <= maxDiff, "Oracle sources diverge: possible manipulation");
return feedPrice; // 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 off-chain-feed 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 off-chain aggregated 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 optimistic rollups.
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
- SWC-116: Block Values as a Proxy for Time
- Venus Protocol oracle manipulation post-mortem ($100M+ losses)