From 2207569e622891bf5e270c48ba42eb59d4777cd3 Mon Sep 17 00:00:00 2001 From: refcell Date: Tue, 2 Apr 2024 19:19:28 -0400 Subject: [PATCH] feat(op-dispute-mon): Unexpected Claim Resolution (#10031) * feat(op-dispute-mon): track claims resolved against honest actors * fix(op-dispute-mon): break after honest actor error is logged * fix(op-dispute-mon): lints --- op-dispute-mon/config/config.go | 8 ++-- op-dispute-mon/flags/flags.go | 19 ++++++++ op-dispute-mon/metrics/metrics.go | 15 ++++++ op-dispute-mon/metrics/noop.go | 2 + op-dispute-mon/mon/claims.go | 40 +++++++++++++--- op-dispute-mon/mon/claims_test.go | 77 ++++++++++++++++++++++++------- op-dispute-mon/mon/service.go | 10 ++-- 7 files changed, 139 insertions(+), 32 deletions(-) diff --git a/op-dispute-mon/config/config.go b/op-dispute-mon/config/config.go index 5dc0f62408af..4a90ed9ccb9f 100644 --- a/op-dispute-mon/config/config.go +++ b/op-dispute-mon/config/config.go @@ -32,10 +32,11 @@ 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. + HonestActors []common.Address // List of honest actors to monitor claims for. + 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. MetricsConfig opmetrics.CLIConfig PprofConfig oppprof.CLIConfig @@ -46,6 +47,7 @@ func NewConfig(gameFactoryAddress common.Address, l1EthRpc string) Config { L1EthRpc: l1EthRpc, GameFactoryAddress: gameFactoryAddress, + HonestActors: []common.Address{}, MonitorInterval: DefaultMonitorInterval, GameWindow: DefaultGameWindow, diff --git a/op-dispute-mon/flags/flags.go b/op-dispute-mon/flags/flags.go index e314c7acd1f4..afb6794151f2 100644 --- a/op-dispute-mon/flags/flags.go +++ b/op-dispute-mon/flags/flags.go @@ -10,6 +10,7 @@ import ( oplog "github.com/ethereum-optimism/optimism/op-service/log" opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" "github.com/ethereum-optimism/optimism/op-service/oppprof" + "github.com/ethereum/go-ethereum/common" ) const ( @@ -33,6 +34,11 @@ var ( EnvVars: prefixEnvVars("GAME_FACTORY_ADDRESS"), } // Optional Flags + HonestActorsFlag = &cli.StringSliceFlag{ + Name: "honest-actors", + Usage: "List of honest actors that are monitored for any claims that are resolved against them.", + EnvVars: prefixEnvVars("HONEST_ACTORS"), + } RollupRpcFlag = &cli.StringFlag{ Name: "rollup-rpc", Usage: "HTTP provider URL for the rollup node", @@ -62,6 +68,7 @@ var requiredFlags = []cli.Flag{ // optionalFlags is a list of unchecked cli flags var optionalFlags = []cli.Flag{ RollupRpcFlag, + HonestActorsFlag, MonitorIntervalFlag, GameWindowFlag, } @@ -96,6 +103,17 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) { return nil, err } + var actors []common.Address + if ctx.IsSet(HonestActorsFlag.Name) { + for _, addrStr := range ctx.StringSlice(HonestActorsFlag.Name) { + actor, err := opservice.ParseAddress(addrStr) + if err != nil { + return nil, fmt.Errorf("invalid honest actor address: %w", err) + } + actors = append(actors, actor) + } + } + metricsConfig := opmetrics.ReadCLIConfig(ctx) pprofConfig := oppprof.ReadCLIConfig(ctx) @@ -103,6 +121,7 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) { L1EthRpc: ctx.String(L1EthRpcFlag.Name), GameFactoryAddress: gameFactoryAddress, + HonestActors: actors, RollupRpc: ctx.String(RollupRpcFlag.Name), MonitorInterval: ctx.Duration(MonitorIntervalFlag.Name), GameWindow: ctx.Duration(GameWindowFlag.Name), diff --git a/op-dispute-mon/metrics/metrics.go b/op-dispute-mon/metrics/metrics.go index 6621da6bbb23..ed4cdc921d47 100644 --- a/op-dispute-mon/metrics/metrics.go +++ b/op-dispute-mon/metrics/metrics.go @@ -69,6 +69,8 @@ type Metricer interface { RecordInfo(version string) RecordUp() + RecordUnexpectedClaimResolution(address common.Address, count int) + RecordGameResolutionStatus(complete bool, maxDurationReached bool, count int) RecordCredit(expectation CreditExpectation, count int) @@ -104,6 +106,8 @@ type Metrics struct { claims prometheus.GaugeVec + unexpectedClaimResolutions prometheus.GaugeVec + withdrawalRequests prometheus.GaugeVec info prometheus.GaugeVec @@ -161,6 +165,13 @@ func NewMetrics() *Metrics { Name: "claim_resolution_delay_max", Help: "Maximum claim resolution delay in seconds", }), + unexpectedClaimResolutions: *factory.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: Namespace, + Name: "unexpected_claim_resolutions", + Help: "Total number of unexpected claim resolutions against an honest actor", + }, []string{ + "honest_actor_address", + }), resolutionStatus: *factory.NewGaugeVec(prometheus.GaugeOpts{ Namespace: Namespace, Name: "resolution_status", @@ -251,6 +262,10 @@ func (m *Metrics) RecordUp() { m.up.Set(1) } +func (m *Metrics) RecordUnexpectedClaimResolution(address common.Address, count int) { + m.unexpectedClaimResolutions.WithLabelValues(address.Hex()).Set(float64(count)) +} + func (m *Metrics) RecordGameResolutionStatus(complete bool, maxDurationReached bool, count int) { completion := "complete" if !complete { diff --git a/op-dispute-mon/metrics/noop.go b/op-dispute-mon/metrics/noop.go index c727fdc24557..61df18bf1601 100644 --- a/op-dispute-mon/metrics/noop.go +++ b/op-dispute-mon/metrics/noop.go @@ -19,6 +19,8 @@ func (*NoopMetricsImpl) RecordUp() {} func (*NoopMetricsImpl) CacheAdd(_ string, _ int, _ bool) {} func (*NoopMetricsImpl) CacheGet(_ string, _ bool) {} +func (*NoopMetricsImpl) RecordUnexpectedClaimResolution(_ common.Address, _ int) {} + func (*NoopMetricsImpl) RecordGameResolutionStatus(_ bool, _ bool, _ int) {} func (*NoopMetricsImpl) RecordCredit(_ CreditExpectation, _ int) {} diff --git a/op-dispute-mon/mon/claims.go b/op-dispute-mon/mon/claims.go index b8af70b3ff73..19a49b5b92b1 100644 --- a/op-dispute-mon/mon/claims.go +++ b/op-dispute-mon/mon/claims.go @@ -5,6 +5,7 @@ import ( "github.com/ethereum-optimism/optimism/op-dispute-mon/metrics" "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" ) @@ -14,35 +15,60 @@ type RClock interface { type ClaimMetrics interface { RecordClaims(status metrics.ClaimStatus, count int) + RecordUnexpectedClaimResolution(address common.Address, count int) } type ClaimMonitor struct { - logger log.Logger - clock RClock - metrics ClaimMetrics + logger log.Logger + clock RClock + honestActors []common.Address + metrics ClaimMetrics } -func NewClaimMonitor(logger log.Logger, clock RClock, metrics ClaimMetrics) *ClaimMonitor { - return &ClaimMonitor{logger, clock, metrics} +func NewClaimMonitor(logger log.Logger, clock RClock, honestActors []common.Address, metrics ClaimMetrics) *ClaimMonitor { + return &ClaimMonitor{logger, clock, honestActors, metrics} } func (c *ClaimMonitor) CheckClaims(games []*types.EnrichedGameData) { claimStatus := make(map[metrics.ClaimStatus]int) + unexpected := make(map[common.Address]int) for _, game := range games { - c.checkGameClaims(game, claimStatus) + c.checkGameClaims(game, claimStatus, unexpected) } for status, count := range claimStatus { c.metrics.RecordClaims(status, count) } + for address, count := range unexpected { + c.metrics.RecordUnexpectedClaimResolution(address, count) + } +} + +func (c *ClaimMonitor) checkResolvedAgainstHonestActor(proxy common.Address, claim *types.EnrichedClaim, unexpected map[common.Address]int) { + for _, actor := range c.honestActors { + if claim.Claimant == actor && claim.CounteredBy != (common.Address{}) { + unexpected[actor]++ + c.logger.Error("Claim resolved against honest actor", "game", proxy, "honest_actor", actor, "countered_by", claim.CounteredBy, "claim_contract_index", claim.ContractIndex) + break + } + } } -func (c *ClaimMonitor) checkGameClaims(game *types.EnrichedGameData, claimStatus map[metrics.ClaimStatus]int) { +func (c *ClaimMonitor) checkGameClaims( + game *types.EnrichedGameData, + claimStatus map[metrics.ClaimStatus]int, + unexpected map[common.Address]int, +) { // Check if the game is in the first half duration := uint64(c.clock.Now().Unix()) - game.Timestamp firstHalf := duration <= (game.Duration / 2) // Iterate over the game's claims for _, claim := range game.Claims { + // Check if the claim has resolved against an honest actor + if claim.Resolved { + c.checkResolvedAgainstHonestActor(game.Proxy, &claim, unexpected) + } + // Check if the clock has expired if firstHalf && claim.Resolved { c.logger.Error("Claim resolved in the first half of the game duration", "game", game.Proxy, "claimContractIndex", claim.ContractIndex) diff --git a/op-dispute-mon/mon/claims_test.go b/op-dispute-mon/mon/claims_test.go index 0e47116880b6..f4aed028bd55 100644 --- a/op-dispute-mon/mon/claims_test.go +++ b/op-dispute-mon/mon/claims_test.go @@ -17,30 +17,51 @@ import ( var frozen = time.Unix(int64(time.Hour.Seconds()), 0) func TestClaimMonitor_CheckClaims(t *testing.T) { - cm, cl, cMetrics := newTestClaimMonitor(t) - games := makeMultipleTestGames(uint64(cl.Now().Unix())) - cm.CheckClaims(games) + t.Run("RecordsClaims", func(t *testing.T) { + monitor, cl, cMetrics := newTestClaimMonitor(t) + games := makeMultipleTestGames(uint64(cl.Now().Unix())) + monitor.CheckClaims(games) - require.Equal(t, 1, cMetrics.calls[metrics.FirstHalfExpiredResolved]) - require.Equal(t, 1, cMetrics.calls[metrics.FirstHalfExpiredUnresolved]) - require.Equal(t, 1, cMetrics.calls[metrics.FirstHalfNotExpiredResolved]) - require.Equal(t, 1, cMetrics.calls[metrics.FirstHalfNotExpiredUnresolved]) + require.Equal(t, 2, cMetrics.calls[metrics.FirstHalfExpiredResolved]) + require.Equal(t, 1, cMetrics.calls[metrics.FirstHalfExpiredUnresolved]) + require.Equal(t, 1, cMetrics.calls[metrics.FirstHalfNotExpiredResolved]) + require.Equal(t, 1, cMetrics.calls[metrics.FirstHalfNotExpiredUnresolved]) - require.Equal(t, 1, cMetrics.calls[metrics.SecondHalfExpiredResolved]) - require.Equal(t, 1, cMetrics.calls[metrics.SecondHalfExpiredUnresolved]) - require.Equal(t, 1, cMetrics.calls[metrics.SecondHalfNotExpiredResolved]) - require.Equal(t, 1, cMetrics.calls[metrics.SecondHalfNotExpiredUnresolved]) + require.Equal(t, 2, cMetrics.calls[metrics.SecondHalfExpiredResolved]) + require.Equal(t, 1, cMetrics.calls[metrics.SecondHalfExpiredUnresolved]) + require.Equal(t, 1, cMetrics.calls[metrics.SecondHalfNotExpiredResolved]) + require.Equal(t, 1, cMetrics.calls[metrics.SecondHalfNotExpiredUnresolved]) + }) + + t.Run("RecordsUnexpectedClaimResolution", func(t *testing.T) { + monitor, cl, cMetrics := newTestClaimMonitor(t) + games := makeMultipleTestGames(uint64(cl.Now().Unix())) + monitor.CheckClaims(games) + + // Our honest actors 0x01 has claims resolved against them (1 per game) + require.Equal(t, 2, cMetrics.unexpected[common.Address{0x01}]) + require.Equal(t, 0, cMetrics.unexpected[common.Address{0x02}]) + + // The other actors should not be metriced + require.Equal(t, 0, cMetrics.unexpected[common.Address{0x03}]) + require.Equal(t, 0, cMetrics.unexpected[common.Address{0x04}]) + }) } func newTestClaimMonitor(t *testing.T) (*ClaimMonitor, *clock.DeterministicClock, *stubClaimMetrics) { logger := testlog.Logger(t, log.LvlInfo) cl := clock.NewDeterministicClock(frozen) metrics := &stubClaimMetrics{} - return NewClaimMonitor(logger, cl, metrics), cl, metrics + honestActors := []common.Address{ + common.Address{0x01}, + common.Address{0x02}, + } + return NewClaimMonitor(logger, cl, honestActors, metrics), cl, metrics } type stubClaimMetrics struct { - calls map[metrics.ClaimStatus]int + calls map[metrics.ClaimStatus]int + unexpected map[common.Address]int } func (s *stubClaimMetrics) RecordClaims(status metrics.ClaimStatus, count int) { @@ -50,6 +71,13 @@ func (s *stubClaimMetrics) RecordClaims(status metrics.ClaimStatus, count int) { s.calls[status] += count } +func (s *stubClaimMetrics) RecordUnexpectedClaimResolution(address common.Address, count int) { + if s.unexpected == nil { + s.unexpected = make(map[common.Address]int) + } + s.unexpected[address] += count +} + func makeMultipleTestGames(duration uint64) []*types.EnrichedGameData { return []*types.EnrichedGameData{ makeTestGame(duration), // first half @@ -68,21 +96,36 @@ func makeTestGame(duration uint64) *types.EnrichedGameData { Claims: []types.EnrichedClaim{ { Claim: faultTypes.Claim{ - Clock: faultTypes.NewClock(time.Duration(0), frozen), + Clock: faultTypes.NewClock(time.Duration(0), frozen), + Claimant: common.Address{0x02}, + }, + Resolved: true, + }, + { + Claim: faultTypes.Claim{ + Claimant: common.Address{0x01}, + CounteredBy: common.Address{0x03}, }, Resolved: true, }, { - Claim: faultTypes.Claim{}, + Claim: faultTypes.Claim{ + Claimant: common.Address{0x04}, + CounteredBy: common.Address{0x02}, + }, Resolved: true, }, { Claim: faultTypes.Claim{ - Clock: faultTypes.NewClock(time.Duration(0), frozen), + Claimant: common.Address{0x04}, + CounteredBy: common.Address{0x02}, + Clock: faultTypes.NewClock(time.Duration(0), frozen), }, }, { - Claim: faultTypes.Claim{}, + Claim: faultTypes.Claim{ + Claimant: common.Address{0x01}, + }, }, }, } diff --git a/op-dispute-mon/mon/service.go b/op-dispute-mon/mon/service.go index fd646286cdce..2b5bd403a679 100644 --- a/op-dispute-mon/mon/service.go +++ b/op-dispute-mon/mon/service.go @@ -88,8 +88,8 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error return fmt.Errorf("failed to init rollup client: %w", err) } + s.initClaimMonitor(cfg) s.initResolutionMonitor() - s.initClaimMonitor() s.initWithdrawalMonitor() s.initOutputValidator() // Must be called before initForecast @@ -109,12 +109,12 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error return nil } -func (s *Service) initResolutionMonitor() { - s.resolutions = NewResolutionMonitor(s.logger, s.metrics, s.cl) +func (s *Service) initClaimMonitor(cfg *config.Config) { + s.claims = NewClaimMonitor(s.logger, s.cl, cfg.HonestActors, s.metrics) } -func (s *Service) initClaimMonitor() { - s.claims = NewClaimMonitor(s.logger, s.cl, s.metrics) +func (s *Service) initResolutionMonitor() { + s.resolutions = NewResolutionMonitor(s.logger, s.metrics, s.cl) } func (s *Service) initWithdrawalMonitor() {