2025-09-22·14 min read

Oracle integration testing: fuzzing price feeds and manipulation resistance

Oracle Integration Testing: Fuzzing Price Feeds and Manipulation Resistance

By nican0r

Oracles are the bridge between on-chain and off-chain reality. When that bridge lies, protocols lose millions. We saw it with the Mango Markets exploit ($114M), with Cream Finance ($130M), and dozens of smaller incidents. The pattern is always the same: a protocol trusted a price feed without verifying it was telling the truth.

You can't unit-test your way out of oracle bugs. The failure modes are combinatorial — stale prices interacting with high volatility, flash loan manipulation combined with specific pool states, multi-oracle disagreement during network congestion. Fuzzing with mock oracles is the only way to cover this space.

The core oracle properties

Every protocol that reads a price feed needs these properties, no exceptions:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Test} from "forge-std/Test.sol";

contract OracleInvariantTest is Test {
    LendingProtocol protocol;
    OracleHandler handler;
    MockChainlinkOracle mockOracle;

    function setUp() public {
        mockOracle = new MockChainlinkOracle();
        protocol = new LendingProtocol(address(mockOracle));
        handler = new OracleHandler(protocol, mockOracle);
        targetContract(address(handler));
    }

    /// @notice Protocol must reject stale price data
    function invariant_staleness_check() public view {
        (, , , uint256 updatedAt, ) = mockOracle.latestRoundData();
        uint256 staleness = block.timestamp - updatedAt;

        if (staleness > protocol.maxStaleness()) {
            // Any operation that reads price should have reverted
            // We track this via ghost variable in handler
            assertEq(
                handler.operationsWithStalePrice(),
                0,
                "Protocol accepted stale price"
            );
        }
    }

    /// @notice Protocol must reject zero or negative prices
    function invariant_price_sanity() public view {
        (, int256 price, , , ) = mockOracle.latestRoundData();
        if (price <= 0) {
            assertEq(
                handler.operationsWithInvalidPrice(),
                0,
                "Protocol accepted zero/negative price"
            );
        }
    }
}

These look simple. That's the point. The number of production protocols that skip staleness checks is staggering. The Chainlink documentation explicitly warns about this, and developers still miss it.

Price deviation bounds

Prices don't jump 50% in a single block under normal conditions. If your oracle reports a massive deviation from the last known price, something's probably wrong — either the oracle is being manipulated or there's a data feed issue.

/// @notice Price changes must be within acceptable deviation
function invariant_price_deviation_bounded() public view {
    int256 currentPrice = handler.lastAcceptedPrice();
    int256 previousPrice = handler.previousAcceptedPrice();

    if (previousPrice == 0 || currentPrice == 0) return;

    uint256 deviation;
    if (currentPrice > previousPrice) {
        deviation = uint256(currentPrice - previousPrice) * 10000
            / uint256(previousPrice);
    } else {
        deviation = uint256(previousPrice - currentPrice) * 10000
            / uint256(previousPrice);
    }

    // Max 20% deviation per heartbeat (configurable per asset)
    assertLe(
        deviation,
        2000, // 20% in basis points
        "Price deviated beyond safe threshold"
    );
}

The deviation threshold depends on the asset. ETH/USD might allow 15% between heartbeats. A volatile small-cap token might need 50%. Stablecoins should be 5% at most. Match your bounds to the asset's actual volatility profile.

TWAP vs spot price comparison

Time-weighted average prices resist single-block manipulation. If your protocol uses a spot price, cross-reference it against a TWAP to detect manipulation attempts.

/// @notice Spot price must not deviate too far from TWAP
function invariant_spot_vs_twap_consistency() public view {
    uint256 spotPrice = protocol.getSpotPrice(address(tokenA));
    uint256 twapPrice = protocol.getTWAP(address(tokenA), 30 minutes);

    if (spotPrice == 0 || twapPrice == 0) return;

    uint256 deviation;
    if (spotPrice > twapPrice) {
        deviation = ((spotPrice - twapPrice) * 10000) / twapPrice;
    } else {
        deviation = ((twapPrice - spotPrice) * 10000) / twapPrice;
    }

    // If deviation > 10%, the protocol should have flagged it
    if (deviation > 1000) {
        assertTrue(
            protocol.priceManipulationDetected(),
            "Large spot/TWAP deviation not detected"
        );
    }
}

This is how you catch flash loan attacks that manipulate AMM spot prices. The attacker can move the spot price in one block, but they can't move the TWAP (at least not without sustained capital allocation over many blocks).

Circuit breaker properties

Circuit breakers pause operations when price feeds behave abnormally. They're your protocol's kill switch. Properties need to verify they actually trigger:

/// @notice Circuit breaker must activate on extreme price movement
function invariant_circuit_breaker_triggers() public view {
    if (handler.extremePriceEventOccurred()) {
        assertTrue(
            protocol.circuitBreakerActive(),
            "Circuit breaker didn't trigger on extreme price"
        );
    }
}

/// @notice No borrows or liquidations while circuit breaker is active
function invariant_circuit_breaker_halts_operations() public view {
    if (protocol.circuitBreakerActive()) {
        assertEq(
            handler.operationsDuringCircuitBreaker(),
            0,
            "Operations executed during circuit breaker"
        );
    }
}

/// @notice Circuit breaker must be resettable after conditions normalize
function invariant_circuit_breaker_recovery() public view {
    if (handler.priceNormalizedAfterBreaker()) {
        assertFalse(
            protocol.circuitBreakerActive(),
            "Circuit breaker stuck active after normalization"
        );
    }
}

I've seen circuit breakers that trigger correctly but never reset. The protocol is paused forever. That last property catches this scenario. Once prices return to normal, the breaker should release.

Fallback oracle consistency

Production deployments should never depend on a single oracle. When Chainlink goes down (it's happened), you need a fallback. But fallbacks introduce their own bugs:

/// @notice Primary and fallback oracle must agree within tolerance
function invariant_multi_oracle_consistency() public view {
    if (!handler.bothOraclesActive()) return;

    int256 primaryPrice = handler.primaryOraclePrice();
    int256 fallbackPrice = handler.fallbackOraclePrice();

    if (primaryPrice <= 0 || fallbackPrice <= 0) return;

    uint256 deviation;
    if (primaryPrice > fallbackPrice) {
        deviation = uint256(primaryPrice - fallbackPrice) * 10000
            / uint256(fallbackPrice);
    } else {
        deviation = uint256(fallbackPrice - primaryPrice) * 10000
            / uint256(primaryPrice);
    }

    // If oracles disagree by more than 5%, protocol should pause
    if (deviation > 500) {
        assertTrue(
            protocol.oracleDisagreementDetected(),
            "Oracle disagreement not detected"
        );
    }
}

/// @notice Fallback must activate when primary fails
function invariant_fallback_activates() public view {
    if (handler.primaryOracleFailed()) {
        assertTrue(
            protocol.usingFallbackOracle(),
            "Primary failed but fallback not active"
        );
    }
}

Manipulation resistance: flash loan + oracle

This is the big one. Oracle manipulation via flash loans is the most common DeFi exploit vector. Your fuzzer needs to simulate this attack pattern:

contract OracleHandler is CommonBase, StdCheats, StdUtils {
    /// @notice Simulate flash loan price manipulation
    function manipulatePrice(
        uint256 manipulationAmount,
        uint256 actionSeed
    ) external {
        manipulationAmount = bound(
            manipulationAmount,
            1e18,
            1e26
        );

        // Record price before manipulation
        int256 priceBefore = _getCurrentPrice();

        // Simulate large swap that moves AMM price
        mockOracle.setPrice(
            priceBefore * int256(manipulationAmount) / 1e18
        );

        // Try to exploit the protocol at manipulated price
        uint8 action = uint8(bound(actionSeed, 0, 2));
        if (action == 0) _tryBorrow();
        else if (action == 1) _tryLiquidate();
        else _tryWithdraw();

        // Price returns to normal (flash loan repaid)
        mockOracle.setPrice(priceBefore);

        // Track whether any action succeeded at manipulated price
        _recordManipulationResult();
    }

    /// @notice Normal price update within realistic bounds
    function updatePrice(uint256 newPrice) external {
        int256 current = _getCurrentPrice();
        // Bound to ±20% of current price
        int256 lower = current * 80 / 100;
        int256 upper = current * 120 / 100;
        int256 bounded = int256(
            bound(uint256(newPrice), uint256(lower), uint256(upper))
        );
        mockOracle.setPrice(bounded);
        mockOracle.setUpdatedAt(block.timestamp);
    }

    /// @notice Advance time to make price stale
    function advanceTime(uint256 seconds_) external {
        seconds_ = bound(seconds_, 1, 2 days);
        vm.warp(block.timestamp + seconds_);
    }
}

The manipulatePrice action is where the real testing happens. It simulates an attacker who pushes the price to an extreme value, performs some protocol action, then lets the price snap back. Any protocol action that succeeds at the manipulated price is a potential exploit.

/// @notice No profit from flash manipulation
function invariant_flash_manipulation_unprofitable() public view {
    for (uint256 i = 0; i < handler.manipulationAttempts(); i++) {
        ManipulationResult memory result = handler.getResult(i);
        if (result.actionSucceeded) {
            assertLe(
                result.attackerProfitBps,
                0,
                "Flash manipulation was profitable"
            );
        }
    }
}

Chainlink-specific properties

If you're using Chainlink, these additional properties matter:

/// @notice roundId must increase monotonically
function invariant_round_id_monotonic() public view {
    (uint80 currentRound, , , , ) = mockOracle.latestRoundData();
    assertGe(
        currentRound,
        handler.previousRoundId(),
        "Round ID decreased"
    );
}

/// @notice answeredInRound must be >= roundId (not stale round)
function invariant_answered_in_round() public view {
    (
        uint80 roundId,
        ,
        ,
        ,
        uint80 answeredInRound
    ) = mockOracle.latestRoundData();
    assertGe(
        answeredInRound,
        roundId,
        "Answer is from a previous round"
    );
}

/// @notice Price must have correct number of decimals
function invariant_decimals_consistency() public view {
    uint8 decimals = mockOracle.decimals();
    (, int256 price, , , ) = mockOracle.latestRoundData();

    // Price should be reasonable for the decimal count
    // e.g., ETH at $2000 with 8 decimals = 200000000000
    if (decimals == 8) {
        assertGt(price, 1e6, "Price too low for 8 decimals");
        assertLt(price, 1e14, "Price too high for 8 decimals");
    }
}

Pyth and custom TWAP patterns

Pyth oracles work differently. They use a pull model where the user submits the price update. This changes your property suite:

/// @notice Pyth price confidence interval must be within bounds
function invariant_pyth_confidence_bounded() public view {
    PythStructs.Price memory price = pyth.getPrice(priceFeedId);

    // Confidence should be less than 5% of price
    uint256 confPercent = (uint256(uint64(price.conf)) * 10000)
        / uint256(uint64(price.price > 0 ? price.price : -price.price));

    assertLe(
        confPercent,
        500,
        "Pyth confidence interval too wide, price unreliable"
    );
}

/// @notice Custom TWAP must use sufficient observation window
function invariant_twap_window_sufficient() public view {
    uint256 twapWindow = protocol.twapObservationWindow();
    assertGe(
        twapWindow,
        15 minutes,
        "TWAP window too short, vulnerable to manipulation"
    );
}

For custom TWAP implementations (like Uniswap V3's oracle), your biggest risk is short observation windows. A 1-block TWAP is basically a spot price. You need at least 15-30 minutes to resist sustained manipulation.

Multi-oracle aggregation

Some protocols aggregate multiple oracles. The aggregation logic itself can have bugs:

/// @notice Median of 3 oracles must equal actual median
function invariant_median_correctness() public view {
    int256 price1 = oracle1.price();
    int256 price2 = oracle2.price();
    int256 price3 = oracle3.price();

    int256 expectedMedian = _calculateMedian(price1, price2, price3);
    int256 protocolMedian = protocol.getAggregatedPrice();

    assertEq(
        protocolMedian,
        expectedMedian,
        "Aggregated price != actual median"
    );
}

function _calculateMedian(
    int256 a,
    int256 b,
    int256 c
) internal pure returns (int256) {
    if (a <= b && b <= c) return b;
    if (a <= c && c <= b) return c;
    if (b <= a && a <= c) return a;
    if (b <= c && c <= a) return c;
    if (c <= a && a <= b) return a;
    return b;
}

Building mock oracles for fuzzing

Your mock oracle needs to support both normal updates and adversarial manipulation:

contract MockChainlinkOracle {
    int256 private _price;
    uint256 private _updatedAt;
    uint80 private _roundId;
    uint8 private _decimals;

    function setPrice(int256 price) external {
        _price = price;
    }

    function setUpdatedAt(uint256 timestamp) external {
        _updatedAt = timestamp;
    }

    function incrementRound() external {
        _roundId++;
        _updatedAt = block.timestamp;
    }

    function latestRoundData() external view returns (
        uint80 roundId,
        int256 answer,
        uint256 startedAt,
        uint256 updatedAt,
        uint80 answeredInRound
    ) {
        return (
            _roundId,
            _price,
            _updatedAt,
            _updatedAt,
            _roundId
        );
    }

    function decimals() external view returns (uint8) {
        return _decimals;
    }
}

The handler controls this mock. Normal actions update the price within reasonable bounds. Attack actions set extreme prices. The invariants verify the protocol handles both correctly.

Where to start

For oracle integration testing, your priority order should be:

  1. Staleness rejection: most common oracle bug in production
  2. Zero/negative price rejection: second most common
  3. Flash manipulation resistance: highest dollar-amount exploits
  4. TWAP/spot consistency: catches manipulation the staleness check misses
  5. Fallback activation: for production resilience

If you're new to invariant testing, start with how to write your first invariant test. For the broader context of why this matters, why invariant testing matters for DeFi security lays it out.


Need oracle integration testing for your protocol? Request an Audit or Try Recon Pro to generate oracle properties automatically.

Related Posts

Related Glossary Terms

Test your oracle integration