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 IConcentratedLiquidityPool {
function observe(uint32[] calldata secondsAgos)
external view
returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulatives);
}
contract SafeProtocol {
uint256 public lastAcceptedPrice;
address public oracle;
IConcentratedLiquidityPool 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 {
IConcentratedLiquidityPool 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 IConcentratedLiquidityPool {
function liquidity() external view returns (uint128);
}
contract LiquidityGuardedOracle {
IConcentratedLiquidityPool 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);
}
}
Off-chain feed 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 "./interfaces/AggregatorV3Interface.sol";
contract FeedWithTwapGuard {
AggregatorV3Interface public chainlinkFeed;
IConcentratedLiquidityPool public twapPool;
uint256 public constant MAX_DEVIATION_BPS = 200; // 2% feed vs TWAP
uint256 public constant MAX_STALENESS = 1 hours;
function getSafePrice() public view returns (uint256) {
(, int256 feedPrice,, uint256 updatedAt,) = chainlinkFeed.latestRoundData();
require(feedPrice > 0, "Invalid feed price");
require(block.timestamp - updatedAt <= MAX_STALENESS, "Feed stale");
uint256 twapPrice = _getTwapPrice(1800);
uint256 clPrice = uint256(feedPrice);
if (twapPrice > 0) {
uint256 deviation = clPrice > twapPrice
? (clPrice - twapPrice) * 10000 / twapPrice
: (twapPrice - clPrice) * 10000 / twapPrice;
require(deviation <= MAX_DEVIATION_BPS, "feed 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.