multi: Update revoked tickets to expired/missed.

Any tickets in the database which are currently revoked should be
updated to either expired or missed.

This is achieved with a heuristic based on the expiry height and
revoke height of the tickets. It is not guaranteed to be 100% correct
but should be pretty close.

The web api is not yet updated to reflect this change, missed/expired
tickets will continue to be counted as revoked.
This commit is contained in:
jholdstock 2023-08-26 09:42:30 +01:00 committed by Jamie Holdstock
parent 2fc60321e0
commit a52034cda7
3 changed files with 118 additions and 8 deletions

View File

@ -25,6 +25,39 @@ func (s *spentTicket) voted() bool {
return stake.IsSSGen(s.spendingTx)
}
func (s *spentTicket) missed() bool {
// The following switch statement is a heuristic to estimate whether a
// ticket was missed or expired based on its revoke height. Absolute
// precision is not needed here as this status is only used to report VSP
// stats via /vspinfo, which could be forged by a malicious VSP operator
// anyway.
switch {
case s.heightSpent < s.expiryHeight:
// A ticket revoked before expiry height was definitely missed.
return true
case s.heightSpent == s.expiryHeight:
// If a ticket was revoked on exactly expiry height, assume it expired.
// This might be incorrect if DCP-0009 was not active and a missed
// ticket was coincidentally revoked on exactly the expiry height.
return false
case s.heightSpent == s.expiryHeight+1:
// Revoking after the expiry height was only possible before DCP-0009
// activated. Cannot be certain if missed or expired, but if it was
// revoked exactly in the first block an expired ticket could have
// possibly been revoked, there is a high probability the voter was
// online and didn't miss the vote, so assume expired.
return false
default:
// Revoking after the expiry height was only possible before DCP-0009
// activated. Cannot be certain if missed or expired, but if it was
// revoked later than the first block an expired ticket could have
// possibly been revoked, it is probably because the voter was offline
// and there is a much higher probability that the ticket was missed, so
// assume missed.
return true
}
}
// findSpentTickets attempts to find transactions that vote/revoke the provided
// tickets by matching the payment script of the ticket's commitment address
// against the block filters of the mainchain blocks between the provided start

View File

@ -219,7 +219,12 @@ func (v *vspd) run() int {
func (v *vspd) checkDatabaseIntegrity() error {
err := v.checkPurchaseHeights()
if err != nil {
return err
return fmt.Errorf("checkPurchaseHeights error: %w", err)
}
err = v.checkRevoked()
if err != nil {
return fmt.Errorf("checkRevoked error: %w", err)
}
return nil
@ -270,6 +275,62 @@ func (v *vspd) checkPurchaseHeights() error {
return nil
}
// checkRevoked ensures that any tickets in the database with outcome set to
// revoked are updated to either expired or missed.
func (v *vspd) checkRevoked() error {
revoked, err := v.db.GetRevokedTickets()
if err != nil {
return fmt.Errorf("db.GetRevoked error: %w", err)
}
if len(revoked) == 0 {
// Nothing to do, return.
return nil
}
v.log.Warnf("Updating %s in revoked status, this may take a while...",
pluralize(len(revoked), "ticket"))
// Search for the transactions which spend these tickets, starting at the
// earliest height one of them matured.
startHeight := revoked.EarliestPurchaseHeight() + int64(v.cfg.netParams.TicketMaturity)
spent, _, err := v.findSpentTickets(revoked, startHeight)
if err != nil {
return fmt.Errorf("findSpentTickets error: %w", err)
}
fixedMissed := 0
fixedExpired := 0
// Update database with correct voted status.
for hash, spentTicket := range spent {
switch {
case spentTicket.voted():
v.log.Errorf("Ticket voted but was recorded as revoked. Please contact "+
"developers so this can be investigated (ticketHash=%s)", hash)
continue
case spentTicket.missed():
spentTicket.dbTicket.Outcome = database.Missed
fixedMissed++
default:
spentTicket.dbTicket.Outcome = database.Expired
fixedExpired++
}
err = v.db.UpdateTicket(spentTicket.dbTicket)
if err != nil {
v.log.Errorf("Could not update status of ticket %s: %v", hash, err)
}
}
v.log.Infof("%s updated (%d missed, %d expired)",
pluralize(fixedExpired+fixedMissed, "revoked ticket"),
fixedMissed, fixedExpired)
return nil
}
// blockConnected is called once when vspd starts up, and once each time a
// blockconnected notification is received from dcrd.
func (v *vspd) blockConnected() {
@ -502,10 +563,13 @@ func (v *vspd) blockConnected() {
for _, spentTicket := range spent {
dbTicket := spentTicket.dbTicket
if spentTicket.voted() {
switch {
case spentTicket.voted():
dbTicket.Outcome = database.Voted
} else {
dbTicket.Outcome = database.Revoked
case spentTicket.missed():
dbTicket.Outcome = database.Missed
default:
dbTicket.Outcome = database.Expired
}
err = v.db.UpdateTicket(dbTicket)

View File

@ -31,11 +31,17 @@ const (
type TicketOutcome string
const (
// Revoked indicates the ticket has been revoked, either because it was
// missed or it expired.
Revoked TicketOutcome = "revoked"
// 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.
@ -319,7 +325,7 @@ func (vdb *VspDatabase) CountTickets() (int64, int64, int64, error) {
switch TicketOutcome(tBkt.Get(outcomeK)) {
case Voted:
voted++
case Revoked:
case Revoked, Expired, Missed:
revoked++
default:
voting++
@ -380,6 +386,13 @@ func (vdb *VspDatabase) GetVotedTickets() (TicketList, error) {
})
}
// 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) {