mirror of
https://github.com/luxfi/explorer.git
synced 2026-09-24 00:25:12 +00:00
`ChainConfig.Enabled` existed on the struct, was written in every chains.yaml
we ship, and was consulted by nothing. Only `Graph.Enabled` and the subgraph
flag were ever read (supervisor.go). So `enabled: false` registered the chain,
spawned its indexer and polled its RPC exactly like `enabled: true` — a switch
that looked like a switch and was wired to nothing.
Found by using it. lux-mainnet's config lists spc, which is a sovereign L1 and
has never been a chain on that network. Setting `enabled: false` and rolling
the config changed nothing: the pod still logged
[registry] added chain spc (…/v1/bc/spc/rpc) source=config
[spc/amm] [indexer] starting
[evm] Failed to get latest block: http status 404
at 1,800 errors an hour, against a chain that was already switched off.
Honoured in ChainRegistry.Add, which is the single door — config, env, mDNS
and the admin API all arrive there — so this is the whole of what the field
means, in one place. A disabled chain is not registered, so it does not index,
mounts no routes, and does not reach the SPA's chain list (which reads
registry.List()).
Enabled is now *bool because absent must not mean off. Every config written
while the field was inert says `enabled: true` out of convention; a plain bool
would read a future config that simply omits the key as false and make that
chain vanish with no error and no log. Nil is no opinion; only an explicit
`enabled: false` disables.
Tests: registration gated in all three states (true / false / absent), a
disabled chain proven not to reach the supervisor or the chain list, and
LoadConfig proven to decode into the pointer — a wrong yaml tag would leave it
nil and silently re-enable everything it disables. Verified by defeating the
check: the false and supervisor cases fail, the absent case still passes.
Also: the Dockerfile now runs vet + tests in the builder stage. This repo has
exactly one workflow, release.yml, which builds on a v* tag; nothing ran the
test files at all. Putting them where cgo and sqlite-dev already exist costs
no new job, no new runner dependency, and means a release that fails them
never becomes an image.
Co-authored-by: Hanzo Dev <[email protected]>
291 lines
7.9 KiB
Go
291 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ChainsFile is the legacy `chains: [...]` YAML shape used by tests and the
|
|
// minimal config. The full Config struct in config.go is the canonical schema.
|
|
type ChainsFile struct {
|
|
Chains []ChainConfig `yaml:"chains"`
|
|
}
|
|
|
|
// ChainEntry holds a chain config plus its runtime state.
|
|
type ChainEntry struct {
|
|
Config ChainConfig
|
|
}
|
|
|
|
// ChainRegistry is a thread-safe, runtime-mutable chain store.
|
|
type ChainRegistry struct {
|
|
mu sync.RWMutex
|
|
chains map[string]*ChainEntry
|
|
hub *RealtimeHub
|
|
sup *ChainSupervisor // nil = registry-only mode (tests)
|
|
}
|
|
|
|
// NewChainRegistry creates an empty registry.
|
|
func NewChainRegistry() *ChainRegistry {
|
|
return &ChainRegistry{
|
|
chains: make(map[string]*ChainEntry),
|
|
hub: NewRealtimeHub(),
|
|
}
|
|
}
|
|
|
|
// AttachSupervisor wires a ChainSupervisor so admin/config/mdns adds also
|
|
// spawn per-chain indexer + graph goroutines, and removes cancel them.
|
|
func (r *ChainRegistry) AttachSupervisor(s *ChainSupervisor) { r.sup = s }
|
|
|
|
// Add registers a chain. mDNS-sourced chains never override higher-priority
|
|
// (config/env/admin) entries; admin/config/env duplicates return an error.
|
|
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"
|
|
}
|
|
// A disabled chain is not registered at all. Every path in — config, env,
|
|
// mDNS, admin — lands here, so this is the whole of what `enabled` means.
|
|
//
|
|
// It used to mean nothing. The field was declared on ChainConfig and read
|
|
// nowhere, so `enabled: false` registered the chain, spawned its indexer
|
|
// and polled its RPC exactly like `enabled: true`. lux-mainnet carried an
|
|
// spc entry pointing at a chain that does not exist on that network; the
|
|
// indexer polled it forever and logged 1,800 errors an hour against a
|
|
// switch that was already off.
|
|
if !cfg.On() {
|
|
log.Printf("[registry] chain %s disabled, not registered", cfg.Slug)
|
|
return nil
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if existing, ok := r.chains[cfg.Slug]; ok {
|
|
if cfg.Source == "mdns" && existing.Config.Source != "mdns" {
|
|
r.mu.Unlock()
|
|
return fmt.Errorf("chain %q already configured via %s", cfg.Slug, existing.Config.Source)
|
|
}
|
|
if cfg.Source != "mdns" {
|
|
r.mu.Unlock()
|
|
return fmt.Errorf("chain %q already exists", cfg.Slug)
|
|
}
|
|
existing.Config.RPC = cfg.RPC
|
|
r.mu.Unlock()
|
|
return nil
|
|
}
|
|
r.chains[cfg.Slug] = &ChainEntry{Config: cfg}
|
|
r.mu.Unlock()
|
|
log.Printf("[registry] added chain %s (%s) source=%s", cfg.Slug, cfg.RPC, cfg.Source)
|
|
|
|
if r.sup != nil {
|
|
r.sup.start(cfg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Remove deletes a chain by slug.
|
|
func (r *ChainRegistry) Remove(slug string) bool {
|
|
r.mu.Lock()
|
|
if _, ok := r.chains[slug]; !ok {
|
|
r.mu.Unlock()
|
|
return false
|
|
}
|
|
delete(r.chains, slug)
|
|
r.mu.Unlock()
|
|
log.Printf("[registry] removed chain %s", slug)
|
|
|
|
if r.sup != nil {
|
|
r.sup.stop(slug)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Update modifies an existing chain's config.
|
|
func (r *ChainRegistry) Update(slug string, cfg ChainConfig) error {
|
|
r.mu.Lock()
|
|
entry, ok := r.chains[slug]
|
|
if !ok {
|
|
r.mu.Unlock()
|
|
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"
|
|
updated := entry.Config
|
|
r.mu.Unlock()
|
|
|
|
log.Printf("[registry] updated chain %s", slug)
|
|
|
|
if r.sup != nil {
|
|
r.sup.stop(slug)
|
|
r.sup.start(updated)
|
|
}
|
|
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 using the legacy `chains: [...]`
|
|
// shape. Full Config (with brand, networks, etc.) loads via LoadConfig.
|
|
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)
|
|
c.WS = os.Expand(c.WS, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 a 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})
|
|
}
|