Repository: decred/dcrd
Pull request #2978 (2022-08-02 11:09:15)

multi: Enforce testnet difficulty throttling.
Currently, version 3 of the test network implements a minimum difficulty reduction rule that was inherited from btcsuite which intends to act as mechanism to deal with major difficulty spikes due to ASICs which are typically not running testnet and since it's not reasonable to require high-powered hardware to keep the test network running smoothly.

Unfortunately, this existing rule is not a particularly good solution in general as it is not very deterministic and introduces additional complications around difficulty selection.  It is an even worse solution in the the case of Decred due to its hybrid model.

Rather than the aforementioned reactive approach, this introduces more deterministic proactive testnet rules in order limit the type of games that ASICs can play on testnet.

In particular, two new rules are introduced that are only imposed starting with block height 962928:

- A maximum allowed difficulty is now imposed on testnet such that CPU mining will still be feasible without resorting to any type of reactive and more complicated difficulty dropping
- Once the maximum allowed difficulty is reached on testnet, blocks must be at least 1 minute apart

The combination of these rules will prevent the difficulty on testnet from ever rising to levels that are out of reach for CPUs to continue mining blocks and throttle production in the case of higher-powered hardware such as GPUs and ASICs.

It should be noted that this solution is only suitable on a test network where no real monetary value is in play and thus the typical game theory mechanics do not apply.

Code to invalidate the existing extremely high work testnet chain which has stalled testnet after that point is added to allow the test network to be recovered without needing to fire up a bunch of ASICs.

Finally, this includes several other commits which improve the handling of mining and syncing when the new rules are enforced.



Patch:
diff --git a/chaincfg/params.go b/chaincfg/params.go
index eb297a79..0e67fca3 100644
--- a/chaincfg/params.go
+++ b/chaincfg/params.go
@@ -245,12 +245,16 @@ type Params struct {
 	// minimum required difficulty after a long enough period of time has
 	// passed without finding a block.  This is really only useful for test
 	// networks and should not be set on a main network.
+	//
+	// Deprecated: This will be removed in the next major version bump.
 	ReduceMinDifficulty bool
 
 	// MinDiffReductionTime is the amount of time after which the minimum
 	// required difficulty should be reduced when a block hasn't been found.
 	//
 	// NOTE: This only applies if ReduceMinDifficulty is true.
+	//
+	// Deprecated: This will be removed in the next major version bump.
 	MinDiffReductionTime time.Duration
 
 	// GenerateSupported specifies whether or not CPU mining is allowed.
diff --git a/internal/blockchain/chain.go b/internal/blockchain/chain.go
index edb40086..8eff651e 100644
--- a/internal/blockchain/chain.go
+++ b/internal/blockchain/chain.go
@@ -11,6 +11,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"math/big"
 	"sync"
 	"time"
 
@@ -143,6 +144,7 @@ type BlockChain struct {
 	expectedBlocksInTwoWeeks int64
 	deploymentVers           map[string]uint32
 	minKnownWork             *uint256.Uint256
+	minTestNetTarget         *big.Int
 	db                       database.DB
 	dbInfo                   *databaseInfo
 	chainParams              *chaincfg.Params
@@ -2203,6 +2205,12 @@ func (b *BlockChain) RemoveSpendEntry(hash *chainhash.Hash) error {
 	return err
 }
 
+// isTestNet3 returns whether or not the chain instance is for version 3 of the
+// test network.
+func (b *BlockChain) isTestNet3() bool {
+	return b.chainParams.Net == wire.TestNet3
+}
+
 // ChainParams returns the network parameters of the chain.
 //
 // This is part of the indexers.ChainQueryer interface.
@@ -2333,6 +2341,16 @@ func New(ctx context.Context, config *Config) (*BlockChain, error) {
 		minKnownWork = new(uint256.Uint256).SetBig(params.MinKnownChainWork)
 	}
 
+	// Impose a maximum difficulty target on the test network to prevent runaway
+	// difficulty on testnet by ASICs and GPUs since it's not reasonable to
+	// require high-powered hardware to keep the test network running smoothly.
+	var minTestNetTarget *big.Int
+	if params.Net == wire.TestNet3 {
+		// This equates to a maximum difficulty of 2^6 = 64.
+		const maxTestDiffShift = 6
+		minTestNetTarget = new(big.Int).Rsh(params.PowLimit, maxTestDiffShift)
+	}
+
 	// Either use the subsidy cache provided by the caller or create a new
 	// one when one was not provided.
 	subsidyCache := config.SubsidyCache
@@ -2359,6 +2377,7 @@ func New(ctx context.Context, config *Config) (*BlockChain, error) {
 		expectedBlocksInTwoWeeks:      expectedBlksInTwoWeeks,
 		deploymentVers:                deploymentVers,
 		minKnownWork:                  minKnownWork,
+		minTestNetTarget:              minTestNetTarget,
 		db:                            config.DB,
 		chainParams:                   params,
 		timeSource:                    config.TimeSource,
@@ -2411,6 +2430,38 @@ func New(ctx context.Context, config *Config) (*BlockChain, error) {
 	log.Infof("UTXO database version info: version: %d, compression: %d, utxo "+
 		"set: %d", utxoDbInfo.version, utxoDbInfo.compVer, utxoDbInfo.utxoVer)
 
+	// Manually invalidate any chains on version 3 of the test network that were
+	// created prior to enforcement of the maximum difficulty rules.
+	if b.isTestNet3() {
+		// Discover any existing nodes at the max diff activation height that do
+		// not have the expected hash and have not already been invalidated.
+		invalidateNodes := make([]*blockNode, 0, 1)
+		b.index.Lock()
+		b.index.forEachChainTip(func(tip *blockNode) error {
+			node := tip.Ancestor(testNet3MaxDiffActivationHeight)
+			if node != nil && node.hash != *block962928Hash &&
+				!node.status.KnownInvalid() {
+
+				invalidateNodes = append(invalidateNodes, node)
+			}
+			return nil
+		})
+		b.index.Unlock()
+
+		// Invalidate the blocks without notification callbacks as the calling
+		// code will not be fully initialized yet at this point and this is
+		// being done as a part of chain initialization.
+		curNtfnCallback := b.notifications
+		b.notifications = nil
+		for _, node := range invalidateNodes {
+			if err := b.InvalidateBlock(&node.hash); err != nil {
+				b.notifications = curNtfnCallback
+				return nil, err
+			}
+		}
+		b.notifications = curNtfnCallback
+	}
+
 	bestHdr := b.index.BestHeader()
 	log.Infof("Best known header: height %d, hash %v", bestHdr.height,
 		bestHdr.hash)
diff --git a/internal/blockchain/difficulty.go b/internal/blockchain/difficulty.go
index e61b8f26..de729200 100644
--- a/internal/blockchain/difficulty.go
+++ b/internal/blockchain/difficulty.go
@@ -49,88 +49,90 @@ func (b *BlockChain) findPrevTestNetDifficulty(startNode *blockNode) uint32 {
 
 // calcNextRequiredDifficulty calculates the required difficulty for the block
 // after the passed previous block node based on the difficulty retarget rules.
-func (b *BlockChain) calcNextRequiredDifficulty(curNode *blockNode, newBlockTime time.Time) uint32 {
+func (b *BlockChain) calcNextRequiredDifficulty(prevNode *blockNode, newBlockTime time.Time) uint32 {
 	// Get the old difficulty; if we aren't at a block height where it changes,
 	// just return this.
-	oldDiff := curNode.bits
-	oldDiffBig := standalone.CompactToBig(curNode.bits)
+	oldDiff := prevNode.bits
+	oldDiffBig := standalone.CompactToBig(prevNode.bits)
 
 	// We're not at a retarget point, return the oldDiff.
-	if (curNode.height+1)%b.chainParams.WorkDiffWindowSize != 0 {
-		// For networks that support it, allow special reduction of the
-		// required difficulty once too much time has elapsed without
-		// mining a block.
-		if b.chainParams.ReduceMinDifficulty {
+	params := b.chainParams
+	nextHeight := prevNode.height + 1
+	if nextHeight%params.WorkDiffWindowSize != 0 {
+		// For networks that support it, allow special reduction of the required
+		// difficulty once too much time has elapsed without mining a block.
+		//
+		// Note that this behavior is deprecated and thus is only supported on
+		// testnet v3 prior to the max diff activation height.  It will be
+		// removed in future version of testnet.
+		if params.ReduceMinDifficulty && (!b.isTestNet3() || nextHeight <
+			testNet3MaxDiffActivationHeight) {
+
 			// Return minimum difficulty when more than the desired
 			// amount of time has elapsed without mining a block.
-			reductionTime := int64(b.chainParams.MinDiffReductionTime /
-				time.Second)
-			allowMinTime := curNode.timestamp + reductionTime
+			reductionTime := int64(params.MinDiffReductionTime / time.Second)
+			allowMinTime := prevNode.timestamp + reductionTime
 			if newBlockTime.Unix() > allowMinTime {
-				return b.chainParams.PowLimitBits
+				return params.PowLimitBits
 			}
 
 			// The block was mined within the desired timeframe, so
 			// return the difficulty for the last block which did
 			// not have the special minimum difficulty rule applied.
-			return b.findPrevTestNetDifficulty(curNode)
+			return b.findPrevTestNetDifficulty(prevNode)
 		}
 
 		return oldDiff
 	}
 
 	// Declare some useful variables.
-	RAFBig := big.NewInt(b.chainParams.RetargetAdjustmentFactor)
-	nextDiffBigMin := standalone.CompactToBig(curNode.bits)
+	RAFBig := big.NewInt(params.RetargetAdjustmentFactor)
+	nextDiffBigMin := standalone.CompactToBig(prevNode.bits)
 	nextDiffBigMin.Div(nextDiffBigMin, RAFBig)
-	nextDiffBigMax := standalone.CompactToBig(curNode.bits)
+	nextDiffBigMax := standalone.CompactToBig(prevNode.bits)
 	nextDiffBigMax.Mul(nextDiffBigMax, RAFBig)
 
-	alpha := b.chainParams.WorkDiffAlpha
+	alpha := params.WorkDiffAlpha
 
 	// Number of nodes to traverse while calculating difficulty.
-	nodesToTraverse := (b.chainParams.WorkDiffWindowSize *
-		b.chainParams.WorkDiffWindows)
+	nodesToTraverse := (params.WorkDiffWindowSize * params.WorkDiffWindows)
 
 	// Initialize bigInt slice for the percentage changes for each window period
 	// above or below the target.
-	windowChanges := make([]*big.Int, b.chainParams.WorkDiffWindows)
+	windowChanges := make([]*big.Int, params.WorkDiffWindows)
 
 	// Regress through all of the previous blocks and store the percent changes
 	// per window period; use bigInts to emulate 64.32 bit fixed point.
 	var olderTime, windowPeriod int64
 	var weights uint64
-	oldNode := curNode
-	recentTime := curNode.timestamp
+	oldNode := prevNode
+	recentTime := prevNode.timestamp
 
 	for i := int64(0); ; i++ {
 		// Store and reset after reaching the end of every window period.
-		if i%b.chainParams.WorkDiffWindowSize == 0 && i != 0 {
+		if i%params.WorkDiffWindowSize == 0 && i != 0 {
 			olderTime = oldNode.timestamp
 			timeDifference := recentTime - olderTime
 
 			// Just assume we're at the target (no change) if we've
 			// gone all the way back to the genesis block.
 			if oldNode.height == 0 {
-				timeDifference = int64(b.chainParams.TargetTimespan /
-					time.Second)
+				timeDifference = int64(params.TargetTimespan / time.Second)
 			}
 
 			timeDifBig := big.NewInt(timeDifference)
 			timeDifBig.Lsh(timeDifBig, 32) // Add padding
-			targetTemp := big.NewInt(int64(b.chainParams.TargetTimespan /
-				time.Second))
+			targetTemp := big.NewInt(int64(params.TargetTimespan / time.Second))
 
 			windowAdjusted := targetTemp.Div(timeDifBig, targetTemp)
 
 			// Weight it exponentially. Be aware that this could at some point
 			// overflow if alpha or the number of blocks used is really large.
 			windowAdjusted = windowAdjusted.Lsh(windowAdjusted,
-				uint((b.chainParams.WorkDiffWindows-windowPeriod)*alpha))
+				uint((params.WorkDiffWindows-windowPeriod)*alpha))
 
 			// Sum up all the different weights incrementally.
-			weights += 1 << uint64((b.chainParams.WorkDiffWindows-windowPeriod)*
-				alpha)
+			weights += 1 << uint64((params.WorkDiffWindows-windowPeriod)*alpha)
 
 			// Store it in the slice.
 			windowChanges[windowPeriod] = windowAdjusted
@@ -153,7 +155,7 @@ func (b *BlockChain) calcNextRequiredDifficulty(curNode *blockNode, newBlockTime
 
 	// Sum up the weighted window periods.
 	weightedSum := big.NewInt(0)
-	for i := int64(0); i < b.chainParams.WorkDiffWindows; i++ {
+	for i := int64(0); i < params.WorkDiffWindows; i++ {
 		weightedSum.Add(weightedSum, windowChanges[i])
 	}
 
@@ -173,16 +175,36 @@ func (b *BlockChain) calcNextRequiredDifficulty(curNode *blockNode, newBlockTime
 	if oldDiffBig.Cmp(bigZero) == 0 { // This should never really happen,
 		nextDiffBig.Set(nextDiffBig) // but in case it does...
 	} else if nextDiffBig.Cmp(bigZero) == 0 {
-		nextDiffBig.Set(b.chainParams.PowLimit)
+		nextDiffBig.Set(params.PowLimit)
 	} else if nextDiffBig.Cmp(nextDiffBigMax) == 1 {
 		nextDiffBig.Set(nextDiffBigMax)
 	} else if nextDiffBig.Cmp(nextDiffBigMin) == -1 {
 		nextDiffBig.Set(nextDiffBigMin)
 	}
 
-	// Limit new value to the proof of work limit.
-	if nextDiffBig.Cmp(b.chainParams.PowLimit) > 0 {
-		nextDiffBig.Set(b.chainParams.PowLimit)
+	// Prevent the difficulty from going lower than the minimum allowed
+	// difficulty.
+	//
+	// Larger numbers result in a lower difficulty, so imposing a minimum
+	// difficulty equates to limiting the maximum target value.
+	if nextDiffBig.Cmp(params.PowLimit) > 0 {
+		nextDiffBig.Set(params.PowLimit)
+	}
+
+	// Prevent the difficulty from going higher than a maximum allowed
+	// difficulty on the test network.  This is to prevent runaway difficulty on
+	// testnet by ASICs and GPUs since it's not reasonable to require
+	// high-powered hardware to keep the test network running smoothly.
+	//
+	// Smaller numbers result in a higher difficulty, so imposing a maximum
+	// difficulty equates to limiting the minimum target value.
+	//
+	// This rule is only active on the version 3 test network once the max diff
+	// activation height has been reached.
+	if b.minTestNetTarget != nil && nextDiffBig.Cmp(b.minTestNetTarget) < 0 &&
+		(!b.isTestNet3() || nextHeight >= testNet3MaxDiffActivationHeight) {
+
+		nextDiffBig = b.minTestNetTarget
 	}
 
 	// Convert the difficulty to the compact representation and return it.
diff --git a/internal/blockchain/error.go b/internal/blockchain/error.go
index 636eb873..c87c758d 100644
--- a/internal/blockchain/error.go
+++ b/internal/blockchain/error.go
@@ -76,9 +76,9 @@ const (
 	// timestamps to have a maximum precision of one second.
 	ErrInvalidTime = ErrorKind("ErrInvalidTime")
 
-	// ErrTimeTooOld indicates the time is either before the median time of
-	// the last several blocks per the chain consensus rules or prior to the
-	// most recent checkpoint.
+	// ErrTimeTooOld indicates the time is either before the median time of the
+	// last several blocks per the chain consensus rules or prior to the time
+	// that is required by max difficulty limitations on the test network.
 	ErrTimeTooOld = ErrorKind("ErrTimeTooOld")
 
 	// ErrTimeTooNew indicates the time is too far in the future as compared
@@ -107,6 +107,11 @@ const (
 	// before the fork rejection checkpoint.
 	ErrForkTooOld = ErrorKind("ErrForkTooOld")
 
+	// ErrBadMaxDiffCheckpoint indicates a block on the version 3 test network
+	// at the height used to activate maximum difficulty semantics does not
+	// match the expected one.
+	ErrBadMaxDiffCheckpoint = ErrorKind("ErrBadMaxDiffCheckpoint")
+
 	// ErrNoTransactions indicates the block does not have at least one
 	// transaction.  A valid block must have at least the coinbase transaction.
 	ErrNoTransactions = ErrorKind("ErrNoTransactions")
diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go
index cf739d6d..023c7522 100644
--- a/internal/blockchain/validate.go
+++ b/internal/blockchain/validate.go
@@ -98,6 +98,10 @@ const (
 	// another. However, because there is a 2^128 chance of a collision, the
 	// paranoid user may wish to turn this feature on.
 	checkForDuplicateHashes = false
+
+	// testNet3MaxDiffActivationHeight is the height that enforcement of the
+	// maximum difficulty rules starts.
+	testNet3MaxDiffActivationHeight = 962928
 )
 
 // mustParseHash converts the passed big-endian hex string into a
@@ -133,6 +137,10 @@ var (
 	block424011Hash = mustParseHash("0000000000000000317fc6c7a8a6578be7dfa9c96eb81d620050a3732b02d572")
 	block428809Hash = mustParseHash("00000000000000003147798ccffcecaa420fb1c7934d8f4e33809a871ee34aaa")
 	block430191Hash = mustParseHash("00000000000000002127ad6d4cb30cc16f6344589b417e42650388bb0690a88e")
+
+	// block962928Hash is the hash of the checkpoint used to activate maximum
+	// difficulty semantics on the version 3 test network.
+	block962928Hash = mustParseHash("0000004fd1b267fd39111d456ff557137824538e6f6776168600e56002e23b93")
 )
 
 // voteBitsApproveParent returns whether or not the passed vote bits indicate
@@ -1022,6 +1030,27 @@ func (b *BlockChain) checkBlockHeaderPositional(header *wire.BlockHeader, prevNo
 			str = fmt.Sprintf(str, header.Timestamp, medianTime)
 			return ruleError(ErrTimeTooOld, str)
 		}
+
+		// A block on the test network must have a timestamp that is at least
+		// one minute after the previous one once the maximum allowed difficulty
+		// has been reached.  This helps throttle ASICs and GPUs since it's not
+		// reasonable to require high-powered hardware to keep the test network
+		// running smoothly.
+		//
+		// This rule is only active on the version 3 test network once the max
+		// diff activation height has been reached.
+		blockHeight := prevNode.height + 1
+		if b.minTestNetTarget != nil &&
+			expDiff <= standalone.BigToCompact(b.minTestNetTarget) &&
+			(!b.isTestNet3() || blockHeight >= testNet3MaxDiffActivationHeight) {
+
+			minTime := time.Unix(prevNode.timestamp, 0).Add(time.Minute)
+			if header.Timestamp.Before(minTime) {
+				str := "testnet block timestamp of %v is before required %v"
+				str = fmt.Sprintf(str, header.Timestamp, minTime)
+				return ruleError(ErrTimeTooOld, str)
+			}
+		}
 	}
 
 	// The height of this block is one more than the referenced previous
@@ -1053,6 +1082,16 @@ func (b *BlockChain) checkBlockHeaderPositional(header *wire.BlockHeader, prevNo
 		return ruleError(ErrForkTooOld, str)
 	}
 
+	// Reject version 3 test network chains that are not specifically the chain
+	// used to activate maximum difficulty semantics.
+	if b.isTestNet3() && blockHeight == testNet3MaxDiffActivationHeight &&
+		blockHash != *block962928Hash {
+
+		str := fmt.Sprintf("block at height %d does not match checkpoint hash",
+			blockHeight)
+		return ruleError(ErrBadMaxDiffCheckpoint, str)
+	}
+
 	if !fastAdd {
 		// Reject old version blocks once a majority of the network has
 		// upgraded.
diff --git a/internal/mining/mining.go b/internal/mining/mining.go
index 79cf2ba1..553609b6 100644
--- a/internal/mining/mining.go
+++ b/internal/mining/mining.go
@@ -855,6 +855,15 @@ func (g *BlkTmplGenerator) handleTooFewVoters(nextHeight int64,
 			block.AddSTransaction(treasuryBase.MsgTx())
 		}
 
+		// Copy all of the regular transactions over.
+		for i, tx := range topBlock.Transactions() {
+			if i == 0 {
+				// Skip copying coinbase.
+				continue
+			}
+			block.AddTransaction(tx.MsgTx())
+		}
+
 		// Copy all of the stake transactions over.
 		for i, stx := range topBlock.STransactions() {
 			if i == 0 && isTreasuryEnabled {
@@ -2371,8 +2380,7 @@ nextPriorityQueueItem:
 	err = g.cfg.CheckConnectBlockTemplate(block)
 	if err != nil {
 		str := fmt.Sprintf("failed to do final check for check connect "+
-			"block when making new block template: %v",
-			err.Error())
+			"block when making new block template: %v", err)
 		return nil, makeError(ErrCheckConnectBlock, str)
 	}
 
diff --git a/internal/netsync/manager.go b/internal/netsync/manager.go
index d9544d68..9c93cd60 100644
--- a/internal/netsync/manager.go
+++ b/internal/netsync/manager.go
@@ -763,6 +763,19 @@ func (m *SyncManager) processBlock(block *dcrutil.Block) (int64, error) {
 	if err != nil {
 		return 0, err
 	}
+
+	// Update the sync height when the block is higher than the currently best
+	// known value and it extends the main chain.
+	onMainChain := forkLen == 0
+	if onMainChain {
+		m.syncHeightMtx.Lock()
+		blockHeight := int64(block.MsgBlock().Header.Height)
+		if blockHeight > m.syncHeight {
+			m.syncHeight = blockHeight
+		}
+		m.syncHeightMtx.Unlock()
+	}
+
 	m.isCurrentMtx.Lock()
 	m.maybeUpdateIsCurrent()
 	m.isCurrentMtx.Unlock()
@@ -1052,6 +1065,18 @@ func (m *SyncManager) handleHeadersMsg(hmsg *headersMsg) {
 	for _, header := range headers {
 		err := chain.ProcessBlockHeader(header)
 		if err != nil {
+			// Update the sync height when the sync peer fails to process any
+			// headers since that chain is invalid from the local point of view
+			// and thus whatever the best known good header is becomes the new
+			// sync height unless a better one is discovered from the new sync
+			// peer.
+			if peer == m.syncPeer && !headersSynced {
+				_, newBestHeaderHeight := chain.BestHeader()
+				m.syncHeightMtx.Lock()
+				m.syncHeight = newBestHeaderHeight
+				m.syncHeightMtx.Unlock()
+			}
+
 			// Note that there is no need to check for an orphan header here
 			// because they were already verified to connect above.
 
diff --git a/internal/rpcserver/rpcserver.go b/internal/rpcserver/rpcserver.go
index 16cd9cb4..37d746d6 100644
--- a/internal/rpcserver/rpcserver.go
+++ b/internal/rpcserver/rpcserver.go
@@ -3660,11 +3660,20 @@ func getWorkTemplateKey(header *wire.BlockHeader) [merkleRootPairSize]byte {
 //
 // This function MUST be called with the RPC workstate locked.
 func handleGetWorkRequest(s *Server) (interface{}, error) {
+	// Return an error immediately in the case of a failed background template.
+	//
+	// The only time this is expected is due to imposition of certain additional
+	// time-based rules such as when the maximum allowed difficulty on testnet
+	// is reached and not enough time has passed since the last block.
+	bt := s.cfg.BlockTemplater
+	if _, err := bt.CurrentTemplate(); err != nil {
+		return nil, rpcMiscError(fmt.Sprintf("no work is available: %v", err))
+	}
+
 	// Prune old templates and wait for updated templates when the current best
 	// chain changes.
 	state := s.workState
 	best := s.cfg.Chain.BestSnapshot()
-	bt := s.cfg.BlockTemplater
 	var template *mining.BlockTemplate
 	if state.prevHash == nil || *state.prevHash != best.Hash {
 		// Prune old templates from the pool when the best block changes.
@@ -3726,10 +3735,10 @@ func handleGetWorkRequest(s *Server) (interface{}, error) {
 	// retuned as part of the data below.
 	//
 	// For reference (0-index based, end value is exclusive):
-	// data[115:119] --> Bits
-	// data[135:139] --> Timestamp
-	// data[139:143] --> Nonce
-	// data[143:151] --> ExtraNonce
+	// data[116:120] --> Bits
+	// data[136:140] --> Timestamp
+	// data[140:144] --> Nonce
+	// data[144:152] --> ExtraNonce
 	data := make([]byte, 0, getworkDataLen)
 	buf := bytes.NewBuffer(data)
 	err = headerCopy.Serialize(buf)
diff --git a/server.go b/server.go
index 7f9c976e..265bc76f 100644
--- a/server.go
+++ b/server.go
@@ -2519,9 +2519,10 @@ func (s *server) handleBlockchainNotification(notification *blockchain.Notificat
 	// possibly notify RPC clients with the winning tickets.
 	case blockchain.NTBlockAccepted:
 		// Don't relay or notify RPC clients with winning tickets if we are not
-		// current. Other peers that are current should already know about it
-		// and clients, such as wallets, shouldn't be voting on old blocks.
-		if !s.syncManager.IsCurrent() {
+		// current and unsynced mining is not allowed.  Other peers that are
+		// current should already know about it and clients, such as wallets,
+		// shouldn't be voting on old blocks.
+		if !cfg.AllowUnsyncedMining && !s.syncManager.IsCurrent() {
 			return
 		}
 


Test patch:
diff --git a/chaincfg/testnetparams.go b/chaincfg/testnetparams.go
index 559ff0aa..29bb4faf 100644
--- a/chaincfg/testnetparams.go
+++ b/chaincfg/testnetparams.go
@@ -78,7 +78,10 @@ func TestNet3Params() *Params {
 			{"testnet-seed.decred.org", true},
 		},
 
-		// Chain parameters
+		// Chain parameters.
+		//
+		// Note that the minimum difficulty reduction parameter only applies up
+		// to and including block height 962927.
 		GenesisBlock:             &genesisBlock,
 		GenesisHash:              genesisBlock.BlockHash(),
 		PowLimit:                 testNetPowLimit,
diff --git a/internal/blockchain/error_test.go b/internal/blockchain/error_test.go
index 1618a05e..b5d84027 100644
--- a/internal/blockchain/error_test.go
+++ b/internal/blockchain/error_test.go
@@ -32,6 +32,7 @@ func TestErrorKindStringer(t *testing.T) {
 		{ErrBadMerkleRoot, "ErrBadMerkleRoot"},
 		{ErrBadCommitmentRoot, "ErrBadCommitmentRoot"},
 		{ErrForkTooOld, "ErrForkTooOld"},
+		{ErrBadMaxDiffCheckpoint, "ErrBadMaxDiffCheckpoint"},
 		{ErrNoTransactions, "ErrNoTransactions"},
 		{ErrNoTxInputs, "ErrNoTxInputs"},
 		{ErrNoTxOutputs, "ErrNoTxOutputs"},
diff --git a/internal/rpcserver/rpcserverhandlers_test.go b/internal/rpcserver/rpcserverhandlers_test.go
index 6e8975ee..a66e3b1b 100644
--- a/internal/rpcserver/rpcserverhandlers_test.go
+++ b/internal/rpcserver/rpcserverhandlers_test.go
@@ -5756,7 +5756,7 @@ func TestHandleGetWork(t *testing.T) {
 			return templater
 		}(),
 		wantErr: true,
-		errCode: dcrjson.ErrRPCInternal.Code,
+		errCode: dcrjson.ErrRPCMisc,
 	}, {
 		name:    "handleGetWork: no work during chain reorg",
 		handler: handleGetWork,
diff --git a/rpctest/simnet_miner.go b/rpctest/simnet_miner.go
index 5e6d3409..21f77692 100644
--- a/rpctest/simnet_miner.go
+++ b/rpctest/simnet_miner.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/decred/dcrd/blockchain/standalone/v2"
 	"github.com/decred/dcrd/chaincfg/chainhash"
+	dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4"
 	"github.com/decred/dcrd/rpcclient/v8"
 	"github.com/decred/dcrd/wire"
 )
@@ -110,10 +111,14 @@ func waitPredicate(pred func() bool, timeout time.Duration) error {
 // This is only applicable for tests that run on simnet or other networks that
 // have a target block per count of 1 second.
 func AdjustedSimnetMiner(ctx context.Context, client *rpcclient.Client, nb uint32) ([]*chainhash.Hash, error) {
-
-	hashes := make([]*chainhash.Hash, nb)
-
-	prevWork, err := client.GetWork(ctx)
+	// Fetch the current template. This might fail if there aren't enough
+	// tickets in the mempool yet, so perform a few tries.
+	var prevWork *dcrdtypes.GetWorkResult
+	err := waitPredicate(func() bool {
+		var err error
+		prevWork, err = client.GetWork(ctx)
+		return err == nil
+	}, time.Second*10)
 	if err != nil {
 		return nil, err
 	}
@@ -135,6 +140,7 @@ func AdjustedSimnetMiner(ctx context.Context, client *rpcclient.Client, nb uint3
 		return work.Data != prevWork.Data
 	}, time.Second)
 
+	hashes := make([]*chainhash.Hash, nb)
 	for i := uint32(0); i < nb; i++ {
 		work, err := client.GetWork(ctx)
 		if err != nil {