What changed, and why it matters
This commit adds a new read-only overlay for Bitcoin Core's coin cache system. It lets the software look up transaction inputs during block validation without writing those lookups into the main in-memory cache. The goal is to avoid polluting the cache with data from blocks that might turn out to be invalid, and to prepare for future multi-threaded input fetching where worker threads should not change shared state. The change itself is defensive infrastructure, not a fix for an active bug or vulnerability.
No immediate action required. Treat as normal refactoring and defensive infrastructure. Reviewers should verify that PeekCoin() correctly propagates through all cache layers and that Flush()/BatchWrite() behavior preserves existing invariants, especially around spent coins and best-block updates.
Security signals we found
Prevents unvalidated-block inputs from being cached in the main coins cache, reducing cache pollution and potential side effects from invalid blocks
Supports future async input fetching by ensuring worker threads do not mutate shared cache state
Adds defensive test coverage for non-mutating reads, spent-coin handling, and no double-spend scenarios
Evidence from the diff
The commit introduces CoinsViewOverlay, a CCoinsViewCache subclass that overrides FetchCoinFromBase() to call base->PeekCoin() instead of base->GetCoin(). PeekCoin() reads through cache layers without inserting missing entries into parent caches, while GetCoin() populates them. The overlay is intended for use during ConnectBlock() as a temporary top-level view that is only flushed on successful validation. The change also refactors CCoinsViewCache::FetchCoin() to use a new virtual FetchCoinFromBase() helper, adds unit tests, and extends an existing fuzz target to cover the overlay.
Changed components
src/coins.cppsrc/coins.hsrc/test/coinsviewoverlay_tests.cppsrc/test/fuzz/coinscache_sim.cppInspect captured patch +201 / −2
diff --git a/src/coins.cpp b/src/coins.cpp
index 8d51737d..3385922f 100644
--- a/src/coins.cpp
+++ b/src/coins.cpp
@@ -60,10 +60,15 @@ size_t CCoinsViewCache::DynamicMemoryUsage() const {
return memusage::DynamicUsage(cacheCoins) + cachedCoinsUsage;
}
+std::optional<Coin> CCoinsViewCache::FetchCoinFromBase(const COutPoint& outpoint) const
+{
+ return base->GetCoin(outpoint);
+}
+
CCoinsMap::iterator CCoinsViewCache::FetchCoin(const COutPoint &outpoint) const {
const auto [ret, inserted] = cacheCoins.try_emplace(outpoint);
if (inserted) {
- if (auto coin{base->GetCoin(outpoint)}) {
+ if (auto coin{FetchCoinFromBase(outpoint)}) {
ret->second.coin = std::move(*coin);
cachedCoinsUsage += ret->second.coin.DynamicMemoryUsage();
Assert(!ret->second.coin.IsSpent());
diff --git a/src/coins.h b/src/coins.h
index 36516ef7..a2768298 100644
--- a/src/coins.h
+++ b/src/coins.h
@@ -383,6 +383,9 @@ protected:
*/
void Reset() noexcept;
+ /* Fetch the coin from base. Used for cache misses in FetchCoin. */
+ virtual std::optional<Coin> FetchCoinFromBase(const COutPoint& outpoint) const;
+
public:
CCoinsViewCache(CCoinsView *baseIn, bool deterministic = false);
@@ -512,6 +515,27 @@ private:
CCoinsMap::iterator FetchCoin(const COutPoint &outpoint) const;
};
+/**
+ * CCoinsViewCache overlay that avoids populating/mutating parent cache layers on cache misses.
+ *
+ * This is achieved by fetching coins from the base view using PeekCoin() instead of GetCoin(),
+ * so intermediate CCoinsViewCache layers are not filled.
+ *
+ * Used during ConnectBlock() as an ephemeral, resettable top-level view that is flushed only
+ * on success, so invalid blocks don't pollute the underlying cache.
+ */
+class CoinsViewOverlay : public CCoinsViewCache
+{
+private:
+ std::optional<Coin> FetchCoinFromBase(const COutPoint& outpoint) const override
+ {
+ return base->PeekCoin(outpoint);
+ }
+
+public:
+ using CCoinsViewCache::CCoinsViewCache;
+};
+
//! Utility function to add all of a transaction's outputs to a cache.
//! When check is false, this assumes that overwrites are only possible for coinbase transactions.
//! When check is true, the underlying view may be queried to determine whether an addition is
diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt
index 2466a483..61c3a71b 100644
--- a/src/test/CMakeLists.txt
+++ b/src/test/CMakeLists.txt
@@ -33,6 +33,7 @@ add_executable(test_bitcoin
coins_tests.cpp
coinscachepair_tests.cpp
coinstatsindex_tests.cpp
+ coinsviewoverlay_tests.cpp
common_url_tests.cpp
compress_tests.cpp
crypto_tests.cpp
diff --git a/src/test/coinsviewoverlay_tests.cpp b/src/test/coinsviewoverlay_tests.cpp
new file mode 100644
index 00000000..6b20b312
--- /dev/null
+++ b/src/test/coinsviewoverlay_tests.cpp
@@ -0,0 +1,165 @@
+// Copyright (c) The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <coins.h>
+#include <primitives/block.h>
+#include <primitives/transaction.h>
+#include <primitives/transaction_identifier.h>
+#include <txdb.h>
+#include <uint256.h>
+#include <util/byte_units.h>
+#include <util/hasher.h>
+
+#include <boost/test/unit_test.hpp>
+
+#include <cstdint>
+#include <cstring>
+#include <ranges>
+
+BOOST_AUTO_TEST_SUITE(coinsviewoverlay_tests)
+
+namespace {
+
+CBlock CreateBlock() noexcept
+{
+ static constexpr auto NUM_TXS{100};
+ CBlock block;
+ CMutableTransaction coinbase;
+ coinbase.vin.emplace_back();
+ block.vtx.push_back(MakeTransactionRef(coinbase));
+
+ for (const auto i : std::views::iota(1, NUM_TXS)) {
+ CMutableTransaction tx;
+ Txid txid{Txid::FromUint256(uint256(i))};
+ tx.vin.emplace_back(txid, 0);
+ block.vtx.push_back(MakeTransactionRef(tx));
+ }
+
+ return block;
+}
+
+void PopulateView(const CBlock& block, CCoinsView& view, bool spent = false)
+{
+ CCoinsViewCache cache{&view};
+ cache.SetBestBlock(uint256::ONE);
+
+ for (const auto& tx : block.vtx | std::views::drop(1)) {
+ for (const auto& in : tx->vin) {
+ Coin coin{};
+ if (!spent) coin.out.nValue = 1;
+ cache.EmplaceCoinInternalDANGER(COutPoint{in.prevout}, std::move(coin));
+ }
+ }
+
+ cache.Flush();
+}
+
+void CheckCache(const CBlock& block, const CCoinsViewCache& cache)
+{
+ uint32_t counter{0};
+
+ for (const auto& tx : block.vtx) {
+ if (tx->IsCoinBase()) {
+ BOOST_CHECK(!cache.HaveCoinInCache(tx->vin[0].prevout));
+ } else {
+ for (const auto& in : tx->vin) {
+ const auto& outpoint{in.prevout};
+ const auto& first{cache.AccessCoin(outpoint)};
+ const auto& second{cache.AccessCoin(outpoint)};
+ BOOST_CHECK_EQUAL(&first, &second);
+ ++counter;
+ BOOST_CHECK(cache.HaveCoinInCache(outpoint));
+ }
+ }
+ }
+ BOOST_CHECK_EQUAL(cache.GetCacheSize(), counter);
+}
+
+} // namespace
+
+BOOST_AUTO_TEST_CASE(fetch_inputs_from_db)
+{
+ const auto block{CreateBlock()};
+ CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}};
+ PopulateView(block, db);
+ CCoinsViewCache main_cache{&db};
+ CoinsViewOverlay view{&main_cache};
+ const auto& outpoint{block.vtx[1]->vin[0].prevout};
+
+ BOOST_CHECK(view.HaveCoin(outpoint));
+ BOOST_CHECK(view.GetCoin(outpoint).has_value());
+ BOOST_CHECK(!main_cache.HaveCoinInCache(outpoint));
+
+ CheckCache(block, view);
+ // Check that no coins have been moved up to main cache from db
+ for (const auto& tx : block.vtx) {
+ for (const auto& in : tx->vin) {
+ BOOST_CHECK(!main_cache.HaveCoinInCache(in.prevout));
+ }
+ }
+
+ view.SetBestBlock(uint256::ONE);
+ BOOST_CHECK(view.SpendCoin(outpoint));
+ view.Flush();
+ BOOST_CHECK(!main_cache.PeekCoin(outpoint).has_value());
+}
+
+BOOST_AUTO_TEST_CASE(fetch_inputs_from_cache)
+{
+ const auto block{CreateBlock()};
+ CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}};
+ CCoinsViewCache main_cache{&db};
+ PopulateView(block, main_cache);
+ CoinsViewOverlay view{&main_cache};
+ CheckCache(block, view);
+
+ const auto& outpoint{block.vtx[1]->vin[0].prevout};
+ view.SetBestBlock(uint256::ONE);
+ BOOST_CHECK(view.SpendCoin(outpoint));
+ view.Flush();
+ BOOST_CHECK(!main_cache.PeekCoin(outpoint).has_value());
+}
+
+// Test for the case where a block spends coins that are spent in the cache, but
+// the spentness has not been flushed to the db.
+BOOST_AUTO_TEST_CASE(fetch_no_double_spend)
+{
+ const auto block{CreateBlock()};
+ CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}};
+ PopulateView(block, db);
+ CCoinsViewCache main_cache{&db};
+ // Add all inputs as spent already in cache
+ PopulateView(block, main_cache, /*spent=*/true);
+ CoinsViewOverlay view{&main_cache};
+ for (const auto& tx : block.vtx) {
+ for (const auto& in : tx->vin) {
+ const auto& c{view.AccessCoin(in.prevout)};
+ BOOST_CHECK(c.IsSpent());
+ BOOST_CHECK(!view.HaveCoin(in.prevout));
+ BOOST_CHECK(!view.GetCoin(in.prevout));
+ }
+ }
+ // Coins are not added to the view, even though they exist unspent in the parent db
+ BOOST_CHECK_EQUAL(view.GetCacheSize(), 0);
+}
+
+BOOST_AUTO_TEST_CASE(fetch_no_inputs)
+{
+ const auto block{CreateBlock()};
+ CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}};
+ CCoinsViewCache main_cache{&db};
+ CoinsViewOverlay view{&main_cache};
+ for (const auto& tx : block.vtx) {
+ for (const auto& in : tx->vin) {
+ const auto& c{view.AccessCoin(in.prevout)};
+ BOOST_CHECK(c.IsSpent());
+ BOOST_CHECK(!view.HaveCoin(in.prevout));
+ BOOST_CHECK(!view.GetCoin(in.prevout));
+ }
+ }
+ BOOST_CHECK_EQUAL(view.GetCacheSize(), 0);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
diff --git a/src/test/fuzz/coinscache_sim.cpp b/src/test/fuzz/coinscache_sim.cpp
index b1b28423..c8534e4f 100644
--- a/src/test/fuzz/coinscache_sim.cpp
+++ b/src/test/fuzz/coinscache_sim.cpp
@@ -374,7 +374,11 @@ FUZZ_TARGET(coinscache_sim)
[&]() { // Add a cache level (if not already at the max).
if (caches.size() != MAX_CACHES) {
// Apply to real caches.
- caches.emplace_back(new CCoinsViewCache(&*caches.back(), /*deterministic=*/true));
+ if (provider.ConsumeBool()) {
+ caches.emplace_back(new CCoinsViewCache(&*caches.back(), /*deterministic=*/true));
+ } else {
+ caches.emplace_back(new CoinsViewOverlay(&*caches.back(), /*deterministic=*/true));
+ }
// Apply to simulation data.
sim_caches[caches.size()].Wipe();
}
Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.