vspd/database/ticket.go
Jamie Holdstock a254e943f7
webapi: Add missed tickets to admin page.
A new tab on the admin page displays a list of all tickets which were
registered with the VSP but missed their votes. Clicking on the ticket
hash redirects to the Ticket Search tab with the details of the missed
ticket displayed.
2023-09-26 17:18:32 +01:00

426 lines
13 KiB
Go

// Copyright (c) 2020-2023 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.
package database
import (
"fmt"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
// FeeStatus represents the current state of a ticket fee payment.
type FeeStatus string
const (
// NoFee indicates no fee tx has been received yet.
NoFee FeeStatus = "none"
// FeeReceieved indicates fee tx has been received but not broadcast.
FeeReceieved FeeStatus = "received"
// FeeBroadcast indicates fee tx has been broadcast but not confirmed.
FeeBroadcast FeeStatus = "broadcast"
// FeeConfirmed indicates fee tx has been broadcast and confirmed.
FeeConfirmed FeeStatus = "confirmed"
// FeeError indicates fee tx could not be broadcast due to an error.
FeeError FeeStatus = "error"
)
// TicketOutcome describes the reason a ticket is no longer votable.
type TicketOutcome string
const (
// Expired indicates the ticket expired and has been revoked.
Expired TicketOutcome = "expired"
// Missed indicates the ticket was missed and has been revoked.
Missed TicketOutcome = "missed"
// Voted indicates the ticket has already voted.
Voted TicketOutcome = "voted"
// Revoked is a deprecated status which should no longer be used. It was
// used before vspd was able to distinguish between expired and missed
// tickets.
Revoked TicketOutcome = "revoked"
)
// The keys used to store ticket values in the database.
var (
hashK = []byte("Hash")
purchaseHeightK = []byte("PurchaseHeight")
commitmentAddressK = []byte("CommitmentAddress")
feeAddressIndexK = []byte("FeeAddressIndex")
feeAddressK = []byte("FeeAddress")
feeAmountK = []byte("FeeAmount")
feeExpirationK = []byte("FeeExpiration")
confirmedK = []byte("Confirmed")
votingWIFK = []byte("VotingWIF")
voteChoicesK = []byte("VoteChoices")
tSpendPolicyK = []byte("TSpendPolicy")
treasuryPolicyK = []byte("TreasuryPolicy")
feeTxHexK = []byte("FeeTxHex")
feeTxHashK = []byte("FeeTxHash")
feeTxStatusK = []byte("FeeTxStatus")
outcomeK = []byte("Outcome")
)
type Ticket struct {
Hash string
PurchaseHeight int64
CommitmentAddress string
FeeAddressIndex uint32
FeeAddress string
FeeAmount int64
FeeExpiration int64
// Confirmed will be set when the ticket has 6+ confirmations.
Confirmed bool
// VotingWIF is set in /payfee.
VotingWIF string
// VoteChoices, TSpendPolicy and TreasuryPolicy are initially set in
// /payfee, but can be updated in /setvotechoices.
VoteChoices map[string]string
TSpendPolicy map[string]string
TreasuryPolicy map[string]string
// FeeTxHex and FeeTxHash will be set when the fee tx has been received.
FeeTxHex string
FeeTxHash string
// FeeTxStatus indicates the current state of the fee transaction.
FeeTxStatus FeeStatus
// Outcome is set once a ticket is either voted or revoked. An empty outcome
// indicates that a ticket is still votable.
Outcome TicketOutcome
}
type TicketList []Ticket
// EarliestPurchaseHeight returns the lowest non-zero purchase height in the
// list of tickets. Zero will be returned if the list is empty, or if every
// ticket in the list has zero purchase height.
func (t TicketList) EarliestPurchaseHeight() int64 {
var oldestHeight int64
for _, ticket := range t {
// Skip unconfirmed tickets.
if ticket.PurchaseHeight == 0 {
continue
}
if oldestHeight == 0 || oldestHeight > ticket.PurchaseHeight {
oldestHeight = ticket.PurchaseHeight
}
}
return oldestHeight
}
func (t TicketList) SortByPurchaseHeight() {
sort.Slice(t, func(i, j int) bool {
return t[i].PurchaseHeight > t[j].PurchaseHeight
})
}
func (t *Ticket) FeeExpired() bool {
now := time.Now()
return now.After(time.Unix(t.FeeExpiration, 0))
}
// InsertNewTicket will insert the provided ticket into the database. Returns an
// error if either the ticket hash or fee address already exist.
func (vdb *VspDatabase) InsertNewTicket(ticket Ticket) error {
return vdb.db.Update(func(tx *bolt.Tx) error {
ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK)
// Create a bucket for the new ticket. Returns an error if bucket
// already exists.
newTicketBkt, err := ticketBkt.CreateBucket([]byte(ticket.Hash))
if err != nil {
return fmt.Errorf("could not create bucket for ticket: %w", err)
}
err = putTicketInBucket(newTicketBkt, ticket)
if err != nil {
return fmt.Errorf("putting ticket in bucket failed: %w", err)
}
return nil
})
}
// putTicketInBucket encodes each of the fields of the provided ticket as a byte
// array, and stores them as values within the provided db bucket.
func putTicketInBucket(bkt *bolt.Bucket, ticket Ticket) error {
var err error
if err = bkt.Put(hashK, []byte(ticket.Hash)); err != nil {
return err
}
if err = bkt.Put(commitmentAddressK, []byte(ticket.CommitmentAddress)); err != nil {
return err
}
if err = bkt.Put(feeAddressK, []byte(ticket.FeeAddress)); err != nil {
return err
}
if err = bkt.Put(votingWIFK, []byte(ticket.VotingWIF)); err != nil {
return err
}
if err = bkt.Put(feeTxHexK, []byte(ticket.FeeTxHex)); err != nil {
return err
}
if err = bkt.Put(feeTxHashK, []byte(ticket.FeeTxHash)); err != nil {
return err
}
if err = bkt.Put(feeTxStatusK, []byte(ticket.FeeTxStatus)); err != nil {
return err
}
if err = bkt.Put(outcomeK, []byte(ticket.Outcome)); err != nil {
return err
}
if err = bkt.Put(purchaseHeightK, int64ToBytes(ticket.PurchaseHeight)); err != nil {
return err
}
if err = bkt.Put(feeAddressIndexK, uint32ToBytes(ticket.FeeAddressIndex)); err != nil {
return err
}
if err = bkt.Put(feeAmountK, int64ToBytes(ticket.FeeAmount)); err != nil {
return err
}
if err = bkt.Put(feeExpirationK, int64ToBytes(ticket.FeeExpiration)); err != nil {
return err
}
if err = bkt.Put(confirmedK, boolToBytes(ticket.Confirmed)); err != nil {
return err
}
if err = bkt.Put(tSpendPolicyK, stringMapToBytes(ticket.TSpendPolicy)); err != nil {
return err
}
if err = bkt.Put(treasuryPolicyK, stringMapToBytes(ticket.TreasuryPolicy)); err != nil {
return err
}
return bkt.Put(voteChoicesK, stringMapToBytes(ticket.VoteChoices))
}
func getTicketFromBkt(bkt *bolt.Bucket) (Ticket, error) {
var ticket Ticket
ticket.Hash = string(bkt.Get(hashK))
ticket.CommitmentAddress = string(bkt.Get(commitmentAddressK))
ticket.FeeAddress = string(bkt.Get(feeAddressK))
ticket.VotingWIF = string(bkt.Get(votingWIFK))
ticket.FeeTxHex = string(bkt.Get(feeTxHexK))
ticket.FeeTxHash = string(bkt.Get(feeTxHashK))
ticket.FeeTxStatus = FeeStatus(bkt.Get(feeTxStatusK))
ticket.Outcome = TicketOutcome(bkt.Get(outcomeK))
ticket.PurchaseHeight = bytesToInt64(bkt.Get(purchaseHeightK))
ticket.FeeAddressIndex = bytesToUint32(bkt.Get(feeAddressIndexK))
ticket.FeeAmount = bytesToInt64(bkt.Get(feeAmountK))
ticket.FeeExpiration = bytesToInt64(bkt.Get(feeExpirationK))
ticket.Confirmed = bytesToBool(bkt.Get(confirmedK))
var err error
ticket.VoteChoices, err = bytesToStringMap(bkt.Get(voteChoicesK))
if err != nil {
return ticket, fmt.Errorf("unmarshal VoteChoices err: %w", err)
}
ticket.TSpendPolicy, err = bytesToStringMap(bkt.Get(tSpendPolicyK))
if err != nil {
return ticket, fmt.Errorf("unmarshal TSpendPolicy err: %w", err)
}
ticket.TreasuryPolicy, err = bytesToStringMap(bkt.Get(treasuryPolicyK))
if err != nil {
return ticket, fmt.Errorf("unmarshal TreasuryPolicy err: %w", err)
}
return ticket, nil
}
func (vdb *VspDatabase) DeleteTicket(ticket Ticket) error {
return vdb.db.Update(func(tx *bolt.Tx) error {
ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK)
err := ticketBkt.DeleteBucket([]byte(ticket.Hash))
if err != nil {
return fmt.Errorf("could not delete ticket: %w", err)
}
return nil
})
}
func (vdb *VspDatabase) UpdateTicket(ticket Ticket) error {
return vdb.db.Update(func(tx *bolt.Tx) error {
ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK)
bkt := ticketBkt.Bucket([]byte(ticket.Hash))
if bkt == nil {
return fmt.Errorf("ticket does not exist with hash %s", ticket.Hash)
}
return putTicketInBucket(bkt, ticket)
})
}
func (vdb *VspDatabase) GetTicketByHash(ticketHash string) (Ticket, bool, error) {
var ticket Ticket
var found bool
err := vdb.db.View(func(tx *bolt.Tx) error {
ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK).Bucket([]byte(ticketHash))
if ticketBkt == nil {
return nil
}
var err error
ticket, err = getTicketFromBkt(ticketBkt)
if err != nil {
return fmt.Errorf("could not get ticket: %w", err)
}
found = true
return nil
})
return ticket, found, err
}
// Size returns the current size of the database in bytes. Note that this may
// not exactly match the size of the database file stored on disk.
func (vdb *VspDatabase) Size() (uint64, error) {
var size uint64
err := vdb.db.View(func(tx *bolt.Tx) error {
size = uint64(tx.Size())
return nil
})
return size, err
}
// CountTickets returns the total number of voted, expired, missed, and
// currently voting tickets. This func iterates over every ticket so should be
// used sparingly.
func (vdb *VspDatabase) CountTickets() (int64, int64, int64, int64, error) {
var voting, voted, expired, missed int64
err := vdb.db.View(func(tx *bolt.Tx) error {
ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK)
return ticketBkt.ForEachBucket(func(k []byte) error {
tBkt := ticketBkt.Bucket(k)
if FeeStatus(tBkt.Get(feeTxStatusK)) == FeeConfirmed {
switch TicketOutcome(tBkt.Get(outcomeK)) {
case Voted:
voted++
case Expired:
expired++
case Missed:
missed++
case Revoked:
// There shouldn't be any revoked tickets in the db, they
// should have been updated to expired/missed. Give benefit
// of doubt to VSP admin and count these as expired.
expired++
default:
voting++
}
}
return nil
})
})
return voting, voted, expired, missed, err
}
// GetUnconfirmedTickets returns tickets which are not yet confirmed.
func (vdb *VspDatabase) GetUnconfirmedTickets() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return !bytesToBool(t.Get(confirmedK))
})
}
// GetPendingFees returns tickets which are confirmed and have a fee tx which is
// not yet broadcast.
func (vdb *VspDatabase) GetPendingFees() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return bytesToBool(t.Get(confirmedK)) && FeeStatus(t.Get(feeTxStatusK)) == FeeReceieved
})
}
// GetUnconfirmedFees returns tickets with a fee tx that is broadcast but not
// confirmed yet.
func (vdb *VspDatabase) GetUnconfirmedFees() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return FeeStatus(t.Get(feeTxStatusK)) == FeeBroadcast
})
}
// GetVotableTickets returns tickets with a confirmed fee tx and no outcome (ie.
// not expired/voted/missed).
func (vdb *VspDatabase) GetVotableTickets() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return FeeStatus(t.Get(feeTxStatusK)) == FeeConfirmed && TicketOutcome(t.Get(outcomeK)) == ""
})
}
// GetVotedTickets returns tickets with a confirmed fee tx and outcome == voted.
func (vdb *VspDatabase) GetVotedTickets() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return FeeStatus(t.Get(feeTxStatusK)) == FeeConfirmed && TicketOutcome(t.Get(outcomeK)) == Voted
})
}
// GetRevokedTickets returns all tickets which have outcome == revoked.
func (vdb *VspDatabase) GetRevokedTickets() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return TicketOutcome(t.Get(outcomeK)) == Revoked
})
}
// GetMissingPurchaseHeight returns tickets which are confirmed but do not have
// a purchase height.
func (vdb *VspDatabase) GetMissingPurchaseHeight() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return bytesToBool(t.Get(confirmedK)) && bytesToInt64(t.Get(purchaseHeightK)) == 0
})
}
// GetMissedTickets returns all tickets which have outcome == missed.
func (vdb *VspDatabase) GetMissedTickets() (TicketList, error) {
return vdb.filterTickets(func(t *bolt.Bucket) bool {
return TicketOutcome(t.Get(outcomeK)) == Missed
})
}
// filterTickets accepts a filter function and returns all tickets from the
// database which match the filter.
func (vdb *VspDatabase) filterTickets(filter func(*bolt.Bucket) bool) (TicketList, error) {
var tickets TicketList
err := vdb.db.View(func(tx *bolt.Tx) error {
ticketBkt := tx.Bucket(vspBktK).Bucket(ticketBktK)
return ticketBkt.ForEachBucket(func(k []byte) error {
ticketBkt := ticketBkt.Bucket(k)
if filter(ticketBkt) {
ticket, err := getTicketFromBkt(ticketBkt)
if err != nil {
return fmt.Errorf("could not get ticket: %w", err)
}
tickets = append(tickets, ticket)
}
return nil
})
})
return tickets, err
}