EIP-4788: Beacon block root in the EVM
Expose beacon chain roots in the EVM
Abstract
Commit to the hash tree root of each beacon chain block in the corresponding execution payload header.
Store each of these roots in a smart contract.
Motivation
Roots of the beacon chain blocks are cryptographic accumulators that allow proofs of arbitrary consensus state. Exposing these roots inside the EVM allows for trust-minimized access to the consensus layer. This functionality supports a wide variety of use cases that improve trust assumptions of staking pools, restaking constructions, smart contract bridges, MEV mitigations and more.
Specification
constants | value |
---|---|
FORK_TIMESTAMP | 1710338135 |
HISTORY_BUFFER_LENGTH | 8191 |
SYSTEM_ADDRESS | 0xfffffffffffffffffffffffffffffffffffffffe |
BEACON_ROOTS_ADDRESS | 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02 |
Background
The high-level idea is that each execution block contains the parent beacon block's root. Even in the event of missed slots since the previous block root does not change, we only need a constant amount of space to represent this "oracle" in each execution block. To improve the usability of this oracle, a small history of block roots are stored in the contract.
To bound the amount of storage this construction consumes, a ring buffer is used that mirrors a block root accumulator on the consensus layer.
Block structure and validity
Beginning at the execution timestamp FORK_TIMESTAMP
, execution clients MUST extend the header schema with an additional field: the parent_beacon_block_root
.
This root consumes 32 bytes and is exactly the hash tree root of the parent beacon block for the given execution block.
The resulting RLP encoding of the header is therefore:
Validity of the parent beacon block root is guaranteed from the consensus layer, much like how withdrawals are handled.
When verifying a block, execution clients MUST ensure the root value in the block header matches the one provided by the consensus client.
For a genesis block with no existing parent beacon block root the 32 zero bytes are used as a root placeholder.
Beacon roots contract
The beacon roots contract has two operations: get
and set
. The input itself is not used to determine which function to execute, for that the result of caller
is used. If caller
is equal to SYSTEM_ADDRESS
then the operation to perform is set
. Otherwise, get
.
get
- Callers provide the
timestamp
they are querying encoded as 32 bytes in big-endian format. - If the input is not exactly 32 bytes, the contract must revert.
- If the input is equal to 0, the contract must revert.
- Given
timestamp
, the contract computes the storage index in which the timestamp is stored by computing the modulotimestamp % HISTORY_BUFFER_LENGTH
and reads the value. - If the
timestamp
does not match, the contract must revert. - Finally, the beacon root associated with the timestamp is returned to the user. It is stored at
timestamp % HISTORY_BUFFER_LENGTH + HISTORY_BUFFER_LENGTH
.
set
- Caller provides the parent beacon block root as calldata to the contract.
- Set the storage value at
header.timestamp % HISTORY_BUFFER_LENGTH
to beheader.timestamp
- Set the storage value at
header.timestamp % HISTORY_BUFFER_LENGTH + HISTORY_BUFFER_LENGTH
to becalldata[0:32]
Pseudocode
Bytecode
The exact contract bytecode is shared below.
Deployment
The beacon roots contract is deployed like any other smart contract. A special synthetic address is generated by working backwards from the desired deployment transaction:
Note, the input in the transaction has a simple constructor prefixing the desired runtime code.
The sender of the transaction can be calculated as 0x0B799C86a49DEeb90402691F1041aa3AF2d3C875
. The address of the first contract deployed from the account is rlp([sender, 0])
which equals 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02
. This is how BEACON_ROOTS_ADDRESS
is determined. Although this style of contract creation is not tied to any specific initcode like create2 is, the synthetic address is cryptographically bound to the input data of the transaction (e.g. the initcode).
Block processing
At the start of processing any execution block where block.timestamp >= FORK_TIMESTAMP
(i.e. before processing any transactions), call BEACON_ROOTS_ADDRESS
as SYSTEM_ADDRESS
with the 32-byte input of header.parent_beacon_block_root
, a gas limit of 30_000_000
, and 0
value. This will trigger the set()
routine of the beacon roots contract. This is a system operation and therefore:
- the call must execute to completion
- the call does not count against the block's gas limit
- the call does not follow the EIP-1559 burn semantics - no value should be transferred as part of the call
- if no code exists at
BEACON_ROOTS_ADDRESS
, the call must fail silently
Clients may decide to omit an explicit EVM call and directly set the storage values. Note: While this is a valid optimization for Ethereum mainnet, it could be problematic on non-mainnet situations in case a different contract is used.
If this EIP is active in a genesis block, the genesis header's parent_beacon_block_root
must be 0x0
and no system transaction may occur.
Rationale
Why not repurpose BLOCKHASH
?
The BLOCKHASH
opcode could be repurposed to provide the beacon root instead of some execution block hash.
To minimize code change, avoid breaking changes to smart contracts, and simplify deployment to mainnet, this EIP suggests leaving BLOCKHASH
alone and adding new
functionality with the desired semantics.
Beacon block root instead of state root
Block roots are preferred over state roots so there is a constant amount of work to do with each new execution block. Otherwise, skipped slots would require a linear amount of work with each new payload. While skipped slots are quite rare on mainnet, it is best to not add additional load under what would already be nonfavorable conditions.
Use of block root over state root does mean proofs will require a few additional nodes but this cost is negligible (and could be amortized across all consumers, e.g. with a singleton state root contract that caches the proof per slot).
Why two ring buffers?
The first ring buffer only tracks HISTORY_BUFFER_LENGTH
worth of roots and so for all possible timestamp values would consume a constant amount of storage.
However, this design opens the contract to an attack where a skipped slot that has the same value modulo the ring buffer length would return an old root value,
rather than the most recent one.
To nullify this attack while retaining a fixed memory footprint, this EIP keeps track of the pair of data (parent_beacon_block_root, timestamp)
for each index into the
ring buffer and verifies the timestamp matches the one originally used to write the root data when being read. Given the fixed size of storage slots (only 32 bytes), the requirement
to store a pair of values necessitates two ring buffers, rather than just one.
Size of ring buffers
The ring buffer data structures are sized to hold 8191 roots from the consensus layer. Using a prime number as the ring buffer size ensures that no value is overwritten until the entire ring buffer has been saturated and thereafter, each value will be updated once per iteration. This also means that even if the slot times were to change, we would continue to use at most 8191 storage slots.
Given the current mainnet values, 8191 roots provides about a day of coverage. This gives users plenty of time to make a transaction with a verification against a specific root and get the transaction included on-chain.
Backwards Compatibility
No issues.
Test Cases
N/A
Reference Implementation
N/A
Security Considerations
N/A
Copyright
Copyright and related rights waived via CC0.