From fe4179b066c80d0232cfaa018206bcaafdb7959d Mon Sep 17 00:00:00 2001 From: snowkide Date: Wed, 14 Oct 2026 14:28:49 +0200 Subject: [PATCH 1/4] fix(json-rpc): treat "error": null in upstream responses as success JSON-RPC 1.0-style servers (Bitcoin Core and its forks, among others) always include the "error" member and set it to null on success. The response parser handed the literal "null" to ParseError, whose fallback turned it into a server-side exception, so every successful call to such a node was reported as a failure and retried against other upstreams. Treat a null error as the absence of an error, both in the streaming parser and in ParseError (used by the batch and from-bytes paths). Co-Authored-By: Claude Opus 5.5 --- common/json_rpc.go | 15 +++++++++++++-- common/json_rpc_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/common/json_rpc.go b/common/json_rpc.go index a967d2217..4f7e55df5 100644 --- a/common/json_rpc.go +++ b/common/json_rpc.go @@ -373,7 +373,10 @@ func (r *JsonRpcResponse) ParseFromStream(ctx []context.Context, reader io.Reade } } - if len(temp.Error) > 0 { + // JSON-RPC 1.0-style servers (e.g. Bitcoin Core and its forks) always emit + // the "error" member and set it to null on success. A null error is the + // absence of an error, not an error. + if len(temp.Error) > 0 && string(temp.Error) != "null" { if err := r.ParseError(string(temp.Error)); err != nil { return err } @@ -401,11 +404,19 @@ func (r *JsonRpcResponse) ParseError(raw string) error { r.errBytes = nil + // `"error": null` means success (JSON-RPC 1.0-style servers always emit the + // member). Without this, the fallback below would turn the literal "null" + // into a server-side exception and fail an otherwise successful call. + if raw == "null" { + r.Error = nil + return nil + } + // First attempt to unmarshal the error as a typical JSON-RPC error var rpcErr ErrJsonRpcExceptionExternal if err := SonicCfg.UnmarshalFromString(raw, &rpcErr); err != nil { // Special case: check for non-standard error structures in the raw data - if raw == "" || raw == "null" { + if raw == "" { r.Error = NewErrJsonRpcExceptionExternal( int(JsonRpcErrorServerSideException), "unexpected empty response from upstream endpoint", diff --git a/common/json_rpc_test.go b/common/json_rpc_test.go index 1a78528c4..83ce14131 100644 --- a/common/json_rpc_test.go +++ b/common/json_rpc_test.go @@ -238,6 +238,36 @@ func TestJsonRpcRequest_MarshalParams(t *testing.T) { }) } +func TestJsonRpcResponse_ErrorNullIsSuccess(t *testing.T) { + t.Run("ParseFromStream", func(t *testing.T) { + raw := `{"result":67851510,"error":null,"id":1}` + r := &JsonRpcResponse{} + require.NoError(t, r.ParseFromStream(nil, bytes.NewReader([]byte(raw)), len(raw))) + assert.Nil(t, r.Error) + assert.Equal(t, "67851510", string(r.result)) + }) + + t.Run("ParseError", func(t *testing.T) { + r := &JsonRpcResponse{} + require.NoError(t, r.ParseError("null")) + assert.Nil(t, r.Error) + }) + + t.Run("FromBytes", func(t *testing.T) { + r, err := NewJsonRpcResponseFromBytes([]byte("1"), []byte(`"ok"`), []byte("null")) + require.NoError(t, err) + assert.Nil(t, r.Error) + }) + + t.Run("NonNullErrorStillParsed", func(t *testing.T) { + raw := `{"result":null,"error":{"code":-32601,"message":"Method not found"},"id":1}` + r := &JsonRpcResponse{} + require.NoError(t, r.ParseFromStream(nil, bytes.NewReader([]byte(raw)), len(raw))) + require.NotNil(t, r.Error) + assert.Equal(t, -32601, r.Error.Code) + }) +} + func TestJsonRpcResponse_CanonicalHash_EmptyishNormalization(t *testing.T) { // Test cases that should produce the same hash due to emptyish normalization testGroups := []struct { From 5017038eedc7be50481d9c696a4ffda0c55668aa Mon Sep 17 00:00:00 2001 From: snowkide Date: Wed, 14 Oct 2026 14:45:03 +0200 Subject: [PATCH 2/4] feat(architecture): add a generic "jsonrpc" passthrough network architecture eRPC currently serves EVM and SVM chains, each with a chain model (chain-id or genesis detection, state poller, method hooks). Many other chains expose a plain JSON-RPC API (Bitcoin-family nodes, Stellar RPC, and others) and only need eRPC's generic pipeline: upstream selection, failsafe policies, rate limiting, multiplexing, metrics and healthchecks. Add a third architecture, "jsonrpc", that provides exactly that and nothing chain-specific: - architecture/jsonrpc registers a no-op ArchitectureHandler through the architecture registry; its error extractor claims nothing, so errors fall through to the default extractor (HTTP status + standard JSON-RPC codes). - Networks: `architecture: jsonrpc` with `jsonRpc.id` (defaults to the alias); network id "jsonrpc:". Inferred from a `jsonRpc` block, never receives networkDefaults.evm, and can be created lazily from the URL (//jsonrpc/) like other architectures. - Upstreams: `type: jsonrpc` with `jsonRpc.networkId` (type is inferred when networkId is set). Bootstrap only derives the network id from config; nothing is sent to the node. They reuse the generic HTTP and WebSocket JSON-RPC clients; gRPC endpoints are rejected. - Requests are validated as JSON-RPC and forwarded unchanged (no normalization); finality is unknown unless set via methods.definitions, and no cache is wired since there is no block/slot model. - Healthcheck scoping and all:activeUpstreams counting understand jsonrpc networks. - TypeScript config types regenerated with tygo; NetworkArchitecture and UpstreamType unions extended. Co-Authored-By: Claude Opus 5.5 --- architecture/jsonrpc/handler.go | 60 ++++ architecture/jsonrpc/handler_test.go | 48 +++ clients/registry.go | 6 +- clients/registry_test.go | 32 ++ common/architecture_jsonrpc.go | 23 ++ common/architecture_jsonrpc_test.go | 187 ++++++++++ common/config.go | 13 + common/defaults.go | 33 +- common/network.go | 9 +- common/validation.go | 16 + erpc/erpc.go | 1 + erpc/healthcheck.go | 7 +- erpc/http_server.go | 2 +- erpc/jsonrpc_passthrough_test.go | 331 ++++++++++++++++++ erpc/networks.go | 11 +- erpc/networks_registry.go | 8 + .../config/src/__tests__/jsonrpc.test-d.ts | 68 ++++ typescript/config/src/generated.ts | 40 +++ typescript/config/src/index.ts | 3 + typescript/config/src/types/generic.ts | 5 +- upstream/registry.go | 4 +- upstream/upstream.go | 15 + util/ids.go | 9 + util/ids_test.go | 23 ++ 24 files changed, 937 insertions(+), 17 deletions(-) create mode 100644 architecture/jsonrpc/handler.go create mode 100644 architecture/jsonrpc/handler_test.go create mode 100644 common/architecture_jsonrpc.go create mode 100644 common/architecture_jsonrpc_test.go create mode 100644 erpc/jsonrpc_passthrough_test.go create mode 100644 typescript/config/src/__tests__/jsonrpc.test-d.ts diff --git a/architecture/jsonrpc/handler.go b/architecture/jsonrpc/handler.go new file mode 100644 index 000000000..49b5043d7 --- /dev/null +++ b/architecture/jsonrpc/handler.go @@ -0,0 +1,60 @@ +// Package jsonrpc implements the "jsonrpc" network architecture: a +// chain-agnostic passthrough for generic JSON-RPC upstreams. +// +// Networks of this architecture get the generic pipeline (routing, failsafe +// policies, rate limiting, selection policies, multiplexing, metrics) but no +// chain-specific behavior: no chain-id or genesis detection, no state poller, +// no request normalization and no per-method hooks. Every method is forwarded +// to the selected upstream unchanged. +package jsonrpc + +import ( + "context" + "net/http" + + "github.com/erpc/erpc/common" +) + +func init() { + common.RegisterArchitecture(common.ArchitectureJsonRpc, &JsonRpcArchitectureHandler{}) +} + +// JsonRpcArchitectureHandler registers the passthrough architecture with the +// generic pipeline. All hooks are no-ops. +type JsonRpcArchitectureHandler struct{} + +var _ common.ArchitectureHandler = (*JsonRpcArchitectureHandler)(nil) + +func (h *JsonRpcArchitectureHandler) HandleProjectPreForward(ctx context.Context, network common.Network, req *common.NormalizedRequest) (bool, *common.NormalizedResponse, error) { + return false, nil, nil +} + +func (h *JsonRpcArchitectureHandler) HandleNetworkPreForward(ctx context.Context, network common.Network, upstreams []common.Upstream, req *common.NormalizedRequest) (bool, *common.NormalizedResponse, error) { + return false, nil, nil +} + +func (h *JsonRpcArchitectureHandler) HandleNetworkPostForward(ctx context.Context, network common.Network, req *common.NormalizedRequest, resp *common.NormalizedResponse, err error) (*common.NormalizedResponse, error) { + return resp, err +} + +func (h *JsonRpcArchitectureHandler) HandleUpstreamPreForward(ctx context.Context, network common.Network, upstream common.Upstream, req *common.NormalizedRequest, skipCacheRead bool) (bool, *common.NormalizedResponse, error) { + return false, nil, nil +} + +func (h *JsonRpcArchitectureHandler) HandleUpstreamPostForward(ctx context.Context, network common.Network, upstream common.Upstream, req *common.NormalizedRequest, resp *common.NormalizedResponse, err error, skipCacheRead bool) (*common.NormalizedResponse, error) { + return resp, err +} + +// NewJsonRpcErrorExtractor returns an extractor that claims no errors. The +// composite extractor then falls through to the EVM extractor (which only +// skips svm upstreams), so jsonrpc upstreams get the default mapping of HTTP +// status codes and standard JSON-RPC error codes into eRPC's error taxonomy. +func (h *JsonRpcArchitectureHandler) NewJsonRpcErrorExtractor() common.JsonRpcErrorExtractor { + return passthroughErrorExtractor{} +} + +type passthroughErrorExtractor struct{} + +func (passthroughErrorExtractor) Extract(*http.Response, *common.NormalizedResponse, *common.JsonRpcResponse, common.Upstream) error { + return nil +} diff --git a/architecture/jsonrpc/handler_test.go b/architecture/jsonrpc/handler_test.go new file mode 100644 index 000000000..02475797c --- /dev/null +++ b/architecture/jsonrpc/handler_test.go @@ -0,0 +1,48 @@ +package jsonrpc + +import ( + "context" + "errors" + "testing" + + "github.com/erpc/erpc/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJsonRpcArchitectureHandler_RegisteredAsPassthrough(t *testing.T) { + h, err := common.GetArchitectureHandler(common.ArchitectureJsonRpc) + require.NoError(t, err) + require.IsType(t, &JsonRpcArchitectureHandler{}, h) + + ctx := context.Background() + req := common.NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"getblockcount"}`)) + + handled, resp, err := h.HandleProjectPreForward(ctx, nil, req) + assert.False(t, handled) + assert.Nil(t, resp) + assert.NoError(t, err) + + handled, resp, err = h.HandleNetworkPreForward(ctx, nil, nil, req) + assert.False(t, handled) + assert.Nil(t, resp) + assert.NoError(t, err) + + handled, resp, err = h.HandleUpstreamPreForward(ctx, nil, nil, req, false) + assert.False(t, handled) + assert.Nil(t, resp) + assert.NoError(t, err) + + // Post-forward hooks hand back exactly what they were given. + in := common.NewNormalizedResponse().WithRequest(req) + inErr := errors.New("upstream failed") + out, outErr := h.HandleNetworkPostForward(ctx, nil, req, in, inErr) + assert.Same(t, in, out) + assert.Same(t, inErr, outErr) + out, outErr = h.HandleUpstreamPostForward(ctx, nil, nil, req, in, inErr, false) + assert.Same(t, in, out) + assert.Same(t, inErr, outErr) + + // The extractor claims nothing, deferring to the default extractor. + assert.NoError(t, h.NewJsonRpcErrorExtractor().Extract(nil, nil, nil, nil)) +} diff --git a/clients/registry.go b/clients/registry.go index a4530ee6c..813c3fbd8 100644 --- a/clients/registry.go +++ b/clients/registry.go @@ -95,7 +95,9 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U creation.once.Do(func() { lg := manager.logger.With().Str("upstreamId", cfg.Id).Logger() switch cfg.Type { - case common.UpstreamTypeEvm: + case common.UpstreamTypeEvm, common.UpstreamTypeJsonRpc: + // jsonrpc (passthrough) upstreams reuse the generic HTTP and + // WebSocket JSON-RPC clients; gRPC BDS stays EVM-only. if parsedUrl.Scheme == "http" || parsedUrl.Scheme == "https" { newClient, err = NewGenericHttpJsonRpcClient( appCtx, @@ -129,6 +131,8 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U clientErr = fmt.Errorf("failed to create WebSocket client for upstream %v: %w", cfg.Id, err) } } + } else if cfg.Type == common.UpstreamTypeJsonRpc { + clientErr = fmt.Errorf("unsupported endpoint scheme for jsonrpc upstream %v: %v (only http/https/ws/wss supported)", cfg.Id, parsedUrl.Scheme) } else if parsedUrl.Scheme == "grpc" || parsedUrl.Scheme == "grpc+bds" || parsedUrl.Scheme == "grpcs" { grpcPoolSize, grpcHealthCheckService := 0, "" if cfg.Grpc != nil { diff --git a/clients/registry_test.go b/clients/registry_test.go index 6f4ef1d79..1895bbc7c 100644 --- a/clients/registry_test.go +++ b/clients/registry_test.go @@ -47,3 +47,35 @@ func TestClientRegistryRejectsProxyPoolForWebSocket(t *testing.T) { _, err = registry.GetOrCreateClient(ctx, up) require.ErrorContains(t, err, "proxyPool is not supported for WebSocket") } + +// jsonrpc (passthrough) upstreams reuse the generic HTTP and WebSocket +// JSON-RPC clients; gRPC endpoints are EVM-only and must be refused. +func TestClientRegistryJsonRpcUpstreams(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + logger := zerolog.Nop() + registry := NewClientRegistry(&logger, "test-project", nil, nil) + + newUps := func(id, endpoint string) common.Upstream { + up := common.NewFakeUpstream(id) + up.Config().Type = common.UpstreamTypeJsonRpc + up.Config().Endpoint = endpoint + up.Config().JsonRpc = &common.JsonRpcUpstreamConfig{NetworkId: "bitcoin-mainnet"} + return up + } + + client, err := registry.GetOrCreateClient(ctx, newUps("http-upstream", "http://rpc1.localhost")) + require.NoError(t, err) + require.IsType(t, &GenericHttpJsonRpcClient{}, client) + + // Nothing listens on port 1: the initial dial fails fast and the client + // keeps retrying in the background until ctx is cancelled. + client, err = registry.GetOrCreateClient(ctx, newUps("ws-upstream", "ws://127.0.0.1:1")) + require.NoError(t, err) + require.NotNil(t, client) + + for _, scheme := range []string{"grpc", "grpc+bds", "grpcs"} { + _, err = registry.GetOrCreateClient(ctx, newUps(scheme+"-upstream", scheme+"://rpc1.localhost")) + require.ErrorContains(t, err, "unsupported endpoint scheme for jsonrpc upstream", scheme) + } +} diff --git a/common/architecture_jsonrpc.go b/common/architecture_jsonrpc.go new file mode 100644 index 000000000..fa91701dd --- /dev/null +++ b/common/architecture_jsonrpc.go @@ -0,0 +1,23 @@ +package common + +const ( + // UpstreamTypeJsonRpc marks an upstream that speaks plain JSON-RPC and is + // served through the "jsonrpc" passthrough architecture. Such upstreams + // get no chain-specific detection, state polling or method hooks. + UpstreamTypeJsonRpc UpstreamType = "jsonrpc" +) + +// JsonRpcNetworkConfig identifies a generic JSON-RPC network served by the +// "jsonrpc" passthrough architecture. It is meant for non-EVM, non-SVM nodes +// (for example Bitcoin-family or Stellar RPC servers) where eRPC should only +// provide routing, failover, rate limiting and metrics, and forward every +// method unchanged. +// +// The resulting network id is "jsonrpc:". Upstreams join the network by +// setting the same value in jsonRpc.networkId. +type JsonRpcNetworkConfig struct { + // Id is the network slug, e.g. "bitcoin-mainnet". It may contain only + // alphanumeric characters, dash and underscore. When omitted it defaults + // to the network alias. + Id string `yaml:"id,omitempty" json:"id,omitempty"` +} diff --git a/common/architecture_jsonrpc_test.go b/common/architecture_jsonrpc_test.go new file mode 100644 index 000000000..29f7ea78f --- /dev/null +++ b/common/architecture_jsonrpc_test.go @@ -0,0 +1,187 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestJsonRpcArchitecture_NetworkId(t *testing.T) { + n := &NetworkConfig{ + Architecture: ArchitectureJsonRpc, + JsonRpc: &JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}, + } + assert.Equal(t, "jsonrpc:bitcoin-mainnet", n.NetworkId()) + + assert.Equal(t, "", (&NetworkConfig{Architecture: ArchitectureJsonRpc}).NetworkId(), + "no id yet means no network id") + assert.Equal(t, "", (&NetworkConfig{Architecture: ArchitectureJsonRpc, JsonRpc: &JsonRpcNetworkConfig{}}).NetworkId()) +} + +func TestJsonRpcArchitecture_IsValid(t *testing.T) { + assert.True(t, IsValidArchitecture(string(ArchitectureJsonRpc))) + + for _, valid := range []string{"jsonrpc:bitcoin-mainnet", "jsonrpc:stellar_testnet", "jsonrpc:x1"} { + assert.True(t, IsValidNetwork(valid), valid) + } + for _, invalid := range []string{"jsonrpc:", "jsonrpc:a:b", "jsonrpc:has space", "jsonrpc:a.b", "jsonrpc"} { + assert.False(t, IsValidNetwork(invalid), invalid) + } +} + +func TestJsonRpcArchitecture_UnmarshalYAML(t *testing.T) { + t.Run("current format", func(t *testing.T) { + const raw = ` +architecture: jsonrpc +alias: bitcoin +jsonRpc: + id: bitcoin-mainnet +failsafe: + - matchMethod: "*" + timeout: + duration: 30s +` + var n NetworkConfig + require.NoError(t, yaml.Unmarshal([]byte(raw), &n)) + assert.Equal(t, ArchitectureJsonRpc, n.Architecture) + require.NotNil(t, n.JsonRpc) + assert.Equal(t, "bitcoin-mainnet", n.JsonRpc.Id) + assert.Equal(t, "jsonrpc:bitcoin-mainnet", n.NetworkId()) + }) + + t.Run("legacy single-object failsafe keeps jsonRpc block", func(t *testing.T) { + const raw = ` +architecture: jsonrpc +jsonRpc: + id: bitcoin-mainnet +failsafe: + timeout: + duration: 30s +` + var n NetworkConfig + require.NoError(t, yaml.Unmarshal([]byte(raw), &n)) + require.NotNil(t, n.JsonRpc) + assert.Equal(t, "bitcoin-mainnet", n.JsonRpc.Id) + require.Len(t, n.Failsafe, 1) + assert.NotNil(t, n.Failsafe[0].Timeout) + }) + + t.Run("upstream jsonRpc.networkId", func(t *testing.T) { + const raw = ` +id: node-1 +endpoint: https://rpc.example.com +jsonRpc: + networkId: bitcoin-mainnet +` + var u UpstreamConfig + require.NoError(t, yaml.Unmarshal([]byte(raw), &u)) + require.NotNil(t, u.JsonRpc) + assert.Equal(t, "bitcoin-mainnet", u.JsonRpc.NetworkId) + }) +} + +func TestJsonRpcArchitecture_NetworkSetDefaults(t *testing.T) { + evmDefaults := func() *NetworkDefaults { + return &NetworkDefaults{Evm: &EvmNetworkConfig{GetLogsMaxAllowedRange: 30000}} + } + + t.Run("architecture inferred from jsonRpc block", func(t *testing.T) { + n := &NetworkConfig{JsonRpc: &JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}} + require.NoError(t, n.SetDefaults(nil, nil)) + assert.Equal(t, ArchitectureJsonRpc, n.Architecture) + assert.Equal(t, "jsonrpc:bitcoin-mainnet", n.NetworkId()) + }) + + t.Run("id defaults to alias", func(t *testing.T) { + n := &NetworkConfig{Architecture: ArchitectureJsonRpc, Alias: "stellar-mainnet"} + require.NoError(t, n.SetDefaults(nil, nil)) + require.NotNil(t, n.JsonRpc) + assert.Equal(t, "stellar-mainnet", n.JsonRpc.Id) + assert.Equal(t, "jsonrpc:stellar-mainnet", n.NetworkId()) + }) + + t.Run("explicit id wins over alias", func(t *testing.T) { + n := &NetworkConfig{Architecture: ArchitectureJsonRpc, Alias: "btc", JsonRpc: &JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}} + require.NoError(t, n.SetDefaults(nil, nil)) + assert.Equal(t, "jsonrpc:bitcoin-mainnet", n.NetworkId()) + }) + + t.Run("networkDefaults.evm is not injected (explicit architecture)", func(t *testing.T) { + n := &NetworkConfig{Architecture: ArchitectureJsonRpc, JsonRpc: &JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}} + require.NoError(t, n.SetDefaults(nil, evmDefaults())) + assert.Nil(t, n.Evm) + assert.Equal(t, ArchitectureJsonRpc, n.Architecture) + }) + + t.Run("networkDefaults.evm is not injected (inferred architecture)", func(t *testing.T) { + n := &NetworkConfig{JsonRpc: &JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}} + require.NoError(t, n.SetDefaults(nil, evmDefaults())) + assert.Nil(t, n.Evm, "an evm block would flip the derived architecture to evm") + assert.Equal(t, ArchitectureJsonRpc, n.Architecture) + }) +} + +func TestJsonRpcArchitecture_NetworkValidate(t *testing.T) { + cfg := &Config{} + + n := &NetworkConfig{Architecture: ArchitectureJsonRpc} + require.NoError(t, n.SetDefaults(nil, nil)) + assert.ErrorContains(t, n.Validate(cfg), "network.*.jsonRpc.id") + + n = &NetworkConfig{Architecture: ArchitectureJsonRpc, JsonRpc: &JsonRpcNetworkConfig{Id: "bad:id"}} + require.NoError(t, n.SetDefaults(nil, nil)) + assert.ErrorContains(t, n.Validate(cfg), "is invalid") + + n = &NetworkConfig{Architecture: ArchitectureJsonRpc, JsonRpc: &JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}} + require.NoError(t, n.SetDefaults(nil, nil)) + assert.NoError(t, n.Validate(cfg)) +} + +func TestJsonRpcArchitecture_UpstreamDefaultsAndValidate(t *testing.T) { + cfg := &Config{} + + t.Run("type inferred from jsonRpc.networkId", func(t *testing.T) { + u := &UpstreamConfig{ + Endpoint: "https://rpc.example.com", + JsonRpc: &JsonRpcUpstreamConfig{NetworkId: "bitcoin-mainnet"}, + } + require.NoError(t, u.SetDefaults(nil)) + assert.Equal(t, UpstreamTypeJsonRpc, u.Type) + assert.NoError(t, u.Validate(cfg, false)) + }) + + t.Run("jsonRpc block without networkId keeps the evm default", func(t *testing.T) { + u := &UpstreamConfig{ + Endpoint: "https://rpc.example.com", + JsonRpc: &JsonRpcUpstreamConfig{SupportsBatch: &FALSE}, + } + require.NoError(t, u.SetDefaults(nil)) + assert.Equal(t, UpstreamTypeEvm, u.Type) + }) + + t.Run("networkId is not inherited from upstreamDefaults", func(t *testing.T) { + // A project-wide jsonRpc block is shared with EVM upstreams; inheriting + // networkId would silently retype them as jsonrpc. + u := &UpstreamConfig{Endpoint: "https://rpc.example.com"} + require.NoError(t, u.SetDefaults(&UpstreamConfig{JsonRpc: &JsonRpcUpstreamConfig{NetworkId: "bitcoin-mainnet"}})) + assert.Equal(t, UpstreamTypeEvm, u.Type) + }) + + t.Run("explicit type requires networkId", func(t *testing.T) { + u := &UpstreamConfig{Type: UpstreamTypeJsonRpc, Endpoint: "https://rpc.example.com"} + require.NoError(t, u.SetDefaults(nil)) + assert.ErrorContains(t, u.Validate(cfg, false), "upstream.*.jsonRpc.networkId is required") + }) + + t.Run("networkId must be an identifier", func(t *testing.T) { + u := &UpstreamConfig{ + Type: UpstreamTypeJsonRpc, + Endpoint: "https://rpc.example.com", + JsonRpc: &JsonRpcUpstreamConfig{NetworkId: "jsonrpc:bitcoin-mainnet"}, + } + require.NoError(t, u.SetDefaults(nil)) + assert.ErrorContains(t, u.Validate(cfg, false), "is invalid") + }) +} diff --git a/common/config.go b/common/config.go index 3343a4139..68bae8b0e 100644 --- a/common/config.go +++ b/common/config.go @@ -1327,6 +1327,11 @@ type JsonRpcUpstreamConfig struct { EnableGzip *bool `yaml:"enableGzip,omitempty" json:"enableGzip"` Headers map[string]string `yaml:"headers,omitempty" json:"headers"` ProxyPool string `yaml:"proxyPool,omitempty" json:"proxyPool"` + // NetworkId binds an upstream of type "jsonrpc" to the passthrough network + // "jsonrpc:" (slug only, e.g. "bitcoin-mainnet"). Required for + // type jsonrpc; setting it on an upstream without an explicit type infers + // type jsonrpc. Ignored for other upstream types. + NetworkId string `yaml:"networkId,omitempty" json:"networkId,omitempty"` } func (c *JsonRpcUpstreamConfig) Copy() *JsonRpcUpstreamConfig { @@ -2356,6 +2361,7 @@ type NetworkConfig struct { Failsafe []*FailsafeConfig `yaml:"failsafe,omitempty" json:"failsafe"` Evm *EvmNetworkConfig `yaml:"evm,omitempty" json:"evm"` Svm *SvmNetworkConfig `yaml:"svm,omitempty" json:"svm"` + JsonRpc *JsonRpcNetworkConfig `yaml:"jsonRpc,omitempty" json:"jsonRpc"` SelectionPolicy *SelectionPolicyConfig `yaml:"selectionPolicy,omitempty" json:"selectionPolicy"` DirectiveDefaults *DirectiveDefaultsConfig `yaml:"directiveDefaults,omitempty" json:"directiveDefaults"` Alias string `yaml:"alias,omitempty" json:"alias"` @@ -2437,6 +2443,7 @@ func (n *NetworkConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { Failsafe *FailsafeConfig `yaml:"failsafe,omitempty"` Evm *EvmNetworkConfig `yaml:"evm,omitempty"` Svm *SvmNetworkConfig `yaml:"svm,omitempty"` + JsonRpc *JsonRpcNetworkConfig `yaml:"jsonRpc,omitempty"` SelectionPolicy *SelectionPolicyConfig `yaml:"selectionPolicy,omitempty"` DirectiveDefaults *DirectiveDefaultsConfig `yaml:"directiveDefaults,omitempty"` Alias string `yaml:"alias,omitempty"` @@ -2458,6 +2465,7 @@ func (n *NetworkConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { n.RateLimitBudget = old.RateLimitBudget n.Evm = old.Evm n.Svm = old.Svm + n.JsonRpc = old.JsonRpc n.SelectionPolicy = old.SelectionPolicy n.DirectiveDefaults = old.DirectiveDefaults n.Alias = old.Alias @@ -3424,6 +3432,11 @@ func (c *NetworkConfig) NetworkId() string { return "" } return util.SvmNetworkId(c.Svm.Chain, c.Svm.Cluster) + case ArchitectureJsonRpc: + if c.JsonRpc == nil || c.JsonRpc.Id == "" { + return "" + } + return util.JsonRpcNetworkId(c.JsonRpc.Id) default: return "" } diff --git a/common/defaults.go b/common/defaults.go index 60f33f30e..3e9c1dab9 100644 --- a/common/defaults.go +++ b/common/defaults.go @@ -1944,8 +1944,14 @@ func (u *UpstreamConfig) SetDefaults(defaults *UpstreamConfig) error { } } if u.Type == "" { - // TODO make actual calls to detect other types (solana, btc, etc)? - u.Type = UpstreamTypeEvm + if u.JsonRpc != nil && u.JsonRpc.NetworkId != "" { + // jsonRpc.networkId only has meaning for the jsonrpc passthrough + // architecture, so it is an unambiguous type signal. + u.Type = UpstreamTypeJsonRpc + } else { + // TODO make actual calls to detect other types (solana, btc, etc)? + u.Type = UpstreamTypeEvm + } } if len(u.Failsafe) > 0 { @@ -2291,12 +2297,13 @@ func (n *NetworkConfig) SetDefaults(upstreams []*UpstreamConfig, defaults *Netwo if n.Evm.StripSubscribeFromBlockZero == nil && defaults.Evm.StripSubscribeFromBlockZero != nil { n.Evm.StripSubscribeFromBlockZero = defaults.Evm.StripSubscribeFromBlockZero } - } else if n.Evm == nil && defaults.Evm != nil && n.Svm == nil && n.Architecture != ArchitectureSvm { + } else if n.Evm == nil && defaults.Evm != nil && n.Svm == nil && n.Architecture != ArchitectureSvm && + n.JsonRpc == nil && n.Architecture != ArchitectureJsonRpc { // Copy EVM defaults only onto networks that are (or can become) EVM. - // Without the SVM guard, a mixed project with networkDefaults.evm - // would inject an evm block into every svm network — and the - // architecture derivation below checks n.Evm BEFORE n.Svm, silently - // flipping an `svm:`-authored network to architecture=evm. + // Without the SVM/JSON-RPC guards, a mixed project with + // networkDefaults.evm would inject an evm block into every svm or + // jsonrpc network — and the architecture derivation below checks + // n.Evm first, silently flipping such a network to architecture=evm. n.Evm = &EvmNetworkConfig{} *n.Evm = *defaults.Evm } @@ -2322,6 +2329,8 @@ func (n *NetworkConfig) SetDefaults(upstreams []*UpstreamConfig, defaults *Netwo n.Architecture = "evm" } else if n.Svm != nil { n.Architecture = ArchitectureSvm + } else if n.JsonRpc != nil { + n.Architecture = ArchitectureJsonRpc } } @@ -2331,6 +2340,16 @@ func (n *NetworkConfig) SetDefaults(upstreams []*UpstreamConfig, defaults *Netwo if n.Architecture == ArchitectureSvm && n.Svm == nil { n.Svm = &SvmNetworkConfig{} } + if n.Architecture == ArchitectureJsonRpc { + if n.JsonRpc == nil { + n.JsonRpc = &JsonRpcNetworkConfig{} + } + // The alias doubles as the network slug when no explicit id is given, + // so `architecture: jsonrpc` + `alias: foo` serves "jsonrpc:foo". + if n.JsonRpc.Id == "" { + n.JsonRpc.Id = n.Alias + } + } // Apply methods defaults if n.Methods == nil { diff --git a/common/network.go b/common/network.go index 8b1367f11..242ae2daa 100644 --- a/common/network.go +++ b/common/network.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/erpc/erpc/util" "github.com/rs/zerolog" ) @@ -14,6 +15,9 @@ type NetworkArchitecture string const ( ArchitectureEvm NetworkArchitecture = "evm" ArchitectureSvm NetworkArchitecture = "svm" + // ArchitectureJsonRpc is a chain-agnostic passthrough for generic JSON-RPC + // upstreams: requests are forwarded as-is, with no architecture hooks. + ArchitectureJsonRpc NetworkArchitecture = "jsonrpc" ) type Network interface { @@ -87,7 +91,7 @@ func EvmLeaderUpstream(n Network, ctx context.Context) Upstream { func IsValidArchitecture(architecture string) bool { switch NetworkArchitecture(architecture) { - case ArchitectureEvm, ArchitectureSvm: + case ArchitectureEvm, ArchitectureSvm, ArchitectureJsonRpc: return true } return false @@ -143,6 +147,9 @@ func IsValidNetwork(network string) bool { } return isIdentifier(chain) && isIdentifier(cluster) } + if strings.HasPrefix(network, "jsonrpc:") { + return util.IsValidIdentifier(strings.TrimPrefix(network, "jsonrpc:")) + } return false } diff --git a/common/validation.go b/common/validation.go index 895e97e01..16dd5f800 100644 --- a/common/validation.go +++ b/common/validation.go @@ -1096,6 +1096,14 @@ func (u *UpstreamConfig) Validate(c *Config, skipEndpointCheck bool) error { return err } } + if u.Type == UpstreamTypeJsonRpc { + if u.JsonRpc == nil || u.JsonRpc.NetworkId == "" { + return fmt.Errorf("upstream.*.jsonRpc.networkId is required for jsonrpc upstreams") + } + if !util.IsValidIdentifier(u.JsonRpc.NetworkId) { + return fmt.Errorf("upstream.*.jsonRpc.networkId '%s' is invalid, must contain only alphanumeric characters, dash, or underscore", u.JsonRpc.NetworkId) + } + } if u.Failsafe != nil { for _, fs := range u.Failsafe { if err := fs.Validate(); err != nil { @@ -1568,6 +1576,14 @@ func (n *NetworkConfig) Validate(c *Config) error { if n.Architecture == ArchitectureSvm && n.Svm == nil { return fmt.Errorf("network.*.svm is required for svm networks") } + if n.Architecture == ArchitectureJsonRpc { + if n.JsonRpc == nil || n.JsonRpc.Id == "" { + return fmt.Errorf("network.*.jsonRpc.id (or network.*.alias) is required for jsonrpc networks") + } + if !util.IsValidIdentifier(n.JsonRpc.Id) { + return fmt.Errorf("network.*.jsonRpc.id '%s' is invalid, must contain only alphanumeric characters, dash, or underscore", n.JsonRpc.Id) + } + } if n.Evm != nil { if err := n.Evm.Validate(); err != nil { return err diff --git a/erpc/erpc.go b/erpc/erpc.go index a7491a3f7..9320f98f4 100644 --- a/erpc/erpc.go +++ b/erpc/erpc.go @@ -5,6 +5,7 @@ import ( "time" "github.com/erpc/erpc/architecture/evm" + _ "github.com/erpc/erpc/architecture/jsonrpc" // registers the jsonrpc passthrough architecture "github.com/erpc/erpc/architecture/svm" "github.com/erpc/erpc/auth" "github.com/erpc/erpc/clients" diff --git a/erpc/healthcheck.go b/erpc/healthcheck.go index 5349b547a..cc972cd59 100644 --- a/erpc/healthcheck.go +++ b/erpc/healthcheck.go @@ -215,7 +215,7 @@ func (s *HttpServer) handleHealthCheck( if upsConfig.Evm != nil && upsConfig.Evm.ChainId == cid { filteredUpstreams = append(filteredUpstreams, ups) } - case common.ArchitectureSvm: + case common.ArchitectureSvm, common.ArchitectureJsonRpc: if ups.NetworkId() == targetNetworkId { filteredUpstreams = append(filteredUpstreams, ups) } @@ -528,6 +528,11 @@ func (s *HttpServer) evaluateNetworkHealth( upsCfg.Svm.Cluster == nwCfg.Svm.Cluster { networkStaticUpsCount++ } + case common.ArchitectureJsonRpc: + if upsCfg.Type == common.UpstreamTypeJsonRpc && upsCfg.JsonRpc != nil && nwCfg.JsonRpc != nil && + upsCfg.JsonRpc.NetworkId == nwCfg.JsonRpc.Id { + networkStaticUpsCount++ + } } } } diff --git a/erpc/http_server.go b/erpc/http_server.go index 92c7eccef..74ec7cad1 100644 --- a/erpc/http_server.go +++ b/erpc/http_server.go @@ -1127,7 +1127,7 @@ func (s *HttpServer) parseUrlPath( } if (chainId != "" || architecture != "") && !common.IsValidArchitecture(architecture) { - return "", "", "", false, false, common.NewErrInvalidUrlPath("architecture is not valid (must be 'evm' or 'svm')", ps) + return "", "", "", false, false, common.NewErrInvalidUrlPath("architecture is not valid (must be 'evm', 'svm' or 'jsonrpc')", ps) } if !isPost && !isOptions && !isWebSocketUpgradeRequest(r, s.webSocketEnabled()) { diff --git a/erpc/jsonrpc_passthrough_test.go b/erpc/jsonrpc_passthrough_test.go new file mode 100644 index 000000000..5a922b540 --- /dev/null +++ b/erpc/jsonrpc_passthrough_test.go @@ -0,0 +1,331 @@ +package erpc + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/erpc/erpc/common" + "github.com/erpc/erpc/data" + "github.com/erpc/erpc/health" + "github.com/erpc/erpc/thirdparty" + "github.com/erpc/erpc/upstream" + "github.com/erpc/erpc/util" + "github.com/h2non/gock" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// wireRecorder captures the JSON-RPC bodies erpc sends to mocked upstreams so +// tests can assert on the exact wire shape (and that no EVM probes were sent). +type wireRecorder struct { + mu sync.Mutex + bodies map[string][]string // host -> bodies +} + +func newWireRecorder() *wireRecorder { + return &wireRecorder{bodies: map[string][]string{}} +} + +func (w *wireRecorder) filter(host string) func(*http.Request) bool { + return func(r *http.Request) bool { + if r.URL.Host != host { + return false + } + body := util.SafeReadBody(r) + w.mu.Lock() + w.bodies[host] = append(w.bodies[host], body) + w.mu.Unlock() + return true + } +} + +func (w *wireRecorder) get(host string) []string { + w.mu.Lock() + defer w.mu.Unlock() + return append([]string(nil), w.bodies[host]...) +} + +func jsonRpcPassthroughConfig(networks []*common.NetworkConfig, upstreams ...*common.UpstreamConfig) *common.Config { + return &common.Config{ + Server: &common.ServerConfig{ + MaxTimeout: common.Duration(5 * time.Second).Ptr(), + }, + Projects: []*common.ProjectConfig{ + { + Id: "test_project", + Networks: networks, + Upstreams: upstreams, + }, + }, + RateLimiters: &common.RateLimiterConfig{}, + } +} + +func jsonRpcPassthroughUpstream(id, host string) *common.UpstreamConfig { + return &common.UpstreamConfig{ + Id: id, + Type: common.UpstreamTypeJsonRpc, + Endpoint: "http://" + host, + JsonRpc: &common.JsonRpcUpstreamConfig{ + NetworkId: "bitcoin-mainnet", + SupportsBatch: &common.FALSE, + }, + } +} + +func postJsonRpc(t *testing.T, url, body string) (int, string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, string(respBody) +} + +func TestHttpServer_JsonRpcPassthrough(t *testing.T) { + t.Run("ForwardsArbitraryMethodUnchanged", func(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + + rec := newWireRecorder() + gock.New("http://rpc1.localhost"). + Post(""). + Filter(rec.filter("rpc1.localhost")). + Reply(200). + // JSON-RPC 1.0-style success: "error" is present and null. + BodyString(`{"result":{"chain":"main","blocks":840000},"error":null,"id":1}`) + + cfg := jsonRpcPassthroughConfig( + []*common.NetworkConfig{{ + Architecture: common.ArchitectureJsonRpc, + JsonRpc: &common.JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}, + }}, + jsonRpcPassthroughUpstream("rpc1", "rpc1.localhost"), + ) + _, _, baseURL, shutdown, _ := createServerTestFixtures(cfg, t) + defer shutdown() + + status, body := postJsonRpc(t, baseURL+"/test_project/jsonrpc/bitcoin-mainnet", + `{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":["verbose",1]}`) + + assert.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, `"blocks":840000`) + assert.NotContains(t, body, `"error"`) + + sent := rec.get("rpc1.localhost") + require.Len(t, sent, 1, "exactly one upstream call: no chain detection or state polling") + assert.Contains(t, sent[0], `"method":"getblockchaininfo"`) + assert.Contains(t, sent[0], `"params":["verbose",1]`) + }) + + t.Run("OmitsEmptyParamsAndServesByAlias", func(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + + rec := newWireRecorder() + gock.New("http://rpc1.localhost"). + Post(""). + Filter(rec.filter("rpc1.localhost")). + Reply(200). + BodyString(`{"jsonrpc":"2.0","id":1,"result":{"status":"healthy"}}`) + + cfg := jsonRpcPassthroughConfig( + []*common.NetworkConfig{{ + Architecture: common.ArchitectureJsonRpc, + // No jsonRpc.id: the alias doubles as the network slug. + Alias: "bitcoin-mainnet", + }}, + jsonRpcPassthroughUpstream("rpc1", "rpc1.localhost"), + ) + _, _, baseURL, shutdown, _ := createServerTestFixtures(cfg, t) + defer shutdown() + + status, body := postJsonRpc(t, baseURL+"/test_project/bitcoin-mainnet", + `{"jsonrpc":"2.0","id":1,"method":"getHealth","params":[]}`) + + assert.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, `"status":"healthy"`) + + sent := rec.get("rpc1.localhost") + require.Len(t, sent, 1) + assert.Contains(t, sent[0], `"method":"getHealth"`) + assert.NotContains(t, sent[0], `"params"`) + }) + + t.Run("FailsOverToNextUpstream", func(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + + rec := newWireRecorder() + gock.New("http://rpc1.localhost"). + Post(""). + Filter(rec.filter("rpc1.localhost")). + Persist(). + Reply(503). + BodyString(`service unavailable`) + gock.New("http://rpc2.localhost"). + Post(""). + Filter(rec.filter("rpc2.localhost")). + Reply(200). + BodyString(`{"jsonrpc":"2.0","id":1,"result":"0000000000000000000abc"}`) + + cfg := jsonRpcPassthroughConfig( + []*common.NetworkConfig{{ + Architecture: common.ArchitectureJsonRpc, + JsonRpc: &common.JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}, + Failsafe: []*common.FailsafeConfig{{ + Retry: &common.RetryPolicyConfig{MaxAttempts: 3}, + }}, + }}, + jsonRpcPassthroughUpstream("rpc1", "rpc1.localhost"), + jsonRpcPassthroughUpstream("rpc2", "rpc2.localhost"), + ) + _, _, baseURL, shutdown, _ := createServerTestFixtures(cfg, t) + defer shutdown() + + status, body := postJsonRpc(t, baseURL+"/test_project/jsonrpc/bitcoin-mainnet", + `{"jsonrpc":"2.0","id":1,"method":"getbestblockhash"}`) + + assert.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, `"0000000000000000000abc"`) + assert.Len(t, rec.get("rpc2.localhost"), 1) + }) + + t.Run("LazilyCreatesNetworkFromUpstreams", func(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + + rec := newWireRecorder() + gock.New("http://rpc1.localhost"). + Post(""). + Filter(rec.filter("rpc1.localhost")). + Reply(200). + BodyString(`{"jsonrpc":"2.0","id":1,"result":42}`) + + // No networks configured: the network is created on first request + // from the jsonrpc: path and matched to upstreams by networkId. + cfg := jsonRpcPassthroughConfig(nil, jsonRpcPassthroughUpstream("rpc1", "rpc1.localhost")) + _, _, baseURL, shutdown, _ := createServerTestFixtures(cfg, t) + defer shutdown() + + status, body := postJsonRpc(t, baseURL+"/test_project/jsonrpc/bitcoin-mainnet", + `{"jsonrpc":"2.0","id":1,"method":"getblockcount","params":[]}`) + + assert.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, `"result":42`) + assert.Len(t, rec.get("rpc1.localhost"), 1) + }) + + t.Run("RejectsInvalidNetworkId", func(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + + cfg := jsonRpcPassthroughConfig(nil, jsonRpcPassthroughUpstream("rpc1", "rpc1.localhost")) + _, _, baseURL, shutdown, _ := createServerTestFixtures(cfg, t) + defer shutdown() + + status, body := postJsonRpc(t, baseURL+"/test_project/jsonrpc/not.valid", + `{"jsonrpc":"2.0","id":1,"method":"getblockcount"}`) + + assert.NotEqual(t, http.StatusOK, status, body) + assert.Contains(t, body, "invalid network id") + }) +} + +// TestHealthCheckJsonRpcNetworkScope pins that the healthcheck scopes jsonrpc +// networks by architecture+id and counts their statically configured +// upstreams for the all:activeUpstreams evaluation. +func TestHealthCheckJsonRpcNetworkScope(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := &log.Logger + vr := thirdparty.NewVendorsRegistry() + pr, err := thirdparty.NewProvidersRegistry(logger, vr, nil, nil) + require.NoError(t, err) + ssr, err := data.NewSharedStateRegistry(ctx, logger, &common.SharedStateConfig{ + ClusterKey: "test", + Connector: &common.ConnectorConfig{ + Driver: common.DriverMemory, + Memory: &common.MemoryConnectorConfig{ + MaxItems: 100_000, MaxTotalSize: "1GB", + }, + }, + }) + require.NoError(t, err) + mtk := health.NewTracker(logger, "test", time.Second) + + ups := jsonRpcPassthroughUpstream("rpc1", "rpc1.localhost") + pp := &PreparedProject{ + Config: &common.ProjectConfig{ + Id: "test_project", + Upstreams: []*common.UpstreamConfig{ups}, + Networks: []*common.NetworkConfig{ + { + Architecture: common.ArchitectureJsonRpc, + JsonRpc: &common.JsonRpcNetworkConfig{Id: "bitcoin-mainnet"}, + }, + }, + }, + upstreamsRegistry: upstream.NewUpstreamsRegistry(ctx, logger, "", []*common.UpstreamConfig{ups}, ssr, nil, vr, pr, nil, mtk, nil), + } + pp.upstreamsRegistry.Bootstrap(ctx) + pp.networksRegistry = NewNetworksRegistry(pp, ctx, pp.upstreamsRegistry, mtk, nil, nil, nil, nil, logger) + + s := &HttpServer{ + logger: logger, + erpc: &ERPC{ + projectsRegistry: &ProjectsRegistry{ + preparedProjects: map[string]*PreparedProject{"test_project": pp}, + }, + }, + healthCheckCfg: &common.HealthCheckConfig{ + Mode: common.HealthCheckModeNetworks, + DefaultEval: common.EvalAllActiveUpstreams, + }, + draining: &atomic.Bool{}, + } + + time.Sleep(200 * time.Millisecond) + + w := httptest.NewRecorder() + startTime := time.Now() + encoder := common.SonicCfg.NewEncoder(w) + s.handleHealthCheck( + ctx, w, + &http.Request{Method: "GET", URL: &url.URL{Path: "/"}}, + &startTime, + "test_project", "jsonrpc", "bitcoin-mainnet", + encoder, + func(ctx context.Context, statusCode int, body error) { + w.WriteHeader(statusCode) + _ = encoder.Encode(map[string]string{"error": body.Error()}) + }, + ) + + resp := w.Result() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + assert.Contains(t, string(body), `"id":"jsonrpc:bitcoin-mainnet"`) + assert.Contains(t, string(body), `"state":"OK"`) +} diff --git a/erpc/networks.go b/erpc/networks.go index 2b3a8b026..b5125812a 100644 --- a/erpc/networks.go +++ b/erpc/networks.go @@ -2839,9 +2839,9 @@ func (n *Network) prepareRequest(ctx context.Context, nr *common.NormalizedReque ) } evm.NormalizeHttpJsonRpc(ctx, nr, jsonRpcReq) - case common.ArchitectureSvm: - // SVM doesn't need any EVM-style normalization (hex padding, block tag expansion, etc.). - // Validate that the request parses as JSON-RPC and move on. + case common.ArchitectureSvm, common.ArchitectureJsonRpc: + // SVM doesn't need any EVM-style normalization (hex padding, block tag expansion, etc.), + // and jsonrpc is a passthrough. Validate that the request parses as JSON-RPC and move on. if _, err := nr.JsonRpcRequest(ctx); err != nil { return common.NewErrJsonRpcExceptionInternal( 0, @@ -2905,6 +2905,11 @@ func (n *Network) GetFinality(ctx context.Context, req *common.NormalizedRequest if n.cfg.Architecture == common.ArchitectureSvm { return svm.GetFinality(ctx, n, req, resp) } + // The jsonrpc passthrough has no chain model to derive finality from; only + // the per-method finalized/realtime config above applies. + if n.cfg.Architecture == common.ArchitectureJsonRpc { + return finality + } blockRef, blockNumber, _ := evm.ExtractBlockReferenceFromRequest(ctx, req) diff --git a/erpc/networks_registry.go b/erpc/networks_registry.go index 5b3f75018..dfdd438a7 100644 --- a/erpc/networks_registry.go +++ b/erpc/networks_registry.go @@ -339,6 +339,9 @@ func (nr *NetworksRegistry) prepareNetwork(nwCfg *common.NetworkConfig) (*Networ if nr.svmJsonRpcCache != nil { network.cacheDal = nr.svmJsonRpcCache.WithProjectId(nr.project.Config.Id) } + case common.ArchitectureJsonRpc: + // No cache: without a chain model there is no way to tell which + // responses are safe to reuse. } // Register alias for lazy-created networks to support alias-based routing if nwCfg.Alias != "" { @@ -401,6 +404,11 @@ func (nr *NetworksRegistry) resolveNetworkConfig(networkId string) (*common.Netw return nil, common.NewErrInvalidEvmChainId(networkId) } nwCfg.Svm = &common.SvmNetworkConfig{Chain: chain, Cluster: cluster} + case common.ArchitectureJsonRpc: + if !util.IsValidIdentifier(s[1]) { + return nil, common.NewErrInvalidRequest(fmt.Errorf("invalid jsonrpc network id: '%s'", networkId)) + } + nwCfg.JsonRpc = &common.JsonRpcNetworkConfig{Id: s[1]} default: return nil, common.NewErrInvalidEvmChainId(networkId) } diff --git a/typescript/config/src/__tests__/jsonrpc.test-d.ts b/typescript/config/src/__tests__/jsonrpc.test-d.ts new file mode 100644 index 000000000..74679a5f7 --- /dev/null +++ b/typescript/config/src/__tests__/jsonrpc.test-d.ts @@ -0,0 +1,68 @@ +/** + * Compile-time tests for `@erpc-cloud/config` typings of the generic + * JSON-RPC passthrough architecture ("jsonrpc"). + * + * Run via: + * pnpm --filter @erpc-cloud/config test + */ + +import { + createConfig, + ArchitectureJsonRpc, + UpstreamTypeJsonRpc, + type NetworkArchitecture, + type UpstreamType, + type JsonRpcNetworkConfig, +} from "../index"; + +/* ─────────────────── 1. Realistic jsonrpc config compiles ─────────────── */ + +const _realisticJsonRpcConfig = createConfig({ + projects: [ + { + id: "main", + upstreams: [ + { + id: "node-1", + type: "jsonrpc", + endpoint: "https://rpc.example.com", + jsonRpc: { networkId: "bitcoin-mainnet" }, + }, + { + // type is inferred as "jsonrpc" from jsonRpc.networkId + id: "node-2", + endpoint: "wss://rpc2.example.com", + jsonRpc: { networkId: "bitcoin-mainnet", supportsBatch: false }, + }, + ], + networks: [ + { architecture: "jsonrpc", jsonRpc: { id: "bitcoin-mainnet" } }, + // id defaults to the alias + { architecture: "jsonrpc", alias: "stellar-mainnet" }, + ], + }, + ], +}); + +/* ───────────────── 2. Root-level constants are values ─────────────────── */ + +const _architecture: NetworkArchitecture = ArchitectureJsonRpc as NetworkArchitecture; +const _upstreamType: UpstreamType = UpstreamTypeJsonRpc as UpstreamType; +const _network: JsonRpcNetworkConfig = { id: "bitcoin-mainnet" }; + +/* ───────────────────── 3. Negative cases (must error) ─────────────────── */ + +createConfig({ + projects: [ + { + id: "bad-architecture", + // @ts-expect-error — the architecture value is "jsonrpc", not "json-rpc" + networks: [{ architecture: "json-rpc", jsonRpc: { id: "bitcoin-mainnet" } }], + }, + ], +}); + +void _realisticJsonRpcConfig; +void _architecture; +void _upstreamType; +void _network; diff --git a/typescript/config/src/generated.ts b/typescript/config/src/generated.ts index 0d97d080f..580eb83cb 100644 --- a/typescript/config/src/generated.ts +++ b/typescript/config/src/generated.ts @@ -132,6 +132,33 @@ export interface EvmProbeEarliestInfo { schedulerRunning?: boolean; } +////////// +// source: architecture_jsonrpc.go + +/** + * UpstreamTypeJsonRpc marks an upstream that speaks plain JSON-RPC and is + * served through the "jsonrpc" passthrough architecture. Such upstreams + * get no chain-specific detection, state polling or method hooks. + */ +export const UpstreamTypeJsonRpc: UpstreamType = "jsonrpc"; +/** + * JsonRpcNetworkConfig identifies a generic JSON-RPC network served by the + * "jsonrpc" passthrough architecture. It is meant for non-EVM, non-SVM nodes + * (for example Bitcoin-family or Stellar RPC servers) where eRPC should only + * provide routing, failover, rate limiting and metrics, and forward every + * method unchanged. + * The resulting network id is "jsonrpc:". Upstreams join the network by + * setting the same value in jsonRpc.networkId. + */ +export interface JsonRpcNetworkConfig { + /** + * Id is the network slug, e.g. "bitcoin-mainnet". It may contain only + * alphanumeric characters, dash and underscore. When omitted it defaults + * to the network alias. + */ + id?: string; +} + ////////// // source: architecture_svm.go @@ -996,6 +1023,13 @@ export interface JsonRpcUpstreamConfig { enableGzip?: boolean; headers?: { [key: string]: string}; proxyPool?: string; + /** + * NetworkId binds an upstream of type "jsonrpc" to the passthrough network + * "jsonrpc:" (slug only, e.g. "bitcoin-mainnet"). Required for + * type jsonrpc; setting it on an upstream without an explicit type infers + * type jsonrpc. Ignored for other upstream types. + */ + networkId?: string; } /** * GrpcUpstreamConfig tunes a gRPC (grpc:// / grpc+bds:// / grpcs://) upstream. It is the @@ -1439,6 +1473,7 @@ export interface NetworkConfig { failsafe?: (FailsafeConfig | undefined)[]; evm?: EvmNetworkConfig; svm?: SvmNetworkConfig; + jsonRpc?: JsonRpcNetworkConfig; selectionPolicy?: SelectionPolicyConfig; directiveDefaults?: DirectiveDefaultsConfig; alias?: string; @@ -2621,6 +2656,11 @@ export interface ExecStateSnapshot { export type NetworkArchitecture = string; export const ArchitectureEvm: NetworkArchitecture = "evm"; export const ArchitectureSvm: NetworkArchitecture = "svm"; +/** + * ArchitectureJsonRpc is a chain-agnostic passthrough for generic JSON-RPC + * upstreams: requests are forwarded as-is, with no architecture hooks. + */ +export const ArchitectureJsonRpc: NetworkArchitecture = "jsonrpc"; export type Network = any; /** * EvmNetwork is the EVM-specific view of a Network. Callers that need diff --git a/typescript/config/src/index.ts b/typescript/config/src/index.ts index 0c7da1a48..e6f156ecf 100644 --- a/typescript/config/src/index.ts +++ b/typescript/config/src/index.ts @@ -63,9 +63,11 @@ export { // Architecture export ArchitectureEvm, ArchitectureSvm, + ArchitectureJsonRpc, // Upstream types const exprots UpstreamTypeEvm, UpstreamTypeSvm, + UpstreamTypeJsonRpc, // Auth types AuthTypeSecret, AuthTypeJwt, @@ -133,6 +135,7 @@ export type { EvmNetworkConfig, EvmIntegrityConfig, SvmNetworkConfig, + JsonRpcNetworkConfig, SelectionPolicyConfig, EvalScope, DirectiveDefaultsConfig, diff --git a/typescript/config/src/types/generic.ts b/typescript/config/src/types/generic.ts index 6d2cb4c9a..1685a71ed 100644 --- a/typescript/config/src/types/generic.ts +++ b/typescript/config/src/types/generic.ts @@ -47,7 +47,7 @@ import type { /** * Suported network architecture */ - export type NetworkArchitecture = "evm" | "svm"; + export type NetworkArchitecture = "evm" | "svm" | "jsonrpc"; /** * Supported connector driver type overide @@ -110,7 +110,8 @@ import type { | "evm+blockpi" | "evm+ankr" | "evm+routemesh" - | "svm"; + | "svm" + | "jsonrpc"; /** * Supported auth type diff --git a/upstream/registry.go b/upstream/registry.go index 5a5202272..c900c12c7 100644 --- a/upstream/registry.go +++ b/upstream/registry.go @@ -536,7 +536,9 @@ func (u *UpstreamsRegistry) buildUpstreamBootstrapTask(upsCfg *common.UpstreamCo cfg := upsCfg.Copy() // Name: network//upstream/ if chainId configured; else upstream/ taskName := fmt.Sprintf("upstream/%s", cfg.Id) - if cfg.Evm != nil && cfg.Evm.ChainId > 0 { + if cfg.Type == common.UpstreamTypeJsonRpc && cfg.JsonRpc != nil && cfg.JsonRpc.NetworkId != "" { + taskName = fmt.Sprintf("network/%s/upstream/%s", util.JsonRpcNetworkId(cfg.JsonRpc.NetworkId), cfg.Id) + } else if cfg.Evm != nil && cfg.Evm.ChainId > 0 { taskName = fmt.Sprintf("network/%s/upstream/%s", util.EvmNetworkId(cfg.Evm.ChainId), cfg.Id) } return util.NewBootstrapTask( diff --git a/upstream/upstream.go b/upstream/upstream.go index 251dce30d..a17f5a1f9 100644 --- a/upstream/upstream.go +++ b/upstream/upstream.go @@ -1413,6 +1413,21 @@ func (u *Upstream) detectFeatures(ctx context.Context) error { // Genesis-hash validation runs in Bootstrap (svmVerifyGenesisHash) once // the client and networkId are in place, so it can go through the // upstream's normal Forward path. + } else if cfg.Type == common.UpstreamTypeJsonRpc { + // Passthrough: the network id comes from config alone; nothing is + // probed on the upstream. A missing or malformed id is a config error + // that retries cannot fix. + if cfg.JsonRpc == nil || cfg.JsonRpc.NetworkId == "" { + return common.NewTaskFatal(common.NewErrUpstreamClientInitialization( + fmt.Errorf("jsonrpc upstream %q is missing jsonRpc.networkId", cfg.Id), u, + )) + } + if !util.IsValidIdentifier(cfg.JsonRpc.NetworkId) { + return common.NewTaskFatal(common.NewErrUpstreamClientInitialization( + fmt.Errorf("jsonrpc upstream %q has invalid jsonRpc.networkId %q", cfg.Id, cfg.JsonRpc.NetworkId), u, + )) + } + u.networkId.Store(util.JsonRpcNetworkId(cfg.JsonRpc.NetworkId)) } else { return fmt.Errorf("upstream type not supported: %s", cfg.Type) } diff --git a/util/ids.go b/util/ids.go index f9016bf61..6d3a3443b 100644 --- a/util/ids.go +++ b/util/ids.go @@ -24,6 +24,12 @@ func SvmNetworkId(chain, cluster string) string { return "svm:" + chain + ":" + cluster } +// JsonRpcNetworkId derives the canonical "jsonrpc:" network ID used by +// the generic JSON-RPC passthrough architecture. +func JsonRpcNetworkId(id string) string { + return "jsonrpc:" + id +} + var validIdentifierRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) func IsValidIdentifier(s string) bool { @@ -63,6 +69,9 @@ func IsValidNetworkId(s string) bool { } return true } + if strings.HasPrefix(s, "jsonrpc:") { + return IsValidIdentifier(s[len("jsonrpc:"):]) + } return false } diff --git a/util/ids_test.go b/util/ids_test.go index 474ec4886..df950306a 100644 --- a/util/ids_test.go +++ b/util/ids_test.go @@ -57,3 +57,26 @@ func TestIsValidNetworkId_SvmWithChain(t *testing.T) { }) } } + +func TestJsonRpcNetworkId(t *testing.T) { + if got := JsonRpcNetworkId("bitcoin-mainnet"); got != "jsonrpc:bitcoin-mainnet" { + t.Fatalf("JsonRpcNetworkId = %q", got) + } + + cases := []struct { + id string + want bool + }{ + {"jsonrpc:bitcoin-mainnet", true}, + {"jsonrpc:stellar_testnet", true}, + {"jsonrpc:", false}, + {"jsonrpc:a:b", false}, + {"jsonrpc:a.b", false}, + {"jsonrpc:a b", false}, + } + for _, tc := range cases { + if got := IsValidNetworkId(tc.id); got != tc.want { + t.Errorf("IsValidNetworkId(%q) = %v, want %v", tc.id, got, tc.want) + } + } +} From 0007183bd4f3ba4cb63579fecd28d74e5ba4c80a Mon Sep 17 00:00:00 2001 From: snowkide Date: Thu, 15 Oct 2026 09:59:38 +0200 Subject: [PATCH 3/4] feat(json-rpc): add upstreams[].jsonRpc.omitEmptyParams "params" is optional in JSON-RPC 2.0, and some non-EVM JSON-RPC servers reject an empty positional array ("params":[]) for methods without parameters while accepting the member being absent. The new upstream setting jsonRpc.omitEmptyParams leaves the member out of outbound requests whose params are empty, for the HTTP client (single and batch) and the WebSocket client. It defaults to true for upstreams of type jsonrpc and false otherwise, so the wire format of evm and svm upstreams is unchanged unless the setting is turned on. The request cache hash is computed from the parsed params, not from the wire bytes, so cache keys are unchanged. Co-Authored-By: Claude Opus 5.5 --- clients/http_json_json_client_test.go | 50 +++++++++++++++++++++++++++ clients/http_json_rpc_client.go | 29 +++++++--------- clients/ws_json_rpc_client.go | 21 +++++++---- common/config.go | 6 ++++ common/defaults.go | 16 +++++---- common/defaults_test.go | 17 +++++++++ common/json_rpc.go | 19 ++++++++++ common/json_rpc_test.go | 17 +++++++++ typescript/config/src/generated.ts | 8 +++++ 9 files changed, 153 insertions(+), 30 deletions(-) diff --git a/clients/http_json_json_client_test.go b/clients/http_json_json_client_test.go index 711a0b170..109621b83 100644 --- a/clients/http_json_json_client_test.go +++ b/clients/http_json_json_client_test.go @@ -102,6 +102,56 @@ func TestHttpJsonRpcClient_SingleRequests(t *testing.T) { }) } +func TestHttpJsonRpcClient_OmitEmptyParams(t *testing.T) { + cases := []struct { + name string + omit *bool + wantParams bool + }{ + {name: "DefaultSendsEmptyParams", omit: nil, wantParams: true}, + {name: "DisabledSendsEmptyParams", omit: util.BoolPtr(false), wantParams: true}, + {name: "EnabledOmitsEmptyParams", omit: util.BoolPtr(true), wantParams: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + logger := log.Logger + + var sentBody string + gock.New("http://rpc1.localhost:8545"). + Post("/"). + Filter(func(req *http.Request) bool { + sentBody = util.SafeReadBody(req) + return true + }). + Reply(200). + BodyString(`{"jsonrpc":"2.0","id":1,"result":"ok"}`) + + ups := common.NewFakeUpstream("rpc1") + ups.Config().Endpoint = "http://rpc1.localhost:8545" + jrCfg := &common.JsonRpcUpstreamConfig{OmitEmptyParams: tc.omit} + client, err := NewGenericHttpJsonRpcClient(ctx, &logger, "prj1", ups, &url.URL{Scheme: "http", Host: "rpc1.localhost:8545"}, jrCfg, nil, &noopErrorExtractor{}) + require.NoError(t, err) + + req := common.NewNormalizedRequest([]byte(`{"jsonrpc":"2.0","id":1,"method":"getHealth","params":[]}`)) + resp, err := client.SendRequest(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Contains(t, sentBody, `"method":"getHealth"`) + if tc.wantParams { + assert.Contains(t, sentBody, `"params":[]`) + } else { + assert.NotContains(t, sentBody, `"params"`) + } + }) + } +} + func TestHttpJsonRpcClient_BatchRequests(t *testing.T) { logger := log.Logger diff --git a/clients/http_json_rpc_client.go b/clients/http_json_rpc_client.go index 736668956..325a0a5c3 100644 --- a/clients/http_json_rpc_client.go +++ b/clients/http_json_rpc_client.go @@ -40,10 +40,11 @@ type GenericHttpJsonRpcClient struct { httpClient *http.Client isLogLevelTrace bool - enableGzip bool - supportsBatch bool - batchMaxSize int - batchMaxWait time.Duration + enableGzip bool + omitEmptyParams bool + supportsBatch bool + batchMaxSize int + batchMaxWait time.Duration batchMu sync.Mutex batchRequests map[interface{}]*batchRequest @@ -132,6 +133,10 @@ func NewGenericHttpJsonRpcClient( client.enableGzip = *jsonRpcCfg.EnableGzip } + if jsonRpcCfg.OmitEmptyParams != nil { + client.omitEmptyParams = *jsonRpcCfg.OmitEmptyParams + } + if jsonRpcCfg.Headers != nil { client.headers = jsonRpcCfg.Headers } @@ -371,7 +376,7 @@ func (c *GenericHttpJsonRpcClient) processBatch(alreadyLocked bool) { return } - batchReq := make([]common.JsonRpcRequest, 0, ln) + batchReq := make([]interface{}, 0, ln) for _, req := range requests { jrReq, err := req.request.JsonRpcRequest() c.logger.Trace().Interface("id", req.request.ID()).Str("method", jrReq.Method).Msgf("preparing batch request") @@ -387,12 +392,7 @@ func (c *GenericHttpJsonRpcClient) processBatch(alreadyLocked bool) { } req.request.RLock() jrReq.RLock() - batchReq = append(batchReq, common.JsonRpcRequest{ - JSONRPC: jrReq.JSONRPC, - Method: jrReq.Method, - Params: jrReq.Params, - ID: jrReq.ID, - }) + batchReq = append(batchReq, jrReq.WireRequest(c.omitEmptyParams)) } requestBody, err := common.SonicCfg.Marshal(batchReq) @@ -699,12 +699,7 @@ func (c *GenericHttpJsonRpcClient) sendSingleRequest(ctx context.Context, req *c jrReq.RLock() span.SetAttributes(attribute.String("request.method", jrReq.Method)) - requestBody, err := common.SonicCfg.Marshal(common.JsonRpcRequest{ - JSONRPC: jrReq.JSONRPC, - Method: jrReq.Method, - Params: jrReq.Params, - ID: jrReq.ID, - }) + requestBody, err := common.SonicCfg.Marshal(jrReq.WireRequest(c.omitEmptyParams)) jrReq.RUnlock() if err != nil { common.SetTraceSpanError(span, err) diff --git a/clients/ws_json_rpc_client.go b/clients/ws_json_rpc_client.go index 24a689e53..e622c72f3 100644 --- a/clients/ws_json_rpc_client.go +++ b/clients/ws_json_rpc_client.go @@ -49,10 +49,11 @@ type WsJsonRpcClient struct { Url *url.URL headers http.Header - projectId string - upstream common.Upstream - appCtx context.Context - logger *zerolog.Logger + projectId string + upstream common.Upstream + appCtx context.Context + logger *zerolog.Logger + omitEmptyParams bool pingInterval time.Duration pongWait time.Duration @@ -184,6 +185,9 @@ func NewWsJsonRpcClient( onReconnectCbs: make(map[string]func()), errorExtractor: extractor, } + if jsonRpcCfg != nil && jsonRpcCfg.OmitEmptyParams != nil { + client.omitEmptyParams = *jsonRpcCfg.OmitEmptyParams + } if err := client.connect(); err != nil { // readLoop keeps re-dialing in the background. @@ -242,12 +246,15 @@ func (c *WsJsonRpcClient) SendRequest(ctx context.Context, req *common.Normalize jrReq.RLock() originalID := jrReq.ID method := jrReq.Method - requestBody, err := common.SonicCfg.Marshal(map[string]interface{}{ + envelope := map[string]interface{}{ "jsonrpc": jrReq.JSONRPC, "id": wireID, "method": method, - "params": jrReq.Params, - }) + } + if !c.omitEmptyParams || len(jrReq.Params) > 0 { + envelope["params"] = jrReq.Params + } + requestBody, err := common.SonicCfg.Marshal(envelope) jrReq.RUnlock() if err != nil { common.SetTraceSpanError(span, err) diff --git a/common/config.go b/common/config.go index 68bae8b0e..b30f122d6 100644 --- a/common/config.go +++ b/common/config.go @@ -1332,6 +1332,12 @@ type JsonRpcUpstreamConfig struct { // type jsonrpc; setting it on an upstream without an explicit type infers // type jsonrpc. Ignored for other upstream types. NetworkId string `yaml:"networkId,omitempty" json:"networkId,omitempty"` + // OmitEmptyParams leaves the "params" member out of outbound requests + // whose params are empty instead of sending "params":[]. "params" is + // optional in JSON-RPC 2.0 and some servers reject an empty positional + // array for methods without parameters. Defaults to true for upstreams of + // type jsonrpc and false otherwise. + OmitEmptyParams *bool `yaml:"omitEmptyParams,omitempty" json:"omitEmptyParams,omitempty"` } func (c *JsonRpcUpstreamConfig) Copy() *JsonRpcUpstreamConfig { diff --git a/common/defaults.go b/common/defaults.go index 3e9c1dab9..fa5a71eb1 100644 --- a/common/defaults.go +++ b/common/defaults.go @@ -1878,12 +1878,13 @@ func (u *UpstreamConfig) ApplyDefaults(defaults *UpstreamConfig) error { } if u.JsonRpc == nil && defaults.JsonRpc != nil { u.JsonRpc = &JsonRpcUpstreamConfig{ - SupportsBatch: defaults.JsonRpc.SupportsBatch, - BatchMaxSize: defaults.JsonRpc.BatchMaxSize, - BatchMaxWait: defaults.JsonRpc.BatchMaxWait, - EnableGzip: defaults.JsonRpc.EnableGzip, - ProxyPool: defaults.JsonRpc.ProxyPool, - Headers: defaults.JsonRpc.Headers, + SupportsBatch: defaults.JsonRpc.SupportsBatch, + BatchMaxSize: defaults.JsonRpc.BatchMaxSize, + BatchMaxWait: defaults.JsonRpc.BatchMaxWait, + EnableGzip: defaults.JsonRpc.EnableGzip, + ProxyPool: defaults.JsonRpc.ProxyPool, + Headers: defaults.JsonRpc.Headers, + OmitEmptyParams: defaults.JsonRpc.OmitEmptyParams, } } if u.Grpc == nil && defaults.Grpc != nil { @@ -2039,6 +2040,9 @@ func (u *UpstreamConfig) SetDefaults(defaults *UpstreamConfig) error { if err := u.JsonRpc.SetDefaults(); err != nil { return fmt.Errorf("failed to set defaults for json rpc: %w", err) } + if u.JsonRpc.OmitEmptyParams == nil { + u.JsonRpc.OmitEmptyParams = util.BoolPtr(u.Type == UpstreamTypeJsonRpc) + } // By default if any allowed methods are specified, all other methods are ignored (unless ignoreMethods is explicitly defined by user) // Similar to how common network security policies work. if u.AllowMethods != nil { diff --git a/common/defaults_test.go b/common/defaults_test.go index 1bfdd4d52..f25a6f00a 100644 --- a/common/defaults_test.go +++ b/common/defaults_test.go @@ -2083,3 +2083,20 @@ func TestUpstreamConfig_Copy_SvmBlockAvailabilityNotAliased(t *testing.T) { // but resolves no config (no gating, no caching, no block-ref extraction). // Canonicalization is lookup-only: the wire method string is forwarded // upstream verbatim (per JSON-RPC 2.0 the method member is case-sensitive). + +func TestUpstreamConfig_SetDefaults_OmitEmptyParams(t *testing.T) { + evm := &UpstreamConfig{Endpoint: "http://rpc1.localhost"} + require.NoError(t, evm.SetDefaults(nil)) + require.NotNil(t, evm.JsonRpc.OmitEmptyParams) + assert.False(t, *evm.JsonRpc.OmitEmptyParams) + + jr := &UpstreamConfig{Endpoint: "http://rpc1.localhost", JsonRpc: &JsonRpcUpstreamConfig{NetworkId: "chain-a"}} + require.NoError(t, jr.SetDefaults(nil)) + assert.Equal(t, UpstreamTypeJsonRpc, jr.Type) + require.NotNil(t, jr.JsonRpc.OmitEmptyParams) + assert.True(t, *jr.JsonRpc.OmitEmptyParams) + + optOut := &UpstreamConfig{Endpoint: "http://rpc1.localhost", JsonRpc: &JsonRpcUpstreamConfig{NetworkId: "chain-a", OmitEmptyParams: util.BoolPtr(false)}} + require.NoError(t, optOut.SetDefaults(nil)) + assert.False(t, *optOut.JsonRpc.OmitEmptyParams) +} diff --git a/common/json_rpc.go b/common/json_rpc.go index 4f7e55df5..8a5b1e54c 100644 --- a/common/json_rpc.go +++ b/common/json_rpc.go @@ -1212,6 +1212,25 @@ func isEmptyishValue(v interface{}) bool { // JSON-RPC Request // +// jsonRpcRequestOmitEmptyParams is the wire shape of a request whose empty +// params are left out (see JsonRpcUpstreamConfig.OmitEmptyParams). +type jsonRpcRequestOmitEmptyParams struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID interface{} `json:"id,omitempty"` + Method string `json:"method"` + Params []interface{} `json:"params,omitempty"` +} + +// WireRequest returns the value to serialize when sending r to an upstream. +// With omitEmptyParams, empty params are left out instead of sent as +// "params":[]. The caller must hold r's read lock. +func (r *JsonRpcRequest) WireRequest(omitEmptyParams bool) interface{} { + if omitEmptyParams && len(r.Params) == 0 { + return &jsonRpcRequestOmitEmptyParams{JSONRPC: r.JSONRPC, ID: r.ID, Method: r.Method} + } + return &JsonRpcRequest{JSONRPC: r.JSONRPC, ID: r.ID, Method: r.Method, Params: r.Params} +} + type JsonRpcRequest struct { sync.RWMutex diff --git a/common/json_rpc_test.go b/common/json_rpc_test.go index 83ce14131..112b7c502 100644 --- a/common/json_rpc_test.go +++ b/common/json_rpc_test.go @@ -210,6 +210,23 @@ func sortJSONReverse(v interface{}) ([]byte, error) { } } +func TestJsonRpcRequest_WireRequest(t *testing.T) { + r := &JsonRpcRequest{JSONRPC: "2.0", ID: 1, Method: "getHealth", Params: []interface{}{}} + + raw, err := SonicCfg.Marshal(r.WireRequest(false)) + require.NoError(t, err) + assert.Equal(t, `{"jsonrpc":"2.0","id":1,"method":"getHealth","params":[]}`, string(raw)) + + raw, err = SonicCfg.Marshal(r.WireRequest(true)) + require.NoError(t, err) + assert.Equal(t, `{"jsonrpc":"2.0","id":1,"method":"getHealth"}`, string(raw)) + + r.Params = []interface{}{"0x1"} + raw, err = SonicCfg.Marshal(r.WireRequest(true)) + require.NoError(t, err) + assert.Equal(t, `{"jsonrpc":"2.0","id":1,"method":"getHealth","params":["0x1"]}`, string(raw)) +} + func TestJsonRpcRequest_MarshalParams(t *testing.T) { t.Run("Empty", func(t *testing.T) { rawReq, err := SonicCfg.Marshal(JsonRpcRequest{ diff --git a/typescript/config/src/generated.ts b/typescript/config/src/generated.ts index 580eb83cb..1e7a01857 100644 --- a/typescript/config/src/generated.ts +++ b/typescript/config/src/generated.ts @@ -1030,6 +1030,14 @@ export interface JsonRpcUpstreamConfig { * type jsonrpc. Ignored for other upstream types. */ networkId?: string; + /** + * OmitEmptyParams leaves the "params" member out of outbound requests + * whose params are empty instead of sending "params":[]. "params" is + * optional in JSON-RPC 2.0 and some servers reject an empty positional + * array for methods without parameters. Defaults to true for upstreams of + * type jsonrpc and false otherwise. + */ + omitEmptyParams?: boolean; } /** * GrpcUpstreamConfig tunes a gRPC (grpc:// / grpc+bds:// / grpcs://) upstream. It is the From 8ca1410c2426a94cb4ffd25090459b0bfd8a3332 Mon Sep 17 00:00:00 2001 From: snowkide Date: Wed, 14 Oct 2026 14:45:03 +0200 Subject: [PATCH 4/4] docs: document the jsonrpc passthrough architecture Describe the architecture, the networks[].jsonRpc and upstreams[].jsonRpc.networkId fields, type inference and the behavioral differences from evm/svm (no detection, no cache, unknown finality), with YAML/TypeScript examples. Co-Authored-By: Claude Opus 5.5 --- docs/pages/config/projects/networks.mdx | 66 ++++++++++++++++++++++-- docs/pages/config/projects/upstreams.mdx | 44 +++++++++++++++- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/docs/pages/config/projects/networks.mdx b/docs/pages/config/projects/networks.mdx index 555ae393f..4fafa2f6d 100644 --- a/docs/pages/config/projects/networks.mdx +++ b/docs/pages/config/projects/networks.mdx @@ -20,6 +20,7 @@ A **network** is one chain inside a project. Declare it once and eRPC owns the w **What you get:** - Any EVM chain or SVM (Solana) cluster, any provider — routes automatically on first request. +- Any other JSON-RPC node (Bitcoin-family, Stellar, …) through the chain-agnostic `jsonrpc` passthrough — same failover, retries and rate limits, methods forwarded unchanged. - One `networkDefaults` block replaces repetitive per-chain config. - `alias: arbitrum` turns every URL into `/main/arbitrum` instead of `/main/evm/42161`. - Static canned replies for chains with non-standard genesis blocks. @@ -258,9 +259,11 @@ https://docs.erpc.cloud/config/projects/networks.llms.txt`} ### How it works -**Architecture concept.** `NetworkArchitecture` is a string enum with two valid values: `"evm"` and `"svm"` (). The canonical id is `evm:` from `util.EvmNetworkId`, or `svm:` / `svm::` from `util.SvmNetworkId` (). The `network` Prometheus label equals the alias when set, otherwise the raw id. +**Architecture concept.** `NetworkArchitecture` is a string enum with three valid values: `"evm"`, `"svm"` and `"jsonrpc"` (). The canonical id is `evm:` from `util.EvmNetworkId`, `svm:` / `svm::` from `util.SvmNetworkId`, or `jsonrpc:` from `util.JsonRpcNetworkId` (). The `network` Prometheus label equals the alias when set, otherwise the raw id. -`architecture` is inferred when omitted: a network carrying an `svm` block derives `architecture: svm`, one carrying `evm` (or neither) derives `evm` (). Declaring `architecture: svm` auto-creates an empty `svm` block, and `networkDefaults.evm` is never injected into an SVM network. See [SvmNetworkConfig](#projectsnetworkssvm--svmnetworkconfig) below and the [SVM reference pages](/reference/svm/commitment). +`architecture` is inferred when omitted: a network carrying an `svm` block derives `architecture: svm`, one carrying a `jsonRpc` block derives `jsonrpc`, one carrying `evm` (or none of them) derives `evm` (). Declaring `architecture: svm` auto-creates an empty `svm` block, and `networkDefaults.evm` is never injected into an SVM or jsonrpc network. See [SvmNetworkConfig](#projectsnetworkssvm--svmnetworkconfig) below and the [SVM reference pages](/reference/svm/commitment). + +**Generic JSON-RPC passthrough (`jsonrpc`).** For JSON-RPC nodes that are neither EVM nor SVM, `architecture: jsonrpc` gives the network the generic pipeline — upstream selection, failsafe policies (retry, hedge, timeout, circuit breaker, consensus), rate limiting, multiplexing, metrics, healthcheck — with no chain model at all: no chain-id or genesis detection at bootstrap, no state poller, no request normalization and no per-method hooks (its `ArchitectureHandler` is a no-op, ). Every method is forwarded to the selected upstream unchanged, except that empty `params` are left out by default (`upstreams[].jsonRpc.omitEmptyParams`). Upstreams join a `jsonrpc:` network by declaring `type: jsonrpc` and the same id in `jsonRpc.networkId` (see [Upstreams](/config/projects/upstreams)); they use the regular HTTP(S) and WebSocket JSON-RPC clients, while gRPC endpoints are rejected. Because there is no block or slot model, responses are not cached and finality is `unknown` unless `methods.definitions` marks a method `finalized` or `realtime`. Upstream errors fall through to the default extractor, which maps HTTP status codes and standard JSON-RPC error codes into eRPC's error taxonomy. **Lazy creation.** `NetworksRegistry.GetNetwork` fast-paths a `sync.Map` lookup; on miss it validates the id format and schedules a bootstrap task. The task: (1) resolves or synthesizes a `NetworkConfig` and runs `SetDefaults` so it inherits `networkDefaults` exactly like static networks; (2) calls `PrepareUpstreamsForNetwork` — fans out to every configured provider, polls 200 ms for up to 30 s until ≥1 upstream is ready; (3) wires the cache DAL; (4) registers with the selection-policy engine. Statically-declared networks bootstrap in background at startup through the same machinery; a failed eager bootstrap is retried when the first request arrives. @@ -288,9 +291,10 @@ https://docs.erpc.cloud/config/projects/networks.llms.txt`} | Field | Type | Default | Behavior / footguns | |---|---|---|---| -| `architecture` | `string` (`evm` \| `svm`) | Inferred from the sub-block: `svm` present → `"svm"`, otherwise `"evm"` () | Both `evm` and `svm` are valid (). Required by validation. | +| `architecture` | `string` (`evm` \| `svm` \| `jsonrpc`) | Inferred from the sub-block: `svm` present → `"svm"`, `jsonRpc` present → `"jsonrpc"`, otherwise `"evm"` () | `evm`, `svm` and `jsonrpc` are valid (). Required by validation. | | `evm` | `EvmNetworkConfig` | Auto-created empty struct for EVM networks (); never injected into an SVM network | See EvmNetworkConfig table below. | | `svm` | `SvmNetworkConfig` | Auto-created empty struct when `architecture: svm` () | Required for SVM networks — `network.*.svm is required for svm networks` otherwise. See SvmNetworkConfig table below. | +| `jsonRpc` | `JsonRpcNetworkConfig` | Auto-created when `architecture: jsonrpc`; `id` defaults to `alias` | Required for jsonrpc networks (an `alias` is enough) — `network.*.jsonRpc.id (or network.*.alias) is required for jsonrpc networks` otherwise. See JsonRpcNetworkConfig table below. | | `alias` | `string` | `""` | Charset `[a-zA-Z0-9_-]+` (); unique per project (). Used as the `network` metric label via `Network.Label()` (). Aliases work in URL path only — not in body `networkId`. | | `cacheKeySuffix` | `string` | `""`; inherits `networkDefaults.cacheKeySuffix` when empty () | Inserted into the JSON-RPC cache partition key as `{networkId}:{suffix}:{blockRef}` so two networks that share a chainId and a Redis do not collide. Empty keeps `{networkId}:{blockRef}`. Charset `[a-zA-Z0-9_-]+` (same as alias); `:` is rejected. Not unique — many networks may share `systx`. When `{networkId}:{suffix}` is itself a valid network id (`svm:` plus a suffix), colons in the network id are escaped so that partition cannot be read as the longer network; unsuffixed keys and EVM suffixed keys are unchanged. Reverse-index wildcard is `{networkId}:{suffix}:*` (same helper as the main key), so hash-keyed / tag reads are isolated even when the block ref contains `:`. | | `rateLimitBudget` | `string` | `""` (none); inherits `networkDefaults.rateLimitBudget` when empty () | Must reference an existing budget. Enforced per-request before upstream contact → `ErrNetworkRateLimitRuleExceeded`. | @@ -361,6 +365,14 @@ Not present on `SvmNetworkConfig`: there is no `maxSlotsPerSignaturesQuery`. An **Cluster and genesis-hash validation.** Known `(chain, cluster)` pairs — currently Solana `mainnet-beta`, `devnet`, and `testnet` — have their genesis hash validated at bootstrap by a single `getGenesisHash` call compared against a hardcoded table. Both a mismatch and a *fetch failure* fail the upstream, so a node mis-pointed at the wrong cluster never joins the pool. For unknown clusters (forks, private clusters) the same check runs only when the upstream sets `svm.checkGenesisHash: true`. `getGenesisHash` itself is then answered from the table with no upstream round-trip, mirroring EVM's `eth_chainId` short-circuit. +#### `projects[].networks[].jsonRpc` — JsonRpcNetworkConfig + +| Field | Type | Default | Behavior / footguns | +|---|---|---|---| +| `id` | `string` | the network's `alias` | Network slug; the network id is `jsonrpc:`. Charset `[a-zA-Z0-9_-]+` (same as `alias`; `:` and `.` are rejected). **This is network identity**: upstreams are matched to the network by `jsonRpc.networkId == id`. | + +There is no `networkDefaults.jsonRpc`: the passthrough has no tunables beyond the generic network fields (`failsafe`, `selectionPolicy`, `rateLimitBudget`, `methods`, …), which inherit from `networkDefaults` as usual. + #### `projects[].networks[].directiveDefaults` — DirectiveDefaultsConfig | Field | Type | Default | @@ -386,7 +398,7 @@ Not present on `SvmNetworkConfig`: there is no `maxSlotsPerSignaturesQuery`. An | `failsafe[]` | `[]FailsafeConfig` | `nil` | Network has none → deep-copied wholesale. Network has some → per-entry merge from the FIRST compatible default (wildcard method + finality match); break on first match (). | | `selectionPolicy` | `SelectionPolicyConfig` | `nil` | Shallow-copied when network's is nil (). | | `directiveDefaults` | `DirectiveDefaultsConfig` | `nil` | Shallow-copied when network's is nil — **no per-field merge** (). | -| `evm` | `EvmNetworkConfig` | `nil` | Struct-copied wholesale when network has no `evm` block — **except onto SVM networks, which never receive it**. Otherwise per-field fill for: `integrity`, `fallbackStatePollerDebounce`, `dynamicBlockTimeDebounceMultiplier`, `blockUnavailableDelayMultiplier`, `fallbackFinalityDepth`, `getLogsMaxAllowed*`, `getLogs*`, `traceFilter*`, `servedTip`, `emptyResultConfidence`, `shortCircuitFutureBlocks`. NOT inherited individually: `chainId`, `enforceBlockAvailability`, `maxRetryableBlockDistance`, `markEmptyAsErrorMethods`, `idempotentTransactionBroadcast` (). | +| `evm` | `EvmNetworkConfig` | `nil` | Struct-copied wholesale when network has no `evm` block — **except onto SVM and jsonrpc networks, which never receive it**. Otherwise per-field fill for: `integrity`, `fallbackStatePollerDebounce`, `dynamicBlockTimeDebounceMultiplier`, `blockUnavailableDelayMultiplier`, `fallbackFinalityDepth`, `getLogsMaxAllowed*`, `getLogs*`, `traceFilter*`, `servedTip`, `emptyResultConfidence`, `shortCircuitFutureBlocks`. NOT inherited individually: `chainId`, `enforceBlockAvailability`, `maxRetryableBlockDistance`, `markEmptyAsErrorMethods`, `idempotentTransactionBroadcast` (). | | `svm` | `SvmNetworkConfig` | `nil` | Per-field fill: `chain`, `commitment`, `statePollerDebounce`, `maxFinalizedSlotLag`, `enforceBlockAvailability` (). **`cluster` is deliberately NOT inherited** — it is network identity, and inheriting it would let two networks alias one another. The two pointer fields are tested for `nil`, not for their zero value, so an explicit `maxFinalizedSlotLag: 0` or `enforceBlockAvailability: false` set here survives the merge. | | `multiplexing` | `*bool` | `nil` | Value-copied when network's `multiplexing` is nil (). No deep merge — a network must set its own `multiplexing` to deviate from the project-wide default. | @@ -694,6 +706,50 @@ POST /main/svm/fogo:mainnet → svm:fogo:mainnet POST /main → body {"networkId": "svm:fogo:mainnet", ...} ``` +**7. Generic JSON-RPC passthrough for a non-EVM chain.** `architecture: jsonrpc` proxies any JSON-RPC node without a chain model: no chain-id detection, no state poller, every method forwarded as-is. The network is identified by a slug (`jsonRpc.id`, defaulting to `alias`), and upstreams opt in with `type: jsonrpc` plus the same slug in `jsonRpc.networkId`: + + + +``` +POST /main/jsonrpc/bitcoin-mainnet → jsonrpc:bitcoin-mainnet +POST /main/bitcoin → jsonrpc:bitcoin-mainnet (alias) +``` + ### Request/response behavior - The `network` label on every Prometheus metric equals the alias when set, otherwise the raw network id (`evm:1`, `svm:mainnet-beta`, `svm:fogo:mainnet`). This affects all `erpc_network_*` metrics. [`erpc/networks.go:L278-286`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L278-L286) @@ -723,7 +779,7 @@ POST /main → body {"networkId": "svm:fogo:mainnet", ...} ### Edge cases & gotchas 1. **Aliases work in URLs only, not body `networkId`** — body fallback splits on `:` with no alias lookup; only `parseUrlPath` resolves aliases. [`erpc/http_server.go:L613-634`](https://github.com/erpc/erpc/blob/main/erpc/http_server.go#L613-L634) -2. **Unknown alias segment falls through silently** — an unresolvable single path segment is treated as architecture, yielding "architecture is not valid (must be 'evm')" instead of an alias-not-found error. +2. **Unknown alias segment falls through silently** — an unresolvable single path segment is treated as architecture, yielding "architecture is not valid (must be 'evm', 'svm' or 'jsonrpc')" instead of an alias-not-found error. 3. **`directiveDefaults` is all-or-nothing** — a network-level `directiveDefaults` block completely replaces `networkDefaults.directiveDefaults`; individual fields are not merged. [`common/defaults.go:L1837-1840`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L1837-L1840) 4. **`failsafe` replaces, not merges** — any `networks[].failsafe` list supersedes `networkDefaults.failsafe` entirely for that network. 5. **`preserveDefaultMethods: false` + one custom `definitions` entry drops ALL built-ins** — including `eth_call`, `eth_getLogs`, etc. Only the custom entries plus the 6 stateful markers survive. [`common/defaults.go:L561-573`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L561-L573) diff --git a/docs/pages/config/projects/upstreams.mdx b/docs/pages/config/projects/upstreams.mdx index 44ee4e67a..b2b3ed150 100644 --- a/docs/pages/config/projects/upstreams.mdx +++ b/docs/pages/config/projects/upstreams.mdx @@ -107,7 +107,7 @@ fields; all-or-nothing for `tags`, `failsafe`, and `routing`; per-field merge fo EVM sub-fields; wholesale-then-per-field for SVM sub-fields — then `SetDefaults` fills remaining gaps: deriving an `id` if empty, defaulting `type` to `evm` **only when it was omitted** (so an SVM upstream must say -`type: svm`), injecting `ignoreMethods: ["*"]` when `allowMethods` is set +`type: svm`; a `jsonRpc.networkId` without a `type` infers `type: jsonrpc`), injecting `ignoreMethods: ["*"]` when `allowMethods` is set without an explicit `ignoreMethods`. Endpoints using a vendor shorthand (`alchemy://KEY`, `drpc://KEY`, and 20+ other prefixes) are converted into provider configs at this stage; the rest of the upstream block travels as a `"*"` override. @@ -213,7 +213,7 @@ All paths relative to `projects[*]`. "Default" = value after `SetDefaults`/`Appl | Field | Type | Default | Behavior / footguns | |---|---|---|---| | `upstreams[*].id` | string | Derived: native-scheme endpoint → `-`; non-native → `-`; if `vendorName` set → `-` () | Used in task names, metrics labels, `use-upstream` matching, admin cordon RPCs. | -| `upstreams[*].type` | `UpstreamType` | `"evm"` when omitted () | `evm` and `svm` are both supported at runtime. The `evm+` shorthand schemes are normalized to `"evm"` at defaults time. **Footgun**: `svm` is never inferred — an upstream serving Solana that omits `type` silently becomes an EVM upstream and fails to bootstrap. Always write `type: svm` explicitly. | +| `upstreams[*].type` | `UpstreamType` | `"evm"` when omitted () | `evm`, `svm` and `jsonrpc` are supported at runtime. The `evm+` shorthand schemes are normalized to `"evm"` at defaults time. **Footgun**: `svm` is never inferred — an upstream serving Solana that omits `type` silently becomes an EVM upstream and fails to bootstrap. Always write `type: svm` explicitly. `jsonrpc` (generic passthrough, see [Networks](/config/projects/networks)) is inferred when `jsonRpc.networkId` is set. | | `upstreams[*].endpoint` | string | `""` (required) | Native schemes: `http://`, `https://`, `ws://`, `wss://`, `grpc://`, `grpc+bds://`. Non-native (e.g. `alchemy://KEY`) converts the upstream into a provider. `ws://`/`wss://` create a persistent WebSocket client (auto-reconnect, keepalive) that serves regular requests and `eth_subscribe` — see [WebSocket](/operation/websocket). Redacted in JSON/YAML output. | | `upstreams[*].tags` | `[]string` | nil; all-or-nothing inheritance from `upstreamDefaults.tags` () | `:` convention. Matched by `use-upstream` and policy stdlib. **Footgun**: one tag on the upstream drops ALL defaults tags. | | `upstreams[*].vendorName` | string | `""`; at runtime filled by URL pattern match or `guessVendorName()` | When non-empty, forces name-lookup only — `OwnsUpstream` URL matching is skipped. Mismatch silently applies the wrong error normalizer. No warning is logged. | @@ -237,6 +237,8 @@ All paths relative to `projects[*]`. "Default" = value after `SetDefaults`/`Appl | `upstreams[*].jsonRpc.enableGzip` | `*bool` | nil (false) | Compresses outbound request body. Client always sends `Accept-Encoding: gzip` and decompresses responses regardless. | | `upstreams[*].jsonRpc.headers` | `map[string]string` | nil | Static headers on every outbound request. Applied via `Header.Set` (overwrites defaults for matching keys). | | `upstreams[*].jsonRpc.proxyPool` | string | `""` | References `proxyPools[].id`. Error at startup if pool not found. | +| `upstreams[*].jsonRpc.networkId` | string | `""`; **not** inherited from `upstreamDefaults.jsonRpc` | Required for `type: jsonrpc`: binds the upstream to the passthrough network `jsonrpc:` (slug only, charset `[a-zA-Z0-9_-]+`). Bootstrap sends nothing to the upstream — no chain-id detection, no state poller. Only `http(s)://` and `ws(s)://` endpoints are accepted. Ignored for other types. Not inherited because a project-wide `jsonRpc` block is usually shared with EVM upstreams, which would otherwise be retyped. | +| `upstreams[*].jsonRpc.omitEmptyParams` | `*bool` | `true` for `type: jsonrpc`, `false` otherwise | Leaves the `params` member out of outbound requests whose params are empty instead of sending `"params":[]`. `params` is optional in JSON-RPC 2.0, but some servers reject an empty array for methods without parameters. Applies to the HTTP client (single and batch) and the WebSocket client; cache keys are unaffected. | | `upstreams[*].grpc.headers` | `map[string]string` | nil | Applied as gRPC metadata on every outbound request. | | `upstreams[*].shadow.enabled` | bool | `false` | Registers into `networkShadowUpstreams`; never serves real traffic; receives async mirrored traffic post-response. | | `upstreams[*].shadow.sampleRate` | `*float64` | nil → effective `1.0` | Probability a real response triggers a mirror to this upstream. | @@ -810,6 +812,44 @@ SVM upstream gets its own slot/health poller automatically: }]`} /> +**7. Generic JSON-RPC upstreams (`type: jsonrpc`).** For JSON-RPC nodes that are neither +EVM nor SVM, `jsonRpc.networkId` names the passthrough network the upstream serves. eRPC +does not probe the node at bootstrap and forwards every method unchanged; failover, +retries, rate limits and metrics work as for any other upstream: + + + +Without a matching entry in `networks[]`, the `jsonrpc:bitcoin-mainnet` network is created +on its first request (`POST /main/jsonrpc/bitcoin-mainnet`). + ### Request/response behavior - `shouldSkip` runs before every `Forward` call. Skip reasons surface as