Skip to content

fix(l1): close EIP-8025 stateless witness validation gaps (re-enable 9 zkevm tests)#6541

Open
avilagaston9 wants to merge 14 commits intomainfrom
fix/l1-stateless-witness-validation-gaps
Open

fix(l1): close EIP-8025 stateless witness validation gaps (re-enable 9 zkevm tests)#6541
avilagaston9 wants to merge 14 commits intomainfrom
fix/l1-stateless-witness-validation-gaps

Conversation

@avilagaston9
Copy link
Copy Markdown
Contributor

@avilagaston9 avilagaston9 commented Apr 27, 2026

Motivation

PR #6527 widened stateless coverage to all for_amsterdam fixtures and skipped 9 validation_* tests that exposed gaps in ethrex's witness handling. This PR closes those gaps so the 9 tests pass.

Description

Four spec-aligned changes (refs to EELS execution-specs@projects/zkevm and geth core/stateless/database.go are inline in the code):

  • RPC-side toleranceinto_execution_witness drops trie-node entries that fail RLP-decode instead of failing the whole conversion. Bad/extra entries can't be looked up by hash; if execution requires one, the trie walk errors there.
  • Guest-side strictnessfrom_witness rejects malformed headers and chains where headers[i].parent_hash != keccak(headers[i-1]). Mirrors EELS validate_headers.
  • Codes completenessget_account_code / get_code_metadata error on missing bytecode instead of silently returning empty code.
  • Generator alignmentgenerate_witness_for_blocks now emits ancestors in ascending order (was descending). Required so ethrex-generated witnesses satisfy the contiguity check above.

Cached pre-fix witnesses are descending; they self-heal within the ~120-block cache window, so no read-side sort is added.

How to test

make -C tooling/ef_tests/blockchain test-stateless

Expected: 8720 passed / 0 failed / 0 ignored, ~155 s. test-levm unaffected.

Checklist

  • Updated STORE_SCHEMA_VERSION — N/A

…5.6.1

base, so it lands cleanly on main) and widen the Makefile zkevm-vectors
extraction from just eip8025_optional_proofs to the whole for_amsterdam
tree. This lifts witness-based stateless coverage from the ~93 eip8025
fixtures to all 2763 for_amsterdam fixtures, since every zkevm fixture
now carries executionWitness.

Order the zkevm extraction after amsterdam-vectors so its witness-bearing
JSONs overlay the non-witness copies produced by bal@v5.6.1, giving us
the "free extra checks" the EEST maintainers suggested.

Teach run_stateless_from_fixture about the zkevm output format. The
fixture's statelessOutputBytes packs
  new_payload_request_root (32 B) || valid (1 B) || padding,
so byte 32 is the expected validity marker. parse_expected_valid_flag
decodes it and the outcome match now accepts two cases as success:
valid=1 with Ok and valid=0 with Err (the new tolerance/rejection tests
rely on the latter). Without this, every zkevm@v0.3.3 "invalid
executionWitness" test would fail with an inverted error.

Refactor the skip list in tests/all.rs so prover/stateless extras are
feature-gated instead of living in SKIPPED_BASE. Add a new
#[cfg(feature = "stateless")] EXTRA_SKIPS group covering nine
validation_* tests the v0.3.3 bundle exposes: four "tolerance" cases
(valid=1) where RpcExecutionWitness::into_execution_witness eagerly
rejects entries the execution path never touches, and five "rejection"
cases (valid=0) where our stateless path runs clean instead of
noticing a missing code or ancestor entry. Both groups have TODOs
pointing at the real follow-up work (lazy witness parsing and
completeness checks in the guest program).

End state: test-stateless goes from 16 failures to 8720 passed /
0 failed / 9 skipped. test-levm behavior is unchanged.
so the resulting list satisfies `headers[i].parent_hash == keccak(headers[i-1])`,
matching the EELS contract for `validate_headers`. The generator walked the
chain backward (newest -> oldest), so reverse the byte list before returning.

This is a no-op for current consumers because the headers are stored in a
`BTreeMap<u64, BlockHeader>` keyed by number, but it makes the witness valid
for any spec-conformant stateless verifier and is a precondition for adding
the EIP-8025 contiguity check on the consumer side.

Reference: https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L171-L191
…teness:

- Tolerance: when decoding state nodes and ancestor headers in `into_execution_witness`
  and `from_witness`, drop entries that fail to RLP-decode instead of failing the
  whole conversion. A bad/extra entry cannot be looked up by hash anyway; if the
  trie walk or BLOCKHASH path actually requires the dropped entry, the lookup
  fails explicitly there. Mirrors EELS `witness_state.build_node_db` and geth
  `MakeHashDB`, which both store entries keyed by hash without pre-validation.

- Header chain contiguity: in `from_witness`, walk the header byte list in order
  and reject when `headers[i].parent_hash != keccak(headers[i-1])`. A reordered
  or fragmented header chain is not a valid witness even if the by-number
  lookup would otherwise resolve to the right header. Mirrors EELS
  `stateless.validate_headers`. Malformed entries are treated as a chain break
  (subsequent headers won't satisfy the parent_hash check), preserving the
  tolerance behavior for blocks that do not actually request the bad header.

- Codes completeness: `get_account_code` and `get_code_metadata` now error on
  missing bytecode instead of silently defaulting to empty code. EIP-8025 mandates
  that a stateless executor reaching a code lookup whose hash is not in the witness
  treat the witness as incomplete and reject. Matches EELS `witness_state.get_code`
  (raises KeyError on miss) and geth's documented "bytecode lookup will error on
  junk" model.

References:
- https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/witness_state.py#L37-L42
- https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/witness_state.py#L204-L212
- https://github.com/ethereum/execution-specs/blob/projects/zkevm/src/ethereum/forks/amsterdam/stateless.py#L171-L191
- https://github.com/ethereum/go-ethereum/blob/master/core/stateless/database.go#L26-L67
…ed under

`feature = "stateless"`. They now pass with the witness-consumer alignment
(tolerance + contiguity + codes-completeness) and the generator-side ascending
header order:

  - validation_state_extra_unused_trie_node
  - validation_headers_malformed_rlp_header
  - validation_headers_missing_oldest_blockhash_ancestor
  - validation_headers_missing_parent_header
  - validation_headers_non_contiguous_chain
  - 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

`make test-stateless`: 8720 passed / 0 failed / 0 ignored (~155 s).
`make test-levm`: unchanged.
…; keep

the spec link but drop the prose. No behavior change.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 27, 2026

Lines of code report

Total lines added: 7
Total lines removed: 11
Total lines changed: 18

Detailed view
+-------------------------------------------------------+-------+------+
| File                                                  | Lines | Diff |
+-------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs                | 2494  | +4   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_execution_witness.rs | 559   | -11  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/common/execution.rs   | 138   | +3   |
+-------------------------------------------------------+-------+------+

…nversion

comments — keep the prose and the spec links, just remove the symbol-plus-word
labels. Restore the `This is an optimized path for EXTCODESIZE opcode.` doc
line on `get_code_metadata` that was dropped while trimming. No behavior
change.
…ader.parent_hash != expected_parent { ... } }` in `from_witness` into a single let-chain `if`. Required to satisfy `clippy::collapsible_if` under `-D warnings` on rust 1.91 — the CI Lint and Lint L2 jobs were failing on this. Behavior unchanged.
@avilagaston9 avilagaston9 marked this pull request as ready for review April 28, 2026 16:52
@avilagaston9 avilagaston9 requested a review from a team as a code owner April 28, 2026 16:52
@ethrex-project-sync ethrex-project-sync Bot moved this to In Review in ethrex_l1 Apr 28, 2026
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR closes four EIP-8025 stateless-witness validation gaps, re-enabling 9 previously-skipped validation_* ef-tests: (1) malformed trie nodes/headers are now silently dropped instead of aborting conversion; (2) from_witness validates that the header list forms a contiguous chain in list order; (3) get_account_code / get_code_metadata now hard-error on missing bytecode; and (4) the witness generator emits ancestor headers in ascending order to satisfy the new contiguity check.

Confidence Score: 4/5

Safe to merge; only P2 style issues found, core logic is correct and all targeted tests pass.

All findings are P2 (a misleading comment and using Custom instead of the typed NoncontiguousBlockHeaders variant). The spec-aligned logic changes are well-reasoned and backed by EELS references.

crates/common/types/block_execution_witness.rs — comment accuracy and error variant usage in from_witness.

Important Files Changed

Filename Overview
crates/common/types/block_execution_witness.rs Four EIP-8025 changes: tolerance for malformed trie nodes/headers, contiguous-chain validation in from_witness, and hard errors on missing bytecode. Minor: a misleading comment on the malformed-entry path and a Custom error where the typed NoncontiguousBlockHeaders variant already exists.
crates/blockchain/blockchain.rs Adds block_headers_bytes.reverse() in both generate_witness_for_blocks code paths so the emitted ancestor list is ascending, satisfying the new contiguity check in from_witness.
tooling/ef_tests/blockchain/tests/all.rs Removes the stateless-specific skip list (9 tests) and simplifies to a single #[cfg(not(feature = "sp1"))] empty slice, re-enabling all formerly-skipped validation tests.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["RpcExecutionWitness\n(from RPC / fixture)"] -->|"into_execution_witness()"| B["ExecutionWitness\n(block_headers_bytes in ascending order)"]
    GEN["generate_witness_for_blocks\n(walks chain backward)"] -->|"reverse() → ascending"| B

    B -->|"from_witness()"| C{For each header bytes}
    C -->|"decode fails"| D["prev_hash = None\nskip (tolerance)"]
    D --> C
    C -->|"decode ok\nprev_hash != None AND\nheader.parent_hash != prev_hash"| E["❌ NoncontiguousBlockHeaders\nerror"]
    C -->|"decode ok\nchain link valid"| F["insert into block_headers\nprev_hash = keccak(bytes)"]
    F --> C
    C -->|"done"| G["GuestProgramState"]

    G --> H["get_account_code(hash)"]
    H -->|"hash missing in codes_hashed"| I["❌ Database error\n(was: silent empty code)"]
    H -->|"hash found"| J["✅ Return Code"]
Loading
Prompt To Fix All 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.

---

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.

Reviews (1): Last reviewed commit: "Collapse the nested `if let Some(expecte..." | Re-trigger Greptile

Comment on lines +330 to +334
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;
};
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.

Comment on lines +337 to +340
{
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.

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have a full picture of the PR. Here is my review:


PR #6541 Review: fix(l1): close EIP-8025 stateless witness validation gaps

Overview

This PR closes four stateless witness validation gaps that were preventing 9 validation_* EF-test fixtures from passing under the stateless feature flag. The changes are well-motivated, commit messages are thorough, and spec references (EELS + geth) are provided inline. All 9 previously-skipped tests are re-enabled.


Correctness Analysis

1. Header contiguity check — comment is inaccurate (minor bug)

block_execution_witness.rs, from_witness:

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;
};
if let Some(expected_parent) = prev_hash
    && header.parent_hash != expected_parent
{
    return Err(...);
}

Setting prev_hash = None means the if let Some(expected_parent) guard on the next valid header evaluates to false, so the check is skipped, not "failed" as the comment claims. The real behavior is: a malformed entry resets the chain anchor, and the subsequent valid header starts a new unverified segment.

The commit message repeats the same inaccuracy: "subsequent headers won't satisfy the parent_hash check." They actually bypass it. The behavior is spec-correct (tolerance) but the comment will mislead future readers. Suggested replacement:

// Drop the malformed entry; reset the chain anchor so the next valid
// header starts a new contiguous segment (EIP-8025 tolerance).
prev_hash = None;

2. Malformed-entry reset creates a potential contiguity bypass window

As a consequence of Point 1: a witness shaped as [valid_A, malformed_blob, valid_B_not_contiguous_with_A] passes the contiguity check because the malformed blob resets prev_hash. Whether the spec intends this is not entirely clear from the EELS link cited. Given that validation_headers_non_contiguous_chain (a pure-valid-header case) is now correctly rejected and validation_headers_malformed_rlp_header is correctly accepted, this is likely intentional — but worth a brief note or a reference to the EELS line that makes the "chain-restart-on-junk" behavior explicit.

3. into_execution_witness — removal of the 0x80 (RLP null) special case

Old code:

if b == Bytes::from_static(&[0x80]) {
    return None;
}
let hash = keccak(&b);
Some(Node::decode(&b).map(|node| (hash, node)))

New code:

let node = Node::decode(&b).ok()?;
Some((keccak(&b), node))

The 0x80 case is now implicitly handled because Node::decode on [0x80] (RLP null) returns an error, so ok()? drops it. This is cleaner, but the old comment explained why some implementations emit 0x80 (other implementations allow a Null node). Dropping that comment removes useful cross-client context. Consider keeping one line like:

// EIP-8025 tolerance: drop entries that don't decode (e.g. RLP-null 0x80 from some clients).

4. get_account_code / get_code_metadata — error variant mismatch

The new missing-bytecode paths use GuestProgramStateError::Database, while the header contiguity failure uses GuestProgramStateError::Custom. If the caller dispatches on error type, this inconsistency could matter. Worth confirming these are intentionally different, or aligning them.


Code Quality

5. Duplicated block_headers_bytes.reverse() in blockchain.rs

The identical fix appears at two call sites (~line 1439 and ~line 1676). This is fine for now, but the accompanying comments (and the EELS URL) are copy-pasted verbatim. If a third call site is added later, it's easy to miss. Not blocking, but worth noting.

6. hex::encode(code_hash) on H256

H256 likely already implements Display / LowerHex, so format!("{code_hash:x}") or format!("{code_hash:#x}") may avoid the hex::encode dependency. Minor.


Test Coverage

The test simplification in all.rs is correct: the #[cfg(feature = "stateless")] override with 9 skips is removed, and the previously separate #[cfg(not(any(feature = "sp1", feature = "stateless")))] empty slice collapses into #[cfg(not(feature = "sp1"))]. The result is that the stateless path now runs with the same (empty) extra-skip list as the baseline, which is exactly right.

No new unit tests are added for the individual behaviors (contiguity check, decode tolerance, missing-code error). Given that the EF fixture suite covers these paths, this is acceptable — but a targeted unit test for from_witness contiguity rejection would be a cheap safety net for regressions.


Summary

Area Verdict
Spec alignment (EELS / geth) Correct
EF test re-enablement Correct
Ascending header order fix Correct
Contiguity check logic Correct, comment misleading
Tolerance (drop malformed entries) Correct
Codes completeness (error on miss) Correct
Comment clarity One inaccurate comment (Point 1)
Error variant consistency Minor inconsistency (Point 4)

The logic is sound and the spec alignment looks right. The main ask before merge is fixing the comment in the malformed-header branch — it currently says the next check "fails" when it is actually skipped.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. GuestProgramState::from_witness still lets some non-contiguous header sets through. At crates/common/types/block_execution_witness.rs:329 the new check only enforces parent_hash == keccak(previous header bytes); it never enforces header.number == prev.number + 1. On top of that, a malformed entry resets prev_hash at crates/common/types/block_execution_witness.rs:330, so the next decoded header is not checked against the previous chain at all. That would still accept witnesses like [98, <garbage>, 100] or forged-number chains like [98, 100(parent=hash98)] unless block 99 is actually queried. The fallback check does exist, but its Err(NoncontiguousBlockHeaders) path is ignored in crates/guest-program/src/common/execution.rs:72. I’d add an explicit number-adjacency check here, or propagate that error instead of discarding it.

  2. The header-order change is not backward-compatible with cached witnesses. Newly generated witnesses are reversed into ascending order at crates/blockchain/blockchain.rs:1439 and crates/blockchain/blockchain.rs:1676, but cached RPC witnesses are still served back verbatim from crates/storage/store.rs:2025 via crates/networking/rpc/debug/execution_witness.rs:84. RpcExecutionWitness::into_execution_witness also preserves self.headers unchanged at crates/common/types/block_execution_witness.rs:222. After an upgrade, the same node can therefore return old descending witnesses for cached blocks and new ascending witnesses for fresh ones, and the new stateless path will reject the cached ones as “not contiguous”. This needs cache invalidation/migration or read-side normalization.

Assumption: I’m reading the EIP-8025 intent here as “tolerate unused malformed entries, but require the usable header chain itself to be contiguous”. Under that interpretation, Item 1 is still a real validation gap.

I couldn’t run cargo check here because rustup tried to write under /home/runner/.rustup, which is read-only in this sandbox. Otherwise, the stricter missing-bytecode handling looks directionally correct.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

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.

Comment thread crates/blockchain/blockchain.rs Outdated
}
// 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.

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.

}
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.

…instead of skipping the failed-decode entry and resetting `prev_hash`. The previous lenient path silently bypassed the parent_hash check on the next valid header, so a witness shaped `[valid_A, malformed, fake_B]` was accepted with `fake_B.parent_hash` arbitrary. The malformed-decode branch now propagates the `RLPDecodeError` via `?`, and the contiguity branch returns the typed `GuestProgramStateError::NoncontiguousBlockHeaders` variant in place of `Custom("witness headers are not contiguous")`.

Stop discarding `Err(...)` from `get_first_invalid_block_hash` in `execute_blocks`. The `if let Ok(Some(...))` pattern silently dropped the `NoncontiguousBlockHeaders` error returned when `next_number != number + 1`, so the number-adjacency check at `block_execution_witness.rs:498` only fired when the parent-hash check at line 509 also passed.

Update the `into_execution_witness` comment near the parent state-root lookup to flag that the local `.ok()` filter on headers does not relax the strict chain check downstream.
…:encode(code_hash)` in `get_account_code` and `get_code_metadata`. `H256` implements `LowerHex` (no `0x` prefix), matching `hex::encode`'s output.
…cks_with_fee_configs` and `generate_witness_from_account_updates` into a free function `build_ascending_ancestor_headers_bytes` next to `find_parent_header`. Both call sites built `block_headers_bytes` with the same loop and the same `EELS expects ascending order` reverse — the parallel implementations were a drift risk noted in review.

Add an integration test in `test/tests/blockchain/witness_tests.rs` that builds a 3-block chain on an in-memory store, calls `generate_witness_for_blocks(&[block_2, block_3])`, decodes the resulting `block_headers_bytes`, and asserts the ancestor numbers form a strictly ascending sequence.
Base automatically changed from feat/l1-ef-tests-bump-zkevm-fixtures-v0.3.3 to main May 6, 2026 21:06
…gs in #6527 (zkevm v0.3.3 fixtures + amsterdam stateless coverage), #6516 (EIP-8025 compliance), the v11.0.0 bump, and the rest of main since the branch diverged.

Conflicts resolved:
- `tooling/ef_tests/blockchain/Makefile`: kept main's explanatory comment on `zkevm-vectors`.
- `tooling/ef_tests/blockchain/test_runner.rs`: kept main's improved `run_stateless_from_fixture`, which hard-fails on malformed `statelessOutputBytes` and on parse/conversion errors so the `(false, Err(_))` arm cannot mask regressions, and the `Result<bool, String>` signature for `parse_expected_valid_flag`. Dropped the now-duplicate `let expected_valid = ...and_then(parse_expected_valid_flag).unwrap_or(true)` block that git auto-merged from HEAD.

Verified `make -C tooling/ef_tests/blockchain test-stateless` returns 8770 passed / 77 failed — failure set identical to the pre-merge baseline.
Copilot AI review requested due to automatic review settings May 6, 2026 21:24
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR closes remaining gaps in EIP-8025 stateless witness validation/execution so previously skipped validation_* zkevm fixtures can run through ethrex’s stateless path.

Changes:

  • Make RPC witness conversion more tolerant of malformed trie node entries (drop undecodable nodes instead of failing conversion).
  • Enforce stricter witness correctness: contiguous header chain validation and erroring on missing bytecode/code-metadata.
  • Align witness generation to emit ancestor headers in ascending order, plus add a regression test for header ordering.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/tests/blockchain/witness_tests.rs Adds an integration test asserting generated witness ancestor headers are ordered ascending.
test/tests/blockchain/mod.rs Registers the new witness_tests module.
crates/guest-program/src/common/execution.rs Propagates errors from witness block-hash validation instead of silently ignoring them.
crates/common/types/block_execution_witness.rs Updates witness parsing/validation: tolerate undecodable trie nodes, validate header contiguity, and error on missing bytecode.
crates/blockchain/blockchain.rs Refactors ancestor-header collection into a helper and reverses output to ascending order.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/blockchain/blockchain.rs Outdated
let mut current_header = walk_start_header.clone();
while current_header.hash() != first_needed_block_hash {
let parent_hash = current_header.parent_hash;
let current_number = current_header.number - 1;
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.

Done in 0f78f21checked_sub(1) returning WitnessGeneration if walked past genesis.

Comment thread test/tests/blockchain/witness_tests.rs Outdated
Comment on lines +39 to +41
assert!(
numbers.windows(2).all(|w| w[0] < w[1]),
"ancestor headers must be ascending, got {numbers:?}"
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.

Done in f9f0fdc — pinned exact ancestor numbers via assert_eq!.

Comment on lines +324 to +333
// Headers must decode and form a contiguous chain in list order.
// 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 header = BlockHeader::decode(bytes.as_ref())?;
if let Some(expected_parent) = prev_hash
&& header.parent_hash != expected_parent
{
return Err(GuestProgramStateError::NoncontiguousBlockHeaders);
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.

Stale — from_witness now hard-errors on malformed headers (b531dab). PR description updated.

…r_headers_bytes` so the helper returns a typed `WitnessGeneration` error instead of panicking in debug (or wrapping to `u64::MAX` in release) if the chain walk reaches block 0 without matching `first_needed_block_hash`. Production paths can't trigger this — the cached chain walk always terminates within the witness cache window — but the helper is now load-bearing for both call sites and the underflow surfaced as a confusing error string in the format!(...) message.
…_witness_has_ancestor_headers_in_ascending_order` with `assert_eq!(numbers, vec![block_1.header.number, block_2.header.number])`. The original passes vacuously when `block_headers_bytes` has 0 or 1 entries — `windows(2)` yields an empty iterator and `all(...)` returns true — so a regression that drops or shortens the ancestor list would slip through. Pinning the exact expected ancestors fails loudly instead.
…cution_witness` filter comment. Some clients emit the RLP-null marker for absent trie nodes; `Node::decode` errors on it and `.ok()?` drops it. The previous EIP-8025 cleanup removed this context, so a reader had to dig into other implementations to learn why empty entries are expected here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants