Files

193 lines
5.2 KiB
Go

// Package luxd asks one luxd node what it thinks is true.
//
// Every answer is optional. A node that will not talk is not an error to be
// propagated — it is a fact about the fleet, and it is the fact the caller most
// needs. So the zero value of every field means "did not say", and the caller
// decides what that implies.
package luxd
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
)
// Client speaks to one node's HTTP surface. /v1 only: /ext is the dead prefix.
type Client struct {
Base string
HTTP *http.Client
// CPath is the C-Chain JSON-RPC path. Empty means the canonical one.
CPath string
}
// cPath is the C-Chain JSON-RPC path this client will use.
func (c *Client) cPath() string {
if c.CPath == "" {
return "/v1/chain/C/rpc"
}
return c.CPath
}
// Block is the part of an eth_getBlockByNumber result fleet health reads.
type Block struct {
Number uint64
Hash string
Timestamp uint64
}
// Health is luxd's /v1/health answer, including the named checks. luxd returns
// HTTP 503 *with* a JSON body when a check fails, so the body is read off the
// error response too — a handler that only catches the transport error throws
// the diagnosis away and leaves every node reading a reasonless healthy=0.
type Health struct {
Healthy bool `json:"healthy"`
Checks map[string]struct {
Healthy *bool `json:"healthy"`
} `json:"checks"`
}
// FailedChecks is the sorted-by-caller list of check names currently failing —
// the reason behind healthy=0.
func (h *Health) FailedChecks() []string {
var out []string
for k, v := range h.Checks {
if v.Healthy != nil && !*v.Healthy {
out = append(out, k)
}
}
return out
}
func (c *Client) post(ctx context.Context, path string, body []byte) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+path, bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return b, resp.StatusCode, err
}
// rpc issues one JSON-RPC call and unwraps "result" into out.
func (c *Client) rpc(ctx context.Context, path, method string, params any, out any) error {
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
})
if err != nil {
return err
}
raw, _, err := c.post(ctx, path, body)
if err != nil {
return err
}
var env struct {
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return err
}
if len(env.Result) == 0 || string(env.Result) == "null" {
return errNoResult
}
return json.Unmarshal(env.Result, out)
}
type constErr string
func (e constErr) Error() string { return string(e) }
const errNoResult = constErr("luxd: empty result")
// hexInt accepts both the 0x form the EVM returns and a bare decimal.
func hexInt(s string) (uint64, error) {
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
return strconv.ParseUint(s[2:], 16, 64)
}
return strconv.ParseUint(s, 10, 64)
}
// CBlock reads a C-Chain block by tag ("latest", "finalized").
func (c *Client) CBlock(ctx context.Context, tag string) (*Block, error) {
var r struct {
Number string `json:"number"`
Hash string `json:"hash"`
Timestamp string `json:"timestamp"`
}
if err := c.rpc(ctx, c.cPath(), "eth_getBlockByNumber", []any{tag, false}, &r); err != nil {
return nil, err
}
n, err := hexInt(r.Number)
if err != nil {
return nil, err
}
ts, err := hexInt(r.Timestamp)
if err != nil {
return nil, err
}
return &Block{Number: n, Hash: r.Hash, Timestamp: ts}, nil
}
// BlockNumber is the accepted C-Chain head height in one round trip. The tip
// gate uses this rather than fetching a whole block: it runs every few seconds
// against every validator, and it only ever needed the number.
func (c *Client) BlockNumber(ctx context.Context) (uint64, error) {
var s string
if err := c.rpc(ctx, c.cPath(), "eth_blockNumber", []any{}, &s); err != nil {
return 0, err
}
return hexInt(s)
}
// Peers is the connected peer count.
func (c *Client) Peers(ctx context.Context) (int, error) {
var r struct {
NumPeers json.Number `json:"numPeers"`
}
if err := c.rpc(ctx, "/v1/info", "info.peers", map[string]any{}, &r); err != nil {
return 0, err
}
n, err := r.NumPeers.Int64()
return int(n), err
}
// Bootstrapped reports whether the named chain finished bootstrapping.
func (c *Client) Bootstrapped(ctx context.Context, chain string) (bool, error) {
var r struct {
IsBootstrapped bool `json:"isBootstrapped"`
}
err := c.rpc(ctx, "/v1/info", "info.isBootstrapped", map[string]any{"chain": chain}, &r)
return r.IsBootstrapped, err
}
// Health reads /v1/health, keeping the body on a 503 so the failing check
// names survive.
func (c *Client) Health(ctx context.Context) (*Health, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/v1/health", nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var h Health
if err := json.Unmarshal(b, &h); err != nil {
return nil, err
}
return &h, nil
}