diff --git a/cmd/obol/network.go b/cmd/obol/network.go index 658d92ce..e68a31c3 100644 --- a/cmd/obol/network.go +++ b/cmd/obol/network.go @@ -8,7 +8,6 @@ import ( "slices" "sort" "strings" - "time" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/embed" @@ -494,18 +493,7 @@ func networkStatusCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "status", Usage: "Show eRPC gateway health and upstream counts", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "no-probe", - Usage: "Skip the eth_chainId reachability probe against each upstream.", - }, - &cli.DurationFlag{ - Name: "probe-timeout", - Value: 2 * time.Second, - Usage: "Per-upstream probe timeout. Probes run in parallel.", - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { + Action: func(_ context.Context, cmd *cli.Command) error { u := getUI(cmd) podStatus, upstreamCounts, err := network.GetERPCStatus(cfg) @@ -540,65 +528,11 @@ func networkStatusCommand(cfg *config.Config) *cli.Command { } } - if cmd.Bool("no-probe") { - return nil - } - - renderUpstreamProbes(ctx, cfg, u, cmd.Duration("probe-timeout")) return nil }, } } -// renderUpstreamProbes runs eth_chainId against every upstream and prints a -// warning block when any upstream is unreachable or returns a chain id that -// disagrees with the chain it's pinned to in the eRPC config. The most common -// trigger is a custom pin (`obol network add --endpoint `) -// left over from a flow run whose Anvil has since been killed or recreated for -// a different chain. -func renderUpstreamProbes(ctx context.Context, cfg *config.Config, u uiPrinter, timeout time.Duration) { - results, err := network.ProbeAllUpstreams(ctx, cfg, timeout) - if err != nil { - u.Warnf("upstream probe skipped: %v", err) - return - } - - var dead, mismatched []network.UpstreamProbeResult - for _, r := range results { - switch { - case !r.Reachable: - dead = append(dead, r) - case r.Mismatch(): - mismatched = append(mismatched, r) - } - } - - if len(dead) == 0 && len(mismatched) == 0 { - u.Printf("\nReachability: all %d upstream(s) responded with the expected chain id.\n", len(results)) - return - } - - u.Printf("\nReachability warnings:\n") - for _, r := range dead { - u.Warnf(" upstream %q (chain %d) at %s is unreachable: %s", - r.ID, r.DeclaredChain, r.Endpoint, r.Err) - } - for _, r := range mismatched { - u.Warnf(" upstream %q is pinned to chain %d but answered eth_chainId with %d (%s)", - r.ID, r.DeclaredChain, r.ObservedChain, r.Endpoint) - } - u.Printf("\nIf any of these are stale custom pins from a previous test run, drop them with:\n") - u.Printf(" obol network remove # e.g. obol network remove base-sepolia\n") -} - -// uiPrinter is the subset of *ui.UI used by renderUpstreamProbes. Defined -// locally so tests can pass a buffer-backed printer without dragging the full -// ui.UI type into the test scope. -type uiPrinter interface { - Printf(format string, args ...any) - Warnf(format string, args ...any) -} - // chainIDToName returns a human-readable name for a chain ID. func chainIDToName(chainID int) string { names := map[int]string{ diff --git a/internal/network/probe.go b/internal/network/probe.go deleted file mode 100644 index ec8ec43a..00000000 --- a/internal/network/probe.go +++ /dev/null @@ -1,145 +0,0 @@ -package network - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "github.com/ObolNetwork/obol-stack/internal/config" -) - -// UpstreamProbeResult records the outcome of a quick eth_chainId probe against -// an eRPC upstream. The check exists so `obol network status` can warn about -// custom pins (most often `obol network add --endpoint ` -// left over from an integration flow) that no longer reach a live node. -type UpstreamProbeResult struct { - ID string - Endpoint string - DeclaredChain int - ObservedChain int - Reachable bool - Err string -} - -// Mismatch returns true when the upstream answered eth_chainId with a chain id -// that does not match the chain id declared in the eRPC config. A mismatch is -// almost always a stale custom pin re-pointing at a different fork. -func (r UpstreamProbeResult) Mismatch() bool { - return r.Reachable && r.ObservedChain != 0 && r.ObservedChain != r.DeclaredChain -} - -// ProbeUpstream sends a single eth_chainId JSON-RPC call to the given upstream -// with a bounded timeout. It never panics and always returns a result; the -// caller decides how to render warnings. -func ProbeUpstream(ctx context.Context, info RPCUpstreamInfo, timeout time.Duration) UpstreamProbeResult { - res := UpstreamProbeResult{ - ID: info.ID, - Endpoint: info.Endpoint, - DeclaredChain: info.ChainID, - } - - if strings.TrimSpace(info.Endpoint) == "" { - res.Err = "empty endpoint" - return res - } - - body := []byte(`{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`) - - probeCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, info.Endpoint, bytes.NewReader(body)) - if err != nil { - res.Err = err.Error() - return res - } - req.Header.Set("content-type", "application/json") - - client := &http.Client{Timeout: timeout} - resp, err := client.Do(req) - if err != nil { - res.Err = err.Error() - return res - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - res.Err = fmt.Sprintf("http %d", resp.StatusCode) - return res - } - - var payload struct { - Result string `json:"result"` - Error *struct { - Message string `json:"message"` - } `json:"error"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - res.Err = "decode: " + err.Error() - return res - } - if payload.Error != nil { - res.Err = payload.Error.Message - return res - } - - chainID, err := parseHexUint(payload.Result) - if err != nil { - res.Err = "parse chainId: " + err.Error() - return res - } - - res.Reachable = true - res.ObservedChain = chainID - return res -} - -// ProbeAllUpstreams probes every upstream listed in the eRPC config in parallel -// with a per-probe timeout. The returned slice is in the same order as -// ListRPCNetworks output (chain-grouped), suitable for direct rendering. -func ProbeAllUpstreams(ctx context.Context, cfg *config.Config, timeout time.Duration) ([]UpstreamProbeResult, error) { - networks, err := ListRPCNetworks(cfg) - if err != nil { - return nil, err - } - - var flat []RPCUpstreamInfo - for _, n := range networks { - flat = append(flat, n.Upstreams...) - } - - results := make([]UpstreamProbeResult, len(flat)) - - var wg sync.WaitGroup - for i, info := range flat { - wg.Add(1) - go func(i int, info RPCUpstreamInfo) { - defer wg.Done() - results[i] = ProbeUpstream(ctx, info, timeout) - }(i, info) - } - wg.Wait() - - return results, nil -} - -// parseHexUint accepts a hex string with or without a 0x prefix and returns the -// parsed integer. We keep it small and unsigned because chain ids fit in int. -func parseHexUint(s string) (int, error) { - s = strings.TrimSpace(s) - if s == "" { - return 0, fmt.Errorf("empty") - } - s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X") - v, err := strconv.ParseInt(s, 16, 64) - if err != nil { - return 0, err - } - return int(v), nil -} diff --git a/internal/network/probe_test.go b/internal/network/probe_test.go deleted file mode 100644 index 01c677a9..00000000 --- a/internal/network/probe_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package network - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestProbeUpstream_OK(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - if !strings.Contains(string(body), "eth_chainId") { - t.Errorf("expected eth_chainId in body, got %s", body) - } - w.Header().Set("content-type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x14a34"}`)) // 84_532 = base sepolia - })) - defer srv.Close() - - res := ProbeUpstream(context.Background(), RPCUpstreamInfo{ - ID: "u1", Endpoint: srv.URL, ChainID: 84532, - }, 2*time.Second) - - if !res.Reachable { - t.Fatalf("expected reachable, got err=%q", res.Err) - } - if res.ObservedChain != 84532 { - t.Fatalf("expected observed 84532, got %d", res.ObservedChain) - } - if res.Mismatch() { - t.Fatalf("expected no mismatch") - } -} - -func TestProbeUpstream_ChainMismatch_StalePin(t *testing.T) { - // Simulates the report's exact failure mode: a custom upstream the operator - // added pointing at a local Anvil fork that has since been recreated for a - // different chain (or is now the host's other anvil from a parallel test). - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("content-type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x539"}`)) // 1337 - })) - defer srv.Close() - - res := ProbeUpstream(context.Background(), RPCUpstreamInfo{ - ID: "custom-84532-0", Endpoint: srv.URL, ChainID: 84532, - }, 2*time.Second) - - if !res.Reachable { - t.Fatalf("expected reachable, got err=%q", res.Err) - } - if !res.Mismatch() { - t.Fatalf("expected mismatch (declared 84532 vs observed %d)", res.ObservedChain) - } -} - -func TestProbeUpstream_DeadEndpoint(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "no", http.StatusServiceUnavailable) - })) - srv.Close() // close immediately so the URL is unreachable - - res := ProbeUpstream(context.Background(), RPCUpstreamInfo{ - ID: "dead", Endpoint: srv.URL, ChainID: 84532, - }, 250*time.Millisecond) - - if res.Reachable { - t.Fatalf("expected unreachable") - } - if res.Err == "" { - t.Fatalf("expected error message") - } -} - -func TestProbeUpstream_RPCError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("content-type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}`)) - })) - defer srv.Close() - - res := ProbeUpstream(context.Background(), RPCUpstreamInfo{ - ID: "broken", Endpoint: srv.URL, ChainID: 84532, - }, 2*time.Second) - - if res.Reachable { - t.Fatalf("expected unreachable when JSON-RPC reports error") - } - if !strings.Contains(res.Err, "method not found") { - t.Fatalf("expected error to surface RPC error message, got %q", res.Err) - } -} - -func TestProbeUpstream_EmptyEndpoint(t *testing.T) { - res := ProbeUpstream(context.Background(), RPCUpstreamInfo{ID: "x", Endpoint: "", ChainID: 1}, time.Second) - if res.Reachable { - t.Fatalf("empty endpoint must not be marked reachable") - } - if res.Err == "" { - t.Fatalf("expected error explaining empty endpoint") - } -} - -func TestParseHexUint(t *testing.T) { - cases := map[string]int{ - "0x1": 1, - "0x14a34": 84532, - "0X10": 16, - "539": 1337, - } - for input, want := range cases { - got, err := parseHexUint(input) - if err != nil { - t.Errorf("parseHexUint(%q) errored: %v", input, err) - continue - } - if got != want { - t.Errorf("parseHexUint(%q) = %d, want %d", input, got, want) - } - } - if _, err := parseHexUint(""); err == nil { - t.Errorf("expected error for empty string") - } -}