diff --git a/cmd/validator.go b/cmd/validator.go index a914c13..874c9ab 100644 --- a/cmd/validator.go +++ b/cmd/validator.go @@ -316,10 +316,38 @@ var validatorAddAutoRenewedCmd = &cobra.Command{ } fmt.Printf("TX ID: %s\n", txID) + warnIfValidatorNotConnected(ctx, netConfig.RPCURL, nodeID) return nil }, } +// warnIfValidatorNotConnected reports when the network cannot see the node that +// was just staked. Auto-renewal is conditioned on reward eligibility, so a node +// the network never reaches is removed at the first cycle boundary and forfeits +// that cycle's rewards instead of renewing. The transaction has already been +// accepted by this point, so every failure here is advisory only. +func warnIfValidatorNotConnected(ctx context.Context, rpcURL string, nodeID ids.NodeID) { + health, err := pchain.GetValidatorHealth(ctx, rpcURL, nodeID) + if err != nil { + fmt.Printf("\nNote: could not check whether %s is connected: %v\n", nodeID, err) + return + } + if !health.Found { + fmt.Printf("\nNote: %s is not in the validator set yet. Confirm the node is connected once it appears.\n", nodeID) + return + } + if health.Connected { + return + } + + fmt.Printf("\nWarning: the network does not currently see %s as connected.\n", nodeID) + fmt.Println("Auto-renewal requires meeting the uptime requirement at each cycle boundary. A node") + fmt.Println("that stays unreachable is removed at the end of the cycle, forfeiting that cycle's") + fmt.Println("rewards, rather than renewing.") + fmt.Println("Check that the node is running with the BLS key used here, that --public-ip is set,") + fmt.Println("and that inbound TCP port 9651 is reachable.") +} + var validatorSetAutoConfigCmd = &cobra.Command{ Use: "set-auto-renewed-config", Short: "Set auto-renewed validator config (SetAutoRenewedValidatorConfigTx)", diff --git a/pkg/pchain/validator_health.go b/pkg/pchain/validator_health.go new file mode 100644 index 0000000..11d53c8 --- /dev/null +++ b/pkg/pchain/validator_health.go @@ -0,0 +1,72 @@ +package pchain + +import ( + "context" + "fmt" + "strconv" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/constants" + "github.com/ava-labs/avalanchego/vms/platformvm" +) + +// ValidatorHealth is how the network currently sees a primary network validator. +// +// It matters most for auto-renewed validators: renewal is conditioned on reward +// eligibility, so a validator the network cannot reach is removed at the next +// cycle boundary rather than renewed, and forfeits that cycle's rewards. +type ValidatorHealth struct { + // Found is false when nodeID is not in the current validator set. + Found bool + Connected bool + // UptimePercent is the queried node's view of this validator's uptime. It is + // 0 for a validator that has only just started validating, so it is only + // meaningful once the validator has been in the set for a while. + UptimePercent float64 +} + +// GetValidatorHealth reports the connectivity and uptime that +// platform.getCurrentValidators returns for nodeID on the primary network. +func GetValidatorHealth(ctx context.Context, rpcURL string, nodeID ids.NodeID) (*ValidatorHealth, error) { + client := platformvm.NewClient(rpcURL) + args := &platformvm.GetCurrentValidatorsArgs{ + SubnetID: constants.PrimaryNetworkID, + NodeIDs: []ids.NodeID{nodeID}, + } + reply := &getCurrentValidatorsHealthReply{} + if err := client.Requester.SendRequest(ctx, "platform.getCurrentValidators", args, reply); err != nil { + return nil, fmt.Errorf("failed to fetch current validators: %w", err) + } + + for _, validator := range reply.Validators { + if validator.NodeID != nodeID.String() { + continue + } + health := &ValidatorHealth{ + Found: true, + Connected: validator.Connected, + } + // Uptime is omitted for subnet validators and may be absent on nodes that + // do not track it; treat a missing value as 0 rather than an error. + if validator.Uptime != "" { + uptime, err := strconv.ParseFloat(validator.Uptime, 64) + if err != nil { + return nil, fmt.Errorf("invalid uptime %q for %s: %w", validator.Uptime, nodeID, err) + } + health.UptimePercent = uptime + } + return health, nil + } + + return &ValidatorHealth{}, nil +} + +type getCurrentValidatorsHealthReply struct { + Validators []validatorHealthEntry `json:"validators"` +} + +type validatorHealthEntry struct { + NodeID string `json:"nodeID"` + Connected bool `json:"connected"` + Uptime string `json:"uptime"` +} diff --git a/pkg/pchain/validator_health_test.go b/pkg/pchain/validator_health_test.go new file mode 100644 index 0000000..a5e616a --- /dev/null +++ b/pkg/pchain/validator_health_test.go @@ -0,0 +1,106 @@ +package pchain + +import ( + "context" + "strings" + "testing" + + "github.com/ava-labs/avalanchego/ids" +) + +func TestGetValidatorHealth(t *testing.T) { + nodeID := ids.GenerateTestNodeID() + otherNodeID := ids.GenerateTestNodeID() + + tests := []struct { + name string + validators []map[string]any + wantFound bool + wantConnected bool + wantUptime float64 + }{ + { + name: "connected validator", + validators: []map[string]any{{ + "nodeID": nodeID.String(), + "connected": true, + "uptime": "99.5000", + }}, + wantFound: true, + wantConnected: true, + wantUptime: 99.5, + }, + { + name: "disconnected validator", + validators: []map[string]any{{ + "nodeID": nodeID.String(), + "connected": false, + "uptime": "0.0000", + }}, + wantFound: true, + wantConnected: false, + }, + { + name: "missing uptime is treated as zero", + validators: []map[string]any{{ + "nodeID": nodeID.String(), + "connected": true, + }}, + wantFound: true, + wantConnected: true, + }, + { + name: "not in the validator set", + validators: []map[string]any{}, + }, + { + name: "other node ids are ignored", + validators: []map[string]any{{ + "nodeID": otherNodeID.String(), + "connected": true, + "uptime": "100.0000", + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotParams string + server := newCurrentValidatorsServer(t, &gotParams, tt.validators) + defer server.Close() + + health, err := GetValidatorHealth(context.Background(), server.URL, nodeID) + if err != nil { + t.Fatalf("GetValidatorHealth() error = %v", err) + } + if health.Found != tt.wantFound { + t.Errorf("Found = %v, want %v", health.Found, tt.wantFound) + } + if health.Connected != tt.wantConnected { + t.Errorf("Connected = %v, want %v", health.Connected, tt.wantConnected) + } + if health.UptimePercent != tt.wantUptime { + t.Errorf("UptimePercent = %v, want %v", health.UptimePercent, tt.wantUptime) + } + // The query must be scoped to the node and the primary network, so the + // reply cannot be diluted by the whole validator set. + if !strings.Contains(gotParams, nodeID.String()) { + t.Errorf("request params = %s, want it to contain %s", gotParams, nodeID) + } + }) + } +} + +func TestGetValidatorHealthInvalidUptime(t *testing.T) { + nodeID := ids.GenerateTestNodeID() + server := newCurrentValidatorsServer(t, nil, []map[string]any{{ + "nodeID": nodeID.String(), + "connected": true, + "uptime": "not-a-number", + }}) + defer server.Close() + + if _, err := GetValidatorHealth(context.Background(), server.URL, nodeID); err == nil { + t.Fatal("GetValidatorHealth() error = nil, want an error for an unparseable uptime") + } +}