Files
zeekayandHanzo Dev 9264049f1d A health address is built in one place, like a chain address
`lux dev start` could not bring up a node built from the current tree, for
three reasons that share a shape with the chain-segment rename: the CLI
spelled out an address the node had already left behind, and nothing failed
to compile.

The health service moved onto typed ops. /v1/health is gone, so every probe
written against it read a healthy node as unhealthy and every printed
endpoint sent a reader to a 404. route.Health, route.Liveness and
route.Readiness compose the three addresses the node answers on, next to
route.Chain and off the same base, and the ten call sites that had it
written out now ask for the one they mean: a probe polls readiness, a
report reads the full checks, a printed endpoint offers the report.

Two luxd flags no longer exist, and passing an unknown flag is fatal, so
the node refused the config before it read any of it:

  --api-keystore-enabled  the keystore API is gone
  --automine-interval     block cadence is the C-Chain's own
                          enable-automining, read by the block builder from
                          the chain config dir, not a node flag

The --automine duration flag went with the second: it named a knob that
does not exist.

`lux dev stop` looked only in the default data-dir, and otherwise pkill'd
anything whose command line carried a quorum size of one — a pattern that
fits every dev node on the machine, not just the one this command started.
It takes the same --data-dir as start now, resolved by the same function,
and reads the PID from there; no PID file is an answer rather than a reason
to guess.

Health gets composition but not the guard that pins chain addresses:
pkg/ssh still asks for health as a JSON-RPC method, and the whole
JSON-RPC-over-SSH family it belongs to is owed the same move onto typed
ops. A guard added before that migration would need an exemption for it,
and an exemption is how a guard rots.

Measured against luxd 1.36.181 (constants v1.6.4): start reaches ready,
prints /v1/chain/C/rpc and /v1/health/ops/health, both answer 200, and
stop interrupts the node it started and reports plainly when there is
none.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-05 19:06:43 -07:00

372 lines
13 KiB
Go

// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package devcmd
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/luxfi/cli/pkg/route"
"github.com/luxfi/cli/pkg/ux"
"github.com/luxfi/constants"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
port int
nodePath string
logLevel string
cleanState bool
networkID uint32
genesisFile string
dataDir string
buildTags string
pluginDir string
)
const nodeBinaryName = "luxd"
// devDataDir is where the dev node keeps its state — the caller's --data-dir,
// or the anvil-compat default. start and stop resolve it the same way, so stop
// looks where start wrote.
func devDataDir() string {
if dataDir != "" {
return dataDir
}
return filepath.Join(os.Getenv("HOME"), constants.BaseDirName, constants.DevDir)
}
// devPIDFile is the node's address for `lux dev stop`.
func devPIDFile() string {
return filepath.Join(devDataDir(), "luxd.pid")
}
// dchainBuildTag is the build tag that selects a luxd built WITH the D-Chain
// (dexvm) linked in. The public default luxd is built without it; passing
// --build-tags dchain tells `lux dev start` to launch the dchain-tagged binary
// (which bakes D-Chain on localnet 1337) and to resolve the plugin dir
// explicitly so the EVM plugin subprocess is found deterministically.
const dchainBuildTag = "dchain"
// hasBuildTag reports whether the comma-separated --build-tags value contains
// tag (space-insensitive on each element).
func hasBuildTag(tags, tag string) bool {
for _, t := range strings.Split(tags, ",") {
if strings.TrimSpace(t) == tag {
return true
}
}
return false
}
// resolvePluginDir picks the VM plugin directory to pass to luxd. An explicit
// --plugin-dir (explicit) always wins. For a dchain launch with no explicit
// value it defaults to <baseDir>/plugins/current (luxd's own default) so the EVM
// plugin subprocess is found deterministically. For a non-dchain launch with no
// explicit value it returns "" — luxd then uses its own default unchanged.
func resolvePluginDir(explicit, baseDir string, dchain bool) string {
if explicit != "" {
return explicit
}
if dchain {
return filepath.Join(baseDir, constants.PluginsDir, "current")
}
return ""
}
func newStartCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "start",
Short: "Start local dev node",
Long: `Start a single-node Lux development network.
The dev node uses K=1 consensus for instant block finality without
validator sampling. All chains are enabled with full validator signing:
• C-Chain: EVM-compatible smart contracts
• P-Chain: Platform staking and validation
• X-Chain: UTXO-based asset exchange
• T-Chain: Threshold FHE operations
Default port is 8545 (Anvil-compatible) so it works seamlessly with
Hardhat, Foundry, and other Ethereum tooling.
FHE Support:
The T-Chain provides threshold homomorphic encryption for confidential
smart contracts. Use FHE precompiles at 0x0200...0080 or the @luxfi/fhe SDK.
DEX / D-Chain:
The public default luxd does NOT include the DEX D-Chain. To launch the
dchain-tagged luxd (which bakes D-Chain on localnet 1337) pass:
lux dev start --build-tags dchain
This resolves the dchain-tagged binary and points --plugin-dir at the plugin
directory so the EVM plugin subprocess is found deterministically.
Examples:
lux dev start # Start on default port 8545
lux dev start --port 9650 # Start on custom port
lux dev start --build-tags dchain # Start the D-Chain-enabled (dexvm) node`,
RunE: startDevNode,
Args: cobra.ExactArgs(0),
SilenceUsage: true,
}
cmd.Flags().IntVar(&port, "port", 8545, "HTTP port for RPC (Anvil-compatible default)")
cmd.Flags().StringVar(&nodePath, "node-path", "", "path to luxd binary (auto-detected if not set)")
cmd.Flags().StringVar(&logLevel, "log-level", "info", "log level (debug, info, warn, error)")
cmd.Flags().BoolVar(&cleanState, "clean", false, "clean state before starting (fresh genesis)")
cmd.Flags().Uint32Var(&networkID, "network-id", 1337, "sovereign-L1 networkID (override 1337 default)")
cmd.Flags().StringVar(&genesisFile, "genesis-file", "", "genesis file path (uses luxd embedded if empty)")
cmd.Flags().StringVar(&dataDir, "data-dir", "", "luxd data-dir (default ~/.lux/devnet)")
cmd.Flags().StringVar(&buildTags, "build-tags", "", "luxd build-tag selector; 'dchain' launches the D-Chain-enabled (dexvm) node")
cmd.Flags().StringVar(&pluginDir, "plugin-dir", "", "VM plugin directory passed to luxd (default: luxd's own ~/.lux/plugins/current)")
return cmd
}
// findNodeBinary locates the luxd binary. An explicit --node-path always wins.
// When dchain is true, auto-detection prefers the dchain build output
// (node/build/luxd) over a luxd on PATH, because the public PATH luxd is built
// without the D-Chain — silently launching it would yield a node with no
// D-Chain. The default (dchain=false) search order is unchanged.
func findNodeBinary(dchain bool) (string, error) {
// Priority 1: User-provided path (explicit choice always wins).
if nodePath != "" {
if _, err := os.Stat(nodePath); os.IsNotExist(err) {
return "", fmt.Errorf("%s not found at: %s", nodeBinaryName, nodePath)
}
return nodePath, nil
}
// nodeBuildDir is the standard dchain build output: <repo>/node/build/luxd,
// resolved relative to this CLI binary. The dchain-tagged luxd is built here.
nodeBuildBinary := func() (string, bool) {
execPath, err := os.Executable()
if err != nil {
return "", false
}
if execPath, err = filepath.EvalSymlinks(execPath); err != nil {
return "", false
}
cliDir := filepath.Dir(filepath.Dir(execPath))
absPath, err := filepath.Abs(filepath.Join(cliDir, "..", "node", "build", nodeBinaryName))
if err != nil {
return "", false
}
if _, err := os.Stat(absPath); err != nil {
return "", false
}
return absPath, true
}
// For a dchain launch, prefer the dchain build output BEFORE PATH/config so a
// public PATH luxd (no D-Chain) is never picked silently.
if dchain {
if p, ok := nodeBuildBinary(); ok {
return p, nil
}
}
// Priority 2: Environment/config
if configPath := viper.GetString(constants.ConfigNodePath); configPath != "" {
if strings.HasPrefix(configPath, "~") {
home, _ := os.UserHomeDir()
configPath = filepath.Join(home, configPath[1:])
}
if _, err := os.Stat(configPath); err == nil {
return configPath, nil
}
}
// Priority 3: PATH
if binaryPath, err := exec.LookPath(nodeBinaryName); err == nil {
return binaryPath, nil
}
// Priority 4: Relative to CLI (node/build/luxd).
if p, ok := nodeBuildBinary(); ok {
return p, nil
}
if dchain {
return "", fmt.Errorf("dchain-tagged %s not found. Build it (cd node && go build -tags dchain -o build/luxd ./main) or set --node-path", nodeBinaryName)
}
return "", fmt.Errorf("%s not found. Set --node-path or add to PATH", nodeBinaryName)
}
func startDevNode(*cobra.Command, []string) error {
ux.Logger.PrintToUser("Starting Lux dev node (K=1 consensus)...")
dchain := hasBuildTag(buildTags, dchainBuildTag)
localNodePath, err := findNodeBinary(dchain)
if err != nil {
return err
}
// Data directories - use constants for consistent paths
baseDir := filepath.Join(os.Getenv("HOME"), constants.BaseDirName)
dataDir = devDataDir()
dbDir := filepath.Join(dataDir, "db")
logDir := filepath.Join(dataDir, "logs")
// Clean state if requested or if db doesn't exist
if cleanState {
ux.Logger.PrintToUser("Cleaning dev state...")
if err := os.RemoveAll(dbDir); err != nil {
ux.Logger.PrintToUser("Warning: failed to clean database: %v", err)
}
}
// Ensure directories exist
if err := os.MkdirAll(logDir, 0o750); err != nil {
return fmt.Errorf("failed to create log directory: %w", err)
}
stakingPort := port + 1
// Resolve the VM plugin directory (the capstone's missing --plugin-dir
// blocker): explicit wins; a dchain launch defaults to ~/.lux/plugins/current
// so the EVM plugin subprocess is found; a non-dchain launch stays empty and
// lets luxd use its own default unchanged.
effPluginDir := resolvePluginDir(pluginDir, baseDir, dchain)
ux.Logger.PrintToUser("Binary: %s", localNodePath)
if dchain {
ux.Logger.PrintToUser("Build tags: dchain (D-Chain / dexvm enabled)")
}
ux.Logger.PrintToUser("Port: %d (staking: %d)", port, stakingPort)
// Build luxd command. luxd has no `--dev` shortcut, so we spell out the
// K=1, no-bootstrap, no-sybil-protection profile explicitly. --automine
// supplies single-validator-quorum consensus and instant finality. Block
// cadence is not set here: it is the C-Chain's own enable-automining, read
// by the block builder from the chain config dir.
// Chain config dir - luxd's --chain-config-dir points here.
// Uses ~/.lux/chains/ for all chain configs (genesis, config.json, etc.)
chainConfigDir := filepath.Join(baseDir, constants.ChainsDir)
args := []string{
"--automine",
"--consensus-sample-size=1",
"--consensus-quorum-size=1",
"--sybil-protection-enabled=false",
"--skip-bootstrap=true",
fmt.Sprintf("--network-id=%d", networkID),
fmt.Sprintf("--http-host=%s", "0.0.0.0"),
fmt.Sprintf("--http-port=%d", port),
fmt.Sprintf("--staking-port=%d", stakingPort),
fmt.Sprintf("--data-dir=%s", dataDir),
fmt.Sprintf("--log-dir=%s", logDir),
fmt.Sprintf("--log-level=%s", logLevel),
fmt.Sprintf("--chain-config-dir=%s", chainConfigDir),
"--api-admin-enabled=true",
"--index-enabled=true",
"--track-all-chains=true",
}
if genesisFile != "" {
args = append(args, fmt.Sprintf("--genesis-file=%s", genesisFile))
}
if effPluginDir != "" {
args = append(args, fmt.Sprintf("--plugin-dir=%s", effPluginDir))
ux.Logger.PrintToUser("Plugin dir: %s", effPluginDir)
}
cmd := exec.Command(localNodePath, args...) //nolint:gosec // G204: Running our own node binary
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start luxd: %w", err)
}
// Save PID file for later use by 'lux dev stop' and network detection
pidFile := devPIDFile()
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { //nolint:gosec // G306: PID file needs to be readable
ux.Logger.PrintToUser("Warning: failed to save PID file: %v", err)
}
ux.Logger.PrintToUser("luxd started (PID: %d)", cmd.Process.Pid)
// Wait for health with explicit timeout (60 seconds for all chains to bootstrap)
nodeURL := fmt.Sprintf("http://localhost:%d", port)
healthTimeout := 60 * time.Second
healthCtx, healthCancel := context.WithTimeout(context.Background(), healthTimeout)
defer healthCancel()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-healthCtx.Done():
return fmt.Errorf("timeout waiting for node to become healthy after %s: %w", healthTimeout, healthCtx.Err())
case <-ticker.C:
resp, err := http.Get(route.Readiness(nodeURL))
if err != nil {
continue // Network not ready yet
}
_ = resp.Body.Close()
if resp.StatusCode != 200 {
continue
}
// Additional check: verify C-Chain is responding
cchainURL := route.Chain(fmt.Sprintf("http://localhost:%d", port), "C") + "/rpc"
cResp, cErr := http.Post(cchainURL, "application/json",
strings.NewReader(`{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}`))
if cErr != nil {
continue
}
_ = cResp.Body.Close()
if cResp.StatusCode == 200 {
goto healthy
}
}
}
healthy:
// Print success info
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Dev node ready!")
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Endpoints:")
uri := fmt.Sprintf("http://localhost:%d", port)
ws := fmt.Sprintf("ws://localhost:%d", port)
ux.Logger.PrintToUser(" C-Chain RPC: %s", route.Chain(uri, "C")+"/rpc")
ux.Logger.PrintToUser(" C-Chain WS: %s", route.Chain(ws, "C")+"/ws")
ux.Logger.PrintToUser(" P-Chain: %s", route.Chain(uri, "P"))
ux.Logger.PrintToUser(" X-Chain: %s", route.Chain(uri, "X"))
ux.Logger.PrintToUser(" T-Chain: %s", route.Chain(uri, "T"))
if dchain {
ux.Logger.PrintToUser(" D-Chain: %s", route.Chain(uri, "D"))
}
ux.Logger.PrintToUser(" Health: %s", route.Health(nodeURL))
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Features:")
ux.Logger.PrintToUser(" • K=1 consensus (instant finality)")
ux.Logger.PrintToUser(" • Full validator signing")
ux.Logger.PrintToUser(" • All chains: C/P/X/T enabled")
ux.Logger.PrintToUser(" • Chain ID: 1337")
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("FHE Precompiles (C-Chain):")
ux.Logger.PrintToUser(" • FHEOS: 0x0200000000000000000000000000000000000080")
ux.Logger.PrintToUser(" • ACL: 0x0200000000000000000000000000000000000081")
ux.Logger.PrintToUser(" • Verifier: 0x0200000000000000000000000000000000000082")
ux.Logger.PrintToUser(" • Gateway: 0x0200000000000000000000000000000000000083")
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Data: %s", dataDir)
ux.Logger.PrintToUser("Logs: %s", logDir)
ux.Logger.PrintToUser("")
ux.Logger.PrintToUser("Stop with: lux dev stop")
// Wait for process (foreground mode — user backgrounds with shell `&` if needed)
return cmd.Wait()
}