From 6f8eea426f64ef86fb2b00a60ed72b92ad8f670f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Jul 2026 12:14:32 -0400 Subject: [PATCH] feat: warn when a new auto-renewed validator is not connected Auto-renewal is conditioned on reward eligibility, so a node the network cannot reach is removed at the first cycle boundary and forfeits that cycle's rewards instead of renewing. Nothing in the flow surfaced this: add-auto-renewed printed a TX ID and exited even when the validator was never visible to the network. After the transaction is accepted, look the validator up with platform.getCurrentValidators and warn when it is not connected. The transaction has already landed at that point, so every failure here is advisory and never changes the exit status. --- cmd/validator.go | 28 ++++++++ pkg/pchain/validator_health.go | 72 +++++++++++++++++++ pkg/pchain/validator_health_test.go | 106 ++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 pkg/pchain/validator_health.go create mode 100644 pkg/pchain/validator_health_test.go 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") + } +}