From 0a1a71c443bdc22efdad4d5960db51a373629a09 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Thu, 29 Oct 2026 14:47:45 -0700 Subject: [PATCH] feat: add the EVM RPC keys sei-chain reads that the schema was missing sei-chain's [evm] section has 32 keys the unified schema did not model, including the new per-IP rate limiting (rate_limiting_enabled, ip_rate_limit_rps, ip_rate_limit_burst, trusted_proxy_cidrs) and the request-admission limits around it. BuildRegistry never produced them, so ApplyOverrides rejected each as unknown and the controller could not set them, and WriteConfigToDir dropped them from app.toml, leaving every managed node on seid's compiled-in defaults. The fields mirror seid's key names and defaults, so the override key is evm.. Validate() now rejects what seid refuses to start with: max_filters or max_block_filter_hashes of 0, negative connection or timeout limits, non-native tracer names, invalid trusted proxy CIDRs, malformed rpc_method_timeouts entries, and a rate-limit burst below batch_request_limit. ReadConfigFromDir seeds these keys with defaults before decoding app.toml. Files rendered by earlier versions lack them, and decoding into a zero value would let an incremental apply write rate_limiting_enabled = false and max_filters = 0 back to the node. Keys present in the file, including an explicit empty list, still decode as written. Refs PLT-1553 Co-Authored-By: Claude Opus 5.5 --- config.go | 59 +++++++++++++ defaults.go | 27 ++++++ enrichments.go | 122 ++++++++++++++++++++++++++ evm_test.go | 229 +++++++++++++++++++++++++++++++++++++++++++++++++ io.go | 2 +- legacy.go | 141 ++++++++++++++++++++++++++++++ validate.go | 74 ++++++++++++++++ 7 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 evm_test.go 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) {