diff --git a/.circleci/config.yml b/.circleci/config.yml index d1c6aaa121d61..5f81c09fe7931 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1494,6 +1494,7 @@ workflows: op-service op-supervisor op-deployer + op-validator op-e2e/system op-e2e/e2eutils op-e2e/opgeth diff --git a/op-service/testutils/mockrpc/matchers.go b/op-service/testutils/mockrpc/matchers.go new file mode 100644 index 0000000000000..d554cd2e66ffe --- /dev/null +++ b/op-service/testutils/mockrpc/matchers.go @@ -0,0 +1,71 @@ +package mockrpc + +import ( + "bytes" + "encoding/json" + "sync" +) + +type ParamsMatcher func(params json.RawMessage) bool + +func AnyParamsMatcher() ParamsMatcher { + return func(params json.RawMessage) bool { + return true + } +} + +func NullMatcher() ParamsMatcher { + return func(params json.RawMessage) bool { + return isNullish(params) + } +} + +var bufPool = &sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func getBuf() *bytes.Buffer { + return bufPool.Get().(*bytes.Buffer) +} + +func putBuf(b *bytes.Buffer) { + b.Reset() + bufPool.Put(b) +} + +// JSONParamsMatcher returns a ParamsMatcher that compares the JSON representation +// of the expected and actual parameters. json.Indent is used to canonicalize the +// JSON representation. Newlines are removed from the input prior to indentation. +func JSONParamsMatcher(expected json.RawMessage) ParamsMatcher { + if isNullish(expected) { + return NullMatcher() + } + + replaced := bytes.ReplaceAll(expected, []byte("\n"), nil) + + expDst := getBuf() + if err := json.Indent(expDst, replaced, "", ""); err != nil { + panic(err) + } + expStr := expDst.String() + putBuf(expDst) + + return func(params json.RawMessage) bool { + paramsDst := getBuf() + defer putBuf(paramsDst) + + replaced := bytes.ReplaceAll(params, []byte("\n"), nil) + if err := json.Indent(paramsDst, replaced, "", ""); err != nil { + return false + } + + actStr := paramsDst.String() + return expStr == actStr + } +} + +func isNullish(params json.RawMessage) bool { + return params == nil || string(params) == "null" +} diff --git a/op-service/testutils/mockrpc/mockrpc.go b/op-service/testutils/mockrpc/mockrpc.go new file mode 100644 index 0000000000000..3304de8c1d6cc --- /dev/null +++ b/op-service/testutils/mockrpc/mockrpc.go @@ -0,0 +1,252 @@ +package mockrpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +var ( + ErrNoMoreCalls = errors.New("no more calls") + ErrNoMatchingCalls = errors.New("no matching calls") +) + +type jsonRPCReq struct { + ID json.RawMessage `json:"id"` + Params json.RawMessage `json:"params"` + Method string `json:"method"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type jsonRPCResp struct { + ID json.RawMessage `json:"id"` + JSONRPC string `json:"jsonrpc"` + Result any `json:"result"` + Error *jsonRPCError `json:"error"` +} + +func newResp(id json.RawMessage, result any) jsonRPCResp { + return jsonRPCResp{ + JSONRPC: "2.0", + ID: id, + Result: result, + } +} + +func newErrResp(id json.RawMessage, errCode int, err error) jsonRPCResp { + return jsonRPCResp{ + JSONRPC: "2.0", + ID: id, + Error: &jsonRPCError{ + Code: errCode, + Message: err.Error(), + }, + } +} + +type rpcCall struct { + Method string `json:"method"` + ParamsMatcher ParamsMatcher `json:"-"` + Params json.RawMessage `json:"params"` + Result any `json:"result"` + Err string `json:"err"` + ErrCode int `json:"errCode"` +} + +type MockRPC struct { + calls []rpcCall + lgr log.Logger + + lis net.Listener + err error +} + +type Option func(*MockRPC) + +func WithExpectationsFile(t *testing.T, path string) Option { + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + + var calls []rpcCall + require.NoError(t, json.NewDecoder(f).Decode(&calls)) + + return func(rpc *MockRPC) { + for _, call := range calls { + rpc.calls = append(rpc.calls, rpcCall{ + Method: call.Method, + ParamsMatcher: JSONParamsMatcher(call.Params), + Result: call.Result, + Err: call.Err, + ErrCode: call.ErrCode, + }) + } + } +} + +func NewMockRPC(t *testing.T, lgr log.Logger, opts ...Option) *MockRPC { + m := &MockRPC{ + lgr: lgr, + } + for _, opt := range opts { + opt(m) + } + + lis, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + m.lis = lis + + srv := &http.Server{ + Handler: m, + } + + errCh := make(chan error, 1) + go func() { + err := srv.Serve(m.lis) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + errCh <- err + }() + + timer := time.NewTimer(10 * time.Millisecond) + select { + case err := <-errCh: + require.NoError(t, err) + case <-timer.C: + } + + t.Cleanup(func() { + require.NoError(t, srv.Shutdown(context.Background())) + require.NoError(t, <-errCh) + }) + + return m +} + +func (m *MockRPC) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if m.err != nil { + m.writeResp(w, newErrResp(nil, -32601, m.err)) + return + } + + if r.Method != http.MethodPost { + m.lgr.Warn("method not allowed", "method", r.Method) + http.Error(w, "only POST requests are allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + m.lgr.Warn("error reading request body", "err", err) + m.writeResp(w, newErrResp(nil, -32700, err)) + return + } + + var reqs []jsonRPCReq + if body[0] == '[' { + if err := json.Unmarshal(body, &reqs); err != nil { + m.lgr.Warn("error unmarshalling request body", "err", err) + m.writeResp(w, newErrResp(nil, -32700, err)) + return + } + } else { + var req jsonRPCReq + if err := json.Unmarshal(body, &req); err != nil { + m.lgr.Warn("error unmarshalling request body", "err", err) + m.writeResp(w, newErrResp(nil, -32700, err)) + return + } + reqs = append(reqs, req) + } + + var resps []jsonRPCResp + for _, req := range reqs { + if len(m.calls) == 0 { + m.err = ErrNoMoreCalls + resps = append(resps, newErrResp(req.ID, -32601, m.err)) + continue + } + + call := m.calls[0] + m.calls = m.calls[1:] + + if call.Method != req.Method { + m.lgr.Warn("method mismatch", "expected", call.Method, "actual", req.Method) + m.err = ErrNoMatchingCalls + resps = append(resps, newErrResp(req.ID, -32601, m.err)) + continue + } + + if !call.ParamsMatcher(req.Params) { + m.lgr.Warn("params did not match", "method", req.Method) + m.err = ErrNoMatchingCalls + resps = append(resps, newErrResp(req.ID, -32602, m.err)) + continue + } + + var resp jsonRPCResp + if call.Err == "" { + resp = newResp(req.ID, call.Result) + } else { + resp = newErrResp(req.ID, call.ErrCode, errors.New(call.Err)) + } + resps = append(resps, resp) + } + + var respBytes []byte + if len(resps) == 1 { + respBytes, err = json.Marshal(resps[0]) + } else { + respBytes, err = json.Marshal(resps) + } + if err != nil { + m.lgr.Warn("error marshalling response", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(respBytes); err != nil { + m.lgr.Warn("error writing response", "err", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (m *MockRPC) Endpoint() string { + return fmt.Sprintf("http://%s", m.lis.Addr().String()) +} + +func (m *MockRPC) AssertExpectations(t *testing.T) { + require.NoError(t, m.err) + require.Empty(t, m.calls) +} + +func (m *MockRPC) writeResp(w http.ResponseWriter, in jsonRPCResp) { + respBytes, err := json.Marshal(in) + if err != nil { + m.lgr.Warn("error marshalling response", "err", err) + return + } + + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(respBytes); err != nil { + m.lgr.Warn("error writing response", "err", err) + return + } +} diff --git a/op-validator/.gitignore b/op-validator/.gitignore new file mode 100644 index 0000000000000..ba077a4031add --- /dev/null +++ b/op-validator/.gitignore @@ -0,0 +1 @@ +bin diff --git a/op-validator/README.md b/op-validator/README.md new file mode 100644 index 0000000000000..98377ee3013b1 --- /dev/null +++ b/op-validator/README.md @@ -0,0 +1,50 @@ +# op-validator + +The op-validator is a tool for validating Optimism chain configurations and deployments. It works by calling into the +StandardValidator smart contracts (StandardValidatorV180 and StandardValidatorV200). These then perform a set of checks, +and return error codes for any issues found. These checks include: + +- Contract implementations and versions +- Proxy configurations +- System parameters +- Cross-component relationships +- Security settings + +## Usage + +The validator supports different protocol versions through subcommands: + +```bash +op-validator validate [version] [flags] +``` + +Where version is one of: + +- `v1.8.0` - For validating protocol version 1.8.0 +- `v2.0.0` - For validating protocol version 2.0.0 + +### Required Flags + +- `--l1-rpc-url`: L1 RPC URL (can also be set via L1_RPC_URL environment variable) +- `--absolute-prestate`: Absolute prestate as hex string +- `--proxy-admin`: Proxy admin address as hex string. This should be a specific chain's proxy admin contract on L1. + It is not the proxy admin owner or the superchain proxy admin. +- `--system-config`: System config proxy address as hex string +- `--l2-chain-id`: L2 chain ID + +### Optional Flags + +- `--fail`: Exit with non-zero code if validation errors are found (defaults to true) + +### Example + +```bash +op-validator validate v2.0.0 \ + --l1-rpc-url "https://mainnet.infura.io/v3/YOUR-PROJECT-ID" \ + --absolute-prestate "0x1234..." \ + --proxy-admin "0xabcd..." \ + --system-config "0xefgh..." \ + --l2-chain-id "10" \ + --fail +``` + diff --git a/op-validator/cmd/main.go b/op-validator/cmd/main.go new file mode 100644 index 0000000000000..8ff7e20501926 --- /dev/null +++ b/op-validator/cmd/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os" + + "github.com/ethereum-optimism/optimism/op-validator/pkg/validations" + + oplog "github.com/ethereum-optimism/optimism/op-service/log" + "github.com/ethereum-optimism/optimism/op-validator/pkg/service" + "github.com/urfave/cli/v2" +) + +const EnvVarPrefix = "OP_VALIDATOR" + +var ( + GitCommit = "" + GitDate = "" + Version = "" +) + +func main() { + app := cli.NewApp() + app.Version = Version + app.Name = "op-validator" + app.Usage = "Optimism Validator Service" + app.Description = "CLI to validate Optimism L2 deployments" + app.Flags = oplog.CLIFlags(EnvVarPrefix) + app.Commands = []*cli.Command{ + { + Name: "validate", + Usage: "Run validation for a specific version", + Subcommands: []*cli.Command{ + versionCmd(validations.VersionV180), + versionCmd(validations.VersionV200), + }, + }, + } + app.Writer = os.Stdout + app.ErrWriter = os.Stderr + + err := app.Run(os.Args) + if err != nil { + fmt.Fprintf(os.Stderr, "Application failed: %v\n", err) + os.Exit(1) + } +} + +func versionCmd(version string) *cli.Command { + return &cli.Command{ + Name: version, + Usage: fmt.Sprintf("Run validation for %s", version), + Flags: append(service.ValidateFlags, oplog.CLIFlags(EnvVarPrefix)...), + Action: func(cliCtx *cli.Context) error { + return service.ValidateCmd(cliCtx, version) + }, + } +} diff --git a/op-validator/justfile b/op-validator/justfile new file mode 100644 index 0000000000000..f2871597dd497 --- /dev/null +++ b/op-validator/justfile @@ -0,0 +1,2 @@ +build: + go build -o bin/op-validator ./cmd/main.go diff --git a/op-validator/pkg/service/flags.go b/op-validator/pkg/service/flags.go new file mode 100644 index 0000000000000..cba8fbd176424 --- /dev/null +++ b/op-validator/pkg/service/flags.go @@ -0,0 +1,80 @@ +package service + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v2" +) + +var ( + L1RPCURLFlag = &cli.StringFlag{ + Name: "l1-rpc-url", + Usage: "L1 RPC URL", + Required: true, + } + AbsolutePrestateFlag = &cli.StringFlag{ + Name: "absolute-prestate", + Usage: "Absolute prestate as hex string", + Required: true, + } + ProxyAdminFlag = &cli.StringFlag{ + Name: "proxy-admin", + Usage: "Proxy admin address as hex string", + Required: true, + } + SystemConfigFlag = &cli.StringFlag{ + Name: "system-config", + Usage: "System config address as hex string", + Required: true, + } + L2ChainIDFlag = &cli.StringFlag{ + Name: "l2-chain-id", + Usage: "L2 chain ID", + Required: true, + } + FailOnErrorFlag = &cli.BoolFlag{ + Name: "fail", + Usage: "Exit with non-zero code if validation errors are found", + Value: true, + } +) + +// ValidateFlags contains all the flags needed for validation +var ValidateFlags = []cli.Flag{ + L1RPCURLFlag, + AbsolutePrestateFlag, + ProxyAdminFlag, + SystemConfigFlag, + L2ChainIDFlag, + FailOnErrorFlag, +} + +// Config represents the configuration for the validator service +type Config struct { + L1RPCURL string + AbsolutePrestate common.Hash + ProxyAdmin common.Address + SystemConfig common.Address + L2ChainID *big.Int +} + +// NewConfig creates a new Config from CLI context +func NewConfig(ctx *cli.Context) (*Config, error) { + absolutePrestate := common.HexToHash(ctx.String(AbsolutePrestateFlag.Name)) + proxyAdmin := common.HexToAddress(ctx.String(ProxyAdminFlag.Name)) + systemConfig := common.HexToAddress(ctx.String(SystemConfigFlag.Name)) + l2ChainID, ok := new(big.Int).SetString(ctx.String(L2ChainIDFlag.Name), 10) + if !ok { + return nil, fmt.Errorf("invalid L2 chain ID: %s", ctx.String(L2ChainIDFlag.Name)) + } + + return &Config{ + L1RPCURL: ctx.String(L1RPCURLFlag.Name), + AbsolutePrestate: absolutePrestate, + ProxyAdmin: proxyAdmin, + SystemConfig: systemConfig, + L2ChainID: l2ChainID, + }, nil +} diff --git a/op-validator/pkg/service/validate.go b/op-validator/pkg/service/validate.go new file mode 100644 index 0000000000000..d5aa7aa53ce1d --- /dev/null +++ b/op-validator/pkg/service/validate.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "fmt" + + oplog "github.com/ethereum-optimism/optimism/op-service/log" + "github.com/ethereum-optimism/optimism/op-validator/pkg/validations" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/rpc" + "github.com/urfave/cli/v2" +) + +func ValidateCmd(cliCtx *cli.Context, release string) error { + logCfg := oplog.ReadCLIConfig(cliCtx) + lgr := oplog.NewLogger(oplog.AppOut(cliCtx), logCfg) + cfg, err := NewConfig(cliCtx) + if err != nil { + return err + } + + errors, err := Validate(cliCtx.Context, lgr, release, cfg) + if err != nil { + return fmt.Errorf("failed to validate: %w", err) + } + + out := validations.Output{ + Errors: errors, + } + + fmt.Println(out.AsMarkdown()) + + if cliCtx.Bool(FailOnErrorFlag.Name) && len(errors) > 0 { + return cli.Exit("Validation errors found", 1) + } + + return nil +} + +func Validate(ctx context.Context, lgr log.Logger, release string, cfg *Config) ([]string, error) { + l1Client, err := rpc.Dial(cfg.L1RPCURL) + if err != nil { + return nil, fmt.Errorf("failed to dial L1 RPC: %w", err) + } + + var validator validations.Validator + switch release { + case validations.VersionV180: + validator = validations.NewV180Validator(l1Client) + case validations.VersionV200: + validator = validations.NewV200Validator(l1Client) + default: + return nil, fmt.Errorf("invalid release: %s", release) + } + + return validator.Validate(ctx, validations.BaseValidatorInput{ + ProxyAdminAddress: cfg.ProxyAdmin, + SystemConfigAddress: cfg.SystemConfig, + AbsolutePrestate: cfg.AbsolutePrestate, + L2ChainID: cfg.L2ChainID, + }) +} diff --git a/op-validator/pkg/validations/addresses.go b/op-validator/pkg/validations/addresses.go new file mode 100644 index 0000000000000..cf80b7002efdc --- /dev/null +++ b/op-validator/pkg/validations/addresses.go @@ -0,0 +1,32 @@ +package validations + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" +) + +const ( + VersionV180 = "v1.8.0" + VersionV200 = "v2.0.0" +) + +var addresses = map[uint64]map[string]common.Address{ + 11155111: { + VersionV180: common.HexToAddress("0x2A788Bb1D32AD0dcEC1A51B7156015Aa90548d8C"), + VersionV200: common.HexToAddress("0x34FFEEF9D42E0EF0d999fBF01E006f745083Fd9b"), + }, +} + +func ValidatorAddress(chainID uint64, version string) (common.Address, error) { + chainAddresses, ok := addresses[chainID] + if !ok { + return common.Address{}, fmt.Errorf("unsupported chain ID: %d", chainID) + } + + address, ok := chainAddresses[version] + if !ok { + return common.Address{}, fmt.Errorf("unsupported version: %s", version) + } + return address, nil +} diff --git a/op-validator/pkg/validations/addresses_test.go b/op-validator/pkg/validations/addresses_test.go new file mode 100644 index 0000000000000..f14370ba97b08 --- /dev/null +++ b/op-validator/pkg/validations/addresses_test.go @@ -0,0 +1,60 @@ +package validations + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestValidatorAddress(t *testing.T) { + tests := []struct { + name string + chainID uint64 + version string + want common.Address + expectError bool + }{ + { + name: "Valid Sepolia v1.8.0", + chainID: 11155111, + version: VersionV180, + want: common.HexToAddress("0x2A788Bb1D32AD0dcEC1A51B7156015Aa90548d8C"), + expectError: false, + }, + { + name: "Valid Sepolia v2.0.0", + chainID: 11155111, + version: VersionV200, + want: common.HexToAddress("0x34FFEEF9D42E0EF0d999fBF01E006f745083Fd9b"), + expectError: false, + }, + { + name: "Invalid Chain ID", + chainID: 999, + version: VersionV180, + want: common.Address{}, + expectError: true, + }, + { + name: "Invalid Version", + chainID: 11155111, + version: "v3.0.0", + want: common.Address{}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidatorAddress(tt.chainID, tt.version) + if tt.expectError { + require.Error(t, err) + require.Equal(t, tt.want, got) + } else { + require.NoError(t, err) + require.Equal(t, tt.want, got) + } + }) + } +} diff --git a/op-validator/pkg/validations/codes.go b/op-validator/pkg/validations/codes.go new file mode 100644 index 0000000000000..6845700d97877 --- /dev/null +++ b/op-validator/pkg/validations/codes.go @@ -0,0 +1,136 @@ +package validations + +var descriptions = map[string]string{ + // SuperchainConfig validations + "SPRCFG-10": "SuperchainConfig version mismatch", + "SPRCFG-20": "SuperchainConfig implementation address mismatch", + "SPRCFG-30": "SuperchainConfig is paused", + + // Protocol Versions validations + "PVER-10": "ProtocolVersions version mismatch", + "PVER-20": "ProtocolVersions implementation address mismatch", + + // ProxyAdmin validations + "PROXYA-10": "ProxyAdmin owner is not set to L1 PAO multisig", + + // SystemConfig validations + "SYSCON-10": "SystemConfig version mismatch", + "SYSCON-20": "SystemConfig gas limit is not set to 60,000,000", + "SYSCON-30": "SystemConfig scalar is not set to 1", + "SYSCON-40": "SystemConfig implementation address mismatch", + "SYSCON-50": "SystemConfig maxResourceLimit is not set to 20,000,000", + "SYSCON-60": "SystemConfig elasticityMultiplier is not set to 10", + "SYSCON-70": "SystemConfig baseFeeMaxChangeDenominator is not set to 8", + "SYSCON-80": "SystemConfig systemTxMaxGas is not set to 1,000,000", + "SYSCON-90": "SystemConfig minimumBaseFee is not set to 1 gwei", + "SYSCON-100": "SystemConfig maximumBaseFee is not set to max uint128", + + // L1 Cross Domain Messenger validations + "L1xDM-10": "L1CrossDomainMessenger version mismatch", + "L1xDM-20": "L1CrossDomainMessenger implementation address mismatch", + "L1xDM-30": "L1CrossDomainMessenger OTHER_MESSENGER address mismatch", + "L1xDM-40": "L1CrossDomainMessenger otherMessenger address mismatch", + "L1xDM-50": "L1CrossDomainMessenger PORTAL address mismatch", + "L1xDM-60": "L1CrossDomainMessenger portal address mismatch", + "L1xDM-70": "L1CrossDomainMessenger superchainConfig address mismatch", + + // L1 Standard Bridge validations + "L1SB-10": "L1StandardBridge version mismatch", + "L1SB-20": "L1StandardBridge implementation address mismatch", + "L1SB-30": "L1StandardBridge MESSENGER address mismatch", + "L1SB-40": "L1StandardBridge messenger address mismatch", + "L1SB-50": "L1StandardBridge OTHER_BRIDGE address mismatch", + "L1SB-60": "L1StandardBridge otherBridge address mismatch", + "L1SB-70": "L1StandardBridge superchainConfig address mismatch", + + // Optimism Mintable ERC20 Factory validations + "MERC20F-10": "OptimismMintableERC20Factory version mismatch", + "MERC20F-20": "OptimismMintableERC20Factory implementation address mismatch", + "MERC20F-30": "OptimismMintableERC20Factory BRIDGE address mismatch", + "MERC20F-40": "OptimismMintableERC20Factory bridge address mismatch", + + // L1 ERC721 Bridge validations + "L721B-10": "L1ERC721Bridge version mismatch", + "L721B-20": "L1ERC721Bridge implementation address mismatch", + "L721B-30": "L1ERC721Bridge OTHER_BRIDGE address mismatch", + "L721B-40": "L1ERC721Bridge otherBridge address mismatch", + "L721B-50": "L1ERC721Bridge MESSENGER address mismatch", + "L721B-60": "L1ERC721Bridge messenger address mismatch", + "L721B-70": "L1ERC721Bridge superchainConfig address mismatch", + + // Optimism Portal validations + "PORTAL-10": "OptimismPortal version mismatch", + "PORTAL-20": "OptimismPortal implementation address mismatch", + "PORTAL-30": "OptimismPortal disputeGameFactory address mismatch", + "PORTAL-40": "OptimismPortal systemConfig address mismatch", + "PORTAL-50": "OptimismPortal superchainConfig address mismatch", + "PORTAL-60": "OptimismPortal guardian address mismatch", + "PORTAL-70": "OptimismPortal paused state mismatch with superchainConfig", + "PORTAL-80": "OptimismPortal l2Sender not set to default value", + + // Dispute Factory validations + "DF-10": "DisputeGameFactory version mismatch", + "DF-20": "DisputeGameFactory implementation address mismatch", + "DF-30": "DisputeGameFactory owner is not set to L1 PAO multisig", + + // Permissioned Dispute Game validations + "PDDG-10": "Permissioned dispute game implementation not found", + "PDDG-20": "Permissioned dispute game version mismatch", + "PDDG-30": "Permissioned dispute game type mismatch", + "PDDG-40": "Permissioned dispute game absolute prestate mismatch", + "PDDG-50": "Permissioned dispute game VM address mismatch", + "PDDG-60": "Permissioned dispute game L2 chain ID mismatch", + "PDDG-70": "Permissioned dispute game L2 block number not set to 0", + "PDDG-80": "Permissioned dispute game clock extension not set to 10800", + "PDDG-90": "Permissioned dispute game split depth not set to 30", + "PDDG-100": "Permissioned dispute game max game depth not set to 73", + "PDDG-110": "Permissioned dispute game max clock duration not set to 302400", + "PDDG-120": "Permissioned dispute game challenger address mismatch", + + // Permissionless Dispute Game validations + "PLDG-10": "Permissionless dispute game implementation not found", + "PLDG-20": "Permissionless dispute game version mismatch", + "PLDG-30": "Permissionless dispute game type mismatch", + "PLDG-40": "Permissionless dispute game absolute prestate mismatch", + "PLDG-50": "Permissionless dispute game VM address mismatch", + "PLDG-60": "Permissionless dispute game L2 chain ID mismatch", + "PLDG-70": "Permissionless dispute game L2 block number not set to 0", + "PLDG-80": "Permissionless dispute game clock extension not set to 10800", + "PLDG-90": "Permissionless dispute game split depth not set to 30", + "PLDG-100": "Permissionless dispute game max game depth not set to 73", + "PLDG-110": "Permissionless dispute game max clock duration not set to 302400", + + // Delayed WETH validations (for both PDDG and PLDG) + "PDDG-DWETH-10": "Permissioned dispute game delayed WETH version mismatch", + "PDDG-DWETH-20": "Permissioned dispute game delayed WETH implementation address mismatch", + "PDDG-DWETH-30": "Permissioned dispute game delayed WETH owner mismatch", + "PDDG-DWETH-40": "Permissioned dispute game delayed WETH delay not set to 1 week", + "PLDG-DWETH-10": "Permissionless dispute game delayed WETH version mismatch", + "PLDG-DWETH-20": "Permissionless dispute game delayed WETH implementation address mismatch", + "PLDG-DWETH-30": "Permissionless dispute game delayed WETH owner mismatch", + "PLDG-DWETH-40": "Permissionless dispute game delayed WETH delay not set to 1 week", + + // Anchor State Registry validations (for both PDDG and PLDG) + "PDDG-ANCHORP-10": "Permissioned dispute game anchor state registry version mismatch", + "PDDG-ANCHORP-20": "Permissioned dispute game anchor state registry implementation address mismatch", + "PDDG-ANCHORP-30": "Permissioned dispute game anchor state registry dispute game factory address mismatch", + "PDDG-ANCHORP-40": "Permissioned dispute game anchor state registry root hash mismatch", + "PDDG-ANCHORP-50": "Permissioned dispute game anchor state registry superchain config address mismatch", + "PLDG-ANCHORP-10": "Permissionless dispute game anchor state registry version mismatch", + "PLDG-ANCHORP-20": "Permissionless dispute game anchor state registry implementation address mismatch", + "PLDG-ANCHORP-30": "Permissionless dispute game anchor state registry dispute game factory address mismatch", + "PLDG-ANCHORP-40": "Permissionless dispute game anchor state registry root hash mismatch", + "PLDG-ANCHORP-50": "Permissionless dispute game anchor state registry superchain config address mismatch", + + // Preimage Oracle validations (for both PDDG and PLDG) + "PDDG-PIMGO-10": "Permissioned dispute game preimage oracle version mismatch", + "PDDG-PIMGO-20": "Permissioned dispute game preimage oracle challenge period not set to 86400", + "PDDG-PIMGO-30": "Permissioned dispute game preimage oracle min proposal size not set to 126000", + "PLDG-PIMGO-10": "Permissionless dispute game preimage oracle version mismatch", + "PLDG-PIMGO-20": "Permissionless dispute game preimage oracle challenge period not set to 86400", + "PLDG-PIMGO-30": "Permissionless dispute game preimage oracle min proposal size not set to 126000", +} + +func ErrorDescription(code string) string { + return descriptions[code] +} diff --git a/op-validator/pkg/validations/codes_test.go b/op-validator/pkg/validations/codes_test.go new file mode 100644 index 0000000000000..c9517b5aa84d9 --- /dev/null +++ b/op-validator/pkg/validations/codes_test.go @@ -0,0 +1,38 @@ +package validations + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestErrorDescription(t *testing.T) { + tests := []struct { + name string + code string + expected string + }{ + { + name: "known error code", + code: "SPRCFG-10", + expected: "SuperchainConfig version mismatch", + }, + { + name: "another known error code", + code: "PORTAL-10", + expected: "OptimismPortal version mismatch", + }, + { + name: "unknown error code", + code: "INVALID-CODE", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ErrorDescription(tt.code) + require.Equal(t, tt.expected, got, "ErrorDescription returned unexpected value for code %q", tt.code) + }) + } +} diff --git a/op-validator/pkg/validations/output.go b/op-validator/pkg/validations/output.go new file mode 100644 index 0000000000000..b88595329cf08 --- /dev/null +++ b/op-validator/pkg/validations/output.go @@ -0,0 +1,37 @@ +package validations + +import ( + "bytes" + + "github.com/olekukonko/tablewriter" +) + +type Output struct { + Errors []string +} + +func (o *Output) AsMarkdown() string { + buf := new(bytes.Buffer) + table := tablewriter.NewWriter(buf) + table.SetHeader([]string{"Error", "Description"}) + table.SetAutoMergeCells(true) + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetCenterSeparator("|") + + if len(o.Errors) == 0 { + table.Append([]string{"No errors.", "No errors."}) + table.Render() + return buf.String() + } + + for _, error := range o.Errors { + errDesc := ErrorDescription(error) + if errDesc == "" { + errDesc = "Unknown error code, please check the implementation for more details." + } + table.Append([]string{error, errDesc}) + } + + table.Render() + return buf.String() +} diff --git a/op-validator/pkg/validations/testdata/validations-v180.json b/op-validator/pkg/validations/testdata/validations-v180.json new file mode 100644 index 0000000000000..d5a64878d0655 --- /dev/null +++ b/op-validator/pkg/validations/testdata/validations-v180.json @@ -0,0 +1,17 @@ +[ + { + "method": "eth_chainId", + "result": "0xaa36a7" + }, + { + "method": "eth_call", + "params": [ + { + "to": "0x2a788bb1d32ad0dcec1a51b7156015aa90548d8c", + "data": "0x30d14888000000000000000000000000189abaaaa82dfc015a588a7dbad6f13b1d3485bc000000000000000000000000034edd2a225f7f429a63e0f1d2084b9e0a93b538038512e02c4c3f7bdaec27d00edf55b7155e0905301e1a88083e4e0a6764d54c0000000000000000000000000000000000000000000000000000000000aa37dc0000000000000000000000000000000000000000000000000000000000000001" + }, + "latest" + ], + "result": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004b504444472d34302c504444472d44574554482d33302c504444472d414e43484f52502d34302c504c44472d34302c504c44472d44574554482d33302c504c44472d414e43484f52502d3430000000000000000000000000000000000000000000" + } +] diff --git a/op-validator/pkg/validations/testdata/validations-v200.json b/op-validator/pkg/validations/testdata/validations-v200.json new file mode 100644 index 0000000000000..0e1635e0444b4 --- /dev/null +++ b/op-validator/pkg/validations/testdata/validations-v200.json @@ -0,0 +1,17 @@ +[ + { + "method": "eth_chainId", + "result": "0xaa36a7" + }, + { + "method": "eth_call", + "params": [ + { + "to": "0x34ffeef9d42e0ef0d999fbf01e006f745083fd9b", + "data": "0x30d14888000000000000000000000000189abaaaa82dfc015a588a7dbad6f13b1d3485bc000000000000000000000000034edd2a225f7f429a63e0f1d2084b9e0a93b538038512e02c4c3f7bdaec27d00edf55b7155e0905301e1a88083e4e0a6764d54c0000000000000000000000000000000000000000000000000000000000aa37dc0000000000000000000000000000000000000000000000000000000000000001" + }, + "latest" + ], + "result": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004b504444472d34302c504444472d44574554482d33302c504444472d414e43484f52502d34302c504c44472d34302c504c44472d44574554482d33302c504c44472d414e43484f52502d3430000000000000000000000000000000000000000000" + } +] diff --git a/op-validator/pkg/validations/validations.go b/op-validator/pkg/validations/validations.go new file mode 100644 index 0000000000000..e509a48c703ac --- /dev/null +++ b/op-validator/pkg/validations/validations.go @@ -0,0 +1,90 @@ +package validations + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/lmittmann/w3" + "github.com/lmittmann/w3/module/eth" + "github.com/lmittmann/w3/w3types" +) + +var validateFunc = w3.MustNewFunc("validate((address proxyAdminAddress,address systemConfigAddress,bytes32 absolutePrestate,uint256 chainID) input,bool allowFailure)", "string") + +type validateFuncArgs struct { + ProxyAdminAddress common.Address + SystemConfigAddress common.Address + AbsolutePrestate common.Hash + ChainID *big.Int +} + +type Validator interface { + Validate(ctx context.Context, input BaseValidatorInput) ([]string, error) +} + +type BaseValidator struct { + client *rpc.Client + release string +} + +type BaseValidatorInput struct { + ProxyAdminAddress common.Address + SystemConfigAddress common.Address + AbsolutePrestate common.Hash + L2ChainID *big.Int +} + +func newBaseValidator(client *rpc.Client, release string) *BaseValidator { + return &BaseValidator{client: client, release: release} +} + +func (v *BaseValidator) Validate(ctx context.Context, input BaseValidatorInput) ([]string, error) { + l1ChainID, err := ethclient.NewClient(v.client).ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get chain ID: %w", err) + } + + validatorAddr, err := ValidatorAddress(l1ChainID.Uint64(), v.release) + if err != nil { + return nil, fmt.Errorf("failed to get validator address: %w", err) + } + + var rawOutput []byte + if err := w3.NewClient(v.client).CallCtx( + ctx, + eth.Call(&w3types.Message{ + To: &validatorAddr, + Func: validateFunc, + Args: []any{ + validateFuncArgs{ + ProxyAdminAddress: input.ProxyAdminAddress, + SystemConfigAddress: input.SystemConfigAddress, + AbsolutePrestate: input.AbsolutePrestate, + ChainID: input.L2ChainID, + }, + true, + }, + }, nil, nil).Returns(&rawOutput), + ); err != nil { + return nil, fmt.Errorf("failed to call validate: %w", err) + } + + var output string + if err := validateFunc.DecodeReturns(rawOutput, &output); err != nil { + return nil, fmt.Errorf("failed to unmarshal output: %w", err) + } + return strings.Split(output, ","), nil +} + +func NewV180Validator(client *rpc.Client) *BaseValidator { + return newBaseValidator(client, VersionV180) +} + +func NewV200Validator(client *rpc.Client) *BaseValidator { + return newBaseValidator(client, VersionV200) +} diff --git a/op-validator/pkg/validations/validations_test.go b/op-validator/pkg/validations/validations_test.go new file mode 100644 index 0000000000000..451d31516dbec --- /dev/null +++ b/op-validator/pkg/validations/validations_test.go @@ -0,0 +1,57 @@ +package validations + +import ( + "context" + "fmt" + "log/slog" + "math/big" + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-service/testutils/mockrpc" +) + +func TestValidate(t *testing.T) { + tests := []struct { + version string + validator func(rpcClient *rpc.Client) Validator + }{ + { + version: "v180", + validator: func(rpcClient *rpc.Client) Validator { + return NewV180Validator(rpcClient) + }, + }, + { + version: "v200", + validator: func(rpcClient *rpc.Client) Validator { + return NewV200Validator(rpcClient) + }, + }, + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + mockRPC := mockrpc.NewMockRPC(t, testlog.Logger(t, slog.LevelInfo), mockrpc.WithExpectationsFile(t, fmt.Sprintf("testdata/validations-%s.json", tt.version))) + rpcClient, err := rpc.Dial(mockRPC.Endpoint()) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + errCodes, err := tt.validator(rpcClient).Validate(ctx, BaseValidatorInput{ + ProxyAdminAddress: common.HexToAddress("0x189aBAAaa82DfC015A588A7dbaD6F13b1D3485Bc"), + SystemConfigAddress: common.HexToAddress("0x034edD2A225f7f429A63E0f1D2084B9E0A93b538"), + AbsolutePrestate: common.HexToHash("0x038512e02c4c3f7bdaec27d00edf55b7155e0905301e1a88083e4e0a6764d54c"), + L2ChainID: big.NewInt(11155420), + }) + require.NoError(t, err) + require.Equal(t, []string{"PDDG-40", "PDDG-DWETH-30", "PDDG-ANCHORP-40", "PLDG-40", "PLDG-DWETH-30", "PLDG-ANCHORP-40"}, errCodes) + mockRPC.AssertExpectations(t) + }) + } +}