Files
zeekayandHanzo Dev 27979a225c The wallet has no C-Chain, so stop asking it for one
The SDK's primary wallet no longer builds a C-Chain wallet, and every
caller here was carrying scaffolding for the one it used to build: an
emptyEVMKeychain that answers nothing, passed as the EVMKeychain of a
wallet that never consults it, and in one case a nil.

rpc transfer went further and reached around the wallet, building
C-Chain atomic import and export transactions by hand against
lux.issueTx and polling lux.getAtomicTxStatus. Value on the C-Chain
moves by an ordinary EVM transaction against /v1/bc/C/rpc; an atomic
export from an EVM chain is a different act with a different settlement
path, and this command was not the place for it. transfer now moves
value between P and X, names an unsupported chain in its error, and
reports both the export and the import transaction rather than one.

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

1064 lines
36 KiB
Go

// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package chain provides chain deployment and management utilities.
package chain
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"github.com/luxfi/cli/pkg/application"
"github.com/luxfi/cli/pkg/binutils"
keychainwrapper "github.com/luxfi/cli/pkg/keychain"
climodels "github.com/luxfi/cli/pkg/models"
"github.com/luxfi/cli/pkg/route"
"github.com/luxfi/cli/pkg/utils"
"github.com/luxfi/cli/pkg/ux"
"github.com/luxfi/constants"
"github.com/luxfi/evm/core"
"github.com/luxfi/geth/params"
"github.com/luxfi/ids"
"github.com/luxfi/keychain"
"github.com/luxfi/netrunner/client"
anrnetwork "github.com/luxfi/netrunner/network"
"github.com/luxfi/netrunner/rpcpb"
"github.com/luxfi/netrunner/server"
anrutils "github.com/luxfi/netrunner/utils"
platformapi "github.com/luxfi/proto/p/api"
"github.com/luxfi/proto/p/reward"
"github.com/luxfi/proto/p/signer"
"github.com/luxfi/proto/p/txs"
"github.com/luxfi/sdk/models"
"github.com/luxfi/sdk/platformvm"
"github.com/luxfi/sdk/wallet/primary"
lux "github.com/luxfi/utxo"
"github.com/luxfi/utxo/secp256k1fx"
"github.com/luxfi/vm/components/verify"
)
// Chain deployment constants.
const (
WriteReadReadPerms = 0o644
// ChainHealthTimeout is the maximum time to wait for a newly deployed chain to become healthy
// For local networks (3 nodes on localhost), chains should be healthy in <10s
ChainHealthTimeout = 10 * time.Second
// LocalNetworkHealthTimeout is for checking if the network itself is running
LocalNetworkHealthTimeout = 5 * time.Second
// BlockchainCreationTimeout is the maximum time to wait for CreateChains RPC call
// This involves a P-chain transaction, chain creation, chain creation, node restarts,
// and P-chain height sync across all 3 validators. Needs 90s minimum for stability.
BlockchainCreationTimeout = 90 * time.Second
)
// DeploymentError represents a chain deployment failure that does NOT crash the network.
// The network remains running and can accept new deployments.
type DeploymentError struct {
ChainName string
Cause error
// NetworkHealthy indicates if the primary network is still running after the failure
NetworkHealthy bool
// Recoverable indicates if the error can be fixed and retried
Recoverable bool
// Suggestion provides actionable guidance to fix the issue
Suggestion string
}
func (e *DeploymentError) Error() string {
status := "network crashed"
if e.NetworkHealthy {
status = "network still running"
}
msg := fmt.Sprintf("chain '%s' deployment failed (%s): %v", e.ChainName, status, e.Cause)
if e.Suggestion != "" {
msg += "\n\nTo fix: " + e.Suggestion
}
return msg
}
func (e *DeploymentError) Unwrap() error {
return e.Cause
}
// NewRecoverableDeploymentError creates a deployment error that can be retried
func NewRecoverableDeploymentError(chainName string, cause error, suggestion string) *DeploymentError {
return &DeploymentError{
ChainName: chainName,
Cause: cause,
NetworkHealthy: true, // Recoverable errors shouldn't crash the network
Recoverable: true,
Suggestion: suggestion,
}
}
// LocalDeployer handles local chain deployment.
type LocalDeployer struct {
procChecker binutils.ProcessChecker
binChecker binutils.BinaryChecker
getClientFunc getGRPCClientFunc
binaryDownloader binutils.PluginBinaryDownloader
app *application.Lux
backendStartedHere bool
setDefaultSnapshot setDefaultSnapshotFunc
luxVersion string
vmBin string
networkType string // "mainnet", "testnet", or "local"
}
// NewLocalDeployer creates a new LocalDeployer instance.
func NewLocalDeployer(app *application.Lux, luxVersion string, vmBin string) *LocalDeployer {
return &LocalDeployer{
procChecker: binutils.NewProcessChecker(),
binChecker: binutils.NewBinaryChecker(),
getClientFunc: binutils.NewGRPCClient,
binaryDownloader: binutils.NewPluginBinaryDownloader(app),
app: app,
setDefaultSnapshot: SetDefaultSnapshot,
luxVersion: luxVersion,
vmBin: vmBin,
networkType: "", // Auto-detect from network state
}
}
// NewLocalDeployerForNetwork creates a deployer for a specific network type
func NewLocalDeployerForNetwork(app *application.Lux, luxVersion, vmBin, networkType string) *LocalDeployer {
d := NewLocalDeployer(app, luxVersion, vmBin)
d.networkType = networkType
// Use network-aware gRPC client
d.getClientFunc = func(opts ...binutils.GRPCClientOpOption) (client.Client, error) {
opts = append(opts, binutils.WithNetworkType(networkType))
return binutils.NewGRPCClient(opts...)
}
return d
}
type getGRPCClientFunc func(...binutils.GRPCClientOpOption) (client.Client, error)
type setDefaultSnapshotFunc func(string, bool) error
// DeployToLocalNetwork deploys to an already running network.
// It does NOT start the network - use 'lux network start' first.
func (d *LocalDeployer) DeployToLocalNetwork(chain string, chainGenesis []byte, genesisPath string) (ids.ID, ids.ID, error) {
// Create step tracker that warns after 5 seconds
tracker := ux.NewStepTracker(ux.Logger, 5*time.Second)
// Connect to existing gRPC server - do NOT start one
tracker.Start("Connecting to network")
cli, err := d.getClientFunc()
if err != nil {
tracker.Failed("connection failed")
return ids.Empty, ids.Empty, fmt.Errorf("failed to connect to network. Is it running? Start with: lux network start --mainnet\nError: %w", err)
}
defer func() { _ = cli.Close() }()
tracker.Complete("")
// Quick health check with short timeout for local network (5s max)
ctx, cancel := context.WithTimeout(context.Background(), LocalNetworkHealthTimeout)
defer cancel()
tracker.Start("Checking network health")
_, err = WaitForHealthy(ctx, cli)
if err != nil {
if server.IsServerError(err, server.ErrNotBootstrapped) {
tracker.Failed("network not running")
return ids.Empty, ids.Empty, fmt.Errorf("network is not running. Start it first with: lux network start --mainnet")
}
tracker.Failed(err.Error())
return ids.Empty, ids.Empty, fmt.Errorf("network is unhealthy: %w", err)
}
tracker.CompleteSuccess()
return d.doDeploy(chain, chainGenesis, genesisPath)
}
func getAssetID(wallet primary.Wallet, ownerAddr ids.ShortID, tokenName string, tokenSymbol string, maxSupply uint64) (ids.ID, error) {
xWallet := wallet.X()
owner := &secp256k1fx.OutputOwners{
Threshold: 1,
Addrs: []ids.ShortID{
ownerAddr,
},
}
_, cancel := context.WithTimeout(context.Background(), constants.DefaultWalletCreationTimeout)
chainAssetTx, err := xWallet.IssueCreateAssetTx(
tokenName,
tokenSymbol,
9, // denomination for UI purposes only in explorer
map[uint32][]verify.State{
0: {
&secp256k1fx.TransferOutput{
Amt: maxSupply,
OutputOwners: *owner,
},
},
},
)
defer cancel()
if err != nil {
return ids.Empty, err
}
return chainAssetTx.ID(), nil
}
func exportToPChain(wallet primary.Wallet, owner *secp256k1fx.OutputOwners, chainAssetID ids.ID, maxSupply uint64) error {
xWallet := wallet.X()
_, cancel := context.WithTimeout(context.Background(), constants.DefaultWalletCreationTimeout)
_, err := xWallet.IssueExportTx(
ids.Empty,
[]*lux.TransferableOutput{
{
Asset: lux.Asset{
ID: chainAssetID,
},
Out: &secp256k1fx.TransferOutput{
Amt: maxSupply,
OutputOwners: *owner,
},
},
},
)
defer cancel()
return err
}
func importFromXChain(wallet primary.Wallet, owner *secp256k1fx.OutputOwners) error {
pWallet := wallet.P()
xChainID := ids.FromStringOrPanic("2oYMBNV4eNHyqk2fjjV5nVQLDbtmNJzq5s3qs3Lo6ftnC6FByM") // X-Chain ID
_, cancel := context.WithTimeout(context.Background(), constants.DefaultWalletCreationTimeout)
_, err := pWallet.IssueImportTx(
xChainID,
owner,
)
defer cancel()
return err
}
// IssueTransformChainTx transforms a chain to a permissionless elastic chain.
func IssueTransformChainTx(
elasticChainConfig climodels.ElasticChainConfig,
kc keychain.Keychain,
_ ids.ID, // chainID comes from elasticChainConfig
tokenName string,
tokenSymbol string,
maxSupply uint64,
) (ids.ID, ids.ID, error) {
ctx := context.Background()
api := constants.LocalAPIEndpoint
wallet, err := primary.MakeWallet(ctx, &primary.WalletConfig{
URI: api,
LUXKeychain: keychainwrapper.WrapCryptoKeychain(kc),
})
if err != nil {
return ids.Empty, ids.Empty, err
}
// Get the first address from the keychain for ownership
addrs := kc.Addresses()
if addrs.Len() == 0 {
return ids.Empty, ids.Empty, errors.New("keychain has no addresses")
}
ownerAddr := addrs.List()[0]
chainAssetID, err := getAssetID(wallet, ownerAddr, tokenName, tokenSymbol, maxSupply)
if err != nil {
return ids.Empty, ids.Empty, err
}
owner := &secp256k1fx.OutputOwners{
Threshold: 1,
Addrs: []ids.ShortID{
ownerAddr,
},
}
err = exportToPChain(wallet, owner, chainAssetID, maxSupply)
if err != nil {
return ids.Empty, ids.Empty, err
}
err = importFromXChain(wallet, owner)
if err != nil {
return ids.Empty, ids.Empty, err
}
_, cancel := context.WithTimeout(context.Background(), constants.DefaultConfirmTxTimeout)
transformChainTxID, err := wallet.P().IssueTransformChainTx(elasticChainConfig.ChainID, chainAssetID,
elasticChainConfig.InitialSupply, elasticChainConfig.MaxSupply, elasticChainConfig.MinConsumptionRate,
elasticChainConfig.MaxConsumptionRate, elasticChainConfig.MinValidatorStake, elasticChainConfig.MaxValidatorStake,
elasticChainConfig.MinStakeDuration, elasticChainConfig.MaxStakeDuration, elasticChainConfig.MinDelegationFee,
elasticChainConfig.MinDelegatorStake, elasticChainConfig.MaxValidatorWeightFactor, elasticChainConfig.UptimeRequirement,
)
defer cancel()
if err != nil {
return ids.Empty, ids.Empty, err
}
return transformChainTxID.ID(), chainAssetID, err
}
// IssueAddPermissionlessValidatorTx issues an add permissionless validator transaction.
func IssueAddPermissionlessValidatorTx(
kc keychain.Keychain,
chainID ids.ID,
nodeID ids.NodeID,
stakeAmount uint64,
assetID ids.ID,
startTime uint64,
endTime uint64,
) (ids.ID, error) {
ctx := context.Background()
api := constants.LocalAPIEndpoint
// Use P-Chain only wallet since our X-Chain uses exchangevm which doesn't
// support standard XVM API methods.
wallet, err := primary.MakePChainWallet(ctx, &primary.WalletConfig{
URI: api,
LUXKeychain: keychainwrapper.WrapCryptoKeychain(kc),
})
if err != nil {
return ids.Empty, err
}
// Get the first address from the keychain for ownership
addrs := kc.Addresses()
if addrs.Len() == 0 {
return ids.Empty, errors.New("keychain has no addresses")
}
ownerAddr := addrs.List()[0]
owner := &secp256k1fx.OutputOwners{
Threshold: 1,
Addrs: []ids.ShortID{
ownerAddr,
},
}
_, cancel := context.WithTimeout(context.Background(), constants.DefaultConfirmTxTimeout)
txID, err := wallet.P().IssueAddPermissionlessValidatorTx(
&txs.ChainValidator{
Validator: txs.Validator{
NodeID: nodeID,
Start: startTime,
End: endTime,
Wght: stakeAmount,
},
Chain: chainID,
},
&signer.Empty{},
assetID,
owner,
&secp256k1fx.OutputOwners{},
reward.PercentDenominator,
)
defer cancel()
if err != nil {
return ids.Empty, err
}
return txID.ID(), err
}
// StartServer starts the gRPC server for the deployer's network type.
// If no network type is set, defaults to mainnet for backward compatibility.
func (d *LocalDeployer) StartServer() error {
networkType := d.networkType
if networkType == "" {
networkType = "mainnet" // Default for backward compatibility
}
return d.StartServerForNetwork(networkType)
}
// StartServerForNetwork starts the gRPC server for a specific network type.
func (d *LocalDeployer) StartServerForNetwork(networkType string) error {
isRunning, err := binutils.IsServerProcessRunningForNetwork(d.app, networkType)
if err != nil {
return fmt.Errorf("failed querying if server process is running: %w", err)
}
if !isRunning {
d.app.Log.Debug("gRPC server is not running", "network", networkType)
if err := binutils.StartServerProcessForNetwork(d.app, networkType); err != nil {
return fmt.Errorf("failed starting gRPC server for %s: %w", networkType, err)
}
d.backendStartedHere = true
}
return nil
}
// GetCurrentSupply prints the current supply of a chain.
func GetCurrentSupply(chainID ids.ID) error {
api := constants.LocalAPIEndpoint
pClient := platformvm.NewClient(api)
ctx, cancel := context.WithTimeout(context.Background(), constants.E2ERequestTimeout)
defer cancel()
_, _, err := pClient.GetCurrentSupply(ctx, chainID)
return err
}
// BackendStartedHere returns true if the backend was started by this run,
// or false if it found it there already
func (d *LocalDeployer) BackendStartedHere() bool {
return d.backendStartedHere
}
// DeployBlockchain deploys a blockchain to the local network
func (d *LocalDeployer) DeployBlockchain(chain string, chainGenesis []byte) (ids.ID, ids.ID, error) {
// For local deployment, we just call the regular deployment function
return d.DeployToLocalNetwork(chain, chainGenesis, "")
}
// doDeploy deploys a blockchain to an already running network.
// Network must already be running - this function only deploys.
// IMPORTANT: This function is designed to NEVER crash the primary network.
// If deployment fails, it returns a DeploymentError but the network continues running.
//
// Steps:
// 1. Preflight validation (VM binary, genesis, config)
// 2. Install VM plugin binary
// 3. Deploy blockchain to network
// 4. Wait for chain health
// 5. Show status
func (d *LocalDeployer) doDeploy(chain string, chainGenesis []byte, genesisPath string) (ids.ID, ids.ID, error) {
// Create step tracker that warns after 5 seconds
tracker := ux.NewStepTracker(ux.Logger, 5*time.Second)
backendLogFile, err := binutils.GetBackendLogFile(d.app)
var backendLogDir string
if err == nil {
backendLogDir = filepath.Dir(backendLogFile)
}
tracker.Start("Connecting to gRPC server")
cli, err := d.getClientFunc()
if err != nil {
tracker.Failed("connection failed")
return ids.Empty, ids.Empty, fmt.Errorf("error creating gRPC Client: %w", err)
}
defer func() { _ = cli.Close() }()
tracker.Complete("")
// loading sidecar before it's needed so we catch any error early
tracker.Start("Loading chain configuration")
sc, err := d.app.LoadSidecar(chain)
if err != nil {
tracker.Failed("config not found")
return ids.Empty, ids.Empty, fmt.Errorf("failed to load sidecar: %w", err)
}
tracker.Complete("")
// Get the actual VM name based on VM type
// The VMID is computed from the VM name, not the chain name
// For EVM chains, we use "Lux EVM" as the VM name
// For custom VMs, we use the chain name
vmName := "Lux EVM" // Default for EVM chains
if sc.VM == models.CustomVM {
vmName = chain // For custom VMs, use chain name
}
// Network must already be running - get cluster info
// Use short timeout for local network health check
healthCtx, healthCancel := context.WithTimeout(context.Background(), LocalNetworkHealthTimeout)
defer healthCancel()
tracker.Start("Verifying network is ready")
clusterInfo, err := WaitForHealthy(healthCtx, cli)
if err != nil {
tracker.Failed("network unhealthy")
return ids.Empty, ids.Empty, fmt.Errorf("network is not healthy: %w", err)
}
tracker.CompleteSuccess()
rootDir := clusterInfo.GetRootDataDir()
chainVMID, err := anrutils.VMID(vmName)
if err != nil {
return ids.Empty, ids.Empty, fmt.Errorf("failed to create VM ID from %s: %w", vmName, err)
}
d.app.Log.Debug("this VM will get ID", "vm-id", chainVMID.String(), "vm-name", vmName)
// Check if this specific chain is already deployed (by chain name, not VM ID)
// Multiple chains can use the same VM (e.g., multiple EVM chains using Lux EVM)
if alreadyDeployedByName(chain, clusterInfo) {
ux.Logger.GreenCheckmarkToUser("Chain %s already deployed", chain)
return ids.Empty, ids.Empty, nil
}
// Each blockchain gets its own chain unless explicitly configured to share one.
// The netrunner will create a new chain for this chain.
// NOTE: Removed the round-robin logic that incorrectly assigned new chains to existing chains.
// Each chain is independent and needs its own chain for proper isolation.
var chainParentID string
d.app.Log.Debug("no chain parent specified, netrunner will create a new chain for this chain")
// if a chainConfig has been configured
var (
chainConfig string
chainConfigFile = filepath.Join(d.app.GetChainsDir(), chain, constants.ChainConfigFile)
perNodeChainConfig string
perNodeChainConfigFile = filepath.Join(d.app.GetChainsDir(), chain, constants.PerNodeChainConfigFileName)
)
if _, err := os.Stat(chainConfigFile); err == nil {
// currently the ANR only accepts the file as a path, not its content
chainConfig = chainConfigFile
}
if _, err := os.Stat(perNodeChainConfigFile); err == nil {
perNodeChainConfig = perNodeChainConfigFile
}
// === PREFLIGHT VALIDATION ===
// Validate VM binary BEFORE installing to catch issues early.
// This prevents deploying a broken VM that would crash nodes.
tracker.Start("Validating VM binary")
if err := d.validateVMBinary(d.vmBin, chainVMID); err != nil {
tracker.Failed("validation failed")
return ids.Empty, ids.Empty, NewRecoverableDeploymentError(
chain,
fmt.Errorf("VM preflight validation failed: %w", err),
"Rebuild the VM binary or check the VM path",
)
}
tracker.CompleteSuccess()
// install the plugin binary for the new VM
tracker.Start("Installing VM plugin")
if err := d.installPlugin(chainVMID, d.vmBin); err != nil {
tracker.Failed("installation failed")
return ids.Empty, ids.Empty, NewRecoverableDeploymentError(
chain,
fmt.Errorf("failed to install VM plugin: %w", err),
"Check plugin directory permissions and disk space",
)
}
tracker.CompleteSuccess()
// Create a new blockchain on the running network
// VmName must be the actual VM name (e.g., "Lux EVM") not the chain name
// This is used by netrunner to compute the VMID
tracker.Start(fmt.Sprintf("Creating blockchain '%s' on P-chain", chain))
spec := &rpcpb.BlockchainSpec{
VmName: vmName,
Genesis: genesisPath,
ChainConfig: chainConfig,
BlockchainAlias: chain,
PerNodeChainConfig: perNodeChainConfig,
}
// Only set ChainId if we have an existing parent
if chainParentID != "" {
spec.ChainId = &chainParentID
}
blockchainSpecs := []*rpcpb.BlockchainSpec{spec}
// Use short timeout for blockchain creation - should complete in <15s on local network
// If it takes longer, the network or VM has a problem
createCtx, createCancel := context.WithTimeout(context.Background(), BlockchainCreationTimeout)
defer createCancel()
// Start a goroutine to check for warnings during long operations
done := make(chan struct{})
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
tracker.CheckWarn()
}
}
}()
deployBlockchainsInfo, err := cli.CreateChains(
createCtx,
blockchainSpecs,
)
close(done) // Stop the warning checker
if err != nil {
tracker.Failed(err.Error())
utils.FindErrorLogs(rootDir, backendLogDir)
// Check if the network is still healthy after the failure
networkHealthy := d.checkNetworkHealthQuick(cli)
// Provide specific error message based on failure type
var errMsg string
if errors.Is(err, context.DeadlineExceeded) {
errMsg = fmt.Sprintf("blockchain creation timed out after %s (limit: %s)", tracker.Elapsed().Round(time.Millisecond), BlockchainCreationTimeout)
} else {
errMsg = fmt.Sprintf("blockchain creation failed after %s: %v", tracker.Elapsed().Round(time.Millisecond), err)
}
return ids.Empty, ids.Empty, &DeploymentError{
ChainName: chain,
Cause: errors.New(errMsg),
NetworkHealthy: networkHealthy,
}
}
tracker.CompleteSuccess()
// Wait for validators to track the chain
tracker.Start("Waiting for validators to track chain")
// Start warning checker for health wait
healthDone := make(chan struct{})
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-healthDone:
return
case <-ticker.C:
tracker.CheckWarn()
}
}
}()
// Quick status check to verify chain is tracked
statusCtx, statusCancel := context.WithTimeout(context.Background(), ChainHealthTimeout)
defer statusCancel()
if statusResp, statusErr := cli.Status(statusCtx); statusErr == nil && statusResp.ClusterInfo != nil {
clusterInfo = statusResp.ClusterInfo
}
close(healthDone)
tracker.CompleteSuccess()
d.app.Log.Debug(fmt.Sprintf("%+v", deployBlockchainsInfo))
fmt.Println()
ux.Logger.GreenCheckmarkToUser("Blockchain deployed successfully")
ux.Logger.PrintToUser("Chain is now available - nodes will sync in background.")
endpoint := GetFirstEndpoint(clusterInfo, chain)
fmt.Println()
ux.Logger.PrintToUser("Network ready to use. Local network node endpoints:")
ux.PrintTableEndpoints(clusterInfo)
fmt.Println()
ux.Logger.PrintToUser("Browser Extension connection details (any node URL from above works):")
if endpoint != "" {
httpIdx := strings.LastIndex(endpoint, "http")
if httpIdx >= 0 {
ux.Logger.PrintToUser("RPC URL: %s", endpoint[httpIdx:])
}
}
if sc.VM == models.EVM {
if err := d.printExtraEvmInfo(chain, chainGenesis); err != nil {
// not supposed to happen due to genesis pre validation
return ids.Empty, ids.Empty, nil
}
}
// Find the blockchain and chain IDs from the cluster info
var chainID, blockchainID ids.ID
for _, info := range clusterInfo.CustomChains {
if info.VmId == chainVMID.String() {
blockchainID, _ = ids.FromString(info.BlockchainId)
chainID, _ = ids.FromString(info.PchainId)
}
}
return chainID, blockchainID, nil
}
func (d *LocalDeployer) printExtraEvmInfo(chain string, chainGenesis []byte) error {
var evmGenesis core.Genesis
if err := json.Unmarshal(chainGenesis, &evmGenesis); err != nil {
return fmt.Errorf("failed to unmarshall genesis: %w", err)
}
for address := range evmGenesis.Alloc {
amount := evmGenesis.Alloc[address].Balance
formattedAmount := new(big.Int).Div(amount, big.NewInt(params.Ether))
ux.Logger.PrintToUser("Funded address: %s with %s", address, formattedAmount.String())
}
ux.Logger.PrintToUser("Network name: %s", chain)
ux.Logger.PrintToUser("Chain ID: %s", evmGenesis.Config.ChainID)
ux.Logger.PrintToUser("Currency Symbol: %s", d.app.GetTokenName(chain))
return nil
}
// SetupLocalEnv also does some heavy lifting:
// * sets up default snapshot if not installed
// * checks if node is installed in the local binary path
// * if not, it downloads it and installs it (os - and archive dependent)
// * returns the location of the node path
func (d *LocalDeployer) SetupLocalEnv() (string, error) {
err := d.setDefaultSnapshot(d.app.GetSnapshotsDir(), false)
if err != nil {
return "", fmt.Errorf("failed setting up snapshots: %w", err)
}
luxDir, err := d.setupLocalEnv()
if err != nil {
return "", fmt.Errorf("failed setting up local environment: %w", err)
}
pluginDir := d.app.GetCurrentPluginsDir()
nodeBinPath := filepath.Join(luxDir, "luxd")
if err := os.MkdirAll(pluginDir, constants.DefaultPerms755); err != nil {
return "", fmt.Errorf("could not create pluginDir %s", pluginDir)
}
if info, err := os.Stat(pluginDir); err != nil || !info.IsDir() {
return "", fmt.Errorf("evaluated pluginDir to be %s but it does not exist", pluginDir)
}
// Version management: compare latest to local version
// and update if necessary based on compatibility requirements
if _, err := os.Stat(nodeBinPath); err != nil {
return "", fmt.Errorf(
"evaluated nodeBinPath to be %s but it does not exist", nodeBinPath)
}
return nodeBinPath, nil
}
func (d *LocalDeployer) setupLocalEnv() (string, error) {
return binutils.SetupLux(d.app, d.luxVersion)
}
// WaitForHealthy polls continuously until the network is ready to be used
func WaitForHealthy(
ctx context.Context,
cli client.Client,
) (*rpcpb.ClusterInfo, error) {
cancel := make(chan struct{})
defer close(cancel)
go ux.PrintWait(cancel)
resp, err := cli.WaitForHealthy(ctx)
if err != nil {
return nil, err
}
return resp.ClusterInfo, nil
}
// WaitForChainHealthyWithTimeout waits for a chain to become healthy with a specific timeout.
// Returns an error with helpful diagnostics if the chain fails to become healthy.
func WaitForChainHealthyWithTimeout(
cli client.Client,
chainName string,
timeout time.Duration,
rootDir string,
backendLogDir string,
) (*rpcpb.ClusterInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cancelPrint := make(chan struct{})
defer close(cancelPrint)
go ux.PrintWait(cancelPrint)
resp, err := cli.WaitForHealthy(ctx)
if err != nil {
// Check if it's a timeout
if errors.Is(err, context.DeadlineExceeded) {
return nil, formatChainHealthError(cli, chainName, timeout, rootDir, backendLogDir)
}
// For other errors, still try to get diagnostic info
if resp != nil && resp.ClusterInfo != nil {
return resp.ClusterInfo, formatChainHealthError(cli, chainName, timeout, rootDir, backendLogDir)
}
return nil, fmt.Errorf("chain health check failed: %w", err)
}
// Even if WaitForHealthy returns without error, verify the chain is actually healthy
if resp.ClusterInfo != nil && !resp.ClusterInfo.CustomChainsHealthy {
return resp.ClusterInfo, formatChainHealthError(cli, chainName, timeout, rootDir, backendLogDir)
}
return resp.ClusterInfo, nil
}
// formatChainHealthError creates a detailed error message when a chain fails to become healthy
func formatChainHealthError(
cli client.Client,
chainName string,
timeout time.Duration,
rootDir string,
backendLogDir string,
) error {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n\nERROR: Chain '%s' failed to become healthy within %s\n\n", chainName, timeout))
// Try to get current health status for more info
healthCtx, healthCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer healthCancel()
healthResp, healthErr := cli.Health(healthCtx)
if healthErr == nil && healthResp != nil && healthResp.ClusterInfo != nil {
sb.WriteString("Node health check shows:\n")
if !healthResp.ClusterInfo.Healthy {
sb.WriteString(" - Network is not healthy\n")
}
if !healthResp.ClusterInfo.CustomChainsHealthy {
sb.WriteString(" - Custom chains are not healthy\n")
}
// Show info about any custom chains that were being deployed
for chainID, chainInfo := range healthResp.ClusterInfo.CustomChains {
sb.WriteString(fmt.Sprintf(" - Chain %s (VM: %s): %s\n", chainID, chainInfo.VmId, chainInfo.ChainName))
}
}
sb.WriteString("\nThe VM likely crashed during initialization. Common causes:\n")
sb.WriteString(" - Invalid genesis configuration\n")
sb.WriteString(" - VM binary incompatibility\n")
sb.WriteString(" - Missing or incorrect chain configuration\n")
sb.WriteString("\nTo debug:\n")
if rootDir != "" {
sb.WriteString(fmt.Sprintf(" tail -f %s/node1/logs/*.log\n", rootDir))
}
if backendLogDir != "" {
sb.WriteString(fmt.Sprintf(" tail -f %s/*.log\n", backendLogDir))
}
sb.WriteString(" lux network status\n")
// Try to find and print relevant error logs
if rootDir != "" || backendLogDir != "" {
utils.FindErrorLogs(rootDir, backendLogDir)
}
return errors.New(sb.String())
}
// GetFirstEndpoint get a human readable endpoint for the given chain
func GetFirstEndpoint(clusterInfo *rpcpb.ClusterInfo, chain string) string {
var endpoint string
for _, nodeInfo := range clusterInfo.NodeInfos {
for blockchainID, chainInfo := range clusterInfo.CustomChains {
if chainInfo.ChainName == chain && nodeInfo.Name == clusterInfo.NodeNames[0] {
endpoint = fmt.Sprintf("Endpoint at node %s for blockchain %q with VM ID %q: %s", nodeInfo.Name, blockchainID, chainInfo.VmId, route.Chain(nodeInfo.GetUri(), blockchainID)+"/rpc")
}
}
}
return endpoint
}
// HasEndpoints returns true if cluster info contains custom blockchains
func HasEndpoints(clusterInfo *rpcpb.ClusterInfo) bool {
return len(clusterInfo.CustomChains) > 0
}
// alreadyDeployedByName returns true if a chain with the given name is already deployed
// This is the correct check for multi-chain deployments using the same VM (e.g., multiple EVM chains)
func alreadyDeployedByName(chainName string, clusterInfo *rpcpb.ClusterInfo) bool {
if clusterInfo != nil {
for _, chainInfo := range clusterInfo.CustomChains {
if chainInfo.ChainName == chainName {
return true
}
}
}
return false
}
// get list of all needed plugins and install them
func (d *LocalDeployer) installPlugin(
vmID ids.ID,
vmBin string,
) error {
return d.binaryDownloader.InstallVM(vmID.String(), vmBin)
}
// checkNetworkHealthQuick performs a fast health check to see if the network is still running.
// This is used after deployment failures to determine if the network crashed.
// Returns true if network appears healthy, false otherwise.
func (d *LocalDeployer) checkNetworkHealthQuick(cli client.Client) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := cli.Health(ctx)
if err != nil {
d.app.Log.Debug("quick health check failed", "error", err)
return false
}
if resp == nil || resp.ClusterInfo == nil {
return false
}
return resp.ClusterInfo.Healthy
}
// validateVMBinary performs preflight checks on a VM binary before deployment.
// This catches common issues that would crash nodes when loading the VM.
// Returns nil if validation passes, error with actionable message otherwise.
func (d *LocalDeployer) validateVMBinary(vmBin string, vmID ids.ID) error {
// Check binary exists
info, err := os.Stat(vmBin)
if os.IsNotExist(err) {
return fmt.Errorf("VM binary not found: %s\n\nTo fix: build or download the VM binary first", vmBin)
}
if err != nil {
return fmt.Errorf("cannot access VM binary %s: %w", vmBin, err)
}
// Check it's a regular file (not directory, symlink, etc)
if !info.Mode().IsRegular() {
// If symlink, check target exists
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(vmBin)
if err != nil {
return fmt.Errorf("VM binary %s is a symlink but cannot read target: %w", vmBin, err)
}
if _, err := os.Stat(target); os.IsNotExist(err) {
return fmt.Errorf("VM binary symlink %s points to missing target: %s\n\nTo fix: rebuild the VM or update the symlink", vmBin, target)
}
}
}
// Check executable permissions
if info.Mode()&0o111 == 0 {
return fmt.Errorf("VM binary %s is not executable\n\nTo fix: chmod +x %s", vmBin, vmBin)
}
// Check minimum file size (VM binaries should be at least a few KB)
const minVMSize = 1024 // 1KB minimum
if info.Size() < minVMSize {
return fmt.Errorf("VM binary %s is too small (%d bytes) - may be corrupted\n\nTo fix: rebuild the VM", vmBin, info.Size())
}
d.app.Log.Debug("VM binary validation passed", "binary", vmBin, "vmid", vmID.String(), "size", info.Size())
return nil
}
// SetDefaultSnapshot initializes the default snapshot with the bootstrap snapshot archive.
// If force flag is set to true, it overwrites the default snapshot if it exists.
func SetDefaultSnapshot(snapshotsDir string, force bool) error {
defaultSnapshotPath := filepath.Join(snapshotsDir, "anr-snapshot-"+constants.DefaultSnapshotName)
if force {
if err := os.RemoveAll(defaultSnapshotPath); err != nil {
return fmt.Errorf("failed removing default snapshot: %w", err)
}
}
// Always create a fresh snapshot with embedded genesis from netrunner
// This avoids downloading potentially corrupted snapshots from GitHub
if _, err := os.Stat(defaultSnapshotPath); os.IsNotExist(err) {
if err := os.MkdirAll(defaultSnapshotPath, 0o750); err != nil {
return fmt.Errorf("failed creating snapshot directory: %w", err)
}
// Create network.json with embedded genesis from netrunner
genesis, err := anrnetwork.LoadLocalGenesis()
if err != nil {
return fmt.Errorf("failed loading local genesis: %w", err)
}
genesisBytes, err := json.Marshal(genesis)
if err != nil {
return fmt.Errorf("failed marshaling genesis: %w", err)
}
networkConfig := map[string]interface{}{
"genesis": string(genesisBytes),
"networkID": 1337,
}
networkBytes, err := json.MarshalIndent(networkConfig, "", " ")
if err != nil {
return fmt.Errorf("failed marshaling network config: %w", err)
}
networkJSONPath := filepath.Join(defaultSnapshotPath, "network.json")
if err := os.WriteFile(networkJSONPath, networkBytes, WriteReadReadPerms); err != nil {
return fmt.Errorf("failed writing network.json: %w", err)
}
ux.Logger.PrintToUser("Created fresh snapshot with embedded genesis")
}
return nil
}
// GetLocallyDeployedChains returns the locally deployed chains. Returns an error if the server cannot be contacted.
func GetLocallyDeployedChains() (map[string]struct{}, error) {
deployedNames := map[string]struct{}{}
// if the server can not be contacted, or there is a problem with the query,
// DO NOT FAIL, just print No for deployed status
cli, err := binutils.NewGRPCClient()
if err != nil {
return nil, err
}
ctx := binutils.GetAsyncContext()
resp, err := cli.Status(ctx)
if err != nil {
return nil, err
}
for _, chain := range resp.ClusterInfo.CustomChains {
deployedNames[chain.ChainName] = struct{}{}
}
return deployedNames, nil
}
// IssueRemoveChainValidatorTx issues a remove chain validator transaction.
func IssueRemoveChainValidatorTx(kc keychain.Keychain, chainID ids.ID, nodeID ids.NodeID) (ids.ID, error) {
ctx := context.Background()
api := constants.LocalAPIEndpoint
// Use P-Chain only wallet since our X-Chain uses exchangevm which doesn't
// support standard XVM API methods.
wallet, err := primary.MakePChainWallet(ctx, &primary.WalletConfig{
URI: api,
LUXKeychain: keychainwrapper.WrapCryptoKeychain(kc),
})
if err != nil {
return ids.Empty, err
}
tx, err := wallet.P().IssueRemoveChainValidatorTx(nodeID, chainID)
if err != nil {
return ids.Empty, err
}
return tx.ID(), nil
}
// GetChainValidators returns the validators for a chain.
func GetChainValidators(chainID ids.ID) ([]platformvm.ClientPermissionlessValidator, error) {
api := constants.LocalAPIEndpoint
pClient := platformvm.NewClient(api)
ctx, cancel := context.WithTimeout(context.Background(), constants.E2ERequestTimeout)
defer cancel()
return pClient.GetCurrentValidators(ctx, chainID, nil)
}
// CheckNodeIsInChainPendingValidators checks if a node is in the pending validators for a chain.
func CheckNodeIsInChainPendingValidators(chainID ids.ID, nodeID string) (bool, error) {
api := constants.LocalAPIEndpoint
pClient := platformvm.NewClient(api)
ctx, cancel := context.WithTimeout(context.Background(), constants.E2ERequestTimeout)
defer cancel()
// Get validators that will be active in the future (pending validators)
futureTime := uint64(time.Now().Add(time.Hour).Unix()) //nolint:gosec // G115: Unix time is positive
validators, err := pClient.GetValidatorsAt(ctx, chainID, platformapi.Height(futureTime))
if err != nil {
return false, err
}
// Convert nodeID string to ids.NodeID for comparison
nID, err := ids.NodeIDFromString(nodeID)
if err != nil {
return false, err
}
// Check current validators
currentValidators, err := pClient.GetCurrentValidators(ctx, chainID, nil)
if err != nil {
return false, err
}
// Check if the node is in future validators but not in current validators
inFuture := false
for id := range validators {
if id == nID {
inFuture = true
break
}
}
if !inFuture {
return false, nil
}
// Check if it's already a current validator
for _, v := range currentValidators {
if v.NodeID == nID {
return false, nil // Already active, not pending
}
}
return true, nil // In future but not current = pending
}