-
Notifications
You must be signed in to change notification settings - Fork 5.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
1 changed file
with
399 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,399 @@ | ||
--- | ||
eip: 7886 | ||
title: Delayed execution | ||
description: Make blocks statically verifiable by charging coinbase for inclusion costs upfront | ||
author: Francesco D`Amato (@fradamt), Toni Wahrstätter (@nerolation) | ||
discussions-to: https://ethereum-magicians.org/t/eip-7886-delayed-execution/22890 | ||
status: Draft | ||
type: Standards Track | ||
category: Core | ||
created: 2025-02-18 | ||
--- | ||
|
||
## Abstract | ||
|
||
This proposal makes (execution) blocks statically verifiable through minimal checks that only require the previous state, but no execution of the block's transactions, allowing validators attest to a block's validity without completing its execution. We allow transactions to be skipped when invalid at execution time, without invalidating the whole block. To ensure that even skipped transactions pay for their resources, the `COINBASE` pays for all inclusion costs upfront (base cost, calldata and blobs), and recovers the costs only when transactions are successfully executed. | ||
|
||
## Motivation | ||
|
||
A key advantage of this proposal is that it enables **asynchronous block validation**. Currently, blocks must be fully executed before validators can attest to their validity. This requirement creates a bottleneck in the consensus process, as attestors must wait for execution results before committing their votes. | ||
|
||
By introducing a mechanism where invalid transactions are skipped rather than invalidating the entire block, execution is no longer an immediate requirement for validation. Instead, a block’s validity can be determined based on its structural correctness and the upfront payment of inclusion costs by the `COINBASE`. This allows attestation to happen earlier, independent of execution, potentially enabling higher block gas limits. | ||
|
||
## Specification | ||
|
||
### Change to the header structure | ||
|
||
#### Deferred fields | ||
|
||
In order to be verifiable before execution, the header cannot commit to any execution output. In particular, we need to defer these fields: `state_root, receipt_root, bloom, gas_used, requests_hash`. We replace them with the equivalent execution outputs from the parent block. | ||
|
||
#### Coinbase signature over the header | ||
|
||
We include a signature from `COINBASE` over the rest of the header in the header , so that the `COINBASE` address can authorize the upfront payment of inclusion costs. | ||
|
||
The final header structure then is: | ||
|
||
```python | ||
class Header: | ||
parent_hash: Hash32 | ||
ommers_hash: Hash32 | ||
coinbase: Address | ||
pre_state_root: Root # Deferred | ||
transactions_root: Root | ||
parent_receipt_root: Root # Deferred | ||
parent_bloom: Bloom # Deferred | ||
difficulty: Uint | ||
number: Uint | ||
gas_limit: Uint | ||
parent_gas_used: Uint | ||
timestamp: U256 | ||
extra_data: Bytes | ||
prev_randao: Bytes32 | ||
nonce: Bytes8 | ||
base_fee_per_gas: Uint | ||
withdrawals_root: Root | ||
blob_gas_used: U64 | ||
excess_blob_gas: U64 | ||
parent_beacon_block_root: Root | ||
parent_requests_hash: Hash32 # Deferred | ||
# Signature over the header | ||
y_parity: U256 | ||
r: U256 | ||
s: U256 | ||
``` | ||
|
||
The following function is used to recover the signer’s address, intended to be `COINBASE`, from the signed block header: | ||
|
||
```python | ||
def recover_header_signer( | ||
chain_id: U64, | ||
header: Header, | ||
) -> Address: | ||
signing_hash = keccak256( | ||
b"0x06" | ||
+ rlp.encode( | ||
( | ||
chain_id, | ||
header.parent_hash, | ||
header.ommers_hash, | ||
header.coinbase, | ||
header.pre_state_root, | ||
header.transactions_root, | ||
header.parent_receipt_root, | ||
header.parent_bloom, | ||
header.difficulty, | ||
header.number, | ||
header.gas_limit, | ||
header.parent_gas_used, | ||
header.timestamp, | ||
header.extra_data, | ||
header.prev_randao, | ||
header.nonce, | ||
header.base_fee_per_gas, | ||
header.withdrawals_root, | ||
header.blob_gas_used, | ||
header.excess_blob_gas, | ||
header.parent_beacon_block_root, | ||
header.parent_requests_hash, | ||
) | ||
) | ||
) | ||
``` | ||
|
||
If `COINBASE != header_signer`, the block MUST be considered invalid. | ||
|
||
```python | ||
header_signer = recover_header_signer( | ||
chain.chain_id, | ||
block.header, | ||
) | ||
if coinbase != header_signer: | ||
raise InvalidBlock | ||
``` | ||
|
||
### Static block validation | ||
|
||
We split up a block's validation from its execution. In the ethereum/execution-specs, static validation is done in `[validate_block](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L480)`, after which a block is guaranteed to be valid and can be attested to, while execution remains within `[apply_body](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L696)`. In `validate_block`, we do some formal checks, as well as: | ||
|
||
* validate all deferred header fields (`pre_state_root`, `parent_gas_used`, `parent_receipts_root`, `parent_bloom`, `parent_receipts_hash`, `parent_requests_hash`) against the output from execution *of the parent block*. | ||
* validate all statically verifiable header fields: | ||
* check that `transactions_root` and `withdrawals_root` are correct, by building the respective tries, but *without yet processing either the transactions or the withdrawals* | ||
* check that `blob_gas_used` is correct | ||
* validate the header signature of the `COINBASE`. | ||
* do static checks for each transaction in `[check_transaction_static](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L443)`: | ||
* verify the transaction's signature | ||
* verify that the gas prices are sufficient for the current basefees | ||
* determine the inclusion costs of all transactions, and check that `COINBASE` can pay for the total inclusion cost | ||
* check that neither the total inclusion gas nor the total blob gas go over the limits | ||
|
||
In other words, we validate everything that we can validate without doing any execution. | ||
|
||
```python | ||
def check_transaction_static( | ||
tx: Transaction, | ||
chain_id: U64, | ||
) -> Address: | ||
|
||
... | ||
|
||
if isinstance(tx, (FeeMarketTransaction, BlobTransaction, SetCodeTransaction)): | ||
if tx.max_fee_per_gas < tx.max_priority_fee_per_gas: | ||
raise InvalidBlock | ||
if tx.max_fee_per_gas < base_fee_per_gas: | ||
raise InvalidBlock | ||
else: | ||
if tx.gas_price < base_fee_per_gas: | ||
raise InvalidBlock | ||
|
||
if isinstance(tx, BlobTransaction): | ||
... | ||
if Uint(tx.max_fee_per_blob_gas) < blob_gas_price: | ||
raise InvalidBlock | ||
... | ||
|
||
return recover_sender(chain_id, tx) | ||
|
||
|
||
def validate_block( | ||
chain: BlockChain, | ||
block: Block, | ||
) -> List[Address]: | ||
|
||
parent_header = chain.blocks[-1].header | ||
validate_header(block.header, parent_header) | ||
|
||
# validate deferred execution outputs from the parent | ||
if block.header.parent_gas_used != chain.last_block_gas_used: | ||
raise InvalidBlock | ||
if block.header.parent_receipt_root != chain.last_receipt_root: | ||
raise InvalidBlock | ||
if block.header.parent_bloom != chain.last_block_logs_bloom: | ||
raise InvalidBlock | ||
if block.header.parent_requests_hash != chain.last_requests_hash: | ||
raise InvalidBlock | ||
if block.header.pre_state_root != state_root(chain.state): | ||
raise InvalidBlock | ||
|
||
if block.ommers != (): | ||
raise InvalidBlock | ||
|
||
# Validate coinbase's signature over the header | ||
coinbase = block.header.coinbase | ||
header_signer = recover_header_signer( | ||
chain.chain_id, | ||
block.header, | ||
) | ||
if coinbase != header_signer: | ||
raise InvalidBlock | ||
|
||
sender_addresses = [] | ||
for i, tx in enumerate(map(decode_transaction, block.transactions)): | ||
sender_address = check_transaction_static(tx, chain.chain_id) | ||
sender_addresses.append(sender_address) | ||
_, inclusion_gas = calculate_inclusion_gas_cost(tx) | ||
blob_gas_used = calculate_total_blob_gas(tx) | ||
|
||
total_inclusion_gas += inclusion_gas | ||
total_blob_gas_used += blob_gas_used | ||
|
||
trie_set( | ||
transactions_trie, rlp.encode(Uint(i)), encode_transaction(tx) | ||
) | ||
|
||
# Check that inclusion resources are within the limits | ||
if total_inclusion_gas > block.header.gas_limit: | ||
raise InvalidBlock | ||
if total_blob_gas_used > MAX_BLOB_GAS_PER_BLOCK: | ||
raise InvalidBlock | ||
|
||
blob_gas_price = calculate_blob_gas_price(block.header.excess_blob_gas) | ||
inclusion_cost = ( | ||
total_inclusion_gas * block.header.base_fee_per_gas | ||
+ total_blob_gas_used * blob_gas_price | ||
) | ||
|
||
# Check that coinbase can pay for inclusion costs | ||
coinbase_account = get_account(chain.state, coinbase) | ||
if Uint(coinbase_account.balance) < inclusion_cost: | ||
raise InvalidBlock | ||
|
||
for i, wd in enumerate(block.withdrawals): | ||
trie_set(withdrawals_trie, rlp.encode(Uint(i)), rlp.encode(wd)) | ||
|
||
if block.header.transactions_root != root(transactions_trie): | ||
raise InvalidBlock | ||
if block.header.withdrawals_root != root(withdrawals_trie): | ||
raise InvalidBlock | ||
if block.header.blob_gas_used != blob_gas_used: | ||
raise InvalidBlock | ||
|
||
return sender_addresses | ||
``` | ||
|
||
### Block execution | ||
|
||
This logic is implemented into the ethereum/execution-specs, in `[apply_body](https://github.com/ethereum/execution-specs/blob/ae2c77989cb83e5d5e5eb1f51d9da840a337d5b0/src/ethereum/prague/fork.py#L696)`. | ||
|
||
#### Coinbase Pays Inclusion Cost Upfront | ||
|
||
* The block's `COINBASE` is charged `inclusion_cost = 21000 + max_calldata_fee + blob_fee` for each transaction at the start of block execution. The `inclusion_cost` is determined by adding up the blob fee and the floor cost of [EIP-7623](./eip-7623.md), itself comprising the 21k base cost and the calldata cost charged at `TOTAL_COST_FLOOR_PER_TOKEN`. | ||
* If the transaction is skipped, **the `COINBASE` loses** these inclusion fees and thereby pays for DA and other base costs like signature verification. | ||
|
||
```python | ||
# The inclusion gas consists of the base cost + the calldata cost | ||
def calculate_inclusion_gas_cost(tx: Transaction) -> Uint: | ||
tokens_in_calldata = zero_bytes + (len(tx.data) - zero_bytes) * 4 | ||
calldata_floor_gas_cost = tokens_in_calldata * FLOOR_CALLDATA_COST | ||
return TX_BASE_COST + calldata_floor_gas_cost | ||
|
||
def apply_body( | ||
... | ||
) -> ApplyBodyOutput: | ||
|
||
... | ||
total_inclusion_gas = sum(calculate_inclusion_gas_cost(tx) for tx in decoded_transactions) | ||
total_blob_gas_used = sum(calculate_total_blob_gas(tx) for tx in decoded_transactions) | ||
inclusion_cost = ( | ||
total_inclusion_gas * base_fee_per_gas | ||
+ total_blob_gas_used * blob_gas_price | ||
) | ||
coinbase_account = get_account(state, coinbase) | ||
coinbase_balance_after_inclusion_cost = ( | ||
Uint(coinbase_account.balance) - inclusion_cost | ||
) | ||
# Charge coinbase for inclusion costs | ||
set_account_balance( | ||
env.state, | ||
env.coinbase, | ||
U256(coinbase_balance_after_inclusion_cost), | ||
) | ||
... | ||
``` | ||
|
||
Besides deducting the inclusion costs from the`COINBASE`'s balance, we deduct the `inclusion_gas` from `gas_available` upfront, since this gas is going to be consumed regardless of how execution goes. When executing a transaction, we do the following: | ||
|
||
* We initially add its inclusion gas to `gas_available`, making it available for the transaction to consume. | ||
* If the transaction is skipped, the inclusion gas is deducted from the `gas_available` | ||
* For transactions that are not skipped (c.f. *executed*), we deduct the actual `gas_used` from `gas_available`. | ||
|
||
```python | ||
def apply_body( | ||
... | ||
) -> ApplyBodyOutput: | ||
... | ||
|
||
total_inclusion_gas = sum(calculate_inclusion_gas_cost(tx) for tx in decoded_transactions) | ||
... | ||
|
||
gas_available = block_gas_limit - total_inclusion_gas | ||
... | ||
|
||
for i, tx in enumerate(txs): | ||
... | ||
inclusion_gas = calculate_inclusion_gas_cost(tx) | ||
gas_available += inclusion_gas | ||
( | ||
is_transaction_skipped, | ||
effective_gas_price, | ||
blob_versioned_hashes, | ||
) = check_transaction( | ||
state, | ||
tx, | ||
sender_address, | ||
gas_available, | ||
) | ||
|
||
|
||
if is_transaction_skipped: | ||
gas_available -= inclusion_gas | ||
else: | ||
|
||
... | ||
|
||
gas_used, logs, error = process_transaction(env, tx) | ||
gas_available -= gas_used | ||
|
||
... | ||
... | ||
``` | ||
|
||
#### Skipped Transactions | ||
|
||
**Definition**: A "skipped transaction" is a transaction that: | ||
|
||
* Is included in the block (part of the transactions list) | ||
* Is not executed during block execution | ||
* Consumes no gas beyond its inclusion cost, which the block's `COINBASE` pays and **does not get refunded** if the transaction is ultimately skipped. | ||
|
||
Skipping might occur because: | ||
|
||
1. The transaction is underfunded, meaning that the sender cannot cover the maximum transaction costs plus `tx.value` | ||
2. There is not enough gas available in the block | ||
3. The sender's nonce does not match | ||
4. The sender is not an EOA | ||
|
||
More precisely, this is how we determine if a transaction should be skipped: | ||
|
||
```python | ||
is_sender_eoa = ( | ||
sender_account.code == bytearray() | ||
or is_valid_delegation(sender_account.code) | ||
) | ||
is_transaction_skipped = ( | ||
tx.gas > gas_available | ||
or Uint(sender_account.balance) < max_gas_fee + Uint(tx.value) | ||
or sender_account.nonce != tx.nonce | ||
or not is_sender_eoa | ||
) | ||
``` | ||
|
||
Note that signature verification and other checks *that do not depend on execution* are done in advance, when statically checking the block's validity. If those fail, the block is invalid. A transaction is skipped only when an *execution-dependent* check fails, in a block that's already been determined to be valid. | ||
|
||
#### Transaction execution | ||
|
||
When a transaction is executed, rather than skipped, the only change from the previous behavior is that `COINBASE` receives not only the priority fees, but also a refund of the inclusion costs. From the transaction sender's perspective, there is no change at all: the transaction executes in the same way, and the same exact fees are paid. From the protocol's perspective, there is also no difference, because the extra fees collected by `COINBASE` are exactly those that it had paid upfront at the beginning of the block's execution. | ||
|
||
```python | ||
def process_transaction( | ||
... | ||
inclusion_cost_refund = ( | ||
inclusion_gas * base_fee_per_gas | ||
+ blob_gas_used * blob_gas_price | ||
) | ||
|
||
# transfer priority fees and refund inclusion cost | ||
coinbase_balance_after_transaction = ( | ||
coinbase_account.balance | ||
+ priority_fee | ||
+ inclusion_cost_refund | ||
) | ||
... | ||
``` | ||
|
||
## Rationale | ||
|
||
### Overview | ||
|
||
Enabling delayed execution by making the block's validity statically verifiable requires two things: | ||
|
||
1. **Deferred execution outputs**: all header fields that commit to execution outputs are deferred by one slot. For example `state_root` and `receipt_root` become `pre_state_root`, `parent_receipt_root` the root of the state and receipt trie obtained after executing the block's parent. The same applies to the General Purpose Execution Layer Requests from [EIP-7685](./eip-7685.md): the requests are deferred by one slot and the requests in the CL must correspond to the `parent_requests_hash` in the EL header. However, this alone would only defer the computation of these execution outputs (mainly of the state root) rather than the actual execution, because verifying transaction validity would still require executing. | ||
2. **Upfront payment of inclusion costs by `COINBASE`**: in addition, we need to be able to skip (no-op) invalid transactions without invalidating the whole block. Right now, this is not possible because of the free-DA problem: as soon as we include a transaction into a block, it must pay for its data footprint. By charging the inclusion cost of all transactions upfront from the block's `COINBASE`, it is possible to skip transactions that are found to be invalid at execution time, because the protocol has already been compensated for the inclusion. | ||
|
||
### Coinbase signature over the header | ||
|
||
By signing over the header, the `COINBASE` address explicitly accepts responsibility for the upfront inclusion costs *of this block*. Therefore, the recovered address MUST equal the block's `COINBASE`. The `COINBASE`'s commitment is protected from replay attacks, because the header is a commitment to the block, so the signature only serves as an authorization for the exact block for which the `COINBASE` has agreed to take responsibility. | ||
|
||
## Backwards Compatibility | ||
|
||
This change is not backward compatible and requires a hard fork activation. | ||
|
||
## Security Considerations | ||
|
||
### Coinbase funding | ||
|
||
At the time of block creation, the `COINBASE` must be sufficiently funded to cover up to `block.gas_limit * base_fee_per_gas` + `blob_gas_price * max_blob_gas_per_block` to be able to cover the maximum possible inclusion cost. For instance, with a base fee of 100 gwei and a 36 million gas limit, the `COINBASE` would need to hold 3.6 ETH to front this cost (ignoring the blob fees) for a worst-case block. This requirement could introduce additional liquidity constraints for block proposers, especially under high base fee conditions. However, the inclusion costs under normal conditions (lower base fee, inclusion gas much below the gas limit) are significantly lower. Over a one year period of blocks from ~19.1M to ~21.7M, the average inclusion costs would have been ~5.5M gas per block, or ~0.55 ETH even at 100 gwei. | ||
|
||
## Copyright | ||
|
||
Copyright and related rights waived via [CC0](../LICENSE.md). |