upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNsable-sh <nsable-sh@noreply.git.bitflash.network>2026-08-07 11:25:45 -0300
committerBitflash <admin@bitflash.network>2026-08-07 15:05:18 -0300
commit0b01d6cfce8be68150f5e06c11e13e0443372c4d (patch)
treeb1b7af277e3a978f25d0f039ef0fd65760c31e56
parentb72f403c054838e4b2203f48f757df0dad017628 (diff)
pool: make payout ledger crash-safe
-rw-r--r--src/rpc.cpp247
1 files changed, 196 insertions, 51 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index d73b09c..8f3740d 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -251,36 +251,72 @@ static bool RebuildJob()
251// Payouts -- event loop thread only 251// Payouts -- event loop thread only
252// --------------------------------------------------------------------------- 252// ---------------------------------------------------------------------------
253struct PendingPayout { 253struct PendingPayout {
254 std::string id;
255 int blockHeight;
254 int matureAtHeight; 256 int matureAtHeight;
255 std::map<std::string, int64> amounts; // address -> satoshis 257 std::map<std::string, int64> amounts; // address -> satoshis
258 std::map<std::string, std::string> paidTxids; // address -> txid
259 int64 nextAttempt;
260
261 PendingPayout() : blockHeight(0), matureAtHeight(0), nextAttempt(0) {}
256}; 262};
257static std::vector<PendingPayout> gPendingPayouts; 263static std::vector<PendingPayout> gPendingPayouts;
264static std::mutex gPayoutLedgerMutex;
258 265
259static std::string PayoutFilePath() 266static std::string PayoutFilePath()
260{ 267{
261 return GetAppDir() + "/pending_payouts.json"; 268 return GetAppDir() + "/pending_payouts.json";
262} 269}
263 270
264static void SavePendingPayouts() 271static std::string PayoutIdForBlock(int blockHeight)
272{
273 return strprintf("block-%d", blockHeight);
274}
275
276static bool PayoutFullyPaid(const PendingPayout& pp)
277{
278 for (const auto& kv : pp.amounts)
279 if (pp.paidTxids.count(kv.first) == 0)
280 return false;
281 return true;
282}
283
284static void SavePendingPayoutsLocked()
265{ 285{
266 nlohmann::json arr = nlohmann::json::array(); 286 nlohmann::json arr = nlohmann::json::array();
267 for (const auto& pp : gPendingPayouts) { 287 for (const auto& pp : gPendingPayouts) {
268 nlohmann::json obj; 288 nlohmann::json obj;
289 obj["id"] = pp.id;
290 obj["blockHeight"] = pp.blockHeight;
269 obj["matureAtHeight"] = pp.matureAtHeight; 291 obj["matureAtHeight"] = pp.matureAtHeight;
292 obj["nextAttempt"] = pp.nextAttempt;
270 nlohmann::json amounts = nlohmann::json::object(); 293 nlohmann::json amounts = nlohmann::json::object();
271 for (const auto& kv : pp.amounts) 294 for (const auto& kv : pp.amounts)
272 amounts[kv.first] = kv.second; 295 amounts[kv.first] = kv.second;
273 obj["amounts"] = amounts; 296 obj["amounts"] = amounts;
297 nlohmann::json paid = nlohmann::json::object();
298 for (const auto& kv : pp.paidTxids)
299 paid[kv.first] = kv.second;
300 obj["paidTxids"] = paid;
274 arr.push_back(obj); 301 arr.push_back(obj);
275 } 302 }
276 std::string path = PayoutFilePath(); 303 std::string path = PayoutFilePath();
277 FILE* f = fopen(path.c_str(), "w"); 304 std::string tmp = path + ".tmp";
305 FILE* f = fopen(tmp.c_str(), "w");
278 if (f) { 306 if (f) {
279 std::string s = arr.dump(2); 307 std::string s = arr.dump(2);
280 fwrite(s.c_str(), 1, s.size(), f); 308 fwrite(s.c_str(), 1, s.size(), f);
281 fclose(f); 309 if (fclose(f) != 0) {
310 LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str());
311 return;
312 }
313#ifdef _WIN32
314 remove(path.c_str());
315#endif
316 if (rename(tmp.c_str(), path.c_str()) != 0)
317 LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str());
282 } else { 318 } else {
283 LogPrint("payout", "[payout] WARNING: could not write %s\n", path.c_str()); 319 LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str());
284 } 320 }
285} 321}
286 322
@@ -297,11 +333,25 @@ static void LoadPendingPayouts()
297 fclose(f); 333 fclose(f);
298 try { 334 try {
299 nlohmann::json arr = nlohmann::json::parse(s); 335 nlohmann::json arr = nlohmann::json::parse(s);
336 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
300 for (const auto& obj : arr) { 337 for (const auto& obj : arr) {
301 PendingPayout pp; 338 PendingPayout pp;
339 if (obj.contains("blockHeight"))
340 pp.blockHeight = obj["blockHeight"].get<int>();
302 pp.matureAtHeight = obj["matureAtHeight"].get<int>(); 341 pp.matureAtHeight = obj["matureAtHeight"].get<int>();
342 if (obj.contains("id") && obj["id"].is_string())
343 pp.id = obj["id"].get<std::string>();
344 if (pp.blockHeight == 0)
345 pp.blockHeight = pp.matureAtHeight - COINBASE_MATURITY;
346 if (pp.id.empty())
347 pp.id = PayoutIdForBlock(pp.blockHeight);
348 if (obj.contains("nextAttempt"))
349 pp.nextAttempt = obj["nextAttempt"].get<int64>();
303 for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) 350 for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it)
304 pp.amounts[it.key()] = it.value().get<int64>(); 351 pp.amounts[it.key()] = it.value().get<int64>();
352 if (obj.contains("paidTxids") && obj["paidTxids"].is_object())
353 for (auto it = obj["paidTxids"].begin(); it != obj["paidTxids"].end(); ++it)
354 pp.paidTxids[it.key()] = it.value().get<std::string>();
305 gPendingPayouts.push_back(pp); 355 gPendingPayouts.push_back(pp);
306 } 356 }
307 LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n", 357 LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n",
@@ -319,6 +369,35 @@ static std::vector<PendingPayout> gPayoutExecQueue;
319static std::mutex gPayoutExecMutex; 369static std::mutex gPayoutExecMutex;
320static std::atomic<bool> gPayoutThreadRunning{false}; 370static std::atomic<bool> gPayoutThreadRunning{false};
321 371
372static std::set<std::string> gQueuedPayouts;
373
374static bool FindRecordedPoolPayout(const std::string& id,
375 const std::string& address,
376 int64 amount,
377 std::string& txidRet)
378{
379 CRITICAL_BLOCK(cs_mapWallet)
380 {
381 for (map<uint256, CWalletTx>::const_iterator it = mapWallet.begin();
382 it != mapWallet.end(); ++it)
383 {
384 const CWalletTx& wtx = it->second;
385 map<string, string>::const_iterator idIt = wtx.mapValue.find("pool_payout_id");
386 if (idIt == wtx.mapValue.end() || idIt->second != id)
387 continue;
388 map<string, string>::const_iterator addrIt = wtx.mapValue.find("pool_payout_address");
389 if (addrIt == wtx.mapValue.end() || addrIt->second != address)
390 continue;
391 map<string, string>::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount");
392 if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount))
393 continue;
394 txidRet = wtx.GetHash().GetHex();
395 return true;
396 }
397 }
398 return false;
399}
400
322static void QueuePayouts(int blockHeight, 401static void QueuePayouts(int blockHeight,
323 const std::map<std::string, uint64>& shareCount, 402 const std::map<std::string, uint64>& shareCount,
324 uint64 total) 403 uint64 total)
@@ -341,6 +420,8 @@ static void QueuePayouts(int blockHeight,
341 int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); 420 int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac));
342 421
343 PendingPayout pp; 422 PendingPayout pp;
423 pp.id = PayoutIdForBlock(blockHeight);
424 pp.blockHeight = blockHeight;
344 pp.matureAtHeight = blockHeight + COINBASE_MATURITY; 425 pp.matureAtHeight = blockHeight + COINBASE_MATURITY;
345 426
346 int skipped = 0; 427 int skipped = 0;
@@ -364,8 +445,11 @@ static void QueuePayouts(int blockHeight,
364 pp.amounts[kv.first] = amount; 445 pp.amounts[kv.first] = amount;
365 } 446 }
366 447
367 gPendingPayouts.push_back(pp); 448 {
368 SavePendingPayouts(); 449 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
450 gPendingPayouts.push_back(pp);
451 SavePendingPayoutsLocked();
452 }
369 453
370 int64 operatorCut = nSubsidy - minerReward; 454 int64 operatorCut = nSubsidy - minerReward;
371 LogPrint("payout", 455 LogPrint("payout",
@@ -383,42 +467,33 @@ static void QueuePayouts(int blockHeight,
383 : ""); 467 : "");
384} 468}
385 469
386// Called from event loop: move matured entries to the exec queue (fast). 470// Called from event loop: copy matured entries to the exec queue (fast).
387// NOT crash-safe, despite the ordering below looking like it is. 471// The ledger entry stays on disk until every recipient has a txid recorded.
388// 472// On restart, FindRecordedPoolPayout() checks wallet metadata before sending,
389// A matured payout is erased from gPendingPayouts, the file is rewritten 473// so a crash after SendMoney but before the ledger update does not double-pay.
390// without it, and only then does it reach the exec queue -- which lives in
391// memory alone. SavePendingPayouts writes gPendingPayouts and nothing else;
392// there is no exec-queue section of the file to recover from. Die anywhere
393// between the rewrite and SendMoney and those miners are simply never paid,
394// with no record that they were owed anything.
395//
396// Making this safe needs the payment to be idempotent -- record the txid and
397// check the wallet for it on restart -- rather than a reordering, since
398// re-queueing blindly would double-pay whoever was already sent to.
399static void FlushMaturePayouts() 474static void FlushMaturePayouts()
400{ 475{
401 std::vector<PendingPayout> ready; 476 std::vector<PendingPayout> ready;
402 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { 477 {
403 if (nBestHeight < it->matureAtHeight) { ++it; continue; } 478 std::lock_guard<std::mutex> ledger(gPayoutLedgerMutex);
404 LogPrint("payout", "[payout] block height %d matured -- queuing %zu payments\n", 479 std::lock_guard<std::mutex> queue(gPayoutExecMutex);
405 it->matureAtHeight, it->amounts.size()); 480 int64 now = GetTime();
406 ready.push_back(*it); // copy first; erase after save 481 for (const PendingPayout& pp : gPendingPayouts) {
407 ++it; 482 if (nBestHeight < pp.matureAtHeight)
408 } 483 continue;
409 if (!ready.empty()) { 484 if (pp.nextAttempt > now)
410 // Erase from pending then save before handing off to exec thread, 485 continue;
411 // so the file never contains an entry that is already being sent. 486 if (PayoutFullyPaid(pp))
412 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { 487 continue;
413 if (nBestHeight >= it->matureAtHeight) 488 if (gQueuedPayouts.count(pp.id))
414 it = gPendingPayouts.erase(it); 489 continue;
415 else 490 LogPrint("payout", "[payout] block height %d matured -- queuing payout %s (%zu recipients)\n",
416 ++it; 491 pp.matureAtHeight, pp.id.c_str(), pp.amounts.size());
492 ready.push_back(pp);
493 gQueuedPayouts.insert(pp.id);
417 } 494 }
418 SavePendingPayouts();
419 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
420 for (auto& pp : ready) 495 for (auto& pp : ready)
421 gPayoutExecQueue.push_back(std::move(pp)); 496 gPayoutExecQueue.push_back(pp);
422 } 497 }
423} 498}
424 499
@@ -437,10 +512,21 @@ static void PayoutThreadFn(void*)
437 batch.swap(gPayoutExecQueue); 512 batch.swap(gPayoutExecQueue);
438 } 513 }
439 514
440 for (auto& pp : batch) { 515 for (auto& ppQueued : batch) {
441 LogPrint("payout", "[payout] executing payments for block matured at %d" 516 LogPrint("payout", "[payout] executing payments for block matured at %d"
442 " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); 517 " (%zu miners)\n", ppQueued.matureAtHeight, ppQueued.amounts.size());
443 for (auto& kv : pp.amounts) { 518 bool fAbortBatch = false;
519 for (auto& kv : ppQueued.amounts) {
520 bool alreadyPaid = false;
521 {
522 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
523 for (const PendingPayout& pp : gPendingPayouts)
524 if (pp.id == ppQueued.id && pp.paidTxids.count(kv.first))
525 alreadyPaid = true;
526 }
527 if (alreadyPaid)
528 continue;
529
444 uint160 h160; 530 uint160 h160;
445 if (!AddressToHash160(kv.first, h160)) { 531 if (!AddressToHash160(kv.first, h160)) {
446 // Should not happen -- bad addresses are filtered in QueuePayouts 532 // Should not happen -- bad addresses are filtered in QueuePayouts
@@ -451,16 +537,70 @@ static void PayoutThreadFn(void*)
451 CScript sc; 537 CScript sc;
452 sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; 538 sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG;
453 CWalletTx wtx; 539 CWalletTx wtx;
454 wtx.mapValue["comment"] = strprintf("Pool payout height %d", 540 wtx.mapValue["comment"] = strprintf("Pool payout height %d", ppQueued.matureAtHeight);
455 pp.matureAtHeight); 541 wtx.mapValue["pool_payout_id"] = ppQueued.id;
456 if (SendMoney(sc, kv.second, wtx)) 542 wtx.mapValue["pool_payout_address"] = kv.first;
543 wtx.mapValue["pool_payout_amount"] = strprintf("%lld", kv.second);
544
545 std::string txid;
546 if (FindRecordedPoolPayout(ppQueued.id, kv.first, kv.second, txid))
547 {
548 LogPrint("payout", "[payout] recovered recorded payout %s to %s"
549 " from wallet tx %s\n",
550 FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str());
551 }
552 else if (SendMoney(sc, kv.second, wtx))
553 {
554 txid = wtx.GetHash().GetHex();
457 LogPrint("payout", "[payout] paid %s to %s\n", 555 LogPrint("payout", "[payout] paid %s to %s\n",
458 FormatMoney(kv.second).c_str(), kv.first.c_str()); 556 FormatMoney(kv.second).c_str(), kv.first.c_str());
557 }
459 else 558 else
559 {
460 LogPrint("payout", "[payout] FAILED to pay %s to %s" 560 LogPrint("payout", "[payout] FAILED to pay %s to %s"
461 " (insufficient funds or wallet locked?)\n", 561 " (insufficient funds or wallet locked?)\n",
462 FormatMoney(kv.second).c_str(), kv.first.c_str()); 562 FormatMoney(kv.second).c_str(), kv.first.c_str());
563 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
564 for (PendingPayout& pp : gPendingPayouts)
565 if (pp.id == ppQueued.id)
566 pp.nextAttempt = GetTime() + 60;
567 SavePendingPayoutsLocked();
568 fAbortBatch = true;
569 break;
570 }
571
572 {
573 std::lock_guard<std::mutex> lk(gPayoutLedgerMutex);
574 for (PendingPayout& pp : gPendingPayouts) {
575 if (pp.id != ppQueued.id)
576 continue;
577 pp.paidTxids[kv.first] = txid;
578 pp.nextAttempt = 0;
579 SavePendingPayoutsLocked();
580 break;
581 }
582 }
463 } 583 }
584
585 {
586 std::lock_guard<std::mutex> ledger(gPayoutLedgerMutex);
587 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) {
588 if (it->id == ppQueued.id && PayoutFullyPaid(*it)) {
589 LogPrint("payout", "[payout] payout %s complete -- removing from ledger\n",
590 it->id.c_str());
591 it = gPendingPayouts.erase(it);
592 SavePendingPayoutsLocked();
593 } else {
594 ++it;
595 }
596 }
597 }
598 {
599 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
600 gQueuedPayouts.erase(ppQueued.id);
601 }
602 if (fAbortBatch)
603 continue;
464 } 604 }
465 605
466 Sleep(500); // check for new work every 500ms 606 Sleep(500); // check for new work every 500ms
@@ -1073,16 +1213,21 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal)
1073 : a.lastSeen > b.lastSeen; 1213 : a.lastSeen > b.lastSeen;
1074 }); 1214 });
1075 1215
1076 // gPendingPayouts is event-loop-only data (no lock needed here since
1077 // RefreshStats is always called from the event loop thread).
1078 gPayoutViews.clear(); 1216 gPayoutViews.clear();
1217 std::lock_guard<std::mutex> ledger(gPayoutLedgerMutex);
1079 for (const PendingPayout& pp : gPendingPayouts) { 1218 for (const PendingPayout& pp : gPendingPayouts) {
1080 PendingPayoutView pv; 1219 PendingPayoutView pv;
1081 pv.matureAtHeight = pp.matureAtHeight; 1220 pv.matureAtHeight = pp.matureAtHeight;
1082 pv.recipients = (int)pp.amounts.size(); 1221 pv.recipients = 0;
1083 pv.totalAmount = 0; 1222 pv.totalAmount = 0;
1084 for (auto& kv : pp.amounts) pv.totalAmount += kv.second; 1223 for (auto& kv : pp.amounts) {
1085 gPayoutViews.push_back(pv); 1224 if (pp.paidTxids.count(kv.first))
1225 continue;
1226 pv.recipients++;
1227 pv.totalAmount += kv.second;
1228 }
1229 if (pv.recipients > 0)
1230 gPayoutViews.push_back(pv);
1086 } 1231 }
1087} 1232}
1088 1233