EIP-7886 - Delayed execution

Created 2025-02-18
Status Draft
Category Core
Type Standards Track
Authors

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:

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:

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.

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:

In other words, we validate everything that we can validate without doing any execution.

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 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 theCOINBASE'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:

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:

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:

    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.

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