diff --git a/pkg/app/flags.go b/pkg/app/flags.go index a38162d..67705a7 100644 --- a/pkg/app/flags.go +++ b/pkg/app/flags.go @@ -43,6 +43,10 @@ var Flags = []cli.Flag{ Name: "no-staking", Usage: "disable calls to staking module (useful for consumer chains)", }, + &cli.BoolFlag{ + Name: "no-slashing", + Usage: "disable calls to slashing module", + }, &cli.BoolFlag{ Name: "no-commission", Usage: "disable calls to get validator commission (useful for chains without distribution module)", diff --git a/pkg/app/run.go b/pkg/app/run.go index fa83168..b0cb78b 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -42,6 +42,7 @@ func RunFunc(cCtx *cli.Context) error { noStaking = cCtx.Bool("no-staking") noUpgrade = cCtx.Bool("no-upgrade") noCommission = cCtx.Bool("no-commission") + noSlashing = cCtx.Bool("no-slashing") denom = cCtx.String("denom") denomExpon = cCtx.Uint("denom-exponent") startTimeout = cCtx.Duration("start-timeout") @@ -128,6 +129,16 @@ func RunFunc(cCtx *cli.Context) error { }) } + // + // Slashing watchers + // + if !noSlashing { + slashingWatcher := watcher.NewSlashingWatcher(metrics, pool) + errg.Go(func() error { + return slashingWatcher.Start(ctx) + }) + } + // // Pool watchers // @@ -135,6 +146,7 @@ func RunFunc(cCtx *cli.Context) error { validatorsWatcher := watcher.NewValidatorsWatcher(trackedValidators, metrics, pool, watcher.ValidatorsWatcherOptions{ Denom: denom, DenomExponent: denomExpon, + NoSlashing: noSlashing, }) errg.Go(func() error { return validatorsWatcher.Start(ctx) @@ -320,8 +332,10 @@ func createTrackedValidators(ctx context.Context, pool *rpc.Pool, validators []s for _, stakingVal := range stakingValidators { address := crypto.PubKeyAddress(stakingVal.ConsensusPubkey) if address == val.Address { + hrp := crypto.GetHrpPrefix(stakingVal.OperatorAddress) + "valcons" val.Moniker = stakingVal.Description.Moniker val.OperatorAddress = stakingVal.OperatorAddress + val.ConsensusAddress = crypto.PubKeyBech32Address(stakingVal.ConsensusPubkey, hrp) } } @@ -336,6 +350,7 @@ func createTrackedValidators(ctx context.Context, pool *rpc.Pool, validators []s Str("alias", val.Name). Str("moniker", val.Moniker). Str("operator", val.OperatorAddress). + Str("consensus", val.ConsensusAddress). Msgf("validator info") return val diff --git a/pkg/crypto/utils.go b/pkg/crypto/utils.go index 05930c8..9939ec5 100644 --- a/pkg/crypto/utils.go +++ b/pkg/crypto/utils.go @@ -1,21 +1,51 @@ package crypto import ( + "strings" + + "github.com/cometbft/cometbft/libs/bytes" types1 "github.com/cosmos/cosmos-sdk/codec/types" "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + "github.com/cosmos/cosmos-sdk/types/bech32" ) -func PubKeyAddress(consensusPubkey *types1.Any) string { +func PubKeyAddressHelper(consensusPubkey *types1.Any) bytes.HexBytes { switch consensusPubkey.TypeUrl { case "/cosmos.crypto.ed25519.PubKey": key := ed25519.PubKey{Key: consensusPubkey.Value[2:]} - return key.Address().String() + return key.Address() case "/cosmos.crypto.secp256k1.PubKey": key := secp256k1.PubKey{Key: consensusPubkey.Value[2:]} - return key.Address().String() + return key.Address() } - panic("unknown pubkey type: " + consensusPubkey.TypeUrl) } + +func PubKeyAddress(consensusPubkey *types1.Any) string { + key := PubKeyAddressHelper(consensusPubkey) + return key.String() +} + +func PubKeyBech32Address(consensusPubkey *types1.Any, prefix string) string { + key := PubKeyAddressHelper(consensusPubkey) + address, _ := bech32.ConvertAndEncode(prefix, key) + return address +} + +// GetHrpPrefix returns the human-readable prefix for a given address. +// Examples of valid address HRPs are "cosmosvalcons", "cosmosvaloper". +// So this will return "cosmos" as the prefix +func GetHrpPrefix(a string) string { + + hrp, _, err := bech32.DecodeAndConvert(a) + if err != nil { + return err.Error() + } + + for _, v := range []string{"valoper", "cncl", "valcons"} { + hrp = strings.TrimSuffix(hrp, v) + } + return hrp +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index ee0b3b3..e5e5f6d 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -9,27 +9,33 @@ type Metrics struct { Registry *prometheus.Registry // Global metrics - ActiveSet *prometheus.GaugeVec - BlockHeight *prometheus.GaugeVec - ProposalEndTime *prometheus.GaugeVec - SeatPrice *prometheus.GaugeVec - SkippedBlocks *prometheus.CounterVec - TrackedBlocks *prometheus.CounterVec - Transactions *prometheus.CounterVec - UpgradePlan *prometheus.GaugeVec + ActiveSet *prometheus.GaugeVec + BlockHeight *prometheus.GaugeVec + ProposalEndTime *prometheus.GaugeVec + SeatPrice *prometheus.GaugeVec + SkippedBlocks *prometheus.CounterVec + TrackedBlocks *prometheus.CounterVec + Transactions *prometheus.CounterVec + UpgradePlan *prometheus.GaugeVec + SignedBlocksWindow *prometheus.GaugeVec + MinSignedBlocksPerWindow *prometheus.GaugeVec + DowntimeJailDuration *prometheus.GaugeVec + SlashFractionDoubleSign *prometheus.GaugeVec + SlashFractionDowntime *prometheus.GaugeVec // Validator metrics - Rank *prometheus.GaugeVec - ProposedBlocks *prometheus.CounterVec - ValidatedBlocks *prometheus.CounterVec - MissedBlocks *prometheus.CounterVec - SoloMissedBlocks *prometheus.CounterVec + Rank *prometheus.GaugeVec + ProposedBlocks *prometheus.CounterVec + ValidatedBlocks *prometheus.CounterVec + MissedBlocks *prometheus.CounterVec + SoloMissedBlocks *prometheus.CounterVec ConsecutiveMissedBlocks *prometheus.GaugeVec - Tokens *prometheus.GaugeVec - IsBonded *prometheus.GaugeVec - IsJailed *prometheus.GaugeVec - Commission *prometheus.GaugeVec - Vote *prometheus.GaugeVec + MissedBlocksWindow *prometheus.GaugeVec + Tokens *prometheus.GaugeVec + IsBonded *prometheus.GaugeVec + IsJailed *prometheus.GaugeVec + Commission *prometheus.GaugeVec + Vote *prometheus.GaugeVec // Node metrics NodeBlockHeight *prometheus.GaugeVec @@ -111,6 +117,14 @@ func New(namespace string) *Metrics { }, []string{"chain_id", "address", "name"}, ), + MissedBlocksWindow: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "missed_blocks_window", + Help: "Number of missed blocks per validator for the current signing window (for a bonded validator)", + }, + []string{"chain_id", "address", "name"}, + ), TrackedBlocks: prometheus.NewCounterVec( prometheus.CounterOpts{ Namespace: namespace, @@ -207,6 +221,46 @@ func New(namespace string) *Metrics { }, []string{"chain_id", "proposal_id"}, ), + SignedBlocksWindow: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "signed_blocks_window", + Help: "Number of blocks per signing window", + }, + []string{"chain_id"}, + ), + MinSignedBlocksPerWindow: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "min_signed_blocks_per_window", + Help: "Minimum number of blocks required to be signed per signing window", + }, + []string{"chain_id"}, + ), + DowntimeJailDuration: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "downtime_jail_duration", + Help: "Duration of the jail period for a validator in seconds", + }, + []string{"chain_id"}, + ), + SlashFractionDoubleSign: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "slash_fraction_double_sign", + Help: "Slash penaltiy for double-signing", + }, + []string{"chain_id"}, + ), + SlashFractionDowntime: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "slash_fraction_downtime", + Help: "Slash penaltiy for downtime", + }, + []string{"chain_id"}, + ), } return metrics @@ -225,6 +279,7 @@ func (m *Metrics) Register() { m.Registry.MustRegister(m.MissedBlocks) m.Registry.MustRegister(m.SoloMissedBlocks) m.Registry.MustRegister(m.ConsecutiveMissedBlocks) + m.Registry.MustRegister(m.MissedBlocksWindow) m.Registry.MustRegister(m.TrackedBlocks) m.Registry.MustRegister(m.Transactions) m.Registry.MustRegister(m.SkippedBlocks) @@ -237,4 +292,9 @@ func (m *Metrics) Register() { m.Registry.MustRegister(m.NodeSynced) m.Registry.MustRegister(m.UpgradePlan) m.Registry.MustRegister(m.ProposalEndTime) + m.Registry.MustRegister(m.SignedBlocksWindow) + m.Registry.MustRegister(m.MinSignedBlocksPerWindow) + m.Registry.MustRegister(m.DowntimeJailDuration) + m.Registry.MustRegister(m.SlashFractionDoubleSign) + m.Registry.MustRegister(m.SlashFractionDowntime) } diff --git a/pkg/watcher/slashing.go b/pkg/watcher/slashing.go new file mode 100644 index 0000000..6372f8c --- /dev/null +++ b/pkg/watcher/slashing.go @@ -0,0 +1,90 @@ +package watcher + +import ( + "context" + "fmt" + "time" + + "github.com/cosmos/cosmos-sdk/client" + slashing "github.com/cosmos/cosmos-sdk/x/slashing/types" + "github.com/kilnfi/cosmos-validator-watcher/pkg/metrics" + "github.com/kilnfi/cosmos-validator-watcher/pkg/rpc" + "github.com/rs/zerolog/log" +) + +type SlashingWatcher struct { + metrics *metrics.Metrics + pool *rpc.Pool + + signedBlocksWindow int64 + minSignedPerWindow float64 + downtimeJailDuration float64 + slashFractionDoubleSign float64 + slashFractionDowntime float64 +} + +func NewSlashingWatcher(metrics *metrics.Metrics, pool *rpc.Pool) *SlashingWatcher { + return &SlashingWatcher{ + metrics: metrics, + pool: pool, + } +} + +func (w *SlashingWatcher) Start(ctx context.Context) error { + // update metrics every 30 minutes + ticker := time.NewTicker(30 * time.Minute) + + for { + node := w.pool.GetSyncedNode() + if node == nil { + log.Warn().Msg("no node available to fetch slashing parameters") + } else if err := w.fetchSlashingParameters(ctx, node); err != nil { + log.Error().Err(err). + Str("node", node.Redacted()). + Msg("failed to fetch slashing parameters") + } + + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + } +} + +func (w *SlashingWatcher) fetchSlashingParameters(ctx context.Context, node *rpc.Node) error { + clientCtx := (client.Context{}).WithClient(node.Client) + queryClient := slashing.NewQueryClient(clientCtx) + sigininParams, err := queryClient.Params(ctx, &slashing.QueryParamsRequest{}) + if err != nil { + return fmt.Errorf("failed to get slashing parameters: %w", err) + } + + w.handleSlashingParams(node.ChainID(), sigininParams.Params) + + return nil + +} + +func (w *SlashingWatcher) handleSlashingParams(chainID string, params slashing.Params) { + log.Debug(). + Str("chainID", chainID). + Str("downtimeJailDuration", params.DowntimeJailDuration.String()). + Str("minSignedPerWindow", fmt.Sprintf("%.2f", params.MinSignedPerWindow.MustFloat64())). + Str("signedBlocksWindow", fmt.Sprint(params.SignedBlocksWindow)). + Str("slashFractionDoubleSign", fmt.Sprintf("%.2f", params.SlashFractionDoubleSign.MustFloat64())). + Str("slashFractionDowntime", fmt.Sprintf("%.2f", params.SlashFractionDowntime.MustFloat64())). + Msgf("updating slashing metrics") + + w.signedBlocksWindow = params.SignedBlocksWindow + w.minSignedPerWindow, _ = params.MinSignedPerWindow.Float64() + w.downtimeJailDuration = params.DowntimeJailDuration.Seconds() + w.slashFractionDoubleSign, _ = params.SlashFractionDoubleSign.Float64() + w.slashFractionDowntime, _ = params.SlashFractionDowntime.Float64() + + w.metrics.SignedBlocksWindow.WithLabelValues(chainID).Set(float64(w.signedBlocksWindow)) + w.metrics.MinSignedBlocksPerWindow.WithLabelValues(chainID).Set(w.minSignedPerWindow) + w.metrics.DowntimeJailDuration.WithLabelValues(chainID).Set(w.downtimeJailDuration) + w.metrics.SlashFractionDoubleSign.WithLabelValues(chainID).Set(w.slashFractionDoubleSign) + w.metrics.SlashFractionDowntime.WithLabelValues(chainID).Set(w.slashFractionDowntime) +} diff --git a/pkg/watcher/slashing_test.go b/pkg/watcher/slashing_test.go new file mode 100644 index 0000000..0a52f27 --- /dev/null +++ b/pkg/watcher/slashing_test.go @@ -0,0 +1,45 @@ +package watcher + +import ( + "testing" + "time" + + cosmossdk_io_math "cosmossdk.io/math" + slashing "github.com/cosmos/cosmos-sdk/x/slashing/types" + "github.com/kilnfi/cosmos-validator-watcher/pkg/metrics" + "github.com/prometheus/client_golang/prometheus/testutil" + "gotest.tools/assert" +) + +func TestSlashingWatcher(t *testing.T) { + var chainID = "test-chain" + + watcher := NewSlashingWatcher( + metrics.New("cosmos_validator_watcher"), + nil, + ) + + t.Run("Handle Slashing Parameters", func(t *testing.T) { + + minSignedPerWindow := cosmossdk_io_math.LegacyMustNewDecFromStr("0.1") + slashFractionDoubleSign := cosmossdk_io_math.LegacyMustNewDecFromStr("0.01") + slashFractionDowntime := cosmossdk_io_math.LegacyMustNewDecFromStr("0.001") + + params := slashing.Params{ + SignedBlocksWindow: int64(1000), + MinSignedPerWindow: minSignedPerWindow, + DowntimeJailDuration: time.Duration(10) * time.Second, + SlashFractionDoubleSign: slashFractionDoubleSign, + SlashFractionDowntime: slashFractionDowntime, + } + + watcher.handleSlashingParams(chainID, params) + + assert.Equal(t, float64(1000), testutil.ToFloat64(watcher.metrics.SignedBlocksWindow.WithLabelValues(chainID))) + assert.Equal(t, float64(0.1), testutil.ToFloat64(watcher.metrics.MinSignedBlocksPerWindow.WithLabelValues(chainID))) + assert.Equal(t, float64(10), testutil.ToFloat64(watcher.metrics.DowntimeJailDuration.WithLabelValues(chainID))) + assert.Equal(t, float64(0.01), testutil.ToFloat64(watcher.metrics.SlashFractionDoubleSign.WithLabelValues(chainID))) + assert.Equal(t, float64(0.001), testutil.ToFloat64(watcher.metrics.SlashFractionDowntime.WithLabelValues(chainID))) + }) + +} diff --git a/pkg/watcher/types.go b/pkg/watcher/types.go index df2e2e8..cf6c5e9 100644 --- a/pkg/watcher/types.go +++ b/pkg/watcher/types.go @@ -4,13 +4,15 @@ import ( "strings" "github.com/cosmos/cosmos-sdk/types/bech32" + utils "github.com/kilnfi/cosmos-validator-watcher/pkg/crypto" ) type TrackedValidator struct { - Address string - Name string - Moniker string - OperatorAddress string + Address string + Name string + Moniker string + OperatorAddress string + ConsensusAddress string } func ParseValidator(val string) TrackedValidator { @@ -29,14 +31,13 @@ func ParseValidator(val string) TrackedValidator { } func (t TrackedValidator) AccountAddress() string { - prefix, bytes, err := bech32.DecodeAndConvert(t.OperatorAddress) + _, bytes, err := bech32.DecodeAndConvert(t.OperatorAddress) if err != nil { return err.Error() } - for _, v := range []string{"valoper", "cncl"} { - prefix = strings.TrimSuffix(prefix, v) - } + prefix := utils.GetHrpPrefix(t.OperatorAddress) + conv, err := bech32.ConvertAndEncode(prefix, bytes) if err != nil { return err.Error() diff --git a/pkg/watcher/validators.go b/pkg/watcher/validators.go index e6ce835..6facbae 100644 --- a/pkg/watcher/validators.go +++ b/pkg/watcher/validators.go @@ -8,6 +8,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/types/query" + slashing "github.com/cosmos/cosmos-sdk/x/slashing/types" staking "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/kilnfi/cosmos-validator-watcher/pkg/crypto" "github.com/kilnfi/cosmos-validator-watcher/pkg/metrics" @@ -26,6 +27,7 @@ type ValidatorsWatcher struct { type ValidatorsWatcherOptions struct { Denom string DenomExponent uint + NoSlashing bool } func NewValidatorsWatcher(validators []TrackedValidator, metrics *metrics.Metrics, pool *rpc.Pool, opts ValidatorsWatcherOptions) *ValidatorsWatcher { @@ -48,8 +50,11 @@ func (w *ValidatorsWatcher) Start(ctx context.Context) error { log.Error().Err(err). Str("node", node.Redacted()). Msg("failed to fetch staking validators") + } else if err := w.fetchSigningInfos(ctx, node); err != nil { + log.Error().Err(err). + Str("node", node.Redacted()). + Msg("failed to fetch signing infos") } - select { case <-ctx.Done(): return nil @@ -58,6 +63,28 @@ func (w *ValidatorsWatcher) Start(ctx context.Context) error { } } +func (w *ValidatorsWatcher) fetchSigningInfos(ctx context.Context, node *rpc.Node) error { + if !w.opts.NoSlashing { + clientCtx := (client.Context{}).WithClient(node.Client) + queryClient := slashing.NewQueryClient(clientCtx) + signingInfos, err := queryClient.SigningInfos(ctx, &slashing.QuerySigningInfosRequest{ + Pagination: &query.PageRequest{ + Limit: 3000, + }, + }) + if err != nil { + return fmt.Errorf("failed to get signing infos: %w", err) + } + + w.handleSigningInfos(node.ChainID(), signingInfos.Info) + + return nil + } else { + return nil + } + +} + func (w *ValidatorsWatcher) fetchValidators(ctx context.Context, node *rpc.Node) error { clientCtx := (client.Context{}).WithClient(node.Client) queryClient := staking.NewQueryClient(clientCtx) @@ -76,6 +103,20 @@ func (w *ValidatorsWatcher) fetchValidators(ctx context.Context, node *rpc.Node) return nil } +func (w *ValidatorsWatcher) handleSigningInfos(chainID string, signingInfos []slashing.ValidatorSigningInfo) { + for _, tracked := range w.validators { + + for _, val := range signingInfos { + + if tracked.ConsensusAddress == val.Address { + w.metrics.MissedBlocksWindow.WithLabelValues(chainID, tracked.Address, tracked.Name).Set(float64(val.MissedBlocksCounter)) + break + } + } + + } +} + func (w *ValidatorsWatcher) handleValidators(chainID string, validators []staking.Validator) { // Sort validators by tokens & status (bonded, unbonded, jailed) sort.Sort(RankedValidators(validators)) @@ -99,7 +140,6 @@ func (w *ValidatorsWatcher) handleValidators(chainID string, validators []stakin for i, val := range validators { address := crypto.PubKeyAddress(val.ConsensusPubkey) - if tracked.Address == address { var ( rank = i + 1 diff --git a/pkg/watcher/validators_test.go b/pkg/watcher/validators_test.go index 101adb2..671d399 100644 --- a/pkg/watcher/validators_test.go +++ b/pkg/watcher/validators_test.go @@ -6,7 +6,9 @@ import ( "cosmossdk.io/math" codectypes "github.com/cosmos/cosmos-sdk/codec/types" + slashing "github.com/cosmos/cosmos-sdk/x/slashing/types" staking "github.com/cosmos/cosmos-sdk/x/staking/types" + utils "github.com/kilnfi/cosmos-validator-watcher/pkg/crypto" "github.com/kilnfi/cosmos-validator-watcher/pkg/metrics" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" @@ -23,8 +25,9 @@ func TestValidatorsWatcher(t *testing.T) { validatorsWatcher := NewValidatorsWatcher( []TrackedValidator{ { - Address: kilnAddress, - Name: kilnName, + Address: kilnAddress, + Name: kilnName, + ConsensusAddress: "cosmosvalcons18hzd6cggzasx449gl8tk9grg4q0gws0z52nvvy", }, }, metrics.New("cosmos_validator_watcher"), @@ -32,6 +35,7 @@ func TestValidatorsWatcher(t *testing.T) { ValidatorsWatcherOptions{ Denom: "denom", DenomExponent: 6, + NoSlashing: false, }, ) @@ -47,6 +51,12 @@ func TestValidatorsWatcher(t *testing.T) { } } + createConsAddress := func(pubkey codectypes.Any) string { + prefix := "cosmosvalcons" + consensusAddress := utils.PubKeyBech32Address(&pubkey, prefix) + return consensusAddress + } + validators := []staking.Validator{ { OperatorAddress: "", @@ -78,11 +88,20 @@ func TestValidatorsWatcher(t *testing.T) { }, } + validatorSigningInfo := []slashing.ValidatorSigningInfo{ + { + Address: createConsAddress(*createAddress("915dea44121fbceb01452f98ca005b457fe8360c5e191b6601ee01b8a8d407a0")), + MissedBlocksCounter: 3, + }} + validatorsWatcher.handleValidators(chainID, validators) + validatorsWatcher.handleSigningInfos(chainID, validatorSigningInfo) assert.Equal(t, float64(42), testutil.ToFloat64(validatorsWatcher.metrics.Tokens.WithLabelValues(chainID, kilnAddress, kilnName, "denom"))) assert.Equal(t, float64(2), testutil.ToFloat64(validatorsWatcher.metrics.Rank.WithLabelValues(chainID, kilnAddress, kilnName))) assert.Equal(t, float64(1), testutil.ToFloat64(validatorsWatcher.metrics.IsBonded.WithLabelValues(chainID, kilnAddress, kilnName))) assert.Equal(t, float64(0), testutil.ToFloat64(validatorsWatcher.metrics.IsJailed.WithLabelValues(chainID, kilnAddress, kilnName))) + + assert.Equal(t, float64(3), testutil.ToFloat64(validatorsWatcher.metrics.MissedBlocksWindow.WithLabelValues(chainID, kilnAddress, kilnName))) }) }