diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-11 20:52:09 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-08-23 04:37:36 +0000 |
| commit | 3d7c30ebb6ac0c586635c693a40bc39180f539b9 (patch) | |
| tree | fc4b0db498c03fdab8244fd4a2a3360db77aa0b9 | |
| parent | 090628b34d1aefc7029be37d7bc8512b5edb3ca5 (diff) | |
pool: refuse jobs when no peer has reported a height for 20 minutes
The peer-height gate replaced a tip-age check that was wrong in both
directions. It is a strict improvement and it is still blind in the one
case that matters most.
When the node goes deaf -- peers still in vNodes, nothing arriving -- no
peer announces anything, GetPeerMedianHeight() returns -1, and the
fallback mines our best chain. That fallback is right for a node that has
not been told yet and wrong for a node that has stopped being able to
hear, and a height nobody sent cannot tell those apart. A wedged node is
exactly a node with no peer height information, so the gate was silent
for the whole of heights 3966-4085: 120 blocks, 5890.50 BTF voided.
Time can tell them apart. GetPeerHeightAgeSecs() measures how long since
any peer reported a height, counting from first use rather than the epoch
so a starting node does not refuse jobs before its first handshake. Past
20 minutes the pool stops issuing jobs and says so unconditionally in the
log. /poolmineblind overrides it: without an escape hatch a genuinely
isolated pool that is 90% of the hashrate could never restart its own
chain, which is the deadlock the tip-age check used to cause.
Also stop nStartingHeight from going stale. It was a snapshot taken once
during the version handshake and never touched again, so on a long-lived
connection the median is built from heights the peers had hours ago, and
the behind-the-network check drifts toward 'everyone is behind us' and
quietly stops firing. A peer that hands us a block is demonstrably at
least at that block's height.
(cherry picked from commit 0800c18faf703b4acde1b131d0b742d37a86d92b)
| -rw-r--r-- | src/main.cpp | 15 | ||||
| -rw-r--r-- | src/main.h | 1 | ||||
| -rw-r--r-- | src/main_gui.cpp | 3 | ||||
| -rw-r--r-- | src/net.cpp | 23 | ||||
| -rw-r--r-- | src/net.h | 8 | ||||
| -rw-r--r-- | src/rpc.cpp | 32 |
6 files changed, 82 insertions, 0 deletions
diff --git a/src/main.cpp b/src/main.cpp index 382a008..5d0b70a 100644 --- a/src/main.cpp +++ b/src/main.cpp | |||
| @@ -88,6 +88,7 @@ string strSetDataDir; | |||
| 88 | int nDropMessagesTest = 0; | 88 | int nDropMessagesTest = 0; |
| 89 | bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local test) | 89 | bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local test) |
| 90 | bool fPoolNoAnnounce = false; // /noannounce: operator serves miners but never publishes pool announcements | 90 | bool fPoolNoAnnounce = false; // /noannounce: operator serves miners but never publishes pool announcements |
| 91 | bool fPoolMineBlind = false; // /poolmineblind: issue jobs even when no peer has reported a height | ||
| 91 | 92 | ||
| 92 | // Settings | 93 | // Settings |
| 93 | int fGenerateBitcoins; | 94 | int fGenerateBitcoins; |
| @@ -3172,7 +3173,21 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) | |||
| 3172 | pfrom->AddInventoryKnown(inv); | 3173 | pfrom->AddInventoryKnown(inv); |
| 3173 | 3174 | ||
| 3174 | if (ProcessBlock(pfrom, pblock.release())) | 3175 | if (ProcessBlock(pfrom, pblock.release())) |
| 3176 | { | ||
| 3175 | mapAlreadyAskedFor.erase(inv); | 3177 | mapAlreadyAskedFor.erase(inv); |
| 3178 | |||
| 3179 | // A peer that just handed us a block is demonstrably at least at | ||
| 3180 | // that block's height. Without this, nStartingHeight is a snapshot | ||
| 3181 | // taken once during the version handshake and never touched again, | ||
| 3182 | // so on a long-lived connection it is the peer's height from | ||
| 3183 | // however many hours ago it happened to connect. The pool's | ||
| 3184 | // behind-the-network check compares against the median of those | ||
| 3185 | // numbers, so as connections age it drifts toward "everyone is | ||
| 3186 | // behind us" and quietly stops firing -- the opposite of its job. | ||
| 3187 | map<uint256, CBlockIndex*>::iterator mi = mapBlockIndex.find(inv.hash); | ||
| 3188 | if (mi != mapBlockIndex.end() && mi->second) | ||
| 3189 | pfrom->nStartingHeight = max(pfrom->nStartingHeight, mi->second->nHeight); | ||
| 3190 | } | ||
| 3176 | } | 3191 | } |
| 3177 | 3192 | ||
| 3178 | 3193 | ||
| @@ -93,6 +93,7 @@ extern string strParticipantPool; // participant: pool .btf address | |||
| 93 | extern string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) | 93 | extern string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) |
| 94 | extern string strPayoutFile; // participant: /payoutfile address list, rotated per reconnect | 94 | extern string strPayoutFile; // participant: /payoutfile address list, rotated per reconnect |
| 95 | extern bool fPoolNoAnnounce; // /noannounce: suppress pool announcements | 95 | extern bool fPoolNoAnnounce; // /noannounce: suppress pool announcements |
| 96 | extern bool fPoolMineBlind; // /poolmineblind: mine on even when deaf to every peer | ||
| 96 | extern int nHashThreads; // participant: /hashthreads override (0 = cores-1) | 97 | extern int nHashThreads; // participant: /hashthreads override (0 = cores-1) |
| 97 | extern string strPoolName; // operator: announced pool name | 98 | extern string strPoolName; // operator: announced pool name |
| 98 | extern string strPoolDashboardUrl; // operator: optional dashboard URL | 99 | extern string strPoolDashboardUrl; // operator: optional dashboard URL |
diff --git a/src/main_gui.cpp b/src/main_gui.cpp index b1fe0af..c20ae46 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp | |||
| @@ -253,6 +253,9 @@ static void ParseStartupArguments(int argc, char* argv[]) | |||
| 253 | if (arg(argc,argv,"/noannounce") || arg(argc,argv,"-noannounce")) | 253 | if (arg(argc,argv,"/noannounce") || arg(argc,argv,"-noannounce")) |
| 254 | fPoolNoAnnounce = true; | 254 | fPoolNoAnnounce = true; |
| 255 | 255 | ||
| 256 | if (arg(argc,argv,"/poolmineblind") || arg(argc,argv,"-poolmineblind")) | ||
| 257 | fPoolMineBlind = true; | ||
| 258 | |||
| 256 | string poolFee = argval2(argc, argv, "/poolfee", "-poolfee"); | 259 | string poolFee = argval2(argc, argv, "/poolfee", "-poolfee"); |
| 257 | if (!poolFee.empty()) | 260 | if (!poolFee.empty()) |
| 258 | dPoolFeePercent = atof(poolFee.c_str()); | 261 | dPoolFeePercent = atof(poolFee.c_str()); |
diff --git a/src/net.cpp b/src/net.cpp index 5cf2f8c..964097b 100644 --- a/src/net.cpp +++ b/src/net.cpp | |||
| @@ -1700,6 +1700,13 @@ void ThreadMessageHandler(void* parg) | |||
| 1700 | } | 1700 | } |
| 1701 | } | 1701 | } |
| 1702 | 1702 | ||
| 1703 | // When any peer last told us a height. A wedged node -- one that still has | ||
| 1704 | // peers but has stopped hearing them -- is exactly a node where this stops | ||
| 1705 | // advancing, and that is the one thing the height comparison itself cannot | ||
| 1706 | // see: there is no height to compare. | ||
| 1707 | static int64 nLastPeerHeightSeen = 0; | ||
| 1708 | static int64 nPeerHeightFirstAsked = 0; | ||
| 1709 | |||
| 1703 | int GetPeerMedianHeight() | 1710 | int GetPeerMedianHeight() |
| 1704 | { | 1711 | { |
| 1705 | vector<int> vHeights; | 1712 | vector<int> vHeights; |
| @@ -1711,10 +1718,26 @@ int GetPeerMedianHeight() | |||
| 1711 | if (vHeights.empty()) | 1718 | if (vHeights.empty()) |
| 1712 | return -1; | 1719 | return -1; |
| 1713 | 1720 | ||
| 1721 | nLastPeerHeightSeen = GetTime(); | ||
| 1714 | sort(vHeights.begin(), vHeights.end()); | 1722 | sort(vHeights.begin(), vHeights.end()); |
| 1715 | return vHeights[vHeights.size() / 2]; | 1723 | return vHeights[vHeights.size() / 2]; |
| 1716 | } | 1724 | } |
| 1717 | 1725 | ||
| 1726 | // Seconds since any peer last reported a height. | ||
| 1727 | // | ||
| 1728 | // Before ever hearing one, measure from the first time somebody asked rather | ||
| 1729 | // than from the epoch -- otherwise a node refuses jobs the instant it starts, | ||
| 1730 | // before it has had a chance to complete a single handshake. | ||
| 1731 | int64 GetPeerHeightAgeSecs() | ||
| 1732 | { | ||
| 1733 | int64 nNow = GetTime(); | ||
| 1734 | if (nPeerHeightFirstAsked == 0) | ||
| 1735 | nPeerHeightFirstAsked = nNow; | ||
| 1736 | if (nLastPeerHeightSeen == 0) | ||
| 1737 | return nNow - nPeerHeightFirstAsked; | ||
| 1738 | return nNow - nLastPeerHeightSeen; | ||
| 1739 | } | ||
| 1740 | |||
| 1718 | 1741 | ||
| 1719 | // Say out loud when we are behind the network. | 1742 | // Say out loud when we are behind the network. |
| 1720 | // | 1743 | // |
| @@ -120,6 +120,13 @@ static const unsigned int MAX_CONNECTIONS = 125; | |||
| 120 | // this long regardless of what is still buffered. | 120 | // this long regardless of what is still buffered. |
| 121 | static const int64 DISCONNECT_DRAIN_SECS = 60; | 121 | static const int64 DISCONNECT_DRAIN_SECS = 60; |
| 122 | 122 | ||
| 123 | // How long a pool may go without hearing a height from ANY peer before it | ||
| 124 | // stops issuing jobs. Long enough that ordinary peer churn and a quiet | ||
| 125 | // handshake window do not trip it; short enough that a deaf pool cannot build | ||
| 126 | // more than a few blocks of private fork before it is stopped. Heights | ||
| 127 | // 3966-4085 -- 120 blocks, 5890.50 BTF -- were mined in exactly this state. | ||
| 128 | static const int64 BTF_NO_PEER_HEIGHT_MAX_SECS = 20 * 60; | ||
| 129 | |||
| 123 | // Send a ping after this long with nothing to say, so a peer running the | 130 | // Send a ping after this long with nothing to say, so a peer running the |
| 124 | // inactivity check above does not mistake a quiet node for a dead one. Must | 131 | // inactivity check above does not mistake a quiet node for a dead one. Must |
| 125 | // stay well under BTF_RECV_TIMEOUT_SECS. | 132 | // stay well under BTF_RECV_TIMEOUT_SECS. |
| @@ -160,6 +167,7 @@ extern int nBestHeight; | |||
| 160 | // has told us anything. Median rather than maximum so one peer claiming an | 167 | // has told us anything. Median rather than maximum so one peer claiming an |
| 161 | // absurd height cannot move it. | 168 | // absurd height cannot move it. |
| 162 | int GetPeerMedianHeight(); | 169 | int GetPeerMedianHeight(); |
| 170 | int64 GetPeerHeightAgeSecs(); | ||
| 163 | 171 | ||
| 164 | // Lightweight .btf churn accounting. These counters are diagnostics only: they | 172 | // Lightweight .btf churn accounting. These counters are diagnostics only: they |
| 165 | // let an operator tell stale descriptors, relay pairing failures, and paired | 173 | // let an operator tell stale descriptors, relay pairing failures, and paired |
diff --git a/src/rpc.cpp b/src/rpc.cpp index 8a0d112..da22b1d 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp | |||
| @@ -180,6 +180,38 @@ static bool RebuildJob() | |||
| 180 | return false; | 180 | return false; |
| 181 | } | 181 | } |
| 182 | 182 | ||
| 183 | // The comparison above is blind in the one case that matters most. | ||
| 184 | // | ||
| 185 | // When the node goes deaf -- peers still in vNodes, nothing arriving -- | ||
| 186 | // no peer announces anything, GetPeerMedianHeight() returns -1, and the | ||
| 187 | // fallback ("nobody has told us anything, so mine our best chain") let | ||
| 188 | // the pool build a private fork for as long as the deafness lasted. | ||
| 189 | // That fallback is right for a node that has simply not been told yet | ||
| 190 | // and wrong for a node that has stopped being able to hear, and a | ||
| 191 | // height nobody sent cannot tell those apart. Time can. | ||
| 192 | // | ||
| 193 | // Deliberately not behind a debug category, and deliberately not a | ||
| 194 | // silent refusal: an operator whose pool has stopped issuing jobs needs | ||
| 195 | // the reason in the log. /poolmineblind is the escape hatch for a node | ||
| 196 | // that really is alone -- without one, a genuinely isolated pool that is | ||
| 197 | // the majority of the hashrate could never restart its own chain, which | ||
| 198 | // is the deadlock the tip-age check used to cause. | ||
| 199 | if (nPeerHeight < 0 && !fPoolMineBlind) { | ||
| 200 | int64 nAge = GetPeerHeightAgeSecs(); | ||
| 201 | if (nAge >= BTF_NO_PEER_HEIGHT_MAX_SECS) { | ||
| 202 | static int64 nLastSaid = 0; | ||
| 203 | if (GetTime() - nLastSaid > 60) { | ||
| 204 | nLastSaid = GetTime(); | ||
| 205 | printf("RebuildJob: no peer has reported a height for %d minutes " | ||
| 206 | "-- not issuing jobs (our height %d, %d peers connected). " | ||
| 207 | "This node is deaf; mining now would fork it. " | ||
| 208 | "Pass /poolmineblind to override.\n", | ||
| 209 | (int)(nAge / 60), nBestHeight, (int)vNodes.size()); | ||
| 210 | } | ||
| 211 | return false; | ||
| 212 | } | ||
| 213 | } | ||
| 214 | |||
| 183 | CBlockIndex* pindexPrev = pindexBest; | 215 | CBlockIndex* pindexPrev = pindexBest; |
| 184 | unsigned int nBits = GetNextWorkRequired(pindexPrev); | 216 | unsigned int nBits = GetNextWorkRequired(pindexPrev); |
| 185 | 217 | ||