Skip to content

feat: integrate with ethrex over the Engine API#367

Open
pablodeymo wants to merge 20 commits into
mainfrom
engine-api-integration
Open

feat: integrate with ethrex over the Engine API#367
pablodeymo wants to merge 20 commits into
mainfrom
engine-api-integration

Conversation

@pablodeymo
Copy link
Copy Markdown
Collaborator

@pablodeymo pablodeymo commented May 13, 2026

🗒️ Description / Motivation

Lands the full Engine API integration between ethlambda (consensus) and any Engine-API-speaking execution layer, validated live against ethrex. This is Option C from docs/plans/engine-api-integration.md: ExecutionPayloadV3 is embedded in Lean BlockBody, processed by the STF, fetched from the EL on proposal, and validated against the EL on import. The transport / auth / per-slot FCU foundation (Option B) is the substrate it rides on.

Option What it means Picked?
A. Spike One-off JWT + HTTP call, prove the wire works No — too thin
B. Scaffold Typed client crate + CLI + capability handshake + per-slot FCU Foundation (lands here)
C. Full pairing Add executionPayload to Lean BlockBody + STF + newPayload/getPayload on the slot loop ✅ this PR

End-to-end result: ethrex receives real execution payloads from ethlambda every slot and reports the resulting head back via FCU, slot-by-slot, chain advancing on both sides. Logs in "Live end-to-end" below.

What Changed

New crate: crates/net/ethrex-client/

File Purpose
src/lib.rs Public API + ETHLAMBDA_ENGINE_CAPABILITIES advertised list
src/auth.rs JwtSecret — HS256 token minting, hex/file loaders, deterministic-per-iat
src/client.rs EngineClient — typed wrappers for engine_* methods over reqwest+JWT
src/types.rs Wire types: ForkChoiceState, PayloadAttributesV3, ExecutionPayloadV3, PayloadStatus, Withdrawal, hex serde helpers
src/error.rs EngineClientError — distinct variants for transport / RPC / serialize / empty-response
examples/smoke.rs Live demo binary: one-shot or --loop <N> mode at 4s slot cadence
tests/wire_smoke.rs End-to-end JSON-RPC roundtrip against a hand-rolled tokio::net::TcpListener mock

Engine methods wrapped:

  • engine_exchangeCapabilities — startup handshake
  • engine_forkchoiceUpdatedV3 — per-tick FCU + build-mode (with PayloadAttributesV3) at interval 4
  • engine_newPayloadV3 / engine_newPayloadV4 / engine_newPayloadV5 — import path; V4 adds executionRequests, V5 covers Amsterdam-era (BAL / EIP-7928)
  • engine_getPayloadV3 / engine_getPayloadV4 / engine_getPayloadV5 — fetch built payloads at interval 0
  • engine_getClientVersionV1 — diagnostics

ELs validate method version against payload timestamp; the actor calls V5 against ethrex main (which advertises V5). Field names match canonical execution-apis JSON character-for-character.

Schema change: payload in BlockBody + header in State

  • crates/common/types/src/block.rsBlockBody { attestations, execution_payload: ExecutionPayloadV3 }
  • crates/common/types/src/state.rsState { ..., latest_execution_payload_header: ExecutionPayloadHeader }
  • crates/common/types/src/execution_payload.rs — full SSZ + JSON dual encoding for ExecutionPayloadV3, ExecutionPayloadHeader, Withdrawal; hex serde helpers for primitives; BYTES_PER_LOGS_BLOOM=256, MAX_EXTRA_DATA_BYTES=32, MAX_BYTES_PER_TRANSACTION=1073741824, MAX_TRANSACTIONS_PER_PAYLOAD=1048576, MAX_WITHDRAWALS_PER_PAYLOAD=16 per execution-apis.

STF: process_execution_payload

crates/blockchain/state_transition/src/lib.rs adds:

  • process_execution_payload(state, payload) — checks payload.parent_hash == state.latest_execution_payload_header.block_hash, checks payload.timestamp == compute_time_at_slot(state.slot), caches the projected header on the post-state
  • New error variants InvalidPayloadParentHash, InvalidPayloadTimestamp
  • SECONDS_PER_SLOT = 4 and compute_time_at_slot(slot) = genesis_time + slot * 4
  • 4 unit tests covering parent-hash, timestamp, header projection, and roundtrip

crates/blockchain/ actor: real EL pairing

  • pending_payload_id: Option<(u64, PayloadId)> on BlockChainServer — caches the EL's payload-id between interval 4 (request) and interval 0 (consume)
  • current_el_forkchoice_state() shared helper reads real EL block hashes from block.body.execution_payload.block_hash; used by both heartbeat FCU and build-mode FCU (no more zeroed placeholders)
  • At interval 4: request_payload_id_for_next_slot fires forkchoiceUpdatedV3 with PayloadAttributesV3 to ask the EL to start building
  • At interval 0: take_prepared_payload consumes the cached id via getPayloadV5, embeds the resulting ExecutionPayloadV3 in the proposed block, and fires newPayloadV5 so the EL imports its own payload
  • On received blocks: validate_payload_with_el calls newPayloadV5 before STF; failures abort import

bin/ethlambda/

New CLI flags:

--execution-endpoint            <URL>      # e.g. http://127.0.0.1:8551
--execution-jwt-secret          <PATH>     # 32-byte hex secret file
--execution-genesis-block-hash  <0x...>    # EL genesis hash; seeds the lean anchor body

--execution-genesis-block-hash is required when pairing with an EL because the lean anchor state must agree on the EL's genesis. The flag:

  1. Stamps genesis_state.latest_execution_payload_header.block_hash = el_hash
  2. Builds a genesis BlockBody with execution_payload.block_hash = el_hash
  3. Computes state.hash_tree_root() with latest_block_header.state_root zeroed, then stamps it on both the state header and the genesis block — required by anchor_pair_is_consistent's block.state_root == state_root check
  4. Hands the consistent anchor pair to Store::get_forkchoice_store

Without the flag, integration is disabled (unchanged behavior).

Docs

  • docs/plans/engine-api-integration.md — full plan including M1–M7
  • docs/plans/lean-execution-payload-schema.md — leanSpec proposal draft for the BlockBody/State schema (Phase 7 artifact, to upstream)

Correctness / Behavior Guarantees

  • Opt-in. Without --execution-endpoint + --execution-jwt-secret, BlockChain receives None, the EL hooks short-circuit, and the genesis seed code path is bypassed. Existing nodes are bitwise-unaffected.
  • Consensus never deadlocks on the EL. All EL calls in the slot loop are tokio::spawn-ed off-tick; the actor never awaits them. RPC failures become warn! log entries — never panics, never tick stalls.
  • JWT spec compliance. Token carries only iat (per execution-apis auth spec); EL accepts ±60s skew; HS256 with a 32-byte secret; fresh token per request.
  • Anchor consistency. anchor_pair_is_consistent requires block.state_root == state.hash_tree_root() with latest_block_header.state_root zeroed. The genesis-seed path computes and stamps the canonical root before calling get_forkchoice_store.
  • STF rejects payloads whose parent_hash or timestamp don't match the prior state, with typed InvalidPayloadParentHash / InvalidPayloadTimestamp errors surfaced through process_block.

Tests Added / Run

Unit

  • cargo test -p ethlambda-ethrex-client --lib — JWT, types, client builder, transport error
  • cargo test -p ethlambda-state-transition — 4 new tests for process_execution_payload
  • cargo test -p ethlambda-blockchain --libbuild_block_embeds_provided_execution_payload

Wire smoke (cargo test -p ethlambda-ethrex-client --test wire_smoke)

  • forkchoice_updated_v3_round_trip — asserts the request body shape (camelCase, JWT, method name) and parses canned SYNCING response
  • rpc_error_surfaces_typed — JSON-RPC error envelope surfaces as typed error
  • Note: these TcpListener::bind tests fail under a sandbox with PermissionDenied; they pass in a normal shell.

Spec fixtures

The BlockBody schema change (added execution_payload) breaks the leanSpec fixture set, which predates this schema. Fixtures are gated behind FIXTURES_AWAIT_M6_REGEN: bool = true in forkchoice_spectests.rs, stf_spectests.rs, signature_spectests.rs, plus a type-filter in ssz_spectests.rs. These will be re-enabled once leanSpec lands the equivalent ExecutionPayload field (the proposal draft in docs/plans/lean-execution-payload-schema.md is the upstream artifact for that).

Live end-to-end against real ethrex — chain advancing both sides

Setup: ethrex main (v8.0.0-HEAD-8d83b00) with Shanghai/Cancun/Prague/Osaka @0, chainId=9, --syncmode full, JWT shared with ethlambda. Single ethlambda validator (is-aggregator=true).

ethrex log (per slot):

INFO New fork choice update version=v3 head=0xb923…c7af safe=… finalized=…
INFO Fork choice updated includes payload attributes. Creating a new payload id=…
INFO Requested payload with id=0x…
INFO New fork choice update version=v3 head=0x7a5a…eccf safe=0xb923… finalized=0xb923…
INFO Received new payload block_hash=0x7a5a…eccf block_number=1
INFO Executing payload block_hash=0x7a5a…eccf block_number=1
INFO Block with hash 0x7a5a…eccf executed and added to storage succesfully

…then block_number=2 ... 3 ... 4 ... 5 ... 6 ... 7 ... 8 ... 9 ..., each FCU's head matches the previous slot's minted payload.

ethlambda log (per slot):

INFO We are the proposer for this slot slot=N validator_id=0
INFO Processed new block slot=N block_root=0x… (attestation_count=…)
INFO Fork choice head updated head_slot=N head_root=… justified_slot=… finalized_slot=…
INFO Published block to gossipsub slot=N parent_root=… attestation_count=…

Fork Choice Tree:
  Finalized: slot N-2 | root …
  Justified: slot N-1 | root …
  Head:      slot N   | root …

Finalization advances slot-by-slot on both sides. Round-trip confirmed: ethrex's FCU head= matches ethlambda's block.body.execution_payload.block_hash, ethlambda's next-slot FCU advances to ethrex's newly-minted block hash.

Reproduction

Pre-flight:

  • /tmp/jwt.hex exists — openssl rand -hex 32 > /tmp/jwt.hex (same file read by both processes)
  • /tmp/ethrex-genesis.json exists with Shanghai/Cancun/Prague/Osaka @0 and chainId: 9
  • ethrex on PATH is main HEAD (V5 support) — not the v8.0.0-tagged release
  • cargo build --release in this repo

Start T1 first, wait for Starting Auth-RPC server at 127.0.0.1:8551, then T2.

T1 — ethrex

rm -rf /tmp/ethrex-data && \
ethrex --network /tmp/ethrex-genesis.json \
       --datadir /tmp/ethrex-data \
       --authrpc.addr 127.0.0.1 --authrpc.port 8551 \
       --authrpc.jwtsecret /tmp/jwt.hex \
       --http.addr 127.0.0.1 --http.port 8545 \
       --p2p.disabled \
       --syncmode full \
       --log.level info

T2 — ethlambda paired

rm -rf /tmp/ethlambda-data && \
GENESIS=/tmp/claude-501/lean-quickstart-fresh/local-devnet/genesis && \
./target/release/ethlambda \
    --genesis           $GENESIS/config.yaml \
    --validators        $GENESIS/annotated_validators.yaml \
    --bootnodes         $GENESIS/nodes.yaml \
    --validator-config  $GENESIS/validator-config.yaml \
    --hash-sig-keys-dir $GENESIS/hash-sig-keys \
    --node-key          $GENESIS/ethlambda_0.key \
    --node-id           ethlambda_0 \
    --is-aggregator \
    --data-dir          /tmp/ethlambda-data \
    --execution-endpoint           http://127.0.0.1:8551 \
    --execution-jwt-secret         /tmp/jwt.hex \
    --execution-genesis-block-hash 0xb923444291dd386fd99c90376a7660b31879ef73b212c8c92ba85147185fc7af

The --execution-genesis-block-hash value is the Genesis Block Hash ethrex prints on startup — read it from T1's first few log lines if you regenerate the EL genesis.

Related Issues / PRs

  • Reference upstream: ethrex Engine API, execution-apis spec
  • leanSpec proposal: docs/plans/lean-execution-payload-schema.md — to be upstreamed; unlocks spec-fixture regeneration which lifts the FIXTURES_AWAIT_M6_REGEN gates
  • Cross-client: lean-quickstart needs a paired-EL genesis bundle (jwt.hex per node + EL genesis JSON) for multi-client testing

✅ Verification Checklist

  • make fmt — clean
  • make lint (clippy -D warnings) — clean
  • cargo test --workspace --release — workspace green (spec fixtures intentionally gated; see above)
  • Live ethrex pairing — multiple slots, chain advances on both sides, finalization confirmed

Add ethlambda-ethrex-client crate speaking JWT HS256-authenticated
JSON-RPC to the EL auth endpoint, with typed V3 wrappers for the four
engine_* methods we use (exchangeCapabilities, forkchoiceUpdatedV3,
newPayloadV3, getPayloadV3) and field-for-field schema match against
the canonical execution-apis spec.

The blockchain actor takes an optional EngineClient and fires
engine_forkchoiceUpdatedV3 at interval 0 of every slot, fire-and-forget;
errors are logged but never block consensus. Integration is opt-in via
--execution-endpoint + --execution-jwt-secret flags (clap enforces
both-or-neither).

Verified end-to-end against real ethrex: capability handshake returns
the 18 advertised methods, FCUs round-trip in sub-ms with SYNCING
(expected -- Lean blocks do not carry an executionPayload yet; that
schema change is deferred to an upstream leanSpec proposal, see
docs/plans/engine-api-integration.md).

Tests: 12 unit + 2 wire smoke tests covering JWT signing, V3 type
serde, RPC error surfacing, and full request/response against a
hand-rolled mock HTTP server.
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: Well-structured PR implementing Engine API scaffolding. Correctly keeps EL integration off the consensus critical path. Minor serialization bugs in wire types need fixing.

Critical Issues

  1. Incorrect PayloadId serialization (crates/net/ethrex-client/src/types.rs:94-102)
    The #[serde(transparent)] attribute on PayloadId serializes the inner [u8; 8] as a JSON array of numbers, but the Engine API spec requires a 16-character hex string (e.g., "0x1234..."). This will cause deserialization failures when the EL returns a payload ID.

    • Fix: Remove #[serde(transparent)] and implement custom Serialize/Deserialize using to_hex() and a hex parser.
  2. Missing hex encoding for fixed-size arrays (crates/net/ethrex-client/src/types.rs)
    PayloadAttributesV3.suggested_fee_recipient: [u8; 20] and Withdrawal.address: [u8; 20] will serialize as JSON arrays [0,1,2...] instead of hex strings. The spec requires DATA (hex) encoding for addresses.

    • Fix: Add a hex_fixed_array serde helper (or use serde_with::hex::Hex if that dependency is acceptable) for these fields, similar to the existing hex_bytes for Vec<u8>.

Security & Safety

  1. JWT Secret File Permissions (crates/net/ethrex-client/src/auth.rs:76-80)
    JwtSecret::from_file reads the secret without checking file permissions. Ethereum clients conventionally require JWT secret files to have restrictive permissions (e.g., 0o600) to prevent unauthorized reads.
    • Suggestion: Add a permissions check (e.g., std::fs::metadata) and warn/error if the file is world-readable.

**Code Quality


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. High: Engine API address fields are serialized with the wrong JSON shape. In types.rs:35, types.rs:48, and types.rs:102, suggested_fee_recipient, Withdrawal.address, and fee_recipient are plain [u8; 20]. serde_json will emit those as byte arrays, not 0x... hex DATA strings, so engine_forkchoiceUpdatedV3(..., Some(payload_attributes)) and engine_newPayloadV3() are wire-incompatible with a spec-compliant EL.

  2. Medium: Two valid Engine API responses cannot currently be deserialized. types.rs:57 models PayloadId as a transparent [u8; 8], but the wire format is a hex string like "0x0123...". types.rs:69 uses rename_all = "UPPERCASE" for PayloadStatusKind, which maps InvalidBlockHash to INVALIDBLOCKHASH instead of INVALID_BLOCK_HASH. A non-null payloadId or an INVALID_BLOCK_HASH response will surface as DeserializeResponse instead of a typed result.

  3. Medium: hex_u256::deserialize can panic on malformed EL input. In types.rs:194, the code decodes arbitrary-length hex and then unconditionally copy_from_slices into a [u8; 32]. Anything longer than 32 bytes will panic and can take down the node. This should return a serde error, not crash the process.

  4. Medium: The live FCU scaffold is sending beacon roots as execution block hashes. In lib.rs:231 through lib.rs:238, head(), safe_target(), and latest_finalized().root are passed straight into ForkChoiceState, but those are beacon roots, not EL block hashes. Until there is a real payload/header mapping, this should use an explicit dummy value (likely zeroes, which the comments/examples already describe) or keep FCU disabled; otherwise behavior depends on EL-specific handling of invalid hashes.

Open Questions / Assumptions

  • I assumed the intent is still “wire scaffold only” until execution payloads exist. If so, Item 4 looks unintentional rather than a design change.
  • The capability handshake result is logged but not acted on in main.rs:601. If “other EL” support matters, I’d expect unsupported V3 methods to disable per-slot FCU instead of warning forever.
  • I couldn’t run cargo test here because the sandbox blocks dependency fetches and writable Cargo cache setup.

Outside the new EL client and tick hook, I didn’t see direct changes to LMD GHOST / 3SF-mini, attestation validation, justification/finalization, XMSS, or SSZ paths.


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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 13, 2026

Greptile Summary

This PR adds a new ethlambda-ethrex-client crate implementing JWT-authenticated Engine API connectivity (V3/Cancun subset) and wires it into the BlockChain actor as a fire-and-forget engine_forkchoiceUpdatedV3 call at every slot boundary.

  • New ethrex-client crate: JwtSecret (HS256 mint, hex/file loaders), EngineClient (reqwest + JSON-RPC envelope), typed V3 wire types, and a hand-rolled TCP mock integration test.
  • BlockChain actor hookup: optional EngineClient threaded in at startup; FCU fired via tokio::spawn at interval 0, never awaited, errors warn-logged.
  • CLI flags: --execution-endpoint / --execution-jwt-secret with clap mutual-require; both absent means integration disabled with zero behavior change for existing nodes.

Confidence Score: 3/5

Safe for current FCU-only usage; two type definition bugs in types.rs will break wire format when payload building or INVALID_BLOCK_HASH responses are encountered

The JWT auth, transport, and blockchain-actor wiring are solid. However types.rs has two defects that matter as soon as deferred milestones are picked up: PayloadStatusKind would produce a deserialization error on any INVALID_BLOCK_HASH EL response, and the [u8; 20] address fields emit integer arrays instead of hex strings, guaranteed to be rejected by the EL when payload building is attempted.

crates/net/ethrex-client/src/types.rs needs the PayloadStatusKind rename attribute corrected and hex serializers added to the three [u8; 20] address fields before payload flow is wired

Important Files Changed

Filename Overview
crates/net/ethrex-client/src/types.rs New Engine API V3 wire types; two bugs: PayloadStatusKind uses UPPERCASE rename (should be SCREAMING_SNAKE_CASE) and [u8; 20] address fields lack hex serde
crates/net/ethrex-client/src/auth.rs JWT HS256 auth: correct iat-only claims, 32-byte length enforcement, hex parsing with/without 0x prefix
crates/net/ethrex-client/src/client.rs EngineClient wraps reqwest with JWT bearer auth, JSON-RPC envelope, and typed method wrappers
crates/blockchain/src/lib.rs Threads optional EngineClient into BlockChainServer; fires FCU via tokio::spawn fire-and-forget at interval 0
bin/ethlambda/src/main.rs Adds CLI flags with clap mutual-require; build_execution_client degrades gracefully but has a misleading comment
docs/plans/engine-api-integration.md Detailed integration plan; References section contains absolute local filesystem paths
crates/net/ethrex-client/tests/wire_smoke.rs End-to-end integration test using a hand-rolled TCP mock

Sequence Diagram

sequenceDiagram
    participant CLI as bin/ethlambda
    participant EC as EngineClient
    participant EL as ethrex Engine API

    Note over CLI: startup
    CLI->>EC: new(endpoint, JwtSecret)
    CLI->>EC: exchange_capabilities
    EC->>EL: POST engine_exchangeCapabilities
    EL-->>EC: capability list
    EC-->>CLI: Ok or warn and continue

    Note over CLI: BlockChain spawned with optional client

    loop every slot at interval 0
        CLI->>EC: notify_execution_layer via tokio::spawn
        EC->>EC: sign fresh JWT
        EC->>EL: POST engine_forkchoiceUpdatedV3
        EL-->>EC: SYNCING scaffold response
        EC-->>CLI: logged, never blocks consensus
    end
Loading

Comments Outside Diff (1)

  1. bin/ethlambda/src/main.rs, line 321-325 (link)

    P2 The warning message "will keep retrying on each tick" implies engine_exchangeCapabilities is called on every slot. In practice only engine_forkchoiceUpdatedV3 runs on each tick; the capability handshake is one-shot at startup. The comment should describe what actually happens, e.g. "EL will still receive FCU calls; capability list is unknown".

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: bin/ethlambda/src/main.rs
    Line: 321-325
    
    Comment:
    The warning message "will keep retrying on each tick" implies `engine_exchangeCapabilities` is called on every slot. In practice only `engine_forkchoiceUpdatedV3` runs on each tick; the capability handshake is one-shot at startup. The comment should describe what actually happens, e.g. "EL will still receive FCU calls; capability list is unknown".
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
crates/net/ethrex-client/src/types.rs:68-76
`UPPERCASE` rename collapses multi-word variant names without inserting underscores, so `InvalidBlockHash` serializes/deserializes as `"INVALIDBLOCKHASH"` rather than the Engine API spec value `"INVALID_BLOCK_HASH"`. Any EL response carrying that status will fail to deserialize, surfacing as an `EngineClientError::DeserializeResponse` instead of the correct typed variant. Using `SCREAMING_SNAKE_CASE` fixes all five variants simultaneously.

```suggestion
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PayloadStatusKind {
    Valid,
    Invalid,
    Syncing,
    Accepted,
    InvalidBlockHash,
}
```

### Issue 2 of 4
crates/net/ethrex-client/src/types.rs:35
`[u8; 20]` with no custom serializer is emitted by `serde_json` as a JSON integer array (`[0,1,2,...]`), not the `"0x..."` DATA string the Engine API spec requires. The same issue affects `fee_recipient` in `ExecutionPayloadV3` (line 102) and `address` in `Withdrawal` (line 48). Both `engine_newPayloadV3` and any FCU call that passes `PayloadAttributesV3` will produce a malformed request body that the EL will reject.

### Issue 3 of 4
docs/plans/engine-api-integration.md:168-170
The References section embeds absolute paths from the author's local machine (`/Users/pablodeymonnaz/Lambda/ethrex/...`). These should be replaced with GitHub URLs so other contributors can follow them. The same issue appears in the "Starting state" section (line 33).

### Issue 4 of 4
bin/ethlambda/src/main.rs:321-325
The warning message "will keep retrying on each tick" implies `engine_exchangeCapabilities` is called on every slot. In practice only `engine_forkchoiceUpdatedV3` runs on each tick; the capability handshake is one-shot at startup. The comment should describe what actually happens, e.g. "EL will still receive FCU calls; capability list is unknown".

Reviews (1): Last reviewed commit: "feat: integrate with ethrex over Engine ..." | Re-trigger Greptile

Comment on lines +68 to +76
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum PayloadStatusKind {
Valid,
Invalid,
Syncing,
Accepted,
InvalidBlockHash,
}
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.

P1 UPPERCASE rename collapses multi-word variant names without inserting underscores, so InvalidBlockHash serializes/deserializes as "INVALIDBLOCKHASH" rather than the Engine API spec value "INVALID_BLOCK_HASH". Any EL response carrying that status will fail to deserialize, surfacing as an EngineClientError::DeserializeResponse instead of the correct typed variant. Using SCREAMING_SNAKE_CASE fixes all five variants simultaneously.

Suggested change
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum PayloadStatusKind {
Valid,
Invalid,
Syncing,
Accepted,
InvalidBlockHash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PayloadStatusKind {
Valid,
Invalid,
Syncing,
Accepted,
InvalidBlockHash,
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/ethrex-client/src/types.rs
Line: 68-76

Comment:
`UPPERCASE` rename collapses multi-word variant names without inserting underscores, so `InvalidBlockHash` serializes/deserializes as `"INVALIDBLOCKHASH"` rather than the Engine API spec value `"INVALID_BLOCK_HASH"`. Any EL response carrying that status will fail to deserialize, surfacing as an `EngineClientError::DeserializeResponse` instead of the correct typed variant. Using `SCREAMING_SNAKE_CASE` fixes all five variants simultaneously.

```suggestion
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PayloadStatusKind {
    Valid,
    Invalid,
    Syncing,
    Accepted,
    InvalidBlockHash,
}
```

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

#[serde(with = "hex_u64")]
pub timestamp: u64,
pub prev_randao: H256,
pub suggested_fee_recipient: [u8; 20],
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.

P1 [u8; 20] with no custom serializer is emitted by serde_json as a JSON integer array ([0,1,2,...]), not the "0x..." DATA string the Engine API spec requires. The same issue affects fee_recipient in ExecutionPayloadV3 (line 102) and address in Withdrawal (line 48). Both engine_newPayloadV3 and any FCU call that passes PayloadAttributesV3 will produce a malformed request body that the EL will reject.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/net/ethrex-client/src/types.rs
Line: 35

Comment:
`[u8; 20]` with no custom serializer is emitted by `serde_json` as a JSON integer array (`[0,1,2,...]`), not the `"0x..."` DATA string the Engine API spec requires. The same issue affects `fee_recipient` in `ExecutionPayloadV3` (line 102) and `address` in `Withdrawal` (line 48). Both `engine_newPayloadV3` and any FCU call that passes `PayloadAttributesV3` will produce a malformed request body that the EL will reject.

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

Comment thread docs/plans/engine-api-integration.md Outdated
Comment on lines +168 to +170
- ethrex Engine API: `/Users/pablodeymonnaz/Lambda/ethrex/crates/networking/rpc/engine/`
- ethrex auth client (template): `/Users/pablodeymonnaz/Lambda/ethrex/crates/networking/rpc/clients/auth/mod.rs`
- ethrex JWT auth: `/Users/pablodeymonnaz/Lambda/ethrex/crates/networking/rpc/authentication.rs`
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.

P2 The References section embeds absolute paths from the author's local machine (/Users/pablodeymonnaz/Lambda/ethrex/...). These should be replaced with GitHub URLs so other contributors can follow them. The same issue appears in the "Starting state" section (line 33).

Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/plans/engine-api-integration.md
Line: 168-170

Comment:
The References section embeds absolute paths from the author's local machine (`/Users/pablodeymonnaz/Lambda/ethrex/...`). These should be replaced with GitHub URLs so other contributors can follow them. The same issue appears in the "Starting state" section (line 33).

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 thorough view of all the critical files. Let me compile the review.


Review: feat: integrate with ethrex over the Engine API

Overview

This PR adds the Engine API plumbing between ethlambda and an execution layer (ethrex). It is deliberately scoped to Option B ("scaffold") — the JWT/transport/tick-wiring layer — while deferring actual payload flow until leanSpec defines ExecutionPayload. That scope is appropriate and the architecture is sound. A few correctness issues in the type layer need addressing before this merges.


Correctness Issues

1. PayloadStatusKind::InvalidBlockHash will not round-trip correctly

types.rs, PayloadStatusKind:

#[serde(rename_all = "UPPERCASE")]
pub enum PayloadStatusKind {
    ...
    InvalidBlockHash,
}

serde(rename_all = "UPPERCASE") converts each variant by uppercasing the entire name, so InvalidBlockHash"INVALIDBLOCKHASH". The execution-apis spec requires "INVALID_BLOCK_HASH". Any response from the EL with that status will fail deserialization silently (falling through as an UnknownVariant error). Fix:

#[serde(rename = "INVALID_BLOCK_HASH")]
InvalidBlockHash,

2. [u8; 20] address fields serialize as integer arrays, not hex strings

types.rs, PayloadAttributesV3, ExecutionPayloadV3, and Withdrawal:

pub suggested_fee_recipient: [u8; 20],   // PayloadAttributesV3
pub fee_recipient: [u8; 20],              // ExecutionPayloadV3
pub address: [u8; 20],                    // Withdrawal

serde_json serializes [u8; N] as [0, 0, 0, ...], not "0x000...000". The Engine API spec requires DATA encoding (hex with 0x prefix). This is dormant in M4 because forkchoice_updated_v3 is only called with payload_attributes: None and ExecutionPayloadV3 is not yet wired — but the types are part of the crate's public API and they will silently produce malformed JSON the moment they're exercised. The bug should be fixed now alongside the type definitions, not left for M6.

The fix is a newtype or a custom hex_address serde module (mirroring hex_u64/hex_bytes) applied via #[serde(with = "hex_address")].


Minor Issues

3. Misleading log message in build_execution_client

main.rs:

Err(err) => warn!(
    %err,
    "EL capability handshake failed (will keep retrying on each tick)"
),

exchange_capabilities is called exactly once, at startup. The per-tick call is forkchoice_updated_v3, not the capability handshake. The parenthetical is inaccurate and will confuse operators debugging a failed handshake. Suggest: "EL capability handshake failed; FCU calls will still be attempted each slot".

4. No HTTP status check before body parsing in rpc_call

client.rs:

let raw = self
    .http
    .post(&self.url)
    ...
    .send()
    .await?
    .text()
    .await?;

If the EL returns a 401 (wrong JWT), 403, or 500, the body may not be valid JSON-RPC. The error will surface as EngineClientError::DeserializeResponse with no indication of the HTTP status code, making debugging harder. Adding .error_for_status() before .text() would give a cleaner error path for authentication/server failures.

5. Fixed id: 1 in JSON-RPC requests

client.rs, JsonRpcRequest:

id: 1,

Fine for fire-and-forget sequential calls, but if concurrent requests are ever issued (possible if two rapid tick intervals fire before the first completes given the 8s timeout), request/response correlation by id breaks. Not a bug today given the tokio::spawn-based fire-and-forget model, but worth noting for future parallelism.

6. JwtSecret stores bytes as Vec<u8>

auth.rs:

pub struct JwtSecret {
    bytes: Vec<u8>,
}

Since from_bytes enforces exactly 32 bytes, the type could be [u8; 32], which eliminates the heap allocation, makes Clone cheaper (stack copy vs. heap clone), and opens the door to deriving Copy. The WrongLength variant would still exist but would only be reachable from from_bytes.


Positive Observations

  • Fire-and-forget via tokio::spawn is exactly right. Consensus ticks are never awaited on EL state.
  • Fresh JWT per request with sign_now() is spec-correct; the sign(iat) / sign_now() split makes deterministic testing clean.
  • Graceful degradation: startup failures return None and log at error!/warn! rather than crashing the node. Correct.
  • ForkChoiceState comment explicitly calls out that beacon roots are used as EL block hashes in M4 scaffold mode. Helps future reviewers.
  • Wire smoke test (tests/wire_smoke.rs) uses a hand-rolled TcpListener rather than a mock crate — no extra dependencies and the assertions cover both the JWT header and the JSON-RPC body shape.
  • Both-or-neither CLI enforcement via #[arg(requires = ...)] is clean and avoids partial configuration.
  • hex_u256 serializer correctly handles the all-zeros case ("0x0") and strips leading zero nibbles per the QUANTITY spec.
  • ethrex-client as a separate crate (not folded into crates/net/rpc) is the right call given the orthogonal dependency footprint.

Summary

The transport and auth foundations are solid. Two correctness issues need fixing before M6 exercises the types:

  • Item 1 (InvalidBlockHash rename) is a compile-silent deserialization bug that will manifest in any network that sends that status.
  • Item 2 ([u8; 20] address serialization) is a wire-format bug dormant in M4 but guaranteed to surface in M6; fixing it now is cheaper than discovering it mid-integration.

Items 3–6 are non-blocking improvements.


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

Comment thread crates/blockchain/src/lib.rs Outdated
Comment on lines +234 to +237
let state = ForkChoiceState {
head_block_hash: head,
safe_block_hash: safe,
finalized_block_hash: finalized,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is wrong

pablodeymo added 18 commits May 14, 2026 18:41
…d FCU

- types.rs: PayloadStatusKind now uses SCREAMING_SNAKE_CASE so
  `InvalidBlockHash` round-trips as `INVALID_BLOCK_HASH` (was
  `INVALIDBLOCKHASH`, which would silently fail to deserialize from any
  spec-compliant EL).
- types.rs: PayloadId serializes/deserializes as a hex DATA string
  (`"0x..."`) instead of `[serde(transparent)]` over `[u8; 8]` (which
  emitted a JSON integer array).
- types.rs: Added `hex_address` serde helper and applied it to
  `PayloadAttributesV3.suggested_fee_recipient`,
  `Withdrawal.address`, and `ExecutionPayloadV3.fee_recipient` —
  previously these `[u8; 20]` fields were emitted as integer arrays
  rather than the spec-required hex DATA strings.
- types.rs: `hex_u256::deserialize` now returns a serde error on >32-byte
  input rather than panicking via `copy_from_slice`.
- client.rs: HTTP responses now run through `.error_for_status()` before
  body parsing so 401/403/5xx surface as `EngineClientError::Transport`
  with a readable message instead of `DeserializeResponse`.
- blockchain/lib.rs: `notify_execution_layer` now sends `H256::ZERO` for
  head/safe/finalized instead of beacon roots. Beacon roots are not EL
  block hashes; passing them confuses the EL into syncing to garbage.
  Zero is the spec-friendly "unknown head" sentinel until Lean blocks
  carry an executionPayload.
- bin/ethlambda/main.rs: Fixed misleading warn log — the capability
  handshake is one-shot at startup, not retried on each tick.
- docs/plans/engine-api-integration.md: Replaced absolute local
  filesystem paths with GitHub URLs.

Added unit tests for each bug fix (6 new tests, 16 total in
ethrex-client lib). All targeted tests pass, `cargo fmt --all -- --check`
clean, `cargo clippy --workspace --all-targets -- -D warnings` clean.
  Phase 1a of the M6 plan (docs/plans/engine-api-integration.md, also
  updated in this commit). The canonical block-component types —
  ExecutionPayloadV3, Withdrawal, HexBytes, and the hex_* serde helpers —
  move from the engine-client crate into the foundational types crate,
  where the Lean BlockBody can later embed them directly.

  ethlambda-ethrex-client's public API stays stable through re-exports.
  No SSZ derives yet; those land in Phase 2 alongside the BlockBody embed.
  Phase 2a of the M6 plan. Adds SszEncode/SszDecode/HashTreeRoot derives
  so the canonical execution-payload types can later be embedded in
  BlockBody and State (Phase 2c).

  Variable-length list fields move to bounded SSZ types — the spec
  requires limits at compile time for merkle tree layout:
    extra_data:   Vec<u8>          → ByteList<MAX_EXTRA_DATA_BYTES>
    transactions: Vec<HexBytes>    → SszList<ByteList<MAX_BYTES_PER_TX>, MAX_TXS>
    withdrawals:  Vec<Withdrawal>  → SszList<Withdrawal, 16>

  Fixed-size byte fields move to plain arrays:
    logs_bloom: Vec<u8> → [u8; 256]

  JSON wire format is preserved byte-for-byte through new helper modules
  (byte_list_hex, hex_bytes_fixed, transactions_serde, withdrawals_serde).
  HexBytes is removed; its role is subsumed by ByteList<N> plus the new
  transactions serde wrapper.

  Manual Default impl on ExecutionPayloadV3: stdlib only auto-derives
  Default for arrays up to length 32, and logs_bloom is 256 bytes.

  Verified: 32 ethlambda-types tests pass (new SSZ + JSON roundtrips
  check hash_tree_root consistency across both encodings); 12
  ethrex-client lib tests pass; fmt clean; clippy clean.
  Phase 2b of the M6 plan. Adds the cached projection that the consensus
  state will carry between blocks (Capella+Deneb spec): every fixed-size
  field copies from the payload verbatim, and the two variable-length
  lists (transactions, withdrawals) collapse to their SSZ hash-tree roots
  so the header itself stays bounded.

    ExecutionPayloadV3::to_header() — explicit method
    From<&ExecutionPayloadV3> for ExecutionPayloadHeader — sugar
    Default — manual (same [u8; 256] reason as ExecutionPayloadV3)

  Genesis convention: ExecutionPayloadHeader::default() is all-zeros.
  The first Lean block carrying a real payload will assert its
  parent_hash matches state.latest_execution_payload_header.block_hash —
  which is H256::ZERO at genesis. Subsequent blocks chain forward
  normally.

  35 ethlambda-types tests pass (3 new: header default, header
  SSZ+JSON roundtrip, to_header projects transactions/withdrawals to
  their hash tree roots and copies every other field verbatim).
…reak)

  Phase 2c of the M6 plan. Adds the canonical execution-payload fields
  to the two SSZ containers that block import and STF revolve around:

    BlockBody { attestations,
                execution_payload: ExecutionPayloadV3 }

    State   { ...,
              latest_execution_payload_header: ExecutionPayloadHeader }

  State::from_genesis seeds the header all-zero so the first non-genesis
  blocks payload must have parent_hash = H256::ZERO to be accepted —
  clean genesis convention without pinning a real EL block hash.

  This is the schema-breaking commit in the M6 sequence. Hash tree roots
  for BlockBody, Block, and State all change. Consequences:

    * Pinned genesis state_root + block_root unit test in
      crates/common/types/src/genesis.rs updated to the new values.

    * Every fixture-driven spec test that exercises these containers is
      gated behind a FIXTURES_AWAIT_M6_REGEN: bool = true flag at the
      top of its fn run(). To re-enable a group, flip the flag and
      make leanSpec/fixtures after upstream lands the schema. Groups
      gated wholesale: forkchoice_spectests (84 cases), stf_spectests
      (49 cases), signature_spectests (11 cases). The ssz_spectests
      dispatch skips only the BlockBody / Block / State / SignedBlock
      arms (127 unrelated cases keep running).

    * All other workspace tests pass; ethlambda-types lib tests still
      cover ExecutionPayloadV3 / Header SSZ + JSON roundtrips.

  Trade-off taken (vs. cargo feature flag, see plan doc Phase 7): the
  fixture skips are explicit and tracked in code, but a real cargo
  feature would have inflated every BlockBody / State construction with
  cfg pollution. The feature-flag alternative was rejected in favor of
  the localized skip.

  Phase 2d will wire process_execution_payload into the STF —
  parent_hash and timestamp assertions per the Capella spec.
  Phase 2d of the M6 plan, closing out Phase 2. The STF now enforces the
  Capella-style payload assertions Pablo flagged on PR #367:

    1. payload.parent_hash == state.latest_execution_payload_header.block_hash
    2. payload.timestamp   == compute_time_at_slot(slot)

  Both checks run inside process_block between header processing and
  attestation processing — same ordering as the spec. On success the
  new payloads header is cached onto state so the next block can chain
  forward. New error variants InvalidPayloadParentHash and
  InvalidPayloadTimestamp.

  Omitted vs. the spec, by design:

    * verify_and_notify_new_payload (engine_newPayloadV3 roundtrip):
      lives in the blockchain actor (Phase 3). The STF runs in-process,
      fork-choice testing, and spec-test harness contexts — none want a
      network call.

    * prev_randao: Lean state has no randao mix and leanSpec hasnt
      defined one. Re-add when upstream lands the field.

    * SECONDS_PER_SLOT is a duplicate const that must track
      ethlambda_blockchain::MILLISECONDS_PER_SLOT (currently 4000).
      state_transition cant depend on blockchain (wrong direction) and
      the millisecond resolution is wasted in STF. Documented in the
      consts doc comment.

  To keep non-EL proposers minting valid blocks until Phase 4 wires
  engine_getPayloadV3, build_block now calls a synthetic_payload
  helper that fills in (parent_hash, timestamp) deterministically from
  state. Phase 4 will swap this for the real EL response when an
  endpoint is configured. The 20 existing blockchain lib tests
  (including the two build_block tests) continue to pass.

  4 new state_transition unit tests cover the happy path, parent-hash
  mismatch, timestamp mismatch, and a two-block chain-forward case.
…oadV3

  Phase 3 of the M6 plan. Every block arriving over the network — gossip
  or BlocksByRoot req-resp — now passes through the EL before fork-choice
  insertion. The Handler\<NewBlock\> in BlockChainServer awaits
  engine_newPayloadV3(payload, \[\], H256::ZERO) and drops the block on
  explicit INVALID / INVALID_BLOCK_HASH verdicts; VALID, SYNCING, and
  ACCEPTED all proceed to the existing on_block sync path. Transport
  failures are permissive — same policy as notify_execution_layer:
  warn-and-accept so EL flakes cant gridlock consensus.

  Design notes:

    * The EL call lives in the actors async handler, not in
      store::on_block. Keeping the store layer sync preserves the
      on_block_without_verification seam that fork-choice spec tests
      rely on, and avoids fanning async fn across the whole import
      pipeline. The validate_payload_with_el helper is private to the
      actor.

    * Own-built blocks (proposer path at line 453) bypass the pre-check
      intentionally — they were either built from a real
      engine_getPayloadV3 response (Phase 4, future) or via the
      synthetic_payload helper, neither of which the EL needs to
      re-validate.

    * Pending children inherit validation: every block enters via
      Handler<NewBlock>, so anything in pending_blocks already
      passed the EL once. The cascade processing at line 610 stays
      sync.

    * The V3 calls last two params — expected_blob_versioned_hashes
      and parent_beacon_block_root — are stubbed to vec![] and
      H256::ZERO. Lean blocks dont carry blob transactions or a
      beacon-root analogue yet; refine when those land.

  Phase 4 next will replace synthetic_payload in build_block with the
  real engine_getPayloadV3 response when an EL endpoint is configured.
  Phase 4 of the M6 plan. Closes the proposer loop end-to-end when an EL
  endpoint is configured:

    interval 4 of slot N-1:
      request_payload_id_for_next_slot(N-1) — if any of our validators
      will propose at slot N, fire engine_forkchoiceUpdatedV3 with
      PayloadAttributesV3 (correct slot-timestamp). Stash the returned
      payload_id.

    interval 0 of slot N:
      take_prepared_payload(N) — pop the stashed id, call
      engine_getPayloadV3, parse executionPayload, hand to
      produce_block_with_signatures. build_block embeds it directly into
      BlockBody.execution_payload; STFs process_execution_payload from
      Phase 2d then enforces parent_hash + timestamp.

  Fallback paths (any of which trigger the Phase 2d synthetic_payload):

    * no EL configured
    * we didnt queue a build (interval-4 path skipped)
    * EL was syncing at interval 4 (payload_id = None on the FCU response)
    * stashed slot doesnt match the proposal slot (we skipped a tick)
    * engine_getPayloadV3 transport / parse failure

  API touches:

    * EngineClient::get_payload_v3 now returns ExecutionPayloadV3 directly
      (extracts executionPayload from the envelope; drops blobsBundle
      and blockValue for now).
    * produce_block_with_signatures and the private build_block take an
      Option<ExecutionPayloadV3>. None → synthesize.
    * propose_block is now async (was sync) and accepts the optional
      payload. The two on_tick call sites adjust accordingly.
    * New BlockChainServer field pending_payload_id: Option<(u64, PayloadId)>.

  What still wont work end-to-end until M6 is fully complete:

    * notify_execution_layer still sends H256::ZERO for head/safe/finalized
      (Phase 5). Until that goes, the EL has no idea what we consider the
      canonical head and may stay in SYNCING.
    * suggested_fee_recipient and prev_randao are hardcoded zero. A real
      devnet needs CLI flags / RANDAO accumulation.
    * Lean blocks still dont propagate blob transactions or a meaningful
      parent_beacon_block_root.

  These are the next phase-5/6/7 items in docs/plans/engine-api-integration.md.

  All workspace tests pass; wire_smoke is sandbox-only.
… draft

  Phase 7 of the M6 plan, closing out the in-repo work:

    * build_block_embeds_provided_execution_payload — unit test in
      crates/blockchain/src/store.rs. Confirms that when produce_block_with_signatures
      is handed an engine_getPayloadV3-style ExecutionPayloadV3, build_block
      embeds it verbatim (block_hash + full hash_tree_root match) instead of
      falling back to synthetic_payload. The other M6-related units are
      already covered: process_execution_payloads parent/timestamp guards in
      state_transition (Phase 2d) and the ExecutionPayloadV3/Header SSZ + JSON
      roundtrips in ethlambda-types (Phases 2a/2b).

    * docs/plans/lean-execution-payload-schema.md — draft of the leanSpec
      issue proposing the schema upstream. Frames the missing-EL-payload
      problem, the canonical-V3 mirror choice, the genesis convention, and
      enumerates ethlambdas reference commits as proof of feasibility.
      File this verbatim on leanSpec when ready.

    * docs/plans/engine-api-integration.md — Phase 7 section now reflects
      what landed vs whats deferred. The two EL-mocked tests
      (on_block_rejects_when_el_says_invalid /
      notify_execution_layer_sends_real_hashes_after_first_block) sit behind
      an EngineClient-trait-abstraction refactor that isnt worth blocking
      on for this PR; tracked as follow-up in the same section.

  21 blockchain lib tests pass (was 20). fmt + clippy clean.
…datedV3

  Phase 5 of the M6 plan, retiring the H256::ZERO placeholder that
  MegaRedHand flagged on PR #367 (\"This is wrong\") and that started
  this whole expansion.

  notify_execution_layer now resolves each of the head, safe, and
  finalized Lean roots to its blocks body.execution_payload.block_hash
  via a small el_hash_at helper. At genesis the helper naturally rolls
  back to H256::ZERO (because BlockBody::default() carries
  ExecutionPayloadV3::default()), so the existing "first FCU is all
  zeros" startup behavior is preserved — no extra slot-0 special case
  needed. From the first non-genesis block onward the EL receives the
  hash it actually minted via engine_getPayloadV3 (Phase 4), so it can
  chain its own fork choice forward off blocks its already seen via
  engine_newPayloadV3 (Phase 3) instead of chasing zeros indefinitely.

  el_hash_at defensively falls back to H256::ZERO when a root is
  missing from storage. That shouldnt fire for head/safe/finalized
  (which are always present), but a torn write or pruning bug shouldnt
  crash the EL notifier; warn-on-failure semantics are preserved further
  down by the spawned forkchoice_updated_v3 call.

  Doc comment on notify_execution_layer updated; the in-body comment
  before the call site no longer claims "we send all-zero hashes".

  All workspace tests pass; wire_smoke is sandbox-only.
  `crates/common/types/src/genesis.rs::GenesisValidatorEntry` was already
  on the dual-pubkey schema (`attestation_pubkey` / `proposal_pubkey`),
  but the validators-file parser in `bin/ethlambda/src/main.rs` was still
  on the older single-pubkey, role-by-filename layout. A node booting
  from a current `lean-quickstart` bundle would crash on
  `missing field \`pubkey_hex\`` before reaching the consensus stack —
  this commit aligns the two.

  New `AnnotatedValidator` carries both pubkeys and both privkey filenames
  on the same entry, matching the on-disk format the genesis generator
  emits today. `read_validator_keys` loses the role-by-filename indirection
  (classify_role + RoleSlots) since each entry is now self-describing —
  one entry per validator, both files explicit.

  No behavior change for the success path on the new format. The single-
  pubkey/by-filename path is dropped entirely; no client is producing
  that shape anymore. Net: +22 / -71.
…lock-hash

  New CLI flag `--execution-genesis-block-hash` takes the ELs genesis
  block hash (32-byte hex, `0x`-prefixed or bare) and stores it in
  `state.latest_execution_payload_header.block_hash` at genesis-state
  construction.

  Without this seed the first `engine_forkchoiceUpdatedV3` carries an
  all-zero head, ethrex replies `SYNCING`, the build-mode FCU at interval
  4 returns `payload_id = None`, and the chain never bootstraps a real EL
  payload. With the seed, the very first FCU references the ELs actual
  genesis block, ethrex accepts, and the get-payload / new-payload loop
  starts producing real execution payloads.

  The flag requires `--execution-endpoint` (clap enforces) and is parsed
  through the new `parse_h256_hex` helper which rejects wrong-length input.
  `State::from_genesis` is untouched — the seed happens in
  `fetch_initial_state` right after construction, so the dozen other
  test/internal call sites of `from_genesis` dont change.

  Operators find the value in the EL boot log
  (`Genesis Block Hash: 0xb923...` for ethrex).
  After `propose_block` successfully runs STF on a freshly-built block,
  fire `engine_newPayloadV3` to the EL so it imports the payload as a
  real block in its chain. Without this call the EL knows the payload
  only as a candidate from `engine_getPayloadV3`, so subsequent FCU
  `head_block_hash` lookups against that hash bounce as SYNCING and
  the chain doesnt advance on the EL side.

  For received blocks the same call already happens in `Handler<NewBlock>`s
  EL pre-check (Phase 3 of M6); this commit closes the parallel gap for
  own-built blocks, which never re-enter the gossip-handler path.

  Fire-and-forget via `tokio::spawn` (~ms roundtrip, next FCU is 4s
  away). INVALID/error verdicts are logged but dont reverse the local
  `process_block` — by design, mirroring `notify_execution_layer`s
  "consensus must keep running regardless of EL state" stance.
  Three intertwined changes that together unblock real `engine_newPayload`
  acceptance on ethrex and end the "FCU always carries ZERO head" loop:

    1. **V4 newPayload.** Add `engine_newPayloadV4` to ethrex-client and
       switch both call sites (Phase 3 receive-side import check; Phase 5
       follow-up own-built notify) from V3 to V4. V4 takes the same
       `ExecutionPayloadV3` shape plus an `executionRequests` parameter
       (EIP-7685 system contracts — empty for Lean blocks). ethrex rejects
       V3 with `-38005 Unsupported fork: Prague` once the payload timestamp
       crosses `pragueTime`, which our devnet genesis sets at 0. The
       capability advertisement is updated to include V4.

    2. **Genesis BLOCK body seed.** The previous commit seeded
       `state.latest_execution_payload_header.block_hash` (which drives
       STFs parent_hash check) but `el_hash_at` — Phase 5s FCU
       `head_block_hash` source — reads
       `block.body.execution_payload.block_hash`, and the genesis blocks
       body was synthesized as `BlockBody::default()` (all zero) regardless
       of any state seeding. `fetch_initial_state` now also builds an
       explicit genesis BlockBody whose `execution_payload.block_hash`
       equals the seed, updates the headers `body_root`, and uses
       `Store::get_forkchoice_store` (which persists the body) instead of
       `from_anchor_state` (which assumed the empty body). With both seeds
       in place, the very first FCU at interval 0 of slot 0 carries the
       real EL genesis hash.

    3. **Build-mode FCU uses real hashes.**
       `request_payload_id_for_next_slot` (Phase 4 — interval-4 FCU+attrs
       that asks the EL to start building) was hardcoding the
       `ForkChoiceState` triplet to ZERO, so even with everything else
       correct the EL would never recognize our head and return
       `payload_id = None`. Factored both that path and
       `notify_execution_layer` onto a shared
       `current_el_forkchoice_state()` helper.

  After these three: at slot 0 interval 4 ethlambda fires
  FCU+attrs(head=EL genesis hash); ethrex accepts, returns a payload_id;
  at slot 1 interval 0 ethlambda fetches via `engine_getPayloadV3`,
  Three intertwined fixes that finally close the loop: ethrex now
  receives real, non-zero execution payloads from ethlambda every slot
  and reports the new head back via FCU, slot-by-slot.

    1. **Anchor consistency at genesis seed.** `Store::get_forkchoice_store`s
       `anchor_pair_is_consistent` requires `block.state_root` to equal
       `state.hash_tree_root()` (with header.state_root zeroed) exactly —
       `ZERO` is not accepted. `fetch_initial_state` now zero-passes
       `latest_block_header.state_root`, computes the canonical state root,
       stamps it on both the state header and the genesis block before
       calling get_forkchoice_store.

    2. **Build-mode FCU carries real EL hashes.** `request_payload_id_for_next_slot`
       (interval-4 FCU+attrs that asks the EL to start building) was
       hardcoding head/safe/finalized to ZERO. Refactored both this path
       and `notify_execution_layer` onto a shared
       `current_el_forkchoice_state()` helper that reads via `el_hash_at`,
       so the EL sees the same head whether the call is heartbeat or
       build-mode.

    3. **V4 + V5 newPayload / getPayload.** Added typed wrappers for both
       on `EngineClient`; advertised in `ETHLAMBDA_ENGINE_CAPABILITIES`.
       The actor calls V5 because ethrex on main implements V5 for
       Amsterdam-era (EIP-7928 BAL) and rejects V4 with
       `-38005 Unsupported fork` once `timestamp >= amsterdamTime`.
       The genesis JSON must activate Amsterdam at 0 for V5 to apply.

  Confirmed end-to-end against ethrex main: per-slot FCU carries
  0xb923…c7af (ethrexs genesis), interval-4 FCU+attrs returns a real
  `payload_id`, getPayloadV5 returns a payload ethrex minted, newPayloadV5
  on the proposed block lands cleanly, next slots FCU advances to the
  new ethrex-minted block_hash. Real EL chain advancing in lockstep.
ilitteri added a commit to lambdaclass/ethereum-package that referenced this pull request May 20, 2026
the standard `participants:` block, and remove the parallel
`lean_participants:` schema entirely. The new shape collapses Lean and
EL+CL into one input surface:

  participants:
    - el_type: ethrex
      cl_type: ethlambda
      is_aggregator: true
    - el_type: none
      cl_type: ream

ethlambda is the only Lean client that implements Engine API today
(lambdaclass/ethlambda#367); when paired with an EL (`el_type` != none)
the Lean launcher reads the EL's genesis block hash via
`eth_getBlockByNumber 0x0` after the EL is up, stages the network JWT
into the ethlambda container, and adds the three Engine API flags
(`--execution-endpoint`, `--execution-jwt-secret`,
`--execution-genesis-block-hash`) to its CLI. The other seven Lean
clients run client-only — `el_type: none` skips EL launch entirely
(the package already supports this for `consensoor` etc.).

Cl_TYPE additions: ethlambda, ream, zeam, qlean, lantern, gean,
lean_grandine, lean_lighthouse. The last two are prefixed because
`grandine` and `lighthouse` already exist in CL_TYPE for the Eth1 CLs
of the same name (different binaries from different repos).
`LEAN_CL_TYPES` is the set the cl_launcher dispatcher checks to
decide whether to skip a participant (Lean cl_types are launched by
src/lean/lean_launcher.star, not the standard CL launchers); main.star
then builds a Lean record per such participant and hands the list to
the Lean launcher with the network jwt_file attached.

Other plumbing changes that fall out of this:

- `is_aggregator` is now a first-class per-participant field. Ignored
  on non-Lean cl_types.
- The "first participant cannot have el_type=none without bootnodoor"
  guard is relaxed when every participant has a Lean cl_type — Lean
  uses its own libp2p QUIC mesh and doesn't need an Eth1 bootnode.
- The Fulu/PeerDAS validation skips Lean cl_types (they don't speak
  PeerDAS).
- The VC / remote-signer / snooper / metrics-exporter pipeline is
  skipped for Lean cl_types in participant_network.star — Lean
  validators live inside the consensus binary, not a separate VC.
- shared_utils.get_client_names is None-safe: when cl_context is None
  (Lean participants), it falls back to the cl_type string from the
  participant config so downstream consumers (validator-ranges, dora,
  etc.) still get a usable row name.

`lean_network_params:` stays as a separate config block for Lean-only
knobs (`active_epoch`, `attestation_committee_count`,
`num_validator_keys_per_node`, `metrics_enabled`, ...).
`parse_lean_participants` and `DEFAULT_LEAN_IMAGES` are deleted; the
DEFAULT_CL_IMAGES table now carries the Lean defaults too.

Args files migrated:
- `.github/tests/lean-devnet4.yaml` — every entry moved to
  `participants:` with `el_type: none`.
- `.github/tests/lean-smoke.yaml` — same shape, two ethlambda nodes.
- `.github/tests/ethlambda-el-pair.yaml` — new, single ethrex+ethlambda
  pair.
- `.github/tests/ethlambda-el-pair-2node.yaml` — new, two pairs with
  one aggregator + one non-aggregator on the Lean side.

Validated locally: the 2-node ethrex+ethlambda pair finalizes
slot-by-slot, both ethrex ELs converge on identical block hashes via
the Lean libp2p mesh between the two ethlambdas.
…oadV5

  d0c5b72 switched the build path (engine_getPayload + the FCU-then-build chain)
  to V5 but missed the import-validation path (`validate_payload_with_el`) and
  the post-propose self-notification path. Both kept calling new_payload_v4
  while every surrounding log line, comment, and the commit message itself
  claimed V5.

  This worked against the demos ethrex genesis only because that genesis
  activates forks through Osaka @0 with no `amsterdamTime` set — without an
  Amsterdam timestamp the EL doesnt gate V4 yet. The moment a paired EL
  activates Amsterdam, ethrex would have returned `-38005 Unsupported fork:
  Osaka/Amsterdam` on those two paths and our import-validation would
  silently flip to the permissive "accepting block" branch.

    - validate_payload_with_el now calls new_payload_v5; surrounding doc
      comment rewritten to describe Amsterdam-era V5 (BAL on the payload,
      same JSON-RPC param shape as V4).
    - The own-built fire-and-forget notification after propose_block now
      calls new_payload_v5; surrounding comment updated to reference
      engine_getPayloadV5 (the version that minted the candidate) and
      engine_newPayloadV5 (the version that promotes it).
    - Module docstring and ETHLAMBDA_ENGINE_CAPABILITIES doc-comment in
      ethrex-client/src/lib.rs both now reflect V3/V4/V5 across newPayload
      and getPayload, with the version-selection rule (timestamp against
      the EL fork schedule) made explicit.
    - cargo fmt picks up three .await line breaks in get_payload_v3/v4/v5
      that d0c5b72 introduced but didnt reformat.

  Caught by an automated three-agent code review; two of the agents flagged
  the V4 calls independently as a functional drift between the code and the
  commit message.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants