Commit 962e5eca authored by refcell's avatar refcell Committed by GitHub

feat(op-dispute-mon): CLI Setup and Monitoring Metrics (#9371)

* feat(ctb): `useFaultProofs` runtime override (#9399)

* Add `useFaultProofs` runtime override

updates

* Update gas snapshot

* improve CI matrix names

* revert e2e changes

* feat(op-dispute-mon): cli setup with metrics

---------
Co-authored-by: default avatarclabby <ben@clab.by>
parent 292eb443
...@@ -18,6 +18,7 @@ var ( ...@@ -18,6 +18,7 @@ var (
methodMaxGameDepth = "maxGameDepth" methodMaxGameDepth = "maxGameDepth"
methodAbsolutePrestate = "absolutePrestate" methodAbsolutePrestate = "absolutePrestate"
methodStatus = "status" methodStatus = "status"
methodRootClaim = "rootClaim"
methodClaimCount = "claimDataLen" methodClaimCount = "claimDataLen"
methodClaim = "claimData" methodClaim = "claimData"
methodL1Head = "l1Head" methodL1Head = "l1Head"
...@@ -78,6 +79,27 @@ func (c *FaultDisputeGameContract) GetBlockRange(ctx context.Context) (prestateB ...@@ -78,6 +79,27 @@ func (c *FaultDisputeGameContract) GetBlockRange(ctx context.Context) (prestateB
return return
} }
// GetGameMetadata returns the game's L2 block number, root claim, and status.
func (c *FaultDisputeGameContract) GetGameMetadata(ctx context.Context) (uint64, common.Hash, gameTypes.GameStatus, error) {
results, err := c.multiCaller.Call(ctx, batching.BlockLatest,
c.contract.Call(methodL2BlockNumber),
c.contract.Call(methodRootClaim),
c.contract.Call(methodStatus))
if err != nil {
return 0, common.Hash{}, 0, fmt.Errorf("failed to retrieve game metadata: %w", err)
}
if len(results) != 3 {
return 0, common.Hash{}, 0, fmt.Errorf("expected 3 results but got %v", len(results))
}
l2BlockNumber := results[0].GetBigInt(0).Uint64()
rootClaim := results[1].GetHash(0)
status, err := gameTypes.GameStatusFromUint8(results[2].GetUint8(0))
if err != nil {
return 0, common.Hash{}, 0, fmt.Errorf("failed to convert game status: %w", err)
}
return l2BlockNumber, rootClaim, status, nil
}
func (c *FaultDisputeGameContract) GetGenesisOutputRoot(ctx context.Context) (common.Hash, error) { func (c *FaultDisputeGameContract) GetGenesisOutputRoot(ctx context.Context) (common.Hash, error) {
genesisOutputRoot, err := c.multiCaller.SingleCall(ctx, batching.BlockLatest, c.contract.Call(methodGenesisOutputRoot)) genesisOutputRoot, err := c.multiCaller.SingleCall(ctx, batching.BlockLatest, c.contract.Call(methodGenesisOutputRoot))
if err != nil { if err != nil {
......
...@@ -278,6 +278,21 @@ func TestGetSplitDepth(t *testing.T) { ...@@ -278,6 +278,21 @@ func TestGetSplitDepth(t *testing.T) {
require.Equal(t, expectedSplitDepth, splitDepth) require.Equal(t, expectedSplitDepth, splitDepth)
} }
func TestGetGameMetadata(t *testing.T) {
stubRpc, contract := setupFaultDisputeGameTest(t)
expectedL2BlockNumber := uint64(123)
expectedRootClaim := common.Hash{0x01, 0x02}
expectedStatus := types.GameStatusChallengerWon
stubRpc.SetResponse(fdgAddr, methodL2BlockNumber, batching.BlockLatest, nil, []interface{}{new(big.Int).SetUint64(expectedL2BlockNumber)})
stubRpc.SetResponse(fdgAddr, methodRootClaim, batching.BlockLatest, nil, []interface{}{expectedRootClaim})
stubRpc.SetResponse(fdgAddr, methodStatus, batching.BlockLatest, nil, []interface{}{expectedStatus})
l2BlockNumber, rootClaim, status, err := contract.GetGameMetadata(context.Background())
require.NoError(t, err)
require.Equal(t, expectedL2BlockNumber, l2BlockNumber)
require.Equal(t, expectedRootClaim, rootClaim)
require.Equal(t, expectedStatus, status)
}
func TestGetGenesisOutputRoot(t *testing.T) { func TestGetGenesisOutputRoot(t *testing.T) {
stubRpc, contract := setupFaultDisputeGameTest(t) stubRpc, contract := setupFaultDisputeGameTest(t)
expectedOutputRoot := common.HexToHash("0x1234") expectedOutputRoot := common.HexToHash("0x1234")
......
...@@ -19,11 +19,6 @@ import ( ...@@ -19,11 +19,6 @@ import (
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
) )
var (
cannonGameType = uint32(0)
alphabetGameType = uint32(255)
)
type CloseFunc func() type CloseFunc func()
type Registry interface { type Registry interface {
...@@ -102,16 +97,16 @@ func registerAlphabet( ...@@ -102,16 +97,16 @@ func registerAlphabet(
genesisValidator := NewPrestateValidator(contract.GetGenesisOutputRoot, prestateProvider) genesisValidator := NewPrestateValidator(contract.GetGenesisOutputRoot, prestateProvider)
return NewGamePlayer(ctx, cl, logger, m, dir, game.Proxy, txSender, contract, []Validator{prestateValidator, genesisValidator}, creator) return NewGamePlayer(ctx, cl, logger, m, dir, game.Proxy, txSender, contract, []Validator{prestateValidator, genesisValidator}, creator)
} }
oracle, err := createOracle(ctx, gameFactory, caller, alphabetGameType) oracle, err := createOracle(ctx, gameFactory, caller, faultTypes.AlphabetGameType)
if err != nil { if err != nil {
return err return err
} }
registry.RegisterGameType(alphabetGameType, playerCreator, oracle) registry.RegisterGameType(faultTypes.AlphabetGameType, playerCreator, oracle)
contractCreator := func(game types.GameMetadata) (claims.BondContract, error) { contractCreator := func(game types.GameMetadata) (claims.BondContract, error) {
return contracts.NewFaultDisputeGameContract(game.Proxy, caller) return contracts.NewFaultDisputeGameContract(game.Proxy, caller)
} }
registry.RegisterBondContract(alphabetGameType, contractCreator) registry.RegisterBondContract(faultTypes.AlphabetGameType, contractCreator)
return nil return nil
} }
...@@ -169,15 +164,15 @@ func registerCannon( ...@@ -169,15 +164,15 @@ func registerCannon(
genesisValidator := NewPrestateValidator(contract.GetGenesisOutputRoot, prestateProvider) genesisValidator := NewPrestateValidator(contract.GetGenesisOutputRoot, prestateProvider)
return NewGamePlayer(ctx, cl, logger, m, dir, game.Proxy, txSender, contract, []Validator{prestateValidator, genesisValidator}, creator) return NewGamePlayer(ctx, cl, logger, m, dir, game.Proxy, txSender, contract, []Validator{prestateValidator, genesisValidator}, creator)
} }
oracle, err := createOracle(ctx, gameFactory, caller, cannonGameType) oracle, err := createOracle(ctx, gameFactory, caller, faultTypes.CannonGameType)
if err != nil { if err != nil {
return err return err
} }
registry.RegisterGameType(cannonGameType, playerCreator, oracle) registry.RegisterGameType(faultTypes.CannonGameType, playerCreator, oracle)
contractCreator := func(game types.GameMetadata) (claims.BondContract, error) { contractCreator := func(game types.GameMetadata) (claims.BondContract, error) {
return contracts.NewFaultDisputeGameContract(game.Proxy, caller) return contracts.NewFaultDisputeGameContract(game.Proxy, caller)
} }
registry.RegisterBondContract(cannonGameType, contractCreator) registry.RegisterBondContract(faultTypes.CannonGameType, contractCreator)
return nil return nil
} }
...@@ -18,6 +18,11 @@ var ( ...@@ -18,6 +18,11 @@ var (
NoLocalContext = common.Hash{} NoLocalContext = common.Hash{}
) )
const (
CannonGameType uint32 = 0
AlphabetGameType uint32 = 255
)
type ClockReader interface { type ClockReader interface {
Now() time.Time Now() time.Time
} }
......
...@@ -3,27 +3,49 @@ package config ...@@ -3,27 +3,49 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum-optimism/optimism/op-service/oppprof" "github.com/ethereum-optimism/optimism/op-service/oppprof"
"github.com/ethereum/go-ethereum/common"
) )
var ( var (
ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url") ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url")
ErrMissingGameFactoryAddress = errors.New("missing game factory address")
)
const (
// DefaultGameWindow is the default maximum time duration in the past
// to look for games to monitor. The default value is 11 days, which
// is a 4 day resolution buffer plus the 7 day game finalization window.
DefaultGameWindow = time.Duration(11 * 24 * time.Hour)
// DefaultMonitorInterval is the default interval at which the dispute
// monitor will check for new games to monitor.
DefaultMonitorInterval = time.Second * 30
) )
// Config is a well typed config that is parsed from the CLI params. // Config is a well typed config that is parsed from the CLI params.
// It also contains config options for auxiliary services. // It also contains config options for auxiliary services.
type Config struct { type Config struct {
L1EthRpc string // L1 RPC Url L1EthRpc string // L1 RPC Url
GameFactoryAddress common.Address // Address of the dispute game factory
MonitorInterval time.Duration // Frequency to check for new games to monitor.
GameWindow time.Duration // Maximum window to look for games to monitor.
MetricsConfig opmetrics.CLIConfig MetricsConfig opmetrics.CLIConfig
PprofConfig oppprof.CLIConfig PprofConfig oppprof.CLIConfig
} }
func NewConfig(l1EthRpc string) Config { func NewConfig(gameFactoryAddress common.Address, l1EthRpc string) Config {
return Config{ return Config{
L1EthRpc: l1EthRpc, L1EthRpc: l1EthRpc,
GameFactoryAddress: gameFactoryAddress,
MonitorInterval: DefaultMonitorInterval,
GameWindow: DefaultGameWindow,
MetricsConfig: opmetrics.DefaultCLIConfig(), MetricsConfig: opmetrics.DefaultCLIConfig(),
PprofConfig: oppprof.DefaultCLIConfig(), PprofConfig: oppprof.DefaultCLIConfig(),
} }
...@@ -33,6 +55,9 @@ func (c Config) Check() error { ...@@ -33,6 +55,9 @@ func (c Config) Check() error {
if c.L1EthRpc == "" { if c.L1EthRpc == "" {
return ErrMissingL1EthRPC return ErrMissingL1EthRPC
} }
if c.GameFactoryAddress == (common.Address{}) {
return ErrMissingGameFactoryAddress
}
if err := c.MetricsConfig.Check(); err != nil { if err := c.MetricsConfig.Check(); err != nil {
return fmt.Errorf("metrics config: %w", err) return fmt.Errorf("metrics config: %w", err)
} }
......
...@@ -4,14 +4,21 @@ import ( ...@@ -4,14 +4,21 @@ import (
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/ethereum/go-ethereum/common"
) )
var ( var (
validL1EthRpc = "http://localhost:8545" validL1EthRpc = "http://localhost:8545"
validGameFactoryAddress = common.Address{0x23}
) )
func validConfig() Config { func validConfig() Config {
return NewConfig(validL1EthRpc) return NewConfig(validGameFactoryAddress, validL1EthRpc)
}
func TestValidConfigIsValid(t *testing.T) {
require.NoError(t, validConfig().Check())
} }
func TestL1EthRpcRequired(t *testing.T) { func TestL1EthRpcRequired(t *testing.T) {
...@@ -19,3 +26,9 @@ func TestL1EthRpcRequired(t *testing.T) { ...@@ -19,3 +26,9 @@ func TestL1EthRpcRequired(t *testing.T) {
config.L1EthRpc = "" config.L1EthRpc = ""
require.ErrorIs(t, config.Check(), ErrMissingL1EthRPC) require.ErrorIs(t, config.Check(), ErrMissingL1EthRPC)
} }
func TestGameFactoryAddressRequired(t *testing.T) {
config := validConfig()
config.GameFactoryAddress = common.Address{}
require.ErrorIs(t, config.Check(), ErrMissingGameFactoryAddress)
}
...@@ -27,16 +27,38 @@ var ( ...@@ -27,16 +27,38 @@ var (
Usage: "HTTP provider URL for L1.", Usage: "HTTP provider URL for L1.",
EnvVars: prefixEnvVars("L1_ETH_RPC"), EnvVars: prefixEnvVars("L1_ETH_RPC"),
} }
FactoryAddressFlag = &cli.StringFlag{
Name: "game-factory-address",
Usage: "Address of the fault game factory contract.",
EnvVars: prefixEnvVars("GAME_FACTORY_ADDRESS"),
}
// Optional Flags // Optional Flags
MonitorIntervalFlag = &cli.DurationFlag{
Name: "monitor-interval",
Usage: "The interval at which the dispute monitor will check for new games to monitor.",
EnvVars: prefixEnvVars("MONITOR_INTERVAL"),
Value: config.DefaultMonitorInterval,
}
GameWindowFlag = &cli.DurationFlag{
Name: "game-window",
Usage: "The time window which the monitor will consider games to report on. " +
"This should include a bond claim buffer for games outside the maximum game duration.",
EnvVars: prefixEnvVars("GAME_WINDOW"),
Value: config.DefaultGameWindow,
}
) )
// requiredFlags are checked by [CheckRequired] // requiredFlags are checked by [CheckRequired]
var requiredFlags = []cli.Flag{ var requiredFlags = []cli.Flag{
L1EthRpcFlag, L1EthRpcFlag,
FactoryAddressFlag,
} }
// optionalFlags is a list of unchecked cli flags // optionalFlags is a list of unchecked cli flags
var optionalFlags = []cli.Flag{} var optionalFlags = []cli.Flag{
MonitorIntervalFlag,
GameWindowFlag,
}
func init() { func init() {
optionalFlags = append(optionalFlags, oplog.CLIFlags(envVarPrefix)...) optionalFlags = append(optionalFlags, oplog.CLIFlags(envVarPrefix)...)
...@@ -63,12 +85,21 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) { ...@@ -63,12 +85,21 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) {
if err := CheckRequired(ctx); err != nil { if err := CheckRequired(ctx); err != nil {
return nil, err return nil, err
} }
gameFactoryAddress, err := opservice.ParseAddress(ctx.String(FactoryAddressFlag.Name))
if err != nil {
return nil, err
}
metricsConfig := opmetrics.ReadCLIConfig(ctx) metricsConfig := opmetrics.ReadCLIConfig(ctx)
pprofConfig := oppprof.ReadCLIConfig(ctx) pprofConfig := oppprof.ReadCLIConfig(ctx)
return &config.Config{ return &config.Config{
L1EthRpc: ctx.String(L1EthRpcFlag.Name), L1EthRpc: ctx.String(L1EthRpcFlag.Name),
GameFactoryAddress: gameFactoryAddress,
MonitorInterval: ctx.Duration(MonitorIntervalFlag.Name),
GameWindow: ctx.Duration(GameWindowFlag.Name),
MetricsConfig: metricsConfig, MetricsConfig: metricsConfig,
PprofConfig: pprofConfig, PprofConfig: pprofConfig,
}, nil }, nil
......
...@@ -19,6 +19,8 @@ type Metricer interface { ...@@ -19,6 +19,8 @@ type Metricer interface {
RecordInfo(version string) RecordInfo(version string)
RecordUp() RecordUp()
RecordGamesStatus(inProgress, defenderWon, challengerWon int)
caching.Metrics caching.Metrics
} }
...@@ -34,6 +36,8 @@ type Metrics struct { ...@@ -34,6 +36,8 @@ type Metrics struct {
info prometheus.GaugeVec info prometheus.GaugeVec
up prometheus.Gauge up prometheus.Gauge
trackedGames prometheus.GaugeVec
} }
func (m *Metrics) Registry() *prometheus.Registry { func (m *Metrics) Registry() *prometheus.Registry {
...@@ -65,6 +69,13 @@ func NewMetrics() *Metrics { ...@@ -65,6 +69,13 @@ func NewMetrics() *Metrics {
Name: "up", Name: "up",
Help: "1 if the op-challenger has finished starting up", Help: "1 if the op-challenger has finished starting up",
}), }),
trackedGames: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "tracked_games",
Help: "Number of games being tracked by the challenger",
}, []string{
"status",
}),
} }
} }
...@@ -95,3 +106,9 @@ func (m *Metrics) RecordUp() { ...@@ -95,3 +106,9 @@ func (m *Metrics) RecordUp() {
func (m *Metrics) Document() []opmetrics.DocumentedMetric { func (m *Metrics) Document() []opmetrics.DocumentedMetric {
return m.factory.Document() return m.factory.Document()
} }
func (m *Metrics) RecordGamesStatus(inProgress, defenderWon, challengerWon int) {
m.trackedGames.WithLabelValues("in_progress").Set(float64(inProgress))
m.trackedGames.WithLabelValues("defender_won").Set(float64(defenderWon))
m.trackedGames.WithLabelValues("challenger_won").Set(float64(challengerWon))
}
...@@ -9,3 +9,5 @@ func (*NoopMetricsImpl) RecordUp() {} ...@@ -9,3 +9,5 @@ func (*NoopMetricsImpl) RecordUp() {}
func (*NoopMetricsImpl) CacheAdd(_ string, _ int, _ bool) {} func (*NoopMetricsImpl) CacheAdd(_ string, _ int, _ bool) {}
func (*NoopMetricsImpl) CacheGet(_ string, _ bool) {} func (*NoopMetricsImpl) CacheGet(_ string, _ bool) {}
func (*NoopMetricsImpl) RecordGamesStatus(inProgress, defenderWon, challengerWon int) {}
package mon
import (
"context"
"fmt"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/sources/batching"
"github.com/ethereum-optimism/optimism/op-service/sources/caching"
)
const metricsLabel = "binding_creator"
type MetadataLoader interface {
GetGameMetadata(context.Context) (uint64, common.Hash, types.GameStatus, error)
}
type metadataCreator struct {
cache *caching.LRUCache[common.Address, *contracts.FaultDisputeGameContract]
caller *batching.MultiCaller
}
func NewMetadataCreator(m caching.Metrics, caller *batching.MultiCaller) *metadataCreator {
return &metadataCreator{
caller: caller,
cache: caching.NewLRUCache[common.Address, *contracts.FaultDisputeGameContract](m, metricsLabel, 100),
}
}
func (m *metadataCreator) CreateContract(game types.GameMetadata) (MetadataLoader, error) {
if fdg, ok := m.cache.Get(game.Proxy); ok {
return fdg, nil
}
switch game.GameType {
case faultTypes.CannonGameType, faultTypes.AlphabetGameType:
fdg, err := contracts.NewFaultDisputeGameContract(game.Proxy, m.caller)
if err != nil {
return nil, fmt.Errorf("failed to create FaultDisputeGameContract: %w", err)
}
m.cache.Add(game.Proxy, fdg)
return fdg, nil
default:
return nil, fmt.Errorf("unsupported game type: %d", game.GameType)
}
}
package mon
import (
"fmt"
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/sources/batching"
batchingTest "github.com/ethereum-optimism/optimism/op-service/sources/batching/test"
"github.com/stretchr/testify/require"
)
var (
fdgAddr = common.HexToAddress("0x24112842371dFC380576ebb09Ae16Cb6B6caD7CB")
)
func TestMetadataCreator_CreateContract(t *testing.T) {
tests := []struct {
name string
game types.GameMetadata
expectedErr error
}{
{
name: "validCannonGameType",
game: types.GameMetadata{GameType: faultTypes.CannonGameType, Proxy: fdgAddr},
},
{
name: "validAlphabetGameType",
game: types.GameMetadata{GameType: faultTypes.AlphabetGameType, Proxy: fdgAddr},
},
{
name: "InvalidGameType",
game: types.GameMetadata{GameType: 2, Proxy: fdgAddr},
expectedErr: fmt.Errorf("unsupported game type: 2"),
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
caller, metrics := setupMetadataLoaderTest(t)
creator := NewMetadataCreator(metrics, caller)
_, err := creator.CreateContract(test.game)
require.Equal(t, test.expectedErr, err)
if test.expectedErr == nil {
require.Equal(t, 1, metrics.cacheAddCalls)
require.Equal(t, 1, metrics.cacheGetCalls)
}
_, err = creator.CreateContract(test.game)
require.Equal(t, test.expectedErr, err)
if test.expectedErr == nil {
require.Equal(t, 1, metrics.cacheAddCalls)
require.Equal(t, 2, metrics.cacheGetCalls)
}
})
}
}
func setupMetadataLoaderTest(t *testing.T) (*batching.MultiCaller, *mockCacheMetrics) {
fdgAbi, err := bindings.FaultDisputeGameMetaData.GetAbi()
require.NoError(t, err)
stubRpc := batchingTest.NewAbiBasedRpc(t, fdgAddr, fdgAbi)
caller := batching.NewMultiCaller(stubRpc, batching.DefaultBatchSize)
return caller, &mockCacheMetrics{}
}
type mockCacheMetrics struct {
cacheAddCalls int
cacheGetCalls int
}
func (m *mockCacheMetrics) CacheAdd(_ string, _ int, _ bool) {
m.cacheAddCalls++
}
func (m *mockCacheMetrics) CacheGet(_ string, _ bool) {
m.cacheGetCalls++
}
package mon
import (
"context"
"fmt"
"math/big"
"time"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/clock"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
)
type blockNumberFetcher func(ctx context.Context) (uint64, error)
type blockHashFetcher func(ctx context.Context, number *big.Int) (common.Hash, error)
// gameSource loads information about the games available to play
type gameSource interface {
GetGamesAtOrAfter(ctx context.Context, blockHash common.Hash, earliestTimestamp uint64) ([]types.GameMetadata, error)
}
type MonitorMetricer interface {
RecordGamesStatus(inProgress, defenderWon, challengerWon int)
}
type MetadataCreator interface {
CreateContract(game types.GameMetadata) (MetadataLoader, error)
}
type gameMonitor struct {
logger log.Logger
metrics MonitorMetricer
ctx context.Context
cancel context.CancelFunc
clock clock.Clock
monitorInterval time.Duration
done chan struct{}
source gameSource
metadata MetadataCreator
gameWindow time.Duration
fetchBlockNumber blockNumberFetcher
fetchBlockHash blockHashFetcher
}
func newGameMonitor(
ctx context.Context,
logger log.Logger,
metrics MonitorMetricer,
cl clock.Clock,
monitorInterval time.Duration,
source gameSource,
metadata MetadataCreator,
gameWindow time.Duration,
fetchBlockNumber blockNumberFetcher,
fetchBlockHash blockHashFetcher,
) *gameMonitor {
return &gameMonitor{
logger: logger,
metrics: metrics,
ctx: ctx,
clock: cl,
done: make(chan struct{}),
monitorInterval: monitorInterval,
source: source,
metadata: metadata,
gameWindow: gameWindow,
fetchBlockNumber: fetchBlockNumber,
fetchBlockHash: fetchBlockHash,
}
}
func (m *gameMonitor) minGameTimestamp() uint64 {
if m.gameWindow.Seconds() == 0 {
return 0
}
// time: "To compute t-d for a duration d, use t.Add(-d)."
// https://pkg.go.dev/time#Time.Sub
if m.clock.Now().Unix() > int64(m.gameWindow.Seconds()) {
return uint64(m.clock.Now().Add(-m.gameWindow).Unix())
}
return 0
}
func (m *gameMonitor) monitorGames() error {
blockNumber, err := m.fetchBlockNumber(m.ctx)
if err != nil {
return fmt.Errorf("Failed to fetch block number: %w", err)
}
m.logger.Debug("Fetched block number", "blockNumber", blockNumber)
blockHash, err := m.fetchBlockHash(context.Background(), new(big.Int).SetUint64(blockNumber))
if err != nil {
return fmt.Errorf("Failed to fetch block hash: %w", err)
}
games, err := m.source.GetGamesAtOrAfter(m.ctx, blockHash, m.minGameTimestamp())
if err != nil {
return fmt.Errorf("failed to load games: %w", err)
}
return m.recordGamesStatus(m.ctx, games)
}
func (m *gameMonitor) recordGamesStatus(ctx context.Context, games []types.GameMetadata) error {
inProgress, defenderWon, challengerWon := 0, 0, 0
for _, game := range games {
loader, err := m.metadata.CreateContract(game)
if err != nil {
m.logger.Error("Failed to create contract", "err", err)
continue
}
_, _, status, err := loader.GetGameMetadata(ctx)
if err != nil {
m.logger.Error("Failed to get game metadata", "err", err)
continue
}
switch status {
case types.GameStatusInProgress:
inProgress++
case types.GameStatusDefenderWon:
defenderWon++
case types.GameStatusChallengerWon:
challengerWon++
}
}
m.metrics.RecordGamesStatus(inProgress, defenderWon, challengerWon)
return nil
}
func (m *gameMonitor) loop() {
ticker := m.clock.NewTicker(m.monitorInterval)
defer ticker.Stop()
for {
select {
case <-ticker.Ch():
if err := m.monitorGames(); err != nil {
m.logger.Error("Failed to monitor games", "err", err)
}
case <-m.done:
m.logger.Info("Stopping game monitor")
return
}
}
}
func (m *gameMonitor) StartMonitoring() {
// Setup the cancellation only if it's not already set.
// This prevents overwriting the context and cancel function
// if, for example, this function is called multiple times.
if m.cancel == nil {
ctx, cancel := context.WithCancel(m.ctx)
m.ctx = ctx
m.cancel = cancel
}
m.logger.Info("Starting game monitor")
go m.loop()
}
func (m *gameMonitor) StopMonitoring() {
m.logger.Info("Stopping game monitor")
if m.cancel != nil {
m.cancel()
m.cancel = nil
}
close(m.done)
}
package mon
import (
"context"
"errors"
"math/big"
"testing"
"time"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/clock"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
var (
mockErr = errors.New("mock error")
)
func TestMonitor_MinGameTimestamp(t *testing.T) {
t.Parallel()
t.Run("zero game window returns zero", func(t *testing.T) {
monitor, _, _, _ := setupMonitorTest(t)
monitor.gameWindow = time.Duration(0)
require.Equal(t, monitor.minGameTimestamp(), uint64(0))
})
t.Run("non-zero game window with zero clock", func(t *testing.T) {
monitor, _, _, _ := setupMonitorTest(t)
monitor.gameWindow = time.Minute
monitor.clock = clock.NewDeterministicClock(time.Unix(0, 0))
require.Equal(t, uint64(0), monitor.minGameTimestamp())
})
t.Run("minimum computed correctly", func(t *testing.T) {
monitor, _, _, _ := setupMonitorTest(t)
monitor.gameWindow = time.Minute
frozen := time.Unix(int64(time.Hour.Seconds()), 0)
monitor.clock = clock.NewDeterministicClock(frozen)
expected := uint64(frozen.Add(-time.Minute).Unix())
require.Equal(t, monitor.minGameTimestamp(), expected)
})
}
func TestMonitor_RecordGamesStatus(t *testing.T) {
tests := []struct {
name string
games []types.GameMetadata
status func(loader *mockMetadataLoader)
creator func(creator *mockMetadataCreator)
metrics func(m *stubMonitorMetricer)
}{
{
name: "NoGames",
games: []types.GameMetadata{},
metrics: func(m *stubMonitorMetricer) {
require.Equal(t, 0, m.inProgress)
require.Equal(t, 0, m.defenderWon)
require.Equal(t, 0, m.challengerWon)
},
},
{
name: "InProgress",
games: []types.GameMetadata{{}},
metrics: func(m *stubMonitorMetricer) {
require.Equal(t, 1, m.inProgress)
require.Equal(t, 0, m.defenderWon)
require.Equal(t, 0, m.challengerWon)
},
},
{
name: "DefenderWon",
games: []types.GameMetadata{{}},
status: func(loader *mockMetadataLoader) {
loader.status = types.GameStatusDefenderWon
},
metrics: func(m *stubMonitorMetricer) {
require.Equal(t, 0, m.inProgress)
require.Equal(t, 1, m.defenderWon)
require.Equal(t, 0, m.challengerWon)
},
},
{
name: "ChallengerWon",
games: []types.GameMetadata{{}},
status: func(loader *mockMetadataLoader) {
loader.status = types.GameStatusChallengerWon
},
metrics: func(m *stubMonitorMetricer) {
require.Equal(t, 0, m.inProgress)
require.Equal(t, 0, m.defenderWon)
require.Equal(t, 1, m.challengerWon)
},
},
{
name: "MetadataLoaderError",
games: []types.GameMetadata{{}},
status: func(loader *mockMetadataLoader) {
loader.err = mockErr
},
metrics: func(m *stubMonitorMetricer) {
require.Equal(t, 0, m.inProgress)
require.Equal(t, 0, m.defenderWon)
require.Equal(t, 0, m.challengerWon)
},
},
{
name: "MetadataCreatorError",
games: []types.GameMetadata{{}},
creator: func(creator *mockMetadataCreator) { creator.err = mockErr },
metrics: func(m *stubMonitorMetricer) {
require.Equal(t, 0, m.inProgress)
require.Equal(t, 0, m.defenderWon)
require.Equal(t, 0, m.challengerWon)
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
monitor, _, metrics, creator := setupMonitorTest(t)
if test.status != nil {
test.status(creator.loader)
}
if test.creator != nil {
test.creator(creator)
}
err := monitor.recordGamesStatus(context.Background(), test.games)
require.NoError(t, err) // All errors are handled gracefully
test.metrics(metrics)
})
}
}
func TestMonitor_MonitorGames(t *testing.T) {
t.Parallel()
t.Run("FailedFetchBlocknumber", func(t *testing.T) {
monitor, _, _, _ := setupMonitorTest(t)
boom := errors.New("boom")
monitor.fetchBlockNumber = func(ctx context.Context) (uint64, error) {
return 0, boom
}
err := monitor.monitorGames()
require.ErrorIs(t, err, boom)
})
t.Run("FailedFetchBlockHash", func(t *testing.T) {
monitor, _, _, _ := setupMonitorTest(t)
boom := errors.New("boom")
monitor.fetchBlockHash = func(ctx context.Context, number *big.Int) (common.Hash, error) {
return common.Hash{}, boom
}
err := monitor.monitorGames()
require.ErrorIs(t, err, boom)
})
t.Run("NoGames", func(t *testing.T) {
monitor, source, _, creator := setupMonitorTest(t)
source.games = []types.GameMetadata{}
err := monitor.monitorGames()
require.NoError(t, err)
require.Equal(t, 0, creator.calls)
})
t.Run("CreatorErrorsHandled", func(t *testing.T) {
monitor, source, _, creator := setupMonitorTest(t)
source.games = []types.GameMetadata{{}}
creator.err = errors.New("boom")
err := monitor.monitorGames()
require.NoError(t, err)
require.Equal(t, 1, creator.calls)
})
t.Run("Success", func(t *testing.T) {
monitor, source, metrics, _ := setupMonitorTest(t)
source.games = []types.GameMetadata{{}, {}, {}}
err := monitor.monitorGames()
require.NoError(t, err)
require.Equal(t, len(source.games), metrics.inProgress)
})
}
func TestMonitor_StartMonitoring(t *testing.T) {
t.Run("Monitors games", func(t *testing.T) {
addr1 := common.Address{0xaa}
addr2 := common.Address{0xbb}
monitor, source, metrics, _ := setupMonitorTest(t)
source.games = []types.GameMetadata{newFDG(addr1, 9999), newFDG(addr2, 9999)}
source.maxSuccess = len(source.games) // Only allow two successful fetches
monitor.StartMonitoring()
require.Eventually(t, func() bool {
return metrics.inProgress == 2
}, time.Second, 50*time.Millisecond)
monitor.StopMonitoring()
require.Equal(t, len(source.games), metrics.inProgress) // Each game's status is recorded twice
})
t.Run("Fails to monitor games", func(t *testing.T) {
monitor, source, metrics, _ := setupMonitorTest(t)
source.fetchErr = errors.New("boom")
monitor.StartMonitoring()
require.Eventually(t, func() bool {
return source.calls > 0
}, time.Second, 50*time.Millisecond)
monitor.StopMonitoring()
require.Equal(t, 0, metrics.inProgress)
require.Equal(t, 0, metrics.defenderWon)
require.Equal(t, 0, metrics.challengerWon)
})
}
func newFDG(proxy common.Address, timestamp uint64) types.GameMetadata {
return types.GameMetadata{
Proxy: proxy,
Timestamp: timestamp,
}
}
func setupMonitorTest(t *testing.T) (*gameMonitor, *stubGameSource, *stubMonitorMetricer, *mockMetadataCreator) {
logger := testlog.Logger(t, log.LvlDebug)
source := &stubGameSource{}
fetchBlockNum := func(ctx context.Context) (uint64, error) {
return 1, nil
}
fetchBlockHash := func(ctx context.Context, number *big.Int) (common.Hash, error) {
return common.Hash{}, nil
}
metrics := &stubMonitorMetricer{}
monitorInterval := time.Duration(100 * time.Millisecond)
loader := &mockMetadataLoader{}
creator := &mockMetadataCreator{loader: loader}
cl := clock.NewAdvancingClock(10 * time.Millisecond)
cl.Start()
monitor := newGameMonitor(
context.Background(),
logger,
metrics,
cl,
monitorInterval,
source,
creator,
time.Duration(10*time.Second),
fetchBlockNum,
fetchBlockHash,
)
return monitor, source, metrics, creator
}
type mockMetadataCreator struct {
calls int
err error
loader *mockMetadataLoader
}
func (m *mockMetadataCreator) CreateContract(game types.GameMetadata) (MetadataLoader, error) {
m.calls++
if m.err != nil {
return nil, m.err
}
return m.loader, nil
}
type mockMetadataLoader struct {
calls int
status types.GameStatus
err error
}
func (m *mockMetadataLoader) GetGameMetadata(ctx context.Context) (uint64, common.Hash, types.GameStatus, error) {
m.calls++
if m.err != nil {
return 0, common.Hash{}, m.status, m.err
}
return 0, common.Hash{}, m.status, nil
}
type stubMonitorMetricer struct {
inProgress int
defenderWon int
challengerWon int
}
func (s *stubMonitorMetricer) RecordGamesStatus(inProgress, defenderWon, challengerWon int) {
s.inProgress = inProgress
s.defenderWon = defenderWon
s.challengerWon = challengerWon
}
type stubGameSource struct {
fetchErr error
calls int
maxSuccess int
games []types.GameMetadata
}
func (s *stubGameSource) GetGamesAtOrAfter(
_ context.Context,
_ common.Hash,
_ uint64,
) ([]types.GameMetadata, error) {
s.calls++
if s.fetchErr != nil {
return nil, s.fetchErr
}
if s.calls > s.maxSuccess && s.maxSuccess != 0 {
return nil, mockErr
}
return s.games, nil
}
...@@ -4,23 +4,36 @@ import ( ...@@ -4,23 +4,36 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math/big"
"sync/atomic" "sync/atomic"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
"github.com/ethereum-optimism/optimism/op-dispute-mon/config" "github.com/ethereum-optimism/optimism/op-dispute-mon/config"
"github.com/ethereum-optimism/optimism/op-dispute-mon/metrics" "github.com/ethereum-optimism/optimism/op-dispute-mon/metrics"
"github.com/ethereum-optimism/optimism/op-dispute-mon/version" "github.com/ethereum-optimism/optimism/op-dispute-mon/version"
"github.com/ethereum-optimism/optimism/op-service/clock"
"github.com/ethereum-optimism/optimism/op-service/dial" "github.com/ethereum-optimism/optimism/op-service/dial"
"github.com/ethereum-optimism/optimism/op-service/httputil" "github.com/ethereum-optimism/optimism/op-service/httputil"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum-optimism/optimism/op-service/oppprof" "github.com/ethereum-optimism/optimism/op-service/oppprof"
"github.com/ethereum-optimism/optimism/op-service/sources/batching"
) )
type Service struct { type Service struct {
logger log.Logger logger log.Logger
metrics metrics.Metricer metrics metrics.Metricer
monitor *gameMonitor
factoryContract *contracts.DisputeGameFactoryContract
cl clock.Clock
metadata *metadataCreator
l1Client *ethclient.Client l1Client *ethclient.Client
...@@ -33,6 +46,7 @@ type Service struct { ...@@ -33,6 +46,7 @@ type Service struct {
// NewService creates a new Service. // NewService creates a new Service.
func NewService(ctx context.Context, logger log.Logger, cfg *config.Config) (*Service, error) { func NewService(ctx context.Context, logger log.Logger, cfg *config.Config) (*Service, error) {
s := &Service{ s := &Service{
cl: clock.SystemClock,
logger: logger, logger: logger,
metrics: metrics.NewMetrics(), metrics: metrics.NewMetrics(),
} }
...@@ -54,12 +68,22 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error ...@@ -54,12 +68,22 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error
if err := s.initMetricsServer(&cfg.MetricsConfig); err != nil { if err := s.initMetricsServer(&cfg.MetricsConfig); err != nil {
return fmt.Errorf("failed to init metrics server: %w", err) return fmt.Errorf("failed to init metrics server: %w", err)
} }
if err := s.initFactoryContract(cfg); err != nil {
return fmt.Errorf("failed to create factory contract bindings: %w", err)
}
s.initMetadataCreator()
s.initMonitor(ctx, cfg)
s.metrics.RecordInfo(version.SimpleWithMeta) s.metrics.RecordInfo(version.SimpleWithMeta)
s.metrics.RecordUp() s.metrics.RecordUp()
return nil return nil
} }
func (s *Service) initMetadataCreator() {
s.metadata = NewMetadataCreator(s.metrics, batching.NewMultiCaller(s.l1Client.Client(), batching.DefaultBatchSize))
}
func (s *Service) initL1Client(ctx context.Context, cfg *config.Config) error { func (s *Service) initL1Client(ctx context.Context, cfg *config.Config) error {
l1Client, err := dial.DialEthClientWithTimeout(ctx, dial.DefaultDialTimeout, s.logger, cfg.L1EthRpc) l1Client, err := dial.DialEthClientWithTimeout(ctx, dial.DefaultDialTimeout, s.logger, cfg.L1EthRpc)
if err != nil { if err != nil {
...@@ -104,9 +128,42 @@ func (s *Service) initMetricsServer(cfg *opmetrics.CLIConfig) error { ...@@ -104,9 +128,42 @@ func (s *Service) initMetricsServer(cfg *opmetrics.CLIConfig) error {
return nil return nil
} }
func (s *Service) initFactoryContract(cfg *config.Config) error {
factoryContract, err := contracts.NewDisputeGameFactoryContract(cfg.GameFactoryAddress,
batching.NewMultiCaller(s.l1Client.Client(), batching.DefaultBatchSize))
if err != nil {
return fmt.Errorf("failed to bind the fault dispute game factory contract: %w", err)
}
s.factoryContract = factoryContract
return nil
}
func (s *Service) initMonitor(ctx context.Context, cfg *config.Config) {
blockHashFetcher := func(ctx context.Context, blockNumber *big.Int) (common.Hash, error) {
block, err := s.l1Client.BlockByNumber(ctx, blockNumber)
if err != nil {
return common.Hash{}, fmt.Errorf("failed to fetch block by number: %w", err)
}
return block.Hash(), nil
}
s.monitor = newGameMonitor(
ctx,
s.logger,
s.metrics,
s.cl,
cfg.MonitorInterval,
s.factoryContract,
s.metadata,
cfg.GameWindow,
s.l1Client.BlockNumber,
blockHashFetcher,
)
}
func (s *Service) Start(ctx context.Context) error { func (s *Service) Start(ctx context.Context) error {
s.logger.Info("starting scheduler") s.logger.Info("starting scheduler")
s.logger.Info("starting monitoring") s.logger.Info("starting monitoring")
s.monitor.StartMonitoring()
s.logger.Info("dispute monitor game service start completed") s.logger.Info("dispute monitor game service start completed")
return nil return nil
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment