Skip to content

feat: centralized-sequencer EL support (V2 engine API + reorg + next_l1_msg_index hardening)#124

Open
panos-xyz wants to merge 3 commits into
mainfrom
feat/centralized-sequencer-engine-v2
Open

feat: centralized-sequencer EL support (V2 engine API + reorg + next_l1_msg_index hardening)#124
panos-xyz wants to merge 3 commits into
mainfrom
feat/centralized-sequencer-engine-v2

Conversation

@panos-xyz
Copy link
Copy Markdown
Contributor

@panos-xyz panos-xyz commented Jun 5, 2026

Why

Morph is switching to a centralized sequencer, which (unlike the Tendermint instant-finality model) introduces reorgs. morph-reth's L2 Engine API was built on a "never reorg" assumption, so it is missing the EL surface the new consensus client drives.

This ports the EL changes from morph-l2/go-ethereum#331 and aligns with the CL contract on morph-l2/morph: feat/sequencer-final:

  • follower executor.ApplyBlockV2 → engine_newL2BlockV2 (reorg via SetCanonical)
  • derivation deriveForce → engine_newSafeL2Block with SafeL2Data.ParentHash (L1-canonical self-heal reorg)
  • sequencer role engine_assembleL2BlockV2

What

feat(engine-api) — reorg-capable V2 methods

  • engine_newL2BlockV2 — selects the parent by hash instead of requiring it to equal the current head; the forkchoice update reorgs the canonical chain onto the imported block. Returns the header. Takes ExecutableL2Data (unchanged shape).
  • engine_newSafeL2Block — optional SafeL2Data.parentHash pins a non-head parent for the derivation reorg path; build_l2_payload resolves the parent from an override.
  • engine_assembleL2BlockV2 — builds on an explicit parent hash. Takes a single AssembleL2BlockV2Params struct ({ parentHash, transactions, timestamp }), mirroring V1's engine_assembleL2Block style: transactions are 0x-hex bytes and timestamp is a hex quantity.

⚠️ Wire-contract change (intentional). geth's currently-deployed engine_assembleL2BlockV2 is a 3 positional-param call (parentHash, bare-number timestamp, base64 txs). This PR unifies it to the V1-style single struct (hex), dropping the base64 quirk. The companion changes (go-ethereum authclient.AssembleL2BlockV2 + server l2_api.go, morph node retryable_client, and the morph-cross-client-tests contract/hive fixtures) must adopt the same struct shape and land in lockstep. newL2BlockV2/newSafeL2Block already use single structs matching geth and are unaffected.

feat(consensus) — exact next_l1_msg_index from Jade (anti-tamper, geth PR #331)

next_l1_msg_index is not covered by the block hash, so from Jade it must exactly match the value derived from the canonical L1 message stream:

  • blocks with L1 messages: header.next == last_queue_index + 1 (stateless consensus check; pre-Jade keeps the lenient lower bound for the legacy "early L1 msg skip").
  • empty blocks / parent-aware exactness: header.next == parent.next + 0, enforced in the engine-tree payload validator (validate_post_execution) — the only point with both the parent header and the block body.
  • error message carries the invalid block.NextL1MsgIndex substring so the CL's retry classifier (retryable_client.go) treats it as permanent (non-retryable).

Stale "Tendermint instant finality / no reorgs possible" comments are corrected.

Tests

fmt + clippy --all --all-targets -D warnings + cargo test --all + e2e all green. New coverage:

  • e2e: new_l2_block_v2_imports_block_on_current_head, new_l2_block_v2_reorgs_onto_sibling_block, new_safe_l2_block_with_parent_hash_reorgs_onto_non_head_parent, assemble_l2_block_v2_builds_on_explicit_parent
  • e2e: next_l1_msg_index_skip_past_included_messages_rejected_post_jade, ..._can_skip_past_included_messages_pre_jade, next_l1_msg_index_empty_block_cannot_advance_post_jade
  • unit: AssembleL2BlockV2Params / SafeL2Data.parentHash serde, consensus Jade-exact + error-string

Notes / design

  • Finalization unchanged: live newL2BlockV2 near-wall-clock blocks use finalized=ZERO, so the engine tree permits reorgs; the divergent block in a deriveForce reorg comes via the live (non-finalized) path. Verified by the reorg e2e tests.
  • geth's reorg() short-chain fix is not ported directly (reth uses its own engine tree); the 1-block sibling-reorg scenario it guards is covered by e2e.

⚠️ Pre-merge checklist (operational, not code)

  • Companion wire-contract change lands in lockstep: go-ethereum authclient.AssembleL2BlockV2 + l2_api.go, morph node retryable_client, and morph-cross-client-tests adopt the AssembleL2BlockV2Params struct shape (hex txs, hex-quantity timestamp).
  • Verify the Jade exact-index tightening against the real chain. Confirm a post-Jade hoodi/mainnet block that includes L1 messages satisfies next_l1_msg_index == last_queue_index + 1 (no trailing skips). Consensus-safe by geth-parity (PR #331 hard-fails the same blocks), but the previous reth behavior allowed trailing skips, so a real-chain spot check is prudent.

Depends on the CL companion branch morph: feat/sequencer-final (still unmerged) for the call sites.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added engine_assembleL2BlockV2 and engine_newL2BlockV2 JSON-RPC methods enabling explicit parent selection for block assembly and import
    • Added optional parent hash parameter to safe L2 block operations for chain reorganization scenarios
  • Bug Fixes

    • Improved L1 message index validation error message for clarity
  • Tests

    • Added consensus validation tests for Jade hardfork boundary behavior
    • Added engine integration tests for block v2 operations and chain reorganization workflows

panos-xyz added 2 commits June 5, 2026 17:25
…sequencer

Ports the EL surface the centralized-sequencer consensus client requires
(morph-l2/go-ethereum#331, morph feat/sequencer-final):

- engine_newL2BlockV2: select the parent by hash instead of requiring it to
  equal the current head, so the forkchoice update reorgs the canonical
  chain onto the imported block.
- engine_assembleL2BlockV2: build on an explicitly given parent hash.
  Positional params (parentHash, timestamp as a bare JSON number, txs); txs
  are base64-encoded to match go-ethereum's raw [][]byte and are decoded via
  AssembleV2Transactions (which also accepts 0x-hex for robustness).
- engine_newSafeL2Block: optional SafeL2Data.parentHash pins a non-head
  parent for the derivation reorg path (deriveForce). build_l2_payload now
  resolves the parent from an override rather than always the head.

e2e tests cover the happy path, sibling reorg, safe-block reorg and the V2
assemble path.
Mirrors go-ethereum PR #331's writeBlockStateWithoutHead anti-tampering
check. next_l1_msg_index is not covered by the block hash, so from Jade it
must exactly match the value derived from the canonical L1 message stream:

- Blocks with L1 messages: header.next == last_queue_index + 1, enforced in
  the stateless consensus check. Pre-Jade keeps the lenient lower bound to
  preserve the legacy "early L1 msg skip" behavior.
- Empty blocks / parent-aware exactness: header.next == parent.next + 0,
  enforced in the engine-tree payload validator (validate_post_execution),
  the only point with both the parent header and the block body available.

The error message carries the "invalid block.NextL1MsgIndex" substring so the
consensus client's retry classifier treats it as a permanent (non-retryable)
error. Stale "Tendermint instant finality / no reorgs" comments are corrected
now that the V2 path is reorg-capable.
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, reopen this pull request to trigger a review.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5d50f2bc-51bf-4374-a842-74a5117f6cd4

📥 Commits

Reviewing files that changed from the base of the PR and between 37f39f8 and 7f68987.

📒 Files selected for processing (6)
  • crates/engine-api/src/api.rs
  • crates/engine-api/src/builder.rs
  • crates/engine-api/src/rpc.rs
  • crates/node/tests/it/engine.rs
  • crates/payload/types/src/lib.rs
  • crates/payload/types/src/params.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/node/tests/it/engine.rs
  • crates/engine-api/src/builder.rs

📝 Walkthrough

Walkthrough

This PR implements Jade fork L1 message index validation with fork-dependent exactness rules and adds v2 engine APIs for reorg-capable block management. L1 message header checks now require exact matches under Jade but allow lower-bound skipping pre-Jade. The payload types are extended with AssembleL2BlockV2Params and optional parent_hash in SafeL2Data to support explicit parent selection. New engine API v2 endpoints (assemble_l2_block_v2, new_l2_block_v2) and RPC methods enable block assembly and import with arbitrary parent pinning, triggering reorganization when needed.

Changes

Jade L1 Validation & Engine V2 APIs

Layer / File(s) Summary
L1 Message Error Text Update
crates/consensus/src/error.rs
InvalidNextL1MessageIndex error display string updated to invalid block.NextL1MsgIndex with unit test assertion matching node non-retryable classifier.
Fork-Dependent L1 Message Header Index Validation
crates/consensus/src/validation.rs
validate_l1_messages_in_block now accepts is_jade parameter; Jade enforces exact next_l1_msg_index == expected, pre-Jade allows >= expected for early skips. Docs updated, logic refactored, comprehensive unit tests added for fork boundary behavior.
V2 Payload Types: Parent Hash & Parameter Struct
crates/payload/types/src/params.rs, crates/payload/types/src/safe_l2_data.rs, crates/payload/types/src/lib.rs
AssembleL2BlockV2Params struct added with parent_hash, transactions, optional timestamp (camelCase JSON). SafeL2Data extended with optional parent_hash for derivation reorg context. Serialization/deserialization tests verify camelCase handling and omission when absent.
Engine API V2 Trait Methods
crates/engine-api/src/api.rs
MorphL2EngineApi trait extended with assemble_l2_block_v2 (explicit parent, optional timestamp) and new_l2_block_v2 (parent-pinned import) async methods. AssembleL2BlockV2Params added to imports.
RPC V2 Endpoints & Handlers
crates/engine-api/src/rpc.rs
MorphL2EngineRpc trait adds engine_assembleL2BlockV2 and engine_newL2BlockV2 RPC methods. Handler implementation includes v2-specific tracing (parent hash, optional timestamp) and delegates to underlying engine API with error mapping.
Engine API V2 Implementation & Payload Builder Refactoring
crates/engine-api/src/builder.rs
RealMorphL2EngineApi implements assemble_l2_block_v2 and new_l2_block_v2. build_l2_payload extended with parent_override: Option<B256> for parent pinning. assemble_l2_block updated to pass None for v1 compat. new_safe_l2_block uses caller-pinned parent via override. Forkchoice state resolution uses BlockTagTracker with time-based fallback.
Post-Execution Parent-Aware L1 Index Validation
crates/engine-tree-ext/src/payload_validator.rs
New validate_next_l1_msg_index_against_parent check added (Jade-conditional) that scans in-block L1 messages and validates header next_l1_msg_index against parent-derived expectation. Generic constraints refined to require Morph-specific node primitives. Check executes post-execution after header validation.
Code Formatting Updates
crates/node/src/validator.rs, crates/node/tests/it/engine.rs
Multi-line formatting of generic type constraints and import statements (no behavioral changes).
Consensus & Engine Integration Tests
crates/node/tests/it/consensus.rs, crates/node/tests/it/engine.rs
Consensus tests cover Jade fork boundary: empty block rejection, exact index enforcement, pre-Jade skip allowance. Engine tests verify v2 block assembly/import, canonical head advancement, chain reorganization on sibling blocks, and safe block reorg with pinned parent.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • morph-l2/morph-reth#6: Both PRs refine Morph consensus validation around L1 message queue/indexing—this PR makes next_l1_msg_index fork-dependent and updates error text, building on earlier introduction of InvalidNextL1MessageIndex error variant.
  • morph-l2/morph-reth#98: Both PRs add parent-aware block/consensus checks in crates/engine-tree-ext/src/payload_validator.rs, with this PR's new post-execution L1 index validation building on the engine-tree-ext payload validator foundation.
  • morph-l2/morph-reth#12: Both PRs extend the MorphL2EngineApi/JSON-RPC surfaces in engine-api by adding v2 block methods/handlers, directly building on the engine API scaffold from the earlier PR.

Suggested reviewers

  • chengwenxi
  • anylots

Poem

🐰 A fork draws near, exactness now required,
Parent hashes pinned where reorgs are desired,
Jade gates the truth with Monad's light touch,
V2 APIs born—no longer need much,
Block-by-block harmony restored once more. 🌙

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and specifically describes the main changes: V2 engine API support, reorg capability, and next_l1_msg_index hardening for Jade fork.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/centralized-sequencer-engine-v2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/node/tests/it/consensus.rs (1)

322-324: ⚡ Quick win

Pin Jade schedule explicitly in this test.

This test currently depends on the default schedule being AllActive. Please set with_schedule(HardforkSchedule::AllActive) explicitly (as done in nearby tests) so the fork-boundary intent can’t drift if defaults change.

Proposed diff
-    // Default schedule is AllActive (Jade on).
-    let (mut nodes, _wallet) = TestNodeBuilder::new().build().await?;
+    let (mut nodes, _wallet) = TestNodeBuilder::new()
+        .with_schedule(HardforkSchedule::AllActive)
+        .build()
+        .await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/node/tests/it/consensus.rs` around lines 322 - 324, Test relies on the
default hardfork schedule; explicitly pin it by updating the TestNodeBuilder
invocation to use with_schedule(HardforkSchedule::AllActive) before build(),
e.g. call
TestNodeBuilder::new().with_schedule(HardforkSchedule::AllActive).build().await?
so the test’s fork-boundary assumptions remain stable; locate the builder usage
around TestNodeBuilder::new().build().await? and replace/augment it with
with_schedule(HardforkSchedule::AllActive) prior to build().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine-tree-ext/src/payload_validator.rs`:
- Around line 898-921: The loop computing expected from
parent_block.next_l1_msg_index over block.body().transactions() currently only
sets expected = queue_index + 1 and never verifies continuity against the
parent-derived expected, allowing gaps; change the logic in the for loop that
handles tx.is_l1_msg() to first compare queue_index()? (from tx.queue_index())
with the current expected and return a ConsensusError if they differ, then set
expected = queue_index.checked_add(1)... as before (use the same error
formatting referencing block.header().next_l1_msg_index and expected/actual),
ensuring you still handle the tx.queue_index() None case and overflow with the
same ok_or_else checks.

---

Nitpick comments:
In `@crates/node/tests/it/consensus.rs`:
- Around line 322-324: Test relies on the default hardfork schedule; explicitly
pin it by updating the TestNodeBuilder invocation to use
with_schedule(HardforkSchedule::AllActive) before build(), e.g. call
TestNodeBuilder::new().with_schedule(HardforkSchedule::AllActive).build().await?
so the test’s fork-boundary assumptions remain stable; locate the builder usage
around TestNodeBuilder::new().build().await? and replace/augment it with
with_schedule(HardforkSchedule::AllActive) prior to build().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 325b247a-059f-4850-8f6f-6a62049ee2af

📥 Commits

Reviewing files that changed from the base of the PR and between 2fa4dfa and 37f39f8.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • crates/consensus/src/error.rs
  • crates/consensus/src/validation.rs
  • crates/engine-api/src/api.rs
  • crates/engine-api/src/builder.rs
  • crates/engine-api/src/rpc.rs
  • crates/engine-tree-ext/src/payload_validator.rs
  • crates/node/src/validator.rs
  • crates/node/tests/it/consensus.rs
  • crates/node/tests/it/engine.rs
  • crates/payload/types/Cargo.toml
  • crates/payload/types/src/lib.rs
  • crates/payload/types/src/params.rs
  • crates/payload/types/src/safe_l2_data.rs

Comment on lines +898 to +921
let mut expected = parent_block.next_l1_msg_index;
for tx in block.body().transactions() {
if !tx.is_l1_msg() {
break;
}

let queue_index = tx.queue_index().ok_or_else(|| {
ConsensusError::msg("L1 message transaction is missing queue index")
})?;
expected = queue_index.checked_add(1).ok_or_else(|| {
ConsensusError::msg(format!(
"invalid block.NextL1MsgIndex: expected {}, got {}",
u64::MAX,
block.header().next_l1_msg_index
))
})?;
}

let actual = block.header().next_l1_msg_index;
if actual != expected {
return Err(ConsensusError::msg(format!(
"invalid block.NextL1MsgIndex: expected {expected}, got {actual}"
)));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Enforce parent-stream queue continuity in Jade parent-aware validation.

The loop derives expected from each tx’s queue_index + 1 but never checks that queue_index equals the parent-derived expected value. That allows skipped queue indices to pass post-Jade (e.g., parent.next=2, first L1 tx queue=5, header.next=6).

🔧 Proposed fix
         let mut expected = parent_block.next_l1_msg_index;
         for tx in block.body().transactions() {
             if !tx.is_l1_msg() {
                 break;
             }

             let queue_index = tx.queue_index().ok_or_else(|| {
                 ConsensusError::msg("L1 message transaction is missing queue index")
             })?;
-            expected = queue_index.checked_add(1).ok_or_else(|| {
+            if queue_index != expected {
+                return Err(ConsensusError::msg(format!(
+                    "invalid block.NextL1MsgIndex: expected {expected}, got {queue_index}"
+                )));
+            }
+            expected = expected.checked_add(1).ok_or_else(|| {
                 ConsensusError::msg(format!(
                     "invalid block.NextL1MsgIndex: expected {}, got {}",
                     u64::MAX,
                     block.header().next_l1_msg_index
                 ))
             })?;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut expected = parent_block.next_l1_msg_index;
for tx in block.body().transactions() {
if !tx.is_l1_msg() {
break;
}
let queue_index = tx.queue_index().ok_or_else(|| {
ConsensusError::msg("L1 message transaction is missing queue index")
})?;
expected = queue_index.checked_add(1).ok_or_else(|| {
ConsensusError::msg(format!(
"invalid block.NextL1MsgIndex: expected {}, got {}",
u64::MAX,
block.header().next_l1_msg_index
))
})?;
}
let actual = block.header().next_l1_msg_index;
if actual != expected {
return Err(ConsensusError::msg(format!(
"invalid block.NextL1MsgIndex: expected {expected}, got {actual}"
)));
}
let mut expected = parent_block.next_l1_msg_index;
for tx in block.body().transactions() {
if !tx.is_l1_msg() {
break;
}
let queue_index = tx.queue_index().ok_or_else(|| {
ConsensusError::msg("L1 message transaction is missing queue index")
})?;
if queue_index != expected {
return Err(ConsensusError::msg(format!(
"invalid block.NextL1MsgIndex: expected {expected}, got {queue_index}"
)));
}
expected = expected.checked_add(1).ok_or_else(|| {
ConsensusError::msg(format!(
"invalid block.NextL1MsgIndex: expected {}, got {}",
u64::MAX,
block.header().next_l1_msg_index
))
})?;
}
let actual = block.header().next_l1_msg_index;
if actual != expected {
return Err(ConsensusError::msg(format!(
"invalid block.NextL1MsgIndex: expected {expected}, got {actual}"
)));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine-tree-ext/src/payload_validator.rs` around lines 898 - 921, The
loop computing expected from parent_block.next_l1_msg_index over
block.body().transactions() currently only sets expected = queue_index + 1 and
never verifies continuity against the parent-derived expected, allowing gaps;
change the logic in the for loop that handles tx.is_l1_msg() to first compare
queue_index()? (from tx.queue_index()) with the current expected and return a
ConsensusError if they differ, then set expected = queue_index.checked_add(1)...
as before (use the same error formatting referencing
block.header().next_l1_msg_index and expected/actual), ensuring you still handle
the tx.queue_index() None case and overflow with the same ok_or_else checks.

Replaces the three positional params (parentHash, bare-number timestamp,
base64 txs) with a single AssembleL2BlockV2Params struct, mirroring V1's
engine_assembleL2Block parameter style:

- params: [{ parentHash, transactions, timestamp }] instead of three
  positional values
- transactions use the normal 0x-hex bytes encoding (drops the base64
  AssembleV2Transactions shim and its dependency)
- timestamp is a hex quantity, consistent with the other engine types

This unifies the V1/V2 wire shape and removes the base64 quirk. The
consensus client (go-ethereum authclient AssembleL2BlockV2 + morph node
retryable_client) and the cross-client contract must adopt the same struct
shape in lockstep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant