Table of Contents
- Overview
- Batch Submission
- Architecture
- Deriving Payload Attributes
- Communication with the Execution Engine
- WARNING: BELOW THIS LINE, THE SPEC HAS NOT BEEN REVIEWED AND MAY CONTAIN MISTAKES
- Handling L1 Re-Orgs
Note the following assumes a single sequencer and batcher. In the future, the design will be adapted to accomodate multiple such entities.
L2 chain derivation — deriving L2 blocks from L1 data — is one of the main responsibility of the rollup node, both in validator mode, and in sequencer mode (where derivation acts as a sanity check on sequencing, and enables detecting L1 chain re-organizations).
The L2 chain is derived from the L1 chain. In particular, each L1 block is mapped to an L2 sequencing epoch comprising multiple L2 blocks. The epoch number is defined to be equal to the corresponding L1 block number.
To derive the L2 blocks in an epoch E
, we need the following inputs:
- The L1 sequencing window for epoch
E
: the L1 blocks in the range[E, E + SWS)
whereSWS
is the sequencing window size (note that this means that epochs are overlapping). In particular we need:- The batcher transactions included in the sequencing window. These allow us to
reconstruct sequencer batches containing the transactions to include in L2 blocks (each batch
maps to a single L2 block).
- Note that it is impossible to have a batcher transaction containing a batch relative to epoch
E
on L1 blockE
, as the batch must contain the hash of L1 blockE
.
- Note that it is impossible to have a batcher transaction containing a batch relative to epoch
- The deposits made in L1 block
E
(in the form of events emitted by the deposit contract). - The L1 block attributes from L1 block
E
(to derive the L1 attributes deposited transaction).
- The batcher transactions included in the sequencing window. These allow us to
reconstruct sequencer batches containing the transactions to include in L2 blocks (each batch
maps to a single L2 block).
- The state of the L2 chain after the last L2 block of epoch
E - 1
, or — if epochE - 1
does not exist — the L2 genesis state.- An epoch
E
does not exist ifE <= L2CI
, whereL2CI
is the L2 chain inception.
- An epoch
TODO specify sequencing window size (current thinking: on the order of a few hours, to give maximal flexibility to the batch submitter)
To derive the whole L2 chain from scratch, we simply start with the L2 genesis state, and the L2 chain inception as first epoch, then process all sequencing windows in order. Refer to the Architecture section for more information on how we implement this in practice.
Each epoch may contain a variable number of L2 blocks (one every l2_block_time
, 2s on Optimism), at the discretion of
the sequencer, but subject to the following constraints for each block:
min_l2_timestamp <= block.timestamp < max_l2_timestamp
, where- all these values are denominated in seconds
min_l2_timestamp = prev_l2_timestamp + l2_block_time
prev_l2_timestamp
is the timestamp of the last L2 block of the previous epochl2_block_time
is a configurable parameter of the time between L2 blocks (on Optimism, 2s)
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
TODO specify max sequencer drift (current thinking: on the order of 10 minutes, we've been using 2-4 minutes in testnets)
Put together, these constraints mean that there must be an L2 block every l2_block_time
seconds, and that the
timestamp for the first L2 block of an epoch must never fall behind the timestamp of the L1 block matching the epoch.
Post-merge, Ethereum has a fixed block time of 12s (though some slots can be skipped). It is thus
expected that, most of the time, each epoch on Optimism will contain 12/2 = 6
L2 blocks. The sequencer can however
lengthen or shorten epochs (subject to above constraints). The rationale is to maintain liveness in case of either a
skipped slot on L1, or a temporary loss of connection to L1 — which requires longer epochs. Shorter epochs are then
required to avoid L2 timestamps drifting further and further ahead of L1.
In practice, it is often not necesary to wait for a full sequencing window of L1 blocks in order to start deriving the L2 blocks in an epoch. Indeed, as long as we are able to reconstruct sequential batches, we can start deriving the corresponding L2 blocks. We call this eager block derivation.
However, in the very worst case, we can only reconstruct the batch for the first L2 block in the epoch by reading the last L1 block of the sequencing window. This happens when some data for that batch is included in the last L1 block of the window. In that case, not only can we not derive the first L2 block in the poch, we also can't derive any further L2 block in the epoch until then, as they need the state that results from applying the epoch's first L2 block. (Note that this only applies to block derivation. We can still derive further batches, we just won't be able to create blocks from them.)
The sequencer accepts L2 transactions from users. It is responsible for building blocks out of these. For each such block, it also creates a corresponding sequencer batch. It is also responsible for submitting each batch to a data availability provider (e.g. Ethereum calldata), which it does via its batcher component.
The difference between an L2 block and a batch is subtle but important: the block includes an L2 state root, whereas the batch only commits to transactions at a given L2 timestamp (equivalently: L2 block number). A block also includes a reference to the previous block (*).
(*) This matters in some edge case where a L1 reorg would occur and a batch would be reposted to the L1 chain but not the preceding batch, whereas the predecessor of an L2 block cannot possibly change.
This means that even if the sequencer applies a state transition incorrectly, the transactions in the batch will stil be considered part of the canonical L2 chain. Batches are still subject to validity checks (i.e. they have to be encoded correctly), and so are individual transactions within the batch (e.g. signatures have to be valid). Invalid batches and invalid individual transactions within an otherwise valid batch are discarded by correct nodes.
If the sequencer applies a state transition incorrectly and posts an output root, then this output root will be incorrect. The incorrect output root which will be challenged by a fault proof, then replaced by a correct output root for the existing sequencer batches.
Refer to the Batch Submission specification for more information.
TODO rewrite the batch submission specification
Here are some things that should be included there:
- There may be different concurrent data submissions to L1
- There may be different actors that submit the data, the system cannot rely on a single EOA nonce value.
- The batcher requests safe L2 safe head from the rollup node, then queries the execution engine for the block data.
- In the future we might be able to get the safe hea dinformation from the execution engine directly. Not possible right now but there is an upstream geth PR open.
- specify batcher authentication (cf. TODO below)
Batch submission is closely tied to L2 chain derivation because the derivation process must decode the batches that have been encoded for the purpose of batch submission.
The batcher submits batcher transactions to a data availability provider. These transactions contain one or multiple channel frames, which are chunks of data belonging to a channel.
A channel is a sequence of sequencer batches (for sequential blocks) compressed together. The reason to group multiple batches together is simply to obtain a better compression rate, hence reducing data availability costs.
Channels might be too large to fit in a single batcher transaction, hence we need to split it into chunks known as channel frames. A single batcher transaction can also carry multiple frames (belonging to the same or to different channels).
This design gives use the maximum flexibility in how we aggregate batches into channels, and split channels over batcher transactions. It notably allows us to maximize data utilisation in a batcher transaction: for instance it allows us to pack the final (small) frame of a window with large frames from the next window. It also allows the batcher to employ multiple signers (private keys) to submit one or multiple channels in parallel (1).
(1) This helps alleviate issues where, because of transaction nonces, multiple transactions made by the same signer are stuck waiting on the inclusion of a previous transaction.
Also note that we use a streaming compression scheme, and we do not need to know how many blocks a channel will end up containing when we start a channel, or even as we send the first frames in the channel.
All of this is illustrated in the following diagram. Explanations below.
The first line represents L1 blocks with their numbers. The boxes under the L1 blocks represent batcher transactions included within the block. The squiggles under the L1 blocks represent deposits (more specifically, events emitted by the deposit contract).
Each colored chunk within the boxes represents a channel frame. So A
and B
are
channels whereas A0
, A1
, B0
, B1
, B2
are frames. Notice that:
- multiple channels are interleaved
- frames do not need to be transmitted in order
- a single batcher transaction can carry frames from multiple channels
In the next line, the rounded boxes represent individual sequencer batches that were extracted from
the channels. The four blue/purple/pink were derived from channel A
while the other were derived from channel B
.
These batches are here represented in the order the were decoded from batches (in this case B
is decoded first).
Note The caption here says "Channel B was seen first and will be decoded into batches first", but this is not a requirement. For instance, it would be equally acceptable for an implementation to peek into the channels and decode the one that contains the oldest batches first.
The rest of the diagram is conceptually distinct from the first part and illustrates L2 chain derivation after the channels have been reordered.
The first line shows batcher transactions. Note that in this case, there exists an ordering of the batches that makes
all frames within the channels appear contiguously. This is not true in true in general. For instance, in the second
transaction, the position of A1
and B0
could have been inverted for exactly the same result — no changes needed in
the rest of the diagram.
The second line shows the reconstructed channels in proper order. The third line shows the batches extracted from the channel. Because the channels are ordered and the batches within a channel are sequential, this means the batches are ordered too. The fourth line shows the L2 block derived from each batch. Note that we have a 1-1 batch to block mapping here but, as we'll see later, empty blocks that do not map to batches can be inserted in cases where there are "gaps" in the batches posted on L1.
The fifth line shows the L1 attributes deposited transaction which, within each L2 block, records information about the L1 block that matches the L2 block's epoch. The first number denotes the epoch/L1x number, while the second number (the "sequence number") denotes the position within the epoch.
Finally, the sixth line shows user-deposited transactions derived from the deposit contract event mentionned earlier.
Note the 101-0
L1 attributes transaction on the bottom right of the diagram. Its presence there is only possible if
frame B2
indicates that it is the last frame within the channel and (2) no empty blocks must be inserted.
The diagram does not specify the sequencing window size in use, but from it we can infer that it must be at least 4
blocks, because the last frame of channel A
appears in block 102, but belong to epoch 99.
As for the comment on "security types", it explains the classification of blocks as used on L1 and L2.
- Unsafe L2 blocks:
- Safe L2 blocks:
- Finalized L2 blocks: currently the same as the safe L2 block, but could be changed in the future to refer to block that have been derived from finalized L1 data, or alternatively, from L1 blacks that are older than the [challenge period].
These security levels map to the headBlockHash
, safeBlockHash
and finalizedBlockHash
values transmitted when
interacting with the execution-engine API. Refer to the the Communication with the Execution
Engine section for more information.
Batcher transactions are encoded as version_byte ++ rollup_payload
(where ++
denotes concatenation).
version_byte |
rollup_payload |
---|---|
0 | frame ... (one or more frames, concatenated) |
Unknown versions make the batcher transaction invalid (it must be ignored by the rollup node).
The rollup_payload
may be right-padded with 0s, which will be ignored. It's allowed for them to be
interpreted as frames for channel 0, which must always be ignored.
TODO specify batcher authentication (i.e. where do we store / make available the public keys of authorize batcher signers)
A channel frame is encoded as:
frame = channel_id ++ frame_number ++ frame_data_length ++ frame_data ++ is_last
channel_id = bytes16
frame_number = uint16
frame_data_length = uint32
frame_data = bytes
is_last = bool
Where uint32
and uint16
are all big-endian unsigned integers. Type names should be interpreted to and
encoded according to the Solidity ABI.
All data in a frame is fixed-size, except the frame_data
. The fixed overhead is 16 + 2 + 4 + 1 = 23 bytes
.
Fixed-size frame metadata avoids a circular dependency with the target total data length,
to simplify packing of frames with varying content length.
where:
channel_id
is an opaque identifier for the channel. It should not be reused and is suggested to be random; however, outside of timeout rules, it is not checked for validityframe_number
identifies the index of the frame within the channelframe_data_length
is the length offrame_data
in bytes. It is capped to 1,000,000 bytes.frame_data
is a sequence of bytes belonging to the channel, logically after the bytes from the previous framesis_last
is a single byte with a value of 1 if the frame is the last in the channel, 0 if there are frames in the channel. Any other value makes the frame invalid (it must be ignored by the rollup node).
A channel is encoded as channel_encoding
, defined as:
rlp_batches = []
for batch in batches:
rlp_batches.append(batch)
channel_encoding = compress(rlp_batches)
where:
batches
is the input, a sequence of batches byte-encoded as per the next section ("Batch Encoding")rlp_batches
is the concatenation of the RLP-encoded batchescompress
is a function performing compression, using the ZLIB algorithm (as specified in RFC-1950) with no dictionarychannel_encoding
is the compressed version ofrlp_batches
When decompressing a channel, we limit the amount of decompressed data to MAX_RLP_BYTES_PER_CHANNEL
(currently
10,000,000 bytes), in order to avoid "zip-bomb" types of attack (where a small compressed input decompresses to a
humongous amount of data). If the decompressed data exceeds the limit, things proceeds as thought the channel contained
only the first MAX_RLP_BYTES_PER_CHANNEL
decompressed bytes.
While the above pseudocode implies that all batches are known in advance, it is possible to perform streaming compression and decompression of RLP-encoded batches. This means it is possible to start including channel frames in a batcher transaction before we know how many batches (and how many frames) the channel will contain.
Recall that a batch contains a list of transactions to be included in a specific L2 block.
A batch is encoded as batch_version ++ content
, where content
depends on the batch_version
:
batch_version |
content |
---|---|
0 | rlp_encode([parent_hash, epoch_number, epoch_hash, timestamp, transaction_list]) |
where:
batch_version
is a single byte, prefixed before the RLP contents, alike to transaction typing.rlp_encode
is a function that encodes a batch according to the RLP format, and[x, y, z]
denotes a list containing itemsx
,y
andz
parent_hash
is the block hash of the previous L2 blockepoch_number
andepoch_hash
are the number and hash of the L1 block corresponding to the sequencing epoch of the L2 blocktimestamp
is the timestamp of the L2 blocktransaction_list
is an RLP-encoded list of EIP-2718 encoded transactions.
Unknown versions make the batch invalid (it must be ignored by the rollup node), as do malformed contents.
The epoch_number
and the timestamp
must also respect the constraints listed in the Batch
Buffering section, otherwise the batch is considered invalid.
The above describes the general process of L2 chain derivation, and specifies how batches are encoded within batcher transactions.
However, there remains many details to specify. These are mostly tied to the rollup node architecture for derivation. Therefore we present this architecture as a way to specify these details.
A validator that only reads from L1 (and so doesn't interact with the sequencer directly) does not need to be implemented in the way presented below. It does however need to derive the same blocks (i.e. it needs to be semantically equivalent). We do believe the architecture presented below has many advantages.
Our architecture decomposes the derivation process into a pipeline made up of the following stages:
- L1 Traversal
- L1 Retrieval
- Channel Bank
- Batch Decoding (called
ChannelInReader
in the code) - Batch Buffering (Called
BatchQueue
in the code) - Payload Attributes Derivation (called
AttributesQueue
in the code) - Engine Queue
TODO can we change code names for these three things? maybe as part of a refactor
The data flows flows from the start (outer) of the pipeline towards the end (inner). Each stage is able to push data to the next stage.
However, data is processed in reverse order. Meaning that if there is any data to be processed in the last stage, it will be processed first. Processing proceeds in "steps" that can be taken at each stage. We try to take as many steps as possible in the last (most inner) stage before taking any steps in its outer stage, etc.
This ensures that we use the data we already have before pulling more data and minimizes the latency of data traversing the derivation pipeline.
Each stage can maintain its own inner state as necessary. In particular, each stage maintains a L1 block reference (number + hash) to the latest L1 block such that all data originating from previous blocks has been fully processed, and the data from that block is being or has been processed.
Let's briefly describe each stage of the pipeline.
In the L1 Traversal stage, we simply read the header of the next L1 block. In normal operations, these will be new L1 blocks as they get created, though we can also read old blocks while syncing, or in case of an L1 re-org.
In the L1 Retrieval stage, we read the block we get from the outer stage (L1 traversal), and extract data for it. In particular we extract a byte string that corresponds to the concatenation of the data in all the batcher transaction belonging to the block. This byte stream encodes a stream of channel frames (see the Batch Submission Wire Format section for more info).
These frames are parsed, then grouped per channel into a structure we call the channel bank.
Some frames are ignored:
- Frames where
frame.frame_number <= highest_frame_number
, wherehighest_frame_number
is the highest frame number that was previously encountered for this channel.- i.e. in case of duplicate frame, the first frame read from L1 is considered canonical.
- Frames with a higher number than that of the final frame of the channel (i.e. the first frame marked with
frame.is_last == 1
) are ignored.- These frames could still be written into the channel bank if we haven't seen the final frame yet. But they will never be read out from the channel bank.
- Frames with a channel ID whose timestamp are higher than that of the L1 block on which the frame appears.
Channels are also recorded in FIFO order in a structure called the channel queue. A channel is added to the channel queue the first time a frame belonging to the channel is seen. This structure is used in the next stage.
The Channel Bank stage is responsible for managing buffering from the channel bank that was written to by the L1 retrieval stage. A step in the channel bank stage tries to read data from channels that are "ready".
In principle, we should be able to read any channel that has any number of sequential frames at the "front" of the channel (i.e. right after any frames that have been read from the bank already) and decompress batches from them. (Note that if we did this, we'd need to keep partially decompressed batches around.)
However, our current implementation doesn't support streaming decompression, so currently we have to wait until either:
- We have received all frames in the channel: i.e. we received the last frame in the channel (
is_last == 1
) and every frame with a lower number. - The channel has timed out (in which we case we read all contiguous sequential frames from the start of the channel).
- A channel is considered to be timed out if
currentL1Block.number > channeld_id.starting_l1_number + CHANNEL_TIMEOUT
.- where
currentL1Block
is the L1 block maintained by this stage, which is the most recent L1 block whose frames have been added to the channel bank.
- where
- A channel is considered to be timed out if
- The channel is pruned out of the channel bank (see below), in which case it isn't passed to the further stages.
TODO specify CHANNEL_TIMEOUT (currently 120s on Goerli testnet)
As currently implemented, each step in this stage performs the following actions:
- Try to prune the channel bank.
- This occurs if the size of the channel bank exceeds
MAX_CHANNEL_BANK_SIZE
(currently set to 100,000,000 bytes). - The size of channel bank is the sum of the sizes (in btes) of all the frames contained within it.
- In this case, channels are dropped from the front of the channel queue (see previous stage), and the frames belonging from these channels are dropped from the channel bank.
- As many channels are dropped as is necessary so that the channel bank size falls back below
MAX_CHANNEL_BANK_SIZE
.
- This occurs if the size of the channel bank exceeds
- Take the first channel and the channel queue, determine if it is ready, and process it if so.
- A channel is ready if all its frames have been received or it timed out (see list above for details).
- If the channel is ready, determine its contiguous frame sequence, which is a contiguous sequence of frames,
starting from the first frame in the channel.
- For a full channel, those are all the frames.
- For a timed channel, those are all the frames until the first missing frame. Frames after the first missing frame are discarded.
- Concatenate the data of the contiguous frame sequence (in sequential order) and push it to the next stage.
TODO Instead of waiting on the first seen channel (which might not contain the oldest batches, meaning buffering further down the pipeline), we could process any channel in the queue that is ready. We could do this by checking for channel readiness upon writing into the bank, and moving ready channel to the front of the queue.
In the Batch Decoding stage, we decompress the channel we received in the last stage, then parse batches from the decompressed byte stream.
During the Batch Buffering stage, we reorder batches by their timestamps. If batches are missing for some time slots and a valid batch with a higher timestamp exists, this stage also generates empty batches to fill the gaps.
Batches are pushed to the next stage whenever there is one or more sequential batch(es) directly following the timestamp of the current safe L2 head (the last block that can be derived from the canonical L1 chain).
Note that the presence of any gaps in the batches derived from L1 means that this stage will need to buffer for a whole sequencing window before it can generate empty batches (because the missing batch(es) could have data in the last L1 block of the window in the worst case).
A batch can have 4 different forms of validity:
drop
: the batch is invalid, and will always be in the future, unless we reorg. It can be removed from the buffer.accept
: the batch is valid and should be processed.undecided
: we are lacking L1 information until we can proceed batch filtering.future
: the batch may be valid, but cannot be processed yet and should be checked again later.
The batches are processed in order of the inclusion on L1: if multiple batches can be accept
-ed the first is applied.
The batches validity is derived as follows:
Definitions:
batch
as defined in the Batch format section.epoch = safe_l2_head.l1_origin
a L1 origin coupled to the batch, with properties:number
(L1 block number),hash
(L1 block hash), andtimestamp
(L1 block timestamp).inclusion_block_number
is the L1 block number whenbatch
was first fully derived, i.e. decoded and output by the previous stage.next_timestamp = safe_l2_head.timestamp + block_time
is the expected L2 timestamp the next batch should have, see block time information.next_epoch
may not be known yet, but would be the L1 block afterepoch
if available.batch_origin
is eitherepoch
ornext_epoch
, depending on validation.
Note that processing of a batch can be deferred until batch.timestamp <= next_timestamp
,
since future
batches will have to be retained anyway.
Rules, in validation order:
batch.timestamp > next_timestamp
->future
: i.e. the batch must be ready to process.batch.timestamp < next_timestamp
->drop
: i.e. the batch must not be too old.batch.parent_hash != safe_l2_head.hash
->drop
: i.e. the parent hash must be equal to the L2 safe head block hash.batch.epoch_num + sequence_window_size < inclusion_block_number
->drop
: i.e. the batch must be included timely.batch.epoch_num < epoch.number
->drop
: i.e. the batch origin is not older than that of the L2 safe head.batch.epoch_num == epoch.number
: definebatch_origin
asepoch
.batch.epoch_num == epoch.number+1
:- If
next_epoch
is not known ->undecided
: i.e. a batch that changes the L1 origin cannot be processed until we have the L1 origin data. - If known, then define
batch_origin
asnext_epoch
- If
batch.epoch_num > epoch.number+1
->drop
: i.e. the L1 origin cannot change by more than one L1 block per L2 block.batch.epoch_hash != batch_origin.hash
->drop
: i.e. a batch must reference a canonical L1 origin, to prevent batches from being replayed onto unexpected L1 chains.batch.timestamp > batch_origin.time + max_sequencer_drift
->drop
: i.e. a batch that does not adopt the next L1 within time will be dropped, in favor of an empty batch that can advance the L1 origin.batch.transactions
:drop
if thebatch.transactions
list contains a transaction that is invalid or derived by other means exclusively:- any transaction that is empty (zero length byte string)
- any deposited transactions (identified by the transaction type prefix byte)
If no batch can be accept
-ed, and the stage has completed buffering of all batches that can fully be read from the L1
block at height epoch.number + sequence_window_size
, and the next_epoch
is available,
then an empty batch can be derived with the following properties:
parent_hash = safe_l2_head.hash
timestamp = next_timestamp
transactions
is empty, i.e. no sequencer transactions. Deposited transactions may be added in the next stage.- If
next_timestamp < next_epoch.time
: the current L1 origin is repeated, to preserve the L2 time invariant.epoch_num = epoch.number
epoch_hash = epoch.hash
- Otherwise,
epoch_num = next_epoch.number
epoch_hash = next_epoch.hash
In the Payload Attributes Derivation stage, we convert the batches we get from the previous stage into instances of
the PayloadAttributes
structure. Such a structure encodes the transactions that need to figure into
a block, as well as other block inputs (timestamp, fee recipient, etc). Payload attributes derivation is detailed in the
section Deriving Payload Attributes section below.
In the Engine Queue stage, the previously derived PayloadAttributes
structures are buffered and sent to the
execution engine to be executed and converted into a proper L2 block.
The engine queue maintains references to two L2 blocks:
- The safe L2 head: everything up to and including this block can be fully derived from the canonical L1 chain.
- The unsafe L2 head: blocks between the safe and unsafe heads are unsafe blocks that have not been derived from L1. These blocks either come from sequencing (in sequencer mode) or from unsafe sync to the sequencer (in validator mode).
If the unsafe head is ahead of the safe head, then consolidation is attempted.
During consolidation, we consider the oldest unsafe L2 block, i.e. the unsafe L2 block directly after the safe head. If the payload attributes match this oldest unsafe L2 block, then that block can be considered "safe" and becomes the new safe head.
In particular, the following fields of the payload attributes are checked for equality with the block:
parent_hash
timestamp
randao
fee_recipient
transactions_list
(first length, then equality of each of the encoded transactions)
If consolidation fails, the unsafe L2 head is reset to the safe L2 head.
If the safe and unsafe L2 heads are identical (whether because of failed consolidation or not), we send the block to the execution engine to be converted into a proper L2 block, which will become both the new L2 safe and unsafe head.
Interaction with the execution engine via the execution engine API is detailed in the Communication with the Execution Engine section.
It is possible to reset the pipeline, for instance if we detect an L1 re-org. For more details on this, see the Handling L1 Re-Orgs section.
For every L2 block we wish to create, we need to build payload attributes,
represented by an expanded version of the PayloadAttributesV1
object,
which includes the additional transactions
and noTxPool
fields.
For each L2 block to be created by the sequencer, we start from a sequencer batch matching the target L2 block number. This could potentially be an empty auto-generated batch, if the L1 chain did not include a batch for the target L2 block number. Remember that the batch includes a sequencing epoch number, an L2 timestamp, and a transaction list.
This block is part of a sequencing epoch, whose number matches that of an L1 block (its L1 origin). This L1 block is used to derive L1 attributes and (for the first L2 block in the epoch) user deposits.
Therefore, a PayloadAttributesV1
object must include the following transactions:
- one or more deposited transactions, of two kinds:
- a single L1 attributes deposited transaction, derived from the L1 origin.
- for the first L2 block in the epoch, zero or more user-deposited transactions, derived from the receipts of the L1 origin.
- zero or more sequenced transactions: regular transactions signed by L2 users, included in the sequencer batch.
Transactions must appear in this order in the payload attributes.
The L1 attributes are read from the L1 block header, while deposits are read from the L1 block's receipts. Refer to the deposit contract specification for details on how deposits are encoded as log entries.
After deriving the transaction list, the rollup node constructs a PayloadAttributesV1
as follows:
timestamp
is set to the batch's timestamp.random
is set to theprev_randao
L1 block attribute.suggestedFeeRecipient
is set to an address determined by the sequencer.transactions
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.
The [engine queue] is responsible for interacting with the execution engine, sending it
PayloadAttributesV1
objects and receiving L2 block references as a result. This happens whenever
the safe L2 head and the unsafe L2 head are identical, either because unsafe
block consolidation failed or because no unsafe L2 blocks were known in the first
place. This section explains how this happens.
Note This only describes interaction with the execution engine in the context of L2 chain derivation from L1. The sequencer also interacts with the engine when it needs to create new L2 blocks using L2 transactions submitted by users.
Let:
safeL2Head
be a variable in the state of the execution engine, tracking the (hash of) the current safe L2 headunsafeL2Head
be a variable in the state of the execution engine, tracking the (hash of) the current unsafe L2 headfinalizedL2Head
be a variable in the state of the execution engine, tracking the (hash of) the current finalized L2 head- This is not yet implemented, and currently always holds the zero hash — this does not prevent the pseudocode below from working.
payloadAttributes
be some previously derived payload attributes for the L2 block with numberl2Number(safeL2Head) + 1
Then we can apply the following pseudocode logic to update the state of both the rollup driver and execution engine:
fun makeL2Block(payloadAttributes) {
// request a new execution payload
forkChoiceState = {
headBlockHash: safeL2Head,
safeBlockHash: safeL2Head,
finalizedBlockHash: finalizedL2Head,
}
[status, payloadID, rpcErr] = engine_forkchoiceUpdatedV1(forkChoiceState, payloadAttributes)
if (rpcErr != null) return softError()
if (status != "VALID") return payloadError()
// retrieve and execute the execution payload
[executionPayload, rpcErr] = engine_getPayloadV1(payloadID)
if (rpcErr != null) return softError()
[status, rpcErr] = engine_newPayloadV1(executionPayload)
if (rpcErr != null) return softError()
if (status != "VALID") return payloadError()
newL2Head = executionPayload.blockHash
// update head to new refL2
forkChoiceState = {
headBlockHash: newL2Head,
safeBlockHash: newL2Head,
finalizedBlockHash: finalizedL2Head,
}
[status, payloadID, rpcErr] = engine_forkchoiceUpdatedV1(forkChoiceState, null)
if (rpcErr != null) return softError()
if (status != "SUCCESS") return payloadError()
return newL2Head
}
result = softError()
while (isSoftError(result)) {
result = makeL2Block(payloadAttributes)
if (isPayloadError(result)) {
payloadAttributes = onlyDeposits(payloadAttributes)
result = makeL2Block(payloadAttributes)
}
if (isPayloadError(result)) {
panic("this should never happen")
}
}
if (!isError(result)) {
safeL2Head = result
unsafeL2Head = result
}
TODO
finalizedL2Head
is not being changed yet, but can be set to point to a L2 block fully derived from data up to a finalized L1 block.
As should apparent from the assignations, within the forkChoiceState
object, the properties have the following
meaning:
headBlockHash
: block hash of the last block of the L2 chain, according to the sequencer.safeBlockHash
: same asheadBlockHash
.finalizedBlockHash
: the finalized L2 head.
Error handling:
- A value returned by
payloadError()
means the inputs were wrong.- This could mean the sequencer included invalid transactions in the batch. In this case, all transactions from the
batch should be dropped. We assume this is the case, and modify the payload via
onlyDeposits
to only include deposited transactions, and retry. - In the case of deposits, the execution engine will skip invalid transactions, so bad deposited transactions should never cause a payload error.
- This could mean the sequencer included invalid transactions in the batch. In this case, all transactions from the
batch should be dropped. We assume this is the case, and modify the payload via
- A value returned by
softError()
means that the interaction failed by chance, and should be reattempted (this is the purpose of thewhile
loop in the pseudo-code).
TODO define "invalid transactions" properly, check the interpretation for the execution engine
The following JSON-RPC methods are part of the execution engine API:
engine_forkchoiceUpdatedV1
— updates the forkchoice (i.e. the chain head) toheadBlockHash
if different, and instructs the engine to start building an execution payload if the payload attributes isn'tnull
engine_getPayloadV1
— retrieves a previously requested execution payloadengine_newPayloadV1
— executes an execution payload to create a block
The execution payload is an object of type ExecutionPayloadV1
.
We still expect that the explanations here should be pretty useful.
The L2 chain derivation pipeline as described above assumes linear progression of the L1 chain.
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.
A re-org can be recovered without re-deriving the full L2 chain, by resetting each pipeline stage from end (Engine Queue) to start (L1 Traversal).
The general idea is to backpropagate the new L1 head through the stages, and reset the state in each stage so that the stage will next process data originating from that block onwards.
The engine queue maintains references to two L2 blocks:
- The safe L2 block (or safe head): everything up to and including this block can be fully derived from the canonical L1 chain.
- The unsafe L2 block (or unsafe head): blocks between the safe and unsafe heads are blocks that have not been derived from L1. These blocks either come from sequencing (in sequencer mode) or from "unsafe sync" to the sequencer (in validator mode).
When resetting the L1 head, we need to rollback the safe head such that the L1 origin of the new safe head is a canonical L1 block (i.e. an the new L1 head, or one of its ancestors). We achieved this by walking back the L2 chain (starting from the current safe head) until we find such an L2 block. While doing this, we must take care not to walk past the L2 genesis or L1 genesis.
The unsafe head does not necessarily need to be reset, as long as its L1 origin is plausible. The L1 origin of the unsafe head is considered plausible as long as it is in the canonical L1 chain or is ahead (higher number) than the head of the L1 chain. When we determine that this is no longer the case, we reset the unsafe head to be equal to the safe head.
TODO Don't we always need to discard the unsafe head when there is a L1 re-org, because the unsafe head's origin builds on L1 blocks that have been re-orged away?
I'm guessing maybe we received some unsafe blocks that build upon the re-orged L2, which we accept without relating them back to the safe head?
In payload attribute derivation, we need to ensure that the L1 head is reset to the safe L2 head's L1 origin. In the
worst case, this would be as far back as SWS
(sequencing window size) blocks before the engine
queue's L1 head.
In the worst case, a whole sequencing window of L1 blocks was required to derive the L2 safe head (meaning that
safeL2Head.l1Origin == engineQueue.l1Head - SWS
). This means that to derive the next L2 block, we have to read data
derived from L1 block engineQueue.l1Head - SWS
and onwards, hence the need to reset the L1 head back to that value for
this stage.
However, in general, it is only necessary to reset as far back as safeL2Head.l1Origin
, since it marks the start of the
sequencing window for the safe L2 head's epoch. As such, the next L2 block never depends on data derived from L1 blocks
before safeL2Head.l1Origin
.
TODO in the implementation, we always rollback by SWS, which is unecessary Quote from original spec:"We must find the first L2 block whose complete sequencing window is unchanged in the reorg."
TODO sanity check this section, it was incorrect in previous spec, and confused me multiple times
The batch decoding stage is simply reset by resetting its L1 head to the payload attribute derivation stage's L1 head. (The same reasoning as the payload derivation stage applies.)
Note in this section, the term next (L2) block will refer to the block that will become the next L2 safe head.
TODO The above can be changed in the case where we always reset the unsafe head to the safe head upon L1 re-org. (See TODO above in "Resetting the Engine Queue")
Because we group sequencer batches into channels, it means that decoding a batch that has data posted (in a channel frame) within the sequencing window of its epoch might require channel frames posted before the start of the sequencing window. Note that this is only possible if we start sending channel frames before knowing all the batches that will go into the channel.
In the worst case, decoding the batch for the next L2 block would require reading the last frame from a channel, posted
in a batcher transaction in safeL2Head.l1Origin + 1
(second L1 block of the next L2 block's
epoch sequencing window, assuming it is in the same epoch as safeL2Head
).
Note In reality, there are no checks or constraints preventing the batch from landing in
safeL2Head.l1Origin
. However this would be strange, because the next L2 block is built after the current L2 safe block, which requires reading the deposits L1 attributes and deposits fromsafeL2Head.l1Origin
. Still, a wonky or misbehaving sequencer could post a batch for the L2 blocksafeL2Head + 1
on L1 blocksafeL2Head.1Origin
.
Keeping things worst case, safeL2Head.l1Origin
would also be the last allowable block for the frame to land. The
allowed time range for frames within a channel to land on L1 is [channel_id.number, channel_id.number + CHANNEL_TIMEOUT]
. The allowed L1 block range for these frames are any L1 block whose number falls inside this block
range.
Therefore, to be safe, we can reset the L1 head of Channel Buffering to the L1 block whose number is
safeL2Head.l1Origin.number - CHANNEL_TIMEOUT
.
Note The above is what the implementation currently does.
In reality it's only strictly necessary to reset the oldest L1 block whose timestamp is higher than the oldest
channel_id.timestamp
found in the batcher transaction that is not older than safeL2Head.l1Origin.timestamp - CHANNEL_TIMEOUT
.
We define CHANNEL_TIMEOUT = 50
, i.e. 10mins
TODO does
CHANNEL_TIMEOUT
have a relationship withSWS
?I think yes, it has to be shorter than
SWS
but ONLY if we can't do streaming decryption (the case currently). Otherwise it could be shorter or longer.
— and explain its relationship with SWS
if any?
This situation is the main purpose of the channel timeout: without the timeout, we might have to look arbitrarily far back on L1 to be able to decompress batches, which is not acceptable for performance reasons.
The other puprose of the channel timeout is to avoid having the rollup node keep old unclosed channel data around forever.
Once the L1 head is reset, we then need to discard any frames read from blocks more recent than this updated L1 head.
These are simply reset by resetting their L1 head to channelBuffering.l1Head
, and dropping any buffered data.
Note that post-merge, the depth of re-orgs will be bounded by the L1 finality delay (every 2 epochs, or approximately 12 minutes, unless an attacker controls more than 1/3 of the total stake).
TODO This was in the spec:
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.
This makes sense, but is in conflict with how the L2 chain inception is currently determined, which is via the L2 output oracle deployment & upgrades.