Skip to content

Feat: Sequencer Final PR#331

Open
tomatoishealthy wants to merge 8 commits into
mainfrom
feat/sequencer-final
Open

Feat: Sequencer Final PR#331
tomatoishealthy wants to merge 8 commits into
mainfrom
feat/sequencer-final

Conversation

@tomatoishealthy

@tomatoishealthy tomatoishealthy commented May 29, 2026

Copy link
Copy Markdown
Contributor

1. Purpose or design rationale of this PR

...

2. PR title

Your PR title must follow conventional commits (as we are doing squash merge for each PR), so it must start with one of the following types:

  • build: Changes that affect the build system or external dependencies (example scopes: yarn, eslint, typescript)
  • ci: Changes to our CI configuration files and scripts (example scopes: vercel, github, cypress)
  • docs: Documentation-only changes
  • feat: A new feature
  • fix: A bug fix
  • perf: A code change that improves performance
  • refactor: A code change that doesn't fix a bug, or add a feature, or improves performance
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
  • test: Adding missing tests or correcting existing tests

3. Deployment tag versioning

Has the version in params/version.go been updated?

  • This PR doesn't involve a new deployment, git tag, docker image tag, and it doesn't affect traces
  • Yes

4. Breaking change label

Does this PR have the breaking-change label?

  • This PR is not a breaking change
  • Yes

Summary by CodeRabbit

  • Bug Fixes

    • Fixed short-chain reorg handling so stale canonical hashes are consistently removed.
  • New Features

    • Added a reorg-capable L2 block commit API with optional parent-pinning for constructing blocks.
    • Client RPC updated to call the new block-commit API and pass pinned-parent parameters.
    • Enhanced validation to detect and reject blocks with tampered L1 message index values.
  • Tests

    • Added tests covering acceptance, rejection, and reorg scenarios for the new API.

allen.wu and others added 4 commits May 28, 2026 16:54
Add engine_newL2BlockV2 which relaxes the parent constraint from
"must be currentHead" to "must exist", allowing SetCanonical to
automatically handle chain reorganization internally.

- NewL2BlockV2(params, isSafe): only validates parent exists and
  block number == parent+1; reorg detection delegated to SetCanonical
- Fix reorg() canonical hash deletion start point (commonBlock.Number)
  to correctly handle short-chain reorgs where len(newChain) <= 1
- Add authclient wrapper for engine_newL2BlockV2 RPC call
- Unit tests: 6 subtests covering sequential, reorg, parent-not-found,
  wrong-number, safe-path, consecutive-reorg

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reject NewL2BlockV2 when the declared hash differs from the header hash
recomputed from the assembled block. Reject in writeBlockStateWithoutHead
when header.NextL1MsgIndex differs from the value derived from the L1
message stream. Companion changes in tendermint and morph/node.

Made-with: Cursor
…L2BlockV2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@tomatoishealthy tomatoishealthy requested a review from a team as a code owner May 29, 2026 09:42
@tomatoishealthy tomatoishealthy requested review from panos-xyz and removed request for a team May 29, 2026 09:42

@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 May 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds NewL2BlockV2 and parent-pinning for safe blocks, validates header.NextL1MsgIndex before persisting, fixes short-chain reorg canonical-hash cleanup, updates AssembleL2BlockV2/SafeL2Data JSON, and adds client RPC wiring plus tests.

Changes

L2 Reorg API and Security Validation

Layer / File(s) Summary
Canonical hash cleanup for short-chain reorgs
core/blockchain.go
BlockChain.reorg now bases the canonical-number deletion cutoff on the common ancestor or newChain[1] when applicable, fixing short-chain reorg cleanup.
L1 message index tampering protection
core/blockchain_l2.go
Adds ErrInvalidNextL1MsgIndex, fmt import, and a validation in writeBlockStateWithoutHead that compares the computed next L1 message index with block.Header().NextL1MsgIndex, logging and aborting the write post-Jade on mismatch.
SafeL2Data and AssembleL2BlockV2 JSON
eth/catalyst/api_types.go, eth/catalyst/gen_l2_sd.go, eth/catalyst/gen_l2blockv2params.go
Adds AssembleL2BlockV2Params JSON marshal/unmarshal and an optional SafeL2Data.ParentHash with generated MarshalJSON/UnmarshalJSON handling.
NewL2BlockV2 & NewSafeL2Block updates
eth/catalyst/l2_api.go
Adds rawdb import, supports caller-pinned parent selection in NewSafeL2Block, derives header.NextL1MsgIndex from canonical L1 queue when available, refactors AssembleL2BlockV2 to accept params struct, and introduces NewL2BlockV2 to build, verify, process, commit, and return executable L2 blocks by parent hash.
RPC client integration and tests
ethclient/authclient/engine.go, eth/catalyst/l2_api_test.go
Adds Client.NewL2BlockV2 RPC method and TestNewL2BlockV2 covering sequential acceptance, missing-parent and wrong-number rejection, reorg head updates with canonical-hash cleanup, NextL1MsgIndex tampering rejection, and acceptance flows.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • morph-l2/go-ethereum#274 — Prior PR that expands the L2 block v2 API surface and parameter types closely related to these changes.

Suggested Reviewers

  • panos-xyz
  • Web3Jumb0

Poem

🐇 I hop through headers, counting L1s with care,
I sniff out tamper whispers hiding in the air.
NewL2BlockV2 lands tidy, parent pinned and true,
Reorgs get cleaned, indices checked anew.
Hooray — the chain grows steady, stitched by a rabbit's cue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Feat: Sequencer Final PR' is vague and does not meaningfully describe the specific changes in this changeset, which include blockchain reorganization logic fixes, L1 message index validation, L2 block assembly refactoring, and API changes. Replace with a specific description of the main change, e.g., 'Add L1 message index validation and refactor L2 block assembly APIs' to clearly convey the primary modifications.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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/sequencer-final

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

Error: can't load config: unsupported version of the configuration: "" See https://golangci-lint.run/docs/product/migration-guide for migration instructions
The command is terminated due to an error: can't load config: unsupported version of the configuration: "" See https://golangci-lint.run/docs/product/migration-guide for migration instructions


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.

@tomatoishealthy tomatoishealthy changed the title Feat: Sequencer final Feat: Sequencer Final PR May 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@eth/catalyst/l2_api.go`:
- Around line 499-502: The verified-cache hit path returns nil for the header
causing callers to receive a nil parent; change the return in the isVerified
branch to return the block's header instead of nil so both paths return a valid
*types.Header. Specifically, in the block where api.isVerified(block.Hash()) is
true, replace the current "return nil, bc.WriteStateAndSetHead(block,
bas.receipts, bas.state, bas.procTime)" with "return block.Header(),
bc.WriteStateAndSetHead(block, bas.receipts, bas.state, bas.procTime)" so
api.isVerified, UpdateBlockProcessMetrics and bc.WriteStateAndSetHead keep
behavior but the header is non-nil.
🪄 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: 326bd641-3ae4-4db9-8889-2fe29a058223

📥 Commits

Reviewing files that changed from the base of the PR and between 01e8a42 and 5c5b433.

📒 Files selected for processing (5)
  • core/blockchain.go
  • core/blockchain_l2.go
  • eth/catalyst/l2_api.go
  • eth/catalyst/l2_api_test.go
  • ethclient/authclient/engine.go

Comment thread eth/catalyst/l2_api.go
Consolidates the safe-write API into NewSafeL2Block by adding optional
ParentHash to SafeL2Data. NewSafeL2Block now executes the block against
that parent (when supplied) and lets WriteStateAndSetHead's SetCanonical
auto-reorg the chain. Caller (derivation.deriveForce) only knows block
contents from L1 batch data — not pre-computed execution roots — so this
path is the right home for it: ProcessBlock fills StateRoot / GasUsed /
Bloom / ReceiptHash / NextL1MsgIndex into the header from the resulting
state, instead of trusting caller-supplied (zero) values.

NewL2BlockV2 drops the isSafe parameter and the v2 isSafe branch. It is
now sequencer-signed-only — caller (executor.ApplyBlockV2) supplies all
execution-result fields and ProcessBlock + ValidateState verify them.
The hash-mismatch and verifyBlock checks become unconditional. Removes
TC-05 SafeBlock test (the path it covered now lives under
TestNewSafeL2Block) and the dual-call-shape pattern in remaining tests.

* api_types.SafeL2Data: add ParentHash *common.Hash (nil = legacy
  currentHead+1 semantics; non-nil = pin parent for reorg case)
* l2_api.NewSafeL2Block: branch on ParentHash to look up parent vs
  fall back to CurrentBlock; rest of the flow (ProcessBlock with
  safe=true, header reconstruction, NextL1MsgIndex backfill from
  L1 message stream) unchanged
* l2_api.NewL2BlockV2: signature drops isSafe bool; verifyBlock and
  hash check unconditional; ProcessBlock pinned to safe=false
* authclient.NewL2BlockV2: client wrapper drops isSafe arg
* l2_api_test.go: drop TC-05 (covered by TestNewSafeL2Block); strip
  isSafe arg from remaining 7 NewL2BlockV2 calls

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
eth/catalyst/l2_api.go (1)

242-262: ⚡ Quick win

New caller-pinned parent / reorg branch is untested for safe blocks.

The params.ParentHash != nil branch (the reorg path that relies on SetCanonical to switch chains) has no test. TestNewSafeL2Block only constructs SafeL2Data without ParentHash, so only the legacy currentHead+1 path is covered. The equivalent reorg path for NewL2BlockV2 is covered by TestNewL2BlockV2, but this safe-block path is the headline feature of the PR and exercises ProcessBlock(..., true) against a non-head parent.

Consider mirroring the Reorg subtest from TestNewL2BlockV2 for NewSafeL2Block (pin an older parent and assert head switchover + stale canonical-hash cleanup).

Want me to draft a reorg subtest for NewSafeL2Block?

🤖 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 `@eth/catalyst/l2_api.go` around lines 242 - 262, Add a new "Reorg" subtest
mirroring TestNewL2BlockV2 for NewSafeL2Block: exercise the params.ParentHash !=
nil branch by pinning an older parent via the test blockchain (use the same
header hash used in TestNewL2BlockV2), call NewSafeL2Block with ParentHash set
and then call ProcessBlock(..., true) against that non-head parent, and assert
that SetCanonical was applied (head switched to the new chain) and any stale
canonical-hash entries were cleaned up; update TestNewSafeL2Block to include
this subtest and reuse the same assertions used in the Reorg subtest of
TestNewL2BlockV2 for consistency.
🤖 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.

Nitpick comments:
In `@eth/catalyst/l2_api.go`:
- Around line 242-262: Add a new "Reorg" subtest mirroring TestNewL2BlockV2 for
NewSafeL2Block: exercise the params.ParentHash != nil branch by pinning an older
parent via the test blockchain (use the same header hash used in
TestNewL2BlockV2), call NewSafeL2Block with ParentHash set and then call
ProcessBlock(..., true) against that non-head parent, and assert that
SetCanonical was applied (head switched to the new chain) and any stale
canonical-hash entries were cleaned up; update TestNewSafeL2Block to include
this subtest and reuse the same assertions used in the Reorg subtest of
TestNewL2BlockV2 for consistency.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08b4fa89-e28e-46f6-ab21-454203291caf

📥 Commits

Reviewing files that changed from the base of the PR and between 5c5b433 and eb5fbf8.

📒 Files selected for processing (5)
  • eth/catalyst/api_types.go
  • eth/catalyst/gen_l2_sd.go
  • eth/catalyst/l2_api.go
  • eth/catalyst/l2_api_test.go
  • ethclient/authclient/engine.go
✅ Files skipped from review due to trivial changes (1)
  • eth/catalyst/gen_l2_sd.go

@panos-xyz

Copy link
Copy Markdown
Contributor

Heads-up on the new NextL1MsgIndex equality check in core/blockchain_l2.go — I think it may not be back-compatible with mainnet history.

The check is:

newIndex := *queueIndex + block.NumL1MessagesProcessed(*queueIndex)
if block.Header().NextL1MsgIndex != newIndex { reject }

NumL1MessagesProcessed (core/types/block.go:491) computes lastQueueIndex - firstQueueIndex + 1, where lastQueueIndex is taken from the last L1MessageTx in the block body. This handles "skip in the middle" correctly, but it cannot account for trailing skip — i.e. queue indices skipped after the last included L1 message in the block.

Concrete case: morph mainnet block 628697 has L1 messages with queue indices 2572, 2573 in the body, but header.NextL1MsgIndex = 2575, meaning queue index 2574 was skipped after the last included message (assuming parent.NextL1MsgIndex = 2572):

  • lastQueueIndex = 2573
  • numProcessed = 2573 - 2572 + 1 = 2
  • newIndex = 2572 + 2 = 2574
  • but header.NextL1MsgIndex = 2575 → equality fails

morph-reth ran into this same case during sync earlier this year and had to relax its check from == to >= (see morph-l2/morph-reth@96eb175). If this PR ships as-is, geth nodes re-deriving mainnet history (or any future block where the sequencer chooses to skip the tail of the queue) will hit the same wall.

Two options worth considering:

  1. Relax the check to header.NextL1MsgIndex >= newIndex (matching morph-reth's current behavior). This still blocks the "decrease" attack but accepts trailing skip.
  2. Compute numProcessed from header.NextL1MsgIndex - parent.firstQueueIndex instead of from the body, then validate the body separately (block-internal queue indices must all fall within [parent.qi, header.NextL1MsgIndex) and be sequential among themselves). This preserves a strict equality semantics that's compatible with trailing skip.

Either way, the threat model in the doc-comment (signature replay tampering this field, since it's not covered by Header.Hash()) is real and worth defending against — the fix just needs to be compatible with the existing chain.

@panos-xyz

Copy link
Copy Markdown
Contributor

A few things worth a closer look:

1. NewL2BlockV2 fast-path vs declared-hash check (eth/catalyst/l2_api.go:498-526)

block, _ := api.executableDataToBlock(params)
if bas, verified := api.isVerified(block.Hash()); verified {
    return nil, bc.WriteStateAndSetHead(block, bas.receipts, bas.state, bas.procTime)
}
if (params.Hash != common.Hash{}) && block.Hash() != params.Hash { ... }

Two issues:

  • The cache lookup uses the recomputed block.Hash(), so a tampered body cannot pass through (it would just miss the cache). But the declared-hash invariant is never enforced on the cached path. If V2 is meant to require params.Hash, the check should run before the fast-path so the contract is uniform.
  • The fast-path returns nil for the header, while the slow path returns block.Header(). That breaks the API contract on what is the most common path.

Suggest moving the hash comparison above the isVerified branch and returning block.Header() from both paths.

2. NewSafeL2Block parent-pinned reorg can roll back finalized (eth/catalyst/l2_api.go:242-298)

The new params.ParentHash != nil branch accepts any known parent and goes straight to WriteStateAndSetHead. There is no check that the parent is at or above the current finalized height. If a caller passes a parent below finalized N, SetCanonical will rewrite canonical hashes while the finalized pointer still references the old chain, so eth_getBlockByNumber("finalized") ends up pointing at a non-canonical block.

The old path was implicitly safe because parent = CurrentBlock(). The new path needs an explicit guard:

  • Reject when parent.Number < bc.CurrentFinalizedBlock().Number.
  • If the safe tag falls on the old segment, update or clear it atomically.
  • Add a regression test that finalizes to N and then calls NewSafeL2Block with a parent below N.

3. Missing FirstQueueIndexNotInL2Block silently disables the new check (core/blockchain_l2.go:103-149)

queueIndex := rawdb.ReadFirstQueueIndexNotInL2Block(bc.db, block.ParentHash())
if queueIndex != nil {
    // validate NextL1MsgIndex + write new index
}

When the parent has no entry, both validation and the write are skipped, so the gap propagates: every subsequent block's parent is also missing the key, and the check stays off forever.

This is reachable on upgrade: a chain that ran on a version that didn't write FirstQueueIndexNotInL2Block will commit the first post-upgrade block in this state. The fact that the test seeds the index manually for synthetic ancestors confirms the path exists.

For post-Jade blocks I think missing parent metadata should be an error (or recovered by walking ancestors once), not silently bypassed. Otherwise an arbitrary NextL1MsgIndex lands on disk and downstream prover/queue state drifts — exactly what this PR is trying to prevent.


(2) looks like the most important one to fix before merge; (1) and (3) are smaller but worth addressing in the same pass.

@tomatoishealthy

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Walking through each point:

1. NewL2BlockV2 cache fast path

Agreed on the return mismatch — will fix:

 if bas, verified := api.isVerified(block.Hash()); verified {
     bc.UpdateBlockProcessMetrics(bas.state, bas.procTime)
-    return nil, bc.WriteStateAndSetHead(block, bas.receipts, bas.state, bas.procTime)
+    return block.Header(), bc.WriteStateAndSetHead(block, bas.receipts, bas.state, bas.procTime)
 }

I'd like to keep the declared-hash check on the slow path only, and document the invariant on the fast path:

  • api.verified is populated exclusively by this node's own AssembleL2Block via writeVerified. Every cached entry is a block this node built and fully executed itself.
  • The cache is keyed by the recomputed block.Hash(). A cache hit therefore implies the parsed body hashes to a value matching one we built — i.e., the body is byte-identical to our own block (modulo Keccak-256 collision). That's strictly stronger than params.Hash == block.Hash().
  • Any wire-level body modification changes the recomputed hash, misses the cache, and falls through to the slow path where the declared-hash check (and verifyBlock + ProcessBlock) runs.

Will add an inline comment on the cache branch making this invariant explicit.

2. NewSafeL2Block parent-pinned reorg crossing finalized

In morph the finalized pointer is advanced exclusively from L1 ZK-proof verification — it's not driven by local writes. Derivation is the only caller of the params.ParentHash != nil branch, and its cursor is always at or above local finalized:

  • L1 reorg detection runs at `--derivation.confirmations=finalized` granularity, so the L1 cursor never rewinds below L1-finality;
  • deriveForce only ever targets batches surfaced through that cursor, so the parent it pins is always >= local finalized.

Given that, SetCanonical operates strictly on the segment above finalized and the finalized tag stays canonical. Will document this precondition in the NewSafeL2Block doc comment so a future caller doesn't violate it implicitly.

Happy to add the explicit parent.Number >= CurrentFinalizedBlock().Number guard if you'd prefer defense-in-depth; my hesitation is that it makes the API less general for a precondition the only caller already structurally satisfies.

3. Missing FirstQueueIndexNotInL2Block silently disables the check

Traced every writer and reader of the key:

  • Genesis is unconditionally seeded to 0 in three places: core/genesis.go:461, core/blockchain.go:283 (runs on every NewBlockChain startup), and core/blockchain.go:725 (ResetWithGenesisBlock).
  • Every block committed via WriteStateAndSetHead -> writeBlockStateWithoutHead writes its own entry.
  • No Delete accessor exists for this key.

The only callers that produce a block without the entry are bc.InsertChain / bc.InsertHeaderChain (they use writeBlockWithState, which doesn't write queueIndex). Both are upstream geth's standard P2P sync paths and morph doesn't exercise them — the consensus layer drives block insertion via tendermint blocksync, not eth's block fetcher.

The "header-only ancestors" inline comment was carried over from upstream and doesn't reflect any actual morph path; will replace it with the real invariant.

If we ever start exercising those upstream sync paths, your hard-error / walk-ancestors approach becomes the right move. As-is, nil is unreachable.

panos-xyz
panos-xyz previously approved these changes Jun 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
eth/catalyst/api_types.go (1)

147-160: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

SafeL2Data still omits the canonical L1 queue cursor.

NewSafeL2Block now has to reconstruct header.NextL1MsgIndex from local block contents because this payload does not carry it. That breaks on valid batches with trailing skipped queue indices, and it also turns missing parent queue metadata into a fail-open unset header field. Please carry the authoritative next index (or equivalent total popped value) in SafeL2Data instead of deriving it from NumL1MessagesProcessed.

🤖 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 `@eth/catalyst/api_types.go` around lines 147 - 160, SafeL2Data currently omits
the canonical L1 queue cursor, forcing NewSafeL2Block to reconstruct
header.NextL1MsgIndex from local contents (NumL1MessagesProcessed) which breaks
on batches with trailing skipped indices; add an explicit field to SafeL2Data
(e.g. NextL1MsgIndex or TotalPopped) that carries the authoritative next
index/total-popped value, surface it through JSON tags like the other fields,
and update NewSafeL2Block to read header.NextL1MsgIndex from this new SafeL2Data
field instead of deriving it from NumL1MessagesProcessed so skipped indices and
missing parent metadata are handled correctly.
eth/catalyst/l2_api.go (2)

509-526: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce params.Hash before the verified-cache fast path.

A cache hit currently skips the declared-hash invariant entirely. Any request that reproduces a cached block body will be committed even when it carries a different non-zero params.Hash, so the anti-tamper/signature-replay check only runs on cache misses.

💡 Minimal fix
+	// Defense against signature-replay: ensure the declared block hash matches the
+	// hash recomputed from the canonical fields. Without this check, an attacker
+	// could keep params.Hash from a legitimately signed block while tampering with
+	// other content fields, and have the signature verification on the consensus
+	// side accept the tampered body. This is the last line of defense before the
+	// block is committed to the chain; the tendermint side performs the same check
+	// earlier in VerifyBlockSignature to reject tampered blocks before propagation.
+	if (params.Hash != common.Hash{}) && block.Hash() != params.Hash {
+		log.Error("NewL2BlockV2 hash mismatch (signature replay or tampering)",
+			"declared", params.Hash.Hex(), "computed", block.Hash().Hex(), "number", params.Number)
+		return nil, fmt.Errorf("block hash mismatch: declared %s, computed %s",
+			params.Hash.Hex(), block.Hash().Hex())
+	}
+
 	if bas, verified := api.isVerified(block.Hash()); verified {
 		bc.UpdateBlockProcessMetrics(bas.state, bas.procTime)
 		return block.Header(), bc.WriteStateAndSetHead(block, bas.receipts, bas.state, bas.procTime)
 	}
-
-	// Defense against signature-replay: ensure the declared block hash matches the
-	// hash recomputed from the canonical fields. Without this check, an attacker
-	// could keep params.Hash from a legitimately signed block while tampering with
-	// other content fields, and have the signature verification on the consensus
-	// side accept the tampered body. This is the last line of defense before the
-	// block is committed to the chain; the tendermint side performs the same check
-	// earlier in VerifyBlockSignature to reject tampered blocks before propagation.
-	if (params.Hash != common.Hash{}) && block.Hash() != params.Hash {
-		log.Error("NewL2BlockV2 hash mismatch (signature replay or tampering)",
-			"declared", params.Hash.Hex(), "computed", block.Hash().Hex(), "number", params.Number)
-		return nil, fmt.Errorf("block hash mismatch: declared %s, computed %s",
-			params.Hash.Hex(), block.Hash().Hex())
-	}
🤖 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 `@eth/catalyst/l2_api.go` around lines 509 - 526, The declared-hash anti-tamper
check must run before any cache-fast-path return: currently api.isVerified(...)
can return a cached bas and cause an early return without validating
params.Hash, allowing signature-replay; ensure that the (params.Hash !=
common.Hash{}) && block.Hash() != params.Hash check is executed before accepting
a cached result or, at minimum, before returning from the verified-cache branch
(the branch that calls bc.UpdateBlockProcessMetrics(bas.state, bas.procTime) and
bc.WriteStateAndSetHead). Concretely, move or duplicate the declared-hash
comparison so that the code compares params.Hash to block.Hash() (using
block.Hash().Hex() for logging) prior to returning on a successful
api.isVerified(...) hit, referencing api.isVerified, bas/verified, block.Hash(),
params.Hash, UpdateBlockProcessMetrics and WriteStateAndSetHead to locate where
to insert the check.

242-255: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject pinned parents below finalized.

This branch accepts any known parent hash and then canonicalizes through WriteStateAndSetHead. If that parent sits below bc.CurrentFinalizedBlock(), a caller can rewrite canonical history under the finalized boundary and leave finalized queries pointing at a non-canonical chain. Add an explicit finalized-height guard before processing.

🤖 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 `@eth/catalyst/l2_api.go` around lines 242 - 255, The branch handling
params.ParentHash must reject parent headers at or below the finalized boundary
to prevent rewriting finalized history: after obtaining parentHeader via
bc.GetHeaderByHash(*params.ParentHash) (inside the params.ParentHash != nil
branch) add a check against bc.CurrentFinalizedBlock()—compare
parentHeader.Number.Uint64() to bc.CurrentFinalizedBlock().Number.Uint64() and
return an error (e.g. "parent below finalized") if the parent is at or below the
finalized height—do this before proceeding to bc.GetBlock(...) and before any
call to WriteStateAndSetHead to ensure finalized guards are enforced.
🤖 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.

Outside diff comments:
In `@eth/catalyst/api_types.go`:
- Around line 147-160: SafeL2Data currently omits the canonical L1 queue cursor,
forcing NewSafeL2Block to reconstruct header.NextL1MsgIndex from local contents
(NumL1MessagesProcessed) which breaks on batches with trailing skipped indices;
add an explicit field to SafeL2Data (e.g. NextL1MsgIndex or TotalPopped) that
carries the authoritative next index/total-popped value, surface it through JSON
tags like the other fields, and update NewSafeL2Block to read
header.NextL1MsgIndex from this new SafeL2Data field instead of deriving it from
NumL1MessagesProcessed so skipped indices and missing parent metadata are
handled correctly.

In `@eth/catalyst/l2_api.go`:
- Around line 509-526: The declared-hash anti-tamper check must run before any
cache-fast-path return: currently api.isVerified(...) can return a cached bas
and cause an early return without validating params.Hash, allowing
signature-replay; ensure that the (params.Hash != common.Hash{}) && block.Hash()
!= params.Hash check is executed before accepting a cached result or, at
minimum, before returning from the verified-cache branch (the branch that calls
bc.UpdateBlockProcessMetrics(bas.state, bas.procTime) and
bc.WriteStateAndSetHead). Concretely, move or duplicate the declared-hash
comparison so that the code compares params.Hash to block.Hash() (using
block.Hash().Hex() for logging) prior to returning on a successful
api.isVerified(...) hit, referencing api.isVerified, bas/verified, block.Hash(),
params.Hash, UpdateBlockProcessMetrics and WriteStateAndSetHead to locate where
to insert the check.
- Around line 242-255: The branch handling params.ParentHash must reject parent
headers at or below the finalized boundary to prevent rewriting finalized
history: after obtaining parentHeader via bc.GetHeaderByHash(*params.ParentHash)
(inside the params.ParentHash != nil branch) add a check against
bc.CurrentFinalizedBlock()—compare parentHeader.Number.Uint64() to
bc.CurrentFinalizedBlock().Number.Uint64() and return an error (e.g. "parent
below finalized") if the parent is at or below the finalized height—do this
before proceeding to bc.GetBlock(...) and before any call to
WriteStateAndSetHead to ensure finalized guards are enforced.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3acc122f-8988-40a9-8a87-03d59bd30b50

📥 Commits

Reviewing files that changed from the base of the PR and between e0a2cd3 and fe02cc1.

📒 Files selected for processing (4)
  • eth/catalyst/api_types.go
  • eth/catalyst/gen_l2blockv2params.go
  • eth/catalyst/l2_api.go
  • ethclient/authclient/engine.go
✅ Files skipped from review due to trivial changes (1)
  • eth/catalyst/gen_l2blockv2params.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • ethclient/authclient/engine.go

panos-xyz added a commit to morph-l2/morph-reth that referenced this pull request Jun 9, 2026
…l1_msg_index hardening) (#124)

* feat(engine-api): add reorg-capable L2 engine V2 API for centralized 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.

* feat(consensus): enforce exact next_l1_msg_index from Jade onward

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.

* refactor(engine-api): use a struct param for assembleL2BlockV2

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.

* fix(consensus): reject L1 message forward-skip in parent-aware next_l1_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.

* test(payload-types): assert assembleL2BlockV2 decodes explicit null timestamp

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.

* fix(consensus): align L1 forward-skip handling with geth

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
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.

Reorg integration based on L1 batches Sequencer HA implementation Sequencer verification and rotation

2 participants