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/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/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/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/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..b30f122d6 100644 --- a/common/config.go +++ b/common/config.go @@ -1327,6 +1327,17 @@ 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"` + // 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 { @@ -2356,6 +2367,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 +2449,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 +2471,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 +3438,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..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 { @@ -1944,8 +1945,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 { @@ -2033,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 { @@ -2291,12 +2301,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 +2333,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 +2344,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/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 a967d2217..8a5b1e54c 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", @@ -1201,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 1a78528c4..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{ @@ -238,6 +255,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 { 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/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 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..1e7a01857 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,21 @@ 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; + /** + * 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 @@ -1439,6 +1481,7 @@ export interface NetworkConfig { failsafe?: (FailsafeConfig | undefined)[]; evm?: EvmNetworkConfig; svm?: SvmNetworkConfig; + jsonRpc?: JsonRpcNetworkConfig; selectionPolicy?: SelectionPolicyConfig; directiveDefaults?: DirectiveDefaultsConfig; alias?: string; @@ -2621,6 +2664,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) + } + } +}