Add vote-validator tool. (#335)
vote-validator is a tool for VSP admins to verify that their vspd deployment is voting correctly according to user preferences.
This commit is contained in:
parent
32790984fe
commit
8bb868a5a5
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||
|
||||
23
cmd/vote-validator/README.md
Normal file
23
cmd/vote-validator/README.md
Normal file
@ -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
|
||||
```
|
||||
87
cmd/vote-validator/dcrdata.go
Normal file
87
cmd/vote-validator/dcrdata.go
Normal file
@ -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
|
||||
}
|
||||
250
cmd/vote-validator/main.go
Normal file
250
cmd/vote-validator/main.go
Normal file
@ -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
|
||||
}
|
||||
79
cmd/vote-validator/results.go
Normal file
79
cmd/vote-validator/results.go
Normal file
@ -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
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user