From 7c1ae59f7a26c74ba2705906cbd344055f28e036 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 28 Feb 2025 14:25:01 +1000 Subject: [PATCH 1/3] op-dispute-mon: Add support for super root game types. --- op-dispute-mon/cmd/main_test.go | 21 +- op-dispute-mon/config/config.go | 22 +- op-dispute-mon/config/config_test.go | 20 +- op-dispute-mon/flags/flags.go | 14 +- ...richer.go => output_agreement_enricher.go} | 28 ++- ...t.go => output_agreement_enricher_test.go} | 68 ++++- .../mon/extract/super_agreement_enricher.go | 67 +++++ .../extract/super_agreement_enricher_test.go | 236 ++++++++++++++++++ op-dispute-mon/mon/service.go | 35 ++- 9 files changed, 480 insertions(+), 31 deletions(-) rename op-dispute-mon/mon/extract/{agreement_enricher.go => output_agreement_enricher.go} (69%) rename op-dispute-mon/mon/extract/{agreement_enricher_test.go => output_agreement_enricher_test.go} (70%) create mode 100644 op-dispute-mon/mon/extract/super_agreement_enricher.go create mode 100644 op-dispute-mon/mon/extract/super_agreement_enricher_test.go diff --git a/op-dispute-mon/cmd/main_test.go b/op-dispute-mon/cmd/main_test.go index f3fa7cd7afefe..9e58e1b95afd0 100644 --- a/op-dispute-mon/cmd/main_test.go +++ b/op-dispute-mon/cmd/main_test.go @@ -59,9 +59,13 @@ func TestL1EthRpc(t *testing.T) { }) } +func TestMustSpecifyEitherRollupRpcOrSupervisorRpc(t *testing.T) { + verifyArgsInvalid(t, "flag rollup-rpc or supervisor-rpc is required", addRequiredArgsExcept("--rollup-rpc")) +} + func TestRollupRpc(t *testing.T) { - t.Run("Required", func(t *testing.T) { - verifyArgsInvalid(t, "flag rollup-rpc is required", addRequiredArgsExcept("--rollup-rpc")) + t.Run("NotRequiredIfSupervisorRpcSupplied", func(t *testing.T) { + configForArgs(t, addRequiredArgsExcept("--rollup-rpc", "--supervisor-rpc", "http://localhost/supervisor")) }) t.Run("Valid", func(t *testing.T) { @@ -71,6 +75,19 @@ func TestRollupRpc(t *testing.T) { }) } +func TestSupervisorRpc(t *testing.T) { + t.Run("NotRequiredIfRollupRpcSupplied", func(t *testing.T) { + // rollup-rpc is in the default args. + configForArgs(t, addRequiredArgsExcept("--supervisor-rpc")) + }) + + t.Run("Valid", func(t *testing.T) { + url := "http://example.com:9999" + cfg := configForArgs(t, addRequiredArgsExcept("--rollup-rpc", "--supervisor-rpc", url)) + require.Equal(t, url, cfg.SupervisorRpc) + }) +} + func TestGameFactoryAddress(t *testing.T) { t.Run("RequiredIfNetworkNetSet", func(t *testing.T) { verifyArgsInvalid(t, "flag game-factory-address or network is required", addRequiredArgsExcept("--game-factory-address")) diff --git a/op-dispute-mon/config/config.go b/op-dispute-mon/config/config.go index 8329819d0025b..7cf631f5af5ec 100644 --- a/op-dispute-mon/config/config.go +++ b/op-dispute-mon/config/config.go @@ -12,10 +12,10 @@ import ( ) var ( - ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url") - ErrMissingGameFactoryAddress = errors.New("missing game factory address") - ErrMissingRollupRpc = errors.New("missing rollup rpc url") - ErrMissingMaxConcurrency = errors.New("missing max concurrency") + ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url") + ErrMissingGameFactoryAddress = errors.New("missing game factory address") + ErrMissingRollupAndSupervisorRpc = errors.New("must specify rollup rpc or supervisor rpc") + ErrMissingMaxConcurrency = errors.New("missing max concurrency") ) const ( @@ -40,6 +40,7 @@ type Config struct { HonestActors []common.Address // List of honest actors to monitor claims for. RollupRpc string // The rollup node RPC URL. + SupervisorRpc string // The supervisor RPC URL. MonitorInterval time.Duration // Frequency to check for new games to monitor. GameWindow time.Duration // Maximum window to look for games to monitor. IgnoredGames []common.Address // Games to exclude from monitoring @@ -49,10 +50,19 @@ type Config struct { PprofConfig oppprof.CLIConfig } +func NewInteropConfig(gameFactoryAddress common.Address, l1EthRpc string, supervisorRpc string) Config { + return NewCombinedConfig(gameFactoryAddress, l1EthRpc, "", supervisorRpc) +} + func NewConfig(gameFactoryAddress common.Address, l1EthRpc string, rollupRpc string) Config { + return NewCombinedConfig(gameFactoryAddress, l1EthRpc, rollupRpc, "") +} + +func NewCombinedConfig(gameFactoryAddress common.Address, l1EthRpc string, rollupRpc string, supervisorRpc string) Config { return Config{ L1EthRpc: l1EthRpc, RollupRpc: rollupRpc, + SupervisorRpc: supervisorRpc, GameFactoryAddress: gameFactoryAddress, MonitorInterval: DefaultMonitorInterval, @@ -68,8 +78,8 @@ func (c Config) Check() error { if c.L1EthRpc == "" { return ErrMissingL1EthRPC } - if c.RollupRpc == "" { - return ErrMissingRollupRpc + if c.RollupRpc == "" && c.SupervisorRpc == "" { + return ErrMissingRollupAndSupervisorRpc } if c.GameFactoryAddress == (common.Address{}) { return ErrMissingGameFactoryAddress diff --git a/op-dispute-mon/config/config_test.go b/op-dispute-mon/config/config_test.go index f199d8554a3a5..bc4e275cb9723 100644 --- a/op-dispute-mon/config/config_test.go +++ b/op-dispute-mon/config/config_test.go @@ -12,6 +12,7 @@ var ( validL1EthRpc = "http://localhost:8545" validGameFactoryAddress = common.Address{0x23} validRollupRpc = "http://localhost:8555" + validSupervisorRpc = "http://localhost:8999" ) func validConfig() Config { @@ -34,10 +35,25 @@ func TestGameFactoryAddressRequired(t *testing.T) { require.ErrorIs(t, config.Check(), ErrMissingGameFactoryAddress) } -func TestRollupRpcRequired(t *testing.T) { +func TestRollupRpcOrSupervisorRpcRequired(t *testing.T) { config := validConfig() config.RollupRpc = "" - require.ErrorIs(t, config.Check(), ErrMissingRollupRpc) + config.SupervisorRpc = "" + require.ErrorIs(t, config.Check(), ErrMissingRollupAndSupervisorRpc) +} + +func TestRollupRpcNotRequiredWhenSupervisorRpcSet(t *testing.T) { + config := validConfig() + config.RollupRpc = "" + config.SupervisorRpc = validSupervisorRpc + require.NoError(t, config.Check()) +} + +func TestSupervisorRpcNotRequiredWhenRollupRpcSet(t *testing.T) { + config := validConfig() + config.RollupRpc = validRollupRpc + config.SupervisorRpc = "" + require.NoError(t, config.Check()) } func TestMaxConcurrencyRequired(t *testing.T) { diff --git a/op-dispute-mon/flags/flags.go b/op-dispute-mon/flags/flags.go index a8f534162f58c..497c77e97981e 100644 --- a/op-dispute-mon/flags/flags.go +++ b/op-dispute-mon/flags/flags.go @@ -32,12 +32,17 @@ var ( Usage: "HTTP provider URL for L1.", EnvVars: prefixEnvVars("L1_ETH_RPC"), } + // Optional Flags RollupRpcFlag = &cli.StringFlag{ Name: "rollup-rpc", Usage: "HTTP provider URL for the rollup node", EnvVars: prefixEnvVars("ROLLUP_RPC"), } - // Optional Flags + SupervisorRpcFlag = &cli.StringFlag{ + Name: "supervisor-rpc", + Usage: "HTTP provider URL for the supervisor node", + EnvVars: prefixEnvVars("SUPERVISOR_RPC"), + } GameFactoryAddressFlag = &cli.StringFlag{ Name: "game-factory-address", Usage: "Address of the fault game factory contract.", @@ -78,11 +83,12 @@ var ( // requiredFlags are checked by [CheckRequired] var requiredFlags = []cli.Flag{ L1EthRpcFlag, - RollupRpcFlag, } // optionalFlags is a list of unchecked cli flags var optionalFlags = []cli.Flag{ + RollupRpcFlag, + SupervisorRpcFlag, GameFactoryAddressFlag, NetworkFlag, HonestActorsFlag, @@ -109,6 +115,9 @@ func CheckRequired(ctx *cli.Context) error { return fmt.Errorf("flag %s is required", f.Names()[0]) } } + if !ctx.IsSet(RollupRpcFlag.Name) && !ctx.IsSet(SupervisorRpcFlag.Name) { + return fmt.Errorf("flag %s or %s is required", RollupRpcFlag.Name, SupervisorRpcFlag.Name) + } return nil } @@ -156,6 +165,7 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) { L1EthRpc: ctx.String(L1EthRpcFlag.Name), GameFactoryAddress: gameFactoryAddress, RollupRpc: ctx.String(RollupRpcFlag.Name), + SupervisorRpc: ctx.String(SupervisorRpcFlag.Name), HonestActors: actors, MonitorInterval: ctx.Duration(MonitorIntervalFlag.Name), diff --git a/op-dispute-mon/mon/extract/agreement_enricher.go b/op-dispute-mon/mon/extract/output_agreement_enricher.go similarity index 69% rename from op-dispute-mon/mon/extract/agreement_enricher.go rename to op-dispute-mon/mon/extract/output_agreement_enricher.go index dafa09cba9383..67b79a14660d0 100644 --- a/op-dispute-mon/mon/extract/agreement_enricher.go +++ b/op-dispute-mon/mon/extract/output_agreement_enricher.go @@ -2,16 +2,22 @@ package extract import ( "context" + "errors" "fmt" + "slices" "strings" - "time" monTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" + "github.com/ethereum-optimism/optimism/op-service/clock" + "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" +) - "github.com/ethereum-optimism/optimism/op-service/eth" +var ( + ErrRollupRpcRequired = errors.New("rollup rpc required") + outputRootGameTypes = []uint32{0, 1, 2, 3, 6, 254, 255, 1337} ) type OutputRollupClient interface { @@ -23,22 +29,30 @@ type OutputMetrics interface { RecordOutputFetchTime(float64) } -type AgreementEnricher struct { +type OutputAgreementEnricher struct { log log.Logger metrics OutputMetrics client OutputRollupClient + clock clock.Clock } -func NewAgreementEnricher(logger log.Logger, metrics OutputMetrics, client OutputRollupClient) *AgreementEnricher { - return &AgreementEnricher{ +func NewOutputAgreementEnricher(logger log.Logger, metrics OutputMetrics, client OutputRollupClient, cl clock.Clock) *OutputAgreementEnricher { + return &OutputAgreementEnricher{ log: logger, metrics: metrics, client: client, + clock: cl, } } // Enrich validates the specified root claim against the output at the given block number. -func (o *AgreementEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error { +func (o *OutputAgreementEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error { + if !slices.Contains(outputRootGameTypes, game.GameType) { + return nil + } + if o.client == nil { + return fmt.Errorf("%w but required for game type %v", ErrRollupRpcRequired, game.GameType) + } output, err := o.client.OutputAtBlock(ctx, game.L2BlockNumber) if err != nil { // string match as the error comes from the remote server so we can't use Errors.Is sadly. @@ -49,7 +63,7 @@ func (o *AgreementEnricher) Enrich(ctx context.Context, block rpcblock.Block, ca } return fmt.Errorf("failed to get output at block: %w", err) } - o.metrics.RecordOutputFetchTime(float64(time.Now().Unix())) + o.metrics.RecordOutputFetchTime(float64(o.clock.Now().Unix())) game.ExpectedRootClaim = common.Hash(output.OutputRoot) rootMatches := game.RootClaim == game.ExpectedRootClaim if !rootMatches { diff --git a/op-dispute-mon/mon/extract/agreement_enricher_test.go b/op-dispute-mon/mon/extract/output_agreement_enricher_test.go similarity index 70% rename from op-dispute-mon/mon/extract/agreement_enricher_test.go rename to op-dispute-mon/mon/extract/output_agreement_enricher_test.go index bd9d3853e790e..ba9f64a5dbd4c 100644 --- a/op-dispute-mon/mon/extract/agreement_enricher_test.go +++ b/op-dispute-mon/mon/extract/output_agreement_enricher_test.go @@ -3,9 +3,13 @@ package extract import ( "context" "errors" + "fmt" "testing" + "time" + challengerTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" + "github.com/ethereum-optimism/optimism/op-service/clock" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" "github.com/ethereum-optimism/optimism/op-service/testlog" @@ -14,9 +18,67 @@ import ( "github.com/stretchr/testify/require" ) -func TestDetector_CheckRootAgreement(t *testing.T) { +func TestDetector_CheckOutputRootAgreement(t *testing.T) { t.Parallel() + t.Run("ErrorWhenNoRollupClient", func(t *testing.T) { + validator, _, _ := setupOutputValidatorTest(t) + validator.client = nil + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 0, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.ErrorIs(t, err, ErrRollupRpcRequired) + }) + + t.Run("SkipNonOutputRootGameTypes", func(t *testing.T) { + gameTypes := []uint32{4, 5, 7, 8, 10, 49812} + for _, gameType := range gameTypes { + gameType := gameType + t.Run(fmt.Sprintf("GameType_%d", gameType), func(t *testing.T) { + validator, _, metrics := setupOutputValidatorTest(t) + validator.client = nil // Should not error even though there's no rollup client + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: gameType, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Zero(t, metrics.fetchTime) + }) + } + }) + + t.Run("FetchAllOutputRootGameTypes", func(t *testing.T) { + gameTypes := []uint32{0, 1, 2, 3, 6, 254, 255, 1337} + for _, gameType := range gameTypes { + gameType := gameType + t.Run(fmt.Sprintf("GameType_%d", gameType), func(t *testing.T) { + validator, _, metrics := setupOutputValidatorTest(t) + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: gameType, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.NotZero(t, metrics.fetchTime, "should have fetched output root") + }) + } + }) + t.Run("OutputFetchFails", func(t *testing.T) { validator, rollup, metrics := setupOutputValidatorTest(t) rollup.outputErr = errors.New("boom") @@ -136,11 +198,11 @@ func TestDetector_CheckRootAgreement(t *testing.T) { }) } -func setupOutputValidatorTest(t *testing.T) (*AgreementEnricher, *stubRollupClient, *stubOutputMetrics) { +func setupOutputValidatorTest(t *testing.T) (*OutputAgreementEnricher, *stubRollupClient, *stubOutputMetrics) { logger := testlog.Logger(t, log.LvlInfo) client := &stubRollupClient{safeHeadNum: 99999999999} metrics := &stubOutputMetrics{} - validator := NewAgreementEnricher(logger, metrics, client) + validator := NewOutputAgreementEnricher(logger, metrics, client, clock.NewDeterministicClock(time.Unix(9824924, 499))) return validator, client, metrics } diff --git a/op-dispute-mon/mon/extract/super_agreement_enricher.go b/op-dispute-mon/mon/extract/super_agreement_enricher.go new file mode 100644 index 0000000000000..99bae2f7aecdc --- /dev/null +++ b/op-dispute-mon/mon/extract/super_agreement_enricher.go @@ -0,0 +1,67 @@ +package extract + +import ( + "context" + "errors" + "fmt" + "slices" + + monTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" + "github.com/ethereum-optimism/optimism/op-service/clock" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/log" +) + +var ( + ErrSupervisorRpcRequired = errors.New("supervisor rpc required") +) + +type SuperRootProvider interface { + SuperRootAtTimestamp(ctx context.Context, timestamp hexutil.Uint64) (eth.SuperRootResponse, error) +} + +type SuperAgreementEnricher struct { + log log.Logger + metrics OutputMetrics + client SuperRootProvider + clock clock.Clock +} + +func NewSuperAgreementEnricher(logger log.Logger, metrics OutputMetrics, client SuperRootProvider, cl clock.Clock) *SuperAgreementEnricher { + return &SuperAgreementEnricher{ + log: logger, + metrics: metrics, + client: client, + clock: cl, + } +} + +func (e *SuperAgreementEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error { + // TODO: Would be better to have a bool flag that another enricher sets to indicate if the game uses super roots + if slices.Contains(outputRootGameTypes, game.GameType) { + return nil + } + if e.client == nil { + return fmt.Errorf("%w but required for game type %v", ErrSupervisorRpcRequired, game.GameType) + } + response, err := e.client.SuperRootAtTimestamp(ctx, hexutil.Uint64(game.L2BlockNumber)) + if errors.Is(err, ethereum.NotFound) { + // Super root doesn't exist, so we must disagree with it. + game.AgreeWithClaim = false + return nil + } else if err != nil { + return fmt.Errorf("failed to retrieve super root at timestamp %v: %w", game.L2BlockNumber, err) + } + e.metrics.RecordOutputFetchTime(float64(e.clock.Now().Unix())) + game.ExpectedRootClaim = common.Hash(response.SuperRoot) + if game.RootClaim != game.ExpectedRootClaim { + game.AgreeWithClaim = false + return nil + } + game.AgreeWithClaim = response.CrossSafeDerivedFrom.Number <= game.L1HeadNum + return nil +} diff --git a/op-dispute-mon/mon/extract/super_agreement_enricher_test.go b/op-dispute-mon/mon/extract/super_agreement_enricher_test.go new file mode 100644 index 0000000000000..e5c69b06fe6d4 --- /dev/null +++ b/op-dispute-mon/mon/extract/super_agreement_enricher_test.go @@ -0,0 +1,236 @@ +package extract + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + challengerTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" + "github.com/ethereum-optimism/optimism/op-service/clock" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +func TestDetector_CheckSuperRootAgreement(t *testing.T) { + t.Parallel() + + t.Run("ErrorWhenNoSupervisorClient", func(t *testing.T) { + validator, _, _ := setupSuperValidatorTest(t) + validator.client = nil + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.ErrorIs(t, err, ErrSupervisorRpcRequired) + }) + + t.Run("SkipOutputRootGameTypes", func(t *testing.T) { + gameTypes := []uint32{0, 1, 2, 3, 6, 254, 255, 1337} + for _, gameType := range gameTypes { + gameType := gameType + t.Run(fmt.Sprintf("GameType_%d", gameType), func(t *testing.T) { + validator, _, metrics := setupSuperValidatorTest(t) + validator.client = nil // Should not error even though there's no rollup client + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: gameType, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Zero(t, metrics.fetchTime) + }) + } + }) + + t.Run("FetchAllNonOutputRootGameTypes", func(t *testing.T) { + gameTypes := []uint32{4, 5, 7, 8, 10, 49812} // Treat unknown game types as using super roots + for _, gameType := range gameTypes { + gameType := gameType + t.Run(fmt.Sprintf("GameType_%d", gameType), func(t *testing.T) { + validator, _, metrics := setupSuperValidatorTest(t) + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: gameType, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.NotZero(t, metrics.fetchTime, "should have fetched output root") + }) + } + }) + + t.Run("OutputFetchFails", func(t *testing.T) { + validator, rollup, metrics := setupSuperValidatorTest(t) + rollup.outputErr = errors.New("boom") + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 100, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.ErrorIs(t, err, rollup.outputErr) + require.Equal(t, common.Hash{}, game.ExpectedRootClaim) + require.False(t, game.AgreeWithClaim) + require.Zero(t, metrics.fetchTime) + }) + + t.Run("OutputMismatch_Safe", func(t *testing.T) { + validator, _, metrics := setupSuperValidatorTest(t) + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 100, + L2BlockNumber: 0, + RootClaim: common.Hash{}, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Equal(t, mockRootClaim, game.ExpectedRootClaim) + require.False(t, game.AgreeWithClaim) + require.NotZero(t, metrics.fetchTime) + }) + + t.Run("OutputMatches_Safe_DerivedFromGameHead", func(t *testing.T) { + validator, client, metrics := setupSuperValidatorTest(t) + client.derivedFromL1BlockNum = 200 + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Equal(t, mockRootClaim, game.ExpectedRootClaim) + require.True(t, game.AgreeWithClaim) + require.NotZero(t, metrics.fetchTime) + }) + + t.Run("OutputMatches_Safe_DerivedFromBeforeGameHead", func(t *testing.T) { + validator, client, metrics := setupSuperValidatorTest(t) + client.derivedFromL1BlockNum = 199 + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 200, + L2BlockNumber: 0, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Equal(t, mockRootClaim, game.ExpectedRootClaim) + require.True(t, game.AgreeWithClaim) + require.NotZero(t, metrics.fetchTime) + }) + + t.Run("OutputMismatch_NotSafe", func(t *testing.T) { + validator, client, metrics := setupSuperValidatorTest(t) + client.derivedFromL1BlockNum = 101 + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 100, + L2BlockNumber: 0, + RootClaim: common.Hash{}, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Equal(t, mockRootClaim, game.ExpectedRootClaim) + require.False(t, game.AgreeWithClaim) + require.NotZero(t, metrics.fetchTime) + }) + + t.Run("OutputMatches_NotSafe", func(t *testing.T) { + validator, client, metrics := setupSuperValidatorTest(t) + client.derivedFromL1BlockNum = 201 + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 200, + L2BlockNumber: 100, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Equal(t, mockRootClaim, game.ExpectedRootClaim) + require.False(t, game.AgreeWithClaim) + require.NotZero(t, metrics.fetchTime) + }) + + t.Run("OutputNotFound", func(t *testing.T) { + validator, client, metrics := setupSuperValidatorTest(t) + // The supervisor client automatically translates RPC errors back to ethereum.NotFound for us + client.outputErr = ethereum.NotFound + game := &types.EnrichedGameData{ + GameMetadata: challengerTypes.GameMetadata{ + GameType: 999, + }, + L1HeadNum: 100, + L2BlockNumber: 42984924, + RootClaim: mockRootClaim, + } + err := validator.Enrich(context.Background(), rpcblock.Latest, nil, game) + require.NoError(t, err) + require.Equal(t, common.Hash{}, game.ExpectedRootClaim) + require.False(t, game.AgreeWithClaim) + require.Zero(t, metrics.fetchTime) + }) +} + +func setupSuperValidatorTest(t *testing.T) (*SuperAgreementEnricher, *stubSupervisorClient, *stubOutputMetrics) { + logger := testlog.Logger(t, log.LvlInfo) + client := &stubSupervisorClient{derivedFromL1BlockNum: 0} + metrics := &stubOutputMetrics{} + validator := NewSuperAgreementEnricher(logger, metrics, client, clock.NewDeterministicClock(time.Unix(9824924, 499))) + return validator, client, metrics +} + +type stubSupervisorClient struct { + requestedTimestamp uint64 + outputErr error + derivedFromL1BlockNum uint64 +} + +func (s *stubSupervisorClient) SuperRootAtTimestamp(_ context.Context, timestamp hexutil.Uint64) (eth.SuperRootResponse, error) { + s.requestedTimestamp = uint64(timestamp) + if s.outputErr != nil { + return eth.SuperRootResponse{}, s.outputErr + } + return eth.SuperRootResponse{ + CrossSafeDerivedFrom: eth.BlockID{Number: s.derivedFromL1BlockNum}, + Timestamp: uint64(timestamp), + SuperRoot: eth.Bytes32(mockRootClaim), + Version: eth.SuperRootVersionV1, + }, nil +} diff --git a/op-dispute-mon/mon/service.go b/op-dispute-mon/mon/service.go index c44f082d3a886..f7c3e954c56dc 100644 --- a/op-dispute-mon/mon/service.go +++ b/op-dispute-mon/mon/service.go @@ -38,14 +38,15 @@ type Service struct { cl clock.Clock - extractor *extract.Extractor - forecast *Forecast - bonds *bonds.Bonds - game *extract.GameCallerCreator - resolutions *ResolutionMonitor - claims *ClaimMonitor - withdrawals *WithdrawalMonitor - rollupClient *sources.RollupClient + extractor *extract.Extractor + forecast *Forecast + bonds *bonds.Bonds + game *extract.GameCallerCreator + resolutions *ResolutionMonitor + claims *ClaimMonitor + withdrawals *WithdrawalMonitor + rollupClient *sources.RollupClient + supervisorClient *sources.SupervisorClient l1RPC rpcclient.RPC l1Client *sources.L1Client @@ -89,6 +90,9 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error if err := s.initOutputRollupClient(ctx, cfg); err != nil { return fmt.Errorf("failed to init rollup client: %w", err) } + if err := s.initSupervisorClient(ctx, cfg); err != nil { + return fmt.Errorf("failed to init supervisor client: %w", err) + } s.initClaimMonitor(cfg) s.initResolutionMonitor() @@ -139,7 +143,8 @@ func (s *Service) initExtractor(cfg *config.Config) { extract.NewBondEnricher(), extract.NewBalanceEnricher(), extract.NewL1HeadBlockNumEnricher(s.l1Client), - extract.NewAgreementEnricher(s.logger, s.metrics, s.rollupClient), + extract.NewSuperAgreementEnricher(s.logger, s.metrics, s.supervisorClient, clock.SystemClock), + extract.NewOutputAgreementEnricher(s.logger, s.metrics, s.rollupClient, clock.SystemClock), ) } @@ -160,6 +165,18 @@ func (s *Service) initOutputRollupClient(ctx context.Context, cfg *config.Config return nil } +func (s *Service) initSupervisorClient(ctx context.Context, cfg *config.Config) error { + if cfg.SupervisorRpc == "" { + return nil + } + rpcClient, err := dial.DialRPCClientWithTimeout(ctx, dial.DefaultDialTimeout, s.logger, cfg.SupervisorRpc) + if err != nil { + return fmt.Errorf("failed to dial supervisor client: %w", err) + } + s.supervisorClient = sources.NewSupervisorClient(rpcclient.NewBaseRPCClient(rpcClient)) + return nil +} + func (s *Service) initL1Client(ctx context.Context, cfg *config.Config) error { l1RPC, err := dial.DialRPCClientWithTimeout(ctx, dial.DefaultDialTimeout, s.logger, cfg.L1EthRpc) if err != nil { From 578ac5eaa8d0848b4b883b886beb7ff10b5dd31a Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Mon, 3 Mar 2025 06:48:58 +1000 Subject: [PATCH 2/3] op-dispute-mon: Switch to a single getter method for determining root type. --- .../mon/extract/output_agreement_enricher.go | 4 +-- .../mon/extract/super_agreement_enricher.go | 4 +-- op-dispute-mon/mon/types/types.go | 10 ++++++ op-dispute-mon/mon/types/types_test.go | 32 +++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 op-dispute-mon/mon/types/types_test.go diff --git a/op-dispute-mon/mon/extract/output_agreement_enricher.go b/op-dispute-mon/mon/extract/output_agreement_enricher.go index 67b79a14660d0..378ce20465929 100644 --- a/op-dispute-mon/mon/extract/output_agreement_enricher.go +++ b/op-dispute-mon/mon/extract/output_agreement_enricher.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "slices" "strings" monTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" @@ -17,7 +16,6 @@ import ( var ( ErrRollupRpcRequired = errors.New("rollup rpc required") - outputRootGameTypes = []uint32{0, 1, 2, 3, 6, 254, 255, 1337} ) type OutputRollupClient interface { @@ -47,7 +45,7 @@ func NewOutputAgreementEnricher(logger log.Logger, metrics OutputMetrics, client // Enrich validates the specified root claim against the output at the given block number. func (o *OutputAgreementEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error { - if !slices.Contains(outputRootGameTypes, game.GameType) { + if !game.UsesOutputRoots() { return nil } if o.client == nil { diff --git a/op-dispute-mon/mon/extract/super_agreement_enricher.go b/op-dispute-mon/mon/extract/super_agreement_enricher.go index 99bae2f7aecdc..b202fc43a397f 100644 --- a/op-dispute-mon/mon/extract/super_agreement_enricher.go +++ b/op-dispute-mon/mon/extract/super_agreement_enricher.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "slices" monTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" "github.com/ethereum-optimism/optimism/op-service/clock" @@ -41,8 +40,7 @@ func NewSuperAgreementEnricher(logger log.Logger, metrics OutputMetrics, client } func (e *SuperAgreementEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error { - // TODO: Would be better to have a bool flag that another enricher sets to indicate if the game uses super roots - if slices.Contains(outputRootGameTypes, game.GameType) { + if game.UsesOutputRoots() { return nil } if e.client == nil { diff --git a/op-dispute-mon/mon/types/types.go b/op-dispute-mon/mon/types/types.go index c1d5b258acf1d..6cbd31104cb16 100644 --- a/op-dispute-mon/mon/types/types.go +++ b/op-dispute-mon/mon/types/types.go @@ -2,6 +2,7 @@ package types import ( "math/big" + "slices" "time" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts" @@ -10,6 +11,10 @@ import ( "github.com/ethereum/go-ethereum/common" ) +// outputRootGameTypes lists the set of legacy game types that use output roots +// It is assumed that all other game types use super roots +var outputRootGameTypes = []uint32{0, 1, 2, 3, 6, 254, 255, 1337} + // EnrichedClaim extends the faultTypes.Claim with additional context. type EnrichedClaim struct { faultTypes.Claim @@ -56,6 +61,11 @@ type EnrichedGameData struct { ETHCollateral *big.Int } +// UsesOutputRoots returns true if the game type is one of the known types that use output roots as proposals. +func (g EnrichedGameData) UsesOutputRoots() bool { + return slices.Contains(outputRootGameTypes, g.GameType) +} + // BidirectionalTree is a tree of claims represented as a flat list of claims. // This keeps the tree structure identical to how claims are stored in the contract. type BidirectionalTree struct { diff --git a/op-dispute-mon/mon/types/types_test.go b/op-dispute-mon/mon/types/types_test.go new file mode 100644 index 0000000000000..92ecf495ab257 --- /dev/null +++ b/op-dispute-mon/mon/types/types_test.go @@ -0,0 +1,32 @@ +package types + +import ( + "fmt" + "testing" + + "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/stretchr/testify/require" +) + +func TestEnrichedGameData_UsesOutputRoots(t *testing.T) { + for _, gameType := range outputRootGameTypes { + gameType := gameType + t.Run(fmt.Sprintf("GameType-%v", gameType), func(t *testing.T) { + data := EnrichedGameData{ + GameMetadata: types.GameMetadata{GameType: gameType}, + } + require.True(t, data.UsesOutputRoots()) + }) + } + + nonOutputRootTypes := []uint32{4, 5, 9, 42982, 20013130} + for _, gameType := range nonOutputRootTypes { + gameType := gameType + t.Run(fmt.Sprintf("GameType-%v", gameType), func(t *testing.T) { + data := EnrichedGameData{ + GameMetadata: types.GameMetadata{GameType: gameType}, + } + require.False(t, data.UsesOutputRoots()) + }) + } +} From 9b9de01934d80499e8239566d9e7df75e8adecf0 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Wed, 5 Mar 2025 10:38:03 +1000 Subject: [PATCH 3/3] op-dispute-mon: Don't init rollup client if rollup-rpc is not set. --- op-dispute-mon/mon/service.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/op-dispute-mon/mon/service.go b/op-dispute-mon/mon/service.go index f7c3e954c56dc..817501e3a59d4 100644 --- a/op-dispute-mon/mon/service.go +++ b/op-dispute-mon/mon/service.go @@ -157,6 +157,9 @@ func (s *Service) initBonds() { } func (s *Service) initOutputRollupClient(ctx context.Context, cfg *config.Config) error { + if cfg.RollupRpc == "" { + return nil + } outputRollupClient, err := dial.DialRollupClientWithTimeout(ctx, dial.DefaultDialTimeout, s.logger, cfg.RollupRpc) if err != nil { return fmt.Errorf("failed to dial rollup client: %w", err)