diff --git a/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el/run_test.sh b/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el/run_test.sh new file mode 100755 index 00000000000..5ab9d0d0213 --- /dev/null +++ b/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el/run_test.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Script to run the sync tester external EL test +# This test validates op-node syncing against external execution layer endpoints + +set -e + +# Configuration +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_FILE="${TEST_DIR}/test_run_$(date +%Y%m%d_%H%M%S).log" +TIP_MODE_RPC="${OP_NODE_ROLLUP_TIP_MODE_RPC:-}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "==========================================" +echo "OP Stack Sync Tester - External EL" +echo "==========================================" +echo "Test Directory: ${TEST_DIR}" +echo "Log File: ${LOG_FILE}" +if [ -n "${TIP_MODE_RPC}" ]; then + echo -e "${YELLOW}Tip Mode: ENABLED${NC}" + echo "Tip Mode RPC: ${TIP_MODE_RPC}" +else + echo "Tip Mode: DISABLED (standard derivation)" +fi +echo "==========================================" +echo "" + +cd "${TEST_DIR}" + +# Run the test +echo "Starting test..." +CIRCLECI_PARAMETERS_SYNC_TEST_OP_NODE_DISPATCH=true \ + TAILSCALE_NETWORKING=true \ + NETWORK_PRESET=op-sepolia \ + GOMAXPROCS=5 \ + OP_NODE_ROLLUP_TIP_MODE_RPC="${TIP_MODE_RPC}" \ + go test -run '^TestSyncTesterExtEL$' -v -count=1 2>&1 | tee "${LOG_FILE}" + +# Check exit code +EXIT_CODE=${PIPESTATUS[0]} + +echo "" +echo "==========================================" +if [ ${EXIT_CODE} -eq 0 ]; then + echo -e "${GREEN}TEST PASSED ✓${NC}" +else + echo -e "${RED}TEST FAILED ✗${NC}" +fi +echo "Exit Code: ${EXIT_CODE}" +echo "Log File: ${LOG_FILE}" +echo "==========================================" + +exit ${EXIT_CODE} diff --git a/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el/sync_tester_ext_el_test.go b/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el/sync_tester_ext_el_test.go index 9c4f57f2638..0a873325dbf 100644 --- a/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el/sync_tester_ext_el_test.go +++ b/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el/sync_tester_ext_el_test.go @@ -72,17 +72,10 @@ var ( L1ELEndpoint: "https://ci-mainnet-l1.optimism.io", }, } - L2CLSyncMode = getSyncMode("L2_CL_SYNCMODE") ) -func getSyncMode(envVar string) sync.Mode { - if value := os.Getenv(envVar); value == sync.ELSyncString { - return sync.ELSync - } - return sync.CLSync -} - -func TestSyncTesterExtEL(gt *testing.T) { +// runSyncTest contains the shared test logic for all sync modes +func runSyncTest(gt *testing.T, syncMode sync.Mode, tipModeRPC string) { t := devtest.SerialT(gt) if os.Getenv("CIRCLECI_PIPELINE_SCHEDULE_NAME") != "build_daily" && os.Getenv("CIRCLECI_PARAMETERS_SYNC_TEST_OP_NODE_DISPATCH") != "true" { @@ -92,10 +85,10 @@ func TestSyncTesterExtEL(gt *testing.T) { l := t.Logger() require := t.Require() blocksToSync := uint64(20) - sys, target := setupSystem(gt, t, blocksToSync) + sys, target := setupSystem(gt, t, blocksToSync, syncMode, tipModeRPC) attempts := 500 - if L2CLSyncMode == sync.ELSync { + if syncMode == sync.ELSync { // After EL Sync is finished, the FCU state will advance to target immediately so less attempts attempts = 5 // Signal L2CL for triggering EL Sync @@ -125,10 +118,28 @@ func TestSyncTesterExtEL(gt *testing.T) { l.Info("SyncTester ExtEL test completed successfully", "l2cl_chain_id", sys.L2CL.ID().ChainID(), "l2cl_sync_status", l2CLSyncStatus) } +// TestSyncTesterExtEL tests op-node syncing in CL or EL sync mode +func TestSyncTesterExtEL(gt *testing.T) { + syncMode := sync.CLSync + if value := os.Getenv("L2_CL_SYNCMODE"); value == sync.ELSyncString { + syncMode = sync.ELSync + } + runSyncTest(gt, syncMode, "") +} + +// TestSyncTesterExtELTipMode tests op-node syncing in tip mode (RPC-based sync) +func TestSyncTesterExtELTipMode(gt *testing.T) { + tipModeRPC := os.Getenv("OP_NODE_ROLLUP_TIP_MODE_RPC") + if tipModeRPC == "" { + gt.Skip("OP_NODE_ROLLUP_TIP_MODE_RPC not set, skipping tip mode test") + } + runSyncTest(gt, sync.CLSync, tipModeRPC) +} + // setupSystem initializes the system for the test and returns the system and the target block number of the session -func setupSystem(gt *testing.T, t devtest.T, blocksToSync uint64) (*presets.MinimalExternalEL, uint64) { +func setupSystem(gt *testing.T, t devtest.T, blocksToSync uint64, syncMode sync.Mode, tipModeRPC string) (*presets.MinimalExternalEL, uint64) { // Initialize orchestrator - orch, target := setupOrchestrator(gt, t, blocksToSync) + orch, target := setupOrchestrator(gt, t, blocksToSync, syncMode, tipModeRPC) system := shim.NewSystem(t) orch.Hydrate(system) @@ -154,7 +165,7 @@ func setupSystem(gt *testing.T, t devtest.T, blocksToSync uint64) (*presets.Mini } // setupOrchestrator initializes and configures the orchestrator for the test and returns the orchestrator and the target block number of the session -func setupOrchestrator(gt *testing.T, t devtest.T, blocksToSync uint64) (*sysgo.Orchestrator, uint64) { +func setupOrchestrator(gt *testing.T, t devtest.T, blocksToSync uint64, syncMode sync.Mode, tipModeRPC string) (*sysgo.Orchestrator, uint64) { l := t.Logger() ctx := t.Ctx() require := t.Require() @@ -185,7 +196,8 @@ func setupOrchestrator(gt *testing.T, t devtest.T, blocksToSync uint64) (*sysgo. l.Info("L1_CL_BEACON_ENDPOINT", "value", config.L1CLBeaconEndpoint) l.Info("L1_EL_ENDPOINT", "value", config.L1ELEndpoint) l.Info("TAILSCALE_NETWORKING", "value", os.Getenv("TAILSCALE_NETWORKING")) - l.Info("L2_CL_SYNCMODE", "value", L2CLSyncMode) + l.Info("L2_CL_SYNCMODE", "value", syncMode) + l.Info("TIP_MODE_RPC", "value", tipModeRPC) // Setup orchestrator logger := testlog.Logger(gt, log.LevelInfo) @@ -212,8 +224,16 @@ func setupOrchestrator(gt *testing.T, t devtest.T, blocksToSync uint64) (*sysgo. target := initial + blocksToSync l.Info("LATEST_BLOCK", "latest_block", latestBlock.NumberU64(), "session_initial_block", initial, "target_block", target) + // Set tip mode environment variable if provided + // The op-node will automatically pick this up during initialization + if tipModeRPC != "" { + os.Setenv("OP_NODE_ROLLUP_TIP_MODE", "true") + os.Setenv("OP_NODE_ROLLUP_TIP_MODE_RPC", tipModeRPC) + } + opt := presets.WithExternalELWithSuperchainRegistry(config) - if L2CLSyncMode == sync.ELSync { + + if syncMode == sync.ELSync { chainCfg := chaincfg.ChainByName(config.L2NetworkName) if chainCfg == nil { panic(fmt.Sprintf("network %s not found in superchain registry", config.L2NetworkName)) diff --git a/op-acceptance-tests/tests/tip_mode/README.md b/op-acceptance-tests/tests/tip_mode/README.md new file mode 100644 index 00000000000..b0574f614e8 --- /dev/null +++ b/op-acceptance-tests/tests/tip_mode/README.md @@ -0,0 +1,127 @@ +# Tip Mode Acceptance Tests + +This directory contains acceptance tests for the tip mode feature in op-node. + +## What is Tip Mode? + +Tip mode is a new sync mode where the op-node sources safe/finalized heads from a remote RPC endpoint instead of deriving them from L1. It's designed for resource-constrained nodes that want to sync quickly without performing L1 derivation. + +### Key Characteristics + +- **Disables L1 derivation pipeline** for safe head progression +- **Polls a remote RPC endpoint** for safe/finalized blocks +- **Imports blocks from remote** and promotes them locally +- **CL sync (P2P gossip)** still handles unsafe blocks +- **Lower resource requirements** compared to full derivation + +## Test Structure + +### Test Files + +- `init_test.go` - Test setup and configuration +- `tip_mode_test.go` - Test cases for tip mode functionality + +### Test Cases + +1. **TestTipModeBasicSync** - Verifies basic safe head synchronization + - Tests that tip mode verifier syncs safe heads from sequencer + - Ensures safe head progression matches the sequencer + +2. **TestTipModeFinalizedSync** - Verifies finalized head synchronization + - Tests that tip mode verifier syncs finalized heads from sequencer + - Ensures finalized head progression matches the sequencer + +3. **TestTipModeUnsafeViaP2P** - Verifies P2P gossip still works + - Tests that unsafe blocks are still received via P2P + - Ensures CL sync remains functional in tip mode + +4. **TestTipModeContinuousSync** - Verifies continuous operation + - Tests that tip mode continues to sync over multiple rounds + - Ensures the verifier stays in sync with the sequencer + +## Implementation Details + +### Configuration + +The tip mode tests use a custom system preset that: + +1. Creates a standard single-chain multi-node setup (sequencer + verifier) +2. Configures the verifier to run in tip mode +3. Sets the verifier's remote RPC to the sequencer's RPC endpoint +4. Maintains P2P connections for unsafe block sync + +### Code Organization + +#### Preset Layer (`op-devstack/presets/`) + +- **`tip_mode.go`** - Defines the `TipMode` preset and `NewTipMode()` constructor +- **`cl_config.go`** - Contains `WithTipMode()` option for configuring tip mode + +#### System Layer (`op-devstack/sysgo/`) + +- **`system_tip_mode.go`** - Defines `TipModeSystem()` that creates the test infrastructure +- **`l2_cl.go`** - Extended `L2CLConfig` with `TipModeEnabled` and `TipModeRemoteRPC` fields +- **`l2_cl_opnode.go`** - Modified to use tip mode config from `L2CLConfig` + +### How It Works + +1. **System Creation**: `TipModeSystem()` creates a minimal system with sequencer +2. **Dynamic Configuration**: After sequencer is created, retrieves its RPC URL +3. **Verifier Setup**: Creates verifier CL node with tip mode enabled, pointing to sequencer RPC +4. **P2P Setup**: Connects nodes via P2P for unsafe block gossip +5. **Test Execution**: Tests verify sync behavior across different safety levels + +## Running the Tests + +```bash +# Run all tip mode tests +go test ./op-acceptance-tests/tests/tip_mode/... + +# Run a specific test +go test ./op-acceptance-tests/tests/tip_mode/ -run TestTipModeBasicSync + +# Run with verbose output +go test ./op-acceptance-tests/tests/tip_mode/... -v +``` + +## Design Decisions + +### Why AfterDeploy Hook? + +The system uses `stack.AfterDeploy()` to configure the tip mode verifier because: +- The sequencer must be created first to get its RPC endpoint +- The verifier needs the sequencer's RPC URL for tip mode configuration +- This ensures proper ordering of node creation and configuration + +### Why Keep P2P Connections? + +Even in tip mode, P2P connections are maintained because: +- Unsafe blocks are still received via P2P gossip +- This ensures the node can participate in consensus layer sync +- It provides a more complete syncing experience + +### Configuration Approach + +The implementation supports two configuration methods: +1. **Programmatic**: Via `L2CLConfig` fields (preferred for tests) +2. **Environment Variables**: Via `OP_NODE_ROLLUP_TIP_MODE*` (backward compatible) + +This dual approach ensures: +- Clean, testable code with explicit configuration +- Backward compatibility with existing environment-based setups + +## Future Enhancements + +Potential improvements to these tests: + +1. **Reorg Testing** - Verify behavior during chain reorganizations +2. **Connection Failure** - Test recovery when remote RPC is unavailable +3. **Performance Metrics** - Measure sync speed vs. full derivation +4. **Multiple Verifiers** - Test multiple tip mode nodes syncing from same source +5. **Mixed Mode** - Test systems with both tip and full derivation verifiers + +## Related Files + +- `/root/optimism-2/op-node/rollup/driver/tip_mode.go` - Core tip mode implementation +- `/root/optimism-2/op-node/rollup/driver/driver.go` - Integration with driver +- `/root/optimism-2/op-node/rollup/sync/config.go` - Sync configuration diff --git a/op-acceptance-tests/tests/tip_mode/init_test.go b/op-acceptance-tests/tests/tip_mode/init_test.go new file mode 100644 index 00000000000..db5029b555c --- /dev/null +++ b/op-acceptance-tests/tests/tip_mode/init_test.go @@ -0,0 +1,16 @@ +package tip_mode + +import ( + "testing" + + "github.com/ethereum-optimism/optimism/op-devstack/compat" + "github.com/ethereum-optimism/optimism/op-devstack/presets" +) + +func TestMain(m *testing.M) { + presets.DoMain(m, + presets.WithTipModeSystem(), + presets.WithConsensusLayerSync(), + presets.WithCompatibleTypes(compat.SysGo), + ) +} diff --git a/op-acceptance-tests/tests/tip_mode/tip_mode_test.go b/op-acceptance-tests/tests/tip_mode/tip_mode_test.go new file mode 100644 index 00000000000..21fedd593ce --- /dev/null +++ b/op-acceptance-tests/tests/tip_mode/tip_mode_test.go @@ -0,0 +1,165 @@ +package tip_mode + +import ( + "testing" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +// TestTipModeBasicSync verifies that a tip mode verifier can sync safe and finalized heads +// from the sequencer without running L1 derivation. +func TestTipModeBasicSync(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewTipMode(t) + require := t.Require() + logger := t.Logger() + + logger.Info("Starting tip mode basic sync test") + + // The sequencer should be producing blocks + initialSafeSeq := sys.L2CL.SafeL2BlockRef().Number + logger.Info("Initial sequencer state", "safe", initialSafeSeq) + + // Wait for sequencer to advance safe head by at least 5 blocks + targetDelta := uint64(5) + dsl.CheckAll(t, + sys.L2CL.AdvancedFn(types.LocalSafe, targetDelta, 30), + ) + + newSafeSeq := sys.L2CL.SafeL2BlockRef().Number + logger.Info("Sequencer advanced", "old_safe", initialSafeSeq, "new_safe", newSafeSeq) + require.GreaterOrEqual(newSafeSeq, initialSafeSeq+targetDelta, "sequencer should have advanced safe head") + + // The tip mode verifier should sync to match the sequencer's safe head + // Give it some time to poll and sync + logger.Info("Waiting for tip mode verifier to sync") + sys.L2CLB.Matched(sys.L2CL, types.LocalSafe, 30) + + verifierSafe := sys.L2CLB.SafeL2BlockRef() + logger.Info("Tip mode verifier synced", "safe", verifierSafe.Number, "hash", verifierSafe.Hash) + + // Verify the safe heads match + require.Equal(newSafeSeq, verifierSafe.Number, "tip mode verifier safe head should match sequencer") + require.Equal(sys.L2CL.SafeL2BlockRef().Hash, verifierSafe.Hash, "tip mode verifier safe hash should match sequencer") +} + +// TestTipModeFinalizedSync verifies that tip mode correctly syncs finalized heads. +func TestTipModeFinalizedSync(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewTipMode(t) + require := t.Require() + logger := t.Logger() + + logger.Info("Starting tip mode finalized sync test") + + // Wait for L1 to produce enough blocks for finalization + // L1 needs head > finalizedDistance (20) for any blocks to be finalized + logger.Info("Waiting for L1 to produce sufficient blocks for finalization...") + for i := 0; i < 30; i++ { + l1Head := sys.L1Network.WaitForBlock() + logger.Info("L1 block produced", "number", l1Head.Number) + if l1Head.Number >= 23 { + logger.Info("L1 has sufficient blocks for finalization") + break + } + } + + // Wait for both nodes to advance finalized heads + initialFinSeq := sys.L2CL.HeadBlockRef(types.Finalized).Number + logger.Info("Initial sequencer finalized", "finalized", initialFinSeq) + + // Wait for sequencer to advance finalized by at least 3 blocks + targetDelta := uint64(3) + sys.L2CL.Advanced(types.Finalized, targetDelta, 50) + + newFinSeq := sys.L2CL.HeadBlockRef(types.Finalized).Number + logger.Info("Sequencer finalized advanced", "old_fin", initialFinSeq, "new_fin", newFinSeq) + require.GreaterOrEqual(newFinSeq, initialFinSeq+targetDelta, "sequencer should have advanced finalized head") + + // The tip mode verifier should sync finalized head + logger.Info("Waiting for tip mode verifier to sync finalized") + sys.L2CLB.Matched(sys.L2CL, types.Finalized, 30) + + verifierFin := sys.L2CLB.HeadBlockRef(types.Finalized) + logger.Info("Tip mode verifier finalized synced", "finalized", verifierFin.Number, "hash", verifierFin.Hash) + + // Verify the finalized heads match + require.Equal(newFinSeq, verifierFin.Number, "tip mode verifier finalized head should match sequencer") + require.Equal(sys.L2CL.HeadBlockRef(types.Finalized).Hash, verifierFin.Hash, "tip mode verifier finalized hash should match sequencer") +} + +// TestTipModeUnsafeViaP2P verifies that tip mode nodes still receive unsafe blocks via P2P gossip. +func TestTipModeUnsafeViaP2P(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewTipMode(t) + require := t.Require() + logger := t.Logger() + + logger.Info("Starting tip mode unsafe P2P test") + + // Verify P2P connection between nodes + sys.L2CLB.IsP2PConnected(sys.L2CL) + logger.Info("Tip mode verifier is P2P connected to sequencer") + + // First, wait for the sequencer to produce some blocks and for safe heads to sync + initialSafeSeq := sys.L2CL.SafeL2BlockRef().Number + logger.Info("Initial sequencer safe head", "safe", initialSafeSeq) + + // Wait for sequencer to advance safe head by at least 5 blocks + targetDelta := uint64(5) + dsl.CheckAll(t, + sys.L2CL.AdvancedFn(types.LocalSafe, targetDelta, 30), + ) + + newSafeSeq := sys.L2CL.SafeL2BlockRef().Number + logger.Info("Sequencer advanced safe head", "old_safe", initialSafeSeq, "new_safe", newSafeSeq) + + // Wait for verifier to sync safe head via tip mode RPC + logger.Info("Waiting for verifier to sync safe head") + sys.L2CLB.Matched(sys.L2CL, types.LocalSafe, 60) + logger.Info("Verifier safe head synced") + + // Now verify that unsafe blocks also sync via P2P (alongside safe head progression) + logger.Info("Waiting for verifier unsafe head to sync via P2P") + sys.L2CLB.Matched(sys.L2CL, types.LocalUnsafe, 60) + + verifierUnsafe := sys.L2CLB.HeadBlockRef(types.LocalUnsafe) + seqUnsafe := sys.L2CL.HeadBlockRef(types.LocalUnsafe) + logger.Info("P2P sync complete", "sequencer", seqUnsafe.Number, "verifier", verifierUnsafe.Number) + + // Verify unsafe heads match (P2P sync is working) + require.Equal(seqUnsafe.Number, verifierUnsafe.Number, "tip mode verifier unsafe head should match sequencer via P2P") + require.Equal(seqUnsafe.Hash, verifierUnsafe.Hash, "tip mode verifier unsafe hash should match sequencer") +} + +// TestTipModeContinuousSync verifies that tip mode continues to sync as the sequencer progresses. +func TestTipModeContinuousSync(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewTipMode(t) + require := t.Require() + logger := t.Logger() + + logger.Info("Starting tip mode continuous sync test") + + // Perform multiple rounds of sync to verify continuous operation + for i := 0; i < 3; i++ { + logger.Info("Continuous sync round", "round", i+1) + + // Wait for sequencer to advance + sys.L2CL.Advanced(types.LocalSafe, 2, 30) + + // Verify tip mode verifier keeps up + sys.L2CLB.Matched(sys.L2CL, types.LocalSafe, 30) + + seqSafe := sys.L2CL.SafeL2BlockRef() + verSafe := sys.L2CLB.SafeL2BlockRef() + logger.Info("Sync round complete", "round", i+1, "seq_safe", seqSafe.Number, "ver_safe", verSafe.Number) + + require.Equal(seqSafe.Hash, verSafe.Hash, "tip mode verifier should stay synced with sequencer") + } + + logger.Info("Continuous sync test completed successfully") +} diff --git a/op-devstack/presets/cl_config.go b/op-devstack/presets/cl_config.go index d56cbe4d48f..3d446b7d992 100644 --- a/op-devstack/presets/cl_config.go +++ b/op-devstack/presets/cl_config.go @@ -31,3 +31,16 @@ func WithSafeDBEnabled() stack.CommonOption { cfg.SafeDBPath = p.TempDir() }))) } + +// WithTipMode configures verifier nodes to run in tip mode, sourcing safe/finalized heads from a remote RPC. +// The remoteRPC parameter should be the RPC endpoint URL to source blocks from (typically the sequencer's RPC). +// This function should be used with a specific node ID to configure only that node. +func WithTipMode(remoteRPC string) sysgo.L2CLOption { + return sysgo.L2CLOptionFn(func(p devtest.P, id stack.L2CLNodeID, cfg *sysgo.L2CLConfig) { + // Only enable tip mode on verifiers, not sequencers + if !cfg.IsSequencer { + cfg.TipModeEnabled = true + cfg.TipModeRemoteRPC = remoteRPC + } + }) +} diff --git a/op-devstack/presets/tip_mode.go b/op-devstack/presets/tip_mode.go new file mode 100644 index 00000000000..927877d69d1 --- /dev/null +++ b/op-devstack/presets/tip_mode.go @@ -0,0 +1,53 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/shim" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-devstack/stack/match" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +// TipMode contains a single-chain multi-node setup where one verifier runs in tip mode +type TipMode struct { + Minimal + + // L2ELB is the verifier EL node + L2ELB *dsl.L2ELNode + // L2CLB is the verifier CL node running in tip mode + L2CLB *dsl.L2CLNode +} + +func WithTipModeSystem() stack.CommonOption { + return stack.MakeCommon(sysgo.TipModeSystem(&sysgo.DefaultSingleChainMultiNodeSystemIDs{})) +} + +func NewTipMode(t devtest.T) *TipMode { + system := shim.NewSystem(t) + orch := Orchestrator() + orch.Hydrate(system) + minimal := minimalFromSystem(t, system, orch) + l2 := system.L2Network(match.Assume(t, match.L2ChainA)) + verifierCL := l2.L2CLNode(match.Assume(t, + match.And( + match.Not(match.WithSequencerActive(t.Ctx())), + match.Not[stack.L2CLNodeID, stack.L2CLNode](minimal.L2CL.ID()), + ))) + verifierEL := l2.L2ELNode(match.Assume(t, + match.And( + match.EngineFor(verifierCL), + match.Not[stack.L2ELNodeID, stack.L2ELNode](minimal.L2EL.ID())))) + preset := &TipMode{ + Minimal: *minimal, + L2ELB: dsl.NewL2ELNode(verifierEL, orch.ControlPlane()), + L2CLB: dsl.NewL2CLNode(verifierCL, orch.ControlPlane()), + } + // Ensure the tip mode follower node is in sync with the sequencer before starting tests + dsl.CheckAll(t, + preset.L2CLB.MatchedFn(preset.L2CL, types.LocalSafe, 30), + preset.L2CLB.MatchedFn(preset.L2CL, types.Finalized, 30), + ) + return preset +} diff --git a/op-devstack/sysgo/l2_cl.go b/op-devstack/sysgo/l2_cl.go index a7f3a4366dc..58299d0ec3c 100644 --- a/op-devstack/sysgo/l2_cl.go +++ b/op-devstack/sysgo/l2_cl.go @@ -27,6 +27,11 @@ type L2CLConfig struct { IsSequencer bool IndexingMode bool + + // TipModeEnabled disables L1 derivation and sources safe/finalized heads from an external RPC + TipModeEnabled bool + // TipModeRemoteRPC is the RPC endpoint to source safe/finalized heads from when TipModeEnabled is true + TipModeRemoteRPC string } func L2CLSequencer() L2CLOption { diff --git a/op-devstack/sysgo/l2_cl_opnode.go b/op-devstack/sysgo/l2_cl_opnode.go index 9fab9be3ca0..a3bf7cbdd54 100644 --- a/op-devstack/sysgo/l2_cl_opnode.go +++ b/op-devstack/sysgo/l2_cl_opnode.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "flag" "fmt" + "os" "sync" "time" @@ -241,6 +242,23 @@ func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L // Get the L2 engine address from the EL node (which can be a regular EL node or a SyncTesterEL) l2EngineAddr := l2EL.EngineRPC() + // Check for tip mode configuration (from L2CLConfig or environment variables for backward compatibility) + tipModeEnabled := cfg.TipModeEnabled + tipModeRPC := cfg.TipModeRemoteRPC + + // Fall back to environment variables if not configured via L2CLConfig + if !tipModeEnabled && os.Getenv("OP_NODE_ROLLUP_TIP_MODE") == "true" { + tipModeEnabled = true + tipModeRPC = os.Getenv("OP_NODE_ROLLUP_TIP_MODE_RPC") + } + + if tipModeEnabled { + if tipModeRPC == "" { + p.Require().FailNow("Tip mode enabled but TipModeRemoteRPC not set") + } + logger.Info("Tip mode enabled for op-node", "remote_rpc", tipModeRPC) + } + nodeCfg := &config.Config{ L1: &config.L1EndpointConfig{ L1NodeAddr: l1EL.userRPC, @@ -260,8 +278,10 @@ func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L BeaconAddr: l1CL.beaconHTTPAddr, }, Driver: driver.Config{ - SequencerEnabled: cfg.IsSequencer, - SequencerConfDepth: 2, + SequencerEnabled: cfg.IsSequencer, + SequencerConfDepth: 2, + TipModeEnabled: tipModeEnabled, + TipModeRPC: tipModeRPC, }, Rollup: *l2Net.rollupCfg, DependencySet: depSet, @@ -282,6 +302,7 @@ func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L SyncMode: syncMode, SkipSyncStartCheck: false, SupportsPostFinalizationELSync: false, + TipModeEnabled: tipModeEnabled, }, ConfigPersistence: config.DisabledConfigPersistence{}, Metrics: opmetrics.CLIConfig{}, diff --git a/op-devstack/sysgo/system_tip_mode.go b/op-devstack/sysgo/system_tip_mode.go new file mode 100644 index 00000000000..3342e60d95d --- /dev/null +++ b/op-devstack/sysgo/system_tip_mode.go @@ -0,0 +1,86 @@ +package sysgo + +import ( + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/stack" +) + +// TipModeSystem creates a single-chain multi-node system where one verifier runs in tip mode, +// sourcing safe/finalized heads from the sequencer's RPC endpoint. +// Note: This system does NOT include a challenger because tip mode nodes do not maintain +// a safe head database, which the challenger requires for dispute game verification. +func TipModeSystem(dest *DefaultSingleChainMultiNodeSystemIDs) stack.Option[*Orchestrator] { + ids := NewDefaultSingleChainMultiNodeSystemIDs(DefaultL1ID, DefaultL2AID) + + opt := stack.Combine[*Orchestrator]() + + // Build a minimal system without the challenger (since tip mode doesn't support safe head DB) + opt.Add(stack.BeforeDeploy(func(o *Orchestrator) { + o.P().Logger().Info("Setting up tip mode system (no challenger)") + })) + + opt.Add(WithMnemonicKeys(devkeys.TestMnemonic)) + + opt.Add(WithDeployer(), + WithDeployerOptions( + WithLocalContractSources(), + WithCommons(ids.L1.ChainID()), + WithPrefundedL2(ids.L1.ChainID(), ids.L2.ChainID()), + ), + ) + + opt.Add(WithL1Nodes(ids.L1EL, ids.L1CL)) + + opt.Add(WithL2ELNode(ids.L2EL)) + opt.Add(WithL2CLNode(ids.L2CL, ids.L1CL, ids.L1EL, ids.L2EL, L2CLSequencer())) + + opt.Add(WithBatcher(ids.L2Batcher, ids.L1EL, ids.L2CL, ids.L2EL)) + opt.Add(WithProposer(ids.L2Proposer, ids.L1EL, &ids.L2CL, nil)) + + opt.Add(WithFaucets([]stack.L1ELNodeID{ids.L1EL}, []stack.L2ELNodeID{ids.L2EL})) + + opt.Add(WithTestSequencer(ids.TestSequencer, ids.L1CL, ids.L2CL, ids.L1EL, ids.L2EL)) + + // NOTE: Challenger is intentionally omitted because it queries the safe head database, + // which is disabled in tip mode. This was causing 1350+ error logs per test run. + + // Add verifier EL node + opt.Add(WithL2ELNode(ids.L2ELB)) + + // Add verifier CL node with tip mode configuration + // We use AfterDeploy to configure tip mode after the sequencer is available + opt.Add(stack.AfterDeploy(func(orch *Orchestrator) { + // Get the sequencer's EL RPC endpoint (not CL) + // Tip mode needs to fetch blocks via eth_getBlockByNumber which is only available on the EL + sequencerEL, ok := orch.l2ELs.Get(ids.L2EL) + orch.P().Require().True(ok, "sequencer EL node required for tip mode") + + sequencerRPC := sequencerEL.UserRPC() + orch.P().Logger().Info("Configuring tip mode verifier", "sequencer_rpc", sequencerRPC) + + // Create the verifier with tip mode enabled using the sequencer's EL RPC + stack.ApplyOptionLifecycle(WithL2CLNode(ids.L2CLB, ids.L1CL, ids.L1EL, ids.L2ELB, WithTipModeOption(sequencerRPC)), orch) + })) + + // P2P connect L2CL nodes (for unsafe block sync via P2P) + opt.Add(WithL2CLP2PConnection(ids.L2CL, ids.L2CLB)) + opt.Add(WithL2ELP2PConnection(ids.L2EL, ids.L2ELB)) + + opt.Add(stack.Finally(func(orch *Orchestrator) { + *dest = ids + })) + return opt +} + +// WithTipModeOption creates an L2CLOption that enables tip mode with the given remote RPC +func WithTipModeOption(remoteRPC string) L2CLOption { + return L2CLOptionFn(func(p devtest.P, id stack.L2CLNodeID, cfg *L2CLConfig) { + // Only enable tip mode on verifiers + if !cfg.IsSequencer { + cfg.TipModeEnabled = true + cfg.TipModeRemoteRPC = remoteRPC + p.Logger().Info("Tip mode configured for node", "node_id", id, "remote_rpc", remoteRPC) + } + }) +} diff --git a/op-node/flags/flags.go b/op-node/flags/flags.go index e032e14c439..353cfe29e91 100644 --- a/op-node/flags/flags.go +++ b/op-node/flags/flags.go @@ -299,6 +299,20 @@ var ( EnvVars: prefixEnvVars("ROLLUP_LOAD_PROTOCOL_VERSIONS"), Category: RollupCategory, } + /* Tip Mode Flags */ + TipModeEnabled = &cli.BoolFlag{ + Name: "rollup.tip-mode", + Usage: "Enable tip mode: disables L1 derivation and sources safe/finalized heads from external RPC", + EnvVars: prefixEnvVars("ROLLUP_TIP_MODE"), + Value: false, + Category: RollupCategory, + } + TipModeRPC = &cli.StringFlag{ + Name: "rollup.tip-mode-rpc", + Usage: "External L2 RPC endpoint to query for safe/finalized heads in tip mode (required if tip-mode is enabled)", + EnvVars: prefixEnvVars("ROLLUP_TIP_MODE_RPC"), + Category: RollupCategory, + } SafeDBPath = &cli.StringFlag{ Name: "safedb.path", Usage: "File path used to persist safe head update data. Disabled if not set.", @@ -452,6 +466,8 @@ var optionalFlags = []cli.Flag{ HeartbeatURLFlag, RollupHalt, RollupLoadProtocolVersions, + TipModeEnabled, + TipModeRPC, ConductorEnabledFlag, ConductorRpcFlag, ConductorRpcTimeoutFlag, diff --git a/op-node/rollup/driver/config.go b/op-node/rollup/driver/config.go index 5446e4da16a..da903af5d49 100644 --- a/op-node/rollup/driver/config.go +++ b/op-node/rollup/driver/config.go @@ -24,4 +24,13 @@ type Config struct { // RecoverMode forces the sequencer to select the next L1 Origin exactly, and create an empty block, // to be compatible with verifiers forcefully generating the same block while catching up the sequencing window timeout. RecoverMode bool `json:"recover_mode"` + + // TipModeEnabled disables L1 derivation and sources safe/finalized heads from an external RPC. + // When enabled, the node will not perform derivation and will instead poll the remote RPC + // for safe and finalized block heads. + TipModeEnabled bool `json:"tip_mode_enabled"` + + // TipModeRPC is the remote execution client RPC endpoint to query for safe/finalized heads. + // Only used when TipModeEnabled is true. + TipModeRPC string `json:"tip_mode_rpc"` } diff --git a/op-node/rollup/driver/driver.go b/op-node/rollup/driver/driver.go index 0d746fbca78..f941226df1a 100644 --- a/op-node/rollup/driver/driver.go +++ b/op-node/rollup/driver/driver.go @@ -23,8 +23,10 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup/sequencing" "github.com/ethereum-optimism/optimism/op-node/rollup/status" "github.com/ethereum-optimism/optimism/op-node/rollup/sync" + "github.com/ethereum-optimism/optimism/op-service/client" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/event" + "github.com/ethereum-optimism/optimism/op-service/sources" ) // NewDriver composes an events handler that tracks L1 state, triggers L2 Derivation, and optionally sequences new L2 blocks. @@ -132,6 +134,40 @@ func NewDriver( sequencer = sequencing.DisabledSequencer{} } + // Initialize tip mode sync if enabled + var tipModeSync *TipModeSync + if syncCfg.TipModeEnabled { + if driverCfg.TipModeRPC == "" { + log.Crit("Tip mode enabled but no remote RPC endpoint configured") + } + + log.Info("Initializing tip mode sync", "remote_rpc", driverCfg.TipModeRPC) + + // Create remote L2 client (no JWT needed for external RPCs) + remoteRPC, err := client.NewRPC(driverCtx, log, driverCfg.TipModeRPC) + if err != nil { + log.Crit("Failed to create remote RPC client for tip mode", "err", err) + } + + // Create L2 client for the remote endpoint + // Use nil metrics since we don't need caching metrics for remote client + remoteClientCfg := sources.L2ClientDefaultConfig(cfg, true) + remoteL2Client, err := sources.NewL2Client(remoteRPC, log, nil, remoteClientCfg) + if err != nil { + log.Crit("Failed to create remote L2 client for tip mode", "err", err) + } + + tipModeSync = NewTipModeSync( + driverCtx, + log, + cfg, + remoteL2Client, // remoteEL + l2, // localEL + ec, // engine + ) + syncDeriver.TipModeSync = tipModeSync + } + driverEmitter := sys.Register("driver", nil) driver := &Driver{ StatusTracker: statusTracker, @@ -149,6 +185,7 @@ func NewDriver( sequencer: sequencer, metrics: metrics, altSync: altSync, + tipModeSync: tipModeSync, } return driver @@ -181,6 +218,9 @@ type Driver struct { sequencer sequencing.SequencerIface + // Tip mode sync component (nil if not in tip mode) + tipModeSync *TipModeSync + metrics Metrics log log.Logger @@ -195,6 +235,7 @@ type Driver struct { func (s *Driver) Start() error { log.Info("Starting driver", "sequencerEnabled", s.driverConfig.SequencerEnabled, "sequencerStopped", s.driverConfig.SequencerStopped, "recoverMode", s.driverConfig.RecoverMode) + if s.driverConfig.SequencerEnabled { if s.driverConfig.RecoverMode { log.Warn("sequencer is in recover mode") @@ -229,6 +270,13 @@ func (s *Driver) eventLoop() { defer s.driverCancel() + // In tip mode, emit a reset event at startup to properly initialize forkchoice state. + // This triggers FindL2Heads which handles the case where unsafe < finalized. + if s.tipModeSync != nil { + s.log.Info("Tip mode: requesting initial engine reset to establish forkchoice state") + s.emitter.Emit(s.driverCtx, engine.ResetEngineRequestEvent{}) + } + // reqStep requests a derivation step nicely, with a delay if this is a reattempt, or not at all if we already scheduled a reattempt. reqStep := func() { s.sched.RequestStep(s.driverCtx, false) diff --git a/op-node/rollup/driver/interfaces.go b/op-node/rollup/driver/interfaces.go index 4a826883c36..1cf290e1dfc 100644 --- a/op-node/rollup/driver/interfaces.go +++ b/op-node/rollup/driver/interfaces.go @@ -51,6 +51,7 @@ type L2Chain interface { L2BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L2BlockRef, error) L2BlockRefByHash(ctx context.Context, l2Hash common.Hash) (eth.L2BlockRef, error) L2BlockRefByNumber(ctx context.Context, num uint64) (eth.L2BlockRef, error) + L2BlockRefByNumberHeaderOnly(ctx context.Context, num uint64) (eth.L2BlockRef, error) } type DerivationPipeline interface { diff --git a/op-node/rollup/driver/sync_deriver.go b/op-node/rollup/driver/sync_deriver.go index 8d0122cb8db..037b8a2324c 100644 --- a/op-node/rollup/driver/sync_deriver.go +++ b/op-node/rollup/driver/sync_deriver.go @@ -50,6 +50,9 @@ type SyncDeriver struct { ManagedBySupervisor bool StepDeriver StepDeriver + + // TipModeSync handles safe/finalized head progression in tip mode + TipModeSync *TipModeSync } func (s *SyncDeriver) AttachEmitter(em event.Emitter) { @@ -235,6 +238,24 @@ func (s *SyncDeriver) SyncStep() { return } + if s.SyncCfg.TipModeEnabled { + // In tip mode, safe head progression comes from TipModeSync polling a remote RPC, + // not from L1 derivation. + if s.TipModeSync != nil { + madeProgress, err := s.TipModeSync.SyncStep() + if err != nil { + s.Log.Error("Tip mode sync step failed", "err", err) + } else if madeProgress { + // Only request immediate next step if we actually synced new blocks + // This prevents CPU starvation of P2P unsafe block processing + s.StepDeriver.ResetStepBackoff(s.Ctx) + s.StepDeriver.RequestStep(s.Ctx, true) + } + // If no progress, let normal backoff happen to give P2P time to process + } + return + } + // Any now processed forkchoice updates will trigger CL-sync payload processing, if any payload is queued up. // Since we don't force attributes to be processed at this point, diff --git a/op-node/rollup/driver/tip_mode.go b/op-node/rollup/driver/tip_mode.go new file mode 100644 index 00000000000..49be75d79a9 --- /dev/null +++ b/op-node/rollup/driver/tip_mode.go @@ -0,0 +1,283 @@ +package driver + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// L2Source is the interface for querying L2 blocks and payloads from remote/local EL +type L2Source interface { + L2BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L2BlockRef, error) + L2BlockRefByNumber(ctx context.Context, num uint64) (eth.L2BlockRef, error) + L2BlockRefByNumberHeaderOnly(ctx context.Context, num uint64) (eth.L2BlockRef, error) + PayloadByNumber(ctx context.Context, number uint64) (*eth.ExecutionPayloadEnvelope, error) +} + +// EngineCtrl provides methods to update heads +type EngineCtrl interface { + InsertUnsafePayload(ctx context.Context, envelope *eth.ExecutionPayloadEnvelope, ref eth.L2BlockRef) error + PromoteSafe(ctx context.Context, ref eth.L2BlockRef, source eth.L1BlockRef) + PromoteFinalized(ctx context.Context, ref eth.L2BlockRef) + SafeL2Head() eth.L2BlockRef + Finalized() eth.L2BlockRef +} + +// TipModeSync handles safe/finalized head progression by polling an external RPC +type TipModeSync struct { + log log.Logger + ctx context.Context + remoteEL L2Source + localEL L2Source + engine EngineCtrl + cfg *rollup.Config +} + +// NewTipModeSync creates a new tip mode sync component +func NewTipModeSync( + ctx context.Context, + log log.Logger, + cfg *rollup.Config, + remoteEL L2Source, + localEL L2Source, + eng EngineCtrl, +) *TipModeSync { + return &TipModeSync{ + log: log, + ctx: ctx, + remoteEL: remoteEL, + localEL: localEL, + engine: eng, + cfg: cfg, + } +} + +// SyncStep performs one iteration of sync by polling the remote RPC for safe/finalized heads. +// Returns (madeProgress, error) where madeProgress indicates if we actually synced new blocks. +func (lm *TipModeSync) SyncStep() (bool, error) { + madeProgress := false + + // Step 1: Update finalized head (may also catch up safe head) + finalizedProgress, err := lm.updateFinalized() + if err != nil { + return false, fmt.Errorf("failed to update finalized head: %w", err) + } + madeProgress = madeProgress || finalizedProgress + + // Step 2: Find and import next safe block + safeProgress, err := lm.findAndImportNextSafe() + if err != nil { + return false, fmt.Errorf("failed to import next safe block: %w", err) + } + madeProgress = madeProgress || safeProgress + + // Note: Unsafe head is managed by CL sync (P2P gossip) and safe head promotion + + return madeProgress, nil +} +// findAndImportNextSafe walks backward from remote safe to find where it connects to our chain. +// Returns (madeProgress, error) where madeProgress indicates if we imported a new block. +func (lm *TipModeSync) findAndImportNextSafe() (bool, error) { + localSafe := lm.engine.SafeL2Head() + localFinalized := lm.engine.Finalized() + + // Fetch what the remote considers safe + remoteSafe, err := lm.remoteEL.L2BlockRefByLabel(lm.ctx, eth.Safe) + if err != nil { + return false, fmt.Errorf("failed to fetch remote safe head: %w", err) + } + + // Don't move safe below finalized + if remoteSafe.Number < localFinalized.Number { + return false, fmt.Errorf("remote safe head is behind local finalized head (remote_safe=%d, local_finalized=%d)", + remoteSafe.Number, localFinalized.Number) + } + + // If we're already at the target, nothing to do + if localSafe.Number == remoteSafe.Number && localSafe.Hash == remoteSafe.Hash { + return false, nil + } + + // Determine starting point for backward walk + var startNum uint64 + if remoteSafe.Number < localSafe.Number { + // Reorg case: remote is behind, start from remoteSafe + startNum = remoteSafe.Number + } else { + // Forward progress: remote is ahead, start from next block + // This avoids walking backward through hundreds of blocks + startNum = localSafe.Number + 1 + } + + // Walk backward from startNum until we find where it connects to our chain + for currentNum := startNum; currentNum > localFinalized.Number; currentNum-- { + // Fetch the remote block header (optimization: no full transaction data) + remoteBlock, err := lm.remoteEL.L2BlockRefByNumberHeaderOnly(lm.ctx, currentNum) + if err != nil { + continue // Remote block unavailable, keep walking back + } + + // Get the parent block number + parentNum := currentNum - 1 + if parentNum < localFinalized.Number { + return false, fmt.Errorf("remote safe chain diverged below finalized (at block %d)", currentNum) + } + + // Get the parent block hash from our local chain + // We should have all blocks between finalized and safe, so if we don't find it, that's an error + localParentHash, found := lm.getLocalBlockHash(parentNum) + if !found { + return false, fmt.Errorf("missing local block %d during safe head sync (safe=%d, finalized=%d)", + parentNum, localSafe.Number, localFinalized.Number) + } + + // Check if this remote block builds on our local parent + if remoteBlock.ParentHash == localParentHash { + // Found the connection point! Now fetch full block data and insert + remoteBlockRef, err := lm.remoteEL.L2BlockRefByNumber(lm.ctx, currentNum) + if err != nil { + return false, fmt.Errorf("failed to fetch full block data for insertion: %w", err) + } + if err := lm.insertAndPromoteBlock(currentNum, remoteBlockRef); err != nil { + return false, err + } + return true, nil // Successfully imported a block + } + // Hash mismatch - keep walking back + } + + // Walked all the way back to finalized without finding connection + return false, fmt.Errorf("remote safe chain diverged from local chain above finalized") +} + +// getLocalBlockHash returns the hash of a local block by number. +// It checks in-memory heads first (safe, finalized) before querying the local EL. +// Returns (hash, true) if found locally, (zero, false) if not found. +func (lm *TipModeSync) getLocalBlockHash(num uint64) (common.Hash, bool) { + localSafe := lm.engine.SafeL2Head() + localFinalized := lm.engine.Finalized() + + // Fast path: check in-memory heads first + if num == localSafe.Number { + return localSafe.Hash, true + } + if num == localFinalized.Number { + return localFinalized.Hash, true + } + + // Slow path: block is between finalized and safe, fetch from local EL + block, err := lm.localEL.L2BlockRefByNumberHeaderOnly(lm.ctx, num) + if err == nil { + return block.Hash, true + } + + return common.Hash{}, false +} + +// insertAndPromoteBlock fetches the payload and inserts it via the engine +func (lm *TipModeSync) insertAndPromoteBlock(blockNum uint64, blockRef eth.L2BlockRef) error { + // Check if we already have this block locally (via P2P or previous sync) + localBlock, err := lm.localEL.L2BlockRefByNumber(lm.ctx, blockNum) + needsInsertion := err != nil || localBlock.Hash != blockRef.Hash + + if needsInsertion { + // Block doesn't exist locally or has wrong hash - fetch and insert it + payload, err := lm.remoteEL.PayloadByNumber(lm.ctx, blockNum) + if err != nil { + return fmt.Errorf("failed to fetch payload for block %d: %w", blockNum, err) + } + + // Insert the payload as unsafe first + if err := lm.engine.InsertUnsafePayload(lm.ctx, payload, blockRef); err != nil { + return fmt.Errorf("failed to insert payload for block %d: %w", blockNum, err) + } + + lm.log.Info("Tip mode: inserted payload", + "number", blockRef.Number, + "hash", blockRef.Hash, + ) + } else { + lm.log.Debug("Tip mode: block already exists locally (likely from P2P), skipping insertion", + "number", blockRef.Number, + "hash", blockRef.Hash, + ) + } + + // Promote to safe (dummy L1 origin for tip mode) + lm.engine.PromoteSafe(lm.ctx, blockRef, eth.L1BlockRef{}) + + lm.log.Info("Tip mode: promoted block to safe", + "number", blockRef.Number, + "hash", blockRef.Hash, + ) + + return nil +} + +// updateFinalized updates the finalized head if remote is ahead. +// Returns (madeProgress, error) where madeProgress indicates if we updated finalized. +func (lm *TipModeSync) updateFinalized() (bool, error) { + // Fetch remote finalized head + remoteFin, err := lm.remoteEL.L2BlockRefByLabel(lm.ctx, eth.Finalized) + if err != nil { + return false, fmt.Errorf("failed to fetch remote finalized head: %w", err) + } + + localFin := lm.engine.Finalized() + + // Only update if remote is ahead + if remoteFin.Number <= localFin.Number { + return false, nil + } + + // Fetch local block at remote finalized number + localBlock, err := lm.localEL.L2BlockRefByNumber(lm.ctx, remoteFin.Number) + if err != nil { + // Block doesn't exist locally yet - will be imported by safe head progression + return false, nil + } + + // Check if hashes match + if localBlock.Hash != remoteFin.Hash { + lm.log.Warn("Finalized hash mismatch - reorg detected", + "number", remoteFin.Number, + "local_hash", localBlock.Hash, + "remote_hash", remoteFin.Hash, + ) + // Don't promote - will resolve as safe head progresses + return false, nil + } + + // Promote to finalized (no error return) + lm.engine.PromoteFinalized(lm.ctx, remoteFin) + + lm.log.Info("Tip mode: promoted block to finalized", + "number", remoteFin.Number, + "hash", remoteFin.Hash, + ) + + madeProgress := true + + // If safe head is behind finalized after this update, catch it up + // This can happen when finalized jumps ahead (e.g., epoch boundary) + // while safe is still progressing block-by-block + localSafe := lm.engine.SafeL2Head() + if localSafe.Number < remoteFin.Number { + lm.log.Info("Tip mode: catching up safe head to finalized", + "old_safe", localSafe.Number, + "finalized", remoteFin.Number) + + // Promote safe to match finalized + lm.engine.PromoteSafe(lm.ctx, remoteFin, eth.L1BlockRef{}) + lm.log.Info("Tip mode: promoted safe head to match finalized", + "number", remoteFin.Number, + "hash", remoteFin.Hash) + } + + return madeProgress, nil +} diff --git a/op-node/rollup/sync/config.go b/op-node/rollup/sync/config.go index 4e36092aaaf..f4c72a91084 100644 --- a/op-node/rollup/sync/config.go +++ b/op-node/rollup/sync/config.go @@ -72,4 +72,7 @@ type Config struct { SkipSyncStartCheck bool `json:"skip_sync_start_check"` SupportsPostFinalizationELSync bool `json:"supports_post_finalization_elsync"` + + // TipModeEnabled disables derivation and sources safe/finalized heads from an external RPC. + TipModeEnabled bool `json:"tip_mode_enabled"` } diff --git a/op-node/service.go b/op-node/service.go index b27503f33a7..a6ab8f1dea4 100644 --- a/op-node/service.go +++ b/op-node/service.go @@ -194,12 +194,14 @@ func NewConfigPersistence(ctx *cli.Context) config.ConfigPersistence { func NewDriverConfig(ctx *cli.Context) *driver.Config { return &driver.Config{ - VerifierConfDepth: ctx.Uint64(flags.VerifierL1Confs.Name), - SequencerConfDepth: ctx.Uint64(flags.SequencerL1Confs.Name), - SequencerEnabled: ctx.Bool(flags.SequencerEnabledFlag.Name), - SequencerStopped: ctx.Bool(flags.SequencerStoppedFlag.Name), - SequencerMaxSafeLag: ctx.Uint64(flags.SequencerMaxSafeLagFlag.Name), - RecoverMode: ctx.Bool(flags.SequencerRecoverMode.Name), + VerifierConfDepth: ctx.Uint64(flags.VerifierL1Confs.Name), + SequencerConfDepth: ctx.Uint64(flags.SequencerL1Confs.Name), + SequencerEnabled: ctx.Bool(flags.SequencerEnabledFlag.Name), + SequencerStopped: ctx.Bool(flags.SequencerStoppedFlag.Name), + SequencerMaxSafeLag: ctx.Uint64(flags.SequencerMaxSafeLagFlag.Name), + RecoverMode: ctx.Bool(flags.SequencerRecoverMode.Name), + TipModeEnabled: ctx.Bool(flags.TipModeEnabled.Name), + TipModeRPC: ctx.String(flags.TipModeRPC.Name), } } @@ -314,6 +316,7 @@ func NewSyncConfig(ctx *cli.Context, log log.Logger) (*sync.Config, error) { SyncMode: mode, SkipSyncStartCheck: ctx.Bool(flags.SkipSyncStartCheck.Name), SupportsPostFinalizationELSync: engineKind.SupportsPostFinalizationELSync(), + TipModeEnabled: ctx.Bool(flags.TipModeEnabled.Name), } if ctx.Bool(flags.L2EngineSyncEnabled.Name) { cfg.SyncMode = sync.ELSync diff --git a/op-service/sources/l2_client.go b/op-service/sources/l2_client.go index 616b5d9facf..44a8538ced0 100644 --- a/op-service/sources/l2_client.go +++ b/op-service/sources/l2_client.go @@ -135,6 +135,31 @@ func (s *L2Client) L2BlockRefByNumber(ctx context.Context, num uint64) (eth.L2Bl return ref, nil } +// L2BlockRefByNumberHeaderOnly returns the [eth.L2BlockRef] for the given block number, +// fetching only the block header (without full transaction data) for efficiency. +// This is useful for verification where only hash/parentHash/number are needed. +// Note: This returns a partial L2BlockRef with empty L1Origin and SequenceNumber fields. +func (s *L2Client) L2BlockRefByNumberHeaderOnly(ctx context.Context, num uint64) (eth.L2BlockRef, error) { + // Fetch block info (header) without full transaction data + info, err := s.InfoByNumber(ctx, num) + if err != nil { + return eth.L2BlockRef{}, fmt.Errorf("failed to fetch block header at height %v: %w", num, err) + } + + // Construct a minimal L2BlockRef with only hash/parentHash/number + // L1Origin and SequenceNumber are left as zero values since we don't have tx data + ref := eth.L2BlockRef{ + Hash: info.Hash(), + Number: info.NumberU64(), + ParentHash: info.ParentHash(), + Time: info.Time(), + // L1Origin and SequenceNumber require parsing the first transaction + // which is not available in header-only mode + } + + return ref, nil +} + // L2BlockRefByHash returns the [eth.L2BlockRef] for the given block hash. // The returned BlockRef may not be in the canonical chain. func (s *L2Client) L2BlockRefByHash(ctx context.Context, hash common.Hash) (eth.L2BlockRef, error) {