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