Skip to main content

ethrex_common/
validation.rs

1//! Stateless block validation utilities.
2//!
3//! This module provides pure validation functions that can be used without
4//! storage dependencies, making them suitable for use in zkVM guest programs.
5
6use crate::constants::{GAS_PER_BLOB, MAX_RLP_BLOCK_SIZE, POST_OSAKA_GAS_LIMIT_CAP};
7use crate::errors::InvalidBlockError;
8use crate::types::requests::{EncodedRequests, Requests, compute_requests_hash};
9use crate::types::{
10    Block, BlockHeader, ChainConfig, EIP4844Transaction, Receipt, Transaction,
11    compute_receipts_root_and_logs_bloom, validate_block_header, validate_cancun_header_fields,
12    validate_prague_header_fields, validate_pre_cancun_header_fields,
13};
14use ethrex_crypto::{Crypto, NativeCrypto};
15use ethrex_rlp::encode::RLPEncode;
16
17/// Performs pre-execution validation of the block's header values in reference to the parent_header.
18/// Verifies that blob gas fields in the header are correct in reference to the block's body.
19/// If a block passes this check, execution will still fail with execute_block when a transaction runs out of gas.
20///
21/// # WARNING
22///
23/// This doesn't validate that the transactions or withdrawals root of the header matches the body
24/// contents, since we assume the caller already did it. And, in any case, that wouldn't invalidate the block header.
25///
26/// To validate it, use [`ethrex_common::types::validate_block_body`]
27pub fn validate_block_pre_execution(
28    block: &Block,
29    parent_header: &BlockHeader,
30    chain_config: &ChainConfig,
31    elasticity_multiplier: u64,
32) -> Result<(), InvalidBlockError> {
33    // Verify initial header validity against parent
34    validate_block_header(&block.header, parent_header, elasticity_multiplier)?;
35
36    if chain_config.is_osaka_activated(block.header.timestamp) {
37        let block_rlp_size = block.length();
38        if block_rlp_size > MAX_RLP_BLOCK_SIZE as usize {
39            return Err(InvalidBlockError::MaximumRlpSizeExceeded(
40                MAX_RLP_BLOCK_SIZE,
41                block_rlp_size as u64,
42            ));
43        }
44    }
45    if chain_config.is_prague_activated(block.header.timestamp) {
46        validate_prague_header_fields(&block.header, parent_header, chain_config)?;
47        verify_blob_gas_usage(block, chain_config)?;
48        if chain_config.is_osaka_activated(block.header.timestamp)
49            && !chain_config.is_amsterdam_activated(block.header.timestamp)
50        {
51            verify_transaction_max_gas_limit(block)?;
52        }
53    } else if chain_config.is_cancun_activated(block.header.timestamp) {
54        validate_cancun_header_fields(&block.header, parent_header, chain_config)?;
55        verify_blob_gas_usage(block, chain_config)?;
56    } else {
57        validate_pre_cancun_header_fields(&block.header)?;
58    }
59
60    // A transaction's chain id is bound into its signature (EIP-155 / the typed-tx
61    // `chain_id` field), so the recovered sender is only valid for that chain. Reject any
62    // transaction whose chain id does not match this chain: other clients (geth's signer)
63    // reject it at block validation, so accepting one would split consensus. Legacy
64    // pre-EIP-155 transactions carry no chain id (`None`) and are left untouched.
65    //
66    // Privileged (L2) transactions are exempt: their sender comes from an unsigned `from`
67    // field, so chain id is not a signature-binding scalar for them, and on L2 it may name
68    // a different source chain (cross-chain deposits). On L1 they are rejected outright as
69    // an unsupported transaction type, so this exemption opens no L1 gap. The other L2-only
70    // type, `FeeTokenTransaction` (0x7d), is signature-recovered and therefore stays
71    // checked — on L2 it carries the L2 chain id; on L1 it is rejected as unsupported.
72    for tx in &block.body.transactions {
73        if matches!(tx, Transaction::PrivilegedL2Transaction(_)) {
74            continue;
75        }
76        if let Some(tx_chain_id) = tx.chain_id()
77            && tx_chain_id != chain_config.chain_id
78        {
79            return Err(InvalidBlockError::InvalidTransactionChainId {
80                have: tx_chain_id,
81                want: chain_config.chain_id,
82            });
83        }
84    }
85
86    Ok(())
87}
88
89/// Validates that the block gas used matches the block header.
90/// For Amsterdam+ (EIP-7778), block_gas_used is PRE-REFUND and differs from
91/// receipt cumulative_gas_used which is POST-REFUND.
92pub fn validate_gas_used(
93    block_gas_used: u64,
94    block_header: &BlockHeader,
95) -> Result<(), InvalidBlockError> {
96    if block_gas_used != block_header.gas_used {
97        return Err(InvalidBlockError::GasUsedMismatch(
98            block_gas_used,
99            block_header.gas_used,
100        ));
101    }
102    Ok(())
103}
104
105/// Validates both the receipts root and the header `logs_bloom` against the executed
106/// receipts in a single pass.
107///
108/// The receipts root commits to each receipt's per-receipt bloom, but *not* to the
109/// header's aggregate `logs_bloom` field — so the aggregate must be checked separately
110/// or a block with a correct receipts root but an arbitrary bloom would be accepted,
111/// diverging from geth/reth. Both checks need each receipt's bloom, so computing them
112/// together hashes it only once (it is cycle-counted in the zkVM guest).
113pub fn validate_receipts_root_and_logs_bloom(
114    block_header: &BlockHeader,
115    receipts: &[Receipt],
116    crypto: &dyn Crypto,
117) -> Result<(), InvalidBlockError> {
118    let (receipts_root, logs_bloom) = compute_receipts_root_and_logs_bloom(receipts, crypto);
119
120    if receipts_root != block_header.receipts_root {
121        return Err(InvalidBlockError::ReceiptsRootMismatch);
122    }
123    if logs_bloom != block_header.logs_bloom {
124        return Err(InvalidBlockError::LogsBloomMismatch);
125    }
126    Ok(())
127}
128
129/// Validates that the requests hash matches the block header (Prague+).
130pub fn validate_requests_hash(
131    header: &BlockHeader,
132    chain_config: &ChainConfig,
133    requests: &[Requests],
134) -> Result<(), InvalidBlockError> {
135    if !chain_config.is_prague_activated(header.timestamp) {
136        return Ok(());
137    }
138
139    let encoded_requests: Vec<EncodedRequests> = requests.iter().map(|r| r.encode()).collect();
140    let computed_requests_hash = compute_requests_hash(&encoded_requests);
141    let valid = header
142        .requests_hash
143        .map(|requests_hash| requests_hash == computed_requests_hash)
144        .unwrap_or(false);
145
146    if !valid {
147        return Err(InvalidBlockError::RequestsHashMismatch);
148    }
149
150    Ok(())
151}
152
153/// Helper to validate that all indices in an iterator are within bounds.
154fn validate_bal_indices(
155    indices: impl Iterator<Item = u32>,
156    max_valid_index: u32,
157) -> Result<(), InvalidBlockError> {
158    for index in indices {
159        if index > max_valid_index {
160            return Err(InvalidBlockError::BlockAccessListIndexOutOfBounds {
161                index,
162                max: max_valid_index,
163            });
164        }
165    }
166    Ok(())
167}
168
169/// Validates that all indices in the header BAL are within valid bounds (Amsterdam+).
170/// This is a subset of the full hash check — used in the parallel execution path
171/// where we have the header BAL but do not build a new BAL during execution.
172/// Per EIP-7928: valid indices are 0 (pre-exec) through len(transactions)+1 (post-exec).
173pub fn validate_header_bal_indices(
174    bal: &crate::types::block_access_list::BlockAccessList,
175    transaction_count: usize,
176) -> Result<(), InvalidBlockError> {
177    let max_valid_index = u32::try_from(transaction_count + 1).unwrap_or(u32::MAX);
178
179    for account in bal.accounts() {
180        validate_bal_indices(
181            account
182                .storage_changes
183                .iter()
184                .flat_map(|slot| slot.slot_changes.iter().map(|c| c.block_access_index)),
185            max_valid_index,
186        )?;
187        validate_bal_indices(
188            account.balance_changes.iter().map(|c| c.block_access_index),
189            max_valid_index,
190        )?;
191        validate_bal_indices(
192            account.nonce_changes.iter().map(|c| c.block_access_index),
193            max_valid_index,
194        )?;
195        validate_bal_indices(
196            account.code_changes.iter().map(|c| c.block_access_index),
197            max_valid_index,
198        )?;
199    }
200    Ok(())
201}
202
203/// Validates that the block access list hash matches the block header (Amsterdam+).
204/// Also validates that all BlockAccessIndex values are within valid bounds per EIP-7928,
205/// and that the BAL size does not exceed the gas-derived limit.
206pub fn validate_block_access_list_hash(
207    header: &BlockHeader,
208    chain_config: &ChainConfig,
209    computed_bal: &crate::types::block_access_list::BlockAccessList,
210    transaction_count: usize,
211    crypto: &dyn Crypto,
212) -> Result<(), InvalidBlockError> {
213    use crate::constants::BAL_ITEM_COST;
214
215    // BAL validation only applies to Amsterdam+ forks
216    if !chain_config.is_amsterdam_activated(header.timestamp) {
217        return Ok(());
218    }
219
220    // Per EIP-7928: "Invalidate block if access list...contains indices exceeding len(transactions) + 1"
221    // Index semantics: 0=pre-exec, 1..n=tx indices, n+1=post-exec (withdrawals)
222    let max_valid_index = u32::try_from(transaction_count + 1).unwrap_or(u32::MAX);
223
224    // Validate all indices and compute item count in a single pass over the BAL.
225    let mut bal_items: u64 = 0;
226    for account in computed_bal.accounts() {
227        bal_items += 1; // address
228        bal_items += account.storage_reads.len() as u64;
229        bal_items += account.storage_changes.len() as u64;
230
231        // Check storage_changes indices
232        validate_bal_indices(
233            account
234                .storage_changes
235                .iter()
236                .flat_map(|slot| slot.slot_changes.iter().map(|c| c.block_access_index)),
237            max_valid_index,
238        )?;
239
240        // Check balance_changes indices
241        validate_bal_indices(
242            account.balance_changes.iter().map(|c| c.block_access_index),
243            max_valid_index,
244        )?;
245
246        // Check nonce_changes indices
247        validate_bal_indices(
248            account.nonce_changes.iter().map(|c| c.block_access_index),
249            max_valid_index,
250        )?;
251
252        // Check code_changes indices
253        validate_bal_indices(
254            account.code_changes.iter().map(|c| c.block_access_index),
255            max_valid_index,
256        )?;
257    }
258
259    // EIP-7928 size cap: bal_items <= gas_limit / GAS_BLOCK_ACCESS_LIST_ITEM
260    let max_items = header.gas_limit / BAL_ITEM_COST;
261    if bal_items > max_items {
262        return Err(InvalidBlockError::BlockAccessListSizeExceeded {
263            items: bal_items,
264            max_items,
265        });
266    }
267
268    let computed_hash = computed_bal.compute_hash(crypto);
269    let valid = header
270        .block_access_list_hash
271        .map(|expected_hash| expected_hash == computed_hash)
272        .unwrap_or(false);
273
274    if !valid {
275        return Err(InvalidBlockError::BlockAccessListHashMismatch);
276    }
277
278    Ok(())
279}
280
281/// Validates that the block access list does not exceed the maximum allowed size (Amsterdam+).
282/// Per EIP-7928: bal_items <= block_gas_limit // GAS_BLOCK_ACCESS_LIST_ITEM
283///
284/// Prefer using [`validate_block_access_list_hash`] when both hash and size validation are needed,
285/// as it performs both checks in a single pass over the BAL.
286pub fn validate_block_access_list_size(
287    header: &BlockHeader,
288    chain_config: &ChainConfig,
289    computed_bal: &crate::types::block_access_list::BlockAccessList,
290) -> Result<(), InvalidBlockError> {
291    use crate::constants::BAL_ITEM_COST;
292
293    if !chain_config.is_amsterdam_activated(header.timestamp) {
294        return Ok(());
295    }
296
297    let bal_items = computed_bal.item_count();
298    let max_items = header.gas_limit / BAL_ITEM_COST;
299
300    if bal_items > max_items {
301        return Err(InvalidBlockError::BlockAccessListSizeExceeded {
302            items: bal_items,
303            max_items,
304        });
305    }
306
307    Ok(())
308}
309
310/// Perform validations over the block's blob gas usage.
311/// Must be called only if the block has cancun activated.
312fn verify_blob_gas_usage(block: &Block, config: &ChainConfig) -> Result<(), InvalidBlockError> {
313    let mut blob_gas_used = 0_u32;
314    let mut blobs_in_block = 0_u32;
315    let max_blob_number_per_block = config
316        .get_fork_blob_schedule(block.header.timestamp)
317        .map(|schedule| schedule.max)
318        .ok_or(InvalidBlockError::InvalidBlockFork)?;
319    let max_blob_gas_per_block = max_blob_number_per_block * GAS_PER_BLOB;
320
321    for transaction in block.body.transactions.iter() {
322        if let crate::types::Transaction::EIP4844Transaction(tx) = transaction {
323            blob_gas_used += get_total_blob_gas(tx);
324            blobs_in_block += tx.blob_versioned_hashes.len() as u32;
325        }
326    }
327    if blob_gas_used > max_blob_gas_per_block {
328        return Err(InvalidBlockError::ExceededMaxBlobGasPerBlock);
329    }
330    if blobs_in_block > max_blob_number_per_block {
331        return Err(InvalidBlockError::ExceededMaxBlobNumberPerBlock);
332    }
333    if block
334        .header
335        .blob_gas_used
336        .is_some_and(|header_blob_gas_used| header_blob_gas_used != blob_gas_used as u64)
337    {
338        return Err(InvalidBlockError::BlobGasUsedMismatch);
339    }
340    Ok(())
341}
342
343/// Perform validations over the block's gas usage.
344/// Must be called only if the block has osaka activated
345/// as specified in https://eips.ethereum.org/EIPS/eip-7825
346fn verify_transaction_max_gas_limit(block: &Block) -> Result<(), InvalidBlockError> {
347    for transaction in block.body.transactions.iter() {
348        if transaction.gas_limit() > POST_OSAKA_GAS_LIMIT_CAP {
349            return Err(InvalidBlockError::InvalidTransaction(format!(
350                "Transaction gas limit exceeds maximum. Transaction hash: {}, transaction gas limit: {}",
351                transaction.hash(&NativeCrypto),
352                transaction.gas_limit()
353            )));
354        }
355    }
356    Ok(())
357}
358
359/// Calculates the blob gas required by a transaction.
360pub fn get_total_blob_gas(tx: &EIP4844Transaction) -> u32 {
361    GAS_PER_BLOB * tx.blob_versioned_hashes.len() as u32
362}