jholdstock 2bd340ba08 multi: Explicitly handle help requests.
Checking for --help as an explicit step before parsing any other configs
makes the code more intuitive by removing a convoluted bit of error
handling, which happened to be unnecessarily duplicated in three places.

Moving it to a function in the internal package makes it reusable by
multiple binaries.

This also enables the IgnoreUnknown option to be used whilst parsing for
help, which ensures the presence of --help will always result in the
help message being printed. This fixes a minor inconsistency where the
help message would be printed if the flag was placed before an invalid
config, but placing it after would cause an invalid config error to be
written instead. For example, `vspd --help --fakeflag` vs `vspd
--fakeflag --help`.
2024-05-18 08:42:06 +01:00

263 lines
6.7 KiB
Go

// Copyright (c) 2022-2024 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 (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"os"
"sort"
"github.com/decred/slog"
"github.com/jessevdk/go-flags"
"github.com/decred/vspd/database"
"github.com/decred/vspd/internal/config"
"github.com/decred/vspd/internal/signal"
)
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 {
// If command line options are requesting help, write it to stdout and exit.
if config.WriteHelp(&cfg) {
return 0
}
// Parse command line options.
_, err := flags.Parse(&cfg)
if err != nil {
return 1
}
var network *config.Network
if cfg.Testnet {
network = &config.TestNet3
} else {
network = &config.MainNet
}
dcrdata := &dcrdataClient{URL: network.BlockExplorerURL}
// Get the latest vote version. Any votes which don't match this version
// will be ignored.
latestVoteVersion := network.CurrentVoteVersion()
// Open database.
log := slog.NewBackend(os.Stdout).Logger("")
vdb, err := database.Open(cfg.DatabaseFile, log, 999)
if err != nil {
log.Error(err)
return 1
}
ctx := signal.ShutdownListener(log)
// 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 {
// Stop if shutdown requested.
if ctx.Err() != nil {
return 0
}
end := i + chunkSize
if end > numTickets {
end = numTickets
}
// Get the tx info for each ticket.
ticketTxns, err := dcrdata.txns(ctx, ticketHashes[i:end], true)
if err != nil {
if errors.Is(err, context.Canceled) {
return 0
}
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(ctx, spenderHashes, false)
if err != nil {
if errors.Is(err, context.Canceled) {
return 0
}
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 := network.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] {
// Stop if shutdown requested.
if ctx.Err() != nil {
return 0
}
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
}