← Back to Blog

Chainlink Oracle Edge Cases: Sequencer Uptime, Min/Max Answer, and Price Feed Pitfalls

2026-04-18 chainlink oracle price feed sequencer l2 min answer solidity security

In May 2022, LUNA collapsed from $80 to fractions of a cent over 72 hours. Protocols using Chainlink's LUNA/USD feed did not receive the real price at the bottom. They received $0.10 — the feed's hard-coded minAnswer floor — while the actual market price was $0.0001. Any protocol that accepted that price to value LUNA collateral was operating on data that was 1,000 times too high.

This was not a Chainlink failure. It was an integration failure. Chainlink's documentation describes the minAnswer and maxAnswer bounds; the protocols that lost money had not accounted for them. The same pattern repeats here: the oracle behaves exactly as documented, and the integrating protocol silently produces wrong outputs because it only checked for staleness — or checked nothing at all.

Staleness validation is table stakes. The real vulnerabilities live in six other places: L2 sequencer status, circuit-breaker bounds, decimal normalization, deprecated function usage, signed integer casting, and single-source dependency. Each one can cause fund loss independently.


1. L2 Sequencer Uptime Feed Not Checked

On Optimism, Arbitrum, and Base, Chainlink price feeds are updated by transactions the L2 sequencer posts to the chain. If the sequencer goes offline, no new updates reach the chain and stale prices persist. Protocols that check updatedAt against a staleness window will correctly reject these prices — but only if the threshold is tight enough.

The subtler problem is sequencer restart. When the sequencer comes back online, it replays queued transactions at once and price feeds jump to current values immediately. Pending liquidations and other time-sensitive operations accumulated during downtime then execute against suddenly-updated prices. A grace period after restart is required before accepting oracle data.

// VULNERABLE: no sequencer check on L2
contract LendingProtocol {
    AggregatorV3Interface public priceFeed;

    function getPrice() external view returns (uint256) {
        (, int256 answer, , uint256 updatedAt,) = priceFeed.latestRoundData();
        require(block.timestamp - updatedAt <= 3600, "stale price");
        require(answer > 0, "invalid price");
        return uint256(answer);
    }
}

During a sequencer outage, updatedAt is frozen at the last pre-outage update. If the outage is shorter than the staleness window, getPrice() returns stale data with no error. Liquidations, borrows, and withdrawals proceed against prices that could be hours old.

// FIXED: sequencer uptime check with grace period
contract LendingProtocolFixed {
    AggregatorV3Interface public priceFeed;
    AggregatorV2V3Interface public sequencerUptimeFeed;
    uint256 public constant GRACE_PERIOD = 3600; // 1 hour after restart
    uint256 public constant MAX_STALENESS = 3600;

    function getPrice() external view returns (uint256) {
        // Check sequencer status first
        (, int256 sequencerAnswer, uint256 startedAt,,) =
            sequencerUptimeFeed.latestRoundData();

        bool isSequencerUp = sequencerAnswer == 0;
        require(isSequencerUp, "sequencer offline");

        // Reject prices during grace period after restart
        uint256 timeSinceRestart = block.timestamp - startedAt;
        require(timeSinceRestart > GRACE_PERIOD, "grace period active");

        (, int256 answer, , uint256 updatedAt,) = priceFeed.latestRoundData();
        require(block.timestamp - updatedAt <= MAX_STALENESS, "stale price");
        require(answer > 0, "invalid price");
        return uint256(answer);
    }
}

Chainlink maintains sequencer uptime feeds for Arbitrum, Optimism, Base, and Metis. The feed returns 0 when the sequencer is up and 1 when it is down. startedAt marks when the current status began, doubling as the restart timestamp when status is 0.


2. Answer == minAnswer or maxAnswer (Circuit Breaker)

Every Chainlink aggregator has two hard-coded constants: minAnswer and maxAnswer. If the reported price would fall below minAnswer, the aggregator returns minAnswer. If it would exceed maxAnswer, the aggregator returns maxAnswer. The real market price is not returned.

The bounds guard against obviously incorrect data from malfunctioning nodes — but they create a dangerous blind spot: the protocol cannot distinguish between a price that genuinely equals minAnswer and one that has been floored there because the real value is far lower.

// VULNERABLE: no check against circuit breaker bounds
function getTokenPrice() external view returns (uint256) {
    (, int256 answer, , uint256 updatedAt,) = priceFeed.latestRoundData();
    require(block.timestamp - updatedAt <= 3600, "stale");
    require(answer > 0, "negative");
    return uint256(answer); // Could be minAnswer during a crash
}

During the LUNA collapse, any protocol using this pattern valued LUNA collateral at $0.10 while the real price was effectively zero. Borrowers could drain reserves by posting worthless LUNA and borrowing assets at the inflated floor.

// FIXED: reject prices at circuit breaker bounds
interface IChainlinkAggregator {
    function minAnswer() external view returns (int192);
    function maxAnswer() external view returns (int192);
    function aggregator() external view returns (address);
}

function getTokenPrice(address feedAddress) external view returns (uint256) {
    AggregatorV3Interface feed = AggregatorV3Interface(feedAddress);
    (, int256 answer, , uint256 updatedAt,) = feed.latestRoundData();

    require(block.timestamp - updatedAt <= 3600, "stale price");
    require(answer > 0, "non-positive price");

    // Retrieve the underlying aggregator bounds
    IChainlinkAggregator aggregator =
        IChainlinkAggregator(IChainlinkAggregator(feedAddress).aggregator());
    int192 minAns = aggregator.minAnswer();
    int192 maxAns = aggregator.maxAnswer();

    require(int192(answer) > minAns, "price at minimum circuit breaker");
    require(int192(answer) < maxAns, "price at maximum circuit breaker");

    return uint256(answer);
}

When answer equals exactly minAnswer, the protocol halts and requires human intervention. This is the correct behavior: the protocol cannot safely operate when the oracle's true signal is unknown.


3. Decimal Mismatch Between Feed and Token

Chainlink price feeds do not all use the same decimal precision. Most USD pairs return 8-decimal prices — ETH/USD, BTC/USD, LINK/USD. Some feeds return 18 decimals. A protocol that hardcodes the assumption of 8 decimals will either wildly overvalue or undervalue assets priced by a different feed.

// VULNERABLE: hardcoded 8-decimal assumption
function getUSDValue(address token, uint256 amount) external view returns (uint256) {
    (, int256 price, , ,) = priceFeeds[token].latestRoundData();
    // Assumes all feeds return 8 decimals — breaks for 18-decimal feeds
    return (amount * uint256(price)) / 1e8;
}

If this function is called with a token whose feed returns 18 decimals, the calculated USD value is divided by 1e8 instead of 1e18, making the asset appear 10^10 times more valuable than it is. A user could deposit a tiny amount of that token and borrow against an astronomical phantom valuation.

// FIXED: normalize to 18 decimals using feed's reported decimals()
function getUSDValue(address token, uint256 amount) external view returns (uint256) {
    AggregatorV3Interface feed = priceFeeds[token];
    (, int256 price, , uint256 updatedAt,) = feed.latestRoundData();

    require(block.timestamp - updatedAt <= 3600, "stale price");
    require(price > 0, "non-positive price");

    uint8 feedDecimals = feed.decimals();
    // Normalize price to 18 decimals
    uint256 normalizedPrice = feedDecimals < 18
        ? uint256(price) * (10 ** (18 - feedDecimals))
        : uint256(price) / (10 ** (feedDecimals - 18));

    // Also normalize token amount to 18 decimals
    uint8 tokenDecimals = IERC20Metadata(token).decimals();
    uint256 normalizedAmount = tokenDecimals < 18
        ? amount * (10 ** (18 - tokenDecimals))
        : amount / (10 ** (tokenDecimals - 18));

    return (normalizedAmount * normalizedPrice) / 1e18;
}

Always call feed.decimals() at integration time and normalize before arithmetic. Do not cache the decimals value across upgrades — feed configurations can change.


4. Using latestAnswer Instead of latestRoundData

latestAnswer() is a legacy function inherited from Chainlink's early AggregatorInterface. It returns a single int256 price with no associated metadata: no round ID, no timestamp, no answeredInRound. This makes staleness validation impossible.

// VULNERABLE: deprecated function, no staleness check possible
contract PriceOracle {
    AggregatorInterface public feed;

    function getPrice() external view returns (uint256) {
        int256 price = feed.latestAnswer();
        require(price > 0, "invalid price");
        return uint256(price);
    }
}

If the Chainlink node network experiences a disruption, latestAnswer() continues returning the last known value with no indication of when it was posted. A protocol using this pattern can operate on prices that are hours or days stale with no runtime error.

Chainlink officially deprecated latestAnswer() in favor of latestRoundData(). The replacement returns five values that enable complete validation.

// FIXED: latestRoundData with full validation
contract PriceOracleFixed {
    AggregatorV3Interface public feed;
    uint256 public constant MAX_STALENESS = 3600;

    function getPrice() external view returns (uint256) {
        (
            uint80 roundId,
            int256 answer,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = feed.latestRoundData();

        require(answer > 0, "non-positive price");
        require(updatedAt != 0, "incomplete round");
        require(block.timestamp - updatedAt <= MAX_STALENESS, "stale price");
        // answeredInRound < roundId means the answer is from a previous round
        require(answeredInRound >= roundId, "stale round answer");

        return uint256(answer);
    }
}

The answeredInRound >= roundId check catches the edge case where a new round was started but not yet answered — meaning the returned price is from the previous round, which could be significantly older than updatedAt suggests.


5. Assuming Price Is Always Positive

latestRoundData returns int256 answer — a signed integer. Most price feeds for standard assets will never return a negative value in practice, but some synthetic asset feeds, commodity feeds (such as natural gas or certain agricultural products during extreme market conditions), or custom feeds for internal accounting can return zero or negative values. A naive cast to uint256 wraps a negative value to an astronomically large number.

// VULNERABLE: unchecked cast from int256 to uint256
function getCollateralValue(address token, uint256 amount) external view returns (uint256) {
    (, int256 price, , ,) = priceFeed.latestRoundData();
    // If price == -1, uint256(-1) == type(uint256).max — catastrophic overvaluation
    uint256 uPrice = uint256(price);
    return (amount * uPrice) / 1e8;
}

A negative answer cast to uint256 produces 2^256 - |answer|. For answer == -1, that is the maximum possible uint256 value. Any collateral valued with this price appears infinitely valuable, allowing unlimited borrowing.

// FIXED: explicit positivity check before casting
function getCollateralValue(address token, uint256 amount) external view returns (uint256) {
    (
        uint80 roundId,
        int256 answer,
        ,
        uint256 updatedAt,
        uint80 answeredInRound
    ) = priceFeed.latestRoundData();

    require(answer > 0, "price must be positive");
    require(updatedAt != 0, "round not complete");
    require(block.timestamp - updatedAt <= 3600, "stale price");
    require(answeredInRound >= roundId, "stale answer");

    uint256 price = uint256(answer);
    return (amount * price) / (10 ** priceFeed.decimals());
}

require(answer > 0) is a one-line fix with no performance cost. It belongs in every Chainlink integration unconditionally.


6. Oracle Aggregation Without Fallback

Protocols that depend on a single Chainlink feed with no fallback become non-functional when that feed is deprecated, paused, or returns a zero answer. Chainlink has deprecated feeds before — AMPL/ETH and several lesser-used pairs were removed with limited notice. A protocol that bricked during feed deprecation would require an emergency governance vote and upgrade to restore functionality, during which all operations are halted.

// VULNERABLE: single feed, no fallback
function getPrice() external view returns (uint256) {
    (, int256 answer, , uint256 updatedAt,) = primaryFeed.latestRoundData();
    require(answer > 0 && block.timestamp - updatedAt <= 3600, "bad price");
    return uint256(answer);
}

If primaryFeed is deprecated or returns a zero answer, every function that calls getPrice() reverts. Liquidations halt. Withdrawals fail. The protocol is frozen until governance intervention.

// FIXED: multi-source with automatic fallback and deviation guard
contract ResilientOracle {
    AggregatorV3Interface public chainlinkFeed;
    IUniswapV3Pool public uniswapPool;
    uint32 public constant TWAP_PERIOD = 1800; // 30-minute TWAP
    uint256 public constant MAX_STALENESS = 3600;
    uint256 public constant MAX_DEVIATION_BPS = 500; // 5%

    function getPrice() external view returns (uint256 price, bool usingFallback) {
        bool chainlinkValid;
        uint256 chainlinkPrice;

        try chainlinkFeed.latestRoundData() returns (
            uint80 roundId, int256 answer, uint256, uint256 updatedAt, uint80 answeredInRound
        ) {
            chainlinkValid = answer > 0
                && updatedAt != 0
                && block.timestamp - updatedAt <= MAX_STALENESS
                && answeredInRound >= roundId;

            if (chainlinkValid) {
                chainlinkPrice = uint256(answer);
            }
        } catch {
            chainlinkValid = false;
        }

        uint256 twapPrice = _getUniswapTWAP();

        if (!chainlinkValid) {
            return (twapPrice, true);
        }

        // Cross-check: reject if sources diverge more than MAX_DEVIATION_BPS
        uint256 diff = chainlinkPrice > twapPrice
            ? chainlinkPrice - twapPrice
            : twapPrice - chainlinkPrice;
        uint256 deviationBps = (diff * 10000) / chainlinkPrice;

        if (deviationBps > MAX_DEVIATION_BPS) {
            revert("oracle sources diverged");
        }

        return (chainlinkPrice, false);
    }

    function _getUniswapTWAP() internal view returns (uint256) {
        uint32[] memory secondsAgos = new uint32[](2);
        secondsAgos[0] = TWAP_PERIOD;
        secondsAgos[1] = 0;
        (int56[] memory tickCumulatives,) = uniswapPool.observe(secondsAgos);
        int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
        int24 avgTick = int24(tickDelta / int56(uint56(TWAP_PERIOD)));
        return _tickToPrice(avgTick);
    }
}

The try/catch handles the case where the feed contract is removed entirely and calls revert at the EVM level. The deviation check prevents the fallback from silently accepting a manipulated TWAP. The returned boolean indicates which source was used, enabling protocol-level risk gating such as blocking new borrows while on fallback.


What ContractScan Detects

ContractScan performs static and semantic analysis of Chainlink oracle integrations, flagging each of these vulnerability classes without requiring test deployments or manual audit review.

Vulnerability Detection Method Severity
Missing sequencer uptime check Detects L2 deployments using Chainlink feeds without SequencerUptimeFeed call High
minAnswer / maxAnswer not checked Identifies feeds where returned answer is not compared against aggregator bounds Critical
Decimal mismatch Flags hardcoded divisors that don't use feed.decimals() for normalization High
latestAnswer() usage Flags any call to deprecated latestAnswer() and missing updatedAt validation Medium
Unchecked int256 cast Detects uint256(answer) without prior answer > 0 guard High
Single oracle with no fallback Identifies price feed calls with no try/catch and no secondary source High

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →