From 8e559f60378781dcbd9b38ebfee271a8ae207496 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 18:28:33 +0800 Subject: [PATCH 01/26] feat(derivation): add batch verification and L1 reorg detection Replace the challenge mechanism with a batch-data-as-source-of-truth model: - When local L2 blocks don't match L1 batch data, rollback and re-derive from L1 instead of issuing a state challenge - Add L1 reorg detection for non-finalized confirmation modes (latest/safe) by tracking L1 block hashes and comparing on each derivation loop - L1 reorg only triggers DB cleanup and re-derivation; L2 rollback is only triggered when batch data comparison actually fails Key changes: - Remove validator/challenge dependency from derivation - Add verifyBlockContext() and verifyBatchRoots() for batch data comparison - Add detectReorg() with configurable check depth (default 64 blocks) - Add rollbackLocalChain() stub (TODO: geth SetHead API integration) - Add L1 block hash tracking in DB for reorg detection - Add metrics: l1_reorg_detected_total, l2_rollback_total, block_mismatch_total - Add --derivation.reorgCheckDepth CLI flag Made-with: Cursor --- node/cmd/node/main.go | 11 +- node/db/keys.go | 8 +- node/db/store.go | 61 ++++++ node/derivation/DERIVATION_REFACTOR.md | 154 ++++++++++++++ node/derivation/config.go | 8 + node/derivation/database.go | 5 + node/derivation/derivation.go | 280 ++++++++++++++++++++----- node/derivation/metrics.go | 45 +++- node/flags/flags.go | 8 + 9 files changed, 512 insertions(+), 68 deletions(-) create mode 100644 node/derivation/DERIVATION_REFACTOR.md diff --git a/node/cmd/node/main.go b/node/cmd/node/main.go index 2a71f2a28..2ef1bdbed 100644 --- a/node/cmd/node/main.go +++ b/node/cmd/node/main.go @@ -24,7 +24,6 @@ import ( "morph-l2/node/sequencer/mock" "morph-l2/node/sync" "morph-l2/node/types" - "morph-l2/node/validator" ) var keyConverterCmd = cli.Command{ @@ -94,10 +93,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) @@ -106,12 +101,8 @@ 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) } diff --git a/node/db/keys.go b/node/db/keys.go index b0d50ddcd..2101a8814 100644 --- a/node/db/keys.go +++ b/node/db/keys.go @@ -7,7 +7,8 @@ var ( L1MessagePrefix = []byte("l1") BatchBlockNumberPrefix = []byte("batch") - derivationL1HeightKey = []byte("LastDerivationL1Height") + derivationL1HeightKey = []byte("LastDerivationL1Height") + derivationL1BlockPrefix = []byte("derivL1Block") ) // encodeBlockNumber encodes an L1 enqueue index as big endian uint64 @@ -26,3 +27,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)...) +} diff --git a/node/db/store.go b/node/db/store.go index 1a87a227c..ea8c20dea 100644 --- a/node/db/store.go +++ b/node/db/store.go @@ -156,6 +156,67 @@ 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 + BatchIndex uint64 // 0 means no batch in this block + L2EndBlock uint64 // last L2 block number in the batch (0 if no batch) +} + +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)) + } +} + func isNotFoundErr(err error) bool { return err.Error() == leveldb.ErrNotFound.Error() || err.Error() == types.ErrMemoryDBNotFound.Error() } diff --git a/node/derivation/DERIVATION_REFACTOR.md b/node/derivation/DERIVATION_REFACTOR.md new file mode 100644 index 000000000..e77c7a138 --- /dev/null +++ b/node/derivation/DERIVATION_REFACTOR.md @@ -0,0 +1,154 @@ +# Derivation Refactor: Batch Verification & L1 Reorg Detection + +## Background + +The derivation module is the core component that syncs L2 state from L1 batch data. Previously it only ran on validator nodes and used a challenge mechanism when state mismatches were detected. This refactor makes two fundamental changes: + +1. **L1 batch data is the source of truth** — when local L2 blocks don't match L1 batch data, roll back and re-derive from L1 instead of issuing a challenge. +2. **Support `latest` mode** for fetching L1 batches (instead of only `finalized`), with L1 reorg detection to handle the reduced confirmation window. + +## Design Principles + +- **L2 rollback is only triggered by batch data mismatch**, never by L1 reorg alone. + - L1 reorg → clean up DB → re-derive from reorg point → batch comparison decides if L2 needs rollback. + - Most L1 reorgs just re-include the same batch tx in a different block — L2 stays valid. +- **Derivation can run as a verification thread** — when blocks already exist locally (e.g. produced by sequencer), derivation compares them against L1 batch data instead of skipping. + +## What Changed + +### Removed + +| Item | Reason | +|------|--------| +| `validator` field in `Derivation` struct | Challenge mechanism removed | +| `validator.Validator` parameter in `NewDerivationClient()` | No longer needed | +| `ChallengeState` / `ChallengeEnable` logic in `derivationBlock()` | Replaced by rollback + re-derive | +| `validator` import in `node/cmd/node/main.go` | No longer referenced | + +### Added — L1 Reorg Detection + +When `confirmations` is not `finalized` (i.e. using `latest` or `safe`), each derivation loop checks recent L1 blocks for hash changes before processing new batches. + +**New DB layer** (`node/db/`): + +- `DerivationL1Block` struct — stores `{Number, Hash, BatchIndex, L2EndBlock}` per L1 block +- `WriteDerivationL1Block` / `ReadDerivationL1Block` / `ReadDerivationL1BlockRange` / `DeleteDerivationL1BlocksFrom` +- DB key prefix: `derivL1Block` + uint64 big-endian height + +**New config** (`node/derivation/config.go`): + +- `ReorgCheckDepth uint64` — how many recent L1 blocks to verify each loop (default: 64) +- CLI flag: `--derivation.reorgCheckDepth` / env `MORPH_DERIVATION_REORG_CHECK_DEPTH` + +**New methods** (`node/derivation/derivation.go`): + +| Method | Purpose | +|--------|---------| +| `detectReorg(ctx)` | Iterates recent L1 block hashes from DB, compares against current L1 chain. Returns the height where a mismatch is found, or nil. | +| `handleL1Reorg(height)` | Cleans DB records from the reorg point and resets `latestDerivationL1Height`. Does NOT rollback L2 — the next derivation loop re-fetches batches and the normal comparison logic decides. | +| `recordL1Blocks(ctx, from, to)` | After each derivation round, records L1 block hashes for the processed range. | + +**Flow**: + +``` +derivationBlock() loop start +│ +├─ [if not finalized] detectReorg() +│ ├─ no reorg → continue +│ └─ reorg at height X → handleL1Reorg(X) +│ ├─ DeleteDerivationL1BlocksFrom(X) +│ ├─ WriteLatestDerivationL1Height(X-1) +│ └─ return (next loop re-processes from X) +│ +├─ fetch CommitBatch logs from L1 +├─ process each batch → derive() + verifyBatchRoots() +├─ recordL1Blocks(start, end) +└─ WriteLatestDerivationL1Height(end) +``` + +### Added — Batch Data Verification + +When `derive()` encounters an L2 block that already exists locally, it now **compares** the block against the L1 batch data instead of blindly skipping it. + +**New methods**: + +| Method | Purpose | +|--------|---------| +| `verifyBlockContext(localHeader, blockData)` | Compares timestamp, gasLimit, baseFee between local L2 block header and batch block context. | +| `verifyBatchRoots(batchInfo, lastHeader)` | Compares stateRoot and withdrawalRoot between L1 batch and last derived L2 block. Extracted from the old inline logic. | +| `rollbackLocalChain(targetBlockNumber)` | **TODO stub** — will call geth `SetHead` API to rewind L2 chain. | + +**`derive()` new flow for each block in batch**: + +``` +block.Number <= latestBlockNumber? +├─ YES (block exists) +│ ├─ verifyBlockContext() passes → skip, continue +│ └─ verifyBlockContext() fails +│ ├─ IncBlockMismatchCount() +│ ├─ rollbackLocalChain(block.Number - 1) +│ └─ fall through to NewSafeL2Block (re-execute) +│ +└─ NO (new block) + └─ NewSafeL2Block (execute normally) +``` + +**`derivationBlock()` batch-level verification**: + +``` +After derive(batchInfo) completes: +│ +├─ verifyBatchRoots() passes → normal +└─ verifyBatchRoots() fails + ├─ IncRollbackCount() + ├─ rollbackLocalChain(firstBlockNumber - 1) + ├─ re-derive(batchInfo) + ├─ verifyBatchRoots() again + │ ├─ passes → recovered + │ └─ fails → CRITICAL error, stop (manual intervention needed) +``` + +### Added — Metrics + +| Metric | Type | Description | +|--------|------|-------------| +| `morphnode_derivation_l1_reorg_detected_total` | Counter | L1 reorg detection count | +| `morphnode_derivation_l2_rollback_total` | Counter | L2 rollbacks triggered by batch mismatch | +| `morphnode_derivation_block_mismatch_total` | Counter | Block-level context mismatches | + +## Modified Files + +| File | Changes | +|------|---------| +| `node/derivation/derivation.go` | Core refactor: removed validator/challenge, added reorg detection, batch verification, rollback flow | +| `node/derivation/database.go` | Extended `Reader`/`Writer` interfaces for L1 block hash tracking | +| `node/derivation/config.go` | Added `ReorgCheckDepth` config field | +| `node/derivation/metrics.go` | Added 3 new counter metrics | +| `node/db/keys.go` | Added `derivationL1BlockPrefix` and `DerivationL1BlockKey()` | +| `node/db/store.go` | Added `DerivationL1Block` struct and 4 CRUD methods | +| `node/flags/flags.go` | Added `DerivationReorgCheckDepth` CLI flag | +| `node/cmd/node/main.go` | Removed `validator` dependency from `NewDerivationClient` call | + +## TODO (follow-up work) + +### `rollbackLocalChain()` — geth SetHead integration + +Currently a stub that logs and returns nil. Needs: + +1. Expose `SetL2Head(number uint64)` in `go-ethereum/eth/catalyst/l2_api.go` +2. Add `SetHead` method to `go-ethereum/ethclient/authclient` +3. Add `SetHead` method to `node/types/retryable_client.go` +4. Call `d.l2Client.SetHead(d.ctx, targetBlockNumber)` in `rollbackLocalChain()` + +Note: geth already has `BlockChain.SetHead(head uint64) error` — we just need to expose it through the engine API chain. + +### Concurrency safety + +When running as a verification thread alongside a sequencer, concurrent access between block production and rollback needs locking. This will be handled separately. + +## How to Test + +1. **Existing behavior preserved**: Set `--derivation.confirmations` to finalized (default) — reorg detection is skipped, batch verification still runs. +2. **Latest mode**: Set `--derivation.confirmations` to `-2` (latest) — reorg detection activates, L1 block hashes are tracked. +3. **Reorg detection**: Simulate by modifying a saved L1 block hash in DB — next loop should detect and clean up. +4. **Batch verification**: When an existing L2 block matches L1 batch data, it logs "block verified" and skips. When mismatched, it logs the mismatch and attempts rollback (currently a no-op stub). diff --git a/node/derivation/config.go b/node/derivation/config.go index efb1ceb31..81767d520 100644 --- a/node/derivation/config.go +++ b/node/derivation/config.go @@ -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 { @@ -42,6 +45,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"` @@ -55,6 +59,7 @@ func DefaultConfig() *Config { PollInterval: DefaultPollInterval, LogProgressInterval: DefaultLogProgressInterval, FetchBlockRange: DefaultFetchBlockRange, + ReorgCheckDepth: DefaultReorgCheckDepth, L2: new(types.L2Config), L2Next: nil, // optional, only for upgrade switch } @@ -111,6 +116,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) diff --git a/node/derivation/database.go b/node/derivation/database.go index a63f4eba1..369b135e1 100644 --- a/node/derivation/database.go +++ b/node/derivation/database.go @@ -1,6 +1,7 @@ package derivation import ( + "morph-l2/node/db" "morph-l2/node/sync" ) @@ -12,8 +13,12 @@ type Database interface { type Reader interface { ReadLatestDerivationL1Height() *uint64 + ReadDerivationL1Block(l1Height uint64) *db.DerivationL1Block + ReadDerivationL1BlockRange(from, to uint64) []*db.DerivationL1Block } type Writer interface { WriteLatestDerivationL1Height(latest uint64) + WriteDerivationL1Block(block *db.DerivationL1Block) + DeleteDerivationL1BlocksFrom(height uint64) } diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 8fb311b0e..bf58fa147 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -25,9 +25,9 @@ import ( "morph-l2/bindings/bindings" "morph-l2/bindings/predeploys" nodecommon "morph-l2/node/common" + "morph-l2/node/db" "morph-l2/node/sync" "morph-l2/node/types" - "morph-l2/node/validator" ) var ( @@ -42,7 +42,6 @@ type Derivation struct { RollupContractAddress common.Address confirmations rpc.BlockNumber l2Client *types.RetryableClient - validator *validator.Validator logger tmlog.Logger rollup *bindings.Rollup metrics *Metrics @@ -60,6 +59,7 @@ type Derivation struct { startHeight uint64 baseHeight uint64 fetchBlockRange uint64 + reorgCheckDepth uint64 pollInterval time.Duration logProgressInterval time.Duration stop chan struct{} @@ -76,7 +76,7 @@ type DeployContractBackend interface { ethereum.TransactionReader } -func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db Database, validator *validator.Validator, rollup *bindings.Rollup, logger tmlog.Logger) (*Derivation, error) { +func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db Database, rollup *bindings.Rollup, logger tmlog.Logger) (*Derivation, error) { l1Client, err := ethclient.Dial(cfg.L1.Addr) if err != nil { return nil, err @@ -152,7 +152,6 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db: db, l1Client: l1Client, syncer: syncer, - validator: validator, rollup: rollup, rollupABI: rollupAbi, legacyRollupABI: legacyRollupAbi, @@ -166,6 +165,7 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, startHeight: cfg.StartHeight, baseHeight: cfg.BaseHeight, fetchBlockRange: cfg.FetchBlockRange, + reorgCheckDepth: cfg.ReorgCheckDepth, pollInterval: cfg.PollInterval, logProgressInterval: cfg.LogProgressInterval, metrics: metrics, @@ -214,6 +214,24 @@ func (d *Derivation) Stop() { } func (d *Derivation) derivationBlock(ctx context.Context) { + // Step 1: Check for L1 reorg (only meaningful when not using finalized) + if d.confirmations != rpc.FinalizedBlockNumber { + reorgAt, err := d.detectReorg(ctx) + if err != nil { + d.logger.Error("reorg detection failed", "err", err) + return + } + if reorgAt != nil { + d.logger.Error("L1 reorg detected, invoking reorg handler", "reorgAtL1Height", *reorgAt) + d.metrics.IncReorgCount() + if err := d.handleL1Reorg(*reorgAt); err != nil { + d.logger.Error("handle L1 reorg failed", "err", err) + return + } + } + } + + // Step 2: Determine L1 scan range latestDerivation := d.db.ReadLatestDerivationL1Height() latest, err := d.getLatestConfirmedBlockNumber(d.ctx) if err != nil { @@ -233,7 +251,9 @@ func (d *Derivation) derivationBlock(ctx context.Context) { } else if latest-start >= d.fetchBlockRange { end = start + d.fetchBlockRange } - d.logger.Info("derivation start pull rollupData form l1", "startBlock", start, "end", end) + d.logger.Info("derivation start pull rollupData from l1", "startBlock", start, "end", end) + + // Step 3: Fetch CommitBatch logs logs, err := d.fetchRollupLog(ctx, start, end) if err != nil { d.logger.Error("eth_getLogs failed", "err", err) @@ -247,6 +267,7 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.metrics.SetLatestBatchIndex(latestBatchIndex.Uint64()) d.logger.Info("fetched rollup tx", "txNum", len(logs), "latestBatchIndex", latestBatchIndex) + // Step 4: Process each batch for _, lg := range logs { batchInfo, err := d.fetchRollupDataByTxHash(lg.TxHash, lg.BlockNumber) if err != nil { @@ -259,75 +280,213 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.logger.Info("fetch rollup transaction success", "txNonce", batchInfo.nonce, "txHash", batchInfo.txHash, "l1BlockNumber", batchInfo.l1BlockNumber, "firstL2BlockNumber", batchInfo.firstBlockNumber, "lastL2BlockNumber", batchInfo.lastBlockNumber) - // derivation + // Derive or verify blocks lastHeader, err := d.derive(batchInfo) if err != nil { d.logger.Error("derive blocks interrupt", "error", err) return } - // only last block of batch + d.logger.Info("batch derivation complete", "batch_index", batchInfo.batchIndex, "currentBatchEndBlock", lastHeader.Number.Uint64()) d.metrics.SetL2DeriveHeight(lastHeader.Number.Uint64()) d.metrics.SetSyncedBatchIndex(batchInfo.batchIndex) + if lastHeader.Number.Uint64() <= d.baseHeight { continue } - withdrawalRoot, err := d.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ - BlockNumber: lastHeader.Number, - }) - if err != nil { - d.logger.Error("get withdrawal root failed", "error", err) - return - } - rootMismatch := !bytes.Equal(lastHeader.Root.Bytes(), batchInfo.root.Bytes()) - withdrawalMismatch := !bytes.Equal(withdrawalRoot[:], batchInfo.withdrawalRoot.Bytes()) - - if rootMismatch || withdrawalMismatch { - // Check if should skip validation during upgrade transition - // Skip if: (before switch && MPT geth) or (after switch && ZK geth) - skipValidation := false - if d.switchTime > 0 { - beforeSwitch := lastHeader.Time < d.switchTime - if (beforeSwitch && !d.useZktrie) || (!beforeSwitch && d.useZktrie) { - skipValidation = true - d.logger.Info("Root validation skipped during upgrade transition", - "originStateRootHash", batchInfo.root, - "deriveStateRootHash", lastHeader.Root.Hex(), - "blockTimestamp", lastHeader.Time, - "switchTime", d.switchTime, - "useZktrie", d.useZktrie, - ) - } + // Verify state root and withdrawal root against L1 batch data + if err := d.verifyBatchRoots(batchInfo, lastHeader); err != nil { + d.logger.Error("batch root verification failed, attempting rollback and re-derive", + "batchIndex", batchInfo.batchIndex, "error", err) + d.metrics.SetBatchStatus(stateException) + d.metrics.IncRollbackCount() + + rollbackTarget := batchInfo.firstBlockNumber - 1 + if err := d.rollbackLocalChain(rollbackTarget); err != nil { + d.logger.Error("rollback local chain failed", "target", rollbackTarget, "error", err) + return } - if !skipValidation { - d.metrics.SetBatchStatus(stateException) - // TODO The challenge switch is currently on and will be turned on in the future - if d.validator != nil && d.validator.ChallengeEnable() { - if err := d.validator.ChallengeState(batchInfo.batchIndex); err != nil { - d.logger.Error("challenge state failed") - return - } - } - d.logger.Error("root hash or withdrawal hash is not equal", - "originStateRootHash", batchInfo.root, - "deriveStateRootHash", lastHeader.Root.Hex(), - "batchWithdrawalRoot", batchInfo.withdrawalRoot.Hex(), - "deriveWithdrawalRoot", common.BytesToHash(withdrawalRoot[:]).Hex(), - ) + // Re-derive the batch using L1 batch data as source of truth + lastHeader, err = d.derive(batchInfo) + if err != nil { + d.logger.Error("re-derive after rollback failed", "error", err) return } + + // Verify again after re-derive + if err := d.verifyBatchRoots(batchInfo, lastHeader); err != nil { + d.logger.Error("CRITICAL: batch roots still mismatch after rollback and re-derive, manual intervention required", + "batchIndex", batchInfo.batchIndex, "error", err) + return + } + d.logger.Info("rollback and re-derive succeeded", "batchIndex", batchInfo.batchIndex) } + d.metrics.SetBatchStatus(stateNormal) d.metrics.SetL1SyncHeight(lg.BlockNumber) } + // Step 5: Record L1 block hashes for reorg detection + d.recordL1Blocks(ctx, start, end) + d.db.WriteLatestDerivationL1Height(end) d.metrics.SetL1SyncHeight(end) d.logger.Info("write latest derivation l1 height success", "l1BlockNumber", end) } +// detectReorg checks recent L1 blocks for hash mismatches indicating a reorg. +// Returns the L1 height where reorg was first detected, or nil if no reorg. +func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { + latestDerivation := d.db.ReadLatestDerivationL1Height() + if latestDerivation == nil { + return nil, nil + } + + checkFrom := d.startHeight + if *latestDerivation > d.reorgCheckDepth && (*latestDerivation-d.reorgCheckDepth) > checkFrom { + checkFrom = *latestDerivation - d.reorgCheckDepth + } + + savedBlocks := d.db.ReadDerivationL1BlockRange(checkFrom, *latestDerivation) + if len(savedBlocks) == 0 { + return nil, nil + } + + // Check from newest to oldest for earliest divergence + for i := len(savedBlocks) - 1; i >= 0; i-- { + block := savedBlocks[i] + header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(block.Number))) + if err != nil { + return nil, fmt.Errorf("failed to get L1 header at %d: %w", block.Number, err) + } + savedHash := common.BytesToHash(block.Hash[:]) + if header.Hash() != savedHash { + d.logger.Error("L1 block hash mismatch detected", + "height", block.Number, + "savedHash", savedHash.Hex(), + "currentHash", header.Hash().Hex(), + ) + return &block.Number, nil + } + } + return nil, nil +} + +// handleL1Reorg handles an L1 reorg detected at the given L1 height. +// It only cleans up derivation DB state and resets the derivation L1 height +// so the next derivation loop re-processes from the reorg point. +// +// L1 reorg does NOT directly trigger an L2 rollback — in most cases the same +// batch tx gets re-included in a new L1 block with identical content, so L2 +// blocks remain valid. The normal derivation loop will re-fetch batches and +// run verifyBlockContext / verifyBatchRoots; only if those comparisons fail +// will an L2 rollback be triggered through rollbackLocalChain. +func (d *Derivation) handleL1Reorg(reorgAtL1Height uint64) error { + d.logger.Info("L1 reorg detected, cleaning DB records and restarting derivation from reorg point", + "reorgAtL1Height", reorgAtL1Height) + + d.db.DeleteDerivationL1BlocksFrom(reorgAtL1Height) + + if reorgAtL1Height > d.startHeight { + d.db.WriteLatestDerivationL1Height(reorgAtL1Height - 1) + } + + return nil +} + +// rollbackLocalChain rolls back the local L2 chain to the specified block number. +// This is only triggered when batch data comparison fails — i.e. the local L2 block +// does not match the L1 batch data (block context mismatch or state root mismatch). +// After rollback, the caller re-derives blocks using L1 batch data as source of truth. +// +// TODO: Implement actual rollback via geth SetHead engine API: +// 1. Expose SetL2Head(number uint64) in go-ethereum/eth/catalyst/l2_api.go +// 2. Add SetHead method to go-ethereum/ethclient/authclient +// 3. Add SetHead method to node/types/retryable_client.go +// 4. Call d.l2Client.SetHead(d.ctx, targetBlockNumber) +func (d *Derivation) rollbackLocalChain(targetBlockNumber uint64) error { + d.logger.Info("L2 chain rollback not yet implemented", + "targetBlockNumber", targetBlockNumber) + + return nil +} + +// verifyBatchRoots verifies that the local state root and withdrawal root match the L1 batch data. +func (d *Derivation) verifyBatchRoots(batchInfo *BatchInfo, lastHeader *eth.Header) error { + withdrawalRoot, err := d.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ + BlockNumber: lastHeader.Number, + }) + if err != nil { + return fmt.Errorf("get withdrawal root failed: %w", err) + } + + rootMismatch := !bytes.Equal(lastHeader.Root.Bytes(), batchInfo.root.Bytes()) + withdrawalMismatch := !bytes.Equal(withdrawalRoot[:], batchInfo.withdrawalRoot.Bytes()) + + if rootMismatch || withdrawalMismatch { + // Check if should skip validation during upgrade transition + if d.switchTime > 0 { + beforeSwitch := lastHeader.Time < d.switchTime + if (beforeSwitch && !d.useZktrie) || (!beforeSwitch && d.useZktrie) { + d.logger.Info("Root validation skipped during upgrade transition", + "originStateRootHash", batchInfo.root, + "deriveStateRootHash", lastHeader.Root.Hex(), + "blockTimestamp", lastHeader.Time, + "switchTime", d.switchTime, + "useZktrie", d.useZktrie, + ) + return nil + } + } + return fmt.Errorf("root mismatch: stateRoot(l1=%s, local=%s) withdrawalRoot(l1=%s, local=%s)", + batchInfo.root.Hex(), lastHeader.Root.Hex(), + batchInfo.withdrawalRoot.Hex(), common.BytesToHash(withdrawalRoot[:]).Hex()) + } + return nil +} + +// verifyBlockContext compares a local L2 block header against the batch block context from L1. +func (d *Derivation) verifyBlockContext(localHeader *eth.Header, blockData *BlockContext) error { + if localHeader.Time != blockData.Timestamp { + return fmt.Errorf("timestamp mismatch at block %d: local=%d, batch=%d", + blockData.Number, localHeader.Time, blockData.Timestamp) + } + if localHeader.GasLimit != blockData.GasLimit { + return fmt.Errorf("gasLimit mismatch at block %d: local=%d, batch=%d", + blockData.Number, localHeader.GasLimit, blockData.GasLimit) + } + if blockData.BaseFee != nil && localHeader.BaseFee != nil { + if localHeader.BaseFee.Cmp(blockData.BaseFee) != 0 { + return fmt.Errorf("baseFee mismatch at block %d: local=%s, batch=%s", + blockData.Number, localHeader.BaseFee.String(), blockData.BaseFee.String()) + } + } + return nil +} + +// recordL1Blocks saves L1 block hashes for reorg detection. +func (d *Derivation) recordL1Blocks(ctx context.Context, from, to uint64) { + for h := from; h <= to; h++ { + header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(h))) + if err != nil { + d.logger.Error("failed to get L1 header for recording", "height", h, "err", err) + continue + } + + var hashBytes [32]byte + copy(hashBytes[:], header.Hash().Bytes()) + + l1Block := &db.DerivationL1Block{ + Number: h, + Hash: hashBytes, + } + + d.db.WriteDerivationL1Block(l1Block) + } +} + func (d *Derivation) fetchRollupLog(ctx context.Context, from, to uint64) ([]eth.Log, error) { query := ethereum.FilterQuery{ FromBlock: big.NewInt(0).SetUint64(from), @@ -606,13 +765,32 @@ func (d *Derivation) derive(rollupData *BatchInfo) (*eth.Header, error) { return nil, fmt.Errorf("get derivation geth block number error:%v", err) } if blockData.SafeL2Data.Number <= latestBlockNumber { - d.logger.Info("new L2 Data block number less than latestBlockNumber", "safeL2DataNumber", blockData.SafeL2Data.Number, "latestBlockNumber", latestBlockNumber) - lastHeader, err = d.l2Client.HeaderByNumber(d.ctx, big.NewInt(int64(blockData.SafeL2Data.Number))) + // Block already exists locally - verify it matches the batch data + localHeader, err := d.l2Client.HeaderByNumber(d.ctx, big.NewInt(int64(blockData.SafeL2Data.Number))) if err != nil { return nil, fmt.Errorf("query header by number error:%v", err) } - continue + + if err := d.verifyBlockContext(localHeader, blockData); err != nil { + d.logger.Error("block context mismatch with L1 batch data, rollback required", + "blockNumber", blockData.Number, "error", err) + d.metrics.IncBlockMismatchCount() + + // Rollback to just before this block, then re-execute from batch data + rollbackTarget := blockData.SafeL2Data.Number - 1 + if err := d.rollbackLocalChain(rollbackTarget); err != nil { + return nil, fmt.Errorf("rollback to %d failed: %v", rollbackTarget, err) + } + // Fall through to NewSafeL2Block below to re-execute + } else { + d.logger.Info("block verified against L1 batch data", + "blockNumber", blockData.Number) + lastHeader = localHeader + continue + } } + + // Execute the block (either new block or re-execution after rollback) err = func() error { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60)*time.Second) defer cancel() diff --git a/node/derivation/metrics.go b/node/derivation/metrics.go index da5e8937d..fad618643 100644 --- a/node/derivation/metrics.go +++ b/node/derivation/metrics.go @@ -18,12 +18,15 @@ const ( ) type Metrics struct { - L1SyncHeight metrics.Gauge - RollupL2Height metrics.Gauge - DeriveL2Height metrics.Gauge - BatchStatus metrics.Gauge - LatestBatchIndex metrics.Gauge - SyncedBatchIndex metrics.Gauge + L1SyncHeight metrics.Gauge + RollupL2Height metrics.Gauge + DeriveL2Height metrics.Gauge + BatchStatus metrics.Gauge + LatestBatchIndex metrics.Gauge + SyncedBatchIndex metrics.Gauge + ReorgCount metrics.Counter + RollbackCount metrics.Counter + BlockMismatchCount metrics.Counter } func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { @@ -68,6 +71,24 @@ func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { Name: "synced_batch_index", Help: "", }, labels).With(labelsAndValues...), + ReorgCount: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "l1_reorg_detected_total", + Help: "Total number of L1 reorgs detected", + }, labels).With(labelsAndValues...), + RollbackCount: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "l2_rollback_total", + Help: "Total number of L2 chain rollbacks triggered by batch mismatch", + }, labels).With(labelsAndValues...), + BlockMismatchCount: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "block_mismatch_total", + Help: "Total number of block context mismatches detected during verification", + }, labels).With(labelsAndValues...), } } @@ -95,6 +116,18 @@ func (m *Metrics) SetSyncedBatchIndex(batchIndex uint64) { m.SyncedBatchIndex.Set(float64(batchIndex)) } +func (m *Metrics) IncReorgCount() { + m.ReorgCount.Add(1) +} + +func (m *Metrics) IncRollbackCount() { + m.RollbackCount.Add(1) +} + +func (m *Metrics) IncBlockMismatchCount() { + m.BlockMismatchCount.Add(1) +} + func (m *Metrics) Serve(hostname string, port uint64) (*http.Server, error) { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) diff --git a/node/flags/flags.go b/node/flags/flags.go index 2c00f4a87..8b6201077 100644 --- a/node/flags/flags.go +++ b/node/flags/flags.go @@ -256,6 +256,13 @@ var ( Usage: "The number of confirmations needed on L1 for finalization. If not set, the default value is l1.confirmations", EnvVar: prefixEnvVar("DERIVATION_CONFIRMATIONS"), } + + DerivationReorgCheckDepth = cli.Uint64Flag{ + Name: "derivation.reorgCheckDepth", + Usage: "Number of recent L1 blocks to check for reorgs (default: 64)", + EnvVar: prefixEnvVar("DERIVATION_REORG_CHECK_DEPTH"), + Value: 64, + } // Logger LogLevel = &cli.StringFlag{ Name: "log.level", @@ -364,6 +371,7 @@ var Flags = []cli.Flag{ DerivationLogProgressInterval, DerivationFetchBlockRange, DerivationConfirmations, + DerivationReorgCheckDepth, L1BeaconAddr, // blocktag options From 0c48f5c6b297ed5ad1bc595cf716c54e7365ffa5 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 18:38:32 +0800 Subject: [PATCH 02/26] fix(derivation): address code review feedback Bug fixes: - Fix detectReorg traversal direction: iterate oldest-to-newest to find the earliest divergence point, not the latest - Make rollbackLocalChain return error instead of nil to prevent silent fall-through to NewSafeL2Block on an already-existing block number - Handle edge case in handleL1Reorg when reorgAtL1Height <= startHeight Optimizations: - Skip recordL1Blocks in finalized mode (reorg detection is disabled, recording L1 hashes is unnecessary overhead) Cleanup: - Remove unused BatchIndex/L2EndBlock fields from DerivationL1Block - Add batch-internal tx count consistency check in verifyBlockContext - Use Info instead of Error for L1 reorg detection logs (expected in latest mode) - Update DERIVATION_REFACTOR.md with review feedback changes Made-with: Cursor --- node/db/store.go | 6 ++-- node/derivation/DERIVATION_REFACTOR.md | 14 ++++++-- node/derivation/derivation.go | 47 ++++++++++++++++++-------- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/node/db/store.go b/node/db/store.go index ea8c20dea..16bd912bf 100644 --- a/node/db/store.go +++ b/node/db/store.go @@ -158,10 +158,8 @@ func (s *Store) WriteSyncedL1Messages(messages []types.L1Message, latestSynced u // DerivationL1Block stores L1 block info for reorg detection. type DerivationL1Block struct { - Number uint64 - Hash [32]byte - BatchIndex uint64 // 0 means no batch in this block - L2EndBlock uint64 // last L2 block number in the batch (0 if no batch) + Number uint64 + Hash [32]byte } func (s *Store) WriteDerivationL1Block(block *DerivationL1Block) { diff --git a/node/derivation/DERIVATION_REFACTOR.md b/node/derivation/DERIVATION_REFACTOR.md index e77c7a138..121eafd07 100644 --- a/node/derivation/DERIVATION_REFACTOR.md +++ b/node/derivation/DERIVATION_REFACTOR.md @@ -133,7 +133,8 @@ After derive(batchInfo) completes: ### `rollbackLocalChain()` — geth SetHead integration -Currently a stub that logs and returns nil. Needs: +Currently a stub that returns an error. Any batch mismatch will be detected and logged, but the +actual L2 chain rollback cannot proceed until this is implemented: 1. Expose `SetL2Head(number uint64)` in `go-ethereum/eth/catalyst/l2_api.go` 2. Add `SetHead` method to `go-ethereum/ethclient/authclient` @@ -142,13 +143,20 @@ Currently a stub that logs and returns nil. Needs: Note: geth already has `BlockChain.SetHead(head uint64) error` — we just need to expose it through the engine API chain. +### Transaction-level verification + +`verifyBlockContext` currently checks timestamp, gasLimit, baseFee, and batch-internal tx count +consistency. Full transaction hash comparison against local blocks requires `BlockByNumber` RPC +on `RetryableClient`, which is not yet exposed. State root verification in `verifyBatchRoots` +covers transaction execution correctness as an indirect check. + ### Concurrency safety When running as a verification thread alongside a sequencer, concurrent access between block production and rollback needs locking. This will be handled separately. ## How to Test -1. **Existing behavior preserved**: Set `--derivation.confirmations` to finalized (default) — reorg detection is skipped, batch verification still runs. +1. **Existing behavior preserved**: Set `--derivation.confirmations` to finalized (default) — reorg detection and L1 block hash recording are both skipped, batch verification still runs. 2. **Latest mode**: Set `--derivation.confirmations` to `-2` (latest) — reorg detection activates, L1 block hashes are tracked. 3. **Reorg detection**: Simulate by modifying a saved L1 block hash in DB — next loop should detect and clean up. -4. **Batch verification**: When an existing L2 block matches L1 batch data, it logs "block verified" and skips. When mismatched, it logs the mismatch and attempts rollback (currently a no-op stub). +4. **Batch verification**: When an existing L2 block matches L1 batch data, it logs "block verified" and skips. When mismatched, it logs the error and returns (rollback stub returns error, preventing silent continuation). diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index bf58fa147..c7fe4a5c9 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -222,7 +222,7 @@ func (d *Derivation) derivationBlock(ctx context.Context) { return } if reorgAt != nil { - d.logger.Error("L1 reorg detected, invoking reorg handler", "reorgAtL1Height", *reorgAt) + d.logger.Info("L1 reorg detected, invoking reorg handler", "reorgAtL1Height", *reorgAt) d.metrics.IncReorgCount() if err := d.handleL1Reorg(*reorgAt); err != nil { d.logger.Error("handle L1 reorg failed", "err", err) @@ -328,8 +328,10 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.metrics.SetL1SyncHeight(lg.BlockNumber) } - // Step 5: Record L1 block hashes for reorg detection - d.recordL1Blocks(ctx, start, end) + // Step 5: Record L1 block hashes for reorg detection (only needed for non-finalized modes) + if d.confirmations != rpc.FinalizedBlockNumber { + d.recordL1Blocks(ctx, start, end) + } d.db.WriteLatestDerivationL1Height(end) d.metrics.SetL1SyncHeight(end) @@ -354,8 +356,8 @@ func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { return nil, nil } - // Check from newest to oldest for earliest divergence - for i := len(savedBlocks) - 1; i >= 0; i-- { + // Check from oldest to newest to find the earliest divergence point + for i := 0; i < len(savedBlocks); i++ { block := savedBlocks[i] header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(block.Number))) if err != nil { @@ -363,7 +365,7 @@ func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { } savedHash := common.BytesToHash(block.Hash[:]) if header.Hash() != savedHash { - d.logger.Error("L1 block hash mismatch detected", + d.logger.Info("L1 block hash mismatch detected", "height", block.Number, "savedHash", savedHash.Hex(), "currentHash", header.Hash().Hex(), @@ -391,6 +393,12 @@ func (d *Derivation) handleL1Reorg(reorgAtL1Height uint64) error { if reorgAtL1Height > d.startHeight { d.db.WriteLatestDerivationL1Height(reorgAtL1Height - 1) + } else { + // Reorg at or before startHeight — reset to startHeight so next loop + // starts from the beginning. Write startHeight-1 so that start = startHeight. + if d.startHeight > 0 { + d.db.WriteLatestDerivationL1Height(d.startHeight - 1) + } } return nil @@ -400,17 +408,16 @@ func (d *Derivation) handleL1Reorg(reorgAtL1Height uint64) error { // This is only triggered when batch data comparison fails — i.e. the local L2 block // does not match the L1 batch data (block context mismatch or state root mismatch). // After rollback, the caller re-derives blocks using L1 batch data as source of truth. -// -// TODO: Implement actual rollback via geth SetHead engine API: -// 1. Expose SetL2Head(number uint64) in go-ethereum/eth/catalyst/l2_api.go -// 2. Add SetHead method to go-ethereum/ethclient/authclient -// 3. Add SetHead method to node/types/retryable_client.go -// 4. Call d.l2Client.SetHead(d.ctx, targetBlockNumber) func (d *Derivation) rollbackLocalChain(targetBlockNumber uint64) error { - d.logger.Info("L2 chain rollback not yet implemented", + d.logger.Error("L2 chain rollback not yet implemented", "targetBlockNumber", targetBlockNumber) - return nil + // TODO: Implement actual rollback via geth SetHead engine API: + // 1. Expose SetL2Head(number uint64) in go-ethereum/eth/catalyst/l2_api.go + // 2. Add SetHead method to go-ethereum/ethclient/authclient + // 3. Add SetHead method to node/types/retryable_client.go + // 4. Call d.l2Client.SetHead(d.ctx, targetBlockNumber) + return fmt.Errorf("rollback not implemented yet, target=%d", targetBlockNumber) } // verifyBatchRoots verifies that the local state root and withdrawal root match the L1 batch data. @@ -463,6 +470,18 @@ func (d *Derivation) verifyBlockContext(localHeader *eth.Header, blockData *Bloc blockData.Number, localHeader.BaseFee.String(), blockData.BaseFee.String()) } } + // Verify transaction count: batch txsNum should match the number of transactions + // in SafeL2Data (L1 messages + L2 transactions assembled during batch parsing). + if blockData.SafeL2Data != nil { + batchTxCount := len(blockData.SafeL2Data.Transactions) + if batchTxCount != int(blockData.txsNum) { + return fmt.Errorf("tx count mismatch at block %d: batchContext=%d, batchData=%d", + blockData.Number, blockData.txsNum, batchTxCount) + } + } + // NOTE: Full transaction-level comparison (tx hashes) against the local block + // requires BlockByNumber RPC which RetryableClient doesn't expose yet. + // State root verification in verifyBatchRoots covers transaction execution correctness. return nil } From f9a25783b9892a1ff636ce654098dc96dce65f7e Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 18:43:51 +0800 Subject: [PATCH 03/26] fix(derivation): address round-2 review feedback - Handle startHeight==0 edge case in handleL1Reorg by writing 0 instead of skipping the reset - Change recordL1Blocks to return on first failure instead of continue, preventing gaps in L1 block hash tracking that could cause missed reorgs - Return immediately after L1 reorg handling instead of continuing the same derivation loop, avoiding recording unstable L1 hashes during ongoing reorgs - Clarify verifyBlockContext tx count check is batch-internal consistency, not local-vs-L1 comparison (local-vs-L1 covered by state root in verifyBatchRoots) Made-with: Cursor --- node/derivation/derivation.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index c7fe4a5c9..77fd73d47 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -226,8 +226,12 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.metrics.IncReorgCount() if err := d.handleL1Reorg(*reorgAt); err != nil { d.logger.Error("handle L1 reorg failed", "err", err) - return } + // Always return after reorg detection — don't continue processing in + // the same loop. Let the next poll interval re-fetch from the reset + // height. This avoids recording potentially unstable L1 block hashes + // if the chain is still reorging. + return } } @@ -394,10 +398,11 @@ func (d *Derivation) handleL1Reorg(reorgAtL1Height uint64) error { if reorgAtL1Height > d.startHeight { d.db.WriteLatestDerivationL1Height(reorgAtL1Height - 1) } else { - // Reorg at or before startHeight — reset to startHeight so next loop - // starts from the beginning. Write startHeight-1 so that start = startHeight. + // Reorg at or before startHeight — reset so next loop starts from startHeight. if d.startHeight > 0 { d.db.WriteLatestDerivationL1Height(d.startHeight - 1) + } else { + d.db.WriteLatestDerivationL1Height(0) } } @@ -470,18 +475,18 @@ func (d *Derivation) verifyBlockContext(localHeader *eth.Header, blockData *Bloc blockData.Number, localHeader.BaseFee.String(), blockData.BaseFee.String()) } } - // Verify transaction count: batch txsNum should match the number of transactions - // in SafeL2Data (L1 messages + L2 transactions assembled during batch parsing). + // Batch internal consistency check: txsNum in the block context should match the + // actual number of transactions assembled in SafeL2Data (L1 messages + L2 txs). + // This catches batch parsing/corruption issues, not local-vs-L1 divergence. + // Local-vs-L1 transaction divergence is covered by state root verification + // in verifyBatchRoots (different txs → different state root). if blockData.SafeL2Data != nil { batchTxCount := len(blockData.SafeL2Data.Transactions) if batchTxCount != int(blockData.txsNum) { - return fmt.Errorf("tx count mismatch at block %d: batchContext=%d, batchData=%d", + return fmt.Errorf("batch internal tx count inconsistency at block %d: blockContext.txsNum=%d, safeL2Data.Transactions=%d", blockData.Number, blockData.txsNum, batchTxCount) } } - // NOTE: Full transaction-level comparison (tx hashes) against the local block - // requires BlockByNumber RPC which RetryableClient doesn't expose yet. - // State root verification in verifyBatchRoots covers transaction execution correctness. return nil } @@ -490,8 +495,8 @@ func (d *Derivation) recordL1Blocks(ctx context.Context, from, to uint64) { for h := from; h <= to; h++ { header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(h))) if err != nil { - d.logger.Error("failed to get L1 header for recording", "height", h, "err", err) - continue + d.logger.Error("failed to get L1 header for recording, will retry next loop", "height", h, "err", err) + return } var hashBytes [32]byte From 675e8fb90cae12d299705927cd7889abd7be748c Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 18:49:07 +0800 Subject: [PATCH 04/26] fix(derivation): prevent derivation height advancing when L1 block recording fails recordL1Blocks now returns error. If any L1 header fetch fails mid-range, derivationBlock returns early without calling WriteLatestDerivationL1Height. This prevents permanent gaps in L1 block hash tracking that would make reorgs in the gap range undetectable. Made-with: Cursor --- node/derivation/derivation.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 77fd73d47..578d53c74 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -334,7 +334,10 @@ func (d *Derivation) derivationBlock(ctx context.Context) { // Step 5: Record L1 block hashes for reorg detection (only needed for non-finalized modes) if d.confirmations != rpc.FinalizedBlockNumber { - d.recordL1Blocks(ctx, start, end) + if err := d.recordL1Blocks(ctx, start, end); err != nil { + d.logger.Error("recordL1Blocks failed, will retry next loop", "err", err) + return + } } d.db.WriteLatestDerivationL1Height(end) @@ -491,24 +494,24 @@ func (d *Derivation) verifyBlockContext(localHeader *eth.Header, blockData *Bloc } // recordL1Blocks saves L1 block hashes for reorg detection. -func (d *Derivation) recordL1Blocks(ctx context.Context, from, to uint64) { +// Returns an error if any header fetch fails — the caller must not advance +// derivation height to avoid permanent gaps in L1 block hash tracking. +func (d *Derivation) recordL1Blocks(ctx context.Context, from, to uint64) error { for h := from; h <= to; h++ { header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(h))) if err != nil { - d.logger.Error("failed to get L1 header for recording, will retry next loop", "height", h, "err", err) - return + return fmt.Errorf("failed to get L1 header at %d: %w", h, err) } var hashBytes [32]byte copy(hashBytes[:], header.Hash().Bytes()) - l1Block := &db.DerivationL1Block{ + d.db.WriteDerivationL1Block(&db.DerivationL1Block{ Number: h, Hash: hashBytes, - } - - d.db.WriteDerivationL1Block(l1Block) + }) } + return nil } func (d *Derivation) fetchRollupLog(ctx context.Context, from, to uint64) ([]eth.Log, error) { From fb3e1ce1fcad64a148633cee1c9eb4cbe5ff234c Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 21:08:11 +0800 Subject: [PATCH 05/26] =?UTF-8?q?fix(derivation):=20address=20round-3=20re?= =?UTF-8?q?view=20=E2=80=94=20halt=20on=20mismatch,=20optimize=20reorg=20c?= =?UTF-8?q?heck,=20fix=20baseFee=20nil=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add `halted` flag: when rollback stub fails on batch mismatch, derivation stops instead of infinitely retrying the same batch with wasted L1 RPCs. 2. Optimize detectReorg: check newest saved block first — if it matches, skip the full scan (1 RPC instead of 64 in the common no-reorg case). 3. Fix verifyBlockContext BaseFee: explicitly error when one side is nil and the other is not, instead of silently skipping the comparison. 4. Fix doc: DerivationL1Block field list now matches code ({Number, Hash}). Made-with: Cursor --- node/derivation/DERIVATION_REFACTOR.md | 2 +- node/derivation/derivation.go | 44 ++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/node/derivation/DERIVATION_REFACTOR.md b/node/derivation/DERIVATION_REFACTOR.md index 121eafd07..09a197f96 100644 --- a/node/derivation/DERIVATION_REFACTOR.md +++ b/node/derivation/DERIVATION_REFACTOR.md @@ -31,7 +31,7 @@ When `confirmations` is not `finalized` (i.e. using `latest` or `safe`), each de **New DB layer** (`node/db/`): -- `DerivationL1Block` struct — stores `{Number, Hash, BatchIndex, L2EndBlock}` per L1 block +- `DerivationL1Block` struct — stores `{Number, Hash}` per L1 block - `WriteDerivationL1Block` / `ReadDerivationL1Block` / `ReadDerivationL1BlockRange` / `DeleteDerivationL1BlocksFrom` - DB key prefix: `derivL1Block` + uint64 big-endian height diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 578d53c74..2288be39d 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -63,6 +63,7 @@ type Derivation struct { pollInterval time.Duration logProgressInterval time.Duration stop chan struct{} + halted bool // set when an unrecoverable mismatch is detected but rollback is not yet implemented // geth upgrade config (fetched once at startup) switchTime uint64 @@ -214,6 +215,11 @@ func (d *Derivation) Stop() { } func (d *Derivation) derivationBlock(ctx context.Context) { + if d.halted { + d.logger.Error("derivation halted due to unrecoverable batch mismatch, manual intervention required") + return + } + // Step 1: Check for L1 reorg (only meaningful when not using finalized) if d.confirmations != rpc.FinalizedBlockNumber { reorgAt, err := d.detectReorg(ctx) @@ -308,7 +314,9 @@ func (d *Derivation) derivationBlock(ctx context.Context) { rollbackTarget := batchInfo.firstBlockNumber - 1 if err := d.rollbackLocalChain(rollbackTarget); err != nil { - d.logger.Error("rollback local chain failed", "target", rollbackTarget, "error", err) + d.logger.Error("rollback failed, halting derivation to prevent infinite retry", + "target", rollbackTarget, "batchIndex", batchInfo.batchIndex, "error", err) + d.halted = true return } @@ -321,8 +329,9 @@ func (d *Derivation) derivationBlock(ctx context.Context) { // Verify again after re-derive if err := d.verifyBatchRoots(batchInfo, lastHeader); err != nil { - d.logger.Error("CRITICAL: batch roots still mismatch after rollback and re-derive, manual intervention required", + d.logger.Error("CRITICAL: batch roots still mismatch after rollback and re-derive, halting derivation", "batchIndex", batchInfo.batchIndex, "error", err) + d.halted = true return } d.logger.Info("rollback and re-derive succeeded", "batchIndex", batchInfo.batchIndex) @@ -347,6 +356,11 @@ func (d *Derivation) derivationBlock(ctx context.Context) { // detectReorg checks recent L1 blocks for hash mismatches indicating a reorg. // Returns the L1 height where reorg was first detected, or nil if no reorg. +// +// Optimization: checks the newest saved block first. If it matches, there is +// no reorg (1 RPC call in the common case). Only when the newest block +// mismatches does it do a full oldest-to-newest scan to find the earliest +// divergence point. func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { latestDerivation := d.db.ReadLatestDerivationL1Height() if latestDerivation == nil { @@ -363,7 +377,17 @@ func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { return nil, nil } - // Check from oldest to newest to find the earliest divergence point + // Fast path: check the newest block first. If it matches, no reorg occurred. + newest := savedBlocks[len(savedBlocks)-1] + newestHeader, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(newest.Number))) + if err != nil { + return nil, fmt.Errorf("failed to get L1 header at %d: %w", newest.Number, err) + } + if newestHeader.Hash() == common.BytesToHash(newest.Hash[:]) { + return nil, nil + } + + // Slow path: reorg detected. Scan oldest-to-newest to find the earliest divergence. for i := 0; i < len(savedBlocks); i++ { block := savedBlocks[i] header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(block.Number))) @@ -472,11 +496,18 @@ func (d *Derivation) verifyBlockContext(localHeader *eth.Header, blockData *Bloc return fmt.Errorf("gasLimit mismatch at block %d: local=%d, batch=%d", blockData.Number, localHeader.GasLimit, blockData.GasLimit) } - if blockData.BaseFee != nil && localHeader.BaseFee != nil { + switch { + case blockData.BaseFee != nil && localHeader.BaseFee != nil: if localHeader.BaseFee.Cmp(blockData.BaseFee) != 0 { return fmt.Errorf("baseFee mismatch at block %d: local=%s, batch=%s", blockData.Number, localHeader.BaseFee.String(), blockData.BaseFee.String()) } + case blockData.BaseFee == nil && localHeader.BaseFee == nil: + // Both nil — pre-EIP-1559 or legacy batch format, OK. + default: + // One side has BaseFee, the other doesn't — structural inconsistency. + return fmt.Errorf("baseFee nil mismatch at block %d: local=%v, batch=%v", + blockData.Number, localHeader.BaseFee, blockData.BaseFee) } // Batch internal consistency check: txsNum in the block context should match the // actual number of transactions assembled in SafeL2Data (L1 messages + L2 txs). @@ -803,12 +834,11 @@ func (d *Derivation) derive(rollupData *BatchInfo) (*eth.Header, error) { "blockNumber", blockData.Number, "error", err) d.metrics.IncBlockMismatchCount() - // Rollback to just before this block, then re-execute from batch data rollbackTarget := blockData.SafeL2Data.Number - 1 if err := d.rollbackLocalChain(rollbackTarget); err != nil { - return nil, fmt.Errorf("rollback to %d failed: %v", rollbackTarget, err) + d.halted = true + return nil, fmt.Errorf("rollback to %d failed (derivation halted): %v", rollbackTarget, err) } - // Fall through to NewSafeL2Block below to re-execute } else { d.logger.Info("block verified against L1 batch data", "blockNumber", blockData.Number) From 3c960cf740b3bc3d8bb4eb31180d9fc4ef049a46 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 21:12:18 +0800 Subject: [PATCH 06/26] feat(derivation): add halted gauge metric for production alerting Expose morphnode_derivation_halted gauge (0/1) so operators can set up alerts when derivation halts due to unrecoverable batch mismatch. All three code paths that set d.halted=true now also call metrics.SetHalted(). Made-with: Cursor --- node/derivation/derivation.go | 3 +++ node/derivation/metrics.go | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 2288be39d..90d41355e 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -317,6 +317,7 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.logger.Error("rollback failed, halting derivation to prevent infinite retry", "target", rollbackTarget, "batchIndex", batchInfo.batchIndex, "error", err) d.halted = true + d.metrics.SetHalted() return } @@ -332,6 +333,7 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.logger.Error("CRITICAL: batch roots still mismatch after rollback and re-derive, halting derivation", "batchIndex", batchInfo.batchIndex, "error", err) d.halted = true + d.metrics.SetHalted() return } d.logger.Info("rollback and re-derive succeeded", "batchIndex", batchInfo.batchIndex) @@ -837,6 +839,7 @@ func (d *Derivation) derive(rollupData *BatchInfo) (*eth.Header, error) { rollbackTarget := blockData.SafeL2Data.Number - 1 if err := d.rollbackLocalChain(rollbackTarget); err != nil { d.halted = true + d.metrics.SetHalted() return nil, fmt.Errorf("rollback to %d failed (derivation halted): %v", rollbackTarget, err) } } else { diff --git a/node/derivation/metrics.go b/node/derivation/metrics.go index fad618643..a0ae4817a 100644 --- a/node/derivation/metrics.go +++ b/node/derivation/metrics.go @@ -27,6 +27,7 @@ type Metrics struct { ReorgCount metrics.Counter RollbackCount metrics.Counter BlockMismatchCount metrics.Counter + Halted metrics.Gauge } func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { @@ -89,6 +90,12 @@ func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { Name: "block_mismatch_total", Help: "Total number of block context mismatches detected during verification", }, labels).With(labelsAndValues...), + Halted: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "halted", + Help: "Set to 1 when derivation is halted due to unrecoverable batch mismatch requiring manual intervention", + }, labels).With(labelsAndValues...), } } @@ -128,6 +135,10 @@ func (m *Metrics) IncBlockMismatchCount() { m.BlockMismatchCount.Add(1) } +func (m *Metrics) SetHalted() { + m.Halted.Set(1) +} + func (m *Metrics) Serve(hostname string, port uint64) (*http.Server, error) { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) From ca81ed76f81671123f0f7a82520db177211357c8 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 21:30:12 +0800 Subject: [PATCH 07/26] fix(derivation): guard against nil lastHeader panic on empty batch, fix doc env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add nil check for lastHeader after derive() returns — if blockContexts is empty, skip the batch instead of panicking on lastHeader.Number. 2. Fix DERIVATION_REFACTOR.md: env var is MORPH_NODE_DERIVATION_REORG_CHECK_DEPTH (was missing NODE_ prefix). Made-with: Cursor --- node/derivation/DERIVATION_REFACTOR.md | 2 +- node/derivation/derivation.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/node/derivation/DERIVATION_REFACTOR.md b/node/derivation/DERIVATION_REFACTOR.md index 09a197f96..21453a967 100644 --- a/node/derivation/DERIVATION_REFACTOR.md +++ b/node/derivation/DERIVATION_REFACTOR.md @@ -38,7 +38,7 @@ When `confirmations` is not `finalized` (i.e. using `latest` or `safe`), each de **New config** (`node/derivation/config.go`): - `ReorgCheckDepth uint64` — how many recent L1 blocks to verify each loop (default: 64) -- CLI flag: `--derivation.reorgCheckDepth` / env `MORPH_DERIVATION_REORG_CHECK_DEPTH` +- CLI flag: `--derivation.reorgCheckDepth` / env `MORPH_NODE_DERIVATION_REORG_CHECK_DEPTH` **New methods** (`node/derivation/derivation.go`): diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 90d41355e..bd364a567 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -296,6 +296,10 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.logger.Error("derive blocks interrupt", "error", err) return } + if lastHeader == nil { + d.logger.Error("derive returned nil header, skipping empty batch", "batchIndex", batchInfo.batchIndex) + continue + } d.logger.Info("batch derivation complete", "batch_index", batchInfo.batchIndex, "currentBatchEndBlock", lastHeader.Number.Uint64()) d.metrics.SetL2DeriveHeight(lastHeader.Number.Uint64()) From 182b8d1bb76e262aef98dd5484dd817c8717ebe4 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 22:03:43 +0800 Subject: [PATCH 08/26] fix(derivation): add nil check for lastHeader on re-derive path, document halted metric Made-with: Cursor --- node/derivation/DERIVATION_REFACTOR.md | 1 + node/derivation/derivation.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/node/derivation/DERIVATION_REFACTOR.md b/node/derivation/DERIVATION_REFACTOR.md index 21453a967..eeb45962f 100644 --- a/node/derivation/DERIVATION_REFACTOR.md +++ b/node/derivation/DERIVATION_REFACTOR.md @@ -115,6 +115,7 @@ After derive(batchInfo) completes: | `morphnode_derivation_l1_reorg_detected_total` | Counter | L1 reorg detection count | | `morphnode_derivation_l2_rollback_total` | Counter | L2 rollbacks triggered by batch mismatch | | `morphnode_derivation_block_mismatch_total` | Counter | Block-level context mismatches | +| `morphnode_derivation_halted` | Gauge | Set to 1 when derivation halts due to unrecoverable batch mismatch (alert on this) | ## Modified Files diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index bd364a567..e3fa540a9 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -331,6 +331,10 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.logger.Error("re-derive after rollback failed", "error", err) return } + if lastHeader == nil { + d.logger.Error("re-derive returned nil header after rollback", "batchIndex", batchInfo.batchIndex) + return + } // Verify again after re-derive if err := d.verifyBatchRoots(batchInfo, lastHeader); err != nil { From 46edfa156e86b615208e1386ddbac01939af5e06 Mon Sep 17 00:00:00 2001 From: corey Date: Tue, 10 Mar 2026 22:18:32 +0800 Subject: [PATCH 09/26] docs(derivation): add language tags to fenced code blocks (MD040) Made-with: Cursor --- node/derivation/DERIVATION_REFACTOR.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/node/derivation/DERIVATION_REFACTOR.md b/node/derivation/DERIVATION_REFACTOR.md index eeb45962f..9f94940a1 100644 --- a/node/derivation/DERIVATION_REFACTOR.md +++ b/node/derivation/DERIVATION_REFACTOR.md @@ -50,7 +50,7 @@ When `confirmations` is not `finalized` (i.e. using `latest` or `safe`), each de **Flow**: -``` +```text derivationBlock() loop start │ ├─ [if not finalized] detectReorg() @@ -80,7 +80,7 @@ When `derive()` encounters an L2 block that already exists locally, it now **com **`derive()` new flow for each block in batch**: -``` +```text block.Number <= latestBlockNumber? ├─ YES (block exists) │ ├─ verifyBlockContext() passes → skip, continue @@ -95,7 +95,7 @@ block.Number <= latestBlockNumber? **`derivationBlock()` batch-level verification**: -``` +```text After derive(batchInfo) completes: │ ├─ verifyBatchRoots() passes → normal From a45705c1a25822158bf0b85d5a84ac7ca134434d Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 11 Mar 2026 11:15:10 +0800 Subject: [PATCH 10/26] refactor(derivation): split new logic into verify.go and reorg.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract newly added functions into dedicated files for clarity: - verify.go: rollbackLocalChain, verifyBatchRoots, verifyBlockContext - reorg.go: detectReorg, handleL1Reorg, recordL1Blocks Existing batch parsing code stays in derivation.go to keep the diff scoped to this PR's changes only. No logic changes — pure file split. Made-with: Cursor --- node/derivation/derivation.go | 191 ---------------------------------- node/derivation/reorg.go | 114 ++++++++++++++++++++ node/derivation/verify.go | 98 +++++++++++++++++ 3 files changed, 212 insertions(+), 191 deletions(-) create mode 100644 node/derivation/reorg.go create mode 100644 node/derivation/verify.go diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index e3fa540a9..73a17b741 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -25,7 +25,6 @@ import ( "morph-l2/bindings/bindings" "morph-l2/bindings/predeploys" nodecommon "morph-l2/node/common" - "morph-l2/node/db" "morph-l2/node/sync" "morph-l2/node/types" ) @@ -364,196 +363,6 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.logger.Info("write latest derivation l1 height success", "l1BlockNumber", end) } -// detectReorg checks recent L1 blocks for hash mismatches indicating a reorg. -// Returns the L1 height where reorg was first detected, or nil if no reorg. -// -// Optimization: checks the newest saved block first. If it matches, there is -// no reorg (1 RPC call in the common case). Only when the newest block -// mismatches does it do a full oldest-to-newest scan to find the earliest -// divergence point. -func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { - latestDerivation := d.db.ReadLatestDerivationL1Height() - if latestDerivation == nil { - return nil, nil - } - - checkFrom := d.startHeight - if *latestDerivation > d.reorgCheckDepth && (*latestDerivation-d.reorgCheckDepth) > checkFrom { - checkFrom = *latestDerivation - d.reorgCheckDepth - } - - savedBlocks := d.db.ReadDerivationL1BlockRange(checkFrom, *latestDerivation) - if len(savedBlocks) == 0 { - return nil, nil - } - - // Fast path: check the newest block first. If it matches, no reorg occurred. - newest := savedBlocks[len(savedBlocks)-1] - newestHeader, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(newest.Number))) - if err != nil { - return nil, fmt.Errorf("failed to get L1 header at %d: %w", newest.Number, err) - } - if newestHeader.Hash() == common.BytesToHash(newest.Hash[:]) { - return nil, nil - } - - // Slow path: reorg detected. Scan oldest-to-newest to find the earliest divergence. - for i := 0; i < len(savedBlocks); i++ { - block := savedBlocks[i] - header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(block.Number))) - if err != nil { - return nil, fmt.Errorf("failed to get L1 header at %d: %w", block.Number, err) - } - savedHash := common.BytesToHash(block.Hash[:]) - if header.Hash() != savedHash { - d.logger.Info("L1 block hash mismatch detected", - "height", block.Number, - "savedHash", savedHash.Hex(), - "currentHash", header.Hash().Hex(), - ) - return &block.Number, nil - } - } - return nil, nil -} - -// handleL1Reorg handles an L1 reorg detected at the given L1 height. -// It only cleans up derivation DB state and resets the derivation L1 height -// so the next derivation loop re-processes from the reorg point. -// -// L1 reorg does NOT directly trigger an L2 rollback — in most cases the same -// batch tx gets re-included in a new L1 block with identical content, so L2 -// blocks remain valid. The normal derivation loop will re-fetch batches and -// run verifyBlockContext / verifyBatchRoots; only if those comparisons fail -// will an L2 rollback be triggered through rollbackLocalChain. -func (d *Derivation) handleL1Reorg(reorgAtL1Height uint64) error { - d.logger.Info("L1 reorg detected, cleaning DB records and restarting derivation from reorg point", - "reorgAtL1Height", reorgAtL1Height) - - d.db.DeleteDerivationL1BlocksFrom(reorgAtL1Height) - - if reorgAtL1Height > d.startHeight { - d.db.WriteLatestDerivationL1Height(reorgAtL1Height - 1) - } else { - // Reorg at or before startHeight — reset so next loop starts from startHeight. - if d.startHeight > 0 { - d.db.WriteLatestDerivationL1Height(d.startHeight - 1) - } else { - d.db.WriteLatestDerivationL1Height(0) - } - } - - return nil -} - -// rollbackLocalChain rolls back the local L2 chain to the specified block number. -// This is only triggered when batch data comparison fails — i.e. the local L2 block -// does not match the L1 batch data (block context mismatch or state root mismatch). -// After rollback, the caller re-derives blocks using L1 batch data as source of truth. -func (d *Derivation) rollbackLocalChain(targetBlockNumber uint64) error { - d.logger.Error("L2 chain rollback not yet implemented", - "targetBlockNumber", targetBlockNumber) - - // TODO: Implement actual rollback via geth SetHead engine API: - // 1. Expose SetL2Head(number uint64) in go-ethereum/eth/catalyst/l2_api.go - // 2. Add SetHead method to go-ethereum/ethclient/authclient - // 3. Add SetHead method to node/types/retryable_client.go - // 4. Call d.l2Client.SetHead(d.ctx, targetBlockNumber) - return fmt.Errorf("rollback not implemented yet, target=%d", targetBlockNumber) -} - -// verifyBatchRoots verifies that the local state root and withdrawal root match the L1 batch data. -func (d *Derivation) verifyBatchRoots(batchInfo *BatchInfo, lastHeader *eth.Header) error { - withdrawalRoot, err := d.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ - BlockNumber: lastHeader.Number, - }) - if err != nil { - return fmt.Errorf("get withdrawal root failed: %w", err) - } - - rootMismatch := !bytes.Equal(lastHeader.Root.Bytes(), batchInfo.root.Bytes()) - withdrawalMismatch := !bytes.Equal(withdrawalRoot[:], batchInfo.withdrawalRoot.Bytes()) - - if rootMismatch || withdrawalMismatch { - // Check if should skip validation during upgrade transition - if d.switchTime > 0 { - beforeSwitch := lastHeader.Time < d.switchTime - if (beforeSwitch && !d.useZktrie) || (!beforeSwitch && d.useZktrie) { - d.logger.Info("Root validation skipped during upgrade transition", - "originStateRootHash", batchInfo.root, - "deriveStateRootHash", lastHeader.Root.Hex(), - "blockTimestamp", lastHeader.Time, - "switchTime", d.switchTime, - "useZktrie", d.useZktrie, - ) - return nil - } - } - return fmt.Errorf("root mismatch: stateRoot(l1=%s, local=%s) withdrawalRoot(l1=%s, local=%s)", - batchInfo.root.Hex(), lastHeader.Root.Hex(), - batchInfo.withdrawalRoot.Hex(), common.BytesToHash(withdrawalRoot[:]).Hex()) - } - return nil -} - -// verifyBlockContext compares a local L2 block header against the batch block context from L1. -func (d *Derivation) verifyBlockContext(localHeader *eth.Header, blockData *BlockContext) error { - if localHeader.Time != blockData.Timestamp { - return fmt.Errorf("timestamp mismatch at block %d: local=%d, batch=%d", - blockData.Number, localHeader.Time, blockData.Timestamp) - } - if localHeader.GasLimit != blockData.GasLimit { - return fmt.Errorf("gasLimit mismatch at block %d: local=%d, batch=%d", - blockData.Number, localHeader.GasLimit, blockData.GasLimit) - } - switch { - case blockData.BaseFee != nil && localHeader.BaseFee != nil: - if localHeader.BaseFee.Cmp(blockData.BaseFee) != 0 { - return fmt.Errorf("baseFee mismatch at block %d: local=%s, batch=%s", - blockData.Number, localHeader.BaseFee.String(), blockData.BaseFee.String()) - } - case blockData.BaseFee == nil && localHeader.BaseFee == nil: - // Both nil — pre-EIP-1559 or legacy batch format, OK. - default: - // One side has BaseFee, the other doesn't — structural inconsistency. - return fmt.Errorf("baseFee nil mismatch at block %d: local=%v, batch=%v", - blockData.Number, localHeader.BaseFee, blockData.BaseFee) - } - // Batch internal consistency check: txsNum in the block context should match the - // actual number of transactions assembled in SafeL2Data (L1 messages + L2 txs). - // This catches batch parsing/corruption issues, not local-vs-L1 divergence. - // Local-vs-L1 transaction divergence is covered by state root verification - // in verifyBatchRoots (different txs → different state root). - if blockData.SafeL2Data != nil { - batchTxCount := len(blockData.SafeL2Data.Transactions) - if batchTxCount != int(blockData.txsNum) { - return fmt.Errorf("batch internal tx count inconsistency at block %d: blockContext.txsNum=%d, safeL2Data.Transactions=%d", - blockData.Number, blockData.txsNum, batchTxCount) - } - } - return nil -} - -// recordL1Blocks saves L1 block hashes for reorg detection. -// Returns an error if any header fetch fails — the caller must not advance -// derivation height to avoid permanent gaps in L1 block hash tracking. -func (d *Derivation) recordL1Blocks(ctx context.Context, from, to uint64) error { - for h := from; h <= to; h++ { - header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(h))) - if err != nil { - return fmt.Errorf("failed to get L1 header at %d: %w", h, err) - } - - var hashBytes [32]byte - copy(hashBytes[:], header.Hash().Bytes()) - - d.db.WriteDerivationL1Block(&db.DerivationL1Block{ - Number: h, - Hash: hashBytes, - }) - } - return nil -} func (d *Derivation) fetchRollupLog(ctx context.Context, from, to uint64) ([]eth.Log, error) { query := ethereum.FilterQuery{ diff --git a/node/derivation/reorg.go b/node/derivation/reorg.go new file mode 100644 index 000000000..8773e3117 --- /dev/null +++ b/node/derivation/reorg.go @@ -0,0 +1,114 @@ +package derivation + +import ( + "context" + "fmt" + "math/big" + + "github.com/morph-l2/go-ethereum/common" + + "morph-l2/node/db" +) + +// detectReorg checks recent L1 blocks for hash mismatches indicating a reorg. +// Returns the L1 height where reorg was first detected, or nil if no reorg. +// +// Optimization: checks the newest saved block first. If it matches, there is +// no reorg (1 RPC call in the common case). Only when the newest block +// mismatches does it do a full oldest-to-newest scan to find the earliest +// divergence point. +func (d *Derivation) detectReorg(ctx context.Context) (*uint64, error) { + latestDerivation := d.db.ReadLatestDerivationL1Height() + if latestDerivation == nil { + return nil, nil + } + + checkFrom := d.startHeight + if *latestDerivation > d.reorgCheckDepth && (*latestDerivation-d.reorgCheckDepth) > checkFrom { + checkFrom = *latestDerivation - d.reorgCheckDepth + } + + savedBlocks := d.db.ReadDerivationL1BlockRange(checkFrom, *latestDerivation) + if len(savedBlocks) == 0 { + return nil, nil + } + + // Fast path: check the newest block first. If it matches, no reorg occurred. + newest := savedBlocks[len(savedBlocks)-1] + newestHeader, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(newest.Number))) + if err != nil { + return nil, fmt.Errorf("failed to get L1 header at %d: %w", newest.Number, err) + } + if newestHeader.Hash() == common.BytesToHash(newest.Hash[:]) { + return nil, nil + } + + // Slow path: reorg detected. Scan oldest-to-newest to find the earliest divergence. + for i := 0; i < len(savedBlocks); i++ { + block := savedBlocks[i] + header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(block.Number))) + if err != nil { + return nil, fmt.Errorf("failed to get L1 header at %d: %w", block.Number, err) + } + savedHash := common.BytesToHash(block.Hash[:]) + if header.Hash() != savedHash { + d.logger.Info("L1 block hash mismatch detected", + "height", block.Number, + "savedHash", savedHash.Hex(), + "currentHash", header.Hash().Hex(), + ) + return &block.Number, nil + } + } + return nil, nil +} + +// handleL1Reorg handles an L1 reorg detected at the given L1 height. +// It only cleans up derivation DB state and resets the derivation L1 height +// so the next derivation loop re-processes from the reorg point. +// +// L1 reorg does NOT directly trigger an L2 rollback — in most cases the same +// batch tx gets re-included in a new L1 block with identical content, so L2 +// blocks remain valid. The normal derivation loop will re-fetch batches and +// run verifyBlockContext / verifyBatchRoots; only if those comparisons fail +// will an L2 rollback be triggered through rollbackLocalChain. +func (d *Derivation) handleL1Reorg(reorgAtL1Height uint64) error { + d.logger.Info("L1 reorg detected, cleaning DB records and restarting derivation from reorg point", + "reorgAtL1Height", reorgAtL1Height) + + d.db.DeleteDerivationL1BlocksFrom(reorgAtL1Height) + + if reorgAtL1Height > d.startHeight { + d.db.WriteLatestDerivationL1Height(reorgAtL1Height - 1) + } else { + // Reorg at or before startHeight — reset so next loop starts from startHeight. + if d.startHeight > 0 { + d.db.WriteLatestDerivationL1Height(d.startHeight - 1) + } else { + d.db.WriteLatestDerivationL1Height(0) + } + } + + return nil +} + +// recordL1Blocks saves L1 block hashes for reorg detection. +// Returns an error if any header fetch fails — the caller must not advance +// derivation height to avoid permanent gaps in L1 block hash tracking. +func (d *Derivation) recordL1Blocks(ctx context.Context, from, to uint64) error { + for h := from; h <= to; h++ { + header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(h))) + if err != nil { + return fmt.Errorf("failed to get L1 header at %d: %w", h, err) + } + + var hashBytes [32]byte + copy(hashBytes[:], header.Hash().Bytes()) + + d.db.WriteDerivationL1Block(&db.DerivationL1Block{ + Number: h, + Hash: hashBytes, + }) + } + return nil +} diff --git a/node/derivation/verify.go b/node/derivation/verify.go new file mode 100644 index 000000000..271b4a3a5 --- /dev/null +++ b/node/derivation/verify.go @@ -0,0 +1,98 @@ +package derivation + +import ( + "bytes" + "fmt" + + "github.com/morph-l2/go-ethereum/accounts/abi/bind" + "github.com/morph-l2/go-ethereum/common" + eth "github.com/morph-l2/go-ethereum/core/types" +) + +// rollbackLocalChain rolls back the local L2 chain to the specified block number. +// This is only triggered when batch data comparison fails — i.e. the local L2 block +// does not match the L1 batch data (block context mismatch or state root mismatch). +// After rollback, the caller re-derives blocks using L1 batch data as source of truth. +func (d *Derivation) rollbackLocalChain(targetBlockNumber uint64) error { + d.logger.Error("L2 chain rollback not yet implemented", + "targetBlockNumber", targetBlockNumber) + + // TODO: Implement actual rollback via geth SetHead engine API: + // 1. Expose SetL2Head(number uint64) in go-ethereum/eth/catalyst/l2_api.go + // 2. Add SetHead method to go-ethereum/ethclient/authclient + // 3. Add SetHead method to node/types/retryable_client.go + // 4. Call d.l2Client.SetHead(d.ctx, targetBlockNumber) + return fmt.Errorf("rollback not implemented yet, target=%d", targetBlockNumber) +} + +// verifyBatchRoots verifies that the local state root and withdrawal root match the L1 batch data. +func (d *Derivation) verifyBatchRoots(batchInfo *BatchInfo, lastHeader *eth.Header) error { + withdrawalRoot, err := d.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ + BlockNumber: lastHeader.Number, + }) + if err != nil { + return fmt.Errorf("get withdrawal root failed: %w", err) + } + + rootMismatch := !bytes.Equal(lastHeader.Root.Bytes(), batchInfo.root.Bytes()) + withdrawalMismatch := !bytes.Equal(withdrawalRoot[:], batchInfo.withdrawalRoot.Bytes()) + + if rootMismatch || withdrawalMismatch { + // Check if should skip validation during upgrade transition + if d.switchTime > 0 { + beforeSwitch := lastHeader.Time < d.switchTime + if (beforeSwitch && !d.useZktrie) || (!beforeSwitch && d.useZktrie) { + d.logger.Info("Root validation skipped during upgrade transition", + "originStateRootHash", batchInfo.root, + "deriveStateRootHash", lastHeader.Root.Hex(), + "blockTimestamp", lastHeader.Time, + "switchTime", d.switchTime, + "useZktrie", d.useZktrie, + ) + return nil + } + } + return fmt.Errorf("root mismatch: stateRoot(l1=%s, local=%s) withdrawalRoot(l1=%s, local=%s)", + batchInfo.root.Hex(), lastHeader.Root.Hex(), + batchInfo.withdrawalRoot.Hex(), common.BytesToHash(withdrawalRoot[:]).Hex()) + } + return nil +} + +// verifyBlockContext compares a local L2 block header against the batch block context from L1. +func (d *Derivation) verifyBlockContext(localHeader *eth.Header, blockData *BlockContext) error { + if localHeader.Time != blockData.Timestamp { + return fmt.Errorf("timestamp mismatch at block %d: local=%d, batch=%d", + blockData.Number, localHeader.Time, blockData.Timestamp) + } + if localHeader.GasLimit != blockData.GasLimit { + return fmt.Errorf("gasLimit mismatch at block %d: local=%d, batch=%d", + blockData.Number, localHeader.GasLimit, blockData.GasLimit) + } + switch { + case blockData.BaseFee != nil && localHeader.BaseFee != nil: + if localHeader.BaseFee.Cmp(blockData.BaseFee) != 0 { + return fmt.Errorf("baseFee mismatch at block %d: local=%s, batch=%s", + blockData.Number, localHeader.BaseFee.String(), blockData.BaseFee.String()) + } + case blockData.BaseFee == nil && localHeader.BaseFee == nil: + // Both nil — pre-EIP-1559 or legacy batch format, OK. + default: + // One side has BaseFee, the other doesn't — structural inconsistency. + return fmt.Errorf("baseFee nil mismatch at block %d: local=%v, batch=%v", + blockData.Number, localHeader.BaseFee, blockData.BaseFee) + } + // Batch internal consistency check: txsNum in the block context should match the + // actual number of transactions assembled in SafeL2Data (L1 messages + L2 txs). + // This catches batch parsing/corruption issues, not local-vs-L1 divergence. + // Local-vs-L1 transaction divergence is covered by state root verification + // in verifyBatchRoots (different txs → different state root). + if blockData.SafeL2Data != nil { + batchTxCount := len(blockData.SafeL2Data.Transactions) + if batchTxCount != int(blockData.txsNum) { + return fmt.Errorf("batch internal tx count inconsistency at block %d: blockContext.txsNum=%d, safeL2Data.Transactions=%d", + blockData.Number, blockData.txsNum, batchTxCount) + } + } + return nil +} From 3a27baeeef4544e24dcb8890fd1efd236fabfeab Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 7 May 2026 14:41:54 +0800 Subject: [PATCH 11/26] fix devnet docker submitter config --- ops/docker/docker-compose-4nodes.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ops/docker/docker-compose-4nodes.yml b/ops/docker/docker-compose-4nodes.yml index 32ea8b79b..39febd04a 100644 --- a/ops/docker/docker-compose-4nodes.yml +++ b/ops/docker/docker-compose-4nodes.yml @@ -403,7 +403,7 @@ services: - "7546:8546" - "7551:8551" healthcheck: - test: curl -f http://localhost:8545 + test: ["CMD-SHELL", "wget -qO- --header='Content-Type: application/json' --post-data='{\"jsonrpc\":\"2.0\",\"method\":\"eth_chainId\",\"params\":[],\"id\":1}' http://localhost:8545 | grep -q '\"result\"'"] interval: 30s timeout: 5s retries: 3 @@ -483,6 +483,7 @@ services: - TX_SUBMITTER_FINALIZE=true - TX_SUBMITTER_MAX_FINALIZE_NUM=100 - TX_SUBMITTER_PRIORITY_ROLLUP=false + - TX_SUBMITTER_SEAL_BATCH=true - TX_SUBMITTER_METRICS_SERVER_ENABLE=false - TX_SUBMITTER_METRICS_HOSTNAME=0.0.0.0 - TX_SUBMITTER_METRICS_PORT=6060 @@ -525,6 +526,7 @@ services: - TX_SUBMITTER_FINALIZE=false - TX_SUBMITTER_MAX_FINALIZE_NUM=100 - TX_SUBMITTER_PRIORITY_ROLLUP=false + - TX_SUBMITTER_SEAL_BATCH=true - TX_SUBMITTER_METRICS_SERVER_ENABLE=false - TX_SUBMITTER_METRICS_HOSTNAME=0.0.0.0 - TX_SUBMITTER_METRICS_PORT=6060 @@ -567,6 +569,7 @@ services: - TX_SUBMITTER_FINALIZE=false - TX_SUBMITTER_MAX_FINALIZE_NUM=100 - TX_SUBMITTER_PRIORITY_ROLLUP=false + - TX_SUBMITTER_SEAL_BATCH=true - TX_SUBMITTER_METRICS_SERVER_ENABLE=false - TX_SUBMITTER_METRICS_HOSTNAME=0.0.0.0 - TX_SUBMITTER_METRICS_PORT=6060 @@ -609,6 +612,7 @@ services: - TX_SUBMITTER_FINALIZE=false - TX_SUBMITTER_MAX_FINALIZE_NUM=100 - TX_SUBMITTER_PRIORITY_ROLLUP=false + - TX_SUBMITTER_SEAL_BATCH=true - TX_SUBMITTER_METRICS_SERVER_ENABLE=false - TX_SUBMITTER_METRICS_HOSTNAME=0.0.0.0 - TX_SUBMITTER_METRICS_PORT=6060 From b793c64ea3468f631eaea6d545613069ce2e3e20 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 7 May 2026 15:56:52 +0800 Subject: [PATCH 12/26] support reth execution client in devnet --- Makefile | 46 +++++++++-- node/ops-morph/docker-compose.yml | 16 ++-- node/ops-morph/testnet/docker-compose.yml | 48 ++++++------ node/ops-morph/testnet/static-nodes.json | 2 +- ops/devnet-morph/devnet/__init__.py | 14 +++- .../test_devnet_execution_client.py | 21 +++++ .../docker-compose.override.yml | 20 ++--- ops/docker-sequencer-test/run-test.sh | 20 ++--- .../scripts/tx-generator.sh | 2 +- ops/docker/docker-compose-4nodes.yml | 76 +++++++++---------- ops/docker/docker-compose-reth.yml | 69 +++++++++++++++++ ops/docker/static-nodes.json | 8 +- oracle/docker-compose.yml | 2 +- 13 files changed, 237 insertions(+), 107 deletions(-) create mode 100644 ops/devnet-morph/test_devnet_execution_client.py create mode 100644 ops/docker/docker-compose-reth.yml diff --git a/Makefile b/Makefile index c8a72db59..2ff3a00b5 100644 --- a/Makefile +++ b/Makefile @@ -137,25 +137,52 @@ go-ubuntu-builder: ################## devnet 4 nodes #################### -devnet-up: submodules go-ubuntu-builder - python3 ops/devnet-morph/main.py --polyrepo-dir=. +EXECUTION_CLIENT ?= geth +MORPH_RETH_DIR ?= ../morph-reth +MORPH_RETH_BUILD_PROFILE ?= release +MORPH_RETH_RUSTFLAGS ?= +MORPH_RETH_DOCKER_TARGET ?= builder +MORPH_RETH_ENTRYPOINT ?= /app/morph-reth +export MORPH_RETH_DIR +export MORPH_RETH_BUILD_PROFILE +export MORPH_RETH_RUSTFLAGS +export MORPH_RETH_DOCKER_TARGET +export MORPH_RETH_ENTRYPOINT +DEVNET_COMPOSE_FILES := -f docker-compose-4nodes.yml + +ifeq ($(EXECUTION_CLIENT),geth) +DEVNET_EXECUTION_DEPS := submodules +else ifeq ($(EXECUTION_CLIENT),reth) +DEVNET_EXECUTION_DEPS := reth +DEVNET_COMPOSE_FILES += -f docker-compose-reth.yml +else +$(error unsupported EXECUTION_CLIENT "$(EXECUTION_CLIENT)", expected "geth" or "reth") +endif + +devnet-up: $(DEVNET_EXECUTION_DEPS) go-ubuntu-builder + python3 ops/devnet-morph/main.py --polyrepo-dir=. --execution-client=$(EXECUTION_CLIENT) .PHONY: devnet-up -devnet-up-debugccc: - python3 ops/devnet-morph/main.py --polyrepo-dir=. --debugccc +devnet-up-reth: + $(MAKE) devnet-up EXECUTION_CLIENT=reth +.PHONY: devnet-up-reth + +devnet-up-debugccc: $(DEVNET_EXECUTION_DEPS) go-ubuntu-builder + python3 ops/devnet-morph/main.py --polyrepo-dir=. --execution-client=$(EXECUTION_CLIENT) --debugccc .PHONY: devnet-up-debugccc devnet-down: - cd ops/docker && docker compose -f docker-compose-4nodes.yml down + cd ops/docker && docker compose $(DEVNET_COMPOSE_FILES) down .PHONY: devnet-down devnet-clean-build: devnet-l1-clean - cd ops/docker && docker compose -f docker-compose-4nodes.yml down --volumes --remove-orphans + cd ops/docker && docker compose $(DEVNET_COMPOSE_FILES) down --volumes --remove-orphans docker volume ls --filter name=docker_ --format='{{.Name}}' | xargs docker volume rm 2>/dev/null || true rm -rf ops/l2-genesis/.devnet rm -rf ops/docker/.devnet rm -rf ops/docker/consensus/beacondata ops/docker/consensus/validatordata ops/docker/consensus/genesis.ssz rm -rf ops/docker/execution/geth + rm -rf ops/docker/execution/reth .PHONY: devnet-clean-build devnet-clean: devnet-clean-build @@ -171,9 +198,14 @@ devnet-l1-clean: .PHONY: devnet-l1-clean devnet-logs: - @(cd ops/docker && docker-compose logs -f) + @(cd ops/docker && docker compose $(DEVNET_COMPOSE_FILES) logs -f) .PHONY: devnet-logs +reth: + @test -d "$(MORPH_RETH_DIR)" || (echo "morph-reth directory not found: $(MORPH_RETH_DIR)" && exit 1) + docker build -t morph-reth:latest --target "$(MORPH_RETH_DOCKER_TARGET)" --build-arg BUILD_PROFILE="$(MORPH_RETH_BUILD_PROFILE)" --build-arg RUSTFLAGS="$(MORPH_RETH_RUSTFLAGS)" "$(MORPH_RETH_DIR)" +.PHONY: reth + # tx-submitter SUBMITTERS := $(shell grep -o 'tx-submitter-[0-9]*[^:]' ops/docker/docker-compose-4nodes.yml | sort | uniq) rebuild-all-tx-submitter: diff --git a/node/ops-morph/docker-compose.yml b/node/ops-morph/docker-compose.yml index 6557ba900..4f9106aad 100644 --- a/node/ops-morph/docker-compose.yml +++ b/node/ops-morph/docker-compose.yml @@ -1,11 +1,11 @@ version: '3.8' volumes: - sequencer_geth_data: + sequencer_el_data: sequencer_node_data: services: - sequencer_geth: + morph-el-0: image: morph/l2geth:latest ports: - "8545:8545" @@ -18,7 +18,7 @@ services: timeout: 5s retries: 3 volumes: - - "sequencer_geth_data:${GETH_DATA_DIR}" + - "sequencer_el_data:${GETH_DATA_DIR}" - "${PWD}/jwt-secret.txt:${JWT_SECRET_PATH}" - "${PWD}/genesis_geth.json:${GENESIS_FILE_PATH}" entrypoint: # pass the L2 specific flags by overriding the entry-point and adding extra arguments @@ -27,7 +27,7 @@ services: sequencer_node: depends_on: - sequencer_geth: + morph-el-0: condition: service_started build: context: .. @@ -37,8 +37,8 @@ services: - "26656:26656" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://sequencer_geth:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://sequencer_geth:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-0:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-0:8551 - MORPH_NODE_L2_ENGINE_AUTH=jwt-secret.txt ## todo need to replace it to a public network - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} @@ -54,7 +54,7 @@ services: tx-submitter: depends_on: - sequencer_geth: + morph-el-0: condition: service_started sequencer_node: condition: service_started @@ -62,7 +62,7 @@ services: command: rollup environment: - TX_SUBMITTER_L1_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - - TX_SUBMITTER_L2_RPC_URL=http://sequencer_geth:8545 + - TX_SUBMITTER_L2_RPC_URL=http://morph-el-0:8545 - TX_SUBMITTER_L1_RPC_URL=${L1_ETH_RPC} - TX_SUBMITTER_ROLLUP_CONTRACT_ADDRESS=0x6900000000000000000000000000000000000010 - TX_SUBMITTER_EVENT_NAME=SubmitBatches diff --git a/node/ops-morph/testnet/docker-compose.yml b/node/ops-morph/testnet/docker-compose.yml index 95330c83e..d0322fb09 100644 --- a/node/ops-morph/testnet/docker-compose.yml +++ b/node/ops-morph/testnet/docker-compose.yml @@ -32,7 +32,7 @@ volumes: o: bind services: - morph-geth-0: + morph-el-0: image: morph/l2geth:latest ports: - "8545:8545" @@ -48,9 +48,9 @@ services: - "/bin/bash" - "/entrypoint.sh" - morph-geth-1: + morph-el-1: depends_on: - - morph-geth-0 + - morph-el-0 image: morph/l2geth:latest ports: - "8645:8545" @@ -63,14 +63,14 @@ services: - "${PWD}/../genesis_geth.json:/genesis.json" - "${PWD}/static-nodes.json:/db/geth/static-nodes.json" environment: - - BOOT_NODES=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-geth-0:30303 + - BOOT_NODES=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303 entrypoint: # pass the L2 specific flags by overriding the entry-point and adding extra arguments - "/bin/bash" - "/entrypoint.sh" - morph-geth-2: + morph-el-2: depends_on: - - morph-geth-0 + - morph-el-0 image: morph/l2geth:latest ports: - "8745:8545" @@ -83,14 +83,14 @@ services: - "${PWD}/../genesis_geth.json:/genesis.json" - "${PWD}/static-nodes.json:/db/geth/static-nodes.json" environment: - - BOOT_NODES=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-geth-0:30303 + - BOOT_NODES=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303 entrypoint: # pass the L2 specific flags by overriding the entry-point and adding extra arguments - "/bin/bash" - "/entrypoint.sh" - morph-geth-3: + morph-el-3: depends_on: - - morph-geth-0 + - morph-el-0 image: morph/l2geth:latest ports: - "8845:8545" @@ -103,14 +103,14 @@ services: - "${PWD}/../genesis_geth.json:/genesis.json" - "${PWD}/static-nodes.json:/db/geth/static-nodes.json" environment: - - BOOT_NODES=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-geth-0:30303 + - BOOT_NODES=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303 entrypoint: # pass the L2 specific flags by overriding the entry-point and adding extra arguments - "/bin/bash" - "/entrypoint.sh" node-0: depends_on: - morph-geth-0: + morph-el-0: condition: service_started image: morph-node:latest ports: @@ -119,8 +119,8 @@ services: - "26658" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-0:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-0:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-0:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-0:8551 - MORPH_NODE_L2_ENGINE_AUTH=jwt-secret.txt - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=0x6900000000000000000000000000000000000001 @@ -134,7 +134,7 @@ services: node-1: depends_on: - morph-geth-1: + morph-el-1: condition: service_started image: morph-node:latest ports: @@ -143,8 +143,8 @@ services: - "26658" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-1:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-1:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-1:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-1:8551 - MORPH_NODE_L2_ENGINE_AUTH=jwt-secret.txt - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=0x6900000000000000000000000000000000000001 @@ -158,7 +158,7 @@ services: node-2: depends_on: - morph-geth-2: + morph-el-2: condition: service_started image: morph-node:latest ports: @@ -167,8 +167,8 @@ services: - "26658" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-2:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-2:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-2:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-2:8551 - MORPH_NODE_L2_ENGINE_AUTH=jwt-secret.txt - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=0x6900000000000000000000000000000000000001 @@ -182,17 +182,17 @@ services: node-3: depends_on: - morph-geth-3: + morph-el-3: condition: service_started - image: -node:latest + image: morph-node:latest ports: - "26656" - "26657" - "26658" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-3:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-3:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-3:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-3:8551 - MORPH_NODE_L2_ENGINE_AUTH=jwt-secret.txt - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=0x6900000000000000000000000000000000000001 @@ -202,4 +202,4 @@ services: command: > morphnode --dev-sequencer - --home $NODE_DATA_DIR \ No newline at end of file + --home $NODE_DATA_DIR diff --git a/node/ops-morph/testnet/static-nodes.json b/node/ops-morph/testnet/static-nodes.json index a8876e3dd..e3b48b8d5 100644 --- a/node/ops-morph/testnet/static-nodes.json +++ b/node/ops-morph/testnet/static-nodes.json @@ -1 +1 @@ -["enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-geth-0:30303"] \ No newline at end of file +["enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303"] \ No newline at end of file diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index 385a7a2a3..3fc3bff79 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -21,6 +21,8 @@ parser = argparse.ArgumentParser(description='devnet launcher') parser.add_argument('--polyrepo-dir', help='Directory of the polyrepo', default=os.getcwd()) parser.add_argument('--only-l1', help='Only bootstrap l1 geth', action="store_true") +parser.add_argument('--execution-client', choices=('geth', 'reth'), default='geth', + help='L2 execution client implementation to run') # parser.add_argument('--deploy', help='Whether the contracts should be predeployed or deployed', action="store_true") parser.add_argument('--debugccc', help='Whether set the debug log level for ccc', action="store_true") @@ -30,6 +32,13 @@ ETH = GWEI * GWEI +def compose_file_args(execution_client): + args = ['-f', 'docker-compose-4nodes.yml'] + if execution_client == 'reth': + args.extend(['-f', 'docker-compose-reth.yml']) + return args + + class Bunch: def __init__(self, **kwds): self.__dict__.update(kwds) @@ -255,12 +264,11 @@ def devnet_deploy(paths, args): envfile.truncate() envfile.close() - log.info('Bringing up L2.') + log.info(f'Bringing up L2 with {args.execution_client}.') - run_command(['docker', 'compose', '-f', 'docker-compose-4nodes.yml', 'up', - '--no-recreate','-d'], check=False, cwd=paths.ops_dir, + run_command(['docker', 'compose', *compose_file_args(args.execution_client), 'up', '-d'], check=False, cwd=paths.ops_dir, env={ 'MORPH_PORTAL': addresses['Proxy__L1MessageQueueWithGasPriceOracle'], 'MORPH_ROLLUP': addresses['Proxy__Rollup'], diff --git a/ops/devnet-morph/test_devnet_execution_client.py b/ops/devnet-morph/test_devnet_execution_client.py new file mode 100644 index 000000000..167a15bb0 --- /dev/null +++ b/ops/devnet-morph/test_devnet_execution_client.py @@ -0,0 +1,21 @@ +import unittest +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from devnet import compose_file_args + + +class ExecutionClientComposeArgsTest(unittest.TestCase): + def test_geth_uses_base_compose_file(self): + self.assertEqual(compose_file_args("geth"), ["-f", "docker-compose-4nodes.yml"]) + + def test_reth_adds_reth_override_file(self): + self.assertEqual( + compose_file_args("reth"), + ["-f", "docker-compose-4nodes.yml", "-f", "docker-compose-reth.yml"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ops/docker-sequencer-test/docker-compose.override.yml b/ops/docker-sequencer-test/docker-compose.override.yml index 9cc69cae8..44aa1c3f7 100644 --- a/ops/docker-sequencer-test/docker-compose.override.yml +++ b/ops/docker-sequencer-test/docker-compose.override.yml @@ -3,20 +3,20 @@ version: '3.8' services: - morph-geth-0: - image: morph-geth-test:latest + morph-el-0: + image: morph-el-test:latest build: context: ../.. dockerfile: ops/docker-sequencer-test/Dockerfile.l2-geth-test - morph-geth-1: - image: morph-geth-test:latest + morph-el-1: + image: morph-el-test:latest - morph-geth-2: - image: morph-geth-test:latest + morph-el-2: + image: morph-el-test:latest - morph-geth-3: - image: morph-geth-test:latest + morph-el-3: + image: morph-el-test:latest node-0: image: morph-node-test:latest @@ -55,8 +55,8 @@ services: - MORPH_NODE_CONSENSUS_SWITCH_HEIGHT=${CONSENSUS_SWITCH_HEIGHT:-10} - sentry-geth-0: - image: morph-geth-test:latest + sentry-el-0: + image: morph-el-test:latest sentry-node-0: image: morph-node-test:latest diff --git a/ops/docker-sequencer-test/run-test.sh b/ops/docker-sequencer-test/run-test.sh index 81361fefa..9b0f0a553 100755 --- a/ops/docker-sequencer-test/run-test.sh +++ b/ops/docker-sequencer-test/run-test.sh @@ -109,9 +109,9 @@ build_test_images() { # log_warn "Build may fail due to network issues" # fi - # Build test geth image - log_info "Building morph-geth-test (using local go-ethereum)..." - docker build -t morph-geth-test:latest \ + # Build test execution image + log_info "Building morph-el-test (using local go-ethereum)..." + docker build -t morph-el-test:latest \ -f morph/ops/docker-sequencer-test/Dockerfile.l2-geth-test . # Build test node image @@ -275,17 +275,17 @@ start_l2_test() { # Stop any existing L2 containers $COMPOSE_CMD stop \ - morph-geth-0 morph-geth-1 morph-geth-2 morph-geth-3 \ + morph-el-0 morph-el-1 morph-el-2 morph-el-3 \ node-0 node-1 node-2 node-3 2>/dev/null || true # Note: Test images should already be built by build_test_images() # Uncomment below if you need to rebuild during start # log_info "Building L2 containers with test images..." - # $COMPOSE_CMD build morph-geth-0 node-0 + # $COMPOSE_CMD build morph-el-0 node-0 - # Start L2 geth nodes - log_info "Starting L2 geth nodes..." - $COMPOSE_CMD up -d morph-geth-0 morph-geth-1 morph-geth-2 morph-geth-3 + # Start L2 execution nodes + log_info "Starting L2 execution nodes..." + $COMPOSE_CMD up -d morph-el-0 morph-el-1 morph-el-2 morph-el-3 sleep 5 @@ -364,7 +364,7 @@ test_fullnode_sync() { # Start sentry node (fullnode) log_info "Starting fullnode (sentry-node-0)..." - $COMPOSE_CMD up -d sentry-geth-0 sentry-node-0 + $COMPOSE_CMD up -d sentry-el-0 sentry-node-0 sleep 10 wait_for_rpc "http://127.0.0.1:8945" @@ -522,7 +522,7 @@ case "${1:-}" in echo "Usage: $0 {build|setup|start|stop|clean|logs|test|tx|status|upgrade-height}" echo "" echo "Commands:" - echo " build - Build test Docker images (morph-geth-test, morph-node-test)" + echo " build - Build test Docker images (morph-el-test, morph-node-test)" echo " setup - Run full devnet setup (L1 + contracts + L2 genesis)" echo " start - Start L2 nodes with test images" echo " stop - Stop all containers" diff --git a/ops/docker-sequencer-test/scripts/tx-generator.sh b/ops/docker-sequencer-test/scripts/tx-generator.sh index 2311a64d5..d6ee40cdf 100644 --- a/ops/docker-sequencer-test/scripts/tx-generator.sh +++ b/ops/docker-sequencer-test/scripts/tx-generator.sh @@ -4,7 +4,7 @@ set -e -L2_RPC="${L2_RPC:-http://morph-geth-0:8545}" +L2_RPC="${L2_RPC:-http://morph-el-0:8545}" INTERVAL="${TX_INTERVAL:-5}" # seconds between txs PRIVATE_KEY="${PRIVATE_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" diff --git a/ops/docker/docker-compose-4nodes.yml b/ops/docker/docker-compose-4nodes.yml index 39febd04a..83d4f8b9e 100644 --- a/ops/docker/docker-compose-4nodes.yml +++ b/ops/docker/docker-compose-4nodes.yml @@ -8,13 +8,13 @@ volumes: morph_data_1: morph_data_2: morph_data_3: - sentry_geth_data: + sentry_el_data: node_data_0: node_data_1: node_data_2: node_data_3: sentry_node_data: - validator_geth_data: + validator_el_data: validator_node_data: layer1-el-data: layer1-cl-data: @@ -116,8 +116,8 @@ services: restart: unless-stopped # ========== L2 Services ========== - morph-geth-0: - container_name: morph-geth-0 + morph-el-0: + container_name: morph-el-0 depends_on: layer1-el: condition: service_started @@ -143,10 +143,10 @@ services: - "/bin/bash" - "/entrypoint.sh" - morph-geth-1: - container_name: morph-geth-1 + morph-el-1: + container_name: morph-el-1 depends_on: - - morph-geth-0 + - morph-el-0 image: morph-geth:latest restart: unless-stopped ports: @@ -167,10 +167,10 @@ services: - "/bin/bash" - "/entrypoint.sh" - morph-geth-2: - container_name: morph-geth-2 + morph-el-2: + container_name: morph-el-2 depends_on: - - morph-geth-0 + - morph-el-0 image: morph-geth:latest restart: unless-stopped ports: @@ -191,10 +191,10 @@ services: - "/bin/bash" - "/entrypoint.sh" - morph-geth-3: - container_name: morph-geth-3 + morph-el-3: + container_name: morph-el-3 depends_on: - - morph-geth-0 + - morph-el-0 image: morph-geth:latest restart: unless-stopped ports: @@ -219,7 +219,7 @@ services: node-0: container_name: node-0 depends_on: - morph-geth-0: + morph-el-0: condition: service_started image: morph-node:latest build: @@ -232,8 +232,8 @@ services: - "26658" - "26660" environment: - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-0:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-0:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-0:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-0:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} @@ -261,8 +261,8 @@ services: - "26658" - "26660" environment: - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-1:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-1:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-1:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-1:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} @@ -291,8 +291,8 @@ services: - "26660" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-2:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-2:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-2:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-2:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} @@ -321,8 +321,8 @@ services: - "26660" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://morph-geth-3:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://morph-geth-3:8551 + - MORPH_NODE_L2_ETH_RPC=http://morph-el-3:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://morph-el-3:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} @@ -337,8 +337,8 @@ services: morphnode --home $NODE_DATA_DIR - sentry-geth-0: - container_name: sentry-geth-0 + sentry-el-0: + container_name: sentry-el-0 depends_on: node-3: condition: service_started @@ -354,7 +354,7 @@ services: - "6060" - "30303" volumes: - - "sentry_geth_data:/db" + - "sentry_el_data:/db" - "${PWD}/jwt-secret.txt:/jwt-secret.txt" - "${PWD}/../l2-genesis/.devnet/genesis-l2.json:/genesis.json" - "${PWD}/static-nodes.json:/db/geth/static-nodes.json" @@ -376,8 +376,8 @@ services: - "26660" environment: - EMPTY_BLOCK_DELAY=true - - MORPH_NODE_L2_ETH_RPC=http://sentry-geth-0:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://sentry-geth-0:8551 + - MORPH_NODE_L2_ETH_RPC=http://sentry-el-0:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://sentry-el-0:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} @@ -392,8 +392,8 @@ services: --home $NODE_DATA_DIR - validator_geth: - container_name: validator_geth + validator-el: + container_name: validator-el image: morph-geth:latest depends_on: tx-submitter-0: @@ -408,7 +408,7 @@ services: timeout: 5s retries: 3 volumes: - - "validator_geth_data:${GETH_DATA_DIR}" + - "validator_el_data:${GETH_DATA_DIR}" - "${PWD}/jwt-secret.txt:${JWT_SECRET_PATH}" - "${PWD}/../l2-genesis/.devnet/genesis-l2.json:/genesis.json" entrypoint: # pass the L2 specific flags by overriding the entry-point and adding extra arguments @@ -418,7 +418,7 @@ services: validator_node: container_name: validator_node depends_on: - validator_geth: + validator-el: condition: service_started node-0: condition: service_started @@ -426,8 +426,8 @@ services: ports: - "26660" environment: - - MORPH_NODE_L2_ETH_RPC=http://validator_geth:8545 - - MORPH_NODE_L2_ENGINE_RPC=http://validator_geth:8551 + - MORPH_NODE_L2_ETH_RPC=http://validator-el:8545 + - MORPH_NODE_L2_ENGINE_RPC=http://validator-el:8551 - MORPH_NODE_L2_ENGINE_AUTH=${JWT_SECRET_PATH} ## todo need to replace it to a public network - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} @@ -470,7 +470,7 @@ services: - TX_SUBMITTER_BUILD_ENV=dev - TX_SUBMITTER_L1_ETH_RPC=${L1_ETH_RPC} - TX_SUBMITTER_L1_PRIVATE_KEY=0xd99870855d97327d20c666abc78588f1449b1fac76ed0c86c1afb9ce2db85f32 - - TX_SUBMITTER_L2_ETH_RPCS=http://morph-geth-0:8545,http://morph-geth-1:8545 + - TX_SUBMITTER_L2_ETH_RPCS=http://morph-el-0:8545,http://morph-el-1:8545 - TX_SUBMITTER_MAX_BATCH_BUILD_TIME=60s - TX_SUBMITTER_MAX_TX_SIZE=125952 - TX_SUBMITTER_POLL_INTERVAL=3s @@ -513,7 +513,7 @@ services: - TX_SUBMITTER_BUILD_ENV=dev - TX_SUBMITTER_L1_ETH_RPC=${L1_ETH_RPC} - TX_SUBMITTER_L1_PRIVATE_KEY=0x0890c388c3bf5e04fee1d8f3c117e5f44f435ced7baf7bfd66c10e1f3a3f4b10 - - TX_SUBMITTER_L2_ETH_RPCS=http://morph-geth-0:8545,http://morph-geth-1:8545 + - TX_SUBMITTER_L2_ETH_RPCS=http://morph-el-0:8545,http://morph-el-1:8545 - TX_SUBMITTER_MAX_BATCH_BUILD_TIME=60s - TX_SUBMITTER_MAX_TX_SIZE=125952 - TX_SUBMITTER_POLL_INTERVAL=3s @@ -556,7 +556,7 @@ services: - TX_SUBMITTER_BUILD_ENV=dev - TX_SUBMITTER_L1_ETH_RPC=${L1_ETH_RPC} - TX_SUBMITTER_L1_PRIVATE_KEY=0x6fd437eef7a83c486bd2e0a802ae071b3912d125ac31ac08f60841fd891559ae - - TX_SUBMITTER_L2_ETH_RPCS=http://morph-geth-2:8545,http://morph-geth-3:8545 + - TX_SUBMITTER_L2_ETH_RPCS=http://morph-el-2:8545,http://morph-el-3:8545 - TX_SUBMITTER_MAX_BATCH_BUILD_TIME=60s - TX_SUBMITTER_MAX_TX_SIZE=125952 - TX_SUBMITTER_POLL_INTERVAL=3s @@ -599,7 +599,7 @@ services: - TX_SUBMITTER_BUILD_ENV=dev - TX_SUBMITTER_L1_ETH_RPC=${L1_ETH_RPC} - TX_SUBMITTER_L1_PRIVATE_KEY=0x9ae53aecdaebe4dcbfec96f3123a2a8c53f9596bf4b3d5adc9a388ccb361b4c0 - - TX_SUBMITTER_L2_ETH_RPCS=http://morph-geth-2:8545,http://morph-geth-3:8545 + - TX_SUBMITTER_L2_ETH_RPCS=http://morph-el-2:8545,http://morph-el-3:8545 - TX_SUBMITTER_MAX_BATCH_BUILD_TIME=60s - TX_SUBMITTER_MAX_TX_SIZE=125952 - TX_SUBMITTER_POLL_INTERVAL=3s @@ -641,7 +641,7 @@ services: environment: - GAS_ORACLE_L1_RPC=${L1_ETH_RPC} - GAS_ORACLE_L1_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - - GAS_ORACLE_L2_RPC=http://morph-geth-0:8545 + - GAS_ORACLE_L2_RPC=http://morph-el-0:8545 - GAS_THRESHOLD=5 - INTERVAL=28000 - L2_GAS_PRICE_ORACLE=0x530000000000000000000000000000000000000F diff --git a/ops/docker/docker-compose-reth.yml b/ops/docker/docker-compose-reth.yml new file mode 100644 index 000000000..7a9d0420c --- /dev/null +++ b/ops/docker/docker-compose-reth.yml @@ -0,0 +1,69 @@ +x-reth-command: &reth-command + - node + - --chain + - /genesis.json + - --datadir + - /db + - --http + - --http.addr + - 0.0.0.0 + - --http.port + - "8545" + - --http.api + - web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr + - 0.0.0.0 + - --ws.port + - "8546" + - --ws.api + - web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr + - 0.0.0.0 + - --authrpc.port + - "8551" + - --authrpc.jwtsecret + - /jwt-secret.txt + - --disable-nat + - --disable-discovery + - --engine.persistence-threshold + - "256" + - --engine.memory-block-buffer-target + - "16" + - --engine.persistence-backpressure-threshold + - "512" + +x-reth-service: &reth-service + image: morph-reth:latest + user: "0:0" + entrypoint: + - ${MORPH_RETH_ENTRYPOINT:-/app/morph-reth} + command: *reth-command + +services: + morph-el-0: + <<: *reth-service + build: + context: ${MORPH_RETH_DIR:-../../../morph-reth} + dockerfile: Dockerfile + target: ${MORPH_RETH_DOCKER_TARGET:-builder} + args: + BUILD_PROFILE: ${MORPH_RETH_BUILD_PROFILE:-release} + RUSTFLAGS: ${MORPH_RETH_RUSTFLAGS:-} + + morph-el-1: + <<: *reth-service + + morph-el-2: + <<: *reth-service + + morph-el-3: + <<: *reth-service + + sentry-el-0: + <<: *reth-service + + validator-el: + <<: *reth-service + healthcheck: + disable: true diff --git a/ops/docker/static-nodes.json b/ops/docker/static-nodes.json index 2142637e3..7502f805e 100644 --- a/ops/docker/static-nodes.json +++ b/ops/docker/static-nodes.json @@ -1,5 +1,5 @@ -["enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-geth-0:30303", - "enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-geth-1:30303", - "enode://c91a993ace50749c89d37d554f12b2f4937d2ecca0232695bb33772d95a01f53564ad9dd71465c229be21e231e5c46929c2adaa78bea9d5f0966c46fca327c46@morph-geth-2:30303", - "enode://7211a9f1d896d6fef69154b97a868f1ac59e178eadfa54c3fc9644fa0f25ba2a0771927acdc08bb1d6ae2ea7a64f7ed9ddd74e97472e7d2e0df66dae5608fb10@morph-geth-3:30303" +["enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303", + "enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303", + "enode://c91a993ace50749c89d37d554f12b2f4937d2ecca0232695bb33772d95a01f53564ad9dd71465c229be21e231e5c46929c2adaa78bea9d5f0966c46fca327c46@morph-el-2:30303", + "enode://7211a9f1d896d6fef69154b97a868f1ac59e178eadfa54c3fc9644fa0f25ba2a0771927acdc08bb1d6ae2ea7a64f7ed9ddd74e97472e7d2e0df66dae5608fb10@morph-el-3:30303" ] \ No newline at end of file diff --git a/oracle/docker-compose.yml b/oracle/docker-compose.yml index 18e6e8b2c..6612f321f 100644 --- a/oracle/docker-compose.yml +++ b/oracle/docker-compose.yml @@ -13,7 +13,7 @@ services: - STAKING_ORACLE_BUILD_ENV=dev - STAKING_ORACLE_L1_ETH_RPC=${L1_ETH_RPC} - STAKING_ORACLE_RECORD_PRIVATE_KEY=${RECORD_PRIVATE_KEY} - - STAKING_ORACLE_L2_ETH_RPC=http://morph-geth-0:8545 + - STAKING_ORACLE_L2_ETH_RPC=http://morph-el-0:8545 - STAKING_ORACLE_L2_TENDERMINT_RPC=http://node-0:26657 - STAKING_ORACLE_L2_WS_ENDPOINT=http://node-0:26656 - STAKING_ORACLE_ROLLUP=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} From 67128c8795762544f768397aa3bd9261394519f7 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 7 May 2026 22:09:41 +0800 Subject: [PATCH 13/26] use published reth image by default --- Makefile | 26 +++++++++++++-- ops/docker/docker-compose-reth.yml | 52 ++++++++---------------------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/Makefile b/Makefile index 2ff3a00b5..c26c638dd 100644 --- a/Makefile +++ b/Makefile @@ -138,11 +138,19 @@ go-ubuntu-builder: ################## devnet 4 nodes #################### EXECUTION_CLIENT ?= geth +MORPH_RETH_BUILD_FROM_SOURCE ?= false +ifeq ($(MORPH_RETH_BUILD_FROM_SOURCE),true) +MORPH_RETH_IMAGE ?= morph-reth:latest +MORPH_RETH_ENTRYPOINT ?= /app/morph-reth +else +MORPH_RETH_IMAGE ?= ghcr.io/morph-l2/morph-reth:latest +MORPH_RETH_ENTRYPOINT ?= /usr/local/bin/morph-reth +endif MORPH_RETH_DIR ?= ../morph-reth MORPH_RETH_BUILD_PROFILE ?= release MORPH_RETH_RUSTFLAGS ?= MORPH_RETH_DOCKER_TARGET ?= builder -MORPH_RETH_ENTRYPOINT ?= /app/morph-reth +export MORPH_RETH_IMAGE export MORPH_RETH_DIR export MORPH_RETH_BUILD_PROFILE export MORPH_RETH_RUSTFLAGS @@ -153,8 +161,12 @@ DEVNET_COMPOSE_FILES := -f docker-compose-4nodes.yml ifeq ($(EXECUTION_CLIENT),geth) DEVNET_EXECUTION_DEPS := submodules else ifeq ($(EXECUTION_CLIENT),reth) -DEVNET_EXECUTION_DEPS := reth DEVNET_COMPOSE_FILES += -f docker-compose-reth.yml +ifeq ($(MORPH_RETH_BUILD_FROM_SOURCE),true) +DEVNET_EXECUTION_DEPS := reth +else +DEVNET_EXECUTION_DEPS := reth-image +endif else $(error unsupported EXECUTION_CLIENT "$(EXECUTION_CLIENT)", expected "geth" or "reth") endif @@ -185,6 +197,10 @@ devnet-clean-build: devnet-l1-clean rm -rf ops/docker/execution/reth .PHONY: devnet-clean-build +devnet-clean-build-reth: + $(MAKE) devnet-clean-build EXECUTION_CLIENT=reth +.PHONY: devnet-clean-build-reth + devnet-clean: devnet-clean-build docker image ls '*morph*' --format='{{.Repository}}' | xargs -r docker rmi docker image ls '*sentry-*' --format='{{.Repository}}' | xargs -r docker rmi @@ -201,9 +217,13 @@ devnet-logs: @(cd ops/docker && docker compose $(DEVNET_COMPOSE_FILES) logs -f) .PHONY: devnet-logs +reth-image: + docker pull "$(MORPH_RETH_IMAGE)" +.PHONY: reth-image + reth: @test -d "$(MORPH_RETH_DIR)" || (echo "morph-reth directory not found: $(MORPH_RETH_DIR)" && exit 1) - docker build -t morph-reth:latest --target "$(MORPH_RETH_DOCKER_TARGET)" --build-arg BUILD_PROFILE="$(MORPH_RETH_BUILD_PROFILE)" --build-arg RUSTFLAGS="$(MORPH_RETH_RUSTFLAGS)" "$(MORPH_RETH_DIR)" + docker build -t "$(MORPH_RETH_IMAGE)" --target "$(MORPH_RETH_DOCKER_TARGET)" --build-arg BUILD_PROFILE="$(MORPH_RETH_BUILD_PROFILE)" --build-arg RUSTFLAGS="$(MORPH_RETH_RUSTFLAGS)" "$(MORPH_RETH_DIR)" .PHONY: reth # tx-submitter diff --git a/ops/docker/docker-compose-reth.yml b/ops/docker/docker-compose-reth.yml index 7a9d0420c..920c8af98 100644 --- a/ops/docker/docker-compose-reth.yml +++ b/ops/docker/docker-compose-reth.yml @@ -1,55 +1,31 @@ x-reth-command: &reth-command - node - - --chain - - /genesis.json - - --datadir - - /db + - --chain=/genesis.json + - --datadir=/db - --http - - --http.addr - - 0.0.0.0 - - --http.port - - "8545" - - --http.api - - web3,debug,eth,txpool,net,trace,admin,reth + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth - --ws - - --ws.addr - - 0.0.0.0 - - --ws.port - - "8546" - - --ws.api - - web3,debug,eth,txpool,net,trace,admin,reth - - --authrpc.addr - - 0.0.0.0 - - --authrpc.port - - "8551" - - --authrpc.jwtsecret - - /jwt-secret.txt - - --disable-nat + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none - --disable-discovery - - --engine.persistence-threshold - - "256" - - --engine.memory-block-buffer-target - - "16" - - --engine.persistence-backpressure-threshold - - "512" x-reth-service: &reth-service - image: morph-reth:latest + image: ${MORPH_RETH_IMAGE:-ghcr.io/morph-l2/morph-reth:latest} user: "0:0" entrypoint: - - ${MORPH_RETH_ENTRYPOINT:-/app/morph-reth} + - ${MORPH_RETH_ENTRYPOINT:-/usr/local/bin/morph-reth} command: *reth-command services: morph-el-0: <<: *reth-service - build: - context: ${MORPH_RETH_DIR:-../../../morph-reth} - dockerfile: Dockerfile - target: ${MORPH_RETH_DOCKER_TARGET:-builder} - args: - BUILD_PROFILE: ${MORPH_RETH_BUILD_PROFILE:-release} - RUSTFLAGS: ${MORPH_RETH_RUSTFLAGS:-} morph-el-1: <<: *reth-service From 74eed9eb5da8bbfe60bb04ae22dd6d71cb76a891 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 7 May 2026 22:24:53 +0800 Subject: [PATCH 14/26] remove devnet execution client test --- .../test_devnet_execution_client.py | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 ops/devnet-morph/test_devnet_execution_client.py diff --git a/ops/devnet-morph/test_devnet_execution_client.py b/ops/devnet-morph/test_devnet_execution_client.py deleted file mode 100644 index 167a15bb0..000000000 --- a/ops/devnet-morph/test_devnet_execution_client.py +++ /dev/null @@ -1,21 +0,0 @@ -import unittest -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from devnet import compose_file_args - - -class ExecutionClientComposeArgsTest(unittest.TestCase): - def test_geth_uses_base_compose_file(self): - self.assertEqual(compose_file_args("geth"), ["-f", "docker-compose-4nodes.yml"]) - - def test_reth_adds_reth_override_file(self): - self.assertEqual( - compose_file_args("reth"), - ["-f", "docker-compose-4nodes.yml", "-f", "docker-compose-reth.yml"], - ) - - -if __name__ == "__main__": - unittest.main() From d2bb14ec16bf7ce1b0fc0cf9182cd81164b94504 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 8 May 2026 10:55:58 +0800 Subject: [PATCH 15/26] chore(docker-sequencer-test): rename bitget to polyrepo Replace the internal codename "bitget" with the neutral term "polyrepo" in build context references, variable names, and container paths. --- .../Dockerfile.l2-geth-test | 2 +- .../Dockerfile.l2-node-test | 36 +++++++++---------- ops/docker-sequencer-test/run-test.sh | 14 ++++---- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/ops/docker-sequencer-test/Dockerfile.l2-geth-test b/ops/docker-sequencer-test/Dockerfile.l2-geth-test index 1c053f44b..17de81dd1 100644 --- a/ops/docker-sequencer-test/Dockerfile.l2-geth-test +++ b/ops/docker-sequencer-test/Dockerfile.l2-geth-test @@ -1,5 +1,5 @@ # Build Geth for Sequencer Test -# Build context should be bitget/ (parent of morph) +# Build context should be the polyrepo root (parent of morph) FROM ghcr.io/morph-l2/go-ubuntu-builder:go-1.24-ubuntu AS builder # Copy local go-ethereum (not submodule) diff --git a/ops/docker-sequencer-test/Dockerfile.l2-node-test b/ops/docker-sequencer-test/Dockerfile.l2-node-test index 1ece1eb81..c7ce80847 100644 --- a/ops/docker-sequencer-test/Dockerfile.l2-node-test +++ b/ops/docker-sequencer-test/Dockerfile.l2-node-test @@ -5,33 +5,33 @@ FROM ghcr.io/morph-l2/go-ubuntu-builder:go-1.24-ubuntu AS builder # Order matters for cache efficiency # Copy go-ethereum dependency files -COPY ./go-ethereum/go.mod ./go-ethereum/go.sum /bitget/go-ethereum/ +COPY ./go-ethereum/go.mod ./go-ethereum/go.sum /polyrepo/go-ethereum/ # Copy tendermint dependency files -COPY ./tendermint/go.mod ./tendermint/go.sum /bitget/tendermint/ +COPY ./tendermint/go.mod ./tendermint/go.sum /polyrepo/tendermint/ # Copy morph go.work and all module dependency files -COPY ./morph/go.work ./morph/go.work.sum /bitget/morph/ -COPY ./morph/node/go.mod ./morph/node/go.sum /bitget/morph/node/ -COPY ./morph/bindings/go.mod ./morph/bindings/go.sum /bitget/morph/bindings/ -COPY ./morph/contracts/go.mod ./morph/contracts/go.sum /bitget/morph/contracts/ -COPY ./morph/oracle/go.mod ./morph/oracle/go.sum /bitget/morph/oracle/ -COPY ./morph/tx-submitter/go.mod ./morph/tx-submitter/go.sum /bitget/morph/tx-submitter/ -COPY ./morph/ops/l2-genesis/go.mod ./morph/ops/l2-genesis/go.sum /bitget/morph/ops/l2-genesis/ -COPY ./morph/ops/tools/go.mod ./morph/ops/tools/go.sum /bitget/morph/ops/tools/ -COPY ./morph/token-price-oracle/go.mod ./morph/token-price-oracle/go.sum /bitget/morph/token-price-oracle/ +COPY ./morph/go.work ./morph/go.work.sum /polyrepo/morph/ +COPY ./morph/node/go.mod ./morph/node/go.sum /polyrepo/morph/node/ +COPY ./morph/bindings/go.mod ./morph/bindings/go.sum /polyrepo/morph/bindings/ +COPY ./morph/contracts/go.mod ./morph/contracts/go.sum /polyrepo/morph/contracts/ +COPY ./morph/oracle/go.mod ./morph/oracle/go.sum /polyrepo/morph/oracle/ +COPY ./morph/tx-submitter/go.mod ./morph/tx-submitter/go.sum /polyrepo/morph/tx-submitter/ +COPY ./morph/ops/l2-genesis/go.mod ./morph/ops/l2-genesis/go.sum /polyrepo/morph/ops/l2-genesis/ +COPY ./morph/ops/tools/go.mod ./morph/ops/tools/go.sum /polyrepo/morph/ops/tools/ +COPY ./morph/token-price-oracle/go.mod ./morph/token-price-oracle/go.sum /polyrepo/morph/token-price-oracle/ # Download dependencies (this layer is cached if go.mod/go.sum don't change) -WORKDIR /bitget/morph/node +WORKDIR /polyrepo/morph/node RUN go mod download -x # Now copy all source code -COPY ./go-ethereum /bitget/go-ethereum -COPY ./tendermint /bitget/tendermint -COPY ./morph /bitget/morph +COPY ./go-ethereum /polyrepo/go-ethereum +COPY ./tendermint /polyrepo/tendermint +COPY ./morph /polyrepo/morph # Build (no need to download again, just compile) -WORKDIR /bitget/morph/node +WORKDIR /polyrepo/morph/node RUN make build # Final Stage @@ -41,7 +41,7 @@ RUN apt-get -qq update \ && apt-get -qq install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /bitget/morph/node/build/bin/tendermint /usr/local/bin/ -COPY --from=builder /bitget/morph/node/build/bin/morphnode /usr/local/bin/ +COPY --from=builder /polyrepo/morph/node/build/bin/tendermint /usr/local/bin/ +COPY --from=builder /polyrepo/morph/node/build/bin/morphnode /usr/local/bin/ CMD ["morphnode", "--home", "/data"] diff --git a/ops/docker-sequencer-test/run-test.sh b/ops/docker-sequencer-test/run-test.sh index 9b0f0a553..d1928de7e 100755 --- a/ops/docker-sequencer-test/run-test.sh +++ b/ops/docker-sequencer-test/run-test.sh @@ -6,7 +6,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MORPH_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -BITGET_ROOT="$(cd "$MORPH_ROOT/.." && pwd)" +POLYREPO_ROOT="$(cd "$MORPH_ROOT/.." && pwd)" OPS_DIR="$MORPH_ROOT/ops" DOCKER_DIR="$OPS_DIR/docker" DEVNET_DIR="$OPS_DIR/devnet-morph" @@ -87,17 +87,17 @@ set_upgrade_height() { } # Build test images (with -test suffix) -# Uses bitget/ as build context to access local go-ethereum and tendermint +# Uses the polyrepo root as build context to access local go-ethereum and tendermint build_test_images() { log_info "Building test Docker images..." - log_info "Using build context: $BITGET_ROOT" - + log_info "Using build context: $POLYREPO_ROOT" + # Build go-ubuntu-builder if needed cd "$MORPH_ROOT" make go-ubuntu-builder - - # Build from bitget/ directory to access all repos - cd "$BITGET_ROOT" + + # Build from the polyrepo root to access all repos + cd "$POLYREPO_ROOT" # # Copy go module cache to avoid network downloads # if [ -d "$HOME/go/pkg/mod" ]; then From 1672b69a1c4228f31203623d6c0434895c0b8c13 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 8 May 2026 10:56:58 +0800 Subject: [PATCH 16/26] docs(devnet): add docstring to compose_file_args --- ops/devnet-morph/devnet/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index 3fc3bff79..92b92f8cb 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -33,6 +33,7 @@ def compose_file_args(execution_client): + """Return docker-compose -f flags for the chosen L2 execution client.""" args = ['-f', 'docker-compose-4nodes.yml'] if execution_client == 'reth': args.extend(['-f', 'docker-compose-reth.yml']) From 4a2e1f3f809cf5ef3e66b8536430b7425e37e0e2 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 8 May 2026 11:22:19 +0800 Subject: [PATCH 17/26] docs(devnet): add docstring to devnet_deploy --- ops/devnet-morph/devnet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index 92b92f8cb..ebc73e2b7 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -154,8 +154,8 @@ def devnet_build(paths): }) -# Bring up the devnet where the contracts are deployed to L1 def devnet_deploy(paths, args): + """Bring up the devnet where the contracts are deployed to L1.""" if not test_port(9545): devnet_l1(paths) done_file = pjoin(paths.devnet_dir, 'done') From f9327506c49074ba2291721e98e0e478746372d9 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 8 May 2026 11:32:05 +0800 Subject: [PATCH 18/26] docs(devnet): add docstrings to remaining functions Cover the rest of the file with one-line docstrings to satisfy CodeRabbit's docstring coverage threshold. --- ops/devnet-morph/devnet/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index ebc73e2b7..d7b5b8f01 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -41,11 +41,15 @@ def compose_file_args(execution_client): class Bunch: + """Lightweight attribute container constructed from keyword arguments.""" + def __init__(self, **kwds): + """Store all keyword arguments as attributes on the instance.""" self.__dict__.update(kwds) def main(): + """Entry point: parse CLI arguments and bring up the L1-only or full devnet.""" args = parser.parse_args() polyrepo_dir = os.path.abspath(args.polyrepo_dir) @@ -82,6 +86,7 @@ def main(): def devnet_l1(paths, result=None): + """Start the L1 execution/consensus/validator stack and fund sequencer accounts.""" log.info('Starting L1.') layer1_dir = pjoin(paths.ops_dir, 'layer1') @@ -147,6 +152,7 @@ def devnet_l1(paths, result=None): def devnet_build(paths): + """Build the docker images declared in docker-compose-4nodes.yml.""" run_command(['docker', 'compose', '-f', 'docker-compose-4nodes.yml', 'build'], cwd=paths.ops_dir, env={ 'PWD': paths.ops_dir, 'DOCKER_BUILDKIT': '1', # (should be available by default in later versions, but explicitly enable it anyway) @@ -286,6 +292,7 @@ def devnet_deploy(paths, args): def wait_for_rpc_server(url): + """Block until the JSON-RPC server at url answers an eth_chainId call successfully.""" log.info(f'Waiting for RPC server at {url}') conn = http.client.HTTPConnection(url) @@ -306,6 +313,7 @@ def wait_for_rpc_server(url): def run_command(args, check=True, shell=False, cwd=None, env=None, output=None): + """Run a subprocess with the parent environment merged with the supplied env dict.""" env = env if env else {} return subprocess.run( args, @@ -323,6 +331,7 @@ def run_command(args, check=True, shell=False, cwd=None, env=None, output=None): def run_command_capture_output(args, check=True, shell=False, cwd=None, env=None): + """Run a subprocess and return its CompletedProcess with stdout/stderr captured.""" env = env if env else {} return subprocess.run( args, @@ -339,6 +348,7 @@ def run_command_capture_output(args, check=True, shell=False, cwd=None, env=None def wait_up(port, retries=10, wait_secs=1): + """Poll a TCP port on 127.0.0.1 until it accepts a connection or retries are exhausted.""" for i in range(0, retries): log.info(f'Trying 127.0.0.1:{port}') s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -354,6 +364,7 @@ def wait_up(port, retries=10, wait_secs=1): def test_port(port): + """Return True if a TCP connection to 127.0.0.1:port succeeds, False otherwise.""" log.info(f'Testing 127.0.0.1:{port}') s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: @@ -366,16 +377,19 @@ def test_port(port): def write_json(path, data): + """Serialize data to path as indented JSON.""" with open(path, 'w+') as f: json.dump(data, f, indent=' ') def read_json(path): + """Load and return the JSON document stored at path.""" with open(path, 'r') as f: return json.load(f) def eth_accounts(url): + """Call eth_accounts on url and return the raw JSON-RPC response body.""" log.info(f'Fetch eth_accounts {url}') conn = http.client.HTTPConnection(url) headers = {'Content-type': 'application/json'} From 6d8d745d955b8b3a23de7c8667fe0fa73d9ad646 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 8 May 2026 14:40:48 +0800 Subject: [PATCH 19/26] fix(devnet): isolate geth build config to prevent reth from inheriting Dockerfile.l2-geth Move the morph-el-0 build: section from docker-compose-4nodes.yml into a dedicated docker-compose-geth-build.yml, included only when EXECUTION_CLIENT=geth. The reth overlay now sees no build: on morph-el-0, eliminating the risk of docker compose up building geth code and tagging it as the reth image when the reth image is absent. --- Makefile | 1 + ops/devnet-morph/devnet/__init__.py | 2 ++ ops/docker/docker-compose-4nodes.yml | 3 --- ops/docker/docker-compose-geth-build.yml | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 ops/docker/docker-compose-geth-build.yml diff --git a/Makefile b/Makefile index c26c638dd..39b0eab6b 100644 --- a/Makefile +++ b/Makefile @@ -160,6 +160,7 @@ DEVNET_COMPOSE_FILES := -f docker-compose-4nodes.yml ifeq ($(EXECUTION_CLIENT),geth) DEVNET_EXECUTION_DEPS := submodules +DEVNET_COMPOSE_FILES += -f docker-compose-geth-build.yml else ifeq ($(EXECUTION_CLIENT),reth) DEVNET_COMPOSE_FILES += -f docker-compose-reth.yml ifeq ($(MORPH_RETH_BUILD_FROM_SOURCE),true) diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index d7b5b8f01..ce4f789a5 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -37,6 +37,8 @@ def compose_file_args(execution_client): args = ['-f', 'docker-compose-4nodes.yml'] if execution_client == 'reth': args.extend(['-f', 'docker-compose-reth.yml']) + elif execution_client == 'geth': + args.extend(['-f', 'docker-compose-geth-build.yml']) return args diff --git a/ops/docker/docker-compose-4nodes.yml b/ops/docker/docker-compose-4nodes.yml index 83d4f8b9e..180199ceb 100644 --- a/ops/docker/docker-compose-4nodes.yml +++ b/ops/docker/docker-compose-4nodes.yml @@ -122,9 +122,6 @@ services: layer1-el: condition: service_started image: morph-geth:latest - build: - context: ../.. - dockerfile: ops/docker/Dockerfile.l2-geth restart: unless-stopped ports: - "8545:8545" diff --git a/ops/docker/docker-compose-geth-build.yml b/ops/docker/docker-compose-geth-build.yml new file mode 100644 index 000000000..f8a3070ad --- /dev/null +++ b/ops/docker/docker-compose-geth-build.yml @@ -0,0 +1,5 @@ +services: + morph-el-0: + build: + context: ../.. + dockerfile: ops/docker/Dockerfile.l2-geth From 8a5fc0af223d75383b8446ab04904f1180c02665 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 8 May 2026 15:44:04 +0800 Subject: [PATCH 20/26] fix(devnet): reset reth inherited geth builds Keep the base devnet compose file self-contained for geth while using the reth overlay to explicitly reset inherited geth build definitions. Constraint: Do not include the devnet execution-client test file in this commit Rejected: Keep a separate geth build compose file | changes direct base compose usage Confidence: high Scope-risk: narrow --- Makefile | 1 - ops/devnet-morph/devnet/__init__.py | 2 -- ops/docker/docker-compose-4nodes.yml | 3 +++ ops/docker/docker-compose-geth-build.yml | 5 ----- ops/docker/docker-compose-reth.yml | 2 ++ 5 files changed, 5 insertions(+), 8 deletions(-) delete mode 100644 ops/docker/docker-compose-geth-build.yml diff --git a/Makefile b/Makefile index 39b0eab6b..c26c638dd 100644 --- a/Makefile +++ b/Makefile @@ -160,7 +160,6 @@ DEVNET_COMPOSE_FILES := -f docker-compose-4nodes.yml ifeq ($(EXECUTION_CLIENT),geth) DEVNET_EXECUTION_DEPS := submodules -DEVNET_COMPOSE_FILES += -f docker-compose-geth-build.yml else ifeq ($(EXECUTION_CLIENT),reth) DEVNET_COMPOSE_FILES += -f docker-compose-reth.yml ifeq ($(MORPH_RETH_BUILD_FROM_SOURCE),true) diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index ce4f789a5..d7b5b8f01 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -37,8 +37,6 @@ def compose_file_args(execution_client): args = ['-f', 'docker-compose-4nodes.yml'] if execution_client == 'reth': args.extend(['-f', 'docker-compose-reth.yml']) - elif execution_client == 'geth': - args.extend(['-f', 'docker-compose-geth-build.yml']) return args diff --git a/ops/docker/docker-compose-4nodes.yml b/ops/docker/docker-compose-4nodes.yml index 180199ceb..83d4f8b9e 100644 --- a/ops/docker/docker-compose-4nodes.yml +++ b/ops/docker/docker-compose-4nodes.yml @@ -122,6 +122,9 @@ services: layer1-el: condition: service_started image: morph-geth:latest + build: + context: ../.. + dockerfile: ops/docker/Dockerfile.l2-geth restart: unless-stopped ports: - "8545:8545" diff --git a/ops/docker/docker-compose-geth-build.yml b/ops/docker/docker-compose-geth-build.yml deleted file mode 100644 index f8a3070ad..000000000 --- a/ops/docker/docker-compose-geth-build.yml +++ /dev/null @@ -1,5 +0,0 @@ -services: - morph-el-0: - build: - context: ../.. - dockerfile: ops/docker/Dockerfile.l2-geth diff --git a/ops/docker/docker-compose-reth.yml b/ops/docker/docker-compose-reth.yml index 920c8af98..fecc42f89 100644 --- a/ops/docker/docker-compose-reth.yml +++ b/ops/docker/docker-compose-reth.yml @@ -26,6 +26,7 @@ x-reth-service: &reth-service services: morph-el-0: <<: *reth-service + build: !reset null morph-el-1: <<: *reth-service @@ -38,6 +39,7 @@ services: sentry-el-0: <<: *reth-service + build: !reset null validator-el: <<: *reth-service From 3e49457db49a9025d7489cf7a458262ff21374eb Mon Sep 17 00:00:00 2001 From: corey Date: Fri, 8 May 2026 17:56:46 +0800 Subject: [PATCH 21/26] refactor(node): remove validator/challenge bypass per SPEC-005 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete node/validator package (config.go, validator.go, validator_test.go) - Drop validator wiring from node/cmd/node/main.go and derivation.NewDerivationClient signature - Remove validator.challengeEnable / validator.privateKey CLI flags - Drop ChallengeEnable/ChallengeState invocation in derivation rollback path - Remove MORPH_NODE_VALIDATOR_PRIVATE_KEY env from docker compose files Refs: morph-l2/morph-specs SPEC-005 §3.2.1 Co-authored-by: Cursor --- node/cmd/node/main.go | 12 +- node/derivation/derivation.go | 13 +-- node/flags/flags.go | 17 --- node/ops-morph/docker-compose-validator.yml | 1 - node/validator/config.go | 46 -------- node/validator/validator.go | 118 -------------------- node/validator/validator_test.go | 48 -------- ops/docker/docker-compose-4nodes.yml | 1 - 8 files changed, 2 insertions(+), 254 deletions(-) delete mode 100644 node/validator/config.go delete mode 100644 node/validator/validator.go delete mode 100644 node/validator/validator_test.go diff --git a/node/cmd/node/main.go b/node/cmd/node/main.go index 5884fe6fd..4b5d4fc3c 100644 --- a/node/cmd/node/main.go +++ b/node/cmd/node/main.go @@ -30,7 +30,6 @@ import ( "morph-l2/node/sequencer/mock" "morph-l2/node/sync" "morph-l2/node/types" - "morph-l2/node/validator" ) func main() { @@ -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) @@ -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) } diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 16e4f36c7..e972bd426 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -27,7 +27,6 @@ import ( nodecommon "morph-l2/node/common" "morph-l2/node/sync" "morph-l2/node/types" - "morph-l2/node/validator" ) var ( @@ -42,7 +41,6 @@ type Derivation struct { RollupContractAddress common.Address confirmations rpc.BlockNumber l2Client *types.RetryableClient - validator *validator.Validator logger tmlog.Logger rollup *bindings.Rollup metrics *Metrics @@ -74,7 +72,7 @@ type DeployContractBackend interface { ethereum.TransactionReader } -func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db Database, val *validator.Validator, rollup *bindings.Rollup, logger tmlog.Logger) (*Derivation, error) { +func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db Database, rollup *bindings.Rollup, logger tmlog.Logger) (*Derivation, error) { l1Client, err := ethclient.Dial(cfg.L1.Addr) if err != nil { return nil, err @@ -124,7 +122,6 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, db: db, l1Client: l1Client, syncer: syncer, - validator: val, rollup: rollup, rollupABI: rollupAbi, legacyRollupABI: legacyRollupAbi, @@ -286,14 +283,6 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.metrics.SetBatchStatus(stateException) d.metrics.IncRollbackCount() - // TODO The challenge switch is currently on and will be turned on in the future - if d.validator != nil && d.validator.ChallengeEnable() { - if err := d.validator.ChallengeState(batchInfo.batchIndex); err != nil { - d.logger.Error("challenge state failed", "batchIndex", batchInfo.batchIndex, "error", err) - return - } - } - rollbackTarget := batchInfo.firstBlockNumber - 1 if err := d.rollbackLocalChain(rollbackTarget); err != nil { d.logger.Error("rollback failed, halting derivation to prevent infinite retry", diff --git a/node/flags/flags.go b/node/flags/flags.go index a94c7aa11..573090f9f 100644 --- a/node/flags/flags.go +++ b/node/flags/flags.go @@ -168,19 +168,6 @@ var ( EnvVar: prefixEnvVar("VALIDATOR"), } - ChallengeEnable = cli.BoolFlag{ - Name: "validator.challengeEnable", - Usage: "Enable the validator challenge", - EnvVar: prefixEnvVar("VALIDATOR_CHALLENGE_ENABLE"), - } - - // validator - ValidatorPrivateKey = cli.StringFlag{ - Name: "validator.privateKey", - Usage: "Private Key corresponding to SUBSIDY Owner", - EnvVar: prefixEnvVar("VALIDATOR_PRIVATE_KEY"), - } - // derivation RollupContractAddress = cli.StringFlag{ Name: "derivation.rollupAddress", @@ -358,10 +345,6 @@ var Flags = []cli.Flag{ TendermintConfigPath, MockEnabled, ValidatorEnable, - ChallengeEnable, - - // validator - ValidatorPrivateKey, // derivation RollupContractAddress, diff --git a/node/ops-morph/docker-compose-validator.yml b/node/ops-morph/docker-compose-validator.yml index 09a1efa74..0b0bc4d63 100644 --- a/node/ops-morph/docker-compose-validator.yml +++ b/node/ops-morph/docker-compose-validator.yml @@ -21,7 +21,6 @@ services: ## todo need to replace it to a public network - MORPH_NODE_L1_ETH_RPC=http://host.docker.internal:9545 - MORPH_NODE_L1_ETH_BEACON_RPC=http://host.docker.internal:3500 - - MORPH_NODE_VALIDATOR_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000001 - MORPH_NODE_ROLLUP_ADDRESS=0xa513e6e4b8f2a923d98304ec87f64353c4d5c853 - MORPH_NODE_DERIVATION_START_HEIGHT=1 - MORPH_NODE_DERIVATION_FETCH_BLOCK_RANGE=1000 diff --git a/node/validator/config.go b/node/validator/config.go deleted file mode 100644 index 986fd16d5..000000000 --- a/node/validator/config.go +++ /dev/null @@ -1,46 +0,0 @@ -package validator - -import ( - "crypto/ecdsa" - "math/big" - "strings" - - "github.com/morph-l2/go-ethereum/common" - "github.com/morph-l2/go-ethereum/crypto" - "github.com/urfave/cli" - - "morph-l2/node/flags" -) - -type Config struct { - l1RPC string - PrivateKey *ecdsa.PrivateKey - L1ChainID *big.Int - rollupContract common.Address - challengeEnable bool -} - -func NewConfig() *Config { - return &Config{} -} - -func (c *Config) SetCliContext(ctx *cli.Context) error { - l1NodeAddr := ctx.GlobalString(flags.L1NodeAddr.Name) - l1ChainID := ctx.GlobalUint64(flags.L1ChainID.Name) - c.challengeEnable = ctx.GlobalBool(flags.ChallengeEnable.Name) - if c.challengeEnable { - hexPrvKey := ctx.GlobalString(flags.ValidatorPrivateKey.Name) - hex := strings.TrimPrefix(hexPrvKey, "0x") - privateKey, err := crypto.HexToECDSA(hex) - if err != nil { - return err - } - c.PrivateKey = privateKey - } - addrHex := ctx.GlobalString(flags.RollupContractAddress.Name) - rollupContract := common.HexToAddress(addrHex) - c.l1RPC = l1NodeAddr - c.L1ChainID = big.NewInt(int64(l1ChainID)) - c.rollupContract = rollupContract - return nil -} diff --git a/node/validator/validator.go b/node/validator/validator.go deleted file mode 100644 index 224c8c3d8..000000000 --- a/node/validator/validator.go +++ /dev/null @@ -1,118 +0,0 @@ -package validator - -import ( - "context" - "crypto/ecdsa" - "errors" - "fmt" - "math/big" - "time" - - "github.com/morph-l2/go-ethereum" - "github.com/morph-l2/go-ethereum/accounts/abi/bind" - ethtypes "github.com/morph-l2/go-ethereum/core/types" - "github.com/morph-l2/go-ethereum/ethclient" - "github.com/morph-l2/go-ethereum/log" - tmlog "github.com/tendermint/tendermint/libs/log" - - "morph-l2/bindings/bindings" -) - -type Validator struct { - cli DeployContractBackend - privateKey *ecdsa.PrivateKey - l1ChainID *big.Int - contract *bindings.Rollup - challengeEnable bool - logger tmlog.Logger -} - -type DeployContractBackend interface { - bind.DeployBackend - bind.ContractBackend -} - -func NewValidator(cfg *Config, rollup *bindings.Rollup, logger tmlog.Logger) (*Validator, error) { - cli, err := ethclient.Dial(cfg.l1RPC) - if err != nil { - return nil, fmt.Errorf("dial l1 node error:%v", err) - } - return &Validator{ - cli: cli, - contract: rollup, - privateKey: cfg.PrivateKey, - l1ChainID: cfg.L1ChainID, - challengeEnable: cfg.challengeEnable, - logger: logger, - }, nil -} - -func (v *Validator) SetLogger() { - v.logger = v.logger.With("module", "validator") -} - -func (v *Validator) ChallengeEnable() bool { - return v.challengeEnable -} - -func (v *Validator) ChallengeState(batchIndex uint64) error { - if !v.ChallengeEnable() { - return fmt.Errorf("the challenge is not enabled,please set challengeEnable is true") - } - opts, err := bind.NewKeyedTransactorWithChainID(v.privateKey, v.l1ChainID) - if err != nil { - return err - } - gasPrice, err := v.cli.SuggestGasPrice(opts.Context) - if err != nil { - return err - } - opts.GasPrice = gasPrice - opts.NoSend = true - batchHash, err := v.contract.CommittedBatches( - &bind.CallOpts{ - Pending: false, - Context: opts.Context, - }, - new(big.Int).SetUint64(batchIndex), - ) - if err != nil { - return err - } - tx, err := v.contract.ChallengeState(opts, batchIndex, batchHash) - if err != nil { - return err - } - log.Info("send ChallengeState transaction ", "txHash", tx.Hash().Hex()) - if err := v.cli.SendTransaction(context.Background(), tx); err != nil { - return err - } - // Wait for the receipt - receipt, err := waitForReceipt(v.cli, tx) - if err != nil { - return err - } - log.Info("Validator has already started the challenge", "hash", tx.Hash().Hex(), - "gas-used", receipt.GasUsed, "blocknumber", receipt.BlockNumber) - return nil -} - -func waitForReceipt(backend DeployContractBackend, tx *ethtypes.Transaction) (*ethtypes.Receipt, error) { - t := time.NewTicker(300 * time.Millisecond) - receipt := new(ethtypes.Receipt) - var err error - for range t.C { - receipt, err = backend.TransactionReceipt(context.Background(), tx.Hash()) - if errors.Is(err, ethereum.NotFound) { - continue - } - if err != nil { - return nil, err - } - if receipt != nil { - t.Stop() - break - } - } - return receipt, nil -} diff --git a/node/validator/validator_test.go b/node/validator/validator_test.go deleted file mode 100644 index 038a6f978..000000000 --- a/node/validator/validator_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package validator - -import ( - "crypto/ecdsa" - "math/big" - "testing" - - "github.com/morph-l2/go-ethereum/accounts/abi/bind" - "github.com/morph-l2/go-ethereum/accounts/abi/bind/backends" - "github.com/morph-l2/go-ethereum/core" - "github.com/morph-l2/go-ethereum/core/rawdb" - "github.com/morph-l2/go-ethereum/crypto" - "github.com/morph-l2/go-ethereum/ethdb" - "github.com/morph-l2/go-ethereum/log" - "github.com/stretchr/testify/require" - - "morph-l2/bindings/bindings" -) - -func TestValidator_ChallengeState(t *testing.T) { - key, _ := crypto.GenerateKey() - sim, _ := newSimulatedBackend(key) - opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1337)) - require.NoError(t, err) - addr, _, rollup, err := bindings.DeployRollup(opts, sim, 1337) - require.NoError(t, err) - sim.Commit() - v := Validator{ - cli: sim, - privateKey: key, - l1ChainID: big.NewInt(1), - contract: rollup, - challengeEnable: true, - } - err = v.ChallengeState(10) - log.Info("addr:", addr) - require.EqualError(t, err, "execution reverted: only challenger allowed") -} - -func newSimulatedBackend(key *ecdsa.PrivateKey) (*backends.SimulatedBackend, ethdb.Database) { - var gasLimit uint64 = 9_000_000 - auth, _ := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1337)) - genAlloc := make(core.GenesisAlloc) - genAlloc[auth.From] = core.GenesisAccount{Balance: big.NewInt(9223372036854775807)} - db := rawdb.NewMemoryDatabase() - sim := backends.NewSimulatedBackendWithDatabase(db, genAlloc, gasLimit) - return sim, db -} diff --git a/ops/docker/docker-compose-4nodes.yml b/ops/docker/docker-compose-4nodes.yml index 32ea8b79b..370808b89 100644 --- a/ops/docker/docker-compose-4nodes.yml +++ b/ops/docker/docker-compose-4nodes.yml @@ -433,7 +433,6 @@ services: - MORPH_NODE_L1_ETH_RPC=${L1_ETH_RPC} - MORPH_NODE_L1_ETH_BEACON_RPC=${L1_BEACON_CHAIN_RPC} - MORPH_NODE_SYNC_DEPOSIT_CONTRACT_ADDRESS=${MORPH_PORTAL:-0x6900000000000000000000000000000000000001} - - MORPH_NODE_VALIDATOR_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - MORPH_NODE_ROLLUP_ADDRESS=${MORPH_ROLLUP:-0x6900000000000000000000000000000000000010} - MORPH_NODE_DERIVATION_START_HEIGHT=1 - MORPH_NODE_SYNC_START_HEIGHT=1 From 4822571de9643aee3ef214f74a5077edc31ce1b5 Mon Sep 17 00:00:00 2001 From: corey Date: Fri, 8 May 2026 18:02:38 +0800 Subject: [PATCH 22/26] =?UTF-8?q?feat(derivation):=20SPEC-005=20P2=20?= =?UTF-8?q?=E2=80=94=20state=20model=20+=20head=20anchors=20+=20dual-chann?= =?UTF-8?q?el=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the data model and persistence schema for the SPEC-005 state hierarchy (unsafe / safe / finalized / halted) without yet rewiring the main derivation loop. The main loop continues to consume the existing single confirmations cursor; switching to dual-channel drive is gated on the SPEC-005 §8 blocking decisions. What this commit adds: - node/derivation/state.go: L2HeadStage enum + HeadAnchor type per SPEC-005 §3.1. - node/db/{keys,store}.go + node/derivation/{database,head_anchor}.go: Persistent safe_head / finalized_head anchors (RLP DerivationHeadAnchor) plus typed read/write helpers on Derivation. finalized_head is documented as monotonic per SPEC-005 §3.1; atomicity caveats spelled out in head_anchor.go for the future P3 rollback executor. - node/derivation/dual_channel.go: L1 safe / finalized tag fetchers per SPEC-005 §3.2. Not yet called from the main loop on purpose — see file-level comment. - node/derivation/verify.go: Doc-only change: documents the SPEC-005 §3.4 invariant that verifyBatchRoots is always executed and never depends on blob data (both roots come from L1 calldata at parse time). - node/derivation/metrics.go: New gauges/counters for safe_head_l2_number, finalized_head_l2_number, path_b_triggered_total, batch_root_mismatch_total. No existing call sites are modified, so runtime behaviour is unchanged. This commit is intended to be reviewable independently from P3 and the go-ethereum SetHead-by-hash dependency. Refs: morph-l2/morph-specs SPEC-005 §3.1 / §3.2 / §3.3 / §3.4 / §3.5 Co-authored-by: Cursor --- node/db/keys.go | 8 +++-- node/db/store.go | 55 ++++++++++++++++++++++++++++ node/derivation/database.go | 6 ++++ node/derivation/dual_channel.go | 49 +++++++++++++++++++++++++ node/derivation/head_anchor.go | 64 +++++++++++++++++++++++++++++++++ node/derivation/metrics.go | 52 +++++++++++++++++++++++++++ node/derivation/state.go | 57 +++++++++++++++++++++++++++++ node/derivation/verify.go | 8 +++++ 8 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 node/derivation/dual_channel.go create mode 100644 node/derivation/head_anchor.go create mode 100644 node/derivation/state.go diff --git a/node/db/keys.go b/node/db/keys.go index 2101a8814..336b4abcc 100644 --- a/node/db/keys.go +++ b/node/db/keys.go @@ -7,8 +7,12 @@ var ( L1MessagePrefix = []byte("l1") BatchBlockNumberPrefix = []byte("batch") - derivationL1HeightKey = []byte("LastDerivationL1Height") - derivationL1BlockPrefix = []byte("derivL1Block") + 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 diff --git a/node/db/store.go b/node/db/store.go index 16bd912bf..cff4d319d 100644 --- a/node/db/store.go +++ b/node/db/store.go @@ -162,6 +162,61 @@ type DerivationL1Block struct { 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 { diff --git a/node/derivation/database.go b/node/derivation/database.go index 369b135e1..c3922e935 100644 --- a/node/derivation/database.go +++ b/node/derivation/database.go @@ -15,10 +15,16 @@ 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) } diff --git a/node/derivation/dual_channel.go b/node/derivation/dual_channel.go new file mode 100644 index 000000000..61a38723e --- /dev/null +++ b/node/derivation/dual_channel.go @@ -0,0 +1,49 @@ +package derivation + +import ( + "context" + "fmt" + "math/big" + + "github.com/morph-l2/go-ethereum/rpc" +) + +// SPEC-005 §3.2 "L1 双通道驱动": +// +// The derivation pipeline must consume two independent L1 cursors: +// - "safe" drives safe_head and is allowed to roll back when L1 reorgs out a batch. +// - "finalized" drives finalized_head; it is monotonic and never rolls back. +// +// The current main loop in derivationBlock() still consumes a single +// `d.confirmations` cursor (rpc.FinalizedBlockNumber by default). The helpers +// below are intentionally not yet wired into the main loop — switching the +// main loop is gated on the SPEC-005 §8 blocking decisions (anchor-window +// depth, sequencer mutex granularity). They are exposed now so that +// downstream tasks #5 / #6 / #7 can build on them without re-establishing +// the L1 access pattern from scratch. + +// fetchLatestSafeHeight returns the L1 block number of the latest "safe" head. +// +// "safe" here is the consensus-layer "safe" tag exposed via L1 RPC, not a +// confirmations-derived height. Use this to drive safe_head. +func (d *Derivation) fetchLatestSafeHeight(ctx context.Context) (uint64, error) { + return d.fetchTaggedHeight(ctx, rpc.SafeBlockNumber, "safe") +} + +// fetchLatestFinalizedHeight returns the L1 block number of the latest +// "finalized" head. Use this to drive finalized_head; the result is +// expected to be monotonic across calls. +func (d *Derivation) fetchLatestFinalizedHeight(ctx context.Context) (uint64, error) { + return d.fetchTaggedHeight(ctx, rpc.FinalizedBlockNumber, "finalized") +} + +func (d *Derivation) fetchTaggedHeight(ctx context.Context, tag rpc.BlockNumber, label string) (uint64, error) { + header, err := d.l1Client.HeaderByNumber(ctx, big.NewInt(int64(tag))) + if err != nil { + return 0, fmt.Errorf("get L1 %s head: %w", label, err) + } + if header == nil || header.Number == nil { + return 0, fmt.Errorf("got nil header for L1 %s head", label) + } + return header.Number.Uint64(), nil +} diff --git a/node/derivation/head_anchor.go b/node/derivation/head_anchor.go new file mode 100644 index 000000000..c81625dbc --- /dev/null +++ b/node/derivation/head_anchor.go @@ -0,0 +1,64 @@ +package derivation + +import "morph-l2/node/db" + +// toDBAnchor converts the in-memory HeadAnchor to the persistent representation. +func (a HeadAnchor) toDBAnchor() *db.DerivationHeadAnchor { + return &db.DerivationHeadAnchor{ + L2Number: a.L2Number, + L2Hash: a.L2Hash, + L1Number: a.L1Number, + L1Hash: a.L1Hash, + } +} + +// headAnchorFromDB inflates a stored anchor back into the in-memory representation. +// Returns nil if the input is nil. +func headAnchorFromDB(a *db.DerivationHeadAnchor) *HeadAnchor { + if a == nil { + return nil + } + return &HeadAnchor{ + L2Number: a.L2Number, + L2Hash: a.L2Hash, + L1Number: a.L1Number, + L1Hash: a.L1Hash, + } +} + +// readSafeHead returns the persisted safe-stage anchor, or nil if unset. +func (d *Derivation) readSafeHead() *HeadAnchor { + return headAnchorFromDB(d.db.ReadDerivationSafeHead()) +} + +// readFinalizedHead returns the persisted finalized-stage anchor, or nil if unset. +func (d *Derivation) readFinalizedHead() *HeadAnchor { + return headAnchorFromDB(d.db.ReadDerivationFinalizedHead()) +} + +// writeSafeHead persists a new safe-stage anchor. +// +// Per SPEC-005 §3.5 ("Restart and consistency"), this should ideally be written +// atomically with the corresponding L1 anchor window updates so the node never +// observes a half-committed state across a restart. The current implementation +// uses single-key Put because the underlying KV store does not yet expose a +// transactional API; this is acceptable for now because: +// - safe head ratchets forward inside a single derivation loop iteration; +// - L1 anchor window writes are append-only and idempotent; +// - on crash mid-write, the next loop will re-derive from the last persisted +// LatestDerivationL1Height and re-establish consistency before advancing. +// +// TODO(spec-005): expose a multi-key atomic write helper on db.Store and +// migrate this + WriteDerivationL1Block + WriteLatestDerivationL1Height onto +// it once the rollback executor (P3) lands. +func (d *Derivation) writeSafeHead(anchor HeadAnchor) { + d.db.WriteDerivationSafeHead(anchor.toDBAnchor()) +} + +// writeFinalizedHead persists a new finalized-stage anchor. +// +// Per SPEC-005 §3.1, finalized_head is monotonic and never rolls back; callers +// must enforce this invariant before calling. +func (d *Derivation) writeFinalizedHead(anchor HeadAnchor) { + d.db.WriteDerivationFinalizedHead(anchor.toDBAnchor()) +} diff --git a/node/derivation/metrics.go b/node/derivation/metrics.go index a0ae4817a..00cfe8302 100644 --- a/node/derivation/metrics.go +++ b/node/derivation/metrics.go @@ -28,6 +28,18 @@ type Metrics struct { RollbackCount metrics.Counter BlockMismatchCount metrics.Counter Halted metrics.Gauge + + // SPEC-005 head stages. + SafeHeadL2Number metrics.Gauge + FinalizedHeadL2Number metrics.Gauge + + // SPEC-005 §3.3 path B (degraded) verification trigger counter. + PathBTriggeredCount metrics.Counter + + // SPEC-005 §4.2 batch-root mismatch counter (separate from generic rollback + // count to distinguish "first attempt failed and re-derive succeeded" from + // "second attempt failed and we entered halted"). + BatchRootMismatchCount metrics.Counter } func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { @@ -96,6 +108,30 @@ func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { Name: "halted", Help: "Set to 1 when derivation is halted due to unrecoverable batch mismatch requiring manual intervention", }, labels).With(labelsAndValues...), + SafeHeadL2Number: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "safe_head_l2_number", + Help: "L2 block number of the latest safe-stage head (anchored to L1 safe)", + }, labels).With(labelsAndValues...), + FinalizedHeadL2Number: prometheus.NewGaugeFrom(stdprometheus.GaugeOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "finalized_head_l2_number", + Help: "L2 block number of the latest finalized-stage head (anchored to L1 finalized; monotonic)", + }, labels).With(labelsAndValues...), + PathBTriggeredCount: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "path_b_triggered_total", + Help: "Total number of times batch-content verification fell back to local-rebuild path (SPEC-005 §3.3 path B)", + }, labels).With(labelsAndValues...), + BatchRootMismatchCount: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "batch_root_mismatch_total", + Help: "Total number of state-root or withdrawal-root mismatches against L1 batch calldata", + }, labels).With(labelsAndValues...), } } @@ -139,6 +175,22 @@ func (m *Metrics) SetHalted() { m.Halted.Set(1) } +func (m *Metrics) SetSafeHeadL2Number(n uint64) { + m.SafeHeadL2Number.Set(float64(n)) +} + +func (m *Metrics) SetFinalizedHeadL2Number(n uint64) { + m.FinalizedHeadL2Number.Set(float64(n)) +} + +func (m *Metrics) IncPathBTriggered() { + m.PathBTriggeredCount.Add(1) +} + +func (m *Metrics) IncBatchRootMismatchCount() { + m.BatchRootMismatchCount.Add(1) +} + func (m *Metrics) Serve(hostname string, port uint64) (*http.Server, error) { mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) diff --git a/node/derivation/state.go b/node/derivation/state.go new file mode 100644 index 000000000..15ce7e553 --- /dev/null +++ b/node/derivation/state.go @@ -0,0 +1,57 @@ +package derivation + +import "fmt" + +// L2HeadStage represents the public-facing safety level of an L2 head per SPEC-005. +// +// State semantics: +// - StageUnsafe: Block executed locally; not yet anchored to any L1 batch. +// - StageSafe: Anchored to an L1 batch found on L1 `safe`; subject to rollback +// if the L1 batch reorgs out or batch verification fails. +// - StageFinalized: Anchored to an L1 batch whose origin is on L1 `finalized`. +// Monotonic; never rolls back. +// - StageHalted: Unrecoverable inconsistency (e.g. second batch-root mismatch +// after rollback, or a rollback target below FinalizedHead). +// Derivation refuses to advance until manual intervention. +// +// A node always advertises a single stage per head (one each for safe / finalized); +// halted is global to the derivation pipeline. +type L2HeadStage uint8 + +const ( + StageUnsafe L2HeadStage = iota + StageSafe + StageFinalized + StageHalted +) + +func (s L2HeadStage) String() string { + switch s { + case StageUnsafe: + return "unsafe" + case StageSafe: + return "safe" + case StageFinalized: + return "finalized" + case StageHalted: + return "halted" + default: + return fmt.Sprintf("unknown(%d)", uint8(s)) + } +} + +// HeadAnchor pairs an L2 head with the L1 origin that justifies its current +// safety stage. Both safe_head and finalized_head are persisted as HeadAnchor +// to allow detecting L1 reorgs that invalidate previously recorded anchors. +type HeadAnchor struct { + L2Number uint64 + L2Hash [32]byte + L1Number uint64 + L1Hash [32]byte +} + +// IsZero reports whether the anchor is uninitialized (e.g. at first node start +// before the first derivation loop has succeeded). +func (a HeadAnchor) IsZero() bool { + return a.L2Number == 0 && a.L1Number == 0 +} diff --git a/node/derivation/verify.go b/node/derivation/verify.go index 4e932b893..cec23a3ac 100644 --- a/node/derivation/verify.go +++ b/node/derivation/verify.go @@ -26,6 +26,14 @@ func (d *Derivation) rollbackLocalChain(targetBlockNumber uint64) error { } // verifyBatchRoots verifies that the local state root and withdrawal root match the L1 batch data. +// +// SPEC-005 §3.4 / §3.2 invariant: this check is **always executed and never +// depends on blob data**. Both `batchInfo.root` (postStateRoot) and +// `batchInfo.withdrawalRoot` are extracted from L1 calldata at parse time +// (see batch_info.go); they reach this function regardless of whether the +// beacon-side blob fetch (Path A) or the local rebuild fallback (Path B, +// SPEC-005 §3.3) has succeeded. Code review must reject any change that +// makes this verification conditional on blob availability. func (d *Derivation) verifyBatchRoots(batchInfo *BatchInfo, lastHeader *eth.Header) error { withdrawalRoot, err := d.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ BlockNumber: lastHeader.Number, From 2ed4e8c98d4fa1bec43ac5c362e132f25c69028e Mon Sep 17 00:00:00 2001 From: corey Date: Fri, 8 May 2026 18:05:23 +0800 Subject: [PATCH 23/26] =?UTF-8?q?feat(derivation):=20SPEC-005=20P3=20?= =?UTF-8?q?=E2=80=94=20path=20B=20/=20rollback=20executor=20/=20admin=20RP?= =?UTF-8?q?C=20/=20mutex=20skeletons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds non-runtime skeletons for the four pieces of SPEC-005 work that are gated on pending blocking decisions (tech-design §8). All four files are self-contained and compile, but none are wired into the main derivation loop yet — switching them on requires the corresponding §8 decisions to land. Each TODO is annotated with the specific blocking item. What this commit adds: - node/derivation/verify_path_b.go (SPEC-005 §3.3 path B): Eligibility check (last_block ≤ safe_head + locally present) and trigger condition matching tech-design §3.2.2. Stub returns errPathBUnavailable; the actual blob-rebuild encoder is left as a TODO pending confirmation that we should reuse tx-submitter helpers rather than duplicate them (open question §8 #3). - node/derivation/verify.go::rollbackLocalChain (SPEC-005 §5.2): Replaces the previous stub message with the formal 8-step atomic ordering, plus a checkRollbackBoundary helper that enforces the finalized_head boundary (SPEC-005 §3.6). The actual SetHead call is still TODO and depends on §8 #4 (go-ethereum hash-matched SetHead). - node/derivation/sequencer_mutex.go (SPEC-005 §3.6 / §4): SequencerMutex primitive (RWMutex-based for now) with public Acquire/Release Production / Rollback methods. Granularity (global stop-the-world vs interval lock) intentionally hidden behind the method API so §8 #5 can switch implementation without churning callers. - node/derivation/admin_rpc.go (SPEC-005 §5.1): AdminAPI.SetL2Head(number, hash) skeleton. Hash-matched per tech-design §3.3 and rejects targets below finalized_head before delegating to the rollback executor. Authentication wiring blocked on §8 #2. No call sites are modified; runtime behaviour is unchanged. P3 unblocks parallel work on §8 decisions: while operators decide on auth / mutex granularity / SetHead semantics, downstream developers can build tests against these signatures. Refs: morph-l2/morph-specs SPEC-005 §3.3 / §3.6 / §4 / §5 / §8 Co-authored-by: Cursor --- node/derivation/admin_rpc.go | 67 +++++++++++++++++++++ node/derivation/sequencer_mutex.go | 64 ++++++++++++++++++++ node/derivation/verify.go | 54 ++++++++++++++--- node/derivation/verify_path_b.go | 94 ++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 node/derivation/admin_rpc.go create mode 100644 node/derivation/sequencer_mutex.go create mode 100644 node/derivation/verify_path_b.go diff --git a/node/derivation/admin_rpc.go b/node/derivation/admin_rpc.go new file mode 100644 index 000000000..01bfa0750 --- /dev/null +++ b/node/derivation/admin_rpc.go @@ -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()) +} diff --git a/node/derivation/sequencer_mutex.go b/node/derivation/sequencer_mutex.go new file mode 100644 index 000000000..3bd424e80 --- /dev/null +++ b/node/derivation/sequencer_mutex.go @@ -0,0 +1,64 @@ +package derivation + +import "sync" + +// SPEC-005 §3.6 / §4 sequencer ↔ derivation mutual exclusion. +// +// Per SPEC-005 §3.7 non-target "do not modify sequencer block production", +// the mutex is enforced **on the morph/node side of the L2Node interface +// (RequestBlockData / DeliverBlock)**. The tendermint consensus layer is +// *not* modified. +// +// This file provides the mutex primitive. Wiring on the sequencer entry +// points (morph/node/sequencer/...) is a separate task tracked in +// tech-design §6.2 task #11. +// +// Granularity (global stop-the-world vs interval lock) is a SPEC-005 §8 #5 +// open question. The default scaffold below is a single global RWMutex, +// which gives global exclusion; if interval locking is later chosen, the +// public API stays the same but the internal representation grows a per- +// range structure. Callers should therefore depend only on the methods, +// not on this being a single global lock. + +// SequencerMutex coordinates between block production and derivation +// rollback. Any path producing a new unsafe L2 block must acquire a +// production lock; the rollback executor takes an exclusive lock during +// the SetHead → metadata persistence sequence. +type SequencerMutex struct { + mu sync.RWMutex +} + +// NewSequencerMutex returns a fresh mutex. There is one such mutex per +// node process; sharing is established through the constructor wiring. +func NewSequencerMutex() *SequencerMutex { + return &SequencerMutex{} +} + +// AcquireProduction blocks until the rollback executor (if any) has +// released the exclusive lock, then reserves a slot for block production. +// Each call must be paired with a deferred ReleaseProduction. +// +// TODO(spec-005-mutex): once SPEC-005 §8 #5 picks interval locking, this +// signature gains a (from, to) range and the implementation switches to +// a per-range exclusion table. +func (m *SequencerMutex) AcquireProduction() { + m.mu.RLock() +} + +// ReleaseProduction releases a production reservation acquired via +// AcquireProduction. Safe to call from defer. +func (m *SequencerMutex) ReleaseProduction() { + m.mu.RUnlock() +} + +// AcquireRollback blocks until all in-flight production reservations have +// been released, then reserves the exclusive rollback slot. Each call must +// be paired with a deferred ReleaseRollback. +func (m *SequencerMutex) AcquireRollback() { + m.mu.Lock() +} + +// ReleaseRollback releases the exclusive rollback slot. Safe to call from defer. +func (m *SequencerMutex) ReleaseRollback() { + m.mu.Unlock() +} diff --git a/node/derivation/verify.go b/node/derivation/verify.go index cec23a3ac..b57002602 100644 --- a/node/derivation/verify.go +++ b/node/derivation/verify.go @@ -10,21 +10,59 @@ import ( ) // rollbackLocalChain rolls back the local L2 chain to the specified block number. -// This is only triggered when batch data comparison fails — i.e. the local L2 block -// does not match the L1 batch data (block context mismatch or state root mismatch). -// After rollback, the caller re-derives blocks using L1 batch data as source of truth. +// +// SPEC-005 §3.6 / §5: triggered on block-context mismatch or batch-root mismatch. +// After rollback the caller re-derives the offending batch from L1 calldata. +// +// SPEC-005 §4 (safety considerations) requires the rollback to be atomic w.r.t. +// the sequencer's block-production path: the sequencer must not be able to +// produce a new unsafe block while the rollback is in flight. The atomic +// ordering is: +// +// 1. Acquire the sequencer ↔ derivation mutex (P3 — sequencer_mutex.go). +// 2. Pause sequencer block production (mutex blocks RequestBlockData / +// DeliverBlock entry points on the L2Node interface; tendermint +// consensus layer is not modified — see tech-design §3.2.2). +// 3. Pause this derivation loop (already serialized; the caller is the loop). +// 4. Call go-ethereum's hash-matched SetHead (SPEC-005 §8 #4 blocking item). +// 5. Clear derivation cursor for the rolled-back range. +// 6. Clear L1 anchor records for the discarded segment. +// 7. Atomically persist the new safe_head metadata (head_anchor.go). +// 8. Release the mutex. +// +// Boundary: target < finalized_head → halted (SPEC-005 §3.6); enforced before +// invoking the SetHead call. target < genesis → halted. func (d *Derivation) rollbackLocalChain(targetBlockNumber uint64) error { + if err := d.checkRollbackBoundary(targetBlockNumber); err != nil { + return err + } + d.logger.Error("L2 chain rollback not yet implemented", "targetBlockNumber", targetBlockNumber) - // TODO: Implement actual rollback via geth SetHead engine API: - // 1. Expose SetL2Head(number uint64) in go-ethereum/eth/catalyst/l2_api.go - // 2. Add SetHead method to go-ethereum/ethclient/authclient - // 3. Add SetHead method to node/types/retryable_client.go - // 4. Call d.l2Client.SetHead(d.ctx, targetBlockNumber) + // TODO(spec-005-rollback): implement steps 1-8 above. Blocked on: + // - SPEC-005 §8 #2: sequencer mutex granularity (sequencer_mutex.go). + // - SPEC-005 §8 #4: go-ethereum hash-matched SetHead interface (must + // refuse to roll back if the supplied (number, hash) does not match + // the local canonical chain — see tech-design §3.3). + // - node/types/retryable_client.go SetHead wrapper once the upstream + // EL method is finalised. return fmt.Errorf("rollback not implemented yet, target=%d", targetBlockNumber) } +// checkRollbackBoundary enforces the SPEC-005 §3.6 boundary: rolling back +// past finalized_head is fatal, regardless of why the caller wanted to. +func (d *Derivation) checkRollbackBoundary(targetBlockNumber uint64) error { + finalized := d.readFinalizedHead() + if finalized != nil && targetBlockNumber < finalized.L2Number { + // SPEC-005 §3.6 / §4.3: enter halted; no recovery short of manual + // intervention. The caller is expected to set d.halted in response. + return fmt.Errorf("rollback target %d below finalized_head %d — halted boundary", + targetBlockNumber, finalized.L2Number) + } + return nil +} + // verifyBatchRoots verifies that the local state root and withdrawal root match the L1 batch data. // // SPEC-005 §3.4 / §3.2 invariant: this check is **always executed and never diff --git a/node/derivation/verify_path_b.go b/node/derivation/verify_path_b.go new file mode 100644 index 000000000..48a6be0ea --- /dev/null +++ b/node/derivation/verify_path_b.go @@ -0,0 +1,94 @@ +package derivation + +import ( + "context" + "errors" +) + +// SPEC-005 §3.3 path B (degraded batch-content verification). +// +// When path A (online beacon blob) is unavailable, this path rebuilds the +// versioned blob hash from local L2 blocks and compares it against the +// blob hash recorded in L1 commitBatch calldata. State / withdrawal root +// verification (verify.go::verifyBatchRoots) runs independently and is +// never gated on either path; see SPEC-005 §3.4. +// +// Trigger conditions (must all hold per SPEC-005 §3.3): +// 1. Path A returned an empty / unavailable result for this batch. +// 2. The batch's last L2 block is at or below safe_head — i.e. the batch +// is in the historical tail, the only segment where blob retention +// can legitimately have lapsed. +// 3. The local node still holds every L2 block in the batch range. +// +// Default-on/off behaviour and whether to retry path A on success are the +// SPEC-005 §8 #3 open question. + +// errPathBUnavailable indicates the caller must fall back to the standard +// path-A failure handling (rollback / re-derive) — i.e. path B was either +// not eligible to run or failed to reproduce the blob hash. +var errPathBUnavailable = errors.New("path B unavailable") + +// verifyBatchContentPathB attempts the degraded path B verification for the +// given batch. Returns nil on success. +// +// Eligibility check (returns errPathBUnavailable when not eligible) is +// kept inside this function so callers can blindly invoke it as a +// fallback after path A has failed — there is no separate "isEligible" +// query to keep two-stage races out of the main loop. +func (d *Derivation) verifyBatchContentPathB(ctx context.Context, batchInfo *BatchInfo) error { + if err := ctx.Err(); err != nil { + return err + } + if !d.pathBEnabled() { + return errPathBUnavailable + } + if !d.pathBEligible(batchInfo) { + return errPathBUnavailable + } + + // TODO(spec-005-path-b): rebuild versioned blob hash from local L2 blocks. + // + // Implementation sketch: + // 1. For each L2 block in [batchInfo.firstBlockNumber, batchInfo.lastBlockNumber]: + // - fetch local block (already on disk; geth eth_getBlockByNumber). + // - encode tx list using node/types.MaxBlobBytesSize / RetrieveBlobBytes + // inverse: see node/types/blob.go for the path-A decode helpers. + // 2. Compress with node/zstd, slice to blob-sized chunks (see SPEC-002 batching). + // 3. For each chunk, compute kzg4844 commitment + versioned hash. + // 4. Compare ordered versioned hashes against batchInfo.blobHashes. + // + // This is gated on confirming there's no double-implementation cost vs the + // existing tx-submitter blob construction path (open question per + // tech-design §8 / per-module §5 #3); production-grade code should reuse + // existing helpers rather than reimplementing the encoder. + + d.metrics.IncPathBTriggered() + d.logger.Info("path B verification triggered (skeleton — not yet implemented)", + "batchIndex", batchInfo.batchIndex) + return errPathBUnavailable +} + +// pathBEnabled reports whether the operator has opted into the degraded path. +// +// TODO(spec-005-path-b): wire this to a flag once SPEC-005 §8 #3 is decided +// (default-on vs default-off). Until then, path B is permanently disabled. +func (d *Derivation) pathBEnabled() bool { + return false +} + +// pathBEligible reports whether path B can run for the given batch. +// Per SPEC-005 §3.3: batch must be historical (lastBlock <= safe_head) AND +// every L2 block in the range must exist locally. +func (d *Derivation) pathBEligible(batchInfo *BatchInfo) bool { + safe := d.readSafeHead() + if safe == nil { + return false + } + if batchInfo.lastBlockNumber > safe.L2Number { + // Live segment, not eligible — Path A failure here is a real anomaly. + return false + } + // TODO(spec-005-path-b): walk [first, last] confirming local presence. + // Skipped for skeleton — pathBEnabled() is false anyway. + return true +} From 3848b87c6345ffda306c7b68877ba709fa8aea19 Mon Sep 17 00:00:00 2001 From: corey Date: Sat, 9 May 2026 10:55:01 +0800 Subject: [PATCH 24/26] =?UTF-8?q?docs(derivation):=20drop=20DERIVATION=5FR?= =?UTF-8?q?EFACTOR.md=20=E2=80=94=20superseded=20by=20morph-specs=20SPEC-0?= =?UTF-8?q?05?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design rationale lived inline at node/derivation/DERIVATION_REFACTOR.md while we were iterating, but the authoritative spec now lives in morph-l2/morph-specs SPEC-005 (state tiering and rollback). Keeping a parallel copy here only invites drift. The corresponding SPEC-005 sections cover the same material: - §3.1 / §3.2 L2 state semantics + L1 dual-channel - §3.3 Path A / path B verification - §3.4 / §3.5 Root-independence + persistence - §3.6 Rollback boundaries - §5 Atomic rollback ordering Co-authored-by: Cursor --- node/derivation/DERIVATION_REFACTOR.md | 163 ------------------------- 1 file changed, 163 deletions(-) delete mode 100644 node/derivation/DERIVATION_REFACTOR.md diff --git a/node/derivation/DERIVATION_REFACTOR.md b/node/derivation/DERIVATION_REFACTOR.md deleted file mode 100644 index 9f94940a1..000000000 --- a/node/derivation/DERIVATION_REFACTOR.md +++ /dev/null @@ -1,163 +0,0 @@ -# Derivation Refactor: Batch Verification & L1 Reorg Detection - -## Background - -The derivation module is the core component that syncs L2 state from L1 batch data. Previously it only ran on validator nodes and used a challenge mechanism when state mismatches were detected. This refactor makes two fundamental changes: - -1. **L1 batch data is the source of truth** — when local L2 blocks don't match L1 batch data, roll back and re-derive from L1 instead of issuing a challenge. -2. **Support `latest` mode** for fetching L1 batches (instead of only `finalized`), with L1 reorg detection to handle the reduced confirmation window. - -## Design Principles - -- **L2 rollback is only triggered by batch data mismatch**, never by L1 reorg alone. - - L1 reorg → clean up DB → re-derive from reorg point → batch comparison decides if L2 needs rollback. - - Most L1 reorgs just re-include the same batch tx in a different block — L2 stays valid. -- **Derivation can run as a verification thread** — when blocks already exist locally (e.g. produced by sequencer), derivation compares them against L1 batch data instead of skipping. - -## What Changed - -### Removed - -| Item | Reason | -|------|--------| -| `validator` field in `Derivation` struct | Challenge mechanism removed | -| `validator.Validator` parameter in `NewDerivationClient()` | No longer needed | -| `ChallengeState` / `ChallengeEnable` logic in `derivationBlock()` | Replaced by rollback + re-derive | -| `validator` import in `node/cmd/node/main.go` | No longer referenced | - -### Added — L1 Reorg Detection - -When `confirmations` is not `finalized` (i.e. using `latest` or `safe`), each derivation loop checks recent L1 blocks for hash changes before processing new batches. - -**New DB layer** (`node/db/`): - -- `DerivationL1Block` struct — stores `{Number, Hash}` per L1 block -- `WriteDerivationL1Block` / `ReadDerivationL1Block` / `ReadDerivationL1BlockRange` / `DeleteDerivationL1BlocksFrom` -- DB key prefix: `derivL1Block` + uint64 big-endian height - -**New config** (`node/derivation/config.go`): - -- `ReorgCheckDepth uint64` — how many recent L1 blocks to verify each loop (default: 64) -- CLI flag: `--derivation.reorgCheckDepth` / env `MORPH_NODE_DERIVATION_REORG_CHECK_DEPTH` - -**New methods** (`node/derivation/derivation.go`): - -| Method | Purpose | -|--------|---------| -| `detectReorg(ctx)` | Iterates recent L1 block hashes from DB, compares against current L1 chain. Returns the height where a mismatch is found, or nil. | -| `handleL1Reorg(height)` | Cleans DB records from the reorg point and resets `latestDerivationL1Height`. Does NOT rollback L2 — the next derivation loop re-fetches batches and the normal comparison logic decides. | -| `recordL1Blocks(ctx, from, to)` | After each derivation round, records L1 block hashes for the processed range. | - -**Flow**: - -```text -derivationBlock() loop start -│ -├─ [if not finalized] detectReorg() -│ ├─ no reorg → continue -│ └─ reorg at height X → handleL1Reorg(X) -│ ├─ DeleteDerivationL1BlocksFrom(X) -│ ├─ WriteLatestDerivationL1Height(X-1) -│ └─ return (next loop re-processes from X) -│ -├─ fetch CommitBatch logs from L1 -├─ process each batch → derive() + verifyBatchRoots() -├─ recordL1Blocks(start, end) -└─ WriteLatestDerivationL1Height(end) -``` - -### Added — Batch Data Verification - -When `derive()` encounters an L2 block that already exists locally, it now **compares** the block against the L1 batch data instead of blindly skipping it. - -**New methods**: - -| Method | Purpose | -|--------|---------| -| `verifyBlockContext(localHeader, blockData)` | Compares timestamp, gasLimit, baseFee between local L2 block header and batch block context. | -| `verifyBatchRoots(batchInfo, lastHeader)` | Compares stateRoot and withdrawalRoot between L1 batch and last derived L2 block. Extracted from the old inline logic. | -| `rollbackLocalChain(targetBlockNumber)` | **TODO stub** — will call geth `SetHead` API to rewind L2 chain. | - -**`derive()` new flow for each block in batch**: - -```text -block.Number <= latestBlockNumber? -├─ YES (block exists) -│ ├─ verifyBlockContext() passes → skip, continue -│ └─ verifyBlockContext() fails -│ ├─ IncBlockMismatchCount() -│ ├─ rollbackLocalChain(block.Number - 1) -│ └─ fall through to NewSafeL2Block (re-execute) -│ -└─ NO (new block) - └─ NewSafeL2Block (execute normally) -``` - -**`derivationBlock()` batch-level verification**: - -```text -After derive(batchInfo) completes: -│ -├─ verifyBatchRoots() passes → normal -└─ verifyBatchRoots() fails - ├─ IncRollbackCount() - ├─ rollbackLocalChain(firstBlockNumber - 1) - ├─ re-derive(batchInfo) - ├─ verifyBatchRoots() again - │ ├─ passes → recovered - │ └─ fails → CRITICAL error, stop (manual intervention needed) -``` - -### Added — Metrics - -| Metric | Type | Description | -|--------|------|-------------| -| `morphnode_derivation_l1_reorg_detected_total` | Counter | L1 reorg detection count | -| `morphnode_derivation_l2_rollback_total` | Counter | L2 rollbacks triggered by batch mismatch | -| `morphnode_derivation_block_mismatch_total` | Counter | Block-level context mismatches | -| `morphnode_derivation_halted` | Gauge | Set to 1 when derivation halts due to unrecoverable batch mismatch (alert on this) | - -## Modified Files - -| File | Changes | -|------|---------| -| `node/derivation/derivation.go` | Core refactor: removed validator/challenge, added reorg detection, batch verification, rollback flow | -| `node/derivation/database.go` | Extended `Reader`/`Writer` interfaces for L1 block hash tracking | -| `node/derivation/config.go` | Added `ReorgCheckDepth` config field | -| `node/derivation/metrics.go` | Added 3 new counter metrics | -| `node/db/keys.go` | Added `derivationL1BlockPrefix` and `DerivationL1BlockKey()` | -| `node/db/store.go` | Added `DerivationL1Block` struct and 4 CRUD methods | -| `node/flags/flags.go` | Added `DerivationReorgCheckDepth` CLI flag | -| `node/cmd/node/main.go` | Removed `validator` dependency from `NewDerivationClient` call | - -## TODO (follow-up work) - -### `rollbackLocalChain()` — geth SetHead integration - -Currently a stub that returns an error. Any batch mismatch will be detected and logged, but the -actual L2 chain rollback cannot proceed until this is implemented: - -1. Expose `SetL2Head(number uint64)` in `go-ethereum/eth/catalyst/l2_api.go` -2. Add `SetHead` method to `go-ethereum/ethclient/authclient` -3. Add `SetHead` method to `node/types/retryable_client.go` -4. Call `d.l2Client.SetHead(d.ctx, targetBlockNumber)` in `rollbackLocalChain()` - -Note: geth already has `BlockChain.SetHead(head uint64) error` — we just need to expose it through the engine API chain. - -### Transaction-level verification - -`verifyBlockContext` currently checks timestamp, gasLimit, baseFee, and batch-internal tx count -consistency. Full transaction hash comparison against local blocks requires `BlockByNumber` RPC -on `RetryableClient`, which is not yet exposed. State root verification in `verifyBatchRoots` -covers transaction execution correctness as an indirect check. - -### Concurrency safety - -When running as a verification thread alongside a sequencer, concurrent access between block production and rollback needs locking. This will be handled separately. - -## How to Test - -1. **Existing behavior preserved**: Set `--derivation.confirmations` to finalized (default) — reorg detection and L1 block hash recording are both skipped, batch verification still runs. -2. **Latest mode**: Set `--derivation.confirmations` to `-2` (latest) — reorg detection activates, L1 block hashes are tracked. -3. **Reorg detection**: Simulate by modifying a saved L1 block hash in DB — next loop should detect and clean up. -4. **Batch verification**: When an existing L2 block matches L1 batch data, it logs "block verified" and skips. When mismatched, it logs the error and returns (rollback stub returns error, preventing silent continuation). From 308dfcb1d926f1709ba0810aeb19e6aa49c21604 Mon Sep 17 00:00:00 2001 From: corey Date: Sat, 9 May 2026 15:09:49 +0800 Subject: [PATCH 25/26] =?UTF-8?q?feat(derivation):=20SPEC-005=20=C2=A73.1?= =?UTF-8?q?=20dual-channel=20main=20loop=20+=20safe/finalized=20head=20anc?= =?UTF-8?q?hors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per morph-specs SPEC-005 §6.2 task table: #5 Drive derivation main loop on L1 safe; reorg detection unconditional #6 Persist safe_head anchor after each verified batch (+ metric) #6b finalized_head tracker advances at the tail of every iteration Changes: - node/derivation/config.go Default `Confirmations` flips from FinalizedBlockNumber to SafeBlockNumber, matching SPEC-005 §3.1 step 1. Operators may still override via `--derivation.confirmations`. - node/derivation/derivation.go - Step 1: drop the `confirmations != Finalized` guard around detectReorg — reorg detection now runs every loop on the safe segment, per SPEC-005 §3.2. - After verifyBatchRoots success, write a HeadAnchor (L2 last block + L1 commit block) as the new safe_head and update SafeHeadL2Number metric. - End of loop: invoke advanceFinalizedHead from the new finalized tracker. - node/derivation/finalized_tracker.go (new) advanceFinalizedHead implements SPEC-005 §3.1 step 4. Two paths: cheap path: safe_head.L1Number ≤ L1 finalized → ratchet to safe_head. steady-state: walk commit-batch logs in (currentFinalized, L1 finalized], decode-light the latest one, anchor finalized_head at its L2 last block. Calldata-only — never fetches blob data. Monotonicity is enforced; any regression attempt halts the node. - node/derivation/dual_channel.go Update header comment now that the main loop and finalized tracker are wired into both cursors. Out of scope (per spec §6.2 / §8 and per project decision to leave full rollback for the rollback-executor PR): - L1 anchor window depth tuning (#7 / §8 #1) - Path B implementation (#10) — skeleton stays - Rollback executor (#12) — skeleton stays in verify.go - Sequencer mutex wiring (#11) — skeleton stays - Admin RPC auth (#13 / §8 #2) — skeleton stays --- node/derivation/config.go | 4 +- node/derivation/derivation.go | 68 ++++++++----- node/derivation/dual_channel.go | 23 +++-- node/derivation/finalized_tracker.go | 142 +++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 node/derivation/finalized_tracker.go diff --git a/node/derivation/config.go b/node/derivation/config.go index 93bd5fd7f..3f087d348 100644 --- a/node/derivation/config.go +++ b/node/derivation/config.go @@ -53,7 +53,9 @@ type Config struct { func DefaultConfig() *Config { return &Config{ L1: &types.L1Config{ - Confirmations: rpc.FinalizedBlockNumber, + // SPEC-005 §3.1 / §3.2: derivation main loop drives on L1 safe. + // finalized_head is advanced by a separate tracker (finalized_tracker.go). + Confirmations: rpc.SafeBlockNumber, }, PollInterval: DefaultPollInterval, LogProgressInterval: DefaultLogProgressInterval, diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index e972bd426..4753b74f9 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -187,25 +187,27 @@ func (d *Derivation) derivationBlock(ctx context.Context) { return } - // Step 1: Check for L1 reorg (only meaningful when not using finalized) - if d.confirmations != rpc.FinalizedBlockNumber { - reorgAt, err := d.detectReorg(ctx) - if err != nil { - d.logger.Error("reorg detection failed", "err", err) - return - } - if reorgAt != nil { - d.logger.Info("L1 reorg detected, invoking reorg handler", "reorgAtL1Height", *reorgAt) - d.metrics.IncReorgCount() - if err := d.handleL1Reorg(*reorgAt); err != nil { - d.logger.Error("handle L1 reorg failed", "err", err) - } - // Always return after reorg detection — don't continue processing in - // the same loop. Let the next poll interval re-fetch from the reset - // height. This avoids recording potentially unstable L1 block hashes - // if the chain is still reorging. - return + // Step 1: SPEC-005 §3.2 — detect L1 reorg on the safe segment. + // Reorg detection runs unconditionally now that the main loop drives on + // L1 safe by default; finalized-mode operators can still opt in via + // `--derivation.confirmations`, in which case detectReorg is a cheap + // no-op (finalized never reorgs). + reorgAt, err := d.detectReorg(ctx) + if err != nil { + d.logger.Error("reorg detection failed", "err", err) + return + } + if reorgAt != nil { + d.logger.Info("L1 reorg detected, invoking reorg handler", "reorgAtL1Height", *reorgAt) + d.metrics.IncReorgCount() + if err := d.handleL1Reorg(*reorgAt); err != nil { + d.logger.Error("handle L1 reorg failed", "err", err) } + // Always return after reorg detection — don't continue processing in + // the same loop. Let the next poll interval re-fetch from the reset + // height. This avoids recording potentially unstable L1 block hashes + // if the chain is still reorging. + return } // Step 2: Determine L1 scan range @@ -316,19 +318,37 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.metrics.SetBatchStatus(stateNormal) d.metrics.SetL1SyncHeight(lg.BlockNumber) - } - // Step 5: Record L1 block hashes for reorg detection (only needed for non-finalized modes) - if d.confirmations != rpc.FinalizedBlockNumber { - if err := d.recordL1Blocks(ctx, start, end); err != nil { - d.logger.Error("recordL1Blocks failed, will retry next loop", "err", err) - return + // SPEC-005 §3.1 step 3 — anchor safe_head to (L2 last block, L1 commit + // block) after a batch is fully verified. Single-key Put for now; see + // writeSafeHead doc for the deferred multi-key atomicity TODO. + safeAnchor := HeadAnchor{ + L2Number: lastHeader.Number.Uint64(), + L1Number: lg.BlockNumber, } + copy(safeAnchor.L2Hash[:], lastHeader.Hash().Bytes()) + copy(safeAnchor.L1Hash[:], lg.BlockHash.Bytes()) + d.writeSafeHead(safeAnchor) + d.metrics.SetSafeHeadL2Number(safeAnchor.L2Number) + } + + // Step 5: Record L1 block hashes for reorg detection on the safe segment. + // SPEC-005 §3.2: anchor window is required on the safe channel; on a + // finalized-mode override the recordL1Blocks calls are still cheap and + // the records are simply unused. + if err := d.recordL1Blocks(ctx, start, end); err != nil { + d.logger.Error("recordL1Blocks failed, will retry next loop", "err", err) + return } d.db.WriteLatestDerivationL1Height(end) d.metrics.SetL1SyncHeight(end) d.logger.Info("write latest derivation l1 height success", "l1BlockNumber", end) + + // Step 6: SPEC-005 §3.1 step 4 — advance finalized_head from L1 finalized + // segment. Best-effort: errors are logged, never block the safe-channel + // progress. + d.advanceFinalizedHead(ctx) } func (d *Derivation) fetchRollupLog(ctx context.Context, from, to uint64) ([]eth.Log, error) { diff --git a/node/derivation/dual_channel.go b/node/derivation/dual_channel.go index 61a38723e..24744fa0c 100644 --- a/node/derivation/dual_channel.go +++ b/node/derivation/dual_channel.go @@ -10,17 +10,20 @@ import ( // SPEC-005 §3.2 "L1 双通道驱动": // -// The derivation pipeline must consume two independent L1 cursors: -// - "safe" drives safe_head and is allowed to roll back when L1 reorgs out a batch. -// - "finalized" drives finalized_head; it is monotonic and never rolls back. +// The derivation pipeline consumes two independent L1 cursors: +// - "safe" drives safe_head via the main derivation loop +// (see derivation.go::derivationBlock); it is allowed to roll +// back when L1 reorgs out a batch. +// - "finalized" drives finalized_head via the finalized tracker +// (see finalized_tracker.go::advanceFinalizedHead); it is +// monotonic and never rolls back. // -// The current main loop in derivationBlock() still consumes a single -// `d.confirmations` cursor (rpc.FinalizedBlockNumber by default). The helpers -// below are intentionally not yet wired into the main loop — switching the -// main loop is gated on the SPEC-005 §8 blocking decisions (anchor-window -// depth, sequencer mutex granularity). They are exposed now so that -// downstream tasks #5 / #6 / #7 can build on them without re-establishing -// the L1 access pattern from scratch. +// As of SPEC-005 task #5, the main loop's L1 source is `d.confirmations`, +// which defaults to rpc.SafeBlockNumber (see config.go). Operators may +// override it back to rpc.FinalizedBlockNumber via the existing +// `--derivation.confirmations` flag, in which case the safe channel and +// finalized channel collapse onto the same cursor and reorg detection +// becomes a no-op. // fetchLatestSafeHeight returns the L1 block number of the latest "safe" head. // diff --git a/node/derivation/finalized_tracker.go b/node/derivation/finalized_tracker.go new file mode 100644 index 000000000..5a7a0d11f --- /dev/null +++ b/node/derivation/finalized_tracker.go @@ -0,0 +1,142 @@ +package derivation + +import ( + "context" + "fmt" + "math/big" +) + +// SPEC-005 §3.1 step 4 — advance finalized_head from the L1 "finalized" channel. +// +// The main loop drives the L1 "safe" channel; finalized_head lags behind +// safe_head by the L1 finalization delay (~13 minutes on mainnet). On every +// loop iteration we: +// +// 1. Read the latest safe_head (the most recent batch we've verified). +// 2. Fetch the latest L1 finalized height. +// 3. If safe_head.L1Number ≤ L1 finalized, the cheap path applies: every +// batch up through safe_head is finalized, so finalized_head can ratchet +// directly to safe_head. +// 4. Otherwise (the steady-state case where safe is ahead of finalized), walk +// the rollup commit logs in the (currentFinalized.L1Number, L1 finalized] +// range to find the latest finalizable batch, and ratchet finalized_head +// to its L2 anchor. +// +// finalized_head is monotonic per spec §3.1; any regression attempt halts the +// node. +// +// This function is best-effort: any L1 RPC error is logged and the call +// returns. The next iteration retries. +func (d *Derivation) advanceFinalizedHead(ctx context.Context) { + safe := d.readSafeHead() + if safe == nil { + return + } + + finalizedL1, err := d.fetchLatestFinalizedHeight(ctx) + if err != nil { + d.logger.Debug("fetch L1 finalized failed during finalized_head advance", "err", err) + return + } + + current := d.readFinalizedHead() + + // Cheap path: safe_head's L1 anchor is already finalized. + if safe.L1Number <= finalizedL1 { + d.commitFinalizedHead(*safe, current) + return + } + + // Steady-state path: walk commit logs in the (currentFinalized, L1 finalized] window. + var fromL1 uint64 + if current != nil { + fromL1 = current.L1Number + 1 + } else { + fromL1 = d.startHeight + } + if fromL1 > finalizedL1 { + return + } + + candidate, err := d.scanFinalizableBatch(ctx, fromL1, finalizedL1) + if err != nil { + d.logger.Debug("scan finalizable batch failed", "err", err, "from", fromL1, "to", finalizedL1) + return + } + if candidate == nil { + return + } + d.commitFinalizedHead(*candidate, current) +} + +// scanFinalizableBatch returns the L2 anchor of the latest commit-batch event +// in the inclusive [from, to] L1 block window, or nil if no commit lands in +// the window. Decode-light: only consumes calldata, never fetches blob data. +func (d *Derivation) scanFinalizableBatch(ctx context.Context, from, to uint64) (*HeadAnchor, error) { + logs, err := d.fetchRollupLog(ctx, from, to) + if err != nil { + return nil, fmt.Errorf("fetch rollup logs: %w", err) + } + if len(logs) == 0 { + return nil, nil + } + lg := logs[len(logs)-1] + + // Decode-light: pull the L2 last block out of calldata. No blob fetch. + tx, pending, err := d.l1Client.TransactionByHash(ctx, lg.TxHash) + if err != nil { + return nil, fmt.Errorf("tx by hash %s: %w", lg.TxHash.Hex(), err) + } + if pending { + return nil, nil + } + rb, err := d.UnPackData(tx.Data()) + if err != nil { + return nil, fmt.Errorf("unpack commit batch: %w", err) + } + // Older calldata variants don't carry LastBlockNumber on the wire; in that + // case skip finalized advance for this iteration. The next iteration after + // safe_head ratchets past this L1 height will pick it up via the cheap + // path above. + if rb.LastBlockNumber == 0 { + return nil, nil + } + + l2Header, err := d.l2Client.HeaderByNumber(ctx, big.NewInt(int64(rb.LastBlockNumber))) + if err != nil { + return nil, fmt.Errorf("L2 header by number %d: %w", rb.LastBlockNumber, err) + } + if l2Header == nil { + return nil, fmt.Errorf("nil L2 header for block %d", rb.LastBlockNumber) + } + + anchor := HeadAnchor{ + L2Number: l2Header.Number.Uint64(), + L1Number: lg.BlockNumber, + } + copy(anchor.L2Hash[:], l2Header.Hash().Bytes()) + copy(anchor.L1Hash[:], lg.BlockHash.Bytes()) + return &anchor, nil +} + +// commitFinalizedHead writes a new finalized_head, enforcing the SPEC-005 +// §3.1 monotonicity invariant: finalized_head must never regress. Any attempt +// to do so halts the node. +func (d *Derivation) commitFinalizedHead(candidate HeadAnchor, current *HeadAnchor) { + if current != nil { + if candidate.L2Number == current.L2Number { + return + } + if candidate.L2Number < current.L2Number { + d.logger.Error("CRITICAL: finalized_head regression attempt; halting derivation", + "current", current.L2Number, "candidate", candidate.L2Number) + d.halted = true + d.metrics.SetHalted() + return + } + } + d.writeFinalizedHead(candidate) + d.metrics.SetFinalizedHeadL2Number(candidate.L2Number) + d.logger.Info("finalized_head advanced", + "l2Number", candidate.L2Number, "l1Number", candidate.L1Number) +} From 4158e4e6bb6c1b9921cebfba9ece4659c1cff0a3 Mon Sep 17 00:00:00 2001 From: corey Date: Sat, 9 May 2026 17:07:35 +0800 Subject: [PATCH 26/26] =?UTF-8?q?Revert=20"feat(derivation):=20SPEC-005=20?= =?UTF-8?q?=C2=A73.1=20dual-channel=20main=20loop=20+=20safe/finalized=20h?= =?UTF-8?q?ead=20anchors"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 308dfcb1d926f1709ba0810aeb19e6aa49c21604. SPEC-005 scope was reset (morph-specs#18) to cover only: A. validator cleanup B. derivation Path B (local blob hash reconstruction fallback) The reverted commit changed Confirmations default from Finalized to Safe, added safe_head writes in the main loop, and introduced finalized_tracker.go. All of that is L2 state-tiering work — out of scope for SPEC-005 and deferred to a future independent PR. Skeleton files remain in node/derivation/ as placeholders to be reused by the future state-tiering PR: - state.go, head_anchor.go, dual_channel.go: state tiering primitives - admin_rpc.go, sequencer_mutex.go, verify.go::rollbackLocalChain: rollback executor primitives - verify_path_b.go: scope-A target (Path B implementation pending) --- node/derivation/config.go | 4 +- node/derivation/derivation.go | 68 +++++-------- node/derivation/dual_channel.go | 23 ++--- node/derivation/finalized_tracker.go | 142 --------------------------- 4 files changed, 35 insertions(+), 202 deletions(-) delete mode 100644 node/derivation/finalized_tracker.go diff --git a/node/derivation/config.go b/node/derivation/config.go index 3f087d348..93bd5fd7f 100644 --- a/node/derivation/config.go +++ b/node/derivation/config.go @@ -53,9 +53,7 @@ type Config struct { func DefaultConfig() *Config { return &Config{ L1: &types.L1Config{ - // SPEC-005 §3.1 / §3.2: derivation main loop drives on L1 safe. - // finalized_head is advanced by a separate tracker (finalized_tracker.go). - Confirmations: rpc.SafeBlockNumber, + Confirmations: rpc.FinalizedBlockNumber, }, PollInterval: DefaultPollInterval, LogProgressInterval: DefaultLogProgressInterval, diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 4753b74f9..e972bd426 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -187,27 +187,25 @@ func (d *Derivation) derivationBlock(ctx context.Context) { return } - // Step 1: SPEC-005 §3.2 — detect L1 reorg on the safe segment. - // Reorg detection runs unconditionally now that the main loop drives on - // L1 safe by default; finalized-mode operators can still opt in via - // `--derivation.confirmations`, in which case detectReorg is a cheap - // no-op (finalized never reorgs). - reorgAt, err := d.detectReorg(ctx) - if err != nil { - d.logger.Error("reorg detection failed", "err", err) - return - } - if reorgAt != nil { - d.logger.Info("L1 reorg detected, invoking reorg handler", "reorgAtL1Height", *reorgAt) - d.metrics.IncReorgCount() - if err := d.handleL1Reorg(*reorgAt); err != nil { - d.logger.Error("handle L1 reorg failed", "err", err) + // Step 1: Check for L1 reorg (only meaningful when not using finalized) + if d.confirmations != rpc.FinalizedBlockNumber { + reorgAt, err := d.detectReorg(ctx) + if err != nil { + d.logger.Error("reorg detection failed", "err", err) + return + } + if reorgAt != nil { + d.logger.Info("L1 reorg detected, invoking reorg handler", "reorgAtL1Height", *reorgAt) + d.metrics.IncReorgCount() + if err := d.handleL1Reorg(*reorgAt); err != nil { + d.logger.Error("handle L1 reorg failed", "err", err) + } + // Always return after reorg detection — don't continue processing in + // the same loop. Let the next poll interval re-fetch from the reset + // height. This avoids recording potentially unstable L1 block hashes + // if the chain is still reorging. + return } - // Always return after reorg detection — don't continue processing in - // the same loop. Let the next poll interval re-fetch from the reset - // height. This avoids recording potentially unstable L1 block hashes - // if the chain is still reorging. - return } // Step 2: Determine L1 scan range @@ -318,37 +316,19 @@ func (d *Derivation) derivationBlock(ctx context.Context) { d.metrics.SetBatchStatus(stateNormal) d.metrics.SetL1SyncHeight(lg.BlockNumber) + } - // SPEC-005 §3.1 step 3 — anchor safe_head to (L2 last block, L1 commit - // block) after a batch is fully verified. Single-key Put for now; see - // writeSafeHead doc for the deferred multi-key atomicity TODO. - safeAnchor := HeadAnchor{ - L2Number: lastHeader.Number.Uint64(), - L1Number: lg.BlockNumber, + // Step 5: Record L1 block hashes for reorg detection (only needed for non-finalized modes) + if d.confirmations != rpc.FinalizedBlockNumber { + if err := d.recordL1Blocks(ctx, start, end); err != nil { + d.logger.Error("recordL1Blocks failed, will retry next loop", "err", err) + return } - copy(safeAnchor.L2Hash[:], lastHeader.Hash().Bytes()) - copy(safeAnchor.L1Hash[:], lg.BlockHash.Bytes()) - d.writeSafeHead(safeAnchor) - d.metrics.SetSafeHeadL2Number(safeAnchor.L2Number) - } - - // Step 5: Record L1 block hashes for reorg detection on the safe segment. - // SPEC-005 §3.2: anchor window is required on the safe channel; on a - // finalized-mode override the recordL1Blocks calls are still cheap and - // the records are simply unused. - if err := d.recordL1Blocks(ctx, start, end); err != nil { - d.logger.Error("recordL1Blocks failed, will retry next loop", "err", err) - return } d.db.WriteLatestDerivationL1Height(end) d.metrics.SetL1SyncHeight(end) d.logger.Info("write latest derivation l1 height success", "l1BlockNumber", end) - - // Step 6: SPEC-005 §3.1 step 4 — advance finalized_head from L1 finalized - // segment. Best-effort: errors are logged, never block the safe-channel - // progress. - d.advanceFinalizedHead(ctx) } func (d *Derivation) fetchRollupLog(ctx context.Context, from, to uint64) ([]eth.Log, error) { diff --git a/node/derivation/dual_channel.go b/node/derivation/dual_channel.go index 24744fa0c..61a38723e 100644 --- a/node/derivation/dual_channel.go +++ b/node/derivation/dual_channel.go @@ -10,20 +10,17 @@ import ( // SPEC-005 §3.2 "L1 双通道驱动": // -// The derivation pipeline consumes two independent L1 cursors: -// - "safe" drives safe_head via the main derivation loop -// (see derivation.go::derivationBlock); it is allowed to roll -// back when L1 reorgs out a batch. -// - "finalized" drives finalized_head via the finalized tracker -// (see finalized_tracker.go::advanceFinalizedHead); it is -// monotonic and never rolls back. +// The derivation pipeline must consume two independent L1 cursors: +// - "safe" drives safe_head and is allowed to roll back when L1 reorgs out a batch. +// - "finalized" drives finalized_head; it is monotonic and never rolls back. // -// As of SPEC-005 task #5, the main loop's L1 source is `d.confirmations`, -// which defaults to rpc.SafeBlockNumber (see config.go). Operators may -// override it back to rpc.FinalizedBlockNumber via the existing -// `--derivation.confirmations` flag, in which case the safe channel and -// finalized channel collapse onto the same cursor and reorg detection -// becomes a no-op. +// The current main loop in derivationBlock() still consumes a single +// `d.confirmations` cursor (rpc.FinalizedBlockNumber by default). The helpers +// below are intentionally not yet wired into the main loop — switching the +// main loop is gated on the SPEC-005 §8 blocking decisions (anchor-window +// depth, sequencer mutex granularity). They are exposed now so that +// downstream tasks #5 / #6 / #7 can build on them without re-establishing +// the L1 access pattern from scratch. // fetchLatestSafeHeight returns the L1 block number of the latest "safe" head. // diff --git a/node/derivation/finalized_tracker.go b/node/derivation/finalized_tracker.go deleted file mode 100644 index 5a7a0d11f..000000000 --- a/node/derivation/finalized_tracker.go +++ /dev/null @@ -1,142 +0,0 @@ -package derivation - -import ( - "context" - "fmt" - "math/big" -) - -// SPEC-005 §3.1 step 4 — advance finalized_head from the L1 "finalized" channel. -// -// The main loop drives the L1 "safe" channel; finalized_head lags behind -// safe_head by the L1 finalization delay (~13 minutes on mainnet). On every -// loop iteration we: -// -// 1. Read the latest safe_head (the most recent batch we've verified). -// 2. Fetch the latest L1 finalized height. -// 3. If safe_head.L1Number ≤ L1 finalized, the cheap path applies: every -// batch up through safe_head is finalized, so finalized_head can ratchet -// directly to safe_head. -// 4. Otherwise (the steady-state case where safe is ahead of finalized), walk -// the rollup commit logs in the (currentFinalized.L1Number, L1 finalized] -// range to find the latest finalizable batch, and ratchet finalized_head -// to its L2 anchor. -// -// finalized_head is monotonic per spec §3.1; any regression attempt halts the -// node. -// -// This function is best-effort: any L1 RPC error is logged and the call -// returns. The next iteration retries. -func (d *Derivation) advanceFinalizedHead(ctx context.Context) { - safe := d.readSafeHead() - if safe == nil { - return - } - - finalizedL1, err := d.fetchLatestFinalizedHeight(ctx) - if err != nil { - d.logger.Debug("fetch L1 finalized failed during finalized_head advance", "err", err) - return - } - - current := d.readFinalizedHead() - - // Cheap path: safe_head's L1 anchor is already finalized. - if safe.L1Number <= finalizedL1 { - d.commitFinalizedHead(*safe, current) - return - } - - // Steady-state path: walk commit logs in the (currentFinalized, L1 finalized] window. - var fromL1 uint64 - if current != nil { - fromL1 = current.L1Number + 1 - } else { - fromL1 = d.startHeight - } - if fromL1 > finalizedL1 { - return - } - - candidate, err := d.scanFinalizableBatch(ctx, fromL1, finalizedL1) - if err != nil { - d.logger.Debug("scan finalizable batch failed", "err", err, "from", fromL1, "to", finalizedL1) - return - } - if candidate == nil { - return - } - d.commitFinalizedHead(*candidate, current) -} - -// scanFinalizableBatch returns the L2 anchor of the latest commit-batch event -// in the inclusive [from, to] L1 block window, or nil if no commit lands in -// the window. Decode-light: only consumes calldata, never fetches blob data. -func (d *Derivation) scanFinalizableBatch(ctx context.Context, from, to uint64) (*HeadAnchor, error) { - logs, err := d.fetchRollupLog(ctx, from, to) - if err != nil { - return nil, fmt.Errorf("fetch rollup logs: %w", err) - } - if len(logs) == 0 { - return nil, nil - } - lg := logs[len(logs)-1] - - // Decode-light: pull the L2 last block out of calldata. No blob fetch. - tx, pending, err := d.l1Client.TransactionByHash(ctx, lg.TxHash) - if err != nil { - return nil, fmt.Errorf("tx by hash %s: %w", lg.TxHash.Hex(), err) - } - if pending { - return nil, nil - } - rb, err := d.UnPackData(tx.Data()) - if err != nil { - return nil, fmt.Errorf("unpack commit batch: %w", err) - } - // Older calldata variants don't carry LastBlockNumber on the wire; in that - // case skip finalized advance for this iteration. The next iteration after - // safe_head ratchets past this L1 height will pick it up via the cheap - // path above. - if rb.LastBlockNumber == 0 { - return nil, nil - } - - l2Header, err := d.l2Client.HeaderByNumber(ctx, big.NewInt(int64(rb.LastBlockNumber))) - if err != nil { - return nil, fmt.Errorf("L2 header by number %d: %w", rb.LastBlockNumber, err) - } - if l2Header == nil { - return nil, fmt.Errorf("nil L2 header for block %d", rb.LastBlockNumber) - } - - anchor := HeadAnchor{ - L2Number: l2Header.Number.Uint64(), - L1Number: lg.BlockNumber, - } - copy(anchor.L2Hash[:], l2Header.Hash().Bytes()) - copy(anchor.L1Hash[:], lg.BlockHash.Bytes()) - return &anchor, nil -} - -// commitFinalizedHead writes a new finalized_head, enforcing the SPEC-005 -// §3.1 monotonicity invariant: finalized_head must never regress. Any attempt -// to do so halts the node. -func (d *Derivation) commitFinalizedHead(candidate HeadAnchor, current *HeadAnchor) { - if current != nil { - if candidate.L2Number == current.L2Number { - return - } - if candidate.L2Number < current.L2Number { - d.logger.Error("CRITICAL: finalized_head regression attempt; halting derivation", - "current", current.L2Number, "candidate", candidate.L2Number) - d.halted = true - d.metrics.SetHalted() - return - } - } - d.writeFinalizedHead(candidate) - d.metrics.SetFinalizedHeadL2Number(candidate.L2Number) - d.logger.Info("finalized_head advanced", - "l2Number", candidate.L2Number, "l1Number", candidate.L1Number) -}