diff options
| author | Nsable-sh <nsable-sh@noreply.git.bitflash.network> | 2026-08-07 17:14:36 -0300 |
|---|---|---|
| committer | Bitflash <admin@bitflash.network> | 2026-08-07 17:21:08 -0300 |
| commit | b1eb8b66b8d7b47c7fb2e85d5d4db86a11823366 (patch) | |
| tree | 8f0cb17723ce1dc434ae7be7a148f7fec3f977a0 | |
| parent | 060a37de4a1f08ffb5939c8fc47a805c5d0d0024 (diff) | |
pool: publish round proof ledger
| -rw-r--r-- | README.md | 14 | ||||
| -rw-r--r-- | docs/pool-directory.md | 12 | ||||
| -rw-r--r-- | scripts/build-pool-index.py | 18 | ||||
| -rw-r--r-- | src/main.cpp | 1 | ||||
| -rw-r--r-- | src/main.h | 1 | ||||
| -rw-r--r-- | src/main_gui.cpp | 5 | ||||
| -rw-r--r-- | src/readme.txt | 4 | ||||
| -rw-r--r-- | src/rpc.cpp | 316 |
8 files changed, 359 insertions, 12 deletions
| @@ -219,10 +219,12 @@ Open **Options** from the menu bar. Under Mining Mode: | |||
| 219 | 219 | ||
| 220 | Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. | 220 | Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. |
| 221 | 221 | ||
| 222 | Pool operators also write a local `pool_status.json` every ten seconds. By | 222 | Pool operators also write a local `pool_status.json` every ten seconds and a |
| 223 | default it is created in the data directory; use `-poolstatusfile=PATH` to write | 223 | `pool_rounds.json` proof ledger whenever they find blocks or pay miners. By |
| 224 | it somewhere a dashboard or web sync can read. The file is informational only: | 224 | default both are created in the data directory; use `-poolstatusfile=PATH` and |
| 225 | nodes still discover pools through Nostr and `.btf`, not through any web domain. | 225 | `-poolroundsfile=PATH` to write them somewhere a dashboard or web sync can read. |
| 226 | The files are informational only: nodes still discover pools through Nostr and | ||
| 227 | `.btf`, not through any web domain. | ||
| 226 | 228 | ||
| 227 | **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. | 229 | **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. |
| 228 | 230 | ||
| @@ -243,7 +245,9 @@ xmrig -a rx/0 -o 127.0.0.1:3333 -u YOUR_BTF_ADDRESS -p x | |||
| 243 | ./bitflash -nogui # node only | 245 | ./bitflash -nogui # node only |
| 244 | ./bitflash -nogui -gen # node + solo mining | 246 | ./bitflash -nogui -gen # node + solo mining |
| 245 | ./bitflash -nogui -gen -operator # pool operator | 247 | ./bitflash -nogui -gen -operator # pool operator |
| 246 | ./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json | 248 | ./bitflash -nogui -gen -operator \ |
| 249 | -poolstatusfile=/var/www/pool_status.json \ | ||
| 250 | -poolroundsfile=/var/www/pool_rounds.json | ||
| 247 | ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool | 251 | ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool |
| 248 | ./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS # local bridge for XMRig/SRBMiner | 252 | ./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS # local bridge for XMRig/SRBMiner |
| 249 | ``` | 253 | ``` |
diff --git a/docs/pool-directory.md b/docs/pool-directory.md index 6456084..76269b9 100644 --- a/docs/pool-directory.md +++ b/docs/pool-directory.md | |||
| @@ -4,10 +4,13 @@ | |||
| 4 | Nodes discover pools through Nostr and `.btf`; the website only helps humans | 4 | Nodes discover pools through Nostr and `.btf`; the website only helps humans |
| 5 | compare operators. | 5 | compare operators. |
| 6 | 6 | ||
| 7 | Operators can publish the `pool_status.json` file produced by: | 7 | Operators can publish the `pool_status.json` and `pool_rounds.json` files |
| 8 | produced by: | ||
| 8 | 9 | ||
| 9 | ```bash | 10 | ```bash |
| 10 | bitflash -nogui -gen -operator -poolstatusfile=/var/www/pool_status.json | 11 | bitflash -nogui -gen -operator \ |
| 12 | -poolstatusfile=/var/www/pool_status.json \ | ||
| 13 | -poolroundsfile=/var/www/pool_rounds.json | ||
| 11 | ``` | 14 | ``` |
| 12 | 15 | ||
| 13 | Build a static directory from one or more status files: | 16 | Build a static directory from one or more status files: |
| @@ -23,6 +26,11 @@ The generated output contains: | |||
| 23 | - `index.html`: human-readable ranking page. | 26 | - `index.html`: human-readable ranking page. |
| 24 | - `pools.json`: machine-readable API for `api.bitflash.network/pools.json`. | 27 | - `pools.json`: machine-readable API for `api.bitflash.network/pools.json`. |
| 25 | 28 | ||
| 29 | When a source is an HTTP(S) `pool_status.json`, the directory links to a | ||
| 30 | `pool_rounds.json` proof file next to it. That file records the block height, | ||
| 31 | block hash, coinbase txid, coinbase value, pool fee, total shares, per-address | ||
| 32 | share counts, computed payouts, and payout txids once paid. | ||
| 33 | |||
| 26 | Ranking order is intentionally simple: | 34 | Ranking order is intentionally simple: |
| 27 | 35 | ||
| 28 | 1. online pools first; | 36 | 1. online pools first; |
diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py index 382b6c3..d3eb386 100644 --- a/scripts/build-pool-index.py +++ b/scripts/build-pool-index.py | |||
| @@ -8,6 +8,7 @@ import html | |||
| 8 | import json | 8 | import json |
| 9 | import os | 9 | import os |
| 10 | import time | 10 | import time |
| 11 | import urllib.parse | ||
| 11 | import urllib.request | 12 | import urllib.request |
| 12 | 13 | ||
| 13 | 14 | ||
| @@ -23,6 +24,10 @@ def normalize_pool(source, obj, now): | |||
| 23 | operator = obj.get("operator", {}) | 24 | operator = obj.get("operator", {}) |
| 24 | pool = obj.get("pool", {}) | 25 | pool = obj.get("pool", {}) |
| 25 | node = obj.get("node", {}) | 26 | node = obj.get("node", {}) |
| 27 | rounds_file = operator.get("roundsFile") or "pool_rounds.json" | ||
| 28 | rounds_url = "" | ||
| 29 | if source.startswith("http://") or source.startswith("https://"): | ||
| 30 | rounds_url = urllib.parse.urljoin(source, rounds_file) | ||
| 26 | updated = int(obj.get("updatedAt") or 0) | 31 | updated = int(obj.get("updatedAt") or 0) |
| 27 | age = max(0, now - updated) if updated else None | 32 | age = max(0, now - updated) if updated else None |
| 28 | online = bool(node.get("poolRunning")) and age is not None and age <= 120 | 33 | online = bool(node.get("poolRunning")) and age is not None and age <= 120 |
| @@ -39,6 +44,8 @@ def normalize_pool(source, obj, now): | |||
| 39 | "blocksFoundSession": int(pool.get("blocksFoundSession") or 0), | 44 | "blocksFoundSession": int(pool.get("blocksFoundSession") or 0), |
| 40 | "roundShares": int(pool.get("roundShares") or 0), | 45 | "roundShares": int(pool.get("roundShares") or 0), |
| 41 | "hashRate": float(pool.get("hashRate") or 0.0), | 46 | "hashRate": float(pool.get("hashRate") or 0.0), |
| 47 | "roundsUrl": rounds_url, | ||
| 48 | "recentRounds": obj.get("recentRounds", []), | ||
| 42 | } | 49 | } |
| 43 | 50 | ||
| 44 | 51 | ||
| @@ -56,6 +63,7 @@ def render_html(pools, generated_at): | |||
| 56 | rows = [] | 63 | rows = [] |
| 57 | for p in pools: | 64 | for p in pools: |
| 58 | dash = p["dashboardUrl"] | 65 | dash = p["dashboardUrl"] |
| 66 | rounds = p.get("roundsUrl", "") | ||
| 59 | # Only linkify http(s). The dashboard URL comes from an operator's | 67 | # Only linkify http(s). The dashboard URL comes from an operator's |
| 60 | # self-published status file, so a "javascript:" or "data:" scheme | 68 | # self-published status file, so a "javascript:" or "data:" scheme |
| 61 | # would become a clickable script-injection on this public page. | 69 | # would become a clickable script-injection on this public page. |
| @@ -65,6 +73,12 @@ def render_html(pools, generated_at): | |||
| 65 | html.escape(dash, quote=True)) | 73 | html.escape(dash, quote=True)) |
| 66 | else: | 74 | else: |
| 67 | dash_html = "-" | 75 | dash_html = "-" |
| 76 | rounds_lower = rounds.lower() | ||
| 77 | if rounds_lower.startswith("http://") or rounds_lower.startswith("https://"): | ||
| 78 | rounds_html = '<a href="{0}" rel="noopener noreferrer">proof</a>'.format( | ||
| 79 | html.escape(rounds, quote=True)) | ||
| 80 | else: | ||
| 81 | rounds_html = "-" | ||
| 68 | rows.append( | 82 | rows.append( |
| 69 | "<tr>" | 83 | "<tr>" |
| 70 | "<td>{status}</td>" | 84 | "<td>{status}</td>" |
| @@ -76,6 +90,7 @@ def render_html(pools, generated_at): | |||
| 76 | "<td>{height}</td>" | 90 | "<td>{height}</td>" |
| 77 | "<td>{age}</td>" | 91 | "<td>{age}</td>" |
| 78 | "<td>{dash}</td>" | 92 | "<td>{dash}</td>" |
| 93 | "<td>{rounds}</td>" | ||
| 79 | "</tr>".format( | 94 | "</tr>".format( |
| 80 | status="online" if p["online"] else "stale", | 95 | status="online" if p["online"] else "stale", |
| 81 | name=html.escape(p["name"]), | 96 | name=html.escape(p["name"]), |
| @@ -87,6 +102,7 @@ def render_html(pools, generated_at): | |||
| 87 | height=p["height"], | 102 | height=p["height"], |
| 88 | age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-", | 103 | age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-", |
| 89 | dash=dash_html, | 104 | dash=dash_html, |
| 105 | rounds=rounds_html, | ||
| 90 | ) | 106 | ) |
| 91 | ) | 107 | ) |
| 92 | 108 | ||
| @@ -110,7 +126,7 @@ def render_html(pools, generated_at): | |||
| 110 | <p class="note">This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.</p> | 126 | <p class="note">This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.</p> |
| 111 | <table> | 127 | <table> |
| 112 | <thead> | 128 | <thead> |
| 113 | <tr><th>Status</th><th>Pool</th><th>Fee</th><th>Miners</th><th>H/s</th><th>Blocks</th><th>Height</th><th>Age</th><th>Link</th></tr> | 129 | <tr><th>Status</th><th>Pool</th><th>Fee</th><th>Miners</th><th>H/s</th><th>Blocks</th><th>Height</th><th>Age</th><th>Link</th><th>Proof</th></tr> |
| 114 | </thead> | 130 | </thead> |
| 115 | <tbody> | 131 | <tbody> |
| 116 | {rows} | 132 | {rows} |
diff --git a/src/main.cpp b/src/main.cpp index 2be9c54..2eb0e3a 100644 --- a/src/main.cpp +++ b/src/main.cpp | |||
| @@ -105,6 +105,7 @@ string strParticipantPool; // participant mode: pool .btf address | |||
| 105 | string strPoolName = "Bitflash Pool"; | 105 | string strPoolName = "Bitflash Pool"; |
| 106 | string strPoolDashboardUrl; | 106 | string strPoolDashboardUrl; |
| 107 | string strPoolStatusFile; | 107 | string strPoolStatusFile; |
| 108 | string strPoolRoundsFile; | ||
| 108 | double dPoolFeePercent = 0.0; | 109 | double dPoolFeePercent = 0.0; |
| 109 | bool fStratumBridge = false; | 110 | bool fStratumBridge = false; |
| 110 | int nStratumBridgePort = 3333; | 111 | int nStratumBridgePort = 3333; |
| @@ -87,6 +87,7 @@ extern string strParticipantPool; // participant: pool .btf address | |||
| 87 | extern string strPoolName; // operator: announced pool name | 87 | extern string strPoolName; // operator: announced pool name |
| 88 | extern string strPoolDashboardUrl; // operator: optional dashboard URL | 88 | extern string strPoolDashboardUrl; // operator: optional dashboard URL |
| 89 | extern string strPoolStatusFile; // operator: optional JSON status output path | 89 | extern string strPoolStatusFile; // operator: optional JSON status output path |
| 90 | extern string strPoolRoundsFile; // operator: optional public round proof path | ||
| 90 | extern double dPoolFeePercent; // operator: announced fee percent | 91 | extern double dPoolFeePercent; // operator: announced fee percent |
| 91 | extern bool fStratumBridge; // participant helper: local Stratum bridge | 92 | extern bool fStratumBridge; // participant helper: local Stratum bridge |
| 92 | extern int nStratumBridgePort; // local bridge listen port, default 3333 | 93 | extern int nStratumBridgePort; // local bridge listen port, default 3333 |
diff --git a/src/main_gui.cpp b/src/main_gui.cpp index ce6d2b5..7ad2075 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp | |||
| @@ -126,6 +126,7 @@ static void PrintUsage() | |||
| 126 | printf(" /poolfee=PCT\n"); | 126 | printf(" /poolfee=PCT\n"); |
| 127 | printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); | 127 | printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); |
| 128 | printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); | 128 | printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); |
| 129 | printf(" /poolroundsfile=PATH (write public pool round proofs JSON)\n"); | ||
| 129 | printf("\n"); | 130 | printf("\n"); |
| 130 | printf(".btf and rendezvous:\n"); | 131 | printf(".btf and rendezvous:\n"); |
| 131 | printf(" /connectbtf=PEER_BTF_ADDRESS\n"); | 132 | printf(" /connectbtf=PEER_BTF_ADDRESS\n"); |
| @@ -262,6 +263,10 @@ static void ParseStartupArguments(int argc, char* argv[]) | |||
| 262 | if (!poolStatusFile.empty()) | 263 | if (!poolStatusFile.empty()) |
| 263 | strPoolStatusFile = poolStatusFile; | 264 | strPoolStatusFile = poolStatusFile; |
| 264 | 265 | ||
| 266 | string poolRoundsFile = argval2(argc, argv, "/poolroundsfile", "-poolroundsfile"); | ||
| 267 | if (!poolRoundsFile.empty()) | ||
| 268 | strPoolRoundsFile = poolRoundsFile; | ||
| 269 | |||
| 265 | string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); | 270 | string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); |
| 266 | if (!btfConnect.empty()) | 271 | if (!btfConnect.empty()) |
| 267 | strBtfConnect = btfConnect; | 272 | strBtfConnect = btfConnect; |
diff --git a/src/readme.txt b/src/readme.txt index fa58b6e..b8b89c1 100644 --- a/src/readme.txt +++ b/src/readme.txt | |||
| @@ -55,6 +55,10 @@ Command-line options (in addition to the original ones) | |||
| 55 | /poolfee=PCT operator announcement fee percent (example: 0.75) | 55 | /poolfee=PCT operator announcement fee percent (example: 0.75) |
| 56 | /pooldashboard=URL | 56 | /pooldashboard=URL |
| 57 | operator announcement dashboard URL | 57 | operator announcement dashboard URL |
| 58 | /poolstatusfile=PATH | ||
| 59 | write pool status JSON for dashboards | ||
| 60 | /poolroundsfile=PATH | ||
| 61 | write public pool round proofs JSON | ||
| 58 | /connectbtf=ADDR | 62 | /connectbtf=ADDR |
| 59 | keep an outbound .btf connection to ADDR | 63 | keep an outbound .btf connection to ADDR |
| 60 | /rvrelay=HOST:PORT | 64 | /rvrelay=HOST:PORT |
diff --git a/src/rpc.cpp b/src/rpc.cpp index 1aa4f9b..813f1d1 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp | |||
| @@ -263,11 +263,47 @@ struct PendingPayout { | |||
| 263 | static std::vector<PendingPayout> gPendingPayouts; | 263 | static std::vector<PendingPayout> gPendingPayouts; |
| 264 | static std::mutex gPayoutLedgerMutex; | 264 | static std::mutex gPayoutLedgerMutex; |
| 265 | 265 | ||
| 266 | struct PoolRoundProof { | ||
| 267 | std::string id; | ||
| 268 | int blockHeight; | ||
| 269 | int matureAtHeight; | ||
| 270 | int64 createdAt; | ||
| 271 | std::string blockHash; | ||
| 272 | std::string coinbaseTxid; | ||
| 273 | std::string foundBy; | ||
| 274 | double feePercent; | ||
| 275 | uint64 totalShares; | ||
| 276 | int64 coinbaseValue; | ||
| 277 | int64 subsidy; | ||
| 278 | int64 minerReward; | ||
| 279 | int64 operatorReward; | ||
| 280 | int skippedDust; | ||
| 281 | int64 forfeitedBadAddress; | ||
| 282 | std::map<std::string, uint64> shares; | ||
| 283 | std::map<std::string, int64> payouts; | ||
| 284 | std::map<std::string, std::string> paidTxids; | ||
| 285 | |||
| 286 | PoolRoundProof() | ||
| 287 | : blockHeight(0), matureAtHeight(0), createdAt(0), feePercent(0.0), | ||
| 288 | totalShares(0), coinbaseValue(0), subsidy(0), minerReward(0), operatorReward(0), | ||
| 289 | skippedDust(0), forfeitedBadAddress(0) {} | ||
| 290 | }; | ||
| 291 | static std::vector<PoolRoundProof> gPoolRounds; | ||
| 292 | static std::mutex gPoolRoundsMutex; | ||
| 293 | static const size_t MAX_POOL_ROUNDS = 200; | ||
| 294 | |||
| 266 | static std::string PayoutFilePath() | 295 | static std::string PayoutFilePath() |
| 267 | { | 296 | { |
| 268 | return GetAppDir() + "/pending_payouts.json"; | 297 | return GetAppDir() + "/pending_payouts.json"; |
| 269 | } | 298 | } |
| 270 | 299 | ||
| 300 | static std::string PoolRoundsFilePath() | ||
| 301 | { | ||
| 302 | if (!strPoolRoundsFile.empty()) | ||
| 303 | return strPoolRoundsFile; | ||
| 304 | return GetAppDir() + "/pool_rounds.json"; | ||
| 305 | } | ||
| 306 | |||
| 271 | static std::string PayoutIdForBlock(int blockHeight) | 307 | static std::string PayoutIdForBlock(int blockHeight) |
| 272 | { | 308 | { |
| 273 | return strprintf("block-%d", blockHeight); | 309 | return strprintf("block-%d", blockHeight); |
| @@ -362,6 +398,167 @@ static void LoadPendingPayouts() | |||
| 362 | } | 398 | } |
| 363 | } | 399 | } |
| 364 | 400 | ||
| 401 | static nlohmann::json PoolRoundToJson(const PoolRoundProof& r) | ||
| 402 | { | ||
| 403 | nlohmann::json j; | ||
| 404 | j["id"] = r.id; | ||
| 405 | j["blockHeight"] = r.blockHeight; | ||
| 406 | j["matureAtHeight"] = r.matureAtHeight; | ||
| 407 | j["createdAt"] = r.createdAt; | ||
| 408 | j["blockHash"] = r.blockHash; | ||
| 409 | j["coinbaseTxid"] = r.coinbaseTxid; | ||
| 410 | j["foundBy"] = r.foundBy; | ||
| 411 | j["feePercent"] = r.feePercent; | ||
| 412 | j["totalShares"] = r.totalShares; | ||
| 413 | j["coinbaseValueSatoshis"] = r.coinbaseValue; | ||
| 414 | j["coinbaseValueBtf"] = FormatMoney(r.coinbaseValue); | ||
| 415 | j["subsidySatoshis"] = r.subsidy; | ||
| 416 | j["subsidyBtf"] = FormatMoney(r.subsidy); | ||
| 417 | j["minerRewardSatoshis"] = r.minerReward; | ||
| 418 | j["minerRewardBtf"] = FormatMoney(r.minerReward); | ||
| 419 | j["operatorRewardSatoshis"] = r.operatorReward; | ||
| 420 | j["operatorRewardBtf"] = FormatMoney(r.operatorReward); | ||
| 421 | j["skippedDust"] = r.skippedDust; | ||
| 422 | j["forfeitedBadAddressSatoshis"] = r.forfeitedBadAddress; | ||
| 423 | j["forfeitedBadAddressBtf"] = FormatMoney(r.forfeitedBadAddress); | ||
| 424 | |||
| 425 | j["shares"] = nlohmann::json::object(); | ||
| 426 | for (const auto& kv : r.shares) | ||
| 427 | j["shares"][kv.first] = kv.second; | ||
| 428 | |||
| 429 | j["payouts"] = nlohmann::json::object(); | ||
| 430 | for (const auto& kv : r.payouts) { | ||
| 431 | nlohmann::json p; | ||
| 432 | p["amountSatoshis"] = kv.second; | ||
| 433 | p["amountBtf"] = FormatMoney(kv.second); | ||
| 434 | std::map<std::string, std::string>::const_iterator tx = r.paidTxids.find(kv.first); | ||
| 435 | if (tx != r.paidTxids.end()) | ||
| 436 | p["txid"] = tx->second; | ||
| 437 | else | ||
| 438 | p["txid"] = nullptr; | ||
| 439 | j["payouts"][kv.first] = p; | ||
| 440 | } | ||
| 441 | return j; | ||
| 442 | } | ||
| 443 | |||
| 444 | static void SavePoolRoundsLocked() | ||
| 445 | { | ||
| 446 | nlohmann::json root; | ||
| 447 | root["schema"] = "bitflash-pool-rounds-1"; | ||
| 448 | root["updatedAt"] = GetTime(); | ||
| 449 | root["rounds"] = nlohmann::json::array(); | ||
| 450 | for (const PoolRoundProof& r : gPoolRounds) | ||
| 451 | root["rounds"].push_back(PoolRoundToJson(r)); | ||
| 452 | |||
| 453 | std::string path = PoolRoundsFilePath(); | ||
| 454 | std::string tmp = path + ".tmp"; | ||
| 455 | FILE* f = fopen(tmp.c_str(), "w"); | ||
| 456 | if (!f) { | ||
| 457 | LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); | ||
| 458 | return; | ||
| 459 | } | ||
| 460 | std::string s = root.dump(2); | ||
| 461 | fwrite(s.c_str(), 1, s.size(), f); | ||
| 462 | if (fclose(f) != 0) { | ||
| 463 | LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); | ||
| 464 | return; | ||
| 465 | } | ||
| 466 | #ifdef _WIN32 | ||
| 467 | remove(path.c_str()); | ||
| 468 | #endif | ||
| 469 | if (rename(tmp.c_str(), path.c_str()) != 0) | ||
| 470 | LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); | ||
| 471 | } | ||
| 472 | |||
| 473 | static void LoadPoolRounds() | ||
| 474 | { | ||
| 475 | std::string path = PoolRoundsFilePath(); | ||
| 476 | FILE* f = fopen(path.c_str(), "r"); | ||
| 477 | if (!f) return; | ||
| 478 | fseek(f, 0, SEEK_END); | ||
| 479 | long sz = ftell(f); | ||
| 480 | fseek(f, 0, SEEK_SET); | ||
| 481 | std::string s(sz, '\0'); | ||
| 482 | fread(&s[0], 1, sz, f); | ||
| 483 | fclose(f); | ||
| 484 | |||
| 485 | try { | ||
| 486 | nlohmann::json root = nlohmann::json::parse(s); | ||
| 487 | nlohmann::json arr = root.is_array() ? root : root["rounds"]; | ||
| 488 | std::lock_guard<std::mutex> lk(gPoolRoundsMutex); | ||
| 489 | for (const auto& obj : arr) { | ||
| 490 | PoolRoundProof r; | ||
| 491 | if (obj.contains("id") && obj["id"].is_string()) r.id = obj["id"].get<std::string>(); | ||
| 492 | if (obj.contains("blockHeight")) r.blockHeight = obj["blockHeight"].get<int>(); | ||
| 493 | if (obj.contains("matureAtHeight")) r.matureAtHeight = obj["matureAtHeight"].get<int>(); | ||
| 494 | if (obj.contains("createdAt")) r.createdAt = obj["createdAt"].get<int64>(); | ||
| 495 | if (obj.contains("blockHash") && obj["blockHash"].is_string()) r.blockHash = obj["blockHash"].get<std::string>(); | ||
| 496 | if (obj.contains("coinbaseTxid") && obj["coinbaseTxid"].is_string()) r.coinbaseTxid = obj["coinbaseTxid"].get<std::string>(); | ||
| 497 | if (obj.contains("foundBy") && obj["foundBy"].is_string()) r.foundBy = obj["foundBy"].get<std::string>(); | ||
| 498 | if (obj.contains("feePercent")) r.feePercent = obj["feePercent"].get<double>(); | ||
| 499 | if (obj.contains("totalShares")) r.totalShares = obj["totalShares"].get<uint64>(); | ||
| 500 | if (obj.contains("coinbaseValueSatoshis")) r.coinbaseValue = obj["coinbaseValueSatoshis"].get<int64>(); | ||
| 501 | if (obj.contains("subsidySatoshis")) r.subsidy = obj["subsidySatoshis"].get<int64>(); | ||
| 502 | if (obj.contains("minerRewardSatoshis")) r.minerReward = obj["minerRewardSatoshis"].get<int64>(); | ||
| 503 | if (obj.contains("operatorRewardSatoshis")) r.operatorReward = obj["operatorRewardSatoshis"].get<int64>(); | ||
| 504 | if (obj.contains("skippedDust")) r.skippedDust = obj["skippedDust"].get<int>(); | ||
| 505 | if (obj.contains("forfeitedBadAddressSatoshis")) r.forfeitedBadAddress = obj["forfeitedBadAddressSatoshis"].get<int64>(); | ||
| 506 | if (obj.contains("shares") && obj["shares"].is_object()) | ||
| 507 | for (auto it = obj["shares"].begin(); it != obj["shares"].end(); ++it) | ||
| 508 | r.shares[it.key()] = it.value().get<uint64>(); | ||
| 509 | if (obj.contains("payouts") && obj["payouts"].is_object()) | ||
| 510 | for (auto it = obj["payouts"].begin(); it != obj["payouts"].end(); ++it) { | ||
| 511 | if (it.value().is_object()) { | ||
| 512 | r.payouts[it.key()] = it.value().value("amountSatoshis", (int64)0); | ||
| 513 | if (it.value().contains("txid") && it.value()["txid"].is_string()) | ||
| 514 | r.paidTxids[it.key()] = it.value()["txid"].get<std::string>(); | ||
| 515 | } else if (it.value().is_number_integer()) { | ||
| 516 | r.payouts[it.key()] = it.value().get<int64>(); | ||
| 517 | } | ||
| 518 | } | ||
| 519 | if (!r.id.empty()) | ||
| 520 | gPoolRounds.push_back(r); | ||
| 521 | } | ||
| 522 | while (gPoolRounds.size() > MAX_POOL_ROUNDS) | ||
| 523 | gPoolRounds.erase(gPoolRounds.begin()); | ||
| 524 | LogPrint("payout", "[payout] loaded %zu pool round proof(s) from disk\n", | ||
| 525 | gPoolRounds.size()); | ||
| 526 | } catch (const std::exception& e) { | ||
| 527 | LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", | ||
| 528 | path.c_str(), e.what()); | ||
| 529 | } | ||
| 530 | } | ||
| 531 | |||
| 532 | static void RecordPoolRound(const PoolRoundProof& round) | ||
| 533 | { | ||
| 534 | std::lock_guard<std::mutex> lk(gPoolRoundsMutex); | ||
| 535 | for (PoolRoundProof& existing : gPoolRounds) { | ||
| 536 | if (existing.id == round.id) { | ||
| 537 | existing = round; | ||
| 538 | SavePoolRoundsLocked(); | ||
| 539 | return; | ||
| 540 | } | ||
| 541 | } | ||
| 542 | gPoolRounds.push_back(round); | ||
| 543 | while (gPoolRounds.size() > MAX_POOL_ROUNDS) | ||
| 544 | gPoolRounds.erase(gPoolRounds.begin()); | ||
| 545 | SavePoolRoundsLocked(); | ||
| 546 | } | ||
| 547 | |||
| 548 | static void MarkPoolRoundPaid(const std::string& id, | ||
| 549 | const std::string& address, | ||
| 550 | const std::string& txid) | ||
| 551 | { | ||
| 552 | std::lock_guard<std::mutex> lk(gPoolRoundsMutex); | ||
| 553 | for (PoolRoundProof& r : gPoolRounds) { | ||
| 554 | if (r.id != id) | ||
| 555 | continue; | ||
| 556 | r.paidTxids[address] = txid; | ||
| 557 | SavePoolRoundsLocked(); | ||
| 558 | return; | ||
| 559 | } | ||
| 560 | } | ||
| 561 | |||
| 365 | // Payouts ready to execute are moved here under gPayoutExecMutex. | 562 | // Payouts ready to execute are moved here under gPayoutExecMutex. |
| 366 | // A dedicated thread drains this list so SendMoney (which takes cs_main) | 563 | // A dedicated thread drains this list so SendMoney (which takes cs_main) |
| 367 | // never runs on the event loop thread and can't stall miner I/O. | 564 | // never runs on the event loop thread and can't stall miner I/O. |
| @@ -399,10 +596,28 @@ static bool FindRecordedPoolPayout(const std::string& id, | |||
| 399 | } | 596 | } |
| 400 | 597 | ||
| 401 | static void QueuePayouts(int blockHeight, | 598 | static void QueuePayouts(int blockHeight, |
| 599 | const std::string& blockHash, | ||
| 600 | const std::string& coinbaseTxid, | ||
| 601 | int64 coinbaseValue, | ||
| 602 | const std::string& foundBy, | ||
| 402 | const std::map<std::string, uint64>& shareCount, | 603 | const std::map<std::string, uint64>& shareCount, |
| 403 | uint64 total) | 604 | uint64 total) |
| 404 | { | 605 | { |
| 606 | PoolRoundProof round; | ||
| 607 | round.id = PayoutIdForBlock(blockHeight); | ||
| 608 | round.blockHeight = blockHeight; | ||
| 609 | round.matureAtHeight = blockHeight + COINBASE_MATURITY; | ||
| 610 | round.createdAt = GetTime(); | ||
| 611 | round.blockHash = blockHash; | ||
| 612 | round.coinbaseTxid = coinbaseTxid; | ||
| 613 | round.coinbaseValue = coinbaseValue; | ||
| 614 | round.foundBy = foundBy; | ||
| 615 | round.feePercent = dPoolFeePercent; | ||
| 616 | round.totalShares = total; | ||
| 617 | round.shares = shareCount; | ||
| 618 | |||
| 405 | if (total == 0) { | 619 | if (total == 0) { |
| 620 | RecordPoolRound(round); | ||
| 406 | LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n", | 621 | LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n", |
| 407 | blockHeight); | 622 | blockHeight); |
| 408 | return; | 623 | return; |
| @@ -418,9 +633,13 @@ static void QueuePayouts(int blockHeight, | |||
| 418 | int64 nSubsidy = 50 * COIN; | 633 | int64 nSubsidy = 50 * COIN; |
| 419 | nSubsidy >>= (blockHeight / 210000); // halving schedule | 634 | nSubsidy >>= (blockHeight / 210000); // halving schedule |
| 420 | int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); | 635 | int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); |
| 636 | int64 operatorCut = nSubsidy - minerReward; | ||
| 637 | round.subsidy = nSubsidy; | ||
| 638 | round.minerReward = minerReward; | ||
| 639 | round.operatorReward = operatorCut; | ||
| 421 | 640 | ||
| 422 | PendingPayout pp; | 641 | PendingPayout pp; |
| 423 | pp.id = PayoutIdForBlock(blockHeight); | 642 | pp.id = round.id; |
| 424 | pp.blockHeight = blockHeight; | 643 | pp.blockHeight = blockHeight; |
| 425 | pp.matureAtHeight = blockHeight + COINBASE_MATURITY; | 644 | pp.matureAtHeight = blockHeight + COINBASE_MATURITY; |
| 426 | 645 | ||
| @@ -443,15 +662,18 @@ static void QueuePayouts(int blockHeight, | |||
| 443 | continue; | 662 | continue; |
| 444 | } | 663 | } |
| 445 | pp.amounts[kv.first] = amount; | 664 | pp.amounts[kv.first] = amount; |
| 665 | round.payouts[kv.first] = amount; | ||
| 446 | } | 666 | } |
| 667 | round.skippedDust = skipped; | ||
| 668 | round.forfeitedBadAddress = burned; | ||
| 447 | 669 | ||
| 448 | { | 670 | { |
| 449 | std::lock_guard<std::mutex> lk(gPayoutLedgerMutex); | 671 | std::lock_guard<std::mutex> lk(gPayoutLedgerMutex); |
| 450 | gPendingPayouts.push_back(pp); | 672 | gPendingPayouts.push_back(pp); |
| 451 | SavePendingPayoutsLocked(); | 673 | SavePendingPayoutsLocked(); |
| 452 | } | 674 | } |
| 675 | RecordPoolRound(round); | ||
| 453 | 676 | ||
| 454 | int64 operatorCut = nSubsidy - minerReward; | ||
| 455 | LogPrint("payout", | 677 | LogPrint("payout", |
| 456 | "[payout] block %d: subsidy %s BTF, miners get %s" | 678 | "[payout] block %d: subsidy %s BTF, miners get %s" |
| 457 | " (fee %.2f%% = %s to operator)," | 679 | " (fee %.2f%% = %s to operator)," |
| @@ -548,12 +770,14 @@ static void PayoutThreadFn(void*) | |||
| 548 | LogPrint("payout", "[payout] recovered recorded payout %s to %s" | 770 | LogPrint("payout", "[payout] recovered recorded payout %s to %s" |
| 549 | " from wallet tx %s\n", | 771 | " from wallet tx %s\n", |
| 550 | FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str()); | 772 | FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str()); |
| 773 | MarkPoolRoundPaid(ppQueued.id, kv.first, txid); | ||
| 551 | } | 774 | } |
| 552 | else if (SendMoney(sc, kv.second, wtx)) | 775 | else if (SendMoney(sc, kv.second, wtx)) |
| 553 | { | 776 | { |
| 554 | txid = wtx.GetHash().GetHex(); | 777 | txid = wtx.GetHash().GetHex(); |
| 555 | LogPrint("payout", "[payout] paid %s to %s\n", | 778 | LogPrint("payout", "[payout] paid %s to %s\n", |
| 556 | FormatMoney(kv.second).c_str(), kv.first.c_str()); | 779 | FormatMoney(kv.second).c_str(), kv.first.c_str()); |
| 780 | MarkPoolRoundPaid(ppQueued.id, kv.first, txid); | ||
| 557 | } | 781 | } |
| 558 | else | 782 | else |
| 559 | { | 783 | { |
| @@ -664,7 +888,8 @@ static void WritePoolStatusJsonLocked() | |||
| 664 | {"name", strPoolName}, | 888 | {"name", strPoolName}, |
| 665 | {"btfAddress", BtfLocalAddress()}, | 889 | {"btfAddress", BtfLocalAddress()}, |
| 666 | {"feePercent", dPoolFeePercent}, | 890 | {"feePercent", dPoolFeePercent}, |
| 667 | {"dashboardUrl", strPoolDashboardUrl} | 891 | {"dashboardUrl", strPoolDashboardUrl}, |
| 892 | {"roundsFile", "pool_rounds.json"} | ||
| 668 | }; | 893 | }; |
| 669 | 894 | ||
| 670 | j["node"] = { | 895 | j["node"] = { |
| @@ -702,6 +927,30 @@ static void WritePoolStatusJsonLocked() | |||
| 702 | }); | 927 | }); |
| 703 | } | 928 | } |
| 704 | 929 | ||
| 930 | j["recentRounds"] = nlohmann::json::array(); | ||
| 931 | { | ||
| 932 | std::lock_guard<std::mutex> lk(gPoolRoundsMutex); | ||
| 933 | size_t start = gPoolRounds.size() > 10 ? gPoolRounds.size() - 10 : 0; | ||
| 934 | for (size_t i = start; i < gPoolRounds.size(); i++) { | ||
| 935 | const PoolRoundProof& r = gPoolRounds[i]; | ||
| 936 | int unpaid = 0; | ||
| 937 | for (const auto& kv : r.payouts) | ||
| 938 | if (r.paidTxids.count(kv.first) == 0) | ||
| 939 | unpaid++; | ||
| 940 | j["recentRounds"].push_back({ | ||
| 941 | {"id", r.id}, | ||
| 942 | {"blockHeight", r.blockHeight}, | ||
| 943 | {"blockHash", r.blockHash}, | ||
| 944 | {"coinbaseTxid", r.coinbaseTxid}, | ||
| 945 | {"totalShares", r.totalShares}, | ||
| 946 | {"payouts", (int)r.payouts.size()}, | ||
| 947 | {"unpaidPayouts", unpaid}, | ||
| 948 | {"coinbaseValueBtf", FormatMoney(r.coinbaseValue)}, | ||
| 949 | {"minerRewardBtf", FormatMoney(r.minerReward)} | ||
| 950 | }); | ||
| 951 | } | ||
| 952 | } | ||
| 953 | |||
| 705 | std::string path = PoolStatusFilePath(); | 954 | std::string path = PoolStatusFilePath(); |
| 706 | std::string tmp = path + ".tmp"; | 955 | std::string tmp = path + ".tmp"; |
| 707 | FILE* f = fopen(tmp.c_str(), "w"); | 956 | FILE* f = fopen(tmp.c_str(), "w"); |
| @@ -1231,7 +1480,9 @@ static bool HandleLine(Miner* m, const std::string& rawLine, | |||
| 1231 | if (accepted) { | 1480 | if (accepted) { |
| 1232 | // Snapshot height before RebuildJob() (which increments it) | 1481 | // Snapshot height before RebuildJob() (which increments it) |
| 1233 | int foundHeight = gCurrentJob.height; | 1482 | int foundHeight = gCurrentJob.height; |
| 1234 | QueuePayouts(foundHeight, roundShareCount, roundShareTotal); | 1483 | QueuePayouts(foundHeight, b.GetHash().GetHex(), b.vtx[0].GetHash().GetHex(), |
| 1484 | b.vtx[0].vout[0].nValue, m->address, | ||
| 1485 | roundShareCount, roundShareTotal); | ||
| 1235 | // Reset round shares | 1486 | // Reset round shares |
| 1236 | roundShareCount.clear(); | 1487 | roundShareCount.clear(); |
| 1237 | roundShareTotal = 0; | 1488 | roundShareTotal = 0; |
| @@ -1333,6 +1584,62 @@ int RunPoolStratumSelfTest() | |||
| 1333 | HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); | 1584 | HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); |
| 1334 | check(roundShareTotal == 1, "stale submit does not increment shares"); | 1585 | check(roundShareTotal == 1, "stale submit does not increment shares"); |
| 1335 | 1586 | ||
| 1587 | std::vector<PoolRoundProof> savedRounds = gPoolRounds; | ||
| 1588 | std::string savedRoundsFile = strPoolRoundsFile; | ||
| 1589 | std::string proofPath = "pool_rounds_selftest.json"; | ||
| 1590 | remove(proofPath.c_str()); | ||
| 1591 | remove((proofPath + ".tmp").c_str()); | ||
| 1592 | gPoolRounds.clear(); | ||
| 1593 | strPoolRoundsFile = proofPath; | ||
| 1594 | |||
| 1595 | PoolRoundProof proof; | ||
| 1596 | proof.id = "selftest-round"; | ||
| 1597 | proof.blockHeight = 42; | ||
| 1598 | proof.matureAtHeight = 42 + COINBASE_MATURITY; | ||
| 1599 | proof.createdAt = GetTime(); | ||
| 1600 | proof.blockHash = block.GetHash().GetHex(); | ||
| 1601 | proof.coinbaseTxid = coinbase.GetHash().GetHex(); | ||
| 1602 | proof.foundBy = miner.address; | ||
| 1603 | proof.feePercent = 1.25; | ||
| 1604 | proof.totalShares = 3; | ||
| 1605 | proof.coinbaseValue = 50 * COIN; | ||
| 1606 | proof.subsidy = 50 * COIN; | ||
| 1607 | proof.minerReward = 49 * COIN; | ||
| 1608 | proof.operatorReward = 1 * COIN; | ||
| 1609 | proof.shares[miner.address] = 3; | ||
| 1610 | proof.payouts[miner.address] = 49 * COIN; | ||
| 1611 | RecordPoolRound(proof); | ||
| 1612 | MarkPoolRoundPaid(proof.id, miner.address, "selftest-txid"); | ||
| 1613 | |||
| 1614 | bool proofOk = false; | ||
| 1615 | FILE* pf = fopen(proofPath.c_str(), "r"); | ||
| 1616 | if (pf) { | ||
| 1617 | fseek(pf, 0, SEEK_END); | ||
| 1618 | long sz = ftell(pf); | ||
| 1619 | fseek(pf, 0, SEEK_SET); | ||
| 1620 | std::string s(sz, '\0'); | ||
| 1621 | fread(&s[0], 1, sz, pf); | ||
| 1622 | fclose(pf); | ||
| 1623 | try { | ||
| 1624 | json root = json::parse(s); | ||
| 1625 | proofOk = | ||
| 1626 | root.value("schema", "") == "bitflash-pool-rounds-1" && | ||
| 1627 | root["rounds"].is_array() && | ||
| 1628 | root["rounds"].size() == 1 && | ||
| 1629 | root["rounds"][0].value("id", "") == proof.id && | ||
| 1630 | root["rounds"][0]["shares"].value(miner.address, (uint64)0) == 3 && | ||
| 1631 | root["rounds"][0]["payouts"][miner.address].value("txid", "") == "selftest-txid"; | ||
| 1632 | } catch (...) { | ||
| 1633 | proofOk = false; | ||
| 1634 | } | ||
| 1635 | } | ||
| 1636 | check(proofOk, "pool round proof ledger is written and paid txid is recorded"); | ||
| 1637 | |||
| 1638 | remove(proofPath.c_str()); | ||
| 1639 | remove((proofPath + ".tmp").c_str()); | ||
| 1640 | strPoolRoundsFile = savedRoundsFile; | ||
| 1641 | gPoolRounds = savedRounds; | ||
| 1642 | |||
| 1336 | gCurrentJob = savedJob; | 1643 | gCurrentJob = savedJob; |
| 1337 | gHaveJob = savedHaveJob; | 1644 | gHaveJob = savedHaveJob; |
| 1338 | 1645 | ||
| @@ -1754,6 +2061,7 @@ void ThreadRPCServer(void*) | |||
| 1754 | } | 2061 | } |
| 1755 | 2062 | ||
| 1756 | LoadPendingPayouts(); | 2063 | LoadPendingPayouts(); |
| 2064 | LoadPoolRounds(); | ||
| 1757 | 2065 | ||
| 1758 | _beginthread(AcceptLoopFn, 0, NULL); | 2066 | _beginthread(AcceptLoopFn, 0, NULL); |
| 1759 | _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); | 2067 | _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); |