Skip to content

Commit

Permalink
feat: monitor validators vote on pending proposals (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
MattKetmo committed Sep 26, 2023
1 parent b2dd68f commit 2d1e363
Show file tree
Hide file tree
Showing 9 changed files with 235 additions and 5 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
4 changes: 4 additions & 0 deletions pkg/app/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
16 changes: 15 additions & 1 deletion pkg/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
})

Expand Down
10 changes: 10 additions & 0 deletions pkg/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Metrics struct {
Tokens *prometheus.GaugeVec
IsBonded *prometheus.GaugeVec
IsJailed *prometheus.GaugeVec
Vote *prometheus.GaugeVec

// Node metrics
NodeBlockHeight *prometheus.GaugeVec
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 20 additions & 1 deletion pkg/watcher/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package watcher

import "strings"
import (
"strings"

"github.com/cosmos/cosmos-sdk/types/bech32"
)

type TrackedValidator struct {
Address string
Expand All @@ -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
}
35 changes: 35 additions & 0 deletions pkg/watcher/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
4 changes: 1 addition & 3 deletions pkg/watcher/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package watcher

import (
"context"
"fmt"
"time"

"github.com/cosmos/cosmos-sdk/client"
Expand All @@ -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()
Expand All @@ -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
Expand Down
108 changes: 108 additions & 0 deletions pkg/watcher/votes.go
Original file line number Diff line number Diff line change
@@ -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
}
40 changes: 40 additions & 0 deletions pkg/watcher/votes_test.go
Original file line number Diff line number Diff line change
@@ -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")))
})
}

0 comments on commit 2d1e363

Please sign in to comment.