Skip to content

feat: embed ethrex as the beacon node's execution client (include-ethrex) - #730

Draft
MegaRedHand wants to merge 5 commits into
mainfrom
feat/include-ethrex
Draft

MegaRedHand wants to merge 5 commits into
mainfrom
feat/include-ethrex

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

With the include-ethrex feature, ethlambda beacon without --execution-endpoint starts ethrex in-process and validates payloads through an in-process Engine API transport: every newPayload, forkchoiceUpdated and getPayload is a direct call into ethrex's own handler, with no socket, no JWT and no JSON request body. An explicit endpoint still selects an external client over HTTP, so one image serves both setups.

Ported from nightly to main. The branch was first built on nightly, and that build (4424b51) is what ran on mainnet (see Tests). Moving it onto main changed two things beyond the commits themselves:

  • Cargo.lock: every version main locks is kept, and the 367 crates the ethrex dependencies add are at the versions the nightly build ran, except clipboard-win (Windows-only), moved from 5.4.1 (since yanked; 5.4.2 and 5.4.3 are younger than the cooldown) to 5.1.0 so make cooldown-check passes.
  • The ethrex CI job pins its actions like the rest of ci.yml (ci: pin GitHub action versions #713), so gh actions-lock --verify passes.

What Changed

Area Change
Cargo.toml, crates/net/p2p ethrex bumped v25 to v29.0.1 as a single workspace dependency. Discovery adapts to v29's DiscoveryServer (a shared local identity, an execution fork id channel started at None); DiscoveryConfig gains nat_extip_set, tied to --discovery.advertise-ip so IP voting cannot override an address the operator chose
crates/net/engine EngineClient keeps one public API over a private Transport enum (Http / InProcess), so the chain actor and Beacon API handlers do not change. in_process.rs builds each ethrex handler struct and calls RpcHandler::handle
crates/execution/ethrex (new ethlambda-ethrex) start is ethrex's init_l1 without its RPC servers; --ethrex.* flag subset with ETHREX_* env for the rest; opt-in execution JSON-RPC server over the shared context; shutdown after the chain actor joins
bin/ethlambda/src/{cli,main}.rs feature-gated wiring: choose embedded vs external client, port clash checks against the beacon node's own ports
Makefile, .github/workflows/ci.yml make test-ethrex + CI job ethrex (kept out of test-node for the runner's disk ceiling); extra clippy pass over the binary's feature-gated code
docs/ethrex.md, CLAUDE.md flags, lifecycle, how a call works, what is left out

Correctness / Behavior Guarantees

Build --execution-endpoint given Execution client
default no none: blocks import without payload validation
default yes the external client at that endpoint
include-ethrex no embedded ethrex
include-ethrex yes the external client at that endpoint; ethrex does not start
  • The ethrex bump is workspace-wide, so the default build's discv5 moves to v29 too. The feature does not touch ethlambda node (lean), but the dependency does.
  • ethrex keeps most ExecutionPayload fields crate-private, so a payload goes through from_block with the consensus layer's claimed block hash put back: ethrex checks that claim rather than a recomputation. A payload no ethrex block can carry (undecodable transaction, base fee past u64) goes to the handler's own JSON parse, so ethrex gives the verdict it gives over the wire.
  • Errors keep their wire codes (-38005, -38006, ...). No retry ladder in-process (every error is an answer), but the per-method timeouts stay, since the chain actor awaits newPayload inline.
  • start repeats init_l1's steps, so when the ethrex pin moves, init_l1, init_rpc_api and the binary's server_shutdown must be re-diffed against crates/execution/ethrex/src/lib.rs (also in the crate docs).
  • Known: embedded ethrex restarts up to ~34 blocks below its pre-SIGINT head (state for the newest blocks is not persisted on shutdown) and re-executes them within ~30 s. Not a resync.

Tests Added / Run

  • crates/execution/ethrex/tests/engine_round_trip.rs: Engine API round trips against an embedded ethrex, on Osaka and Amsterdam genesis fixtures. Run with make test-ethrex.
  • Live (on the nightly build): mainnet follower on eth-5, binary built from 4424b51, one process for beacon + ethrex, from 2026-10-24 17:20Z to 2026-10-26 21:14Z. A fresh checkpoint sync on 2026-10-26 (anchor 15516640, ethrex DB kept, ~5500 blocks behind) went optimistic until ethrex caught up, then sync_distance 0 and is_optimistic: false ~14 min after the restart, head matching publicnode.

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test (test-consensus plus test-node, at release-fast) — all passing

The embedded execution client (next commits) needs ethrex v29.0.1, the
release the servers run. Moving discovery to the same tag keeps a single
copy of ethrex in the graph, and putting the pin in the workspace
manifest makes the shared version explicit.

v25 was only the latest release when discovery landed. Against v29 the
discovery server takes two new arguments: a shared local identity, which
we hand over and never read back, and an execution fork id channel, which
a consensus record has no use for, so it starts at None with its sender
dropped. `DiscoveryConfig` gains `nat_extip_set`, tied to
`--discovery.advertise-ip` so IP voting cannot override an address the
operator chose.
Behind a new `include-ethrex` feature, `EngineClient::in_process` drives
an ethrex running in this process through its own Engine API handlers
instead of JSON-RPC over HTTP. `EngineClient` keeps one public API over a
private `Transport` enum, so the chain actor and the Beacon API handlers
that hold a client do not change.

Requests are typed: each method builds the ethrex handler struct it
names and calls `RpcHandler::handle`. ethrex keeps most of its
`ExecutionPayload` fields crate-private, so a payload goes through
`from_block` with the consensus layer's claimed block hash put back,
which keeps ethrex checking that claim rather than a recomputation. A
payload no ethrex block can carry (an undecodable transaction, a base fee
past u64) is malformed by definition, and is handed to the handler's own
JSON `parse` so ethrex gives the verdict it gives over the wire.

Answers are the JSON values ethrex would send, decoded by the types the
HTTP transport already uses; errors keep their wire codes. There is no
retry ladder, since every in-process error is an answer, but the
per-method timeouts stay: the chain actor awaits `newPayload` inline.
…rex)

With the `include-ethrex` feature, `ethlambda beacon` without
`--execution-endpoint` starts ethrex in-process and validates payloads
through the in-process Engine API transport. An explicit endpoint still
selects an external client over HTTP, so one image serves both setups.

The new `ethlambda-ethrex` crate owns the embedded node:

- `start` is ethrex's `init_l1` without its RPC servers. `init_l1`
  builds its RPC context inside `bind_api` and never returns it, so the
  steps are repeated and the context is assembled from the same parts.
  The crate docs say to re-diff them whenever the ethrex pin moves.
- `--ethrex.*` flags are a subset of ethrex's own. Everything else keeps
  the standalone binary's defaults and `ETHREX_*` overrides. The data
  directory and the network are derived from the beacon node's.
- An opt-in execution JSON-RPC server (`--ethrex.http-port`) answers
  `eth_*`/`net_*`/`web3_*` over the shared context, since ethrex's own
  server would build a second one with its own sync manager.
- Shutdown runs after the chain actor joins, so no Engine API call is in
  flight while ethrex flushes its store. An ethrex subsystem that cancels
  the node takes the beacon node down too, with an error.

ethrex's ports are checked against ours before anything binds. Its tests
run in their own make target and CI job (`test-ethrex`), because ethrex's
RPC and node crates would push the node half of the suite past the
runner's disk ceiling. An extra clippy pass covers the binary's
feature-gated code.

The lockfile is held to the 14-day publish-age cooldown for the crates
the new dependencies pulled in.
Seen on the first live run: ethrex's metrics server repeats every lean_*
series and --metrics-port carries a few ethrex_* ones, since both gather
the process-wide default registry. The prefixes keep them from colliding,
but the page claimed the two were kept apart.
The job was written before #713 locked every workflow's actions to exact
versions, so it still named `checkout@v6` and `rust-cache@v2`, neither of
which `actions.lock` lists. It now uses the pinned versions and the
free-disk path the other jobs use.
@MegaRedHand
MegaRedHand changed the base branch from nightly to main October 29, 2026 20:49

This branch has not been deployed

No deployments
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.

1 participant