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

1250 lines
36 KiB
Go

// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package status
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/luxfi/cli/pkg/route"
"github.com/luxfi/constants"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)
var (
// ErrNoNetwork indicates no nodes are running for a network status check.
ErrNoNetwork = errors.New("no network running")
)
// StatusService handles status probing and reporting
type StatusService struct {
concurrencyLimit int
timeout time.Duration
}
// NewStatusService creates a new status service
func NewStatusService() *StatusService {
return &StatusService{
concurrencyLimit: 32, // Global concurrency limit
timeout: 2 * time.Second,
}
}
// NewStatusServiceWithProgress creates a new status service with a progress bar (if needed)
func NewStatusServiceWithProgress(progress interface{}) *StatusService {
// For now, we ignore the progress interface as the service doesn't use it directly yet
return NewStatusService()
}
// GetStatus retrieves the status of all networks and chains
func (s *StatusService) GetStatus(ctx context.Context) (*StatusResult, error) {
startTime := time.Now()
// Get network configurations
networks, err := s.getNetworkConfigurations()
if err != nil {
return nil, fmt.Errorf("failed to get network configurations: %w", err)
}
// Create semaphore for concurrency control
sem := semaphore.NewWeighted(int64(s.concurrencyLimit))
// Process each network concurrently
errGroup, ctx := errgroup.WithContext(ctx)
var result StatusResult
result.Networks = make([]Network, len(networks))
for i, network := range networks {
i := i
network := network
errGroup.Go(func() error {
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
// Skip probing stopped networks - just copy them as-is
if network.Metadata.Status == "stopped" || len(network.Nodes) == 0 {
result.Networks[i] = network
return nil
}
// Probe this network
probedNetwork, err := s.probeNetwork(ctx, network)
if err != nil {
return err
}
result.Networks[i] = *probedNetwork
return nil
})
}
// Wait for all networks to be probed
if err := errGroup.Wait(); err != nil {
return nil, fmt.Errorf("failed to probe networks: %w", err)
}
// Probe tracked L1 EVMs (Zoo, Hanzo, SPC)
trackedEVMs := s.probeTrackedEVMs(ctx, result.Networks)
result.TrackedEVMs = trackedEVMs
// Calculate duration
durationMS := int(time.Since(startTime).Milliseconds())
result.Timestamp = time.Now()
result.DurationMS = durationMS
return &result, nil
}
// getL1ChainConfig returns the L1 chain configurations for Zoo, Hanzo, SPC
func (s *StatusService) getL1ChainConfig() []TrackedEVM {
return []TrackedEVM{
// Zoo - Decentralized AI network
{Name: "zoo", Network: "mainnet", RPCs: []string{}, BlockchainID: "", VMID: ""},
{Name: "zoo", Network: "testnet", RPCs: []string{}, BlockchainID: "", VMID: ""},
// Hanzo - AI compute network
{Name: "hanzo", Network: "mainnet", RPCs: []string{}, BlockchainID: "", VMID: ""},
{Name: "hanzo", Network: "testnet", RPCs: []string{}, BlockchainID: "", VMID: ""},
// SPC - Smart Payment Chain
{Name: "spc", Network: "mainnet", RPCs: []string{}, BlockchainID: "", VMID: ""},
{Name: "spc", Network: "testnet", RPCs: []string{}, BlockchainID: "", VMID: ""},
}
}
// probeTrackedEVMs probes L1 chains (Zoo, Hanzo, SPC) based on network status
func (s *StatusService) probeTrackedEVMs(ctx context.Context, networks []Network) []EVMStatus {
var results []EVMStatus
// L1 chain IDs from CLAUDE.md
l1Chains := map[string]map[string]uint64{
"zoo": {"mainnet": 200200, "testnet": 200201},
"hanzo": {"mainnet": 36963, "testnet": 36962},
"spc": {"mainnet": 36911, "testnet": 36910},
}
// For each running network, try to discover L1 chains
for _, network := range networks {
if network.Metadata.Status != "up" || len(network.Nodes) == 0 {
continue
}
baseURL := network.Nodes[0].HTTPURL
networkType := network.Name // mainnet or testnet
// Query for any L1 blockchains that might be running
blockchains, err := s.getBlockchainsFromNode(ctx, baseURL)
if err != nil {
continue
}
// Check for each L1 chain
for chainName, chainIDs := range l1Chains {
expectedChainID := chainIDs[networkType]
if expectedChainID == 0 {
continue
}
// Look for this chain in the discovered blockchains
for _, bc := range blockchains {
bcName, _ := bc["name"].(string)
bcID, _ := bc["id"].(string)
// Match by name (case-insensitive) or check the chain ID via RPC
if strings.EqualFold(bcName, chainName+"-chain") || strings.Contains(strings.ToLower(bcName), chainName) {
// Found a potential L1 chain, probe it
rpcURL := route.Chain(baseURL, bcID) + "/rpc"
evmStatus := s.probeL1Chain(ctx, chainName, networkType, rpcURL, expectedChainID)
if evmStatus != nil {
results = append(results, *evmStatus)
}
break
}
}
}
}
return results
}
// getBlockchainsFromNode retrieves the list of blockchains from a node
func (s *StatusService) getBlockchainsFromNode(ctx context.Context, baseURL string) ([]map[string]interface{}, error) {
client := &http.Client{Timeout: 3 * time.Second}
requestURL := route.Chain(baseURL, "P")
requestBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "platform.getBlockchains",
"params": map[string]interface{}{},
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var responseMap map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&responseMap); err != nil {
return nil, err
}
var blockchains []map[string]interface{}
if result, ok := responseMap["result"].(map[string]interface{}); ok {
if bcs, ok := result["blockchains"].([]interface{}); ok {
for _, bc := range bcs {
if bcMap, ok := bc.(map[string]interface{}); ok {
blockchains = append(blockchains, bcMap)
}
}
}
}
return blockchains, nil
}
// probeL1Chain probes a single L1 EVM chain
func (s *StatusService) probeL1Chain(ctx context.Context, name, network, rpcURL string, expectedChainID uint64) *EVMStatus {
resolver := &EVMHeightResolver{}
height, meta, err := resolver.Height(ctx, rpcURL)
status := &EVMStatus{
Name: name,
Network: network,
Endpoints: []EndpointStatus{
{ChainAlias: name, URL: rpcURL, OK: err == nil},
},
}
if err != nil {
return status
}
status.Height = height
// Extract chain ID
if chainID, ok := meta["chain_id"].(uint64); ok {
status.ChainID = chainID
if chainID != expectedChainID {
status.ChainIDMismatch = true
}
}
// Extract client version
if version, ok := meta["client_version"].(string); ok {
status.ClientVersion = version
}
// Extract syncing status
if syncing, ok := meta["syncing"]; ok {
status.Syncing = syncing
}
return status
}
// probeNetwork probes a single network
func (s *StatusService) probeNetwork(ctx context.Context, network Network) (*Network, error) {
// Create context with timeout for this network
networkCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
// Probe nodes concurrently - use a separate context for errgroup to avoid cancellation issues
nodeErrGroup, nodeCtx := errgroup.WithContext(networkCtx)
var mu sync.Mutex
probedNodes := make([]Node, len(network.Nodes))
for i, node := range network.Nodes {
i := i
node := node
nodeErrGroup.Go(func() error {
probedNode, err := s.probeNode(nodeCtx, node)
if err != nil {
return err
}
mu.Lock()
probedNodes[i] = *probedNode
mu.Unlock()
return nil
})
}
if err := nodeErrGroup.Wait(); err != nil {
return nil, fmt.Errorf("failed to probe nodes: %w", err)
}
// Update network with probed nodes
network.Nodes = probedNodes
// Probe chains - use the main networkCtx, not the cancelled nodeCtx
probedChains, err := s.probeChains(networkCtx, network)
if err != nil {
return nil, fmt.Errorf("failed to probe chains: %w", err)
}
network.Chains = probedChains
// Query balances for validators if we have any
if len(network.Validators) > 0 && len(network.Nodes) > 0 {
baseURL := network.Nodes[0].HTTPURL
network.Validators = s.queryValidatorBalances(networkCtx, baseURL, network.Validators)
}
return &network, nil
}
// queryValidatorBalances queries P/X/C balances for all validators
func (s *StatusService) queryValidatorBalances(ctx context.Context, baseURL string, validators []ValidatorAccount) []ValidatorAccount {
// Query balances concurrently for all validators
var wg sync.WaitGroup
var mu sync.Mutex
for i := range validators {
wg.Add(1)
go func(idx int) {
defer wg.Done()
v := &validators[idx]
// Query P-chain balance
if v.PChainAddress != "" {
if balance, err := s.QueryPChainBalance(ctx, baseURL, v.PChainAddress); err == nil {
mu.Lock()
validators[idx].PChainBalance = balance
mu.Unlock()
}
}
// Query X-chain balance
if v.XChainAddress != "" {
if balance, err := s.QueryXChainBalance(ctx, baseURL, v.XChainAddress); err == nil {
mu.Lock()
validators[idx].XChainBalance = balance
mu.Unlock()
}
}
// Query C-chain balance
if v.CChainAddress != "" {
if balance, err := s.QueryCChainBalance(ctx, baseURL, v.CChainAddress); err == nil {
mu.Lock()
validators[idx].CChainBalance = balance
validators[idx].CChainBalanceLUX = FormatCChainBalanceLUX(balance)
mu.Unlock()
}
}
}(i)
}
wg.Wait()
return validators
}
// probeNode probes a single node by making real API calls
func (s *StatusService) probeNode(ctx context.Context, node Node) (*Node, error) {
startTime := time.Now()
// 1. Get Node Version
versionURL := fmt.Sprintf("%s/v1/info", node.HTTPURL)
versionBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "info.getNodeVersion",
"params": map[string]interface{}{},
}
vJson, _ := json.Marshal(versionBody)
client := &http.Client{Timeout: 2 * time.Second}
req, _ := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(vJson))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err == nil {
defer resp.Body.Close()
var r map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&r); err == nil {
if result, ok := r["result"].(map[string]interface{}); ok {
if version, ok := result["version"].(string); ok {
node.Version = version
}
}
}
} else {
return &node, fmt.Errorf("node unreachable: %w", err)
}
// 2. Get NodeID
idBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "info.getNodeID",
"params": map[string]interface{}{},
}
idJson, _ := json.Marshal(idBody)
reqID, _ := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(idJson))
reqID.Header.Set("Content-Type", "application/json")
if respID, err := client.Do(reqID); err == nil {
defer respID.Body.Close()
var r map[string]interface{}
if err := json.NewDecoder(respID.Body).Decode(&r); err == nil {
if result, ok := r["result"].(map[string]interface{}); ok {
if nodeID, ok := result["nodeID"].(string); ok {
node.NodeID = nodeID
}
}
}
}
// 3. Get Peers
peersBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "info.peers",
"params": map[string]interface{}{},
}
pJson, _ := json.Marshal(peersBody)
reqP, _ := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(pJson))
reqP.Header.Set("Content-Type", "application/json")
if respP, err := client.Do(reqP); err == nil {
defer respP.Body.Close()
var r map[string]interface{}
if err := json.NewDecoder(respP.Body).Decode(&r); err == nil {
if result, ok := r["result"].(map[string]interface{}); ok {
if peers, ok := result["peers"].([]interface{}); ok {
node.PeerCount = len(peers)
}
}
}
}
// 4. Get Uptime
uptimeBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "info.uptime",
"params": map[string]interface{}{},
}
uptimeJson, _ := json.Marshal(uptimeBody)
reqUptime, _ := http.NewRequestWithContext(ctx, "POST", versionURL, bytes.NewBuffer(uptimeJson))
reqUptime.Header.Set("Content-Type", "application/json")
if respUptime, err := client.Do(reqUptime); err == nil {
defer respUptime.Body.Close()
var r map[string]interface{}
if err := json.NewDecoder(respUptime.Body).Decode(&r); err == nil {
if result, ok := r["result"].(map[string]interface{}); ok {
if uptime, ok := result["rewardingStakePercentage"].(float64); ok {
node.Uptime = fmt.Sprintf("%.1f%%", uptime*100)
}
}
}
}
// 5. Check GPU acceleration (via health check or custom endpoint)
healthURL := route.Health(node.HTTPURL)
healthReq, _ := http.NewRequestWithContext(ctx, "GET", healthURL, nil)
if healthResp, err := client.Do(healthReq); err == nil {
defer healthResp.Body.Close()
var r map[string]interface{}
if err := json.NewDecoder(healthResp.Body).Decode(&r); err == nil {
// Check for GPU-related info in health response
if checks, ok := r["checks"].(map[string]interface{}); ok {
if gpuCheck, ok := checks["gpu"].(map[string]interface{}); ok {
if msg, ok := gpuCheck["message"].(map[string]interface{}); ok {
if device, ok := msg["device"].(string); ok {
node.GPUDevice = device
node.GPUAccelerated = true
}
if driver, ok := msg["driver"].(string); ok {
node.GPUDriverVersion = driver
}
}
}
}
}
}
// 6. Get validator addresses if this node is a validator
if node.NodeID != "" {
// Query platform.getCurrentValidators to get validator address
validatorsBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "platform.getCurrentValidators",
"params": map[string]interface{}{
"nodeIDs": []string{node.NodeID},
},
}
validatorsJson, _ := json.Marshal(validatorsBody)
pChainURL := route.Chain(node.HTTPURL, "P")
reqValidators, _ := http.NewRequestWithContext(ctx, "POST", pChainURL, bytes.NewBuffer(validatorsJson))
reqValidators.Header.Set("Content-Type", "application/json")
if respValidators, err := client.Do(reqValidators); err == nil {
defer respValidators.Body.Close()
var r map[string]interface{}
if err := json.NewDecoder(respValidators.Body).Decode(&r); err == nil {
if result, ok := r["result"].(map[string]interface{}); ok {
if validators, ok := result["validators"].([]interface{}); ok && len(validators) > 0 {
if validator, ok := validators[0].(map[string]interface{}); ok {
// Get validationRewardOwner address (P-chain address)
if rewardOwner, ok := validator["validationRewardOwner"].(map[string]interface{}); ok {
if addrs, ok := rewardOwner["addresses"].([]interface{}); ok && len(addrs) > 0 {
if addr, ok := addrs[0].(string); ok {
// Address format: "11111111111111111111111111111111P-lux1..." or "...P-test1..."
// Extract just the P-... part
if idx := strings.Index(addr, "P-lux"); idx >= 0 {
node.PChainAddress = addr[idx:]
node.XChainAddress = "X-lux" + strings.TrimPrefix(addr[idx:], "P-lux")
} else if idx := strings.Index(addr, "P-test"); idx >= 0 {
node.PChainAddress = addr[idx:]
node.XChainAddress = "X-test" + strings.TrimPrefix(addr[idx:], "P-test")
} else {
node.PChainAddress = addr
}
}
}
}
}
}
}
}
}
// 7. Get C-chain address (derive from nodeID or check if node exposes it)
// C-chain addresses are Ethereum-style (0x...) and derived differently
// For now, try to get it from the node's keystore if available
cChainBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_accounts",
"params": []interface{}{},
}
cChainJson, _ := json.Marshal(cChainBody)
cChainURL := route.Chain(node.HTTPURL, "C") + "/rpc"
reqCChain, _ := http.NewRequestWithContext(ctx, "POST", cChainURL, bytes.NewBuffer(cChainJson))
reqCChain.Header.Set("Content-Type", "application/json")
if respCChain, err := client.Do(reqCChain); err == nil {
defer respCChain.Body.Close()
var r map[string]interface{}
if err := json.NewDecoder(respCChain.Body).Decode(&r); err == nil {
if accounts, ok := r["result"].([]interface{}); ok && len(accounts) > 0 {
if addr, ok := accounts[0].(string); ok {
node.CChainAddress = addr
}
}
}
}
}
node.LatencyMS = int(time.Since(startTime).Milliseconds())
node.OK = true
return &node, nil
}
// probeChains probes all chains for a network
func (s *StatusService) probeChains(ctx context.Context, network Network) ([]ChainStatus, error) {
// Get all chain endpoints for this network
endpoints, err := s.getChainEndpoints(network)
if err != nil {
return nil, fmt.Errorf("failed to get chain endpoints: %w", err)
}
// Create semaphore for chain probing
sem := semaphore.NewWeighted(int64(s.concurrencyLimit))
errGroup, ctx := errgroup.WithContext(ctx)
var mu sync.Mutex
probedChains := make([]ChainStatus, len(endpoints))
for i, endpoint := range endpoints {
i := i
endpoint := endpoint
errGroup.Go(func() error {
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1)
probedChain, err := s.probeChainEndpoint(ctx, endpoint)
if err != nil {
// Store error but don't fail the whole operation
probedChain := ChainStatus{
Alias: endpoint.ChainAlias,
Kind: "unknown",
RPC_OK: false,
LastError: fmt.Sprintf("failed to probe: %v", err),
}
mu.Lock()
probedChains[i] = probedChain
mu.Unlock()
return nil
}
mu.Lock()
probedChains[i] = *probedChain
mu.Unlock()
return nil
})
}
if err := errGroup.Wait(); err != nil {
return nil, fmt.Errorf("failed to probe chain endpoints: %w", err)
}
return probedChains, nil
}
// probeChainEndpoint probes a single chain endpoint
func (s *StatusService) probeChainEndpoint(ctx context.Context, endpoint EndpointStatus) (*ChainStatus, error) {
startTime := time.Now()
// Get resolver for this chain
resolver := GetResolverForChain(endpoint.ChainAlias)
// Probe the endpoint
height, meta, err := resolver.Height(ctx, endpoint.URL)
latencyMS := int(time.Since(startTime).Milliseconds())
if err != nil {
return &ChainStatus{
Alias: endpoint.ChainAlias,
Kind: resolver.Kind(),
RPC_OK: false,
LatencyMS: latencyMS,
LastError: err.Error(),
Metadata: meta,
}, nil
}
// Extract metadata
chainStatus := ChainStatus{
Alias: endpoint.ChainAlias,
Kind: resolver.Kind(),
Height: height,
RPC_OK: true,
LatencyMS: latencyMS,
Metadata: meta,
}
// Extract chain ID if available
if chainID, ok := meta["chain_id"].(uint64); ok {
chainStatus.ChainID = fmt.Sprintf("%d", chainID)
}
// Extract syncing status if available
if syncing, ok := meta["syncing"]; ok {
chainStatus.Syncing = syncing
}
return &chainStatus, nil
}
// getNetworkConfigurations returns the network configurations
func (s *StatusService) getNetworkConfigurations() ([]Network, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
luxDir := filepath.Join(home, ".lux")
// Define all known network types that should be tracked
knownNetworks := []string{"mainnet", "testnet", "devnet", "custom"}
// Find all network state files
matches, err := filepath.Glob(filepath.Join(luxDir, "*_network_state.json"))
if err != nil {
return nil, fmt.Errorf("failed to glob network state files: %w", err)
}
var networks []Network
foundNetworks := make(map[string]bool)
// First, process any existing network state files
for _, match := range matches {
data, err := os.ReadFile(match)
if err != nil {
continue
}
type ValidatorInfo struct {
Index int `json:"index"`
NodeID string `json:"nodeID"`
PChainAddress string `json:"pChainAddress"`
XChainAddress string `json:"xChainAddress"`
CChainAddress string `json:"cChainAddress"`
}
type ActiveAccountInfo struct {
Index int `json:"index"`
PChainAddress string `json:"pChainAddress"`
XChainAddress string `json:"xChainAddress"`
CChainAddress string `json:"cChainAddress"`
}
type NetworkState struct {
NetworkType string `json:"network_type"`
PortBase int `json:"port_base"`
GRPCPort int `json:"grpc_port"`
Running bool `json:"running"`
ApiEndpoint string `json:"api_endpoint"`
Validators []ValidatorInfo `json:"validators"`
ActiveAccount *ActiveAccountInfo `json:"active_account"`
}
var state NetworkState
if err := json.Unmarshal(data, &state); err != nil {
continue // Skip invalid JSON
}
foundNetworks[state.NetworkType] = true
if !state.Running {
// Include stopped networks but mark them
networks = append(networks, Network{
Name: state.NetworkType,
Metadata: NetworkMetadata{
Status: "stopped",
},
})
continue
}
// Discover nodes for this network by checking the runs directory first
runDirPattern := filepath.Join(luxDir, "runs", state.NetworkType, "run_*")
runDirs, err := filepath.Glob(runDirPattern)
if err != nil {
runDirs = []string{}
}
var nodeDirs []string
if len(runDirs) > 0 {
// Use the most recent run directory to discover nodes
latestRunDir := ""
var latestModTime time.Time
for _, runDir := range runDirs {
if info, err := os.Stat(runDir); err == nil {
if info.ModTime().After(latestModTime) {
latestModTime = info.ModTime()
latestRunDir = runDir
}
}
}
if latestRunDir != "" {
nodeDirs, _ = filepath.Glob(filepath.Join(latestRunDir, "node*"))
}
} else {
// Fallback to the old networks directory if no runs directory exists
networkDir := filepath.Join(luxDir, "networks", state.NetworkType)
nodeDirs, _ = filepath.Glob(filepath.Join(networkDir, "node*"))
}
// Limit discovered node dirs to the validator count from state file.
// Stale directories from previous runs with more nodes must be ignored.
maxNodes := len(nodeDirs)
if len(state.Validators) > 0 && len(state.Validators) < maxNodes {
maxNodes = len(state.Validators)
}
var nodes []Node
for _, nodeDir := range nodeDirs[:maxNodes] {
nodeName := filepath.Base(nodeDir)
nodeID := strings.TrimPrefix(nodeName, "node")
// Try to read process.json
procPath := filepath.Join(nodeDir, "process.json")
procData, err := os.ReadFile(procPath)
uri := ""
if err == nil {
var proc struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(procData, &proc); err == nil {
uri = proc.URI
}
}
// Fallback if process.json is missing or invalid
if uri == "" {
idx, _ := strconv.Atoi(nodeID)
if idx > 0 {
apiPort := state.PortBase + ((idx - 1) * 2)
uri = fmt.Sprintf("http://127.0.0.1:%d", apiPort)
}
}
if uri != "" {
nodes = append(nodes, Node{
ID: nodeID,
HTTPURL: uri,
})
}
}
// Handle single-node networks (like devnet) where node directories might not exist
if len(nodes) == 0 && state.ApiEndpoint != "" {
nodes = append(nodes, Node{
ID: "1",
HTTPURL: state.ApiEndpoint,
})
} else if len(nodes) == 0 && state.PortBase > 0 {
// Fallback to PortBase if API endpoint is missing
nodes = append(nodes, Node{
ID: "1",
HTTPURL: fmt.Sprintf("http://127.0.0.1:%d", state.PortBase),
})
}
// Get gRPC port from constants if not set in state
grpcPort := state.GRPCPort
if grpcPort == 0 {
ports := constants.GetGRPCPorts(state.NetworkType)
grpcPort = ports.Server
}
// Convert validators from state to status model
var validators []ValidatorAccount
for _, v := range state.Validators {
validators = append(validators, ValidatorAccount{
Index: v.Index,
NodeID: v.NodeID,
PChainAddress: v.PChainAddress,
XChainAddress: v.XChainAddress,
CChainAddress: v.CChainAddress,
})
}
// Convert active account
var activeAccount *ActiveAccount
if state.ActiveAccount != nil {
activeAccount = &ActiveAccount{
Index: state.ActiveAccount.Index,
PChainAddress: state.ActiveAccount.PChainAddress,
XChainAddress: state.ActiveAccount.XChainAddress,
CChainAddress: state.ActiveAccount.CChainAddress,
}
}
networks = append(networks, Network{
Name: state.NetworkType,
Nodes: nodes,
Validators: validators,
ActiveAccount: activeAccount,
Metadata: NetworkMetadata{
GRPCPort: grpcPort,
NodesCount: len(nodes),
VMsCount: 1, // Placeholder until probed
Controller: "on",
Status: (func() string {
if state.Running {
return "up"
}
return "stopped"
}()),
},
})
}
// Add any known networks that weren't found in state files (they might be stopped)
for _, netType := range knownNetworks {
if !foundNetworks[netType] {
// Check if this network has any runtime data
networkDir := filepath.Join(luxDir, "networks", netType)
if _, err := os.Stat(networkDir); err == nil {
// Network directory exists but no state file - mark as stopped
networks = append(networks, Network{
Name: netType,
Metadata: NetworkMetadata{
Status: "stopped",
},
})
}
}
}
if len(networks) == 0 {
return nil, ErrNoNetwork
}
return networks, nil
}
// getChainEndpoints returns the chain endpoints for a network
func (s *StatusService) getChainEndpoints(network Network) ([]EndpointStatus, error) {
if len(network.Nodes) == 0 {
return nil, ErrNoNetwork
}
baseURL := network.Nodes[0].HTTPURL
// Try to discover actual chain endpoints from the node
endpoints, err := s.discoverChainEndpointsFromNode(baseURL)
if err != nil {
// Fallback to all native chains if discovery fails
endpoints = s.getAllNativeChainEndpoints(baseURL)
}
return endpoints, nil
}
// getAllNativeChainEndpoints returns endpoints for all native Lux chains
// P-chain and X-chain use JSON-RPC directly (no /rpc suffix)
// EVM chains (C, Q, A, B, T, Z, G, K, D) use /rpc suffix
func (s *StatusService) getAllNativeChainEndpoints(baseURL string) []EndpointStatus {
return []EndpointStatus{
{ChainAlias: "p", URL: route.Chain(baseURL, "P")}, // Platform chain (JSON-RPC)
{ChainAlias: "x", URL: route.Chain(baseURL, "X")}, // Exchange chain (JSON-RPC)
{ChainAlias: "c", URL: route.Chain(baseURL, "C") + "/rpc"}, // Coreth (EVM)
{ChainAlias: "q", URL: route.Chain(baseURL, "Q") + "/rpc"}, // Quantum (EVM)
{ChainAlias: "a", URL: route.Chain(baseURL, "A") + "/rpc"}, // AI (EVM)
{ChainAlias: "b", URL: route.Chain(baseURL, "B") + "/rpc"}, // Bridge (EVM)
{ChainAlias: "t", URL: route.Chain(baseURL, "T") + "/rpc"}, // Threshold (EVM)
{ChainAlias: "z", URL: route.Chain(baseURL, "Z") + "/rpc"}, // ZK (EVM)
{ChainAlias: "g", URL: route.Chain(baseURL, "G") + "/rpc"}, // Graph (EVM)
{ChainAlias: "k", URL: route.Chain(baseURL, "K") + "/rpc"}, // KMS (EVM)
{ChainAlias: "d", URL: route.Chain(baseURL, "D") + "/rpc"}, // DEX (EVM)
}
}
// discoverChainEndpointsFromNode attempts to discover all available chain endpoints
func (s *StatusService) discoverChainEndpointsFromNode(baseURL string) ([]EndpointStatus, error) {
// Create HTTP client with timeout
client := &http.Client{
Timeout: 3 * time.Second,
}
// Build the request URL for platform.getBlockchains
requestURL := route.Chain(baseURL, "P") + "/rpc"
// Create JSON-RPC request
requestBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "platform.getBlockchains",
"params": map[string]interface{}{},
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// Parse response
var responseMap map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&responseMap); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Extract blockchains from response
var endpoints []EndpointStatus
if result, ok := responseMap["result"].(map[string]interface{}); ok {
if blockchains, ok := result["blockchains"].([]interface{}); ok {
for _, bc := range blockchains {
if blockchain, ok := bc.(map[string]interface{}); ok {
if id, ok := blockchain["id"].(string); ok {
// Map blockchain ID to chain alias
chainAlias := s.mapBlockchainIDToAlias(id)
if chainAlias != "" {
url := route.Chain(baseURL, id)
// Special case for C-Chain (EVM) which uses /rpc endpoint
if chainAlias == "c" {
url = route.Chain(baseURL, "C") + "/rpc"
}
endpoints = append(endpoints, EndpointStatus{
ChainAlias: chainAlias,
URL: url,
})
}
}
}
}
}
}
// Always include all native chains if not found
foundChains := make(map[string]bool)
for _, ep := range endpoints {
foundChains[ep.ChainAlias] = true
}
// Add all native chains that weren't discovered
nativeChains := s.getAllNativeChainEndpoints(baseURL)
for _, nc := range nativeChains {
if !foundChains[nc.ChainAlias] {
endpoints = append(endpoints, nc)
}
}
return endpoints, nil
}
// mapBlockchainIDToAlias maps blockchain IDs to chain aliases
func (s *StatusService) mapBlockchainIDToAlias(blockchainID string) string {
// Standard Lux blockchain IDs
switch blockchainID {
case "P":
return "p"
case "X":
return "x"
case "C":
return "c"
case "2ebCneCbwthjQ1rYT41nhd7M76Hc6YmosMAQrTFhBq8SvgU1s": // Mainnet C-Chain
return "c"
case "2oYMBNV4eNHyqk2fjjV5nP2rB8kJLnN57D7D77D7D7D7D7D7D": // Testnet C-Chain
return "c"
case "yH8D7ThNJkxmtkuv2jgBa4P1Rn3Qpr4pPrRJU1J1U1J1U1J1J": // Devnet C-Chain
return "c"
default:
// Custom chains - use first few characters as alias
if len(blockchainID) >= 4 {
return blockchainID[:4]
}
return blockchainID
}
}
// QueryPChainBalance queries the P-chain balance for an address
func (s *StatusService) QueryPChainBalance(ctx context.Context, baseURL, address string) (uint64, error) {
client := &http.Client{Timeout: 3 * time.Second}
requestURL := route.Chain(baseURL, "P")
requestBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "platform.getBalance",
"params": map[string]interface{}{
"addresses": []string{address},
},
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var responseMap map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&responseMap); err != nil {
return 0, err
}
if result, ok := responseMap["result"].(map[string]interface{}); ok {
if balanceStr, ok := result["balance"].(string); ok {
balance, err := strconv.ParseUint(balanceStr, 10, 64)
if err != nil {
return 0, err
}
return balance, nil
}
}
return 0, fmt.Errorf("failed to parse P-chain balance response")
}
// QueryXChainBalance queries the X-chain balance for an address
func (s *StatusService) QueryXChainBalance(ctx context.Context, baseURL, address string) (uint64, error) {
client := &http.Client{Timeout: 3 * time.Second}
requestURL := route.Chain(baseURL, "X")
requestBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "avm.getBalance",
"params": map[string]interface{}{
"address": address,
"assetID": "LUX",
},
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var responseMap map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&responseMap); err != nil {
return 0, err
}
if result, ok := responseMap["result"].(map[string]interface{}); ok {
if balanceStr, ok := result["balance"].(string); ok {
balance, err := strconv.ParseUint(balanceStr, 10, 64)
if err != nil {
return 0, err
}
return balance, nil
}
}
return 0, fmt.Errorf("failed to parse X-chain balance response")
}
// QueryCChainBalance queries the C-chain balance for an address (0x format)
func (s *StatusService) QueryCChainBalance(ctx context.Context, baseURL, address string) (string, error) {
client := &http.Client{Timeout: 3 * time.Second}
requestURL := route.Chain(baseURL, "C") + "/rpc"
requestBody := map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getBalance",
"params": []interface{}{address, "latest"},
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var responseMap map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&responseMap); err != nil {
return "", err
}
if result, ok := responseMap["result"].(string); ok {
return result, nil // Returns hex string like "0x1234..."
}
return "", fmt.Errorf("failed to parse C-chain balance response")
}
// FormatCChainBalanceLUX converts C-chain balance (wei hex) to human-readable LUX
func FormatCChainBalanceLUX(weiHex string) string {
// Remove 0x prefix
weiHex = strings.TrimPrefix(weiHex, "0x")
if weiHex == "" || weiHex == "0" {
return "0 LUX"
}
// Parse as big int
wei := new(big.Int)
wei.SetString(weiHex, 16)
// 1 LUX = 10^18 wei
divisor := new(big.Int)
divisor.SetString("1000000000000000000", 10)
// Calculate whole LUX and remainder
luxWhole := new(big.Int).Div(wei, divisor)
remainder := new(big.Int).Mod(wei, divisor)
// Format with decimals if there's a remainder
if remainder.Cmp(big.NewInt(0)) == 0 {
return fmt.Sprintf("%s LUX", luxWhole.String())
}
// Show up to 4 decimal places
remainderStr := fmt.Sprintf("%018s", remainder.String())
remainderStr = strings.TrimRight(remainderStr[:4], "0")
if remainderStr == "" {
return fmt.Sprintf("%s LUX", luxWhole.String())
}
return fmt.Sprintf("%s.%s LUX", luxWhole.String(), remainderStr)
}
// FormatNLUXToLUX converts nLUX (nanoLUX) to human-readable LUX
func FormatNLUXToLUX(nLUX uint64) string {
if nLUX == 0 {
return "0 LUX"
}
// 1 LUX = 10^9 nLUX
luxWhole := nLUX / 1_000_000_000
remainder := nLUX % 1_000_000_000
if remainder == 0 {
return fmt.Sprintf("%d LUX", luxWhole)
}
// Show up to 4 decimal places
remainderStr := fmt.Sprintf("%09d", remainder)
remainderStr = strings.TrimRight(remainderStr[:4], "0")
if remainderStr == "" {
return fmt.Sprintf("%d LUX", luxWhole)
}
return fmt.Sprintf("%d.%s LUX", luxWhole, remainderStr)
}