Skip to content

Commit

Permalink
multi: Find voted/revoked tickets with GCS filters
Browse files Browse the repository at this point in the history
Use dcrd and GCS filters to find voted/revoked tickets rather than using
the dcrwallet TicketInfo RPC.

Using TicketInfo was a bit flakey because wallets do not always
correctly detect votes/revokes, and as a result VSP admins may notice
that with this change vspd detects some historic voted/revoked tickets
which TicketInfo never detected.
  • Loading branch information
jholdstock committed Aug 26, 2023
1 parent 618cfc7 commit 9be203c
Show file tree
Hide file tree
Showing 6 changed files with 365 additions and 50 deletions.
15 changes: 15 additions & 0 deletions cmd/vspd/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type netParams struct {
// deployment on this network. vspd will log an error and refuse to start if
// fewer wallets are configured.
minWallets int
// dcp0005Height is the activation height of DCP-0005 block header
// commitments agenda on this network.
dcp0005Height int64
}

var mainNetParams = netParams{
Expand All @@ -25,6 +28,9 @@ var mainNetParams = netParams{
walletRPCServerPort: "9110",
blockExplorerURL: "https://dcrdata.decred.org",
minWallets: 3,
// dcp0005Height on mainnet is block
// 000000000000000010815bed2c4dc431c34a859f4fc70774223dde788e95a01e.
dcp0005Height: 431488,
}

var testNet3Params = netParams{
Expand All @@ -33,4 +39,13 @@ var testNet3Params = netParams{
walletRPCServerPort: "19110",
blockExplorerURL: "https://testnet.dcrdata.org",
minWallets: 1,
// dcp0005Height on testnet3 is block
// 0000003e54421d585f4a609393a8694509af98f62b8449f245b09fe1389f8f77.
dcp0005Height: 323328,
}

// dcp5Active returns true if the DCP-0005 block header commitments agenda is
// active on this network at the provided height, otherwise false.
func (n *netParams) dcp5Active(height int64) bool {
return height >= n.dcp0005Height
}
174 changes: 174 additions & 0 deletions cmd/vspd/spentticket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2023 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.package main

package main

import (
"fmt"

"github.com/decred/dcrd/blockchain/stake/v5"
"github.com/decred/dcrd/chaincfg/chainhash"
"github.com/decred/dcrd/txscript/v4/stdaddr"
"github.com/decred/dcrd/wire"
"github.com/decred/vspd/database"
)

type spentTicket struct {
dbTicket database.Ticket
expiryHeight int64
heightSpent int64
spendingTx *wire.MsgTx
}

func (s *spentTicket) voted() bool {
return stake.IsSSGen(s.spendingTx)
}

// 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
// block and the current best block. Returns any found spent tickets and the
// height of the most recent scanned block.
func (v *vspd) findSpentTickets(toCheck database.TicketList, startHeight int64) ([]spentTicket, int64, error) {
params := v.cfg.netParams

dcrdClient, _, err := v.dcrd.Client()
if err != nil {
return nil, 0, err
}

endHeight, err := dcrdClient.GetBlockCount()
if err != nil {
return nil, 0, fmt.Errorf("dcrd.GetBlockCount error: %w", err)
}

if startHeight > endHeight {
return nil, 0, fmt.Errorf("start height %d greater than best block height %d",
startHeight, endHeight)
}

numBlocks := 1 + endHeight - startHeight

// Only log if checking a larger number of blocks to avoid spam.
if numBlocks > 5 {
v.log.Debugf("Scanning %d blocks for %s",
numBlocks, pluralize(len(toCheck), "spent ticket"))
}

// Get commitment address payment script for each ticket.
type ticketTuple struct {
dbTicket database.Ticket
pkScript []byte
}

tickets := make(map[chainhash.Hash]ticketTuple)
for _, ticket := range toCheck {
parsedAddr, err := stdaddr.DecodeAddress(ticket.CommitmentAddress, params)
if err != nil {
return nil, 0, err
}
_, script := parsedAddr.PaymentScript()

hash, err := chainhash.NewHashFromStr(ticket.Hash)
if err != nil {
return nil, 0, err
}

tickets[*hash] = ticketTuple{
dbTicket: ticket,
pkScript: script,
}
}

spent := make([]spentTicket, 0)

for iHeight := startHeight; iHeight <= endHeight; iHeight++ {
iHash, err := dcrdClient.GetBlockHash(iHeight)
if err != nil {
return nil, 0, err
}

iHeader, err := dcrdClient.GetBlockHeader(iHash)
if err != nil {
return nil, 0, err
}

verifyProof := v.cfg.netParams.dcp5Active(iHeight)
key, filter, err := dcrdClient.GetCFilterV2(iHeader, verifyProof)
if err != nil {
return nil, 0, err
}

var iBlock *wire.MsgBlock
outer:
for ticketHash, ticket := range tickets {
if filter.Match(key, ticket.pkScript) {
// Filter match means the ticket is likely spent in block. Get
// the full block to confirm.
if iBlock == nil {
iBlock, err = dcrdClient.GetBlock(iHash)
if err != nil {
return nil, 0, err
}
}

// The regular transaction tree does not need to be checked
// because tickets can only be spent by vote or revoke
// transactions which are always in the stake tree.
for _, blkTx := range iBlock.STransactions {
if !txSpendsTicket(blkTx, ticketHash) {
continue
}

// Confirmed - ticket is spent in block.

spent = append(spent, spentTicket{
dbTicket: ticket.dbTicket,
expiryHeight: ticket.dbTicket.PurchaseHeight + int64(params.TicketMaturity) + int64(params.TicketExpiry),
heightSpent: iHeight,
spendingTx: blkTx,
})

// Remove this ticket and continue with the next one.
delete(tickets, ticketHash)
continue outer
}

// Ticket is not spent in block.
}
}

if len(tickets) == 0 {
// Found spenders for all tickets, stop searching.
break
}
}

return spent, endHeight, nil
}

// txSpendsTicket returns true if the passed tx has an input that spends the
// specified output.
func txSpendsTicket(tx *wire.MsgTx, outputHash chainhash.Hash) bool {
for _, txIn := range tx.TxIn {
prevOut := &txIn.PreviousOutPoint
if prevOut.Index == 0 && prevOut.Hash == outputHash {
return true // Found spender.
}
}
return false
}

// pluralize suffixes the provided noun with "s" if n is not 1, then
// concatenates n and noun with a space between them. For example:
//
// (0, "biscuit") will return "0 biscuits"
// (1, "biscuit") will return "1 biscuit"
// (3, "biscuit") will return "3 biscuits"
func pluralize(n int, noun string) string {
if n != 1 {
noun += "s"
}
return fmt.Sprintf("%d %s", n, noun)
}
91 changes: 43 additions & 48 deletions cmd/vspd/vspd.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ type vspd struct {
wallets rpc.WalletConnect

blockNotifChan chan *wire.BlockHeader

// lastScannedBlock is the height of the most recent block which has been
// scanned for spent tickets.
lastScannedBlock int64
}

// newVspd creates the essential resources required by vspd - a database, logger
Expand Down Expand Up @@ -466,63 +470,54 @@ func (v *vspd) blockConnected() {

// Step 4/4: Set ticket outcome in database if any tickets are voted/revoked.

// Ticket status needs to be checked on every wallet. This is because only
// one of the voting wallets will actually succeed in voting/revoking
// tickets (the others will get errors like "tx already exists"). Only the
// successful wallet will have the most up-to-date ticket status, the others
// will be outdated.
for _, walletClient := range walletClients {
votableTickets, err := v.db.GetVotableTickets()
if err != nil {
v.log.Errorf("%s: db.GetVotableTickets failed: %v", funcName, err)
continue
}
votableTickets, err := v.db.GetVotableTickets()
if err != nil {
v.log.Errorf("%s: db.GetVotableTickets failed: %v", funcName, err)
return
}

// If the database has no votable tickets, there is nothing more to do
if len(votableTickets) == 0 {
break
}
// If the database has no votable tickets, there is nothing more to do.
if len(votableTickets) == 0 {
return
}

// Find the oldest block height from confirmed tickets.
oldestHeight := votableTickets.EarliestPurchaseHeight()
var startHeight int64
if v.lastScannedBlock == 0 {
// Use the earliest height at which a votable ticket matured if vspd has
// not performed a scan for spent tickets since it started. This will
// catch any tickets which were spent whilst vspd was offline.
startHeight = votableTickets.EarliestPurchaseHeight() + int64(v.cfg.netParams.TicketMaturity)
} else {
startHeight = v.lastScannedBlock
}

ticketInfo, err := walletClient.TicketInfo(oldestHeight)
if err != nil {
v.log.Errorf("%s: dcrwallet.TicketInfo failed (startHeight=%d, wallet=%s): %v",
funcName, oldestHeight, walletClient.String(), err)
continue
}
spent, endHeight, err := v.findSpentTickets(votableTickets, startHeight)
if err != nil {
v.log.Errorf("%s: findSpentTickets error: %v", funcName, err)
return
}

for _, dbTicket := range votableTickets {
tInfo, ok := ticketInfo[dbTicket.Hash]
if !ok {
v.log.Warnf("%s: TicketInfo response did not include expected ticket (wallet=%s, ticketHash=%s)",
funcName, walletClient.String(), dbTicket.Hash)
continue
}
v.lastScannedBlock = endHeight

switch tInfo.Status {
case "missed", "expired", "revoked":
dbTicket.Outcome = database.Revoked
case "voted":
dbTicket.Outcome = database.Voted
default:
// Skip to next ticket.
continue
}
for _, spentTicket := range spent {
dbTicket := spentTicket.dbTicket

err = v.db.UpdateTicket(dbTicket)
if err != nil {
v.log.Errorf("%s: db.UpdateTicket error, failed to set ticket outcome (ticketHash=%s): %v",
funcName, dbTicket.Hash, err)
continue
}
if spentTicket.voted() {
dbTicket.Outcome = database.Voted
} else {
dbTicket.Outcome = database.Revoked
}

v.log.Infof("%s: Ticket no longer votable: outcome=%s, ticketHash=%s", funcName,
dbTicket.Outcome, dbTicket.Hash)
err = v.db.UpdateTicket(dbTicket)
if err != nil {
v.log.Errorf("%s: db.UpdateTicket error, failed to set ticket outcome (ticketHash=%s): %v",
funcName, dbTicket.Hash, err)
continue
}
}

v.log.Infof("%s: Ticket %s %s at height %d", funcName,
dbTicket.Hash, dbTicket.Outcome, spentTicket.heightSpent)
}
}

// checkWalletConsistency will retrieve all votable tickets from the database
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/decred/dcrd/chaincfg/v3 v3.2.0
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0
github.com/decred/dcrd/dcrutil/v4 v4.0.1
github.com/decred/dcrd/gcs/v4 v4.0.0
github.com/decred/dcrd/hdkeychain/v3 v3.1.1
github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.0.0
github.com/decred/dcrd/txscript/v4 v4.1.0
Expand Down Expand Up @@ -49,10 +50,12 @@ require (
github.com/gorilla/websocket v1.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
Expand All @@ -62,7 +65,9 @@ require (
golang.org/x/sys v0.9.0 // indirect
golang.org/x/text v0.10.0 // indirect
golang.org/x/time v0.3.0
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/blake3 v1.2.1 // indirect
)
Loading

0 comments on commit 9be203c

Please sign in to comment.