DEV Community

TangHaosuan
TangHaosuan

Posted on

REVM Source Code - Execution Flow Part 4

Execute

The Execute stage is the most important in the entire flow.

All transactions are executed here.

Let's jump directly to the source implementation.

The second parameter, init_and_floor_gas, is the return value from the Validate stage — it represents the transaction's "initial gas consumption + floor gas limit".

gas_limit = tx's GasLimit - init_and_floor_gas.initial_gas

// crates/handler/src/handler.rs
    #[inline]
    fn execution(
        &mut self,
        evm: &mut Self::Evm,
        init_and_floor_gas: &InitialAndFloorGas,
    ) -> Result<FrameResult, Self::Error> {
        let gas_limit = evm.ctx().tx().gas_limit() - init_and_floor_gas.initial_gas;
        // Create first frame action
        let first_frame_input = self.first_frame_input(evm, gas_limit)?;

        // Run execution loop
        let mut frame_result = self.run_exec_loop(evm, first_frame_input)?;

        // Handle last frame result
        self.last_frame_result(evm, &mut frame_result)?;
        Ok(frame_result)
    }
Enter fullscreen mode Exit fullscreen mode

1. first_frame_input

Creates the initial frame input using transaction parameters, gas limit, and configuration.

Let's jump directly to the code implementation:

// crates/handler/src/handler.rs
#[inline]
    fn first_frame_input(
        &mut self,
        evm: &mut Self::Evm,
        gas_limit: u64,
    ) -> Result<FrameInit, Self::Error> {
        let ctx = evm.ctx_mut();
        let mut memory = SharedMemory::new_with_buffer(ctx.local().shared_memory_buffer().clone());
        memory.set_memory_limit(ctx.cfg().memory_limit());

        let (tx, journal) = ctx.tx_journal_mut();
        let bytecode = if let Some(&to) = tx.kind().to() {
            let account = &journal.load_account_with_code(to)?.info;

            if let Some(Bytecode::Eip7702(eip7702_bytecode)) = &account.code {
                let delegated_address = eip7702_bytecode.delegated_address;
                let account = &journal.load_account_with_code(delegated_address)?.info;
                Some((
                    account.code.clone().unwrap_or_default(),
                    account.code_hash(),
                ))
            } else {
                Some((
                    account.code.clone().unwrap_or_default(),
                    account.code_hash(),
                ))
            }
        } else {
            None
        };

        Ok(FrameInit {
            depth: 0,
            memory,
            frame_input: execution::create_init_frame(tx, bytecode, gas_limit),
        })
    }
Enter fullscreen mode Exit fullscreen mode

ctx.local() returns a LocalContext, whose purpose is to provide a "local, temporary, shared context cache" for the current transaction (tx). It's used throughout the entire transaction execution process (including multiple call layers) to reuse resources and state, avoiding redundant allocation and copying, improving performance, and handling special scenarios (such as initcode transactions, precompile error propagation).

SharedMemory::new_with_buffer simply wraps an outer layer, packaging Rc<RefCell<Vec<u8>>> (i.e., ctx.local().shared_memory_buffer()) into a safer, more user-friendly, pre-allocatable shared memory object. The goal is to make memory copying and slicing operations during transaction execution more efficient and less error-prone, while maintaining the performance advantage of sharing a single buffer across the entire tx.

I haven't looked into SharedMemory and LocalContext in detail yet — the above is Grok's explanation.

Let's finish the flow first, and cover them in separate chapters later.

tx.kind was introduced earlier — it's divided into Create and Call.

Contract creation is Create; contract calls and transfers are Call.

EIP-4844 and EIP-7702 can only Call, cannot Create.

Here we get the tx's to:

if let Some(&to) = tx.kind().to()
Enter fullscreen mode Exit fullscreen mode

Gets the to's code and checks if it's EIP7702.

If so, finds the delegated address and loads the delegated address's code:

if let Some(Bytecode::Eip7702(eip7702_bytecode)) = &account.code {
    let delegated_address = eip7702_bytecode.delegated_address;
    let account = &journal.load_account_with_code(delegated_address)?.info;
    Some((
        account.code.clone().unwrap_or_default(),
        account.code_hash(),
    ))
} 
Enter fullscreen mode Exit fullscreen mode

If it's not EIP7702, it's a contract — directly get the contract's code:

else {
    Some((
        account.code.clone().unwrap_or_default(),
        account.code_hash(),
    ))
}
Enter fullscreen mode Exit fullscreen mode

Let's briefly introduce Frame here.

A Frame is a Call Frame. During EVM execution, every inter-contract call (not function calls), such as CALL, DELEGATECALL, STATICCALL, CREATE, generates a Frame containing the context for that call.

Later there will also be a frame_stack responsible for storing all generated frames.

Return result of first_frame_input:

depth refers to the frame call depth — for example, if contract A calls contract B, which then calls contract C, contract C's frame depth is 2.

FrameInit {
    depth: 0,
    memory,
    frame_input: execution::create_init_frame(tx, bytecode, gas_limit),
}
Enter fullscreen mode Exit fullscreen mode

create_init_frame doesn't need special explanation either — it returns a FrameInput type:

// crates/handler/src/execution.rs
#[inline]
pub fn create_init_frame(
    tx: &impl Transaction,
    bytecode: Option<(Bytecode, B256)>,
    gas_limit: u64,
) -> FrameInput {
    let input = tx.input().clone();

    match tx.kind() {
        TxKind::Call(target_address) => {
            let known_bytecode = bytecode.map(|(code, hash)| (hash, code));
            FrameInput::Call(Box::new(CallInputs {
                input: CallInput::Bytes(input),
                gas_limit,
                target_address,
                bytecode_address: target_address,
                known_bytecode,
                caller: tx.caller(),
                value: CallValue::Transfer(tx.value()),
                scheme: CallScheme::Call,
                is_static: false,
                return_memory_offset: 0..0,
            }))
        }
        TxKind::Create => FrameInput::Create(Box::new(CreateInputs::new(
            tx.caller(),
            CreateScheme::Create,
            tx.value(),
            input,
            gas_limit,
        ))),
    }
}
Enter fullscreen mode Exit fullscreen mode

2. run_exec_loop

The functionality of run_exec_loop as described in the comments:

Executes the main frame processing loop. This loop manages the frame stack, processing each frame until execution completes.

Each iteration:

  1. Runs the current frame
  2. Handles the returned frame input or result
  3. Creates new frames or propagates results as needed

Let's look at the code directly.

The input parameters are EVM and the FrameInit returned from the previous step first_frame_input.

// crates/handler/src/handler.rs
#[inline]
    fn run_exec_loop(
        &mut self,
        evm: &mut Self::Evm,
        first_frame_input: <<Self::Evm as EvmTr>::Frame as FrameTr>::FrameInit,
    ) -> Result<FrameResult, Self::Error> {
        let res = evm.frame_init(first_frame_input)?;

        if let ItemOrResult::Result(frame_result) = res {
            return Ok(frame_result);
        }

        loop {
            let call_or_result = evm.frame_run()?;

            let result = match call_or_result {
                ItemOrResult::Item(init) => {
                    match evm.frame_init(init)? {
                        ItemOrResult::Item(_) => {
                            continue;
                        }
                        // Do not pop the frame since no new frame was created
                        ItemOrResult::Result(result) => result,
                    }
                }
                ItemOrResult::Result(result) => result,
            };

            if let Some(result) = evm.frame_return_result(result)? {
                return Ok(result);
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Let's first look at the frame_init implementation:

frame_init

// crates/handler/src/evm.rs
#[inline]
    fn frame_init(
        &mut self,
        frame_input: <Self::Frame as FrameTr>::FrameInit,
    ) -> Result<FrameInitResult<'_, Self::Frame>, ContextDbError<CTX>> {
        let is_first_init = self.frame_stack.index().is_none();
        let new_frame = if is_first_init {
            self.frame_stack.start_init()
        } else {
            self.frame_stack.get_next()
        };

        let ctx = &mut self.ctx;
        let precompiles = &mut self.precompiles;
        let res = Self::Frame::init_with_context(
            new_frame,
            ctx,
            precompiles,
            frame_input,
            self.instruction.gas_params(),
        )?;

        Ok(res.map_frame(|token| {
            if is_first_init {
                unsafe { self.frame_stack.end_init(token) };
            } else {
                unsafe { self.frame_stack.push(token) };
            }
            self.frame_stack.get()
        }))
    }
Enter fullscreen mode Exit fullscreen mode

Determines whether this is the first initialization based on frame_stack.index.

Calls start_init and get_next respectively:

  • start_init
    • If the stack is empty, pre-allocates space for 8 elements
  • get_next
    • If the stack is full, pre-allocates space for 8 more elements
  • out_frame_at returns a reference to the element at the given position
// crates/context/interface/src/local.rs
#[inline]
pub fn start_init(&mut self) -> OutFrame<'_, T> {
    self.index = None;
    if self.stack.is_empty() {
        self.stack.reserve(8);
    }
    self.out_frame_at(0)
}
#[inline]
pub fn get_next(&mut self) -> OutFrame<'_, T> {
    if self.index.unwrap() + 1 == self.stack.capacity() {
        // allocate 8 more items
        self.stack.reserve(8);
    }
    self.out_frame_at(self.index.unwrap() + 1)
}
Enter fullscreen mode Exit fullscreen mode

Continuing further, the main focus is init_with_context:

init_with_context

The purpose of init_with_context is to initialize a frame with the given context and precompiles.

Let's jump to the implementation. First, an explanation of the input parameters:

  • OutFrame — the empty Frame created and returned by start_init and get_next above
  • precompiles — precompiled contracts
  • frame_init — returned from the earlier call to first_frame_input
  • gas_params — instruction.gas_params(), stores the base gas consumption for each opcode (static part only; if memory expansion or similar occurs during execution, additional gas is charged)
// crates/handler/src/frame.rs
pub fn init_with_context<
        CTX: ContextTr,
        PRECOMPILES: PrecompileProvider<CTX, Output = InterpreterResult>,
    >(
        this: OutFrame<'_, Self>,
        ctx: &mut CTX,
        precompiles: &mut PRECOMPILES,
        frame_init: FrameInit,
        gas_params: GasParams,
    ) -> Result<
        ItemOrResult<FrameToken, FrameResult>,
        ContextError<<<CTX as ContextTr>::Db as Database>::Error>,
    > {
        // TODO cleanup inner make functions
        let FrameInit {
            depth,
            memory,
            frame_input,
        } = frame_init;

        match frame_input {
            FrameInput::Call(inputs) => {
                Self::make_call_frame(this, ctx, precompiles, depth, memory, inputs, gas_params)
            }
            FrameInput::Create(inputs) => {
                Self::make_create_frame(this, ctx, depth, memory, inputs, gas_params)
            }
            FrameInput::Empty => unreachable!(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Different Frames are generated based on the frame_input type. frame_input is from the result returned by first_frame_input.

make_call_frame

The function is too long, so we won't paste it all this time.

Let's first cover the function parameters — only the parts not in init_with_context:

  • depth — current inter-contract call depth
  • memory — the shared context cache mentioned in first_frame_input
  • inputs — the frame_input returned earlier

Continuing with the function implementation.

This creates a closure for returning error results:

// crates/handler/src/frame.rs -> make_call_frame
let return_result = |instruction_result: InstructionResult| {
    Ok(ItemOrResult::Result(FrameResult::Call(CallOutcome {
        result: InterpreterResult {
            result: instruction_result,
            gas,
            output: Bytes::new(),
        },
        memory_offset: inputs.return_memory_offset.clone(),
        was_precompile_called: false,
        precompile_call_logs: Vec::new(),
    })))
};
Enter fullscreen mode Exit fullscreen mode

Creates a checkpoint, used to revert to this point if execution fails later:

let checkpoint = ctx.journal_mut().checkpoint();
Enter fullscreen mode Exit fullscreen mode

Let's jump in and look at the checkpoint implementation.

It simply creates a JournalCheckpoint and returns it.

We'll cover the Revert mechanism later.

The depth here is the same thing mentioned earlier — the inter-contract call depth.

However, here depth is a global state:

// crates/context/src/journal/inner.rs
#[inline]
pub fn checkpoint(&mut self) -> JournalCheckpoint {
    let checkpoint = JournalCheckpoint {
        log_i: self.logs.len(),
        journal_i: self.journal.len(),
    };
    self.depth += 1;
    checkpoint
}
Enter fullscreen mode Exit fullscreen mode

Continuing further — if the tx has a value, the Balance is pre-deducted from from and added to to:

// crates/handler/src/frame.rs -> make_call_frame
if let CallValue::Transfer(value) = inputs.value {
    // Transfer value from caller to called account
    // Target will get touched even if balance transferred is zero.
    if let Some(i) =
        ctx.journal_mut()
            .transfer_loaded(inputs.caller, inputs.target_address, value)
    {
        ctx.journal_mut().checkpoint_revert(checkpoint);
        return return_result(i.into());
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's look at the transfer_loaded implementation — explanations are provided directly in the comments:

// crates/context/src/journal/inner.rs
#[inline]
    pub fn transfer_loaded(
        &mut self,
        from: Address,
        to: Address,
        balance: U256,
    ) -> Option<TransferError> {
        // Self-transfer case
        if from == to {
            let from_balance = self.state.get_mut(&to).unwrap().info.balance;
            // Check if from balance is enough to transfer the balance.
            if balance > from_balance {
                return Some(TransferError::OutOfFunds);
            }
            return None;
        }
        // Case where tx.value is 0
        // touch_account marks the account as touched, to facilitate account state handling after transaction completion
        if balance.is_zero() {
            Self::touch_account(&mut self.journal, to, self.state.get_mut(&to).unwrap());
            return None;
        }
        // Deduct from's balance
        let from_account = self.state.get_mut(&from).unwrap();
        Self::touch_account(&mut self.journal, from, from_account);
        let from_balance = &mut from_account.info.balance;
        let Some(from_balance_decr) = from_balance.checked_sub(balance) else {
            return Some(TransferError::OutOfFunds);
        };
        *from_balance = from_balance_decr;

        // Add to to's balance
        let to_account = self.state.get_mut(&to).unwrap();
        Self::touch_account(&mut self.journal, to, to_account);
        let to_balance = &mut to_account.info.balance;
        let Some(to_balance_incr) = to_balance.checked_add(balance) else {
            // Overflow of U256 balance is not possible to happen on mainnet. We don't bother to return funds from from_acc.
            return Some(TransferError::OverflowPayment);
        };
        *to_balance = to_balance_incr;

        // Push journal entry — every change is pushed for later revert
        self.journal
            .push(ENTRY::balance_transfer(from, to, balance));

        None
    }
Enter fullscreen mode Exit fullscreen mode

Let's continue and look at the checkpoint_revert implementation.

The revert mechanism works by saving every change to the journal — what's saved is the differential change, not the state.

Just like git, it saves the diff each time rather than a full copy.

Revert works by undoing changes from back to front until the checkpoint is reached.

The logic here is explained directly in the comments:

// crates/context/src/journal/inner.rs
#[inline]
    pub fn checkpoint_revert(&mut self, checkpoint: JournalCheckpoint) {
        let is_spurious_dragon_enabled = self.spec.is_enabled_in(SPURIOUS_DRAGON);
        let state = &mut self.state;
        let transient_storage = &mut self.transient_storage;
        // The checkpoint was generated when the contract call started,
        // reverting means undoing this contract call, so depth - 1
        self.depth = self.depth.saturating_sub(1);
        // Truncate logs to the log position saved at checkpoint time.
        // This effectively deletes all logs after the checkpoint
        self.logs.truncate(checkpoint.log_i);
        // self.journal.drain(checkpoint.journal_i..) removes all entries after the checkpoint
        // and returns them as an iterator, then .rev() reverses the order
        // This means restoring step by step from back to front
        if checkpoint.journal_i < self.journal.len() {
            self.journal
                .drain(checkpoint.journal_i..)
                .rev()
                .for_each(|entry| {
                    entry.revert(state, Some(transient_storage), is_spurious_dragon_enabled);
                });
        }
    }

Enter fullscreen mode Exit fullscreen mode

The key part is entry.revert, which handles each situation individually.

The code is too long to paste — check it out yourself if interested.

Location: crates/context/interface/src/journaled_state/entry.rs -> revert

Continuing with the make_call_frame logic.

This checks whether tx.to is a precompile address.

If so, it goes directly to the precompile logic rather than the interpreter logic — saving time and improving performance.

We won't dive deep into precompiles.run here.

// crates/handler/src/frame.rs -> make_call_frame
let interpreter_input = InputsImpl {
    target_address: inputs.target_address,
    caller_address: inputs.caller,
    bytecode_address: Some(inputs.bytecode_address),
    input: inputs.input.clone(),
    call_value: inputs.value.get(),
};
let is_static = inputs.is_static;
let gas_limit = inputs.gas_limit;

if let Some(result) = precompiles.run(ctx, &inputs).map_err(ERROR::from_string)? {
    let mut logs = Vec::new();
    if result.result.is_ok() {
        ctx.journal_mut().checkpoint_commit();
    } else {
        // If precompile execution errors, revert
        logs = ctx.journal_mut().logs()[checkpoint.log_i..].to_vec();
        ctx.journal_mut().checkpoint_revert(checkpoint);
    }
    return Ok(ItemOrResult::Result(FrameResult::Call(CallOutcome {
        result,
        memory_offset: inputs.return_memory_offset.clone(),
        was_precompile_called: true,
        precompile_call_logs: logs,
    })));
}
Enter fullscreen mode Exit fullscreen mode

Continuing further.

This handles a special case branch.

When forking mainnet or simulating transactions, the caller may have already obtained the bytecode from RPC or cache, so passing in known_bytecode directly is faster — avoiding load_account_with_code every time.

If known_bytecode doesn't exist, it's loaded from the database.

If the bytecode is empty, it means to is an EOA account — return directly:

// crates/handler/src/frame.rs -> make_call_frame
let (bytecode, bytecode_hash) = if let Some((hash, code)) = inputs.known_bytecode.clone() {
    (code, hash)
} else {
    let account = ctx
        .journal_mut()
        .load_account_with_code(inputs.bytecode_address)?;
    (
        account.info.code.clone().unwrap_or_default(),
        account.info.code_hash,
    )
};

// 
if bytecode.is_empty() {
    ctx.journal_mut().checkpoint_commit();
    return return_result(InstructionResult::Stop);
}
Enter fullscreen mode Exit fullscreen mode

Continuing further.

Initialize and start a new CallFrame.

get(EthFrame::invalid).clear finds the invalid frame in the stack and resets it to a new CallFrame.

Ok(ItemOrResult::Item(this.consume())) indicates the frame has been initialized:

// crates/handler/src/frame.rs -> make_call_frame
this.get(EthFrame::invalid).clear(
    FrameData::Call(CallFrame {
        return_memory_range: inputs.return_memory_offset.clone(),
    }),
    FrameInput::Call(inputs),
    depth,
    memory,
    ExtBytecode::new_with_hash(bytecode, bytecode_hash),
    interpreter_input,
    is_static,
    ctx.cfg().spec().into(),
    gas_limit,
    checkpoint,
    gas_params,
);
Ok(ItemOrResult::Item(this.consume()))
Enter fullscreen mode Exit fullscreen mode
make_create_frame

Function parameters are the same as above:

  • depth — current inter-contract call depth
  • memory — the shared context cache mentioned in first_frame_input
  • inputs — the frame_input returned earlier

Many parts are similar to make_call_frame — we'll mainly cover the differences.

Here it checks whether Create or Create2 is used.

The two compute addresses differently and produce different addresses.

CREATE2's generation is independent of nonce, so it can produce the same address each time.

  • CREATE address = last 20 bytes of keccak256(rlp([sender_address, sender_nonce]))
  • CREATE2 last 20 bytes of keccak256(0xff ++ sender_address ++ salt ++ keccak256(initcode))

We won't dive into the details here — check it out yourself if interested:

// crates/handler/src/frame.rs -> make_create_frame
let mut init_code_hash = None;
let created_address = match inputs.scheme() {
    CreateScheme::Create => inputs.caller().create(old_nonce),
    CreateScheme::Create2 { salt } => {
        let init_code_hash = *init_code_hash.insert(keccak256(inputs.init_code()));
        inputs.caller().create2(salt.to_be_bytes(), init_code_hash)
    }
    CreateScheme::Custom { address } => address,
};
Enter fullscreen mode Exit fullscreen mode

Continuing further.

Explanations directly in the code:

// crates/handler/src/frame.rs -> make_create_frame
// Wrap the transaction's init_code (initialization code) into ExtBytecode
let bytecode = ExtBytecode::new_with_optional_hash(
    Bytecode::new_legacy(inputs.init_code().clone()),
    init_code_hash,
);

// REVM interpreter (Interpreter) input structure describing the "current execution context"
let interpreter_input = InputsImpl {
    target_address: created_address,
    caller_address: inputs.caller(),
    bytecode_address: None,
    input: CallInput::Bytes(Bytes::new()),
    call_value: inputs.value(),
};
let gas_limit = inputs.gas_limit();

// Initialize and start a new CreateFrame
// get(EthFrame::invalid).clear finds the invalid frame in the stack and resets it to a new CreateFrame
this.get(EthFrame::invalid).clear(
    FrameData::Create(CreateFrame { created_address }),
    FrameInput::Create(inputs),
    depth,
    memory,
    bytecode,
    interpreter_input,
    false,
    spec,
    gas_limit,
    checkpoint,
    gas_params,
);
Enter fullscreen mode Exit fullscreen mode

Back to crates/handler/src/evm.rs -> frame_init

The token mainly contains a bool value indicating whether frame initialization succeeded:

Ok(res.map_frame(|token| {
    if is_first_init {
        unsafe { self.frame_stack.end_init(token) };
    } else {
        unsafe { self.frame_stack.push(token) };
    }
    self.frame_stack.get()
}))
Enter fullscreen mode Exit fullscreen mode

Remember earlier when we checked is_first_init via self.frame_stack.index().is_none()?

let is_first_init = self.frame_stack.index().is_none()
Enter fullscreen mode Exit fullscreen mode

Let's look at end_init.

As you can see, end_init sets self.index to 0, so next time is_first_init will be false:

// crates/context/interface/src/local.rs
#[inline]
pub unsafe fn end_init(&mut self, token: FrameToken) {
    token.assert();
    if self.stack.is_empty() {
        unsafe { self.stack.set_len(1) };
    }
    self.index = Some(0);
}
Enter fullscreen mode Exit fullscreen mode

Let's also look at push:

// crates/context/interface/src/local.rs
#[inline]
pub unsafe fn push(&mut self, token: FrameToken) {
    token.assert();
    // The index here has a similar effect to the depth mentioned earlier
    let index = self.index.as_mut().unwrap();
    *index += 1;
    // capacity of stack is incremented in `get_next`
    debug_assert!(
        *index < self.stack.capacity(),
        "Stack capacity is not enough for index"
    );
    // If index is the last element, increase the length
    if *index == self.stack.len() {
        unsafe { self.stack.set_len(self.stack.len() + 1) };
    }
}
Enter fullscreen mode Exit fullscreen mode

Back to crates/handler/src/handler.rs -> run_exec_loop

// to is an EOA account, precompiled contract, or other failure cases
if let ItemOrResult::Result(frame_result) = res {
    return Ok(frame_result);
}
Enter fullscreen mode Exit fullscreen mode

Continuing further — let's first explain the overall flow, then dive deeper into frame_run and frame_return_result:

loop {
    // Try to run the current frame (execute one or more opcodes)
    let call_or_result = evm.frame_run()?;

    // Based on frame_run's return, determine whether to "initialize a new frame" or "get the result directly"
    let result = match call_or_result {
        ItemOrResult::Item(init) => {
            match evm.frame_init(init)? {
                // New frame created successfully, continue loop to execute this new frame
                ItemOrResult::Item(_) => {
                    continue;
                }
                // New frame initialization failed
                ItemOrResult::Result(result) => result,
            }
        }
        ItemOrResult::Result(result) => result,
    };

    // Handle the frame's return result (may end the entire execution, or may continue)
    if let Some(result) = evm.frame_return_result(result)? {
        return Ok(result);
    }
    // If frame_return_result returns None, execution needs to continue (e.g., there are still parent frames)
}
Enter fullscreen mode Exit fullscreen mode

Diving into frame_run:

// crates/handler/src/evm.rs
#[inline]
    fn frame_run(&mut self) -> Result<FrameInitOrResult<Self::Frame>, ContextDbError<CTX>> {
        // Pop the current frame from frame_stack
        let frame = self.frame_stack.get();
        let context = &mut self.ctx;
        // instructions stores opcodes and their corresponding function pointers
        let instructions = &mut self.instruction;

        let action = frame
            .interpreter
            .run_plain(instructions.instruction_table(), context);

        frame.process_next_action(context, action).inspect(|i| {
            if i.is_result() {
                frame.set_finished(true);
            }
        })
    }
Enter fullscreen mode Exit fullscreen mode

Diving further into run_plain.

The logic is simple — it loops through self.bytecode, and if not empty, executes step:

// crates/interpreter/src/interpreter.rs
#[inline]
pub fn run_plain<H: Host + ?Sized>(
    &mut self,
    instruction_table: &InstructionTable<IW, H>,
    host: &mut H,
) -> InterpreterAction {
    while self.bytecode.is_not_end() {
        self.step(instruction_table, host);
    }
    self.take_next_action()
}
Enter fullscreen mode Exit fullscreen mode

Diving further into step.

The flow is quite clear.

The difficulty isn't in the flow but in the opcodes.

In this article series, we won't dive deep into opcodes — there will be a dedicated series later:

#[inline]
    pub fn step<H: Host + ?Sized>(
        &mut self,
        instruction_table: &InstructionTable<IW, H>,
        host: &mut H,
    ) {
        // Get the current opcode
        let opcode = self.bytecode.opcode();
        // Jump to the next opcode
        self.bytecode.relative_jump(1);

        // Get the function corresponding to the opcode
        let instruction = unsafe { instruction_table.get_unchecked(opcode as usize) };

        // Get the static gas for the current instruction
        // Check if current gas is sufficient; if not, return out-of-gas error
        if self.gas.record_cost_unsafe(instruction.static_gas()) {
            return self.halt_oog();
        }
        let context = InstructionContext {
            interpreter: self,
            host,
        };
        // Execute
        instruction.execute(context);
    }
Enter fullscreen mode Exit fullscreen mode

take_next_action

During frame execution (running opcodes), some actions are generated.

For example, CALL, CREATE, RETURN — as mentioned earlier, inter-contract calls generate new frames.

After generating an action, the loop breaks (while self.bytecode.is_not_end()).

take_next_action extracts the action and hands it to the outer frame_run for processing:

// crates/interpreter/src/interpreter.rs
#[inline]
pub fn take_next_action(&mut self) -> InterpreterAction {
    self.bytecode.reset_action();
    // Return next action if it is some.
    let action = core::mem::take(self.bytecode.action()).expect("Interpreter to set action");
    action
}
Enter fullscreen mode Exit fullscreen mode

Back to frame_run:

frame.process_next_action(context, action).inspect(|i| {
    if i.is_result() {
        frame.set_finished(true);
    }
})
Enter fullscreen mode Exit fullscreen mode

Jumping to the actual implementation:

// crates/handler/src/frame.rs
pub fn process_next_action<
        CTX: ContextTr,
        ERROR: From<ContextTrDbError<CTX>> + FromStringError,
    >(
        &mut self,
        context: &mut CTX,
        next_action: InterpreterAction,
    ) -> Result<FrameInitOrResult<Self>, ERROR> {
        let spec = context.cfg().spec().into();
        // Handle the action returned by take_next_action here
        let mut interpreter_result = match next_action {
            // This returns the result for creating a new frame
            // The actual processing doesn't happen here, but in the outer handler's run_exec_loop, going through the flow again
            InterpreterAction::NewFrame(frame_input) => {
                let depth = self.depth + 1;
                return Ok(ItemOrResult::Item(FrameInit {
                    frame_input,
                    depth,
                    memory: self.interpreter.memory.new_child_context(),
                }));
            }
            // Frame ended (includes both normal and abnormal endings)
            InterpreterAction::Return(result) => result,
        };

        // Handle the frame's return — this is similar to tx.kind
        // Only two result types: call and create
        let result = match &self.data {
            FrameData::Call(frame) => {
                // Normal result
                if interpreter_result.result.is_ok() {
                    context.journal_mut().checkpoint_commit();
                } else {
                    // Frame revert case
                    context.journal_mut().checkpoint_revert(self.checkpoint);
                }
                ItemOrResult::Result(FrameResult::Call(CallOutcome::new(
                    interpreter_result,
                    frame.return_memory_range.clone(),
                )))
            }
            FrameData::Create(frame) => {
                let max_code_size = context.cfg().max_code_size();
                let is_eip3541_disabled = context.cfg().is_eip3541_disabled();
                return_create(
                    context.journal_mut(),
                    self.checkpoint,
                    &mut interpreter_result,
                    frame.created_address,
                    max_code_size,
                    is_eip3541_disabled,
                    spec,
                );

                ItemOrResult::Result(FrameResult::Create(CreateOutcome::new(
                    interpreter_result,
                    Some(frame.created_address),
                )))
            }
        };

        Ok(result)
    }
Enter fullscreen mode Exit fullscreen mode

Jumping back to crates/handler/src/handler.rs -> run_exec_loop:

if let Some(result) = evm.frame_return_result(result)? {
    return Ok(result);
}
Enter fullscreen mode Exit fullscreen mode

Let's look at the implementation.

Explanations directly in the code:

// crates/handler/src/evm.rs
#[inline]
    fn frame_return_result(
        &mut self,
        result: <Self::Frame as FrameTr>::FrameResult,
    ) -> Result<Option<<Self::Frame as FrameTr>::FrameResult>, ContextDbError<Self::Context>> {
        // Check if the current frame has finished
        // If so, pop it — now the stack top becomes the parent frame
        if self.frame_stack.get().is_finished() {
            self.frame_stack.pop();
        }
        // If index is None after popping, we've reached the outermost layer
        // Transaction execution is complete
        if self.frame_stack.index().is_none() {
            return Ok(Some(result));
        }
        // After the pop above, the stack top is the parent frame
        // Here the current frame's return_result is passed back to the parent frame
        self.frame_stack
            .get()
            .return_result::<_, ContextDbError<Self::Context>>(&mut self.ctx, result)?;
        Ok(None)
    }
Enter fullscreen mode Exit fullscreen mode

Jumping back to the outermost crates/handler/src/handler.rs > execution:

last_frame_result

This handles Gas refund processing:

#[inline]
    fn last_frame_result(
        &mut self,
        evm: &mut Self::Evm,
        frame_result: &mut <<Self::Evm as EvmTr>::Frame as FrameTr>::FrameResult,
    ) -> Result<(), Self::Error> {
        let instruction_result = frame_result.interpreter_result().result;
        // The current frame's gas struct, containing spent, remaining, and refunded
        let gas = frame_result.gas_mut();
        // Remaining gas — to be returned to the caller later
        let remaining = gas.remaining();
        // Refundable gas — refunds generated by SSTORE zeroing, SELFDESTRUCT, etc.
        let refunded = gas.refunded();
        // Pre-deduct all gas first, then calculate refunds later
        *gas = Gas::new_spent(evm.ctx().tx().gas_limit());
        // Regardless of success or failure, refund remaining gas from execution
        if instruction_result.is_ok_or_revert() {
            gas.erase_cost(remaining);
        }
        // Only refund on successful execution; no refund on revert
        if instruction_result.is_ok() {
            gas.record_refund(refunded);
        }
        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

Top comments (0)