diff options
| author | Nsable-sh <nsable-sh@noreply.git.bitflash.network> | 2026-08-07 11:25:45 -0300 |
|---|---|---|
| committer | Bitflash <admin@bitflash.network> | 2026-08-07 15:05:18 -0300 |
| commit | 0b01d6cfce8be68150f5e06c11e13e0443372c4d (patch) | |
| tree | b1b7af277e3a978f25d0f039ef0fd65760c31e56 | |
| parent | b72f403c054838e4b2203f48f757df0dad017628 (diff) | |
pool: make payout ledger crash-safe
| -rw-r--r-- | src/rpc.cpp | 247 |
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 | // --------------------------------------------------------------------------- |
| 253 | struct PendingPayout { | 253 | struct 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 | }; |
| 257 | static std::vector<PendingPayout> gPendingPayouts; | 263 | static std::vector<PendingPayout> gPendingPayouts; |
| 264 | static std::mutex gPayoutLedgerMutex; | ||
| 258 | 265 | ||
| 259 | static std::string PayoutFilePath() | 266 | static std::string PayoutFilePath() |
| 260 | { | 267 | { |
| 261 | return GetAppDir() + "/pending_payouts.json"; | 268 | return GetAppDir() + "/pending_payouts.json"; |
| 262 | } | 269 | } |
| 263 | 270 | ||
| 264 | static void SavePendingPayouts() | 271 | static std::string PayoutIdForBlock(int blockHeight) |
| 272 | { | ||
| 273 | return strprintf("block-%d", blockHeight); | ||
| 274 | } | ||
| 275 | |||
| 276 | static 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 | |||
| 284 | static 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; | |||
| 319 | static std::mutex gPayoutExecMutex; | 369 | static std::mutex gPayoutExecMutex; |
| 320 | static std::atomic<bool> gPayoutThreadRunning{false}; | 370 | static std::atomic<bool> gPayoutThreadRunning{false}; |
| 321 | 371 | ||
| 372 | static std::set<std::string> gQueuedPayouts; | ||
| 373 | |||
| 374 | static 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 | |||
| 322 | static void QueuePayouts(int blockHeight, | 401 | static 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. | ||
| 399 | static void FlushMaturePayouts() | 474 | static 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 | ||