From 28c6c2acfdf7fc2b1e7777e16b79cd1e42a52cb7 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Thu, 16 Apr 2026 14:56:16 -0300 Subject: [PATCH 1/3] Bump zkevm fixtures to v0.3.3 and tighten witness validation. The v0.3.3 release adds tests for invalid/unusual execution witnesses that exercised existing gaps in ethrex's stateless validation. Fix them: - get_account_code / get_code_metadata now error on missing bytecode instead of silently falling back to empty code. A missing code hash means the witness is incomplete, so the guest program must not conclude anything about the state. - RpcExecutionWitness::into_execution_witness tolerates extra unused state entries that don't decode as trie nodes (matches other clients and passes validation_state_extra_unused_trie_node). - GuestProgramState::from_witness rejects non-contiguous or reordered block_headers_bytes; headers must form a contiguous ascending chain. - execute_blocks now propagates errors from get_first_invalid_block_hash instead of swallowing NoncontiguousBlockHeaders. Test runner: run_stateless_from_fixture reads the fixture's statelessOutputBytes valid flag and expects execution to fail when the fixture marks the block invalid (valid=0x00). --- .../common/types/block_execution_witness.rs | 67 +++++++++++-------- crates/guest-program/src/common/execution.rs | 9 ++- .../ef_tests/blockchain/.fixtures_url_zkevm | 2 +- tooling/ef_tests/blockchain/test_runner.rs | 61 +++++++++++++++-- 4 files changed, 99 insertions(+), 40 deletions(-) diff --git a/crates/common/types/block_execution_witness.rs b/crates/common/types/block_execution_witness.rs index 71071764fab..96204db6ebf 100644 --- a/crates/common/types/block_execution_witness.rs +++ b/crates/common/types/block_execution_witness.rs @@ -177,10 +177,13 @@ impl RpcExecutionWitness { // which would fail to decode in ours return None; } + // Tolerate extra unused witness entries that don't decode as trie nodes. + // Nodes that are actually needed for state access are looked up by hash + // later; extra junk cannot be referenced. let hash = keccak(&b); - Some(Node::decode(&b).map(|node| (hash, node))) + Node::decode(&b).ok().map(|node| (hash, 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, _) = @@ -325,14 +328,25 @@ impl GuestProgramState { value: ExecutionWitness, crypto: &dyn Crypto, ) -> Result { - let block_headers: BTreeMap = value + let decoded_headers: Vec = value .block_headers_bytes .into_iter() .map(|bytes| BlockHeader::decode(bytes.as_ref())) .collect::, _>>() .map_err(|e| { GuestProgramStateError::Custom(format!("Failed to decode block headers: {}", e)) - })? + })?; + + // The witness must provide headers in ascending block-number order (oldest + // first) so they form a contiguous chain. Reordered or gapped inputs are + // rejected here rather than silently normalized by the BTreeMap below. + for window in decoded_headers.windows(2) { + if window[1].number != window[0].number + 1 { + return Err(GuestProgramStateError::NoncontiguousBlockHeaders); + } + } + + let block_headers: BTreeMap = decoded_headers .into_iter() .map(|header| (header.number, header)) .collect(); @@ -589,28 +603,25 @@ impl GuestProgramState { } /// Retrieves the account code for a specific account. - /// Returns an Err if the code is not found. + /// Returns an Err if the code is not found — a missing code hash means + /// the execution witness is invalid and the guest program must not + /// conclude anything about the state. 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_else(|| { + GuestProgramStateError::Custom(format!( + "missing bytecode for hash {} in execution witness", + hex::encode(code_hash) + )) + }) } /// Retrieves code metadata (length) for a specific code hash. /// This is an optimized path for EXTCODESIZE opcode. + /// Returns an Err if the code is not found — same rationale as + /// [`Self::get_account_code`]. pub fn get_code_metadata( &self, code_hash: H256, @@ -620,19 +631,17 @@ impl GuestProgramState { 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.", + }) + .ok_or_else(|| { + GuestProgramStateError::Custom(format!( + "missing bytecode for hash {} in execution witness", hex::encode(code_hash) - ); - Ok(CodeMetadata { length: 0 }) - } - } + )) + }) } /// 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/common/execution.rs b/crates/guest-program/src/common/execution.rs index b43988ee818..f458ca9c29e 100644 --- a/crates/guest-program/src/common/execution.rs +++ b/crates/guest-program/src/common/execution.rs @@ -70,10 +70,13 @@ where // Validate execution witness' block hashes report_cycles("get_first_invalid_block_hash", || { - if let Ok(Some(invalid_block_header)) = wrapped_db.get_first_invalid_block_hash() { - return Err(ExecutionError::InvalidBlockHash(invalid_block_header)); + match wrapped_db.get_first_invalid_block_hash() { + Ok(Some(invalid_block_header)) => { + Err(ExecutionError::InvalidBlockHash(invalid_block_header)) + } + Ok(None) => Ok(()), + Err(e) => Err(ExecutionError::GuestProgramState(e)), } - Ok(()) })?; // Validate initial state diff --git a/tooling/ef_tests/blockchain/.fixtures_url_zkevm b/tooling/ef_tests/blockchain/.fixtures_url_zkevm index 7b92a046c93..1c4b11fd867 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.0/fixtures_zkevm.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/zkevm%40v0.3.3/fixtures_zkevm.tar.gz diff --git a/tooling/ef_tests/blockchain/test_runner.rs b/tooling/ef_tests/blockchain/test_runner.rs index 85fac6dd9b7..f6838ea0b2c 100644 --- a/tooling/ef_tests/blockchain/test_runner.rs +++ b/tooling/ef_tests/blockchain/test_runner.rs @@ -529,6 +529,11 @@ async fn re_run_stateless( /// trie nodes, codes, and ancestor headers needed for that specific block. /// Following the spec, we execute each block /// independently with its own witness. +/// +/// The fixture's `statelessOutputBytes` encodes the expected output as +/// `new_payload_request_root (32 bytes) || valid (1 byte) || padding`. When +/// `valid == 0`, the witness is intentionally invalid and stateless execution +/// is expected to fail (or conclude the block is invalid). #[cfg(feature = "stateless")] async fn run_stateless_from_fixture( test: &TestUnit, @@ -551,6 +556,10 @@ async fn run_stateless_from_fixture( continue; }; + let expect_stateless_valid = expected_stateless_valid( + block_data.stateless_output_bytes.as_deref(), + ); + let block: CoreBlock = block_data.clone().into(); let block_number = block.header.number; @@ -559,9 +568,20 @@ async fn run_stateless_from_fixture( format!("Failed to parse executionWitness for block {block_number}: {e}") })?; - let execution_witness = rpc_witness - .into_execution_witness(*chain_config, block_number) - .map_err(|e| format!("Witness conversion failed for block {block_number}: {e}"))?; + let witness_conversion_result = rpc_witness.into_execution_witness(*chain_config, block_number); + let execution_witness = match witness_conversion_result { + Ok(w) => w, + Err(e) => { + // An intentionally invalid witness may fail conversion — that's a valid + // "block is invalid" conclusion when the fixture marks this block invalid. + if !expect_stateless_valid { + continue; + } + return Err(format!( + "Witness conversion failed for block {block_number}: {e}" + )); + } + }; let program_input = ProgramInput::new(vec![block], execution_witness); @@ -571,12 +591,39 @@ async fn run_stateless_from_fixture( BackendType::SP1 => Sp1Backend::new().execute(program_input), }; - if let Err(e) = execute_result { - return Err(format!( - "Stateless execution from fixture failed for {test_key} block {block_number}: {e}" - )); + match execute_result { + Ok(()) => { + if !expect_stateless_valid { + return Err(format!( + "Expected stateless execution to fail for {test_key} block {block_number} (fixture valid=0x00) but it succeeded" + )); + } + } + Err(e) => { + if expect_stateless_valid { + return Err(format!( + "Stateless execution from fixture failed for {test_key} block {block_number}: {e}" + )); + } + } } } Ok(()) } + +/// Parse the `valid` flag (byte 32) from a fixture's `statelessOutputBytes`. +/// +/// Layout: `new_payload_request_root (32 bytes) || valid (1 byte) || padding`. +/// If absent or unparseable, default to expecting success (true) to preserve +/// the prior behavior for fixtures without this field. +#[cfg(feature = "stateless")] +fn expected_stateless_valid(hex_str: Option<&str>) -> bool { + let Some(s) = hex_str else { return true }; + let s = s.strip_prefix("0x").unwrap_or(s); + // valid byte is at hex offset 64..66 (bytes[32]) + match s.get(64..66).and_then(|b| u8::from_str_radix(b, 16).ok()) { + Some(0) => false, + _ => true, + } +} From 1b94e2a0d4c8ed56c2cd65f6b5c8af4594f2e5be Mon Sep 17 00:00:00 2001 From: ilitteri Date: Thu, 16 Apr 2026 15:00:33 -0300 Subject: [PATCH 2/3] Add a dedicated CI job for the stateless zkEVM EIP-8025 fixtures. The regular Blockchain EF tests already exercise these tests as part of make test (which runs test-stateless), but a dedicated job makes failures visible and self-contained. Mirrors the matrix-less setup of the existing blockchain EF tests step. --- .github/workflows/pr-main_l1.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/pr-main_l1.yaml b/.github/workflows/pr-main_l1.yaml index f1962402b8d..1ec6bbbcd95 100644 --- a/.github/workflows/pr-main_l1.yaml +++ b/.github/workflows/pr-main_l1.yaml @@ -126,6 +126,24 @@ jobs: run: | make -C tooling/ef_tests/blockchain test + test-stateless-zkevm: + name: Blockchain EF tests - Stateless zkEVM (EIP-8025) + needs: detect-changes + if: ${{ needs.detect-changes.outputs.run_tests == 'true' && github.event_name != 'merge_group' }} + runs-on: ubuntu-22.04 + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Free Disk Space + uses: ./.github/actions/free-disk + + - name: Setup Rust Environment + uses: ./.github/actions/setup-rust + + - name: Run stateless zkEVM EF tests + run: make -C tooling/ef_tests/blockchain test-stateless-zkevm + docker_build: name: Build Docker runs-on: ubuntu-latest From 8517e5089702865b60e4e5b1eb8ce5905a053ed6 Mon Sep 17 00:00:00 2001 From: ilitteri Date: Thu, 16 Apr 2026 15:49:04 -0300 Subject: [PATCH 3/3] Scope the header ordering check to RPC witnesses, rename CI job. Moving the "headers must be a contiguous ascending chain" invariant into GuestProgramState::from_witness was too broad: ethrex-generated witnesses used by the regular stateless EF tests may list ancestor headers in other orders, and the strict check rejected them. Instead, apply the check only inside RpcExecutionWitness::into_execution_witness, which is the ingress point for fixture-provided witnesses (and the one actually exercised by the v0.3.3 validation_headers_non_contiguous_chain test). Revert the error-propagation change in execute_blocks for the same reason. Also rename the new CI job to "Test - Stateless zkEVM" to match the existing "Test - " naming convention, and fix cargo fmt. --- .github/workflows/pr-main_l1.yaml | 4 +- .../common/types/block_execution_witness.rs | 40 +++++++++---------- crates/guest-program/src/common/execution.rs | 9 ++--- tooling/ef_tests/blockchain/test_runner.rs | 8 ++-- 4 files changed, 29 insertions(+), 32 deletions(-) diff --git a/.github/workflows/pr-main_l1.yaml b/.github/workflows/pr-main_l1.yaml index 1ec6bbbcd95..271a444a340 100644 --- a/.github/workflows/pr-main_l1.yaml +++ b/.github/workflows/pr-main_l1.yaml @@ -127,7 +127,7 @@ jobs: make -C tooling/ef_tests/blockchain test test-stateless-zkevm: - name: Blockchain EF tests - Stateless zkEVM (EIP-8025) + name: Test - Stateless zkEVM needs: detect-changes if: ${{ needs.detect-changes.outputs.run_tests == 'true' && github.event_name != 'merge_group' }} runs-on: ubuntu-22.04 @@ -141,7 +141,7 @@ jobs: - name: Setup Rust Environment uses: ./.github/actions/setup-rust - - name: Run stateless zkEVM EF tests + - name: Run Blockchain EF tests run: make -C tooling/ef_tests/blockchain test-stateless-zkevm docker_build: diff --git a/crates/common/types/block_execution_witness.rs b/crates/common/types/block_execution_witness.rs index 96204db6ebf..86e51a5bb54 100644 --- a/crates/common/types/block_execution_witness.rs +++ b/crates/common/types/block_execution_witness.rs @@ -151,16 +151,27 @@ impl RpcExecutionWitness { )); } - 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; + // Decode headers up front so we can validate the ordering invariant: + // an RPC witness must provide headers in ascending, contiguous block-number + // order. Reordered or gapped inputs would otherwise be silently normalized + // by the BTreeMap in `GuestProgramState::from_witness` and yield an + // incorrect conclusion about the block's validity. + let decoded_headers: Vec = self + .headers + .iter() + .map(|h| BlockHeader::decode(h)) + .collect::>()?; + for window in decoded_headers.windows(2) { + if window[1].number != window[0].number + 1 { + return Err(GuestProgramStateError::NoncontiguousBlockHeaders); } } + let initial_state_root = decoded_headers + .iter() + .find(|h| h.number == first_block_number - 1) + .map(|h| h.state_root); + let initial_state_root = initial_state_root.ok_or_else(|| { GuestProgramStateError::Custom(format!( "header for block {} not found", @@ -328,25 +339,14 @@ impl GuestProgramState { value: ExecutionWitness, crypto: &dyn Crypto, ) -> Result { - let decoded_headers: Vec = value + let block_headers: BTreeMap = value .block_headers_bytes .into_iter() .map(|bytes| BlockHeader::decode(bytes.as_ref())) .collect::, _>>() .map_err(|e| { GuestProgramStateError::Custom(format!("Failed to decode block headers: {}", e)) - })?; - - // The witness must provide headers in ascending block-number order (oldest - // first) so they form a contiguous chain. Reordered or gapped inputs are - // rejected here rather than silently normalized by the BTreeMap below. - for window in decoded_headers.windows(2) { - if window[1].number != window[0].number + 1 { - return Err(GuestProgramStateError::NoncontiguousBlockHeaders); - } - } - - let block_headers: BTreeMap = decoded_headers + })? .into_iter() .map(|header| (header.number, header)) .collect(); diff --git a/crates/guest-program/src/common/execution.rs b/crates/guest-program/src/common/execution.rs index f458ca9c29e..b43988ee818 100644 --- a/crates/guest-program/src/common/execution.rs +++ b/crates/guest-program/src/common/execution.rs @@ -70,13 +70,10 @@ where // Validate execution witness' block hashes report_cycles("get_first_invalid_block_hash", || { - match wrapped_db.get_first_invalid_block_hash() { - Ok(Some(invalid_block_header)) => { - Err(ExecutionError::InvalidBlockHash(invalid_block_header)) - } - Ok(None) => Ok(()), - Err(e) => Err(ExecutionError::GuestProgramState(e)), + if let Ok(Some(invalid_block_header)) = wrapped_db.get_first_invalid_block_hash() { + return Err(ExecutionError::InvalidBlockHash(invalid_block_header)); } + Ok(()) })?; // Validate initial state diff --git a/tooling/ef_tests/blockchain/test_runner.rs b/tooling/ef_tests/blockchain/test_runner.rs index f6838ea0b2c..9740ed2fba7 100644 --- a/tooling/ef_tests/blockchain/test_runner.rs +++ b/tooling/ef_tests/blockchain/test_runner.rs @@ -556,9 +556,8 @@ async fn run_stateless_from_fixture( continue; }; - let expect_stateless_valid = expected_stateless_valid( - block_data.stateless_output_bytes.as_deref(), - ); + let expect_stateless_valid = + expected_stateless_valid(block_data.stateless_output_bytes.as_deref()); let block: CoreBlock = block_data.clone().into(); let block_number = block.header.number; @@ -568,7 +567,8 @@ async fn run_stateless_from_fixture( format!("Failed to parse executionWitness for block {block_number}: {e}") })?; - let witness_conversion_result = rpc_witness.into_execution_witness(*chain_config, block_number); + let witness_conversion_result = + rpc_witness.into_execution_witness(*chain_config, block_number); let execution_witness = match witness_conversion_result { Ok(w) => w, Err(e) => {