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: Bound precompile storage #7178

Merged
merged 13 commits into from
Jul 5, 2023
83 changes: 67 additions & 16 deletions EIPS/eip-4788.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ restaking constructions, smart contract bridges, MEV mitigations and more.
|--- |--- |---
| `FORK_TIMESTAMP` | TBD |
| `HISTORY_STORAGE_ADDRESS` | `Bytes20(0xB)` |
| `G_beacon_root` | 2100 | gas
| `G_beacon_root` | 4200 | gas
| `HISTORICAL_ROOTS_MODULUS` | 98304 |

### Background

Expand All @@ -43,7 +44,9 @@ To bound the amount of storage this construction consumes, a ring buffer is used
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](https://github.com/ethereum/consensus-specs/blob/fa09d896484bbe240334fa21ffaa454bafe5842e/ssz/simple-serialize.md#merkleization) of the parent beacon block for the given execution block.

Validity is guaranteed from the consensus layer, much like how withdrawals are handled.
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.

### EVM changes

Expand All @@ -52,43 +55,75 @@ Validity is guaranteed from the consensus layer, much like how withdrawals are h
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`.

The root itself is used as a key into the contract's storage and the timestamp of the header is written as the key's value.
The timestamp (a 64-bit unsigned integer value) is encoded as 32 bytes in big-endian format.
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.

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.

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

In Python 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)

timestamp_as_uint256 = to_uint256_be(block_header.timestamp)
parent_beacon_block_root = block_header.parent_beacon_block_root
timestamp = to_uint256_be(block_header.timestamp)

sstore(HISTORY_STORAGE_ADDRESS, parent_beacon_block_root, timestamp)
sstore(HISTORY_STORAGE_ADDRESS, timestamp_index, timestamp_as_uint256)
sstore(HISTORY_STORAGE_ADDRESS, root_index, parent_beacon_block_root)
```

#### New stateful precompile

Beginning at the execution timestamp `FORK_TIMESTAMP`, the code and storage at `HISTORY_STORAGE_ADDRESS` constitute a "stateful" precompile.
Beginning at the execution timestamp `FORK_TIMESTAMP`, a "stateful" precompile is deployed at `HISTORY_STORAGE_ADDRESS`.

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.
ralexstokes marked this conversation as resolved.
Show resolved Hide resolved

Given this input, the precompile reduces the `timestamp` in the same way during the write routine and first checks if
ralexstokes marked this conversation as resolved.
Show resolved Hide resolved
the `timestamp` recorded in the ring buffer matches the one supplied by the caller.

Callers of the precompile should provide the `root` they are querying encoded as 32 bytes.
If the `timestamp` **does NOT** match, the client **MUST** return the "zero" word -- the 32-byte value where each byte is `0x00`.

Alongside the existing gas for calling the precompile, there is an additional gas cost of `G_beacon_root` cost to reflect the implicit `SLOAD` from
the precompile's state. The timestamp of the corresponding root is returned as 32 bytes in the caller's provided return buffer and represents the
64-bit unsigned integer from the header in big-endian format.
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.

In pseudocode:

```python
root = evm.calldata[:32]
timestamp = sload(HISTORY_STORAGE_ADDRESS, root)
evm.returndata[:32].set(timestamp)
timestamp = evm.calldata[:32]
if len(timestamp) != 32:
evm.revert()
return

timestamp_reduced = to_uint64_be(timestamp) % HISTORICAL_ROOTS_MODULUS
timestamp_index = to_uint256_be(timestamp_reduced)

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

If there is no timestamp stored at the given root, the opcode follows the existing EVM semantics of `sload` returning `0`.
The precompile costs `G_beacon_root` gas to reflect the two (2) implicit `SLOAD`s from the precompile's state.

## Rationale

### Gas cost of precompile

The suggested gas cost reflects a cold `SLOAD` analogous to the operation performed while executing the precompile's logic.
The suggested gas cost reflects a cold `SLOAD` analogous to the operation(s) performed while executing the precompile's logic.

### Why not repurpose `BLOCKHASH`?

Expand All @@ -104,6 +139,22 @@ 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 `HISTORICAL_ROOTS_MODULUS` 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.

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 8192 roots from the consensus layer at current slot timings (`SECONDS_PER_SLOT` is 12 seconds on mainnet, and `8192 * 12 == 98304`).
At mainnet values, 8192 roots provides about a day of coverage of the chain which gives users plenty of time to make a transaction with a verification against a given root in
the chain and get the transaction included.

## Backwards Compatibility

No issues.
Expand Down