L2 Sequencer and Stale Oracle Prices Bug

lopotras
6 min readJun 16, 2023

--

In recent months one bug started being quite popular in auditing competitions for protocols using Chainlink oracle prices, which deploy their contracts on L2s — missing sequencer uptime check.

Let’s break it down step-by-step to make sure it’s crystal clear!

Layer-2s and Optimistic Rollups

A layer-2 (L2) is a name for a solution built on top of an existing blockchain network (layer-1), which is aimed to enhance the capabilities of the underlying protocol. Some of potential improvements a layer-2 can bring is higher transaction throughput and smaller fees.

To further explain let’s focus on one of existing Ethereum L2s — Arbitrum.

To scale Ethereum, Arbitrum uses what is called Optimistic Rollups. It means that on Arbitrum transactions happen independently from L1, but they are batched together and put on the Mainnet, assuming that everything on the networks runs according to the rules. Transaction batches put on Ethereum are referred to as rollups and you can imagine them as checkpoints.

In case a violation of the rules occur, it can be disputed on L1 and, after fraud will be proved, invalid claim is disregarded and malicious party — financially penalized.

Sequencer

Under normal conditions the party responsible for ordering incoming transactions, issuing immediate (soft) receipts to users and submitting transaction batches to the underlying L1 is called a Sequencer.

A Sequencer is essentially an Arbitrum full node, physically running somewhere and as such it can go offline.

But worry not, there is an emergency way to submit transactions to the L2 network, prepared just for such a case: a delayed Inbox.

Delayed Inbox

To force their transaction onto Arbitrum in case of Sequencer downtime, users can submit their L2 message via L1 into a delayed Inbox. It will require the message to stay there for roughly 24 hours, before being eligible to be manually included to the L2 network by using SequencerInbox’s forceInclusion method.

Now that we have the necessary background, let’s dive into the problem!

Oracle Prices and the Bug

Potential vulnerability is present for protocol which use Oracle Price data on L2 without checking the sequencer uptime feed, as it is generally recommended.

As Oracles on blockchains are contracts which need to be updated via transactions, if the Sequencer becomes unavailable, the Oracle feeds on particular L2 will stop being updated and become stale.

If Sequencer’s downtime exceeds the delay time implemented on the emergency procedure to submit transactions to the network, users are theoretically able to use stale prices to execute transactions.

The following graph shows such a situation:

Let’s take the ETH/USD Chainlink Oracle. It has two trigger parameters: Deviation threshold and Heartbeat. That means that the price feed will update if the price moves by at least 0.5% or, at latest, every hour (Heartbeat for ETH/USD price feed).

In case of situation shown on the graph, after Oracle update to version x Sequencer goes down. The ETH/USD pair starts going through a rapid price movement triggering price feed updates, but they do not go onto the the L2 network — the last version there is still x.

Alice spots her opportunity and via delayed inbox she send a transaction to borrow funds on a lending protocol, using ETH as collateral. Her ETH will still be valued at price relevant to Oracle version x, while the current version is x+2.

Delay time passes, Sequencer is still down, Alice’s transaction is accepted through forceInclusion, allowing her to borrow more assets than she should be given the current ETH price.

Solution

Luckily the solution is quite simple: implement Sequencer Uptime check, before getting Oracle data. Example of such check is provided by Chainlink on their docs:

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

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

/**
* THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
* THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
* DO NOT USE THIS CODE IN PRODUCTION.
*/

contract DataConsumerWithSequencerCheck {
AggregatorV2V3Interface internal dataFeed;
AggregatorV2V3Interface internal sequencerUptimeFeed;

uint256 private constant GRACE_PERIOD_TIME = 3600;

error SequencerDown();
error GracePeriodNotOver();

/**
* Network: Optimism Goerli testnet
* Data Feed: BTC/USD
* Data Feed address: 0xC16679B963CeB52089aD2d95312A5b85E318e9d2
* Uptime Feed address: 0x4C4814aa04433e0FB31310379a4D6946D5e1D353
* For a list of available Sequencer Uptime Feed proxy addresses, see:
* https://docs.chain.link/docs/data-feeds/l2-sequencer-feeds
*/
constructor() {
dataFeed = AggregatorV2V3Interface(
0xC16679B963CeB52089aD2d95312A5b85E318e9d2
);
sequencerUptimeFeed = AggregatorV2V3Interface(
0x4C4814aa04433e0FB31310379a4D6946D5e1D353
);
}

// Check the sequencer status and return the latest data
function getLatestData() public view returns (int) {
// prettier-ignore
(
/*uint80 roundID*/,
int256 answer,
uint256 startedAt,
/*uint256 updatedAt*/,
/*uint80 answeredInRound*/
) = sequencerUptimeFeed.latestRoundData();

// Answer == 0: Sequencer is up
// Answer == 1: Sequencer is down
bool isSequencerUp = answer == 0;
if (!isSequencerUp) {
revert SequencerDown();
}

// Make sure the grace period has passed after the
// sequencer is back up.
uint256 timeSinceUp = block.timestamp - startedAt;
if (timeSinceUp <= GRACE_PERIOD_TIME) {
revert GracePeriodNotOver();
}

// prettier-ignore
(
/*uint80 roundID*/,
int data,
/*uint startedAt*/,
/*uint timeStamp*/,
/*uint80 answeredInRound*/
) = dataFeed.latestRoundData();

return data;
}
}

Let’s break it down!

We can see that first in the constructor 2 data sources are defined:

constructor() {
dataFeed = AggregatorV2V3Interface(
0xC16679B963CeB52089aD2d95312A5b85E318e9d2
);
sequencerUptimeFeed = AggregatorV2V3Interface(
0x4C4814aa04433e0FB31310379a4D6946D5e1D353
);
}

Next in the getLatestData() first call is made to the Sequencer uptime feed:

// Check the sequencer status and return the latest data
function getLatestData() public view returns (int) {
// prettier-ignore
(
/*uint80 roundID*/,
int256 answer,
uint256 startedAt,
/*uint256 updatedAt*/,
/*uint80 answeredInRound*/
) = sequencerUptimeFeed.latestRoundData();

If the Sequencer is down the transaction will revert:

// Answer == 0: Sequencer is up
// Answer == 1: Sequencer is down
bool isSequencerUp = answer == 0;
if (!isSequencerUp) {
revert SequencerDown();
}

After that there’s a check to make sure that since the Sequencer has been back up enough time has passed to consider the Oracle feed up to date:

// Make sure the grace period has passed after the
// sequencer is back up.
uint256 timeSinceUp = block.timestamp - startedAt;
if (timeSinceUp <= GRACE_PERIOD_TIME) {
revert GracePeriodNotOver();
}

Finally after all checks have passed, the price feed is called and received data returned:

(
/*uint80 roundID*/,
int data,
/*uint startedAt*/,
/*uint timeStamp*/,
/*uint80 answeredInRound*/
) = dataFeed.latestRoundData();

return data;

Implementing this process will ensure that users cannot execute transactions on L2 using stale Oracle prices, as they will be reverted during Sequencer downtime.

That’s a wrap for today!

Relevant resources:

If you liked the content check out what else I’ve got in store for you on Twitter and Medium.

Until next time! 😉

--

--

lopotras

Web3 Builder | Sharing my journey in On-chain Security