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:
Krystie
2026-07-16 21:47:21 -07:00
parent c68a8cb47c
commit 935d1d527c
6 changed files with 507 additions and 119 deletions
+22 -21
View File
@@ -1378,34 +1378,35 @@ bool AppInit2()
if (!LoadBlockIndex()) if (!LoadBlockIndex())
return InitError(_("Error loading blkindex.dat")); return InitError(_("Error loading blkindex.dat"));
// triangles fix (pitfall #61): initialize pindexFinalized from the // pindexLastHardenedCheckpoint is initialized from the hardened checkpoint
// hardcoded checkpoint on startup, BEFORE the daemon opens any peer // map on startup, BEFORE the daemon opens any peer connections or
// connections or processes any block messages. // 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 // GetLastCheckpoint(mapBlockIndex) returns the newest compiled
// we have 2.2M blocks on disk, because the auto-checkpoint code in // checkpoint present in this node's local block index. On current
// ActivateBestChain() at main.cpp:2459 only sets it when // master (2026-07) the newest compiled checkpoint is whatever block
// !IsInitialBlockDownload(). If the chain tip is more than 24h stale // hash is highest in src/checkpoints.cpp::mapCheckpoints and present
// (which happens on every restart with a synced chain), IsInitialBlockDownload() // in the local index; it is NOT hardcoded to block 2,205,000 here.
// returns true and pindexFinalized never gets set. // The downstream rules that consume this variable are:
// // - main.cpp Reorganize(): reject reorgs whose fork point is at
// The downstream reorg guard at main.cpp:2198 short-circuits when // or below the checkpoint height (convergence rule, see the
// pindexFinalized is NULL, which allowed a 3,755-block minority fork // comment block above the rejection in Reorganize()).
// to overwrite a healthy 2,206,004-block chain on 2026-06-16. Loading // - main.cpp getheaders handler: when the peer's locator contains
// the hardcoded checkpoint from checkpoints.cpp (block 2,205,000) on // the checkpoint, serve canonical headers from the checkpoint
// startup means the reorg guard is always active whenever the // forward; otherwise fall back to the last common ancestor (or
// checkpointed block is in our local mapBlockIndex. // genesis if none). This is the recovery path for forked peers.
{ {
CBlockIndex* pCheckpoint = Checkpoints::GetLastCheckpoint(mapBlockIndex); CBlockIndex* pCheckpoint = Checkpoints::GetLastCheckpoint(mapBlockIndex);
if (pCheckpoint && pCheckpoint != pindexFinalized) if (pCheckpoint && pCheckpoint != pindexLastHardenedCheckpoint)
{ {
pindexFinalized = pCheckpoint; pindexLastHardenedCheckpoint = pCheckpoint;
printf("STARTUP-CHECKPOINT: pindexFinalized set to block %d (%s) from hardcoded checkpoint\n", printf("STARTUP-CHECKPOINT: pindexLastHardenedCheckpoint set to block %d (%s) from compiled hardened checkpoint\n",
pindexFinalized->nHeight, pindexFinalized->GetBlockHash().ToString().substr(0,20).c_str()); pindexLastHardenedCheckpoint->nHeight, pindexLastHardenedCheckpoint->GetBlockHash().ToString().substr(0,20).c_str());
} }
else if (!pCheckpoint) 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
View File
@@ -74,7 +74,7 @@ uint256 nBestInvalidTrust = 0;
uint256 hashBestChain = 0; uint256 hashBestChain = 0;
CBlockIndex* pindexBest = nullptr; 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 // nAssumeValidThreshold: highest block height covered by the assumeValid
// fast path. The fast path skips sigops/script/UTXO validation for blocks // 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()); 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() bool IsInitialBlockDownload()
{ {
// Bootstrap escape hatch: when the network has stalled and every node // 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"); return error("Reorganize() : pfork->pprev is null");
} }
// Finality: reject reorgs that go below the auto-checkpoint or // Convergence rule (fix/consensus-convergence):
// exceed MAX_REORG_DEPTH blocks. During IBD we allow deep reorgs //
// since we haven't settled on a tip yet. // Above the last globally shared hardened checkpoint, the valid chain
if (!IsInitialBlockDownload()) // 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 point %d is below shared hardened checkpoint %d\n",
{ pfork->nHeight, pindexLastHardenedCheckpoint->nHeight);
printf("REORGANIZE: REJECTED — fork at %d is below finalized block %d\n", return error("Reorganize() : fork point %d at or below shared hardened checkpoint %d",
pfork->nHeight, pindexFinalized->nHeight); pfork->nHeight, pindexLastHardenedCheckpoint->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);
}
} }
// List of what to disconnect // List of what to disconnect
@@ -2842,22 +2889,8 @@ bool CBlock::SetBestChain(CTxDBBase& txdb, CBlockIndex* pindexNew)
nTimeBestReceived = GetTime(); nTimeBestReceived = GetTime();
nTransactionsUpdated++; nTransactionsUpdated++;
// Auto-checkpoint: finalize the block at depth MAX_REORG_DEPTH. // pindexLastHardenedCheckpoint is intentionally NOT advanced here. See
// Only set when fully synced (not IBD) so we don't lock in a // fix/consensus-convergence in init.cpp and Reorganize().
// 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());
}
}
// Rolling assumeValid threshold: advance so blocks older than // Rolling assumeValid threshold: advance so blocks older than
// ASSUME_VALID_BUFFER from the tip take the fast path on future // 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, // happens when LoadBlockIndex() didn't fully heal pnext links,
// or the chain was bootstrapped from a snapshot). // or the chain was bootstrapped from a snapshot).
// //
// pitfall #61 guard: if pindexFinalized is set (from the startup // fork-peer getheaders recovery (fix/consensus-convergence).
// hardcoded-checkpoint init in init.cpp), serve from there instead //
// of genesis. This prevents a fork peer from feeding us their // A forked peer calls getheaders with a locator containing the
// short chain back via getheaders — the peer only learns our // highest blocks it knows. If none of those hashes are in our
// canonical chain from the finalized point forward, and their // main chain, locator.GetBlockIndex() returns pindexGenesisBlock
// conflicting fork gets rejected at the reorg check in // and the for-loop below would send zero headers (the peer
// Reorganize() because the fork point is below pindexFinalized. // 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 && if (!locator.IsNull() && pindex == pindexGenesisBlock &&
pindexGenesisBlock && locator.GetTipHash() != pindexGenesisBlock->GetBlockHash()) 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", if (locator.Has(pindexLastHardenedCheckpoint->GetBlockHash()))
pfrom->addr.ToString().c_str(), pindexFinalized->nHeight); {
pindex = pindexFinalized; 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"); // Last-common-ancestor walk via the public locator API. We can't
pindex = pindexGenesisBlock; // 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
View File
@@ -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_TRANSACTIONS = MAX_BLOCK_SIZE/100;
constexpr unsigned int MAX_ORPHAN_BLOCKS = 750; constexpr unsigned int MAX_ORPHAN_BLOCKS = 750;
constexpr unsigned int MAX_ORPHAN_BLOCKS_IBD = 1500; 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 // ASSUME_VALID_BUFFER: how many blocks BACK from the tip to keep fully
// validating. Blocks at or below nAssumeValidThreshold take the fast path // validating. Blocks at or below nAssumeValidThreshold take the fast path
@@ -96,7 +99,7 @@ extern uint256 nBestChainTrust;
extern uint256 nBestInvalidTrust; extern uint256 nBestInvalidTrust;
extern uint256 hashBestChain; extern uint256 hashBestChain;
extern CBlockIndex* pindexBest; 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 int nAssumeValidThreshold; // highest height covered by assumeValid fast path
extern unsigned int nTransactionsUpdated; extern unsigned int nTransactionsUpdated;
extern uint64_t nLastBlockTx; 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 ComputeMinWork(unsigned int nBase, int64_t nTime);
unsigned int ComputeMinStake(unsigned int nBase, int64_t nTime, unsigned int nBlockTime); unsigned int ComputeMinStake(unsigned int nBase, int64_t nTime, unsigned int nBlockTime);
int GetNumBlocksOfPeers(); 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(); [[nodiscard]] bool IsInitialBlockDownload();
// Height-based consensus fast path for historical checkpoint / rolling // Height-based consensus fast path for historical checkpoint / rolling
// assume-valid validation. This intentionally excludes operational IBD states // assume-valid validation. This intentionally excludes operational IBD states
@@ -1547,6 +1572,39 @@ public:
return vHave.empty(); 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 // Return the first hash in the locator (peer's tip), or 0 if empty
uint256 GetTipHash() const uint256 GetTipHash() const
{ {
+27 -14
View File
@@ -391,7 +391,6 @@ void StakeMiner(CWallet *pwallet)
// Make this thread recognisable as the mining thread // Make this thread recognisable as the mining thread
RenameThread("Triangles-miner"); RenameThread("Triangles-miner");
bool fTryToSync = true;
bool fForceStaking = GetBoolArg("-forcestaking", false); bool fForceStaking = GetBoolArg("-forcestaking", false);
while (true) while (true)
@@ -407,24 +406,38 @@ void StakeMiner(CWallet *pwallet)
return; 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; if (!IsStakingSafe(pwallet, vNodes))
fTryToSync = true;
MilliSleep(1000);
if (fShutdown)
return;
}
if (fTryToSync && !fForceStaking)
{
fTryToSync = false;
if (vNodes.size() < 2 || nBestHeight < GetNumBlocksOfPeers())
{ {
MilliSleep(60000); nLastCoinStakeSearchInterval = 0;
MilliSleep(1000);
continue; 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) // Update cached stake weight for UI display (avoids heavy work on UI thread)
+185 -12
View File
@@ -29,22 +29,34 @@ extern int nCoinbaseMaturity;
BOOST_AUTO_TEST_SUITE(consensus_safety_tests) BOOST_AUTO_TEST_SUITE(consensus_safety_tests)
// ─── Reorg finality (P0 — security) ──────────────────────────────────────── // ─── Convergence rule (P0 — security) ─────────────────────────────────────
// MAX_REORG_DEPTH caps how deep a reorg can go. If unset or too small, // fix/consensus-convergence: above the last globally shared hardened
// an attacker can rewrite recent history. If too large, accidental splits // checkpoint, the valid chain with strictly greater cumulative chain
// become possible. This is a hard consensus rule: a node that accepts a // trust wins. No depth cap, no local finality, no trust hysteresis.
// 200-block reorg will diverge from one that rejects it. // Below the hardened checkpoint: rejection is unconditional.
BOOST_AUTO_TEST_CASE(max_reorg_depth_enforced) //
// 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); BOOST_CHECK_EQUAL(MAX_REORG_DEPTH, 100);
// The constant must be positive (otherwise every reorg is rejected). // pindexLastHardenedCheckpoint is declared extern and must be
BOOST_CHECK_GT(MAX_REORG_DEPTH, 0); // 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 // The convergence rule itself is verified by the convergence
// target). If someone bumps this to 10000 without a coordinated // tests below; here we only pin that the rule is expressed
// network upgrade, anyone running old code will reject the reorg. // exclusively in Reorganize() against pindexLastHardenedCheckpoint
BOOST_CHECK_LE(MAX_REORG_DEPTH, 1000); // and that the auto-walking tip-minus-100 logic has been removed.
} }
// ─── Money supply cap (P0 — inflation safety) ───────────────────────────── // ─── Money supply cap (P0 — inflation safety) ─────────────────────────────
@@ -380,4 +392,165 @@ BOOST_AUTO_TEST_CASE(target_spacing_immutable)
BOOST_CHECK_EQUAL(blocksPerYear, 262800); 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() BOOST_AUTO_TEST_SUITE_END()
+68
View File
@@ -316,4 +316,72 @@ BOOST_AUTO_TEST_CASE(weight_v5_min_age_floor_still_applies)
BOOST_CHECK_EQUAL(weight, 0); 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() BOOST_AUTO_TEST_SUITE_END()