Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions