diff --git a/op-challenger/game/fault/contracts/optimisticzkdisputegame.go b/op-challenger/game/fault/contracts/optimisticzkdisputegame.go index 4bd942d8345..02ee2f28f04 100644 --- a/op-challenger/game/fault/contracts/optimisticzkdisputegame.go +++ b/op-challenger/game/fault/contracts/optimisticzkdisputegame.go @@ -3,6 +3,7 @@ package contracts import ( "context" "fmt" + "math/big" "time" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts/metrics" @@ -24,6 +25,23 @@ const ( ProposalStatusResolved ) +func (p ProposalStatus) String() string { + switch p { + case ProposalStatusUnchallenged: + return "Unchallenged" + case ProposalStatusChallenged: + return "Challenged" + case ProposalStatusUnchallengedAndValidProofProvided: + return "UnchallengedAndValidProofProvided" + case ProposalStatusChallengedAndValidProofProvided: + return "ChallengedAndValidProofProvided" + case ProposalStatusResolved: + return "Resolved" + default: + return fmt.Sprintf("ProposalStatus(%d)", uint8(p)) + } +} + var ( methodChallenge = "challenge" methodChallengerBond = "challengerBond" @@ -44,6 +62,8 @@ type OptimisticZKDisputeGameContract interface { ChallengeTx(ctx context.Context) (txmgr.TxCandidate, error) GetProposal(ctx context.Context) (common.Hash, uint64, error) GetChallengerMetadata(ctx context.Context, block rpcblock.Block) (ChallengerMetadata, error) + GetCredit(ctx context.Context, recipient common.Address) (*big.Int, gameTypes.GameStatus, error) + ClaimCreditTx(ctx context.Context, recipient common.Address) (txmgr.TxCandidate, error) } type OptimisticZKDisputeGameContractLatest struct { @@ -52,6 +72,37 @@ type OptimisticZKDisputeGameContractLatest struct { contract *batching.BoundContract } +func (g *OptimisticZKDisputeGameContractLatest) GetCredit(ctx context.Context, recipient common.Address) (*big.Int, gameTypes.GameStatus, error) { + defer g.metrics.StartContractRequest("GetCredit")() + results, err := g.multiCaller.Call(ctx, rpcblock.Latest, + g.contract.Call(methodCredit, recipient), + g.contract.Call(methodStatus)) + if err != nil { + return nil, gameTypes.GameStatusInProgress, err + } + if len(results) != 2 { + return nil, gameTypes.GameStatusInProgress, fmt.Errorf("expected 2 results but got %v", len(results)) + } + credit := results[0].GetBigInt(0) + status, err := gameTypes.GameStatusFromUint8(results[1].GetUint8(0)) + if err != nil { + return nil, gameTypes.GameStatusInProgress, fmt.Errorf("invalid game status %v: %w", status, err) + } + return credit, status, nil +} + +func (g *OptimisticZKDisputeGameContractLatest) ClaimCreditTx(ctx context.Context, recipient common.Address) (txmgr.TxCandidate, error) { + defer g.metrics.StartContractRequest("ClaimCredit")() + call := g.contract.Call(methodClaimCredit, recipient) + _, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, call) + if err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("%w: %w", ErrSimulationFailed, err) + } + return call.ToTxCandidate() +} + +var _ OptimisticZKDisputeGameContract = (*OptimisticZKDisputeGameContractLatest)(nil) + func NewOptimisticZKDisputeGameContract( m metrics.ContractMetricer, addr common.Address, diff --git a/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go b/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go index 85ae1232ad0..0e994941cc8 100644 --- a/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go +++ b/op-challenger/game/fault/contracts/optimisticzkdisputegame_test.go @@ -2,6 +2,7 @@ package contracts import ( "context" + "errors" "math/big" "testing" "time" @@ -11,6 +12,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/sources/batching" "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" batchingTest "github.com/ethereum-optimism/optimism/op-service/sources/batching/test" + "github.com/ethereum-optimism/optimism/op-service/txmgr" "github.com/ethereum-optimism/optimism/packages/contracts-bedrock/snapshots" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" @@ -223,6 +225,52 @@ func TestZKGetProposal(t *testing.T) { } } +func TestZKGame_GetCredit(t *testing.T) { + for _, version := range zkVersions { + version := version + t.Run(version.String(), func(t *testing.T) { + stubRpc, game := setupZKDisputeGameTest(t, version) + addr := common.Address{0x01} + expectedCredit := big.NewInt(4284) + expectedStatus := gameTypes.GameStatusChallengerWon + stubRpc.SetResponse(zkGameAddr, methodCredit, rpcblock.Latest, []interface{}{addr}, []interface{}{expectedCredit}) + stubRpc.SetResponse(zkGameAddr, methodStatus, rpcblock.Latest, nil, []interface{}{expectedStatus}) + + actualCredit, actualStatus, err := game.GetCredit(context.Background(), addr) + require.NoError(t, err) + require.Equal(t, expectedCredit, actualCredit) + require.Equal(t, expectedStatus, actualStatus) + }) + } +} + +func TestZKGame_ClaimCreditTx(t *testing.T) { + for _, version := range zkVersions { + version := version + t.Run(version.String(), func(t *testing.T) { + t.Run("Success", func(t *testing.T) { + stubRpc, game := setupZKDisputeGameTest(t, version) + addr := common.Address{0xaa} + + stubRpc.SetResponse(zkGameAddr, methodClaimCredit, rpcblock.Latest, []interface{}{addr}, nil) + tx, err := game.ClaimCreditTx(context.Background(), addr) + require.NoError(t, err) + stubRpc.VerifyTxCandidate(tx) + }) + + t.Run("SimulationFails", func(t *testing.T) { + stubRpc, game := setupZKDisputeGameTest(t, version) + addr := common.Address{0xaa} + + stubRpc.SetError(zkGameAddr, methodClaimCredit, rpcblock.Latest, []interface{}{addr}, errors.New("still locked")) + tx, err := game.ClaimCreditTx(context.Background(), addr) + require.ErrorIs(t, err, ErrSimulationFailed) + require.Equal(t, txmgr.TxCandidate{}, tx) + }) + }) + } +} + func setupZKDisputeGameTest(t *testing.T, version contractVersion) (*batchingTest.AbiBasedRpc, OptimisticZKDisputeGameContract) { fdgAbi := version.loadAbi() diff --git a/op-challenger/game/zk/actor.go b/op-challenger/game/zk/actor.go index 2a8ec7a9f63..84ce2e25401 100644 --- a/op-challenger/game/zk/actor.go +++ b/op-challenger/game/zk/actor.go @@ -130,6 +130,14 @@ func (a *Actor) isValidProposal(ctx context.Context) (bool, error) { // Output root doesn't match so can't be valid return false, nil } + if canonicalOutput.Status.SafeL2.Number < proposalSeqNum { + // Note this deliberately uses the simpler check of if the proposed block is currently unsafe + // The proposal is not necessarily supported by data on L1 up to the game's L1 head + // but we don't need to challenge it as long as supporting data has since become available + // and the output matches the canonical chain. + a.logger.Debug("Proposed block is not yet safe, treating as invalid", "safe", canonicalOutput.Status.SafeL2.Number, "proposed", proposalSeqNum) + return false, nil + } return true, nil } @@ -167,6 +175,10 @@ func (a *Actor) createResolveTx(ctx context.Context, gameState contracts.Challen return txmgr.TxCandidate{}, errNoResolutionRequired } -func (a *Actor) AdditionalStatus(_ context.Context) ([]any, error) { - return nil, nil +func (a *Actor) AdditionalStatus(ctx context.Context) ([]any, error) { + metadata, err := a.contract.GetChallengerMetadata(ctx, rpcblock.Latest) + if err != nil { + return nil, fmt.Errorf("failed to get challenger metadata: %w", err) + } + return []any{"proposalStatus", metadata.ProposalStatus}, nil } diff --git a/op-challenger/game/zk/actor_test.go b/op-challenger/game/zk/actor_test.go index ac141d4e026..e7823a04a09 100644 --- a/op-challenger/game/zk/actor_test.go +++ b/op-challenger/game/zk/actor_test.go @@ -75,6 +75,15 @@ func TestActor(t *testing.T) { }, challenge: true, }, + { + name: "ChallengeCurrentlyUnsafeProposal", + setup: func(t *testing.T, stubs *zkTestStubs) { + stubs.contract.proposalHash = stubs.rootProvider.root + stubs.contract.l2SequenceNumber = stubs.rootProvider.rootBlockNum + stubs.rootProvider.safeBlockNum = stubs.rootProvider.rootBlockNum - 1 + }, + challenge: true, + }, { name: "ChallengeUnresolvableGameWithNoParent", setup: func(t *testing.T, stubs *zkTestStubs) { @@ -203,6 +212,7 @@ func setupActorTest(t *testing.T) (*Actor, *zkTestStubs) { rootProvider := &stubRootProvider{ root: common.Hash{0x11}, rootBlockNum: rootBlockNum, + safeBlockNum: rootBlockNum + 10, } // Default to a valid proposal contract := &stubContract{ @@ -231,6 +241,7 @@ type stubRootProvider struct { outputErr error rootBlockNum uint64 root common.Hash + safeBlockNum uint64 } func (s *stubRootProvider) OutputAtBlock(_ context.Context, blockNum uint64) (*eth.OutputResponse, error) { @@ -242,6 +253,11 @@ func (s *stubRootProvider) OutputAtBlock(_ context.Context, blockNum uint64) (*e } return ð.OutputResponse{ OutputRoot: eth.Bytes32(s.root), + Status: ð.SyncStatus{ + SafeL2: eth.L2BlockRef{ + Number: s.safeBlockNum, + }, + }, }, nil } diff --git a/op-challenger/game/zk/register.go b/op-challenger/game/zk/register.go index e063671b3e5..0676472e779 100644 --- a/op-challenger/game/zk/register.go +++ b/op-challenger/game/zk/register.go @@ -7,6 +7,7 @@ import ( "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/claims" "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" @@ -22,6 +23,7 @@ type ClockReader interface { type Registry interface { RegisterGameType(gameType gameTypes.GameType, creator scheduler.PlayerCreator) + RegisterBondContract(gameType gameTypes.GameType, creator claims.BondContractCreator) } type TxSender interface { @@ -60,6 +62,9 @@ func RegisterGameTypes( ActorCreator(l1Clock, rollupClient, gameStatusProvider, contract, txSender), ) }) + registry.RegisterBondContract(gameTypes.OptimisticZKGameType, func(game gameTypes.GameMetadata) (claims.BondContract, error) { + return contracts.NewOptimisticZKDisputeGameContract(m, game.Proxy, clients.MultiCaller()) + }) } return nil }