diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d75f98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.toml +main +*.json \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..f44e150 --- /dev/null +++ b/config.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" +) + +type Chain struct { + Name string `toml:"name"` + LCDEndpoints []string `toml:"lcd-endpoints"` + Wallets []string `toml:"wallets"` +} + +func (c *Chain) Validate() error { + if c.Name == "" { + return fmt.Errorf("empty chain name") + } + + if len(c.LCDEndpoints) == 0 { + return fmt.Errorf("no LCD endpoints provided") + } + + if len(c.Wallets) == 0 { + return fmt.Errorf("no wallets provided") + } + + return nil +} + +type Config struct { + LogConfig LogConfig `toml:"log"` + StatePath string `toml:"state-path"` + Chains []Chain `toml:"chains"` +} + +type LogConfig struct { + LogLevel string `toml:"level"` + JSONOutput bool `toml:"json"` +} + +func (c *Config) Validate() error { + if len(c.Chains) == 0 { + return fmt.Errorf("no chains provided") + } + + for index, chain := range c.Chains { + if err := chain.Validate(); err != nil { + return fmt.Errorf("error in chain %d: %s", index, err) + } + } + + return nil +} + +func GetConfig(path string) (*Config, error) { + configBytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + configString := string(configBytes) + + configStruct := Config{} + if _, err = toml.Decode(configString, &configStruct); err != nil { + return nil, err + } + + return &configStruct, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fe591b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module main + +go 1.18 + +require ( + github.com/BurntSushi/toml v1.1.0 + github.com/rs/zerolog v1.26.1 + github.com/spf13/cobra v1.4.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3b672c0 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= +github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= +github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= +github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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 new file mode 100644 index 0000000..7ddf265 --- /dev/null +++ b/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "time" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" +) + +const PaginationLimit = 1000 + +func Execute(configPath string) { + config, err := GetConfig(configPath) + if err != nil { + GetDefaultLogger().Fatal().Err(err).Msg("Could not load config") + } + + if err = config.Validate(); err != nil { + GetDefaultLogger().Fatal().Err(err).Msg("Provided config is invalid!") + } + + log := GetLogger(config.LogConfig) + + stateManager := NewStateManager(config.StatePath, log) + reportGenerator := NewReportGenerator(stateManager, log, config.Chains) + + for { + _ = reportGenerator.GenerateReport() + time.Sleep(time.Second * 30) + } +} + +func main() { + var ConfigPath string + + var rootCmd = &cobra.Command{ + Use: "cosmos-proposals-checker", + Long: "Checks the specific wallets on different chains for proposal votes.", + Run: func(cmd *cobra.Command, args []string) { + Execute(ConfigPath) + }, + } + + rootCmd.PersistentFlags().StringVar(&ConfigPath, "config", "", "Config file path") + rootCmd.MarkPersistentFlagRequired("config") + + if err := rootCmd.Execute(); err != nil { + log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger() + log.Fatal().Err(err).Msg("Could not start application") + } +} diff --git a/report_generator.go b/report_generator.go new file mode 100644 index 0000000..d1e4673 --- /dev/null +++ b/report_generator.go @@ -0,0 +1,120 @@ +package main + +import ( + "fmt" + + "github.com/rs/zerolog" +) + +type ReportGenerator struct { + StateManager *StateManager + Chains []Chain + RPC *RPC + Logger zerolog.Logger +} + +type ReportEntry struct { + Chain string + Wallet string + ProposalId string + ProposalDescription string + Vote string +} + +type Report struct { + Entries []ReportEntry +} + +func NewReportGenerator( + manager *StateManager, + logger *zerolog.Logger, + chains []Chain, +) *ReportGenerator { + return &ReportGenerator{ + StateManager: manager, + Chains: chains, + Logger: logger.With().Str("component", "report_generator").Logger(), + } +} + +func (g *ReportGenerator) GenerateReport() *Report { + votesMap := make(map[string]map[string]map[string]*Vote) + proposalsMap := make(map[string][]Proposal) + + for _, chain := range g.Chains { + votesMap[chain.Name] = make(map[string]map[string]*Vote) + + rpc := NewRPC(chain.LCDEndpoints, g.Logger) + + g.Logger.Info().Str("name", chain.Name).Msg("Processing a chain") + proposals, err := rpc.GetAllProposals() + if err != nil { + g.Logger.Warn().Err(err).Msg("Error processing proposals") + continue + } + + g.Logger.Info().Int("len", len(proposals)).Msg("Got proposals") + proposalsMap[chain.Name] = proposals + + for _, proposal := range proposals { + for _, wallet := range chain.Wallets { + if g.StateManager.HasVotedBefore(chain.Name, proposal.ProposalID, wallet) { + g.Logger.Trace(). + Str("proposal", proposal.ProposalID). + Str("wallet", wallet). + Msg("Wallet has already voted, not checking again,") + continue + } + + g.Logger.Info(). + Str("proposal", proposal.ProposalID). + Str("wallet", wallet). + Msg("Checking if a wallet had voted") + + vote, err := rpc.GetVote(proposal.ProposalID, wallet) + if err != nil { + g.Logger.Warn().Err(err).Msg("Error processing vote") + } + + g.Logger.Info().Str("result", fmt.Sprintf("%+v", vote)).Msg("Got vote") + g.StateManager.SetVote(chain.Name, proposal.ProposalID, wallet, vote.Vote) + } + } + } + + entries := []ReportEntry{} + + for _, chain := range g.Chains { + for _, proposal := range proposalsMap[chain.Name] { + for _, wallet := range chain.Wallets { + votedNow := g.StateManager.HasVotedNow(chain.Name, proposal.ProposalID, wallet) + votedBefore := g.StateManager.HasVotedBefore(chain.Name, proposal.ProposalID, wallet) + + // Hasn't voted for this proposal - need to notify. + if !votedNow { + entries = append(entries, ReportEntry{ + Chain: chain.Name, + Wallet: wallet, + ProposalId: proposal.ProposalID, + ProposalDescription: proposal.Content.Description, + }) + } + + // Hasn't voted before but voted now - need to close alert/notify about new vote. + if votedNow && !votedBefore { + // entries = append(entries, ReportEntry{ + // Chain: chain.Name, + // Wallet: wallet, + // ProposalId: proposal.ProposalID, + // ProposalDescription: proposal.Content.Description, + // Vote: , + // }) + } + } + } + } + + g.StateManager.CommitNewState() + + return &Report{} +} diff --git a/state_manager.go b/state_manager.go new file mode 100644 index 0000000..79f2b02 --- /dev/null +++ b/state_manager.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "os" + + "github.com/rs/zerolog" +) + +type StateManager struct { + StatePath string + Logger zerolog.Logger + State State +} + +type State struct { + VotesState VotesState + OldVotesState VotesState +} + +type WalletVotes map[string]*Vote +type ProposalVotes map[string]WalletVotes + +// ["chain"]["proposal"]["wallet"]["vote"] +type VotesState map[string]ProposalVotes + +func NewStateManager(path string, logger *zerolog.Logger) *StateManager { + return &StateManager{ + StatePath: path, + Logger: logger.With().Str("component", "state_manager").Logger(), + } +} + +func (m *StateManager) SetVote(chain, proposal, wallet string, vote *Vote) { + var votesState VotesState + + if m.State.VotesState == nil { + votesState = make(VotesState) + m.State.VotesState = votesState + } + + votesState = m.State.VotesState + + if _, ok := votesState[chain]; !ok { + votesState[chain] = make(ProposalVotes) + } + + if _, ok := votesState[chain][proposal]; !ok { + votesState[chain][proposal] = make(WalletVotes) + } + + if vote != nil { + votesState[chain][proposal][wallet] = vote + } +} + +func (m *StateManager) HasVotedNow(chain, proposal, wallet string) bool { + if m.State.VotesState == nil { + return false + } + + votesState := m.State.VotesState + if _, ok := votesState[chain]; !ok { + return false + } + + if _, ok := votesState[chain][proposal]; !ok { + return false + } + + _, ok := votesState[chain][proposal][wallet] + return ok +} + +func (m *StateManager) HasVotedBefore(chain, proposal, wallet string) bool { + if m.State.OldVotesState == nil { + return false + } + + votesState := m.State.OldVotesState + if _, ok := votesState[chain]; !ok { + return false + } + + if _, ok := votesState[chain][proposal]; !ok { + return false + } + + _, ok := votesState[chain][proposal][wallet] + return ok +} + +func (m *StateManager) Load() { + content, err := os.ReadFile(m.StatePath) + if err != nil { + m.Logger.Warn().Err(err).Msg("Could not load state") + return + } + + var state VotesState + if err = json.Unmarshal([]byte(content), &state); err != nil { + m.Logger.Warn().Err(err).Msg("Could not unmarshall state") + m.State.OldVotesState = make(VotesState) + return + } + + m.State.OldVotesState = state +} + +func (m *StateManager) Save() { + content, err := json.Marshal(m.State.OldVotesState) + if err != nil { + m.Logger.Warn().Err(err).Msg("Could not marshal state") + return + } + + if err = os.WriteFile(m.StatePath, content, 0644); err != nil { + m.Logger.Warn().Err(err).Msg("Could not save state") + return + } +} + +func (m *StateManager) CommitNewState() { + m.State.OldVotesState = m.State.VotesState + m.State.VotesState = make(VotesState) + + m.Save() +} diff --git a/tendermint.go b/tendermint.go new file mode 100644 index 0000000..b4ce728 --- /dev/null +++ b/tendermint.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/rs/zerolog" +) + +type RPC struct { + URLs []string + Logger zerolog.Logger +} + +func NewRPC(urls []string, logger zerolog.Logger) *RPC { + return &RPC{ + URLs: urls, + Logger: logger.With().Str("component", "rpc").Logger(), + } +} + +func (rpc *RPC) GetAllProposals() ([]Proposal, error) { + proposals := []Proposal{} + offset := 0 + + for { + url := fmt.Sprintf( + // 2 is for PROPOSAL_STATUS_VOTING_PERIOD + "/cosmos/gov/v1beta1/proposals?pagination.limit=%d&pagination.offset=%d&proposal_status=2", + PaginationLimit, + offset, + ) + + var batchProposals ProposalsRPCResponse + if err := rpc.Get(url, &batchProposals); err != nil { + return nil, err + } + + proposals = append(proposals, batchProposals.Proposals...) + if len(batchProposals.Proposals) < PaginationLimit { + break + } + + offset += PaginationLimit + } + + return proposals, nil +} + +func (rpc *RPC) GetVote(proposal, voter string) (*VoteRPCResponse, error) { + url := fmt.Sprintf( + "/cosmos/gov/v1beta1/proposals/%s/votes/%s", + proposal, + voter, + ) + + var vote VoteRPCResponse + if err := rpc.Get(url, &vote); err != nil { + return nil, err + } + + return &vote, nil +} + +func (rpc *RPC) Get(url string, target interface{}) error { + for _, lcd := range rpc.URLs { + fullUrl := lcd + url + rpc.Logger.Trace().Str("url", fullUrl).Msg("Trying making request to LCD") + + err := rpc.GetFull( + fullUrl, + target, + ) + + if err == nil { + return nil + } + + rpc.Logger.Warn().Str("url", fullUrl).Err(err).Msg("LCD request failed") + } + + rpc.Logger.Warn().Str("url", url).Msg("All LCD requests failed") + return fmt.Errorf("all LCD requests failed") +} + +func (rpc *RPC) GetFull(url string, target interface{}) error { + client := &http.Client{Timeout: 10 * 1000000000} + start := time.Now() + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + rpc.Logger.Debug().Str("url", url).Msg("Doing a query...") + + res, err := client.Do(req) + if err != nil { + rpc.Logger.Warn().Str("url", url).Err(err).Msg("Query failed") + return err + } + defer res.Body.Close() + + rpc.Logger.Debug().Str("url", url).Dur("duration", time.Since(start)).Msg("Query is finished") + + return json.NewDecoder(res.Body).Decode(target) +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..cd4d68d --- /dev/null +++ b/types.go @@ -0,0 +1,30 @@ +package main + +const VotingPeriod = "PROPOSAL_STATUS_VOTING_PERIOD" + +// RPC response types. +type Proposal struct { + ProposalID string `json:"proposal_id"` + Status string `json:"status"` + Content *ProposalContent `json:"content"` +} + +type ProposalContent struct { + Title string `json:"title"` + Description string `json:"description"` +} + +type ProposalsRPCResponse struct { + Proposals []Proposal `json:"proposals"` +} + +type Vote struct { + ProposalID string `json:"proposal_id"` + Voter string `json:"voter"` + Option string `json:"option"` +} + +type VoteRPCResponse struct { + Code int64 `json:"code"` + Vote *Vote `json:"vote"` +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..3c71909 --- /dev/null +++ b/utils.go @@ -0,0 +1,11 @@ +package main + +func Filter[T any](slice []T, f func(T) bool) []T { + var n []T + for _, e := range slice { + if f(e) { + n = append(n, e) + } + } + return n +}