diff --git a/op-e2e/actions/interop/dsl/dsl.go b/op-e2e/actions/interop/dsl/dsl.go index e2457c2b9caa2..de9e367ef8901 100644 --- a/op-e2e/actions/interop/dsl/dsl.go +++ b/op-e2e/actions/interop/dsl/dsl.go @@ -5,6 +5,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup/derive" "github.com/ethereum-optimism/optimism/op-node/rollup/event" "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/backend/depset" "github.com/stretchr/testify/require" ) @@ -97,6 +98,10 @@ func NewInteropDSL(t helpers.Testing) *InteropDSL { } } +func (d *InteropDSL) DepSet() *depset.StaticConfigDependencySet { + return d.setup.DepSet +} + func (d *InteropDSL) defaultChainOpts() ChainOpts { return ChainOpts{ // Defensive copy to make sure the original slice isn't modified diff --git a/op-e2e/actions/interop/dsl/interop.go b/op-e2e/actions/interop/dsl/interop.go index 9bcf2e1838196..d4498a60d1a75 100644 --- a/op-e2e/actions/interop/dsl/interop.go +++ b/op-e2e/actions/interop/dsl/interop.go @@ -165,7 +165,7 @@ func worldToDepSet(t helpers.Testing, worldOutput *interopgen.WorldOutput) *deps HistoryMinTime: 0, } } - depSet, err := depset.NewStaticConfigDependencySet(depSetCfg) + depSet, err := depset.NewStaticConfigDependencySetWithMessageExpiryOverride(depSetCfg, messageExpiryTime) require.NoError(t, err) return depSet } diff --git a/op-e2e/actions/interop/proofs_test.go b/op-e2e/actions/interop/proofs_test.go index 34de3b5c16bb8..a65184cff0fd5 100644 --- a/op-e2e/actions/interop/proofs_test.go +++ b/op-e2e/actions/interop/proofs_test.go @@ -17,7 +17,6 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/testlog" "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/backend/depset" - supervisortypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/stretchr/testify/require" @@ -533,9 +532,6 @@ func TestInteropFaultProofs_CascadeInvalidBlock(gt *testing.T) { func TestInteropFaultProofs_MessageExpiry(gt *testing.T) { t := helpers.NewDefaultTesting(gt) - // TODO(#14234): Check message expiry in op-supervisor - t.Skip("Message expiry not yet implemented") - system := dsl.NewInteropDSL(t) actors := system.Actors @@ -772,7 +768,7 @@ func runFppAndChallengerTests(gt *testing.T, system *dsl.InteropDSL, tests []*tr for _, test := range tests { test := test gt.Run(fmt.Sprintf("%s-fpp", test.name), func(gt *testing.T) { - runFppTest(gt, test, system.Actors) + runFppTest(gt, test, system.Actors, system.DepSet()) }) gt.Run(fmt.Sprintf("%s-challenger", test.name), func(gt *testing.T) { @@ -781,7 +777,7 @@ func runFppAndChallengerTests(gt *testing.T, system *dsl.InteropDSL, tests []*tr } } -func runFppTest(gt *testing.T, test *transitionTest, actors *dsl.InteropActors) { +func runFppTest(gt *testing.T, test *transitionTest, actors *dsl.InteropActors, depSet *depset.StaticConfigDependencySet) { t := helpers.NewDefaultTesting(gt) if test.skipProgram { t.Skip("Not yet implemented") @@ -805,7 +801,7 @@ func runFppTest(gt *testing.T, test *transitionTest, actors *dsl.InteropActors) logger, actors.L1Miner, checkResult, - WithInteropEnabled(t, actors, test.agreedClaim, crypto.Keccak256Hash(test.disputedClaim), proposalTimestamp), + WithInteropEnabled(t, actors, depSet, test.agreedClaim, crypto.Keccak256Hash(test.disputedClaim), proposalTimestamp), fpHelpers.WithL1Head(l1Head), ) } @@ -853,29 +849,14 @@ func runChallengerTest(gt *testing.T, test *transitionTest, actors *dsl.InteropA } } -func WithInteropEnabled(t helpers.StatefulTesting, actors *dsl.InteropActors, agreedPrestate []byte, disputedClaim common.Hash, claimTimestamp uint64) fpHelpers.FixtureInputParam { +func WithInteropEnabled(t helpers.StatefulTesting, actors *dsl.InteropActors, depSet *depset.StaticConfigDependencySet, agreedPrestate []byte, disputedClaim common.Hash, claimTimestamp uint64) fpHelpers.FixtureInputParam { return func(f *fpHelpers.FixtureInputs) { f.InteropEnabled = true f.AgreedPrestate = agreedPrestate f.L2OutputRoot = crypto.Keccak256Hash(agreedPrestate) f.L2Claim = disputedClaim f.L2BlockNumber = claimTimestamp - - deps := map[eth.ChainID]*depset.StaticConfigDependency{ - actors.ChainA.ChainID: { - ChainIndex: supervisortypes.ChainIndex(0), - ActivationTime: 0, - HistoryMinTime: 0, - }, - actors.ChainB.ChainID: { - ChainIndex: supervisortypes.ChainIndex(1), - ActivationTime: 0, - HistoryMinTime: 0, - }, - } - var err error - f.DependencySet, err = depset.NewStaticConfigDependencySet(deps) - require.NoError(t, err) + f.DependencySet = depSet for _, chain := range []*dsl.Chain{actors.ChainA, actors.ChainB} { f.L2Sources = append(f.L2Sources, &fpHelpers.FaultProofProgramL2Source{ diff --git a/op-e2e/interop/interop_test.go b/op-e2e/interop/interop_test.go index 732c9350c774f..03168d05cc1d9 100644 --- a/op-e2e/interop/interop_test.go +++ b/op-e2e/interop/interop_test.go @@ -215,7 +215,7 @@ func TestInterop_EmitLogs(t *testing.T) { // all logs should be cross-safe for _, log := range logsA { identifier, expectedHash := logToIdentifier(chainA, log) - safety, err := supervisor.CheckMessage(context.Background(), identifier, expectedHash) + safety, err := supervisor.CheckMessage(context.Background(), identifier, expectedHash, types.ExecutingDescriptor{Timestamp: identifier.Timestamp}) require.NoError(t, err) // the supervisor could progress the safety level more quickly than we expect, // which is why we check for a minimum safety level @@ -223,7 +223,7 @@ func TestInterop_EmitLogs(t *testing.T) { } for _, log := range logsB { identifier, expectedHash := logToIdentifier(chainB, log) - safety, err := supervisor.CheckMessage(context.Background(), identifier, expectedHash) + safety, err := supervisor.CheckMessage(context.Background(), identifier, expectedHash, types.ExecutingDescriptor{Timestamp: identifier.Timestamp}) require.NoError(t, err) // the supervisor could progress the safety level more quickly than we expect, // which is why we check for a minimum safety level @@ -234,7 +234,7 @@ func TestInterop_EmitLogs(t *testing.T) { identifier, expectedHash := logToIdentifier(chainA, logsA[0]) // make the timestamp incorrect identifier.Timestamp = 333 - safety, err := supervisor.CheckMessage(context.Background(), identifier, expectedHash) + safety, err := supervisor.CheckMessage(context.Background(), identifier, expectedHash, types.ExecutingDescriptor{Timestamp: 333}) require.NoError(t, err) require.Equal(t, types.Invalid, safety) diff --git a/op-program/client/interop/consolidate.go b/op-program/client/interop/consolidate.go index edbd8e217eac2..09eb78357aae3 100644 --- a/op-program/client/interop/consolidate.go +++ b/op-program/client/interop/consolidate.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" - "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-program/client/boot" "github.com/ethereum-optimism/optimism/op-program/client/interop/types" "github.com/ethereum-optimism/optimism/op-program/client/l1" @@ -102,11 +101,7 @@ func RunConsolidation( Number: optimisticBlock.NumberU64(), Timestamp: optimisticBlock.Time(), } - rollupCfg, err := bootInfo.Configs.RollupConfig(chain.ChainID) - if err != nil { - return eth.Bytes32{}, fmt.Errorf("no rollup config available for chain ID %v: %w", chain.ChainID, err) - } - if err := checkHazards(rollupCfg, deps, candidate, chain.ChainID, execMsgs); err != nil { + if err := checkHazards(deps, candidate, chain.ChainID, execMsgs); err != nil { if !isInvalidMessageError(err) { return eth.Bytes32{}, err } @@ -159,27 +154,15 @@ func isInvalidMessageError(err error) bool { type ConsolidateCheckDeps interface { cross.UnsafeFrontierCheckDeps cross.CycleCheckDeps - Contains(chain eth.ChainID, query supervisortypes.ContainsQuery) (includedIn supervisortypes.BlockSeal, err error) + cross.UnsafeStartDeps } func checkHazards( - rollupCfg *rollup.Config, deps ConsolidateCheckDeps, candidate supervisortypes.BlockSeal, chainID eth.ChainID, execMsgs []*supervisortypes.ExecutingMessage, ) error { - // TODO(#14234): remove this check once the supervisor is updated handle msg expiry - messageExpiryTimeSeconds := rollupCfg.GetMessageExpiryTimeInterop() - for _, msg := range execMsgs { - if msg.Timestamp+messageExpiryTimeSeconds < candidate.Timestamp { - return fmt.Errorf( - "message timestamp is too old: %d < %d: %w", - msg.Timestamp+messageExpiryTimeSeconds, candidate.Timestamp, supervisortypes.ErrConflict, - ) - } - } - hazards, err := cross.CrossUnsafeHazards(deps, chainID, candidate, execMsgs) if err != nil { return err diff --git a/op-service/sources/supervisor_client.go b/op-service/sources/supervisor_client.go index 50bcea1fe6425..a656d9fe85a4a 100644 --- a/op-service/sources/supervisor_client.go +++ b/op-service/sources/supervisor_client.go @@ -60,20 +60,22 @@ func (cl *SupervisorClient) AddL2RPC(ctx context.Context, rpc string, auth eth.B return result } -func (cl *SupervisorClient) CheckMessage(ctx context.Context, identifier types.Identifier, logHash common.Hash) (types.SafetyLevel, error) { +func (cl *SupervisorClient) CheckMessage(ctx context.Context, identifier types.Identifier, logHash common.Hash, executingDescriptor types.ExecutingDescriptor) (types.SafetyLevel, error) { var result types.SafetyLevel err := cl.client.CallContext( ctx, &result, "supervisor_checkMessage", identifier, - logHash) + logHash, + executingDescriptor) if err != nil { - return types.Invalid, fmt.Errorf("failed to check message (chain %s), (block %v), (index %v), (logHash %s): %w", + return types.Invalid, fmt.Errorf("failed to check message (chain %s), (block %v), (index %v), (logHash %s), (executingTimestamp %v): %w", identifier.ChainID, identifier.BlockNumber, identifier.LogIndex, logHash, + executingDescriptor.Timestamp, err) } return result, nil diff --git a/op-supervisor/supervisor/backend/backend.go b/op-supervisor/supervisor/backend/backend.go index 6b5b80d5462f2..d06e266fbc298 100644 --- a/op-supervisor/supervisor/backend/backend.go +++ b/op-supervisor/supervisor/backend/backend.go @@ -423,7 +423,7 @@ func (su *SupervisorBackend) DependencySet() depset.DependencySet { // Query methods // ---------------------------- -func (su *SupervisorBackend) CheckMessage(identifier types.Identifier, payloadHash common.Hash) (types.SafetyLevel, error) { +func (su *SupervisorBackend) CheckMessage(identifier types.Identifier, payloadHash common.Hash, executingDescriptor types.ExecutingDescriptor) (types.SafetyLevel, error) { logHash := types.PayloadHashToLogHash(payloadHash, identifier.Origin) chainID := identifier.ChainID blockNum := identifier.BlockNumber @@ -446,9 +446,45 @@ func (su *SupervisorBackend) CheckMessage(identifier types.Identifier, payloadHa if err != nil { return types.Invalid, fmt.Errorf("failed to check log: %w", err) } + if identifier.Timestamp+su.depSet.MessageExpiryWindow() < executingDescriptor.Timestamp { + su.logger.Debug("Message expired", "identifier", identifier, "payloadHash", payloadHash, "executingTimestamp", executingDescriptor.Timestamp) + return types.Invalid, nil + } + if identifier.Timestamp > executingDescriptor.Timestamp { + su.logger.Debug("Message timestamp is in the future", "identifier", identifier, "payloadHash", payloadHash, "executingTimestamp", executingDescriptor.Timestamp) + return types.Invalid, nil + } return su.chainDBs.Safest(chainID, blockNum, logIdx) } +func (su *SupervisorBackend) CheckMessagesV2( + messages []types.Message, + minSafety types.SafetyLevel, + executingDescriptor types.ExecutingDescriptor) error { + su.logger.Debug("Checking messages", "count", len(messages), "minSafety", minSafety, "executingTimestamp", executingDescriptor.Timestamp) + + for _, msg := range messages { + su.logger.Debug("Checking message", + "identifier", msg.Identifier, "payloadHash", msg.PayloadHash.String(), "executingTimestamp", executingDescriptor.Timestamp) + safety, err := su.CheckMessage(msg.Identifier, msg.PayloadHash, executingDescriptor) + if err != nil { + su.logger.Error("Check message failed", "err", err, + "identifier", msg.Identifier, "payloadHash", msg.PayloadHash.String(), "executingTimestamp", executingDescriptor.Timestamp) + return fmt.Errorf("failed to check message: %w", err) + } + if !safety.AtLeastAsSafe(minSafety) { + su.logger.Error("Message is not sufficiently safe", + "safety", safety, "minSafety", minSafety, + "identifier", msg.Identifier, "payloadHash", msg.PayloadHash.String(), "executingTimestamp", executingDescriptor.Timestamp) + return fmt.Errorf("message %v (safety level: %v) does not meet the minimum safety %v", + msg.Identifier, + safety, + minSafety) + } + } + return nil +} + func (su *SupervisorBackend) CheckMessages( messages []types.Message, minSafety types.SafetyLevel) error { @@ -457,7 +493,9 @@ func (su *SupervisorBackend) CheckMessages( for _, msg := range messages { su.logger.Debug("Checking message", "identifier", msg.Identifier, "payloadHash", msg.PayloadHash.String()) - safety, err := su.CheckMessage(msg.Identifier, msg.PayloadHash) + // Guarantee message expiry checks do not fail by setting the executing timestamp to the message timestamp + // This is intentionally done to avoid breaking checkMessagesV1 which doesn't handle message expiry checks + safety, err := su.CheckMessage(msg.Identifier, msg.PayloadHash, types.ExecutingDescriptor{Timestamp: msg.Identifier.Timestamp}) if err != nil { su.logger.Error("Check message failed", "err", err, "identifier", msg.Identifier, "payloadHash", msg.PayloadHash.String()) diff --git a/op-supervisor/supervisor/backend/cross/safe_frontier_test.go b/op-supervisor/supervisor/backend/cross/safe_frontier_test.go index 71c45db208bbf..157b7ed8602cf 100644 --- a/op-supervisor/supervisor/backend/cross/safe_frontier_test.go +++ b/op-supervisor/supervisor/backend/cross/safe_frontier_test.go @@ -166,9 +166,10 @@ func (m *mockSafeFrontierCheckDeps) DependencySet() depset.DependencySet { } type mockDependencySet struct { - chainIDFromIndexfn func() (eth.ChainID, error) - canExecuteAtfn func() (bool, error) - canInitiateAtfn func() (bool, error) + chainIDFromIndexfn func() (eth.ChainID, error) + canExecuteAtfn func() (bool, error) + canInitiateAtfn func() (bool, error) + messageExpiryWindow uint64 } func (m mockDependencySet) CanExecuteAt(chain eth.ChainID, timestamp uint64) (bool, error) { @@ -209,3 +210,10 @@ func (m mockDependencySet) Chains() []eth.ChainID { func (m mockDependencySet) HasChain(chain eth.ChainID) bool { return true } + +func (m mockDependencySet) MessageExpiryWindow() uint64 { + if m.messageExpiryWindow == 0 { + return 100 + } + return m.messageExpiryWindow +} diff --git a/op-supervisor/supervisor/backend/cross/safe_start.go b/op-supervisor/supervisor/backend/cross/safe_start.go index 497065578b079..f4b5061d007b7 100644 --- a/op-supervisor/supervisor/backend/cross/safe_start.go +++ b/op-supervisor/supervisor/backend/cross/safe_start.go @@ -77,6 +77,10 @@ func CrossSafeHazards(d SafeStartDeps, chainID eth.ChainID, inL1Source eth.Block return nil, fmt.Errorf("msg %s was included in block %s derived from %s which is not in cross-safe scope %s: %w", msg, includedIn, initSource, inL1Source, types.ErrOutOfScope) } + // Run expiry window invariant check *after* verifying that the message is non-conflicting. + if msg.Timestamp+depSet.MessageExpiryWindow() < candidate.Timestamp { + return nil, fmt.Errorf("timestamp of message %s (chain %s) has expired: %d < %d: %w", msg, chainID, msg.Timestamp+depSet.MessageExpiryWindow(), candidate.Timestamp, types.ErrConflict) + } } else if msg.Timestamp == candidate.Timestamp { // If timestamp is equal: we have to inspect ordering of individual // log events to ensure non-cyclic cross-chain message ordering. diff --git a/op-supervisor/supervisor/backend/cross/safe_start_test.go b/op-supervisor/supervisor/backend/cross/safe_start_test.go index c3a0bdd9e1ec1..fb6ae7dd81261 100644 --- a/op-supervisor/supervisor/backend/cross/safe_start_test.go +++ b/op-supervisor/supervisor/backend/cross/safe_start_test.go @@ -314,6 +314,37 @@ func TestCrossSafeHazards(t *testing.T) { require.NoError(t, err) require.Empty(t, hazards) }) + t.Run("message expiry", func(t *testing.T) { + ssd := &mockSafeStartDeps{} + ssd.deps.messageExpiryWindow = 10 + chainID := eth.ChainIDFromUInt64(0) + inL1Source := eth.BlockID{Number: 1} + candidate := types.BlockSeal{Timestamp: 12} + em1 := &types.ExecutingMessage{Chain: types.ChainIndex(0), Timestamp: 1} + execMsgs := []*types.ExecutingMessage{em1} + // when there is one execMsg, and the timestamp is less than the candidate, + // and DerivedToSource returns a BlockSeal with a equal to the Number of inL1Source, + // no error is returned + hazards, err := CrossSafeHazards(ssd, chainID, inL1Source, candidate, execMsgs) + require.ErrorIs(t, err, types.ErrConflict) + require.ErrorContains(t, err, "has expired") + require.Empty(t, hazards) + }) + t.Run("message close to expiry", func(t *testing.T) { + ssd := &mockSafeStartDeps{} + ssd.deps.messageExpiryWindow = 10 + chainID := eth.ChainIDFromUInt64(0) + inL1Source := eth.BlockID{Number: 1} + candidate := types.BlockSeal{Timestamp: 11} + em1 := &types.ExecutingMessage{Chain: types.ChainIndex(0), Timestamp: 1} + execMsgs := []*types.ExecutingMessage{em1} + // when there is one execMsg, and the timestamp is less than the candidate, + // and DerivedToSource returns a BlockSeal with a equal to the Number of inL1Source, + // no error is returned + hazards, err := CrossSafeHazards(ssd, chainID, inL1Source, candidate, execMsgs) + require.NoError(t, err) + require.Empty(t, hazards) + }) } type mockSafeStartDeps struct { diff --git a/op-supervisor/supervisor/backend/cross/safe_update_test.go b/op-supervisor/supervisor/backend/cross/safe_update_test.go index d319a01d2db14..4b9f2f03e427e 100644 --- a/op-supervisor/supervisor/backend/cross/safe_update_test.go +++ b/op-supervisor/supervisor/backend/cross/safe_update_test.go @@ -109,6 +109,42 @@ func TestCrossSafeUpdate(t *testing.T) { require.NoError(t, err) require.True(t, invalidated) }) + t.Run("scopedCrossSafeUpdate returns ErrExpired and triggers invalidate-local-safe", func(t *testing.T) { + logger := testlog.Logger(t, log.LevelDebug) + chainID := eth.ChainIDFromUInt64(0) + csd := &mockCrossSafeDeps{} + candidate := eth.BlockRef{Number: 1, Time: 11} + candidateScope := eth.BlockRef{Number: 2} + csd.candidateCrossSafeFn = func() (pair types.DerivedBlockRefPair, err error) { + return types.DerivedBlockRefPair{ + Source: candidateScope, + Derived: candidate, + }, nil + } + opened := eth.BlockRef{Number: 1, Time: 11} + execs := map[uint32]*types.ExecutingMessage{1: {}} + csd.openBlockFn = func(chainID eth.ChainID, blockNum uint64) (ref eth.BlockRef, logCount uint32, execMsgs map[uint32]*types.ExecutingMessage, err error) { + return opened, 10, execs, nil + } + csd.checkFn = func(chainID eth.ChainID, blockNum uint64, logIdx uint32, logHash common.Hash) (types.BlockSeal, error) { + return types.BlockSeal{Number: 1, Timestamp: 1}, nil + } + invalidated := false + csd.invalidateLocalSafeFn = func(id eth.ChainID, p types.DerivedBlockRefPair) error { + require.Equal(t, chainID, id) + require.Equal(t, candidate, p.Derived) + require.Equal(t, candidateScope, p.Source) + invalidated = true + return nil + } + csd.deps = mockDependencySet{} + csd.deps.messageExpiryWindow = 10 + // when scopedCrossSafeUpdate returns no error, + // no error is returned + err := CrossSafeUpdate(logger, chainID, csd) + require.NoError(t, err) + require.True(t, invalidated) + }) t.Run("scopedCrossSafeUpdate returns ErrOutOfScope", func(t *testing.T) { logger := testlog.Logger(t, log.LevelDebug) chainID := eth.ChainIDFromUInt64(0) @@ -407,6 +443,30 @@ func TestScopedCrossSafeUpdate(t *testing.T) { require.ErrorContains(t, err, "failed to update") require.Equal(t, eth.BlockRef{Number: 2}, pair.Source) }) + t.Run("UpdateCrossSafe returns ErrExpired", func(t *testing.T) { + logger := testlog.Logger(t, log.LevelDebug) + chainID := eth.ChainIDFromUInt64(0) + csd := &mockCrossSafeDeps{} + csd.deps.messageExpiryWindow = 10 + candidate := eth.BlockRef{Number: 1, Time: 11} + csd.candidateCrossSafeFn = func() (types.DerivedBlockRefPair, error) { + return types.DerivedBlockRefPair{ + Source: eth.BlockRef{}, + Derived: candidate, + }, nil + } + opened := eth.BlockRef{Number: 1, Time: 11} + execs := map[uint32]*types.ExecutingMessage{1: {}} + csd.openBlockFn = func(chainID eth.ChainID, blockNum uint64) (ref eth.BlockRef, logCount uint32, execMsgs map[uint32]*types.ExecutingMessage, err error) { + return opened, 0, execs, nil + } + // when OpenBlock and CandidateCrossSafe return different blocks, + // an ErrConflict is returned + pair, err := scopedCrossSafeUpdate(logger, chainID, csd) + require.ErrorIs(t, err, types.ErrConflict) + require.ErrorContains(t, err, "has expired") + require.Equal(t, eth.BlockRef{}, pair.Source) + }) t.Run("successful update", func(t *testing.T) { logger := testlog.Logger(t, log.LevelDebug) chainID := eth.ChainIDFromUInt64(0) diff --git a/op-supervisor/supervisor/backend/cross/unsafe_start.go b/op-supervisor/supervisor/backend/cross/unsafe_start.go index 8a57d9cd1972c..d90e78f3862a6 100644 --- a/op-supervisor/supervisor/backend/cross/unsafe_start.go +++ b/op-supervisor/supervisor/backend/cross/unsafe_start.go @@ -75,6 +75,10 @@ func CrossUnsafeHazards(d UnsafeStartDeps, chainID eth.ChainID, if includedIn.Timestamp != msg.Timestamp { return nil, fmt.Errorf("executing msg %s exists, but has different timestamp than block %s: %w", msg, includedIn, types.ErrConflict) } + // Run expiry window invariant check *after* verifying that the message is non-conflicting. + if msg.Timestamp+depSet.MessageExpiryWindow() < candidate.Timestamp { + return nil, fmt.Errorf("timestamp of message %s (chain %s) has expired: %d < %d: %w", msg, chainID, msg.Timestamp+depSet.MessageExpiryWindow(), candidate.Timestamp, types.ErrConflict) + } } else if msg.Timestamp == candidate.Timestamp { // If timestamp is equal: we have to inspect ordering of individual // log events to ensure non-cyclic cross-chain message ordering. diff --git a/op-supervisor/supervisor/backend/cross/unsafe_start_test.go b/op-supervisor/supervisor/backend/cross/unsafe_start_test.go index e12d7c14fa4d6..962c9fba9e757 100644 --- a/op-supervisor/supervisor/backend/cross/unsafe_start_test.go +++ b/op-supervisor/supervisor/backend/cross/unsafe_start_test.go @@ -253,6 +253,40 @@ func TestCrossUnsafeHazards(t *testing.T) { require.NoError(t, err) require.Empty(t, hazards) }) + t.Run("message expiry", func(t *testing.T) { + usd := &mockUnsafeStartDeps{} + usd.deps.messageExpiryWindow = 10 + sampleBlockSeal := types.BlockSeal{Timestamp: 1} + usd.checkFn = func() (includedIn types.BlockSeal, err error) { + return sampleBlockSeal, nil + } + chainID := eth.ChainIDFromUInt64(0) + candidate := types.BlockSeal{Timestamp: 12} + em1 := &types.ExecutingMessage{Chain: types.ChainIndex(0), Timestamp: 1} + execMsgs := []*types.ExecutingMessage{em1} + // when there is one execMsg that has just expired, + // ErrExpired is returned + hazards, err := CrossUnsafeHazards(usd, chainID, candidate, execMsgs) + require.ErrorIs(t, err, types.ErrConflict) + require.ErrorContains(t, err, "has expired") + require.Empty(t, hazards) + }) + t.Run("message near expiry", func(t *testing.T) { + usd := &mockUnsafeStartDeps{} + usd.deps.messageExpiryWindow = 10 + sampleBlockSeal := types.BlockSeal{Timestamp: 1} + usd.checkFn = func() (includedIn types.BlockSeal, err error) { + return sampleBlockSeal, nil + } + chainID := eth.ChainIDFromUInt64(0) + candidate := types.BlockSeal{Timestamp: 11} + em1 := &types.ExecutingMessage{Chain: types.ChainIndex(0), Timestamp: 1} + execMsgs := []*types.ExecutingMessage{em1} + // when there is one execMsg that is near expiry, then no error is returned + hazards, err := CrossUnsafeHazards(usd, chainID, candidate, execMsgs) + require.NoError(t, err) + require.Empty(t, hazards) + }) } type mockUnsafeStartDeps struct { diff --git a/op-supervisor/supervisor/backend/cross/unsafe_update_test.go b/op-supervisor/supervisor/backend/cross/unsafe_update_test.go index f030b9712cd2a..6371fc598e86b 100644 --- a/op-supervisor/supervisor/backend/cross/unsafe_update_test.go +++ b/op-supervisor/supervisor/backend/cross/unsafe_update_test.go @@ -71,7 +71,7 @@ func TestCrossUnsafeUpdate(t *testing.T) { err := CrossUnsafeUpdate(logger, chainID, usd) require.ErrorIs(t, err, types.ErrConflict) }) - t.Run("CrossSafeHazards returns error", func(t *testing.T) { + t.Run("CrossUnsafeHazards returns error", func(t *testing.T) { logger := testlog.Logger(t, log.LevelDebug) chainID := eth.ChainIDFromUInt64(0) usd := &mockCrossUnsafeDeps{} @@ -181,6 +181,7 @@ func TestCrossUnsafeUpdate(t *testing.T) { type mockCrossUnsafeDeps struct { deps mockDependencySet + messageExpiryWindow uint64 crossUnsafeFn func(chainID eth.ChainID) (types.BlockSeal, error) openBlockFn func(chainID eth.ChainID, blockNum uint64) (ref eth.BlockRef, logCount uint32, execMsgs map[uint32]*types.ExecutingMessage, err error) updateCrossUnsafeFn func(chain eth.ChainID, crossUnsafe types.BlockSeal) error @@ -198,6 +199,10 @@ func (m *mockCrossUnsafeDeps) DependencySet() depset.DependencySet { return m.deps } +func (m *mockCrossUnsafeDeps) MessageExpiryWindow() uint64 { + return m.messageExpiryWindow +} + func (m *mockCrossUnsafeDeps) Contains(chainID eth.ChainID, q types.ContainsQuery) (types.BlockSeal, error) { if m.checkFn != nil { return m.checkFn(chainID, q.BlockNum, q.Timestamp, q.LogIdx, q.LogHash) diff --git a/op-supervisor/supervisor/backend/depset/depset.go b/op-supervisor/supervisor/backend/depset/depset.go index 415a849699fb8..4d03e60405ddd 100644 --- a/op-supervisor/supervisor/backend/depset/depset.go +++ b/op-supervisor/supervisor/backend/depset/depset.go @@ -36,6 +36,9 @@ type DependencySet interface { ChainIndexFromID(id eth.ChainID) (types.ChainIndex, error) + // MessageExpiryWindow returns the message expiry window to use for this dependency set. + MessageExpiryWindow() uint64 + ChainIndexFromID ChainIDFromIndex } diff --git a/op-supervisor/supervisor/backend/depset/depset_test.go b/op-supervisor/supervisor/backend/depset/depset_test.go index 0522b26eca527..31b86cbd0b04b 100644 --- a/op-supervisor/supervisor/backend/depset/depset_test.go +++ b/op-supervisor/supervisor/backend/depset/depset_test.go @@ -7,6 +7,7 @@ import ( "path" "testing" + "github.com/ethereum-optimism/optimism/op-node/params" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/stretchr/testify/require" ) @@ -75,4 +76,41 @@ func TestDependencySet(t *testing.T) { v, err = result.CanInitiateAt(eth.ChainIDFromUInt64(902), 100000) require.NoError(t, err) require.False(t, v, "902 not a dependency") + + require.Equal(t, uint64(params.MessageExpiryTimeSecondsInterop), result.MessageExpiryWindow()) +} + +func TestDependencySetWithMessageExpiryOverride(t *testing.T) { + d := path.Join(t.TempDir(), "tmp_dep_set.json") + + depSet, err := NewStaticConfigDependencySet( + map[eth.ChainID]*StaticConfigDependency{ + eth.ChainIDFromUInt64(900): { + ChainIndex: 900, + ActivationTime: 42, + HistoryMinTime: 100, + }, + eth.ChainIDFromUInt64(901): { + ChainIndex: 901, + ActivationTime: 30, + HistoryMinTime: 20, + }, + }) + require.NoError(t, err) + depSet.overrideMessageExpiryWindow = 10 + data, err := json.Marshal(depSet) + require.NoError(t, err) + + require.NoError(t, os.WriteFile(d, data, 0644)) + + loader := &JsonDependencySetLoader{Path: d} + result, err := loader.LoadDependencySet(context.Background()) + require.NoError(t, err) + + chainIDs := result.Chains() + require.Equal(t, []eth.ChainID{ + eth.ChainIDFromUInt64(900), + eth.ChainIDFromUInt64(901), + }, chainIDs) + require.Equal(t, uint64(10), result.MessageExpiryWindow()) } diff --git a/op-supervisor/supervisor/backend/depset/static.go b/op-supervisor/supervisor/backend/depset/static.go index a1dbeff809a28..8dad6d2508689 100644 --- a/op-supervisor/supervisor/backend/depset/static.go +++ b/op-supervisor/supervisor/backend/depset/static.go @@ -7,6 +7,7 @@ import ( "slices" "sort" + "github.com/ethereum-optimism/optimism/op-node/params" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" ) @@ -34,6 +35,8 @@ type StaticConfigDependencySet struct { indexToID map[types.ChainIndex]eth.ChainID // cached list of chain IDs, sorted by ID value chainIDs []eth.ChainID + // overrideMessageExpiryWindow is the message expiry window to use for this dependency set + overrideMessageExpiryWindow uint64 } func NewStaticConfigDependencySet(dependencies map[eth.ChainID]*StaticConfigDependency) (*StaticConfigDependencySet, error) { @@ -44,16 +47,28 @@ func NewStaticConfigDependencySet(dependencies map[eth.ChainID]*StaticConfigDepe return out, nil } +// NewStaticConfigDependencySetWithMessageExpiryOverride creates a new StaticConfigDependencySet with a message expiry window override. +// To be used only for testing. +func NewStaticConfigDependencySetWithMessageExpiryOverride(dependencies map[eth.ChainID]*StaticConfigDependency, overrideMessageExpiryWindow uint64) (*StaticConfigDependencySet, error) { + out := &StaticConfigDependencySet{dependencies: dependencies, overrideMessageExpiryWindow: overrideMessageExpiryWindow} + if err := out.hydrate(); err != nil { + return nil, err + } + return out, nil +} + // jsonStaticConfigDependencySet is a util for JSON encoding/decoding, // to encode/decode just the attributes that matter, // while wrapping the decoding functionality with additional hydration step. type jsonStaticConfigDependencySet struct { - Dependencies map[eth.ChainID]*StaticConfigDependency `json:"dependencies"` + Dependencies map[eth.ChainID]*StaticConfigDependency `json:"dependencies"` + OverrideMessageExpiryWindow uint64 `json:"overrideMessageExpiryWindow,omitempty"` } func (ds *StaticConfigDependencySet) MarshalJSON() ([]byte, error) { out := &jsonStaticConfigDependencySet{ - Dependencies: ds.dependencies, + Dependencies: ds.dependencies, + OverrideMessageExpiryWindow: ds.overrideMessageExpiryWindow, } return json.Marshal(out) } @@ -64,6 +79,7 @@ func (ds *StaticConfigDependencySet) UnmarshalJSON(data []byte) error { return err } ds.dependencies = v.Dependencies + ds.overrideMessageExpiryWindow = v.OverrideMessageExpiryWindow return ds.hydrate() } @@ -132,3 +148,10 @@ func (ds *StaticConfigDependencySet) ChainIDFromIndex(index types.ChainIndex) (e } return id, nil } + +func (ds *StaticConfigDependencySet) MessageExpiryWindow() uint64 { + if ds.overrideMessageExpiryWindow == 0 { + return params.MessageExpiryTimeSecondsInterop + } + return ds.overrideMessageExpiryWindow +} diff --git a/op-supervisor/supervisor/backend/mock.go b/op-supervisor/supervisor/backend/mock.go index 9022f6bf7fae0..c6de2b91df4d1 100644 --- a/op-supervisor/supervisor/backend/mock.go +++ b/op-supervisor/supervisor/backend/mock.go @@ -47,7 +47,7 @@ func (m *MockBackend) AddL2RPC(ctx context.Context, rpc string, jwtSecret eth.By return nil } -func (m *MockBackend) CheckMessage(identifier types.Identifier, payloadHash common.Hash) (types.SafetyLevel, error) { +func (m *MockBackend) CheckMessage(identifier types.Identifier, payloadHash common.Hash, executingDescriptor types.ExecutingDescriptor) (types.SafetyLevel, error) { return types.CrossUnsafe, nil } @@ -55,6 +55,10 @@ func (m *MockBackend) CheckMessages(messages []types.Message, minSafety types.Sa return nil } +func (m *MockBackend) CheckMessagesV2(messages []types.Message, minSafety types.SafetyLevel, executingDescriptor types.ExecutingDescriptor) error { + return nil +} + func (m *MockBackend) LocalUnsafe(ctx context.Context, chainID eth.ChainID) (eth.BlockID, error) { return eth.BlockID{}, nil } diff --git a/op-supervisor/supervisor/backend/rewinder/rewinder_test.go b/op-supervisor/supervisor/backend/rewinder/rewinder_test.go index b01a8d613ca04..692a335468715 100644 --- a/op-supervisor/supervisor/backend/rewinder/rewinder_test.go +++ b/op-supervisor/supervisor/backend/rewinder/rewinder_test.go @@ -1016,10 +1016,5 @@ func (m *mockL1Node) L1BlockRefByNumber(ctx context.Context, number uint64) (eth if !ok { return eth.L1BlockRef{}, fmt.Errorf("block not found: %d", number) } - return eth.L1BlockRef{ - Hash: block.Hash, - Number: block.Number, - Time: block.Time, - ParentHash: block.ParentHash, - }, nil + return eth.L1BlockRef(block), nil } diff --git a/op-supervisor/supervisor/frontend/frontend.go b/op-supervisor/supervisor/frontend/frontend.go index 1901b8f5a4b15..91dfe3fe29ef3 100644 --- a/op-supervisor/supervisor/frontend/frontend.go +++ b/op-supervisor/supervisor/frontend/frontend.go @@ -16,8 +16,9 @@ type AdminBackend interface { } type QueryBackend interface { - CheckMessage(identifier types.Identifier, payloadHash common.Hash) (types.SafetyLevel, error) + CheckMessage(identifier types.Identifier, payloadHash common.Hash, executingDescriptor types.ExecutingDescriptor) (types.SafetyLevel, error) CheckMessages(messages []types.Message, minSafety types.SafetyLevel) error + CheckMessagesV2(messages []types.Message, minSafety types.SafetyLevel, executingDescriptor types.ExecutingDescriptor) error CrossDerivedToSource(ctx context.Context, chainID eth.ChainID, derived eth.BlockID) (derivedFrom eth.BlockRef, err error) LocalUnsafe(ctx context.Context, chainID eth.ChainID) (eth.BlockID, error) CrossSafe(ctx context.Context, chainID eth.ChainID) (types.DerivedIDPair, error) @@ -41,12 +42,22 @@ var _ QueryBackend = (*QueryFrontend)(nil) // CheckMessage checks the safety-level of an individual message. // The payloadHash references the hash of the message-payload of the message. -func (q *QueryFrontend) CheckMessage(identifier types.Identifier, payloadHash common.Hash) (types.SafetyLevel, error) { - return q.Supervisor.CheckMessage(identifier, payloadHash) +func (q *QueryFrontend) CheckMessage(identifier types.Identifier, payloadHash common.Hash, executingDescriptor types.ExecutingDescriptor) (types.SafetyLevel, error) { + return q.Supervisor.CheckMessage(identifier, payloadHash, executingDescriptor) +} + +// CheckMessagesV2 checks the safety-level of a collection of messages, +// and returns if the minimum safety-level is met for all messages. +func (q *QueryFrontend) CheckMessagesV2( + messages []types.Message, + minSafety types.SafetyLevel, + executingDescriptor types.ExecutingDescriptor) error { + return q.Supervisor.CheckMessagesV2(messages, minSafety, executingDescriptor) } // CheckMessages checks the safety-level of a collection of messages, // and returns if the minimum safety-level is met for all messages. +// Deprecated: This method does not check for message expiry. func (q *QueryFrontend) CheckMessages( messages []types.Message, minSafety types.SafetyLevel) error { diff --git a/op-supervisor/supervisor/service_test.go b/op-supervisor/supervisor/service_test.go index 4b17f8269bec2..01003e17f6e75 100644 --- a/op-supervisor/supervisor/service_test.go +++ b/op-supervisor/supervisor/service_test.go @@ -73,7 +73,7 @@ func TestSupervisorService(t *testing.T) { LogIndex: 42, Timestamp: 1234567, ChainID: eth.ChainID{0xbb}, - }, common.Hash{0xcc}) + }, common.Hash{0xcc}, types.ExecutingDescriptor{Timestamp: 1234568}) cancel() require.NoError(t, err) require.Equal(t, types.CrossUnsafe, dest, "expecting mock to return cross-unsafe") diff --git a/op-supervisor/supervisor/types/types.go b/op-supervisor/supervisor/types/types.go index 6a2a9e1c14846..6302a7086bf6e 100644 --- a/op-supervisor/supervisor/types/types.go +++ b/op-supervisor/supervisor/types/types.go @@ -184,6 +184,30 @@ const ( Invalid SafetyLevel = "invalid" ) +type ExecutingDescriptor struct { + // Timestamp is the timestamp of the executing message + Timestamp uint64 +} + +type executingDescriptorMarshaling struct { + Timestamp hexutil.Uint64 `json:"timestamp"` +} + +func (ed ExecutingDescriptor) MarshalJSON() ([]byte, error) { + var enc executingDescriptorMarshaling + enc.Timestamp = hexutil.Uint64(ed.Timestamp) + return json.Marshal(&enc) +} + +func (ed *ExecutingDescriptor) UnmarshalJSON(input []byte) error { + var dec executingDescriptorMarshaling + if err := json.Unmarshal(input, &dec); err != nil { + return err + } + ed.Timestamp = uint64(dec.Timestamp) + return nil +} + type ReferenceView struct { Local eth.BlockID `json:"local"` Cross eth.BlockID `json:"cross"`