Skip to content

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

Merged
panos-xyz merged 7 commits into
mainfrom
feat/centralized-sequencer-engine-v2
Jun 9, 2026
Merged

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

Conversation

@panos-xyz

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

Copy link
Copy Markdown
Contributor

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

  • New Features

    • v2 engine endpoints to assemble and import blocks by explicit parent-hash
    • Parent-hash support in safe L2 payloads and new assemble-by-parent parameters
  • Bug Fixes

    • Clarified L1 message index error text for clearer diagnostics
    • Fork-dependent L1 message-index validation: stricter exactness post‑Jade, lenient pre‑Jade behavior retained
  • Tests

    • Added consensus tests around the Jade boundary, engine integration tests for v2 flows, and unit tests for new params/serde

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.

@claude claude Bot left a comment

Copy link
Copy Markdown

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

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99df58c8-54ec-4ee7-a67f-5448c5d7c977

📥 Commits

Reviewing files that changed from the base of the PR and between 609216f and 6e7a152.

📒 Files selected for processing (1)
  • crates/consensus/src/validation.rs

📝 Walkthrough

Walkthrough

Adds Jade-gated exactness for header.next_l1_msg_index, updates error text/tests, introduces parent-pinned engine v2 assemble/import APIs and RPCs, extends payload types with optional parent_hash, and adds post-execution parent-aware validation.

Changes

Jade L1 Validation & Engine V2 APIs

Layer / File(s) Summary
L1 Message Error Text Update
crates/consensus/src/error.rs
InvalidNextL1MessageIndex display string changed to invalid block.NextL1MsgIndex and unit test added.
Fork-Dependent L1 Message Header Index Validation
crates/consensus/src/validation.rs
validate_l1_messages_in_block gains is_jade flag; Jade enforces next_l1_msg_index == expected, pre-Jade allows >= expected. Docs, logic, and many unit tests updated.
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
Adds AssembleL2BlockV2Params (parent_hash, transactions, timestamp) and SafeL2Data.parent_hash: Option<B256> with camelCase serde and tests.
Engine API V2 Trait Methods
crates/engine-api/src/api.rs
Adds assemble_l2_block_v2(AssembleL2BlockV2Params) -> ExecutableL2Data and new_l2_block_v2(ExecutableL2Data) -> MorphHeader to MorphL2EngineApi.
RPC V2 Endpoints & Handlers
crates/engine-api/src/rpc.rs
Adds engine_assembleL2BlockV2 and engine_newL2BlockV2 RPC methods with v2 tracing; handlers delegate to engine API and map errors.
Engine API V2 Implementation & Payload Builder Refactoring
crates/engine-api/src/builder.rs
Implements v2 methods in RealMorphL2EngineApi, adds parent_override: Option<B256> to build_l2_payload, resolves pinned parent/timestamp, adjusts assemble_l2_block/new_safe_l2_block, and updates forkchoice state tag resolution.
Post-Execution Parent-Aware L1 Index Validation
crates/engine-tree-ext/src/payload_validator.rs
Adds validate_next_l1_msg_index_against_parent (Jade-conditional) executed post-execution after header-vs-parent validation; refines generic constraints to Morph-specific primitives.
Formatting Updates
crates/node/src/validator.rs, crates/node/tests/it/engine.rs
Minor reformatting of where-clauses and imports (no behavioral changes).
Consensus & Engine Integration Tests
crates/node/tests/it/consensus.rs, crates/node/tests/it/engine.rs
Adds consensus integration tests for Jade/pre-Jade next_l1_msg_index behavior and engine integration tests for v2 assemble/import and reorg/safe-parent flows.
sequenceDiagram
  participant Client
  participant RpcHandler
  participant EngineAPI
  participant PayloadBuilder
  participant EngineTree
  participant Storage

  Client->>RpcHandler: engine_assembleL2BlockV2(params)
  RpcHandler->>EngineAPI: assemble_l2_block_v2(params)
  EngineAPI->>PayloadBuilder: build_l2_payload(parent_override=parent_hash)
  PayloadBuilder->>Storage: lookup parent by hash
  PayloadBuilder-->>EngineAPI: ExecutableL2Data
  Client->>RpcHandler: engine_newL2BlockV2(data)
  RpcHandler->>EngineAPI: new_l2_block_v2(data)
  EngineAPI->>EngineTree: import_l2_block_via_engine (resolve parent from data.parent_hash)
  EngineTree->>Storage: apply block / update head
  EngineAPI-->>RpcHandler: MorphHeader
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • chengwenxi
  • anylots
  • tomatoishealthy

Poem

"🐇 I hopped through code with parent_hash in sight,
Jade asked for exactness and held it tight,
v2 calls assemble, then import to reorg,
Tests and errors chant in the moonlit log,
The rabbit waves a flag — green build tonight!"

🚥 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 title accurately captures the main focus: adding V2 engine API with reorg support and next_l1_msg_index hardening for centralized sequencer, which is the primary change across all modified files.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 thread crates/engine-tree-ext/src/payload_validator.rs
panos-xyz added 3 commits June 5, 2026 19:47
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.
…1_msg_index check

The Jade parent-aware validator derived `expected` from each leading L1
message's queue_index + 1 but never checked that the queue_index continued
the parent's stream. A block whose first L1 message skipped ahead (e.g.
parent.next=2, first queue=5, header.next=6) passed: the in-block messages
were contiguous and header.next == last+1, so neither the stateless
consensus check nor the trailing-skip check caught the silently dropped
queue indices 2,3,4. go-ethereum avoids this by deriving the index from its
canonical L1 queue; reth must check first == parent.next explicitly.

Now each leading L1 message must equal the running expected index (first ==
parent.next, rest contiguous); pre-Jade behavior is unchanged. Adds post-Jade
reject + pre-Jade allow e2e tests that craft a self-consistent block (queue
index does not affect execution, so the state root stays valid, isolating
the continuity violation).

Reported by CodeRabbit on PR #124.
…imestamp

geth's gencodec MarshalJSON always emits the `timestamp` field, writing
`null` (not omitting it) when the sequencer passes no timestamp. Lock in
that the reth server decodes that production payload to None rather than
erroring, guarding the geth<->reth wire contract for the struct param.
panos-xyz and others added 2 commits June 8, 2026 19:45
Match go-ethereum PR #331 by deriving post-Jade NextL1MsgIndex from the last leading L1 queue index, so forward skips are counted as processed while trailing skips remain rejected.

Constraint: Preserve geth cross-client consensus behavior
Confidence: high
Scope-risk: narrow
@panos-xyz panos-xyz merged commit ddc4175 into main Jun 9, 2026
11 of 12 checks passed
@panos-xyz panos-xyz deleted the feat/centralized-sequencer-engine-v2 branch June 9, 2026 03:20
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.

2 participants