diff --git a/.gitignore b/.gitignore index 22b9958..bb62367 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ main - +config.toml dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..211415b --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,39 @@ +linters-settings: + gocritic: + disabled-checks: + - ifElseChain + gosec: + excludes: + - G402 + - G404 + revive: + rules: + - name: var-declaration + disabled: true + +linters: + enable-all: true + disable: + - funlen + - scopelint + - interfacer + - exhaustivestruct + - maligned + - golint + - nlreturn + - wrapcheck + - gomnd + - cyclop + - goerr113 + - exhaustruct + - wsl + - lll + - dupl + - varnamelen + - gomoddirectives + # - prealloc + # - unparam + # - nestif + # - errcheck + # - noctx + # - gocognit diff --git a/README.md b/README.md index 41ade12..671fa3c 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,18 @@ Download the latest release from [the releases page](https://github.com/solarlab ```sh wget -tar xvfz missed-blocks-checker_* +tar ./missed-blocks-checker --telegram-token --telegram-chat ``` -That's not really interesting, what you probably want to do is to have it running in the background. For that, first of all, we have to copy the file to the system apps folder: +Alternatively, install `golang` (>1.18), clone the repo and build it. This will generate a `./main` binary file in the repository folder: +``` +git clone https://github.com/solarlabsteam/missed-blocks-checker +cd missed-blocks-checker +go build +``` + +What you probably want to do is to have it running in the background. For that, first of all, we have to copy the file to the system apps folder: ```sh sudo cp ./missed-blocks-checker /usr/bin @@ -39,7 +46,7 @@ User= TimeoutStartSec=0 CPUWeight=95 IOWeight=95 -ExecStart=missed-blocks-checker --telegram-token --telegram-chat +ExecStart=missed-blocks-checker --config Restart=always RestartSec=2 LimitNOFILE=800000 @@ -52,9 +59,9 @@ WantedBy=multi-user.target Then we'll add this service to the autostart and run it: ```sh -sudo systemctl daemon-reload -sudo systemctl enable missed-blocks-checker -sudo systemctl start missed-blocks-checker +sudo systemctl daemon-reload # reload config to reflect changed +sudo systemctl enable missed-blocks-checker # put service to autostart +sudo systemctl start missed-blocks-checker # start the service sudo systemctl status missed-blocks-checker # validate it's running ``` @@ -70,37 +77,7 @@ It periodically queries the full node via gRPC for all validators and their miss ## How can I configure it? -You can pass the artuments to the executable file to configure it. Here is the parameters list: - -- `--bech-prefix` - the global prefix for addresses. Defaults to `persistence` -- `--node` - the gRPC node URL. Defaults to `localhost:9090` -- `--log-devel` - logger level. Defaults to `info`. You can set it to `debug` to make it more verbose. -- `--limit` - pagination limit for gRPC requests. Defaults to 1000. -- `--telegram-token` - Telegram bot token -- `--telegram-chat` - Telegram user or chat ID -- `--mintscan-prefix` - This bot generates links to Mintscan for validators, using this prefix. Links have the following format: `https://mintscan.io//validator/`. Defaults to `persistence`. -- `--interval` - Interval between the two checks, in seconds. Defaults to 120 -- `--include` - a comma-separated list of validators' operators addresses. If specified, only the validators from this list would be monitored. -- `--exclude` - a comma-separated list of validators' operators addresses. If specified, all validators except the ones from this list would be monitored. - -(Note that you cannot use `--include` and `--exclude` at the same time.) - - -You can also specify custom Bech32 prefixes for wallets, validators, consensus nodes, and their pubkeys by using the following params: -- `--bech-validator-prefix` -- `--bech-validator-pubkey-prefix` -- `--bech-consensus-node-prefix` -- `--bech-consensus-node-pubkey-prefix` - -By default, if not specified, it defaults to the next values (as it works this way for the most of the networks): -- `--bech-validator-prefix` = `--bech-prefix` + "valoper" -- `--bech-validator-pubkey-prefix` = `--bech-prefix` + "valoperpub" -- `--bech-consensus-node-prefix` = `--bech-prefix` + "valcons" -- `--bech-consensus-node-pubkey-prefix` = `--bech-prefix` + "valconspub" - -An example of the network where you have to specify all the prefixes manually is Iris. - -Additionally, you can pass a `--config` flag with a path to your config file (I use .toml, but anything supported by [viper](https://github.com/spf13/viper) should work). +All configuration is done via `.toml` config file, which is mandatory. Run the app with `--config ` to specify config. Check out `config.example.toml` to see the params that can be set. ## Notifications channels @@ -112,13 +89,13 @@ Go to @BotFather in Telegram and create a bot. After that, there are two options - you want to send messages to a channel. Write something to a channel, then forward it to @getmyid_bot and copy the `Forwarded from chat` number. Then add the bot as an admin. -Then run a program with `--telegram-token --telegram-chat `. +Then add a Telegram config to your config file (see `config.example.toml` for reference). 2) Slack Go to the Slack web interface -> Manage apps and create a new app. Give the app the `chat:write` scope and add the integration to a channel by typing `/invite ` there. -After that, run the program with `--slack-token --slack-chat `. +After that add a Slack config to your config file (see `config.example.toml` for reference). ## Which networks this is guaranteed to work? diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..6a40903 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,95 @@ +# Bech prefixes for network. +bech-prefix = "cosmos" +# If a network has specific bech prefixes for validator and for consensus node +# and their pubkeys, it's possible to specify them separately. +bech-validator-prefix = "cosmosvaloper" +bech-validator-pubkey-prefix = "cosmosvaloperpub" +bech-consensus-node-prefix = "cosmosvalcons" +bech-consensus-node-pubkey-prefix = "cosmosvalconspub" +# Scrape interval, in seconds. Defaults to 120 +interval = 120 +# List of validators to monitor, with it specified, only the selected validators +# will be monitored. Cannot be used together with exclude-validators. +# If both include-validators and exclude-validators are not specified, +# all validators will be monitored. +include-validators = ["cosmosvaloperxxx"] +# List of validators to exclude from monitoring, with it specified, all validators except mentioned +# will be monitored. Cannot be used together with include-validators. +exclude-validators = ["cosmosvaloperyyy"] + +# Node config. +[node] +# gRPC node address to get signing info and validators info from, defaults to localhost:9090 +grpc-address = "localhost:9090" +# Tendermint RPC node to get block info from. Defaults to http://localhost:26657. +rpc-address = "http://localhost:26657" + +# Logging config. +[log] +# Log level. Defaults to 'info', you can set it to 'debug' or even 'trace' +# to make it more verbose. +level = "info" +# Log all output in JSON except for fatal errors, useful if you are using +# logging aggregation solutions such as ELK stack. +json = true + +# Chain info config. +[chain-info] +# Mintscan prefix, to generate links to validator. +mintscan-prefix = "cosmos" + +# List of missed blocks groups. +[[missed-blocks-groups]] +# Start value of missed blocks. If a validator's missed blocks counter is between this +# and end value, it will fall under this group. +start = 0 +# End value of missed blocks +end = 999 +# Emoji displayed when a validator enters this group. +emoji-start = "🟡" +# Emoji displayed when a validator leaves this group. +emoji-end = "🟢" +# Description displayed when a validator enters this group. +desc-start = "is skipping blocks (0-10%)" +# Description displayed when a validator leaves this group. +desc-end = "is recovered (<10%)" + +[[missed-blocks-groups]] +start = 1000 +end = 4999 +emoji-start = "🟠" +emoji-end = "🟡" +desc-start = "is skipping blocks (>10%)" +desc-end = "is recovering (<50%)" + +[[missed-blocks-groups]] +start = 5000 +end = 8999 +emoji-start = "🔴" +emoji-end = "🟠" +desc-start = "is skipping blocks (>50%)" +desc-end = "is recovering (<90%)" + +[[missed-blocks-groups]] +start = 9000 +end = 10000 +emoji-start = "🔴" +emoji-end = "🟠" +desc-start = "is skipping blocks (>90%)" +desc-end = "is recovering (90-100%)" + +# Telegram reporter. All fields are mandatory, otherwise the reporter won't be enabled. +[telegram] +# A Telegram bot token. +token = "111:222" +# A Telegram chat to send messages to. +chat = -123 +# Path to a file storing all information about people's links to validators. +config-path = "/home/user/config/missed-blocks-checker-telegram-labels.toml" + +# Slack reporter. All fields are mandatory, otherwise the reporter won't be enabled. +[slack] +# A Slack bot token. +token = "xorb-xxxyyyy" +# A Slack channel or username to send messages to. +chat = "#general" diff --git a/config.go b/config.go new file mode 100644 index 0000000..5ff098c --- /dev/null +++ b/config.go @@ -0,0 +1,218 @@ +package main + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" + "github.com/mcuadros/go-defaults" +) + +type TelegramAppConfig struct { + Token string `toml:"token"` + Chat int `toml:"chat"` + ConfigPath string `toml:"config-path"` +} + +type SlackConfig struct { + Token string `toml:"token"` + Chat string `toml:"chat"` +} + +type LogConfig struct { + LogLevel string `toml:"level" default:"info"` + JSONOutput bool `toml:"json" default:"false"` +} + +type ChainInfoConfig struct { + MintscanPrefix string `toml:"mintscan-prefix"` +} + +type NodeConfig struct { + GrpcAddress string `toml:"grpc-address" default:"localhost:9090"` + TendermintRPC string `toml:"rpc-address" default:"http://localhost:26657"` +} + +type AppConfig struct { + LogConfig LogConfig `toml:"log"` + ChainInfoConfig ChainInfoConfig `toml:"chain-info"` + NodeConfig NodeConfig `toml:"node"` + + Interval int `toml:"interval" default:"120"` + + Prefix string `toml:"bech-prefix"` + ValidatorPrefix string `toml:"bech-validator-prefix"` + ValidatorPubkeyPrefix string `toml:"bech-validator-pubkey-prefix"` + ConsensusNodePrefix string `toml:"bech-consensus-node-prefix"` + ConsensusNodePubkeyPrefix string `toml:"bech-consensus-node-pubkey-prefix"` + + IncludeValidators []string `toml:"include-validators"` + ExcludeValidators []string `toml:"exclude-validators"` + + MissedBlocksGroups MissedBlocksGroups `toml:"missed-blocks-groups"` + + TelegramConfig TelegramAppConfig `toml:"telegram"` + SlackConfig SlackConfig `toml:"slack"` +} + +type MissedBlocksGroup struct { + Start int64 `toml:"start"` + End int64 `toml:"end"` + EmojiStart string `toml:"emoji-start"` + EmojiEnd string `toml:"emoji-end"` + DescStart string `toml:"desc-start"` + DescEnd string `toml:"desc-end"` +} + +type MissedBlocksGroups []MissedBlocksGroup + +// Checks that MissedBlocksGroup is an array of sorted MissedBlocksGroup +// covering each interval. +// Example (start - end), given that window = 300: +// 0 - 99, 100 - 199, 200 - 300 - valid +// 0 - 50 - not valid. +func (g MissedBlocksGroups) Validate(window int64) error { + if len(g) == 0 { + return fmt.Errorf("MissedBlocksGroups is empty") + } + + if g[0].Start != 0 { + return fmt.Errorf("first MissedBlocksGroup's start should be 0, got %d", g[0].Start) + } + + if g[len(g)-1].End < window { + return fmt.Errorf("last MissedBlocksGroup's end should be >= %d, got %d", window, g[len(g)-1].End) + } + + for i := 0; i < len(g)-1; i++ { + if g[i+1].Start-g[i].End != 1 { + return fmt.Errorf( + "MissedBlocksGroup at index %d ends at %d, and the next one starts with %d", + i, + g[i].End, + g[i+1].Start, + ) + } + } + + return nil +} + +func (g MissedBlocksGroups) GetGroup(missed int64) (*MissedBlocksGroup, error) { + for _, group := range g { + if missed >= group.Start && missed <= group.End { + return &group, nil + } + } + + return nil, fmt.Errorf("could not find a group for missed blocks counter = %d", missed) +} + +func LoadConfig(path string) (*AppConfig, error) { + configBytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + configString := string(configBytes) + + configStruct := AppConfig{} + if _, err = toml.Decode(configString, &configStruct); err != nil { + return nil, err + } + + defaults.SetDefaults(&configStruct) + return &configStruct, nil +} + +func (config *AppConfig) Validate() { + if len(config.IncludeValidators) != 0 && len(config.ExcludeValidators) != 0 { + GetDefaultLogger().Fatal().Msg("Cannot use --include and --exclude at the same time!") + } +} + +func (config *AppConfig) SetBechPrefixes() { + if config.Prefix == "" && config.ValidatorPrefix == "" { + GetDefaultLogger().Fatal().Msg("Both bech-validator-prefix and bech-prefix are not set!") + } else if config.ValidatorPrefix == "" { + config.ValidatorPrefix = config.Prefix + "valoper" + } + + if config.Prefix == "" && config.ValidatorPubkeyPrefix == "" { + GetDefaultLogger().Fatal().Msg("Both bech-validator-pubkey-prefix and bech-prefix are not set!") + } else if config.ValidatorPubkeyPrefix == "" { + config.ValidatorPubkeyPrefix = config.Prefix + "valoperpub" + } + + if config.Prefix == "" && config.ConsensusNodePrefix == "" { + GetDefaultLogger().Fatal().Msg("Both bech-consensus-node-prefix and bech-prefix are not set!") + } else if config.ConsensusNodePrefix == "" { + config.ConsensusNodePrefix = config.Prefix + "valcons" + } + + if config.Prefix == "" && config.ConsensusNodePubkeyPrefix == "" { + GetDefaultLogger().Fatal().Msg("Both bech-consensus-node-pubkey-prefix and bech-prefix are not set!") + } else if config.ConsensusNodePubkeyPrefix == "" { + config.ConsensusNodePubkeyPrefix = config.Prefix + "valconspub" + } +} + +func (config *AppConfig) SetDefaultMissedBlocksGroups(params Params) { + if config.MissedBlocksGroups != nil { + GetDefaultLogger().Debug().Msg("MissedBlockGroups is set, not setting the default ones.") + return + } + + totalRange := float64(params.SignedBlocksWindow) + 1 // from 0 till max blocks allowed, including + + groups := []MissedBlocksGroup{} + + percents := []float64{0, 0.5, 1, 5, 10, 25, 50, 75, 90, 100} + emojiStart := []string{"🟡", "🟡", "🟡", "🟠", "🟠", "🟠", "🔴", "🔴", "🔴"} + emojiEnd := []string{"🟢", "🟡", "🟡", "🟡", "🟡", "🟠", "🟠", "🟠", "🟠"} + + for i := 0; i < len(percents)-1; i++ { + start := totalRange * percents[i] / 100 + end := totalRange*percents[i+1]/100 - 1 + + groups = append(groups, MissedBlocksGroup{ + Start: int64(start), + End: int64(end), + EmojiStart: emojiStart[i], + EmojiEnd: emojiEnd[i], + DescStart: fmt.Sprintf("is skipping blocks (> %.1f%%)", percents[i]), + DescEnd: fmt.Sprintf("is recovering (< %.1f%%)", percents[i+1]), + }) + } + + groups[0].DescEnd = fmt.Sprintf("is recovered (< %.1f%%)", percents[1]) + + config.MissedBlocksGroups = groups +} + +func (config *AppConfig) IsValidatorMonitored(address string) bool { + // If no args passed, we want to be notified about all validators. + if len(config.IncludeValidators) == 0 && len(config.ExcludeValidators) == 0 { + return true + } + + // If monitoring only specific validators + if len(config.IncludeValidators) != 0 { + for _, monitoredValidatorAddr := range config.IncludeValidators { + if monitoredValidatorAddr == address { + return true + } + } + + return false + } + + // If monitoring all validators except the specified ones + for _, monitoredValidatorAddr := range config.ExcludeValidators { + if monitoredValidatorAddr == address { + return false + } + } + + return true +} diff --git a/go.mod b/go.mod index 2cb1341..aab9017 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alp require ( github.com/BurntSushi/toml v0.3.1 github.com/cosmos/cosmos-sdk v0.42.4 + github.com/mcuadros/go-defaults v1.2.0 github.com/rs/zerolog v1.21.0 github.com/slack-go/slack v0.9.1 github.com/spf13/cobra v1.1.1 diff --git a/go.sum b/go.sum index 67acc9e..e09442a 100644 --- a/go.sum +++ b/go.sum @@ -358,6 +358,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= +github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643 h1:hLDRPB66XQT/8+wG9WsDpiCvZf1yKO7sz7scAjSlBa0= github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM= diff --git a/grpc.go b/grpc.go new file mode 100644 index 0000000..3757b7b --- /dev/null +++ b/grpc.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "strconv" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + querytypes "github.com/cosmos/cosmos-sdk/types/query" + slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/rs/zerolog" + "google.golang.org/grpc" +) + +type TendermintGRPC struct { + NodeConfig NodeConfig + Limit uint64 + Client *grpc.ClientConn + Logger zerolog.Logger + Registry codectypes.InterfaceRegistry +} + +func NewTendermintGRPC(nodeConfig NodeConfig, registry codectypes.InterfaceRegistry, logger *zerolog.Logger) *TendermintGRPC { + grpcConn, err := grpc.Dial( + nodeConfig.GrpcAddress, + grpc.WithInsecure(), + ) + if err != nil { + GetDefaultLogger().Fatal().Err(err).Msg("Could not establish gRPC connection") + } + + return &TendermintGRPC{ + NodeConfig: nodeConfig, + Limit: 1000, + Logger: logger.With().Str("component", "grpc").Logger(), + Client: grpcConn, + Registry: registry, + } +} + +type SlashingParams struct { + SignedBlocksWindow int64 + MinSignedPerWindow float64 + MissedBlocksToJail int64 +} + +func (grpc *TendermintGRPC) GetSlashingParams() SlashingParams { + slashingClient := slashingtypes.NewQueryClient(grpc.Client) + params, err := slashingClient.Params( + context.Background(), + &slashingtypes.QueryParamsRequest{}, + ) + if err != nil { + grpc.Logger.Fatal().Err(err).Msg("Could not query slashing params") + } + + minSignedPerWindow, err := strconv.ParseFloat(params.Params.MinSignedPerWindow.String(), 64) + if err != nil { + grpc.Logger.Fatal(). + Err(err). + Msg("Could not parse min signed per window") + } + + return SlashingParams{ + SignedBlocksWindow: params.Params.SignedBlocksWindow, + MinSignedPerWindow: minSignedPerWindow, + MissedBlocksToJail: int64(float64(params.Params.SignedBlocksWindow) * (1 - minSignedPerWindow)), + } +} + +func (grpc *TendermintGRPC) GetSigningInfos() ([]slashingtypes.ValidatorSigningInfo, error) { + slashingClient := slashingtypes.NewQueryClient(grpc.Client) + signingInfos, err := slashingClient.SigningInfos( + context.Background(), + &slashingtypes.QuerySigningInfosRequest{ + Pagination: &querytypes.PageRequest{ + Limit: grpc.Limit, + }, + }, + ) + if err != nil { + grpc.Logger.Error().Err(err).Msg("Could not query for signing info") + return nil, err + } + + return signingInfos.Info, nil +} + +func (grpc *TendermintGRPC) GetValidators() ([]stakingtypes.Validator, error) { + stakingClient := stakingtypes.NewQueryClient(grpc.Client) + validatorsResult, err := stakingClient.Validators( + context.Background(), + &stakingtypes.QueryValidatorsRequest{ + Pagination: &querytypes.PageRequest{ + Limit: grpc.Limit, + }, + }, + ) + if err != nil { + grpc.Logger.Error().Err(err).Msg("Could not query for validators") + return nil, err + } + + return validatorsResult.Validators, nil +} + +func (grpc *TendermintGRPC) GetValidator(address string) (stakingtypes.Validator, error) { + stakingClient := stakingtypes.NewQueryClient(grpc.Client) + + validatorResponse, err := stakingClient.Validator( + context.Background(), + &stakingtypes.QueryValidatorRequest{ValidatorAddr: address}, + ) + if err != nil { + return stakingtypes.Validator{}, err + } + + return validatorResponse.Validator, nil +} + +func (grpc *TendermintGRPC) GetSigningInfo(validator stakingtypes.Validator) (slashingtypes.ValidatorSigningInfo, error) { + slashingClient := slashingtypes.NewQueryClient(grpc.Client) + + err := validator.UnpackInterfaces(grpc.Registry) // Unpack interfaces, to populate the Anys' cached values + if err != nil { + grpc.Logger.Error(). + Str("address", validator.OperatorAddress). + Err(err). + Msg("Could not get unpack validator inferfaces") + return slashingtypes.ValidatorSigningInfo{}, err + } + + pubKey, err := validator.GetConsAddr() + if err != nil { + grpc.Logger.Error(). + Str("address", validator.OperatorAddress). + Err(err). + Msg("Could not get validator pubkey") + return slashingtypes.ValidatorSigningInfo{}, err + } + + signingInfosResponse, err := slashingClient.SigningInfo( + context.Background(), + &slashingtypes.QuerySigningInfoRequest{ConsAddress: pubKey.String()}, + ) + if err != nil { + grpc.Logger.Error(). + Str("address", validator.OperatorAddress). + Err(err). + Msg("Could not get signing info") + return slashingtypes.ValidatorSigningInfo{}, err + } + + return signingInfosResponse.ValSigningInfo, nil +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..44110ec --- /dev/null +++ b/logger.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + + "github.com/rs/zerolog" +) + +func GetDefaultLogger() *zerolog.Logger { + log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() + return &log +} + +func GetLogger(config LogConfig) *zerolog.Logger { + log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() + + logLevel, err := zerolog.ParseLevel(config.LogLevel) + if err != nil { + log.Fatal().Err(err).Msg("Could not parse log level") + } + + if config.JSONOutput { + log = zerolog.New(os.Stdout).With().Timestamp().Logger() + } + + zerolog.SetGlobalLevel(logLevel) + return &log +} diff --git a/main.go b/main.go index 6fcd885..f3c57c2 100644 --- a/main.go +++ b/main.go @@ -1,156 +1,74 @@ package main import ( - "context" "fmt" - "os" - "strconv" "time" - "google.golang.org/grpc" - "github.com/cosmos/cosmos-sdk/simapp" sdk "github.com/cosmos/cosmos-sdk/types" - tmrpc "github.com/tendermint/tendermint/rpc/client/http" - ctypes "github.com/tendermint/tendermint/types" - - querytypes "github.com/cosmos/cosmos-sdk/types/query" - slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" - stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - - "github.com/rs/zerolog" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" ) -var ( - Config AppConfig - - BlocksDiffInThePast int64 = 100 - AvgBlockTime float64 - SignedBlocksWindow int64 - MissedBlocksToJail int64 - - grpcConn *grpc.ClientConn - - State ValidatorsState = make(map[string]ValidatorState) -) - -var reporters []Reporter - -var log = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() - -var ( - encCfg = simapp.MakeTestEncodingConfig() - interfaceRegistry = encCfg.InterfaceRegistry -) - -var rootCmd = &cobra.Command{ - Use: "missed-blocks-checker", - Long: "Tool to monitor missed blocks for Cosmos-chain validators", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if Config.ConfigPath == "" { - SetBechPrefixes(cmd) - return nil - } - - viper.SetConfigFile(Config.ConfigPath) - if err := viper.ReadInConfig(); err != nil { - log.Info().Err(err).Msg("Error reading config file") - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return err - } - } - - // Credits to https://carolynvanslyck.com/blog/2020/08/sting-of-the-viper/ - cmd.Flags().VisitAll(func(f *pflag.Flag) { - if !f.Changed && viper.IsSet(f.Name) { - val := viper.Get(f.Name) - if err := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)); err != nil { - log.Fatal().Err(err).Msg("Could not set flag") - } - } - }) - - SetBechPrefixes(cmd) - - return nil - }, - Run: Execute, +type Params struct { + AvgBlockTime float64 + SignedBlocksWindow int64 + MissedBlocksToJail int64 } -func Execute(cmd *cobra.Command, args []string) { - logLevel, err := zerolog.ParseLevel(Config.LogLevel) +func Execute(configPath string) { + appConfig, err := LoadConfig(configPath) if err != nil { - log.Fatal().Err(err).Msg("Could not parse log level") + GetDefaultLogger().Fatal().Err(err).Msg("Could not load config") } - if Config.JsonOutput { - log = zerolog.New(os.Stdout).With().Timestamp().Logger() - } - - zerolog.SetGlobalLevel(logLevel) + appConfig.Validate() // will exit if not valid + appConfig.SetBechPrefixes() // will exit if not valid + SetSdkConfigPrefixes(appConfig) - config := sdk.GetConfig() - config.SetBech32PrefixForValidator(Config.ValidatorPrefix, Config.ValidatorPubkeyPrefix) - config.SetBech32PrefixForConsensusNode(Config.ConsensusNodePrefix, Config.ConsensusNodePubkeyPrefix) - config.Seal() + log := GetLogger(appConfig.LogConfig) - log.Info(). - Str("config", fmt.Sprintf("%+v", Config)). - Msg("Started with following parameters") - - if len(Config.IncludeValidators) != 0 && len(Config.ExcludeValidators) != 0 { - log.Fatal().Msg("Cannot use --include and --exclude at the same time!") - } - - if len(Config.IncludeValidators) == 0 && len(Config.ExcludeValidators) == 0 { + if len(appConfig.IncludeValidators) == 0 && len(appConfig.ExcludeValidators) == 0 { log.Info().Msg("Monitoring all validators") - } else if len(Config.IncludeValidators) != 0 { + } else if len(appConfig.IncludeValidators) != 0 { log.Info(). - Strs("validators", Config.IncludeValidators). + Strs("validators", appConfig.IncludeValidators). Msg("Monitoring specific validators") } else { log.Info(). - Strs("validators", Config.ExcludeValidators). + Strs("validators", appConfig.ExcludeValidators). Msg("Monitoring all validators except specific") } - grpcConn, err = grpc.Dial( - Config.NodeAddress, - grpc.WithInsecure(), - ) - if err != nil { - log.Fatal().Err(err).Msg("Could not establish gRPC connection") - } + encCfg := simapp.MakeTestEncodingConfig() + interfaceRegistry := encCfg.InterfaceRegistry - defer grpcConn.Close() + rpc := NewTendermintRPC(appConfig.NodeConfig, log) + grpc := NewTendermintGRPC(appConfig.NodeConfig, interfaceRegistry, log) + slashingParams := grpc.GetSlashingParams() - SetAvgBlockTime() - SetMissedBlocksToJail() - SetDefaultMissedBlocksGroups() + params := Params{ + AvgBlockTime: rpc.GetAvgBlockTime(), + SignedBlocksWindow: slashingParams.SignedBlocksWindow, + MissedBlocksToJail: slashingParams.MissedBlocksToJail, + } log.Info(). - Str("groups", fmt.Sprintf("%+v", Config.MissedBlocksGroups)). - Msg("Using the following MissedBlocksGroups") + Int64("missedBlocksToJail", params.MissedBlocksToJail). + Float64("avgBlockTime", params.AvgBlockTime). + Msg("Chain params calculated") - if err := Config.MissedBlocksGroups.Validate(SignedBlocksWindow); err != nil { + appConfig.SetDefaultMissedBlocksGroups(params) + if err := appConfig.MissedBlocksGroups.Validate(params.SignedBlocksWindow); err != nil { log.Fatal().Err(err).Msg("MissedBlockGroups config is invalid") } - reporters = []Reporter{ - &TelegramReporter{ - TelegramToken: Config.TelegramToken, - TelegramChat: Config.TelegramChat, - TelegramConfigPath: Config.TelegramConfigPath, - Config: &Config, - }, - &SlackReporter{ - SlackToken: Config.SlackToken, - SlackChat: Config.SlackChat, - }, + log.Info(). + Str("config", fmt.Sprintf("%+v", appConfig)). + Msg("Started with following parameters") + + reporters := []Reporter{ + NewTelegramReporter(appConfig.ChainInfoConfig, appConfig.TelegramConfig, appConfig, ¶ms, grpc, log), + NewSlackReporter(appConfig.ChainInfoConfig, appConfig.SlackConfig, ¶ms, log), } for _, reporter := range reporters { @@ -161,11 +79,13 @@ func Execute(cmd *cobra.Command, args []string) { } } + reportGenerator := NewReportGenerator(params, grpc, appConfig, log, interfaceRegistry) + for { - report := GenerateReport() + report := reportGenerator.GenerateReport() if report == nil || len(report.Entries) == 0 { log.Info().Msg("Report is empty, not sending.") - time.Sleep(time.Duration(Config.Interval) * time.Second) + time.Sleep(time.Duration(appConfig.Interval) * time.Second) continue } @@ -181,422 +101,34 @@ func Execute(cmd *cobra.Command, args []string) { } } - time.Sleep(time.Duration(Config.Interval) * time.Second) + time.Sleep(time.Duration(appConfig.Interval) * time.Second) } } -func GenerateReport() *Report { - newState, err := GetNewState() - if err != nil { - log.Error().Err(err).Msg("Error getting new state") - return &Report{} - } - - if len(State) == 0 { - log.Info().Msg("No previous state, skipping.") - State = newState - return &Report{} - } - - entries := []ReportEntry{} - - for address, info := range newState { - oldState, ok := State[address] - if !ok { - log.Warn().Str("address", address).Msg("No old state present for address") - continue - } - - entry, present := GetValidatorReportEntry(oldState, info) - if !present { - log.Trace(). - Str("address", address). - Msg("No report entry present") - continue - } - - entries = append(entries, *entry) - } - - State = newState - - return &Report{Entries: entries} +func SetSdkConfigPrefixes(appConfig *AppConfig) { + config := sdk.GetConfig() + config.SetBech32PrefixForValidator(appConfig.ValidatorPrefix, appConfig.ValidatorPubkeyPrefix) + config.SetBech32PrefixForConsensusNode(appConfig.ConsensusNodePrefix, appConfig.ConsensusNodePubkeyPrefix) + config.Seal() } -func GetNewState() (ValidatorsState, error) { - log.Debug().Msg("Querying for signing infos...") - - slashingClient := slashingtypes.NewQueryClient(grpcConn) - signingInfos, err := slashingClient.SigningInfos( - context.Background(), - &slashingtypes.QuerySigningInfosRequest{ - Pagination: &querytypes.PageRequest{ - Limit: Config.Limit, - }, - }, - ) - if err != nil { - log.Error().Err(err).Msg("Could not query for signing info") - return nil, err - } +func main() { + var ConfigPath string - stakingClient := stakingtypes.NewQueryClient(grpcConn) - validatorsResult, err := stakingClient.Validators( - context.Background(), - &stakingtypes.QueryValidatorsRequest{ - Pagination: &querytypes.PageRequest{ - Limit: Config.Limit, - }, + rootCmd := &cobra.Command{ + Use: "tendermint-exporter", + Long: "Scrape the data on Tendermint node.", + Run: func(cmd *cobra.Command, args []string) { + Execute(ConfigPath) }, - ) - if err != nil { - log.Error().Err(err).Msg("Could not query for validators") - return nil, err - } - - validatorsMap := make(map[string]stakingtypes.Validator, len(validatorsResult.Validators)) - for _, validator := range validatorsResult.Validators { - err := validator.UnpackInterfaces(interfaceRegistry) - if err != nil { - log.Error().Err(err).Msg("Could not unpack interface") - return nil, err - } - - pubKey, err := validator.GetConsAddr() - if err != nil { - log.Error().Err(err).Msg("Could not get cons addr") - return nil, err - } - - validatorsMap[pubKey.String()] = validator - } - - newState := make(ValidatorsState, len(signingInfos.Info)) - - for _, info := range signingInfos.Info { - validator, ok := validatorsMap[info.Address] - if !ok { - log.Warn().Str("address", info.Address).Msg("Could not find validator by pubkey") - continue - } - - if !IsValidatorMonitored(validator.OperatorAddress) { - log.Trace().Str("address", info.Address).Msg("Not monitoring this validator, skipping.") - continue - } - - newState[info.Address] = ValidatorState{ - Address: validator.OperatorAddress, - Moniker: validator.Description.Moniker, - ConsensusAddress: info.Address, - MissedBlocks: info.MissedBlocksCounter, - Jailed: validator.Jailed, - Tombstoned: info.Tombstoned, - } - } - - return newState, nil -} - -func GetValidatorReportEntry(oldState, newState ValidatorState) (*ReportEntry, bool) { - log.Trace(). - Str("oldState", fmt.Sprintf("%+v", oldState)). - Str("newState", fmt.Sprintf("%+v", newState)). - Msg("Processing validator report entry") - - // 1. If validator's tombstoned, but wasn't - set tombstoned report entry. - if newState.Tombstoned && !oldState.Tombstoned { - log.Debug(). - Str("address", oldState.Address). - Msg("Validator is tombstoned") - return &ReportEntry{ - ValidatorAddress: newState.Address, - ValidatorMoniker: newState.Moniker, - Emoji: TombstonedEmoji, - Description: TombstonedDesc, - Direction: TOMBSTONED, - }, true } - // 2. If validator's jailed, but wasn't - set jailed report entry. - if newState.Jailed && !oldState.Jailed { - log.Debug(). - Str("address", oldState.Address). - Msg("Validator is jailed") - return &ReportEntry{ - ValidatorAddress: newState.Address, - ValidatorMoniker: newState.Moniker, - Emoji: JailedEmoju, - Description: JailedDesc, - Direction: JAILED, - }, true + rootCmd.PersistentFlags().StringVar(&ConfigPath, "config", "", "Config file path") + if err := rootCmd.MarkPersistentFlagRequired("config"); err != nil { + GetDefaultLogger().Fatal().Err(err).Msg("Could not set flags") } - // 3. If validator's not jailed, but was - set unjailed report entry. - if !newState.Jailed && oldState.Jailed { - log.Debug(). - Str("address", oldState.Address). - Msg("Validator is unjailed") - return &ReportEntry{ - ValidatorAddress: newState.Address, - ValidatorMoniker: newState.Moniker, - Emoji: UnjailedEmoji, - Description: UnjailedDesc, - Direction: UNJAILED, - }, true - } - - // 4. If validator is and was jailed - do nothing. - if newState.Jailed && oldState.Jailed { - log.Debug(). - Str("address", oldState.Address). - Msg("Validator is and was jailed - no need to send report") - return nil, false - } - - // 5. Validator isn't and wasn't jailed. - // - // First, check if old and new groups are the same - if they have different start, - // they are different. If they don't - they aren't so no need to send a notification. - oldGroup, oldGroupErr := Config.MissedBlocksGroups.GetGroup(oldState.MissedBlocks) - if oldGroupErr != nil { - log.Error().Err(oldGroupErr).Msg("Could not get old group") - return nil, false - } - newGroup, newGroupErr := Config.MissedBlocksGroups.GetGroup(newState.MissedBlocks) - if newGroupErr != nil { - log.Error().Err(newGroupErr).Msg("Could not get new group") - return nil, false - } - - if oldGroup.Start == newGroup.Start { - log.Debug(). - Str("address", oldState.Address). - Int64("before", oldState.MissedBlocks). - Int64("after", newState.MissedBlocks). - Msg("Validator didn't change group - no need to send report") - return nil, false - } - - // Validator switched from one MissedBlockGroup to another, 2 cases how that may happen - // 1) validator is skipping blocks - // 2) validator skipped some blocks in the past, but recovered, is now signing, and the window - // moves - the amount of missed blocks is decreasing. - // Need to understand which one it is: if old missed blocks < new missed blocks - - // it's 1), if vice versa, then 2) - - entry := &ReportEntry{ - ValidatorAddress: newState.Address, - ValidatorMoniker: newState.Moniker, - MissingBlocks: newState.MissedBlocks, - } - - if oldState.MissedBlocks < newState.MissedBlocks { - // skipping blocks - log.Debug(). - Str("address", oldState.Address). - Int64("before", oldState.MissedBlocks). - Int64("after", newState.MissedBlocks). - Msg("Validator's missed blocks increasing") - entry.Direction = INCREASING - entry.Emoji = newGroup.EmojiStart - entry.Description = newGroup.DescStart - } else { - // restoring - log.Debug(). - Str("address", oldState.Address). - Int64("before", oldState.MissedBlocks). - Int64("after", newState.MissedBlocks). - Msg("Validator's missed blocks decreasing") - entry.Direction = DECREASING - entry.Emoji = newGroup.EmojiEnd - entry.Description = newGroup.DescEnd - } - - return entry, true -} - -func SetBechPrefixes(cmd *cobra.Command) { - if flag, err := cmd.Flags().GetString("bech-validator-prefix"); flag != "" && err == nil { - Config.ValidatorPrefix = flag - } else if Config.Prefix == "" { - log.Fatal().Msg("Both bech-validator-prefix and bech-prefix are not set!") - } else { - Config.ValidatorPrefix = Config.Prefix + "valoper" - } - - if flag, err := cmd.Flags().GetString("bech-validator-pubkey-prefix"); flag != "" && err == nil { - Config.ValidatorPubkeyPrefix = flag - } else if Config.Prefix == "" { - log.Fatal().Msg("Both bech-validator-pubkey-prefix and bech-prefix are not set!") - } else { - Config.ValidatorPubkeyPrefix = Config.Prefix + "valoperpub" - } - - if flag, err := cmd.Flags().GetString("bech-consensus-node-prefix"); flag != "" && err == nil { - Config.ConsensusNodePrefix = flag - } else if Config.Prefix == "" { - log.Fatal().Msg("Both bech-consensus-node-prefix and bech-prefix are not set!") - } else { - Config.ConsensusNodePrefix = Config.Prefix + "valcons" - } - - if flag, err := cmd.Flags().GetString("bech-consensus-node-pubkey-prefix"); flag != "" && err == nil { - Config.ConsensusNodePubkeyPrefix = flag - } else if Config.Prefix == "" { - log.Fatal().Msg("Both bech-consensus-node-pubkey-prefix and bech-prefix are not set!") - } else { - Config.ConsensusNodePubkeyPrefix = Config.Prefix + "valconspub" - } -} - -func IsValidatorMonitored(address string) bool { - // If no args passed, we want to be notified about all validators. - if len(Config.IncludeValidators) == 0 && len(Config.ExcludeValidators) == 0 { - return true - } - - // If monitoring only specific validators - if len(Config.IncludeValidators) != 0 { - for _, monitoredValidatorAddr := range Config.IncludeValidators { - if monitoredValidatorAddr == address { - return true - } - } - - return false - } - - // If monitoring all validators except the specified ones - for _, monitoredValidatorAddr := range Config.ExcludeValidators { - if monitoredValidatorAddr == address { - return false - } - } - - return true -} - -func SetMissedBlocksToJail() { - slashingClient := slashingtypes.NewQueryClient(grpcConn) - params, err := slashingClient.Params( - context.Background(), - &slashingtypes.QueryParamsRequest{}, - ) - if err != nil { - log.Fatal().Err(err).Msg("Could not query for slashing params") - } - - // because cosmos's dec doesn't have .toFloat64() method or whatever and returns everything as int - minSignedPerWindow, err := strconv.ParseFloat(params.Params.MinSignedPerWindow.String(), 64) - if err != nil { - log.Fatal(). - Err(err). - Msg("Could not parse delegator shares") - } - - SignedBlocksWindow = params.Params.SignedBlocksWindow - MissedBlocksToJail = int64(float64(params.Params.SignedBlocksWindow) * (1 - minSignedPerWindow)) - - log.Info(). - Int64("missedBlocksToJail", MissedBlocksToJail). - Msg("Missed blocks to jail calculated") -} - -func SetAvgBlockTime() { - latestBlock := GetBlock(nil) - latestHeight := latestBlock.Height - beforeLatestBlockHeight := latestBlock.Height - BlocksDiffInThePast - beforeLatestBlock := GetBlock(&beforeLatestBlockHeight) - - heightDiff := float64(latestHeight - beforeLatestBlockHeight) - timeDiff := latestBlock.Time.Sub(beforeLatestBlock.Time).Seconds() - - AvgBlockTime = timeDiff / heightDiff - - log.Info(). - Float64("heightDiff", heightDiff). - Float64("timeDiff", timeDiff). - Float64("avgBlockTime", AvgBlockTime). - Msg("Average block time calculated") -} - -func SetDefaultMissedBlocksGroups() { - if Config.MissedBlocksGroups != nil { - log.Debug().Msg("MissedBlockGroups is set, not setting the default ones.") - return - } - - totalRange := float64(SignedBlocksWindow) + 1 // from 0 till max blocks allowed, including - - groups := []MissedBlocksGroup{} - - percents := []float64{0, 0.5, 1, 5, 10, 25, 50, 75, 90, 100} - emojiStart := []string{"🟡", "🟡", "🟡", "🟠", "🟠", "🟠", "🔴", "🔴", "🔴"} - emojiEnd := []string{"🟢", "🟡", "🟡", "🟡", "🟡", "🟠", "🟠", "🟠", "🟠"} - - for i := 0; i < len(percents)-1; i++ { - start := totalRange * percents[i] / 100 - end := totalRange*percents[i+1]/100 - 1 - - groups = append(groups, MissedBlocksGroup{ - Start: int64(start), - End: int64(end), - EmojiStart: emojiStart[i], - EmojiEnd: emojiEnd[i], - DescStart: fmt.Sprintf("is skipping blocks (> %.1f%%)", percents[i]), - DescEnd: fmt.Sprintf("is recovering (< %.1f%%)", percents[i+1]), - }) - } - - groups[0].DescEnd = fmt.Sprintf("is recovered (< %.1f%%)", percents[1]) - - Config.MissedBlocksGroups = groups -} - -func GetBlock(height *int64) *ctypes.Block { - client, err := tmrpc.New(Config.TendermintRPC, "/websocket") - if err != nil { - log.Fatal().Err(err).Msg("Could not create Tendermint client") - } - - block, err := client.Block(context.Background(), height) - if err != nil { - log.Fatal().Err(err).Msg("Could not query Tendermint status") - } - - return block.Block -} - -func main() { - rootCmd.PersistentFlags().StringVar(&Config.ConfigPath, "config", "", "Config file path") - rootCmd.PersistentFlags().StringVar(&Config.NodeAddress, "node", "localhost:9090", "RPC node address") - rootCmd.PersistentFlags().StringVar(&Config.LogLevel, "log-level", "info", "Logging level") - rootCmd.PersistentFlags().BoolVar(&Config.JsonOutput, "json", false, "Output logs as JSON") - rootCmd.PersistentFlags().IntVar(&Config.Interval, "interval", 120, "Interval between two checks, in seconds") - rootCmd.PersistentFlags().Uint64Var(&Config.Limit, "limit", 1000, "gRPC query pagination limit") - rootCmd.PersistentFlags().StringVar(&Config.MintscanPrefix, "mintscan-prefix", "", "Prefix for mintscan links like https://mintscan.io/{prefix}") - rootCmd.PersistentFlags().StringVar(&Config.TendermintRPC, "tendermint-rpc", "http://localhost:26657", "Tendermint RPC address") - - rootCmd.PersistentFlags().StringVar(&Config.TelegramToken, "telegram-token", "", "Telegram bot token") - rootCmd.PersistentFlags().IntVar(&Config.TelegramChat, "telegram-chat", 0, "Telegram chat or user ID") - rootCmd.PersistentFlags().StringVar(&Config.TelegramConfigPath, "telegram-config", "", "Telegram config path") - rootCmd.PersistentFlags().StringVar(&Config.SlackToken, "slack-token", "", "Slack bot token") - rootCmd.PersistentFlags().StringVar(&Config.SlackChat, "slack-chat", "", "Slack chat or user ID") - - rootCmd.PersistentFlags().StringSliceVar(&Config.IncludeValidators, "include", []string{}, "Validators to monitor") - rootCmd.PersistentFlags().StringSliceVar(&Config.ExcludeValidators, "exclude", []string{}, "Validators to not monitor") - - // some networks, like Iris, have the different prefixes for address, validator and consensus node - rootCmd.PersistentFlags().StringVar(&Config.Prefix, "bech-prefix", "", "Bech32 global prefix") - rootCmd.PersistentFlags().StringVar(&Config.ValidatorPrefix, "bech-validator-prefix", "", "Bech32 validator prefix") - rootCmd.PersistentFlags().StringVar(&Config.ValidatorPubkeyPrefix, "bech-validator-pubkey-prefix", "", "Bech32 pubkey validator prefix") - rootCmd.PersistentFlags().StringVar(&Config.ConsensusNodePrefix, "bech-consensus-node-prefix", "", "Bech32 consensus node prefix") - rootCmd.PersistentFlags().StringVar(&Config.ConsensusNodePubkeyPrefix, "bech-consensus-node-pubkey-prefix", "", "Bech32 pubkey consensus node prefix") - - zerolog.SetGlobalLevel(zerolog.InfoLevel) - if err := rootCmd.Execute(); err != nil { - log.Fatal().Err(err).Msg("Could not start application") + GetDefaultLogger().Fatal().Err(err).Msg("Could not start application") } } diff --git a/report_generator.go b/report_generator.go new file mode 100644 index 0000000..e6d4384 --- /dev/null +++ b/report_generator.go @@ -0,0 +1,249 @@ +package main + +import ( + "fmt" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/rs/zerolog" +) + +type ReportGenerator struct { + Params Params + Config *AppConfig + gRPC *TendermintGRPC + Logger zerolog.Logger + State ValidatorsState + Registry codectypes.InterfaceRegistry +} + +func NewReportGenerator( + params Params, + grpc *TendermintGRPC, + config *AppConfig, + logger *zerolog.Logger, + registry codectypes.InterfaceRegistry, +) *ReportGenerator { + return &ReportGenerator{ + Params: params, + gRPC: grpc, + Config: config, + Logger: logger.With().Str("component", "report_generator").Logger(), + Registry: registry, + } +} + +func (g *ReportGenerator) GetNewState() (ValidatorsState, error) { + g.Logger.Debug().Msg("Querying for signing infos...") + + signingInfos, err := g.gRPC.GetSigningInfos() + if err != nil { + g.Logger.Error().Err(err).Msg("Could not query for signing infos") + return nil, err + } + + validators, err := g.gRPC.GetValidators() + if err != nil { + g.Logger.Error().Err(err).Msg("Could not query for validators") + return nil, err + } + + validatorsMap := make(map[string]stakingtypes.Validator, len(validators)) + for _, validator := range validators { + err := validator.UnpackInterfaces(g.Registry) + if err != nil { + g.Logger.Error().Err(err).Msg("Could not unpack interface") + return nil, err + } + + pubKey, err := validator.GetConsAddr() + if err != nil { + g.Logger.Error().Err(err).Msg("Could not get cons addr") + return nil, err + } + + validatorsMap[pubKey.String()] = validator + } + + newState := make(ValidatorsState, len(signingInfos)) + + for _, info := range signingInfos { + validator, ok := validatorsMap[info.Address] + if !ok { + g.Logger.Warn().Str("address", info.Address).Msg("Could not find validator by pubkey") + continue + } + + if !g.Config.IsValidatorMonitored(validator.OperatorAddress) { + g.Logger.Trace().Str("address", info.Address).Msg("Not monitoring this validator, skipping.") + continue + } + + newState[info.Address] = ValidatorState{ + Address: validator.OperatorAddress, + Moniker: validator.Description.Moniker, + ConsensusAddress: info.Address, + MissedBlocks: info.MissedBlocksCounter, + Jailed: validator.Jailed, + Tombstoned: info.Tombstoned, + } + } + + return newState, nil +} + +func (g *ReportGenerator) GetValidatorReportEntry(oldState, newState ValidatorState) (*ReportEntry, bool) { + g.Logger.Trace(). + Str("oldState", fmt.Sprintf("%+v", oldState)). + Str("newState", fmt.Sprintf("%+v", newState)). + Msg("Processing validator report entry") + + // 1. If validator's tombstoned, but wasn't - set tombstoned report entry. + if newState.Tombstoned && !oldState.Tombstoned { + g.Logger.Debug(). + Str("address", oldState.Address). + Msg("Validator is tombstoned") + return &ReportEntry{ + ValidatorAddress: newState.Address, + ValidatorMoniker: newState.Moniker, + Emoji: TombstonedEmoji, + Description: TombstonedDesc, + Direction: TOMBSTONED, + }, true + } + + // 2. If validator's jailed, but wasn't - set jailed report entry. + if newState.Jailed && !oldState.Jailed { + g.Logger.Debug(). + Str("address", oldState.Address). + Msg("Validator is jailed") + return &ReportEntry{ + ValidatorAddress: newState.Address, + ValidatorMoniker: newState.Moniker, + Emoji: JailedEmoju, + Description: JailedDesc, + Direction: JAILED, + }, true + } + + // 3. If validator's not jailed, but was - set unjailed report entry. + if !newState.Jailed && oldState.Jailed { + g.Logger.Debug(). + Str("address", oldState.Address). + Msg("Validator is unjailed") + return &ReportEntry{ + ValidatorAddress: newState.Address, + ValidatorMoniker: newState.Moniker, + Emoji: UnjailedEmoji, + Description: UnjailedDesc, + Direction: UNJAILED, + }, true + } + + // 4. If validator is and was jailed - do nothing. + if newState.Jailed && oldState.Jailed { + g.Logger.Debug(). + Str("address", oldState.Address). + Msg("Validator is and was jailed - no need to send report") + return nil, false + } + + // 5. Validator isn't and wasn't jailed. + // + // First, check if old and new groups are the same - if they have different start, + // they are different. If they don't - they aren't so no need to send a notification. + oldGroup, oldGroupErr := g.Config.MissedBlocksGroups.GetGroup(oldState.MissedBlocks) + if oldGroupErr != nil { + g.Logger.Error().Err(oldGroupErr).Msg("Could not get old group") + return nil, false + } + newGroup, newGroupErr := g.Config.MissedBlocksGroups.GetGroup(newState.MissedBlocks) + if newGroupErr != nil { + g.Logger.Error().Err(newGroupErr).Msg("Could not get new group") + return nil, false + } + + if oldGroup.Start == newGroup.Start { + g.Logger.Debug(). + Str("address", oldState.Address). + Int64("before", oldState.MissedBlocks). + Int64("after", newState.MissedBlocks). + Msg("Validator didn't change group - no need to send report") + return nil, false + } + + // Validator switched from one MissedBlockGroup to another, 2 cases how that may happen + // 1) validator is skipping blocks + // 2) validator skipped some blocks in the past, but recovered, is now signing, and the window + // moves - the amount of missed blocks is decreasing. + // Need to understand which one it is: if old missed blocks < new missed blocks - + // it's 1), if vice versa, then 2) + + entry := &ReportEntry{ + ValidatorAddress: newState.Address, + ValidatorMoniker: newState.Moniker, + MissingBlocks: newState.MissedBlocks, + } + + if oldState.MissedBlocks < newState.MissedBlocks { + // skipping blocks + g.Logger.Debug(). + Str("address", oldState.Address). + Int64("before", oldState.MissedBlocks). + Int64("after", newState.MissedBlocks). + Msg("Validator's missed blocks increasing") + entry.Direction = INCREASING + entry.Emoji = newGroup.EmojiStart + entry.Description = newGroup.DescStart + } else { + // restoring + g.Logger.Debug(). + Str("address", oldState.Address). + Int64("before", oldState.MissedBlocks). + Int64("after", newState.MissedBlocks). + Msg("Validator's missed blocks decreasing") + entry.Direction = DECREASING + entry.Emoji = newGroup.EmojiEnd + entry.Description = newGroup.DescEnd + } + + return entry, true +} + +func (g *ReportGenerator) GenerateReport() *Report { + newState, err := g.GetNewState() + if err != nil { + g.Logger.Error().Err(err).Msg("Error getting new state") + return &Report{} + } + + if len(g.State) == 0 { + g.Logger.Info().Msg("No previous state, skipping.") + g.State = newState + return &Report{} + } + + entries := []ReportEntry{} + + for address, info := range newState { + oldState, ok := g.State[address] + if !ok { + g.Logger.Warn().Str("address", address).Msg("No old state present for address") + continue + } + + entry, present := g.GetValidatorReportEntry(oldState, info) + if !present { + g.Logger.Trace(). + Str("address", address). + Msg("No report entry present") + continue + } + + entries = append(entries, *entry) + } + + g.State = newState + + return &Report{Entries: entries} +} diff --git a/slack.go b/slack.go index b832a66..419f5ee 100644 --- a/slack.go +++ b/slack.go @@ -4,16 +4,33 @@ import ( "fmt" "strings" + "github.com/rs/zerolog" "github.com/slack-go/slack" ) type SlackReporter struct { - SlackToken string - SlackChat string + ChainInfoConfig ChainInfoConfig + SlackConfig SlackConfig + Params *Params + Logger zerolog.Logger SlackClient slack.Client } +func NewSlackReporter( + chainInfoConfig ChainInfoConfig, + slackConfig SlackConfig, + params *Params, + logger *zerolog.Logger, +) *SlackReporter { + return &SlackReporter{ + ChainInfoConfig: chainInfoConfig, + SlackConfig: slackConfig, + Params: params, + Logger: logger.With().Str("component", "slack_reporter").Logger(), + } +} + func (r SlackReporter) Serialize(report Report) string { var sb strings.Builder @@ -24,12 +41,12 @@ func (r SlackReporter) Serialize(report Report) string { ) if entry.Direction == INCREASING { - timeToJail = fmt.Sprintf(" (%s till jail)", entry.GetTimeToJail()) + timeToJail = fmt.Sprintf(" (%s till jail)", entry.GetTimeToJail(r.Params)) } validatorLink = fmt.Sprintf( "%s", - Config.MintscanPrefix, + r.ChainInfoConfig.MintscanPrefix, entry.ValidatorAddress, entry.ValidatorMoniker, ) @@ -47,23 +64,23 @@ func (r SlackReporter) Serialize(report Report) string { } func (r *SlackReporter) Init() { - if r.SlackToken == "" || r.SlackChat == "" { - log.Debug().Msg("Slack credentials not set, not creating Slack reporter.") + if r.SlackConfig.Token == "" || r.SlackConfig.Chat == "" { + r.Logger.Debug().Msg("Slack credentials not set, not creating Slack reporter.") return } - client := slack.New(r.SlackToken) + client := slack.New(r.SlackConfig.Token) r.SlackClient = *client } func (r SlackReporter) Enabled() bool { - return r.SlackToken != "" && r.SlackChat != "" + return r.SlackConfig.Token != "" && r.SlackConfig.Chat != "" } func (r SlackReporter) SendReport(report Report) error { serializedReport := r.Serialize(report) _, _, err := r.SlackClient.PostMessage( - r.SlackChat, + r.SlackConfig.Chat, slack.MsgOptionText(serializedReport, false), slack.MsgOptionDisableLinkUnfurl(), ) diff --git a/telegram.go b/telegram.go index 3e75230..0345de6 100644 --- a/telegram.go +++ b/telegram.go @@ -1,7 +1,6 @@ package main import ( - "context" "fmt" "html" "io/ioutil" @@ -9,21 +8,23 @@ import ( "strings" "time" + "github.com/BurntSushi/toml" slashingtypes "github.com/cosmos/cosmos-sdk/x/slashing/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - - "github.com/BurntSushi/toml" + "github.com/rs/zerolog" tb "gopkg.in/tucnak/telebot.v2" ) type TelegramReporter struct { - TelegramToken string - TelegramChat int - TelegramConfigPath string - TelegramConfig TelegramConfig - Config *AppConfig - - TelegramBot *tb.Bot + ChainInfoConfig ChainInfoConfig + TelegramAppConfig TelegramAppConfig + AppConfig *AppConfig + Params *Params + Client *TendermintGRPC + Logger zerolog.Logger + + TelegramConfig TelegramConfig + TelegramBot *tb.Bot } type NotificationInfo struct { @@ -35,6 +36,24 @@ type TelegramConfig struct { NotiticationInfos []*NotificationInfo } +func NewTelegramReporter( + chainInfoConfig ChainInfoConfig, + telegramAppConfig TelegramAppConfig, + appConfig *AppConfig, + params *Params, + client *TendermintGRPC, + logger *zerolog.Logger, +) *TelegramReporter { + return &TelegramReporter{ + ChainInfoConfig: chainInfoConfig, + TelegramAppConfig: telegramAppConfig, + AppConfig: appConfig, + Params: params, + Client: client, + Logger: logger.With().Str("component", "telegram_reporter").Logger(), + } +} + func (i *NotificationInfo) addNotifier(notifier string) error { if stringInSlice(notifier, i.Notifiers) { return fmt.Errorf("You are already subscribed to this validator's notifications.") //nolint @@ -110,12 +129,12 @@ func (r TelegramReporter) Serialize(report Report) string { ) if entry.Direction == INCREASING { - timeToJail = fmt.Sprintf(" (%s till jail)", entry.GetTimeToJail()) + timeToJail = fmt.Sprintf(" (%s till jail)", entry.GetTimeToJail(r.Params)) } validatorLink = fmt.Sprintf( "%s", - Config.MintscanPrefix, + r.ChainInfoConfig.MintscanPrefix, html.EscapeString(entry.ValidatorAddress), entry.ValidatorMoniker, ) @@ -136,17 +155,17 @@ func (r TelegramReporter) Serialize(report Report) string { } func (r *TelegramReporter) Init() { - if r.TelegramToken == "" || r.TelegramChat == 0 || r.TelegramConfigPath == "" { - log.Debug().Msg("Telegram credentials or config path not set, not creating Telegram reporter.") + if r.TelegramAppConfig.Token == "" || r.TelegramAppConfig.Chat == 0 || r.TelegramAppConfig.ConfigPath == "" { + r.Logger.Debug().Msg("Telegram credentials or config path not set, not creating Telegram reporter.") return } bot, err := tb.NewBot(tb.Settings{ - Token: Config.TelegramToken, + Token: r.TelegramAppConfig.Token, Poller: &tb.LongPoller{Timeout: 10 * time.Second}, }) if err != nil { - log.Warn().Err(err).Msg("Could not create Telegram bot") + r.Logger.Warn().Err(err).Msg("Could not create Telegram bot") return } @@ -170,7 +189,7 @@ func (r TelegramReporter) SendReport(report Report) error { serializedReport := r.Serialize(report) _, err := r.TelegramBot.Send( &tb.User{ - ID: r.TelegramChat, + ID: r.TelegramAppConfig.Chat, }, serializedReport, tb.ModeHTML, @@ -195,14 +214,14 @@ func (r TelegramReporter) sendMessage(message *tb.Message, text string) { tb.NoPreview, ) if err != nil { - log.Error().Err(err).Msg("Could not send Telegram message") + r.Logger.Error().Err(err).Msg("Could not send Telegram message") } } func (r TelegramReporter) getHelp(message *tb.Message) { var sb strings.Builder sb.WriteString("missed-block-checker\n\n") - sb.WriteString(fmt.Sprintf("Query for the %s network info.\n", Config.MintscanPrefix)) + sb.WriteString(fmt.Sprintf("Query for the %s network info.\n", r.ChainInfoConfig.MintscanPrefix)) sb.WriteString("Can understand the following commands:\n") sb.WriteString("- /subscribe <validator address> - be notified on validator's missed block in a Telegram channel\n") sb.WriteString("- /unsubscribe <validator address> - undo the subscription given at the previous step\n") @@ -222,7 +241,7 @@ func (r TelegramReporter) getHelp(message *tb.Message) { sb.WriteString("- Osmosis\n") r.sendMessage(message, sb.String()) - log.Info(). + r.Logger.Info(). Str("user", message.Sender.Username). Msg("Successfully returned help info") } @@ -235,11 +254,11 @@ func (r *TelegramReporter) getValidatorStatus(message *tb.Message) { } address := args[1] - log.Debug().Str("address", address).Msg("getValidatorStatus: address") + r.Logger.Debug().Str("address", address).Msg("getValidatorStatus: address") - validator, err := getValidator(address) + validator, err := r.Client.GetValidator(address) if err != nil { - log.Error(). + r.Logger.Error(). Str("address", address). Err(err). Msg("Could not get validators") @@ -247,21 +266,21 @@ func (r *TelegramReporter) getValidatorStatus(message *tb.Message) { return } - signingInfo, err := getSigningInfo(validator) + signingInfo, err := r.Client.GetSigningInfo(validator) if err != nil { r.sendMessage(message, "Could not get missed blocks info") return } - r.sendMessage(message, getValidatorWithMissedBlocksSerialized(validator, signingInfo)) - log.Info(). + r.sendMessage(message, r.getValidatorWithMissedBlocksSerialized(validator, signingInfo)) + r.Logger.Info(). Str("user", message.Sender.Username). Str("address", address). Msg("Successfully returned validator status") } func (r *TelegramReporter) getSubscribedValidatorsStatuses(message *tb.Message) { - log.Debug().Msg("getSubscribedValidatorsStatuses") + r.Logger.Debug().Msg("getSubscribedValidatorsStatuses") subscribedValidators := r.TelegramConfig.getNotifiedValidators(message.Sender.Username) if len(subscribedValidators) == 0 { @@ -272,9 +291,9 @@ func (r *TelegramReporter) getSubscribedValidatorsStatuses(message *tb.Message) var sb strings.Builder for _, address := range subscribedValidators { - validator, err := getValidator(address) + validator, err := r.Client.GetValidator(address) if err != nil { - log.Error(). + r.Logger.Error(). Str("address", address). Err(err). Msg("Could not get validators") @@ -282,34 +301,37 @@ func (r *TelegramReporter) getSubscribedValidatorsStatuses(message *tb.Message) return } - signingInfo, err := getSigningInfo(validator) + signingInfo, err := r.Client.GetSigningInfo(validator) if err != nil { r.sendMessage(message, "Could not get missed blocks info") return } - sb.WriteString(getValidatorWithMissedBlocksSerialized(validator, signingInfo)) + sb.WriteString(r.getValidatorWithMissedBlocksSerialized(validator, signingInfo)) sb.WriteString("\n") } r.sendMessage(message, sb.String()) - log.Info(). + r.Logger.Info(). Str("user", message.Sender.Username). Msg("Successfully returned subscribed validator statuses") } -func getValidatorWithMissedBlocksSerialized(validator stakingtypes.Validator, signingInfo slashingtypes.ValidatorSigningInfo) string { +func (r *TelegramReporter) getValidatorWithMissedBlocksSerialized( + validator stakingtypes.Validator, + signingInfo slashingtypes.ValidatorSigningInfo, +) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("%s\n", validator.Description.Moniker)) sb.WriteString(fmt.Sprintf( "Missed blocks: %d/%d (%.2f%%)\n", signingInfo.MissedBlocksCounter, - SignedBlocksWindow, - float64(signingInfo.MissedBlocksCounter)/float64(SignedBlocksWindow)*100, + r.Params.SignedBlocksWindow, + float64(signingInfo.MissedBlocksCounter)/float64(r.Params.SignedBlocksWindow)*100, )) sb.WriteString(fmt.Sprintf( "Mintscan\n", - Config.MintscanPrefix, + r.ChainInfoConfig.MintscanPrefix, validator.OperatorAddress, )) @@ -329,11 +351,11 @@ func (r *TelegramReporter) subscribeToValidatorUpdates(message *tb.Message) { } address := args[1] - log.Debug().Str("address", address).Msg("subscribeToValidatorUpdates: address") + r.Logger.Debug().Str("address", address).Msg("subscribeToValidatorUpdates: address") - validator, err := getValidator(address) + validator, err := r.Client.GetValidator(address) if err != nil { - log.Error(). + r.Logger.Error(). Str("address", address). Err(err). Msg("Could not get validator") @@ -354,12 +376,12 @@ func (r *TelegramReporter) subscribeToValidatorUpdates(message *tb.Message) { sb.WriteString(fmt.Sprintf("Subscribed to the notification of %s ", validator.Description.Moniker)) sb.WriteString(fmt.Sprintf( "Mintscan\n", - Config.MintscanPrefix, + r.ChainInfoConfig.MintscanPrefix, validator.OperatorAddress, )) r.sendMessage(message, sb.String()) - log.Info(). + r.Logger.Info(). Str("user", message.Sender.Username). Str("address", address). Msg("Successfully subscribed to validator's notifications.") @@ -378,11 +400,11 @@ func (r *TelegramReporter) unsubscribeFromValidatorUpdates(message *tb.Message) } address := args[1] - log.Debug().Str("address", address).Msg("unsubscribeFromValidatorUpdates: address") + r.Logger.Debug().Str("address", address).Msg("unsubscribeFromValidatorUpdates: address") - validator, err := getValidator(address) + validator, err := r.Client.GetValidator(address) if err != nil { - log.Error(). + r.Logger.Error(). Str("address", address). Err(err). Msg("Could not get validator") @@ -403,12 +425,12 @@ func (r *TelegramReporter) unsubscribeFromValidatorUpdates(message *tb.Message) sb.WriteString(fmt.Sprintf("Unsubscribed from the notification of %s ", validator.Description.Moniker)) sb.WriteString(fmt.Sprintf( "Mintscan\n", - Config.MintscanPrefix, + r.ChainInfoConfig.MintscanPrefix, validator.OperatorAddress, )) r.sendMessage(message, sb.String()) - log.Info(). + r.Logger.Info(). Str("user", message.Sender.Username). Str("address", address). Msg("Successfully unsubscribed from validator's notifications.") @@ -417,26 +439,26 @@ func (r *TelegramReporter) unsubscribeFromValidatorUpdates(message *tb.Message) func (r *TelegramReporter) displayConfig(message *tb.Message) { var sb strings.Builder - if len(r.Config.ExcludeValidators) == 0 && len(r.Config.IncludeValidators) == 0 { + if len(r.AppConfig.ExcludeValidators) == 0 && len(r.AppConfig.IncludeValidators) == 0 { sb.WriteString("Monitoring all validators.\n") - } else if len(r.Config.IncludeValidators) == 0 { + } else if len(r.AppConfig.IncludeValidators) == 0 { sb.WriteString("Monitoring all validators, except the following ones:\n") - for _, validator := range r.Config.ExcludeValidators { + for _, validator := range r.AppConfig.ExcludeValidators { sb.WriteString(fmt.Sprintf( "- %s\n", - Config.MintscanPrefix, + r.ChainInfoConfig.MintscanPrefix, validator, validator, )) } - } else if len(r.Config.ExcludeValidators) == 0 { + } else if len(r.AppConfig.ExcludeValidators) == 0 { sb.WriteString("Monitoring the following validators:\n") - for _, validator := range r.Config.IncludeValidators { + for _, validator := range r.AppConfig.IncludeValidators { sb.WriteString(fmt.Sprintf( "- %s\n", - Config.MintscanPrefix, + r.ChainInfoConfig.MintscanPrefix, validator, validator, )) @@ -444,7 +466,7 @@ func (r *TelegramReporter) displayConfig(message *tb.Message) { } sb.WriteString("Missed blocks thresholds:\n") - for _, group := range r.Config.MissedBlocksGroups { + for _, group := range r.AppConfig.MissedBlocksGroups { sb.WriteString(fmt.Sprintf("%s %d - %d\n", group.EmojiStart, group.Start, group.End)) } @@ -452,90 +474,40 @@ func (r *TelegramReporter) displayConfig(message *tb.Message) { } func (r *TelegramReporter) loadConfigFromYaml() { - if _, err := os.Stat(r.TelegramConfigPath); os.IsNotExist(err) { - log.Info().Str("path", r.TelegramConfigPath).Msg("Telegram config file does not exist, creating.") - if _, err = os.Create(r.TelegramConfigPath); err != nil { - log.Fatal().Err(err).Msg("Could not create Telegram config!") + if _, err := os.Stat(r.TelegramAppConfig.ConfigPath); os.IsNotExist(err) { + r.Logger.Info().Str("path", r.TelegramAppConfig.ConfigPath).Msg("Telegram config file does not exist, creating.") + if _, err = os.Create(r.TelegramAppConfig.ConfigPath); err != nil { + r.Logger.Fatal().Err(err).Msg("Could not create Telegram config!") } } else if err != nil { - log.Fatal().Err(err).Msg("Could not fetch Telegram config!") + r.Logger.Fatal().Err(err).Msg("Could not fetch Telegram config!") } - bytes, err := ioutil.ReadFile(r.TelegramConfigPath) + bytes, err := ioutil.ReadFile(r.TelegramAppConfig.ConfigPath) if err != nil { - log.Fatal().Err(err).Msg("Could not read Telegram config!") + r.Logger.Fatal().Err(err).Msg("Could not read Telegram config!") } var conf TelegramConfig if _, err := toml.Decode(string(bytes), &conf); err != nil { - log.Fatal().Err(err).Msg("Could not load Telegram config!") + r.Logger.Fatal().Err(err).Msg("Could not load Telegram config!") } r.TelegramConfig = conf - log.Debug().Msg("Telegram config is loaded successfully.") + r.Logger.Debug().Msg("Telegram config is loaded successfully.") } func (r *TelegramReporter) saveYamlConfig() { - f, err := os.Create(r.TelegramConfigPath) + f, err := os.Create(r.TelegramAppConfig.ConfigPath) if err != nil { - log.Fatal().Err(err).Msg("Could not open Telegram config when saving") + r.Logger.Fatal().Err(err).Msg("Could not open Telegram config when saving") } if err := toml.NewEncoder(f).Encode(r.TelegramConfig); err != nil { - log.Fatal().Err(err).Msg("Could not save Telegram config") + r.Logger.Fatal().Err(err).Msg("Could not save Telegram config") } if err := f.Close(); err != nil { - log.Fatal().Err(err).Msg("Could not close Telegram config when saving") - } - - log.Debug().Msg("Telegram config is updated successfully.") -} - -func getValidator(address string) (stakingtypes.Validator, error) { - stakingClient := stakingtypes.NewQueryClient(grpcConn) - - validatorResponse, err := stakingClient.Validator( - context.Background(), - &stakingtypes.QueryValidatorRequest{ValidatorAddr: address}, - ) - if err != nil { - return stakingtypes.Validator{}, err - } - - return validatorResponse.Validator, nil -} - -func getSigningInfo(validator stakingtypes.Validator) (slashingtypes.ValidatorSigningInfo, error) { - slashingClient := slashingtypes.NewQueryClient(grpcConn) - - err := validator.UnpackInterfaces(interfaceRegistry) // Unpack interfaces, to populate the Anys' cached values - if err != nil { - log.Error(). - Str("address", validator.OperatorAddress). - Err(err). - Msg("Could not get unpack validator inferfaces") - return slashingtypes.ValidatorSigningInfo{}, err - } - - pubKey, err := validator.GetConsAddr() - if err != nil { - log.Error(). - Str("address", validator.OperatorAddress). - Err(err). - Msg("Could not get validator pubkey") - return slashingtypes.ValidatorSigningInfo{}, err - } - - signingInfosResponse, err := slashingClient.SigningInfo( - context.Background(), - &slashingtypes.QuerySigningInfoRequest{ConsAddress: pubKey.String()}, - ) - if err != nil { - log.Error(). - Str("address", validator.OperatorAddress). - Err(err). - Msg("Could not get signing info") - return slashingtypes.ValidatorSigningInfo{}, err + r.Logger.Fatal().Err(err).Msg("Could not close Telegram config when saving") } - return signingInfosResponse.ValSigningInfo, nil + r.Logger.Debug().Msg("Telegram config is updated successfully.") } diff --git a/tendermint.go b/tendermint.go new file mode 100644 index 0000000..96715e7 --- /dev/null +++ b/tendermint.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + + "github.com/rs/zerolog" + tmrpc "github.com/tendermint/tendermint/rpc/client/http" + ctypes "github.com/tendermint/tendermint/types" +) + +type TendermintRPC struct { + NodeConfig NodeConfig + BlocksDiffInThePast int64 + Logger zerolog.Logger +} + +func NewTendermintRPC(nodeConfig NodeConfig, logger *zerolog.Logger) *TendermintRPC { + return &TendermintRPC{ + NodeConfig: nodeConfig, + BlocksDiffInThePast: 100, + Logger: logger.With().Str("component", "rpc").Logger(), + } +} + +func (rpc *TendermintRPC) GetAvgBlockTime() float64 { + latestBlock := rpc.GetBlock(nil) + latestHeight := latestBlock.Height + beforeLatestBlockHeight := latestBlock.Height - rpc.BlocksDiffInThePast + beforeLatestBlock := rpc.GetBlock(&beforeLatestBlockHeight) + + heightDiff := float64(latestHeight - beforeLatestBlockHeight) + timeDiff := latestBlock.Time.Sub(beforeLatestBlock.Time).Seconds() + + return timeDiff / heightDiff +} + +func (rpc *TendermintRPC) GetBlock(height *int64) *ctypes.Block { + client, err := tmrpc.New(rpc.NodeConfig.TendermintRPC, "/websocket") + if err != nil { + rpc.Logger.Fatal().Err(err).Msg("Could not create Tendermint client") + } + + block, err := client.Block(context.Background(), height) + if err != nil { + rpc.Logger.Fatal().Err(err).Msg("Could not query Tendermint status") + } + + return block.Block +} diff --git a/types.go b/types.go index 4a8c013..6cd45f5 100644 --- a/types.go +++ b/types.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "time" ) @@ -27,34 +26,6 @@ const ( UnjailedDesc = "was unjailed" ) -type AppConfig struct { - ConfigPath string - NodeAddress string - LogLevel string - JsonOutput bool - Interval int - Limit uint64 - MintscanPrefix string - TendermintRPC string - - TelegramToken string - TelegramConfigPath string - TelegramChat int - SlackToken string - SlackChat string - - Prefix string - ValidatorPrefix string - ValidatorPubkeyPrefix string - ConsensusNodePrefix string - ConsensusNodePubkeyPrefix string - - IncludeValidators []string - ExcludeValidators []string - - MissedBlocksGroups MissedBlocksGroups -} - type ValidatorState struct { Address string Moniker string @@ -66,59 +37,6 @@ type ValidatorState struct { type ValidatorsState map[string]ValidatorState -type MissedBlocksGroup struct { - Start int64 - End int64 - EmojiStart string - EmojiEnd string - DescStart string - DescEnd string -} - -type MissedBlocksGroups []MissedBlocksGroup - -// Checks that MissedBlocksGroup is an array of sorted MissedBlocksGroup -// covering each interval. -// Example (start - end), given that window = 300: -// 0 - 99, 100 - 199, 200 - 300 - valid -// 0 - 50 - not valid. -func (g MissedBlocksGroups) Validate(window int64) error { - if len(g) == 0 { - return fmt.Errorf("MissedBlocksGroups is empty") - } - - if g[0].Start != 0 { - return fmt.Errorf("first MissedBlocksGroup's start should be 0, got %d", g[0].Start) - } - - if g[len(g)-1].End < window { - return fmt.Errorf("last MissedBlocksGroup's end should be >= %d, got %d", window, g[len(g)-1].End) - } - - for i := 0; i < len(g)-1; i++ { - if g[i+1].Start-g[i].End != 1 { - return fmt.Errorf( - "MissedBlocksGroup at index %d ends at %d, and the next one starts with %d", - i, - g[i].End, - g[i+1].Start, - ) - } - } - - return nil -} - -func (g MissedBlocksGroups) GetGroup(missed int64) (*MissedBlocksGroup, error) { - for _, group := range g { - if missed >= group.Start && missed <= group.End { - return &group, nil - } - } - - return nil, fmt.Errorf("could not find a group for missed blocks counter = %d", missed) -} - type ReportEntry struct { ValidatorAddress string ValidatorMoniker string @@ -128,9 +46,9 @@ type ReportEntry struct { Direction Direction } -func (r ReportEntry) GetTimeToJail() time.Duration { - blocksLeftToJail := MissedBlocksToJail - r.MissingBlocks - secondsLeftToJail := AvgBlockTime * float64(blocksLeftToJail) +func (r ReportEntry) GetTimeToJail(params *Params) time.Duration { + blocksLeftToJail := params.MissedBlocksToJail - r.MissingBlocks + secondsLeftToJail := params.AvgBlockTime * float64(blocksLeftToJail) return time.Duration(secondsLeftToJail) * time.Second }