Skip to content

Commit

Permalink
blockchain: Add ticket exhaustion check.
Browse files Browse the repository at this point in the history
This adds a new function named checkTicketExhaustion which will return
a new rule error if extending a given block with another block that contains a
specified number of tickets will result in an unrecoverable chain due to
inevitable ticket exhaustion along with tests to ensure proper
functionality.

The approach taken is such that it should be easy to use in a future
consensus change gated behind a vote if that is ultimately desired,
which I personally think would be a good idea.

An exported variant is also provided that takes the hash of the block to
extend for use by external callers such as the mining template code.
  • Loading branch information
davecgh committed Oct 5, 2020
1 parent bf7072f commit 4063b55
Show file tree
Hide file tree
Showing 4 changed files with 326 additions and 0 deletions.
5 changes: 5 additions & 0 deletions blockchain/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,10 @@ const (
// block is larger than the maximum allowed.
ErrTooManyTAdds

// ErrTicketExhaustion indicates extending a given block with another one
// would result in an unrecoverable chain due to ticket exhaustion.
ErrTicketExhaustion

// numErrorCodes is the maximum error code number used in tests.
numErrorCodes
)
Expand Down Expand Up @@ -767,6 +771,7 @@ var errorCodeStrings = map[ErrorCode]string{
ErrInvalidTemplateParent: "ErrInvalidTemplateParent",
ErrInvalidTAddChange: "ErrInvalidTAddChange",
ErrTooManyTAdds: "ErrTooManyTAdds",
ErrTicketExhaustion: "ErrTicketExhaustion",
}

// String returns the ErrorCode as a human-readable name.
Expand Down
1 change: 1 addition & 0 deletions blockchain/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ func TestErrorCodeStringer(t *testing.T) {
{ErrBadTSpendScriptLen, "ErrBadTSpendScriptLen"},
{ErrInvalidTAddChange, "ErrInvalidTAddChange"},
{ErrTooManyTAdds, "ErrTooManyTAdds"},
{ErrTicketExhaustion, "ErrTicketExhaustion"},
{0xffff, "Unknown ErrorCode (65535)"},
}

Expand Down
81 changes: 81 additions & 0 deletions blockchain/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3917,3 +3917,84 @@ func (b *BlockChain) CheckConnectBlockTemplate(block *dcrutil.Block) error {
// rules.
return b.checkConnectBlock(newNode, block, parent, view, nil, nil)
}

// checkTicketExhaustion ensures that extending the provided block with a block
// that contains the specified number of ticket purchases will not result in
// a chain that is unrecoverable due to inevitable ticket exhaustion. This
// scenario happens when the number of live tickets drops below the number of
// tickets that is needed to reach the next block at which any outstanding
// immature ticket purchases that would provide the necessary live tickets
// mature.
func (b *BlockChain) checkTicketExhaustion(prevNode *blockNode, ticketPurchases uint8) error {
// Nothing to do if the chain is not far enough along where ticket
// exhaustion could be an issue.
//
// Note that the +1 added to the ticket maturity is because the lottery for
// each block selects tickets from the state of the pool as of the previous
// block and the first votes are required to be included at stake validation
// height, which means the tickets need to be live as of one block prior.
//
// Another way to look at it is that matured tickets do not become eligible
// for selection until the block AFTER the maturity period.
nextHeight := prevNode.height + 1
stakeValidationHeight := b.chainParams.StakeValidationHeight
ticketMaturity := int64(b.chainParams.TicketMaturity)
if nextHeight+ticketMaturity+1 < stakeValidationHeight {
return nil
}

// Calculate the final pool size of live tickets after the ticket maturity
// period relative to the next block height.
//
// Note the the pool size in the block header indicates the pool size BEFORE
// applying the block, so this adjusts the final pool size accordingly by
// adding any tickets that matured in said previous block as well as
// subtracting tickets consumed by votes if at least at stake validation
// height. This is why both the call to sum purchased tickets and the
// number of voting blocks in the maturity period are one more than might
// otherwise be expected.
//
// In addition, adjust the pool size for all tickets that will mature during
// the ticket maturity interval, which includes any tickets purchased in the
// block that is extending the block being checked, as well as all votes
// that will consume tickets.
finalPoolSize := int64(prevNode.poolSize)
finalPoolSize += b.sumPurchasedTickets(prevNode, ticketMaturity+1)
finalPoolSize += int64(ticketPurchases)
votingBlocksInMaturityPeriod := ticketMaturity + 2
if prevNode.height < stakeValidationHeight {
nonVotingBlocks := stakeValidationHeight - prevNode.height
votingBlocksInMaturityPeriod -= nonVotingBlocks
}
votesPerBlock := int64(b.chainParams.TicketsPerBlock)
finalPoolSize -= votingBlocksInMaturityPeriod * votesPerBlock

// Ensure that the final pool of live tickets after the ticket maturity
// period will have enough tickets to prevent becoming unrecoverable.
if finalPoolSize < votesPerBlock {
purchasesNeeded := votesPerBlock - finalPoolSize
str := fmt.Sprintf("extending block %s (height %d) with a block that "+
"contains fewer than %d ticket purchase(s) would result in an "+
"unrecoverable chain due to ticket exhaustion", prevNode.hash,
prevNode.height, purchasesNeeded)
return ruleError(ErrTicketExhaustion, str)
}

return nil
}

// CheckTicketExhaustion ensures that extending the block associated with the
// provided hash with a block that contains the specified number of ticket
// purchases will not result in a chain that is unrecoverable due to inevitable
// ticket exhaustion. This scenario happens when the number of live tickets
// drops below the number of tickets that is needed to reach the next block at
// which any outstanding immature ticket purchases that would provide the
// necessary live tickets mature.
func (b *BlockChain) CheckTicketExhaustion(hash *chainhash.Hash, ticketPurchases uint8) error {
node := b.index.LookupNode(hash)
if node == nil || !b.index.NodeStatus(node).HaveData() {
return UnknownBlockError(*hash)
}

return b.checkTicketExhaustion(node, ticketPurchases)
}
239 changes: 239 additions & 0 deletions blockchain/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1029,3 +1029,242 @@ func TestCheckConnectBlockTemplate(t *testing.T) {
g.NextBlock("b4ct", outs[3], ticketOuts[3], changeNonce)
acceptedBlockTemplate()
}

// TestCheckTicketExhaustion ensures the function which checks for inevitable
// ticket exhaustion works as intended with a variety of scenarios including
// various corner cases such as before, after, and straddling stake validation
// height.
func TestCheckTicketExhaustion(t *testing.T) {
// Hardecoded values expected by the tests so they remain valid if network
// parameters change.
const (
coinbaseMaturity = 16
ticketMaturity = 16
ticketsPerBlock = 5
stakeEnabledHeight = coinbaseMaturity + ticketMaturity
stakeValidationHeight = 144
)

// Create chain params based on regnet params with the specific values
// overridden.
params := chaincfg.RegNetParams()
params.CoinbaseMaturity = coinbaseMaturity
params.TicketMaturity = ticketMaturity
params.TicketsPerBlock = ticketsPerBlock
params.StakeEnabledHeight = stakeEnabledHeight
params.StakeValidationHeight = stakeValidationHeight

// isErr is a convenience func which acts as a limited version of errors.Is
// until the package is converted to support it at which point this can be
// removed.
isErr := func(err error, target error) bool {
if (err == nil) != (target == nil) {
return false
}
return target == nil || IsErrorCode(err, target.(RuleError).ErrorCode)
}

// ticketInfo is used to control the tests by specifying the details about
// how many fake blocks to create with the specified number of tickets.
type ticketInfo struct {
numNodes uint32 // number of fake blocks to create
tickets uint8 // number of tickets to buy in each block
}

tests := []struct {
name string // test description
ticketInfo []ticketInfo // num blocks and tickets to construct
newBlockTix uint8 // num tickets in new block for check call
err error // expected error
}{{
// Reach inevitable ticket exhaustion by not including any ticket
// purchases up to and including the final possible block prior to svh
// that can to prevent it.
name: "guaranteed exhaustion prior to svh",
ticketInfo: []ticketInfo{
{126, 0}, // height: 126, 0 live, 0 immature
},
newBlockTix: 0, // extending height: 126, 0 live, 0 immature
err: ruleError(ErrTicketExhaustion, ""),
}, {
// Reach inevitable ticket exhaustion by not including any ticket
// purchases up to just before the final possible block prior to svh
// that can prevent it and only include enough tickets in that final
// block such that it is one short of the required amount.
name: "one ticket short in final possible block prior to svh",
ticketInfo: []ticketInfo{
{126, 0}, // height: 126, 0 live, 0 immature
},
newBlockTix: 4, // extending height: 126, 0 live, 4 immature
err: ruleError(ErrTicketExhaustion, ""),
}, {
// Construct chain such that there are no ticket purchases up to just
// before the final possible block prior to svh that can prevent ticket
// exhaustion and that final block contains the exact amount of ticket
// purchases required to prevent it.
name: "just enough in final possible block prior to svh",
ticketInfo: []ticketInfo{
{126, 0}, // height: 126, 0 live, 0 immature
},
newBlockTix: 5, // extending height: 126, 0 live, 5 immature
err: nil,
}, {
// Reach inevitable ticket exhaustion with one live ticket less than
// needed to prevent it at the first block which ticket exhaustion can
// happen.
name: "one ticket short with live tickets at 1st possible exhaustion",
ticketInfo: []ticketInfo{
{16, 0}, // height: 16, 0 live, 0 immature
{1, 4}, // height: 17, 0 live, 4 immature
{109, 0}, // height: 126, 4 live, 0 immature
},
newBlockTix: 0, // extending height: 126, 4 live, 0 immature
err: ruleError(ErrTicketExhaustion, ""),
}, {
name: "just enough live tickets at 1st possible exhaustion",
ticketInfo: []ticketInfo{
{16, 0}, // height: 16, 0 live, 0 immature
{1, 5}, // height: 17, 0 live, 5 immature
{109, 0}, // height: 126, 5 live, 0 immature
},
newBlockTix: 0, // extending height: 126, 5 live, 0 immature
err: nil,
}, {
// Reach inevitable ticket exhaustion in the second possible block that
// it can happen. Notice this means it consumes the exact number of
// live tickets in the first block that ticket exhaustion can happen.
name: "exhaustion at 2nd possible block, five tickets short",
ticketInfo: []ticketInfo{
{16, 0}, // height: 16, 0 live, 0 immature
{1, 5}, // height: 17, 0 live, 5 immature
{110, 0}, // height: 127, 5 live, 0 immature
},
newBlockTix: 0, // extending height: 127, 5 live, 0 immature
err: ruleError(ErrTicketExhaustion, ""),
}, {
// Reach inevitable ticket exhaustion in the second possible block that
// it can happen with one live ticket less than needed to prevent it.
name: "exhaustion at 2nd possible block, one ticket short",
ticketInfo: []ticketInfo{
{16, 0}, // height: 16, 0 live, 0 immature
{1, 9}, // height: 17, 0 live, 9 immature
{110, 0}, // height: 127, 9 live, 0 immature
},
newBlockTix: 0, // extending height: 127, 9 live, 0 immature
err: ruleError(ErrTicketExhaustion, ""),
}, {
// Construct chain to one block before svh such that there are exactly
// enough live tickets to prevent exhaustion.
name: "just enough to svh-1 with live tickets",
ticketInfo: []ticketInfo{
{36, 0}, // height: 36, 0 live, 0 immature
{4, 20}, // height: 40, 0 live, 80 immature
{1, 5}, // height: 41, 0 live, 85 immature
{101, 0}, // height: 142, 85 live, 0 immature
},
newBlockTix: 0, // extending height: 142, 85 live, 0 immature
err: nil,
}, {
// Construct chain to one block before svh such that there is a mix of
// live and immature tickets that sum to exactly enough prevent
// exhaustion.
name: "just enough to svh-1 with mix of live and immature tickets",
ticketInfo: []ticketInfo{
{36, 0}, // height: 36, 0 live, 0 immature
{3, 20}, // height: 39, 0 live, 60 immature
{1, 15}, // height: 40, 0 live, 75 immature
{101, 0}, // height: 141, 75 live, 0 immature
{1, 5}, // height: 142, 75 live, 5 immature
},
newBlockTix: 5, // extending height: 142, 75 live, 10 immature
err: nil,
}, {
// Construct chain to svh such that there are exactly enough live
// tickets to prevent exhaustion.
name: "just enough to svh with live tickets",
ticketInfo: []ticketInfo{
{32, 0}, // height: 32, 0 live, 0 immature
{4, 20}, // height: 36, 0 live, 80 immature
{1, 10}, // height: 37, 0 live, 90 immature
{106, 0}, // height: 143, 90 live, 0 immature
},
newBlockTix: 0, // extending height: 143, 90 live, 0 immature
err: nil,
}, {
// Construct chain to svh such that there are exactly enough live
// tickets just becoming mature to prevent exhaustion.
name: "just enough to svh with maturing",
ticketInfo: []ticketInfo{
{126, 0}, // height: 126, 0 live, 0 immature
{16, 5}, // height: 142, 0 live, 80 immature
{1, 5}, // height: 143, 5 live, 80 immature
},
newBlockTix: 5, // extending height: 143, 5 live, 80 immature
err: nil,
}, {
// Reach inevitable ticket exhaustion after creating a large pool of
// live tickets and allowing the live ticket pool to dwindle due to
// votes without buying more any more tickets.
name: "exhaustion due to dwindling live tickets w/o new purchases",
ticketInfo: []ticketInfo{
{126, 0}, // height: 126, 0 live, 0 immature
{25, 20}, // height: 151, 140 live, 360 immature
{75, 0}, // height: 226, 85 live, 0 immature
},
newBlockTix: 0, // extending height: 226, 85 live, 0 immature
err: ruleError(ErrTicketExhaustion, ""),
}}

for _, test := range tests {
bc := newFakeChain(params)

// immatureTickets tracks which height the purchased tickets will mature
// and thus be eligible for admission to the live ticket pool.
immatureTickets := make(map[int64]uint8)
var poolSize uint32
node := bc.bestChain.Tip()
blockTime := time.Unix(node.timestamp, 0)
for _, ticketInfo := range test.ticketInfo {
for i := uint32(0); i < ticketInfo.numNodes; i++ {
blockTime = blockTime.Add(time.Second)
node = newFakeNode(node, 1, 1, 0, blockTime)
node.poolSize = poolSize
node.freshStake = ticketInfo.tickets

// Update the pool size for the next header. Notice how tickets
// that mature for this block do not show up in the pool size
// until the next block. This is correct behavior.
poolSize += uint32(immatureTickets[node.height])
delete(immatureTickets, node.height)
if node.height >= stakeValidationHeight {
poolSize -= ticketsPerBlock
}

// Track maturity height for new ticket purchases.
maturityHeight := node.height + ticketMaturity
immatureTickets[maturityHeight] = ticketInfo.tickets

// Add the new fake node to the block index and update the chain
// to use it as the new best node.
bc.index.AddNode(node)
bc.bestChain.SetTip(node)

// Ensure the test data does not have any invalid intermediate
// states leading up to the final test condition.
parentHash := &node.parent.hash
err := bc.CheckTicketExhaustion(parentHash, ticketInfo.tickets)
if err != nil {
t.Errorf("%q: unexpected err: %v", test.name, err)
}
}
}

// Ensure the expected result is returned from ticket exhaustion check.
err := bc.CheckTicketExhaustion(&node.hash, test.newBlockTix)
if !isErr(err, test.err) {
t.Errorf("%q: mismatched err -- got %v, want %v", test.name, err,
test.err)
continue
}
}
}

0 comments on commit 4063b55

Please sign in to comment.