mirror of
https://github.com/luxfi/explorer.git
synced 2026-09-24 00:25:12 +00:00
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:
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user