Files
zeekayandHanzo Dev 62d5f28c22 A chain address is built in one place
The node moved every chain route from /v1/bc/<alias> to /v1/chain/<alias>
and deleted the old prefix, so every chain call answered 404. The address
was spelled out at 112 sites across 34 files and constants.ChainAliasPrefix
was used at none of them, which is why nothing failed to compile when the
segment moved.

route.Chain(uri, alias) composes it now, and route.Alias reads one back,
replacing three hand-rolled parsers — one of which took the alias by
position in the split path and only worked because the new segment happens
to leave the component count unchanged.

TestChainAddressBuiltOnlyHere walks every .go file in the module and fails
on a chain address in a string literal, so the segment cannot drift back;
prose is free to name a route. TestGuardCatchesEachShape plants each
forbidden shape and requires the guard to find it.

The endpoint box and the eleven-chain table were the same list written
twice; there is one list, rendered once, measured rather than hand-padded.
The generated CLI reference and the faucet manifest compose through the
same function.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-30 15:05:42 -07:00

507 lines
13 KiB
Go

// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chainkit
import (
"bytes"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"text/template"
"github.com/luxfi/cli/pkg/route"
)
// GenerateResult holds all generated manifests for a network.
type GenerateResult struct {
Network string // e.g. "mainnet", "testnet", "devnet"
Namespace string // K8s namespace
// CRD manifests (consumed by lux-operator)
LuxNetwork string // LuxNetwork CR YAML
LuxIndexer string // LuxIndexer CR YAML
LuxExplorer string // LuxExplorer CR YAML
LuxGateway string // LuxGateway CR YAML
// Standard K8s manifests (for services without CRDs)
Namespace_ string // Namespace YAML
Exchange string // Exchange Deployment YAML (if enabled)
Faucet string // Faucet Deployment YAML (if enabled)
}
// Generate produces all K8s manifests for a single network from chain.yaml.
func Generate(cfg *ChainConfig, network string) (*GenerateResult, error) {
net, ok := cfg.Networks[network]
if !ok {
return nil, fmt.Errorf("network %q not defined in chain.yaml", network)
}
ns := cfg.NamespaceFor(network)
result := &GenerateResult{
Network: network,
Namespace: ns,
}
// Context passed to all templates
ctx := &templateCtx{
Config: cfg,
Network: network,
NetSpec: net,
Namespace: ns,
}
var err error
// Namespace
result.Namespace_, err = renderTemplate("namespace", tplNamespace, ctx)
if err != nil {
return nil, fmt.Errorf("generate namespace: %w", err)
}
// LuxNetwork CR
if cfg.Services.Node.Enabled {
ctx.GenesisJSON, err = cfg.LoadGenesisJSON()
if err != nil {
return nil, fmt.Errorf("load genesis: %w", err)
}
ctx.PrecompileUpgrades = buildPrecompileUpgrades(cfg.Precompiles)
result.LuxNetwork, err = renderTemplate("luxnetwork", tplLuxNetwork, ctx)
if err != nil {
return nil, fmt.Errorf("generate LuxNetwork: %w", err)
}
}
// LuxIndexer CR
if cfg.Services.Indexer.Enabled {
result.LuxIndexer, err = renderTemplate("luxindexer", tplLuxIndexer, ctx)
if err != nil {
return nil, fmt.Errorf("generate LuxIndexer: %w", err)
}
}
// LuxExplorer CR
if cfg.Services.Explorer.Enabled {
result.LuxExplorer, err = renderTemplate("luxexplorer", tplLuxExplorer, ctx)
if err != nil {
return nil, fmt.Errorf("generate LuxExplorer: %w", err)
}
}
// LuxGateway CR
if cfg.Services.Gateway.Enabled {
domain := cfg.Brand.Domains.RPC
if domain == "" {
domain = fmt.Sprintf("api.%s.%s.network", network, cfg.Chain.Slug)
}
ctx.GatewayHost = domain
result.LuxGateway, err = renderTemplate("luxgateway", tplLuxGateway, ctx)
if err != nil {
return nil, fmt.Errorf("generate LuxGateway: %w", err)
}
}
// Exchange Deployment (not a CRD yet)
if cfg.Services.Exchange.Enabled {
result.Exchange, err = renderTemplate("exchange", tplExchange, ctx)
if err != nil {
return nil, fmt.Errorf("generate Exchange: %w", err)
}
}
// Faucet Deployment
if cfg.Services.Faucet.Enabled && network != "mainnet" {
result.Faucet, err = renderTemplate("faucet", tplFaucet, ctx)
if err != nil {
return nil, fmt.Errorf("generate Faucet: %w", err)
}
}
return result, nil
}
// GenerateAll produces manifests for all networks defined in chain.yaml.
func GenerateAll(cfg *ChainConfig) ([]*GenerateResult, error) {
var results []*GenerateResult
for name := range cfg.Networks {
r, err := Generate(cfg, name)
if err != nil {
return nil, fmt.Errorf("network %s: %w", name, err)
}
results = append(results, r)
}
return results, nil
}
// WriteManifests writes all generated manifests to an output directory.
func WriteManifests(results []*GenerateResult, outDir string) ([]string, error) {
var files []string
for _, r := range results {
dir := filepath.Join(outDir, r.Network)
pairs := []struct {
name string
data string
}{
{"namespace.yaml", r.Namespace_},
{"luxnetwork.yaml", r.LuxNetwork},
{"luxindexer.yaml", r.LuxIndexer},
{"luxexplorer.yaml", r.LuxExplorer},
{"luxgateway.yaml", r.LuxGateway},
{"exchange.yaml", r.Exchange},
{"faucet.yaml", r.Faucet},
}
for _, p := range pairs {
if p.data == "" {
continue
}
path := filepath.Join(dir, p.name)
files = append(files, path)
}
}
return files, nil
}
// --- template context and helpers ---
type templateCtx struct {
Config *ChainConfig
Network string
NetSpec NetworkSpec
Namespace string
GenesisJSON json.RawMessage
PrecompileUpgrades string
GatewayHost string
}
func renderTemplate(name, tpl string, ctx *templateCtx) (string, error) {
funcMap := template.FuncMap{
"indent": func(n int, s string) string {
pad := strings.Repeat(" ", n)
lines := strings.Split(s, "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
},
// chain is the same composer the Go code uses, so a manifest and a
// dialled client cannot disagree about where a chain answers.
"chain": route.Chain,
"lower": strings.ToLower,
"split": strings.Split,
"boolDefault": func(b *bool, def bool) bool {
if b == nil {
return def
}
return *b
},
}
t, err := template.New(name).Funcs(funcMap).Parse(tpl)
if err != nil {
return "", fmt.Errorf("parse template %s: %w", name, err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, ctx); err != nil {
return "", fmt.Errorf("execute template %s: %w", name, err)
}
return buf.String(), nil
}
func buildPrecompileUpgrades(precompiles []PrecompileSpec) string {
if len(precompiles) == 0 {
return ""
}
var lines []string
for _, p := range precompiles {
lines = append(lines, fmt.Sprintf(" - %s: {blockTimestamp: %d}", p.Name, p.BlockTimestamp))
}
return strings.Join(lines, "\n")
}
// --- templates ---
const tplNamespace = `apiVersion: v1
kind: Namespace
metadata:
name: {{.Namespace}}
labels:
app.kubernetes.io/part-of: {{.Config.Chain.Slug}}-network
lux.network/chain: {{.Config.Chain.Slug}}
lux.network/network: {{.Network}}
`
const tplLuxNetwork = `apiVersion: lux.network/v1alpha1
kind: LuxNetwork
metadata:
name: {{.Config.Chain.Slug}}d
namespace: {{.Namespace}}
labels:
app.kubernetes.io/part-of: {{.Config.Chain.Slug}}-network
lux.network/network: {{.Network}}
spec:
networkId: {{.NetSpec.NetworkID}}
validators: {{.NetSpec.Validators}}
dbType: {{.Config.Chain.DBType}}
networkCompressionType: {{.Config.Chain.Compression}}
image:
repository: {{.Config.Services.Node.Image}}
{{- if .NetSpec.ImageTag}}
tag: "{{.NetSpec.ImageTag}}"
{{- end}}
ports:
http: 9650
staking: 9651
consensus:
sybilProtectionEnabled: {{boolDefault .NetSpec.SybilProtection true}}
storage:
size: "{{.Config.Services.Node.StorageSize}}"
{{- if .Config.Services.Node.StorageClass}}
storageClass: {{.Config.Services.Node.StorageClass}}
{{- end}}
{{- if .Config.Services.Node.StakingKMS}}
staking:
kms:
hostApi: {{.Config.Services.Node.StakingKMS.HostAPI}}
projectSlug: {{.Config.Services.Node.StakingKMS.ProjectSlug}}
envSlug: {{.Config.Services.Node.StakingKMS.EnvSlug}}
secretsPath: {{.Config.Services.Node.StakingKMS.SecretsPath}}
{{- end}}
{{- if .NetSpec.SeedRestoreURL}}
seedRestore:
enabled: true
sourceType: ObjectStore
objectStoreUrl: "{{.NetSpec.SeedRestoreURL}}"
{{- end}}
{{- if gt .NetSpec.SnapshotInterval 0}}
snapshotSchedule:
enabled: true
intervalSeconds: {{.NetSpec.SnapshotInterval}}
{{- end}}
chainTracking:
trackAllChains: true
startupGate:
onTimeout: StartAnyway
timeoutSeconds: 30
{{- if .PrecompileUpgrades}}
chainUpgradeConfig:
precompileUpgrades:
{{.PrecompileUpgrades}}
{{- end}}
{{- if .GenesisJSON}}
genesis: {{printf "%s" .GenesisJSON}}
{{- end}}
`
const tplLuxIndexer = `apiVersion: lux.network/v1alpha1
kind: LuxIndexer
metadata:
name: indexer-{{.Config.Chain.Slug}}
namespace: {{.Namespace}}
labels:
app.kubernetes.io/part-of: {{.Config.Chain.Slug}}-network
lux.network/network: {{.Network}}
lux.network/chain: {{.Config.Chain.Slug}}
spec:
networkRef: {{.Config.Chain.Slug}}d
chainAlias: "{{.Config.Chain.Slug}}"
chainId: {{.NetSpec.ChainID}}
{{- if .NetSpec.BlockchainID}}
blockchainId: "{{.NetSpec.BlockchainID}}"
{{- end}}
image:
repository: {{.Config.Services.Indexer.Image}}
tag: {{.Config.Services.Indexer.ImageTag}}
database:
managed: true
storageSize: "{{.Config.Services.Indexer.DBStorageSize}}"
port: 4000
replicas: {{.Config.Services.Indexer.Replicas}}
traceEnabled: {{.Config.Services.Indexer.TraceEnabled}}
contractVerification: {{.Config.Services.Indexer.ContractVerification}}
pollInterval: {{.Config.Services.Indexer.PollInterval}}
storage:
size: "10Gi"
`
const tplLuxExplorer = `apiVersion: lux.network/v1alpha1
kind: LuxExplorer
metadata:
name: explorer
namespace: {{.Namespace}}
labels:
app.kubernetes.io/part-of: {{.Config.Chain.Slug}}-network
lux.network/network: {{.Network}}
spec:
networkRef: {{.Config.Chain.Slug}}d
indexerRefs:
- indexerName: indexer-{{.Config.Chain.Slug}}
displayName: "{{.Config.Brand.DisplayName}}"
default: true
{{- if .Config.Brand.PrimaryColor}}
color: "{{.Config.Brand.PrimaryColor}}"
{{- end}}
replicas: {{.Config.Services.Explorer.Replicas}}
port: 3000
ingress:
{{- if .Config.Brand.Domains.Explorer}}
host: {{.Config.Brand.Domains.Explorer}}
{{- else}}
host: explorer.{{.Network}}.{{.Config.Chain.Slug}}.network
{{- end}}
ingressClass: {{.Config.Services.Explorer.IngressClass}}
branding:
networkName: "{{.Config.Brand.DisplayName}} ({{.Network}})"
{{- if .Config.Brand.Logo}}
logo: "{{.Config.Brand.Logo}}"
{{- end}}
{{- if .Config.Brand.PrimaryColor}}
primaryColor: "{{.Config.Brand.PrimaryColor}}"
{{- end}}
`
const tplLuxGateway = `apiVersion: lux.network/v1alpha1
kind: LuxGateway
metadata:
name: api-gateway
namespace: {{.Namespace}}
labels:
app.kubernetes.io/part-of: {{.Config.Chain.Slug}}-network
lux.network/network: {{.Network}}
spec:
networkRef: {{.Config.Chain.Slug}}d
host: {{.GatewayHost}}
replicas: {{.Config.Services.Gateway.Replicas}}
port: 8080
autoRoutes: true
cors:
allowedOrigins:
{{- if .Config.Services.Gateway.CORSAllowOrigins}}
{{- range $origin := split .Config.Services.Gateway.CORSAllowOrigins ","}}
- "{{$origin}}"
{{- end}}
{{- else}}
- "*"
{{- end}}
allowedMethods:
- "GET"
- "POST"
- "OPTIONS"
allowedHeaders:
- "Content-Type"
- "Authorization"
rateLimit:
requestsPerSecond: {{.Config.Services.Gateway.RateLimitRPS}}
burst: {{.Config.Services.Gateway.RateLimitBurst}}
`
const tplExchange = `apiVersion: apps/v1
kind: Deployment
metadata:
name: {{.Config.Chain.Slug}}-exchange
namespace: {{.Namespace}}
labels:
app.kubernetes.io/part-of: {{.Config.Chain.Slug}}-network
app.kubernetes.io/component: exchange
spec:
replicas: 1
selector:
matchLabels:
app: {{.Config.Chain.Slug}}-exchange
template:
metadata:
labels:
app: {{.Config.Chain.Slug}}-exchange
spec:
containers:
- name: exchange
image: {{.Config.Services.Exchange.Image}}
ports:
- containerPort: 3000
env:
- name: NEXT_PUBLIC_BRAND_PACKAGE
value: "{{.Config.Services.Exchange.BrandPackage}}"
- name: NEXT_PUBLIC_CHAIN_ID
value: "{{.NetSpec.ChainID}}"
---
apiVersion: v1
kind: Service
metadata:
name: {{.Config.Chain.Slug}}-exchange
namespace: {{.Namespace}}
spec:
selector:
app: {{.Config.Chain.Slug}}-exchange
ports:
- port: 3000
targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{.Config.Chain.Slug}}-exchange
namespace: {{.Namespace}}
spec:
ingressClassName: {{.Config.Deploy.IngressClass}}
rules:
- host: {{.Config.Brand.Domains.Exchange}}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{.Config.Chain.Slug}}-exchange
port:
number: 3000
`
const tplFaucet = `apiVersion: apps/v1
kind: Deployment
metadata:
name: {{.Config.Chain.Slug}}-faucet
namespace: {{.Namespace}}
labels:
app.kubernetes.io/part-of: {{.Config.Chain.Slug}}-network
app.kubernetes.io/component: faucet
spec:
replicas: 1
selector:
matchLabels:
app: {{.Config.Chain.Slug}}-faucet
template:
metadata:
labels:
app: {{.Config.Chain.Slug}}-faucet
spec:
containers:
- name: faucet
image: {{.Config.Deploy.Registry}}/faucet:latest
ports:
- containerPort: 8080
env:
- name: CHAIN_ID
value: "{{.NetSpec.ChainID}}"
- name: RPC_URL
value: "{{chain (printf "http://%sd-0.%sd:9650" .Config.Chain.Slug .Config.Chain.Slug) "C"}}/rpc"
- name: DRIP_AMOUNT
value: "{{.Config.Services.Faucet.DripAmount}}"
- name: RATE_LIMIT
value: "{{.Config.Services.Faucet.RateLimit}}"
---
apiVersion: v1
kind: Service
metadata:
name: {{.Config.Chain.Slug}}-faucet
namespace: {{.Namespace}}
spec:
selector:
app: {{.Config.Chain.Slug}}-faucet
ports:
- port: 8080
targetPort: 8080
`