Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8e559f6
feat(derivation): add batch verification and L1 reorg detection
Mar 10, 2026
0c48f5c
fix(derivation): address code review feedback
Mar 10, 2026
f9a2578
fix(derivation): address round-2 review feedback
Mar 10, 2026
675e8fb
fix(derivation): prevent derivation height advancing when L1 block re…
Mar 10, 2026
fb3e1ce
fix(derivation): address round-3 review — halt on mismatch, optimize …
Mar 10, 2026
3c960cf
feat(derivation): add halted gauge metric for production alerting
Mar 10, 2026
ca81ed7
fix(derivation): guard against nil lastHeader panic on empty batch, f…
Mar 10, 2026
182b8d1
fix(derivation): add nil check for lastHeader on re-derive path, docu…
Mar 10, 2026
46edfa1
docs(derivation): add language tags to fenced code blocks (MD040)
Mar 10, 2026
a45705c
refactor(derivation): split new logic into verify.go and reorg.go
Mar 11, 2026
4380f96
Merge branch 'origin/main' into feat/derivation-batch-verification-re…
Apr 29, 2026
b178497
Merge branch 'main' into feat/derivation-batch-verification-reorg-det…
curryxbo May 7, 2026
3e49457
refactor(node): remove validator/challenge bypass per SPEC-005
May 8, 2026
4822571
feat(derivation): SPEC-005 P2 — state model + head anchors + dual-cha…
May 8, 2026
2ed4e8c
feat(derivation): SPEC-005 P3 — path B / rollback executor / admin RP…
May 8, 2026
3848b87
docs(derivation): drop DERIVATION_REFACTOR.md — superseded by morph-s…
May 9, 2026
a2a4390
Merge branch 'main' into feat/state-tiering-and-rollback
curryxbo May 9, 2026
308dfcb
feat(derivation): SPEC-005 §3.1 dual-channel main loop + safe/finaliz…
May 9, 2026
4158e4e
Revert "feat(derivation): SPEC-005 §3.1 dual-channel main loop + safe…
May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions node/cmd/node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import (
"morph-l2/node/sequencer/mock"
"morph-l2/node/sync"
"morph-l2/node/types"
"morph-l2/node/validator"
)

func main() {
Expand Down Expand Up @@ -99,10 +98,6 @@ func L2NodeMain(ctx *cli.Context) error {
if err != nil {
return fmt.Errorf("failed to create syncer, error: %v", err)
}
validatorCfg := validator.NewConfig()
if err := validatorCfg.SetCliContext(ctx); err != nil {
return fmt.Errorf("validator set cli context error: %v", err)
}
l1Client, err := ethclient.Dial(derivationCfg.L1.Addr)
if err != nil {
return fmt.Errorf("dial l1 node error:%v", err)
Expand All @@ -111,12 +106,7 @@ func L2NodeMain(ctx *cli.Context) error {
if err != nil {
return fmt.Errorf("NewRollup error:%v", err)
}
vt, err := validator.NewValidator(validatorCfg, rollup, nodeConfig.Logger)
if err != nil {
return fmt.Errorf("new validator client error: %v", err)
}

dvNode, err = derivation.NewDerivationClient(context.Background(), derivationCfg, syncer, store, vt, rollup, nodeConfig.Logger)
dvNode, err = derivation.NewDerivationClient(context.Background(), derivationCfg, syncer, store, rollup, nodeConfig.Logger)
if err != nil {
return fmt.Errorf("new derivation client error: %v", err)
}
Expand Down
12 changes: 11 additions & 1 deletion node/db/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ var (
L1MessagePrefix = []byte("l1")
BatchBlockNumberPrefix = []byte("batch")

derivationL1HeightKey = []byte("LastDerivationL1Height")
derivationL1HeightKey = []byte("LastDerivationL1Height")
derivationL1BlockPrefix = []byte("derivL1Block")

// SPEC-005: safe / finalized head anchors. Each value is an RLP-encoded HeadAnchor.
derivationSafeHeadKey = []byte("DerivationSafeHead")
derivationFinalizedHeadKey = []byte("DerivationFinalizedHead")
)

// encodeBlockNumber encodes an L1 enqueue index as big endian uint64
Expand All @@ -26,3 +31,8 @@ func L1MessageKey(enqueueIndex uint64) []byte {
func BatchBlockNumberKey(batchIndex uint64) []byte {
return append(BatchBlockNumberPrefix, encodeEnqueueIndex(batchIndex)...)
}

// DerivationL1BlockKey = derivationL1BlockPrefix + l1Height (uint64 big endian)
func DerivationL1BlockKey(l1Height uint64) []byte {
return append(derivationL1BlockPrefix, encodeEnqueueIndex(l1Height)...)
}
114 changes: 114 additions & 0 deletions node/db/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,120 @@ func (s *Store) WriteSyncedL1Messages(messages []types.L1Message, latestSynced u
return batch.Write()
}

// DerivationL1Block stores L1 block info for reorg detection.
type DerivationL1Block struct {
Number uint64
Hash [32]byte
}

// DerivationHeadAnchor pairs an L2 head with the L1 origin that justifies its
// current safety stage. Persisted form of derivation.HeadAnchor (kept in this
// package to avoid an import cycle between db and derivation).
type DerivationHeadAnchor struct {
L2Number uint64
L2Hash [32]byte
L1Number uint64
L1Hash [32]byte
}

func (s *Store) writeHeadAnchor(key []byte, anchor *DerivationHeadAnchor) {
data, err := rlp.EncodeToBytes(anchor)
if err != nil {
panic(fmt.Sprintf("failed to RLP encode DerivationHeadAnchor, err: %v", err))
}
if err := s.db.Put(key, data); err != nil {
panic(fmt.Sprintf("failed to write DerivationHeadAnchor, err: %v", err))
}
}

func (s *Store) readHeadAnchor(key []byte) *DerivationHeadAnchor {
data, err := s.db.Get(key)
if err != nil && !isNotFoundErr(err) {
panic(fmt.Sprintf("failed to read DerivationHeadAnchor, err: %v", err))
}
if len(data) == 0 {
return nil
}
var anchor DerivationHeadAnchor
if err := rlp.DecodeBytes(data, &anchor); err != nil {
panic(fmt.Sprintf("invalid DerivationHeadAnchor RLP, err: %v", err))
}
return &anchor
}

// WriteDerivationSafeHead persists the safe-stage L2 head together with its L1 origin.
func (s *Store) WriteDerivationSafeHead(anchor *DerivationHeadAnchor) {
s.writeHeadAnchor(derivationSafeHeadKey, anchor)
}

// ReadDerivationSafeHead reads the safe-stage L2 head, or nil if unset.
func (s *Store) ReadDerivationSafeHead() *DerivationHeadAnchor {
return s.readHeadAnchor(derivationSafeHeadKey)
}

// WriteDerivationFinalizedHead persists the finalized-stage L2 head together with its L1 origin.
func (s *Store) WriteDerivationFinalizedHead(anchor *DerivationHeadAnchor) {
s.writeHeadAnchor(derivationFinalizedHeadKey, anchor)
}

// ReadDerivationFinalizedHead reads the finalized-stage L2 head, or nil if unset.
func (s *Store) ReadDerivationFinalizedHead() *DerivationHeadAnchor {
return s.readHeadAnchor(derivationFinalizedHeadKey)
}

func (s *Store) WriteDerivationL1Block(block *DerivationL1Block) {
data, err := rlp.EncodeToBytes(block)
if err != nil {
panic(fmt.Sprintf("failed to RLP encode DerivationL1Block, err: %v", err))
}
if err := s.db.Put(DerivationL1BlockKey(block.Number), data); err != nil {
panic(fmt.Sprintf("failed to write DerivationL1Block, err: %v", err))
}
}

func (s *Store) ReadDerivationL1Block(l1Height uint64) *DerivationL1Block {
data, err := s.db.Get(DerivationL1BlockKey(l1Height))
if err != nil && !isNotFoundErr(err) {
panic(fmt.Sprintf("failed to read DerivationL1Block, err: %v", err))
}
if len(data) == 0 {
return nil
}
var block DerivationL1Block
if err := rlp.DecodeBytes(data, &block); err != nil {
panic(fmt.Sprintf("invalid DerivationL1Block RLP, err: %v", err))
}
return &block
}

func (s *Store) ReadDerivationL1BlockRange(from, to uint64) []*DerivationL1Block {
var blocks []*DerivationL1Block
for h := from; h <= to; h++ {
b := s.ReadDerivationL1Block(h)
if b != nil {
blocks = append(blocks, b)
}
}
return blocks
}

func (s *Store) DeleteDerivationL1BlocksFrom(height uint64) {
batch := s.db.NewBatch()
for h := height; ; h++ {
key := DerivationL1BlockKey(h)
has, err := s.db.Has(key)
if err != nil || !has {
break
}
if err := batch.Delete(key); err != nil {
panic(fmt.Sprintf("failed to delete DerivationL1Block at %d, err: %v", h, err))
}
}
if err := batch.Write(); err != nil {
panic(fmt.Sprintf("failed to write batch delete for DerivationL1Blocks, err: %v", err))
}
}
Comment on lines +256 to +271
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Iterate L1 blocks via prefix scan and panic on Has errors.

Two related concerns in DeleteDerivationL1BlocksFrom:

  1. if err != nil || !has { break } swallows DB errors from Has and treats them as "not found", silently terminating deletion. This breaks consistency with the rest of Store (and the established codebase pattern), which panics on non-NotFound errors.
  2. The for-loop assumes records are perfectly contiguous from height upward. A previous interrupted recordL1Blocks (e.g. mid-range header fetch failure) can leave a hole; deletion will stop at the first gap, leaving stale block records at higher heights. When a reorg rewinds latestDerivationL1Height below those stale heights, they never get overwritten and can mislead future detectReorg comparisons if the chain re-advances.

Prefer iterating the derivation-L1-block key prefix with db.NewIterator(derivationL1BlockPrefix, …) (or pass in an explicit upper bound from ReadLatestDerivationL1Height) and treat any Has/iterator error as fatal.

🛡️ Sketch of safer implementation
 func (s *Store) DeleteDerivationL1BlocksFrom(height uint64) {
-	batch := s.db.NewBatch()
-	for h := height; ; h++ {
-		key := DerivationL1BlockKey(h)
-		has, err := s.db.Has(key)
-		if err != nil || !has {
-			break
-		}
-		if err := batch.Delete(key); err != nil {
-			panic(fmt.Sprintf("failed to delete DerivationL1Block at %d, err: %v", h, err))
-		}
-	}
-	if err := batch.Write(); err != nil {
-		panic(fmt.Sprintf("failed to write batch delete for DerivationL1Blocks, err: %v", err))
-	}
+	batch := s.db.NewBatch()
+	it := s.db.NewIterator(derivationL1BlockPrefix, encodeBlockNumber(height))
+	defer it.Release()
+	for it.Next() {
+		if err := batch.Delete(it.Key()); err != nil {
+			panic(fmt.Sprintf("failed to delete DerivationL1Block, err: %v", err))
+		}
+	}
+	if err := it.Error(); err != nil {
+		panic(fmt.Sprintf("iterator error deleting DerivationL1Blocks, err: %v", err))
+	}
+	if err := batch.Write(); err != nil {
+		panic(fmt.Sprintf("failed to write batch delete for DerivationL1Blocks, err: %v", err))
+	}
 }

(Adjust encodeBlockNumber / iterator helper to whatever exists in node/db/keys.go.)

Based on learnings: "DB write methods … use a panic-on-error pattern instead of returning errors. This panic behavior is intentional and consistent across the codebase".

🤖 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 `@node/db/store.go` around lines 256 - 271, DeleteDerivationL1BlocksFrom
currently uses Has in a contiguous-height loop which swallows DB errors and
stops at the first missing height, leaving stale records; instead, use a prefix
iterator (s.db.NewIterator with the derivation-L1-block prefix or an explicit
upper bound from ReadLatestDerivationL1Height) to walk all DerivationL1BlockKey
entries starting at the encoded height and call batch.Delete for each key, and
treat any iterator/Has error as fatal by panicking (consistent with the
codebase) before writing the batch; update references to DerivationL1BlockKey,
s.db.NewIterator, and ReadLatestDerivationL1Height accordingly.


func isNotFoundErr(err error) bool {
return err.Error() == leveldb.ErrNotFound.Error() || err.Error() == types.ErrMemoryDBNotFound.Error()
}
67 changes: 67 additions & 0 deletions node/derivation/admin_rpc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package derivation

import (
"context"
"errors"
"fmt"

"github.com/morph-l2/go-ethereum/common"
)

// SPEC-005 §3.6 / §5.1 admin RPC: operator-triggered rollback entry point.
//
// Exposes the ability to roll the local L2 chain back to a target (number,
// hash) pair. Per tech-design §3.3, the rollback **must** match by hash —
// rolling back to a number alone is unsafe because it can silently land
// on a different fork after a reorg.
//
// Authentication and the concrete wire-up (registering this with the
// node's existing admin RPC server) are blocked on SPEC-005 §8 #2:
// - dev-mode only (current default below)
// - operator-only via a node-local UNIX socket
// - signed multisig request
// All three options keep the same public method signature.

// AdminAPI groups operator-only RPC entry points exposed by the
// derivation pipeline.
//
// TODO(spec-005-admin-rpc): wire this into morph/node/cmd/node/main.go
// once SPEC-005 §8 #2 (auth) is decided. Until then, AdminAPI is
// constructible but unregistered; tests can still exercise it directly.
type AdminAPI struct {
d *Derivation
}

// NewAdminAPI returns the operator-only API surface bound to the given
// Derivation instance.
func NewAdminAPI(d *Derivation) *AdminAPI {
return &AdminAPI{d: d}
}

// SetL2Head requests a rollback of the local L2 chain to the supplied
// (number, hash). The implementation must verify that hash matches the
// local block at the given number before delegating to the rollback
// executor (SPEC-005 §5.1 / §5.2).
//
// Returns an error if:
// - the (number, hash) does not match the local canonical chain;
// - the target is below finalized_head (SPEC-005 §3.6: halted);
// - the rollback executor itself fails (the node enters halted).
func (a *AdminAPI) SetL2Head(ctx context.Context, number uint64, hash common.Hash) error {
if a == nil || a.d == nil {
return errors.New("admin API not bound to a derivation instance")
}

if err := a.d.checkRollbackBoundary(number); err != nil {
return err
}

// TODO(spec-005-admin-rpc):
// 1. Authenticate the request (SPEC-005 §8 #2).
// 2. Verify hash matches local block at `number` via l2Client.
// 3. Acquire sequencerMutex.AcquireRollback() / defer release.
// 4. Call into rollbackLocalChain(number) — currently returns
// "not implemented" because the underlying go-ethereum
// hash-matched SetHead interface (SPEC-005 §8 #4) is not finalised.
return fmt.Errorf("admin SetL2Head not yet implemented (number=%d, hash=%s)", number, hash.Hex())
}
8 changes: 8 additions & 0 deletions node/derivation/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const (

// DefaultLogProgressInterval is the frequency at which we log progress.
DefaultLogProgressInterval = time.Second * 10

// DefaultReorgCheckDepth is the number of recent L1 blocks to check for reorgs.
DefaultReorgCheckDepth = uint64(64)
)

type Config struct {
Expand All @@ -41,6 +44,7 @@ type Config struct {
PollInterval time.Duration `json:"poll_interval"`
LogProgressInterval time.Duration `json:"log_progress_interval"`
FetchBlockRange uint64 `json:"fetch_block_range"`
ReorgCheckDepth uint64 `json:"reorg_check_depth"`
MetricsPort uint64 `json:"metrics_port"`
MetricsHostname string `json:"metrics_hostname"`
MetricsServerEnable bool `json:"metrics_server_enable"`
Expand All @@ -54,6 +58,7 @@ func DefaultConfig() *Config {
PollInterval: DefaultPollInterval,
LogProgressInterval: DefaultLogProgressInterval,
FetchBlockRange: DefaultFetchBlockRange,
ReorgCheckDepth: DefaultReorgCheckDepth,
L2: new(types.L2Config),
}
}
Expand Down Expand Up @@ -109,6 +114,9 @@ func (c *Config) SetCliContext(ctx *cli.Context) error {
return errors.New("invalid fetchBlockRange")
}
}
if ctx.GlobalIsSet(flags.DerivationReorgCheckDepth.Name) {
c.ReorgCheckDepth = ctx.GlobalUint64(flags.DerivationReorgCheckDepth.Name)
}

l2EthAddr := ctx.GlobalString(flags.L2EthAddr.Name)
l2EngineAddr := ctx.GlobalString(flags.L2EngineAddr.Name)
Expand Down
11 changes: 11 additions & 0 deletions node/derivation/database.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package derivation

import (
"morph-l2/node/db"
"morph-l2/node/sync"
)

Expand All @@ -12,8 +13,18 @@ type Database interface {

type Reader interface {
ReadLatestDerivationL1Height() *uint64
ReadDerivationL1Block(l1Height uint64) *db.DerivationL1Block
ReadDerivationL1BlockRange(from, to uint64) []*db.DerivationL1Block
// SPEC-005: safe / finalized head anchors.
ReadDerivationSafeHead() *db.DerivationHeadAnchor
ReadDerivationFinalizedHead() *db.DerivationHeadAnchor
}

type Writer interface {
WriteLatestDerivationL1Height(latest uint64)
WriteDerivationL1Block(block *db.DerivationL1Block)
DeleteDerivationL1BlocksFrom(height uint64)
// SPEC-005: safe / finalized head anchors.
WriteDerivationSafeHead(anchor *db.DerivationHeadAnchor)
WriteDerivationFinalizedHead(anchor *db.DerivationHeadAnchor)
}
Loading
Loading