Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-7886: Delayed execution

Make blocks statically verifiable by charging coinbase for inclusion costs upfront

Authors Francesco D`Amato (@fradamt), Toni Wahrstätter (@nerolation)
Created 2025-02-18
Discussion Link https://ethereum-magicians.org/t/eip-7886-delayed-execution/22890

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:

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

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

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

    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 and related rights waived via CC0.

Citation

Please cite this document as:

Francesco D`Amato (@fradamt), Toni Wahrstätter (@nerolation), "EIP-7886: Delayed execution [DRAFT]," Ethereum Improvement Proposals, no. 7886, February 2025. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7886.