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.
Within each snapshot, the implementation can be roughly split into two parts:
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.
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.
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.
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.
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).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 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:
tx.origin
and msg.sender
in Solidity—are set to SYSTEM_ADDRESS = 0xfff..ffe
.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):
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.BeaconRoots
(EIP-4788) and HistoryStorage
(EIP-2935) system contracts.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—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:
Let’s walk through its logic step by step—there are quite a few:
2**64-1
(EIP-2681),from
field!). The tx is then validated against the current chain state:
data
is interpreted based on whether the tx is a contract creation:
data
becomes the new contract’s init code, and the deployment address is derived from the sender’s address and nonce.data
becomes the EVM message’s data (aka. “calldata” in Solidity), and the executable code is loaded from the tx’s to
address.process_message_call
function discussed earlier.selfdestruct
ed) are destroyed, meaning they are removed from the state tries.Thankfully, all the work done in this function makes things much simpler for the ones that follow.
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.
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.
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:
WithdrawalRequest
system contract).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.
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.
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.
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.