diff --git a/blockchain/error.go b/blockchain/error.go index ccede28a8f..5edce24a7d 100644 --- a/blockchain/error.go +++ b/blockchain/error.go @@ -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 ) @@ -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. diff --git a/blockchain/error_test.go b/blockchain/error_test.go index 21482a7fe7..c9b6993e51 100644 --- a/blockchain/error_test.go +++ b/blockchain/error_test.go @@ -144,6 +144,7 @@ func TestErrorCodeStringer(t *testing.T) { {ErrBadTSpendScriptLen, "ErrBadTSpendScriptLen"}, {ErrInvalidTAddChange, "ErrInvalidTAddChange"}, {ErrTooManyTAdds, "ErrTooManyTAdds"}, + {ErrTicketExhaustion, "ErrTicketExhaustion"}, {0xffff, "Unknown ErrorCode (65535)"}, } diff --git a/blockchain/validate.go b/blockchain/validate.go index 2116f8b91b..afce382605 100644 --- a/blockchain/validate.go +++ b/blockchain/validate.go @@ -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) +} diff --git a/blockchain/validate_test.go b/blockchain/validate_test.go index 8da5a15212..6f27effbcf 100644 --- a/blockchain/validate_test.go +++ b/blockchain/validate_test.go @@ -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 + } + } +}