You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On Morph mainnet, morph-reth loses a subset of blocks while derivation runs ahead of the network's block rate — i.e. whenever a node catches up after being bootstrapped from a snapshot. The affected blocks are gone from storage: they are not retrievable by number or by hash, and state queries at those heights fail. Blocks that came from the snapshot itself, and blocks ingested at normal tip rate, are stored correctly.
go-ethereum (morph 2.2.5) driven by the same morph-node on the same host does not lose blocks in the same ranges.
The node contradicts its own storage
This needs no external comparison — two queries against the affected node:
Morph mainnet, archive, no pruning flags, storage v2
Bootstrapped from snapshot-archive-reth-20260803-1 using a paired reth + node restore at the same height (24 980 107), as the README requires: reth-data/ → EL datadir, data/ → node-data. Nothing was started on the extracted copy beforehand.
With that bootstrap morph-node drives the EL through the expected path (ApplyBlockV2 success per block, EL and CL advancing together) and derivation reached the tip in about 11 minutes, roughly 309 blocks/s.
Measurements
A block counts as absent only when the node answers {"result": null}; JSON-RPC errors are counted separately so transient failures are not mistaken for missing data. rpc_errors was 0 in every scan below.
Same node, blocks grouped by how they arrived:
how the blocks got in
window
absent
from the snapshot (CL stopped, no derivation)
24 979 507–24 980 107
0 / 601
from the snapshot
24 900 000–24 900 600
0 / 601
from the snapshot
24 664 700–24 665 300
0 / 601
ingested while catching up
24 980 157–24 980 230
7 / 74
ingested while catching up
25 000 000–25 000 600
32 / 601
ingested while catching up
25 100 000–25 100 600
16 / 601
ingested at tip rate (last 141 blocks)
head−150 … head−10
0 / 141
The first missing block sits only 59 blocks above the snapshot base, so loss begins almost as soon as derivation outpaces the network.
Control — go-ethereum 2.2.5, same host, same morph-node, its own datadir, started with no CL attached:
window
absent
24 664 700–24 665 300
0 / 601
24 900 000–24 900 600
0 / 601
25 000 000–25 000 600
0 / 601
Where the loss happens
Both import entry points in crates/engine-api/src/builder.rs are strict about continuity:
new_l2_block requires data.number == current_head.number + 1 and data.parent_hash == current_head.hash, otherwise it returns DiscontinuousBlockNumber / WrongParentHash, or logs ignoring past block number when the number is behind.
new_l2_block_v2, which is what morph-node uses (ApplyBlockV2 in its logs), resolves the parent with sealed_header_by_hash and fails with parent block not found if it is missing, then requires data.number == parent.number() + 1.
A block therefore cannot be skipped silently at this boundary — a skip would surface as an error on the very next block. Across 5 037 136 log lines captured from the affected runs there is not one occurrence of discontinuous, wrong parent hash, parent block not found or ignoring past block. As a control, matching known strings (Canonical chain committed, reference index) over the same data returns 1 181 752 hits, so the search itself is sound.
So every block is accepted, executed and made canonical, and the loss happens after acceptance — on the persistence path, presumably in import_l2_block_via_engine and the engine tree's handling of a stream of one newPayload + forkchoiceUpdated per block arriving faster than the persistence task drains.
This matches the symptoms: the canonical chain stays linked by headers (otherwise sealed_header_by_hash for the child of a missing block would have failed and derivation would have stopped), state and receipts around the gaps are correct, and only retrieval of the block itself fails — by number and by hash alike.
Things that do not help
--engine.persistence-threshold 0. The obvious theory was blocks piling up in memory ahead of the last persisted block. Re-ran the full bootstrap with --engine.persistence-threshold 0 and --engine.memory-block-buffer-target 0 (verified in /proc/1/cmdline) — gaps still appear, and their positions move between runs rather than recurring at fixed heights. Consistent with the section above: if the blocks never reach the persistence task, forcing it to flush more eagerly changes nothing.
Falling back to P2P sync. Started morph-reth on an empty datadir with --trusted-peers set to the three mainnet peers and no consensus client. It connects (connected_peers=3) but never advances — latest_block=0 after several minutes. So every node, whether restored from a snapshot or synced from genesis, must pass through the fast-ingest path.
Knock-on effects
The reference index can never finish a backfill. It walks the canonical range and stops at the first gap, then retries the same range forever:
INFO morph::reference_index: reference index phase changed phase=Backfill
WARN morph::reference_index: reference index reconciliation failed; execution continues
error="canonical block range 24664765..=24665276 returned 47 of 512 blocks" retry_backoff=30s
24 664 765 + 47 = 24 664 812, exactly the first absent block in that run. On a datadir without gaps the same code reaches phase=Live in about 20 s, so the index logic works — it is starved by the missing blocks.
Re-fetching the bodies fails at the block immediately before the first gap:
morph-reth stage run --from 24664700 --to 24670000 --commit --trusted-peers <...> bodies
Error: stage encountered an error in block #24664811: validation error: mismatched block state root:
got 0x113ca1c87b3acfdc7ac6631bf3840d5dbd1c1002bef88d44ed4545269a875d8c,
expected 0x03a772765105775e1b4e4e776c05209e7952e007cd0761e976c8f649309bf313
During catch-up the trie proof workers also log HeaderNotFound heavily (~16 000 occurrences in 3 minutes across ~47 distinct block numbers) alongside State root task timed out, racing serial fallback.
Reproduction
Restore an official snapshot-archive-reth-* snapshot, both halves at the same height, into fresh datadirs. Do not start anything on the extracted copy first.
Start morph-rethwithoutmorph-node; scan windows below the snapshot head — all blocks present.
Start morph-node and let derivation carry the node to the tip.
Scan windows inside the range derivation just replayed — blocks are missing.
Every published snapshot necessarily lags the tip, and there is no P2P path to fall back on, so the snapshot → catch-up route always passes through the mode in which blocks are lost. That makes it hard to bring up a complete archive node at all.
Is the fast-ingest path expected to persist every block, or is some backfill meant to run afterwards?
Is there a supported way to repair an existing datadir, given stage run bodies fails on the state root check?
Would a snapshot taken at or very near the current tip avoid this?
Happy to run further diagnostics. In particular I can run an instrumented build that logs accepted versus persisted block numbers around import_l2_block_via_engine and report which blocks are acknowledged but never written, if that would help narrow it down.
Summary
On Morph mainnet,
morph-rethloses a subset of blocks while derivation runs ahead of the network's block rate — i.e. whenever a node catches up after being bootstrapped from a snapshot. The affected blocks are gone from storage: they are not retrievable by number or by hash, and state queries at those heights fail. Blocks that came from the snapshot itself, and blocks ingested at normal tip rate, are stored correctly.go-ethereum(morph 2.2.5) driven by the samemorph-nodeon the same host does not lose blocks in the same ranges.The node contradicts its own storage
This needs no external comparison — two queries against the affected node:
The node serves a block and cannot produce that block's parent. On
https://rpc.morphl2.iothat hash is block 25 000 012 with one transaction.The missing blocks form contiguous runs — here 25 000 005 … 25 000 012, with 25 000 004 and 25 000 013 both present. For blocks in a run:
eth_getBlockByNumber→nulleth_getBlockByHash→null(so this is not a number→hash lookup issue)eth_getBlockTransactionCountByNumber→nulleth_getBalance→-32001 block not found: 0x17d7845Execution itself is unaffected: the head hash always matches the public RPC, and state and receipts around the gaps are correct.
Environment
morph-rethv1.1.0. The published image cannot start (Docker images v1.1.0 and latest fail to start: GLIBC_2.38 / GLIBC_2.39 not found #151), so this is the officialmorph-reth-1.1.0-x86_64-linux.tar.gzrelease binary onubuntu:24.04.ghcr.io/morph-l2/node:0.6.0snapshot-archive-reth-20260803-1using a pairedreth+noderestore at the same height (24 980 107), as the README requires:reth-data/→ EL datadir,data/→node-data. Nothing was started on the extracted copy beforehand.With that bootstrap
morph-nodedrives the EL through the expected path (ApplyBlockV2 successper block, EL and CL advancing together) and derivation reached the tip in about 11 minutes, roughly 309 blocks/s.Measurements
A block counts as absent only when the node answers
{"result": null}; JSON-RPC errors are counted separately so transient failures are not mistaken for missing data.rpc_errorswas 0 in every scan below.Same node, blocks grouped by how they arrived:
The first missing block sits only 59 blocks above the snapshot base, so loss begins almost as soon as derivation outpaces the network.
Control —
go-ethereum2.2.5, same host, samemorph-node, its own datadir, started with no CL attached:Where the loss happens
Both import entry points in
crates/engine-api/src/builder.rsare strict about continuity:new_l2_blockrequiresdata.number == current_head.number + 1anddata.parent_hash == current_head.hash, otherwise it returnsDiscontinuousBlockNumber/WrongParentHash, or logsignoring past block numberwhen the number is behind.new_l2_block_v2, which is whatmorph-nodeuses (ApplyBlockV2in its logs), resolves the parent withsealed_header_by_hashand fails withparent block not foundif it is missing, then requiresdata.number == parent.number() + 1.A block therefore cannot be skipped silently at this boundary — a skip would surface as an error on the very next block. Across 5 037 136 log lines captured from the affected runs there is not one occurrence of
discontinuous,wrong parent hash,parent block not foundorignoring past block. As a control, matching known strings (Canonical chain committed,reference index) over the same data returns 1 181 752 hits, so the search itself is sound.So every block is accepted, executed and made canonical, and the loss happens after acceptance — on the persistence path, presumably in
import_l2_block_via_engineand the engine tree's handling of a stream of onenewPayload+forkchoiceUpdatedper block arriving faster than the persistence task drains.This matches the symptoms: the canonical chain stays linked by headers (otherwise
sealed_header_by_hashfor the child of a missing block would have failed and derivation would have stopped), state and receipts around the gaps are correct, and only retrieval of the block itself fails — by number and by hash alike.Things that do not help
--engine.persistence-threshold 0. The obvious theory was blocks piling up in memory ahead of the last persisted block. Re-ran the full bootstrap with--engine.persistence-threshold 0and--engine.memory-block-buffer-target 0(verified in/proc/1/cmdline) — gaps still appear, and their positions move between runs rather than recurring at fixed heights. Consistent with the section above: if the blocks never reach the persistence task, forcing it to flush more eagerly changes nothing.Falling back to P2P sync. Started
morph-rethon an empty datadir with--trusted-peersset to the three mainnet peers and no consensus client. It connects (connected_peers=3) but never advances —latest_block=0after several minutes. So every node, whether restored from a snapshot or synced from genesis, must pass through the fast-ingest path.Knock-on effects
The reference index can never finish a backfill. It walks the canonical range and stops at the first gap, then retries the same range forever:
24 664 765 + 47 = 24 664 812, exactly the first absent block in that run. On a datadir without gaps the same code reaches
phase=Livein about 20 s, so the index logic works — it is starved by the missing blocks.Re-fetching the bodies fails at the block immediately before the first gap:
During catch-up the trie proof workers also log
HeaderNotFoundheavily (~16 000 occurrences in 3 minutes across ~47 distinct block numbers) alongsideState root task timed out, racing serial fallback.Reproduction
snapshot-archive-reth-*snapshot, both halves at the same height, into fresh datadirs. Do not start anything on the extracted copy first.morph-rethwithoutmorph-node; scan windows below the snapshot head — all blocks present.morph-nodeand let derivation carry the node to the tip.Scanner, keeping
nulland errors apart:Impact and questions
Every published snapshot necessarily lags the tip, and there is no P2P path to fall back on, so the snapshot → catch-up route always passes through the mode in which blocks are lost. That makes it hard to bring up a complete archive node at all.
stage run bodiesfails on the state root check?Happy to run further diagnostics. In particular I can run an instrumented build that logs accepted versus persisted block numbers around
import_l2_block_via_engineand report which blocks are acknowledged but never written, if that would help narrow it down.