Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cmd/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
72 changes: 72 additions & 0 deletions pkg/pchain/validator_health.go
Original file line number Diff line number Diff line change
@@ -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"`
}
106 changes: 106 additions & 0 deletions pkg/pchain/validator_health_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading