Validate
The previous article mentioned that the handler has five major steps. Validate is step one.
In this article, we'll analyze it in detail and understand what it's responsible for.
// crates/handler/src/handler.rs
#[inline]
fn validate(&self, evm: &mut Self::Evm) -> Result<InitialAndFloorGas, Self::Error> {
self.validate_env(evm)?;
self.validate_initial_tx_gas(evm)
}
From the function and internal function call names, we can see that Validate is mainly responsible for two parts:
validate_env: Validate the environment.
validate_initial_tx_gas: Validate the initial gas for the transaction.
validate_env
Let's first analyze validate_env — jump in:
// crates/handler/src/handler.rs
#[inline]
fn validate_env(&self, evm: &mut Self::Evm) -> Result<(), Self::Error> {
validation::validate_env(evm.ctx())
}
Nothing useful — just a function call. Continue jumping:
// crates/handler/src/validation.rs
pub fn validate_env<CTX: ContextTr, ERROR: From<InvalidHeader> + From<InvalidTransaction>>(
context: CTX,
) -> Result<(), ERROR> {
let spec = context.cfg().spec().into();
// `prevrandao` is required for the merge
if spec.is_enabled_in(SpecId::MERGE) && context.block().prevrandao().is_none() {
return Err(InvalidHeader::PrevrandaoNotSet.into());
}
// `excess_blob_gas` is required for Cancun
if spec.is_enabled_in(SpecId::CANCUN) && context.block().blob_excess_gas_and_price().is_none() {
return Err(InvalidHeader::ExcessBlobGasNotSet.into());
}
validate_tx_env::<CTX>(context, spec).map_err(Into::into)
}
let spec = context.cfg().spec().into()
Gets the spec from the current context, which can be understood as the current EVM version. Returns an enum type.
is_enabled_in()
Compares the current spec with the passed-in spec. Returns true if the current spec is greater than or equal to the passed-in spec.
context.block().prevrandao().is_none()
Checks whether prevrandao is set in the context.
context.block().blob_excess_gas_and_price().is_none()
Checks whether blob_excess_gas_and_price is set in the context.
We can see this section mainly checks: if a specific EVM version is set in the context, whether the features and upgrades of that version are implemented.
Moving on to this line — from the function name, it checks the tx environment:
validate_tx_env::<CTX>(context, spec).map_err(Into::into)
Let's jump directly to the function implementation:
// crates/handler/src/validation.rs
pub fn validate_tx_env<CTX: ContextTr>(
context: CTX,
spec_id: SpecId,
) -> Result<(), InvalidTransaction> {
// Check if the transaction's chain id is correct
let tx_type = context.tx().tx_type();
let tx = context.tx();
let base_fee = if context.cfg().is_base_fee_check_disabled() {
None
} else {
Some(context.block().basefee() as u128)
};
let tx_type = TransactionType::from(tx_type);
// Check chain_id if config is enabled.
// EIP-155: Simple replay attack protection
if context.cfg().tx_chain_id_check() {
if let Some(chain_id) = tx.chain_id() {
if chain_id != context.cfg().chain_id() {
return Err(InvalidTransaction::InvalidChainId);
}
} else if !tx_type.is_legacy() && !tx_type.is_custom() {
// Legacy transaction are the only one that can omit chain_id.
return Err(InvalidTransaction::MissingChainId);
}
}
// EIP-7825: Transaction Gas Limit Cap
let cap = context.cfg().tx_gas_limit_cap();
if tx.gas_limit() > cap {
return Err(InvalidTransaction::TxGasLimitGreaterThanCap {
gas_limit: tx.gas_limit(),
cap,
});
}
let disable_priority_fee_check = context.cfg().is_priority_fee_check_disabled();
match tx_type {
TransactionType::Legacy => {
validate_legacy_gas_price(tx.gas_price(), base_fee)?;
}
TransactionType::Eip2930 => {
// Enabled in BERLIN hardfork
if !spec_id.is_enabled_in(SpecId::BERLIN) {
return Err(InvalidTransaction::Eip2930NotSupported);
}
validate_legacy_gas_price(tx.gas_price(), base_fee)?;
}
TransactionType::Eip1559 => {
if !spec_id.is_enabled_in(SpecId::LONDON) {
return Err(InvalidTransaction::Eip1559NotSupported);
}
validate_priority_fee_for_tx(tx, base_fee, disable_priority_fee_check)?;
}
TransactionType::Eip4844 => {
if !spec_id.is_enabled_in(SpecId::CANCUN) {
return Err(InvalidTransaction::Eip4844NotSupported);
}
validate_priority_fee_for_tx(tx, base_fee, disable_priority_fee_check)?;
validate_eip4844_tx(
tx.blob_versioned_hashes(),
tx.max_fee_per_blob_gas(),
context.block().blob_gasprice().unwrap_or_default(),
context.cfg().max_blobs_per_tx(),
)?;
}
TransactionType::Eip7702 => {
// Check if EIP-7702 transaction is enabled.
if !spec_id.is_enabled_in(SpecId::PRAGUE) {
return Err(InvalidTransaction::Eip7702NotSupported);
}
validate_priority_fee_for_tx(tx, base_fee, disable_priority_fee_check)?;
let auth_list_len = tx.authorization_list_len();
// The transaction is considered invalid if the length of authorization_list is zero.
if auth_list_len == 0 {
return Err(InvalidTransaction::EmptyAuthorizationList);
}
}
TransactionType::Custom => {
// Custom transaction type check is not done here.
}
};
// Check if gas_limit is more than block_gas_limit
if !context.cfg().is_block_gas_limit_disabled() && tx.gas_limit() > context.block().gas_limit()
{
return Err(InvalidTransaction::CallerGasLimitMoreThanBlock);
}
// EIP-3860: Limit and meter initcode. Still valid with EIP-7907 and increase of initcode size.
if spec_id.is_enabled_in(SpecId::SHANGHAI)
&& tx.kind().is_create()
&& context.tx().input().len() > context.cfg().max_initcode_size()
{
return Err(InvalidTransaction::CreateInitCodeSizeLimit);
}
Ok(())
}
It looks long, but it's really just various checks. Let's break it down section by section:
// crates/handler/src/validation.rs
// Check chain_id if config is enabled.
// EIP-155: Simple replay attack protection
if context.cfg().tx_chain_id_check() {
if let Some(chain_id) = tx.chain_id() {
if chain_id != context.cfg().chain_id() {
return Err(InvalidTransaction::InvalidChainId);
}
} else if !tx_type.is_legacy() && !tx_type.is_custom() {
// Legacy transaction are the only one that can omit chain_id.
return Err(InvalidTransaction::MissingChainId);
}
}
Checks whether tx_chain_id_check is enabled in the Context.
If tx_chain_id_check is enabled, it checks whether the current tx's chain_id matches the context's.
The chain_id verification is to prevent replay attacks.
Without a chain_id, a successful transaction you sent on ETH could be replayed by others on another EVM chain, causing you to lose assets on other chains. (For example, if you have 10 tokens on both BSC and ETH, and you only sent 1 ETH to A on ETH. Someone else could take your raw Transaction from ETH and broadcast it on BSC, causing you to also send 1 BNB to A on BSC.)
// EIP-7825: Transaction Gas Limit Cap
let cap = context.cfg().tx_gas_limit_cap();
if tx.gas_limit() > cap {
return Err(InvalidTransaction::TxGasLimitGreaterThanCap {
gas_limit: tx.gas_limit(),
cap,
});
}
Checks whether the current tx's GasLimit exceeds the value set in Context.
EIP-7825 is primarily to prevent attacks.
Without a limit, a single tx could bloat the block, reducing the number of transactions that can be packaged. This would cause network congestion.
The higher the GasLimit, the more complex the logic, and during processing CPU/memory can explode, slowing down node synchronization too.
match tx_type {
TransactionType::Legacy => {
validate_legacy_gas_price(tx.gas_price(), base_fee)?;
}
TransactionType::Eip2930 => {
// Enabled in BERLIN hardfork
if !spec_id.is_enabled_in(SpecId::BERLIN) {
return Err(InvalidTransaction::Eip2930NotSupported);
}
validate_legacy_gas_price(tx.gas_price(), base_fee)?;
}
TransactionType::Eip1559 => {
if !spec_id.is_enabled_in(SpecId::LONDON) {
return Err(InvalidTransaction::Eip1559NotSupported);
}
validate_priority_fee_for_tx(tx, base_fee, disable_priority_fee_check)?;
}
TransactionType::Eip4844 => {
if !spec_id.is_enabled_in(SpecId::CANCUN) {
return Err(InvalidTransaction::Eip4844NotSupported);
}
validate_priority_fee_for_tx(tx, base_fee, disable_priority_fee_check)?;
validate_eip4844_tx(
tx.blob_versioned_hashes(),
tx.max_fee_per_blob_gas(),
context.block().blob_gasprice().unwrap_or_default(),
context.cfg().max_blobs_per_tx(),
)?;
}
TransactionType::Eip7702 => {
// Check if EIP-7702 transaction is enabled.
if !spec_id.is_enabled_in(SpecId::PRAGUE) {
return Err(InvalidTransaction::Eip7702NotSupported);
}
validate_priority_fee_for_tx(tx, base_fee, disable_priority_fee_check)?;
let auth_list_len = tx.authorization_list_len();
// The transaction is considered invalid if the length of authorization_list is zero.
if auth_list_len == 0 {
return Err(InvalidTransaction::EmptyAuthorizationList);
}
}
TransactionType::Custom => {
// Custom transaction type check is not done here.
}
Before we start, let's explain EIP-1559. After EIP-1559, GasPrice is influenced by 3 components:
- Max Fee: The maximum GasPrice the Sender is willing to pay
- Priority Fee: Miner tip, used to speed up transactions
- Base Fee: The block's base fee, which gets burned
- GasPrice = Priority Fee + min(Base Fee, Max Fee - Base Fee)
Back to the code — this section looks long, but if you look closely,
except for Eip4844 and Eip7702, each check mainly does two things:
- Checks whether the current EVM version supports the transaction type
- Checks whether the GasPrice settings are correct
- validate_legacy_gas_price
- Ensures the tx's GasPrice is greater than the Base_Fee set in Context
- validate_priority_fee_for_tx
- Actually calls validate_priority_fee_tx; specifics are in the code
- validate_legacy_gas_price
pub fn validate_priority_fee_tx(
max_fee: u128,
max_priority_fee: u128,
base_fee: Option<u128>,
disable_priority_fee_check: bool,
) -> Result<(), InvalidTransaction> {
// Ensures max_priority_fee doesn't exceed max_fee
if !disable_priority_fee_check && max_priority_fee > max_fee {
return Err(InvalidTransaction::PriorityFeeGreaterThanMaxFee);
}
// This is the formula above — ensures Base fee + Max Priority Fee doesn't exceed Max Fee
if let Some(base_fee) = base_fee {
let effective_gas_price = cmp::min(max_fee, base_fee.saturating_add(max_priority_fee));
if effective_gas_price < base_fee {
return Err(InvalidTransaction::GasPriceLessThanBasefee);
}
}
Ok(())
}
- EIP-4844 tx handling — EIP-4844 mainly targets L2, skipping
- EIP-7702 tx handling — validates whether there's an authorization_list
validate_initial_tx_gas
According to the code comments, validate_initial_tx_gas is responsible for:
- Calculating the initial gas fee based on the transaction type and input data
- Including additional costs for access lists and authorization lists
- Verifying that the initial cost doesn't exceed the transaction gas limit
Let's continue with the code:
// crates/handler/src/handler.rs
#[inline]
fn validate_initial_tx_gas(
&self,
evm: &mut Self::Evm,
) -> Result<InitialAndFloorGas, Self::Error> {
let ctx = evm.ctx_ref();
validation::validate_initial_tx_gas(
ctx.tx(),
ctx.cfg().spec().into(),
ctx.cfg().is_eip7623_disabled(),
)
.map_err(From::from)
}
The main call is to validation::validate_initial_tx_gas. Let's jump in:
// crates/handler/src/validation.rs
pub fn validate_initial_tx_gas(
tx: impl Transaction,
spec: SpecId,
is_eip7623_disabled: bool,
) -> Result<InitialAndFloorGas, InvalidTransaction> {
let mut gas = gas::calculate_initial_tx_gas_for_tx(&tx, spec);
if is_eip7623_disabled {
gas.floor_gas = 0
}
// Additional check to see if limit is big enough to cover initial gas.
if gas.initial_gas > tx.gas_limit() {
return Err(InvalidTransaction::CallGasCostMoreThanGasLimit {
gas_limit: tx.gas_limit(),
initial_gas: gas.initial_gas,
});
}
// EIP-7623: Increase calldata cost
// floor gas should be less than gas limit.
if spec.is_enabled_in(SpecId::PRAGUE) && gas.floor_gas > tx.gas_limit() {
return Err(InvalidTransaction::GasFloorMoreThanGasLimit {
gas_floor: gas.floor_gas,
gas_limit: tx.gas_limit(),
});
};
Ok(gas)
}
Let's analyze step by step. First, focus on the first line — from the name it calculates the tx's initial gas:
let mut gas = gas::calculate_initial_tx_gas_for_tx(&tx, spec);
Jump in to see the implementation:
pub fn calculate_initial_tx_gas_for_tx(tx: impl Transaction, spec: SpecId) -> InitialAndFloorGas {
let mut accounts = 0;
let mut storages = 0;
// legacy is only tx type that does not have access list.
if tx.tx_type() != TransactionType::Legacy {
(accounts, storages) = tx
.access_list()
.map(|al| {
al.fold((0, 0), |(mut num_accounts, mut num_storage_slots), item| {
num_accounts += 1;
num_storage_slots += item.storage_slots().count();
(num_accounts, num_storage_slots)
})
})
.unwrap_or_default();
}
calculate_initial_tx_gas(
spec,
tx.input(),
tx.kind().is_create(),
accounts as u64,
storages as u64,
tx.authorization_list_len() as u64,
)
}
The first big section handles the access_list.
access_list: When sending a tx, you can pre-declare the list of addresses and storage keys that the tx will read or write during execution.
Addresses and storage keys declared in the tx are pre-loaded and warmed, which can reduce Gas consumption.
The first section looks uncomfortable to read, so let's break it down step by step.
First, let's look at the access_list return type — it returns
Option>>
fn access_list(&self) -> Option<impl Iterator<Item = Self::AccessListItem<'_>>>
// access_list is an Option — nothing happens for None, for Some the closure function inside map is executed
tx.access_list().map()
// fold performs an accumulation over the iterator's elements
// (0, 0) initial value
// (mut num_accounts, mut num_storage_slots) temp variables for storing results during accumulation
// item — the iterator's element
|al| {al.fold((0, 0), |(mut num_accounts, mut num_storage_slots), item| {
num_accounts += 1;
num_storage_slots += item.storage_slots().count();
(num_accounts, num_storage_slots)
}) }
Combined: if the access_list is not empty, it accumulates the accounts and storage_slots in the access_list, calculating the actual number of accounts and storage_slots.
Next, look at the calculate_initial_tx_gas part — jump to the implementation:
// crates/interpreter/src/gas/calc.rs
pub fn calculate_initial_tx_gas(
spec_id: SpecId,
input: &[u8],
is_create: bool,
access_list_accounts: u64,
access_list_storages: u64,
authorization_list_num: u64,
) -> InitialAndFloorGas {
let mut gas = InitialAndFloorGas::default();
// Initdate stipend
let tokens_in_calldata = get_tokens_in_calldata(input, spec_id.is_enabled_in(SpecId::ISTANBUL));
gas.initial_gas += tokens_in_calldata * STANDARD_TOKEN_COST;
// Get number of access list account and storages.
gas.initial_gas += access_list_accounts * ACCESS_LIST_ADDRESS;
gas.initial_gas += access_list_storages * ACCESS_LIST_STORAGE_KEY;
// Base stipend
gas.initial_gas += if is_create {
if spec_id.is_enabled_in(SpecId::HOMESTEAD) {
// EIP-2: Homestead Hard-fork Changes
53000
} else {
21000
}
} else {
21000
};
// EIP-3860: Limit and meter initcode
// Init code stipend for bytecode analysis
if spec_id.is_enabled_in(SpecId::SHANGHAI) && is_create {
gas.initial_gas += initcode_cost(input.len())
}
// EIP-7702
if spec_id.is_enabled_in(SpecId::PRAGUE) {
gas.initial_gas += authorization_list_num * eip7702::PER_EMPTY_ACCOUNT_COST;
// Calculate gas floor for EIP-7623
gas.floor_gas = calc_tx_floor_cost(tokens_in_calldata);
}
gas
}
Continue jumping to the get_tokens_in_calldata implementation:
#[inline]
pub fn get_tokens_in_calldata(input: &[u8], is_istanbul: bool) -> u64 {
let zero_data_len = input.iter().filter(|v| **v == 0).count() as u64;
let non_zero_data_len = input.len() as u64 - zero_data_len;
let non_zero_data_multiplier = if is_istanbul {
// EIP-2028: Transaction data gas cost reduction
NON_ZERO_BYTE_MULTIPLIER_ISTANBUL
} else {
NON_ZERO_BYTE_MULTIPLIER
};
zero_data_len + non_zero_data_len * non_zero_data_multiplier
}
The logic is simple — it finds the count of zero and non-zero bytes in the tx's calldata, multiplying the non-zero count by a fixed value.
This is done because calldata is permanently stored on-chain. Data with many zero bytes can be efficiently compressed for storage with low cost, while non-zero data is harder to compress and costs more to store.
The remaining logic in calculate_initial_tx_gas is fairly obvious, so we won't go into detail.
Jumping back to validate_initial_tx_gas, the remaining logic is also fairly obvious, so we won't continue either.
The validate stage ends here.
Top comments (0)