diff --git a/director.go b/director.go index 06cb62c..a982931 100644 --- a/director.go +++ b/director.go @@ -8,6 +8,7 @@ import ( "github.com/decred/dcrdata/exchanges/v2" "github.com/planetdecred/pdanalytics/attackcost" "github.com/planetdecred/pdanalytics/dcrd" + "github.com/planetdecred/pdanalytics/gov/agendas" "github.com/planetdecred/pdanalytics/gov/politeia" "github.com/planetdecred/pdanalytics/homepage" "github.com/planetdecred/pdanalytics/mempool" @@ -141,6 +142,23 @@ func setupModules(ctx context.Context, cfg *config, client *dcrd.Dcrd, server *w }() } + if cfg.EnableAgendas || cfg.EnableAgendasHttp { + db, err := dbInstance() + if err != nil { + return err + } + // TODO: use valid implementation + var voteCounter = func(string) (uint32, uint32, uint32, error) { + return 1, 0, 1, nil + } + err = agendas.Activate(ctx, client, voteCounter, db, cfg.AgendasDBFileName, + cfg.DataDir, server, cfg.EnableAgendas, cfg.EnableAgendasHttp, cfg.SimNet) + if err != nil { + return err + } + log.Info("Agendas module Enabled") + } + if cfg.EnableNetworkSnapshot || cfg.EnableNetworkSnapshotHTTP { db, err := dbInstance() if err != nil { diff --git a/go.mod b/go.mod index d74d195..d91c114 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/caarlos0/env v3.5.0+incompatible github.com/decred/dcrd/chaincfg/chainhash v1.0.2 github.com/decred/dcrd/chaincfg/v2 v2.3.0 + github.com/decred/dcrd/dcrjson/v3 v3.1.0 // indirect github.com/decred/dcrd/dcrutil v1.4.0 github.com/decred/dcrd/dcrutil/v2 v2.0.1 github.com/decred/dcrd/gcs/v2 v2.1.0 // indirect diff --git a/gov/agendas/agendas.go b/gov/agendas/agendas.go new file mode 100644 index 0000000..865037f --- /dev/null +++ b/gov/agendas/agendas.go @@ -0,0 +1,147 @@ +package agendas + +import ( + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sync" + + "github.com/decred/dcrd/wire" + "github.com/planetdecred/pdanalytics/dbhelper" + "github.com/planetdecred/pdanalytics/dcrd" + "github.com/planetdecred/pdanalytics/web" +) + +type dataSource interface { + AgendasVotesSummary(agendaID string) (summary *dbhelper.AgendaSummary, err error) +} + +type agendas struct { + ctx context.Context + client *dcrd.Dcrd + server *web.Server + reorgLock sync.Mutex + height uint32 + agendasSource *AgendaDB + voteTracker *VoteTracker + dataSource dataSource +} + +// Activate activates the proposal module. +// This may take some time and should be ran in a goroutine +func Activate(ctx context.Context, client *dcrd.Dcrd, voteCounter voteCounter, dataSource dataSource, + agendasDBFileName, dataDir string, webServer *web.Server, dataMode, httpMode, simNet bool) error { + + agendaDB, err := NewAgendasDB(client.Rpc, filepath.Join(dataDir, agendasDBFileName)) + if err != nil { + return fmt.Errorf("failed to create new agendas db instance: %v", err) + } + + // A vote tracker tracks current block and stake versions and votes. Only + // initialize the vote tracker if not on simnet. nil tracker is a sentinel + // value throughout. + var tracker *VoteTracker + if !simNet { + tracker, err = NewVoteTracker(client.Params, client.Rpc, voteCounter) + if err != nil { + return fmt.Errorf("Unable to initialize vote tracker: %v", err) + } + } + + agen := &agendas{ + client: client, + server: webServer, + agendasSource: agendaDB, + voteTracker: tracker, + dataSource: dataSource, + } + + // move cache folder to the data directory + // if the config file is missing, create the default + pathNotExists := func(path string) bool { + _, err := os.Stat(path) + return os.IsNotExist(err) + } + + if pathNotExists(path.Join(dataDir, "agendas_cache")) { + log.Infof("creating %s for agendas cache", path.Join(dataDir, "agendas_cache")) + if err = os.MkdirAll(path.Join(dataDir, "agendas_cache"), os.ModePerm); err != nil { + return fmt.Errorf("Missing agendas cache dir and cannot create it - %s", err.Error()) + } + } + + hash, err := client.Rpc.GetBestBlockHash() + if err != nil { + return err + } + blockHeader, err := client.Rpc.GetBlockHeader(hash) + if err != nil { + return err + } + + if err = agen.ConnectBlock(blockHeader); err != nil { + return err + } + + if httpMode { + agen.server.AddRoute("/agendas", web.GET, agen.AgendasPage) + if err := agen.server.Templates.AddTemplate("agendas"); err != nil { + return err + } + + agen.server.AddRoute("/agenda/{id}", web.GET, agen.AgendaPage, agendaPathCtx) + if err := agen.server.Templates.AddTemplate("agenda"); err != nil { + return err + } + + agen.server.AddMenuItem(web.MenuItem{ + Href: "/agendas", + HyperText: "Agendas", + Attributes: map[string]string{ + "class": "menu-item", + "title": "Agendas", + }, + }) + } + + if dataMode { + // The proposals and agenda db updates are run after the db indexing. + // Retrieve blockchain deployment updates and add them to the agendas db. + if err = agendaDB.UpdateAgendas(); err != nil { + return fmt.Errorf("updating agendas db failed: %v", err) + } + } + + return nil +} + +func copyFile(sourec, destination string) error { + from, err := os.Open(sourec) + if err != nil { + return err + } + defer from.Close() + + to, err := os.OpenFile(destination, os.O_RDWR|os.O_CREATE, 0666) + if err != nil { + return err + } + defer to.Close() + + _, err = io.Copy(to, from) + if err != nil { + return err + } + + return nil +} + +func (ac *agendas) ConnectBlock(w *wire.BlockHeader) error { + ac.reorgLock.Lock() + defer ac.reorgLock.Unlock() + ac.height = w.Height + return nil +} diff --git a/gov/agendas/deployments.go b/gov/agendas/deployments.go new file mode 100644 index 0000000..8e47e07 --- /dev/null +++ b/gov/agendas/deployments.go @@ -0,0 +1,294 @@ +// Copyright (c) 2018-2020, The Decred developers +// See LICENSE for details. + +// Package agendas manages the various deployment agendas that are directly +// voted upon with the vote bits in vote transactions. +package agendas + +import ( + "fmt" + "os" + "strings" + + "github.com/asdine/storm/v3" + "github.com/asdine/storm/v3/q" + "github.com/decred/dcrd/dcrjson/v3" + chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v2" + dbtypes "github.com/planetdecred/pdanalytics/gov/agendas/types" + "github.com/planetdecred/pdanalytics/semver" +) + +// AgendaDB represents the data for the stored DB. +type AgendaDB struct { + sdb *storm.DB + stakeVersions []uint32 + deploySource DeploymentSource +} + +// AgendaTagged has the same fields as chainjson.Agenda plus the VoteVersion +// field, but with the ID field marked as the primary key via the `storm:"id"` +// tag. Fields tagged for indexing by the DB are: StartTime, ExpireTime, Status, +// and QuorumProgress. +type AgendaTagged struct { + ID string `json:"id" storm:"id"` + Description string `json:"description"` + Mask uint16 `json:"mask"` + StartTime uint64 `json:"starttime" storm:"index"` + ExpireTime uint64 `json:"expiretime" storm:"index"` + Status dbtypes.AgendaStatusType `json:"status" storm:"index"` + QuorumProgress float64 `json:"quorumprogress" storm:"index"` + Choices []chainjson.Choice `json:"choices"` + VoteVersion uint32 `json:"voteversion"` +} + +var ( + // dbVersion is the current required version of the agendas.db. + dbVersion = semver.NewSemver(1, 0, 0) +) + +// dbInfo defines the property that holds the db version. +const dbInfo = "_agendas.db_" + +// DeploymentSource provides a cleaner way to track the rpcclient methods used +// in this package. It also allows usage of alternative implementations to +// satisfy the interface. +type DeploymentSource interface { + GetVoteInfo(version uint32) (*chainjson.GetVoteInfoResult, error) +} + +// NewAgendasDB opens an existing database or create a new one using with the +// specified file name. It also checks the DB version, reindexes the DB if need +// be, and sets the required DB version. +func NewAgendasDB(client DeploymentSource, dbPath string) (*AgendaDB, error) { + if dbPath == "" { + return nil, fmt.Errorf("empty db Path found") + } + + if client == DeploymentSource(nil) { + return nil, fmt.Errorf("invalid deployment source found") + } + + _, err := os.Stat(dbPath) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + db, err := storm.Open(dbPath) + if err != nil { + return nil, err + } + + // Check if the correct DB version has been set. + var version string + err = db.Get(dbInfo, "version", &version) + if err != nil && err != storm.ErrNotFound { + return nil, err + } + + // Check if the versions match. + if version != dbVersion.String() { + // Attempt to delete AgendaTagged bucket. + if err = db.Drop(&AgendaTagged{}); err != nil { + // If error due bucket not found was returned ignore it. + if !strings.Contains(err.Error(), "not found") { + return nil, fmt.Errorf("delete bucket struct failed: %v", err) + } + } + + // Set the required db version. + err = db.Set(dbInfo, "version", dbVersion.String()) + if err != nil { + return nil, err + } + log.Infof("agendas.db version %v was set", dbVersion) + } + + // Determine stake versions known by dcrd. + stakeVersions, err := listStakeVersions(client) + if err != nil { + return nil, err + } + + adb := &AgendaDB{ + sdb: db, + deploySource: client, + stakeVersions: stakeVersions, + } + return adb, nil +} + +func listStakeVersions(client DeploymentSource) ([]uint32, error) { + agendaIDs := func(agendas []chainjson.Agenda) (ids []string) { + for i := range agendas { + ids = append(ids, agendas[i].ID) + } + return + } + + var firstVer uint32 + for { + voteInfo, err := client.GetVoteInfo(firstVer) + if err == nil { + // That's the first version. + log.Debugf("Stake version %d: %v", firstVer, agendaIDs(voteInfo.Agendas)) + // startTime = voteInfo.Agendas[0].StartTime + break + } + + // When dcrd fixes the code, do this instead of regexp: + if jerr, ok := err.(*dcrjson.RPCError); ok && + jerr.Code == dcrjson.ErrRPCInvalidParameter { + firstVer++ + if firstVer == 10 { + log.Warnf("No stake versions found < 10. aborting scan") + return nil, nil + } + continue + } + + return nil, err + } + + versions := []uint32{firstVer} + for i := firstVer + 1; ; i++ { + voteInfo, err := client.GetVoteInfo(i) + if err == nil { + log.Debugf("Stake version %d: %v", i, agendaIDs(voteInfo.Agendas)) + versions = append(versions, i) + continue + } + + if jerr, ok := err.(*dcrjson.RPCError); ok && + jerr.Code == dcrjson.ErrRPCInvalidParameter { + break + } + + // Something went wrong. + return nil, err + } + + return versions, nil +} + +// Close should be called when you are done with the AgendaDB to close the +// underlying database. +func (db *AgendaDB) Close() error { + if db == nil || db.sdb == nil { + return nil + } + return db.sdb.Close() +} + +// loadAgenda retrieves an agenda corresponding to the specified unique agenda +// ID, or returns nil if it does not exist. +func (db *AgendaDB) loadAgenda(agendaID string) (*AgendaTagged, error) { + agenda := new(AgendaTagged) + if err := db.sdb.One("ID", agendaID, agenda); err != nil { + return nil, err + } + + return agenda, nil +} + +// agendasForVoteVersion fetches the agendas using the vote versions provided. +func agendasForVoteVersion(ver uint32, client DeploymentSource) ([]AgendaTagged, error) { + voteInfo, err := client.GetVoteInfo(ver) + if err != nil { + return nil, err + } + + // Set the agendas slice capacity. + agendas := make([]AgendaTagged, 0, len(voteInfo.Agendas)) + for i := range voteInfo.Agendas { + v := &voteInfo.Agendas[i] + agendas = append(agendas, AgendaTagged{ + ID: v.ID, + Description: v.Description, + Mask: v.Mask, + StartTime: v.StartTime, + ExpireTime: v.ExpireTime, + Status: dbtypes.AgendaStatusFromStr(v.Status), + QuorumProgress: v.QuorumProgress, + Choices: v.Choices, + VoteVersion: voteInfo.VoteVersion, + }) + } + + return agendas, nil +} + +// updateDB updates the agenda data for all configured vote versions. +// chainjson.GetVoteInfoResult and chaincfg.ConsensusDeployment hold almost +// similar data contents but chaincfg.Vote does not contain the important vote +// status field that is found in chainjson.Agenda. +func (db *AgendaDB) updateDB() (int, error) { + agendas := make([]AgendaTagged, 0, len(db.stakeVersions)) + for _, voteVersion := range db.stakeVersions { + taggedAgendas, err := agendasForVoteVersion(voteVersion, db.deploySource) + if err != nil || len(taggedAgendas) == 0 { + return -1, fmt.Errorf("vote version %d agendas retrieval failed: %v", + voteVersion, err) + } + + agendas = append(agendas, taggedAgendas...) + } + + for i := range agendas { + agenda := &agendas[i] + err := db.storeAgenda(agenda) + if err != nil { + return -1, fmt.Errorf("agenda '%s' was not saved: %v", + agenda.Description, err) + } + } + + return len(agendas), nil +} + +// storeAgenda saves an agenda in the database. +func (db *AgendaDB) storeAgenda(agenda *AgendaTagged) error { + return db.sdb.Save(agenda) +} + +// UpdateAgendas updates agenda data for all configured vote versions. +func (db *AgendaDB) UpdateAgendas() error { + if db.stakeVersions == nil { + log.Debugf("skipping agendas update") + return nil + } + + numRecords, err := db.updateDB() + if err != nil { + return fmt.Errorf("agendas.UpdateAgendas failed: %v", err) + } + + log.Infof("%d agenda records (agendas) were updated", numRecords) + return nil +} + +// AgendaInfo fetches an agenda's details given its agendaID. +func (db *AgendaDB) AgendaInfo(agendaID string) (*AgendaTagged, error) { + if db.stakeVersions == nil { + return nil, fmt.Errorf("No deployments") + } + + agenda, err := db.loadAgenda(agendaID) + if err != nil { + return nil, err + } + + return agenda, nil +} + +// AllAgendas returns all agendas and their info in the db. +func (db *AgendaDB) AllAgendas() (agendas []*AgendaTagged, err error) { + if db.stakeVersions == nil { + return []*AgendaTagged{}, nil + } + + err = db.sdb.Select(q.True()).OrderBy("VoteVersion", "ID").Reverse().Find(&agendas) + if err != nil { + log.Errorf("Failed to fetch data from Agendas DB: %v", err) + } + return +} diff --git a/gov/agendas/deployments_test.go b/gov/agendas/deployments_test.go new file mode 100644 index 0000000..d8024d2 --- /dev/null +++ b/gov/agendas/deployments_test.go @@ -0,0 +1,274 @@ +package agendas + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/asdine/storm/v3" + "github.com/decred/dcrd/dcrjson/v3" + chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v2" + dbtypes "github.com/planetdecred/pdanalytics/gov/agendas/types" +) + +var db *storm.DB +var tempDir string + +var firstAgendaInfo = &AgendaTagged{ + ID: "fixlnseqlocks", + Description: "Modify sequence lock handling as defined in DCP0004", + Mask: 6, + StartTime: 1548633600, + ExpireTime: 1580169600, + Status: dbtypes.InitialAgendaStatus, + Choices: []chainjson.Choice{ + { + ID: "abstain", + Description: "abstain voting for change", + IsAbstain: true, + }, { + ID: "no", + Description: "keep the existing consensus rules", + Bits: 2, + IsNo: true, + }, { + ID: "yes", + Description: "change to the new consensus rules", + Bits: 4, + }, + }, + VoteVersion: 4, +} + +// TestMain sets up the temporary db needed for testing +func TestMain(m *testing.M) { + var err error + tempDir, err = ioutil.TempDir(os.TempDir(), "onchain") + if err != nil { + panic(err) + } + + db, err = storm.Open(filepath.Join(tempDir, "test.db")) + if err != nil { + panic(err) + } + + // Save the first sample agendaInfo + err = db.Save(firstAgendaInfo) + if err != nil { + panic(err) + } + + returnVal := m.Run() + + db.Close() + // clean up + os.RemoveAll(tempDir) + + // Exit with the return value + os.Exit(returnVal) +} + +// testClient needed to mock the actual client GetVoteInfo implementation +type testClient int + +// GetVoteInfo implementation showing a sample data format expected. +func (*testClient) GetVoteInfo(version uint32) (*chainjson.GetVoteInfoResult, error) { + if version != 5 { + msg := fmt.Sprintf("stake version %d does not exist", version) + return nil, dcrjson.NewRPCError(dcrjson.ErrRPCInvalidParameter, msg) + } + resp := &chainjson.GetVoteInfoResult{ + CurrentHeight: 319842, + StartHeight: 318592, + EndHeight: 326655, + Hash: "000000000000000021a2128e44824adc7ad0560d38ca0692aeb8281f75b6d9a0", + VoteVersion: 5, + Quorum: 4032, + TotalVotes: 0, + Agendas: []chainjson.Agenda{ + { + ID: "TestAgenda0001", + Description: "This agenda just shows chainjson.GetVoteInfoResult payload format", + Mask: 6, + StartTime: 1493164800, + ExpireTime: 1524700800, + Status: "active", + QuorumProgress: 100, + Choices: []chainjson.Choice{ + { + ID: "abstain", + Description: "abstain voting for change", + Bits: 0, + IsAbstain: true, + IsNo: false, + Count: 120, + Progress: 100, + }, + { + ID: "no", + Description: "keep the existing algorithm", + Bits: 2, + IsAbstain: false, + IsNo: true, + Count: 10, + Progress: 100, + }, + { + ID: "yes", + Description: "change to the new algorithm", + Bits: 4, + IsAbstain: false, + IsNo: false, + Count: 12120, + Progress: 100, + }, + }, + }, + }, + } + return resp, nil +} + +// TestNewAgendasDB tests the functionality of NewAgendaDB. +func TestNewAgendasDB(t *testing.T) { + type testData struct { + rpc DeploymentSource + dbPath string + // Outputs + IsDBInstance bool + errMsg string + } + + var client *testClient + testPath := filepath.Join(tempDir, "test2.db") + + td := []testData{ + {nil, "", false, "empty db Path found"}, + {client, "", false, "empty db Path found"}, + {nil, testPath, false, "invalid deployment source found"}, + {client, testPath, true, ""}, + } + + for i, val := range td { + results, err := NewAgendasDB(val.rpc, val.dbPath) + if err == nil && val.errMsg != "" { + t.Fatalf("expected no error but found '%v' ", err) + } + + if err != nil && err.Error() != val.errMsg { + t.Fatalf(" expected error '%v' but found '%v", val.errMsg, err) + } + + if results == nil && val.IsDBInstance { + t.Fatalf("%d: expected a non-nil db instance but found a nil instance", i) + } + + // If a valid db instance is expected test if the corresponding db exists. + if val.IsDBInstance { + if _, err := os.Stat(val.dbPath); os.IsNotExist(err) { + t.Fatalf("expected to find the corresponding db at '%v' path but did not.", val.dbPath) + } + } + } +} + +var expectedAgenda = &AgendaTagged{ + ID: "TestAgenda0001", + Description: "This agenda just shows chainjson.GetVoteInfoResult payload format", + Mask: 6, + StartTime: 1493164800, + ExpireTime: 1524700800, + Status: dbtypes.ActivatedAgendaStatus, + QuorumProgress: 100, + Choices: []chainjson.Choice{ + { + ID: "abstain", + Description: "abstain voting for change", + IsAbstain: true, + Count: 120, + Progress: 100, + }, + { + ID: "no", + Description: "keep the existing algorithm", + Bits: 2, + IsNo: true, + Count: 10, + Progress: 100, + }, + { + ID: "yes", + Description: "change to the new algorithm", + Bits: 4, + Count: 12120, + Progress: 100, + }, + }, + VoteVersion: 5, +} + +// TestUpdateAndRetrievals tests the agendas db updating and retrieval of one +// and many agendas. +func TestUpdateAndRetrievals(t *testing.T) { + var client *testClient + + voteVersions := []uint32{5} + dbInstance := &AgendaDB{ + sdb: db, + deploySource: client, + stakeVersions: voteVersions, + } + + err := dbInstance.UpdateAgendas() + if err != nil { + t.Fatalf("agenda update error: %v", err) + } + + // Test retrieval of all agendas. + t.Run("Test_AllAgendas", func(t *testing.T) { + agendas, err := dbInstance.AllAgendas() + if err != nil { + t.Fatalf("expected no error but found '%v'", err) + } + + if len(agendas) != 2 { + t.Fatalf("expected to find two agendas but found: %d ", len(agendas)) + } + + for _, data := range agendas { + if data == nil { + t.Fatal("expected to find non nil data") + } + + switch data.ID { + case firstAgendaInfo.ID: + if !reflect.DeepEqual(data, firstAgendaInfo) { + t.Fatal("expected the returned data to be equal to firstAgendainfo but it wasn't") + } + + case expectedAgenda.ID: + if !reflect.DeepEqual(data, expectedAgenda) { + t.Fatal("expected the returned data to be equal to second agenda data but it wasn't") + } + default: + t.Fatalf("inaccurate agendas data found") + } + } + }) + + // Testing retrieval of single agenda by ID. + t.Run("Test_AgendaInfo", func(t *testing.T) { + agenda, err := dbInstance.AgendaInfo(firstAgendaInfo.ID) + if err != nil { + t.Fatalf("expected to find no error but found %v", err) + } + + if !reflect.DeepEqual(agenda, firstAgendaInfo) { + t.Fatal("expected the agenda info returned to be equal to firstAgendaInfo but it wasn't") + } + }) +} diff --git a/gov/agendas/handlers.go b/gov/agendas/handlers.go new file mode 100644 index 0000000..89c172b --- /dev/null +++ b/gov/agendas/handlers.go @@ -0,0 +1,171 @@ +package agendas + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi" + "github.com/planetdecred/pdanalytics/web" +) + +// AgendasPage is the page handler for the "/agendas" path. +func (exp *agendas) AgendasPage(w http.ResponseWriter, r *http.Request) { + if exp.voteTracker == nil { + log.Warnf("Agendas requested with nil voteTracker") + exp.server.StatusPage(w, r, "", "agendas disabled on simnet", "", web.ExpStatusPageDisabled) + return + } + + agenda, err := exp.agendasSource.AllAgendas() + if err != nil { + log.Errorf("Error fetching agendas: %v", err) + exp.server.StatusPage(w, r, web.DefaultErrorCode, web.DefaultErrorMessage, "", web.ExpStatusError) + return + } + + str, err := exp.server.Templates.ExecTemplateToString("agendas", struct { + *web.CommonPageData + Agendas []*AgendaTagged + VotingSummary *VoteSummary + BreadcrumbItems []web.BreadcrumbItem + }{ + CommonPageData: exp.server.CommonData(r), + Agendas: agenda, + VotingSummary: exp.voteTracker.Summary(), + BreadcrumbItems: []web.BreadcrumbItem{ + { + HyperText: "Agendas", + Active: true, + }, + }, + }) + + if err != nil { + log.Errorf("Template execute failure: %v", err) + exp.server.StatusPage(w, r, web.DefaultErrorCode, web.DefaultErrorMessage, "", web.ExpStatusError) + return + } + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + io.WriteString(w, str) +} + +// AgendaPage is the page handler for the "/agenda" path. +func (exp *agendas) AgendaPage(w http.ResponseWriter, r *http.Request) { + errPageInvalidAgenda := func(err error) { + log.Errorf("Template execute failure: %v", err) + exp.server.StatusPage(w, r, web.DefaultErrorCode, "the agenda ID given seems to not exist", + "", web.ExpStatusNotFound) + } + + // Attempt to get agendaid string from URL path. + agendaId := getAgendaIDCtx(r) + agendaInfo, err := exp.agendasSource.AgendaInfo(agendaId) + if err != nil { + errPageInvalidAgenda(err) + return + } + + summary, err := exp.dataSource.AgendasVotesSummary(agendaId) + if err != nil { + log.Errorf("fetching Cumulative votes choices count failed: %v", err) + } + + // Overrides the default count value with the actual vote choices count + // matching data displayed on "Cumulative Vote Choices" and "Vote Choices By + // Block" charts. + var totalVotes uint32 + for index := range agendaInfo.Choices { + switch strings.ToLower(agendaInfo.Choices[index].ID) { + case "abstain": + agendaInfo.Choices[index].Count = summary.Abstain + case "yes": + agendaInfo.Choices[index].Count = summary.Yes + case "no": + agendaInfo.Choices[index].Count = summary.No + } + totalVotes += agendaInfo.Choices[index].Count + } + + ruleChangeI := exp.client.Params.RuleChangeActivationInterval + qVotes := uint32(float64(ruleChangeI) * agendaInfo.QuorumProgress) + + var timeLeft string + blocksLeft := summary.LockedIn - int64(exp.height) + + if blocksLeft > 0 { + // Approximately 1 block per 5 minutes. + var minPerblock = 5 * time.Minute + + hoursLeft := int((time.Duration(blocksLeft) * minPerblock).Hours()) + if hoursLeft > 0 { + timeLeft = fmt.Sprintf("%v days %v hours", hoursLeft/24, hoursLeft%24) + } + } else { + blocksLeft = 0 + } + + str, err := exp.server.Templates.ExecTemplateToString("agenda", struct { + *web.CommonPageData + Ai *AgendaTagged + QuorumVotes uint32 + RuleChangeI uint32 + VotingStarted int64 + LockedIn int64 + BlocksLeft int64 + TimeRemaining string + TotalVotes uint32 + BreadcrumbItems []web.BreadcrumbItem + }{ + CommonPageData: exp.server.CommonData(r), + Ai: agendaInfo, + QuorumVotes: qVotes, + RuleChangeI: ruleChangeI, + VotingStarted: summary.VotingStarted, + LockedIn: summary.LockedIn, + BlocksLeft: blocksLeft, + TimeRemaining: timeLeft, + TotalVotes: totalVotes, + BreadcrumbItems: []web.BreadcrumbItem{ + { + HyperText: "Agendas", + Href: "/agendas", + }, + { + HyperText: agendaInfo.ID, + Active: true, + }, + }, + }) + + if err != nil { + log.Errorf("Template execute failure: %v", err) + exp.server.StatusPage(w, r, web.DefaultErrorCode, web.DefaultErrorMessage, "", web.ExpStatusError) + return + } + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + io.WriteString(w, str) +} + +// proposalPathCtx embeds "proposalrefID" into the request context +func agendaPathCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agendaID := chi.URLParam(r, "id") + ctx := context.WithValue(r.Context(), web.CtxAgendaId, agendaID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func getAgendaIDCtx(r *http.Request) string { + hash, ok := r.Context().Value(web.CtxAgendaId).(string) + if !ok { + log.Trace("Agendaid not set") + return "" + } + return hash +} diff --git a/gov/agendas/log.go b/gov/agendas/log.go new file mode 100644 index 0000000..31761ce --- /dev/null +++ b/gov/agendas/log.go @@ -0,0 +1,22 @@ +// Copyright (c) 2018-2020, The Decred developers +// See LICENSE for details. + +package agendas + +import "github.com/decred/slog" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +var log = slog.Disabled + +// DisableLog disables all library log output. Logging output is disabled +// by default until UseLogger is called. +func DisableLog() { + log = slog.Disabled +} + +// UseLogger uses a specified Logger to output package logging info. +func UseLogger(logger slog.Logger) { + log = logger +} diff --git a/gov/agendas/tracker.go b/gov/agendas/tracker.go new file mode 100644 index 0000000..60b7276 --- /dev/null +++ b/gov/agendas/tracker.go @@ -0,0 +1,514 @@ +// Copyright (c) 2019-2020, The Decred developers +// See LICENSE for details. + +package agendas + +import ( + "fmt" + "sync" + + "github.com/decred/dcrd/chaincfg/v2" + chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v2" +) + +const ( + statusDefined = "defined" + statusStarted = "started" + statusLocked = "lockedin" + statusActive = "active" + statusFailed = "failed" + choiceYes = "yes" + choiceNo = "no" + choiceAbstain = "abstain" +) + +// VoteDataSource is satisfied by rpcclient.Client. +type VoteDataSource interface { + GetStakeVersionInfo(int32) (*chainjson.GetStakeVersionInfoResult, error) + GetVoteInfo(uint32) (*chainjson.GetVoteInfoResult, error) + GetStakeVersions(string, int32) (*chainjson.GetStakeVersionsResult, error) +} + +// dcrd does not supply vote counts for completed votes, so the tracker will +// need a means to get the counts from a database somewhere. +type voteCounter func(string) (uint32, uint32, uint32, error) + +// AgendaSummary summarizes the current state of voting on a particular agenda. +type AgendaSummary struct { + Description string `json:"description"` + ID string `json:"id"` + Quorum uint32 `json:"quorum"` + QuorumProgress float32 `json:"quorum_progress"` + QuorumAchieved bool `json:"quorum_achieved"` + Aye uint32 `json:"aye"` + Nay uint32 `json:"nay"` + Abstain uint32 `json:"abstain"` + AbstainRate float32 `json:"abstain_rate"` + VoteCount uint32 `json:"vote_count"` // non-abstaining + PassThreshold float32 `json:"pass_threshold"` + FailThreshold float32 `json:"fail_threshold"` + Approval float32 `json:"approval"` + LockCount uint32 `json:"lock_count"` + IsWinning bool `json:"is_winning"` + IsLosing bool `json:"is_losing"` + IsVoting bool `json:"is_voting"` + IsDefined bool `json:"is_defined"` + VotingTriggered bool `json:"voting_triggered"` + IsLocked bool `json:"is_locked"` + IsFailed bool `json:"is_failed"` + IsActive bool `json:"is_active"` +} + +// VoteSummary summarizes the current state of consensus voting. VoteSummary is +// the primary exported type produced by VoteTracker. +type VoteSummary struct { + Version uint32 `json:"version"` + Height int64 `json:"height"` + Hash string `json:"hash"` + VoteVersion uint32 `json:"vote_version"` + Agendas []AgendaSummary `json:"agendas"` + OldVoters uint32 `json:"old_voters"` + NewVoters uint32 `json:"new_voters"` + VoterCount uint32 `json:"voter_count"` + VoterThreshold float32 `json:"voter_threshold"` + VoterProgress float32 `json:"voter_progress"` + OldMiners uint32 `json:"old_miners"` + NewMiners uint32 `json:"new_miners"` + MinerCount uint32 `json:"miner_count"` + MinerThreshold float32 `json:"miner_threshold"` + MinerProgress float32 `json:"miner_progress"` + RCIBlocks uint32 `json:"rci_blocks"` + RCIMined uint32 `json:"rci_mined"` + RCIProgress float32 `json:"rci_progress"` + SVIBlocks uint32 `json:"svi_blocks"` + SVIMined uint32 `json:"svi_mined"` + SVIProgress float32 `json:"svi_progress"` + TilNextRCI int64 `json:"til_next_rci"` + NextRCIHeight uint32 `json:"next_rci_height"` + NetworkUpgraded bool `json:"network_upgrading"` + VotingTriggered bool `json:"voting_triggered"` +} + +// Store counts fetched using the voteCounter to prevent extra database calls. +// The counts are only required for votes that have completed, so the numbers +// will not change once cached. +type voteCount struct { + yes uint32 + no uint32 + abstain uint32 +} + +// VoteTracker manages the current state of node version data and vote data on +// the blockchain. VoteTracker refreshes its data when it is signaled by +// a call to Refresh. A VoteSummary is created and stored for requests with the +// Summary method. +type VoteTracker struct { + mtx sync.RWMutex + node VoteDataSource + voteCounter voteCounter + countCache map[string]*voteCount + params *chaincfg.Params + version uint32 + blockVersion int32 + stakeVersion uint32 + stakeInfo *chainjson.GetStakeVersionInfoResult + voteInfo *chainjson.GetVoteInfoResult + summary *VoteSummary + ringIndex int + ringHeight int64 + blockRing []int32 + minerThreshold float32 + voterThreshold float32 + sviBlocks uint32 + rciBlocks uint32 + blockTime int64 + passThreshold float32 + rciVotes uint32 +} + +// NewVoteTracker is a constructor for a VoteTracker. +func NewVoteTracker(params *chaincfg.Params, node VoteDataSource, counter voteCounter) (*VoteTracker, error) { + stakeVersions, err := listStakeVersions(node) + if err != nil { + return nil, err + } + if len(stakeVersions) == 0 { + return nil, fmt.Errorf("no stake versions found") + } + latestStakeVersion := stakeVersions[len(stakeVersions)-1] + + tracker := &VoteTracker{ + node: node, + voteCounter: counter, + countCache: make(map[string]*voteCount), + params: params, + version: latestStakeVersion, + summary: &VoteSummary{}, + ringIndex: -1, + blockRing: make([]int32, params.BlockUpgradeNumToCheck), + minerThreshold: float32(params.BlockRejectNumRequired) / float32(params.BlockUpgradeNumToCheck), + voterThreshold: float32(params.RuleChangeActivationMultiplier) / float32(params.RuleChangeActivationDivisor), + sviBlocks: uint32(params.StakeVersionInterval), + rciBlocks: params.RuleChangeActivationInterval, + blockTime: int64(params.TargetTimePerBlock.Seconds()), + passThreshold: float32(params.RuleChangeActivationMultiplier) / float32(params.RuleChangeActivationDivisor), + rciVotes: uint32(params.TicketsPerBlock) * params.RuleChangeActivationInterval, + } + + // first sync has different error handling than Refresh. + voteInfo, err := tracker.refreshRCI() + if err != nil { + return nil, err + } + if voteInfo == nil { + // No deployments found. Not an error, but no reason to go any farther. + return tracker, nil + } + blocksToAdd, stakeVersion, err := tracker.fetchBlocks(voteInfo) + if err != nil { + return nil, err + } + stakeInfo, err := tracker.refreshSVIs(voteInfo) + if err != nil { + return nil, err + } + tracker.update(voteInfo, blocksToAdd, stakeInfo, stakeVersion) + + return tracker, nil +} + +// Refresh refreshes node version and vote data. It can be called as a +// goroutine. All VoteTracker updating and mutex locking is handled within +// VoteTracker.update. +func (tracker *VoteTracker) Refresh() { + voteInfo, err := tracker.refreshRCI() + if err != nil { + log.Errorf("VoteTracker.Refresh -> refreshRCI: %v", err) + return + } + if voteInfo == nil { + // No deployments found. Not an error, but no reason to go any farther. + return + } + blocksToAdd, stakeVersion, err := tracker.fetchBlocks(voteInfo) + if err != nil { + log.Errorf("VoteTracker.Refresh -> fetchBlocks: %v", err) + return + } + stakeInfo, err := tracker.refreshSVIs(voteInfo) + if err != nil { + log.Errorf("VoteTracker.Refresh -> refreshSVIs: %v", err) + return + } + tracker.update(voteInfo, blocksToAdd, stakeInfo, stakeVersion) +} + +// Version returns the current best known vote version. +// Since version could technically be updated without turning off dcrdata, +// the field must be protected. +func (tracker *VoteTracker) Version() uint32 { + tracker.mtx.RLock() + defer tracker.mtx.RUnlock() + return tracker.version +} + +// Grab the getvoteinfo data. Do not update VoteTracker.voteInfo here, as it +// will be updated with other fields under mutex lock in VoteTracker.update. +func (tracker *VoteTracker) refreshRCI() (*chainjson.GetVoteInfoResult, error) { + oldVersion := tracker.Version() + v := oldVersion + var err error + var voteInfo, vinfo *chainjson.GetVoteInfoResult + + // Retrieves the voteinfo for the last stake version supported. + for { + vinfo, err = tracker.node.GetVoteInfo(v) + if err != nil { + break + } + voteInfo = vinfo + v++ + } + + if voteInfo == nil { + if oldVersion == 0 { + // Probably no deployments found. Not necessarily an error. + log.Info("No agenda information retrieved from dcrd.") + return nil, nil + } + return nil, fmt.Errorf("refreshRCI: Vote information not found: %v", err) + } + if v > oldVersion+1 { + tracker.mtx.Lock() + tracker.version = v + tracker.mtx.Unlock() + } + return voteInfo, nil +} + +// The number of blocks that have been mined in the rule change interval. +func rciBlocks(voteInfo *chainjson.GetVoteInfoResult) int64 { + return voteInfo.CurrentHeight - voteInfo.StartHeight + 1 +} + +// Grab the block versions for up to the last BlockUpgradeNumToCheck blocks. +// If the current block builds upon the last block, only request a single +// block's data. Otherwise, request all BlockUpgradeNumToCheck. +func (tracker *VoteTracker) fetchBlocks(voteInfo *chainjson.GetVoteInfoResult) ([]int32, uint32, error) { + blocksToRequest := 1 + // If this isn't the next block, request them all again + if voteInfo.CurrentHeight < 0 || voteInfo.CurrentHeight != tracker.ringHeight+1 { + blocksToRequest = int(tracker.params.BlockUpgradeNumToCheck) + } + r, err := tracker.node.GetStakeVersions(voteInfo.Hash, int32(blocksToRequest)) + if err != nil { + return nil, 0, err + } + blockCount := len(r.StakeVersions) + if blockCount != blocksToRequest { + return nil, 0, fmt.Errorf("Unexpected number of blocks returns from GetStakeVersions. Asked for %d, received %d", blocksToRequest, blockCount) + } + blocks := make([]int32, blockCount) + var block chainjson.StakeVersions + for i := 0; i < blockCount; i++ { + block = r.StakeVersions[blockCount-i-1] // iterate backwards + blocks[i] = block.BlockVersion + } + return blocks, block.StakeVersion, nil +} + +// Get the info for the stake versions in the current rule change interval. +func (tracker *VoteTracker) refreshSVIs(voteInfo *chainjson.GetVoteInfoResult) (*chainjson.GetStakeVersionInfoResult, error) { + blocksInCurrentRCI := rciBlocks(voteInfo) + svis := int32(blocksInCurrentRCI / tracker.params.StakeVersionInterval) + // blocksInCurrentSVI := int32(blocksInCurrentRCI % params.StakeVersionInterval) + if blocksInCurrentRCI%tracker.params.StakeVersionInterval > 0 { + svis++ + } + si, err := tracker.node.GetStakeVersionInfo(svis) + if err != nil { + return nil, fmt.Errorf("Error retrieving stake version info: %v", err) + } + return si, nil +} + +// The cached voteCount for the given agenda, or nil if not found. +func (tracker *VoteTracker) cachedCounts(agendaID string) *voteCount { + tracker.mtx.RLock() + defer tracker.mtx.RUnlock() + return tracker.countCache[agendaID] +} + +// Cache the voteCount for the given agenda. +func (tracker *VoteTracker) cacheVoteCounts(agendaID string, counts *voteCount) { + tracker.mtx.Lock() + defer tracker.mtx.Unlock() + tracker.countCache[agendaID] = counts +} + +// Once all resources have been retrieved from dcrd, update VoteTracker fields. +func (tracker *VoteTracker) update(voteInfo *chainjson.GetVoteInfoResult, blocks []int32, + stakeInfo *chainjson.GetStakeVersionInfoResult, stakeVersion uint32) { + // Check if voteCounts are needed + for idx := range voteInfo.Agendas { + agenda := &voteInfo.Agendas[idx] + if agenda.Status != statusDefined && agenda.Status != statusStarted { + // check the cache + counts := tracker.cachedCounts(agenda.ID) + if counts == nil { + counts = new(voteCount) + var err error + counts.yes, counts.abstain, counts.no, err = tracker.voteCounter(agenda.ID) + if err != nil { + log.Errorf("Error counting votes for %s: %v", agenda.ID, err) + continue + } + tracker.cacheVoteCounts(agenda.ID, counts) + } + for idx := range agenda.Choices { + choice := &agenda.Choices[idx] + if choice.ID == choiceYes { + choice.Count = counts.yes + } else if choice.ID == choiceNo { + choice.Count = counts.no + } else { + choice.Count = counts.abstain + } + } + } + } + tracker.mtx.Lock() + defer tracker.mtx.Unlock() + tracker.voteInfo = voteInfo + tracker.stakeInfo = stakeInfo + ringLen := int(tracker.params.BlockUpgradeNumToCheck) + for idx := range blocks { + tracker.ringIndex = (tracker.ringIndex + 1) % ringLen + tracker.blockRing[tracker.ringIndex] = blocks[idx] + } + tracker.blockVersion = tracker.blockRing[tracker.ringIndex] + tracker.stakeVersion = stakeVersion + tracker.ringHeight = voteInfo.CurrentHeight + tracker.summary = tracker.newVoteSummary() +} + +// Create a new VoteSummary from the currently saved info. +func (tracker *VoteTracker) newVoteSummary() *VoteSummary { + summary := &VoteSummary{ + Version: tracker.version, + Height: tracker.voteInfo.CurrentHeight, + Hash: tracker.voteInfo.Hash, + VoteVersion: tracker.version, + MinerThreshold: tracker.minerThreshold, + VoterThreshold: tracker.voterThreshold, + RCIBlocks: tracker.rciBlocks, + SVIBlocks: tracker.sviBlocks, + NextRCIHeight: uint32(tracker.voteInfo.EndHeight + 1), + RCIMined: uint32(tracker.voteInfo.CurrentHeight - tracker.voteInfo.StartHeight + 1), + } + summary.Agendas = make([]AgendaSummary, len(tracker.voteInfo.Agendas)) + summary.RCIProgress = float32(summary.RCIMined) / float32(summary.RCIBlocks) + + // Count the miners in the rolling window. + currentBlockVersion := int32(tracker.version) + for _, blockVersion := range tracker.blockRing { + if blockVersion == currentBlockVersion { + summary.NewMiners++ + } else { + summary.OldMiners++ + } + } + + summary.MinerCount = summary.NewMiners + summary.OldMiners + summary.MinerProgress = float32(summary.NewMiners) / float32(summary.MinerCount) + summary.NetworkUpgraded = summary.MinerProgress >= summary.MinerThreshold && tracker.stakeVersion == tracker.version + + for idx := range tracker.voteInfo.Agendas { + agenda := &tracker.voteInfo.Agendas[idx] + agendaSummary := AgendaSummary{ + Description: agenda.Description, + ID: agenda.ID, + Quorum: tracker.params.RuleChangeActivationQuorum, + PassThreshold: tracker.passThreshold, + // LockCount: tracker.lockCount, + } + status := agenda.Status + agendaSummary.IsLocked = status == statusLocked + agendaSummary.IsFailed = status == statusFailed + agendaSummary.IsActive = status == statusActive + agendaSummary.IsVoting = status == statusStarted + agendaSummary.IsDefined = status == statusDefined + for idy := range agenda.Choices { + choice := &agenda.Choices[idy] + if choice.IsNo { + agendaSummary.Nay = choice.Count + } else if choice.IsAbstain { + agendaSummary.Abstain = choice.Count + } else { + agendaSummary.Aye = choice.Count + } + } + agendaSummary.VoteCount = agendaSummary.Aye + agendaSummary.Nay + if agendaSummary.VoteCount > 0 { + agendaSummary.Approval = float32(agendaSummary.Aye) / float32(agendaSummary.VoteCount) + } + if agendaSummary.VoteCount+agendaSummary.Abstain > 0 { + agendaSummary.AbstainRate = float32(agendaSummary.Abstain) / float32(agendaSummary.VoteCount+agendaSummary.Abstain) + } + agendaSummary.QuorumProgress = float32(agendaSummary.VoteCount) / float32(agendaSummary.Quorum) + agendaSummary.FailThreshold = 1 - agendaSummary.PassThreshold + + // Get the number of votes that locks in approval, considering missed votes. + missedVotes := summary.RCIMined*uint32(tracker.params.TicketsPerBlock) - agendaSummary.VoteCount - agendaSummary.Abstain + agendaSummary.LockCount = uint32(float32(tracker.rciVotes-missedVotes) * tracker.passThreshold) + + agendaSummary.QuorumAchieved = agendaSummary.VoteCount > agendaSummary.Quorum + if agendaSummary.QuorumProgress >= 1 { + agendaSummary.QuorumProgress = 1 + agendaSummary.QuorumAchieved = true + } + if agendaSummary.Aye >= agendaSummary.LockCount { + agendaSummary.IsLocked = true + } + if agendaSummary.Approval >= agendaSummary.PassThreshold { + agendaSummary.IsWinning = true + } + if agendaSummary.Approval < agendaSummary.FailThreshold { + agendaSummary.IsLosing = true + } + if agendaSummary.IsDefined && summary.NetworkUpgraded { + agendaSummary.VotingTriggered = true + summary.VotingTriggered = true + } + + summary.Agendas[idx] = agendaSummary + } + var sviMined uint32 + for idx := range tracker.stakeInfo.Intervals { + interval := &tracker.stakeInfo.Intervals[idx] + var newVoters, oldVoters uint32 + for idy := range interval.VoteVersions { + version := &interval.VoteVersions[idy] + if version.Version == tracker.version { + newVoters = version.Count + } else { + oldVoters += version.Count + } + } + if idx == 0 { + sviMined = uint32(summary.Height - interval.StartHeight + 1) + summary.NewVoters = newVoters + summary.OldVoters = oldVoters + } + } + summary.VoterCount = summary.OldVoters + summary.NewVoters + summary.VoterProgress = float32(summary.NewVoters) / float32(summary.VoterCount) + + summary.SVIMined = sviMined + summary.SVIProgress = float32(summary.SVIMined) / float32(summary.SVIBlocks) + + summary.TilNextRCI = int64(summary.RCIBlocks-summary.RCIMined) * tracker.blockTime + + return summary +} + +// Summary is a getter for the cached VoteSummary. The summary returned will +// never be modified by VoteTracker, so can be used read-only by any number +// of threads. +func (tracker *VoteTracker) Summary() *VoteSummary { + tracker.mtx.RLock() + defer tracker.mtx.RUnlock() + return tracker.summary +} + +// for testing +func spoof(summary *VoteSummary) { + log.Infof("Spoofing vote data for testing. Don't forget to remove this call.") + // summary.NetworkUpgraded = false + // summary.VoterProgress = 0.57 + // summary.MinerProgress = 0.86 + agenda := &summary.Agendas[0] + summary.VotingTriggered = false + agenda.VotingTriggered = false + agenda.IsVoting = true + agenda.IsDefined = false + + agenda.Aye = agenda.Quorum * 9 + agenda.Nay = agenda.Aye / 7 + agenda.Abstain = agenda.Nay + + // agenda.Aye = 0 + // agenda.Nay = 0 + // agenda.Abstain = 0 + + agenda.VoteCount = agenda.Aye + agenda.Nay + agenda.AbstainRate = float32(agenda.Abstain) / float32(agenda.VoteCount) + agenda.QuorumProgress = 1 + agenda.QuorumAchieved = true + agenda.Approval = float32(agenda.Aye) / float32(agenda.VoteCount) + agenda.IsWinning = true + agenda.IsLosing = false + agenda.IsLocked = false + agenda.IsActive = false + agenda.IsFailed = false +} diff --git a/gov/agendas/tracker_test.go b/gov/agendas/tracker_test.go new file mode 100644 index 0000000..6b82ac1 --- /dev/null +++ b/gov/agendas/tracker_test.go @@ -0,0 +1,166 @@ +package agendas + +import ( + "fmt" + "strconv" + "testing" + + "github.com/decred/dcrd/chaincfg/v2" + "github.com/decred/dcrd/dcrjson/v3" + chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v2" +) + +type dataSourceStub struct{} + +func (source dataSourceStub) GetStakeVersionInfo(version int32) (*chainjson.GetStakeVersionInfoResult, error) { + if version > 6 { + return nil, fmt.Errorf(" ") + } + h := int64(version * 50000) + return &chainjson.GetStakeVersionInfoResult{ + CurrentHeight: h, + Hash: strconv.Itoa(int(version)), + Intervals: []chainjson.VersionInterval{ + { + StartHeight: h - 500, + EndHeight: h + 500, + PoSVersions: []chainjson.VersionCount{ + { + Version: uint32(version), + Count: 5, + }, + { + Version: uint32(version), + Count: 100000, + }, + }, + VoteVersions: []chainjson.VersionCount{ + { + Version: uint32(version), + Count: 5, + }, + { + Version: uint32(version), + Count: 100000, + }, + }, + }, + { + StartHeight: h - 1500, + EndHeight: h - 501, + PoSVersions: []chainjson.VersionCount{ + { + Version: uint32(version), + Count: 5, + }, + { + Version: uint32(version), + Count: 100000, + }, + }, + VoteVersions: []chainjson.VersionCount{ + { + Version: uint32(version), + Count: 5, + }, + { + Version: uint32(version), + Count: 100000, + }, + }, + }, + }, + }, nil +} + +func (source dataSourceStub) GetVoteInfo(version uint32) (*chainjson.GetVoteInfoResult, error) { + if version > 6 { + msg := fmt.Sprintf("stake version %d does not exist", version) + return nil, dcrjson.NewRPCError(dcrjson.ErrRPCInvalidParameter, msg) + } + h := int64(version * 50000) + return &chainjson.GetVoteInfoResult{ + CurrentHeight: h, + StartHeight: h - 1500, + EndHeight: h + 500, + Hash: strconv.Itoa(int(version)), + VoteVersion: version, + Quorum: 4032, + TotalVotes: 10000, + Agendas: []chainjson.Agenda{ + { + ID: "test agenda", + Description: "agenda for testing", + Mask: 6, + StartTime: 5, + ExpireTime: 10, + Status: "failed", + QuorumProgress: 0, + Choices: []chainjson.Choice{ + { + ID: "abstain", + Description: "abstain voting for change", + Bits: 0, + IsAbstain: true, + IsNo: false, + Count: 0, + Progress: 0, + }, + { + ID: "no", + Description: "keep the existing consensus rules", + Bits: 2, + IsAbstain: false, + IsNo: true, + Count: 0, + Progress: 0, + }, + { + ID: "yes", + Description: "change to the new consensus rules", + Bits: 4, + IsAbstain: false, + IsNo: false, + Count: 0, + Progress: 0, + }, + }, + }, + }, + }, nil +} + +func (source dataSourceStub) GetStakeVersions(hash string, count int32) (*chainjson.GetStakeVersionsResult, error) { + h, _ := strconv.Atoi(hash) + result := &chainjson.GetStakeVersionsResult{ + StakeVersions: make([]chainjson.StakeVersions, int(count)), + } + c := int(count) + for i := 0; i < c; i++ { + result.StakeVersions[i] = chainjson.StakeVersions{ + Hash: strconv.Itoa(h), + Height: int64(h), + BlockVersion: 6, + StakeVersion: 6, + Votes: []chainjson.VersionBits{}, // VoteTracker does not use this + } + h-- + } + return result, nil +} + +func counter(hash string) (uint32, uint32, uint32, error) { + return 1, 2, 3, nil +} + +func TestVoteTracker(t *testing.T) { + tracker, err := NewVoteTracker(chaincfg.MainNetParams(), dataSourceStub{}, counter) + if err != nil { + t.Fatalf("NewVoteTracker error: %v", err) + } + + summary := tracker.Summary() + if summary == nil { + t.Errorf("nil VoteSummary error") + } +} diff --git a/gov/agendas/types/types.go b/gov/agendas/types/types.go new file mode 100644 index 0000000..00f5bb6 --- /dev/null +++ b/gov/agendas/types/types.go @@ -0,0 +1,91 @@ +package types + +import ( + "encoding/json" + "strings" +) + +// AgendaStatusType defines the various agenda statuses. +type AgendaStatusType int8 + +const ( + // InitialAgendaStatus is the agenda status when the agenda is not yet up for + // voting and the votes tally is not also available. + InitialAgendaStatus AgendaStatusType = iota + + // StartedAgendaStatus is the agenda status when the agenda is up for voting. + StartedAgendaStatus + + // FailedAgendaStatus is the agenda status set when the votes tally does not + // attain the minimum threshold set. Activation height is not set for such an + // agenda. + FailedAgendaStatus + + // LockedInAgendaStatus is the agenda status when the agenda is considered to + // have passed after attaining the minimum set threshold. This agenda will + // have its activation height set. + LockedInAgendaStatus + + // ActivatedAgendaStatus is the agenda status chaincfg.Params.RuleChangeActivationInterval + // blocks (e.g. 8064 blocks = 2016 * 4 for 4 weeks on mainnet) after + // LockedInAgendaStatus ("lockedin") that indicates when the rule change is to + // be effected. https://docs.decred.org/glossary/#rule-change-interval-rci. + ActivatedAgendaStatus + + // UnknownStatus is used when a status string is not recognized. + UnknownStatus +) + +func (a AgendaStatusType) String() string { + switch a { + case InitialAgendaStatus: + return "upcoming" + case StartedAgendaStatus: + return "in progress" + case LockedInAgendaStatus: + return "locked in" + case FailedAgendaStatus: + return "failed" + case ActivatedAgendaStatus: + return "finished" + default: + return "unknown" + } +} + +// Ensure at compile time that AgendaStatusType satisfies interface json.Marshaller. +var _ json.Marshaler = (*AgendaStatusType)(nil) + +// MarshalJSON is AgendaStatusType default marshaller. +func (a AgendaStatusType) MarshalJSON() ([]byte, error) { + return json.Marshal(a.String()) +} + +// UnmarshalJSON is the default unmarshaller for AgendaStatusType. +func (a *AgendaStatusType) UnmarshalJSON(b []byte) error { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + *a = AgendaStatusFromStr(str) + return nil +} + +// AgendaStatusFromStr creates an agenda status from a string. If "UnknownStatus" +// is returned then an invalid status string has been passed. +func AgendaStatusFromStr(status string) AgendaStatusType { + switch strings.ToLower(status) { + case "defined", "upcoming": + return InitialAgendaStatus + case "started", "in progress": + return StartedAgendaStatus + case "failed": + return FailedAgendaStatus + case "lockedin", "locked in": + return LockedInAgendaStatus + case "active", "finished": + return ActivatedAgendaStatus + default: + return UnknownStatus + } +} diff --git a/postgres/agendas.go b/postgres/agendas.go new file mode 100644 index 0000000..4ca26b9 --- /dev/null +++ b/postgres/agendas.go @@ -0,0 +1,127 @@ +package postgres + +import "github.com/planetdecred/pdanalytics/dbhelper" + +const ( + // agendas table + + CreateAgendasTable = `CREATE TABLE IF NOT EXISTS agendas ( + id SERIAL PRIMARY KEY, + name TEXT, + status INT2, + locked_in INT4, + activated INT4, + hard_forked INT4 + );` + + // index + IndexOfAgendasTableOnName = "uix_agendas_name" + + // Insert + insertAgendaRow = `INSERT INTO agendas (name, status, locked_in, activated, + hard_forked) VALUES ($1, $2, $3, $4, $5) ` + + InsertAgendaRow = insertAgendaRow + `RETURNING id;` + + UpsertAgendaRow = insertAgendaRow + `ON CONFLICT (name) DO UPDATE + SET status = $2, locked_in = $3, activated = $4, hard_forked = $5 RETURNING id;` + + IndexAgendasTableOnAgendaID = `CREATE UNIQUE INDEX ` + IndexOfAgendasTableOnName + + ` ON agendas(name);` + DeindexAgendasTableOnAgendaID = `DROP INDEX ` + IndexOfAgendasTableOnName + ` CASCADE;` + + SelectAllAgendas = `SELECT id, name, status, locked_in, activated, hard_forked + FROM agendas;` + + SelectAgendasLockedIn = `SELECT locked_in FROM agendas WHERE name = $1;` + + SelectAgendasHardForked = `SELECT hard_forked FROM agendas WHERE name = $1;` + + SelectAgendasActivated = `SELECT activated FROM agendas WHERE name = $1;` + + SetVoteMileStoneheights = `UPDATE agendas SET status = $2, locked_in = $3, + activated = $4, hard_forked = $5 WHERE id = $1;` + + // DeleteAgendasDuplicateRows removes rows that would violate the unique + // index uix_agendas_name. This should be run prior to creating the index. + DeleteAgendasDuplicateRows = `DELETE FROM agendas + WHERE id IN (SELECT id FROM ( + SELECT id, ROW_NUMBER() + OVER (partition BY name ORDER BY id) AS rnum + FROM agendas) t + WHERE t.rnum > 1);` + + // agendas votes table + + CreateAgendaVotesTable = `CREATE TABLE IF NOT EXISTS agenda_votes ( + id SERIAL PRIMARY KEY, + votes_row_id INT8, + agendas_row_id INT8, + agenda_vote_choice INT2 + );` + + // index + IndexOfAgendaVotesTableOnRowIDs = "uix_agenda_votes" + + // Insert + insertAgendaVotesRow = `INSERT INTO agenda_votes (votes_row_id, agendas_row_id, + agenda_vote_choice) VALUES ($1, $2, $3) ` + + InsertAgendaVotesRow = insertAgendaVotesRow + `RETURNING id;` + + UpsertAgendaVotesRow = insertAgendaVotesRow + `ON CONFLICT (agendas_row_id, + votes_row_id) DO UPDATE SET agenda_vote_choice = $3 RETURNING id;` + + IndexAgendaVotesTableOnAgendaID = `CREATE UNIQUE INDEX ` + IndexOfAgendaVotesTableOnRowIDs + + ` ON agenda_votes(votes_row_id, agendas_row_id);` + DeindexAgendaVotesTableOnAgendaID = `DROP INDEX ` + IndexOfAgendaVotesTableOnRowIDs + ` CASCADE;` + + // DeleteAgendaVotesDuplicateRows removes rows that would violate the unique + // index uix_agenda_votes. This should be run prior to creating the index. + DeleteAgendaVotesDuplicateRows = `DELETE FROM agenda_votes + WHERE id IN (SELECT id FROM ( + SELECT id, ROW_NUMBER() + OVER (partition BY votes_row_id, agendas_row_id ORDER BY id) AS rnum + FROM agenda_votes) t + WHERE t.rnum > 1);` + + // Select + + SelectAgendasVotesByTime = `SELECT votes.block_time AS timestamp,` + + selectAgendaVotesQuery + `GROUP BY timestamp ORDER BY timestamp;` + + SelectAgendasVotesByHeight = `SELECT votes.height AS height,` + + selectAgendaVotesQuery + `GROUP BY height ORDER BY height;` + + SelectAgendaVoteTotals = `SELECT ` + selectAgendaVotesQuery + `;` + + selectAgendaVotesQuery = ` + count(CASE WHEN agenda_votes.agenda_vote_choice = $1 THEN 1 ELSE NULL END) AS yes, + count(CASE WHEN agenda_votes.agenda_vote_choice = $2 THEN 1 ELSE NULL END) AS abstain, + count(CASE WHEN agenda_votes.agenda_vote_choice = $3 THEN 1 ELSE NULL END) AS no, + count(*) AS total + FROM agenda_votes + INNER JOIN votes ON agenda_votes.votes_row_id = votes.id + WHERE agenda_votes.agendas_row_id = (SELECT id from agendas WHERE name = $4) + AND votes.height >= $5 AND votes.height <= $6 + AND votes.is_mainchain = TRUE ` +) + +var agendasVotesSummaries = map[string]*dbhelper.AgendaSummary{ + "treasury": { + Yes: 20133, + No: 4260, + Abstain: 30, + VotingStarted: 1596240000, + LockedIn: 544383, + }, + "treasurwy": { + Yes: 20133, + No: 4260, + Abstain: 30, + }, +} + +func (pg *PgDb) AgendasVotesSummary(agendaID string) (summary *dbhelper.AgendaSummary, err error) { + return nil, nil +} diff --git a/postgres/setup.go b/postgres/setup.go index 1380175..ef49b47 100644 --- a/postgres/setup.go +++ b/postgres/setup.go @@ -158,6 +158,8 @@ var ( "vote_receive_time_deviation": createVoteReceiveTimeDeviationTableScript, "proposals": createProposalTableScript, "proposal_votes": createProposalVotesTableScript, + "agendas": CreateAgendasTable, + "agenda_votes": CreateAgendaVotesTable, } tableOrder = []string{ @@ -176,6 +178,8 @@ var ( "vote_receive_time_deviation", "proposals", "proposal_votes", + "agendas", + "agenda_votes", } // createIndexScripts is a map of table name to a collection of index on the table @@ -186,6 +190,12 @@ var ( "proposal_votes": { IndexProposalVotesTableOnProposalsID, }, + "agendas": { + IndexAgendasTableOnAgendaID, + }, + "agenda_votes": { + IndexAgendaVotesTableOnAgendaID, + }, } ) diff --git a/web/templates.go b/web/templates.go index b284e73..0611194 100644 --- a/web/templates.go +++ b/web/templates.go @@ -430,6 +430,8 @@ func MakeTemplateFuncMap(params *chaincfg.Params) template.FuncMap { return dateTime.Format("2006-01-02 15:04:05") }, "floor": math.Floor, + "toLowerCase": strings.ToLower, + "toTitleCase": strings.Title, } }