Commit fea26bd3 authored by refcell's avatar refcell Committed by GitHub

feat(op-dispute-mon): rollup output root checking (#9422)

Co-authored-by: default avatarclabby <ben@clab.by>
parent c9340197
......@@ -14,6 +14,7 @@ import (
var (
ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url")
ErrMissingGameFactoryAddress = errors.New("missing game factory address")
ErrMissingRollupRpc = errors.New("missing rollup rpc url")
)
const (
......@@ -31,6 +32,8 @@ const (
type Config struct {
L1EthRpc string // L1 RPC Url
GameFactoryAddress common.Address // Address of the dispute game factory
RollupRpc string // The rollup node RPC URL.
MonitorInterval time.Duration // Frequency to check for new games to monitor.
GameWindow time.Duration // Maximum window to look for games to monitor.
......@@ -55,6 +58,9 @@ func (c Config) Check() error {
if c.L1EthRpc == "" {
return ErrMissingL1EthRPC
}
if c.RollupRpc == "" {
return ErrMissingRollupRpc
}
if c.GameFactoryAddress == (common.Address{}) {
return ErrMissingGameFactoryAddress
}
......
......@@ -11,10 +11,13 @@ import (
var (
validL1EthRpc = "http://localhost:8545"
validGameFactoryAddress = common.Address{0x23}
validRollupRpc = "http://localhost:8555"
)
func validConfig() Config {
return NewConfig(validGameFactoryAddress, validL1EthRpc)
cfg := NewConfig(validGameFactoryAddress, validL1EthRpc)
cfg.RollupRpc = validRollupRpc
return cfg
}
func TestValidConfigIsValid(t *testing.T) {
......@@ -32,3 +35,9 @@ func TestGameFactoryAddressRequired(t *testing.T) {
config.GameFactoryAddress = common.Address{}
require.ErrorIs(t, config.Check(), ErrMissingGameFactoryAddress)
}
func TestRollupRpcRequired(t *testing.T) {
config := validConfig()
config.RollupRpc = ""
require.ErrorIs(t, config.Check(), ErrMissingRollupRpc)
}
......@@ -33,6 +33,11 @@ var (
EnvVars: prefixEnvVars("GAME_FACTORY_ADDRESS"),
}
// Optional Flags
RollupRpcFlag = &cli.StringFlag{
Name: "rollup-rpc",
Usage: "HTTP provider URL for the rollup node",
EnvVars: prefixEnvVars("ROLLUP_RPC"),
}
MonitorIntervalFlag = &cli.DurationFlag{
Name: "monitor-interval",
Usage: "The interval at which the dispute monitor will check for new games to monitor.",
......@@ -56,6 +61,7 @@ var requiredFlags = []cli.Flag{
// optionalFlags is a list of unchecked cli flags
var optionalFlags = []cli.Flag{
RollupRpcFlag,
MonitorIntervalFlag,
GameWindowFlag,
}
......@@ -97,6 +103,7 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) {
L1EthRpc: ctx.String(L1EthRpcFlag.Name),
GameFactoryAddress: gameFactoryAddress,
RollupRpc: ctx.String(RollupRpcFlag.Name),
MonitorInterval: ctx.Duration(MonitorIntervalFlag.Name),
GameWindow: ctx.Duration(GameWindowFlag.Name),
......
......@@ -20,6 +20,7 @@ type Metricer interface {
RecordUp()
RecordGamesStatus(inProgress, defenderWon, challengerWon int)
RecordGameAgreement(status string, count int)
caching.Metrics
}
......@@ -38,6 +39,7 @@ type Metrics struct {
up prometheus.Gauge
trackedGames prometheus.GaugeVec
gamesAgreement prometheus.GaugeVec
}
func (m *Metrics) Registry() *prometheus.Registry {
......@@ -76,6 +78,13 @@ func NewMetrics() *Metrics {
}, []string{
"status",
}),
gamesAgreement: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "games_agreement",
Help: "Number of games broken down by whether the result agrees with the reference node",
}, []string{
"status",
}),
}
}
......@@ -112,3 +121,7 @@ func (m *Metrics) RecordGamesStatus(inProgress, defenderWon, challengerWon int)
m.trackedGames.WithLabelValues("defender_won").Set(float64(defenderWon))
m.trackedGames.WithLabelValues("challenger_won").Set(float64(challengerWon))
}
func (m *Metrics) RecordGameAgreement(status string, count int) {
m.gamesAgreement.WithLabelValues(status).Set(float64(count))
}
......@@ -11,3 +11,4 @@ func (*NoopMetricsImpl) CacheAdd(_ string, _ int, _ bool) {}
func (*NoopMetricsImpl) CacheGet(_ string, _ bool) {}
func (*NoopMetricsImpl) RecordGamesStatus(inProgress, defenderWon, challengerWon int) {}
func (*NoopMetricsImpl) RecordGameAgreement(status string, count int) {}
package mon
import (
"context"
"fmt"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
)
type statusBatch struct {
inProgress, defenderWon, challengerWon int
}
func (s *statusBatch) Add(status types.GameStatus) {
switch status {
case types.GameStatusInProgress:
s.inProgress++
case types.GameStatusDefenderWon:
s.defenderWon++
case types.GameStatusChallengerWon:
s.challengerWon++
}
}
type detectionBatch struct {
inProgress int
agreeDefenderWins int
disagreeDefenderWins int
agreeChallengerWins int
disagreeChallengerWins int
}
func (d *detectionBatch) merge(other detectionBatch) {
d.inProgress += other.inProgress
d.agreeDefenderWins += other.agreeDefenderWins
d.disagreeDefenderWins += other.disagreeDefenderWins
d.agreeChallengerWins += other.agreeChallengerWins
d.disagreeChallengerWins += other.disagreeChallengerWins
}
type OutputRollupClient interface {
OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error)
}
type MetadataCreator interface {
CreateContract(game types.GameMetadata) (MetadataLoader, error)
}
type DetectorMetrics interface {
RecordGameAgreement(status string, count int)
RecordGamesStatus(inProgress, defenderWon, challengerWon int)
}
type detector struct {
logger log.Logger
metrics DetectorMetrics
creator MetadataCreator
outputClient OutputRollupClient
}
func newDetector(logger log.Logger, metrics DetectorMetrics, creator MetadataCreator, outputClient OutputRollupClient) *detector {
return &detector{
logger: logger,
metrics: metrics,
creator: creator,
outputClient: outputClient,
}
}
func (d *detector) Detect(ctx context.Context, games []types.GameMetadata) {
statBatch := statusBatch{}
detectBatch := detectionBatch{}
for _, game := range games {
// Fetch the game metadata to ensure the game status is recorded
// regardless of whether the game agreement is checked.
l2BlockNum, rootClaim, status, err := d.fetchGameMetadata(ctx, game)
if err != nil {
d.logger.Error("Failed to fetch game metadata", "err", err)
continue
}
statBatch.Add(status)
processed, err := d.checkAgreement(ctx, game.Proxy, l2BlockNum, rootClaim, status)
if err != nil {
d.logger.Error("Failed to process game", "err", err)
continue
}
detectBatch.merge(processed)
}
d.metrics.RecordGamesStatus(statBatch.inProgress, statBatch.defenderWon, statBatch.challengerWon)
d.recordBatch(detectBatch)
}
func (d *detector) recordBatch(batch detectionBatch) {
d.metrics.RecordGameAgreement("in_progress", batch.inProgress)
d.metrics.RecordGameAgreement("agree_defender_wins", batch.agreeDefenderWins)
d.metrics.RecordGameAgreement("disagree_defender_wins", batch.disagreeDefenderWins)
d.metrics.RecordGameAgreement("agree_challenger_wins", batch.agreeChallengerWins)
d.metrics.RecordGameAgreement("disagree_challenger_wins", batch.disagreeChallengerWins)
}
func (d *detector) fetchGameMetadata(ctx context.Context, game types.GameMetadata) (uint64, common.Hash, types.GameStatus, error) {
loader, err := d.creator.CreateContract(game)
if err != nil {
return 0, common.Hash{}, 0, fmt.Errorf("failed to create contract: %w", err)
}
blockNum, rootClaim, status, err := loader.GetGameMetadata(ctx)
if err != nil {
return 0, common.Hash{}, 0, fmt.Errorf("failed to fetch game metadata: %w", err)
}
return blockNum, rootClaim, status, nil
}
func (d *detector) checkAgreement(ctx context.Context, addr common.Address, blockNum uint64, rootClaim common.Hash, status types.GameStatus) (detectionBatch, error) {
agree, err := d.checkRootAgreement(ctx, blockNum, rootClaim)
if err != nil {
return detectionBatch{}, err
}
batch := detectionBatch{}
switch status {
case types.GameStatusInProgress:
batch.inProgress++
case types.GameStatusDefenderWon:
if agree {
batch.agreeDefenderWins++
} else {
batch.disagreeDefenderWins++
d.logger.Error("Defender won but root claim does not match", "gameAddr", addr, "rootClaim", rootClaim)
}
case types.GameStatusChallengerWon:
if agree {
batch.agreeChallengerWins++
} else {
batch.disagreeChallengerWins++
d.logger.Error("Challenger won but root claim does not match", "gameAddr", addr, "rootClaim", rootClaim)
}
}
return batch, nil
}
func (d *detector) checkRootAgreement(ctx context.Context, blockNum uint64, rootClaim common.Hash) (bool, error) {
output, err := d.outputClient.OutputAtBlock(ctx, blockNum)
if err != nil {
return false, fmt.Errorf("failed to get output at block: %w", err)
}
return rootClaim == common.Hash(output.OutputRoot), nil
}
package mon
import (
"context"
"errors"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/eth"
"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 (
mockRootClaim = common.HexToHash("0x10")
)
func TestDetector_Detect(t *testing.T) {
t.Parallel()
t.Run("NoGames", func(t *testing.T) {
detector, metrics, _, _ := setupDetectorTest(t)
detector.Detect(context.Background(), []types.GameMetadata{})
metrics.Equals(t, 0, 0, 0)
metrics.Mapped(t, map[string]int{})
})
t.Run("MetadataFetchFails", func(t *testing.T) {
detector, metrics, creator, _ := setupDetectorTest(t)
creator.err = errors.New("boom")
detector.Detect(context.Background(), []types.GameMetadata{{}})
metrics.Equals(t, 0, 0, 0)
metrics.Mapped(t, map[string]int{})
})
t.Run("CheckAgreementFails", func(t *testing.T) {
detector, metrics, creator, rollup := setupDetectorTest(t)
rollup.err = errors.New("boom")
creator.loader = &mockMetadataLoader{status: types.GameStatusInProgress}
detector.Detect(context.Background(), []types.GameMetadata{{}})
metrics.Equals(t, 1, 0, 0) // Status should still be metriced here!
metrics.Mapped(t, map[string]int{})
})
t.Run("SingleGame", func(t *testing.T) {
detector, metrics, creator, _ := setupDetectorTest(t)
loader := &mockMetadataLoader{status: types.GameStatusInProgress}
creator.loader = loader
detector.Detect(context.Background(), []types.GameMetadata{{}})
metrics.Equals(t, 1, 0, 0)
metrics.Mapped(t, map[string]int{"in_progress": 1})
})
t.Run("MultipleGames", func(t *testing.T) {
detector, metrics, creator, _ := setupDetectorTest(t)
loader := &mockMetadataLoader{status: types.GameStatusInProgress}
creator.loader = loader
detector.Detect(context.Background(), []types.GameMetadata{{}, {}, {}})
metrics.Equals(t, 3, 0, 0)
metrics.Mapped(t, map[string]int{"in_progress": 3})
})
}
func TestDetector_RecordBatch(t *testing.T) {
tests := []struct {
name string
batch detectionBatch
expect func(*testing.T, *mockDetectorMetricer)
}{
{
name: "no games",
batch: detectionBatch{},
expect: func(t *testing.T, metrics *mockDetectorMetricer) {},
},
{
name: "in_progress",
batch: detectionBatch{inProgress: 1},
expect: func(t *testing.T, metrics *mockDetectorMetricer) {
require.Equal(t, 1, metrics.gameAgreement["in_progress"])
},
},
{
name: "agree_defender_wins",
batch: detectionBatch{agreeDefenderWins: 1},
expect: func(t *testing.T, metrics *mockDetectorMetricer) {
require.Equal(t, 1, metrics.gameAgreement["agree_defender_wins"])
},
},
{
name: "disagree_defender_wins",
batch: detectionBatch{disagreeDefenderWins: 1},
expect: func(t *testing.T, metrics *mockDetectorMetricer) {
require.Equal(t, 1, metrics.gameAgreement["disagree_defender_wins"])
},
},
{
name: "agree_challenger_wins",
batch: detectionBatch{agreeChallengerWins: 1},
expect: func(t *testing.T, metrics *mockDetectorMetricer) {
require.Equal(t, 1, metrics.gameAgreement["agree_challenger_wins"])
},
},
{
name: "disagree_challenger_wins",
batch: detectionBatch{disagreeChallengerWins: 1},
expect: func(t *testing.T, metrics *mockDetectorMetricer) {
require.Equal(t, 1, metrics.gameAgreement["disagree_challenger_wins"])
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
monitor, metrics, _, _ := setupDetectorTest(t)
monitor.recordBatch(test.batch)
test.expect(t, metrics)
})
}
}
func TestDetector_FetchGameMetadata(t *testing.T) {
t.Parallel()
t.Run("CreateContractFails", func(t *testing.T) {
detector, _, creator, _ := setupDetectorTest(t)
creator.err = errors.New("boom")
_, _, _, err := detector.fetchGameMetadata(context.Background(), types.GameMetadata{})
require.ErrorIs(t, err, creator.err)
})
t.Run("GetGameMetadataFails", func(t *testing.T) {
detector, _, creator, _ := setupDetectorTest(t)
loader := &mockMetadataLoader{err: errors.New("boom")}
creator.loader = loader
_, _, _, err := detector.fetchGameMetadata(context.Background(), types.GameMetadata{})
require.Error(t, err)
})
t.Run("Success", func(t *testing.T) {
detector, _, creator, _ := setupDetectorTest(t)
loader := &mockMetadataLoader{status: types.GameStatusInProgress}
creator.loader = loader
_, _, status, err := detector.fetchGameMetadata(context.Background(), types.GameMetadata{})
require.NoError(t, err)
require.Equal(t, types.GameStatusInProgress, status)
})
}
func TestDetector_CheckAgreement_Fails(t *testing.T) {
detector, _, _, rollup := setupDetectorTest(t)
rollup.err = errors.New("boom")
_, err := detector.checkAgreement(context.Background(), common.Address{}, 0, common.Hash{}, types.GameStatusInProgress)
require.ErrorIs(t, err, rollup.err)
}
func TestDetector_CheckAgreement_Succeeds(t *testing.T) {
tests := []struct {
name string
rootClaim common.Hash
status types.GameStatus
expectBatch func(*detectionBatch)
err error
}{
{
name: "in_progress",
expectBatch: func(batch *detectionBatch) {
require.Equal(t, 1, batch.inProgress)
},
},
{
name: "agree_defender_wins",
rootClaim: mockRootClaim,
status: types.GameStatusDefenderWon,
expectBatch: func(batch *detectionBatch) {
require.Equal(t, 1, batch.agreeDefenderWins)
},
},
{
name: "disagree_defender_wins",
status: types.GameStatusDefenderWon,
expectBatch: func(batch *detectionBatch) {
require.Equal(t, 1, batch.disagreeDefenderWins)
},
},
{
name: "agree_challenger_wins",
rootClaim: mockRootClaim,
status: types.GameStatusChallengerWon,
expectBatch: func(batch *detectionBatch) {
require.Equal(t, 1, batch.agreeChallengerWins)
},
},
{
name: "disagree_challenger_wins",
status: types.GameStatusChallengerWon,
expectBatch: func(batch *detectionBatch) {
require.Equal(t, 1, batch.disagreeChallengerWins)
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
detector, _, _, _ := setupDetectorTest(t)
batch, err := detector.checkAgreement(context.Background(), common.Address{}, 0, test.rootClaim, test.status)
require.NoError(t, err)
test.expectBatch(&batch)
})
}
}
func TestDetector_CheckRootAgreement(t *testing.T) {
t.Parallel()
t.Run("OutputFetchFails", func(t *testing.T) {
detector, _, _, rollup := setupDetectorTest(t)
rollup.err = errors.New("boom")
agree, err := detector.checkRootAgreement(context.Background(), 0, mockRootClaim)
require.ErrorIs(t, err, rollup.err)
require.False(t, agree)
})
t.Run("OutputMismatch", func(t *testing.T) {
detector, _, _, _ := setupDetectorTest(t)
agree, err := detector.checkRootAgreement(context.Background(), 0, common.Hash{})
require.NoError(t, err)
require.False(t, agree)
})
t.Run("OutputMatches", func(t *testing.T) {
detector, _, _, _ := setupDetectorTest(t)
agree, err := detector.checkRootAgreement(context.Background(), 0, mockRootClaim)
require.NoError(t, err)
require.True(t, agree)
})
}
func setupDetectorTest(t *testing.T) (*detector, *mockDetectorMetricer, *mockMetadataCreator, *stubRollupClient) {
logger := testlog.Logger(t, log.LvlDebug)
metrics := &mockDetectorMetricer{}
loader := &mockMetadataLoader{}
creator := &mockMetadataCreator{loader: loader}
rollupClient := &stubRollupClient{}
detector := newDetector(logger, metrics, creator, rollupClient)
return detector, metrics, creator, rollupClient
}
type stubRollupClient struct {
blockNum uint64
err error
}
func (s *stubRollupClient) OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error) {
s.blockNum = blockNum
return &eth.OutputResponse{OutputRoot: eth.Bytes32(mockRootClaim)}, s.err
}
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 mockDetectorMetricer struct {
inProgress int
defenderWon int
challengerWon int
gameAgreement map[string]int
}
func (m *mockDetectorMetricer) Equals(t *testing.T, inProgress, defenderWon, challengerWon int) {
require.Equal(t, inProgress, m.inProgress)
require.Equal(t, defenderWon, m.defenderWon)
require.Equal(t, challengerWon, m.challengerWon)
}
func (m *mockDetectorMetricer) Mapped(t *testing.T, expected map[string]int) {
for k, v := range m.gameAgreement {
require.Equal(t, expected[k], v)
}
}
func (m *mockDetectorMetricer) RecordGamesStatus(inProgress, defenderWon, challengerWon int) {
m.inProgress = inProgress
m.defenderWon = defenderWon
m.challengerWon = challengerWon
}
func (m *mockDetectorMetricer) RecordGameAgreement(status string, count int) {
if m.gameAgreement == nil {
m.gameAgreement = make(map[string]int)
}
m.gameAgreement[status] += count
}
......@@ -13,61 +13,48 @@ import (
"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 Detect func(ctx context.Context, games []types.GameMetadata)
type BlockHashFetcher func(ctx context.Context, number *big.Int) (common.Hash, error)
type BlockNumberFetcher func(ctx context.Context) (uint64, error)
type FactoryGameFetcher func(ctx context.Context, blockHash common.Hash, earliestTimestamp uint64) ([]types.GameMetadata, error)
type gameMonitor struct {
logger log.Logger
metrics MonitorMetricer
clock clock.Clock
done chan struct{}
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
monitorInterval time.Duration
detect Detect
fetchGames FactoryGameFetcher
fetchBlockHash BlockHashFetcher
fetchBlockNumber BlockNumberFetcher
}
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,
detect Detect,
factory FactoryGameFetcher,
fetchBlockNumber BlockNumberFetcher,
fetchBlockHash BlockHashFetcher,
) *gameMonitor {
return &gameMonitor{
logger: logger,
metrics: metrics,
ctx: ctx,
clock: cl,
ctx: ctx,
done: make(chan struct{}),
monitorInterval: monitorInterval,
source: source,
metadata: metadata,
gameWindow: gameWindow,
detect: detect,
fetchGames: factory,
fetchBlockNumber: fetchBlockNumber,
fetchBlockHash: fetchBlockHash,
}
......@@ -95,36 +82,11 @@ func (m *gameMonitor) monitorGames() error {
if err != nil {
return fmt.Errorf("Failed to fetch block hash: %w", err)
}
games, err := m.source.GetGamesAtOrAfter(m.ctx, blockHash, m.minGameTimestamp())
games, err := m.fetchGames(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)
m.detect(m.ctx, games)
return nil
}
......
......@@ -22,21 +22,21 @@ var (
func TestMonitor_MinGameTimestamp(t *testing.T) {
t.Parallel()
t.Run("zero game window returns zero", func(t *testing.T) {
monitor, _, _, _ := setupMonitorTest(t)
t.Run("ZeroGameWindow", 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)
t.Run("ZeroClock", 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)
t.Run("ValidArithmetic", func(t *testing.T) {
monitor, _, _ := setupMonitorTest(t)
monitor.gameWindow = time.Minute
frozen := time.Unix(int64(time.Hour.Seconds()), 0)
monitor.clock = clock.NewDeterministicClock(frozen)
......@@ -45,102 +45,11 @@ func TestMonitor_MinGameTimestamp(t *testing.T) {
})
}
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)
monitor, _, _ := setupMonitorTest(t)
boom := errors.New("boom")
monitor.fetchBlockNumber = func(ctx context.Context) (uint64, error) {
return 0, boom
......@@ -150,7 +59,7 @@ func TestMonitor_MonitorGames(t *testing.T) {
})
t.Run("FailedFetchBlockHash", func(t *testing.T) {
monitor, _, _, _ := setupMonitorTest(t)
monitor, _, _ := setupMonitorTest(t)
boom := errors.New("boom")
monitor.fetchBlockHash = func(ctx context.Context, number *big.Int) (common.Hash, error) {
return common.Hash{}, boom
......@@ -159,60 +68,49 @@ func TestMonitor_MonitorGames(t *testing.T) {
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")
t.Run("DetectsWithNoGames", func(t *testing.T) {
monitor, factory, detector := setupMonitorTest(t)
factory.games = []types.GameMetadata{}
err := monitor.monitorGames()
require.NoError(t, err)
require.Equal(t, 1, creator.calls)
require.Equal(t, 1, detector.calls)
})
t.Run("Success", func(t *testing.T) {
monitor, source, metrics, _ := setupMonitorTest(t)
source.games = []types.GameMetadata{{}, {}, {}}
t.Run("DetectsMultipleGames", func(t *testing.T) {
monitor, factory, detector := setupMonitorTest(t)
factory.games = []types.GameMetadata{{}, {}, {}}
err := monitor.monitorGames()
require.NoError(t, err)
require.Equal(t, len(source.games), metrics.inProgress)
require.Equal(t, 1, detector.calls)
})
}
func TestMonitor_StartMonitoring(t *testing.T) {
t.Run("Monitors games", func(t *testing.T) {
t.Run("MonitorsGames", 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, factory, detector := setupMonitorTest(t)
factory.games = []types.GameMetadata{newFDG(addr1, 9999), newFDG(addr2, 9999)}
factory.maxSuccess = len(factory.games) // Only allow two successful fetches
monitor.StartMonitoring()
require.Eventually(t, func() bool {
return metrics.inProgress == 2
return detector.calls >= 2
}, time.Second, 50*time.Millisecond)
monitor.StopMonitoring()
require.Equal(t, len(source.games), metrics.inProgress) // Each game's status is recorded twice
require.Equal(t, len(factory.games), detector.calls) // 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")
t.Run("FailsToFetchGames", func(t *testing.T) {
monitor, factory, detector := setupMonitorTest(t)
factory.fetchErr = errors.New("boom")
monitor.StartMonitoring()
require.Eventually(t, func() bool {
return source.calls > 0
return factory.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)
require.Equal(t, 0, detector.calls)
})
}
......@@ -223,94 +121,59 @@ func newFDG(proxy common.Address, timestamp uint64) types.GameMetadata {
}
}
func setupMonitorTest(t *testing.T) (*gameMonitor, *stubGameSource, *stubMonitorMetricer, *mockMetadataCreator) {
func setupMonitorTest(t *testing.T) (*gameMonitor, *mockFactory, *mockDetector) {
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()
factory := &mockFactory{}
detect := &mockDetector{}
monitor := newGameMonitor(
context.Background(),
logger,
metrics,
cl,
monitorInterval,
source,
creator,
time.Duration(10*time.Second),
detect.Detect,
factory.GetGamesAtOrAfter,
fetchBlockNum,
fetchBlockHash,
)
return monitor, source, metrics, creator
return monitor, factory, detect
}
type mockMetadataCreator struct {
type mockDetector struct {
calls int
err error
loader *mockMetadataLoader
}
func (m *mockMetadataCreator) CreateContract(game types.GameMetadata) (MetadataLoader, error) {
func (m *mockDetector) Detect(ctx context.Context, games []types.GameMetadata) {
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 {
type mockFactory struct {
fetchErr error
calls int
maxSuccess int
games []types.GameMetadata
}
func (s *stubGameSource) GetGamesAtOrAfter(
func (m *mockFactory) GetGamesAtOrAfter(
_ context.Context,
_ common.Hash,
_ uint64,
) ([]types.GameMetadata, error) {
s.calls++
if s.fetchErr != nil {
return nil, s.fetchErr
m.calls++
if m.fetchErr != nil {
return nil, m.fetchErr
}
if s.calls > s.maxSuccess && s.maxSuccess != 0 {
if m.calls > m.maxSuccess && m.maxSuccess != 0 {
return nil, mockErr
}
return s.games, nil
return m.games, nil
}
......@@ -21,6 +21,7 @@ import (
"github.com/ethereum-optimism/optimism/op-service/httputil"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum-optimism/optimism/op-service/oppprof"
"github.com/ethereum-optimism/optimism/op-service/sources"
"github.com/ethereum-optimism/optimism/op-service/sources/batching"
)
......@@ -34,6 +35,8 @@ type Service struct {
cl clock.Clock
metadata *metadataCreator
rollupClient *sources.RollupClient
detector *detector
l1Client *ethclient.Client
......@@ -71,6 +74,10 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error
if err := s.initFactoryContract(cfg); err != nil {
return fmt.Errorf("failed to create factory contract bindings: %w", err)
}
if err := s.initOutputRollupClient(ctx, cfg); err != nil {
return fmt.Errorf("failed to init rollup client: %w", err)
}
s.initDetector()
s.initMetadataCreator()
s.initMonitor(ctx, cfg)
......@@ -80,6 +87,19 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error
return nil
}
func (s *Service) initDetector() {
s.detector = newDetector(s.logger, s.metrics, s.metadata, s.rollupClient)
}
func (s *Service) initOutputRollupClient(ctx context.Context, cfg *config.Config) error {
outputRollupClient, err := dial.DialRollupClientWithTimeout(ctx, dial.DefaultDialTimeout, s.logger, cfg.RollupRpc)
if err != nil {
return fmt.Errorf("failed to dial rollup client: %w", err)
}
s.rollupClient = outputRollupClient
return nil
}
func (s *Service) initMetadataCreator() {
s.metadata = NewMetadataCreator(s.metrics, batching.NewMultiCaller(s.l1Client.Client(), batching.DefaultBatchSize))
}
......@@ -149,12 +169,11 @@ func (s *Service) initMonitor(ctx context.Context, cfg *config.Config) {
s.monitor = newGameMonitor(
ctx,
s.logger,
s.metrics,
s.cl,
cfg.MonitorInterval,
s.factoryContract,
s.metadata,
cfg.GameWindow,
s.detector.Detect,
s.factoryContract.GetGamesAtOrAfter,
s.l1Client.BlockNumber,
blockHashFetcher,
)
......
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