diff --git a/op-challenger/game/fault/contracts/disputegame.go b/op-challenger/game/fault/contracts/disputegame.go index 5dd756d5099..611e0e0cf60 100644 --- a/op-challenger/game/fault/contracts/disputegame.go +++ b/op-challenger/game/fault/contracts/disputegame.go @@ -23,6 +23,7 @@ type GenericGameMetadata struct { } type DisputeGameContract interface { + Addr() common.Address GetL1Head(ctx context.Context) (common.Hash, error) GetStatus(ctx context.Context) (gameTypes.GameStatus, error) GetGameRange(ctx context.Context) (prestateBlock uint64, poststateBlock uint64, retErr error) diff --git a/op-challenger/game/fault/contracts/faultdisputegame.go b/op-challenger/game/fault/contracts/faultdisputegame.go index 8d5f3fa2baa..d6239633c71 100644 --- a/op-challenger/game/fault/contracts/faultdisputegame.go +++ b/op-challenger/game/fault/contracts/faultdisputegame.go @@ -162,6 +162,10 @@ func mustParseAbi(json []byte) *abi.ABI { return &loaded } +func (f *FaultDisputeGameContractLatest) Addr() common.Address { + return f.contract.Addr() +} + // GetBalanceAndDelay returns the total amount of ETH controlled by this contract. // Note that the ETH is actually held by the DelayedWETH contract which may be shared by multiple games. // Returns the balance and the address of the contract that actually holds the balance. diff --git a/op-challenger/game/fault/contracts/gamefactory.go b/op-challenger/game/fault/contracts/gamefactory.go index 4727d0b9cc5..29097433a80 100644 --- a/op-challenger/game/fault/contracts/gamefactory.go +++ b/op-challenger/game/fault/contracts/gamefactory.go @@ -106,15 +106,29 @@ func (f *DisputeGameFactoryContract) GetGameCount(ctx context.Context, blockHash return result.GetBigInt(0).Uint64(), nil } -func (f *DisputeGameFactoryContract) GetGame(ctx context.Context, idx uint64, blockHash common.Hash) (gameTypes.GameMetadata, error) { +func (f *DisputeGameFactoryContract) GetGame(ctx context.Context, idx uint64, block rpcblock.Block) (gameTypes.GameMetadata, error) { defer f.metrics.StartContractRequest("GetGame")() - result, err := f.multiCaller.SingleCall(ctx, rpcblock.ByHash(blockHash), f.contract.Call(methodGameAtIndex, new(big.Int).SetUint64(idx))) + result, err := f.multiCaller.SingleCall(ctx, block, f.contract.Call(methodGameAtIndex, new(big.Int).SetUint64(idx))) if err != nil { return gameTypes.GameMetadata{}, fmt.Errorf("failed to load game %v: %w", idx, err) } return f.decodeGame(idx, result), nil } +func (f *DisputeGameFactoryContract) GetGameStatus(ctx context.Context, idx uint64) (gameTypes.GameStatus, error) { + defer f.metrics.StartContractRequest("GetGameStatus")() + game, err := f.GetGame(ctx, idx, rpcblock.Latest) + if err != nil { + return 0, fmt.Errorf("failed to load game status: %w", err) + } + + gameContract, err := NewDisputeGameContract(ctx, f.metrics, f.multiCaller, gameTypes.GameType(game.GameType), game.Proxy) + if err != nil { + return 0, fmt.Errorf("failed to create contract bindings for game %s: %w", game.Proxy, err) + } + return gameContract.GetStatus(ctx) +} + func (f *DisputeGameFactoryContract) getGameImpl(ctx context.Context, gameType gameTypes.GameType) (common.Address, error) { defer f.metrics.StartContractRequest("GetGameImpl")() result, err := f.multiCaller.SingleCall(ctx, rpcblock.Latest, f.contract.Call(methodGameImpls, gameType)) diff --git a/op-challenger/game/fault/contracts/gamefactory_test.go b/op-challenger/game/fault/contracts/gamefactory_test.go index bdd468c49f4..b451628ef0b 100644 --- a/op-challenger/game/fault/contracts/gamefactory_test.go +++ b/op-challenger/game/fault/contracts/gamefactory_test.go @@ -123,8 +123,8 @@ func TestLoadGame(t *testing.T) { } expectedGames := []gameTypes.GameMetadata{game0, game1, game2} for idx, expected := range expectedGames { - expectGetGame(stubRpc, idx, blockHash, expected) - actual, err := factory.GetGame(context.Background(), uint64(idx), blockHash) + expectGetGame(stubRpc, idx, rpcblock.ByHash(blockHash), expected) + actual, err := factory.GetGame(context.Background(), uint64(idx), rpcblock.ByHash(blockHash)) require.NoError(t, err) require.Equal(t, expected, actual) } @@ -132,6 +132,28 @@ func TestLoadGame(t *testing.T) { } } +func TestGetGameStatus(t *testing.T) { + for _, version := range factoryVersions { + t.Run(version.String(), func(t *testing.T) { + stubRpc, factory := setupDisputeGameFactoryTest(t, version) + game0 := gameTypes.GameMetadata{ + Index: 0, + GameType: 0, + Timestamp: 1234, + Proxy: common.Address{0xaa}, + } + expectGetGame(stubRpc, 0, rpcblock.Latest, game0) + stubRpc.AddContract(game0.Proxy, snapshots.LoadFaultDisputeGameABI()) + expectedStatus := gameTypes.GameStatusChallengerWon + stubRpc.SetResponse(game0.Proxy, methodVersion, rpcblock.Latest, nil, []interface{}{versLatest}) + stubRpc.SetResponse(game0.Proxy, methodStatus, rpcblock.Latest, nil, []interface{}{expectedStatus}) + actual, err := factory.GetGameStatus(context.Background(), 0) + require.NoError(t, err) + require.Equal(t, expectedStatus, actual) + }) + } +} + func TestGetAllGames(t *testing.T) { for _, version := range factoryVersions { t.Run(version.String(), func(t *testing.T) { @@ -159,7 +181,7 @@ func TestGetAllGames(t *testing.T) { expectedGames := []gameTypes.GameMetadata{game0, game1, game2} stubRpc.SetResponse(factoryAddr, methodGameCount, rpcblock.ByHash(blockHash), nil, []interface{}{big.NewInt(int64(len(expectedGames)))}) for idx, expected := range expectedGames { - expectGetGame(stubRpc, idx, blockHash, expected) + expectGetGame(stubRpc, idx, rpcblock.ByHash(blockHash), expected) } actualGames, err := factory.GetAllGames(context.Background(), blockHash) require.NoError(t, err) @@ -201,7 +223,7 @@ func TestGetAllGamesAtOrAfter(t *testing.T) { stubRpc.SetResponse(factoryAddr, methodGameCount, rpcblock.ByHash(blockHash), nil, []interface{}{big.NewInt(int64(len(allGames)))}) for idx, expected := range allGames { - expectGetGame(stubRpc, idx, blockHash, expected) + expectGetGame(stubRpc, idx, rpcblock.ByHash(blockHash), expected) } // Set an earliest timestamp that's in the middle of a batch earliestTimestamp := uint64(test.earliestGameIdx) @@ -427,11 +449,11 @@ func TestDecodeDisputeGameCreatedLog(t *testing.T) { } } -func expectGetGame(stubRpc *batchingTest.AbiBasedRpc, idx int, blockHash common.Hash, game gameTypes.GameMetadata) { +func expectGetGame(stubRpc *batchingTest.AbiBasedRpc, idx int, block rpcblock.Block, game gameTypes.GameMetadata) { stubRpc.SetResponse( factoryAddr, methodGameAtIndex, - rpcblock.ByHash(blockHash), + block, []interface{}{big.NewInt(int64(idx))}, []interface{}{ game.GameType, diff --git a/op-challenger/game/fault/contracts/optimisticzkdisputegame.go b/op-challenger/game/fault/contracts/optimisticzkdisputegame.go index 72ce31768e3..4bd942d8345 100644 --- a/op-challenger/game/fault/contracts/optimisticzkdisputegame.go +++ b/op-challenger/game/fault/contracts/optimisticzkdisputegame.go @@ -41,9 +41,9 @@ type claimData struct { type OptimisticZKDisputeGameContract interface { DisputeGameContract - CanChallenge(ctx context.Context) (bool, error) ChallengeTx(ctx context.Context) (txmgr.TxCandidate, error) GetProposal(ctx context.Context) (common.Hash, uint64, error) + GetChallengerMetadata(ctx context.Context, block rpcblock.Block) (ChallengerMetadata, error) } type OptimisticZKDisputeGameContractLatest struct { @@ -65,6 +65,10 @@ func NewOptimisticZKDisputeGameContract( }, nil } +func (g *OptimisticZKDisputeGameContractLatest) Addr() common.Address { + return g.contract.Addr() +} + // GetMetadata returns the basic game metadata func (g *OptimisticZKDisputeGameContractLatest) GetMetadata(ctx context.Context, block rpcblock.Block) (GenericGameMetadata, error) { defer g.metrics.StartContractRequest("GetMetadata")() @@ -131,21 +135,34 @@ func (g *OptimisticZKDisputeGameContractLatest) GetGameRange(ctx context.Context return } -func (g *OptimisticZKDisputeGameContractLatest) CanChallenge(ctx context.Context) (bool, error) { - data, err := g.claimData(ctx) - if err != nil { - return false, err - } - return data.Status == ProposalStatusUnchallenged, nil +type ChallengerMetadata struct { + ParentIndex uint32 + ProposalStatus ProposalStatus + ProposedRoot common.Hash + L2SequenceNumber uint64 + Deadline time.Time } -func (g *OptimisticZKDisputeGameContractLatest) claimData(ctx context.Context) (claimData, error) { - defer g.metrics.StartContractRequest("ClaimData")() - result, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, g.contract.Call(methodClaimData)) +func (g *OptimisticZKDisputeGameContractLatest) GetChallengerMetadata(ctx context.Context, block rpcblock.Block) (ChallengerMetadata, error) { + defer g.metrics.StartContractRequest("GetChallengerMetadata")() + results, err := g.multiCaller.Call(ctx, block, + g.contract.Call(methodClaimData), + g.contract.Call(methodL2SequenceNumber)) if err != nil { - return claimData{}, fmt.Errorf("failed to retrieve claim data: %w", err) + return ChallengerMetadata{}, fmt.Errorf("failed to retrieve challenger metadata: %w", err) } - return g.decodeClaimData(result), nil + if len(results) != 2 { + return ChallengerMetadata{}, fmt.Errorf("expected 2 results but got %v", len(results)) + } + data := g.decodeClaimData(results[0]) + l2SeqNum := results[1].GetBigInt(0).Uint64() + return ChallengerMetadata{ + ParentIndex: data.ParentIndex, + ProposalStatus: data.Status, + ProposedRoot: data.Claim, + L2SequenceNumber: l2SeqNum, + Deadline: time.Unix(int64(data.Deadline), 0), + }, nil } func (g *OptimisticZKDisputeGameContractLatest) ChallengeTx(ctx context.Context) (txmgr.TxCandidate, error) { diff --git a/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go b/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go index 3c9a6e9511f..85ae1232ad0 100644 --- a/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go +++ b/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go @@ -157,63 +157,33 @@ func TestZKResolveTx(t *testing.T) { } } -func TestZKCanChallenge(t *testing.T) { +func TestZKGetChallengerMetadata(t *testing.T) { for _, version := range zkVersions { version := version t.Run(version.String(), func(t *testing.T) { - parentIndex := uint32(525) - claim := common.Hash{0xbb} - deadline := uint64(42824240) - - tests := []struct { - name string - counteredBy common.Address - prover common.Address - status ProposalStatus - expectedResult bool - }{ - { - name: "Unchallenged", - status: ProposalStatusUnchallenged, - expectedResult: true, - }, - { - name: "Challenged", - counteredBy: common.Address{0xaa}, - status: ProposalStatusChallenged, - expectedResult: false, - }, - { - name: "UnchallengedAndProven", - prover: common.Address{0xaa}, - status: ProposalStatusUnchallengedAndValidProofProvided, - expectedResult: false, - }, - { - name: "ChallengedAndProven", - counteredBy: common.Address{0xaa}, - prover: common.Address{0xbb}, - status: ProposalStatusChallengedAndValidProofProvided, - expectedResult: false, - }, - { - name: "Resolved", - status: ProposalStatusResolved, - expectedResult: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - stubRpc, game := setupZKDisputeGameTest(t, version) - stubRpc.SetResponse(zkGameAddr, methodClaimData, rpcblock.Latest, nil, []interface{}{ - parentIndex, test.counteredBy, test.prover, claim, test.status, deadline, - }) - result, err := game.CanChallenge(context.Background()) - require.NoError(t, err) - require.Equal(t, test.expectedResult, result) - }) + stubRpc, contract := setupZKDisputeGameTest(t, version) + expectedParentIndex := uint32(525) + expectedProposalStatus := ProposalStatusChallengedAndValidProofProvided + counteredBy := common.Address{0xad} + prover := common.Address{0xac} + expectedL2BlockNumber := uint64(123) + expectedRootClaim := common.Hash{0x01, 0x02} + expectedDeadline := time.Unix(84928429020, 0) + block := rpcblock.ByNumber(889) + stubRpc.SetResponse(zkGameAddr, methodClaimData, block, nil, []interface{}{ + expectedParentIndex, counteredBy, prover, expectedRootClaim, expectedProposalStatus, uint64(expectedDeadline.Unix()), + }) + stubRpc.SetResponse(zkGameAddr, methodL2SequenceNumber, block, nil, []interface{}{new(big.Int).SetUint64(expectedL2BlockNumber)}) + actual, err := contract.GetChallengerMetadata(context.Background(), block) + expected := ChallengerMetadata{ + ParentIndex: expectedParentIndex, + ProposalStatus: expectedProposalStatus, + ProposedRoot: expectedRootClaim, + L2SequenceNumber: expectedL2BlockNumber, + Deadline: expectedDeadline, } + require.NoError(t, err) + require.Equal(t, expected, actual) }) } } diff --git a/op-challenger/game/service.go b/op-challenger/game/service.go index 4d9fadaeb36..8188667f5ed 100644 --- a/op-challenger/game/service.go +++ b/op-challenger/game/service.go @@ -218,7 +218,7 @@ func (s *Service) registerGameTypes(ctx context.Context, cfg *config.Config) err if err != nil { return err } - err = zk.RegisterGameTypes(ctx, s.systemClock, s.l1Clock, s.logger, s.metrics, cfg, gameTypeRegistry, s.txSender, s.clientProvider) + err = zk.RegisterGameTypes(ctx, s.l1Clock, s.logger, s.metrics, cfg, gameTypeRegistry, s.txSender, s.clientProvider, s.factoryContract) if err != nil { return err } diff --git a/op-challenger/game/zk/actor.go b/op-challenger/game/zk/actor.go index 05c35aa9c01..2a8ec7a9f63 100644 --- a/op-challenger/game/zk/actor.go +++ b/op-challenger/game/zk/actor.go @@ -4,80 +4,117 @@ import ( "context" "errors" "fmt" + "math" "strings" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts" "github.com/ethereum-optimism/optimism/op-challenger/game/generic" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" "github.com/ethereum-optimism/optimism/op-service/eth" + "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" "github.com/ethereum/go-ethereum/rpc" ) +var ( + errNoChallengeRequired = errors.New("no challenge required") + errNoResolutionRequired = errors.New("no resolution required") +) + type RootProvider interface { OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error) } +type GameStatusProvider interface { + GetGameStatus(ctx context.Context, idx uint64) (gameTypes.GameStatus, error) +} + type ChallengableContract interface { - CanChallenge(ctx context.Context) (bool, error) + Addr() common.Address ChallengeTx(ctx context.Context) (txmgr.TxCandidate, error) GetProposal(ctx context.Context) (common.Hash, uint64, error) + GetChallengerMetadata(ctx context.Context, block rpcblock.Block) (contracts.ChallengerMetadata, error) + ResolveTx() (txmgr.TxCandidate, error) } type Actor struct { - logger log.Logger - rootProvider RootProvider - contract ChallengableContract - txSender TxSender - l1Head eth.BlockID + logger log.Logger + l1Clock ClockReader + rootProvider RootProvider + gameStatusProvider GameStatusProvider + contract ChallengableContract + txSender TxSender + l1Head eth.BlockID } -func ActorCreator(rootProvider RootProvider, contract ChallengableContract, txSender TxSender) generic.ActorCreator { +func ActorCreator(l1Clock ClockReader, rootProvider RootProvider, gameStatusProvider GameStatusProvider, contract ChallengableContract, txSender TxSender) generic.ActorCreator { return func(ctx context.Context, logger log.Logger, l1Head eth.BlockID) (generic.Actor, error) { return &Actor{ - logger: logger, - rootProvider: rootProvider, - contract: contract, - txSender: txSender, - l1Head: l1Head, + logger: logger, + l1Clock: l1Clock, + rootProvider: rootProvider, + gameStatusProvider: gameStatusProvider, + contract: contract, + txSender: txSender, + l1Head: l1Head, }, nil } } func (a *Actor) Act(ctx context.Context) error { - canChallenge, err := a.contract.CanChallenge(ctx) + gameState, err := a.contract.GetChallengerMetadata(ctx, rpcblock.Latest) if err != nil { - return fmt.Errorf("failed to check if game can be challenged: %w", err) + return fmt.Errorf("failed to get zk game state: %w", err) } - if !canChallenge { - a.logger.Debug("Skipping unchallengeable zk game") + + var txs []txmgr.TxCandidate + if tx, err := a.createChallengeTx(ctx, gameState); errors.Is(err, errNoChallengeRequired) { + a.logger.Debug("No challenge required") + } else if err != nil { + return err + } else { + txs = append(txs, tx) + } + if tx, err := a.createResolveTx(ctx, gameState); errors.Is(err, errNoResolutionRequired) { + a.logger.Debug("No resolution required") + } else if err != nil { + return err + } else { + txs = append(txs, tx) + } + + if len(txs) == 0 { return nil } + if err := a.txSender.SendAndWaitSimple(fmt.Sprintf("respond to game %v", a.contract.Addr()), txs...); err != nil { + return fmt.Errorf("failed to send transactions for game %v: %w", a.contract.Addr(), err) + } + return nil +} - // Check if we agree with the proposal - proposalHash, proposalSeqNum, err := a.contract.GetProposal(ctx) - if err != nil { - return fmt.Errorf("failed to get zk game proposal: %w", err) +func (a *Actor) createChallengeTx(ctx context.Context, gameState contracts.ChallengerMetadata) (txmgr.TxCandidate, error) { + if gameState.ProposalStatus != contracts.ProposalStatusUnchallenged || gameState.Deadline.Before(a.l1Clock.Now()) { + a.logger.Trace("Skipping unchallengeable zk game") + return txmgr.TxCandidate{}, errNoChallengeRequired } - if valid, err := a.isValidProposal(ctx, proposalSeqNum, proposalHash); err != nil { - return fmt.Errorf("failed to check if proposal is valid: %w", err) + if valid, err := a.isValidProposal(ctx); err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("failed to check if proposal is valid: %w", err) } else if valid { - a.logger.Debug("Not challenging valid zk game") - return nil + a.logger.Trace("Not challenging valid zk game") + return txmgr.TxCandidate{}, errNoChallengeRequired } a.logger.Info("Challenging game") - tx, err := a.contract.ChallengeTx(ctx) - if err != nil { - return fmt.Errorf("failed to create challenge tx: %w", err) - } - if err := a.txSender.SendAndWaitSimple("challenge zk game", tx); err != nil { - return fmt.Errorf("failed to challenge zk game: %w", err) - } - return nil + return a.contract.ChallengeTx(ctx) } -func (a *Actor) isValidProposal(ctx context.Context, proposalSeqNum uint64, proposalHash common.Hash) (bool, error) { +func (a *Actor) isValidProposal(ctx context.Context) (bool, error) { + proposalHash, proposalSeqNum, err := a.contract.GetProposal(ctx) + if err != nil { + return false, fmt.Errorf("failed to get zk game proposal: %w", err) + } canonicalOutput, err := a.rootProvider.OutputAtBlock(ctx, proposalSeqNum) if err != nil { var rpcErr rpc.Error @@ -96,6 +133,40 @@ func (a *Actor) isValidProposal(ctx context.Context, proposalSeqNum uint64, prop return true, nil } -func (a *Actor) AdditionalStatus(ctx context.Context) ([]any, error) { +func (a *Actor) createResolveTx(ctx context.Context, gameState contracts.ChallengerMetadata) (txmgr.TxCandidate, error) { + if gameState.ProposalStatus == contracts.ProposalStatusResolved { + a.logger.Trace("Skipping resolution of resolved zk game") + return txmgr.TxCandidate{}, errNoResolutionRequired + } + deadlineExpired := gameState.Deadline.Before(a.l1Clock.Now()) + + if gameState.ParentIndex != math.MaxUint32 { + parentStatus, err := a.gameStatusProvider.GetGameStatus(ctx, uint64(gameState.ParentIndex)) + if err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("failed to get parent game status: %w", err) + } + if parentStatus == gameTypes.GameStatusInProgress { + a.logger.Trace("Skipping resolution of zk game with parent in progress") + return txmgr.TxCandidate{}, errNoResolutionRequired + } + if parentStatus == gameTypes.GameStatusChallengerWon { + // Resolve if the parent game is invalid + return a.contract.ResolveTx() + } + } + + if gameState.ProposalStatus == contracts.ProposalStatusChallengedAndValidProofProvided || + gameState.ProposalStatus == contracts.ProposalStatusUnchallengedAndValidProofProvided { + // Resolve if a valid proof is provided + return a.contract.ResolveTx() + } + if deadlineExpired { + // Resolve if the deadline has expired (either for challenging or proving) + return a.contract.ResolveTx() + } + return txmgr.TxCandidate{}, errNoResolutionRequired +} + +func (a *Actor) AdditionalStatus(_ context.Context) ([]any, error) { return nil, nil } diff --git a/op-challenger/game/zk/actor_test.go b/op-challenger/game/zk/actor_test.go index f047eb22aa9..ac141d4e026 100644 --- a/op-challenger/game/zk/actor_test.go +++ b/op-challenger/game/zk/actor_test.go @@ -3,9 +3,15 @@ package zk import ( "context" "errors" + "math" "testing" + "time" + "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/clock" "github.com/ethereum-optimism/optimism/op-service/eth" + "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" @@ -14,56 +20,180 @@ import ( "github.com/stretchr/testify/require" ) -var challengeData = []byte{0x99, 0x98} - -func TestActor_DoNothingIfAlreadyChallenged(t *testing.T) { - actor, rootProvider, contract, sender := setupActorTest(t) - rootProvider.root = common.Hash{0xba, 0xd0} // Disagree but already challenged - contract.challenged = true - verifyNoChallenge(t, actor, contract, sender) -} - -func TestActor_ChallengeIncorrectProposal(t *testing.T) { - actor, rootProvider, contract, sender := setupActorTest(t) - rootProvider.root = common.Hash{0xba, 0xd0} - contract.proposalHash = common.Hash{0x11} - contract.l2SequenceNumber = uint64(28492) - verifyChallenge(t, actor, contract, sender) -} - -func TestActor_ChallengeProposalBeyondCurrentUnsafeHead(t *testing.T) { - actor, rootProvider, contract, sender := setupActorTest(t) - rootProvider.root = common.Hash{0xba, 0xd0} - rootProvider.outputErr = mockNotFoundRPCError() - contract.proposalHash = rootProvider.root - contract.l2SequenceNumber = rootProvider.rootBlockNum - verifyChallenge(t, actor, contract, sender) -} - -func TestActor_DoNotChallengeCorrectProposal(t *testing.T) { - actor, rootProvider, contract, sender := setupActorTest(t) - contract.challenged = false - contract.proposalHash = rootProvider.root - contract.l2SequenceNumber = rootProvider.rootBlockNum - verifyNoChallenge(t, actor, contract, sender) -} +var ( + challengeData = "challenge" + resolveData = "resolve" + l1Time = time.Unix(9892842, 0) +) -func verifyNoChallenge(t *testing.T, actor *Actor, contract *stubContract, sender *stubTxSender) { - err := actor.Act(context.Background()) - require.NoError(t, err) - require.False(t, contract.txCreated, "should not challenge already challenged game") - require.Empty(t, sender.sent, "should not send challenge tx") +type zkTestStubs struct { + rootProvider *stubRootProvider + contract *stubContract + sender *stubTxSender } -func verifyChallenge(t *testing.T, actor *Actor, contract *stubContract, sender *stubTxSender) { - err := actor.Act(context.Background()) - require.NoError(t, err) - require.True(t, contract.txCreated, "should not challenge already challenged game") - require.Len(t, sender.sent, 1, "should not send challenge tx") - require.Equal(t, challengeData, sender.sent[0].TxData, "should have sent expected challenge transaction") +func TestActor(t *testing.T) { + // Output root: Valid, Invalid + // Safety: Safe, Unsafe, Beyond unsafe + // In challenge period, ChallengePeriodExpired, In proof period, ProvenWithoutChallenge, ProvenAfterChallenge, ProofPeriodExpired, Resolved + // No parent, parent in progress, parent valid, parent invalid + tests := []struct { + name string + setup func(t *testing.T, stubs *zkTestStubs) + challenge bool + resolve bool + }{ + { + name: "DoNotChallengeCorrectProposal", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.proposalHash = stubs.rootProvider.root + stubs.contract.l2SequenceNumber = stubs.rootProvider.rootBlockNum + }, + }, + { + name: "ChallengeIncorrectProposal", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.proposalHash = common.Hash{0xba, 0xd0} + }, + challenge: true, + }, + { + name: "DoNothingIfAlreadyChallenged", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.rootProvider.root = common.Hash{0xba, 0xd0} // Disagree but already challenged + stubs.contract.challenge(t) + }, + }, + { + name: "ChallengeProposalBeyondCurrentUnsafeHead", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.rootProvider.root = common.Hash{0xba, 0xd0} + stubs.rootProvider.outputErr = mockNotFoundRPCError() + stubs.contract.proposalHash = stubs.rootProvider.root + stubs.contract.l2SequenceNumber = stubs.rootProvider.rootBlockNum + }, + challenge: true, + }, + { + name: "ChallengeUnresolvableGameWithNoParent", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.proposalHash = common.Hash{0xba, 0xd0} + stubs.contract.parentIndex = math.MaxUint32 + }, + challenge: true, + }, + { + name: "ResolveGameWithNoParent", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineExpired() + stubs.contract.proposalHash = common.Hash{0xba, 0xd0} + stubs.contract.parentIndex = math.MaxUint32 + }, + resolve: true, + }, + { + name: "DoNothingWhenDeadlineExpiredButParentNotResolved", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineExpired() + // Proposal is invalid but can't challenge because the deadline is expired + stubs.contract.proposalHash = common.Hash{0xba, 0xd0} + // And can't resolve because the parent is still unresolved + stubs.contract.setParentStatus(types.GameStatusInProgress) + }, + }, + { + name: "InChallengePeriodWithInvalidParent", + setup: func(t *testing.T, stubs *zkTestStubs) { + // Game should be challenged + stubs.contract.proposalHash = common.Hash{0xba, 0xd0} + stubs.contract.setDeadlineNotReached() + // And is immediately resolvable because the parent is invalid + stubs.contract.setParentStatus(types.GameStatusChallengerWon) + }, + challenge: true, + resolve: true, + }, + { + name: "UnchallengedWithDeadlineExpired", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineExpired() + }, + resolve: true, + }, + { + name: "ChallengedWithDeadlineExpired", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineExpired() + stubs.contract.challenge(t) + }, + resolve: true, + }, + { + name: "ChallengedAndProvenWithDeadlineExpired", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineExpired() + stubs.contract.challenge(t) + stubs.contract.prove(t) + }, + resolve: true, + }, + { + name: "ChallengedAndProvenWithDeadlineNotReached", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + stubs.contract.prove(t) + }, + resolve: true, + }, + { + name: "UnchallengedAndProvenWithDeadlineExpired", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineExpired() + stubs.contract.prove(t) + }, + resolve: true, + }, + { + name: "UnchallengedAndProvenWithDeadlineNotReached", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.prove(t) + }, + resolve: true, + }, + { + name: "AlreadyResolved", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.markResolved() + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actor, stubs := setupActorTest(t) + if tt.setup != nil { + tt.setup(t, stubs) + } + err := actor.Act(context.Background()) + require.NoError(t, err) + expectedTxCount := 0 + if tt.challenge { + require.Contains(t, stubs.sender.sentData, challengeData) + expectedTxCount++ + } + if tt.resolve { + require.Contains(t, stubs.sender.sentData, resolveData) + expectedTxCount++ + } + require.Len(t, stubs.sender.sentData, expectedTxCount) + }) + } } -func setupActorTest(t *testing.T) (*Actor, *stubRootProvider, *stubContract, *stubTxSender) { +func setupActorTest(t *testing.T) (*Actor, *zkTestStubs) { logger := testlog.Logger(t, log.LvlInfo) l1Head := eth.BlockID{ Hash: common.Hash{0x12}, @@ -78,14 +208,23 @@ func setupActorTest(t *testing.T) (*Actor, *stubRootProvider, *stubContract, *st contract := &stubContract{ proposalHash: rootProvider.root, l2SequenceNumber: rootProvider.rootBlockNum, + parentStatus: types.GameStatusDefenderWon, + parentIndex: 482, } + contract.setDeadlineNotReached() txSender := &stubTxSender{} - creator := ActorCreator(rootProvider, contract, txSender) + l1Clock := clock.NewDeterministicClock(l1Time) + // Simplify the tests by using the same stub for the game and the dispute game factory + creator := ActorCreator(l1Clock, rootProvider, contract, contract, txSender) genericActor, err := creator(context.Background(), logger, l1Head) require.NoError(t, err, "failed to create actor") actor, ok := genericActor.(*Actor) require.True(t, ok, "actor is not of expected type") - return actor, rootProvider, contract, txSender + return actor, &zkTestStubs{ + rootProvider: rootProvider, + contract: contract, + sender: txSender, + } } type stubRootProvider struct { @@ -107,20 +246,79 @@ func (s *stubRootProvider) OutputAtBlock(_ context.Context, blockNum uint64) (*e } type stubContract struct { - challenged bool + parentIndex uint32 + parentStatus types.GameStatus + proposalStatus contracts.ProposalStatus + deadline time.Time txCreated bool proposalHash common.Hash l2SequenceNumber uint64 } -func (s *stubContract) CanChallenge(_ context.Context) (bool, error) { - return !s.challenged, nil +func (s *stubContract) Addr() common.Address { + return common.Address{0x67, 0x67, 0x67} +} + +func (s *stubContract) challenge(t *testing.T) { + require.Equal(t, contracts.ProposalStatusUnchallenged, s.proposalStatus, "game not in challengable state") + s.proposalStatus = contracts.ProposalStatusChallenged +} + +func (s *stubContract) prove(t *testing.T) { + if s.proposalStatus == contracts.ProposalStatusUnchallenged { + s.proposalStatus = contracts.ProposalStatusUnchallengedAndValidProofProvided + return + } + require.Equal(t, contracts.ProposalStatusChallenged, s.proposalStatus, "game not in provable state") + s.proposalStatus = contracts.ProposalStatusChallengedAndValidProofProvided +} + +func (s *stubContract) setDeadlineExpired() { + s.deadline = l1Time.Add(-1 * time.Second) +} + +func (s *stubContract) setDeadlineNotReached() { + s.deadline = l1Time.Add(1 * time.Second) +} + +func (s *stubContract) markResolved() { + s.proposalStatus = contracts.ProposalStatusResolved +} + +func (s *stubContract) setParentStatus(status types.GameStatus) { + s.parentStatus = status +} + +func (s *stubContract) GetGameStatus(_ context.Context, idx uint64) (types.GameStatus, error) { + if idx != uint64(s.parentIndex) { + return 0, errors.New("unexpected parent index") + } + if idx == math.MaxUint32 { + return 0, errors.New("execution reverted") // no such game + } + return s.parentStatus, nil +} + +func (s *stubContract) GetChallengerMetadata(_ context.Context, _ rpcblock.Block) (contracts.ChallengerMetadata, error) { + return contracts.ChallengerMetadata{ + ParentIndex: s.parentIndex, + ProposalStatus: s.proposalStatus, + ProposedRoot: s.proposalHash, + L2SequenceNumber: s.l2SequenceNumber, + Deadline: s.deadline, + }, nil } func (s *stubContract) ChallengeTx(_ context.Context) (txmgr.TxCandidate, error) { s.txCreated = true return txmgr.TxCandidate{ - TxData: challengeData, + TxData: []byte(challengeData), + }, nil +} + +func (s *stubContract) ResolveTx() (txmgr.TxCandidate, error) { + return txmgr.TxCandidate{ + TxData: []byte(resolveData), }, nil } @@ -129,12 +327,14 @@ func (s *stubContract) GetProposal(_ context.Context) (common.Hash, uint64, erro } type stubTxSender struct { - sent []txmgr.TxCandidate - sendErr error + sentData []string + sendErr error } func (s *stubTxSender) SendAndWaitSimple(_ string, candidates ...txmgr.TxCandidate) error { - s.sent = append(s.sent, candidates...) + for _, candidate := range candidates { + s.sentData = append(s.sentData, string(candidate.TxData)) + } if s.sendErr != nil { return s.sendErr } diff --git a/op-challenger/game/zk/register.go b/op-challenger/game/zk/register.go index 89d93876521..e063671b3e5 100644 --- a/op-challenger/game/zk/register.go +++ b/op-challenger/game/zk/register.go @@ -12,7 +12,6 @@ import ( "github.com/ethereum-optimism/optimism/op-challenger/game/scheduler" gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" "github.com/ethereum-optimism/optimism/op-challenger/metrics" - "github.com/ethereum-optimism/optimism/op-service/clock" "github.com/ethereum-optimism/optimism/op-service/txmgr" "github.com/ethereum/go-ethereum/log" ) @@ -31,7 +30,6 @@ type TxSender interface { func RegisterGameTypes( ctx context.Context, - systemClock clock.Clock, l1Clock ClockReader, logger log.Logger, m metrics.Metricer, @@ -39,6 +37,7 @@ func RegisterGameTypes( registry Registry, txSender TxSender, clients *client.Provider, + gameStatusProvider GameStatusProvider, ) error { if cfg.GameTypeEnabled(gameTypes.OptimisticZKGameType) { registry.RegisterGameType(gameTypes.OptimisticZKGameType, func(game gameTypes.GameMetadata, dir string) (scheduler.GamePlayer, error) { @@ -58,7 +57,7 @@ func RegisterGameTypes( syncValidator, nil, clients.L1Client(), - ActorCreator(rollupClient, contract, txSender), + ActorCreator(l1Clock, rollupClient, gameStatusProvider, contract, txSender), ) }) } diff --git a/op-e2e/interop/interop_test.go b/op-e2e/interop/interop_test.go index ca03e511274..080b06fda1c 100644 --- a/op-e2e/interop/interop_test.go +++ b/op-e2e/interop/interop_test.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/dial" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" "github.com/ethereum-optimism/optimism/op-service/testlog" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/log" @@ -442,7 +443,7 @@ func TestProposals(t *testing.T) { head, err := ethClient.BlockByNumber(context.Background(), nil) require.NoError(t, err) - game, err := factory.GetGame(context.Background(), 0, head.Hash()) + game, err := factory.GetGame(context.Background(), 0, rpcblock.ByHash(head.Hash())) require.NoError(t, err) require.Equal(t, uint32(4) /* super permissionless */, game.GameType) }