From 3c4a0b79a492db90f3819fadc8a7d2f8229eedc1 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Mon, 1 Dec 2025 13:41:34 +1000 Subject: [PATCH 1/4] op-challenger: Support challenging optimistic ZK games --- op-challenger/cmd/main_test.go | 2 +- op-challenger/config/config.go | 5 + op-challenger/config/config_test.go | 7 + op-challenger/flags/flags.go | 2 +- .../contracts/optimisticzkdisputegame.go | 87 +++++++++ .../contracts/optimisticzkdisputegame_test.go | 124 ++++++++++-- op-challenger/game/service.go | 5 + op-challenger/game/types/game_type.go | 3 +- op-challenger/game/zk/actor.go | 109 +++++++++++ op-challenger/game/zk/actor_test.go | 177 ++++++++++++++++++ op-challenger/game/zk/register.go | 72 +++++++ 11 files changed, 578 insertions(+), 15 deletions(-) create mode 100644 op-challenger/game/zk/actor.go create mode 100644 op-challenger/game/zk/actor_test.go create mode 100644 op-challenger/game/zk/register.go diff --git a/op-challenger/cmd/main_test.go b/op-challenger/cmd/main_test.go index 2e5ae1652b3..740c2f9057b 100644 --- a/op-challenger/cmd/main_test.go +++ b/op-challenger/cmd/main_test.go @@ -1409,7 +1409,7 @@ func requiredArgs(gameType gameTypes.GameType) map[string]string { addRequiredSuperCannonKonaArgs(args) case gameTypes.SuperAsteriscKonaGameType: addRequiredSuperAsteriscKonaArgs(args) - case gameTypes.AlphabetGameType, gameTypes.FastGameType: + case gameTypes.OptimisticZKGameType, gameTypes.AlphabetGameType, gameTypes.FastGameType: addRequiredOutputRootArgs(args) } return args diff --git a/op-challenger/config/config.go b/op-challenger/config/config.go index 469fba793f2..fa37498de70 100644 --- a/op-challenger/config/config.go +++ b/op-challenger/config/config.go @@ -365,6 +365,11 @@ func (c Config) Check() error { return err } } + if c.GameTypeEnabled(gameTypes.OptimisticZKGameType) { + if c.RollupRpc == "" { + return ErrMissingRollupRpc + } + } if c.GameTypeEnabled(gameTypes.AlphabetGameType) || c.GameTypeEnabled(gameTypes.FastGameType) { if c.RollupRpc == "" { return ErrMissingRollupRpc diff --git a/op-challenger/config/config_test.go b/op-challenger/config/config_test.go index 3b1b0107b48..86b38b175e1 100644 --- a/op-challenger/config/config_test.go +++ b/op-challenger/config/config_test.go @@ -150,6 +150,10 @@ func applyValidConfigForSuperAsteriscKona(t *testing.T, cfg *Config) { applyValidConfigForAsteriscKona(t, cfg) } +func applyValidConfigForOptimisticZK(cfg *Config) { + cfg.RollupRpc = validRollupRpc +} + func validConfig(t *testing.T, gameType gameTypes.GameType) Config { cfg := NewConfig(validGameFactoryAddress, validL1EthRpc, validL1BeaconUrl, validRollupRpc, validL2Rpc, validDatadir, gameType) if gameType == gameTypes.SuperCannonGameType || gameType == gameTypes.SuperPermissionedGameType { @@ -173,6 +177,9 @@ func validConfig(t *testing.T, gameType gameTypes.GameType) Config { if gameType == gameTypes.SuperAsteriscKonaGameType { applyValidConfigForSuperAsteriscKona(t, &cfg) } + if gameType == gameTypes.OptimisticZKGameType { + applyValidConfigForOptimisticZK(&cfg) + } return cfg } diff --git a/op-challenger/flags/flags.go b/op-challenger/flags/flags.go index 1a9c4c2efd2..c2faf0f24f7 100644 --- a/op-challenger/flags/flags.go +++ b/op-challenger/flags/flags.go @@ -589,7 +589,7 @@ func CheckRequired(ctx *cli.Context, types []gameTypes.GameType) error { if err := CheckSuperAsteriscKonaFlags(ctx); err != nil { return err } - case gameTypes.AlphabetGameType, gameTypes.FastGameType: + case gameTypes.OptimisticZKGameType, gameTypes.AlphabetGameType, gameTypes.FastGameType: if err := checkOutputProviderFlags(ctx); err != nil { return err } diff --git a/op-challenger/game/fault/contracts/optimisticzkdisputegame.go b/op-challenger/game/fault/contracts/optimisticzkdisputegame.go index 0a497d99ed2..72ce31768e3 100644 --- a/op-challenger/game/fault/contracts/optimisticzkdisputegame.go +++ b/op-challenger/game/fault/contracts/optimisticzkdisputegame.go @@ -14,8 +14,36 @@ import ( "github.com/ethereum/go-ethereum/common" ) +type ProposalStatus uint8 + +const ( + ProposalStatusUnchallenged ProposalStatus = iota + ProposalStatusChallenged + ProposalStatusUnchallengedAndValidProofProvided + ProposalStatusChallengedAndValidProofProvided + ProposalStatusResolved +) + +var ( + methodChallenge = "challenge" + methodChallengerBond = "challengerBond" + methodClaimData = "claimData" +) + +type claimData struct { + ParentIndex uint32 + CounteredBy common.Address + Prover common.Address + Claim common.Hash + Status ProposalStatus + Deadline uint64 +} + 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) } type OptimisticZKDisputeGameContractLatest struct { @@ -103,6 +131,48 @@ 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 +} + +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)) + if err != nil { + return claimData{}, fmt.Errorf("failed to retrieve claim data: %w", err) + } + return g.decodeClaimData(result), nil +} + +func (g *OptimisticZKDisputeGameContractLatest) ChallengeTx(ctx context.Context) (txmgr.TxCandidate, error) { + tx, err := g.contract.Call(methodChallenge).ToTxCandidate() + if err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("failed to create challenge tx: %w", err) + } + + result, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, g.contract.Call(methodChallengerBond)) + if err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("failed to retrieve challenger bond: %w", err) + } + tx.Value = result.GetBigInt(0) + return tx, nil +} + +func (g *OptimisticZKDisputeGameContractLatest) GetProposal(ctx context.Context) (common.Hash, uint64, error) { + results, err := g.multiCaller.Call(ctx, rpcblock.Latest, g.contract.Call(methodRootClaim), g.contract.Call(methodL2SequenceNumber)) + if err != nil { + return common.Hash{}, 0, fmt.Errorf("failed to retrieve proposal: %w", err) + } + if len(results) != 2 { + return common.Hash{}, 0, fmt.Errorf("expected 2 results but got %v", len(results)) + } + return results[0].GetHash(0), results[1].GetBigInt(0).Uint64(), nil +} + func (g *OptimisticZKDisputeGameContractLatest) GetResolvedAt(ctx context.Context, block rpcblock.Block) (time.Time, error) { defer g.metrics.StartContractRequest("GetResolvedAt")() result, err := g.multiCaller.SingleCall(ctx, block, g.contract.Call(methodResolvedAt)) @@ -132,4 +202,21 @@ func (g *OptimisticZKDisputeGameContractLatest) resolveCall() *batching.Contract return g.contract.Call(methodResolve) } +func (g *OptimisticZKDisputeGameContractLatest) decodeClaimData(result *batching.CallResult) claimData { + parentIndex := result.GetUint32(0) + counteredBy := result.GetAddress(1) + prover := result.GetAddress(2) + claim := result.GetHash(3) + status := result.GetUint8(4) + deadline := result.GetUint64(5) + return claimData{ + ParentIndex: parentIndex, + CounteredBy: counteredBy, + Prover: prover, + Claim: claim, + Status: ProposalStatus(status), + Deadline: deadline, + } +} + var _ DisputeGameContract = (*OptimisticZKDisputeGameContractLatest)(nil) diff --git a/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go b/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go index 9c9a393f2fc..3c9a6e9511f 100644 --- a/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go +++ b/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go @@ -20,6 +20,10 @@ const ( versZKLatest = "0.0.0" ) +var ( + zkGameAddr = common.Address{0x45, 0x44, 0x43} +) + var zkVersions = []contractVersion{ { version: versZKLatest, @@ -82,7 +86,7 @@ func TestZKSimpleGetters(t *testing.T) { t.Skip("Skipping for this version") } stubRpc, game := setupZKDisputeGameTest(t, version) - stubRpc.SetResponse(fdgAddr, test.method, rpcblock.Latest, nil, []interface{}{test.result}) + stubRpc.SetResponse(zkGameAddr, test.method, rpcblock.Latest, nil, []interface{}{test.result}) status, err := test.call(game) require.NoError(t, err) expected := test.expected @@ -106,10 +110,10 @@ func TestZKGetMetadata(t *testing.T) { expectedRootClaim := common.Hash{0x01, 0x02} expectedStatus := gameTypes.GameStatusChallengerWon block := rpcblock.ByNumber(889) - stubRpc.SetResponse(fdgAddr, methodL1Head, block, nil, []interface{}{expectedL1Head}) - stubRpc.SetResponse(fdgAddr, methodL2SequenceNumber, block, nil, []interface{}{new(big.Int).SetUint64(expectedL2BlockNumber)}) - stubRpc.SetResponse(fdgAddr, methodRootClaim, block, nil, []interface{}{expectedRootClaim}) - stubRpc.SetResponse(fdgAddr, methodStatus, block, nil, []interface{}{expectedStatus}) + stubRpc.SetResponse(zkGameAddr, methodL1Head, block, nil, []interface{}{expectedL1Head}) + stubRpc.SetResponse(zkGameAddr, methodL2SequenceNumber, block, nil, []interface{}{new(big.Int).SetUint64(expectedL2BlockNumber)}) + stubRpc.SetResponse(zkGameAddr, methodRootClaim, block, nil, []interface{}{expectedRootClaim}) + stubRpc.SetResponse(zkGameAddr, methodStatus, block, nil, []interface{}{expectedStatus}) actual, err := contract.GetMetadata(context.Background(), block) expected := GenericGameMetadata{ L1Head: expectedL1Head, @@ -130,8 +134,8 @@ func TestZKGetGameRange(t *testing.T) { stubRpc, contract := setupZKDisputeGameTest(t, version) expectedStart := uint64(65) expectedEnd := uint64(102) - stubRpc.SetResponse(fdgAddr, methodStartingBlockNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(expectedStart)}) - stubRpc.SetResponse(fdgAddr, methodL2SequenceNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(expectedEnd)}) + stubRpc.SetResponse(zkGameAddr, methodStartingBlockNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(expectedStart)}) + stubRpc.SetResponse(zkGameAddr, methodL2SequenceNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(expectedEnd)}) start, end, err := contract.GetGameRange(context.Background()) require.NoError(t, err) require.Equal(t, expectedStart, start) @@ -145,7 +149,7 @@ func TestZKResolveTx(t *testing.T) { version := version t.Run(version.String(), func(t *testing.T) { stubRpc, game := setupZKDisputeGameTest(t, version) - stubRpc.SetResponse(fdgAddr, methodResolve, rpcblock.Latest, nil, nil) + stubRpc.SetResponse(zkGameAddr, methodResolve, rpcblock.Latest, nil, nil) tx, err := game.ResolveTx() require.NoError(t, err) stubRpc.VerifyTxCandidate(tx) @@ -153,21 +157,117 @@ func TestZKResolveTx(t *testing.T) { } } +func TestZKCanChallenge(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) + }) + } + }) + } +} + +func TestZKChallengeTx(t *testing.T) { + for _, version := range zkVersions { + version := version + t.Run(version.String(), func(t *testing.T) { + bond := big.NewInt(97592472) + + stubRpc, game := setupZKDisputeGameTest(t, version) + stubRpc.SetResponse(zkGameAddr, methodChallengerBond, rpcblock.Latest, nil, []interface{}{bond}) + stubRpc.SetResponse(zkGameAddr, methodChallenge, rpcblock.Latest, nil, nil) + + tx, err := game.ChallengeTx(context.Background()) + require.NoError(t, err) + stubRpc.VerifyTxCandidate(tx) + }) + } +} + +func TestZKGetProposal(t *testing.T) { + for _, version := range zkVersions { + version := version + t.Run(version.String(), func(t *testing.T) { + rootClaim := common.Hash{0xaa} + l2SequenceNumber := big.NewInt(1236) + stubRpc, game := setupZKDisputeGameTest(t, version) + stubRpc.SetResponse(zkGameAddr, methodRootClaim, rpcblock.Latest, nil, []interface{}{rootClaim}) + stubRpc.SetResponse(zkGameAddr, methodL2SequenceNumber, rpcblock.Latest, nil, []interface{}{l2SequenceNumber}) + + actualClaim, actualSeqNum, err := game.GetProposal(context.Background()) + require.NoError(t, err) + require.Equal(t, rootClaim, actualClaim) + require.Equal(t, l2SequenceNumber.Uint64(), actualSeqNum) + }) + } +} + func setupZKDisputeGameTest(t *testing.T, version contractVersion) (*batchingTest.AbiBasedRpc, OptimisticZKDisputeGameContract) { fdgAbi := version.loadAbi() vmAbi := snapshots.LoadMIPSABI() oracleAbi := snapshots.LoadPreimageOracleABI() - stubRpc := batchingTest.NewAbiBasedRpc(t, fdgAddr, fdgAbi) + stubRpc := batchingTest.NewAbiBasedRpc(t, zkGameAddr, fdgAbi) stubRpc.AddContract(vmAddr, vmAbi) stubRpc.AddContract(oracleAddr, oracleAbi) caller := batching.NewMultiCaller(stubRpc, batching.DefaultBatchSize) - stubRpc.SetResponse(fdgAddr, methodGameType, rpcblock.Latest, nil, []interface{}{uint32(version.gameType)}) - stubRpc.SetResponse(fdgAddr, methodVersion, rpcblock.Latest, nil, []interface{}{version.version}) + stubRpc.SetResponse(zkGameAddr, methodGameType, rpcblock.Latest, nil, []interface{}{uint32(version.gameType)}) + stubRpc.SetResponse(zkGameAddr, methodVersion, rpcblock.Latest, nil, []interface{}{version.version}) stubRpc.SetResponse(oracleAddr, methodVersion, rpcblock.Latest, nil, []interface{}{oracleLatest}) - game, err := NewOptimisticZKDisputeGameContract(contractMetrics.NoopContractMetrics, fdgAddr, caller) + game, err := NewOptimisticZKDisputeGameContract(contractMetrics.NoopContractMetrics, zkGameAddr, caller) require.NoError(t, err) return stubRpc, game } diff --git a/op-challenger/game/service.go b/op-challenger/game/service.go index eb7be17c098..4d9fadaeb36 100644 --- a/op-challenger/game/service.go +++ b/op-challenger/game/service.go @@ -10,6 +10,7 @@ import ( challengerClient "github.com/ethereum-optimism/optimism/op-challenger/game/client" "github.com/ethereum-optimism/optimism/op-challenger/game/keccak" "github.com/ethereum-optimism/optimism/op-challenger/game/keccak/fetcher" + "github.com/ethereum-optimism/optimism/op-challenger/game/zk" "github.com/ethereum-optimism/optimism/op-challenger/sender" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" @@ -217,6 +218,10 @@ 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) + if err != nil { + return err + } s.registry = gameTypeRegistry s.oracles = oracles return nil diff --git a/op-challenger/game/types/game_type.go b/op-challenger/game/types/game_type.go index aec84d80aa0..9e9943d0575 100644 --- a/op-challenger/game/types/game_type.go +++ b/op-challenger/game/types/game_type.go @@ -21,7 +21,7 @@ const ( SuperAsteriscKonaGameType GameType = 7 CannonKonaGameType GameType = 8 SuperCannonKonaGameType GameType = 9 - OptimisticZKGameType GameType = 10 // Not (yet) supported by op-challenger + OptimisticZKGameType GameType = 10 FastGameType GameType = 254 AlphabetGameType GameType = 255 KailuaGameType GameType = 1337 // Not supported by op-challenger @@ -42,6 +42,7 @@ var SupportedGameTypes = []GameType{ SuperCannonKonaGameType, SuperPermissionedGameType, SuperAsteriscKonaGameType, + OptimisticZKGameType, } // Set implements the Set method required by the [cli.Generic] interface. diff --git a/op-challenger/game/zk/actor.go b/op-challenger/game/zk/actor.go new file mode 100644 index 00000000000..a7af5d29308 --- /dev/null +++ b/op-challenger/game/zk/actor.go @@ -0,0 +1,109 @@ +package zk + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/ethereum-optimism/optimism/op-challenger/game/generic" + "github.com/ethereum-optimism/optimism/op-service/eth" + "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" +) + +type RootProvider interface { + OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error) + SafeHeadAtL1Block(ctx context.Context, blockNum uint64) (*eth.SafeHeadResponse, error) +} + +type ChallengableContract interface { + CanChallenge(ctx context.Context) (bool, error) + ChallengeTx(ctx context.Context) (txmgr.TxCandidate, error) + GetProposal(ctx context.Context) (common.Hash, uint64, error) +} + +type Actor struct { + logger log.Logger + rootProvider RootProvider + contract ChallengableContract + txSender TxSender + l1Head eth.BlockID +} + +func ActorCreator(rootProvider RootProvider, 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, + }, nil + } +} + +func (a *Actor) Act(ctx context.Context) error { + canChallenge, err := a.contract.CanChallenge(ctx) + if err != nil { + return fmt.Errorf("failed to check if game can be challenged: %w", err) + } + if !canChallenge { + a.logger.Debug("Skipping unchallengeable zk game") + 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) + } + if valid, err := a.isValidProposal(ctx, proposalSeqNum, proposalHash); err != nil { + return fmt.Errorf("failed to check if proposal is valid: %w", err) + } else if valid { + a.logger.Debug("Not challenging valid zk game") + return nil + } + + 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 +} + +func (a *Actor) isValidProposal(ctx context.Context, proposalSeqNum uint64, proposalHash common.Hash) (bool, error) { + canonicalOutput, err := a.rootProvider.OutputAtBlock(ctx, proposalSeqNum) + if err != nil { + var rpcErr rpc.Error + if errors.As(err, &rpcErr) { + if strings.Contains(strings.ToLower(rpcErr.Error()), "not found") { + // There is no valid output at the proposal sequence number (it's in the future) + return false, nil + } + } + return false, fmt.Errorf("failed to get canonical output at block %v: %w", proposalSeqNum, err) + } + if common.Hash(canonicalOutput.OutputRoot) != proposalHash { + // Output root doesn't match so can't be valid + return false, nil + } + // Check that the proposal was safe at the L1 head + safeHead, err := a.rootProvider.SafeHeadAtL1Block(ctx, a.l1Head.Number) + if err != nil { + return false, fmt.Errorf("failed to get safe head at L1 head: %w", err) + } + if safeHead.SafeHead.Number <= proposalSeqNum { + return false, nil + } + return true, nil +} + +func (a *Actor) AdditionalStatus(ctx 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 new file mode 100644 index 00000000000..ad0717bdc8f --- /dev/null +++ b/op-challenger/game/zk/actor_test.go @@ -0,0 +1,177 @@ +package zk + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "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" + "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) +} + +func TestActor_ChallengeCorrectButUnsafeProposal(t *testing.T) { + actor, rootProvider, contract, sender := setupActorTest(t) + contract.challenged = false + contract.proposalHash = rootProvider.root + contract.l2SequenceNumber = rootProvider.rootBlockNum + rootProvider.safeHeadAtL1Block = rootProvider.rootBlockNum - 1 + verifyChallenge(t, actor, contract, sender) +} + +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") +} + +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 setupActorTest(t *testing.T) (*Actor, *stubRootProvider, *stubContract, *stubTxSender) { + logger := testlog.Logger(t, log.LvlInfo) + l1Head := eth.BlockID{ + Hash: common.Hash{0x12}, + Number: 785, + } + rootBlockNum := uint64(28492) + rootProvider := &stubRootProvider{ + root: common.Hash{0x11}, + rootBlockNum: rootBlockNum, + l1HeadBlockNum: l1Head.Number, + safeHeadAtL1Block: rootBlockNum + 108, + } + // Default to a valid proposal + contract := &stubContract{ + proposalHash: rootProvider.root, + l2SequenceNumber: rootProvider.rootBlockNum, + } + txSender := &stubTxSender{} + creator := ActorCreator(rootProvider, 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 +} + +type stubRootProvider struct { + outputErr error + rootBlockNum uint64 + root common.Hash + + l1HeadBlockNum uint64 + safeHeadAtL1Block uint64 +} + +func (s *stubRootProvider) OutputAtBlock(_ context.Context, blockNum uint64) (*eth.OutputResponse, error) { + if s.outputErr != nil { + return nil, s.outputErr + } + if blockNum != s.rootBlockNum { + return nil, errors.New("unexpected output request") + } + return ð.OutputResponse{ + OutputRoot: eth.Bytes32(s.root), + }, nil +} + +func (s *stubRootProvider) SafeHeadAtL1Block(_ context.Context, blockNum uint64) (*eth.SafeHeadResponse, error) { + if blockNum != s.l1HeadBlockNum { + return nil, errors.New("unexpected safe head request") + } + return ð.SafeHeadResponse{ + SafeHead: eth.BlockID{Number: s.safeHeadAtL1Block}, + }, nil +} + +type stubContract struct { + challenged bool + txCreated bool + proposalHash common.Hash + l2SequenceNumber uint64 +} + +func (s *stubContract) CanChallenge(_ context.Context) (bool, error) { + return !s.challenged, nil +} + +func (s *stubContract) ChallengeTx(_ context.Context) (txmgr.TxCandidate, error) { + s.txCreated = true + return txmgr.TxCandidate{ + TxData: challengeData, + }, nil +} + +func (s *stubContract) GetProposal(_ context.Context) (common.Hash, uint64, error) { + return s.proposalHash, s.l2SequenceNumber, nil +} + +type stubTxSender struct { + sent []txmgr.TxCandidate + sendErr error +} + +func (s *stubTxSender) SendAndWaitSimple(_ string, candidates ...txmgr.TxCandidate) error { + s.sent = append(s.sent, candidates...) + if s.sendErr != nil { + return s.sendErr + } + return nil +} + +// mockNotFoundRPCError creates a minimal rpc.Error that reports a "not found" message +// to exercise the JSON-RPC application error path in the enricher. +func mockNotFoundRPCError() rpc.Error { return testRPCError{msg: "not found", code: -32000} } + +type testRPCError struct { + msg string + code int +} + +func (e testRPCError) Error() string { return e.msg } +func (e testRPCError) ErrorCode() int { return e.code } diff --git a/op-challenger/game/zk/register.go b/op-challenger/game/zk/register.go new file mode 100644 index 00000000000..0ea44dcf59a --- /dev/null +++ b/op-challenger/game/zk/register.go @@ -0,0 +1,72 @@ +package zk + +import ( + "context" + "fmt" + "time" + + "github.com/ethereum-optimism/optimism/op-challenger/config" + "github.com/ethereum-optimism/optimism/op-challenger/game/client" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts" + "github.com/ethereum-optimism/optimism/op-challenger/game/generic" + "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/common" + gethTypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" +) + +type ClockReader interface { + Now() time.Time +} + +type Registry interface { + RegisterGameType(gameType gameTypes.GameType, creator scheduler.PlayerCreator) +} + +type TxSender interface { + SendAndWaitSimple(txPurpose string, txs ...txmgr.TxCandidate) error +} + +type L1HeaderSource interface { + HeaderByHash(context.Context, common.Hash) (*gethTypes.Header, error) +} + +func RegisterGameTypes( + ctx context.Context, + systemClock clock.Clock, + l1Clock ClockReader, + logger log.Logger, + m metrics.Metricer, + cfg *config.Config, + registry Registry, + txSender TxSender, + clients *client.Provider, +) error { + if cfg.GameTypeEnabled(gameTypes.OptimisticZKGameType) { + registry.RegisterGameType(gameTypes.OptimisticZKGameType, func(game gameTypes.GameMetadata, dir string) (scheduler.GamePlayer, error) { + rollupClient, syncValidator, err := clients.RollupClients() + if err != nil { + return nil, fmt.Errorf("failed to create rollup clients: %w", err) + } + contract, err := contracts.NewOptimisticZKDisputeGameContract(m, game.Proxy, clients.MultiCaller()) + if err != nil { + return nil, fmt.Errorf("failed to create optimistic zk dispute game bindings: %w", err) + } + return generic.NewGenericGamePlayer( + ctx, + logger, + game.Proxy, + contract, + syncValidator, + nil, + clients.L1Client(), + ActorCreator(rollupClient, contract, txSender), + ) + }) + } + return nil +} From c233ad52785408f7daf40b08b62f9bfe94625357 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Mon, 1 Dec 2025 13:42:35 +1000 Subject: [PATCH 2/4] op-challenger: Remove check that the proposal was safe. --- op-challenger/game/zk/actor.go | 9 --------- op-challenger/game/zk/actor_test.go | 27 ++------------------------- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/op-challenger/game/zk/actor.go b/op-challenger/game/zk/actor.go index a7af5d29308..227ac1b1d2f 100644 --- a/op-challenger/game/zk/actor.go +++ b/op-challenger/game/zk/actor.go @@ -16,7 +16,6 @@ import ( type RootProvider interface { OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error) - SafeHeadAtL1Block(ctx context.Context, blockNum uint64) (*eth.SafeHeadResponse, error) } type ChallengableContract interface { @@ -93,14 +92,6 @@ func (a *Actor) isValidProposal(ctx context.Context, proposalSeqNum uint64, prop // Output root doesn't match so can't be valid return false, nil } - // Check that the proposal was safe at the L1 head - safeHead, err := a.rootProvider.SafeHeadAtL1Block(ctx, a.l1Head.Number) - if err != nil { - return false, fmt.Errorf("failed to get safe head at L1 head: %w", err) - } - if safeHead.SafeHead.Number <= proposalSeqNum { - return false, nil - } return true, nil } diff --git a/op-challenger/game/zk/actor_test.go b/op-challenger/game/zk/actor_test.go index ad0717bdc8f..f047eb22aa9 100644 --- a/op-challenger/game/zk/actor_test.go +++ b/op-challenger/game/zk/actor_test.go @@ -48,15 +48,6 @@ func TestActor_DoNotChallengeCorrectProposal(t *testing.T) { verifyNoChallenge(t, actor, contract, sender) } -func TestActor_ChallengeCorrectButUnsafeProposal(t *testing.T) { - actor, rootProvider, contract, sender := setupActorTest(t) - contract.challenged = false - contract.proposalHash = rootProvider.root - contract.l2SequenceNumber = rootProvider.rootBlockNum - rootProvider.safeHeadAtL1Block = rootProvider.rootBlockNum - 1 - verifyChallenge(t, actor, contract, sender) -} - func verifyNoChallenge(t *testing.T, actor *Actor, contract *stubContract, sender *stubTxSender) { err := actor.Act(context.Background()) require.NoError(t, err) @@ -80,10 +71,8 @@ func setupActorTest(t *testing.T) (*Actor, *stubRootProvider, *stubContract, *st } rootBlockNum := uint64(28492) rootProvider := &stubRootProvider{ - root: common.Hash{0x11}, - rootBlockNum: rootBlockNum, - l1HeadBlockNum: l1Head.Number, - safeHeadAtL1Block: rootBlockNum + 108, + root: common.Hash{0x11}, + rootBlockNum: rootBlockNum, } // Default to a valid proposal contract := &stubContract{ @@ -103,9 +92,6 @@ type stubRootProvider struct { outputErr error rootBlockNum uint64 root common.Hash - - l1HeadBlockNum uint64 - safeHeadAtL1Block uint64 } func (s *stubRootProvider) OutputAtBlock(_ context.Context, blockNum uint64) (*eth.OutputResponse, error) { @@ -120,15 +106,6 @@ func (s *stubRootProvider) OutputAtBlock(_ context.Context, blockNum uint64) (*e }, nil } -func (s *stubRootProvider) SafeHeadAtL1Block(_ context.Context, blockNum uint64) (*eth.SafeHeadResponse, error) { - if blockNum != s.l1HeadBlockNum { - return nil, errors.New("unexpected safe head request") - } - return ð.SafeHeadResponse{ - SafeHead: eth.BlockID{Number: s.safeHeadAtL1Block}, - }, nil -} - type stubContract struct { challenged bool txCreated bool From 0447f09dc20371d844dd1850e071c286b5a9b3fd Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 2 Dec 2025 06:55:08 +1000 Subject: [PATCH 3/4] op-challenger: Add log message before challenging --- op-challenger/game/zk/actor.go | 1 + 1 file changed, 1 insertion(+) diff --git a/op-challenger/game/zk/actor.go b/op-challenger/game/zk/actor.go index 227ac1b1d2f..05c35aa9c01 100644 --- a/op-challenger/game/zk/actor.go +++ b/op-challenger/game/zk/actor.go @@ -66,6 +66,7 @@ func (a *Actor) Act(ctx context.Context) error { return nil } + a.logger.Info("Challenging game") tx, err := a.contract.ChallengeTx(ctx) if err != nil { return fmt.Errorf("failed to create challenge tx: %w", err) From 2fb37d910123a1ebacb55a0c8691631e05c2fc2c Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Wed, 3 Dec 2025 07:04:29 +1000 Subject: [PATCH 4/4] op-dispute-mon: Remove unused interface --- op-challenger/game/zk/register.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/op-challenger/game/zk/register.go b/op-challenger/game/zk/register.go index 0ea44dcf59a..89d93876521 100644 --- a/op-challenger/game/zk/register.go +++ b/op-challenger/game/zk/register.go @@ -14,8 +14,6 @@ import ( "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/common" - gethTypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/log" ) @@ -31,10 +29,6 @@ type TxSender interface { SendAndWaitSimple(txPurpose string, txs ...txmgr.TxCandidate) error } -type L1HeaderSource interface { - HeaderByHash(context.Context, common.Hash) (*gethTypes.Header, error) -} - func RegisterGameTypes( ctx context.Context, systemClock clock.Clock,