Development of Oracle Manipulation Protection System
Oracle manipulation is one of the most profitable attack vectors in DeFi. Essence: an attacker artificially changes the price in a data source (oracle) that the protocol uses, profits from the incorrect price, then the price returns to normal. Unlike classic code exploits, you don't necessarily need to hack the smart contract—just temporarily influence the data that the contract trusts.
Mango Markets (October 2022): $114M stolen through manipulation of MNGO tokens in Mango oracle. CREAM Finance: $130M through flash loan + price manipulation of yUSD. These aren't edge cases—they're systemic risk with incorrect price feed architecture.
Types of Oracles and Attack Vectors
Spot price from AMM (worst option)
Using getReserves() from a Uniswap V2 pair as a price source—direct path to flash loan attack.
// CRITICALLY VULNERABLE
function getPrice(address token) public view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
// Spot price = reserve1 / reserve0
// Flash loan can change reserves 100x in one transaction
return uint256(reserve1) * 1e18 / uint256(reserve0);
}
Attack takes one transaction: flash loan → swap distorts reserves → call vulnerable protocol → repay flash loan. No traces, no risk to attacker.
TWAP (Time-Weighted Average Price)
TWAP is the standard defense against flash loan attacks. Average price over a period cannot be significantly changed by a single transaction.
// Uniswap V3 TWAP via OracleLibrary
import "@uniswap/v3-periphery/contracts/libraries/OracleLibrary.sol";
contract TWAPOracle {
address public immutable pool; // Uniswap V3 pool
uint32 public constant TWAP_PERIOD = 30 minutes;
function getTWAP() public view returns (uint256 price) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = TWAP_PERIOD;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives, ) = IUniswapV3Pool(pool).observe(secondsAgos);
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
int24 arithmeticMeanTick = int24(tickCumulativesDelta / int56(uint56(TWAP_PERIOD)));
// Convert tick to price
uint160 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(arithmeticMeanTick);
price = FullMath.mulDiv(
uint256(sqrtRatioX96) * uint256(sqrtRatioX96),
1e18,
2 ** 192
);
}
}
TWAP Limitations: 30-minute TWAP can be shifted with gradual manipulation over 30 minutes. For tokens with low liquidity—attack cost decreases. TWAP isn't suitable as the sole defense for volatile assets or low-liquidity pools.
Chainlink Price Feeds
Chainlink is a decentralized oracle network. Price is aggregated from dozens of independent node operators, updated when deviation exceeds threshold (usually 0.5% or 1%) or by heartbeat (every 24 hours).
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract ChainlinkOracleConsumer {
AggregatorV3Interface public immutable priceFeed;
// Maximum time since last update
uint256 public constant STALENESS_THRESHOLD = 3600; // 1 hour for most feeds
constructor(address _priceFeed) {
priceFeed = AggregatorV3Interface(_priceFeed);
}
function getPrice() public view returns (uint256) {
(
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// Check staleness — price not outdated?
require(
block.timestamp - updatedAt <= STALENESS_THRESHOLD,
"Oracle: stale price"
);
// Check round completeness
require(answeredInRound >= roundId, "Oracle: incomplete round");
// Check for negative price (shouldn't happen, but check)
require(answer > 0, "Oracle: invalid price");
// Normalize to 18 decimals
uint8 decimals = priceFeed.decimals();
uint256 normalizedPrice = uint256(answer);
if (decimals < 18) {
normalizedPrice = normalizedPrice * 10 ** (18 - decimals);
} else if (decimals > 18) {
normalizedPrice = normalizedPrice / 10 ** (decimals - 18);
}
return normalizedPrice;
}
}
Common Mistakes with Chainlink:
- Not checking
updatedAt—accepting stale price during Chainlink downtime - Not checking
answeredInRound >= roundId—accepting price from incomplete round - Hardcoding
STALENESS_THRESHOLDwithout considering specific feed (heartbeat for ETH/USD = 1 hour, for exotic assets = 24 hours)
Multi-oracle System: Median Aggregation
Best defense—multiple independent sources with median aggregation. Manipulation of one source doesn't affect the median of three.
contract MultiOracleAggregator {
struct OracleConfig {
address oracle;
uint256 stalenessThreshold;
uint256 weight; // weight for weighted average if needed
bool active;
}
OracleConfig[] public oracles;
// Maximum allowed deviation between oracles (basis points)
uint256 public constant MAX_DEVIATION = 500; // 5%
function getPrice() external view returns (uint256 price, bool isValid) {
uint256[] memory prices = new uint256[](oracles.length);
uint256 validCount = 0;
for (uint256 i = 0; i < oracles.length; i++) {
if (!oracles[i].active) continue;
try IOracle(oracles[i].oracle).getPrice() returns (uint256 p, bool valid) {
if (valid && p > 0) {
prices[validCount++] = p;
}
} catch {
// Oracle unavailable—continue with others
}
}
// Need minimum 2 valid sources
require(validCount >= 2, "Insufficient oracle responses");
// Sort and take median
uint256 median = _getMedian(prices, validCount);
// Check all prices within MAX_DEVIATION of median
bool allWithinDeviation = true;
for (uint256 i = 0; i < validCount; i++) {
uint256 deviation = _absDiff(prices[i], median) * 10000 / median;
if (deviation > MAX_DEVIATION) {
allWithinDeviation = false;
break;
}
}
return (median, allWithinDeviation);
}
function _getMedian(uint256[] memory arr, uint256 length) internal pure returns (uint256) {
// Insertion sort for small arrays (usually 3-5 oracles)
for (uint256 i = 1; i < length; i++) {
uint256 key = arr[i];
int256 j = int256(i) - 1;
while (j >= 0 && arr[uint256(j)] > key) {
arr[uint256(j + 1)] = arr[uint256(j)];
j--;
}
arr[uint256(j + 1)] = key;
}
if (length % 2 == 1) {
return arr[length / 2];
} else {
return (arr[length / 2 - 1] + arr[length / 2]) / 2;
}
}
}
Circuit Breaker on Abnormal Prices
When price deviates sharply—automatic protocol pause:
contract PriceCircuitBreaker {
uint256 public lastValidPrice;
uint256 public lastUpdateTime;
// Maximum price change per update
uint256 public constant MAX_PRICE_CHANGE_BPS = 1000; // 10% per update
// Maximum change per hour
uint256 public constant MAX_HOURLY_CHANGE_BPS = 2000; // 20% per hour
bool public circuitBreakerTripped;
struct PriceHistory {
uint256 price;
uint256 timestamp;
}
PriceHistory[10] public priceHistory; // Ring buffer of last 10 prices
uint256 public historyIndex;
function updatePrice(uint256 newPrice) external onlyOracle {
require(!circuitBreakerTripped, "Circuit breaker active");
if (lastValidPrice > 0) {
uint256 change = _absDiff(newPrice, lastValidPrice) * 10000 / lastValidPrice;
// Sharp change per update
if (change > MAX_PRICE_CHANGE_BPS) {
circuitBreakerTripped = true;
emit CircuitBreakerTripped(lastValidPrice, newPrice, change);
return;
}
// Change per hour
uint256 hourlyChange = _calculateHourlyChange(newPrice);
if (hourlyChange > MAX_HOURLY_CHANGE_BPS) {
circuitBreakerTripped = true;
emit CircuitBreakerTripped(lastValidPrice, newPrice, hourlyChange);
return;
}
}
// Save to history
priceHistory[historyIndex % 10] = PriceHistory(newPrice, block.timestamp);
historyIndex++;
lastValidPrice = newPrice;
lastUpdateTime = block.timestamp;
emit PriceUpdated(newPrice, block.timestamp);
}
function resetCircuitBreaker() external onlyGovernance {
// Reset only through governance after analyzing cause
circuitBreakerTripped = false;
emit CircuitBreakerReset(msg.sender);
}
}
Flash Loan Vulnerability: Detection and Protection
Read-only Reentrancy
Specific attack for Balancer/Curve-style pools: attacker uses flash loan callback to call another protocol while balances are already changed but state protection (reentrancy guard) still active in original pool.
// Balancer V2 re-entrancy lock check
interface IVault {
function getReserves() external view returns (uint256, uint256);
// Vault locked → getReserves() returns incorrect data during callback
}
contract BalancerOracleConsumer {
IVault public immutable balancerVault;
modifier ensureNotLocked() {
// Check Balancer vault not locked (not in flash loan)
// Try calling operationKind—if vault locked, reverts
(, bytes memory data) = address(balancerVault).staticcall(
abi.encodeWithSignature("getAuthorizer()")
);
require(data.length > 0, "Balancer vault locked");
_;
}
function getBalancerPrice() external view ensureNotLocked returns (uint256) {
// Safe call only when vault not in flash loan
(uint256 reserve0, uint256 reserve1) = balancerVault.getReserves();
return reserve1 * 1e18 / reserve0;
}
}
Flash Loan Detection at Transaction Level
contract FlashLoanAwareProtocol {
// Mapping from block.number → true if flash loan occurred in this block
// through known flash loan providers
mapping(uint256 => bool) private _flashLoanInBlock;
// Callback from flash loan providers (AAVE, Uniswap, Balancer)
// We get notified if flash loan is used in our protocol
function markFlashLoanBlock() external onlyFlashLoanProvider {
_flashLoanInBlock[block.number] = true;
}
modifier noFlashLoan() {
require(!_flashLoanInBlock[block.number], "Flash loan detected in block");
_;
}
// Price-sensitive operations only when no flash loan in block
function borrow(address asset, uint256 amount) external noFlashLoan {
uint256 price = oracle.getPrice(asset);
// ...
}
}
Pyth Network and Pull Oracle Model
Chainlink uses push model: data updates automatically on deviation or heartbeat. Pyth uses pull model: user updates price before transaction by providing signed price attestation from network.
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
contract PythOracleConsumer {
IPyth public immutable pyth;
bytes32 public immutable priceId;
// Maximum price lifetime (60 seconds for most assets)
uint256 public constant PRICE_MAX_AGE = 60;
// updatePriceFeeds called in same transaction as operation
function borrowWithPythPrice(
uint256 borrowAmount,
bytes[] calldata priceUpdateData // data from Pyth
) external payable {
// Update price (user pays fee ~$0.001)
uint256 updateFee = pyth.getUpdateFee(priceUpdateData);
pyth.updatePriceFeeds{value: updateFee}(priceUpdateData);
// Get updated price
PythStructs.Price memory price = pyth.getPriceNoOlderThan(priceId, PRICE_MAX_AGE);
require(price.price > 0, "Invalid price");
require(price.conf < uint64(price.price) / 10, "Price confidence too low");
uint256 normalizedPrice = _normalizePrice(price.price, price.expo);
_executeBorrow(msg.sender, borrowAmount, normalizedPrice);
}
function _normalizePrice(int64 price, int32 expo) internal pure returns (uint256) {
if (expo >= 0) {
return uint256(uint64(price)) * 10 ** uint32(expo);
} else {
return uint256(uint64(price)) / 10 ** uint32(-expo);
}
}
}
Confidence interval in Pyth—important parameter: conf shows price uncertainty. If conf / price > 10%—data unreliable, operation should be rejected.
Oracle Anomaly Monitoring
interface PriceAnomaly {
asset: string;
currentPrice: bigint;
historicalMedian: bigint;
deviationPct: number;
timestamp: number;
oracleSource: string;
}
class OracleMonitor {
private priceHistory: Map<string, bigint[]> = new Map();
async detectAnomalies(
assets: string[],
oracleAddresses: string[]
): Promise<PriceAnomaly[]> {
const anomalies: PriceAnomaly[] = [];
for (let i = 0; i < assets.length; i++) {
const currentPrice = await this.getCurrentPrice(oracleAddresses[i]);
const history = this.priceHistory.get(assets[i]) ?? [];
if (history.length >= 10) {
// Median of last 10 prices
const sorted = [...history].sort((a, b) => (a > b ? 1 : -1));
const median = sorted[Math.floor(sorted.length / 2)];
const deviation = Math.abs(
Number((currentPrice - median) * 10000n / median)
) / 100;
if (deviation > 15) { // 15% deviation from median
anomalies.push({
asset: assets[i],
currentPrice,
historicalMedian: median,
deviationPct: deviation,
timestamp: Date.now(),
oracleSource: oracleAddresses[i]
});
// Send alert immediately
await this.sendAlert(anomalies[anomalies.length - 1]);
}
}
// Update history
history.push(currentPrice);
if (history.length > 100) history.shift();
this.priceHistory.set(assets[i], history);
}
return anomalies;
}
}
Recommendations for Choosing Oracle Strategy
| Scenario | Recommended Solution |
|---|---|
| Major assets (ETH, BTC, stablecoins) | Chainlink + TWAP as fallback |
| Long-tail DeFi tokens | Uniswap V3 TWAP 30min + Circuit breaker |
| Cross-chain applications | Pyth (fast update) + Chainlink verification |
| High-frequency protocols | Pyth pull model (current price in each tx) |
| Low-liquidity assets | Multi-oracle with high deviation threshold |
Development Phases
| Phase | Content | Timeline |
|---|---|---|
| Audit current oracle | Analyze vulnerabilities of existing integration | 1–2 weeks |
| Design multi-oracle | Choose sources, weights, aggregation logic | 1–2 weeks |
| Smart contracts | Aggregator, circuit breaker, anomaly detection | 3–4 weeks |
| Integration | Chainlink, Pyth, TWAP connection | 2–3 weeks |
| Monitoring system | Off-chain alerting, dashboard | 2–3 weeks |
| Testing | Fork tests with attack simulation, fuzz | 2–3 weeks |
| Audit | 2–3 weeks |







