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.

Kevan Shah · · 15 min read

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:

  1. Latency: Prices must update quickly for accurate liquidations
  2. Manipulation Resistance: Must resist oracle attacks
  3. Availability: 100% uptime during market hours
  4. 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:

  1. Never Trust Single Source: Always use multiple oracles
  2. TWAP for Safety: Use time-weighted prices for liquidations
  3. MEV Protection: Private mempools, batch auctions
  4. Circuit Breakers: Pause during anomalies
  5. 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.