Oracle Infrastructure for Perpetual DEXs: Price Feeds and MEV Protection
Build robust oracle infrastructure for perpetual DEXs. Implement TWAP oracles, MEV-resistant price feeds, and multi-source aggregation.
Oracles are the critical infrastructure connecting on-chain perpetual DEXs to off-chain price data. A compromised or manipulated oracle can drain millions in minutes. Building robust, manipulation-resistant oracle systems is essential for any perpetual exchange.
This guide covers oracle architectures, TWAP implementations, MEV protection strategies, and multi-source aggregation for perpetual DEXs.
Oracle Requirements for Perpetuals
Key Challenges:
- Latency: Prices must update quickly for accurate liquidations
- Manipulation Resistance: Must resist oracle attacks
- Availability: 100% uptime during market hours
- Cost: Frequent updates must be gas-efficient
Dual Oracle System
contract PerpetualOracle {
struct PriceData {
uint256 indexPrice; // Spot price from external markets
uint256 markPrice; // Perpetual's trading price
uint256 lastUpdateTime;
uint256 confidence;
bool isValid;
}
PriceData public currentPrice;
// Oracles
address public indexOracle; // Chainlink, etc.
address public markPriceSource; // Internal AMM/Order book
// Validation parameters
uint256 public constant MAX_DEVIATION = 500; // 5%
uint256 public constant STALENESS_THRESHOLD = 300; // 5 minutes
uint256 public constant MIN_CONFIDENCE = 8000; // 80%
event PriceUpdated(
uint256 indexPrice,
uint256 markPrice,
uint256 timestamp
);
event PriceDeviationAlert(
uint256 indexPrice,
uint256 markPrice,
uint256 deviation
);
function updatePrices() external {
// Fetch prices from sources
uint256 newIndexPrice = fetchIndexPrice();
uint256 newMarkPrice = fetchMarkPrice();
// Validate price deviation
uint256 deviation = calculateDeviation(newIndexPrice, newMarkPrice);
if (deviation > MAX_DEVIATION) {
// Handle excessive deviation
emit PriceDeviationAlert(newIndexPrice, newMarkPrice, deviation);
// Use more conservative price
newMarkPrice = calculateConservativePrice(newIndexPrice, newMarkPrice);
}
// Calculate confidence score
uint256 confidence = calculateConfidence(newIndexPrice, newMarkPrice, deviation);
require(confidence >= MIN_CONFIDENCE, "Low confidence");
// Update storage
currentPrice = PriceData({
indexPrice: newIndexPrice,
markPrice: newMarkPrice,
lastUpdateTime: block.timestamp,
confidence: confidence,
isValid: true
});
emit PriceUpdated(newIndexPrice, newMarkPrice, block.timestamp);
}
function fetchIndexPrice() internal view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = AggregatorV3Interface(indexOracle)
.latestRoundData();
require(price > 0, "Invalid price");
require(
block.timestamp - updatedAt <= STALENESS_THRESHOLD,
"Stale price"
);
return uint256(price);
}
function fetchMarkPrice() internal view returns (uint256) {
// Get from internal trading venue
return markPriceSource.getWeightedAveragePrice();
}
function calculateDeviation(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 diff = a > b ? a - b : b - a;
return (diff * 10000) / a;
}
}
Time-Weighted Average Price (TWAP)
TWAP Implementation
contract TWAPOracle {
struct Observation {
uint32 blockTimestamp;
int56 tickCumulative;
uint160 secondsPerLiquidityCumulativeX128;
bool initialized;
}
Observation[] public observations;
uint32 public constant TWAP_PERIOD = 1800; // 30 minutes
function observe(uint32[] calldata secondsAgos)
external
view
returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s)
{
require(secondsAgos.length > 0, "Invalid seconds ago");
tickCumulatives = new int56[](secondsAgos.length);
secondsPerLiquidityCumulativeX128s = new uint160[](secondsAgos.length);
for (uint i = 0; i < secondsAgos.length; i++) {
(tickCumulatives[i], secondsPerLiquidityCumulativeX128s[i]) =
observeSingle(block.timestamp - secondsAgos[i]);
}
}
function observeSingle(uint32 targetTimestamp)
internal
view
returns (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128)
{
require(targetTimestamp <= block.timestamp, "Invalid timestamp");
// Binary search for relevant observations
uint256 left = 0;
uint256 right = observations.length - 1;
while (left < right) {
uint256 mid = (left + right + 1) / 2;
if (observations[mid].blockTimestamp <= targetTimestamp) {
left = mid;
} else {
right = mid - 1;
}
}
Observation memory beforeOrAt = observations[left];
if (beforeOrAt.blockTimestamp == targetTimestamp) {
return (beforeOrAt.tickCumulative, beforeOrAt.secondsPerLiquidityCumulativeX128);
}
// Interpolate between observations
if (left < observations.length - 1) {
Observation memory afterOrAt = observations[left + 1];
uint32 delta = afterOrAt.blockTimestamp - beforeOrAt.blockTimestamp;
if (delta > 0) {
uint32 progress = targetTimestamp - beforeOrAt.blockTimestamp;
tickCumulative = beforeOrAt.tickCumulative +
((afterOrAt.tickCumulative - beforeOrAt.tickCumulative) * int56(uint56(progress))) / int56(uint56(delta));
}
}
return (tickCumulative, secondsPerLiquidityCumulativeX128);
}
function getTWAPPrice(uint32 twapPeriod) external view returns (uint256) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = twapPeriod;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives, ) = observe(secondsAgos);
int56 tickCumulativeDelta = tickCumulatives[1] - tickCumulatives[0];
int24 timeWeightedAverageTick = int24(tickCumulativeDelta / int56(uint56(twapPeriod)));
// Convert tick to price
return tickToPrice(timeWeightedAverageTick);
}
function tickToPrice(int24 tick) internal pure returns (uint256) {
// price = 1.0001^tick
uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(tick);
return (uint256(sqrtPriceX96) ** 2 * 1e18) >> 192;
}
function writeObservation(int24 tick, uint128 liquidity) external {
Observation memory last = observations[observations.length - 1];
if (block.timestamp == last.blockTimestamp) {
// Update current observation
observations[observations.length - 1] = Observation({
blockTimestamp: uint32(block.timestamp),
tickCumulative: last.tickCumulative + int56(tick),
secondsPerLiquidityCumulativeX128: last.secondsPerLiquidityCumulativeX128 +
((uint160(block.timestamp - last.blockTimestamp) << 128) / liquidity),
initialized: true
});
} else {
// Create new observation
observations.push(Observation({
blockTimestamp: uint32(block.timestamp),
tickCumulative: last.tickCumulative + int56(tick) * int56(uint56(block.timestamp - last.blockTimestamp)),
secondsPerLiquidityCumulativeX128: last.secondsPerLiquidityCumulativeX128 +
((uint160(block.timestamp - last.blockTimestamp) << 128) / liquidity),
initialized: true
}));
}
// Remove old observations
_pruneObservations();
}
function _pruneObservations() internal {
uint32 cutoff = uint32(block.timestamp - TWAP_PERIOD * 2);
while (observations.length > 2 && observations[1].blockTimestamp < cutoff) {
// Remove first observation
for (uint i = 0; i < observations.length - 1; i++) {
observations[i] = observations[i + 1];
}
observations.pop();
}
}
}
TWAP for Liquidations vs Trading
contract MultiPeriodTWAP {
// Different TWAP periods for different use cases
uint32 public constant TRADING_TWAP = 300; // 5 minutes - responsive
uint32 public constant LIQUIDATION_TWAP = 900; // 15 minutes - manipulation resistant
uint32 public constant FUNDING_TWAP = 3600; // 1 hour - stable
function getPriceForUseCase(string memory useCase) external view returns (uint256) {
if (keccak256(bytes(useCase)) == keccak256(bytes("trading"))) {
return getTWAPPrice(TRADING_TWAP);
} else if (keccak256(bytes(useCase)) == keccak256(bytes("liquidation"))) {
return getTWAPPrice(LIQUIDATION_TWAP);
} else if (keccak256(bytes(useCase)) == keccak256(bytes("funding"))) {
return getTWAPPrice(FUNDING_TWAP);
}
revert("Invalid use case");
}
}
Multi-Source Aggregation
Consensus-Based Oracle
contract MultiSourceOracle {
struct OracleSource {
address oracle;
uint256 weight;
bool isActive;
uint256 lastUpdateTime;
uint256 lastPrice;
}
OracleSource[] public sources;
uint256 public constant MIN_SOURCES = 3;
uint256 public constant MAX_DEVIATION = 300; // 3%
function addSource(address oracle, uint256 weight) external onlyGovernance {
sources.push(OracleSource({
oracle: oracle,
weight: weight,
isActive: true,
lastUpdateTime: 0,
lastPrice: 0
}));
}
function aggregatePrice() external view returns (uint256) {
uint256[] memory prices = new uint256[](sources.length);
uint256[] memory validWeights = new uint256[](sources.length);
uint256 validCount = 0;
// Collect prices from all active sources
for (uint i = 0; i < sources.length; i++) {
if (!sources[i].isActive) continue;
try IOracle(sources[i].oracle).getPrice() returns (uint256 price) {
if (price > 0) {
prices[validCount] = price;
validWeights[validCount] = sources[i].weight;
validCount++;
}
} catch {
continue;
}
}
require(validCount >= MIN_SOURCES, "Insufficient sources");
// Check for outliers
uint256 medianPrice = findMedian(prices, validCount);
for (uint i = 0; i < validCount; i++) {
uint256 deviation = calculateDeviation(prices[i], medianPrice);
if (deviation > MAX_DEVIATION) {
// Reject outlier
validWeights[i] = 0;
}
}
// Calculate weighted average of valid prices
uint256 weightedSum = 0;
uint256 totalWeight = 0;
for (uint i = 0; i < validCount; i++) {
if (validWeights[i] > 0) {
weightedSum += prices[i] * validWeights[i];
totalWeight += validWeights[i];
}
}
require(totalWeight > 0, "No valid prices");
return weightedSum / totalWeight;
}
function findMedian(uint256[] memory arr, uint256 length)
internal pure returns (uint256) {
// Simple sort (for production, use more efficient algorithm)
for (uint i = 0; i < length; i++) {
for (uint j = i + 1; j < length; j++) {
if (arr[j] < arr[i]) {
uint256 temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
}
if (length % 2 == 0) {
return (arr[length / 2 - 1] + arr[length / 2]) / 2;
} else {
return arr[length / 2];
}
}
}
MEV Protection Strategies
Batch Auctions
contract BatchAuctionOracle {
struct PriceSubmission {
address submitter;
uint256 price;
uint256 stake;
bytes32 commitment;
}
mapping(uint256 => PriceSubmission[]) public roundSubmissions;
mapping(uint256 => mapping(address => bool)) public hasRevealed;
uint256 public currentRound;
uint256 public constant COMMIT_PHASE = 2 minutes;
uint256 public constant REVEAL_PHASE = 1 minutes;
uint256 public constant MIN_STAKE = 1e18; // 1 token
enum Phase { COMMIT, REVEAL, CALCULATE }
function commitPrice(bytes32 commitment) external payable {
require(msg.value >= MIN_STAKE, "Insufficient stake");
require(getCurrentPhase() == Phase.COMMIT, "Not in commit phase");
roundSubmissions[currentRound].push(PriceSubmission({
submitter: msg.sender,
price: 0,
stake: msg.value,
commitment: commitment
}));
}
function revealPrice(uint256 price, uint256 salt) external {
require(getCurrentPhase() == Phase.REVEAL, "Not in reveal phase");
require(!hasRevealed[currentRound][msg.sender], "Already revealed");
// Verify commitment
bytes32 commitment = keccak256(abi.encodePacked(price, salt, msg.sender));
PriceSubmission[] storage submissions = roundSubmissions[currentRound];
for (uint i = 0; i < submissions.length; i++) {
if (submissions[i].submitter == msg.sender &&
submissions[i].commitment == commitment) {
submissions[i].price = price;
hasRevealed[currentRound][msg.sender] = true;
break;
}
}
}
function calculateAggregatedPrice() external {
require(getCurrentPhase() == Phase.CALCULATE, "Not in calculate phase");
PriceSubmission[] storage submissions = roundSubmissions[currentRound];
uint256[] memory validPrices = new uint256[](submissions.length);
uint256 validCount = 0;
for (uint i = 0; i < submissions.length; i++) {
if (submissions[i].price > 0) {
validPrices[validCount] = submissions[i].price;
validCount++;
} else {
// Slash stake for not revealing
payable(treasury).transfer(submissions[i].stake);
}
}
require(validCount >= 3, "Insufficient valid submissions");
uint256 aggregatedPrice = findMedian(validPrices, validCount);
// Update oracle price
currentPrice = aggregatedPrice;
lastUpdateTime = block.timestamp;
// Reward submitters
distributeRewards(submissions, validCount, aggregatedPrice);
// Advance round
currentRound++;
roundStartTime = block.timestamp;
emit PriceUpdated(aggregatedPrice, currentRound - 1);
}
function distributeRewards(
PriceSubmission[] memory submissions,
uint256 validCount,
uint256 targetPrice
) internal {
uint256 totalRewards = 0;
// Calculate rewards based on accuracy
for (uint i = 0; i < submissions.length; i++) {
if (submissions[i].price == 0) continue;
uint256 deviation = calculateDeviation(submissions[i].price, targetPrice);
if (deviation <= 100) { // Within 1%
uint256 reward = (submissions[i].stake * 110) / 100; // 10% profit
payable(submissions[i].submitter).transfer(reward);
totalRewards += reward;
} else if (deviation <= 500) { // Within 5%
payable(submissions[i].submitter).transfer(submissions[i].stake);
} else {
// Slash for large deviation
payable(treasury).transfer(submissions[i].stake);
}
}
}
}
Private Mempool Integration
contract MEVProtectedOracle {
address public constant FLASHBOTS_RELAY = 0x...;
function submitPrivateUpdate(bytes calldata updateData) external {
// Submit to Flashbots/MEV-Blocker to avoid frontrunning
(bool success, ) = FLASHBOTS_RELAY.call(updateData);
require(success, "Private submission failed");
}
function updatePriceWithProtection(uint256 newPrice) external {
// Check for sandwich attack indicators
require(!isSandwichAttackLikely(), "Potential MEV attack");
// Use commit-reveal for sensitive operations
bytes32 commit = keccak256(abi.encodePacked(newPrice, block.number));
// Wait for confirmation
require(block.number > lastCommitBlock + 2, "Wait for confirmation");
currentPrice = newPrice;
}
function isSandwichAttackLikely() internal view returns (bool) {
// Check for unusual gas price patterns
// Check for multiple pending transactions
// Check for price manipulation indicators
return false; // Simplified
}
}
Circuit Breakers and Safety Mechanisms
contract OracleCircuitBreaker {
uint256 public constant MAX_PRICE_CHANGE = 1000; // 10%
uint256 public constant MAX_STALENESS = 600; // 10 minutes
uint256 public constant CIRCUIT_BREAKER_DURATION = 1800; // 30 minutes
bool public isCircuitBreakerActive;
uint256 public circuitBreakerEndTime;
uint256 public lastValidPrice;
uint256 public lastUpdateTime;
mapping(address => bool) public emergencyOracles;
event CircuitBreakerTriggered(uint256 price, string reason);
event CircuitBreakerReset();
function validateAndUpdatePrice(uint256 newPrice) external {
// Check staleness
require(
block.timestamp - lastUpdateTime <= MAX_STALENESS,
"Stale oracle"
);
// Check for extreme price movement
if (lastValidPrice > 0) {
uint256 priceChange = newPrice > lastValidPrice
? ((newPrice - lastValidPrice) * 10000) / lastValidPrice
: ((lastValidPrice - newPrice) * 10000) / lastValidPrice;
if (priceChange > MAX_PRICE_CHANGE) {
_triggerCircuitBreaker(newPrice, "Extreme price movement");
return;
}
}
// Update price
lastValidPrice = newPrice;
lastUpdateTime = block.timestamp;
emit PriceUpdated(newPrice);
}
function _triggerCircuitBreaker(uint256 suspiciousPrice, string memory reason) internal {
isCircuitBreakerActive = true;
circuitBreakerEndTime = block.timestamp + CIRCUIT_BREAKER_DURATION;
// Pause trading
exchange.pause();
emit CircuitBreakerTriggered(suspiciousPrice, reason);
}
function resetCircuitBreaker() external onlyGovernance {
require(block.timestamp >= circuitBreakerEndTime, "Circuit breaker active");
isCircuitBreakerActive = false;
exchange.unpause();
emit CircuitBreakerReset();
}
function emergencyPriceUpdate(uint256 newPrice) external {
require(emergencyOracles[msg.sender], "Not emergency oracle");
require(isCircuitBreakerActive, "No emergency");
// Require multiple emergency oracles to agree
// Implementation of multi-sig oracle
}
}
Best Practices
1. Multiple Oracle Layers
- Primary: High-frequency updates
- Secondary: TWAP for liquidations
- Tertiary: Emergency oracle
2. Gradual Staleness
function getStalenessPenalty(uint256 lastUpdate) public view returns (uint256) {
uint256 timeSinceUpdate = block.timestamp - lastUpdate;
if (timeSinceUpdate < 300) return 0; // < 5 min: no penalty
if (timeSinceUpdate < 600) return 1000; // 5-10 min: 10% penalty
if (timeSinceUpdate < 1800) return 3000; // 10-30 min: 30% penalty
return 10000; // > 30 min: reject
}
3. Confidence Scoring
function calculateConfidence(
uint256[] memory prices,
uint256[] memory timestamps
) public pure returns (uint256) {
// Higher confidence when:
// - More sources agree
// - Recent updates
// - Low volatility
uint256 agreementScore = calculateAgreement(prices);
uint256 recencyScore = calculateRecency(timestamps);
uint256 stabilityScore = calculateStability(prices);
return (agreementScore + recencyScore + stabilityScore) / 3;
}
4. Fallback Mechanisms
- Multiple oracle providers
- On-chain TWAP as backup
- Manual override capability
5. Monitoring and Alerting
event OracleAnomaly(
string anomalyType,
uint256 severity,
uint256 timestamp
);
function checkOracleHealth() external {
// Check price volatility
// Check update frequency
// Check source diversity
// Check deviation from other sources
}
Conclusion
Robust oracle infrastructure is critical for perpetual DEX survival:
Key Principles:
- Never Trust Single Source: Always use multiple oracles
- TWAP for Safety: Use time-weighted prices for liquidations
- MEV Protection: Private mempools, batch auctions
- Circuit Breakers: Pause during anomalies
- Continuous Monitoring: Detect issues before they become crises
The exchanges that survive extreme market conditions will be those with sophisticated, multi-layered oracle systems that can withstand attacks, manipulation attempts, and extreme volatility.