diff --git a/op-challenger/config/config.go b/op-challenger/config/config.go index 78c2e0b041bd7..bf2161223a5b5 100644 --- a/op-challenger/config/config.go +++ b/op-challenger/config/config.go @@ -88,6 +88,11 @@ 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 + TeeProveTimeout time.Duration // Total timeout for a single game's prove attempt (including retries) + MaxPendingTx uint64 // Maximum number of pending transactions (0 == no limit) TxMgrConfig txmgr.CLIConfig @@ -225,10 +230,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 +298,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_test.go b/op-challenger/config/config_test.go index 70485d1f1265d..b86ce335f61bd 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 } @@ -508,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 new file mode 100644 index 0000000000000..490a9d0ac8496 --- /dev/null +++ b/op-challenger/config/config_xlayer.go @@ -0,0 +1,62 @@ +package config + +import ( + "errors" + "time" + + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" +) + +var ( + 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 ( + DefaultTeeProvePollInterval = 30 * time.Second + DefaultTeeProveTimeout = 1 * time.Hour +) + +// 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 + } + if c.TeeProvePollInterval <= 0 { + return ErrInvalidTeeProvePollInterval + } + if c.TeeProveTimeout <= 0 { + return ErrInvalidTeeProveTimeout + } + } + 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/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/flags/flags.go b/op-challenger/flags/flags.go index bf758ca9141d1..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...) } @@ -439,11 +440,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 +668,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 + 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 new file mode 100644 index 0000000000000..e4559c038d43f --- /dev/null +++ b/op-challenger/flags/flags_xlayer.go @@ -0,0 +1,39 @@ +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, + } + 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, + } + + 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 { + 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..b0682daac7363 --- /dev/null +++ b/op-challenger/game/fault/contracts/teedisputegame.go @@ -0,0 +1,347 @@ +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" + + // 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. +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 { + // 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)) + } + params.StartBlockHash = parentResults[0].GetHash(0) + params.StartStateHash = parentResults[1].GetHash(0) + + 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) { + 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) + } + 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/fault/contracts/teedisputegame_test.go b/op-challenger/game/fault/contracts/teedisputegame_test.go new file mode 100644 index 0000000000000..90caaf8de705a --- /dev/null +++ b/op-challenger/game/fault/contracts/teedisputegame_test.go @@ -0,0 +1,315 @@ +package contracts + +import ( + "context" + "errors" + "math" + "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 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) + 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/service.go b/op-challenger/game/service.go index 5028588a12eca..10d98872201b1 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 @@ -249,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 +} diff --git a/op-challenger/game/tee/actor.go b/op-challenger/game/tee/actor.go new file mode 100644 index 0000000000000..a93ee5cd4e4e8 --- /dev/null +++ b/op-challenger/game/tee/actor.go @@ -0,0 +1,271 @@ +package tee + +import ( + "context" + "encoding/hex" + "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 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 + 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 +} + +// ActorCreator returns a generic.ActorCreator that creates TEE Actors. +func ActorCreator( + serviceCtx context.Context, + l1Clock ClockReader, + proverClient *ProverClient, + proveTimeout time.Duration, + 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: factory, + factory: factory, + proveTimeout: proveTimeout, + serviceCtx: serviceCtx, + proveResultCh: make(chan proveResult, 1), + }, nil + } +} + +// 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 { + 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 { + // 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) + 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, 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 { + 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 { + 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) + } + + req := ProveRequest{ + 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", + "startBlock", params.StartBlockNum, + "endBlock", params.EndBlockNum, + "game", a.contract.Addr()) + + a.proveInFlight = true + go func() { + // 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} + }() + + 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) + } + if a.proveGivenUp { + status = append(status, "proveGivenUp", true) + } + return status, nil +} + +func decodeProofBytes(hexStr string) ([]byte, error) { + if len(hexStr) >= 2 && hexStr[:2] == "0x" { + hexStr = hexStr[2:] + } + return hex.DecodeString(hexStr) +} diff --git a/op-challenger/game/tee/actor_test.go b/op-challenger/game/tee/actor_test.go new file mode 100644 index 0000000000000..4e7ef6e028694 --- /dev/null +++ b/op-challenger/game/tee/actor_test.go @@ -0,0 +1,349 @@ +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 should set proveGivenUp=true + actor.proveResultCh <- proveResult{err: errors.New("tee prover failed")} + actor.proveInFlight = true + }, + // No prove or resolve tx — error consumed, proveGivenUp=true, no retry + }, + { + 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 TestActorProveErrorSetsGivenUp(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) + 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) { + 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, + proveTimeout: 1 * time.Hour, + 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.go b/op-challenger/game/tee/prover_client.go new file mode 100644 index 0000000000000..16e1454047126 --- /dev/null +++ b/op-challenger/game/tee/prover_client.go @@ -0,0 +1,255 @@ +package tee + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" +) + +const ( + taskBasePath = "/tee/task/" +) + +// Task statuses returned by the TEE Prover. +const ( + 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 { + 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"` +} + +// 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"` +} + +// CreateTaskData is the data returned from POST /tee/task/. +type CreateTaskData struct { + TaskID string `json:"taskId"` +} + +// TaskResultData is the data returned from GET /tee/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. +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 + 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) + } + 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 ProverResponse + if err := json.Unmarshal(respBody, &proveResp); err != nil { + return "", fmt.Errorf("failed to unmarshal prove response: %w", err) + } + + 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 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) + } + + 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 envelope ProverResponse + if err := json.Unmarshal(respBody, &envelope); err != nil { + return nil, fmt.Errorf("failed to unmarshal task response: %w", err) + } + + 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 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() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + result, err := c.GetTaskResult(ctx, taskID) + if err != nil { + // HTTP error or task not found → return to outer retry loop + return nil, err + } + + switch result.Status { + case TaskStatusFinished: + c.logger.Info("TEE prove task finished", "taskID", 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.Status) + } + } + } +} 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..ef0c64df3192a --- /dev/null +++ b/op-challenger/game/tee/prover_client_test.go @@ -0,0 +1,316 @@ +package tee + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "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, "/tee/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.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) + })) + defer server.Close() + + client := NewProverClient(server.URL, time.Second, testlog.Logger(t, log.LvlInfo)) + taskID, err := client.Prove(context.Background(), ProveRequest{ + 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) +} + +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 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, "/tee/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() + + 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.Status) + require.Equal(t, proofHex, result.ProofBytes) +} + +func TestGetTaskRunning(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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, time.Second, testlog.Logger(t, log.LvlInfo)) + result, err := client.GetTaskResult(context.Background(), "task-456") + require.NoError(t, err) + 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 := 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)) + _, err := client.GetTaskResult(context.Background(), "task-789") + require.Error(t, err) + require.Contains(t, err.Error(), "not found") +} + +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) { + 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: + data, _ := json.Marshal(CreateTaskData{TaskID: "task-wait"}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} + json.NewEncoder(w).Encode(resp) + case http.MethodGet: + count := getCount.Add(1) + var data []byte + if count >= 2 { + 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(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) + } + })) + 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) +} + +func TestProveAndWaitContextCancel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + data, _ := json.Marshal(CreateTaskData{TaskID: "task-cancel"}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} + json.NewEncoder(w).Encode(resp) + case http.MethodGet: + data, _ := json.Marshal(TaskResultData{Status: TaskStatusRunning}) + resp := ProverResponse{Code: codeOK, Message: "ok", Data: data} + 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) +} diff --git a/op-challenger/game/tee/register.go b/op-challenger/game/tee/register.go new file mode 100644 index 0000000000000..df4e8baacc18e --- /dev/null +++ b/op-challenger/game/tee/register.go @@ -0,0 +1,65 @@ +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) + 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()) + 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, proveTimeout, 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..dc1cf31a8b75c 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 ) @@ -40,6 +41,7 @@ var SupportedGameTypes = []GameType{ SuperCannonKonaGameType, SuperPermissionedGameType, OptimisticZKGameType, + TeeGameType, // For XLayer } // Set implements the Set method required by the [cli.Generic] interface. @@ -98,6 +100,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-geth b/op-geth index 3402175277067..717a6ab11f3d5 160000 --- a/op-geth +++ b/op-geth @@ -1 +1 @@ -Subproject commit 3402175277067607b2aa72448d723d4aec1d9f6a +Subproject commit 717a6ab11f3d5a9724a5fbb1a1c78d3c781fa1a1 diff --git a/op-proposer/contracts/disputegamefactory.go b/op-proposer/contracts/disputegamefactory.go index ad8ae47aed100..27cda39a271f9 100644 --- a/op-proposer/contracts/disputegamefactory.go +++ b/op-proposer/contracts/disputegamefactory.go @@ -38,6 +38,7 @@ type DisputeGameFactory struct { contract *batching.BoundContract gameABI *abi.ABI networkTimeout time.Duration + teeCache gameIndexCache // For xlayer } func NewDisputeGameFactory(addr common.Address, caller *batching.MultiCaller, networkTimeout time.Duration) *DisputeGameFactory { @@ -133,16 +134,32 @@ 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 == 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, 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) + } else if gameType == TEEGameType { // For xlayer: uses different claimData() ABI with no args + // 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)) + 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) } - // 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) + // Other game types are not handled, claimant and claim remain zero values return gameMetadata{ GameType: gameType, diff --git a/op-proposer/contracts/teedisputegame_xlayer.go b/op-proposer/contracts/teedisputegame_xlayer.go new file mode 100644 index 0000000000000..c6f9f8127f945 --- /dev/null +++ b/op-proposer/contracts/teedisputegame_xlayer.go @@ -0,0 +1,255 @@ +// For xlayer: TEE dispute game helpers — status/proposer getters and parent game index resolution. +package contracts + +import ( + "context" + "fmt" + "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-optimism/optimism/packages/contracts-bedrock/snapshots" + "github.com/ethereum/go-ethereum/common" +) + +// 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 // max DGF entries to scan for parent game + +// GameStatus enum values matching TeeDisputeGame (Types.sol) +const ( + GameStatusInProgress uint8 = 0 + GameStatusChallengerWins uint8 = 1 + GameStatusDefenderWins uint8 = 2 +) + +// 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 { + GameType uint32 + Address common.Address + Proposer common.Address + ProposerFetched bool +} + +// 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 fetched exactly once via asrOnce. +type gameIndexCache struct { + once sync.Once // guards one-time LRU initialization + entries *lru.Cache[uint64, cachedGameEntry] // bounded LRU, thread-safe, lazily initialized + // For xlayer: guards one-time ASR address fetch + asrOnce sync.Once + asrAddr common.Address + asrErr error +} + +// 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) // 4096-entry bounded LRU + }) + return c.entries +} + +func (c *gameIndexCache) get(idx uint64) (cachedGameEntry, bool) { + 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) // lru.Cache is thread-safe; no mutex needed +} + +// 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() + // 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"), + ) + if err != nil { + return 0, common.Address{}, fmt.Errorf("tee-rollup: failed to get status/proposer of game %v: %w", proxyAddr, err) + } + return results[0].GetUint8(0), results[1].GetAddress(0), nil +} + +// 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) { + 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 + } + return f.teeCache.asrAddr, nil +} + +// 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) { + cCtx, cancel := context.WithTimeout(ctx, f.networkTimeout) + defer cancel() + asrContract := batching.NewBoundContract(asrSnapshotABI, 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 ASR validation for %v: %w", proxyAddr, err) + } + 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 +// 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) { + 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 + } + // 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 { + return 0, false, nil + } + scanned++ + + // 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: 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) + } + + // contract rejects CHALLENGER_WINS parents (TeeDisputeGame.sol:204) + if status == GameStatusChallengerWins { + if idx == 0 { + break + } + continue + } + 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 { + // game is retired/blacklisted/not-respected — skip + if idx == 0 { + break + } + continue + } + + // DEFENDER_WINS — accept immediately + if status == GameStatusDefenderWins { + return idx, true, nil + } + + // IN_PROGRESS — only accept if self-proposed + if gameProposer == proposer { + return idx, true, nil + } + // IN_PROGRESS by another proposer — skip + + if idx == 0 { + break + } + } + return 0, false, nil +} diff --git a/op-proposer/flags/flags.go b/op-proposer/flags/flags.go index 51570b1438d56..53b908250eba8 100644 --- a/op-proposer/flags/flags.go +++ b/op-proposer/flags/flags.go @@ -79,7 +79,13 @@ var ( Value: false, EnvVars: prefixEnvVars("WAIT_NODE_SYNC"), } - // X Layer: Genesis height may not be zero + // 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"}, + } + // For xlayer: genesis height GenesisHeight = &cli.Uint64Flag{ Name: "genesis-height", Usage: "The genesis block height to use", @@ -105,7 +111,8 @@ var optionalFlags = []cli.Flag{ DisputeGameTypeFlag, ActiveSequencerCheckDurationFlag, WaitNodeSyncFlag, - GenesisHeight, // X Layer: Genesis height may not be zero + TeeRollupRpcFlag, // For xlayer + GenesisHeight, // For xlayer } func init() { diff --git a/op-proposer/proposer/config.go b/op-proposer/proposer/config.go index e2ab84447b44a..c1a972c8e9187 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" @@ -20,6 +21,7 @@ var ( 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 @@ -83,7 +85,9 @@ type CLIConfig struct { // Whether to wait for the sequencer to sync to a recent block at startup. WaitNodeSync bool - // X Layer: Genesis height may not be zero + // For xlayer: TeeRollup RPC base URL for game type 1960. + TeeRollupRpc string + // For xlayer: genesis height (may be non-zero on XLayer). GenesisHeight uint64 } @@ -121,6 +125,9 @@ func (c *CLIConfig) Check() error { if len(c.SuperNodeRpcs) != 0 { sourceCount++ } + if c.TeeRollupRpc != "" { + sourceCount++ + } if sourceCount > 1 { return ErrConflictingSource } @@ -132,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 == contracts.TEEGameType && c.TeeRollupRpc == "" { + return ErrMissingTeeRollupRpc + } // For unknown game types, allow any source, but require at least one. if sourceCount == 0 { return ErrMissingSource @@ -158,6 +169,7 @@ 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), - GenesisHeight: ctx.Uint64(flags.GenesisHeight.Name), // X Layer: Genesis height may not be zero + TeeRollupRpc: ctx.String(flags.TeeRollupRpcFlag.Name), // For xlayer + GenesisHeight: ctx.Uint64(flags.GenesisHeight.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 19dca3d9a8d9a..41ad4cbfcca2f 100644 --- a/op-proposer/proposer/service.go +++ b/op-proposer/proposer/service.go @@ -9,6 +9,7 @@ import ( "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" @@ -97,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) @@ -128,6 +128,11 @@ 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 — 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)") + } + l1Client, err := dial.DialEthClientWithTimeout(ctx, dial.DefaultDialTimeout, ps.Log, cfg.L1EthRpc) if err != nil { return fmt.Errorf("failed to dial L1 RPC: %w", err) @@ -171,6 +176,14 @@ func (ps *ProposerService) initRPCClients(ctx context.Context, cfg *CLIConfig) e } ps.ProposalSource = source.NewSuperNodeProposalSource(ps.Log, clients...) } + // For xlayer: initialize TeeRollup proposal source + 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 } @@ -263,6 +276,14 @@ func (ps *ProposerService) initDriver() error { return err } ps.driver = driver + + // 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 + } + } + return nil } diff --git a/op-proposer/proposer/service_tee_xlayer.go b/op-proposer/proposer/service_tee_xlayer.go new file mode 100644 index 0000000000000..403a26697ed72 --- /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" +) + +// 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 // not a TEE game type — nothing to wire + } + dgfCaller, ok := driver.dgfContract.(*contracts.DisputeGameFactory) + if !ok { + // 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) + 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.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..754d594d0834c --- /dev/null +++ b/op-proposer/proposer/source/source_tee_rollup_xlayer.go @@ -0,0 +1,253 @@ +// 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" +) + +// 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"` +} + +// 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() +} + +// TeeRollupHTTPClient implements TeeRollupClient using HTTP REST. +type TeeRollupHTTPClient struct { + baseURL string + httpClient *http.Client + cache *lru.Cache[uint64, TeeRollupBlockInfo] +} + +// 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 +} + +// 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() + 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)) + 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 +} + +// 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 +} + +// Close is a no-op (satisfies TeeRollupClient interface). +func (c *TeeRollupHTTPClient) Close() {} + +// 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) // resolves parent DGF game index +} + +// NewTeeRollupProposalSource creates a new TeeRollupProposalSource. +func NewTeeRollupProposalSource(log log.Logger, clients ...TeeRollupClient) *TeeRollupProposalSource { + if len(clients) == 0 { + panic("no TeeRollup clients provided") + } + return &TeeRollupProposalSource{ + log: log, + clients: clients, + } +} + +// 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 +} + +// 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{}, // always zero — no L1 derivation + SafeL2: lowestHeight, + FinalizedL2: lowestHeight, + }, nil +} + +// 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) + + // 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 { + 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{}, // always zero — no L1 derivation + TeeRollupData: &TeeRollupProposalData{ + L2SeqNum: seqNum, + ParentIdx: parentIdx, + BlockHash: info.BlockHash, + StateHash: info.AppHash, + }, + } + return proposal, nil + } + return Proposal{}, fmt.Errorf("tee-rollup: all clients failed for seqNum=%d: %w", seqNum, lastErr) +} + +// 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()...)) +} + +// 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") +} 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/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)"); + } +} 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/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..ad3388fecdec8 100644 --- a/packages/contracts-bedrock/snapshots/abi_loader.go +++ b/packages/contracts-bedrock/snapshots/abi_loader.go @@ -34,6 +34,12 @@ 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 + func LoadDisputeGameFactoryABI() *abi.ABI { return loadABI(disputeGameFactory) } @@ -69,6 +75,14 @@ func LoadCrossL2InboxABI() *abi.ABI { return loadABI(crossL2Inbox) } +func LoadAnchorStateRegistryABI() *abi.ABI { // For xlayer + return loadABI(anchorStateRegistry) +} + +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) 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..cf3d6c6ad6a97 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol @@ -0,0 +1,486 @@ +// 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 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); } + 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]; + } +}