Commit e8145e7c authored by refcell.eth's avatar refcell.eth Committed by GitHub

feat(op-challenger): Bond Claiming (#9257)

* feat(op-challenger): bond claiming

* fix(op-challenger): Add a simple bond claim failure metric for tracking

* fix(op-challenger): add multiple error test to claimer and move claim scheduling to before allowlist filtering
parent a320015e
......@@ -90,7 +90,7 @@ const (
// DefaultGameWindow is the default maximum time duration in the past
// that the challenger will look for games to progress.
// The default value is 11 days, which is a 4 day resolution buffer
// plus the 7 day game finalization window.
// and bond claiming buffer plus the 7 day game finalization window.
DefaultGameWindow = time.Duration(11 * 24 * time.Hour)
DefaultMaxPendingTx = 10
)
......
......@@ -131,8 +131,9 @@ var (
Value: config.DefaultCannonInfoFreq,
}
GameWindowFlag = &cli.DurationFlag{
Name: "game-window",
Usage: "The time window which the challenger will look for games to progress.",
Name: "game-window",
Usage: "The time window which the challenger will look for games to progress and claim bonds. " +
"This should include a buffer for the challenger to claim bonds for games outside the maximum game duration.",
EnvVars: prefixEnvVars("GAME_WINDOW"),
Value: config.DefaultGameWindow,
}
......
package claims
import (
"context"
"errors"
"fmt"
"math/big"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
"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/txmgr"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
)
var _ BondClaimer = (*claimer)(nil)
type BondClaimer interface {
ClaimBonds(ctx context.Context, games []types.GameMetadata) error
}
type BondClaimMetrics interface {
RecordBondClaimed(amount uint64)
}
type BondContract interface {
GetCredit(ctx context.Context, receipient common.Address) (*big.Int, error)
ClaimCredit(receipient common.Address) (txmgr.TxCandidate, error)
}
type claimer struct {
logger log.Logger
metrics BondClaimMetrics
caller *batching.MultiCaller
txSender types.TxSender
}
func NewBondClaimer(l log.Logger, m BondClaimMetrics, c *batching.MultiCaller, txSender types.TxSender) *claimer {
return &claimer{
logger: l,
metrics: m,
caller: c,
txSender: txSender,
}
}
func (c *claimer) ClaimBonds(ctx context.Context, games []types.GameMetadata) (err error) {
for _, game := range games {
err = errors.Join(err, c.claimBond(ctx, game.Proxy))
}
return err
}
func (c *claimer) claimBond(ctx context.Context, gameAddr common.Address) error {
c.logger.Debug("attempting to claim bonds for", "game", gameAddr)
contract, err := contracts.NewFaultDisputeGameContract(gameAddr, c.caller)
if err != nil {
return fmt.Errorf("failed to create contract: %w", err)
}
credit, err := contract.GetCredit(ctx, c.txSender.From())
if err != nil {
return fmt.Errorf("failed to get credit: %w", err)
}
if credit.Cmp(big.NewInt(0)) == 0 {
c.logger.Debug("no credit to claim", "game", gameAddr)
return nil
}
candidate, err := contract.ClaimCredit(c.txSender.From())
if err != nil {
return fmt.Errorf("failed to create credit claim tx: %w", err)
}
if _, err = c.txSender.SendAndWait("claim credit", candidate); err != nil {
return fmt.Errorf("failed to claim credit: %w", err)
}
c.metrics.RecordBondClaimed(credit.Uint64())
return nil
}
package claims
import (
"context"
"errors"
"math/big"
"testing"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"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/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum/go-ethereum/common"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
var (
methodCredit = "credit"
mockTxMgrSendError = errors.New("mock tx mgr send error")
)
func TestClaimer_ClaimBonds(t *testing.T) {
t.Run("MultipleBondClaimsSucceed", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, rpc, txSender := newTestClaimer(t, gameAddr)
rpc.SetResponse(gameAddr, methodCredit, batching.BlockLatest, []interface{}{txSender.From()}, []interface{}{big.NewInt(1)})
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}, {Proxy: gameAddr}, {Proxy: gameAddr}})
require.NoError(t, err)
require.Equal(t, 3, txSender.sends)
require.Equal(t, 3, m.RecordBondClaimedCalls)
})
t.Run("BondClaimSucceeds", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, rpc, txSender := newTestClaimer(t, gameAddr)
rpc.SetResponse(gameAddr, methodCredit, batching.BlockLatest, []interface{}{txSender.From()}, []interface{}{big.NewInt(1)})
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}})
require.NoError(t, err)
require.Equal(t, 1, txSender.sends)
require.Equal(t, 1, m.RecordBondClaimedCalls)
})
t.Run("BondClaimFails", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, rpc, txSender := newTestClaimer(t, gameAddr)
txSender.sendFails = true
rpc.SetResponse(gameAddr, methodCredit, batching.BlockLatest, []interface{}{txSender.From()}, []interface{}{big.NewInt(1)})
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}})
require.ErrorIs(t, err, mockTxMgrSendError)
require.Equal(t, 1, txSender.sends)
require.Equal(t, 0, m.RecordBondClaimedCalls)
})
t.Run("ZeroCreditReturnsNil", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, rpc, txSender := newTestClaimer(t, gameAddr)
rpc.SetResponse(gameAddr, methodCredit, batching.BlockLatest, []interface{}{txSender.From()}, []interface{}{big.NewInt(0)})
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}})
require.NoError(t, err)
require.Equal(t, 0, txSender.sends)
require.Equal(t, 0, m.RecordBondClaimedCalls)
})
t.Run("MultipleBondClaimFails", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, rpc, txSender := newTestClaimer(t, gameAddr)
rpc.SetResponse(gameAddr, methodCredit, batching.BlockLatest, []interface{}{txSender.From()}, []interface{}{big.NewInt(1)})
txSender.sendFails = true
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}, {Proxy: gameAddr}, {Proxy: gameAddr}})
require.ErrorIs(t, err, mockTxMgrSendError)
require.Equal(t, 3, txSender.sends)
require.Equal(t, 0, m.RecordBondClaimedCalls)
})
}
func newTestClaimer(t *testing.T, gameAddr common.Address) (*claimer, *mockClaimMetrics, *batchingTest.AbiBasedRpc, *mockTxSender) {
logger := testlog.Logger(t, log.LvlDebug)
m := &mockClaimMetrics{}
txSender := &mockTxSender{}
fdgAbi, err := bindings.FaultDisputeGameMetaData.GetAbi()
require.NoError(t, err)
stubRpc := batchingTest.NewAbiBasedRpc(t, gameAddr, fdgAbi)
caller := batching.NewMultiCaller(stubRpc, 100)
c := NewBondClaimer(logger, m, caller, txSender)
return c, m, stubRpc, txSender
}
type mockClaimMetrics struct {
RecordBondClaimedCalls int
}
func (m *mockClaimMetrics) RecordBondClaimed(amount uint64) {
m.RecordBondClaimedCalls++
}
type mockTxSender struct {
sends int
sendFails bool
statusFail bool
}
func (s *mockTxSender) From() common.Address {
return common.HexToAddress("0x33333")
}
func (s *mockTxSender) SendAndWait(_ string, _ ...txmgr.TxCandidate) ([]*ethtypes.Receipt, error) {
s.sends++
if s.sendFails {
return nil, mockTxMgrSendError
}
if s.statusFail {
return []*ethtypes.Receipt{{Status: ethtypes.ReceiptStatusFailed}}, nil
}
return []*ethtypes.Receipt{{Status: ethtypes.ReceiptStatusSuccessful}}, nil
}
package claims
import (
"context"
"sync"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum/go-ethereum/log"
)
type BondClaimScheduler struct {
log log.Logger
metrics BondClaimSchedulerMetrics
ch chan schedulerMessage
claimer BondClaimer
cancel func()
wg sync.WaitGroup
}
type BondClaimSchedulerMetrics interface {
RecordBondClaimFailed()
}
type schedulerMessage struct {
blockNumber uint64
games []types.GameMetadata
}
func NewBondClaimScheduler(logger log.Logger, metrics BondClaimSchedulerMetrics, claimer BondClaimer) *BondClaimScheduler {
return &BondClaimScheduler{
log: logger,
metrics: metrics,
ch: make(chan schedulerMessage, 1),
claimer: claimer,
}
}
func (s *BondClaimScheduler) Start(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.wg.Add(1)
go s.run(ctx)
}
func (s *BondClaimScheduler) Close() error {
s.cancel()
s.wg.Wait()
return nil
}
func (s *BondClaimScheduler) run(ctx context.Context) {
defer s.wg.Done()
for {
select {
case <-ctx.Done():
return
case msg := <-s.ch:
if err := s.claimer.ClaimBonds(ctx, msg.games); err != nil {
s.metrics.RecordBondClaimFailed()
s.log.Error("Failed to claim bonds", "blockNumber", msg.blockNumber, "err", err)
}
}
}
}
func (s *BondClaimScheduler) Schedule(blockNumber uint64, games []types.GameMetadata) error {
select {
case s.ch <- schedulerMessage{blockNumber, games}:
default:
s.log.Trace("Skipping game bond claim while claiming in progress")
}
return nil
}
......@@ -33,6 +33,8 @@ var (
methodSplitDepth = "splitDepth"
methodL2BlockNumber = "l2BlockNumber"
methodRequiredBond = "getRequiredBond"
methodClaimCredit = "claimCredit"
methodCredit = "credit"
)
type FaultDisputeGameContract struct {
......@@ -92,6 +94,19 @@ func (c *FaultDisputeGameContract) GetSplitDepth(ctx context.Context) (types.Dep
return types.Depth(splitDepth.GetBigInt(0).Uint64()), nil
}
func (c *FaultDisputeGameContract) GetCredit(ctx context.Context, receipient common.Address) (*big.Int, error) {
credit, err := c.multiCaller.SingleCall(ctx, batching.BlockLatest, c.contract.Call(methodCredit, receipient))
if err != nil {
return nil, fmt.Errorf("failed to retrieve credit: %w", err)
}
return credit.GetBigInt(0), nil
}
func (f *FaultDisputeGameContract) ClaimCredit(recipient common.Address) (txmgr.TxCandidate, error) {
call := f.contract.Call(methodClaimCredit, recipient)
return call.ToTxCandidate()
}
func (c *FaultDisputeGameContract) GetRequiredBond(ctx context.Context, position types.Position) (*big.Int, error) {
bond, err := c.multiCaller.SingleCall(ctx, batching.BlockLatest, c.contract.Call(methodRequiredBond, position.ToGIndex()))
if err != nil {
......
......@@ -23,6 +23,8 @@ type GameContract interface {
DefendTx(parentContractIndex uint64, pivot common.Hash) (txmgr.TxCandidate, error)
StepTx(claimIdx uint64, isAttack bool, stateData []byte, proof []byte) (txmgr.TxCandidate, error)
GetRequiredBond(ctx context.Context, position types.Position) (*big.Int, error)
GetCredit(ctx context.Context, receipient common.Address) (*big.Int, error)
ClaimCredit(receipient common.Address) (txmgr.TxCandidate, error)
}
type Oracle interface {
......@@ -31,8 +33,7 @@ type Oracle interface {
// FaultResponder implements the [Responder] interface to send onchain transactions.
type FaultResponder struct {
log log.Logger
log log.Logger
sender gameTypes.TxSender
contract GameContract
uploader preimages.PreimageUploader
......
......@@ -411,3 +411,11 @@ func (m *mockContract) UpdateOracleTx(_ context.Context, claimIdx uint64, data *
func (m *mockContract) GetRequiredBond(_ context.Context, position types.Position) (*big.Int, error) {
return big.NewInt(5), nil
}
func (m *mockContract) GetCredit(_ context.Context, _ common.Address) (*big.Int, error) {
return big.NewInt(5), nil
}
func (m *mockContract) ClaimCredit(_ common.Address) (txmgr.TxCandidate, error) {
return txmgr.TxCandidate{TxData: ([]byte)("claimCredit")}, nil
}
......@@ -34,6 +34,10 @@ type preimageScheduler interface {
Schedule(blockHash common.Hash, blockNumber uint64) error
}
type claimer interface {
Schedule(blockNumber uint64, games []types.GameMetadata) error
}
type gameMonitor struct {
logger log.Logger
clock clock.Clock
......@@ -41,6 +45,7 @@ type gameMonitor struct {
scheduler gameScheduler
preimages preimageScheduler
gameWindow time.Duration
claimer claimer
fetchBlockNumber blockNumberFetcher
allowedGames []common.Address
l1HeadsSub ethereum.Subscription
......@@ -67,6 +72,7 @@ func newGameMonitor(
scheduler gameScheduler,
preimages preimageScheduler,
gameWindow time.Duration,
claimer claimer,
fetchBlockNumber blockNumberFetcher,
allowedGames []common.Address,
l1Source MinimalSubscriber,
......@@ -78,6 +84,7 @@ func newGameMonitor(
preimages: preimages,
source: source,
gameWindow: gameWindow,
claimer: claimer,
fetchBlockNumber: fetchBlockNumber,
allowedGames: allowedGames,
l1Source: &headSource{inner: l1Source},
......@@ -113,6 +120,9 @@ func (m *gameMonitor) progressGames(ctx context.Context, blockHash common.Hash,
if err != nil {
return fmt.Errorf("failed to load games: %w", err)
}
if err := m.claimer.Schedule(blockNumber, games); err != nil {
return fmt.Errorf("failed to schedule bond claims: %w", err)
}
var gamesToPlay []types.GameMetadata
for _, game := range games {
if !m.allowedGame(game.Proxy) {
......
......@@ -189,6 +189,7 @@ func setupMonitorTest(
sched := &stubScheduler{}
preimages := &stubPreimageScheduler{}
mockHeadSource := &mockNewHeadSource{}
mockScheduler := &mockScheduler{}
monitor := newGameMonitor(
logger,
clock.SystemClock,
......@@ -196,6 +197,7 @@ func setupMonitorTest(
sched,
preimages,
time.Duration(0),
mockScheduler,
fetchBlockNum,
allowedGames,
mockHeadSource,
......@@ -242,6 +244,16 @@ func (m *mockNewHeadSource) EthSubscribe(
return m.sub, nil
}
type mockScheduler struct {
scheduleErr error
scheduleCalls int
}
func (m *mockScheduler) Schedule(uint64, []types.GameMetadata) error {
m.scheduleCalls++
return m.scheduleErr
}
type mockSubscription struct {
errChan chan error
headers chan<- *ethtypes.Header
......@@ -254,7 +266,8 @@ func (m *mockSubscription) Err() <-chan error {
}
type stubGameSource struct {
games []types.GameMetadata
fetchErr error
games []types.GameMetadata
}
func (s *stubGameSource) FetchAllGamesAtBlock(
......@@ -262,6 +275,9 @@ func (s *stubGameSource) FetchAllGamesAtBlock(
_ uint64,
_ common.Hash,
) ([]types.GameMetadata, error) {
if s.fetchErr != nil {
return nil, s.fetchErr
}
return s.games, nil
}
......
......@@ -16,6 +16,7 @@ import (
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/claims"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
"github.com/ethereum-optimism/optimism/op-challenger/game/loader"
"github.com/ethereum-optimism/optimism/op-challenger/game/registry"
......@@ -49,6 +50,8 @@ type Service struct {
loader *loader.GameLoader
claimer *claims.BondClaimScheduler
factoryContract *contracts.DisputeGameFactoryContract
registry *registry.GameTypeRegistry
rollupClient *sources.RollupClient
......@@ -105,6 +108,9 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error
if err := s.initGameLoader(); err != nil {
return fmt.Errorf("failed to init game loader: %w", err)
}
if err := s.initBondClaims(cfg); err != nil {
return fmt.Errorf("failed to init bond claiming: %w", err)
}
if err := s.registerGameTypes(ctx, cfg); err != nil {
return fmt.Errorf("failed to register game types: %w", err)
}
......@@ -201,6 +207,13 @@ func (s *Service) initGameLoader() error {
return nil
}
func (s *Service) initBondClaims(cfg *config.Config) error {
caller := batching.NewMultiCaller(s.l1Client.Client(), batching.DefaultBatchSize)
claimer := claims.NewBondClaimer(s.logger, s.metrics, caller, s.txSender)
s.claimer = claims.NewBondClaimScheduler(s.logger, s.metrics, claimer)
return nil
}
func (s *Service) initRollupClient(ctx context.Context, cfg *config.Config) error {
if cfg.RollupRpc == "" {
return nil
......@@ -240,7 +253,7 @@ func (s *Service) initLargePreimages() error {
}
func (s *Service) initMonitor(cfg *config.Config) {
s.monitor = newGameMonitor(s.logger, s.cl, s.loader, s.sched, s.preimages, cfg.GameWindow, s.l1Client.BlockNumber, cfg.GameAllowlist, s.pollClient)
s.monitor = newGameMonitor(s.logger, s.cl, s.loader, s.sched, s.preimages, cfg.GameWindow, s.claimer, s.l1Client.BlockNumber, cfg.GameAllowlist, s.pollClient)
}
func (s *Service) Start(ctx context.Context) error {
......
......@@ -34,6 +34,9 @@ type Metricer interface {
RecordGameMove()
RecordCannonExecutionTime(t float64)
RecordBondClaimFailed()
RecordBondClaimed(amount uint64)
RecordGamesStatus(inProgress, defenderWon, challengerWon int)
RecordGameUpdateScheduled()
......@@ -62,6 +65,9 @@ type Metrics struct {
executors prometheus.GaugeVec
bondClaimFailures prometheus.Counter
bondsClaimed prometheus.Counter
highestActedL1Block prometheus.Gauge
moves prometheus.Counter
......@@ -129,6 +135,16 @@ func NewMetrics() *Metrics {
[]float64{1.0, 10.0},
prometheus.ExponentialBuckets(30.0, 2.0, 14)...),
}),
bondClaimFailures: factory.NewCounter(prometheus.CounterOpts{
Namespace: Namespace,
Name: "claim_failures",
Help: "Number of bond claims that failed",
}),
bondsClaimed: factory.NewCounter(prometheus.CounterOpts{
Namespace: Namespace,
Name: "bonds",
Help: "Number of bonds claimed by the challenge agent",
}),
trackedGames: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "tracked_games",
......@@ -185,6 +201,14 @@ func (m *Metrics) RecordGameStep() {
m.steps.Add(1)
}
func (m *Metrics) RecordBondClaimFailed() {
m.bondClaimFailures.Add(1)
}
func (m *Metrics) RecordBondClaimed(amount uint64) {
m.bondsClaimed.Add(float64(amount))
}
func (m *Metrics) RecordCannonExecutionTime(t float64) {
m.cannonExecutionTime.Observe(t)
}
......
......@@ -28,6 +28,9 @@ func (*NoopMetricsImpl) RecordGameStep() {}
func (*NoopMetricsImpl) RecordActedL1Block(_ uint64) {}
func (*NoopMetricsImpl) RecordBondClaimFailed() {}
func (*NoopMetricsImpl) RecordBondClaimed(uint64) {}
func (*NoopMetricsImpl) RecordCannonExecutionTime(t float64) {}
func (*NoopMetricsImpl) RecordGamesStatus(inProgress, defenderWon, challengerWon 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