diff --git a/docker/localnode/config/app.toml b/docker/localnode/config/app.toml index 6a2c8b9dfc..9a5bab2960 100644 --- a/docker/localnode/config/app.toml +++ b/docker/localnode/config/app.toml @@ -277,6 +277,51 @@ enable_test_api = true # Set to 0 to disable request limiter, otherwise this limits the number of concurrent simulation calls. max_concurrent_simulation_calls = 0 +# Legacy sei_* / sei2_* JSON-RPC (EVM HTTP only). +# DEPRECATION: All sei_* and sei2_* methods are deprecated and scheduled for removal - no new integrations. +# HTTP 200; gate errors: JSON-RPC error (data legacy_sei_deprecated). Success: unchanged body; optional header Sei-Legacy-RPC-Deprecation. +# Docker localnet enables every gated method except sei_sign (integration tests assert the +# disabled-method path; sei_sign is not in partner telemetry and has no rpc_io_test fixtures). +# Production defaults from seid init remain the three-method sei_* allowlist - see evmrpc/config. +enabled_legacy_sei_apis = [ +# "sei_sign", + "sei_associate", + "sei_getBlockByHash", + "sei_getBlockByHashExcludeTraceFail", + "sei_getBlockByNumber", + "sei_getBlockByNumberExcludeTraceFail", + "sei_getBlockReceipts", + "sei_getBlockTransactionCountByHash", + "sei_getBlockTransactionCountByNumber", + "sei_getCosmosTx", + "sei_getEVMAddress", + "sei_getEvmTx", + "sei_getFilterChanges", + "sei_getFilterLogs", + "sei_getLogs", + "sei_getSeiAddress", + "sei_getTransactionByBlockHashAndIndex", + "sei_getTransactionByBlockNumberAndIndex", + "sei_getTransactionByHash", + "sei_getTransactionCount", + "sei_getTransactionErrorByHash", + "sei_getTransactionReceipt", + "sei_getTransactionReceiptExcludeTraceFail", + "sei_getVMError", + "sei_newBlockFilter", + "sei_newFilter", + "sei_traceBlockByHashExcludeTraceFail", + "sei_traceBlockByNumberExcludeTraceFail", + "sei_uninstallFilter", + "sei2_getBlockByHash", + "sei2_getBlockByHashExcludeTraceFail", + "sei2_getBlockByNumber", + "sei2_getBlockByNumberExcludeTraceFail", + "sei2_getBlockReceipts", + "sei2_getBlockTransactionCountByHash", + "sei2_getBlockTransactionCountByNumber", +] + ############################################################################### ### Admin Configuration (Auto-managed) ### ############################################################################### diff --git a/docker/rpcnode/config/app.toml b/docker/rpcnode/config/app.toml index 1eca02c9c4..8a9cda2067 100644 --- a/docker/rpcnode/config/app.toml +++ b/docker/rpcnode/config/app.toml @@ -264,3 +264,46 @@ enable_test_api = true # Set to 0 to disable request limiter, otherwise this limits the number of concurrent simulation calls. max_concurrent_simulation_calls = 0 + +# Legacy sei_* / sei2_* JSON-RPC (EVM HTTP only). +# DEPRECATION: All sei_* and sei2_* methods are deprecated and scheduled for removal - no new integrations. +# HTTP 200; gate errors: JSON-RPC error (data legacy_sei_deprecated). Success: unchanged body; optional header Sei-Legacy-RPC-Deprecation. +# Same allowlist as docker/localnode (all gated methods except sei_sign for integration coverage). +enabled_legacy_sei_apis = [ +# "sei_sign" + "sei_associate", + "sei_getBlockByHash", + "sei_getBlockByHashExcludeTraceFail", + "sei_getBlockByNumber", + "sei_getBlockByNumberExcludeTraceFail", + "sei_getBlockReceipts", + "sei_getBlockTransactionCountByHash", + "sei_getBlockTransactionCountByNumber", + "sei_getCosmosTx", + "sei_getEVMAddress", + "sei_getEvmTx", + "sei_getFilterChanges", + "sei_getFilterLogs", + "sei_getLogs", + "sei_getSeiAddress", + "sei_getTransactionByBlockHashAndIndex", + "sei_getTransactionByBlockNumberAndIndex", + "sei_getTransactionByHash", + "sei_getTransactionCount", + "sei_getTransactionErrorByHash", + "sei_getTransactionReceipt", + "sei_getTransactionReceiptExcludeTraceFail", + "sei_getVMError", + "sei_newBlockFilter", + "sei_newFilter", + "sei_traceBlockByHashExcludeTraceFail", + "sei_traceBlockByNumberExcludeTraceFail", + "sei_uninstallFilter", + "sei2_getBlockByHash", + "sei2_getBlockByHashExcludeTraceFail", + "sei2_getBlockByNumber", + "sei2_getBlockByNumberExcludeTraceFail", + "sei2_getBlockReceipts", + "sei2_getBlockTransactionCountByHash", + "sei2_getBlockTransactionCountByNumber", +] diff --git a/evmrpc/AGENTS.md b/evmrpc/AGENTS.md index 19de501174..8bcf0957dc 100644 --- a/evmrpc/AGENTS.md +++ b/evmrpc/AGENTS.md @@ -13,9 +13,13 @@ EVM RPCs prefixed by `eth_` and `debug_` on Sei generally follows [Ethereum's sp - `eth_newPendingTransactionFilter` - `eth_syncing` -## `sei_` prefixed endpoints +## `sei_` and `sei2_` prefixed endpoints Several `eth_` prefixed endpoints have a `sei_` prefixed counterpart. `eth_` endpoints only have visibility into EVM transactions, whereas `sei_` endpoints have visibility into EVM transactions plus Cosmos transactions that have synthetic EVM receipts. +The **`sei2`** namespace exposes the same **block** JSON-RPC shape as `sei` blocks, with **bank transfers** included in block payloads (HTTP only). There are seven `sei2_*` methods (block + block receipts + tx counts + `*ExcludeTraceFail` variants); there is no `sei2` transaction or filter API. + +Legacy **`sei_*` and `sei2_*`** JSON-RPC (EVM HTTP only) are **gated** by the same `[evm].enabled_legacy_sei_apis` list in `app.toml` (after `deny_list`). Enforcement is **centralized** in `wrapSeiLegacyHTTP` (see `sei_legacy_http.go`): it inspects the JSON-RPC `method` field only. Wired from `HTTPServer.EnableRPC` via `HTTPConfig.SeiLegacyAllowlist` — handlers do not duplicate gate logic. Both surfaces are **deprecated** and scheduled for removal; **only methods named in that array** are allowed. `seid init` / `DefaultConfig` pre-fill the three `sei_*` address/Cosmos helpers; other gated methods (including `sei2_*`) appear **commented** in the generated template. **Docker localnet** (`docker/localnode/config/app.toml`) enables **all** gated methods except **`sei_sign`**. **HTTP 200** for all responses. **Disabled** methods return JSON-RPC `error` code `-32601`, `message` explains not enabled + deprecated, `data` `"legacy_sei_deprecated"`. **Allowed** responses pass through **unchanged**; optional deprecation signal: HTTP header `Sei-Legacy-RPC-Deprecation` (`SeiLegacyDeprecationHTTPHeader` in `sei_legacy.go`). Coverage: `evmrpc/sei_legacy_test.go` and `integration_test/evm_module/rpc_io_test/testdata/sei_legacy_deprecation/*.iox`. + ## `debug_` prefixed endpoints `debug_trace*` endpoints should faithfully replay historical execution. If a transaction encountered an error during its actual execution, a `debug_trace*` call for it should reflect so. If a transction consumed X amount of gas during its actual execution, a `debug_trace*` call should show that exact amount as well. diff --git a/evmrpc/config/config.go b/evmrpc/config/config.go index 8ee36149c6..6ca013a9a3 100644 --- a/evmrpc/config/config.go +++ b/evmrpc/config/config.go @@ -134,6 +134,10 @@ type Config struct { // WorkerQueueSize defines the size of the task queue in the worker pool. // Set to 0 to use default: 1000 WorkerQueueSize int `mapstructure:"worker_queue_size"` + + // EnabledLegacySeiApis lists which gated sei_* and sei2_* JSON-RPC methods are allowed on the EVM HTTP endpoint. + // Set in app.toml [evm] as enabled_legacy_sei_apis (see ReadConfig and ConfigTemplate defaults). + EnabledLegacySeiApis []string `mapstructure:"enabled_legacy_sei_apis"` } var DefaultConfig = Config{ @@ -165,6 +169,11 @@ var DefaultConfig = Config{ RPCStatsInterval: 10 * time.Second, WorkerPoolSize: min(MaxWorkerPoolSize, runtime.NumCPU()*2), // Default: min(64, CPU cores × 2) WorkerQueueSize: DefaultWorkerQueueSize, // Default: 1000 tasks + EnabledLegacySeiApis: []string{ + "sei_getSeiAddress", + "sei_getEVMAddress", + "sei_getCosmosTx", + }, } const ( @@ -196,6 +205,7 @@ const ( flagRPCStatsInterval = "evm.rpc_stats_interval" flagWorkerPoolSize = "evm.worker_pool_size" flagWorkerQueueSize = "evm.worker_queue_size" + flagEVMLegacySeiApis = "evm.enabled_legacy_sei_apis" ) func ReadConfig(opts servertypes.AppOptions) (Config, error) { @@ -341,6 +351,11 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { return cfg, err } } + if v := opts.Get(flagEVMLegacySeiApis); v != nil { + if cfg.EnabledLegacySeiApis, err = cast.ToStringSliceE(v); err != nil { + return cfg, err + } + } return cfg, nil } @@ -412,6 +427,59 @@ slow = {{ .EVM.Slow }} # Deny list defines list of methods that EVM RPC should fail fast, e.g ["debug_traceBlockByNumber"] deny_list = {{ .EVM.DenyList }} +# Legacy sei_* / sei2_* JSON-RPC (EVM HTTP only - not Cosmos REST on 1317). +# +# DEPRECATION: The sei_* and sei2_* JSON-RPC surfaces are deprecated and scheduled for removal. Do not +# build new integrations on them; use eth_* / debug_* and documented replacements. HTTP 200; +# gate errors use standard JSON-RPC error encoding (see evmrpc/AGENTS.md). Successful allowlisted +# responses are unchanged; nodes may set HTTP header Sei-Legacy-RPC-Deprecation (see AGENTS.md). +# +# Only methods listed in enabled_legacy_sei_apis are allowed. Init defaults enable the three +# address/Cosmos helpers; uncomment optional lines below to enable more legacy methods (include +# sei2_* block methods at the end of the list if you need them). +enabled_legacy_sei_apis = [ +{{- range .EVM.EnabledLegacySeiApis }} + "{{ . }}", +{{- end }} + + # Optional legacy methods - uncomment to enable (same deprecation applies): + # "sei_associate", + # "sei_getBlockByHash", + # "sei_getBlockByHashExcludeTraceFail", + # "sei_getBlockByNumber", + # "sei_getBlockByNumberExcludeTraceFail", + # "sei_getBlockReceipts", + # "sei_getBlockTransactionCountByHash", + # "sei_getBlockTransactionCountByNumber", + # "sei_getEvmTx", + # "sei_getFilterChanges", + # "sei_getFilterLogs", + # "sei_getLogs", + # "sei_getTransactionByBlockHashAndIndex", + # "sei_getTransactionByBlockNumberAndIndex", + # "sei_getTransactionByHash", + # "sei_getTransactionCount", + # "sei_getTransactionErrorByHash", + # "sei_getTransactionReceipt", + # "sei_getTransactionReceiptExcludeTraceFail", + # "sei_getVMError", + # "sei_newBlockFilter", + # "sei_newFilter", + # "sei_sign", + # "sei_traceBlockByHashExcludeTraceFail", + # "sei_traceBlockByNumberExcludeTraceFail", + # "sei_uninstallFilter", + # + # Optional sei2_* block namespace (bank transfers in blocks; HTTP only): + # "sei2_getBlockByHash", + # "sei2_getBlockByHashExcludeTraceFail", + # "sei2_getBlockByNumber", + # "sei2_getBlockByNumberExcludeTraceFail", + # "sei2_getBlockReceipts", + # "sei2_getBlockTransactionCountByHash", + # "sei2_getBlockTransactionCountByNumber", +] + # max number of logs returned if block range is open-ended max_log_no_block = {{ .EVM.MaxLogNoBlock }} diff --git a/evmrpc/config/config_test.go b/evmrpc/config/config_test.go index 11e65ca7d3..faacf1a440 100644 --- a/evmrpc/config/config_test.go +++ b/evmrpc/config/config_test.go @@ -128,6 +128,9 @@ func (o *opts) Get(k string) interface{} { if k == "evm.worker_queue_size" { return o.workerQueueSize } + if k == "evm.enabled_legacy_sei_apis" { + return nil + } panic("unknown key") } diff --git a/evmrpc/rpcstack.go b/evmrpc/rpcstack.go index fe35390a0d..703db7a3b4 100644 --- a/evmrpc/rpcstack.go +++ b/evmrpc/rpcstack.go @@ -42,6 +42,9 @@ type HTTPConfig struct { CorsAllowedOrigins []string Vhosts []string DenyList []string + // SeiLegacyAllowlist is BuildSeiLegacyEnabledSet(app.toml enabled_legacy_sei_apis); nil skips the HTTP gate + // for gated sei_* and sei2_* methods. + SeiLegacyAllowlist map[string]struct{} prefix string // path prefix on which to mount http handler RPCEndpointConfig } @@ -312,8 +315,9 @@ func (h *HTTPServer) EnableRPC(apis []rpc.API, config HTTPConfig) error { srv.RegisterDenyList(method) } h.HTTPConfig = config + base := NewHTTPHandlerStack(srv, config.CorsAllowedOrigins, config.Vhosts, config.JwtSecret) h.httpHandler.Store(&rpcHandler{ - Handler: NewHTTPHandlerStack(srv, config.CorsAllowedOrigins, config.Vhosts, config.JwtSecret), + Handler: wrapSeiLegacyHTTP(base, config.SeiLegacyAllowlist), server: srv, }) return nil diff --git a/evmrpc/sei_legacy.go b/evmrpc/sei_legacy.go new file mode 100644 index 0000000000..b020fc158a --- /dev/null +++ b/evmrpc/sei_legacy.go @@ -0,0 +1,184 @@ +package evmrpc + +import ( + "sort" + "strings" + + "github.com/ethereum/go-ethereum/rpc" +) + +// SeiLegacyDeprecationHTTPHeader is set on HTTP responses that successfully forwarded an allowlisted +// gated sei_* / sei2_* JSON-RPC call (body is unchanged; clients should not rely on JSON result mutation). +const ( + SeiLegacyDeprecationHTTPHeader = "Sei-Legacy-RPC-Deprecation" + SeiLegacyDeprecationMessage = "All sei_* and sei2_* JSON-RPC methods are deprecated and scheduled for removal; migrate to eth_* and supported APIs." +) + +// errSeiLegacyNotEnabled is returned when a gated sei_* / sei2_* method is not listed in enabled_legacy_sei_apis. +// It follows github.com/ethereum/go-ethereum/rpc error encoding (jsonrpcMessage.error via rpc.Error / rpc.DataError). +type errSeiLegacyNotEnabled struct { + method string +} + +func (e *errSeiLegacyNotEnabled) Error() string { + return seiLegacyMethodDisabledMessage(e.method) +} + +func (e *errSeiLegacyNotEnabled) ErrorCode() int { + return -32601 +} + +func (e *errSeiLegacyNotEnabled) ErrorData() interface{} { + return "legacy_sei_deprecated" +} + +var ( + _ rpc.Error = (*errSeiLegacyNotEnabled)(nil) + _ rpc.DataError = (*errSeiLegacyNotEnabled)(nil) +) + +// seiLegacyGatedMethods is the full set of JSON-RPC methods on the sei and sei2 namespaces that +// are subject to [evm] enabled_legacy_sei_apis in app.toml (same allowlist for both prefixes). +var seiLegacyGatedMethods = map[string]struct{}{ + "sei_associate": {}, + "sei_getBlockByHash": {}, + "sei_getBlockByNumber": {}, + "sei_getBlockReceipts": {}, + "sei_getBlockTransactionCountByHash": {}, + "sei_getBlockTransactionCountByNumber": {}, + "sei_getBlockByHashExcludeTraceFail": {}, + "sei_getBlockByNumberExcludeTraceFail": {}, + "sei_getCosmosTx": {}, + "sei_getEVMAddress": {}, + "sei_getEvmTx": {}, + "sei_getFilterChanges": {}, + "sei_getFilterLogs": {}, + "sei_getLogs": {}, + "sei_getSeiAddress": {}, + "sei_getTransactionByBlockHashAndIndex": {}, + "sei_getTransactionByBlockNumberAndIndex": {}, + "sei_getTransactionByHash": {}, + "sei_getTransactionCount": {}, + "sei_getTransactionErrorByHash": {}, + "sei_getTransactionReceipt": {}, + "sei_getTransactionReceiptExcludeTraceFail": {}, + "sei_getVMError": {}, + "sei_newBlockFilter": {}, + "sei_newFilter": {}, + "sei_sign": {}, + "sei_traceBlockByHashExcludeTraceFail": {}, + "sei_traceBlockByNumberExcludeTraceFail": {}, + "sei_uninstallFilter": {}, + // sei2_* block namespace (HTTP only; bank transfers in blocks). Gated via the same allowlist. + "sei2_getBlockByHash": {}, + "sei2_getBlockByHashExcludeTraceFail": {}, + "sei2_getBlockByNumber": {}, + "sei2_getBlockByNumberExcludeTraceFail": {}, + "sei2_getBlockReceipts": {}, + "sei2_getBlockTransactionCountByHash": {}, + "sei2_getBlockTransactionCountByNumber": {}, +} + +// SeiLegacyAllExtraMethodNames returns gated sei_* methods other than the usual default trio +// (sei_getSeiAddress, sei_getEVMAddress, sei_getCosmosTx). Used to compose full test configs. +func SeiLegacyAllExtraMethodNames() []string { + out := make([]string, 0, len(seiLegacyGatedMethods)) + for m := range seiLegacyGatedMethods { + switch strings.ToLower(m) { + case "sei_getseiaddress", "sei_getevmaddress", "sei_getcosmostx": + continue + default: + out = append(out, m) + } + } + sort.Strings(out) + return out +} + +// SeiLegacyAllGatedMethodNames returns every gated sei_* and sei2_* method (sorted). Use when tests need full parity. +func SeiLegacyAllGatedMethodNames() []string { + out := make([]string, 0, len(seiLegacyGatedMethods)) + for m := range seiLegacyGatedMethods { + out = append(out, m) + } + sort.Strings(out) + return out +} + +// BuildSeiLegacyEnabledSet returns the set of allowed gated sei_* / sei2_* JSON-RPC methods from +// config only ([evm].enabled_legacy_sei_apis). Names are matched case-insensitively to canonical RPC names. +func BuildSeiLegacyEnabledSet(enabledLegacySeiApis []string) map[string]struct{} { + enabled := make(map[string]struct{}, len(enabledLegacySeiApis)) + for _, raw := range enabledLegacySeiApis { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + canonical := canonicalizeSeiLegacyMethodName(name) + if canonical == "" { + continue + } + if _, ok := seiLegacyGatedMethods[canonical]; ok { + enabled[canonical] = struct{}{} + } + } + return enabled +} + +func canonicalizeSeiLegacyMethodName(name string) string { + lower := strings.ToLower(strings.TrimSpace(name)) + for m := range seiLegacyGatedMethods { + if strings.ToLower(m) == lower { + return m + } + } + return "" +} + +func seiLegacyMethodDisabledMessage(method string) string { + return method + " is not enabled on this node. The sei_* and sei2_* JSON-RPC surfaces are deprecated, scheduled for removal, and should not be used for new integrations - " + + "prefer standard eth_* (and debug_*) methods and official migration guidance. " + + "To allow this legacy method, add it to enabled_legacy_sei_apis under [evm] in app.toml." +} + +func seiLegacyIsGatedNamespaceMethod(method string) bool { + return strings.HasPrefix(method, "sei2_") || strings.HasPrefix(method, "sei_") +} + +// seiLegacyGateError enforces [evm].enabled_legacy_sei_apis when allowlist is non-nil. +// allowlist nil means ungated (HTTP middleware disabled, or non-enforcing paths). +func seiLegacyGateError(method string, allowlist map[string]struct{}) error { + if allowlist == nil { + return nil + } + if !seiLegacyIsGatedNamespaceMethod(method) { + return nil + } + canon := canonicalizeSeiLegacyMethodName(method) + if canon == "" { + // Fail closed: sei_* / sei2_* names not in seiLegacyGatedMethods must not bypass the allowlist + // (e.g. future handlers or typos would otherwise reach the inner server). + return &errSeiLegacyNotEnabled{method: strings.TrimSpace(method)} + } + if _, ok := allowlist[canon]; ok { + return nil + } + return &errSeiLegacyNotEnabled{method: canon} +} + +// seiLegacyForwardedGatedMethod is true when the request method is a gated sei_* / sei2_* name listed +// in the allowlist (the call was forwarded to the inner JSON-RPC server). Used only for optional HTTP metadata. +func seiLegacyForwardedGatedMethod(method string, allowlist map[string]struct{}) bool { + if allowlist == nil { + return false + } + if !seiLegacyIsGatedNamespaceMethod(method) { + return false + } + canon := canonicalizeSeiLegacyMethodName(method) + if canon == "" { + return false + } + _, ok := allowlist[canon] + return ok +} diff --git a/evmrpc/sei_legacy_http.go b/evmrpc/sei_legacy_http.go new file mode 100644 index 0000000000..0aed0fc232 --- /dev/null +++ b/evmrpc/sei_legacy_http.go @@ -0,0 +1,224 @@ +package evmrpc + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" +) + +const seiLegacyHTTPMaxBody = 32 << 20 // 32MiB, typical RPC max message limits + +// wrapSeiLegacyHTTP wraps the EVM JSON-RPC HTTP handler to enforce [evm].enabled_legacy_sei_apis for +// gated sei_* and sei2_* methods. Disallowed calls get a JSON-RPC error without invoking the inner handler; +// allowed calls pass through unchanged. Optional deprecation: HTTP header SeiLegacyDeprecationHTTPHeader +// on successful forwards (no JSON body mutation). allowlist nil disables the wrapper. +func wrapSeiLegacyHTTP(inner http.Handler, allowlist map[string]struct{}) http.Handler { + if allowlist == nil { + return inner + } + return &seiLegacyHTTPGate{inner: inner, allowlist: allowlist} +} + +type seiLegacyHTTPGate struct { + inner http.Handler + allowlist map[string]struct{} +} + +func (g *seiLegacyHTTPGate) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Read the body once; delegate JSON-RPC validation to the inner handler. We only intercept + // when we can parse JSON-RPC and the method is a gated sei_* / sei2_* name. + body, err := io.ReadAll(io.LimitReader(r.Body, seiLegacyHTTPMaxBody)) + _ = r.Body.Close() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + trim := bytes.TrimSpace(body) + if len(trim) > 0 && trim[0] == '[' { + g.handleBatch(w, r, body) + return + } + g.handleSingle(w, r, body) +} + +func (g *seiLegacyHTTPGate) serveInnerWithBody(w http.ResponseWriter, r *http.Request, body []byte) { + sub := r.Clone(r.Context()) + sub.Body = io.NopCloser(bytes.NewReader(body)) + sub.ContentLength = int64(len(body)) + sub.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } + g.inner.ServeHTTP(w, sub) +} + +func orNullID(id json.RawMessage) json.RawMessage { + if len(id) == 0 { + return json.RawMessage(`null`) + } + return id +} + +func (g *seiLegacyHTTPGate) handleSingle(w http.ResponseWriter, r *http.Request, body []byte) { + var msg struct { + Method string `json:"method"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(body, &msg); err != nil { + g.serveInnerWithBody(w, r, body) + return + } + if err := seiLegacyGateError(msg.Method, g.allowlist); err != nil { + writeSeiLegacyBlocked(w, orNullID(msg.ID), err) + return + } + rec := httptest.NewRecorder() + sub := r.Clone(r.Context()) + sub.Body = io.NopCloser(bytes.NewReader(body)) + sub.ContentLength = int64(len(body)) + sub.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } + g.inner.ServeHTTP(rec, sub) + if seiLegacyForwardedGatedMethod(msg.Method, g.allowlist) { + rec.Header().Set(SeiLegacyDeprecationHTTPHeader, SeiLegacyDeprecationMessage) + } + copyHTTPHeader(w.Header(), rec.Header()) + w.WriteHeader(rec.Code) + _, _ = w.Write(rec.Body.Bytes()) +} + +func (g *seiLegacyHTTPGate) handleBatch(w http.ResponseWriter, r *http.Request, body []byte) { + var msgs []json.RawMessage + if err := json.Unmarshal(body, &msgs); err != nil { + g.serveInnerWithBody(w, r, body) + return + } + if len(msgs) == 0 { + g.serveInnerWithBody(w, r, body) + return + } + methods := make([]string, len(msgs)) + ids := make([]json.RawMessage, len(msgs)) + for i, raw := range msgs { + var msg struct { + Method string `json:"method"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + g.serveInnerWithBody(w, r, body) + return + } + methods[i] = msg.Method + ids[i] = msg.ID + } + blocked := make([]bool, len(msgs)) + blockedErr := make([]error, len(msgs)) + for i := range msgs { + if err := seiLegacyGateError(methods[i], g.allowlist); err != nil { + blocked[i] = true + blockedErr[i] = err + } + } + var forward []json.RawMessage + forwardLegacy := false + for i := range msgs { + if !blocked[i] { + forward = append(forward, msgs[i]) + if seiLegacyForwardedGatedMethod(methods[i], g.allowlist) { + forwardLegacy = true + } + } + } + if len(forward) == 0 { + outArr := make([]json.RawMessage, len(msgs)) + for i := range msgs { + outArr[i] = json.RawMessage(marshalBlockedResponse(orNullID(ids[i]), blockedErr[i])) + } + writeJSONArrayResponse(w, http.StatusOK, outArr) + return + } + forwardBody, err := json.Marshal(forward) + if err != nil { + g.serveInnerWithBody(w, r, body) + return + } + rec := httptest.NewRecorder() + sub := r.Clone(r.Context()) + sub.Body = io.NopCloser(bytes.NewReader(forwardBody)) + sub.ContentLength = int64(len(forwardBody)) + sub.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(forwardBody)), nil + } + g.inner.ServeHTTP(rec, sub) + var innerArr []json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &innerArr); err != nil || len(innerArr) != len(forward) { + copyHTTPHeader(w.Header(), rec.Header()) + w.WriteHeader(rec.Code) + _, _ = w.Write(rec.Body.Bytes()) + return + } + outArr := make([]json.RawMessage, len(msgs)) + innerPos := 0 + for i := range msgs { + if blocked[i] { + outArr[i] = json.RawMessage(marshalBlockedResponse(orNullID(ids[i]), blockedErr[i])) + continue + } + outArr[i] = innerArr[innerPos] + innerPos++ + } + copyHTTPHeader(w.Header(), rec.Header()) + if forwardLegacy { + w.Header().Set(SeiLegacyDeprecationHTTPHeader, SeiLegacyDeprecationMessage) + } + writeJSONArrayResponse(w, rec.Code, outArr) +} + +func copyHTTPHeader(dst, src http.Header) { + for k, vv := range src { + dst.Del(k) + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func writeJSONArrayResponse(w http.ResponseWriter, code int, arr []json.RawMessage) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(arr) +} + +func writeSeiLegacyBlocked(w http.ResponseWriter, id json.RawMessage, gateErr error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(marshalBlockedResponse(id, gateErr)) +} + +func marshalBlockedResponse(id json.RawMessage, gateErr error) []byte { + e, ok := gateErr.(*errSeiLegacyNotEnabled) + if !ok { + fallback, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]interface{}{ + "code": -32603, + "message": gateErr.Error(), + }, + }) + return fallback + } + m := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]interface{}{ + "code": e.ErrorCode(), + "message": e.Error(), + "data": e.ErrorData(), + }, + } + b, _ := json.Marshal(m) + return b +} diff --git a/evmrpc/sei_legacy_test.go b/evmrpc/sei_legacy_test.go new file mode 100644 index 0000000000..63428ee003 --- /dev/null +++ b/evmrpc/sei_legacy_test.go @@ -0,0 +1,308 @@ +package evmrpc + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/rpc" +) + +func TestBuildSeiLegacyEnabledSet_Empty(t *testing.T) { + s := BuildSeiLegacyEnabledSet(nil) + if len(s) != 0 { + t.Fatalf("expected empty set, got %v", s) + } +} + +func TestBuildSeiLegacyEnabledSet_InitDefaults(t *testing.T) { + s := BuildSeiLegacyEnabledSet([]string{"sei_getSeiAddress", "sei_getEVMAddress", "sei_getCosmosTx"}) + if len(s) != 3 { + t.Fatalf("want 3 entries, got %d", len(s)) + } + if _, ok := s["sei_getBlockByNumber"]; ok { + t.Fatal("block should be off") + } +} + +func TestBuildSeiLegacyEnabledSet_Extra(t *testing.T) { + s := BuildSeiLegacyEnabledSet([]string{"sei_getBlockByNumber", "SEI_GETBLOCKRECEIPTS"}) + if _, ok := s["sei_getBlockByNumber"]; !ok { + t.Fatal("expected sei_getBlockByNumber") + } + if _, ok := s["sei_getBlockReceipts"]; !ok { + t.Fatal("expected case-insensitive match") + } +} + +func TestSeiLegacyGateError_DisabledWhenEmptyAllowlist(t *testing.T) { + err := seiLegacyGateError("sei_getBlockByNumber", BuildSeiLegacyEnabledSet(nil)) + if err == nil { + t.Fatal("expected error") + } + var withData rpc.DataError + if !errors.As(err, &withData) { + t.Fatalf("want rpc.DataError, got %T", err) + } + if withData.ErrorData() != "legacy_sei_deprecated" { + t.Fatalf("error data: %v", withData.ErrorData()) + } + msg := err.Error() + if !strings.Contains(msg, "not enabled") { + t.Fatalf("message: %s", msg) + } +} + +func TestSeiLegacyGateError_AllowedWhenListed(t *testing.T) { + enabled := BuildSeiLegacyEnabledSet([]string{"sei_getBlockByNumber"}) + err := seiLegacyGateError("Sei_GetBlockByNumber", enabled) + if err != nil { + t.Fatalf("unexpected: %v", err) + } +} + +func TestSeiLegacyGateError_UnknownSeiNamespaceFailsClosed(t *testing.T) { + enabled := BuildSeiLegacyEnabledSet([]string{"sei_getBlockByNumber"}) + err := seiLegacyGateError("sei_notARealRegisteredMethod", enabled) + if err == nil { + t.Fatal("expected error for unknown sei_* method when allowlist is active") + } + var withData rpc.DataError + if !errors.As(err, &withData) { + t.Fatalf("want rpc.DataError, got %T", err) + } + if withData.ErrorData() != "legacy_sei_deprecated" { + t.Fatalf("error data: %v", withData.ErrorData()) + } +} + +func TestSeiLegacyGateError_Sei2BlockedUnlessListed(t *testing.T) { + err := seiLegacyGateError("sei2_getBlockByNumber", BuildSeiLegacyEnabledSet(nil)) + if err == nil { + t.Fatal("expected error") + } + enabled := BuildSeiLegacyEnabledSet([]string{"sei2_getBlockByNumber"}) + if err := seiLegacyGateError("SEI2_GETBLOCKBYNUMBER", enabled); err != nil { + t.Fatalf("unexpected: %v", err) + } +} + +func TestBuildSeiLegacyEnabledSet_IncludesSei2(t *testing.T) { + s := BuildSeiLegacyEnabledSet([]string{"sei2_getBlockReceipts"}) + if _, ok := s["sei2_getBlockReceipts"]; !ok { + t.Fatalf("got %v", s) + } + if _, ok := s["sei_getBlockReceipts"]; ok { + t.Fatal("sei_* should not be enabled from sei2_ name only") + } +} + +func TestSeiLegacyGateError_NilAllowlistUngated(t *testing.T) { + err := seiLegacyGateError("sei_getBlockByNumber", nil) + if err != nil { + t.Fatal(err) + } +} + +func TestWrapSeiLegacyHTTP_UnknownSeiMethodBlocked(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not run for unknown sei_* method") + }) + enabled := BuildSeiLegacyEnabledSet([]string{"sei_getBlockByNumber"}) + h := wrapSeiLegacyHTTP(inner, enabled) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"sei_futureHypotheticalMethod","params":[]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp["error"] == nil { + t.Fatalf("expected error, got %s", rec.Body.String()) + } + errObj, _ := resp["error"].(map[string]interface{}) + if errObj["data"] != "legacy_sei_deprecated" { + t.Fatalf("error data: %v", errObj) + } +} + +func TestWrapSeiLegacyHTTP_BlocksDisabledMethod(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not run") + }) + h := wrapSeiLegacyHTTP(inner, BuildSeiLegacyEnabledSet(nil)) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"sei_getBlockByNumber","params":["0x1",false]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + errObj, _ := resp["error"].(map[string]interface{}) + if errObj == nil { + t.Fatalf("expected error, got %s", rec.Body.String()) + } + if errObj["data"] != "legacy_sei_deprecated" { + t.Fatalf("error data: %v", errObj) + } + if rec.Code != http.StatusOK { + t.Fatalf("want HTTP 200, got %d", rec.Code) + } +} + +func TestWrapSeiLegacyHTTP_AllowedMethodPassthroughAndDeprecationHeader(t *testing.T) { + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"number":"0x1"}}`)) + }) + enabled := BuildSeiLegacyEnabledSet([]string{"sei_getBlockByNumber"}) + h := wrapSeiLegacyHTTP(inner, enabled) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"sei_getBlockByNumber","params":["latest",false]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !called { + t.Fatal("inner should run for allowlisted method") + } + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + res, _ := resp["result"].(map[string]interface{}) + if res == nil { + t.Fatalf("expected result object: %s", rec.Body.String()) + } + if res["number"] != "0x1" { + t.Fatalf("inner result should be unchanged: %+v", res) + } + if rec.Header().Get(SeiLegacyDeprecationHTTPHeader) == "" { + t.Fatal("expected deprecation HTTP header on allowlisted sei_* response") + } +} + +func TestWrapSeiLegacyHTTP_StringResultPassthrough(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"bech32addr"}`)) + }) + enabled := BuildSeiLegacyEnabledSet([]string{"sei_getSeiAddress"}) + h := wrapSeiLegacyHTTP(inner, enabled) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"sei_getSeiAddress","params":["0x0000000000000000000000000000000000000001"]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + res, _ := resp["result"].(string) + if res != "bech32addr" { + t.Fatalf("result: %v", resp) + } + if rec.Header().Get(SeiLegacyDeprecationHTTPHeader) == "" { + t.Fatal("expected deprecation HTTP header") + } +} + +func TestWrapSeiLegacyHTTP_Sei2BlockedWhenNotAllowlisted(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("inner should not run") + }) + h := wrapSeiLegacyHTTP(inner, BuildSeiLegacyEnabledSet(nil)) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"sei2_getBlockByNumber","params":["latest",false]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp["error"] == nil { + t.Fatalf("expected error: %s", rec.Body.String()) + } +} + +func TestWrapSeiLegacyHTTP_Sei2AllowlistedPassthroughAndHeader(t *testing.T) { + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"number":"0x1"}}`)) + }) + enabled := BuildSeiLegacyEnabledSet([]string{"sei2_getBlockByNumber"}) + h := wrapSeiLegacyHTTP(inner, enabled) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"sei2_getBlockByNumber","params":["latest",false]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !called { + t.Fatal("inner should run") + } + if rec.Header().Get(SeiLegacyDeprecationHTTPHeader) == "" { + t.Fatal("expected deprecation header") + } +} + +func TestWrapSeiLegacyHTTP_EthPassthrough(t *testing.T) { + called := false + inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + }) + h := wrapSeiLegacyHTTP(inner, BuildSeiLegacyEnabledSet(nil)) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !called { + t.Fatal("eth_* should reach inner") + } + if rec.Header().Get(SeiLegacyDeprecationHTTPHeader) != "" { + t.Fatal("eth_* should not set legacy deprecation header") + } +} + +func TestWrapSeiLegacyHTTP_BatchMixed(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if !strings.Contains(string(b), "eth_chainId") { + t.Fatalf("unexpected forward body: %s", b) + } + _, _ = w.Write([]byte(`[{"jsonrpc":"2.0","id":2,"result":"0x1"}]`)) + }) + h := wrapSeiLegacyHTTP(inner, BuildSeiLegacyEnabledSet(nil)) + body := `[{"jsonrpc":"2.0","id":1,"method":"sei_getBlockByNumber","params":[]},{"jsonrpc":"2.0","id":2,"method":"eth_chainId","params":[]}]` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var batch []map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &batch); err != nil { + t.Fatal(err) + } + if len(batch) != 2 { + t.Fatalf("want 2 results, got %d", len(batch)) + } + if batch[0]["error"] == nil { + t.Fatal("first should be error") + } + if batch[1]["result"] == nil { + t.Fatal("second should be result") + } + err0, _ := batch[0]["error"].(map[string]interface{}) + if err0["data"] != "legacy_sei_deprecated" { + t.Fatalf("blocked error data: %+v", err0) + } +} diff --git a/evmrpc/server.go b/evmrpc/server.go index 5c4ceed66e..9d85948870 100644 --- a/evmrpc/server.go +++ b/evmrpc/server.go @@ -97,6 +97,8 @@ func NewEVMHTTPServer( return debugAPI.isPanicOrSyntheticTx(ctx, hash) } } + seiLegacyAllowlist := BuildSeiLegacyEnabledSet(config.EnabledLegacySeiApis) + seiTxAPI := NewSeiTransactionAPI(tmClient, k, ctxProvider, txConfigProvider, homeDir, ConnectionTypeHTTP, isPanicOrSyntheticTxFunc, watermarks, globalBlockCache, cacheCreationMutex) seiDebugAPI := NewSeiDebugAPI(tmClient, k, beginBlockKeepers, ctxProvider, txConfigProvider, simulateConfig, app, antehandler, ConnectionTypeHTTP, config, globalBlockCache, cacheCreationMutex, watermarks) @@ -218,6 +220,7 @@ func NewEVMHTTPServer( CorsAllowedOrigins: strings.Split(config.CORSOrigins, ","), Vhosts: []string{"*"}, DenyList: config.DenyList, + SeiLegacyAllowlist: seiLegacyAllowlist, }); err != nil { return nil, err } diff --git a/evmrpc/setup_test.go b/evmrpc/setup_test.go index 31355c47e8..41c40348c0 100644 --- a/evmrpc/setup_test.go +++ b/evmrpc/setup_test.go @@ -593,6 +593,7 @@ func init() { goodConfig.WSPort = TestWSPort goodConfig.FilterTimeout = 500 * time.Millisecond goodConfig.MaxLogNoBlock = 10 + goodConfig.EnabledLegacySeiApis = evmrpc.SeiLegacyAllGatedMethodNames() txConfigProvider := func(int64) client.TxConfig { return TxConfig } HttpServer, err := evmrpc.NewEVMHTTPServer(goodConfig, &MockClient{}, EVMKeeper, testApp.BeginBlockKeepers, testApp.BaseApp, testApp.TracerAnteHandler, ctxProvider, txConfigProvider, "", testApp.GetStateStore(), isPanicTxFunc) if err != nil { diff --git a/evmrpc/tests/utils.go b/evmrpc/tests/utils.go index c98a4ad691..e871aba24c 100644 --- a/evmrpc/tests/utils.go +++ b/evmrpc/tests/utils.go @@ -150,6 +150,7 @@ func setupTestServer( cfg := evmrpcconfig.DefaultConfig cfg.HTTPEnabled = true cfg.HTTPPort = port + cfg.EnabledLegacySeiApis = evmrpc.SeiLegacyAllGatedMethodNames() s, err := evmrpc.NewEVMHTTPServer( cfg, mockClient, diff --git a/integration_test/evm_module/rpc_io_test/FAILED_TEST_ANALYSIS.md b/integration_test/evm_module/rpc_io_test/FAILED_TEST_ANALYSIS.md index a17ebd7d7e..0a6f087f21 100644 --- a/integration_test/evm_module/rpc_io_test/FAILED_TEST_ANALYSIS.md +++ b/integration_test/evm_module/rpc_io_test/FAILED_TEST_ANALYSIS.md @@ -2,7 +2,9 @@ **Principle:** Fixtures encode **Ethereum-expected behavior**. A test must **fail** when Sei RPC diverges. Fix the **RPC**, not the fixture. -## Latest run (evm_rpc_tests.sh) +## Runs + +### Post-trim baseline (157 fixtures on main after explicit-unsupported swap) | Metric | Count | | --------- | ----- | @@ -12,7 +14,31 @@ | Skipped | 0 | | Pass rate | (re-run `evm_rpc_tests.sh` after changes) | -## Failed tests by endpoint (~19 after explicit-unsupported fixes; re-run to confirm) +*Leave this block as the older **157**-only snapshot; see `RPC_IO_README.md` summary column **unsupported-fix** (~142 / ~15 / ~90.4%) for the recorded pre-`sei_*`-harness baseline.* + +### With sei_* gating + deprecation IO (159 fixtures) + +Docker localnet with expanded `[evm].enabled_legacy_sei_apis` (all gated `sei_*` except `sei_sign`) plus `testdata/sei_legacy_deprecation/*.iox` (two files on top of main's 157). + +| Metric | Count | +| --------- | ----- | +| Total | 159 | +| Passed | 145 | +| Failed | 14 | +| Skipped | 0 | +| Pass rate | 91.2% | + +Reference: `TestEVMRPCSpecSummary` after `evm_rpc_tests.sh` (**159** fixtures, docker localnet with expanded `enabled_legacy_sei_apis`). **Two** `sei_legacy_deprecation/*.iox` pass. **145** pass / **14** fail / **91.2%** (see `RPC_IO_README.md` summary column **sei_* fix**). + +**Legacy `sei_*`:** Every **`sei_*`** fixture under `testdata/` passes on that run (blocks, traces, filters, logs, receipts, deprecation `.iox`, etc.). There are **no** `sei2_*` `.io`/`.iox` files in the suite yet. + +**Moved from fail to pass** (vs earlier 159-file runs): `eth_getFilterLogs/getFilterLogs-lifecycle.iox` and `sei_getFilterLogs/getFilterLogs.iox` - filter criteria changed from `0x1`->`latest` to `latest`->`latest` so the span stays within `max_blocks_for_log` on long-lived localnet. + +**Moved from fail to pass** (baseline to typical docker localnet, varies by build): `eth_blobBaseFee`, `eth_getBlockByHash` (empty / not-found hash), `eth_getBlockReceipts` (empty / not-found hash), `eth_getBlockTransactionCountByHash/get-genesis.iox` - when the node returns spec-shaped `null` or exposes the method. + +## Failed tests by endpoint (14 failures on the **159**-file reference run; **unsupported-fix** baseline remains ~15 fails on **157** files without deprecation `.iox`) + +`debug_getRaw*`, `eth_newPendingTransactionFilter`, and `eth_syncing` now use **`not-supported.iox`** (expect JSON-RPC error `-32000`); they are not listed below as -32601 failures. See [docs/evm_jsonrpc_unsupported.md](../../../docs/evm_jsonrpc_unsupported.md). | Endpoint | # | Fixtures / cause | | -------- | - | ---------------- | @@ -20,10 +46,7 @@ | eth_createAccessList | 3 | create-al-abi-revert, create-al-contract-eip1559, create-al-contract (insufficient funds / gas fee) | | eth_estimateGas | 2 | estimate-with-eip4844.iox, estimate-with-eip7702.iox (parse error) | | eth_estimateGasAfterCalls | 1 | estimateGasAfterCalls.iox (insufficient funds) | -| eth_getBlockByHash | 2 | get-block-by-empty-hash, get-block-by-notfound-hash (Sei returns error; spec: result=null) | -| eth_getBlockByNumber | 1 | get-block-notfound.iox (height not available vs spec null) | -| eth_getBlockReceipts | 2 | get-block-receipts-empty, get-block-receipts-not-found (Sei returns error; spec: result=null) | -| eth_getBlockTransactionCountByHash | 1 | get-genesis.iox (hash lookup: block from getBlockByNumber("0x0") not found by hash) | +| eth_getBlockByNumber | 1 | get-block-notfound.iox (-32000 e.g. `requested height 1000 is not yet available; safe latest is 128` vs spec `result: null`) | | eth_getLogs | 1 | filter-error-future-block-range.io (Sei returns []; spec: error when range > head) | | eth_getProof | 3 | get-account-proof-* (cannot find EVM IAVL store) | | eth_getTransactionByBlockHashAndIndex | 1 | get-block-n.iox (transaction index out of range) | @@ -40,14 +63,16 @@ These methods are **implemented** to return JSON-RPC error code `-32000` with a | `eth_newPendingTransactionFilter` | `eth_newPendingTransactionFilter/not-supported.iox` | | `eth_syncing` | `eth_syncing/not-supported.iox` | +`eth_blobBaseFee`: on recent localnet builds the method is often exposed (returns a JSON-RPC error for "blobs not supported" per spec); when missing it failed older runs with -32601. + ## Fix direction (no fixture changes) | Category | Endpoints / fixtures | Action | | -------- | -------------------- | ------ | -| **Return null for missing block** | eth_getBlockByHash, eth_getBlockReceipts (empty/notfound) | RPC: return `result: null` instead of -32000 for non-existent block hash | +| **Return null for missing block** | eth_getBlockByHash, eth_getBlockReceipts (empty/notfound) | RPC: return `result: null` instead of -32000 for non-existent block hash (if still failing on your node) | | **Block hash lookup** | eth_getBlockTransactionCountByHash (get-genesis) | RPC: resolve block by hash when that hash was returned by getBlockByNumber | | **Block range validation** | eth_getLogs (filter-error-future-block-range) | RPC: return -32602 when toBlock > current head | | **EIP1559 in eth_call** | eth_call (call-callenv-options-eip1559) | RPC: accept maxFeePerGas/maxPriorityFeePerGas and return result | -| **Other** | eth_createAccessList (3), eth_estimateGas (2), eth_estimateGasAfterCalls, eth_getBlockByNumber (notfound), eth_getProof (3), eth_getTransactionBy*Index (2) | Investigate; fix RPC or env (e.g. funded “from”, parse, IAVL store, tx index) | +| **Other** | eth_createAccessList (3), eth_estimateGas (2), eth_estimateGasAfterCalls, eth_getBlockByNumber (notfound), eth_getProof (3), eth_getTransactionBy*Index (2) | Investigate; fix RPC or env (e.g. funded "from", parse, IAVL store, tx index) | *Removed fixtures (not in suite): call-revert-abi-error.io, call-revert-abi-panic.io, estimate-call-abi-error.io, estimate-failed-call.io. Revert coverage: call-revert-abi-error-sei.iox, call-revert-abi-panic-sei.iox, estimate-call-abi-error-sei.iox, estimate-call-abi-panic-sei.iox (use __REVERTER__). eth_simulateV1 folder is not under testdata.* diff --git a/integration_test/evm_module/rpc_io_test/RPC_IO_README.md b/integration_test/evm_module/rpc_io_test/RPC_IO_README.md index 41756194c0..39659460c8 100644 --- a/integration_test/evm_module/rpc_io_test/RPC_IO_README.md +++ b/integration_test/evm_module/rpc_io_test/RPC_IO_README.md @@ -1,6 +1,11 @@ # EVM RPC .io / .iox tests -Integration tests for Sei EVM RPC compatibility with Ethereum JSON-RPC. The suite runs **157 tests** from `testdata/` against a live RPC endpoint. +Integration tests for Sei EVM RPC compatibility with Ethereum JSON-RPC. The suite runs **159** `.io`/`.iox` files from `testdata/` against a live RPC endpoint (**157** after main's explicit-unsupported fixture refresh, plus **two** `sei_legacy_deprecation/*.iox` for gated `sei_*` and deprecation signaling). + +### `.io` vs `.iox` + +- **`.io`** - vanilla JSON-RPC fixtures (`>>` / `<<`): no Sei-specific harness directives such as `@ expect_body_contains`. +- **`.iox`** - same line format, plus Sei extensions: `@ bind` / `<< @ ref_pair`, `@ expect_body_contains`, `@ expect_response_header`, `not-supported.iox` (documented `-32000` errors), and other non-vanilla tags the parser accepts. ## How to run @@ -12,6 +17,12 @@ Integration tests for Sei EVM RPC compatibility with Ethereum JSON-RPC. The suit When the target is localhost, the script sends one EVM tx and deploys one contract inside the node container before `go test`, so data-dependent `.iox` tests have block/tx/contract. Default RPC URL: `http://127.0.0.1:8545` (override with `SEI_EVM_RPC_URL`). +**Legacy `sei_*` / `sei2_*` gating:** The docker localnet `app.toml` enables every gated method except **`sei_sign`** (low partner risk: not reported by Binance/Dune/Alchemy; unused by other fixtures). Deprecation is asserted in `testdata/sei_legacy_deprecation/*.iox`: **gate errors** use `error.data` `legacy_sei_deprecated` and messages mentioning disabled + deprecated; **successful** allowlisted calls use `@ expect_response_header Sei-Legacy-RPC-Deprecation` (JSON body unchanged). Directives: +- `@ expect_body_contains substring` - response body must contain the substring. +- `@ expect_response_header Header-Name` - response must include that HTTP header (case-insensitive lookup). + +Production `seid init` defaults remain the three-method allowlist (`sei_getSeiAddress`, `sei_getEVMAddress`, `sei_getCosmosTx`). + ### Comparing legacy vs giga (RPC parity) To check that **giga** behaves like **legacy** at the spec level (same methods return result vs error): @@ -50,9 +61,9 @@ Without `GIGA_EXECUTOR` and `GIGA_OCC`, the cluster uses the legacy (V2) executo ```bash SEI_EVM_RPC_URL= ./integration_test/evm_module/scripts/evm_rpc_tests.sh ``` -3. Compare **Total**, **Passed**, **Failed**, and **Skipped**. Same numbers ⇒ spec parity for that run. Any difference indicates a method that returns result on one node and error on the other (or vice versa). +3. Compare **Total**, **Passed**, **Failed**, and **Skipped**. Same numbers => spec parity for that run. Any difference indicates a method that returns result on one node and error on the other (or vice versa). -For a fair comparison, both endpoints should serve the **same chain** (same genesis and blocks). If using the script’s seed (deploy tx), run the script once to create the seed on one node; for the second run you can point at the other node only if it has the same chain and the same block containing that deploy (e.g. two nodes in the same network). +For a fair comparison, both endpoints should serve the **same chain** (same genesis and blocks). If using the script's seed (deploy tx), run the script once to create the seed on one node; for the second run you can point at the other node only if it has the same chain and the same block containing that deploy (e.g. two nodes in the same network). ## Test mix @@ -60,8 +71,8 @@ For a fair comparison, both endpoints should serve the **same chain** (same gene | Kind | Count | Description | | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **.io** | ~50 | Request/response fixtures; curated from [ethereum/execution-apis](https://github.com/ethereum/execution-apis) plus Sei-added. | -| **.iox** | ~114 | Sei-generated; use `@ bind` and optional `@ ref_pair N` so data comes from a first request. | -| **Total** | 157 | All under `testdata/`; runner executes every .io and .iox file. | +| **.iox** | ~109 | Sei-generated; use `@ bind` and optional `@ ref_pair N` so data comes from a first request; includes `not-supported.iox`, `sei_legacy_deprecation/*.iox`. | +| **Total** | 159 | All under `testdata/`; runner executes every .io and .iox file. | Fixtures live in `testdata/`; see `testdata/README.md` (do not overwrite with a raw copy from execution-apis). @@ -77,7 +88,7 @@ The following fixtures were **removed** (no longer in the suite) because they de | `eth_estimateGas/estimate-call-abi-error.io` | Same fixed address, expects revert error | `eth_estimateGas/estimate-call-abi-error-sei.iox` (uses `__REVERTER__`) | | `eth_estimateGas/estimate-failed-call.io` | Fixed address `0x17e7ee...`, expects revert error | Revert (Error) and panic covered by `estimate-call-abi-error-sei.iox` and `estimate-call-abi-panic-sei.iox` (same `__REVERTER__`, input `0x01` / `0x02`) | -The total count reflects the current `.io`/`.iox` set under `testdata/` as of the latest run. +The total count reflects the current `.io`/`.iox` set under `testdata/` (159 files: main baseline plus sei deprecation `.iox`). ## What is checked @@ -97,26 +108,30 @@ The total count reflects the current `.io`/`.iox` set under `testdata/` as of th 2. The script sets `SEI_EVM_IO_SEED_BLOCK` to that block number (hex) and `SEI_EVM_IO_DEPLOY_TX_HASH` to the deploy tx hash. 3. In `.iox` fixtures, `__SEED__` in a request is replaced by that block number (or by `"latest"` if the script didn't run / seed isn't set). 4. Fixtures can bind `${txHash}` from the first request (e.g. `eth_getBlockByNumber(__SEED__, true)` -> `result.transactions.0.hash`) and `${deployTxHash}` is pre-filled from the script when set, so later requests use a known block and known tx hashes instead of "latest" (which might be empty). -5. The script also deploys a **reverter** contract; it sets `SEI_EVM_IO_REVERTER_ADDRESS`. In fixtures, `__REVERTER__` is replaced by that address. The reverter responds to calldata: empty or `0x01` → `Error("user error")`; `0x02` → panic (assert false). Used by `eth_call/call-revert-abi-error-sei.iox`, `eth_call/call-revert-abi-panic-sei.iox`, `eth_estimateGas/estimate-call-abi-error-sei.iox`, and `eth_estimateGas/estimate-call-abi-panic-sei.iox`. If a fixture uses `__REVERTER__` and the env is not set, the test is skipped. +5. The script also deploys a **reverter** contract; it sets `SEI_EVM_IO_REVERTER_ADDRESS`. In fixtures, `__REVERTER__` is replaced by that address. The reverter responds to calldata: empty or `0x01` -> `Error("user error")`; `0x02` -> panic (assert false). Used by `eth_call/call-revert-abi-error-sei.iox`, `eth_call/call-revert-abi-panic-sei.iox`, `eth_estimateGas/estimate-call-abi-error-sei.iox`, and `eth_estimateGas/estimate-call-abi-panic-sei.iox`. If a fixture uses `__REVERTER__` and the env is not set, the test is skipped. So "seed" = a known-good block (and deploy tx) that the script creates and the runner uses so **SEED** and deploy/tx bindings resolve. --- -## Test results (latest run) +## Test results (reference runs) *Source:* **Eth exec api** = from [ethereum/execution-apis](https://github.com/ethereum/execution-apis) (`.io`); **Sei** = Sei-generated (`.iox` or Sei-added `.io`). -### Passed tests (135) +**Latest recorded `TestEVMRPCSpec` (docker localnet, 159 files):** **145** passed, **14** failed, **91.2%** pass rate. **All `sei_*` fixtures pass** (every `testdata/**/sei_*` file, including `sei_legacy_deprecation/*.iox` for HTTP gate + `Sei-Legacy-RPC-Deprecation` header). The **14** failures are **only** `eth_*` (gas, proofs, log range semantics, block height, tx index - see *Failed tests* below). There are **no** `sei2_*` fixtures in `testdata/` yet. + +**Column guide (Summary table below):** **First run** = historical full suite before trimming. **Post-trim baseline** = early **164**-fixture snapshot. **unsupported-fix** = **157** fixtures: current `testdata/` after `eth_simulateV1` removal and **`not-supported.iox`** explicit errors (see [docs/evm_jsonrpc_unsupported.md](../../../docs/evm_jsonrpc_unsupported.md)). **sei_* fix** = **159** files: **157** + two `sei_legacy_deprecation/*.iox`, docker `enabled_legacy_sei_apis` expanded (all gated `sei_*` / `sei2_*` except `sei_sign`); filter-log lifecycle `.iox` use `latest`->`latest` so they respect `max_blocks_for_log`. + +### Passed tests (145 of 159) | Endpoint | Test | Source | | -------------------------------------- | -------------------------------------------------------------- | ------------ | | cross_check | get-block-by-number-then-by-hash.iox | Sei | -| debug_getRawBlock | get-invalid-number.io | Eth exec api | -| debug_getRawHeader | get-invalid-number.io | Eth exec api | -| debug_getRawReceipts | get-invalid-number.io | Eth exec api | -| debug_getRawTransaction | get-invalid-hash.io | Eth exec api | +| debug_getRawBlock | not-supported.iox | Sei | +| debug_getRawHeader | not-supported.iox | Sei | +| debug_getRawReceipts | not-supported.iox | Sei | +| debug_getRawTransaction | not-supported.iox | Sei | | debug_traceBlockByHash | traceBlockByHash.iox | Sei | | debug_traceBlockByNumber | traceBlockByNumber.iox | Sei | | debug_traceBlockByNumber | traceBlockByNumber-latest.io | Eth exec api | @@ -127,6 +142,7 @@ So "seed" = a known-good block (and deploy tx) that the script creates and the r | debug_traceTransaction | traceTransaction.iox | Sei | | echo_echo | echo.io | Sei | | eth_accounts | accounts.io | Sei | +| eth_blobBaseFee | blobs-not-supported-error.iox | Sei | | eth_blockNumber | simple-test.io | Eth exec api | | eth_call | call-callenv.io | Eth exec api | | eth_call | call-contract-from-deploy.iox | Sei | @@ -147,7 +163,9 @@ So "seed" = a known-good block (and deploy tx) that the script creates and the r | eth_getBalance | get-balance-blockhash.iox | Sei | | eth_getBalance | get-balance-unknown-account.io | Eth exec api | | eth_getBalance | get-balance.io | Eth exec api | +| eth_getBlockByHash | get-block-by-empty-hash.iox | Sei | | eth_getBlockByHash | get-block-by-hash.iox | Sei | +| eth_getBlockByHash | get-block-by-notfound-hash.iox | Sei | | eth_getBlockByNumber | get-block-cancun-fork.io | Eth exec api | | eth_getBlockByNumber | get-block-london-fork.io | Eth exec api | | eth_getBlockByNumber | get-block-merge-fork.io | Eth exec api | @@ -164,8 +182,11 @@ So "seed" = a known-good block (and deploy tx) that the script creates and the r | eth_getBlockReceipts | get-block-receipts-future.io | Eth exec api | | eth_getBlockReceipts | get-block-receipts-latest.io | Eth exec api | | eth_getBlockReceipts | get-block-receipts-n.io | Eth exec api | +| eth_getBlockReceipts | get-block-receipts-empty.iox | Sei | +| eth_getBlockReceipts | get-block-receipts-not-found.iox | Sei | | eth_getBlockReceipts | get-receipts-by-latest-block.iox | Sei | | eth_getBlockTransactionCountByHash | get-block-n.iox | Sei | +| eth_getBlockTransactionCountByHash | get-genesis.iox | Sei | | eth_getBlockTransactionCountByNumber | get-block-n.io | Eth exec api | | eth_getBlockTransactionCountByNumber | get-genesis.io | Eth exec api | | eth_getCode | get-code-eip7702-delegation.io | Eth exec api | @@ -218,6 +239,8 @@ So "seed" = a known-good block (and deploy tx) that the script creates and the r | eth_maxPriorityFeePerGas | maxPriorityFeePerGas.io | Sei | | eth_newBlockFilter | newBlockFilter.io | Sei | | eth_newFilter | newFilter.io | Sei | +| eth_newPendingTransactionFilter | not-supported.iox | Sei | +| eth_syncing | not-supported.iox | Sei | | eth_sendRawTransaction | send-access-list-transaction.iox | Sei | | eth_sendRawTransaction | send-blob-tx.iox | Sei | | eth_sendRawTransaction | send-dynamic-fee-access-list-transaction.iox | Sei | @@ -240,6 +263,8 @@ So "seed" = a known-good block (and deploy tx) that the script creates and the r | sei_getLogs | getLogs.io | Sei | | sei_getSeiAddress | getSeiAddress-not-found.io | Sei | | sei_getTransactionReceiptExcludeTraceFail | getTransactionReceiptExcludeTraceFail.iox | Sei | +| sei_legacy_deprecation | deprecation-success.iox | Sei | +| sei_legacy_deprecation | sei_sign-disabled.iox | Sei | | sei_newBlockFilter | newBlockFilter.io | Sei | | sei_newFilter | newFilter.io | Sei | | sei_traceBlockByHashExcludeTraceFail | traceBlockByHashExcludeTraceFail.iox | Sei | @@ -249,26 +274,22 @@ So "seed" = a known-good block (and deploy tx) that the script creates and the r | web3_clientVersion | clientVersion.io | Sei | -### Failed tests (~20; re-run suite for current count) +### Failed tests (14 on latest 159-file reference run; all `sei_*` pass) -Methods that Sei documents as unsupported use dedicated **`not-supported.iox`** fixtures (and `eth_blobBaseFee/blobs-not-supported-error.iox`). They return JSON-RPC **error** `-32000` with a fixed message. See [docs/evm_jsonrpc_unsupported.md](../../../docs/evm_jsonrpc_unsupported.md). +Methods that Sei documents as unsupported use dedicated **`not-supported.iox`** fixtures (and `eth_blobBaseFee/blobs-not-supported-error.iox`). They return JSON-RPC **error** `-32000` with a fixed message (not `-32601`). See [docs/evm_jsonrpc_unsupported.md](../../../docs/evm_jsonrpc_unsupported.md). +*Remaining failures* are **only** `eth_*`: gas / base fee, proofs (IAVL), log range vs spec, block-not-found shape, tx-by-index. **No `sei_*` rows.** On **docker localnet** with expanded `enabled_legacy_sei_apis`, some historically flaky `eth_*` cases pass when the node returns `null` for missing blocks (varies by build/config). | Endpoint | Test | Status | Source | Reason | Error message | | ---------------------------------- | --------------------------------------------------------------------------------- | ------ | ------------ | ---------------------- | ---------------------------------------------------------------------------------------- | -| eth_call | call-callenv-options-eip1559.iox | FAIL | Sei | Gas fee issue | error code=-32000 message="max fee per gas less than block base fee" | +| eth_call | call-callenv-options-eip1559.iox | FAIL | Sei | Gas fee issue | error code=-32000 (e.g. `max fee per gas less than block base fee`, `maxFeePerGas` vs `baseFee`) | | eth_createAccessList | create-al-abi-revert.iox | FAIL | Sei | Insufficient funds | error code=-32000 message="insufficient funds for gas * price + value" | | eth_createAccessList | create-al-contract-eip1559.iox | FAIL | Sei | Gas fee issue | error code=-32000 message="max fee per gas less than block base fee" | | eth_createAccessList | create-al-contract.iox | FAIL | Sei | Insufficient funds | error code=-32000 message="insufficient funds for gas * price + value" | | eth_estimateGas | estimate-with-eip4844.iox | FAIL | Sei | Parse error | error code=-32700 message="parse error" | | eth_estimateGas | estimate-with-eip7702.iox | FAIL | Sei | Parse error | error code=-32700 message="parse error" | | eth_estimateGasAfterCalls | estimateGasAfterCalls.iox | FAIL | Sei | Insufficient funds | error code=-32000 message="insufficient funds for gas * price + value" | -| eth_getBlockByHash | get-block-by-empty-hash.iox | FAIL | Sei | Block not found | error code=-32000 message="could not find block for hash 0000000000000000000000000000000000000000000000000000000000000000" | -| eth_getBlockByHash | get-block-by-notfound-hash.iox | FAIL | Sei | Block not found | error code=-32000 message="could not find block for hash 00000000000000000000000000000000000000000000DEADBEEF" | -| eth_getBlockByNumber | get-block-notfound.iox | FAIL | Sei | Block not available | error code=-32000 message="requested height 1000 is not yet available; safe latest is 655" | -| eth_getBlockReceipts | get-block-receipts-empty.iox | FAIL | Sei | Block not found | error code=-32000 message="could not find block for hash 0000000000000000000000000000000000000000000000000000000000000000" | -| eth_getBlockReceipts | get-block-receipts-not-found.iox | FAIL | Sei | Block not found | error code=-32000 message="could not find block for hash 00000000000000000000000000000000000000000000DEADBEEF" | -| eth_getBlockTransactionCountByHash | get-genesis.iox | FAIL | Sei | Block not found | error code=-32000 message="could not find block for hash F9D3845DF25B43B1C6926F3CEDA6845C17F5624E12212FD8847D0BA01DA1AB9E" | +| eth_getBlockByNumber | get-block-notfound.iox | FAIL | Sei | Block not available | error code=-32000 message="requested height 1000 is not yet available; safe latest is ..." (height varies) | | eth_getLogs | filter-error-future-block-range.io | FAIL | Eth exec api | Expected error, got result | response kind mismatch: expected result=false error=true, actual result=true error=false | | eth_getProof | get-account-proof-blockhash.iox | FAIL | Sei | Store not found | error code=-32000 message="cannot find EVM IAVL store" | | eth_getProof | get-account-proof-latest.iox | FAIL | Sei | Store not found | error code=-32000 message="cannot find EVM IAVL store" | @@ -279,7 +300,7 @@ Methods that Sei documents as unsupported use dedicated **`not-supported.iox`** ### Skipped tests (0) -With the script setting **SEI_EVM_IO_SEED_BLOCK** and **SEI_EVM_IO_DEPLOY_TX_HASH**, no tests are skipped in the latest run. If you run `go test` without the script, some tests may skip for missing `${txHash}` or `${deployTxHash}`. When a test skips, the runner logs **[SKIP]** lines with bindings and placeholders so you can see why. +With the script setting **SEI_EVM_IO_SEED_BLOCK** and **SEI_EVM_IO_DEPLOY_TX_HASH**, no tests are skipped in the reference runs above. If you run `go test` without the script, some tests may skip for missing `${txHash}` or `${deployTxHash}`. When a test skips, the runner logs **[SKIP]** lines with bindings and placeholders so you can see why. **Debug one or a few SEED tests:** Run only specific files with extra per-pair logging (request after substitution, bindings, whether `result.transactions` is present): @@ -289,20 +310,20 @@ SEI_EVM_IO_RUN_INTEGRATION=1 SEI_EVM_IO_DEBUG_FILES="debug_getRawTransaction/not Use a comma-separated list to run up to a few files, e.g. `debug_getRawTransaction/not-supported.iox,debug_traceTransaction/traceTransaction.iox`. Logs show `SEI_EVM_IO_SEED_BLOCK`, each pair's placeholders and binding values, the actual request sent, and bindings after each response. -### Summary (three recorded runs) +### Summary (recorded runs) + +| Metric | First run | Post-trim baseline | unsupported-fix | sei_* fix | +| ------ | --------- | ------------------- | ---------------- | ---------------------- | +| **Total tests** | 255 | 164 | 157 | 159 | +| **Passed** | 157 | 135 | 142 | 145 | +| **Failed** | 98 | 29 | 15 | 14 | +| **Skipped** | 0 | 0 | 0 | 0 | +| **Pass rate** | 61.6% | 82.3% | ~90.4% | 91.2% | -| Metric | 248 tests¹ | 157 tests (29 fails)² | 157 tests latest (15 fails)³ | -| ------ | ---------- | ---------------------- | ---------------------------- | -| **Total tests** | 248 | 157 | 157 | -| **Passed** | 157 | 128 | 142 | -| **Failed** | 98 | 29 | 15 | -| **Skipped** | 0 | 0 | 0 | -| **Pass rate** | 61.6% | 81.5% | 90.4% | +(1) **First run** / **Post-trim** = historical snapshots. (2) **unsupported-fix** = **157** fixtures with `not-supported.iox` and related explicit unsupported RPC behavior (see [docs/evm_jsonrpc_unsupported.md](../../../docs/evm_jsonrpc_unsupported.md)); representative run ~142 pass / ~15 fail. (3) **sei_* fix (159)** = **157** + `sei_legacy_deprecation/*.iox`; docker `enabled_legacy_sei_apis` per `docker/localnode/config/app.toml` (gated `sei_*` / `sei2_*`). Reference `TestEVMRPCSpecSummary`: **145 / 14 / 91.2%**. Associate setup may log `result: null` for `sei_associate` in the script; that is separate from the `.iox` assertions. -¹ Broader / earlier suite snapshot (includes more fixtures than current `testdata/`). -² Current fixture count after trimming; **29** spec mismatches before explicit unsupported-RPC (`not-supported.iox`) work and related fixes. -³ Same **157** fixtures, latest `evm_rpc_tests.sh` (e.g. Mar 2026); **15** fails—includes height-sensitive filter lifecycle when chain span >2000 blocks. +**Legacy `sei_*`:** All `sei_*` fixtures pass with expanded allowlist (including `sei_legacy_deprecation/*.iox` and filter lifecycle `.iox`). Remaining fails are **`eth_*` only**, not gated `sei_*` denial. @@ -315,4 +336,4 @@ Use a comma-separated list to run up to a few files, e.g. `debug_getRawTransacti **eth_simulateV1**: that folder (1 endpoint, 64 fixtures) is no longer under `testdata/`, it was removed, so the current suite has **70** endpoint folders. -*Results are from a single local run; re-run `evm_rpc_tests.sh` to refresh.* +*Re-run `./integration_test/evm_module/scripts/evm_rpc_tests.sh` to refresh counts; **sei_* fix** column matches docker localnet with expanded `[evm].enabled_legacy_sei_apis` (see `docker/localnode/config/app.toml`).* diff --git a/integration_test/evm_module/rpc_io_test/io.go b/integration_test/evm_module/rpc_io_test/io.go index 54942185ef..2769498ed9 100644 --- a/integration_test/evm_module/rpc_io_test/io.go +++ b/integration_test/evm_module/rpc_io_test/io.go @@ -35,15 +35,22 @@ type binding struct { Path string } -// ioxPair is one request/response pair from a .io/.iox file. +// ioxPair is one request/response pair from a .io or .iox file type ioxPair struct { Request []byte Expected []byte AfterBindings []binding RefPair int // 1-based; 0 = no ref check + + // ExpectBodyContains: each substring must appear in the response body (UTF-8). + ExpectBodyContains []string + + // ExpectResponseHeaders: each name must be present on the HTTP response (case-insensitive). + ExpectResponseHeaders []string } -// parseIOFile parses .io/.iox content. Supports ">> request", "<< expected", "@ bind var = path", "<< @ ref_pair N". +// parseIOFile parses .io/.iox content. Supports ">> request", "<< expected", "@ bind var = path", +// "<< @ ref_pair N", "@ expect_body_contains substring", "@ expect_response_header Header-Name". func parseIOFile(content string) ([]ioxPair, error) { var pairs []ioxPair var curReq []byte @@ -101,23 +108,61 @@ func parseIOFile(content string) ([]ioxPair, error) { return nil, fmt.Errorf("invalid bind directive: var and path must be non-empty, got %q", trimmed) } pairs[lastIdx].AfterBindings = append(pairs[lastIdx].AfterBindings, binding{Var: varName, Path: path}) + continue + } + if after, ok := strings.CutPrefix(rest, "expect_body_contains "); ok { + sub := strings.TrimSpace(after) + if sub == "" { + return nil, fmt.Errorf("expect_body_contains needs non-empty substring: %q", trimmed) + } + pairs[lastIdx].ExpectBodyContains = append(pairs[lastIdx].ExpectBodyContains, sub) + continue } + if after, ok := strings.CutPrefix(rest, "expect_response_header "); ok { + name := strings.TrimSpace(after) + if name == "" { + return nil, fmt.Errorf("expect_response_header needs non-empty header name: %q", trimmed) + } + pairs[lastIdx].ExpectResponseHeaders = append(pairs[lastIdx].ExpectResponseHeaders, name) + continue + } + return nil, fmt.Errorf("unknown directive: %q", trimmed) } } return pairs, nil } -func (c *rpcClient) call(req []byte) ([]byte, int, error) { +// assertPairBodyDirectives checks optional @ expect_body_contains rules for one .io pair. +func assertPairBodyDirectives(t *testing.T, pair ioxPair, body []byte) { + t.Helper() + for _, sub := range pair.ExpectBodyContains { + if !strings.Contains(string(body), sub) { + t.Fatalf("expected response body to contain %q", sub) + } + } +} + +func assertPairHeaderDirectives(t *testing.T, pair ioxPair, hdr http.Header) { + t.Helper() + for _, name := range pair.ExpectResponseHeaders { + if hdr.Get(name) == "" { + t.Fatalf("expected response header %q to be set (got %v)", name, hdr) + } + } +} + +func (c *rpcClient) call(req []byte) ([]byte, int, http.Header, error) { resp, err := c.httpClient().Post(c.URL, "application/json", bytes.NewReader(req)) if err != nil { - return nil, 0, err + return nil, 0, nil, err } defer func() { _ = resp.Body.Close() }() + hcopy := resp.Header.Clone() var buf bytes.Buffer if _, err := buf.ReadFrom(resp.Body); err != nil { - return nil, resp.StatusCode, err + return nil, resp.StatusCode, hcopy, err } - return buf.Bytes(), resp.StatusCode, nil + return buf.Bytes(), resp.StatusCode, hcopy, nil } func (c *rpcClient) httpClient() *http.Client { diff --git a/integration_test/evm_module/rpc_io_test/io_parse_test.go b/integration_test/evm_module/rpc_io_test/io_parse_test.go index 402eeec323..1b5ffd5ee8 100644 --- a/integration_test/evm_module/rpc_io_test/io_parse_test.go +++ b/integration_test/evm_module/rpc_io_test/io_parse_test.go @@ -97,6 +97,40 @@ func TestParseIOFile_CommentsAndWhitespace(t *testing.T) { } } +func TestParseIOFile_ExpectBodyContains(t *testing.T) { + content := `>> {"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]} +<< {"jsonrpc":"2.0","id":1,"result":"0x1"} +@ expect_body_contains jsonrpc +` + pairs, err := parseIOFile(content) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(pairs) != 1 { + t.Fatalf("expected 1 pair, got %d", len(pairs)) + } + if len(pairs[0].ExpectBodyContains) != 1 || pairs[0].ExpectBodyContains[0] != "jsonrpc" { + t.Fatalf("ExpectBodyContains: %+v", pairs[0].ExpectBodyContains) + } +} + +func TestParseIOFile_ExpectResponseHeader(t *testing.T) { + content := `>> {"jsonrpc":"2.0","id":1,"method":"sei_getBlockByNumber","params":["latest",false]} +<< {"jsonrpc":"2.0","id":1,"result":{}} +@ expect_response_header Sei-Legacy-RPC-Deprecation +` + pairs, err := parseIOFile(content) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(pairs) != 1 { + t.Fatalf("expected 1 pair, got %d", len(pairs)) + } + if len(pairs[0].ExpectResponseHeaders) != 1 || pairs[0].ExpectResponseHeaders[0] != "Sei-Legacy-RPC-Deprecation" { + t.Fatalf("ExpectResponseHeaders: %+v", pairs[0].ExpectResponseHeaders) + } +} + func TestParseIOFile_RefPairOnly(t *testing.T) { content := `>> {"method":"eth_getBlockByHash","params":["0xabc",false]} << @ ref_pair 1 @@ -152,6 +186,16 @@ func TestParseIOFile_InvalidDirectives(t *testing.T) { t.Fatal("expected error for bind with empty var") } }) + t.Run("unknown directive", func(t *testing.T) { + content := `>> {"method":"eth_chainId"} +<< {"result":"0x1"} +@ not_a_real_directive foo +` + _, err := parseIOFile(content) + if err == nil { + t.Fatal("expected error for unknown directive") + } + }) } func TestGetJSONPath(t *testing.T) { diff --git a/integration_test/evm_module/rpc_io_test/rpc_io_test.go b/integration_test/evm_module/rpc_io_test/rpc_io_test.go index 64138d4eb4..fe7f52e19b 100644 --- a/integration_test/evm_module/rpc_io_test/rpc_io_test.go +++ b/integration_test/evm_module/rpc_io_test/rpc_io_test.go @@ -128,7 +128,7 @@ func TestEVMRPCSpec(t *testing.T) { if debug { t.Logf("[DEBUG] pair %d: request %s", i+1, req) } - body, status, err := client.call(req) + body, status, respHdr, err := client.call(req) if err != nil { t.Fatalf("pair %d: call: %v", i+1, err) } @@ -152,6 +152,8 @@ func TestEVMRPCSpec(t *testing.T) { if !sameBlockResult(t, body, responses[refIdx]) { t.Fatalf("pair %d: ref_pair %d check failed", i+1, pair.RefPair) } + assertPairBodyDirectives(t, pair, body) + assertPairHeaderDirectives(t, pair, respHdr) continue } if len(pair.Expected) > 0 { @@ -159,6 +161,8 @@ func TestEVMRPCSpec(t *testing.T) { logActualResponse(t, body) t.Fatalf("pair %d: spec-only check failed", i+1) } + assertPairBodyDirectives(t, pair, body) + assertPairHeaderDirectives(t, pair, respHdr) continue } var m map[string]json.RawMessage @@ -170,6 +174,8 @@ func TestEVMRPCSpec(t *testing.T) { t.Fatalf("pair %d: response has neither result nor error", i+1) } } + assertPairBodyDirectives(t, pair, body) + assertPairHeaderDirectives(t, pair, respHdr) } }) } @@ -208,7 +214,7 @@ func rpcURL() string { } func nodeReachable(c *rpcClient) bool { - body, status, err := c.call([]byte(`{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}`)) + body, status, _, err := c.call([]byte(`{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}`)) if err != nil || status != http.StatusOK { return false } diff --git a/integration_test/evm_module/rpc_io_test/testdata/eth_getFilterLogs/getFilterLogs-lifecycle.iox b/integration_test/evm_module/rpc_io_test/testdata/eth_getFilterLogs/getFilterLogs-lifecycle.iox index 79f871dc82..4a00ecf03c 100644 --- a/integration_test/evm_module/rpc_io_test/testdata/eth_getFilterLogs/getFilterLogs-lifecycle.iox +++ b/integration_test/evm_module/rpc_io_test/testdata/eth_getFilterLogs/getFilterLogs-lifecycle.iox @@ -1,5 +1,6 @@ // Data-dependent: create filter, bind filterId, then getFilterLogs(filterId). Run evm_rpc_tests.sh when local. ->> {"jsonrpc":"2.0","id":1,"method":"eth_newFilter","params":[{"fromBlock":"0x1","toBlock":"latest"}]} +// latest->latest keeps span <= max_blocks_for_log; 0x1->latest fails once chain height exceeds the limit. +>> {"jsonrpc":"2.0","id":1,"method":"eth_newFilter","params":[{"fromBlock":"latest","toBlock":"latest"}]} << {"jsonrpc":"2.0","id":1,"result":"0x"} @ bind filterId = result >> {"jsonrpc":"2.0","id":2,"method":"eth_getFilterLogs","params":["${filterId}"]} diff --git a/integration_test/evm_module/rpc_io_test/testdata/sei_getFilterLogs/getFilterLogs.iox b/integration_test/evm_module/rpc_io_test/testdata/sei_getFilterLogs/getFilterLogs.iox index ab2d1b7db5..884d993dbe 100644 --- a/integration_test/evm_module/rpc_io_test/testdata/sei_getFilterLogs/getFilterLogs.iox +++ b/integration_test/evm_module/rpc_io_test/testdata/sei_getFilterLogs/getFilterLogs.iox @@ -1,5 +1,6 @@ // Data-dependent: sei_newFilter, bind filterId, then sei_getFilterLogs(filterId). Run evm_rpc_tests.sh when local. ->> {"jsonrpc":"2.0","id":1,"method":"sei_newFilter","params":[{"fromBlock":"0x1","toBlock":"latest"}]} +// Use latest->latest so block span stays within max_blocks_for_log (default 2000); 0x1->latest fails on long-lived localnet. +>> {"jsonrpc":"2.0","id":1,"method":"sei_newFilter","params":[{"fromBlock":"latest","toBlock":"latest"}]} << {"jsonrpc":"2.0","id":1,"result":"0x"} @ bind filterId = result >> {"jsonrpc":"2.0","id":2,"method":"sei_getFilterLogs","params":["${filterId}"]} diff --git a/integration_test/evm_module/rpc_io_test/testdata/sei_legacy_deprecation/deprecation-success.iox b/integration_test/evm_module/rpc_io_test/testdata/sei_legacy_deprecation/deprecation-success.iox new file mode 100644 index 0000000000..ac0e32183e --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/sei_legacy_deprecation/deprecation-success.iox @@ -0,0 +1,4 @@ +// Docker localnet: all gated sei_* enabled except sei_sign. Asserts deprecation HTTP header on a successful sei_* call (JSON body unchanged). +>> {"jsonrpc":"2.0","id":1,"method":"sei_getBlockByNumber","params":["latest",false]} +<< {"jsonrpc":"2.0","id":1,"result":{}} +@ expect_response_header Sei-Legacy-RPC-Deprecation diff --git a/integration_test/evm_module/rpc_io_test/testdata/sei_legacy_deprecation/sei_sign-disabled.iox b/integration_test/evm_module/rpc_io_test/testdata/sei_legacy_deprecation/sei_sign-disabled.iox new file mode 100644 index 0000000000..944a2d430c --- /dev/null +++ b/integration_test/evm_module/rpc_io_test/testdata/sei_legacy_deprecation/sei_sign-disabled.iox @@ -0,0 +1,6 @@ +// sei_sign is omitted from docker enabled_legacy_sei_apis to exercise the gate (partner-safe: not in Binance/Dune/Alchemy telemetry; no other rpc_io fixtures use it). +>> {"jsonrpc":"2.0","id":42,"method":"sei_sign","params":[]} +<< {"jsonrpc":"2.0","id":42,"error":{}} +@ expect_body_contains legacy_sei_deprecated +@ expect_body_contains not enabled +@ expect_body_contains deprecated