mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
391 lines
13 KiB
Go
391 lines
13 KiB
Go
// Copyright (C) 2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
package nodecmd
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
blockchaincmd "github.com/luxfi/cli/v2/cmd/blockchaincmd"
|
|
"github.com/luxfi/cli/v2/pkg/ansible"
|
|
"github.com/luxfi/cli/v2/pkg/cobrautils"
|
|
"github.com/luxfi/cli/v2/pkg/constants"
|
|
"github.com/luxfi/cli/v2/pkg/keychain"
|
|
"github.com/luxfi/cli/v2/pkg/models"
|
|
"github.com/luxfi/cli/v2/pkg/node"
|
|
"github.com/luxfi/cli/v2/pkg/subnet"
|
|
"github.com/luxfi/cli/v2/pkg/utils"
|
|
"github.com/luxfi/cli/v2/pkg/ux"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/crypto/bls/signer/localsigner"
|
|
"github.com/luxfi/node/v2/v2/utils/units"
|
|
"github.com/luxfi/node/v2/v2/vms/platformvm"
|
|
"github.com/luxfi/node/v2/v2/vms/platformvm/signer"
|
|
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/exp/maps"
|
|
)
|
|
|
|
var (
|
|
keyName string
|
|
useEwoq bool
|
|
useLedger bool
|
|
useStaticIP bool
|
|
awsProfile string
|
|
ledgerAddresses []string
|
|
weight uint64
|
|
startTimeStr string
|
|
duration time.Duration
|
|
defaultValidatorParams bool
|
|
useCustomDuration bool
|
|
ErrMutuallyExlusiveKeyLedger = errors.New("--key and --ledger,--ledger-addrs are mutually exclusive")
|
|
ErrStoredKeyOnMainnet = errors.New("--key is not available for mainnet operations")
|
|
)
|
|
|
|
func newValidatePrimaryCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "primary [clusterName]",
|
|
Short: "(ALPHA Warning) Join Primary Network as a validator",
|
|
Long: `(ALPHA Warning) This command is currently in experimental mode.
|
|
|
|
The node validate primary command enables all nodes in a cluster to be validators of Primary
|
|
Network.`,
|
|
Args: cobrautils.ExactArgs(1),
|
|
RunE: validatePrimaryNetwork,
|
|
}
|
|
|
|
cmd.Flags().StringVarP(&keyName, "key", "k", "", "select the key to use [testnet only]")
|
|
cmd.Flags().BoolVarP(&useLedger, "ledger", "g", false, "use ledger instead of key (always true on mainnet, defaults to false on testnet/devnet)")
|
|
cmd.Flags().BoolVarP(&useEwoq, "ewoq", "e", false, "use ewoq key [testnet/devnet only]")
|
|
cmd.Flags().StringSliceVar(&ledgerAddresses, "ledger-addrs", []string{}, "use the given ledger addresses")
|
|
|
|
cmd.Flags().Uint64Var(&weight, "stake-amount", 0, "how many LUX to stake in the validator")
|
|
cmd.Flags().StringVar(&startTimeStr, "start-time", "", "UTC start time when this validator starts validating, in 'YYYY-MM-DD HH:MM:SS' format")
|
|
cmd.Flags().DurationVar(&duration, "staking-period", 0, "how long validator validates for after start time")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func GetMinStakingAmount(network models.Network) (uint64, error) {
|
|
pClient := platformvm.NewClient(network.Endpoint)
|
|
ctx, cancel := utils.GetAPIContext()
|
|
defer cancel()
|
|
minValStake, _, err := pClient.GetMinStake(ctx, ids.Empty)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return minValStake, nil
|
|
}
|
|
|
|
func joinAsPrimaryNetworkValidator(
|
|
deployer *subnet.PublicDeployer,
|
|
network models.Network,
|
|
kc *keychain.Keychain,
|
|
nodeID ids.NodeID,
|
|
nodeIndex int,
|
|
signingKeyPath string,
|
|
nodeCmd bool,
|
|
) error {
|
|
ux.Logger.PrintToUser(fmt.Sprintf("Adding node %s as a Primary Network Validator...", nodeID.String()))
|
|
defer ux.Logger.PrintLineSeparator()
|
|
var (
|
|
start time.Time
|
|
err error
|
|
)
|
|
minValStake, err := GetMinStakingAmount(network)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if weight == 0 {
|
|
weight, err = PromptWeightPrimaryNetwork(network)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if weight < minValStake {
|
|
return fmt.Errorf("illegal weight, must be greater than or equal to %d: %d", minValStake, weight)
|
|
}
|
|
start, duration, err = GetTimeParametersPrimaryNetwork(network, nodeIndex, duration, startTimeStr, nodeCmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
recipientAddr := kc.Addresses().List()[0]
|
|
PrintNodeJoinPrimaryNetworkOutput(nodeID, weight, network, start)
|
|
// we set the starting time for node to be a Primary Network Validator to be in 1 minute
|
|
// we use min delegation fee as default
|
|
delegationFee := network.GenesisParams().MinDelegationFee
|
|
blsKeyBytes, err := os.ReadFile(signingKeyPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
blsSk, err := localsigner.FromBytes(blsKeyBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pop, err := signer.NewProofOfPossession(blsSk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := deployer.AddPermissionlessValidator(
|
|
ids.Empty,
|
|
ids.Empty,
|
|
nodeID,
|
|
weight,
|
|
uint64(start.Unix()),
|
|
uint64(start.Add(duration).Unix()),
|
|
recipientAddr,
|
|
delegationFee,
|
|
nil,
|
|
pop,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
ux.Logger.PrintToUser(fmt.Sprintf("Node %s successfully added as Primary Network validator!", nodeID.String()))
|
|
return nil
|
|
}
|
|
|
|
func PromptWeightPrimaryNetwork(network models.Network) (uint64, error) {
|
|
defaultStake := network.GenesisParams().MinValidatorStake
|
|
defaultWeight := fmt.Sprintf("Default (%s)", convertNanoLuxToLuxString(defaultStake))
|
|
txt := "What stake weight would you like to assign to the validator?"
|
|
weightOptions := []string{defaultWeight, "Custom"}
|
|
weightOption, err := app.Prompt.CaptureList(txt, weightOptions)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
switch weightOption {
|
|
case defaultWeight:
|
|
return defaultStake, nil
|
|
default:
|
|
return app.Prompt.CaptureWeight(txt, func(uint64) error { return nil })
|
|
}
|
|
}
|
|
|
|
func GetTimeParametersPrimaryNetwork(network models.Network, nodeIndex int, validationDuration time.Duration, validationStartTimeStr string, nodeCmd bool) (time.Time, time.Duration, error) {
|
|
const (
|
|
defaultDurationOption = "Minimum staking duration on primary network"
|
|
custom = "Custom"
|
|
)
|
|
var err error
|
|
var start time.Time
|
|
if validationStartTimeStr != "" {
|
|
start, err = time.Parse(constants.TimeParseLayout, validationStartTimeStr)
|
|
if err != nil {
|
|
return time.Time{}, 0, err
|
|
}
|
|
} else {
|
|
start = time.Now().Add(constants.PrimaryNetworkValidatingStartLeadTimeNodeCmd)
|
|
if !nodeCmd {
|
|
start = time.Now().Add(constants.PrimaryNetworkValidatingStartLeadTime)
|
|
}
|
|
}
|
|
if useCustomDuration && validationDuration != 0 {
|
|
return start, duration, nil
|
|
}
|
|
if validationDuration != 0 {
|
|
duration, err = getDefaultValidationTime(start, network, nodeIndex)
|
|
if err != nil {
|
|
return time.Time{}, 0, err
|
|
}
|
|
return start, duration, nil
|
|
}
|
|
msg := "How long should your validator validate for?"
|
|
durationOptions := []string{defaultDurationOption, custom}
|
|
durationOption, err := app.Prompt.CaptureList(msg, durationOptions)
|
|
if err != nil {
|
|
return time.Time{}, 0, err
|
|
}
|
|
switch durationOption {
|
|
case defaultDurationOption:
|
|
duration, err = getDefaultValidationTime(start, network, nodeIndex)
|
|
if err != nil {
|
|
return time.Time{}, 0, err
|
|
}
|
|
default:
|
|
useCustomDuration = true
|
|
duration, err = blockchaincmd.PromptDuration(start, network, false) // not L1
|
|
if err != nil {
|
|
return time.Time{}, 0, err
|
|
}
|
|
}
|
|
return start, duration, nil
|
|
}
|
|
|
|
func getDefaultValidationTime(start time.Time, network models.Network, nodeIndex int) (time.Duration, error) {
|
|
durationStr := constants.DefaultTestnetStakeDuration
|
|
if network.Kind == models.Mainnet {
|
|
durationStr = constants.DefaultMainnetStakeDuration
|
|
}
|
|
durationInt, err := strconv.Atoi(durationStr[:len(durationStr)-1])
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// stagger expiration time by 1 day for each added node
|
|
durationAddition := 24 * nodeIndex
|
|
durationStr = strconv.Itoa(durationInt+durationAddition) + "h"
|
|
d, err := time.ParseDuration(durationStr)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
end := start.Add(d)
|
|
if nodeIndex == 0 {
|
|
confirm := fmt.Sprintf("Your validator will finish staking by %s", end.Format(constants.TimeParseLayout))
|
|
yes, err := app.Prompt.CaptureYesNo(confirm)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !yes {
|
|
return 0, errors.New("you have to confirm staking duration")
|
|
}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func getNodeIDs(hosts []*models.Host) (map[string]ids.NodeID, map[string]error) {
|
|
nodeIDMap := map[string]ids.NodeID{}
|
|
failedNodes := map[string]error{}
|
|
for _, host := range hosts {
|
|
cloudNodeID := host.GetCloudID()
|
|
nodeID, err := getNodeID(app.GetNodeInstanceDirPath(cloudNodeID))
|
|
if err != nil {
|
|
failedNodes[host.NodeID] = err
|
|
continue
|
|
}
|
|
nodeIDMap[host.NodeID] = nodeID
|
|
}
|
|
return nodeIDMap, failedNodes
|
|
}
|
|
|
|
// checkNodeIsPrimaryNetworkValidator returns true if node is already a Primary Network validator
|
|
func checkNodeIsPrimaryNetworkValidator(nodeID ids.NodeID, network models.Network) (bool, error) {
|
|
isValidator, err := subnet.IsSubnetValidator(ids.Empty, nodeID, network)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return isValidator, nil
|
|
}
|
|
|
|
// addNodeAsPrimaryNetworkValidator returns bool if node is added as primary network validator
|
|
// as it impacts the output in adding node as subnet validator in the next steps
|
|
func addNodeAsPrimaryNetworkValidator(
|
|
deployer *subnet.PublicDeployer,
|
|
network models.Network,
|
|
kc *keychain.Keychain,
|
|
nodeID ids.NodeID,
|
|
nodeIndex int,
|
|
instanceID string,
|
|
) error {
|
|
if isValidator, err := checkNodeIsPrimaryNetworkValidator(nodeID, network); err != nil {
|
|
return err
|
|
} else if !isValidator {
|
|
signingKeyPath := app.GetNodeBLSSecretKeyPath(instanceID)
|
|
return joinAsPrimaryNetworkValidator(deployer, network, kc, nodeID, nodeIndex, signingKeyPath, true)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePrimaryNetwork(_ *cobra.Command, args []string) error {
|
|
clusterName := args[0]
|
|
if err := node.CheckCluster(app, clusterName); err != nil {
|
|
return err
|
|
}
|
|
|
|
clusterConfig, err := app.GetClusterConfig(clusterName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if clusterConfig.Local {
|
|
return notImplementedForLocal("validate primary")
|
|
}
|
|
network := clusterConfig.Network
|
|
|
|
allHosts, err := ansible.GetInventoryFromAnsibleInventoryFile(app.GetAnsibleInventoryDirPath(clusterName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hosts := clusterConfig.GetValidatorHosts(allHosts) // exlude api nodes
|
|
defer node.DisconnectHosts(hosts)
|
|
|
|
// TODO: will estimate fee in subsecuent PR
|
|
// AddPrimaryNetworkValidatorFee * uint64(len(hosts))
|
|
fee := uint64(0)
|
|
kc, err := keychain.GetKeychainFromCmdLineFlags(
|
|
app,
|
|
constants.PayTxsFeesMsg,
|
|
network,
|
|
keyName,
|
|
useEwoq,
|
|
useLedger,
|
|
ledgerAddresses,
|
|
fee,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
deployer := subnet.NewPublicDeployer(app, kc, network)
|
|
|
|
if err := node.CheckHostsAreBootstrapped(hosts); err != nil {
|
|
return err
|
|
}
|
|
if err := node.CheckHostsAreHealthy(hosts); err != nil {
|
|
return err
|
|
}
|
|
|
|
ux.Logger.PrintToUser("Note that we have staggered the end time of validation period to increase by 24 hours for each node added if multiple nodes are added as Primary Network validators simultaneously")
|
|
nodeIDMap, failedNodesMap := getNodeIDs(hosts)
|
|
nodeErrors := map[string]error{}
|
|
for i, host := range hosts {
|
|
nodeID, b := nodeIDMap[host.NodeID]
|
|
if !b {
|
|
err, b := failedNodesMap[host.NodeID]
|
|
if !b {
|
|
return fmt.Errorf("expected to found an error for non mapped node")
|
|
}
|
|
ux.Logger.PrintToUser("Failed to add node %s as Primary Network validator due to %s", host.NodeID, err)
|
|
nodeErrors[host.NodeID] = err
|
|
continue
|
|
}
|
|
_, clusterNodeID, err := models.HostAnsibleIDToCloudID(host.NodeID)
|
|
if err != nil {
|
|
ux.Logger.PrintToUser("Failed to add node %s as Primary Network due to %s", host.NodeID, err.Error())
|
|
nodeErrors[host.NodeID] = err
|
|
continue
|
|
}
|
|
if err = addNodeAsPrimaryNetworkValidator(deployer, network, kc, nodeID, i, clusterNodeID); err != nil {
|
|
ux.Logger.PrintToUser("Failed to add node %s as Primary Network validator due to %s", host.NodeID, err)
|
|
nodeErrors[host.NodeID] = err
|
|
}
|
|
}
|
|
if len(nodeErrors) > 0 {
|
|
ux.Logger.PrintToUser("Failed nodes: ")
|
|
for node, nodeErr := range nodeErrors {
|
|
ux.Logger.PrintToUser("node %s failed due to %v", node, nodeErr)
|
|
}
|
|
return fmt.Errorf("node(s) %s failed to validate the Primary Network", maps.Keys(nodeErrors))
|
|
} else {
|
|
ux.Logger.PrintToUser(fmt.Sprintf("All nodes in cluster %s are successfully added as Primary Network validators!", clusterName))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// convertNanoLuxToLuxString converts nanoLUX to LUX
|
|
func convertNanoLuxToLuxString(weight uint64) string {
|
|
return fmt.Sprintf("%.2f %s", float64(weight)/float64(units.Lux), constants.LUXSymbol)
|
|
}
|
|
|
|
func PrintNodeJoinPrimaryNetworkOutput(nodeID ids.NodeID, weight uint64, network models.Network, start time.Time) {
|
|
ux.Logger.PrintToUser("NodeID: %s", nodeID.String())
|
|
ux.Logger.PrintToUser("Network: %s", network.Name())
|
|
ux.Logger.PrintToUser("Start time: %s", start.Format(constants.TimeParseLayout))
|
|
ux.Logger.PrintToUser("End time: %s", start.Add(duration).Format(constants.TimeParseLayout))
|
|
// we need to divide by 10 ^ 9 since we were using nanoLux
|
|
ux.Logger.PrintToUser("Weight: %s", convertNanoLuxToLuxString(weight))
|
|
ux.Logger.PrintToUser("Inputs complete, issuing transaction to add the provided validator information...")
|
|
}
|