diff --git a/.gitignore b/.gitignore index de71701..54bfd87 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,13 @@ ./cmd/vspd/vspd.exe ./cmd/vspd/vspd +./vote-validator.exe +./vote-validator +./cmd/vote-validator/vote-validator.exe +./cmd/vote-validator/vote-validator + +vote-validator.log + # Testing, profiling, and benchmarking artifacts cov.out *cpu.out diff --git a/cmd/vote-validator/README.md b/cmd/vote-validator/README.md new file mode 100644 index 0000000..097a358 --- /dev/null +++ b/cmd/vote-validator/README.md @@ -0,0 +1,23 @@ +# vote-validator + +vote-validator is a tool for VSP admins to verify that their vspd deployment +is voting correctly according to user preferences. + +## What it does + +1. Retrieve all voted tickets from the provided vspd database file. +1. Retrieve vote info from dcrdata for every voted ticket. +1. For the n most recently voted tickets, compare the vote choices recorded + on-chain to the vote choices set by the user. +1. Write details of any discrepancies to a file for further investigation. + +## How to run it + +Only run vote-validator using a copy of the vspd database backup file. +Never use a real production database. + +vote-validator can be run from the repository root as such: + +```no-highlight +go run ./cmd/vote-validator -n 1000 -f ./vspd.db-backup +``` diff --git a/cmd/vote-validator/dcrdata.go b/cmd/vote-validator/dcrdata.go new file mode 100644 index 0000000..7782626 --- /dev/null +++ b/cmd/vote-validator/dcrdata.go @@ -0,0 +1,87 @@ +// Copyright (c) 2022 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/decred/dcrd/rpc/jsonrpc/types/v3" +) + +type txns struct { + Transactions []string `json:"transactions"` +} + +type txInputID struct { + Hash string `json:"hash"` + Index uint32 `json:"vin_index"` +} + +type vout struct { + Value float64 `json:"value"` + N uint32 `json:"n"` + Version uint16 `json:"version"` + ScriptPubKeyDecoded types.ScriptPubKeyResult `json:"scriptPubKey"` + Spend *txInputID `json:"spend"` +} + +type tx struct { + TxID string `json:"txid"` + Size int32 `json:"size"` + Version int32 `json:"version"` + Locktime uint32 `json:"locktime"` + Expiry uint32 `json:"expiry"` + Vin []types.Vin `json:"vin"` + Vout []vout `json:"vout"` + Confirmations int64 `json:"confirmations"` +} + +type dcrdataClient struct { + URL string +} + +func (d *dcrdataClient) txns(txnHashes []string, spends bool) ([]tx, error) { + jsonData, err := json.Marshal(txns{ + Transactions: txnHashes, + }) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/api/txs?spends=%t", d.URL, spends) + request, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json; charset=UTF-8") + + client := &http.Client{} + resp, err := client.Do(request) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("dcrdata response: %v", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var txns []tx + err = json.Unmarshal(body, &txns) + if err != nil { + return nil, err + } + + return txns, nil +} diff --git a/cmd/vote-validator/main.go b/cmd/vote-validator/main.go new file mode 100644 index 0000000..4e80b02 --- /dev/null +++ b/cmd/vote-validator/main.go @@ -0,0 +1,250 @@ +// Copyright (c) 2022 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package main + +import ( + "encoding/binary" + "encoding/hex" + "errors" + "os" + "sort" + + "github.com/decred/dcrd/chaincfg/v3" + "github.com/decred/slog" + "github.com/jessevdk/go-flags" + + "github.com/decred/vspd/database" +) + +const ( + logPath = "./vote-validator.log" + + // Send tx hashes to dcrdata in "chunks" to avoid hitting request size limits. + chunkSize = 2000 +) + +var cfg struct { + Testnet bool `short:"t" long:"testnet" description:"Run testnet instead of mainnet"` + ToCheck int `short:"n" long:"tickets_to_check" required:"true" description:"Validate votes of the n most recently voted tickets"` + DatabaseFile string `short:"f" long:"database_file" required:"true" description:"Full path of database file"` +} + +type votedTicket struct { + // From vspd db. + ticket database.Ticket + // From dcrdata. + voteHeight uint32 + voteVersion uint32 + vote map[string]string +} + +func main() { + // Run until an exit code is returned. + os.Exit(run()) +} + +func run() int { + // Load config, display help if requested. + _, err := flags.Parse(&cfg) + if err != nil { + var e *flags.Error + if errors.As(err, &e) { + if e.Type == flags.ErrHelp { + return 0 + } + } + return 1 + } + + var dcrdata *dcrdataClient + var params *chaincfg.Params + if cfg.Testnet { + dcrdata = &dcrdataClient{URL: "https://testnet.dcrdata.org"} + params = chaincfg.TestNet3Params() + } else { + dcrdata = &dcrdataClient{URL: "https://explorer.dcrdata.org"} + params = chaincfg.MainNetParams() + } + + // Get the latest vote version. Any votes which don't match this version + // will be ignored. + var latestVoteVersion uint32 + for version := range params.Deployments { + if version > latestVoteVersion { + latestVoteVersion = version + } + } + + // Open database. + log := slog.NewBackend(os.Stdout).Logger("") + vdb, err := database.Open(cfg.DatabaseFile, log, 999) + if err != nil { + log.Error(err) + return 1 + } + + // Get all voted tickets from database. + dbTickets, err := vdb.GetVotedTickets() + if err != nil { + log.Error(err) + return 1 + } + + numTickets := len(dbTickets) + log.Infof("Database has %d voted tickets", numTickets) + + // A bit of pre-processing for later: + // - Store the tickets in a map using their hash as the key. This makes + // it easier to reference them later. + // - Create an array of all hashes. This can easily be sliced into + // "chunks" and sent to dcrdata. + + ticketMap := make(map[string]*votedTicket, numTickets) + ticketHashes := make([]string, 0) + for _, t := range dbTickets { + ticketMap[t.Hash] = &votedTicket{ticket: t} + ticketHashes = append(ticketHashes, t.Hash) + } + + // Use dcrdata to get spender info for voted tickets (dcrd can't do this). + log.Infof("Getting vote info from %s", dcrdata.URL) + for i := 0; i < numTickets; i += chunkSize { + end := i + chunkSize + if end > numTickets { + end = numTickets + } + + // Get the tx info for each ticket. + ticketTxns, err := dcrdata.txns(ticketHashes[i:end], true) + if err != nil { + log.Error(err) + return 1 + } + + spenderHashes := make([]string, 0) + mapSpenderToTicket := make(map[string]string, len(ticketTxns)) + for _, txn := range ticketTxns { + spenderHash := txn.Vout[0].Spend.Hash + spenderHashes = append(spenderHashes, spenderHash) + mapSpenderToTicket[spenderHash] = txn.TxID + } + + spenderTxns, err := dcrdata.txns(spenderHashes, false) + if err != nil { + log.Error(err) + return 1 + } + + for _, tx := range spenderTxns { + ticketHash := mapSpenderToTicket[tx.TxID] + + // Extract vote height from vOut[0] + vOut0Script, err := hex.DecodeString(tx.Vout[0].ScriptPubKeyDecoded.Hex) + if err != nil { + log.Error(err) + return 1 + } + // dcrd/blockchain/stake/staketx.go - SSGenBlockVotedOn() + votedHeight := binary.LittleEndian.Uint32(vOut0Script[34:38]) + + // Extract vote version and votebits from vOut[1] + vOut1Script, err := hex.DecodeString(tx.Vout[1].ScriptPubKeyDecoded.Hex) + if err != nil { + log.Error(err) + return 1 + } + // dcrd/blockchain/stake/staketx.go - SSGenVersion() + voteVersion := binary.LittleEndian.Uint32(vOut1Script[4:8]) + + // dcrd/blockchain/stake/staketx.go - SSGenVoteBits() + votebits := binary.LittleEndian.Uint16(vOut1Script[2:4]) + + // Get the recorded on-chain votes for this ticket. + actualVote := make(map[string]string) + agendas := params.Deployments[latestVoteVersion] + for _, agenda := range agendas { + for _, choice := range agenda.Vote.Choices { + if votebits&agenda.Vote.Mask == choice.Bits { + actualVote[agenda.Vote.Id] = choice.Id + } + } + } + + ticketMap[ticketHash].voteHeight = votedHeight + ticketMap[ticketHash].voteVersion = voteVersion + ticketMap[ticketHash].vote = actualVote + } + + log.Infof(" %6d of %d (%0.2f%%)", end, numTickets, float32(end)/float32(numTickets)*100) + } + + // Convert ticketMap into a slice so that it can be sorted. + sorted := make([]*votedTicket, 0) + for _, ticket := range ticketMap { + sorted = append(sorted, ticket) + } + + // Sort tickets by vote height + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].voteHeight > sorted[j].voteHeight + }) + + // Do the checks. + results := &results{ + badVotes: make([]*votedTicket, 0), + noPreferences: make([]*votedTicket, 0), + wrongVersion: make([]*votedTicket, 0), + } + + for _, t := range sorted[0:cfg.ToCheck] { + + if t.voteVersion != latestVoteVersion { + results.wrongVersion = append(results.wrongVersion, t) + continue + } + + // Get the vote preferences requested by the user. + requestedVote := t.ticket.VoteChoices + + if len(requestedVote) == 0 { + results.noPreferences = append(results.noPreferences, t) + } + + badVote := false + for agenda, actualChoice := range t.vote { + reqChoice, ok := requestedVote[agenda] + // If no choice set, should be abstain. + if !ok { + reqChoice = "abstain" + } + + if actualChoice != reqChoice { + badVote = true + } + } + + if badVote { + results.badVotes = append(results.badVotes, t) + } + } + + log.Infof("") + log.Infof("Checked %d most recently voted tickets", cfg.ToCheck) + log.Infof(" %6d tickets had incorrect votes", len(results.badVotes)) + log.Infof(" %6d tickets not checked due to wrong vote version", len(results.wrongVersion)) + log.Infof(" %6d tickets had no voting preferences set by user", len(results.noPreferences)) + + written, err := results.writeFile(logPath) + if err != nil { + log.Errorf("Failed to write log file: %v", err) + return 1 + } + if written { + log.Infof("") + log.Infof("Detailed information written to %s", logPath) + } + + return 0 +} diff --git a/cmd/vote-validator/results.go b/cmd/vote-validator/results.go new file mode 100644 index 0000000..c471013 --- /dev/null +++ b/cmd/vote-validator/results.go @@ -0,0 +1,79 @@ +// Copyright (c) 2022 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package main + +import ( + "fmt" + "os" +) + +type results struct { + badVotes []*votedTicket + noPreferences []*votedTicket + wrongVersion []*votedTicket +} + +func (r *results) writeFile(path string) (bool, error) { + + if len(r.badVotes) == 0 && + len(r.noPreferences) == 0 && + len(r.wrongVersion) == 0 { + return false, nil + } + + // Open a log file. + f, err := os.Create(path) + if err != nil { + return false, fmt.Errorf("opening log file failed: %w", err) + } + + write := func(f *os.File, str string, a ...any) { + _, err := f.WriteString(fmt.Sprintf(str+"\n", a...)) + if err != nil { + f.Close() + panic(fmt.Sprintf("writing to log file failed: %v", err)) + } + } + + if len(r.badVotes) > 0 { + write(f, "Tickets with bad votes:") + for _, t := range r.badVotes { + write(f, + "Hash: %s VoteHeight: %d ExpectedVote: %v ActualVote: %v", + t.ticket.Hash, t.voteHeight, t.ticket.VoteChoices, t.vote, + ) + } + write(f, "\n") + } + + if len(r.wrongVersion) > 0 { + write(f, "Tickets with the wrong vote version:") + for _, t := range r.wrongVersion { + write(f, + "Hash: %s", + t.ticket.Hash, + ) + } + write(f, "\n") + } + + if len(r.noPreferences) > 0 { + write(f, "Tickets with no user set vote preferences:") + for _, t := range r.noPreferences { + write(f, + "Hash: %s", + t.ticket.Hash, + ) + } + write(f, "\n") + } + + err = f.Close() + if err != nil { + return false, fmt.Errorf("closing log file failed: %w", err) + } + + return true, nil +} diff --git a/database/ticket.go b/database/ticket.go index e3df2d8..dd68609 100644 --- a/database/ticket.go +++ b/database/ticket.go @@ -383,6 +383,13 @@ func (vdb *VspDatabase) GetVotableTickets() ([]Ticket, error) { }) } +// GetVotedTickets returns tickets with a confirmed fee tx and outcome == voted. +func (vdb *VspDatabase) GetVotedTickets() ([]Ticket, error) { + return vdb.filterTickets(func(t *bolt.Bucket) bool { + return FeeStatus(t.Get(feeTxStatusK)) == FeeConfirmed && TicketOutcome(t.Get(outcomeK)) == Voted + }) +} + // GetMissingPurchaseHeight returns tickets which are confirmed but do not have // a purchase height. func (vdb *VspDatabase) GetMissingPurchaseHeight() ([]Ticket, error) { diff --git a/go.mod b/go.mod index 382210c..4adcf7b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/decred/vspd -go 1.17 +go 1.18 require ( decred.org/dcrwallet/v2 v2.0.1