6 Commits

Author SHA1 Message Date
762e25294f fix: guard BuildWitnessCache against an off-active-chain pindex (heap overflow)
BuildWitnessCache sizes its blockCms buffer from pindex->GetHeight() but the
Phase-1 loop walks the active chain (chainActive.Next), terminating only on
pbi==pindex. If a reorg moved pindex off the active chain while the notify
thread lagged (cs_main is released between per-block ChainTip calls) and the new
active tip is taller, the loop never reaches pindex and, once past pindex's
height, writes blockCms[h-startHeight] out of bounds -- a heap overflow.
Rebuilding witnesses for an abandoned block is meaningless anyway, so bail early
when pindex is not on the active chain; cs_main is held for the whole function,
so the check cannot race the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:48:39 +02:00
3bb4eb3a5a fix: harden ThreadNotifyWallets IBD read-retry (crash + abandoned disconnects)
Two defects in the IBD ReadBlockFromDisk retry path of ThreadNotifyWallets:

- The connect-loop rebuild indexed recentlyConflicted.first.at(pindex), which
  throws std::out_of_range for a block whose conflict entry was drained in an
  earlier retry cycle. Uncaught under cs_main in the notify boost thread, that
  aborts the node and crash-loops. Use operator[] (empty-list default), i.e.
  best-effort conflict notifications, instead of throwing.

- The disconnect-loop IBD break fell through into the connect loop, which
  advanced pindexLastTip to the new tip and permanently abandoned the pending
  disconnect notifications (leaving wallet witness/anchor state desynced from
  the chain). Add a flag so the break skips the connect loop this cycle and
  truly retries the disconnect on the next one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:48:39 +02:00
7914fca0f2 fix: gate the RandomX PoW-verification skip on the in-index checkpoint
RandomXValidationRequired skipped RandomX verification for blocks below the
static top checkpoint (GetTotalBlocksEstimate), while the fork-rejection guard
uses the in-index checkpoint (GetLastCheckpoint). Once the checkpoint list
extends above the RandomX activation height, that asymmetry opens a gap during
IBD/eclipse in which a peer with no RandomX hashpower can get SHA256-grinded,
RandomX-forged blocks accepted (CheckProofOfWork hashes the header including the
attacker-controlled nSolution). Gate the skip on GetLastCheckpoint()->GetHeight()
so a block is PoW-exempt only when provably below a checkpoint the node has
locked into its index -- the same boundary the fork guard uses. Currently inert
(top checkpoint 2838000 < activation 2838976) but becomes live at the next
checkpoint refresh; the check runs under cs_main and only ever adds verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:48:39 +02:00
810bd6712f fix: make nCoinCacheUsage std::atomic to close an adaptive-dbcache data race
The scheduled AdjustCoinCacheForMemoryPressure task writes nCoinCacheUsage from
the scheduler thread holding no lock, while cs_main-holding threads
(FlushStateToDisk, VerifyDB) read it -- an unsynchronized read/write of a
non-atomic size_t (C++ UB). Make it std::atomic<size_t>; correct the comment
that incorrectly described the access as lock-free/race-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:48:39 +02:00
bf5b066a8d fix: evaluate wolfSSL_pending() under cs_hSocket in the recv-drain loop
The bulk-streaming recv-drain loop called wolfSSL_pending(pnode->ssl) after
releasing cs_hSocket, racing with SocketSendData (wolfSSL_write) and
CloseSocketDisconnect (wolfSSL_free) on the same TLS session -- a data race and
potential use-after-free on any TLS peer. Capture the pending-byte count inside
the cs_hSocket-locked block and use the captured value for the drain decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:48:39 +02:00
19e1ce6f00 fix: self-heal a corrupt/torn notarizations DB instead of aborting startup
A torn or corrupt notarizations (dPoW) leveldb -- a 0-byte log left by a torn
snapshot, or a corrupt MANIFEST -- threw at open and was caught by the block-DB
load try/catch, aborting startup with a misleading Error-opening-block-database
message and forcing a full resync. The notarizations DB is non-essential and
node-regenerable, so on open failure move it aside (notarizations.corrupt,
preserving the data in case the error was transient) and regenerate a fresh one;
if the fresh recreate also fails it still propagates as fatal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:48:39 +02:00
7 changed files with 73 additions and 12 deletions

View File

@@ -593,6 +593,7 @@ int TLSManager::threadSocketHandler(CNode* pnode, fd_set& fdsetRecv, fd_set& fds
int64_t nPassBytes = 0; int64_t nPassBytes = 0;
bool fKeepReading = true; bool fKeepReading = true;
while (fKeepReading) { while (fKeepReading) {
int nSSLPending = 0;
if (nRecvBase + nPassBytes > (int64_t)ReceiveFloodSize()) if (nRecvBase + nPassBytes > (int64_t)ReceiveFloodSize())
break; break;
{ {
@@ -609,6 +610,10 @@ int TLSManager::threadSocketHandler(CNode* pnode, fd_set& fdsetRecv, fd_set& fds
wolfSSL_ERR_clear_error(); // clear the error queue, otherwise we may be reading an old error that occurred previously in the current thread wolfSSL_ERR_clear_error(); // clear the error queue, otherwise we may be reading an old error that occurred previously in the current thread
nBytes = wolfSSL_read(pnode->ssl, pchBuf, sizeof(pchBuf)); nBytes = wolfSSL_read(pnode->ssl, pchBuf, sizeof(pchBuf));
nRet = wolfSSL_get_error(pnode->ssl, nBytes); nRet = wolfSSL_get_error(pnode->ssl, nBytes);
// Capture TLS buffered-byte count while still under cs_hSocket; the drain-continuation
// check below runs unlocked, so touching pnode->ssl there would race with
// SocketSendData / CloseSocketDisconnect (which free ssl) -> data race / use-after-free.
nSSLPending = wolfSSL_pending(pnode->ssl);
} else { } else {
nBytes = recv(pnode->hSocket, pchBuf, sizeof(pchBuf), MSG_DONTWAIT); nBytes = recv(pnode->hSocket, pchBuf, sizeof(pchBuf), MSG_DONTWAIT);
nRet = WSAGetLastError(); nRet = WSAGetLastError();
@@ -628,7 +633,7 @@ int TLSManager::threadSocketHandler(CNode* pnode, fd_set& fdsetRecv, fd_set& fds
// buffer, or TLS has buffered decrypted bytes) and within the per-pass cap. // buffer, or TLS has buffered decrypted bytes) and within the per-pass cap.
// The flood ceiling is enforced pre-read at the top of the loop. // The flood ceiling is enforced pre-read at the top of the loop.
if (fKeepReading) { if (fKeepReading) {
bool fMore = (nBytes == (int)sizeof(pchBuf)) || (bIsSSL && wolfSSL_pending(pnode->ssl) > 0); bool fMore = (nBytes == (int)sizeof(pchBuf)) || (bIsSSL && nSSLPending > 0);
if (!fMore || ++nDrainReads >= MAX_DRAIN_READS) if (!fMore || ++nDrainReads >= MAX_DRAIN_READS)
fKeepReading = false; fKeepReading = false;
} }

View File

@@ -1087,8 +1087,8 @@ static const int64_t g_nMinCoinCacheMB = 256; // never thrash below this working
// Scheduled task: nudge nCoinCacheUsage toward "use all RAM except the reserve". If free RAM is below // Scheduled task: nudge nCoinCacheUsage toward "use all RAM except the reserve". If free RAM is below
// the reserve we shrink the target (the next per-block flush releases the excess); if there is spare // the reserve we shrink the target (the next per-block flush releases the excess); if there is spare
// RAM we grow it back toward the startup ceiling. Lock-free: it only reads system memory and writes // RAM we grow it back toward the startup ceiling. nCoinCacheUsage is std::atomic<size_t>, so this
// the aligned size_t threshold that the flush path reads. // cross-thread write (vs the cs_main-held reads in FlushStateToDisk/VerifyDB) is well-defined, no lock needed.
static void AdjustCoinCacheForMemoryPressure() static void AdjustCoinCacheForMemoryPressure()
{ {
if (g_nMaxCoinCacheUsage == 0) if (g_nMaxCoinCacheUsage == 0)
@@ -2072,7 +2072,25 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
pcoinsdbview = new CCoinsViewDB(nCoinDBCache, false, fReindex); pcoinsdbview = new CCoinsViewDB(nCoinDBCache, false, fReindex);
pcoinscatcher = new CCoinsViewErrorCatcher(pcoinsdbview); pcoinscatcher = new CCoinsViewErrorCatcher(pcoinsdbview);
pcoinsTip = new CCoinsViewCache(pcoinscatcher); pcoinsTip = new CCoinsViewCache(pcoinscatcher);
try {
pnotarizations = new NotarizationDB(100*1024*1024, false, fReindex); pnotarizations = new NotarizationDB(100*1024*1024, false, fReindex);
} catch (const std::exception& e) {
// The notarizations (dPoW) DB is non-essential and node-regenerable. It has been seen to
// snapshot/flush torn (0-byte log -> leveldb "Database I/O error" on reopen), which
// previously aborted startup with a spurious "Error opening block database" and forced a
// full resync. Wipe and recreate it instead of failing hard.
LogPrintf("%s: notarizations DB failed to open (%s); moving aside and regenerating (non-fatal)\n", __FUNCTION__, e.what());
// Move (do NOT delete) the old DB aside, so a transient open failure (fd
// exhaustion, disk full, permissions) cannot permanently destroy notarization
// history. If the recreate below also fails it propagates as fatal and the old
// data survives in notarizations.corrupt for recovery.
{
boost::filesystem::path ndir = GetDataDir() / "notarizations";
boost::filesystem::remove_all(ndir.string() + ".corrupt");
boost::filesystem::rename(ndir, ndir.string() + ".corrupt");
}
pnotarizations = new NotarizationDB(100*1024*1024, false, true);
}
if (fReindex) { if (fReindex) {

View File

@@ -109,7 +109,7 @@ bool fIsBareMultisigStd = true;
bool fCheckBlockIndex = false; bool fCheckBlockIndex = false;
bool fCheckpointsEnabled = true; bool fCheckpointsEnabled = true;
bool fCoinbaseEnforcedProtectionEnabled = true; bool fCoinbaseEnforcedProtectionEnabled = true;
size_t nCoinCacheUsage = 5000 * 300; std::atomic<size_t> nCoinCacheUsage(5000 * 300);
uint64_t nPruneTarget = 0; uint64_t nPruneTarget = 0;
// If the tip is older than this (in seconds), the node is considered to be in initial block download. // If the tip is older than this (in seconds), the node is considered to be in initial block download.
int64_t nMaxTipAge = DEFAULT_MAX_TIP_AGE; int64_t nMaxTipAge = DEFAULT_MAX_TIP_AGE;

View File

@@ -43,6 +43,7 @@
#include "txmempool.h" #include "txmempool.h"
#include "uint256.h" #include "uint256.h"
#include <atomic>
#include <algorithm> #include <algorithm>
#include <exception> #include <exception>
#include <map> #include <map>
@@ -189,7 +190,7 @@ extern bool fCheckpointsEnabled;
// TODO: remove this flag by structuring our code such that // TODO: remove this flag by structuring our code such that
// it is unneeded for testing // it is unneeded for testing
extern bool fCoinbaseEnforcedProtectionEnabled; extern bool fCoinbaseEnforcedProtectionEnabled;
extern size_t nCoinCacheUsage; extern std::atomic<size_t> nCoinCacheUsage;
extern CFeeRate minRelayTxFee; extern CFeeRate minRelayTxFee;
extern int64_t nMaxTipAge; extern int64_t nMaxTipAge;

View File

@@ -739,8 +739,18 @@ bool RandomXValidationRequired(int32_t height)
if (HUSH_LOADINGBLOCKS != 0) if (HUSH_LOADINGBLOCKS != 0)
return false; return false;
extern bool fCheckpointsEnabled; extern bool fCheckpointsEnabled;
if (fCheckpointsEnabled && height < Checkpoints::GetTotalBlocksEstimate(Params().Checkpoints())) // Gate the RandomX skip on the last checkpoint actually LOCKED INTO this node's block index
// (GetLastCheckpoint), NOT the static top checkpoint (GetTotalBlocksEstimate). The fork-rejection
// guard uses this same in-index boundary, so a block below it is provably on the checkpoint-pinned
// chain and cannot be a forged fork. Using the static boundary would, once checkpoints extend above
// the RandomX activation height, leave a gap (in-index checkpoint .. static top) during IBD/eclipse
// where a no-hashpower peer could get SHA256-grinded, RandomX-forged blocks accepted. Safe to walk
// mapBlockIndex here: called only under cs_main (ActivateBestChainStep + inline CheckRandomXSolution).
if (fCheckpointsEnabled) {
CBlockIndex* pcheckpoint = Checkpoints::GetLastCheckpoint(Params().Checkpoints());
if (pcheckpoint != NULL && height < pcheckpoint->GetHeight())
return false; return false;
}
return true; return true;
} }

View File

@@ -156,10 +156,17 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip)
assert(pcoinsTip->GetSaplingAnchorAt(SaplingMerkleTree::empty_root(), oldSaplingTree)); assert(pcoinsTip->GetSaplingAnchorAt(SaplingMerkleTree::empty_root(), oldSaplingTree));
} }
// Use operator[] (empty-list default), NOT .at(): a block's conflict entry can be
// absent if it was drained in an earlier cycle whose connect loop hit the IBD
// ReadBlockFromDisk retry below (that break leaves pindexLastTip behind, so the block
// gets rebuilt here but its conflicts were already cleared and are never re-inserted).
// .at() would throw std::out_of_range which, uncaught under cs_main in this boost
// thread, aborts the node (and crash-loops since pindexLastTip cannot advance). A
// missing entry simply means no conflict notifications for this block (best-effort).
blockStack.emplace_back( blockStack.emplace_back(
pindex, pindex,
std::make_pair(oldSproutTree, oldSaplingTree), std::make_pair(oldSproutTree, oldSaplingTree),
recentlyConflicted.first.at(pindex)); recentlyConflicted.first[pindex]);
pindex = pindex->pprev; pindex = pindex->pprev;
} }
@@ -174,7 +181,11 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip)
// network message processing thread. // network message processing thread.
// //
// Notify block disconnects // Notify block disconnects. If an IBD read fails mid-disconnect we must NOT fall through
// to the connect loop (which advances pindexLastTip to the new tip and permanently abandons
// the remaining disconnects -> wallet witness/anchor desync); skip connects this cycle and
// retry the whole disconnect next cycle, exactly as the connect-side break retries.
bool fDisconnectIncomplete = false;
while (pindexLastTip && pindexLastTip != pindexFork) { while (pindexLastTip && pindexLastTip != pindexFork) {
// Read block from disk. // Read block from disk.
CBlock block; CBlock block;
@@ -184,6 +195,7 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip)
// Sleep briefly and retry on the next cycle instead of crashing. // Sleep briefly and retry on the next cycle instead of crashing.
LogPrintf("%s: block at height %d not yet readable, will retry\n", LogPrintf("%s: block at height %d not yet readable, will retry\n",
__func__, pindexLastTip->GetHeight()); __func__, pindexLastTip->GetHeight());
fDisconnectIncomplete = true;
break; break;
} }
LogPrintf("*** %s\n", "Failed to read block while notifying wallets of block disconnects"); LogPrintf("*** %s\n", "Failed to read block while notifying wallets of block disconnects");
@@ -205,8 +217,9 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip)
pindexLastTip = pindexLastTip->pprev; pindexLastTip = pindexLastTip->pprev;
} }
// Notify block connections // Notify block connections (skipped this cycle if the disconnect loop broke to retry an
while (!blockStack.empty()) { // unreadable block, so pindexLastTip is not advanced past the un-disconnected blocks).
while (!fDisconnectIncomplete && !blockStack.empty()) {
auto blockData = blockStack.back(); auto blockData = blockStack.back();
blockStack.pop_back(); blockStack.pop_back();

View File

@@ -1229,6 +1229,20 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly)
LOCK2(cs_main, cs_wallet); LOCK2(cs_main, cs_wallet);
// The Phase-1 loop below walks the ACTIVE chain (chainActive.Next) and sizes blockCms from
// pindex->GetHeight(), terminating only on pbi==pindex. If pindex was reorged OFF the active
// chain (a reorg landed while ThreadNotifyWallets drained its connect backlog with cs_main
// released), the loop never reaches pindex and, once the active tip passes pindex's height, it
// writes blockCms[h-startHeight] out of bounds -> heap overflow. Rebuilding witnesses for an
// abandoned block is meaningless; the ChainTip for the new active tip re-drives this. cs_main is
// held for the whole function, so this check cannot race the loop below.
if (pindex != chainActive[pindex->GetHeight()]) {
if (fZdebug)
LogPrintf("%s: pindex height=%d not on active chain (reorg); skipping witness rebuild\n",
__func__, pindex->GetHeight());
return;
}
int startHeight = VerifyAndSetInitialWitness(pindex, witnessOnly) + 1; int startHeight = VerifyAndSetInitialWitness(pindex, witnessOnly) + 1;
if (startHeight > pindex->GetHeight() || witnessOnly) { if (startHeight > pindex->GetHeight() || witnessOnly) {