Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/blockchain/blockchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,9 @@ impl Blockchain {

block_headers_bytes.push(current_header.encode_to_vec());
}
// EELS expects ascending order; we walked the chain backward, so reverse.
// Ref: https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L171-L191
block_headers_bytes.reverse();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth confirming: this code walks the chain backward (parent → ... → child) and pushes to block_headers_bytes, then reverses to ascending. The reverse is correct if the loop pushes in strictly child-to-parent order. The hunk shows the same pattern at line 1678 — both call sites do the same walk and reverse. If they ever diverge (say, one of them changes the iteration direction), the reverse becomes wrong silently. Worth extracting the build-headers logic into a single helper to avoid the parallel-implementation drift risk.

Also worth a unit test asserting block_headers_bytes is in ascending order after this loop — would catch any future change to the loop direction.

Copy link
Copy Markdown
Contributor Author

@avilagaston9 avilagaston9 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 29c80c2 — extracted build_ascending_ancestor_headers_bytes, both sites share it. Test in test/tests/blockchain/witness_tests.rs.


// Get initial state trie root and embed the rest of the trie into it
let nodes: BTreeMap<H256, Node> = used_trie_nodes
Expand Down Expand Up @@ -1670,6 +1673,9 @@ impl Blockchain {

block_headers_bytes.push(current_header.encode_to_vec());
}
// EELS expects ascending order; we walked the chain backward, so reverse.
// Ref: https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L171-L191
block_headers_bytes.reverse();

// Get initial state trie root and embed the rest of the trie into it
let nodes: BTreeMap<H256, Node> = used_trie_nodes
Expand Down
122 changes: 60 additions & 62 deletions crates/common/types/block_execution_witness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,36 +151,31 @@ 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;
}
}

let initial_state_root = initial_state_root.ok_or_else(|| {
GuestProgramStateError::Custom(format!(
"header for block {} not found",
first_block_number - 1
))
})?;
// Skip headers that fail to decode, then pick parent by number.
let initial_state_root = self
.headers
.iter()
.filter_map(|h| BlockHeader::decode(h).ok())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lenient .ok() on RPC side is intentional per EIP-8025 — the witness can carry extra entries that don't decode as headers. That's a different choice from the guest-side strict parsing at line 327+ (which now contiguity-checks each header).

This split is correct in shape (RPC = lenient input, guest = strict invariant). Worth a short comment explaining the asymmetry — currently a future maintainer reading just one of these blocks would wonder why the other is different.

Copy link
Copy Markdown
Contributor Author

@avilagaston9 avilagaston9 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b531dab.

.find(|header| header.number == first_block_number - 1)
.map(|header| header.state_root)
.ok_or_else(|| {
GuestProgramStateError::Custom(format!(
"header for block {} not found",
first_block_number - 1
))
})?;

// EIP-8025: drop entries that don't decode. They can't be looked up by
// hash anyway; if execution needs them, the trie walk fails there.
// Ref: https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/witness_state.py#L37-L42
let nodes: BTreeMap<H256, Node> = 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::<Result<_, RLPDecodeError>>()?;
.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, _) =
Expand Down Expand Up @@ -325,17 +320,28 @@ impl GuestProgramState {
value: ExecutionWitness,
crypto: &dyn Crypto,
) -> Result<Self, GuestProgramStateError> {
let block_headers: BTreeMap<u64, BlockHeader> = value
.block_headers_bytes
.into_iter()
.map(|bytes| BlockHeader::decode(bytes.as_ref()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
GuestProgramStateError::Custom(format!("Failed to decode block headers: {}", e))
})?
.into_iter()
.map(|header| (header.number, header))
.collect();
// EIP-8025: headers must form a contiguous chain in list order (each
// `parent_hash` matches keccak of the previous header bytes). Reordered
// or fragmented chains are invalid even if by-number lookup would resolve.
// Ref: https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L171-L191
let mut block_headers: BTreeMap<u64, BlockHeader> = BTreeMap::new();
let mut prev_hash: Option<H256> = None;
for bytes in &value.block_headers_bytes {
let Ok(header) = BlockHeader::decode(bytes.as_ref()) else {
// Malformed entry is a chain break; the next parent_hash check fails.
prev_hash = None;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed-header recovery skips contiguity check on next header (the inline I posted before — re-anchoring with line+side):

After a malformed header, prev_hash = None skips the contiguity check on the next successful header — if let Some(expected_parent) = prev_hash is None, so the check is skipped, and we proceed with prev_hash reset to the current header's keccak.

Consequence: an attacker can insert a malformed entry at position N, then place a fake header at position N+1 with arbitrary parent_hash. The for-loop accepts it without verification. Since block_headers is then indexed by number, the fake header's state_root (or anything else) is silently used downstream.

The doc comment says "Malformed entry is a chain break; the next parent_hash check fails" — but the actual behavior is that the check is skipped, not failed.

Two possible fixes:

  1. Hard-fail on any malformed header: return Err(GuestProgramStateError::Custom("malformed header in witness")) instead of prev_hash = None; continue. Matches the strict-validation theme of this PR.
  2. Accept segments but require each segment to start at a known-good boundary (e.g., genesis or the parent of the first executed block).

Option 1 is simpler and more defensible. If EELS does intend lenient acceptance, the comment should at least be corrected to reflect that the rest of the chain after a break is unvalidated.

Copy link
Copy Markdown
Contributor Author

@avilagaston9 avilagaston9 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b531dab — hard-fail + typed NoncontiguousBlockHeaders. Also propagated the previously-discarded Err from get_first_invalid_block_hash.

continue;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Misleading inline comment — check is skipped, not failed

The comment says "the next parent_hash check fails," but when prev_hash is set to None, the subsequent if let Some(expected_parent) = prev_hash guard in the next iteration simply does not fire — the check is bypassed, not triggered-and-failed. A validator reading this could mistake the skipping for active rejection.

Suggested change
let Ok(header) = BlockHeader::decode(bytes.as_ref()) else {
// Malformed entry is a chain break; the next parent_hash check fails.
prev_hash = None;
continue;
};
let Ok(header) = BlockHeader::decode(bytes.as_ref()) else {
// Malformed entry: skip it and reset the chain cursor so the
// next decodable header is accepted without a parent-hash check.
prev_hash = None;
continue;
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/types/block_execution_witness.rs
Line: 330-334

Comment:
**Misleading inline comment — check is skipped, not failed**

The comment says "the next parent_hash check fails," but when `prev_hash` is set to `None`, the subsequent `if let Some(expected_parent) = prev_hash` guard in the next iteration simply does not fire — the check is bypassed, not triggered-and-failed. A validator reading this could mistake the skipping for active rejection.

```suggestion
            let Ok(header) = BlockHeader::decode(bytes.as_ref()) else {
                // Malformed entry: skip it and reset the chain cursor so the
                // next decodable header is accepted without a parent-hash check.
                prev_hash = None;
                continue;
            };
```

How can I resolve this? If you propose a fix, please make it concise.

if let Some(expected_parent) = prev_hash
&& header.parent_hash != expected_parent
{
return Err(GuestProgramStateError::Custom(
"witness headers are not contiguous".to_string(),
));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Use the existing NoncontiguousBlockHeaders error variant

There is already a typed variant GuestProgramStateError::NoncontiguousBlockHeaders (defined a few lines above) with the message "Non-contiguous block headers (there's a gap in the block headers list)". Using Custom(...) here bypasses that variant and makes error matching on the call-site harder.

Suggested change
{
return Err(GuestProgramStateError::Custom(
"witness headers are not contiguous".to_string(),
));
{
return Err(GuestProgramStateError::NoncontiguousBlockHeaders);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/types/block_execution_witness.rs
Line: 337-340

Comment:
**Use the existing `NoncontiguousBlockHeaders` error variant**

There is already a typed variant `GuestProgramStateError::NoncontiguousBlockHeaders` (defined a few lines above) with the message "Non-contiguous block headers (there's a gap in the block headers list)". Using `Custom(...)` here bypasses that variant and makes error matching on the call-site harder.

```suggestion
            {
                return Err(GuestProgramStateError::NoncontiguousBlockHeaders);
            }
```

How can I resolve this? If you propose a fix, please make it concise.

}
prev_hash = Some(H256(crypto.keccak256(bytes)));
block_headers.insert(header.number, header);
}

let parent_number =
value
Expand Down Expand Up @@ -588,28 +594,22 @@ impl GuestProgramState {
Ok(self.chain_config)
}

/// Retrieves the account code for a specific account.
/// Returns an Err if the code is not found.
/// Retrieves bytecode by code hash. Errors if missing — EIP-8025.
///
/// Ref: https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/witness_state.py#L204-L212
pub fn get_account_code(&self, code_hash: H256) -> Result<Code, GuestProgramStateError> {
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::Database(format!(
"missing bytecode for hash {} in witness",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good change — the old println!("Missing bytecode... defaulting to empty") silently masked real bugs (missing-witness bytecode would lead to a state root mismatch later, but the println would have already scrolled past in a noisy log). Hard-erroring here surfaces the witness-corruption scenario where it should be surfaced.

One thing worth confirming: are there any callers that legitimately tolerated a missing-bytecode return in the old code? The comment said "In client implementations there are differences and it's natural for some clients to access more/less information in some edge cases" — that's a hint the lenient default was masking a known issue. If there's a real EELS test fixture where bytecode is intentionally absent (e.g., for self-destructed accounts), this strict error would now flag it. Worth confirming against the v0.3.3 fixture suite that bumped in #6498.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — test-stateless passes 8720/0 with the strict-on-missing-bytecode change in place.

hex::encode(code_hash)
))
})
}

/// Retrieves code metadata (length) for a specific code hash.
/// Code length by hash. Errors on miss, like `get_account_code`.
/// This is an optimized path for EXTCODESIZE opcode.
pub fn get_code_metadata(
&self,
Expand All @@ -620,19 +620,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::Database(format!(
"missing bytecode for hash {} in 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.
Expand Down
2 changes: 1 addition & 1 deletion tooling/ef_tests/blockchain/.fixtures_url_zkevm
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions tooling/ef_tests/blockchain/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ amsterdam-vectors: $(AMSTERDAM_ARTIFACT) $(SPECTEST_VECTORS_DIR)
$(ZKEVM_ARTIFACT): $(ZKEVM_FIXTURES_FILE)
curl -L -o $(ZKEVM_ARTIFACT) $(ZKEVM_URL)

zkevm-vectors: $(ZKEVM_ARTIFACT) $(SPECTEST_VECTORS_DIR)
tar -xzf $(ZKEVM_ARTIFACT) --strip-components=2 -C $(SPECTEST_VECTORS_DIR) fixtures/blockchain_tests/for_amsterdam/amsterdam/eip8025_optional_proofs
zkevm-vectors: $(ZKEVM_ARTIFACT) $(SPECTEST_VECTORS_DIR) amsterdam-vectors
tar -xzf $(ZKEVM_ARTIFACT) --strip-components=2 -C $(SPECTEST_VECTORS_DIR) fixtures/blockchain_tests/for_amsterdam

help: ## 📚 Show help for each of the Makefile recipes
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
Expand Down
70 changes: 49 additions & 21 deletions tooling/ef_tests/blockchain/test_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,32 +551,60 @@ async fn run_stateless_from_fixture(
continue;
};

// zkevm fixtures encode the expected stateless outcome in `statelessOutputBytes`
// as `new_payload_request_root (32 bytes) ++ valid (1 byte) ++ trailing padding`.
// When the fixture signals `valid = false` the witness is deliberately incomplete
// and the stateless path must reject it; absent bytes means "expected to succeed".
let expected_valid = block_data
.stateless_output_bytes
.as_deref()
.and_then(parse_expected_valid_flag)
.unwrap_or(true);

let block: CoreBlock = block_data.clone().into();
let block_number = block.header.number;

let rpc_witness: RpcExecutionWitness = serde_json::from_value(witness_json.clone())
.map_err(|e| {
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 program_input = ProgramInput::new(vec![block], execution_witness);

let execute_result = match backend_type {
BackendType::Exec => ExecBackend::new().execute(program_input),
#[cfg(feature = "sp1")]
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}"
));
let stateless_outcome: Result<(), String> = (|| {
let rpc_witness: RpcExecutionWitness = serde_json::from_value(witness_json.clone())
.map_err(|e| format!("executionWitness parse: {e}"))?;
let execution_witness = rpc_witness
.into_execution_witness(*chain_config, block_number)
.map_err(|e| format!("witness conversion: {e}"))?;
let program_input = ProgramInput::new(vec![block.clone()], execution_witness);
let res = match backend_type {
BackendType::Exec => ExecBackend::new().execute(program_input),
#[cfg(feature = "sp1")]
BackendType::SP1 => Sp1Backend::new().execute(program_input),
};
res.map(|_| ()).map_err(|e| format!("execution: {e}"))
})();

match (expected_valid, stateless_outcome) {
(true, Ok(())) | (false, Err(_)) => {}
(true, Err(e)) => {
return Err(format!(
"Stateless execution from fixture failed for {test_key} block {block_number}: {e}"
));
}
(false, Ok(())) => {
return Err(format!(
"Stateless execution from fixture succeeded for {test_key} block \
{block_number} but fixture expected it to fail (invalid executionWitness)"
));
}
}
}

Ok(())
}

/// Extract the `valid` byte from a zkevm-fixture `statelessOutputBytes` hex string.
///
/// The output encoding is `new_payload_request_root (32 bytes) ++ valid (1 byte) ++ padding`,
/// so byte index 32 carries the validity marker.
#[cfg(feature = "stateless")]
fn parse_expected_valid_flag(hex: &str) -> Option<bool> {
let trimmed = hex.strip_prefix("0x").unwrap_or(hex);
let byte_hex = trimmed.get(64..66)?;
u8::from_str_radix(byte_hex, 16).ok().map(|b| b != 0)
}
Loading