DEV Community

TangHaosuan
TangHaosuan

Posted on

REVM Source Code - Execution Flow Part 1

Preface

After a few years of crypto trading — winning some, losing some — it became increasingly exhausting, but I didn't want to leave the crypto space.

After much thought, I decided to look at the RETH source code.

REVM, as the most important module of RETH, understanding it would be a major accomplishment.

I hadn't learned Rust before — I'd read tutorials but never used it in a real project. I decided to learn while reading, using AI for Q&A.

REVM Official Repository

Directory Structure

Main directories to focus on:

  • book
    • REVM documentation
  • crates
    • Where the actual source code lives
  • examples
    • Examples

Getting Started

The best way to learn a project is to start from the examples.

Here we'll start with the examples/erc20_gas project.

Open the project's src.

// examples/erc20_gas/src/main.rs
let rpc_url = "https://mainnet.infura.io/v3/c60b0bb42f8a4c6481ecd229eddaca27";
let provider = ProviderBuilder::new().connect(rpc_url).await?.erased();
let alloy_db = WrapDatabaseAsync::new(AlloyDB::new(provider, BlockId::latest())).unwrap();
let mut cache_db = CacheDB::new(alloy_db);
Enter fullscreen mode Exit fullscreen mode

The first two lines are easy to understand — creating a provider to fetch on-chain data.

The next two lines involve 3 new concepts:

  • AlloyDB: Lets the EVM fetch on-chain data directly via RPC on demand
  • WrapDatabaseAsync: Converts AlloyDB from Async to Sync
  • CacheDB: Acts as a middle layer, caching read data and staging write modifications

In other words, CacheDB is created to cache data read from the chain. When the EVM needs it, data is read directly from CacheDB rather than fetching from the chain again.

These three classes are mainly used for forking mainnet for transaction simulation and testing, so we won't dive into their implementation.

The code after the above is just configuration, so let's jump directly to the transfer function implementation.

fn transfer(from: Address, to: Address, amount: U256, cache_db: &mut AlloyCacheDB) -> Result<()> {
    let mut ctx = Context::mainnet()
        .with_db(cache_db)
        .modify_cfg_chained(|cfg| {
            cfg.spec = SpecId::CANCUN;
        })
        .with_tx(
            TxEnv::builder()
                .caller(from)
                .kind(TxKind::Call(to))
                .value(amount)
                .gas_price(2)
                .build()
                .unwrap(),
        )
        .modify_block_chained(|b| {
            b.basefee = 1;
        })
        .build_mainnet();

    transact_erc20evm_commit(&mut ctx).unwrap();

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Context::mainnet() creates a mainnet context (Context).

Context is a very important concept that permeates the entire EVM.

Understanding Context's functionality will make reading the subsequent source code much easier.

Let's jump to the Context::mainnet implementation.

//crates/handler/src/mainnet_builder.rs
impl MainContext for Context<BlockEnv, TxEnv, CfgEnv, EmptyDB, Journal<EmptyDB>, ()> {
    fn mainnet() -> Self {
        Context::new(EmptyDB::new(), SpecId::default())
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing useful here — just uses a MainContext Trait.

Let's continue jumping deeper:

// crates/context/src/context.rs
impl<
        BLOCK: Block + Default,
        TX: Transaction + Default,
        DB: Database,
        JOURNAL: JournalTr<Database = DB>,
        CHAIN: Default,
        LOCAL: LocalContextTr + Default,
        SPEC: Default + Copy + Into<SpecId>,
    > Context<BLOCK, TX, CfgEnv<SPEC>, DB, JOURNAL, CHAIN, LOCAL>
{
    /// Creates a new context with a new database type.
    ///
    /// This will create a new [`Journal`] object.
    pub fn new(db: DB, spec: SPEC) -> Self {
        let mut journaled_state = JOURNAL::new(db);
        journaled_state.set_spec_id(spec.into());
        Self {
            tx: TX::default(),
            block: BLOCK::default(),
            cfg: CfgEnv {
                spec,
                ..Default::default()
            },
            local: LOCAL::default(),
            journaled_state,
            chain: Default::default(),
            error: Ok(()),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A new Context is created, configured, and returned. Context contains the following:

  • tx — creates a new Transaction (each Transaction creates a new EVM, so one EVM only has one tx)
  • block — the block
  • cfg
    • chain_id — chain ID
    • tx_chain_id_check — whether to check chain_id (Transactions carry a chain_id; this checks if the Transaction's chain_id matches the current EVM's chain_id)
    • spec — current EVM version
    • limit_contract_code_size — contract code size limit
    • limit_contract_initcode_size — contract initialization code size limit
    • disable_nonce_check — whether to check nonce (each transaction carries the sender's nonce, equal to the number of sent transactions)
    • max_blobs_per_tx — used by L2
    • blob_base_fee_update_fraction — used by L2
    • tx_gas_limit_cap — Transaction Gas upper limit
    • memory_limit — maximum memory limit used by EVM
    • disable_balance_check
    • disable_block_gas_limit
    • disable_eip3541
    • disable_eip3607
    • disable_eip7623
    • disable_base_fee
    • disable_priority_fee_check
    • disable_fee_charge
  • local — LocalContext, a local temporary context that gets gradually populated by the interpreter/execution logic during EVM execution
  • journaled_state — Journaled state (tracks account modifications, logs, reverts, etc.)
  • chain — placeholder, currently unused

From the fields, we can see that Context contains the data and state needed for EVM transaction execution.

After reviewing Context, let's return to main.rs:

//***examples/erc20_gas/src/main.rs***
 .with_db(cache_db)
        .modify_cfg_chained(|cfg| {
            cfg.spec = SpecId::CANCUN;
        })
        .with_tx(
            TxEnv::builder()
                .caller(from)
                .kind(TxKind::Call(to))
                .value(amount)
                .gas_price(2)
                .build()
                .unwrap(),
        )
        .modify_block_chained(|b| {
            b.basefee = 1;
        })
        .build_mainnet();
Enter fullscreen mode Exit fullscreen mode

Nothing special in the earlier part — just setting the chain version, current tx, and block's basefee.

Let's go directly to build_mainnet and jump in:

//crates/handler/src/mainnet_builder.rs
fn build_mainnet(self) -> MainnetEvm<Self::Context> {
        Evm {
            ctx: self,
            inspector: (),
            instruction: EthInstructions::default(),
            precompiles: EthPrecompiles::default(),
            frame_stack: FrameStack::new_prealloc(8),
        }
    }
Enter fullscreen mode Exit fullscreen mode

It directly creates an Evm object. Let me explain the other fields here.

inspector: Think of it as a monitor (hook), mainly used for debugging. You can hook before/after opcode execution to inspect memory, stack, and gas consumption at that point.

precompiles: Collection of precompiled contracts (sha256, ecrecover, etc.)

instruction: Instruction table. Stores the gas cost for each opcode and the corresponding function for each opcode.

frame_stack: Call stack — a frame is created for each cross-contract call, used for inter-contract calls.

Many new concepts are introduced here. These concepts are important components of the EVM. Remember their roles — it will make understanding the EVM flow much easier later.

Let's return to the Transfer function in examples/erc20_gas/src/main.rs:

transact_erc20evm_commit(&mut ctx).unwrap();
Enter fullscreen mode Exit fullscreen mode

Jump in — just one function call. The key is transact_erc20evm:

pub fn transact_erc20evm_commit<EVM>(
    evm: &mut EVM,
) -> Result<ExecutionResult<HaltReason>, Erc20Error<EVM::Context>>
where
    EVM: EvmTr<
        Context: ContextTr<Journal: JournalTr<State = EvmState>, Db: DatabaseCommit>,
        Precompiles: PrecompileProvider<EVM::Context, Output = InterpreterResult>,
        Instructions: InstructionProvider<
            Context = EVM::Context,
            InterpreterTypes = EthInterpreter,
        >,
        Frame = EthFrame<EthInterpreter>,
    >,
{
    transact_erc20evm(evm).map(|(result, state)| {
        evm.ctx().db_mut().commit(state);
        result
    })
}
Enter fullscreen mode Exit fullscreen mode

Let's continue jumping to transact_erc20evm:

pub fn transact_erc20evm<EVM>(
    evm: &mut EVM,
) -> Result<(ExecutionResult<HaltReason>, EvmState), Erc20Error<EVM::Context>>
where
    EVM: EvmTr<
        Context: ContextTr<Journal: JournalTr<State = EvmState>>,
        Precompiles: PrecompileProvider<EVM::Context, Output = InterpreterResult>,
        Instructions: InstructionProvider<
            Context = EVM::Context,
            InterpreterTypes = EthInterpreter,
        >,
        Frame = EthFrame<EthInterpreter>,
    >,
{
    Erc20MainnetHandler::new().run(evm).map(|r| {
        let state = evm.ctx().journal_mut().finalize();
        (r, state)
    })
}
Enter fullscreen mode Exit fullscreen mode

For now, just focus on Erc20MainnetHandler::new().run(evm).

The first part just creates an Erc20MainnetHandler, but that's not our focus today.

Although we're approaching from erc20_gas, it's only to find an entry point. Let's jump directly to run:

// crates/handler/src/handler.rs
 #[inline]
    fn run(
        &mut self,
        evm: &mut Self::Evm,
    ) -> Result<ExecutionResult<Self::HaltReason>, Self::Error> {
        self.configure(evm);
        // Run inner handler and catch all errors to handle cleanup.
        match self.run_without_catch_error(evm) {
            Ok(output) => Ok(output),
            Err(e) => self.catch_error(evm, e),
        }
    }
Enter fullscreen mode Exit fullscreen mode

Again just one function call — let's continue:

// crates/handler/src/handler.rs
#[inline]
    fn run_without_catch_error(
        &mut self,
        evm: &mut Self::Evm,
    ) -> Result<ExecutionResult<Self::HaltReason>, Self::Error> {
        let init_and_floor_gas = self.validate(evm)?;
        let eip7702_refund = self.pre_execution(evm)? as i64;
        let mut exec_result = self.execution(evm, &init_and_floor_gas)?;
        self.post_execution(evm, &mut exec_result, init_and_floor_gas, eip7702_refund)?;

        // Prepare the output
        self.execution_result(evm, exec_result)
    }

Enter fullscreen mode Exit fullscreen mode

And here we arrive at today's focus: run_without_catch_error.

run_without_catch_error is an important function in the handler, used to execute the complete EVM flow.

Handler

Just like at the very beginning, main.rs is the project's entry point.

Handler is the entry point for the entire EVM.

Core purpose: Defines and organizes the various stages of EVM execution flow (such as transaction validation, Gas consumption, instruction execution, Log and Selfdestruct handling, etc.), and allows users to insert custom logic (Hooks) at these stages.

run_without_catch_error is where REVM's five major stages execute:

  • Validation: Ensures the transaction complies with protocol rules and locks fees before execution.
  • pre_execution: Prepares for specific protocols (like EIP-7702) or custom logic.
  • execution: Executes contract logic, completing all state modifications and log recording.
  • post_execution: Based on execution results, completes final Gas settlement and all state cleanup work.
  • execution_result: Provides a standardized transaction execution result containing all final data.

This is a rather rough overview, because conceptual content is hard to remember from just reading. We'll cover it in detail in the dedicated handler articles later.

Top comments (0)