Oracle vulnerabilities are not edge cases. Oracle manipulation accounted for over $400 million in DeFi losses during 2024–2025, and the Loopscale exploit ($5.8 million, April 2025) proved that even sophisticated protocols remain vulnerable. In 2024 alone, price manipulation attacks accounted for over $52 million in losses across 37 incidents, making them the second most damaging attack vector. The mechanics differ by oracle type, but the underlying pattern is consistent: a contract trusted a number it should not have.
This article dissects every major oracle attack surface — spot price manipulation via flash loans, TWAP window attacks, the six most common Chainlink integration bugs, multi-oracle aggregation, circuit breakers, and the conceptual boundary between price manipulation and latency arbitrage. Each section includes exploitable Solidity patterns and their hardened counterparts.
1. Spot Price Oracles and Flash Loan Manipulation
The simplest oracle is a ratio. In a constant-product AMM, the spot price of token A in terms of token B is reserveB / reserveA. Any contract that reads this ratio as a price feed is immediately exploitable.
The naive use of an AMM pool as a price oracle is essentially a centralized oracle that is vulnerable to manipulation, even if the AMM itself is decentralized. The attacker does not need to own large capital to execute the attack — they borrow it. Flash loans allow attackers to borrow large amounts of capital without collateral, provided the loan is repaid within the same transaction block, and as Chainlink’s explainer emphasizes, flash loans do not create the vulnerability — they provide the scale needed to exploit one that already exists.
The full sequence is mechanical:
The attacker uses borrowed capital to buy or sell aggressively in the AMM pool, shifting its reserves and therefore its spot price. In an ordinary market context, this would be a temporary distortion that arbitrageurs quickly trade away. But the attacker does not wait — in the same transaction, while the pool is still distorted, the attacker deposits token B into the lending protocol. The protocol queries the oracle, sees the manipulated spot price, concludes that B is worth far more than it really is, and allows the attacker to borrow too much A.
Consider this vulnerable lending contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IUniswapV2Pair {
function getReserves() external view returns (
uint112 reserve0,
uint112 reserve1,
uint32 blockTimestampLast
);
}
/// @notice VULNERABLE: reads spot price directly from AMM reserves
contract VulnerableLending {
IUniswapV2Pair public immutable pair;
address public immutable tokenA; // the loan asset
address public immutable tokenB; // the collateral asset
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
constructor(address _pair, address _tokenA, address _tokenB) {
pair = IUniswapV2Pair(_pair);
tokenA = _tokenA;
tokenB = _tokenB;
}
/// @notice Returns price of tokenB in terms of tokenA — MANIPULABLE
function getSpotPrice() public view returns (uint256) {
(uint112 r0, uint112 r1, ) = pair.getReserves();
// Assumes token0 == tokenA, token1 == tokenB
// Price = reserveA / reserveB => how many A per unit B
return (uint256(r0) * 1e18) / uint256(r1);
}
/// @notice Borrow tokenA against tokenB collateral
/// Attack: inflate tokenB price via flash loan, borrow excess tokenA
function borrow(uint256 borrowAmount) external {
uint256 price = getSpotPrice(); // <-- reads manipulated state
uint256 collateralValue = (collateral[msg.sender] * price) / 1e18;
require(collateralValue * 100 >= borrowAmount * 150, "Undercollateralized");
debt[msg.sender] += borrowAmount;
// transfer borrowAmount of tokenA to msg.sender
}
}
The swap in a flash loan attack significantly impacts the ratio between the tokens in the pool as it is a relatively large trade on a pool with very shallow liquidity, and consequently affects the relative price returned by the oracle. Protocols using a liquidity pool as their oracle are essentially 99.9% likely to be exploited because of the volatility in prices when leveraging flash loans.
The fix is to never read reserve0 / reserve1 as a canonical price for any purpose other than determining swap output within the same transaction. For off-protocol pricing, use a TWAP or an external oracle. Never use spot prices for collateral valuation, liquidation thresholds, or mint ratios.
2. TWAP Oracles and Their Attack Windows
Time-weighted average prices were introduced specifically to resist single-transaction manipulation. UniswapV2 first introduced the concept of time-weighted average prices (TWAPs), which calculate the average price based on some chosen period of time with the use of the cumulative price. UniswapV3 extended this with the geometric mean TWAP, accumulating the sum of log prices rather than raw prices.
The Cost of a TWAP Attack
To manipulate a geometric mean TWAP requires an attacker to manipulate the spot price on at least one block within the TWAP window. Because TWAP oracles are updated on the first transaction of block i using the last trade price on block i-1, an attacker is required to expose their price manipulation efforts to arbitrageurs for at least one block.
The cost of manipulating the price for a specific time period can be roughly estimated as the amount lost to arbitrage and fees every block for the entire period. For larger liquidity pools and over longer time periods, this attack is impractical, as the cost of manipulation typically exceeds the value at stake.
For the USDC/WETH pool, the numbers are almost incomprehensible: two-block Uniswap v3 TWAP oracle manipulation is prohibitively expensive on top pairs — potential manipulators need to move the price by 71,721,000,000,000% for a single block to change the 30-min TWAP oracle price by 20%. However, for illiquid long-tail tokens, the arithmetic is entirely different.
The Short-Window Trap
For a short time frame setting of TWAP oracle, it is ineffective to filter out the extreme price data points injected by attackers. For a TWAP oracle with a long time frame, the time delay and price deviation error would also be increased because the oracle fails to follow up-to-date prices, while a higher price deviation error creates additional opportunities for arbitrage attacks.
This is the fundamental TWAP tradeoff: tighter windows → more manipulable; wider windows → staler prices. For low-liquidity assets, even a 30-minute TWAP can be moved profitably.
The Observations Array Underflow
There is a second, less-discussed TWAP bug. When calling observe, the oracle specifies a secondsAgo parameter that sets the length of the TWAP. If you call observe with secondsAgo = [3600, 0] you should get a 1-hour TWAP — but only as long as sufficiently many entries of the observations array have been initialized so that the earliest tick cumulative was added at least an hour ago. If the earliest tick cumulative is more recent than an hour ago, you are not actually getting a 1-hour TWAP — you are getting something shorter.
A pool that was just initialized, or a pool whose cardinality was never expanded with increaseObservationCardinalityNext, will silently serve a sub-window TWAP, which is far cheaper to manipulate.
Here is a Uniswap V3 TWAP consumer with explicit cardinality and freshness guards:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "@uniswap/v3-core/contracts/libraries/TickMath.sol";
import "@uniswap/v3-core/contracts/libraries/FullMath.sol";
library TWAPLib {
error TWAPWindowTooShort(uint32 actual, uint32 required);
error InsufficientObservations();
/// @param pool The Uniswap V3 pool
/// @param twapWindow Desired TWAP window in seconds (e.g. 1800 for 30 min)
/// @param minWindow Minimum acceptable realised window (guard against
/// under-initialized cardinality)
function getTWAP(
IUniswapV3Pool pool,
uint32 twapWindow,
uint32 minWindow
) internal view returns (uint256 priceX96) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = twapWindow;
secondsAgos[1] = 0;
// observe() will revert if twapWindow exceeds oldest observation
(int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);
// Verify the realised window is at least minWindow
// (protects against newly-seeded cardinality)
(, , uint16 observationIndex, uint16 observationCardinality, , , ) =
pool.slot0();
// Simple heuristic: if cardinality < 2 the window is untrustworthy
if (observationCardinality < 2) revert InsufficientObservations();
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
int24 arithmeticMeanTick = int24(tickCumulativesDelta / int56(int32(twapWindow)));
// Convert tick to sqrtPriceX96, then to price
uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(arithmeticMeanTick);
priceX96 = FullMath.mulDiv(
uint256(sqrtRatioX96),
uint256(sqrtRatioX96),
1 << 96
);
}
}
Key defensive choices made here:
- Reverts if the pool cannot serve the full
twapWindow(viaobserve’s built-in revert). - Checks
observationCardinality >= 2to detect uninitialized pools. - The caller controls
minWindow— a 30-minute window is reasonable for major pairs; use 60 minutes for illiquid assets and require the deployer to callincreaseObservationCardinalityNextbefore activation.
3. Chainlink Integration Pitfalls
Chainlink’s aggregated, off-chain node network is structurally resistant to single-transaction manipulation. Each Chainlink oracle node pulls data from multiple data aggregators and takes the median value, and the final price consumed by a smart contract represents the median value from numerous independent, security-reviewed node operator responses, preventing any single node from being a point of failure. But the integration layer — how your Solidity code reads and validates that price — introduces its own category of vulnerabilities.
3.1 Staleness Check
Many smart contracts use Chainlink to request off-chain pricing data, but a common error occurs when the smart contract doesn’t check whether that data is stale. Chainlink feeds have two trigger parameters that decide when the answer must be updated: the deviation threshold and the heartbeat. Writing an update to a feed requires at least 11 of 16 data sources to respond. When the quorum is not met, the maximum staleness and deviation of the feed’s latest answer are theoretically unbounded.
In practice this means: if there’s no volatility, nodes don’t submit updates. So latestRoundData() might return a price from 30 minutes ago. When spot markets move during that window — a CEX goes down, true price shifts — your on-chain price is now garbage.
The real-world consequence: the updatedAt timestamp in AuraVault.sol was commented out and not checked for price staleness, which could lead to using stale incorrect prices. This was a live audit finding in a production lending protocol.
Vulnerable:
// @audit no staleness check — classic finding
function getPrice() external view returns (uint256) {
(, int256 answer, , , ) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price");
return uint256(answer);
}
Hardened:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AggregatorV3Interface} from
"@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
library OracleLib {
error StalePrice(address feed, uint256 updatedAt, uint256 maxAge);
error InvalidPrice(address feed, int256 answer);
error RoundNotComplete(address feed);
/// @param feed The Chainlink AggregatorV3Interface address
/// @param maxAge Feed-specific staleness bound in seconds.
/// Set to (heartbeat + buffer), e.g. 3600 + 60 = 3660 for
/// a 1-hour heartbeat feed.
function readFreshPrice(
AggregatorV3Interface feed,
uint256 maxAge
) internal view returns (uint256 price, uint8 decimals) {
(
uint80 roundId,
int256 answer,
,
uint256 updatedAt,
uint80 answeredInRound
) = feed.latestRoundData();
if (answer <= 0) revert InvalidPrice(address(feed), answer);
if (updatedAt == 0) revert RoundNotComplete(address(feed));
if (answeredInRound < roundId)
revert StalePrice(address(feed), updatedAt, maxAge);
if (block.timestamp - updatedAt > maxAge)
revert StalePrice(address(feed), updatedAt, maxAge);
decimals = feed.decimals();
price = uint256(answer);
}
}
Note on
answeredInRound: The Chainlink docs have deprecated this field on newer deployments andupdatedAtis the authoritative freshness signal. The checkansweredInRound >= roundIdis kept here for compatibility with older feed versions, but treatupdatedAtas the primary guard.
3.2 Zero / Negative Price Check
latestRoundData() returns int256 answer, which can technically be negative. Negative asset prices are far-fetched but not impossible: futures markets occasionally see contracts drop below zero when supply/demand shocks hit. More practically, during certain types of oracle failures or feed deprecations, some implementations return zero. Contracts should always check that the data is not stale by checking the timestamp, and they should check that the data is not an anomalous value such as zero or a negative number. In these cases, the contract should resort to a sensible default or pause the application until the feed starts reporting correct data again.
// Pattern: guard both zero AND negative in one check
if (answer <= 0) revert InvalidPrice(address(feed), answer);
Casting a negative int256 to uint256 without this guard produces an astronomically large number, which would allow an attacker to borrow against infinite collateral valuation.
3.3 Decimals Mismatch
When working with Oracle price feeds, developers must account for different price feeds having different decimal precision; it is an error to assume that every price feed will report prices using the same precision. Generally, non-ETH pairs report using 8 decimals, while ETH pairs report using 18 decimals.
A common consequence: USD-denominated feeds have 8 decimals while ETH-denominated feeds have 18 decimals. If two feeds with 8 decimals of precision are combined, results will only have 8 decimals of precision and the rest will be division error.
This is especially dangerous when deriving a derived price by dividing two feeds:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AggregatorV3Interface} from
"@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
/// @notice Derive TOKEN/ETH price from TOKEN/USD and ETH/USD feeds
/// @dev Both feeds are USD-denominated => 8 decimals each.
/// To return a price in 18-decimal WAD format we scale appropriately.
contract DerivedPriceFeed {
AggregatorV3Interface public immutable tokenUsdFeed;
AggregatorV3Interface public immutable ethUsdFeed;
uint256 private constant PRECISION = 1e18;
constructor(address _tokenUsd, address _ethUsd) {
tokenUsdFeed = AggregatorV3Interface(_tokenUsd);
ethUsdFeed = AggregatorV3Interface(_ethUsd);
}
/// @notice Returns TOKEN price in ETH, scaled to 1e18
function getTokenPriceInEth() external view returns (uint256) {
(, int256 tokenUsd, , , ) = tokenUsdFeed.latestRoundData();
(, int256 ethUsd, , , ) = ethUsdFeed.latestRoundData();
require(tokenUsd > 0 && ethUsd > 0, "Bad price");
uint8 tokenDec = tokenUsdFeed.decimals(); // e.g. 8
uint8 ethDec = ethUsdFeed.decimals(); // e.g. 8
// Normalise both to 18 decimals before division
uint256 tokenNorm = uint256(tokenUsd) * (10 ** (18 - tokenDec));
uint256 ethNorm = uint256(ethUsd) * (10 ** (18 - ethDec));
// TOKEN/ETH = TOKEN/USD ÷ ETH/USD, result in 1e18
return (tokenNorm * PRECISION) / ethNorm;
}
}
Always call feed.decimals() dynamically. Never hardcode 8 or 18.
3.4 L2 Sequencer Uptime Feed
Layer-2 deployments introduce a unique oracle failure mode. The state on L2, including oracle updates, may become stale during sequencer downtime. Protocols should not rely on Chainlink feeds until the sequencer is back up and has processed its pending transactions. When fetching Chainlink data on Arbitrum, Optimism, and Metis, check that the sequencer has been up for a minimum amount of time.
When using Chainlink with L2 chains like Arbitrum, smart contracts must check whether the L2 Sequencer is down to avoid stale pricing data that appears fresh. Smart contract auditors should look out for missing L2 sequencer activity checks when they see price code calling latestRoundData() in projects deployed on L2s.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AggregatorV3Interface} from
"@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
contract L2AwarePriceFeed {
AggregatorV3Interface public immutable dataFeed;
AggregatorV3Interface public immutable sequencerUptimeFeed;
/// @dev Require sequencer to have been UP for at least this long
/// before trusting a new price. 3600 seconds (1 hour) is conservative
/// and allows sufficient time for the L2 state to settle.
uint256 public constant GRACE_PERIOD = 3600;
error SequencerDown();
error GracePeriodNotOver(uint256 resumedAt, uint256 gracePeriodEnds);
error StalePrice();
error InvalidPrice();
constructor(address _dataFeed, address _sequencerUptimeFeed) {
dataFeed = AggregatorV3Interface(_dataFeed);
sequencerUptimeFeed = AggregatorV3Interface(_sequencerUptimeFeed);
}
function getPrice(uint256 maxAge) external view returns (uint256) {
// ── 1. Check sequencer liveness ───────────────────────────────────
(
,
int256 sequencerAnswer,
uint256 startedAt,
,
) = sequencerUptimeFeed.latestRoundData();
// 0 = sequencer is up, 1 = sequencer is down
if (sequencerAnswer != 0) revert SequencerDown();
uint256 gracePeriodEnds = startedAt + GRACE_PERIOD;
if (block.timestamp < gracePeriodEnds)
revert GracePeriodNotOver(startedAt, gracePeriodEnds);
// ── 2. Read the price feed ─────────────────────────────────────────
(
,
int256 answer,
,
uint256 updatedAt,
) = dataFeed.latestRoundData();
if (answer <= 0) revert InvalidPrice();
if (block.timestamp - updatedAt > maxAge) revert StalePrice();
return uint256(answer);
}
}
The grace period matters: an attacker who anticipated sequencer downtime could have stale positions that become exploitable the moment the sequencer resumes. The GRACE_PERIOD forces a cooldown so that arbitrageurs can close those positions before the protocol begins accepting new oracle-sensitive operations.
3.5 Per-Feed Heartbeat Mismatch
One frequently missed pattern is applying a single heartbeatInterval across multiple feeds with different update schedules. A common vulnerable pattern uses two feeds with a single shared heartbeatInterval — but if the first price feed has a heartbeat of 1 hour while the second has a heartbeat of 24 hours, they require different heartbeat values in their staleness checks.
// BAD: one threshold for two feeds with different heartbeats
uint256 constant STALE_THRESHOLD = 3600; // 1 hour
require(block.timestamp - updatedAt1 <= STALE_THRESHOLD); // 1h feed ✓
require(block.timestamp - updatedAt2 <= STALE_THRESHOLD); // 24h feed ✗ — always false
// GOOD: per-feed thresholds
uint256 constant FEED1_MAX_AGE = 3_660; // 1h heartbeat + 60s buffer
uint256 constant FEED2_MAX_AGE = 86_460; // 24h heartbeat + 60s buffer
require(block.timestamp - updatedAt1 <= FEED1_MAX_AGE);
require(block.timestamp - updatedAt2 <= FEED2_MAX_AGE);
4. Multi-Oracle Design and Aggregation Strategies
No single oracle is an adequate risk surface for a lending protocol or stablecoin. The right architecture combines two or more independent price sources and has a defined policy for what to do when they disagree.
Architectural Goals
- Independence: Sources should have different trust models — e.g., an off-chain aggregator (Chainlink) + an on-chain TWAP (Uniswap V3). A shared failure mode defeats the purpose.
- Aggregation method: Prefer using a median over a simple average when aggregating data from multiple sources — medians are more resistant to outliers or a few malicious data points.
- Deviation policy: Implement logic that checks if a newly reported price deviates too much from the previous price, or from a trusted secondary source. If a significant deviation is detected, the protocol can pause operations, trigger an emergency governance vote, or fall back to a different, more conservative price source.
Reference Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AggregatorV3Interface} from
"@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
interface ITWAPOracle {
function getPrice() external view returns (uint256);
}
/// @notice Multi-oracle aggregator with deviation guard
contract MultiOracle {
using OracleLib for AggregatorV3Interface;
AggregatorV3Interface public immutable chainlinkFeed;
ITWAPOracle public immutable twapOracle;
uint256 public constant CHAINLINK_MAX_AGE = 3_660; // 1h + buffer
uint256 public constant MAX_DEVIATION_BPS = 500; // 5%
uint256 public constant BPS_DENOMINATOR = 10_000;
error PriceDeviationTooHigh(uint256 priceA, uint256 priceB, uint256 bps);
error BothOraclesFailed();
constructor(address _chainlink, address _twap) {
chainlinkFeed = AggregatorV3Interface(_chainlink);
twapOracle = ITWAPOracle(_twap);
}
/// @notice Returns the agreed-upon price, or reverts if sources diverge.
function getPrice() external view returns (uint256 price) {
uint256 clPrice;
uint256 twapPrice;
bool clOk;
bool twapOk;
// ── Chainlink ──────────────────────────────────────────────────────
try chainlinkFeed.readFreshPrice(CHAINLINK_MAX_AGE)
returns (uint256 p, uint8 dec)
{
// Normalise to 1e18
clPrice = p * (10 ** (18 - dec));
clOk = true;
} catch {}
// ── TWAP ───────────────────────────────────────────────────────────
try twapOracle.getPrice() returns (uint256 p) {
twapPrice = p;
twapOk = true;
} catch {}
// ── Aggregation logic ──────────────────────────────────────────────
if (clOk && twapOk) {
_assertDeviation(clPrice, twapPrice);
// Use the more conservative (lower) price for collateral valuations
price = clPrice < twapPrice ? clPrice : twapPrice;
} else if (clOk) {
price = clPrice;
} else if (twapOk) {
price = twapPrice;
} else {
revert BothOraclesFailed();
}
}
/// @dev Reverts if the two prices deviate more than MAX_DEVIATION_BPS
function _assertDeviation(uint256 a, uint256 b) internal pure {
uint256 larger = a > b ? a : b;
uint256 smaller = a > b ? b : a;
uint256 devBps = ((larger - smaller) * BPS_DENOMINATOR) / larger;
if (devBps > MAX_DEVIATION_BPS)
revert PriceDeviationTooHigh(a, b, devBps);
}
}
Design decisions worth noting:
try/catchwraps each oracle so a single revert (e.g., stale Chainlink) does not brick the system.- The protocol uses the lower of two prices for collateral valuation — conservative by construction and harder to profit from even if one source is mildly manipulated.
- Divergence above 5% halts the system entirely. That threshold should be calibrated to each asset’s historical volatility.
5. Circuit Breakers
A circuit breaker is a stateful mechanism that suspends price-sensitive operations when the oracle environment is unhealthy. There are two categories:
A soft breaker puts a hold on minting or borrowing but still lets repayments and redemptions proceed. It kicks in when the stale time goes beyond maxAge, there is a deviation over a threshold percentage, or the L2 sequencer goes down.
A hard breaker is for serious situations — a verified anomaly or an incident with the feed. Once triggered, only an admin can lift the pause, and it is time-locked.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Protocol-level circuit breaker for oracle anomalies
contract OracleCircuitBreaker is Ownable, Pausable {
// ── Configuration ──────────────────────────────────────────────────────
uint256 public maxDeviationBps = 500; // 5% soft breaker threshold
uint256 public hardBreakerDelay = 2 days; // time-lock on admin resumption
// ── State ──────────────────────────────────────────────────────────────
uint256 public lastSafePrice;
uint256 public hardBreakerTriggeredAt;
bool public hardBreakerActive;
// ── Events ─────────────────────────────────────────────────────────────
event SoftBreakerTriggered(uint256 currentPrice, uint256 lastSafePrice, uint256 devBps);
event HardBreakerTriggered(uint256 triggeredAt);
event HardBreakerLifted(address admin);
function checkAndUpdate(uint256 currentPrice) external onlyOwner returns (bool safe) {
// ── Soft breaker ──────────────────────────────────────────────────
if (lastSafePrice > 0) {
uint256 deviation = currentPrice > lastSafePrice
? ((currentPrice - lastSafePrice) * 10_000) / lastSafePrice
: ((lastSafePrice - currentPrice) * 10_000) / lastSafePrice;
if (deviation > maxDeviationBps) {
emit SoftBreakerTriggered(currentPrice, lastSafePrice, deviation);
_pause();
// Hard breaker: if deviation is extreme (>20%), lock for 2 days
if (deviation > 2_000) {
hardBreakerActive = true;
hardBreakerTriggeredAt = block.timestamp;
emit HardBreakerTriggered(block.timestamp);
}
return false;
}
}
lastSafePrice = currentPrice;
if (paused()) _unpause();
return true;
}
function liftHardBreaker() external onlyOwner {
require(hardBreakerActive, "Hard breaker not active");
require(
block.timestamp >= hardBreakerTriggeredAt + hardBreakerDelay,
"Time-lock not elapsed"
);
hardBreakerActive = false;
_unpause();
emit HardBreakerLifted(msg.sender);
}
}
Circuit breakers are not a substitute for correct oracle integration — they are a last line of defense. A protocol that relies on its circuit breaker to absorb oracle manipulation has already failed to design a resilient oracle layer.
The Oracle Integration Checklist
Spot price oracles (AMM-based)
- No protocol reads
getReserves()orslot0()for pricing without a TWAP - TWAP window is at least 30 minutes for liquid assets, 60+ for illiquid
- The cost of manipulation is calculated and documented for the chosen TWAP window and liquidity depth
Chainlink integration
-
latestRoundData()return values are fully destructured and validated -
updatedAtis checked:block.timestamp - updatedAt <= STALENESS_THRESHOLD -
answeris checked:answer > 0 -
answeredInRound >= roundIdis checked - On L2 deployments: sequencer uptime feed is queried before price feed
- Decimals are read dynamically via
aggregator.decimals(), not hardcoded - Price is normalized to a consistent internal precision (WAD or RAY)
Multi-oracle and fallback
- Primary and fallback oracles are independent (different data sources, different keepers)
- Deviation between primary and fallback triggers a circuit breaker, not a silent fallback
- The fallback oracle’s trust assumptions are explicitly documented
Circuit breakers
- Price deviation threshold is calibrated to asset volatility (5% for stablecoins, 20% for volatile assets)
- Hard breaker with time-lock prevents immediate admin resumption after extreme events
- Positions opened during a breaker cannot be liquidated at the stale price
Price vs latency arbitrage
- Protocol differentiates between manipulation (attacker inflates price) and latency (oracle lags market)
- If latency arbitrage is possible, the expected loss per update cycle is quantified and accepted as a design choice