package track1 import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) // RPCProbeResult is one JSON-RPC health check (URLs are redacted to origin only in JSON). type RPCProbeResult struct { Name string `json:"name"` ChainKey string `json:"chainKey,omitempty"` Endpoint string `json:"endpoint"` OK bool `json:"ok"` LatencyMs int64 `json:"latencyMs"` BlockNumber string `json:"blockNumber,omitempty"` BlockNumberDec string `json:"blockNumberDec,omitempty"` HeadAgeSeconds float64 `json:"headAgeSeconds,omitempty"` Error string `json:"error,omitempty"` } type jsonRPCReq struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params []interface{} `json:"params"` ID int `json:"id"` } type jsonRPCResp struct { Result json.RawMessage `json:"result"` Error *struct { Message string `json:"message"` } `json:"error"` } func redactRPCOrigin(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil || u.Host == "" { return "hidden" } if u.Scheme == "" { return u.Host } return u.Scheme + "://" + u.Host } func postJSONRPC(ctx context.Context, client *http.Client, rpcURL string, method string, params []interface{}) (json.RawMessage, int64, error) { if client == nil { client = http.DefaultClient } body, err := json.Marshal(jsonRPCReq{JSONRPC: "2.0", Method: method, Params: params, ID: 1}) if err != nil { return nil, 0, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) if err != nil { return nil, 0, err } req.Header.Set("Content-Type", "application/json") start := time.Now() resp, err := client.Do(req) latency := time.Since(start).Milliseconds() if err != nil { return nil, latency, err } defer resp.Body.Close() b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, latency, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, latency, fmt.Errorf("http %d", resp.StatusCode) } var out jsonRPCResp if err := json.Unmarshal(b, &out); err != nil { return nil, latency, err } if out.Error != nil && out.Error.Message != "" { return nil, latency, fmt.Errorf("rpc error: %s", out.Error.Message) } return out.Result, latency, nil } // ProbeEVMJSONRPC runs eth_blockNumber and eth_getBlockByNumber(latest) for head age. func ProbeEVMJSONRPC(ctx context.Context, name, chainKey, rpcURL string) RPCProbeResult { rpcURL = strings.TrimSpace(rpcURL) res := RPCProbeResult{ Name: name, ChainKey: chainKey, Endpoint: redactRPCOrigin(rpcURL), } if rpcURL == "" { res.Error = "empty rpc url" return res } client := &http.Client{Timeout: 6 * time.Second} numRaw, lat1, err := postJSONRPC(ctx, client, rpcURL, "eth_blockNumber", []interface{}{}) if err != nil { res.LatencyMs = lat1 res.Error = err.Error() return res } var numHex string if err := json.Unmarshal(numRaw, &numHex); err != nil { res.LatencyMs = lat1 res.Error = "blockNumber decode: " + err.Error() return res } res.BlockNumber = numHex if n, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(numHex), "0x"), 16, 64); err == nil { res.BlockNumberDec = strconv.FormatInt(n, 10) } blockRaw, lat2, err := postJSONRPC(ctx, client, rpcURL, "eth_getBlockByNumber", []interface{}{"latest", false}) res.LatencyMs = lat1 + lat2 if err != nil { res.OK = true res.Error = "head block timestamp unavailable: " + err.Error() return res } var block struct { Timestamp string `json:"timestamp"` } if err := json.Unmarshal(blockRaw, &block); err != nil || block.Timestamp == "" { res.OK = true if err != nil { res.Error = "block decode: " + err.Error() } return res } tsHex := strings.TrimSpace(block.Timestamp) ts, err := strconv.ParseInt(strings.TrimPrefix(tsHex, "0x"), 16, 64) if err != nil { res.OK = true res.Error = "timestamp parse: " + err.Error() return res } bt := time.Unix(ts, 0) res.HeadAgeSeconds = time.Since(bt).Seconds() res.OK = true return res } func readOptionalVerifyJSON() map[string]interface{} { path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_VERIFY_JSON")) if path == "" { return nil } b, err := os.ReadFile(path) if err != nil || len(b) == 0 { return map[string]interface{}{"error": "unreadable or empty", "path": path} } if len(b) > 512*1024 { return map[string]interface{}{"error": "file too large", "path": path} } var v map[string]interface{} if err := json.Unmarshal(b, &v); err != nil { return map[string]interface{}{"error": err.Error(), "path": path} } return v } // ParseExtraRPCProbes reads MISSION_CONTROL_EXTRA_RPCS lines "name|url" or "name|url|chainKey". func ParseExtraRPCProbes() [][3]string { raw := strings.TrimSpace(os.Getenv("MISSION_CONTROL_EXTRA_RPCS")) if raw == "" { return nil } var out [][3]string for _, line := range strings.Split(raw, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.Split(line, "|") if len(parts) < 2 { continue } name := strings.TrimSpace(parts[0]) u := strings.TrimSpace(parts[1]) ck := "" if len(parts) > 2 { ck = strings.TrimSpace(parts[2]) } if name != "" && u != "" { out = append(out, [3]string{name, u, ck}) } } return out }