Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update EIP-4788: initial stab at v2 #7456

Merged
merged 19 commits into from
Aug 24, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 149 additions & 59 deletions EIPS/eip-4788.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@
eip: 4788
title: Beacon block root in the EVM
description: Expose beacon chain roots in the EVM
author: Alex Stokes (@ralexstokes), Ansgar Dietrichs (@adietrichs), Danny Ryan (@djrtwo)
author: Alex Stokes (@ralexstokes), Ansgar Dietrichs (@adietrichs), Danny Ryan (@djrtwo), Martin Holst Swende (@holiman), lightclient (@lightclient)
discussions-to: https://ethereum-magicians.org/t/eip-4788-beacon-root-in-evm/8281
status: Draft
type: Standards Track
category: Core
created: 2022-02-10
requires: 1559
---

## 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 stateful precompile.
Store each of these roots in a smart contract.

## Motivation

Expand All @@ -25,18 +26,19 @@ restaking constructions, smart contract bridges, MEV mitigations and more.

## Specification

| constants | value | units
|--- |--- |---
| constants | value |
|--- |--- |
| `FORK_TIMESTAMP` | TBD |
| `HISTORY_STORAGE_ADDRESS` | `Bytes20(0xB)` |
| `G_beacon_root` | 4200 | gas
| `HISTORICAL_ROOTS_MODULUS` | 98304 |
| `HISTORY_BUFFER_LENGTH` | `98304` |
| `SYSTEM_ADDRESS` | `0xfffffffffffffffffffffffffffffffffffffffe` |
| `BEACON_ROOTS_ADDRESS` | `0xbEac00dDB15f3B6d645C48263dC93862413A222D` |

### Background

The high-level idea is that each execution block contains the parent beacon block root. Even in the event of missed slots since the previous block root does not change,
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 a stateful precompile.
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
Expand Down Expand Up @@ -77,76 +79,164 @@ When verifying a block, execution clients **MUST** ensure the root value in the

For a genesis block with no existing parent beacon block root the 32 zero bytes are used as a root placeholder.

### EVM changes
#### Beacon roots contract

#### Block processing
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`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, funny. seems okay though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This somewhat seems to imply that this contract has an ABI (which it has not, at least not in current assembly). I would really like to do the ABI-like approach as in lightclient/4788asm#5 since this will also be very helpful for solidity users (you can now BeaconRootContract(address).get(timest).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a bad idea to enshrine solidity ABI behavior. It is ubiquitous today, but in the future I expect other language will have different calling conventions. Solidity should directly support this contract like they do for ecrecover.


At the start of processing any execution block where `block.timestamp >= FORK_TIMESTAMP` (i.e. before processing any transactions),
write the parent beacon root provided in the block header into the storage of the contract at `HISTORY_STORAGE_ADDRESS`.
##### `get`

In order to bound the storage used by this precompile, two ring buffers are used: one to track the latest timestamp at a given index in the ring buffer and another to track
the latest root at a given index.
* 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.
* Given `timestamp`, the contract computes the storage index in which the timestamp is stored by computing the modulo `timestamp % 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`.

To derive the index `timestamp_index` into the timestamp ring buffer, the timestamp (a 64-bit unsigned integer value) is reduced modulo `HISTORICAL_ROOTS_MODULUS`.
To derive the index `root_index` into the root ring buffer, add `HISTORICAL_ROOTS_MODULUS` to the index into the timestamp ring buffer.
Both resulting 64-bit unsigned integers should be encoded as 32 bytes in big-endian format when writing to the storage.
##### `set`

The timestamp from the header, encoded as 32 bytes in big-endian format, is the value to write behind the `timestamp_index`.
The 32 bytes of the `parent_beacon_block_root` (as provided) are the value to write behind the `root_index`.
* Caller provides the parent beacon block root as calldata to the contract.
* Set the storage value at `header.timestamp % HISTORY_BUFFER_LENGTH` to be `header.timestamp`
* Set the storage value at `header.timestamp % HISTORY_BUFFER_LENGTH + HISTORY_BUFFER_LENGTH` to be `calldata[0:32]`

In Python pseudocode:
##### Pseudocode

```python
timestamp_reduced = block_header.timestamp % HISTORICAL_ROOTS_MODULUS
timestamp_extended = timestamp_reduced + HISTORICAL_ROOTS_MODULUS
timestamp_index = to_uint256_be(timestamp_reduced)
root_index = to_uint256_be(timestamp_extended)
if evm.caller == SYSTEM_ADDRESS:
set()
else:
get()

timestamp_as_uint256 = to_uint256_be(block_header.timestamp)
parent_beacon_block_root = block_header.parent_beacon_block_root
def get():
if len(evm.calldata) != 32:
evm.revert()

sstore(HISTORY_STORAGE_ADDRESS, timestamp_index, timestamp_as_uint256)
sstore(HISTORY_STORAGE_ADDRESS, root_index, parent_beacon_block_root)
```
timestamp_idx = to_uint256_be(evm.calldata) % HISTORY_BUFFER_LENGTH
timestamp = storage.get(timestamp_idx)

#### New stateful precompile
if timestamp != evm.calldata:
evm.revert()

Beginning at the execution timestamp `FORK_TIMESTAMP`, a "stateful" precompile is deployed at `HISTORY_STORAGE_ADDRESS`.
root_idx = timestamp_idx + HISTORY_BUFFER_LENGTH
root = storage.get(root_idx)

evm.return(root)

Callers of the precompile should provide the `timestamp` they are querying encoded as 32 bytes in big-endian format.
Clients **MUST** sanitize this input call data to the precompile.
If the input is _more_ than 32 bytes, the precompile only takes the first 32 bytes of the input buffer and ignores the rest.
If the input is _less_ than 32 bytes, the precompile should revert.
def set():
timestamp_idx = to_uint256_be(evm.timestamp) % HISTORY_BUFFER_LENGTH
root_idx = timestamp_idx + HISTORY_BUFFER_LENGTH

storage.set(timestamp_idx, evm.timestamp)
storage.set(root_idx, evm.calldata)
```

Given this input, the precompile reduces the `timestamp` in the same way during the write routine and first checks if
the `timestamp` recorded in the ring buffer matches the one supplied by the caller.
##### Bytecode

The exact initcode to deploy is shared below.

```asm
push1 0x58
dup1
push1 0x09
push0
codecopy
push0
return

caller
push20 0xfffffffffffffffffffffffffffffffffffffffe
eq
push1 0x44
jumpi

push1 0x20
calldatasize
eq
push1 0x24
jumpi

push0
push0
revert

jumpdest
push3 0x018000
push0
calldataload
mod
dup1
sload
push0
calldataload
eq
push1 0x37
jumpi

push0
push0
revert

jumpdest
push3 0x018000
add
sload
push0
mstore
push1 0x20
push0
return

jumpdest
push3 0x018000
timestamp
mod
timestamp
dup2
sstore
push0
calldataload
swap1
push3 0x018000
add
sstore
stop
```

If the `timestamp` **does NOT** match, the client **MUST** return the "zero" word -- the 32-byte value where each byte is `0x00`.
#### 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:
lightclient marked this conversation as resolved.
Show resolved Hide resolved

```json
{
"type": "0x0",
"nonce": "0x0",
"to": null,
"gas": "0x27eac",
"gasPrice": "0xe8d4a51000",
"maxPriorityFeePerGas": null,
"maxFeePerGas": null,
"value": "0x0",
"input": "0x60588060095f395ff33373fffffffffffffffffffffffffffffffffffffffe14604457602036146024575f5ffd5b620180005f350680545f35146037575f5ffd5b6201800001545f5260205ff35b6201800042064281555f359062018000015500",
"v": "0x1b",
"r": "0x539",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this method with a fake signature and stuff...

Can we not just drop bytecode X at address Y at the fork?
e.g. like 1011 proposed -- https://eips.ethereum.org/EIPS/eip-1011#deploying-casper-contract

I think it's more straight forward to just place it exactly where we want it, rather than trying to use a TX of sorts but without gas or signature verification which requires a lot more exceptional logic

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

and then we don't have to think about things like this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than trying to use a TX of sorts but without gas or signature verification which requires a lot more exceptional logic

To be clear, this tx does still abide by gas and signature verification. The signature is constructed arbitrarily and therefore the sender is a one-time sender address (pk is not known).

--

In an effort to minimize protocol baggage, I think it is better to deploy the contract using standard methods. Once we are satisfied with the bytecode we'd like to deploy, it could be deployed by anyone. We simply have to agree on the address at which it is deployed. It doesn't have to be a synthetic tx, although I do slightly prefer this method as it is permissionless (anyone can fund the account and submit the tx).

"s": "0x133700f3a77843802897db",
"hash": "0x14789a20c0508b81ab7a0287a12a3a41ca960aa18244af8e98689e37ed569f07"
}
```

If the `timestamp` **does** match, the client **MUST** read the root from the contract storage and return those 32 bytes in the caller's return buffer.
The sender of the transaction can be calculated as `0x3e266d3c3a70c238bdddafef1ba06fbd58958d70`. The address of the first contract deployed from the account is `rlp([sender, 0])` which equals `0xbEac00dDB15f3B6d645C48263dC93862413A222D`. 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).

In pseudocode:
### Block processing

```python
timestamp = evm.calldata[:32]
if len(timestamp) != 32:
evm.revert()
return
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:

timestamp_reduced = to_uint64_be(timestamp) % HISTORICAL_ROOTS_MODULUS
timestamp_index = to_uint256_be(timestamp_reduced)
* 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](./eip-1559.md) 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming the prior section specifies when to deploy the bytecode, this bullet will become unnecessary

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in other thread, I don't think it is important to specify the deployment. But I suppose the bullet is unnecessary because that is the semantics of an evm call anyways.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bullet is unnecessary, other than as clarification: any non-value call to account with non-existing code has no discernible effect on state.
If you prefix the bullet with Note: or Clarification:, it becomes apparent that this bullet does not intend to add any behavioural changes.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@holiman But shouldn't it then rather say:

  • if no code exists at BEACON_ROOTS_ADDRESS, the call must succeed as with any other account without code

I find "fail" a bit misleading here.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree: fail or succeed depends on your point of view. Whole bullet is moot and should not exist


recorded_timestamp = sload(HISTORY_STORAGE_ADDRESS, timestamp_index)
if recorded_timestamp != timestamp:
evm.returndata[:32].set(uint256(0))
else:
timestamp_extended = timestamp_reduced + HISTORICAL_ROOTS_MODULUS
root_index = to_uint256_be(timestamp_extended)
root = sload(HISTORY_STORAGE_ADDRESS, root_index)
evm.returndata[:32].set(root)
```
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.

The precompile costs `G_beacon_root` gas to reflect the two (2) implicit `SLOAD`s from the precompile's state.
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

Expand All @@ -170,7 +260,7 @@ e.g. with a singleton state root contract that caches the proof per slot).

### Why two ring buffers?

The first ring buffer only tracks `HISTORICAL_ROOTS_MODULUS` worth of roots and so for all possible timestamp values would consume a constant amount of storage.
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 precompile 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.

Expand Down