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)
}
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),
})
}
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()
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(),
))
}
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(),
))
}
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),
}
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,
))),
}
}
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:
- Runs the current frame
- Handles the returned frame input or result
- 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);
}
}
}
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()
}))
}
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)
}
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!(),
}
}
}
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(),
})))
};
Creates a checkpoint, used to revert to this point if execution fails later:
let checkpoint = ctx.journal_mut().checkpoint();
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
}
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());
}
}
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
}
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);
});
}
}
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,
})));
}
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);
}
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()))
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,
};
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,
);
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()
}))
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()
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);
}
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) };
}
}
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);
}
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)
}
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);
}
})
}
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()
}
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);
}
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
}
Back to frame_run:
frame.process_next_action(context, action).inspect(|i| {
if i.is_result() {
frame.set_finished(true);
}
})
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)
}
Jumping back to crates/handler/src/handler.rs -> run_exec_loop:
if let Some(result) = evm.frame_return_result(result)? {
return Ok(result);
}
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)
}
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(())
}
Top comments (0)