honour chains[].enabled — it was declared and read nowhere

`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]>
This commit is contained in:
2026-08-07 12:06:16 -07:00
co-authored by Hanzo Dev
parent d1ddc08aae
commit 8558c7d562
4 changed files with 117 additions and 1 deletions
+7
View File
@@ -37,6 +37,13 @@ RUN --mount=type=secret,id=ghtok,required=false \
go mod download
COPY . .
# Tests run here, in the one place that already has cgo + sqlite-dev, so a
# release that fails them never becomes an image. Nothing else in this repo
# runs them: .hanzo/workflows/release.yml builds on a v* tag and there is no
# other workflow, so a test file added to this repo was, until now, never
# executed by anything but a developer's laptop.
RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go vet ./... && \
CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go test ./...
RUN CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" \
go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
+14 -1
View File
@@ -41,7 +41,16 @@ type ChainConfig struct {
WS string `json:"ws" yaml:"ws"`
CoinSymbol string `json:"coin" yaml:"coin"`
PoolManager string `json:"pool_manager" yaml:"pool_manager"` // DEX settlement precompile (0x9999); empty => indexer default
Enabled bool `json:"enabled" yaml:"enabled"`
// Enabled gates the chain entirely: a disabled chain is never registered,
// so it is not indexed, mounts no routes, and does not reach the SPA's
// chain list. See ChainRegistry.Add, the single place it is honoured.
//
// A pointer, so absent and false are distinguishable. Absent means ON —
// every config predating this field wrote `enabled: true` by convention
// while the field was inert, and a plain bool would read a future config
// that simply omits the key as "off" and make the chain vanish with no
// error. Nil is no opinion; only an explicit `enabled: false` disables.
Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
Default bool `json:"default" yaml:"default"`
Source string `json:"source" yaml:"-"` // config, env, mdns, admin
@@ -100,6 +109,10 @@ type Network struct {
ChainID int64 `yaml:"chain_id" json:"chainId"`
}
// On reports whether the chain should run. Absent means on; only an explicit
// `enabled: false` turns a chain off.
func (c ChainConfig) On() bool { return c.Enabled == nil || *c.Enabled }
// slugPattern restricts chain slugs to lowercase alphanumeric + hyphen so they
// remain safe in URL routes (/v1/indexer/{slug}/) and filesystem paths.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
+13
View File
@@ -55,6 +55,19 @@ func (r *ChainRegistry) Add(cfg ChainConfig) error {
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 {
+83
View File
@@ -413,3 +413,86 @@ func TestHTTPIntegration(t *testing.T) {
t.Fatalf("expected 0 chains after delete")
}
}
// TestChainEnabledGatesRegistration pins what `enabled` means. It meant nothing
// before: the field was declared on ChainConfig and read nowhere, so a chain
// switched off in config was registered, indexed and polled exactly like one
// switched on. Each case below fails independently if the check is removed or
// if the absent-means-on default is dropped.
func TestChainEnabledGatesRegistration(t *testing.T) {
on, off := true, false
base := ChainConfig{RPC: "http://localhost:9650/v1/bc/C/rpc", Type: "evm", Source: "config"}
for _, tc := range []struct {
name string
enabled *bool
register bool
}{
{"explicit false does not register", &off, false},
{"explicit true registers", &on, true},
// The reason Enabled is a pointer. Every chains.yaml written while the
// field was inert says `enabled: true` by convention, but a plain bool
// would read a config that simply omits the key as "off" and drop the
// chain silently — no error, no log, just a missing chain.
{"absent registers", nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
r := NewChainRegistry()
c := base
c.Slug, c.Enabled = "cchain", tc.enabled
if err := r.Add(c); err != nil {
t.Fatalf("Add: %v", err)
}
if got := r.Count() == 1; got != tc.register {
t.Fatalf("registered=%v, want %v", got, tc.register)
}
if _, ok := r.Get("cchain"); ok != tc.register {
t.Fatalf("Get ok=%v, want %v", ok, tc.register)
}
})
}
}
// TestDisabledChainNeverStartsIndexer is the failure this fixes, stated as the
// symptom: a disabled chain must not reach the supervisor, because reaching it
// is what spawned the indexer goroutine that polled a nonexistent RPC forever.
func TestDisabledChainNeverStartsIndexer(t *testing.T) {
off := false
r := NewChainRegistry()
r.AttachSupervisor(nil) // a real supervisor would spawn goroutines; nil panics if reached
if err := r.Add(ChainConfig{
Slug: "spc", RPC: "http://localhost:9650/v1/bc/spc/rpc", Type: "evm",
Source: "config", Enabled: &off,
}); err != nil {
t.Fatalf("Add: %v", err)
}
if r.Count() != 0 {
t.Fatalf("disabled chain reached the registry: count=%d", r.Count())
}
if len(r.List()) != 0 {
t.Fatalf("disabled chain reached the SPA chain list: %v", r.List())
}
}
// TestLoadConfigKeepsEnabled proves the YAML actually decodes into the pointer —
// a wrong tag would leave it nil and silently re-enable every disabled chain.
func TestLoadConfigKeepsEnabled(t *testing.T) {
p := filepath.Join(t.TempDir(), "chains.yaml")
if err := os.WriteFile(p, []byte(
"chains:\n"+
" - slug: cchain\n rpc: http://x/rpc\n enabled: true\n"+
" - slug: spc\n rpc: http://x/rpc\n enabled: false\n"+
" - slug: osage\n rpc: http://x/rpc\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(p)
if err != nil {
t.Fatal(err)
}
want := map[string]bool{"cchain": true, "spc": false, "osage": true}
for _, c := range cfg.Chains {
if c.On() != want[c.Slug] {
t.Errorf("%s: On()=%v, want %v", c.Slug, c.On(), want[c.Slug])
}
}
}