Skip to content

feat(sequencer): mirror centralized sequencer changes from go-ethereum#331 #122

@panos-xyz

Description

@panos-xyz

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

  • morph-payload-types: add parent_hash: Option<B256> to SafeL2Data
  • morph-engine-api:
    • engine_newSafeL2Block: support optional ParentHash parent pinning
    • engine_newSafeL2Block: derive next_l1_msg_index from canonical stream
    • engine_newL2BlockV2: new RPC, mandatory ParentHash + hash-mismatch defense
  • morph-consensus (or post-execution validator): Jade-gated NextL1MsgIndex integrity check
  • Verify reorg behavior through reth engine tree against geth TestNewL2BlockV2 cases (Normal / Reorg / ConsecutiveReorg / TamperedNextL1MessageIndex / HonestNextL1MessageIndexAfterTampered / WrongBlockNumber / ParentNotFound)
  • Update tests + docs

Acceptance Criteria

  • engine_newL2BlockV2 accepts mandatory parentHash, rejects mismatched hash and wrong block number with the same error wording as geth
  • engine_newSafeL2Block accepts optional parentHash, falls back to currentHead when nil
  • Tampered NextL1MsgIndex is rejected post-Jade and warned pre-Jade; honest value applies cleanly on the same parent
  • Reorg from height 11 → block 7 leaves canonical hashes 8..=11 cleared
  • Consecutive reorg (8 → 5 → 3, etc.) stays consistent
  • cargo nextest run --workspace passes
  • cargo nextest run -p morph-node --features test-utils -E 'binary(it)' passes
  • No regression in existing engine_newL2Block / engine_newSafeL2Block v1 callers

References

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions