diff --git a/config.go b/config.go index 425c2bd..bd7560e 100644 --- a/config.go +++ b/config.go @@ -434,6 +434,50 @@ type EVMConfig struct { WorkerPoolSize int `toml:"worker_pool_size"` WorkerQueueSize int `toml:"worker_queue_size"` + MaxFilters uint64 `toml:"max_filters"` + MaxBlockFilterHashes uint64 `toml:"max_block_filter_hashes"` + MaxSubscriptionsLogs uint64 `toml:"max_subscriptions_logs"` + MaxLogBytes int64 `toml:"max_log_bytes"` + + EnableSimulation bool `toml:"enable_simulation"` + MaxEstimateGasCalls int `toml:"max_estimate_gas_calls"` + MaxStateOverrideAccounts int `toml:"max_state_override_accounts"` + MaxStateOverrideSlots int `toml:"max_state_override_slots"` + + MaxTraceStructLogBytes uint64 `toml:"max_trace_struct_log_bytes"` + TraceAllowedTracers []string `toml:"trace_allowed_tracers"` + TraceAllowJSTracers bool `toml:"trace_allow_js_tracers"` + EnableParallelizedBlockTrace bool `toml:"enable_parallelized_block_trace"` + + // Trace baking re-executes each committed block in the background and + // caches the trace output so debug_trace* can serve from cache. + TraceBakeEnabled bool `toml:"trace_bake_enabled"` + TraceBakeWorkers int `toml:"trace_bake_workers"` + TraceBakeQueueSize int `toml:"trace_bake_queue_size"` + TraceBakeTracers []string `toml:"trace_bake_tracers"` + TraceBakeWindowBlocks int64 `toml:"trace_bake_window_blocks"` + TraceBakeUseSnapshot bool `toml:"trace_bake_use_snapshot"` + TraceBakeSnapshotWindow int64 `toml:"trace_bake_snapshot_window"` + + // Per-IP rate limiting and request admission on the EVM HTTP plane. + RateLimitingEnabled bool `toml:"rate_limiting_enabled"` + IPRateLimitRPS float64 `toml:"ip_rate_limit_rps"` + IPRateLimitBurst int `toml:"ip_rate_limit_burst"` + TrustedProxyCIDRs []string `toml:"trusted_proxy_cidrs"` + + BatchRequestLimit int `toml:"batch_request_limit"` + BatchResponseMaxSize int `toml:"batch_response_max_size"` + MaxRequestBodyBytes int64 `toml:"max_request_body_bytes"` + MaxConcurrentRequestBytes int64 `toml:"max_concurrent_request_bytes"` + BodyReadIdleTimeout Duration `toml:"body_read_idle_timeout"` + WSAdmissionTimeout Duration `toml:"ws_admission_timeout"` + MaxOpenConnections int `toml:"max_open_connections"` + + // RPCMethodTimeouts entries are "method=duration" (e.g. "eth_call=60s"); + // a method ending in "*" matches every method with that prefix. + RPCDefaultTimeout Duration `toml:"rpc_default_timeout"` + RPCMethodTimeouts []string `toml:"rpc_method_timeouts"` + Query EVMQueryConfig `toml:"query"` Replay EVMReplayConfig `toml:"replay"` BlockTest EVMBlockTestConfig `toml:"block_test"` @@ -586,3 +630,18 @@ func defaultConcurrencyWorkers() int { func defaultEVMWorkerPoolSize() int { return min(64, runtime.NumCPU()*2) } + +// nativeTraceTracers are the native geth tracer names seid accepts in +// evm.trace_allowed_tracers and evm.trace_bake_tracers. +var nativeTraceTracers = map[string]bool{ + "4byteTracer": true, + "callTracer": true, + "flatCallTracer": true, + "muxTracer": true, + "noopTracer": true, + "prestateTracer": true, +} + +func defaultTraceAllowedTracers() []string { + return []string{"callTracer", "prestateTracer", "flatCallTracer", "4byteTracer", "noopTracer", "muxTracer"} +} diff --git a/defaults.go b/defaults.go index 095c4a1..c262659 100644 --- a/defaults.go +++ b/defaults.go @@ -179,6 +179,33 @@ func baseDefaults() *SeiConfig { RPCStatsInterval: Dur(10 * time.Second), WorkerPoolSize: defaultEVMWorkerPoolSize(), WorkerQueueSize: 1000, + MaxFilters: 1000, + MaxBlockFilterHashes: 1000, + MaxSubscriptionsLogs: 1000, + MaxLogBytes: 64 << 20, // 64 MiB + EnableSimulation: true, + MaxEstimateGasCalls: 100, + MaxStateOverrideAccounts: 100, + MaxStateOverrideSlots: 1000, + MaxTraceStructLogBytes: 32 << 20, // 32 MiB + TraceAllowedTracers: defaultTraceAllowedTracers(), + TraceBakeWorkers: 1, + TraceBakeQueueSize: 4096, + TraceBakeTracers: []string{"callTracer"}, + TraceBakeSnapshotWindow: 64, + RateLimitingEnabled: true, + IPRateLimitRPS: 200, + IPRateLimitBurst: 200, + TrustedProxyCIDRs: []string{}, + BatchRequestLimit: 100, + BatchResponseMaxSize: 25_000_000, // 25 MB + MaxRequestBodyBytes: 5 << 20, // 5 MiB + MaxConcurrentRequestBytes: 128 << 20, // 128 MiB + BodyReadIdleTimeout: Dur(10 * time.Second), + WSAdmissionTimeout: Dur(30 * time.Second), + MaxOpenConnections: 2000, + RPCDefaultTimeout: Dur(30 * time.Second), + RPCMethodTimeouts: []string{"eth_estimateGasAfterCalls=5m"}, Query: EVMQueryConfig{ GasLimit: 300_000, }, diff --git a/enrichments.go b/enrichments.go index 35cc9f2..ca350ca 100644 --- a/enrichments.go +++ b/enrichments.go @@ -328,6 +328,128 @@ func DefaultEnrichments() map[string][]FieldOption { "evm.worker_pool_size": { WithDescription("Number of workers in the EVM RPC worker pool. 0 for default."), }, + "evm.max_filters": { + WithDescription("Maximum active eth_newFilter and eth_newBlockFilter filters. Must be > 0."), + }, + "evm.max_block_filter_hashes": { + WithDescription("Maximum unpolled block hashes retained per eth_newBlockFilter filter. Must be > 0."), + }, + "evm.max_subscriptions_logs": { + WithDescription("Maximum concurrent logs subscriptions."), + }, + "evm.max_log_bytes": { + WithDescription("Maximum estimated heap bytes of matched logs a single eth_getLogs query may " + + "materialize. Non-positive uses the receipt store default."), + WithUnit("bytes"), + }, + "evm.enable_simulation": { + WithDescription("Simulate eth_sendRawTransaction before broadcasting it."), + }, + "evm.max_estimate_gas_calls": { + WithDescription("Maximum calls allowed in an eth_estimateGasAfterCalls request."), + }, + "evm.max_state_override_accounts": { + WithDescription("Maximum accounts allowed in a single state override."), + }, + "evm.max_state_override_slots": { + WithDescription("Maximum storage slots allowed per account in a state override."), + }, + "evm.max_trace_struct_log_bytes": { + WithDescription("Maximum retained struct-logger output per traced transaction on debug_trace*. 0 for " + + "unlimited."), + WithUnit("bytes"), + }, + "evm.trace_allowed_tracers": { + WithDescription("Native tracers callers may request on debug_trace*. JavaScript tracers are never " + + "allowed through this list."), + }, + "evm.trace_allow_js_tracers": { + WithDescription("Allow callers to supply JavaScript tracer source, which runs request-supplied code " + + "in-process."), + }, + "evm.enable_parallelized_block_trace": { + WithDescription("Use the parallelized default debug_traceBlock* path."), + }, + "evm.trace_bake_enabled": { + WithDescription("Re-execute each committed block in the background and cache traces for debug_trace*. " + + "RPC nodes only."), + }, + "evm.trace_bake_workers": { + WithDescription("Re-execution goroutines for trace baking."), + }, + "evm.trace_bake_queue_size": { + WithDescription("In-flight height queue size for trace baking."), + }, + "evm.trace_bake_tracers": { + WithDescription("Native tracers whose output trace baking caches."), + }, + "evm.trace_bake_window_blocks": { + WithDescription("Rolling window of baked traces to keep. 0 disables pruning."), + WithUnit("blocks"), + }, + "evm.trace_bake_use_snapshot": { + WithDescription("Bake traces from an in-memory memiavl snapshot instead of the state store. Requires " + + "memiavl_only write mode; falls back otherwise."), + }, + "evm.trace_bake_snapshot_window": { + WithDescription("Recent memiavl snapshots kept for trace baking."), + WithUnit("blocks"), + }, + "evm.rate_limiting_enabled": { + WithDescription("Master switch for the EVM HTTP rate-limit admission middleware. When false, requests " + + "bypass all rejections from that layer."), + }, + "evm.ip_rate_limit_rps": { + WithDescription("Per-IP sustained request rate. 0 disables the token bucket."), + WithUnit("requests/sec"), + }, + "evm.ip_rate_limit_burst": { + WithDescription("Per-IP burst size. Must be >= batch_request_limit when rate limiting is enabled. 0 " + + "disables the token bucket."), + WithUnit("requests"), + }, + "evm.trusted_proxy_cidrs": { + WithDescription("CIDRs whose X-Forwarded-For headers are trusted when resolving the client IP for rate " + + "limiting. Empty trusts no proxy."), + }, + "evm.batch_request_limit": { + WithDescription("Maximum requests in a single JSON-RPC batch (HTTP and WebSocket). 0 disables the limit."), + WithUnit("requests"), + }, + "evm.batch_response_max_size": { + WithDescription("Maximum bytes returned from a batched JSON-RPC call. 0 disables the limit."), + WithUnit("bytes"), + }, + "evm.max_request_body_bytes": { + WithDescription("Maximum size of a single HTTP request body or WebSocket frame. 0 uses the go-ethereum " + + "default (5 MiB)."), + WithUnit("bytes"), + }, + "evm.max_concurrent_request_bytes": { + WithDescription("Total request-body bytes admitted concurrently, budgeted separately for HTTP and " + + "WebSocket. 0 disables the limit."), + WithUnit("bytes"), + }, + "evm.body_read_idle_timeout": { + WithDescription("Maximum idle time between HTTP request body chunks. 0 disables the idle guard."), + }, + "evm.ws_admission_timeout": { + WithDescription("How long a WebSocket connection waits for concurrent-byte budget before it is closed. " + + "0 uses the go-ethereum default (30s)."), + }, + "evm.max_open_connections": { + WithDescription("Maximum simultaneously accepted connections per EVM listener (HTTP and WebSocket " + + "each). 0 disables the limit."), + WithUnit("connections"), + }, + "evm.rpc_default_timeout": { + WithDescription("Deadline for RPC methods without a Sei-specific timeout. 0 disables the default " + + "deadline."), + }, + "evm.rpc_method_timeouts": { + WithDescription("Per-method deadline overrides as method=duration (e.g. eth_call=60s). A trailing * " + + "matches a method prefix."), + }, // --------------------------------------------------------------- // API diff --git a/evm_test.go b/evm_test.go new file mode 100644 index 0000000..e07b3d8 --- /dev/null +++ b/evm_test.go @@ -0,0 +1,229 @@ +package seiconfig + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// newEVMKeys are the [evm] keys added after nodes already had app.toml files +// rendered without them. +var newEVMKeys = []string{ + "max_filters", "max_block_filter_hashes", "max_subscriptions_logs", "max_log_bytes", + "enable_simulation", "max_estimate_gas_calls", "max_state_override_accounts", "max_state_override_slots", + "max_trace_struct_log_bytes", "trace_allowed_tracers", "trace_allow_js_tracers", "enable_parallelized_block_trace", + "trace_bake_enabled", "trace_bake_workers", "trace_bake_queue_size", "trace_bake_tracers", + "trace_bake_window_blocks", "trace_bake_use_snapshot", "trace_bake_snapshot_window", + "rate_limiting_enabled", "ip_rate_limit_rps", "ip_rate_limit_burst", "trusted_proxy_cidrs", + "batch_request_limit", "batch_response_max_size", "max_request_body_bytes", "max_concurrent_request_bytes", + "body_read_idle_timeout", "ws_admission_timeout", "max_open_connections", + "rpc_default_timeout", "rpc_method_timeouts", +} + +func TestEVMNewKeys_RoundTrip(t *testing.T) { + cfg := DefaultForMode(ModeFull) + e := &cfg.EVM + e.MaxFilters = 50 + e.MaxBlockFilterHashes = 60 + e.MaxSubscriptionsLogs = 70 + e.MaxLogBytes = 1 << 20 + e.EnableSimulation = false + e.MaxEstimateGasCalls = 7 + e.MaxStateOverrideAccounts = 8 + e.MaxStateOverrideSlots = 9 + e.MaxTraceStructLogBytes = 0 + e.TraceAllowedTracers = []string{"callTracer"} + e.TraceAllowJSTracers = true + e.EnableParallelizedBlockTrace = true + e.TraceBakeEnabled = true + e.TraceBakeWorkers = 4 + e.TraceBakeQueueSize = 128 + e.TraceBakeTracers = []string{"callTracer", "prestateTracer"} + e.TraceBakeWindowBlocks = 5000 + e.TraceBakeUseSnapshot = true + e.TraceBakeSnapshotWindow = 32 + e.RateLimitingEnabled = false + e.IPRateLimitRPS = 12.5 + e.IPRateLimitBurst = 500 + e.TrustedProxyCIDRs = []string{"10.0.0.0/8", "fd00::/8"} + e.BatchRequestLimit = 50 + e.BatchResponseMaxSize = 1000 + e.MaxRequestBodyBytes = 2048 + e.MaxConcurrentRequestBytes = 4096 + e.BodyReadIdleTimeout = Dur(3 * time.Second) + e.WSAdmissionTimeout = Dur(4 * time.Second) + e.MaxOpenConnections = 0 + e.RPCDefaultTimeout = Dur(0) + e.RPCMethodTimeouts = []string{"eth_call=60s", "debug_trace*=0"} + + dir := t.TempDir() + if err := WriteConfigToDir(cfg, dir); err != nil { + t.Fatalf("WriteConfigToDir: %v", err) + } + loaded, err := ReadConfigFromDir(dir) + if err != nil { + t.Fatalf("ReadConfigFromDir: %v", err) + } + if !reflect.DeepEqual(loaded.EVM, cfg.EVM) { + t.Errorf("evm round-trip mismatch:\n got %+v\n want %+v", loaded.EVM, cfg.EVM) + } +} + +// TestEVMNewKeys_AbsentReadAsDefaults covers a node whose app.toml was rendered +// before these keys existed. They must read back at seid's defaults, not the +// zero value: re-rendering zeros would turn off rate limiting and write +// max_filters = 0, which seid refuses to start with. +func TestEVMNewKeys_AbsentReadAsDefaults(t *testing.T) { + dir := t.TempDir() + want := DefaultForMode(ModeFull) + if err := WriteConfigToDir(want, dir); err != nil { + t.Fatalf("WriteConfigToDir: %v", err) + } + stripAppTomlKeys(t, dir, newEVMKeys) + + loaded, err := ReadConfigFromDir(dir) + if err != nil { + t.Fatalf("ReadConfigFromDir: %v", err) + } + if !reflect.DeepEqual(loaded.EVM, want.EVM) { + t.Errorf("absent keys did not read as defaults:\n got %+v\n want %+v", loaded.EVM, want.EVM) + } + if vr := Validate(loaded); vr.HasErrors() { + t.Errorf("config read from an older app.toml fails validation: %v", vr.Errors()) + } +} + +// TestEVMNewKeys_ExplicitEmptyListNotSeeded guards the seeding: an explicit +// empty list in the file must not be replaced by (or merged with) the default. +func TestEVMNewKeys_ExplicitEmptyListNotSeeded(t *testing.T) { + cfg := DefaultForMode(ModeFull) + cfg.EVM.TraceAllowedTracers = []string{} + cfg.EVM.RPCMethodTimeouts = []string{"eth_call=1s"} + + dir := t.TempDir() + if err := WriteConfigToDir(cfg, dir); err != nil { + t.Fatalf("WriteConfigToDir: %v", err) + } + loaded, err := ReadConfigFromDir(dir) + if err != nil { + t.Fatalf("ReadConfigFromDir: %v", err) + } + if got := loaded.EVM.TraceAllowedTracers; got == nil || len(got) != 0 { + t.Errorf("trace_allowed_tracers: got %#v, want empty non-nil", got) + } + if got := loaded.EVM.RPCMethodTimeouts; !reflect.DeepEqual(got, []string{"eth_call=1s"}) { + t.Errorf("rpc_method_timeouts: got %v, want [eth_call=1s]", got) + } +} + +func TestEVMNewKeys_Overrides(t *testing.T) { + cfg := DefaultForMode(ModeFull) + err := ApplyOverrides(cfg, map[string]string{ + "evm.rate_limiting_enabled": "false", + "evm.ip_rate_limit_rps": "50.5", + "evm.ip_rate_limit_burst": "300", + "evm.trusted_proxy_cidrs": "10.0.0.0/8,172.16.0.0/12", + "evm.rpc_method_timeouts": "eth_call=10s", + "evm.ws_admission_timeout": "15s", + }) + if err != nil { + t.Fatalf("ApplyOverrides: %v", err) + } + e := cfg.EVM + if e.RateLimitingEnabled { + t.Error("rate_limiting_enabled: got true, want false") + } + if e.IPRateLimitRPS != 50.5 { + t.Errorf("ip_rate_limit_rps: got %v, want 50.5", e.IPRateLimitRPS) + } + if e.IPRateLimitBurst != 300 { + t.Errorf("ip_rate_limit_burst: got %d, want 300", e.IPRateLimitBurst) + } + if !reflect.DeepEqual(e.TrustedProxyCIDRs, []string{"10.0.0.0/8", "172.16.0.0/12"}) { + t.Errorf("trusted_proxy_cidrs: got %v", e.TrustedProxyCIDRs) + } + if !reflect.DeepEqual(e.RPCMethodTimeouts, []string{"eth_call=10s"}) { + t.Errorf("rpc_method_timeouts: got %v", e.RPCMethodTimeouts) + } + if e.WSAdmissionTimeout.Duration != 15*time.Second { + t.Errorf("ws_admission_timeout: got %s, want 15s", e.WSAdmissionTimeout) + } +} + +func TestValidateEVM_NewKeys(t *testing.T) { + tests := []struct { + name string + mutate func(e *EVMConfig) + wantErr bool + }{ + {"defaults", func(e *EVMConfig) {}, false}, + {"zero max_filters", func(e *EVMConfig) { e.MaxFilters = 0 }, true}, + {"zero max_block_filter_hashes", func(e *EVMConfig) { e.MaxBlockFilterHashes = 0 }, true}, + {"negative max_open_connections", func(e *EVMConfig) { e.MaxOpenConnections = -1 }, true}, + {"negative body_read_idle_timeout", func(e *EVMConfig) { e.BodyReadIdleTimeout = Dur(-time.Second) }, true}, + {"negative rpc_default_timeout", func(e *EVMConfig) { e.RPCDefaultTimeout = Dur(-time.Second) }, true}, + {"js tracer name in allowlist", func(e *EVMConfig) { e.TraceAllowedTracers = []string{"myJSTracer"} }, true}, + {"unknown bake tracer", func(e *EVMConfig) { e.TraceBakeTracers = []string{"nope"} }, true}, + {"empty tracer allowlist", func(e *EVMConfig) { e.TraceAllowedTracers = []string{} }, false}, + {"invalid trusted proxy CIDR", func(e *EVMConfig) { e.TrustedProxyCIDRs = []string{"10.0.0.1"} }, true}, + {"valid trusted proxy CIDRs", func(e *EVMConfig) { e.TrustedProxyCIDRs = []string{"10.0.0.0/8", "::1/128"} }, false}, + {"method timeout missing =", func(e *EVMConfig) { e.RPCMethodTimeouts = []string{"eth_call"} }, true}, + {"method timeout bad duration", func(e *EVMConfig) { e.RPCMethodTimeouts = []string{"eth_call=soon"} }, true}, + {"method timeout negative", func(e *EVMConfig) { e.RPCMethodTimeouts = []string{"eth_call=-1s"} }, true}, + {"method timeout bare wildcard", func(e *EVMConfig) { e.RPCMethodTimeouts = []string{"*=1s"} }, true}, + {"method timeout inner wildcard", func(e *EVMConfig) { e.RPCMethodTimeouts = []string{"debug_*trace=1s"} }, true}, + {"method timeout prefix and unitless", func(e *EVMConfig) { + e.RPCMethodTimeouts = []string{"debug_trace*=0", "eth_call=5"} + }, false}, + {"burst below batch limit", func(e *EVMConfig) { e.IPRateLimitBurst = 50; e.BatchRequestLimit = 100 }, true}, + {"burst below batch limit, rate limiting off", func(e *EVMConfig) { + e.IPRateLimitBurst = 50 + e.BatchRequestLimit = 100 + e.RateLimitingEnabled = false + }, false}, + {"burst zero disables bucket", func(e *EVMConfig) { e.IPRateLimitBurst = 0 }, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultForMode(ModeFull) + tt.mutate(&cfg.EVM) + if got := Validate(cfg).HasErrors(); got != tt.wantErr { + t.Errorf("HasErrors: got %v, want %v (diagnostics: %v)", got, tt.wantErr, Validate(cfg).Diagnostics) + } + }) + } +} + +// stripAppTomlKeys removes the given keys from homeDir/config/app.toml, +// simulating a file rendered before they existed. +func stripAppTomlKeys(t *testing.T, homeDir string, keys []string) { + t.Helper() + path := filepath.Join(homeDir, configDir, appTomlFile) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read app.toml: %v", err) + } + drop := make(map[string]bool, len(keys)) + for _, k := range keys { + drop[k] = true + } + var kept []string + removed := 0 + for line := range strings.SplitSeq(string(data), "\n") { + key, _, _ := strings.Cut(strings.TrimSpace(line), "=") + if drop[strings.TrimSpace(key)] { + removed++ + continue + } + kept = append(kept, line) + } + if removed != len(keys) { + t.Fatalf("stripped %d keys from app.toml, want %d", removed, len(keys)) + } + if err := os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatalf("write app.toml: %v", err) + } +} diff --git a/io.go b/io.go index dac5012..0e619ae 100644 --- a/io.go +++ b/io.go @@ -30,7 +30,7 @@ func ReadConfigFromDir(homeDir string) (*SeiConfig, error) { return nil, fmt.Errorf("reading %s: %w", configPath, err) } - var app legacyAppConfig + app := legacyAppConfig{EVM: newLegacyEVMForRead()} if err := decodeTOMLFile(appPath, &app); err != nil { return nil, fmt.Errorf("reading %s: %w", appPath, err) } diff --git a/legacy.go b/legacy.go index 931f06e..b20e6c8 100644 --- a/legacy.go +++ b/legacy.go @@ -334,6 +334,38 @@ type legacyEVM struct { RPCStatsInterval Duration `toml:"rpc_stats_interval"` WorkerPoolSize int `toml:"worker_pool_size"` WorkerQueueSize int `toml:"worker_queue_size"` + MaxFilters uint64 `toml:"max_filters"` + MaxBlockFilterHashes uint64 `toml:"max_block_filter_hashes"` + MaxSubscriptionsLogs uint64 `toml:"max_subscriptions_logs"` + MaxLogBytes int64 `toml:"max_log_bytes"` + EnableSimulation bool `toml:"enable_simulation"` + MaxEstimateGasCalls int `toml:"max_estimate_gas_calls"` + MaxStateOverrideAccounts int `toml:"max_state_override_accounts"` + MaxStateOverrideSlots int `toml:"max_state_override_slots"` + MaxTraceStructLogBytes uint64 `toml:"max_trace_struct_log_bytes"` + TraceAllowedTracers []string `toml:"trace_allowed_tracers"` + TraceAllowJSTracers bool `toml:"trace_allow_js_tracers"` + EnableParallelizedBlockTrace bool `toml:"enable_parallelized_block_trace"` + TraceBakeEnabled bool `toml:"trace_bake_enabled"` + TraceBakeWorkers int `toml:"trace_bake_workers"` + TraceBakeQueueSize int `toml:"trace_bake_queue_size"` + TraceBakeTracers []string `toml:"trace_bake_tracers"` + TraceBakeWindowBlocks int64 `toml:"trace_bake_window_blocks"` + TraceBakeUseSnapshot bool `toml:"trace_bake_use_snapshot"` + TraceBakeSnapshotWindow int64 `toml:"trace_bake_snapshot_window"` + RateLimitingEnabled bool `toml:"rate_limiting_enabled"` + IPRateLimitRPS float64 `toml:"ip_rate_limit_rps"` + IPRateLimitBurst int `toml:"ip_rate_limit_burst"` + TrustedProxyCIDRs []string `toml:"trusted_proxy_cidrs"` + BatchRequestLimit int `toml:"batch_request_limit"` + BatchResponseMaxSize int `toml:"batch_response_max_size"` + MaxRequestBodyBytes int64 `toml:"max_request_body_bytes"` + MaxConcurrentRequestBytes int64 `toml:"max_concurrent_request_bytes"` + BodyReadIdleTimeout Duration `toml:"body_read_idle_timeout"` + WSAdmissionTimeout Duration `toml:"ws_admission_timeout"` + MaxOpenConnections int `toml:"max_open_connections"` + RPCDefaultTimeout Duration `toml:"rpc_default_timeout"` + RPCMethodTimeouts []string `toml:"rpc_method_timeouts"` } type legacyWASM struct { @@ -656,6 +688,38 @@ func (cfg *SeiConfig) toLegacyApp() legacyAppConfig { RPCStatsInterval: cfg.EVM.RPCStatsInterval, WorkerPoolSize: cfg.EVM.WorkerPoolSize, WorkerQueueSize: cfg.EVM.WorkerQueueSize, + MaxFilters: cfg.EVM.MaxFilters, + MaxBlockFilterHashes: cfg.EVM.MaxBlockFilterHashes, + MaxSubscriptionsLogs: cfg.EVM.MaxSubscriptionsLogs, + MaxLogBytes: cfg.EVM.MaxLogBytes, + EnableSimulation: cfg.EVM.EnableSimulation, + MaxEstimateGasCalls: cfg.EVM.MaxEstimateGasCalls, + MaxStateOverrideAccounts: cfg.EVM.MaxStateOverrideAccounts, + MaxStateOverrideSlots: cfg.EVM.MaxStateOverrideSlots, + MaxTraceStructLogBytes: cfg.EVM.MaxTraceStructLogBytes, + TraceAllowedTracers: cfg.EVM.TraceAllowedTracers, + TraceAllowJSTracers: cfg.EVM.TraceAllowJSTracers, + EnableParallelizedBlockTrace: cfg.EVM.EnableParallelizedBlockTrace, + TraceBakeEnabled: cfg.EVM.TraceBakeEnabled, + TraceBakeWorkers: cfg.EVM.TraceBakeWorkers, + TraceBakeQueueSize: cfg.EVM.TraceBakeQueueSize, + TraceBakeTracers: cfg.EVM.TraceBakeTracers, + TraceBakeWindowBlocks: cfg.EVM.TraceBakeWindowBlocks, + TraceBakeUseSnapshot: cfg.EVM.TraceBakeUseSnapshot, + TraceBakeSnapshotWindow: cfg.EVM.TraceBakeSnapshotWindow, + RateLimitingEnabled: cfg.EVM.RateLimitingEnabled, + IPRateLimitRPS: cfg.EVM.IPRateLimitRPS, + IPRateLimitBurst: cfg.EVM.IPRateLimitBurst, + TrustedProxyCIDRs: cfg.EVM.TrustedProxyCIDRs, + BatchRequestLimit: cfg.EVM.BatchRequestLimit, + BatchResponseMaxSize: cfg.EVM.BatchResponseMaxSize, + MaxRequestBodyBytes: cfg.EVM.MaxRequestBodyBytes, + MaxConcurrentRequestBytes: cfg.EVM.MaxConcurrentRequestBytes, + BodyReadIdleTimeout: cfg.EVM.BodyReadIdleTimeout, + WSAdmissionTimeout: cfg.EVM.WSAdmissionTimeout, + MaxOpenConnections: cfg.EVM.MaxOpenConnections, + RPCDefaultTimeout: cfg.EVM.RPCDefaultTimeout, + RPCMethodTimeouts: cfg.EVM.RPCMethodTimeouts, }, WASM: legacyWASM{ @@ -928,6 +992,38 @@ func fromLegacy(tm legacyTendermintConfig, app legacyAppConfig) *SeiConfig { RPCStatsInterval: app.EVM.RPCStatsInterval, WorkerPoolSize: app.EVM.WorkerPoolSize, WorkerQueueSize: app.EVM.WorkerQueueSize, + MaxFilters: app.EVM.MaxFilters, + MaxBlockFilterHashes: app.EVM.MaxBlockFilterHashes, + MaxSubscriptionsLogs: app.EVM.MaxSubscriptionsLogs, + MaxLogBytes: app.EVM.MaxLogBytes, + EnableSimulation: app.EVM.EnableSimulation, + MaxEstimateGasCalls: app.EVM.MaxEstimateGasCalls, + MaxStateOverrideAccounts: app.EVM.MaxStateOverrideAccounts, + MaxStateOverrideSlots: app.EVM.MaxStateOverrideSlots, + MaxTraceStructLogBytes: app.EVM.MaxTraceStructLogBytes, + TraceAllowedTracers: app.EVM.TraceAllowedTracers, + TraceAllowJSTracers: app.EVM.TraceAllowJSTracers, + EnableParallelizedBlockTrace: app.EVM.EnableParallelizedBlockTrace, + TraceBakeEnabled: app.EVM.TraceBakeEnabled, + TraceBakeWorkers: app.EVM.TraceBakeWorkers, + TraceBakeQueueSize: app.EVM.TraceBakeQueueSize, + TraceBakeTracers: app.EVM.TraceBakeTracers, + TraceBakeWindowBlocks: app.EVM.TraceBakeWindowBlocks, + TraceBakeUseSnapshot: app.EVM.TraceBakeUseSnapshot, + TraceBakeSnapshotWindow: app.EVM.TraceBakeSnapshotWindow, + RateLimitingEnabled: app.EVM.RateLimitingEnabled, + IPRateLimitRPS: app.EVM.IPRateLimitRPS, + IPRateLimitBurst: app.EVM.IPRateLimitBurst, + TrustedProxyCIDRs: app.EVM.TrustedProxyCIDRs, + BatchRequestLimit: app.EVM.BatchRequestLimit, + BatchResponseMaxSize: app.EVM.BatchResponseMaxSize, + MaxRequestBodyBytes: app.EVM.MaxRequestBodyBytes, + MaxConcurrentRequestBytes: app.EVM.MaxConcurrentRequestBytes, + BodyReadIdleTimeout: app.EVM.BodyReadIdleTimeout, + WSAdmissionTimeout: app.EVM.WSAdmissionTimeout, + MaxOpenConnections: app.EVM.MaxOpenConnections, + RPCDefaultTimeout: app.EVM.RPCDefaultTimeout, + RPCMethodTimeouts: app.EVM.RPCMethodTimeouts, Query: EVMQueryConfig{ GasLimit: app.EVMQuery.GasLimit, }, @@ -1037,3 +1133,48 @@ func splitRPCServers(s string) []string { } return out } + +// newLegacyEVMForRead returns the [evm] decode target for app.toml, seeded with +// defaults for the keys sei-config added after nodes already had app.toml files +// rendered without them. Decoding leaves an absent key at its seeded value, so +// such a file reads back at seid's default rather than the zero value, which for +// these keys would turn off rate limiting or fail seid's startup checks +// (max_filters = 0). Keys present in the file, including an explicit empty +// list, still decode as written. +func newLegacyEVMForRead() legacyEVM { + d := baseDefaults().EVM + return legacyEVM{ + MaxFilters: d.MaxFilters, + MaxBlockFilterHashes: d.MaxBlockFilterHashes, + MaxSubscriptionsLogs: d.MaxSubscriptionsLogs, + MaxLogBytes: d.MaxLogBytes, + EnableSimulation: d.EnableSimulation, + MaxEstimateGasCalls: d.MaxEstimateGasCalls, + MaxStateOverrideAccounts: d.MaxStateOverrideAccounts, + MaxStateOverrideSlots: d.MaxStateOverrideSlots, + MaxTraceStructLogBytes: d.MaxTraceStructLogBytes, + TraceAllowedTracers: d.TraceAllowedTracers, + TraceAllowJSTracers: d.TraceAllowJSTracers, + EnableParallelizedBlockTrace: d.EnableParallelizedBlockTrace, + TraceBakeEnabled: d.TraceBakeEnabled, + TraceBakeWorkers: d.TraceBakeWorkers, + TraceBakeQueueSize: d.TraceBakeQueueSize, + TraceBakeTracers: d.TraceBakeTracers, + TraceBakeWindowBlocks: d.TraceBakeWindowBlocks, + TraceBakeUseSnapshot: d.TraceBakeUseSnapshot, + TraceBakeSnapshotWindow: d.TraceBakeSnapshotWindow, + RateLimitingEnabled: d.RateLimitingEnabled, + IPRateLimitRPS: d.IPRateLimitRPS, + IPRateLimitBurst: d.IPRateLimitBurst, + TrustedProxyCIDRs: d.TrustedProxyCIDRs, + BatchRequestLimit: d.BatchRequestLimit, + BatchResponseMaxSize: d.BatchResponseMaxSize, + MaxRequestBodyBytes: d.MaxRequestBodyBytes, + MaxConcurrentRequestBytes: d.MaxConcurrentRequestBytes, + BodyReadIdleTimeout: d.BodyReadIdleTimeout, + WSAdmissionTimeout: d.WSAdmissionTimeout, + MaxOpenConnections: d.MaxOpenConnections, + RPCDefaultTimeout: d.RPCDefaultTimeout, + RPCMethodTimeouts: d.RPCMethodTimeouts, + } +} diff --git a/validate.go b/validate.go index 4215b80..d0a5861 100644 --- a/validate.go +++ b/validate.go @@ -4,6 +4,9 @@ import ( "encoding/hex" "fmt" "math" + "net" + "strings" + "time" ) // Severity classifies a validation finding. @@ -299,6 +302,77 @@ func validateEVM(r *ValidationResult, cfg *SeiConfig) { if cfg.Mode == ModeValidator && (e.HTTPEnabled || e.WSEnabled) { r.addWarning("evm", "EVM RPC is enabled on a validator node; this is unusual and may increase attack surface") } + + // The checks below mirror the ones seid fails startup on. + if e.MaxFilters == 0 { + r.addError("evm.max_filters", "must be > 0") + } + if e.MaxBlockFilterHashes == 0 { + r.addError("evm.max_block_filter_hashes", "must be > 0") + } + if e.MaxOpenConnections < 0 { + r.addError("evm.max_open_connections", "must be >= 0 (0 disables the limit)") + } + if e.BodyReadIdleTimeout.Duration < 0 { + r.addError("evm.body_read_idle_timeout", "must be >= 0 (0 disables the idle guard)") + } + if e.RPCDefaultTimeout.Duration < 0 { + r.addError("evm.rpc_default_timeout", "must be >= 0 (0 disables the default deadline)") + } + validateNativeTracers(r, "evm.trace_allowed_tracers", e.TraceAllowedTracers) + validateNativeTracers(r, "evm.trace_bake_tracers", e.TraceBakeTracers) + for _, cidr := range e.TrustedProxyCIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + r.addError("evm.trusted_proxy_cidrs", fmt.Sprintf("invalid CIDR %q", cidr)) + } + } + for _, entry := range e.RPCMethodTimeouts { + if err := validateMethodTimeout(entry); err != nil { + r.addError("evm.rpc_method_timeouts", err.Error()) + } + } + // The rate limiter charges one token per batch element, so a burst below + // the batch limit permanently rejects every full-size batch. + if e.RateLimitingEnabled && e.IPRateLimitBurst > 0 && e.BatchRequestLimit > 0 && + e.IPRateLimitBurst < e.BatchRequestLimit { + r.addError("evm.ip_rate_limit_burst", fmt.Sprintf( + "must be >= evm.batch_request_limit (%d) when rate limiting is enabled; got %d", + e.BatchRequestLimit, e.IPRateLimitBurst)) + } +} + +func validateNativeTracers(r *ValidationResult, field string, names []string) { + for _, name := range names { + if !nativeTraceTracers[strings.TrimSpace(name)] { + r.addError(field, fmt.Sprintf("%q is not a native tracer", name)) + } + } +} + +// validateMethodTimeout checks one evm.rpc_method_timeouts entry the way seid +// parses it: "method=duration", where a duration without a unit is read as +// nanoseconds and a method may end in a single trailing "*" wildcard. +func validateMethodTimeout(entry string) error { + method, raw, ok := strings.Cut(entry, "=") + if !ok || method == "" { + return fmt.Errorf("%q must be formatted as method=duration, for example eth_call=60s", entry) + } + if !strings.ContainsAny(raw, "nsuµmh") { + raw += "ns" + } + d, err := time.ParseDuration(raw) + if err != nil { + return fmt.Errorf("%q: %w", entry, err) + } + if d < 0 { + return fmt.Errorf("%q: duration must be >= 0 (0 disables the method deadline)", entry) + } + prefix, wildcard := strings.CutSuffix(method, "*") + if strings.Contains(prefix, "*") || (wildcard && prefix == "") { + return fmt.Errorf("%q: \"*\" is only supported as a trailing wildcard after a non-empty prefix, "+ + "for example debug_trace*", entry) + } + return nil } func validateMetrics(r *ValidationResult, cfg *SeiConfig) {