Repository: decred/dcrd
Pull request #2995 (2022-09-13 10:52:10)

blockchain: Harden and optimize utxo cache.
~**This requires #2994**.~

This reworks several portions of the utxo cache to improve its robustness, optimize it, and correct some hard to hit corner cases that involve a mix of manual block invalidation, conditional flushing, and successive unclean shutdowns along with reworking several of the tests and significantly increasing the associated scenarios covered by the tests.

Note that since **this is consensus critical code**, significant effort has been put into making these changes easier to reason about and review by carefully crafting a series of individual commits such that every commit message thoroughly describes its purpose, maintains consensus, and therefore passes all tests.

The following is a high-level overview of the changes:
- Miscellaneous consistency cleanup
- Pre-allocates the map used to track in-flight transactions in utxo views
- Removes exported utxo cache `SpendEntry` method
- Remove exported utxo cache `AddEntry` method
- Removes unused utxo cache add entry error
- Removes the unnecessary `tip` parameter from the utxo cache `Initialize` method
- Modifies the utxo cache tests to allow overriding cache flushing
- Improves utxo cache initialize tests to ensure initialize code itself flushes the expected data to the backend
- Corrects an extremely hard to hit corner case involving a mix of manual block invalidation, conditional flushing, and successive unclean shutdowns
  - Adds tests for this condition to ensure proper behavior
- Modifies the code that deals with fetching input utxos to avoid needlessly looking up inputs for `treasurybase` and `treasury spend` transactions 
- Updates the code when connecting transactions in a utxo view to avoid adding `treasurybase` utxos since they are never directly spendable
- Simplifies the various utxo cache tests and improves their legibility 
- Reworks utxo cache spend entry tests to simplify them and make them more flexible
- Reworks utxo cache commit tests to simplify them, make them more flexible, and test a lot more conditions
- Improves the robustness of `Commit` in regards to unmodified spent and unspent fresh view entries to help defend against code changes that could easily inadvertently violate the non-obvious, and heretofore undocumented, assumptions
- Reworks the utxo cache add entry tests to simplify them and make them more flexible
- Separates utxo cache and view state by updating the `addEntry` and `fetchEntry` methods to ensure the modified and spent flags are cleared on cloned entries loaded from the cache and and always set correctly when adding entries to the cache
- Modifies the utxo cache commit logic to ensure more robust handling of spent outputs including checking the backend for entries not currently in the cache and conditionally loading them if needed
  - Includes the addition of a double spend assertion
  - Adds tests to ensure the new behavior works as intended
- Splits the logic for handling the connection of transactions from the regular tree and stake tree to a utxo view to provide a clean path for optimizing zero confirmation spends
- Updates the utxo view and cache handling to detect zero confirmation spends in the regular transaction tree and bypass the cache when committing the view to avoid cache misses given they are guaranteed to never have any cache entries since they never exist beyond the scope of the view

dnldd: Looks good overall, will be running a mainnet sync with this soon.
dnldd: Synced mainnet with the changes made here without issue.

Patch:
diff --git a/internal/blockchain/chain.go b/internal/blockchain/chain.go
index f22ae378..1265adc9 100644
--- a/internal/blockchain/chain.go
+++ b/internal/blockchain/chain.go
@@ -940,7 +940,8 @@ func countSpentStakeOutputs(block *dcrutil.Block) int {
 			continue
 		}
 
-		// Exclude TreasuryBase and TSpend.
+		// Exclude treasurybase and treasury spends since neither have any
+		// inputs.
 		if stake.IsTreasuryBase(stx) || stake.IsTSpend(stx) {
 			continue
 		}
@@ -2211,7 +2212,7 @@ func New(ctx context.Context, config *Config) (*BlockChain, error) {
 
 	// Initialize the UTXO state.  This entails running any database migrations
 	// as necessary as well as initializing the UTXO cache.
-	if err := b.utxoCache.Initialize(ctx, &b, b.bestChain.tip()); err != nil {
+	if err := b.utxoCache.Initialize(ctx, &b); err != nil {
 		return nil, err
 	}
 
diff --git a/internal/blockchain/indexers/indexsubscriber.go b/internal/blockchain/indexers/indexsubscriber.go
index cf7ea665..d306378b 100644
--- a/internal/blockchain/indexers/indexsubscriber.go
+++ b/internal/blockchain/indexers/indexsubscriber.go
@@ -243,8 +243,8 @@ func (s *IndexSubscriber) findLowestIndexTipHeight(queryer ChainQueryer) (int64,
 	return lowestHeight, bestHeight, nil
 }
 
-// CatchUp syncs all subscribed indexes to the the main chain by connecting
-// blocks from after the lowest index tip to the current main chain tip.
+// CatchUp syncs all subscribed indexes to the main chain by connecting blocks
+// from after the lowest index tip to the current main chain tip.
 //
 // This should be called after all indexes have subscribed for updates.
 func (s *IndexSubscriber) CatchUp(ctx context.Context, db database.DB, queryer ChainQueryer) error {
diff --git a/internal/blockchain/utxocache.go b/internal/blockchain/utxocache.go
index d5344910..1a0889a5 100644
--- a/internal/blockchain/utxocache.go
+++ b/internal/blockchain/utxocache.go
@@ -102,7 +102,7 @@ type UtxoCacher interface {
 	// Initialize initializes the utxo cache and underlying utxo backend.  This
 	// entails running any database migrations as well as ensuring that the utxo
 	// set is caught up to the tip of the best chain.
-	Initialize(ctx context.Context, b *BlockChain, tip *blockNode) error
+	Initialize(ctx context.Context, b *BlockChain) error
 
 	// MaybeFlush conditionally flushes the cache to the backend.  A flush can
 	// be forced by setting the force flush parameter.
@@ -186,6 +186,12 @@ type UtxoCache struct {
 	// defaults to time.Now but an alternative function can be provided for
 	// testing purposes.
 	timeNow func() time.Time
+
+	// maybeFlushFn defines the function to use for potentially flushing the
+	// cache to the backend.  It defaults to the exported MaybeFlush method of
+	// this cache instance, but an alternative can be provided for testing
+	// purposes.
+	maybeFlushFn func(*chainhash.Hash, uint32, bool, bool) error
 }
 
 // Ensure UtxoCache implements the UtxoCacher interface.
@@ -222,7 +228,7 @@ func NewUtxoCache(config *UtxoCacheConfig) *UtxoCache {
 		p2pkhScriptLen
 	maxEntries := math.Ceil(float64(config.MaxSize) / float64(avgEntrySize))
 
-	return &UtxoCache{
+	c := &UtxoCache{
 		backend:       config.Backend,
 		flushBlockDB:  config.FlushBlockDB,
 		maxSize:       config.MaxSize,
@@ -230,6 +236,8 @@ func NewUtxoCache(config *UtxoCacheConfig) *UtxoCache {
 		lastFlushTime: time.Now(),
 		timeNow:       time.Now,
 	}
+	c.maybeFlushFn = c.MaybeFlush
+	return c
 }
 
 // totalSize returns the total size of the cache on a 64-bit platform, in bytes.
@@ -265,60 +273,132 @@ func (c *UtxoCache) hitRatio() float64 {
 // it to the cache.
 //
 // This function MUST be called with the cache lock held.
-func (c *UtxoCache) addEntry(outpoint wire.OutPoint, entry *UtxoEntry) error {
+func (c *UtxoCache) addEntry(outpoint wire.OutPoint, entry *UtxoEntry) {
+	// The entry will either be added to the cache when there is not already
+	// an existing entry for the specified outpoint or will overwrite the
+	// existing one when there is, but in either case the resulting cache
+	// entry is modified and thus needs to be marked as such.
+	entry.state |= utxoStateModified
+
 	// Attempt to get an existing entry from the cache.
 	cachedEntry := c.entries[outpoint]
 
-	// If an existing entry does not exist, the added entry should be marked as
-	// modified and fresh.
+	// Mark the entry as fresh, add it to the cache, and update the total
+	// cache size when there is not already an existing cache entry.
 	if cachedEntry == nil {
-		entry.state |= utxoStateModified | utxoStateFresh
+		entry.state |= utxoStateFresh
+		c.entries[outpoint] = entry
+		c.totalEntrySize += entry.size()
+		return
 	}
 
-	// Add the entry to the cache.  In the case that an entry already exists,
-	// the existing entry is overwritten.  All fields are overwritten because
-	// it's possible (although extremely unlikely) that the existing entry is
-	// being replaced by a different transaction with the same hash.  This is
-	// allowed so long as the previous transaction is fully spent.
-	c.entries[outpoint] = entry
-
-	// Update the total entry size of the cache.
-	if cachedEntry != nil {
-		c.totalEntrySize -= cachedEntry.size()
+	// There is an existing entry in the cache at this point.
+	//
+	// Ensure the state of whether or not the existing entry is fresh is
+	// maintained, overwrite the existing entry, and update the total cache size
+	// accordingly.
+	//
+	// All fields are overwritten because it's possible (although extremely
+	// unlikely) that the existing entry is being replaced by a different
+	// transaction with the same hash.  This is allowed so long as the previous
+	// transaction is fully spent.
+	if cachedEntry.isFresh() {
+		entry.state |= utxoStateFresh
+	} else {
+		entry.state &^= utxoStateFresh
 	}
+	c.entries[outpoint] = entry
+	c.totalEntrySize -= cachedEntry.size()
 	c.totalEntrySize += entry.size()
-
-	return nil
 }
 
-// AddEntry adds the specified output to the cache.  The entry being added MUST
-// NOT be mutated by the caller after being passed to this function.
+// spendEntry marks the specified output as spent.
 //
-// Note that this function does not check if the entry is unspendable and
-// therefore the caller should ensure that the entry is spendable before adding
-// it to the cache.
+// When the provided output does not already have an associated entry in the
+// cache, the cache will be populated with the entry from the backend (if one
+// exists) and marked spent to ensure the utxo is later removed from the backend
+// on the next cache flush.
 //
-// This function is safe for concurrent access.
-func (c *UtxoCache) AddEntry(outpoint wire.OutPoint, entry *UtxoEntry) error {
-	c.cacheLock.Lock()
-	err := c.addEntry(outpoint, entry)
-	c.cacheLock.Unlock()
-	return err
-}
-
-// spendEntry marks the specified output as spent.
+// An error will be returned in the case the output exists in the cache and is
+// already spent.
 //
 // This function MUST be called with the cache lock held.
-func (c *UtxoCache) spendEntry(outpoint wire.OutPoint) {
-	// Attempt to get an existing entry from the cache.
-	cachedEntry := c.entries[outpoint]
+func (c *UtxoCache) spendEntry(outpoint wire.OutPoint) error {
+	// Attempt to get the entry associated with the output from the cache.
+	//
+	// In the case there is a nil entry in the cache for the output, it does not
+	// exist in the backend.  This is because either an attempt to load the utxo
+	// from the backend was made and the nil entry was added to the cache to
+	// avoid further lookups against the backend when the output is known to not
+	// exist or the output was added to the cache and spent in between flushes
+	// to the backend and thus has been pruned.
+	//
+	// In either case, the cache does not need to be updated, so there is
+	// nothing more to do.
+	cachedEntry, found := c.entries[outpoint]
+	if found && cachedEntry == nil {
+		return nil
+	}
 
-	// If the entry is nil or already spent, return immediately.
-	if cachedEntry == nil || cachedEntry.IsSpent() {
-		return
+	// Sanity check double spends of existing entries.  This should be
+	// impossible to hit since a view should never be committed until after
+	// double spend checks have already been done.  However, it is good practice
+	// to be paranoid and double check assumptions when it comes to potential
+	// double spends.
+	if cachedEntry != nil && cachedEntry.IsSpent() {
+		return AssertError(fmt.Sprintf("attempt to double spend output %v in "+
+			"view commit", outpoint))
 	}
 
-	// If the entry is fresh, and is now being spent, it can safely be removed.
+	// Attempt to repopulate the cache from the backend when there is no entry
+	// for the output in the cache.  This is necessary because the associated
+	// utxo might exist in the backend but not have been loaded into the cache
+	// yet or have otherwise been evicted.
+	//
+	// Thus, in order to ensure the utxo is removed from the backend on the next
+	// cache flush in the case it exists, the associated cache entry from the
+	// backend is loaded and marked spent.
+	if !found {
+		// Account for the cache miss and attempt to load the entry for the utxo
+		// from the backend.
+		//
+		// Notice that this intentionally attempts to load the entry from the
+		// backend directly as opposed to using the normal cache entry fetching
+		// method since that involves querying the cache again which would be
+		// wasteful since it is already known to not exist and also employs
+		// slightly different logic regarding populating the cache.
+		//
+		// NOTE: Missing backend entries are not an error.
+		c.misses++
+		backendEntry, err := c.backend.FetchEntry(outpoint)
+		if err != nil {
+			return err
+		}
+
+		// The cache does not need to be updated when the associated utxo does
+		// not exist in the backend, so there is nothing more to do in that
+		// case.
+		//
+		// Note that a nil entry is not added to cache for the output here since
+		// there should realistically never be any future lookups for the spent
+		// output given that would be a double spend.
+		if backendEntry == nil {
+			return nil
+		}
+
+		// Add the entry to the cache and update its total entry size.
+		c.entries[outpoint] = backendEntry
+		c.totalEntrySize += backendEntry.size()
+
+		// Mark the output as spent and modified.
+		backendEntry.Spend()
+		return nil
+	}
+
+	// Remove the entry associated with the output from the cache when the
+	// backend does not need to be updated because the backend does not have an
+	// associated utxo (aka the cache entry is fresh).
+	//
 	// This is an optimization to skip writing to the backend for outputs that
 	// are added and spent in between flushes to the backend.
 	if cachedEntry.isFresh() {
@@ -327,40 +407,42 @@ func (c *UtxoCache) spendEntry(outpoint wire.OutPoint) {
 		// and avoid querying the backend.
 		c.entries[outpoint] = nil
 		c.totalEntrySize -= cachedEntry.size()
-		return
+		return nil
 	}
 
 	// Mark the output as spent and modified.
 	cachedEntry.Spend()
-}
-
-// SpendEntry marks the specified output as spent.
-//
-// This function is safe for concurrent access.
-func (c *UtxoCache) SpendEntry(outpoint wire.OutPoint) {
-	c.cacheLock.Lock()
-	c.spendEntry(outpoint)
-	c.cacheLock.Unlock()
+	return nil
 }
 
 // fetchEntry returns the specified transaction output from the utxo set.  If
 // the output exists in the cache, it is returned immediately.  Otherwise, it
 // fetches the output from the backend, caches it, and returns it to the
 // caller.  A cloned copy of the entry is returned so it can safely be mutated
-// by the caller without invalidating the cache.
+// by the caller without invalidating the cache.  The returned entry will not
+// have the modified or fresh flags set to ensure the cache state is separate
+// from the caller state.
 //
 // When there is no entry for the provided output, nil will be returned for both
 // the entry and the error.
 //
 // This function MUST be called with the cache lock held.
 func (c *UtxoCache) fetchEntry(outpoint wire.OutPoint) (*UtxoEntry, error) {
-	// If the cache already has the entry, return it immediately.  A cloned copy
-	// of the entry is returned so it can safely be mutated by the caller
-	// without invalidating the cache.
+	// Return a cloned copy of the cache entry when one exists for the provided
+	// output.  A cloned copy of the entry is returned so it can safely be
+	// mutated by the caller without invalidating the cache.
+	//
+	// Also clear the modified and fresh flags for the returned entry to ensure
+	// the cache state is separate from the view state.
 	var entry *UtxoEntry
 	if entry, found := c.entries[outpoint]; found {
 		c.hits++
-		return entry.Clone(), nil
+
+		clonedEntry := entry.Clone()
+		if clonedEntry != nil {
+			clonedEntry.state &^= utxoStateModified | utxoStateFresh
+		}
+		return clonedEntry, nil
 	}
 
 	// Increment cache misses.
@@ -385,6 +467,9 @@ func (c *UtxoCache) fetchEntry(outpoint wire.OutPoint) (*UtxoEntry, error) {
 	// Add the entry to the cache and return it.  A cloned copy of the entry is
 	// returned so it can safely be mutated by the caller without invalidating
 	// the cache.
+	//
+	// Also note that all entries loaded from the backend will not have any
+	// state flags set since they are memory only flags.
 	c.entries[outpoint] = entry
 	return entry.Clone(), nil
 }
@@ -444,7 +529,7 @@ func (c *UtxoCache) FetchBackendState() (*UtxoSetState, error) {
 func (c *UtxoCache) FetchStats(bestHash *chainhash.Hash, bestHeight uint32) (*UtxoStats, error) {
 	// Force a UTXO cache flush.  This is required in order for the backend to
 	// fetch statistics on the full UTXO set.
-	err := c.MaybeFlush(bestHash, bestHeight, true, false)
+	err := c.maybeFlushFn(bestHash, bestHeight, true, false)
 	if err != nil {
 		return nil, err
 	}
@@ -461,52 +546,81 @@ func (c *UtxoCache) FetchStats(bestHash *chainhash.Hash, bestHeight uint32) (*Ut
 //
 // This function is safe for concurrent access.
 func (c *UtxoCache) Commit(view *UtxoViewpoint) error {
+	defer c.cacheLock.Unlock()
 	c.cacheLock.Lock()
+
 	for outpoint, entry := range view.entries {
-		// If the entry is nil, delete it from the view and continue.
+		// There is nothing to update in the cache when the view entry is nil,
+		// so remove it from the view and continue to the next entry.
 		if entry == nil {
 			delete(view.entries, outpoint)
 			continue
 		}
 
-		// If the entry is not modified and not fresh, continue as there is
-		// nothing to do.
-		if !entry.isModified() && !entry.isFresh() {
+		// There is nothing to update in the cache nor the view when the view
+		// entry was not modified.
+		if !entry.isModified() {
 			continue
 		}
 
-		// If the entry is modified and spent, mark it as spent in the cache and
-		// then delete it from the view.
-		if entry.isModified() && entry.IsSpent() {
-			// Mark the entry as spent in the cache.
-			c.spendEntry(outpoint)
+		// ---------------------------------------------------------------------
+		// NOTE: As an optimization in order to avoid an additional allocation,
+		// the cache takes ownership of all modified entries from the view.
+		//
+		// Thus, unless there is an error preventing the cache from taking
+		// ownership and ultimately causing the commit to fail, the entry will
+		// be removed from the view in all remaining paths to ensure that it is
+		// not mutated by the caller after being added to the cache.
+		//
+		// This does cause the view to refetch the entry if it is requested
+		// again after being removed.  However, this only really occurs during
+		// reorgs, whereas committing the view to the cache happens with every
+		// connected block, so this optimizes for the much more common case.
+		// ---------------------------------------------------------------------
+
+		// There is nothing to update in the cache when the output is spent by a
+		// transaction later in the regular transaction tree of the same block
+		// since such outputs never exist beyond the scope of the view and
+		// therefore do not have any cache entries.
+		//
+		// Further, it is no longer needed in the view either since it's already
+		// spent and thus nothing else in future blocks could possibly spend it.
+		//
+		// Thus, remove the entry from the view and continue to the next one.
+		if entry.isSpentByZeroConf() {
+			// Sanity check zero confirmation spends are also marked as spent.
+			if !entry.IsSpent() {
+				return AssertError(fmt.Sprintf("output %v from view@%s is "+
+					"simultaneously marked as spent by a transaction later in "+
+					"the same block and not spent", outpoint,
+					view.BestHash()))
+			}
 
-			// Delete the entry from the view.
 			delete(view.entries, outpoint)
 			continue
 		}
 
-		// If we passed all of the conditions above, the entry is modified or
-		// fresh, but not spent, and should be added to the cache.
-		err := c.addEntry(outpoint, entry)
-		if err != nil {
-			c.cacheLock.Unlock()
-			return err
+		// Mark the spent view entry as modified and spent in the cache and
+		// remove it from the view.
+		//
+		// This might entail populating the cache with the unspent output from
+		// the backend when there is not already a corresponding entry in the
+		// cache to ensure the utxo is later removed from the backend on the
+		// next cache flush.
+		if entry.IsSpent() {
+			if err := c.spendEntry(outpoint); err != nil {
+				return err
+			}
+
+			delete(view.entries, outpoint)
+			continue
 		}
 
-		// All entries that are added to the cache should be removed from the
-		// provided view.  This is an optimization to allow the cache to take
-		// ownership of the entry from the view and avoid an additional
-		// allocation.  It is removed from the view to ensure that it is not
-		// mutated by the caller after being added to the cache.
-		//
-		// This does cause the view to refetch the entry if it is requested
-		// again after being removed.  However, this only really occurs during
-		// reorgs, whereas committing the view to the cache happens with every
-		// connected block, so this optimizes for the much more common case.
+		// Update the existing entry in the cache or add a new one whenever the
+		// view entry is both modified and unspent and remove it from the view.
+		c.addEntry(outpoint, entry)
 		delete(view.entries, outpoint)
 	}
-	c.cacheLock.Unlock()
 
 	return nil
 }
@@ -698,7 +812,7 @@ func (c *UtxoCache) MaybeFlush(bestHash *chainhash.Hash, bestHeight uint32,
 // last flushed to the tip block through the cache.
 //
 // This function should only be called during initialization.
-func (c *UtxoCache) Initialize(ctx context.Context, b *BlockChain, tip *blockNode) error {
+func (c *UtxoCache) Initialize(ctx context.Context, b *BlockChain) error {
 	log.Infof("UTXO cache initializing (max size: %d MiB)...",
 		c.maxSize/1024/1024)
 
@@ -717,6 +831,7 @@ func (c *UtxoCache) Initialize(ctx context.Context, b *BlockChain, tip *blockNod
 	// If the state is nil, update the state to the tip.  This should only be
 	// the case when starting from a fresh backend or a backend that has not
 	// been run with the utxo cache yet.
+	tip := b.bestChain.Tip()
 	if state == nil {
 		state = &UtxoSetState{
 			lastFlushHeight: uint32(tip.height),
@@ -835,7 +950,10 @@ func (c *UtxoCache) Initialize(ctx context.Context, b *BlockChain, tip *blockNod
 		// Conditionally flush the utxo cache to the backend.  Don't force flush
 		// since many blocks may be disconnected and connected in quick
 		// succession when initializing.
-		err = c.MaybeFlush(&n.hash, uint32(n.height), false, true)
+		const forceFlush = false
+		const logFlush = true
+		err = c.maybeFlushFn(&n.parent.hash, uint32(n.parent.height),
+			forceFlush, logFlush)
 		if err != nil {
 			return err
 		}
@@ -910,7 +1028,9 @@ func (c *UtxoCache) Initialize(ctx context.Context, b *BlockChain, tip *blockNod
 		// Conditionally flush the utxo cache to the backend.  Don't force flush
 		// since many blocks may be connected in quick succession when
 		// initializing.
-		err = c.MaybeFlush(&n.hash, uint32(n.height), false, true)
+		const forceFlush = false
+		const logFlush = true
+		err = c.maybeFlushFn(&n.hash, uint32(n.height), forceFlush, logFlush)
 		if err != nil {
 			return err
 		}
diff --git a/internal/blockchain/utxoentry.go b/internal/blockchain/utxoentry.go
index c0a1fadf..9c05bb71 100644
--- a/internal/blockchain/utxoentry.go
+++ b/internal/blockchain/utxoentry.go
@@ -20,19 +20,24 @@ const (
 //
 // The bit representation is:
 //
-//	bit  0    - transaction output has been spent
-//	bit  1    - transaction output has been modified since it was loaded
-//	bit  2    - transaction output is fresh
-//	bits 3-7  - unused
+//	bit  0    - transaction output has been modified since it was loaded
+//	bit  1    - transaction output has been spent
+//	bit  2    - transaction output has been spent by tx later in the same block
+//	bit  3    - transaction output is fresh
+//	bits 4-7  - unused
 type utxoState uint8
 
 const (
-	// utxoStateSpent indicates that a txout is spent.
-	utxoStateSpent utxoState = 1 << iota
-
 	// utxoStateModified indicates that a txout has been modified since it was
 	// loaded.
-	utxoStateModified
+	utxoStateModified utxoState = 1 << iota
+
+	// utxoStateSpent indicates that a txout is spent.
+	utxoStateSpent
+
+	// utxoStateSpentByZeroConf indicates that a txout was spent by another
+	// transaction later in the same block.
+	utxoStateSpentByZeroConf
 
 	// utxoStateFresh indicates that a txout is fresh, which means that it
 	// exists in the utxo cache but does not exist in the underlying database.
@@ -149,6 +154,13 @@ func (entry *UtxoEntry) isFresh() bool {
 	return entry.state&utxoStateFresh == utxoStateFresh
 }
 
+// isSpentByZeroConf returns whether or not the output is marked as spent by a
+// zero confirmation transaction.  In other words, it is an output that was
+// created in a block and later spent by another transaction in the same block.
+func (entry *UtxoEntry) isSpentByZeroConf() bool {
+	return entry.state&utxoStateSpentByZeroConf == utxoStateSpentByZeroConf
+}
+
 // IsCoinBase returns whether or not the output was contained in a coinbase
 // transaction.
 func (entry *UtxoEntry) IsCoinBase() bool {
diff --git a/internal/blockchain/utxoviewpoint.go b/internal/blockchain/utxoviewpoint.go
index 7f9b38fd..63bad2b1 100644
--- a/internal/blockchain/utxoviewpoint.go
+++ b/internal/blockchain/utxoviewpoint.go
@@ -51,6 +51,13 @@ func (view *UtxoViewpoint) LookupEntry(outpoint wire.OutPoint) *UtxoEntry {
 	return view.entries[outpoint]
 }
 
+// RemoveEntry removes the given transaction output from the current state of
+// the view.  It will have no effect if the passed output does not exist in the
+// view.
+func (view *UtxoViewpoint) RemoveEntry(outpoint wire.OutPoint) {
+	delete(view.entries, outpoint)
+}
+
 // addTxOut adds the specified output to the view if it is not provably
 // unspendable.  When the view already has an entry for the output, it will be
 // marked unspent.  All fields will be updated for existing entries since it's
@@ -83,7 +90,7 @@ func (view *UtxoViewpoint) addTxOut(outpoint wire.OutPoint, txOut *wire.TxOut,
 
 	// The referenced transaction output should always be marked as unspent and
 	// modified when being added to the view.
-	entry.state &^= utxoStateSpent
+	entry.state &^= utxoStateSpent | utxoStateSpentByZeroConf
 	entry.state |= utxoStateModified
 
 	// Deep copy the script when the script in the entry differs from the one in
@@ -149,9 +156,8 @@ func (view *UtxoViewpoint) AddTxOut(tx *dcrutil.Tx, txOutIdx uint32,
 func (view *UtxoViewpoint) AddTxOuts(tx *dcrutil.Tx, blockHeight int64,
 	blockIndex uint32, isTreasuryEnabled bool) {
 
-	msgTx := tx.MsgTx()
-
 	// Set encoded flags for the transaction.
+	msgTx := tx.MsgTx()
 	isCoinBase := standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled)
 	hasExpiry := msgTx.Expiry != wire.NoExpiryValue
 	txType := stake.DetermineTxType(msgTx)
@@ -210,79 +216,193 @@ func (view *UtxoViewpoint) PriorityInput(prevOut *wire.OutPoint) (int64, int64,
 	return 0, 0, false
 }
 
-// connectTransaction updates the view by adding all new utxos created by the
-// passed transaction and marking all utxos that the transaction spends as
-// spent.  In addition, when the 'stxos' argument is not nil, it will be updated
-// to append an entry for each spent txout.  An error will be returned if the
-// view does not contain the required utxos.
-func (view *UtxoViewpoint) connectTransaction(tx *dcrutil.Tx, blockHeight int64,
-	blockIndex uint32, stxos *[]spentTxOut, isTreasuryEnabled bool) error {
+// utxoEntryToSpentTxOut converts the provided utxo entry to a spent txout for
+// use in tracking all spent txouts for the spend journal.
+func utxoEntryToSpentTxOut(entry *UtxoEntry) spentTxOut {
+	// Populate the stxo details using the utxo entry.
+	return spentTxOut{
+		amount:        entry.Amount(),
+		pkScript:      entry.PkScript(),
+		ticketMinOuts: entry.ticketMinOuts,
+		blockHeight:   uint32(entry.BlockHeight()),
+		blockIndex:    entry.BlockIndex(),
+		scriptVersion: entry.ScriptVersion(),
+		packedFlags: encodeFlags(entry.IsCoinBase(), entry.HasExpiry(),
+			entry.TransactionType()),
+	}
+}
+
+// connectStakeTransaction updates the view by adding all new utxos created by
+// the passed transaction from the stake transaction tree and marking all utxos
+// that the transaction spends as spent.  In addition, when the 'stxos' argument
+// is not nil, it will be updated to append an entry for each spent txout.  An
+// error will be returned if the view does not contain the required utxos.
+func (view *UtxoViewpoint) connectStakeTransaction(tx *dcrutil.Tx,
+	blockHeight int64, blockIndex uint32, stxos *[]spentTxOut,
+	isTreasuryEnabled bool) error {
 
-	// Coinbase transactions don't have any inputs to spend.
+	// Treasurybase transactions don't have any inputs to spend or outputs to
+	// add.
+	//
+	// NOTE: This check relies on the code already having validated that the
+	// first transaction, and only the first transaction, in the stake tree is
+	// the required treasurybase when the treasury agenda is active.
 	msgTx := tx.MsgTx()
-	if standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled) {
-		// Add the transaction's outputs as available utxos.
-		view.AddTxOuts(tx, blockHeight, blockIndex, isTreasuryEnabled)
+	if isTreasuryEnabled && blockIndex == 0 {
 		return nil
 	}
 
-	// Treasurybase transactions don't have any inputs to spend.
-	if isTreasuryEnabled && standalone.IsTreasuryBase(msgTx) {
+	// Treasury spends don't have any inputs to spend.
+	isTreasurySpend := isTreasuryEnabled && stake.IsTSpend(msgTx)
+	if isTreasurySpend {
+		// Add the transaction's outputs as available utxos.
+		view.AddTxOuts(tx, blockHeight, blockIndex, isTreasuryEnabled)
 		return nil
 	}
 
 	// Spend the referenced utxos by marking them spent in the view and, if a
 	// slice was provided for the spent txout details, append an entry to it.
 	isVote := stake.IsSSGen(msgTx)
-	var isTSpend bool
-	if isTreasuryEnabled {
-		isTSpend = stake.IsTSpend(msgTx)
-	}
 	for txInIdx, txIn := range msgTx.TxIn {
 		// Ignore stakebase since it has no input.
 		if isVote && txInIdx == 0 {
 			continue
 		}
 
-		// Ignore TSpend since it has no input.
-		if isTSpend {
-			continue
+		// Ensure the referenced utxo exists in the view.  This should never
+		// happen unless a bug is introduced in the code.
+		prevOut := &txIn.PreviousOutPoint
+		entry := view.entries[*prevOut]
+		if entry == nil {
+			return AssertError(fmt.Sprintf("view missing input %v", *prevOut))
+		}
+
+		// Only create the stxo details if requested.
+		if stxos != nil {
+			// Populate the stxo details using the utxo entry.
+			*stxos = append(*stxos, utxoEntryToSpentTxOut(entry))
 		}
 
+		// Mark the entry as spent.  This is not done until after the relevant
+		// details have been accessed since spending it might clear the fields
+		// from memory in the future.
+		entry.Spend()
+	}
+
+	// Add the transaction's outputs as available utxos.
+	view.AddTxOuts(tx, blockHeight, blockIndex, isTreasuryEnabled)
+
+	return nil
+}
+
+// connectStakeTransactions updates the view by adding all new utxos created by
+// the transactions in the stake transaction tree of the block and marking all
+// utxos those same transactions spend as spent.  In addition, when the 'stxos'
+// argument is not nil, it will be updated to append an entry for each spent
+// txout.  An error will be returned if the view does not contain the required
+// utxos.
+func (view *UtxoViewpoint) connectStakeTransactions(block *dcrutil.Block,
+	stxos *[]spentTxOut, isTreasuryEnabled bool) error {
+
+	// Connect all of the transactions in the stake transaction tree.
+	for i, tx := range block.STransactions() {
+		err := view.connectStakeTransaction(tx, block.Height(), uint32(i),
+			stxos, isTreasuryEnabled)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// connectRegularTransaction updates the view by adding all new utxos created by
+// the passed transaction from the regular transaction tree and marking all
+// utxos that the transaction spends as spent.  The transaction is added to the
+// provided in-flight tx map which is used to detect and mark utxos earlier in
+// the same regular transaction tree as zero conf spends.
+//
+// When the 'stxos' argument is not nil, it will be updated to append an entry
+// for each spent txout.  An error will be returned if the view does not contain
+// the required utxos.
+func (view *UtxoViewpoint) connectRegularTransaction(tx *dcrutil.Tx,
+	blockHeight int64, blockIndex uint32, inFlightTx map[chainhash.Hash]uint32,
+	stxos *[]spentTxOut, isTreasuryEnabled bool) error {
+
+	// Coinbase transactions don't have any inputs to spend.
+	msgTx := tx.MsgTx()
+	if standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled) {
+		// Add the transaction's outputs as available utxos.
+		view.AddTxOuts(tx, blockHeight, blockIndex, isTreasuryEnabled)
+
+		// Keep track of in-flight transactions in order detect spends of
+		// earlier outputs by transactions later in the same block.
+		inFlightTx[*tx.Hash()] = blockIndex
+		return nil
+	}
+
+	// Spend the referenced utxos by marking them spent in the view and, if a
+	// slice was provided for the spent txout details, append an entry to it.
+	for _, txIn := range msgTx.TxIn {
 		// Ensure the referenced utxo exists in the view.  This should never
-		// happen unless there is a bug is introduced in the code.
-		entry := view.entries[txIn.PreviousOutPoint]
+		// happen unless a bug is introduced in the code.
+		prevOut := &txIn.PreviousOutPoint
+		entry := view.entries[*prevOut]
 		if entry == nil {
-			return AssertError(fmt.Sprintf("view missing input %v",
-				txIn.PreviousOutPoint))
+			return AssertError(fmt.Sprintf("view missing input %v", *prevOut))
 		}
 
 		// Only create the stxo details if requested.
 		if stxos != nil {
 			// Populate the stxo details using the utxo entry.
-			var stxo = spentTxOut{
-				amount:        entry.Amount(),
-				pkScript:      entry.PkScript(),
-				ticketMinOuts: entry.ticketMinOuts,
-				blockHeight:   uint32(entry.BlockHeight()),
-				blockIndex:    entry.BlockIndex(),
-				scriptVersion: entry.ScriptVersion(),
-				packedFlags: encodeFlags(entry.IsCoinBase(), entry.HasExpiry(),
-					entry.TransactionType()),
-			}
-
-			*stxos = append(*stxos, stxo)
+			*stxos = append(*stxos, utxoEntryToSpentTxOut(entry))
 		}
 
 		// Mark the entry as spent.  This is not done until after the relevant
 		// details have been accessed since spending it might clear the fields
 		// from memory in the future.
 		entry.Spend()
+
+		// Mark txouts that are spent by transaction inputs later in the regular
+		// transaction tree of the same block as zero conf spends.
+		if inFlightIdx, ok := inFlightTx[prevOut.Hash]; ok && blockIndex >
+			inFlightIdx {
+
+			entry.state |= utxoStateSpentByZeroConf
+		}
 	}
 
 	// Add the transaction's outputs as available utxos.
 	view.AddTxOuts(tx, blockHeight, blockIndex, isTreasuryEnabled)
 
+	// Keep track of in-flight transactions in order detect spends of earlier
+	// outputs by transactions later in the same block.
+	inFlightTx[*tx.Hash()] = blockIndex
+	return nil
+}
+
+// connectRegularTransactions updates the view by adding all new utxos created
+// by the transactions in the regular transaction tree of the block and marking
+// all utxos those same transactions spend as spent.  It also marks all earlier
+// utxos spent by transactions later in the tree as zero confirmation spends.
+//
+// When the 'stxos' argument is not nil, it will be updated to append an entry
+// for each spent txout.  An error will be returned if the view does not contain
+// the required utxos.
+func (view *UtxoViewpoint) connectRegularTransactions(block *dcrutil.Block,
+	stxos *[]spentTxOut, isTreasuryEnabled bool) error {
+
+	// Connect all of the transactions in the regular transaction tree.
+	regularTxns := block.Transactions()
+	inFlightTx := make(map[chainhash.Hash]uint32, len(regularTxns))
+	for i, tx := range regularTxns {
+		err := view.connectRegularTransaction(tx, block.Height(), uint32(i),
+			inFlightTx, stxos, isTreasuryEnabled)
+		if err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -300,11 +420,20 @@ func (view *UtxoViewpoint) disconnectTransactions(block *dcrutil.Block,
 	// disconnecting stake transactions.
 	stxoIdx := len(stxos) - 1
 	transactions := block.Transactions()
+	numSpentRegularOutputs := countSpentRegularOutputs(block)
 	if stakeTree {
-		stxoIdx = len(stxos) - countSpentRegularOutputs(block) - 1
+		stxoIdx = len(stxos) - numSpentRegularOutputs - 1
 		transactions = block.STransactions()
 	}
 
+	// Create a map to keep track of all in-flight spends by the regular
+	// transaction tree in order detect spends of earlier outputs by
+	// transactions later in the same block.
+	var spendsInFlight map[wire.OutPoint]int
+	if !stakeTree {
+		spendsInFlight = make(map[wire.OutPoint]int, numSpentRegularOutputs)
+	}
+
 	for txIdx := len(transactions) - 1; txIdx > -1; txIdx-- {
 		tx := transactions[txIdx]
 		txHash := tx.Hash()
@@ -315,12 +444,11 @@ func (view *UtxoViewpoint) disconnectTransactions(block *dcrutil.Block,
 		}
 		isVote := txType == stake.TxTypeSSGen
 
-		var isTreasuryBase, isTSpend bool
-
+		var isTreasuryBase, isTreasurySpend bool
 		if isTreasuryEnabled {
-			isTSpend = txType == stake.TxTypeTSpend && stakeTree
-			isTreasuryBase = txType == stake.TxTypeTreasuryBase &&
-				stakeTree && txIdx == 0
+			isTreasurySpend = txType == stake.TxTypeTSpend && stakeTree
+			isTreasuryBase = txType == stake.TxTypeTreasuryBase && stakeTree &&
+				txIdx == 0
 		}
 
 		tree := wire.TxTreeRegular
@@ -384,13 +512,21 @@ func (view *UtxoViewpoint) disconnectTransactions(block *dcrutil.Block,
 			}
 
 			entry.Spend()
+
+			// Mark txouts that are spent by transaction inputs later in the
+			// same block as zero conf spends.
+			if inFlightIdx, ok := spendsInFlight[outpoint]; ok &&
+				txIdx < inFlightIdx {
+
+				entry.state |= utxoStateSpentByZeroConf
+			}
 		}
 
 		// Loop backwards through all of the transaction inputs (except for the
-		// coinbase and treasurybase which have no inputs) and unspend the
-		// referenced txos.  This is necessary to match the order of the spent
-		// txout entries.
-		if isCoinBase || isTreasuryBase || isTSpend {
+		// coinbase, treasurybase, and treasury spends which have no inputs) and
+		// unspend the referenced txos.  This is necessary to match the order of
+		// the spent txout entries.
+		if isCoinBase || isTreasuryBase || isTreasurySpend {
 			continue
 		}
 		for txInIdx := len(msgTx.TxIn) - 1; txInIdx > -1; txInIdx-- {
@@ -427,21 +563,21 @@ func (view *UtxoViewpoint) disconnectTransactions(block *dcrutil.Block,
 
 			// Mark the existing referenced transaction output as unspent and
 			// modified.
-			entry.state &^= utxoStateSpent
+			entry.state &^= utxoStateSpent | utxoStateSpentByZeroConf
 			entry.state |= utxoStateModified
+
+			// Keep track of all in-flight spends by the regular transaction
+			// tree in order detect spends of earlier outputs by transactions
+			// later in the same block.
+			if !stakeTree {
+				spendsInFlight[txIn.PreviousOutPoint] = txIdx
+			}
 		}
 	}
 
 	return nil
 }
 
-// RemoveEntry removes the given transaction output from the current state of
-// the view.  It will have no effect if the passed output does not exist in the
-// view.
-func (view *UtxoViewpoint) RemoveEntry(outpoint wire.OutPoint) {
-	delete(view.entries, outpoint)
-}
-
 // disconnectRegularTransactions updates the view by removing all utxos created
 // by the transactions in regular tree of the provided block and unspending all
 // of the txos spent by those same transactions by using the provided spent txo
@@ -520,8 +656,7 @@ func (view *UtxoViewpoint) connectBlock(db database.DB, block, parent *dcrutil.B
 	// Disconnect the transactions in the regular tree of the parent block if
 	// the passed block disapproves it.
 	if !headerApprovesParent(&block.MsgBlock().Header) {
-		err := view.disconnectDisapprovedBlock(db, parent,
-			isTreasuryEnabled)
+		err := view.disconnectDisapprovedBlock(db, parent, isTreasuryEnabled)
 		if err != nil {
 			return err
 		}
@@ -540,19 +675,13 @@ func (view *UtxoViewpoint) connectBlock(db database.DB, block, parent *dcrutil.B
 	// of transactions created in the regular tree of the same block, which is
 	// important since the regular tree may be disapproved by the subsequent
 	// block while the stake tree must remain valid.
-	for i, stx := range block.STransactions() {
-		err := view.connectTransaction(stx, block.Height(), uint32(i),
-			stxos, isTreasuryEnabled)
-		if err != nil {
-			return err
-		}
+	err = view.connectStakeTransactions(block, stxos, isTreasuryEnabled)
+	if err != nil {
+		return err
 	}
-	for i, tx := range block.Transactions() {
-		err := view.connectTransaction(tx, block.Height(), uint32(i),
-			stxos, isTreasuryEnabled)
-		if err != nil {
-			return err
-		}
+	err = view.connectRegularTransactions(block, stxos, isTreasuryEnabled)
+	if err != nil {
+		return err
 	}
 
 	// Update the best hash for view to include this block since all of its
@@ -617,12 +746,9 @@ func (view *UtxoViewpoint) disconnectBlock(block, parent *dcrutil.Block,
 			return err
 		}
 
-		for i, tx := range parent.Transactions() {
-			err := view.connectTransaction(tx, parent.Height(),
-				uint32(i), nil, isTreasuryEnabled)
-			if err != nil {
-				return err
-			}
+		err = view.connectRegularTransactions(parent, nil, isTreasuryEnabled)
+		if err != nil {
+			return err
 		}
 	}
 
@@ -637,8 +763,8 @@ func (view *UtxoViewpoint) Entries() map[wire.OutPoint]*UtxoEntry {
 	return view.entries
 }
 
-// ViewFilteredSet represents a set of utxos to fetch from the database that are
-// not already in a view.
+// ViewFilteredSet represents a set of utxos to fetch from the cache/backend
+// that are not already in a view.
 type ViewFilteredSet map[wire.OutPoint]struct{}
 
 // add conditionally adds the provided outpoint to the set if it does not
@@ -676,8 +802,8 @@ func (view *UtxoViewpoint) addRegularInputUtxos(block *dcrutil.Block,
 	// Build a map of in-flight transactions because some of the inputs in the
 	// regular transaction tree of this block could be referencing other
 	// transactions earlier in the block which are not yet in the chain.
-	txInFlight := map[chainhash.Hash]int{}
 	regularTxns := block.Transactions()
+	txInFlight := make(map[chainhash.Hash]int, len(regularTxns))
 	for i, tx := range regularTxns {
 		txInFlight[*tx.Hash()] = i
 	}
@@ -703,8 +829,8 @@ func (view *UtxoViewpoint) addRegularInputUtxos(block *dcrutil.Block,
 				i >= inFlightIndex {
 
 				originTx := regularTxns[inFlightIndex]
-				view.AddTxOuts(originTx, block.Height(),
-					uint32(inFlightIndex), isTreasuryEnabled)
+				view.AddTxOuts(originTx, block.Height(), uint32(inFlightIndex),
+					isTreasuryEnabled)
 				continue
 			}
 
@@ -754,9 +880,20 @@ func (view *UtxoViewpoint) fetchInputUtxos(block *dcrutil.Block,
 	// stake tree are not allowed to access outputs of transactions earlier in
 	// the block.  This applies to both transactions earlier in the stake tree
 	// as well as those in the regular tree.
-	for _, stx := range block.STransactions() {
-		isVote := stake.IsSSGen(stx.MsgTx())
-		for txInIdx, txIn := range stx.MsgTx().TxIn {
+	for txIdx, stx := range block.STransactions() {
+		// Ignore treasurybases and treasury spends since they have no inputs.
+		msgTx := stx.MsgTx()
+		shouldBeTreasuryBase := isTreasuryEnabled && txIdx == 0
+		if shouldBeTreasuryBase && stake.IsTreasuryBase(msgTx) {
+			continue
+		}
+		isTreasurySpend := isTreasuryEnabled && stake.IsTSpend(msgTx)
+		if isTreasurySpend {
+			continue
+		}
+
+		isVote := stake.IsSSGen(msgTx)
+		for txInIdx, txIn := range msgTx.TxIn {
 			// Ignore stakebase since it has no input.
 			if isVote && txInIdx == 0 {
 				continue
@@ -872,8 +1009,13 @@ func (b *BlockChain) FetchUtxoView(tx *dcrutil.Tx, includeRegularTxns bool) (*Ut
 		outpoint.Index = uint32(txOutIdx)
 		filteredSet.add(view, &outpoint)
 	}
-	if !standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled) {
-		isVote := stake.IsSSGen(msgTx)
+	// Ignore coinbases, treasurybases, and treasury spends since none of them
+	// have any inputs.
+	isCoinBase := standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled)
+	isTreasuryBase := txType == stake.TxTypeTreasuryBase
+	isTreasurySpend := txType == stake.TxTypeTSpend
+	if !isCoinBase && !isTreasuryBase && !isTreasurySpend {
+		isVote := txType == stake.TxTypeSSGen
 		for txInIdx, txIn := range msgTx.TxIn {
 			// Ignore stakebase since it has no input.
 			if isVote && txInIdx == 0 {
diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go
index d7bb21b2..9b5262fa 100644
--- a/internal/blockchain/validate.go
+++ b/internal/blockchain/validate.go
@@ -95,8 +95,8 @@ const (
 	// checkForDuplicateHashes checks for duplicate hashes when validating
 	// blocks.  Because of the rule inserting the height into the second (nonce)
 	// txOut, there should never be a duplicate transaction hash that overwrites
-	// another. However, because there is a 2^128 chance of a collision, the
-	// paranoid user may wish to turn this feature on.
+	// another. However, because there is a 1 in 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
@@ -863,10 +863,8 @@ func checkBlockSanity(block *dcrutil.Block, timeSource MedianTimeSource, flags B
 	// Do some preliminary checks on each regular transaction to ensure they
 	// are sane.
 	maxTxSize := uint64(chainParams.MaxTxSize)
-	transactions := block.Transactions()
-	for _, tx := range transactions {
-		msgTx := tx.MsgTx()
-		err := standalone.CheckTransactionSanity(msgTx, maxTxSize)
+	for _, tx := range msgBlock.Transactions {
+		err := standalone.CheckTransactionSanity(tx, maxTxSize)
 		if err != nil {
 			return standaloneToChainRuleError(err)
 		}
@@ -898,8 +896,9 @@ func checkBlockSanity(block *dcrutil.Block, timeSource MedianTimeSource, flags B
 
 	// Check for duplicate transactions.
 	existingTxHashes := make(map[chainhash.Hash]struct{})
+	regularTransactions := block.Transactions()
 	stakeTransactions := block.STransactions()
-	allTransactions := append(transactions, stakeTransactions...)
+	allTransactions := append(regularTransactions, stakeTransactions...)
 	for _, tx := range allTransactions {
 		hash := tx.Hash()
 		if _, exists := existingTxHashes[*hash]; exists {
@@ -3256,20 +3255,19 @@ func CountP2SHSigOps(tx *dcrutil.Tx, isCoinBaseTx bool, isStakeBaseTx bool, view
 // checkNumSigOps Checks the number of P2SH signature operations to make
 // sure they don't overflow the limits.  It takes a cumulative number of sig
 // ops as an argument and increments will each call.
-// TxTree true == Regular, false == Stake
-func checkNumSigOps(tx *dcrutil.Tx, view *UtxoViewpoint, index int, txTree bool, cumulativeSigOps int, isTreasuryEnabled bool) (int, error) {
+func checkNumSigOps(tx *dcrutil.Tx, view *UtxoViewpoint, index int, stakeTree bool, cumulativeSigOps int, isTreasuryEnabled bool) (int, error) {
 	msgTx := tx.MsgTx()
 	isSSGen := stake.IsSSGen(msgTx)
-	numsigOps := CountSigOps(tx, (index == 0) && txTree, isSSGen,
-		isTreasuryEnabled)
+	isCoinbaseTx := (index == 0) && !stakeTree
+	numsigOps := CountSigOps(tx, isCoinbaseTx, isSSGen, isTreasuryEnabled)
 
 	// Since the first (and only the first) transaction has already been
 	// verified to be a coinbase transaction, use (i == 0) && TxTree as an
 	// optimization for the flag to countP2SHSigOps for whether or not the
 	// transaction is a coinbase transaction rather than having to do a
 	// full coinbase check again.
-	numP2SHSigOps, err := CountP2SHSigOps(tx, (index == 0) && txTree,
-		isSSGen, view, isTreasuryEnabled)
+	numP2SHSigOps, err := CountP2SHSigOps(tx, isCoinbaseTx, isSSGen, view,
+		isTreasuryEnabled)
 	if err != nil {
 		log.Tracef("CountP2SHSigOps failed; error returned %v", err)
 		return 0, err
@@ -3372,7 +3370,7 @@ func getStakeBaseAmounts(txs []*dcrutil.Tx, view *UtxoViewpoint, isTreasuryEnabl
 }
 
 // getStakeTreeFees determines the amount of fees for in the stake tx tree of
-// some node given a transaction store.
+// some node given a utxo view.
 func getStakeTreeFees(subsidyCache *standalone.SubsidyCache, height int64,
 	txs []*dcrutil.Tx, view *UtxoViewpoint, isTreasuryEnabled,
 	isSubsidySplitEnabled bool) (dcrutil.Amount, error) {
@@ -3382,11 +3380,8 @@ func getStakeTreeFees(subsidyCache *standalone.SubsidyCache, height int64,
 	for _, tx := range txs {
 		msgTx := tx.MsgTx()
 		isSSGen := stake.IsSSGen(msgTx)
-		var isTSpend, isTreasuryBase bool
-		if isTreasuryEnabled {
-			isTSpend = stake.IsTSpend(msgTx)
-			isTreasuryBase = stake.IsTreasuryBase(msgTx)
-		}
+		isTreasuryBase := isTreasuryEnabled && stake.IsTreasuryBase(msgTx)
+		isTreasurySpend := isTreasuryEnabled && stake.IsTSpend(msgTx)
 
 		for i, in := range msgTx.TxIn {
 			// Ignore stakebases.
@@ -3394,13 +3389,9 @@ func getStakeTreeFees(subsidyCache *standalone.SubsidyCache, height int64,
 				continue
 			}
 
-			// Ignore TSpend.
-			if isTSpend && i == 0 {
-				continue
-			}
-
-			// Ignore treasury base.
-			if isTreasuryBase && i == 0 {
+			// Ignore treasury spends and treasurybases since they have no
+			// inputs.
+			if isTreasuryBase || isTreasurySpend {
 				continue
 			}
 
@@ -3431,7 +3422,7 @@ func getStakeTreeFees(subsidyCache *standalone.SubsidyCache, height int64,
 				isSubsidySplitEnabled)
 		}
 
-		if isTSpend {
+		if isTreasurySpend {
 			totalOutputs -= msgTx.TxIn[0].ValueIn
 		}
 
@@ -3450,10 +3441,10 @@ func getStakeTreeFees(subsidyCache *standalone.SubsidyCache, height int64,
 }
 
 // checkTransactionsAndConnect is the local function used to check the
-// transaction inputs for a transaction list given a predetermined TxStore.
+// transaction inputs for a transaction list given a predetermined utxo view.
 // After ensuring the transaction is valid, the transaction is connected to the
-// UTXO viewpoint.  TxTree true == Regular, false == Stake
-func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, node *blockNode, txs []*dcrutil.Tx, view *UtxoViewpoint, stxos *[]spentTxOut, txTree bool) error {
+// utxo view.
+func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, node *blockNode, txs []*dcrutil.Tx, view *UtxoViewpoint, stxos *[]spentTxOut, stakeTree bool) error {
 	isTreasuryEnabled, err := b.isTreasuryAgendaActive(node.parent)
 	if err != nil {
 		return err
@@ -3480,6 +3471,10 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, node
 	// expensive (though still relatively cheap as compared to running the
 	// scripts) checks against all the inputs when the signature operations
 	// are out of bounds.
+	var inFlightRegularTx map[chainhash.Hash]uint32
+	if !stakeTree {
+		inFlightRegularTx = make(map[chainhash.Hash]uint32, len(txs))
+	}
 	totalFees := int64(inputFees) // Stake tx tree carry forward
 	prevHeader := node.parent.Header()
 	var cumulativeSigOps int
@@ -3487,21 +3482,27 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, node
 		// Ensure that the number of signature operations is not beyond
 		// the consensus limit.
 		var err error
-		cumulativeSigOps, err = checkNumSigOps(tx, view, idx, txTree,
+		cumulativeSigOps, err = checkNumSigOps(tx, view, idx, stakeTree,
 			cumulativeSigOps, isTreasuryEnabled)
 		if err != nil {
 			return err
 		}
 
-		// This step modifies the txStore and marks the tx outs used
-		// spent, so be aware of this.
-		txFee, err := CheckTransactionInputs(b.subsidyCache, tx,
-			node.height, view, true, /* check fraud proofs */
-			b.chainParams, &prevHeader, isTreasuryEnabled,
-			isAutoRevocationsEnabled, isSubsidySplitEnabled)
+		// Perform a series of checks on the inputs to the transaction to ensure
+		// they are valid and calculate the total fees for it.
+		//
+		// An example of some of the checks include verifying all inputs exist,
+		// ensuring the coinbase seasoning requirements are met, detecting
+		// double spends, validating all values and fees are in the legal range,
+		// the total output amount doesn't exceed the input amount, and
+		// verifying the signatures to prove the spender was the owner of the
+		// coins and therefore allowed to spend them.
+		const checkFraudProof = true
+		txFee, err := CheckTransactionInputs(b.subsidyCache, tx, node.height,
+			view, checkFraudProof, b.chainParams, &prevHeader,
+			isTreasuryEnabled, isAutoRevocationsEnabled, isSubsidySplitEnabled)
 		if err != nil {
-			log.Tracef("CheckTransactionInputs failed; error "+
-				"returned: %v", err)
+			log.Tracef("CheckTransactionInputs failed; error returned: %v", err)
 			return err
 		}
 
@@ -3514,12 +3515,28 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, node
 				"overflows accumulator")
 		}
 
-		// Connect the transaction to the UTXO viewpoint, so that in
-		// flight transactions may correctly validate.
-		err = view.connectTransaction(tx, node.height, uint32(idx),
-			stxos, isTreasuryEnabled)
-		if err != nil {
-			return err
+		// Update the view to mark all utxos spent by the transaction as spent
+		// and add all of the outputs for this transaction which are not
+		// provably unspendable as available utxos.
+		//
+		// For the regular transaction tree, keep track of in-flight
+		// transactions in order detect spends of earlier outputs by
+		// transactions later in the same block.
+		//
+		// Also, update the passed spent txos slice to contain an entry for each
+		// output the transaction spends.
+		if !stakeTree {
+			err := view.connectRegularTransaction(tx, node.height, uint32(idx),
+				inFlightRegularTx, stxos, isTreasuryEnabled)
+			if err != nil {
+				return err
+			}
+		} else {
+			err := view.connectStakeTransaction(tx, node.height, uint32(idx),
+				stxos, isTreasuryEnabled)
+			if err != nil {
+				return err
+			}
 		}
 	}
 
@@ -3528,7 +3545,7 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, node
 	// mining the block.  It is safe to ignore overflow and out of range
 	// errors here because those error conditions would have already been
 	// caught by the transaction sanity checks.
-	if txTree { //TxTreeRegular
+	if !stakeTree { //TxTreeRegular
 		// Apply penalty to fees if we're at stake validation height.
 		if node.height >= b.chainParams.StakeValidationHeight {
 			totalFees *= int64(node.voters)
@@ -3859,8 +3876,7 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B
 	// Disconnect all of the transactions in the regular transaction tree of
 	// the parent if the block being checked votes against it.
 	if node.height > 1 && !voteBitsApproveParent(node.voteBits) {
-		err = view.disconnectDisapprovedBlock(b.db, parent,
-			isTreasuryEnabled)
+		err = view.disconnectDisapprovedBlock(b.db, parent, isTreasuryEnabled)
 		if err != nil {
 			return err
 		}
@@ -3870,7 +3886,7 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B
 	// that 'overwrite' older transactions which are not fully spent.
 	err = b.checkDupTxs(block.STransactions(), view, wire.TxTreeStake)
 	if err != nil {
-		log.Tracef("checkDupTxs failed for cur TxTreeStake: %v", err)
+		log.Tracef("checkDupTxs failed for stake tree: %v", err)
 		return err
 	}
 
@@ -3884,11 +3900,11 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B
 		return err
 	}
 
+	const stakeTreeTrue = true
 	err = b.checkTransactionsAndConnect(0, node, block.STransactions(),
-		view, stxos, false)
+		view, stxos, stakeTreeTrue)
 	if err != nil {
-		log.Tracef("checkTransactionsAndConnect failed for "+
-			"TxTreeStake: %v", err)
+		log.Tracef("checkTransactionsAndConnect failed for stake tree: %v", err)
 		return err
 	}
 
@@ -3899,7 +3915,7 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B
 	stakeTreeFees, err := getStakeTreeFees(b.subsidyCache, node.height,
 		block.STransactions(), view, isTreasuryEnabled, isSubsidySplitEnabled)
 	if err != nil {
-		log.Tracef("getStakeTreeFees failed for TxTreeStake: %v", err)
+		log.Tracef("getStakeTreeFees failed for stake tree: %v", err)
 		return err
 	}
 
@@ -3947,15 +3963,16 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B
 	// that 'overwrite' older transactions which are not fully spent.
 	err = b.checkDupTxs(block.Transactions(), view, wire.TxTreeRegular)
 	if err != nil {
-		log.Tracef("checkDupTxs failed for cur TxTreeRegular: %v", err)
+		log.Tracef("checkDupTxs failed for cur regular tree: %v", err)
 		return err
 	}
 
+	const stakeTreeFalse = false
 	err = b.checkTransactionsAndConnect(stakeTreeFees, node,
-		block.Transactions(), view, stxos, true)
+		block.Transactions(), view, stxos, stakeTreeFalse)
 	if err != nil {
-		log.Tracef("checkTransactionsAndConnect failed for cur "+
-			"TxTreeRegular: %v", err)
+		log.Tracef("checkTransactionsAndConnect failed for regular tree: %v",
+			err)
 		return err
 	}
 


Test patch:
diff --git a/internal/blockchain/common_test.go b/internal/blockchain/common_test.go
index 00fbe80d..0fd78bce 100644
--- a/internal/blockchain/common_test.go
+++ b/internal/blockchain/common_test.go
@@ -902,8 +902,10 @@ func (g *chaingenHarness) ExpectUtxoSetState(blockName string) {
 		lastFlushHash:   block.BlockHash(),
 	}
 	if !reflect.DeepEqual(gotState, wantState) {
-		g.t.Fatalf("mismatched utxo set state:\nwant: %+v\n got: %+v\n", wantState,
-			gotState)
+		g.t.Fatalf("mismatched utxo set state:\nwant: hash %s, height %d\n "+
+			"got: hash %s, height %d\n", wantState.lastFlushHash,
+			wantState.lastFlushHeight, gotState.lastFlushHash,
+			gotState.lastFlushHeight)
 	}
 }
 
diff --git a/internal/blockchain/indexers/txindex_test.go b/internal/blockchain/indexers/txindex_test.go
index 084023b7..b11088b4 100644
--- a/internal/blockchain/indexers/txindex_test.go
+++ b/internal/blockchain/indexers/txindex_test.go
@@ -104,8 +104,7 @@ func (tc *testChain) MainChainHasBlock(hash *chainhash.Hash) bool {
 	return ok
 }
 
-// Best returns the height and block hash of the the current
-// chain tip.
+// Best returns the height and block hash of the current chain tip.
 func (tc *testChain) Best() (int64, *chainhash.Hash) {
 	tc.mtx.Lock()
 	defer tc.mtx.Unlock()
diff --git a/internal/blockchain/utxocache_test.go b/internal/blockchain/utxocache_test.go
index e38ed5d4..3e7aecdd 100644
--- a/internal/blockchain/utxocache_test.go
+++ b/internal/blockchain/utxocache_test.go
@@ -6,6 +6,7 @@ package blockchain
 
 import (
 	"context"
+	"errors"
 	"reflect"
 	"testing"
 	"time"
@@ -14,6 +15,7 @@ import (
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/chaincfg/v3"
 	"github.com/decred/dcrd/database/v3"
+	"github.com/decred/dcrd/dcrutil/v4"
 	"github.com/decred/dcrd/wire"
 )
 
@@ -147,12 +149,62 @@ func entry85314() *UtxoEntry {
 	}
 }
 
+// testEntryStates houses a test utxo entry along with various spent, modified,
+// and fresh states for conenient use throughout the tests.
+type testEntryStates struct {
+	unmodified              *UtxoEntry
+	unmodifiedSpent         *UtxoEntry
+	unmodifiedFresh         *UtxoEntry
+	unmodifiedSpentFresh    *UtxoEntry
+	modified                *UtxoEntry
+	modifiedSpent           *UtxoEntry
+	modifiedFresh           *UtxoEntry
+	modifiedSpentFresh      *UtxoEntry
+	modifiedSpentByZeroConf *UtxoEntry
+}
+
+// makeEntryStates creates combinations of the passed test utxo entry in various
+// states for convenient use in the tests below.
+func makeEntryStates(entry *UtxoEntry) testEntryStates {
+	unmodified := entry.Clone()
+	unmodifiedSpent := unmodified.Clone()
+	unmodifiedSpent.state |= utxoStateSpent
+	unmodifiedFresh := unmodified.Clone()
+	unmodifiedFresh.state |= utxoStateFresh
+	unmodifiedSpentFresh := unmodifiedSpent.Clone()
+	unmodifiedSpentFresh.state |= utxoStateFresh
+
+	modified := entry.Clone()
+	modified.state |= utxoStateModified
+	modifiedSpent := modified.Clone()
+	modifiedSpent.state |= utxoStateSpent
+	modifiedFresh := modified.Clone()
+	modifiedFresh.state |= utxoStateFresh
+	modifiedSpentFresh := modifiedSpent.Clone()
+	modifiedSpentFresh.state |= utxoStateFresh
+	modifiedSpentByZeroConf := modifiedSpent.Clone()
+	modifiedSpentByZeroConf.state |= utxoStateSpentByZeroConf
+
+	return testEntryStates{
+		unmodified:              unmodified,
+		unmodifiedSpent:         unmodifiedSpent,
+		unmodifiedFresh:         unmodifiedFresh,
+		unmodifiedSpentFresh:    unmodifiedSpentFresh,
+		modified:                modified,
+		modifiedSpent:           modifiedSpent,
+		modifiedFresh:           modifiedFresh,
+		modifiedSpentFresh:      modifiedSpentFresh,
+		modifiedSpentByZeroConf: modifiedSpentByZeroConf,
+	}
+}
+
 // testUtxoCache provides a mock utxo cache by implementing the UtxoCacher
 // interface.  It allows for toggling flushing on and off to more easily
 // simulate various scenarios.
 type testUtxoCache struct {
 	*UtxoCache
-	disableFlush bool
+	disableFlush bool // disable flushing unconditionally (even if forced)
+	forceFlush   bool // force flushing even when not requested by caller
 }
 
 // MaybeFlush conditionally flushes the cache to the backend.  If the disable
@@ -166,15 +218,21 @@ func (c *testUtxoCache) MaybeFlush(bestHash *chainhash.Hash, bestHeight uint32,
 		return nil
 	}
 
+	// Force a flush if either the flag provided by the caller is true or the
+	// test cache overrides it.
+	forceFlush = forceFlush || c.forceFlush
 	return c.UtxoCache.MaybeFlush(bestHash, bestHeight, forceFlush, logFlush)
 }
 
 // newTestUtxoCache returns a testUtxoCache instance using the provided
 // configuration details.
 func newTestUtxoCache(config *UtxoCacheConfig) *testUtxoCache {
-	return &testUtxoCache{
-		UtxoCache: NewUtxoCache(config),
+	utxoCache := NewUtxoCache(config)
+	c := &testUtxoCache{
+		UtxoCache: utxoCache,
 	}
+	utxoCache.maybeFlushFn = c.MaybeFlush
+	return c
 }
 
 // Ensure testUtxoCache implements the UtxoCacher interface.
@@ -193,9 +251,10 @@ func createTestUtxoCache(t *testing.T, entries map[wire.OutPoint]*UtxoEntry) *Ut
 		// Add the entry to the cache.  The entry is cloned before being added
 		// so that any modifications that the cache makes to the entry are not
 		// reflected in the provided test entry.
-		err := utxoCache.AddEntry(outpoint, entry.Clone())
-		if err != nil {
-			t.Fatalf("unexpected error when adding entry: %v", err)
+		if entry != nil {
+			utxoCache.addEntry(outpoint, entry.Clone())
+		} else {
+			utxoCache.entries[outpoint] = nil
 		}
 
 		// Set the state of the cached entries based on the provided entries.
@@ -300,81 +359,74 @@ func TestAddEntry(t *testing.T) {
 	t.Parallel()
 
 	// Create test entries to be used throughout the tests.
-	outpoint := outpoint299()
-	entry := entry299()
-	entryModified := entry.Clone()
-	entryModified.amount++
-	entryModified.state |= utxoStateModified
-	entryFresh := entry.Clone()
-	entryFresh.state |= utxoStateModified | utxoStateFresh
+	outpoint, origEntry := outpoint299(), makeEntryStates(entry299())
+	mutatedEntry := makeEntryStates(entry299())
+	mutatedEntry.modified.amount++
+	mutatedEntry.modifiedFresh.amount++
 
+	type entriesMap map[wire.OutPoint]*UtxoEntry
 	tests := []struct {
-		name            string
-		existingEntries map[wire.OutPoint]*UtxoEntry
-		outpoint        wire.OutPoint
-		entry           *UtxoEntry
-		wantEntry       *UtxoEntry
+		name          string        // test description
+		cachedEntries entriesMap    // existing cache entires
+		outpoint      wire.OutPoint // outpoint for entry to add/update
+		entry         *UtxoEntry    // entry to add/update
+		wantEntry     *UtxoEntry    // expected entry in updated cache
+		wantCacheSize uint64        // expected size of updated cache
 	}{{
-		name:      "add an entry that does not already exist in the cache",
-		outpoint:  outpoint,
-		entry:     entry,
-		wantEntry: entryFresh,
+		name:          "entry not in cache adds modified fresh entry",
+		outpoint:      outpoint,
+		entry:         origEntry.unmodified,
+		wantEntry:     origEntry.modifiedFresh,
+		wantCacheSize: origEntry.modifiedFresh.size(),
 	}, {
-		name: "add an entry that overwrites an existing entry",
-		existingEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint: entry,
-		},
-		outpoint:  outpoint,
-		entry:     entryModified,
-		wantEntry: entryModified,
+		name:          "existing !fresh cache entry w/ !fresh entry stays !fresh",
+		cachedEntries: entriesMap{outpoint: origEntry.unmodified},
+		outpoint:      outpoint,
+		entry:         mutatedEntry.modified,
+		wantEntry:     mutatedEntry.modified,
+		wantCacheSize: mutatedEntry.modified.size(),
+	}, {
+		name:          "existing !fresh cache entry with fresh entry stays !fresh",
+		cachedEntries: entriesMap{outpoint: origEntry.unmodified},
+		outpoint:      outpoint,
+		entry:         mutatedEntry.modifiedFresh,
+		wantEntry:     mutatedEntry.modified,
+		wantCacheSize: mutatedEntry.modified.size(),
+	}, {
+		name:          "existing fresh cache entry with !fresh entry stays fresh",
+		cachedEntries: entriesMap{outpoint: origEntry.modifiedFresh},
+		outpoint:      outpoint,
+		entry:         mutatedEntry.modified,
+		wantEntry:     mutatedEntry.modifiedFresh,
+		wantCacheSize: mutatedEntry.modifiedFresh.size(),
+	}, {
+		name:          "existing fresh cache entry with fresh entry stays fresh",
+		cachedEntries: entriesMap{outpoint: origEntry.modifiedFresh},
+		outpoint:      outpoint,
+		entry:         mutatedEntry.modifiedFresh,
+		wantEntry:     mutatedEntry.modifiedFresh,
+		wantCacheSize: mutatedEntry.modifiedFresh.size(),
 	}}
 
 	for _, test := range tests {
 		// Create a utxo cache with the existing entries specified by the test.
-		utxoCache := createTestUtxoCache(t, test.existingEntries)
-		wantTotalEntrySize := utxoCache.totalEntrySize
-
-		// Attempt to get an existing entry from the cache.  If it exists,
-		// subtract its size from the expected total entry size since it will be
-		// overwritten.
-		existingEntry := utxoCache.entries[test.outpoint]
-		if existingEntry != nil {
-			wantTotalEntrySize -= test.entry.size()
-		}
-
-		// Add the entry specified by the test.
-		err := utxoCache.AddEntry(test.outpoint, test.entry)
-		if err != nil {
-			t.Fatalf("%q: unexpected error when adding entry: %v", test.name,
-				err)
-		}
-		wantTotalEntrySize += test.entry.size()
-
-		// Attempt to get the added entry from the cache.
-		cachedEntry := utxoCache.entries[test.outpoint]
+		utxoCache := createTestUtxoCache(t, test.cachedEntries)
 
-		// Validate that the added entry exists in the cache.
-		if cachedEntry == nil {
-			t.Fatalf("%q: expected entry for outpoint %v to exist in the cache",
-				test.name, test.outpoint)
-		}
+		// Add/update the entry specified by the test.
+		utxoCache.addEntry(test.outpoint, test.entry.Clone())
 
-		// Validate that the entry is marked as modified.
-		if !cachedEntry.isModified() {
-			t.Fatalf("%q: unexpected modified flag -- got false, want true",
-				test.name)
-		}
-
-		// Validate that the cached entry matches the expected entry.
-		if !reflect.DeepEqual(cachedEntry, test.wantEntry) {
-			t.Fatalf("%q: mismatched cached entry:\nwant: %+v\n got: %+v\n",
-				test.name, test.wantEntry, cachedEntry)
+		// Get the entry associated with the output from the cache and ensure it
+		// matches the expected one.
+		gotEntry := utxoCache.entries[test.outpoint]
+		if !reflect.DeepEqual(gotEntry, test.wantEntry) {
+			t.Fatalf("%q: unexpected entry after spend -- got: %+v, want %+v",
+				test.name, gotEntry, test.wantEntry)
 		}
 
 		// Validate that the total entry size was updated as expected.
-		if utxoCache.totalEntrySize != wantTotalEntrySize {
+		if utxoCache.totalEntrySize != test.wantCacheSize {
 			t.Fatalf("%q: unexpected total entry size -- got %v, want %v",
-				test.name, utxoCache.totalEntrySize, wantTotalEntrySize)
+				test.name, utxoCache.totalEntrySize, test.wantCacheSize)
 		}
 	}
 }
@@ -384,91 +436,92 @@ func TestAddEntry(t *testing.T) {
 func TestSpendEntry(t *testing.T) {
 	t.Parallel()
 
+	// Create a test backend.
+	backend := createTestUtxoBackend(t)
+
 	// Create test entries to be used throughout the tests.
-	outpoint := outpoint299()
-	entry := entry299()
-	entryFresh := entry.Clone()
-	entryFresh.state |= utxoStateModified | utxoStateFresh
-	entrySpent := entry.Clone()
-	entrySpent.Spend()
+	outpoint, entry := outpoint299(), makeEntryStates(entry299())
 
+	type entriesMap map[wire.OutPoint]*UtxoEntry
 	tests := []struct {
-		name            string
-		existingEntries map[wire.OutPoint]*UtxoEntry
-		outpoint        wire.OutPoint
-		entry           *UtxoEntry
+		name           string        // test description
+		cachedEntries  entriesMap    // existing cache entries
+		backendEntries entriesMap    // existing backend entries
+		outpoint       wire.OutPoint // outpoint for the entry to spend
+		wantEntry      *UtxoEntry    // expected entry in cache after spend
+		wantCacheSize  uint64        // expected size of the cache after spend
+		err            error         // expected error
 	}{{
-		name:     "spend an entry that does not exist in the cache",
-		outpoint: outpoint,
-		entry:    entry,
+		name:          "spending cache entry already marked spent asserts",
+		cachedEntries: entriesMap{outpoint: entry.modifiedSpent},
+		outpoint:      outpoint,
+		err:           AssertError(""),
 	}, {
-		name: "spend an entry that exists in the cache but is already spent",
-		existingEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint: entrySpent,
-		},
-		outpoint: outpoint,
-		entry:    entrySpent,
+		name:          "spending nil (aka pruned) cache entry is a noop",
+		cachedEntries: entriesMap{outpoint: nil},
+		outpoint:      outpoint,
+		wantEntry:     nil,
+		wantCacheSize: 0,
 	}, {
-		name: "spend an entry that exists in the cache and is fresh",
-		existingEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint: entryFresh,
-		},
-		outpoint: outpoint,
-		entry:    entryFresh,
+		name:          "spending entry not in cache queries backend (no utxo)",
+		outpoint:      outpoint,
+		wantEntry:     nil,
+		wantCacheSize: 0,
 	}, {
-		name: "spend an entry that exists in the cache and is not fresh",
-		existingEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint: entry,
-		},
-		outpoint: outpoint,
-		entry:    entry,
+		name:           "spending entry not in cache queries backend (with utxo)",
+		outpoint:       outpoint,
+		backendEntries: entriesMap{outpoint: entry.modified},
+		wantEntry:      entry.modifiedSpent,
+		wantCacheSize:  entry.modifiedSpent.size(),
+	}, {
+		name:          "spending cache entry marked fresh makes it nil",
+		cachedEntries: entriesMap{outpoint: entry.modifiedFresh},
+		outpoint:      outpoint,
+		wantEntry:     nil,
+		wantCacheSize: 0,
+	}, {
+		name:          "spending cache entry not marked fresh makes it spent",
+		cachedEntries: entriesMap{outpoint: entry.modified},
+		outpoint:      outpoint,
+		wantEntry:     entry.modifiedSpent,
+		wantCacheSize: entry.modifiedSpent.size(),
 	}}
 
 	for _, test := range tests {
 		// Create a utxo cache with the existing entries specified by the test.
-		utxoCache := createTestUtxoCache(t, test.existingEntries)
-		wantTotalEntrySize := utxoCache.totalEntrySize
+		utxoCache := createTestUtxoCache(t, test.cachedEntries)
+		utxoCache.backend = backend
 
-		// Attempt to get an existing entry from the cache.
-		entry := utxoCache.entries[test.outpoint]
-		var entryAlreadySpent bool
-		if entry != nil {
-			entryAlreadySpent = entry.IsSpent()
+		// Add entries specified by the test to the test backend.
+		err := backend.PutUtxos(test.backendEntries, &UtxoSetState{})
+		if err != nil {
+			t.Fatalf("%q: unexpected error adding entries to test backend: %v",
+				test.name, err)
 		}
 
-		// Spend the entry specified by the test.
-		utxoCache.SpendEntry(test.outpoint)
-
-		// If the existing entry was nil or spent, continue as there is nothing
-		// else to validate.
-		if entry == nil || entryAlreadySpent {
+		// Spend the entry specified by the test and ensure the error matches
+		// the expected one.
+		err = utxoCache.spendEntry(test.outpoint)
+		if !errors.Is(err, test.err) {
+			t.Fatalf("%q: unexpected error spending outpoint %v -- got %v, "+
+				"want %v (%[4]T)", test.name, test.outpoint, err, test.err)
+		}
+		if test.err != nil {
 			continue
 		}
 
-		// If the entry is fresh, validate that it was removed from the cache
-		// when spent.
-		if entry.isFresh() {
-			wantTotalEntrySize -= test.entry.size()
-			if utxoCache.entries[test.outpoint] != nil {
-				t.Fatalf("%q: entry for outpoint %v was not removed from the "+
-					"cache", test.name, test.outpoint)
-			}
+		// Get the entry associated with the output from the cache and ensure it
+		// matches the expected one.
+		gotEntry := utxoCache.entries[test.outpoint]
+		if !reflect.DeepEqual(gotEntry, test.wantEntry) {
+			t.Fatalf("%q: unexpected entry after spend -- got: %+v, want %+v",
+				test.name, gotEntry, test.wantEntry)
 		}
 
 		// Validate that the total entry size was updated as expected.
-		if utxoCache.totalEntrySize != wantTotalEntrySize {
+		if utxoCache.totalEntrySize != test.wantCacheSize {
 			t.Fatalf("%q: unexpected total entry size -- got %v, want %v",
-				test.name, utxoCache.totalEntrySize, wantTotalEntrySize)
-		}
-
-		// If entry is not fresh, validate that it still exists in the cache and
-		// is now marked as spent.
-		if !entry.isFresh() {
-			cachedEntry := utxoCache.entries[test.outpoint]
-			if cachedEntry == nil || !cachedEntry.IsSpent() {
-				t.Fatalf("%q: expected entry for outpoint %v to exist in the "+
-					"cache and be marked spent", test.name, test.outpoint)
-			}
+				test.name, utxoCache.totalEntrySize, test.wantCacheSize)
 		}
 	}
 }
@@ -482,10 +535,7 @@ func TestFetchEntry(t *testing.T) {
 	backend := createTestUtxoBackend(t)
 
 	// Create test entries to be used throughout the tests.
-	outpoint := outpoint299()
-	entry := entry299()
-	entryModified := entry.Clone()
-	entryModified.state |= utxoStateModified
+	outpoint, entry := outpoint299(), makeEntryStates(entry299())
 
 	tests := []struct {
 		name           string
@@ -500,18 +550,18 @@ func TestFetchEntry(t *testing.T) {
 	}, {
 		name: "entry is in the cache",
 		cachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint: entry,
+			outpoint: entry.unmodified,
 		},
 		outpoint:  outpoint,
 		cacheHit:  true,
-		wantEntry: entry,
+		wantEntry: entry.unmodified,
 	}, {
 		name: "entry is not in the cache but is in the backend",
 		backendEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint: entryModified,
+			outpoint: entry.modified,
 		},
 		outpoint:  outpoint,
-		wantEntry: entry,
+		wantEntry: entry.unmodified,
 	}}
 
 	for _, test := range tests {
@@ -528,15 +578,15 @@ func TestFetchEntry(t *testing.T) {
 		}
 
 		// Attempt to fetch the entry for the outpoint specified by the test.
-		entry, err = utxoCache.FetchEntry(test.outpoint)
+		gotEntry, err := utxoCache.FetchEntry(test.outpoint)
 		if err != nil {
 			t.Fatalf("%q: unexpected error fetching entry: %v", test.name, err)
 		}
 
 		// Ensure that the fetched entry matches the expected entry.
-		if !reflect.DeepEqual(entry, test.wantEntry) {
+		if !reflect.DeepEqual(gotEntry, test.wantEntry) {
 			t.Fatalf("%q: mismatched entry:\nwant: %+v\n got: %+v\n", test.name,
-				test.wantEntry, entry)
+				test.wantEntry, gotEntry)
 		}
 
 		// Ensure that the entry is now cached.
@@ -577,12 +627,8 @@ func TestFetchEntries(t *testing.T) {
 
 	// Create test entries to be used throughout the tests.
 	outpoint299 := outpoint299()
-	outpoint1100 := outpoint1100()
-	entry1100 := entry1100()
-	outpoint1200 := outpoint1200()
-	entry1200 := entry1200()
-	entry1200Modified := entry1200.Clone()
-	entry1200Modified.state |= utxoStateModified
+	outpoint1100, entry1100 := outpoint1100(), makeEntryStates(entry1100())
+	outpoint1200, entry1200 := outpoint1200(), makeEntryStates(entry1200())
 
 	tests := []struct {
 		name           string
@@ -593,10 +639,10 @@ func TestFetchEntries(t *testing.T) {
 	}{{
 		name: "entries are fetched from the cache and the backend",
 		cachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1100: entry1100,
+			outpoint1100: entry1100.unmodified,
 		},
 		backendEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1200: entry1200Modified,
+			outpoint1200: entry1200.modified,
 		},
 		filteredSet: ViewFilteredSet{
 			outpoint299:  struct{}{},
@@ -605,8 +651,8 @@ func TestFetchEntries(t *testing.T) {
 		},
 		wantEntries: map[wire.OutPoint]*UtxoEntry{
 			outpoint299:  nil,
-			outpoint1100: entry1100,
-			outpoint1200: entry1200,
+			outpoint1100: entry1100.unmodified,
+			outpoint1200: entry1200.unmodified,
 		},
 	}}
 
@@ -643,66 +689,284 @@ func TestFetchEntries(t *testing.T) {
 func TestCommit(t *testing.T) {
 	t.Parallel()
 
+	// Create a test backend.
+	backend := createTestUtxoBackend(t)
+
 	// Create test entries to be used throughout the tests.
-	outpoint299 := outpoint299()
-	outpoint1100 := outpoint1100()
-	entry1100Unmodified := entry1100()
-	outpoint1200 := outpoint1200()
-	entry1200 := entry1200()
-	entry1200Spent := entry1200.Clone()
-	entry1200Spent.Spend()
-	outpoint85314 := outpoint85314()
-	entry85314Modified := entry85314()
-	entry85314Modified.state |= utxoStateModified
+	outpoint299, entry299 := outpoint299(), makeEntryStates(entry299())
+	outpoint1100, entry1100 := outpoint1100(), makeEntryStates(entry1100())
+	outpoint1200, entry1200 := outpoint1200(), makeEntryStates(entry1200())
+	outpoint85314, entry85314 := outpoint85314(), makeEntryStates(entry85314())
 
 	tests := []struct {
-		name              string
-		viewEntries       map[wire.OutPoint]*UtxoEntry
-		cachedEntries     map[wire.OutPoint]*UtxoEntry
-		wantViewEntries   map[wire.OutPoint]*UtxoEntry
-		wantCachedEntries map[wire.OutPoint]*UtxoEntry
+		name              string                       // test description
+		viewEntries       map[wire.OutPoint]*UtxoEntry // view to commit
+		cachedEntries     map[wire.OutPoint]*UtxoEntry // existing cache entries
+		backendEntries    map[wire.OutPoint]*UtxoEntry // existing backend entries
+		wantViewEntries   map[wire.OutPoint]*UtxoEntry // expected committed view
+		wantCachedEntries map[wire.OutPoint]*UtxoEntry // expected committed cache
+		err               error                        // expected error
 	}{{
-		name: "view contains nil, unmodified, spent, and modified entries",
+		name: "modified spent by zero conf view entry w/o spent asserts",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1200: func() *UtxoEntry {
+				invalidEntry := entry1200.modifiedSpentByZeroConf.Clone()
+				invalidEntry.state &^= utxoStateSpent
+				return invalidEntry
+			}(),
+		},
+		err: AssertError(""),
+	}, {
+		name: "modified spent view entry w/ existing spent cache entry asserts",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1200: entry1200.modifiedSpent,
+		},
+		cachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1200: entry1200.modifiedSpent,
+		},
+		err: AssertError(""),
+	}, {
+		name: "nil view entries are removed from view and do not affect cache",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299: nil,
+		},
+		cachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299: entry299.unmodified,
+		},
+		wantViewEntries: map[wire.OutPoint]*UtxoEntry{},
+		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299: entry299.unmodified,
+		},
+	}, {
+		// This covers the following scenarios:
+		//
+		// - unmodified spent view entries with no corresponding cache entry
+		//   are retained in the view and have no effect on the cache
+		// - unmodified spent view entries with a corresponding nil cache entry
+		//   are retained in the view and have no effect on the cache
+		// - unmodified spent view entries with a corresponding non-fresh
+		//   unmodified cache entry are removed from view and have no effect on
+		//   the cache
+		// - unmodified spent fresh view entries with a corresponding modified
+		//   unspent fresh cache entry are retained in the view and have no
+		//   effect on the cache
+		//
+		// NOTE: The case where there are unmodified spent view entries with
+		// corresponding unspent entries in the cache should really never happen
+		// in practice because any spending done in the view necessarily marks
+		// the entry spent, so the only realistic way for an unmodified spent
+		// view entry to exist would be for it to have come from the cache which
+		// means it would be spent in the cache too, but the cache will not have
+		// unmodified spent entries since those are pruned.
+		//
+		// Similarly, unmodified unspent fresh view entries never happen with
+		// the current implementation because views never set the fresh flag and
+		// the flag is cleared on cloned entries that come from the cache.
+		//
+		// However, they are tested here for completeness in order to ensure
+		// unmodified entries never affect the cache.
+		name: "several unmodified spent view entry combinations",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299:   entry299.unmodifiedSpent,
+			outpoint1100:  entry1100.unmodifiedSpent,
+			outpoint1200:  entry1200.unmodifiedSpent,
+			outpoint85314: entry85314.unmodifiedSpentFresh,
+		},
+		cachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100:  nil,
+			outpoint1200:  entry1200.unmodified,
+			outpoint85314: entry85314.modifiedFresh,
+		},
+		wantViewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299:   entry299.unmodifiedSpent,
+			outpoint1100:  entry1100.unmodifiedSpent,
+			outpoint1200:  entry1200.unmodifiedSpent,
+			outpoint85314: entry85314.unmodifiedSpentFresh,
+		},
+		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100:  nil,
+			outpoint1200:  entry1200.unmodified,
+			outpoint85314: entry85314.modifiedFresh,
+		},
+	}, {
+		// This covers the following scenarios:
+		//
+		// - unmodified unspent view entries with no corresponding cache entry
+		//   are retained in the view and have no effect on the cache
+		// - unmodified unspent view entries with a corresponding nil cache
+		//   entry are retained in the view and have no effect on the cache
+		// - unmodified unspent view entries with a corresponding unmodified
+		//   cache entry are retained in the view and have no effect on the
+		//   cache
+		// - unmodified unspent fresh view entries with a corresponding modified
+		//   unspent fresh cache entry are retained in the view and have no
+		//   effect on the cache
+		//
+		// NOTE: Unmodified unspent fresh view entries never happen with the
+		// current implementation because views never set the fresh flag and the
+		// flag is cleared on cloned entries that come from the cache.  However,
+		// it is tested here for completeness.
+		name: "several unmodified unspent view entry combinations",
 		viewEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint299:   nil,
-			outpoint1100:  entry1100Unmodified,
-			outpoint1200:  entry1200Spent,
-			outpoint85314: entry85314Modified,
+			outpoint299:   entry299.unmodified,
+			outpoint1100:  entry1100.unmodified,
+			outpoint1200:  entry1200.unmodified,
+			outpoint85314: entry85314.unmodifiedFresh,
 		},
 		cachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1200: entry1200,
+			outpoint1100:  nil,
+			outpoint1200:  entry1200.unmodified,
+			outpoint85314: entry85314.modifiedFresh,
 		},
-		// outpoint299 is removed from the view since the entry is nil.
-		// entry1100Unmodified remains in the view since it is unmodified.
-		// entry1200Spent is removed from the view since the entry is spent.
-		// entry85314Modified is removed from the view since it is modified and
-		// added to the cache.
 		wantViewEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1100: entry1100Unmodified,
+			outpoint299:   entry299.unmodified,
+			outpoint1100:  entry1100.unmodified,
+			outpoint1200:  entry1200.unmodified,
+			outpoint85314: entry85314.unmodifiedFresh,
+		},
+		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100:  nil,
+			outpoint1200:  entry1200.unmodified,
+			outpoint85314: entry85314.modifiedFresh,
+		},
+	}, {
+		// This covers the following scenarios:
+		//
+		// - modified spent view entries with a corresponding nil cache entry
+		//   are removed from view and a noop to the cache
+		// - modified spent fresh view entries with a corresponding unspent
+		//   fresh cache entry makes the cache entry nil
+		// - modified spent view entries with a corresponding non-fresh
+		//   unmodified cache entry are removed from view and mark the cache
+		//   entry modified and spent
+		//
+		// NOTE: Modified spent fresh view entries never happen with the current
+		// implementation because views never set the fresh flag and the flag is
+		// cleared on cloned entries that come from the cache.  However, it is
+		// tested here for completeness.
+		name: "several modified spent view entry combinations",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299:  entry299.modifiedSpent,
+			outpoint1100: entry1100.modifiedSpentFresh,
+			outpoint1200: entry1200.modifiedSpent,
+		},
+		cachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299:  nil,
+			outpoint1100: entry1100.modifiedFresh,
+			outpoint1200: entry1200.unmodified,
+		},
+		wantViewEntries: map[wire.OutPoint]*UtxoEntry{},
+		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299:  nil,
+			outpoint1100: nil,
+			outpoint1200: entry1200.modifiedSpent,
+		},
+	}, {
+		// This covers the following scenarios:
+		//
+		// - modified spent view entries without a corresponding cache entry nor
+		//   corresponding backend entry are removed from the view and a noop to
+		//   the cache
+		// - modified spent view entries without a corresponding cache entry
+		//   but with a corresponding backend entry loads the entry from the
+		//   backend, spends it, and adds the spent entry to the cache
+		name: "modified spent view entries not in backend",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299:  entry299.modifiedSpent,
+			outpoint1100: entry1100.modifiedSpent,
+		},
+		cachedEntries: map[wire.OutPoint]*UtxoEntry{},
+		backendEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100: entry1100.modified,
+		},
+		wantViewEntries: map[wire.OutPoint]*UtxoEntry{},
+		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100: entry1100.modifiedSpent,
+		},
+	}, {
+		// This covers the following scenarios:
+		//
+		// - modified unspent view entries with no corresponding cache entry are
+		//   removed from view and added to the cache as fresh entries
+		// - modified unspent view entries with a corresponding unmodified cache
+		//   entry are removed from view and replace the cache entry making it
+		//   modified
+		// - modified unspent fresh view entries with a corresponding modified
+		//   fresh cache entry are removed from view and replace the cache entry
+		//   retaining its modified fresh status
+		// - modified unspent non-fresh view entries with a corresponding
+		//   modified non-fresh cache entry are removed from view and replace
+		//   the cache entry retaining its modified non-fresh status
+		//
+		// NOTE: Modified unspent fresh view entries never happen with the
+		// current implementation because views never set the fresh flag and the
+		// flag is cleared on cloned entries that come from the cache.  However,
+		// it is tested here for completeness.
+		name: "several modified unspent view entry combinations",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299:   entry299.modified,
+			outpoint1100:  entry1100.modified,
+			outpoint1200:  entry1200.modifiedFresh,
+			outpoint85314: entry85314.modified,
+		},
+		cachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100:  entry1100.unmodified,
+			outpoint1200:  entry1200.modifiedFresh,
+			outpoint85314: entry85314.modified,
 		},
-		// entry1200Spent remains in the cache but is now spent.
-		// entry85314Modified is added to the cache.
+		wantViewEntries: map[wire.OutPoint]*UtxoEntry{},
 		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1200:  entry1200Spent,
-			outpoint85314: entry85314Modified,
+			outpoint299:   entry299.modifiedFresh,
+			outpoint1100:  entry1100.modified,
+			outpoint1200:  entry1200.modifiedFresh,
+			outpoint85314: entry85314.modified,
+		},
+	}, {
+		name: "view entry spent by zero conf removed and has no effect on cache",
+		viewEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint299: entry299.modifiedSpentByZeroConf,
+		},
+		cachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100: entry1100.unmodified,
+		},
+		wantViewEntries: map[wire.OutPoint]*UtxoEntry{},
+		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
+			outpoint1100: entry1100.unmodified,
 		},
 	}}
 
 	for _, test := range tests {
 		// Create a utxo cache with the cached entries specified by the test.
 		utxoCache := createTestUtxoCache(t, test.cachedEntries)
+		utxoCache.backend = backend
+
+		// Add entries specified by the test to the test backend.
+		err := backend.PutUtxos(test.backendEntries, &UtxoSetState{})
+		if err != nil {
+			t.Fatalf("%q: unexpected error adding entries to test backend: %v",
+				test.name, err)
+		}
 
-		// Create a utxo cache with the view entries specified by the test.
+		// Create a utxo view with the entries specified by the test.  The view
+		// entries are cloned first in order to ensure the shared instances are
+		// not modified if the cache takes ownership of them.
+		viewEntries := make(map[wire.OutPoint]*UtxoEntry)
+		for k, v := range test.viewEntries {
+			viewEntries[k] = v.Clone()
+		}
 		view := &UtxoViewpoint{
 			cache:   utxoCache,
-			entries: test.viewEntries,
+			entries: viewEntries,
 		}
 
 		// Commit the view to the cache.
-		err := utxoCache.Commit(view)
-		if err != nil {
-			t.Fatalf("%q: unexpected error committing view to the cache: %v",
-				test.name, err)
+		err = utxoCache.Commit(view)
+		if !errors.Is(err, test.err) {
+			t.Fatalf("%q: unexpected error committing view to the cache -- "+
+				"got %v (%[2]T), want %v (%[3]T)", test.name, err, test.err)
+		}
+		if test.err != nil {
+			continue
 		}
 
 		// Validate the cached entries after committing.
@@ -854,34 +1118,10 @@ func TestMaybeFlush(t *testing.T) {
 	block2000Hash := mustParseHash("0000000000000c8a886e3f7c32b1bb08422066dcf" +
 		"d008de596471f11a5aff475")
 
-	// entry299Fresh is from block height 299 and is modified and fresh.
-	outpoint299 := outpoint299()
-	entry299Fresh := entry299()
-	entry299Fresh.state |= utxoStateModified | utxoStateFresh
-
-	// entry299Unmodified is from block height 299 and is unmodified.
-	entry299Unmodified := entry299()
-
-	// entry1100Spent is from block height 1100 and is modified and spent.
-	outpoint1100 := outpoint1100()
-	entry1100Spent := entry1100()
-	entry1100Spent.Spend()
-
-	// entry1100Modified is from block height 1100 and is modified and unspent.
-	entry1100Modified := entry1100()
-	entry1100Modified.state |= utxoStateModified
-
-	// entry1100Unmodified is from block height 1100 and is unspent and
-	// unmodified.
-	entry1100Unmodified := entry1100()
-
-	// entry1200Fresh is from block height 1200 and is modified and fresh.
-	outpoint1200 := outpoint1200()
-	entry1200Fresh := entry1200()
-	entry1200Fresh.state |= utxoStateModified | utxoStateFresh
-
-	// entry1200Unmodified is from block height 1200 and is unmodified.
-	entry1200Unmodified := entry1200()
+	// Create test entries to be used throughout the tests.
+	outpoint299, entry299 := outpoint299(), makeEntryStates(entry299())
+	outpoint1100, entry1100 := outpoint1100(), makeEntryStates(entry1100())
+	outpoint1200, entry1200 := outpoint1200(), makeEntryStates(entry1200())
 
 	tests := []struct {
 		name                     string
@@ -906,22 +1146,22 @@ func TestMaybeFlush(t *testing.T) {
 		bestHash:           block2000Hash,
 		bestHeight:         2000,
 		cachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint299:  entry299Fresh,
-			outpoint1100: entry1100Spent,
-			outpoint1200: entry1200Fresh,
+			outpoint299:  entry299.modifiedFresh,
+			outpoint1100: entry1100.modifiedSpent,
+			outpoint1200: entry1200.modifiedFresh,
 		},
 		backendEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1100: entry1100Modified,
+			outpoint1100: entry1100.modified,
 		},
 		// The cache should remain unchanged since a flush is not required.
 		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint299:  entry299Fresh,
-			outpoint1100: entry1100Spent,
-			outpoint1200: entry1200Fresh,
+			outpoint299:  entry299.modifiedFresh,
+			outpoint1100: entry1100.modifiedSpent,
+			outpoint1200: entry1200.modifiedFresh,
 		},
 		// The backend should remain unchanged since a flush is not required.
 		wantBackendEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1100: entry1100Unmodified,
+			outpoint1100: entry1100.unmodified,
 		},
 		wantLastEvictionHeight:   0,
 		wantLastFlushHash:        block1000Hash,
@@ -934,27 +1174,27 @@ func TestMaybeFlush(t *testing.T) {
 		bestHash:           block2000Hash,
 		bestHeight:         2000,
 		cachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint299:  entry299Fresh,
-			outpoint1100: entry1100Spent,
-			outpoint1200: entry1200Fresh,
+			outpoint299:  entry299.modifiedFresh,
+			outpoint1100: entry1100.modifiedSpent,
+			outpoint1200: entry1200.modifiedFresh,
 		},
 		backendEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1100: entry1100Modified,
+			outpoint1100: entry1100.modified,
 		},
 		// entry299Fresh should be evicted from the cache due to its height.
 		// entry1100Spent should be evicted since it is spent.
 		// entry1200Fresh should remain in the cache but should now be
 		// unmodified.
 		wantCachedEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint1200: entry1200Unmodified,
+			outpoint1200: entry1200.unmodified,
 		},
 		// entry299Unmodified should be added to the backend during the flush.
 		// entry1100Unmodified should be removed from the backend since it now
 		// spent.
 		// entry1200Unmodified should be added to the backend during the flush.
 		wantBackendEntries: map[wire.OutPoint]*UtxoEntry{
-			outpoint299:  entry299Unmodified,
-			outpoint1200: entry1200Unmodified,
+			outpoint299:  entry299.unmodified,
+			outpoint1200: entry1200.unmodified,
 		},
 		wantLastEvictionHeight:   300,
 		wantLastFlushHash:        block2000Hash,
@@ -1075,15 +1315,15 @@ func TestInitialize(t *testing.T) {
 	if err != nil {
 		t.Fatalf("error initializing backend info: %v", err)
 	}
-	resetTestUtxoCache := func() *testUtxoCache {
+	resetTestUtxoCache := func(forceFlush bool) *testUtxoCache {
 		testUtxoCache := newTestUtxoCache(&UtxoCacheConfig{
 			Backend:      backend,
 			FlushBlockDB: g.chain.db.Flush,
 			MaxSize:      100 * 1024 * 1024, // 100 MiB
 		})
+		testUtxoCache.forceFlush = forceFlush
 		g.chain.utxoCache = testUtxoCache
-		err := testUtxoCache.Initialize(context.Background(), g.chain,
-			g.chain.bestChain.Tip())
+		err := testUtxoCache.Initialize(context.Background(), g.chain)
 		if err != nil {
 			t.Fatalf("error initializing test cache: %v", err)
 		}
@@ -1106,7 +1346,7 @@ func TestInitialize(t *testing.T) {
 
 	// Replace the utxo cache in the test chain with a test utxo cache so that
 	// flushing can be toggled on and off for testing.
-	testUtxoCache := resetTestUtxoCache()
+	testUtxoCache := resetTestUtxoCache(false)
 
 	// Validate that the tip and utxo set state are currently at the genesis
 	// block.
@@ -1123,8 +1363,7 @@ func TestInitialize(t *testing.T) {
 	g.ExpectUtxoSetState("genesis")
 
 	// Reset the cache and force a flush.
-	testUtxoCache = resetTestUtxoCache()
-	forceFlush(testUtxoCache)
+	testUtxoCache = resetTestUtxoCache(true)
 
 	// Validate that the utxo cache is now caught up to the tip.
 	g.ExpectUtxoSetState(g.TipName())
@@ -1137,10 +1376,12 @@ func TestInitialize(t *testing.T) {
 
 	outs := g.OldestCoinbaseOuts()
 	g.NextBlock("b0", &outs[0], outs[1:])
+	g.SaveTipCoinbaseOuts()
 	g.AcceptTipBlock()
 
 	outs = g.OldestCoinbaseOuts()
 	b1 := g.NextBlock("b1", &outs[0], outs[1:])
+	g.SaveTipCoinbaseOuts()
 	g.AcceptTipBlock()
 
 	// Force a cache flush and validate that the cache is caught up to block b1.
@@ -1206,11 +1447,75 @@ func TestInitialize(t *testing.T) {
 	g.ExpectUtxoSetState("b1")
 
 	// Reset the cache and force a flush.
-	testUtxoCache = resetTestUtxoCache()
-	forceFlush(testUtxoCache)
+	testUtxoCache = resetTestUtxoCache(true)
 
 	// Validate that the cache recovered and is now caught up to b1a.
 	g.ExpectUtxoSetState("b1a")
+
+	// -------------------------------------------------------------------------
+	// Simulate an unclean shutdown such that the utxocache was last flushed at
+	// the tip block which is later invalidated and ensure the cache and backend
+	// recover back to the parent block as expected.
+	//
+	// This is accomplished by creating a new block that becomes the new best
+	// chain tip, disabling flushing, and invalidating that new block to ensure
+	// that the flushed utxo cache state is the invalidated block.  Then the
+	// cache is reset while forcing flushing during initialization.
+	//
+	//   ... -> b0 -> b1
+	//            |      \-> b2bad
+	//            \-> b1a    -----
+	//                       ^ (marked invalid with no flush to cache backend)
+	// -------------------------------------------------------------------------
+
+	outs = g.OldestCoinbaseOuts()
+	g.SetTip("b1")
+	b2bad := g.NextBlock("b2bad", &outs[0], outs[1:])
+	g.AcceptTipBlock()
+	forceFlush(testUtxoCache)
+	g.ExpectUtxoSetState("b2bad")
+
+	// Disable flushing to ensure the block that is about to be invalidated is
+	// not flushed to the backend.
+	//
+	// Ordinarily, the spend journal entry for disconnected blocks is not
+	// removed when there is an error in flushing, however, since this bypasses
+	// the error, the entry needs to be saved and restored after the block is
+	// invalidated.
+	testUtxoCache.disableFlush = true
+	var b2badStxos []spentTxOut
+	err = g.chain.db.View(func(dbTx database.Tx) error {
+		prevHash := b2bad.Header.PrevBlock
+		isTrsyEnabled, err := g.chain.IsTreasuryAgendaActive(&prevHash)
+		if err != nil {
+			return err
+		}
+		block := dcrutil.NewBlock(b2bad)
+		b2badStxos, err = dbFetchSpendJournalEntry(dbTx, block, isTrsyEnabled)
+		return err
+	})
+	if err != nil {
+		t.Fatalf("unexpected error getting spend journal entry: %v", err)
+	}
+
+	// Invalidate the previously connected block so that it is disconnected and
+	// ensure the flushed utxo cache state is still at the invalidated block.
+	g.InvalidateBlockAndExpectTip("b2bad", nil, "b1")
+	g.ExpectUtxoSetState("b2bad")
+
+	// Restore the spend journal entry for the invalidated block per the above.
+	err = g.chain.db.Update(func(dbTx database.Tx) error {
+		b2badHash := b2bad.BlockHash()
+		return dbPutSpendJournalEntry(dbTx, &b2badHash, b2badStxos)
+	})
+	if err != nil {
+		t.Fatalf("unexpected error restoring spend journal entry: %v", err)
+	}
+
+	// Reset the cache while forcing flushing during the initialization and
+	// ensure the cache and backend recover back to b1 as expected.
+	resetTestUtxoCache(true)
+	g.ExpectUtxoSetState("b1")
 }
 
 // TestShutdownUtxoCache validates that a cache flush is forced when shutting
@@ -1232,8 +1537,7 @@ func TestShutdownUtxoCache(t *testing.T) {
 	if err != nil {
 		t.Fatalf("error initializing backend info: %v", err)
 	}
-	err = testUtxoCache.Initialize(context.Background(), g.chain,
-		g.chain.bestChain.Tip())
+	err = testUtxoCache.Initialize(context.Background(), g.chain)
 	if err != nil {
 		t.Fatalf("error initializing test cache: %v", err)
 	}
diff --git a/internal/blockchain/validate_test.go b/internal/blockchain/validate_test.go
index 65ad48a6..51b3f61e 100644
--- a/internal/blockchain/validate_test.go
+++ b/internal/blockchain/validate_test.go
@@ -1972,7 +1972,7 @@ func TestModifiedSubsidySplitSemantics(t *testing.T) {
 		trsySubsidy := cache.CalcTreasurySubsidy(height, numVotes, noTreasury)
 		powSubsidy := cache.CalcWorkSubsidyV2(height, numVotes, withDCP0010)
 
-		// Update the input value to the the new expected subsidy sum.
+		// Update the input value to the new expected subsidy sum.
 		coinbaseTx := b.Transactions[0]
 		coinbaseTx.TxIn[0].ValueIn = trsySubsidy + powSubsidy