3.11.1 Oracles and External Data

Every smart contract that makes decisions based on real-world data depends on an oracle — a mechanism for getting that data onto the chain. Prices, interest rates, sports scores, weather, credit scores, identity claims, election outcomes: anything a contract reads that did not originate on-chain comes through an oracle. The integrity of the contract is bounded by the integrity of its oracle.

This is not a small concern. Section 3.10.3 (bZx) and Section 3.10.8 (Euler Finance) both depended on flash-loan-amplified oracle manipulation. Section 3.8.5 catalogues oracle manipulation as a vulnerability class. Industry losses to oracle-related exploits over the period 2020-2024 exceed $1 billion. Of all the things a smart contract could get wrong, getting an oracle wrong is among the most likely and most expensive.

This subsection covers the design problem of integrating off-chain data. Not "what is oracle manipulation" — that's Section 3.8.5. Not "what happened to bZx" — that's Section 3.10.3. Instead: when you are building a new protocol that needs off-chain prices or other external facts, how do you choose what to query, how do you query it, and how do you defend against the manipulation patterns that will inevitably be tried?

The Oracle Trust Model

The first question every protocol must answer: what failure mode are you defending against? The choice of oracle is the choice of which trust assumptions you accept.

Three distinct failure modes:

1. Manipulation by a flash-loan-equipped adversary. The attacker has tens of millions of dollars of capital for a single transaction. They can move on-chain prices in a single block. This is the failure mode bZx and Euler suffered. Defense: do not derive prices from a single on-chain spot source that the attacker can move with their capital.

2. Reporter compromise. The off-chain entity that posts prices to the chain is itself attacked, bribed, or makes a mistake. The reporter posts wrong prices; the protocol acts on them. Defense: do not depend on a single reporter; aggregate across many.

3. Stale or unavailable data. The oracle did not get updated; the protocol reads a price that no longer reflects reality. The protocol then under- or over-collateralizes positions; liquidations fire incorrectly; users are harmed. Defense: explicit staleness checks; behavior when data is unavailable.

A given protocol may face all three. The right defense involves multiple oracle approaches, each addressing a different failure mode. Single-oracle architectures, even when the single oracle is Chainlink, expose the protocol to whichever failure modes that oracle does not address.

The Modern Oracle Stack

For most protocols that need price feeds, the contemporary best practice combines several mechanisms. The full stack looks roughly like this:

┌──────────────────────────────────────────────────────────────┐
│  Protocol reads price                                         │
│  ↓                                                            │
│  Primary: Chainlink aggregator (off-chain, multi-reporter)    │
│  ↓                                                            │
│  Sanity check: deviation from a secondary source              │
│  ↓                                                            │
│  Staleness check: rejected if last update > N seconds         │
│  ↓                                                            │
│  Bounds check: rejected if outside reasonable range           │
│  ↓                                                            │
│  Circuit breaker: pause if deviations exceed threshold        │
└──────────────────────────────────────────────────────────────┘

Each layer addresses a different failure mode. Chainlink protects against single-reporter compromise. The secondary source protects against Chainlink-specific issues. The staleness check protects against unavailability. The bounds check catches order-of-magnitude bugs. The circuit breaker protects against sustained anomalies.

Each layer also adds complexity, gas cost, and possible failure modes of its own (e.g., what if the staleness threshold is too short and rejects legitimate-but-slow updates?). The art is in choosing where on the cost/security curve a specific protocol sits.

Pull-Based vs. Push-Based Oracles

Oracle architectures fall into two broad categories, with different security properties.

Off-chain reporters periodically post new prices to an on-chain aggregator contract. The aggregator computes a median (or other aggregate) and updates a stored value. Consumers read the latest stored value.

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

contract PushOracleConsumer {
    AggregatorV3Interface public immutable priceFeed;

    constructor(address _priceFeed) {
        priceFeed = AggregatorV3Interface(_priceFeed);
    }

    function getPrice() public view returns (uint256) {
        (, int256 answer, , uint256 updatedAt, ) = priceFeed.latestRoundData();
        require(answer > 0, "negative or zero price");
        require(block.timestamp - updatedAt < 1 hours, "stale price");
        return uint256(answer);
    }
}

Strengths:

  • Simple consumer code
  • Aggregated across many off-chain reporters — no single reporter can manipulate
  • Updates happen automatically on price-deviation triggers and time-based triggers
  • Long track record (Chainlink has been live since 2019)

Weaknesses:

  • Updates are batched; the on-chain value may lag the real market
  • Update frequency is tuned to economic thresholds (e.g., "update if 0.5% deviation") — small but sub-threshold moves accumulate
  • L2-deployed feeds may have additional latency
  • Consumer pays no per-read cost, but the protocol bears costs at the system level (relayer subsidies, etc.)

When to use: Most DeFi protocols where the lag is acceptable and the cost model fits. Chainlink is the default for assets with established feeds.

Pull-Based (Pyth Network, others)

Reporters maintain a continuous data stream off-chain. Anyone can submit a signed price update to the on-chain contract on demand. The user pays a small fee to "pull" the latest price into their transaction.

import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";

contract PullOracleConsumer {
    IPyth public immutable pyth;
    bytes32 public immutable priceId;

    constructor(address _pyth, bytes32 _priceId) {
        pyth = IPyth(_pyth);
        priceId = _priceId;
    }

    function doSomethingWithPrice(bytes[] calldata pythUpdateData) external payable {
        // Update price by submitting fresh signed data
        uint256 fee = pyth.getUpdateFee(pythUpdateData);
        pyth.updatePriceFeeds{value: fee}(pythUpdateData);

        // Now read the just-updated price
        PythStructs.Price memory price = pyth.getPriceNoOlderThan(priceId, 60);
        // ... use price ...
    }
}

Strengths:

  • Fresh prices: the user submits the latest signed quote at transaction time
  • Wide asset coverage (Pyth covers many assets not in major Chainlink feeds)
  • Cost born by the consumer at the point of need

Weaknesses:

  • Consumer code is more complex (must pass update data)
  • Adds attack surface: the user controls what update data is submitted, and an attacker might choose old data favorable to their position (the getPriceNoOlderThan check is meant to prevent this; tuning the threshold is critical)
  • Less battle-tested than Chainlink for high-value liquidation logic

When to use: Protocols needing fresher prices, assets without Chainlink feeds, or designs where per-call costs are acceptable.

Hybrid Designs

Many protocols use both — Chainlink as the primary price for liquidations (where stale-but-aggregated is safer) and Pyth for time-sensitive actions like opening positions (where fresh data matters). The choice should be explicit in the protocol's design documents.

TWAPs (Time-Weighted Average Prices)

Where the protocol cannot avoid using on-chain DEX prices as an oracle source — for example, because the asset has no Chainlink feed — TWAPs are the primary defense.

A TWAP computes the average price over a window. To manipulate a 30-minute TWAP, an attacker must sustain the manipulation for 30 minutes — far more expensive than manipulating a single block.

Uniswap V2 TWAP

import "@uniswap/v2-periphery/contracts/libraries/UniswapV2OracleLibrary.sol";

contract V2TWAPOracle {
    address public immutable pair;
    uint256 public immutable PERIOD = 30 minutes;
    uint256 public price0CumulativeLast;
    uint256 public price1CumulativeLast;
    uint32 public blockTimestampLast;

    function update() external {
        (uint256 price0Cumulative, uint256 price1Cumulative, uint32 blockTimestamp) =
            UniswapV2OracleLibrary.currentCumulativePrices(pair);
        uint32 timeElapsed = blockTimestamp - blockTimestampLast;
        require(timeElapsed >= PERIOD, "PERIOD not elapsed");

        // Compute TWAP based on cumulative price difference
        price0Average = uint224((price0Cumulative - price0CumulativeLast) / timeElapsed);
        price1Average = uint224((price1Cumulative - price1CumulativeLast) / timeElapsed);

        price0CumulativeLast = price0Cumulative;
        price1CumulativeLast = price1Cumulative;
        blockTimestampLast = blockTimestamp;
    }
}

Strengths:

  • No off-chain dependency; runs entirely on-chain
  • Manipulation requires sustained capital across the TWAP window
  • Decentralized — no single reporter

Weaknesses:

  • Lag: the price always trails the spot market by up to the TWAP window
  • Manipulable if the AMM's liquidity is thin enough that even sustained pressure is affordable
  • V2 TWAPs require manual updates (incentivize callers)

Uniswap V3 Geometric Mean Oracle

Uniswap V3 made TWAPs first-class: the pool itself records price observations, and callers can query a TWAP over any window from a single function call.

import "@uniswap/v3-periphery/contracts/libraries/OracleLibrary.sol";

contract V3TWAPOracle {
    address public immutable pool;
    uint32 public immutable twapWindow = 1800; // 30 min

    constructor(address _pool) {
        pool = _pool;
        // Note: pool must have enough observation cardinality to support window
    }

    function getTWAP() external view returns (uint256 priceX96) {
        (int24 tick, ) = OracleLibrary.consult(pool, twapWindow);
        return OracleLibrary.getQuoteAtTick(tick, 1e18, token0, token1);
    }
}

The pool stores observations in a ring buffer. The cardinality of the buffer determines the maximum TWAP window. New pools start with cardinality 1 — calling increaseObservationCardinalityNext is required to support meaningful TWAP windows. A protocol relying on a V3 TWAP must ensure cardinality is sufficient before treating the TWAP as reliable.

TWAP Window Sizing

The right TWAP window depends on:

  • Asset liquidity. Thinly-traded pairs need longer windows to make manipulation expensive.
  • Use case sensitivity. Liquidations should use longer windows than fee calculations.
  • Acceptable lag. Longer windows = staler prices.

Rough industry defaults:

  • Major pairs (ETH/USDC, WBTC/ETH): 15-30 minutes typical
  • Mid-cap assets: 30-60 minutes
  • Long-tail assets: 1 hour or longer; may be inadequate even then

For long-tail or thinly-traded assets, on-chain TWAPs alone may not be sufficient defense. The protocol should consider not listing those assets, or combining TWAP with off-chain attestation.

Designing the Sanity Layer

Beyond the primary oracle source, every consumer should implement a sanity layer that rejects implausible readings. The layer is cheap, catches integration bugs, and limits damage from oracle failures.

contract SanityCheckedOracle {
    AggregatorV3Interface public immutable primaryFeed;
    uint256 public immutable maxStalenessSec;
    uint256 public immutable minReasonablePrice;
    uint256 public immutable maxReasonablePrice;

    error PriceStale(uint256 updatedAt, uint256 maxAge);
    error PriceOutOfBounds(uint256 price, uint256 min, uint256 max);
    error PriceNonPositive(int256 price);

    function getPrice() external view returns (uint256 price) {
        (, int256 answer, , uint256 updatedAt, ) = primaryFeed.latestRoundData();

        if (answer <= 0) revert PriceNonPositive(answer);
        if (block.timestamp - updatedAt > maxStalenessSec) {
            revert PriceStale(updatedAt, maxStalenessSec);
        }

        price = uint256(answer);
        if (price < minReasonablePrice || price > maxReasonablePrice) {
            revert PriceOutOfBounds(price, minReasonablePrice, maxReasonablePrice);
        }
    }
}

The bounds check is often skipped because "the oracle will never return an unreasonable value." This belief is wrong. Chainlink's MIM/USD feed reported a price 100x off the real value during a brief incident in 2022. Various other feeds have had similar incidents. A simple bounds check would have been a no-cost defense.

The right way to set bounds: identify the range the asset has ever traded in (including extremes), then set the bounds outside that range with a margin. The bounds should be triggerable in real catastrophic moves but not in normal market activity.

Cross-Source Deviation Checks

For higher-stakes integrations, comparing two independent oracles catches when either has an isolated failure:

contract DualOracle {
    AggregatorV3Interface public immutable chainlinkFeed;
    IUniswapV3Pool public immutable uniswapPool;
    uint256 public immutable maxDeviationBps = 500; // 5%

    function getPrice() external view returns (uint256) {
        uint256 chainlinkPrice = _getChainlinkPrice();
        uint256 uniswapTWAP = _getUniswapTWAP();

        uint256 deviation = chainlinkPrice > uniswapTWAP
            ? ((chainlinkPrice - uniswapTWAP) * 10_000) / uniswapTWAP
            : ((uniswapTWAP - chainlinkPrice) * 10_000) / chainlinkPrice;

        require(deviation <= maxDeviationBps, "oracle deviation too large");

        // Return Chainlink as primary, but only if it matches Uniswap
        return chainlinkPrice;
    }
}

When the two sources disagree by more than the threshold, the call reverts. This converts a silent oracle failure into a visible one. The protocol pauses (or its operations selectively fail) until the discrepancy resolves; this is preferable to acting on incorrect data.

The deviation threshold is the security parameter. Too tight and normal market volatility causes false alarms; too loose and significant manipulations slip through. 1-5% deviation thresholds are common; higher-volatility assets need wider thresholds.

Circuit Breakers

For protocols where oracle failures could cause substantial damage even briefly, circuit breakers stop operations when anomalies are detected. The pattern:

contract OracleCircuitBreaker {
    uint256 public lastValidPrice;
    uint256 public maxDeviationBps = 1000; // 10% per update

    bool public paused;
    address public guardian;

    function getPrice() external view returns (uint256) {
        require(!paused, "oracle paused");
        // ... fetch price as above ...
    }

    function _validatePriceMovement(uint256 newPrice) internal {
        if (lastValidPrice == 0) {
            lastValidPrice = newPrice;
            return;
        }

        uint256 diff = newPrice > lastValidPrice
            ? newPrice - lastValidPrice
            : lastValidPrice - newPrice;
        uint256 maxAllowed = (lastValidPrice * maxDeviationBps) / 10_000;

        if (diff > maxAllowed) {
            paused = true;
            emit CircuitBreakerTripped(lastValidPrice, newPrice);
            return; // reverts will follow on subsequent reads
        }

        lastValidPrice = newPrice;
    }

    function unpause() external {
        require(msg.sender == guardian, "not guardian");
        paused = false;
    }
}

The breaker tracks the last-known-good price and flags large single-step deviations as suspicious. Recovery requires manual intervention — a guardian (multisig, timelock, etc.) unpauses after verifying the situation.

The tradeoff: circuit breakers convert manipulation events into denial-of-service events. The protocol stops working; users cannot withdraw, borrow, liquidate. This is sometimes the right trade — better to halt than to release funds against bad data — but it concentrates risk in the guardian and introduces an operational burden. Protocols should design recovery procedures explicitly, not assume the guardian will be available and decisive.

Oracle Manipulation Mitigations Beyond the Oracle

Some protocol-level patterns reduce oracle exposure without changing the oracle:

Delayed Settlement

Liquidations or other oracle-dependent actions trigger a queued operation that executes after a delay. During the delay, the queue is visible; legitimate observers can cancel the action if the oracle reading was anomalous.

mapping(bytes32 => uint256) public queuedActionEarliestExec;
uint256 public liquidationDelay = 10 minutes;

function queueLiquidation(address user) external {
    uint256 currentPrice = oracle.getPrice();
    require(_isLiquidatable(user, currentPrice), "not liquidatable");

    bytes32 key = keccak256(abi.encode("liquidate", user, currentPrice));
    queuedActionEarliestExec[key] = block.timestamp + liquidationDelay;
}

function executeLiquidation(address user, uint256 quotedPrice) external {
    bytes32 key = keccak256(abi.encode("liquidate", user, quotedPrice));
    require(queuedActionEarliestExec[key] != 0, "not queued");
    require(block.timestamp >= queuedActionEarliestExec[key], "delay not met");

    uint256 currentPrice = oracle.getPrice();
    require(currentPrice == quotedPrice, "price changed");

    _executeLiquidation(user);
}

This works for protocols where the action's effectiveness is not time-critical. For DEX trades or arbitrage-sensitive ops, the delay would harm legitimate users.

Multiple Confirmation Windows

For an oracle-dependent action to execute, multiple oracle readings over time must all agree the action is appropriate. A single bad reading is insufficient.

Position Size Limits

Cap the maximum value any single position can have. A flash-loan-equipped attacker still cannot extract more than the cap from a manipulation. Section 3.7.5 covers this pattern in defensive patterns terms.

Auction-Style Liquidations

Instead of liquidating at the oracle price, run a brief Dutch auction. Liquidators bid; the auction discovers the real market price. The oracle's role is reduced to "identify positions that need attention," not "determine the price at which they are settled."

What Has Been Tried That Doesn't Work

Several patterns sound like oracle defenses but provide little real protection. Worth being explicit about them:

1. "Just check the on-chain DEX price." This was bZx's design. As Section 3.10.3 documents, single-block DEX prices are manipulable for the cost of a flash loan plus a slippage premium. This pattern was a leading cause of DeFi losses in 2020-2022.

2. "Use the median of N DEX prices." Same fundamental flaw if N is small and an attacker can manipulate multiple DEXes within a transaction. The flash loan capital required scales but remains in the millions, not billions.

3. "Use a single trusted oracle (e.g., the team posts prices)." This is just a trusted entity with the responsibility laundered through code. The trust assumption is the same as posting prices manually. Acceptable for testnet or experimental protocols; not for production.

4. "Make the oracle expensive to query." Adds friction but does not change the security model. An attacker who can extract $10M by manipulating the oracle will spend $10K to query it once.

5. "Average several blocks of spot prices." Better than a single block, but blocks-level averaging is still cheap to manipulate. Real TWAPs require time-weighted, not block-count-weighted, averaging.

Practical Checklist

For a protocol integrating an oracle:

  • Primary oracle source chosen and justified (Chainlink, Pyth, internal TWAP, etc.)
  • Update frequency and staleness threshold explicitly set; staleness threshold reverts on stale data
  • Bounds check on price values (minReasonablePrice ≤ price ≤ maxReasonablePrice)
  • Secondary source for cross-deviation check (where stakes warrant)
  • Deviation threshold tuned to asset's normal volatility
  • Behavior when oracle is unavailable explicitly designed (revert? use last known? pause?)
  • Circuit breaker for sustained anomalies (where stakes warrant)
  • Guardian / pause mechanism specified for manual intervention
  • Recovery procedure documented (who unpauses, under what conditions, with what review)
  • TWAP window size justified against asset's liquidity profile (if TWAP used)
  • V3 pool's observation cardinality verified sufficient (if Uniswap V3 TWAP used)
  • Flash-loan-equipped adversary considered in threat model
  • Tests cover oracle returning extreme values, stale values, and zero values
  • Tests cover the full liquidation / settlement flow under oracle failure modes

A protocol that checks every box has done the substantial oracle work. A protocol that checks only the first three has done the minimum.

Cross-References

  • Oracle manipulation as a vulnerability class — Section 3.8.5 covers the failure modes catalogued here
  • bZx historical case — Section 3.10.3 illustrates the on-chain spot price failure mode
  • Euler Finance case — Section 3.10.8 illustrates flash-loan-amplified liquidation logic, related to but distinct from oracle manipulation
  • Defensive patterns — Section 3.7.5 covers rate limits, pause mechanisms, and circuit breakers as constructive patterns
  • Flash loans — Section 3.11.4 covers flash loans as a capital primitive that amplifies oracle attacks
  • MEV — Section 3.11.3 covers ordering-based attacks that compose with oracle manipulation
  • Chainlink documentation — for current asset feed addresses, deviation thresholds, and heartbeat intervals: https://docs.chain.link/data-feeds
  • Pyth documentation — for current asset coverage and update mechanics: https://docs.pyth.network