The rollup node is the component responsible for deriving the L2 chain from L1 blocks (and their associated receipts). This process happens in three steps:
- Select a sequencing window from the L1 chain, on top of the last L2 block: a list of blocks, with transactions and associated receipts.
- Read L1 information, deposits, and sequencing batches in order to generate payload attributes (essentially a block without output properties).
- Pass the payload attributes to the execution engine, so that the L2 block (including output block properties) may be computed.
While this process is conceptually a pure function from the L1 chain to the L2 chain, it is in practice incremental. The L2 chain is extended whenever new L1 blocks are added to the L1 chain. Similarly, the L2 chain re-organizes whenever the L1 chain re-organizes.
The part of the rollup node that derives the L2 chain is called the rollup driver. This document is currently only concerned with the specification of the rollup driver.
Table of Contents
- L2 Chain Derivation
- Handling L1 Re-Orgs
This section specifies how the rollup driver derives a sequence of L2 blocks per sequencing window.
Every L2 block carries transactions of two categories:
- deposited transactions: two kinds:
- derived from the L1 chain: a single L1 attributes deposited transaction (always first).
- derived from receipts: zero or more user-deposited transactions.
- sequenced transactions: derived from sequencer batches, zero or more regular transactions, signed by L2 users.
A sequencing window is a fixed number consecutive L1 blocks that a derivation step takes as
input. The window is identified by an epoch
, equal to the block number of the first block in the window.
The derivation of the L2 chain from the L1 chain happens in steps.
Each step adds a variable number of L2 blocks to the L2 chain, derived from the sequencing window for the given epoch.
For epoch N
, the sequencing window comprises L1 blocks [N, N + SEQUENCING_WINDOW_SIZE)
.
Note that the sequencing windows overlap.
The rollup reads the following data from the sequencing window:
- Of the first block in the window only:
- L1 block attributes:
- block number
- timestamp
- basefee
- random (the output of the
RANDOM
opcode)
- L1 log entries emitted for user deposits, augmented with a sourceHash.
- L1 block attributes:
- Of each block in the window:
- Sequencer batches, derived from the transactions:
- The transaction receiver is the sequencer inbox address
- The transaction must be signed by a recognized sequencer account
- The calldata may contain a bundle of batches. (calldata will be substituted with blob data in the future.)
- Batches not matching filter criteria are ignored:
batch.epoch == sequencing_window.epoch
, i.e. for this sequencing window(batch.timestamp - genesis_l2_timestamp) % block_time == 0
, i.e. timestamp is alignedmin_l2_timestamp <= batch.timestamp < max_l2_timestamp
, i.e. timestamp is within rangemin_l2_timestamp = prev_l2_timestamp + l2_block_time
prev_l2_timestamp
is the timestamp of the previous L2 block: the last block of the previous epoch, or the L2 genesis block timestamp if there is no previous epoch.l2_block_time
is a configurable parameter of the time between L2 blocks
max_l2_timestamp = max(l1_timestamp + max_sequencer_drift, min_l2_timestamp + l2_block_time)
l1_timestamp
is the timestamp of the L1 block associated with the L2 block's epochmax_sequencer_drift
is the most a sequencer is allowed to get ahead of L1
- The batch is the first batch with
batch.timestamp
in this sequencing window, i.e. one batch per L2 block number. - The batch only contains sequenced transactions, i.e. it must NOT contain any Deposit-type transactions.
- Sequencer batches, derived from the transactions:
Note that after the above filtering min_l2_timestamp >= l1_timestamp
always holds,
i.e. a L2 block timestamp is always equal or ahead of the timestamp of the corresponding L1 origin block.
A bundle of batches is versioned by prefixing with a bundle version byte: bundle = bundle_version ++ bundle_data
.
Bundle versions:
0
:bundle_data = RLP([batch_0, batch_1, ..., batch_N])
1
:bundle_data = compress(RLP([batch_0, batch_1, ..., batch_N]))
(compression algorithm TBD)
A batch is also versioned by prefixing with a version byte: batch = batch_version ++ batch_data
and encoded as a byte-string (including version prefix byte) in the bundle RLP list.
Batch versions:
0
:batch_data = RLP([epoch, timestamp, transaction_list])
, where each
Batch contents:
epoch
is the sequencing window epoch, i.e. the first L1 block numbertimestamp
is the L2 timestamp of the blocktransaction_list
is an RLP encoded list of EIP-2718 encoded transactions.
The L1 attributes are read from the L1 block header, while deposits are read from the block's receipts. Refer to the deposit contract specification for details on how deposits are encoded as log entries. The deposited and sequenced transactions are combined when the Payload Attributes are constructed.
The L1 attributes deposited transaction is a call that submits the L1 block attributes (listed above) to the L1 attributes predeployed contract.
To encode the L1 attributes deposited transaction, refer to the following sections of the deposits spec:
A user-deposited-transactions is an L2 transaction derived from a user deposit submitted on L1 to the deposit contract. Refer to the deposit contract specification for more details.
The user-deposited transaction is derived from the log entry emitted by the depositing call, which is stored in the depositing transaction's log receipt.
To encode user-deposited transactions, refer to the following sections of the deposits spec:
A sequencing window is derived into a variable number of L2 blocks, defined by a range of timestamps:
- Starting at
min_l2_timestamp
, as defined in the batch filtering. - Up to and including
new_head_l2_timestamp = max(highest_valid_batch_timestamp, next_l1_timestamp - l2_block_time, min_l2_timestamp)
highest_valid_batch_timestamp = max(batch.timestamp for batch in filtered_batches)
, or0
if no there are nofiltered_batches
.batch.timestamp
refers to the L2 block timestamp encoded in the batch.next_l1_timestamp
is the timestamp of the next L1 block.
The L2 chain is extended to new_head_l2_timestamp
with blocks at a fixed block time (l2_block_time
).
This means that every l2_block_time
that has no batch is interpreted as one with no sequenced transactions.
Each of the derived PayloadAttributes
starts with a L1 Attributes transaction.
Like other derived deposits, this does not have to be batch-submitted, and exposes the required L1 information for the
process of finding the sync starting point of the L2 chain, without requiring L2 state access.
The [User-deposited] transactions are all put in the first of the derived PayloadAttributes
,
inserted after the L1 Attributes transaction, before any sequenced transactions.
From the timestamped transaction lists derived from the sequencing window, the rollup node constructs payload
attributes as an expanded version of the PayloadAttributesV1
object, which
includes the additional transactions
and noTxPool
fields.
Each of the timestamped transaction lists translates to a PayloadAttributesV1
as follows:
timestamp
is set to the timestamp of the transaction list.random
is set to the randomexecution_payload.prev_randao
L1 block attributesuggestedFeeRecipient
is set to an address determined by the systemtransactions
is the array of the derived transactions: deposited transactions and sequenced transactions. All encoded with EIP-2718.noTxPool
is set totrue
, to use the exact abovetransactions
list when constructing the block.
Once the payload attributes for a given L1 block B
have been built, and if we have already derived an L2 block from
B
's parent block, then we can use the payload attributes to derive a new L2 block.
Let
refL2
be the (hash of) the current L2 chain headrefL1
be the (hash of) the L1 block from whichrefL2
was derivedpayloadAttributes
be some previously derived payload attributes for the L1 block with numberl1Number(refL1) + 1
Then we can apply the following pseudocode logic to update the state of both the rollup driver and execution engine:
// request a new execution payload
forkChoiceState = {
headBlockHash: refL2,
safeBlockHash: refL2,
finalizedBlockHash: l2BlockHashAt(l2Number(refL2) - FINALIZATION_DELAY_BLOCKS)
}
[status, payloadID] = engine_forkchoiceUpdatedV1(forkChoiceState, payloadAttributes)
if (status != "SUCCESS") error()
// retrieve and execute the execution payload
[executionPayload, error] = engine_getPayloadV1(payloadID)
if (error != null) error()
[status, latestValidHash, validationError] = engine_executePayloadV1(executionPayload)
if (status != "VALID" || validationError != null) error()
refL2 = latestValidHash
refL1 = l1HashForNumber(l1Number(refL1) + 1))
// update head to new refL2
forkChoiceState = {
headBlockHash: refL2,
safeBlockHash: refL2,
finalizedBlockHash: l2BlockHashAt(l2Number(headBlockHash) - FINALIZATION_DELAY_BLOCKS)
}
[status, payloadID] = engine_forkchoiceUpdatedV1(refL2, null)
if (status != "SUCCESS") error()
The following JSON-RPC methods are part of the execution engine API:
TODO fortify the execution engine spec with more information regarding JSON-RPC, notably covering information found here and here
engine_forkchoiceUpdatedV1
— updates the forkchoice (i.e. the chain head) toheadBlockHash
if different, and instructs the engine to start building an execution payload given payload attributes the second argument isn'tnull
engine_getPayloadV1
— retrieves a previously requested execution payloadengine_executePayloadV1
— executes an execution payload to create a block
The execution payload is an object of type ExecutionPayloadV1
.
Within the forkChoiceState
object, the properties have the following meaning:
headBlockHash
: block hash of the last block of the L2 chain, according to the rollup driver.safeBlockHash
: same asheadBlockHash
.finalizedBlockHash
: the hash of the block whose number isl2Number(headBlockHash) - FINALIZATION_DELAY_BLOCKS
if the number of that block is>= L2_CHAIN_INCEPTION
, 0 otherwise (*) See the Finalization Guarantees section for more details.
(*) where:
FINALIZATION_DELAY_BLOCKS == 50400
(approximately 7 days worth of L1 blocks)L2_CHAIN_INCEPTION
is the L2 chain inception (the number of the first L1 block for which an L2 block was produced).
Finally, the error()
function signals an error that must be handled by the implementation. Refer to the next section
for more details.
All invocations of engine_forkchoiceUpdatedV1
, engine_getPayloadV1
and engine_executePayloadV1
by the
rollup driver should not result in errors assuming conformity with the specification. Said otherwise, all errors are
implementation concerns and it is up to them to handle them (e.g. by retrying, or by stopping the chain derivation and
requiring manual user intervention).
The following scenarios are assimilated to errors:
engine_forkchoiceUpdatedV1
returning astatus
of"SYNCING"
instead of"SUCCESS"
whenever passed aheadBlockHash
that it retrieved from a previous call toengine_executePayloadV1
.engine_executePayloadV1
returning astatus
of"SYNCING"
or"INVALID"
whenever passed an execution payload that was obtained by a previous call toengine_getPayloadV1
.
As stated earlier, an L2 block is considered finalized after a delay of FINALIZATION_DELAY_BLOCKS == 50400
L1 blocks
after the L1 block that generated it. This is a duration of approximately 7 days worth of L1 blocks. This is also known
as the "fault proof window", as after this time the block can no longer be challenged by a fault proof.
L1 Ethereum reaches finality approximately every 12.8 minutes. L2 blocks generated from finalized L1 blocksare "safer" than most recent L2 blocks because they will never disappear from the chain's history because of a re-org. However, they can still be challenged by a fault proof until the end of the fault proof window.
TODO the spec doesn't encode the notion of fault proof yet, revisit this (and include links) when it does
The block derivation presents an inductive process: given that we know the last L2 block derived from the previous sequencing window, as well as the next sequencing window, then we can derive payload attributes of the next L2 blocks.
To derive the whole L2 chain from scratch, we simply start with the L2 genesis block as the last L2 block, and the
block at height L2_CHAIN_INCEPTION + 1
as the start of the next sequencing window.
Then we iteratively apply the derivation process from the previous section by shifting the sequencing window one L1
block forward each step, until there is an insufficient number of L1 blocks left for a complete sequencing window.
TODO specify genesis block
The Rollup node has its own RPC method, optimism_outputAtBlock
which returns the
a 32 byte hash corresponding to the L2 output root.
The input and return types here are as defined by the engine API specs).
- method:
optimism_outputAtBlock
- params:
blockNumber
:QUANTITY
, 64 bits - L2 integer block number
ORString
- one of"safe"
,"latest"
, or"pending"
.
- returns:
version
:DATA
, 32 Bytes - the output root version number, beginning with 0.l2OutputRoot
:DATA
, 32 Bytes - the output root
The previous section on L2 chain derivation assumes linear progression of the L1 chain. It is also applicable for batch processing, meaning that any given point in time, the canonical L2 chain is given by processing the whole L1 chain since the L2 chain inception.
If the L1 Chain re-orgs, the rollup node must re-derive sections of the L2 chain such that it derives the same L2 chain that a rollup node would derive if it only followed the new L1 chain.
By itself, the previous section fully specifies the behavior of the rollup driver. The current section is non-specificative but shows how L1 re-orgs can be handled in practice.
In practice, the L1 chain is processed incrementally. However, the L1 chain may occasionally re-organize, meaning the head of the L1 chain changes to a block that is not the child of the previous head but rather another descendant of an ancestor of the previous head. In that case, the rollup driver must first search for the common L1 ancestor, and can re-derive the L2 chain from that L1 block and onward.
The rollup node maintains two heads of the L2 Chain: the unsafe head (often called head) and the safe head.
Each L2 block has an L1 origin block (corresponding to its epoch) that it references in the
L1 attributes deposited transaction. The unsafe head is the head of the L2 chain.
Its L1 origin block should be canonical or potentially extending the canonical chain
(if the rollup node has not yet seen the L1 block that it is based upon).
The safe head is the the last L2 block of the last epoch whose sequencing window is complete
(i.e. the epoch with number L1Head.number
- SEQUENCING_WINDOW_SIZE
).
Steps during a reorg:
- Set "unsafe head" to equal the l2 head we retrieved, just as default
- Set "latest block" to equal the l2 head we retrieved, also just as default
- Walk back L2, and stop until block.l1Origin is found AND canonical, and update "latest block" to this block. And don't override "unsafe head" if it's not found, but do override it when block.l1Origin does not match the canonical L1 block at that height.
- Walk back L2 from the "latest block" until a full sequencing window of L1 blocks has been passed. This is the "safe block".
The purpose of this is to ensure that if the sequencing window for a L2 block has changed since it was derived, that L2 block is re-derived.
The first L1 block of the sequencing window is the L1 attributes for that L2 block. The end of the sequencing
window is the canonical L1 block whose number is SEQUENCING_WINDOW
larger than the start. The end of the
window must be selected by number otherwise the sequencer would not be able to create batches. The problem
with selecting the end of the window by number is that when an L1 reorg occurs, the blocks (and thus batches)
in the window could change. We must find the find the first L2 block whose complete sequencing window is
unchanged in the reorg.
When walking back on the L2 chain, care should be taken to not walk past the rollup genesis.
Note that post-merge, the depth of re-orgs will be bounded by the L1 finality delay (every 2 epochs, approximately 12 minutes).
(*) Post-merge, this is only possible for 12 minutes. In practice, we'll pick an already-finalized L1 block as L2 inception point to preclude the possibility of a re-org past genesis, at the cost of a few empty blocks at the start of the L2 chain.