Background
Centralized sequencer rollout in morph-l2/go-ethereum#331 ("Feat: Sequencer Final PR") introduces a set of consensus-critical engine API and reorg behaviors. To keep morph-reth interoperable with morph-geth once the centralized sequencer hardfork lands, morph-reth must mirror these changes byte-for-byte at the engine API and validation layers.
Tracking issue on the consensus side: morph-l2/morph#878 (Reorg integration based on L1 batches).
Functional Description
Implement the equivalent of geth PR #331 in morph-reth. The work splits into five mostly independent threads.
1. Short-chain reorg fix (core/blockchain.go analogue)
geth bug: when len(newChain) <= 1, stale canonical hashes above the fork point were not deleted because the loop bound was bc.CurrentBlock().NumberU64() (old head) instead of commonBlock.NumberU64().
reth side: reth's engine tree already drives canonical management via FCU, but verify that MorphEngineValidator / custom engine API path does not introduce the same off-by-one when receiving reorg-shaped payloads. Add a regression test that mirrors geth's TestNewL2BlockV2 / Reorg and ConsecutiveReorg cases.
2. NextL1MsgIndex anti-tampering check
geth introduces ErrInvalidNextL1MsgIndex. The field header.NextL1MsgIndex is not covered by Header.Hash(), so a signature-replay attacker can flip it freely. The fix derives the expected value from the canonical L1 message stream (parentQueueIndex + NumL1MessagesProcessed(parentQueueIndex)) — both sides hash through Header.TxHash because they live in L1MessageTx.queueIndex.
reth side: in morph-consensus (or wherever validate_block_post_execution runs), gate by is_jade_active_at_timestamp:
- pre-Jade: log warn on mismatch, do not reject
- post-Jade: hard-reject (
MorphConsensusError::InvalidNextL1MessageIndex or similar)
This complements the existing withdraw-trie-root validation already done in MorphEngineValidator::validate_block_post_execution_with_hashed_state.
3. SafeL2Data.ParentHash (optional parent pin)
geth makes SafeL2Data.ParentHash an Option<H256>. When present, parent is looked up by hash and Number == parent+1 is enforced; reorg is handled implicitly by SetCanonical. When absent, fall back to legacy currentHead+1 semantics.
reth side: extend morph-payload-types::SafeL2Data to add parent_hash: Option<B256>. In engine_newSafeL2Block, branch on parent_hash:
Some(h) → look up parent header, enforce params.number == parent.number + 1, route through reth FCU (parent != currentHead triggers reorg via the engine tree)
None → keep current convert_payload_to_block + currentHead path
Also derive next_l1_msg_index from the canonical L1 message stream when assembling the header (geth uses rawdb.ReadFirstQueueIndexNotInL2Block(parent.Hash()) — reth equivalent reads from the same BlockchainProvider/state-store path).
4. New engine_newL2BlockV2 RPC
geth adds engine_newL2BlockV2(ExecutableL2Data) -> Header. Differences from v1:
ParentHash is required (not optional) — reorg-friendly; sequencer must explicitly track parent
- Hash-mismatch defense: when
params.Hash != 0, recompute block.Hash() and reject on divergence (defends against signature replay where signed Hash is kept but body is tampered)
- isSafe=true path is removed (consolidated into
NewSafeL2Block); engine_newL2BlockV2 is now sequencer-signed-only
reth side: register engine_newL2BlockV2 in morph-engine-api. Internally route through reth's newPayload + FCU (the same approach refactor in PR #35 used). Keep v1 (engine_newL2Block) live for backward compat during rollout.
5. Hash-mismatch / signature-replay defense
Add the same params.hash != 0 && block.hash() != params.hash guard everywhere the morph engine API accepts a sequencer-signed body:
engine_newL2BlockV2
- (verify whether
engine_validateL2Block already covers this on reth side)
This is "last line of defense" — the consensus client already does the check in VerifyBlockSignature, but EL must not trust upstream blindly.
Scope
Acceptance Criteria
References
Background
Centralized sequencer rollout in morph-l2/go-ethereum#331 ("Feat: Sequencer Final PR") introduces a set of consensus-critical engine API and reorg behaviors. To keep
morph-rethinteroperable withmorph-gethonce the centralized sequencer hardfork lands,morph-rethmust mirror these changes byte-for-byte at the engine API and validation layers.Tracking issue on the consensus side: morph-l2/morph#878 (Reorg integration based on L1 batches).
Functional Description
Implement the equivalent of geth PR #331 in
morph-reth. The work splits into five mostly independent threads.1. Short-chain reorg fix (
core/blockchain.goanalogue)geth bug: when
len(newChain) <= 1, stale canonical hashes above the fork point were not deleted because the loop bound wasbc.CurrentBlock().NumberU64()(old head) instead ofcommonBlock.NumberU64().reth side: reth's engine tree already drives canonical management via FCU, but verify that
MorphEngineValidator/ custom engine API path does not introduce the same off-by-one when receiving reorg-shaped payloads. Add a regression test that mirrors geth'sTestNewL2BlockV2 / ReorgandConsecutiveReorgcases.2.
NextL1MsgIndexanti-tampering checkgeth introduces
ErrInvalidNextL1MsgIndex. The fieldheader.NextL1MsgIndexis not covered byHeader.Hash(), so a signature-replay attacker can flip it freely. The fix derives the expected value from the canonical L1 message stream (parentQueueIndex + NumL1MessagesProcessed(parentQueueIndex)) — both sides hash throughHeader.TxHashbecause they live inL1MessageTx.queueIndex.reth side: in
morph-consensus(or wherevervalidate_block_post_executionruns), gate byis_jade_active_at_timestamp:MorphConsensusError::InvalidNextL1MessageIndexor similar)This complements the existing withdraw-trie-root validation already done in
MorphEngineValidator::validate_block_post_execution_with_hashed_state.3.
SafeL2Data.ParentHash(optional parent pin)geth makes
SafeL2Data.ParentHashanOption<H256>. When present, parent is looked up by hash andNumber == parent+1is enforced; reorg is handled implicitly bySetCanonical. When absent, fall back to legacycurrentHead+1semantics.reth side: extend
morph-payload-types::SafeL2Datato addparent_hash: Option<B256>. Inengine_newSafeL2Block, branch onparent_hash:Some(h)→ look up parent header, enforceparams.number == parent.number + 1, route through reth FCU (parent != currentHead triggers reorg via the engine tree)None→ keep currentconvert_payload_to_block+ currentHead pathAlso derive
next_l1_msg_indexfrom the canonical L1 message stream when assembling the header (geth usesrawdb.ReadFirstQueueIndexNotInL2Block(parent.Hash())— reth equivalent reads from the same BlockchainProvider/state-store path).4. New
engine_newL2BlockV2RPCgeth adds
engine_newL2BlockV2(ExecutableL2Data) -> Header. Differences from v1:ParentHashis required (not optional) — reorg-friendly; sequencer must explicitly track parentparams.Hash != 0, recomputeblock.Hash()and reject on divergence (defends against signature replay where signedHashis kept but body is tampered)NewSafeL2Block);engine_newL2BlockV2is now sequencer-signed-onlyreth side: register
engine_newL2BlockV2inmorph-engine-api. Internally route through reth'snewPayload + FCU(the same approach refactor in PR #35 used). Keep v1 (engine_newL2Block) live for backward compat during rollout.5. Hash-mismatch / signature-replay defense
Add the same
params.hash != 0 && block.hash() != params.hashguard everywhere the morph engine API accepts a sequencer-signed body:engine_newL2BlockV2engine_validateL2Blockalready covers this on reth side)This is "last line of defense" — the consensus client already does the check in
VerifyBlockSignature, but EL must not trust upstream blindly.Scope
morph-payload-types: addparent_hash: Option<B256>toSafeL2Datamorph-engine-api:engine_newSafeL2Block: support optionalParentHashparent pinningengine_newSafeL2Block: derivenext_l1_msg_indexfrom canonical streamengine_newL2BlockV2: new RPC, mandatory ParentHash + hash-mismatch defensemorph-consensus(or post-execution validator): Jade-gatedNextL1MsgIndexintegrity checkTestNewL2BlockV2cases (Normal / Reorg / ConsecutiveReorg / TamperedNextL1MessageIndex / HonestNextL1MessageIndexAfterTampered / WrongBlockNumber / ParentNotFound)Acceptance Criteria
engine_newL2BlockV2accepts mandatoryparentHash, rejects mismatched hash and wrong block number with the same error wording as gethengine_newSafeL2Blockaccepts optionalparentHash, falls back to currentHead when nilNextL1MsgIndexis rejected post-Jade and warned pre-Jade; honest value applies cleanly on the same parentcargo nextest run --workspacepassescargo nextest run -p morph-node --features test-utils -E 'binary(it)'passesengine_newL2Block/engine_newSafeL2Blockv1 callersReferences