diff --git a/chains/manager.go b/chains/manager.go index 977b62a14aa1..988b3ae5ba34 100644 --- a/chains/manager.go +++ b/chains/manager.go @@ -410,8 +410,8 @@ func (m *manager) buildChain(chainParams ChainParameters, sb Subnet) (*chain, er // before it's first access would cause a panic. ctx.SetState(snow.Initializing) - if sbConfigs, ok := m.SubnetConfigs[chainParams.SubnetID]; ok { - if sbConfigs.ValidatorOnly { + if subnetConfig, ok := m.SubnetConfigs[chainParams.SubnetID]; ok { + if subnetConfig.ValidatorOnly { ctx.SetValidatorOnly() } } @@ -450,11 +450,13 @@ func (m *manager) buildChain(chainParams ChainParameters, sb Subnet) (*chain, er } consensusParams := m.ConsensusParams - if sbConfigs, ok := m.SubnetConfigs[chainParams.SubnetID]; ok && chainParams.SubnetID != constants.PrimaryNetworkID { - consensusParams = sbConfigs.ConsensusParameters + // short circuit it before reading from subnetConfigs + if chainParams.SubnetID != constants.PrimaryNetworkID { + if subnetConfig, ok := m.SubnetConfigs[chainParams.SubnetID]; ok { + consensusParams = subnetConfig.ConsensusParameters + } } - // The validators of this blockchain var vdrs validators.Set // Validators validating this blockchain var ok bool if m.StakingEnabled { @@ -569,8 +571,11 @@ func (m *manager) createAvalancheChain( msgChan := make(chan common.Message, defaultChannelSize) gossipConfig := m.GossipConfig - if sbConfigs, ok := m.SubnetConfigs[ctx.SubnetID]; ok && ctx.SubnetID != constants.PrimaryNetworkID { - gossipConfig = sbConfigs.GossipConfig + // short circuit it before reading from subnetConfigs + if ctx.SubnetID != constants.PrimaryNetworkID { + if subnetConfig, ok := m.SubnetConfigs[ctx.SubnetID]; ok { + gossipConfig = subnetConfig.GossipConfig + } } // Passes messages from the consensus engine to the network @@ -757,8 +762,11 @@ func (m *manager) createSnowmanChain( msgChan := make(chan common.Message, defaultChannelSize) gossipConfig := m.GossipConfig - if sbConfigs, ok := m.SubnetConfigs[ctx.SubnetID]; ok && ctx.SubnetID != constants.PrimaryNetworkID { - gossipConfig = sbConfigs.GossipConfig + // short circuit it before reading from subnetConfigs + if ctx.SubnetID != constants.PrimaryNetworkID { + if subnetConfig, ok := m.SubnetConfigs[ctx.SubnetID]; ok { + gossipConfig = subnetConfig.GossipConfig + } } // Passes messages from the consensus engine to the network diff --git a/config/config.go b/config/config.go index 53b736e75bc6..5134d2ec3be3 100644 --- a/config/config.go +++ b/config/config.go @@ -1007,11 +1007,7 @@ func getChainConfigsFromDir(v *viper.Viper) (map[string]chains.ChainConfig, erro return make(map[string]chains.ChainConfig), nil } - chainConfigs, err := readChainConfigPath(chainConfigPath) - if err != nil { - return nil, fmt.Errorf("couldn't read chain configs: %w", err) - } - return chainConfigs, nil + return readChainConfigPath(chainConfigPath) } // getChainConfigs reads & puts chainConfigs to node config @@ -1060,6 +1056,13 @@ func readChainConfigPath(chainConfigPath string) (map[string]chains.ChainConfig, return chainConfigMap, nil } +func getSubnetConfigs(v *viper.Viper, subnetIDs []ids.ID) (map[ids.ID]chains.SubnetConfig, error) { + if v.IsSet(SubnetConfigContentKey) { + return getSubnetConfigsFromFlags(v, subnetIDs) + } + return getSubnetConfigsFromDir(v, subnetIDs) +} + func getSubnetConfigsFromFlags(v *viper.Viper, subnetIDs []ids.ID) (map[ids.ID]chains.SubnetConfig, error) { subnetConfigContentB64 := v.GetString(SubnetConfigContentKey) subnetConfigContent, err := base64.StdEncoding.DecodeString(subnetConfigContentB64) @@ -1076,11 +1079,8 @@ func getSubnetConfigsFromFlags(v *viper.Viper, subnetIDs []ids.ID) (map[ids.ID]c res := make(map[ids.ID]chains.SubnetConfig) for _, subnetID := range subnetIDs { if rawSubnetConfigBytes, ok := subnetConfigs[subnetID]; ok { - subnetConfig := defaultSubnetConfig(v) - if err := json.Unmarshal(rawSubnetConfigBytes, &subnetConfig); err != nil { - return nil, err - } - if err := subnetConfig.ConsensusParameters.Valid(); err != nil { + subnetConfig, err := parseSubnetConfigs(rawSubnetConfigBytes, getDefaultSubnetConfig(v)) + if err != nil { return nil, err } res[subnetID] = subnetConfig @@ -1100,22 +1100,7 @@ func getSubnetConfigsFromDir(v *viper.Viper, subnetIDs []ids.ID) (map[ids.ID]cha return make(map[ids.ID]chains.SubnetConfig), nil } - subnetConfigs, err := readSubnetConfigs(subnetConfigPath, subnetIDs, defaultSubnetConfig(v)) - if err != nil { - return nil, fmt.Errorf("couldn't read subnet configs: %w", err) - } - return subnetConfigs, nil -} - -func getSubnetConfigs(v *viper.Viper, subnetIDs []ids.ID) (map[ids.ID]chains.SubnetConfig, error) { - if v.IsSet(SubnetConfigContentKey) { - return getSubnetConfigsFromFlags(v, subnetIDs) - } - return getSubnetConfigsFromDir(v, subnetIDs) -} - -// readSubnetConfigs reads subnet config files from a path and given subnetIDs and returns a map. -func readSubnetConfigs(subnetConfigPath string, subnetIDs []ids.ID, defaultSubnetConfig chains.SubnetConfig) (map[ids.ID]chains.SubnetConfig, error) { + // reads subnet config files from a path and given subnetIDs and returns a map. subnetConfigs := make(map[ids.ID]chains.SubnetConfig) for _, subnetID := range subnetIDs { filePath := filepath.Join(subnetConfigPath, subnetID.String()+subnetConfigFileExt) @@ -1135,21 +1120,28 @@ func readSubnetConfigs(subnetConfigPath string, subnetIDs []ids.ID, defaultSubne if err != nil { return nil, err } - - configData := defaultSubnetConfig - if err := json.Unmarshal(file, &configData); err != nil { - return nil, err - } - if err := configData.ConsensusParameters.Valid(); err != nil { + config, err := parseSubnetConfigs(file, getDefaultSubnetConfig(v)) + if err != nil { return nil, err } - subnetConfigs[subnetID] = configData + subnetConfigs[subnetID] = config } return subnetConfigs, nil } -func defaultSubnetConfig(v *viper.Viper) chains.SubnetConfig { +func parseSubnetConfigs(data []byte, defaultSubnetConfig chains.SubnetConfig) (chains.SubnetConfig, error) { + if err := json.Unmarshal(data, &defaultSubnetConfig); err != nil { + return chains.SubnetConfig{}, err + } + + if err := defaultSubnetConfig.ConsensusParameters.Valid(); err != nil { + return chains.SubnetConfig{}, fmt.Errorf("invalid consensus parameters: %w", err) + } + return defaultSubnetConfig, nil +} + +func getDefaultSubnetConfig(v *viper.Viper) chains.SubnetConfig { return chains.SubnetConfig{ ConsensusParameters: getConsensusConfig(v), ValidatorOnly: false, @@ -1341,14 +1333,24 @@ func GetNodeConfig(v *viper.Viper, buildDir string) (node.Config, error) { // Subnet Configs subnetConfigs, err := getSubnetConfigs(v, nodeConfig.WhitelistedSubnets.List()) if err != nil { - return node.Config{}, err + return node.Config{}, fmt.Errorf("couldn't read subnet configs: %w", err) } nodeConfig.SubnetConfigs = subnetConfigs + // Node health + nodeConfig.MinPercentConnectedStakeHealthy = map[ids.ID]float64{ + constants.PrimaryNetworkID: calcMinConnectedStake(nodeConfig.ConsensusParams.Parameters), + } + + nodeConfig.MinPercentConnectedStakeHealthy = make(map[ids.ID]float64) + for subnetID, config := range subnetConfigs { + nodeConfig.MinPercentConnectedStakeHealthy[subnetID] = calcMinConnectedStake(config.ConsensusParameters.Parameters) + } + // Chain Configs nodeConfig.ChainConfigs, err = getChainConfigs(v) if err != nil { - return node.Config{}, err + return node.Config{}, fmt.Errorf("couldn't read chain configs: %w", err) } // Profiler @@ -1381,3 +1383,12 @@ func GetNodeConfig(v *viper.Viper, buildDir string) (node.Config, error) { nodeConfig.DiskTargeterConfig, err = getDiskTargeterConfig(v) return nodeConfig, err } + +// calcMinConnectedStake takes [consensusParams] as input and calculates the +// expected min connected stake percentage according to alpha and k. +func calcMinConnectedStake(consensusParams snowball.Parameters) float64 { + alpha := consensusParams.Alpha + k := consensusParams.K + r := float64(alpha) / float64(k) + return r*(1-constants.MinConnectedStakeBuffer) + constants.MinConnectedStakeBuffer +} diff --git a/config/config_test.go b/config/config_test.go index 098da2e173bd..f8a48bbeb6f4 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -405,7 +405,7 @@ func TestGetSubnetConfigsFromFile(t *testing.T) { testF: func(require *require.Assertions, given map[ids.ID]chains.SubnetConfig) { require.Nil(given) }, - errMessage: "couldn't read subnet configs", + errMessage: "invalid character", }, "subnet is not whitelisted": { fileName: "Gmt4fuNsGJAd2PX86LBvycGaBpgCYKbuULdCLZs3SEs1Jx1LU.json", @@ -578,6 +578,14 @@ func TestGetSubnetConfigsFromFlags(t *testing.T) { } } +func TestCalcMinConnectedStake(t *testing.T) { + v := setupViperFlags() + defaultParams := getConsensusConfig(v) + defaultExpectedMinStake := 0.8 + minStake := calcMinConnectedStake(defaultParams.Parameters) + require.Equal(t, defaultExpectedMinStake, minStake) +} + // setups config json file and writes content func setupConfigJSON(t *testing.T, rootPath string, value string) string { configFilePath := filepath.Join(rootPath, "config.json") diff --git a/go.sum b/go.sum index 3f0bfcabc7ab..6ac8a9418a88 100644 --- a/go.sum +++ b/go.sum @@ -57,8 +57,6 @@ github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/ava-labs/avalanche-network-runner-sdk v0.2.0 h1:YNvM0oFlb7A825kGe0XwwZuvIXTKF1BsuvxJdRLhIaI= github.com/ava-labs/avalanche-network-runner-sdk v0.2.0/go.mod h1:bEBRVZnGeRiNdDJAFUj+gA/TPzNDbpY/WzgDAHHwJb8= -github.com/ava-labs/coreth v0.11.1-rc.0 h1:NeVdLi2wTu8EjX5jmaVbdOmzHOl3PQCs6RoBcziMyXU= -github.com/ava-labs/coreth v0.11.1-rc.0/go.mod h1:jxBQyF3o5zMKJV1cnauJOcnPcvSEanvDXKUmcxlRAKE= github.com/ava-labs/coreth v0.11.1-rc.1 h1:GpSythfFCOXBoalsNvmzFBae0rgblTUnQuamgAVEhu4= github.com/ava-labs/coreth v0.11.1-rc.1/go.mod h1:8pN1Ko0RfPoEHzBVn9bzuv0uV1b+/vNTuYC+MqY8P0g= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= diff --git a/node/config.go b/node/config.go index 3c90dd35bc0d..028a9d6a8a64 100644 --- a/node/config.go +++ b/node/config.go @@ -222,4 +222,7 @@ type Config struct { RequiredAvailableDiskSpace uint64 `json:"requiredAvailableDiskSpace"` WarningThresholdAvailableDiskSpace uint64 `json:"warningThresholdAvailableDiskSpace"` + + // See comment on [MinPercentConnectedStakeHealthy] in platformvm.Config + MinPercentConnectedStakeHealthy map[ids.ID]float64 `json:"minPercentConnectedStakeHealthy"` } diff --git a/node/node.go b/node/node.go index db1fe8139f26..d4f8d4330892 100644 --- a/node/node.go +++ b/node/node.go @@ -773,32 +773,33 @@ func (n *Node) initVMs() error { errs.Add( vmRegisterer.Register(constants.PlatformVMID, &platformvm.Factory{ Config: config.Config{ - Chains: n.chainManager, - Validators: vdrs, - SubnetTracker: n.Net, - UptimeLockedCalculator: n.uptimeCalculator, - StakingEnabled: n.Config.EnableStaking, - WhitelistedSubnets: n.Config.WhitelistedSubnets, - TxFee: n.Config.TxFee, - CreateAssetTxFee: n.Config.CreateAssetTxFee, - CreateSubnetTxFee: n.Config.CreateSubnetTxFee, - TransformSubnetTxFee: n.Config.TransformSubnetTxFee, - CreateBlockchainTxFee: n.Config.CreateBlockchainTxFee, - AddPrimaryNetworkValidatorFee: n.Config.AddPrimaryNetworkValidatorFee, - AddPrimaryNetworkDelegatorFee: n.Config.AddPrimaryNetworkDelegatorFee, - AddSubnetValidatorFee: n.Config.AddSubnetValidatorFee, - AddSubnetDelegatorFee: n.Config.AddSubnetDelegatorFee, - UptimePercentage: n.Config.UptimeRequirement, - MinValidatorStake: n.Config.MinValidatorStake, - MaxValidatorStake: n.Config.MaxValidatorStake, - MinDelegatorStake: n.Config.MinDelegatorStake, - MinDelegationFee: n.Config.MinDelegationFee, - MinStakeDuration: n.Config.MinStakeDuration, - MaxStakeDuration: n.Config.MaxStakeDuration, - RewardConfig: n.Config.RewardConfig, - ApricotPhase3Time: version.GetApricotPhase3Time(n.Config.NetworkID), - ApricotPhase5Time: version.GetApricotPhase5Time(n.Config.NetworkID), - BanffTime: version.GetBanffTime(n.Config.NetworkID), + Chains: n.chainManager, + Validators: vdrs, + SubnetTracker: n.Net, + UptimeLockedCalculator: n.uptimeCalculator, + StakingEnabled: n.Config.EnableStaking, + WhitelistedSubnets: n.Config.WhitelistedSubnets, + TxFee: n.Config.TxFee, + CreateAssetTxFee: n.Config.CreateAssetTxFee, + CreateSubnetTxFee: n.Config.CreateSubnetTxFee, + TransformSubnetTxFee: n.Config.TransformSubnetTxFee, + CreateBlockchainTxFee: n.Config.CreateBlockchainTxFee, + AddPrimaryNetworkValidatorFee: n.Config.AddPrimaryNetworkValidatorFee, + AddPrimaryNetworkDelegatorFee: n.Config.AddPrimaryNetworkDelegatorFee, + AddSubnetValidatorFee: n.Config.AddSubnetValidatorFee, + AddSubnetDelegatorFee: n.Config.AddSubnetDelegatorFee, + UptimePercentage: n.Config.UptimeRequirement, + MinValidatorStake: n.Config.MinValidatorStake, + MaxValidatorStake: n.Config.MaxValidatorStake, + MinDelegatorStake: n.Config.MinDelegatorStake, + MinDelegationFee: n.Config.MinDelegationFee, + MinStakeDuration: n.Config.MinStakeDuration, + MaxStakeDuration: n.Config.MaxStakeDuration, + RewardConfig: n.Config.RewardConfig, + ApricotPhase3Time: version.GetApricotPhase3Time(n.Config.NetworkID), + ApricotPhase5Time: version.GetApricotPhase5Time(n.Config.NetworkID), + BanffTime: version.GetBanffTime(n.Config.NetworkID), + MinPercentConnectedStakeHealthy: n.Config.MinPercentConnectedStakeHealthy, }, }), vmRegisterer.Register(constants.AVMID, &avm.Factory{ diff --git a/utils/constants/networking.go b/utils/constants/networking.go index 7046efd6f857..28f45b4da505 100644 --- a/utils/constants/networking.go +++ b/utils/constants/networking.go @@ -25,4 +25,9 @@ const ( DefaultByteSliceCap = 128 MaxContainersLen = int(4 * DefaultMaxMessageSize / 5) + + // MinConnectedStakeBuffer is the safety buffer for calculation of MinConnectedStake. + // This increases the required stake percentage above alpha/k. Must be [0-1] + // 0 means MinConnectedStake = alpha/k, 1 means MinConnectedStake = 1 (fully connected) + MinConnectedStakeBuffer = .2 ) diff --git a/vms/platformvm/config/config.go b/vms/platformvm/config/config.go index f86cbf5032af..9533f14f5fb7 100644 --- a/vms/platformvm/config/config.go +++ b/vms/platformvm/config/config.go @@ -95,6 +95,15 @@ type Config struct { // Time of the Banff network upgrade BanffTime time.Time + + // Subnet ID --> Minimum portion of the subnet's stake this node must be + // connected to in order to report healthy. + // [constants.PrimaryNetworkID] is always a key in this map. + // If a subnet is in this map, but it isn't whitelisted, its corresponding + // value isn't used. + // If a subnet is whitelisted but not in this map, we use the value for the + // Primary Network. + MinPercentConnectedStakeHealthy map[ids.ID]float64 } func (c *Config) IsApricotPhase3Activated(timestamp time.Time) bool { diff --git a/vms/platformvm/health.go b/vms/platformvm/health.go index 249766a3ca10..ba6f4b75ffa2 100644 --- a/vms/platformvm/health.go +++ b/vms/platformvm/health.go @@ -4,15 +4,17 @@ package platformvm import ( + "errors" "fmt" "strings" "github.com/ava-labs/avalanchego/utils/constants" + "go.uber.org/zap" ) -// MinConnectedStake is the minimum percentage of the Primary Network's that -// this node must be connected to to be considered healthy -const MinConnectedStake = .80 +const fallbackMinPercentConnected = 0.8 + +var errNotEnoughStake = errors.New("not connected to enough stake") func (vm *VM) HealthCheck() (interface{}, error) { // Returns nil if this node is connected to > alpha percent of the Primary Network's stake @@ -25,13 +27,24 @@ func (vm *VM) HealthCheck() (interface{}, error) { "primary-percentConnected": primaryPercentConnected, } - // TODO: Use alpha from consensus instead of const + primaryMinPercentConnected, ok := vm.MinPercentConnectedStakeHealthy[constants.PrimaryNetworkID] + if !ok { + // This should never happen according to the comment for + // [MinPercentConnectedStakeHealthy] but we include it here to avoid the + // situation where a regression causes the key to be missing so that we + // don't accidentally set [primaryMinPercentConnected] to 0. + vm.ctx.Log.Warn("primary network min connected stake not given", + zap.Float64("fallback value", fallbackMinPercentConnected), + ) + primaryMinPercentConnected = fallbackMinPercentConnected + } + var errorReasons []string - if primaryPercentConnected < MinConnectedStake { + if primaryPercentConnected < primaryMinPercentConnected { errorReasons = append(errorReasons, fmt.Sprintf("connected to %f%% of primary network stake; should be connected to at least %f%%", primaryPercentConnected*100, - MinConnectedStake*100, + primaryMinPercentConnected*100, ), ) } @@ -41,24 +54,28 @@ func (vm *VM) HealthCheck() (interface{}, error) { if err != nil { return nil, fmt.Errorf("couldn't get percent connected for %q: %w", subnetID, err) } + minPercentConnected, ok := vm.MinPercentConnectedStakeHealthy[subnetID] + if !ok { + minPercentConnected = primaryMinPercentConnected + } vm.metrics.SetSubnetPercentConnected(subnetID, percentConnected) key := fmt.Sprintf("%s-percentConnected", subnetID) details[key] = percentConnected - if percentConnected < MinConnectedStake { + if percentConnected < minPercentConnected { errorReasons = append(errorReasons, fmt.Sprintf("connected to %f%% of %q weight; should be connected to at least %f%%", percentConnected*100, subnetID, - MinConnectedStake*100, + minPercentConnected*100, ), ) } } if len(errorReasons) > 0 { - err = fmt.Errorf("platform layer is unhealthy reason: %s", strings.Join(errorReasons, ", ")) + err = fmt.Errorf("platform layer is unhealthy err: %w, details: %s", errNotEnoughStake, strings.Join(errorReasons, ", ")) } return details, err } diff --git a/vms/platformvm/health_test.go b/vms/platformvm/health_test.go new file mode 100644 index 000000000000..95ab622a3b18 --- /dev/null +++ b/vms/platformvm/health_test.go @@ -0,0 +1,106 @@ +// Copyright (C) 2019-2022, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package platformvm + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/version" +) + +const defaultMinConnectedStake = 0.8 + +func TestHealthCheckPrimaryNetwork(t *testing.T) { + require := require.New(t) + + vm, _, _ := defaultVM() + vm.ctx.Lock.Lock() + + defer func() { + require.NoError(vm.Shutdown()) + vm.ctx.Lock.Unlock() + }() + genesisState, _ := defaultGenesis() + for index, validator := range genesisState.Validators { + err := vm.Connected(validator.NodeID, version.CurrentApp) + require.NoError(err) + details, err := vm.HealthCheck() + if float64((index+1)*20) >= defaultMinConnectedStake*100 { + require.NoError(err) + } else { + require.Contains(details, "primary-percentConnected") + require.ErrorIs(err, errNotEnoughStake) + } + } +} + +func TestHealthCheckSubnet(t *testing.T) { + tests := map[string]struct { + minStake float64 + useDefault bool + }{ + "default min stake": { + useDefault: true, + minStake: 0, + }, + "custom min stake": { + minStake: 0.40, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + require := require.New(t) + + vm, _, _ := defaultVM() + vm.ctx.Lock.Lock() + defer func() { + require.NoError(vm.Shutdown()) + vm.ctx.Lock.Unlock() + }() + subnetID := ids.GenerateTestID() + vm.WhitelistedSubnets.Add(subnetID) + testVdrCount := 4 + for i := 0; i < testVdrCount; i++ { + subnetVal := ids.GenerateTestNodeID() + require.NoError(vm.Validators.AddWeight(subnetID, subnetVal, 100)) + } + + vals, ok := vm.Validators.GetValidators(subnetID) + require.True(ok) + + // connect to all primary network validators first + genesisState, _ := defaultGenesis() + for _, validator := range genesisState.Validators { + err := vm.Connected(validator.NodeID, version.CurrentApp) + require.NoError(err) + } + var expectedMinStake float64 + if test.useDefault { + expectedMinStake = defaultMinConnectedStake + } else { + expectedMinStake = test.minStake + vm.MinPercentConnectedStakeHealthy = map[ids.ID]float64{ + subnetID: expectedMinStake, + } + } + for index, validator := range vals.List() { + err := vm.Connected(validator.ID(), version.CurrentApp) + require.NoError(err) + details, err := vm.HealthCheck() + connectedPerc := float64((index + 1) * (100 / testVdrCount)) + if connectedPerc >= expectedMinStake*100 { + require.NoError(err) + } else { + require.Contains(details, fmt.Sprintf("%s-percentConnected", subnetID)) + require.ErrorIs(err, errNotEnoughStake) + } + } + }) + } +}