Commit 7c34ab27 authored by refcell's avatar refcell Committed by GitHub

feat(op-dispute-mon): Claim Monitor (#10012)

* feat(op-dispute-mon): claim monitor

* fix(op-dispute-mon): lints
parent 75320b37
......@@ -35,10 +35,28 @@ const (
DisagreeChallengerWins
)
type ClaimStatus uint8
const (
// Claims where the game is in the first half
FirstHalfExpiredResolved ClaimStatus = iota
FirstHalfExpiredUnresolved
FirstHalfNotExpiredResolved
FirstHalfNotExpiredUnresolved
// Claims where the game is in the second half
SecondHalfExpiredResolved
SecondHalfExpiredUnresolved
SecondHalfNotExpiredResolved
SecondHalfNotExpiredUnresolved
)
type Metricer interface {
RecordInfo(version string)
RecordUp()
RecordClaims(status ClaimStatus, count int)
RecordWithdrawalRequests(delayedWeth common.Address, matches bool, count int)
RecordClaimResolutionDelayMax(delay float64)
......@@ -64,6 +82,8 @@ type Metrics struct {
*opmetrics.CacheMetrics
*contractMetrics.ContractMetrics
claims prometheus.GaugeVec
withdrawalRequests prometheus.GaugeVec
info prometheus.GaugeVec
......@@ -119,6 +139,15 @@ func NewMetrics() *Metrics {
Name: "claim_resolution_delay_max",
Help: "Maximum claim resolution delay in seconds",
}),
claims: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "claims",
Help: "Claims broken down by whether they were resolved, whether the clock expired, and the game time period",
}, []string{
"resolved",
"clock",
"game_time_period",
}),
withdrawalRequests: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "withdrawal_requests",
......@@ -184,6 +213,32 @@ func (m *Metrics) RecordUp() {
m.up.Set(1)
}
func (m *Metrics) RecordClaims(status ClaimStatus, count int) {
asLabels := func(status ClaimStatus) []string {
switch status {
case FirstHalfExpiredResolved:
return []string{"resolved", "expired", "first_half"}
case FirstHalfExpiredUnresolved:
return []string{"unresolved", "expired", "first_half"}
case FirstHalfNotExpiredResolved:
return []string{"resolved", "not_expired", "first_half"}
case FirstHalfNotExpiredUnresolved:
return []string{"unresolved", "not_expired", "first_half"}
case SecondHalfExpiredResolved:
return []string{"resolved", "expired", "second_half"}
case SecondHalfExpiredUnresolved:
return []string{"unresolved", "expired", "second_half"}
case SecondHalfNotExpiredResolved:
return []string{"resolved", "not_expired", "second_half"}
case SecondHalfNotExpiredUnresolved:
return []string{"unresolved", "not_expired", "second_half"}
default:
panic(fmt.Errorf("unknown claim status: %v", status))
}
}
m.claims.WithLabelValues(asLabels(status)...).Set(float64(count))
}
func (m *Metrics) RecordWithdrawalRequests(delayedWeth common.Address, matches bool, count int) {
credits := "matching"
if !matches {
......
......@@ -13,17 +13,20 @@ type NoopMetricsImpl struct {
var NoopMetrics Metricer = new(NoopMetricsImpl)
func (*NoopMetricsImpl) RecordInfo(version string) {}
func (*NoopMetricsImpl) RecordUp() {}
func (*NoopMetricsImpl) RecordInfo(_ string) {}
func (*NoopMetricsImpl) RecordUp() {}
func (*NoopMetricsImpl) CacheAdd(_ string, _ int, _ bool) {}
func (*NoopMetricsImpl) CacheGet(_ string, _ bool) {}
func (*NoopMetricsImpl) RecordClaims(_ ClaimStatus, _ int) {}
func (*NoopMetricsImpl) RecordWithdrawalRequests(_ common.Address, _ bool, _ int) {}
func (*NoopMetricsImpl) RecordClaimResolutionDelayMax(delay float64) {}
func (*NoopMetricsImpl) RecordOutputFetchTime(timestamp float64) {}
func (*NoopMetricsImpl) RecordClaimResolutionDelayMax(_ float64) {}
func (*NoopMetricsImpl) RecordOutputFetchTime(_ float64) {}
func (*NoopMetricsImpl) RecordGameAgreement(status GameAgreementStatus, count int) {}
func (*NoopMetricsImpl) RecordGameAgreement(_ GameAgreementStatus, _ int) {}
func (i *NoopMetricsImpl) RecordBondCollateral(_ common.Address, _ *big.Int, _ *big.Int) {}
package mon
import (
"time"
"github.com/ethereum-optimism/optimism/op-dispute-mon/metrics"
"github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types"
"github.com/ethereum/go-ethereum/log"
)
type RClock interface {
Now() time.Time
}
type ClaimMetrics interface {
RecordClaims(status metrics.ClaimStatus, count int)
}
type ClaimMonitor struct {
logger log.Logger
clock RClock
metrics ClaimMetrics
}
func NewClaimMonitor(logger log.Logger, clock RClock, metrics ClaimMetrics) *ClaimMonitor {
return &ClaimMonitor{logger, clock, metrics}
}
func (c *ClaimMonitor) CheckClaims(games []*types.EnrichedGameData) {
claimStatus := make(map[metrics.ClaimStatus]int)
for _, game := range games {
c.checkGameClaims(game, claimStatus)
}
for status, count := range claimStatus {
c.metrics.RecordClaims(status, count)
}
}
func (c *ClaimMonitor) checkGameClaims(game *types.EnrichedGameData, claimStatus map[metrics.ClaimStatus]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 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)
}
maxChessTime := time.Duration(game.Duration/2) * time.Second
accumulatedTime := claim.ChessTime(c.clock.Now())
clockExpired := accumulatedTime >= maxChessTime
if claim.Resolved {
if clockExpired {
if firstHalf {
claimStatus[metrics.FirstHalfExpiredResolved]++
} else {
claimStatus[metrics.SecondHalfExpiredResolved]++
}
} else {
if firstHalf {
claimStatus[metrics.FirstHalfNotExpiredResolved]++
} else {
claimStatus[metrics.SecondHalfNotExpiredResolved]++
}
}
} else {
if clockExpired {
c.logger.Warn("Claim unresolved after clock expiration", "game", game.Proxy, "claimContractIndex", claim.ContractIndex)
if firstHalf {
claimStatus[metrics.FirstHalfExpiredUnresolved]++
} else {
claimStatus[metrics.SecondHalfExpiredUnresolved]++
}
} else {
if firstHalf {
claimStatus[metrics.FirstHalfNotExpiredUnresolved]++
} else {
claimStatus[metrics.SecondHalfNotExpiredUnresolved]++
}
}
}
}
}
package mon
import (
"testing"
"time"
faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-dispute-mon/metrics"
"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/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
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)
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, 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])
}
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
}
type stubClaimMetrics struct {
calls map[metrics.ClaimStatus]int
}
func (s *stubClaimMetrics) RecordClaims(status metrics.ClaimStatus, count int) {
if s.calls == nil {
s.calls = make(map[metrics.ClaimStatus]int)
}
s.calls[status] += count
}
func makeMultipleTestGames(duration uint64) []*types.EnrichedGameData {
return []*types.EnrichedGameData{
makeTestGame(duration), // first half
makeTestGame(duration * 10), // second half
}
}
func makeTestGame(duration uint64) *types.EnrichedGameData {
return &types.EnrichedGameData{
Duration: duration,
Recipients: map[common.Address]bool{
common.Address{0x02}: true,
common.Address{0x03}: true,
common.Address{0x04}: true,
},
Claims: []types.EnrichedClaim{
{
Claim: faultTypes.Claim{
Clock: faultTypes.NewClock(time.Duration(0), frozen),
},
Resolved: true,
},
{
Claim: faultTypes.Claim{},
Resolved: true,
},
{
Claim: faultTypes.Claim{
Clock: faultTypes.NewClock(time.Duration(0), frozen),
},
},
{
Claim: faultTypes.Claim{},
},
},
}
}
......@@ -15,6 +15,7 @@ import (
type Forecast func(ctx context.Context, games []*types.EnrichedGameData)
type Bonds func(games []*types.EnrichedGameData)
type MonitorClaims func(games []*types.EnrichedGameData)
type MonitorWithdrawals func(games []*types.EnrichedGameData)
type BlockHashFetcher func(ctx context.Context, number *big.Int) (common.Hash, error)
type BlockNumberFetcher func(ctx context.Context) (uint64, error)
......@@ -35,6 +36,7 @@ type gameMonitor struct {
delays RecordClaimResolutionDelayMax
forecast Forecast
bonds Bonds
claims MonitorClaims
withdrawals MonitorWithdrawals
extract Extract
fetchBlockHash BlockHashFetcher
......@@ -50,6 +52,7 @@ func newGameMonitor(
delays RecordClaimResolutionDelayMax,
forecast Forecast,
bonds Bonds,
claims MonitorClaims,
withdrawals MonitorWithdrawals,
extract Extract,
fetchBlockNumber BlockNumberFetcher,
......@@ -65,6 +68,7 @@ func newGameMonitor(
delays: delays,
forecast: forecast,
bonds: bonds,
claims: claims,
withdrawals: withdrawals,
extract: extract,
fetchBlockNumber: fetchBlockNumber,
......@@ -90,6 +94,7 @@ func (m *gameMonitor) monitorGames() error {
m.delays(enrichedGames)
m.forecast(m.ctx, enrichedGames)
m.bonds(enrichedGames)
m.claims(enrichedGames)
m.withdrawals(enrichedGames)
return nil
}
......
......@@ -24,7 +24,7 @@ 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
......@@ -34,7 +34,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
......@@ -44,24 +44,26 @@ func TestMonitor_MonitorGames(t *testing.T) {
})
t.Run("MonitorsWithNoGames", func(t *testing.T) {
monitor, factory, forecast, delays, bonds, withdrawals := setupMonitorTest(t)
monitor, factory, forecast, delays, bonds, withdrawals, claims := setupMonitorTest(t)
factory.games = []*monTypes.EnrichedGameData{}
err := monitor.monitorGames()
require.NoError(t, err)
require.Equal(t, 1, forecast.calls)
require.Equal(t, 1, delays.calls)
require.Equal(t, 1, bonds.calls)
require.Equal(t, 1, claims.calls)
require.Equal(t, 1, withdrawals.calls)
})
t.Run("MonitorsMultipleGames", func(t *testing.T) {
monitor, factory, forecast, delays, bonds, withdrawals := setupMonitorTest(t)
monitor, factory, forecast, delays, bonds, withdrawals, claims := setupMonitorTest(t)
factory.games = []*monTypes.EnrichedGameData{{}, {}, {}}
err := monitor.monitorGames()
require.NoError(t, err)
require.Equal(t, 1, forecast.calls)
require.Equal(t, 1, delays.calls)
require.Equal(t, 1, bonds.calls)
require.Equal(t, 1, claims.calls)
require.Equal(t, 1, withdrawals.calls)
})
}
......@@ -70,7 +72,7 @@ func TestMonitor_StartMonitoring(t *testing.T) {
t.Run("MonitorsGames", func(t *testing.T) {
addr1 := common.Address{0xaa}
addr2 := common.Address{0xbb}
monitor, factory, forecaster, _, _, _ := setupMonitorTest(t)
monitor, factory, forecaster, _, _, _, _ := setupMonitorTest(t)
factory.games = []*monTypes.EnrichedGameData{newEnrichedGameData(addr1, 9999), newEnrichedGameData(addr2, 9999)}
factory.maxSuccess = len(factory.games) // Only allow two successful fetches
......@@ -83,7 +85,7 @@ func TestMonitor_StartMonitoring(t *testing.T) {
})
t.Run("FailsToFetchGames", func(t *testing.T) {
monitor, factory, forecaster, _, _, _ := setupMonitorTest(t)
monitor, factory, forecaster, _, _, _, _ := setupMonitorTest(t)
factory.fetchErr = errors.New("boom")
monitor.StartMonitoring()
......@@ -105,7 +107,7 @@ func newEnrichedGameData(proxy common.Address, timestamp uint64) *monTypes.Enric
}
}
func setupMonitorTest(t *testing.T) (*gameMonitor, *mockExtractor, *mockForecast, *mockDelayCalculator, *mockBonds, *mockWithdrawalMonitor) {
func setupMonitorTest(t *testing.T) (*gameMonitor, *mockExtractor, *mockForecast, *mockDelayCalculator, *mockBonds, *mockWithdrawalMonitor, *mockClaimMonitor) {
logger := testlog.Logger(t, log.LvlDebug)
fetchBlockNum := func(ctx context.Context) (uint64, error) {
return 1, nil
......@@ -119,6 +121,7 @@ func setupMonitorTest(t *testing.T) (*gameMonitor, *mockExtractor, *mockForecast
extractor := &mockExtractor{}
forecast := &mockForecast{}
bonds := &mockBonds{}
claims := &mockClaimMonitor{}
withdrawals := &mockWithdrawalMonitor{}
delays := &mockDelayCalculator{}
monitor := newGameMonitor(
......@@ -130,12 +133,21 @@ func setupMonitorTest(t *testing.T) (*gameMonitor, *mockExtractor, *mockForecast
delays.RecordClaimResolutionDelayMax,
forecast.Forecast,
bonds.CheckBonds,
claims.CheckClaims,
withdrawals.CheckWithdrawals,
extractor.Extract,
fetchBlockNum,
fetchBlockHash,
)
return monitor, extractor, forecast, delays, bonds, withdrawals
return monitor, extractor, forecast, delays, bonds, withdrawals, claims
}
type mockClaimMonitor struct {
calls int
}
func (m *mockClaimMonitor) CheckClaims(games []*monTypes.EnrichedGameData) {
m.calls++
}
type mockWithdrawalMonitor struct {
......
......@@ -42,6 +42,7 @@ type Service struct {
forecast *forecast
bonds *bonds.Bonds
game *extract.GameCallerCreator
claims *ClaimMonitor
withdrawals *WithdrawalMonitor
rollupClient *sources.RollupClient
validator *outputValidator
......@@ -86,6 +87,7 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error
return fmt.Errorf("failed to init rollup client: %w", err)
}
s.initClaimMonitor()
s.initWithdrawalMonitor()
s.initOutputValidator() // Must be called before initForecast
......@@ -105,6 +107,10 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error
return nil
}
func (s *Service) initClaimMonitor() {
s.claims = NewClaimMonitor(s.logger, s.cl, s.metrics)
}
func (s *Service) initWithdrawalMonitor() {
s.withdrawals = NewWithdrawalMonitor(s.logger, s.metrics)
}
......@@ -223,6 +229,7 @@ func (s *Service) initMonitor(ctx context.Context, cfg *config.Config) {
s.delays.RecordClaimResolutionDelayMax,
s.forecast.Forecast,
s.bonds.CheckBonds,
s.claims.CheckClaims,
s.withdrawals.CheckWithdrawals,
s.extractor.Extract,
s.l1Client.BlockNumber,
......
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