From a5287b8192f7bce752dc9136f471c0bff7f77938 Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Wed, 18 Mar 2026 14:39:38 +0800 Subject: [PATCH 01/25] feat(op-challenger): add TEE Dispute Game support for XLayer Implement TEE Actor that defends proposals by submitting TEE proofs when games are challenged. Uses background goroutine + channel pattern for non-blocking prove with user-configurable poll interval. Co-Authored-By: Claude Sonnet 4.6 --- op-challenger/config/config.go | 11 +- op-challenger/config/config_xlayer.go | 53 ++ op-challenger/flags/flags.go | 6 +- op-challenger/flags/flags_xlayer.go | 35 + .../game/fault/contracts/disputegame.go | 2 + .../game/fault/contracts/teedisputegame.go | 339 +++++++ op-challenger/game/service.go | 6 + op-challenger/game/tee/actor.go | 243 +++++ op-challenger/game/tee/prover_client.go | 185 ++++ op-challenger/game/tee/register.go | 64 ++ op-challenger/game/types/game_type.go | 3 + op-challenger/game/types/game_type_xlayer.go | 5 + .../snapshots/abi/TeeDisputeGame.json | 841 ++++++++++++++++++ .../contracts-bedrock/snapshots/abi_loader.go | 7 + 14 files changed, 1797 insertions(+), 3 deletions(-) create mode 100644 op-challenger/config/config_xlayer.go create mode 100644 op-challenger/flags/flags_xlayer.go create mode 100644 op-challenger/game/fault/contracts/teedisputegame.go create mode 100644 op-challenger/game/tee/actor.go create mode 100644 op-challenger/game/tee/prover_client.go create mode 100644 op-challenger/game/tee/register.go create mode 100644 op-challenger/game/types/game_type_xlayer.go create mode 100644 packages/contracts-bedrock/snapshots/abi/TeeDisputeGame.json diff --git a/op-challenger/config/config.go b/op-challenger/config/config.go index 78c2e0b041bd7..508b443335c48 100644 --- a/op-challenger/config/config.go +++ b/op-challenger/config/config.go @@ -88,6 +88,10 @@ type Config struct { CannonKonaAbsolutePreState string // File to load the absolute pre-state for CannonKona traces from CannonKonaAbsolutePreStateBaseURL *url.URL // Base URL to retrieve absolute pre-states for CannonKona traces from + // For XLayer: TEE Dispute Game config + TeeProverRpc string // TEE Prover HTTP service URL + TeeProvePollInterval time.Duration // Polling interval for TEE Prover task status + MaxPendingTx uint64 // Maximum number of pending transactions (0 == no limit) TxMgrConfig txmgr.CLIConfig @@ -225,10 +229,10 @@ func (c Config) Check() error { if c.L1RPCKind == "" { return ErrMissingL1RPCKind } - if c.L1Beacon == "" { + if c.L1Beacon == "" && !c.onlyTeeGameType() { // For XLayer: TEE game type does not require L1 beacon return ErrMissingL1Beacon } - if len(c.L2Rpcs) == 0 { + if len(c.L2Rpcs) == 0 && !c.onlyTeeGameType() { // For XLayer: TEE game type does not require L2 RPC return ErrMissingL2Rpc } if c.GameFactoryAddress == (common.Address{}) { @@ -293,6 +297,9 @@ func (c Config) Check() error { return ErrMissingRollupRpc } } + if err := c.CheckXLayer(); err != nil { // For XLayer + return err + } if err := c.TxMgrConfig.Check(); err != nil { return err } diff --git a/op-challenger/config/config_xlayer.go b/op-challenger/config/config_xlayer.go new file mode 100644 index 0000000000000..153d58608c389 --- /dev/null +++ b/op-challenger/config/config_xlayer.go @@ -0,0 +1,53 @@ +package config + +import ( + "errors" + "time" + + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" +) + +var ( + ErrMissingTeeProverRpc = errors.New("missing TEE prover rpc url") +) + +const ( + DefaultTeeProvePollInterval = 30 * time.Second +) + +// xlayerConfigCheckers holds additional config validation functions registered by XLayer extensions. +var xlayerConfigCheckers []func(Config) error + +func init() { + xlayerConfigCheckers = append(xlayerConfigCheckers, checkTeeConfig) +} + +func checkTeeConfig(c Config) error { + if c.GameTypeEnabled(gameTypes.TeeGameType) { + if c.TeeProverRpc == "" { + return ErrMissingTeeProverRpc + } + } + return nil +} + +// onlyTeeGameType returns true if all enabled game types are TEE (no L2/beacon needed). +func (c Config) onlyTeeGameType() bool { + for _, t := range c.GameTypes { + if t != gameTypes.TeeGameType { + return false + } + } + return len(c.GameTypes) > 0 +} + +// CheckXLayer runs all XLayer-specific config validations. +// Called from the main Check() method via _xlayer integration. +func (c Config) CheckXLayer() error { + for _, checker := range xlayerConfigCheckers { + if err := checker(c); err != nil { + return err + } + } + return nil +} diff --git a/op-challenger/flags/flags.go b/op-challenger/flags/flags.go index bf758ca9141d1..41937cc0e821b 100644 --- a/op-challenger/flags/flags.go +++ b/op-challenger/flags/flags.go @@ -439,11 +439,13 @@ func CheckRequired(ctx *cli.Context, types []gameTypes.GameType) error { return fmt.Errorf("flag %s is required", f.Names()[0]) } } - if !ctx.IsSet(L2EthRpcFlag.Name) { + if !ctx.IsSet(L2EthRpcFlag.Name) && !onlyTeeGameTypes(types) { // For XLayer: TEE game type does not require L2 RPC return fmt.Errorf("flag %s is required", L2EthRpcFlag.Name) } for _, gameType := range types { switch gameType { + case gameTypes.TeeGameType: // For XLayer: TEE game type has no additional flag requirements + continue case gameTypes.CannonGameType, gameTypes.PermissionedGameType: if err := CheckCannonFlags(ctx); err != nil { return err @@ -665,5 +667,7 @@ func NewConfigFromCLI(ctx *cli.Context, logger log.Logger) (*config.Config, erro AllowInvalidPrestate: ctx.Bool(UnsafeAllowInvalidPrestate.Name), ResponseDelay: ctx.Duration(ResponseDelayFlag.Name), ResponseDelayAfter: ctx.Uint64(ResponseDelayAfterFlag.Name), + TeeProverRpc: ctx.String(TeeProverRpcFlag.Name), // For XLayer + TeeProvePollInterval: ctx.Duration(TeeProvePollIntervalFlag.Name), // For XLayer }, nil } diff --git a/op-challenger/flags/flags_xlayer.go b/op-challenger/flags/flags_xlayer.go new file mode 100644 index 0000000000000..da872c3f53f27 --- /dev/null +++ b/op-challenger/flags/flags_xlayer.go @@ -0,0 +1,35 @@ +package flags + +import ( + "github.com/ethereum-optimism/optimism/op-challenger/config" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/urfave/cli/v2" +) + +var ( + TeeProverRpcFlag = &cli.StringFlag{ + Name: "tee-prover-rpc", + Usage: "HTTP provider URL for the TEE Prover service (tee game type only)", + EnvVars: prefixEnvVars("TEE_PROVER_RPC"), + } + TeeProvePollIntervalFlag = &cli.DurationFlag{ + Name: "tee-prove-poll-interval", + Usage: "Polling interval for TEE Prover task status (tee game type only)", + EnvVars: prefixEnvVars("TEE_PROVE_POLL_INTERVAL"), + Value: config.DefaultTeeProvePollInterval, + } +) + +func init() { + optionalFlags = append(optionalFlags, TeeProverRpcFlag, TeeProvePollIntervalFlag) +} + +// onlyTeeGameTypes returns true if all enabled game types are TEE (which doesn't require L2 RPC). +func onlyTeeGameTypes(types []gameTypes.GameType) bool { + for _, t := range types { + if t != gameTypes.TeeGameType { + return false + } + } + return len(types) > 0 +} diff --git a/op-challenger/game/fault/contracts/disputegame.go b/op-challenger/game/fault/contracts/disputegame.go index 0a2f688d9723a..4da6902d5d08d 100644 --- a/op-challenger/game/fault/contracts/disputegame.go +++ b/op-challenger/game/fault/contracts/disputegame.go @@ -51,6 +51,8 @@ func NewDisputeGameContract(ctx context.Context, metrics metrics.ContractMetrice return NewPreInteropFaultDisputeGameContract(ctx, metrics, addr, caller) case gameTypes.OptimisticZKGameType: return NewOptimisticZKDisputeGameContract(metrics, addr, caller) + case gameTypes.TeeGameType: // For XLayer + return NewTeeDisputeGameContract(metrics, addr, caller) default: return nil, ErrUnsupportedGameType } diff --git a/op-challenger/game/fault/contracts/teedisputegame.go b/op-challenger/game/fault/contracts/teedisputegame.go new file mode 100644 index 0000000000000..ea1f9619acae9 --- /dev/null +++ b/op-challenger/game/fault/contracts/teedisputegame.go @@ -0,0 +1,339 @@ +package contracts + +import ( + "context" + "fmt" + "math" + "math/big" + "time" + + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts/metrics" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum-optimism/optimism/op-service/txmgr" + "github.com/ethereum-optimism/optimism/packages/contracts-bedrock/snapshots" + "github.com/ethereum/go-ethereum/common" +) + +var ( + methodProve = "prove" + methodProposer = "proposer" + methodBlockHash = "blockHash" + methodStateHash = "stateHash" +) + +// TeeProveParams contains the parameters needed to request a TEE proof. +type TeeProveParams struct { + StartBlockHash common.Hash + StartStateHash common.Hash + EndBlockHash common.Hash + EndStateHash common.Hash + StartBlockNum uint64 + EndBlockNum uint64 +} + +// TeeDisputeGameContract defines the interface for interacting with TeeDisputeGame. +type TeeDisputeGameContract interface { + DisputeGameContract + + GetChallengerMetadata(ctx context.Context, block rpcblock.Block) (ChallengerMetadata, error) + GetProveParams(ctx context.Context, factory *DisputeGameFactoryContract) (TeeProveParams, error) + ProveTx(ctx context.Context, proofBytes []byte) (txmgr.TxCandidate, error) + GetProposer(ctx context.Context) (common.Address, error) + + // Bond-related (BondContract interface) + GetCredit(ctx context.Context, recipient common.Address) (*big.Int, gameTypes.GameStatus, error) + ClaimCreditTx(ctx context.Context, recipient common.Address) (txmgr.TxCandidate, error) + GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error) + CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error) +} + +// TeeDisputeGameContractLatest implements TeeDisputeGameContract. +type TeeDisputeGameContractLatest struct { + metrics metrics.ContractMetricer + multiCaller *batching.MultiCaller + contract *batching.BoundContract +} + +var _ TeeDisputeGameContract = (*TeeDisputeGameContractLatest)(nil) +var _ DisputeGameContract = (*TeeDisputeGameContractLatest)(nil) + +func NewTeeDisputeGameContract( + m metrics.ContractMetricer, + addr common.Address, + caller *batching.MultiCaller, +) (*TeeDisputeGameContractLatest, error) { + contractAbi := snapshots.LoadTeeDisputeGameABI() + return &TeeDisputeGameContractLatest{ + metrics: m, + multiCaller: caller, + contract: batching.NewBoundContract(contractAbi, addr), + }, nil +} + +func (g *TeeDisputeGameContractLatest) Addr() common.Address { + return g.contract.Addr() +} + +func (g *TeeDisputeGameContractLatest) GetMetadata(ctx context.Context, block rpcblock.Block) (GenericGameMetadata, error) { + defer g.metrics.StartContractRequest("GetMetadata")() + results, err := g.multiCaller.Call(ctx, block, + g.contract.Call(methodL1Head), + g.contract.Call(methodL2SequenceNumber), + g.contract.Call(methodRootClaim), + g.contract.Call(methodStatus), + ) + if err != nil { + return GenericGameMetadata{}, fmt.Errorf("failed to retrieve game metadata: %w", err) + } + if len(results) != 4 { + return GenericGameMetadata{}, fmt.Errorf("expected 4 results but got %v", len(results)) + } + l1Head := results[0].GetHash(0) + l2SequenceNumber := getBlockNumber(results[1], 0) + rootClaim := results[2].GetHash(0) + status, err := gameTypes.GameStatusFromUint8(results[3].GetUint8(0)) + if err != nil { + return GenericGameMetadata{}, fmt.Errorf("failed to convert game status: %w", err) + } + return GenericGameMetadata{ + L1Head: l1Head, + L2SequenceNum: l2SequenceNumber, + ProposedRoot: rootClaim, + Status: status, + }, nil +} + +func (g *TeeDisputeGameContractLatest) GetL1Head(ctx context.Context) (common.Hash, error) { + defer g.metrics.StartContractRequest("GetL1Head")() + result, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, g.contract.Call(methodL1Head)) + if err != nil { + return common.Hash{}, fmt.Errorf("failed to fetch L1 head: %w", err) + } + return result.GetHash(0), nil +} + +func (g *TeeDisputeGameContractLatest) GetStatus(ctx context.Context) (gameTypes.GameStatus, error) { + defer g.metrics.StartContractRequest("GetStatus")() + result, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, g.contract.Call(methodStatus)) + if err != nil { + return 0, fmt.Errorf("failed to fetch status: %w", err) + } + return gameTypes.GameStatusFromUint8(result.GetUint8(0)) +} + +func (g *TeeDisputeGameContractLatest) GetGameRange(ctx context.Context) (prestateBlock uint64, poststateBlock uint64, retErr error) { + defer g.metrics.StartContractRequest("GetGameRange")() + results, err := g.multiCaller.Call(ctx, rpcblock.Latest, + g.contract.Call(methodStartingBlockNumber), + g.contract.Call(methodL2SequenceNumber)) + if err != nil { + retErr = fmt.Errorf("failed to retrieve game block range: %w", err) + return + } + if len(results) != 2 { + retErr = fmt.Errorf("expected 2 results but got %v", len(results)) + return + } + prestateBlock = getBlockNumber(results[0], 0) + poststateBlock = getBlockNumber(results[1], 0) + return +} + +func (g *TeeDisputeGameContractLatest) 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)) + if err != nil { + return time.Time{}, fmt.Errorf("failed to retrieve resolution time: %w", err) + } + resolvedAt := time.Unix(int64(result.GetUint64(0)), 0) + return resolvedAt, nil +} + +func (g *TeeDisputeGameContractLatest) CallResolve(ctx context.Context) (gameTypes.GameStatus, error) { + defer g.metrics.StartContractRequest("CallResolve")() + call := g.contract.Call(methodResolve) + result, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, call) + if err != nil { + return gameTypes.GameStatusInProgress, fmt.Errorf("failed to call resolve: %w", err) + } + return gameTypes.GameStatusFromUint8(result.GetUint8(0)) +} + +func (g *TeeDisputeGameContractLatest) ResolveTx() (txmgr.TxCandidate, error) { + call := g.contract.Call(methodResolve) + return call.ToTxCandidate() +} + +// GetChallengerMetadata reads the claimData struct and l2SequenceNumber. +func (g *TeeDisputeGameContractLatest) 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 ChallengerMetadata{}, fmt.Errorf("failed to retrieve challenger metadata: %w", err) + } + if len(results) != 2 { + return ChallengerMetadata{}, fmt.Errorf("expected 2 results but got %v", len(results)) + } + data := g.decodeClaimData(results[0]) + l2SeqNum := getBlockNumber(results[1], 0) + return ChallengerMetadata{ + ParentIndex: data.ParentIndex, + ProposalStatus: data.Status, + ProposedRoot: data.Claim, + L2SequenceNumber: l2SeqNum, + Deadline: time.Unix(int64(data.Deadline), 0), + }, nil +} + +// GetProveParams reads blockHash/stateHash from the current game and the parent game +// to build the parameters needed for a TEE proof request. +func (g *TeeDisputeGameContractLatest) GetProveParams(ctx context.Context, factory *DisputeGameFactoryContract) (TeeProveParams, error) { + defer g.metrics.StartContractRequest("GetProveParams")() + + // Read end-side data from current game + results, err := g.multiCaller.Call(ctx, rpcblock.Latest, + g.contract.Call(methodL2SequenceNumber), + g.contract.Call(methodBlockHash), + g.contract.Call(methodStateHash), + g.contract.Call(methodStartingBlockNumber), + g.contract.Call(methodClaimData), + ) + if err != nil { + return TeeProveParams{}, fmt.Errorf("failed to retrieve prove params: %w", err) + } + if len(results) != 5 { + return TeeProveParams{}, fmt.Errorf("expected 5 results but got %v", len(results)) + } + + endBlockNum := getBlockNumber(results[0], 0) + endBlockHash := results[1].GetHash(0) + endStateHash := results[2].GetHash(0) + startBlockNum := getBlockNumber(results[3], 0) + data := g.decodeClaimData(results[4]) + + params := TeeProveParams{ + EndBlockHash: endBlockHash, + EndStateHash: endStateHash, + StartBlockNum: startBlockNum, + EndBlockNum: endBlockNum, + } + + // Read start-side data from parent game + parentIndex := data.ParentIndex + if parentIndex != math.MaxUint32 { + // Get parent game address from factory + parentGame, err := factory.GetGame(ctx, uint64(parentIndex), rpcblock.Latest) + if err != nil { + return TeeProveParams{}, fmt.Errorf("failed to get parent game at index %d: %w", parentIndex, err) + } + + // Read parent game's blockHash() and stateHash() CWIA getters + parentContract := batching.NewBoundContract(snapshots.LoadTeeDisputeGameABI(), parentGame.Proxy) + parentResults, err := g.multiCaller.Call(ctx, rpcblock.Latest, + parentContract.Call(methodBlockHash), + parentContract.Call(methodStateHash), + ) + if err != nil { + return TeeProveParams{}, fmt.Errorf("failed to read parent game hashes: %w", err) + } + if len(parentResults) != 2 { + return TeeProveParams{}, fmt.Errorf("expected 2 parent results but got %v", len(parentResults)) + } + params.StartBlockHash = parentResults[0].GetHash(0) + params.StartStateHash = parentResults[1].GetHash(0) + } + // For anchor game (parentIndex == MaxUint32), start hashes remain zero. + // This is an edge case that needs contract-side optimization. + + return params, nil +} + +// ProveTx constructs the prove(bytes) transaction. +func (g *TeeDisputeGameContractLatest) ProveTx(ctx context.Context, proofBytes []byte) (txmgr.TxCandidate, error) { + defer g.metrics.StartContractRequest("ProveTx")() + call := g.contract.Call(methodProve, proofBytes) + _, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, call) + if err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("%w: %w", ErrSimulationFailed, err) + } + return call.ToTxCandidate() +} + +// GetProposer reads the proposer storage variable. +func (g *TeeDisputeGameContractLatest) GetProposer(ctx context.Context) (common.Address, error) { + defer g.metrics.StartContractRequest("GetProposer")() + result, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, g.contract.Call(methodProposer)) + if err != nil { + return common.Address{}, fmt.Errorf("failed to fetch proposer: %w", err) + } + return result.GetAddress(0), nil +} + +func (g *TeeDisputeGameContractLatest) 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 *TeeDisputeGameContractLatest) 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() +} + +func (g *TeeDisputeGameContractLatest) GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error) { + result, err := g.multiCaller.SingleCall(ctx, block, g.contract.Call(methodBondDistributionMode)) + if err != nil { + return 0, fmt.Errorf("failed to fetch bond mode: %w", err) + } + return types.BondDistributionMode(result.GetUint8(0)), nil +} + +func (g *TeeDisputeGameContractLatest) CloseGameTx(ctx context.Context) (txmgr.TxCandidate, error) { + defer g.metrics.StartContractRequest("CloseGame")() + call := g.contract.Call(methodCloseGame) + _, err := g.multiCaller.SingleCall(ctx, rpcblock.Latest, call) + if err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("%w: %w", ErrSimulationFailed, err) + } + return call.ToTxCandidate() +} + +func (g *TeeDisputeGameContractLatest) 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, + } +} diff --git a/op-challenger/game/service.go b/op-challenger/game/service.go index 0fb36afb112a8..46c6ba1b407a2 100644 --- a/op-challenger/game/service.go +++ b/op-challenger/game/service.go @@ -11,6 +11,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/tee" // For XLayer "github.com/ethereum-optimism/optimism/op-challenger/game/zk" "github.com/ethereum-optimism/optimism/op-challenger/sender" "github.com/ethereum-optimism/optimism/op-service/sources" @@ -229,6 +230,11 @@ func (s *Service) registerGameTypes(ctx context.Context, cfg *config.Config) err if err != nil { return err } + // For XLayer + err = tee.RegisterGameTypes(ctx, s.l1Clock, s.logger, s.metrics, cfg, gameTypeRegistry, s.txSender, s.clientProvider, s.factoryContract) + if err != nil { + return err + } s.registry = gameTypeRegistry s.oracles = oracles return nil diff --git a/op-challenger/game/tee/actor.go b/op-challenger/game/tee/actor.go new file mode 100644 index 0000000000000..762c5ed70018a --- /dev/null +++ b/op-challenger/game/tee/actor.go @@ -0,0 +1,243 @@ +package tee + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + "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" +) + +var ( + errNoProveRequired = errors.New("no prove required") + errNoResolutionRequired = errors.New("no resolution required") +) + +// ClockReader provides the current time. +type ClockReader interface { + Now() time.Time +} + +// TxSender sends transactions. +type TxSender interface { + SendAndWaitSimple(txPurpose string, txs ...txmgr.TxCandidate) error +} + +// GameStatusProvider queries parent game status from the factory. +type GameStatusProvider interface { + GetGameStatus(ctx context.Context, idx uint64) (gameTypes.GameStatus, error) +} + +// ProvableContract defines the contract methods the TEE Actor needs. +type ProvableContract interface { + Addr() common.Address + GetChallengerMetadata(ctx context.Context, block rpcblock.Block) (contracts.ChallengerMetadata, error) + GetProveParams(ctx context.Context, factory *contracts.DisputeGameFactoryContract) (contracts.TeeProveParams, error) + ProveTx(ctx context.Context, proofBytes []byte) (txmgr.TxCandidate, error) + ResolveTx() (txmgr.TxCandidate, error) +} + +// proveResult holds the result from a background ProveAndWait goroutine. +type proveResult struct { + proofBytes []byte + err error +} + +// Actor is a TEE dispute game actor that defends proposals by submitting TEE proofs. +// It uses a background goroutine pattern: when a prove is needed, a goroutine is started +// that calls ProveAndWait (which polls at the user-configured interval). Act() checks +// for results via a non-blocking channel read. +type Actor struct { + logger log.Logger + l1Clock ClockReader + contract ProvableContract + proverClient *ProverClient + txSender TxSender + gameStatusProvider GameStatusProvider + factory *contracts.DisputeGameFactoryContract + serviceCtx context.Context // service-level ctx, outlives individual Act() calls + proveResultCh chan proveResult // buffered(1), receives result from background goroutine + proveInFlight bool // whether a background prove goroutine is running +} + +// ActorCreator returns a generic.ActorCreator that creates TEE Actors. +func ActorCreator( + serviceCtx context.Context, + l1Clock ClockReader, + proverClient *ProverClient, + gameStatusProvider GameStatusProvider, + contract ProvableContract, + txSender TxSender, + factory *contracts.DisputeGameFactoryContract, +) generic.ActorCreator { + return func(ctx context.Context, logger log.Logger, l1Head eth.BlockID) (generic.Actor, error) { + return &Actor{ + logger: logger, + l1Clock: l1Clock, + contract: contract, + proverClient: proverClient, + txSender: txSender, + gameStatusProvider: gameStatusProvider, + factory: factory, + serviceCtx: serviceCtx, + proveResultCh: make(chan proveResult, 1), + }, nil + } +} + +func (a *Actor) Act(ctx context.Context) error { + metadata, err := a.contract.GetChallengerMetadata(ctx, rpcblock.Latest) + if err != nil { + return fmt.Errorf("failed to get tee game state: %w", err) + } + + var txs []txmgr.TxCandidate + + // 1. Non-blocking check for background prove result + select { + case result := <-a.proveResultCh: + a.proveInFlight = false + if result.err != nil { + a.logger.Error("Background TEE prove failed", "err", result.err, "game", a.contract.Addr()) + // Don't return error — allow resolve logic to proceed, prove can retry next Act cycle + } else { + a.logger.Info("Background TEE prove finished, submitting proof", "game", a.contract.Addr()) + tx, err := a.contract.ProveTx(ctx, result.proofBytes) + if err != nil { + return fmt.Errorf("failed to create prove tx: %w", err) + } + txs = append(txs, tx) + } + default: + // No result yet + } + + // 2. Start background prove if needed and not already in flight + if len(txs) == 0 && !a.proveInFlight { + if err := a.tryStartProve(ctx, metadata); errors.Is(err, errNoProveRequired) { + a.logger.Debug("No prove required") + } else if err != nil { + return err + } + } + + // 3. Try resolve + if tx, err := a.createResolveTx(ctx, metadata); 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 tee game %v", a.contract.Addr()), txs...); err != nil { + return fmt.Errorf("failed to send transactions for tee game %v: %w", a.contract.Addr(), err) + } + return nil +} + +// tryStartProve checks if a TEE proof needs to be submitted and starts a background goroutine. +func (a *Actor) tryStartProve(ctx context.Context, metadata contracts.ChallengerMetadata) error { + if metadata.ProposalStatus != contracts.ProposalStatusChallenged { + return errNoProveRequired + } + if metadata.Deadline.Before(a.l1Clock.Now()) { + return errNoProveRequired + } + + params, err := a.contract.GetProveParams(ctx, a.factory) + if err != nil { + return fmt.Errorf("failed to get prove params: %w", err) + } + + req := ProveRequest{ + PreAppHash: params.StartStateHash, + PostAppHash: params.EndStateHash, + StartBlockHeight: params.StartBlockNum, + EndBlockHeight: params.EndBlockNum, + StartBlockHash: params.StartBlockHash, + EndBlockHash: params.EndBlockHash, + } + + a.logger.Info("Starting background TEE prove", + "startBlock", params.StartBlockNum, + "endBlock", params.EndBlockNum, + "game", a.contract.Addr()) + + a.proveInFlight = true + go func() { + // Use serviceCtx so the goroutine survives individual Act() calls, + // but is cancelled when the service shuts down. + proofBytes, err := a.proverClient.ProveAndWait(a.serviceCtx, req) + a.proveResultCh <- proveResult{proofBytes: proofBytes, err: err} + }() + + return nil +} + +// createResolveTx determines if the game should be resolved and constructs the transaction. +func (a *Actor) createResolveTx(ctx context.Context, metadata contracts.ChallengerMetadata) (txmgr.TxCandidate, error) { + if metadata.ProposalStatus == contracts.ProposalStatusResolved { + return txmgr.TxCandidate{}, errNoResolutionRequired + } + + deadlineExpired := metadata.Deadline.Before(a.l1Clock.Now()) + + // Check parent game status + if metadata.ParentIndex != math.MaxUint32 { + parentStatus, err := a.gameStatusProvider.GetGameStatus(ctx, uint64(metadata.ParentIndex)) + if err != nil { + return txmgr.TxCandidate{}, fmt.Errorf("failed to get parent game status: %w", err) + } + if parentStatus == gameTypes.GameStatusInProgress { + return txmgr.TxCandidate{}, errNoResolutionRequired + } + if parentStatus == gameTypes.GameStatusChallengerWon { + return a.contract.ResolveTx() + } + } + + // Resolve if a valid proof has been provided + if metadata.ProposalStatus == contracts.ProposalStatusChallengedAndValidProofProvided || + metadata.ProposalStatus == contracts.ProposalStatusUnchallengedAndValidProofProvided { + return a.contract.ResolveTx() + } + + // Resolve if deadline expired + if deadlineExpired { + return a.contract.ResolveTx() + } + + return txmgr.TxCandidate{}, errNoResolutionRequired +} + +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) + } + status := []any{"proposalStatus", metadata.ProposalStatus} + if a.proveInFlight { + status = append(status, "proveInFlight", true) + } + return status, nil +} + +func decodeProofBytes(hexStr string) ([]byte, error) { + if len(hexStr) >= 2 && hexStr[:2] == "0x" { + hexStr = hexStr[2:] + } + return common.Hex2Bytes(hexStr), nil +} diff --git a/op-challenger/game/tee/prover_client.go b/op-challenger/game/tee/prover_client.go new file mode 100644 index 0000000000000..3866370236668 --- /dev/null +++ b/op-challenger/game/tee/prover_client.go @@ -0,0 +1,185 @@ +package tee + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" +) + +const ( + defaultProvePath = "/prove" + defaultTaskPath = "/task/%s" +) + +// Task statuses returned by the TEE Prover. +const ( + TaskStatusFinished = "FINISHED" + TaskStatusPending = "PENDING" + TaskStatusNotFound = "NOT_FOUND" +) + +// ProveRequest is sent to the TEE Prover to initiate a proof task. +type ProveRequest struct { + PreAppHash common.Hash `json:"preAppHash"` + PostAppHash common.Hash `json:"postAppHash"` + StartBlockHeight uint64 `json:"startBlockHeight"` + EndBlockHeight uint64 `json:"endBlockHeight"` + StartBlockHash common.Hash `json:"startBlockHash"` + EndBlockHash common.Hash `json:"endBlockHash"` +} + +// ProveResponse is the response from the TEE Prover after submitting a prove task. +type ProveResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"taskId"` + } `json:"data"` +} + +// TaskResult is the response from polling a prove task's status. +type TaskResult struct { + Code string `json:"code"` + Message string `json:"message"` + Data struct { + Status string `json:"status"` + ProofBytes string `json:"proofBytes"` + } `json:"data"` +} + +// ProverClient communicates with the TEE Prover HTTP service. +type ProverClient struct { + httpClient *http.Client + baseURL string + pollInterval time.Duration + logger log.Logger +} + +// NewProverClient creates a new TEE Prover HTTP client. +func NewProverClient(baseURL string, pollInterval time.Duration, logger log.Logger) *ProverClient { + return &ProverClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(baseURL, "/"), + pollInterval: pollInterval, + logger: logger, + } +} + +// Prove submits a proof request and returns the task ID. +func (c *ProverClient) Prove(ctx context.Context, req ProveRequest) (string, error) { + body, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("failed to marshal prove request: %w", err) + } + + url := c.baseURL + defaultProvePath + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to create prove request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("failed to send prove request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read prove response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("prove request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var proveResp ProveResponse + if err := json.Unmarshal(respBody, &proveResp); err != nil { + return "", fmt.Errorf("failed to unmarshal prove response: %w", err) + } + + return proveResp.Data.TaskID, nil +} + +// GetTaskResult polls the status of a prove task. +func (c *ProverClient) GetTaskResult(ctx context.Context, taskID string) (*TaskResult, error) { + url := c.baseURL + fmt.Sprintf(defaultTaskPath, taskID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create task request: %w", err) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send task request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read task response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("task request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result TaskResult + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal task response: %w", err) + } + + return &result, nil +} + +// ProveAndWait submits a proof request and polls until it is finished or the context is cancelled. +func (c *ProverClient) ProveAndWait(ctx context.Context, req ProveRequest) ([]byte, error) { + taskID, err := c.Prove(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to submit prove task: %w", err) + } + + c.logger.Info("TEE prove task submitted", "taskID", taskID) + + ticker := time.NewTicker(c.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + result, err := c.GetTaskResult(ctx, taskID) + if err != nil { + c.logger.Warn("Failed to poll TEE prove task", "taskID", taskID, "err", err) + continue + } + + switch result.Data.Status { + case TaskStatusFinished: + c.logger.Info("TEE prove task finished", "taskID", taskID) + proofBytes, err := hex.DecodeString(strings.TrimPrefix(result.Data.ProofBytes, "0x")) + if err != nil { + return nil, fmt.Errorf("failed to decode proof bytes: %w", err) + } + return proofBytes, nil + case TaskStatusPending: + c.logger.Debug("TEE prove task still pending", "taskID", taskID) + case TaskStatusNotFound: + return nil, fmt.Errorf("TEE prove task not found: %s", taskID) + default: + c.logger.Warn("Unknown TEE prove task status", "taskID", taskID, "status", result.Data.Status) + } + } + } +} diff --git a/op-challenger/game/tee/register.go b/op-challenger/game/tee/register.go new file mode 100644 index 0000000000000..c6ec65f589c1d --- /dev/null +++ b/op-challenger/game/tee/register.go @@ -0,0 +1,64 @@ +package tee + +import ( + "context" + "fmt" + + "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" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-challenger/metrics" + "github.com/ethereum/go-ethereum/log" +) + +// RegisterGameTypes registers the TEE game type with the game type registry. +func RegisterGameTypes( + ctx context.Context, + l1Clock ClockReader, + logger log.Logger, + m metrics.Metricer, + cfg *config.Config, + registry Registry, + txSender TxSender, + clients *client.Provider, + factoryContract *contracts.DisputeGameFactoryContract, +) error { + if !cfg.GameTypeEnabled(gameTypes.TeeGameType) { + return nil + } + + proverClient := NewProverClient(cfg.TeeProverRpc, cfg.TeeProvePollInterval, logger) + + registry.RegisterGameType(gameTypes.TeeGameType, func(game gameTypes.GameMetadata, dir string) (scheduler.GamePlayer, error) { + contract, err := contracts.NewTeeDisputeGameContract(m, game.Proxy, clients.MultiCaller()) + if err != nil { + return nil, fmt.Errorf("failed to create tee dispute game bindings: %w", err) + } + return generic.NewGenericGamePlayer( + ctx, + logger, + game.Proxy, + contract, + &client.NoopSyncStatusValidator{}, + nil, + clients.L1Client(), + ActorCreator(ctx, l1Clock, proverClient, factoryContract, contract, txSender, factoryContract), + ) + }) + + registry.RegisterBondContract(gameTypes.TeeGameType, func(game gameTypes.GameMetadata) (claims.BondContract, error) { + return contracts.NewTeeDisputeGameContract(m, game.Proxy, clients.MultiCaller()) + }) + + return nil +} + +// Registry is the interface for registering game types. +type Registry interface { + RegisterGameType(gameType gameTypes.GameType, creator scheduler.PlayerCreator) + RegisterBondContract(gameType gameTypes.GameType, creator claims.BondContractCreator) +} diff --git a/op-challenger/game/types/game_type.go b/op-challenger/game/types/game_type.go index 55a50df9b2022..3f39ffa570001 100644 --- a/op-challenger/game/types/game_type.go +++ b/op-challenger/game/types/game_type.go @@ -25,6 +25,7 @@ const ( FastGameType GameType = 254 AlphabetGameType GameType = 255 KailuaGameType GameType = 1337 // Not supported by op-challenger + TeeGameType GameType = 1960 // For XLayer UnknownGameType GameType = math.MaxUint32 // Not supported by op-challenger ) @@ -98,6 +99,8 @@ func (g GameType) String() string { return "fast" case AlphabetGameType: return "alphabet" + case TeeGameType: // For XLayer + return "tee" case KailuaGameType: return "kailua" default: diff --git a/op-challenger/game/types/game_type_xlayer.go b/op-challenger/game/types/game_type_xlayer.go new file mode 100644 index 0000000000000..3d988d7a246c8 --- /dev/null +++ b/op-challenger/game/types/game_type_xlayer.go @@ -0,0 +1,5 @@ +package types + +func init() { + SupportedGameTypes = append(SupportedGameTypes, TeeGameType) +} diff --git a/packages/contracts-bedrock/snapshots/abi/TeeDisputeGame.json b/packages/contracts-bedrock/snapshots/abi/TeeDisputeGame.json new file mode 100644 index 0000000000000..8c565cf56eb2f --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/TeeDisputeGame.json @@ -0,0 +1,841 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_maxChallengeDuration", + "type": "uint64", + "internalType": "Duration" + }, + { + "name": "_maxProveDuration", + "type": "uint64", + "internalType": "Duration" + }, + { + "name": "_disputeGameFactory", + "type": "address", + "internalType": "contract IDisputeGameFactory" + }, + { + "name": "_teeProofVerifier", + "type": "address", + "internalType": "contract ITeeProofVerifier" + }, + { + "name": "_challengerBond", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "_anchorStateRegistry", + "type": "address", + "internalType": "contract IAnchorStateRegistry" + }, + { + "name": "_accessManager", + "type": "address", + "internalType": "contract AccessManager" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "accessManager", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract AccessManager" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "anchorStateRegistry", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IAnchorStateRegistry" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "blockHash", + "inputs": [], + "outputs": [ + { + "name": "blockHash_", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "bondDistributionMode", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum BondDistributionMode" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "challenge", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum TeeDisputeGame.ProposalStatus" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "challengerBond", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "claimCredit", + "inputs": [ + { + "name": "_recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "claimData", + "inputs": [], + "outputs": [ + { + "name": "parentIndex", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "counteredBy", + "type": "address", + "internalType": "address" + }, + { + "name": "prover", + "type": "address", + "internalType": "address" + }, + { + "name": "claim", + "type": "bytes32", + "internalType": "Claim" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enum TeeDisputeGame.ProposalStatus" + }, + { + "name": "deadline", + "type": "uint64", + "internalType": "Timestamp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "closeGame", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "createdAt", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "Timestamp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "credit", + "inputs": [ + { + "name": "_recipient", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "credit_", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "disputeGameFactory", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IDisputeGameFactory" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "extraData", + "inputs": [], + "outputs": [ + { + "name": "extraData_", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "gameCreator", + "inputs": [], + "outputs": [ + { + "name": "creator_", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "gameData", + "inputs": [], + "outputs": [ + { + "name": "gameType_", + "type": "uint32", + "internalType": "GameType" + }, + { + "name": "rootClaim_", + "type": "bytes32", + "internalType": "Claim" + }, + { + "name": "extraData_", + "type": "bytes", + "internalType": "bytes" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "gameOver", + "inputs": [], + "outputs": [ + { + "name": "gameOver_", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "gameType", + "inputs": [], + "outputs": [ + { + "name": "gameType_", + "type": "uint32", + "internalType": "GameType" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "initialize", + "inputs": [], + "outputs": [], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "l1Head", + "inputs": [], + "outputs": [ + { + "name": "l1Head_", + "type": "bytes32", + "internalType": "Hash" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "l2BlockNumber", + "inputs": [], + "outputs": [ + { + "name": "l2BlockNumber_", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "l2SequenceNumber", + "inputs": [], + "outputs": [ + { + "name": "l2SequenceNumber_", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "maxChallengeDuration", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "Duration" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "maxProveDuration", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "Duration" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "normalModeCredit", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "parentIndex", + "inputs": [], + "outputs": [ + { + "name": "parentIndex_", + "type": "uint32", + "internalType": "uint32" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "proposer", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "prove", + "inputs": [ + { + "name": "proofBytes", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum TeeDisputeGame.ProposalStatus" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "refundModeCredit", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "resolve", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum GameStatus" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "resolvedAt", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "Timestamp" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "rootClaim", + "inputs": [], + "outputs": [ + { + "name": "rootClaim_", + "type": "bytes32", + "internalType": "Claim" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "startingBlockNumber", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "startingOutputRoot", + "inputs": [], + "outputs": [ + { + "name": "root", + "type": "bytes32", + "internalType": "Hash" + }, + { + "name": "l2SequenceNumber", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "startingRootHash", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "Hash" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "stateHash", + "inputs": [], + "outputs": [ + { + "name": "stateHash_", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "status", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint8", + "internalType": "enum GameStatus" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "teeProofVerifier", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract ITeeProofVerifier" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string", + "internalType": "string" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "wasRespectedGameTypeWhenCreated", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "Challenged", + "inputs": [ + { + "name": "challenger", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "GameClosed", + "inputs": [ + { + "name": "bondDistributionMode", + "type": "uint8", + "indexed": false, + "internalType": "enum BondDistributionMode" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Proved", + "inputs": [ + { + "name": "prover", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Resolved", + "inputs": [ + { + "name": "status", + "type": "uint8", + "indexed": true, + "internalType": "enum GameStatus" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "AlreadyInitialized", + "inputs": [] + }, + { + "type": "error", + "name": "BadAuth", + "inputs": [] + }, + { + "type": "error", + "name": "BatchBlockNotIncreasing", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "prevBlock", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "curBlock", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "BatchChainBreak", + "inputs": [ + { + "name": "index", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "BondTransferFailed", + "inputs": [] + }, + { + "type": "error", + "name": "ClaimAlreadyChallenged", + "inputs": [] + }, + { + "type": "error", + "name": "ClaimAlreadyResolved", + "inputs": [] + }, + { + "type": "error", + "name": "EmptyBatchProofs", + "inputs": [] + }, + { + "type": "error", + "name": "FinalBlockMismatch", + "inputs": [ + { + "name": "expectedBlock", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "actualBlock", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "error", + "name": "FinalHashMismatch", + "inputs": [ + { + "name": "expectedCombined", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actualCombined", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "GameNotFinalized", + "inputs": [] + }, + { + "type": "error", + "name": "GameNotOver", + "inputs": [] + }, + { + "type": "error", + "name": "GameOver", + "inputs": [] + }, + { + "type": "error", + "name": "IncorrectBondAmount", + "inputs": [] + }, + { + "type": "error", + "name": "IncorrectDisputeGameFactory", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidBondDistributionMode", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidParentGame", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidProposalStatus", + "inputs": [] + }, + { + "type": "error", + "name": "NoCreditToClaim", + "inputs": [] + }, + { + "type": "error", + "name": "ParentGameNotResolved", + "inputs": [] + }, + { + "type": "error", + "name": "RootClaimMismatch", + "inputs": [ + { + "name": "expectedRootClaim", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actualRootClaim", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "StartHashMismatch", + "inputs": [ + { + "name": "expectedCombined", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "actualCombined", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "UnexpectedRootClaim", + "inputs": [ + { + "name": "rootClaim", + "type": "bytes32", + "internalType": "Claim" + } + ] + } +] diff --git a/packages/contracts-bedrock/snapshots/abi_loader.go b/packages/contracts-bedrock/snapshots/abi_loader.go index 83119016ae9d6..0c701dd0bd099 100644 --- a/packages/contracts-bedrock/snapshots/abi_loader.go +++ b/packages/contracts-bedrock/snapshots/abi_loader.go @@ -34,6 +34,9 @@ var systemConfig []byte //go:embed abi/CrossL2Inbox.json var crossL2Inbox []byte +//go:embed abi/TeeDisputeGame.json +var teeDisputeGame []byte // For XLayer + func LoadDisputeGameFactoryABI() *abi.ABI { return loadABI(disputeGameFactory) } @@ -69,6 +72,10 @@ func LoadCrossL2InboxABI() *abi.ABI { return loadABI(crossL2Inbox) } +func LoadTeeDisputeGameABI() *abi.ABI { // For XLayer + return loadABI(teeDisputeGame) +} + func loadABI(json []byte) *abi.ABI { if parsed, err := abi.JSON(bytes.NewReader(json)); err != nil { panic(err) From 760882c7aa7f2a8150f871c95f343c317b4de8ae Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Wed, 18 Mar 2026 15:48:24 +0800 Subject: [PATCH 02/25] test(op-challenger): add unit tests for TEE Dispute Game support Cover contract bindings, actor state machine, prover HTTP client, and config validation for the TEE game type. Co-Authored-By: Claude Sonnet 4.6 --- op-challenger/config/config_test.go | 3 + op-challenger/config/config_xlayer_test.go | 59 ++++ .../fault/contracts/teedisputegame_test.go | 240 +++++++++++++ op-challenger/game/tee/actor_test.go | 327 ++++++++++++++++++ op-challenger/game/tee/prover_client_test.go | 194 +++++++++++ 5 files changed, 823 insertions(+) create mode 100644 op-challenger/config/config_xlayer_test.go create mode 100644 op-challenger/game/fault/contracts/teedisputegame_test.go create mode 100644 op-challenger/game/tee/actor_test.go create mode 100644 op-challenger/game/tee/prover_client_test.go diff --git a/op-challenger/config/config_test.go b/op-challenger/config/config_test.go index 70485d1f1265d..e1a610dc451a8 100644 --- a/op-challenger/config/config_test.go +++ b/op-challenger/config/config_test.go @@ -124,6 +124,9 @@ func validConfig(t *testing.T, gameType gameTypes.GameType) Config { if gameType == gameTypes.OptimisticZKGameType { applyValidConfigForOptimisticZK(&cfg) } + if gameType == gameTypes.TeeGameType { // For XLayer + applyValidConfigForTee(&cfg) + } return cfg } diff --git a/op-challenger/config/config_xlayer_test.go b/op-challenger/config/config_xlayer_test.go new file mode 100644 index 0000000000000..b6d53032a6ed1 --- /dev/null +++ b/op-challenger/config/config_xlayer_test.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "testing" + + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/stretchr/testify/require" +) + +var validTeeProverRpc = "http://localhost:8080" + +func applyValidConfigForTee(cfg *Config) { + cfg.TeeProverRpc = validTeeProverRpc +} + +func TestTeeProverRpcRequired(t *testing.T) { + cfg := validConfig(t, gameTypes.TeeGameType) + applyValidConfigForTee(&cfg) + cfg.TeeProverRpc = "" + require.ErrorIs(t, cfg.Check(), ErrMissingTeeProverRpc) +} + +func TestTeeProverRpcNotRequiredForOtherTypes(t *testing.T) { + for _, gameType := range gameTypes.SupportedGameTypes { + if gameType == gameTypes.TeeGameType { + continue + } + gameType := gameType + t.Run(fmt.Sprintf("GameType-%v", gameType), func(t *testing.T) { + cfg := validConfig(t, gameType) + // TeeProverRpc is not set — should still be valid for non-TEE game types + require.NoError(t, cfg.Check()) + }) + } +} + +func TestTeeOnlyModeNoL1BeaconRequired(t *testing.T) { + cfg := validConfig(t, gameTypes.TeeGameType) + applyValidConfigForTee(&cfg) + cfg.L1Beacon = "" + require.NoError(t, cfg.Check()) +} + +func TestTeeOnlyModeNoL2RpcRequired(t *testing.T) { + cfg := validConfig(t, gameTypes.TeeGameType) + applyValidConfigForTee(&cfg) + cfg.L2Rpcs = nil + require.NoError(t, cfg.Check()) +} + +func TestTeeMixedModeStillRequiresL1Beacon(t *testing.T) { + cfg := validConfig(t, gameTypes.CannonGameType) + // Add TEE game type alongside cannon + cfg.GameTypes = append(cfg.GameTypes, gameTypes.TeeGameType) + applyValidConfigForTee(&cfg) + cfg.L1Beacon = "" + require.ErrorIs(t, cfg.Check(), ErrMissingL1Beacon) +} diff --git a/op-challenger/game/fault/contracts/teedisputegame_test.go b/op-challenger/game/fault/contracts/teedisputegame_test.go new file mode 100644 index 0000000000000..e0af44c6d4cea --- /dev/null +++ b/op-challenger/game/fault/contracts/teedisputegame_test.go @@ -0,0 +1,240 @@ +package contracts + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + contractMetrics "github.com/ethereum-optimism/optimism/op-challenger/game/fault/contracts/metrics" + faultTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum-optimism/optimism/op-service/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" +) + +var ( + teeGameAddr = common.Address{0xee, 0xee, 0x01} +) + +func TestTeeSimpleGetters(t *testing.T) { + tests := []struct { + methodAlias string + method string + args []interface{} + result interface{} + expected interface{} + call func(game TeeDisputeGameContract) (any, error) + }{ + { + methodAlias: "status", + method: methodStatus, + result: gameTypes.GameStatusChallengerWon, + call: func(game TeeDisputeGameContract) (any, error) { + return game.GetStatus(context.Background()) + }, + }, + { + methodAlias: "l1Head", + method: methodL1Head, + result: common.Hash{0xdd, 0xbb}, + call: func(game TeeDisputeGameContract) (any, error) { + return game.GetL1Head(context.Background()) + }, + }, + { + methodAlias: "resolve", + method: methodResolve, + result: gameTypes.GameStatusInProgress, + call: func(game TeeDisputeGameContract) (any, error) { + return game.CallResolve(context.Background()) + }, + }, + { + methodAlias: "resolvedAt", + method: methodResolvedAt, + result: uint64(240402), + expected: time.Unix(240402, 0), + call: func(game TeeDisputeGameContract) (any, error) { + return game.GetResolvedAt(context.Background(), rpcblock.Latest) + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.methodAlias, func(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + stubRpc.SetResponse(teeGameAddr, test.method, rpcblock.Latest, nil, []interface{}{test.result}) + status, err := test.call(game) + require.NoError(t, err) + expected := test.expected + if expected == nil { + expected = test.result + } + require.Equal(t, expected, status) + }) + } +} + +func TestTeeGetMetadata(t *testing.T) { + stubRpc, contract := setupTeeDisputeGameTest(t) + expectedL1Head := common.Hash{0x0a, 0x0b} + expectedL2BlockNumber := uint64(123) + expectedRootClaim := common.Hash{0x01, 0x02} + expectedStatus := gameTypes.GameStatusChallengerWon + block := rpcblock.ByNumber(889) + stubRpc.SetResponse(teeGameAddr, methodL1Head, block, nil, []interface{}{expectedL1Head}) + stubRpc.SetResponse(teeGameAddr, methodL2SequenceNumber, block, nil, []interface{}{new(big.Int).SetUint64(expectedL2BlockNumber)}) + stubRpc.SetResponse(teeGameAddr, methodRootClaim, block, nil, []interface{}{expectedRootClaim}) + stubRpc.SetResponse(teeGameAddr, methodStatus, block, nil, []interface{}{expectedStatus}) + actual, err := contract.GetMetadata(context.Background(), block) + expected := GenericGameMetadata{ + L1Head: expectedL1Head, + L2SequenceNum: expectedL2BlockNumber, + ProposedRoot: expectedRootClaim, + Status: expectedStatus, + } + require.NoError(t, err) + require.Equal(t, expected, actual) +} + +func TestTeeGetGameRange(t *testing.T) { + stubRpc, contract := setupTeeDisputeGameTest(t) + expectedStart := uint64(65) + expectedEnd := uint64(102) + stubRpc.SetResponse(teeGameAddr, methodStartingBlockNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(expectedStart)}) + stubRpc.SetResponse(teeGameAddr, 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) + require.Equal(t, expectedEnd, end) +} + +func TestTeeResolveTx(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + stubRpc.SetResponse(teeGameAddr, methodResolve, rpcblock.Latest, nil, nil) + tx, err := game.ResolveTx() + require.NoError(t, err) + stubRpc.VerifyTxCandidate(tx) +} + +func TestTeeGetChallengerMetadata(t *testing.T) { + stubRpc, contract := setupTeeDisputeGameTest(t) + 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(teeGameAddr, methodClaimData, block, nil, []interface{}{ + expectedParentIndex, counteredBy, prover, expectedRootClaim, expectedProposalStatus, uint64(expectedDeadline.Unix()), + }) + stubRpc.SetResponse(teeGameAddr, 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) +} + +func TestTeeProveTx(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + proofBytes := []byte{0xde, 0xad, 0xbe, 0xef} + // prove() returns uint8 ProposalStatus + stubRpc.SetResponse(teeGameAddr, methodProve, rpcblock.Latest, []interface{}{proofBytes}, []interface{}{uint8(ProposalStatusChallengedAndValidProofProvided)}) + tx, err := game.ProveTx(context.Background(), proofBytes) + require.NoError(t, err) + stubRpc.VerifyTxCandidate(tx) +} + +func TestTeeGetProposer(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + expectedProposer := common.Address{0xaa, 0xbb, 0xcc} + stubRpc.SetResponse(teeGameAddr, methodProposer, rpcblock.Latest, nil, []interface{}{expectedProposer}) + actual, err := game.GetProposer(context.Background()) + require.NoError(t, err) + require.Equal(t, expectedProposer, actual) +} + +func TestTeeGetCredit(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + addr := common.Address{0x01} + expectedCredit := big.NewInt(4284) + expectedStatus := gameTypes.GameStatusChallengerWon + stubRpc.SetResponse(teeGameAddr, methodCredit, rpcblock.Latest, []interface{}{addr}, []interface{}{expectedCredit}) + stubRpc.SetResponse(teeGameAddr, 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 TestTeeClaimCreditTx(t *testing.T) { + t.Run("Success", func(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + addr := common.Address{0xaa} + stubRpc.SetResponse(teeGameAddr, 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 := setupTeeDisputeGameTest(t) + addr := common.Address{0xaa} + stubRpc.SetError(teeGameAddr, 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 TestTeeGetBondDistributionMode(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + stubRpc.SetResponse(teeGameAddr, methodBondDistributionMode, rpcblock.Latest, nil, []interface{}{uint8(faultTypes.NormalDistributionMode)}) + + mode, err := game.GetBondDistributionMode(context.Background(), rpcblock.Latest) + require.NoError(t, err) + require.Equal(t, faultTypes.NormalDistributionMode, mode) +} + +func TestTeeCloseGameTx(t *testing.T) { + t.Run("Success", func(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + stubRpc.SetResponse(teeGameAddr, 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 := setupTeeDisputeGameTest(t) + stubRpc.SetError(teeGameAddr, 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) + }) +} + +func setupTeeDisputeGameTest(t *testing.T) (*batchingTest.AbiBasedRpc, TeeDisputeGameContract) { + teeAbi := snapshots.LoadTeeDisputeGameABI() + stubRpc := batchingTest.NewAbiBasedRpc(t, teeGameAddr, teeAbi) + caller := batching.NewMultiCaller(stubRpc, batching.DefaultBatchSize) + game, err := NewTeeDisputeGameContract(contractMetrics.NoopContractMetrics, teeGameAddr, caller) + require.NoError(t, err) + return stubRpc, game +} diff --git a/op-challenger/game/tee/actor_test.go b/op-challenger/game/tee/actor_test.go new file mode 100644 index 0000000000000..41ccd5f6e706d --- /dev/null +++ b/op-challenger/game/tee/actor_test.go @@ -0,0 +1,327 @@ +package tee + +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/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" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +var ( + proveData = "prove" + resolveData = "resolve" + l1Time = time.Unix(9892842, 0) +) + +type teeTestStubs struct { + contract *stubContract + sender *stubTxSender +} + +func TestActor(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, actor *Actor, stubs *teeTestStubs) + prove bool + resolve bool + }{ + { + name: "UnchallengedNotExpired", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + // Default is Unchallenged — no action expected + }, + }, + { + name: "UnchallengedExpired", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineExpired() + }, + resolve: true, + }, + { + name: "ChallengedSubmitProve", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + // Actor should start background prove — no tx sent this cycle + // But since we use a real ProverClient stub, we need to simulate it differently: + // The actor will call tryStartProve which calls proverClient.ProveAndWait in a goroutine. + // Since we don't have a real HTTP server, we bypass by checking proveInFlight. + }, + // No prove or resolve tx expected on this first Act cycle — goroutine starts but hasn't returned + }, + { + name: "ChallengedProveInFlight", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + actor.proveInFlight = true // Simulate already in-flight + }, + // No action — prove already running, no resolve conditions met + }, + { + name: "ChallengedProveResultReady", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + // Pre-load result into channel to simulate background goroutine completion + actor.proveResultCh <- proveResult{proofBytes: []byte{0xde, 0xad}, err: nil} + actor.proveInFlight = true + }, + prove: true, + }, + { + name: "ChallengedProveResultError", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + // Pre-load error result + actor.proveResultCh <- proveResult{err: errors.New("tee prover failed")} + actor.proveInFlight = true + // After error, prove can retry (new goroutine starts), but no tx this cycle + // Note: the second tryStartProve will try to start a goroutine, + // but since the contract GetProveParams will succeed, it will launch one. + // For this test, the key assertion is no prove tx is sent. + }, + }, + { + name: "ChallengedExpiredNoProof", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineExpired() + stubs.contract.challenge(t) + }, + resolve: true, + }, + { + name: "ChallengedAndProven", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + stubs.contract.prove(t) + }, + resolve: true, + }, + { + name: "UnchallengedAndProven", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.prove(t) + }, + resolve: true, + }, + { + name: "AlreadyResolved", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.markResolved() + }, + }, + { + name: "ParentInProgress", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineExpired() + stubs.contract.setParentStatus(types.GameStatusInProgress) + }, + // Cannot resolve because parent is still in progress + }, + { + name: "ParentChallengerWon", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineNotReached() + stubs.contract.setParentStatus(types.GameStatusChallengerWon) + }, + resolve: true, + }, + { + name: "AnchorGameExpired", + setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { + stubs.contract.setDeadlineExpired() + stubs.contract.parentIndex = math.MaxUint32 + }, + resolve: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actor, stubs := setupTeeActorTest(t) + if tt.setup != nil { + tt.setup(t, actor, stubs) + } + err := actor.Act(context.Background()) + require.NoError(t, err) + expectedTxCount := 0 + if tt.prove { + require.Contains(t, stubs.sender.sentData, proveData) + expectedTxCount++ + } + if tt.resolve { + require.Contains(t, stubs.sender.sentData, resolveData) + expectedTxCount++ + } + require.Len(t, stubs.sender.sentData, expectedTxCount) + }) + } +} + +func TestActorProveResultClearsInFlight(t *testing.T) { + actor, stubs := setupTeeActorTest(t) + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + + // Simulate background goroutine completing + actor.proveInFlight = true + actor.proveResultCh <- proveResult{proofBytes: []byte{0xaa}, err: nil} + + err := actor.Act(context.Background()) + require.NoError(t, err) + require.False(t, actor.proveInFlight, "proveInFlight should be cleared after result consumed") +} + +func TestActorProveErrorClearsInFlight(t *testing.T) { + actor, stubs := setupTeeActorTest(t) + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + + actor.proveInFlight = true + actor.proveResultCh <- proveResult{err: errors.New("prover down")} + + err := actor.Act(context.Background()) + require.NoError(t, err) + // After error consumed, proveInFlight is cleared, then tryStartProve + // re-launches a goroutine (Challenged + deadline not expired), so proveInFlight is true again. + require.True(t, actor.proveInFlight, "proveInFlight should be true after retry goroutine started") +} + +func setupTeeActorTest(t *testing.T) (*Actor, *teeTestStubs) { + logger := testlog.Logger(t, log.LvlInfo) + l1Clock := clock.NewDeterministicClock(l1Time) + contract := &stubContract{ + parentIndex: 482, + parentStatus: types.GameStatusDefenderWon, + } + contract.setDeadlineNotReached() + txSender := &stubTxSender{} + + // Provide a dummy ProverClient so goroutines don't panic on nil. + // Tests that check prove results use the proveResultCh channel directly. + dummyProver := NewProverClient("http://127.0.0.1:1", 10*time.Millisecond, logger) + actor := &Actor{ + logger: logger, + l1Clock: l1Clock, + contract: contract, + proverClient: dummyProver, + txSender: txSender, + gameStatusProvider: contract, + factory: nil, + serviceCtx: context.Background(), + proveResultCh: make(chan proveResult, 1), + } + return actor, &teeTestStubs{ + contract: contract, + sender: txSender, + } +} + +// --- Stubs --- + +type stubContract struct { + parentIndex uint32 + parentStatus types.GameStatus + proposalStatus contracts.ProposalStatus + deadline time.Time + proveParams contracts.TeeProveParams +} + +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") + } + return s.parentStatus, nil +} + +func (s *stubContract) GetChallengerMetadata(_ context.Context, _ rpcblock.Block) (contracts.ChallengerMetadata, error) { + return contracts.ChallengerMetadata{ + ParentIndex: s.parentIndex, + ProposalStatus: s.proposalStatus, + Deadline: s.deadline, + }, nil +} + +func (s *stubContract) GetProveParams(_ context.Context, _ *contracts.DisputeGameFactoryContract) (contracts.TeeProveParams, error) { + return s.proveParams, nil +} + +func (s *stubContract) ProveTx(_ context.Context, _ []byte) (txmgr.TxCandidate, error) { + return txmgr.TxCandidate{ + TxData: []byte(proveData), + }, nil +} + +func (s *stubContract) ResolveTx() (txmgr.TxCandidate, error) { + return txmgr.TxCandidate{ + TxData: []byte(resolveData), + }, nil +} + +type stubTxSender struct { + sentData []string + sendErr error +} + +func (s *stubTxSender) SendAndWaitSimple(_ string, candidates ...txmgr.TxCandidate) error { + for _, candidate := range candidates { + s.sentData = append(s.sentData, string(candidate.TxData)) + } + if s.sendErr != nil { + return s.sendErr + } + return nil +} diff --git a/op-challenger/game/tee/prover_client_test.go b/op-challenger/game/tee/prover_client_test.go new file mode 100644 index 0000000000000..c86846d136976 --- /dev/null +++ b/op-challenger/game/tee/prover_client_test.go @@ -0,0 +1,194 @@ +package tee + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/stretchr/testify/require" +) + +func TestProveSuccess(t *testing.T) { + expectedTaskID := "task-abc-123" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/prove", r.URL.Path) + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var req ProveRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + require.Equal(t, common.Hash{0x01}, req.PreAppHash) + require.Equal(t, common.Hash{0x02}, req.PostAppHash) + require.Equal(t, uint64(100), req.StartBlockHeight) + require.Equal(t, uint64(200), req.EndBlockHeight) + require.Equal(t, common.Hash{0x03}, req.StartBlockHash) + require.Equal(t, common.Hash{0x04}, req.EndBlockHash) + + resp := ProveResponse{Code: "0", Message: "ok"} + resp.Data.TaskID = expectedTaskID + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + taskID, err := client.Prove(context.Background(), ProveRequest{ + PreAppHash: common.Hash{0x01}, + PostAppHash: common.Hash{0x02}, + StartBlockHeight: 100, + EndBlockHeight: 200, + StartBlockHash: common.Hash{0x03}, + EndBlockHash: common.Hash{0x04}, + }) + require.NoError(t, err) + require.Equal(t, expectedTaskID, taskID) +} + +func TestProveServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + _, err := client.Prove(context.Background(), ProveRequest{}) + require.Error(t, err) + require.Contains(t, err.Error(), "500") +} + +func TestProveBadResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("not json")) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + _, err := client.Prove(context.Background(), ProveRequest{}) + require.Error(t, err) + require.Contains(t, err.Error(), "unmarshal") +} + +func TestGetTaskFinished(t *testing.T) { + proofHex := "0xdeadbeef" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/task/task-123", r.URL.Path) + resp := TaskResult{Code: "0", Message: "ok"} + resp.Data.Status = TaskStatusFinished + resp.Data.ProofBytes = proofHex + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + result, err := client.GetTaskResult(context.Background(), "task-123") + require.NoError(t, err) + require.Equal(t, TaskStatusFinished, result.Data.Status) + require.Equal(t, proofHex, result.Data.ProofBytes) +} + +func TestGetTaskPending(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := TaskResult{Code: "0", Message: "ok"} + resp.Data.Status = TaskStatusPending + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + result, err := client.GetTaskResult(context.Background(), "task-456") + require.NoError(t, err) + require.Equal(t, TaskStatusPending, result.Data.Status) + require.Empty(t, result.Data.ProofBytes) +} + +func TestGetTaskNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := TaskResult{Code: "0", Message: "ok"} + resp.Data.Status = TaskStatusNotFound + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + result, err := client.GetTaskResult(context.Background(), "task-789") + require.NoError(t, err) + require.Equal(t, TaskStatusNotFound, result.Data.Status) +} + +func TestGetTaskServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte("bad gateway")) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + _, err := client.GetTaskResult(context.Background(), "task-000") + require.Error(t, err) + require.Contains(t, err.Error(), "502") +} + +func TestProveAndWaitSuccess(t *testing.T) { + callCount := 0 + expectedProof := []byte{0xde, 0xad, 0xbe, 0xef} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + resp := ProveResponse{Code: "0", Message: "ok"} + resp.Data.TaskID = "task-wait" + json.NewEncoder(w).Encode(resp) + case http.MethodGet: + callCount++ + resp := TaskResult{Code: "0", Message: "ok"} + if callCount >= 2 { + resp.Data.Status = TaskStatusFinished + resp.Data.ProofBytes = fmt.Sprintf("0x%x", expectedProof) + } else { + resp.Data.Status = TaskStatusPending + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewProverClient(server.URL, 10*time.Millisecond, testlog.Logger(t, log.LvlInfo)) + proof, err := client.ProveAndWait(context.Background(), ProveRequest{}) + require.NoError(t, err) + require.Equal(t, expectedProof, proof) + require.GreaterOrEqual(t, callCount, 2) +} + +func TestProveAndWaitContextCancel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + resp := ProveResponse{Code: "0", Message: "ok"} + resp.Data.TaskID = "task-cancel" + json.NewEncoder(w).Encode(resp) + case http.MethodGet: + resp := TaskResult{Code: "0", Message: "ok"} + resp.Data.Status = TaskStatusPending + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + client := NewProverClient(server.URL, 10*time.Millisecond, testlog.Logger(t, log.LvlInfo)) + _, err := client.ProveAndWait(ctx, ProveRequest{}) + require.Error(t, err) + require.ErrorIs(t, err, context.DeadlineExceeded) +} From 1c8705a5f44bb67b150530b7499aad07b4db7660 Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Wed, 18 Mar 2026 17:59:42 +0800 Subject: [PATCH 03/25] feat(op-challenger): update TEE prover client to v1 API with retry loop and prove timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update ProverClient to TEE Prover v1 API: paths (/v1/task/), field names (startBlkHeight, startBlkStateHash, etc.), status enums (Running/Finished/Failed) - Add universal error code handling (0=OK, 10000/20001=retryable, 10001=non-retryable, 10004=not found) - Rewrite ProveAndWait as retry loop: POST → poll → Failed → re-POST, until Finished or ctx timeout - Add --tee-prove-timeout flag (default 1h) to bound total prove time including retries - Add proveGivenUp state to Actor: prevents infinite retry after timeout or non-retryable error - Add DeleteTask method for task cleanup - Differentiate error logs: timeout vs non-retryable vs generic failure - Update tests for v1 API, retry scenarios, and proveGivenUp behavior Co-Authored-By: Claude Sonnet 4.6 --- op-challenger/config/config.go | 1 + op-challenger/config/config_test.go | 3 + op-challenger/config/config_xlayer.go | 1 + op-challenger/flags/flags.go | 3 +- op-challenger/flags/flags_xlayer.go | 8 +- op-challenger/game/tee/actor.go | 54 +++-- op-challenger/game/tee/actor_test.go | 40 ++- op-challenger/game/tee/prover_client.go | 214 +++++++++++----- op-challenger/game/tee/prover_client_test.go | 241 +++++++++++++++---- op-challenger/game/tee/register.go | 3 +- 10 files changed, 440 insertions(+), 128 deletions(-) diff --git a/op-challenger/config/config.go b/op-challenger/config/config.go index 508b443335c48..bf2161223a5b5 100644 --- a/op-challenger/config/config.go +++ b/op-challenger/config/config.go @@ -91,6 +91,7 @@ type Config struct { // For XLayer: TEE Dispute Game config TeeProverRpc string // TEE Prover HTTP service URL TeeProvePollInterval time.Duration // Polling interval for TEE Prover task status + TeeProveTimeout time.Duration // Total timeout for a single game's prove attempt (including retries) MaxPendingTx uint64 // Maximum number of pending transactions (0 == no limit) diff --git a/op-challenger/config/config_test.go b/op-challenger/config/config_test.go index e1a610dc451a8..b86ce335f61bd 100644 --- a/op-challenger/config/config_test.go +++ b/op-challenger/config/config_test.go @@ -511,6 +511,9 @@ func TestRollupRpcRequired(t *testing.T) { if gameType == gameTypes.SuperCannonGameType || gameType == gameTypes.SuperPermissionedGameType || gameType == gameTypes.SuperCannonKonaGameType { continue } + if gameType == gameTypes.TeeGameType { // For XLayer: TEE doesn't require RollupRpc + continue + } t.Run(gameType.String(), func(t *testing.T) { config := validConfig(t, gameType) config.RollupRpc = "" diff --git a/op-challenger/config/config_xlayer.go b/op-challenger/config/config_xlayer.go index 153d58608c389..86a999f7b7887 100644 --- a/op-challenger/config/config_xlayer.go +++ b/op-challenger/config/config_xlayer.go @@ -13,6 +13,7 @@ var ( const ( DefaultTeeProvePollInterval = 30 * time.Second + DefaultTeeProveTimeout = 1 * time.Hour ) // xlayerConfigCheckers holds additional config validation functions registered by XLayer extensions. diff --git a/op-challenger/flags/flags.go b/op-challenger/flags/flags.go index 41937cc0e821b..39683210d4ccd 100644 --- a/op-challenger/flags/flags.go +++ b/op-challenger/flags/flags.go @@ -667,7 +667,8 @@ func NewConfigFromCLI(ctx *cli.Context, logger log.Logger) (*config.Config, erro AllowInvalidPrestate: ctx.Bool(UnsafeAllowInvalidPrestate.Name), ResponseDelay: ctx.Duration(ResponseDelayFlag.Name), ResponseDelayAfter: ctx.Uint64(ResponseDelayAfterFlag.Name), - TeeProverRpc: ctx.String(TeeProverRpcFlag.Name), // For XLayer + TeeProverRpc: ctx.String(TeeProverRpcFlag.Name), // For XLayer TeeProvePollInterval: ctx.Duration(TeeProvePollIntervalFlag.Name), // For XLayer + TeeProveTimeout: ctx.Duration(TeeProveTimeoutFlag.Name), // For XLayer }, nil } diff --git a/op-challenger/flags/flags_xlayer.go b/op-challenger/flags/flags_xlayer.go index da872c3f53f27..43113c937aa25 100644 --- a/op-challenger/flags/flags_xlayer.go +++ b/op-challenger/flags/flags_xlayer.go @@ -18,10 +18,16 @@ var ( EnvVars: prefixEnvVars("TEE_PROVE_POLL_INTERVAL"), Value: config.DefaultTeeProvePollInterval, } + TeeProveTimeoutFlag = &cli.DurationFlag{ + Name: "tee-prove-timeout", + Usage: "Total timeout for a single game's prove attempt including retries (tee game type only)", + EnvVars: prefixEnvVars("TEE_PROVE_TIMEOUT"), + Value: config.DefaultTeeProveTimeout, + } ) func init() { - optionalFlags = append(optionalFlags, TeeProverRpcFlag, TeeProvePollIntervalFlag) + optionalFlags = append(optionalFlags, TeeProverRpcFlag, TeeProvePollIntervalFlag, TeeProveTimeoutFlag) } // onlyTeeGameTypes returns true if all enabled game types are TEE (which doesn't require L2 RPC). diff --git a/op-challenger/game/tee/actor.go b/op-challenger/game/tee/actor.go index 762c5ed70018a..846a6255f9416 100644 --- a/op-challenger/game/tee/actor.go +++ b/op-challenger/game/tee/actor.go @@ -54,8 +54,8 @@ type proveResult struct { // Actor is a TEE dispute game actor that defends proposals by submitting TEE proofs. // It uses a background goroutine pattern: when a prove is needed, a goroutine is started -// that calls ProveAndWait (which polls at the user-configured interval). Act() checks -// for results via a non-blocking channel read. +// that calls ProveAndWait (which retries and polls at the user-configured interval). Act() +// checks for results via a non-blocking channel read. type Actor struct { logger log.Logger l1Clock ClockReader @@ -64,9 +64,11 @@ type Actor struct { txSender TxSender gameStatusProvider GameStatusProvider factory *contracts.DisputeGameFactoryContract + proveTimeout time.Duration // total timeout for prove attempts including retries serviceCtx context.Context // service-level ctx, outlives individual Act() calls - proveResultCh chan proveResult // buffered(1), receives result from background goroutine + proveResultCh chan proveResult // buffered(1), receives result from background goroutine proveInFlight bool // whether a background prove goroutine is running + proveGivenUp bool // true after prove timeout or non-retryable error — no more retries } // ActorCreator returns a generic.ActorCreator that creates TEE Actors. @@ -74,6 +76,7 @@ func ActorCreator( serviceCtx context.Context, l1Clock ClockReader, proverClient *ProverClient, + proveTimeout time.Duration, gameStatusProvider GameStatusProvider, contract ProvableContract, txSender TxSender, @@ -88,6 +91,7 @@ func ActorCreator( txSender: txSender, gameStatusProvider: gameStatusProvider, factory: factory, + proveTimeout: proveTimeout, serviceCtx: serviceCtx, proveResultCh: make(chan proveResult, 1), }, nil @@ -107,8 +111,20 @@ func (a *Actor) Act(ctx context.Context) error { case result := <-a.proveResultCh: a.proveInFlight = false if result.err != nil { - a.logger.Error("Background TEE prove failed", "err", result.err, "game", a.contract.Addr()) - // Don't return error — allow resolve logic to proceed, prove can retry next Act cycle + // Two possible errors from ProveAndWait: + // 1. context.DeadlineExceeded — proveTimeout (default 1h) expired after retries + // 2. errNonRetryable — code=10001 invalid params, retrying won't help + a.proveGivenUp = true + if errors.Is(result.err, context.DeadlineExceeded) { + a.logger.Error("TEE prove timed out, giving up", + "timeout", a.proveTimeout, "game", a.contract.Addr()) + } else if errors.Is(result.err, errNonRetryable) { + a.logger.Error("TEE prove failed with non-retryable error, giving up", + "err", result.err, "game", a.contract.Addr()) + } else { + a.logger.Error("TEE prove failed, giving up", + "err", result.err, "game", a.contract.Addr()) + } } else { a.logger.Info("Background TEE prove finished, submitting proof", "game", a.contract.Addr()) tx, err := a.contract.ProveTx(ctx, result.proofBytes) @@ -121,8 +137,8 @@ func (a *Actor) Act(ctx context.Context) error { // No result yet } - // 2. Start background prove if needed and not already in flight - if len(txs) == 0 && !a.proveInFlight { + // 2. Start background prove if needed, not already in flight, and not given up + if len(txs) == 0 && !a.proveInFlight && !a.proveGivenUp { if err := a.tryStartProve(ctx, metadata); errors.Is(err, errNoProveRequired) { a.logger.Debug("No prove required") } else if err != nil { @@ -163,12 +179,12 @@ func (a *Actor) tryStartProve(ctx context.Context, metadata contracts.Challenger } req := ProveRequest{ - PreAppHash: params.StartStateHash, - PostAppHash: params.EndStateHash, - StartBlockHeight: params.StartBlockNum, - EndBlockHeight: params.EndBlockNum, - StartBlockHash: params.StartBlockHash, - EndBlockHash: params.EndBlockHash, + StartBlkHeight: params.StartBlockNum, + EndBlkHeight: params.EndBlockNum, + StartBlkHash: params.StartBlockHash, + EndBlkHash: params.EndBlockHash, + StartBlkStateHash: params.StartStateHash, + EndBlkStateHash: params.EndStateHash, } a.logger.Info("Starting background TEE prove", @@ -178,9 +194,12 @@ func (a *Actor) tryStartProve(ctx context.Context, metadata contracts.Challenger a.proveInFlight = true go func() { - // Use serviceCtx so the goroutine survives individual Act() calls, - // but is cancelled when the service shuts down. - proofBytes, err := a.proverClient.ProveAndWait(a.serviceCtx, req) + // Use proveTimeout to bound the total prove time (including retries). + // The ctx is derived from serviceCtx so the goroutine survives individual + // Act() calls, but is cancelled when the service shuts down or timeout expires. + timeoutCtx, cancel := context.WithTimeout(a.serviceCtx, a.proveTimeout) + defer cancel() + proofBytes, err := a.proverClient.ProveAndWait(timeoutCtx, req) a.proveResultCh <- proveResult{proofBytes: proofBytes, err: err} }() @@ -232,6 +251,9 @@ func (a *Actor) AdditionalStatus(ctx context.Context) ([]any, error) { if a.proveInFlight { status = append(status, "proveInFlight", true) } + if a.proveGivenUp { + status = append(status, "proveGivenUp", true) + } return status, nil } diff --git a/op-challenger/game/tee/actor_test.go b/op-challenger/game/tee/actor_test.go index 41ccd5f6e706d..4e7ef6e028694 100644 --- a/op-challenger/game/tee/actor_test.go +++ b/op-challenger/game/tee/actor_test.go @@ -87,14 +87,11 @@ func TestActor(t *testing.T) { setup: func(t *testing.T, actor *Actor, stubs *teeTestStubs) { stubs.contract.setDeadlineNotReached() stubs.contract.challenge(t) - // Pre-load error result + // Pre-load error result — actor should set proveGivenUp=true actor.proveResultCh <- proveResult{err: errors.New("tee prover failed")} actor.proveInFlight = true - // After error, prove can retry (new goroutine starts), but no tx this cycle - // Note: the second tryStartProve will try to start a goroutine, - // but since the contract GetProveParams will succeed, it will launch one. - // For this test, the key assertion is no prove tx is sent. }, + // No prove or resolve tx — error consumed, proveGivenUp=true, no retry }, { name: "ChallengedExpiredNoProof", @@ -188,7 +185,7 @@ func TestActorProveResultClearsInFlight(t *testing.T) { require.False(t, actor.proveInFlight, "proveInFlight should be cleared after result consumed") } -func TestActorProveErrorClearsInFlight(t *testing.T) { +func TestActorProveErrorSetsGivenUp(t *testing.T) { actor, stubs := setupTeeActorTest(t) stubs.contract.setDeadlineNotReached() stubs.contract.challenge(t) @@ -198,9 +195,33 @@ func TestActorProveErrorClearsInFlight(t *testing.T) { err := actor.Act(context.Background()) require.NoError(t, err) - // After error consumed, proveInFlight is cleared, then tryStartProve - // re-launches a goroutine (Challenged + deadline not expired), so proveInFlight is true again. - require.True(t, actor.proveInFlight, "proveInFlight should be true after retry goroutine started") + require.False(t, actor.proveInFlight, "proveInFlight should be cleared after error consumed") + require.True(t, actor.proveGivenUp, "proveGivenUp should be set after error") +} + +func TestActorProveTimeoutSetsGivenUp(t *testing.T) { + actor, stubs := setupTeeActorTest(t) + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + + actor.proveInFlight = true + actor.proveResultCh <- proveResult{err: context.DeadlineExceeded} + + err := actor.Act(context.Background()) + require.NoError(t, err) + require.True(t, actor.proveGivenUp, "proveGivenUp should be set after timeout") +} + +func TestActorProveGivenUpSkipsProve(t *testing.T) { + actor, stubs := setupTeeActorTest(t) + stubs.contract.setDeadlineNotReached() + stubs.contract.challenge(t) + actor.proveGivenUp = true // Already given up + + err := actor.Act(context.Background()) + require.NoError(t, err) + require.False(t, actor.proveInFlight, "should not start prove goroutine when given up") + require.Len(t, stubs.sender.sentData, 0, "no tx should be sent") } func setupTeeActorTest(t *testing.T) (*Actor, *teeTestStubs) { @@ -224,6 +245,7 @@ func setupTeeActorTest(t *testing.T) (*Actor, *teeTestStubs) { txSender: txSender, gameStatusProvider: contract, factory: nil, + proveTimeout: 1 * time.Hour, serviceCtx: context.Background(), proveResultCh: make(chan proveResult, 1), } diff --git a/op-challenger/game/tee/prover_client.go b/op-challenger/game/tee/prover_client.go index 3866370236668..dfa772aef38ad 100644 --- a/op-challenger/game/tee/prover_client.go +++ b/op-challenger/game/tee/prover_client.go @@ -3,8 +3,8 @@ package tee import ( "bytes" "context" - "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -16,44 +16,55 @@ import ( ) const ( - defaultProvePath = "/prove" - defaultTaskPath = "/task/%s" + taskBasePath = "/v1/task/" ) // Task statuses returned by the TEE Prover. const ( - TaskStatusFinished = "FINISHED" - TaskStatusPending = "PENDING" - TaskStatusNotFound = "NOT_FOUND" + TaskStatusRunning = "Running" + TaskStatusFinished = "Finished" + TaskStatusFailed = "Failed" ) +// TEE Prover error codes. +const ( + codeOK = 0 + codeInvalidSig = 10000 // Invalid signature — retryable + codeInvalidParams = 10001 // Invalid parameters — NOT retryable + codeTaskNotFound = 10004 // Task not found — triggers re-POST + codeInternalError = 20001 // Internal error — retryable +) + +// errNonRetryable is returned when the TEE Prover returns an error that should not be retried. +var errNonRetryable = errors.New("non-retryable prover error") + // ProveRequest is sent to the TEE Prover to initiate a proof task. type ProveRequest struct { - PreAppHash common.Hash `json:"preAppHash"` - PostAppHash common.Hash `json:"postAppHash"` - StartBlockHeight uint64 `json:"startBlockHeight"` - EndBlockHeight uint64 `json:"endBlockHeight"` - StartBlockHash common.Hash `json:"startBlockHash"` - EndBlockHash common.Hash `json:"endBlockHash"` + StartBlkHeight uint64 `json:"startBlkHeight"` + EndBlkHeight uint64 `json:"endBlkHeight"` + StartBlkHash common.Hash `json:"startBlkHash"` + EndBlkHash common.Hash `json:"endBlkHash"` + StartBlkStateHash common.Hash `json:"startBlkStateHash"` + EndBlkStateHash common.Hash `json:"endBlkStateHash"` } -// ProveResponse is the response from the TEE Prover after submitting a prove task. -type ProveResponse struct { - Code string `json:"code"` - Message string `json:"message"` - Data struct { - TaskID string `json:"taskId"` - } `json:"data"` +// ProverResponse is the generic response envelope from the TEE Prover. +type ProverResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` } -// TaskResult is the response from polling a prove task's status. -type TaskResult struct { - Code string `json:"code"` - Message string `json:"message"` - Data struct { - Status string `json:"status"` - ProofBytes string `json:"proofBytes"` - } `json:"data"` +// CreateTaskData is the data returned from POST /v1/task/. +type CreateTaskData struct { + TaskID string `json:"taskId"` +} + +// TaskResultData is the data returned from GET /v1/task/{taskId}. +type TaskResultData struct { + Status string `json:"status"` + ProofBytes string `json:"proofBytes"` + Detail any `json:"detail"` } // ProverClient communicates with the TEE Prover HTTP service. @@ -81,7 +92,7 @@ func (c *ProverClient) Prove(ctx context.Context, req ProveRequest) (string, err return "", fmt.Errorf("failed to marshal prove request: %w", err) } - url := c.baseURL + defaultProvePath + url := c.baseURL + taskBasePath httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("failed to create prove request: %w", err) @@ -103,17 +114,30 @@ func (c *ProverClient) Prove(ctx context.Context, req ProveRequest) (string, err return "", fmt.Errorf("prove request failed with status %d: %s", resp.StatusCode, string(respBody)) } - var proveResp ProveResponse + var proveResp ProverResponse if err := json.Unmarshal(respBody, &proveResp); err != nil { return "", fmt.Errorf("failed to unmarshal prove response: %w", err) } - return proveResp.Data.TaskID, nil + if proveResp.Code != codeOK { + err := fmt.Errorf("prove request returned error code %d: %s", proveResp.Code, proveResp.Message) + if proveResp.Code == codeInvalidParams { + return "", fmt.Errorf("%w: %w", errNonRetryable, err) + } + return "", err + } + + var data CreateTaskData + if err := json.Unmarshal(proveResp.Data, &data); err != nil { + return "", fmt.Errorf("failed to unmarshal prove response data: %w", err) + } + + return data.TaskID, nil } -// GetTaskResult polls the status of a prove task. -func (c *ProverClient) GetTaskResult(ctx context.Context, taskID string) (*TaskResult, error) { - url := c.baseURL + fmt.Sprintf(defaultTaskPath, taskID) +// GetTaskResult queries the status of a prove task. +func (c *ProverClient) GetTaskResult(ctx context.Context, taskID string) (*TaskResultData, error) { + url := c.baseURL + taskBasePath + taskID httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create task request: %w", err) @@ -134,23 +158,109 @@ func (c *ProverClient) GetTaskResult(ctx context.Context, taskID string) (*TaskR return nil, fmt.Errorf("task request failed with status %d: %s", resp.StatusCode, string(respBody)) } - var result TaskResult - if err := json.Unmarshal(respBody, &result); err != nil { + var envelope ProverResponse + if err := json.Unmarshal(respBody, &envelope); err != nil { return nil, fmt.Errorf("failed to unmarshal task response: %w", err) } - return &result, nil + if envelope.Code == codeTaskNotFound { + return nil, fmt.Errorf("task %s not found (code %d)", taskID, codeTaskNotFound) + } + if envelope.Code != codeOK { + return nil, fmt.Errorf("task request returned error code %d: %s", envelope.Code, envelope.Message) + } + + var data TaskResultData + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal task result data: %w", err) + } + + return &data, nil } -// ProveAndWait submits a proof request and polls until it is finished or the context is cancelled. -func (c *ProverClient) ProveAndWait(ctx context.Context, req ProveRequest) ([]byte, error) { - taskID, err := c.Prove(ctx, req) +// DeleteTask terminates and deletes a prove task. +func (c *ProverClient) DeleteTask(ctx context.Context, taskID string) error { + url := c.baseURL + taskBasePath + taskID + httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("failed to create delete request: %w", err) + } + + resp, err := c.httpClient.Do(httpReq) if err != nil { - return nil, fmt.Errorf("failed to submit prove task: %w", err) + return fmt.Errorf("failed to send delete request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read delete response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("delete request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var envelope ProverResponse + if err := json.Unmarshal(respBody, &envelope); err != nil { + return fmt.Errorf("failed to unmarshal delete response: %w", err) } - c.logger.Info("TEE prove task submitted", "taskID", taskID) + // code=10001 on DELETE means task already gone — treat as success + if envelope.Code != codeOK && envelope.Code != codeInvalidParams { + return fmt.Errorf("delete request returned error code %d: %s", envelope.Code, envelope.Message) + } + + return nil +} + +// ProveAndWait submits a proof request and retries until it succeeds or the context is cancelled. +// On task failure (Failed status), it re-submits a new task. On non-retryable errors (code=10001), +// it returns immediately. The ctx should have a timeout set by the caller to bound total prove time. +func (c *ProverClient) ProveAndWait(ctx context.Context, req ProveRequest) ([]byte, error) { + for { + // 1. Submit task + taskID, err := c.Prove(ctx, req) + if err != nil { + if errors.Is(err, errNonRetryable) { + return nil, err + } + // Retryable error (10000, 20001, HTTP 5xx) — wait and retry + c.logger.Warn("Prove request failed, will retry", "err", err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(c.pollInterval): + continue + } + } + c.logger.Info("TEE prove task submitted", "taskID", taskID) + + // 2. Poll task status + proofBytes, err := c.pollTask(ctx, taskID) + if err == nil { + return proofBytes, nil + } + + // 3. Non-retryable → return immediately + if errors.Is(err, errNonRetryable) { + return nil, err + } + + // 4. Retryable (Failed / task not found / HTTP error) → wait and re-POST + c.logger.Warn("Task failed, will re-submit", "taskID", taskID, "err", err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(c.pollInterval): + continue + } + } +} + +// pollTask polls a task until it finishes, fails, or the context is cancelled. +func (c *ProverClient) pollTask(ctx context.Context, taskID string) ([]byte, error) { ticker := time.NewTicker(c.pollInterval) defer ticker.Stop() @@ -161,24 +271,20 @@ func (c *ProverClient) ProveAndWait(ctx context.Context, req ProveRequest) ([]by case <-ticker.C: result, err := c.GetTaskResult(ctx, taskID) if err != nil { - c.logger.Warn("Failed to poll TEE prove task", "taskID", taskID, "err", err) - continue + // HTTP error or task not found → return to outer retry loop + return nil, err } - switch result.Data.Status { + switch result.Status { case TaskStatusFinished: c.logger.Info("TEE prove task finished", "taskID", taskID) - proofBytes, err := hex.DecodeString(strings.TrimPrefix(result.Data.ProofBytes, "0x")) - if err != nil { - return nil, fmt.Errorf("failed to decode proof bytes: %w", err) - } - return proofBytes, nil - case TaskStatusPending: - c.logger.Debug("TEE prove task still pending", "taskID", taskID) - case TaskStatusNotFound: - return nil, fmt.Errorf("TEE prove task not found: %s", taskID) + return decodeProofBytes(result.ProofBytes) + case TaskStatusFailed: + return nil, fmt.Errorf("task %s failed: %v", taskID, result.Detail) + case TaskStatusRunning: + c.logger.Debug("TEE prove task still running", "taskID", taskID) default: - c.logger.Warn("Unknown TEE prove task status", "taskID", taskID, "status", result.Data.Status) + c.logger.Warn("Unknown TEE prove task status", "taskID", taskID, "status", result.Status) } } } diff --git a/op-challenger/game/tee/prover_client_test.go b/op-challenger/game/tee/prover_client_test.go index c86846d136976..b156c56a5d2cb 100644 --- a/op-challenger/game/tee/prover_client_test.go +++ b/op-challenger/game/tee/prover_client_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "sync/atomic" "testing" "time" @@ -20,21 +21,21 @@ func TestProveSuccess(t *testing.T) { expectedTaskID := "task-abc-123" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) - require.Equal(t, "/prove", r.URL.Path) + require.Equal(t, "/v1/task/", r.URL.Path) require.Equal(t, "application/json", r.Header.Get("Content-Type")) var req ProveRequest err := json.NewDecoder(r.Body).Decode(&req) require.NoError(t, err) - require.Equal(t, common.Hash{0x01}, req.PreAppHash) - require.Equal(t, common.Hash{0x02}, req.PostAppHash) - require.Equal(t, uint64(100), req.StartBlockHeight) - require.Equal(t, uint64(200), req.EndBlockHeight) - require.Equal(t, common.Hash{0x03}, req.StartBlockHash) - require.Equal(t, common.Hash{0x04}, req.EndBlockHash) - - resp := ProveResponse{Code: "0", Message: "ok"} - resp.Data.TaskID = expectedTaskID + require.Equal(t, common.Hash{0x01}, req.StartBlkStateHash) + require.Equal(t, common.Hash{0x02}, req.EndBlkStateHash) + require.Equal(t, uint64(100), req.StartBlkHeight) + require.Equal(t, uint64(200), req.EndBlkHeight) + require.Equal(t, common.Hash{0x03}, req.StartBlkHash) + require.Equal(t, common.Hash{0x04}, req.EndBlkHash) + + data, _ := json.Marshal(CreateTaskData{TaskID: expectedTaskID}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) })) @@ -42,12 +43,12 @@ func TestProveSuccess(t *testing.T) { client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) taskID, err := client.Prove(context.Background(), ProveRequest{ - PreAppHash: common.Hash{0x01}, - PostAppHash: common.Hash{0x02}, - StartBlockHeight: 100, - EndBlockHeight: 200, - StartBlockHash: common.Hash{0x03}, - EndBlockHash: common.Hash{0x04}, + StartBlkStateHash: common.Hash{0x01}, + EndBlkStateHash: common.Hash{0x02}, + StartBlkHeight: 100, + EndBlkHeight: 200, + StartBlkHash: common.Hash{0x03}, + EndBlkHash: common.Hash{0x04}, }) require.NoError(t, err) require.Equal(t, expectedTaskID, taskID) @@ -78,14 +79,42 @@ func TestProveBadResponse(t *testing.T) { require.Contains(t, err.Error(), "unmarshal") } +func TestProveNonRetryableError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := ProverResponse{Code: codeInvalidParams, Message: "invalid params"} + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + _, err := client.Prove(context.Background(), ProveRequest{}) + require.Error(t, err) + require.ErrorIs(t, err, errNonRetryable) +} + +func TestProveRetryableErrorCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := ProverResponse{Code: codeInternalError, Message: "internal error"} + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + _, err := client.Prove(context.Background(), ProveRequest{}) + require.Error(t, err) + require.NotErrorIs(t, err, errNonRetryable, "internal error should be retryable") +} + func TestGetTaskFinished(t *testing.T) { proofHex := "0xdeadbeef" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) - require.Equal(t, "/task/task-123", r.URL.Path) - resp := TaskResult{Code: "0", Message: "ok"} - resp.Data.Status = TaskStatusFinished - resp.Data.ProofBytes = proofHex + require.Equal(t, "/v1/task/task-123", r.URL.Path) + data, _ := json.Marshal(TaskResultData{ + Status: TaskStatusFinished, + ProofBytes: proofHex, + }) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} json.NewEncoder(w).Encode(resp) })) defer server.Close() @@ -93,14 +122,14 @@ func TestGetTaskFinished(t *testing.T) { client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) result, err := client.GetTaskResult(context.Background(), "task-123") require.NoError(t, err) - require.Equal(t, TaskStatusFinished, result.Data.Status) - require.Equal(t, proofHex, result.Data.ProofBytes) + require.Equal(t, TaskStatusFinished, result.Status) + require.Equal(t, proofHex, result.ProofBytes) } -func TestGetTaskPending(t *testing.T) { +func TestGetTaskRunning(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := TaskResult{Code: "0", Message: "ok"} - resp.Data.Status = TaskStatusPending + data, _ := json.Marshal(TaskResultData{Status: TaskStatusRunning}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} json.NewEncoder(w).Encode(resp) })) defer server.Close() @@ -108,22 +137,21 @@ func TestGetTaskPending(t *testing.T) { client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) result, err := client.GetTaskResult(context.Background(), "task-456") require.NoError(t, err) - require.Equal(t, TaskStatusPending, result.Data.Status) - require.Empty(t, result.Data.ProofBytes) + require.Equal(t, TaskStatusRunning, result.Status) + require.Empty(t, result.ProofBytes) } func TestGetTaskNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := TaskResult{Code: "0", Message: "ok"} - resp.Data.Status = TaskStatusNotFound + resp := ProverResponse{Code: codeTaskNotFound, Message: "not found"} json.NewEncoder(w).Encode(resp) })) defer server.Close() client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) - result, err := client.GetTaskResult(context.Background(), "task-789") - require.NoError(t, err) - require.Equal(t, TaskStatusNotFound, result.Data.Status) + _, err := client.GetTaskResult(context.Background(), "task-789") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") } func TestGetTaskServerError(t *testing.T) { @@ -139,24 +167,145 @@ func TestGetTaskServerError(t *testing.T) { require.Contains(t, err.Error(), "502") } +func TestDeleteTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "/v1/task/task-del", r.URL.Path) + resp := ProverResponse{Code: codeOK, Message: "ok"} + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + err := client.DeleteTask(context.Background(), "task-del") + require.NoError(t, err) +} + +func TestDeleteTaskNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // code=10001 on DELETE means task already gone — should be treated as success + resp := ProverResponse{Code: codeInvalidParams, Message: "task not found"} + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + err := client.DeleteTask(context.Background(), "task-gone") + require.NoError(t, err) +} + func TestProveAndWaitSuccess(t *testing.T) { - callCount := 0 + var getCount atomic.Int32 expectedProof := []byte{0xde, 0xad, 0xbe, 0xef} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - resp := ProveResponse{Code: "0", Message: "ok"} - resp.Data.TaskID = "task-wait" + data, _ := json.Marshal(CreateTaskData{TaskID: "task-wait"}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} json.NewEncoder(w).Encode(resp) case http.MethodGet: - callCount++ - resp := TaskResult{Code: "0", Message: "ok"} - if callCount >= 2 { - resp.Data.Status = TaskStatusFinished - resp.Data.ProofBytes = fmt.Sprintf("0x%x", expectedProof) + count := getCount.Add(1) + var data []byte + if count >= 2 { + data, _ = json.Marshal(TaskResultData{ + Status: TaskStatusFinished, + ProofBytes: fmt.Sprintf("0x%x", expectedProof), + }) } else { - resp.Data.Status = TaskStatusPending + data, _ = json.Marshal(TaskResultData{Status: TaskStatusRunning}) } + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewProverClient(server.URL, 10*time.Millisecond, testlog.Logger(t, log.LvlInfo)) + proof, err := client.ProveAndWait(context.Background(), ProveRequest{}) + require.NoError(t, err) + require.Equal(t, expectedProof, proof) + require.GreaterOrEqual(t, int(getCount.Load()), 2) +} + +func TestProveAndWaitRetryAfterFailed(t *testing.T) { + var postCount atomic.Int32 + var getCount atomic.Int32 + expectedProof := []byte{0xca, 0xfe} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + count := postCount.Add(1) + taskID := fmt.Sprintf("task-%d", count) + data, _ := json.Marshal(CreateTaskData{TaskID: taskID}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} + json.NewEncoder(w).Encode(resp) + case http.MethodGet: + count := getCount.Add(1) + var data []byte + if postCount.Load() == 1 { + // First task: always fail + data, _ = json.Marshal(TaskResultData{ + Status: TaskStatusFailed, + Detail: "compute error", + }) + } else if count >= 3 { + // Second task: finish after a poll + data, _ = json.Marshal(TaskResultData{ + Status: TaskStatusFinished, + ProofBytes: fmt.Sprintf("0x%x", expectedProof), + }) + } else { + data, _ = json.Marshal(TaskResultData{Status: TaskStatusRunning}) + } + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewProverClient(server.URL, 10*time.Millisecond, testlog.Logger(t, log.LvlInfo)) + proof, err := client.ProveAndWait(context.Background(), ProveRequest{}) + require.NoError(t, err) + require.Equal(t, expectedProof, proof) + require.GreaterOrEqual(t, int(postCount.Load()), 2, "should have re-submitted after failure") +} + +func TestProveAndWaitNonRetryableError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return code=10001 (invalid params) — non-retryable + resp := ProverResponse{Code: codeInvalidParams, Message: "bad params"} + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewProverClient(server.URL, 10*time.Millisecond, testlog.Logger(t, log.LvlInfo)) + _, err := client.ProveAndWait(context.Background(), ProveRequest{}) + require.Error(t, err) + require.ErrorIs(t, err, errNonRetryable) +} + +func TestProveAndWaitRetryAfterPostError(t *testing.T) { + var postCount atomic.Int32 + expectedProof := []byte{0xbb} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + count := postCount.Add(1) + if count == 1 { + // First POST: server error (retryable) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("overloaded")) + return + } + data, _ := json.Marshal(CreateTaskData{TaskID: "task-retry"}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} + json.NewEncoder(w).Encode(resp) + case http.MethodGet: + data, _ := json.Marshal(TaskResultData{ + Status: TaskStatusFinished, + ProofBytes: fmt.Sprintf("0x%x", expectedProof), + }) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} json.NewEncoder(w).Encode(resp) } })) @@ -166,19 +315,19 @@ func TestProveAndWaitSuccess(t *testing.T) { proof, err := client.ProveAndWait(context.Background(), ProveRequest{}) require.NoError(t, err) require.Equal(t, expectedProof, proof) - require.GreaterOrEqual(t, callCount, 2) + require.GreaterOrEqual(t, int(postCount.Load()), 2) } func TestProveAndWaitContextCancel(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - resp := ProveResponse{Code: "0", Message: "ok"} - resp.Data.TaskID = "task-cancel" + data, _ := json.Marshal(CreateTaskData{TaskID: "task-cancel"}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} json.NewEncoder(w).Encode(resp) case http.MethodGet: - resp := TaskResult{Code: "0", Message: "ok"} - resp.Data.Status = TaskStatusPending + data, _ := json.Marshal(TaskResultData{Status: TaskStatusRunning}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} json.NewEncoder(w).Encode(resp) } })) diff --git a/op-challenger/game/tee/register.go b/op-challenger/game/tee/register.go index c6ec65f589c1d..12680c811cb58 100644 --- a/op-challenger/game/tee/register.go +++ b/op-challenger/game/tee/register.go @@ -32,6 +32,7 @@ func RegisterGameTypes( } proverClient := NewProverClient(cfg.TeeProverRpc, cfg.TeeProvePollInterval, logger) + proveTimeout := cfg.TeeProveTimeout registry.RegisterGameType(gameTypes.TeeGameType, func(game gameTypes.GameMetadata, dir string) (scheduler.GamePlayer, error) { contract, err := contracts.NewTeeDisputeGameContract(m, game.Proxy, clients.MultiCaller()) @@ -46,7 +47,7 @@ func RegisterGameTypes( &client.NoopSyncStatusValidator{}, nil, clients.L1Client(), - ActorCreator(ctx, l1Clock, proverClient, factoryContract, contract, txSender, factoryContract), + ActorCreator(ctx, l1Clock, proverClient, proveTimeout, factoryContract, contract, txSender, factoryContract), ) }) From 19072f12ed0cee2cfef91b6ef6495d38e6c3244d Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Thu, 19 Mar 2026 16:56:00 +0800 Subject: [PATCH 04/25] proposer support tee game type --- .gitignore | 5 +- op-proposer/contracts/disputegamefactory.go | 52 +++- op-proposer/flags/flags.go | 7 + op-proposer/mock/README.md | 131 +++++++++ op-proposer/mock/TEST_GUIDE.md | 257 +++++++++++++++++ op-proposer/mock/cmd/mockteerpc/main.go | 163 +++++++++++ op-proposer/mock/list_games.sh | 80 ++++++ op-proposer/mock/mock_tee_rollup_server.go | 227 +++++++++++++++ .../mock/mock_tee_rollup_server_test.go | 62 +++++ op-proposer/proposer/config.go | 15 + op-proposer/proposer/config_test.go | 27 ++ op-proposer/proposer/service.go | 14 + op-proposer/proposer/source/source.go | 34 +++ .../source/source_tee_rollup_xlayer.go | 231 +++++++++++++++ .../source/source_tee_rollup_xlayer_test.go | 262 ++++++++++++++++++ 15 files changed, 1557 insertions(+), 10 deletions(-) create mode 100644 op-proposer/mock/README.md create mode 100644 op-proposer/mock/TEST_GUIDE.md create mode 100644 op-proposer/mock/cmd/mockteerpc/main.go create mode 100755 op-proposer/mock/list_games.sh create mode 100644 op-proposer/mock/mock_tee_rollup_server.go create mode 100644 op-proposer/mock/mock_tee_rollup_server_test.go create mode 100644 op-proposer/proposer/source/source_tee_rollup_xlayer.go create mode 100644 op-proposer/proposer/source/source_tee_rollup_xlayer_test.go diff --git a/.gitignore b/.gitignore index 4031cb1d276f9..6b285727031d7 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ __pycache__ crytic-export # ignore local asdf config -.tool-versions \ No newline at end of file +.tool-versions + +.claude +.omc diff --git a/op-proposer/contracts/disputegamefactory.go b/op-proposer/contracts/disputegamefactory.go index ad8ae47aed100..510fa02949d99 100644 --- a/op-proposer/contracts/disputegamefactory.go +++ b/op-proposer/contracts/disputegamefactory.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/big" + "strings" "time" "github.com/ethereum-optimism/optimism/op-service/bigs" @@ -23,8 +24,24 @@ const ( methodVersion = "version" methodClaim = "claimData" + + teeGameType uint32 = 1960 // For xlayer: TEE game type (TeeRollup) ) +// For xlayer: ABI for new game contract's no-arg claimData() struct getter +const newGameClaimDataABIJSON = `[{"name":"claimData","type":"function","inputs":[],"outputs":[{"name":"parentIndex","type":"uint32"},{"name":"counteredBy","type":"address"},{"name":"prover","type":"address"},{"name":"claim","type":"bytes32"},{"name":"status","type":"uint8"},{"name":"deadline","type":"uint64"}],"stateMutability":"view"}]` + +// For xlayer: parsed ABI for new game contract's claimData() getter +var newGameClaimDataABI abi.ABI + +func init() { + var err error + newGameClaimDataABI, err = abi.JSON(strings.NewReader(newGameClaimDataABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse new game claim data ABI: %v", err)) + } +} + type gameMetadata struct { GameType uint32 Timestamp time.Time @@ -133,16 +150,33 @@ func (f *DisputeGameFactory) gameAtIndex(ctx context.Context, idx uint64) (gameM timestamp := result.GetUint64(1) address := result.GetAddress(2) - gameContract := batching.NewBoundContract(f.gameABI, address) - cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) - defer cancel() - result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, gameContract.Call(methodClaim, big.NewInt(0))) - if err != nil { - return gameMetadata{}, fmt.Errorf("failed to load root claim of game %v: %w", idx, err) + var claimant common.Address + var claim common.Hash + if gameType == teeGameType { + // For xlayer: TEE game type (1960) uses new contract ABI — claimData() takes no args, + // returns (parentIndex, counteredBy, prover, claim, status, deadline). + // prover is at index 2, claim (bytes32) is at index 3. + newGameContract := batching.NewBoundContract(&newGameClaimDataABI, address) + cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, newGameContract.Call(methodClaim)) + if err != nil { + return gameMetadata{}, fmt.Errorf("failed to load root claim of game %v: %w", idx, err) + } + claimant = result.GetAddress(2) + claim = result.GetHash(3) + } else { + gameContract := batching.NewBoundContract(f.gameABI, address) + cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, gameContract.Call(methodClaim, big.NewInt(0))) + if err != nil { + return gameMetadata{}, fmt.Errorf("failed to load root claim of game %v: %w", idx, err) + } + // We don't need most of the claim data, only the claim and the claimant which is the game proposer + claimant = result.GetAddress(2) + claim = result.GetHash(4) } - // We don't need most of the claim data, only the claim and the claimant which is the game proposer - claimant := result.GetAddress(2) - claim := result.GetHash(4) return gameMetadata{ GameType: gameType, diff --git a/op-proposer/flags/flags.go b/op-proposer/flags/flags.go index c15ace036171e..3c8d7a2968c5e 100644 --- a/op-proposer/flags/flags.go +++ b/op-proposer/flags/flags.go @@ -79,6 +79,12 @@ var ( Value: false, EnvVars: prefixEnvVars("WAIT_NODE_SYNC"), } + // For xlayer: TeeRollup RPC flag for game type 1960 + TeeRollupRpcFlag = &cli.StringFlag{ + Name: "tee-rollup-rpc", + Usage: "TeeRollup RPC service base URL (required when --game-type=1960)", + EnvVars: []string{"OP_PROPOSER_TEE_ROLLUP_RPC"}, + } // Legacy Flags L2OutputHDPathFlag = txmgr.L2OutputHDPathFlag ) @@ -98,6 +104,7 @@ var optionalFlags = []cli.Flag{ DisputeGameTypeFlag, ActiveSequencerCheckDurationFlag, WaitNodeSyncFlag, + TeeRollupRpcFlag, // For xlayer } func init() { diff --git a/op-proposer/mock/README.md b/op-proposer/mock/README.md new file mode 100644 index 0000000000000..94f36fc4425af --- /dev/null +++ b/op-proposer/mock/README.md @@ -0,0 +1,131 @@ +# op-proposer/mock + +Test utilities for op-proposer, including a mock TeeRollup HTTP server. + +--- + +## Mock TeeRollup Server + +Simulates the `GET /v1/chain/confirmed_block_info` REST endpoint provided by a real TeeRollup service. + +**Behavior:** +- Starts at block height 1000 (configurable) +- Increments height by a random delta in **[1, 50]** every second +- `appHash` = `keccak256(big-endian uint64 of height)`, `"0x"` prefix, 66 characters +- `blockHash` = `keccak256(appHash)`, `"0x"` prefix, 66 characters + +--- + +## How to Run + +### Option 1: Direct `go run` (recommended, no build step) + +```bash +# From the op-proposer directory +cd op-proposer +go run ./mock/cmd/mockteerpc + +# Custom listen address and initial height +go run ./mock/cmd/mockteerpc --addr :9000 --init-height 5000 + +# 30% error rate + max 500ms delay +go run ./mock/cmd/mockteerpc --error-rate 0.3 --delay 500ms +``` + +### Option 2: Build then run + +```bash +cd op-proposer +go build -o bin/mockteerpc ./mock/cmd/mockteerpc +./bin/mockteerpc --addr :8090 +``` + +Startup output example: +``` +mock TeeRollup server listening on :8090 +initial height: 1000 +endpoint: GET /v1/chain/confirmed_block_info + +tick: height=1023 delta=23 +tick: height=1058 delta=35 +... +``` + +--- + +## curl Testing + +```bash +# Query current confirmed block info +curl -s http://localhost:8090/v1/chain/confirmed_block_info | jq . +``` + +Example response: +```json +{ + "code": 0, + "message": "OK", + "data": { + "height": 1023, + "appHash": "0x3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b", + "blockHash": "0x1234abcd..." + } +} +``` + +### Observe height growth continuously + +```bash +# Request every 0.5s to observe height changes +watch -n 0.5 'curl -s http://localhost:8090/v1/chain/confirmed_block_info | jq .data' +``` + +### Verify hash computation (Python) + +```python +from eth_hash.auto import keccak +import struct, requests + +r = requests.get("http://localhost:8090/v1/chain/confirmed_block_info").json() +height = r["data"]["height"] + +app_hash = keccak(struct.pack(">Q", height)).hex() +block_hash = keccak(bytes.fromhex(app_hash)).hex() + +print(f"height: {height}") +print(f"appHash: 0x{app_hash}") # 66 chars +print(f"blockHash: 0x{block_hash}") # 66 chars +# Should match API response +``` + +--- + +## Usage in Tests + +```go +import "github.com/ethereum-optimism/optimism/op-proposer/mock" + +func TestMyFeature(t *testing.T) { + srv := mock.NewTeeRollupServer(t) // t.Cleanup closes automatically + + // Server URL + baseURL := srv.Addr() // e.g. "http://127.0.0.1:12345" + + // Get current snapshot (no HTTP request needed) + height, appHash, blockHash := srv.CurrentInfo() + _ = height + _ = appHash + _ = blockHash +} +``` + +--- + +## CLI flags + +| Flag | Default | Description | +|----------------|---------|--------------------------------------------------------------------------| +| `--addr` | `:8090` | Listen address | +| `--init-height`| `1000` | Initial block height | +| `--error-rate` | `0` | Error response probability [0.0, 1.0], 0 means no errors | +| `--delay` | `1s` | Maximum random response delay, actual delay is random in [0, delay] (supports `500ms`, `2s`) | diff --git a/op-proposer/mock/TEST_GUIDE.md b/op-proposer/mock/TEST_GUIDE.md new file mode 100644 index 0000000000000..36cdc3240c5ac --- /dev/null +++ b/op-proposer/mock/TEST_GUIDE.md @@ -0,0 +1,257 @@ +# TEE Game Type (1960) — Hands-On Testing Guide + +This guide walks you through testing the op-proposer TEE game type (1960) locally using the mock TeeRollup server. All parameters are provided manually—no `.env` file dependency. + +## Overview + +Testing the TEE game type requires three components working together: + +1. **Mock TeeRollup RPC server** (`mockteerpc`) — Simulates the TeeRollup HTTP endpoint, returning block info with incrementing heights +2. **op-proposer** — The proposer binary that reads from TeeRollup and submits TEE game disputes +3. **Verification script** (`list_games.sh`) — Lists created games to verify proposal success + +The flow: proposer fetches block info from mock TeeRollup → computes rootClaim → submits proposal → list_games.sh confirms the game was created. + +--- + +## Step 1: Install Binaries +0x1D8D70AD07C8E7E442AD78E4AC0A16f958Eba7F0 +On macOS, binaries must be installed via `go install` (not run directly from build output) due to security restrictions. + +From the repository root, install both the mock TeeRollup server and the proposer: + +```bash +cd op-proposer +go install ./mock/cmd/mockteerpc +go install ./cmd/main.go +``` + +Both binaries are now in `$GOPATH/bin` and available system-wide. + +--- + +## Step 2: Start the Mock TeeRollup RPC Server + +In a new terminal, start the mock server with the flags below: + +```bash +mockteerpc \ + --addr=:8090 \ + --init-height=1000000 \ + --error-rate=0 \ + --delay=0 +``` + +### Flag Reference + +| Flag | Description | +|------|-------------| +| `--addr` | Listen address (default `:8090`). Use any available port. | +| `--init-height` | Starting block height (default `1000`). **Use a value higher than your anchor sequence number.** The mock increments this by 1–50 every second. | +| `--error-rate` | Fraction of requests that return errors, 0.0–1.0 (default `0`). Use `0` for testing. | +| `--delay` | Maximum random response delay in milliseconds (default `0`). Use `0` for fastest testing. | + +### Verify the Server is Running + +In another terminal, check that the endpoint returns valid data: + +```bash +curl http://localhost:8090/v1/chain/confirmed_block_info +``` + +Expected output: + +```json +{ + "code": 0, + "message": "OK", + "data": { + "height": 1000000, + "appHash": "0x3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b", + "blockHash": "0x1234abcd5678ef901234567890abcdef1234567890abcdef1234567890abcdef" + } +} +``` + +If you get `"data": null`, the server initialization failed. Restart it. + +--- + +## Step 3: Start the Proposer + +In a third terminal, start the proposer with realistic placeholder addresses and keys: + +```bash +main \ + --l1-eth-rpc="http://localhost:8545" \ + --tee-rollup-rpc="http://localhost:8090" \ + --game-type=1960 \ + --game-factory-address="0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77" \ + --private-key="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" \ + --poll-interval=12s \ + --proposal-interval=12s \ + --rpc.port=7302 \ + --log.level=info +``` + +### Flag Reference + +| Flag | Description | +|------|-------------| +| `--l1-eth-rpc` | L1 Ethereum RPC endpoint. Must be running and synced. | +| `--tee-rollup-rpc` | TEE Rollup RPC endpoint (mock or real). Required for game type 1960. | +| `--game-type` | Dispute game type. Must be `1960` for TEE games. | +| `--game-factory-address` | DisputeGameFactory contract address on L1. Deploy or get from devnet. | +| `--private-key` | Proposer wallet private key (without `0x` prefix if only hex). **Must have sufficient ETH for gas.** | +| `--poll-interval` | How often to check L1 state (default `12s`). Shorter = faster proposals. | +| `--proposal-interval` | Minimum time between proposals (default `12s`). Space out proposals to avoid nonce conflicts. | +| `--rpc.port` | Port for proposer's own RPC server (default `8544`). Use `7302` to avoid conflicts. | +| `--log.level` | Log verbosity: `debug`, `info`, `warn`, `error`. Use `info` for normal testing. | + +**Note:** `--tee-rollup-rpc` is mutually exclusive with `--rollup-rpc`, `--supervisor-rpcs`, and other super-node RPCs. + +### Expected Startup Logs + +Within the first few seconds: + +``` +msg="Connected to DisputeGameFactory" address=0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77 +msg="Started RPC server" endpoint=http://[::]:7302 +msg="Proposer started" +``` + +Within ~12 seconds (one proposal interval): + +``` +msg="No proposals found for at least proposal interval, submitting proposal now" +msg="Proposing output root" sequenceNum=1000012 extraData="0x..." +msg="Transaction confirmed" tx=0xabcd... +``` + +If the proposer waits longer, check that: +- Mock TeeRollup is running and reachable at `--tee-rollup-rpc` +- L1 RPC is running at `--l1-eth-rpc` +- The private key's address has ETH for gas + +--- + +## Step 4: Verify Games with list_games.sh + +In a fourth terminal, use the list_games.sh script to confirm that games were created: + +```bash +cd /Users/jimmyshi/code/optimism/op-proposer/mock +./list_games.sh \ + --rpc http://localhost:8545 \ + --factory 0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77 \ + --count 5 +``` + +### Flag Reference + +| Flag | Description | +|------|-------------| +| `--rpc` | L1 RPC endpoint (same as proposer's `--l1-eth-rpc`). | +| `--factory` | DisputeGameFactory address (same as proposer's `--game-factory-address`). | +| `--count` | Number of latest games to list (default `10`). | + +### Expected Output + +``` +Game 0: + Type: 1960 + Timestamp: 1710892800 + Proxy: 0xa5875EdD032eFbe7773084ae23C588daC243bc01 + +Game 1: + Type: 1960 + Timestamp: 1710892812 + Proxy: 0xb7c96ee3f2c1d4f8a9e0b2c3d4e5f6a7b8c9d0e +``` + +Key observations: +- **Type must be `1960`** (if type is not 1960, the game was not created as a TEE game) +- **Timestamps increase** (each new game is ~12 seconds apart, matching proposal-interval) +- **Proxy addresses differ** (each game gets a unique proxy contract) + +--- + +## Step 5: Verify rootClaim (Optional) + +To verify that the rootClaim was computed correctly, fetch the game's claim data using cast: + +```bash +# Use the proxy address from list_games.sh output +GAME_ADDR=0xa5875EdD032eFbe7773084ae23C588daC243bc01 +L1_RPC="http://localhost:8545" + +# Read the claim fields +cast call $GAME_ADDR "rootClaim()(bytes32)" --rpc-url $L1_RPC +cast call $GAME_ADDR "blockHash()(bytes32)" --rpc-url $L1_RPC +cast call $GAME_ADDR "stateHash()(bytes32)" --rpc-url $L1_RPC +``` + +Example output: + +``` +blockHash: 0x1234abcd5678ef901234567890abcdef1234567890abcdef1234567890abcdef +stateHash: 0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba +rootClaim: 0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 +``` + +### Verify the Formula + +The rootClaim must equal `keccak256(blockHash || stateHash)`: + +```bash +BLOCK_HASH="0x1234abcd5678ef901234567890abcdef1234567890abcdef1234567890abcdef" +STATE_HASH="0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba" + +# Compute expected rootClaim +EXPECTED=$(cast keccak $(cast concat-hex $BLOCK_HASH $STATE_HASH)) +echo "Expected rootClaim: $EXPECTED" + +# Compare with actual +ACTUAL=$(cast call $GAME_ADDR "rootClaim()(bytes32)" --rpc-url $L1_RPC) +echo "Actual rootClaim: $ACTUAL" +``` + +Both values must match. + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| `tee-rollup: no confirmed block available (data is null)` | Mock server not running or endpoint unreachable | Check `mockteerpc` is running; verify `--tee-rollup-rpc` URL is correct | +| `tee-rollup-rpc is required for TeeRollup game type (1960)` | Missing `--tee-rollup-rpc` flag | Add `--tee-rollup-rpc=http://localhost:8090` | +| `l2SequenceNumber() <= anchorSeqNum` | Mock init-height is below anchor state sequence number | Restart `mockteerpc` with higher `--init-height` (e.g., `1000000`) | +| `nonce too low` | Multiple proposer instances running concurrently | Stop all proposer processes; restart only one instance | +| `failed to bind to address "0.0.0.0:7302"` | RPC port already in use | Use a different `--rpc.port` (e.g., `7303`, `7304`) | +| `connection refused` (L1 RPC) | L1 node not running or wrong address | Ensure L1 node is running at `--l1-eth-rpc` | +| `insufficient funds` | Proposer wallet has no ETH | Fund the wallet address derived from `--private-key` | +| `game type 1960 not registered` | DisputeGameFactory not properly configured | Verify game type 1960 is registered in the factory; deploy TestGame contract | + +--- + +## Tips for Local Testing + +- **Speed up iteration:** Use lower values for `--poll-interval` and `--proposal-interval` (e.g., `5s`) to submit games faster. +- **Simulate network delay:** Set `--delay=500` on the mock server to test timeout handling. +- **Simulate errors:** Set `--error-rate=0.1` to randomly fail 10% of mock requests and test retry logic. +- **Watch continuously:** Use `watch` to monitor games in real-time: + ```bash + watch -n 2 './list_games.sh --rpc http://localhost:8545 --factory 0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77 --count 3' + ``` +- **Inspect logs:** Set `--log.level=debug` on the proposer to see detailed execution flow (verbose output). + +--- + +## Next Steps + +Once basic testing works: + +1. Run `op-proposer` unit tests: `go test ./proposer/... -v` +2. Test with a real TeeRollup (not mock) by pointing `--tee-rollup-rpc` to the real endpoint +3. Run end-to-end tests with `op-challenger` and dispute resolution diff --git a/op-proposer/mock/cmd/mockteerpc/main.go b/op-proposer/mock/cmd/mockteerpc/main.go new file mode 100644 index 0000000000000..d074483445be5 --- /dev/null +++ b/op-proposer/mock/cmd/mockteerpc/main.go @@ -0,0 +1,163 @@ +// Command mockteerpc runs a standalone mock TeeRollup HTTP server for local development and curl testing. +// +// Usage: +// +// go run ./mock/cmd/mockteerpc [--addr :8090] +package main + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +type response struct { + Code int `json:"code"` + Message string `json:"message"` + Data *data `json:"data"` +} + +type data struct { + Height uint64 `json:"height"` + AppHash string `json:"appHash"` + BlockHash string `json:"blockHash"` +} + +type server struct { + mu sync.RWMutex + height uint64 + errorRate float64 + maxDelay time.Duration +} + +func (s *server) tick() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for range ticker.C { + delta := uint64(rand.Intn(50) + 1) + s.mu.Lock() + s.height += delta + s.mu.Unlock() + s.mu.RLock() + log.Printf("tick: height=%d delta=%d", s.height, delta) + s.mu.RUnlock() + } +} + +func computeAppHash(height uint64) [32]byte { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], height) + return crypto.Keccak256Hash(buf[:]) +} + +func computeBlockHash(appHash [32]byte) [32]byte { + return crypto.Keccak256Hash(appHash[:]) +} + +func (s *server) handleConfirmedBlockInfo(w http.ResponseWriter, r *http.Request) { + start := time.Now() + log.Printf("[mockteerpc] received %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Random delay in [0, maxDelay]. + if s.maxDelay > 0 { + delay := time.Duration(rand.Int63n(int64(s.maxDelay) + 1)) + time.Sleep(delay) + } + + w.Header().Set("Content-Type", "application/json") + + if s.errorRate > 0 && rand.Float64() < s.errorRate { + writeErrorResponse(w) + log.Printf("[mockteerpc] responded with error (took %s)", time.Since(start)) + return + } + + s.mu.RLock() + h := s.height + s.mu.RUnlock() + + appHash := computeAppHash(h) + blockHash := computeBlockHash(appHash) + + appHashStr := "0x" + hex.EncodeToString(appHash[:]) + resp := response{ + Code: 0, + Message: "OK", + Data: &data{ + Height: h, + AppHash: appHashStr, + BlockHash: "0x" + hex.EncodeToString(blockHash[:]), + }, + } + _ = json.NewEncoder(w).Encode(resp) + log.Printf("[mockteerpc] responded height=%d appHash=%s (took %s)", h, appHashStr[:10]+"...", time.Since(start)) +} + +func writeErrorResponse(w http.ResponseWriter) { + type nullableData struct { + Height *uint64 `json:"height"` + AppHash *string `json:"appHash"` + BlockHash *string `json:"blockHash"` + } + type respNoData struct { + Code int `json:"code"` + Message string `json:"message"` + } + type respWithData struct { + Code int `json:"code"` + Message string `json:"message"` + Data *nullableData `json:"data"` + } + + switch rand.Intn(3) { + case 0: // code != 0, no data field + _ = json.NewEncoder(w).Encode(respNoData{Code: 1, Message: "internal server error"}) + case 1: // code == 0, data is null + _ = json.NewEncoder(w).Encode(respWithData{Code: 0, Message: "OK", Data: nil}) + case 2: // code == 0, data present but all fields null + _ = json.NewEncoder(w).Encode(respWithData{Code: 0, Message: "OK", Data: &nullableData{}}) + } +} + +func main() { + addr := flag.String("addr", ":8090", "listen address") + initHeight := flag.Uint64("init-height", 1000, "initial block height") + errorRate := flag.Float64("error-rate", 0, "probability [0.0, 1.0] of returning an error response") + maxDelay := flag.Duration("delay", time.Second, "maximum random response delay (actual delay is random in [0, delay])") + flag.Parse() + + if *errorRate < 0 || *errorRate > 1 { + log.Fatalf("--error-rate must be in [0.0, 1.0], got %f", *errorRate) + } + + s := &server{height: *initHeight, errorRate: *errorRate, maxDelay: *maxDelay} + go s.tick() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/chain/confirmed_block_info", s.handleConfirmedBlockInfo) + + fmt.Printf("mock TeeRollup server listening on %s\n", *addr) + fmt.Printf("initial height: %d\n", *initHeight) + fmt.Printf("error rate: %.1f%%\n", *errorRate*100) + fmt.Printf("max delay: %s\n", *maxDelay) + fmt.Println("endpoint: GET /v1/chain/confirmed_block_info") + fmt.Println() + + if err := http.ListenAndServe(*addr, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/op-proposer/mock/list_games.sh b/op-proposer/mock/list_games.sh new file mode 100755 index 0000000000000..b08851c2f2da6 --- /dev/null +++ b/op-proposer/mock/list_games.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# list_games.sh — Print the last N TEE dispute games from the factory. +# Usage: ./list_games.sh --rpc --factory [--count ] +set -euo pipefail + +usage() { + echo "Usage: $0 --rpc --factory [--count ]" + echo "" + echo " --rpc L1 RPC endpoint (e.g. http://localhost:8545)" + echo " --factory DisputeGameFactory contract address" + echo " --count Number of games to show, newest first (default: 10)" + exit 1 +} + +COUNT=10 +RPC="" +FACTORY="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --rpc) RPC="$2"; shift 2 ;; + --factory) FACTORY="$2"; shift 2 ;; + --count) COUNT="$2"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; usage ;; + esac +done + +[[ -z "$RPC" ]] && { echo "ERROR: --rpc is required" >&2; usage; } +[[ -z "$FACTORY" ]] && { echo "ERROR: --factory is required" >&2; usage; } + +echo "Factory: $FACTORY" +TOTAL=$(cast call "$FACTORY" "gameCount()(uint256)" --rpc-url "$RPC") +echo "Total games: $TOTAL" +echo "" + +if [[ "$TOTAL" -eq 0 ]]; then + echo "No games yet." + exit 0 +fi + +if [[ "$COUNT" -gt "$TOTAL" ]]; then + COUNT="$TOTAL" +fi + +for (( i = TOTAL - 1; i >= TOTAL - COUNT; i-- )); do + INFO=$(cast call "$FACTORY" "gameAtIndex(uint256)(uint8,uint64,address)" "$i" --rpc-url "$RPC") + GAME_TYPE=$(echo "$INFO" | awk 'NR==1') + TIMESTAMP=$(echo "$INFO" | awk 'NR==2') + ADDR=$(echo "$INFO" | awk 'NR==3') + + PARENT_IDX=$( cast call "$ADDR" "parentIndex()(uint32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + STARTING_BN=$( cast call "$ADDR" "startingBlockNumber()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + L2_BLOCK=$( cast call "$ADDR" "l2BlockNumber()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + STARTING_HASH=$( cast call "$ADDR" "startingRootHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + ROOT_CLAIM=$( cast call "$ADDR" "rootClaim()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + BLOCK_HASH=$( cast call "$ADDR" "blockHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + STATE_HASH=$( cast call "$ADDR" "stateHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + PROPOSER_ADDR=$( cast call "$ADDR" "proposer()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + GAME_CREATOR=$( cast call "$ADDR" "gameCreator()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + + RAW_TS=$(echo "$TIMESTAMP" | awk '{print $1}') + TS_HUMAN=$(date -r "$RAW_TS" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -d "@$RAW_TS" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$TIMESTAMP") + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Index: $i" + echo "GameType: $GAME_TYPE" + echo "Address: $ADDR" + echo "CreatedAt: $TS_HUMAN" + echo "GameCreator: $GAME_CREATOR" + echo "Proposer: $PROPOSER_ADDR" + echo "ParentIndex: $PARENT_IDX" + echo "StartingBlockNumber: $STARTING_BN" + echo "L2BlockNumber: $L2_BLOCK" + echo "StartingRootHash: $STARTING_HASH" + echo "RootClaim: $ROOT_CLAIM" + echo "BlockHash: $BLOCK_HASH" + echo "StateHash: $STATE_HASH" +done + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/op-proposer/mock/mock_tee_rollup_server.go b/op-proposer/mock/mock_tee_rollup_server.go new file mode 100644 index 0000000000000..5c4c7cf33ca8a --- /dev/null +++ b/op-proposer/mock/mock_tee_rollup_server.go @@ -0,0 +1,227 @@ +package mock + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "log" + "math/rand" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +// TeeRollupResponse is the normal JSON shape returned by GET /v1/chain/confirmed_block_info. +type TeeRollupResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Height uint64 `json:"height"` + AppHash string `json:"appHash"` + BlockHash string `json:"blockHash"` + } `json:"data"` +} + +// Option configures a TeeRollupServer. +type Option func(*TeeRollupServer) + +// WithErrorRate sets the probability [0.0, 1.0] that any given RPC call returns an error. +// Three error types are equally likely when an error occurs: +// 1. code != 0, no data field, only message. +// 2. code == 0, data is null. +// 3. code == 0, data is present but all fields (height, appHash, blockHash) are null. +func WithErrorRate(rate float64) Option { + return func(s *TeeRollupServer) { + s.errorRate = rate + } +} + +// WithMaxDelay sets the maximum random response delay. Each request sleeps for a +// random duration in [0, maxDelay]. Default is 1s. +func WithMaxDelay(d time.Duration) Option { + return func(s *TeeRollupServer) { + s.maxDelay = d + } +} + +// TeeRollupServer is a mock TeeRollup HTTP server for testing. +// Height starts at 1000 and increments by a random value in [1, 50] every second. +type TeeRollupServer struct { + server *httptest.Server + mu sync.RWMutex + height uint64 + errorRate float64 + maxDelay time.Duration + stopCh chan struct{} + doneCh chan struct{} + closeOnce sync.Once +} + +// NewTeeRollupServer starts the mock server and its background tick goroutine. +// Close() is registered via t.Cleanup so callers need not call it explicitly. +func NewTeeRollupServer(t *testing.T, opts ...Option) *TeeRollupServer { + t.Helper() + + m := &TeeRollupServer{ + height: 1000, + maxDelay: time.Second, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } + for _, opt := range opts { + opt(m) + } + + mux := http.NewServeMux() + mux.HandleFunc("/v1/chain/confirmed_block_info", m.handleConfirmedBlockInfo) + + m.server = httptest.NewServer(mux) + + go m.tick() + + t.Cleanup(m.Close) + return m +} + +// Addr returns the base URL (scheme + host) of the test server. +func (m *TeeRollupServer) Addr() string { + return m.server.URL +} + +// Close stops the tick goroutine and shuts down the HTTP server. +// Safe to call multiple times. +func (m *TeeRollupServer) Close() { + m.closeOnce.Do(func() { + close(m.stopCh) + <-m.doneCh + m.server.Close() + }) +} + +// CurrentInfo returns the current height, appHash and blockHash snapshot. +// Useful for assertions in tests without making an HTTP round-trip. +func (m *TeeRollupServer) CurrentInfo() (height uint64, appHash, blockHash [32]byte) { + m.mu.RLock() + h := m.height + m.mu.RUnlock() + + appHash = ComputeAppHash(h) + blockHash = ComputeBlockHash(appHash) + return h, appHash, blockHash +} + +// tick increments height by random(1, 50) every second until Close() is called. +func (m *TeeRollupServer) tick() { + defer close(m.doneCh) + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + delta := uint64(rand.Intn(50) + 1) // [1, 50] + m.mu.Lock() + m.height += delta + m.mu.Unlock() + } + } +} + +// handleConfirmedBlockInfo serves GET /v1/chain/confirmed_block_info. +func (m *TeeRollupServer) handleConfirmedBlockInfo(w http.ResponseWriter, r *http.Request) { + start := time.Now() + log.Printf("[mockteerpc] received %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + + // Random delay in [0, maxDelay]. + if m.maxDelay > 0 { + delay := time.Duration(rand.Int63n(int64(m.maxDelay) + 1)) + time.Sleep(delay) + } + + w.Header().Set("Content-Type", "application/json") + + // Inject error according to configured error rate. + if m.errorRate > 0 && rand.Float64() < m.errorRate { + writeErrorResponse(w) + log.Printf("[mockteerpc] responded with error (took %s)", time.Since(start)) + return + } + + m.mu.RLock() + h := m.height + m.mu.RUnlock() + + appHash := ComputeAppHash(h) + blockHash := ComputeBlockHash(appHash) + + resp := TeeRollupResponse{Code: 0, Message: "OK"} + resp.Data.Height = h + resp.Data.AppHash = "0x" + hex.EncodeToString(appHash[:]) + resp.Data.BlockHash = "0x" + hex.EncodeToString(blockHash[:]) + + _ = json.NewEncoder(w).Encode(resp) + log.Printf("[mockteerpc] responded height=%d appHash=%s (took %s)", h, resp.Data.AppHash[:10]+"...", time.Since(start)) +} + +// writeErrorResponse writes one of three error shapes, chosen at random. +// +// Type 0: code != 0, no data field. +// Type 1: code == 0, data is null. +// Type 2: code == 0, data present but all fields are null. +func writeErrorResponse(w http.ResponseWriter) { + type nullableFields struct { + Height *uint64 `json:"height"` + AppHash *string `json:"appHash"` + BlockHash *string `json:"blockHash"` + } + // type 0: no data field + type respNoData struct { + Code int `json:"code"` + Message string `json:"message"` + } + // type 1 & 2: has data field (null or with null fields) + type respWithData struct { + Code int `json:"code"` + Message string `json:"message"` + Data *nullableFields `json:"data"` + } + + switch rand.Intn(3) { + case 0: // code != 0, no data field + _ = json.NewEncoder(w).Encode(respNoData{ + Code: 1, + Message: "internal server error", + }) + case 1: // code == 0, data is null + _ = json.NewEncoder(w).Encode(respWithData{ + Code: 0, + Message: "OK", + Data: nil, + }) + case 2: // code == 0, data present but all fields are null + _ = json.NewEncoder(w).Encode(respWithData{ + Code: 0, + Message: "OK", + Data: &nullableFields{}, // all pointer fields are nil → JSON null + }) + } +} + +// ComputeAppHash returns keccak256(big-endian uint64 bytes of height). +func ComputeAppHash(height uint64) [32]byte { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], height) + return crypto.Keccak256Hash(buf[:]) +} + +// ComputeBlockHash returns keccak256(appHash[:]). +func ComputeBlockHash(appHash [32]byte) [32]byte { + return crypto.Keccak256Hash(appHash[:]) +} diff --git a/op-proposer/mock/mock_tee_rollup_server_test.go b/op-proposer/mock/mock_tee_rollup_server_test.go new file mode 100644 index 0000000000000..81b71ce0dee49 --- /dev/null +++ b/op-proposer/mock/mock_tee_rollup_server_test.go @@ -0,0 +1,62 @@ +package mock_test + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-proposer/mock" + "github.com/stretchr/testify/require" +) + +func TestTeeRollupServer_Basic(t *testing.T) { + srv := mock.NewTeeRollupServer(t) + + // --- first request --- + resp, err := http.Get(srv.Addr() + "/v1/chain/confirmed_block_info") //nolint:noctx + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body mock.TeeRollupResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + + require.Equal(t, 0, body.Code) + require.Equal(t, "OK", body.Message) + require.GreaterOrEqual(t, body.Data.Height, uint64(1000)) + require.Equal(t, 66, len(body.Data.AppHash), "appHash should be 0x + 64 hex chars") + require.Equal(t, 66, len(body.Data.BlockHash), "blockHash should be 0x + 64 hex chars") + + firstHeight := body.Data.Height + + // --- wait for at least one tick --- + time.Sleep(1500 * time.Millisecond) + + resp2, err := http.Get(srv.Addr() + "/v1/chain/confirmed_block_info") //nolint:noctx + require.NoError(t, err) + defer resp2.Body.Close() + + var body2 mock.TeeRollupResponse + require.NoError(t, json.NewDecoder(resp2.Body).Decode(&body2)) + + require.Greater(t, body2.Data.Height, firstHeight, "height should have increased after 1.5s") + + // --- verify CurrentInfo height is >= last observed HTTP height --- + h, _, _ := srv.CurrentInfo() + require.GreaterOrEqual(t, h, body2.Data.Height, + "CurrentInfo height should be >= last HTTP response height") + + // --- verify hash determinism --- + appHash := mock.ComputeAppHash(body2.Data.Height) + require.Equal(t, "0x"+hex.EncodeToString(appHash[:]), body2.Data.AppHash) + blockHash := mock.ComputeBlockHash(appHash) + require.Equal(t, "0x"+hex.EncodeToString(blockHash[:]), body2.Data.BlockHash) +} + +func TestTeeRollupServer_DoubleClose(t *testing.T) { + srv := mock.NewTeeRollupServer(t) + // Explicit close before t.Cleanup runs — must not panic. + require.NotPanics(t, srv.Close) +} diff --git a/op-proposer/proposer/config.go b/op-proposer/proposer/config.go index 22420c78bf1ef..6ad8e2da4c2e8 100644 --- a/op-proposer/proposer/config.go +++ b/op-proposer/proposer/config.go @@ -15,11 +15,15 @@ import ( "github.com/ethereum-optimism/optimism/op-service/txmgr" ) +// For xlayer: TEEGameType is the dispute game type ID for TeeRollup TEE attestations. +const TEEGameType uint32 = 1960 + var ( ErrMissingRollupRpc = errors.New("missing rollup rpc") ErrMissingSupervisorRpc = errors.New("missing supervisor rpc or supernode rpc") ErrMissingSource = errors.New("missing proposal source rpc (rollup, supervisor, or supernode)") ErrConflictingSource = errors.New("must specify exactly one of rollup rpc, supervisor rpc, or supernode rpc") + ErrMissingTeeRollupRpc = errors.New("tee-rollup-rpc is required for TeeRollup game type (1960)") // For xlayer // preInteropGameTypes are game types that enforce having a rollup rpc. // It is ok if this list isn't complete, unknown game types will allow either rollup or supervisor @@ -82,6 +86,9 @@ type CLIConfig struct { // Whether to wait for the sequencer to sync to a recent block at startup. WaitNodeSync bool + + // For xlayer: TeeRollupRpc is the TeeRollup RPC service base URL for game type 1960. + TeeRollupRpc string } func (c *CLIConfig) Check() error { @@ -118,6 +125,9 @@ func (c *CLIConfig) Check() error { if len(c.SuperNodeRpcs) != 0 { sourceCount++ } + if c.TeeRollupRpc != "" { // For xlayer + sourceCount++ + } if sourceCount > 1 { return ErrConflictingSource } @@ -129,6 +139,10 @@ func (c *CLIConfig) Check() error { if c.DGFAddress != "" && slices.Contains(postInteropGameTypes, c.DisputeGameType) && len(c.SupervisorRpcs) == 0 && len(c.SuperNodeRpcs) == 0 { return ErrMissingSupervisorRpc } + // For xlayer: TeeRollup game type requires TeeRollupRpc + if c.DisputeGameType == TEEGameType && c.TeeRollupRpc == "" { + return ErrMissingTeeRollupRpc + } // For unknown game types, allow any source, but require at least one. if sourceCount == 0 { return ErrMissingSource @@ -155,5 +169,6 @@ func NewConfig(ctx *cli.Context) *CLIConfig { DisputeGameType: uint32(ctx.Uint(flags.DisputeGameTypeFlag.Name)), ActiveSequencerCheckDuration: ctx.Duration(flags.ActiveSequencerCheckDurationFlag.Name), WaitNodeSync: ctx.Bool(flags.WaitNodeSyncFlag.Name), + TeeRollupRpc: ctx.String(flags.TeeRollupRpcFlag.Name), // For xlayer } } diff --git a/op-proposer/proposer/config_test.go b/op-proposer/proposer/config_test.go index 08fac84f9f054..5f1de05de6212 100644 --- a/op-proposer/proposer/config_test.go +++ b/op-proposer/proposer/config_test.go @@ -138,6 +138,33 @@ func TestRequireSomeRPCSourceForUnknownGameTypes(t *testing.T) { require.ErrorIs(t, cfg.Check(), ErrMissingSource) } +// For xlayer: Tests for TeeRollup game type (1960) config validation. +func TestTeeRollupRpc(t *testing.T) { + t.Run("RequiredForTEEGameType", func(t *testing.T) { + cfg := validConfig() + cfg.DGFAddress = common.Address{0xaa}.Hex() + cfg.ProposalInterval = 20 + cfg.RollupRpc = "" + cfg.SupervisorRpcs = nil + cfg.SuperNodeRpcs = nil + cfg.TeeRollupRpc = "" + cfg.DisputeGameType = 1960 + require.ErrorIs(t, cfg.Check(), ErrMissingTeeRollupRpc) + }) + + t.Run("ValidWithTEEGameType", func(t *testing.T) { + cfg := validConfig() + cfg.DGFAddress = common.Address{0xaa}.Hex() + cfg.ProposalInterval = 20 + cfg.RollupRpc = "" + cfg.SupervisorRpcs = nil + cfg.SuperNodeRpcs = nil + cfg.TeeRollupRpc = "http://localhost:9999/tee-rollup" + cfg.DisputeGameType = 1960 + require.NoError(t, cfg.Check()) + }) +} + func validConfig() *CLIConfig { return &CLIConfig{ L1EthRpc: "http://localhost:8888/l1", diff --git a/op-proposer/proposer/service.go b/op-proposer/proposer/service.go index dec9a87f7625a..27294e8ffa7da 100644 --- a/op-proposer/proposer/service.go +++ b/op-proposer/proposer/service.go @@ -123,6 +123,12 @@ func (ps *ProposerService) initFromCLIConfig(ctx context.Context, version string } func (ps *ProposerService) initRPCClients(ctx context.Context, cfg *CLIConfig) error { + // For xlayer: TeeRollup has no L1 derivation; CurrentL1 is always zero, + // which would cause waitNodeSync to block forever. + if cfg.DisputeGameType == TEEGameType && cfg.WaitNodeSync { // For xlayer + return fmt.Errorf("--wait-node-sync is not supported with TeeRollup game type (1960)") + } + l1Client, err := dial.DialEthClientWithTimeout(ctx, dial.DefaultDialTimeout, ps.Log, cfg.L1EthRpc) if err != nil { return fmt.Errorf("failed to dial L1 RPC: %w", err) @@ -166,6 +172,14 @@ func (ps *ProposerService) initRPCClients(ctx context.Context, cfg *CLIConfig) e } ps.ProposalSource = source.NewSuperNodeProposalSource(ps.Log, clients...) } + // For xlayer: initialize TeeRollup proposal source for game type 1960 + if cfg.TeeRollupRpc != "" { + teeRollupClient, err := source.NewTeeRollupHTTPClient(cfg.TeeRollupRpc) + if err != nil { + return fmt.Errorf("failed to create TeeRollup HTTP client: %w", err) + } + ps.ProposalSource = source.NewTeeRollupProposalSource(ps.Log, teeRollupClient) + } if ps.ProposalSource == nil { return ErrMissingSource } diff --git a/op-proposer/proposer/source/source.go b/op-proposer/proposer/source/source.go index 5f584f341495b..d4f0d904cfd05 100644 --- a/op-proposer/proposer/source/source.go +++ b/op-proposer/proposer/source/source.go @@ -23,6 +23,17 @@ type Proposal struct { // Legacy provides data that is only available when retrieving data from a single rollup node. // It should only be used for optional logs and metrics. Legacy LegacyProposalData + + // For xlayer: TeeRollupData is present if this is a TEE game type 1960 proposal + TeeRollupData *TeeRollupProposalData +} + +// For xlayer: TEE proposal data for TeeRollup game type (1960) +type TeeRollupProposalData struct { + L2SeqNum uint64 + ParentIdx uint32 // 4 bytes; l2SeqNum encoded as uint256 (32 bytes) → total extraData 100 bytes + BlockHash common.Hash + StateHash common.Hash } // IsSuperRootProposal returns true if the proposal is a Super Root proposal. @@ -30,10 +41,15 @@ func (p *Proposal) IsSuperRootProposal() bool { return p.Super != nil } +// For xlayer: IsTEEProposal returns true if the proposal is a TeeRollup TEE proposal. +func (p *Proposal) IsTEEProposal() bool { return p.TeeRollupData != nil } + // ExtraData returns the Dispute Game extra data as appropriate for the proposal type. func (p *Proposal) ExtraData() []byte { if p.Super != nil { return p.Super.Marshal() + } else if p.TeeRollupData != nil { // For xlayer + return encodeTeeRollupExtraData(p.TeeRollupData) } else { var extraData [32]byte binary.BigEndian.PutUint64(extraData[24:], p.SequenceNum) @@ -41,6 +57,24 @@ func (p *Proposal) ExtraData() []byte { } } +// For xlayer: encodeTeeRollupExtraData encodes TeeRollup proposal data into 100 bytes. +// Byte layout (abi.encodePacked), matching TeeDisputeGame.sol: +// +// [0:32] l2SeqNum — uint256 big-endian (uint64 value in last 8 bytes, first 24 bytes zero-padded) +// [32:36] parentIdx — uint32 big-endian +// [36:68] blockHash — bytes32 +// [68:100] stateHash — bytes32 +func encodeTeeRollupExtraData(d *TeeRollupProposalData) []byte { + // For xlayer: l2SeqNum encoded as uint256 (32 bytes, big-endian), parentIdx uint32 (4 bytes), + // blockHash bytes32 (32 bytes), stateHash bytes32 (32 bytes) = 100 bytes total. + var buf [100]byte + binary.BigEndian.PutUint64(buf[24:32], d.L2SeqNum) // uint256, value in last 8 bytes + binary.BigEndian.PutUint32(buf[32:36], d.ParentIdx) + copy(buf[36:68], d.BlockHash.Bytes()) + copy(buf[68:100], d.StateHash.Bytes()) + return buf[:] +} + type LegacyProposalData struct { HeadL1 eth.L1BlockRef SafeL2 eth.L2BlockRef diff --git a/op-proposer/proposer/source/source_tee_rollup_xlayer.go b/op-proposer/proposer/source/source_tee_rollup_xlayer.go new file mode 100644 index 0000000000000..1593aa1af0f9e --- /dev/null +++ b/op-proposer/proposer/source/source_tee_rollup_xlayer.go @@ -0,0 +1,231 @@ +// For xlayer: TeeRollup HTTP client and proposal source for TEE game type 1960. +package source + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "sync" + "time" + + lru "github.com/hashicorp/golang-lru/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// For xlayer: TeeRollupBlockInfo holds confirmed block info returned by the TeeRollup RPC. +type TeeRollupBlockInfo struct { + Height uint64 + AppHash common.Hash + BlockHash common.Hash +} + +// internal JSON parsing types (pointer fields to distinguish JSON null) +type teeRollupRawResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data *teeRollupData `json:"data"` +} + +type teeRollupData struct { + Height *uint64 `json:"height"` + AppHash *string `json:"appHash"` + BlockHash *string `json:"blockHash"` +} + +// For xlayer: TeeRollupClient is the interface for the TeeRollup RPC client. +type TeeRollupClient interface { + ConfirmedBlockInfo(ctx context.Context) (TeeRollupBlockInfo, error) + ConfirmedBlockInfoAtHeight(ctx context.Context, height uint64) (TeeRollupBlockInfo, error) + Close() +} + +// For xlayer: TeeRollupHTTPClient implements TeeRollupClient using HTTP REST. +type TeeRollupHTTPClient struct { + baseURL string + httpClient *http.Client + cache *lru.Cache[uint64, TeeRollupBlockInfo] +} + +// For xlayer: NewTeeRollupHTTPClient creates a new TeeRollupHTTPClient. +func NewTeeRollupHTTPClient(baseURL string) (*TeeRollupHTTPClient, error) { + cache, err := lru.New[uint64, TeeRollupBlockInfo](16) + if err != nil { + return nil, fmt.Errorf("tee-rollup: failed to create LRU cache: %w", err) + } + return &TeeRollupHTTPClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + cache: cache, + }, nil +} + +// For xlayer: ConfirmedBlockInfo fetches the latest confirmed block info from TeeRollup RPC. +// GET /v1/chain/confirmed_block_info +func (c *TeeRollupHTTPClient) ConfirmedBlockInfo(ctx context.Context) (TeeRollupBlockInfo, error) { + url := c.baseURL + "/v1/chain/confirmed_block_info" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: failed to create request: %w", err) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: HTTP request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: failed to read response body: %w", err) + } + + var raw teeRollupRawResponse + if err := json.Unmarshal(body, &raw); err != nil { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: failed to parse response: %w", err) + } + + if raw.Code != 0 { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: RPC error code=%d message=%s", raw.Code, raw.Message) + } + if raw.Data == nil { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: no confirmed block available (data is null)") + } + if raw.Data.Height == nil || raw.Data.AppHash == nil || raw.Data.BlockHash == nil { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: confirmed block info incomplete (null fields)") + } + + info := TeeRollupBlockInfo{ + Height: *raw.Data.Height, + AppHash: common.HexToHash(*raw.Data.AppHash), + BlockHash: common.HexToHash(*raw.Data.BlockHash), + } + c.cache.Add(info.Height, info) + return info, nil +} + +// For xlayer: ConfirmedBlockInfoAtHeight fetches confirmed block info at a specific height. +// Returns from LRU cache if available; otherwise fetches from RPC and validates exact height match. +func (c *TeeRollupHTTPClient) ConfirmedBlockInfoAtHeight(ctx context.Context, height uint64) (TeeRollupBlockInfo, error) { + if cached, ok := c.cache.Get(height); ok { + return cached, nil + } + info, err := c.ConfirmedBlockInfo(ctx) + if err != nil { + return TeeRollupBlockInfo{}, err + } + if info.Height != height { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: confirmed block height mismatch: expected %d, got %d", height, info.Height) + } + return info, nil +} + +// For xlayer: Close is a no-op for HTTP client (satisfies TeeRollupClient interface). +func (c *TeeRollupHTTPClient) Close() {} + +// For xlayer: TeeRollupProposalSource implements ProposalSource for TeeRollup TEE game type 1960. +type TeeRollupProposalSource struct { + log log.Logger + clients []TeeRollupClient +} + +// For xlayer: NewTeeRollupProposalSource creates a new TeeRollupProposalSource. +func NewTeeRollupProposalSource(log log.Logger, clients ...TeeRollupClient) *TeeRollupProposalSource { + if len(clients) == 0 { + panic("no TeeRollup clients provided") // For xlayer + } + return &TeeRollupProposalSource{ + log: log, + clients: clients, + } +} + +// For xlayer: SyncStatus queries all clients in parallel and returns the most conservative (lowest) height. +// CurrentL1 is always zero value — TeeRollup has no L1 derivation. +func (s *TeeRollupProposalSource) SyncStatus(ctx context.Context) (SyncStatus, error) { + type result struct { + info TeeRollupBlockInfo + err error + } + results := make([]result, len(s.clients)) + var wg sync.WaitGroup + for i, cl := range s.clients { + wg.Add(1) + go func(idx int, client TeeRollupClient) { + defer wg.Done() + info, err := client.ConfirmedBlockInfo(ctx) + results[idx] = result{info: info, err: err} + }(i, cl) + } + wg.Wait() + + var lowestHeight uint64 + var errs []error + first := true + for _, r := range results { + if r.err != nil { + errs = append(errs, r.err) + continue + } + if first || r.info.Height < lowestHeight { + lowestHeight = r.info.Height + first = false + } + } + if len(errs) == len(s.clients) { + return SyncStatus{}, errors.Join(errs...) + } + return SyncStatus{ + CurrentL1: eth.BlockID{}, // For xlayer: always zero — no L1 derivation + SafeL2: lowestHeight, + FinalizedL2: lowestHeight, + }, nil +} + +// For xlayer: ProposalAtSequenceNum fetches the proposal at the given L2 sequence number. +// Tries clients in order, fails over on error. Only accepts exact height match. +func (s *TeeRollupProposalSource) ProposalAtSequenceNum(ctx context.Context, seqNum uint64) (Proposal, error) { + var lastErr error + for _, cl := range s.clients { + info, err := cl.ConfirmedBlockInfoAtHeight(ctx, seqNum) + if err != nil { + lastErr = err + continue + } + rootClaim := computeRootClaim(info.BlockHash, info.AppHash) + proposal := Proposal{ + Root: rootClaim, + SequenceNum: seqNum, + CurrentL1: eth.BlockID{}, // For xlayer: always zero — no L1 derivation + TeeRollupData: &TeeRollupProposalData{ + L2SeqNum: seqNum, + ParentIdx: math.MaxUint32, // For xlayer: type(uint32).max signals "no parent, use anchor state" in TeeDisputeGame + BlockHash: info.BlockHash, + StateHash: info.AppHash, + }, + } + return proposal, nil + } + return Proposal{}, fmt.Errorf("tee-rollup: all clients failed for seqNum=%d: %w", seqNum, lastErr) +} + +// For xlayer: computeRootClaim computes the root claim as keccak256(abi.encode(blockHash, stateHash)). +// abi.encode of two bytes32 values = 64 bytes (each padded to 32 bytes). +func computeRootClaim(blockHash, stateHash common.Hash) common.Hash { + return crypto.Keccak256Hash(append(blockHash.Bytes(), stateHash.Bytes()...)) +} + +// For xlayer: Close closes all underlying TeeRollup clients. +func (s *TeeRollupProposalSource) Close() { + for _, cl := range s.clients { + cl.Close() + } +} diff --git a/op-proposer/proposer/source/source_tee_rollup_xlayer_test.go b/op-proposer/proposer/source/source_tee_rollup_xlayer_test.go new file mode 100644 index 0000000000000..0f8ec8a07ee20 --- /dev/null +++ b/op-proposer/proposer/source/source_tee_rollup_xlayer_test.go @@ -0,0 +1,262 @@ +// For xlayer: Unit tests for TeeRollup HTTP client and proposal source (TEE game type 1960). +package source + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +// helper to create a test HTTP server with a given JSON response +func newTeeRollupServer(body string, statusCode int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + fmt.Fprint(w, body) + })) +} + +func TestConfirmedBlockInfo_Success(t *testing.T) { + height := uint64(42) + blockHash := "0x1111111111111111111111111111111111111111111111111111111111111111" + appHash := "0x2222222222222222222222222222222222222222222222222222222222222222" + body, _ := json.Marshal(map[string]interface{}{ + "code": 0, + "message": "ok", + "data": map[string]interface{}{ + "height": height, + "blockHash": blockHash, + "appHash": appHash, + }, + }) + srv := newTeeRollupServer(string(body), 200) + defer srv.Close() + + cl, err := NewTeeRollupHTTPClient(srv.URL) + require.NoError(t, err) + + info, err := cl.ConfirmedBlockInfo(context.Background()) + require.NoError(t, err) + require.Equal(t, height, info.Height) + require.Equal(t, common.HexToHash(blockHash), info.BlockHash) + require.Equal(t, common.HexToHash(appHash), info.AppHash) + + // verify LRU cache was populated + cached, ok := cl.cache.Get(height) + require.True(t, ok) + require.Equal(t, info, cached) +} + +func TestConfirmedBlockInfo_ErrorCode(t *testing.T) { + body, _ := json.Marshal(map[string]interface{}{ + "code": 500, + "message": "internal error", + "data": nil, + }) + srv := newTeeRollupServer(string(body), 200) + defer srv.Close() + + cl, err := NewTeeRollupHTTPClient(srv.URL) + require.NoError(t, err) + + _, err = cl.ConfirmedBlockInfo(context.Background()) + require.ErrorContains(t, err, "RPC error code=500") +} + +func TestConfirmedBlockInfo_NullData(t *testing.T) { + body, _ := json.Marshal(map[string]interface{}{ + "code": 0, + "message": "ok", + "data": nil, + }) + srv := newTeeRollupServer(string(body), 200) + defer srv.Close() + + cl, err := NewTeeRollupHTTPClient(srv.URL) + require.NoError(t, err) + + _, err = cl.ConfirmedBlockInfo(context.Background()) + require.ErrorContains(t, err, "data is null") +} + +func TestConfirmedBlockInfo_NullFields(t *testing.T) { + body, _ := json.Marshal(map[string]interface{}{ + "code": 0, + "message": "ok", + "data": map[string]interface{}{ + "height": nil, + "blockHash": nil, + "appHash": nil, + }, + }) + srv := newTeeRollupServer(string(body), 200) + defer srv.Close() + + cl, err := NewTeeRollupHTTPClient(srv.URL) + require.NoError(t, err) + + _, err = cl.ConfirmedBlockInfo(context.Background()) + require.ErrorContains(t, err, "null fields") +} + +func TestConfirmedBlockInfoAtHeight_CacheHit(t *testing.T) { + // Server that returns 500 if called (cache should prevent HTTP request) + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(500) + })) + defer srv.Close() + + cl, err := NewTeeRollupHTTPClient(srv.URL) + require.NoError(t, err) + + // Pre-populate cache + expectedInfo := TeeRollupBlockInfo{Height: 100, BlockHash: common.HexToHash("0x1234"), AppHash: common.HexToHash("0x5678")} + cl.cache.Add(uint64(100), expectedInfo) + + info, err := cl.ConfirmedBlockInfoAtHeight(context.Background(), 100) + require.NoError(t, err) + require.Equal(t, expectedInfo, info) + require.Equal(t, 0, callCount, "HTTP should not be called on cache hit") +} + +func TestConfirmedBlockInfoAtHeight_HeightMismatch(t *testing.T) { + height := uint64(99) // server returns 99, but we request 100 + blockHash := "0x1111111111111111111111111111111111111111111111111111111111111111" + appHash := "0x2222222222222222222222222222222222222222222222222222222222222222" + body, _ := json.Marshal(map[string]interface{}{ + "code": 0, + "message": "ok", + "data": map[string]interface{}{ + "height": height, + "blockHash": blockHash, + "appHash": appHash, + }, + }) + srv := newTeeRollupServer(string(body), 200) + defer srv.Close() + + cl, err := NewTeeRollupHTTPClient(srv.URL) + require.NoError(t, err) + + _, err = cl.ConfirmedBlockInfoAtHeight(context.Background(), 100) + require.ErrorContains(t, err, "height mismatch") +} + +func TestComputeRootClaim(t *testing.T) { + blockHash := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") + stateHash := common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222") + + got := computeRootClaim(blockHash, stateHash) + + expected := crypto.Keccak256Hash(append(blockHash.Bytes(), stateHash.Bytes()...)) + require.Equal(t, expected, got) +} + +func TestEncodeTeeRollupExtraData(t *testing.T) { + d := &TeeRollupProposalData{ + L2SeqNum: 0x0102030405060708, + ParentIdx: 0x090A0B0C, + BlockHash: common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + StateHash: common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + } + buf := encodeTeeRollupExtraData(d) + // For xlayer: new layout is 100 bytes (l2SeqNum as uint256) + require.Equal(t, 100, len(buf)) + + // [0:24] high bytes of uint256 l2SeqNum — must be zero + require.Equal(t, make([]byte, 24), buf[0:24]) + // [24:32] low 8 bytes of uint256 l2SeqNum — uint64 big-endian + require.Equal(t, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, buf[24:32]) + // [32:36] parentIdx big-endian + require.Equal(t, []byte{0x09, 0x0A, 0x0B, 0x0C}, buf[32:36]) + // [36:68] blockHash + require.Equal(t, d.BlockHash.Bytes(), buf[36:68]) + // [68:100] stateHash + require.Equal(t, d.StateHash.Bytes(), buf[68:100]) +} + +// mockTeeRollupClient is a test double for TeeRollupClient +type mockTeeRollupClient struct { + info TeeRollupBlockInfo + err error +} + +func (m *mockTeeRollupClient) ConfirmedBlockInfo(ctx context.Context) (TeeRollupBlockInfo, error) { + return m.info, m.err +} +func (m *mockTeeRollupClient) ConfirmedBlockInfoAtHeight(ctx context.Context, height uint64) (TeeRollupBlockInfo, error) { + if m.err != nil { + return TeeRollupBlockInfo{}, m.err + } + if m.info.Height != height { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: confirmed block height mismatch: expected %d, got %d", height, m.info.Height) + } + return m.info, nil +} +func (m *mockTeeRollupClient) Close() {} + +func TestSyncStatus_UsesLowestHeight(t *testing.T) { + logger := testlog.Logger(t, log.LevelInfo) + cl1 := &mockTeeRollupClient{info: TeeRollupBlockInfo{Height: 100}} + cl2 := &mockTeeRollupClient{info: TeeRollupBlockInfo{Height: 50}} + cl3 := &mockTeeRollupClient{info: TeeRollupBlockInfo{Height: 80}} + + src := NewTeeRollupProposalSource(logger, cl1, cl2, cl3) + status, err := src.SyncStatus(context.Background()) + require.NoError(t, err) + require.Equal(t, uint64(50), status.SafeL2) + require.Equal(t, uint64(50), status.FinalizedL2) +} + +func TestSyncStatus_AllFail(t *testing.T) { + logger := testlog.Logger(t, log.LevelInfo) + cl1 := &mockTeeRollupClient{err: errors.New("err1")} + cl2 := &mockTeeRollupClient{err: errors.New("err2")} + + src := NewTeeRollupProposalSource(logger, cl1, cl2) + _, err := src.SyncStatus(context.Background()) + require.Error(t, err) + require.ErrorContains(t, err, "err1") + require.ErrorContains(t, err, "err2") +} + +func TestProposalAtSequenceNum_Success(t *testing.T) { + logger := testlog.Logger(t, log.LevelInfo) + blockHash := common.HexToHash("0xaaaa") + appHash := common.HexToHash("0xbbbb") + cl := &mockTeeRollupClient{info: TeeRollupBlockInfo{Height: 42, BlockHash: blockHash, AppHash: appHash}} + + src := NewTeeRollupProposalSource(logger, cl) + proposal, err := src.ProposalAtSequenceNum(context.Background(), 42) + require.NoError(t, err) + + require.Equal(t, uint64(42), proposal.SequenceNum) + require.NotNil(t, proposal.TeeRollupData) + require.Equal(t, uint64(42), proposal.TeeRollupData.L2SeqNum) + require.Equal(t, blockHash, proposal.TeeRollupData.BlockHash) + require.Equal(t, appHash, proposal.TeeRollupData.StateHash) + + expectedRoot := computeRootClaim(blockHash, appHash) + require.Equal(t, expectedRoot, proposal.Root) +} + +func TestProposalAtSequenceNum_HeightMismatch(t *testing.T) { + logger := testlog.Logger(t, log.LevelInfo) + cl := &mockTeeRollupClient{info: TeeRollupBlockInfo{Height: 99}} + + src := NewTeeRollupProposalSource(logger, cl) + _, err := src.ProposalAtSequenceNum(context.Background(), 100) + require.ErrorContains(t, err, "all clients failed") +} From e76259ff291798f77ae779e5adea32ac0b0209fc Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Thu, 19 Mar 2026 17:00:44 +0800 Subject: [PATCH 05/25] rm md --- op-proposer/mock/TEST_GUIDE.md | 257 --------------------------------- 1 file changed, 257 deletions(-) delete mode 100644 op-proposer/mock/TEST_GUIDE.md diff --git a/op-proposer/mock/TEST_GUIDE.md b/op-proposer/mock/TEST_GUIDE.md deleted file mode 100644 index 36cdc3240c5ac..0000000000000 --- a/op-proposer/mock/TEST_GUIDE.md +++ /dev/null @@ -1,257 +0,0 @@ -# TEE Game Type (1960) — Hands-On Testing Guide - -This guide walks you through testing the op-proposer TEE game type (1960) locally using the mock TeeRollup server. All parameters are provided manually—no `.env` file dependency. - -## Overview - -Testing the TEE game type requires three components working together: - -1. **Mock TeeRollup RPC server** (`mockteerpc`) — Simulates the TeeRollup HTTP endpoint, returning block info with incrementing heights -2. **op-proposer** — The proposer binary that reads from TeeRollup and submits TEE game disputes -3. **Verification script** (`list_games.sh`) — Lists created games to verify proposal success - -The flow: proposer fetches block info from mock TeeRollup → computes rootClaim → submits proposal → list_games.sh confirms the game was created. - ---- - -## Step 1: Install Binaries -0x1D8D70AD07C8E7E442AD78E4AC0A16f958Eba7F0 -On macOS, binaries must be installed via `go install` (not run directly from build output) due to security restrictions. - -From the repository root, install both the mock TeeRollup server and the proposer: - -```bash -cd op-proposer -go install ./mock/cmd/mockteerpc -go install ./cmd/main.go -``` - -Both binaries are now in `$GOPATH/bin` and available system-wide. - ---- - -## Step 2: Start the Mock TeeRollup RPC Server - -In a new terminal, start the mock server with the flags below: - -```bash -mockteerpc \ - --addr=:8090 \ - --init-height=1000000 \ - --error-rate=0 \ - --delay=0 -``` - -### Flag Reference - -| Flag | Description | -|------|-------------| -| `--addr` | Listen address (default `:8090`). Use any available port. | -| `--init-height` | Starting block height (default `1000`). **Use a value higher than your anchor sequence number.** The mock increments this by 1–50 every second. | -| `--error-rate` | Fraction of requests that return errors, 0.0–1.0 (default `0`). Use `0` for testing. | -| `--delay` | Maximum random response delay in milliseconds (default `0`). Use `0` for fastest testing. | - -### Verify the Server is Running - -In another terminal, check that the endpoint returns valid data: - -```bash -curl http://localhost:8090/v1/chain/confirmed_block_info -``` - -Expected output: - -```json -{ - "code": 0, - "message": "OK", - "data": { - "height": 1000000, - "appHash": "0x3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b", - "blockHash": "0x1234abcd5678ef901234567890abcdef1234567890abcdef1234567890abcdef" - } -} -``` - -If you get `"data": null`, the server initialization failed. Restart it. - ---- - -## Step 3: Start the Proposer - -In a third terminal, start the proposer with realistic placeholder addresses and keys: - -```bash -main \ - --l1-eth-rpc="http://localhost:8545" \ - --tee-rollup-rpc="http://localhost:8090" \ - --game-type=1960 \ - --game-factory-address="0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77" \ - --private-key="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" \ - --poll-interval=12s \ - --proposal-interval=12s \ - --rpc.port=7302 \ - --log.level=info -``` - -### Flag Reference - -| Flag | Description | -|------|-------------| -| `--l1-eth-rpc` | L1 Ethereum RPC endpoint. Must be running and synced. | -| `--tee-rollup-rpc` | TEE Rollup RPC endpoint (mock or real). Required for game type 1960. | -| `--game-type` | Dispute game type. Must be `1960` for TEE games. | -| `--game-factory-address` | DisputeGameFactory contract address on L1. Deploy or get from devnet. | -| `--private-key` | Proposer wallet private key (without `0x` prefix if only hex). **Must have sufficient ETH for gas.** | -| `--poll-interval` | How often to check L1 state (default `12s`). Shorter = faster proposals. | -| `--proposal-interval` | Minimum time between proposals (default `12s`). Space out proposals to avoid nonce conflicts. | -| `--rpc.port` | Port for proposer's own RPC server (default `8544`). Use `7302` to avoid conflicts. | -| `--log.level` | Log verbosity: `debug`, `info`, `warn`, `error`. Use `info` for normal testing. | - -**Note:** `--tee-rollup-rpc` is mutually exclusive with `--rollup-rpc`, `--supervisor-rpcs`, and other super-node RPCs. - -### Expected Startup Logs - -Within the first few seconds: - -``` -msg="Connected to DisputeGameFactory" address=0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77 -msg="Started RPC server" endpoint=http://[::]:7302 -msg="Proposer started" -``` - -Within ~12 seconds (one proposal interval): - -``` -msg="No proposals found for at least proposal interval, submitting proposal now" -msg="Proposing output root" sequenceNum=1000012 extraData="0x..." -msg="Transaction confirmed" tx=0xabcd... -``` - -If the proposer waits longer, check that: -- Mock TeeRollup is running and reachable at `--tee-rollup-rpc` -- L1 RPC is running at `--l1-eth-rpc` -- The private key's address has ETH for gas - ---- - -## Step 4: Verify Games with list_games.sh - -In a fourth terminal, use the list_games.sh script to confirm that games were created: - -```bash -cd /Users/jimmyshi/code/optimism/op-proposer/mock -./list_games.sh \ - --rpc http://localhost:8545 \ - --factory 0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77 \ - --count 5 -``` - -### Flag Reference - -| Flag | Description | -|------|-------------| -| `--rpc` | L1 RPC endpoint (same as proposer's `--l1-eth-rpc`). | -| `--factory` | DisputeGameFactory address (same as proposer's `--game-factory-address`). | -| `--count` | Number of latest games to list (default `10`). | - -### Expected Output - -``` -Game 0: - Type: 1960 - Timestamp: 1710892800 - Proxy: 0xa5875EdD032eFbe7773084ae23C588daC243bc01 - -Game 1: - Type: 1960 - Timestamp: 1710892812 - Proxy: 0xb7c96ee3f2c1d4f8a9e0b2c3d4e5f6a7b8c9d0e -``` - -Key observations: -- **Type must be `1960`** (if type is not 1960, the game was not created as a TEE game) -- **Timestamps increase** (each new game is ~12 seconds apart, matching proposal-interval) -- **Proxy addresses differ** (each game gets a unique proxy contract) - ---- - -## Step 5: Verify rootClaim (Optional) - -To verify that the rootClaim was computed correctly, fetch the game's claim data using cast: - -```bash -# Use the proxy address from list_games.sh output -GAME_ADDR=0xa5875EdD032eFbe7773084ae23C588daC243bc01 -L1_RPC="http://localhost:8545" - -# Read the claim fields -cast call $GAME_ADDR "rootClaim()(bytes32)" --rpc-url $L1_RPC -cast call $GAME_ADDR "blockHash()(bytes32)" --rpc-url $L1_RPC -cast call $GAME_ADDR "stateHash()(bytes32)" --rpc-url $L1_RPC -``` - -Example output: - -``` -blockHash: 0x1234abcd5678ef901234567890abcdef1234567890abcdef1234567890abcdef -stateHash: 0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba -rootClaim: 0xabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 -``` - -### Verify the Formula - -The rootClaim must equal `keccak256(blockHash || stateHash)`: - -```bash -BLOCK_HASH="0x1234abcd5678ef901234567890abcdef1234567890abcdef1234567890abcdef" -STATE_HASH="0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba" - -# Compute expected rootClaim -EXPECTED=$(cast keccak $(cast concat-hex $BLOCK_HASH $STATE_HASH)) -echo "Expected rootClaim: $EXPECTED" - -# Compare with actual -ACTUAL=$(cast call $GAME_ADDR "rootClaim()(bytes32)" --rpc-url $L1_RPC) -echo "Actual rootClaim: $ACTUAL" -``` - -Both values must match. - ---- - -## Troubleshooting - -| Error | Cause | Fix | -|-------|-------|-----| -| `tee-rollup: no confirmed block available (data is null)` | Mock server not running or endpoint unreachable | Check `mockteerpc` is running; verify `--tee-rollup-rpc` URL is correct | -| `tee-rollup-rpc is required for TeeRollup game type (1960)` | Missing `--tee-rollup-rpc` flag | Add `--tee-rollup-rpc=http://localhost:8090` | -| `l2SequenceNumber() <= anchorSeqNum` | Mock init-height is below anchor state sequence number | Restart `mockteerpc` with higher `--init-height` (e.g., `1000000`) | -| `nonce too low` | Multiple proposer instances running concurrently | Stop all proposer processes; restart only one instance | -| `failed to bind to address "0.0.0.0:7302"` | RPC port already in use | Use a different `--rpc.port` (e.g., `7303`, `7304`) | -| `connection refused` (L1 RPC) | L1 node not running or wrong address | Ensure L1 node is running at `--l1-eth-rpc` | -| `insufficient funds` | Proposer wallet has no ETH | Fund the wallet address derived from `--private-key` | -| `game type 1960 not registered` | DisputeGameFactory not properly configured | Verify game type 1960 is registered in the factory; deploy TestGame contract | - ---- - -## Tips for Local Testing - -- **Speed up iteration:** Use lower values for `--poll-interval` and `--proposal-interval` (e.g., `5s`) to submit games faster. -- **Simulate network delay:** Set `--delay=500` on the mock server to test timeout handling. -- **Simulate errors:** Set `--error-rate=0.1` to randomly fail 10% of mock requests and test retry logic. -- **Watch continuously:** Use `watch` to monitor games in real-time: - ```bash - watch -n 2 './list_games.sh --rpc http://localhost:8545 --factory 0xCe4cD48CD7802a2dD6b026043bc2eE831c555d77 --count 3' - ``` -- **Inspect logs:** Set `--log.level=debug` on the proposer to see detailed execution flow (verbose output). - ---- - -## Next Steps - -Once basic testing works: - -1. Run `op-proposer` unit tests: `go test ./proposer/... -v` -2. Test with a real TeeRollup (not mock) by pointing `--tee-rollup-rpc` to the real endpoint -3. Run end-to-end tests with `op-challenger` and dispute resolution From 5e9fe831e5160d61a652c312c1ebc68e14e2e6f7 Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Thu, 19 Mar 2026 20:25:56 +0800 Subject: [PATCH 06/25] fix(op-challenger): register TEE flags in init() instead of separate init Move TEE CLI flags (tee-prover-rpc, tee-prove-poll-interval, tee-prove-timeout) from a separate init() in flags_xlayer.go into the main init() in flags.go. The separate init() ran after Flags was already built, so TEE flags were never registered in the CLI app. Co-Authored-By: Claude Sonnet 4.6 --- op-challenger/flags/flags.go | 1 + op-challenger/flags/flags_xlayer.go | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/op-challenger/flags/flags.go b/op-challenger/flags/flags.go index 39683210d4ccd..7aa7e85afe557 100644 --- a/op-challenger/flags/flags.go +++ b/op-challenger/flags/flags.go @@ -313,6 +313,7 @@ func init() { optionalFlags = append(optionalFlags, txmgr.CLIFlagsWithDefaults(EnvVarPrefix, txmgr.DefaultChallengerFlagValues)...) optionalFlags = append(optionalFlags, opmetrics.CLIFlags(EnvVarPrefix)...) optionalFlags = append(optionalFlags, oppprof.CLIFlags(EnvVarPrefix)...) + optionalFlags = append(optionalFlags, teeFlags...) // For XLayer Flags = append(requiredFlags, optionalFlags...) } diff --git a/op-challenger/flags/flags_xlayer.go b/op-challenger/flags/flags_xlayer.go index 43113c937aa25..e4559c038d43f 100644 --- a/op-challenger/flags/flags_xlayer.go +++ b/op-challenger/flags/flags_xlayer.go @@ -24,11 +24,9 @@ var ( EnvVars: prefixEnvVars("TEE_PROVE_TIMEOUT"), Value: config.DefaultTeeProveTimeout, } -) -func init() { - optionalFlags = append(optionalFlags, TeeProverRpcFlag, TeeProvePollIntervalFlag, TeeProveTimeoutFlag) -} + teeFlags = []cli.Flag{TeeProverRpcFlag, TeeProvePollIntervalFlag, TeeProveTimeoutFlag} +) // onlyTeeGameTypes returns true if all enabled game types are TEE (which doesn't require L2 RPC). func onlyTeeGameTypes(types []gameTypes.GameType) bool { From 0f3f3ab63049aa8ec1aa5d1a2f71871c570e5ab8 Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Thu, 19 Mar 2026 20:41:52 +0800 Subject: [PATCH 07/25] fix(op-challenger): register TeeGameType directly in SupportedGameTypes Move TeeGameType from init() in game_type_xlayer.go to the SupportedGameTypes declaration in game_type.go, avoiding init() ordering issues. Co-Authored-By: Claude Sonnet 4.6 --- op-challenger/game/types/game_type.go | 1 + op-challenger/game/types/game_type_xlayer.go | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 op-challenger/game/types/game_type_xlayer.go diff --git a/op-challenger/game/types/game_type.go b/op-challenger/game/types/game_type.go index 3f39ffa570001..dc1cf31a8b75c 100644 --- a/op-challenger/game/types/game_type.go +++ b/op-challenger/game/types/game_type.go @@ -41,6 +41,7 @@ var SupportedGameTypes = []GameType{ SuperCannonKonaGameType, SuperPermissionedGameType, OptimisticZKGameType, + TeeGameType, // For XLayer } // Set implements the Set method required by the [cli.Generic] interface. diff --git a/op-challenger/game/types/game_type_xlayer.go b/op-challenger/game/types/game_type_xlayer.go deleted file mode 100644 index 3d988d7a246c8..0000000000000 --- a/op-challenger/game/types/game_type_xlayer.go +++ /dev/null @@ -1,5 +0,0 @@ -package types - -func init() { - SupportedGameTypes = append(SupportedGameTypes, TeeGameType) -} From dcbf4b1820ce8f21c439009004c7f4978d4ebfe5 Mon Sep 17 00:00:00 2001 From: "jason.huang" Date: Thu, 19 Mar 2026 11:33:35 +0800 Subject: [PATCH 08/25] feat: add TeeDisputeGame, TeeProofVerifier, and DisputeGameFactoryRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEE DisputeGame contracts for OP Stack. Replaces SP1 ZK proofs with AWS Nitro Enclave ECDSA signatures for batch state transition verification. - src/dispute/tee/TeeDisputeGame.sol: IDisputeGame impl (gameType=1960) - src/dispute/tee/TeeProofVerifier.sol: enclave registration + batch ECDSA verification - src/dispute/tee/AccessManager.sol: proposer/challenger permissions with fallback timeout - src/dispute/DisputeGameFactoryRouter.sol: multi-zone router (zoneId→factory) - interfaces/dispute/: ITeeProofVerifier, IRiscZeroVerifier, IDisputeGameFactoryRouter - tests: 69 unit tests covering full lifecycle - scripts: DeployTee.s.sol, RegisterTeeGame.s.sol Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dispute/IDisputeGameFactoryRouter.sol | 42 ++ .../interfaces/dispute/IRiscZeroVerifier.sol | 12 + .../interfaces/dispute/ITeeProofVerifier.sol | 17 + .../scripts/deploy/DeployTee.s.sol | 150 +++++ .../scripts/deploy/RegisterTeeGame.s.sol | 42 ++ .../src/dispute/DisputeGameFactoryRouter.sol | 106 +++ .../src/dispute/tee/AccessManager.sol | 135 ++++ .../src/dispute/tee/TeeDisputeGame.sol | 485 +++++++++++++ .../src/dispute/tee/TeeProofVerifier.sol | 236 +++++++ .../src/dispute/tee/lib/Errors.sol | 30 + .../test/dispute/tee/AccessManager.t.sol | 108 +++ .../AnchorStateRegistryCompatibility.t.sol | 137 ++++ .../tee/DisputeGameFactoryRouter.t.sol | 141 ++++ .../tee/DisputeGameFactoryRouterCreate.t.sol | 72 ++ .../test/dispute/tee/INTEGRATION_TEST_PLAN.md | 153 +++++ .../test/dispute/tee/TeeDisputeGame.t.sol | 636 ++++++++++++++++++ .../test/dispute/tee/TeeProofVerifier.t.sol | 126 ++++ .../fork/DisputeGameFactoryRouterFork.t.sol | 365 ++++++++++ .../test/dispute/tee/helpers/TeeTestUtils.sol | 115 ++++ .../tee/mocks/MockAnchorStateRegistry.sol | 167 +++++ .../tee/mocks/MockCloneableDisputeGame.sol | 56 ++ .../tee/mocks/MockDisputeGameFactory.sol | 177 +++++ .../tee/mocks/MockRiscZeroVerifier.sol | 29 + .../tee/mocks/MockStatusDisputeGame.sol | 85 +++ .../dispute/tee/mocks/MockSystemConfig.sol | 26 + .../tee/mocks/MockTeeProofVerifier.sol | 42 ++ 26 files changed, 3690 insertions(+) create mode 100644 packages/contracts-bedrock/interfaces/dispute/IDisputeGameFactoryRouter.sol create mode 100644 packages/contracts-bedrock/interfaces/dispute/IRiscZeroVerifier.sol create mode 100644 packages/contracts-bedrock/interfaces/dispute/ITeeProofVerifier.sol create mode 100644 packages/contracts-bedrock/scripts/deploy/DeployTee.s.sol create mode 100644 packages/contracts-bedrock/scripts/deploy/RegisterTeeGame.s.sol create mode 100644 packages/contracts-bedrock/src/dispute/DisputeGameFactoryRouter.sol create mode 100644 packages/contracts-bedrock/src/dispute/tee/AccessManager.sol create mode 100644 packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol create mode 100644 packages/contracts-bedrock/src/dispute/tee/TeeProofVerifier.sol create mode 100644 packages/contracts-bedrock/src/dispute/tee/lib/Errors.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/AccessManager.t.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/AnchorStateRegistryCompatibility.t.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouter.t.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouterCreate.t.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/INTEGRATION_TEST_PLAN.md create mode 100644 packages/contracts-bedrock/test/dispute/tee/TeeDisputeGame.t.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/TeeProofVerifier.t.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/fork/DisputeGameFactoryRouterFork.t.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/helpers/TeeTestUtils.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/mocks/MockAnchorStateRegistry.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/mocks/MockCloneableDisputeGame.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/mocks/MockDisputeGameFactory.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/mocks/MockRiscZeroVerifier.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/mocks/MockStatusDisputeGame.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/mocks/MockSystemConfig.sol create mode 100644 packages/contracts-bedrock/test/dispute/tee/mocks/MockTeeProofVerifier.sol diff --git a/packages/contracts-bedrock/interfaces/dispute/IDisputeGameFactoryRouter.sol b/packages/contracts-bedrock/interfaces/dispute/IDisputeGameFactoryRouter.sol new file mode 100644 index 0000000000000..b70163843e32c --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/IDisputeGameFactoryRouter.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {GameType, Claim} from "src/dispute/lib/Types.sol"; + +/// @title IDisputeGameFactoryRouter +/// @notice Interface for routing dispute game creation across multiple zone factories. +interface IDisputeGameFactoryRouter { + /// @notice Parameters for creating a single dispute game in a batch. + struct CreateParams { + uint256 zoneId; + GameType gameType; + Claim rootClaim; + bytes extraData; + uint256 bond; + } + + // ============ Events ============ + + event ZoneRegistered(uint256 indexed zoneId, address indexed factory); + event ZoneUpdated(uint256 indexed zoneId, address indexed oldFactory, address indexed newFactory); + event ZoneRemoved(uint256 indexed zoneId, address indexed factory); + event GameCreated(uint256 indexed zoneId, address indexed proxy); + event BatchGamesCreated(uint256 count); + + // ============ Errors ============ + + error ZoneAlreadyRegistered(uint256 zoneId); + error ZoneNotRegistered(uint256 zoneId); + error ZeroAddress(); + error BatchEmpty(); + error BatchBondMismatch(uint256 totalBonds, uint256 msgValue); + + // ============ Functions ============ + + function registerZone(uint256 zoneId, address factory) external; + function updateZone(uint256 zoneId, address factory) external; + function removeZone(uint256 zoneId) external; + function create(uint256 zoneId, GameType gameType, Claim rootClaim, bytes calldata extraData) external payable returns (address proxy); + function createBatch(CreateParams[] calldata params) external payable returns (address[] memory proxies); + function getFactory(uint256 zoneId) external view returns (address); +} diff --git a/packages/contracts-bedrock/interfaces/dispute/IRiscZeroVerifier.sol b/packages/contracts-bedrock/interfaces/dispute/IRiscZeroVerifier.sol new file mode 100644 index 0000000000000..433d4d4c4ebdd --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/IRiscZeroVerifier.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +/// @title IRiscZeroVerifier +/// @notice Minimal interface for the RISC Zero Groth16 verifier. +interface IRiscZeroVerifier { + /// @notice Verify a RISC Zero proof. + /// @param seal The proof seal (Groth16). + /// @param imageId The guest image ID. + /// @param journalDigest The SHA-256 digest of the journal. + function verify(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) external view; +} diff --git a/packages/contracts-bedrock/interfaces/dispute/ITeeProofVerifier.sol b/packages/contracts-bedrock/interfaces/dispute/ITeeProofVerifier.sol new file mode 100644 index 0000000000000..a7498f2059761 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/ITeeProofVerifier.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +/// @title ITeeProofVerifier +/// @notice Interface for the TEE Proof Verifier contract. +interface ITeeProofVerifier { + /// @notice Verify a batch state transition signed by a registered TEE enclave. + /// @param digest The hash of the batch data. + /// @param signature ECDSA signature (65 bytes: r + s + v). + /// @return signer The address of the verified enclave. + function verifyBatch(bytes32 digest, bytes calldata signature) external view returns (address signer); + + /// @notice Check if an address is a registered enclave. + /// @param enclaveAddress The address to check. + /// @return True if the address is registered. + function isRegistered(address enclaveAddress) external view returns (bool); +} diff --git a/packages/contracts-bedrock/scripts/deploy/DeployTee.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployTee.s.sol new file mode 100644 index 0000000000000..33299a451b4ac --- /dev/null +++ b/packages/contracts-bedrock/scripts/deploy/DeployTee.s.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Script, console2} from "forge-std/Script.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {Duration} from "src/dispute/lib/Types.sol"; +import {IRiscZeroVerifier} from "interfaces/dispute/IRiscZeroVerifier.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; +import {AccessManager} from "src/dispute/tee/AccessManager.sol"; +import {DisputeGameFactoryRouter} from "src/dispute/DisputeGameFactoryRouter.sol"; +import {TeeDisputeGame} from "src/dispute/tee/TeeDisputeGame.sol"; +import {TeeProofVerifier} from "src/dispute/tee/TeeProofVerifier.sol"; + +contract Deploy is Script { + struct DeployConfig { + uint256 deployerKey; + address deployer; + IRiscZeroVerifier riscZeroVerifier; + bytes32 imageId; + bytes nitroRootKey; + IDisputeGameFactory disputeGameFactory; + IAnchorStateRegistry anchorStateRegistry; + uint64 maxChallengeDuration; + uint64 maxProveDuration; + uint256 challengerBond; + uint256 fallbackTimeout; + bool deployRouter; + address proofVerifierOwner; + address[] proposers; + address[] challengers; + uint256[] zoneIds; + address[] routerFactories; + address routerOwner; + } + + function run() + external + returns ( + TeeProofVerifier teeProofVerifier, + AccessManager accessManager, + TeeDisputeGame teeDisputeGame, + DisputeGameFactoryRouter router + ) + { + DeployConfig memory cfg = _readConfig(); + + if (cfg.deployRouter) { + require(cfg.zoneIds.length == cfg.routerFactories.length, "Deploy: router zone/factory length mismatch"); + } + + vm.startBroadcast(cfg.deployerKey); + + teeProofVerifier = new TeeProofVerifier(cfg.riscZeroVerifier, cfg.imageId, cfg.nitroRootKey); + if (cfg.proofVerifierOwner != cfg.deployer) { + teeProofVerifier.transferOwnership(cfg.proofVerifierOwner); + } + + accessManager = new AccessManager(cfg.fallbackTimeout, cfg.disputeGameFactory); + _applyAllowlist(accessManager, cfg.proposers, cfg.challengers); + + teeDisputeGame = new TeeDisputeGame( + Duration.wrap(cfg.maxChallengeDuration), + Duration.wrap(cfg.maxProveDuration), + cfg.disputeGameFactory, + ITeeProofVerifier(address(teeProofVerifier)), + cfg.challengerBond, + cfg.anchorStateRegistry, + accessManager + ); + + if (cfg.deployRouter) { + router = _deployRouter(cfg.routerOwner, cfg.deployer, cfg.zoneIds, cfg.routerFactories); + } + + vm.stopBroadcast(); + + console2.log("deployer", cfg.deployer); + console2.log("teeProofVerifier", address(teeProofVerifier)); + console2.log("accessManager", address(accessManager)); + console2.log("teeDisputeGame", address(teeDisputeGame)); + if (cfg.deployRouter) { + console2.log("router", address(router)); + } + } + + function _readConfig() internal view returns (DeployConfig memory cfg) { + cfg.deployerKey = vm.envUint("PRIVATE_KEY"); + cfg.deployer = vm.addr(cfg.deployerKey); + cfg.riscZeroVerifier = IRiscZeroVerifier(vm.envAddress("RISC_ZERO_VERIFIER")); + cfg.imageId = vm.envBytes32("RISC_ZERO_IMAGE_ID"); + cfg.nitroRootKey = vm.envBytes("NITRO_ROOT_KEY"); + cfg.disputeGameFactory = IDisputeGameFactory(vm.envAddress("DISPUTE_GAME_FACTORY")); + cfg.anchorStateRegistry = IAnchorStateRegistry(vm.envAddress("ANCHOR_STATE_REGISTRY")); + cfg.maxChallengeDuration = uint64(vm.envUint("MAX_CHALLENGE_DURATION")); + cfg.maxProveDuration = uint64(vm.envUint("MAX_PROVE_DURATION")); + cfg.challengerBond = vm.envUint("CHALLENGER_BOND"); + cfg.fallbackTimeout = vm.envUint("FALLBACK_TIMEOUT"); + cfg.deployRouter = vm.envOr("DEPLOY_ROUTER", false); + cfg.proofVerifierOwner = vm.envOr("PROOF_VERIFIER_OWNER", cfg.deployer); + cfg.proposers = _envAddressArray("PROPOSERS"); + cfg.challengers = _envAddressArray("CHALLENGERS"); + cfg.zoneIds = _envUintArray("ROUTER_ZONE_IDS"); + cfg.routerFactories = _envAddressArray("ROUTER_FACTORIES"); + cfg.routerOwner = vm.envOr("ROUTER_OWNER", cfg.deployer); + } + + function _applyAllowlist( + AccessManager accessManager, + address[] memory proposers, + address[] memory challengers + ) + internal + { + for (uint256 i = 0; i < proposers.length; i++) { + accessManager.setProposer(proposers[i], true); + } + for (uint256 i = 0; i < challengers.length; i++) { + accessManager.setChallenger(challengers[i], true); + } + } + + function _deployRouter( + address routerOwner, + address deployer, + uint256[] memory zoneIds, + address[] memory routerFactories + ) + internal + returns (DisputeGameFactoryRouter router) + { + router = new DisputeGameFactoryRouter(); + for (uint256 i = 0; i < zoneIds.length; i++) { + router.registerZone(zoneIds[i], routerFactories[i]); + } + if (routerOwner != deployer) { + router.transferOwnership(routerOwner); + } + } + + function _envAddressArray(string memory name) internal view returns (address[] memory values) { + if (!vm.envExists(name)) return new address[](0); + return vm.envAddress(name, ","); + } + + function _envUintArray(string memory name) internal view returns (uint256[] memory values) { + if (!vm.envExists(name)) return new uint256[](0); + return vm.envUint(name, ","); + } +} diff --git a/packages/contracts-bedrock/scripts/deploy/RegisterTeeGame.s.sol b/packages/contracts-bedrock/scripts/deploy/RegisterTeeGame.s.sol new file mode 100644 index 0000000000000..27b6a1df96c37 --- /dev/null +++ b/packages/contracts-bedrock/scripts/deploy/RegisterTeeGame.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Script, console2} from "forge-std/Script.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {GameType} from "src/dispute/lib/Types.sol"; + +contract RegisterTeeGame is Script { + uint32 internal constant TEE_GAME_TYPE = 1960; + string internal constant GAME_ARGS_UNSUPPORTED = + "RegisterTeeGame: GAME_ARGS is unsupported for gameType=1960"; + + function run() external { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + IDisputeGameFactory disputeGameFactory = IDisputeGameFactory(vm.envAddress("DISPUTE_GAME_FACTORY")); + IDisputeGame teeDisputeGame = IDisputeGame(vm.envAddress("TEE_DISPUTE_GAME_IMPL")); + uint256 initBond = vm.envUint("INIT_BOND"); + bytes memory gameArgs = vm.envOr("GAME_ARGS", bytes("")); + bool setRespectedGameType = vm.envOr("SET_RESPECTED_GAME_TYPE", false); + + require(gameArgs.length == 0, GAME_ARGS_UNSUPPORTED); + + vm.startBroadcast(deployerKey); + + disputeGameFactory.setImplementation(GameType.wrap(TEE_GAME_TYPE), teeDisputeGame); + disputeGameFactory.setInitBond(GameType.wrap(TEE_GAME_TYPE), initBond); + + if (setRespectedGameType) { + IAnchorStateRegistry anchorStateRegistry = IAnchorStateRegistry(vm.envAddress("ANCHOR_STATE_REGISTRY")); + anchorStateRegistry.setRespectedGameType(GameType.wrap(TEE_GAME_TYPE)); + console2.log("anchorStateRegistry respected game type set", TEE_GAME_TYPE); + } + + vm.stopBroadcast(); + + console2.log("registered gameType", TEE_GAME_TYPE); + console2.log("teeDisputeGame", address(teeDisputeGame)); + console2.log("initBond", initBond); + } +} diff --git a/packages/contracts-bedrock/src/dispute/DisputeGameFactoryRouter.sol b/packages/contracts-bedrock/src/dispute/DisputeGameFactoryRouter.sol new file mode 100644 index 0000000000000..280f13501f339 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/DisputeGameFactoryRouter.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {GameType, Claim} from "src/dispute/lib/Types.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {IDisputeGameFactoryRouter} from "interfaces/dispute/IDisputeGameFactoryRouter.sol"; + +/// @title DisputeGameFactoryRouter +/// @notice Routes dispute game creation to the correct zone's DisputeGameFactory. +/// @dev Each zone (identified by a uint256 zoneId) maps to a DisputeGameFactory address. +contract DisputeGameFactoryRouter is Ownable, IDisputeGameFactoryRouter { + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + /// @notice Mapping of zoneId to DisputeGameFactory address. + mapping(uint256 => address) public factories; + + constructor() {} + + //////////////////////////////////////////////////////////////// + // Zone Management // + //////////////////////////////////////////////////////////////// + + /// @notice Register a new zone with its factory address. + function registerZone(uint256 zoneId, address factory) external onlyOwner { + if (factory == address(0)) revert ZeroAddress(); + if (factories[zoneId] != address(0)) revert ZoneAlreadyRegistered(zoneId); + factories[zoneId] = factory; + emit ZoneRegistered(zoneId, factory); + } + + /// @notice Update an existing zone's factory address. + function updateZone(uint256 zoneId, address factory) external onlyOwner { + if (factory == address(0)) revert ZeroAddress(); + address oldFactory = factories[zoneId]; + if (oldFactory == address(0)) revert ZoneNotRegistered(zoneId); + factories[zoneId] = factory; + emit ZoneUpdated(zoneId, oldFactory, factory); + } + + /// @notice Remove a zone. + function removeZone(uint256 zoneId) external onlyOwner { + address factory = factories[zoneId]; + if (factory == address(0)) revert ZoneNotRegistered(zoneId); + delete factories[zoneId]; + emit ZoneRemoved(zoneId, factory); + } + + //////////////////////////////////////////////////////////////// + // Game Creation // + //////////////////////////////////////////////////////////////// + + /// @notice Create a single dispute game in the specified zone. + function create( + uint256 zoneId, + GameType gameType, + Claim rootClaim, + bytes calldata extraData + ) external payable returns (address proxy) { + address factory = factories[zoneId]; + if (factory == address(0)) revert ZoneNotRegistered(zoneId); + + IDisputeGame game = IDisputeGameFactory(factory).create{value: msg.value}( + gameType, rootClaim, extraData + ); + proxy = address(game); + emit GameCreated(zoneId, proxy); + } + + /// @notice Create dispute games across multiple zones in a single transaction. + /// @dev The sum of all params[i].bond must equal msg.value. + function createBatch(CreateParams[] calldata params) external payable returns (address[] memory proxies) { + if (params.length == 0) revert BatchEmpty(); + + uint256 totalBonds; + for (uint256 i = 0; i < params.length; i++) { + totalBonds += params[i].bond; + } + if (totalBonds != msg.value) revert BatchBondMismatch(totalBonds, msg.value); + + proxies = new address[](params.length); + for (uint256 i = 0; i < params.length; i++) { + address factory = factories[params[i].zoneId]; + if (factory == address(0)) revert ZoneNotRegistered(params[i].zoneId); + + IDisputeGame game = IDisputeGameFactory(factory).create{value: params[i].bond}( + params[i].gameType, params[i].rootClaim, params[i].extraData + ); + proxies[i] = address(game); + emit GameCreated(params[i].zoneId, proxies[i]); + } + + emit BatchGamesCreated(params.length); + } + + //////////////////////////////////////////////////////////////// + // View Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Get the factory address for a zone. + function getFactory(uint256 zoneId) external view returns (address) { + return factories[zoneId]; + } +} diff --git a/packages/contracts-bedrock/src/dispute/tee/AccessManager.sol b/packages/contracts-bedrock/src/dispute/tee/AccessManager.sol new file mode 100644 index 0000000000000..a28e1ae39e420 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/AccessManager.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {GameType, Timestamp} from "src/dispute/lib/Types.sol"; + +/// @dev Game type constant for TEE Dispute Game. +uint32 constant TEE_DISPUTE_GAME_TYPE = 1960; + +/// @title AccessManager +/// @notice Manages permissions for dispute game proposers and challengers. +contract AccessManager is Ownable { + //////////////////////////////////////////////////////////////// + // Events // + //////////////////////////////////////////////////////////////// + + /// @notice Event emitted when proposer permissions are updated. + event ProposerPermissionUpdated(address indexed proposer, bool allowed); + + /// @notice Event emitted when challenger permissions are updated. + event ChallengerPermissionUpdated(address indexed challenger, bool allowed); + + //////////////////////////////////////////////////////////////// + // State Vars // + //////////////////////////////////////////////////////////////// + + /// @notice Tracks whitelisted proposers. + mapping(address => bool) public proposers; + + /// @notice Tracks whitelisted challengers. + mapping(address => bool) public challengers; + + /// @notice The timeout (in seconds) after which permissionless proposing is allowed (immutable). + uint256 public immutable FALLBACK_TIMEOUT; + + /// @notice The dispute game factory address. + IDisputeGameFactory public immutable DISPUTE_GAME_FACTORY; + + /// @notice The timestamp of this contract's creation. Used for permissionless fallback proposals. + uint256 public immutable DEPLOYMENT_TIMESTAMP; + + //////////////////////////////////////////////////////////////// + // Constructor // + //////////////////////////////////////////////////////////////// + + /// @notice Constructor sets the fallback timeout and initializes timestamp. + /// @param _fallbackTimeout The timeout in seconds after last proposal when permissionless mode activates. + /// @param _disputeGameFactory The dispute game factory address. + constructor(uint256 _fallbackTimeout, IDisputeGameFactory _disputeGameFactory) { + FALLBACK_TIMEOUT = _fallbackTimeout; + DISPUTE_GAME_FACTORY = _disputeGameFactory; + DEPLOYMENT_TIMESTAMP = block.timestamp; + } + + //////////////////////////////////////////////////////////////// + // Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Allows the owner to whitelist or un-whitelist proposers. + /// @param _proposer The address to set in the proposers mapping. + /// @param _allowed True if whitelisting, false otherwise. + function setProposer(address _proposer, bool _allowed) external onlyOwner { + proposers[_proposer] = _allowed; + emit ProposerPermissionUpdated(_proposer, _allowed); + } + + /// @notice Allows the owner to whitelist or un-whitelist challengers. + /// @param _challenger The address to set in the challengers mapping. + /// @param _allowed True if whitelisting, false otherwise. + function setChallenger(address _challenger, bool _allowed) external onlyOwner { + challengers[_challenger] = _allowed; + emit ChallengerPermissionUpdated(_challenger, _allowed); + } + + /// @notice Returns the last proposal timestamp. + /// @return The last proposal timestamp. + function getLastProposalTimestamp() public view returns (uint256) { + GameType gameType = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + uint256 numGames = DISPUTE_GAME_FACTORY.gameCount(); + + if (numGames == 0) { + return DEPLOYMENT_TIMESTAMP; + } + + uint256 i = numGames - 1; + while (true) { + (GameType gameTypeAtIndex, Timestamp timestamp,) = DISPUTE_GAME_FACTORY.gameAtIndex(i); + uint256 gameTimestamp = uint256(timestamp.raw()); + + if (gameTimestamp < DEPLOYMENT_TIMESTAMP) { + return DEPLOYMENT_TIMESTAMP; + } + + if (gameTypeAtIndex.raw() == gameType.raw()) { + return gameTimestamp; + } + + if (i == 0) { + break; + } + + unchecked { + --i; + } + } + + return DEPLOYMENT_TIMESTAMP; + } + + /// @notice Returns whether proposal fallback timeout has elapsed. + /// @return Whether permissionless proposing is active. + function isProposalPermissionlessMode() public view returns (bool) { + if (proposers[address(0)]) { + return true; + } + + uint256 lastProposalTimestamp = getLastProposalTimestamp(); + return block.timestamp - lastProposalTimestamp > FALLBACK_TIMEOUT; + } + + /// @notice Checks if an address is allowed to propose. + /// @param _proposer The address to check. + /// @return allowed_ Whether the address is allowed to propose. + function isAllowedProposer(address _proposer) external view returns (bool allowed_) { + allowed_ = proposers[_proposer] || isProposalPermissionlessMode(); + } + + /// @notice Checks if an address is allowed to challenge. + /// @param _challenger The address to check. + /// @return allowed_ Whether the address is allowed to challenge. + function isAllowedChallenger(address _challenger) external view returns (bool allowed_) { + allowed_ = challengers[address(0)] || challengers[_challenger]; + } +} diff --git a/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol b/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol new file mode 100644 index 0000000000000..14a3807e6deb3 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +// Libraries +import {Clone} from "@solady/utils/Clone.sol"; +import { + BondDistributionMode, + Claim, + Duration, + GameStatus, + GameType, + Hash, + Proposal, + Timestamp +} from "src/dispute/lib/Types.sol"; +import { + AlreadyInitialized, + BadAuth, + BondTransferFailed, + ClaimAlreadyResolved, + GameNotFinalized, + IncorrectBondAmount, + InvalidBondDistributionMode, + NoCreditToClaim, + UnexpectedRootClaim +} from "src/dispute/lib/Errors.sol"; +import "src/dispute/tee/lib/Errors.sol"; + +// Interfaces +import {ISemver} from "interfaces/universal/ISemver.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; + +// Contracts +import {AccessManager, TEE_DISPUTE_GAME_TYPE} from "src/dispute/tee/AccessManager.sol"; + +/// @title TeeDisputeGame +/// @notice A dispute game that uses TEE (AWS Nitro Enclave) ECDSA signatures +/// instead of SP1 ZK proofs for batch state transition verification. +/// @dev Mirrors OPSuccinctFaultDisputeGame architecture but replaces +/// SP1_VERIFIER.verifyProof() with TEE_PROOF_VERIFIER.verifyBatch(). +/// Uses the same DisputeGameFactory, AnchorStateRegistry, and AccessManager +/// infrastructure from OP Stack. +/// +/// prove() accepts multiple chained batch proofs to support the scenario +/// where different TEE executors handle different sub-ranges within a single game. +/// Each batch carries (startBlockHash, startStateHash, endBlockHash, endStateHash, l2Block). +/// batchDigest = keccak256(abi.encode(startBlockHash, startStateHash, endBlockHash, endStateHash, l2Block)) +/// is computed on-chain and verified via TEE ECDSA signature. +/// +/// rootClaim = keccak256(abi.encode(blockHash, stateHash)) where blockHash and stateHash +/// are passed via extraData. The anchor state stores this combined hash. +contract TeeDisputeGame is Clone, ISemver, IDisputeGame { + //////////////////////////////////////////////////////////////// + // Enums // + //////////////////////////////////////////////////////////////// + + enum ProposalStatus { + Unchallenged, + Challenged, + UnchallengedAndValidProofProvided, + ChallengedAndValidProofProvided, + Resolved + } + + //////////////////////////////////////////////////////////////// + // Structs // + //////////////////////////////////////////////////////////////// + + struct ClaimData { + uint32 parentIndex; + address counteredBy; + address prover; + Claim claim; + ProposalStatus status; + Timestamp deadline; + } + + /// @notice A single batch proof segment within a chained prove() call. + /// @dev Multiple BatchProofs can be submitted to cover a game's full range, + /// e.g. when different TEE executors handle sub-ranges. + struct BatchProof { + bytes32 startBlockHash; + bytes32 startStateHash; + bytes32 endBlockHash; + bytes32 endStateHash; + uint256 l2Block; + bytes signature; // 65 bytes ECDSA (r + s + v) + } + + //////////////////////////////////////////////////////////////// + // Events // + //////////////////////////////////////////////////////////////// + + event Challenged(address indexed challenger); + event Proved(address indexed prover); + event GameClosed(BondDistributionMode bondDistributionMode); + + error EmptyBatchProofs(); + error StartHashMismatch(bytes32 expectedCombined, bytes32 actualCombined); + error BatchChainBreak(uint256 index); + error BatchBlockNotIncreasing(uint256 index, uint256 prevBlock, uint256 curBlock); + error FinalHashMismatch(bytes32 expectedCombined, bytes32 actualCombined); + error FinalBlockMismatch(uint256 expectedBlock, uint256 actualBlock); + error RootClaimMismatch(bytes32 expectedRootClaim, bytes32 actualRootClaim); + + //////////////////////////////////////////////////////////////// + // Immutables // + //////////////////////////////////////////////////////////////// + + Duration internal immutable MAX_CHALLENGE_DURATION; + Duration internal immutable MAX_PROVE_DURATION; + GameType internal immutable GAME_TYPE; + IDisputeGameFactory internal immutable DISPUTE_GAME_FACTORY; + ITeeProofVerifier internal immutable TEE_PROOF_VERIFIER; + uint256 internal immutable CHALLENGER_BOND; + IAnchorStateRegistry internal immutable ANCHOR_STATE_REGISTRY; + AccessManager internal immutable ACCESS_MANAGER; + + //////////////////////////////////////////////////////////////// + // State Vars // + //////////////////////////////////////////////////////////////// + + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + Timestamp public createdAt; + Timestamp public resolvedAt; + GameStatus public status; + /// @notice The proposer EOA captured during initialization, aligned with OP permissioned games. + address public proposer; + bool internal initialized; + ClaimData public claimData; + + mapping(address => uint256) public normalModeCredit; + mapping(address => uint256) public refundModeCredit; + + Proposal public startingOutputRoot; + bool public wasRespectedGameTypeWhenCreated; + BondDistributionMode public bondDistributionMode; + + //////////////////////////////////////////////////////////////// + // Constructor // + //////////////////////////////////////////////////////////////// + + constructor( + Duration _maxChallengeDuration, + Duration _maxProveDuration, + IDisputeGameFactory _disputeGameFactory, + ITeeProofVerifier _teeProofVerifier, + uint256 _challengerBond, + IAnchorStateRegistry _anchorStateRegistry, + AccessManager _accessManager + ) { + GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + MAX_CHALLENGE_DURATION = _maxChallengeDuration; + MAX_PROVE_DURATION = _maxProveDuration; + DISPUTE_GAME_FACTORY = _disputeGameFactory; + TEE_PROOF_VERIFIER = _teeProofVerifier; + CHALLENGER_BOND = _challengerBond; + ANCHOR_STATE_REGISTRY = _anchorStateRegistry; + ACCESS_MANAGER = _accessManager; + } + + //////////////////////////////////////////////////////////////// + // Initialize // + //////////////////////////////////////////////////////////////// + + function initialize() external payable virtual { + if (initialized) revert AlreadyInitialized(); + if (address(DISPUTE_GAME_FACTORY) != msg.sender) revert IncorrectDisputeGameFactory(); + if (!ACCESS_MANAGER.isAllowedProposer(tx.origin)) revert BadAuth(); + + assembly { + if iszero(eq(calldatasize(), 0xBE)) { + mstore(0x00, 0x9824bdab) + revert(0x1C, 0x04) + } + } + + // Verify rootClaim == keccak256(abi.encode(blockHash, stateHash)) + bytes32 expectedRootClaim = keccak256(abi.encode(blockHash(), stateHash())); + if (expectedRootClaim != rootClaim().raw()) { + revert RootClaimMismatch(expectedRootClaim, rootClaim().raw()); + } + + if (parentIndex() != type(uint32).max) { + (,, IDisputeGame proxy) = DISPUTE_GAME_FACTORY.gameAtIndex(parentIndex()); + + if ( + !ANCHOR_STATE_REGISTRY.isGameRespected(proxy) || ANCHOR_STATE_REGISTRY.isGameBlacklisted(proxy) + || ANCHOR_STATE_REGISTRY.isGameRetired(proxy) + ) { + revert InvalidParentGame(); + } + + startingOutputRoot = Proposal({ + l2SequenceNumber: TeeDisputeGame(address(proxy)).l2SequenceNumber(), + root: Hash.wrap(TeeDisputeGame(address(proxy)).rootClaim().raw()) + }); + + if (proxy.status() == GameStatus.CHALLENGER_WINS) revert InvalidParentGame(); + } else { + (startingOutputRoot.root, startingOutputRoot.l2SequenceNumber) = + IAnchorStateRegistry(ANCHOR_STATE_REGISTRY).anchors(GAME_TYPE); + } + + if (l2SequenceNumber() <= startingOutputRoot.l2SequenceNumber) { + revert UnexpectedRootClaim(rootClaim()); + } + + claimData = ClaimData({ + parentIndex: parentIndex(), + counteredBy: address(0), + prover: address(0), + claim: rootClaim(), + status: ProposalStatus.Unchallenged, + deadline: Timestamp.wrap(uint64(block.timestamp + MAX_CHALLENGE_DURATION.raw())) + }); + + initialized = true; + proposer = tx.origin; + refundModeCredit[proposer] += msg.value; + createdAt = Timestamp.wrap(uint64(block.timestamp)); + wasRespectedGameTypeWhenCreated = + GameType.unwrap(ANCHOR_STATE_REGISTRY.respectedGameType()) == GameType.unwrap(GAME_TYPE); + } + + //////////////////////////////////////////////////////////////// + // Core Game Logic // + //////////////////////////////////////////////////////////////// + + function challenge() external payable returns (ProposalStatus) { + if (claimData.status != ProposalStatus.Unchallenged) revert ClaimAlreadyChallenged(); + if (!ACCESS_MANAGER.isAllowedChallenger(msg.sender)) revert BadAuth(); + if (gameOver()) revert GameOver(); + if (msg.value != CHALLENGER_BOND) revert IncorrectBondAmount(); + + claimData.counteredBy = msg.sender; + claimData.status = ProposalStatus.Challenged; + claimData.deadline = Timestamp.wrap(uint64(block.timestamp + MAX_PROVE_DURATION.raw())); + refundModeCredit[msg.sender] += msg.value; + + emit Challenged(claimData.counteredBy); + return claimData.status; + } + + /// @notice Submit chained batch proofs to verify the full state transition. + /// @dev Each BatchProof covers a sub-range with (startBlockHash, startStateHash, endBlockHash, endStateHash). + /// The contract verifies: + /// 1. keccak256(proofs[0].startBlockHash, startStateHash) == startingOutputRoot.root + /// 2. proofs[i].end{Block,State}Hash == proofs[i+1].start{Block,State}Hash (chain continuity) + /// 3. proofs[i].l2Block < proofs[i+1].l2Block (monotonically increasing) + /// 4. keccak256(proofs[last].endBlockHash, endStateHash) == rootClaim + /// 5. proofs[last].l2Block == l2SequenceNumber + /// 6. Each batch's TEE signature is valid (via TEE_PROOF_VERIFIER) + /// @param proofBytes ABI-encoded BatchProof[] array + function prove(bytes calldata proofBytes) external returns (ProposalStatus) { + if (gameOver()) revert GameOver(); + + BatchProof[] memory proofs = abi.decode(proofBytes, (BatchProof[])); + if (proofs.length == 0) revert EmptyBatchProofs(); + + // Verify first proof starts from the starting output root + { + bytes32 startCombined = keccak256(abi.encode(proofs[0].startBlockHash, proofs[0].startStateHash)); + bytes32 expectedStart = Hash.unwrap(startingOutputRoot.root); + if (startCombined != expectedStart) { + revert StartHashMismatch(expectedStart, startCombined); + } + } + + uint256 prevBlock = startingOutputRoot.l2SequenceNumber; + + for (uint256 i = 0; i < proofs.length; i++) { + // Chain continuity: each batch starts where the previous ended + if (i > 0) { + if ( + proofs[i].startBlockHash != proofs[i - 1].endBlockHash + || proofs[i].startStateHash != proofs[i - 1].endStateHash + ) { + revert BatchChainBreak(i); + } + } + + // L2 block must be monotonically increasing + if (proofs[i].l2Block <= prevBlock) { + revert BatchBlockNotIncreasing(i, prevBlock, proofs[i].l2Block); + } + + // Compute batchDigest on-chain and verify TEE signature + bytes32 batchDigest = keccak256( + abi.encode( + proofs[i].startBlockHash, + proofs[i].startStateHash, + proofs[i].endBlockHash, + proofs[i].endStateHash, + proofs[i].l2Block + ) + ); + TEE_PROOF_VERIFIER.verifyBatch(batchDigest, proofs[i].signature); + + prevBlock = proofs[i].l2Block; + } + + // Final endHash must match rootClaim (which is keccak256(blockHash, stateHash)) + { + uint256 last = proofs.length - 1; + bytes32 endCombined = keccak256(abi.encode(proofs[last].endBlockHash, proofs[last].endStateHash)); + if (endCombined != rootClaim().raw()) { + revert FinalHashMismatch(rootClaim().raw(), endCombined); + } + } + + // Final l2Block must match game's l2SequenceNumber + if (prevBlock != l2SequenceNumber()) { + revert FinalBlockMismatch(l2SequenceNumber(), prevBlock); + } + + claimData.prover = msg.sender; + + if (claimData.counteredBy == address(0)) { + claimData.status = ProposalStatus.UnchallengedAndValidProofProvided; + } else { + claimData.status = ProposalStatus.ChallengedAndValidProofProvided; + } + + emit Proved(claimData.prover); + return claimData.status; + } + + function resolve() external returns (GameStatus) { + if (status != GameStatus.IN_PROGRESS) revert ClaimAlreadyResolved(); + + GameStatus parentGameStatus = _getParentGameStatus(); + if (parentGameStatus == GameStatus.IN_PROGRESS) revert ParentGameNotResolved(); + + if (parentGameStatus == GameStatus.CHALLENGER_WINS) { + status = GameStatus.CHALLENGER_WINS; + normalModeCredit[claimData.counteredBy] = address(this).balance; + } else { + if (!gameOver()) revert GameNotOver(); + + if (claimData.status == ProposalStatus.Unchallenged) { + status = GameStatus.DEFENDER_WINS; + normalModeCredit[proposer] = address(this).balance; + } else if (claimData.status == ProposalStatus.Challenged) { + status = GameStatus.CHALLENGER_WINS; + normalModeCredit[claimData.counteredBy] = address(this).balance; + } else if (claimData.status == ProposalStatus.UnchallengedAndValidProofProvided) { + status = GameStatus.DEFENDER_WINS; + normalModeCredit[proposer] = address(this).balance; + } else if (claimData.status == ProposalStatus.ChallengedAndValidProofProvided) { + status = GameStatus.DEFENDER_WINS; + if (claimData.prover == proposer) { + normalModeCredit[claimData.prover] = address(this).balance; + } else { + normalModeCredit[claimData.prover] = CHALLENGER_BOND; + normalModeCredit[proposer] = address(this).balance - CHALLENGER_BOND; + } + } else { + revert InvalidProposalStatus(); + } + } + + claimData.status = ProposalStatus.Resolved; + resolvedAt = Timestamp.wrap(uint64(block.timestamp)); + emit Resolved(status); + + return status; + } + + function claimCredit(address _recipient) external { + closeGame(); + + uint256 recipientCredit; + if (bondDistributionMode == BondDistributionMode.REFUND) { + recipientCredit = refundModeCredit[_recipient]; + } else if (bondDistributionMode == BondDistributionMode.NORMAL) { + recipientCredit = normalModeCredit[_recipient]; + } else { + revert InvalidBondDistributionMode(); + } + + if (recipientCredit == 0) revert NoCreditToClaim(); + + refundModeCredit[_recipient] = 0; + normalModeCredit[_recipient] = 0; + + (bool success,) = _recipient.call{value: recipientCredit}(hex""); + if (!success) revert BondTransferFailed(); + } + + function closeGame() public { + if (bondDistributionMode == BondDistributionMode.REFUND || bondDistributionMode == BondDistributionMode.NORMAL) + { + return; + } else if (bondDistributionMode != BondDistributionMode.UNDECIDED) { + revert InvalidBondDistributionMode(); + } + + bool finalized = ANCHOR_STATE_REGISTRY.isGameFinalized(IDisputeGame(address(this))); + if (!finalized) { + revert GameNotFinalized(); + } + + try ANCHOR_STATE_REGISTRY.setAnchorState(IDisputeGame(address(this))) {} catch {} + + bool properGame = ANCHOR_STATE_REGISTRY.isGameProper(IDisputeGame(address(this))); + + if (properGame) { + bondDistributionMode = BondDistributionMode.NORMAL; + } else { + bondDistributionMode = BondDistributionMode.REFUND; + } + + emit GameClosed(bondDistributionMode); + } + + //////////////////////////////////////////////////////////////// + // View Functions // + //////////////////////////////////////////////////////////////// + + function gameOver() public view returns (bool gameOver_) { + gameOver_ = claimData.deadline.raw() < uint64(block.timestamp) || claimData.prover != address(0); + } + + function credit(address _recipient) external view returns (uint256 credit_) { + if (bondDistributionMode == BondDistributionMode.REFUND) { + credit_ = refundModeCredit[_recipient]; + } else { + credit_ = normalModeCredit[_recipient]; + } + } + + //////////////////////////////////////////////////////////////// + // IDisputeGame Impl // + //////////////////////////////////////////////////////////////// + + function gameType() public view returns (GameType gameType_) { gameType_ = GAME_TYPE; } + function gameCreator() public pure returns (address creator_) { creator_ = _getArgAddress(0x00); } + function rootClaim() public pure returns (Claim rootClaim_) { rootClaim_ = Claim.wrap(_getArgBytes32(0x14)); } + function l1Head() public pure returns (Hash l1Head_) { l1Head_ = Hash.wrap(_getArgBytes32(0x34)); } + function l2SequenceNumber() public pure returns (uint256 l2SequenceNumber_) { l2SequenceNumber_ = _getArgUint256(0x54); } + function l2BlockNumber() public pure returns (uint256 l2BlockNumber_) { l2BlockNumber_ = l2SequenceNumber(); } + function parentIndex() public pure returns (uint32 parentIndex_) { parentIndex_ = _getArgUint32(0x74); } + function blockHash() public pure returns (bytes32 blockHash_) { blockHash_ = _getArgBytes32(0x78); } + function stateHash() public pure returns (bytes32 stateHash_) { stateHash_ = _getArgBytes32(0x98); } + function startingBlockNumber() external view returns (uint256) { return startingOutputRoot.l2SequenceNumber; } + function startingRootHash() external view returns (Hash) { return startingOutputRoot.root; } + function extraData() public pure returns (bytes memory extraData_) { extraData_ = _getArgBytes(0x54, 0x64); } + + function gameData() external view returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_) { + gameType_ = gameType(); + rootClaim_ = rootClaim(); + extraData_ = extraData(); + } + + //////////////////////////////////////////////////////////////// + // Immutable Getters // + //////////////////////////////////////////////////////////////// + + function maxChallengeDuration() external view returns (Duration) { return MAX_CHALLENGE_DURATION; } + function maxProveDuration() external view returns (Duration) { return MAX_PROVE_DURATION; } + function disputeGameFactory() external view returns (IDisputeGameFactory) { return DISPUTE_GAME_FACTORY; } + function teeProofVerifier() external view returns (ITeeProofVerifier) { return TEE_PROOF_VERIFIER; } + function challengerBond() external view returns (uint256) { return CHALLENGER_BOND; } + function anchorStateRegistry() external view returns (IAnchorStateRegistry) { return ANCHOR_STATE_REGISTRY; } + function accessManager() external view returns (AccessManager) { return ACCESS_MANAGER; } + + //////////////////////////////////////////////////////////////// + // Internal Functions // + //////////////////////////////////////////////////////////////// + + function _getParentGameStatus() private view returns (GameStatus) { + if (parentIndex() != type(uint32).max) { + (,, IDisputeGame parentGame) = DISPUTE_GAME_FACTORY.gameAtIndex(parentIndex()); + return parentGame.status(); + } else { + return GameStatus.DEFENDER_WINS; + } + } +} diff --git a/packages/contracts-bedrock/src/dispute/tee/TeeProofVerifier.sol b/packages/contracts-bedrock/src/dispute/tee/TeeProofVerifier.sol new file mode 100644 index 0000000000000..9b1713d39fe2b --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/TeeProofVerifier.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IRiscZeroVerifier} from "interfaces/dispute/IRiscZeroVerifier.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @title TEE Proof Verifier for OP Stack DisputeGame +/// @notice Verifies TEE enclave identity via ZK proof (owner-gated registration) and +/// batch state transitions via ECDSA signature (permissionless verification). +/// @dev Two core responsibilities: +/// 1. register(): Owner verifies ZK proof of Nitro attestation, binds EOA <-> PCR on-chain +/// 2. verifyBatch(): ecrecover signature, check signer is a registered enclave +/// +/// Journal format (from RISC Zero guest program): +/// - 8 bytes: timestamp_ms (big-endian uint64) +/// - 32 bytes: pcr_hash = SHA256(PCR0) +/// - 96 bytes: root_pubkey (P384 without 0x04 prefix) +/// - 1 byte: pubkey_len +/// - pubkey_len bytes: pubkey (secp256k1 uncompressed, 65 bytes) +/// - 2 bytes: user_data_len (big-endian uint16) +/// - user_data_len bytes: user_data +contract TeeProofVerifier { + // ============ Immutables ============ + + /// @notice RISC Zero Groth16 verifier (only called during registration) + IRiscZeroVerifier public immutable riscZeroVerifier; + + /// @notice RISC Zero guest image ID (hash of the attestation verification guest ELF) + bytes32 public immutable imageId; + + /// @notice Expected AWS Nitro root public key (96 bytes, P384 without 0x04 prefix) + bytes public expectedRootKey; + + // ============ State ============ + + struct EnclaveInfo { + bytes32 pcrHash; + uint64 registeredAt; + } + + /// @notice Registered enclaves: EOA address => enclave info + mapping(address => EnclaveInfo) public registeredEnclaves; + + /// @notice Contract owner (can register and revoke enclaves) + address public owner; + + // ============ Events ============ + + event EnclaveRegistered( + address indexed enclaveAddress, bytes32 indexed pcrHash, uint64 timestampMs + ); + + event EnclaveRevoked(address indexed enclaveAddress); + + event OwnerTransferred(address indexed oldOwner, address indexed newOwner); + + // ============ Errors ============ + + error InvalidProof(); + error InvalidRootKey(); + error InvalidPublicKey(); + error EnclaveAlreadyRegistered(); + error EnclaveNotRegistered(); + error InvalidSignature(); + error Unauthorized(); + + // ============ Modifiers ============ + + modifier onlyOwner() { + if (msg.sender != owner) revert Unauthorized(); + _; + } + + // ============ Constructor ============ + + /// @param _riscZeroVerifier RISC Zero verifier contract (Groth16 or mock) + /// @param _imageId RISC Zero guest image ID + /// @param _rootKey Expected AWS Nitro root public key (96 bytes) + constructor( + IRiscZeroVerifier _riscZeroVerifier, + bytes32 _imageId, + bytes memory _rootKey + ) { + riscZeroVerifier = _riscZeroVerifier; + imageId = _imageId; + expectedRootKey = _rootKey; + owner = msg.sender; + } + + // ============ Registration (Owner Only) ============ + + /// @notice Register a TEE enclave by verifying its ZK attestation proof. + /// @dev Only callable by the owner. The owner calling register() is the trust gate -- + /// the PCR and EOA from the proof are automatically trusted upon registration. + /// @param seal The RISC Zero proof seal (Groth16) + /// @param journal The journal output from the guest program + function register(bytes calldata seal, bytes calldata journal) external onlyOwner { + // 1. Verify ZK proof + bytes32 journalDigest = sha256(journal); + try riscZeroVerifier.verify(seal, imageId, journalDigest) {} + catch { + revert InvalidProof(); + } + + // 2. Parse journal + ( + uint64 timestampMs, + bytes32 pcrHash, + bytes memory rootKey, + bytes memory publicKey, + ) = _parseJournal(journal); + + // 3. Verify root key matches AWS Nitro official root + if (keccak256(rootKey) != keccak256(expectedRootKey)) { + revert InvalidRootKey(); + } + + // 4. Extract EOA address from secp256k1 public key (65 bytes: 0x04 + x + y) + if (publicKey.length != 65) { + revert InvalidPublicKey(); + } + address enclaveAddress = _extractAddress(publicKey); + + // 5. Check not already registered + if (registeredEnclaves[enclaveAddress].registeredAt != 0) { + revert EnclaveAlreadyRegistered(); + } + + // 6. Store registration (PCR is implicitly trusted by owner's approval) + registeredEnclaves[enclaveAddress] = + EnclaveInfo({pcrHash: pcrHash, registeredAt: timestampMs}); + + emit EnclaveRegistered(enclaveAddress, pcrHash, timestampMs); + } + + // ============ Batch Verification (Permissionless) ============ + + /// @notice Verify a batch state transition signed by a registered TEE enclave. + /// @param digest The hash of the batch data (pre_batch, txs, post_batch, etc.) + /// @param signature ECDSA signature (65 bytes: r + s + v) + /// @return signer The address of the verified enclave that signed the batch + function verifyBatch(bytes32 digest, bytes calldata signature) + external + view + returns (address signer) + { + // 1. Recover signer from signature + (address recovered, ECDSA.RecoverError err) = ECDSA.tryRecover(digest, signature); + if (err != ECDSA.RecoverError.NoError || recovered == address(0)) { + revert InvalidSignature(); + } + + // 2. Check signer is a registered enclave + if (registeredEnclaves[recovered].registeredAt == 0) { + revert EnclaveNotRegistered(); + } + + return recovered; + } + + // ============ Query Functions ============ + + /// @notice Check if an address is a registered enclave + /// @param enclaveAddress The address to check + /// @return True if the address is registered + function isRegistered(address enclaveAddress) external view returns (bool) { + return registeredEnclaves[enclaveAddress].registeredAt != 0; + } + + // ============ Admin Functions ============ + + /// @notice Revoke a registered enclave + /// @param enclaveAddress The enclave address to revoke + function revoke(address enclaveAddress) external onlyOwner { + if (registeredEnclaves[enclaveAddress].registeredAt == 0) { + revert EnclaveNotRegistered(); + } + delete registeredEnclaves[enclaveAddress]; + emit EnclaveRevoked(enclaveAddress); + } + + /// @notice Transfer ownership + /// @param newOwner The new owner address + function transferOwnership(address newOwner) external onlyOwner { + address oldOwner = owner; + owner = newOwner; + emit OwnerTransferred(oldOwner, newOwner); + } + + // ============ Internal Functions ============ + + /// @notice Parse the journal bytes into attestation fields + function _parseJournal(bytes calldata journal) + internal + pure + returns ( + uint64 timestampMs, + bytes32 pcrHash, + bytes memory rootKey, + bytes memory publicKey, + bytes memory userData + ) + { + uint256 offset = 0; + + timestampMs = uint64(bytes8(journal[offset:offset + 8])); + offset += 8; + + pcrHash = bytes32(journal[offset:offset + 32]); + offset += 32; + + rootKey = journal[offset:offset + 96]; + offset += 96; + + uint8 pubkeyLen = uint8(journal[offset]); + offset += 1; + + publicKey = journal[offset:offset + pubkeyLen]; + offset += pubkeyLen; + + uint16 userDataLen = uint16(bytes2(journal[offset:offset + 2])); + offset += 2; + + userData = journal[offset:offset + userDataLen]; + } + + /// @notice Extract Ethereum address from secp256k1 uncompressed public key + /// @param publicKey 65 bytes: 0x04 prefix + 32-byte x + 32-byte y + function _extractAddress(bytes memory publicKey) internal pure returns (address) { + bytes memory coordinates = new bytes(64); + for (uint256 i = 0; i < 64; i++) { + coordinates[i] = publicKey[i + 1]; + } + return address(uint160(uint256(keccak256(coordinates)))); + } +} diff --git a/packages/contracts-bedrock/src/dispute/tee/lib/Errors.sol b/packages/contracts-bedrock/src/dispute/tee/lib/Errors.sol new file mode 100644 index 0000000000000..9a758435f7321 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/lib/Errors.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +//////////////////////////////////////////////////////////////// +// `TeeDisputeGame` Errors // +//////////////////////////////////////////////////////////////// + +/// @notice Thrown when the claim has already been challenged. +error ClaimAlreadyChallenged(); + +/// @notice Thrown when the game type of the parent game does not match the current game. +error UnexpectedGameType(); + +/// @notice Thrown when the parent game is invalid. +error InvalidParentGame(); + +/// @notice Thrown when the parent game is not resolved. +error ParentGameNotResolved(); + +/// @notice Thrown when the game is over. +error GameOver(); + +/// @notice Thrown when the game is not over. +error GameNotOver(); + +/// @notice Thrown when the proposal status is invalid. +error InvalidProposalStatus(); + +/// @notice Thrown when the game is initialized by an incorrect factory. +error IncorrectDisputeGameFactory(); diff --git a/packages/contracts-bedrock/test/dispute/tee/AccessManager.t.sol b/packages/contracts-bedrock/test/dispute/tee/AccessManager.t.sol new file mode 100644 index 0000000000000..101ff6bfdbe2a --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/AccessManager.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {AccessManager, TEE_DISPUTE_GAME_TYPE} from "src/dispute/tee/AccessManager.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {GameType, Claim, GameStatus} from "src/dispute/lib/Types.sol"; +import {MockDisputeGameFactory} from "test/dispute/tee/mocks/MockDisputeGameFactory.sol"; +import {MockStatusDisputeGame} from "test/dispute/tee/mocks/MockStatusDisputeGame.sol"; + +contract AccessManagerTest is Test { + uint256 internal constant FALLBACK_TIMEOUT = 7 days; + + MockDisputeGameFactory internal factory; + AccessManager internal accessManager; + + function setUp() public { + factory = new MockDisputeGameFactory(); + accessManager = new AccessManager(FALLBACK_TIMEOUT, IDisputeGameFactory(address(factory))); + } + + function test_getLastProposalTimestamp_returnsDeploymentTimestampWhenNoGames() public view { + assertEq(accessManager.getLastProposalTimestamp(), accessManager.DEPLOYMENT_TIMESTAMP()); + } + + function test_getLastProposalTimestamp_scansBackwardForLatestTeeGame() public { + factory.pushGame( + GameType.wrap(100), + uint64(block.timestamp + 1), + IDisputeGame(address(_mockGame(GameType.wrap(100), 1))), + Claim.wrap(bytes32(uint256(1))), + bytes("") + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp + 2), + IDisputeGame(address(_mockGame(GameType.wrap(TEE_DISPUTE_GAME_TYPE), 2))), + Claim.wrap(bytes32(uint256(2))), + bytes("") + ); + factory.pushGame( + GameType.wrap(200), + uint64(block.timestamp + 3), + IDisputeGame(address(_mockGame(GameType.wrap(200), 3))), + Claim.wrap(bytes32(uint256(3))), + bytes("") + ); + + assertEq(accessManager.getLastProposalTimestamp(), block.timestamp + 2); + } + + function test_getLastProposalTimestamp_returnsDeploymentTimestampWhenLatestTeeGameIsOlderThanDeployment() + public + { + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(accessManager.DEPLOYMENT_TIMESTAMP() - 1), + IDisputeGame(address(_mockGame(GameType.wrap(TEE_DISPUTE_GAME_TYPE), 1))), + Claim.wrap(bytes32(uint256(1))), + bytes("") + ); + + assertEq(accessManager.getLastProposalTimestamp(), accessManager.DEPLOYMENT_TIMESTAMP()); + } + + function test_isProposalPermissionlessMode_activatesAfterFallbackTimeout() public { + vm.warp(block.timestamp + FALLBACK_TIMEOUT + 1); + assertTrue(accessManager.isProposalPermissionlessMode()); + } + + function test_isProposalPermissionlessMode_zeroAddressOverride() public { + accessManager.setProposer(address(0), true); + assertTrue(accessManager.isProposalPermissionlessMode()); + } + + function test_isAllowedProposer_returnsTrueForWhitelistedProposer() public { + address proposer = makeAddr("proposer"); + accessManager.setProposer(proposer, true); + assertTrue(accessManager.isAllowedProposer(proposer)); + } + + function test_isAllowedChallenger_respectsZeroAddressWildcard() public { + address challenger = makeAddr("challenger"); + accessManager.setChallenger(address(0), true); + assertTrue(accessManager.isAllowedChallenger(challenger)); + } + + function test_isAllowedChallenger_returnsFalseForUnlistedChallenger() public { + assertFalse(accessManager.isAllowedChallenger(makeAddr("challenger"))); + } + + function _mockGame(GameType gameType_, uint256 nonce) internal returns (MockStatusDisputeGame) { + return new MockStatusDisputeGame({ + creator_: vm.addr(nonce + 1), + gameType_: gameType_, + rootClaim_: Claim.wrap(bytes32(nonce)), + l2SequenceNumber_: nonce, + extraData_: bytes(""), + status_: GameStatus.IN_PROGRESS, + createdAt_: uint64(block.timestamp), + resolvedAt_: 0, + respected_: true, + anchorStateRegistry_: IAnchorStateRegistry(address(0)) + }); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/AnchorStateRegistryCompatibility.t.sol b/packages/contracts-bedrock/test/dispute/tee/AnchorStateRegistryCompatibility.t.sol new file mode 100644 index 0000000000000..7426c95c997dc --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/AnchorStateRegistryCompatibility.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Proxy} from "src/universal/Proxy.sol"; +import {AnchorStateRegistry} from "src/dispute/AnchorStateRegistry.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {ISystemConfig} from "interfaces/L1/ISystemConfig.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; +import {TeeDisputeGame} from "src/dispute/tee/TeeDisputeGame.sol"; +import {AccessManager, TEE_DISPUTE_GAME_TYPE} from "src/dispute/tee/AccessManager.sol"; +import {Claim, Duration, GameType, Hash, Proposal} from "src/dispute/lib/Types.sol"; +import {MockDisputeGameFactory} from "test/dispute/tee/mocks/MockDisputeGameFactory.sol"; +import {MockSystemConfig} from "test/dispute/tee/mocks/MockSystemConfig.sol"; +import {MockTeeProofVerifier} from "test/dispute/tee/mocks/MockTeeProofVerifier.sol"; +import {TeeTestUtils} from "test/dispute/tee/helpers/TeeTestUtils.sol"; + +contract AnchorStateRegistryCompatibilityTest is TeeTestUtils { + uint256 internal constant DEFENDER_BOND = 1 ether; + uint256 internal constant CHALLENGER_BOND = 2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 1 days; + uint64 internal constant MAX_PROVE_DURATION = 12 hours; + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("anchor-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("anchor-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 10; + + MockDisputeGameFactory internal factory; + MockSystemConfig internal systemConfig; + MockTeeProofVerifier internal teeProofVerifier; + AccessManager internal accessManager; + TeeDisputeGame internal implementation; + IAnchorStateRegistry internal anchorStateRegistry; + + address internal proposer; + address internal challenger; + address internal executor; + + function setUp() public { + proposer = makeWallet(DEFAULT_PROPOSER_KEY, "proposer").addr; + challenger = makeWallet(DEFAULT_CHALLENGER_KEY, "challenger").addr; + executor = makeWallet(DEFAULT_EXECUTOR_KEY, "executor").addr; + + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + factory = new MockDisputeGameFactory(); + systemConfig = new MockSystemConfig(address(this)); + teeProofVerifier = new MockTeeProofVerifier(); + + AnchorStateRegistry anchorStateRegistryImpl = new AnchorStateRegistry(0); + Proxy anchorStateRegistryProxy = new Proxy(address(this)); + anchorStateRegistryProxy.upgradeToAndCall( + address(anchorStateRegistryImpl), + abi.encodeCall( + anchorStateRegistryImpl.initialize, + ( + ISystemConfig(address(systemConfig)), + IDisputeGameFactory(address(factory)), + Proposal({ + root: Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), + l2SequenceNumber: ANCHOR_L2_BLOCK + }), + GameType.wrap(TEE_DISPUTE_GAME_TYPE) + ) + ) + ); + anchorStateRegistry = IAnchorStateRegistry(address(anchorStateRegistryProxy)); + + accessManager = new AccessManager(MAX_CHALLENGE_DURATION, IDisputeGameFactory(address(factory))); + accessManager.setProposer(proposer, true); + accessManager.setChallenger(challenger, true); + + implementation = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(teeProofVerifier)), + CHALLENGER_BOND, + anchorStateRegistry, + accessManager + ); + + factory.setImplementation(GameType.wrap(TEE_DISPUTE_GAME_TYPE), implementation); + factory.setInitBond(GameType.wrap(TEE_DISPUTE_GAME_TYPE), DEFENDER_BOND); + } + + function test_anchorStateRegistry_acceptsTeeDisputeGame() public { + vm.warp(block.timestamp + 1); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + assertTrue(anchorStateRegistry.isGameRegistered(game)); + assertTrue(anchorStateRegistry.isGameProper(game)); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: keccak256("end-block"), + endStateHash: keccak256("end-state"), + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + game.prove(abi.encode(proofs)); + game.resolve(); + + vm.warp(block.timestamp + 1); + AnchorStateRegistry(address(anchorStateRegistry)).setAnchorState(game); + + assertEq(address(AnchorStateRegistry(address(anchorStateRegistry)).anchorGame()), address(game)); + } + + function _createGame( + address creator, + uint256 l2SequenceNumber, + uint32 parentIndex, + bytes32 blockHash_, + bytes32 stateHash_ + ) + internal + returns (TeeDisputeGame game, bytes memory extraData, Claim rootClaim) + { + extraData = buildExtraData(l2SequenceNumber, parentIndex, blockHash_, stateHash_); + rootClaim = computeRootClaim(blockHash_, stateHash_); + + vm.startPrank(creator, creator); + game = TeeDisputeGame( + payable(address(factory.create{value: DEFENDER_BOND}(GameType.wrap(TEE_DISPUTE_GAME_TYPE), rootClaim, extraData))) + ); + vm.stopPrank(); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouter.t.sol b/packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouter.t.sol new file mode 100644 index 0000000000000..3c0a1963d7dd0 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouter.t.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {DisputeGameFactoryRouter} from "src/dispute/DisputeGameFactoryRouter.sol"; +import {IDisputeGameFactoryRouter} from "interfaces/dispute/IDisputeGameFactoryRouter.sol"; +import {GameType, Claim} from "src/dispute/lib/Types.sol"; + +contract DisputeGameFactoryRouterTest is Test { + DisputeGameFactoryRouter public router; + + // XLayer factory on ETH mainnet + address constant XLAYER_FACTORY = 0x9D4c8FAEadDdDeeE1Ed0c92dAbAD815c2484f675; + + address owner; + address alice; + + uint256 constant ZONE_XLAYER = 1; + uint256 constant ZONE_OTHER = 2; + + function setUp() public { + owner = address(this); + alice = makeAddr("alice"); + router = new DisputeGameFactoryRouter(); + } + + //////////////////////////////////////////////////////////////// + // Zone CRUD Tests // + //////////////////////////////////////////////////////////////// + + function test_registerZone() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + assertEq(router.getFactory(ZONE_XLAYER), XLAYER_FACTORY); + } + + function test_registerZone_revertDuplicate() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + vm.expectRevert(abi.encodeWithSelector(IDisputeGameFactoryRouter.ZoneAlreadyRegistered.selector, ZONE_XLAYER)); + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + } + + function test_registerZone_revertZeroAddress() public { + vm.expectRevert(IDisputeGameFactoryRouter.ZeroAddress.selector); + router.registerZone(ZONE_XLAYER, address(0)); + } + + function test_updateZone() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + address newFactory = makeAddr("newFactory"); + router.updateZone(ZONE_XLAYER, newFactory); + assertEq(router.getFactory(ZONE_XLAYER), newFactory); + } + + function test_updateZone_revertNotRegistered() public { + vm.expectRevert(abi.encodeWithSelector(IDisputeGameFactoryRouter.ZoneNotRegistered.selector, ZONE_XLAYER)); + router.updateZone(ZONE_XLAYER, XLAYER_FACTORY); + } + + function test_removeZone() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + router.removeZone(ZONE_XLAYER); + assertEq(router.getFactory(ZONE_XLAYER), address(0)); + } + + function test_removeZone_revertNotRegistered() public { + vm.expectRevert(abi.encodeWithSelector(IDisputeGameFactoryRouter.ZoneNotRegistered.selector, ZONE_XLAYER)); + router.removeZone(ZONE_XLAYER); + } + + //////////////////////////////////////////////////////////////// + // Access Control Tests // + //////////////////////////////////////////////////////////////// + + function test_registerZone_revertNotOwner() public { + vm.prank(alice); + vm.expectRevert("Ownable: caller is not the owner"); + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + } + + function test_updateZone_revertNotOwner() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + vm.prank(alice); + vm.expectRevert("Ownable: caller is not the owner"); + router.updateZone(ZONE_XLAYER, makeAddr("newFactory")); + } + + function test_removeZone_revertNotOwner() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + vm.prank(alice); + vm.expectRevert("Ownable: caller is not the owner"); + router.removeZone(ZONE_XLAYER); + } + + //////////////////////////////////////////////////////////////// + // Create Tests (Fork) // + //////////////////////////////////////////////////////////////// + + function test_create_revertZoneNotRegistered() public { + vm.expectRevert(abi.encodeWithSelector(IDisputeGameFactoryRouter.ZoneNotRegistered.selector, ZONE_XLAYER)); + router.create(ZONE_XLAYER, GameType.wrap(0), Claim.wrap(bytes32(0)), ""); + } + + function test_createBatch_revertEmpty() public { + IDisputeGameFactoryRouter.CreateParams[] memory params = new IDisputeGameFactoryRouter.CreateParams[](0); + vm.expectRevert(IDisputeGameFactoryRouter.BatchEmpty.selector); + router.createBatch(params); + } + + function test_createBatch_revertBondMismatch() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + + IDisputeGameFactoryRouter.CreateParams[] memory params = new IDisputeGameFactoryRouter.CreateParams[](1); + params[0] = IDisputeGameFactoryRouter.CreateParams({ + zoneId: ZONE_XLAYER, + gameType: GameType.wrap(0), + rootClaim: Claim.wrap(bytes32(0)), + extraData: "", + bond: 1 ether + }); + + vm.expectRevert(abi.encodeWithSelector(IDisputeGameFactoryRouter.BatchBondMismatch.selector, 1 ether, 0)); + router.createBatch(params); + } + + //////////////////////////////////////////////////////////////// + // View Function Tests // + //////////////////////////////////////////////////////////////// + + function test_getFactory_unregistered() public view { + assertEq(router.getFactory(999), address(0)); + } + + function test_factories_mapping() public { + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + assertEq(router.factories(ZONE_XLAYER), XLAYER_FACTORY); + } + + function test_version() public view { + assertEq(router.version(), "1.0.0"); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouterCreate.t.sol b/packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouterCreate.t.sol new file mode 100644 index 0000000000000..6d4ae3f5400f9 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/DisputeGameFactoryRouterCreate.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {DisputeGameFactoryRouter} from "src/dispute/DisputeGameFactoryRouter.sol"; +import {IDisputeGameFactoryRouter} from "interfaces/dispute/IDisputeGameFactoryRouter.sol"; +import {GameType, Claim} from "src/dispute/lib/Types.sol"; +import {MockCloneableDisputeGame} from "test/dispute/tee/mocks/MockCloneableDisputeGame.sol"; +import {MockDisputeGameFactory} from "test/dispute/tee/mocks/MockDisputeGameFactory.sol"; + +contract DisputeGameFactoryRouterCreateTest is Test { + uint256 internal constant ZONE_ONE = 1; + uint256 internal constant ZONE_TWO = 2; + GameType internal constant GAME_TYPE = GameType.wrap(1960); + + DisputeGameFactoryRouter internal router; + MockDisputeGameFactory internal factoryOne; + MockDisputeGameFactory internal factoryTwo; + MockCloneableDisputeGame internal gameImpl; + + function setUp() public { + router = new DisputeGameFactoryRouter(); + factoryOne = new MockDisputeGameFactory(); + factoryTwo = new MockDisputeGameFactory(); + gameImpl = new MockCloneableDisputeGame(); + + factoryOne.setImplementation(GAME_TYPE, gameImpl); + factoryTwo.setImplementation(GAME_TYPE, gameImpl); + factoryOne.setInitBond(GAME_TYPE, 1 ether); + factoryTwo.setInitBond(GAME_TYPE, 2 ether); + + router.registerZone(ZONE_ONE, address(factoryOne)); + router.registerZone(ZONE_TWO, address(factoryTwo)); + } + + function test_create_routesToZoneFactory() public { + Claim rootClaim = Claim.wrap(keccak256("zone-one")); + bytes memory extraData = abi.encodePacked(uint256(1)); + + address proxy = router.create{value: 1 ether}(ZONE_ONE, GAME_TYPE, rootClaim, extraData); + + assertTrue(proxy != address(0)); + assertEq(factoryOne.gameCount(), 1); + assertEq(factoryTwo.gameCount(), 0); + } + + function test_createBatch_routesAcrossZones() public { + IDisputeGameFactoryRouter.CreateParams[] memory params = new IDisputeGameFactoryRouter.CreateParams[](2); + params[0] = IDisputeGameFactoryRouter.CreateParams({ + zoneId: ZONE_ONE, + gameType: GAME_TYPE, + rootClaim: Claim.wrap(keccak256("zone-one")), + extraData: abi.encodePacked(uint256(11)), + bond: 1 ether + }); + params[1] = IDisputeGameFactoryRouter.CreateParams({ + zoneId: ZONE_TWO, + gameType: GAME_TYPE, + rootClaim: Claim.wrap(keccak256("zone-two")), + extraData: abi.encodePacked(uint256(22)), + bond: 2 ether + }); + + address[] memory proxies = router.createBatch{value: 3 ether}(params); + + assertEq(proxies.length, 2); + assertTrue(proxies[0] != address(0)); + assertTrue(proxies[1] != address(0)); + assertEq(factoryOne.gameCount(), 1); + assertEq(factoryTwo.gameCount(), 1); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/INTEGRATION_TEST_PLAN.md b/packages/contracts-bedrock/test/dispute/tee/INTEGRATION_TEST_PLAN.md new file mode 100644 index 0000000000000..ce29e39f61d6c --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/INTEGRATION_TEST_PLAN.md @@ -0,0 +1,153 @@ +# TEE TEE Dispute Game — Integration Test Plan + +## Background + +Current test coverage has three layers: + +| Layer | Files | Characteristics | +|-------|-------|-----------------| +| Unit tests | 5 files, ~50 tests | All dependencies mocked, isolated per contract | +| Integration test | `AnchorStateRegistryCompatibility.t.sol` (1 test) | Real ASR + Proxy, but Factory and TeeProofVerifier are mocked | +| Fork E2E | `DisputeGameFactoryRouterFork.t.sol` (3 tests) | Mainnet fork, requires `ETH_RPC_URL`, skipped otherwise | + +### Problem + +The integration layer only covers **one happy path** (unchallenged → prove → DEFENDER_WINS → setAnchorState). The fork tests cover a challenged DEFENDER_WINS path but are **conditional on `ETH_RPC_URL`** — if CI doesn't configure it, these paths have zero real-contract coverage. + +Critical paths involving **fund distribution (REFUND vs NORMAL)**, **parent-child game chains**, and **third-party prover bond splitting** have never been verified with real ASR + real Factory together. + +## Goal + +Create `TeeDisputeGameIntegration.t.sol` — a non-fork integration test suite that verifies the TEE TEE dispute game full lifecycle with real contracts, runnable in CI without any RPC dependency. + +## Contract Setup + +### Real contracts (deployed via Proxy where applicable) + +| Contract | Deploy Method | Notes | +|----------|--------------|-------| +| `DisputeGameFactory` | Proxy + `initialize(owner)` | Replaces `MockDisputeGameFactory` | +| `AnchorStateRegistry` | Proxy + `initialize(...)` | Already real in current integration test | +| `TeeProofVerifier` | `new TeeProofVerifier(verifier, imageId, rootKey)` | With real enclave registration flow | +| `TeeDisputeGame` | Implementation set in factory, cloned on `create()` | Real game logic | +| `AccessManager` | `new AccessManager(timeout, factory)` | Already real | +| `DisputeGameFactoryRouter` | `new DisputeGameFactoryRouter()` | For Router-based creation tests | + +### Mocks (minimal, non-critical) + +| Mock | Reason | +|------|--------| +| `MockRiscZeroVerifier` | ZK proof verification is out of scope; only need it to not revert during `register()` | +| `MockSystemConfig` | Provides `paused()` and `guardian`; no real SystemConfig needed for these tests | + +## Test Cases + +### Test 1: `test_lifecycle_unchallenged_defenderWins` + +**Path**: create → (no challenge) → wait MAX_CHALLENGE_DURATION → resolve → closeGame → claimCredit + +**Verifies**: +- Simplest happy path with real Factory + real ASR +- `resolve()` returns `DEFENDER_WINS` when unchallenged and time expires +- `closeGame()` → `ASR.isGameFinalized()` passes → `bondDistributionMode = NORMAL` +- `setAnchorState` succeeds, `anchorGame` updates +- Proposer receives back `DEFENDER_BOND` + +--- + +### Test 2: `test_lifecycle_challenged_proveByProposer_defenderWins` + +**Path**: create → challenge → proposer proves → resolve → closeGame → claimCredit + +**Verifies**: +- Challenge + prove flow with real TeeProofVerifier (registered enclave) +- `resolve()` returns `DEFENDER_WINS` +- `closeGame()` → `bondDistributionMode = NORMAL` +- Proposer receives `DEFENDER_BOND + CHALLENGER_BOND` (wins challenger's bond) +- `setAnchorState` succeeds + +--- + +### Test 3: `test_lifecycle_challenged_proveByThirdParty_bondSplit` + +**Path**: create → challenge → third-party proves → resolve → closeGame → claimCredit (proposer) + claimCredit (prover) + +**Verifies**: +- Third-party prover bond splitting with real ASR determining `bondDistributionMode` +- `bondDistributionMode = NORMAL` +- Proposer receives `DEFENDER_BOND`, prover receives `CHALLENGER_BOND` +- Both `claimCredit` calls succeed with correct amounts + +--- + +### Test 4: `test_lifecycle_challenged_timeout_challengerWins_refund` + +**Path**: create → challenge → (no prove) → wait MAX_PROVE_DURATION → resolve → closeGame → claimCredit + +**Verifies**: +- **REFUND mode** — the most critical untested path with real contracts +- `resolve()` returns `CHALLENGER_WINS` +- `closeGame()` → `ASR.isGameProper()` returns false for CHALLENGER_WINS → `bondDistributionMode = REFUND` +- `setAnchorState` is attempted but silently fails (try-catch in `closeGame`) +- `anchorGame` does NOT update +- Proposer gets back `DEFENDER_BOND`, challenger gets back `CHALLENGER_BOND` (each refunded their own deposit) + +--- + +### Test 5: `test_lifecycle_parentChildChain_defenderWins` + +**Path**: create parent → resolve parent (DEFENDER_WINS) → create child (parentIndex=0) → resolve child + +**Verifies**: +- Child game's `startingOutputRoot` comes from parent's rootClaim (not anchor state) +- Child cannot resolve before parent resolves (revert `ParentGameNotResolved`) +- After parent resolves, child lifecycle works normally +- Real Factory's `gameAtIndex()` is used to look up parent — validates the full lookup chain + +--- + +### Test 6: `test_lifecycle_parentChallengerWins_childShortCircuits` + +**Path**: create parent → challenge parent → parent timeout → resolve parent (CHALLENGER_WINS) → create child → challenge child → resolve child + +**Verifies**: +- When parent is `CHALLENGER_WINS`, child's resolve short-circuits to `CHALLENGER_WINS` +- Bond distribution for short-circuited child: challenger gets `DEFENDER_BOND + CHALLENGER_BOND` +- Tests the cascading failure propagation through game chains + +--- + +### Test 7: `test_lifecycle_viaRouter_fullCycle` + +**Path**: Router.create → challenge → prove → resolve → closeGame → claimCredit + +**Verifies**: +- `gameCreator()` is the Router address +- `proposer()` is `tx.origin` (transparent pass-through) +- Full lifecycle works identically when created via Router vs direct Factory call +- Bond accounting attributes correctly to tx.origin proposer, not Router + +## Shared Test Infrastructure + +Reuse `TeeTestUtils` as base contract. Add a shared `setUp()` helper that deploys the full real-contract stack: + +```solidity +function _deployFullStack() internal { + // 1. Deploy real DisputeGameFactory via Proxy + // 2. Deploy real AnchorStateRegistry via Proxy + // 3. Deploy real TeeProofVerifier (with MockRiscZeroVerifier) + // - Register enclave via real register() flow + // 4. Deploy real AccessManager + // 5. Deploy real TeeDisputeGame implementation + // 6. Register implementation + init bond in factory + // 7. (Optional) Deploy DisputeGameFactoryRouter + register zone +} +``` + +## Relationship to Existing Tests + +| File | Action | +|------|--------| +| `AnchorStateRegistryCompatibility.t.sol` | Can be removed or kept as-is; the new integration tests fully subsume it | +| `DisputeGameFactoryRouterFork.t.sol` | Keep — it uniquely tests XLayer cross-zone interop on mainnet fork | +| Unit test files | Keep — they test error paths and edge cases exhaustively with fast mock-based isolation | diff --git a/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGame.t.sol b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGame.t.sol new file mode 100644 index 0000000000000..419581b435576 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGame.t.sol @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; +import {DisputeGameFactoryRouter} from "src/dispute/DisputeGameFactoryRouter.sol"; +import {TeeDisputeGame} from "src/dispute/tee/TeeDisputeGame.sol"; +import {AccessManager, TEE_DISPUTE_GAME_TYPE} from "src/dispute/tee/AccessManager.sol"; +import {BadAuth, GameNotFinalized, IncorrectBondAmount, UnexpectedRootClaim} from "src/dispute/lib/Errors.sol"; +import { + ClaimAlreadyChallenged, + InvalidParentGame, + ParentGameNotResolved, + GameNotOver +} from "src/dispute/tee/lib/Errors.sol"; +import {BondDistributionMode, Duration, GameType, Claim, Hash, GameStatus} from "src/dispute/lib/Types.sol"; +import {MockAnchorStateRegistry} from "test/dispute/tee/mocks/MockAnchorStateRegistry.sol"; +import {MockDisputeGameFactory} from "test/dispute/tee/mocks/MockDisputeGameFactory.sol"; +import {MockStatusDisputeGame} from "test/dispute/tee/mocks/MockStatusDisputeGame.sol"; +import {MockTeeProofVerifier} from "test/dispute/tee/mocks/MockTeeProofVerifier.sol"; +import {TeeTestUtils} from "test/dispute/tee/helpers/TeeTestUtils.sol"; + +contract TeeDisputeGameTest is TeeTestUtils { + uint256 internal constant DEFENDER_BOND = 1 ether; + uint256 internal constant CHALLENGER_BOND = 2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 1 days; + uint64 internal constant MAX_PROVE_DURATION = 12 hours; + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("anchor-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("anchor-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 10; + + MockDisputeGameFactory internal factory; + MockAnchorStateRegistry internal anchorStateRegistry; + MockTeeProofVerifier internal teeProofVerifier; + AccessManager internal accessManager; + TeeDisputeGame internal implementation; + + address internal proposer; + address internal challenger; + address internal executor; + address internal thirdPartyProver; + + function setUp() public { + proposer = makeWallet(DEFAULT_PROPOSER_KEY, "proposer").addr; + challenger = makeWallet(DEFAULT_CHALLENGER_KEY, "challenger").addr; + executor = makeWallet(DEFAULT_EXECUTOR_KEY, "executor").addr; + thirdPartyProver = makeWallet(DEFAULT_THIRD_PARTY_PROVER_KEY, "third-party-prover").addr; + + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + factory = new MockDisputeGameFactory(); + anchorStateRegistry = new MockAnchorStateRegistry(); + teeProofVerifier = new MockTeeProofVerifier(); + + accessManager = new AccessManager(MAX_CHALLENGE_DURATION, IDisputeGameFactory(address(factory))); + accessManager.setProposer(proposer, true); + accessManager.setChallenger(challenger, true); + + implementation = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(teeProofVerifier)), + CHALLENGER_BOND, + IAnchorStateRegistry(address(anchorStateRegistry)), + accessManager + ); + + factory.setImplementation(GameType.wrap(TEE_DISPUTE_GAME_TYPE), implementation); + factory.setInitBond(GameType.wrap(TEE_DISPUTE_GAME_TYPE), DEFENDER_BOND); + + anchorStateRegistry.setAnchor(Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), ANCHOR_L2_BLOCK); + anchorStateRegistry.setRespectedGameType(GameType.wrap(TEE_DISPUTE_GAME_TYPE)); + } + + function test_initialize_usesAnchorStateForRootGame() public { + (TeeDisputeGame game,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + (Hash startingRoot, uint256 startingBlockNumber) = game.startingOutputRoot(); + assertEq(startingRoot.raw(), computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()); + assertEq(startingBlockNumber, ANCHOR_L2_BLOCK); + assertEq(game.proposer(), proposer); + assertEq(game.refundModeCredit(proposer), DEFENDER_BOND); + assertTrue(game.wasRespectedGameTypeWhenCreated()); + } + + function test_initialize_tracksTxOriginProposerThroughRouter() public { + DisputeGameFactoryRouter router = new DisputeGameFactoryRouter(); + uint256 zoneId = 1; + router.registerZone(zoneId, address(factory)); + + bytes32 endBlockHash = keccak256("router-end-block"); + bytes32 endStateHash = keccak256("router-end-state"); + bytes memory extraData = + buildExtraData(ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + Claim rootClaim = computeRootClaim(endBlockHash, endStateHash); + + vm.startPrank(proposer, proposer); + address proxy = router.create{value: DEFENDER_BOND}(zoneId, GameType.wrap(TEE_DISPUTE_GAME_TYPE), rootClaim, extraData); + vm.stopPrank(); + + TeeDisputeGame game = TeeDisputeGame(payable(proxy)); + assertEq(game.gameCreator(), address(router)); + assertEq(game.proposer(), proposer); + assertEq(game.refundModeCredit(proposer), DEFENDER_BOND); + assertEq(game.refundModeCredit(address(router)), 0); + } + + function test_initialize_usesParentGameOutput() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.IN_PROGRESS, + uint64(block.timestamp), + 0, + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + + (Hash startingRoot, uint256 startingBlockNumber) = game.startingOutputRoot(); + assertEq(startingRoot.raw(), parent.rootClaim().raw()); + assertEq(startingBlockNumber, parent.l2SequenceNumber()); + } + + function test_initialize_revertUnauthorizedProposer() public { + address unauthorized = makeAddr("unauthorized"); + vm.deal(unauthorized, DEFENDER_BOND); + + vm.expectRevert(BadAuth.selector); + _createGame(unauthorized, ANCHOR_L2_BLOCK + 1, type(uint32).max, keccak256("block"), keccak256("state")); + } + + function test_initialize_revertRootClaimMismatch() public { + bytes memory extraData = + buildExtraData(ANCHOR_L2_BLOCK + 1, type(uint32).max, keccak256("block"), keccak256("state")); + Claim wrongRootClaim = Claim.wrap(keccak256("wrong-root-claim")); + Claim expectedRootClaim = computeRootClaim(keccak256("block"), keccak256("state")); + + vm.startPrank(proposer, proposer); + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.RootClaimMismatch.selector, expectedRootClaim.raw(), wrongRootClaim.raw() + ) + ); + factory.create{value: DEFENDER_BOND}(GameType.wrap(TEE_DISPUTE_GAME_TYPE), wrongRootClaim, extraData); + vm.stopPrank(); + } + + function test_initialize_revertWhenL2SequenceNumberDoesNotAdvance() public { + vm.expectRevert(abi.encodeWithSelector(UnexpectedRootClaim.selector, computeRootClaim(keccak256("block"), keccak256("state")))); + _createGame(proposer, ANCHOR_L2_BLOCK, type(uint32).max, keccak256("block"), keccak256("state")); + } + + function test_initialize_revertInvalidParentGame() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.CHALLENGER_WINS, + uint64(block.timestamp), + uint64(block.timestamp), + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + vm.expectRevert(InvalidParentGame.selector); + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + } + + function test_challenge_updatesState() public { + (TeeDisputeGame game,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + TeeDisputeGame.ProposalStatus proposalStatus = game.challenge{value: CHALLENGER_BOND}(); + + (, address counteredBy,, , TeeDisputeGame.ProposalStatus storedStatus,) = game.claimData(); + assertEq(counteredBy, challenger); + assertEq(uint8(proposalStatus), uint8(TeeDisputeGame.ProposalStatus.Challenged)); + assertEq(uint8(storedStatus), uint8(TeeDisputeGame.ProposalStatus.Challenged)); + assertEq(game.refundModeCredit(challenger), CHALLENGER_BOND); + } + + function test_challenge_revertIncorrectBond() public { + (TeeDisputeGame game,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + vm.expectRevert(IncorrectBondAmount.selector); + game.challenge{value: CHALLENGER_BOND - 1}(); + } + + function test_challenge_revertWhenAlreadyChallenged() public { + (TeeDisputeGame game,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + game.challenge{value: CHALLENGER_BOND}(); + + vm.prank(challenger); + vm.expectRevert(ClaimAlreadyChallenged.selector); + game.challenge{value: CHALLENGER_BOND}(); + } + + function test_prove_succeedsWithSingleBatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + TeeDisputeGame.ProposalStatus status = game.prove(abi.encode(proofs)); + (, , address prover,, TeeDisputeGame.ProposalStatus storedStatus,) = game.claimData(); + assertEq(prover, address(this)); + assertEq(uint8(status), uint8(TeeDisputeGame.ProposalStatus.UnchallengedAndValidProofProvided)); + assertEq(uint8(storedStatus), uint8(TeeDisputeGame.ProposalStatus.UnchallengedAndValidProofProvided)); + } + + function test_prove_succeedsWithChainedBatches() public { + bytes32 middleBlockHash = keccak256("middle-block"); + bytes32 middleStateHash = keccak256("middle-state"); + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 8, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](2); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: middleBlockHash, + endStateHash: middleStateHash, + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY + ); + proofs[1] = buildBatchProof( + BatchInput({ + startBlockHash: middleBlockHash, + startStateHash: middleStateHash, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + TeeDisputeGame.ProposalStatus status = game.prove(abi.encode(proofs)); + assertEq(uint8(status), uint8(TeeDisputeGame.ProposalStatus.UnchallengedAndValidProofProvided)); + } + + function test_prove_revertEmptyBatchProofs() public { + (TeeDisputeGame game,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.expectRevert(TeeDisputeGame.EmptyBatchProofs.selector); + game.prove(abi.encode(new TeeDisputeGame.BatchProof[](0))); + } + + function test_prove_revertStartHashMismatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: keccak256("wrong-start-block"), + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.StartHashMismatch.selector, + computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw(), + keccak256(abi.encode(keccak256("wrong-start-block"), ANCHOR_STATE_HASH)) + ) + ); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertBatchChainBreak() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 8, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](2); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: keccak256("middle-block"), + endStateHash: keccak256("middle-state"), + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY + ); + proofs[1] = buildBatchProof( + BatchInput({ + startBlockHash: keccak256("different-block"), + startStateHash: keccak256("different-state"), + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + vm.expectRevert(abi.encodeWithSelector(TeeDisputeGame.BatchChainBreak.selector, 1)); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertBatchBlockNotIncreasing() public { + bytes32 middleBlockHash = keccak256("middle-block"); + bytes32 middleStateHash = keccak256("middle-state"); + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 8, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](2); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: middleBlockHash, + endStateHash: middleStateHash, + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY + ); + proofs[1] = buildBatchProof( + BatchInput({ + startBlockHash: middleBlockHash, + startStateHash: middleStateHash, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY + ); + + vm.expectRevert(abi.encodeWithSelector(TeeDisputeGame.BatchBlockNotIncreasing.selector, 1, ANCHOR_L2_BLOCK + 4, ANCHOR_L2_BLOCK + 4)); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertFinalHashMismatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: keccak256("wrong-end-block"), + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.FinalHashMismatch.selector, + computeRootClaim(endBlockHash, endStateHash).raw(), + keccak256(abi.encode(keccak256("wrong-end-block"), endStateHash)) + ) + ); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertFinalBlockMismatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() - 1 + }), + DEFAULT_EXECUTOR_KEY + ); + + vm.expectRevert( + abi.encodeWithSelector(TeeDisputeGame.FinalBlockMismatch.selector, game.l2SequenceNumber(), game.l2SequenceNumber() - 1) + ); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertWhenVerifierRejectsSignature() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + vm.expectRevert(MockTeeProofVerifier.EnclaveNotRegistered.selector); + game.prove(abi.encode(proofs)); + } + + function test_resolve_revertWhenParentInProgress() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.IN_PROGRESS, + uint64(block.timestamp), + 0, + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + (TeeDisputeGame child,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + + vm.expectRevert(ParentGameNotResolved.selector); + child.resolve(); + } + + function test_resolve_parentChallengerWinsShortCircuits() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.IN_PROGRESS, + uint64(block.timestamp), + 0, + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + (TeeDisputeGame child,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + + parent.setStatus(GameStatus.CHALLENGER_WINS); + parent.setResolvedAt(uint64(block.timestamp)); + + vm.prank(challenger); + child.challenge{value: CHALLENGER_BOND}(); + + GameStatus status = child.resolve(); + assertEq(uint8(status), uint8(GameStatus.CHALLENGER_WINS)); + assertEq(child.normalModeCredit(challenger), DEFENDER_BOND + CHALLENGER_BOND); + } + + function test_resolve_challengedWithThirdPartyProverSplitsCreditAndClaimCredit() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + vm.prank(challenger); + game.challenge{value: CHALLENGER_BOND}(); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + vm.prank(thirdPartyProver); + game.prove(abi.encode(proofs)); + + assertEq(uint8(game.resolve()), uint8(GameStatus.DEFENDER_WINS)); + assertEq(game.normalModeCredit(thirdPartyProver), CHALLENGER_BOND); + assertEq(game.normalModeCredit(proposer), DEFENDER_BOND); + + anchorStateRegistry.setGameFlags(game, true, true, false, false, true, true, true); + + uint256 proposerBalanceBefore = proposer.balance; + uint256 proverBalanceBefore = thirdPartyProver.balance; + game.claimCredit(proposer); + game.claimCredit(thirdPartyProver); + + assertEq(proposer.balance, proposerBalanceBefore + DEFENDER_BOND); + assertEq(thirdPartyProver.balance, proverBalanceBefore + CHALLENGER_BOND); + } + + function test_claimCredit_refundModeRefundsDeposits() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + vm.prank(challenger); + game.challenge{value: CHALLENGER_BOND}(); + + vm.warp(block.timestamp + MAX_PROVE_DURATION + 1); + assertEq(uint8(game.resolve()), uint8(GameStatus.CHALLENGER_WINS)); + + anchorStateRegistry.setGameFlags(game, true, true, false, false, true, false, false); + + uint256 proposerBalanceBefore = proposer.balance; + uint256 challengerBalanceBefore = challenger.balance; + game.claimCredit(proposer); + game.claimCredit(challenger); + + assertEq(proposer.balance, proposerBalanceBefore + DEFENDER_BOND); + assertEq(challenger.balance, challengerBalanceBefore + CHALLENGER_BOND); + assertEq(uint8(game.bondDistributionMode()), uint8(BondDistributionMode.REFUND)); + } + + function test_closeGame_revertWhenNotFinalized() public { + (TeeDisputeGame game,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + game.resolve(); + + vm.expectRevert(GameNotFinalized.selector); + game.closeGame(); + } + + function test_resolve_revertWhenGameNotOver() public { + (TeeDisputeGame game,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.expectRevert(GameNotOver.selector); + game.resolve(); + } + + function _createGame( + address creator, + uint256 l2SequenceNumber, + uint32 parentIndex, + bytes32 blockHash_, + bytes32 stateHash_ + ) + internal + returns (TeeDisputeGame game, bytes memory extraData, Claim rootClaim) + { + extraData = buildExtraData(l2SequenceNumber, parentIndex, blockHash_, stateHash_); + rootClaim = computeRootClaim(blockHash_, stateHash_); + + vm.startPrank(creator, creator); + game = TeeDisputeGame( + payable(address(factory.create{value: DEFENDER_BOND}(GameType.wrap(TEE_DISPUTE_GAME_TYPE), rootClaim, extraData))) + ); + vm.stopPrank(); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/TeeProofVerifier.t.sol b/packages/contracts-bedrock/test/dispute/tee/TeeProofVerifier.t.sol new file mode 100644 index 0000000000000..2ab9d04482384 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/TeeProofVerifier.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Vm} from "forge-std/Vm.sol"; +import {TeeProofVerifier} from "src/dispute/tee/TeeProofVerifier.sol"; +import {MockRiscZeroVerifier} from "test/dispute/tee/mocks/MockRiscZeroVerifier.sol"; +import {TeeTestUtils} from "test/dispute/tee/helpers/TeeTestUtils.sol"; + +contract TeeProofVerifierTest is TeeTestUtils { + MockRiscZeroVerifier internal riscZeroVerifier; + TeeProofVerifier internal verifier; + + Vm.Wallet internal enclaveWallet; + bytes32 internal constant IMAGE_ID = keccak256("tee-image"); + bytes32 internal constant PCR_HASH = keccak256("pcr-hash"); + bytes internal expectedRootKey; + + function setUp() public { + riscZeroVerifier = new MockRiscZeroVerifier(); + expectedRootKey = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), bytes32(uint256(3))); + verifier = new TeeProofVerifier(riscZeroVerifier, IMAGE_ID, expectedRootKey); + enclaveWallet = makeWallet(DEFAULT_EXECUTOR_KEY, "enclave"); + } + + function test_register_succeeds() public { + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), "data"); + + verifier.register(hex"1234", journal); + + (bytes32 pcrHash, uint64 registeredAt) = verifier.registeredEnclaves(enclaveWallet.addr); + assertEq(pcrHash, PCR_HASH); + assertEq(registeredAt, 1234); + assertTrue(verifier.isRegistered(enclaveWallet.addr)); + } + + function test_register_revertUnauthorizedCaller() public { + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), ""); + + vm.prank(makeAddr("attacker")); + vm.expectRevert(TeeProofVerifier.Unauthorized.selector); + verifier.register(hex"1234", journal); + } + + function test_register_revertInvalidProof() public { + riscZeroVerifier.setShouldRevert(true); + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), ""); + + vm.expectRevert(TeeProofVerifier.InvalidProof.selector); + verifier.register(hex"1234", journal); + } + + function test_register_revertInvalidRootKey() public { + bytes memory badRootKey = abi.encodePacked(bytes32(uint256(4)), bytes32(uint256(5)), bytes32(uint256(6))); + bytes memory journal = buildJournal(1234, PCR_HASH, badRootKey, uncompressedPublicKey(enclaveWallet), ""); + + vm.expectRevert(TeeProofVerifier.InvalidRootKey.selector); + verifier.register(hex"1234", journal); + } + + function test_register_revertInvalidPublicKey() public { + bytes memory shortPublicKey = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2))); + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, shortPublicKey, ""); + + vm.expectRevert(TeeProofVerifier.InvalidPublicKey.selector); + verifier.register(hex"1234", journal); + } + + function test_register_revertDuplicateEnclave() public { + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), ""); + verifier.register(hex"1234", journal); + + vm.expectRevert(TeeProofVerifier.EnclaveAlreadyRegistered.selector); + verifier.register(hex"1234", journal); + } + + function test_register_revertMalformedJournal() public { + vm.expectRevert(); + verifier.register(hex"1234", hex"0001"); + } + + function test_verifyBatch_succeedsForRegisteredEnclave() public { + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), ""); + verifier.register(hex"1234", journal); + + bytes32 digest = keccak256("batch"); + bytes memory signature = signDigest(enclaveWallet.privateKey, digest); + + assertEq(verifier.verifyBatch(digest, signature), enclaveWallet.addr); + } + + function test_verifyBatch_revertForUnregisteredSigner() public { + bytes32 digest = keccak256("batch"); + bytes memory signature = signDigest(enclaveWallet.privateKey, digest); + + vm.expectRevert(TeeProofVerifier.EnclaveNotRegistered.selector); + verifier.verifyBatch(digest, signature); + } + + function test_verifyBatch_revertForInvalidSignature() public { + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), ""); + verifier.register(hex"1234", journal); + + vm.expectRevert(TeeProofVerifier.InvalidSignature.selector); + verifier.verifyBatch(keccak256("batch"), hex"1234"); + } + + function test_revoke_succeeds() public { + bytes memory journal = buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), ""); + verifier.register(hex"1234", journal); + + verifier.revoke(enclaveWallet.addr); + + assertFalse(verifier.isRegistered(enclaveWallet.addr)); + } + + function test_revoke_revertWhenEnclaveMissing() public { + vm.expectRevert(TeeProofVerifier.EnclaveNotRegistered.selector); + verifier.revoke(enclaveWallet.addr); + } + + function test_transferOwnership_updatesOwner() public { + address newOwner = makeAddr("newOwner"); + verifier.transferOwnership(newOwner); + assertEq(verifier.owner(), newOwner); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/fork/DisputeGameFactoryRouterFork.t.sol b/packages/contracts-bedrock/test/dispute/tee/fork/DisputeGameFactoryRouterFork.t.sol new file mode 100644 index 0000000000000..1ed0fe4285412 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/fork/DisputeGameFactoryRouterFork.t.sol @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Vm} from "forge-std/Vm.sol"; +import {Proxy} from "src/universal/Proxy.sol"; +import {AnchorStateRegistry} from "src/dispute/AnchorStateRegistry.sol"; +import {DisputeGameFactory} from "src/dispute/DisputeGameFactory.sol"; +import {PermissionedDisputeGame} from "src/dispute/PermissionedDisputeGame.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {ISystemConfig} from "interfaces/L1/ISystemConfig.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; +import {DisputeGameFactoryRouter} from "src/dispute/DisputeGameFactoryRouter.sol"; +import {IDisputeGameFactoryRouter} from "interfaces/dispute/IDisputeGameFactoryRouter.sol"; +import {TeeDisputeGame} from "src/dispute/tee/TeeDisputeGame.sol"; +import {TeeProofVerifier} from "src/dispute/tee/TeeProofVerifier.sol"; +import {AccessManager, TEE_DISPUTE_GAME_TYPE} from "src/dispute/tee/AccessManager.sol"; +import {Claim, Duration, GameStatus, GameType, Hash, Proposal} from "src/dispute/lib/Types.sol"; +import {TeeTestUtils} from "test/dispute/tee/helpers/TeeTestUtils.sol"; +import {MockRiscZeroVerifier} from "test/dispute/tee/mocks/MockRiscZeroVerifier.sol"; +import {MockSystemConfig} from "test/dispute/tee/mocks/MockSystemConfig.sol"; + +contract DisputeGameFactoryRouterForkTest is TeeTestUtils { + struct SecondZoneFixture { + DisputeGameFactory factory; + AnchorStateRegistry anchorStateRegistry; + TeeProofVerifier teeProofVerifier; + TeeDisputeGame implementation; + address registeredExecutor; + } + + struct XLayerConfig { + GameType gameType; + uint256 initBond; + address proposer; + } + + uint256 internal constant DEFENDER_BOND = 1 ether; + uint256 internal constant CHALLENGER_BOND = 2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 1 days; + uint64 internal constant MAX_PROVE_DURATION = 12 hours; + bytes32 internal constant IMAGE_ID = keccak256("fork-tee-image"); + bytes32 internal constant PCR_HASH = keccak256("fork-pcr-hash"); + + // XLayer's dispute game factory is deployed on Ethereum mainnet L1. + address internal constant XLAYER_FACTORY = 0x9D4c8FAEadDdDeeE1Ed0c92dAbAD815c2484f675; + uint256 internal constant ZONE_XLAYER = 1; + uint256 internal constant ZONE_SECOND = 2; + GameType internal constant XLAYER_GAME_TYPE = GameType.wrap(1); + GameType internal constant TEE_GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("fork-anchor-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("fork-anchor-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 10; + + DisputeGameFactory internal xLayerFactory; + DisputeGameFactoryRouter internal router; + bool internal hasFork; + + address internal proposer; + address internal challenger; + + function setUp() public { + proposer = makeWallet(DEFAULT_PROPOSER_KEY, "fork-proposer").addr; + challenger = makeWallet(DEFAULT_CHALLENGER_KEY, "fork-challenger").addr; + + if (!vm.envExists("ETH_RPC_URL")) return; + + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + hasFork = true; + xLayerFactory = DisputeGameFactory(XLAYER_FACTORY); + router = new DisputeGameFactoryRouter(); + router.registerZone(ZONE_XLAYER, XLAYER_FACTORY); + } + + function test_liveFactoryReadPaths() public view { + if (!hasFork) return; + + _assertLiveFactoryFork(); + + assertTrue(xLayerFactory.owner() != address(0)); + assertTrue(bytes(xLayerFactory.version()).length > 0); + assertTrue(address(xLayerFactory.gameImpls(XLAYER_GAME_TYPE)) != address(0)); + assertGt(xLayerFactory.initBonds(XLAYER_GAME_TYPE), 0); + } + + function test_routerCreate_onlyXLayer() public { + if (!hasFork) return; + + _assertLiveFactoryFork(); + XLayerConfig memory xLayer = _readXLayerConfig(); + + vm.deal(xLayer.proposer, 10 ether); + + Claim rootClaim = Claim.wrap(keccak256("xlayer-router-root")); + bytes memory extraData = abi.encodePacked(uint256(1_000_000_000)); + + vm.startPrank(xLayer.proposer, xLayer.proposer); + address proxy = router.create{value: xLayer.initBond}(ZONE_XLAYER, xLayer.gameType, rootClaim, extraData); + vm.stopPrank(); + + assertTrue(proxy != address(0)); + + (IDisputeGame storedGame,) = xLayerFactory.games(xLayer.gameType, rootClaim, extraData); + assertEq(address(storedGame), proxy); + assertEq(storedGame.gameCreator(), address(router)); + assertEq(storedGame.rootClaim().raw(), rootClaim.raw()); + } + + function test_routerCreate_onlySecondZone_lifecycle() public { + if (!hasFork) return; + + _assertLiveFactoryFork(); + SecondZoneFixture memory secondZone = _installSecondZoneFixture(proposer); + + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + bytes32 endBlockHash = keccak256("second-zone-end-block"); + bytes32 endStateHash = keccak256("second-zone-end-state"); + (TeeDisputeGame game, Claim rootClaim, bytes memory extraData) = + _createSecondZoneGame(endBlockHash, endStateHash, ANCHOR_L2_BLOCK + 6); + + _assertStoredSecondZoneGame(secondZone.factory, game, rootClaim, extraData); + assertEq(game.gameCreator(), address(router)); + assertEq(game.proposer(), proposer); + + _runSecondZoneLifecycle(secondZone, game, endBlockHash, endStateHash); + } + + function test_routerCreateBatch_xLayerAndSecondZone() public { + if (!hasFork) return; + + _assertLiveFactoryFork(); + XLayerConfig memory xLayer = _readXLayerConfig(); + SecondZoneFixture memory secondZone = _installSecondZoneFixture(xLayer.proposer); + + vm.deal(xLayer.proposer, 10 ether); + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + Claim xLayerRootClaim = Claim.wrap(keccak256("batch-xlayer-root")); + bytes memory xLayerExtraData = abi.encodePacked(uint256(1_000_000_001)); + + bytes32 secondZoneEndBlockHash = keccak256("batch-second-zone-end-block"); + bytes32 secondZoneEndStateHash = keccak256("batch-second-zone-end-state"); + bytes memory secondZoneExtraData = + buildExtraData(ANCHOR_L2_BLOCK + 8, type(uint32).max, secondZoneEndBlockHash, secondZoneEndStateHash); + Claim secondZoneRootClaim = computeRootClaim(secondZoneEndBlockHash, secondZoneEndStateHash); + + IDisputeGameFactoryRouter.CreateParams[] memory params = new IDisputeGameFactoryRouter.CreateParams[](2); + params[0] = IDisputeGameFactoryRouter.CreateParams({ + zoneId: ZONE_XLAYER, + gameType: xLayer.gameType, + rootClaim: xLayerRootClaim, + extraData: xLayerExtraData, + bond: xLayer.initBond + }); + params[1] = IDisputeGameFactoryRouter.CreateParams({ + zoneId: ZONE_SECOND, + gameType: TEE_GAME_TYPE, + rootClaim: secondZoneRootClaim, + extraData: secondZoneExtraData, + bond: DEFENDER_BOND + }); + + vm.startPrank(xLayer.proposer, xLayer.proposer); + address[] memory proxies = router.createBatch{value: xLayer.initBond + DEFENDER_BOND}(params); + vm.stopPrank(); + + assertEq(proxies.length, 2); + + (IDisputeGame xLayerStoredGame,) = xLayerFactory.games(xLayer.gameType, xLayerRootClaim, xLayerExtraData); + assertEq(address(xLayerStoredGame), proxies[0]); + assertEq(xLayerStoredGame.gameCreator(), address(router)); + + TeeDisputeGame secondZoneGame = TeeDisputeGame(payable(proxies[1])); + _assertStoredSecondZoneGame(secondZone.factory, secondZoneGame, secondZoneRootClaim, secondZoneExtraData); + assertEq(secondZoneGame.gameCreator(), address(router)); + assertEq(secondZoneGame.proposer(), xLayer.proposer); + + _runSecondZoneLifecycle(secondZone, secondZoneGame, secondZoneEndBlockHash, secondZoneEndStateHash); + } + + function _readXLayerConfig() internal view returns (XLayerConfig memory xLayer) { + xLayer.gameType = XLAYER_GAME_TYPE; + xLayer.initBond = xLayerFactory.initBonds(XLAYER_GAME_TYPE); + + PermissionedDisputeGame implementation = + PermissionedDisputeGame(payable(address(xLayerFactory.gameImpls(XLAYER_GAME_TYPE)))); + xLayer.proposer = implementation.proposer(); + + assertTrue(address(implementation) != address(0), "xlayer impl missing"); + assertGt(xLayer.initBond, 0, "xlayer init bond missing"); + } + + function _installSecondZoneFixture(address allowedProposer) + internal + returns (SecondZoneFixture memory secondZone) + { + secondZone.factory = _deployLocalDisputeGameFactory(); + router.registerZone(ZONE_SECOND, address(secondZone.factory)); + + secondZone.anchorStateRegistry = _deployRealAnchorStateRegistry(secondZone.factory); + (secondZone.teeProofVerifier, secondZone.registeredExecutor) = _deployRealTeeProofVerifier(); + + AccessManager accessManager = + new AccessManager(MAX_CHALLENGE_DURATION, IDisputeGameFactory(address(secondZone.factory))); + accessManager.setProposer(allowedProposer, true); + accessManager.setChallenger(challenger, true); + + secondZone.implementation = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(secondZone.factory)), + ITeeProofVerifier(address(secondZone.teeProofVerifier)), + CHALLENGER_BOND, + IAnchorStateRegistry(address(secondZone.anchorStateRegistry)), + accessManager + ); + + secondZone.factory.setImplementation(TEE_GAME_TYPE, IDisputeGame(address(secondZone.implementation)), bytes("")); + secondZone.factory.setInitBond(TEE_GAME_TYPE, DEFENDER_BOND); + + // Real ASR marks games created at or before the retirement timestamp as retired. + vm.warp(block.timestamp + 1); + } + + function _createSecondZoneGame( + bytes32 endBlockHash, + bytes32 endStateHash, + uint256 l2SequenceNumber + ) + internal + returns (TeeDisputeGame game, Claim rootClaim, bytes memory extraData) + { + extraData = buildExtraData(l2SequenceNumber, type(uint32).max, endBlockHash, endStateHash); + rootClaim = computeRootClaim(endBlockHash, endStateHash); + + vm.startPrank(proposer, proposer); + address proxy = router.create{value: DEFENDER_BOND}(ZONE_SECOND, TEE_GAME_TYPE, rootClaim, extraData); + vm.stopPrank(); + + game = TeeDisputeGame(payable(proxy)); + } + + function _runSecondZoneLifecycle( + SecondZoneFixture memory secondZone, + TeeDisputeGame game, + bytes32 endBlockHash, + bytes32 endStateHash + ) + internal + { + (Hash startingRoot, uint256 startingBlockNumber) = game.startingOutputRoot(); + assertEq(startingRoot.raw(), computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()); + assertEq(startingBlockNumber, ANCHOR_L2_BLOCK); + + vm.prank(challenger); + game.challenge{value: CHALLENGER_BOND}(); + + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY + ); + + address gameProposer = game.proposer(); + + vm.prank(gameProposer); + game.prove(abi.encode(proofs)); + + assertEq(uint8(game.resolve()), uint8(GameStatus.DEFENDER_WINS)); + assertTrue(secondZone.anchorStateRegistry.isGameRegistered(game)); + assertTrue(secondZone.anchorStateRegistry.isGameProper(game)); + assertFalse(secondZone.anchorStateRegistry.isGameFinalized(game)); + assertTrue(secondZone.teeProofVerifier.isRegistered(secondZone.registeredExecutor)); + + vm.warp(block.timestamp + 1); + assertTrue(secondZone.anchorStateRegistry.isGameFinalized(game)); + assertEq(game.normalModeCredit(gameProposer), DEFENDER_BOND + CHALLENGER_BOND); + + uint256 proposerBalanceBefore = gameProposer.balance; + game.claimCredit(gameProposer); + assertEq(gameProposer.balance, proposerBalanceBefore + DEFENDER_BOND + CHALLENGER_BOND); + assertEq(address(secondZone.anchorStateRegistry.anchorGame()), address(game)); + } + + function _assertStoredSecondZoneGame( + DisputeGameFactory factory, + TeeDisputeGame game, + Claim rootClaim, + bytes memory extraData + ) + internal + view + { + (IDisputeGame storedGame,) = factory.games(TEE_GAME_TYPE, rootClaim, extraData); + assertEq(address(storedGame), address(game)); + assertEq(game.rootClaim().raw(), rootClaim.raw()); + } + + function _deployLocalDisputeGameFactory() internal returns (DisputeGameFactory factory) { + DisputeGameFactory implementation = new DisputeGameFactory(); + Proxy proxy = new Proxy(address(this)); + proxy.upgradeToAndCall( + address(implementation), + abi.encodeCall(implementation.initialize, (address(this))) + ); + factory = DisputeGameFactory(address(proxy)); + } + + function _deployRealAnchorStateRegistry(DisputeGameFactory factory) + internal + returns (AnchorStateRegistry anchorStateRegistry) + { + MockSystemConfig systemConfig = new MockSystemConfig(address(this)); + AnchorStateRegistry anchorStateRegistryImpl = new AnchorStateRegistry(0); + Proxy anchorStateRegistryProxy = new Proxy(address(this)); + anchorStateRegistryProxy.upgradeToAndCall( + address(anchorStateRegistryImpl), + abi.encodeCall( + anchorStateRegistryImpl.initialize, + ( + ISystemConfig(address(systemConfig)), + IDisputeGameFactory(address(factory)), + Proposal({ + root: Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), + l2SequenceNumber: ANCHOR_L2_BLOCK + }), + TEE_GAME_TYPE + ) + ) + ); + anchorStateRegistry = AnchorStateRegistry(address(anchorStateRegistryProxy)); + } + + function _deployRealTeeProofVerifier() + internal + returns (TeeProofVerifier teeProofVerifier, address registeredExecutor) + { + Vm.Wallet memory enclaveWallet = makeWallet(DEFAULT_EXECUTOR_KEY, "fork-registered-enclave"); + MockRiscZeroVerifier riscZeroVerifier = new MockRiscZeroVerifier(); + bytes memory expectedRootKey = + abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), bytes32(uint256(3))); + + registeredExecutor = enclaveWallet.addr; + teeProofVerifier = new TeeProofVerifier(riscZeroVerifier, IMAGE_ID, expectedRootKey); + bytes memory journal = + buildJournal(1234, PCR_HASH, expectedRootKey, uncompressedPublicKey(enclaveWallet), ""); + teeProofVerifier.register("", journal); + } + + function _assertLiveFactoryFork() internal view { + assertEq(block.chainid, 1, "expected Ethereum mainnet fork"); + assertTrue(XLAYER_FACTORY.code.length > 0, "factory missing on fork"); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/helpers/TeeTestUtils.sol b/packages/contracts-bedrock/test/dispute/tee/helpers/TeeTestUtils.sol new file mode 100644 index 0000000000000..70cddc92a8f59 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/helpers/TeeTestUtils.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Claim} from "src/dispute/lib/Types.sol"; +import {TeeDisputeGame} from "src/dispute/tee/TeeDisputeGame.sol"; + +abstract contract TeeTestUtils is Test { + uint256 internal constant DEFAULT_PROPOSER_KEY = 0xA11CE; + uint256 internal constant DEFAULT_CHALLENGER_KEY = 0xB0B; + uint256 internal constant DEFAULT_EXECUTOR_KEY = 0xC0DE; + uint256 internal constant DEFAULT_THIRD_PARTY_PROVER_KEY = 0xD00D; + + struct BatchInput { + bytes32 startBlockHash; + bytes32 startStateHash; + bytes32 endBlockHash; + bytes32 endStateHash; + uint256 l2Block; + } + + function buildExtraData( + uint256 l2SequenceNumber, + uint32 parentIndex, + bytes32 blockHash_, + bytes32 stateHash_ + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(l2SequenceNumber, parentIndex, blockHash_, stateHash_); + } + + function computeRootClaim(bytes32 blockHash_, bytes32 stateHash_) internal pure returns (Claim) { + return Claim.wrap(keccak256(abi.encode(blockHash_, stateHash_))); + } + + function computeBatchDigest(BatchInput memory batch) internal pure returns (bytes32) { + return keccak256( + abi.encode( + batch.startBlockHash, + batch.startStateHash, + batch.endBlockHash, + batch.endStateHash, + batch.l2Block + ) + ); + } + + function signDigest(uint256 privateKey, bytes32 digest) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } + + function buildBatchProof(BatchInput memory batch, uint256 privateKey) + internal + returns (TeeDisputeGame.BatchProof memory) + { + return TeeDisputeGame.BatchProof({ + startBlockHash: batch.startBlockHash, + startStateHash: batch.startStateHash, + endBlockHash: batch.endBlockHash, + endStateHash: batch.endStateHash, + l2Block: batch.l2Block, + signature: signDigest(privateKey, computeBatchDigest(batch)) + }); + } + + function buildBatchProofWithSignature(BatchInput memory batch, bytes memory signature) + internal + pure + returns (TeeDisputeGame.BatchProof memory) + { + return TeeDisputeGame.BatchProof({ + startBlockHash: batch.startBlockHash, + startStateHash: batch.startStateHash, + endBlockHash: batch.endBlockHash, + endStateHash: batch.endStateHash, + l2Block: batch.l2Block, + signature: signature + }); + } + + function makeWallet(uint256 privateKey, string memory label) internal returns (Vm.Wallet memory wallet) { + wallet = vm.createWallet(privateKey, label); + } + + function uncompressedPublicKey(Vm.Wallet memory wallet) internal pure returns (bytes memory) { + return abi.encodePacked(bytes1(0x04), bytes32(wallet.publicKeyX), bytes32(wallet.publicKeyY)); + } + + function buildJournal( + uint64 timestampMs, + bytes32 pcrHash, + bytes memory rootKey, + bytes memory publicKey, + bytes memory userData + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked( + bytes8(timestampMs), + pcrHash, + rootKey, + bytes1(uint8(publicKey.length)), + publicKey, + bytes2(uint16(userData.length)), + userData + ); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockAnchorStateRegistry.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockAnchorStateRegistry.sol new file mode 100644 index 0000000000000..c0e7a59c8b055 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockAnchorStateRegistry.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {IFaultDisputeGame} from "interfaces/dispute/IFaultDisputeGame.sol"; +import {ISystemConfig} from "interfaces/L1/ISystemConfig.sol"; +import {ISuperchainConfig} from "interfaces/L1/ISuperchainConfig.sol"; +import {IProxyAdmin} from "interfaces/universal/IProxyAdmin.sol"; +import {GameType, Hash, Proposal} from "src/dispute/lib/Types.sol"; + +contract MockAnchorStateRegistry is IAnchorStateRegistry { + struct Flags { + bool registered; + bool respected; + bool blacklisted; + bool retired; + bool finalized; + bool proper; + bool claimValid; + } + + uint8 public initVersion = 1; + ISystemConfig public systemConfig; + IDisputeGameFactory public disputeGameFactory; + IFaultDisputeGame public anchorGame; + Proposal internal anchorRoot; + mapping(IDisputeGame => bool) public disputeGameBlacklist; + mapping(address => Flags) internal flags; + GameType public respectedGameType; + uint64 public retirementTimestamp; + bool public paused; + bool public revertOnSetAnchorState; + IDisputeGame public lastSetAnchorState; + + function initialize( + ISystemConfig _systemConfig, + IDisputeGameFactory _disputeGameFactory, + Proposal memory _startingAnchorRoot, + GameType _startingRespectedGameType + ) + external + { + systemConfig = _systemConfig; + disputeGameFactory = _disputeGameFactory; + anchorRoot = _startingAnchorRoot; + respectedGameType = _startingRespectedGameType; + } + + function anchors(GameType) external view returns (Hash, uint256) { + return (anchorRoot.root, anchorRoot.l2SequenceNumber); + } + + function setAnchor(Hash root_, uint256 l2SequenceNumber_) external { + anchorRoot = Proposal({root: root_, l2SequenceNumber: l2SequenceNumber_}); + } + + function setRespectedGameType(GameType _gameType) external { + respectedGameType = _gameType; + } + + function updateRetirementTimestamp() external { + retirementTimestamp = uint64(block.timestamp); + } + + function blacklistDisputeGame(IDisputeGame game) external { + disputeGameBlacklist[game] = true; + flags[address(game)].blacklisted = true; + } + + function setPaused(bool value) external { + paused = value; + } + + function setRevertOnSetAnchorState(bool value) external { + revertOnSetAnchorState = value; + } + + function setGameFlags( + IDisputeGame game, + bool registered_, + bool respected_, + bool blacklisted_, + bool retired_, + bool finalized_, + bool proper_, + bool claimValid_ + ) + external + { + flags[address(game)] = Flags({ + registered: registered_, + respected: respected_, + blacklisted: blacklisted_, + retired: retired_, + finalized: finalized_, + proper: proper_, + claimValid: claimValid_ + }); + disputeGameBlacklist[game] = blacklisted_; + } + + function isGameBlacklisted(IDisputeGame game) external view returns (bool) { + return flags[address(game)].blacklisted; + } + + function isGameProper(IDisputeGame game) external view returns (bool) { + return flags[address(game)].proper; + } + + function isGameRegistered(IDisputeGame game) external view returns (bool) { + return flags[address(game)].registered; + } + + function isGameResolved(IDisputeGame) external pure returns (bool) { + return false; + } + + function isGameRespected(IDisputeGame game) external view returns (bool) { + return flags[address(game)].respected; + } + + function isGameRetired(IDisputeGame game) external view returns (bool) { + return flags[address(game)].retired; + } + + function isGameFinalized(IDisputeGame game) external view returns (bool) { + return flags[address(game)].finalized; + } + + function isGameClaimValid(IDisputeGame game) external view returns (bool) { + return flags[address(game)].claimValid; + } + + function setAnchorState(IDisputeGame game) external { + if (revertOnSetAnchorState) revert AnchorStateRegistry_InvalidAnchorGame(); + anchorGame = IFaultDisputeGame(address(game)); + lastSetAnchorState = game; + } + + function getAnchorRoot() external view returns (Hash, uint256) { + return (anchorRoot.root, anchorRoot.l2SequenceNumber); + } + + function disputeGameFinalityDelaySeconds() external pure returns (uint256) { + return 0; + } + + function superchainConfig() external view returns (ISuperchainConfig) { + return systemConfig.superchainConfig(); + } + + function version() external pure returns (string memory) { + return "mock"; + } + + function proxyAdmin() external pure returns (IProxyAdmin) { + return IProxyAdmin(address(0)); + } + + function proxyAdminOwner() external pure returns (address) { + return address(0); + } + + function __constructor__(uint256) external { } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockCloneableDisputeGame.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockCloneableDisputeGame.sol new file mode 100644 index 0000000000000..e81fa3f2200c0 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockCloneableDisputeGame.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Clone} from "@solady/utils/Clone.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {Timestamp, GameStatus, GameType, Claim, Hash} from "src/dispute/lib/Types.sol"; + +contract MockCloneableDisputeGame is Clone, IDisputeGame { + bool public initialized; + bool public wasRespectedGameTypeWhenCreated; + Timestamp public createdAt; + Timestamp public resolvedAt; + GameStatus public status; + + function initialize() external payable { + require(!initialized, "MockCloneableDisputeGame: already initialized"); + initialized = true; + createdAt = Timestamp.wrap(uint64(block.timestamp)); + status = GameStatus.IN_PROGRESS; + msg.value; + } + + function resolve() external returns (GameStatus status_) { + status = GameStatus.DEFENDER_WINS; + resolvedAt = Timestamp.wrap(uint64(block.timestamp)); + return status; + } + + function gameType() external pure returns (GameType gameType_) { + return GameType.wrap(0); + } + + function gameCreator() external pure returns (address creator_) { + return address(0); + } + + function rootClaim() external pure returns (Claim rootClaim_) { + return Claim.wrap(bytes32(0)); + } + + function l1Head() external pure returns (Hash l1Head_) { + return Hash.wrap(bytes32(0)); + } + + function l2SequenceNumber() external pure returns (uint256 l2SequenceNumber_) { + return 0; + } + + function extraData() external pure returns (bytes memory extraData_) { + return bytes(""); + } + + function gameData() external pure returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_) { + return (GameType.wrap(0), Claim.wrap(bytes32(0)), bytes("")); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockDisputeGameFactory.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockDisputeGameFactory.sol new file mode 100644 index 0000000000000..fe78a49f89831 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockDisputeGameFactory.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {LibClone} from "@solady/utils/LibClone.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {GameType, Claim, Timestamp} from "src/dispute/lib/Types.sol"; + +contract MockDisputeGameFactory { + using LibClone for address; + + struct StoredGame { + GameType gameType; + Timestamp timestamp; + IDisputeGame proxy; + Claim rootClaim; + bytes extraData; + } + + struct Lookup { + IDisputeGame proxy; + Timestamp timestamp; + } + + error IncorrectBondAmount(); + error NoImplementation(GameType gameType); + + address public owner; + + mapping(GameType => IDisputeGame) public gameImpls; + mapping(GameType => uint256) public initBonds; + mapping(GameType => bytes) public gameArgs; + + StoredGame[] internal storedGames; + mapping(bytes32 => Lookup) internal storedLookups; + + modifier onlyOwner() { + require(msg.sender == owner, "MockDisputeGameFactory: not owner"); + _; + } + + constructor() { + owner = msg.sender; + } + + function create(GameType _gameType, Claim _rootClaim, bytes calldata _extraData) + external + payable + returns (IDisputeGame proxy_) + { + IDisputeGame impl = gameImpls[_gameType]; + if (address(impl) == address(0)) revert NoImplementation(_gameType); + if (msg.value != initBonds[_gameType]) revert IncorrectBondAmount(); + + bytes32 parentHash = blockhash(block.number - 1); + if (gameArgs[_gameType].length == 0) { + proxy_ = IDisputeGame( + address(impl).clone(abi.encodePacked(msg.sender, _rootClaim, parentHash, _extraData)) + ); + } else { + proxy_ = IDisputeGame( + address(impl).clone( + abi.encodePacked(msg.sender, _rootClaim, parentHash, _gameType, _extraData, gameArgs[_gameType]) + ) + ); + } + + proxy_.initialize{value: msg.value}(); + _storeGame(_gameType, _rootClaim, _extraData, proxy_, uint64(block.timestamp)); + } + + function pushGame( + GameType _gameType, + uint64 _timestamp, + IDisputeGame _proxy, + Claim _rootClaim, + bytes memory _extraData + ) + external + { + _storeGame(_gameType, _rootClaim, _extraData, _proxy, _timestamp); + } + + function setImplementation(GameType _gameType, IDisputeGame _impl) external onlyOwner { + gameImpls[_gameType] = _impl; + } + + function setImplementation(GameType _gameType, IDisputeGame _impl, bytes calldata _args) external onlyOwner { + gameImpls[_gameType] = _impl; + gameArgs[_gameType] = _args; + } + + function setInitBond(GameType _gameType, uint256 _initBond) external onlyOwner { + initBonds[_gameType] = _initBond; + } + + function games(GameType _gameType, Claim _rootClaim, bytes calldata _extraData) + external + view + returns (IDisputeGame proxy_, Timestamp timestamp_) + { + Lookup memory lookup = storedLookups[_uuid(_gameType, _rootClaim, _extraData)]; + return (lookup.proxy, lookup.timestamp); + } + + function findLatestGames(GameType, uint256, uint256) external pure returns (bytes memory) { + revert("MockDisputeGameFactory: not implemented"); + } + + function gameAtIndex(uint256 _index) + external + view + returns (GameType gameType_, Timestamp timestamp_, IDisputeGame proxy_) + { + StoredGame storage game = storedGames[_index]; + return (game.gameType, game.timestamp, game.proxy); + } + + function gameCount() external view returns (uint256) { + return storedGames.length; + } + + function transferOwnership(address newOwner) external onlyOwner { + owner = newOwner; + } + + function initialize(address newOwner) external { + owner = newOwner; + } + + function proxyAdmin() external pure returns (address) { + return address(0); + } + + function proxyAdminOwner() external pure returns (address) { + return address(0); + } + + function initVersion() external pure returns (uint8) { + return 1; + } + + function renounceOwnership() external onlyOwner { + owner = address(0); + } + + function version() external pure returns (string memory) { + return "mock"; + } + + function __constructor__() external { } + + function _storeGame( + GameType _gameType, + Claim _rootClaim, + bytes memory _extraData, + IDisputeGame _proxy, + uint64 _timestamp + ) + internal + { + Timestamp timestamp = Timestamp.wrap(_timestamp); + storedGames.push( + StoredGame({ + gameType: _gameType, + timestamp: timestamp, + proxy: _proxy, + rootClaim: _rootClaim, + extraData: _extraData + }) + ); + storedLookups[_uuid(_gameType, _rootClaim, _extraData)] = Lookup({proxy: _proxy, timestamp: timestamp}); + } + + function _uuid(GameType _gameType, Claim _rootClaim, bytes memory _extraData) internal pure returns (bytes32) { + return keccak256(abi.encode(_gameType, _rootClaim, _extraData)); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockRiscZeroVerifier.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockRiscZeroVerifier.sol new file mode 100644 index 0000000000000..ab897314e8fd5 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockRiscZeroVerifier.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IRiscZeroVerifier} from "interfaces/dispute/IRiscZeroVerifier.sol"; + +contract MockRiscZeroVerifier is IRiscZeroVerifier { + bool public shouldRevert; + bytes public lastSeal; + bytes32 public lastImageId; + bytes32 public lastJournalDigest; + + function setShouldRevert(bool value) external { + shouldRevert = value; + } + + function verify(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) external view { + if (shouldRevert) revert("MockRiscZeroVerifier: invalid proof"); + seal; + imageId; + journalDigest; + } + + function verifyAndRecord(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) external { + if (shouldRevert) revert("MockRiscZeroVerifier: invalid proof"); + lastSeal = seal; + lastImageId = imageId; + lastJournalDigest = journalDigest; + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockStatusDisputeGame.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockStatusDisputeGame.sol new file mode 100644 index 0000000000000..3398bdbbebc46 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockStatusDisputeGame.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {Timestamp, GameStatus, GameType, Claim, Hash} from "src/dispute/lib/Types.sol"; + +contract MockStatusDisputeGame { + Timestamp public createdAt; + Timestamp public resolvedAt; + GameStatus public status; + GameType internal _gameType; + Claim internal _rootClaim; + Hash internal _l1Head; + uint256 internal _l2SequenceNumber; + bytes internal _extraData; + address internal _gameCreator; + bool public wasRespectedGameTypeWhenCreated; + IAnchorStateRegistry public anchorStateRegistry; + + constructor( + address creator_, + GameType gameType_, + Claim rootClaim_, + uint256 l2SequenceNumber_, + bytes memory extraData_, + GameStatus status_, + uint64 createdAt_, + uint64 resolvedAt_, + bool respected_, + IAnchorStateRegistry anchorStateRegistry_ + ) { + _gameCreator = creator_; + _gameType = gameType_; + _rootClaim = rootClaim_; + _l2SequenceNumber = l2SequenceNumber_; + _extraData = extraData_; + status = status_; + createdAt = Timestamp.wrap(createdAt_); + resolvedAt = Timestamp.wrap(resolvedAt_); + wasRespectedGameTypeWhenCreated = respected_; + anchorStateRegistry = anchorStateRegistry_; + } + + function initialize() external payable { } + + function resolve() external view returns (GameStatus status_) { + return status; + } + + function setStatus(GameStatus status_) external { + status = status_; + } + + function setResolvedAt(uint64 resolvedAt_) external { + resolvedAt = Timestamp.wrap(resolvedAt_); + } + + function gameData() external view returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_) { + return (_gameType, _rootClaim, _extraData); + } + + function gameType() external view returns (GameType gameType_) { + return _gameType; + } + + function rootClaim() external view returns (Claim rootClaim_) { + return _rootClaim; + } + + function l1Head() external view returns (Hash l1Head_) { + return _l1Head; + } + + function l2SequenceNumber() external view returns (uint256 l2SequenceNumber_) { + return _l2SequenceNumber; + } + + function extraData() external view returns (bytes memory extraData_) { + return _extraData; + } + + function gameCreator() external view returns (address creator_) { + return _gameCreator; + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockSystemConfig.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockSystemConfig.sol new file mode 100644 index 0000000000000..b318e6c19ef08 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockSystemConfig.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {ISuperchainConfig} from "interfaces/L1/ISuperchainConfig.sol"; + +contract MockSystemConfig { + bool public paused; + address public guardian; + ISuperchainConfig public superchainConfig; + + constructor(address guardian_) { + guardian = guardian_; + } + + function setPaused(bool value) external { + paused = value; + } + + function setGuardian(address value) external { + guardian = value; + } + + function setSuperchainConfig(ISuperchainConfig value) external { + superchainConfig = value; + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockTeeProofVerifier.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockTeeProofVerifier.sol new file mode 100644 index 0000000000000..4324c983dcda4 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockTeeProofVerifier.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; + +contract MockTeeProofVerifier is ITeeProofVerifier { + error EnclaveNotRegistered(); + error InvalidSignature(); + + mapping(address => bool) public registered; + bytes32 public lastDigest; + bytes public lastSignature; + + function setRegistered(address enclave, bool value) external { + registered[enclave] = value; + } + + function verifyBatch(bytes32 digest, bytes calldata signature) + external + view + returns (address signer) + { + (address recovered, ECDSA.RecoverError err) = ECDSA.tryRecover(digest, signature); + if (err != ECDSA.RecoverError.NoError || recovered == address(0)) revert InvalidSignature(); + if (!registered[recovered]) revert EnclaveNotRegistered(); + return recovered; + } + + function verifyBatchAndRecord(bytes32 digest, bytes calldata signature) + external + returns (address signer) + { + lastDigest = digest; + lastSignature = signature; + return this.verifyBatch(digest, signature); + } + + function isRegistered(address enclaveAddress) external view returns (bool) { + return registered[enclaveAddress]; + } +} From 5646b4ef25603e43165c7dc2f7f8278978ee665c Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Fri, 20 Mar 2026 17:04:43 +0800 Subject: [PATCH 09/25] add deploy tee game sol --- .../scripts/DevnetAddTeeGame.s.sol | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 packages/contracts-bedrock/scripts/DevnetAddTeeGame.s.sol diff --git a/packages/contracts-bedrock/scripts/DevnetAddTeeGame.s.sol b/packages/contracts-bedrock/scripts/DevnetAddTeeGame.s.sol new file mode 100644 index 0000000000000..98469b8f15a7c --- /dev/null +++ b/packages/contracts-bedrock/scripts/DevnetAddTeeGame.s.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Script, console2} from "forge-std/Script.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {ISystemConfig} from "interfaces/L1/ISystemConfig.sol"; +import {GameType, Duration, Hash, Proposal} from "src/dispute/lib/Types.sol"; +import {AnchorStateRegistry} from "src/dispute/AnchorStateRegistry.sol"; +import {IRiscZeroVerifier} from "interfaces/dispute/IRiscZeroVerifier.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; +import {AccessManager} from "src/dispute/tee/AccessManager.sol"; +import {TeeDisputeGame} from "src/dispute/tee/TeeDisputeGame.sol"; +import {TeeProofVerifier} from "src/dispute/tee/TeeProofVerifier.sol"; +import {MockRiscZeroVerifier} from "test/dispute/tee/mocks/MockRiscZeroVerifier.sol"; +import {MockTeeProofVerifier} from "test/dispute/tee/mocks/MockTeeProofVerifier.sol"; +import {Proxy} from "src/universal/Proxy.sol"; +import {ProxyAdmin} from "src/universal/ProxyAdmin.sol"; + +/// @notice Deploys TeeDisputeGame and a new AnchorStateRegistry onto an existing devnet, +/// wiring the new ASR to the **existing** DisputeGameFactory (so isGameProper() works +/// for games created by that factory). +/// +/// Intentionally does NOT call setImplementation / setInitBond on the existing DGF +/// (those require TRANSACTOR or Safe, handled by the bash wrapper). +/// setRespectedGameType(1960) is called during initialize() to avoid a +/// partial-failure window where the ASR exists with game type 0. +/// +/// Required env vars (set by add-tee-game-type.sh, which sources devnet/.env): +/// PRIVATE_KEY deployer private key +/// EXISTING_DGF existing devnet DGF address +/// EXISTING_ASR existing AnchorStateRegistry (source for starting anchor) +/// SYSTEM_CONFIG_ADDRESS SystemConfig proxy address +/// DISPUTE_GAME_FINALITY_DELAY_SECONDS finality delay for new ASR +/// MAX_CHALLENGE_DURATION seconds +/// MAX_PROVE_DURATION seconds +/// CHALLENGER_BOND wei +/// FALLBACK_TIMEOUT seconds +/// INIT_BOND wei (informational — not set here) +/// +/// Optional: +/// PROPOSER_ADDRESS address to whitelist as proposer (defaults to deployer) +/// +/// Optional (mock mode): +/// USE_MOCK_VERIFIER if "true", deploy MockTeeProofVerifier instead of +/// MockRiscZeroVerifier + TeeProofVerifier. Useful when +/// you want a fully mock verifier (no ECDSA needed). +contract DevnetAddTeeGame is Script { + uint32 internal constant TEE_GAME_TYPE = 1960; + + struct Config { + uint256 deployerKey; + address deployer; + address existingDgf; + address anchorStateRegistry; + address systemConfig; + uint256 disputeGameFinalityDelaySeconds; + uint64 maxChallengeDuration; + uint64 maxProveDuration; + uint256 challengerBond; + uint256 fallbackTimeout; + address proposer; + bool useMockVerifier; + } + + function run() external { + Config memory cfg = _readConfig(); + + vm.startBroadcast(cfg.deployerKey); + + // 1. Select verifier: + // --mock-verifier flag → MockTeeProofVerifier (no ECDSA needed, fully open) + // default → MockRiscZeroVerifier + TeeProofVerifier (accepts any RZ proof) + address verifier; + if (cfg.useMockVerifier) { + verifier = address(new MockTeeProofVerifier()); + console2.log("Verifier mode: MockTeeProofVerifier (mock)"); + } else { + address mockRisc = address(new MockRiscZeroVerifier()); + verifier = address(new TeeProofVerifier( + IRiscZeroVerifier(mockRisc), + bytes32(0), // dummy imageId for devnet + bytes("") // dummy nitroRootKey for devnet + )); + console2.log("Verifier mode: TeeProofVerifier + MockRiscZeroVerifier"); + } + + // 2. Deploy a fresh ProxyAdmin owned by the deployer for TEE-specific proxies. + // The devnet's existing ProxyAdmin is owned by the TRANSACTOR contract, + // not the deployer EOA, so we can't call upgrade() through it directly. + // With our own ProxyAdmin: proxyAdminOwner() == deployer == msg.sender, so + // ProxyAdminOwnedBase._assertOnlyProxyAdminOrProxyAdminOwner() passes in initialize(). + address teeProxyAdmin = address(new ProxyAdmin(cfg.deployer)); + + // 3. New AnchorStateRegistry (impl + Proxy), pointing at the EXISTING DGF. + // This lets isGameProper() recognise games created by the existing factory. + address tzAsr = _deployAsr(cfg, teeProxyAdmin); + + // 4. AccessManager + TeeDisputeGame impl (no proxy — it's the impl) + address teeDisputeGame = _deployTeeStack(cfg, verifier, tzAsr); + + vm.stopBroadcast(); + + _printResults(tzAsr, teeDisputeGame); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + function _readConfig() internal view returns (Config memory cfg) { + cfg.deployerKey = vm.envUint("PRIVATE_KEY"); + cfg.deployer = vm.addr(cfg.deployerKey); + cfg.existingDgf = vm.envAddress("EXISTING_DGF"); + cfg.anchorStateRegistry = vm.envAddress("EXISTING_ASR"); + cfg.systemConfig = vm.envAddress("SYSTEM_CONFIG_ADDRESS"); + cfg.disputeGameFinalityDelaySeconds = vm.envUint("DISPUTE_GAME_FINALITY_DELAY_SECONDS"); + cfg.maxChallengeDuration = uint64(vm.envUint("MAX_CHALLENGE_DURATION")); + cfg.maxProveDuration = uint64(vm.envUint("MAX_PROVE_DURATION")); + cfg.challengerBond = vm.envUint("CHALLENGER_BOND"); + cfg.fallbackTimeout = vm.envUint("FALLBACK_TIMEOUT"); + cfg.proposer = vm.envOr("PROPOSER_ADDRESS", cfg.deployer); + cfg.useMockVerifier = vm.envOr("USE_MOCK_VERIFIER", false); + } + + function _deployAsr(Config memory cfg, address proxyAdmin) internal returns (address) { + // Deploy impl + AnchorStateRegistry asrImpl = new AnchorStateRegistry(cfg.disputeGameFinalityDelaySeconds); + + // Deploy proxy backed by our fresh ProxyAdmin + Proxy asrProxy = new Proxy(payable(proxyAdmin)); + ProxyAdmin(payable(proxyAdmin)).upgrade(payable(address(asrProxy)), address(asrImpl)); + + // Copy starting anchor root from existing ASR (game type 0 = CannonFaultDisputeGame) + (Hash root, uint256 l2SequenceNumber) = + IAnchorStateRegistry(cfg.anchorStateRegistry).anchors(GameType.wrap(0)); + Proposal memory startingAnchorRoot = Proposal({root: root, l2SequenceNumber: l2SequenceNumber}); + + // Initialize pointing at the EXISTING DGF with respectedGameType=1960 from the start, + // avoiding a partial-failure window where the ASR exists but has game type 0. + AnchorStateRegistry(address(asrProxy)).initialize( + ISystemConfig(cfg.systemConfig), + IDisputeGameFactory(cfg.existingDgf), + startingAnchorRoot, + GameType.wrap(TEE_GAME_TYPE) + ); + + return address(asrProxy); + } + + function _deployTeeStack(Config memory cfg, address verifier, address tzAsr) + internal + returns (address teeDisputeGame) + { + AccessManager accessManager = new AccessManager(cfg.fallbackTimeout, IDisputeGameFactory(cfg.existingDgf)); + accessManager.setProposer(cfg.proposer, true); + accessManager.setChallenger(address(0), true); // permissionless challenge on devnet + + teeDisputeGame = address( + new TeeDisputeGame( + Duration.wrap(cfg.maxChallengeDuration), + Duration.wrap(cfg.maxProveDuration), + IDisputeGameFactory(cfg.existingDgf), + ITeeProofVerifier(verifier), + cfg.challengerBond, + IAnchorStateRegistry(tzAsr), + accessManager + ) + ); + } + + function _printResults(address tzAsr, address teeDisputeGame) internal pure { + console2.log(""); + console2.log("=== TEE Game Deployment Results ==="); + console2.log("TeeDisputeGame impl: ", teeDisputeGame); + console2.log("New AnchorStateRegistry:", tzAsr); + console2.log(""); + console2.log("Next steps (handled by bash wrapper):"); + console2.log(" 1. setRespectedGameType(1960) on new ASR (deployer is guardian)"); + console2.log(" 2. setImplementation(1960, impl) on existing DGF (via TRANSACTOR/Safe)"); + console2.log(" 3. setInitBond(1960, bond) on existing DGF (via TRANSACTOR/Safe)"); + } +} From d88abb310bc254c2f5ebb27137f84fa42317223f Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Fri, 20 Mar 2026 22:39:44 +0800 Subject: [PATCH 10/25] support find last game index --- op-proposer/contracts/disputegamefactory.go | 18 +- .../contracts/teedisputegame_xlayer.go | 204 ++++++++++++++++++ op-proposer/proposer/service.go | 27 +++ .../source/source_tee_rollup_xlayer.go | 25 ++- 4 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 op-proposer/contracts/teedisputegame_xlayer.go diff --git a/op-proposer/contracts/disputegamefactory.go b/op-proposer/contracts/disputegamefactory.go index 510fa02949d99..4e81cede25464 100644 --- a/op-proposer/contracts/disputegamefactory.go +++ b/op-proposer/contracts/disputegamefactory.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "math/big" - "strings" "time" "github.com/ethereum-optimism/optimism/op-service/bigs" @@ -24,24 +23,8 @@ const ( methodVersion = "version" methodClaim = "claimData" - - teeGameType uint32 = 1960 // For xlayer: TEE game type (TeeRollup) ) -// For xlayer: ABI for new game contract's no-arg claimData() struct getter -const newGameClaimDataABIJSON = `[{"name":"claimData","type":"function","inputs":[],"outputs":[{"name":"parentIndex","type":"uint32"},{"name":"counteredBy","type":"address"},{"name":"prover","type":"address"},{"name":"claim","type":"bytes32"},{"name":"status","type":"uint8"},{"name":"deadline","type":"uint64"}],"stateMutability":"view"}]` - -// For xlayer: parsed ABI for new game contract's claimData() getter -var newGameClaimDataABI abi.ABI - -func init() { - var err error - newGameClaimDataABI, err = abi.JSON(strings.NewReader(newGameClaimDataABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse new game claim data ABI: %v", err)) - } -} - type gameMetadata struct { GameType uint32 Timestamp time.Time @@ -55,6 +38,7 @@ type DisputeGameFactory struct { contract *batching.BoundContract gameABI *abi.ABI networkTimeout time.Duration + teeCache gameIndexCache // For xlayer: in-memory cache of immutable game index metadata } func NewDisputeGameFactory(addr common.Address, caller *batching.MultiCaller, networkTimeout time.Duration) *DisputeGameFactory { diff --git a/op-proposer/contracts/teedisputegame_xlayer.go b/op-proposer/contracts/teedisputegame_xlayer.go new file mode 100644 index 0000000000000..f78928173bcf1 --- /dev/null +++ b/op-proposer/contracts/teedisputegame_xlayer.go @@ -0,0 +1,204 @@ +// For xlayer: TEE dispute game helpers — status/proposer getters and parent game index resolution. +package contracts + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +const teeGameType uint32 = 1960 // For xlayer: TEE game type (TeeRollup) + +const teeParentScanLimit = 1000 // For xlayer: max DGF entries to scan for parent game + +// For xlayer: GameStatus enum values matching TeeDisputeGame (Types.sol) +const ( + GameStatusInProgress uint8 = 0 + GameStatusChallengerWins uint8 = 1 + GameStatusDefenderWins uint8 = 2 +) + +// For xlayer: ABI for new game contract's no-arg claimData() struct getter +const newGameClaimDataABIJSON = `[{"name":"claimData","type":"function","inputs":[],"outputs":[{"name":"parentIndex","type":"uint32"},{"name":"counteredBy","type":"address"},{"name":"prover","type":"address"},{"name":"claim","type":"bytes32"},{"name":"status","type":"uint8"},{"name":"deadline","type":"uint64"}],"stateMutability":"view"}]` + +// For xlayer: ABI for TeeDisputeGame status() and proposer() getters +const teeGameStatusABIJSON = `[{"name":"status","type":"function","inputs":[],"outputs":[{"name":"","type":"uint8"}],"stateMutability":"view"}]` +const teeGameProposerABIJSON = `[{"name":"proposer","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` + +// For xlayer: parsed ABI for new game contract's claimData() getter +var newGameClaimDataABI abi.ABI + +// For xlayer: parsed ABIs for TeeDisputeGame status() and proposer() getters +var teeGameStatusABI abi.ABI +var teeGameProposerABI abi.ABI + +func init() { + var err error + newGameClaimDataABI, err = abi.JSON(strings.NewReader(newGameClaimDataABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse new game claim data ABI: %v", err)) + } + teeGameStatusABI, err = abi.JSON(strings.NewReader(teeGameStatusABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse tee game status ABI: %v", err)) + } + teeGameProposerABI, err = abi.JSON(strings.NewReader(teeGameProposerABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse tee game proposer ABI: %v", err)) + } +} + +// For xlayer: cachedGameEntry holds immutable per-game metadata cached by DGF index. +type cachedGameEntry struct { + GameType uint32 + Address common.Address + Proposer common.Address + ProposerFetched bool +} + +// For xlayer: gameIndexCache is an in-memory, RWMutex-protected cache of DGF index -> immutable game metadata. +// GameType and Address are cached on first fetch. Proposer (also immutable) is cached lazily on first need. +// Game status is NOT cached — it changes when a game is resolved. +type gameIndexCache struct { + mu sync.RWMutex + entries map[uint64]cachedGameEntry +} + +func (c *gameIndexCache) get(idx uint64) (cachedGameEntry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.entries[idx] + return e, ok +} + +func (c *gameIndexCache) set(idx uint64, e cachedGameEntry) { + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil { + c.entries = make(map[uint64]cachedGameEntry) + } + c.entries[idx] = e +} + +// For xlayer: gameStatusAt fetches the current GameStatus of a TeeDisputeGame proxy via status() getter. +// Status is mutable (changes on resolve) and must NOT be cached. +func (f *DisputeGameFactory) gameStatusAt(ctx context.Context, proxyAddr common.Address) (uint8, error) { + cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + statusContract := batching.NewBoundContract(&teeGameStatusABI, proxyAddr) + result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, statusContract.Call("status")) + if err != nil { + return 0, fmt.Errorf("tee-rollup: failed to get status of game %v: %w", proxyAddr, err) + } + return result.GetUint8(0), nil +} + +// For xlayer: gameProposerAt fetches the proposer() of a TeeDisputeGame proxy. +// Proposer is immutable (set to tx.origin in initialize()) but cached lazily. +func (f *DisputeGameFactory) gameProposerAt(ctx context.Context, proxyAddr common.Address) (common.Address, error) { + cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + proposerContract := batching.NewBoundContract(&teeGameProposerABI, proxyAddr) + result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, proposerContract.Call("proposer")) + if err != nil { + return common.Address{}, fmt.Errorf("tee-rollup: failed to get proposer of game %v: %w", proxyAddr, err) + } + return result.GetAddress(0), nil +} + +// For xlayer: FindLastGameIndex scans the DGF in reverse to find the most recent game with the +// given gameType that is a valid parent candidate: +// - DEFENDER_WINS: accepted immediately (contract allows it) +// - IN_PROGRESS + self-proposed: accepted (proposer == self) +// - CHALLENGER_WINS: skipped (contract rejects with InvalidParentGame) +// - IN_PROGRESS + other proposer: skipped (cannot control resolution) +// +// Uses an in-memory cache for immutable fields (GameType, Address, Proposer) to avoid redundant RPCs. +// Returns (idx, true, nil) if found, (0, false, nil) if not found within maxScan, or (0, false, err) on error. +func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uint32, proposer common.Address, maxScan uint64) (uint64, bool, error) { // For xlayer + gameCount, err := f.gameCount(ctx) + if err != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to get game count: %w", err) + } + if gameCount == 0 { + return 0, false, nil + } + scanned := uint64(0) + for idx := gameCount - 1; ; idx-- { + if scanned >= maxScan { + return 0, false, nil + } + scanned++ + + // For xlayer: check cache first — GameType and Address are immutable per DGF index + entry, cached := f.teeCache.get(idx) + if !cached { + game, err := f.gameAtIndex(ctx, idx) + if err != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to get game at index %d: %w", idx, err) + } + entry = cachedGameEntry{GameType: game.GameType, Address: game.Address} + f.teeCache.set(idx, entry) + } + + if entry.GameType != gameType { + if idx == 0 { + break + } + continue + } + + // For xlayer: fetch status fresh — mutable, must not be cached + status, err := f.gameStatusAt(ctx, entry.Address) + if err != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to get status at index %d: %w", idx, err) + } + + // For xlayer: contract rejects CHALLENGER_WINS parents (TeeDisputeGame.sol:204) + if status == GameStatusChallengerWins { + if idx == 0 { + break + } + continue + } + + // For xlayer: DEFENDER_WINS — accept immediately + if status == GameStatusDefenderWins { + return idx, true, nil + } + + // For xlayer: IN_PROGRESS — only accept if self-proposed + // Check cached proposer first; only call gameProposerAt on cache miss + gameProposer := entry.Proposer + if !entry.ProposerFetched { + gameProposer, err = f.gameProposerAt(ctx, entry.Address) + if err != nil { + // For xlayer: cannot verify proposer — skip this game + if idx == 0 { + break + } + continue + } + // For xlayer: cache proposer (immutable — set once in initialize()) + entry.Proposer = gameProposer + entry.ProposerFetched = true + f.teeCache.set(idx, entry) + } + + if gameProposer == proposer { + return idx, true, nil + } + // For xlayer: IN_PROGRESS by another proposer — skip + + if idx == 0 { + break + } + } + return 0, false, nil +} diff --git a/op-proposer/proposer/service.go b/op-proposer/proposer/service.go index f0fa2ab95131b..58813443f04d2 100644 --- a/op-proposer/proposer/service.go +++ b/op-proposer/proposer/service.go @@ -5,10 +5,12 @@ import ( "errors" "fmt" "io" + "math" "strings" "sync/atomic" "time" + "github.com/ethereum-optimism/optimism/op-proposer/contracts" "github.com/ethereum-optimism/optimism/op-proposer/metrics" "github.com/ethereum-optimism/optimism/op-proposer/proposer/rpc" "github.com/ethereum-optimism/optimism/op-proposer/proposer/source" @@ -277,6 +279,31 @@ func (ps *ProposerService) initDriver() error { return err } ps.driver = driver + + // For xlayer: wire parent index resolver into TeeRollupProposalSource after DGF caller is available + if teeSource, ok := ps.ProposalSource.(*source.TeeRollupProposalSource); ok { + if dgfCaller, ok := driver.dgfContract.(*contracts.DisputeGameFactory); ok { + proposer := driver.Txmgr.From() + gameType := uint32(ps.ProposerConfig.DisputeGameType) + teeSource.SetParentIdxFn(func(ctx context.Context) (uint32, bool, error) { + idx, found, err := dgfCaller.FindLastGameIndex(ctx, gameType, proposer, 1000) // For xlayer + if err != nil { + return 0, false, err + } + if !found { + return 0, false, nil + } + if idx > math.MaxUint32 { + return 0, false, fmt.Errorf("tee-rollup: game index %d exceeds uint32 range", idx) + } + return uint32(idx), true, nil + }) + } else { + // For xlayer: dgfContract is not *contracts.DisputeGameFactory — parentIdxFn not wired, will use MaxUint32 sentinel + ps.Log.Warn("tee-rollup: dgfContract is not *DisputeGameFactory, parentIdxFn not wired") + } + } + return nil } diff --git a/op-proposer/proposer/source/source_tee_rollup_xlayer.go b/op-proposer/proposer/source/source_tee_rollup_xlayer.go index 1593aa1af0f9e..411b2a97e92ef 100644 --- a/op-proposer/proposer/source/source_tee_rollup_xlayer.go +++ b/op-proposer/proposer/source/source_tee_rollup_xlayer.go @@ -133,8 +133,9 @@ func (c *TeeRollupHTTPClient) Close() {} // For xlayer: TeeRollupProposalSource implements ProposalSource for TeeRollup TEE game type 1960. type TeeRollupProposalSource struct { - log log.Logger - clients []TeeRollupClient + log log.Logger + clients []TeeRollupClient + parentIdxFn func(ctx context.Context) (uint32, bool, error) // For xlayer: resolves parent DGF game index } // For xlayer: NewTeeRollupProposalSource creates a new TeeRollupProposalSource. @@ -148,6 +149,13 @@ func NewTeeRollupProposalSource(log log.Logger, clients ...TeeRollupClient) *Tee } } +// For xlayer: SetParentIdxFn injects the callback that resolves the parent DGF game index. +// MUST be called before Start() to satisfy Go's happens-before guarantee. +// If nil (default), ProposalAtSequenceNum always uses math.MaxUint32 (anchor state sentinel). +func (s *TeeRollupProposalSource) SetParentIdxFn(fn func(ctx context.Context) (uint32, bool, error)) { + s.parentIdxFn = fn +} + // For xlayer: SyncStatus queries all clients in parallel and returns the most conservative (lowest) height. // CurrentL1 is always zero value — TeeRollup has no L1 derivation. func (s *TeeRollupProposalSource) SyncStatus(ctx context.Context) (SyncStatus, error) { @@ -201,13 +209,24 @@ func (s *TeeRollupProposalSource) ProposalAtSequenceNum(ctx context.Context, seq continue } rootClaim := computeRootClaim(info.BlockHash, info.AppHash) + + // For xlayer: resolve parentIdx dynamically; fall back to MaxUint32 (anchor state) if not found or error + parentIdx := uint32(math.MaxUint32) + if s.parentIdxFn != nil { + if idx, found, err := s.parentIdxFn(ctx); err != nil { + s.log.Warn("tee-rollup: failed to resolve parent game index, using anchor sentinel", "err", err) + } else if found { + parentIdx = idx + } + } + proposal := Proposal{ Root: rootClaim, SequenceNum: seqNum, CurrentL1: eth.BlockID{}, // For xlayer: always zero — no L1 derivation TeeRollupData: &TeeRollupProposalData{ L2SeqNum: seqNum, - ParentIdx: math.MaxUint32, // For xlayer: type(uint32).max signals "no parent, use anchor state" in TeeDisputeGame + ParentIdx: parentIdx, BlockHash: info.BlockHash, StateHash: info.AppHash, }, From c7adf53bf6d9848b37d45837047fff776e8a15a0 Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Sat, 21 Mar 2026 00:29:18 +0800 Subject: [PATCH 11/25] fix(op-challenger): reject anchor-based games in GetProveParams instead of returning zero hashes Anchor-based games (parentIndex=MaxUint32) cannot be proved because the anchor root is a combined keccak256(blockHash, stateHash) that cannot be decomposed back into individual hashes. Previously returned zero values which would always cause StartHashMismatch revert. Now returns ErrAnchorGameUnprovable so the actor marks proveGivenUp and lets the game resolve via deadline expiry. Co-Authored-By: Claude Opus 4.6 --- .../game/fault/contracts/teedisputegame.go | 53 +++++++++++-------- op-challenger/game/tee/actor.go | 5 ++ 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/op-challenger/game/fault/contracts/teedisputegame.go b/op-challenger/game/fault/contracts/teedisputegame.go index ea1f9619acae9..d09ef7e212623 100644 --- a/op-challenger/game/fault/contracts/teedisputegame.go +++ b/op-challenger/game/fault/contracts/teedisputegame.go @@ -22,6 +22,10 @@ var ( methodProposer = "proposer" methodBlockHash = "blockHash" methodStateHash = "stateHash" + + // ErrAnchorGameUnprovable is returned when a game uses anchor state (parentIndex=MaxUint32) + // and cannot be proved because individual start hashes are not recoverable from the combined anchor root. + ErrAnchorGameUnprovable = fmt.Errorf("anchor-based game (parentIndex=MaxUint32) cannot be proved: start hashes not recoverable") ) // TeeProveParams contains the parameters needed to request a TEE proof. @@ -225,30 +229,33 @@ func (g *TeeDisputeGameContractLatest) GetProveParams(ctx context.Context, facto // Read start-side data from parent game parentIndex := data.ParentIndex - if parentIndex != math.MaxUint32 { - // Get parent game address from factory - parentGame, err := factory.GetGame(ctx, uint64(parentIndex), rpcblock.Latest) - if err != nil { - return TeeProveParams{}, fmt.Errorf("failed to get parent game at index %d: %w", parentIndex, err) - } - - // Read parent game's blockHash() and stateHash() CWIA getters - parentContract := batching.NewBoundContract(snapshots.LoadTeeDisputeGameABI(), parentGame.Proxy) - parentResults, err := g.multiCaller.Call(ctx, rpcblock.Latest, - parentContract.Call(methodBlockHash), - parentContract.Call(methodStateHash), - ) - if err != nil { - return TeeProveParams{}, fmt.Errorf("failed to read parent game hashes: %w", err) - } - if len(parentResults) != 2 { - return TeeProveParams{}, fmt.Errorf("expected 2 parent results but got %v", len(parentResults)) - } - params.StartBlockHash = parentResults[0].GetHash(0) - params.StartStateHash = parentResults[1].GetHash(0) + if parentIndex == math.MaxUint32 { + // Anchor-based games cannot be proved — the anchor root is a combined hash + // keccak256(blockHash, stateHash) and individual hashes are not recoverable. + // The game will resolve based on deadline expiry. + return TeeProveParams{}, ErrAnchorGameUnprovable + } + + // Get parent game address from factory + parentGame, err := factory.GetGame(ctx, uint64(parentIndex), rpcblock.Latest) + if err != nil { + return TeeProveParams{}, fmt.Errorf("failed to get parent game at index %d: %w", parentIndex, err) + } + + // Read parent game's blockHash() and stateHash() CWIA getters + parentContract := batching.NewBoundContract(snapshots.LoadTeeDisputeGameABI(), parentGame.Proxy) + parentResults, err := g.multiCaller.Call(ctx, rpcblock.Latest, + parentContract.Call(methodBlockHash), + parentContract.Call(methodStateHash), + ) + if err != nil { + return TeeProveParams{}, fmt.Errorf("failed to read parent game hashes: %w", err) + } + if len(parentResults) != 2 { + return TeeProveParams{}, fmt.Errorf("expected 2 parent results but got %v", len(parentResults)) } - // For anchor game (parentIndex == MaxUint32), start hashes remain zero. - // This is an edge case that needs contract-side optimization. + params.StartBlockHash = parentResults[0].GetHash(0) + params.StartStateHash = parentResults[1].GetHash(0) return params, nil } diff --git a/op-challenger/game/tee/actor.go b/op-challenger/game/tee/actor.go index 846a6255f9416..dda3019f2367b 100644 --- a/op-challenger/game/tee/actor.go +++ b/op-challenger/game/tee/actor.go @@ -175,6 +175,11 @@ func (a *Actor) tryStartProve(ctx context.Context, metadata contracts.Challenger params, err := a.contract.GetProveParams(ctx, a.factory) if err != nil { + if errors.Is(err, contracts.ErrAnchorGameUnprovable) { + a.proveGivenUp = true + a.logger.Warn("Anchor-based game cannot be proved, giving up", "game", a.contract.Addr()) + return errNoProveRequired + } return fmt.Errorf("failed to get prove params: %w", err) } From 8b46b808f4f6752290f9979301f3ccc17f809eed Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Mon, 23 Mar 2026 16:52:32 +0800 Subject: [PATCH 12/25] fix jump retired game --- .../contracts/teedisputegame_xlayer.go | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/op-proposer/contracts/teedisputegame_xlayer.go b/op-proposer/contracts/teedisputegame_xlayer.go index f78928173bcf1..b6ac03ccd37dc 100644 --- a/op-proposer/contracts/teedisputegame_xlayer.go +++ b/op-proposer/contracts/teedisputegame_xlayer.go @@ -31,6 +31,17 @@ const newGameClaimDataABIJSON = `[{"name":"claimData","type":"function","inputs" const teeGameStatusABIJSON = `[{"name":"status","type":"function","inputs":[],"outputs":[{"name":"","type":"uint8"}],"stateMutability":"view"}]` const teeGameProposerABIJSON = `[{"name":"proposer","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` +// For xlayer: ABI for DGF.gameImpls(uint32) — returns the implementation contract address for a game type +const dgfGameImplsABIJSON = `[{"name":"gameImpls","type":"function","inputs":[{"name":"_gameType","type":"uint32"}],"outputs":[{"name":"impl_","type":"address"}],"stateMutability":"view"}]` + +// For xlayer: ABI for TeeDisputeGame impl anchorStateRegistry() — returns the immutable ASR address +const teeGameAnchorStateRegistryABIJSON = `[{"name":"anchorStateRegistry","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` + +// For xlayer: ASR validation ABIs +const asrIsGameRespectedABIJSON = `[{"name":"isGameRespected","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` +const asrIsGameBlacklistedABIJSON = `[{"name":"isGameBlacklisted","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` +const asrIsGameRetiredABIJSON = `[{"name":"isGameRetired","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` + // For xlayer: parsed ABI for new game contract's claimData() getter var newGameClaimDataABI abi.ABI @@ -38,6 +49,13 @@ var newGameClaimDataABI abi.ABI var teeGameStatusABI abi.ABI var teeGameProposerABI abi.ABI +// For xlayer: parsed ABIs for DGF gameImpls, impl anchorStateRegistry, and ASR validation +var dgfGameImplsABI abi.ABI +var teeGameAnchorStateRegistryABI abi.ABI +var asrIsGameRespectedABI abi.ABI +var asrIsGameBlacklistedABI abi.ABI +var asrIsGameRetiredABI abi.ABI + func init() { var err error newGameClaimDataABI, err = abi.JSON(strings.NewReader(newGameClaimDataABIJSON)) @@ -52,6 +70,26 @@ func init() { if err != nil { panic(fmt.Sprintf("failed to parse tee game proposer ABI: %v", err)) } + dgfGameImplsABI, err = abi.JSON(strings.NewReader(dgfGameImplsABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse DGF gameImpls ABI: %v", err)) + } + teeGameAnchorStateRegistryABI, err = abi.JSON(strings.NewReader(teeGameAnchorStateRegistryABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse tee game anchorStateRegistry ABI: %v", err)) + } + asrIsGameRespectedABI, err = abi.JSON(strings.NewReader(asrIsGameRespectedABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse ASR isGameRespected ABI: %v", err)) + } + asrIsGameBlacklistedABI, err = abi.JSON(strings.NewReader(asrIsGameBlacklistedABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse ASR isGameBlacklisted ABI: %v", err)) + } + asrIsGameRetiredABI, err = abi.JSON(strings.NewReader(asrIsGameRetiredABIJSON)) + if err != nil { + panic(fmt.Sprintf("failed to parse ASR isGameRetired ABI: %v", err)) + } } // For xlayer: cachedGameEntry holds immutable per-game metadata cached by DGF index. @@ -65,9 +103,13 @@ type cachedGameEntry struct { // For xlayer: gameIndexCache is an in-memory, RWMutex-protected cache of DGF index -> immutable game metadata. // GameType and Address are cached on first fetch. Proposer (also immutable) is cached lazily on first need. // Game status is NOT cached — it changes when a game is resolved. +// ASR address is fetched once from the game impl contract and cached for the lifetime of the process. type gameIndexCache struct { mu sync.RWMutex entries map[uint64]cachedGameEntry + // For xlayer: ASR address fetched once from the game impl contract and cached + asrAddr common.Address + asrAddrFetched bool } func (c *gameIndexCache) get(idx uint64) (cachedGameEntry, bool) { @@ -86,6 +128,21 @@ func (c *gameIndexCache) set(idx uint64, e cachedGameEntry) { c.entries[idx] = e } +// For xlayer: getASRAddr returns the cached ASR address if available. +func (c *gameIndexCache) getASRAddr() (common.Address, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.asrAddr, c.asrAddrFetched +} + +// For xlayer: setASRAddr stores the ASR address in the cache. +func (c *gameIndexCache) setASRAddr(addr common.Address) { + c.mu.Lock() + defer c.mu.Unlock() + c.asrAddr = addr + c.asrAddrFetched = true +} + // For xlayer: gameStatusAt fetches the current GameStatus of a TeeDisputeGame proxy via status() getter. // Status is mutable (changes on resolve) and must NOT be cached. func (f *DisputeGameFactory) gameStatusAt(ctx context.Context, proxyAddr common.Address) (uint8, error) { @@ -112,6 +169,75 @@ func (f *DisputeGameFactory) gameProposerAt(ctx context.Context, proxyAddr commo return result.GetAddress(0), nil } +// For xlayer: asrAddrFromImpl fetches the AnchorStateRegistry address from the DGF's +// game implementation contract for the given gameType. The result is immutable and cached. +func (f *DisputeGameFactory) asrAddrFromImpl(ctx context.Context, gameType uint32) (common.Address, error) { + if addr, ok := f.teeCache.getASRAddr(); ok { + return addr, nil + } + // Step 1: get impl address from DGF + cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + implsContract := batching.NewBoundContract(&dgfGameImplsABI, f.contract.Addr()) + result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, implsContract.Call("gameImpls", gameType)) + if err != nil { + return common.Address{}, fmt.Errorf("tee-rollup: failed to get game impl for type %d: %w", gameType, err) + } + implAddr := result.GetAddress(0) + // Step 2: call anchorStateRegistry() on the impl + cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + asrContract := batching.NewBoundContract(&teeGameAnchorStateRegistryABI, implAddr) + result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, asrContract.Call("anchorStateRegistry")) + if err != nil { + return common.Address{}, fmt.Errorf("tee-rollup: failed to get anchorStateRegistry from impl %v: %w", implAddr, err) + } + addr := result.GetAddress(0) + f.teeCache.setASRAddr(addr) + return addr, nil +} + +// For xlayer: isValidParentGame checks AnchorStateRegistry conditions for a candidate parent game. +// Mirrors TeeDisputeGame.initialize() validation: respected && !blacklisted && !retired. +func (f *DisputeGameFactory) isValidParentGame(ctx context.Context, asrAddr common.Address, proxyAddr common.Address) (bool, error) { + asrRespected := batching.NewBoundContract(&asrIsGameRespectedABI, asrAddr) + asrBlacklisted := batching.NewBoundContract(&asrIsGameBlacklistedABI, asrAddr) + asrRetired := batching.NewBoundContract(&asrIsGameRetiredABI, asrAddr) + + cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, asrRespected.Call("isGameRespected", proxyAddr)) + if err != nil { + return false, fmt.Errorf("tee-rollup: failed to call isGameRespected for %v: %w", proxyAddr, err) + } + respected := result.GetBool(0) + if !respected { + return false, nil + } + + cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, asrBlacklisted.Call("isGameBlacklisted", proxyAddr)) + if err != nil { + return false, fmt.Errorf("tee-rollup: failed to call isGameBlacklisted for %v: %w", proxyAddr, err) + } + if result.GetBool(0) { + return false, nil + } + + cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, asrRetired.Call("isGameRetired", proxyAddr)) + if err != nil { + return false, fmt.Errorf("tee-rollup: failed to call isGameRetired for %v: %w", proxyAddr, err) + } + if result.GetBool(0) { + return false, nil + } + + return true, nil +} + // For xlayer: FindLastGameIndex scans the DGF in reverse to find the most recent game with the // given gameType that is a valid parent candidate: // - DEFENDER_WINS: accepted immediately (contract allows it) @@ -168,6 +294,23 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin continue } + // For xlayer: validate against AnchorStateRegistry using impl's ASR address + asrAddr, err := f.asrAddrFromImpl(ctx, gameType) + if err != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to get ASR addr at index %d: %w", idx, err) + } + valid, err := f.isValidParentGame(ctx, asrAddr, entry.Address) + if err != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to validate parent game at index %d: %w", idx, err) + } + if !valid { + // For xlayer: game is retired/blacklisted/not-respected — skip + if idx == 0 { + break + } + continue + } + // For xlayer: DEFENDER_WINS — accept immediately if status == GameStatusDefenderWins { return idx, true, nil From d6ab202d787637f153a07f0915c2590cd1f7eb87 Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Tue, 24 Mar 2026 10:00:40 +0800 Subject: [PATCH 13/25] update list game script --- op-proposer/mock/list_games.sh | 228 +++++++++++++++++++++++++++++---- 1 file changed, 203 insertions(+), 25 deletions(-) diff --git a/op-proposer/mock/list_games.sh b/op-proposer/mock/list_games.sh index b08851c2f2da6..9eff65318c95d 100755 --- a/op-proposer/mock/list_games.sh +++ b/op-proposer/mock/list_games.sh @@ -28,9 +28,96 @@ done [[ -z "$RPC" ]] && { echo "ERROR: --rpc is required" >&2; usage; } [[ -z "$FACTORY" ]] && { echo "ERROR: --factory is required" >&2; usage; } -echo "Factory: $FACTORY" +# ── Helpers ────────────────────────────────────────────────────────────────── + +# Map ProposalStatus enum (uint8) to name +# 0=Unchallenged, 1=Challenged, 2=UnchallengedAndValidProofProvided, +# 3=ChallengedAndValidProofProvided, 4=Resolved +proposal_status_name() { + case "$1" in + 0) echo "Unchallenged" ;; + 1) echo "Challenged" ;; + 2) echo "UnchallengedAndValidProofProvided" ;; + 3) echo "ChallengedAndValidProofProvided" ;; + 4) echo "Resolved" ;; + *) echo "Unknown($1)" ;; + esac +} + +# Map GameStatus enum (uint8) to name +# 0=IN_PROGRESS, 1=CHALLENGER_WINS, 2=DEFENDER_WINS +game_status_name() { + case "$1" in + 0) echo "IN_PROGRESS" ;; + 1) echo "CHALLENGER_WINS" ;; + 2) echo "DEFENDER_WINS" ;; + *) echo "Unknown($1)" ;; + esac +} + +# Map BondDistributionMode enum (uint8) to name +# 0=UNDECIDED, 1=NORMAL, 2=REFUND +bond_mode_name() { + case "$1" in + 0) echo "UNDECIDED" ;; + 1) echo "NORMAL" ;; + 2) echo "REFUND" ;; + *) echo "Unknown($1)" ;; + esac +} + +# Format a unix timestamp as human-readable; "0" or "N/A" → "N/A" +fmt_ts() { + local ts="$1" + if [[ "$ts" == "0" || "$ts" == "N/A" ]]; then echo "N/A"; return; fi + date -r "$ts" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ + || date -d "@$ts" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ + || echo "$ts" +} + +# Format a duration in seconds as "Xh Ym Zs" +fmt_duration() { + local secs="$1" + if [[ "$secs" == "N/A" ]]; then echo "N/A"; return; fi + local h=$(( secs / 3600 )) + local m=$(( (secs % 3600) / 60 )) + local s=$(( secs % 60 )) + printf "%dh %dm %ds" "$h" "$m" "$s" +} + +# Print a labeled table row: " Key: Value" +row() { printf " %-32s %s\n" "$1" "$2"; } + +# Section header / footer +section() { echo " ┌─── $1"; } +section_end() { echo " └$(printf '─%.0s' {1..100})┘"; } + +# Tree-style phase node and indented child row for section [3] +phase() { printf " ├─ %s\n" "$1"; } +trow() { printf " │ %-26s %s\n" "$1" "$2"; } + +# Format a unix timestamp field: extract numeric part from cast output, +# then append human-readable time. Returns "N/A" when value is 0 or N/A. +fmt_ts_field() { + local raw="$1" + local num + num=$(echo "$raw" | awk '{print $1}') # strip cast's "[1.774e9]" suffix + if [[ "$num" == "0" || "$num" == "N/A" || -z "$num" ]]; then + echo "N/A" + return + fi + local human + human=$(date -r "$num" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ + || date -d "@$num" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ + || echo "?") + echo "${num} (${human})" +} + +# ── Main ───────────────────────────────────────────────────────────────────── + +echo "Factory : $FACTORY" TOTAL=$(cast call "$FACTORY" "gameCount()(uint256)" --rpc-url "$RPC") -echo "Total games: $TOTAL" +echo "Total : $TOTAL games" echo "" if [[ "$TOTAL" -eq 0 ]]; then @@ -43,38 +130,129 @@ if [[ "$COUNT" -gt "$TOTAL" ]]; then fi for (( i = TOTAL - 1; i >= TOTAL - COUNT; i-- )); do + + # ── Factory record ────────────────────────────────────────────────────── INFO=$(cast call "$FACTORY" "gameAtIndex(uint256)(uint8,uint64,address)" "$i" --rpc-url "$RPC") GAME_TYPE=$(echo "$INFO" | awk 'NR==1') - TIMESTAMP=$(echo "$INFO" | awk 'NR==2') ADDR=$(echo "$INFO" | awk 'NR==3') + echo "╔══════════════════════════════════════════════════════════════╗" + printf "║ GAME #%-6s │ GameType: %-33s║\n" "$i" "$GAME_TYPE" + echo "╚══════════════════════════════════════════════════════════════╝" + + # ── Fetch all fields ──────────────────────────────────────────────────── + + # Immutables + MAX_CHAL_DUR=$( cast call "$ADDR" "maxChallengeDuration()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + MAX_PROVE_DUR=$(cast call "$ADDR" "maxProveDuration()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + CHAL_BOND=$( cast call "$ADDR" "challengerBond()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + + # Identity + GAME_CREATOR=$( cast call "$ADDR" "gameCreator()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + PROPOSER_ADDR=$( cast call "$ADDR" "proposer()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + WAS_RESPECTED=$( cast call "$ADDR" "wasRespectedGameTypeWhenCreated()(bool)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + + # Proposal range + L2_BLOCK=$( cast call "$ADDR" "l2BlockNumber()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") PARENT_IDX=$( cast call "$ADDR" "parentIndex()(uint32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") STARTING_BN=$( cast call "$ADDR" "startingBlockNumber()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - L2_BLOCK=$( cast call "$ADDR" "l2BlockNumber()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") STARTING_HASH=$( cast call "$ADDR" "startingRootHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") ROOT_CLAIM=$( cast call "$ADDR" "rootClaim()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") BLOCK_HASH=$( cast call "$ADDR" "blockHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") STATE_HASH=$( cast call "$ADDR" "stateHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - PROPOSER_ADDR=$( cast call "$ADDR" "proposer()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - GAME_CREATOR=$( cast call "$ADDR" "gameCreator()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - - RAW_TS=$(echo "$TIMESTAMP" | awk '{print $1}') - TS_HUMAN=$(date -r "$RAW_TS" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -d "@$RAW_TS" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$TIMESTAMP") - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Index: $i" - echo "GameType: $GAME_TYPE" - echo "Address: $ADDR" - echo "CreatedAt: $TS_HUMAN" - echo "GameCreator: $GAME_CREATOR" - echo "Proposer: $PROPOSER_ADDR" - echo "ParentIndex: $PARENT_IDX" - echo "StartingBlockNumber: $STARTING_BN" - echo "L2BlockNumber: $L2_BLOCK" - echo "StartingRootHash: $STARTING_HASH" - echo "RootClaim: $ROOT_CLAIM" - echo "BlockHash: $BLOCK_HASH" - echo "StateHash: $STATE_HASH" + + # ClaimData struct: (uint32 parentIndex, address counteredBy, address prover, + # bytes32 claim, uint8 status, uint64 deadline) + CLAIM_RAW=$(cast call "$ADDR" "claimData()(uint32,address,address,bytes32,uint8,uint64)" \ + --rpc-url "$RPC" 2>/dev/null || echo "N/A") + if [[ "$CLAIM_RAW" != "N/A" ]]; then + CD_COUNTERED=$( echo "$CLAIM_RAW" | awk 'NR==2') + CD_PROVER=$( echo "$CLAIM_RAW" | awk 'NR==3') + CD_STATUS_RAW=$( echo "$CLAIM_RAW" | awk 'NR==5') + CD_DEADLINE=$( echo "$CLAIM_RAW" | awk 'NR==6') + else + CD_COUNTERED="N/A"; CD_PROVER="N/A"; CD_STATUS_RAW="N/A"; CD_DEADLINE="N/A" + fi + + # Game-level state + GAME_STATUS_RAW=$( cast call "$ADDR" "status()(uint8)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + CREATED_AT_RAW=$( cast call "$ADDR" "createdAt()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + RESOLVED_AT_RAW=$( cast call "$ADDR" "resolvedAt()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + BOND_MODE_RAW=$( cast call "$ADDR" "bondDistributionMode()(uint8)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + GAME_OVER=$( cast call "$ADDR" "gameOver()(bool)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") + + # ── Derived values ────────────────────────────────────────────────────── + + CD_STATUS=$(proposal_status_name "$CD_STATUS_RAW") + GAME_STATUS=$(game_status_name "$GAME_STATUS_RAW") + BOND_MODE=$(bond_mode_name "$BOND_MODE_RAW") + + CREATED_AT_FMT=$(fmt_ts_field "$CREATED_AT_RAW") + RESOLVED_AT_FMT=$(fmt_ts_field "$RESOLVED_AT_RAW") + DEADLINE_FMT=$(fmt_ts_field "$(echo "$CD_DEADLINE" | awk '{print $1}')") + + MAX_CHAL_FMT="N/A" + MAX_PROVE_FMT="N/A" + if [[ "$MAX_CHAL_DUR" != "N/A" ]]; then MAX_CHAL_FMT="${MAX_CHAL_DUR}s ($(fmt_duration "$MAX_CHAL_DUR"))"; fi + if [[ "$MAX_PROVE_DUR" != "N/A" ]]; then MAX_PROVE_FMT="${MAX_PROVE_DUR}s ($(fmt_duration "$MAX_PROVE_DUR"))"; fi + + CHAL_BOND_FMT="N/A" + if [[ "$CHAL_BOND" != "N/A" ]]; then + CHAL_BOND_ETH=$(cast to-unit "$CHAL_BOND" ether 2>/dev/null || echo "?") + CHAL_BOND_FMT="${CHAL_BOND_ETH} ETH (${CHAL_BOND} wei)" + fi + + # ── Section 1: Identity & Config ──────────────────────────────────────── + echo "" + section "[1] Identity & Config ──────────────────────────────────────────────────────────────────────────┐" + phase "Identity" + trow "Address:" "$ADDR" + trow "GameType:" "$GAME_TYPE" + trow "GameCreator:" "$GAME_CREATOR" + trow "Proposer:" "$PROPOSER_ADDR" + trow "WasRespectedGameType:" "$WAS_RESPECTED" + phase "Config" + trow "MaxChallengeDuration:" "$MAX_CHAL_FMT" + trow "MaxProveDuration:" "$MAX_PROVE_FMT" + trow "ChallengerBond:" "$CHAL_BOND_FMT" + section_end + + # ── Section 2: Proposal (L2 block range & hashes) ─────────────────────── + echo "" + section "[2] Proposal ──────────────────────────────────────────────────────────────────────────────────┐" + phase "Starting State" + trow "ParentIndex:" "$PARENT_IDX" + trow "StartingBlockNumber:" "$STARTING_BN" + trow "StartingRootHash:" "$STARTING_HASH" + phase "Target State" + trow "L2BlockNumber:" "$L2_BLOCK" + trow "BlockHash:" "$BLOCK_HASH" + trow "StateHash:" "$STATE_HASH" + trow "RootClaim:" "$ROOT_CLAIM" + section_end + + # ── Section 3: Lifecycle State ────────────────────────────────────────── + echo "" + section "[3] Lifecycle State ────────────────────────────────────────────────────────────────────────────┐" + phase "Initialize" + trow "CreatedAt:" "$CREATED_AT_FMT" + phase "Challenge Window" + trow "CounteredBy:" "$CD_COUNTERED" + trow "ClaimData.status:" "$CD_STATUS" + trow "ClaimData.deadline:" "$DEADLINE_FMT" + phase "Prove" + trow "Prover:" "$CD_PROVER" + trow "ClaimData.status:" "$CD_STATUS" + trow "GameOver:" "$GAME_OVER" + phase "Resolve" + trow "GameStatus:" "$GAME_STATUS" + trow "ResolvedAt:" "$RESOLVED_AT_FMT" + phase "CloseGame/ClaimCredit" + trow "BondDistributionMode:" "$BOND_MODE" + section_end + + echo "" done -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "════════════════════════════════════════════════════════════════════════════════════════════════════════" +echo "Done." From 3dbb654d440a09b63668698df607a23c1136fc8e Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Tue, 24 Mar 2026 12:16:11 +0800 Subject: [PATCH 14/25] fix(op-challenger): address review findings for TEE game actor - Replace common.Hex2Bytes with hex.DecodeString in decodeProofBytes to properly return errors on malformed hex - Add single-goroutine safety comment to Act() - Add metrics tracking to GetBondDistributionMode - Add GetProveParams test coverage (normal path + anchor game) - Validate TeeProvePollInterval and TeeProveTimeout > 0 in config Co-Authored-By: Claude Opus 4.6 --- op-challenger/config/config_xlayer.go | 10 ++- .../game/fault/contracts/teedisputegame.go | 1 + .../fault/contracts/teedisputegame_test.go | 75 +++++++++++++++++++ op-challenger/game/tee/actor.go | 4 +- 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/op-challenger/config/config_xlayer.go b/op-challenger/config/config_xlayer.go index 86a999f7b7887..490a9d0ac8496 100644 --- a/op-challenger/config/config_xlayer.go +++ b/op-challenger/config/config_xlayer.go @@ -8,7 +8,9 @@ import ( ) var ( - ErrMissingTeeProverRpc = errors.New("missing TEE prover rpc url") + ErrMissingTeeProverRpc = errors.New("missing TEE prover rpc url") + ErrInvalidTeeProvePollInterval = errors.New("TEE prove poll interval must be greater than 0") + ErrInvalidTeeProveTimeout = errors.New("TEE prove timeout must be greater than 0") ) const ( @@ -28,6 +30,12 @@ func checkTeeConfig(c Config) error { if c.TeeProverRpc == "" { return ErrMissingTeeProverRpc } + if c.TeeProvePollInterval <= 0 { + return ErrInvalidTeeProvePollInterval + } + if c.TeeProveTimeout <= 0 { + return ErrInvalidTeeProveTimeout + } } return nil } diff --git a/op-challenger/game/fault/contracts/teedisputegame.go b/op-challenger/game/fault/contracts/teedisputegame.go index d09ef7e212623..b0682daac7363 100644 --- a/op-challenger/game/fault/contracts/teedisputegame.go +++ b/op-challenger/game/fault/contracts/teedisputegame.go @@ -311,6 +311,7 @@ func (g *TeeDisputeGameContractLatest) ClaimCreditTx(ctx context.Context, recipi } func (g *TeeDisputeGameContractLatest) GetBondDistributionMode(ctx context.Context, block rpcblock.Block) (types.BondDistributionMode, error) { + defer g.metrics.StartContractRequest("GetBondDistributionMode")() result, err := g.multiCaller.SingleCall(ctx, block, g.contract.Call(methodBondDistributionMode)) if err != nil { return 0, fmt.Errorf("failed to fetch bond mode: %w", err) diff --git a/op-challenger/game/fault/contracts/teedisputegame_test.go b/op-challenger/game/fault/contracts/teedisputegame_test.go index e0af44c6d4cea..90caaf8de705a 100644 --- a/op-challenger/game/fault/contracts/teedisputegame_test.go +++ b/op-challenger/game/fault/contracts/teedisputegame_test.go @@ -3,6 +3,7 @@ package contracts import ( "context" "errors" + "math" "math/big" "testing" "time" @@ -230,6 +231,80 @@ func TestTeeCloseGameTx(t *testing.T) { }) } +func TestTeeGetProveParams(t *testing.T) { + parentGameAddr := common.Address{0xbb, 0xcc, 0x01} + factoryAddr := common.Address{0xff, 0xaa, 0x01} + + t.Run("WithParentGame", func(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + teeAbi := snapshots.LoadTeeDisputeGameABI() + + // Set up factory contract on the same stub RPC + factoryAbi := snapshots.LoadDisputeGameFactoryABI() + stubRpc.AddContract(factoryAddr, factoryAbi) + caller := batching.NewMultiCaller(stubRpc, batching.DefaultBatchSize) + factory := newDisputeGameFactoryContract(contractMetrics.NoopContractMetrics, factoryAddr, caller, factoryAbi, getGameArgsNoOp) + + // Current game responses + expectedEndBlockNum := uint64(200) + expectedEndBlockHash := common.Hash{0x11} + expectedEndStateHash := common.Hash{0x22} + expectedStartBlockNum := uint64(100) + parentIndex := uint32(5) + stubRpc.SetResponse(teeGameAddr, methodL2SequenceNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(expectedEndBlockNum)}) + stubRpc.SetResponse(teeGameAddr, methodBlockHash, rpcblock.Latest, nil, []interface{}{expectedEndBlockHash}) + stubRpc.SetResponse(teeGameAddr, methodStateHash, rpcblock.Latest, nil, []interface{}{expectedEndStateHash}) + stubRpc.SetResponse(teeGameAddr, methodStartingBlockNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(expectedStartBlockNum)}) + stubRpc.SetResponse(teeGameAddr, methodClaimData, rpcblock.Latest, nil, []interface{}{ + parentIndex, common.Address{}, common.Address{}, common.Hash{}, uint8(0), uint64(0), + }) + + // Factory returns parent game address + stubRpc.SetResponse(factoryAddr, methodGameAtIndex, rpcblock.Latest, + []interface{}{new(big.Int).SetUint64(uint64(parentIndex))}, + []interface{}{uint32(gameTypes.TeeGameType), uint64(0), parentGameAddr}) + + // Parent game responses + expectedStartBlockHash := common.Hash{0x33} + expectedStartStateHash := common.Hash{0x44} + stubRpc.AddContract(parentGameAddr, teeAbi) + stubRpc.SetResponse(parentGameAddr, methodBlockHash, rpcblock.Latest, nil, []interface{}{expectedStartBlockHash}) + stubRpc.SetResponse(parentGameAddr, methodStateHash, rpcblock.Latest, nil, []interface{}{expectedStartStateHash}) + + params, err := game.GetProveParams(context.Background(), factory) + require.NoError(t, err) + require.Equal(t, TeeProveParams{ + StartBlockHash: expectedStartBlockHash, + StartStateHash: expectedStartStateHash, + EndBlockHash: expectedEndBlockHash, + EndStateHash: expectedEndStateHash, + StartBlockNum: expectedStartBlockNum, + EndBlockNum: expectedEndBlockNum, + }, params) + }) + + t.Run("AnchorGame", func(t *testing.T) { + stubRpc, game := setupTeeDisputeGameTest(t) + + factoryAbi := snapshots.LoadDisputeGameFactoryABI() + stubRpc.AddContract(factoryAddr, factoryAbi) + caller := batching.NewMultiCaller(stubRpc, batching.DefaultBatchSize) + factory := newDisputeGameFactoryContract(contractMetrics.NoopContractMetrics, factoryAddr, caller, factoryAbi, getGameArgsNoOp) + + // parentIndex = MaxUint32 means anchor game + stubRpc.SetResponse(teeGameAddr, methodL2SequenceNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(200)}) + stubRpc.SetResponse(teeGameAddr, methodBlockHash, rpcblock.Latest, nil, []interface{}{common.Hash{0x11}}) + stubRpc.SetResponse(teeGameAddr, methodStateHash, rpcblock.Latest, nil, []interface{}{common.Hash{0x22}}) + stubRpc.SetResponse(teeGameAddr, methodStartingBlockNumber, rpcblock.Latest, nil, []interface{}{new(big.Int).SetUint64(100)}) + stubRpc.SetResponse(teeGameAddr, methodClaimData, rpcblock.Latest, nil, []interface{}{ + uint32(math.MaxUint32), common.Address{}, common.Address{}, common.Hash{}, uint8(0), uint64(0), + }) + + _, err := game.GetProveParams(context.Background(), factory) + require.ErrorIs(t, err, ErrAnchorGameUnprovable) + }) +} + func setupTeeDisputeGameTest(t *testing.T) (*batchingTest.AbiBasedRpc, TeeDisputeGameContract) { teeAbi := snapshots.LoadTeeDisputeGameABI() stubRpc := batchingTest.NewAbiBasedRpc(t, teeGameAddr, teeAbi) diff --git a/op-challenger/game/tee/actor.go b/op-challenger/game/tee/actor.go index dda3019f2367b..f03c3fb3ff3d6 100644 --- a/op-challenger/game/tee/actor.go +++ b/op-challenger/game/tee/actor.go @@ -2,6 +2,7 @@ package tee import ( "context" + "encoding/hex" "errors" "fmt" "math" @@ -98,6 +99,7 @@ func ActorCreator( } } +// NOTE: must be called from a single goroutine. func (a *Actor) Act(ctx context.Context) error { metadata, err := a.contract.GetChallengerMetadata(ctx, rpcblock.Latest) if err != nil { @@ -266,5 +268,5 @@ func decodeProofBytes(hexStr string) ([]byte, error) { if len(hexStr) >= 2 && hexStr[:2] == "0x" { hexStr = hexStr[2:] } - return common.Hex2Bytes(hexStr), nil + return hex.DecodeString(hexStr) } From a8b2817d84e9fb92e54c6e606854ae2968cd7054 Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Tue, 24 Mar 2026 15:18:15 +0800 Subject: [PATCH 15/25] fix according with review --- op-proposer/contracts/disputegamefactory.go | 2 +- .../contracts/teedisputegame_xlayer.go | 42 ++++++++++-------- op-proposer/proposer/config.go | 6 +-- op-proposer/proposer/service.go | 28 +++--------- op-proposer/proposer/service_tee_xlayer.go | 43 +++++++++++++++++++ .../source/source_tee_rollup_xlayer.go | 5 ++- 6 files changed, 79 insertions(+), 47 deletions(-) create mode 100644 op-proposer/proposer/service_tee_xlayer.go diff --git a/op-proposer/contracts/disputegamefactory.go b/op-proposer/contracts/disputegamefactory.go index 4e81cede25464..4fa65a30ea4fa 100644 --- a/op-proposer/contracts/disputegamefactory.go +++ b/op-proposer/contracts/disputegamefactory.go @@ -136,7 +136,7 @@ func (f *DisputeGameFactory) gameAtIndex(ctx context.Context, idx uint64) (gameM var claimant common.Address var claim common.Hash - if gameType == teeGameType { + if gameType == TEEGameType { // For xlayer: TEE game type uses different claimData() ABI // For xlayer: TEE game type (1960) uses new contract ABI — claimData() takes no args, // returns (parentIndex, counteredBy, prover, claim, status, deadline). // prover is at index 2, claim (bytes32) is at index 3. diff --git a/op-proposer/contracts/teedisputegame_xlayer.go b/op-proposer/contracts/teedisputegame_xlayer.go index b6ac03ccd37dc..478fde6d53cbd 100644 --- a/op-proposer/contracts/teedisputegame_xlayer.go +++ b/op-proposer/contracts/teedisputegame_xlayer.go @@ -7,15 +7,20 @@ import ( "strings" "sync" + lru "github.com/hashicorp/golang-lru/v2" // For xlayer: bounded LRU cache for game index entries + "github.com/ethereum-optimism/optimism/op-service/sources/batching" "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" ) -const teeGameType uint32 = 1960 // For xlayer: TEE game type (TeeRollup) +// For xlayer: TEEGameType is the dispute game type ID for TeeRollup TEE attestations. +// Defined here (contracts package) so both contracts and proposer packages can reference it +// without a circular import (proposer already imports contracts). +const TEEGameType uint32 = 1960 -const teeParentScanLimit = 1000 // For xlayer: max DGF entries to scan for parent game +const TeeParentScanLimit = 1000 // For xlayer: max DGF entries to scan for parent game // For xlayer: GameStatus enum values matching TeeDisputeGame (Types.sol) const ( @@ -100,32 +105,33 @@ type cachedGameEntry struct { ProposerFetched bool } -// For xlayer: gameIndexCache is an in-memory, RWMutex-protected cache of DGF index -> immutable game metadata. -// GameType and Address are cached on first fetch. Proposer (also immutable) is cached lazily on first need. -// Game status is NOT cached — it changes when a game is resolved. -// ASR address is fetched once from the game impl contract and cached for the lifetime of the process. +// For xlayer: gameIndexCache is an in-memory cache of DGF index -> immutable game metadata. +// entries uses a lazily-initialized LRU cache (thread-safe) to bound memory usage. +// asrAddr is cached separately with a mutex. type gameIndexCache struct { - mu sync.RWMutex - entries map[uint64]cachedGameEntry + mu sync.RWMutex // For xlayer: protects asrAddr/asrAddrFetched only; entries uses lru's own lock + once sync.Once // For xlayer: guards one-time LRU initialization + entries *lru.Cache[uint64, cachedGameEntry] // For xlayer: bounded LRU, thread-safe, lazily initialized // For xlayer: ASR address fetched once from the game impl contract and cached asrAddr common.Address asrAddrFetched bool } +// For xlayer: lruEntries returns the lazily-initialized LRU cache. +// lru.New never fails for a positive size, so the error is safely ignored. +func (c *gameIndexCache) lruEntries() *lru.Cache[uint64, cachedGameEntry] { + c.once.Do(func() { + c.entries, _ = lru.New[uint64, cachedGameEntry](4096) // For xlayer: 4096-entry bounded LRU + }) + return c.entries +} + func (c *gameIndexCache) get(idx uint64) (cachedGameEntry, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - e, ok := c.entries[idx] - return e, ok + return c.lruEntries().Get(idx) // For xlayer: lru.Cache is thread-safe; no mutex needed } func (c *gameIndexCache) set(idx uint64, e cachedGameEntry) { - c.mu.Lock() - defer c.mu.Unlock() - if c.entries == nil { - c.entries = make(map[uint64]cachedGameEntry) - } - c.entries[idx] = e + c.lruEntries().Add(idx, e) // For xlayer: lru.Cache is thread-safe; no mutex needed } // For xlayer: getASRAddr returns the cached ASR address if available. diff --git a/op-proposer/proposer/config.go b/op-proposer/proposer/config.go index 14e8f3555936c..ed90c86173137 100644 --- a/op-proposer/proposer/config.go +++ b/op-proposer/proposer/config.go @@ -7,6 +7,7 @@ import ( "github.com/urfave/cli/v2" + "github.com/ethereum-optimism/optimism/op-proposer/contracts" "github.com/ethereum-optimism/optimism/op-proposer/flags" oplog "github.com/ethereum-optimism/optimism/op-service/log" opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" @@ -15,9 +16,6 @@ import ( "github.com/ethereum-optimism/optimism/op-service/txmgr" ) -// For xlayer: TEEGameType is the dispute game type ID for TeeRollup TEE attestations. -const TEEGameType uint32 = 1960 - var ( ErrMissingRollupRpc = errors.New("missing rollup rpc") ErrMissingSupervisorRpc = errors.New("missing supervisor rpc or supernode rpc") @@ -142,7 +140,7 @@ func (c *CLIConfig) Check() error { return ErrMissingSupervisorRpc } // For xlayer: TeeRollup game type requires TeeRollupRpc - if c.DisputeGameType == TEEGameType && c.TeeRollupRpc == "" { + if c.DisputeGameType == contracts.TEEGameType && c.TeeRollupRpc == "" { return ErrMissingTeeRollupRpc } // For unknown game types, allow any source, but require at least one. diff --git a/op-proposer/proposer/service.go b/op-proposer/proposer/service.go index 58813443f04d2..232a9c29f7d51 100644 --- a/op-proposer/proposer/service.go +++ b/op-proposer/proposer/service.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "math" "strings" "sync/atomic" "time" @@ -132,7 +131,7 @@ func (ps *ProposerService) initFromCLIConfig(ctx context.Context, version string func (ps *ProposerService) initRPCClients(ctx context.Context, cfg *CLIConfig) error { // For xlayer: TeeRollup has no L1 derivation; CurrentL1 is always zero, // which would cause waitNodeSync to block forever. - if cfg.DisputeGameType == TEEGameType && cfg.WaitNodeSync { // For xlayer + if cfg.DisputeGameType == contracts.TEEGameType && cfg.WaitNodeSync { // For xlayer return fmt.Errorf("--wait-node-sync is not supported with TeeRollup game type (1960)") } @@ -280,27 +279,10 @@ func (ps *ProposerService) initDriver() error { } ps.driver = driver - // For xlayer: wire parent index resolver into TeeRollupProposalSource after DGF caller is available - if teeSource, ok := ps.ProposalSource.(*source.TeeRollupProposalSource); ok { - if dgfCaller, ok := driver.dgfContract.(*contracts.DisputeGameFactory); ok { - proposer := driver.Txmgr.From() - gameType := uint32(ps.ProposerConfig.DisputeGameType) - teeSource.SetParentIdxFn(func(ctx context.Context) (uint32, bool, error) { - idx, found, err := dgfCaller.FindLastGameIndex(ctx, gameType, proposer, 1000) // For xlayer - if err != nil { - return 0, false, err - } - if !found { - return 0, false, nil - } - if idx > math.MaxUint32 { - return 0, false, fmt.Errorf("tee-rollup: game index %d exceeds uint32 range", idx) - } - return uint32(idx), true, nil - }) - } else { - // For xlayer: dgfContract is not *contracts.DisputeGameFactory — parentIdxFn not wired, will use MaxUint32 sentinel - ps.Log.Warn("tee-rollup: dgfContract is not *DisputeGameFactory, parentIdxFn not wired") + // For xlayer: wire TEE-specific parent index resolver only for TEE game type + if ps.ProposerConfig.DisputeGameType == contracts.TEEGameType { + if err := initTeeSource(ps, driver); err != nil { + return err } } diff --git a/op-proposer/proposer/service_tee_xlayer.go b/op-proposer/proposer/service_tee_xlayer.go new file mode 100644 index 0000000000000..1532e532d4ddd --- /dev/null +++ b/op-proposer/proposer/service_tee_xlayer.go @@ -0,0 +1,43 @@ +// For xlayer: TEE-specific service initialization — wires parent index resolver into TeeRollupProposalSource. +package proposer + +import ( + "context" + "fmt" + "math" + + "github.com/ethereum-optimism/optimism/op-proposer/contracts" + "github.com/ethereum-optimism/optimism/op-proposer/proposer/source" +) + +// For xlayer: initTeeSource wires the parent game index resolver into TeeRollupProposalSource. +// Must be called after NewL2OutputSubmitter returns so that driver.dgfContract is available. +// Returns an error if dgfContract is not *contracts.DisputeGameFactory, to prevent silent +// MaxUint32 sentinel usage in production. +func initTeeSource(ps *ProposerService, driver *L2OutputSubmitter) error { + teeSource, ok := ps.ProposalSource.(*source.TeeRollupProposalSource) + if !ok { + return nil // For xlayer: not a TEE game type — nothing to wire + } + dgfCaller, ok := driver.dgfContract.(*contracts.DisputeGameFactory) + if !ok { + // For xlayer: dgfContract is not *contracts.DisputeGameFactory — fail fast to prevent silent MaxUint32 sentinel usage in production + return fmt.Errorf("tee-rollup: dgfContract is not *contracts.DisputeGameFactory, cannot wire parentIdxFn") + } + proposer := driver.Txmgr.From() + gameType := uint32(ps.ProposerConfig.DisputeGameType) + teeSource.SetParentIdxFn(func(ctx context.Context) (uint32, bool, error) { + idx, found, err := dgfCaller.FindLastGameIndex(ctx, gameType, proposer, contracts.TeeParentScanLimit) // For xlayer + if err != nil { + return 0, false, err + } + if !found { + return 0, false, nil + } + if idx > math.MaxUint32 { + return 0, false, fmt.Errorf("tee-rollup: game index %d exceeds uint32 range", idx) + } + return uint32(idx), true, nil + }) + return nil +} diff --git a/op-proposer/proposer/source/source_tee_rollup_xlayer.go b/op-proposer/proposer/source/source_tee_rollup_xlayer.go index 411b2a97e92ef..9a82079b2600e 100644 --- a/op-proposer/proposer/source/source_tee_rollup_xlayer.go +++ b/op-proposer/proposer/source/source_tee_rollup_xlayer.go @@ -82,8 +82,11 @@ func (c *TeeRollupHTTPClient) ConfirmedBlockInfo(ctx context.Context) (TeeRollup return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: HTTP request failed: %w", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { // For xlayer + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: HTTP request failed with status %d", resp.StatusCode) // For xlayer + } // For xlayer - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // For xlayer if err != nil { return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: failed to read response body: %w", err) } From 4889f7ede1ed9e6b730c1f1b38df3de50f1e08f7 Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Tue, 24 Mar 2026 15:30:37 +0800 Subject: [PATCH 16/25] update comments --- op-proposer/contracts/disputegamefactory.go | 7 +- .../contracts/teedisputegame_xlayer.go | 80 +++++++++---------- op-proposer/flags/flags.go | 6 +- op-proposer/proposer/config.go | 8 +- op-proposer/proposer/service.go | 8 +- op-proposer/proposer/service_tee_xlayer.go | 8 +- .../source/source_tee_rollup_xlayer.go | 46 +++++------ 7 files changed, 79 insertions(+), 84 deletions(-) diff --git a/op-proposer/contracts/disputegamefactory.go b/op-proposer/contracts/disputegamefactory.go index 4fa65a30ea4fa..f39639974467c 100644 --- a/op-proposer/contracts/disputegamefactory.go +++ b/op-proposer/contracts/disputegamefactory.go @@ -38,7 +38,7 @@ type DisputeGameFactory struct { contract *batching.BoundContract gameABI *abi.ABI networkTimeout time.Duration - teeCache gameIndexCache // For xlayer: in-memory cache of immutable game index metadata + teeCache gameIndexCache // For xlayer } func NewDisputeGameFactory(addr common.Address, caller *batching.MultiCaller, networkTimeout time.Duration) *DisputeGameFactory { @@ -136,10 +136,7 @@ func (f *DisputeGameFactory) gameAtIndex(ctx context.Context, idx uint64) (gameM var claimant common.Address var claim common.Hash - if gameType == TEEGameType { // For xlayer: TEE game type uses different claimData() ABI - // For xlayer: TEE game type (1960) uses new contract ABI — claimData() takes no args, - // returns (parentIndex, counteredBy, prover, claim, status, deadline). - // prover is at index 2, claim (bytes32) is at index 3. + if gameType == TEEGameType { // For xlayer: uses different claimData() ABI with no args newGameContract := batching.NewBoundContract(&newGameClaimDataABI, address) cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) defer cancel() diff --git a/op-proposer/contracts/teedisputegame_xlayer.go b/op-proposer/contracts/teedisputegame_xlayer.go index 478fde6d53cbd..055457253607e 100644 --- a/op-proposer/contracts/teedisputegame_xlayer.go +++ b/op-proposer/contracts/teedisputegame_xlayer.go @@ -7,7 +7,7 @@ import ( "strings" "sync" - lru "github.com/hashicorp/golang-lru/v2" // For xlayer: bounded LRU cache for game index entries + lru "github.com/hashicorp/golang-lru/v2" // bounded LRU cache for game index entries "github.com/ethereum-optimism/optimism/op-service/sources/batching" "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" @@ -15,46 +15,46 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// For xlayer: TEEGameType is the dispute game type ID for TeeRollup TEE attestations. +// TEEGameType is the dispute game type ID for TeeRollup TEE attestations. // Defined here (contracts package) so both contracts and proposer packages can reference it // without a circular import (proposer already imports contracts). const TEEGameType uint32 = 1960 -const TeeParentScanLimit = 1000 // For xlayer: max DGF entries to scan for parent game +const TeeParentScanLimit = 1000 // max DGF entries to scan for parent game -// For xlayer: GameStatus enum values matching TeeDisputeGame (Types.sol) +// GameStatus enum values matching TeeDisputeGame (Types.sol) const ( GameStatusInProgress uint8 = 0 GameStatusChallengerWins uint8 = 1 GameStatusDefenderWins uint8 = 2 ) -// For xlayer: ABI for new game contract's no-arg claimData() struct getter +// ABI for new game contract's no-arg claimData() struct getter const newGameClaimDataABIJSON = `[{"name":"claimData","type":"function","inputs":[],"outputs":[{"name":"parentIndex","type":"uint32"},{"name":"counteredBy","type":"address"},{"name":"prover","type":"address"},{"name":"claim","type":"bytes32"},{"name":"status","type":"uint8"},{"name":"deadline","type":"uint64"}],"stateMutability":"view"}]` -// For xlayer: ABI for TeeDisputeGame status() and proposer() getters +// ABI for TeeDisputeGame status() and proposer() getters const teeGameStatusABIJSON = `[{"name":"status","type":"function","inputs":[],"outputs":[{"name":"","type":"uint8"}],"stateMutability":"view"}]` const teeGameProposerABIJSON = `[{"name":"proposer","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` -// For xlayer: ABI for DGF.gameImpls(uint32) — returns the implementation contract address for a game type +// ABI for DGF.gameImpls(uint32) — returns the implementation contract address for a game type const dgfGameImplsABIJSON = `[{"name":"gameImpls","type":"function","inputs":[{"name":"_gameType","type":"uint32"}],"outputs":[{"name":"impl_","type":"address"}],"stateMutability":"view"}]` -// For xlayer: ABI for TeeDisputeGame impl anchorStateRegistry() — returns the immutable ASR address +// ABI for TeeDisputeGame impl anchorStateRegistry() — returns the immutable ASR address const teeGameAnchorStateRegistryABIJSON = `[{"name":"anchorStateRegistry","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` -// For xlayer: ASR validation ABIs +// ASR validation ABIs const asrIsGameRespectedABIJSON = `[{"name":"isGameRespected","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` const asrIsGameBlacklistedABIJSON = `[{"name":"isGameBlacklisted","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` const asrIsGameRetiredABIJSON = `[{"name":"isGameRetired","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` -// For xlayer: parsed ABI for new game contract's claimData() getter +// parsed ABI for new game contract's claimData() getter var newGameClaimDataABI abi.ABI -// For xlayer: parsed ABIs for TeeDisputeGame status() and proposer() getters +// parsed ABIs for TeeDisputeGame status() and proposer() getters var teeGameStatusABI abi.ABI var teeGameProposerABI abi.ABI -// For xlayer: parsed ABIs for DGF gameImpls, impl anchorStateRegistry, and ASR validation +// parsed ABIs for DGF gameImpls, impl anchorStateRegistry, and ASR validation var dgfGameImplsABI abi.ABI var teeGameAnchorStateRegistryABI abi.ABI var asrIsGameRespectedABI abi.ABI @@ -97,7 +97,7 @@ func init() { } } -// For xlayer: cachedGameEntry holds immutable per-game metadata cached by DGF index. +// cachedGameEntry holds immutable per-game metadata cached by DGF index. type cachedGameEntry struct { GameType uint32 Address common.Address @@ -105,43 +105,43 @@ type cachedGameEntry struct { ProposerFetched bool } -// For xlayer: gameIndexCache is an in-memory cache of DGF index -> immutable game metadata. +// gameIndexCache is an in-memory cache of DGF index -> immutable game metadata. // entries uses a lazily-initialized LRU cache (thread-safe) to bound memory usage. // asrAddr is cached separately with a mutex. type gameIndexCache struct { - mu sync.RWMutex // For xlayer: protects asrAddr/asrAddrFetched only; entries uses lru's own lock - once sync.Once // For xlayer: guards one-time LRU initialization - entries *lru.Cache[uint64, cachedGameEntry] // For xlayer: bounded LRU, thread-safe, lazily initialized - // For xlayer: ASR address fetched once from the game impl contract and cached + mu sync.RWMutex // protects asrAddr/asrAddrFetched only; entries uses lru's own lock + once sync.Once // guards one-time LRU initialization + entries *lru.Cache[uint64, cachedGameEntry] // bounded LRU, thread-safe, lazily initialized + // ASR address fetched once from the game impl contract and cached asrAddr common.Address asrAddrFetched bool } -// For xlayer: lruEntries returns the lazily-initialized LRU cache. +// lruEntries returns the lazily-initialized LRU cache. // lru.New never fails for a positive size, so the error is safely ignored. func (c *gameIndexCache) lruEntries() *lru.Cache[uint64, cachedGameEntry] { c.once.Do(func() { - c.entries, _ = lru.New[uint64, cachedGameEntry](4096) // For xlayer: 4096-entry bounded LRU + c.entries, _ = lru.New[uint64, cachedGameEntry](4096) // 4096-entry bounded LRU }) return c.entries } func (c *gameIndexCache) get(idx uint64) (cachedGameEntry, bool) { - return c.lruEntries().Get(idx) // For xlayer: lru.Cache is thread-safe; no mutex needed + return c.lruEntries().Get(idx) // lru.Cache is thread-safe; no mutex needed } func (c *gameIndexCache) set(idx uint64, e cachedGameEntry) { - c.lruEntries().Add(idx, e) // For xlayer: lru.Cache is thread-safe; no mutex needed + c.lruEntries().Add(idx, e) // lru.Cache is thread-safe; no mutex needed } -// For xlayer: getASRAddr returns the cached ASR address if available. +// getASRAddr returns the cached ASR address if available. func (c *gameIndexCache) getASRAddr() (common.Address, bool) { c.mu.RLock() defer c.mu.RUnlock() return c.asrAddr, c.asrAddrFetched } -// For xlayer: setASRAddr stores the ASR address in the cache. +// setASRAddr stores the ASR address in the cache. func (c *gameIndexCache) setASRAddr(addr common.Address) { c.mu.Lock() defer c.mu.Unlock() @@ -149,7 +149,7 @@ func (c *gameIndexCache) setASRAddr(addr common.Address) { c.asrAddrFetched = true } -// For xlayer: gameStatusAt fetches the current GameStatus of a TeeDisputeGame proxy via status() getter. +// gameStatusAt fetches the current GameStatus of a TeeDisputeGame proxy via status() getter. // Status is mutable (changes on resolve) and must NOT be cached. func (f *DisputeGameFactory) gameStatusAt(ctx context.Context, proxyAddr common.Address) (uint8, error) { cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) @@ -162,7 +162,7 @@ func (f *DisputeGameFactory) gameStatusAt(ctx context.Context, proxyAddr common. return result.GetUint8(0), nil } -// For xlayer: gameProposerAt fetches the proposer() of a TeeDisputeGame proxy. +// gameProposerAt fetches the proposer() of a TeeDisputeGame proxy. // Proposer is immutable (set to tx.origin in initialize()) but cached lazily. func (f *DisputeGameFactory) gameProposerAt(ctx context.Context, proxyAddr common.Address) (common.Address, error) { cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) @@ -175,7 +175,7 @@ func (f *DisputeGameFactory) gameProposerAt(ctx context.Context, proxyAddr commo return result.GetAddress(0), nil } -// For xlayer: asrAddrFromImpl fetches the AnchorStateRegistry address from the DGF's +// asrAddrFromImpl fetches the AnchorStateRegistry address from the DGF's // game implementation contract for the given gameType. The result is immutable and cached. func (f *DisputeGameFactory) asrAddrFromImpl(ctx context.Context, gameType uint32) (common.Address, error) { if addr, ok := f.teeCache.getASRAddr(); ok { @@ -203,7 +203,7 @@ func (f *DisputeGameFactory) asrAddrFromImpl(ctx context.Context, gameType uint3 return addr, nil } -// For xlayer: isValidParentGame checks AnchorStateRegistry conditions for a candidate parent game. +// isValidParentGame checks AnchorStateRegistry conditions for a candidate parent game. // Mirrors TeeDisputeGame.initialize() validation: respected && !blacklisted && !retired. func (f *DisputeGameFactory) isValidParentGame(ctx context.Context, asrAddr common.Address, proxyAddr common.Address) (bool, error) { asrRespected := batching.NewBoundContract(&asrIsGameRespectedABI, asrAddr) @@ -244,7 +244,7 @@ func (f *DisputeGameFactory) isValidParentGame(ctx context.Context, asrAddr comm return true, nil } -// For xlayer: FindLastGameIndex scans the DGF in reverse to find the most recent game with the +// FindLastGameIndex scans the DGF in reverse to find the most recent game with the // given gameType that is a valid parent candidate: // - DEFENDER_WINS: accepted immediately (contract allows it) // - IN_PROGRESS + self-proposed: accepted (proposer == self) @@ -253,7 +253,7 @@ func (f *DisputeGameFactory) isValidParentGame(ctx context.Context, asrAddr comm // // Uses an in-memory cache for immutable fields (GameType, Address, Proposer) to avoid redundant RPCs. // Returns (idx, true, nil) if found, (0, false, nil) if not found within maxScan, or (0, false, err) on error. -func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uint32, proposer common.Address, maxScan uint64) (uint64, bool, error) { // For xlayer +func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uint32, proposer common.Address, maxScan uint64) (uint64, bool, error) { gameCount, err := f.gameCount(ctx) if err != nil { return 0, false, fmt.Errorf("tee-rollup: failed to get game count: %w", err) @@ -268,7 +268,7 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin } scanned++ - // For xlayer: check cache first — GameType and Address are immutable per DGF index + // check cache first — GameType and Address are immutable per DGF index entry, cached := f.teeCache.get(idx) if !cached { game, err := f.gameAtIndex(ctx, idx) @@ -286,13 +286,13 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin continue } - // For xlayer: fetch status fresh — mutable, must not be cached + // fetch status fresh — mutable, must not be cached status, err := f.gameStatusAt(ctx, entry.Address) if err != nil { return 0, false, fmt.Errorf("tee-rollup: failed to get status at index %d: %w", idx, err) } - // For xlayer: contract rejects CHALLENGER_WINS parents (TeeDisputeGame.sol:204) + // contract rejects CHALLENGER_WINS parents (TeeDisputeGame.sol:204) if status == GameStatusChallengerWins { if idx == 0 { break @@ -300,7 +300,7 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin continue } - // For xlayer: validate against AnchorStateRegistry using impl's ASR address + // validate against AnchorStateRegistry using impl's ASR address asrAddr, err := f.asrAddrFromImpl(ctx, gameType) if err != nil { return 0, false, fmt.Errorf("tee-rollup: failed to get ASR addr at index %d: %w", idx, err) @@ -310,31 +310,31 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin return 0, false, fmt.Errorf("tee-rollup: failed to validate parent game at index %d: %w", idx, err) } if !valid { - // For xlayer: game is retired/blacklisted/not-respected — skip + // game is retired/blacklisted/not-respected — skip if idx == 0 { break } continue } - // For xlayer: DEFENDER_WINS — accept immediately + // DEFENDER_WINS — accept immediately if status == GameStatusDefenderWins { return idx, true, nil } - // For xlayer: IN_PROGRESS — only accept if self-proposed + // IN_PROGRESS — only accept if self-proposed // Check cached proposer first; only call gameProposerAt on cache miss gameProposer := entry.Proposer if !entry.ProposerFetched { gameProposer, err = f.gameProposerAt(ctx, entry.Address) if err != nil { - // For xlayer: cannot verify proposer — skip this game + // cannot verify proposer — skip this game if idx == 0 { break } continue } - // For xlayer: cache proposer (immutable — set once in initialize()) + // cache proposer (immutable — set once in initialize()) entry.Proposer = gameProposer entry.ProposerFetched = true f.teeCache.set(idx, entry) @@ -343,7 +343,7 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin if gameProposer == proposer { return idx, true, nil } - // For xlayer: IN_PROGRESS by another proposer — skip + // IN_PROGRESS by another proposer — skip if idx == 0 { break diff --git a/op-proposer/flags/flags.go b/op-proposer/flags/flags.go index 744e12427e8fa..53b908250eba8 100644 --- a/op-proposer/flags/flags.go +++ b/op-proposer/flags/flags.go @@ -79,13 +79,13 @@ var ( Value: false, EnvVars: prefixEnvVars("WAIT_NODE_SYNC"), } - // For xlayer: TeeRollup RPC flag for game type 1960 + // For xlayer: TeeRollup RPC flag TeeRollupRpcFlag = &cli.StringFlag{ Name: "tee-rollup-rpc", Usage: "TeeRollup RPC service base URL (required when --game-type=1960)", EnvVars: []string{"OP_PROPOSER_TEE_ROLLUP_RPC"}, } - // X Layer: Genesis height may not be zero + // For xlayer: genesis height GenesisHeight = &cli.Uint64Flag{ Name: "genesis-height", Usage: "The genesis block height to use", @@ -112,7 +112,7 @@ var optionalFlags = []cli.Flag{ ActiveSequencerCheckDurationFlag, WaitNodeSyncFlag, TeeRollupRpcFlag, // For xlayer - GenesisHeight, // X Layer: Genesis height may not be zero + GenesisHeight, // For xlayer } func init() { diff --git a/op-proposer/proposer/config.go b/op-proposer/proposer/config.go index ed90c86173137..c1a972c8e9187 100644 --- a/op-proposer/proposer/config.go +++ b/op-proposer/proposer/config.go @@ -85,9 +85,9 @@ type CLIConfig struct { // Whether to wait for the sequencer to sync to a recent block at startup. WaitNodeSync bool - // For xlayer: TeeRollupRpc is the TeeRollup RPC service base URL for game type 1960. + // For xlayer: TeeRollup RPC base URL for game type 1960. TeeRollupRpc string - // X Layer: Genesis height may not be zero + // For xlayer: genesis height (may be non-zero on XLayer). GenesisHeight uint64 } @@ -125,7 +125,7 @@ func (c *CLIConfig) Check() error { if len(c.SuperNodeRpcs) != 0 { sourceCount++ } - if c.TeeRollupRpc != "" { // For xlayer + if c.TeeRollupRpc != "" { sourceCount++ } if sourceCount > 1 { @@ -170,6 +170,6 @@ func NewConfig(ctx *cli.Context) *CLIConfig { ActiveSequencerCheckDuration: ctx.Duration(flags.ActiveSequencerCheckDurationFlag.Name), WaitNodeSync: ctx.Bool(flags.WaitNodeSyncFlag.Name), TeeRollupRpc: ctx.String(flags.TeeRollupRpcFlag.Name), // For xlayer - GenesisHeight: ctx.Uint64(flags.GenesisHeight.Name), // X Layer: Genesis height may not be zero + GenesisHeight: ctx.Uint64(flags.GenesisHeight.Name), // For xlayer } } diff --git a/op-proposer/proposer/service.go b/op-proposer/proposer/service.go index 232a9c29f7d51..41ad4cbfcca2f 100644 --- a/op-proposer/proposer/service.go +++ b/op-proposer/proposer/service.go @@ -98,7 +98,6 @@ func (ps *ProposerService) initFromCLIConfig(ctx context.Context, version string ps.NetworkTimeout = cfg.TxMgrConfig.NetworkTimeout ps.AllowNonFinalized = cfg.AllowNonFinalized ps.WaitNodeSync = cfg.WaitNodeSync - // X Layer: Genesis height may not be zero ps.GenesisHeight = cfg.GenesisHeight ps.initDGF(cfg) @@ -129,9 +128,8 @@ func (ps *ProposerService) initFromCLIConfig(ctx context.Context, version string } func (ps *ProposerService) initRPCClients(ctx context.Context, cfg *CLIConfig) error { - // For xlayer: TeeRollup has no L1 derivation; CurrentL1 is always zero, - // which would cause waitNodeSync to block forever. - if cfg.DisputeGameType == contracts.TEEGameType && cfg.WaitNodeSync { // For xlayer + // For xlayer: TeeRollup has no L1 derivation; CurrentL1 is always zero — waitNodeSync would block forever. + if cfg.DisputeGameType == contracts.TEEGameType && cfg.WaitNodeSync { return fmt.Errorf("--wait-node-sync is not supported with TeeRollup game type (1960)") } @@ -178,7 +176,7 @@ func (ps *ProposerService) initRPCClients(ctx context.Context, cfg *CLIConfig) e } ps.ProposalSource = source.NewSuperNodeProposalSource(ps.Log, clients...) } - // For xlayer: initialize TeeRollup proposal source for game type 1960 + // For xlayer: initialize TeeRollup proposal source if cfg.TeeRollupRpc != "" { teeRollupClient, err := source.NewTeeRollupHTTPClient(cfg.TeeRollupRpc) if err != nil { diff --git a/op-proposer/proposer/service_tee_xlayer.go b/op-proposer/proposer/service_tee_xlayer.go index 1532e532d4ddd..403a26697ed72 100644 --- a/op-proposer/proposer/service_tee_xlayer.go +++ b/op-proposer/proposer/service_tee_xlayer.go @@ -10,24 +10,24 @@ import ( "github.com/ethereum-optimism/optimism/op-proposer/proposer/source" ) -// For xlayer: initTeeSource wires the parent game index resolver into TeeRollupProposalSource. +// initTeeSource wires the parent game index resolver into TeeRollupProposalSource. // Must be called after NewL2OutputSubmitter returns so that driver.dgfContract is available. // Returns an error if dgfContract is not *contracts.DisputeGameFactory, to prevent silent // MaxUint32 sentinel usage in production. func initTeeSource(ps *ProposerService, driver *L2OutputSubmitter) error { teeSource, ok := ps.ProposalSource.(*source.TeeRollupProposalSource) if !ok { - return nil // For xlayer: not a TEE game type — nothing to wire + return nil // not a TEE game type — nothing to wire } dgfCaller, ok := driver.dgfContract.(*contracts.DisputeGameFactory) if !ok { - // For xlayer: dgfContract is not *contracts.DisputeGameFactory — fail fast to prevent silent MaxUint32 sentinel usage in production + // dgfContract is not *contracts.DisputeGameFactory — fail fast to prevent silent MaxUint32 sentinel usage in production return fmt.Errorf("tee-rollup: dgfContract is not *contracts.DisputeGameFactory, cannot wire parentIdxFn") } proposer := driver.Txmgr.From() gameType := uint32(ps.ProposerConfig.DisputeGameType) teeSource.SetParentIdxFn(func(ctx context.Context) (uint32, bool, error) { - idx, found, err := dgfCaller.FindLastGameIndex(ctx, gameType, proposer, contracts.TeeParentScanLimit) // For xlayer + idx, found, err := dgfCaller.FindLastGameIndex(ctx, gameType, proposer, contracts.TeeParentScanLimit) if err != nil { return 0, false, err } diff --git a/op-proposer/proposer/source/source_tee_rollup_xlayer.go b/op-proposer/proposer/source/source_tee_rollup_xlayer.go index 9a82079b2600e..754d594d0834c 100644 --- a/op-proposer/proposer/source/source_tee_rollup_xlayer.go +++ b/op-proposer/proposer/source/source_tee_rollup_xlayer.go @@ -20,7 +20,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" ) -// For xlayer: TeeRollupBlockInfo holds confirmed block info returned by the TeeRollup RPC. +// TeeRollupBlockInfo holds confirmed block info returned by the TeeRollup RPC. type TeeRollupBlockInfo struct { Height uint64 AppHash common.Hash @@ -40,21 +40,21 @@ type teeRollupData struct { BlockHash *string `json:"blockHash"` } -// For xlayer: TeeRollupClient is the interface for the TeeRollup RPC client. +// TeeRollupClient is the interface for the TeeRollup RPC client. type TeeRollupClient interface { ConfirmedBlockInfo(ctx context.Context) (TeeRollupBlockInfo, error) ConfirmedBlockInfoAtHeight(ctx context.Context, height uint64) (TeeRollupBlockInfo, error) Close() } -// For xlayer: TeeRollupHTTPClient implements TeeRollupClient using HTTP REST. +// TeeRollupHTTPClient implements TeeRollupClient using HTTP REST. type TeeRollupHTTPClient struct { baseURL string httpClient *http.Client cache *lru.Cache[uint64, TeeRollupBlockInfo] } -// For xlayer: NewTeeRollupHTTPClient creates a new TeeRollupHTTPClient. +// NewTeeRollupHTTPClient creates a new TeeRollupHTTPClient. func NewTeeRollupHTTPClient(baseURL string) (*TeeRollupHTTPClient, error) { cache, err := lru.New[uint64, TeeRollupBlockInfo](16) if err != nil { @@ -69,7 +69,7 @@ func NewTeeRollupHTTPClient(baseURL string) (*TeeRollupHTTPClient, error) { }, nil } -// For xlayer: ConfirmedBlockInfo fetches the latest confirmed block info from TeeRollup RPC. +// ConfirmedBlockInfo fetches the latest confirmed block info from TeeRollup RPC. // GET /v1/chain/confirmed_block_info func (c *TeeRollupHTTPClient) ConfirmedBlockInfo(ctx context.Context) (TeeRollupBlockInfo, error) { url := c.baseURL + "/v1/chain/confirmed_block_info" @@ -82,11 +82,11 @@ func (c *TeeRollupHTTPClient) ConfirmedBlockInfo(ctx context.Context) (TeeRollup return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: HTTP request failed: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { // For xlayer - return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: HTTP request failed with status %d", resp.StatusCode) // For xlayer - } // For xlayer + if resp.StatusCode != http.StatusOK { + return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: HTTP request failed with status %d", resp.StatusCode) + } - body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // For xlayer + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) if err != nil { return TeeRollupBlockInfo{}, fmt.Errorf("tee-rollup: failed to read response body: %w", err) } @@ -115,7 +115,7 @@ func (c *TeeRollupHTTPClient) ConfirmedBlockInfo(ctx context.Context) (TeeRollup return info, nil } -// For xlayer: ConfirmedBlockInfoAtHeight fetches confirmed block info at a specific height. +// ConfirmedBlockInfoAtHeight fetches confirmed block info at a specific height. // Returns from LRU cache if available; otherwise fetches from RPC and validates exact height match. func (c *TeeRollupHTTPClient) ConfirmedBlockInfoAtHeight(ctx context.Context, height uint64) (TeeRollupBlockInfo, error) { if cached, ok := c.cache.Get(height); ok { @@ -131,20 +131,20 @@ func (c *TeeRollupHTTPClient) ConfirmedBlockInfoAtHeight(ctx context.Context, he return info, nil } -// For xlayer: Close is a no-op for HTTP client (satisfies TeeRollupClient interface). +// Close is a no-op (satisfies TeeRollupClient interface). func (c *TeeRollupHTTPClient) Close() {} -// For xlayer: TeeRollupProposalSource implements ProposalSource for TeeRollup TEE game type 1960. +// TeeRollupProposalSource implements ProposalSource for TeeRollup TEE game type 1960. type TeeRollupProposalSource struct { log log.Logger clients []TeeRollupClient - parentIdxFn func(ctx context.Context) (uint32, bool, error) // For xlayer: resolves parent DGF game index + parentIdxFn func(ctx context.Context) (uint32, bool, error) // resolves parent DGF game index } -// For xlayer: NewTeeRollupProposalSource creates a new TeeRollupProposalSource. +// NewTeeRollupProposalSource creates a new TeeRollupProposalSource. func NewTeeRollupProposalSource(log log.Logger, clients ...TeeRollupClient) *TeeRollupProposalSource { if len(clients) == 0 { - panic("no TeeRollup clients provided") // For xlayer + panic("no TeeRollup clients provided") } return &TeeRollupProposalSource{ log: log, @@ -152,14 +152,14 @@ func NewTeeRollupProposalSource(log log.Logger, clients ...TeeRollupClient) *Tee } } -// For xlayer: SetParentIdxFn injects the callback that resolves the parent DGF game index. +// SetParentIdxFn injects the callback that resolves the parent DGF game index. // MUST be called before Start() to satisfy Go's happens-before guarantee. // If nil (default), ProposalAtSequenceNum always uses math.MaxUint32 (anchor state sentinel). func (s *TeeRollupProposalSource) SetParentIdxFn(fn func(ctx context.Context) (uint32, bool, error)) { s.parentIdxFn = fn } -// For xlayer: SyncStatus queries all clients in parallel and returns the most conservative (lowest) height. +// SyncStatus queries all clients in parallel and returns the most conservative (lowest) height. // CurrentL1 is always zero value — TeeRollup has no L1 derivation. func (s *TeeRollupProposalSource) SyncStatus(ctx context.Context) (SyncStatus, error) { type result struct { @@ -195,13 +195,13 @@ func (s *TeeRollupProposalSource) SyncStatus(ctx context.Context) (SyncStatus, e return SyncStatus{}, errors.Join(errs...) } return SyncStatus{ - CurrentL1: eth.BlockID{}, // For xlayer: always zero — no L1 derivation + CurrentL1: eth.BlockID{}, // always zero — no L1 derivation SafeL2: lowestHeight, FinalizedL2: lowestHeight, }, nil } -// For xlayer: ProposalAtSequenceNum fetches the proposal at the given L2 sequence number. +// ProposalAtSequenceNum fetches the proposal at the given L2 sequence number. // Tries clients in order, fails over on error. Only accepts exact height match. func (s *TeeRollupProposalSource) ProposalAtSequenceNum(ctx context.Context, seqNum uint64) (Proposal, error) { var lastErr error @@ -213,7 +213,7 @@ func (s *TeeRollupProposalSource) ProposalAtSequenceNum(ctx context.Context, seq } rootClaim := computeRootClaim(info.BlockHash, info.AppHash) - // For xlayer: resolve parentIdx dynamically; fall back to MaxUint32 (anchor state) if not found or error + // resolve parentIdx dynamically; fall back to MaxUint32 (anchor sentinel) on error parentIdx := uint32(math.MaxUint32) if s.parentIdxFn != nil { if idx, found, err := s.parentIdxFn(ctx); err != nil { @@ -226,7 +226,7 @@ func (s *TeeRollupProposalSource) ProposalAtSequenceNum(ctx context.Context, seq proposal := Proposal{ Root: rootClaim, SequenceNum: seqNum, - CurrentL1: eth.BlockID{}, // For xlayer: always zero — no L1 derivation + CurrentL1: eth.BlockID{}, // always zero — no L1 derivation TeeRollupData: &TeeRollupProposalData{ L2SeqNum: seqNum, ParentIdx: parentIdx, @@ -239,13 +239,13 @@ func (s *TeeRollupProposalSource) ProposalAtSequenceNum(ctx context.Context, seq return Proposal{}, fmt.Errorf("tee-rollup: all clients failed for seqNum=%d: %w", seqNum, lastErr) } -// For xlayer: computeRootClaim computes the root claim as keccak256(abi.encode(blockHash, stateHash)). +// computeRootClaim computes the root claim as keccak256(abi.encode(blockHash, stateHash)). // abi.encode of two bytes32 values = 64 bytes (each padded to 32 bytes). func computeRootClaim(blockHash, stateHash common.Hash) common.Hash { return crypto.Keccak256Hash(append(blockHash.Bytes(), stateHash.Bytes()...)) } -// For xlayer: Close closes all underlying TeeRollup clients. +// Close closes all underlying TeeRollup clients. func (s *TeeRollupProposalSource) Close() { for _, cl := range s.clients { cl.Close() From f14f283a7ac713ab97253fe435429a114a707ca8 Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Tue, 24 Mar 2026 17:01:05 +0800 Subject: [PATCH 17/25] batch call --- .../contracts/teedisputegame_xlayer.go | 147 ++++++------------ 1 file changed, 45 insertions(+), 102 deletions(-) diff --git a/op-proposer/contracts/teedisputegame_xlayer.go b/op-proposer/contracts/teedisputegame_xlayer.go index 055457253607e..edba7404a64ff 100644 --- a/op-proposer/contracts/teedisputegame_xlayer.go +++ b/op-proposer/contracts/teedisputegame_xlayer.go @@ -32,9 +32,8 @@ const ( // ABI for new game contract's no-arg claimData() struct getter const newGameClaimDataABIJSON = `[{"name":"claimData","type":"function","inputs":[],"outputs":[{"name":"parentIndex","type":"uint32"},{"name":"counteredBy","type":"address"},{"name":"prover","type":"address"},{"name":"claim","type":"bytes32"},{"name":"status","type":"uint8"},{"name":"deadline","type":"uint64"}],"stateMutability":"view"}]` -// ABI for TeeDisputeGame status() and proposer() getters -const teeGameStatusABIJSON = `[{"name":"status","type":"function","inputs":[],"outputs":[{"name":"","type":"uint8"}],"stateMutability":"view"}]` -const teeGameProposerABIJSON = `[{"name":"proposer","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` +// ABI for TeeDisputeGame proxy: status() and proposer() +const teeGameABIJSON = `[{"name":"status","type":"function","inputs":[],"outputs":[{"name":"","type":"uint8"}],"stateMutability":"view"},{"name":"proposer","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` // ABI for DGF.gameImpls(uint32) — returns the implementation contract address for a game type const dgfGameImplsABIJSON = `[{"name":"gameImpls","type":"function","inputs":[{"name":"_gameType","type":"uint32"}],"outputs":[{"name":"impl_","type":"address"}],"stateMutability":"view"}]` @@ -42,24 +41,21 @@ const dgfGameImplsABIJSON = `[{"name":"gameImpls","type":"function","inputs":[{" // ABI for TeeDisputeGame impl anchorStateRegistry() — returns the immutable ASR address const teeGameAnchorStateRegistryABIJSON = `[{"name":"anchorStateRegistry","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` -// ASR validation ABIs -const asrIsGameRespectedABIJSON = `[{"name":"isGameRespected","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` -const asrIsGameBlacklistedABIJSON = `[{"name":"isGameBlacklisted","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` -const asrIsGameRetiredABIJSON = `[{"name":"isGameRetired","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` +// ABI for ASR validation: isGameRespected, isGameBlacklisted, isGameRetired +const asrValidationABIJSON = `[{"name":"isGameRespected","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"},{"name":"isGameBlacklisted","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"},{"name":"isGameRetired","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` // parsed ABI for new game contract's claimData() getter var newGameClaimDataABI abi.ABI -// parsed ABIs for TeeDisputeGame status() and proposer() getters -var teeGameStatusABI abi.ABI -var teeGameProposerABI abi.ABI +// parsed ABI for TeeDisputeGame proxy: status() + proposer() +var teeGameABI abi.ABI -// parsed ABIs for DGF gameImpls, impl anchorStateRegistry, and ASR validation +// parsed ABIs for DGF gameImpls and impl anchorStateRegistry var dgfGameImplsABI abi.ABI var teeGameAnchorStateRegistryABI abi.ABI -var asrIsGameRespectedABI abi.ABI -var asrIsGameBlacklistedABI abi.ABI -var asrIsGameRetiredABI abi.ABI + +// parsed ABI for ASR validation: isGameRespected + isGameBlacklisted + isGameRetired +var asrValidationABI abi.ABI func init() { var err error @@ -67,13 +63,9 @@ func init() { if err != nil { panic(fmt.Sprintf("failed to parse new game claim data ABI: %v", err)) } - teeGameStatusABI, err = abi.JSON(strings.NewReader(teeGameStatusABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse tee game status ABI: %v", err)) - } - teeGameProposerABI, err = abi.JSON(strings.NewReader(teeGameProposerABIJSON)) + teeGameABI, err = abi.JSON(strings.NewReader(teeGameABIJSON)) if err != nil { - panic(fmt.Sprintf("failed to parse tee game proposer ABI: %v", err)) + panic(fmt.Sprintf("failed to parse tee game ABI: %v", err)) } dgfGameImplsABI, err = abi.JSON(strings.NewReader(dgfGameImplsABIJSON)) if err != nil { @@ -83,17 +75,9 @@ func init() { if err != nil { panic(fmt.Sprintf("failed to parse tee game anchorStateRegistry ABI: %v", err)) } - asrIsGameRespectedABI, err = abi.JSON(strings.NewReader(asrIsGameRespectedABIJSON)) + asrValidationABI, err = abi.JSON(strings.NewReader(asrValidationABIJSON)) if err != nil { - panic(fmt.Sprintf("failed to parse ASR isGameRespected ABI: %v", err)) - } - asrIsGameBlacklistedABI, err = abi.JSON(strings.NewReader(asrIsGameBlacklistedABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse ASR isGameBlacklisted ABI: %v", err)) - } - asrIsGameRetiredABI, err = abi.JSON(strings.NewReader(asrIsGameRetiredABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse ASR isGameRetired ABI: %v", err)) + panic(fmt.Sprintf("failed to parse ASR validation ABI: %v", err)) } } @@ -149,30 +133,19 @@ func (c *gameIndexCache) setASRAddr(addr common.Address) { c.asrAddrFetched = true } -// gameStatusAt fetches the current GameStatus of a TeeDisputeGame proxy via status() getter. -// Status is mutable (changes on resolve) and must NOT be cached. -func (f *DisputeGameFactory) gameStatusAt(ctx context.Context, proxyAddr common.Address) (uint8, error) { +// gameStatusAndProposerAt fetches status and proposer of a TeeDisputeGame proxy in one batch call. +func (f *DisputeGameFactory) gameStatusAndProposerAt(ctx context.Context, proxyAddr common.Address) (uint8, common.Address, error) { cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) defer cancel() - statusContract := batching.NewBoundContract(&teeGameStatusABI, proxyAddr) - result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, statusContract.Call("status")) + gameContract := batching.NewBoundContract(&teeGameABI, proxyAddr) + results, err := f.caller.Call(cCtx, rpcblock.Latest, + gameContract.Call("status"), + gameContract.Call("proposer"), + ) if err != nil { - return 0, fmt.Errorf("tee-rollup: failed to get status of game %v: %w", proxyAddr, err) + return 0, common.Address{}, fmt.Errorf("tee-rollup: failed to get status/proposer of game %v: %w", proxyAddr, err) } - return result.GetUint8(0), nil -} - -// gameProposerAt fetches the proposer() of a TeeDisputeGame proxy. -// Proposer is immutable (set to tx.origin in initialize()) but cached lazily. -func (f *DisputeGameFactory) gameProposerAt(ctx context.Context, proxyAddr common.Address) (common.Address, error) { - cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) - defer cancel() - proposerContract := batching.NewBoundContract(&teeGameProposerABI, proxyAddr) - result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, proposerContract.Call("proposer")) - if err != nil { - return common.Address{}, fmt.Errorf("tee-rollup: failed to get proposer of game %v: %w", proxyAddr, err) - } - return result.GetAddress(0), nil + return results[0].GetUint8(0), results[1].GetAddress(0), nil } // asrAddrFromImpl fetches the AnchorStateRegistry address from the DGF's @@ -206,42 +179,21 @@ func (f *DisputeGameFactory) asrAddrFromImpl(ctx context.Context, gameType uint3 // isValidParentGame checks AnchorStateRegistry conditions for a candidate parent game. // Mirrors TeeDisputeGame.initialize() validation: respected && !blacklisted && !retired. func (f *DisputeGameFactory) isValidParentGame(ctx context.Context, asrAddr common.Address, proxyAddr common.Address) (bool, error) { - asrRespected := batching.NewBoundContract(&asrIsGameRespectedABI, asrAddr) - asrBlacklisted := batching.NewBoundContract(&asrIsGameBlacklistedABI, asrAddr) - asrRetired := batching.NewBoundContract(&asrIsGameRetiredABI, asrAddr) - cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) defer cancel() - result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, asrRespected.Call("isGameRespected", proxyAddr)) - if err != nil { - return false, fmt.Errorf("tee-rollup: failed to call isGameRespected for %v: %w", proxyAddr, err) - } - respected := result.GetBool(0) - if !respected { - return false, nil - } - - cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) - defer cancel() - result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, asrBlacklisted.Call("isGameBlacklisted", proxyAddr)) + asrContract := batching.NewBoundContract(&asrValidationABI, asrAddr) + results, err := f.caller.Call(cCtx, rpcblock.Latest, + asrContract.Call("isGameRespected", proxyAddr), + asrContract.Call("isGameBlacklisted", proxyAddr), + asrContract.Call("isGameRetired", proxyAddr), + ) if err != nil { - return false, fmt.Errorf("tee-rollup: failed to call isGameBlacklisted for %v: %w", proxyAddr, err) - } - if result.GetBool(0) { - return false, nil + return false, fmt.Errorf("tee-rollup: failed ASR validation for %v: %w", proxyAddr, err) } - - cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) - defer cancel() - result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, asrRetired.Call("isGameRetired", proxyAddr)) - if err != nil { - return false, fmt.Errorf("tee-rollup: failed to call isGameRetired for %v: %w", proxyAddr, err) - } - if result.GetBool(0) { - return false, nil - } - - return true, nil + respected := results[0].GetBool(0) + blacklisted := results[1].GetBool(0) + retired := results[2].GetBool(0) + return respected && !blacklisted && !retired, nil } // FindLastGameIndex scans the DGF in reverse to find the most recent game with the @@ -286,10 +238,18 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin continue } - // fetch status fresh — mutable, must not be cached - status, err := f.gameStatusAt(ctx, entry.Address) + // fetch status and proposer together (status is always fresh; proposer cached after first fetch) + status, gameProposer, err := f.gameStatusAndProposerAt(ctx, entry.Address) if err != nil { - return 0, false, fmt.Errorf("tee-rollup: failed to get status at index %d: %w", idx, err) + return 0, false, fmt.Errorf("tee-rollup: failed to get status/proposer at index %d: %w", idx, err) + } + // cache proposer if not yet cached (immutable — set once in initialize()) + if !entry.ProposerFetched { + entry.Proposer = gameProposer + entry.ProposerFetched = true + f.teeCache.set(idx, entry) + } else { + gameProposer = entry.Proposer // use cached value } // contract rejects CHALLENGER_WINS parents (TeeDisputeGame.sol:204) @@ -323,23 +283,6 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin } // IN_PROGRESS — only accept if self-proposed - // Check cached proposer first; only call gameProposerAt on cache miss - gameProposer := entry.Proposer - if !entry.ProposerFetched { - gameProposer, err = f.gameProposerAt(ctx, entry.Address) - if err != nil { - // cannot verify proposer — skip this game - if idx == 0 { - break - } - continue - } - // cache proposer (immutable — set once in initialize()) - entry.Proposer = gameProposer - entry.ProposerFetched = true - f.teeCache.set(idx, entry) - } - if gameProposer == proposer { return idx, true, nil } From 962875eb47ab38bc384103ed544945ee7666e381 Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Tue, 24 Mar 2026 17:02:55 +0800 Subject: [PATCH 18/25] fix, support ignoring unrecognized game type --- op-proposer/contracts/disputegamefactory.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/op-proposer/contracts/disputegamefactory.go b/op-proposer/contracts/disputegamefactory.go index f39639974467c..0cc935cf16e3c 100644 --- a/op-proposer/contracts/disputegamefactory.go +++ b/op-proposer/contracts/disputegamefactory.go @@ -136,28 +136,29 @@ func (f *DisputeGameFactory) gameAtIndex(ctx context.Context, idx uint64) (gameM var claimant common.Address var claim common.Hash - if gameType == TEEGameType { // For xlayer: uses different claimData() ABI with no args - newGameContract := batching.NewBoundContract(&newGameClaimDataABI, address) + if gameType == 0 || gameType == 1 { + gameContract := batching.NewBoundContract(f.gameABI, address) cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) defer cancel() - result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, newGameContract.Call(methodClaim)) + result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, gameContract.Call(methodClaim, big.NewInt(0))) if err != nil { return gameMetadata{}, fmt.Errorf("failed to load root claim of game %v: %w", idx, err) } + // We don't need most of the claim data, only the claim and the claimant which is the game proposer claimant = result.GetAddress(2) - claim = result.GetHash(3) - } else { - gameContract := batching.NewBoundContract(f.gameABI, address) + claim = result.GetHash(4) + } else if gameType == TEEGameType { // For xlayer: uses different claimData() ABI with no args + newGameContract := batching.NewBoundContract(&newGameClaimDataABI, address) cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) defer cancel() - result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, gameContract.Call(methodClaim, big.NewInt(0))) + result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, newGameContract.Call(methodClaim)) if err != nil { return gameMetadata{}, fmt.Errorf("failed to load root claim of game %v: %w", idx, err) } - // We don't need most of the claim data, only the claim and the claimant which is the game proposer claimant = result.GetAddress(2) - claim = result.GetHash(4) + claim = result.GetHash(3) } + // Other game types are not handled, claimant and claim remain zero values return gameMetadata{ GameType: gameType, From 43b7b364f1ab04f4708a43323a9acceb164307fd Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Tue, 24 Mar 2026 19:24:47 +0800 Subject: [PATCH 19/25] feat(op-challenger): filter games by enabled type on shared factory When multiple challenger nodes share a single GameFactory contract, each node only processes games matching its configured game types, avoiding "unsupported game type" errors from unrelated games. Co-Authored-By: Claude Opus 4.6 --- op-challenger/game/service.go | 3 +- op-challenger/game/service_xlayer.go | 40 ++++++++++++++++ op-challenger/game/service_xlayer_test.go | 58 +++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 op-challenger/game/service_xlayer.go create mode 100644 op-challenger/game/service_xlayer_test.go diff --git a/op-challenger/game/service.go b/op-challenger/game/service.go index 65f2e07c4465b..10d98872201b1 100644 --- a/op-challenger/game/service.go +++ b/op-challenger/game/service.go @@ -255,7 +255,8 @@ func (s *Service) initLargePreimages() error { } func (s *Service) initMonitor(cfg *config.Config) { - s.monitor = newGameMonitor(s.logger, s.l1Clock, s.factoryContract, s.sched, s.preimages, cfg.GameWindow, s.claimer, cfg.GameAllowlist, s.l1RPC, cfg.MinUpdateInterval) + source := newFilteredGameSource(s.factoryContract, cfg.GameTypes) // For XLayer: filter games by enabled types on shared factory + s.monitor = newGameMonitor(s.logger, s.l1Clock, source, s.sched, s.preimages, cfg.GameWindow, s.claimer, cfg.GameAllowlist, s.l1RPC, cfg.MinUpdateInterval) } func (s *Service) Start(ctx context.Context) error { diff --git a/op-challenger/game/service_xlayer.go b/op-challenger/game/service_xlayer.go new file mode 100644 index 0000000000000..bdee3f9085477 --- /dev/null +++ b/op-challenger/game/service_xlayer.go @@ -0,0 +1,40 @@ +package game + +import ( + "context" + + "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum/go-ethereum/common" +) + +// filteredGameSource wraps a gameSource and only returns games whose type is in the enabled set. +// When enabledTypes is empty, all games are returned (no filtering). +type filteredGameSource struct { + inner gameSource + enabledTypes map[uint32]bool +} + +func newFilteredGameSource(inner gameSource, gameTypes []types.GameType) gameSource { + if len(gameTypes) == 0 { + return inner + } + enabled := make(map[uint32]bool, len(gameTypes)) + for _, t := range gameTypes { + enabled[uint32(t)] = true + } + return &filteredGameSource{inner: inner, enabledTypes: enabled} +} + +func (f *filteredGameSource) GetGamesAtOrAfter(ctx context.Context, blockHash common.Hash, earliestTimestamp uint64) ([]types.GameMetadata, error) { + games, err := f.inner.GetGamesAtOrAfter(ctx, blockHash, earliestTimestamp) + if err != nil { + return nil, err + } + filtered := make([]types.GameMetadata, 0, len(games)) + for _, g := range games { + if f.enabledTypes[g.GameType] { + filtered = append(filtered, g) + } + } + return filtered, nil +} diff --git a/op-challenger/game/service_xlayer_test.go b/op-challenger/game/service_xlayer_test.go new file mode 100644 index 0000000000000..f9ebbf493b68d --- /dev/null +++ b/op-challenger/game/service_xlayer_test.go @@ -0,0 +1,58 @@ +package game + +import ( + "context" + "testing" + + "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestFilteredGameSource(t *testing.T) { + allGames := []types.GameMetadata{ + {Proxy: common.Address{0x01}, GameType: 1960}, + {Proxy: common.Address{0x02}, GameType: 42}, + {Proxy: common.Address{0x03}, GameType: 1960}, + {Proxy: common.Address{0x04}, GameType: 1}, + } + stub := &stubFilterSource{games: allGames} + + t.Run("FiltersToEnabledTypes", func(t *testing.T) { + source := newFilteredGameSource(stub, []types.GameType{1960}) + games, err := source.GetGamesAtOrAfter(context.Background(), common.Hash{}, 0) + require.NoError(t, err) + require.Len(t, games, 2) + require.Equal(t, uint32(1960), games[0].GameType) + require.Equal(t, uint32(1960), games[1].GameType) + }) + + t.Run("EmptyTypesReturnsAll", func(t *testing.T) { + source := newFilteredGameSource(stub, nil) + games, err := source.GetGamesAtOrAfter(context.Background(), common.Hash{}, 0) + require.NoError(t, err) + require.Len(t, games, 4) + }) + + t.Run("MultipleEnabledTypes", func(t *testing.T) { + source := newFilteredGameSource(stub, []types.GameType{1960, 1}) + games, err := source.GetGamesAtOrAfter(context.Background(), common.Hash{}, 0) + require.NoError(t, err) + require.Len(t, games, 3) + }) + + t.Run("NoMatchingTypes", func(t *testing.T) { + source := newFilteredGameSource(stub, []types.GameType{999}) + games, err := source.GetGamesAtOrAfter(context.Background(), common.Hash{}, 0) + require.NoError(t, err) + require.Empty(t, games) + }) +} + +type stubFilterSource struct { + games []types.GameMetadata +} + +func (s *stubFilterSource) GetGamesAtOrAfter(_ context.Context, _ common.Hash, _ uint64) ([]types.GameMetadata, error) { + return s.games, nil +} From f8ad6af84cfa992b329c9475d798248fb9de9110 Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Wed, 25 Mar 2026 14:36:06 +0800 Subject: [PATCH 20/25] optimize code --- op-proposer/contracts/disputegamefactory.go | 3 +- .../contracts/teedisputegame_xlayer.go | 183 +++++++----------- .../contracts-bedrock/snapshots/abi_loader.go | 11 +- 3 files changed, 82 insertions(+), 115 deletions(-) diff --git a/op-proposer/contracts/disputegamefactory.go b/op-proposer/contracts/disputegamefactory.go index 0cc935cf16e3c..27cda39a271f9 100644 --- a/op-proposer/contracts/disputegamefactory.go +++ b/op-proposer/contracts/disputegamefactory.go @@ -148,7 +148,8 @@ func (f *DisputeGameFactory) gameAtIndex(ctx context.Context, idx uint64) (gameM claimant = result.GetAddress(2) claim = result.GetHash(4) } else if gameType == TEEGameType { // For xlayer: uses different claimData() ABI with no args - newGameContract := batching.NewBoundContract(&newGameClaimDataABI, address) + // For xlayer: use snapshot ABI loaded from compiled artifact instead of inline JSON + newGameContract := batching.NewBoundContract(teeDisputeGameSnapshotABI, address) cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) defer cancel() result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, newGameContract.Call(methodClaim)) diff --git a/op-proposer/contracts/teedisputegame_xlayer.go b/op-proposer/contracts/teedisputegame_xlayer.go index edba7404a64ff..c6f9f8127f945 100644 --- a/op-proposer/contracts/teedisputegame_xlayer.go +++ b/op-proposer/contracts/teedisputegame_xlayer.go @@ -4,14 +4,13 @@ package contracts import ( "context" "fmt" - "strings" "sync" lru "github.com/hashicorp/golang-lru/v2" // bounded LRU cache for game index entries "github.com/ethereum-optimism/optimism/op-service/sources/batching" "github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock" - "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum-optimism/optimism/packages/contracts-bedrock/snapshots" "github.com/ethereum/go-ethereum/common" ) @@ -29,57 +28,12 @@ const ( GameStatusDefenderWins uint8 = 2 ) -// ABI for new game contract's no-arg claimData() struct getter -const newGameClaimDataABIJSON = `[{"name":"claimData","type":"function","inputs":[],"outputs":[{"name":"parentIndex","type":"uint32"},{"name":"counteredBy","type":"address"},{"name":"prover","type":"address"},{"name":"claim","type":"bytes32"},{"name":"status","type":"uint8"},{"name":"deadline","type":"uint64"}],"stateMutability":"view"}]` - -// ABI for TeeDisputeGame proxy: status() and proposer() -const teeGameABIJSON = `[{"name":"status","type":"function","inputs":[],"outputs":[{"name":"","type":"uint8"}],"stateMutability":"view"},{"name":"proposer","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` - -// ABI for DGF.gameImpls(uint32) — returns the implementation contract address for a game type -const dgfGameImplsABIJSON = `[{"name":"gameImpls","type":"function","inputs":[{"name":"_gameType","type":"uint32"}],"outputs":[{"name":"impl_","type":"address"}],"stateMutability":"view"}]` - -// ABI for TeeDisputeGame impl anchorStateRegistry() — returns the immutable ASR address -const teeGameAnchorStateRegistryABIJSON = `[{"name":"anchorStateRegistry","type":"function","inputs":[],"outputs":[{"name":"","type":"address"}],"stateMutability":"view"}]` - -// ABI for ASR validation: isGameRespected, isGameBlacklisted, isGameRetired -const asrValidationABIJSON = `[{"name":"isGameRespected","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"},{"name":"isGameBlacklisted","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"},{"name":"isGameRetired","type":"function","inputs":[{"name":"_game","type":"address"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"}]` - -// parsed ABI for new game contract's claimData() getter -var newGameClaimDataABI abi.ABI - -// parsed ABI for TeeDisputeGame proxy: status() + proposer() -var teeGameABI abi.ABI - -// parsed ABIs for DGF gameImpls and impl anchorStateRegistry -var dgfGameImplsABI abi.ABI -var teeGameAnchorStateRegistryABI abi.ABI - -// parsed ABI for ASR validation: isGameRespected + isGameBlacklisted + isGameRetired -var asrValidationABI abi.ABI - -func init() { - var err error - newGameClaimDataABI, err = abi.JSON(strings.NewReader(newGameClaimDataABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse new game claim data ABI: %v", err)) - } - teeGameABI, err = abi.JSON(strings.NewReader(teeGameABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse tee game ABI: %v", err)) - } - dgfGameImplsABI, err = abi.JSON(strings.NewReader(dgfGameImplsABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse DGF gameImpls ABI: %v", err)) - } - teeGameAnchorStateRegistryABI, err = abi.JSON(strings.NewReader(teeGameAnchorStateRegistryABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse tee game anchorStateRegistry ABI: %v", err)) - } - asrValidationABI, err = abi.JSON(strings.NewReader(asrValidationABIJSON)) - if err != nil { - panic(fmt.Sprintf("failed to parse ASR validation ABI: %v", err)) - } -} +// For xlayer: ABI instances loaded once at package init from compiled snapshots and reused +// across all calls (parsed once for performance, avoids repeated JSON parsing per call). +var ( + teeDisputeGameSnapshotABI = snapshots.LoadTeeDisputeGameABI() + asrSnapshotABI = snapshots.LoadAnchorStateRegistryABI() // For xlayer +) // cachedGameEntry holds immutable per-game metadata cached by DGF index. type cachedGameEntry struct { @@ -91,14 +45,14 @@ type cachedGameEntry struct { // gameIndexCache is an in-memory cache of DGF index -> immutable game metadata. // entries uses a lazily-initialized LRU cache (thread-safe) to bound memory usage. -// asrAddr is cached separately with a mutex. +// asrAddr is fetched exactly once via asrOnce. type gameIndexCache struct { - mu sync.RWMutex // protects asrAddr/asrAddrFetched only; entries uses lru's own lock once sync.Once // guards one-time LRU initialization entries *lru.Cache[uint64, cachedGameEntry] // bounded LRU, thread-safe, lazily initialized - // ASR address fetched once from the game impl contract and cached - asrAddr common.Address - asrAddrFetched bool + // For xlayer: guards one-time ASR address fetch + asrOnce sync.Once + asrAddr common.Address + asrErr error } // lruEntries returns the lazily-initialized LRU cache. @@ -118,26 +72,12 @@ func (c *gameIndexCache) set(idx uint64, e cachedGameEntry) { c.lruEntries().Add(idx, e) // lru.Cache is thread-safe; no mutex needed } -// getASRAddr returns the cached ASR address if available. -func (c *gameIndexCache) getASRAddr() (common.Address, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - return c.asrAddr, c.asrAddrFetched -} - -// setASRAddr stores the ASR address in the cache. -func (c *gameIndexCache) setASRAddr(addr common.Address) { - c.mu.Lock() - defer c.mu.Unlock() - c.asrAddr = addr - c.asrAddrFetched = true -} - // gameStatusAndProposerAt fetches status and proposer of a TeeDisputeGame proxy in one batch call. func (f *DisputeGameFactory) gameStatusAndProposerAt(ctx context.Context, proxyAddr common.Address) (uint8, common.Address, error) { cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) defer cancel() - gameContract := batching.NewBoundContract(&teeGameABI, proxyAddr) + // For xlayer: use snapshot ABI loaded from compiled artifact instead of inline JSON + gameContract := batching.NewBoundContract(teeDisputeGameSnapshotABI, proxyAddr) results, err := f.caller.Call(cCtx, rpcblock.Latest, gameContract.Call("status"), gameContract.Call("proposer"), @@ -150,30 +90,35 @@ func (f *DisputeGameFactory) gameStatusAndProposerAt(ctx context.Context, proxyA // asrAddrFromImpl fetches the AnchorStateRegistry address from the DGF's // game implementation contract for the given gameType. The result is immutable and cached. +// For xlayer: sync.Once guarantees the RPC is issued exactly once even under concurrent callers, +// eliminating the TOCTOU race of the previous check-then-fetch pattern. func (f *DisputeGameFactory) asrAddrFromImpl(ctx context.Context, gameType uint32) (common.Address, error) { - if addr, ok := f.teeCache.getASRAddr(); ok { - return addr, nil - } - // Step 1: get impl address from DGF - cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) - defer cancel() - implsContract := batching.NewBoundContract(&dgfGameImplsABI, f.contract.Addr()) - result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, implsContract.Call("gameImpls", gameType)) - if err != nil { - return common.Address{}, fmt.Errorf("tee-rollup: failed to get game impl for type %d: %w", gameType, err) - } - implAddr := result.GetAddress(0) - // Step 2: call anchorStateRegistry() on the impl - cCtx, cancel = context.WithTimeout(ctx, f.networkTimeout) - defer cancel() - asrContract := batching.NewBoundContract(&teeGameAnchorStateRegistryABI, implAddr) - result, err = f.caller.SingleCall(cCtx, rpcblock.Latest, asrContract.Call("anchorStateRegistry")) - if err != nil { - return common.Address{}, fmt.Errorf("tee-rollup: failed to get anchorStateRegistry from impl %v: %w", implAddr, err) + f.teeCache.asrOnce.Do(func() { + // Step 1: get impl address from DGF + cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + result, err := f.caller.SingleCall(cCtx, rpcblock.Latest, f.contract.Call("gameImpls", gameType)) + if err != nil { + f.teeCache.asrErr = fmt.Errorf("tee-rollup: failed to get game impl for type %d: %w", gameType, err) + return + } + implAddr := result.GetAddress(0) + // Step 2: call anchorStateRegistry() on the impl + // For xlayer: use snapshot ABI loaded from compiled artifact instead of inline JSON + cCtx2, cancel2 := context.WithTimeout(ctx, f.networkTimeout) + defer cancel2() + asrContract := batching.NewBoundContract(teeDisputeGameSnapshotABI, implAddr) + result, err = f.caller.SingleCall(cCtx2, rpcblock.Latest, asrContract.Call("anchorStateRegistry")) + if err != nil { + f.teeCache.asrErr = fmt.Errorf("tee-rollup: failed to get anchorStateRegistry from impl %v: %w", implAddr, err) + return + } + f.teeCache.asrAddr = result.GetAddress(0) + }) + if f.teeCache.asrErr != nil { + return common.Address{}, f.teeCache.asrErr } - addr := result.GetAddress(0) - f.teeCache.setASRAddr(addr) - return addr, nil + return f.teeCache.asrAddr, nil } // isValidParentGame checks AnchorStateRegistry conditions for a candidate parent game. @@ -181,7 +126,7 @@ func (f *DisputeGameFactory) asrAddrFromImpl(ctx context.Context, gameType uint3 func (f *DisputeGameFactory) isValidParentGame(ctx context.Context, asrAddr common.Address, proxyAddr common.Address) (bool, error) { cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) defer cancel() - asrContract := batching.NewBoundContract(&asrValidationABI, asrAddr) + asrContract := batching.NewBoundContract(asrSnapshotABI, asrAddr) results, err := f.caller.Call(cCtx, rpcblock.Latest, asrContract.Call("isGameRespected", proxyAddr), asrContract.Call("isGameBlacklisted", proxyAddr), @@ -213,6 +158,12 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin if gameCount == 0 { return 0, false, nil } + // For xlayer: hoist ASR address lookup before the loop — it is immutable per game type + // and caching avoids a redundant RPC on every iteration. + asrAddr, err := f.asrAddrFromImpl(ctx, gameType) + if err != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to get ASR addr for game type %d: %w", gameType, err) + } scanned := uint64(0) for idx := gameCount - 1; ; idx-- { if scanned >= maxScan { @@ -238,18 +189,32 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin continue } - // fetch status and proposer together (status is always fresh; proposer cached after first fetch) - status, gameProposer, err := f.gameStatusAndProposerAt(ctx, entry.Address) - if err != nil { - return 0, false, fmt.Errorf("tee-rollup: failed to get status/proposer at index %d: %w", idx, err) - } - // cache proposer if not yet cached (immutable — set once in initialize()) - if !entry.ProposerFetched { + // For xlayer: when proposer is already cached, only fetch status (SingleCall); + // otherwise batch-fetch both status and proposer together. + var status uint8 + var gameProposer common.Address + if entry.ProposerFetched { + // proposer is immutable — reuse cached value, only fetch fresh status + cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) + gameContract := batching.NewBoundContract(teeDisputeGameSnapshotABI, entry.Address) + result, callErr := f.caller.SingleCall(cCtx, rpcblock.Latest, gameContract.Call("status")) + cancel() + if callErr != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to get status at index %d: %w", idx, callErr) + } + status = result.GetUint8(0) + gameProposer = entry.Proposer + } else { + // first time seeing this game — batch-fetch status and proposer together + var fetchErr error + status, gameProposer, fetchErr = f.gameStatusAndProposerAt(ctx, entry.Address) + if fetchErr != nil { + return 0, false, fmt.Errorf("tee-rollup: failed to get status/proposer at index %d: %w", idx, fetchErr) + } + // cache proposer (immutable — set once in initialize()) entry.Proposer = gameProposer entry.ProposerFetched = true f.teeCache.set(idx, entry) - } else { - gameProposer = entry.Proposer // use cached value } // contract rejects CHALLENGER_WINS parents (TeeDisputeGame.sol:204) @@ -259,12 +224,6 @@ func (f *DisputeGameFactory) FindLastGameIndex(ctx context.Context, gameType uin } continue } - - // validate against AnchorStateRegistry using impl's ASR address - asrAddr, err := f.asrAddrFromImpl(ctx, gameType) - if err != nil { - return 0, false, fmt.Errorf("tee-rollup: failed to get ASR addr at index %d: %w", idx, err) - } valid, err := f.isValidParentGame(ctx, asrAddr, entry.Address) if err != nil { return 0, false, fmt.Errorf("tee-rollup: failed to validate parent game at index %d: %w", idx, err) diff --git a/packages/contracts-bedrock/snapshots/abi_loader.go b/packages/contracts-bedrock/snapshots/abi_loader.go index 0c701dd0bd099..ad3388fecdec8 100644 --- a/packages/contracts-bedrock/snapshots/abi_loader.go +++ b/packages/contracts-bedrock/snapshots/abi_loader.go @@ -34,8 +34,11 @@ var systemConfig []byte //go:embed abi/CrossL2Inbox.json var crossL2Inbox []byte +//go:embed abi/AnchorStateRegistry.json +var anchorStateRegistry []byte // For xlayer + //go:embed abi/TeeDisputeGame.json -var teeDisputeGame []byte // For XLayer +var teeDisputeGame []byte // For xlayer func LoadDisputeGameFactoryABI() *abi.ABI { return loadABI(disputeGameFactory) @@ -72,7 +75,11 @@ func LoadCrossL2InboxABI() *abi.ABI { return loadABI(crossL2Inbox) } -func LoadTeeDisputeGameABI() *abi.ABI { // For XLayer +func LoadAnchorStateRegistryABI() *abi.ABI { // For xlayer + return loadABI(anchorStateRegistry) +} + +func LoadTeeDisputeGameABI() *abi.ABI { // For xlayer return loadABI(teeDisputeGame) } From 21ed992d9c4f79ad890639301c0227704b6e01a2 Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Wed, 25 Mar 2026 15:07:48 +0800 Subject: [PATCH 21/25] rm mockteerpc --- op-proposer/mock/README.md | 131 --------- op-proposer/mock/cmd/mockteerpc/main.go | 163 ----------- op-proposer/mock/list_games.sh | 258 ------------------ op-proposer/mock/mock_tee_rollup_server.go | 227 --------------- .../mock/mock_tee_rollup_server_test.go | 62 ----- 5 files changed, 841 deletions(-) delete mode 100644 op-proposer/mock/README.md delete mode 100644 op-proposer/mock/cmd/mockteerpc/main.go delete mode 100755 op-proposer/mock/list_games.sh delete mode 100644 op-proposer/mock/mock_tee_rollup_server.go delete mode 100644 op-proposer/mock/mock_tee_rollup_server_test.go diff --git a/op-proposer/mock/README.md b/op-proposer/mock/README.md deleted file mode 100644 index 94f36fc4425af..0000000000000 --- a/op-proposer/mock/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# op-proposer/mock - -Test utilities for op-proposer, including a mock TeeRollup HTTP server. - ---- - -## Mock TeeRollup Server - -Simulates the `GET /v1/chain/confirmed_block_info` REST endpoint provided by a real TeeRollup service. - -**Behavior:** -- Starts at block height 1000 (configurable) -- Increments height by a random delta in **[1, 50]** every second -- `appHash` = `keccak256(big-endian uint64 of height)`, `"0x"` prefix, 66 characters -- `blockHash` = `keccak256(appHash)`, `"0x"` prefix, 66 characters - ---- - -## How to Run - -### Option 1: Direct `go run` (recommended, no build step) - -```bash -# From the op-proposer directory -cd op-proposer -go run ./mock/cmd/mockteerpc - -# Custom listen address and initial height -go run ./mock/cmd/mockteerpc --addr :9000 --init-height 5000 - -# 30% error rate + max 500ms delay -go run ./mock/cmd/mockteerpc --error-rate 0.3 --delay 500ms -``` - -### Option 2: Build then run - -```bash -cd op-proposer -go build -o bin/mockteerpc ./mock/cmd/mockteerpc -./bin/mockteerpc --addr :8090 -``` - -Startup output example: -``` -mock TeeRollup server listening on :8090 -initial height: 1000 -endpoint: GET /v1/chain/confirmed_block_info - -tick: height=1023 delta=23 -tick: height=1058 delta=35 -... -``` - ---- - -## curl Testing - -```bash -# Query current confirmed block info -curl -s http://localhost:8090/v1/chain/confirmed_block_info | jq . -``` - -Example response: -```json -{ - "code": 0, - "message": "OK", - "data": { - "height": 1023, - "appHash": "0x3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b", - "blockHash": "0x1234abcd..." - } -} -``` - -### Observe height growth continuously - -```bash -# Request every 0.5s to observe height changes -watch -n 0.5 'curl -s http://localhost:8090/v1/chain/confirmed_block_info | jq .data' -``` - -### Verify hash computation (Python) - -```python -from eth_hash.auto import keccak -import struct, requests - -r = requests.get("http://localhost:8090/v1/chain/confirmed_block_info").json() -height = r["data"]["height"] - -app_hash = keccak(struct.pack(">Q", height)).hex() -block_hash = keccak(bytes.fromhex(app_hash)).hex() - -print(f"height: {height}") -print(f"appHash: 0x{app_hash}") # 66 chars -print(f"blockHash: 0x{block_hash}") # 66 chars -# Should match API response -``` - ---- - -## Usage in Tests - -```go -import "github.com/ethereum-optimism/optimism/op-proposer/mock" - -func TestMyFeature(t *testing.T) { - srv := mock.NewTeeRollupServer(t) // t.Cleanup closes automatically - - // Server URL - baseURL := srv.Addr() // e.g. "http://127.0.0.1:12345" - - // Get current snapshot (no HTTP request needed) - height, appHash, blockHash := srv.CurrentInfo() - _ = height - _ = appHash - _ = blockHash -} -``` - ---- - -## CLI flags - -| Flag | Default | Description | -|----------------|---------|--------------------------------------------------------------------------| -| `--addr` | `:8090` | Listen address | -| `--init-height`| `1000` | Initial block height | -| `--error-rate` | `0` | Error response probability [0.0, 1.0], 0 means no errors | -| `--delay` | `1s` | Maximum random response delay, actual delay is random in [0, delay] (supports `500ms`, `2s`) | diff --git a/op-proposer/mock/cmd/mockteerpc/main.go b/op-proposer/mock/cmd/mockteerpc/main.go deleted file mode 100644 index d074483445be5..0000000000000 --- a/op-proposer/mock/cmd/mockteerpc/main.go +++ /dev/null @@ -1,163 +0,0 @@ -// Command mockteerpc runs a standalone mock TeeRollup HTTP server for local development and curl testing. -// -// Usage: -// -// go run ./mock/cmd/mockteerpc [--addr :8090] -package main - -import ( - "encoding/binary" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "log" - "math/rand" - "net/http" - "sync" - "time" - - "github.com/ethereum/go-ethereum/crypto" -) - -type response struct { - Code int `json:"code"` - Message string `json:"message"` - Data *data `json:"data"` -} - -type data struct { - Height uint64 `json:"height"` - AppHash string `json:"appHash"` - BlockHash string `json:"blockHash"` -} - -type server struct { - mu sync.RWMutex - height uint64 - errorRate float64 - maxDelay time.Duration -} - -func (s *server) tick() { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - for range ticker.C { - delta := uint64(rand.Intn(50) + 1) - s.mu.Lock() - s.height += delta - s.mu.Unlock() - s.mu.RLock() - log.Printf("tick: height=%d delta=%d", s.height, delta) - s.mu.RUnlock() - } -} - -func computeAppHash(height uint64) [32]byte { - var buf [8]byte - binary.BigEndian.PutUint64(buf[:], height) - return crypto.Keccak256Hash(buf[:]) -} - -func computeBlockHash(appHash [32]byte) [32]byte { - return crypto.Keccak256Hash(appHash[:]) -} - -func (s *server) handleConfirmedBlockInfo(w http.ResponseWriter, r *http.Request) { - start := time.Now() - log.Printf("[mockteerpc] received %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) - - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Random delay in [0, maxDelay]. - if s.maxDelay > 0 { - delay := time.Duration(rand.Int63n(int64(s.maxDelay) + 1)) - time.Sleep(delay) - } - - w.Header().Set("Content-Type", "application/json") - - if s.errorRate > 0 && rand.Float64() < s.errorRate { - writeErrorResponse(w) - log.Printf("[mockteerpc] responded with error (took %s)", time.Since(start)) - return - } - - s.mu.RLock() - h := s.height - s.mu.RUnlock() - - appHash := computeAppHash(h) - blockHash := computeBlockHash(appHash) - - appHashStr := "0x" + hex.EncodeToString(appHash[:]) - resp := response{ - Code: 0, - Message: "OK", - Data: &data{ - Height: h, - AppHash: appHashStr, - BlockHash: "0x" + hex.EncodeToString(blockHash[:]), - }, - } - _ = json.NewEncoder(w).Encode(resp) - log.Printf("[mockteerpc] responded height=%d appHash=%s (took %s)", h, appHashStr[:10]+"...", time.Since(start)) -} - -func writeErrorResponse(w http.ResponseWriter) { - type nullableData struct { - Height *uint64 `json:"height"` - AppHash *string `json:"appHash"` - BlockHash *string `json:"blockHash"` - } - type respNoData struct { - Code int `json:"code"` - Message string `json:"message"` - } - type respWithData struct { - Code int `json:"code"` - Message string `json:"message"` - Data *nullableData `json:"data"` - } - - switch rand.Intn(3) { - case 0: // code != 0, no data field - _ = json.NewEncoder(w).Encode(respNoData{Code: 1, Message: "internal server error"}) - case 1: // code == 0, data is null - _ = json.NewEncoder(w).Encode(respWithData{Code: 0, Message: "OK", Data: nil}) - case 2: // code == 0, data present but all fields null - _ = json.NewEncoder(w).Encode(respWithData{Code: 0, Message: "OK", Data: &nullableData{}}) - } -} - -func main() { - addr := flag.String("addr", ":8090", "listen address") - initHeight := flag.Uint64("init-height", 1000, "initial block height") - errorRate := flag.Float64("error-rate", 0, "probability [0.0, 1.0] of returning an error response") - maxDelay := flag.Duration("delay", time.Second, "maximum random response delay (actual delay is random in [0, delay])") - flag.Parse() - - if *errorRate < 0 || *errorRate > 1 { - log.Fatalf("--error-rate must be in [0.0, 1.0], got %f", *errorRate) - } - - s := &server{height: *initHeight, errorRate: *errorRate, maxDelay: *maxDelay} - go s.tick() - - mux := http.NewServeMux() - mux.HandleFunc("/v1/chain/confirmed_block_info", s.handleConfirmedBlockInfo) - - fmt.Printf("mock TeeRollup server listening on %s\n", *addr) - fmt.Printf("initial height: %d\n", *initHeight) - fmt.Printf("error rate: %.1f%%\n", *errorRate*100) - fmt.Printf("max delay: %s\n", *maxDelay) - fmt.Println("endpoint: GET /v1/chain/confirmed_block_info") - fmt.Println() - - if err := http.ListenAndServe(*addr, mux); err != nil { - log.Fatalf("server error: %v", err) - } -} diff --git a/op-proposer/mock/list_games.sh b/op-proposer/mock/list_games.sh deleted file mode 100755 index 9eff65318c95d..0000000000000 --- a/op-proposer/mock/list_games.sh +++ /dev/null @@ -1,258 +0,0 @@ -#!/usr/bin/env bash -# list_games.sh — Print the last N TEE dispute games from the factory. -# Usage: ./list_games.sh --rpc --factory [--count ] -set -euo pipefail - -usage() { - echo "Usage: $0 --rpc --factory [--count ]" - echo "" - echo " --rpc L1 RPC endpoint (e.g. http://localhost:8545)" - echo " --factory DisputeGameFactory contract address" - echo " --count Number of games to show, newest first (default: 10)" - exit 1 -} - -COUNT=10 -RPC="" -FACTORY="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --rpc) RPC="$2"; shift 2 ;; - --factory) FACTORY="$2"; shift 2 ;; - --count) COUNT="$2"; shift 2 ;; - *) echo "Unknown argument: $1" >&2; usage ;; - esac -done - -[[ -z "$RPC" ]] && { echo "ERROR: --rpc is required" >&2; usage; } -[[ -z "$FACTORY" ]] && { echo "ERROR: --factory is required" >&2; usage; } - -# ── Helpers ────────────────────────────────────────────────────────────────── - -# Map ProposalStatus enum (uint8) to name -# 0=Unchallenged, 1=Challenged, 2=UnchallengedAndValidProofProvided, -# 3=ChallengedAndValidProofProvided, 4=Resolved -proposal_status_name() { - case "$1" in - 0) echo "Unchallenged" ;; - 1) echo "Challenged" ;; - 2) echo "UnchallengedAndValidProofProvided" ;; - 3) echo "ChallengedAndValidProofProvided" ;; - 4) echo "Resolved" ;; - *) echo "Unknown($1)" ;; - esac -} - -# Map GameStatus enum (uint8) to name -# 0=IN_PROGRESS, 1=CHALLENGER_WINS, 2=DEFENDER_WINS -game_status_name() { - case "$1" in - 0) echo "IN_PROGRESS" ;; - 1) echo "CHALLENGER_WINS" ;; - 2) echo "DEFENDER_WINS" ;; - *) echo "Unknown($1)" ;; - esac -} - -# Map BondDistributionMode enum (uint8) to name -# 0=UNDECIDED, 1=NORMAL, 2=REFUND -bond_mode_name() { - case "$1" in - 0) echo "UNDECIDED" ;; - 1) echo "NORMAL" ;; - 2) echo "REFUND" ;; - *) echo "Unknown($1)" ;; - esac -} - -# Format a unix timestamp as human-readable; "0" or "N/A" → "N/A" -fmt_ts() { - local ts="$1" - if [[ "$ts" == "0" || "$ts" == "N/A" ]]; then echo "N/A"; return; fi - date -r "$ts" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ - || date -d "@$ts" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ - || echo "$ts" -} - -# Format a duration in seconds as "Xh Ym Zs" -fmt_duration() { - local secs="$1" - if [[ "$secs" == "N/A" ]]; then echo "N/A"; return; fi - local h=$(( secs / 3600 )) - local m=$(( (secs % 3600) / 60 )) - local s=$(( secs % 60 )) - printf "%dh %dm %ds" "$h" "$m" "$s" -} - -# Print a labeled table row: " Key: Value" -row() { printf " %-32s %s\n" "$1" "$2"; } - -# Section header / footer -section() { echo " ┌─── $1"; } -section_end() { echo " └$(printf '─%.0s' {1..100})┘"; } - -# Tree-style phase node and indented child row for section [3] -phase() { printf " ├─ %s\n" "$1"; } -trow() { printf " │ %-26s %s\n" "$1" "$2"; } - -# Format a unix timestamp field: extract numeric part from cast output, -# then append human-readable time. Returns "N/A" when value is 0 or N/A. -fmt_ts_field() { - local raw="$1" - local num - num=$(echo "$raw" | awk '{print $1}') # strip cast's "[1.774e9]" suffix - if [[ "$num" == "0" || "$num" == "N/A" || -z "$num" ]]; then - echo "N/A" - return - fi - local human - human=$(date -r "$num" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ - || date -d "@$num" "+%Y-%m-%d %H:%M:%S" 2>/dev/null \ - || echo "?") - echo "${num} (${human})" -} - -# ── Main ───────────────────────────────────────────────────────────────────── - -echo "Factory : $FACTORY" -TOTAL=$(cast call "$FACTORY" "gameCount()(uint256)" --rpc-url "$RPC") -echo "Total : $TOTAL games" -echo "" - -if [[ "$TOTAL" -eq 0 ]]; then - echo "No games yet." - exit 0 -fi - -if [[ "$COUNT" -gt "$TOTAL" ]]; then - COUNT="$TOTAL" -fi - -for (( i = TOTAL - 1; i >= TOTAL - COUNT; i-- )); do - - # ── Factory record ────────────────────────────────────────────────────── - INFO=$(cast call "$FACTORY" "gameAtIndex(uint256)(uint8,uint64,address)" "$i" --rpc-url "$RPC") - GAME_TYPE=$(echo "$INFO" | awk 'NR==1') - ADDR=$(echo "$INFO" | awk 'NR==3') - - echo "╔══════════════════════════════════════════════════════════════╗" - printf "║ GAME #%-6s │ GameType: %-33s║\n" "$i" "$GAME_TYPE" - echo "╚══════════════════════════════════════════════════════════════╝" - - # ── Fetch all fields ──────────────────────────────────────────────────── - - # Immutables - MAX_CHAL_DUR=$( cast call "$ADDR" "maxChallengeDuration()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - MAX_PROVE_DUR=$(cast call "$ADDR" "maxProveDuration()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - CHAL_BOND=$( cast call "$ADDR" "challengerBond()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - - # Identity - GAME_CREATOR=$( cast call "$ADDR" "gameCreator()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - PROPOSER_ADDR=$( cast call "$ADDR" "proposer()(address)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - WAS_RESPECTED=$( cast call "$ADDR" "wasRespectedGameTypeWhenCreated()(bool)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - - # Proposal range - L2_BLOCK=$( cast call "$ADDR" "l2BlockNumber()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - PARENT_IDX=$( cast call "$ADDR" "parentIndex()(uint32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - STARTING_BN=$( cast call "$ADDR" "startingBlockNumber()(uint256)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - STARTING_HASH=$( cast call "$ADDR" "startingRootHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - ROOT_CLAIM=$( cast call "$ADDR" "rootClaim()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - BLOCK_HASH=$( cast call "$ADDR" "blockHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - STATE_HASH=$( cast call "$ADDR" "stateHash()(bytes32)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - - # ClaimData struct: (uint32 parentIndex, address counteredBy, address prover, - # bytes32 claim, uint8 status, uint64 deadline) - CLAIM_RAW=$(cast call "$ADDR" "claimData()(uint32,address,address,bytes32,uint8,uint64)" \ - --rpc-url "$RPC" 2>/dev/null || echo "N/A") - if [[ "$CLAIM_RAW" != "N/A" ]]; then - CD_COUNTERED=$( echo "$CLAIM_RAW" | awk 'NR==2') - CD_PROVER=$( echo "$CLAIM_RAW" | awk 'NR==3') - CD_STATUS_RAW=$( echo "$CLAIM_RAW" | awk 'NR==5') - CD_DEADLINE=$( echo "$CLAIM_RAW" | awk 'NR==6') - else - CD_COUNTERED="N/A"; CD_PROVER="N/A"; CD_STATUS_RAW="N/A"; CD_DEADLINE="N/A" - fi - - # Game-level state - GAME_STATUS_RAW=$( cast call "$ADDR" "status()(uint8)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - CREATED_AT_RAW=$( cast call "$ADDR" "createdAt()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - RESOLVED_AT_RAW=$( cast call "$ADDR" "resolvedAt()(uint64)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - BOND_MODE_RAW=$( cast call "$ADDR" "bondDistributionMode()(uint8)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - GAME_OVER=$( cast call "$ADDR" "gameOver()(bool)" --rpc-url "$RPC" 2>/dev/null || echo "N/A") - - # ── Derived values ────────────────────────────────────────────────────── - - CD_STATUS=$(proposal_status_name "$CD_STATUS_RAW") - GAME_STATUS=$(game_status_name "$GAME_STATUS_RAW") - BOND_MODE=$(bond_mode_name "$BOND_MODE_RAW") - - CREATED_AT_FMT=$(fmt_ts_field "$CREATED_AT_RAW") - RESOLVED_AT_FMT=$(fmt_ts_field "$RESOLVED_AT_RAW") - DEADLINE_FMT=$(fmt_ts_field "$(echo "$CD_DEADLINE" | awk '{print $1}')") - - MAX_CHAL_FMT="N/A" - MAX_PROVE_FMT="N/A" - if [[ "$MAX_CHAL_DUR" != "N/A" ]]; then MAX_CHAL_FMT="${MAX_CHAL_DUR}s ($(fmt_duration "$MAX_CHAL_DUR"))"; fi - if [[ "$MAX_PROVE_DUR" != "N/A" ]]; then MAX_PROVE_FMT="${MAX_PROVE_DUR}s ($(fmt_duration "$MAX_PROVE_DUR"))"; fi - - CHAL_BOND_FMT="N/A" - if [[ "$CHAL_BOND" != "N/A" ]]; then - CHAL_BOND_ETH=$(cast to-unit "$CHAL_BOND" ether 2>/dev/null || echo "?") - CHAL_BOND_FMT="${CHAL_BOND_ETH} ETH (${CHAL_BOND} wei)" - fi - - # ── Section 1: Identity & Config ──────────────────────────────────────── - echo "" - section "[1] Identity & Config ──────────────────────────────────────────────────────────────────────────┐" - phase "Identity" - trow "Address:" "$ADDR" - trow "GameType:" "$GAME_TYPE" - trow "GameCreator:" "$GAME_CREATOR" - trow "Proposer:" "$PROPOSER_ADDR" - trow "WasRespectedGameType:" "$WAS_RESPECTED" - phase "Config" - trow "MaxChallengeDuration:" "$MAX_CHAL_FMT" - trow "MaxProveDuration:" "$MAX_PROVE_FMT" - trow "ChallengerBond:" "$CHAL_BOND_FMT" - section_end - - # ── Section 2: Proposal (L2 block range & hashes) ─────────────────────── - echo "" - section "[2] Proposal ──────────────────────────────────────────────────────────────────────────────────┐" - phase "Starting State" - trow "ParentIndex:" "$PARENT_IDX" - trow "StartingBlockNumber:" "$STARTING_BN" - trow "StartingRootHash:" "$STARTING_HASH" - phase "Target State" - trow "L2BlockNumber:" "$L2_BLOCK" - trow "BlockHash:" "$BLOCK_HASH" - trow "StateHash:" "$STATE_HASH" - trow "RootClaim:" "$ROOT_CLAIM" - section_end - - # ── Section 3: Lifecycle State ────────────────────────────────────────── - echo "" - section "[3] Lifecycle State ────────────────────────────────────────────────────────────────────────────┐" - phase "Initialize" - trow "CreatedAt:" "$CREATED_AT_FMT" - phase "Challenge Window" - trow "CounteredBy:" "$CD_COUNTERED" - trow "ClaimData.status:" "$CD_STATUS" - trow "ClaimData.deadline:" "$DEADLINE_FMT" - phase "Prove" - trow "Prover:" "$CD_PROVER" - trow "ClaimData.status:" "$CD_STATUS" - trow "GameOver:" "$GAME_OVER" - phase "Resolve" - trow "GameStatus:" "$GAME_STATUS" - trow "ResolvedAt:" "$RESOLVED_AT_FMT" - phase "CloseGame/ClaimCredit" - trow "BondDistributionMode:" "$BOND_MODE" - section_end - - echo "" -done - -echo "════════════════════════════════════════════════════════════════════════════════════════════════════════" -echo "Done." diff --git a/op-proposer/mock/mock_tee_rollup_server.go b/op-proposer/mock/mock_tee_rollup_server.go deleted file mode 100644 index 5c4c7cf33ca8a..0000000000000 --- a/op-proposer/mock/mock_tee_rollup_server.go +++ /dev/null @@ -1,227 +0,0 @@ -package mock - -import ( - "encoding/binary" - "encoding/hex" - "encoding/json" - "log" - "math/rand" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/ethereum/go-ethereum/crypto" -) - -// TeeRollupResponse is the normal JSON shape returned by GET /v1/chain/confirmed_block_info. -type TeeRollupResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - Height uint64 `json:"height"` - AppHash string `json:"appHash"` - BlockHash string `json:"blockHash"` - } `json:"data"` -} - -// Option configures a TeeRollupServer. -type Option func(*TeeRollupServer) - -// WithErrorRate sets the probability [0.0, 1.0] that any given RPC call returns an error. -// Three error types are equally likely when an error occurs: -// 1. code != 0, no data field, only message. -// 2. code == 0, data is null. -// 3. code == 0, data is present but all fields (height, appHash, blockHash) are null. -func WithErrorRate(rate float64) Option { - return func(s *TeeRollupServer) { - s.errorRate = rate - } -} - -// WithMaxDelay sets the maximum random response delay. Each request sleeps for a -// random duration in [0, maxDelay]. Default is 1s. -func WithMaxDelay(d time.Duration) Option { - return func(s *TeeRollupServer) { - s.maxDelay = d - } -} - -// TeeRollupServer is a mock TeeRollup HTTP server for testing. -// Height starts at 1000 and increments by a random value in [1, 50] every second. -type TeeRollupServer struct { - server *httptest.Server - mu sync.RWMutex - height uint64 - errorRate float64 - maxDelay time.Duration - stopCh chan struct{} - doneCh chan struct{} - closeOnce sync.Once -} - -// NewTeeRollupServer starts the mock server and its background tick goroutine. -// Close() is registered via t.Cleanup so callers need not call it explicitly. -func NewTeeRollupServer(t *testing.T, opts ...Option) *TeeRollupServer { - t.Helper() - - m := &TeeRollupServer{ - height: 1000, - maxDelay: time.Second, - stopCh: make(chan struct{}), - doneCh: make(chan struct{}), - } - for _, opt := range opts { - opt(m) - } - - mux := http.NewServeMux() - mux.HandleFunc("/v1/chain/confirmed_block_info", m.handleConfirmedBlockInfo) - - m.server = httptest.NewServer(mux) - - go m.tick() - - t.Cleanup(m.Close) - return m -} - -// Addr returns the base URL (scheme + host) of the test server. -func (m *TeeRollupServer) Addr() string { - return m.server.URL -} - -// Close stops the tick goroutine and shuts down the HTTP server. -// Safe to call multiple times. -func (m *TeeRollupServer) Close() { - m.closeOnce.Do(func() { - close(m.stopCh) - <-m.doneCh - m.server.Close() - }) -} - -// CurrentInfo returns the current height, appHash and blockHash snapshot. -// Useful for assertions in tests without making an HTTP round-trip. -func (m *TeeRollupServer) CurrentInfo() (height uint64, appHash, blockHash [32]byte) { - m.mu.RLock() - h := m.height - m.mu.RUnlock() - - appHash = ComputeAppHash(h) - blockHash = ComputeBlockHash(appHash) - return h, appHash, blockHash -} - -// tick increments height by random(1, 50) every second until Close() is called. -func (m *TeeRollupServer) tick() { - defer close(m.doneCh) - - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - for { - select { - case <-m.stopCh: - return - case <-ticker.C: - delta := uint64(rand.Intn(50) + 1) // [1, 50] - m.mu.Lock() - m.height += delta - m.mu.Unlock() - } - } -} - -// handleConfirmedBlockInfo serves GET /v1/chain/confirmed_block_info. -func (m *TeeRollupServer) handleConfirmedBlockInfo(w http.ResponseWriter, r *http.Request) { - start := time.Now() - log.Printf("[mockteerpc] received %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) - - // Random delay in [0, maxDelay]. - if m.maxDelay > 0 { - delay := time.Duration(rand.Int63n(int64(m.maxDelay) + 1)) - time.Sleep(delay) - } - - w.Header().Set("Content-Type", "application/json") - - // Inject error according to configured error rate. - if m.errorRate > 0 && rand.Float64() < m.errorRate { - writeErrorResponse(w) - log.Printf("[mockteerpc] responded with error (took %s)", time.Since(start)) - return - } - - m.mu.RLock() - h := m.height - m.mu.RUnlock() - - appHash := ComputeAppHash(h) - blockHash := ComputeBlockHash(appHash) - - resp := TeeRollupResponse{Code: 0, Message: "OK"} - resp.Data.Height = h - resp.Data.AppHash = "0x" + hex.EncodeToString(appHash[:]) - resp.Data.BlockHash = "0x" + hex.EncodeToString(blockHash[:]) - - _ = json.NewEncoder(w).Encode(resp) - log.Printf("[mockteerpc] responded height=%d appHash=%s (took %s)", h, resp.Data.AppHash[:10]+"...", time.Since(start)) -} - -// writeErrorResponse writes one of three error shapes, chosen at random. -// -// Type 0: code != 0, no data field. -// Type 1: code == 0, data is null. -// Type 2: code == 0, data present but all fields are null. -func writeErrorResponse(w http.ResponseWriter) { - type nullableFields struct { - Height *uint64 `json:"height"` - AppHash *string `json:"appHash"` - BlockHash *string `json:"blockHash"` - } - // type 0: no data field - type respNoData struct { - Code int `json:"code"` - Message string `json:"message"` - } - // type 1 & 2: has data field (null or with null fields) - type respWithData struct { - Code int `json:"code"` - Message string `json:"message"` - Data *nullableFields `json:"data"` - } - - switch rand.Intn(3) { - case 0: // code != 0, no data field - _ = json.NewEncoder(w).Encode(respNoData{ - Code: 1, - Message: "internal server error", - }) - case 1: // code == 0, data is null - _ = json.NewEncoder(w).Encode(respWithData{ - Code: 0, - Message: "OK", - Data: nil, - }) - case 2: // code == 0, data present but all fields are null - _ = json.NewEncoder(w).Encode(respWithData{ - Code: 0, - Message: "OK", - Data: &nullableFields{}, // all pointer fields are nil → JSON null - }) - } -} - -// ComputeAppHash returns keccak256(big-endian uint64 bytes of height). -func ComputeAppHash(height uint64) [32]byte { - var buf [8]byte - binary.BigEndian.PutUint64(buf[:], height) - return crypto.Keccak256Hash(buf[:]) -} - -// ComputeBlockHash returns keccak256(appHash[:]). -func ComputeBlockHash(appHash [32]byte) [32]byte { - return crypto.Keccak256Hash(appHash[:]) -} diff --git a/op-proposer/mock/mock_tee_rollup_server_test.go b/op-proposer/mock/mock_tee_rollup_server_test.go deleted file mode 100644 index 81b71ce0dee49..0000000000000 --- a/op-proposer/mock/mock_tee_rollup_server_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package mock_test - -import ( - "encoding/hex" - "encoding/json" - "net/http" - "testing" - "time" - - "github.com/ethereum-optimism/optimism/op-proposer/mock" - "github.com/stretchr/testify/require" -) - -func TestTeeRollupServer_Basic(t *testing.T) { - srv := mock.NewTeeRollupServer(t) - - // --- first request --- - resp, err := http.Get(srv.Addr() + "/v1/chain/confirmed_block_info") //nolint:noctx - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) - - var body mock.TeeRollupResponse - require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) - - require.Equal(t, 0, body.Code) - require.Equal(t, "OK", body.Message) - require.GreaterOrEqual(t, body.Data.Height, uint64(1000)) - require.Equal(t, 66, len(body.Data.AppHash), "appHash should be 0x + 64 hex chars") - require.Equal(t, 66, len(body.Data.BlockHash), "blockHash should be 0x + 64 hex chars") - - firstHeight := body.Data.Height - - // --- wait for at least one tick --- - time.Sleep(1500 * time.Millisecond) - - resp2, err := http.Get(srv.Addr() + "/v1/chain/confirmed_block_info") //nolint:noctx - require.NoError(t, err) - defer resp2.Body.Close() - - var body2 mock.TeeRollupResponse - require.NoError(t, json.NewDecoder(resp2.Body).Decode(&body2)) - - require.Greater(t, body2.Data.Height, firstHeight, "height should have increased after 1.5s") - - // --- verify CurrentInfo height is >= last observed HTTP height --- - h, _, _ := srv.CurrentInfo() - require.GreaterOrEqual(t, h, body2.Data.Height, - "CurrentInfo height should be >= last HTTP response height") - - // --- verify hash determinism --- - appHash := mock.ComputeAppHash(body2.Data.Height) - require.Equal(t, "0x"+hex.EncodeToString(appHash[:]), body2.Data.AppHash) - blockHash := mock.ComputeBlockHash(appHash) - require.Equal(t, "0x"+hex.EncodeToString(blockHash[:]), body2.Data.BlockHash) -} - -func TestTeeRollupServer_DoubleClose(t *testing.T) { - srv := mock.NewTeeRollupServer(t) - // Explicit close before t.Cleanup runs — must not panic. - require.NotPanics(t, srv.Close) -} From db54779dfc4ea78f3bbdc9c78a86cb7c1802bbe8 Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Wed, 25 Mar 2026 14:14:33 +0800 Subject: [PATCH 22/25] fix(op-challenger): remove /v1 prefix from TEE prover task API path Co-Authored-By: Claude Opus 4.6 --- op-challenger/game/tee/prover_client.go | 2 +- op-challenger/game/tee/prover_client_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/op-challenger/game/tee/prover_client.go b/op-challenger/game/tee/prover_client.go index dfa772aef38ad..6251c67911679 100644 --- a/op-challenger/game/tee/prover_client.go +++ b/op-challenger/game/tee/prover_client.go @@ -16,7 +16,7 @@ import ( ) const ( - taskBasePath = "/v1/task/" + taskBasePath = "/task/" ) // Task statuses returned by the TEE Prover. diff --git a/op-challenger/game/tee/prover_client_test.go b/op-challenger/game/tee/prover_client_test.go index b156c56a5d2cb..6f223266def8b 100644 --- a/op-challenger/game/tee/prover_client_test.go +++ b/op-challenger/game/tee/prover_client_test.go @@ -21,7 +21,7 @@ func TestProveSuccess(t *testing.T) { expectedTaskID := "task-abc-123" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) - require.Equal(t, "/v1/task/", r.URL.Path) + require.Equal(t, "/task/", r.URL.Path) require.Equal(t, "application/json", r.Header.Get("Content-Type")) var req ProveRequest @@ -109,7 +109,7 @@ func TestGetTaskFinished(t *testing.T) { proofHex := "0xdeadbeef" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) - require.Equal(t, "/v1/task/task-123", r.URL.Path) + require.Equal(t, "/task/task-123", r.URL.Path) data, _ := json.Marshal(TaskResultData{ Status: TaskStatusFinished, ProofBytes: proofHex, @@ -170,7 +170,7 @@ func TestGetTaskServerError(t *testing.T) { func TestDeleteTask(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodDelete, r.Method) - require.Equal(t, "/v1/task/task-del", r.URL.Path) + require.Equal(t, "/task/task-del", r.URL.Path) resp := ProverResponse{Code: codeOK, Message: "ok"} json.NewEncoder(w).Encode(resp) })) From cec4699f2c5e1e3de8ea9362fd7f6c98ff79b591 Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Wed, 25 Mar 2026 15:22:25 +0800 Subject: [PATCH 23/25] refactor(op-challenger): remove unused DeleteTask from TEE prover client Co-Authored-By: Claude Opus 4.6 --- op-challenger/game/tee/prover_client.go | 36 -------------------- op-challenger/game/tee/prover_client_test.go | 27 --------------- 2 files changed, 63 deletions(-) diff --git a/op-challenger/game/tee/prover_client.go b/op-challenger/game/tee/prover_client.go index 6251c67911679..64af5460e2a0e 100644 --- a/op-challenger/game/tee/prover_client.go +++ b/op-challenger/game/tee/prover_client.go @@ -178,42 +178,6 @@ func (c *ProverClient) GetTaskResult(ctx context.Context, taskID string) (*TaskR return &data, nil } -// DeleteTask terminates and deletes a prove task. -func (c *ProverClient) DeleteTask(ctx context.Context, taskID string) error { - url := c.baseURL + taskBasePath + taskID - httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) - if err != nil { - return fmt.Errorf("failed to create delete request: %w", err) - } - - resp, err := c.httpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("failed to send delete request: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read delete response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("delete request failed with status %d: %s", resp.StatusCode, string(respBody)) - } - - var envelope ProverResponse - if err := json.Unmarshal(respBody, &envelope); err != nil { - return fmt.Errorf("failed to unmarshal delete response: %w", err) - } - - // code=10001 on DELETE means task already gone — treat as success - if envelope.Code != codeOK && envelope.Code != codeInvalidParams { - return fmt.Errorf("delete request returned error code %d: %s", envelope.Code, envelope.Message) - } - - return nil -} - // ProveAndWait submits a proof request and retries until it succeeds or the context is cancelled. // On task failure (Failed status), it re-submits a new task. On non-retryable errors (code=10001), // it returns immediately. The ctx should have a timeout set by the caller to bound total prove time. diff --git a/op-challenger/game/tee/prover_client_test.go b/op-challenger/game/tee/prover_client_test.go index 6f223266def8b..a26659159a459 100644 --- a/op-challenger/game/tee/prover_client_test.go +++ b/op-challenger/game/tee/prover_client_test.go @@ -167,33 +167,6 @@ func TestGetTaskServerError(t *testing.T) { require.Contains(t, err.Error(), "502") } -func TestDeleteTask(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodDelete, r.Method) - require.Equal(t, "/task/task-del", r.URL.Path) - resp := ProverResponse{Code: codeOK, Message: "ok"} - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) - err := client.DeleteTask(context.Background(), "task-del") - require.NoError(t, err) -} - -func TestDeleteTaskNotFound(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // code=10001 on DELETE means task already gone — should be treated as success - resp := ProverResponse{Code: codeInvalidParams, Message: "task not found"} - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) - err := client.DeleteTask(context.Background(), "task-gone") - require.NoError(t, err) -} - func TestProveAndWaitSuccess(t *testing.T) { var getCount atomic.Int32 expectedProof := []byte{0xde, 0xad, 0xbe, 0xef} From efc8ba25e70164d127a7caa5f27d8ada644b880f Mon Sep 17 00:00:00 2001 From: haogeng xie <903932861@qq.com> Date: Wed, 25 Mar 2026 15:31:41 +0800 Subject: [PATCH 24/25] refactor(op-challenger): deduplicate factoryContract param in ActorCreator Co-Authored-By: Claude Opus 4.6 --- op-challenger/game/tee/actor.go | 21 ++++++++++----------- op-challenger/game/tee/register.go | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/op-challenger/game/tee/actor.go b/op-challenger/game/tee/actor.go index f03c3fb3ff3d6..a93ee5cd4e4e8 100644 --- a/op-challenger/game/tee/actor.go +++ b/op-challenger/game/tee/actor.go @@ -58,18 +58,18 @@ type proveResult struct { // that calls ProveAndWait (which retries and polls at the user-configured interval). Act() // checks for results via a non-blocking channel read. type Actor struct { - logger log.Logger - l1Clock ClockReader - contract ProvableContract - proverClient *ProverClient + logger log.Logger + l1Clock ClockReader + contract ProvableContract + proverClient *ProverClient txSender TxSender gameStatusProvider GameStatusProvider factory *contracts.DisputeGameFactoryContract - proveTimeout time.Duration // total timeout for prove attempts including retries - serviceCtx context.Context // service-level ctx, outlives individual Act() calls - proveResultCh chan proveResult // buffered(1), receives result from background goroutine - proveInFlight bool // whether a background prove goroutine is running - proveGivenUp bool // true after prove timeout or non-retryable error — no more retries + proveTimeout time.Duration // total timeout for prove attempts including retries + serviceCtx context.Context // service-level ctx, outlives individual Act() calls + proveResultCh chan proveResult // buffered(1), receives result from background goroutine + proveInFlight bool // whether a background prove goroutine is running + proveGivenUp bool // true after prove timeout or non-retryable error — no more retries } // ActorCreator returns a generic.ActorCreator that creates TEE Actors. @@ -78,7 +78,6 @@ func ActorCreator( l1Clock ClockReader, proverClient *ProverClient, proveTimeout time.Duration, - gameStatusProvider GameStatusProvider, contract ProvableContract, txSender TxSender, factory *contracts.DisputeGameFactoryContract, @@ -90,7 +89,7 @@ func ActorCreator( contract: contract, proverClient: proverClient, txSender: txSender, - gameStatusProvider: gameStatusProvider, + gameStatusProvider: factory, factory: factory, proveTimeout: proveTimeout, serviceCtx: serviceCtx, diff --git a/op-challenger/game/tee/register.go b/op-challenger/game/tee/register.go index 12680c811cb58..df4e8baacc18e 100644 --- a/op-challenger/game/tee/register.go +++ b/op-challenger/game/tee/register.go @@ -47,7 +47,7 @@ func RegisterGameTypes( &client.NoopSyncStatusValidator{}, nil, clients.L1Client(), - ActorCreator(ctx, l1Clock, proverClient, proveTimeout, factoryContract, contract, txSender, factoryContract), + ActorCreator(ctx, l1Clock, proverClient, proveTimeout, contract, txSender, factoryContract), ) }) From 14f23b9eeb1aac9661035e5375cf968a80b09ffb Mon Sep 17 00:00:00 2001 From: JimmyShi22 <417711026@qq.com> Date: Wed, 25 Mar 2026 18:17:04 +0800 Subject: [PATCH 25/25] fix tee dispute game --- packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol b/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol index 14a3807e6deb3..cf3d6c6ad6a97 100644 --- a/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol +++ b/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol @@ -443,6 +443,7 @@ contract TeeDisputeGame is Clone, ISemver, IDisputeGame { function gameCreator() public pure returns (address creator_) { creator_ = _getArgAddress(0x00); } function rootClaim() public pure returns (Claim rootClaim_) { rootClaim_ = Claim.wrap(_getArgBytes32(0x14)); } function l1Head() public pure returns (Hash l1Head_) { l1Head_ = Hash.wrap(_getArgBytes32(0x34)); } + function rootClaimByChainId(uint256) external pure returns (Claim rootClaim_) { rootClaim_ = Claim.wrap(_getArgBytes32(0x14)); } function l2SequenceNumber() public pure returns (uint256 l2SequenceNumber_) { l2SequenceNumber_ = _getArgUint256(0x54); } function l2BlockNumber() public pure returns (uint256 l2BlockNumber_) { l2BlockNumber_ = l2SequenceNumber(); } function parentIndex() public pure returns (uint32 parentIndex_) { parentIndex_ = _getArgUint32(0x74); }