PreExecute
According to the code comments, PreExecute is responsible for:
- Preparing the EVM state for execution
- Loading the beneficiary account (EIP-3651: Warm COINBASE) and all accounts/storage from the access list (EIP-2929)
- Deducting the maximum possible fees from the caller's balance
- For EIP-7702 transactions, applying the authorization list and delegating successful authorizations
- Returning the EIP-7702 gas refund amount. Authorizations are applied before execution begins
That's rather abstract, so let's jump directly to the source code:
// crates/handler/src/handler.rs
#[inline]
fn pre_execution(&self, evm: &mut Self::Evm) -> Result<u64, Self::Error> {
self.validate_against_state_and_deduct_caller(evm)?;
self.load_accounts(evm)?;
let gas = self.apply_eip7702_auth_list(evm)?;
Ok(gas)
}
It's divided into 3 steps. Let's look at step 1 first: validate_against_state_and_deduct_caller.
Jump to the source implementation:
1. validate_against_state_and_deduct_caller
// crates/handler/src/handler.rs
#[inline]
fn validate_against_state_and_deduct_caller(
&self,
evm: &mut Self::Evm,
) -> Result<(), Self::Error> {
pre_execution::validate_against_state_and_deduct_caller(evm.ctx())
}
Continue jumping:
// crates/handler/src/pre_execution.rs
#[inline]
pub fn validate_against_state_and_deduct_caller<
CTX: ContextTr,
ERROR: From<InvalidTransaction> + From<<CTX::Db as Database>::Error>,
>(
context: &mut CTX,
) -> Result<(), ERROR> {
let (block, tx, cfg, journal, _, _) = context.all_mut();
// Load caller's account.
let mut caller = journal.load_account_with_code_mut(tx.caller())?.data;
validate_account_nonce_and_code_with_components(&caller.account().info, tx, cfg)?;
let new_balance = calculate_caller_fee(*caller.balance(), tx, block, cfg)?;
caller.set_balance(new_balance);
if tx.kind().is_call() {
caller.bump_nonce();
}
Ok(())
}
Let's look at the definition of context.all_mut():
// crates/context/interface/src/context.rs
fn all_mut(
&mut self,
) -> (
&Self::Block,
&Self::Tx,
&Self::Cfg,
&mut Self::Journal,
&mut Self::Chain,
&mut Self::Local,
);
The functions of these returned variables were introduced earlier, so we won't repeat them here.
Continuing to look further:
let mut caller = journal.load_account_with_code_mut(tx.caller())?.data;
This actually calls the following function (skipping the lookup/jump chain):
// crates/context/src/journal/inner.rs
/// Loads account into memory. If account is already loaded it will be marked as warm.
#[inline]
pub fn load_account_mut_optional_code<'a, 'db, DB: Database>(
&'a mut self,
db: &'db mut DB,
address: Address,
load_code: bool,
skip_cold_load: bool,
) -> Result<StateLoad<JournaledAccount<'a, DB, ENTRY>>, JournalLoadError<DB::Error>>
where
'db: 'a,
{
let mut load = self.load_account_mut_optional(db, address, skip_cold_load)?;
if load_code {
load.data.load_code_preserve_error()?;
}
Ok(load)
}
Database hasn't appeared before — it's responsible for data persistence. It provides all the underlying state data needed for EVM execution. Here it handles loading account information and account bytecode.
Let's first jump to the load_account_mut_optional implementation:
// crates/context/src/journal/inner.rs
#[inline(never)]
pub fn load_account_mut_optional<'a, 'db, DB: Database>(
&'a mut self,
db: &'db mut DB,
address: Address,
skip_cold_load: bool,
) -> Result<StateLoad<JournaledAccount<'a, DB, ENTRY>>, JournalLoadError<DB::Error>>
where
'db: 'a,
{
let (account, is_cold) = match self.state.entry(address) {
Entry::Occupied(entry) => {
let account = entry.into_mut();
// skip load if account is cold.
let mut is_cold = account.is_cold_transaction_id(self.transaction_id);
if unlikely(is_cold) {
is_cold = self
.warm_addresses
.check_is_cold(&address, skip_cold_load)?;
// mark it warm.
account.mark_warm_with_transaction_id(self.transaction_id);
// if it is cold loaded and we have selfdestructed locally it means that
// account was selfdestructed in previous transaction and we need to clear its information and storage.
if account.is_selfdestructed_locally() {
account.selfdestruct();
account.unmark_selfdestructed_locally();
}
// set original info to current info.
*account.original_info = account.info.clone();
// unmark locally created
account.unmark_created_locally();
// journal loading of cold account.
self.journal.push(ENTRY::account_warmed(address));
}
(account, is_cold)
}
Entry::Vacant(vac) => {
// Precompiles, among some other account(access list and coinbase included)
// are warm loaded so we need to take that into account
let is_cold = self
.warm_addresses
.check_is_cold(&address, skip_cold_load)?;
let account = if let Some(account) = db.basic(address)? {
let mut account: Account = account.into();
account.transaction_id = self.transaction_id;
account
} else {
Account::new_not_existing(self.transaction_id)
};
// journal loading of cold account.
if is_cold {
self.journal.push(ENTRY::account_warmed(address));
}
(vac.insert(account), is_cold)
}
};
Ok(StateLoad::new(
JournaledAccount::new(
address,
account,
&mut self.journal,
db,
self.warm_addresses.access_list(),
self.transaction_id,
),
is_cold,
))
}
self.state is a HashMap where Key is Address and Value is Account.
This checks whether the Account has been loaded before and handles the respective logic.
Although the code checks the found case first, let's look at the not-found case first.
Entry::Vacant(vac)
// Check if the current address exists in warm_addresses
// warm_addresses contains precompiles, AccessList, and Coinbase
let is_cold = self
.warm_addresses
.check_is_cold(&address, skip_cold_load)?;
Here's the definition of warm_addresses:
// crates/context/src/journal/warm_addresses.rs
pub struct WarmAddresses {
/// Set of warm loaded precompile addresses.
precompile_set: HashSet<Address>,
/// Bit vector of precompile short addresses. If address is shorter than [`SHORT_ADDRESS_CAP`] it
/// will be stored in this bit vector for faster access.
precompile_short_addresses: BitVec,
/// `true` if all precompiles are short addresses.
precompile_all_short_addresses: bool,
/// Coinbase address.
coinbase: Option<Address>,
/// Access list
access_list: HashMap<Address, HashSet<StorageKey>>,
}
check_is_cold checks whether the current address exists in any of the properties.
Too much expansion, so we won't show the code here.
Let's continue with the rest of Entry::Vacant(vac):
let account = if let Some(account) = db.basic(address)? {
let mut account: Account = account.into();
account.transaction_id = self.transaction_id;
account
} else {
Account::new_not_existing(self.transaction_id)
};
Looks up the address in the database. If found, sets the account's transaction_id and returns.
The transaction_id here is NOT the transaction_hash.
It's the current tx's index in the block — the tx's position in the current block.
Remember this — the reason we looked at the not-found case first is because of the transaction_id setting.
Continuing below is fairly easy to understand — if is_cold, insert into journal and self.state:
if is_cold {
self.journal.push(ENTRY::account_warmed(address));
}
(vac.insert(account), is_cold)
Now let's look at the found case:
Entry::Occupied(entry)
let account = entry.into_mut();
let mut is_cold = account.is_cold_transaction_id(self.transaction_id);
Let's jump to the is_cold_transaction_id implementation:
#[inline]
pub fn is_cold_transaction_id(&self, transaction_id: usize) -> bool {
self.transaction_id != transaction_id || self.status.contains(AccountStatus::Cold)
}
Why can self.transaction_id != transaction_id determine whether the Account is cold?
It's because the self.transaction_id was set in the not-found case earlier.
If they're equal, it means the Account was loaded during this tx execution.
If they're not equal, the Account was loaded during a previous tx execution, so it's already warm.
Continuing:
// This relates to Rust's branch prediction hint — look it up if interested, we won't expand here.
if unlikely(is_cold){
// Already discussed above
is_cold = self
.warm_addresses
.check_is_cold(&address, skip_cold_load)?;
// Mark as warm address
account.mark_warm_with_transaction_id(self.transaction_id);
// Contract self-destruct related code, skipping for now
if account.is_selfdestructed_locally() {
account.selfdestruct();
account.unmark_selfdestructed_locally();
}
// All fairly obvious, won't go into detail
*account.original_info = account.info.clone();
account.unmark_created_locally();
self.journal.push(ENTRY::account_warmed(address));
}
After covering load_account_mut_optional,
jump back to crates/context/src/journal/inner.rs -> load_account_mut_optional_code
and continue:
if load_code {
load.data.load_code_preserve_error()?;
}
Jump in.
The logic is fairly clear, nothing that needs special explanation.
Here code_hash is the hash of the Account's code.
EOA accounts don't have code, so code_hash is empty.
Contract accounts and abstract accounts have code, so code_hash is non-empty.
// crates/context/interface/src/journaled_state/account.rs
pub fn load_code_preserve_error(&mut self) -> Result<&Bytecode, JournalLoadError<DB::Error>> {
if self.account.info.code.is_none() {
let hash = *self.code_hash();
let code = if hash == KECCAK_EMPTY {
Bytecode::default()
} else {
self.db.code_by_hash(hash)?
};
self.account.info.code = Some(code);
}
Ok(self.account.info.code.as_ref().unwrap())
}
After covering load_account_mut_optional_code,
jump back to crates/handler/src/pre_execution.rs -> validate_against_state_and_deduct_caller
and continue to:
validate_account_nonce_and_code_with_components(&caller.account().info, tx, cfg)?;
The actual call is to validate_account_nonce_and_code — let's jump directly to it.
The logic is simple — two checks:
-
eip3607
- This proposal prohibits contracts from signing transactions like EOA accounts using private keys
- It's meant to prevent address collision attacks — look it up if interested
-
nonce
- Validates whether the tx's nonce is set correctly
- nonce equals the number of transactions you've signed and committed on-chain
// crates/handler/src/pre_execution.rs
#[inline]
pub fn validate_account_nonce_and_code(
caller_info: &AccountInfo,
tx_nonce: u64,
is_eip3607_disabled: bool,
is_nonce_check_disabled: bool,
) -> Result<(), InvalidTransaction> {
if !is_eip3607_disabled {
let bytecode = match caller_info.code.as_ref() {
Some(code) => code,
None => &Bytecode::default(),
};
if !bytecode.is_empty() && !bytecode.is_eip7702() {
return Err(InvalidTransaction::RejectCallerWithCode);
}
}
// Check that the transaction's nonce is correct
if !is_nonce_check_disabled {
let tx = tx_nonce;
let state = caller_info.nonce;
match tx.cmp(&state) {
Ordering::Greater => {
return Err(InvalidTransaction::NonceTooHigh { tx, state });
}
Ordering::Less => {
return Err(InvalidTransaction::NonceTooLow { tx, state });
}
_ => {}
}
}
Ok(())
}
Back to crates/handler/src/pre_execution.rs -> validate_against_state_and_deduct_caller,
continuing:
let new_balance = calculate_caller_fee(*caller.balance(), tx, block, cfg)?;
caller.set_balance(new_balance);
Jump to the implementation:
// crates/handler/src/pre_execution.rs
#[inline]
pub fn calculate_caller_fee(
balance: U256,
tx: impl Transaction,
block: impl Block,
cfg: impl Cfg,
) -> Result<U256, InvalidTransaction> {
let basefee = block.basefee() as u128;
let blob_price = block.blob_gasprice().unwrap_or_default();
let is_balance_check_disabled = cfg.is_balance_check_disabled();
if !is_balance_check_disabled {
tx.ensure_enough_balance(balance)?;
}
let effective_balance_spending = tx
.effective_balance_spending(basefee, blob_price)
.expect("effective balance is always smaller than max balance so it can't overflow");
let gas_balance_spending = effective_balance_spending - tx.value();
let mut new_balance = balance.saturating_sub(gas_balance_spending);
if is_balance_check_disabled {
new_balance = new_balance.max(tx.value());
}
Ok(new_balance)
}
The early part just fetches parameters. Let's jump directly to effective_balance_spending and look at its implementation.
Focus on the first section — this estimates the maximum fee the tx will spend:
gas_limit * max_fee + value + additional_gas_cost
// crates/context/interface/src/transaction.rs
#[inline]
fn effective_balance_spending(
&self,
base_fee: u128,
blob_price: u128,
) -> Result<U256, InvalidTransaction> {
let mut effective_balance_spending = (self.gas_limit() as u128)
.checked_mul(self.effective_gas_price(base_fee))
.and_then(|gas_cost| U256::from(gas_cost).checked_add(self.value()))
.ok_or(InvalidTransaction::OverflowPaymentInTransaction)?;
// add blob fee
if self.tx_type() == TransactionType::Eip4844 {
let blob_gas = self.total_blob_gas() as u128;
effective_balance_spending = effective_balance_spending
.checked_add(U256::from(blob_price.saturating_mul(blob_gas)))
.ok_or(InvalidTransaction::OverflowPaymentInTransaction)?;
}
Ok(effective_balance_spending)
}
Jump to effective_gas_price.
Gas Price is similar to the validate_tx_env from the previous article, so we won't go into detail.
You can compare them:
// crates/context/interface/src/transaction.rs
fn effective_gas_price(&self, base_fee: u128) -> u128 {
if self.tx_type() == TransactionType::Legacy as u8
|| self.tx_type() == TransactionType::Eip2930 as u8
{
return self.gas_price();
}
// for EIP-1559 tx and onwards gas_price represents maximum price.
let max_price = self.gas_price();
let Some(max_priority_fee) = self.max_priority_fee_per_gas() else {
return max_price;
};
min(max_price, base_fee.saturating_add(max_priority_fee))
}
Back to calculate_caller_fee.
The logic is fairly simple — subtracts Gas fees from the caller's Balance and ensures the remaining balance is greater than tx.value:
let gas_balance_spending = effective_balance_spending - tx.value();
let mut new_balance = balance.saturating_sub(gas_balance_spending);
if is_balance_check_disabled {
new_balance = new_balance.max(tx.value());
}
Ok(new_balance)
Back to crates/handler/src/pre_execution.rs -> validate_against_state_and_deduct_caller:
if tx.kind().is_call() {
caller.bump_nonce();
}
tx.kind has two variants:
- Call — both transfers and contract calls fall under this category
- Create — creating contracts bump_nonce increments the Caller's nonce by 1. Only the Call case is handled here. Both Create and Call are transactions initiated by the Caller, so normally both should nonce + 1. Create isn't handled here — it's deferred to the Execute stage, where nonce + 1 happens only after the contract is successfully created. Only on-chain transactions get nonce + 1. If contract creation fails during the constructor stage, it won't go on-chain.
2. load_accounts
Jump back to crates/handler/src/handler.rs -> pre_execution:
self.load_accounts(evm)?;
Jump directly to the implementation:
pub fn load_accounts<
EVM: EvmTr<Precompiles: PrecompileProvider<EVM::Context>>,
ERROR: From<<<EVM::Context as ContextTr>::Db as Database>::Error>,
>(
evm: &mut EVM,
) -> Result<(), ERROR> {
let (context, precompiles) = evm.ctx_precompiles();
let gen_spec = context.cfg().spec();
let spec = gen_spec.clone().into();
// sets eth spec id in journal
context.journal_mut().set_spec_id(spec);
let precompiles_changed = precompiles.set_spec(gen_spec);
let empty_warmed_precompiles = context.journal_mut().precompile_addresses().is_empty();
if precompiles_changed || empty_warmed_precompiles {
// load new precompile addresses into journal.
// When precompiles addresses are changed we reset the warmed hashmap to those new addresses.
context
.journal_mut()
.warm_precompiles(precompiles.warm_addresses().collect());
}
// Load coinbase
// EIP-3651: Warm COINBASE. Starts the `COINBASE` address warm
if spec.is_enabled_in(SpecId::SHANGHAI) {
let coinbase = context.block().beneficiary();
context.journal_mut().warm_coinbase_account(coinbase);
}
// Load access list
let (tx, journal) = context.tx_journal_mut();
// legacy is only tx type that does not have access list.
if tx.tx_type() != TransactionType::Legacy {
if let Some(access_list) = tx.access_list() {
let mut map: HashMap<Address, HashSet<StorageKey>> = HashMap::default();
for item in access_list {
map.entry(*item.address())
.or_default()
.extend(item.storage_slots().map(|key| U256::from_be_bytes(key.0)));
}
journal.warm_access_list(map);
}
}
Ok(())
}
Looks long, but the functionality is similar throughout.
Loading Precompiles addresses, Coinbase (miner) address, and the AccessList addresses mentioned in the previous article.
Let's look directly at the loading source code.
Except for Precompiles, the other two are simply set directly.
Precompiles initial addresses take this form:
- 0x0000000000000000000000000000000000000001
- 0x0000000000000000000000000000000000000002
- 0x0000000000000000000000000000000000000003 Short addresses only need the last two non-zero digits.
/// crates/context/src/journal/warm_addresses.rs
#[inline]
pub fn set_precompile_addresses(&mut self, addresses: HashSet<Address>) {
self.precompile_short_addresses.fill(false);
let mut all_short_addresses = true;
for address in addresses.iter() {
if let Some(short_address) = short_address(address) {
self.precompile_short_addresses.set(short_address, true);
} else {
all_short_addresses = false;
}
}
self.precompile_all_short_addresses = all_short_addresses;
self.precompile_set = addresses;
}
/// Set the coinbase address.
#[inline]
pub fn set_coinbase(&mut self, address: Address) {
self.coinbase = Some(address);
}
/// Set the access list.
#[inline]
pub fn set_access_list(&mut self, access_list: HashMap<Address, HashSet<StorageKey>>) {
self.access_list = access_list;
}
3. apply_eip7702_auth_list
Jump back to crates/handler/src/handler.rs -> pre_execution:
let gas = self.apply_eip7702_auth_list(evm)?;
This handles transactions of type EIP7702.
Non-EIP7702 types are skipped directly.
Before we begin, let's introduce EIP-7702.
Its core feature allows EOA accounts to execute complex logic like smart contracts.
The principle is using a private key signature to set the code field to a special bytecode pointing to a delegated address.
Subsequently, the EOA account can choose to make other contract calls through this delegated address.
The delegated account is called a smart contract account, and this delegation process is called account abstraction.
Let's jump directly to the function implementation.
This sets an EOA account as a smart contract account — it's not the Sender calling contracts through the delegated address.
One more point: the account abstraction account isn't necessarily issued by tx.caller (not distinguishing tx.origin here).
It can be signed by the account that wants to be abstracted and then broadcast by someone else.
// crates/handler/src/pre_execution.rs
#[inline]
pub fn apply_eip7702_auth_list<
CTX: ContextTr,
ERROR: From<InvalidTransaction> + From<<CTX::Db as Database>::Error>,
>(
context: &mut CTX,
) -> Result<u64, ERROR> {
let chain_id = context.cfg().chain_id();
let (tx, journal) = context.tx_journal_mut();
// Return if not EIP-7702 transaction.
if tx.tx_type() != TransactionType::Eip7702 {
return Ok(0);
}
apply_auth_list(chain_id, tx.authorization_list(), journal)
}
Continue jumping. The logic below has fairly clear comments, so instead of explaining one by one, comments are added directly in the code:
// crates/handler/src/pre_execution.rs
#[inline]
pub fn apply_auth_list<
JOURNAL: JournalTr,
ERROR: From<InvalidTransaction> + From<<JOURNAL::Database as Database>::Error>,
>(
chain_id: u64,
auth_list: impl Iterator<Item = impl AuthorizationTr>,
journal: &mut JOURNAL,
) -> Result<u64, ERROR> {
let mut refunded_accounts = 0;
for authorization in auth_list {
// 1. Verify the chain ID is 0 or the chain's current ID.
let auth_chain_id = authorization.chain_id();
if !auth_chain_id.is_zero() && auth_chain_id != U256::from(chain_id) {
continue;
}
// 2. Verify the `nonce` is less than `2**64 - 1`.
if authorization.nonce() == u64::MAX {
continue;
}
// Recover authority and authorization address.
// "authority" here refers to the address being abstracted.
// Since it's not necessarily the tx's caller, it must be recovered from the signature.
// 3. `authority = ecrecover(keccak(MAGIC || rlp([chain_id, address, nonce])), y_parity, r, s]`
let Some(authority) = authorization.authority() else {
continue;
};
// 4. Warm the authority and check the nonce.
// load_account_with_code_mut was discussed in depth earlier — loads account info.
let mut authority_acc = journal.load_account_with_code_mut(authority)?;
let authority_acc_info = &authority_acc.account().info;
// 5. Verify that `authority`'s code is empty or already delegated.
if let Some(bytecode) = &authority_acc_info.code {
// if it is not empty and it is not eip7702
if !bytecode.is_empty() && !bytecode.is_eip7702() {
continue;
}
}
// 6. Verify that `authority`'s nonce equals `nonce`. If `authority` doesn't exist in the trie, verify `nonce` equals `0`.
// authorization.nonce here is the original EOA account's nonce for authority.
// If sender and authority are the same, sender's nonce is authority's nonce.
if authorization.nonce() != authority_acc_info.nonce {
continue;
}
// 7. If `authority` exists in the trie, add `PER_EMPTY_ACCOUNT_COST - PER_AUTH_BASE_COST` gas to the global refund counter.
// This calculates refunds. EIP-7702 specifies that if authority is not an empty account, partial auth refund is returned.
// Auth fee calculation is in crates/interpreter/src/gas/calc.rs
// -> calculate_initial_tx_gas, covered in the previous article.
if !(authority_acc_info.is_empty()
&& authority_acc
.account()
.is_loaded_as_not_existing_not_touched())
{
refunded_accounts += 1;
}
// 8. Set `authority`'s code to `0xef0100 || address`. This is a delegation designation.
// * As a special case, if `address` is `0x000000000000000000000000000000000000000000`, no designation is written.
// Clear the account code and reset the account code hash to the empty hash `0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470`.
// 9. Increment `authority`'s nonce by 1.
authority_acc.delegate(authorization.address());
}
let refunded_gas =
refunded_accounts * (eip7702::PER_EMPTY_ACCOUNT_COST - eip7702::PER_AUTH_BASE_COST);
Ok(refunded_gas)
}
Top comments (0)