Skip to content

Commit 0312cd6

Browse files
committed
fix(ethrex-engine): build payloads on the parent consensus expects
A 3-node devnet with the embedded EL never finalized: 22 `parent_hash mismatch` errors, peer imports halved, no aggregation coverage. A consensus-only control run on the same image finalized normally, isolating the fault to the integration. The state transition requires payload.parent_hash == state.latest_execution_payload_header.block_hash which is the parent the consensus chain expects. build_payload instead derived it from store.get_latest_canonical_block_hash(), the node's *own* EL head. Each node runs an independent in-memory execution layer, so those two drift apart, and a proposer's payload then named a parent no peer agreed with — every node's STF rejected the block. It failed silently from the proposer's side: no EL rejection, no warning, the block just never stuck. The Engine-API path did not have this bug, because its build-mode forkchoiceUpdated pointed the EL at el_hash_at(store.head()) before building. Collapsing that two-step into a single call dropped the step that chose the parent. build_payload now takes parent_el_hash explicitly and re-points the EL at that block before building; el_integration passes el_hash_at(head_root). safe and finalized are left unset in that fork-choice call, since pinning them would forbid a later build on an earlier block. Regression test builds_on_the_requested_parent_not_the_el_head advances the EL two blocks, then builds on block 1 and asserts the payload names that parent; it fails against the previous code. Unit tests could not have caught this — it needs more than one execution layer to appear. Verified: 3 nodes, 36 slots, finalized at slot 40 with all nodes on the same finalized root and 43 payloads executed each; zero parent_hash mismatches, zero synthetic fallbacks, zero panics. Matches the consensus-only control (slot 41).
1 parent 134dcb4 commit 0312cd6

5 files changed

Lines changed: 293 additions & 13 deletions

File tree

crates/blockchain/src/el_integration.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,15 @@ impl BlockChainServer {
8585
let engine = self.execution_engine.as_ref()?;
8686
let head_root = self.store.head().unwrap_or_default();
8787
let genesis_time = self.store.config().genesis_time;
88+
// Build on the EL block the *consensus* chain expects to be extended —
89+
// the head block's own payload hash — not whatever this node's EL happens
90+
// to have as its head. The state transition checks the new payload's
91+
// `parent_hash` against `state.latest_execution_payload_header.block_hash`,
92+
// so a drifted EL head yields a block every peer rejects.
93+
let parent_el_hash = self.el_hash_at(head_root);
8894
engine
8995
.build_payload(
96+
parent_el_hash,
9097
compute_time_at_slot(genesis_time, slot),
9198
// Zero until Lean defines a RANDAO mix.
9299
H256::ZERO,

crates/net/ethrex-engine/src/lib.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,27 +112,42 @@ impl EthrexEngine {
112112
Ok(self.store.get_latest_block_number().await?)
113113
}
114114

115-
/// Build the execution payload for a block being proposed on top of the
116-
/// current canonical head.
115+
/// Build the execution payload for a block being proposed on top of
116+
/// `parent_el_hash`.
117117
///
118118
/// One call: ethrex creates the payload skeleton and fills it synchronously,
119119
/// so unlike the Engine API there is no id to hold onto and no second fetch.
120120
///
121+
/// `parent_el_hash` **must** be the EL block hash the consensus chain expects
122+
/// to be extended — the `execution_payload.block_hash` of the Lean block
123+
/// being built on. It is passed in rather than read from this engine's own
124+
/// canonical head because the two can differ: every node runs its own
125+
/// execution layer, and an EL head that has drifted from the consensus chain
126+
/// would produce a payload whose `parent_hash` fails the state transition's
127+
/// check against `state.latest_execution_payload_header.block_hash` — which
128+
/// makes every peer reject the block.
129+
///
121130
/// `beacon_root` follows the lean-parent-root convention — it is the
122131
/// proposed block's `parent_root`, and must be the same value later passed
123132
/// to [`Self::execute_payload`], or the EL's block-hash check fails.
124133
pub async fn build_payload(
125134
&self,
135+
parent_el_hash: LeanH256,
126136
timestamp: u64,
127137
prev_randao: LeanH256,
128138
beacon_root: LeanH256,
129139
fee_recipient: [u8; 20],
130140
) -> Result<ExecutionPayloadV3, EngineError> {
131-
let parent = self
132-
.store
133-
.get_latest_canonical_block_hash()
134-
.await?
135-
.ok_or(EngineError::NoCanonicalHead)?;
141+
let parent = H256(parent_el_hash.0);
142+
// Make the EL treat that block as its head before building on it, so the
143+
// payload is produced against the state the consensus chain expects.
144+
// safe/finalized are left unset (ethrex reads zero as "not provided"):
145+
// pinning them here would forbid a later build on an earlier block.
146+
apply_fork_choice(&self.store, parent, H256::zero(), H256::zero())
147+
.await
148+
.map_err(|err| {
149+
EngineError::Conversion(format!("cannot build on parent {parent:#x}: {err}"))
150+
})?;
136151
let args = BuildPayloadArgs {
137152
parent,
138153
timestamp,

crates/net/ethrex-engine/tests/roundtrip.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async fn builds_executes_and_advances_head() {
2828

2929
let payload = engine
3030
.build_payload(
31+
genesis_hash,
3132
genesis_timestamp + 12,
3233
LeanH256::ZERO,
3334
genesis_hash,
@@ -68,6 +69,7 @@ async fn rejects_payload_with_mismatched_beacon_root() {
6869

6970
let payload = engine
7071
.build_payload(
72+
genesis_hash,
7173
genesis_timestamp + 12,
7274
LeanH256::ZERO,
7375
genesis_hash,
@@ -82,3 +84,59 @@ async fn rejects_payload_with_mismatched_beacon_root() {
8284
"a payload replayed under a different beacon root must not be accepted"
8385
);
8486
}
87+
88+
/// The payload must be built on the parent the caller names, not on whatever
89+
/// this engine's own canonical head happens to be.
90+
///
91+
/// Every node runs its own execution layer, so a proposer's EL head can drift
92+
/// from the consensus chain. The state transition checks a new payload's
93+
/// `parent_hash` against `state.latest_execution_payload_header.block_hash`, so
94+
/// building on the wrong parent produces a block every peer rejects — which
95+
/// stalls finality without any error surfacing locally.
96+
#[tokio::test]
97+
async fn builds_on_the_requested_parent_not_the_el_head() {
98+
let (engine, genesis_timestamp) = engine().await;
99+
let genesis_hash = engine.head_hash().await.unwrap();
100+
101+
// Advance the EL two blocks, so its head is no longer genesis.
102+
let mut parent = genesis_hash;
103+
let mut block_1 = LeanH256::ZERO;
104+
for i in 1..=2u64 {
105+
let payload = engine
106+
.build_payload(
107+
parent,
108+
genesis_timestamp + 12 * i,
109+
LeanH256::ZERO,
110+
parent,
111+
[0u8; 20],
112+
)
113+
.await
114+
.expect("build payload");
115+
engine.execute_payload(&payload, parent).expect("execute");
116+
parent = payload.block_hash;
117+
if i == 1 {
118+
block_1 = parent;
119+
}
120+
engine.set_head(parent, parent, genesis_hash).await.unwrap();
121+
}
122+
assert_eq!(engine.head_number().await.unwrap(), 2, "EL head advanced");
123+
124+
// Now ask for a payload extending block 1 (NOT the EL head at block 2), the
125+
// way a proposer would after a reorg or when its EL ran ahead.
126+
let payload = engine
127+
.build_payload(
128+
block_1,
129+
genesis_timestamp + 999,
130+
LeanH256::ZERO,
131+
block_1,
132+
[0u8; 20],
133+
)
134+
.await
135+
.expect("build on an explicit non-head parent");
136+
137+
assert_eq!(
138+
payload.parent_hash, block_1,
139+
"payload must name the requested parent, not the EL's own head"
140+
);
141+
assert_eq!(payload.block_number, 2, "extending block 1 yields height 2");
142+
}

docs/plans/scope-down-review.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# Review guide: in-process ethrex, scoped down
2+
3+
What to look at, what to be suspicious of, and what is still unfinished.
4+
5+
**Branch:** `feat/ethrex-inprocess` — one commit (`134dcb4`) off `origin/main` (`b4a8f78`).
6+
**Not pushed yet.** Nothing is force-pushed and PR #530 is untouched pending your call (§7).
7+
8+
---
9+
10+
## 1. What this is
11+
12+
Run the execution layer **in-process**: ethrex linked in as a library, driven by
13+
direct function calls. One binary, no Engine API, no JSON-RPC, no JWT.
14+
15+
Per your scoping call, all out-of-process machinery was removed. That work still
16+
exists as PR #367, so nothing is lost — this branch simply stops overlapping it.
17+
18+
| | Previous PR #530 | This branch |
19+
|---|---|---|
20+
| Commits | 12 (3 merges of main, plus #367 absorbed) | **1**, off current main |
21+
| Files changed | ~80 | **43** |
22+
| Engine-API code | ~790 lines (JWT, JSON-RPC client, wire test) | **0** |
23+
| EL interface | `ExecutionEngine` trait, Engine-API methods, `PayloadId`, payload cache, wire types | **3 direct methods** |
24+
| CLI | `--execution-mode` + 3 external flags | **`--el-genesis`** |
25+
26+
Diff: 43 files, +3444 / −779.
27+
28+
## 2. Suggested reading order
29+
30+
Reviewing in this order means each file makes sense before you reach its callers.
31+
32+
1. `crates/net/ethrex-engine/src/lib.rs`**the whole EL surface**, three methods.
33+
Read this first; everything else is wiring.
34+
2. `crates/net/ethrex-engine/src/conversion.rs` — the payload ⇄ block mapping. The
35+
only genuinely fiddly code; check the field table in the guide against it.
36+
3. `crates/blockchain/src/el_integration.rs` — the four actor hooks and the
37+
never-stall-consensus policy.
38+
4. `crates/blockchain/src/lib.rs` — where those hooks attach to the tick loop
39+
(interval 0 head update, interval 4 build, gossip import).
40+
5. `bin/ethlambda/src/main.rs` — engine construction and the **genesis seeding**
41+
(§4, decision 3).
42+
6. `crates/blockchain/state_transition/src/execution_payload.rs` and the type
43+
changes — the consensus-side schema (§5).
44+
7. Everything else is test literals, docs and tooling.
45+
46+
## 3. The claim most worth challenging
47+
48+
**Some code that arrived via #367 stays, and it is not Engine-API code.**
49+
50+
| Kept | Why it is required in-process |
51+
|---|---|
52+
| `ExecutionPayloadV3` in `BlockBody` | The proposer embeds the payload so **peers execute it in their own embedded EL**. Without it, no peer can replicate execution. |
53+
| `process_execution_payload` (STF) | Validates the payload's parent hash and slot timestamp on import. |
54+
| `latest_execution_payload_header` in `State` / `StateDiff` | Reconstructed states must keep the EL block-hash chain, or the parent-hash check breaks after a diff replay. |
55+
| `State::from_genesis_with_el_hash` | Seeds the consensus genesis with the EL genesis hash. |
56+
57+
If you disagree that these belong here, that is the conversation to have — it is
58+
the one place where "only in-process changes" is a judgement call rather than a
59+
mechanical deletion.
60+
61+
## 4. Decisions to scrutinise
62+
63+
Each is reversible; the cost of reversing is noted.
64+
65+
**1. Direct API instead of the `ExecutionEngine` trait.** (your D1=B)
66+
`build_payload` / `execute_payload` / `set_head`. This deleted `PayloadId`, the
67+
`Mutex<HashMap<[u8;8], _>>` payload cache, and the build-then-fetch two-step —
68+
all artefacts of the Engine API being stateless and networked.
69+
*Reversing:* reintroduce the trait, which #367 already contains.
70+
71+
**2. No fee-recipient configuration.***the one I am least sure about*
72+
#367 read `suggested_fee_recipient` from `validator-config.yaml`; main has no such
73+
plumbing. Rather than re-add config parsing for something the integration does not
74+
need, the EL is handed the zero address with a comment. Lean has no fee market or
75+
block rewards, so nothing is being directed anywhere.
76+
*Reversing:* ~20 lines — a config field, a hex parser, and one more `BlockChainConfig` field.
77+
78+
**3. The EL genesis hash is derived, not configured.**
79+
The engine bootstraps from `--el-genesis`, so its startup head *is* the EL genesis
80+
block; `main.rs` reads it back and seeds the consensus anchor. The external path
81+
needed a flag because the EL was a separate process.
82+
*Why it matters:* forgetting this seed fails **silently** — consensus looks healthy
83+
while the EL sits frozen at genesis and every proposal falls back to a synthetic
84+
payload. Worth confirming you find the derivation trustworthy.
85+
86+
**4. `execute_payload` is synchronous.**
87+
`Blockchain::add_block` is a sync ethrex call, so the gossip-import path no longer
88+
awaits. Simpler, but it does mean EL execution happens on the actor thread.
89+
*Consider:* whether block execution time on the actor is acceptable, or whether it
90+
should move off-thread later.
91+
92+
**5. Single ethrex revision across the workspace.**
93+
`crates/net/p2p` was pinned to an older ethrex for ENR parsing; it now follows the
94+
workspace revision, which required porting `parse_enrs` to v15's typed
95+
`NodeRecord`. This touches a crate unrelated to the feature.
96+
*Why it is not optional:* `ethrex-crypto` bundles a C SHA3 with non-namespaced
97+
symbols, so two ethrex versions multiply-define them under GNU `ld`. macOS `ld64`
98+
tolerates it — it only fails in the Linux release build.
99+
100+
**6. In-memory EL store.** EL state resets on restart. Fine for a PoC; persistence
101+
is an `ethrex-storage` feature away and pairs with EL-aware checkpoint sync.
102+
103+
**7. Mock-EL test seam dropped.** (your D4) No trait means nothing to mock; the
104+
engine tests drive a real embedded ethrex instead.
105+
106+
## 5. Consensus-path changes to check carefully
107+
108+
These touch the tick loop, so they deserve more attention than the rest:
109+
110+
- **Interval 4**`build_execution_payload` runs inline, immediately before the
111+
block is assembled. Failure returns `None` and `build_block` falls back to
112+
`synthetic_payload`.
113+
- **Interval 0**`notify_execution_layer` updates the EL head, spawned
114+
fire-and-forget.
115+
- **Gossip import**`import_gossiped_block` executes the payload *before* the
116+
store sees the block. A rejection drops the block; anything else proceeds.
117+
- **Own block** — after building, we execute our own payload, because nobody
118+
gossips it back to us and the EL head would otherwise never advance.
119+
120+
The invariant throughout: **the execution layer never stalls consensus.** Only an
121+
explicit rejection of a received payload drops a block; every other failure logs
122+
and continues.
123+
124+
## 6. Verification status
125+
126+
| Check | Status |
127+
|---|---|
128+
| `cargo build --workspace` | ✅ clean |
129+
| `cargo clippy --workspace --all-targets -- -D warnings` | ✅ clean |
130+
| `cargo fmt --all --check` | ✅ clean |
131+
| Tests (blockchain, state-transition, engine, bin, p2p) |**299 passed, 0 failed** |
132+
| Engine roundtrip + beacon-root rejection tests | ✅ pass |
133+
| 3-node devnet **with** the embedded EL | ✅ finalized at slot 40 |
134+
| 3-node devnet **without** the EL (control) | ✅ finalized at slot 41 |
135+
136+
The EL-enabled run matches the consensus-only control, so the execution layer
137+
costs nothing in liveness. All three nodes agreed on the same finalized root, and
138+
each executed exactly 43 payloads — lockstep.
139+
140+
### 6.1 Bug found by the devnet and fixed: `parent_hash mismatch`
141+
142+
Worth reading, because it is the one real defect the scope-down introduced and no
143+
unit test could have caught it — it only appears with **multiple independent
144+
execution layers**.
145+
146+
**Symptom.** With the EL enabled the chain never finalized: 22 `parent_hash
147+
mismatch` errors, peer imports halved (13 vs 30), no aggregation coverage, no
148+
finality. Silent from the proposer's side — zero EL rejections, zero warnings.
149+
The block simply did not stick anywhere.
150+
151+
**Cause.** The state transition requires
152+
153+
```
154+
payload.parent_hash == state.latest_execution_payload_header.block_hash
155+
```
156+
157+
— the parent the *consensus chain* expects. `build_payload` instead derived the
158+
parent from `store.get_latest_canonical_block_hash()`, this node's *own* EL head.
159+
With three independent ELs those drift apart, so a proposer's payload named the
160+
wrong parent and every node's STF rejected the block.
161+
162+
#367 did not have this bug: its build-mode `forkchoiceUpdated` pointed the EL at
163+
`el_hash_at(store.head())` before building. Collapsing that two-step into one call
164+
dropped the step that set the parent.
165+
166+
**Fix.** `build_payload` takes `parent_el_hash` explicitly; `el_integration` passes
167+
`el_hash_at(head_root)` — the consensus head's payload hash — and the engine
168+
re-points the EL at that block before building. safe/finalized are deliberately
169+
left unset there: pinning them would forbid a later build on an earlier block.
170+
171+
**Regression test.** `builds_on_the_requested_parent_not_the_el_head` advances the
172+
EL two blocks, then asks for a payload extending block 1 and asserts the payload
173+
names *that* parent. It fails against the old code.
174+
175+
**Method note.** The first hypothesis — that `import_gossiped_block` was dropping
176+
blocks on EL rejection — was wrong, and the logs disproved it (zero rejections)
177+
before any code was changed.
178+
179+
## 7. Open questions for you
180+
181+
1. **Publishing.** Force-push this onto `feat/ethrex-inprocess-poc` (keeps PR #530
182+
and its discussion) or push `feat/ethrex-inprocess` as a new PR and close #530?
183+
Force-push rewrites the remote branch, so it needs your say-so.
184+
2. **Decision 2** — fee-recipient config: leave dropped, or restore it?
185+
3. **Decision 4** — EL execution on the actor thread: acceptable for now?
186+
4. Anything in §3 you think should not be in this PR.
187+
188+
## 8. Known remaining work
189+
190+
- The two published artifacts still describe the trait / two-mode design and need
191+
updating once the code settles.
192+
- `docs/plans/scope-down-to-inprocess.md` (the proposal) and this file can both be
193+
dropped from the PR if you would rather not carry planning docs.
194+
- Prague / `ExecutionPayloadV4` support is out of scope; the EL genesis must be
195+
Cancun.

scripts/inprocess-devnet/run.sh

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ TRACE=false
2929
KEEP=false
3030
BUILD=false
3131
VERIFY=true
32+
NO_EL=false
3233

3334
KEYGEN_IMAGE="blockblaz/hash-sig-cli:latest"
3435
GENESIS_IMAGE="ethpandaops/eth-beacon-genesis:pk910-leanchain"
@@ -57,6 +58,7 @@ while [[ $# -gt 0 ]]; do
5758
--keep) KEEP=true; shift ;;
5859
--build) BUILD=true; shift ;;
5960
--no-verify) VERIFY=false; shift ;;
61+
--no-el) NO_EL=true; shift ;;
6062
-h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
6163
*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;;
6264
esac
@@ -279,7 +281,7 @@ for ((i = 0; i < NODES; i++)); do
279281
--http-address 0.0.0.0 \
280282
--metrics-port "$((8081 + i))" \
281283
--api-port "$((15052 + i))" \
282-
--el-genesis /config/el-genesis.json \
284+
$([[ "$NO_EL" == false ]] && echo "--el-genesis /config/el-genesis.json") \
283285
$([[ $i -eq 0 ]] && echo "--is-aggregator") >/dev/null || die "failed to start $NAME"
284286
ok "$NAME (quic $((9001 + i)), api $((15052 + i)))$([[ $i -eq 0 ]] && echo ' [aggregator]')"
285287
done
@@ -323,8 +325,9 @@ count1() { local n; n=$(grep -c "$1" "$2" 2>/dev/null || true); echo "${n:-0}";
323325
FAIL=0
324326

325327
# 1. the embedded EL came up on every node
326-
EL_UP=$(count "In-process ethrex execution engine enabled")
327-
if [[ "$EL_UP" == "$NODES" ]]; then ok "in-process EL enabled on $EL_UP/$NODES node(s)"
328+
EL_UP=$(count "Embedded ethrex enabled")
329+
if [[ "$NO_EL" == true ]]; then ok "consensus-only control run (no EL expected)"
330+
elif [[ "$EL_UP" == "$NODES" ]]; then ok "in-process EL enabled on $EL_UP/$NODES node(s)"
328331
else warn "in-process EL enabled on $EL_UP/$NODES node(s)"; FAIL=1; fi
329332

330333
# 2. blocks were produced (works with a single node, unlike the import path)
@@ -345,9 +348,11 @@ if [[ -n "$FINAL" ]]; then ok "${FINAL#*Checkpoint finalized }"
345348
else warn "no finalization yet (needs ~30 slots; ran $SLOTS)"; fi
346349

347350
# 5. the EL actually built and executed payloads (trace-level: needs --trace)
348-
if [[ "$TRACE" == true ]]; then
351+
if [[ "$NO_EL" == true ]]; then
352+
warn "consensus-only control run (--no-el): EL checks skipped"
353+
elif [[ "$TRACE" == true ]]; then
349354
BUILT=$(count "Built execution payload")
350-
EXECD=$(( $(count "newPayload on own-built block") + $(count "newPayload ok") ))
355+
EXECD=$(count "EL executed payload")
351356
if (( BUILT > 0 )); then ok "EL payloads built: $BUILT"
352357
else warn "no EL payload builds"; FAIL=1; fi
353358
if (( EXECD > 0 )); then ok "EL payloads submitted for execution: $EXECD"
@@ -357,7 +362,7 @@ else
357362
fi
358363

359364
# 6. red flags
360-
BAD=$(( $(count "falling back to synthetic") + $(count "getPayload failed") + $(count "rejected payload") ))
365+
BAD=$(( $(count "using synthetic payload") + $(count "EL rejected payload") ))
361366
if (( BAD == 0 )); then ok "no synthetic fallbacks / rejected payloads"
362367
else warn "EL failure lines: $BAD"; FAIL=1; fi
363368

0 commit comments

Comments
 (0)