Introduction

In this article, we’ll dive into the Ethereum Execution Layer Specification (EELS) focusing on its purpose, the core modules it defines, and a few lesser-known concepts introduced to the protocol in recent hard forks.

EELS is a Python reference implementation of the core components of an Ethereum execution client, with a strong focus on readability and clarity. It serves as a more programmer-friendly and up-to-date successor to the Yellow Paper, and is often used to prototype new EIPs. EELS also provides complete snapshots of the protocol at each fork, along with rendered diffs between consecutive snapshots.

EELS does not implement the JSON-RPC API or peer-to-peer networking. However, an external RPC provider can be used to fetch and validate blocks against EELS, and the resulting state can be stored in a local database after validation.

Architecture

Within each snapshot, the implementation can be roughly split into two parts:

  • EVM Modules: These implement all core features of the EVM, including gas calculations, stack and memory management, opcode and precompile implementations, and an interpreter that ties everything together to process EVM messages.
  • Blockchain Execution Modules: These define core protocol components such as blocks, transactions, and state tries, along with the logic for processing and validating blocks and transactions before adding them to the chain.
Looking at the file structure within each snapshot, we’ll find something like this:
src/ethereum/prague/
├── blocks.py
├── bloom.py
├── exceptions.py
├── fork_types.py
├── fork.py
├── requests.py
├── state.py
├── transactions.py
├── trie.py
├── utils/
└── vm
    ├── eoa_delegation.py
    ├── exceptions.py
    ├── gas.py
    ├── instructions/
    ├── interpreter.py
    ├── memory.py
    ├── precompiled_contracts/
    ├── runtime.py
    └── stack.py

Depending on the fork being inspected, some files may be added or removed based on the EIPs introduced in that particular version. The structure above corresponds to the Prague fork, which is the one live on Mainnet at the time of writing.

Main Functionalities

Now that we have a basic understanding of what EELS is and how it’s structured, let’s dive into the core functionalities of the Execution Layer using a bottom-up approach—starting from EVM messages and working up to block-level behavior.ELSpec

EVM Messages

EVM messages are processed by the process_message_call function located in the vm/interpreter module. This is the main entry point for the EVM and is invoked during transaction execution, as we’ll see in the following sections.

def process_message_call(message: Message) -> MessageCallOutput

As shown in its signature, this function takes a message containing several fields. Notably, it includes a caller (also referred to as msg.sender in Solidity), a target and a current_target (which may differ in contract creation transactions), the code to be executed, the gas limit, and the value (in wei) to be transferred.

Function execution branches depending on whether the target is empty or points to an address. If it’s empty, then the message is treated as a contract creation (with the deployment address being current_target) and is processed by process_create_message. Otherwise, the message is routed to process_message after handling EIP-7702 logic to load EOA code delegations.

  • In the contract creation path, the new contract’s nonce is set to 1, and its runtime code is computed by executing(via process_message) the init code that was preloaded into message.code (we’ll see exactly how this happens in the “User Transactions” section).
  • In process_message, the message’s value is transferred from the caller to the target, and then the message.code is executed in a loop. Opcodes are executed one at a time, and their implementations (e.g., ADD) are responsible for updating the EVM state (program counter, stack, return data, etc.)

Finally, a MessageCallOutput is returned with the results of message processing. This includes information such as the remaining gas, gas refunds, event logs, and return data.

System Transactions

System transactions were introduced relatively recently with EIP-4788 in the Cancun fork (2024). Unlike regular [user] transactions, which are submitted to nodes by external parties, system transactions are created and executed directly within the node’s execution client. They also exhibit several peculiarities:

  • Both the transaction origin and the EVM message caller—known as tx.origin and msg.sender in Solidity—are set to SYSTEM_ADDRESS = 0xfff..ffe.
  • The target is a system contract: a regular, stateful contract (unlike precompiles, which are stateless), where the system address has special write privileges. As with any contract, data stored in system contracts is publicly accessible. If no code exists at the target address, the message processing must fail silently.
  • Calls to system contracts must execute to completion, do not count against the block’s gas limit, and do not follow the EIP-1559 burn semantics—i.e., no value should be transferred as part of the call.

Currently, the EIPs that use system contracts includes EIP-4788, EIP-2935, EIP-7002, and EIP-7251. Implementations for these contracts can be found in the ethereum/sys-asm repository. They are typically deployed using Nick’s method, which (i) uses a “keyless single-use uncontrollable address” to deploy the system contracts, and (ii) facilitates predictable multi-chain deployments.

System transactions are executed by the process_system_transaction function, which comes in two flavors: checked(which raises an error if the target address does not contain code or if the transaction fails—resulting in an invalid block) and unchecked (which—you guessed it—does not perform any checks):

  • The checked variant is used to call the WithdrawalRequest (EIP-7002) and ConsolidationRequest (EIP-7251) predeploy contracts. These are system contracts that must be deployed by the time the fork that includes them is activated.
  • The unchecked variant is used to call the BeaconRoots (EIP-4788) and HistoryStorage (EIP-2935) system contracts.
def process_system_transaction(
    block_env: vm.BlockEnvironment,
    target_address: Address,
    system_contract_code: Bytes,
    data: Bytes,
) -> MessageCallOutput

This function is a thin wrapper around process_message_call, which we discussed in the previous section. It first constructs a TransactionEnvironment, setting the origin to SYSTEM_ADDRESS, the gas limit to 30M, and leaving most other fields empty. Then, it creates an EVM message with SYSTEM_ADDRESS as the caller, and includes the target address, contract code, and data received as arguments to process_system_transaction. Finally, it delegates execution to process_message_call and returns the result.

User Transactions

User transactions—or just transactions—are those signed by external entities and sent to a node for inclusion on the blockchain. There are multiple transaction types, with the FeeMarketTransaction being the most common for users performing onchain actions (e.g., token transfers).

These transactions are executed by the process_transaction function:

def process_transaction(
    block_env: vm.BlockEnvironment,
    block_output: vm.BlockOutput,
    tx: Transaction,
    index: Uint, # Index of the tx in the block
) -> None

Let’s walk through its logic step by step—there are quite a few:

  1. The transaction (or tx) is included in a trie whose root becomes part of the block header.
  2. Initial static validation of the tx fields is performed, ensuring:
    • The gas limit covers the tx’s intrinsic cost,
    • The nonce is below 2**64-1 (EIP-2681),
    • If it’s a contract creation tx, the init code is under the 48 KB limit (twice the max runtime code size).
  3. The sender’s address is recovered from the tx signature (there’s no from field!). The tx is then validated against the current chain state:
    • The gas limit must fit within the block’s remaining gas (considering the block’s gas limit),
    • The gas fee parameters must satisfy EIP-1559,
    • The nonce must match the sender’s,
    • The sender’s balance must cover the max gas fee and any value (ETH) being transferred.
    • Additional checks apply depending on the tx type.
  4. The sender’s nonce is incremented, and the max gas fee is subtracted from their balance. (They’ll be refunded later for unused gas.)
  5. If applicable, the EIP-2930 access list is processed. (This list contains addresses and storage keys that the tx plans to access.)
  6. An EVM message is constructed, using information from the tx and block. Addresses like the sender (aka. origin), target, precompiles, and access list entries are marked as accessed (or “warmed”), making them cheaper to interact with. The tx’s data is interpreted based on whether the tx is a contract creation:
    • If it is, data becomes the new contract’s init code, and the deployment address is derived from the sender’s address and nonce.
    • Otherwise, data becomes the EVM message’s data (aka. “calldata” in Solidity), and the executable code is loaded from the tx’s to address.
  7. The EVM message is executed via the process_message_call function discussed earlier.
  8. Gas refunds are applied for any unused gas, and any **priority fees** (tips) are paid to the block proposer (aka. coinbase) by directly adding them to the proposer’s balance.
  9. Accounts marked for deletion (i.e., those that were created in this tx and then selfdestructed) are destroyed, meaning they are removed from the state tries.
  10. A tx receipt is created and added to the receipts trie, containing gas used, emitted logs, and a success/failure flag. The logs are also added to the overall block logs.

Thankfully, all the work done in this function makes things much simpler for the ones that follow.

Block Execution

Blocks are executed by the apply_body function, which takes an environment containing information such as the chan id and state, along with a list of transactions included in the block and any validator withdrawals to be processed.

def apply_body(
    block_env: vm.BlockEnvironment,
    transactions: Tuple[LegacyTransaction | Bytes, ...],
    withdrawals: Tuple[Withdrawal, ...],
) -> vm.BlockOutput

The implementation is surprisingly simple, as it mainly builds on top of the operations discussed in previous sections. It begins by processing two unchecked system transactions: one to the BeaconRoots contract (EIP-4788), and another to the HistoryStorage contract (EIP-2935). Next, it decodes and processes the list of user transactions—sequentially and one at a time—to include in the block. The third step is processing validator withdrawals, where withdrawal addresses have their incremented balances directly written to the chain state, bypassing regular ETH transfers. Finally, general-purpose requests (which we’ll cover next) are processed, and the block execution output is returned. This output aggregates results from all execution steps, including information such as gas used, event logs, and the transactions trie.

General-Purpose Requests

The concept of a request was introduced in EIP-7685, included in the Pectra upgrade. A request can be defined as “the act of asking for an operation on the execution layer to be recognized on the consensus layer”.

For example, EIP-6110 defines a type of request involving a deposit of ETH into the deposit contract (on the execution layer) to create a validator on the consensus layer. The two other EIPs that make use of requests and are currently active on Mainnet are:

  • EIP-7002: Allows validators to trigger withdrawals and exits from their EL withdrawal credentials (via de WithdrawalRequest system contract).
  • EIP-7251: Allows validators to have larger effective balances, while maintaining the 32 ETH lower bound (via the ConsolidationRequest system contract).

It’s worth noting that a request’s validity often cannot be fully verified within the execution layer. This is precisely why they are referred to as “requests”: they do not, on their own, carry the authority to unilaterally trigger action. Instead, contracts are expected to perform as much validation as possible in the execution layer before passing the data to the consensus layer for final verification.

State Transitions

Now to the main function of the execution layer! The state_transition function is responsible for executing a new block (which may have been received from other peers in the network), verifying its validity, and appending it to the chain.

def state_transition(chain: BlockChain, block: Block) -> None

First, the block header is verified to ensure that its contents make logical sense both on their own and in relation to the parent block’s header (e.g., the new block’s timestamp should be greater than the parent’s). Next, the block is executed via the apply_body function discussed above, using relevant information from the chain (id, state) and the block’s header and body. This call modifies the chain state, and the resulting outputs are then verified against the block header. Any discrepancies between the actual results and the values recorded by the block proposer in the header will result in an InvalidBlock exception. However, if both execution and validation succeed, the new block is appended to the local copy of the chain, and any older blocks no longer needed to process new ones are removed. While the protocol only requires the last 255 blocks to continue processing new ones, real clients typically store more to handle potential chain reorgs.

Conclusion

Phew! That was a lot to cover, but you should now have a solid understanding of the components and functionality within Ethereum’s execution layer. I highly recommend exploring the Python specification yourself if you’d like to dive deeper into any specific section—the links I’ve included throughout the article should help guide your reading. Otherwise, the execution-apis and consensus-specs repositories may be good places to keep learning, as they each cover different yet complementary aspects of Ethereum clients.