Price Deviation Remediation
How to protect protocols from extreme price movements and flash loan manipulation using deviation bounds, TWAP comparison, circuit breakers, and absolute price range constraints.
Price Deviation Remediation
Overview
Protocols that accept any price update without validating how far it deviates from a recent reference are vulnerable to flash loan manipulation. An attacker borrows a large position, moves an on-chain spot price by an extreme amount within a single transaction, exploits the protocol at the manipulated price, and repays the loan — all atomically. Without a deviation check, there is nothing on-chain to distinguish a legitimate 900% price increase from a manipulation.
Mango Markets lost $116M (October 2022) when an attacker manipulated the MNGO token price from $0.038 to $0.91 (a 2,300% increase) and borrowed against the inflated collateral. Venus Protocol lost over $100M from a similar BNB price manipulation. Both protocols lacked deviation checks that would have rejected the extreme price as implausible within a single block.
The crash direction is equally exploitable: an artificial price crash can trigger mass liquidations at artificially depressed prices, with the attacker profiting from liquidation bonuses on positions that should not have been liquidated.
Related Detector: Oracle Manipulation Detector
Recommended Fix
Before (Vulnerable)
contract VulnerableProtocol {
uint256 public lastPrice;
address public oracle;
function updatePrice(uint256 newPrice) external onlyOracle {
// VULNERABLE: Accepts any price update with no validation.
// A 900% price increase in a single block is treated identically
// to a 1% price increase.
lastPrice = newPrice;
emit PriceUpdated(newPrice);
}
function getCollateralValue(uint256 amount) public view returns (uint256) {
// Uses lastPrice directly — fully manipulable in the same transaction
return amount * lastPrice / 1e8;
}
}
After (Fixed)
interface IUniswapV3Pool {
function observe(uint32[] calldata secondsAgos)
external view
returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulatives);
}
contract SafeProtocol {
uint256 public lastAcceptedPrice;
address public oracle;
IUniswapV3Pool public twapPool;
// Per-update deviation: reject any single update that moves price >10%
uint256 public constant MAX_SINGLE_UPDATE_BPS = 1000; // 10%
// Cross-source deviation: reject if spot deviates >5% from 30-min TWAP
uint256 public constant MAX_TWAP_DEVIATION_BPS = 500; // 5%
// Absolute price floor and ceiling for the asset (calibrate per-token)
uint256 public constant MIN_PRICE = 100e8; // $100 minimum (8 decimals)
uint256 public constant MAX_PRICE = 100_000e8; // $100,000 maximum
bool public circuitBroken;
address public guardian;
modifier whenCircuitClosed() {
require(!circuitBroken, "Circuit breaker active");
_;
}
function updatePrice(uint256 newPrice) external onlyOracle whenCircuitClosed {
// 1. Absolute bounds
require(newPrice >= MIN_PRICE, "Price below floor");
require(newPrice <= MAX_PRICE, "Price above ceiling");
// 2. Per-update deviation check
if (lastAcceptedPrice != 0) {
uint256 deviation = newPrice > lastAcceptedPrice
? (newPrice - lastAcceptedPrice) * 10000 / lastAcceptedPrice
: (lastAcceptedPrice - newPrice) * 10000 / lastAcceptedPrice;
if (deviation > MAX_SINGLE_UPDATE_BPS) {
// Trip the circuit breaker — halt price-sensitive operations
circuitBroken = true;
emit CircuitBroken(lastAcceptedPrice, newPrice, deviation);
return;
}
}
// 3. TWAP cross-validation — reject if spot deviates too far from TWAP
uint256 twap = _getTwapPrice();
if (twap > 0) {
uint256 twapDeviation = newPrice > twap
? (newPrice - twap) * 10000 / twap
: (twap - newPrice) * 10000 / twap;
require(twapDeviation <= MAX_TWAP_DEVIATION_BPS, "Exceeds TWAP deviation");
}
lastAcceptedPrice = newPrice;
emit PriceAccepted(newPrice);
}
function _getTwapPrice() internal view returns (uint256) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = 1800; // 30-minute TWAP window
secondsAgos[1] = 0;
try twapPool.observe(secondsAgos) returns (
int56[] memory tickCumulatives,
uint160[] memory
) {
int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
int24 avgTick = int24(tickDelta / int56(uint56(1800)));
return _tickToPrice(avgTick);
} catch {
return 0; // TWAP unavailable — skip the cross-validation
}
}
function restoreCircuit(uint256 validatedPrice) external {
require(msg.sender == guardian, "Only guardian");
require(validatedPrice >= MIN_PRICE && validatedPrice <= MAX_PRICE, "Invalid price");
circuitBroken = false;
lastAcceptedPrice = validatedPrice;
emit CircuitRestored(validatedPrice);
}
function getCollateralValue(uint256 amount) public view whenCircuitClosed returns (uint256) {
require(lastAcceptedPrice > 0, "No valid price");
return amount * lastAcceptedPrice / 1e8;
}
}
Alternative Mitigations
TWAP as the primary price source for all lending decisions — use spot price for display and UX only; use TWAP for all collateral calculations and liquidation triggers. A 30-minute TWAP requires an attacker to sustain the manipulated price for 30 minutes at enormous cost:
contract TwapLending {
IUniswapV3Pool public pool;
uint32 public constant TWAP_PERIOD = 1800; // 30 minutes
// All lending decisions use TWAP — not spot price
function getCollateralValue(uint256 amount) public view returns (uint256) {
uint256 twapPrice = _getTwapPrice(TWAP_PERIOD);
require(twapPrice > 0, "TWAP unavailable");
return amount * twapPrice / 1e18;
}
// Spot price available for display only — never used in protocol logic
function getSpotPriceForDisplay() public view returns (uint256) {
(uint160 sqrtPriceX96,,,,,,) = pool.slot0();
return _sqrtPriceToPrice(sqrtPriceX96);
}
}
Minimum liquidity requirements for AMM price sources — thin markets are cheap to manipulate. Require the AMM pool to have a minimum liquidity depth before accepting its price:
interface IUniswapV3Pool {
function liquidity() external view returns (uint128);
}
contract LiquidityGuardedOracle {
IUniswapV3Pool public pool;
uint128 public constant MIN_LIQUIDITY = 1_000_000e6; // $1M USDC equivalent
function _requireSufficientLiquidity() internal view {
uint128 poolLiquidity = pool.liquidity();
require(poolLiquidity >= MIN_LIQUIDITY, "Insufficient pool liquidity for oracle");
}
function getPrice() external view returns (uint256) {
_requireSufficientLiquidity();
return _getTwapPrice(1800);
}
}
Chainlink with deviation bounds — use a trusted off-chain aggregator as the primary source and compare it against an on-chain TWAP. Reject if they diverge beyond a tolerance:
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract ChainlinkWithTwapGuard {
AggregatorV3Interface public chainlinkFeed;
IUniswapV3Pool public twapPool;
uint256 public constant MAX_DEVIATION_BPS = 200; // 2% Chainlink vs TWAP
uint256 public constant MAX_STALENESS = 1 hours;
function getSafePrice() public view returns (uint256) {
(, int256 chainlinkPrice,, uint256 updatedAt,) = chainlinkFeed.latestRoundData();
require(chainlinkPrice > 0, "Invalid Chainlink price");
require(block.timestamp - updatedAt <= MAX_STALENESS, "Chainlink stale");
uint256 twapPrice = _getTwapPrice(1800);
uint256 clPrice = uint256(chainlinkPrice);
if (twapPrice > 0) {
uint256 deviation = clPrice > twapPrice
? (clPrice - twapPrice) * 10000 / twapPrice
: (twapPrice - clPrice) * 10000 / twapPrice;
require(deviation <= MAX_DEVIATION_BPS, "Chainlink and TWAP diverged");
}
return clPrice;
}
}
Common Mistakes
Setting MAX_SINGLE_UPDATE_BPS too high — a 50% per-update deviation allowance permits a 2x price manipulation in two transactions (two 50% updates). For most assets, a 10% maximum per-update is generous; for stablecoins, 5% or less is appropriate.
Checking deviation only on the way up — attacks are profitable in both directions. A 90% price crash can trigger unnecessary liquidations. Check for extreme downward movement with the same vigilance as extreme upward movement.
Using a TWAP period shorter than 15 minutes — a 1-minute TWAP can be manipulated by holding a price position for 60 seconds, which is economically feasible for large actors. A 30-minute TWAP requires holding the manipulated price for 30 minutes at continuous cost, making it prohibitively expensive for most assets.
Not resetting the circuit breaker with human review — an automatic circuit breaker that can also automatically restore itself provides much weaker protection. Restoration should require a guardian transaction that includes a validated reference price, and the restoration should be logged on-chain for auditability.