Commit 4e88a61f authored by refcell's avatar refcell Committed by GitHub

feat(op-dispute-mon): Track and Metrice Credit Balances (#9928)

* feat(op-dispute-mon): track and metrice credit balances

* fix(op-dispute-mon): Enrich Required Bonds (#9930)

* fix(op-dispute-mon): bad merge

* fix(op-dispute-mon): credit balance check final touches and logging

* Update op-dispute-mon/mon/bonds/monitor.go
Co-authored-by: default avatarInphi <mlaw2501@gmail.com>

* fix(op-dispute-mon): iterative logic

* fix(op-dispute-mon): lints

---------
Co-authored-by: default avatarInphi <mlaw2501@gmail.com>
parent 7c34ab27
...@@ -170,6 +170,22 @@ func (f *FaultDisputeGameContract) GetCredit(ctx context.Context, recipient comm ...@@ -170,6 +170,22 @@ func (f *FaultDisputeGameContract) GetCredit(ctx context.Context, recipient comm
return credit, status, nil return credit, status, nil
} }
func (f *FaultDisputeGameContract) GetRequiredBonds(ctx context.Context, block rpcblock.Block, positions ...*big.Int) ([]*big.Int, error) {
calls := make([]batching.Call, 0, len(positions))
for _, position := range positions {
calls = append(calls, f.contract.Call(methodRequiredBond, position))
}
results, err := f.multiCaller.Call(ctx, block, calls...)
if err != nil {
return nil, fmt.Errorf("failed to retrieve required bonds: %w", err)
}
requiredBonds := make([]*big.Int, 0, len(positions))
for _, result := range results {
requiredBonds = append(requiredBonds, result.GetBigInt(0))
}
return requiredBonds, nil
}
func (f *FaultDisputeGameContract) GetCredits(ctx context.Context, block rpcblock.Block, recipients ...common.Address) ([]*big.Int, error) { func (f *FaultDisputeGameContract) GetCredits(ctx context.Context, block rpcblock.Block, recipients ...common.Address) ([]*big.Int, error) {
defer f.metrics.StartContractRequest("GetCredits")() defer f.metrics.StartContractRequest("GetCredits")()
calls := make([]batching.Call, 0, len(recipients)) calls := make([]batching.Call, 0, len(recipients))
......
...@@ -19,6 +19,20 @@ import ( ...@@ -19,6 +19,20 @@ import (
const Namespace = "op_dispute_mon" const Namespace = "op_dispute_mon"
type CreditExpectation uint8
const (
// Max Duration reached
CreditBelowMaxDuration CreditExpectation = iota
CreditEqualMaxDuration
CreditAboveMaxDuration
// Max Duration not reached
CreditBelowNonMaxDuration
CreditEqualNonMaxDuration
CreditAboveNonMaxDuration
)
type GameAgreementStatus uint8 type GameAgreementStatus uint8
const ( const (
...@@ -55,6 +69,8 @@ type Metricer interface { ...@@ -55,6 +69,8 @@ type Metricer interface {
RecordInfo(version string) RecordInfo(version string)
RecordUp() RecordUp()
RecordCredit(expectation CreditExpectation, count int)
RecordClaims(status ClaimStatus, count int) RecordClaims(status ClaimStatus, count int)
RecordWithdrawalRequests(delayedWeth common.Address, matches bool, count int) RecordWithdrawalRequests(delayedWeth common.Address, matches bool, count int)
...@@ -89,6 +105,8 @@ type Metrics struct { ...@@ -89,6 +105,8 @@ type Metrics struct {
info prometheus.GaugeVec info prometheus.GaugeVec
up prometheus.Gauge up prometheus.Gauge
credits prometheus.GaugeVec
lastOutputFetch prometheus.Gauge lastOutputFetch prometheus.Gauge
claimResolutionDelayMax prometheus.Gauge claimResolutionDelayMax prometheus.Gauge
...@@ -139,6 +157,14 @@ func NewMetrics() *Metrics { ...@@ -139,6 +157,14 @@ func NewMetrics() *Metrics {
Name: "claim_resolution_delay_max", Name: "claim_resolution_delay_max",
Help: "Maximum claim resolution delay in seconds", Help: "Maximum claim resolution delay in seconds",
}), }),
credits: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "credits",
Help: "Cumulative credits",
}, []string{
"credit",
"max_duration",
}),
claims: *factory.NewGaugeVec(prometheus.GaugeOpts{ claims: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace, Namespace: Namespace,
Name: "claims", Name: "claims",
...@@ -213,6 +239,28 @@ func (m *Metrics) RecordUp() { ...@@ -213,6 +239,28 @@ func (m *Metrics) RecordUp() {
m.up.Set(1) m.up.Set(1)
} }
func (m *Metrics) RecordCredit(expectation CreditExpectation, count int) {
asLabels := func(expectation CreditExpectation) []string {
switch expectation {
case CreditBelowMaxDuration:
return []string{"below", "max_duration"}
case CreditEqualMaxDuration:
return []string{"expected", "max_duration"}
case CreditAboveMaxDuration:
return []string{"above", "max_duration"}
case CreditBelowNonMaxDuration:
return []string{"below", "non_max_duration"}
case CreditEqualNonMaxDuration:
return []string{"expected", "non_max_duration"}
case CreditAboveNonMaxDuration:
return []string{"above", "non_max_duration"}
default:
panic(fmt.Errorf("unknown credit expectation: %v", expectation))
}
}
m.credits.WithLabelValues(asLabels(expectation)...).Set(float64(count))
}
func (m *Metrics) RecordClaims(status ClaimStatus, count int) { func (m *Metrics) RecordClaims(status ClaimStatus, count int) {
asLabels := func(status ClaimStatus) []string { asLabels := func(status ClaimStatus) []string {
switch status { switch status {
......
...@@ -19,6 +19,8 @@ func (*NoopMetricsImpl) RecordUp() {} ...@@ -19,6 +19,8 @@ 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) RecordCredit(_ CreditExpectation, _ int) {}
func (*NoopMetricsImpl) RecordClaims(_ ClaimStatus, _ int) {} func (*NoopMetricsImpl) RecordClaims(_ ClaimStatus, _ int) {}
func (*NoopMetricsImpl) RecordWithdrawalRequests(_ common.Address, _ bool, _ int) {} func (*NoopMetricsImpl) RecordWithdrawalRequests(_ common.Address, _ bool, _ int) {}
......
...@@ -2,24 +2,33 @@ package bonds ...@@ -2,24 +2,33 @@ package bonds
import ( import (
"math/big" "math/big"
"time"
"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-dispute-mon/mon/types"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
) )
type RClock interface {
Now() time.Time
}
type BondMetrics interface { type BondMetrics interface {
RecordCredit(expectation metrics.CreditExpectation, count int)
RecordBondCollateral(addr common.Address, required *big.Int, available *big.Int) RecordBondCollateral(addr common.Address, required *big.Int, available *big.Int)
} }
type Bonds struct { type Bonds struct {
logger log.Logger logger log.Logger
clock RClock
metrics BondMetrics metrics BondMetrics
} }
func NewBonds(logger log.Logger, metrics BondMetrics) *Bonds { func NewBonds(logger log.Logger, metrics BondMetrics, clock RClock) *Bonds {
return &Bonds{ return &Bonds{
logger: logger, logger: logger,
clock: clock,
metrics: metrics, metrics: metrics,
} }
} }
...@@ -29,4 +38,62 @@ func (b *Bonds) CheckBonds(games []*types.EnrichedGameData) { ...@@ -29,4 +38,62 @@ func (b *Bonds) CheckBonds(games []*types.EnrichedGameData) {
for addr, collateral := range data { for addr, collateral := range data {
b.metrics.RecordBondCollateral(addr, collateral.Required, collateral.Actual) b.metrics.RecordBondCollateral(addr, collateral.Required, collateral.Actual)
} }
for _, game := range games {
b.checkCredits(game)
}
}
func (b *Bonds) checkCredits(game *types.EnrichedGameData) {
// Check if the max duration has been reached for this game
duration := uint64(b.clock.Now().Unix()) - game.Timestamp
maxDurationReached := duration >= game.Duration
// Iterate over claims and filter out resolved ones
recipients := make(map[int]common.Address)
for i, claim := range game.Claims {
// Skip unresolved claims since these bonds will not appear in the credits.
if !claim.Resolved {
continue
}
// The recipient of a resolved claim is the claimant unless it's been countered.
recipient := claim.Claimant
if claim.CounteredBy != (common.Address{}) {
recipient = claim.CounteredBy
}
recipients[i] = recipient
}
creditMetrics := make(map[metrics.CreditExpectation]int)
for i, recipient := range recipients {
expected := game.Credits[recipient]
comparison := expected.Cmp(game.RequiredBonds[i])
if maxDurationReached {
if comparison > 0 {
creditMetrics[metrics.CreditBelowMaxDuration] += 1
} else if comparison == 0 {
creditMetrics[metrics.CreditEqualMaxDuration] += 1
} else {
creditMetrics[metrics.CreditAboveMaxDuration] += 1
b.logger.Warn("credit above expected amount", "recipient", recipient, "expected", expected, "gameAddr", game.Proxy, "duration", "max_duration")
}
} else {
if comparison > 0 {
creditMetrics[metrics.CreditBelowNonMaxDuration] += 1
} else if comparison == 0 {
creditMetrics[metrics.CreditEqualNonMaxDuration] += 1
} else {
creditMetrics[metrics.CreditAboveNonMaxDuration] += 1
b.logger.Warn("credit above expected amount", "recipient", recipient, "expected", expected, "gameAddr", game.Proxy, "duration", "non_max_duration")
}
}
}
b.metrics.RecordCredit(metrics.CreditBelowMaxDuration, creditMetrics[metrics.CreditBelowMaxDuration])
b.metrics.RecordCredit(metrics.CreditEqualMaxDuration, creditMetrics[metrics.CreditEqualMaxDuration])
b.metrics.RecordCredit(metrics.CreditAboveMaxDuration, creditMetrics[metrics.CreditAboveMaxDuration])
b.metrics.RecordCredit(metrics.CreditBelowNonMaxDuration, creditMetrics[metrics.CreditBelowNonMaxDuration])
b.metrics.RecordCredit(metrics.CreditEqualNonMaxDuration, creditMetrics[metrics.CreditEqualNonMaxDuration])
b.metrics.RecordCredit(metrics.CreditAboveNonMaxDuration, creditMetrics[metrics.CreditAboveNonMaxDuration])
} }
...@@ -3,14 +3,21 @@ package bonds ...@@ -3,14 +3,21 @@ package bonds
import ( import (
"math/big" "math/big"
"testing" "testing"
"time"
"github.com/ethereum-optimism/optimism/op-dispute-mon/metrics"
monTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types" monTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types"
"github.com/ethereum-optimism/optimism/op-service/clock"
"github.com/ethereum-optimism/optimism/op-service/testlog" "github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var (
frozen = time.Unix(int64(time.Hour.Seconds()), 0)
)
func TestCheckBonds(t *testing.T) { func TestCheckBonds(t *testing.T) {
weth1 := common.Address{0x1a} weth1 := common.Address{0x1a}
weth1Balance := big.NewInt(4200) weth1Balance := big.NewInt(4200)
...@@ -32,8 +39,11 @@ func TestCheckBonds(t *testing.T) { ...@@ -32,8 +39,11 @@ func TestCheckBonds(t *testing.T) {
} }
logger := testlog.Logger(t, log.LvlInfo) logger := testlog.Logger(t, log.LvlInfo)
metrics := &stubBondMetrics{recorded: make(map[common.Address]Collateral)} metrics := &stubBondMetrics{
bonds := NewBonds(logger, metrics) credits: make(map[metrics.CreditExpectation]int),
recorded: make(map[common.Address]Collateral),
}
bonds := NewBonds(logger, metrics, clock.NewDeterministicClock(frozen))
bonds.CheckBonds([]*monTypes.EnrichedGameData{game1, game2}) bonds.CheckBonds([]*monTypes.EnrichedGameData{game1, game2})
...@@ -47,6 +57,7 @@ func TestCheckBonds(t *testing.T) { ...@@ -47,6 +57,7 @@ func TestCheckBonds(t *testing.T) {
} }
type stubBondMetrics struct { type stubBondMetrics struct {
credits map[metrics.CreditExpectation]int
recorded map[common.Address]Collateral recorded map[common.Address]Collateral
} }
...@@ -56,3 +67,7 @@ func (s *stubBondMetrics) RecordBondCollateral(addr common.Address, required *bi ...@@ -56,3 +67,7 @@ func (s *stubBondMetrics) RecordBondCollateral(addr common.Address, required *bi
Actual: available, Actual: available,
} }
} }
func (s *stubBondMetrics) RecordCredit(expectation metrics.CreditExpectation, count int) {
s.credits[expectation] = count
}
...@@ -18,6 +18,7 @@ var ErrIncorrectCreditCount = errors.New("incorrect credit count") ...@@ -18,6 +18,7 @@ var ErrIncorrectCreditCount = errors.New("incorrect credit count")
type BondCaller interface { type BondCaller interface {
GetCredits(context.Context, rpcblock.Block, ...common.Address) ([]*big.Int, error) GetCredits(context.Context, rpcblock.Block, ...common.Address) ([]*big.Int, error)
GetRequiredBonds(context.Context, rpcblock.Block, ...*big.Int) ([]*big.Int, error)
} }
type BondEnricher struct{} type BondEnricher struct{}
...@@ -27,8 +28,26 @@ func NewBondEnricher() *BondEnricher { ...@@ -27,8 +28,26 @@ func NewBondEnricher() *BondEnricher {
} }
func (b *BondEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error { func (b *BondEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error {
recipients := maps.Keys(game.Recipients) if err := b.enrichCredits(ctx, block, caller, game); err != nil {
credits, err := caller.GetCredits(ctx, block, recipients...) return err
}
if err := b.enrichRequiredBonds(ctx, block, caller, game); err != nil {
return err
}
return nil
}
func (b *BondEnricher) enrichCredits(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error {
recipients := make(map[common.Address]bool)
for _, claim := range game.Claims {
if claim.CounteredBy != (common.Address{}) {
recipients[claim.CounteredBy] = true
} else {
recipients[claim.Claimant] = true
}
}
recipientAddrs := maps.Keys(recipients)
credits, err := caller.GetCredits(ctx, block, recipientAddrs...)
if err != nil { if err != nil {
return err return err
} }
...@@ -37,7 +56,37 @@ func (b *BondEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller ...@@ -37,7 +56,37 @@ func (b *BondEnricher) Enrich(ctx context.Context, block rpcblock.Block, caller
} }
game.Credits = make(map[common.Address]*big.Int) game.Credits = make(map[common.Address]*big.Int)
for i, credit := range credits { for i, credit := range credits {
game.Credits[recipients[i]] = credit game.Credits[recipientAddrs[i]] = credit
}
return nil
}
func (b *BondEnricher) enrichRequiredBonds(ctx context.Context, block rpcblock.Block, caller GameCaller, game *monTypes.EnrichedGameData) error {
positions := make([]*big.Int, len(game.Claims))
for _, claim := range game.Claims {
// If the claim is not resolved, we don't need to get the bond
// for it since the Bond field in the claim will be accurate.
if !claim.Resolved {
continue
}
positions = append(positions, claim.Position.ToGIndex())
}
bonds, err := caller.GetRequiredBonds(ctx, block, positions...)
if err != nil {
return err
}
if len(bonds) != len(positions) {
return fmt.Errorf("%w, requested %v values but got %v", ErrIncorrectCreditCount, len(positions), len(bonds))
}
game.RequiredBonds = make(map[int]*big.Int)
bondIndex := 0
for i, claim := range game.Claims {
if !claim.Resolved {
game.RequiredBonds[i] = claim.Bond
continue
}
game.RequiredBonds[i] = bonds[bondIndex]
bondIndex++
} }
return nil return nil
} }
...@@ -82,7 +82,13 @@ func TestBondEnricher(t *testing.T) { ...@@ -82,7 +82,13 @@ func TestBondEnricher(t *testing.T) {
recipients[1]: big.NewInt(30), recipients[1]: big.NewInt(30),
recipients[2]: big.NewInt(40), recipients[2]: big.NewInt(40),
} }
caller := &mockGameCaller{credits: expectedCredits} requiredBonds := []*big.Int{
big.NewInt(10),
big.NewInt(20),
big.NewInt(30),
big.NewInt(40),
}
caller := &mockGameCaller{credits: expectedCredits, requiredBonds: requiredBonds}
err := enricher.Enrich(context.Background(), rpcblock.Latest, caller, game) err := enricher.Enrich(context.Background(), rpcblock.Latest, caller, game)
require.NoError(t, err) require.NoError(t, err)
......
...@@ -190,11 +190,22 @@ type mockGameCaller struct { ...@@ -190,11 +190,22 @@ type mockGameCaller struct {
balanceErr error balanceErr error
balance *big.Int balance *big.Int
balanceAddr common.Address balanceAddr common.Address
requiredBondCalls int
requiredBondErr error
requiredBonds []*big.Int
withdrawalsCalls int withdrawalsCalls int
withdrawalsErr error withdrawalsErr error
withdrawals []*contracts.WithdrawalRequest withdrawals []*contracts.WithdrawalRequest
} }
func (m *mockGameCaller) GetRequiredBonds(ctx context.Context, block rpcblock.Block, positions ...*big.Int) ([]*big.Int, error) {
m.requiredBondCalls++
if m.requiredBondErr != nil {
return nil, m.requiredBondErr
}
return m.requiredBonds, nil
}
func (m *mockGameCaller) GetWithdrawals(_ context.Context, _ rpcblock.Block, _ common.Address, _ ...common.Address) ([]*contracts.WithdrawalRequest, error) { func (m *mockGameCaller) GetWithdrawals(_ context.Context, _ rpcblock.Block, _ common.Address, _ ...common.Address) ([]*contracts.WithdrawalRequest, error) {
m.withdrawalsCalls++ m.withdrawalsCalls++
if m.withdrawalsErr != nil { if m.withdrawalsErr != nil {
......
...@@ -146,7 +146,7 @@ func (s *Service) initForecast(cfg *config.Config) { ...@@ -146,7 +146,7 @@ func (s *Service) initForecast(cfg *config.Config) {
} }
func (s *Service) initBonds() { func (s *Service) initBonds() {
s.bonds = bonds.NewBonds(s.logger, s.metrics) s.bonds = bonds.NewBonds(s.logger, s.metrics, s.cl)
} }
func (s *Service) initOutputRollupClient(ctx context.Context, cfg *config.Config) error { func (s *Service) initOutputRollupClient(ctx context.Context, cfg *config.Config) error {
......
...@@ -31,6 +31,11 @@ type EnrichedGameData struct { ...@@ -31,6 +31,11 @@ type EnrichedGameData struct {
// Credits records the paid out bonds for the game, keyed by recipient. // Credits records the paid out bonds for the game, keyed by recipient.
Credits map[common.Address]*big.Int Credits map[common.Address]*big.Int
// RequiredBonds maps *resolved* claim indices to their required bond amounts.
// Required bonds are not needed for unresolved claims since
// the `Bond` field in the claim is the required bond amount.
RequiredBonds map[int]*big.Int
// WithdrawalRequests maps recipients with withdrawal requests in DelayedWETH for this game. // WithdrawalRequests maps recipients with withdrawal requests in DelayedWETH for this game.
WithdrawalRequests map[common.Address]*contracts.WithdrawalRequest WithdrawalRequests map[common.Address]*contracts.WithdrawalRequest
...@@ -39,7 +44,8 @@ type EnrichedGameData struct { ...@@ -39,7 +44,8 @@ type EnrichedGameData struct {
WETHContract common.Address WETHContract common.Address
// ETHCollateral is the ETH balance of the (potentially shared) WETHContract // ETHCollateral is the ETH balance of the (potentially shared) WETHContract
// This ETH balance will be used to pay out any bonds required by the games that use the same DelayedWETH contract. // This ETH balance will be used to pay out any bonds required by the games
// that use the same DelayedWETH contract.
ETHCollateral *big.Int ETHCollateral *big.Int
} }
......
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