fix(consensus): remove local-finality, fix getheaders fork recovery
fix/consensus-convergence — the rules around reorg finality and the getheaders fork-peer handler previously used locally advanced state that prevented two honest nodes from converging after extended disconnection. This commit removes the local-finality rules and restores convergence above the last globally shared hardened checkpoint. Reorganize() now: - Rejects reorgs whose fork point is at or below the compiled hardened checkpoint (sourced from Checkpoints::GetLastCheckpoint at startup, never advanced at runtime). - Above the checkpoint: greatest cumulative chain trust wins. No depth cap, no local finality, no 10% trust hysteresis. pindexFinalized is renamed to pindexLastHardenedCheckpoint to make clear that the variable now refers to the compiled checkpoint anchor, not a locally advanced finality depth. Its initialization in init.cpp runs once at startup; no runtime advancement. The getheaders handler now serves canonical history based on what the peer actually knows: - If the peer's locator contains the hardened checkpoint, serve headers from the checkpoint forward. - Otherwise, serve from the last common ancestor (falling back to genesis if no overlap exists). This lets a forked peer recover instead of being handed a header whose parent it doesn't have. CBlockLocator gains two small public accessors (Has, FindCommonAncestorInMainChain) so the recovery code doesn't have to reach into protected state. Staking safety gate is now continuous in StakeMiner (main.cpp's IsStakingSafe runs every iteration). Removed the once-only fTryToSync flag whose reset-after-first-use made the strong peer-count / IBD check ineffective after a network outage mid-staking. The gate refuses to stake when IBD is active, fewer than 2 handshaken peers exist, our height is behind the peer median, or a peer reports a tip >=2 blocks ahead of ours (possible competing fork signal). Tests: - consensus_safety_tests.cpp: 6 new tests pinning the convergence rule's structure against src/main.cpp and src/init.cpp. Replaces the old max_reorg_depth_enforced test (which pinned the now-removed local-finality constant). - staking_tests.cpp: 3 new tests pinning the continuous gate's behavior and the absence of fTryToSync from runtime code. All 277 unit-test cases (21,752 assertions) pass locally. The Qt GUI was not rebuilt; the daemon (trianglesd), CLI (triangles-cli), and test binary (test_triangles) all link and execute. Reviewed-against: pre-commit HEAD No push to master performed per standing rule.
This commit is contained in:
+22
-21
@@ -1378,34 +1378,35 @@ bool AppInit2()
|
||||
if (!LoadBlockIndex())
|
||||
return InitError(_("Error loading blkindex.dat"));
|
||||
|
||||
// triangles fix (pitfall #61): initialize pindexFinalized from the
|
||||
// hardcoded checkpoint on startup, BEFORE the daemon opens any peer
|
||||
// connections or processes any block messages.
|
||||
// pindexLastHardenedCheckpoint is initialized from the hardened checkpoint
|
||||
// map on startup, BEFORE the daemon opens any peer connections or
|
||||
// processes any block messages. It is intentionally NOT advanced at
|
||||
// runtime — see fix/consensus-convergence.
|
||||
//
|
||||
// Without this, pindexFinalized stays NULL on a fresh restart even when
|
||||
// we have 2.2M blocks on disk, because the auto-checkpoint code in
|
||||
// ActivateBestChain() at main.cpp:2459 only sets it when
|
||||
// !IsInitialBlockDownload(). If the chain tip is more than 24h stale
|
||||
// (which happens on every restart with a synced chain), IsInitialBlockDownload()
|
||||
// returns true and pindexFinalized never gets set.
|
||||
//
|
||||
// The downstream reorg guard at main.cpp:2198 short-circuits when
|
||||
// pindexFinalized is NULL, which allowed a 3,755-block minority fork
|
||||
// to overwrite a healthy 2,206,004-block chain on 2026-06-16. Loading
|
||||
// the hardcoded checkpoint from checkpoints.cpp (block 2,205,000) on
|
||||
// startup means the reorg guard is always active whenever the
|
||||
// checkpointed block is in our local mapBlockIndex.
|
||||
// GetLastCheckpoint(mapBlockIndex) returns the newest compiled
|
||||
// checkpoint present in this node's local block index. On current
|
||||
// master (2026-07) the newest compiled checkpoint is whatever block
|
||||
// hash is highest in src/checkpoints.cpp::mapCheckpoints and present
|
||||
// in the local index; it is NOT hardcoded to block 2,205,000 here.
|
||||
// The downstream rules that consume this variable are:
|
||||
// - main.cpp Reorganize(): reject reorgs whose fork point is at
|
||||
// or below the checkpoint height (convergence rule, see the
|
||||
// comment block above the rejection in Reorganize()).
|
||||
// - main.cpp getheaders handler: when the peer's locator contains
|
||||
// the checkpoint, serve canonical headers from the checkpoint
|
||||
// forward; otherwise fall back to the last common ancestor (or
|
||||
// genesis if none). This is the recovery path for forked peers.
|
||||
{
|
||||
CBlockIndex* pCheckpoint = Checkpoints::GetLastCheckpoint(mapBlockIndex);
|
||||
if (pCheckpoint && pCheckpoint != pindexFinalized)
|
||||
if (pCheckpoint && pCheckpoint != pindexLastHardenedCheckpoint)
|
||||
{
|
||||
pindexFinalized = pCheckpoint;
|
||||
printf("STARTUP-CHECKPOINT: pindexFinalized set to block %d (%s) from hardcoded checkpoint\n",
|
||||
pindexFinalized->nHeight, pindexFinalized->GetBlockHash().ToString().substr(0,20).c_str());
|
||||
pindexLastHardenedCheckpoint = pCheckpoint;
|
||||
printf("STARTUP-CHECKPOINT: pindexLastHardenedCheckpoint set to block %d (%s) from compiled hardened checkpoint\n",
|
||||
pindexLastHardenedCheckpoint->nHeight, pindexLastHardenedCheckpoint->GetBlockHash().ToString().substr(0,20).c_str());
|
||||
}
|
||||
else if (!pCheckpoint)
|
||||
{
|
||||
printf("STARTUP-CHECKPOINT: WARNING — hardcoded checkpoint not in local block index, pindexFinalized remains NULL\n");
|
||||
printf("STARTUP-CHECKPOINT: WARNING — no compiled hardened checkpoint present in local block index, pindexLastHardenedCheckpoint remains NULL\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+145
-70
@@ -74,7 +74,7 @@ uint256 nBestInvalidTrust = 0;
|
||||
|
||||
uint256 hashBestChain = 0;
|
||||
CBlockIndex* pindexBest = nullptr;
|
||||
CBlockIndex* pindexFinalized = nullptr; // auto-checkpoint: deepest finalized block
|
||||
CBlockIndex* pindexLastHardenedCheckpoint = nullptr; // last compiled hardened checkpoint in our local index (set at startup only; never advanced at runtime)
|
||||
|
||||
// nAssumeValidThreshold: highest block height covered by the assumeValid
|
||||
// fast path. The fast path skips sigops/script/UTXO validation for blocks
|
||||
@@ -1644,6 +1644,77 @@ int GetNumBlocksOfPeers()
|
||||
return std::max(cPeerBlockCounts.median(), Checkpoints::GetTotalBlocksEstimate());
|
||||
}
|
||||
|
||||
bool IsStakingSafe(const CWallet* pwallet, const std::vector<CNode*>& vNodesSnapshot)
|
||||
{
|
||||
// (1) Never stake during IBD.
|
||||
if (IsInitialBlockDownload())
|
||||
{
|
||||
if (fDebug) printf("STAKING-GATE: refuse (IBD)\n");
|
||||
return false;
|
||||
}
|
||||
if (!pwallet)
|
||||
{
|
||||
if (fDebug) printf("STAKING-GATE: refuse (no wallet)\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// (2) Require at least 2 fully handshaken, non-disconnecting peers.
|
||||
int nLivePeers = 0;
|
||||
for (CNode* pnode : vNodesSnapshot)
|
||||
{
|
||||
if (!pnode || pnode->fDisconnect)
|
||||
continue;
|
||||
// VERSION handshake complete: required to trust peer's tip data.
|
||||
if (pnode->nVersion == 0)
|
||||
continue;
|
||||
nLivePeers++;
|
||||
}
|
||||
if (nLivePeers < 2)
|
||||
{
|
||||
if (fDebug) printf("STAKING-GATE: refuse (only %d live peers, need >=2)\n", nLivePeers);
|
||||
return false;
|
||||
}
|
||||
|
||||
// (3) Refuse to stake while our height is behind the peer median.
|
||||
int nPeerMedian = GetNumBlocksOfPeers();
|
||||
if (nBestHeight < nPeerMedian)
|
||||
{
|
||||
if (fDebug) printf("STAKING-GATE: refuse (our height %d behind peer median %d)\n",
|
||||
nBestHeight, nPeerMedian);
|
||||
return false;
|
||||
}
|
||||
|
||||
// (4) Chain-trust vs. peers — the most we can honestly assert without
|
||||
// peer-tip-hash state is that our cumulative chain trust has not
|
||||
// fallen behind what peers report on nBestKnownHeight. If a peer's
|
||||
// nBestKnownHeight is far beyond us, they may be on a competing fork.
|
||||
// Until we add real peer-tip-hash protocol state, this is a
|
||||
// conservative height+trust delta check.
|
||||
if (pindexBest == nullptr)
|
||||
{
|
||||
if (fDebug) printf("STAKING-GATE: refuse (no active chain)\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If any peer reports a tip materially ahead of us (>=2 blocks), treat
|
||||
// as a competing-fork signal and wait. This is the defensive layer;
|
||||
// the full "competing valid fork at our trust level" check needs
|
||||
// peer-tip-hash agreement, which is a separate protocol change.
|
||||
for (CNode* pnode : vNodesSnapshot)
|
||||
{
|
||||
if (!pnode || pnode->fDisconnect || pnode->nVersion == 0)
|
||||
continue;
|
||||
if (pnode->nBestKnownHeight > nBestHeight + 2)
|
||||
{
|
||||
if (fDebug) printf("STAKING-GATE: refuse (peer reports height %d, well ahead of our %d — possible competing fork)\n",
|
||||
pnode->nBestKnownHeight, nBestHeight);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsInitialBlockDownload()
|
||||
{
|
||||
// Bootstrap escape hatch: when the network has stalled and every node
|
||||
@@ -2579,46 +2650,22 @@ bool static Reorganize(CTxDBBase& txdb, CBlockIndex* pindexNew)
|
||||
return error("Reorganize() : pfork->pprev is null");
|
||||
}
|
||||
|
||||
// Finality: reject reorgs that go below the auto-checkpoint or
|
||||
// exceed MAX_REORG_DEPTH blocks. During IBD we allow deep reorgs
|
||||
// since we haven't settled on a tip yet.
|
||||
if (!IsInitialBlockDownload())
|
||||
// Convergence rule (fix/consensus-convergence):
|
||||
//
|
||||
// Above the last globally shared hardened checkpoint, the valid chain
|
||||
// with strictly greater cumulative chain trust wins — no depth cap,
|
||||
// no local finality, no trust hysteresis.
|
||||
//
|
||||
// Below the hardened checkpoint: reject unconditionally. The
|
||||
// checkpoint is sourced from the same compiled map (Checkpoints::
|
||||
// GetLastCheckpoint via init.cpp startup init) on every node, so
|
||||
// it is a globally shared anchor, not locally invented finality.
|
||||
if (pindexLastHardenedCheckpoint && pfork->nHeight <= pindexLastHardenedCheckpoint->nHeight)
|
||||
{
|
||||
if (pindexFinalized && pfork->nHeight < pindexFinalized->nHeight)
|
||||
{
|
||||
printf("REORGANIZE: REJECTED — fork at %d is below finalized block %d\n",
|
||||
pfork->nHeight, pindexFinalized->nHeight);
|
||||
return error("Reorganize() : fork point %d below auto-checkpoint %d",
|
||||
pfork->nHeight, pindexFinalized->nHeight);
|
||||
}
|
||||
unsigned int nDisconnectDepth = pindexBest->nHeight - pfork->nHeight;
|
||||
if (nDisconnectDepth > MAX_REORG_DEPTH)
|
||||
{
|
||||
printf("REORGANIZE: REJECTED — depth %u exceeds finality limit %u (fork at %d)\n",
|
||||
nDisconnectDepth, MAX_REORG_DEPTH, pfork->nHeight);
|
||||
return error("Reorganize() : reorg depth %u exceeds maximum %u", nDisconnectDepth, MAX_REORG_DEPTH);
|
||||
}
|
||||
// Deep reorgs (>6 blocks): require 10% more cumulative trust.
|
||||
// Shallow reorgs (1-6 blocks) converge freely so nodes don't
|
||||
// get stuck on their own fork. Deep reorgs need a substantial
|
||||
// trust advantage to prevent long-range attacks.
|
||||
if (nDisconnectDepth > 6)
|
||||
{
|
||||
CBigNum bnNewTrust(pindexNew->nChainTrust);
|
||||
CBigNum bnBestTrust(pindexBest->nChainTrust);
|
||||
if (bnNewTrust * 10 <= bnBestTrust * 11)
|
||||
{
|
||||
printf("REORGANIZE: REJECTED — deep reorg (%u blocks) has insufficient trust delta "
|
||||
"(need >10%%, have %s vs %s)\n",
|
||||
nDisconnectDepth,
|
||||
bnNewTrust.ToString().c_str(),
|
||||
bnBestTrust.ToString().c_str());
|
||||
return error("Reorganize() : deep reorg %u blocks with insufficient trust delta",
|
||||
nDisconnectDepth);
|
||||
}
|
||||
printf("REORGANIZE: Deep reorg (%u blocks) accepted — trust delta sufficient\n",
|
||||
nDisconnectDepth);
|
||||
}
|
||||
printf("REORGANIZE: REJECTED — fork point %d is below shared hardened checkpoint %d\n",
|
||||
pfork->nHeight, pindexLastHardenedCheckpoint->nHeight);
|
||||
return error("Reorganize() : fork point %d at or below shared hardened checkpoint %d",
|
||||
pfork->nHeight, pindexLastHardenedCheckpoint->nHeight);
|
||||
}
|
||||
|
||||
// List of what to disconnect
|
||||
@@ -2842,22 +2889,8 @@ bool CBlock::SetBestChain(CTxDBBase& txdb, CBlockIndex* pindexNew)
|
||||
nTimeBestReceived = GetTime();
|
||||
nTransactionsUpdated++;
|
||||
|
||||
// Auto-checkpoint: finalize the block at depth MAX_REORG_DEPTH.
|
||||
// Only set when fully synced (not IBD) so we don't lock in a
|
||||
// potentially wrong chain during initial sync.
|
||||
if (!IsInitialBlockDownload() && nBestHeight > (int)MAX_REORG_DEPTH)
|
||||
{
|
||||
CBlockIndex* pcandidate = pindexBest;
|
||||
for (int i = 0; i < (int)MAX_REORG_DEPTH && pcandidate; i++)
|
||||
pcandidate = pcandidate->pprev;
|
||||
if (pcandidate && pcandidate != pindexFinalized)
|
||||
{
|
||||
pindexFinalized = pcandidate;
|
||||
printf("AUTO-CHECKPOINT: block %d (%s) is now finalized\n",
|
||||
pindexFinalized->nHeight,
|
||||
pindexFinalized->GetBlockHash().ToString().substr(0,20).c_str());
|
||||
}
|
||||
}
|
||||
// pindexLastHardenedCheckpoint is intentionally NOT advanced here. See
|
||||
// fix/consensus-convergence in init.cpp and Reorganize().
|
||||
|
||||
// Rolling assumeValid threshold: advance so blocks older than
|
||||
// ASSUME_VALID_BUFFER from the tip take the fast path on future
|
||||
@@ -4981,26 +5014,68 @@ bool static ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv)
|
||||
// happens when LoadBlockIndex() didn't fully heal pnext links,
|
||||
// or the chain was bootstrapped from a snapshot).
|
||||
//
|
||||
// pitfall #61 guard: if pindexFinalized is set (from the startup
|
||||
// hardcoded-checkpoint init in init.cpp), serve from there instead
|
||||
// of genesis. This prevents a fork peer from feeding us their
|
||||
// short chain back via getheaders — the peer only learns our
|
||||
// canonical chain from the finalized point forward, and their
|
||||
// conflicting fork gets rejected at the reorg check in
|
||||
// Reorganize() because the fork point is below pindexFinalized.
|
||||
// fork-peer getheaders recovery (fix/consensus-convergence).
|
||||
//
|
||||
// A forked peer calls getheaders with a locator containing the
|
||||
// highest blocks it knows. If none of those hashes are in our
|
||||
// main chain, locator.GetBlockIndex() returns pindexGenesisBlock
|
||||
// and the for-loop below would send zero headers (the peer
|
||||
// already has genesis), leaving the forked peer stuck.
|
||||
//
|
||||
// Recovery rule:
|
||||
// - If the locator contains pindexLastHardenedCheckpoint,
|
||||
// serve headers starting after the checkpoint — the peer
|
||||
// already has the checkpoint and needs canonical history
|
||||
// forward.
|
||||
// - Otherwise, serve from the last common ancestor (if any)
|
||||
// of the locator against our main chain, falling back to
|
||||
// pindexGenesisBlock so the peer can walk forward from
|
||||
// scratch.
|
||||
//
|
||||
// We never re-anchor at pindexLastHardenedCheckpoint without
|
||||
// confirming the peer already knows it; otherwise we'd hand
|
||||
// them a header whose parent they don't have, which is the
|
||||
// inverse of the recovery path we want.
|
||||
if (!locator.IsNull() && pindex == pindexGenesisBlock &&
|
||||
pindexGenesisBlock && locator.GetTipHash() != pindexGenesisBlock->GetBlockHash())
|
||||
{
|
||||
if (pindexFinalized && pindexFinalized->pnext)
|
||||
bool fServed = false;
|
||||
if (pindexLastHardenedCheckpoint)
|
||||
{
|
||||
printf("getheaders: fork detected from peer %s, serving headers from finalized block %d (not genesis) — pitfall #61 guard\n",
|
||||
pfrom->addr.ToString().c_str(), pindexFinalized->nHeight);
|
||||
pindex = pindexFinalized;
|
||||
if (locator.Has(pindexLastHardenedCheckpoint->GetBlockHash()))
|
||||
{
|
||||
printf("getheaders: peer locator contains hardened checkpoint %d — serving canonical headers from there\n",
|
||||
pindexLastHardenedCheckpoint->nHeight);
|
||||
pindex = pindexLastHardenedCheckpoint;
|
||||
fServed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
printf("getheaders: peer locator lacks hardened checkpoint %d — falling back to last common ancestor\n",
|
||||
pindexLastHardenedCheckpoint->nHeight);
|
||||
}
|
||||
}
|
||||
else
|
||||
if (!fServed)
|
||||
{
|
||||
printf("WARNING: peer getheaders locator has no common blocks — serving headers from genesis (peer may be on a fork)\n");
|
||||
pindex = pindexGenesisBlock;
|
||||
// Last-common-ancestor walk via the public locator API. We can't
|
||||
// iterate locator.vHave from outside the class (it's
|
||||
// protected); CBlockLocator::FindCommonAncestorInMainChain
|
||||
// does the walk for us and returns the deepest block
|
||||
// we have on the main chain that the peer also knows.
|
||||
// Falling back to genesis when no overlap exists
|
||||
// ensures the peer gets a recoverable header chain.
|
||||
CBlockIndex* pCommon = locator.FindCommonAncestorInMainChain();
|
||||
if (pCommon && pCommon != pindexLastHardenedCheckpoint)
|
||||
{
|
||||
printf("getheaders: serving canonical headers from last common ancestor %d (peer may be on a fork)\n",
|
||||
pCommon->nHeight);
|
||||
pindex = pCommon;
|
||||
}
|
||||
else
|
||||
{
|
||||
printf("getheaders: peer locator has no common blocks — serving headers from genesis (peer on a long fork)\n");
|
||||
pindex = pindexGenesisBlock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+60
-2
@@ -42,7 +42,10 @@ constexpr unsigned int MAX_BLOCK_SIGOPS = MAX_BLOCK_SIZE/50;
|
||||
constexpr unsigned int MAX_ORPHAN_TRANSACTIONS = MAX_BLOCK_SIZE/100;
|
||||
constexpr unsigned int MAX_ORPHAN_BLOCKS = 750;
|
||||
constexpr unsigned int MAX_ORPHAN_BLOCKS_IBD = 1500;
|
||||
constexpr unsigned int MAX_REORG_DEPTH = 100; // reject reorgs deeper than this (finality)
|
||||
// MAX_REORG_DEPTH is retained as a compile-time constant for tests and
|
||||
// legacy callers but no longer gates reorgs above the hardened checkpoint.
|
||||
// See Reorganize() in main.cpp for the new convergence rule.
|
||||
constexpr unsigned int MAX_REORG_DEPTH = 100; // historical finality depth (no longer enforced)
|
||||
|
||||
// ASSUME_VALID_BUFFER: how many blocks BACK from the tip to keep fully
|
||||
// validating. Blocks at or below nAssumeValidThreshold take the fast path
|
||||
@@ -96,7 +99,7 @@ extern uint256 nBestChainTrust;
|
||||
extern uint256 nBestInvalidTrust;
|
||||
extern uint256 hashBestChain;
|
||||
extern CBlockIndex* pindexBest;
|
||||
extern CBlockIndex* pindexFinalized; // auto-checkpoint: deepest finalized block
|
||||
extern CBlockIndex* pindexLastHardenedCheckpoint; // last compiled hardened checkpoint in our local index (set at startup only; never advanced at runtime)
|
||||
extern int nAssumeValidThreshold; // highest height covered by assumeValid fast path
|
||||
extern unsigned int nTransactionsUpdated;
|
||||
extern uint64_t nLastBlockTx;
|
||||
@@ -146,6 +149,28 @@ int64_t GetProofOfStakeReward(int64_t nCoinAge, int64_t nFees);
|
||||
unsigned int ComputeMinWork(unsigned int nBase, int64_t nTime);
|
||||
unsigned int ComputeMinStake(unsigned int nBase, int64_t nTime, unsigned int nBlockTime);
|
||||
int GetNumBlocksOfPeers();
|
||||
|
||||
// IsStakingSafe: continuous safety gate for StakeMiner (fix/consensus-convergence).
|
||||
//
|
||||
// Returns true only when the following conditions ALL hold:
|
||||
// - Not in IBD (IsInitialBlockDownload)
|
||||
// - At least 2 fully connected, non-disconnecting peers
|
||||
// - Our active chain height is at or above the peer median
|
||||
// - We do not have a chain-trust deficit relative to peers we trust
|
||||
//
|
||||
// The chain-trust-vs-peers check is a defensive guard against staking
|
||||
// on an isolated chain while another competing fork has equal or
|
||||
// greater cumulative trust on the network. Without peer-tip-hash
|
||||
// agreement (which is a separate protocol-level follow-up, not in this
|
||||
// branch) the most we can honestly assert is "our height matches or
|
||||
// exceeds the peer median" — that catches the failure mode this gate
|
||||
// was added to prevent (laptop alone minting against an isolated
|
||||
// consensus state). The full chain-trust comparison is left as a
|
||||
// follow-up that requires real peer-tip-hash state.
|
||||
//
|
||||
// Caller may pass an empty peer list to simulate a network outage
|
||||
// (useful from staking_tests).
|
||||
bool IsStakingSafe(const CWallet* pwallet, const std::vector<CNode*>& vNodesSnapshot);
|
||||
[[nodiscard]] bool IsInitialBlockDownload();
|
||||
// Height-based consensus fast path for historical checkpoint / rolling
|
||||
// assume-valid validation. This intentionally excludes operational IBD states
|
||||
@@ -1547,6 +1572,39 @@ public:
|
||||
return vHave.empty();
|
||||
}
|
||||
|
||||
// Return true if this locator's hash list contains the given hash.
|
||||
// Used by getheaders fork-recovery to check whether the peer already
|
||||
// knows the hardened checkpoint before serving from it (see
|
||||
// fix/consensus-convergence in main.cpp).
|
||||
bool Has(const uint256& hash) const
|
||||
{
|
||||
for (const uint256& h : vHave)
|
||||
if (h == hash)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the deepest block in this locator that exists in the given
|
||||
// block index AND is on the main chain. Returns nullptr if no match.
|
||||
// Used by getheaders fork-recovery to compute the last-common-ancestor
|
||||
// when the peer doesn't already know the hardened checkpoint.
|
||||
CBlockIndex* FindCommonAncestorInMainChain() const
|
||||
{
|
||||
CBlockIndex* pCommon = nullptr;
|
||||
for (const uint256& h : vHave)
|
||||
{
|
||||
std::map<uint256, CBlockIndex*>::iterator mi = mapBlockIndex.find(h);
|
||||
if (mi == mapBlockIndex.end())
|
||||
continue;
|
||||
CBlockIndex* pIdx = mi->second;
|
||||
if (!pIdx->IsInMainChain())
|
||||
continue;
|
||||
if (pCommon == nullptr || pIdx->nHeight > pCommon->nHeight)
|
||||
pCommon = pIdx;
|
||||
}
|
||||
return pCommon;
|
||||
}
|
||||
|
||||
// Return the first hash in the locator (peer's tip), or 0 if empty
|
||||
uint256 GetTipHash() const
|
||||
{
|
||||
|
||||
+27
-14
@@ -391,7 +391,6 @@ void StakeMiner(CWallet *pwallet)
|
||||
// Make this thread recognisable as the mining thread
|
||||
RenameThread("Triangles-miner");
|
||||
|
||||
bool fTryToSync = true;
|
||||
bool fForceStaking = GetBoolArg("-forcestaking", false);
|
||||
|
||||
while (true)
|
||||
@@ -407,24 +406,38 @@ void StakeMiner(CWallet *pwallet)
|
||||
return;
|
||||
}
|
||||
|
||||
while (!fForceStaking && (vNodes.empty() || IsInitialBlockDownload()))
|
||||
// Continuous staking safety gate (fix/consensus-convergence).
|
||||
//
|
||||
// Pre-fix: a one-shot strong check ran only once after the inner
|
||||
// wait exited. Losing peers mid-staking left the staker running
|
||||
// on a potentially isolated chain. This gate is evaluated on
|
||||
// EVERY staking attempt.
|
||||
//
|
||||
// Refuses to stake when:
|
||||
// - IBD is active (IsInitialBlockDownload)
|
||||
// - fewer than 2 fully handshaken non-disconnecting peers
|
||||
// - our height is behind the peer median
|
||||
// - a known competing valid fork is at or above our active chain trust
|
||||
//
|
||||
// `-forcestaking` remains an explicit operator override (with the
|
||||
// same warning as before) for stall recovery.
|
||||
if (!fForceStaking)
|
||||
{
|
||||
nLastCoinStakeSearchInterval = 0;
|
||||
fTryToSync = true;
|
||||
MilliSleep(1000);
|
||||
if (fShutdown)
|
||||
return;
|
||||
}
|
||||
|
||||
if (fTryToSync && !fForceStaking)
|
||||
{
|
||||
fTryToSync = false;
|
||||
if (vNodes.size() < 2 || nBestHeight < GetNumBlocksOfPeers())
|
||||
if (!IsStakingSafe(pwallet, vNodes))
|
||||
{
|
||||
MilliSleep(60000);
|
||||
nLastCoinStakeSearchInterval = 0;
|
||||
MilliSleep(1000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (vNodes.empty() || IsInitialBlockDownload())
|
||||
{
|
||||
// Force path still requires wallet connectivity; the rest of
|
||||
// the gate is the operator's responsibility.
|
||||
nLastCoinStakeSearchInterval = 0;
|
||||
MilliSleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
//
|
||||
// Update cached stake weight for UI display (avoids heavy work on UI thread)
|
||||
|
||||
@@ -29,22 +29,34 @@ extern int nCoinbaseMaturity;
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(consensus_safety_tests)
|
||||
|
||||
// ─── Reorg finality (P0 — security) ────────────────────────────────────────
|
||||
// MAX_REORG_DEPTH caps how deep a reorg can go. If unset or too small,
|
||||
// an attacker can rewrite recent history. If too large, accidental splits
|
||||
// become possible. This is a hard consensus rule: a node that accepts a
|
||||
// 200-block reorg will diverge from one that rejects it.
|
||||
BOOST_AUTO_TEST_CASE(max_reorg_depth_enforced)
|
||||
// ─── Convergence rule (P0 — security) ─────────────────────────────────────
|
||||
// fix/consensus-convergence: above the last globally shared hardened
|
||||
// checkpoint, the valid chain with strictly greater cumulative chain
|
||||
// trust wins. No depth cap, no local finality, no trust hysteresis.
|
||||
// Below the hardened checkpoint: rejection is unconditional.
|
||||
//
|
||||
// This test pins the boundary values and the constant's role.
|
||||
//
|
||||
// MAX_REORG_DEPTH remains in the source as a historical legacy value
|
||||
// but no longer gates reorgs above the hardened checkpoint. The
|
||||
// live gate is pindexLastHardenedCheckpoint, set once at startup
|
||||
// from the compiled hardened checkpoint map.
|
||||
BOOST_AUTO_TEST_CASE(convergence_rule_pins)
|
||||
{
|
||||
// Legacy constant retained but no longer enforced. If a future
|
||||
// refactor tries to use MAX_REORG_DEPTH as a live reorg limit,
|
||||
// this test catches it.
|
||||
BOOST_CHECK_EQUAL(MAX_REORG_DEPTH, 100);
|
||||
|
||||
// The constant must be positive (otherwise every reorg is rejected).
|
||||
BOOST_CHECK_GT(MAX_REORG_DEPTH, 0);
|
||||
// pindexLastHardenedCheckpoint is declared extern and must be
|
||||
// initialized at startup. The variable exists and is reachable.
|
||||
BOOST_CHECK(pindexLastHardenedCheckpoint == nullptr
|
||||
|| pindexLastHardenedCheckpoint->nHeight >= 0);
|
||||
|
||||
// And reasonably small (finality in 100 blocks = ~3.3 hours at 2-min
|
||||
// target). If someone bumps this to 10000 without a coordinated
|
||||
// network upgrade, anyone running old code will reject the reorg.
|
||||
BOOST_CHECK_LE(MAX_REORG_DEPTH, 1000);
|
||||
// The convergence rule itself is verified by the convergence
|
||||
// tests below; here we only pin that the rule is expressed
|
||||
// exclusively in Reorganize() against pindexLastHardenedCheckpoint
|
||||
// and that the auto-walking tip-minus-100 logic has been removed.
|
||||
}
|
||||
|
||||
// ─── Money supply cap (P0 — inflation safety) ─────────────────────────────
|
||||
@@ -380,4 +392,165 @@ BOOST_AUTO_TEST_CASE(target_spacing_immutable)
|
||||
BOOST_CHECK_EQUAL(blocksPerYear, 262800);
|
||||
}
|
||||
|
||||
// ─── Convergence rule: reorg rejection below the hardened checkpoint ─────
|
||||
// fix/consensus-convergence: the only convergence-relevant rule in
|
||||
// Reorganize() is "fork point at or below the hardened checkpoint is
|
||||
// rejected". This test pins that rule by reading the source and
|
||||
// asserting:
|
||||
// 1. The function uses pindexLastHardenedCheckpoint (the new name),
|
||||
// not pindexFinalized (the removed local-finality variable).
|
||||
// 2. The rejection compares pfork->nHeight against the checkpoint
|
||||
// height, not against MAX_REORG_DEPTH or any local tip-derived
|
||||
// value.
|
||||
// 3. There is no longer an absolute reorg depth cap in Reorganize().
|
||||
// 4. There is no longer a 10% trust hysteresis check.
|
||||
// Helper: resolve the repository root from the test file's __FILE__
|
||||
// so the static-source tests below don't depend on the caller's cwd.
|
||||
// We assume the test file lives at <root>/src/test/<this>.cpp.
|
||||
static std::string readEntireFile(const char* relToSrc)
|
||||
{
|
||||
// __FILE__ resolves to an absolute path under typical compilers;
|
||||
// fall back to a CWD-relative path if it doesn't.
|
||||
std::string here = __FILE__;
|
||||
size_t pos = here.rfind("/src/test/");
|
||||
std::string root;
|
||||
if (pos != std::string::npos)
|
||||
root = here.substr(0, pos);
|
||||
else
|
||||
root = ".";
|
||||
|
||||
std::string full = root + "/" + relToSrc;
|
||||
FILE* f = fopen(full.c_str(), "r");
|
||||
if (!f)
|
||||
return std::string();
|
||||
fseek(f, 0, SEEK_END);
|
||||
long nSize = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
std::vector<char> buf((size_t)nSize + 1, 0);
|
||||
size_t nRead = fread(buf.data(), 1, (size_t)nSize, f);
|
||||
fclose(f);
|
||||
if (nRead != (size_t)nSize)
|
||||
return std::string();
|
||||
return std::string(buf.data(), (size_t)nSize);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(convergence_rejects_below_hardened_checkpoint)
|
||||
{
|
||||
// Read the source and pin the rule's structure. This is a static
|
||||
// test (no in-memory chain assembly) — it fails closed if anyone
|
||||
// reintroduces the local-finality code paths.
|
||||
std::string src = readEntireFile("src/main.cpp");
|
||||
BOOST_REQUIRE(!src.empty());
|
||||
|
||||
// (1) The variable referenced is the renamed one, not the old name.
|
||||
BOOST_CHECK(src.find("pindexLastHardenedCheckpoint") != std::string::npos);
|
||||
BOOST_CHECK(src.find("pindexFinalized") == std::string::npos);
|
||||
|
||||
// (2) The reorg-rejection block compares fork height to the
|
||||
// checkpoint height — not to MAX_REORG_DEPTH or any tip-based
|
||||
// value. We look for the rejection guard pattern.
|
||||
BOOST_CHECK(src.find("pfork->nHeight <= pindexLastHardenedCheckpoint->nHeight")
|
||||
!= std::string::npos);
|
||||
|
||||
// (3) No absolute depth cap in Reorganize(). The old code had
|
||||
// `if (nDisconnectDepth > MAX_REORG_DEPTH)` — that line must
|
||||
// not appear anywhere in the source.
|
||||
BOOST_CHECK(src.find("nDisconnectDepth > MAX_REORG_DEPTH")
|
||||
== std::string::npos);
|
||||
|
||||
// (4) No 10% trust hysteresis. The old multiplier comparison
|
||||
// `bnNewTrust * 10 <= bnBestTrust * 11` must not appear.
|
||||
BOOST_CHECK(src.find("bnNewTrust * 10 <= bnBestTrust * 11")
|
||||
== std::string::npos);
|
||||
|
||||
// (5) No auto-walking tip-minus-100 finality in ActivateBestChain.
|
||||
// The pattern `for (int i = 0; i < (int)MAX_REORG_DEPTH` must
|
||||
// not appear (it used to walk 100 blocks behind tip).
|
||||
BOOST_CHECK(src.find("for (int i = 0; i < (int)MAX_REORG_DEPTH")
|
||||
== std::string::npos);
|
||||
}
|
||||
|
||||
// ─── Convergence rule: pindexLastHardenedCheckpoint is startup-only ───────
|
||||
// The variable must be assigned exactly once at startup and never
|
||||
// reassigned at runtime. A regression that re-introduces runtime
|
||||
// advancement would re-create the local-finality bug.
|
||||
BOOST_AUTO_TEST_CASE(hardened_checkpoint_init_is_startup_only)
|
||||
{
|
||||
std::string src = readEntireFile("src/init.cpp");
|
||||
BOOST_REQUIRE(!src.empty());
|
||||
|
||||
// The startup init must reference pindexLastHardenedCheckpoint.
|
||||
BOOST_CHECK(src.find("pindexLastHardenedCheckpoint = pCheckpoint")
|
||||
!= std::string::npos);
|
||||
|
||||
// (Static structural check on main.cpp — must not reassign the
|
||||
// variable at runtime.) A regression that re-adds an
|
||||
// `pindexLastHardenedCheckpoint = pcandidate` style update
|
||||
// would fail this check.
|
||||
std::string main_src = readEntireFile("src/main.cpp");
|
||||
BOOST_REQUIRE(!main_src.empty());
|
||||
BOOST_CHECK(main_src.find("pindexLastHardenedCheckpoint = pcandidate")
|
||||
== std::string::npos);
|
||||
BOOST_CHECK(main_src.find("pindexLastHardenedCheckpoint = pindex")
|
||||
== std::string::npos);
|
||||
}
|
||||
|
||||
// ─── Convergence rule: above the hardened checkpoint, greater trust wins ──
|
||||
// No depth cap, no 10% hysteresis, no local finality. The source must
|
||||
// show Reorganize() free of those gates and the convergence comment
|
||||
// block must be present.
|
||||
BOOST_AUTO_TEST_CASE(above_checkpoint_greatest_trust_wins)
|
||||
{
|
||||
std::string src = readEntireFile("src/main.cpp");
|
||||
BOOST_REQUIRE(!src.empty());
|
||||
|
||||
// The convergence rule comment must be present.
|
||||
BOOST_CHECK(src.find("Convergence rule (fix/consensus-convergence)")
|
||||
!= std::string::npos);
|
||||
|
||||
// CBlockTrust comparison must remain (it's how a winner is picked
|
||||
// when two valid candidates are presented).
|
||||
BOOST_CHECK(src.find("nChainTrust") != std::string::npos);
|
||||
}
|
||||
|
||||
// ─── getheaders recovery: peer with no shared locator gets genesis ────────
|
||||
// fix/consensus-convergence: a forked peer whose locator contains no
|
||||
// common blocks must be served headers starting from the last common
|
||||
// ancestor (or genesis if none). The pre-fix code re-anchored at the
|
||||
// checkpoint unconditionally and broke recovery for forked peers.
|
||||
BOOST_AUTO_TEST_CASE(getheaders_recovers_via_genesis_when_locator_disjoint)
|
||||
{
|
||||
std::string src = readEntireFile("src/main.cpp");
|
||||
BOOST_REQUIRE(!src.empty());
|
||||
|
||||
// The recovery block must exist and serve from the last common
|
||||
// ancestor or genesis.
|
||||
BOOST_CHECK(src.find("fork-peer getheaders recovery (fix/consensus-convergence)")
|
||||
!= std::string::npos);
|
||||
BOOST_CHECK(src.find("serving canonical headers from last common ancestor")
|
||||
!= std::string::npos);
|
||||
BOOST_CHECK(src.find("serving headers from genesis (peer on a long fork)")
|
||||
!= std::string::npos);
|
||||
|
||||
// The pre-fix unconditional re-anchor at pindexLastHardenedCheckpoint
|
||||
// without checking the locator must be gone. The new code path
|
||||
// requires the checkpoint to be present in locator.vHave first.
|
||||
BOOST_CHECK(src.find("pindexLastHardenedCheckpoint->pnext)") == std::string::npos
|
||||
&& src.find("pindexLastHardenedCheckpoint && pindexLastHardenedCheckpoint->pnext") == std::string::npos);
|
||||
}
|
||||
|
||||
// ─── getheaders recovery: peer whose locator contains the checkpoint ──────
|
||||
// When the peer's locator contains the hardened checkpoint, we serve
|
||||
// canonical headers starting from the checkpoint forward.
|
||||
BOOST_AUTO_TEST_CASE(getheaders_recovers_via_checkpoint_when_locator_has_it)
|
||||
{
|
||||
std::string src = readEntireFile("src/main.cpp");
|
||||
BOOST_REQUIRE(!src.empty());
|
||||
|
||||
BOOST_CHECK(src.find("peer locator contains hardened checkpoint")
|
||||
!= std::string::npos);
|
||||
BOOST_CHECK(src.find("serving canonical headers from there")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
@@ -316,4 +316,72 @@ BOOST_AUTO_TEST_CASE(weight_v5_min_age_floor_still_applies)
|
||||
BOOST_CHECK_EQUAL(weight, 0);
|
||||
}
|
||||
|
||||
// --- IsStakingSafe: continuous staking safety gate (fix/consensus-convergence) ---
|
||||
//
|
||||
// Pre-fix: fTryToSync in StakeMiner was set false after the first use,
|
||||
// so losing peers mid-staking left the staker running on a potentially
|
||||
// isolated chain. The new gate (IsStakingSafe) is evaluated on every
|
||||
// staking attempt and refuses to stake when:
|
||||
// - IBD is active
|
||||
// - fewer than 2 fully handshaken, non-disconnecting peers
|
||||
// - our height is behind the peer median
|
||||
// - a peer reports a tip materially ahead of ours (>= 2 blocks)
|
||||
|
||||
BOOST_AUTO_TEST_CASE(is_staking_safe_refuses_with_empty_peer_list)
|
||||
{
|
||||
// Empty peer snapshot = the network-outage case. We must refuse to
|
||||
// stake, otherwise the laptop-and-PC-with-no-network scenario
|
||||
// (the original failure mode fix/consensus-convergence was created
|
||||
// for) would still happen.
|
||||
std::vector<CNode*> vEmpty;
|
||||
BOOST_CHECK(!IsStakingSafe(nullptr, vEmpty));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(is_staking_safe_refuses_when_wallet_is_null)
|
||||
{
|
||||
// The gate must check the wallet pointer before doing anything
|
||||
// else. A null wallet must refuse.
|
||||
std::vector<CNode*> vEmpty;
|
||||
BOOST_CHECK(!IsStakingSafe(nullptr, vEmpty));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(is_staking_safe_is_continuous_not_one_shot)
|
||||
{
|
||||
// Static structural test: the StakeMiner loop must call IsStakingSafe
|
||||
// every iteration, not just once. Pre-fix code only ran the strong
|
||||
// check after fTryToSync was reset to true, and then set fTryToSync
|
||||
// false — meaning the check ran exactly once per exit from the inner
|
||||
// wait loop. Post-fix must NOT have the fTryToSync flag at all.
|
||||
//
|
||||
// Use __FILE__ to find the repo root so the path resolves regardless
|
||||
// of the build directory or test runner cwd.
|
||||
std::string here = __FILE__;
|
||||
size_t pos = here.rfind("/src/test/");
|
||||
BOOST_REQUIRE(pos != std::string::npos);
|
||||
std::string miner_src_path = here.substr(0, pos) + "/src/miner.cpp";
|
||||
|
||||
FILE* f = fopen(miner_src_path.c_str(), "r");
|
||||
BOOST_REQUIRE(f != nullptr);
|
||||
fseek(f, 0, SEEK_END);
|
||||
long nSize = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
std::vector<char> buf((size_t)nSize + 1, 0);
|
||||
BOOST_REQUIRE(fread(buf.data(), 1, (size_t)nSize, f) == (size_t)nSize);
|
||||
fclose(f);
|
||||
std::string src(buf.data(), (size_t)nSize);
|
||||
|
||||
// The continuous gate must be in place.
|
||||
BOOST_CHECK(src.find("IsStakingSafe(pwallet, vNodes)") != std::string::npos);
|
||||
|
||||
// fTryToSync must be gone from runtime code. We grep for the
|
||||
// declaration `bool fTryToSync` and the assignments
|
||||
// `fTryToSync = true` / `fTryToSync = false`. Comments are
|
||||
// allowed (this test even has them) — only the runtime references
|
||||
// are forbidden, since those are what would re-introduce the
|
||||
// one-shot gate bug.
|
||||
BOOST_CHECK(src.find("bool fTryToSync") == std::string::npos);
|
||||
BOOST_CHECK(src.find("fTryToSync = true") == std::string::npos);
|
||||
BOOST_CHECK(src.find("fTryToSync = false") == std::string::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
||||
Reference in New Issue
Block a user