From c4bfe8acff1a250973468fffa74cc19930e8cd1e Mon Sep 17 00:00:00 2001 From: bussyjd <145845+bussyjd@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:06:09 +0400 Subject: [PATCH 1/3] feat(network): add hl-node network + eRPC HyperEVM alias Add hl-node as an obol-stack network (Chart/values/helmfile via bedag/raw): a Hyperliquid non-validating node StatefulSet (hostNetwork, nodeSelector), Service exposing the HyperEVM JSON-RPC on :3001/evm, and a metadata ConfigMap. Register the local node's HyperEVM RPC (chain 999) as an eRPC upstream alias `hyperevm` (+ `hyperevm-testnet`) via resolveLocalERPCRegistration, so in-cluster consumers reach HyperCore at http://erpc.erpc.svc.cluster.local/rpc/hyperevm instead of a hardcoded basket. Claude-Session: https://claude.ai/code/session_01YUTW7NfxjUoVtyKgR6nPQC --- internal/embed/networks/hl-node/Chart.yaml | 5 + .../networks/hl-node/helmfile.yaml.gotmpl | 121 ++++++++++++++++++ .../embed/networks/hl-node/values.yaml.gotmpl | 27 ++++ internal/network/erpc.go | 59 +++++++-- internal/network/erpc_test.go | 70 ++++++++++ 5 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 internal/embed/networks/hl-node/Chart.yaml create mode 100644 internal/embed/networks/hl-node/helmfile.yaml.gotmpl create mode 100644 internal/embed/networks/hl-node/values.yaml.gotmpl diff --git a/internal/embed/networks/hl-node/Chart.yaml b/internal/embed/networks/hl-node/Chart.yaml new file mode 100644 index 00000000..41c76220 --- /dev/null +++ b/internal/embed/networks/hl-node/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: hl-node-local +description: Local Hyperliquid non-validating node resources +type: application +version: 0.1.0 diff --git a/internal/embed/networks/hl-node/helmfile.yaml.gotmpl b/internal/embed/networks/hl-node/helmfile.yaml.gotmpl new file mode 100644 index 00000000..1b614653 --- /dev/null +++ b/internal/embed/networks/hl-node/helmfile.yaml.gotmpl @@ -0,0 +1,121 @@ +repositories: + - name: bedag + url: https://bedag.github.io/helm-charts/ + +releases: + # Hyperliquid non-validating node (closed x86-64 hl-visor binary, containerized on ubuntu:24.04). + # hostNetwork so gossip 4001/4002 bind to the node IP (must be publicly reachable to sync). + - name: hl-node + namespace: hl-node-{{ .Values.id }} + createNamespace: true + chart: bedag/raw + values: + - resources: + - apiVersion: apps/v1 + kind: StatefulSet + metadata: + name: hl-node + namespace: hl-node-{{ .Values.id }} + labels: + app: hl-node + app.kubernetes.io/part-of: obol.stack + obol.stack/id: {{ .Values.id }} + obol.stack/app: hl-node + spec: + serviceName: hl-node + replicas: 1 + selector: + matchLabels: + app: hl-node + template: + metadata: + labels: + app: hl-node + spec: + {{- if .Values.host }} + nodeSelector: + kubernetes.io/hostname: {{ .Values.host }} + {{- end }} + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + containers: + - name: hl-node + image: {{ .Values.image }} + imagePullPolicy: IfNotPresent + env: + - name: HL_CHAIN + value: "{{ .Values.chain }}" + - name: HL_FLAGS + value: "{{ .Values.flags }}" + - name: HL_DATA_DIR + value: /hl-data + - name: HL_ROOT_IP + value: "{{ .Values.rootIp }}" + ports: + - containerPort: 4001 + name: gossip1 + - containerPort: 4002 + name: gossip2 + - containerPort: 3001 + name: evm-rpc + volumeMounts: + - name: data + mountPath: /hl-data + # Official non-validator rec is 128Gi; this is best-effort on smaller nodes. + # No memory limit so the node can use host RAM + swap instead of being OOMKilled. + resources: + requests: + cpu: "4" + memory: "8Gi" + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: {{ .Values.storageSize }} + - apiVersion: v1 + kind: Service + metadata: + name: hl-node + namespace: hl-node-{{ .Values.id }} + labels: + app: hl-node + spec: + selector: + app: hl-node + ports: + - name: evm-rpc + port: 3001 + targetPort: 3001 + + # Metadata ConfigMap for frontend discovery + - name: hl-node-metadata + namespace: hl-node-{{ .Values.id }} + chart: bedag/raw + values: + - resources: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: hl-node-{{ .Values.id }}-metadata + namespace: hl-node-{{ .Values.id }} + labels: + app.kubernetes.io/part-of: obol.stack + obol.stack/id: {{ .Values.id }} + obol.stack/app: hl-node + data: + metadata.json: | + { + "chain": "{{ .Values.chain }}", + "type": "hyperliquid-node", + "endpoints": { + "hyperevm-rpc": { + "internal": "http://hl-node.hl-node-{{ .Values.id }}.svc.cluster.local:3001/evm" + } + }, + "tradeData": "node_trades/node_fills hourly JSONL under the data volume (/hl-data/data)" + } diff --git a/internal/embed/networks/hl-node/values.yaml.gotmpl b/internal/embed/networks/hl-node/values.yaml.gotmpl new file mode 100644 index 00000000..9aee2514 --- /dev/null +++ b/internal/embed/networks/hl-node/values.yaml.gotmpl @@ -0,0 +1,27 @@ +# Configuration via CLI flags +# Template fields populated by obol CLI during network installation + +# @enum Mainnet,Testnet +# @default Mainnet +# @description Hyperliquid chain to follow +chain: {{.Chain}} + +# @default --write-trades --serve-eth-rpc +# @description hl-visor run-non-validator flags. Add --write-fills/--write-order-statuses/--write-raw-book-diffs for more data (each adds tens of GB/day). --serve-eth-rpc exposes HyperEVM JSON-RPC on :3001/evm. +flags: "{{.Flags}}" + +# @default +# @description Pinned gossip root peer IP for fast deterministic peering (from POST {"type":"gossipRootIps"} to api.hyperliquid.xyz/info). Empty = auto-discover. Gossip ports 4001/4002 must be publicly reachable to sync. +rootIp: "{{.RootIp}}" + +# @default +# @description Cluster node hostname to pin to (nodeSelector kubernetes.io/hostname). MUST be x86-64, Ubuntu-capable, >=500GB SSD (ideally 128GB RAM). Empty = schedule anywhere. +host: "{{.Host}}" + +# @default hl-node:dev +# @description Container image (built from obol-exex-indexer deploy/hl-node; must be present on the target node's container runtime or published to a registry). +image: "{{.Image}}" + +# @default 1500Gi +# @description Data volume size. Chain writes ~100GB/day with write-* flags — prune/rotate or size generously. +storageSize: {{.StorageSize}} diff --git a/internal/network/erpc.go b/internal/network/erpc.go index c2956e94..5751d4b4 100644 --- a/internal/network/erpc.go +++ b/internal/network/erpc.go @@ -20,6 +20,8 @@ const ( erpcDeployment = "erpc" ) +var errNoERPCRegistration = errors.New("network does not expose an eRPC upstream") + // networkChainIDs maps network names to EVM chain IDs. var networkChainIDs = map[string]int{ "mainnet": 1, @@ -29,6 +31,51 @@ var networkChainIDs = map[string]int{ "base-sepolia": 84532, } +type localERPCRegistration struct { + ChainID int + Alias string + Endpoint string +} + +func resolveLocalERPCRegistration(networkType, id string, values struct { + Network string `yaml:"network"` +}) (localERPCRegistration, error) { + namespace := fmt.Sprintf("%s-%s", networkType, id) + + switch networkType { + case "ethereum": + chainID, ok := networkChainIDs[values.Network] + if !ok { + return localERPCRegistration{}, fmt.Errorf("unknown network %q — no chain ID mapping", values.Network) + } + + return localERPCRegistration{ + ChainID: chainID, + Alias: values.Network, + Endpoint: fmt.Sprintf("http://ethereum-execution.%s.svc.cluster.local:8545", namespace), + }, nil + case "hl-node": + switch strings.ToLower(strings.TrimSpace(values.Network)) { + case "mainnet": + return localERPCRegistration{ + ChainID: 999, + Alias: "hyperevm", + Endpoint: fmt.Sprintf("http://hl-node.%s.svc.cluster.local:3001/evm", namespace), + }, nil + case "testnet": + return localERPCRegistration{ + ChainID: 998, + Alias: "hyperevm-testnet", + Endpoint: fmt.Sprintf("http://hl-node.%s.svc.cluster.local:3001/evm", namespace), + }, nil + default: + return localERPCRegistration{}, fmt.Errorf("unknown hl-node network %q — expected mainnet or testnet", values.Network) + } + default: + return localERPCRegistration{}, errNoERPCRegistration + } +} + // RegisterERPCUpstream reads the deployed network's RPC endpoint and adds // it as an upstream in the eRPC ConfigMap. The local node becomes the // primary upstream (group: "primary") with automatic fallback to existing @@ -50,17 +97,13 @@ func RegisterERPCUpstream(cfg *config.Config, networkType, id string) error { return fmt.Errorf("could not parse values.yaml: %w", err) } - chainID, ok := networkChainIDs[values.Network] - if !ok { - return fmt.Errorf("unknown network %q — no chain ID mapping", values.Network) + reg, err := resolveLocalERPCRegistration(networkType, id, values) + if err != nil { + return err } - - // Build the internal RPC endpoint for this network's execution client - namespace := fmt.Sprintf("%s-%s", networkType, id) - endpoint := fmt.Sprintf("http://ethereum-execution.%s.svc.cluster.local:8545", namespace) upstreamID := fmt.Sprintf("local-%s-%s", networkType, id) - return patchERPCUpstream(cfg, upstreamID, endpoint, chainID, values.Network, true) + return patchERPCUpstream(cfg, upstreamID, reg.Endpoint, reg.ChainID, reg.Alias, true) } // DeregisterERPCUpstream removes a previously registered local upstream diff --git a/internal/network/erpc_test.go b/internal/network/erpc_test.go index d0b3205c..4abca00a 100644 --- a/internal/network/erpc_test.go +++ b/internal/network/erpc_test.go @@ -389,3 +389,73 @@ func TestNetworkChainIDs(t *testing.T) { } } } + +func TestResolveLocalERPCRegistration(t *testing.T) { + tests := []struct { + name string + networkType string + id string + network string + want localERPCRegistration + }{ + { + name: "ethereum mainnet", + networkType: "ethereum", + id: "mainnet", + network: "mainnet", + want: localERPCRegistration{ + ChainID: 1, + Alias: "mainnet", + Endpoint: "http://ethereum-execution.ethereum-mainnet.svc.cluster.local:8545", + }, + }, + { + name: "hl-node mainnet", + networkType: "hl-node", + id: "mainnet", + network: "mainnet", + want: localERPCRegistration{ + ChainID: 999, + Alias: "hyperevm", + Endpoint: "http://hl-node.hl-node-mainnet.svc.cluster.local:3001/evm", + }, + }, + { + name: "hl-node testnet", + networkType: "hl-node", + id: "testnet", + network: "testnet", + want: localERPCRegistration{ + ChainID: 998, + Alias: "hyperevm-testnet", + Endpoint: "http://hl-node.hl-node-testnet.svc.cluster.local:3001/evm", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveLocalERPCRegistration(tt.networkType, tt.id, struct { + Network string `yaml:"network"` + }{Network: tt.network}) + if err != nil { + t.Fatalf("resolve local erpc registration: %v", err) + } + if got != tt.want { + t.Fatalf("registration = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestResolveLocalERPCRegistrationRejectsUnknownHLNetwork(t *testing.T) { + _, err := resolveLocalERPCRegistration("hl-node", "dev", struct { + Network string `yaml:"network"` + }{Network: "devnet"}) + if err == nil { + t.Fatal("expected unknown hl-node network error") + } + if !strings.Contains(err.Error(), "expected mainnet or testnet") { + t.Fatalf("error = %q, want mainnet/testnet guidance", err) + } +} From f418de02a7decc8ce7021ce28dfb9f65619de36c Mon Sep 17 00:00:00 2001 From: bussyjd Date: Wed, 24 Jun 2026 16:12:14 +0400 Subject: [PATCH 2/3] fix(network): resolve hl-node eRPC chain values --- internal/network/erpc.go | 24 ++++++---- internal/network/erpc_test.go | 83 +++++++++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/internal/network/erpc.go b/internal/network/erpc.go index 5751d4b4..035fba5a 100644 --- a/internal/network/erpc.go +++ b/internal/network/erpc.go @@ -32,14 +32,17 @@ var networkChainIDs = map[string]int{ } type localERPCRegistration struct { - ChainID int - Alias string + ChainID int + Alias string Endpoint string } -func resolveLocalERPCRegistration(networkType, id string, values struct { +type localERPCValues struct { Network string `yaml:"network"` -}) (localERPCRegistration, error) { + Chain string `yaml:"chain"` +} + +func resolveLocalERPCRegistration(networkType, id string, values localERPCValues) (localERPCRegistration, error) { namespace := fmt.Sprintf("%s-%s", networkType, id) switch networkType { @@ -55,7 +58,12 @@ func resolveLocalERPCRegistration(networkType, id string, values struct { Endpoint: fmt.Sprintf("http://ethereum-execution.%s.svc.cluster.local:8545", namespace), }, nil case "hl-node": - switch strings.ToLower(strings.TrimSpace(values.Network)) { + chain := strings.TrimSpace(values.Chain) + if chain == "" { + chain = values.Network + } + + switch strings.ToLower(strings.TrimSpace(chain)) { case "mainnet": return localERPCRegistration{ ChainID: 999, @@ -69,7 +77,7 @@ func resolveLocalERPCRegistration(networkType, id string, values struct { Endpoint: fmt.Sprintf("http://hl-node.%s.svc.cluster.local:3001/evm", namespace), }, nil default: - return localERPCRegistration{}, fmt.Errorf("unknown hl-node network %q — expected mainnet or testnet", values.Network) + return localERPCRegistration{}, fmt.Errorf("unknown hl-node chain %q — expected mainnet or testnet", chain) } default: return localERPCRegistration{}, errNoERPCRegistration @@ -90,9 +98,7 @@ func RegisterERPCUpstream(cfg *config.Config, networkType, id string) error { return fmt.Errorf("could not read values.yaml: %w", err) } - var values struct { - Network string `yaml:"network"` - } + var values localERPCValues if err := yaml.Unmarshal(valuesContent, &values); err != nil { return fmt.Errorf("could not parse values.yaml: %w", err) } diff --git a/internal/network/erpc_test.go b/internal/network/erpc_test.go index 4abca00a..6301f1a7 100644 --- a/internal/network/erpc_test.go +++ b/internal/network/erpc_test.go @@ -1,9 +1,14 @@ package network import ( + "bytes" + "os" + "path/filepath" "strings" "testing" + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/ui" "gopkg.in/yaml.v3" ) @@ -396,6 +401,7 @@ func TestResolveLocalERPCRegistration(t *testing.T) { networkType string id string network string + chain string want localERPCRegistration }{ { @@ -413,7 +419,7 @@ func TestResolveLocalERPCRegistration(t *testing.T) { name: "hl-node mainnet", networkType: "hl-node", id: "mainnet", - network: "mainnet", + chain: "Mainnet", want: localERPCRegistration{ ChainID: 999, Alias: "hyperevm", @@ -424,7 +430,7 @@ func TestResolveLocalERPCRegistration(t *testing.T) { name: "hl-node testnet", networkType: "hl-node", id: "testnet", - network: "testnet", + chain: "Testnet", want: localERPCRegistration{ ChainID: 998, Alias: "hyperevm-testnet", @@ -435,9 +441,10 @@ func TestResolveLocalERPCRegistration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := resolveLocalERPCRegistration(tt.networkType, tt.id, struct { - Network string `yaml:"network"` - }{Network: tt.network}) + got, err := resolveLocalERPCRegistration(tt.networkType, tt.id, localERPCValues{ + Network: tt.network, + Chain: tt.chain, + }) if err != nil { t.Fatalf("resolve local erpc registration: %v", err) } @@ -448,10 +455,70 @@ func TestResolveLocalERPCRegistration(t *testing.T) { } } +func TestResolveLocalERPCRegistrationReadsHLNodeChainValue(t *testing.T) { + var values localERPCValues + if err := yaml.Unmarshal([]byte("chain: Mainnet\n"), &values); err != nil { + t.Fatalf("parse values: %v", err) + } + + got, err := resolveLocalERPCRegistration("hl-node", "review", values) + if err != nil { + t.Fatalf("resolve local erpc registration: %v", err) + } + if got.Alias != "hyperevm" { + t.Fatalf("alias = %q, want hyperevm", got.Alias) + } + if got.Endpoint != "http://hl-node.hl-node-review.svc.cluster.local:3001/evm" { + t.Fatalf("endpoint = %q", got.Endpoint) + } +} + +func TestInstallHLNodeValuesResolveERPCRegistration(t *testing.T) { + tmp := t.TempDir() + cfg := &config.Config{ + ConfigDir: filepath.Join(tmp, "config"), + DataDir: filepath.Join(tmp, "data"), + BinDir: filepath.Join(tmp, "bin"), + StateDir: filepath.Join(tmp, "state"), + } + + var stdout, stderr bytes.Buffer + u := ui.NewForTest(&stdout, &stderr) + if err := Install(cfg, u, "hl-node", map[string]string{"id": "review"}, false); err != nil { + t.Fatalf("Install() error = %v\nstderr:\n%s", err, stderr.String()) + } + + valuesBytes, err := os.ReadFile(filepath.Join(cfg.ConfigDir, "networks", "hl-node", "review", "values.yaml")) + if err != nil { + t.Fatal(err) + } + + var values localERPCValues + if err := yaml.Unmarshal(valuesBytes, &values); err != nil { + t.Fatalf("parse values: %v", err) + } + if values.Network != "" { + t.Fatalf("hl-node values should not rely on network field, got %q", values.Network) + } + if values.Chain != "Mainnet" { + t.Fatalf("chain = %q, want Mainnet", values.Chain) + } + + got, err := resolveLocalERPCRegistration("hl-node", "review", values) + if err != nil { + t.Fatalf("resolve local erpc registration: %v", err) + } + if got != (localERPCRegistration{ + ChainID: 999, + Alias: "hyperevm", + Endpoint: "http://hl-node.hl-node-review.svc.cluster.local:3001/evm", + }) { + t.Fatalf("registration = %+v", got) + } +} + func TestResolveLocalERPCRegistrationRejectsUnknownHLNetwork(t *testing.T) { - _, err := resolveLocalERPCRegistration("hl-node", "dev", struct { - Network string `yaml:"network"` - }{Network: "devnet"}) + _, err := resolveLocalERPCRegistration("hl-node", "dev", localERPCValues{Chain: "devnet"}) if err == nil { t.Fatal("expected unknown hl-node network error") } From 6c7c664b8ae2557c20c685c12a3057a32f58add7 Mon Sep 17 00:00:00 2001 From: bussyjd <145845+bussyjd@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:22:21 +0400 Subject: [PATCH 3/3] chore(erpc): bump eRPC 0.0.64 -> 0.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.1.x brings the unified selection-policy engine, in-house failsafe with per-attempt observability, and dRPC REST-based network discovery. The legacy `selectionPolicy.evalFunction` blocks remain valid via the 0.1.x compat shim (synthesized to `selectionPolicy.eval`, emits a deprecation warning) — a follow-up can migrate them to the new `selectionPolicy.eval` form. Claude-Session: https://claude.ai/code/session_01YUTW7NfxjUoVtyKgR6nPQC --- internal/embed/infrastructure/values/erpc.yaml.gotmpl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/embed/infrastructure/values/erpc.yaml.gotmpl b/internal/embed/infrastructure/values/erpc.yaml.gotmpl index a967a554..9ec5ad17 100644 --- a/internal/embed/infrastructure/values/erpc.yaml.gotmpl +++ b/internal/embed/infrastructure/values/erpc.yaml.gotmpl @@ -16,7 +16,10 @@ replicas: 1 image: repository: ghcr.io/erpc/erpc - tag: 0.0.64 + # 0.1.x: unified selection-policy engine + in-house failsafe + dRPC REST discovery. + # NOTE: the legacy `selectionPolicy.evalFunction` blocks below are deprecated in 0.1.x + # (compat-shimmed to `selectionPolicy.eval`, emit a deprecation warning) — migrate in a follow-up. + tag: 0.1.1 pullPolicy: IfNotPresent # Config file