Skip to content
Merged
81 changes: 68 additions & 13 deletions op-challenger/game/fault/claims/claimer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"math/big"

"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
Expand All @@ -24,6 +26,8 @@ type BondClaimMetrics interface {
type BondContract interface {
GetCredit(ctx context.Context, recipient common.Address) (*big.Int, types.GameStatus, error)
ClaimCreditTx(ctx context.Context, recipient common.Address) (txmgr.TxCandidate, error)
GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (faultTypes.BondDistributionMode, error)
CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error)
}

type BondContractCreator func(game types.GameMetadata) (BondContract, error)
Expand All @@ -34,63 +38,114 @@ type Claimer struct {
contractCreator BondContractCreator
txSender TxSender
claimants []common.Address
selective bool
}

var _ BondClaimer = (*Claimer)(nil)

func NewBondClaimer(l log.Logger, m BondClaimMetrics, contractCreator BondContractCreator, txSender TxSender, claimants ...common.Address) *Claimer {
func NewBondClaimer(l log.Logger, m BondClaimMetrics, contractCreator BondContractCreator, txSender TxSender, selective bool, claimants ...common.Address) *Claimer {
return &Claimer{
logger: l,
metrics: m,
contractCreator: contractCreator,
txSender: txSender,
claimants: claimants,
selective: selective,
}
}

func (c *Claimer) ClaimBonds(ctx context.Context, games []types.GameMetadata) (err error) {
for _, game := range games {
for _, claimant := range c.claimants {
err = errors.Join(err, c.claimBond(ctx, game, claimant))
}
err = errors.Join(err, c.claimBonds(ctx, game))
}
return err
}

func (c *Claimer) claimBond(ctx context.Context, game types.GameMetadata, addr common.Address) error {
c.logger.Debug("Attempting to claim bonds for", "game", game.Proxy, "addr", addr)

func (c *Claimer) claimBonds(ctx context.Context, game types.GameMetadata) error {
contract, err := c.contractCreator(game)
if err != nil {
return fmt.Errorf("failed to create bond contract: %w", err)
}

anyCreditFound := false
var claimErr error
for _, claimant := range c.claimants {
hasCredit, err := c.claimBond(ctx, contract, game, claimant)
claimErr = errors.Join(claimErr, err)
anyCreditFound = anyCreditFound || hasCredit
}

if !anyCreditFound {
claimErr = errors.Join(claimErr, c.closeGame(ctx, contract, game))
}
return claimErr
}

// claimBond attempts to claim credit for a single address.
// Returns true if the address had credit > 0 (regardless of whether the claim succeeded).
Comment thread
ajsutton marked this conversation as resolved.
func (c *Claimer) claimBond(ctx context.Context, contract BondContract, game types.GameMetadata, addr common.Address) (bool, error) {
c.logger.Debug("Attempting to claim bonds for", "game", game.Proxy, "addr", addr)

credit, status, err := contract.GetCredit(ctx, addr)
if err != nil {
return fmt.Errorf("failed to get credit: %w", err)
return false, fmt.Errorf("failed to get credit: %w", err)
}

if status == types.GameStatusInProgress {
c.logger.Debug("Not claiming credit from in progress game", "game", game.Proxy, "addr", addr, "status", status)
return nil
return true, nil // Game is in progress, don't try to close it
}
if credit.Cmp(big.NewInt(0)) == 0 {
c.logger.Debug("No credit to claim", "game", game.Proxy, "addr", addr)
return nil
return false, nil
}
Comment thread
ajsutton marked this conversation as resolved.

candidate, err := contract.ClaimCreditTx(ctx, addr)
if errors.Is(err, contracts.ErrSimulationFailed) {
c.logger.Debug("Credit still locked", "game", game.Proxy, "addr", addr)
return nil
return true, nil // Credit exists but is locked
} else if err != nil {
return fmt.Errorf("failed to create credit claim tx: %w", err)
return true, fmt.Errorf("failed to create credit claim tx: %w", err)
}

if err = c.txSender.SendAndWaitSimple("claim credit", candidate); err != nil {
return fmt.Errorf("failed to claim credit: %w", err)
return true, fmt.Errorf("failed to claim credit: %w", err)
}

c.metrics.RecordBondClaimed(credit.Uint64())
return true, nil
}

func (c *Claimer) closeGame(ctx context.Context, contract BondContract, game types.GameMetadata) error {
Comment thread
ajsutton marked this conversation as resolved.
if c.selective {
c.logger.Debug("Skipping game close in selective claim resolution mode", "game", game.Proxy)
return nil
}

bondMode, err := contract.GetBondDistributionMode(ctx, rpcblock.Latest)
if err != nil {
return fmt.Errorf("failed to get bond distribution mode: %w", err)
}
if bondMode != faultTypes.UndecidedDistributionMode {
c.logger.Debug("Game already closed", "game", game.Proxy, "bondMode", bondMode)
return nil
}

candidate, err := contract.CloseGameTx(ctx)
if errors.Is(err, contracts.ErrSimulationFailed) {
c.logger.Debug("Game not ready to close", "game", game.Proxy)
return nil
} else if errors.Is(err, contracts.ErrCloseGameNotSupported) {
c.logger.Debug("Contract version does not support closeGame", "game", game.Proxy)
return nil
} else if err != nil {
return fmt.Errorf("failed to create close game tx: %w", err)
}

c.logger.Info("Closing game to update anchor state", "game", game.Proxy)
if err = c.txSender.SendAndWaitSimple("close game", candidate); err != nil {
return fmt.Errorf("failed to close game: %w", err)
}

return nil
}
81 changes: 76 additions & 5 deletions op-challenger/game/fault/claims/claimer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"testing"

"github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts"
faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum/go-ethereum/common"
Expand Down Expand Up @@ -50,6 +52,7 @@ func TestClaimer_ClaimBonds(t *testing.T) {
contract.credit[claimant1] = 1
contract.credit[claimant2] = 2
contract.credit[claimant3] = 0
contract.bondDistributionMode = faultTypes.NormalDistributionMode
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}})
require.NoError(t, err)
require.Equal(t, 2, txSender.sends)
Expand Down Expand Up @@ -89,10 +92,57 @@ func TestClaimer_ClaimBonds(t *testing.T) {
require.Equal(t, 0, m.RecordBondClaimedCalls)
})

t.Run("ZeroCreditReturnsNil", func(t *testing.T) {
t.Run("ZeroCreditClosesGameWhenUndecided", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, contract, txSender := newTestClaimer(t)
contract.credit[txSender.From()] = 0
contract.bondDistributionMode = faultTypes.UndecidedDistributionMode
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}})
require.NoError(t, err)
require.Equal(t, 1, txSender.sends)
require.Equal(t, 0, m.RecordBondClaimedCalls)
})

t.Run("ZeroCreditSkipsCloseWhenAlreadyClosed", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, contract, txSender := newTestClaimer(t)
contract.credit[txSender.From()] = 0
contract.bondDistributionMode = faultTypes.NormalDistributionMode
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("ZeroCreditSkipsCloseWhenLegacyMode", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, contract, txSender := newTestClaimer(t)
contract.credit[txSender.From()] = 0
contract.bondDistributionMode = faultTypes.LegacyDistributionMode
contract.closeGameNotSupported = true
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("ZeroCreditSkipsCloseWhenSimulationFails", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, contract, txSender := newTestClaimer(t)
contract.credit[txSender.From()] = 0
contract.bondDistributionMode = faultTypes.UndecidedDistributionMode
contract.closeGameSimulationFails = true
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("ZeroCreditSkipsCloseWhenSelectiveMode", func(t *testing.T) {
gameAddr := common.HexToAddress("0x1234")
c, m, contract, txSender := newTestClaimerWithSelective(t, true)
contract.credit[txSender.From()] = 0
contract.bondDistributionMode = faultTypes.UndecidedDistributionMode
err := c.ClaimBonds(context.Background(), []types.GameMetadata{{Proxy: gameAddr}})
require.NoError(t, err)
require.Equal(t, 0, txSender.sends)
Expand All @@ -112,6 +162,10 @@ func TestClaimer_ClaimBonds(t *testing.T) {
}

func newTestClaimer(t *testing.T, claimants ...common.Address) (*Claimer, *mockClaimMetrics, *stubBondContract, *mockTxSender) {
return newTestClaimerWithSelective(t, false, claimants...)
}

func newTestClaimerWithSelective(t *testing.T, selective bool, claimants ...common.Address) (*Claimer, *mockClaimMetrics, *stubBondContract, *mockTxSender) {
logger := testlog.Logger(t, log.LvlDebug)
m := &mockClaimMetrics{}
txSender := &mockTxSender{}
Expand All @@ -122,7 +176,7 @@ func newTestClaimer(t *testing.T, claimants ...common.Address) (*Claimer, *mockC
if len(claimants) == 0 {
claimants = []common.Address{txSender.From()}
}
c := NewBondClaimer(logger, m, contractCreator, txSender, claimants...)
c := NewBondClaimer(logger, m, contractCreator, txSender, selective, claimants...)
return c, m, bondContract, txSender
}

Expand Down Expand Up @@ -156,9 +210,12 @@ func (s *mockTxSender) SendAndWaitSimple(_ string, _ ...txmgr.TxCandidate) error
}

type stubBondContract struct {
credit map[common.Address]int64
status types.GameStatus
claimSimulationFails bool
credit map[common.Address]int64
status types.GameStatus
claimSimulationFails bool
bondDistributionMode faultTypes.BondDistributionMode
closeGameSimulationFails bool
closeGameNotSupported bool
}

func (s *stubBondContract) GetCredit(_ context.Context, addr common.Address) (*big.Int, types.GameStatus, error) {
Expand All @@ -171,3 +228,17 @@ func (s *stubBondContract) ClaimCreditTx(_ context.Context, _ common.Address) (t
}
return txmgr.TxCandidate{}, nil
}

func (s *stubBondContract) GetBondDistributionMode(_ context.Context, _ rpcblock.Block) (faultTypes.BondDistributionMode, error) {
return s.bondDistributionMode, nil
}

func (s *stubBondContract) CloseGameTx(_ context.Context) (txmgr.TxCandidate, error) {
if s.closeGameNotSupported {
return txmgr.TxCandidate{}, contracts.ErrCloseGameNotSupported
}
if s.closeGameSimulationFails {
return txmgr.TxCandidate{}, fmt.Errorf("failed: %w", contracts.ErrSimulationFailed)
}
return txmgr.TxCandidate{}, nil
}
13 changes: 13 additions & 0 deletions op-challenger/game/fault/contracts/faultdisputegame.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ var (
methodL2BlockNumberChallenger = "l2BlockNumberChallenger"
methodChallengeRootL2Block = "challengeRootL2Block"
methodBondDistributionMode = "bondDistributionMode"
methodCloseGame = "closeGame"
)

var (
ErrSimulationFailed = errors.New("tx simulation failed")
ErrChallengeL2BlockNotSupported = errors.New("contract version does not support challenging L2 block number")
ErrCloseGameNotSupported = errors.New("contract version does not support closeGame")
)

type FaultDisputeGameContractLatest struct {
Expand Down Expand Up @@ -528,6 +530,16 @@ func (f *FaultDisputeGameContractLatest) GetBondDistributionMode(ctx context.Con
return types.BondDistributionMode(result.GetUint8(0)), nil
}

func (f *FaultDisputeGameContractLatest) CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error) {
defer f.metrics.StartContractRequest("CloseGame")()
call := f.contract.Call(methodCloseGame)
_, err := f.multiCaller.SingleCall(ctx, rpcblock.Latest, call)
if err != nil {
return txmgr.TxCandidate{}, fmt.Errorf("%w: %w", ErrSimulationFailed, err)
}
return call.ToTxCandidate()
Comment thread
ajsutton marked this conversation as resolved.
}
Comment thread
ajsutton marked this conversation as resolved.
Comment thread
ajsutton marked this conversation as resolved.

func (f *FaultDisputeGameContractLatest) IsResolved(ctx context.Context, block rpcblock.Block, claims ...types.Claim) ([]bool, error) {
defer f.metrics.StartContractRequest("IsResolved")()
calls := make([]batching.Call, 0, len(claims))
Expand Down Expand Up @@ -706,4 +718,5 @@ type FaultDisputeGameContract interface {
ResolveClaimTx(claimIdx uint64) (txmgr.TxCandidate, error)
Vm(ctx context.Context) (*VMContract, error)
GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error)
CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error)
}
4 changes: 4 additions & 0 deletions op-challenger/game/fault/contracts/faultdisputegame0180.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,7 @@ func (f *FaultDisputeGameContract0180) DefendTx(ctx context.Context, parent type
func (f *FaultDisputeGameContract0180) GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error) {
return types.LegacyDistributionMode, nil
}

func (f *FaultDisputeGameContract0180) CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error) {
return txmgr.TxCandidate{}, ErrCloseGameNotSupported
}
4 changes: 4 additions & 0 deletions op-challenger/game/fault/contracts/faultdisputegame080.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,7 @@ func (f *FaultDisputeGameContract080) DefendTx(ctx context.Context, parent types
func (f *FaultDisputeGameContract080) GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error) {
return types.LegacyDistributionMode, nil
}

func (f *FaultDisputeGameContract080) CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error) {
return txmgr.TxCandidate{}, ErrCloseGameNotSupported
}
4 changes: 4 additions & 0 deletions op-challenger/game/fault/contracts/faultdisputegame111.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ func (f *FaultDisputeGameContract111) DefendTx(ctx context.Context, parent types
func (f *FaultDisputeGameContract111) GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error) {
return types.LegacyDistributionMode, nil
}

func (f *FaultDisputeGameContract111) CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error) {
return txmgr.TxCandidate{}, ErrCloseGameNotSupported
}
5 changes: 5 additions & 0 deletions op-challenger/game/fault/contracts/faultdisputegame131.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
)

//go:embed abis/FaultDisputeGame-1.3.1.json
Expand All @@ -18,3 +19,7 @@ type FaultDisputeGameContract131 struct {
func (f *FaultDisputeGameContract131) GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error) {
return types.LegacyDistributionMode, nil
}

func (f *FaultDisputeGameContract131) CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error) {
return txmgr.TxCandidate{}, ErrCloseGameNotSupported
}
33 changes: 33 additions & 0 deletions op-challenger/game/fault/contracts/faultdisputegame_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,39 @@ func TestFaultDisputeGame_ClaimCreditTx(t *testing.T) {
}
}

func TestFaultDisputeGame_CloseGameTx(t *testing.T) {
unsupportedVersions := []string{vers080, vers0180, vers111, vers120, vers131}
for _, version := range versions {
version := version
t.Run(version.String(), func(t *testing.T) {
supported := !slices.Contains(unsupportedVersions, version.version)

if supported {
t.Run("Success", func(t *testing.T) {
stubRpc, game := setupFaultDisputeGameTest(t, version)
stubRpc.SetResponse(fdgAddr, methodCloseGame, rpcblock.Latest, nil, nil)
tx, err := game.CloseGameTx(context.Background())
require.NoError(t, err)
stubRpc.VerifyTxCandidate(tx)
})

t.Run("SimulationFails", func(t *testing.T) {
stubRpc, game := setupFaultDisputeGameTest(t, version)
stubRpc.SetError(fdgAddr, methodCloseGame, rpcblock.Latest, nil, errors.New("game not ready"))
tx, err := game.CloseGameTx(context.Background())
require.ErrorIs(t, err, ErrSimulationFailed)
require.Equal(t, txmgr.TxCandidate{}, tx)
})
} else {
_, game := setupFaultDisputeGameTest(t, version)
tx, err := game.CloseGameTx(context.Background())
require.ErrorIs(t, err, ErrCloseGameNotSupported)
require.Equal(t, txmgr.TxCandidate{}, tx)
}
})
}
}

func TestFaultDisputeGame_IsResolved(t *testing.T) {
for _, version := range versions {
version := version
Expand Down
Loading