Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e583288
wiring for l2.follow.source
pcw109550 Nov 24, 2025
4a93d15
Follow Safe without handling external safe > local unsafe
pcw109550 Nov 24, 2025
b20020a
safe follow
pcw109550 Nov 25, 2025
b6658b3
safe follow consider unsafe gap EL Sync
pcw109550 Nov 25, 2025
75b721b
follow finalized
pcw109550 Nov 25, 2025
85170b0
Comments
pcw109550 Nov 25, 2025
94bcfef
cleanup
pcw109550 Nov 25, 2025
6ec3dc5
drop unused interface methods
pcw109550 Nov 25, 2025
84ee669
sanity check external finalized
pcw109550 Nov 25, 2025
12be36d
Adjust follow source log level
pcw109550 Nov 25, 2025
555d1e9
op-devstack: Follow Source Support
pcw109550 Nov 26, 2025
6c2ec4c
op-acceptance-tests: Follow Source
pcw109550 Nov 26, 2025
6a8d5c2
op-devstack: Follow Source Support
pcw109550 Nov 26, 2025
8910c6b
simplify labeling
pcw109550 Nov 26, 2025
8052175
l1 reorg protection: reset
pcw109550 Nov 26, 2025
d1b80cf
add reorg tc
pcw109550 Nov 26, 2025
8c70d91
typo
pcw109550 Nov 26, 2025
c8397cc
follow source: check unsafe
pcw109550 Nov 28, 2025
1c33795
convention
pcw109550 Nov 28, 2025
28397c0
Add unsafe only reorg test
pcw109550 Nov 28, 2025
3979a79
devstack/acceptance test : rename FollowSource to FollowL2
pcw109550 Nov 28, 2025
40fe3d7
follow upstream source enabled when derivation disabled, reorg detection
pcw109550 Nov 28, 2025
f63d24a
fix unsafe only reorg sync test comments
pcw109550 Nov 28, 2025
4c68fde
rm unused interface method
pcw109550 Nov 29, 2025
05aa278
dsl
pcw109550 Nov 30, 2025
3d3539f
devstack support for ext sync test + follow l2
pcw109550 Nov 30, 2025
20a5b2b
op-acceptance-tests: Follow L2 using Ext + SyncTester
pcw109550 Nov 30, 2025
2132fb3
use blockref
pcw109550 Nov 30, 2025
4bb9301
fix log msg err
pcw109550 Dec 2, 2025
f17bedd
Fix composite interface naming
pcw109550 Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions op-acceptance-tests/tests/sync/follow_l2/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package follow_l2

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.WithSingleChainTwoVerifiersFollowL2(),
presets.WithReqRespSyncDisabled(),
presets.WithNoDiscovery(),
presets.WithCompatibleTypes(compat.SysGo),
presets.WithUnsafeOnly(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Does WithUnsafeOnly only modify one of the two Verifiers, then?

Copy link
Member Author

Choose a reason for hiding this comment

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

So WithUnsafeOnly modifies every op-node CLs to use unsafe only.

func WithUnsafeOnly() stack.CommonOption {
	return stack.MakeCommon(
		sysgo.WithGlobalL2CLOption(sysgo.L2CLOptionFn(
			func(_ devtest.P, id stack.L2CLNodeID, cfg *sysgo.L2CLConfig) {
				cfg.SequencerUnsafeOnly = true
				cfg.VerifierUnsafeOnly = true
			})))
}

This is a global CL option and applies to every CL. However if every CL does not do derivation, there is no safe source. Therefore at least we need a single CL that does actual derivation.

To make this preset, I implemented DefaultSingleChainTwoVerifiersFollowL2System at

func DefaultSingleChainTwoVerifiersFollowL2System(dest *DefaultSingleChainTwoVerifiersSystemIDs) stack.Option[*Orchestrator] {
with the first verifier as
// Specific options are applied after global options
// this means unsafeOnly is always disabled for the first verifier
opt.Add(WithL2CLNode(ids.L2CLB, ids.L1CL, ids.L1EL, ids.L2ELB, L2CLVerifierDisableUnsafeOnly()))

disabling unsafe only to perform derivation. This works because global option (WithUnsafeOnly) applies first and the node specific option(L2CLVerifierDisableUnsafeOnly()) applies after.

)
}
191 changes: 191 additions & 0 deletions op-acceptance-tests/tests/sync/follow_l2/sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package follow_l2

import (
"testing"
"time"

"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-devstack/stack"
"github.com/ethereum-optimism/optimism/op-devstack/stack/match"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types"
"github.com/ethereum-optimism/optimism/op-test-sequencer/sequencer/seqtypes"
"github.com/ethereum/go-ethereum/common"
)

func TestFollowL2_ReorgRecovery(gt *testing.T) {
t := devtest.SerialT(gt)
sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t)
require := t.Require()
logger := t.Logger()
ctx := t.Ctx()

// L2CLB is the verifier without follow source, derivation enabled

ts := sys.TestSequencer.Escape().ControlAPI(sys.L1Network.ChainID())
cl := sys.L1Network.Escape().L1CLNode(match.FirstL1CL)

// Pass the L1 genesis
sys.L1Network.WaitForBlock()

// Stop auto advancing L1
sys.ControlPlane.FakePoSState(cl.ID(), stack.Stop)

startL1Block := sys.L1EL.BlockRefByLabel(eth.Unsafe)

require.Eventually(func() bool {
// Advance single L1 block
require.NoError(ts.New(ctx, seqtypes.BuildOpts{Parent: common.Hash{}}))
require.NoError(ts.Next(ctx))
l1head := sys.L1EL.BlockRefByLabel(eth.Unsafe)
l2Safe := sys.L2ELB.BlockRefByLabel(eth.Safe)

logger.Info("l1 info", "l1_head", l1head, "l1_origin", l2Safe.L1Origin, "l2Safe", l2Safe)
// Wait until safe L2 block has L1 origin point after the startL1Block
return l2Safe.Number > 0 && l2Safe.L1Origin.Number > startL1Block.Number
}, 120*time.Second, 2*time.Second)

l2BlockBeforeReorg := sys.L2ELB.BlockRefByLabel(eth.Safe)
logger.Info("Target L2 Block to reorg", "l2", l2BlockBeforeReorg, "l1_origin", l2BlockBeforeReorg.L1Origin)

// Make sure verifier safe head is also advanced from reorgL2Block or matched
sys.L2ELB.Reached(eth.Safe, l2BlockBeforeReorg.Number, 3)

// Reorg L1 block which safe block L1 Origin points to
l1BlockBeforeReorg := sys.L1EL.BlockRefByNumber(l2BlockBeforeReorg.L1Origin.Number)
logger.Info("Triggering L1 reorg", "l1", l1BlockBeforeReorg)
require.NoError(ts.New(ctx, seqtypes.BuildOpts{Parent: l1BlockBeforeReorg.ParentHash}))
require.NoError(ts.Next(ctx))

// Start advancing L1
sys.ControlPlane.FakePoSState(cl.ID(), stack.Start)

// Make sure L1 reorged
sys.L1EL.WaitForBlockNumber(l1BlockBeforeReorg.Number)
l1BlockAfterReorg := sys.L1EL.BlockRefByNumber(l1BlockBeforeReorg.Number)
logger.Info("Triggered L1 reorg", "l1", l1BlockAfterReorg)
require.NotEqual(l1BlockAfterReorg.Hash, l1BlockBeforeReorg.Hash)

// Need to poll until the L2CL detects L1 Reorg and trigger L2 Reorg
// What happens:
// L2CL detects L1 Reorg and reset the pipeline. op-node example logs: "reset: detected L1 reorg"
// L2ELB detects L2 Reorg and reorgs. op-geth example logs: "Chain reorg detected"
sys.L2ELB.ReorgTriggered(l2BlockBeforeReorg, 30)
Comment on lines +65 to +75
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we verify that L2ELB does not process the reorg on its own somehow?

Copy link
Member Author

Choose a reason for hiding this comment

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

The L2ELB never progresses or reorgs independently; it is always driven by the L2CLB via the Engine API. So the L2ELB cannot reorg on its own.

ReorgTriggered only checks that the canonical block at the divergence height has changed (same parent, different hash). This can only happen if the CL has processed the L1 reorg and sent a forkchoice update with the new head to the EL.

l2BlockAfterReorg := sys.L2ELB.BlockRefByNumber(l2BlockBeforeReorg.Number)
require.NotEqual(l2BlockAfterReorg.Hash, l2BlockBeforeReorg.Hash)
logger.Info("Triggered L2 reorg", "l2", l2BlockAfterReorg)

attempts := 30
dsl.CheckAll(t,
sys.L2CL.MatchedFn(sys.L2CLB, types.LocalUnsafe, attempts),
sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalUnsafe, attempts),
sys.L2CL.MatchedFn(sys.L2CLB, types.LocalSafe, attempts),
sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalSafe, attempts),
)
}

func TestFollowL2_SafeAndFinalized(gt *testing.T) {
t := devtest.SerialT(gt)
sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t)
logger := t.Logger()
// Takes about 2 minutes for L1 finalization
attempts := 70
target := uint64(3)

// L2CL is the sequencer with follow source, derivation disabled
// L2CLB is the verifier without follow source, derivation enabled
// L2CLC is the verifier with follow source, derivation disabled
// All verifiers must eventually advance unsafe, safe, finalized
checkMatchedAll := func(lvl types.SafetyLevel) {
dsl.CheckAll(t,
sys.L2CL.ReachedFn(lvl, target, attempts),
sys.L2CLB.ReachedFn(lvl, target, attempts),
sys.L2CLC.ReachedFn(lvl, target, attempts),
)
dsl.CheckAll(t,
sys.L2CLB.MatchedFn(sys.L2CL, lvl, attempts),
sys.L2CLB.MatchedFn(sys.L2CLC, lvl, attempts),
)
}

checkMatchedAll(types.LocalUnsafe)
logger.Info("Unsafe head advanced due to CLP2P", "target", target)

checkMatchedAll(types.LocalSafe)
logger.Info("Safe head followed source", "target", target)

checkMatchedAll(types.Finalized)
logger.Info("Finalized head followed source", "target", target)
}

func TestFollowL2_WithoutCLP2P(gt *testing.T) {
t := devtest.SerialT(gt)
sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t)
require := t.Require()
logger := t.Logger()

attempts := 20
target := uint64(3)

// L2CLB is the verifier without follow source, derivation enabled
sys.L2CLB.Advanced(types.LocalUnsafe, target, attempts)

// The test's primary target is the L2CLC, with follow source and derivation disabled
// Normally there should be delta between safe head between unsafe head
status := sys.L2CLC.SyncStatus()
require.NotEqual(status.LocalSafeL2, status.UnsafeL2)

logger.Info("Disconnect CLP2P")
// L2CLC is the verifier with follow source, derivation disabled
// Disconnect CLP2P of verifier which follow source is enabled
sys.L2CLC.DisconnectPeer(sys.L2CLB)
sys.L2CLB.DisconnectPeer(sys.L2CLC)
sys.L2CLC.DisconnectPeer(sys.L2CL)
sys.L2CL.DisconnectPeer(sys.L2CLC)

// Advance few safe blocks
sys.L2CLC.Advanced(types.LocalSafe, target, attempts)
sys.L2CLC.Matched(sys.L2CLB, types.LocalSafe, attempts)

// Make sure the safe head reaches non-moving unsafe head
sys.L2CLC.Reached(types.LocalSafe, sys.L2CLC.UnsafeHead().BlockRef.Number, attempts)
// The only data source for L2CLC is the safe source.
// L2CLC unsafe head will only be advancing with safe head together
status = sys.L2CLC.SyncStatus()
require.Equal(status.LocalSafeL2, status.UnsafeL2)
sys.L2CLC.Advanced(types.LocalSafe, target, attempts)

// Advance few safe blocks
sys.L2CLC.Advanced(types.LocalSafe, target, attempts)

// Check once again that the unsafe head is moving together with safe head
status = sys.L2CLC.SyncStatus()
require.Equal(status.LocalSafeL2, status.UnsafeL2)
sys.L2CLC.Advanced(types.LocalSafe, target, attempts)

// Recover CLP2P
logger.Info("Recover CLP2P")
sys.L2CLC.ConnectPeer(sys.L2CLB)
sys.L2CLB.ConnectPeer(sys.L2CLC)
sys.L2CLC.ConnectPeer(sys.L2CL)
sys.L2CL.ConnectPeer(sys.L2CLC)

// Sequencer unsafe payload will arrive to the verifier, triggering EL sync and filling in the unsafe gap
dsl.CheckAll(t,
// Match with sequencer with derivation disabled
sys.L2CLC.MatchedFn(sys.L2CL, types.LocalSafe, attempts),
sys.L2CLC.MatchedFn(sys.L2CL, types.LocalUnsafe, attempts),
// Match with other verifier with derivation enabled
sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalSafe, attempts),
sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalUnsafe, attempts),
)

t.Cleanup(func() {
sys.L2CLC.ConnectPeer(sys.L2CLB)
sys.L2CLB.ConnectPeer(sys.L2CLC)
sys.L2CLC.ConnectPeer(sys.L2CL)
sys.L2CL.ConnectPeer(sys.L2CLC)
})
}
78 changes: 78 additions & 0 deletions op-acceptance-tests/tests/sync/unsafe_only/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,90 @@ package unsafe_only

import (
"testing"
"time"

"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-devstack/stack"
"github.com/ethereum-optimism/optimism/op-devstack/stack/match"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types"
"github.com/ethereum-optimism/optimism/op-test-sequencer/sequencer/seqtypes"
"github.com/ethereum/go-ethereum/common"
)

func TestUnsafeOnly_ReorgRecovery(gt *testing.T) {
t := devtest.SerialT(gt)
sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t)
require := t.Require()
logger := t.Logger()
ctx := t.Ctx()

// L2CLC is the verifier without follow source, derivation enabled

ts := sys.TestSequencer.Escape().ControlAPI(sys.L1Network.ChainID())
cl := sys.L1Network.Escape().L1CLNode(match.FirstL1CL)

// Pass the L1 genesis
sys.L1Network.WaitForBlock()

// Stop auto advancing L1
sys.ControlPlane.FakePoSState(cl.ID(), stack.Stop)

startL1Block := sys.L1EL.BlockRefByLabel(eth.Unsafe)

require.Eventually(func() bool {
// Advance single L1 block
require.NoError(ts.New(ctx, seqtypes.BuildOpts{Parent: common.Hash{}}))
require.NoError(ts.Next(ctx))
l1head := sys.L1EL.BlockRefByLabel(eth.Unsafe)
l2Unsafe := sys.L2EL.BlockRefByLabel(eth.Unsafe)

logger.Info("l1 info", "l1_head", l1head, "l1_origin", l2Unsafe.L1Origin, "l2Unsafe", l2Unsafe)
// Wait until unsafe L2 block has L1 origin point after the startL1Block
return l2Unsafe.Number > 0 && l2Unsafe.L1Origin.Number > startL1Block.Number
}, 120*time.Second, 2*time.Second)

l2BlockBeforeReorg := sys.L2EL.BlockRefByLabel(eth.Unsafe)
logger.Info("Target L2 Block to reorg", "l2", l2BlockBeforeReorg, "l1_origin", l2BlockBeforeReorg.L1Origin)

// Make sure verifier unsafe head is also advanced from reorgL2Block or matched
sys.L2ELB.Reached(eth.Unsafe, l2BlockBeforeReorg.Number, 3)

// Reorg L1 block which safe block L1 Origin points to
l1BlockBeforeReorg := sys.L1EL.BlockRefByNumber(l2BlockBeforeReorg.L1Origin.Number)
logger.Info("Triggering L1 reorg", "l1", l1BlockBeforeReorg)
require.NoError(ts.New(ctx, seqtypes.BuildOpts{Parent: l1BlockBeforeReorg.ParentHash}))
require.NoError(ts.Next(ctx))

// Start advancing L1
sys.ControlPlane.FakePoSState(cl.ID(), stack.Start)

// Make sure L1 reorged
sys.L1EL.WaitForBlockNumber(l1BlockBeforeReorg.Number)
l1BlockAfterReorg := sys.L1EL.BlockRefByNumber(l1BlockBeforeReorg.Number)
logger.Info("Triggered L1 reorg", "l1", l1BlockAfterReorg)
require.NotEqual(l1BlockAfterReorg.Hash, l1BlockBeforeReorg.Hash)

// Need to poll until the L2CL detects L1 Reorg and trigger L2 Reorg
// What happens:
// L2CL detects L1 Reorg and reset the pipeline. op-node example logs: "reset: detected L1 reorg"
// L2EL detects L2 Reorg and reorgs. op-geth example logs: "Chain reorg detected"
sys.L2EL.ReorgTriggered(l2BlockBeforeReorg, 30)
l2BlockAfterReorg := sys.L2EL.BlockRefByNumber(l2BlockBeforeReorg.Number)
require.NotEqual(l2BlockAfterReorg.Hash, l2BlockBeforeReorg.Hash)
logger.Info("Triggered L2 reorg", "l2", l2BlockAfterReorg)

attempts := 30
dsl.CheckAll(t,
sys.L2CL.MatchedFn(sys.L2CLC, types.LocalUnsafe, attempts),
sys.L2CLB.MatchedFn(sys.L2CLC, types.LocalUnsafe, attempts),
sys.L2EL.MatchedFn(sys.L2ELC, types.LocalUnsafe, attempts),
sys.L2ELB.MatchedFn(sys.L2ELC, types.LocalUnsafe, attempts),
)
}

func TestUnsafeOnly_VerifierUnsafeGapClosed(gt *testing.T) {
t := devtest.SerialT(gt)
sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package sync_tester_follow_l2_ext

import (
"testing"

"github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el"
bss "github.com/ethereum-optimism/optimism/op-batcher/batcher"
"github.com/ethereum-optimism/optimism/op-devstack/compat"
"github.com/ethereum-optimism/optimism/op-devstack/presets"
"github.com/ethereum-optimism/optimism/op-devstack/stack"
"github.com/ethereum-optimism/optimism/op-devstack/sysgo"
"github.com/ethereum-optimism/optimism/op-node/chaincfg"
"github.com/ethereum-optimism/optimism/op-service/eth"
)

func TestMain(m *testing.M) {
// Target op-sepolia
networkName := "op-sepolia"
config, _ := sync_tester_ext_el.GetNetworkPreset(networkName)
chainCfg := chaincfg.ChainByName(networkName)
presets.DoMain(m,
presets.WithExternalELWithSuperchainRegistryFollowL2(config),
// CL connected to sync tester EL is verifier
presets.WithExecutionLayerSyncOnVerifiers(),
// Make sync tester EL mock EL Sync
presets.WithELSyncActive(),
// Only rely on EL sync for unsafe gap filling
presets.WithReqRespSyncDisabled(),
presets.WithNoDiscovery(),
presets.WithCompatibleTypes(compat.SysGo),
presets.WithUnsafeOnly(),
stack.MakeCommon(sysgo.WithBatcherOption(func(id stack.L2BatcherID, cfg *bss.CLIConfig) {
// For stopping derivation, not to advance safe heads
cfg.Stopped = true
})),
// Sync tester EL at genesis
presets.WithSyncTesterELInitialState(eth.FCUState{
Latest: chainCfg.Genesis.L2.Number,
Safe: chainCfg.Genesis.L2.Number,
Finalized: chainCfg.Genesis.L2.Number,
}),
)
}
Loading