diff --git a/README.md b/README.md index 22777a0..83328a5 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ GLOBAL OPTIONS: --namespace value namespace for Prometheus metrics (default: "cosmos_validator_watcher") --no-color disable colored output (default: false) --node value [ --node value ] rpc node endpoint to connect to (specify multiple for high availability) (default: "http://localhost:26657") + --no-gov disable calls to gov module (useful for consumer chains) (default: false) --no-staking disable calls to staking module (useful for consumer chains) (default: false) --validator value [ --validator value ] validator address(es) to track (use :my-label to add a custom label in metrics & ouput) --help, -h show help @@ -99,6 +100,7 @@ Metrics (without prefix) | Description `tokens` | Number of staked tokens per validator `tracked_blocks` | Number of blocks tracked since start `validated_blocks` | Number of validated blocks per validator (for a bonded validator) +`vote` | Set to 1 if the validator has voted on a proposal `upgrade_plan` | Block height of the upcoming upgrade (hard fork) diff --git a/pkg/app/flags.go b/pkg/app/flags.go index 6c563e9..51e1ee7 100644 --- a/pkg/app/flags.go +++ b/pkg/app/flags.go @@ -31,6 +31,10 @@ var Flags = []cli.Flag{ Usage: "rpc node endpoint to connect to (specify multiple for high availability)", Value: cli.NewStringSlice("http://localhost:26657"), }, + &cli.BoolFlag{ + Name: "no-gov", + Usage: "disable calls to gov module (useful for consumer chains)", + }, &cli.BoolFlag{ Name: "no-staking", Usage: "disable calls to staking module (useful for consumer chains)", diff --git a/pkg/app/run.go b/pkg/app/run.go index eab1209..58b24f4 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -35,6 +35,7 @@ func RunFunc(cCtx *cli.Context) error { namespace = cCtx.String("namespace") noColor = cCtx.Bool("no-color") nodes = cCtx.StringSlice("node") + noGov = cCtx.Bool("no-gov") noStaking = cCtx.Bool("no-staking") validators = cCtx.StringSlice("validator") ) @@ -96,6 +97,12 @@ func RunFunc(cCtx *cli.Context) error { return validatorsWatcher.Start(ctx) }) } + if !noGov { + votesWatcher := watcher.NewVotesWatcher(trackedValidators, metrics, pool) + errg.Go(func() error { + return votesWatcher.Start(ctx) + }) + } upgradeWatcher := watcher.NewUpgradeWatcher(metrics, pool) errg.Go(func() error { return upgradeWatcher.Start(ctx) @@ -227,7 +234,6 @@ func createTrackedValidators(ctx context.Context, pool *rpc.Pool, validators []s }, }) if err != nil { - println(err.Error()) return nil, err } stakingValidators = resp.Validators @@ -250,6 +256,14 @@ func createTrackedValidators(ctx context.Context, pool *rpc.Pool, validators []s Str("moniker", val.Moniker). Msgf("tracking validator %s", val.Address) + log.Debug(). + Str("account", val.AccountAddress()). + Str("address", val.Address). + Str("alias", val.Name). + Str("moniker", val.Moniker). + Str("operator", val.OperatorAddress). + Msgf("validator info") + return val }) diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 6c413b0..8496b43 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -20,6 +20,7 @@ type Metrics struct { Tokens *prometheus.GaugeVec IsBonded *prometheus.GaugeVec IsJailed *prometheus.GaugeVec + Vote *prometheus.GaugeVec // Node metrics NodeBlockHeight *prometheus.GaugeVec @@ -124,6 +125,14 @@ func New(namespace string) *Metrics { }, []string{"chain_id", "address", "name"}, ), + Vote: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "vote", + Help: "Set to 1 if the validator has voted on a proposal", + }, + []string{"chain_id", "address", "name", "proposal_id"}, + ), NodeBlockHeight: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Namespace: namespace, @@ -166,6 +175,7 @@ func (m *Metrics) Register() { prometheus.MustRegister(m.Tokens) prometheus.MustRegister(m.IsBonded) prometheus.MustRegister(m.IsJailed) + prometheus.MustRegister(m.Vote) prometheus.MustRegister(m.NodeBlockHeight) prometheus.MustRegister(m.NodeSynced) prometheus.MustRegister(m.UpgradePlan) diff --git a/pkg/watcher/types.go b/pkg/watcher/types.go index cac9e43..16335af 100644 --- a/pkg/watcher/types.go +++ b/pkg/watcher/types.go @@ -1,6 +1,10 @@ package watcher -import "strings" +import ( + "strings" + + "github.com/cosmos/cosmos-sdk/types/bech32" +) type TrackedValidator struct { Address string @@ -23,3 +27,18 @@ func ParseValidator(val string) TrackedValidator { Name: parts[0], } } + +func (t TrackedValidator) AccountAddress() string { + prefix, bytes, err := bech32.DecodeAndConvert(t.OperatorAddress) + if err != nil { + return err.Error() + } + + newPrefix := strings.TrimSuffix(prefix, "valoper") + conv, err := bech32.ConvertAndEncode(newPrefix, bytes) + if err != nil { + return err.Error() + } + + return conv +} diff --git a/pkg/watcher/types_test.go b/pkg/watcher/types_test.go new file mode 100644 index 0000000..b8a2bcb --- /dev/null +++ b/pkg/watcher/types_test.go @@ -0,0 +1,35 @@ +package watcher + +import ( + "testing" + + "gotest.tools/assert" +) + +func TestTrackedValidator(t *testing.T) { + + t.Run("AccountAddress", func(t *testing.T) { + testdata := []struct { + Address string + Account string + }{ + { + Address: "cosmosvaloper1uxlf7mvr8nep3gm7udf2u9remms2jyjqvwdul2", + Account: "cosmos1uxlf7mvr8nep3gm7udf2u9remms2jyjqf6efne", + }, + { + Address: "cosmosvaloper1n229vhepft6wnkt5tjpwmxdmcnfz55jv3vp77d", + Account: "cosmos1n229vhepft6wnkt5tjpwmxdmcnfz55jv5c4tj7", + }, + } + + for _, td := range testdata { + + v := TrackedValidator{ + OperatorAddress: td.Address, + } + + assert.Equal(t, v.AccountAddress(), td.Account) + } + }) +} diff --git a/pkg/watcher/upgrade.go b/pkg/watcher/upgrade.go index d0a7560..3b23c8a 100644 --- a/pkg/watcher/upgrade.go +++ b/pkg/watcher/upgrade.go @@ -2,7 +2,6 @@ package watcher import ( "context" - "fmt" "time" "github.com/cosmos/cosmos-sdk/client" @@ -25,7 +24,7 @@ func NewUpgradeWatcher(metrics *metrics.Metrics, pool *rpc.Pool) *UpgradeWatcher } func (w *UpgradeWatcher) Start(ctx context.Context) error { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(1 * time.Minute) for { node := w.pool.GetSyncedNode() @@ -52,7 +51,6 @@ func (w *UpgradeWatcher) fetchUpgrade(ctx context.Context, node *rpc.Node) error return err } - fmt.Printf("%+v\n", resp.Plan) w.handleUpgradePlan(node.ChainID(), resp.Plan) return nil diff --git a/pkg/watcher/votes.go b/pkg/watcher/votes.go new file mode 100644 index 0000000..1b460d9 --- /dev/null +++ b/pkg/watcher/votes.go @@ -0,0 +1,108 @@ +package watcher + +import ( + "context" + "fmt" + "time" + + "github.com/cosmos/cosmos-sdk/client" + gov "github.com/cosmos/cosmos-sdk/x/gov/types/v1beta1" + "github.com/kilnfi/cosmos-validator-watcher/pkg/metrics" + "github.com/kilnfi/cosmos-validator-watcher/pkg/rpc" + "github.com/rs/zerolog/log" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type VotesWatcher struct { + metrics *metrics.Metrics + validators []TrackedValidator + pool *rpc.Pool +} + +func NewVotesWatcher(validators []TrackedValidator, metrics *metrics.Metrics, pool *rpc.Pool) *VotesWatcher { + return &VotesWatcher{ + metrics: metrics, + validators: validators, + pool: pool, + } +} + +func (w *VotesWatcher) Start(ctx context.Context) error { + ticker := time.NewTicker(1 * time.Minute) + + for { + node := w.pool.GetSyncedNode() + if node == nil { + log.Warn().Msg("no node available to fetch proposals") + } else if err := w.fetchProposals(ctx, node); err != nil { + log.Error().Err(err).Msg("failed to fetch pending proposals") + } + + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} + +func (w *VotesWatcher) fetchProposals(ctx context.Context, node *rpc.Node) error { + clientCtx := (client.Context{}).WithClient(node.Client) + queryClient := gov.NewQueryClient(clientCtx) + + // Fetch all proposals in voting period + proposalsResp, err := queryClient.Proposals(ctx, &gov.QueryProposalsRequest{ + ProposalStatus: gov.StatusVotingPeriod, + }) + if err != nil { + return fmt.Errorf("failed to get proposals: %w", err) + } + + // For each proposal, fetch validators vote + for _, proposal := range proposalsResp.GetProposals() { + for _, validator := range w.validators { + voter := validator.AccountAddress() + if voter == "" { + log.Warn().Str("validator", validator.Name).Msg("no account address for validator") + continue + } + voteResp, err := queryClient.Vote(ctx, &gov.QueryVoteRequest{ + ProposalId: proposal.ProposalId, + Voter: voter, + }) + if isInvalidArgumentError(err) { + w.handleVote(node.ChainID(), validator, proposal.ProposalId, nil) + } else if err != nil { + return fmt.Errorf("failed to get validator vote for proposal %d: %w", proposal.ProposalId, err) + } else { + vote := voteResp.GetVote() + w.handleVote(node.ChainID(), validator, proposal.ProposalId, vote.Options) + } + } + } + + return nil +} + +func (w *VotesWatcher) handleVote(chainID string, validator TrackedValidator, proposalId uint64, votes []gov.WeightedVoteOption) { + voted := false + for _, option := range votes { + if option.Option != gov.OptionEmpty { + voted = true + break + } + } + + w.metrics.Vote. + WithLabelValues(chainID, validator.Address, validator.Name, fmt.Sprintf("%d", proposalId)). + Set(metrics.BoolToFloat64(voted)) +} + +func isInvalidArgumentError(err error) bool { + st, ok := status.FromError(err) + if !ok { + return false + } + return st.Code() == codes.InvalidArgument +} diff --git a/pkg/watcher/votes_test.go b/pkg/watcher/votes_test.go new file mode 100644 index 0000000..65bc64e --- /dev/null +++ b/pkg/watcher/votes_test.go @@ -0,0 +1,40 @@ +package watcher + +import ( + "testing" + + gov "github.com/cosmos/cosmos-sdk/x/gov/types/v1beta1" + "github.com/kilnfi/cosmos-validator-watcher/pkg/metrics" + "github.com/prometheus/client_golang/prometheus/testutil" + "gotest.tools/assert" +) + +func TestVotesWatcher(t *testing.T) { + var ( + kilnAddress = "3DC4DD610817606AD4A8F9D762A068A81E8741E2" + kilnName = "Kiln" + chainID = "chain-42" + validators = []TrackedValidator{ + { + Address: kilnAddress, + Name: kilnName, + }, + } + ) + + votesWatcher := NewVotesWatcher( + validators, + metrics.New("cosmos_validator_watcher"), + nil, + ) + + t.Run("Handle Votes", func(t *testing.T) { + votesWatcher.handleVote(chainID, validators[0], 40, nil) + votesWatcher.handleVote(chainID, validators[0], 41, []gov.WeightedVoteOption{{Option: gov.OptionEmpty}}) + votesWatcher.handleVote(chainID, validators[0], 42, []gov.WeightedVoteOption{{Option: gov.OptionYes}}) + + assert.Equal(t, float64(0), testutil.ToFloat64(votesWatcher.metrics.Vote.WithLabelValues(chainID, kilnAddress, kilnName, "40"))) + assert.Equal(t, float64(0), testutil.ToFloat64(votesWatcher.metrics.Vote.WithLabelValues(chainID, kilnAddress, kilnName, "41"))) + assert.Equal(t, float64(1), testutil.ToFloat64(votesWatcher.metrics.Vote.WithLabelValues(chainID, kilnAddress, kilnName, "42"))) + }) +}