diff --git a/crates/common/types/block_execution_witness.rs b/crates/common/types/block_execution_witness.rs index 71071764fa..35ecfada8d 100644 --- a/crates/common/types/block_execution_witness.rs +++ b/crates/common/types/block_execution_witness.rs @@ -140,47 +140,30 @@ impl TryFrom for RpcExecutionWitness { impl RpcExecutionWitness { /// Convert an RPC execution witness into the internal [`ExecutionWitness`] /// format by rebuilding trie structures from the flat node list. + /// `decoded_headers` is reused (typically from [`decode_witness_headers`]) + /// to avoid a second RLP decode. pub fn into_execution_witness( self, chain_config: ChainConfig, first_block_number: u64, + decoded_headers: &[BlockHeader], ) -> Result { - if first_block_number == 0 { - return Err(GuestProgramStateError::Custom( - "first_block_number must be > 0 (need parent header)".to_string(), - )); - } - - let mut initial_state_root = None; - - for h in &self.headers { - let header = BlockHeader::decode(h)?; - if header.number == first_block_number - 1 { - initial_state_root = Some(header.state_root); - break; - } - } - - let initial_state_root = initial_state_root.ok_or_else(|| { - GuestProgramStateError::Custom(format!( - "header for block {} not found", - first_block_number - 1 - )) - })?; - + let initial_state_root = find_parent_state_root(decoded_headers, first_block_number)?; + // Drop the `0x80` Null-node sentinel some `debug_executionWitness` producers emit. + // Undecodable/unused nodes are dropped per EELS + // `test_validation_state_extra_unused_trie_node`; missing needed nodes surface + // later as `RootNotFound` from `get_embedded_root`. let nodes: BTreeMap = self .state .into_iter() .filter_map(|b| { if b == Bytes::from_static(&[0x80]) { - // other implementations of debug_executionWitness allow for a `Null` node, - // which would fail to decode in ours return None; } - let hash = keccak(&b); - Some(Node::decode(&b).map(|node| (hash, node))) + let node = Node::decode(&b).ok()?; + Some((keccak(&b), node)) }) - .collect::>()?; + .collect(); // get state trie root and embed the rest of the trie into it let state_trie_root = if let NodeRef::Node(state_trie_root, _) = @@ -231,6 +214,58 @@ impl RpcExecutionWitness { } } +/// RLP-decode the raw header byte slices into a `Vec`. +pub fn decode_witness_headers>( + headers_bytes: &[B], +) -> Result, GuestProgramStateError> { + headers_bytes + .iter() + .map(|b| BlockHeader::decode(b.as_ref()).map_err(GuestProgramStateError::from)) + .collect() +} + +/// Locate the parent block's state root from a slice of decoded headers. +/// Returns an error if `first_block_number == 0` (no parent possible) or if the +/// parent header is not in the slice. +fn find_parent_state_root( + headers: &[BlockHeader], + first_block_number: u64, +) -> Result { + let parent_number = first_block_number + .checked_sub(1) + .ok_or(GuestProgramStateError::Custom( + "first_block_number must be > 0 (need parent header)".to_string(), + ))?; + headers + .iter() + .find(|h| h.number == parent_number) + .map(|h| h.state_root) + .ok_or_else(|| { + GuestProgramStateError::Custom(format!("header for block {parent_number} not found")) + }) +} + +/// Check that `headers[1..]` link via `parent_hash == keccak(RLP(prev))` AND +/// `number == prev.number + 1`, in input order. The first header is +/// intentionally unanchored here; the parent end is bound by the post-execution +/// state-root check in `execute_blocks`. +/// +/// Call before any sort/dedup, since reordering hides violations. +pub fn validate_witness_headers_chain( + headers: &[BlockHeader], + crypto: &dyn Crypto, +) -> Result<(), GuestProgramStateError> { + for pair in headers.windows(2) { + let (prev, next) = (&pair[0], &pair[1]); + if prev.number.checked_add(1) != Some(next.number) + || next.parent_hash != prev.compute_block_hash(crypto) + { + return Err(GuestProgramStateError::NoncontiguousBlockHeaders); + } + } + Ok(()) +} + /// Recursively walks an embedded state trie node and collects /// `(hashed_address, storage_root)` pairs from leaf nodes. fn collect_accounts_from_trie( @@ -308,8 +343,10 @@ pub enum GuestProgramStateError { NoBlockHeaders, #[error("Parent block header of block {0} was not found")] MissingParentHeaderOf(u64), - #[error("Non-contiguous block headers (there's a gap in the block headers list)")] + #[error("Non-contiguous block headers")] NoncontiguousBlockHeaders, + #[error("Bytecode for code hash {0} was not found in witness")] + MissingBytecode(H256), #[error("Trie error: {0}")] Trie(#[from] TrieError), #[error("RLP Decode: {0}")] @@ -589,24 +626,18 @@ impl GuestProgramState { } /// Retrieves the account code for a specific account. - /// Returns an Err if the code is not found. + /// + /// A missing code hash is always an error: ethrex's own witness producers + /// include every byte of code the VM reads, and EIP-8025 stateless + /// validation requires the same. pub fn get_account_code(&self, code_hash: H256) -> Result { if code_hash == *EMPTY_KECCACK_HASH { return Ok(Code::default()); } - match self.codes_hashed.get(&code_hash) { - Some(code) => Ok(code.clone()), - None => { - // We do this because what usually happens is that the Witness doesn't have the code we asked for but it is because it isn't relevant for that particular case. - // In client implementations there are differences and it's natural for some clients to access more/less information in some edge cases. - // Sidenote: logger doesn't work inside SP1, that's why we use println! - println!( - "Missing bytecode for hash {} in witness. Defaulting to empty code.", // If there's a state root mismatch and this prints we have to see if it's the cause or not. - hex::encode(code_hash) - ); - Ok(Code::default()) - } - } + self.codes_hashed + .get(&code_hash) + .cloned() + .ok_or(GuestProgramStateError::MissingBytecode(code_hash)) } /// Retrieves code metadata (length) for a specific code hash. @@ -615,24 +646,15 @@ impl GuestProgramState { &self, code_hash: H256, ) -> Result { - use crate::constants::EMPTY_KECCACK_HASH; - if code_hash == *EMPTY_KECCACK_HASH { return Ok(CodeMetadata { length: 0 }); } - match self.codes_hashed.get(&code_hash) { - Some(code) => Ok(CodeMetadata { + self.codes_hashed + .get(&code_hash) + .map(|code| CodeMetadata { length: code.bytecode.len() as u64, - }), - None => { - // Same as get_account_code - default to empty for missing bytecode - println!( - "Missing bytecode for hash {} in witness. Defaulting to empty code metadata.", - hex::encode(code_hash) - ); - Ok(CodeMetadata { length: 0 }) - } - } + }) + .ok_or(GuestProgramStateError::MissingBytecode(code_hash)) } /// When executing multiple blocks in the L2 it happens that the headers in block_headers correspond to the same block headers that we have in the blocks array. The main goal is to hash these only once and set them in both places. diff --git a/crates/guest-program/src/l1/input.rs b/crates/guest-program/src/l1/input.rs index f69f95adcc..302f76a3d5 100644 --- a/crates/guest-program/src/l1/input.rs +++ b/crates/guest-program/src/l1/input.rs @@ -1,7 +1,13 @@ +//! `ProgramInput` is a `struct` without the `eip-8025` feature and an `enum` +//! with it. The `new(...)` constructor and `Default` exist under both, but +//! pattern-matching on `Wire(...)`/`Direct { .. }` only compiles when +//! `eip-8025` is on. + use ethrex_common::types::Block; use ethrex_common::types::block_execution_witness::ExecutionWitness; /// Input for the L1 stateless validation program. +#[cfg(not(feature = "eip-8025"))] #[derive( Clone, Default, @@ -18,6 +24,7 @@ pub struct ProgramInput { pub execution_witness: ExecutionWitness, } +#[cfg(not(feature = "eip-8025"))] impl ProgramInput { /// Creates a new ProgramInput with the given blocks and execution witness. pub fn new(blocks: Vec, execution_witness: ExecutionWitness) -> Self { @@ -28,6 +35,45 @@ impl ProgramInput { } } +/// Input for the L1 stateless validation program (EIP-8025 build). +/// +/// `Direct` carries in-memory blocks + witness (test path). `Wire` carries an +/// already-decoded EIP-8025 stateless input from spec wire bytes. +#[cfg(feature = "eip-8025")] +pub enum ProgramInput { + Direct { + blocks: Vec, + execution_witness: ExecutionWitness, + }, + Wire(DecodedEip8025), +} + +#[cfg(feature = "eip-8025")] +impl Default for ProgramInput { + fn default() -> Self { + Self::Direct { + blocks: Vec::new(), + execution_witness: ExecutionWitness::default(), + } + } +} + +#[cfg(feature = "eip-8025")] +impl ProgramInput { + /// Creates a `Direct` ProgramInput from in-memory blocks and execution witness. + pub fn new(blocks: Vec, execution_witness: ExecutionWitness) -> Self { + Self::Direct { + blocks, + execution_witness, + } + } + + /// Creates a `Wire` ProgramInput from an already-decoded EIP-8025 payload. + pub fn wire(decoded: DecodedEip8025) -> Self { + Self::Wire(decoded) + } +} + /// Wire-format version byte for the legacy EIP-8025 framing. #[cfg(feature = "eip-8025")] pub const EIP8025_VERSION_LEGACY: u8 = 0x00; @@ -79,13 +125,57 @@ const MAX_BYTES_PER_HEADER: usize = 1 << 10; #[cfg(feature = "eip-8025")] const MAX_PUBLIC_KEYS: usize = 1 << 20; #[cfg(feature = "eip-8025")] -const MAX_BYTES_PER_PUBLIC_KEY: usize = 65; +const BYTES_PER_PUBLIC_KEY: usize = 65; + +/// SSZ shape of the per-tx public key list in `CanonicalStatelessInput`: +/// one fixed-size 65-byte uncompressed secp256k1 key per transaction. +#[cfg(feature = "eip-8025")] +pub type PublicKeysList = + libssz_types::SszList, MAX_PUBLIC_KEYS>; +#[cfg(feature = "eip-8025")] +const MAX_OPTIONAL_FORK_ACTIVATION_VALUES: usize = 1; +#[cfg(feature = "eip-8025")] +const MAX_BLOB_SCHEDULES_PER_FORK: usize = 1; + +/// Big-endian schema-id prefix on canonical `SszStatelessInput` wire bytes. +#[cfg(feature = "eip-8025")] +pub const STATELESS_INPUT_SCHEMA_ID: u16 = 0x0001; +/// Byte length of [`STATELESS_INPUT_SCHEMA_ID`] on the wire. +#[cfg(feature = "eip-8025")] +pub const STATELESS_INPUT_SCHEMA_ID_SIZE: usize = 2; + +/// Mirrors `SszBlobSchedule` from the Amsterdam stateless-validation spec. +#[cfg(feature = "eip-8025")] +#[derive(Debug, Clone, PartialEq, Eq, libssz_derive::SszEncode, libssz_derive::SszDecode)] +pub struct CanonicalBlobSchedule { + pub target: u64, + pub max: u64, + pub base_fee_update_fraction: u64, +} + +/// Mirrors `SszForkActivation` from the Amsterdam stateless-validation spec. +#[cfg(feature = "eip-8025")] +#[derive(Debug, Clone, PartialEq, Eq, libssz_derive::SszEncode, libssz_derive::SszDecode)] +pub struct CanonicalForkActivation { + pub block_number: libssz_types::SszList, + pub timestamp: libssz_types::SszList, +} + +/// Mirrors `SszForkConfig` from the Amsterdam stateless-validation spec. +#[cfg(feature = "eip-8025")] +#[derive(Debug, Clone, PartialEq, Eq, libssz_derive::SszEncode, libssz_derive::SszDecode)] +pub struct CanonicalForkConfig { + pub fork: u64, + pub activation: CanonicalForkActivation, + pub blob_schedule: libssz_types::SszList, +} /// Mirrors `SszChainConfig` from the Amsterdam stateless-validation spec. #[cfg(feature = "eip-8025")] #[derive(Debug, Clone, PartialEq, Eq, libssz_derive::SszEncode, libssz_derive::SszDecode)] pub struct CanonicalChainConfig { pub chain_id: u64, + pub active_fork: CanonicalForkConfig, } /// Mirrors `SszExecutionWitness` from the Amsterdam stateless-validation spec. @@ -109,10 +199,9 @@ pub struct CanonicalStatelessInput { pub new_payload_request: ethrex_common::types::eip8025_ssz::NewPayloadRequestAmsterdam, pub witness: CanonicalExecutionWitness, pub chain_config: CanonicalChainConfig, - // Currently the specs do not include proper values for this field, - // but it is planned to be supported in the next release. - pub public_keys: - libssz_types::SszList, MAX_PUBLIC_KEYS>, + /// Per-transaction public keys (uncompressed secp256k1, 65 bytes each). + /// Mirrors `SszList[ByteVector[PUBLIC_KEY_BYTES], MAX_PUBLIC_KEYS]` in the spec. + pub public_keys: PublicKeysList, } /// Decoded EIP-8025 wire payload, dispatched by version byte. @@ -173,6 +262,26 @@ pub fn decode_eip8025(bytes: &[u8]) -> Result Result { + use libssz::SszDecode; + + if bytes.len() < STATELESS_INPUT_SCHEMA_ID_SIZE { + return Err(ProgramInputDecodeError::TooShort); + } + let (schema_bytes, ssz_bytes) = bytes.split_at(STATELESS_INPUT_SCHEMA_ID_SIZE); + let schema_id = u16::from_be_bytes([schema_bytes[0], schema_bytes[1]]); + if schema_id != STATELESS_INPUT_SCHEMA_ID { + return Err(ProgramInputDecodeError::UnknownSchemaId(schema_id)); + } + CanonicalStatelessInput::from_ssz_bytes(ssz_bytes).map_err(ProgramInputDecodeError::Ssz) +} + #[cfg(feature = "eip-8025")] fn decode_eip8025_legacy( bytes: &[u8], @@ -269,6 +378,7 @@ pub enum ProgramInputDecodeError { Ssz(libssz::DecodeError), Rkyv(String), UnknownVersion(u8), + UnknownSchemaId(u16), } #[cfg(feature = "eip-8025")] @@ -279,6 +389,9 @@ impl core::fmt::Display for ProgramInputDecodeError { Self::Ssz(e) => write!(f, "SSZ decode error: {e}"), Self::Rkyv(e) => write!(f, "rkyv decode error: {e}"), Self::UnknownVersion(v) => write!(f, "unknown EIP-8025 wire version: {v:#04x}"), + Self::UnknownSchemaId(v) => { + write!(f, "unknown stateless input schema id: {v:#06x}") + } } } } diff --git a/crates/guest-program/src/l1/mod.rs b/crates/guest-program/src/l1/mod.rs index 6e0292c793..b6f7d8aa7d 100644 --- a/crates/guest-program/src/l1/mod.rs +++ b/crates/guest-program/src/l1/mod.rs @@ -6,9 +6,12 @@ pub use input::ProgramInput; #[cfg(feature = "eip-8025")] pub use input::{ CanonicalChainConfig, CanonicalExecutionWitness, CanonicalStatelessInput, DecodedEip8025, - EIP8025_VERSION_CANONICAL, EIP8025_VERSION_LEGACY, decode_eip8025, encode_eip8025, + EIP8025_VERSION_CANONICAL, EIP8025_VERSION_LEGACY, decode_canonical_stateless_input_bytes, + decode_eip8025, encode_eip8025, }; #[cfg(feature = "eip-8025")] pub use input::{ProgramInputDecodeError, ProgramInputEncodeError}; pub use output::ProgramOutput; +#[cfg(feature = "eip-8025")] +pub use program::execute_decoded; pub use program::execution_program; diff --git a/crates/guest-program/src/l1/program.rs b/crates/guest-program/src/l1/program.rs index 6eef9da872..89cef578c3 100644 --- a/crates/guest-program/src/l1/program.rs +++ b/crates/guest-program/src/l1/program.rs @@ -1,13 +1,18 @@ use std::sync::Arc; +#[cfg(feature = "eip-8025")] +use ethrex_common::Address; +#[cfg(feature = "eip-8025")] +use ethrex_common::utils::keccak; use ethrex_crypto::Crypto; use crate::common::ExecutionError; use crate::common::execute_blocks; -#[cfg(not(feature = "eip-8025"))] use crate::l1::input::ProgramInput; #[cfg(feature = "eip-8025")] -use crate::l1::input::{CanonicalExecutionWitness, CanonicalStatelessInput, DecodedEip8025}; +use crate::l1::input::{ + CanonicalExecutionWitness, CanonicalStatelessInput, DecodedEip8025, PublicKeysList, +}; use crate::l1::output::ProgramOutput; use ethrex_common::types::ELASTICITY_MULTIPLIER; @@ -84,17 +89,48 @@ pub fn execution_program( bytes: &[u8], crypto: Arc, ) -> Result { - use libssz_merkle::HashTreeRoot; - let decoded = super::decode_eip8025(bytes).map_err(|err| { ExecutionError::Internal(format!("failed to decode EIP-8025 input: {err}")) })?; - match decoded { - DecodedEip8025::Legacy { - new_payload_request, + execute_decoded(ProgramInput::Wire(decoded), crypto) +} + +/// Execute an already-built [`ProgramInput`]. +/// +/// The `Direct` arm has no `NewPayloadRequest`, so it returns a sentinel +/// `ProgramOutput` with zero request_root and `valid = true`. `ExecBackend` +/// promotes `valid = false` to `Err` for result-only callers. +#[cfg(feature = "eip-8025")] +pub fn execute_decoded( + input: ProgramInput, + crypto: Arc, +) -> Result { + use libssz_merkle::HashTreeRoot; + + match input { + ProgramInput::Direct { + blocks, execution_witness, } => { + let chain_id = execution_witness.chain_config.chain_id; + execute_blocks( + &blocks, + execution_witness, + ELASTICITY_MULTIPLIER, + |db, _| Ok(Evm::new_for_l1(db.clone(), crypto.clone())), + crypto.clone(), + )?; + Ok(ProgramOutput { + new_payload_request_root: [0u8; 32], + valid: true, + chain_id, + }) + } + ProgramInput::Wire(DecodedEip8025::Legacy { + new_payload_request, + execution_witness, + }) => { let request_root = new_payload_request.hash_tree_root(&CryptoWrapper(crypto.clone())); let chain_id = execution_witness.chain_config.chain_id; let valid = @@ -106,23 +142,35 @@ pub fn execution_program( chain_id, }) } - DecodedEip8025::Canonical { + ProgramInput::Wire(DecodedEip8025::Canonical { stateless_input, chain_config, - } => { - let request_root = stateless_input - .new_payload_request - .hash_tree_root(&CryptoWrapper(crypto.clone())); - let chain_id = stateless_input.chain_config.chain_id; - let valid = - validate_eip8025_canonical_execution(stateless_input, chain_config, crypto).is_ok(); + }) => Ok(execute_canonical_stateless_input_decoded( + stateless_input, + chain_config, + crypto, + )), + } +} - Ok(ProgramOutput { - new_payload_request_root: request_root, - valid, - chain_id, - }) - } +#[cfg(feature = "eip-8025")] +fn execute_canonical_stateless_input_decoded( + stateless_input: CanonicalStatelessInput, + chain_config: ethrex_common::types::ChainConfig, + crypto: Arc, +) -> ProgramOutput { + use libssz_merkle::HashTreeRoot; + + let request_root = stateless_input + .new_payload_request + .hash_tree_root(&CryptoWrapper(crypto.clone())); + let chain_id = stateless_input.chain_config.chain_id; + let valid = validate_eip8025_canonical_execution(stateless_input, chain_config, crypto).is_ok(); + + ProgramOutput { + new_payload_request_root: request_root, + valid, + chain_id, } } @@ -133,8 +181,7 @@ fn decode_payload_transactions = tx_bytes.iter().copied().collect(); - ethrex_common::types::Transaction::decode_canonical(&raw) + ethrex_common::types::Transaction::decode_canonical(tx_bytes) .map_err(|e| format!("tx decode: {e}")) }) .collect::, _>>() @@ -211,15 +258,15 @@ fn new_payload_request_to_block( let requests_hash = compute_requests_hash(&execution_requests); let base_fee_per_gas = base_fee_per_gas_from_le_bytes(&payload.base_fee_per_gas)?; + let logs_bloom = Bloom::from_slice(&payload.logs_bloom); - // Build logs_bloom from SszVector - let bloom_bytes: Vec = payload.logs_bloom.iter().copied().collect(); - let logs_bloom = Bloom::from_slice(&bloom_bytes); + let transactions_root = compute_transactions_root(&transactions, crypto); + let withdrawals_root = compute_withdrawals_root(&withdrawals, crypto); let body = BlockBody { - transactions: transactions.clone(), + transactions, ommers: vec![], - withdrawals: Some(withdrawals.clone()), + withdrawals: Some(withdrawals), }; let header = BlockHeader { @@ -227,7 +274,7 @@ fn new_payload_request_to_block( ommers_hash: *DEFAULT_OMMERS_HASH, coinbase: Address::from_slice(&payload.fee_recipient.0), state_root: H256::from_slice(&payload.state_root), - transactions_root: compute_transactions_root(&body.transactions, crypto), + transactions_root, receipts_root: H256::from_slice(&payload.receipts_root), logs_bloom, difficulty: 0.into(), @@ -235,11 +282,11 @@ fn new_payload_request_to_block( gas_limit: payload.gas_limit, gas_used: payload.gas_used, timestamp: payload.timestamp, - extra_data: Bytes::from(payload.extra_data.iter().copied().collect::>()), + extra_data: Bytes::copy_from_slice(&payload.extra_data), prev_randao: H256::from_slice(&payload.prev_randao), nonce: 0, base_fee_per_gas: Some(base_fee_per_gas), - withdrawals_root: Some(compute_withdrawals_root(&withdrawals, crypto)), + withdrawals_root: Some(withdrawals_root), blob_gas_used: Some(payload.blob_gas_used), excess_blob_gas: Some(payload.excess_blob_gas), parent_beacon_block_root: Some(H256::from_slice(&req.parent_beacon_block_root)), @@ -271,26 +318,27 @@ fn new_payload_request_amsterdam_to_block( let transactions = decode_payload_transactions(&payload.transactions)?; let withdrawals = decode_payload_withdrawals(&payload.withdrawals); - let bal_bytes: Vec = payload.block_access_list.iter().copied().collect(); - let block_access_list = BlockAccessList::decode(&bal_bytes) + let block_access_list = BlockAccessList::decode(&payload.block_access_list) .map_err(|e| format!("block access list decode: {e}"))?; block_access_list .validate_ordering() .map_err(|e| format!("block access list ordering: {e}"))?; - if block_access_list.encode_to_vec() != bal_bytes { + if block_access_list.encode_to_vec().as_slice() != &payload.block_access_list[..] { return Err("block access list is not canonically encoded".to_string()); } let execution_requests = req.execution_requests.to_encoded_requests(); let requests_hash = compute_requests_hash(&execution_requests); let base_fee_per_gas = base_fee_per_gas_from_le_bytes(&payload.base_fee_per_gas)?; - let bloom_bytes: Vec = payload.logs_bloom.iter().copied().collect(); - let logs_bloom = Bloom::from_slice(&bloom_bytes); + let logs_bloom = Bloom::from_slice(&payload.logs_bloom); + + let transactions_root = compute_transactions_root(&transactions, crypto); + let withdrawals_root = compute_withdrawals_root(&withdrawals, crypto); let body = BlockBody { - transactions: transactions.clone(), + transactions, ommers: vec![], - withdrawals: Some(withdrawals.clone()), + withdrawals: Some(withdrawals), }; let header = BlockHeader { @@ -298,7 +346,7 @@ fn new_payload_request_amsterdam_to_block( ommers_hash: *DEFAULT_OMMERS_HASH, coinbase: Address::from_slice(&payload.fee_recipient.0), state_root: H256::from_slice(&payload.state_root), - transactions_root: compute_transactions_root(&body.transactions, crypto), + transactions_root, receipts_root: H256::from_slice(&payload.receipts_root), logs_bloom, difficulty: 0.into(), @@ -306,11 +354,11 @@ fn new_payload_request_amsterdam_to_block( gas_limit: payload.gas_limit, gas_used: payload.gas_used, timestamp: payload.timestamp, - extra_data: Bytes::from(payload.extra_data.iter().copied().collect::>()), + extra_data: Bytes::copy_from_slice(&payload.extra_data), prev_randao: H256::from_slice(&payload.prev_randao), nonce: 0, base_fee_per_gas: Some(base_fee_per_gas), - withdrawals_root: Some(compute_withdrawals_root(&withdrawals, crypto)), + withdrawals_root: Some(withdrawals_root), blob_gas_used: Some(payload.blob_gas_used), excess_blob_gas: Some(payload.excess_blob_gas), parent_beacon_block_root: Some(H256::from_slice(&req.parent_beacon_block_root)), @@ -365,7 +413,7 @@ fn canonical_execution_witness_to_rpc( fn copy_ssz_bytes( bytes: &libssz_types::SszList, ) -> Bytes { - Bytes::from(bytes.iter().copied().collect::>()) + Bytes::copy_from_slice(bytes) } ethrex_common::types::block_execution_witness::RpcExecutionWitness { @@ -387,27 +435,105 @@ fn validate_eip8025_canonical_execution( chain_config: ethrex_common::types::ChainConfig, crypto: Arc, ) -> Result<(), ExecutionError> { - if stateless_input.chain_config.chain_id != chain_config.chain_id { - return Err(ExecutionError::Internal(format!( - "chain_id mismatch between canonical input ({}) and chain config ({})", - stateless_input.chain_config.chain_id, chain_config.chain_id - ))); - } + let block_timestamp = stateless_input + .new_payload_request + .execution_payload + .timestamp; + validate_canonical_chain_config( + &stateless_input.chain_config, + &chain_config, + block_timestamp, + )?; let block_number = stateless_input .new_payload_request .execution_payload .block_number; let rpc_witness = canonical_execution_witness_to_rpc(stateless_input.witness); - let execution_witness = rpc_witness.into_execution_witness(chain_config, block_number)?; + // Decode headers once; reused by the chain-linkage check and `into_execution_witness`. + let decoded_headers = ethrex_common::types::block_execution_witness::decode_witness_headers( + &rpc_witness.headers, + )?; + // EELS `test_validation_headers_non_contiguous_chain`: check chain linkage + // in input order, before any sort/dedup. + ethrex_common::types::block_execution_witness::validate_witness_headers_chain( + &decoded_headers, + crypto.as_ref(), + )?; + + let execution_witness = + rpc_witness.into_execution_witness(chain_config, block_number, &decoded_headers)?; validate_eip8025_amsterdam_execution( &stateless_input.new_payload_request, execution_witness, crypto, + stateless_input.public_keys, ) } +/// Validate `chain_id` and `active_fork.blob_schedule` from the prover's +/// `CanonicalChainConfig` against the verifier's `ChainConfig`. +#[cfg(feature = "eip-8025")] +fn validate_canonical_chain_config( + canonical: &crate::l1::input::CanonicalChainConfig, + expected: ðrex_common::types::ChainConfig, + block_timestamp: u64, +) -> Result<(), ExecutionError> { + if canonical.chain_id != expected.chain_id { + return Err(ExecutionError::Internal(format!( + "chain_id mismatch between canonical input ({}) and chain config ({})", + canonical.chain_id, expected.chain_id + ))); + } + + // TODO: `fork` and `activation` are not compared. EELS and ethrex number + // forks differently, and the spec stores activation values for canonical-root + // determinism rather than verifier cross-checking. The blob-schedule check + // below is a partial proxy and misses forks with identical blob parameters. + + // Single-entry check is sound because `MAX_BLOB_SCHEDULES_PER_FORK = 1`. + let canonical_schedule = canonical.active_fork.blob_schedule.iter().next(); + let expected_schedule = expected.get_fork_blob_schedule(block_timestamp); + match (canonical_schedule, expected_schedule) { + (Some(c), Some(e)) => { + if c.target != e.target as u64 + || c.max != e.max as u64 + || c.base_fee_update_fraction != e.base_fee_update_fraction + { + return Err(ExecutionError::Internal(format!( + "blob_schedule mismatch: canonical \ + (target={}, max={}, base_fee_update_fraction={}) \ + vs chain config (target={}, max={}, base_fee_update_fraction={})", + c.target, + c.max, + c.base_fee_update_fraction, + e.target, + e.max, + e.base_fee_update_fraction + ))); + } + } + (Some(_), None) => { + return Err(ExecutionError::Internal( + "blob_schedule mismatch: canonical input includes a schedule but \ + chain config has none at the block's timestamp" + .to_string(), + )); + } + (None, Some(_)) => { + return Err(ExecutionError::Internal( + "blob_schedule mismatch: canonical input omits the schedule but \ + chain config has one at the block's timestamp" + .to_string(), + )); + } + (None, None) => {} + } + + Ok(()) +} + #[cfg(feature = "eip-8025")] fn validate_eip8025_execution( new_payload_request: ðrex_common::types::eip8025_ssz::NewPayloadRequest, @@ -445,12 +571,41 @@ fn validate_eip8025_amsterdam_execution( new_payload_request: ðrex_common::types::eip8025_ssz::NewPayloadRequestAmsterdam, execution_witness: ethrex_common::types::block_execution_witness::ExecutionWitness, crypto: Arc, + public_keys: PublicKeysList, ) -> Result<(), ExecutionError> { let block = new_payload_request_amsterdam_to_block(new_payload_request, crypto.as_ref()) .map_err(|e| ExecutionError::Internal(format!("payload conversion: {e}")))?; validate_versioned_hashes(&block, new_payload_request.versioned_hashes.iter())?; + if public_keys.len() != block.body.transactions.len() { + return Err(ExecutionError::Internal(format!( + "Found {} public keys in the stateless input, but there are {} transactions", + public_keys.len(), + block.body.transactions.len() + ))); + } + for (public_key, tx) in public_keys.iter().zip(block.body.transactions.iter()) { + // SSZ decode fixes the length at 65; uncompressed secp256k1 is 0x04 || X || Y. + let pk_bytes: &[u8] = public_key; + if pk_bytes[0] != 0x04 { + return Err(ExecutionError::Internal( + "Stateless input public key is not a 65-byte uncompressed secp256k1 key" + .to_string(), + )); + } + let derived = Address::from_slice(&keccak(&pk_bytes[1..])[12..]); + let recovered = tx.sender(crypto.as_ref()).map_err(|e| { + ExecutionError::Internal(format!("failed to recover transaction sender: {e}")) + })?; + if recovered != derived { + return Err(ExecutionError::Internal( + "Stateless input public key does not match recovered transaction sender" + .to_string(), + )); + } + } + let _result = execute_blocks( &[block], execution_witness, diff --git a/crates/prover/src/backend/exec.rs b/crates/prover/src/backend/exec.rs index 1843f6dfc9..f6c1c7fbd2 100644 --- a/crates/prover/src/backend/exec.rs +++ b/crates/prover/src/backend/exec.rs @@ -24,29 +24,18 @@ impl ExecBackend { /// Core execution - runs the guest program directly. fn execute_core(input: ProgramInput) -> Result { let crypto = Arc::new(NativeCrypto); - // In EIP-8025 mode, execution_program takes raw input bytes, which doesn't - // match ProgramInput. Use execute_blocks directly instead. #[cfg(feature = "eip-8025")] { - use ethrex_common::types::ELASTICITY_MULTIPLIER; - use ethrex_vm::Evm; - let chain_id = input.execution_witness.chain_config.chain_id; - let _ = ethrex_guest_program::common::execute_blocks( - &input.blocks, - input.execution_witness, - ELASTICITY_MULTIPLIER, - |db, _| Ok(Evm::new_for_l1(db.clone(), crypto.clone())), - crypto.clone(), - ) - .map_err(BackendError::execution)?; - // Return a dummy output: no real proof is generated by this backend. - // The zeroed new_payload_request_root is intentional — the authoritative - // root is tracked by the coordinator's ProofRequest and checked there. - Ok(ProgramOutput { - new_payload_request_root: [0u8; 32], - valid: true, - chain_id, - }) + let output = ethrex_guest_program::l1::execute_decoded(input, crypto) + .map_err(BackendError::execution)?; + // Surface `valid = false` as Err so result-only callers (e.g. ef_tests) + // treat it as execution failure, matching the legacy path's semantics. + if !output.valid { + return Err(BackendError::execution( + "eip-8025 stateless execution: valid=false", + )); + } + Ok(output) } #[cfg(not(feature = "eip-8025"))] { @@ -91,6 +80,15 @@ impl ProverBackend for ExecBackend { input: ProgramInput, _format: ProofFormat, ) -> Result { + // The `Direct` variant returns a zero `new_payload_request_root` sentinel + // that callers must not interpret as a real commitment. `execute()` is + // fine (discards the output) but `prove()` exposes it. + #[cfg(feature = "eip-8025")] + if matches!(input, ProgramInput::Direct { .. }) { + return Err(BackendError::execution( + "ExecBackend::prove does not accept ProgramInput::Direct (test-only path)", + )); + } warn!("\"exec\" prover backend generates no proof, only executes"); Self::execute_core(input) } diff --git a/crates/prover/src/lib.rs b/crates/prover/src/lib.rs index ca27a35203..a84cfc5853 100644 --- a/crates/prover/src/lib.rs +++ b/crates/prover/src/lib.rs @@ -1,3 +1,16 @@ +// Non-exec backends rkyv-serialize `ProgramInput`, which the `eip-8025` variant +// doesn't implement. +#[cfg(all( + feature = "eip-8025", + any( + feature = "sp1", + feature = "risc0", + feature = "openvm", + feature = "zisk" + ) +))] +compile_error!("feature `eip-8025` cannot be combined with `sp1`, `risc0`, `openvm`, or `zisk`"); + pub mod backend; pub mod protocol; pub mod prover; diff --git a/crates/vm/levm/src/call_frame.rs b/crates/vm/levm/src/call_frame.rs index 65664a3dcf..e7aa4e65ec 100644 --- a/crates/vm/levm/src/call_frame.rs +++ b/crates/vm/levm/src/call_frame.rs @@ -428,6 +428,16 @@ impl CallFrame { Ok(()) } + /// EELS' `check_gas`: assert gas is available without consuming it. + #[inline(always)] + #[expect(clippy::as_conversions, reason = "remaining gas conversion")] + pub fn check_gas(&self, gas: u64) -> Result<(), ExceptionalHalt> { + if self.gas_remaining < 0 || (self.gas_remaining as u64) < gas { + return Err(ExceptionalHalt::OutOfGas); + } + Ok(()) + } + pub fn set_code(&mut self, code: Code) -> Result<(), VMError> { self.bytecode = code; Ok(()) diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 4725e6c915..19c3770624 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -48,27 +48,39 @@ impl OpcodeHandler for OpCallHandler { return Err(ExceptionalHalt::OpcodeNotAllowedInStaticContext.into()); } - // Check EIP-7702 delegation (gas is NOT charged yet, deferred to after BAL recording). - let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = - eip7702_get_code(vm.db, &mut vm.substate, callee)?; - - // Process gas usage. - let (new_memory_size, address_is_empty, address_was_cold) = - vm.get_call_gas_params(args_offset, args_len, return_offset, return_len, callee)?; - - // Record addresses for BAL per EIP-7928. - // gas_remaining has NOT been reduced by eip7702_gas_consumed yet, - // matching the EELS reference where BAL recording sees pre-eip7702 gas. let value_cost = if !value.is_zero() { gas_cost::CALL_POSITIVE_VALUE } else { 0 }; - let create_cost = if address_is_empty && !value.is_zero() { + let (new_memory_size, address_was_cold, static_cost) = vm.check_call_static_gas( + args_offset, + args_len, + return_offset, + return_len, + callee, + value_cost, + )?; + + vm.substate.add_accessed_address(callee); + // `address_is_empty` only feeds gates that also require `value != 0`, + // so skip the read entirely when value is zero (matches EELS' gating + // of `is_account_alive` on `value != 0`). + let address_is_empty = if value.is_zero() { + false + } else { + vm.db.get_account(callee)?.is_empty() + }; + let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = + eip7702_get_code(vm.db, &mut vm.substate, callee)?; + let create_cost = if address_is_empty { gas_cost::CALL_TO_EMPTY_ACCOUNT } else { 0 }; + + // BAL touches the target before the delegation gas check, so a failed + // delegate-access check still leaves the target recorded. vm.record_bal_call_touch( callee, code_address, @@ -81,6 +93,16 @@ impl OpcodeHandler for OpCallHandler { create_cost, ); + // `create_cost` is EIP-8037 state gas (charged via `increase_state_gas` + // below) and must not appear in the regular-gas check. + if is_delegation_7702 { + vm.current_call_frame.check_gas( + static_cost + .checked_add(eip7702_gas_consumed) + .ok_or(ExceptionalHalt::OutOfGas)?, + )?; + } + let fork = vm.env.config.fork; // Compute gas_left after eip7702 consumption (without modifying gas_remaining yet). @@ -93,7 +115,7 @@ impl OpcodeHandler for OpCallHandler { // but charge state gas AFTER regular gas per EIPs#11421. // Regular gas OOG must not consume state gas that would inflate the parent's // reservoir on frame failure. - let needs_state_gas = fork >= Fork::Amsterdam && address_is_empty && !value.is_zero(); + let needs_state_gas = fork >= Fork::Amsterdam && address_is_empty; let gas_left = if needs_state_gas { let state_gas_new_account = vm.state_gas_new_account; let from_reservoir = vm.state_gas_reservoir.min(state_gas_new_account); @@ -195,20 +217,25 @@ impl OpcodeHandler for OpCallCodeHandler { let (args_len, args_offset) = size_offset_to_usize(args_len, args_offset)?; let (return_len, return_offset) = size_offset_to_usize(return_len, return_offset)?; - // Check EIP-7702 delegation (gas is NOT charged yet, deferred to after BAL recording). - let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = - eip7702_get_code(vm.db, &mut vm.substate, address)?; - - // Process gas usage. - let (new_memory_size, _, address_was_cold) = - vm.get_call_gas_params(args_offset, args_len, return_offset, return_len, address)?; - - // Record addresses for BAL per EIP-7928. let value_cost = if !value.is_zero() { gas_cost::CALLCODE_POSITIVE_VALUE } else { 0 }; + let (new_memory_size, address_was_cold, static_cost) = vm.check_call_static_gas( + args_offset, + args_len, + return_offset, + return_len, + address, + value_cost, + )?; + + vm.substate.add_accessed_address(address); + let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = + eip7702_get_code(vm.db, &mut vm.substate, address)?; + + // BAL touches the target before the delegation gas check. vm.record_bal_call_touch( address, code_address, @@ -221,6 +248,14 @@ impl OpcodeHandler for OpCallCodeHandler { 0, ); + if is_delegation_7702 { + vm.current_call_frame.check_gas( + static_cost + .checked_add(eip7702_gas_consumed) + .ok_or(ExceptionalHalt::OutOfGas)?, + )?; + } + #[expect(clippy::as_conversions, reason = "safe")] let gas_left = (vm.current_call_frame.gas_remaining as u64) .checked_sub(eip7702_gas_consumed) @@ -296,15 +331,14 @@ impl OpcodeHandler for OpDelegateCallHandler { let (args_len, args_offset) = size_offset_to_usize(args_len, args_offset)?; let (return_len, return_offset) = size_offset_to_usize(return_len, return_offset)?; - // Check EIP-7702 delegation (gas is NOT charged yet, deferred to after BAL recording). + let (new_memory_size, address_was_cold, static_cost) = + vm.check_call_static_gas(args_offset, args_len, return_offset, return_len, address, 0)?; + + vm.substate.add_accessed_address(address); let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = eip7702_get_code(vm.db, &mut vm.substate, address)?; - // Process gas usage. - let (new_memory_size, _, address_was_cold) = - vm.get_call_gas_params(args_offset, args_len, return_offset, return_len, address)?; - - // Record addresses for BAL per EIP-7928. + // BAL touches the target before the delegation gas check. vm.record_bal_call_touch( address, code_address, @@ -317,6 +351,14 @@ impl OpcodeHandler for OpDelegateCallHandler { 0, ); + if is_delegation_7702 { + vm.current_call_frame.check_gas( + static_cost + .checked_add(eip7702_gas_consumed) + .ok_or(ExceptionalHalt::OutOfGas)?, + )?; + } + #[expect(clippy::as_conversions, reason = "safe")] let gas_left = (vm.current_call_frame.gas_remaining as u64) .checked_sub(eip7702_gas_consumed) @@ -393,15 +435,14 @@ impl OpcodeHandler for OpStaticCallHandler { let (args_len, args_offset) = size_offset_to_usize(args_len, args_offset)?; let (return_len, return_offset) = size_offset_to_usize(return_len, return_offset)?; - // Check EIP-7702 delegation (gas is NOT charged yet, deferred to after BAL recording). + let (new_memory_size, address_was_cold, static_cost) = + vm.check_call_static_gas(args_offset, args_len, return_offset, return_len, address, 0)?; + + vm.substate.add_accessed_address(address); let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) = eip7702_get_code(vm.db, &mut vm.substate, address)?; - // Process gas usage. - let (new_memory_size, _, address_was_cold) = - vm.get_call_gas_params(args_offset, args_len, return_offset, return_len, address)?; - - // Record addresses for BAL per EIP-7928. + // BAL touches the target before the delegation gas check. vm.record_bal_call_touch( address, code_address, @@ -414,6 +455,14 @@ impl OpcodeHandler for OpStaticCallHandler { 0, ); + if is_delegation_7702 { + vm.current_call_frame.check_gas( + static_cost + .checked_add(eip7702_gas_consumed) + .ok_or(ExceptionalHalt::OutOfGas)?, + )?; + } + #[expect(clippy::as_conversions, reason = "safe")] let gas_left = (vm.current_call_frame.gas_remaining as u64) .checked_sub(eip7702_gas_consumed) @@ -880,6 +929,39 @@ impl<'a> VM<'a> { Ok(OpcodeResult::Continue) } + /// Static gas prelude for CALL/CALLCODE/DELEGATECALL/STATICCALL: compute + /// `(new_memory_size, address_was_cold, static_cost)` and `check_gas` it + /// before any state read, mirroring EELS' `# check static gas before state + /// access`. `value_cost` is the per-opcode positive-value cost (0 when + /// none). + fn check_call_static_gas( + &mut self, + args_offset: usize, + args_len: usize, + return_offset: usize, + return_len: usize, + address: Address, + value_cost: u64, + ) -> Result<(usize, bool, u64), VMError> { + let new_memory_size = calculate_memory_size(args_offset, args_len)? + .max(calculate_memory_size(return_offset, return_len)?); + let address_was_cold = !self.substate.is_address_accessed(&address); + let memory_expansion_cost = + memory::expansion_cost(new_memory_size, self.current_call_frame.memory.len())?; + let access_gas_cost = if address_was_cold { + gas_cost::COLD_ADDRESS_ACCESS_COST + } else { + gas_cost::WARM_ADDRESS_ACCESS_COST + }; + let static_cost = memory_expansion_cost + .checked_add(access_gas_cost) + .ok_or(ExceptionalHalt::OutOfGas)? + .checked_add(value_cost) + .ok_or(ExceptionalHalt::OutOfGas)?; + self.current_call_frame.check_gas(static_cost)?; + Ok((new_memory_size, address_was_cold, static_cost)) + } + /// Record BAL touched addresses for CALL-family opcodes per EIP-7928. /// Gated on intermediate gas checks matching the EELS reference. #[expect( @@ -1272,28 +1354,6 @@ impl<'a> VM<'a> { Ok(()) } - /// Obtains the values needed for CALL, CALLCODE, DELEGATECALL and STATICCALL opcodes to calculate total gas cost - fn get_call_gas_params( - &mut self, - args_offset: usize, - args_size: usize, - return_data_offset: usize, - return_data_size: usize, - address: Address, - ) -> Result<(usize, bool, bool), VMError> { - // Creation of previously empty accounts and cold addresses have higher gas cost - let address_was_cold = self.substate.add_accessed_address(address); - let account_is_empty = self.db.get_account(address)?.is_empty(); - - // Calculated here for memory expansion gas cost - let new_memory_size_for_args = calculate_memory_size(args_offset, args_size)?; - let new_memory_size_for_return_data = - calculate_memory_size(return_data_offset, return_data_size)?; - let new_memory_size = new_memory_size_for_args.max(new_memory_size_for_return_data); - - Ok((new_memory_size, account_is_empty, address_was_cold)) - } - fn get_calldata(&mut self, offset: usize, size: usize) -> Result { self.current_call_frame.memory.load_range(offset, size) } diff --git a/docs/known_issues.md b/docs/known_issues.md index 2dd6f0d138..e69de29bb2 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -1,32 +0,0 @@ -# Known Issues - -Tests intentionally excluded from CI. Source of truth for the **Known -Issues** section the L1 workflow appends to each ef-tests job summary -and posts as a sticky PR comment. - -## EF Tests — Stateless coverage narrowed to EIP-8025 optional-proofs - -`make -C tooling/ef_tests/blockchain test` calls `test-stateless-zkevm` -instead of `test-stateless`. The zkevm@v0.3.3 fixtures are filled against -bal@v5.6.1, out of sync with current bal spec; the broad target trips ~549 -fixtures. Re-broaden once the zkevm bundle is regenerated. - -
-Why and resolution path - -[PR #6527](https://github.com/lambdaclass/ethrex/pull/6527) broadened -`test-stateless` to extract the entire `for_amsterdam/` tree from the -zkevm bundle and run all of it under `--features stateless`; combined with -this branch's bal-devnet-7 semantics that scope produces ~549 -`GasUsedMismatch` / `ReceiptsRootMismatch` / -`BlockAccessListHashMismatch` failures. - -`test-stateless-zkevm` filters cargo to the `eip8025_optional_proofs` -suite, which still validates the stateless harness without the bal-version -mismatch. - -Re-broaden by switching `test:` back to `test-stateless` in -`tooling/ef_tests/blockchain/Makefile` once the zkevm bundle is regenerated -against the current bal spec. - -
diff --git a/tooling/Cargo.lock b/tooling/Cargo.lock index 500ba2674a..c5178658b4 100644 --- a/tooling/Cargo.lock +++ b/tooling/Cargo.lock @@ -3340,6 +3340,10 @@ dependencies = [ "indexmap 2.14.0", "lazy_static", "libc", + "libssz", + "libssz-derive", + "libssz-merkle", + "libssz-types", "lru 0.16.3", "once_cell", "rayon", @@ -3420,6 +3424,10 @@ dependencies = [ "ethrex-vm", "hex", "k256 0.13.4 (registry+https://github.com/rust-lang/crates.io-index)", + "libssz", + "libssz-derive", + "libssz-merkle", + "libssz-types", "rkyv", "serde", "serde_with", @@ -5497,6 +5505,43 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "libssz" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "smallvec", +] + +[[package]] +name = "libssz-derive" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "libssz-merkle" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "libssz", + "sha2 0.10.9", +] + +[[package]] +name = "libssz-types" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "libssz", + "libssz-merkle", + "smallvec", +] + [[package]] name = "libtest-mimic" version = "0.8.2" diff --git a/tooling/ef_tests/blockchain/.fixtures_url_zkevm b/tooling/ef_tests/blockchain/.fixtures_url_zkevm index 1c4b11fd86..5467c1de0c 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_zkevm +++ b/tooling/ef_tests/blockchain/.fixtures_url_zkevm @@ -1 +1 @@ -https://github.com/ethereum/execution-spec-tests/releases/download/zkevm%40v0.3.3/fixtures_zkevm.tar.gz +https://github.com/ethereum/execution-specs/releases/download/tests-zkevm%40v0.4.1/fixtures_zkevm.tar.gz diff --git a/tooling/ef_tests/blockchain/Cargo.toml b/tooling/ef_tests/blockchain/Cargo.toml index 5ea3337e9d..b52a8005a5 100644 --- a/tooling/ef_tests/blockchain/Cargo.toml +++ b/tooling/ef_tests/blockchain/Cargo.toml @@ -33,7 +33,12 @@ path = "./lib.rs" default = ["c-kzg"] c-kzg = ["ethrex-blockchain/c-kzg"] sp1 = ["ethrex-guest-program/sp1-build-elf", "ethrex-prover/sp1"] -stateless = [] +stateless = [ + "ethrex-blockchain/eip-8025", + "ethrex-common/eip-8025", + "ethrex-guest-program/eip-8025", + "ethrex-prover/eip-8025", +] l2 = ["ethrex-guest-program/l2", "ethrex-prover/l2"] [[test]] diff --git a/tooling/ef_tests/blockchain/Makefile b/tooling/ef_tests/blockchain/Makefile index 1e078b95e7..6c7aa97d95 100644 --- a/tooling/ef_tests/blockchain/Makefile +++ b/tooling/ef_tests/blockchain/Makefile @@ -1,4 +1,4 @@ -.PHONY: download-test-vectors clean-vectors test test-levm test-sp1 test-stateless amsterdam-vectors zkevm-vectors +.PHONY: download-test-vectors clean-vectors test test-levm test-sp1 test-stateless amsterdam-vectors zkevm-vectors test-stateless-zkevm VECTORS_ROOT := vectors FIXTURES_FILE := .fixtures_url @@ -88,11 +88,4 @@ test-stateless-zkevm: zkevm-vectors test: ## 🧪 Run blockchain tests with LEVM both with state and stateless $(MAKE) test-levm - # Narrow stateless coverage to the EIP-8025 optional-proofs suite. The - # zkevm@v0.3.3 fixtures are filled against bal@v5.6.1, which is out of - # sync with this branch's bal-devnet-6+ (and bal-devnet-7-prep) gas - # accounting; the broader `test-stateless` invocation introduced by - # #6527 trips ~549 of those fixtures with `GasUsedMismatch` / - # `ReceiptsRootMismatch` / `BlockAccessListHashMismatch`. Re-broaden - # once the zkevm bundle is regenerated against the current bal spec. - $(MAKE) test-stateless-zkevm + $(MAKE) test-stateless diff --git a/tooling/ef_tests/blockchain/test_runner.rs b/tooling/ef_tests/blockchain/test_runner.rs index f894419aef..749004f8e0 100644 --- a/tooling/ef_tests/blockchain/test_runner.rs +++ b/tooling/ef_tests/blockchain/test_runner.rs @@ -9,29 +9,15 @@ use ethrex_blockchain::{ error::{ChainError, InvalidBlockError}, fork_choice::apply_fork_choice, }; - -thread_local! { - /// Per-OS-thread merkleization pool, lazily built on first use. Mirrors the - /// pattern used by `tooling/ef_tests/engine` so the ~10k+ blockchain tests - /// don't each spawn a fresh 17-thread rayon pool inside `Blockchain::new`. - /// The merkle protocol's 16 worker jobs cross-communicate via channels, so - /// each pool may have only one concurrent `in_place_scope` caller; keying by - /// `thread_local!` makes the calling test-runner thread the natural - /// exclusive owner. - static MERKLE_POOL: std::cell::OnceCell> = - const { std::cell::OnceCell::new() }; -} - -fn merkle_pool() -> Arc { - MERKLE_POOL.with(|cell| cell.get_or_init(Blockchain::build_merkle_pool).clone()) -} +#[cfg(not(feature = "stateless"))] +use ethrex_common::types::block_access_list::BlockAccessList; #[cfg(feature = "stateless")] use ethrex_common::types::block_execution_witness::RpcExecutionWitness; use ethrex_common::{ constants::EMPTY_KECCACK_HASH, types::{ Account as CoreAccount, Block as CoreBlock, BlockHeader as CoreBlockHeader, - InvalidBlockHeaderError, block_access_list::BlockAccessList, + InvalidBlockHeaderError, }, }; use ethrex_guest_program::input::ProgramInput; @@ -43,6 +29,22 @@ use ethrex_storage::{EngineType, Store}; use ethrex_vm::EvmError; use regex::Regex; +thread_local! { + /// Per-OS-thread merkleization pool, lazily built on first use. Mirrors the + /// pattern used by `tooling/ef_tests/engine` so the ~10k+ blockchain tests + /// don't each spawn a fresh 17-thread rayon pool inside `Blockchain::new`. + /// The merkle protocol's 16 worker jobs cross-communicate via channels, so + /// each pool may have only one concurrent `in_place_scope` caller; keying by + /// `thread_local!` makes the calling test-runner thread the natural + /// exclusive owner. + static MERKLE_POOL: std::cell::OnceCell> = + const { std::cell::OnceCell::new() }; +} + +fn merkle_pool() -> Arc { + MERKLE_POOL.with(|cell| cell.get_or_init(Blockchain::build_merkle_pool).clone()) +} + pub fn parse_and_execute( path: &Path, skipped_tests: Option<&[&str]>, @@ -116,6 +118,11 @@ pub async fn run_ef_test( // Two-pass approach: pass 1 collects the BAL produced by sequential execution, pass 2 // re-executes using that BAL to drive parallel (BAL-warmed) execution and verifies the // same final state is reached. + // Not exercised under `stateless`: the stateless harness runs the guest program directly + // and doesn't drive `add_block_pipeline`, and BAL-warmed parallel execution gives no + // benefit in single-threaded zkVM guest builds. The non-stateless runs are the right + // home for this check. + #[cfg(not(feature = "stateless"))] if test.network == Fork::Amsterdam { run_two_pass_parallel(test_key, test).await?; } @@ -123,14 +130,14 @@ pub async fn run_ef_test( // Run stateless if backend was specified for this. // TODO: See if we can run stateless without needing a previous run. We can't easily do it for now. #4142 if let Some(backend) = stateless_backend { - // If the fixture provides an executionWitness (zkevm format), use it directly - // instead of regenerating the witness from blockchain execution. + // Use the fixture's witness when present (either `executionWitness` or + // `statelessInputBytes`); otherwise regenerate by re-running execution. #[cfg(feature = "stateless")] { let has_fixture_witness = test.blocks.iter().any(|bf| { - bf.block() - .and_then(|b| b.execution_witness.as_ref()) - .is_some() + bf.block().is_some_and(|b| { + b.execution_witness.is_some() || b.stateless_input_bytes.is_some() + }) }); if has_fixture_witness { run_stateless_from_fixture(test, test_key, backend).await?; @@ -205,6 +212,7 @@ async fn run( /// BAL that each block produces. Pass 2 (parallel): creates a fresh chain and re-runs every /// block passing the corresponding BAL so the BAL-warmed parallel path is exercised. The final /// post-state of pass 2 must match the expected post-state. +#[cfg(not(feature = "stateless"))] async fn run_two_pass_parallel(test_key: &str, test: &TestUnit) -> Result<(), String> { // ---- Pass 1: sequential, collect BALs ---- let store1 = build_store_for_test(test).await; @@ -567,10 +575,6 @@ async fn run_stateless_from_fixture( continue; }; - let Some(witness_json) = block_data.execution_witness.as_ref() else { - continue; - }; - let block: CoreBlock = block_data.clone().into(); let block_number = block.header.number; @@ -582,6 +586,24 @@ async fn run_stateless_from_fixture( })?, }; + // Prefer the canonical EIP-8025 wire path (production guest binary entry + // point) which exercises the public_keys / hash_tree_root checks the + // legacy `ProgramInput` route bypasses. + if let Some(input_hex) = block_data.stateless_input_bytes.as_deref() { + run_stateless_from_input_bytes( + test_key, + &test.network, + block_number, + input_hex, + expected_valid, + )?; + continue; + } + + let Some(witness_json) = block_data.execution_witness.as_ref() else { + continue; + }; + // Parse and conversion errors must always fail; only the execution outcome is // matched against `expected_valid` so the (false, Err(_)) arm below cannot // absorb regressions in deserialization or witness conversion. @@ -589,8 +611,15 @@ async fn run_stateless_from_fixture( .map_err(|e| { format!("executionWitness parse failed for {test_key} block {block_number}: {e}") })?; + let decoded_headers = + ethrex_common::types::block_execution_witness::decode_witness_headers( + &rpc_witness.headers, + ) + .map_err(|e| { + format!("witness header decode failed for {test_key} block {block_number}: {e}") + })?; let execution_witness = rpc_witness - .into_execution_witness(*chain_config, block_number) + .into_execution_witness(*chain_config, block_number, &decoded_headers) .map_err(|e| { format!("witness conversion failed for {test_key} block {block_number}: {e}") })?; @@ -621,6 +650,50 @@ async fn run_stateless_from_fixture( Ok(()) } +/// Run a fixture's `statelessInputBytes` (2-byte BE schema-id followed by +/// SSZ-encoded `SszStatelessInput`) through the canonical-input path the +/// production guest binary uses. +#[cfg(feature = "stateless")] +fn run_stateless_from_input_bytes( + test_key: &str, + test_network: &Fork, + block_number: u64, + input_hex: &str, + expected_valid: bool, +) -> Result<(), String> { + use ethrex_guest_program::l1::{DecodedEip8025, decode_canonical_stateless_input_bytes}; + + let trimmed = input_hex.strip_prefix("0x").unwrap_or(input_hex); + let bytes = hex::decode(trimmed).map_err(|e| { + format!("statelessInputBytes hex decode failed for {test_key} block {block_number}: {e}") + })?; + + // Decode failures count as the canonical-input rejection path: a negative + // fixture with malformed top-level SSZ should still match `expected_valid=false`. + let exec_result = match decode_canonical_stateless_input_bytes(&bytes) { + Ok(stateless_input) => { + let chain_config = *test_network.chain_config(); + let program_input = ProgramInput::wire(DecodedEip8025::Canonical { + stateless_input, + chain_config, + }); + ExecBackend::new().execute(program_input) + } + Err(e) => Err(ethrex_prover::BackendError::execution(format!( + "statelessInputBytes decode failed: {e}" + ))), + }; + match (expected_valid, exec_result) { + (true, Ok(_)) | (false, Err(_)) => Ok(()), + (true, Err(e)) => Err(format!( + "Stateless execution failed for {test_key} block {block_number} but fixture expected it to succeed: {e}" + )), + (false, Ok(_)) => Err(format!( + "Stateless execution succeeded for {test_key} block {block_number} but fixture expected it to fail (invalid statelessInputBytes)" + )), + } +} + /// Decode the `valid` byte (index 32) from a zkevm-fixture `statelessOutputBytes` hex /// string, encoded as `new_payload_request_root (32 B) ++ valid (1 B) ++ padding`. #[cfg(feature = "stateless")] diff --git a/tooling/ef_tests/blockchain/tests/all.rs b/tooling/ef_tests/blockchain/tests/all.rs index 03c47deddb..821e564f8e 100644 --- a/tooling/ef_tests/blockchain/tests/all.rs +++ b/tooling/ef_tests/blockchain/tests/all.rs @@ -7,9 +7,8 @@ use std::path::Path; compile_error!("Only one of `sp1` and `stateless` can be enabled at a time."); // test-levm / test-sp1 read snobal-devnet-6 + legacy from `vectors/`. -// test-stateless reads zkevm@v0.3.3 (the only bundle that ships executionWitness) -// from a separate `vectors_zkevm/` so its older bal@v5.6.1 base never overlays -// the snobal fixtures used by the other suites. +// test-stateless reads zkevm@v0.4.1 (EIP-8025 canonical bundle) from a separate +// `vectors_zkevm/` so the bundles don't overlay each other. #[cfg(feature = "stateless")] const TEST_FOLDER: &str = "vectors_zkevm/"; #[cfg(not(feature = "stateless"))] @@ -25,46 +24,6 @@ const SKIPPED_BASE: &[&str] = &[ "ValueOverflowParis", // Skip because it's a "Create" Blob Transaction, which doesn't actually exist. It never reaches the EVM because we can't even parse it as an actual Transaction. "createBlobhashTx", - // EIP-8025 optional-proofs fixtures filled against bal@v5.6.1 (devnets/bal/3), - // which predates EELS PR #2711 "immutable intrinsic_state_gas for EIP-7702". - // Expected gas assumes the auth refund still deducts from block-accounted state - // gas; our devnet-4 (bal@v5.7.0) impl correctly keeps intrinsic_state_gas - // immutable and routes the refund to the reservoir only. Re-enable once the - // zkevm@v0.4.x release ships fixtures regenerated against devnet-4. - "witness_codes_redelegation_old_marker_included_new_marker_excluded", - "witness_codes_reset_delegation", - "witness_codes_reverted_transaction", - "witness_codes_failed_create_includes_factory", - "witness_codes_reverted_create_same_hash_then_read", - "witness_codes_create_then_selfdestruct_same_tx", - // Additional EIP-8025 optional-proofs fixtures whose expected gas magnitudes - // disagree with bal-devnet-7 (bal@v7.1.1) state-gas accounting. Same root - // cause as the block above: zkevm@v0.3.3 bundle is pinned at an older bal - // spec (storage_set / new_account / cpsb constants pre-recalibration plus - // earlier refund-channel semantics) and the broader fork.py changes from - // EELS PRs #2815/#2816/#2823/#2827/#2828. Re-enable once the zkevm bundle - // is regenerated against bal-7. - "witness_codes_delegation_set_in_same_block", - "witness_codes_auth_nonce_mismatch", - "witness_codes_dedup_identical_bytecode", - "witness_codes_create2_excludes_new_bytecode", - "witness_codes_reverted_inner_call", - "witness_codes_create_same_hash_then_read", - "witness_codes_create_then_call_same_block", - "witness_codes_create_then_call_same_tx", - "witness_codes_failed_create_after_initcode_read", - "witness_codes_initcode_calls_existing_contract", - "witness_excludes_bytecode_created_in_same_block", - "witness_keeps_prestate_code_read_even_if_later_created_with_same_hash", - "witness_codes_selfdestruct_in_initcode", - "witness_codes_selfdestruct_beneficiary_no_code", - "witness_state_delete_with_new_dirty_sibling_omits_post_state_node", - "witness_state_block_diff_delete_insert_before_delete_order", - "witness_state_delete_then_insert_uses_insert_before_delete_order", - "witness_state_sstore_into_empty_storage_omits_post_state_nodes", - "witness_state_sstore_new_slot_omits_post_state_nodes", - "validation_state_missing_absent_slot_proof_leaf_node", - "validation_state_missing_storage_proof_node", ]; // Extra skips added only for prover backends. @@ -76,33 +35,7 @@ const EXTRA_SKIPS: &[&str] = &[ "static_Call1MB1024Calldepth", ]; #[cfg(feature = "stateless")] -const EXTRA_SKIPS: &[&str] = &[ - // zkevm@v0.3.3 tolerance tests: the fixture's `statelessOutputBytes` declares `valid = 1` - // because the executed path does not actually consume the malformed/extra/missing witness - // entry, but our RpcExecutionWitness conversion eagerly validates the full witness and - // rejects it. Re-enable once the witness conversion is lazy per EIP-8025 §Tolerance. - "validation_headers_malformed_rlp_header", - "validation_headers_missing_oldest_blockhash_ancestor", - "validation_headers_missing_parent_header", - "validation_state_extra_unused_trie_node", - // zkevm@v0.3.3 rejection tests: `statelessOutputBytes` declares `valid = 0` so the guest - // program must reject the deliberately-incomplete witness, but our stateless path runs - // to completion instead of detecting the missing entry. Re-enable once the witness - // completeness checks land (missing delegation/external-code bytecodes, non-contiguous - // header chain detection). - "validation_codes_missing_delegated_code_on_insufficient_balance_call", - "validation_codes_missing_external_code_read_target", - "validation_codes_missing_redelegation_old_marker", - "validation_codes_missing_sender_delegation_marker", - "validation_headers_non_contiguous_chain", - // zkevm@v0.3.3 conversion-time rejection: `statelessOutputBytes` declares `valid = 0` and - // our `into_execution_witness` correctly rejects the witness because it can't extract the - // initial state root without the parent header. Since 5a597e67d the runner treats - // conversion errors as unconditional regressions, so this correct-rejection-at-the-wrong- - // stage trips the test. Re-enable once conversion is lazy enough to defer the parent- - // header check to execution. - "validation_headers_empty_block_missing_mandatory_parent", -]; +const EXTRA_SKIPS: &[&str] = &[]; #[cfg(not(any(feature = "sp1", feature = "stateless")))] const EXTRA_SKIPS: &[&str] = &[];