Oracle Failure Remediation
How to harden contracts against oracle failure modes — zero prices, extreme values, and feed unavailability — using input validation, bounds checking, fallback oracles, and circuit breakers.
Oracle Failure Remediation
Overview
Oracle failures are distinct from price manipulation: the feed is technically responsive but returns a pathological value — zero from a decimals mismatch or uninitialized aggregator, an astronomically large number from integer overflow, or stale data from a deprecated feed. Contracts that apply no validation to oracle return values are vulnerable to all three failure modes simultaneously.
The Terra/UST depeg (May 2022) illustrated how oracle failures cascade: the on-chain price oracle continued to report UST at $1.00 while market price collapsed to $0.10, enabling massive arbitrage that accelerated the death spiral. Protocols that accepted the oracle’s value without comparing it against plausible bounds had no way to detect the divergence or halt operations. Zero-price acceptance is equally dangerous: a lending contract computing collateralValue = balance * price / 1e18 returns zero when price is zero, which can either trigger mass spurious liquidations or allow unlimited borrowing against worthless collateral, depending on which direction the logic breaks.
Related Detector: Oracle Manipulation Detector
Recommended Fix
Before (Vulnerable)
interface AggregatorV3Interface {
function latestRoundData() external view returns (
uint80 roundId, int256 answer, uint256 startedAt,
uint256 updatedAt, uint80 answeredInRound
);
}
contract VulnerableLending {
AggregatorV3Interface public priceFeed;
function borrow(uint256 amount) external {
// VULNERABLE: no validation of any Chainlink return fields
(, int256 price,,,) = priceFeed.latestRoundData();
// price can be 0 (uninitialised feed), negative (bug), or
// type(uint256).max (overflow in intermediary contract)
uint256 collateralValue = collateral[msg.sender] * uint256(price) / 1e8;
require(collateralValue >= amount, "Undercollateralized");
_transfer(msg.sender, amount);
}
}
After (Fixed)
interface AggregatorV3Interface {
function latestRoundData() external view returns (
uint80 roundId, int256 answer, uint256 startedAt,
uint256 updatedAt, uint80 answeredInRound
);
}
interface ITwapOracle {
function consult(address token, uint32 secondsAgo) external view returns (uint256);
}
contract SafeLending {
AggregatorV3Interface public priceFeed;
ITwapOracle public fallbackFeed;
address public immutable token;
// Calibrate these to the specific asset's realistic price range
uint256 public constant MIN_PRICE = 1e6; // $0.01 minimum (8 decimals)
uint256 public constant MAX_PRICE = 1e15; // $10,000,000 maximum
uint256 public constant MAX_PRICE_AGE = 3600; // 1-hour staleness window
function borrow(uint256 amount) external {
uint256 price = _getSafePrice();
uint256 collateralValue = collateral[msg.sender] * price / 1e8;
require(collateralValue >= amount, "Undercollateralized");
_transfer(msg.sender, amount);
}
function _getSafePrice() internal view returns (uint256) {
// Try primary feed with full validation
try priceFeed.latestRoundData() returns (
uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound
) {
if (
answer > 0
&& uint256(answer) >= MIN_PRICE
&& uint256(answer) <= MAX_PRICE
&& updatedAt >= block.timestamp - MAX_PRICE_AGE
&& answeredInRound >= roundId // Round completed
) {
return uint256(answer);
}
} catch {}
// Fallback: 30-minute TWAP
uint256 fallback = fallbackFeed.consult(token, 1800);
require(fallback >= MIN_PRICE && fallback <= MAX_PRICE, "Fallback price invalid");
return fallback;
}
}
Alternative Mitigations
Explicit validation of every Chainlink latestRoundData field — each field guards a distinct failure mode:
function _validateChainlinkPrice(
uint80 roundId,
int256 price,
uint256 updatedAt,
uint80 answeredInRound
) internal view returns (uint256) {
// 1. Non-zero, positive value
require(price > 0, "Price is zero or negative");
// 2. Plausible range for the asset (set per-token at deploy time)
require(uint256(price) >= minAcceptablePrice, "Price below minimum");
require(uint256(price) <= maxAcceptablePrice, "Price above maximum");
// 3. Freshness — heartbeat varies by feed (ETH/USD is 1 hour)
require(
block.timestamp <= updatedAt + MAX_PRICE_AGE,
"Price feed is stale"
);
// 4. Round completeness — answeredInRound < roundId signals an incomplete round
require(answeredInRound >= roundId, "Incomplete round data");
return uint256(price);
}
Circuit breaker that activates automatically on repeated oracle failures — prevents cascading damage if a feed degrades gradually:
contract CircuitBreakerOracle {
uint256 public consecutiveFailures;
uint256 public constant MAX_FAILURES = 3;
bool public paused;
function getPrice() external returns (uint256) {
try primaryFeed.latestRoundData() returns (
uint80 roundId, int256 price, , uint256 updatedAt, uint80 answeredInRound
) {
if (price > 0 && updatedAt >= block.timestamp - MAX_PRICE_AGE
&& answeredInRound >= roundId) {
consecutiveFailures = 0; // Reset counter on success
return uint256(price);
}
} catch {}
consecutiveFailures++;
if (consecutiveFailures >= MAX_FAILURES) {
paused = true;
emit ProtocolPaused("Oracle failure threshold reached");
}
revert("Oracle unavailable");
}
}
Feed health check as a modifier — enforce oracle health before any price-dependent state change:
modifier withHealthyOracle() {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(price > 0, "Oracle returned non-positive price");
require(block.timestamp - updatedAt <= MAX_PRICE_AGE, "Oracle stale");
_;
}
function liquidate(address user) external withHealthyOracle {
// Liquidation is blocked if the oracle is unhealthy,
// preventing spurious or predatory liquidations on bad data.
}
Common Mistakes
Casting int256 to uint256 without checking sign — int256 can be negative. A negative price cast to uint256 wraps to a very large number, which can allow an attacker to borrow against essentially infinite collateral. Always check price > 0 before casting.
Using latestAnswer() instead of latestRoundData() — latestAnswer() returns only the price with no metadata. It cannot be checked for staleness, round completeness, or aggregator health. Always use latestRoundData() and validate all returned fields.
Setting MAX_PRICE_AGE too long — a 24-hour staleness window for ETH/USD means a 23-hour-old price is accepted as valid. Use the shortest window compatible with the feed’s documented heartbeat (ETH/USD Mainnet: 1 hour, ETH/USD Arbitrum: 1 hour, LINK/USD: 1 hour — check the feed documentation for each asset).
Forgetting to validate the fallback oracle too — protocols that validate the primary feed rigorously but accept any value from the fallback introduce a secondary attack surface. Apply the same bounds checks to every price source.