upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/rpc.cpp
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-08-09 05:56:22 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-19 22:48:26 +0000
commit44849e619fc66d3345ad675f0128bf5443194db0 (patch)
tree9fc88d223696b15acae7b67f15dd8a6d324ac47a /src/rpc.cpp
parentd6972a0ca2269441c7a1125d528fc577c242be7d (diff)
payout: key obligations on block hash, and wait for coin instead of expiring
A 280-block reorg (heights 3075-3354) orphaned 292 of the pool's 707 blocks and destroyed 14,600 BTF of coinbase. The payout ledger did not notice: it records only matureAtHeight, and a reorg replaces the block at a height without changing the height, so ~14.4k BTF of obligations outlived the coin that was supposed to fund them. The pool then paid fork-era miners out of real-chain coin until the wallet was empty -- 17,827 BTF paid against 17,900 BTF of matured supply, leaving under 1.1 BTF spendable and every subsequent payment failing. Three changes, all in the payout path: - Record the funding block's hash and height, persist both, and void an obligation whose block is no longer in the main chain. Checked both at maturity and again in the exec queue, since an entry can sit there for hours across retries and the reorg can land at any point in that window. Entries loaded from a file written before this are grandfathered rather than voided on a missing field. - Stop spending the retry budget on maturity. SelectCoins failing for want of spendable coin is not an error, but it consumed one of 1440 retries every 60s, so a payment the pool fully intended to honour would hit GIVING UP 24h later and the miner would silently never be paid. Those attempts now defer without incrementing retries, logged hourly rather than every minute. GIVING UP now means what it says: 1440 real failures against a block still on-chain with the money available. - Schedule at COINBASE_SPENDABLE_DEPTH instead of COINBASE_MATURITY. At depth 100 a block's own coinbase is 20 blocks short of spendable, so its payout had to come from unrelated mature coin and the pool carried a permanent ~19-block float. The "96% of 934 payments went through at depth 100" note that justified the shorter wait was measured while this node was alone on a private fork paying itself. The extra 20 blocks also put the maturity gate past the reorg that caused all of the above. (cherry picked from commit 696386220a3d2fac88cb8045931392f4909e0985)
Diffstat (limited to 'src/rpc.cpp')
-rw-r--r--src/rpc.cpp138
1 files changed, 126 insertions, 12 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index 24b1c88..b12dc2f 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -230,9 +230,18 @@ static bool RebuildJob()
230// Payouts -- event loop thread only 230// Payouts -- event loop thread only
231// --------------------------------------------------------------------------- 231// ---------------------------------------------------------------------------
232struct PendingPayout { 232struct PendingPayout {
233 // The block that funds this payout, identified by hash and not by height
234 // alone. A reorg replaces the block at a height without changing the
235 // height, so a height-keyed obligation outlives the coinbase that was
236 // supposed to pay it: a 280-block reorg once left ~14.4k BTF of debt
237 // owed against blocks that no longer existed, which then drained the
238 // wallet paying fork-era miners out of real-chain coin.
239 int blockHeight = 0;
240 uint256 blockHash; // zero = entry predates hash keying
233 int matureAtHeight; 241 int matureAtHeight;
234 std::map<std::string, int64> amounts; // address -> satoshis 242 std::map<std::string, int64> amounts; // address -> satoshis
235 int retries = 0; // exec-thread only: SendMoney attempts so far 243 int retries = 0; // exec-thread only: SendMoney attempts so far
244 int deferrals = 0; // exec-thread only: waits for spendable coin
236 int64 retryAfter = 0; // exec-thread only: unix time, 0 = ready now 245 int64 retryAfter = 0; // exec-thread only: unix time, 0 = ready now
237}; 246};
238static std::vector<PendingPayout> gPendingPayouts; 247static std::vector<PendingPayout> gPendingPayouts;
@@ -247,6 +256,8 @@ static void SavePendingPayouts()
247 nlohmann::json arr = nlohmann::json::array(); 256 nlohmann::json arr = nlohmann::json::array();
248 for (const auto& pp : gPendingPayouts) { 257 for (const auto& pp : gPendingPayouts) {
249 nlohmann::json obj; 258 nlohmann::json obj;
259 obj["blockHeight"] = pp.blockHeight;
260 obj["blockHash"] = pp.blockHash.ToString();
250 obj["matureAtHeight"] = pp.matureAtHeight; 261 obj["matureAtHeight"] = pp.matureAtHeight;
251 nlohmann::json amounts = nlohmann::json::object(); 262 nlohmann::json amounts = nlohmann::json::object();
252 for (const auto& kv : pp.amounts) 263 for (const auto& kv : pp.amounts)
@@ -281,6 +292,13 @@ static void LoadPendingPayouts()
281 for (const auto& obj : arr) { 292 for (const auto& obj : arr) {
282 PendingPayout pp; 293 PendingPayout pp;
283 pp.matureAtHeight = obj["matureAtHeight"].get<int>(); 294 pp.matureAtHeight = obj["matureAtHeight"].get<int>();
295 // Entries written before payouts were keyed by hash carry neither
296 // field. Leave blockHash zero and they are grandfathered past the
297 // orphan check rather than voided on a guess.
298 if (obj.contains("blockHash") && obj["blockHash"].is_string())
299 pp.blockHash.SetHex(obj["blockHash"].get<std::string>());
300 if (obj.contains("blockHeight") && obj["blockHeight"].is_number())
301 pp.blockHeight = obj["blockHeight"].get<int>();
284 for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) 302 for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it)
285 pp.amounts[it.key()] = it.value().get<int64>(); 303 pp.amounts[it.key()] = it.value().get<int64>();
286 gPendingPayouts.push_back(pp); 304 gPendingPayouts.push_back(pp);
@@ -300,7 +318,27 @@ static std::vector<PendingPayout> gPayoutExecQueue;
300static std::mutex gPayoutExecMutex; 318static std::mutex gPayoutExecMutex;
301static std::atomic<bool> gPayoutThreadRunning{false}; 319static std::atomic<bool> gPayoutThreadRunning{false};
302 320
321// Is the block that funds this payout still on the main chain?
322//
323// A zero hash means the entry was written before payouts recorded one, so
324// there is nothing to check and we assume it is good -- voiding those would
325// mean refusing to pay miners on the strength of a missing field.
326static bool PayoutBlockStillOnChain(const PendingPayout& pp)
327{
328 if (pp.blockHash == 0)
329 return true;
330 CRITICAL_BLOCK(cs_main)
331 {
332 std::map<uint256, CBlockIndex*>::iterator mi = mapBlockIndex.find(pp.blockHash);
333 if (mi == mapBlockIndex.end() || mi->second == NULL)
334 return false;
335 return mi->second->IsInMainChain();
336 }
337 return false;
338}
339
303static void QueuePayouts(int blockHeight, 340static void QueuePayouts(int blockHeight,
341 const uint256& blockHash,
304 const std::map<std::string, uint64>& shareCount, 342 const std::map<std::string, uint64>& shareCount,
305 uint64 total) 343 uint64 total)
306{ 344{
@@ -322,12 +360,19 @@ static void QueuePayouts(int blockHeight,
322 int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); 360 int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac));
323 361
324 PendingPayout pp; 362 PendingPayout pp;
325 // Deliberately COINBASE_MATURITY, not COINBASE_SPENDABLE_DEPTH: a payout is 363 pp.blockHeight = blockHeight;
326 // funded from whatever coin is already mature, not from its own block's 364 pp.blockHash = blockHash;
327 // coinbase, so waiting the extra 20 blocks delays every payment without 365 // COINBASE_SPENDABLE_DEPTH, not COINBASE_MATURITY. Paying at depth 100
328 // making one more likely to succeed. Measured over 934 payments: 96% went 366 // means the block's own coinbase is still 20 blocks short of spendable, so
329 // through at depth 100, the moment they were queued. 367 // the payment has to come out of some *other* mature coin -- the pool runs
330 pp.matureAtHeight = blockHeight + COINBASE_MATURITY; 368 // a permanent ~19-block float and every payout fails until unrelated coin
369 // matures. The earlier "96% of 934 payments went through at depth 100"
370 // measurement that justified the shorter wait was taken while this node was
371 // alone on a private fork paying itself, so it says nothing about a pool
372 // competing on the real chain. Waiting the extra 20 blocks makes each
373 // payout funded by construction, and puts the maturity gate past the
374 // reorg depth that orphaned 280 blocks of obligations.
375 pp.matureAtHeight = blockHeight + COINBASE_SPENDABLE_DEPTH;
331 376
332 int skipped = 0; 377 int skipped = 0;
333 int64 burned = 0; // amount owed to miners with bad/empty addresses 378 int64 burned = 0; // amount owed to miners with bad/empty addresses
@@ -384,6 +429,23 @@ static void QueuePayouts(int blockHeight,
384// re-queueing blindly would double-pay whoever was already sent to. 429// re-queueing blindly would double-pay whoever was already sent to.
385static void FlushMaturePayouts() 430static void FlushMaturePayouts()
386{ 431{
432 // Void obligations whose funding block lost a reorg. The coin that was
433 // meant to pay them does not exist, so paying anyway spends real-chain
434 // coin the pool owes to somebody else.
435 bool voided = false;
436 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) {
437 if (PayoutBlockStillOnChain(*it)) { ++it; continue; }
438 int64 total = 0;
439 for (auto& kv : it->amounts) total += kv.second;
440 LogPrint("payout", "[payout] block %d (%s) was orphaned -- voiding %zu"
441 " payment(s) totalling %s\n",
442 it->blockHeight, it->blockHash.ToString().substr(0, 14).c_str(),
443 it->amounts.size(), FormatMoney(total).c_str());
444 it = gPendingPayouts.erase(it);
445 voided = true;
446 }
447 if (voided) SavePendingPayouts();
448
387 std::vector<PendingPayout> ready; 449 std::vector<PendingPayout> ready;
388 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { 450 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) {
389 if (nBestHeight < it->matureAtHeight) { ++it; continue; } 451 if (nBestHeight < it->matureAtHeight) { ++it; continue; }
@@ -429,6 +491,18 @@ static void PayoutThreadFn(void*)
429 gPayoutExecQueue.push_back(pp); 491 gPayoutExecQueue.push_back(pp);
430 continue; 492 continue;
431 } 493 }
494 // Re-check here and not only at maturity: an entry can sit in this
495 // queue for hours across retries, and the reorg that kills its
496 // funding block can land at any point in that window.
497 if (!PayoutBlockStillOnChain(pp)) {
498 int64 total = 0;
499 for (auto& kv : pp.amounts) total += kv.second;
500 LogPrint("payout", "[payout] block %d (%s) orphaned while queued"
501 " -- dropping %zu payment(s) totalling %s\n",
502 pp.blockHeight, pp.blockHash.ToString().substr(0, 14).c_str(),
503 pp.amounts.size(), FormatMoney(total).c_str());
504 continue;
505 }
432 LogPrint("payout", "[payout] executing payments for block matured at %d" 506 LogPrint("payout", "[payout] executing payments for block matured at %d"
433 " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); 507 " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size());
434 for (auto& kv : pp.amounts) { 508 for (auto& kv : pp.amounts) {
@@ -443,7 +517,35 @@ static void PayoutThreadFn(void*)
443 sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; 517 sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG;
444 CWalletTx wtx; 518 CWalletTx wtx;
445 wtx.mapValue["comment"] = strprintf("Pool payout height %d", 519 wtx.mapValue["comment"] = strprintf("Pool payout height %d",
446 pp.matureAtHeight); 520 pp.blockHeight);
521
522 // Waiting for coin to mature is not a failure, so it must not
523 // consume the retry budget. Burning a retry every 60s means a
524 // payment the pool fully intends to honour can hit GIVING UP
525 // 24h later purely because the subsidy funding it had not
526 // matured yet -- the miner is then silently never paid.
527 int64 nSpendable = GetBalance();
528 if (nSpendable < kv.second) {
529 PendingPayout dp;
530 dp.blockHeight = pp.blockHeight;
531 dp.blockHash = pp.blockHash;
532 dp.matureAtHeight = pp.matureAtHeight;
533 dp.amounts[kv.first] = kv.second;
534 dp.retries = pp.retries; // unchanged on purpose
535 dp.deferrals = pp.deferrals + 1;
536 dp.retryAfter = GetTime() + 60;
537 // Hourly, not every minute: a dry pool would otherwise
538 // write one line per payment per minute forever.
539 if (dp.deferrals == 1 || dp.deferrals % 60 == 0)
540 LogPrint("payout", "[payout] deferring %s to %s -- only %s"
541 " spendable (waiting %d min so far)\n",
542 FormatMoney(kv.second).c_str(), kv.first.c_str(),
543 FormatMoney(nSpendable).c_str(), dp.deferrals);
544 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
545 gPayoutExecQueue.push_back(std::move(dp));
546 continue;
547 }
548
447 if (SendMoney(sc, kv.second, wtx)) { 549 if (SendMoney(sc, kv.second, wtx)) {
448 LogPrint("payout", "[payout] paid %s to %s\n", 550 LogPrint("payout", "[payout] paid %s to %s\n",
449 FormatMoney(kv.second).c_str(), kv.first.c_str()); 551 FormatMoney(kv.second).c_str(), kv.first.c_str());
@@ -453,24 +555,34 @@ static void PayoutThreadFn(void*)
453 // with backoff. Permanent cases (orphaned coinbase) give 555 // with backoff. Permanent cases (orphaned coinbase) give
454 // up loudly after 1440 tries (~24 h). 556 // up loudly after 1440 tries (~24 h).
455 PendingPayout rp; 557 PendingPayout rp;
558 rp.blockHeight = pp.blockHeight;
559 rp.blockHash = pp.blockHash;
456 rp.matureAtHeight = pp.matureAtHeight; 560 rp.matureAtHeight = pp.matureAtHeight;
457 rp.amounts[kv.first] = kv.second; 561 rp.amounts[kv.first] = kv.second;
458 rp.retries = pp.retries + 1; 562 rp.retries = pp.retries + 1;
563 rp.deferrals = pp.deferrals;
459 rp.retryAfter = GetTime() + 60; 564 rp.retryAfter = GetTime() + 60;
460 { 565 {
461 std::lock_guard<std::mutex> lk(gPayoutExecMutex); 566 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
462 gPayoutExecQueue.push_back(std::move(rp)); 567 gPayoutExecQueue.push_back(std::move(rp));
463 } 568 }
464 LogPrint("payout", "[payout] FAILED to pay %s to %s" 569 LogPrint("payout", "[payout] FAILED to pay %s to %s"
465 " -- retry %d/1440 in 60s\n", 570 " (%s spendable at the time) -- retry %d/1440 in 60s\n",
466 FormatMoney(kv.second).c_str(), kv.first.c_str(), 571 FormatMoney(kv.second).c_str(), kv.first.c_str(),
572 FormatMoney(nSpendable).c_str(),
467 pp.retries + 1); 573 pp.retries + 1);
468 } else { 574 } else {
575 // Reaching here now means 1440 attempts that were NOT
576 // insufficient-funds waits (those defer instead) against a
577 // block still on the main chain -- i.e. a real wallet fault,
578 // not a maturity or reorg question.
469 LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" 579 LogPrint("payout", "[payout] GIVING UP on %s to %s after %d"
470 " retries (orphaned coinbase or persistent wallet" 580 " retries -- block %d is still on-chain and %s was"
471 " error?) -- payment forfeited\n", 581 " spendable, so this is a wallet fault, not maturity"
582 " -- payment forfeited\n",
472 FormatMoney(kv.second).c_str(), kv.first.c_str(), 583 FormatMoney(kv.second).c_str(), kv.first.c_str(),
473 pp.retries); 584 pp.retries, pp.blockHeight,
585 FormatMoney(nSpendable).c_str());
474 } 586 }
475 } 587 }
476 } 588 }
@@ -1065,7 +1177,9 @@ static bool HandleLine(Miner* m, const std::string& rawLine,
1065 if (accepted) { 1177 if (accepted) {
1066 // Snapshot height before RebuildJob() (which increments it) 1178 // Snapshot height before RebuildJob() (which increments it)
1067 int foundHeight = gCurrentJob.height; 1179 int foundHeight = gCurrentJob.height;
1068 QueuePayouts(foundHeight, roundShareCount, roundShareTotal); 1180 // b is the block we submitted, untouched by ProcessBlock (which
1181 // took a copy), so its hash is what the payout must be keyed on.
1182 QueuePayouts(foundHeight, b.GetHash(), roundShareCount, roundShareTotal);
1069 // Reset round shares 1183 // Reset round shares
1070 roundShareCount.clear(); 1184 roundShareCount.clear();
1071 roundShareTotal = 0; 1185 roundShareTotal = 0;