vote-validator is a tool for VSP admins to verify that their vspd deployment is voting correctly according to user preferences.
251 lines
6.5 KiB
Go
251 lines
6.5 KiB
Go
// 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
|
|
}
|