diff --git a/node/batchprocessor/config.go b/node/batchprocessor/config.go new file mode 100644 index 000000000..098da3f84 --- /dev/null +++ b/node/batchprocessor/config.go @@ -0,0 +1,49 @@ +package batchprocessor + +import ( + "fmt" + "time" + + "github.com/morph-l2/go-ethereum/common" + "github.com/urfave/cli" + + node "morph-l2/node/core" + "morph-l2/node/flags" +) + +const ( + DefaultPollInterval = 12 * time.Second + DefaultSafeConfirmations = 10 +) + +type Config struct { + L1Addr string + RollupAddress common.Address + SafeConfirmations uint64 + PollInterval time.Duration +} + +func DefaultConfig() *Config { + return &Config{ + SafeConfirmations: DefaultSafeConfirmations, + PollInterval: DefaultPollInterval, + } +} + +func (c *Config) SetCliContext(ctx *cli.Context) error { + c.L1Addr = ctx.GlobalString(flags.L1NodeAddr.Name) + + if ctx.GlobalBool(flags.MainnetFlag.Name) { + c.RollupAddress = node.MainnetRollupContractAddress + } else if ctx.GlobalIsSet(flags.RollupContractAddress.Name) { + c.RollupAddress = common.HexToAddress(ctx.GlobalString(flags.RollupContractAddress.Name)) + } else { + return fmt.Errorf("rollup contract address is required: either specify --%s or use --%s for mainnet default", + flags.RollupContractAddress.Name, flags.MainnetFlag.Name) + } + + if ctx.GlobalIsSet(flags.BlockTagSafeConfirmations.Name) { + c.SafeConfirmations = ctx.GlobalUint64(flags.BlockTagSafeConfirmations.Name) + } + return nil +} diff --git a/node/batchprocessor/processor.go b/node/batchprocessor/processor.go new file mode 100644 index 000000000..a8907b0fb --- /dev/null +++ b/node/batchprocessor/processor.go @@ -0,0 +1,396 @@ +package batchprocessor + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/morph-l2/go-ethereum" + "github.com/morph-l2/go-ethereum/accounts/abi/bind" + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/ethclient" + "github.com/morph-l2/go-ethereum/rpc" + tmlog "github.com/tendermint/tendermint/libs/log" + + "morph-l2/bindings/bindings" + "morph-l2/node/derivation" + "morph-l2/node/types" +) + +// BatchProcessor sequentially processes committed batches from L1, verifies +// each via BatchVerifier, and updates safe/finalized block tags on L2. +// +// Unlike BlockTagService (which it replaces), it walks every batch in order +// so no intermediate batch is skipped. +type BatchProcessor struct { + ctx context.Context + cancel context.CancelFunc + + l1Client *ethclient.Client + l2Client *types.RetryableClient + rollup *bindings.Rollup + + batchVerifier *derivation.BatchVerifier + + rollupAddress common.Address + safeConfirmations uint64 + pollInterval time.Duration + + // Sequential cursors: the last batch index we verified for each tag. + // On each tick we attempt to advance from cursor+1 up to the on-chain head. + lastSafeBatchIndex uint64 + lastFinalizedBatchIndex uint64 + + // L1 block of the last CommitBatch event we found. Used as the search + // start for subsequent FilterLogs calls so we never miss events during + // catch-up (replaces the fixed 1000-block window). + lastCommitL1Block uint64 + + // Tag state + safeL2BlockHash common.Hash + finalizedL2BlockHash common.Hash + lastNotifiedSafeHash common.Hash + lastNotifiedFinalizedHash common.Hash + + logger tmlog.Logger + stop chan struct{} +} + +func NewBatchProcessor( + ctx context.Context, + l2Client *types.RetryableClient, + config *Config, + bv *derivation.BatchVerifier, + logger tmlog.Logger, +) (*BatchProcessor, error) { + if config.L1Addr == "" { + return nil, fmt.Errorf("L1 RPC address is required") + } + + l1Client, err := ethclient.Dial(config.L1Addr) + if err != nil { + return nil, fmt.Errorf("failed to connect to L1: %w", err) + } + + rollup, err := bindings.NewRollup(config.RollupAddress, l1Client) + if err != nil { + l1Client.Close() + return nil, fmt.Errorf("failed to create rollup binding: %w", err) + } + + ctx, cancel := context.WithCancel(ctx) + + return &BatchProcessor{ + ctx: ctx, + cancel: cancel, + l1Client: l1Client, + l2Client: l2Client, + rollup: rollup, + batchVerifier: bv, + rollupAddress: config.RollupAddress, + safeConfirmations: config.SafeConfirmations, + pollInterval: config.PollInterval, + logger: logger.With("module", "batchprocessor"), + stop: make(chan struct{}), + }, nil +} + +func (bp *BatchProcessor) Start() error { + if err := bp.initCursors(); err != nil { + bp.logger.Error("failed to init cursors, starting from 0", "error", err) + } + + go bp.loop() + bp.logger.Info("BatchProcessor started", + "safeCursor", bp.lastSafeBatchIndex, + "finalizedCursor", bp.lastFinalizedBatchIndex, + "pollInterval", bp.pollInterval) + return nil +} + +func (bp *BatchProcessor) Stop() { + bp.cancel() + <-bp.stop + + bp.l1Client.Close() + if bp.batchVerifier != nil { + bp.batchVerifier.Close() + } + bp.logger.Info("BatchProcessor stopped") +} + +// initCursors sets both cursors to the on-chain LastFinalizedBatchIndex so we +// skip already finalized (and therefore already validated) history. +// It also resolves the corresponding L2 block hashes so that geth receives +// correct safe/finalized tags immediately after restart. +func (bp *BatchProcessor) initCursors() error { + lastFinalized, err := bp.rollup.LastFinalizedBatchIndex(nil) + if err != nil || lastFinalized == nil { + return fmt.Errorf("query LastFinalizedBatchIndex: %w", err) + } + idx := lastFinalized.Uint64() + if idx == 0 { + return nil + } + + bp.lastSafeBatchIndex = idx + bp.lastFinalizedBatchIndex = idx + + // Resolve the L2 block hash for the cursor batch so that notifyGeth can + // send correct tags right away instead of waiting for the next new batch. + batchData, err := bp.rollup.BatchDataStore(nil, new(big.Int).SetUint64(idx)) + if err != nil { + bp.logger.Error("initCursors: failed to query BatchDataStore, tags will be empty until next batch", "error", err) + return nil + } + lastBlock := batchData.BlockNumber.Uint64() + header, err := bp.l2Client.HeaderByNumber(bp.ctx, new(big.Int).SetUint64(lastBlock)) + if err != nil { + bp.logger.Error("initCursors: failed to get L2 header, tags will be empty until next batch", + "l2Block", lastBlock, "error", err) + return nil + } + blockHash := header.Hash() + bp.safeL2BlockHash = blockHash + bp.finalizedL2BlockHash = blockHash + + bp.logger.Info("cursors initialized from on-chain finalized index", + "batchIndex", idx, "l2Block", lastBlock, "blockHash", blockHash.Hex()) + return nil +} + +func (bp *BatchProcessor) loop() { + defer close(bp.stop) + + ticker := time.NewTicker(bp.pollInterval) + defer ticker.Stop() + + for { + select { + case <-bp.ctx.Done(): + return + case <-ticker.C: + if err := bp.processTick(); err != nil { + bp.logger.Error("tick failed", "error", err) + } + } + } +} + +func (bp *BatchProcessor) processTick() error { + l2Head, err := bp.l2Client.BlockNumber(bp.ctx) + if err != nil { + return fmt.Errorf("get L2 head: %w", err) + } + + // Cache L1 head once per tick to avoid redundant RPC calls when processing + // multiple batches in a single tick (catch-up scenario). + currentL1, err := bp.l1Client.BlockNumber(bp.ctx) + if err != nil { + return fmt.Errorf("get L1 head: %w", err) + } + + // --- safe --- + safeL1Head := bp.calcSafeL1Head(currentL1) + if safeL1Head > 0 { + safeOnChainHead, err := bp.getLastCommittedBatchAtBlock(rpc.BlockNumber(safeL1Head)) + if err != nil { + bp.logger.Error("get safe committed head failed", "error", err) + } else { + bp.advanceSafe(l2Head, safeOnChainHead, currentL1) + } + } + + // --- finalized --- + finalizedOnChainHead, err := bp.getLastCommittedBatchAtBlock(rpc.FinalizedBlockNumber) + if err != nil { + bp.logger.Error("get finalized committed head failed", "error", err) + } else { + bp.advanceFinalized(l2Head, finalizedOnChainHead, currentL1) + } + + if err := bp.notifyGeth(); err != nil { + bp.logger.Error("notify geth failed", "error", err) + } + + bp.logger.Debug("tick done", + "l2Head", l2Head, + "safeCursor", bp.lastSafeBatchIndex, + "finalizedCursor", bp.lastFinalizedBatchIndex, + "safeHash", bp.safeL2BlockHash.Hex(), + "finalizedHash", bp.finalizedL2BlockHash.Hex()) + + return nil +} + +// advanceSafe tries to move the safe cursor forward one batch at a time. +func (bp *BatchProcessor) advanceSafe(l2Head, onChainHead, currentL1 uint64) { + for idx := bp.lastSafeBatchIndex + 1; idx <= onChainHead; idx++ { + lastBlock, hash, err := bp.processOneBatch(idx, l2Head, currentL1) + if err != nil { + bp.logger.Error("safe batch processing failed", "batchIndex", idx, "error", err) + return + } + if lastBlock == 0 { + return // batch not yet completed on L2 + } + bp.lastSafeBatchIndex = idx + bp.safeL2BlockHash = hash + bp.logger.Info("safe cursor advanced", "batchIndex", idx, "l2Block", lastBlock) + } +} + +// advanceFinalized tries to move the finalized cursor forward. It can never +// exceed the safe cursor because finalized <= safe. +func (bp *BatchProcessor) advanceFinalized(l2Head, onChainHead, currentL1 uint64) { + limit := onChainHead + if bp.lastSafeBatchIndex < limit { + limit = bp.lastSafeBatchIndex + } + for idx := bp.lastFinalizedBatchIndex + 1; idx <= limit; idx++ { + lastBlock, hash, err := bp.processOneBatch(idx, l2Head, currentL1) + if err != nil { + bp.logger.Error("finalized batch processing failed", "batchIndex", idx, "error", err) + return + } + if lastBlock == 0 { + return + } + bp.lastFinalizedBatchIndex = idx + bp.finalizedL2BlockHash = hash + + // finalized implies safe + if bp.safeL2BlockHash == (common.Hash{}) { + bp.safeL2BlockHash = hash + } + bp.logger.Info("finalized cursor advanced", "batchIndex", idx, "l2Block", lastBlock) + } +} + +// processOneBatch fetches, optionally verifies, and returns the last L2 block +// number + hash for a given batch. Returns (0, empty, nil) if the batch's last +// L2 block is not yet available locally (node still syncing). +func (bp *BatchProcessor) processOneBatch(batchIndex, l2Head, currentL1 uint64) (uint64, common.Hash, error) { + batchData, err := bp.rollup.BatchDataStore(nil, new(big.Int).SetUint64(batchIndex)) + if err != nil { + return 0, common.Hash{}, fmt.Errorf("query BatchDataStore(%d): %w", batchIndex, err) + } + + lastBlock := batchData.BlockNumber.Uint64() + if lastBlock > l2Head { + return 0, common.Hash{}, nil // not synced yet + } + + if bp.batchVerifier != nil { + if err := bp.verifyBatch(batchIndex, currentL1); err != nil { + // TODO: decide on verification failure behavior + return 0, common.Hash{}, fmt.Errorf("batch %d verification failed: %w", batchIndex, err) + } + } + + header, err := bp.l2Client.HeaderByNumber(bp.ctx, new(big.Int).SetUint64(lastBlock)) + if err != nil { + return 0, common.Hash{}, fmt.Errorf("get L2 header %d: %w", lastBlock, err) + } + + return lastBlock, header.Hash(), nil +} + +// verifyBatch locates the CommitBatch L1 tx and runs full verification. +func (bp *BatchProcessor) verifyBatch(batchIndex, currentL1 uint64) error { + txHash, err := bp.fetchCommitBatchTxHash(batchIndex, currentL1) + if err != nil { + return fmt.Errorf("fetch CommitBatch tx: %w", err) + } + + roots, err := bp.batchVerifier.FetchBatchRoots(bp.ctx, txHash, batchIndex) + if err != nil { + return fmt.Errorf("FetchBatchRoots: %w", err) + } + + var batchInfo *derivation.BatchInfo + batchInfo, err = bp.batchVerifier.FetchBatchData(bp.ctx, txHash) + if err != nil { + bp.logger.Error("FetchBatchData failed, skipping tx-level verification", "error", err) + batchInfo = nil + } + + if err := bp.batchVerifier.VerifyBatch(bp.ctx, bp.l2Client, roots, batchInfo); err != nil { + return fmt.Errorf("VerifyBatch: %w", err) + } + return nil +} + +// fetchCommitBatchTxHash finds the CommitBatch event on L1 for a given batch +// index. It searches forward from lastCommitL1Block (the L1 block of the +// previous CommitBatch event) so the window naturally covers catch-up scenarios +// instead of being limited to a fixed range. +func (bp *BatchProcessor) fetchCommitBatchTxHash(batchIndex, currentL1 uint64) (common.Hash, error) { + fromBlock := bp.lastCommitL1Block + + batchIndexBig := new(big.Int).SetUint64(batchIndex) + logs, err := bp.l1Client.FilterLogs(bp.ctx, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(fromBlock), + ToBlock: new(big.Int).SetUint64(currentL1), + Addresses: []common.Address{bp.rollupAddress}, + Topics: [][]common.Hash{ + {derivation.RollupEventTopicHash}, + {common.BigToHash(batchIndexBig)}, + }, + }) + if err != nil { + return common.Hash{}, fmt.Errorf("FilterLogs: %w", err) + } + if len(logs) == 0 { + return common.Hash{}, fmt.Errorf("no CommitBatch event found for batch %d in L1 blocks [%d, %d]", batchIndex, fromBlock, currentL1) + } + + bp.lastCommitL1Block = logs[0].BlockNumber + return logs[0].TxHash, nil +} + +func (bp *BatchProcessor) calcSafeL1Head(currentL1 uint64) uint64 { + if currentL1 <= bp.safeConfirmations { + return 0 + } + return currentL1 - bp.safeConfirmations +} + +func (bp *BatchProcessor) getLastCommittedBatchAtBlock(l1BlockTag rpc.BlockNumber) (uint64, error) { + var blockNum *big.Int + if l1BlockTag == rpc.FinalizedBlockNumber { + blockNum = big.NewInt(int64(rpc.FinalizedBlockNumber)) + } else if l1BlockTag >= 0 { + blockNum = big.NewInt(int64(l1BlockTag)) + } + + lastCommitted, err := bp.rollup.LastCommittedBatchIndex(&bind.CallOpts{ + BlockNumber: blockNum, + Context: bp.ctx, + }) + if err != nil { + return 0, err + } + return lastCommitted.Uint64(), nil +} + +func (bp *BatchProcessor) notifyGeth() error { + safe := bp.safeL2BlockHash + finalized := bp.finalizedL2BlockHash + + if safe == bp.lastNotifiedSafeHash && finalized == bp.lastNotifiedFinalizedHash { + return nil + } + if safe == (common.Hash{}) && finalized == (common.Hash{}) { + return nil + } + + if err := bp.l2Client.SetBlockTags(bp.ctx, safe, finalized); err != nil { + return err + } + + bp.lastNotifiedSafeHash = safe + bp.lastNotifiedFinalizedHash = finalized + return nil +} diff --git a/node/blocktag/config.go b/node/blocktag/config.go index 6c392ce0f..f9aa65fe8 100644 --- a/node/blocktag/config.go +++ b/node/blocktag/config.go @@ -24,6 +24,10 @@ type Config struct { RollupAddress common.Address SafeConfirmations uint64 PollInterval time.Duration + // L1StartBlock is the L1 block number to start searching for CommitBatch events. + // Set this to the rollup contract deployment block to avoid scanning from genesis. + // Defaults to 0 (scan from genesis). + L1StartBlock uint64 } // DefaultConfig returns the default configuration diff --git a/node/blocktag/service.go b/node/blocktag/service.go index 86f9b8d70..c8708fb21 100644 --- a/node/blocktag/service.go +++ b/node/blocktag/service.go @@ -6,6 +6,7 @@ import ( "math/big" "time" + ethereum "github.com/morph-l2/go-ethereum" "github.com/morph-l2/go-ethereum/accounts/abi/bind" "github.com/morph-l2/go-ethereum/common" "github.com/morph-l2/go-ethereum/ethclient" @@ -13,6 +14,7 @@ import ( tmlog "github.com/tendermint/tendermint/libs/log" "morph-l2/bindings/bindings" + "morph-l2/node/derivation" "morph-l2/node/types" ) @@ -51,20 +53,35 @@ type BlockTagService struct { l2Client *types.RetryableClient rollup *bindings.Rollup + // BatchVerifier performs full batch verification (roots, block contexts, txs). + // If nil, falls back to the lightweight CommittedStateRoots-only check. + batchVerifier *derivation.BatchVerifier + // Configuration rollupAddress common.Address safeConfirmations uint64 // Number of L1 blocks to wait before considering a batch as safe pollInterval time.Duration + // Per-tag-type search trackers for CommitBatch L1 log filtering. + // Safe and finalized batches are submitted in L1-block order per tag, but safe batch + // index > finalized batch index, so their corresponding L1 blocks may differ. + // Sharing a single tracker would cause the safe tracker (advanced further) to skip + // finalized log queries that target earlier L1 blocks. + safeSearchTracker *l1SearchTracker + finalizedSearchTracker *l1SearchTracker + logger tmlog.Logger stop chan struct{} } -// NewBlockTagService creates a new BlockTagService +// NewBlockTagService creates a new BlockTagService. +// bv is optional: if non-nil, full batch verification (via BatchVerifier) replaces the +// lightweight CommittedStateRoots-only check. Pass nil to keep the original behavior. func NewBlockTagService( ctx context.Context, l2Client *types.RetryableClient, config *Config, + bv *derivation.BatchVerifier, logger tmlog.Logger, ) (*BlockTagService, error) { if config.L1Addr == "" { @@ -87,16 +104,19 @@ func NewBlockTagService( ctx, cancel := context.WithCancel(ctx) return &BlockTagService{ - ctx: ctx, - cancel: cancel, - l1Client: l1Client, - l2Client: l2Client, - rollup: rollup, - rollupAddress: config.RollupAddress, - safeConfirmations: config.SafeConfirmations, - pollInterval: config.PollInterval, - logger: logger.With("module", "blocktag"), - stop: make(chan struct{}), + ctx: ctx, + cancel: cancel, + l1Client: l1Client, + l2Client: l2Client, + rollup: rollup, + batchVerifier: bv, + rollupAddress: config.RollupAddress, + safeConfirmations: config.SafeConfirmations, + pollInterval: config.PollInterval, + safeSearchTracker: newL1SearchTracker(config.L1StartBlock), + finalizedSearchTracker: newL1SearchTracker(config.L1StartBlock), + logger: logger.With("module", "blocktag"), + stop: make(chan struct{}), }, nil } @@ -123,15 +143,61 @@ func (s *BlockTagService) Stop() { s.cancel() <-s.stop s.l1Client.Close() + if s.batchVerifier != nil { + s.batchVerifier.Close() + } s.logger.Info("BlockTagService stopped") } // initialize initializes the service by checking current L1 batch status func (s *BlockTagService) initialize() error { s.logger.Info("Initializing BlockTagService") + s.initSearchFromBlock() return s.updateBlockTags() } +// initSearchFromBlock refines both search trackers using the last finalized batch's +// CommitBatch L1 block number. This avoids a full-chain scan on every restart. +// +// Note: when auto mode is active and no prior data exists, FromBlock=0 means FilterLogs +// scans from genesis. This is a one-time startup cost; subsequent calls use the advanced +// tracker value. +// +// Skipped when l1StartBlock is explicitly configured (tracker handles that internally). +// Falls back silently to the current tracker value on any error. +func (s *BlockTagService) initSearchFromBlock() { + if s.batchVerifier == nil || !s.safeSearchTracker.IsAuto() { + return + } + lastFinalized, err := s.rollup.LastFinalizedBatchIndex(nil) + if err != nil || lastFinalized == nil || lastFinalized.Uint64() == 0 { + s.logger.Info("initSearchFromBlock: could not get last finalized batch index, using default", + "fromBlock", s.safeSearchTracker.FromBlock(), "error", err) + return + } + batchIndex := lastFinalized.Uint64() + batchIndexHash := common.BigToHash(lastFinalized) + logs, err := s.l1Client.FilterLogs(s.ctx, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(s.safeSearchTracker.FromBlock()), + Addresses: []common.Address{s.rollupAddress}, + Topics: [][]common.Hash{ + {derivation.RollupEventTopicHash}, + {batchIndexHash}, + }, + }) + if err != nil || len(logs) == 0 { + s.logger.Info("initSearchFromBlock: CommitBatch event not found for last finalized batch, using default", + "batchIndex", batchIndex, "fromBlock", s.safeSearchTracker.FromBlock(), "error", err) + return + } + // Both trackers start from the same anchor point (last finalized batch L1 block). + // They will diverge naturally as safe and finalized queries advance independently. + s.safeSearchTracker.Advance(logs[0].BlockNumber) + s.finalizedSearchTracker.Advance(logs[0].BlockNumber) + s.logger.Info("initSearchFromBlock: search start refined from last finalized batch", + "batchIndex", batchIndex, "fromBlock", s.safeSearchTracker.FromBlock()) +} + // loop is the main loop that polls L1 for batch status updates func (s *BlockTagService) loop() { defer close(s.stop) @@ -253,8 +319,8 @@ func (s *BlockTagService) getL2BlockForTag(tagType BlockTagType, l2Head uint64) "lastFinalized", lastFinalizedBatchIndex.Uint64()) return 0, common.Hash{}, nil } - if err := s.validateBatchStateRoot(targetBatchIndex, targetBatchLastBlockNum); err != nil { - s.logger.Error("State root validation failed", + if err := s.validateBatch(tagType, targetBatchIndex, targetBatchLastBlockNum); err != nil { + s.logger.Error("Batch validation failed", "tagType", tagType, "batchIndex", targetBatchIndex, "l2Block", targetBatchLastBlockNum, @@ -282,21 +348,77 @@ func (s *BlockTagService) getL2BlockForTag(tagType BlockTagType, l2Head uint64) return targetBatchLastBlockNum, l2BlockHash, nil } -// validateBatchStateRoot validates that the state root of batch's lastL2Block matches L1 +// validateBatch validates a batch against the L2 chain. +// +// If a BatchVerifier is configured, it fetches the CommitBatch L1 tx and performs +// full verification (PostStateRoot, WithdrawalRoot, PrevStateRoot, BlockContexts). +// Otherwise it falls back to the lightweight CommittedStateRoots contract check. +// +// tagType is used to select the per-tag search tracker, preventing safe queries from +// advancing the tracker beyond finalized batch L1 blocks (and vice versa). +func (s *BlockTagService) validateBatch(tagType BlockTagType, batchIndex uint64, batchLastBlockNum uint64) error { + if s.batchVerifier == nil { + return s.validateBatchStateRoot(batchIndex, batchLastBlockNum) + } + + txHash, err := s.fetchCommitBatchTxHash(tagType, batchIndex) + if err != nil { + return fmt.Errorf("get CommitBatch tx hash for batch %d: %w", batchIndex, err) + } + + roots, err := s.batchVerifier.FetchBatchRoots(s.ctx, txHash, batchIndex) + if err != nil { + return fmt.Errorf("fetch batch roots for batch %d: %w", batchIndex, err) + } + + return s.batchVerifier.VerifyBatch(s.ctx, s.l2Client, roots, nil) +} + +// fetchCommitBatchTxHash retrieves the L1 transaction hash of the CommitBatch event +// for the given batch index by filtering L1 logs. +// CommitBatch(uint256 indexed batchIndex, bytes32 batchHash) — batchIndex is topic[1]. +// +// tagType selects the per-tag search tracker. Safe and finalized batches correspond to +// different L1 block heights, so they must use independent trackers to avoid one tag's +// progress overwriting the other's search start. +func (s *BlockTagService) fetchCommitBatchTxHash(tagType BlockTagType, batchIndex uint64) (common.Hash, error) { + tracker := s.safeSearchTracker + if tagType == TagTypeFinalized { + tracker = s.finalizedSearchTracker + } + + batchIndexHash := common.BigToHash(new(big.Int).SetUint64(batchIndex)) + logs, err := s.l1Client.FilterLogs(s.ctx, ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(tracker.FromBlock()), + Addresses: []common.Address{s.rollupAddress}, + Topics: [][]common.Hash{ + {derivation.RollupEventTopicHash}, + {batchIndexHash}, + }, + }) + if err != nil { + return common.Hash{}, fmt.Errorf("filter CommitBatch logs for batch %d: %w", batchIndex, err) + } + if len(logs) == 0 { + return common.Hash{}, fmt.Errorf("no CommitBatch event found for batch %d", batchIndex) + } + tracker.Advance(logs[0].BlockNumber) + return logs[0].TxHash, nil +} + +// validateBatchStateRoot is the lightweight fallback: checks only PostStateRoot via +// the CommittedStateRoots contract mapping (no tx hash or blob needed). func (s *BlockTagService) validateBatchStateRoot(batchIndex uint64, batchLastBlockNum uint64) error { - // Get L2 block header l2Header, err := s.l2Client.HeaderByNumber(s.ctx, big.NewInt(int64(batchLastBlockNum))) if err != nil { return fmt.Errorf("failed to get L2 block header for block %d: %w", batchLastBlockNum, err) } - // Get state root from L1 committed batch stateRoot, err := s.rollup.CommittedStateRoots(nil, big.NewInt(int64(batchIndex))) if err != nil { return fmt.Errorf("failed to get state root from L1: %w", err) } - // Compare state roots l1StateRoot := common.BytesToHash(stateRoot[:]) if l1StateRoot != l2Header.Root { return fmt.Errorf("state root mismatch for batch %d: L1=%s, L2=%s", batchIndex, l1StateRoot.Hex(), l2Header.Root.Hex()) @@ -460,3 +582,42 @@ func (s *BlockTagService) notifyGeth() error { s.lastNotifiedFinalizedHash = finalizedBlockHash return nil } + +// l1SearchTracker manages the L1 block number used as FilterLogs FromBlock when +// scanning for CommitBatch events. It hides the fixed-vs-auto logic so that callers +// only need to call FromBlock() / Advance(). +// +// - Fixed mode (l1StartBlock > 0): FromBlock always returns the configured value; +// Advance is a no-op. Operator has full control over the search window. +// - Auto mode (l1StartBlock == 0): FromBlock returns the internally tracked value, +// which is refined at startup from the last finalized batch and progressively +// advanced after each successful log query. +// +// NOT concurrency-safe. BlockTagService runs a single polling goroutine (loop), so +// no synchronization is needed. Do not share a tracker across multiple goroutines. +type l1SearchTracker struct { + fixed uint64 // non-zero → fixed mode + current uint64 // used in auto mode +} + +func newL1SearchTracker(l1StartBlock uint64) *l1SearchTracker { + return &l1SearchTracker{fixed: l1StartBlock} +} + +// IsAuto reports whether progressive (auto) tracking is active. +func (t *l1SearchTracker) IsAuto() bool { return t.fixed == 0 } + +// FromBlock returns the L1 block number to use as FilterLogs FromBlock. +func (t *l1SearchTracker) FromBlock() uint64 { + if t.fixed > 0 { + return t.fixed + } + return t.current +} + +// Advance moves the auto-tracked block forward. No-op in fixed mode. +func (t *l1SearchTracker) Advance(blockNumber uint64) { + if t.fixed == 0 { + t.current = blockNumber + } +} diff --git a/node/cmd/node/main.go b/node/cmd/node/main.go index 2a71f2a28..6cce289c5 100644 --- a/node/cmd/node/main.go +++ b/node/cmd/node/main.go @@ -8,23 +8,19 @@ import ( "path/filepath" "syscall" - "github.com/morph-l2/go-ethereum/ethclient" tmnode "github.com/tendermint/tendermint/node" "github.com/tendermint/tendermint/privval" "github.com/urfave/cli" - "morph-l2/bindings/bindings" - "morph-l2/node/blocktag" + "morph-l2/node/batchprocessor" "morph-l2/node/cmd/keyconverter" node "morph-l2/node/core" - "morph-l2/node/db" "morph-l2/node/derivation" "morph-l2/node/flags" "morph-l2/node/sequencer" "morph-l2/node/sequencer/mock" "morph-l2/node/sync" "morph-l2/node/types" - "morph-l2/node/validator" ) var keyConverterCmd = cli.Command{ @@ -52,19 +48,22 @@ func main() { } func L2NodeMain(ctx *cli.Context) error { + // rootCtx is canceled on OS signals, which propagates to startup retries + // (e.g. NewBatchVerifier) so a down L2 endpoint never blocks startup forever. + rootCtx, rootCancel := signal.NotifyContext(context.Background(), + os.Interrupt, os.Kill, syscall.SIGTERM, syscall.SIGQUIT) + defer rootCancel() + var ( - err error - executor *node.Executor - syncer *sync.Syncer - ms *mock.Sequencer - tmNode *tmnode.Node - dvNode *derivation.Derivation - blockTagSvc *blocktag.BlockTagService + err error + executor *node.Executor + ms *mock.Sequencer + tmNode *tmnode.Node + bp *batchprocessor.BatchProcessor nodeConfig = node.DefaultConfig() ) isMockSequencer := ctx.GlobalBool(flags.MockEnabled.Name) - isValidator := ctx.GlobalBool(flags.ValidatorEnable.Name) if err = nodeConfig.SetCliContext(ctx); err != nil { return err @@ -74,99 +73,64 @@ func L2NodeMain(ctx *cli.Context) error { return err } - if isValidator { - // configure store - dbConfig := db.DefaultConfig() - dbConfig.SetCliContext(ctx) - store, err := db.NewStore(dbConfig, home) + // launch tendermint node + tmCfg, err := sequencer.LoadTmConfig(ctx, home) + if err != nil { + return err + } + tmVal := privval.LoadOrGenFilePV(tmCfg.PrivValidatorKeyFile(), tmCfg.PrivValidatorStateFile()) + pubKey, err := tmVal.GetPubKey() + if err != nil { + return fmt.Errorf("failed to get validator public key: %w", err) + } + newSyncerFunc := func() (*sync.Syncer, error) { return node.NewSyncer(ctx, home, nodeConfig) } + executor, err = node.NewExecutor(newSyncerFunc, nodeConfig, pubKey) + if err != nil { + return err + } + if isMockSequencer { + ms, err = mock.NewSequencer(executor) if err != nil { return err } - derivationCfg := derivation.DefaultConfig() - if err := derivationCfg.SetCliContext(ctx); err != nil { - return fmt.Errorf("derivation set cli context error: %v", err) - } - syncConfig := sync.DefaultConfig() - if err = syncConfig.SetCliContext(ctx); err != nil { - return err - } - syncer, err = sync.NewSyncer(context.Background(), store, syncConfig, nodeConfig.Logger) - 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) - } - rollup, err := bindings.NewRollup(derivationCfg.RollupContractAddress, l1Client) - 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) - if err != nil { - return fmt.Errorf("new derivation client error: %v", err) - } - dvNode.Start() - nodeConfig.Logger.Info("derivation node starting") + go ms.Start() } else { - // launch tendermint node - tmCfg, err := sequencer.LoadTmConfig(ctx, home) - if err != nil { - return err + if tmNode, err = sequencer.SetupNode(tmCfg, tmVal, executor, nodeConfig.Logger); err != nil { + return fmt.Errorf("failed to setup consensus node, error: %v", err) } - tmVal := privval.LoadOrGenFilePV(tmCfg.PrivValidatorKeyFile(), tmCfg.PrivValidatorStateFile()) - pubKey, _ := tmVal.GetPubKey() - newSyncerFunc := func() (*sync.Syncer, error) { return node.NewSyncer(ctx, home, nodeConfig) } - executor, err = node.NewExecutor(newSyncerFunc, nodeConfig, pubKey) - if err != nil { - return err - } - if isMockSequencer { - ms, err = mock.NewSequencer(executor) - if err != nil { - return err - } - go ms.Start() - } else { - if tmNode, err = sequencer.SetupNode(tmCfg, tmVal, executor, nodeConfig.Logger); err != nil { - return fmt.Errorf("failed to setup consensus node, error: %v", err) - } - if err = tmNode.Start(); err != nil { - return fmt.Errorf("failed to start consensus node, error: %v", err) - } + if err = tmNode.Start(); err != nil { + return fmt.Errorf("failed to start consensus node, error: %v", err) } + } - // Start BlockTagService for sequencer mode - blockTagConfig := blocktag.DefaultConfig() - if err := blockTagConfig.SetCliContext(ctx); err != nil { - return fmt.Errorf("blocktag config set cli context error: %w", err) - } - blockTagSvc, err = blocktag.NewBlockTagService(context.Background(), executor.L2Client(), blockTagConfig, nodeConfig.Logger) - if err != nil { - return fmt.Errorf("failed to create BlockTagService: %w", err) - } - if err := blockTagSvc.Start(); err != nil { - return fmt.Errorf("failed to start BlockTagService: %w", err) - } + // Start BatchProcessor (replaces BlockTagService) + bpCfg := batchprocessor.DefaultConfig() + if err := bpCfg.SetCliContext(ctx); err != nil { + return fmt.Errorf("batchprocessor config set cli context error: %w", err) + } + + bvCfg := &derivation.Config{ + L1: &types.L1Config{Addr: bpCfg.L1Addr}, + L2: &types.L2Config{EthAddr: nodeConfig.L2.EthAddr}, + RollupContractAddress: bpCfg.RollupAddress, + BeaconRpc: ctx.GlobalString(flags.L1BeaconAddr.Name), + BaseHeight: ctx.GlobalUint64(flags.DerivationBaseHeight.Name), + } + bv, bvErr := derivation.NewBatchVerifier(rootCtx, bvCfg, nil, nodeConfig.Logger) + if bvErr != nil { + nodeConfig.Logger.Error("failed to create BatchVerifier, batch verification disabled", "error", bvErr) + bv = nil + } + + bp, err = batchprocessor.NewBatchProcessor(rootCtx, executor.L2Client(), bpCfg, bv, nodeConfig.Logger) + if err != nil { + return fmt.Errorf("failed to create BatchProcessor: %w", err) + } + if err := bp.Start(); err != nil { + return fmt.Errorf("failed to start BatchProcessor: %w", err) } - interruptChannel := make(chan os.Signal, 1) - signal.Notify(interruptChannel, []os.Signal{ - os.Interrupt, - os.Kill, - syscall.SIGTERM, - syscall.SIGQUIT, - }...) - <-interruptChannel + <-rootCtx.Done() if ms != nil { ms.Stop() @@ -177,14 +141,8 @@ func L2NodeMain(ctx *cli.Context) error { return stopErr } } - if syncer != nil { - syncer.Stop() - } - if dvNode != nil { - dvNode.Stop() - } - if blockTagSvc != nil { - blockTagSvc.Stop() + if bp != nil { + bp.Stop() } return nil diff --git a/node/core/executor.go b/node/core/executor.go index 52d30125d..7393b44ce 100644 --- a/node/core/executor.go +++ b/node/core/executor.go @@ -106,7 +106,7 @@ func NewExecutor(newSyncFunc NewSyncerFunc, config *Config, tmPubKey crypto.PubK } // Fetch geth config at startup (with retry to wait for geth) - gethCfg, err := types.FetchGethConfigWithRetry(config.L2.EthAddr, logger) + gethCfg, err := types.FetchGethConfigWithRetry(context.Background(), config.L2.EthAddr, logger) if err != nil { return nil, fmt.Errorf("failed to fetch geth config: %w", err) } diff --git a/node/derivation/README.md b/node/derivation/README.md new file mode 100644 index 000000000..abf7e1fb3 --- /dev/null +++ b/node/derivation/README.md @@ -0,0 +1,250 @@ +# node/derivation — Batch Verification + +## Overview + +This package previously contained a standalone `Derivation` service that ran its own +block-production loop: it watched L1 for `CommitBatch` events and re-derived L2 blocks +from blob/calldata, writing them via `NewSafeL2Block`. + +The derivation node has been removed. All nodes now run Tendermint-based block production +and receive L2 blocks through P2P sync. The verification logic that lived inside the old +service has been extracted into `BatchVerifier` — a **stateless, schedule-free component** +that other parts of the node can call on demand. + +`BatchProcessor` (`node/batchprocessor`) is the primary consumer: it sequentially walks +every committed batch, calls `BatchVerifier` for full verification, and updates +safe/finalized block tags on L2. + +--- + +## Architecture + +``` +BatchProcessor (scheduler, node/batchprocessor) +│ polls L1 every N seconds +│ maintains two sequential cursors: lastSafeBatchIndex, lastFinalizedBatchIndex +│ walks batches from cursor+1 up to on-chain head +│ +└─► BatchVerifier (stateless, no goroutines, node/derivation) + FetchBatchRoots(txHash) — parse L1 calldata → BatchRoots + FetchBatchData(txHash) — fetch blobs → BatchInfo (optional) + VerifyBatch(roots, data) — 5-step verification against local L2 +``` + +`BatchVerifier` holds long-lived RPC connections (L1, L2, optional L1 Beacon) but owns +no goroutines and performs no scheduling. The caller decides when to invoke it. + +--- + +## BatchVerifier + +### Construction + +```go +bv, err := derivation.NewBatchVerifier(ctx, cfg, validator, logger) +defer bv.Close() +``` + +`cfg` is `*derivation.Config`. Only a subset of fields is required when constructing +`BatchVerifier` directly (e.g. from `main.go` without a full derivation setup): + +| Field | Required | Notes | +|---|---|---| +| `L1.Addr` | yes | L1 RPC endpoint | +| `L2.EthAddr` | yes | L2 eth RPC endpoint | +| `RollupContractAddress` | yes | Rollup contract on L1 | +| `BeaconRpc` | no | Enables blob fetching (Step 5); skipped if empty | +| `BaseHeight` | no | Snapshot base height; blocks <= this value are skipped | + +`NewBatchVerifier` fetches geth upgrade config at startup (retries until geth is ready). + +### Key types + +**`BatchRoots`** — roots and block metadata parsed from L1 calldata only (no blobs): + +```go +type BatchRoots struct { + BatchIndex uint64 + FirstBlockNum uint64 // 0 for blob-based (v0) batches + LastBlockNum uint64 + PrevStateRoot common.Hash // zero for v0 batches + PostStateRoot common.Hash + WithdrawalRoot common.Hash + NumL1Messages uint16 + BlockContexts []BatchBlockContext // nil for v0 batches +} +``` + +**`BatchBlockContext`** — per-block fields decoded from calldata (v1+ batches): + +```go +type BatchBlockContext struct { + Number uint64 + Timestamp uint64 + GasLimit uint64 + BaseFee *big.Int + NumTxs uint16 // total txs (L2 user + L1 message) + NumL1Msgs uint16 // L1 message txs (type 0x7E) +} +``` + +**`BatchInfo`** — full blob-decoded batch data, returned by `FetchBatchData`. Contains +per-block user transaction lists used for Step 5 content verification. + +### Methods + +#### `FetchBatchRoots(ctx, txHash, batchIndex) (*BatchRoots, error)` + +Fetches the L1 transaction and parses its calldata to extract state roots and block +metadata. No blob fetching is performed. + +Calldata is unpacked across three ABI versions (legacy, beforeMoveBlockCtx, current). +For v0/legacy batches where `LastBlockNumber` is absent from calldata, it falls back to +querying `BatchDataStore` on-chain. + +#### `FetchBatchData(ctx, txHash) (*BatchInfo, error)` + +Fetches blob sidecars from the L1 Beacon API and decodes the full batch (user +transactions per block). Returns an error if `BeaconRpc` is not configured. + +This is optional for `VerifyBatch` — pass `nil` to skip Step 5 (transaction content +check). + +#### `VerifyBatch(ctx, l2Client, roots, batchData) error` + +Runs up to five verification steps against the local L2 node: + +| Step | Check | Condition | +|---|---|---| +| 1 | `PostStateRoot`: L2 block state root == L1-committed root | always | +| 2 | `WithdrawalRoot`: `L2ToL1MessagePasser.MessageRoot` == L1-committed root | always | +| 3 | `PrevStateRoot`: block before batch has correct state root | v1+, FirstBlockNum > baseHeight | +| 4 | Block contexts: Number, Timestamp, GasLimit, BaseFee, NumTxs, NumL1Msgs | v1+ (BlockContexts in calldata) | +| 5 | L2 user tx content: blob-decoded txs match actual L2 block txs byte-for-byte | batchData != nil | + +Steps 1 and 2 trigger `validator.ChallengeState` on mismatch when a Validator is +configured and challenge is enabled. Steps 3-5 return errors only. + +Verification is silently skipped during an upgrade transition window (ZK->MPT geth +switch) to avoid false positives while both geth versions coexist. + +Blocks at or below `baseHeight` are skipped in all steps — this is required for nodes +that started from a snapshot rather than genesis. + +#### `Close()` + +Closes the L1 and L2 RPC connections. Call once when the verifier is no longer needed. + +--- + +## BatchProcessor (node/batchprocessor) + +`BatchProcessor` replaces the previous `BlockTagService` as the main scheduler for +tracking safe/finalized block tags. Key differences from the old approach: + +- **Sequential processing**: walks every batch in order from cursor+1 to the on-chain + head. No batch is skipped. +- **Single polling loop**: one goroutine, one ticker. No separate safe/finalized + trackers or binary search. +- **Simple cursor model**: two integer cursors (`lastSafeBatchIndex`, + `lastFinalizedBatchIndex`) track progress. `finalized <= safe` is always guaranteed. + +### Flow per tick + +``` +processTick() +│ +├─ getSafeL1Head() = latest L1 block - safeConfirmations +│ └─ getLastCommittedBatchAtBlock(safeL1Head) → safe on-chain head +│ └─ advanceSafe: for idx in (cursor+1 .. safeHead): +│ processOneBatch(idx) → verify + get L2 block hash +│ update lastSafeBatchIndex, safeL2BlockHash +│ +├─ getLastCommittedBatchAtBlock(finalized) → finalized on-chain head +│ └─ advanceFinalized: for idx in (cursor+1 .. min(finalizedHead, safeCursor)): +│ processOneBatch(idx) → verify + get L2 block hash +│ update lastFinalizedBatchIndex, finalizedL2BlockHash +│ +└─ notifyGeth() → SetBlockTags(safe, finalized) RPC to L2 geth +``` + +### processOneBatch(batchIndex) + +1. `rollup.BatchDataStore(batchIndex)` — get the batch's `lastL2Block` +2. If `lastL2Block > l2Head` — node not synced yet, stop advancing +3. If `BatchVerifier` is available: + - `fetchCommitBatchTxHash(batchIndex)` — find L1 tx via `FilterLogs` + - `FetchBatchRoots` + `FetchBatchData` + `VerifyBatch` + - On failure: log error (TODO: define failure behavior) +4. `HeaderByNumber(lastL2Block)` — get the L2 block hash +5. Return `(lastL2Block, blockHash)` + +### Cursor initialization + +At startup, both cursors are initialized to `LastFinalizedBatchIndex` from the L1 +rollup contract, skipping already-finalized history. If the query fails, cursors +start from 0. + +### Configuration (node/batchprocessor/config.go) + +| Flag | Config field | Default | Notes | +|---|---|---|---| +| `--l1.node.addr` | `L1Addr` | — | L1 RPC URL | +| `--rollup.contract.address` | `RollupAddress` | — | Rollup contract on L1 | +| `--blocktag.safeConfirmations` | `SafeConfirmations` | `10` | L1 blocks before a batch is considered safe | +| *(inherited)* | `PollInterval` | `12s` | Polling interval | + +--- + +## main.go integration + +```go +// Build BatchVerifier (optional, non-critical) +bv, bvErr := derivation.NewBatchVerifier(rootCtx, bvCfg, nil, logger) +if bvErr != nil { + logger.Error("BatchVerifier creation failed, verification disabled", "error", bvErr) + bv = nil +} + +// Create and start BatchProcessor +bp, err := batchprocessor.NewBatchProcessor(rootCtx, l2Client, bpCfg, bv, logger) +bp.Start() + +// ... +<-rootCtx.Done() +bp.Stop() +``` + +`rootCtx` is created via `signal.NotifyContext` so that OS signals (SIGTERM, SIGINT) +propagate to startup retries (e.g. `FetchGethConfigWithRetry` inside `NewBatchVerifier`) +and the main blocking loop. + +--- + +## Configuration (derivation) + +Relevant flags (all under `node/flags`): + +| Flag | Config field | Default | Notes | +|---|---|---|---| +| `--l1.node.addr` | `L1.Addr` | — | L1 RPC URL | +| `--l1.beacon.addr` | `BeaconRpc` | — | L1 Beacon URL; blob fetching disabled if empty | +| `--rollup.contract.address` | `RollupContractAddress` | — | Rollup contract on L1 | +| `--derivation.baseHeight` | `BaseHeight` | `0` | Snapshot base height; 0 = started from genesis | + +--- + +## File map + +| File | Package | Purpose | +|---|---|---| +| `derivation/batch_verifier.go` | derivation | `BatchVerifier`, `BatchRoots`, `BatchBlockContext`, `VerifyBatch` and all sub-checks | +| `derivation/batch_info.go` | derivation | `BatchInfo`, blob-decoded batch representation | +| `derivation/batch_decode.go` | derivation | `unpackCalldataWithABIs`, ABI version selection | +| `derivation/beacon.go` | derivation | `L1BeaconClient`, blob sidecar fetching | +| `derivation/blob_type.go` | derivation | Blob encoding/decoding helpers | +| `derivation/blobs.go` | derivation | KZG commitment verification helpers | +| `derivation/config.go` | derivation | `Config`, `DefaultConfig`, `SetCliContext` | +| `derivation/base_client.go` | derivation | `BasicHTTPClient` for Beacon API | +| `batchprocessor/processor.go` | batchprocessor | `BatchProcessor` — main scheduling loop, tag updates | +| `batchprocessor/config.go` | batchprocessor | `Config`, `DefaultConfig`, `SetCliContext` | diff --git a/node/derivation/batch_info.go b/node/derivation/batch_info.go index d795092b8..c22eb0942 100644 --- a/node/derivation/batch_info.go +++ b/node/derivation/batch_info.go @@ -51,9 +51,6 @@ type BatchInfo struct { txNum uint64 version uint64 blockContexts []*BlockContext - l1BlockNumber uint64 - txHash common.Hash - nonce uint64 lastBlockNumber uint64 firstBlockNumber uint64 diff --git a/node/derivation/batch_verifier.go b/node/derivation/batch_verifier.go new file mode 100644 index 000000000..016c91e05 --- /dev/null +++ b/node/derivation/batch_verifier.go @@ -0,0 +1,799 @@ +package derivation + +import ( + "bytes" + "context" + "fmt" + "math/big" + + "github.com/morph-l2/go-ethereum/accounts/abi" + "github.com/morph-l2/go-ethereum/accounts/abi/bind" + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/common/hexutil" + eth "github.com/morph-l2/go-ethereum/core/types" + "github.com/morph-l2/go-ethereum/crypto" + "github.com/morph-l2/go-ethereum/crypto/kzg4844" + geth "github.com/morph-l2/go-ethereum/eth" + "github.com/morph-l2/go-ethereum/ethclient" + tmlog "github.com/tendermint/tendermint/libs/log" + + "morph-l2/bindings/bindings" + "morph-l2/bindings/predeploys" + "morph-l2/node/types" + "morph-l2/node/validator" +) + +var ( + // RollupEventTopic is the CommitBatch event signature string. + RollupEventTopic = "CommitBatch(uint256,bytes32)" + // RollupEventTopicHash is the keccak256 hash of RollupEventTopic. + // Used when filtering L1 logs for batch submissions. + RollupEventTopicHash = crypto.Keccak256Hash([]byte(RollupEventTopic)) +) + +// BatchVerifyL2Client is the minimal L2 read interface required for batch verification. +// *types.RetryableClient satisfies this interface. +// +// HeaderByNumber is used for lightweight checks (root verification, PrevStateRoot). +// BlockByNumber is used for block-context verification where transaction data is needed. +type BatchVerifyL2Client interface { + HeaderByNumber(ctx context.Context, number *big.Int) (*eth.Header, error) + // BlockByNumber returns the full block including all transactions. + // Used in verifyBlockContextHeaders to check NumTxs and NumL1Msgs. + BlockByNumber(ctx context.Context, number *big.Int) (*eth.Block, error) +} + +// BatchBlockContext holds per-block fields decoded from the BlockContexts calldata field. +// Only present for v1+ batches where block contexts are encoded directly in calldata. +// Blob-based (v0/legacy) batches cannot provide this without blob decoding. +// +// 60-byte wire layout (per block): +// +// Number(8) | Timestamp(8) | BaseFee(32) | GasLimit(8) | NumTxs(2) | NumL1Msgs(2) +type BatchBlockContext struct { + Number uint64 + Timestamp uint64 + GasLimit uint64 + BaseFee *big.Int + NumTxs uint16 // total transactions in the block (L2 + L1 messages) + NumL1Msgs uint16 // L1 message transactions in the block +} + +// BatchRoots contains the key roots and block metadata parsed from a CommitBatch L1 transaction. +// All fields are populated via calldata parsing only — no blob decoding required. +// +// BlockContexts is non-nil only for v1+ batches where block contexts are encoded in calldata. +// For blob-based (v0/legacy) batches, BlockContexts is nil and only root verification is possible. +type BatchRoots struct { + BatchIndex uint64 + FirstBlockNum uint64 // first L2 block number in the batch; 0 if unavailable (blob batches) + LastBlockNum uint64 + PrevStateRoot common.Hash // stateRoot of the block just before this batch; zero if unavailable + PostStateRoot common.Hash + WithdrawalRoot common.Hash + NumL1Messages uint16 // total L1 messages in the batch + BlockContexts []BatchBlockContext // per-block metadata; nil for blob-based batches +} + +// BatchVerifier encapsulates the stateless logic for fetching and verifying L1 batch data. +// It exposes callable methods with no internal goroutines or scheduling. +// Scheduling is owned by the caller (e.g. BlockTagService). +type BatchVerifier struct { + l1Client *ethclient.Client + l2EthClient *ethclient.Client // owns the connection used by L2ToL1MessagePasser; closed via Close() + l1BeaconClient *L1BeaconClient // nil if BeaconRpc not configured + rollup *bindings.Rollup + rollupABI *abi.ABI + legacyRollupABI *abi.ABI + beforeMoveBlockCtxABI *abi.ABI + RollupContractAddress common.Address + + // L2 contract for withdrawal root verification + L2ToL1MessagePasser *bindings.L2ToL1MessagePasser + + // Optional: triggers challenge on state mismatch when enabled + validator *validator.Validator + + // Upgrade transition config (fetched from geth at startup) + baseHeight uint64 + switchTime uint64 + useZktrie bool + + logger tmlog.Logger +} + +// NewBatchVerifier creates a BatchVerifier using a subset of derivation Config. +// It connects to L1 and L2 but does not start any background goroutines. +// Call Close() when the BatchVerifier is no longer needed to release connections. +func NewBatchVerifier(ctx context.Context, cfg *Config, vt *validator.Validator, logger tmlog.Logger) (*BatchVerifier, error) { + l1Client, err := ethclient.Dial(cfg.L1.Addr) + if err != nil { + return nil, fmt.Errorf("dial l1 node error: %w", err) + } + + l2EthClient, err := ethclient.Dial(cfg.L2.EthAddr) + if err != nil { + l1Client.Close() + return nil, fmt.Errorf("dial l2 eth node error: %w", err) + } + + rollup, err := bindings.NewRollup(cfg.RollupContractAddress, l1Client) + if err != nil { + l1Client.Close() + l2EthClient.Close() + return nil, fmt.Errorf("create rollup binding error: %w", err) + } + + msgPasser, err := bindings.NewL2ToL1MessagePasser(predeploys.L2ToL1MessagePasserAddr, l2EthClient) + if err != nil { + l1Client.Close() + l2EthClient.Close() + return nil, fmt.Errorf("create L2ToL1MessagePasser binding error: %w", err) + } + + rollupAbi, err := bindings.RollupMetaData.GetAbi() + if err != nil { + l1Client.Close() + l2EthClient.Close() + return nil, fmt.Errorf("get rollup ABI: %w", err) + } + legacyRollupAbi, err := types.LegacyRollupMetaData.GetAbi() + if err != nil { + l1Client.Close() + l2EthClient.Close() + return nil, fmt.Errorf("get legacy rollup ABI: %w", err) + } + beforeMoveBlockCtxAbi, err := types.BeforeMoveBlockCtxABI.GetAbi() + if err != nil { + l1Client.Close() + l2EthClient.Close() + return nil, fmt.Errorf("get beforeMoveBlockCtx ABI: %w", err) + } + + // Fetch upgrade transition config from geth (retries until geth is ready or ctx is canceled) + gethCfg, err := types.FetchGethConfigWithRetry(ctx, cfg.L2.EthAddr, logger) + if err != nil { + l1Client.Close() + l2EthClient.Close() + return nil, fmt.Errorf("failed to fetch geth config: %w", err) + } + logger.Info("BatchVerifier: geth config fetched", + "switchTime", gethCfg.SwitchTime, + "useZktrie", gethCfg.UseZktrie, + ) + + // L1 Beacon client for blob fetching (optional: only required for FetchBatchData) + var l1BeaconClient *L1BeaconClient + if cfg.BeaconRpc != "" { + baseHttp := NewBasicHTTPClient(cfg.BeaconRpc, logger) + l1BeaconClient = NewL1BeaconClient(baseHttp) + logger.Info("BatchVerifier: L1 beacon client configured", "beaconRpc", cfg.BeaconRpc) + } else { + logger.Info("BatchVerifier: BeaconRpc not set, blob fetching disabled") + } + + return &BatchVerifier{ + l1Client: l1Client, + l2EthClient: l2EthClient, + l1BeaconClient: l1BeaconClient, + rollup: rollup, + rollupABI: rollupAbi, + legacyRollupABI: legacyRollupAbi, + beforeMoveBlockCtxABI: beforeMoveBlockCtxAbi, + RollupContractAddress: cfg.RollupContractAddress, + L2ToL1MessagePasser: msgPasser, + validator: vt, + baseHeight: cfg.BaseHeight, + switchTime: gethCfg.SwitchTime, + useZktrie: gethCfg.UseZktrie, + logger: logger.With("module", "batch_verifier"), + }, nil +} + +// Close releases the L1 and L2 RPC connections held by the BatchVerifier. +func (bv *BatchVerifier) Close() { + bv.l1Client.Close() + bv.l2EthClient.Close() +} + +// FetchBatchRoots fetches state roots and block metadata from a CommitBatch L1 transaction. +// Only calldata is parsed — no blob fetching required. +// +// Populated fields depend on batch version: +// - v0/legacy: only PostStateRoot, WithdrawalRoot, LastBlockNum (from BatchDataStore fallback) +// - v1+: all fields including PrevStateRoot, FirstBlockNum, NumL1Messages, BlockContexts +// +// For v0/legacy batches where LastBlockNumber is absent from calldata, +// it falls back to querying BatchDataStore on-chain. +func (bv *BatchVerifier) FetchBatchRoots(ctx context.Context, txHash common.Hash, batchIndex uint64) (*BatchRoots, error) { + tx, pending, err := bv.l1Client.TransactionByHash(ctx, txHash) + if err != nil { + return nil, fmt.Errorf("get transaction %s: %w", txHash.Hex(), err) + } + if pending { + return nil, fmt.Errorf("transaction %s is still pending", txHash.Hex()) + } + + batch, err := unpackCalldataWithABIs(bv.rollupABI, bv.legacyRollupABI, bv.beforeMoveBlockCtxABI, tx.Data()) + if err != nil { + return nil, fmt.Errorf("unpack calldata for tx %s: %w", txHash.Hex(), err) + } + + // Derive batchIndex from parentBatchHeader embedded in calldata + parentBatchHeader := types.BatchHeaderBytes(batch.ParentBatchHeader) + parentBatchIndex, err := parentBatchHeader.BatchIndex() + if err != nil { + return nil, fmt.Errorf("decode parent batch index: %w", err) + } + + roots := &BatchRoots{ + BatchIndex: parentBatchIndex + 1, + LastBlockNum: batch.LastBlockNumber, + PrevStateRoot: batch.PrevStateRoot, + PostStateRoot: batch.PostStateRoot, + WithdrawalRoot: batch.WithdrawRoot, + NumL1Messages: batch.NumL1Messages, + } + + // v0/legacy batches do not encode LastBlockNumber in calldata (it's in the blob). + // Fall back to the on-chain BatchDataStore. + if roots.LastBlockNum == 0 { + batchData, err := bv.rollup.BatchDataStore(&bind.CallOpts{Context: ctx}, new(big.Int).SetUint64(batchIndex)) + if err != nil { + return nil, fmt.Errorf("query BatchDataStore for batchIndex %d: %w", batchIndex, err) + } + roots.LastBlockNum = batchData.BlockNumber.Uint64() + } + + // Parse per-block contexts when encoded in calldata (v1+ batches). + // Format: [numBlocks: 2 bytes][block0: 60 bytes][block1: 60 bytes]... + // Each 60-byte context: Number(8)+Timestamp(8)+BaseFee(32)+GasLimit(8)+numTxs(2)+numL1Msgs(2) + if len(batch.BlockContexts) >= 2 { + blockContexts, firstBlockNum, err := parseBlockContexts(batch.BlockContexts) + if err != nil { + // Non-fatal: log and continue without block context verification + bv.logger.Info("FetchBatchRoots: failed to parse BlockContexts, skipping block metadata", + "batchIndex", roots.BatchIndex, "error", err) + } else { + roots.BlockContexts = blockContexts + roots.FirstBlockNum = firstBlockNum + } + } + + return roots, nil +} + +// parseBlockContexts decodes the BlockContexts calldata field into individual BatchBlockContext entries. +// Returns the slice of contexts and the first block's Number. +// Format: [numBlocks: 2 bytes] followed by numBlocks × 60-byte context records. +func parseBlockContexts(data []byte) ([]BatchBlockContext, uint64, error) { + if len(data) < 2 { + return nil, 0, fmt.Errorf("BlockContexts too short: %d bytes", len(data)) + } + + numBlocks := int(data[0])<<8 | int(data[1]) + if numBlocks == 0 { + return nil, 0, fmt.Errorf("BlockContexts: numBlocks is zero") + } + expectedLen := 2 + numBlocks*60 + if len(data) < expectedLen { + return nil, 0, fmt.Errorf("BlockContexts: need %d bytes for %d blocks, got %d", expectedLen, numBlocks, len(data)) + } + + contexts := make([]BatchBlockContext, numBlocks) + for i := 0; i < numBlocks; i++ { + off := 2 + i*60 + raw := data[off : off+60] + + var wb types.WrappedBlock + txsNum, l1MsgNum, err := wb.DecodeBlockContext(raw) + if err != nil { + return nil, 0, fmt.Errorf("decode block context %d: %w", i, err) + } + contexts[i] = BatchBlockContext{ + Number: wb.Number, + Timestamp: wb.Timestamp, + GasLimit: wb.GasLimit, + BaseFee: wb.BaseFee, + NumTxs: txsNum, + NumL1Msgs: l1MsgNum, + } + } + + return contexts, contexts[0].Number, nil +} + +// FetchBatchData fetches a complete batch from L1, including blob decoding. +// It returns a *BatchInfo containing per-block metadata and the decoded L2 user transactions +// for each block (L1 messages are NOT included — they are injected separately by the sequencer). +// +// This extends FetchBatchRoots: where FetchBatchRoots only parses calldata, +// FetchBatchData also fetches blobs from the L1 beacon chain and decodes them via BatchInfo.ParseBatch. +// +// Requires l1BeaconClient to be configured (cfg.BeaconRpc must be set). +// For batches without blobs (no blobHashes on the L1 tx), ParseBatch still works +// with an empty Sidecar; blob-free batches encode txs directly in calldata. +func (bv *BatchVerifier) FetchBatchData(ctx context.Context, l1TxHash common.Hash) (*BatchInfo, error) { + if bv.l1BeaconClient == nil { + return nil, fmt.Errorf("FetchBatchData: l1BeaconClient not configured (set BeaconRpc in config)") + } + + tx, pending, err := bv.l1Client.TransactionByHash(ctx, l1TxHash) + if err != nil { + return nil, fmt.Errorf("get L1 transaction %s: %w", l1TxHash.Hex(), err) + } + if pending { + return nil, fmt.Errorf("L1 transaction %s is still pending", l1TxHash.Hex()) + } + + batch, err := unpackCalldataWithABIs(bv.rollupABI, bv.legacyRollupABI, bv.beforeMoveBlockCtxABI, tx.Data()) + if err != nil { + return nil, fmt.Errorf("unpack calldata for tx %s: %w", l1TxHash.Hex(), err) + } + + // If the L1 tx carries blob hashes, fetch and attach the blob sidecar. + blobHashes := tx.BlobHashes() + if len(blobHashes) > 0 { + bv.logger.Info("FetchBatchData: tx has blobs, fetching from beacon", + "txHash", l1TxHash.Hex(), "blobCount", len(blobHashes)) + + // Get the L1 block to build indexed blob hashes (position of each blob in the block's sidecar). + receipt, err := bv.l1Client.TransactionReceipt(ctx, l1TxHash) + if err != nil { + return nil, fmt.Errorf("get L1 tx receipt %s: %w", l1TxHash.Hex(), err) + } + l1BlockNum := receipt.BlockNumber + + l1Block, err := bv.l1Client.BlockByNumber(ctx, l1BlockNum) + if err != nil { + return nil, fmt.Errorf("get L1 block %d: %w", l1BlockNum.Uint64(), err) + } + indexedBlobHashes := dataAndHashesFromTxs(l1Block.Transactions(), tx) + + // Beacon chain lookup uses the L1 block timestamp to derive the slot number. + blobSidecars, err := bv.l1BeaconClient.GetBlobSidecarsEnhanced(ctx, L1BlockRef{ + Time: l1Block.Time(), + }, indexedBlobHashes) + if err != nil { + return nil, fmt.Errorf("fetch blob sidecars for tx %s (L1 block %d): %w", + l1TxHash.Hex(), l1BlockNum.Uint64(), err) + } + + // Match each blob hash to its sidecar and assemble the BlobTxSidecar. + var blobTxSidecar eth.BlobTxSidecar + matchedCount := 0 + for _, sidecar := range blobSidecars { + var commitment kzg4844.Commitment + copy(commitment[:], sidecar.KZGCommitment[:]) + versionedHash := KZGToVersionedHash(commitment) + + for _, expectedHash := range blobHashes { + if !bytes.Equal(versionedHash[:], expectedHash[:]) { + continue + } + matchedCount++ + b, err := hexutil.Decode(sidecar.Blob) + if err != nil { + return nil, fmt.Errorf("decode blob hex for tx %s: %w", l1TxHash.Hex(), err) + } + var blob Blob + copy(blob[:], b) + blobTxSidecar.Blobs = append(blobTxSidecar.Blobs, *blob.KZGBlob()) + blobTxSidecar.Commitments = append(blobTxSidecar.Commitments, commitment) + blobTxSidecar.Proofs = append(blobTxSidecar.Proofs, kzg4844.Proof(sidecar.KZGProof)) + break + } + } + if matchedCount == 0 { + return nil, fmt.Errorf("FetchBatchData: no matching blob found for tx %s", l1TxHash.Hex()) + } + bv.logger.Info("FetchBatchData: blobs matched", "matched", matchedCount, "expected", len(blobHashes)) + batch.Sidecar = blobTxSidecar + } + + batchInfo := new(BatchInfo) + if err := batchInfo.ParseBatch(batch); err != nil { + return nil, fmt.Errorf("parse batch for tx %s: %w", l1TxHash.Hex(), err) + } + return batchInfo, nil +} + +// VerifyBatch validates the L2 state for a completed batch against the L1 commitment. +// +// Verification steps (in order): +// 1. PostStateRoot: l2Block(LastBlockNum).stateRoot must equal L1-committed PostStateRoot. +// 2. WithdrawalRoot: L2ToL1MessagePasser.MessageRoot at LastBlockNum must equal WithdrawalRoot. +// 3. PrevStateRoot: l2Block(FirstBlockNum-1).stateRoot must equal L1-committed PrevStateRoot +// (ensures the batch was applied to the correct prior state; skipped if FirstBlockNum unavailable). +// 4. BlockContexts: for each block context decoded from calldata (v1+ batches), verifies that +// the actual L2 block header matches the committed Number, Timestamp, GasLimit, BaseFee, +// NumTxs, and NumL1Msgs. +// 5. L2 user transactions: if batchData (from FetchBatchData) is provided, for each block the +// decoded L2 user transactions from the blob are compared against the actual L2 block +// transactions (excluding L1 message txs). This detects tx content divergence directly. +// +// Prerequisites: +// - The L2 blocks must already exist locally (produced via Tendermint P2P). +// - BatchRoots must have been obtained via FetchBatchRoots. +// - batchData may be nil; if non-nil it must be the result of FetchBatchData for the same batch. +// +// On mismatch for step 1 or 2: +// - If a Validator is configured and challenge is enabled, ChallengeState is triggered. +// +// Steps 3–5 return errors but do not trigger challenge (metadata/content checks, not fraud proofs). +// +// Validation is silently skipped during upgrade transitions (switchTime window). +func (bv *BatchVerifier) VerifyBatch(ctx context.Context, l2Client BatchVerifyL2Client, roots *BatchRoots, batchData *BatchInfo) error { + // Blocks at or below the snapshot/genesis base height have no meaningful roots to verify + if roots.LastBlockNum <= bv.baseHeight { + bv.logger.Info("skipping verification: block at or below base height", + "lastBlockNum", roots.LastBlockNum, + "baseHeight", bv.baseHeight, + ) + return nil + } + + // ── Step 1 & 2: PostStateRoot + WithdrawalRoot ────────────────────────────── + l2Header, err := l2Client.HeaderByNumber(ctx, new(big.Int).SetUint64(roots.LastBlockNum)) + if err != nil { + return fmt.Errorf("get L2 header for block %d: %w", roots.LastBlockNum, err) + } + + withdrawalRoot, err := bv.L2ToL1MessagePasser.MessageRoot(&bind.CallOpts{ + Context: ctx, + BlockNumber: l2Header.Number, + }) + if err != nil { + return fmt.Errorf("get withdrawal root at block %d: %w", roots.LastBlockNum, err) + } + + rootMismatch := l2Header.Root != roots.PostStateRoot + withdrawalMismatch := !bytes.Equal(withdrawalRoot[:], roots.WithdrawalRoot.Bytes()) + + if rootMismatch || withdrawalMismatch { + // During an upgrade transition (ZK→MPT switch), skip to avoid false positives + if bv.shouldSkipValidation(l2Header.Time) { + bv.logger.Info("root validation skipped during upgrade transition", + "batchIndex", roots.BatchIndex, + "l1StateRoot", roots.PostStateRoot.Hex(), + "l2StateRoot", l2Header.Root.Hex(), + "blockTimestamp", l2Header.Time, + "switchTime", bv.switchTime, + "useZktrie", bv.useZktrie, + ) + return nil + } + + bv.logger.Error("batch verification failed: root mismatch", + "batchIndex", roots.BatchIndex, + "lastBlockNum", roots.LastBlockNum, + "l1StateRoot", roots.PostStateRoot.Hex(), + "l2StateRoot", l2Header.Root.Hex(), + "l1WithdrawalRoot", roots.WithdrawalRoot.Hex(), + "l2WithdrawalRoot", common.BytesToHash(withdrawalRoot[:]).Hex(), + "rootMismatch", rootMismatch, + "withdrawalMismatch", withdrawalMismatch, + ) + + // Trigger challenge if validator is configured and enabled + if bv.validator != nil && bv.validator.ChallengeEnable() { + if err := bv.validator.ChallengeState(roots.BatchIndex); err != nil { + bv.logger.Error("challenge state failed", "batchIndex", roots.BatchIndex, "error", err) + } + } + + return fmt.Errorf("state mismatch for batch %d (block %d): stateRoot[L1=%s L2=%s] withdrawalRoot[L1=%s L2=%s]", + roots.BatchIndex, + roots.LastBlockNum, + roots.PostStateRoot.Hex(), + l2Header.Root.Hex(), + roots.WithdrawalRoot.Hex(), + common.BytesToHash(withdrawalRoot[:]).Hex(), + ) + } + + // ── Step 3: PrevStateRoot (batch continuity) ──────────────────────────────── + // Verifies that this batch was applied on top of the correct prior L2 state. + // Only checked when FirstBlockNum is known (v1+ batches) and is above the base height. + if roots.PrevStateRoot != (common.Hash{}) && roots.FirstBlockNum > bv.baseHeight { + prevBlockNum := roots.FirstBlockNum - 1 + prevHeader, err := l2Client.HeaderByNumber(ctx, new(big.Int).SetUint64(prevBlockNum)) + if err != nil { + // Non-fatal: the previous block might not be available yet (e.g. syncing) + bv.logger.Info("VerifyBatch: could not fetch prev block header for PrevStateRoot check", + "batchIndex", roots.BatchIndex, + "prevBlockNum", prevBlockNum, + "error", err, + ) + } else if prevHeader.Root != roots.PrevStateRoot { + return fmt.Errorf("PrevStateRoot mismatch for batch %d (block %d): L1=%s L2=%s", + roots.BatchIndex, + prevBlockNum, + roots.PrevStateRoot.Hex(), + prevHeader.Root.Hex(), + ) + } + } + + // ── Step 4: Per-block metadata (v1+ batches with BlockContexts in calldata) ─ + // For blob-based batches BlockContexts is nil and this step is skipped. + if len(roots.BlockContexts) > 0 { + if err := bv.verifyBlockContextHeaders(ctx, l2Client, roots); err != nil { + return err + } + } + + // ── Step 5: L2 user transaction content (requires blob data from FetchBatchData) ── + // Compares decoded L2 user transactions from the blob with the actual L2 block transactions. + // L1 message transactions (type 0x7E) are excluded from both sides of the comparison + // because they are injected by the sequencer from the L1 queue and are NOT encoded in the blob. + // + // Note: PostStateRoot already guarantees correctness by implication, but this step provides + // explicit per-transaction content validation and enables earlier, more targeted diagnostics. + if batchData != nil { + if err := bv.verifyBatchTransactions(ctx, l2Client, batchData); err != nil { + return err + } + } + + return nil +} + +// verifyBatchTransactions compares the L2 user transactions decoded from the blob +// against the actual transactions in each L2 block. +// +// L1 message transactions (L1MessageTxType = 0x7E) are excluded from the L2 block side +// because they are not encoded in the blob — they are fetched from the L1 message queue +// and injected at the front of the block by the sequencer at execution time. +// +// Per-block checks: +// - User tx count: len(blobTxs) == len(l2Block non-L1-msg txs) +// - User tx content: each tx's binary encoding must match byte-for-byte +func (bv *BatchVerifier) verifyBatchTransactions(ctx context.Context, l2Client BatchVerifyL2Client, batchData *BatchInfo) error { + for _, bc := range batchData.blockContexts { + if bc.Number <= bv.baseHeight { + continue // skip blocks at or below snapshot height + } + + block, err := l2Client.BlockByNumber(ctx, new(big.Int).SetUint64(bc.Number)) + if err != nil { + return fmt.Errorf("block %d: get L2 block for tx verification: %w", bc.Number, err) + } + + // Partition L2 block transactions into L1 message txs and L2 user txs. + var l2UserTxs eth.Transactions + var l1MsgCount uint16 + for _, tx := range block.Transactions() { + if tx.IsL1MessageTx() { + l1MsgCount++ + } else { + l2UserTxs = append(l2UserTxs, tx) + } + } + + // Verify L1 message count against the blob-decoded block context (cross-validates + // the blob-side l1MsgNum with the actual L2 block; complements Step 4's calldata check). + if l1MsgCount != bc.l1MsgNum { + return fmt.Errorf("block %d: L1 message tx count mismatch: blob=%d L2=%d", + bc.Number, bc.l1MsgNum, l1MsgCount) + } + + // bc.SafeL2Data.Transactions holds the blob-decoded L2 user txs as RLP-encoded bytes. + blobTxs := bc.SafeL2Data.Transactions + if len(l2UserTxs) != len(blobTxs) { + return fmt.Errorf("block %d: L2 user tx count mismatch: blob=%d L2=%d", + bc.Number, len(blobTxs), len(l2UserTxs)) + } + + for i, l2Tx := range l2UserTxs { + encodedL2Tx, err := l2Tx.MarshalBinary() + if err != nil { + return fmt.Errorf("block %d: tx[%d] marshal: %w", bc.Number, i, err) + } + if !bytes.Equal(encodedL2Tx, blobTxs[i]) { + return fmt.Errorf("block %d: tx[%d] content mismatch: hash=%s", + bc.Number, i, l2Tx.Hash().Hex()) + } + } + } + return nil +} + +// verifyBlockContextHeaders checks that each block context decoded from L1 calldata +// matches the actual L2 block. +// +// Per-block checks: +// - Number, Timestamp, GasLimit, BaseFee — from block header +// - NumTxs — total transaction count (L2 txs + L1 message txs) +// - NumL1Msgs — count of L1MessageTxType (0x7E) transactions; these are always the first +// NumL1Msgs entries in the block, injected by the sequencer from the L1 message queue +// +// Uses BlockByNumber (one RPC per block) to obtain both header fields and transaction list, +// replacing the previous HeaderByNumber + TransactionCount two-call pattern. +// +// Note: transaction *content* and ordering are already guaranteed by PostStateRoot. +// These checks give explicit metadata consistency and more targeted early error detection. +func (bv *BatchVerifier) verifyBlockContextHeaders(ctx context.Context, l2Client BatchVerifyL2Client, roots *BatchRoots) error { + for i, bc := range roots.BlockContexts { + if bc.Number <= bv.baseHeight { + continue // skip blocks at or below snapshot height + } + + block, err := l2Client.BlockByNumber(ctx, new(big.Int).SetUint64(bc.Number)) + if err != nil { + return fmt.Errorf("block %d (context %d): get L2 block: %w", bc.Number, i, err) + } + header := block.Header() + + // ── Header field checks ──────────────────────────────────────────────── + if header.Number.Uint64() != bc.Number { + return fmt.Errorf("block %d: Number mismatch: L1=%d L2=%d", + bc.Number, bc.Number, header.Number.Uint64()) + } + if header.Time != bc.Timestamp { + return fmt.Errorf("block %d: Timestamp mismatch: L1=%d L2=%d", + bc.Number, bc.Timestamp, header.Time) + } + if header.GasLimit != bc.GasLimit { + return fmt.Errorf("block %d: GasLimit mismatch: L1=%d L2=%d", + bc.Number, bc.GasLimit, header.GasLimit) + } + // BaseFee: only check when L1-committed value is non-zero (pre-EIP-1559 blocks have nil) + if bc.BaseFee != nil && bc.BaseFee.Sign() > 0 { + if header.BaseFee == nil { + return fmt.Errorf("block %d: BaseFee mismatch: L1=%s L2=nil", + bc.Number, bc.BaseFee.String()) + } + if header.BaseFee.Cmp(bc.BaseFee) != 0 { + return fmt.Errorf("block %d: BaseFee mismatch: L1=%s L2=%s", + bc.Number, bc.BaseFee.String(), header.BaseFee.String()) + } + } + + // ── Transaction count checks ─────────────────────────────────────────── + txs := block.Transactions() + + // NumTxs: total transactions (L2 user txs + L1 message txs) + if uint16(len(txs)) != bc.NumTxs { + return fmt.Errorf("block %d: transaction count mismatch: L1=%d L2=%d", + bc.Number, bc.NumTxs, len(txs)) + } + + // NumL1Msgs: L1 message transactions are type 0x7E (L1MessageTxType). + // They are always injected at the front of the block before L2 user transactions. + var l1MsgCount uint16 + for _, tx := range txs { + if tx.IsL1MessageTx() { + l1MsgCount++ + } + } + if l1MsgCount != bc.NumL1Msgs { + return fmt.Errorf("block %d: L1 message count mismatch: L1=%d L2=%d", + bc.Number, bc.NumL1Msgs, l1MsgCount) + } + } + return nil +} + +// shouldSkipValidation returns true when validation should be bypassed during the +// ZK→MPT upgrade window to avoid false-positive challenges. +// Skip conditions: +// - Before switchTime and running MPT geth (useZktrie=false): old blocks, new geth +// - After switchTime and running ZK geth (useZktrie=true): new blocks, old geth +func (bv *BatchVerifier) shouldSkipValidation(blockTimestamp uint64) bool { + if bv.switchTime == 0 { + return false + } + beforeSwitch := blockTimestamp < bv.switchTime + return (beforeSwitch && !bv.useZktrie) || (!beforeSwitch && bv.useZktrie) +} + +// unpackCalldataWithABIs unpacks a CommitBatch transaction's calldata across all known ABI versions. +// It is a package-level function so both Derivation and BatchVerifier can share it. +// Only calldata fields are populated; Sidecar (blob) is left empty. +func unpackCalldataWithABIs(rollupABI, legacyRollupABI, beforeMoveBlockCtxABI *abi.ABI, data []byte) (geth.RPCRollupBatch, error) { + var batch geth.RPCRollupBatch + if len(data) < 4 { + return batch, fmt.Errorf("calldata too short: %d bytes", len(data)) + } + + switch { + case bytes.Equal(beforeMoveBlockCtxABI.Methods["commitBatch"].ID, data[:4]): + args, err := beforeMoveBlockCtxABI.Methods["commitBatch"].Inputs.Unpack(data[4:]) + if err != nil { + return batch, fmt.Errorf("beforeMoveBlockCtx commitBatch unpack: %w", err) + } + rollupBatchData := args[0].(struct { + Version uint8 "json:\"version\"" + ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" + BlockContexts []uint8 "json:\"blockContexts\"" + PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" + PostStateRoot [32]uint8 "json:\"postStateRoot\"" + WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" + }) + batch = geth.RPCRollupBatch{ + Version: uint(rollupBatchData.Version), + ParentBatchHeader: rollupBatchData.ParentBatchHeader, + BlockContexts: rollupBatchData.BlockContexts, + PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), + PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), + WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), + } + + case bytes.Equal(legacyRollupABI.Methods["commitBatch"].ID, data[:4]): + args, err := legacyRollupABI.Methods["commitBatch"].Inputs.Unpack(data[4:]) + if err != nil { + return batch, fmt.Errorf("legacy commitBatch unpack: %w", err) + } + rollupBatchData := args[0].(struct { + Version uint8 "json:\"version\"" + ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" + BlockContexts []uint8 "json:\"blockContexts\"" + SkippedL1MessageBitmap []uint8 "json:\"skippedL1MessageBitmap\"" + PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" + PostStateRoot [32]uint8 "json:\"postStateRoot\"" + WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" + }) + batch = geth.RPCRollupBatch{ + Version: uint(rollupBatchData.Version), + ParentBatchHeader: rollupBatchData.ParentBatchHeader, + BlockContexts: rollupBatchData.BlockContexts, + PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), + PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), + WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), + } + + case bytes.Equal(rollupABI.Methods["commitBatch"].ID, data[:4]): + args, err := rollupABI.Methods["commitBatch"].Inputs.Unpack(data[4:]) + if err != nil { + return batch, fmt.Errorf("commitBatch unpack: %w", err) + } + rollupBatchData := args[0].(struct { + Version uint8 "json:\"version\"" + ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" + LastBlockNumber uint64 "json:\"lastBlockNumber\"" + NumL1Messages uint16 "json:\"numL1Messages\"" + PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" + PostStateRoot [32]uint8 "json:\"postStateRoot\"" + WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" + }) + batch = geth.RPCRollupBatch{ + Version: uint(rollupBatchData.Version), + ParentBatchHeader: rollupBatchData.ParentBatchHeader, + LastBlockNumber: rollupBatchData.LastBlockNumber, + NumL1Messages: rollupBatchData.NumL1Messages, + PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), + PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), + WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), + } + + case bytes.Equal(rollupABI.Methods["commitBatchWithProof"].ID, data[:4]): + args, err := rollupABI.Methods["commitBatchWithProof"].Inputs.Unpack(data[4:]) + if err != nil { + return batch, fmt.Errorf("commitBatchWithProof unpack: %w", err) + } + rollupBatchData := args[0].(struct { + Version uint8 "json:\"version\"" + ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" + LastBlockNumber uint64 "json:\"lastBlockNumber\"" + NumL1Messages uint16 "json:\"numL1Messages\"" + PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" + PostStateRoot [32]uint8 "json:\"postStateRoot\"" + WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" + }) + batch = geth.RPCRollupBatch{ + Version: uint(rollupBatchData.Version), + ParentBatchHeader: rollupBatchData.ParentBatchHeader, + LastBlockNumber: rollupBatchData.LastBlockNumber, + NumL1Messages: rollupBatchData.NumL1Messages, + PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), + PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), + WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), + } + + default: + return batch, types.ErrNotCommitBatchTx + } + + return batch, nil +} diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go deleted file mode 100644 index 8fb311b0e..000000000 --- a/node/derivation/derivation.go +++ /dev/null @@ -1,637 +0,0 @@ -package derivation - -import ( - "bytes" - "context" - "errors" - "fmt" - "math/big" - "time" - - "github.com/morph-l2/go-ethereum" - "github.com/morph-l2/go-ethereum/accounts/abi" - "github.com/morph-l2/go-ethereum/accounts/abi/bind" - "github.com/morph-l2/go-ethereum/common" - "github.com/morph-l2/go-ethereum/common/hexutil" - eth "github.com/morph-l2/go-ethereum/core/types" - "github.com/morph-l2/go-ethereum/crypto" - "github.com/morph-l2/go-ethereum/crypto/kzg4844" - geth "github.com/morph-l2/go-ethereum/eth" - "github.com/morph-l2/go-ethereum/ethclient" - "github.com/morph-l2/go-ethereum/ethclient/authclient" - "github.com/morph-l2/go-ethereum/rpc" - tmlog "github.com/tendermint/tendermint/libs/log" - - "morph-l2/bindings/bindings" - "morph-l2/bindings/predeploys" - nodecommon "morph-l2/node/common" - "morph-l2/node/sync" - "morph-l2/node/types" - "morph-l2/node/validator" -) - -var ( - RollupEventTopic = "CommitBatch(uint256,bytes32)" - RollupEventTopicHash = crypto.Keccak256Hash([]byte(RollupEventTopic)) -) - -type Derivation struct { - ctx context.Context - syncer *sync.Syncer - l1Client *ethclient.Client - RollupContractAddress common.Address - confirmations rpc.BlockNumber - l2Client *types.RetryableClient - validator *validator.Validator - logger tmlog.Logger - rollup *bindings.Rollup - metrics *Metrics - l1BeaconClient *L1BeaconClient - L2ToL1MessagePasser *bindings.L2ToL1MessagePasser - - rollupABI *abi.ABI - legacyRollupABI *abi.ABI // before remove skipMap - beforeMoveBlockCtxABI *abi.ABI - - db Database - - cancel context.CancelFunc - - startHeight uint64 - baseHeight uint64 - fetchBlockRange uint64 - pollInterval time.Duration - logProgressInterval time.Duration - stop chan struct{} - - // geth upgrade config (fetched once at startup) - switchTime uint64 - useZktrie bool -} - -type DeployContractBackend interface { - bind.DeployBackend - bind.ContractBackend - ethereum.ChainReader - 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) { - l1Client, err := ethclient.Dial(cfg.L1.Addr) - if err != nil { - return nil, err - } - // L2 geth endpoint (required - current geth) - aClient, err := authclient.DialContext(context.Background(), cfg.L2.EngineAddr, cfg.L2.JwtSecret) - if err != nil { - return nil, err - } - eClient, err := ethclient.Dial(cfg.L2.EthAddr) - if err != nil { - return nil, err - } - - // L2Next endpoint (optional - for upgrade switch) - var aNextClient *authclient.Client - var eNextClient *ethclient.Client - if cfg.L2Next != nil && cfg.L2Next.EngineAddr != "" && cfg.L2Next.EthAddr != "" { - aNextClient, err = authclient.DialContext(context.Background(), cfg.L2Next.EngineAddr, cfg.L2Next.JwtSecret) - if err != nil { - return nil, err - } - eNextClient, err = ethclient.Dial(cfg.L2Next.EthAddr) - if err != nil { - return nil, err - } - logger.Info("L2Next geth configured (upgrade switch enabled)", "engineAddr", cfg.L2Next.EngineAddr, "ethAddr", cfg.L2Next.EthAddr) - } else { - logger.Info("L2Next geth not configured (no upgrade switch)") - } - - msgPasser, err := bindings.NewL2ToL1MessagePasser(predeploys.L2ToL1MessagePasserAddr, eClient) - if err != nil { - return nil, err - } - rollupAbi, err := bindings.RollupMetaData.GetAbi() - if err != nil { - return nil, err - } - legacyRollupAbi, err := types.LegacyRollupMetaData.GetAbi() - if err != nil { - return nil, err - } - beforeMoveBlockCtxAbi, err := types.BeforeMoveBlockCtxABI.GetAbi() - if err != nil { - return nil, err - } - ctx, cancel := context.WithCancel(ctx) - logger = logger.With("module", "derivation") - metrics := PrometheusMetrics("morphnode") - if cfg.MetricsServerEnable { - go func() { - _, err := metrics.Serve(cfg.MetricsHostname, cfg.MetricsPort) - if err != nil { - panic(fmt.Errorf("metrics server start error:%v", err)) - } - }() - logger.Info("metrics server enabled", "host", cfg.MetricsHostname, "port", cfg.MetricsPort) - } - baseHttp := NewBasicHTTPClient(cfg.BeaconRpc, logger) - l1BeaconClient := NewL1BeaconClient(baseHttp) - - // Fetch geth config once at startup for root validation skip logic (with retry) - gethCfg, err := types.FetchGethConfigWithRetry(cfg.L2.EthAddr, logger) - if err != nil { - cancel() // cancel context to avoid leak - return nil, fmt.Errorf("failed to fetch geth config: %w", err) - } - logger.Info("Geth config fetched", "switchTime", gethCfg.SwitchTime, "useZktrie", gethCfg.UseZktrie) - - return &Derivation{ - ctx: ctx, - db: db, - l1Client: l1Client, - syncer: syncer, - validator: validator, - rollup: rollup, - rollupABI: rollupAbi, - legacyRollupABI: legacyRollupAbi, - beforeMoveBlockCtxABI: beforeMoveBlockCtxAbi, - logger: logger, - RollupContractAddress: cfg.RollupContractAddress, - confirmations: cfg.L1.Confirmations, - l2Client: types.NewRetryableClient(aClient, eClient, aNextClient, eNextClient, gethCfg.SwitchTime, logger), - cancel: cancel, - stop: make(chan struct{}), - startHeight: cfg.StartHeight, - baseHeight: cfg.BaseHeight, - fetchBlockRange: cfg.FetchBlockRange, - pollInterval: cfg.PollInterval, - logProgressInterval: cfg.LogProgressInterval, - metrics: metrics, - l1BeaconClient: l1BeaconClient, - L2ToL1MessagePasser: msgPasser, - switchTime: gethCfg.SwitchTime, - useZktrie: gethCfg.UseZktrie, - }, nil -} - -func (d *Derivation) Start() { - // block node startup during initial sync and print some helpful logs - go func() { - d.syncer.Start() - t := time.NewTicker(d.pollInterval) - defer t.Stop() - - for { - // don't wait for ticker during startup - d.derivationBlock(d.ctx) - - select { - case <-d.ctx.Done(): - d.logger.Error("derivation node Unexpected exit") - close(d.stop) - return - case <-t.C: - continue - } - } - }() -} - -func (d *Derivation) Stop() { - if d == nil { - return - } - - d.logger.Info("stopping derivation service") - - if d.cancel != nil { - d.cancel() - } - <-d.stop - d.logger.Info("derivation service is stopped") -} - -func (d *Derivation) derivationBlock(ctx context.Context) { - latestDerivation := d.db.ReadLatestDerivationL1Height() - latest, err := d.getLatestConfirmedBlockNumber(d.ctx) - if err != nil { - d.logger.Error("get latest block number failed", "err", err) - return - } - var start uint64 - if latestDerivation == nil { - start = d.startHeight - } else { - start = *latestDerivation + 1 - } - end := latest - if latest < start { - d.logger.Info("latest less than start", "latest", latest, "start", start) - return - } else if latest-start >= d.fetchBlockRange { - end = start + d.fetchBlockRange - } - d.logger.Info("derivation start pull rollupData form l1", "startBlock", start, "end", end) - logs, err := d.fetchRollupLog(ctx, start, end) - if err != nil { - d.logger.Error("eth_getLogs failed", "err", err) - return - } - latestBatchIndex, err := d.rollup.LastCommittedBatchIndex(nil) - if err != nil { - d.logger.Error("query rollup latestCommitted batch Index failed", "err", err) - return - } - d.metrics.SetLatestBatchIndex(latestBatchIndex.Uint64()) - d.logger.Info("fetched rollup tx", "txNum", len(logs), "latestBatchIndex", latestBatchIndex) - - for _, lg := range logs { - batchInfo, err := d.fetchRollupDataByTxHash(lg.TxHash, lg.BlockNumber) - if err != nil { - if errors.Is(err, types.ErrNotCommitBatchTx) { - continue - } - d.logger.Error("fetch batch info failed", "txHash", lg.TxHash, "blockNumber", lg.BlockNumber, "error", err) - return - } - d.logger.Info("fetch rollup transaction success", "txNonce", batchInfo.nonce, "txHash", batchInfo.txHash, - "l1BlockNumber", batchInfo.l1BlockNumber, "firstL2BlockNumber", batchInfo.firstBlockNumber, "lastL2BlockNumber", batchInfo.lastBlockNumber) - - // derivation - 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, - ) - } - } - - 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(), - ) - return - } - } - d.metrics.SetBatchStatus(stateNormal) - d.metrics.SetL1SyncHeight(lg.BlockNumber) - } - - d.db.WriteLatestDerivationL1Height(end) - d.metrics.SetL1SyncHeight(end) - d.logger.Info("write latest derivation l1 height success", "l1BlockNumber", end) -} - -func (d *Derivation) fetchRollupLog(ctx context.Context, from, to uint64) ([]eth.Log, error) { - query := ethereum.FilterQuery{ - FromBlock: big.NewInt(0).SetUint64(from), - ToBlock: big.NewInt(0).SetUint64(to), - Addresses: []common.Address{ - d.RollupContractAddress, - }, - Topics: [][]common.Hash{ - {RollupEventTopicHash}, - }, - } - return d.l1Client.FilterLogs(ctx, query) -} - -func (d *Derivation) fetchRollupDataByTxHash(txHash common.Hash, blockNumber uint64) (*BatchInfo, error) { - tx, pending, err := d.l1Client.TransactionByHash(context.Background(), txHash) - if err != nil { - return nil, err - } - if pending { - return nil, errors.New("pending transaction") - } - batch, err := d.UnPackData(tx.Data()) - if err != nil { - return nil, err - } - - // Get block header to retrieve timestamp - header, err := d.l1Client.HeaderByNumber(d.ctx, big.NewInt(int64(blockNumber))) - if err != nil { - return nil, err - } - - // Get transaction blob hashes - blobHashes := tx.BlobHashes() - if len(blobHashes) > 0 { - d.logger.Info("Transaction contains blobs", "txHash", txHash, "blobCount", len(blobHashes)) - - // Initialize indexedBlobHashes as nil - var indexedBlobHashes []IndexedBlobHash - - // Only try to build IndexedBlobHash array if not forcing get all blobs - // Try to get the block to build IndexedBlobHash array - block, err := d.l1Client.BlockByNumber(d.ctx, big.NewInt(int64(blockNumber))) - if err == nil { - // Successfully got the block, now build IndexedBlobHash array - d.logger.Info("Building IndexedBlobHash array from block", "blockNumber", blockNumber) - indexedBlobHashes = dataAndHashesFromTxs(block.Transactions(), tx) - d.logger.Info("Built IndexedBlobHash array", "count", len(indexedBlobHashes)) - } else { - d.logger.Info("Failed to get block, will try fetching all blobs", "blockNumber", blockNumber, "error", err) - } - - // Get all blobs corresponding to this timestamp - blobSidecars, err := d.l1BeaconClient.GetBlobSidecarsEnhanced(d.ctx, L1BlockRef{ - Time: header.Time, - }, indexedBlobHashes) - if err != nil { - return nil, fmt.Errorf("failed to get blobs, continuing processing:%v", err) - } - if len(blobSidecars) > 0 { - // Create blob sidecar - var blobTxSidecar eth.BlobTxSidecar - matchedCount := 0 - - // Match blobs - for _, sidecar := range blobSidecars { - var commitment kzg4844.Commitment - copy(commitment[:], sidecar.KZGCommitment[:]) - versionedHash := KZGToVersionedHash(commitment) - - for _, expectedHash := range blobHashes { - if bytes.Equal(versionedHash[:], expectedHash[:]) { - matchedCount++ - d.logger.Info("Found matching blob", "index", sidecar.Index, "hash", versionedHash.Hex()) - - // Decode and process blob data - var blob Blob - b, err := hexutil.Decode(sidecar.Blob) - if err != nil { - d.logger.Error("Failed to decode blob data", "error", err) - continue - } - copy(blob[:], b) - - // Verify blob - //if err := VerifyBlobProof(&blob, commitment, kzg4844.Proof(sidecar.KZGProof)); err != nil { - // d.logger.Error("Blob verification failed", "error", err) - // continue - //} - - // Add to sidecar - blobTxSidecar.Blobs = append(blobTxSidecar.Blobs, *blob.KZGBlob()) - blobTxSidecar.Commitments = append(blobTxSidecar.Commitments, commitment) - blobTxSidecar.Proofs = append(blobTxSidecar.Proofs, kzg4844.Proof(sidecar.KZGProof)) - break - } - } - } - - d.logger.Info("Blob matching results", "matched", matchedCount, "expected", len(blobHashes)) - if matchedCount == 0 { - return nil, fmt.Errorf("no matching versionedHash was found") - } - batch.Sidecar = blobTxSidecar - } else { - return nil, fmt.Errorf("not matched blob,txHash:%v,blockNumber:%v", txHash, blockNumber) - } - } - - // Get L2 height - l2Height, err := d.l2Client.BlockNumber(d.ctx) - if err != nil { - return nil, fmt.Errorf("query l2 block number error:%v", err) - } - rollupData, err := d.parseBatch(batch, l2Height) - if err != nil { - d.logger.Error("parse batch failed", "txNonce", tx.Nonce(), "txHash", txHash, - "l1BlockNumber", blockNumber) - return rollupData, fmt.Errorf("parse batch error:%v", err) - } - rollupData.l1BlockNumber = blockNumber - rollupData.txHash = txHash - rollupData.nonce = tx.Nonce() - return rollupData, nil -} - -func (d *Derivation) UnPackData(data []byte) (geth.RPCRollupBatch, error) { - var batch geth.RPCRollupBatch - if bytes.Equal(d.beforeMoveBlockCtxABI.Methods["commitBatch"].ID, data[:4]) { - args, err := d.beforeMoveBlockCtxABI.Methods["commitBatch"].Inputs.Unpack(data[4:]) - if err != nil { - return batch, fmt.Errorf("submitBatches Unpack error:%v", err) - } - rollupBatchData := args[0].(struct { - Version uint8 "json:\"version\"" - ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" - BlockContexts []uint8 "json:\"blockContexts\"" - PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" - PostStateRoot [32]uint8 "json:\"postStateRoot\"" - WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" - }) - batch = geth.RPCRollupBatch{ - Version: uint(rollupBatchData.Version), - ParentBatchHeader: rollupBatchData.ParentBatchHeader, - BlockContexts: rollupBatchData.BlockContexts, - PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), - PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), - WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), - } - } else if bytes.Equal(d.legacyRollupABI.Methods["commitBatch"].ID, data[:4]) { - args, err := d.legacyRollupABI.Methods["commitBatch"].Inputs.Unpack(data[4:]) - if err != nil { - return batch, fmt.Errorf("submitBatches Unpack error:%v", err) - } - rollupBatchData := args[0].(struct { - Version uint8 "json:\"version\"" - ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" - BlockContexts []uint8 "json:\"blockContexts\"" - SkippedL1MessageBitmap []uint8 "json:\"skippedL1MessageBitmap\"" - PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" - PostStateRoot [32]uint8 "json:\"postStateRoot\"" - WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" - }) - batch = geth.RPCRollupBatch{ - Version: uint(rollupBatchData.Version), - ParentBatchHeader: rollupBatchData.ParentBatchHeader, - BlockContexts: rollupBatchData.BlockContexts, - PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), - PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), - WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), - } - } else if bytes.Equal(d.rollupABI.Methods["commitBatch"].ID, data[:4]) { - args, err := d.rollupABI.Methods["commitBatch"].Inputs.Unpack(data[4:]) - if err != nil { - return batch, fmt.Errorf("submitBatches Unpack error:%v", err) - } - rollupBatchData := args[0].(struct { - Version uint8 "json:\"version\"" - ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" - LastBlockNumber uint64 "json:\"lastBlockNumber\"" - NumL1Messages uint16 "json:\"numL1Messages\"" - PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" - PostStateRoot [32]uint8 "json:\"postStateRoot\"" - WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" - }) - batch = geth.RPCRollupBatch{ - Version: uint(rollupBatchData.Version), - ParentBatchHeader: rollupBatchData.ParentBatchHeader, - LastBlockNumber: rollupBatchData.LastBlockNumber, - NumL1Messages: rollupBatchData.NumL1Messages, - PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), - PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), - WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), - } - } else if bytes.Equal(d.rollupABI.Methods["commitBatchWithProof"].ID, data[:4]) { - args, err := d.rollupABI.Methods["commitBatchWithProof"].Inputs.Unpack(data[4:]) - if err != nil { - return batch, fmt.Errorf("commitBatchWithProof Unpack error:%v", err) - } - rollupBatchData := args[0].(struct { - Version uint8 "json:\"version\"" - ParentBatchHeader []uint8 "json:\"parentBatchHeader\"" - LastBlockNumber uint64 "json:\"lastBlockNumber\"" - NumL1Messages uint16 "json:\"numL1Messages\"" - PrevStateRoot [32]uint8 "json:\"prevStateRoot\"" - PostStateRoot [32]uint8 "json:\"postStateRoot\"" - WithdrawalRoot [32]uint8 "json:\"withdrawalRoot\"" - }) - batch = geth.RPCRollupBatch{ - Version: uint(rollupBatchData.Version), - ParentBatchHeader: rollupBatchData.ParentBatchHeader, - LastBlockNumber: rollupBatchData.LastBlockNumber, - NumL1Messages: rollupBatchData.NumL1Messages, - PrevStateRoot: common.BytesToHash(rollupBatchData.PrevStateRoot[:]), - PostStateRoot: common.BytesToHash(rollupBatchData.PostStateRoot[:]), - WithdrawRoot: common.BytesToHash(rollupBatchData.WithdrawalRoot[:]), - } - } else { - return batch, types.ErrNotCommitBatchTx - } - return batch, nil -} - -func (d *Derivation) parseBatch(batch geth.RPCRollupBatch, l2Height uint64) (*BatchInfo, error) { - batchInfo := new(BatchInfo) - if err := batchInfo.ParseBatch(batch); err != nil { - return nil, fmt.Errorf("parse batch error:%v", err) - } - if err := d.handleL1Message(batchInfo, batchInfo.parentTotalL1MessagePopped, l2Height); err != nil { - return nil, fmt.Errorf("handle l1 message error:%v", err) - } - return batchInfo, nil -} - -func (d *Derivation) handleL1Message(rollupData *BatchInfo, parentTotalL1MessagePopped, l2Height uint64) error { - totalL1MessagePopped := parentTotalL1MessagePopped - for bIndex, block := range rollupData.blockContexts { - // This may happen to nodes started from snapshot, in which case we will no longer handle L1Msg - if block.Number <= l2Height { - continue - } - var l1Transactions []*eth.Transaction - l1Messages, err := d.getL1Message(totalL1MessagePopped, uint64(block.l1MsgNum)) - if err != nil { - return fmt.Errorf("get l1 message error:%v", err) - } - if len(l1Messages) != int(block.l1MsgNum) { - return fmt.Errorf("invalid l1 msg num,expect %v,have %v", block.l1MsgNum, l1Messages) - } - totalL1MessagePopped += uint64(block.l1MsgNum) - if len(l1Messages) > 0 { - for _, l1Message := range l1Messages { - transaction := eth.NewTx(&l1Message.L1MessageTx) - l1Transactions = append(l1Transactions, transaction) - } - } - rollupData.blockContexts[bIndex].SafeL2Data.Transactions = append(encodeTransactions(l1Transactions), rollupData.blockContexts[bIndex].SafeL2Data.Transactions...) - } - - return nil -} - -func (d *Derivation) getL1Message(l1MessagePopped, l1MsgNum uint64) ([]types.L1Message, error) { - if l1MsgNum == 0 { - return nil, nil - } - return d.syncer.ReadL1MessagesInRange(l1MessagePopped, l1MessagePopped+l1MsgNum-1), nil -} - -func (d *Derivation) derive(rollupData *BatchInfo) (*eth.Header, error) { - var lastHeader *eth.Header - for _, blockData := range rollupData.blockContexts { - latestBlockNumber, err := d.l2Client.BlockNumber(context.Background()) - if err != nil { - 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))) - if err != nil { - return nil, fmt.Errorf("query header by number error:%v", err) - } - continue - } - err = func() error { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60)*time.Second) - defer cancel() - lastHeader, err = d.l2Client.NewSafeL2Block(ctx, blockData.SafeL2Data) - if err != nil { - d.logger.Error("new l2 block failed", "latestBlockNumber", latestBlockNumber, "error", err) - return err - } - return nil - }() - if err != nil { - return nil, fmt.Errorf("derivation error:%v", err) - } - d.logger.Info("new l2 block success", "blockNumber", blockData.Number) - } - - return lastHeader, nil -} - -func (d *Derivation) getLatestConfirmedBlockNumber(ctx context.Context) (uint64, error) { - return nodecommon.GetLatestConfirmedBlockNumber(ctx, d.l1Client, d.confirmations) -} diff --git a/node/derivation/derivation_test.go b/node/derivation/derivation_test.go index 69eb750d6..2fca4629b 100644 --- a/node/derivation/derivation_test.go +++ b/node/derivation/derivation_test.go @@ -23,21 +23,19 @@ func TestUnPackData(t *testing.T) { require.NoError(t, err) beforeMoveBlockCtxABI, err := types.BeforeMoveBlockCtxABI.GetAbi() require.NoError(t, err) - d := Derivation{ - rollupABI: rollupAbi, - legacyRollupABI: legacyRollupAbi, - beforeMoveBlockCtxABI: beforeMoveBlockCtxABI, - } + errorTxData, err := hexutil.Decode(errorData) require.NoError(t, err) - _, err = d.UnPackData(errorTxData) + _, err = unpackCalldataWithABIs(rollupAbi, legacyRollupAbi, beforeMoveBlockCtxABI, errorTxData) require.Error(t, err) + legacyTxData, err := hexutil.Decode(legacyData) require.NoError(t, err) - _, err = d.UnPackData(legacyTxData) + _, err = unpackCalldataWithABIs(rollupAbi, legacyRollupAbi, beforeMoveBlockCtxABI, legacyTxData) require.NoError(t, err) + beforeMoveBctxTxData, err := hexutil.Decode(beforeMoveBctxData) require.NoError(t, err) - _, err = d.UnPackData(beforeMoveBctxTxData) + _, err = unpackCalldataWithABIs(rollupAbi, legacyRollupAbi, beforeMoveBlockCtxABI, beforeMoveBctxTxData) require.NoError(t, err) } diff --git a/node/types/retryable_client.go b/node/types/retryable_client.go index 899c3be12..c81e92a5a 100644 --- a/node/types/retryable_client.go +++ b/node/types/retryable_client.go @@ -65,16 +65,26 @@ type GethConfig struct { } // FetchGethConfigWithRetry fetches geth config with retry, waiting for geth to be ready. -func FetchGethConfigWithRetry(rpcURL string, logger tmlog.Logger) (*GethConfig, error) { +// The call blocks until geth responds, ctx is canceled, or the retry limit is reached. +func FetchGethConfigWithRetry(ctx context.Context, rpcURL string, logger tmlog.Logger) (*GethConfig, error) { var lastErr error for i := 0; i < GethRetryAttempts; i++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } config, err := FetchGethConfig(rpcURL, logger) if err == nil { return config, nil } lastErr = err logger.Info("Waiting for geth to be ready...", "attempt", i+1, "error", err) - time.Sleep(GethRetryInterval) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(GethRetryInterval): + } } return nil, fmt.Errorf("geth not ready after %d attempts: %w", GethRetryAttempts, lastErr) } @@ -424,6 +434,24 @@ func (rc *RetryableClient) BlockNumber(ctx context.Context) (ret uint64, err err return } +func (rc *RetryableClient) BlockByNumber(ctx context.Context, number *big.Int) (ret *eth.Block, err error) { + if retryErr := backoff.Retry(func() error { + resp, respErr := rc.eClient().BlockByNumber(ctx, number) + if respErr != nil { + rc.logger.Info("failed to call BlockByNumber", "error", respErr) + if retryableError(respErr) { + return respErr + } + err = respErr + } + ret = resp + return nil + }, rc.b); retryErr != nil { + return nil, retryErr + } + return +} + func (rc *RetryableClient) HeaderByNumber(ctx context.Context, blockNumber *big.Int) (ret *eth.Header, err error) { if retryErr := backoff.Retry(func() error { resp, respErr := rc.eClient().HeaderByNumber(ctx, blockNumber)