Oracle integration testing: fuzzing price feeds and manipulation resistance
Oracle Integration Testing: Fuzzing Price Feeds and Manipulation Resistance
By nican0r
Oracles are the bridge between on-chain and off-chain reality. When that bridge lies, protocols lose millions. We saw it with the Mango Markets exploit ($114M), with Cream Finance ($130M), and dozens of smaller incidents. The pattern is always the same: a protocol trusted a price feed without verifying it was telling the truth.
You can't unit-test your way out of oracle bugs. The failure modes are combinatorial — stale prices interacting with high volatility, flash loan manipulation combined with specific pool states, multi-oracle disagreement during network congestion. Fuzzing with mock oracles is the only way to cover this space.
The core oracle properties
Every protocol that reads a price feed needs these properties, no exceptions:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
contract OracleInvariantTest is Test {
LendingProtocol protocol;
OracleHandler handler;
MockChainlinkOracle mockOracle;
function setUp() public {
mockOracle = new MockChainlinkOracle();
protocol = new LendingProtocol(address(mockOracle));
handler = new OracleHandler(protocol, mockOracle);
targetContract(address(handler));
}
/// @notice Protocol must reject stale price data
function invariant_staleness_check() public view {
(, , , uint256 updatedAt, ) = mockOracle.latestRoundData();
uint256 staleness = block.timestamp - updatedAt;
if (staleness > protocol.maxStaleness()) {
// Any operation that reads price should have reverted
// We track this via ghost variable in handler
assertEq(
handler.operationsWithStalePrice(),
0,
"Protocol accepted stale price"
);
}
}
/// @notice Protocol must reject zero or negative prices
function invariant_price_sanity() public view {
(, int256 price, , , ) = mockOracle.latestRoundData();
if (price <= 0) {
assertEq(
handler.operationsWithInvalidPrice(),
0,
"Protocol accepted zero/negative price"
);
}
}
}
These look simple. That's the point. The number of production protocols that skip staleness checks is staggering. The Chainlink documentation explicitly warns about this, and developers still miss it.
Price deviation bounds
Prices don't jump 50% in a single block under normal conditions. If your oracle reports a massive deviation from the last known price, something's probably wrong — either the oracle is being manipulated or there's a data feed issue.
/// @notice Price changes must be within acceptable deviation
function invariant_price_deviation_bounded() public view {
int256 currentPrice = handler.lastAcceptedPrice();
int256 previousPrice = handler.previousAcceptedPrice();
if (previousPrice == 0 || currentPrice == 0) return;
uint256 deviation;
if (currentPrice > previousPrice) {
deviation = uint256(currentPrice - previousPrice) * 10000
/ uint256(previousPrice);
} else {
deviation = uint256(previousPrice - currentPrice) * 10000
/ uint256(previousPrice);
}
// Max 20% deviation per heartbeat (configurable per asset)
assertLe(
deviation,
2000, // 20% in basis points
"Price deviated beyond safe threshold"
);
}
The deviation threshold depends on the asset. ETH/USD might allow 15% between heartbeats. A volatile small-cap token might need 50%. Stablecoins should be 5% at most. Match your bounds to the asset's actual volatility profile.
TWAP vs spot price comparison
Time-weighted average prices resist single-block manipulation. If your protocol uses a spot price, cross-reference it against a TWAP to detect manipulation attempts.
/// @notice Spot price must not deviate too far from TWAP
function invariant_spot_vs_twap_consistency() public view {
uint256 spotPrice = protocol.getSpotPrice(address(tokenA));
uint256 twapPrice = protocol.getTWAP(address(tokenA), 30 minutes);
if (spotPrice == 0 || twapPrice == 0) return;
uint256 deviation;
if (spotPrice > twapPrice) {
deviation = ((spotPrice - twapPrice) * 10000) / twapPrice;
} else {
deviation = ((twapPrice - spotPrice) * 10000) / twapPrice;
}
// If deviation > 10%, the protocol should have flagged it
if (deviation > 1000) {
assertTrue(
protocol.priceManipulationDetected(),
"Large spot/TWAP deviation not detected"
);
}
}
This is how you catch flash loan attacks that manipulate AMM spot prices. The attacker can move the spot price in one block, but they can't move the TWAP (at least not without sustained capital allocation over many blocks).
Circuit breaker properties
Circuit breakers pause operations when price feeds behave abnormally. They're your protocol's kill switch. Properties need to verify they actually trigger:
/// @notice Circuit breaker must activate on extreme price movement
function invariant_circuit_breaker_triggers() public view {
if (handler.extremePriceEventOccurred()) {
assertTrue(
protocol.circuitBreakerActive(),
"Circuit breaker didn't trigger on extreme price"
);
}
}
/// @notice No borrows or liquidations while circuit breaker is active
function invariant_circuit_breaker_halts_operations() public view {
if (protocol.circuitBreakerActive()) {
assertEq(
handler.operationsDuringCircuitBreaker(),
0,
"Operations executed during circuit breaker"
);
}
}
/// @notice Circuit breaker must be resettable after conditions normalize
function invariant_circuit_breaker_recovery() public view {
if (handler.priceNormalizedAfterBreaker()) {
assertFalse(
protocol.circuitBreakerActive(),
"Circuit breaker stuck active after normalization"
);
}
}
I've seen circuit breakers that trigger correctly but never reset. The protocol is paused forever. That last property catches this scenario. Once prices return to normal, the breaker should release.
Fallback oracle consistency
Production deployments should never depend on a single oracle. When Chainlink goes down (it's happened), you need a fallback. But fallbacks introduce their own bugs:
/// @notice Primary and fallback oracle must agree within tolerance
function invariant_multi_oracle_consistency() public view {
if (!handler.bothOraclesActive()) return;
int256 primaryPrice = handler.primaryOraclePrice();
int256 fallbackPrice = handler.fallbackOraclePrice();
if (primaryPrice <= 0 || fallbackPrice <= 0) return;
uint256 deviation;
if (primaryPrice > fallbackPrice) {
deviation = uint256(primaryPrice - fallbackPrice) * 10000
/ uint256(fallbackPrice);
} else {
deviation = uint256(fallbackPrice - primaryPrice) * 10000
/ uint256(primaryPrice);
}
// If oracles disagree by more than 5%, protocol should pause
if (deviation > 500) {
assertTrue(
protocol.oracleDisagreementDetected(),
"Oracle disagreement not detected"
);
}
}
/// @notice Fallback must activate when primary fails
function invariant_fallback_activates() public view {
if (handler.primaryOracleFailed()) {
assertTrue(
protocol.usingFallbackOracle(),
"Primary failed but fallback not active"
);
}
}
Manipulation resistance: flash loan + oracle
This is the big one. Oracle manipulation via flash loans is the most common DeFi exploit vector. Your fuzzer needs to simulate this attack pattern:
contract OracleHandler is CommonBase, StdCheats, StdUtils {
/// @notice Simulate flash loan price manipulation
function manipulatePrice(
uint256 manipulationAmount,
uint256 actionSeed
) external {
manipulationAmount = bound(
manipulationAmount,
1e18,
1e26
);
// Record price before manipulation
int256 priceBefore = _getCurrentPrice();
// Simulate large swap that moves AMM price
mockOracle.setPrice(
priceBefore * int256(manipulationAmount) / 1e18
);
// Try to exploit the protocol at manipulated price
uint8 action = uint8(bound(actionSeed, 0, 2));
if (action == 0) _tryBorrow();
else if (action == 1) _tryLiquidate();
else _tryWithdraw();
// Price returns to normal (flash loan repaid)
mockOracle.setPrice(priceBefore);
// Track whether any action succeeded at manipulated price
_recordManipulationResult();
}
/// @notice Normal price update within realistic bounds
function updatePrice(uint256 newPrice) external {
int256 current = _getCurrentPrice();
// Bound to ±20% of current price
int256 lower = current * 80 / 100;
int256 upper = current * 120 / 100;
int256 bounded = int256(
bound(uint256(newPrice), uint256(lower), uint256(upper))
);
mockOracle.setPrice(bounded);
mockOracle.setUpdatedAt(block.timestamp);
}
/// @notice Advance time to make price stale
function advanceTime(uint256 seconds_) external {
seconds_ = bound(seconds_, 1, 2 days);
vm.warp(block.timestamp + seconds_);
}
}
The manipulatePrice action is where the real testing happens. It simulates an attacker who pushes the price to an extreme value, performs some protocol action, then lets the price snap back. Any protocol action that succeeds at the manipulated price is a potential exploit.
/// @notice No profit from flash manipulation
function invariant_flash_manipulation_unprofitable() public view {
for (uint256 i = 0; i < handler.manipulationAttempts(); i++) {
ManipulationResult memory result = handler.getResult(i);
if (result.actionSucceeded) {
assertLe(
result.attackerProfitBps,
0,
"Flash manipulation was profitable"
);
}
}
}
Chainlink-specific properties
If you're using Chainlink, these additional properties matter:
/// @notice roundId must increase monotonically
function invariant_round_id_monotonic() public view {
(uint80 currentRound, , , , ) = mockOracle.latestRoundData();
assertGe(
currentRound,
handler.previousRoundId(),
"Round ID decreased"
);
}
/// @notice answeredInRound must be >= roundId (not stale round)
function invariant_answered_in_round() public view {
(
uint80 roundId,
,
,
,
uint80 answeredInRound
) = mockOracle.latestRoundData();
assertGe(
answeredInRound,
roundId,
"Answer is from a previous round"
);
}
/// @notice Price must have correct number of decimals
function invariant_decimals_consistency() public view {
uint8 decimals = mockOracle.decimals();
(, int256 price, , , ) = mockOracle.latestRoundData();
// Price should be reasonable for the decimal count
// e.g., ETH at $2000 with 8 decimals = 200000000000
if (decimals == 8) {
assertGt(price, 1e6, "Price too low for 8 decimals");
assertLt(price, 1e14, "Price too high for 8 decimals");
}
}
Pyth and custom TWAP patterns
Pyth oracles work differently. They use a pull model where the user submits the price update. This changes your property suite:
/// @notice Pyth price confidence interval must be within bounds
function invariant_pyth_confidence_bounded() public view {
PythStructs.Price memory price = pyth.getPrice(priceFeedId);
// Confidence should be less than 5% of price
uint256 confPercent = (uint256(uint64(price.conf)) * 10000)
/ uint256(uint64(price.price > 0 ? price.price : -price.price));
assertLe(
confPercent,
500,
"Pyth confidence interval too wide, price unreliable"
);
}
/// @notice Custom TWAP must use sufficient observation window
function invariant_twap_window_sufficient() public view {
uint256 twapWindow = protocol.twapObservationWindow();
assertGe(
twapWindow,
15 minutes,
"TWAP window too short, vulnerable to manipulation"
);
}
For custom TWAP implementations (like Uniswap V3's oracle), your biggest risk is short observation windows. A 1-block TWAP is basically a spot price. You need at least 15-30 minutes to resist sustained manipulation.
Multi-oracle aggregation
Some protocols aggregate multiple oracles. The aggregation logic itself can have bugs:
/// @notice Median of 3 oracles must equal actual median
function invariant_median_correctness() public view {
int256 price1 = oracle1.price();
int256 price2 = oracle2.price();
int256 price3 = oracle3.price();
int256 expectedMedian = _calculateMedian(price1, price2, price3);
int256 protocolMedian = protocol.getAggregatedPrice();
assertEq(
protocolMedian,
expectedMedian,
"Aggregated price != actual median"
);
}
function _calculateMedian(
int256 a,
int256 b,
int256 c
) internal pure returns (int256) {
if (a <= b && b <= c) return b;
if (a <= c && c <= b) return c;
if (b <= a && a <= c) return a;
if (b <= c && c <= a) return c;
if (c <= a && a <= b) return a;
return b;
}
Building mock oracles for fuzzing
Your mock oracle needs to support both normal updates and adversarial manipulation:
contract MockChainlinkOracle {
int256 private _price;
uint256 private _updatedAt;
uint80 private _roundId;
uint8 private _decimals;
function setPrice(int256 price) external {
_price = price;
}
function setUpdatedAt(uint256 timestamp) external {
_updatedAt = timestamp;
}
function incrementRound() external {
_roundId++;
_updatedAt = block.timestamp;
}
function latestRoundData() external view returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) {
return (
_roundId,
_price,
_updatedAt,
_updatedAt,
_roundId
);
}
function decimals() external view returns (uint8) {
return _decimals;
}
}
The handler controls this mock. Normal actions update the price within reasonable bounds. Attack actions set extreme prices. The invariants verify the protocol handles both correctly.
Where to start
For oracle integration testing, your priority order should be:
- Staleness rejection: most common oracle bug in production
- Zero/negative price rejection: second most common
- Flash manipulation resistance: highest dollar-amount exploits
- TWAP/spot consistency: catches manipulation the staleness check misses
- Fallback activation: for production resilience
If you're new to invariant testing, start with how to write your first invariant test. For the broader context of why this matters, why invariant testing matters for DeFi security lays it out.
Need oracle integration testing for your protocol? Request an Audit or Try Recon Pro to generate oracle properties automatically.
Related Posts
Price manipulation attacks: spot prices, TWAPs, and how to fuzz your defenses
Flash loan price manipulation, TWAP gaming, oracle sandwiches — here's how each attack works and the...
Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed — And Invariants Didn't
The dev team ran Echidna for 24 hours: zero findings. The same vulnerability was found by invariant ...