txdb: detect UTXO deserialization errors via CDBWrapper::TryRead()

If a UTXO entry on disk can't be deserialized, the node treats it
as if the coin doesn't exist. Any block that spends that coin is
permanently rejected as invalid (BLOCK_FAILED_VALID), silently
forking the node from the rest of the network. This can hardly be
triggered in practice (details below), but it's still the wrong
behavior that could affect us in the future.

The root cause is that CDBWrapper::Read() returns false for both
missing keys and deserialization failures, so the consensus
class CCoinsViewDB::GetCoin() has no way to tell them apart.
CCoinsViewErrorCatcher was built to catch database read errors
and abort, but it never fires because CDBWrapper::Read() swallows
the exception before it can propagate.

In practice, this scenario isn't a latent risk at the moment. It
requires either a bug in the coin serialization path, or memory
corruption before the data reaches LevelDB (at which point we have
bigger problems). Any random disk-level bit flips are caught earlier
by LevelDB's verification (the verify_checksums=true option enabled
by default), which surfaces as a DatabaseError rather than a
deserialization failure.

This commit switches CCoinsViewDB::GetCoin() to use
CDBWrapper::TryRead(), which lets the caller discriminate between
all possible outcomes. On deserialization error, the exception now
propagates through CCoinsViewErrorCatcher to ExecuteBackedWrapper(),
which invokes the shutdown callbacks and aborts the node accordantly.

This also fixes PeekCoin(), which delegates to GetCoin() at the
CCoinsViewDB level.
This commit is contained in:
furszy
2026-03-25 10:09:31 -04:00
parent 5dfbb91b6c
commit 4652cd0d82

View File

@@ -71,11 +71,24 @@ void CCoinsViewDB::ResizeCache(size_t new_cache_size)
std::optional<Coin> CCoinsViewDB::GetCoin(const COutPoint& outpoint) const
{
if (Coin coin; m_db->Read(CoinEntry(&outpoint), coin)) {
Assert(!coin.IsSpent()); // The UTXO database should never contain spent coins
return coin;
Coin coin;
const CDBWrapper::ReadStatus res = m_db->TryRead(CoinEntry(&outpoint), coin);
if (!res) {
// Propagate errors so CCoinsViewErrorCatcher triggers a clean shutdown.
switch (const auto& [err_code, err_msg] = res.error(); err_code) {
case CDBWrapper::ReadFailure::Code::DeserializationError:
throw dbwrapper_error{strprintf("Coin deserialization failure: %s", err_msg)};
case CDBWrapper::ReadFailure::Code::DatabaseError:
throw dbwrapper_error{strprintf("Coin DB read failure: %s", err_msg)};
} // no default case, so the compiler can warn about missing cases
std::abort(); // unreachable
}
return std::nullopt;
// Check whether the coin exists
if (!res.value()) return std::nullopt;
// Coin found, ensure UTXO database never contains spent coins
Assert(!coin.IsSpent());
return coin;
}
bool CCoinsViewDB::HaveCoin(const COutPoint &outpoint) const {