From a52034cda79f14d0d0cfcb28b21069ac19c8b59b Mon Sep 17 00:00:00 2001 From: jholdstock Date: Sat, 26 Aug 2023 09:42:30 +0100 Subject: [PATCH] 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. --- cmd/vspd/spentticket.go | 33 +++++++++++++++++++ cmd/vspd/vspd.go | 72 ++++++++++++++++++++++++++++++++++++++--- database/ticket.go | 21 +++++++++--- 3 files changed, 118 insertions(+), 8 deletions(-) diff --git a/cmd/vspd/spentticket.go b/cmd/vspd/spentticket.go index 5146c1b..92ecdb2 100644 --- a/cmd/vspd/spentticket.go +++ b/cmd/vspd/spentticket.go @@ -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 diff --git a/cmd/vspd/vspd.go b/cmd/vspd/vspd.go index bebd0db..dfde738 100644 --- a/cmd/vspd/vspd.go +++ b/cmd/vspd/vspd.go @@ -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) diff --git a/database/ticket.go b/database/ticket.go index 2bd1f99..da013d9 100644 --- a/database/ticket.go +++ b/database/ticket.go @@ -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) {