From a1c396ae222f09a605e65a708b2b0c6f520c4fd6 Mon Sep 17 00:00:00 2001 From: snowkide Date: Tue, 25 Aug 2026 14:39:29 +0200 Subject: [PATCH 1/3] feat: add jsonrpc architecture for non-EVM HAProxy upstreams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passthrough JSON-RPC networks (jsonrpc:) skip EVM chainId/state poller/hooks while keeping HTTP/WS forwarding and metrics — including forwardedClientId from #20. Point upstreams at HAProxy/direct endpoints. Co-authored-by: Cursor --- common/architecture_jsonrpc.go | 12 ++++++++++++ common/architecture_jsonrpc_test.go | 23 +++++++++++++++++++++++ common/config.go | 16 ++++++++++++++-- common/defaults.go | 25 +++++++++++++++++++++---- common/network.go | 14 ++++++++++++-- common/validation.go | 18 +++++++++++++++++- erpc/networks.go | 11 +++++++++++ erpc/networks_registry.go | 9 ++++++++- upstream/registry.go | 2 ++ upstream/upstream.go | 8 ++++++++ util/ids.go | 8 ++++++++ 11 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 common/architecture_jsonrpc.go create mode 100644 common/architecture_jsonrpc_test.go diff --git a/common/architecture_jsonrpc.go b/common/architecture_jsonrpc.go new file mode 100644 index 000000000..edacd589a --- /dev/null +++ b/common/architecture_jsonrpc.go @@ -0,0 +1,12 @@ +package common + +const ( + UpstreamTypeJsonRpc UpstreamType = "jsonrpc" +) + +// JsonRpcNetworkConfig identifies a non-EVM JSON-RPC network (Solana, Starknet, …). +// Network id becomes jsonrpc:. No chainId / state poller / EVM method hooks. +type JsonRpcNetworkConfig struct { + // Id is a stable slug (usually the CLL alias), e.g. solana-mainnet. + Id string `yaml:"id" json:"id"` +} diff --git a/common/architecture_jsonrpc_test.go b/common/architecture_jsonrpc_test.go new file mode 100644 index 000000000..dc8133e92 --- /dev/null +++ b/common/architecture_jsonrpc_test.go @@ -0,0 +1,23 @@ +package common + +import "testing" + +func TestJsonRpcNetworkId(t *testing.T) { + n := &NetworkConfig{ + Architecture: ArchitectureJsonRpc, + Alias: "solana-mainnet", + JsonRpc: &JsonRpcNetworkConfig{Id: "solana-mainnet"}, + } + if got := n.NetworkId(); got != "jsonrpc:solana-mainnet" { + t.Fatalf("NetworkId()=%q", got) + } + if !IsValidArchitecture(string(ArchitectureJsonRpc)) { + t.Fatal("ArchitectureJsonRpc should be valid") + } + if !IsValidNetwork("jsonrpc:solana-mainnet") { + t.Fatal("jsonrpc:solana-mainnet should be valid") + } + if IsValidNetwork("jsonrpc:") || IsValidNetwork("jsonrpc:a:b") { + t.Fatal("invalid jsonrpc ids accepted") + } +} diff --git a/common/config.go b/common/config.go index 84ab77cd2..bbc936f36 100644 --- a/common/config.go +++ b/common/config.go @@ -1123,6 +1123,9 @@ 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 this upstream to architecture jsonrpc (slug only, e.g. solana-mainnet). + // Required when type is jsonrpc; skipped for EVM upstreams that already use jsonRpc for batch/headers. + NetworkId string `yaml:"networkId,omitempty" json:"networkId,omitempty"` } func (c *JsonRpcUpstreamConfig) Copy() *JsonRpcUpstreamConfig { @@ -2013,6 +2016,7 @@ type NetworkConfig struct { RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget"` Failsafe []*FailsafeConfig `yaml:"failsafe,omitempty" json:"failsafe"` Evm *EvmNetworkConfig `yaml:"evm,omitempty" json:"evm"` + 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"` @@ -2562,13 +2566,21 @@ type RateLimitStoreConfig struct { } func (c *NetworkConfig) NetworkId() string { - if c.Architecture == "" || c.Evm == nil { + if c.Architecture == "" { return "" } switch c.Architecture { - case "evm": + case ArchitectureEvm: + if c.Evm == nil { + return "" + } return util.EvmNetworkId(c.Evm.ChainId) + 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 045da8026..3f1f6a0cc 100644 --- a/common/defaults.go +++ b/common/defaults.go @@ -1646,8 +1646,17 @@ 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 != "" { + u.Type = UpstreamTypeJsonRpc + } else { + // TODO make actual calls to detect other types (solana, btc, etc)? + u.Type = UpstreamTypeEvm + } + } + if u.Type == UpstreamTypeJsonRpc { + if u.JsonRpc == nil { + u.JsonRpc = &JsonRpcUpstreamConfig{} + } } if len(u.Failsafe) > 0 { @@ -1940,13 +1949,21 @@ func (n *NetworkConfig) SetDefaults(upstreams []*UpstreamConfig, defaults *Netwo if n.Architecture == "" { if n.Evm != nil { - n.Architecture = "evm" + n.Architecture = ArchitectureEvm + } else if n.JsonRpc != nil && n.JsonRpc.Id != "" { + n.Architecture = ArchitectureJsonRpc } } - if n.Architecture == "evm" && n.Evm == nil { + if n.Architecture == ArchitectureEvm && n.Evm == nil { n.Evm = &EvmNetworkConfig{} } + if n.Architecture == ArchitectureJsonRpc && n.JsonRpc == nil { + n.JsonRpc = &JsonRpcNetworkConfig{} + } + if n.Architecture == ArchitectureJsonRpc && n.JsonRpc.Id == "" && n.Alias != "" { + n.JsonRpc.Id = n.Alias + } // Apply methods defaults if n.Methods == nil { diff --git a/common/network.go b/common/network.go index 44a843c67..72ff622f3 100644 --- a/common/network.go +++ b/common/network.go @@ -12,7 +12,8 @@ import ( type NetworkArchitecture string const ( - ArchitectureEvm NetworkArchitecture = "evm" + ArchitectureEvm NetworkArchitecture = "evm" + ArchitectureJsonRpc NetworkArchitecture = "jsonrpc" ) type Network interface { @@ -33,7 +34,12 @@ type Network interface { } func IsValidArchitecture(architecture string) bool { - return architecture == string(ArchitectureEvm) // TODO add more architectures when they are supported + switch NetworkArchitecture(architecture) { + case ArchitectureEvm, ArchitectureJsonRpc: + return true + default: + return false + } } func IsValidNetwork(network string) bool { @@ -44,6 +50,10 @@ func IsValidNetwork(network string) bool { } return chainId > 0 } + if strings.HasPrefix(network, "jsonrpc:") { + id := strings.TrimPrefix(network, "jsonrpc:") + return id != "" && !strings.Contains(id, ":") + } return false } diff --git a/common/validation.go b/common/validation.go index 25bcafd87..86ecdd3d0 100644 --- a/common/validation.go +++ b/common/validation.go @@ -856,6 +856,14 @@ func (u *UpstreamConfig) Validate(c *Config, skipEndpointCheck bool) error { if !skipEndpointCheck && u.Endpoint == "" { return fmt.Errorf("upstream.*.endpoint is required") } + if u.Type == UpstreamTypeJsonRpc { + if u.JsonRpc == nil || u.JsonRpc.NetworkId == "" { + return fmt.Errorf("upstream.*.jsonRpc.networkId is required for type jsonrpc") + } + if !util.IsValidIdentifier(u.JsonRpc.NetworkId) { + return fmt.Errorf("upstream.*.jsonRpc.networkId '%s' is invalid", u.JsonRpc.NetworkId) + } + } if u.Evm != nil { if err := u.Evm.Validate(u); err != nil { return err @@ -1274,9 +1282,17 @@ func (n *NetworkConfig) Validate(c *Config) error { if n.Architecture == "" { return fmt.Errorf("network.*.architecture is required") } - if n.Architecture == "evm" && n.Evm == nil { + if n.Architecture == ArchitectureEvm && n.Evm == nil { return fmt.Errorf("network.*.evm is required for evm networks") } + if n.Architecture == ArchitectureJsonRpc { + if n.JsonRpc == nil || n.JsonRpc.Id == "" { + return fmt.Errorf("network.*.jsonRpc.id is required for jsonrpc networks") + } + if !util.IsValidIdentifier(n.JsonRpc.Id) { + return fmt.Errorf("network.*.jsonRpc.id '%s' 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/networks.go b/erpc/networks.go index 6a6374176..ab7ea550b 100644 --- a/erpc/networks.go +++ b/erpc/networks.go @@ -1392,6 +1392,17 @@ func (n *Network) prepareRequest(ctx context.Context, nr *common.NormalizedReque ) } evm.NormalizeHttpJsonRpc(ctx, nr, jsonRpcReq) + case common.ArchitectureJsonRpc: + // Passthrough: parse/validate JSON-RPC envelope only — no EVM method hooks. + if _, err := nr.JsonRpcRequest(ctx); err != nil { + return common.NewErrJsonRpcExceptionInternal( + 0, + common.JsonRpcErrorParseException, + "failed to unmarshal json-rpc request", + err, + nil, + ) + } default: return common.NewErrJsonRpcExceptionInternal( 0, diff --git a/erpc/networks_registry.go b/erpc/networks_registry.go index 4183ec944..5246a8af2 100644 --- a/erpc/networks_registry.go +++ b/erpc/networks_registry.go @@ -309,10 +309,12 @@ func (nr *NetworksRegistry) prepareNetwork(nwCfg *common.NetworkConfig) (*Networ } switch nwCfg.Architecture { - case "evm": + case common.ArchitectureEvm: if nr.evmJsonRpcCache != nil { network.cacheDal = nr.evmJsonRpcCache.WithProjectId(nr.project.Config.Id) } + case common.ArchitectureJsonRpc: + // No architecture-specific cache yet; failsafe + metrics still apply. default: return nil, errors.New("unknown network architecture") } @@ -363,6 +365,11 @@ func (nr *NetworksRegistry) resolveNetworkConfig(networkId string) (*common.Netw return nil, e } nwCfg.Evm = &common.EvmNetworkConfig{ChainId: int64(c)} + case common.ArchitectureJsonRpc: + if !util.IsValidIdentifier(s[1]) { + return nil, fmt.Errorf("invalid jsonrpc network id: %s", networkId) + } + nwCfg.JsonRpc = &common.JsonRpcNetworkConfig{Id: s[1]} } if err := nwCfg.SetDefaults(prj.Config.Upstreams, prj.Config.NetworkDefaults); err != nil { return nil, fmt.Errorf("failed to set defaults for network config: %w", err) diff --git a/upstream/registry.go b/upstream/registry.go index 6c5f43b1b..8b31f09b0 100644 --- a/upstream/registry.go +++ b/upstream/registry.go @@ -474,6 +474,8 @@ func (u *UpstreamsRegistry) buildUpstreamBootstrapTask(upsCfg *common.UpstreamCo taskName := fmt.Sprintf("upstream/%s", cfg.Id) if cfg.Evm != nil && cfg.Evm.ChainId > 0 { taskName = fmt.Sprintf("network/%s/upstream/%s", util.EvmNetworkId(cfg.Evm.ChainId), cfg.Id) + } else 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) } return util.NewBootstrapTask( taskName, diff --git a/upstream/upstream.go b/upstream/upstream.go index d7b294636..21fae355c 100644 --- a/upstream/upstream.go +++ b/upstream/upstream.go @@ -1487,6 +1487,14 @@ func (u *Upstream) detectFeatures(ctx context.Context) error { // TODO evm: check trace methods availability (by engine? erigon/geth/etc) // TODO evm: detect max eth_getLogs max block range + } else if cfg.Type == common.UpstreamTypeJsonRpc { + if cfg.JsonRpc == nil || cfg.JsonRpc.NetworkId == "" { + return common.NewTaskFatal(fmt.Errorf("upstream.*.jsonRpc.networkId is required for type jsonrpc")) + } + if !util.IsValidIdentifier(cfg.JsonRpc.NetworkId) { + return common.NewTaskFatal(fmt.Errorf("upstream.*.jsonRpc.networkId '%s' is invalid", cfg.JsonRpc.NetworkId)) + } + 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 0b6bc9f77..433b16f86 100644 --- a/util/ids.go +++ b/util/ids.go @@ -12,6 +12,10 @@ func EvmNetworkId(chainId interface{}) string { return fmt.Sprintf("evm:%d", chainId) } +func JsonRpcNetworkId(id string) string { + return fmt.Sprintf("jsonrpc:%s", id) +} + var validIdentifierRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) func IsValidIdentifier(s string) bool { @@ -23,6 +27,10 @@ func IsValidNetworkId(s string) bool { _, err := strconv.Atoi(s[4:]) return err == nil } + if strings.HasPrefix(s, "jsonrpc:") { + id := s[len("jsonrpc:"):] + return id != "" && IsValidIdentifier(id) + } return false } From fef78c8ae9fa974d3c9ea99c4f7f42cd6d68b35b Mon Sep 17 00:00:00 2001 From: snowkide Date: Wed, 26 Aug 2026 13:20:01 +0200 Subject: [PATCH 2/3] fix: accept jsonRpc on NetworkConfig failsafe-object fallback Chart emits failsafe as a single object, so UnmarshalYAML uses oldNetworkConfig. That struct lacked JsonRpc, so architecture: jsonrpc networks crashed config load (chainlink-erpc CrashLoopBackOff). Co-authored-by: Cursor --- common/architecture_jsonrpc_test.go | 42 ++++++++++++++++++++++++++++- common/config.go | 2 ++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/common/architecture_jsonrpc_test.go b/common/architecture_jsonrpc_test.go index dc8133e92..3047e3b25 100644 --- a/common/architecture_jsonrpc_test.go +++ b/common/architecture_jsonrpc_test.go @@ -1,6 +1,10 @@ package common -import "testing" +import ( + "testing" + + "gopkg.in/yaml.v3" +) func TestJsonRpcNetworkId(t *testing.T) { n := &NetworkConfig{ @@ -21,3 +25,39 @@ func TestJsonRpcNetworkId(t *testing.T) { t.Fatal("invalid jsonrpc ids accepted") } } + +func TestJsonRpcNetworkConfig_UnmarshalYAML_FailsafeObject(t *testing.T) { + // Chart emits failsafe as a single object (not a list). NetworkConfig must + // still accept architecture/jsonRpc via the oldNetworkConfig fallback. + const raw = ` +architecture: jsonrpc +alias: solana-mainnet +jsonRpc: + id: solana-mainnet +failsafe: + timeout: + duration: 30s + retry: + maxAttempts: 5 + delay: 50ms +` + var n NetworkConfig + if err := yaml.Unmarshal([]byte(raw), &n); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if n.Architecture != ArchitectureJsonRpc { + t.Fatalf("architecture=%q", n.Architecture) + } + if n.JsonRpc == nil || n.JsonRpc.Id != "solana-mainnet" { + t.Fatalf("jsonRpc=%v", n.JsonRpc) + } + if n.Alias != "solana-mainnet" { + t.Fatalf("alias=%q", n.Alias) + } + if len(n.Failsafe) != 1 || n.Failsafe[0].Timeout == nil { + t.Fatalf("failsafe not converted from object: %+v", n.Failsafe) + } + if got := n.NetworkId(); got != "jsonrpc:solana-mainnet" { + t.Fatalf("NetworkId()=%q", got) + } +} diff --git a/common/config.go b/common/config.go index bbc936f36..5440992b1 100644 --- a/common/config.go +++ b/common/config.go @@ -2088,6 +2088,7 @@ func (n *NetworkConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { RateLimitBudget string `yaml:"rateLimitBudget,omitempty"` Failsafe *FailsafeConfig `yaml:"failsafe,omitempty"` Evm *EvmNetworkConfig `yaml:"evm,omitempty"` + JsonRpc *JsonRpcNetworkConfig `yaml:"jsonRpc,omitempty"` SelectionPolicy *SelectionPolicyConfig `yaml:"selectionPolicy,omitempty"` DirectiveDefaults *DirectiveDefaultsConfig `yaml:"directiveDefaults,omitempty"` Alias string `yaml:"alias,omitempty"` @@ -2106,6 +2107,7 @@ func (n *NetworkConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { n.Architecture = old.Architecture n.RateLimitBudget = old.RateLimitBudget n.Evm = old.Evm + n.JsonRpc = old.JsonRpc n.SelectionPolicy = old.SelectionPolicy n.DirectiveDefaults = old.DirectiveDefaults n.Alias = old.Alias From 8dfeb1a074269b361b7c2c02eb24247b4900129c Mon Sep 17 00:00:00 2001 From: snowkide Date: Wed, 26 Aug 2026 14:21:23 +0200 Subject: [PATCH 3/3] fix: create HTTP/WS clients for upstream type jsonrpc clients/registry only accepted type=evm, so every non-EVM upstream failed with "unsupported upstream type: jsonrpc" and networks never left ErrNetworkInitializing. Co-authored-by: Cursor --- clients/registry.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/clients/registry.go b/clients/registry.go index b35e9a2d5..c512534c3 100644 --- a/clients/registry.go +++ b/clients/registry.go @@ -96,7 +96,9 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U var c ClientInterface var cerr error switch cfg.Type { - case common.UpstreamTypeEvm: + case common.UpstreamTypeEvm, common.UpstreamTypeJsonRpc: + // jsonrpc architecture reuses the generic HTTP/WS JSON-RPC clients + // (passthrough; no EVM chainId/state poller). gRPC BDS remains EVM-only. switch parsedUrl.Scheme { case "http", "https": c, cerr = NewGenericHttpJsonRpcClient( @@ -126,6 +128,10 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U cerr = fmt.Errorf("failed to create WebSocket client for upstream %v: %w", cfg.Id, cerr) } case "grpc", "grpc+bds": + if cfg.Type == common.UpstreamTypeJsonRpc { + cerr = fmt.Errorf("unsupported endpoint scheme: %v for upstream type jsonrpc: %v", parsedUrl.Scheme, cfg.Id) + break + } c, cerr = NewGrpcBdsClient( appCtx, &lg,