DEV Community

TangHaosuan
TangHaosuan

Posted on

REVM Source Code - Execution Flow Part 5

PostExecution logic is relatively simple, mainly used for calculating gas refunds and paying miner rewards.

So this chapter combines PostExecution and ExecutionResult.

PostExecution

PostExecution uses the results from the previous three stages as input.

Let's recall the return values:

init_and_floor_gas: Return value from Validate

  • init_gas — the transaction's inherent, most basic gas consumption that must be deducted regardless of what the transaction executes
  • floor_gas — the minimum gas amount that must be reserved for the transaction, preventing gas_limit from being too low for the transaction to execute at all

eip7702_gas_refund: Return value from PreExecute — the EIP-7702 gas refund. If the delegated address already exists in the database rather than being a new address, partial gas is refunded.

exec_result: Return value from the Execution stage — the core output of the entire transaction execution.

  • Success
    • output — return data
    • gas_used — actual gas consumed (excluding refund)
    • gas_refunded — refundable gas
    • logs — event logs
    • created_address — if CREATE, the new contract address
  • Revert
    • output — failure reason
  • Halt
    • reason — abnormal termination reason
// crates/handler/src/handler.rs
#[inline]
    fn post_execution(
        &self,
        evm: &mut Self::Evm,
        exec_result: &mut FrameResult,
        init_and_floor_gas: InitialAndFloorGas,
        eip7702_gas_refund: i64,
    ) -> Result<(), Self::Error> {
        // Calculate final refund and add EIP-7702 refund to gas.
        self.refund(evm, exec_result, eip7702_gas_refund);
        // Ensure gas floor is met and minimum floor gas is spent.
        // if `cfg.is_eip7623_disabled` is true, floor gas will be set to zero
        self.eip7623_check_gas_floor(evm, exec_result, init_and_floor_gas);
        // Return unused gas to caller
        self.reimburse_caller(evm, exec_result)?;
        // Pay transaction fees to beneficiary
        self.reward_beneficiary(evm, exec_result)?;
        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

refund

Let's jump directly to the refund implementation.

The function calculates the Gas refund value and ensures the refund doesn't exceed a specified ratio:

pub fn refund(spec: SpecId, gas: &mut Gas, eip7702_refund: i64) {
    // Internal implementation is self.refunded += refund, won't expand
    gas.record_refund(eip7702_refund);

    gas.set_final_refund(spec.is_enabled_in(SpecId::LONDON));
}
Enter fullscreen mode Exit fullscreen mode

Jump to the set_final_refund implementation.

This sets the maximum allowed refund to not exceed a specified value.

Before the London upgrade, the maximum was 50% of spent; after London, it cannot exceed 20%:

#[inline]
pub fn set_final_refund(&mut self, is_london: bool) {
    let max_refund_quotient = if is_london { 5 } else { 2 };
    //
    self.refunded = (self.refunded() as u64).min(self.spent() / max_refund_quotient) as i64;
}
Enter fullscreen mode Exit fullscreen mode

eip7623_check_gas_floor

Jump to the eip7623_check_gas_floor implementation.

The name spent_sub_refunded is fairly self-explanatory: spent_sub_refunded = spent - refunded.

The result of subtracting refunded from spent is the actual gas consumed.

This ensures the minimum consumption is greater than floor_gas (the floor value) — like a restaurant's minimum charge.

It's designed to prevent nodes from being DOS-attacked with a flood of low-gas-cost transactions:

// crates/handler/src/post_execution.rs
pub fn eip7623_check_gas_floor(gas: &mut Gas, init_and_floor_gas: InitialAndFloorGas) {
    // spent_sub_refunded = spent - refunded
    // i.e., the actual gas spent, ensuring actual gas spent meets the floor
    if gas.spent_sub_refunded() < init_and_floor_gas.floor_gas {
        gas.set_spent(init_and_floor_gas.floor_gas);
        gas.set_refund(0);
    }
}
Enter fullscreen mode Exit fullscreen mode

reimburse_caller

Jump to the reimburse_caller implementation.

Earlier we only calculated the spent and refund settings — the actual refund hasn't been returned to the Caller yet.

This is where the pending gas refund is added to the Caller's account.

additional_refund is 0 in the current EVM version.

effective_gas_price uses the same formula as mentioned before:

  • GasPrice = Priority Fee + min(Base Fee, Max Fee - Base Fee)

The subsequent logic is straightforward, layer by layer from inside out.

Total refund GasLimit = (gas.remaining() + gas.refunded() as u64)

saturating_mul is multiplication.

context.journal_mut().load_account_mut(caller)?.incr_balance finds the caller and increases their balance:

// crates/handler/src/post_execution.rs
#[inline]
pub fn reimburse_caller<CTX: ContextTr>(
    context: &mut CTX,
    gas: &Gas,
    additional_refund: U256,
) -> Result<(), <CTX::Db as Database>::Error> {
    let basefee = context.block().basefee() as u128;
    let caller = context.tx().caller();
    let effective_gas_price = context.tx().effective_gas_price(basefee);

    context
        .journal_mut()
        .load_account_mut(caller)?
        .incr_balance(
            U256::from(
                effective_gas_price
                    .saturating_mul((gas.remaining() + gas.refunded() as u64) as u128),
            ) + additional_refund,
        );

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

reward_beneficiary

Pays the tx's Gas fees to the miner.

This is the Priority reward from tx Gas fees, not the block mining reward.

Before EIP-1559, miners could take the entire GasPrice set by the user.

After EIP-1559, miners can only take the PriorityFee reward — the BaseFee portion is burned.

The main difficulty here is EIP-1559; the rest of the logic is fairly simple:

// crates/handler/src/post_execution.rs
#[inline]
pub fn reward_beneficiary<CTX: ContextTr>(
    context: &mut CTX,
    gas: &Gas,
) -> Result<(), <CTX::Db as Database>::Error> {
    let (block, tx, cfg, journal, _, _) = context.all_mut();
    let basefee = block.basefee() as u128;
    let effective_gas_price = tx.effective_gas_price(basefee);

    // Transfer fee to coinbase/beneficiary.
    // EIP-1559 discard basefee for coinbase transfer. Basefee amount of gas is discarded.
    let coinbase_gas_price = if cfg.spec().into().is_enabled_in(SpecId::LONDON) {
        effective_gas_price.saturating_sub(basefee)
    } else {
        effective_gas_price
    };

    // reward beneficiary
    journal
        .load_account_mut(block.beneficiary())?
        .incr_balance(U256::from(coinbase_gas_price * gas.used() as u128));

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

ExecutionResult

core::mem::replace(evm.ctx().error(), Ok(())) checks whether there were errors during previous execution:

  • DB error → directly return Err
  • Custom error → convert to Self::Error and return
  • Ok → continue execution
// crates/handler/src/handler.rs
fn execution_result(
        &mut self,
        evm: &mut Self::Evm,
        result: <<Self::Evm as EvmTr>::Frame as FrameTr>::FrameResult,
    ) -> Result<ExecutionResult<Self::HaltReason>, Self::Error> {
        match core::mem::replace(evm.ctx().error(), Ok(())) {
            Err(ContextError::Db(e)) => return Err(e.into()),
            Err(ContextError::Custom(e)) => return Err(Self::Error::from_string(e)),
            Ok(()) => (),
        }

        let exec_result = post_execution::output(evm.ctx(), result);

        // commit transaction
        evm.ctx().journal_mut().commit_tx();
        evm.ctx().local_mut().clear();
        evm.frame_stack().clear();

        Ok(exec_result)
    }
Enter fullscreen mode Exit fullscreen mode

post_execution::output

Processes the execution result return value — nothing special to explain here, skipping:

// crates/handler/src/post_execution.rs
pub fn output<CTX: ContextTr<Journal: JournalTr>, HALTREASON: HaltReasonTr>(
    context: &mut CTX,
    // TODO, make this more generic and nice.
    // FrameResult should be a generic that returns gas and interpreter result.
    result: FrameResult,
) -> ExecutionResult<HALTREASON> {
    // Used gas with refund calculated.
    let gas_refunded = result.gas().refunded() as u64;
    let gas_used = result.gas().used();
    let output = result.output();
    let instruction_result = result.into_interpreter_result();

    // take logs from journal.
    let logs = context.journal_mut().take_logs();

    match SuccessOrHalt::<HALTREASON>::from(instruction_result.result) {
        SuccessOrHalt::Success(reason) => ExecutionResult::Success {
            reason,
            gas_used,
            gas_refunded,
            logs,
            output,
        },
        SuccessOrHalt::Revert => ExecutionResult::Revert {
            gas_used,
            output: output.into_data(),
        },
        SuccessOrHalt::Halt(reason) => {
            // Bubble up precompile errors from context when available
            if matches!(
                instruction_result.result,
                interpreter::InstructionResult::PrecompileError
            ) {
                if let Some(message) = context.local_mut().take_precompile_error_context() {
                    return ExecutionResult::Halt {
                        reason: HALTREASON::from(HaltReason::PrecompileErrorWithContext(message)),
                        gas_used,
                    };
                }
            }
            ExecutionResult::Halt { reason, gas_used }
        }
        // Only two internal return flags.
        flag @ (SuccessOrHalt::FatalExternalError | SuccessOrHalt::Internal(_)) => {
            panic!(
                "Encountered unexpected internal return flag: {flag:?} with instruction result: {instruction_result:?}"
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

evm.ctx().journal_mut().commit_tx()

The name here is a bit misleading — it sounds like it commits and saves the current tx's execution results to the database.

But actually, this mainly prepares for the next tx.

The entire function has no commit/write operations, only clearing and resetting:

// crates/context/src/journal/inner.rs
pub fn commit_tx(&mut self) {
        // Clears all field from JournalInner. Doing it this way to avoid
        // missing any field.
        let Self {
            state,
            transient_storage,
            logs,
            depth,
            journal,
            transaction_id,
            spec,
            warm_addresses,
        } = self;

        let _ = spec;
        let _ = state;
        transient_storage.clear();
        *depth = 0;

        journal.clear();

        warm_addresses.clear_coinbase_and_access_list();
        *transaction_id += 1;

        logs.clear();
    }
Enter fullscreen mode Exit fullscreen mode

The subsequent evm.ctx().local_mut().clear(); and evm.frame_stack().clear(); are also just clearing.

So where is the logic that actually commits changes and saves to the database?

evm.ctx().journal_mut().finalize

Let's go back to the first article.

Jump to examples/erc20_gas/src/exec.rs -> transact_erc20evm.

Here the results from Erc20MainnetHandler::new().run are processed:

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

Jump directly to the finalize implementation.

It's another round of clearing and resetting, but the difference is it returns the state, and transaction_id is also reset to 0.

commit_tx is executed after each tx ends, preparing for the next tx. At this point, data is still held in memory.

finalize is called after the current block has finished executing all its txs:

// 
#[inline]
pub fn finalize(&mut self) -> EvmState {
    let Self {
        state,
        transient_storage,
        logs,
        depth,
        journal,
        transaction_id,
        spec,
        warm_addresses,
    } = self;
    let _ = spec;
    warm_addresses.clear_coinbase_and_access_list();

    let state = mem::take(state);
    logs.clear();
    transient_storage.clear();

    journal.clear();
    *depth = 0;
    *transaction_id = 0;

    state
}
Enter fullscreen mode Exit fullscreen mode

evm.ctx().db_mut().commit

Jump back to examples/erc20_gas/src/exec.rs -> transact_erc20evm_commit.

Here the return result from transact_erc20evm is processed:

// examples/erc20_gas/src/exec.rs
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 click into commit.

It jumps to DatabaseCommit.

First, a note: this can be any database, as long as it implements the various Traits required by EVM.

Because EVM is only responsible for TX execution, not the database persistence layer.

The database is provided by the party that calls EVM for TX execution.

For example, RETH calls REVM — the database is provided by RETH.

In the Example we're looking at, erc20_gas calls EVM, so the database is provided by erc20_gas:

// crates/database/interface/src/lib.rs
#[auto_impl(&mut, Box)]
pub trait DatabaseCommit {
    /// Commit changes to the database.
    fn commit(&mut self, changes: HashMap<Address, Account>);
    fn commit_iter(&mut self, changes: impl IntoIterator<Item = (Address, Account)>) {
        let changes: HashMap<Address, Account> = changes.into_iter().collect();
        self.commit(changes);
    }
}
Enter fullscreen mode Exit fullscreen mode

Back to examples/erc20_gas/src/main.rs -> transfer.

with_db(cache_db) — this is where the db is passed in and set:

// examples/erc20_gas/src/main.rs
// type AlloyCacheDB = CacheDB<WrapDatabaseAsync<AlloyDB<Ethereum, DynProvider>>>;
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

It uses AlloyCacheDB — let's jump directly to AlloyCacheDB's commit implementation.

Nothing special to say — check it out yourself if interested:

impl<ExtDB> DatabaseCommit for CacheDB<ExtDB> {
    fn commit(&mut self, changes: HashMap<Address, Account>) {
        for (address, mut account) in changes {
            if !account.is_touched() {
                continue;
            }
            if account.is_selfdestructed() {
                let db_account = self.cache.accounts.entry(address).or_default();
                db_account.storage.clear();
                db_account.account_state = AccountState::NotExisting;
                db_account.info = AccountInfo::default();
                continue;
            }
            let is_newly_created = account.is_created();
            self.insert_contract(&mut account.info);

            let db_account = self.cache.accounts.entry(address).or_default();
            db_account.info = account.info;

            db_account.account_state = if is_newly_created {
                db_account.storage.clear();
                AccountState::StorageCleared
            } else if db_account.account_state.is_storage_cleared() {
                // Preserve old account state if it already exists
                AccountState::StorageCleared
            } else {
                AccountState::Touched
            };
            db_account.storage.extend(
                account
                    .storage
                    .into_iter()
                    .map(|(key, value)| (key, value.present_value())),
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)