Skip to content

Commit 1ac7b91

Browse files
committed
Let an EL-enabled node survive a restart instead of dropping every block
Block import is gated on execution, so an execution layer that cannot extend the consensus head is not degraded — it is terminal. Every arriving block names a parent it does not have, fails, and is dropped before the store sees it. With the execution store in memory, that was the state of every restarted node: consensus resumed at its old slot, execution rewound to genesis, and the node never followed the chain again. Checkpoint sync made it worse by moving the consensus head further ahead. Two parts. The execution store now persists in RocksDB under --data-dir/el, on the same volume as the consensus store so it survives container recreation rather than just a restart. And el_sync replays, at startup, whatever the execution layer is missing: walk back from the consensus head to the last block it can actually extend, then execute forward. The Lean chain carries every ExecutionPayloadV3 in its block bodies, so it already is a complete execution history — no new wire protocol and no peers needed. Persistence turned out not to be sufficient on its own, which is worth recording: a reopened datadir keeps the block index and canonical head but NOT executable state. So the head is present and unbuildable, and the first attempt at gap detection — "do I have this block?" — answered yes and concluded there was nothing to do. The predicate is now can_build_on: the block is present AND its post-state is reachable, which is the same condition ethrex checks before accepting a fork choice update, and therefore the exact question build_payload will later ask. Measured on a devnet restart: 44 payloads replayed in 8ms. Also fixes a latent corruption risk found while testing this. The value seeded into the consensus genesis anchor was read from head_hash(), which equals the genesis hash only on a fresh store. With execution state persisted, a node whose consensus datadir was wiped would have anchored a newly created genesis state to whatever block its execution layer last executed, producing a chain no peer agrees with — silently. Added genesis_hash() and used that. start_p2p now runs after the resync rather than before. It calls set_synced(), which is what unlocks inbound transaction gossip, and that should not happen while the execution layer is still behind the chain. The devnet harness gains --restart-node N. It stops a node, waits out the gossipsub backoff, starts it, and then asserts what a node on a private fork cannot fake: blocks imported from peers after the restart, and no synthetic-payload fallbacks. The first version of this check asserted that the head slot advanced and that the node was level with its peers — both of which a forked node satisfies exactly, since a fork advances at the same rate. It passed while the node was importing nothing. Head slot is now reported rather than asserted. Per-node checks also count nodes instead of log lines, since a restart logs startup twice and produced totals like "4/3 nodes". Verified: 585 tests pass, three new, fmt and clippy -D warnings clean. On a 3-node devnet, node 2 was stopped at slot 54 and restarted: it replayed 44 payloads, imported 16 blocks from peers, built payloads with no synthetic fallbacks, and reached slot 84 level with its peers. The chain finalized at slot 75 throughout, with cross-node transaction gossip still working.
1 parent fb7e623 commit 1ac7b91

10 files changed

Lines changed: 617 additions & 29 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/ethlambda/src/main.rs

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -207,23 +207,72 @@ async fn main() -> eyre::Result<()> {
207207
let (execution_engine, el_genesis_hash) = match options.el_genesis.as_deref() {
208208
None => (None, None),
209209
Some(path) => {
210-
let engine = EthrexEngine::from_genesis_path(path)
210+
// Execution state lives beside the consensus store, so a restarted
211+
// node keeps the blocks it has already executed. Without this it
212+
// rewinds to genesis while consensus resumes at its old slot, and
213+
// since block import is gated on execution, the node then drops
214+
// every block it receives — permanently.
215+
let el_store_dir = options.data_dir.join("el");
216+
let engine = EthrexEngine::from_genesis_path(path, Some(el_store_dir.as_path()))
211217
.await
212218
.map_err(|err| eyre::eyre!("failed to bootstrap embedded ethrex: {err}"))?;
213-
let hash = engine
214-
.head_hash()
219+
// The *genesis* hash, not the head. They coincide only on a fresh
220+
// store; with execution state persisted, the head after a restart is
221+
// whatever block was last executed, and anchoring a newly created
222+
// consensus genesis to that would silently build a chain no peer
223+
// agrees with.
224+
let genesis_hash = engine
225+
.genesis_hash()
215226
.await
216227
.map_err(|err| eyre::eyre!("failed to read EL genesis block hash: {err}"))?;
217-
info!(genesis = %path.display(), el_genesis_hash = %hash, "Embedded ethrex enabled");
218-
(Some(Arc::new(engine)), Some(hash))
228+
let head = engine
229+
.head_number()
230+
.await
231+
.map_err(|err| eyre::eyre!("failed to read EL head: {err}"))?;
232+
info!(
233+
genesis = %path.display(),
234+
el_genesis_hash = %genesis_hash,
235+
el_head_block = head,
236+
"Embedded ethrex enabled"
237+
);
238+
(Some(Arc::new(engine)), Some(genesis_hash))
219239
}
220240
};
221241

242+
let clean_checkpoint_urls: Vec<String> = options
243+
.checkpoint_sync_url
244+
.into_iter()
245+
.map(|url| url.trim().to_string())
246+
.filter(|url| !url.is_empty())
247+
.collect();
248+
249+
let store = fetch_initial_state(
250+
&clean_checkpoint_urls,
251+
&genesis_config,
252+
backend.clone(),
253+
el_genesis_hash,
254+
)
255+
.await
256+
.inspect_err(|err| error!(%err, "Failed to initialize state"))?;
257+
258+
// Catch the execution layer up to the consensus chain before anything else
259+
// runs. On a fresh genesis this is a no-op; on a restart it replays whatever
260+
// the persistent execution store is missing. It has to happen before the
261+
// blockchain actor starts, because the actor's first tick may propose a block
262+
// and would build on the wrong parent.
263+
if let Some(engine) = execution_engine.as_ref() {
264+
ethlambda_blockchain::el_sync::resync_execution_layer(&store, engine).await;
265+
}
266+
222267
// Join the execution layers into their own transaction-gossip mesh. Without
223268
// it each mempool is isolated, so a submitted transaction waits for the turn
224269
// of the node that received it; with it, whichever node proposes next can
225270
// include it. Independent of consensus gossip in every respect — own key,
226271
// own port, own peer set.
272+
//
273+
// After the resync, deliberately: joining marks the execution layer as
274+
// synced, which is what unlocks inbound transaction gossip, and that should
275+
// not happen while it is still behind the chain.
227276
if let (Some(engine), Some(port)) = (execution_engine.as_ref(), options.el_p2p_port) {
228277
let mut el_p2p = P2PConfig::loopback(derive_el_node_key(&node_p2p_key), port);
229278
el_p2p.bootnodes = options.el_bootnodes.clone();
@@ -236,22 +285,6 @@ async fn main() -> eyre::Result<()> {
236285
}
237286
}
238287

239-
let clean_checkpoint_urls: Vec<String> = options
240-
.checkpoint_sync_url
241-
.into_iter()
242-
.map(|url| url.trim().to_string())
243-
.filter(|url| !url.is_empty())
244-
.collect();
245-
246-
let store = fetch_initial_state(
247-
&clean_checkpoint_urls,
248-
&genesis_config,
249-
backend.clone(),
250-
el_genesis_hash,
251-
)
252-
.await
253-
.inspect_err(|err| error!(%err, "Failed to initialize state"))?;
254-
255288
let validator_ids: Vec<u64> = validator_keys.keys().copied().collect();
256289

257290
// Shared, runtime-mutable aggregator flag. Seeded from the CLI and

crates/blockchain/src/el_sync.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Bring a restarted node's execution layer back in step with the consensus
2+
//! chain, by replaying the payloads it has not executed yet.
3+
//!
4+
//! # Why this exists
5+
//!
6+
//! Block import is gated on execution: `import_gossiped_block` drops any block
7+
//! whose payload the execution layer rejects. So an execution layer that is
8+
//! behind the consensus chain is not a degraded state — it is terminal. Every
9+
//! arriving block names a parent the execution layer does not have, fails with
10+
//! `ParentNotFound`, and is dropped before the store sees it. The node stops
11+
//! following the chain permanently, and no amount of checkpoint syncing helps:
12+
//! that moves the consensus head *further* ahead, widening the gap.
13+
//!
14+
//! Persisting execution state alongside the consensus store removes the common
15+
//! cause (a restart), but not every cause: the two stores are written
16+
//! independently, so a crash between them, an operator wiping one, or a
17+
//! consensus store restored from a checkpoint can all leave a gap.
18+
//!
19+
//! # How
20+
//!
21+
//! The Lean chain already contains every `ExecutionPayloadV3` in its block
22+
//! bodies, so it *is* a complete execution-layer history — no new wire protocol
23+
//! and no peers needed. Walk back from the consensus head until reaching a block
24+
//! the execution layer already has, then replay forward from there.
25+
//!
26+
//! The cost is bounded by the size of the gap, not the length of the chain: a
27+
//! node whose execution state is current walks back exactly one block and
28+
//! replays nothing.
29+
30+
use ethlambda_ethrex_engine::EthrexEngine;
31+
use ethlambda_storage::Store;
32+
use ethlambda_types::{ShortRoot, block::Block};
33+
use tracing::{info, warn};
34+
35+
/// Outcome of a resync attempt, for logging and tests.
36+
#[derive(Debug, Default, PartialEq, Eq)]
37+
pub struct ResyncReport {
38+
/// Payloads executed to close the gap.
39+
pub replayed: usize,
40+
/// Blocks skipped because their payload carries no execution-layer block —
41+
/// the pass-through shape a proposer emits when its own build failed.
42+
pub skipped: usize,
43+
/// True when the walk ran out of consensus blocks before reaching one the
44+
/// execution layer had, so the gap could not be closed.
45+
pub incomplete: bool,
46+
}
47+
48+
/// Replay the payloads between the execution layer's head and the consensus
49+
/// head.
50+
///
51+
/// Returns what it did. Never fails the caller: a node that cannot close the gap
52+
/// should start and say so loudly rather than refuse to boot, since the operator
53+
/// may be deliberately reusing a datadir.
54+
pub async fn resync_execution_layer(store: &Store, engine: &EthrexEngine) -> ResyncReport {
55+
let mut report = ResyncReport::default();
56+
57+
let head_root = match store.head() {
58+
Ok(root) if !root.is_zero() => root,
59+
// No consensus head yet (fresh genesis): nothing to replay.
60+
_ => return report,
61+
};
62+
63+
// Walk back along parent links, collecting blocks the execution layer is
64+
// missing. Stops at the first block it already has, which is the common
65+
// ancestor of the two views.
66+
let mut missing: Vec<Block> = Vec::new();
67+
let mut root = head_root;
68+
loop {
69+
let block = match store.get_block(&root) {
70+
Ok(Some(block)) => block,
71+
Ok(None) | Err(_) => {
72+
// Ran out of history without meeting the execution layer. Only
73+
// reachable when the consensus store does not go back far enough
74+
// — a checkpoint-synced node, or a partially pruned datadir.
75+
report.incomplete = true;
76+
break;
77+
}
78+
};
79+
80+
let payload_hash = block.body.execution_payload.block_hash;
81+
match engine.can_build_on(payload_hash).await {
82+
Ok(true) => break,
83+
Ok(false) => {}
84+
Err(err) => {
85+
warn!(%err, "Could not query the execution layer; skipping resync");
86+
return report;
87+
}
88+
}
89+
90+
let parent_root = block.parent_root;
91+
missing.push(block);
92+
if parent_root.is_zero() {
93+
// Reached the anchor. Its payload is the EL genesis, which the
94+
// engine always has, so this means the chain does not link back to
95+
// the execution layer's genesis at all.
96+
report.incomplete = true;
97+
break;
98+
}
99+
root = parent_root;
100+
}
101+
102+
if missing.is_empty() && !report.incomplete {
103+
// Logged even though there is nothing to do. "No output" is the same
104+
// thing an accidentally-skipped resync produces, and telling those two
105+
// apart from a log file afterwards is worth one line at startup.
106+
info!(
107+
head_slot = store.head_slot(),
108+
"Execution layer is in step with the consensus chain"
109+
);
110+
return report;
111+
}
112+
113+
info!(
114+
blocks = missing.len(),
115+
head_slot = store.head_slot(),
116+
"Execution layer is behind the consensus chain; replaying payloads"
117+
);
118+
119+
// Oldest first: each payload extends the one before it.
120+
for block in missing.iter().rev() {
121+
let payload = &block.body.execution_payload;
122+
123+
// A pass-through payload repeats its parent hash instead of naming a new
124+
// execution-layer block: the proposer's build failed and no block was
125+
// produced. There is nothing to execute, and trying would fail the
126+
// block-hash check.
127+
if payload.block_hash == payload.parent_hash {
128+
report.skipped += 1;
129+
continue;
130+
}
131+
132+
if let Err(err) = engine.execute_payload(payload, block.parent_root) {
133+
// Stop at the first failure rather than pressing on: every later
134+
// payload builds on this one, so they would all fail too and bury
135+
// the real error in noise.
136+
warn!(
137+
slot = block.slot,
138+
block_root = %ShortRoot(&payload.block_hash.0),
139+
%err,
140+
"Replay failed; execution layer left behind the consensus chain"
141+
);
142+
report.incomplete = true;
143+
return report;
144+
}
145+
report.replayed += 1;
146+
}
147+
148+
if report.incomplete {
149+
warn!(
150+
replayed = report.replayed,
151+
"Could not fully resync the execution layer from the consensus chain. \
152+
This node will drop every block it receives. Wipe its data directory \
153+
and restart to rebuild from genesis."
154+
);
155+
} else {
156+
info!(
157+
replayed = report.replayed,
158+
skipped = report.skipped,
159+
"Execution layer resynced with the consensus chain"
160+
);
161+
}
162+
163+
report
164+
}

crates/blockchain/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub mod aggregation;
4040
pub mod block_builder;
4141
pub(crate) mod coverage;
4242
mod el_integration;
43+
pub mod el_sync;
4344
pub mod events;
4445
pub(crate) mod fork_choice_tree;
4546
pub mod key_manager;

crates/net/ethrex-engine/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ rust-version.workspace = true
77

88
[dependencies]
99
ethrex-common.workspace = true
10-
ethrex-storage.workspace = true
10+
ethrex-storage = { workspace = true, features = ["rocksdb"] }
1111
# `c-kzg` gates the blob-transaction mempool API. It already arrives through
1212
# ethrex-p2p's default features, but naming it here keeps `cargo check -p
1313
# ethlambda-ethrex-engine` and any --no-default-features build honest.

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

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,24 +100,54 @@ impl EthrexEngine {
100100
/// The genesis must be **Cancun**: a Prague genesis makes ethrex require a
101101
/// `requests_hash` in the block header that the Cancun-shaped
102102
/// [`ExecutionPayloadV3`] cannot carry, and every payload is then rejected.
103-
pub async fn from_genesis_path(path: impl AsRef<std::path::Path>) -> Result<Self, EngineError> {
103+
///
104+
/// `store_dir` selects where execution state lives. `Some(dir)` persists it
105+
/// in RocksDB, so a restarted node keeps the blocks it has already executed;
106+
/// `None` keeps it in memory, which is what tests want. A persistent store
107+
/// re-reads its own genesis on reopen and rejects a *different* one, so the
108+
/// directory doubles as a genesis fingerprint.
109+
pub async fn from_genesis_path(
110+
path: impl AsRef<std::path::Path>,
111+
store_dir: Option<&std::path::Path>,
112+
) -> Result<Self, EngineError> {
104113
let path = path.as_ref();
105114
let file = std::fs::File::open(path)
106115
.map_err(|err| EngineError::GenesisLoad(format!("open {}: {err}", path.display())))?;
107116
let genesis: Genesis = serde_json::from_reader(std::io::BufReader::new(file))
108117
.map_err(|err| EngineError::GenesisLoad(format!("parse {}: {err}", path.display())))?;
109-
Self::from_genesis(genesis).await
118+
Self::build(genesis, store_dir).await
110119
}
111120

112-
/// Bootstrap an engine with an in-memory store initialised from `genesis`.
121+
/// Bootstrap an engine with an **in-memory** store initialised from
122+
/// `genesis`. Execution state does not survive the process.
113123
pub async fn from_genesis(genesis: Genesis) -> Result<Self, EngineError> {
124+
Self::build(genesis, None).await
125+
}
126+
127+
async fn build(
128+
genesis: Genesis,
129+
store_dir: Option<&std::path::Path>,
130+
) -> Result<Self, EngineError> {
114131
// Load the KZG trusted setup on a background thread. It is compiled in,
115132
// so there is no file to find, but the first blob verification would
116133
// otherwise pay a multi-second initialisation — and the call that
117134
// triggers it could be a payload build at interval 4.
118135
ethrex_crypto::kzg::warm_up_trusted_setup();
119136

120-
let mut store = Store::new("", EngineType::InMemory)?;
137+
let mut store = match store_dir {
138+
Some(dir) => {
139+
std::fs::create_dir_all(dir).map_err(|err| {
140+
EngineError::Store(StoreError::Custom(format!(
141+
"create EL store dir {}: {err}",
142+
dir.display()
143+
)))
144+
})?;
145+
Store::new(dir.to_string_lossy().as_ref(), EngineType::RocksDB)?
146+
}
147+
None => Store::new("", EngineType::InMemory)?,
148+
};
149+
// Idempotent: on an existing datadir this recognises its own genesis and
150+
// returns, and rejects a mismatched one rather than corrupting the chain.
121151
store.add_initial_state(genesis).await?;
122152
let blockchain = Arc::new(Blockchain::default_with_store(store.clone()));
123153
Ok(Self {
@@ -129,6 +159,41 @@ impl EthrexEngine {
129159
})
130160
}
131161

162+
/// Whether the execution layer can extend the block with this hash — it has
163+
/// the block *and* the block's post-state is reachable in the database.
164+
///
165+
/// Both halves matter, and testing only the first is a trap. An unclean
166+
/// shutdown can leave a block's header durable while its state trie is not,
167+
/// and such a block looks present while every attempt to build on it fails
168+
/// with `StateNotReachable`. This is the same condition ethrex checks before
169+
/// accepting a fork-choice update, so it answers exactly the question
170+
/// [`Self::build_payload`] will later ask.
171+
///
172+
/// Used to find where a restarted node's execution state effectively stopped,
173+
/// so only the unusable payloads are replayed.
174+
pub async fn can_build_on(&self, hash: LeanH256) -> Result<bool, EngineError> {
175+
let Some(header) = self.store.get_block_header_by_hash(H256(hash.0))? else {
176+
return Ok(false);
177+
};
178+
Ok(self.store.has_state_root(header.state_root)?)
179+
}
180+
181+
/// Hash of the execution layer's **genesis** block.
182+
///
183+
/// Distinct from [`Self::head_hash`], which is only the same thing on a fresh
184+
/// store. This is what seeds the consensus genesis anchor, so a node with a
185+
/// persistent execution store must not use the head: after a restart that is
186+
/// whatever block it last executed, and anchoring a genesis state to it would
187+
/// silently produce a chain no peer agrees with.
188+
pub async fn genesis_hash(&self) -> Result<LeanH256, EngineError> {
189+
let hash = self
190+
.store
191+
.get_block_header(0)?
192+
.ok_or(EngineError::NoCanonicalHead)?
193+
.hash();
194+
Ok(LeanH256(hash.0))
195+
}
196+
132197
/// Hash of the current canonical head block.
133198
///
134199
/// Immediately after [`Self::from_genesis`] this is the EL genesis block

0 commit comments

Comments
 (0)