feat: runtime chain registry, mDNS discovery, /v1/explorer/realtime

Runtime-mutable chain registry with admin CRUD API, mDNS auto-discovery
of local luxd nodes, and multi-chain WebSocket realtime subscriptions.

Config sources (priority order):
  1. Admin API (POST/PUT/DELETE /v1/explorer/admin/chains)
  2. --config chains.yaml
  3. CHAINS env var
  4. mDNS _luxd._tcp discovery (lowest, never overrides manual)

21 tests, all passing.
This commit is contained in:
2026-04-13 01:36:31 -07:00
parent ed8fb4b646
commit bf584f6b48
8 changed files with 1516 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
module github.com/luxfi/explorer-unified
go 1.26.1
require (
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/luxfi/mdns v0.1.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/miekg/dns v1.1.62 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/tools v0.22.0 // indirect
)
+41
View File
@@ -0,0 +1,41 @@
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/luxfi/mdns v0.1.0 h1:VB3mQcETc9j5SY1S6lAgFtuGr/rjWuDgPYnxS+OKWMQ=
github.com/luxfi/mdns v0.1.0/go.mod h1:/3dheKVjUk2yiS/ocH1IDzeLXOIe+kpVsErIGDOZdiQ=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+156
View File
@@ -0,0 +1,156 @@
// Package main is the unified explorer entry point.
//
// Runtime-mutable chain registry with mDNS discovery and WebSocket realtime.
//
// Build: GOWORK=off go build -o explorer .
// Run: explorer --config chains.yaml
// explorer --mdns=false --port 8090
//
// Env:
// PORT — HTTP port (default 8090)
// EXPLORER_CONFIG — path to chains.yaml
// EXPLORER_MDNS — enable mDNS discovery (default true)
// CHAINS — inline chain list: "cchain:http://...,zoo:http://..."
//
// Admin API:
// GET /v1/explorer/admin/chains — list chains
// POST /v1/explorer/admin/chains — add chain
// PUT /v1/explorer/admin/chains/{slug} — update chain
// DELETE /v1/explorer/admin/chains/{slug} — remove chain
//
// Realtime:
// WS /v1/explorer/realtime — subscribe to blocks/transactions per chain
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
port := flag.Int("port", envInt("PORT", 8090), "HTTP port")
configFile := flag.String("config", env("EXPLORER_CONFIG", ""), "chains.yaml path")
enableMDNS := flag.Bool("mdns", envBool("EXPLORER_MDNS", true), "auto-discover local chains via mDNS")
flag.Parse()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("[explorer] shutdown signal received")
cancel()
}()
// Chain registry (runtime mutable)
registry := NewChainRegistry()
// Load from config file
if *configFile != "" {
if err := registry.LoadFromFile(*configFile); err != nil {
log.Fatalf("[explorer] config: %v", err)
}
}
// Load from env
if chains := env("CHAINS", ""); chains != "" {
registry.LoadFromEnv(chains)
}
// Start mDNS discovery
if *enableMDNS {
go registry.StartMDNSDiscovery()
}
// Start realtime hub
go registry.hub.Run(ctx)
// HTTP mux
mux := http.NewServeMux()
// Health
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"chains": registry.Count(),
})
})
// Admin API
mux.HandleFunc("GET /v1/explorer/admin/chains", registry.HandleList)
mux.HandleFunc("POST /v1/explorer/admin/chains", registry.HandleAdd)
mux.HandleFunc("PUT /v1/explorer/admin/chains/{slug}", registry.HandleUpdate)
mux.HandleFunc("DELETE /v1/explorer/admin/chains/{slug}", registry.HandleRemove)
// Realtime WebSocket
mux.HandleFunc("/v1/explorer/realtime", registry.hub.HandleRealtime)
// Realtime stats
mux.HandleFunc("GET /v1/explorer/realtime/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(registry.hub.Stats())
})
log.Printf("[explorer] listening :%d (%d chains, mdns=%v)", *port, registry.Count(), *enableMDNS)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", *port),
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 16,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[explorer] server: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
log.Println("[explorer] stopped")
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
var n int
fmt.Sscanf(v, "%d", &n)
return n
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := os.Getenv(key)
switch v {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
default:
return fallback
}
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/luxfi/mdns"
)
// StartMDNSDiscovery browses for _luxd._tcp services and auto-registers
// discovered chains. Blocks until the registry's hub context is cancelled.
func (r *ChainRegistry) StartMDNSDiscovery() {
disc := mdns.New("_luxd._tcp", "explorer", 0,
mdns.WithBrowseInterval(10*time.Second),
mdns.WithStaleTimeout(60*time.Second),
)
disc.OnPeer(func(peer *mdns.Peer, joined bool) {
if !joined {
return
}
log.Printf("[mdns] discovered luxd at %s:%d", peer.Addr, peer.Port)
r.probeNode(peer.Addr, peer.Port)
})
if err := disc.Start(); err != nil {
log.Printf("[mdns] failed to start: %v", err)
return
}
defer disc.Stop()
// Block forever - caller runs this in a goroutine
select {}
}
// probeNode queries a luxd node's info and blockchain endpoints,
// then registers any discovered chains.
func (r *ChainRegistry) probeNode(host string, port int) {
base := fmt.Sprintf("http://%s:%d", host, port)
// Query node info
info, err := rpcCall(base+"/ext/info", "info.getNodeID", nil)
if err != nil {
log.Printf("[mdns] probe %s failed: %v", base, err)
return
}
nodeID, _ := info["nodeID"].(string)
log.Printf("[mdns] node %s at %s", nodeID, base)
// Query blockchains
chains, err := rpcCall(base+"/ext/bc", "platform.getBlockchains", nil)
if err != nil {
// Fallback: try standard chains
r.registerStandardChains(base)
return
}
blockchains, ok := chains["blockchains"].([]any)
if !ok {
r.registerStandardChains(base)
return
}
for _, bc := range blockchains {
bcMap, ok := bc.(map[string]any)
if !ok {
continue
}
name, _ := bcMap["name"].(string)
id, _ := bcMap["id"].(string)
vmID, _ := bcMap["vmID"].(string)
if name == "" || id == "" {
continue
}
slug := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
chainType := "evm"
if strings.Contains(vmID, "avm") {
chainType = "dag"
} else if strings.Contains(vmID, "platform") {
chainType = "pchain"
}
r.Add(ChainConfig{
Slug: slug,
Name: name,
RPC: fmt.Sprintf("%s/ext/bc/%s/rpc", base, id),
Type: chainType,
Source: "mdns",
})
}
}
// registerStandardChains adds C/P/X chains from a standard luxd node.
func (r *ChainRegistry) registerStandardChains(base string) {
standards := []ChainConfig{
{Slug: "cchain", Name: "C-Chain", RPC: base + "/ext/bc/C/rpc", Type: "evm", Source: "mdns", Default: true},
{Slug: "pchain", Name: "P-Chain", RPC: base + "/ext/bc/P", Type: "pchain", Source: "mdns"},
{Slug: "xchain", Name: "X-Chain", RPC: base + "/ext/bc/X", Type: "dag", Source: "mdns"},
}
for _, c := range standards {
if err := r.Add(c); err != nil {
log.Printf("[mdns] skip %s: %v", c.Slug, err)
}
}
}
// rpcCall makes a JSON-RPC call and returns the result map.
func rpcCall(url, method string, params any) (map[string]any, error) {
body := map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": method,
}
if params != nil {
body["params"] = params
}
encoded, err := json.Marshal(body)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Post(url, "application/json", strings.NewReader(string(encoded)))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Result map[string]any `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if result.Error != nil {
return nil, fmt.Errorf("rpc error: %s", result.Error.Message)
}
return result.Result, nil
}
+245
View File
@@ -0,0 +1,245 @@
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
// RealtimeHub manages multi-chain WebSocket subscriptions.
type RealtimeHub struct {
mu sync.RWMutex
clients map[*wsClient]struct{}
}
type wsClient struct {
conn *websocket.Conn
subs map[string]bool // "blocks", "transactions", "blocks:cchain", etc.
mu sync.Mutex
closed bool
}
// RealtimeMessage is the wire format for realtime events.
type RealtimeMessage struct {
Type string `json:"type"`
Chain string `json:"chain,omitempty"`
Data any `json:"data,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// SubscribeRequest is what clients send to subscribe.
type SubscribeRequest struct {
Subscribe string `json:"subscribe"` // blocks, transactions, token_transfers
Chain string `json:"chain"` // optional chain slug; empty = default
}
// NewRealtimeHub creates a new hub.
func NewRealtimeHub() *RealtimeHub {
return &RealtimeHub{
clients: make(map[*wsClient]struct{}),
}
}
// Run starts the heartbeat loop. Cancel ctx to stop.
func (h *RealtimeHub) Run(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
h.closeAll()
return
case <-ticker.C:
h.heartbeat()
}
}
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // CORS handled at mux level
},
HandshakeTimeout: 10 * time.Second,
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// HandleRealtime is the HTTP handler for /v1/explorer/realtime.
func (h *RealtimeHub) HandleRealtime(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
client := &wsClient{
conn: conn,
subs: make(map[string]bool),
}
h.mu.Lock()
h.clients[client] = struct{}{}
h.mu.Unlock()
// Send connected message
client.send(RealtimeMessage{
Type: "connected",
Timestamp: time.Now().UnixMilli(),
})
// Read loop: handle subscribe/unsubscribe/ping
h.readPump(client)
// Cleanup
h.mu.Lock()
delete(h.clients, client)
h.mu.Unlock()
conn.Close()
}
func (h *RealtimeHub) readPump(c *wsClient) {
c.conn.SetReadLimit(4096)
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, msg, err := c.conn.ReadMessage()
if err != nil {
return
}
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
var req SubscribeRequest
if err := json.Unmarshal(msg, &req); err != nil {
continue
}
switch req.Subscribe {
case "blocks", "transactions", "token_transfers":
key := req.Subscribe
if req.Chain != "" {
key = req.Subscribe + ":" + req.Chain
}
c.mu.Lock()
c.subs[key] = true
c.mu.Unlock()
c.send(RealtimeMessage{
Type: "subscribed",
Chain: req.Chain,
Data: map[string]string{"channel": req.Subscribe},
Timestamp: time.Now().UnixMilli(),
})
case "ping":
c.send(RealtimeMessage{
Type: "pong",
Timestamp: time.Now().UnixMilli(),
})
}
}
}
// Broadcast sends an event to all clients subscribed to the given channel+chain.
func (h *RealtimeHub) Broadcast(eventType, chain string, data any) {
msg := RealtimeMessage{
Type: eventType,
Chain: chain,
Data: data,
Timestamp: time.Now().UnixMilli(),
}
encoded, err := json.Marshal(msg)
if err != nil {
return
}
// Match: exact channel, channel:chain, or unscoped channel
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
client.mu.Lock()
match := client.subs[eventType] || client.subs[eventType+":"+chain]
client.mu.Unlock()
if match {
client.sendRaw(encoded)
}
}
}
func (h *RealtimeHub) heartbeat() {
msg := RealtimeMessage{
Type: "heartbeat",
Timestamp: time.Now().UnixMilli(),
}
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
client.send(msg)
}
}
func (h *RealtimeHub) closeAll() {
h.mu.Lock()
defer h.mu.Unlock()
for client := range h.clients {
client.conn.Close()
}
h.clients = make(map[*wsClient]struct{})
}
// Stats returns connection statistics.
func (h *RealtimeHub) Stats() map[string]int {
h.mu.RLock()
defer h.mu.RUnlock()
subs := 0
for client := range h.clients {
client.mu.Lock()
subs += len(client.subs)
client.mu.Unlock()
}
return map[string]int{
"connections": len(h.clients),
"subscriptions": subs,
}
}
func (c *wsClient) send(msg RealtimeMessage) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteJSON(msg); err != nil {
c.closed = true
log.Printf("[realtime] write error: %v", err)
}
}
func (c *wsClient) sendRaw(data []byte) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
c.closed = true
}
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestRealtimeHubStats(t *testing.T) {
hub := NewRealtimeHub()
stats := hub.Stats()
if stats["connections"] != 0 {
t.Fatalf("expected 0 connections, got %d", stats["connections"])
}
}
func TestRealtimeWebSocket(t *testing.T) {
hub := NewRealtimeHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
ts := httptest.NewServer(mux)
defer ts.Close()
// Connect WebSocket
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Read connected message
var msg RealtimeMessage
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read connected: %v", err)
}
if msg.Type != "connected" {
t.Fatalf("expected connected, got %s", msg.Type)
}
// Subscribe to blocks
sub := SubscribeRequest{Subscribe: "blocks", Chain: "cchain"}
conn.WriteJSON(sub)
// Read subscribed confirmation
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read subscribed: %v", err)
}
if msg.Type != "subscribed" {
t.Fatalf("expected subscribed, got %s", msg.Type)
}
// Broadcast a block
hub.Broadcast("blocks", "cchain", map[string]any{"number": 1})
// Read the broadcast
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read broadcast: %v", err)
}
if msg.Type != "blocks" {
t.Fatalf("expected blocks event, got %s", msg.Type)
}
if msg.Chain != "cchain" {
t.Fatalf("expected chain=cchain, got %s", msg.Chain)
}
// Verify stats
stats := hub.Stats()
if stats["connections"] != 1 {
t.Fatalf("expected 1 connection, got %d", stats["connections"])
}
if stats["subscriptions"] != 1 {
t.Fatalf("expected 1 subscription, got %d", stats["subscriptions"])
}
}
func TestRealtimePing(t *testing.T) {
hub := NewRealtimeHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
ts := httptest.NewServer(mux)
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Consume connected
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.ReadJSON(&RealtimeMessage{})
// Send ping
conn.WriteJSON(SubscribeRequest{Subscribe: "ping"})
var msg RealtimeMessage
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read pong: %v", err)
}
if msg.Type != "pong" {
t.Fatalf("expected pong, got %s", msg.Type)
}
}
func TestRealtimeUnscopedSubscription(t *testing.T) {
hub := NewRealtimeHub()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/v1/explorer/realtime", hub.HandleRealtime)
ts := httptest.NewServer(mux)
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/v1/explorer/realtime"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// Consume connected
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.ReadJSON(&RealtimeMessage{})
// Subscribe to transactions without chain scope (gets all chains)
conn.WriteJSON(SubscribeRequest{Subscribe: "transactions"})
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.ReadJSON(&RealtimeMessage{}) // subscribed
// Broadcast from any chain should match
hub.Broadcast("transactions", "zoo", map[string]any{"hash": "0xabc"})
var msg RealtimeMessage
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
err = conn.ReadJSON(&msg)
if err != nil {
t.Fatalf("read broadcast: %v", err)
}
if msg.Type != "transactions" {
t.Fatalf("expected transactions, got %s", msg.Type)
}
if msg.Chain != "zoo" {
t.Fatalf("expected chain=zoo, got %s", msg.Chain)
}
}
func TestRealtimeStatsEndpoint(t *testing.T) {
hub := NewRealtimeHub()
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/explorer/realtime/stats", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hub.Stats())
})
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := ts.Client().Get(ts.URL + "/v1/explorer/realtime/stats")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var stats map[string]int
json.NewDecoder(resp.Body).Decode(&stats)
if stats["connections"] != 0 {
t.Fatalf("expected 0 connections, got %d", stats["connections"])
}
}
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// ChainConfig defines a single chain to index.
type ChainConfig struct {
Slug string `json:"slug" yaml:"slug"`
Name string `json:"name" yaml:"name"`
ChainID int64 `json:"chain_id" yaml:"chain_id"`
RPC string `json:"rpc" yaml:"rpc"`
Type string `json:"type" yaml:"type"` // evm, dag, pchain, xchain
Default bool `json:"default" yaml:"default"`
Source string `json:"source" yaml:"-"` // config, env, mdns, admin
}
// ChainsFile is the top-level YAML structure for chains.yaml.
type ChainsFile struct {
Chains []ChainConfig `yaml:"chains"`
}
// ChainEntry holds a chain config plus its runtime state.
type ChainEntry struct {
Config ChainConfig
}
// slugPattern restricts slugs to lowercase alphanumeric + hyphen.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// ChainRegistry is a thread-safe, runtime-mutable chain store.
type ChainRegistry struct {
mu sync.RWMutex
chains map[string]*ChainEntry // slug -> entry
hub *RealtimeHub
}
// NewChainRegistry creates an empty registry.
func NewChainRegistry() *ChainRegistry {
return &ChainRegistry{
chains: make(map[string]*ChainEntry),
hub: NewRealtimeHub(),
}
}
// Add registers a chain. Returns error if slug is invalid or already exists from a higher-priority source.
func (r *ChainRegistry) Add(cfg ChainConfig) error {
if !slugPattern.MatchString(cfg.Slug) {
return fmt.Errorf("invalid slug %q", cfg.Slug)
}
if cfg.RPC == "" {
return fmt.Errorf("rpc required for chain %q", cfg.Slug)
}
if cfg.Type == "" {
cfg.Type = "evm"
}
r.mu.Lock()
defer r.mu.Unlock()
if existing, ok := r.chains[cfg.Slug]; ok {
// mDNS never overrides manual config
if cfg.Source == "mdns" && existing.Config.Source != "mdns" {
return fmt.Errorf("chain %q already configured via %s", cfg.Slug, existing.Config.Source)
}
// All other sources: reject duplicate (use Update to modify)
if cfg.Source != "mdns" {
return fmt.Errorf("chain %q already exists", cfg.Slug)
}
// mDNS re-discovery: update last-seen silently
existing.Config.RPC = cfg.RPC
return nil
}
r.chains[cfg.Slug] = &ChainEntry{Config: cfg}
log.Printf("[registry] added chain %s (%s) source=%s", cfg.Slug, cfg.RPC, cfg.Source)
return nil
}
// Remove deletes a chain by slug.
func (r *ChainRegistry) Remove(slug string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.chains[slug]; !ok {
return false
}
delete(r.chains, slug)
log.Printf("[registry] removed chain %s", slug)
return true
}
// Update modifies an existing chain's config.
func (r *ChainRegistry) Update(slug string, cfg ChainConfig) error {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.chains[slug]
if !ok {
return fmt.Errorf("chain %q not found", slug)
}
if cfg.Name != "" {
entry.Config.Name = cfg.Name
}
if cfg.RPC != "" {
entry.Config.RPC = cfg.RPC
}
if cfg.ChainID != 0 {
entry.Config.ChainID = cfg.ChainID
}
if cfg.Type != "" {
entry.Config.Type = cfg.Type
}
entry.Config.Default = cfg.Default
entry.Config.Source = "admin"
log.Printf("[registry] updated chain %s", slug)
return nil
}
// Get returns a chain by slug.
func (r *ChainRegistry) Get(slug string) (ChainConfig, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
entry, ok := r.chains[slug]
if !ok {
return ChainConfig{}, false
}
return entry.Config, true
}
// List returns all chains.
func (r *ChainRegistry) List() []ChainConfig {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]ChainConfig, 0, len(r.chains))
for _, e := range r.chains {
out = append(out, e.Config)
}
return out
}
// Count returns the number of registered chains.
func (r *ChainRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.chains)
}
// LoadFromFile loads chains from a YAML file.
func (r *ChainRegistry) LoadFromFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
var f ChainsFile
if err := yaml.Unmarshal(data, &f); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
for _, c := range f.Chains {
c.Source = "config"
c.RPC = os.Expand(c.RPC, os.Getenv)
if err := r.Add(c); err != nil {
log.Printf("[registry] skip %s from config: %v", c.Slug, err)
}
}
log.Printf("[registry] loaded %d chains from %s", len(f.Chains), path)
return nil
}
// LoadFromEnv parses CHAINS env var: "slug:rpc,slug:rpc,..."
func (r *ChainRegistry) LoadFromEnv(val string) {
for _, pair := range strings.Split(val, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
parts := strings.SplitN(pair, ":", 2)
if len(parts) != 2 {
log.Printf("[registry] skip invalid CHAINS entry: %q", pair)
continue
}
cfg := ChainConfig{
Slug: strings.TrimSpace(parts[0]),
Name: strings.TrimSpace(parts[0]),
RPC: strings.TrimSpace(parts[1]),
Type: "evm",
Source: "env",
}
if err := r.Add(cfg); err != nil {
log.Printf("[registry] skip %s from env: %v", cfg.Slug, err)
}
}
}
// --- HTTP Handlers ---
// HandleList returns all chains as JSON.
func (r *ChainRegistry) HandleList(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"chains": r.List(),
"count": r.Count(),
})
}
// HandleAdd creates a new chain from JSON body.
func (r *ChainRegistry) HandleAdd(w http.ResponseWriter, req *http.Request) {
var cfg ChainConfig
if err := json.NewDecoder(http.MaxBytesReader(w, req.Body, 4096)).Decode(&cfg); err != nil {
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
return
}
cfg.Source = "admin"
if err := r.Add(cfg); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(cfg)
}
// HandleUpdate modifies an existing chain.
func (r *ChainRegistry) HandleUpdate(w http.ResponseWriter, req *http.Request) {
slug := req.PathValue("slug")
if slug == "" {
http.Error(w, `{"error":"slug required"}`, http.StatusBadRequest)
return
}
var cfg ChainConfig
if err := json.NewDecoder(http.MaxBytesReader(w, req.Body, 4096)).Decode(&cfg); err != nil {
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
return
}
if err := r.Update(slug, cfg); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
updated, _ := r.Get(slug)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(updated)
}
// HandleRemove deletes a chain by slug.
func (r *ChainRegistry) HandleRemove(w http.ResponseWriter, req *http.Request) {
slug := req.PathValue("slug")
if slug == "" {
http.Error(w, `{"error":"slug required"}`, http.StatusBadRequest)
return
}
if !r.Remove(slug) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "removed", "slug": slug})
}
+418
View File
@@ -0,0 +1,418 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestRegistryAddAndList(t *testing.T) {
r := NewChainRegistry()
err := r.Add(ChainConfig{
Slug: "cchain",
Name: "C-Chain",
RPC: "http://localhost:9650/ext/bc/C/rpc",
Type: "evm",
Source: "config",
})
if err != nil {
t.Fatal(err)
}
if r.Count() != 1 {
t.Fatalf("expected 1 chain, got %d", r.Count())
}
chains := r.List()
if chains[0].Slug != "cchain" {
t.Fatalf("expected slug cchain, got %s", chains[0].Slug)
}
}
func TestRegistryInvalidSlug(t *testing.T) {
r := NewChainRegistry()
err := r.Add(ChainConfig{Slug: "BAD SLUG", RPC: "http://x"})
if err == nil {
t.Fatal("expected error for invalid slug")
}
err = r.Add(ChainConfig{Slug: "../escape", RPC: "http://x"})
if err == nil {
t.Fatal("expected error for path traversal slug")
}
}
func TestRegistryMissingRPC(t *testing.T) {
r := NewChainRegistry()
err := r.Add(ChainConfig{Slug: "test", Name: "test"})
if err == nil {
t.Fatal("expected error for missing RPC")
}
}
func TestRegistryRemove(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "test", RPC: "http://x", Source: "admin"})
if !r.Remove("test") {
t.Fatal("expected Remove to return true")
}
if r.Count() != 0 {
t.Fatal("expected 0 chains after remove")
}
if r.Remove("nonexistent") {
t.Fatal("expected Remove to return false for nonexistent")
}
}
func TestRegistryUpdate(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "old", RPC: "http://old", Source: "config"})
err := r.Update("test", ChainConfig{Name: "new", RPC: "http://new"})
if err != nil {
t.Fatal(err)
}
cfg, ok := r.Get("test")
if !ok {
t.Fatal("chain not found after update")
}
if cfg.Name != "new" {
t.Fatalf("expected name=new, got %s", cfg.Name)
}
if cfg.RPC != "http://new" {
t.Fatalf("expected rpc=http://new, got %s", cfg.RPC)
}
}
func TestRegistryUpdateNotFound(t *testing.T) {
r := NewChainRegistry()
err := r.Update("nonexistent", ChainConfig{Name: "x"})
if err == nil {
t.Fatal("expected error for nonexistent chain")
}
}
func TestRegistryMDNSPriority(t *testing.T) {
r := NewChainRegistry()
// Config source takes precedence
r.Add(ChainConfig{Slug: "cchain", Name: "C-Chain", RPC: "http://config", Source: "config"})
// mDNS should not override
err := r.Add(ChainConfig{Slug: "cchain", Name: "C-Chain", RPC: "http://mdns", Source: "mdns"})
if err == nil {
t.Fatal("expected error when mdns tries to override config")
}
cfg, _ := r.Get("cchain")
if cfg.RPC != "http://config" {
t.Fatalf("expected config RPC to persist, got %s", cfg.RPC)
}
}
func TestRegistryDefaultType(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "test", RPC: "http://x", Source: "admin"})
cfg, _ := r.Get("test")
if cfg.Type != "evm" {
t.Fatalf("expected default type=evm, got %s", cfg.Type)
}
}
func TestRegistryLoadFromEnv(t *testing.T) {
r := NewChainRegistry()
r.LoadFromEnv("cchain:http://localhost:9650/ext/bc/C/rpc,zoo:http://localhost:9650/ext/bc/Zoo/rpc")
if r.Count() != 2 {
t.Fatalf("expected 2 chains, got %d", r.Count())
}
}
func TestRegistryLoadFromFile(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "chains.yaml")
os.WriteFile(f, []byte(`chains:
- slug: cchain
name: C-Chain
chain_id: 96369
rpc: http://localhost:9650/ext/bc/C/rpc
type: evm
default: true
- slug: zoo
name: Zoo
chain_id: 200200
rpc: http://localhost:9650/ext/bc/Zoo/rpc
type: evm
`), 0644)
r := NewChainRegistry()
err := r.LoadFromFile(f)
if err != nil {
t.Fatal(err)
}
if r.Count() != 2 {
t.Fatalf("expected 2 chains, got %d", r.Count())
}
cfg, ok := r.Get("cchain")
if !ok {
t.Fatal("cchain not found")
}
if cfg.ChainID != 96369 {
t.Fatalf("expected chain_id=96369, got %d", cfg.ChainID)
}
if cfg.Source != "config" {
t.Fatalf("expected source=config, got %s", cfg.Source)
}
}
func TestHandleList(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "Test", RPC: "http://x", Source: "admin"})
req := httptest.NewRequest("GET", "/v1/explorer/admin/chains", nil)
w := httptest.NewRecorder()
r.HandleList(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
var body map[string]any
json.NewDecoder(w.Body).Decode(&body)
if body["count"].(float64) != 1 {
t.Fatalf("expected count=1, got %v", body["count"])
}
}
func TestHandleAddAndRemove(t *testing.T) {
r := NewChainRegistry()
// Add
payload := `{"slug":"cchain","name":"C-Chain","rpc":"http://localhost:9650/ext/bc/C/rpc","type":"evm"}`
req := httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 201 {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if r.Count() != 1 {
t.Fatalf("expected 1 chain after add")
}
// Duplicate should conflict
req = httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString(payload))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 409 {
t.Fatalf("expected 409 for duplicate, got %d", w.Code)
}
// Remove
req = httptest.NewRequest("DELETE", "/v1/explorer/admin/chains/cchain", nil)
req.SetPathValue("slug", "cchain")
w = httptest.NewRecorder()
r.HandleRemove(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if r.Count() != 0 {
t.Fatal("expected 0 chains after remove")
}
}
func TestHandleUpdate(t *testing.T) {
r := NewChainRegistry()
r.Add(ChainConfig{Slug: "test", Name: "Old", RPC: "http://old", Source: "config"})
payload := `{"name":"New","rpc":"http://new"}`
req := httptest.NewRequest("PUT", "/v1/explorer/admin/chains/test", bytes.NewBufferString(payload))
req.SetPathValue("slug", "test")
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.HandleUpdate(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var cfg ChainConfig
json.NewDecoder(w.Body).Decode(&cfg)
if cfg.Name != "New" {
t.Fatalf("expected name=New, got %s", cfg.Name)
}
}
func TestHandleUpdateNotFound(t *testing.T) {
r := NewChainRegistry()
payload := `{"name":"x"}`
req := httptest.NewRequest("PUT", "/v1/explorer/admin/chains/nonexistent", bytes.NewBufferString(payload))
req.SetPathValue("slug", "nonexistent")
w := httptest.NewRecorder()
r.HandleUpdate(w, req)
if w.Code != 404 {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestHandleRemoveNotFound(t *testing.T) {
r := NewChainRegistry()
req := httptest.NewRequest("DELETE", "/v1/explorer/admin/chains/nonexistent", nil)
req.SetPathValue("slug", "nonexistent")
w := httptest.NewRecorder()
r.HandleRemove(w, req)
if w.Code != 404 {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestHandleAddInvalid(t *testing.T) {
r := NewChainRegistry()
// Bad JSON
req := httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString("not json"))
w := httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 400 {
t.Fatalf("expected 400 for bad json, got %d", w.Code)
}
// Missing slug
req = httptest.NewRequest("POST", "/v1/explorer/admin/chains", bytes.NewBufferString(`{"rpc":"http://x"}`))
w = httptest.NewRecorder()
r.HandleAdd(w, req)
if w.Code != 409 {
t.Fatalf("expected 409 for missing slug, got %d", w.Code)
}
}
func TestEnvHelpers(t *testing.T) {
if env("NONEXISTENT_KEY_12345", "fallback") != "fallback" {
t.Fatal("env fallback failed")
}
if envInt("NONEXISTENT_KEY_12345", 42) != 42 {
t.Fatal("envInt fallback failed")
}
if envBool("NONEXISTENT_KEY_12345", true) != true {
t.Fatal("envBool fallback failed")
}
}
// TestHTTPIntegration uses a real mux + httptest server to verify routing.
func TestHTTPIntegration(t *testing.T) {
registry := NewChainRegistry()
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/explorer/admin/chains", registry.HandleList)
mux.HandleFunc("POST /v1/explorer/admin/chains", registry.HandleAdd)
mux.HandleFunc("PUT /v1/explorer/admin/chains/{slug}", registry.HandleUpdate)
mux.HandleFunc("DELETE /v1/explorer/admin/chains/{slug}", registry.HandleRemove)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "chains": registry.Count()})
})
ts := httptest.NewServer(mux)
defer ts.Close()
client := ts.Client()
// Health
resp, err := client.Get(ts.URL + "/health")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("health: expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// List (empty)
resp, err = client.Get(ts.URL + "/v1/explorer/admin/chains")
if err != nil {
t.Fatal(err)
}
var listBody map[string]any
json.NewDecoder(resp.Body).Decode(&listBody)
resp.Body.Close()
if listBody["count"].(float64) != 0 {
t.Fatalf("expected 0 chains initially")
}
// Add
addPayload := `{"slug":"cchain","name":"C-Chain","chain_id":96369,"rpc":"http://localhost:9650/ext/bc/C/rpc","type":"evm","default":true}`
resp, err = client.Post(ts.URL+"/v1/explorer/admin/chains", "application/json", bytes.NewBufferString(addPayload))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 201 {
t.Fatalf("add: expected 201, got %d", resp.StatusCode)
}
resp.Body.Close()
// List (1 chain)
resp, err = client.Get(ts.URL + "/v1/explorer/admin/chains")
if err != nil {
t.Fatal(err)
}
json.NewDecoder(resp.Body).Decode(&listBody)
resp.Body.Close()
if listBody["count"].(float64) != 1 {
t.Fatalf("expected 1 chain after add")
}
// Update
updatePayload := `{"name":"Lux C-Chain","rpc":"http://new:9650/ext/bc/C/rpc"}`
updateReq, _ := http.NewRequest("PUT", ts.URL+"/v1/explorer/admin/chains/cchain", bytes.NewBufferString(updatePayload))
updateReq.Header.Set("Content-Type", "application/json")
resp, err = client.Do(updateReq)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("update: expected 200, got %d", resp.StatusCode)
}
var updated ChainConfig
json.NewDecoder(resp.Body).Decode(&updated)
resp.Body.Close()
if updated.Name != "Lux C-Chain" {
t.Fatalf("expected updated name, got %s", updated.Name)
}
// Delete
delReq, _ := http.NewRequest("DELETE", ts.URL+"/v1/explorer/admin/chains/cchain", nil)
resp, err = client.Do(delReq)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("delete: expected 200, got %d", resp.StatusCode)
}
resp.Body.Close()
// Verify empty
resp, _ = client.Get(ts.URL + "/v1/explorer/admin/chains")
json.NewDecoder(resp.Body).Decode(&listBody)
resp.Body.Close()
if listBody["count"].(float64) != 0 {
t.Fatalf("expected 0 chains after delete")
}
}