From 6ca1a1d6799474f8ca977b93ab630af7ace879c2 Mon Sep 17 00:00:00 2001 From: Victorio Nascimento <155357371+Vic-Nas@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:45:28 -0400 Subject: Add Linux GUI installer via Wine (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Linux GUI installer via Wine * Fix launcher creation and add BTF_RELAY env var support * Pool server, Linux GUI, unified binary, README update * Root Makefile: make linux / make windows, cleaner README * Remove headless.cpp * Fix XPM color count mismatch causing toolbar image errors on Linux * XPM icons: same code on Windows and Linux, no ifdefs around image loading * Fix GTK icon crash and Linux printf format * btfrv: 10s socket timeout on relay connects * Faster peer discovery: more dials per pass, shorter backoff and sleep * Parallel btf peer connections * Fix GetMyExternalIP - was disabled, now uses modern IP echo services * Fix tunnel disconnect on idle and restore external IP detection * Filter stale Nostr descriptors * Sort peers by freshness, fresh first * Pool: add .btf Stratum listener — miners connect via rendezvous, no port forwarding needed * btf pool: self-replacing listeners, one per relay, no queuing between miners * Mining modes: solo/operator/participant — pool server on-demand, participant Stratum client * Status bar: show external IP and .btf address * README: clean rewrite, concept-first, minimal * make linux produces AppImage * make linux produces AppImage * Pool dashboard: connect instructions, tabs, share bars * Perf fixes: GetMyExternalIP threaded, btf listener counted * Perf fixes: GetMyExternalIP threaded, btf listener counted * Fix pool payouts: defer until coinbase maturity * Pool: clean mode switching, IP warning * Pool: clean mode switching * Fix gPoolRunning: remove static * AppImage: use real icon from bitcoin.ico * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Windows build: use mingw32-make * Windows build: simpler make invocation * Add deps-windows target * Windows build fixes * Windows build fixes * Windows makefile: fix obj dir * Windows: output to repo root * Windows: bundle DLLs into zip * Replace wxWidgets with ImGui+GLFW: same build on Linux and Windows * Fix %I64d that slipped back through rebase (main.h, net.h) * MinGW: add _mkdir shim for non-MSVC Windows; fix %I64d * Fix gPoolRunning extern outside ifndef _WIN32 * Remove -lboost_system: boost is header-only in this codebase * Status bar: copy buttons for IP and .btf address * Windows: add app icon via windres, strip wx stuff from rc file * Clean GUI: single Options menu item, bigger font, better spacing, cleaner options dialog; update README * GUI: embed Roboto font at 16px for clear readability on all platforms * Windows: windres depends on obj dir target * Fix balance hidden off-screen: put on own line below address bar * Windows: inline mkdir in every rule, remove dir target that confused make * Windows: move all target to top so it's the default rule * Fix pool server not starting: always start when operator selected, re-sync dialog state on open * Write debug.log to data directory (AppData/Roaming/Bitflash on Windows) * Participant miner: use wallet address as username so payouts go to wallet * Add .gitattributes to lock LF line endings, prevent CRLF drift * Refactor pool mining to btf-only transport * Persist pool settings and startup wiring * Prune stale peers and expose pool queries * Fix build regressions and add CLI mode/arg parity * Fix rpc miner stats declaration order * Centralize pool runtime extern declarations * Separate operator/participant dashboards and restore peer stats * Add live telemetry strip and clearer per-mode runtime metrics * Reduce mining dashboard clutter and remove manual pool entry * Count only authorized pool miners * Seed initial pool job on startup * Use a real share target for pool work * Switch pool accounting to round-based shares * Fix nostr pool announcer stats call sites * Fix pool stats definition order * Improve pool worker status display * Add mutex include for participant status * Replace ImClamp in dashboard layout * Clarify participant work status * Log participant status transitions * Reduce peer discovery log noise * Add participant status badge * Track participant auth acknowledgements * Log and validate pool job delivery --------- Co-authored-by: Bitflash Pool Dev --- src/rpc.cpp | 708 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 708 insertions(+) create mode 100644 src/rpc.cpp (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp new file mode 100644 index 0000000..99d182e --- /dev/null +++ b/src/rpc.cpp @@ -0,0 +1,708 @@ +// Copyright (c) 2009 Satoshi Nakamoto / Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Bitflash pool server — .btf-only pool listener. +// Miners connect through rendezvous tunnels; no raw IP:port endpoints remain. +// Pending payouts are held until maturity and shown in the GUI. +// +// Compiles on both Windows (mingw) and Linux unchanged. +// Launched from ui.cpp (GUI) and headless.cpp (node) via ThreadRPCServer(). + +// nlohmann must be included before headers.h because util.h redefines snprintf +#pragma push_macro("snprintf") +#undef snprintf +#include +#pragma pop_macro("snprintf") + +#include "headers.h" +#ifdef snprintf +#undef snprintf +#endif +#include "btftunnel.h" + +// POSIX socket headers — already provided by compat.h on Windows, +// and by the OS directly on Linux. +#ifndef _WIN32 +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +// Cross-platform socket close / send flags +#ifdef _WIN32 +#define sock_close(s) closesocket(s) +#define SEND_FLAGS 0 +#else +#define sock_close(s) ::close(s) +#define SEND_FLAGS MSG_NOSIGNAL +#endif + +// Cross-platform thread launch: wraps _beginthread (compat.h maps it to +// pthread_create+detach on Linux, native _beginthread on Windows). +// fn must be void(*)(void*). +static void LaunchThread(void (*fn)(void*), void* arg) +{ + _beginthread(fn, 0, arg); +} + +// --------------------------------------------------------------------------- +// Config — set by headless.cpp / ui.cpp before threads launch +// --------------------------------------------------------------------------- +std::string gRpcUser; +std::string gRpcPassword; + +// Pool server running flag — set false to stop server cleanly (all loops check it) +volatile bool gPoolRunning = false; + +// --------------------------------------------------------------------------- +// Stubs for symbols absent from Bitflash 0.1.0 +// --------------------------------------------------------------------------- +double dHashesPerSec = 0.0; +int64 nHPSTimerStart = 0; + +static bool IsInitialBlockDownload() +{ + return (pindexBest == NULL || nBestHeight < 1); +} + +// --------------------------------------------------------------------------- +// Hex helpers +// --------------------------------------------------------------------------- +static std::string ToHex(const void* p, size_t n) +{ + const unsigned char* b = (const unsigned char*)p; + std::string s; s.reserve(n*2); + static const char* H = "0123456789abcdef"; + for (size_t i = 0; i < n; i++) { s += H[b[i]>>4]; s += H[b[i]&0xf]; } + return s; +} +static std::vector FromHex(const std::string& s) +{ + std::vector v; + for (size_t i = 0; i+1 < s.size(); i += 2) { + auto h=[](char c)->int{ + if(c>='0'&&c<='9') return c-'0'; + if(c>='a'&&c<='f') return c-'a'+10; + if(c>='A'&&c<='F') return c-'A'+10; + return 0; + }; + v.push_back((unsigned char)((h(s[i])<<4)|h(s[i+1]))); + } + return v; +} + +// --------------------------------------------------------------------------- +// PPLNS share tracking +// --------------------------------------------------------------------------- +struct Share { + std::string minerAddress; + int64 timestamp; + uint64 difficulty; +}; + +struct MinerStats { + std::string address; + std::string worker; + uint64 totalShares = 0; + uint64 roundShares = 0; + int64 lastSeen = 0; +}; +static std::mutex gMinerStatsMutex; +static std::map gMinerStats; + +struct FoundBlock { + int height; + int64 timestamp; + std::string minerAddress; + uint256 hash; +}; +static std::mutex gBlocksMutex; +static std::deque gFoundBlocks; + +struct Miner; +static std::mutex gMinersMutex; +static std::vector gMiners; + +static std::string MinerKey(const std::string& address, const std::string& worker) +{ + return address + "\n" + worker; +} + +void GetPoolWorkerStats(std::vector& out) +{ + out.clear(); + int64 now = GetTime(); + std::lock_guard lk(gMinerStatsMutex); + for (std::map::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) + { + const MinerStats& ms = it->second; + if (ms.lastSeen <= 0) + continue; + if (now - ms.lastSeen > 15 * 60) + continue; + PoolWorkerStatView view; + view.address = ms.address; + view.worker = ms.worker; + view.totalShares = ms.totalShares; + view.roundShares = ms.roundShares; + view.lastSeen = ms.lastSeen; + out.push_back(view); + } + std::sort(out.begin(), out.end(), [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { + if (a.roundShares != b.roundShares) return a.roundShares > b.roundShares; + if (a.totalShares != b.totalShares) return a.totalShares > b.totalShares; + return a.lastSeen > b.lastSeen; + }); +} + +// --------------------------------------------------------------------------- +// Current mining job +// --------------------------------------------------------------------------- +struct StratumJob { + std::string jobId; + CBlock block; + uint256 target; + uint256 shareTarget; + int height; +}; + +static std::mutex gJobMutex; +static StratumJob gCurrentJob; +static bool gHaveJob = false; +static std::atomic gJobSeq{0}; + +static uint256 MakeShareTarget(const uint256& blockTarget) +{ + uint256 shareTarget = blockTarget; + shareTarget <<= 8; + if (shareTarget < blockTarget) + shareTarget = ~uint256(0); + return shareTarget; +} + +static bool RebuildJob() +{ + if (!pindexBest) return false; + + CBlockIndex* pindexPrev = pindexBest; + unsigned int nBits = GetNextWorkRequired(pindexPrev); + + CKey key; + key.MakeNewKey(); + static std::atomic sExtra{0}; + uint32_t extraNonce = ++sExtra; + + CTransaction txNew; + txNew.vin.resize(1); + txNew.vin[0].prevout.SetNull(); + txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce; + txNew.vout.resize(1); + // Always pay coinbase to a fresh wallet key. DoPayouts() distributes + // shares to miners via SendMoney() after the block is accepted. + txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; + + CBlock block; + block.vtx.push_back(txNew); + + { + CTxDB txdb("r"); + CRITICAL_BLOCK(cs_mapTransactions) + { + map pool; + unsigned int sz = 0; + for (auto& kv : mapTransactions) { + CTransaction& tx = kv.second; + if (tx.IsCoinBase() || !tx.IsFinal()) continue; + int64 nFees = 0; + int64 nMinFee = tx.GetMinFee(block.vtx.size() < 100); + map tmp(pool); + if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, false, true, nMinFee)) continue; + pool = tmp; + block.vtx.push_back(tx); + sz += ::GetSerializeSize(tx, SER_NETWORK); + if (sz > MAX_SIZE/2) break; + } + } + } + block.vtx[0].vout[0].nValue = block.GetBlockValue(0); + block.hashPrevBlock = pindexPrev->GetBlockHash(); + block.hashMerkleRoot = block.BuildMerkleTree(); + block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast()+1), + (unsigned int)GetAdjustedTime()); + block.nBits = nBits; + block.nNonce = 1; + + AddKey(key); + + StratumJob job; + job.jobId = ToHex(&extraNonce, 4); + job.block = block; + job.target = CBigNum().SetCompact(nBits).getuint256(); + job.shareTarget = MakeShareTarget(job.target); + job.height = nBestHeight + 1; + + std::lock_guard lk(gJobMutex); + gCurrentJob = job; + gHaveJob = true; + ++gJobSeq; + return true; +} + +// --------------------------------------------------------------------------- +// PPLNS payout +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Deferred payouts — coinbase outputs require COINBASE_MATURITY (100) blocks +// before they can be spent. We store the share snapshot when a block is found +// and execute the actual SendMoney() calls once the coinbase matures. +// --------------------------------------------------------------------------- +struct PendingPayout { + int matureAtHeight; // pay out when nBestHeight >= this + std::map amounts; // addr -> BTF amount +}; + +static std::mutex gPayoutMutex; +static std::vector gPendingPayouts; + +void GetPendingPayouts(std::vector& out) +{ + out.clear(); + std::lock_guard lk(gPayoutMutex); + for (const PendingPayout& p : gPendingPayouts) + { + PendingPayoutView view; + view.matureAtHeight = p.matureAtHeight; + view.recipients = (int)p.amounts.size(); + view.totalAmount = 0; + for (const std::map::value_type& kv : p.amounts) + view.totalAmount += kv.second; + out.push_back(view); + } +} + +// Called when a block is found: snapshot shares, schedule payout for maturity. +static void QueuePayouts(int blockHeight) +{ + if (nMineMode != MINE_OPERATOR) return; + + std::map shareCount; + uint64 total = 0; + { + std::lock_guard lk(gMinerStatsMutex); + for (std::map::iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) + { + MinerStats& ms = it->second; + if (ms.roundShares == 0) + continue; + if (ms.address.empty()) + continue; + shareCount[ms.address] += ms.roundShares; + total += ms.roundShares; + ms.roundShares = 0; + } + } + if (total == 0) return; + + int64 blockReward = CBlock().GetBlockValue(0); + PendingPayout pp; + pp.matureAtHeight = blockHeight + COINBASE_MATURITY; + for (auto& kv : shareCount) { + int64 amount = (int64)(((double)kv.second / (double)total) * (double)blockReward); + if (amount >= CENT) + pp.amounts[kv.first] = amount; + } + + { std::lock_guard lk(gPayoutMutex); gPendingPayouts.push_back(pp); } + + printf("Pool: block %d payout queued for height %d (%zu miners)\n", + blockHeight, pp.matureAtHeight, pp.amounts.size()); +} + +// Called from JobWatcher every new block: execute any matured payouts. +static void FlushMaturePayouts() +{ + if (nMineMode != MINE_OPERATOR) return; + + std::lock_guard lk(gPayoutMutex); + for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { + if (nBestHeight < it->matureAtHeight) { ++it; continue; } + + printf("Pool: executing payout for block matured at height %d\n", it->matureAtHeight); + for (auto& kv : it->amounts) { + uint160 hash160; + if (!AddressToHash160(kv.first, hash160)) continue; + CScript scriptPubKey; + scriptPubKey << OP_DUP << OP_HASH160 << hash160 << OP_EQUALVERIFY << OP_CHECKSIG; + CWalletTx wtx; + wtx.mapValue["comment"] = strprintf("Pool payout height %d", it->matureAtHeight); + if (SendMoney(scriptPubKey, kv.second, wtx)) + printf("Pool: paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); + else + printf("Pool: payout FAILED for %s (will not retry)\n", kv.first.c_str()); + } + it = gPendingPayouts.erase(it); + } +} + +// --------------------------------------------------------------------------- +// Stratum protocol +// --------------------------------------------------------------------------- +static json MakeNotifyParams(const StratumJob& job, bool clean) +{ + CBlock& b = const_cast(job.block); + unsigned char hdr[80]; + memcpy(hdr, &b.nVersion, 4); + memcpy(hdr+4, &b.hashPrevBlock, 32); + memcpy(hdr+36, &b.hashMerkleRoot, 32); + memcpy(hdr+68, &b.nTime, 4); + memcpy(hdr+72, &b.nBits, 4); + memcpy(hdr+76, &b.nNonce, 4); + unsigned char shareTgt[32]; memcpy(shareTgt, &job.shareTarget, 32); + return json::array({job.jobId, ToHex(hdr,80), b.hashPrevBlock.GetHex(), ToHex(shareTgt,32), clean}); +} + +struct Miner { + SOCKET fd; + bool authorised; + std::string address; + std::string worker; + std::string readBuf; + int64 connectTime; + uint64 sessionShares; + Miner(SOCKET f) : fd(f), authorised(false), connectTime(GetTime()), sessionShares(0) {} +}; + +void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares) +{ + authorizedMiners = 0; + blocksFound = 0; + roundShares = 0; + { + std::lock_guard lk(gMinersMutex); + for (Miner* m : gMiners) + if (m->authorised) + authorizedMiners++; + } + { + std::lock_guard lk(gBlocksMutex); + blocksFound = (int)gFoundBlocks.size(); + } + { + std::lock_guard lk(gMinerStatsMutex); + for (std::map::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) + roundShares += it->second.roundShares; + } +} + +static bool SendLine(Miner* m, const json& j) +{ + std::string s = j.dump() + "\n"; + int off = 0; + while (off < (int)s.size()) { + int n = send(m->fd, s.c_str() + off, (int)s.size() - off, SEND_FLAGS); + if (n <= 0) + return false; + off += n; + } + return true; +} + +static void BroadcastJob(bool clean) +{ + StratumJob job; + { std::lock_guard lk(gJobMutex); if(!gHaveJob) return; job=gCurrentJob; } + json n = {{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,clean)}}; + std::lock_guard lk(gMinersMutex); + for (Miner* m : gMiners) + if (m->authorised && !SendLine(m, n)) + printf("Stratum: failed to broadcast job to %s\n", m->address.c_str()); +} + +static void HandleLine(Miner* m, const std::string& line) +{ + json req; + try { req = json::parse(line); } catch(...){ return; } + + json id = req.value("id", json(nullptr)); + std::string method = req.value("method",""); + + auto reply = [&](json result, json error=nullptr){ + if (!SendLine(m, json{{"id",id},{"result",result},{"error",error}})) + printf("Stratum: failed to reply to miner %s\n", m->address.c_str()); + }; + + if (method == "mining.subscribe") { + std::string sid = ToHex(&m->fd, 4); + printf("Stratum: miner subscribed %s\n", sid.c_str()); + reply(json::array({json::array({json::array({"mining.notify",sid})}),sid,4})); + return; + } + + if (method == "mining.authorize") { + auto& p = req["params"]; + m->address = (p.size()>0 && p[0].is_string()) ? p[0].get() : ""; + m->worker = (p.size()>1 && p[1].is_string()) ? p[1].get() : "worker"; + m->authorised = true; + printf("Stratum: miner authorized address=%s worker=%s\n", m->address.c_str(), m->worker.c_str()); + reply(true); + + uint160 h; bool valid = AddressToHash160(m->address, h); + if (!valid) printf("Stratum: miner %s invalid address '%s' — shares won't pay out\n", + m->worker.c_str(), m->address.c_str()); + + { std::lock_guard lk(gMinerStatsMutex); + auto& ms = gMinerStats[m->address]; + ms.lastSeen = GetTime(); ms.worker = m->worker; } + + if (!SendLine(m, json{{"id",nullptr},{"method","mining.set_difficulty"},{"params",json::array({1})}})) + printf("Stratum: failed to send difficulty to %s\n", m->address.c_str()); + + StratumJob job; bool have; + { std::lock_guard lk(gJobMutex); have=gHaveJob; if(have) job=gCurrentJob; } + if (have) { + printf("Stratum: sending job %s to %s\n", job.jobId.c_str(), m->address.c_str()); + if (!SendLine(m, json{{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,true)}})) + printf("Stratum: failed to send job %s to %s\n", job.jobId.c_str(), m->address.c_str()); + } else { + printf("Stratum: no job available for %s\n", m->address.c_str()); + } + return; + } + + if (method == "mining.extranonce.subscribe") { reply(true); return; } + + if (method == "mining.submit") { + if (!m->authorised) { reply(false,"not authorised"); return; } + + auto& p = req["params"]; + if (p.size() < 4) { reply(false,"bad params"); return; } + + std::string jobId = p[1].get(); + std::string nonceHex = p[2].get(); + + StratumJob job; + { std::lock_guard lk(gJobMutex); + if (!gHaveJob || gCurrentJob.jobId != jobId) { reply(false,"stale job"); return; } + job = gCurrentJob; } + + auto nb = FromHex(nonceHex); + if (nb.size()<4) { reply(false,"bad nonce"); return; } + unsigned int nNonce; memcpy(&nNonce, nb.data(), 4); + + CBlock& b = job.block; + b.nNonce = nNonce; + unsigned char hdr[80]; + memcpy(hdr, &b.nVersion, 4); + memcpy(hdr+4, &b.hashPrevBlock, 32); + memcpy(hdr+36, &b.hashMerkleRoot,32); + memcpy(hdr+68, &b.nTime, 4); + memcpy(hdr+72, &b.nBits, 4); + memcpy(hdr+76, &b.nNonce, 4); + + uint256 powHash = RandomXPoWHash(hdr, 80); + + // Record share in the current round. + std::string key = MinerKey(m->address, m->worker); + { std::lock_guard lk(gMinerStatsMutex); + MinerStats& ms = gMinerStats[key]; + ms.address = m->address; + ms.worker = m->worker; + ms.roundShares++; + ms.totalShares++; + ms.lastSeen = GetTime(); } + m->sessionShares++; + + if (powHash > job.shareTarget) { reply(true); return; } // valid share, not a full block + + // Full block + CBlock* pblock = new CBlock(b); + bool accepted = false; + CRITICAL_BLOCK(cs_main) + { + if (ProcessBlock(NULL, pblock)) { + accepted = true; + printf("Pool: BLOCK FOUND by %s height=%d nNonce=%u\n", + m->address.c_str(), job.height, nNonce); + FoundBlock fb{job.height, GetTime(), m->address, pblock->GetHash()}; + { std::lock_guard lk(gBlocksMutex); + gFoundBlocks.push_front(fb); + if (gFoundBlocks.size()>50) gFoundBlocks.pop_back(); } + { std::lock_guard lk(gJobMutex); gHaveJob=false; } + } else { + delete pblock; + } + } + reply(accepted); + if (accepted) { QueuePayouts(job.height); RebuildJob(); BroadcastJob(true); } + return; + } + + if (method == "mining.get_transactions") { reply(json::array()); return; } + reply(nullptr, "unknown method"); +} + +static void MinerThreadFn(void* arg) +{ + Miner* m = (Miner*)arg; + while (!fShutdown && gPoolRunning) { + fd_set fds; FD_ZERO(&fds); FD_SET(m->fd, &fds); + struct timeval tv={1,0}; + if (select((int)m->fd+1, &fds, NULL, NULL, &tv) <= 0) continue; + char buf[4096]; + int n = recv(m->fd, buf, sizeof(buf)-1, 0); + if (n <= 0) break; + buf[n]=0; m->readBuf += buf; + size_t pos; + while ((pos = m->readBuf.find('\n')) != std::string::npos) { + std::string line = m->readBuf.substr(0, pos); + m->readBuf = m->readBuf.substr(pos+1); + if (!line.empty() && line.back()=='\r') line.pop_back(); + if (!line.empty()) HandleLine(m, line); + } + } + sock_close(m->fd); + printf("Stratum: miner %s disconnected (%llu shares)\n", + m->worker.empty()?"unknown":m->worker.c_str(), + (unsigned long long)m->sessionShares); + { std::lock_guard lk(gMinersMutex); + auto it = std::find(gMiners.begin(), gMiners.end(), m); + if (it != gMiners.end()) gMiners.erase(it); } + delete m; +} + +static void JobWatcherFn(void*) +{ + int lastHeight = -1; + while (!fShutdown && gPoolRunning) { + Sleep(500); + bool newBlock = false; + CRITICAL_BLOCK(cs_main) + { if (nBestHeight != lastHeight && pindexBest) { lastHeight = nBestHeight; newBlock = true; } } + if (newBlock) { + FlushMaturePayouts(); + if (RebuildJob()) { printf("Stratum: new job height=%d\n", lastHeight+1); BroadcastJob(true); } + } else if (!gHaveJob) { + if (RebuildJob()) BroadcastJob(false); + } + } +} + +// --------------------------------------------------------------------------- +// Main Stratum accept loop — entry point called from ui.cpp and headless.cpp +// --------------------------------------------------------------------------- +// .btf Stratum listener — miners connect via rendezvous relay using the +// node's .btf address instead of a raw IP:port. Same Stratum protocol, +// same MinerThreadFn handler, works through CGNAT with no port forwarding. +// +// Keeps one listener registered per relay at all times. When a miner +// connects, a replacement listener is spawned immediately so the next +// miner doesn't have to wait. +// --------------------------------------------------------------------------- + +struct BtfAcceptCtx { unsigned char pk[32]; unsigned char sk[32]; string relay; }; + +// Track active listener count per relay to avoid pile-up when relay is slow +static std::map gBtfListenerCount; +static std::mutex gBtfListenerMutex; + +static void BtfStratumAcceptOneFn(void* arg) +{ + BtfAcceptCtx* ctx = (BtfAcceptCtx*)arg; + unsigned char pk[32], sk[32]; + memcpy(pk, ctx->pk, 32); memcpy(sk, ctx->sk, 32); + string relay = ctx->relay; + delete ctx; + + auto decrement = [&]() { + std::lock_guard lk(gBtfListenerMutex); + gBtfListenerCount[relay]--; + }; + + size_t colon = relay.rfind(':'); + if (colon == string::npos) { decrement(); return; } + string strHost = relay.substr(0, colon); + int nPort = atoi(relay.substr(colon+1).c_str()); + + btf::RvSocket rv = btf::RvServiceRegister(strHost.c_str(), (unsigned short)nPort, pk); + if (fShutdown || !gPoolRunning) { if (rv != btf::RV_INVALID) btf::RvClose(rv); decrement(); return; } + if (rv == btf::RV_INVALID) { decrement(); return; } + + // Paired: spawn replacement (count transfers to new thread) + if (!fShutdown && gPoolRunning) { + BtfAcceptCtx* next = new BtfAcceptCtx(); + memcpy(next->pk, pk, 32); memcpy(next->sk, sk, 32); + next->relay = relay; + LaunchThread(BtfStratumAcceptOneFn, next); + } else { + decrement(); + } + + btf_socket_t hSocket = btf::BtfServiceWrap(rv, sk); + if (hSocket == INVALID_SOCKET) return; + + printf("Stratum/.btf: miner connected via %s\n", relay.c_str()); + Miner* m = new Miner(hSocket); + { std::lock_guard lk(gMinersMutex); gMiners.push_back(m); } + LaunchThread(MinerThreadFn, m); +} + +static void BtfStratumAcceptFn(void*) +{ + printf("Stratum/.btf: starting \xe2\x80\x94 miners can connect via %s\n", + BtfLocalAddress().c_str()); + + unsigned char pk[32], sk[32]; + while (!BtfGetIdentity(pk, sk)) { + if (fShutdown || !gPoolRunning) return; + Sleep(2000); + } + + while (!fShutdown && gPoolRunning) { + vector relays = BtfAllRelays(); + if (relays.empty()) { Sleep(5000); continue; } + + // Spawn one listener per relay, only if none already active + for (const string& relay : relays) { + std::lock_guard lk(gBtfListenerMutex); + if (gBtfListenerCount[relay] == 0) { + gBtfListenerCount[relay]++; + BtfAcceptCtx* ctx = new BtfAcceptCtx(); + memcpy(ctx->pk, pk, 32); memcpy(ctx->sk, sk, 32); + ctx->relay = relay; + LaunchThread(BtfStratumAcceptOneFn, ctx); + } + } + for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000); + } +} +void ThreadRPCServer(void*) +{ + gPoolRunning = true; + printf("Bitflash pool server starting\n"); + printf(" Stratum .btf: %s (no port forwarding needed)\n", + BtfLocalAddress().c_str()); + printf(" Mode: operator\n"); + + if (RebuildJob()) + printf("Stratum: initial job seeded height=%d\n", nBestHeight + 1); + + LaunchThread(JobWatcherFn, NULL); + LaunchThread(BtfStratumAcceptFn, NULL); + LaunchThread(ThreadBtfPoolAnnouncer, NULL); + + while (!fShutdown && gPoolRunning) + Sleep(1000); + printf("Pool server stopped\n"); +} -- cgit v1.2.3 From 663784eae9b04ec71e3e1eebf445910e5a8add42 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 28 Jul 2026 12:55:04 -0300 Subject: Merge PR #6 (large refactor), preserving main's fixes (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Linux GUI installer via Wine * Fix launcher creation and add BTF_RELAY env var support * Pool server, Linux GUI, unified binary, README update * Root Makefile: make linux / make windows, cleaner README * Remove headless.cpp * Fix XPM color count mismatch causing toolbar image errors on Linux * XPM icons: same code on Windows and Linux, no ifdefs around image loading * Fix GTK icon crash and Linux printf format * btfrv: 10s socket timeout on relay connects * Faster peer discovery: more dials per pass, shorter backoff and sleep * Parallel btf peer connections * Fix GetMyExternalIP - was disabled, now uses modern IP echo services * Fix tunnel disconnect on idle and restore external IP detection * Filter stale Nostr descriptors * Sort peers by freshness, fresh first * Pool: add .btf Stratum listener — miners connect via rendezvous, no port forwarding needed * btf pool: self-replacing listeners, one per relay, no queuing between miners * Mining modes: solo/operator/participant — pool server on-demand, participant Stratum client * Status bar: show external IP and .btf address * README: clean rewrite, concept-first, minimal * make linux produces AppImage * make linux produces AppImage * Pool dashboard: connect instructions, tabs, share bars * Perf fixes: GetMyExternalIP threaded, btf listener counted * Perf fixes: GetMyExternalIP threaded, btf listener counted * Fix pool payouts: defer until coinbase maturity * Pool: clean mode switching, IP warning * Pool: clean mode switching * Fix gPoolRunning: remove static * AppImage: use real icon from bitcoin.ico * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Fix exit crash; Mining menu * Windows build: use mingw32-make * Windows build: simpler make invocation * Add deps-windows target * Windows build fixes * Windows build fixes * Windows makefile: fix obj dir * Windows: output to repo root * Windows: bundle DLLs into zip * Replace wxWidgets with ImGui+GLFW: same build on Linux and Windows * Fix %I64d that slipped back through rebase (main.h, net.h) * MinGW: add _mkdir shim for non-MSVC Windows; fix %I64d * Fix gPoolRunning extern outside ifndef _WIN32 * Remove -lboost_system: boost is header-only in this codebase * Status bar: copy buttons for IP and .btf address * Windows: add app icon via windres, strip wx stuff from rc file * Clean GUI: single Options menu item, bigger font, better spacing, cleaner options dialog; update README * GUI: embed Roboto font at 16px for clear readability on all platforms * Windows: windres depends on obj dir target * Fix balance hidden off-screen: put on own line below address bar * Windows: inline mkdir in every rule, remove dir target that confused make * Windows: move all target to top so it's the default rule * Fix pool server not starting: always start when operator selected, re-sync dialog state on open * Write debug.log to data directory (AppData/Roaming/Bitflash on Windows) * Participant miner: use wallet address as username so payouts go to wallet * Add .gitattributes to lock LF line endings, prevent CRLF drift * Refactor pool mining to btf-only transport * Persist pool settings and startup wiring * Prune stale peers and expose pool queries * Fix build regressions and add CLI mode/arg parity * Fix rpc miner stats declaration order * Centralize pool runtime extern declarations * Separate operator/participant dashboards and restore peer stats * Add live telemetry strip and clearer per-mode runtime metrics * Reduce mining dashboard clutter and remove manual pool entry * Count only authorized pool miners * Seed initial pool job on startup * Use a real share target for pool work * Switch pool accounting to round-based shares * Fix nostr pool announcer stats call sites * Fix pool stats definition order * Improve pool worker status display * Add mutex include for participant status * Replace ImClamp in dashboard layout * Clarify participant work status * Log participant status transitions * Reduce peer discovery log noise * Add participant status badge * Track participant auth acknowledgements * Log and validate pool job delivery * Stop persisting mining mode/pool settings across restarts LoadWallet() runs after CLI flags are parsed in main_gui.cpp, and was restoring nMineMode/strParticipantPool/strPoolName/strPoolDashboardUrl/ dPoolFeePercent/fGenerateBitcoins from wallet.dat every time, silently overwriting whatever was just set on the command line or in the current Options session -- no error, no log line. This is why pool name/address could show correctly in the UI but stay stale in the logs (UI held the in-memory value just set; logs eventually reflected whatever LoadWallet clobbered it back to), and why mode changed behavior across restarts. Mode/pool config is now decided fresh every launch: CLI flags if given, otherwise the compiled-in default. Nothing is written back to wallet.dat for these settings anymore, so there's nothing left to go stale or fight over load order. * Add explicit Relay mode as the new startup default 4th mining mode: node runs/syncs, never mines, no pool server -- an explicit option instead of an implicit side-effect of 'Solo + never click Start Mining'. Now the compiled-in default (was Solo). - main.h: MINE_RELAY = 0, renumbered other modes (nothing compares nMineMode against raw integers elsewhere, verified). - main.cpp: default nMineMode = MINE_RELAY; BitcoinMiner() early-returns for Relay, mirroring the existing Participant-mode guard. - gui.cpp: added Relay radio button, status label, and telemetry-strip text. Start/Stop Mining button is hidden for Relay (meaningless -- it never mines by definition); OK forces fGenerateBitcoins off if you switch into Relay while previously mining. * Announce pool changes immediately instead of waiting up to 60s ThreadBtfPoolAnnouncer re-read strPoolName/dPoolFeePercent/etc live, but only once per iteration of its 60s sleep loop, so a pool name/fee change in Options could take up to a minute to reach other nodes. - nostr.h/.cpp: add gAnnounceNow flag; the announcer's sleep loop now wakes early and publishes immediately when it's set, then clears it. - gui.cpp: Options OK handler sets gAnnounceNow when operator pool settings actually changed (name/dashboard/fee/mode), so a saved change is announced right away instead of on the next tick. * Give each pool miner a distinct extranonce2 so jobs no longer overlap mining.notify previously broadcast one identical 80-byte header (baked from a single server-side extraNonce) to every connected miner, so any two miners on the same job searched the exact same nNonce space instead of disjoint ranges. Each Miner connection is now assigned a unique extranonce2 on connect. BuildMinerBlock() takes the shared job template and appends that miner's extranonce2 to the coinbase scriptSig, recomputing the merkle root, so every miner gets its own header variant of the same job. mining.submit validates and, on a full block, assembles the block using that same miner-specific variant (via m->extranonce2), not the shared template. No client-side protocol change needed: main.cpp's participant miner already just takes whatever header bytes mining.notify hands it and submits (jobId, nonce) back, so it works unmodified against per-miner headers. Still simplified vs standard Stratum (no coinb1/coinb2/merkle-branch wire split, no mining.extranonce.subscribe reply payload) since this protocol was already a custom simplification pre-existing this fix. Syntax-checked with g++ -fsyntax-only (full link needs secp256k1/ RandomX/GLFW not present in this sandbox). * Fix debug.log never being written on the real Windows build printf-to-debug.log routing was gated on: #if defined(_WINDOWS) || !defined(_WIN32) _WINDOWS is only ever defined by the legacy, unused MSVC makefile.vc. The actual Windows build (makefile.mingw) never defines it, and _WIN32 IS defined there (mingw builtin), so on real Windows this condition was always false || false = false. printf() fell through to plain libc printf (stdout only), so debug.log was never created, and output was only visible if the process happened to be run attached to a console. OutputDebugStringF() was already platform-safe internally (always writes debug.log; only echoes to console when not on Windows), so the outer condition was pure dead weight. Made the routing unconditional. * Reduce base UI font size one more step (16px -> 14px) * Remove Start/Stop Mining button; mining state follows selected mode The button let a user configure a pool then click a separate toggle, creating states where the button's label/action didn't match what the selected mode actually needed. OK now starts/stops the miner thread itself based on nMineMode: Relay clears fGenerateBitcoins, the other three modes set it and spawn ThreadBitcoinMiner if it isn't already running (guarded by vfThreadRunning[3]). * Fix participant miner stuck waiting for notify after buffered multi-line recv Root cause of the operator/participant pairing successfully but no work ever flowing: PoolParticipantMiner()'s receive loop only pulled one line out of the socket per select() wakeup. The server sends the authorize reply, mining.set_difficulty, and mining.notify back-to-back on the same connection, and these often land in a single TCP segment — one recv() inside StratumRecvLine() buffers all three lines into 'buf', but only the first was extracted and processed. The remaining lines (frequently mining.notify itself) sat in 'buf' indefinitely, because select() only reports readability of the OS socket, not whether our own buffer still holds unprocessed complete lines — and the server had nothing further to send once the initial burst was over, so select() never fired again. Fix: drain any complete line already sitting in 'buf' first; only fall back to select()+recv() when 'buf' has no complete line left. * Fix mining.submit rejecting every share due to wrong param-count check Server required params.size() >= 4 for mining.submit, but the client only ever sends 3: [username, jobId, nonceHex] (indices 0-2, matching what the handler actually reads: p[1]=jobId, p[2]=nonceHex). Every submission was bounced with 'bad params' before its nonce was ever checked against the job, producing an endless share-sent/share-rejected loop with no other visible cause. Likely a leftover from the standard 5-field Stratum mining.submit shape, which this simplified 3-field protocol never matched. Threshold corrected to size() >= 3. * Fix mining.submit rejecting legitimate shares that weren't also winning blocks Pool share difficulty is intentionally ~256x easier than full network difficulty (MakeShareTarget() shifts the block target left 8 bits), so miners are expected to submit many shares per actual block found. But the handler replied to the miner based on ProcessBlock()/CheckBlock() success — validation against the much harder network target — rather than on whether the share met pool difficulty. Every legitimate share that wasn't also a winning block (the vast majority, by design) failed CheckBlock()'s nBits check and got reported to the miner as rejected. Now: reply(true) as soon as a submission clears pool-share difficulty. Only if it ALSO clears full network difficulty (job.target, not job.shareTarget) does it get submitted as a block — with no second reply sent, since the share was already acknowledged. * Fix dead-peer dial priority: freshness sort was undone by full shuffle ConnectDiscoveredBtfPeers sorted candidates newest-first, then immediately shuffled the whole list, making the freshness sort a no-op — every pass was fully random. Meanwhile g_btfPeerFails was tracked but never used to prioritize, so a peer that had failed 20 times in a row got equal footing with an untried or previously-successful peer; the only effect was backoff, capped at 15 minutes, so dead descriptors cycled back into the dial pool constantly. - Track successful connections (g_btfPeerEverConnected) and use it, plus fail history, to order candidates in tiers: known-good, then untried, then known-bad (worst offenders last). Freshness order is preserved within each tier instead of being discarded. - Raise the backoff cap for chronic failures (6+ in a row) from 15 minutes to 6 hours, so descriptors that are clearly dead stop consuming dial budget every pass while still getting retried eventually. * Batch-resolve .btf descriptors to stop hammering public relays during dial passes Root cause of 'stuck at 1 peer' despite good peer discovery: each parallel dial thread in ConnectDiscoveredBtfPeers called ConnectNodeBtf, which itself walked all configured relays (relay.damus.io, nos.lol, relay.nostr.band, relay.snort.social) to resolve that one peer's descriptor. With up to BTF_DIALS_PER_PASS (12) threads dialing at once, a single pass could fire up to 12 * nNostrRelays near-simultaneous fresh connections at the same handful of public relays -- easily enough to trip rate limits or get handshakes refused (exactly what the logs showed: 'handshake refused by relay.damus.io', repeated connect failures to relay.nostr.band / relay.snort.social). Peers that were genuinely reachable looked dead simply because their descriptor couldn't be fetched under that load, and then got penalized as known-bad by the dial-priority tiering from the previous fix. - Add BtfResolveMany: resolves every still-unresolved candidate's descriptor in a single Nostr REQ per relay (multiple authors in one filter), instead of one relay connection per candidate. A 12-candidate pass now costs at most a few relay round-trips total instead of dozens. - Add ConnectNodeBtfResolved / ConnectNodeBtfTail (net.cpp): given an already-resolved descriptor, dial straight to the peer's rendezvous node with zero further relay contact. ConnectDiscoveredBtfPeers now batch- resolves all candidates up front, then fans out tunnel-only connect threads in parallel -- no thundering herd against the relays. - Candidates that fail to resolve are recorded as a failure immediately (same backoff/fail-count bookkeeping as before) instead of spawning a thread with nothing to dial. * Stop reconnecting to relays constantly: pool one persistent connection per relay Real root cause of the ongoing 'handshake refused' / 'connect failed' spam and peer count stuck at 0-1: every relay-touching operation (startup self- test, per-pass seeding, batch descriptor resolve, pool announce, pool query) opened its own brand-new CWebSocket every single time it ran. Two independent threads (ThreadNostrSeed and ThreadBtfPoolAnnouncer) each reconnect from scratch on their own ~60s cycle, so a node could fire well over a dozen fresh TCP+TLS+WS handshakes at the same 4 public relays inside any given minute. Public relays -- relay.damus.io especially -- rate-limit new connections per source IP fairly aggressively, so this node was effectively DoS-ing itself: peers were genuinely reachable, but their descriptors couldn't be fetched because the relay had already started refusing this node's own repeated reconnects. - Add a small per-relay connection pool (g_relayPool) and an RAII CRelayLease that reuses a relay's live socket across calls instead of reconnecting every time, only reconnecting when a socket actually drops or was never established. - Route every relay call site through it: the startup self-test, the self-test loop, SeedFromRelay, BtfResolve, BtfResolveMany, BtfPublishPoolAnnouncement, BtfQueryPoolAnnouncement. - Only log 'connected to ' on an actual fresh (re)connect, not on every pass that's just reusing an already-live socket, so the log reflects what's really happening instead of implying constant churn. This cuts fresh relay connections from several per minute down to essentially one per relay for the life of the process (until a relay actually drops the connection), which should make the rate-limit-driven refusals go away and let descriptor resolution -- and therefore dialing -- actually succeed at the rate the peer list implies it should. * Fix wallet crash on rejected send; remove legacy IP-based peer discovery - SendMoney() no longer throws when a committed tx is rejected by AcceptTransaction() (e.g. raced input already spent). It now unwinds the wallet state via RollbackTransactionSpent() and returns false, which the GUI already handles as 'Transaction failed.' - Removed the dead IP-based peer discovery/connect path now that discovery is entirely Nostr/.btf: CAddrDB/addr.dat, mapAddresses, AddAddress, ConnectNode(CAddress), FindNode(CAddress), ConnectSocket (SOCKS4 proxy helper), addrProxy, ThreadOpenConnections/2 class-C dialer and its thread spawn, the addr/getaddr P2P handlers, and the dead vAddrToSend/setAddrKnown fields on CNode. - Deleted irc.cpp/irc.h (already-disabled legacy seed code) and scrubbed references from headers_core.h and all four makefiles. * Raise peer connection target and add dial diversity; fix IPv4-only relay connect - BTF_TARGET_CONN: 8 -> 24. With ~100 known .btf peers, average degree needs only to exceed ln(N)~=4.6 for the network to stay connected with overwhelming probability (Erdos-Renyi); 24 is a healthy margin. - ConnectDiscoveredBtfPeers now splits each pass's dial budget ~70/30 between the existing reliability-tiered order and a random sample from the full fresh peer pool. Pure reliability bias means every node converges on the same 'known good' hub set, which correlates peer sets across the network -- a relay outage then drops the same edges everywhere at once instead of independent ones. The random slice keeps a floor of uncorrelated edges. - CWebSocket::Connect (relay websocket) now resolves with AF_UNSPEC and tries every returned address (IPv6 or IPv4) in turn instead of hard-failing on AF_INET-only resolution, which was one plausible cause of the relay.nostr.band:443 connect failures (CDN-fronted relays increasingly rate-limit/filter the v4 path). * Remove accidentally committed build binary; ignore src/bitflash * Fix pool participants getting stuck after the first job JobWatcherFn only rebuilt/broadcast a new Stratum job when a new block arrived on this node or no job existed yet. Once a job was built after startup, gHaveJob stayed true forever unless *this* node found the block, so with no new block (slow interval, or this node just not seeing one) every connected miner sat hashing the exact same job -- stale timestamp, stale mempool snapshot -- indefinitely. From the miner's side that's indistinguishable from being stuck: it worked at connect (gets the one-time job sent on authorize) and then nothing ever arrives again. Added a 60s periodic refresh so the job (and therefore mining.notify) gets rebroadcast on a timer too, same as a normal stratum pool, not only on new-block/no-job events. * Diversify Nostr discovery relay list Went from 4 relays to 7, and reduced Cloudflare concentration: previously 2 of 4 (damus.io, snort.social) sat behind the same CDN, so a Cloudflare- side issue could take out half the list even though they're nominally independent relays. Kept damus.io (most established) as the one CF-fronted entry and filled the rest with different hosts (nos.lol, nostr.band on Hetzner, nostr21.com, offchain.pub, nostr.mom, primal.net) so peer discovery doesn't have a single shared upstream as a point of failure. 7 relays is within the commonly recommended 5-10 range for redundancy. * Fix data race in RebuildJob: chain-state reads weren't holding cs_main pindexBest, nBestHeight, GetNextWorkRequired() and GetMedianTimePast() were all read without cs_main. A block being accepted concurrently by the P2P thread could interleave with this: pindexPrev captured from the old best block, but nBestHeight already bumped to the new one by the time job.height is computed, or pindexBest reassigned mid-read. That mismatch would make QueuePayouts() schedule a payout for the wrong maturity height for whatever block gets found against that job. Wrapped the whole chain-state snapshot in one CRITICAL_BLOCK(cs_main), consistent with how the rest of the codebase touches this state. * add operator fee * Add categorized, burst-collapsing logging for pool/worker/payout flow debug.log was growing unboundedly because every printf() call wrote unconditionally, so loops that log once per item (discovered peers, connect attempts) produced a near-identical line per address with no cap. Add LogPrint(category, ...): a drop-in printf replacement that tags each call with a subsystem (pool/worker/payout/net/nostr) and collapses repeated calls from the same call site into one line per 3s window plus a '(xN similar in 3s)' summary, so log growth is bounded by elapsed time instead of event count. Wire it through the whole pool -> work -> payout pipeline (rpc.cpp operator side, main.cpp participant/worker side, net.cpp/nostr.cpp peer & relay discovery), and along the way fix several previously *silent* failure points that produced exactly the reported symptom (worker stuck asking for work, nothing logged): - Relay listener registration failing: no log at all, so a pool with every relay down looked identical to a healthy idle pool. - No relays configured: silent infinite 5s retry loop. - Initial job seed failing at pool startup: silent. - JobWatcher: added a stuck-job watchdog that, after 15s with no job available, logs a specific diagnosis (no chain tip yet vs. RebuildJob() failing) instead of retrying forever in silence. - QueuePayouts(): a block found with zero recorded shares queued no payout and logged nothing. - FlushMaturePayouts(): an unparseable payout address was skipped with no log. - Participant miner: the 'waiting for pool notify' stall was only ever shown as a transient GUI status string, never written to debug.log. Now logs on a 15s cadence while stalled, and after 120s proactively drops the tunnel so the existing reconnect-in-10s loop gets a fresh attempt instead of hanging indefinitely. - Participant miner: share rejections were only a transient GUI status string; now logged with the pool's rejection reason. All edited files verified with g++ -fsyntax-only (gnu++14). * Fix two bugs causing worker stuck + log bloat 1. Worker stuck asking for work (main.cpp): The participant miner checked msg["result"].is_boolean() to detect the mining.subscribe acknowledgement (id==1), but the pool sends an array ([[["mining.notify",sid],sid,4]]) as per Stratum spec, not a boolean. is_boolean() always returned false, so gotSubscribeAck was never set true. This didn't directly block authorize (id==2 path is independent), but it did mean the status never advanced through "subscribed", leaving the progress logging misleading. More importantly, the same wrong assumption meant any pool that also gated job dispatch on a prior subscribe confirmation would leave the worker stuck forever with no diagnosis. Fix: accept any non-null result for id==1 (the array is the session/extranonce info; its contents don't matter for our use case). 2. Log bloat from ProcessMessages (main.cpp): ProcessMessages() used a bare printf() which fired unconditionally for every P2P message received -- the bulk of debug.log was this one line repeating thousands of times. Convert to LogPrint("net", ...) so it is gated behind fDebug (off by default) and burst-collapsed by the dedup introduced in the previous commit. * Fix log bloat, em-dashes, dead code, status bar, Connected/Detected peers Log bloat: - Gate all per-message/per-block/per-loop printf in main.cpp, net.h, net.cpp behind LogAcceptsCategory("net") -- only visible with -debug. This includes: received:/sending: hex dumps, got inventory, askfor, getblocks, getdata, received block, AddToWallet, RelayWalletTransactions, ProcessMessage FAILED, mining loop N-transactions line, retarget block, socket recv/send errors. AddToBlockIndex new best and ProcessBlock ACCEPTED intentionally left always-on (one line per block). - PoolParticipantMiner: status -> now LogPrint(worker) not printf. Em-dashes: - Replace all UTF-8 em-dashes (0xE2 0x80 0x94) with ASCII '--' in every source file (rpc.cpp, gui.cpp, main.cpp, main_gui.cpp, net.cpp, nostr.cpp, util.cpp, btfrv.cpp, db.cpp, db.h, main.h, headers.h, headers_core.h, util.h). 40 occurrences across 14 files. Dead code: - Remove unused gJobSeq atomic from rpc.cpp (written, never read). - Fix minerReward: was computed but payout loop used blockReward directly (operator fee was advertised but never deducted). Now payout correctly uses minerReward = blockReward * (1 - feeFrac). Status bar / peers: - Add GetDiscoveredPeerCount() to nostr.cpp/nostr.h (reads g_btfPeers size under cs_btfPeers). - Replace scrollable inline status text with DrawStatusBar(): a separate ImGui overlay window pinned to bottom of screen (NoScrollbar, NoBringToDisplayFront). Labels changed from 'Peers N' to 'Connected peers: N Detected peers: N'. * Rewrite gui.cpp: proper fixed status bar, scrollable content child Layout architecture: - DrawMainWindow() covers the full display width, height minus STATUS_BAR_H (22px). NoScrollbar + NoScrollWithMouse on the outer window so the chrome (menu, address/balance strip, telemetry strip, separators) never scrolls. - A BeginChild("##scroll") fills the remaining height after the fixed chrome, sized to GetContentRegionAvail().y -- which is already the space between the cursor and the bottom of the outer window. All mode-specific content (transaction table, participant pool list, operator workers/payouts) lives inside this child. Content that overflows gets a scrollbar; nothing bleeds past the outer window edge. - DrawStatusBar() is a separate overlay window pinned to the bottom 22px, drawn last so it is always on top. NoBringToDisplayFront is NOT used here -- drawing last is sufficient and avoids interaction issues with dialogs. - Status bar background is fully opaque (not alpha-blended) so it never shows content behind it. Labels and content: - 'Peers N' replaced with 'Connected peers: N Detected peers: N' using GetDiscoveredPeerCount(). - Mining indicator: '* Mode' instead of the 'filled circle' Unicode glyph that renders as boxes on some Linux font configs. - FmtAge used consistently; 'blocks deep' shortened to 'N deep' in tx list. - Options dialog: em-dashes removed from RadioButton labels (ASCII '--'). - Removed usedY dead variable (GetCursorPosY() result was unused). * Fix blocking bugs, dead code, pool/participant flow correctness Bugs fixed: StratumRecvLine blocking (main.cpp): The participant miner's recv loop called recv() with no timeout after select() said the socket was readable. If the server sent a partial line (split across two TCP segments), the second recv() would block indefinitely waiting for the rest -- the classic 'stuck asking for work' hang even though the pool was alive and had sent data. Fix: single non-blocking recv() pass (MSG_DONTWAIT on Linux, FIONREAD+recv on Windows). If no complete line is in the buffer yet, return false; the outer select() loop retries when more data arrives. BroadcastJob holding mutex during send() (rpc.cpp): BroadcastJob() held gMinersMutex across all SendLine() calls. send() can block if a slow miner's TCP receive buffer is full. With the mutex held, MinerThreadFn could not acquire it to remove a disconnecting miner, causing a deadlock-adjacent stall: broadcast blocked on slow miner, disconnect blocked on broadcast, new jobs not delivered. Fix: snapshot {fd, authorised, address, extranonce2} under the mutex, release it, then send to each fd outside the lock. Dead code removed (rpc.cpp): - gRpcUser, gRpcPassword: defined, never read or set by anything. - dHashesPerSec, nHPSTimerStart: leftover Bitcoin 0.1 stubs, unused. - IsInitialBlockDownload(): stub that returned pindexBest==NULL, never called anywhere in this codebase. - struct Share: declared with three fields, never instantiated. - Duplicate comment block before payout section. Other cleanup (main.cpp): - STUCK_NO_JOB_TIMEOUT_SECS moved from inside the loop body to file scope (was 'static const' inside a loop -- correct but confusing). - pblock->print() in solo miner gated behind LogAcceptsCategory("net") -- was unconditionally dumping a full block to stdout on every solve. - ERROR in BitcoinMiner printf converted to LogPrint("net"). * Pool/participant: fix recv blocking, BroadcastJob mutex, listener spawn, escaped em-dash main.cpp -- StratumRecvLine rewrite: The previous version called recv() in a while loop -- if select() said the socket was readable but only a partial line had arrived, the second recv() call would block indefinitely waiting for the rest of the line. This was the root cause of the recurring stuck-worker bug. Rewrite: single non-blocking recv (MSG_DONTWAIT on Linux, FIONREAD+recv on Windows). If no complete newline is in the buffer yet, return false immediately; the outer 50ms select() loop retries when more data arrives. This means the hash loop always stays responsive regardless of network fragmentation. rpc.cpp -- BroadcastJob mutex held across send(): BroadcastJob held gMinersMutex for the entire duration of sending jobs to all connected miners. send() can block if a slow miner's TCP buffer is full. With the mutex held, MinerThreadFn could not acquire it to clean up a disconnecting miner, stalling the whole broadcast. Fix: snapshot {fd, authorised, address, extranonce2} under the mutex, release it, then send to each fd without holding any lock. rpc.cpp -- LaunchThread failure not handled in BtfStratumAcceptOneFn: The replacement listener thread was launched via LaunchThread() which wraps _beginthread() but discarded the return value. If _beginthread fails (thread limit, OOM), the listener count stayed at 1 with no thread behind it -- that relay would get no new listener until the 30s outer loop in BtfStratumAcceptFn fired. Fix: call _beginthread() directly, check the return value, decrement and log on failure. rpc.cpp -- escaped em-dash in log string: BtfStratumAcceptFn had a literal \xe2\x80\x94 in a LogPrint format string that survived the em-dash sweep (it was source-encoded, not a UTF-8 byte in the file). Replaced with '--'. * Rewrite pool server: single event loop, no per-miner threads Replace the old architecture (one thread per connected miner + separate JobWatcher thread + BtfStratumAcceptFn thread with listener count tracking) with a clean two-layer design: Accept layer (one thread per relay, AcceptOneFn): Each relay gets exactly one persistent thread. It loops: calls blocking RvServiceRegister (waits for a miner to knock), then BtfServiceWrap (echan handshake), then pushes the finished plain socket to a small lock-protected queue, then loops immediately to accept the next miner. No listener count tracking. No replacement thread spawning. No races around spawn failure. The relay always has exactly one pending accept. Event loop (PoolEventLoop, runs on the ThreadRPCServer thread): Single thread owns all connected miner sockets. Each tick: 1. Drain the accept queue -- pick up any newly-connected miners. 2. Check for a new block -- rebuild job and broadcast if so. 3. No-job watchdog -- log and retry if job is missing too long. 4. Periodic job refresh -- restamp nTime every 60s. 5. select() over all miner fds with 50ms timeout. 6. For each readable fd: non-blocking RecvLine, drain all complete lines, dispatch each to HandleLine, collect dead fds. 7. Refresh GUI stats snapshot every 2s. Because the event loop is the only writer to gMiners, no mutex is needed on the miner list. The only mutexes remaining are: - gStatsMutex: guards the GUI-visible snapshot (StatsSnapshot, gWorkerStats, gPayoutViews) written by the event loop and read by GUI threads via GetPoolOperatorStats/GetPoolWorkerStats/GetPendingPayouts. - gReadyMutex: guards the accept queue between AcceptOneFn threads and the event loop. Other improvements in this rewrite: - gPendingPayouts is event-loop-only (no mutex needed). - Round share tracking lives in the event loop (roundShareCount map + roundShareTotal counter) -- zeroed on block found, no per-miner stat mutex needed mid-round. - QueuePayouts takes the share snapshot as a parameter instead of reaching into gMinerStats under a separate mutex. - BroadcastJob no longer snapshots miners to avoid a mutex -- it runs directly in the event loop with exclusive access to gMiners. - AcceptOneFn backs off 30s on relay registration failure instead of returning -- one relay going down doesn't kill the accept thread. - No goto. No per-thread dynamic allocation for listener contexts on each accepted connection. * Thorough logging and correctness fixes across the full pool/participant flow Every failure point in the operator->miner->payout chain now logs exactly what broke, with enough context to diagnose without a debugger. Operator side (rpc.cpp): RebuildJob: logs 'no chain tip (pindexBest is null)' on failure instead of silently returning false. HandleLine: - JSON parse errors logged with the raw line (first 80 chars) - subscribe: logs fd and session id - authorize: logs address, worker, and whether the address is valid - submit: logs stale-job case with submitted vs current job id; logs high-hash with actual hash and target values; logs valid shares with nonce and running session count; logs BLOCK CANDIDATE separately from BLOCK ACCEPTED/REJECTED so a ProcessBlock failure is unambiguous - unknown method logged (reveals miner software quirks) - reply() logs send failures with method name QueuePayouts: logs total payout amount, miner count, fee applied, and how many miners were below dust threshold. FlushMaturePayouts: logs 'insufficient funds or wallet locked?' on SendMoney failure instead of just 'FAILED'. BroadcastJob: logs job id and miner count on every broadcast. Event loop: logs when each new miner socket enters the loop; logs disconnect with session and round share counts. No-job watchdog: RebuildJob retried every 5s (not every 50ms tick), eliminating cs_main hammering when there is no chain tip. AcceptLoopFn: re-polls BtfAllRelays every 60s so relays discovered after startup are picked up; logs each new relay thread spawned. AcceptOneFn: logs relay name on start/stop; logs BtfServiceWrap (echan handshake) failure separately from RvServiceRegister failure. RecvLine: disconnected out-param distinguishes 'socket closed' from 'no complete line yet', preventing incorrect disconnection on partial recv. Participant side (main.cpp): StratumRecvLine: same disconnected out-param fix as RecvLine in rpc.cpp. Previously a false return could mean either 'disconnect' or 'no data yet', causing spurious reconnects when a partial line arrived. PoolParticipantMiner rewritten with step-by-step logging: Step 1 resolve: logs specific failure (no descriptor vs bad format) Step 2 tunnel: logs relay and failure reason Step 3 address: warns explicitly if payout address cannot be determined Step 4 subscribe/authorize: logs each send and each response Step 5 RandomX: logs VM mode Step 6 main loop: - malformed mining.notify (wrong param count or short header/target) logged with sizes instead of silently ignored - authorize rejected: logs reason, does not disconnect (some pools reject bad addresses but still send work) - share accepted/rejected: logs share id and rejection reason - unknown server method logged - pool disconnect logged with handshake state at that moment (gotSubscribeAck, gotAuthorizeAck, haveJob) so you know exactly how far the connection got - nonce space exhausted logged (was silent before) - on exit: logs total hashes, shares sent, shares accepted * Fix build error: ImGuiWindowFlags_NoBringToDisplayFront -> NoBringToFrontOnFocus NoBringToDisplayFront does not exist in this version of ImGui (v1.89.x). The correct flag is ImGuiWindowFlags_NoBringToFrontOnFocus (bit 13). Used in two places: ##statusbar and ##main windows. Also remove dead variable usedY (GetCursorPosY() result assigned but never read) and replace the confusing 'ImGuiWindowFlags_NoScrollbar * 0' expression (evaluates to 0 but looks like a mistake) with a plain 0 in the BeginChild call. * Remove duplicate status logging from SetParticipantMiningStatus Every transition in PoolParticipantMiner already has a direct LogPrint with full context (addresses, job ids, nonces, error reasons). The LogPrint inside SetParticipantMiningStatus was firing on top of those, producing redundant 'PoolParticipantMiner: status -> ...' lines after every meaningful log entry. SetParticipantMiningStatus now just updates the GUI string silently. * Implement vardiff: stable share rate instead of fixed difficulty Previously the pool set shareTarget = blockTarget >> 8 for every miner forever. This meant at low network difficulty a fast miner found shares every few nonces (dozens per second), while at high difficulty a slow miner might go hours without one -- both are bad for fair payout accounting and pool stability. Pool (rpc.cpp): Per-miner difficulty that adjusts to target one share every 15 seconds. After every 5 shares, check actual seconds/share vs target. If the ratio is off by more than 50% (VARDIFF_RETARGET_RATIO), recompute: new_difficulty = current_difficulty / (actual_secs_per_share / target) i.e. shares coming too fast -> difficulty goes up; too slow -> down. Only apply if the change exceeds 10% to avoid jitter. Difficulty is bounded to [0.001, 1e12]. Each miner gets their own shareTarget derived from their difficulty via ShareTargetFromDifficulty(), which maps Stratum difficulty to a uint256 target using the Bitcoin difficulty-1 base (0x1d00ffff compact). New difficulty is sent to the miner via mining.set_difficulty. Participant (main.cpp): Apply the received difficulty value from mining.set_difficulty. Previously the handler was a no-op ('acknowledge only'); now it parses the difficulty double and logs the change. The share target used for comparison comes from the pool's mining.notify p[3] field, which the pool updates after each vardiff adjustment -- this is the standard approach and matches how cpuminer, xmrig etc. handle it. With vardiff, share rate is roughly constant regardless of miner speed or network difficulty. Payouts are proportional to shares submitted, so each miner's payout accurately reflects their CPU contribution. * Fix share target mismatch: pool rejected shares it should have accepted Root cause: the pool put job.shareTarget (from blockTarget<<8, a fixed per-job value) into mining.notify p[3], but checked m->shareTarget (from ShareTargetFromDifficulty, the vardiff value) on submit. With difficulty=1.0, ShareTargetFromDifficulty gives a much HARDER target than blockTarget<<8, so shares that the participant thought were valid (passed the easier notify target) were rejected by the pool (failed the harder difficulty-1 target). Fix: MakeNotifyParams now takes the per-miner shareTarget as an explicit parameter. SendJob passes m->shareTarget. Both sides -- the participant reading p[3] and HandleLine checking m->shareTarget on submit -- now use exactly the same value. Single source of truth. Cleanup: - Remove shareTarget field from StratumJob (was only set, never read after the MakeNotifyParams call was fixed). - Remove MakeShareTarget() function (was only used to populate that now-dead field). - Participant set_difficulty log: 'initial' vs 'updated' instead of 'unchanged' (first connect sends 1.0 which matches the default 1.0, so it was misleadingly logged as 'unchanged'). * Fix double broadcast, correct block reward calculation, improve payout log Double broadcast (event loop vs HandleLine): When a pool miner finds a block, HandleLine calls RebuildJob() + BroadcastJob(true) immediately. The event loop then detects nBestHeight changed (step 2) and was calling RebuildJob() + BroadcastJob(true) again -- miners received duplicate job broadcasts. Fix: step 2 now checks gHaveJob before rebuilding. If HandleLine already built and broadcast a job for this height (gHaveJob=true), step 2 just logs 'job already broadcast by submit handler' and skips. If the block came from an external peer (not our miner), gHaveJob is false and step 2 handles it as before. Block reward calculation in QueuePayouts: Was calling CBlock().GetBlockValue(0) on an empty block object. GetBlockValue uses nBestHeight globally, which by the time QueuePayouts runs has already been incremented to the height AFTER the found block. Fix: compute subsidy directly as '50 COIN >> (blockHeight / 210000)' using the actual found height. At current scale this makes no difference (same subsidy for thousands of blocks) but is now correct by construction. Payout log now shows: - Full block subsidy - Miner share after fee - Operator fee amount in BTF (not just percentage) - Number of recipients, maturity height, dust-filtered count Example: '[payout] block 2096: subsidy 50.00 BTF, miners get 49.00 (fee 2.00% = 1.00 to operator), 3 recipients, matures height 2196' * Fix 6 bugs found in full flow re-read Bug 1 -- Double broadcast (re-applied fix that was lost): When HandleLine accepted a block it called RebuildJob()+BroadcastJob(true). The event loop then detected nBestHeight changed and did it again. Miners received two back-to-back mining.notify clean=true, wasting their work. Fix: step 2 of event loop checks gHaveJob before rebuilding. If HandleLine already built a fresh job (gHaveJob=true), step 2 just logs and skips. Bug 3 -- Disconnect log showed global totals, not session totals: gParticipantSharesSent/Accepted accumulate since node start, not since the last connect. Added sessionSharesSent/sessionSharesAccepted local counters that reset each time PoolParticipantMiner() is called. Bug 4 -- Dead function ParticipantShareTargetFromDiff (main.cpp): Defined but never called anywhere. Removed. Bug 5 -- Invalid-address miner shares silently burned: If a miner authorized with an invalid address, their share of the block reward was neither paid out nor logged -- it just vanished. QueuePayouts now validates addresses up front, logs the forfeited amount explicitly ('X BTF forfeited (bad address)'), and excludes those entries so the payout log is accurate. Bug 6 -- SendMoney called from event loop (stalls miner I/O): FlushMaturePayouts called SendMoney which takes cs_main. If the P2P thread held cs_main (processing a block), the event loop would stall for seconds, causing all connected miners to time out waiting for responses. Fix: FlushMaturePayouts now only moves matured entries to gPayoutExecQueue (fast, no locks except a brief mutex). A new PayoutThreadFn thread drains the queue and calls SendMoney independently, so the event loop is never blocked by wallet I/O. Bug 7 -- Dead goto + dead HandleLine return-false path: HandleLine always returns true; the goto next_miner path was never reached. Removed goto and simplified the select drain loop. * Fix share-target scale mismatch and dead pool hashrate reporting - ShareTargetFromDifficulty() based Stratum difficulty 1 on Bitcoin's SHA256 diff-1 constant (0x1d00ffff, ~2^32 hashes/share) instead of this chain's actual PoW floor (bnProofOfWorkLimit = ~uint256(0)>>12, ~2^12 hashes/share). That's a ~2^20 (~1,048,576x) mismatch: at the supposedly-easy difficulty of 1.0, workers were being asked for shares roughly a million times harder than the chain's own easiest target. Vardiff never gets a chance to correct this because it only retargets after real shares come in, and none were coming in. Fixed by basing the diff-1 reference on bnProofOfWorkLimit instead. - Pool hashrate was never computed anywhere despite the data (per-miner difficulty + share timing) being available: ThreadBtfPoolAnnouncer in nostr.cpp hardcoded ann.hashRate = 0.0, PoolWorkerStatView had no hashrate field, and the GUI workers table had no column for it. Added an EWMA hashrate estimate per miner (updated on each accepted share from expected-hashes-at-difficulty / time-since-last-share), threaded it through GetPoolOperatorStats/GetPoolWorkerStats, the pool announcement payload, and the GUI (new Hashrate column + totals in the telemetry line). Verified: full clean build via src/Makefile (secp256k1 + RandomX deps built from source, g++ 13, all four touched files compile and link). * Remove stale wxWidgets-era build files and orphaned assets These predate the ImGui/GLFW migration (83af065) and are dead weight: - src/makefile.linux, build-linux.sh: superseded by src/Makefile + root Makefile (make linux). The old ones reference obj/uibase.o and obj/ui.o, which don't exist -- they never worked post-migration. - src/makefile.vc: MSVC build for the old wx UI (obj\ui.obj, obj\uibase.obj, obj\irc.obj) -- irc.cpp doesn't even exist anymore, and nothing invokes this makefile. Windows builds go through makefile.mingw via the root Makefile's target. - src/ui.rc, src/uiproject.fbp: wxFormBuilder project/resource files for the old wx UI. The current Windows resource file is bitflash.rc. - src/rc/{addressbook,send}*.bmp, check.ico, bitcoin_bc_original.ico: icons used only by the old wx UI. bitcoin.ico (used by bitflash.rc) is kept. Verified nothing includes uibase.h/ui.h/irc.h anywhere, and did a full clean rebuild afterward (secp256k1 + RandomX from source, src/Makefile) to confirm removal doesn't break the real build. * Mine with RandomX fast (2GB dataset) mode instead of light mode forever RandomXInitDataset() was fully implemented and ready to switch mining into fast mode, but nothing ever called it -- not in PoolParticipantMiner() (pool worker), not in the solo BitcoinMiner() loop. RandomXCreateMinerVM() falls back to light mode whenever the dataset isn't built, so every miner, participant or solo, has always been hashing in light/cache-only mode. Light mode exists for cheap single-hash verification (which is why the pool's own share verification correctly stays on it) -- it recomputes dataset items on the fly for every hash, which is dramatically slower than fast mode's direct dataset lookups. Combined with the share-target scale bug fixed in e87a905, this compounds into exactly the symptom reported: a participant sitting there hashing for many minutes per share instead of the vardiff-targeted ~15s, even after the target fix landed. Fix: build the ~2GB dataset once (multi-threaded, sized to hardware_concurrency) before mining starts, in both the participant and solo paths, so RandomXCreateMinerVM() actually gets to use fast mode. Verified: full clean rebuild via src/Makefile. * Add vardiff stall watchdog: force difficulty down on zero shares, not just share count ShareTargetFromDifficulty already fixed the diff-1 scale mismatch (chain floor vs Bitcoin's SHA256 diff-1), so difficulty 1 correctly means ~4096 expected hashes on this chain, not ~2^32. That part was already correct. But the normal vardiff retarget in HandleLine only runs after VARDIFF_SHARES_PER_CHECK (5) shares arrive. A miner that never finds even one share -- e.g. stuck in RandomX light/cache mode instead of the fast 2GB dataset, or just slower hardware than the target assumes -- never reaches that check, so it can sit at its initial/current difficulty indefinitely with vardiff having no samples to act on. Add a time-based fallback in PoolEventLoop: if a miner's vardiff window has been open for VARDIFF_STALL_SECS (90s, 6x the 15s target) with zero shares, force difficulty down by VARDIFF_STALL_CUT (4x) regardless of share count, and re-notify via mining.set_difficulty. Once a miner is already at VARDIFF_MIN, log it plainly as a hashrate/connectivity issue instead of a difficulty issue, so it's not confused with the same-looking but different problem this was actually fixed for before. * Fix PrintException rethrow killing the whole node on any transient exception The uploaded participant log shows the real bug behind the mining stalls: the node process itself was dying and restarting from scratch every minute or two ('UNKNOWN EXCEPTION ... in ThreadMessageHandler()' / 'ThreadSocketHandler()'), wiping the RandomX dataset/cache and losing all progress toward a share -- not a difficulty problem. Every background thread (ThreadMessageHandler, ThreadSocketHandler, ThreadBtfConnect, the Nostr helpers) wraps its real work in a 'try { Foo2() } catch(...) { log, sleep 5s, loop again }' self-healing retry pattern specifically so one bad exception doesn't take the whole node down. But PrintException(), called from inside every one of those catch blocks, ended with 'if (!fShutdown) throw;' -- re-throwing the just- caught exception during ordinary operation. Nothing above the retry loop catches it, so it escapes the thread and std::terminate()s the entire process on the very first transient error (a flaky relay connect, a malformed message, whatever). The comment's intent (suppress noise while already shutting down) was inverted from what the surrounding retry loops actually need. Removed the rethrow. Threads now log the exception and let their own retry loop handle it, as designed. * Push a fresh job immediately after any difficulty change, not just on next broadcast mining.set_difficulty alone doesn't change what the worker actually hashes against -- the participant only reads its share target from mining.notify's params[3] (see main.cpp's mining.notify handler); the set_difficulty message just updates a locally-logged currentDifficulty value. So both the normal vardiff retarget (in HandleLine, on 5 shares) and the new stall watchdog were updating m->shareTarget and telling the worker about the new difficulty, but the worker kept hashing against its old target until the next natural job broadcast -- a new block, or the 60s trickle refresh. That could delay any difficulty correction by up to a minute for no reason. Call SendJob(m, false) right after each set_difficulty send so the updated shareTarget reaches the worker in the very next tick instead of waiting on the next unrelated broadcast. * Build RandomX dataset before stratum handshake, not after Worker was sending mining.subscribe/authorize, then blocking for the duration of the synchronous ~2GB RandomX dataset build, and only then reading the socket. The pool's replies (subscribe ack, authorize ack, set_difficulty, first job) sat unread in the OS receive buffer for that entire window -- nothing was lost, but with the build taking anywhere from seconds to a couple minutes depending on core count, the worker looked and logged as permanently stuck on 'waiting for pool response' when it just wasn't listening yet. Move the dataset build ahead of the subscribe/authorize sends so the socket is actively being read the moment the pool replies. Also add missing RandomXDestroyMinerVM cleanup on the two early-return paths that now happen after VM creation. * Reset round shares on externally-found blocks, not just our own Round shares were only cleared in HandleLine() after our own miner's block got accepted. When the chain height advanced because someone else found the block, the main loop rebuilt the job but left roundShareCount/roundShareTotal/Miner::roundShares untouched, so they kept accumulating across rounds instead of resetting. * Show pool hashrate in participant view; show immature coinbase value with maturity countdown * Persist pending payouts to disk -- survive operator restarts * Fix log escapes, stall timeout 90->45s, save-before-exec ordering, Immature tx status * Sort transactions by nTimeReceived not chain median time -- new txs always on top * Sort txs by chain depth not nTimeReceived -- survives resync, unconfirmed always first * Fix orphan block false-positive payout; meaningful tx status instead of raw depth * Show Orphaned status in red for orphaned coinbases; distinguish from genuinely unconfirmed --------- Co-authored-by: Vic-Nas Co-authored-by: Bitflash Pool Dev Co-authored-by: Victorio Nascimento <155357371+Vic-Nas@users.noreply.github.com> Co-authored-by: Claude --- .gitignore | 1 + Bitflash-1.1.0-windows/glfw3.dll | Bin 0 -> 263756 bytes Bitflash-1.1.0-windows/libcrypto-3-x64.dll | Bin 0 -> 5495100 bytes Bitflash-1.1.0-windows/libdb_cxx-6.2.dll | Bin 0 -> 1666981 bytes Bitflash-1.1.0-windows/libgcc_s_seh-1.dll | Bin 0 -> 153412 bytes Bitflash-1.1.0-windows/libsodium-26.dll | Bin 0 -> 451415 bytes Bitflash-1.1.0-windows/libssl-3-x64.dll | Bin 0 -> 996164 bytes Bitflash-1.1.0-windows/libstdc++-6.dll | Bin 0 -> 2667044 bytes Bitflash-1.1.0-windows/libwinpthread-1.dll | Bin 0 -> 64703 bytes build-linux.sh | 72 - install-bitflash-wine.sh | 211 - src/Makefile | 4 +- src/btfrv.cpp | 4 +- src/db.cpp | 97 +- src/db.h | 15 +- src/gui.cpp | 701 +- src/headers.h | 2 +- src/headers_core.h | 5 +- src/irc.cpp | 271 - src/irc.h | 7 - src/main.cpp | 631 +- src/main.h | 23 +- src/main_gui.cpp | 4 +- src/makefile.linux | 55 - src/makefile.mingw | 4 +- src/makefile.vc | 77 - src/net.cpp | 391 +- src/net.h | 31 +- src/nostr.cpp | 518 +- src/nostr.h | 26 + src/rc/addressbook16.bmp | Bin 1334 -> 0 bytes src/rc/addressbook16mask.bmp | Bin 126 -> 0 bytes src/rc/addressbook20.bmp | Bin 1478 -> 0 bytes src/rc/addressbook20mask.bmp | Bin 142 -> 0 bytes src/rc/bitcoin_bc_original.ico | Bin 22486 -> 0 bytes src/rc/check.ico | Bin 766 -> 0 bytes src/rc/send16.bmp | Bin 1334 -> 0 bytes src/rc/send16mask.bmp | Bin 126 -> 0 bytes src/rc/send16masknoshadow.bmp | Bin 126 -> 0 bytes src/rc/send20.bmp | Bin 1478 -> 0 bytes src/rc/send20mask.bmp | Bin 142 -> 0 bytes src/rpc.cpp | 1544 ++-- src/ui.rc | 14 - src/uiproject.fbp | 11825 --------------------------- src/util.cpp | 108 +- src/util.h | 42 +- 46 files changed, 2648 insertions(+), 14035 deletions(-) create mode 100644 Bitflash-1.1.0-windows/glfw3.dll create mode 100644 Bitflash-1.1.0-windows/libcrypto-3-x64.dll create mode 100644 Bitflash-1.1.0-windows/libdb_cxx-6.2.dll create mode 100644 Bitflash-1.1.0-windows/libgcc_s_seh-1.dll create mode 100644 Bitflash-1.1.0-windows/libsodium-26.dll create mode 100644 Bitflash-1.1.0-windows/libssl-3-x64.dll create mode 100644 Bitflash-1.1.0-windows/libstdc++-6.dll create mode 100644 Bitflash-1.1.0-windows/libwinpthread-1.dll delete mode 100644 build-linux.sh delete mode 100644 install-bitflash-wine.sh delete mode 100644 src/irc.cpp delete mode 100644 src/irc.h delete mode 100644 src/makefile.linux delete mode 100644 src/makefile.vc delete mode 100644 src/rc/addressbook16.bmp delete mode 100644 src/rc/addressbook16mask.bmp delete mode 100644 src/rc/addressbook20.bmp delete mode 100644 src/rc/addressbook20mask.bmp delete mode 100644 src/rc/bitcoin_bc_original.ico delete mode 100644 src/rc/check.ico delete mode 100644 src/rc/send16.bmp delete mode 100644 src/rc/send16mask.bmp delete mode 100644 src/rc/send16masknoshadow.bmp delete mode 100644 src/rc/send20.bmp delete mode 100644 src/rc/send20mask.bmp delete mode 100644 src/ui.rc delete mode 100644 src/uiproject.fbp (limited to 'src/rpc.cpp') diff --git a/.gitignore b/.gitignore index 29ebcff..a734faf 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ dist/ # Editor/OS cruft .DS_Store Thumbs.db +src/bitflash diff --git a/Bitflash-1.1.0-windows/glfw3.dll b/Bitflash-1.1.0-windows/glfw3.dll new file mode 100644 index 0000000..f988a2e Binary files /dev/null and b/Bitflash-1.1.0-windows/glfw3.dll differ diff --git a/Bitflash-1.1.0-windows/libcrypto-3-x64.dll b/Bitflash-1.1.0-windows/libcrypto-3-x64.dll new file mode 100644 index 0000000..76250f0 Binary files /dev/null and b/Bitflash-1.1.0-windows/libcrypto-3-x64.dll differ diff --git a/Bitflash-1.1.0-windows/libdb_cxx-6.2.dll b/Bitflash-1.1.0-windows/libdb_cxx-6.2.dll new file mode 100644 index 0000000..c4279f6 Binary files /dev/null and b/Bitflash-1.1.0-windows/libdb_cxx-6.2.dll differ diff --git a/Bitflash-1.1.0-windows/libgcc_s_seh-1.dll b/Bitflash-1.1.0-windows/libgcc_s_seh-1.dll new file mode 100644 index 0000000..55f4ad1 Binary files /dev/null and b/Bitflash-1.1.0-windows/libgcc_s_seh-1.dll differ diff --git a/Bitflash-1.1.0-windows/libsodium-26.dll b/Bitflash-1.1.0-windows/libsodium-26.dll new file mode 100644 index 0000000..add05ab Binary files /dev/null and b/Bitflash-1.1.0-windows/libsodium-26.dll differ diff --git a/Bitflash-1.1.0-windows/libssl-3-x64.dll b/Bitflash-1.1.0-windows/libssl-3-x64.dll new file mode 100644 index 0000000..cbabbbc Binary files /dev/null and b/Bitflash-1.1.0-windows/libssl-3-x64.dll differ diff --git a/Bitflash-1.1.0-windows/libstdc++-6.dll b/Bitflash-1.1.0-windows/libstdc++-6.dll new file mode 100644 index 0000000..bd3fc0f Binary files /dev/null and b/Bitflash-1.1.0-windows/libstdc++-6.dll differ diff --git a/Bitflash-1.1.0-windows/libwinpthread-1.dll b/Bitflash-1.1.0-windows/libwinpthread-1.dll new file mode 100644 index 0000000..32d8a01 Binary files /dev/null and b/Bitflash-1.1.0-windows/libwinpthread-1.dll differ diff --git a/build-linux.sh b/build-linux.sh deleted file mode 100644 index 4c3844d..0000000 --- a/build-linux.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# Bitflash headless node/miner -- one-shot Linux build (Debian/Ubuntu). -# Builds the dependencies (libsecp256k1 with schnorrsig, RandomX) and then the -# node itself. Run from the project root (the directory that contains src/): -# -# bash build-linux.sh -# -# Result: src/bitflash-node . Run it with: ./src/bitflash-node -gen - -set -e -ROOT="$(cd "$(dirname "$0")" && pwd)" -cd "$ROOT" - -# Use sudo only when not already root (a root login often has no sudo installed). -SUDO="" -if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi - -echo "==> installing system packages" -if command -v apt-get >/dev/null 2>&1; then - $SUDO apt-get update - $SUDO apt-get install -y build-essential cmake git pkg-config autoconf libtool \ - libssl-dev libdb5.3++-dev libsodium-dev nlohmann-json3-dev libboost-dev \ - libboost-system-dev libwxgtk3.2-dev -else - echo "!! non-apt system: install manually -> g++ cmake git autoconf libtool" - echo " libssl-dev libdb++-dev libsodium-dev nlohmann-json (headers)" -fi - -# ---- libsecp256k1 (Schnorr/BIP340 + extrakeys), static, installed to /usr/local -if [ ! -f /usr/local/lib/libsecp256k1.a ]; then - echo "==> building libsecp256k1" - rm -rf "$ROOT/secp256k1-build" - git clone --depth 1 https://github.com/bitcoin-core/secp256k1 "$ROOT/secp256k1-build" - cd "$ROOT/secp256k1-build" - ./autogen.sh - ./configure --enable-module-schnorrsig --enable-module-extrakeys \ - --disable-shared --with-pic --disable-benchmark --disable-tests - make -j"$(nproc)" - $SUDO make install - $SUDO ldconfig - cd "$ROOT" -else - echo "==> libsecp256k1 already installed, skipping" -fi - -# ---- RandomX (memory-hard CPU PoW), static lib -if [ ! -f "$HOME/RandomX/build/librandomx.a" ]; then - echo "==> building RandomX" - rm -rf "$HOME/RandomX" - git clone --depth 1 https://github.com/tevador/RandomX "$HOME/RandomX" - mkdir -p "$HOME/RandomX/build" - cd "$HOME/RandomX/build" - cmake .. -DCMAKE_BUILD_TYPE=Release - make -j"$(nproc)" randomx - cd "$ROOT" -else - echo "==> RandomX already built, skipping" -fi - -# ---- build -echo "==> building bitflash" -cd "$ROOT/src" -make -f makefile.linux RANDOMX_DIR="$HOME/RandomX" -j"$(nproc)" - -echo -echo "===================================================================" -echo " DONE. $ROOT/src/bitflash" -echo -echo " GUI: ./src/bitflash" -echo " Daemon mode: ./src/bitflash /nogui /gen" -echo " Pool server starts automatically on Stratum :3333, stats :19012" -echo "===================================================================" diff --git a/install-bitflash-wine.sh b/install-bitflash-wine.sh deleted file mode 100644 index 91fef59..0000000 --- a/install-bitflash-wine.sh +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env bash -# Bitflash GUI installer for Linux (via Wine) — hardened -set -euo pipefail - -INSTALL_DIR="$HOME/.btf-wine" -WINE_DATA="$HOME/.wine/drive_c/users/$USER/AppData/Roaming/Bitflash" -LINUX_DATA="$HOME/.bitflash" -BIN_DIR="$HOME/.local/bin" -REPO="Bitflash-sh/bitflash" - -echo "==> Bitflash GUI installer" -echo - -# ---- Safety: don't run as root (breaks Wine + pollutes /root) -if [ "$(id -u)" -eq 0 ]; then - echo "ERROR: do not run this as root. Run as your normal user (it will call sudo only when needed)." >&2 - exit 1 -fi - -need() { command -v "$1" >/dev/null 2>&1; } - -# ---- Wine -echo "==> Checking Wine..." -if ! need wine; then - echo " Installing Wine..." - if ! need apt-get; then - echo "ERROR: apt-get not found. Install 'wine' manually, then re-run." >&2 - exit 1 - fi - sudo apt-get update -qq - sudo apt-get install -y wine -fi -echo " Wine: $(wine --version)" - -# ---- db-util (db_dump / db_load for wallet conversion) -echo "==> Checking db-util..." -if ! need db_dump; then - echo " Installing db-util..." - sudo apt-get install -y db-util -fi -echo " db_dump: $(command -v db_dump)" - -# ---- unzip + sha256sum must exist -for tool in unzip sha256sum curl; do - if ! need "$tool"; then - echo " Installing $tool..." - sudo apt-get install -y "$tool" - fi -done - -# ---- Resolve latest release (single API call) -echo "==> Fetching latest release metadata from GitHub..." -API_JSON=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest") - -RELEASE_URL=$(printf '%s\n' "$API_JSON" | grep '"browser_download_url"' \ - | grep -iE 'win|windows|gui' | head -1 | cut -d '"' -f 4 || true) -[ -z "$RELEASE_URL" ] && RELEASE_URL=$(printf '%s\n' "$API_JSON" | grep '"browser_download_url"' \ - | grep -iE '\.zip"?$' | head -1 | cut -d '"' -f 4 || true) - -if [ -z "$RELEASE_URL" ]; then - echo "ERROR: no downloadable release asset found at https://github.com/$REPO/releases" >&2 - exit 1 -fi -ASSET_NAME=$(basename "$RELEASE_URL") -echo " Asset: $ASSET_NAME" - -# checksum manifest published alongside the release (SHA256SUMS) -SUMS_URL=$(printf '%s\n' "$API_JSON" | grep '"browser_download_url"' \ - | grep -iE 'sha256sums|checksums|\.sha256"?$' | head -1 | cut -d '"' -f 4 || true) - -# ---- Download the release -TMPZIP=$(mktemp /tmp/bitflash-XXXXXX.zip) -cleanup() { rm -f "$TMPZIP" "${TMPSUMS:-}"; } -trap cleanup EXIT -echo "==> Downloading release..." -curl -fL --retry 3 --retry-delay 2 -o "$TMPZIP" "$RELEASE_URL" -echo " Downloaded: $(du -h "$TMPZIP" | cut -f1)" - -# ---- MANDATORY integrity check -echo "==> Verifying integrity..." -GOT=$(sha256sum "$TMPZIP" | awk '{print $1}') -if [ -n "${EXPECTED_SHA256:-}" ]; then - # Strongest: user pinned a known-good hash out-of-band. - if [ "$GOT" != "$EXPECTED_SHA256" ]; then - echo "ERROR: checksum mismatch. expected $EXPECTED_SHA256, got $GOT" >&2 - exit 1 - fi - echo " OK (matches pinned EXPECTED_SHA256)" -elif [ -n "$SUMS_URL" ]; then - TMPSUMS=$(mktemp /tmp/bitflash-sums-XXXXXX) - curl -fsSL --retry 3 -o "$TMPSUMS" "$SUMS_URL" - WANT=$(grep -iF "$ASSET_NAME" "$TMPSUMS" | awk '{print $1}' | head -1 || true) - if [ -z "$WANT" ]; then - echo "ERROR: no checksum for $ASSET_NAME in published SHA256SUMS." >&2 - exit 1 - fi - if [ "$GOT" != "$WANT" ]; then - echo "ERROR: checksum mismatch. published $WANT, got $GOT" >&2 - exit 1 - fi - echo " OK (matches published SHA256SUMS)" -else - echo "ERROR: no checksum available for this release." >&2 - echo " Publish a SHA256SUMS asset with the release, or re-run with" >&2 - echo " EXPECTED_SHA256= to verify against a known-good value." >&2 - echo " (To bypass at your own risk: ALLOW_UNVERIFIED=1)" >&2 - if [ "${ALLOW_UNVERIFIED:-0}" != "1" ]; then - exit 1 - fi - echo " WARNING: proceeding UNVERIFIED (ALLOW_UNVERIFIED=1). sha256=$GOT" -fi - -# ---- Extract -echo "==> Extracting to $INSTALL_DIR..." -rm -rf "$INSTALL_DIR" -mkdir -p "$INSTALL_DIR" -unzip -j -o "$TMPZIP" -d "$INSTALL_DIR" >/dev/null - -# ---- Rename backslash-mangled entries (Windows zip paths) -count=0 -for f in "$INSTALL_DIR"/*; do - [ -f "$f" ] || continue - base=$(basename "$f") - newname=$(printf '%s' "$base" | sed 's/.*\\//') - if [ "$newname" != "$base" ]; then - mv "$f" "$INSTALL_DIR/$newname" - count=$((count+1)) - fi -done -echo " Renamed $count files; total: $(ls "$INSTALL_DIR" | wc -l)" - -# Locate the Bitflash exe case-insensitively (zip may ship Bitflash.exe or bitflash.exe) -EXE=$(find "$INSTALL_DIR" -maxdepth 1 -iname 'bitflash.exe' | head -1) -if [ -z "$EXE" ]; then - echo "ERROR: bitflash exe not found after extract. Contents:" >&2 - ls "$INSTALL_DIR" >&2 - exit 1 -fi -echo " Found $(basename "$EXE")" - -# ---- Init Wine data dir -echo "==> Initializing Wine data directory..." -WINEDLLOVERRIDES="libdb_cxx-6.2=n" wine "$EXE" & -WINE_PID=$! -created=0 -for i in $(seq 1 30); do - sleep 1 - if [ -f "$WINE_DATA/wallet.dat" ]; then - echo " wallet.dat created after ${i}s" - created=1 - break - fi -done -kill "$WINE_PID" 2>/dev/null || true -wait "$WINE_PID" 2>/dev/null || true -sleep 1 -mkdir -p "$WINE_DATA" -[ "$created" -eq 1 ] || echo " NOTE: Wine did not create wallet.dat in 30s; continuing." - -# ---- Optional wallet import (with backup) -echo -if [ -f "$LINUX_DATA/wallet.dat" ]; then - echo "==> Found existing Linux wallet at $LINUX_DATA/wallet.dat" - read -r -p " Import it into the GUI wallet? [Y/n] " ans - ans=${ans:-Y} - if [[ "$ans" =~ ^[Yy] ]]; then - if [ -f "$WINE_DATA/wallet.dat" ]; then - BK="$WINE_DATA/wallet.dat.bak.$(date +%Y%m%d%H%M%S)" - cp "$WINE_DATA/wallet.dat" "$BK" - echo " Backed up existing GUI wallet -> $BK" - fi - DUMP=$(mktemp); CONVERTED=$(mktemp); rm -f "$CONVERTED" - echo " Converting Linux wallet format..." - db_dump -p "$LINUX_DATA/wallet.dat" > "$DUMP" - db_load -f "$DUMP" "$CONVERTED" - cp "$CONVERTED" "$WINE_DATA/wallet.dat" - rm -f "$DUMP" "$CONVERTED" - echo " Wallet imported." - else - echo " Skipped. Re-run to import later." - fi -else - echo "==> No Linux wallet found, starting fresh." -fi - -# ---- Launcher -echo "==> Creating 'bitflash' command..." -mkdir -p "$BIN_DIR" -LAUNCHER_FILE="$BIN_DIR/bitflash" -cat > "$LAUNCHER_FILE" </dev/null; then - echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc" - echo " Added ~/.local/bin to PATH (restart shell or: source ~/.bashrc)" -fi - -echo -echo "==================================================================" -echo " DONE." -echo " Launch: bitflash" -echo " Mine: bitflash -gen" -echo " Relay: bitflash -announcerelay=YOUR_HOST:8434" -echo " Data: $WINE_DATA" -echo "==================================================================" diff --git a/src/Makefile b/src/Makefile index 3280fd3..c0b6e1d 100644 --- a/src/Makefile +++ b/src/Makefile @@ -21,14 +21,14 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ -lglfw -lGL HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ - key.h bignum.h script.h db.h base58.h net.h irc.h nostr.h \ + key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h compat.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ - obj/market.o obj/sha.o obj/irc.o obj/nostr.o obj/randomx_pow.o \ + obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o GUI_OBJS = obj/gui.o obj/main_gui.o diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 812d077..0d02ca8 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -101,7 +101,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) // Apply a short timeout only for the connect() and initial handshake so an // unresponsive relay doesn't block the thread. Once the tunnel is live the - // timeout is cleared — a forwarding pipe must block indefinitely on recv(). + // timeout is cleared -- a forwarding pipe must block indefinitely on recv(). #ifdef _WIN32 DWORD tvShort = 10000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tvShort, sizeof(tvShort)); @@ -118,7 +118,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) } freeaddrinfo(res); - // Clear the timeout — the socket is now a live tunnel and must not + // Clear the timeout -- the socket is now a live tunnel and must not // time out when idle. recv() will block until data arrives or peer closes. #ifdef _WIN32 DWORD tvOff = 0; diff --git a/src/db.cpp b/src/db.cpp index de71a57..199fa44 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -392,77 +392,8 @@ bool CTxDB::LoadBlockIndex() -// -// CAddrDB -// - -bool CAddrDB::WriteAddress(const CAddress& addr) -{ - return Write(make_pair(string("addr"), addr.GetKey()), addr); -} - -bool CAddrDB::LoadAddresses() -{ - CRITICAL_BLOCK(cs_mapAddresses) - { - // Load user provided addresses - CAutoFile filein = fopen("addr.txt", "rt"); - if (filein) - { - try - { - char psz[1000]; - while (fgets(psz, sizeof(psz), filein)) - { - CAddress addr(psz, NODE_NETWORK); - if (addr.ip != 0) - AddAddress(*this, addr); - } - } - catch (...) { } - } - - // Get cursor - Dbc* pcursor = GetCursor(); - if (!pcursor) - return false; - - loop - { - // Read next record - CDataStream ssKey; - CDataStream ssValue; - int ret = ReadAtCursor(pcursor, ssKey, ssValue); - if (ret == DB_NOTFOUND) - break; - else if (ret != 0) - return false; - - // Unserialize - string strType; - ssKey >> strType; - if (strType == "addr") - { - CAddress addr; - ssValue >> addr; - mapAddresses.insert(make_pair(addr.GetKey(), addr)); - } - } - - //// debug print - printf("mapAddresses:\n"); - foreach(const PAIRTYPE(vector, CAddress)& item, mapAddresses) - item.second.print(); - printf("-----\n"); - } - - return true; -} - -bool LoadAddresses() -{ - return CAddrDB("cr+").LoadAddresses(); -} +// Legacy IP-based peer address database (CAddrDB / addr.dat) removed -- +// peer discovery is entirely Nostr/.btf-based now. @@ -563,24 +494,28 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { string strKey; ssKey >> strKey; - if (strKey == "fGenerateBitcoins") ssValue >> fGenerateBitcoins; if (strKey == "nTransactionFee") ssValue >> nTransactionFee; if (strKey == "addrIncoming") ssValue >> addrIncoming; - if (strKey == "nMineMode") ssValue >> nMineMode; - if (strKey == "strParticipantPool") ssValue >> strParticipantPool; - if (strKey == "strPoolName") ssValue >> strPoolName; - if (strKey == "strPoolDashboardUrl") ssValue >> strPoolDashboardUrl; - if (strKey == "dPoolFeePercent") ssValue >> dPoolFeePercent; + // Mining-mode/pool settings (nMineMode, strParticipantPool, strPoolName, + // strPoolDashboardUrl, dPoolFeePercent, fGenerateBitcoins) are intentionally + // NOT restored here. They used to be, and LoadWallet() runs after CLI flags + // are parsed in main_gui.cpp, so a saved value would silently overwrite + // whatever was just requested on the command line or in a previous Options + // session -- no error, no log line. That's what caused pool name/address to + // show correctly in the UI but stay stale in the logs, and mode to change + // behavior across restarts. Mode/pool config is decided fresh every launch + // instead: CLI flags if given, otherwise the compiled-in default. Nothing to + // go stale, nothing to fight over load order. } } } - printf("fGenerateBitcoins = %d\n", fGenerateBitcoins); printf("nTransactionFee = %lld\n", nTransactionFee); printf("addrIncoming = %s\n", addrIncoming.ToString().c_str()); - printf("nMineMode = %d, strParticipantPool = %s\n", nMineMode, strParticipantPool.c_str()); - printf("strPoolName = %s, dPoolFeePercent = %.2f\n", strPoolName.c_str(), dPoolFeePercent); - printf("strPoolDashboardUrl = %s\n", strPoolDashboardUrl.c_str()); + printf("nMineMode = %d, strParticipantPool = %s, fGenerateBitcoins = %d (from CLI/default, not wallet.dat)\n", + nMineMode, strParticipantPool.c_str(), fGenerateBitcoins); + printf("strPoolName = %s, dPoolFeePercent = %.2f, strPoolDashboardUrl = %s (from CLI/default, not wallet.dat)\n", + strPoolName.c_str(), dPoolFeePercent, strPoolDashboardUrl.c_str()); return true; } diff --git a/src/db.h b/src/db.h index feaa45e..6dc3a4f 100644 --- a/src/db.h +++ b/src/db.h @@ -316,19 +316,8 @@ private: -class CAddrDB : public CDB -{ -public: - CAddrDB(const char* pszMode="r+", bool fTxn=false) : CDB("addr.dat", pszMode, fTxn) { } -private: - CAddrDB(const CAddrDB&); - void operator=(const CAddrDB&); -public: - bool WriteAddress(const CAddress& addr); - bool LoadAddresses(); -}; - -bool LoadAddresses(); +// Legacy IP-based peer address database (CAddrDB / addr.dat) removed -- +// peer discovery is entirely Nostr/.btf-based now. diff --git a/src/gui.cpp b/src/gui.cpp index 2210296..a1a0698 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1,4 +1,4 @@ -// Bitflash GUI — ImGui + GLFW + OpenGL3 +// Bitflash GUI -- ImGui + GLFW + OpenGL3 // Same source on Linux and Windows. No platform ifdefs. #include "imgui/imgui.h" @@ -25,6 +25,7 @@ extern std::string strParticipantPool; extern CAddress addrLocalHost; void MainFrameRepaint(); +int GetDiscoveredPeerCount(); string DateTimeStr(int64 nTime); bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew); int64 GetBalance(); @@ -34,15 +35,20 @@ void ThreadBitcoinMiner(void*); void ThreadRPCServer(void*); // --------------------------------------------------------------------------- -// State +// Layout constants +// --------------------------------------------------------------------------- +static const float STATUS_BAR_H = 22.0f; + +// --------------------------------------------------------------------------- +// GUI state // --------------------------------------------------------------------------- static std::string g_myAddress; -static int64 g_balance = 0; +static int64 g_balance = 0; static int64 g_lastWalletRefresh = 0; -static bool g_showSend = false; -static bool g_showOptions = false; -static bool g_showAbout = false; -static bool g_needRefresh = true; +static bool g_showSend = false; +static bool g_showOptions = false; +static bool g_showAbout = false; +static bool g_needRefresh = true; static char g_sendAddr[128] = {}; static char g_sendAmount[32] = {}; @@ -73,36 +79,34 @@ static std::string FmtMoney(int64 n) static std::string FmtAge(int64 createdAt) { int64 age = GetTime() - createdAt; - if (age < 0) - age = 0; - if (age < 60) - return strprintf("%llds", (long long)age); - if (age < 3600) - return strprintf("%lldm", (long long)(age / 60)); - return strprintf("%lldh", (long long)(age / 3600)); + if (age < 0) age = 0; + if (age < 60) return strprintf("%llds", (long long)age); + if (age < 3600) return strprintf("%lldm", (long long)(age / 60)); + return strprintf("%lldh", (long long)(age / 3600)); } -static ImVec4 ParticipantStatusColor(const std::string& status) +static ImVec4 StatusColor(const std::string& s) { - if (status.find("failed") != std::string::npos || - status.find("rejected") != std::string::npos || - status.find("stopped") != std::string::npos) - return ImVec4(1.0f, 0.38f, 0.38f, 1.0f); - if (status.find("hashing") != std::string::npos || - status.find("accepted") != std::string::npos || - status.find("connected") != std::string::npos) - return ImVec4(0.35f, 1.0f, 0.45f, 1.0f); - if (status.find("asking") != std::string::npos || - status.find("authoriz") != std::string::npos || - status.find("subscrib") != std::string::npos) - return ImVec4(1.0f, 0.75f, 0.25f, 1.0f); - return ImVec4(0.55f, 0.80f, 1.0f, 1.0f); + if (s.find("failed") != std::string::npos || + s.find("rejected") != std::string::npos || + s.find("stopped") != std::string::npos) + return ImVec4(1.0f, 0.38f, 0.38f, 1.0f); // red + if (s.find("hashing") != std::string::npos || + s.find("accepted") != std::string::npos || + s.find("connected")!= std::string::npos) + return ImVec4(0.35f, 1.0f, 0.45f, 1.0f); // green + if (s.find("asking") != std::string::npos || + s.find("authoriz") != std::string::npos || + s.find("subscrib") != std::string::npos) + return ImVec4(1.0f, 0.75f, 0.25f, 1.0f); // amber + return ImVec4(0.55f, 0.80f, 1.0f, 1.0f); // blue-grey } static std::string PoolLabel(const BtfPoolAnnouncement& ann) { std::ostringstream o; - o << ann.poolName << " - " << std::fixed << std::setprecision(2) << ann.feePercent << "%"; + o << ann.poolName << " - " + << std::fixed << std::setprecision(2) << ann.feePercent << "%"; if (ann.connectedMiners > 0) o << " - " << ann.connectedMiners << " miners"; return o.str(); @@ -118,19 +122,47 @@ static void RefreshWallet() g_txRows.clear(); TRY_CRITICAL_BLOCK(cs_mapWallet) { - std::vector> vs; - for (auto& kv : mapWallet) vs.push_back({kv.second.GetTxTime(), kv.first}); - std::sort(vs.rbegin(), vs.rend()); + // Sort: unconfirmed first (depth 0), then by depth ascending (least + // deep = most recent). We can't use nTimeReceived because a node + // resync re-stamps all txs with the same time. Depth is always correct. + std::vector> vs; + for (auto& kv : mapWallet) + vs.push_back({kv.second.GetDepthInMainChain(), kv.second.GetHash()}); + std::sort(vs.begin(), vs.end(), + [](const std::pair& a, const std::pair& b) { + return a.first < b.first; // 0 (unconfirmed) first, then shallowest + }); for (auto& sv : vs) { auto mi = mapWallet.find(sv.second); if (mi == mapWallet.end()) continue; CWalletTx& wtx = mi->second; - int64 net = wtx.GetCredit() - wtx.GetDebit(); - int depth = wtx.GetDepthInMainChain(); + int depth = wtx.GetDepthInMainChain(); + int toMature = wtx.IsCoinBase() ? wtx.GetBlocksToMaturity() : 0; + // An orphaned coinbase has hashBlock set but depth=0 (block not in main chain). + // A genuinely unconfirmed tx has hashBlock==0. + bool isOrphaned = wtx.IsCoinBase() && wtx.hashBlock != 0 && depth == 0; + int64 net; std::string desc; - if (wtx.IsCoinBase()) desc = depth<1 ? "Generated (unconfirmed)" : "Generated"; - else if (wtx.GetDebit() > 0) desc = "Sent"; - else desc = "Received"; + if (wtx.IsCoinBase()) { + if (isOrphaned) { + net = 0; + desc = "Generated (orphaned -- block not in main chain)"; + } else if (toMature > 0) { + net = wtx.GetValueOut(); + char buf[64]; + snprintf(buf, sizeof(buf), "Generated (matures in %d blocks)", toMature); + desc = buf; + } else { + net = wtx.GetCredit() - wtx.GetDebit(); + desc = depth < 1 ? "Generated (unconfirmed)" : "Generated"; + } + } else if (wtx.GetDebit() > 0) { + net = wtx.GetCredit() - wtx.GetDebit(); + desc = "Sent"; + } else { + net = wtx.GetCredit() - wtx.GetDebit(); + desc = "Received"; + } g_txRows.push_back({DateTimeStr(wtx.GetTxTime()), desc, net, depth}); } } @@ -148,19 +180,104 @@ string DateTimeStr(int64 nTime) return buf; } +// --------------------------------------------------------------------------- +// Status bar -- separate fullscreen-width overlay pinned to bottom edge. +// Drawn last so it always sits on top of everything else. +// --------------------------------------------------------------------------- +static void DrawStatusBar() +{ + ImGuiIO& io = ImGui::GetIO(); + ImGui::SetNextWindowPos(ImVec2(0.0f, io.DisplaySize.y - STATUS_BAR_H)); + ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, STATUS_BAR_H)); + ImGui::SetNextWindowBgAlpha(1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f, 3.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.13f, 0.13f, 0.13f, 1.0f)); + ImGui::Begin("##statusbar", nullptr, + ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse| + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(); + + int connected = (int)vNodes.size(); + int discovered = GetDiscoveredPeerCount(); + + if (nMineMode == MINE_PARTICIPANT) { + uint64 sent = 0, accepted = 0; + double hashRate = 0.0; + std::string st = GetParticipantMiningStatus(); + GetParticipantMiningStats(sent, accepted, hashRate); + if (sent > 0 || accepted > 0 || hashRate > 0.0) + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d" + " Shares: %llu sent / %llu accepted %.2f H/s", + connected, discovered, nBestHeight, + (unsigned long long)sent, (unsigned long long)accepted, hashRate); + else if (!st.empty()) + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d %s", + connected, discovered, nBestHeight, st.c_str()); + else + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d", + connected, discovered, nBestHeight); + } else if (nMineMode == MINE_OPERATOR) { + int miners = 0, blocksFound = 0; + uint64 roundShares = 0; + double totalHashRate = 0.0; + GetPoolOperatorStats(miners, blocksFound, roundShares, totalHashRate); + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d" + " Miners: %d Round shares: %llu Blocks found: %d %.2f H/s", + connected, discovered, nBestHeight, + miners, (unsigned long long)roundShares, blocksFound, totalHashRate); + } else { + ImGui::TextDisabled( + "Connected peers: %d Detected peers: %d Height: %d", + connected, discovered, nBestHeight); + } + + ImGui::End(); +} + // --------------------------------------------------------------------------- // Main window +// +// Layout: +// [menu bar] -- ImGui built-in menu +// [header strip] -- address, balance, send button, mine status +// [separator] +// [telemetry strip] -- mode-specific live stats (never scrolls) +// [separator] +// [scrollable content child] -- transactions + mode panels +// +// The scrollable child ends at the top of the status bar so content never +// hides behind it. The child is explicitly sized to fill the remaining space. // --------------------------------------------------------------------------- static void DrawMainWindow() { ImGuiIO& io = ImGui::GetIO(); - ImGui::SetNextWindowPos(ImVec2(0,0)); - ImGui::SetNextWindowSize(io.DisplaySize); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12,12)); + + // Full display height minus the status bar at the bottom. + const float winH = io.DisplaySize.y - STATUS_BAR_H; + + ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, winH)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(12.0f, 12.0f)); ImGui::Begin("##main", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_MenuBar | - ImGuiWindowFlags_NoFocusOnAppearing); + ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_MenuBar | + ImGuiWindowFlags_NoBringToFrontOnFocus); ImGui::SetWindowFontScale(0.92f); ImGui::PopStyleVar(); @@ -170,179 +287,209 @@ static void DrawMainWindow() if (ImGui::MenuItem("Exit")) glfwSetWindowShouldClose(glfwGetCurrentContext(), true); ImGui::EndMenu(); } - if (ImGui::MenuItem("Options")) g_showOptions = true; - if (ImGui::MenuItem("About")) g_showAbout = true; + if (ImGui::MenuItem("Options")) g_showOptions = true; + if (ImGui::MenuItem("About")) g_showAbout = true; ImGui::EndMenuBar(); } - // ---- Address bar ---- + // ---- Header strip (never scrolls) ---- ImGui::Spacing(); ImGui::TextDisabled("Address:"); ImGui::SameLine(); - ImGui::SetNextItemWidth(-80); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.14f,0.14f,0.14f,1)); + ImGui::SetNextItemWidth(-80.0f); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.14f, 0.14f, 0.14f, 1.0f)); ImGui::InputText("##addr", (char*)g_myAddress.c_str(), g_myAddress.size()+1, ImGuiInputTextFlags_ReadOnly); ImGui::PopStyleColor(); ImGui::SameLine(); if (ImGui::Button("Copy##a")) ImGui::SetClipboardText(g_myAddress.c_str()); - // ---- Balance on its own line ---- ImGui::TextDisabled("Balance:"); ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.35f,1.0f,0.35f,1.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.35f, 1.0f, 0.35f, 1.0f)); ImGui::Text("%s BTF", FmtMoney(g_balance).c_str()); ImGui::PopStyleColor(); - // ---- Toolbar ---- ImGui::Spacing(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f,0.40f,0.15f,1)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f,0.55f,0.20f,1)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.40f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f, 0.55f, 0.20f, 1.0f)); if (ImGui::Button(" Send Coins ")) g_showSend = true; ImGui::PopStyleColor(2); - // Mining status indicator - ImGui::SameLine(0, 20); + ImGui::SameLine(0.0f, 20.0f); if (fGenerateBitcoins) { - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f,0.6f,0.0f,1.0f)); - const char* modeLabel = nMineMode==MINE_OPERATOR ? "Mining (operator)" : - nMineMode==MINE_PARTICIPANT ? "Mining (pool)" : "Mining"; - ImGui::Text("⬤ %s", modeLabel); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.6f, 0.0f, 1.0f)); + const char* modeLabel = + nMineMode == MINE_OPERATOR ? "Mining (operator)" : + nMineMode == MINE_PARTICIPANT ? "Mining (pool)" : + nMineMode == MINE_RELAY ? "Relay" : "Mining"; + ImGui::Text("* %s", modeLabel); ImGui::PopStyleColor(); if (nMineMode == MINE_PARTICIPANT) { - std::string mineStatus = GetParticipantMiningStatus(); - if (!mineStatus.empty()) { - ImGui::SameLine(0, 12); - ImGui::PushStyleColor(ImGuiCol_Text, ParticipantStatusColor(mineStatus)); - ImGui::Text("[%s]", mineStatus.c_str()); + std::string st = GetParticipantMiningStatus(); + if (!st.empty()) { + ImGui::SameLine(0.0f, 12.0f); + ImGui::PushStyleColor(ImGuiCol_Text, StatusColor(st)); + ImGui::Text("[%s]", st.c_str()); ImGui::PopStyleColor(); } } } else { - ImGui::TextDisabled("○ Not mining"); + ImGui::TextDisabled("Not mining"); } ImGui::Spacing(); ImGui::Separator(); - ImGui::Spacing(); - // ---- Live telemetry strip ---- + // ---- Telemetry strip (never scrolls) ---- { - const char* modeName = nMineMode==MINE_OPERATOR ? "Operator" : - nMineMode==MINE_PARTICIPANT ? "Participant" : "Solo"; - int64 age = g_lastWalletRefresh > 0 ? (GetTime() - g_lastWalletRefresh) : -1; - if (age < 0) age = 0; + ImGui::Spacing(); + const char* modeName = + nMineMode == MINE_OPERATOR ? "Operator" : + nMineMode == MINE_PARTICIPANT ? "Participant" : + nMineMode == MINE_RELAY ? "Relay" : "Solo"; + int64 age = g_lastWalletRefresh > 0 ? GetTime() - g_lastWalletRefresh : 0; ImGui::Text("Mode: %s", modeName); - ImGui::SameLine(0, 16); - ImGui::Text("Wallet refresh: %llds ago", (long long)age); + ImGui::SameLine(0.0f, 16.0f); + ImGui::TextDisabled("Wallet refreshed %llds ago", (long long)age); if (nMineMode == MINE_PARTICIPANT) { uint64 sent = 0, accepted = 0; double hashRate = 0.0; - std::string mineStatus = GetParticipantMiningStatus(); + std::string st = GetParticipantMiningStatus(); GetParticipantMiningStats(sent, accepted, hashRate); if (sent > 0 || accepted > 0 || hashRate > 0.0) { - double acceptPct = sent ? (100.0 * (double)accepted / (double)sent) : 0.0; - ImGui::TextDisabled("Your mining: sent %llu, accepted %llu (%.1f%%), %.2f H/s", - (unsigned long long)sent, - (unsigned long long)accepted, - acceptPct, - hashRate); - } else if (!mineStatus.empty()) { - ImGui::TextDisabled("Your mining: %s", mineStatus.c_str()); + double pct = sent ? (100.0 * (double)accepted / (double)sent) : 0.0; + ImGui::TextDisabled( + "Sent %llu shares, accepted %llu (%.1f%%), %.2f H/s", + (unsigned long long)sent, (unsigned long long)accepted, pct, hashRate); + } else if (!st.empty()) { + ImGui::TextDisabled("Status: %s", st.c_str()); } else { - ImGui::TextDisabled("Your mining: asking pool for work"); + ImGui::TextDisabled("Waiting for pool work..."); } } else if (nMineMode == MINE_OPERATOR) { int miners = 0, blocksFound = 0; uint64 roundShares = 0; - GetPoolOperatorStats(miners, blocksFound, roundShares); + double totalHashRate = 0.0; + GetPoolOperatorStats(miners, blocksFound, roundShares, totalHashRate); if (miners > 0 || blocksFound > 0 || roundShares > 0) - ImGui::TextDisabled("Pool runtime: %d authorized miners, %llu round shares, %d blocks found", miners, (unsigned long long)roundShares, blocksFound); + ImGui::TextDisabled( + "Authorized miners: %d Round shares: %llu Blocks found: %d %.2f H/s", + miners, (unsigned long long)roundShares, blocksFound, totalHashRate); else - ImGui::TextDisabled("Pool runtime: waiting for miners"); + ImGui::TextDisabled("Pool server running, waiting for miners"); + } else if (nMineMode == MINE_RELAY) { + ImGui::TextDisabled("Relay mode: syncing, not mining"); } else { - ImGui::TextDisabled("Solo runtime: local wallet mining telemetry"); + ImGui::TextDisabled("Solo mining"); } ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); } + // ---- Scrollable content child ---- + // Fills all remaining height in the window (cursor Y to bottom of window). + // Content that overflows scrolls; nothing leaks past the window edge. + // Size the scrollable child to fill exactly the remaining window height. + // GetContentRegionAvail() already accounts for padding/borders. + const float availH = ImGui::GetContentRegionAvail().y; + ImGui::BeginChild("##scroll", ImVec2(0.0f, availH), false, 0); + // ---- Transaction list ---- - float listHeight = io.DisplaySize.y * 0.26f; - if (listHeight < 190.0f) listHeight = 190.0f; - if (listHeight > 260.0f) listHeight = 260.0f; + float txH = io.DisplaySize.y * 0.26f; + if (txH < 180.0f) txH = 180.0f; + if (txH > 260.0f) txH = 260.0f; + if (ImGui::BeginTable("txlist", 4, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | - ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, - ImVec2(0, listHeight))) + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | + ImGuiTableFlags_SizingStretchProp, + ImVec2(0.0f, txH))) { ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 110); - ImGui::TableSetupColumn("Date", ImGuiTableColumnFlags_WidthFixed, 130); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Date", ImGuiTableColumnFlags_WidthFixed, 130.0f); ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Amount", ImGuiTableColumnFlags_WidthFixed, 140); + ImGui::TableSetupColumn("Amount", ImGuiTableColumnFlags_WidthFixed, 140.0f); ImGui::TableHeadersRow(); for (auto& row : g_txRows) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - if (row.depth < 1) - ImGui::TextColored(ImVec4(1,0.6f,0,1), "Unconfirmed"); + bool isImmatureCoinbase = (row.desc.find("matures in") != std::string::npos); + bool isOrphaned = (row.desc.find("orphaned") != std::string::npos); + if (isOrphaned) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Orphaned"); + else if (row.depth < 1) + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "Unconfirmed"); + else if (isImmatureCoinbase) + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), "Immature"); + else if (row.depth < 6) + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "%d confirm%s", + row.depth, row.depth == 1 ? "" : "s"); else - ImGui::Text("%d blocks deep", row.depth); + ImGui::TextColored(ImVec4(0.5f, 0.9f, 0.5f, 1.0f), "Confirmed"); ImGui::TableSetColumnIndex(1); ImGui::TextDisabled("%s", row.date.c_str()); ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(row.desc.c_str()); ImGui::TableSetColumnIndex(3); if (row.amount >= 0) - ImGui::TextColored(ImVec4(0.35f,1,0.35f,1), "+%s BTF", FmtMoney(row.amount).c_str()); + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), + "+%s BTF", FmtMoney(row.amount).c_str()); else - ImGui::TextColored(ImVec4(1,0.4f,0.4f,1), "%s BTF", FmtMoney(row.amount).c_str()); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "%s BTF", FmtMoney(row.amount).c_str()); } ImGui::EndTable(); } + // ---- Mode panels ---- std::vector pools; BtfGetPoolAnnouncements(pools); if (nMineMode == MINE_PARTICIPANT) { ImGui::Spacing(); ImGui::SeparatorText("Participant Mining"); - ImGui::Text("Selected pool:"); + + ImGui::TextDisabled("Selected pool:"); + ImGui::SameLine(); if (strParticipantPool.empty()) - ImGui::TextDisabled("No pool selected yet"); + ImGui::TextDisabled("none"); else ImGui::TextWrapped("%s", strParticipantPool.c_str()); - const BtfPoolAnnouncement* selected = NULL; + const BtfPoolAnnouncement* sel = nullptr; for (size_t i = 0; i < pools.size(); i++) - if (pools[i].btfAddress == strParticipantPool) { selected = &pools[i]; break; } - - if (selected) { - ImGui::Text("Pool name: %s", selected->poolName.c_str()); - ImGui::Text("Fee: %.2f%%", selected->feePercent); - ImGui::Text("Live stats: %d miners, %d blocks", selected->connectedMiners, selected->blocksFound); - if (!selected->dashboardUrl.empty()) - ImGui::TextWrapped("Dashboard: %s", selected->dashboardUrl.c_str()); + if (pools[i].btfAddress == strParticipantPool) { sel = &pools[i]; break; } + + if (sel) { + ImGui::Text("Pool: %s Fee: %.2f%% Miners: %d Blocks: %d %.2f H/s", + sel->poolName.c_str(), sel->feePercent, + sel->connectedMiners, sel->blocksFound, sel->hashRate); + if (!sel->dashboardUrl.empty()) + ImGui::TextDisabled("Dashboard: %s", sel->dashboardUrl.c_str()); } else if (!strParticipantPool.empty()) { - ImGui::TextDisabled("No fresh announcement found for this pool yet"); + ImGui::TextDisabled("No live announcement for this pool yet."); } if (!pools.empty()) { ImGui::Spacing(); ImGui::SeparatorText("Live Pools"); if (ImGui::BeginTable("livepools", 5, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Pool", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Fee", ImGuiTableColumnFlags_WidthFixed, 70); - ImGui::TableSetupColumn("Stats", ImGuiTableColumnFlags_WidthFixed, 160); + ImGui::TableSetupColumn("Pool", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Fee", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("Stats", ImGuiTableColumnFlags_WidthFixed, 150.0f); ImGui::TableSetupColumn("Dashboard", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Use", ImGuiTableColumnFlags_WidthFixed, 60); + ImGui::TableSetupColumn("Use", ImGuiTableColumnFlags_WidthFixed, 50.0f); ImGui::TableHeadersRow(); for (size_t i = 0; i < pools.size(); i++) { const BtfPoolAnnouncement& ann = pools[i]; @@ -353,15 +500,19 @@ static void DrawMainWindow() ImGui::TableSetColumnIndex(1); ImGui::Text("%.2f%%", ann.feePercent); ImGui::TableSetColumnIndex(2); - ImGui::Text("%d miners, %d blocks, %s old", ann.connectedMiners, ann.blocksFound, FmtAge(ann.createdAt).c_str()); + ImGui::Text("%d miners %d blocks %.2f H/s %s", + ann.connectedMiners, ann.blocksFound, ann.hashRate, + FmtAge(ann.createdAt).c_str()); ImGui::TableSetColumnIndex(3); if (!ann.dashboardUrl.empty()) ImGui::TextWrapped("%s", ann.dashboardUrl.c_str()); else ImGui::TextDisabled("-"); ImGui::TableSetColumnIndex(4); - if (ImGui::SmallButton((std::string("Use##") + ann.btfAddress).c_str())) { - strncpy(g_participantPool, ann.btfAddress.c_str(), sizeof(g_participantPool)-1); + std::string btnId = "Use##" + ann.btfAddress; + if (ImGui::SmallButton(btnId.c_str())) { + strncpy(g_participantPool, ann.btfAddress.c_str(), + sizeof(g_participantPool)-1); strParticipantPool = ann.btfAddress; CWalletDB().WriteSetting("strParticipantPool", strParticipantPool); g_needRefresh = true; @@ -375,55 +526,60 @@ static void DrawMainWindow() if (nMineMode == MINE_OPERATOR) { ImGui::Spacing(); ImGui::SeparatorText("Operator Overview"); - ImGui::Text("Pool .btf address:"); + std::string opAddr = BtfLocalAddress(); + ImGui::TextDisabled("Pool .btf address:"); + ImGui::SameLine(); if (opAddr.empty()) - ImGui::TextDisabled("Identity not ready yet"); + ImGui::TextDisabled("identity not ready"); else ImGui::TextWrapped("%s", opAddr.c_str()); + // Pending payouts std::vector owed; GetPendingPayouts(owed); ImGui::Spacing(); - ImGui::SeparatorText("Pending Pool Payouts (you owe miners)"); + ImGui::SeparatorText("Pending Payouts (owed to miners)"); if (owed.empty()) { - ImGui::TextDisabled("No pending pool payouts"); + ImGui::TextDisabled("No pending payouts"); } else if (ImGui::BeginTable("owedpayouts", 4, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Matures At", ImGuiTableColumnFlags_WidthFixed, 100); - ImGui::TableSetupColumn("Blocks Left", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Recipients", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Total", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Matures At", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Blocks left", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("Recipients", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("Total", ImGuiTableColumnFlags_WidthStretch); ImGui::TableHeadersRow(); for (const PendingPayoutView& row : owed) { - int remaining = std::max(0, row.matureAtHeight - nBestHeight); + int left = std::max(0, row.matureAtHeight - nBestHeight); ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::Text("%d", row.matureAtHeight); - ImGui::TableSetColumnIndex(1); - ImGui::Text("%d", remaining); - ImGui::TableSetColumnIndex(2); - ImGui::Text("%d", row.recipients); - ImGui::TableSetColumnIndex(3); - ImGui::Text("%s BTF", FmtMoney(row.totalAmount).c_str()); + ImGui::TableSetColumnIndex(0); ImGui::Text("%d", row.matureAtHeight); + ImGui::TableSetColumnIndex(1); ImGui::Text("%d", left); + ImGui::TableSetColumnIndex(2); ImGui::Text("%d", row.recipients); + ImGui::TableSetColumnIndex(3); ImGui::Text("%s BTF", FmtMoney(row.totalAmount).c_str()); } ImGui::EndTable(); } + // Workers std::vector workers; GetPoolWorkerStats(workers); ImGui::Spacing(); ImGui::SeparatorText("Current Round Workers"); if (workers.empty()) { - ImGui::TextDisabled("No active workers in this round"); - } else if (ImGui::BeginTable("workers", 4, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) + ImGui::TextDisabled("No active workers this round"); + } else if (ImGui::BeginTable("workers", 5, + ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("Payout Addr", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Worker", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Round Shares", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Last Seen", ImGuiTableColumnFlags_WidthFixed, 100); + ImGui::TableSetupColumn("Payout Address", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Worker", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Hashrate", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Round shares", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Last seen", ImGuiTableColumnFlags_WidthFixed, 90.0f); ImGui::TableHeadersRow(); for (const PoolWorkerStatView& row : workers) { ImGui::TableNextRow(); @@ -432,53 +588,19 @@ static void DrawMainWindow() ImGui::TableSetColumnIndex(1); ImGui::TextDisabled("%s", row.worker.empty() ? "worker" : row.worker.c_str()); ImGui::TableSetColumnIndex(2); - ImGui::Text("%llu", (unsigned long long)row.roundShares); + ImGui::Text("%.2f H/s", row.hashRate); ImGui::TableSetColumnIndex(3); - ImGui::TextDisabled("%s", FmtAge(row.lastSeen).c_str()); + ImGui::Text("%llu", (unsigned long long)row.roundShares); + ImGui::TableSetColumnIndex(4); + ImGui::TextDisabled("%s ago", FmtAge(row.lastSeen).c_str()); } ImGui::EndTable(); } } - // ---- Status bar ---- - { - ImGui::Spacing(); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4,2)); - - int peers = (int)vNodes.size(); - if (nMineMode == MINE_PARTICIPANT) { - uint64 sent = 0, accepted = 0; - double hashRate = 0.0; - std::string mineStatus = GetParticipantMiningStatus(); - GetParticipantMiningStats(sent, accepted, hashRate); - if (sent > 0 || accepted > 0 || hashRate > 0.0) - ImGui::TextDisabled("Peers %d height %d shares sent %llu accepted %llu hash rate %.2f H/s", - peers, - nBestHeight, - (unsigned long long)sent, - (unsigned long long)accepted, - hashRate); - else if (!mineStatus.empty()) - ImGui::TextDisabled("Peers %d height %d %s", peers, nBestHeight, mineStatus.c_str()); - else - ImGui::TextDisabled("Peers %d height %d asking pool for work", peers, nBestHeight); - } else if (nMineMode == MINE_OPERATOR) { - int miners = 0, blocksFound = 0; - uint64 roundShares = 0; - GetPoolOperatorStats(miners, blocksFound, roundShares); - if (miners > 0 || blocksFound > 0 || roundShares > 0) - ImGui::TextDisabled("Peers %d height %d authorized miners %d round shares %llu blocks found %d", - peers, nBestHeight, miners, (unsigned long long)roundShares, blocksFound); - else - ImGui::TextDisabled("Peers %d height %d waiting for miners", peers, nBestHeight); - } else { - ImGui::TextDisabled("Peers %d height %d", peers, nBestHeight); - } - ImGui::PopStyleVar(); - } - + ImGui::EndChild(); // ##scroll ImGui::SetWindowFontScale(1.0f); - ImGui::End(); + ImGui::End(); // ##main } // --------------------------------------------------------------------------- @@ -487,42 +609,53 @@ static void DrawMainWindow() static void DrawSendDialog() { if (!g_showSend) return; - ImGui::SetNextWindowSize(ImVec2(500, 180), ImGuiCond_Always); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(.5f,.5f)); + ImGui::SetNextWindowSize(ImVec2(500.0f, 180.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("Send Coins", &g_showSend, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { ImGui::Text("Recipient address:"); - ImGui::SetNextItemWidth(-1); + ImGui::SetNextItemWidth(-1.0f); ImGui::InputText("##sa", g_sendAddr, sizeof(g_sendAddr)); ImGui::Text("Amount (BTF):"); - ImGui::SetNextItemWidth(180); + ImGui::SetNextItemWidth(180.0f); ImGui::InputText("##sm", g_sendAmount, sizeof(g_sendAmount)); if (!g_sendStatus.empty()) { ImGui::Spacing(); - ImGui::TextColored(ImVec4(1,.4f,.4f,1), "%s", g_sendStatus.c_str()); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "%s", g_sendStatus.c_str()); } ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); - if (ImGui::Button("Send", ImVec2(90,0))) { + if (ImGui::Button("Send", ImVec2(90.0f, 0.0f))) { uint160 h; int64 nv = 0; - if (!AddressToHash160(g_sendAddr, h)) g_sendStatus = "Invalid address."; - else if (!ParseMoney(g_sendAmount,nv)||nv<=0) g_sendStatus = "Invalid amount."; + if (!AddressToHash160(g_sendAddr, h)) + g_sendStatus = "Invalid address."; + else if (!ParseMoney(g_sendAmount, nv) || nv <= 0) + g_sendStatus = "Invalid amount."; else { - CScript s; s << OP_DUP << OP_HASH160 << h << OP_EQUALVERIFY << OP_CHECKSIG; + CScript sc; + sc << OP_DUP << OP_HASH160 << h << OP_EQUALVERIFY << OP_CHECKSIG; CWalletTx wtx; - if (SendMoney(s, nv, wtx)) { - g_showSend=false; g_sendStatus=""; - memset(g_sendAddr,0,sizeof(g_sendAddr)); - memset(g_sendAmount,0,sizeof(g_sendAmount)); + if (SendMoney(sc, nv, wtx)) { + g_showSend = false; + g_sendStatus = ""; + memset(g_sendAddr, 0, sizeof(g_sendAddr)); + memset(g_sendAmount, 0, sizeof(g_sendAmount)); g_needRefresh = true; - } else g_sendStatus = "Transaction failed."; + } else { + g_sendStatus = "Transaction failed."; + } } } ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(90,0))) { g_showSend=false; g_sendStatus=""; } + if (ImGui::Button("Cancel", ImVec2(90.0f, 0.0f))) { + g_showSend = false; + g_sendStatus = ""; + } } ImGui::End(); } @@ -534,7 +667,6 @@ static void DrawOptionsDialog() { if (!g_showOptions) return; - // Always sync from current globals when dialog opens static bool wasOpen = false; if (!wasOpen) { g_mineRadio = nMineMode; @@ -544,42 +676,46 @@ static void DrawOptionsDialog() strncpy(g_poolDash, strPoolDashboardUrl.c_str(), sizeof(g_poolDash)-1); } wasOpen = true; - ImGui::SetNextWindowSize(ImVec2(500, 310), ImGuiCond_Always); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(.5f,.5f)); + + ImGui::SetNextWindowSize(ImVec2(500.0f, 335.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("Options", &g_showOptions, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { // Transaction fee ImGui::SeparatorText("Transaction Fee"); static char feeStr[32] = {}; - if (feeStr[0]=='\0') snprintf(feeStr,sizeof(feeStr),"%s",FmtMoney(nTransactionFee).c_str()); - ImGui::SetNextItemWidth(180); + if (feeStr[0] == '\0') + snprintf(feeStr, sizeof(feeStr), "%s", FmtMoney(nTransactionFee).c_str()); + ImGui::SetNextItemWidth(180.0f); ImGui::InputText("BTF per transaction##fee", feeStr, sizeof(feeStr)); - // Mining + // Mining mode ImGui::SeparatorText("Mining Mode"); - ImGui::RadioButton("Solo — rewards go to your wallet", &g_mineRadio, MINE_SOLO); - ImGui::RadioButton("Operator — run a pool for other miners", &g_mineRadio, MINE_OPERATOR); - ImGui::RadioButton("Participant — mine to someone's pool", &g_mineRadio, MINE_PARTICIPANT); + ImGui::RadioButton("Relay -- just run the node, don't mine", &g_mineRadio, MINE_RELAY); + ImGui::RadioButton("Solo -- rewards go to your wallet", &g_mineRadio, MINE_SOLO); + ImGui::RadioButton("Operator -- run a pool for other miners", &g_mineRadio, MINE_OPERATOR); + ImGui::RadioButton("Participant -- mine to someone's pool", &g_mineRadio, MINE_PARTICIPANT); if (g_mineRadio == MINE_OPERATOR) { ImGui::Spacing(); ImGui::SeparatorText("Pool Announcement"); ImGui::Text("Pool name:"); - ImGui::SetNextItemWidth(-1); + ImGui::SetNextItemWidth(-1.0f); ImGui::InputText("##poolname", g_poolName, sizeof(g_poolName)); ImGui::Text("Fee %%:"); - ImGui::SetNextItemWidth(120); + ImGui::SetNextItemWidth(120.0f); ImGui::InputText("##poolfee", g_poolFee, sizeof(g_poolFee)); ImGui::Text("Dashboard URL:"); - ImGui::SetNextItemWidth(-1); + ImGui::SetNextItemWidth(-1.0f); ImGui::InputText("##pooldash", g_poolDash, sizeof(g_poolDash)); } if (g_mineRadio == MINE_PARTICIPANT) { ImGui::Spacing(); ImGui::SeparatorText("Pool Selection"); - ImGui::TextDisabled("Pick from live discovered pools. Manual entry is disabled here."); + ImGui::TextDisabled("Pick from live discovered pools."); std::vector pools; BtfGetPoolAnnouncements(pools); @@ -591,46 +727,49 @@ static void DrawOptionsDialog() break; } } - if (ImGui::BeginCombo("Discovered pools", current.c_str())) { + if (ImGui::BeginCombo("Discovered pools##combo", current.c_str())) { for (size_t i = 0; i < pools.size(); i++) { const BtfPoolAnnouncement& ann = pools[i]; - std::string label = PoolLabel(ann); - bool selected = ann.btfAddress == g_participantPool; - if (ImGui::Selectable(label.c_str(), selected)) { - strncpy(g_participantPool, ann.btfAddress.c_str(), sizeof(g_participantPool)-1); - } + bool selected = (ann.btfAddress == g_participantPool); + if (ImGui::Selectable(PoolLabel(ann).c_str(), selected)) + strncpy(g_participantPool, ann.btfAddress.c_str(), + sizeof(g_participantPool)-1); } ImGui::EndCombo(); } } else { - ImGui::TextDisabled("No live pools discovered yet"); + ImGui::TextDisabled("No live pools discovered yet."); } } - ImGui::Spacing(); - const char* genLabel = fGenerateBitcoins ? "Stop Mining" : "Start Mining"; - if (ImGui::Button(genLabel, ImVec2(130,0))) { - fGenerateBitcoins = fGenerateBitcoins ? 0 : 1; - if (fGenerateBitcoins) _beginthread(ThreadBitcoinMiner, 0, NULL); - CWalletDB().WriteSetting("fGenerateBitcoins", fGenerateBitcoins); - } - ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); - if (ImGui::Button("OK", ImVec2(90,0))) { - int64 fee=0; - if (ParseMoney(feeStr,fee)) { nTransactionFee=fee; CWalletDB().WriteSetting("nTransactionFee",nTransactionFee); } - - bool changed = (g_mineRadio != nMineMode); - nMineMode = g_mineRadio; - strParticipantPool = g_participantPool; - strPoolName = g_poolName[0] ? g_poolName : "Bitflash Pool"; - strPoolDashboardUrl = g_poolDash; - dPoolFeePercent = atof(g_poolFee); - CWalletDB().WriteSetting("nMineMode", nMineMode); - CWalletDB().WriteSetting("strParticipantPool", strParticipantPool); - CWalletDB().WriteSetting("strPoolName", strPoolName); - CWalletDB().WriteSetting("strPoolDashboardUrl", strPoolDashboardUrl); - CWalletDB().WriteSetting("dPoolFeePercent", dPoolFeePercent); + if (ImGui::Button("OK", ImVec2(90.0f, 0.0f))) { + int64 fee = 0; + if (ParseMoney(feeStr, fee)) { + nTransactionFee = fee; + CWalletDB().WriteSetting("nTransactionFee", nTransactionFee); + } + + bool poolChanged = + nMineMode != g_mineRadio || + strPoolName != (g_poolName[0] ? g_poolName : "Bitflash Pool") || + strPoolDashboardUrl != g_poolDash || + dPoolFeePercent != atof(g_poolFee); + + nMineMode = g_mineRadio; + strParticipantPool = g_participantPool; + strPoolName = g_poolName[0] ? g_poolName : "Bitflash Pool"; + strPoolDashboardUrl = g_poolDash; + dPoolFeePercent = atof(g_poolFee); + + if (nMineMode == MINE_OPERATOR && poolChanged) gAnnounceNow = true; + + if (nMineMode == MINE_RELAY) { + fGenerateBitcoins = 0; + } else { + fGenerateBitcoins = 1; + if (!vfThreadRunning[3]) _beginthread(ThreadBitcoinMiner, 0, NULL); + } if (nMineMode == MINE_OPERATOR) { if (!gPoolServerRunning) { @@ -645,7 +784,7 @@ static void DrawOptionsDialog() g_showOptions = false; } ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(90,0))) g_showOptions = false; + if (ImGui::Button("Cancel", ImVec2(90.0f, 0.0f))) g_showOptions = false; } if (!g_showOptions) wasOpen = false; ImGui::End(); @@ -657,18 +796,21 @@ static void DrawOptionsDialog() static void DrawAboutDialog() { if (!g_showAbout) return; - ImGui::SetNextWindowSize(ImVec2(380, 170), ImGuiCond_Always); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(.5f,.5f)); + ImGui::SetNextWindowSize(ImVec2(380.0f, 170.0f), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Always, ImVec2(0.5f, 0.5f)); if (ImGui::Begin("About Bitflash", &g_showAbout, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse)) { - ImGui::TextColored(ImVec4(1,.6f,0,1), "Bitflash BTF v1.1.0"); + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "Bitflash BTF v1.1.0"); ImGui::Spacing(); - ImGui::TextWrapped("CPU-only cryptocurrency. RandomX proof of work. Anonymous .btf addressing over Nostr. No premine."); + ImGui::TextWrapped( + "CPU-only cryptocurrency. RandomX proof of work. " + "Anonymous .btf addressing over Nostr. No premine."); ImGui::Spacing(); ImGui::TextDisabled("Based on Bitcoin 0.1.0 (Satoshi Nakamoto, 2009)"); ImGui::Spacing(); - if (ImGui::Button("OK", ImVec2(90,0))) g_showAbout = false; + if (ImGui::Button("OK", ImVec2(90.0f, 0.0f))) g_showAbout = false; } ImGui::End(); } @@ -705,34 +847,33 @@ int RunGUI(int argc, char* argv[]) ImGuiIO& io = ImGui::GetIO(); io.IniFilename = nullptr; - // Load Roboto at 16px — clean, readable on all platforms io.Fonts->AddFontFromMemoryCompressedTTF( - RobotoMedium_compressed_data, RobotoMedium_compressed_size, 16.0f); + RobotoMedium_compressed_data, RobotoMedium_compressed_size, 14.0f); ImGui::StyleColorsDark(); - ImGuiStyle& s = ImGui::GetStyle(); - s.WindowRounding = s.FrameRounding = s.PopupRounding = 4.0f; - s.ItemSpacing = ImVec2(10, 7); - s.FramePadding = ImVec2(8, 5); - s.WindowPadding = ImVec2(12, 12); + ImGuiStyle& st = ImGui::GetStyle(); + st.WindowRounding = st.FrameRounding = st.PopupRounding = 4.0f; + st.ItemSpacing = ImVec2(10.0f, 7.0f); + st.FramePadding = ImVec2(8.0f, 5.0f); + st.WindowPadding = ImVec2(12.0f, 12.0f); // Orange accent - s.Colors[ImGuiCol_TitleBgActive] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_CheckMark] = ImVec4(1.0f,.60f,.00f,1); - s.Colors[ImGuiCol_SliderGrab] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_Button] = ImVec4(.25f,.25f,.25f,1); - s.Colors[ImGuiCol_ButtonHovered] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_ButtonActive] = ImVec4(.60f,.30f,.00f,1); - s.Colors[ImGuiCol_Header] = ImVec4(.80f,.45f,.00f,.40f); - s.Colors[ImGuiCol_HeaderHovered] = ImVec4(.80f,.45f,.20f,.80f); - s.Colors[ImGuiCol_Tab] = ImVec4(.20f,.20f,.20f,1); - s.Colors[ImGuiCol_TabHovered] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_TabActive] = ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_SeparatorHovered]=ImVec4(.80f,.45f,.00f,1); - s.Colors[ImGuiCol_FrameBg] = ImVec4(.14f,.14f,.14f,1); - s.Colors[ImGuiCol_FrameBgHovered] = ImVec4(.20f,.20f,.20f,1); - s.Colors[ImGuiCol_TableHeaderBg] = ImVec4(.18f,.18f,.18f,1); - s.Colors[ImGuiCol_TableRowBg] = ImVec4(.11f,.11f,.11f,1); - s.Colors[ImGuiCol_TableRowBgAlt] = ImVec4(.14f,.14f,.14f,1); + st.Colors[ImGuiCol_TitleBgActive] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_CheckMark] = ImVec4(1.0f, .60f, .00f, 1.0f); + st.Colors[ImGuiCol_SliderGrab] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_Button] = ImVec4(.25f, .25f, .25f, 1.0f); + st.Colors[ImGuiCol_ButtonHovered] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_ButtonActive] = ImVec4(.60f, .30f, .00f, 1.0f); + st.Colors[ImGuiCol_Header] = ImVec4(.80f, .45f, .00f, .40f); + st.Colors[ImGuiCol_HeaderHovered] = ImVec4(.80f, .45f, .20f, .80f); + st.Colors[ImGuiCol_Tab] = ImVec4(.20f, .20f, .20f, 1.0f); + st.Colors[ImGuiCol_TabHovered] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_TabActive] = ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_SeparatorHovered]= ImVec4(.80f, .45f, .00f, 1.0f); + st.Colors[ImGuiCol_FrameBg] = ImVec4(.14f, .14f, .14f, 1.0f); + st.Colors[ImGuiCol_FrameBgHovered] = ImVec4(.20f, .20f, .20f, 1.0f); + st.Colors[ImGuiCol_TableHeaderBg] = ImVec4(.18f, .18f, .18f, 1.0f); + st.Colors[ImGuiCol_TableRowBg] = ImVec4(.11f, .11f, .11f, 1.0f); + st.Colors[ImGuiCol_TableRowBgAlt] = ImVec4(.14f, .14f, .14f, 1.0f); ImGui_ImplGlfw_InitForOpenGL(window, true); ImGui_ImplOpenGL3_Init("#version 330"); @@ -744,22 +885,28 @@ int RunGUI(int argc, char* argv[]) while (!glfwWindowShouldClose(window) && !fShutdown) { glfwPollEvents(); - if (g_needRefresh || (frame % 120 == 0)) { RefreshWallet(); g_needRefresh = false; } + if (g_needRefresh || (frame % 120 == 0)) { + RefreshWallet(); + g_needRefresh = false; + } frame++; ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); + // Draw order matters: status bar last so it is always on top. DrawMainWindow(); DrawSendDialog(); DrawOptionsDialog(); DrawAboutDialog(); + DrawStatusBar(); ImGui::Render(); - int w, h; glfwGetFramebufferSize(window, &w, &h); + int w, h; + glfwGetFramebufferSize(window, &w, &h); glViewport(0, 0, w, h); - glClearColor(.08f,.08f,.08f,1); + glClearColor(.08f, .08f, .08f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); glfwSwapBuffers(window); diff --git a/src/headers.h b/src/headers.h index bb252c7..367e9b0 100644 --- a/src/headers.h +++ b/src/headers.h @@ -1,4 +1,4 @@ -// Bitflash — main include file. +// Bitflash -- main include file. // Includes core node headers. GUI is handled separately in gui.cpp. #pragma once #include "headers_core.h" diff --git a/src/headers_core.h b/src/headers_core.h index 089b67f..6c0c602 100644 --- a/src/headers_core.h +++ b/src/headers_core.h @@ -1,4 +1,4 @@ -// Core node headers — no GUI framework dependency. +// Core node headers -- no GUI framework dependency. // Included by gui.cpp and all non-GUI source files. #ifdef _MSC_VER @@ -63,12 +63,11 @@ inline typename std::common_type::type max(const T1& a, const T2& b) #include "script.h" #include "db.h" #include "net.h" -#include "irc.h" #include "nostr.h" #include "randomx_pow.h" #include "main.h" #include "market.h" -// GUI callback — implemented in gui.cpp (or as no-op in headless builds) +// GUI callback -- implemented in gui.cpp (or as no-op in headless builds) void MainFrameRepaint(); string DateTimeStr(int64 nTime); diff --git a/src/irc.cpp b/src/irc.cpp deleted file mode 100644 index ff82c66..0000000 --- a/src/irc.cpp +++ /dev/null @@ -1,271 +0,0 @@ -// Copyright (c) 2009 Satoshi Nakamoto -// Distributed under the MIT/X11 software license, see the accompanying -// file license.txt or http://www.opensource.org/licenses/mit-license.php. - -#include "headers.h" - - - - -#pragma pack(1) -struct ircaddr -{ - int ip; - short port; -}; - -string EncodeAddress(const CAddress& addr) -{ - struct ircaddr tmp; - tmp.ip = addr.ip; - tmp.port = addr.port; - - vector vch(UBEGIN(tmp), UEND(tmp)); - return string("u") + EncodeBase58Check(vch); -} - -bool DecodeAddress(string str, CAddress& addr) -{ - vector vch; - if (!DecodeBase58Check(str.substr(1), vch)) - return false; - - struct ircaddr tmp; - if (vch.size() != sizeof(tmp)) - return false; - memcpy(&tmp, &vch[0], sizeof(tmp)); - - addr = CAddress(tmp.ip, tmp.port); - return true; -} - - - - - - -static bool Send(SOCKET hSocket, const char* pszSend) -{ - if (strstr(pszSend, "PONG") != pszSend) - printf("SENDING: %s\n", pszSend); - const char* psz = pszSend; - const char* pszEnd = psz + strlen(psz); - while (psz < pszEnd) - { - int ret = send(hSocket, psz, pszEnd - psz, 0); - if (ret < 0) - return false; - psz += ret; - } - return true; -} - -bool RecvLine(SOCKET hSocket, string& strLine) -{ - strLine = ""; - loop - { - char c; - int nBytes = recv(hSocket, &c, 1, 0); - if (nBytes > 0) - { - if (c == '\n') - continue; - if (c == '\r') - return true; - strLine += c; - } - else if (nBytes <= 0) - { - if (!strLine.empty()) - return true; - // socket closed - printf("IRC socket closed\n"); - return false; - } - else - { - // socket error - int nErr = WSAGetLastError(); - if (nErr != WSAEMSGSIZE && nErr != WSAEINTR && nErr != WSAEINPROGRESS) - { - printf("IRC recv failed: %d\n", nErr); - return false; - } - } - } -} - -bool RecvLineIRC(SOCKET hSocket, string& strLine) -{ - loop - { - bool fRet = RecvLine(hSocket, strLine); - if (fRet) - { - if (fShutdown) - return false; - vector vWords; - ParseString(strLine, ' ', vWords); - if (vWords[0] == "PING") - { - strLine[1] = 'O'; - strLine += '\r'; - Send(hSocket, strLine.c_str()); - continue; - } - } - return fRet; - } -} - -bool RecvUntil(SOCKET hSocket, const char* psz1, const char* psz2=NULL, const char* psz3=NULL) -{ - loop - { - string strLine; - if (!RecvLineIRC(hSocket, strLine)) - return false; - printf("IRC %s\n", strLine.c_str()); - if (psz1 && strLine.find(psz1) != -1) - return true; - if (psz2 && strLine.find(psz2) != -1) - return true; - if (psz3 && strLine.find(psz3) != -1) - return true; - } -} - - - - -bool fRestartIRCSeed = false; - -void ThreadIRCSeed(void* parg) -{ - // IRC peer discovery disabled: the 2009 IRC network no longer exists (and - // what answers today crashes the parser). Replaced by Nostr relay - // discovery. - printf("ThreadIRCSeed: IRC peer discovery disabled (using Nostr)\n"); - return; - - loop - { - struct hostent* phostent = gethostbyname("chat.freenode.net"); - CAddress addrConnect(*(u_long*)phostent->h_addr_list[0], htons(6667)); - - SOCKET hSocket; - if (!ConnectSocket(addrConnect, hSocket)) - { - printf("IRC connect failed\n"); - return; - } - - if (!RecvUntil(hSocket, "Found your hostname", "using your IP address instead", "Couldn't look up your hostname")) - { - closesocket(hSocket); - return; - } - - string strMyName = EncodeAddress(addrLocalHost); - - if (!addrLocalHost.IsRoutable()) - strMyName = strprintf("x%u", GetRand(1000000000)); - - Send(hSocket, strprintf("NICK %s\r", strMyName.c_str()).c_str()); - Send(hSocket, strprintf("USER %s 8 * : %s\r", strMyName.c_str(), strMyName.c_str()).c_str()); - - if (!RecvUntil(hSocket, " 004 ")) - { - closesocket(hSocket); - return; - } - Sleep(500); - - Send(hSocket, "JOIN #bitcoin\r"); - Send(hSocket, "WHO #bitcoin\r"); - - while (!fRestartIRCSeed) - { - string strLine; - if (fShutdown || !RecvLineIRC(hSocket, strLine)) - { - closesocket(hSocket); - return; - } - if (strLine.empty() || strLine[0] != ':') - continue; - printf("IRC %s\n", strLine.c_str()); - - vector vWords; - ParseString(strLine, ' ', vWords); - if (vWords.size() < 2) - continue; - - char pszName[10000]; - pszName[0] = '\0'; - - if (vWords[1] == "352" && vWords.size() >= 8) - { - // index 7 is limited to 16 characters - // could get full length name at index 10, but would be different from join messages - strcpy(pszName, vWords[7].c_str()); - printf("GOT WHO: [%s] ", pszName); - } - - if (vWords[1] == "JOIN") - { - // :username!username@50000007.F000000B.90000002.IP JOIN :#channelname - strcpy(pszName, vWords[0].c_str() + 1); - if (strchr(pszName, '!')) - *strchr(pszName, '!') = '\0'; - printf("GOT JOIN: [%s] ", pszName); - } - - if (pszName[0] == 'u') - { - CAddress addr; - if (DecodeAddress(pszName, addr)) - { - CAddrDB addrdb; - if (AddAddress(addrdb, addr)) - printf("new "); - addr.print(); - } - else - { - printf("decode failed\n"); - } - } - } - - fRestartIRCSeed = false; - closesocket(hSocket); - } -} - - - - - - - - - - -#ifdef TEST -int main(int argc, char *argv[]) -{ - WSADATA wsadata; - if (WSAStartup(MAKEWORD(2,2), &wsadata) != NO_ERROR) - { - printf("Error at WSAStartup()\n"); - return false; - } - - ThreadIRCSeed(NULL); - - WSACleanup(); - return 0; -} -#endif diff --git a/src/irc.h b/src/irc.h deleted file mode 100644 index 1ae4ffc..0000000 --- a/src/irc.h +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2009 Satoshi Nakamoto -// Distributed under the MIT/X11 software license, see the accompanying -// file license.txt or http://www.opensource.org/licenses/mit-license.php. - -extern bool RecvLine(SOCKET hSocket, string& strLine); -extern void ThreadIRCSeed(void* parg); -extern bool fRestartIRCSeed; diff --git a/src/main.cpp b/src/main.cpp index f3de4d3..aff7bab 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include "sha.h" #include #include +#include #include "btfaddr.h" #include "btftunnel.h" #ifndef _WIN32 @@ -73,7 +74,7 @@ bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local t int fGenerateBitcoins; int64 nTransactionFee = 0; CAddress addrIncoming; -int nMineMode = MINE_SOLO; +int nMineMode = MINE_RELAY; string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; @@ -88,11 +89,10 @@ static std::string gParticipantStatus; void SetParticipantMiningStatus(const std::string& status) { + // Updates the GUI status string only. Logging is done at each call site + // with full context; duplicating it here just adds noise to the log. std::lock_guard lk(gParticipantStatusMutex); - if (gParticipantStatus == status) - return; gParticipantStatus = status; - printf("PoolParticipantMiner: status -> %s\n", status.c_str()); } std::string GetParticipantMiningStatus() @@ -159,7 +159,7 @@ bool AddToWallet(const CWalletTx& wtxIn) wtx.nTimeReceived = GetAdjustedTime(); //// debug print - printf("AddToWallet %s %s\n", wtxIn.GetHash().ToString().substr(0,6).c_str(), fInsertedNew ? "new" : "update"); + if (LogAcceptsCategory("net")) printf("AddToWallet %s %s\n", wtxIn.GetHash().ToString().substr(0,6).c_str(), fInsertedNew ? "new" : "update"); if (!fInsertedNew) { @@ -372,7 +372,7 @@ int CMerkleTx::SetMerkleBranch(const CBlock* pblock) { vMerkleBranch.clear(); nIndex = -1; - printf("ERROR: SetMerkleBranch() : couldn't find tx in block\n"); + if (LogAcceptsCategory("net")) printf("ERROR: SetMerkleBranch() : couldn't find tx in block\n"); return 0; } @@ -433,7 +433,7 @@ void CWalletTx::AddSupportingTransactions(CTxDB& txdb) } else { - printf("ERROR: AddSupportingTransactions() : unsupported transaction\n"); + if (LogAcceptsCategory("net")) printf("ERROR: AddSupportingTransactions() : unsupported transaction\n"); continue; } @@ -519,7 +519,7 @@ bool CTransaction::AcceptTransaction(CTxDB& txdb, bool fCheckInputs, bool* pfMis { if (ptxOld) { - printf("mapTransaction.erase(%s) replacing with new version\n", ptxOld->GetHash().ToString().c_str()); + if (LogAcceptsCategory("net")) printf("mapTransaction.erase(%s) replacing with new version\n", ptxOld->GetHash().ToString().c_str()); mapTransactions.erase(ptxOld->GetHash()); } AddToMemoryPool(); @@ -530,7 +530,7 @@ bool CTransaction::AcceptTransaction(CTxDB& txdb, bool fCheckInputs, bool* pfMis if (ptxOld) EraseFromWallet(ptxOld->GetHash()); - printf("AcceptTransaction(): accepted %s\n", hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("AcceptTransaction(): accepted %s\n", hash.ToString().substr(0,6).c_str()); return true; } @@ -669,7 +669,7 @@ void CWalletTx::RelayWalletTransaction(CTxDB& txdb) uint256 hash = GetHash(); if (!txdb.ContainsTx(hash)) { - printf("Relaying wtx %s\n", hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("Relaying wtx %s\n", hash.ToString().substr(0,6).c_str()); RelayMessage(CInv(MSG_TX, hash), (CTransaction)*this); } } @@ -683,7 +683,7 @@ void RelayWalletTransactions() nLastTime = GetTime(); // Rebroadcast any of our txes that aren't in a block yet - printf("RelayWalletTransactions()\n"); + if (LogAcceptsCategory("net")) printf("RelayWalletTransactions()\n"); CTxDB txdb("r"); CRITICAL_BLOCK(cs_mapWallet) { @@ -764,7 +764,7 @@ unsigned int GetNextWorkRequired(const CBlockIndex* pindexLast) // Limit adjustment step unsigned int nActualTimespan = pindexLast->nTime - pindexFirst->nTime; - printf(" nActualTimespan = %d before bounds\n", nActualTimespan); + if (LogAcceptsCategory("net")) printf(" nActualTimespan = %d before bounds\n", nActualTimespan); if (nActualTimespan < nTargetTimespan/4) nActualTimespan = nTargetTimespan/4; if (nActualTimespan > nTargetTimespan*4) @@ -780,10 +780,7 @@ unsigned int GetNextWorkRequired(const CBlockIndex* pindexLast) bnNew = bnProofOfWorkLimit; /// debug print - printf("\n\n\nGetNextWorkRequired RETARGET *****\n"); - printf("nTargetTimespan = %d nActualTimespan = %d\n", nTargetTimespan, nActualTimespan); - printf("Before: %08x %s\n", pindexLast->nBits, CBigNum().SetCompact(pindexLast->nBits).getuint256().ToString().c_str()); - printf("After: %08x %s\n", bnNew.GetCompact(), bnNew.getuint256().ToString().c_str()); + if (LogAcceptsCategory("net")) { printf("\n\n\nGetNextWorkRequired RETARGET *****\n"); printf("nTargetTimespan = %d nActualTimespan = %d\n", nTargetTimespan, nActualTimespan); printf("Before: %08x %s\n", pindexLast->nBits, CBigNum().SetCompact(pindexLast->nBits).getuint256().ToString().c_str()); printf("After: %08x %s\n", bnNew.GetCompact(), bnNew.getuint256().ToString().c_str()); } return bnNew.GetCompact(); } @@ -1037,7 +1034,7 @@ bool CBlock::ConnectBlock(CTxDB& txdb, CBlockIndex* pindex) bool Reorganize(CTxDB& txdb, CBlockIndex* pindexNew) { - printf("*** REORGANIZE ***\n"); + LogPrint("net", "*** REORGANIZE ***\n"); // Find the fork CBlockIndex* pfork = pindexBest; @@ -1712,7 +1709,7 @@ bool ProcessMessages(CNode* pfrom) CDataStream& vRecv = pfrom->vRecv; if (vRecv.empty()) return true; - printf("ProcessMessages(%d bytes)\n", vRecv.size()); + LogPrint("net", "ProcessMessages(%d bytes)\n", vRecv.size()); // // Message format @@ -1730,13 +1727,13 @@ bool ProcessMessages(CNode* pfrom) { if (vRecv.size() > sizeof(CMessageHeader)) { - printf("\n\nPROCESSMESSAGE MESSAGESTART NOT FOUND\n\n"); + if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE MESSAGESTART NOT FOUND\n\n"); vRecv.erase(vRecv.begin(), vRecv.end() - sizeof(CMessageHeader)); } break; } if (pstart - vRecv.begin() > 0) - printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", pstart - vRecv.begin()); + if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE SKIPPED %d BYTES\n\n", pstart - vRecv.begin()); vRecv.erase(vRecv.begin(), pstart); // Read header @@ -1744,7 +1741,7 @@ bool ProcessMessages(CNode* pfrom) vRecv >> hdr; if (!hdr.IsValid()) { - printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str()); + if (LogAcceptsCategory("net")) printf("\n\nPROCESSMESSAGE: ERRORS IN HEADER %s\n\n\n", hdr.GetCommand().c_str()); continue; } string strCommand = hdr.GetCommand(); @@ -1755,7 +1752,7 @@ bool ProcessMessages(CNode* pfrom) { // Rewind and wait for rest of message ///// need a mechanism to give up waiting for overlong message size error - printf("MESSAGE-BREAK 2\n"); + if (LogAcceptsCategory("net")) printf("MESSAGE-BREAK 2\n"); vRecv.insert(vRecv.begin(), BEGIN(hdr), END(hdr)); Sleep(100); break; @@ -1776,7 +1773,7 @@ bool ProcessMessages(CNode* pfrom) } CATCH_PRINT_EXCEPTION("ProcessMessage()") if (!fRet) - printf("ProcessMessage(%s, %d bytes) from %s to %s FAILED\n", strCommand.c_str(), nMessageSize, pfrom->addr.ToString().c_str(), addrLocalHost.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("ProcessMessage(%s, %d bytes) from %s to %s FAILED\n", strCommand.c_str(), nMessageSize, pfrom->addr.ToString().c_str(), addrLocalHost.ToString().c_str()); } vRecv.Compact(); @@ -1789,13 +1786,10 @@ bool ProcessMessages(CNode* pfrom) bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) { static map > mapReuseKey; - printf("received: %-12s (%d bytes) ", strCommand.c_str(), vRecv.size()); - for (int i = 0; i < min(vRecv.size(), (unsigned int)25); i++) - printf("%02x ", vRecv[i] & 0xff); - printf("\n"); + if (LogAcceptsCategory("net")) { printf("received: %-12s (%d bytes) ", strCommand.c_str(), vRecv.size()); for (int i = 0; i < min(vRecv.size(), (unsigned int)25); i++) printf("%02x ", vRecv[i] & 0xff); printf("\n"); } if (nDropMessagesTest > 0 && GetRand(nDropMessagesTest) == 0) { - printf("dropmessages DROPPING RECV MESSAGE\n"); + if (LogAcceptsCategory("net")) printf("dropmessages DROPPING RECV MESSAGE\n"); return true; } @@ -1833,7 +1827,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->PushMessage("getblocks", CBlockLocator(pindexBest), uint256(0)); } - printf("version addrMe = %s\n", addrMe.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("version addrMe = %s\n", addrMe.ToString().c_str()); } @@ -1844,30 +1838,6 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } - else if (strCommand == "addr") - { - vector vAddr; - vRecv >> vAddr; - - // Store the new addresses - CAddrDB addrdb; - foreach(const CAddress& addr, vAddr) - { - if (fShutdown) - return true; - if (AddAddress(addrdb, addr)) - { - // Put on lists to send to other nodes - pfrom->setAddrKnown.insert(addr); - CRITICAL_BLOCK(cs_vNodes) - foreach(CNode* pnode, vNodes) - if (!pnode->setAddrKnown.count(addr)) - pnode->vAddrToSend.push_back(addr); - } - } - } - - else if (strCommand == "inv") { vector vInv; @@ -1881,7 +1851,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->AddInventoryKnown(inv); bool fAlreadyHave = AlreadyHave(txdb, inv); - printf(" got inventory: %s %s\n", inv.ToString().c_str(), fAlreadyHave ? "have" : "new"); + if (LogAcceptsCategory("net")) printf(" got inventory: %s %s\n", inv.ToString().c_str(), fAlreadyHave ? "have" : "new"); if (!fAlreadyHave) pfrom->AskFor(inv); @@ -1900,7 +1870,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) { if (fShutdown) return true; - printf("received getdata for: %s\n", inv.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("received getdata for: %s\n", inv.ToString().c_str()); if (inv.type == MSG_BLOCK) { @@ -1940,12 +1910,12 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) // Send the rest of the chain if (pindex) pindex = pindex->pnext; - printf("getblocks %d to %s\n", (pindex ? pindex->nHeight : -1), hashStop.ToString().substr(0,14).c_str()); + if (LogAcceptsCategory("net")) printf("getblocks %d to %s\n", (pindex ? pindex->nHeight : -1), hashStop.ToString().substr(0,14).c_str()); for (; pindex; pindex = pindex->pnext) { if (pindex->GetBlockHash() == hashStop) { - printf(" getblocks stopping at %d %s\n", pindex->nHeight, pindex->GetBlockHash().ToString().substr(0,14).c_str()); + if (LogAcceptsCategory("net")) printf(" getblocks stopping at %d %s\n", pindex->nHeight, pindex->GetBlockHash().ToString().substr(0,14).c_str()); break; } @@ -1997,7 +1967,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) if (tx.AcceptTransaction(true)) { - printf(" accepted orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf(" accepted orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); AddToWalletIfMine(tx, NULL); RelayMessage(inv, vMsg); mapAlreadyAskedFor.erase(inv); @@ -2011,7 +1981,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } else if (fMissingInputs) { - printf("storing orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("storing orphan tx %s\n", inv.hash.ToString().substr(0,6).c_str()); AddOrphanTx(vMsg); } } @@ -2041,7 +2011,7 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) vRecv >> *pblock; //// debug print - printf("received block:\n"); pblock->print(); + if (LogAcceptsCategory("net")) { printf("received block:\n"); pblock->print(); } CInv inv(MSG_BLOCK, pblock->GetHash()); pfrom->AddInventoryKnown(inv); @@ -2051,25 +2021,6 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } - else if (strCommand == "getaddr") - { - pfrom->vAddrToSend.clear(); - //// need to expand the time range if not enough found - int64 nSince = GetAdjustedTime() - 60 * 60; // in the last hour - CRITICAL_BLOCK(cs_mapAddresses) - { - foreach(const PAIRTYPE(vector, CAddress)& item, mapAddresses) - { - if (fShutdown) - return true; - const CAddress& addr = item.second; - if (addr.nTime > nSince) - pfrom->vAddrToSend.push_back(addr); - } - } - } - - else if (strCommand == "checkorder") { uint256 hashReply; @@ -2134,12 +2085,12 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) else { // Ignore unknown commands for extensibility - printf("ProcessMessage(%s) : Ignored unknown message\n", strCommand.c_str()); + if (LogAcceptsCategory("net")) printf("ProcessMessage(%s) : Ignored unknown message\n", strCommand.c_str()); } if (!vRecv.empty()) - printf("ProcessMessage(%s) : %d extra bytes\n", strCommand.c_str(), vRecv.size()); + if (LogAcceptsCategory("net")) printf("ProcessMessage(%s) : %d extra bytes\n", strCommand.c_str(), vRecv.size()); return true; } @@ -2162,19 +2113,6 @@ bool SendMessages(CNode* pto) return true; - // - // Message: addr - // - vector vAddrToSend; - vAddrToSend.reserve(pto->vAddrToSend.size()); - foreach(const CAddress& addr, pto->vAddrToSend) - if (!pto->setAddrKnown.count(addr)) - vAddrToSend.push_back(addr); - pto->vAddrToSend.clear(); - if (!vAddrToSend.empty()) - pto->PushMessage("addr", vAddrToSend); - - // // Message: inventory // @@ -2204,7 +2142,7 @@ bool SendMessages(CNode* pto) while (!pto->mapAskFor.empty() && (*pto->mapAskFor.begin()).first <= nNow) { const CInv& inv = (*pto->mapAskFor.begin()).second; - printf("sending getdata: %s\n", inv.ToString().c_str()); + if (LogAcceptsCategory("net")) printf("sending getdata: %s\n", inv.ToString().c_str()); if (!AlreadyHave(txdb, inv)) vAskFor.push_back(inv); pto->mapAskFor.erase(pto->mapAskFor.begin()); @@ -2280,13 +2218,21 @@ void BlockSHA256(const void* pin, unsigned int nBlocks, void* pout) // --------------------------------------------------------------------------- -// Participant pool miner — connects to an external Stratum server, receives +// Participant pool miner -- connects to an external Stratum server, receives // RandomX jobs, hashes with the local CPU, and submits shares. // Called from BitcoinMiner() when nMineMode == MINE_PARTICIPANT. // --------------------------------------------------------------------------- using json = nlohmann::json; + +// --------------------------------------------------------------------------- +// Participant miner -- connects to a pool over a .btf tunnel, runs Stratum, +// hashes with RandomX, submits shares. Called from BitcoinMiner(). +// --------------------------------------------------------------------------- + +using json = nlohmann::json; + static std::string ToHexStr(const void* p, size_t n) { const unsigned char* b = (const unsigned char*)p; @@ -2300,10 +2246,10 @@ static std::vector FromHexStr(const std::string& s) { std::vector v; for (size_t i = 0; i+1 < s.size(); i += 2) { - auto h=[](char c)->int{ - if(c>='0'&&c<='9')return c-'0'; - if(c>='a'&&c<='f')return c-'a'+10; - if(c>='A'&&c<='F')return c-'A'+10; + auto h = [](char c)->int{ + if(c>='0'&&c<='9') return c-'0'; + if(c>='a'&&c<='f') return c-'a'+10; + if(c>='A'&&c<='F') return c-'A'+10; return 0; }; v.push_back((unsigned char)((h(s[i])<<4)|h(s[i+1]))); @@ -2313,14 +2259,13 @@ static std::vector FromHexStr(const std::string& s) static void UpdateParticipantHashRate(uint64 hashesSinceSample, int64 sampleStart) { - int64 now = GetTime(); - int64 elapsed = now - sampleStart; - if (elapsed <= 0) - elapsed = 1; - double rate = (double)hashesSinceSample / (double)elapsed; - gParticipantHashRateX1000.store((uint64)(rate * 1000.0)); + int64 elapsed = GetTime() - sampleStart; + if (elapsed <= 0) elapsed = 1; + gParticipantHashRateX1000.store( + (uint64)((double)hashesSinceSample / (double)elapsed * 1000.0)); } +// Blocking send of a JSON line to the pool. static bool StratumSendLine(SOCKET s, const json& j) { std::string line = j.dump() + "\n"; @@ -2333,38 +2278,59 @@ static bool StratumSendLine(SOCKET s, const json& j) return true; } -static bool StratumRecvLine(SOCKET s, std::string& buf, std::string& line) +// Non-blocking recv into carry buffer. Returns true + fills line when a +// complete newline-terminated message is ready. Returns false in two cases: +// disconnected=true -- socket closed by peer; caller must stop +// disconnected=false -- no complete line yet; caller should select() and retry +static bool StratumRecvLine(SOCKET s, std::string& buf, + std::string& line, bool& disconnected) { - while (true) { - size_t pos = buf.find('\n'); - if (pos != std::string::npos) { - line = buf.substr(0, pos); - if (!line.empty() && line.back()=='\r') line.pop_back(); - buf = buf.substr(pos+1); - return true; - } - char tmp[4096]; - int r = recv(s, tmp, sizeof(tmp)-1, 0); - if (r <= 0) return false; - tmp[r] = 0; - buf += tmp; + disconnected = false; + char tmp[4096]; +#ifdef _WIN32 + u_long avail = 0; + ioctlsocket(s, FIONREAD, &avail); + if (avail > 0) { + int r = recv(s, tmp, (int)std::min((u_long)(sizeof(tmp)-1), avail), 0); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0) { disconnected = true; return false; } + } +#else + { + int r = recv(s, tmp, sizeof(tmp)-1, MSG_DONTWAIT); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0) { disconnected = true; return false; } + // r < 0: EAGAIN -- no data right now } +#endif + size_t pos = buf.find('\n'); + if (pos == std::string::npos) return false; + line = buf.substr(0, pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + buf = buf.substr(pos + 1); + return true; } +// How long to wait for a mining.notify after authorize before giving up +static const int64 STUCK_NO_JOB_TIMEOUT_SECS = 120; + static bool PoolParticipantMiner() { - printf("PoolParticipantMiner: connecting to %s\n", strParticipantPool.c_str()); - SetParticipantMiningStatus("resolving pool address"); - + // ------------------------------------------------------------------ + // Step 1: resolve .btf address -> meeting node + // ------------------------------------------------------------------ if (strParticipantPool.empty()) { - printf("PoolParticipantMiner: no pool selected\n"); + LogPrint("worker", "[worker] no pool selected\n"); SetParticipantMiningStatus("no pool selected"); return false; } + LogPrint("worker", "[worker] connecting to pool %s\n", strParticipantPool.c_str()); + SetParticipantMiningStatus("resolving pool address"); + unsigned char target_pubkey[32]; if (!btf::ParseAddress(strParticipantPool, target_pubkey)) { - printf("PoolParticipantMiner: invalid .btf address %s\n", strParticipantPool.c_str()); + LogPrint("worker", "[worker] invalid .btf address: %s\n", strParticipantPool.c_str()); SetParticipantMiningStatus("invalid pool address"); return false; } @@ -2372,35 +2338,48 @@ static bool PoolParticipantMiner() std::string meetingHostPort; unsigned char service_enc_pub[32]; if (!BtfResolve(strParticipantPool, meetingHostPort, service_enc_pub)) { - printf("PoolParticipantMiner: failed to resolve %s\n", strParticipantPool.c_str()); + LogPrint("worker", "[worker] failed to resolve pool %s" + " (no Nostr descriptor found -- pool may be offline or relays down)\n", + strParticipantPool.c_str()); SetParticipantMiningStatus("failed to resolve pool"); return false; } + LogPrint("worker", "[worker] pool resolved to meeting node %s\n", meetingHostPort.c_str()); size_t colon = meetingHostPort.rfind(':'); if (colon == std::string::npos) { - printf("PoolParticipantMiner: bad meeting node %s\n", meetingHostPort.c_str()); + LogPrint("worker", "[worker] bad meeting node format '%s'\n", meetingHostPort.c_str()); SetParticipantMiningStatus("bad pool endpoint"); return false; } std::string host = meetingHostPort.substr(0, colon); int port = atoi(meetingHostPort.substr(colon + 1).c_str()); if (port <= 0 || port > 65535) { - printf("PoolParticipantMiner: bad meeting port %d\n", port); + LogPrint("worker", "[worker] bad meeting port %d in '%s'\n", + port, meetingHostPort.c_str()); SetParticipantMiningStatus("bad pool port"); return false; } - btf_socket_t s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, target_pubkey, service_enc_pub); + // ------------------------------------------------------------------ + // Step 2: open encrypted tunnel to pool + // ------------------------------------------------------------------ + SetParticipantMiningStatus("opening tunnel"); + btf_socket_t s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, + target_pubkey, service_enc_pub); if (s == INVALID_SOCKET) { - printf("PoolParticipantMiner: tunnel connect failed via %s\n", meetingHostPort.c_str()); + LogPrint("worker", "[worker] tunnel connect FAILED via %s" + " (relay unreachable or pool not listening)\n", + meetingHostPort.c_str()); SetParticipantMiningStatus("tunnel connect failed"); return false; } - printf("PoolParticipantMiner: connected via %s\n", meetingHostPort.c_str()); - SetParticipantMiningStatus("tunnel connected"); + LogPrint("worker", "[worker] tunnel open via %s\n", meetingHostPort.c_str()); + SetParticipantMiningStatus("tunnel open"); - // Use our wallet address as mining username so payouts go to our wallet + // ------------------------------------------------------------------ + // Step 3: determine our payout address (mining username) + // ------------------------------------------------------------------ std::string username; { std::vector vchPubKey; @@ -2408,102 +2387,260 @@ static bool PoolParticipantMiner() username = PubKeyToAddress(vchPubKey); } if (username.empty()) username = BtfLocalAddress(); - if (username.empty()) username = "unknown"; - + if (username.empty()) { + LogPrint("worker", "[worker] WARNING: could not determine wallet address" + " -- shares will not pay out\n"); + username = "unknown"; + } + LogPrint("worker", "[worker] payout address: %s\n", username.c_str()); + + // ------------------------------------------------------------------ + // Step 4: build the fast (~2 GB dataset) RandomX mode if not already + // built, then start our VM. Without this, RandomXCreateMinerVM() falls + // back to "light" mode (cache only) forever, which is dramatically + // slower per hash than fast mode -- it recomputes dataset items on the + // fly for every single hash instead of a direct memory lookup. That + // alone can turn a "few seconds per share" pool into "many minutes per + // share" regardless of how easy the share target is. + // + // This MUST happen before we send mining.subscribe/authorize, not + // after: it's a synchronous, single-threaded-per-call build that can + // take anywhere from several seconds to a couple of minutes depending + // on core count. If it runs after the handshake messages are sent, + // the pool's replies (subscribe ack, authorize ack, set_difficulty, + // and the first job) all land in the OS socket buffer while we're + // not reading -- nothing is lost, but we sit there looking (and + // logging) like we're "stuck waiting for pool response" for the + // entire build duration, when really we're just not listening yet. + // Building the dataset first means that by the time we say + // "subscribing", we're genuinely ready to read the reply the instant + // it arrives. + // ------------------------------------------------------------------ + if (!RandomXFastReady()) { + SetParticipantMiningStatus("building RandomX dataset (~2GB, one-time)"); + unsigned int nThreads = std::thread::hardware_concurrency(); + RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); + } + void* rxvm = RandomXCreateMinerVM(); + if (!rxvm) { + LogPrint("worker", "[worker] failed to create RandomX VM\n"); + SetParticipantMiningStatus("RandomX init failed"); + closesocket(s); return false; + } + LogPrint("worker", "[worker] RandomX VM ready (%s)\n", + RandomXFastReady() ? "fast 2GB" : "light 256MB"); + + // ------------------------------------------------------------------ + // Step 5: Stratum subscribe + authorize. The socket is read from + // immediately after this, in the main loop below, so nothing sits + // unread behind a blocking dataset build anymore. + // ------------------------------------------------------------------ std::string buf; int msgId = 1; std::set pendingSubmitIds; - // Subscribe SetParticipantMiningStatus("subscribing"); - if (!StratumSendLine(s, json{{"id",msgId++},{"method","mining.subscribe"},{"params",json::array()}})) - { SetParticipantMiningStatus("subscribe failed"); closesocket(s); return false; } + LogPrint("worker", "[worker->pool] sending mining.subscribe\n"); + if (!StratumSendLine(s, json{{"id",msgId++},{"method","mining.subscribe"}, + {"params",json::array()}})) { + LogPrint("worker", "[worker] subscribe send FAILED -- pool dropped connection\n"); + SetParticipantMiningStatus("subscribe send failed"); + RandomXDestroyMinerVM(rxvm); + closesocket(s); return false; + } - // Authorize SetParticipantMiningStatus("authorizing"); + LogPrint("worker", "[worker->pool] sending mining.authorize as %s\n", username.c_str()); if (!StratumSendLine(s, json{{"id",msgId++},{"method","mining.authorize"}, - {"params",json::array({username,"x"})}})) - { SetParticipantMiningStatus("authorize failed"); closesocket(s); return false; } + {"params",json::array({username,"x"})}})) { + LogPrint("worker", "[worker] authorize send FAILED -- pool dropped connection\n"); + SetParticipantMiningStatus("authorize send failed"); + RandomXDestroyMinerVM(rxvm); + closesocket(s); return false; + } - // Job state + // ------------------------------------------------------------------ + // Step 6: main loop -- receive messages, hash, submit shares + // ------------------------------------------------------------------ std::string jobId; - unsigned char header[80]; + unsigned char header[80] = {}; uint256 target; - bool haveJob = false; + double currentDifficulty = 1.0; // updated by mining.set_difficulty from pool + bool haveJob = false; bool gotSubscribeAck = false; bool gotAuthorizeAck = false; - int64 authWaitStart = 0; - unsigned int nNonce = 1; - uint64 hashesSinceSample = 0; - int64 sampleStart = GetTime(); + int64 authWaitStart = 0; + int64 lastStuckLog = 0; + unsigned int nNonce = 1; + uint64 hashesSinceSample = 0; + uint64 sessionSharesSent = 0; // this connection only + uint64 sessionSharesAccepted = 0; + int64 sampleStart = GetTime(); - void* rxvm = RandomXCreateMinerVM(); - if (!rxvm) { SetParticipantMiningStatus("failed to start RandomX miner"); closesocket(s); return false; } - printf("PoolParticipantMiner: RandomX VM ready (%s)\n", - RandomXFastReady() ? "fast 2GB" : "light 256MB"); - SetParticipantMiningStatus("authorized; asking pool for work"); + SetParticipantMiningStatus("waiting for pool response"); while (fGenerateBitcoins && nMineMode == MINE_PARTICIPANT && !fShutdown) { - // Non-blocking receive check + // -- Receive: check buffer first, then select -- { - fd_set fds; FD_ZERO(&fds); FD_SET(s, &fds); - struct timeval tv={0,50000}; // 50ms - if (select((int)s+1, &fds, NULL, NULL, &tv) > 0) { + bool haveBuffered = buf.find('\n') != std::string::npos; + bool readable = false; + if (!haveBuffered) { + fd_set fds; FD_ZERO(&fds); FD_SET(s, &fds); + struct timeval tv = {0, 50000}; // 50ms + readable = select((int)s+1, &fds, NULL, NULL, &tv) > 0; + } + + if (haveBuffered || readable) { std::string line; - if (!StratumRecvLine(s, buf, line)) break; - try { - json msg = json::parse(line); - if (msg.contains("id") && msg["id"].is_number_integer() && msg.contains("result")) { - int id = msg["id"].get(); - if (id == 1 && msg["result"].is_boolean() && msg["result"].get()) { - gotSubscribeAck = true; - SetParticipantMiningStatus("subscribed; authorizing"); - } else if (id == 2 && msg["result"].is_boolean()) { - gotAuthorizeAck = msg["result"].get(); - authWaitStart = GetTime(); - SetParticipantMiningStatus(gotAuthorizeAck ? "authorized; asking pool for work" : "authorization rejected"); + bool disc = false; + while (StratumRecvLine(s, buf, line, disc)) { + json msg; + try { msg = json::parse(line); } catch (...) { + LogPrint("worker", "[pool->worker] JSON parse error: %.80s\n", + line.c_str()); + continue; + } + + // -- Response to our subscribe (id=1) -- + if (msg.contains("id") && msg["id"].is_number_integer() + && msg["id"].get() == 1 + && msg.contains("result") && !msg["result"].is_null()) { + gotSubscribeAck = true; + LogPrint("worker", "[pool->worker] subscribe ack received\n"); + SetParticipantMiningStatus("subscribed; waiting for authorize ack"); + } + + // -- Response to our authorize (id=2) -- + else if (msg.contains("id") && msg["id"].is_number_integer() + && msg["id"].get() == 2 + && msg.contains("result")) { + bool ok = msg["result"].is_boolean() && msg["result"].get(); + authWaitStart = GetTime(); + if (ok) { + gotAuthorizeAck = true; + LogPrint("worker", "[pool->worker] authorize OK -- waiting for job\n"); + SetParticipantMiningStatus("authorized; waiting for job"); + } else { + std::string reason = msg.value("error", json("unspecified")).is_string() + ? msg["error"].get() : "unspecified"; + LogPrint("worker", "[pool->worker] authorize REJECTED: %s\n", + reason.c_str()); + SetParticipantMiningStatus("authorization rejected"); + // Don't break -- pool might still send a job (some pools + // reject bad addresses but still send work) } - if (pendingSubmitIds.erase(id) > 0) { - if (msg["result"].is_boolean() && msg["result"].get()) { + } + + // -- Share submission response -- + else if (msg.contains("id") && msg["id"].is_number_integer()) { + int rid = msg["id"].get(); + if (pendingSubmitIds.erase(rid) > 0) { + bool accepted = msg.contains("result") + && msg["result"].is_boolean() + && msg["result"].get(); + if (accepted) { gParticipantSharesAccepted.fetch_add(1); + sessionSharesAccepted++; + LogPrint("worker", "[pool->worker] share id=%d ACCEPTED\n", rid); SetParticipantMiningStatus("share accepted"); } else { - SetParticipantMiningStatus("share rejected"); + std::string reason = (msg.contains("error") + && msg["error"].is_string()) + ? msg["error"].get() : "unspecified"; + LogPrint("worker", "[pool->worker] share id=%d REJECTED: %s\n", + rid, reason.c_str()); + SetParticipantMiningStatus("share rejected: " + reason); } } } - std::string method = msg.value("method",""); - if (method == "mining.notify") { - auto& p = msg["params"]; - jobId = p[0].get(); - // p[1] = full 80-byte header hex - auto hdrBytes = FromHexStr(p[1].get()); - if (hdrBytes.size() >= 80) - memcpy(header, hdrBytes.data(), 80); - // p[3] = share target hex (little-endian uint256) - auto tgtBytes = FromHexStr(p[3].get()); - if (tgtBytes.size() >= 32) - memcpy(&target, tgtBytes.data(), 32); - haveJob = true; - nNonce = 1; - SetParticipantMiningStatus("job received; hashing"); - printf("PoolParticipantMiner: new job %s\n", jobId.c_str()); - } else if (method == "mining.set_difficulty") { - // acknowledged, we use the target from notify + + // -- Server notification -- + else { + std::string method = msg.value("method", ""); + + if (method == "mining.notify") { + auto& p = msg["params"]; + if (!p.is_array() || p.size() < 4) { + LogPrint("worker", "[pool->worker] malformed mining.notify" + " (params size=%zu)\n", p.is_array() ? p.size() : 0); + } else { + std::string newJobId = p[0].get(); + auto hdrBytes = FromHexStr(p[1].get()); + auto tgtBytes = FromHexStr(p[3].get()); + + if (hdrBytes.size() < 80) { + LogPrint("worker", "[pool->worker] mining.notify:" + " header too short (%zu bytes)\n", + hdrBytes.size()); + } else if (tgtBytes.size() < 32) { + LogPrint("worker", "[pool->worker] mining.notify:" + " target too short (%zu bytes)\n", + tgtBytes.size()); + } else { + jobId = newJobId; + memcpy(header, hdrBytes.data(), 80); + memcpy(&target, tgtBytes.data(), 32); + haveJob = true; + nNonce = 1; + LogPrint("worker", "[pool->worker] new job %s received\n", + jobId.c_str()); + SetParticipantMiningStatus("hashing job " + jobId); + } + } + } else if (method == "mining.set_difficulty") { + // Pool adjusts our share difficulty via vardiff. + // Apply it immediately so we start hashing at the new target. + if (msg.contains("params") && msg["params"].is_array() + && msg["params"].size() > 0 + && msg["params"][0].is_number()) { + double newDiff = msg["params"][0].get(); + if (newDiff > 0.0) { + bool changed = (newDiff != currentDifficulty); + currentDifficulty = newDiff; + LogPrint("worker", "[pool->worker] set_difficulty %.6f%s\n", + newDiff, changed ? " (updated)" : " (initial)"); + } + } else { + LogPrint("worker", "[pool->worker] set_difficulty" + " received (no valid params)\n"); + } + } else if (!method.empty()) { + LogPrint("worker", "[pool->worker] unknown server method: %s\n", + method.c_str()); + } } - } catch(...) {} + } + + if (disc) { + LogPrint("worker", "[worker] pool closed the connection" + " (gotSubscribeAck=%d gotAuthorizeAck=%d haveJob=%d)\n", + gotSubscribeAck, gotAuthorizeAck, haveJob); + break; + } } } - if (gotAuthorizeAck && !haveJob && authWaitStart > 0 && GetTime() - authWaitStart > 10) { - SetParticipantMiningStatus("authorized; waiting for pool notify"); - authWaitStart = 0; + // -- Stuck watchdog: authorized but no job arriving -- + if (gotAuthorizeAck && !haveJob && authWaitStart > 0) { + int64 waited = GetTime() - authWaitStart; + if (waited > 10 && GetTime() - lastStuckLog >= 15) { + LogPrint("worker", "[worker] authorized but no job after %llds" + " -- pool tunnel is live, waiting on mining.notify\n", + (long long)waited); + lastStuckLog = GetTime(); + } + if (waited > STUCK_NO_JOB_TIMEOUT_SECS) { + LogPrint("worker", "[worker] no job received after %llds -- disconnecting" + " and reconnecting\n", (long long)STUCK_NO_JOB_TIMEOUT_SECS); + SetParticipantMiningStatus("timeout waiting for job; reconnecting"); + break; + } } if (!haveJob) { Sleep(100); continue; } - // Hash one nonce + // -- Hash one nonce -- memcpy(header+76, &nNonce, 4); uint256 hash = RandomXHashWithVM(rxvm, header, 80); hashesSinceSample++; @@ -2512,38 +2649,59 @@ static bool PoolParticipantMiner() UpdateParticipantHashRate(hashesSinceSample, sampleStart); if (hash <= target) { - printf("PoolParticipantMiner: share found! nonce=%u\n", nNonce); + LogPrint("worker", "[worker] share found! nonce=%u job=%s difficulty=%.4f\n", + nNonce, jobId.c_str(), currentDifficulty); SetParticipantMiningStatus("share found; submitting"); int submitId = msgId++; pendingSubmitIds.insert(submitId); gParticipantSharesSent.fetch_add(1); + sessionSharesSent++; json submit = {{"id",submitId},{"method","mining.submit"}, - {"params",json::array({username, jobId, - ToHexStr(&nNonce,4)})}}; - if (!StratumSendLine(s, submit)) break; + {"params",json::array({username, jobId, ToHexStr(&nNonce,4)})}}; + if (!StratumSendLine(s, submit)) { + LogPrint("worker", "[worker] share submit send FAILED" + " -- pool connection lost\n"); + break; + } } nNonce++; - if (nNonce == 0) { haveJob = false; } // exhausted, wait for new job + if (nNonce == 0) { + LogPrint("worker", "[worker] nonce space exhausted for job %s" + " -- waiting for new job\n", jobId.c_str()); + haveJob = false; + } } + // ------------------------------------------------------------------ + // Cleanup + // ------------------------------------------------------------------ RandomXDestroyMinerVM(rxvm); closesocket(s); UpdateParticipantHashRate(hashesSinceSample, sampleStart); - SetParticipantMiningStatus("participant miner stopped"); - printf("PoolParticipantMiner: disconnected\n"); + SetParticipantMiningStatus("disconnected"); + LogPrint("worker", "[worker] disconnected from pool" + " (hashes this session: %llu, shares sent: %llu, accepted: %llu)\n", + (unsigned long long)hashesSinceSample, + (unsigned long long)sessionSharesSent, + (unsigned long long)sessionSharesAccepted); return true; } bool BitcoinMiner() { + // Relay mode: never mines, just relays/syncs. (Shouldn't normally get here + // since the GUI hides Start Mining for Relay, but guard it regardless.) + if (nMineMode == MINE_RELAY) + return true; + // Participant mode: connect to external pool instead of mining solo/operator if (nMineMode == MINE_PARTICIPANT) { while (fGenerateBitcoins && nMineMode == MINE_PARTICIPANT && !fShutdown) { PoolParticipantMiner(); if (fGenerateBitcoins && nMineMode == MINE_PARTICIPANT && !fShutdown) { - printf("PoolParticipantMiner: reconnecting in 10s\n"); + LogPrint("worker", "PoolParticipantMiner: reconnecting in 10s\n"); for (int i = 0; i < 10 && !fShutdown; i++) Sleep(1000); } } @@ -2554,9 +2712,16 @@ bool BitcoinMiner() if (nMineMode == MINE_OPERATOR) return true; - printf("BitcoinMiner started\n"); + LogPrint("net", "BitcoinMiner started\n"); SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_LOWEST); + // Build the fast (~2 GB dataset) RandomX mode once, up front, so we're + // not stuck mining in the much slower light/cache-only mode forever. + if (!RandomXFastReady()) { + unsigned int nThreads = std::thread::hardware_concurrency(); + RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); + } + CKey key; key.MakeNewKey(); CBigNum bnExtraNonce = 0; @@ -2639,7 +2804,7 @@ bool BitcoinMiner() } pblock->nBits = nBits; pblock->vtx[0].vout[0].nValue = pblock->GetBlockValue(nFees); - printf("\n\nRunning BitcoinMiner with %d transactions in block\n", pblock->vtx.size()); + if (LogAcceptsCategory("net")) printf("\n\nRunning BitcoinMiner with %d transactions in block\n", pblock->vtx.size()); // @@ -2658,11 +2823,11 @@ bool BitcoinMiner() void* rxvm = RandomXCreateMinerVM(); if (!rxvm) { - printf("BitcoinMiner: failed to create RandomX VM\n"); + LogPrint("net", "BitcoinMiner: failed to create RandomX VM\n"); Sleep(1000); continue; } - printf("BitcoinMiner: mining with RandomX (%s)\n", + LogPrint("net", "BitcoinMiner: mining with RandomX (%s)\n", RandomXFastReady() ? "fast 2GB" : "light 256MB"); unsigned int nStart = GetTime(); @@ -2675,9 +2840,9 @@ bool BitcoinMiner() if (hash <= hashTarget) { //// debug print - printf("BitcoinMiner:\n"); - printf("RandomX proof-of-work found \n powhash: %s \ntarget: %s\n", hash.GetHex().c_str(), hashTarget.GetHex().c_str()); - pblock->print(); + if (LogAcceptsCategory("net")) printf("BitcoinMiner:\n"); + if (LogAcceptsCategory("net")) printf("RandomX proof-of-work found \n powhash: %s \ntarget: %s\n", hash.GetHex().c_str(), hashTarget.GetHex().c_str()); + if (LogAcceptsCategory("net")) pblock->print(); SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_NORMAL); CRITICAL_BLOCK(cs_main) @@ -2692,7 +2857,7 @@ bool BitcoinMiner() // Process this block the same as if we had received it from another node if (!ProcessBlock(NULL, pblock.release())) - printf("ERROR in BitcoinMiner, ProcessBlock, block not accepted\n"); + LogPrint("net", "ERROR in BitcoinMiner, ProcessBlock, block not accepted\n"); } SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_LOWEST); @@ -2860,11 +3025,11 @@ bool SelectCoins(int64 nTargetValue, set& setCoinsRet) setCoinsRet.insert(vValue[i].second); //// debug print - printf("SelectCoins() best subset: "); + if (LogAcceptsCategory("net")) printf("SelectCoins() best subset: "); for (int i = 0; i < vValue.size(); i++) if (vfBest[i]) - printf("%s ", FormatMoney(vValue[i].first).c_str()); - printf("total %s\n", FormatMoney(nBest).c_str()); + if (LogAcceptsCategory("net")) printf("%s ", FormatMoney(vValue[i].first).c_str()); + if (LogAcceptsCategory("net")) printf("total %s\n", FormatMoney(nBest).c_str()); } return true; @@ -2953,6 +3118,34 @@ bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, in return true; } +// Undo what CommitTransactionSpent did: unmark the inputs it marked spent +// and erase the half-committed wallet entry. Used when a transaction is +// accepted into the wallet but then rejected by the mempool/chain, so we +// don't end up with a wallet that thinks money is spent that never left. +void RollbackTransactionSpent(const CWalletTx& wtxNew) +{ + CRITICAL_BLOCK(cs_main) + CRITICAL_BLOCK(cs_mapWallet) + { + set setCoins; + foreach(const CTxIn& txin, wtxNew.vin) + { + map::iterator mi = mapWallet.find(txin.prevout.hash); + if (mi != mapWallet.end()) + setCoins.insert(&mi->second); + } + foreach(CWalletTx* pcoin, setCoins) + { + pcoin->fSpent = false; + pcoin->WriteToDisk(); + vWalletUpdated.push_back(make_pair(pcoin->GetHash(), false)); + } + + EraseFromWallet(wtxNew.GetHash()); + } + MainFrameRepaint(); +} + // Call after CreateTransaction unless you want to abort bool CommitTransactionSpent(const CWalletTx& wtxNew) { @@ -3003,13 +3196,17 @@ bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew) return error("SendMoney() : Error finalizing transaction"); } - printf("SendMoney: %s\n", wtxNew.GetHash().ToString().substr(0,6).c_str()); + if (LogAcceptsCategory("net")) printf("SendMoney: %s\n", wtxNew.GetHash().ToString().substr(0,6).c_str()); // Broadcast if (!wtxNew.AcceptTransaction()) { - // This must not fail. The transaction has already been signed and recorded. - throw runtime_error("SendMoney() : wtxNew.AcceptTransaction() failed\n"); + // The transaction was already signed and committed to the wallet + // above, but the chain/mempool just rejected it (e.g. a raced + // input already spent by another transaction). Unwind the + // wallet state we just wrote instead of crashing the app. + RollbackTransactionSpent(wtxNew); + return error("SendMoney() : wtxNew.AcceptTransaction() failed"); } wtxNew.RelayWalletTransaction(); } diff --git a/src/main.h b/src/main.h index 3d7d6dd..8bb8609 100644 --- a/src/main.h +++ b/src/main.h @@ -48,13 +48,17 @@ extern CBlockIndex* pindexBest; extern unsigned int nTransactionsUpdated; extern string strSetDataDir; extern int nDropMessagesTest; -// Mining mode — persisted in wallet.dat -// MINE_SOLO (0): mine to own wallet, no pool server -// MINE_OPERATOR (1): run pool server, mine to own wallet, distribute to miners -// MINE_PARTICIPANT (2): connect to external pool, submit shares, receive payouts -#define MINE_SOLO 0 -#define MINE_OPERATOR 1 -#define MINE_PARTICIPANT 2 +// Mining mode -- persisted in wallet.dat +// Mining mode -- decided fresh every launch (CLI flags, else default). Never +// restored from wallet.dat; see LoadWallet() in db.cpp. +// MINE_RELAY (0): default. Node runs/syncs, never mines, no pool server. +// MINE_SOLO (1): mine to own wallet, no pool server +// MINE_OPERATOR (2): run pool server, mine to own wallet, distribute to miners +// MINE_PARTICIPANT (3): connect to external pool, submit shares, receive payouts +#define MINE_RELAY 0 +#define MINE_SOLO 1 +#define MINE_OPERATOR 2 +#define MINE_PARTICIPANT 3 extern int nMineMode; extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name @@ -78,6 +82,7 @@ struct PoolWorkerStatView uint64 totalShares; uint64 roundShares; int64 lastSeen; + double hashRate; // estimated H/s, derived from share difficulty + submit rate }; // Settings @@ -102,11 +107,11 @@ void RelayWalletTransactions(); bool LoadBlockIndex(bool fAllowNew=true); void PrintBlockTree(); bool BitcoinMiner(); -void ThreadRPCServer(void* parg); // rpc.cpp — .btf pool server +void ThreadRPCServer(void* parg); // rpc.cpp -- .btf pool server void GetParticipantMiningStats(uint64& sharesSent, uint64& sharesAccepted, double& hashRate); void SetParticipantMiningStatus(const std::string& status); std::string GetParticipantMiningStatus(); -void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares); +void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate); void GetPoolWorkerStats(std::vector& out); void GetPendingPayouts(std::vector& out); bool ProcessMessages(CNode* pfrom); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index b7377da..842b0d5 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,4 +1,4 @@ -// Bitflash entry point — starts node threads then runs GUI (or headless). +// Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" #ifndef _WIN32 @@ -126,8 +126,6 @@ int main(int argc, char* argv[]) ParseStartupArguments(argc, argv); - printf("Loading addresses...\n"); - if (!LoadAddresses()) printf("Warning: error loading addr.dat\n"); printf("Loading block index...\n"); string strErrors; if (!LoadBlockIndex()) { fprintf(stderr,"LoadBlockIndex failed\n"); return 1; } diff --git a/src/makefile.linux b/src/makefile.linux deleted file mode 100644 index 9e63f7d..0000000 --- a/src/makefile.linux +++ /dev/null @@ -1,55 +0,0 @@ -# Bitflash Linux build — GUI wallet + pool server + daemon mode. -# Requires: bash build-linux.sh (installs deps, builds secp256k1 + RandomX) -# Result: src/bitflash -# -# ./bitflash — GUI wallet -# ./bitflash /nogui /gen — headless daemon (same binary, no window) - -CXX ?= g++ - -SECP_INC ?= /usr/local/include -SECP_LIB ?= /usr/local/lib -RANDOMX_DIR ?= $(HOME)/RandomX - -WX_CFLAGS := $(shell wx-config --cxxflags) -WX_LIBS := $(shell wx-config --libs) - -CXXFLAGS = -std=gnu++14 -O2 -w -fpermissive -pthread \ - -DSECP256K1_STATIC \ - -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ - -I/usr/include/nlohmann \ - $(WX_CFLAGS) - -LIBS = -L$(SECP_LIB) -lsecp256k1 \ - -L$(RANDOMX_DIR)/build -lrandomx \ - -lsodium -lssl -lcrypto -ldb_cxx -lpthread \ - -lboost_system \ - $(WX_LIBS) - -HEADERS = headers.h util.h main.h serialize.h uint256.h key.h bignum.h script.h \ - db.h base58.h net.h irc.h nostr.h randomx_pow.h btfaddr.h btfchan.h \ - btfrv.h btftunnel.h market.h compat.h uibase.h ui.h - -OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o obj/market.o \ - obj/sha.o obj/irc.o obj/nostr.o obj/randomx_pow.o \ - obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o \ - obj/rpc.o obj/uibase.o obj/ui.o - -all: bitflash - -obj/%.o: %.cpp $(HEADERS) - @mkdir -p obj - $(CXX) -c $(CXXFLAGS) -o $@ $< - -obj/sha.o: sha.cpp sha.h - @mkdir -p obj - $(CXX) -c $(CXXFLAGS) -O3 -o $@ $< - -bitflash: $(OBJS) - $(CXX) $(CXXFLAGS) -o $@ $(OBJS) $(LIBS) - @echo "Built: bitflash" - @echo " GUI: ./bitflash" - @echo " Daemon: ./bitflash /nogui /gen" - -clean: - rm -rf obj bitflash diff --git a/src/makefile.mingw b/src/makefile.mingw index d381653..7d35d92 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -28,13 +28,13 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ - obj/market.o obj/sha.o obj/irc.o obj/nostr.o obj/randomx_pow.o \ + obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ - key.h bignum.h script.h db.h base58.h net.h irc.h nostr.h \ + key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h all: bitflash.exe diff --git a/src/makefile.vc b/src/makefile.vc deleted file mode 100644 index 60dca78..0000000 --- a/src/makefile.vc +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright (c) 2009 Satoshi Nakamoto -# Distributed under the MIT/X11 software license, see the accompanying -# file license.txt or http://www.opensource.org/licenses/mit-license.php. - - -!IF "$(BUILD)" != "debug" && "$(BUILD)" != "release" -BUILD=debug -!ENDIF -!IF "$(BUILD)" == "debug" -D=d -DEBUGFLAGS=/Zi /Od /D__WXDEBUG__ -!ENDIF - - - -INCLUDEPATHS=/I"/boost" /I"/DB/build_windows" /I"/OpenSSL/include" /I"/wxWidgets/lib/vc_lib/mswd" /I"/wxWidgets/include" -LIBPATHS=/LIBPATH:"/DB/build_windows/$(BUILD)" /LIBPATH:"/OpenSSL/out" /LIBPATH:"/wxWidgets/lib/vc_lib" -LIBS= \ - libdb47s$(D).lib \ - libeay32.lib \ - wxmsw28$(D)_richtext.lib wxmsw28$(D)_html.lib wxmsw28$(D)_core.lib wxbase28$(D).lib wxtiff$(D).lib wxjpeg$(D).lib wxpng$(D).lib wxzlib$(D).lib wxregex$(D).lib wxexpat$(D).lib \ - kernel32.lib user32.lib gdi32.lib comdlg32.lib winspool.lib winmm.lib shell32.lib comctl32.lib ole32.lib oleaut32.lib uuid.lib rpcrt4.lib advapi32.lib ws2_32.lib -WXDEFS=/DWIN32 /D__WXMSW__ /D_WINDOWS /DNOPCH -CFLAGS=/c /nologo /Ob0 /MD$(D) /EHsc /GR /Zm300 /YX /Fpobj/headers.pch $(DEBUGFLAGS) $(WXDEFS) $(INCLUDEPATHS) -HEADERS=headers.h util.h main.h serialize.h uint256.h key.h bignum.h script.h db.h base58.h - - - -all: bitcoin.exe - - -obj\util.obj: util.cpp $(HEADERS) - cl $(CFLAGS) /Fo$@ %s - -obj\script.obj: script.cpp $(HEADERS) - cl $(CFLAGS) /Fo$@ %s - -obj\db.obj: db.cpp $(HEADERS) market.h - cl $(CFLAGS) /Fo$@ %s - -obj\net.obj: net.cpp $(HEADERS) net.h - cl $(CFLAGS) /Fo$@ %s - -obj\main.obj: main.cpp $(HEADERS) net.h market.h - cl $(CFLAGS) /Fo$@ %s - -obj\market.obj: market.cpp $(HEADERS) market.h - cl $(CFLAGS) /Fo$@ %s - -obj\ui.obj: ui.cpp $(HEADERS) net.h uibase.h ui.h market.h - cl $(CFLAGS) /Fo$@ %s - -obj\uibase.obj: uibase.cpp uibase.h - cl $(CFLAGS) /Fo$@ %s - -obj\sha.obj: sha.cpp sha.h - cl $(CFLAGS) /O2 /Fo$@ %s - -obj\irc.obj: irc.cpp $(HEADERS) - cl $(CFLAGS) /Fo$@ %s - -obj\ui.res: ui.rc rc/bitcoin.ico rc/check.ico rc/send16.bmp rc/send16mask.bmp rc/send16masknoshadow.bmp rc/send20.bmp rc/send20mask.bmp rc/addressbook16.bmp rc/addressbook16mask.bmp rc/addressbook20.bmp rc/addressbook20mask.bmp - rc $(INCLUDEPATHS) $(WXDEFS) /Fo$@ %s - - - -OBJS=obj\util.obj obj\script.obj obj\db.obj obj\net.obj obj\main.obj obj\market.obj \ - obj\ui.obj obj\uibase.obj obj\sha.obj obj\irc.obj obj\ui.res - -bitcoin.exe: $(OBJS) - -kill /f bitcoin.exe & sleep 1 - link /nologo /DEBUG /SUBSYSTEM:WINDOWS /OUT:$@ $(LIBPATHS) $** $(LIBS) - -clean: - -del /Q obj\* - -del *.ilk - -del *.pdb diff --git a/src/net.cpp b/src/net.cpp index bdfb9c3..ab07f94 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -12,7 +12,6 @@ void ThreadMessageHandler2(void* parg); void ThreadSocketHandler2(void* parg); -void ThreadOpenConnections2(void* parg); @@ -32,8 +31,6 @@ bool fShutdown = false; array vfThreadRunning; vector vNodes; CCriticalSection cs_vNodes; -map, CAddress> mapAddresses; -CCriticalSection cs_mapAddresses; map mapRelay; deque > vRelayExpiration; CCriticalSection cs_mapRelay; @@ -41,61 +38,6 @@ map mapAlreadyAskedFor; string strBtfConnect; // .btf peer to keep connected to (from /connectbtf) - -CAddress addrProxy; - -bool ConnectSocket(const CAddress& addrConnect, SOCKET& hSocketRet) -{ - hSocketRet = INVALID_SOCKET; - - SOCKET hSocket = socket(AF_INET, SOCK_STREAM, 0); - if (hSocket == INVALID_SOCKET) - return false; - - bool fRoutable = !(addrConnect.GetByte(3) == 10 || (addrConnect.GetByte(3) == 192 && addrConnect.GetByte(2) == 168)); - bool fProxy = (addrProxy.ip && fRoutable); - struct sockaddr_in sockaddr = (fProxy ? addrProxy.GetSockAddr() : addrConnect.GetSockAddr()); - - if (connect(hSocket, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) == SOCKET_ERROR) - { - closesocket(hSocket); - return false; - } - - if (fProxy) - { - printf("Proxy connecting to %s\n", addrConnect.ToString().c_str()); - char pszSocks4IP[] = "\4\1\0\0\0\0\0\0user"; - memcpy(pszSocks4IP + 2, &addrConnect.port, 2); - memcpy(pszSocks4IP + 4, &addrConnect.ip, 4); - char* pszSocks4 = pszSocks4IP; - int nSize = sizeof(pszSocks4IP); - - int ret = send(hSocket, pszSocks4, nSize, 0); - if (ret != nSize) - { - closesocket(hSocket); - return error("Error sending to proxy\n"); - } - char pchRet[8]; - if (recv(hSocket, pchRet, 8, 0) != 8) - { - closesocket(hSocket); - return error("Error reading proxy response\n"); - } - if (pchRet[1] != 0x5a) - { - closesocket(hSocket); - return error("Proxy returned error %d\n", pchRet[1]); - } - printf("Proxy connection established %s\n", addrConnect.ToString().c_str()); - } - - hSocketRet = hSocket; - return true; -} - - bool GetMyExternalIP(unsigned int& ipRet) { // Try several plain-text IP echo services in order. @@ -178,38 +120,6 @@ bool GetMyExternalIP(unsigned int& ipRet) -bool AddAddress(CAddrDB& addrdb, const CAddress& addr) -{ - if (!addr.IsRoutable()) - return false; - if (addr.ip == addrLocalHost.ip) - return false; - CRITICAL_BLOCK(cs_mapAddresses) - { - map, CAddress>::iterator it = mapAddresses.find(addr.GetKey()); - if (it == mapAddresses.end()) - { - // New address - mapAddresses.insert(make_pair(addr.GetKey(), addr)); - addrdb.WriteAddress(addr); - return true; - } - else - { - CAddress& addrFound = (*it).second; - if ((addrFound.nServices | addr.nServices) != addrFound.nServices) - { - // Services have been added - addrFound.nServices |= addr.nServices; - addrdb.WriteAddress(addrFound); - return true; - } - } - } - return false; -} - - @@ -330,64 +240,6 @@ CNode* FindNode(unsigned int ip) return NULL; } -CNode* FindNode(CAddress addr) -{ - CRITICAL_BLOCK(cs_vNodes) - { - foreach(CNode* pnode, vNodes) - if (pnode->addr == addr) - return (pnode); - } - return NULL; -} - -CNode* ConnectNode(CAddress addrConnect, int64 nTimeout) -{ - if (addrConnect.ip == addrLocalHost.ip) - return NULL; - - // Look for an existing connection - CNode* pnode = FindNode(addrConnect.ip); - if (pnode) - { - if (nTimeout != 0) - pnode->AddRef(nTimeout); - else - pnode->AddRef(); - return pnode; - } - - if (fDebug) - printf("trying %s\n", addrConnect.ToString().c_str()); - - // Connect - SOCKET hSocket; - if (ConnectSocket(addrConnect, hSocket)) - { - if (fDebug) - printf("connected %s\n", addrConnect.ToString().c_str()); - - // Add node - CNode* pnode = new CNode(hSocket, addrConnect, false); - if (nTimeout != 0) - pnode->AddRef(nTimeout); - else - pnode->AddRef(); - CRITICAL_BLOCK(cs_vNodes) - vNodes.push_back(pnode); - - CRITICAL_BLOCK(cs_mapAddresses) - mapAddresses[addrConnect.GetKey()].nLastFailed = 0; - return pnode; - } - else - { - CRITICAL_BLOCK(cs_mapAddresses) - mapAddresses[addrConnect.GetKey()].nLastFailed = GetTime(); - return NULL; - } -} - // Synthetic marker address for a tunneled `.btf` peer. The real endpoint is // unknown by design (the tunnel hides it), so tag the CNode with a non-routable // 10.x.x.x address (fails IsRoutable, so it is never gossiped as a real peer) @@ -401,20 +253,15 @@ static CAddress BtfMarkerAddr(const unsigned char b5[5]) return CAddress(ip, port, nLocalServices); } -// Connect to a peer by its `.btf` address: resolve the self-certified -// descriptor over Nostr, then tunnel through its meeting node with the -// end-to-end channel. Neither side ever learns the other's IP. -CNode* ConnectNodeBtf(const string& strBtfAddr) +// Shared tail: given a peer's pubkey and already-resolved rendezvous +// coordinates, open the tunnel and register the CNode. Used by both +// ConnectNodeBtf (which resolves the descriptor itself) and +// ConnectNodeBtfResolved (which takes an already-resolved descriptor, so it +// makes no Nostr relay calls at all -- see nostr.cpp's BtfResolveMany for why +// that matters when dialing many candidates in parallel). +static CNode* ConnectNodeBtfTail(const string& strBtfAddr, const unsigned char pk[32], + const string& strMeeting, const unsigned char enc_pub[32]) { - unsigned char pk[32]; - if (!btf::ParseAddress(strBtfAddr, pk)) - { - printf("ConnectNodeBtf: invalid address %s\n", strBtfAddr.c_str()); - return NULL; - } - if (BtfLocalAddress() == strBtfAddr) - return NULL; // ourselves - CAddress addr = BtfMarkerAddr(pk); CNode* pnode = FindNode(addr.ip); if (pnode) @@ -423,17 +270,6 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) return pnode; } - if (fDebug) - printf("trying %s\n", strBtfAddr.c_str()); - - string strMeeting; - unsigned char enc_pub[32]; - if (!BtfResolve(strBtfAddr, strMeeting, enc_pub)) - { - if (fDebug) - printf("ConnectNodeBtf: could not resolve a descriptor for %s\n", strBtfAddr.c_str()); - return NULL; - } size_t colon = strMeeting.rfind(':'); if (colon == string::npos) return NULL; @@ -446,12 +282,12 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) if (hSocket == INVALID_SOCKET) { if (fDebug) - printf("ConnectNodeBtf: tunnel to %s via %s failed\n", strBtfAddr.c_str(), strMeeting.c_str()); + LogPrint("net", "ConnectNodeBtf: tunnel to %s via %s failed\n", strBtfAddr.c_str(), strMeeting.c_str()); return NULL; } if (fDebug) - printf("connected %s via rendezvous %s\n", strBtfAddr.c_str(), strMeeting.c_str()); + LogPrint("net", "connected %s via rendezvous %s\n", strBtfAddr.c_str(), strMeeting.c_str()); // Add node pnode = new CNode(hSocket, addr, false); @@ -461,6 +297,53 @@ CNode* ConnectNodeBtf(const string& strBtfAddr) return pnode; } +// Connect to a peer by its `.btf` address: resolve the self-certified +// descriptor over Nostr, then tunnel through its meeting node with the +// end-to-end channel. Neither side ever learns the other's IP. +CNode* ConnectNodeBtf(const string& strBtfAddr) +{ + unsigned char pk[32]; + if (!btf::ParseAddress(strBtfAddr, pk)) + { + LogPrint("net", "ConnectNodeBtf: invalid address %s\n", strBtfAddr.c_str()); + return NULL; + } + if (BtfLocalAddress() == strBtfAddr) + return NULL; // ourselves + + if (fDebug) + LogPrint("net", "trying %s\n", strBtfAddr.c_str()); + + string strMeeting; + unsigned char enc_pub[32]; + if (!BtfResolve(strBtfAddr, strMeeting, enc_pub)) + { + if (fDebug) + LogPrint("net", "ConnectNodeBtf: could not resolve a descriptor for %s\n", strBtfAddr.c_str()); + return NULL; + } + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); +} + +// Same as ConnectNodeBtf, but skips the Nostr resolve step because the caller +// already resolved the descriptor (e.g. via BtfResolveMany, batching many +// addresses into one relay round-trip instead of one relay round-trip per +// address). Makes zero relay connections -- only talks to the peer's +// rendezvous meeting node. +CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, + const unsigned char enc_pub[32]) +{ + unsigned char pk[32]; + if (!btf::ParseAddress(strBtfAddr, pk)) + { + LogPrint("net", "ConnectNodeBtf: invalid address %s\n", strBtfAddr.c_str()); + return NULL; + } + if (BtfLocalAddress() == strBtfAddr) + return NULL; // ourselves + return ConnectNodeBtfTail(strBtfAddr, pk, strMeeting, enc_pub); +} + // Anonymous inbound listener (this node's `.btf` hidden service). Registers at // the meeting relay and blocks until a client dials our address; each pairing // becomes a normal inbound CNode riding the end-to-end channel, then we @@ -535,7 +418,7 @@ void ThreadBtfAccept(void* parg) RAND_bytes(rnd, sizeof(rnd)); CAddress addr = BtfMarkerAddr(rnd); - printf("accepted .btf connection via rendezvous %s\n", strMeeting.c_str()); + LogPrint("net", "accepted .btf connection via rendezvous %s\n", strMeeting.c_str()); CNode* pnode = new CNode(hSocket, addr, true); pnode->AddRef(); CRITICAL_BLOCK(cs_vNodes) @@ -574,7 +457,7 @@ void ThreadBtfConnect(void* parg) void CNode::Disconnect() { - printf("disconnecting node %s\n", addr.ToString().c_str()); + LogPrint("net", "disconnecting node %s\n", addr.ToString().c_str()); closesocket(hSocket); @@ -653,7 +536,7 @@ void ThreadSocketHandler2(void* parg) if (pnodeExtra->GetRefCount() <= (pnodeExtra->fNetworkNode ? 1 : 0)) { - printf("(%d nodes) disconnecting duplicate: %s\n", vNodes.size(), pnodeExtra->addr.ToString().c_str()); + LogPrint("net", "(%d nodes) disconnecting duplicate: %s\n", vNodes.size(), pnodeExtra->addr.ToString().c_str()); if (pnodeExtra->fNetworkNode && !pnode->fNetworkNode) { pnode->AddRef(); @@ -745,7 +628,7 @@ void ThreadSocketHandler2(void* parg) if (nSelect == SOCKET_ERROR) { int nErr = WSAGetLastError(); - printf("select failed: %d\n", nErr); + LogPrint("net", "select failed: %d\n", nErr); for (int i = 0; i <= hSocketMax; i++) { FD_SET(i, &fdsetRecv); @@ -784,7 +667,7 @@ void ThreadSocketHandler2(void* parg) } else { - printf("accepted connection from %s\n", addr.ToString().c_str()); + LogPrint("net", "accepted connection from %s\n", addr.ToString().c_str()); CNode* pnode = new CNode(hSocket, addr, true); pnode->AddRef(); CRITICAL_BLOCK(cs_vNodes) @@ -823,7 +706,7 @@ void ThreadSocketHandler2(void* parg) { // socket closed gracefully if (!pnode->fDisconnect) - printf("recv: socket closed\n"); + LogPrint("net", "recv: socket closed\n"); pnode->fDisconnect = true; } else if (nBytes < 0) @@ -833,7 +716,7 @@ void ThreadSocketHandler2(void* parg) if (nErr != WSAEWOULDBLOCK && nErr != WSAEMSGSIZE && nErr != WSAEINTR && nErr != WSAEINPROGRESS) { if (!pnode->fDisconnect) - printf("recv failed: %d\n", nErr); + LogPrint("net", "recv failed: %d\n", nErr); pnode->fDisconnect = true; } } @@ -862,7 +745,7 @@ void ThreadSocketHandler2(void* parg) } else { - printf("send error %d\n", nBytes); + LogPrint("net", "send error %d\n", nBytes); if (pnode->ReadyToDisconnect()) pnode->vSend.clear(); } @@ -885,141 +768,6 @@ void ThreadSocketHandler2(void* parg) -void ThreadOpenConnections(void* parg) -{ - IMPLEMENT_RANDOMIZE_STACK(ThreadOpenConnections(parg)); - - loop - { - vfThreadRunning[1] = true; - CheckForShutdown(1); - try - { - ThreadOpenConnections2(parg); - } - CATCH_PRINT_EXCEPTION("ThreadOpenConnections()") - vfThreadRunning[1] = false; - Sleep(5000); - } -} - -void ThreadOpenConnections2(void* parg) -{ - printf("ThreadOpenConnections started\n"); - - // Initiate network connections - const int nMaxConnections = 15; - loop - { - // Wait - vfThreadRunning[1] = false; - Sleep(500); - while (vNodes.size() >= nMaxConnections || vNodes.size() >= mapAddresses.size()) - { - CheckForShutdown(1); - Sleep(2000); - } - vfThreadRunning[1] = true; - CheckForShutdown(1); - - - // Make a list of unique class C's - unsigned char pchIPCMask[4] = { 0xff, 0xff, 0xff, 0x00 }; - unsigned int nIPCMask = *(unsigned int*)pchIPCMask; - vector vIPC; - CRITICAL_BLOCK(cs_mapAddresses) - { - vIPC.reserve(mapAddresses.size()); - unsigned int nPrev = 0; - foreach(const PAIRTYPE(vector, CAddress)& item, mapAddresses) - { - const CAddress& addr = item.second; - if (!addr.IsIPv4()) - continue; - - // Taking advantage of mapAddresses being in sorted order, - // with IPs of the same class C grouped together. - unsigned int ipC = addr.ip & nIPCMask; - if (ipC != nPrev) - vIPC.push_back(nPrev = ipC); - } - } - - // - // The IP selection process is designed to limit vulnerability to address flooding. - // Any class C (a.b.c.?) has an equal chance of being chosen, then an IP is - // chosen within the class C. An attacker may be able to allocate many IPs, but - // they would normally be concentrated in blocks of class C's. They can hog the - // attention within their class C, but not the whole IP address space overall. - // A lone node in a class C will get as much attention as someone holding all 255 - // IPs in another class C. - // - bool fSuccess = false; - int nLimit = vIPC.size(); - while (!fSuccess && nLimit-- > 0) - { - // Choose a random class C - unsigned int ipC = vIPC[GetRand(vIPC.size())]; - - // Organize all addresses in the class C by IP - map > mapIP; - CRITICAL_BLOCK(cs_mapAddresses) - { - unsigned int nDelay = ((30 * 60) << vNodes.size()); - if (nDelay > 8 * 60 * 60) - nDelay = 8 * 60 * 60; - for (map, CAddress>::iterator mi = mapAddresses.lower_bound(CAddress(ipC, 0).GetKey()); - mi != mapAddresses.upper_bound(CAddress(ipC | ~nIPCMask, 0xffff).GetKey()); - ++mi) - { - const CAddress& addr = (*mi).second; - unsigned int nRandomizer = (addr.nLastFailed * addr.ip * 7777U) % 20000; - if (GetTime() - addr.nLastFailed > nDelay * nRandomizer / 10000) - mapIP[addr.ip].push_back(addr); - } - } - if (mapIP.empty()) - break; - - // Choose a random IP in the class C - map >::iterator mi = mapIP.begin(); - advance(mi, GetRand(mapIP.size())); - - // Once we've chosen an IP, we'll try every given port before moving on - foreach(const CAddress& addrConnect, (*mi).second) - { - if (addrConnect.ip == addrLocalHost.ip || !addrConnect.IsIPv4() || FindNode(addrConnect.ip)) - continue; - - CNode* pnode = ConnectNode(addrConnect); - if (!pnode) - continue; - pnode->fNetworkNode = true; - - if (addrLocalHost.IsRoutable()) - { - // Advertise our address - vector vAddrToSend; - vAddrToSend.push_back(addrLocalHost); - pnode->PushMessage("addr", vAddrToSend); - } - - // Get as many addresses as we can - pnode->PushMessage("getaddr"); - - ////// should the one on the receiving end do this too? - // Subscribe our local subscription list - const unsigned int nHops = 0; - for (unsigned int nChannel = 0; nChannel < pnodeLocalHost->vfSubscribe.size(); nChannel++) - if (pnodeLocalHost->vfSubscribe[nChannel]) - pnode->PushMessage("subscribe", nChannel, nHops); - - fSuccess = true; - break; - } - } - } -} @@ -1187,7 +935,7 @@ bool StartNode(string& strError) return false; } - // Get our external IP in a background thread — up to 4 HTTP requests with + // Get our external IP in a background thread -- up to 4 HTTP requests with // 5s timeouts each could otherwise delay startup by 20s on first run. // addrIncoming from a previous session is used immediately as a fallback. if (addrIncoming.ip) @@ -1228,13 +976,6 @@ bool StartNode(string& strError) return false; } - if (_beginthread(ThreadOpenConnections, 0, NULL) == -1) - { - strError = "Error: _beginthread(ThreadOpenConnections) failed"; - printf("%s\n", strError.c_str()); - return false; - } - if (_beginthread(ThreadMessageHandler, 0, NULL) == -1) { strError = "Error: _beginthread(ThreadMessageHandler) failed"; diff --git a/src/net.h b/src/net.h index e732eb3..f028c3b 100644 --- a/src/net.h +++ b/src/net.h @@ -22,12 +22,10 @@ enum -bool ConnectSocket(const CAddress& addrConnect, SOCKET& hSocketRet); bool GetMyExternalIP(unsigned int& ipRet); -bool AddAddress(CAddrDB& addrdb, const CAddress& addr); -CNode* FindNode(unsigned int ip); -CNode* ConnectNode(CAddress addrConnect, int64 nTimeout=0); CNode* ConnectNodeBtf(const string& strBtfAddr); +CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting, + const unsigned char enc_pub[32]); void ThreadBtfAccept(void* parg); void ThreadBtfConnect(void* parg); extern string strBtfConnect; @@ -120,7 +118,7 @@ public: // Message size if (nMessageSize > 0x10000000) { - printf("CMessageHeader::IsValid() : nMessageSize too large %u\n", nMessageSize); + if (LogAcceptsCategory("net")) printf("CMessageHeader::IsValid() : nMessageSize too large %u\n", nMessageSize); return false; } @@ -294,7 +292,7 @@ public: void print() const { - printf("CAddress(%s)\n", ToString().c_str()); + if (LogAcceptsCategory("net")) printf("CAddress(%s)\n", ToString().c_str()); } }; @@ -387,7 +385,7 @@ public: void print() const { - printf("CInv(%s)\n", ToString().c_str()); + if (LogAcceptsCategory("net")) printf("CInv(%s)\n", ToString().c_str()); } }; @@ -426,13 +424,10 @@ extern bool fShutdown; extern array vfThreadRunning; extern vector vNodes; extern CCriticalSection cs_vNodes; -extern map, CAddress> mapAddresses; -extern CCriticalSection cs_mapAddresses; extern map mapRelay; extern deque > vRelayExpiration; extern CCriticalSection cs_mapRelay; extern map mapAlreadyAskedFor; -extern CAddress addrProxy; @@ -462,10 +457,6 @@ public: map mapRequests; CCriticalSection cs_mapRequests; - // flood - vector vAddrToSend; - set setAddrKnown; - // inventory based relay set setInventoryKnown; set setInventoryKnown2; @@ -555,7 +546,7 @@ public: // We're using mapAskFor as a priority queue, // the key is the earliest time the request can be sent int64& nRequestTime = mapAlreadyAskedFor[inv]; - printf("askfor %s %lld\n", inv.ToString().c_str(), nRequestTime); + if (LogAcceptsCategory("net")) printf("askfor %s %lld\n", inv.ToString().c_str(), nRequestTime); // Make sure not to reuse time indexes to keep things in the same order int64 nNow = (GetTime() - 1) * 1000000; @@ -576,7 +567,7 @@ public: AbortMessage(); nPushPos = vSend.size(); vSend << CMessageHeader(pszCommand, 0); - printf("sending: %-12s ", pszCommand); + if (LogAcceptsCategory("net")) printf("sending: %-12s ", pszCommand); } void AbortMessage() @@ -586,7 +577,7 @@ public: vSend.resize(nPushPos); nPushPos = -1; LeaveCriticalSection(&cs_vSend); - printf("(aborted)\n"); + if (LogAcceptsCategory("net")) printf("(aborted)\n"); } void EndMessage() @@ -594,7 +585,7 @@ public: extern int nDropMessagesTest; if (nDropMessagesTest > 0 && GetRand(nDropMessagesTest) == 0) { - printf("dropmessages DROPPING SEND MESSAGE\n"); + if (LogAcceptsCategory("net")) printf("dropmessages DROPPING SEND MESSAGE\n"); AbortMessage(); return; } @@ -606,10 +597,10 @@ public: unsigned int nSize = vSend.size() - nPushPos - sizeof(CMessageHeader); memcpy((char*)&vSend[nPushPos] + offsetof(CMessageHeader, nMessageSize), &nSize, sizeof(nSize)); - printf("(%d bytes) ", nSize); + if (LogAcceptsCategory("net")) printf("(%d bytes) ", nSize); //for (int i = nPushPos+sizeof(CMessageHeader); i < min(vSend.size(), nPushPos+sizeof(CMessageHeader)+20U); i++) // printf("%02x ", vSend[i] & 0xff); - printf("\n"); + if (LogAcceptsCategory("net")) printf("\n"); nPushPos = -1; LeaveCriticalSection(&cs_vSend); diff --git a/src/nostr.cpp b/src/nostr.cpp index c2ccc4c..009edd1 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -45,7 +45,12 @@ static const int64 BTF_POOL_MAX_AGE = 3 * 60; // seconds // Anonymous auto-discovery: how many peers to keep connected via .btf rendezvous // before we stop dialing more, and how many new dials to attempt per cycle. -static const unsigned int BTF_TARGET_CONN = 8; +// Target outbound peer count. With ~100 known .btf peers on the network, +// an Erdos-Renyi random graph stays connected with overwhelming probability +// once average degree exceeds ln(N) (~4.6 here); 24 is ~5x past that +// threshold, so this is plenty *provided* selection isn't fully correlated +// across nodes -- see the diversity slice in ConnectDiscoveredBtfPeers. +static const unsigned int BTF_TARGET_CONN = 24; static const int BTF_DIALS_PER_PASS = 12; // try more peers per Nostr cycle // Nostr descriptor freshness cutoff: skip peers whose descriptor hasn't been @@ -58,11 +63,27 @@ static map g_btfPeers; static CCriticalSection cs_btfPeers; // Public discovery relays. Tunable. +// +// Picked for: free/public (no NIP-42 auth or payment wall), track record of +// high uptime, and *infrastructure* diversity, not just different hostnames. +// relay.damus.io, relay.snort.social and relay.primal.net are all fronted by +// Cloudflare -- different operators, same upstream, so a Cloudflare-side +// incident (or a Cloudflare IP range getting rate-limited/blocked on some +// network) can take out several of them at once even though they look like +// independent relays. Kept only one Cloudflare-fronted relay (damus.io, the +// most established) and filled the rest with relays on different hosts +// (Hetzner, various independents) so no single upstream provider is a +// single point of failure for peer discovery. 7 relays lands inside the +// commonly recommended 5-10 range for redundancy without keeping an +// excessive number of persistent sockets open per node. const char* pszNostrRelays[] = { "wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band", - "wss://relay.snort.social", + "wss://nostr21.com", + "wss://offchain.pub", + "wss://nostr.mom", + "wss://relay.primal.net", }; const int nNostrRelays = ARRAYLEN(pszNostrRelays); @@ -317,6 +338,13 @@ std::string BtfLocalAddress() return g_nostrKey.BtfAddress(); } +int GetDiscoveredPeerCount() +{ + int n = 0; + CRITICAL_BLOCK(cs_btfPeers) { n = (int)g_btfPeers.size(); } + return n; +} + void BtfGetPoolAnnouncements(std::vector& out) { out.clear(); @@ -425,38 +453,55 @@ public: size_t colon = host.find(':'); if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } - // Resolve and connect (TCP) - struct addrinfo hints, *res = NULL; + // Resolve and connect (TCP). AF_UNSPEC so we get both A and AAAA + // records back and try them in the order the resolver prefers, + // rather than hard-failing whenever the IPv4 path to a relay is + // filtered/rate-limited (increasingly common for CDN-fronted + // relays) while a working IPv6 route exists right there in the + // same getaddrinfo() result. + struct addrinfo hints, *res = NULL, *rp = NULL; memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; + hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; char portstr[16]; sprintf(portstr, "%d", port); if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) return error("Nostr: getaddrinfo %s failed", host.c_str()); - hSocket = socket(res->ai_family, res->ai_socktype, res->ai_protocol); - if (hSocket == INVALID_SOCKET) { freeaddrinfo(res); return false; } - // Socket receive/send timeout: Windows takes a DWORD of milliseconds; - // POSIX takes a struct timeval. + bool fConnected = false; + for (rp = res; rp != NULL; rp = rp->ai_next) + { + hSocket = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (hSocket == INVALID_SOCKET) + continue; + + // Socket receive/send timeout: Windows takes a DWORD of + // milliseconds; POSIX takes a struct timeval. #ifdef _WIN32 - DWORD tv = timeoutSec * 1000; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); + DWORD tv = timeoutSec * 1000; + setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); + setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); #else - struct timeval tv; - tv.tv_sec = timeoutSec; - tv.tv_usec = 0; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); + struct timeval tv; + tv.tv_sec = timeoutSec; + tv.tv_usec = 0; + setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); + setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); #endif - if (connect(hSocket, res->ai_addr, (int)res->ai_addrlen) != 0) - { - freeaddrinfo(res); - return error("Nostr: connect %s:%d failed", host.c_str(), port); + if (connect(hSocket, rp->ai_addr, (int)rp->ai_addrlen) == 0) + { + fConnected = true; + break; + } + + closesocket(hSocket); + hSocket = INVALID_SOCKET; } freeaddrinfo(res); + if (!fConnected) + return error("Nostr: connect %s:%d failed", host.c_str(), port); + // TLS if (fTls) { @@ -598,6 +643,88 @@ private: }; +// Persistent relay connections. Public relays (relay.damus.io in particular) +// rate-limit new connections per source IP fairly aggressively. Before this, +// every single operation -- self-test, per-pass seeding, batch descriptor +// resolve, pool announce, pool query -- opened its own fresh CWebSocket, +// meaning a single node could fire a dozen-plus brand-new TCP+TLS+WS +// handshakes at the same 4 relays within any given minute (ThreadNostrSeed's +// own loop plus the separate ThreadBtfPoolAnnouncer thread, each reconnecting +// from scratch every cycle). That's exactly what was showing up as constant +// "handshake refused by relay.damus.io" and "connect ... failed" -- not +// necessarily dead relays, just this node getting rate-limited by its own +// reconnect churn. +// +// Now every relay-touching call reuses one long-lived socket per relay via +// this pool, reconnecting only when a socket actually drops. That cuts fresh +// connections from several per minute down to essentially one per relay for +// the life of the process. +struct RelayConn +{ + CWebSocket ws; + CCriticalSection cs; // serializes use of this relay's socket + bool fConnected; + RelayConn() : fConnected(false) {} +}; +static map g_relayPool; +static CCriticalSection cs_relayPool; + +static RelayConn* GetRelayConn(const string& relay) +{ + CRITICAL_BLOCK(cs_relayPool) + { + map::iterator it = g_relayPool.find(relay); + if (it != g_relayPool.end()) + return it->second; + RelayConn* rc = new RelayConn(); + g_relayPool[relay] = rc; + return rc; + } + return NULL; // unreachable +} + +// RAII lease on a relay's pooled connection: connects (if not already +// connected) on construction, and on destruction either keeps the socket +// alive for the next caller (if the exchange succeeded) or closes it so the +// next caller reconnects instead of reusing a socket that may be wedged. +// Caller must set fOk = true once its exchange with the relay completed +// cleanly. +class CRelayLease +{ +public: + RelayConn* rc; + bool fOk; + bool fJustConnected; // true if this lease had to (re)connect, false if reusing a live socket + + CRelayLease(const string& relay, int timeoutSec) : rc(NULL), fOk(false), fJustConnected(false) + { + rc = GetRelayConn(relay); + rc->cs.Enter(); + if (!rc->fConnected) + { + rc->ws.Close(); + rc->fConnected = rc->ws.Connect(relay, timeoutSec); + fJustConnected = rc->fConnected; + } + } + ~CRelayLease() + { + if (!fOk) + { + rc->ws.Close(); + rc->fConnected = false; + } + rc->cs.Leave(); + } + bool Connected() const { return rc->fConnected; } + CWebSocket& Ws() { return rc->ws; } + +private: + CRelayLease(const CRelayLease&); + CRelayLease& operator=(const CRelayLease&); +}; + + // Publish this node's self-certifying `.btf` descriptor: "reach me (this key) // via , encrypting to my x25519 key ". Signed by the node // key so only the address's owner can publish it (no hijacking). @@ -673,11 +800,11 @@ bool BtfPublishPoolAnnouncement(const BtfPoolAnnouncement& ann) { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; - if (PublishPoolAnnouncementOnRelay(ws, g_nostrKey, ann)) - fPublished = true; + if (PublishPoolAnnouncementOnRelay(lease.Ws(), g_nostrKey, ann)) + fPublished = lease.fOk = true; } CATCH_PRINT_EXCEPTION("BtfPublishPoolAnnouncement") } @@ -699,9 +826,10 @@ bool BtfQueryPoolAnnouncement(const std::string& poolBtfAddr, BtfPoolAnnouncemen { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; + CWebSocket& ws = lease.Ws(); json filter = json::object(); filter["authors"] = json::array({ HexEncode(pk, 32) }); @@ -756,6 +884,7 @@ bool BtfQueryPoolAnnouncement(const std::string& poolBtfAddr, BtfPoolAnnouncemen else if (type == "EOSE") break; } + lease.fOk = true; } CATCH_PRINT_EXCEPTION("BtfQueryPoolAnnouncement") } @@ -855,12 +984,16 @@ bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; btf::Descriptor d; - if (!ResolveDescriptor(ws, g_nostrKey.ctx, btfAddr, d)) + if (!ResolveDescriptor(lease.Ws(), g_nostrKey.ctx, btfAddr, d)) + { + lease.fOk = true; // exchange completed, just no match continue; + } + lease.fOk = true; if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") continue; // descriptor predates the owner's relay config if (!HexDecode(d.enc, enc_pub, 32)) @@ -873,14 +1006,115 @@ bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, return false; } +// Resolve MANY .btf addresses in one pass. A single relay connection carries +// a REQ with every still-unresolved address's pubkey in "authors", instead of +// opening one relay connection per address. This matters because callers like +// ConnectDiscoveredBtfPeers dial many candidates in parallel; without +// batching, each of those dial threads independently walks the same handful +// of public relays on its own, so a pass of N candidates can fire up to +// N * nNostrRelays near-simultaneous connections at the same few relays -- +// enough to get rate-limited or have handshakes refused, which then looks +// like "dead peers" even though the descriptors are perfectly resolvable. +bool BtfResolveMany(const std::vector& btfAddrs, + std::map& out) +{ + if (!EnsureNostrKey() || btfAddrs.empty()) + return false; + + map hexToAddr; // pubkey hex -> .btf addr, for matching events back + set pending; + for (const string& a : btfAddrs) + { + unsigned char pk[32]; + if (!btf::ParseAddress(a, pk)) + continue; + hexToAddr[HexEncode(pk, 32)] = a; + pending.insert(a); + } + if (pending.empty()) + return false; + + for (int i = 0; i < nNostrRelays && !fShutdown && !pending.empty(); i++) + { + try + { + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) + continue; + CWebSocket& ws = lease.Ws(); + + json authors = json::array(); + for (auto& kv : hexToAddr) + if (pending.count(kv.second)) + authors.push_back(kv.first); + if (authors.empty()) + { + lease.fOk = true; + break; + } + + json filter = json::object(); + filter["authors"] = authors; + filter["kinds"] = json::array({ BTF_DESC_KIND }); + filter["limit"] = (int)authors.size(); + json req = json::array({ "REQ", "btf-resolve-many", filter }); + if (!ws.SendText(req.dump())) + continue; + + for (;;) + { + string msg; + if (!ws.RecvText(msg)) + break; + json j; + try { j = json::parse(msg); } catch (...) { continue; } + if (!j.is_array() || j.empty()) continue; + string t = j[0].get(); + if (t == "EVENT" && j.size() >= 3) + { + const json& ev = j[2]; + if (!ev.contains("pubkey") || !ev.contains("content")) + continue; + string hexpk = ev["pubkey"].get(); + auto ai = hexToAddr.find(hexpk); + if (ai == hexToAddr.end() || !pending.count(ai->second)) + continue; // not one we're waiting on (or already resolved) + + unsigned char pk[32]; + if (!HexDecode(hexpk, pk, 32)) + continue; + btf::Descriptor d; + if (!btf::VerifyDescriptor(g_nostrKey.ctx, ev["content"].get(), pk, d)) + continue; + if (d.meeting_node.empty() || d.meeting_node == "rendezvous-pending") + continue; + BtfResolvedPeer rp; + if (!HexDecode(d.enc, rp.enc_pub, 32)) + continue; + rp.meetingHostPort = d.meeting_node; + out[ai->second] = rp; + pending.erase(ai->second); + } + else if (t == "EOSE") + break; + } + lease.fOk = true; + } + CATCH_PRINT_EXCEPTION("BtfResolveMany") + } + return !out.empty(); +} + // Connect to a relay: publish our .btf descriptor and collect relay data. static bool SeedFromRelay(CNostrKey& key, const string& relay) { - CWebSocket ws; - if (!ws.Connect(relay, 10)) + CRelayLease lease(relay, 10); + if (!lease.Connected()) return false; - printf("Nostr: connected to %s\n", relay.c_str()); + CWebSocket& ws = lease.Ws(); + if (lease.fJustConnected) + LogPrint("nostr", "Nostr: connected to %s\n", relay.c_str()); // Publish our self-certifying .btf descriptor (rendezvous discovery) PublishDescriptor(ws, key); @@ -930,7 +1164,7 @@ static bool SeedFromRelay(CNostrKey& key, const string& relay) else if (createdAt > it->second) it->second = createdAt; // refresh } if (fNew && fDebug) - printf("Nostr: discovered .btf peer %s\n", peerAddr.c_str()); + LogPrint("nostr", "Nostr: discovered .btf peer %s\n", peerAddr.c_str()); if (++nPeers > 500) break; } else if (type == "EOSE") @@ -974,7 +1208,7 @@ static bool SeedFromRelay(CNostrKey& key, const string& relay) if (g_discoveredRelays.size() < BTF_MAX_DISCOVERED_RELAYS) fNew = g_discoveredRelays.insert(r).second; if (fNew && fDebug) - printf("Nostr: discovered relay %s\n", r.c_str()); + LogPrint("nostr", "Nostr: discovered relay %s\n", r.c_str()); } else if (type == "EOSE") break; @@ -1009,12 +1243,15 @@ static bool SeedFromRelay(CNostrKey& key, const string& relay) } } } + lease.fOk = true; return true; } +volatile bool gAnnounceNow = false; + void ThreadBtfPoolAnnouncer(void*) { - printf("ThreadBtfPoolAnnouncer started\n"); + LogPrint("pool", "ThreadBtfPoolAnnouncer started\n"); while (!fShutdown) { if (nMineMode == MINE_OPERATOR && gPoolRunning) @@ -1025,15 +1262,17 @@ void ThreadBtfPoolAnnouncer(void*) ann.dashboardUrl = strPoolDashboardUrl; ann.feePercent = dPoolFeePercent; uint64 roundShares = 0; - GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares); - ann.hashRate = 0.0; + double totalHashRate = 0.0; + GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares, totalHashRate); + ann.hashRate = totalHashRate; ann.createdAt = GetTime(); if (!ann.btfAddress.empty() && !ann.poolName.empty()) BtfPublishPoolAnnouncement(ann); } - for (int i = 0; i < 60 && !fShutdown; i++) + for (int i = 0; i < 60 && !fShutdown && !gAnnounceNow; i++) Sleep(1000); + gAnnounceNow = false; } } @@ -1042,10 +1281,29 @@ void ThreadBtfPoolAnnouncer(void*) // dead/stale descriptors (e.g. abandoned test identities that linger on the // relays) from consuming the whole per-pass dial budget and starving the live // peers. Backoff grows on repeated failures, capped. +// +// g_btfPeerFails also doubles as the priority signal for dial ordering: a peer +// we've connected to before (present in g_btfPeerEverConnected) is known-good +// and goes first; a peer we've never tried goes next; a peer with a growing +// fail count is known-bad and goes last, worst offenders furthest back. This +// replaces the old behavior where the whole candidate list was shuffled right +// after being sorted by freshness, which made the freshness sort a no-op and +// gave dead peers equal footing with everyone else. static map g_btfPeerBackoff; // addr -> next-try time static map g_btfPeerFails; // addr -> consecutive failures +static set g_btfPeerEverConnected; // addr -> has succeeded at least once static CCriticalSection cs_btfPeerState; +// Backoff cap for repeatedly-dead peers. The old 15-minute cap meant a +// descriptor that has failed many times in a row cycles back into the dial +// pool just as often as a peer we've never tried, wasting dial budget on +// known-dead addresses every pass. Once a peer has failed enough times to +// be "chronic", stretch the cap much further so it consumes budget far less +// often while still getting retried eventually in case it comes back online. +static const int64 BTF_BACKOFF_CAP_SECS = 15 * 60; // 15 min: initial ramp cap +static const int64 BTF_BACKOFF_CAP_SECS_DEAD = 6 * 60 * 60; // 6 hr: cap for chronic failures +static const int BTF_CHRONIC_FAIL_THRESHOLD = 6; // fails before "chronic" cap kicks in + // Dial the .btf peers discovered on the relays, up to a target connection count. // Connections are attempted in parallel so a single slow/dead relay doesn't // stall the entire pass. Each candidate gets its own thread; all threads start @@ -1060,23 +1318,18 @@ static void ConnectDiscoveredBtfPeers() for (auto& kv : g_btfPeers) peersWithAge.push_back({kv.second, kv.first}); } - // Sort newest descriptor first — recently-active nodes are much more - // likely to be online right now. Stale ones stay in the list as fallback. - sort(peersWithAge.begin(), peersWithAge.end(), - [](const pair& a, const pair& b){ return a.first > b.first; }); - int64 cutoff = GetTime() - BTF_PEER_MAX_AGE; int nFresh = 0; - vector peers; + vector> fresh; // (createdAt, addr), newest first for (auto& p : peersWithAge) { if (p.first < cutoff) continue; - peers.push_back(p.second); + fresh.push_back(p); nFresh++; } - if (!peers.empty()) - printf("Nostr: %d fresh peers, %d stale (of %d total)\n", nFresh, nTotal-nFresh, nTotal); + if (!fresh.empty()) + LogPrint("nostr", "Nostr: %d fresh peers, %d stale (of %d total)\n", nFresh, nTotal-nFresh, nTotal); // Drop stale peers so we stop retrying dead descriptors until they refresh. { @@ -1092,42 +1345,164 @@ static void ConnectDiscoveredBtfPeers() } } - // Shuffle so we don't always retry the same dead peers at the front. - for (size_t i = peers.size(); i > 1; i--) - swap(peers[i - 1], peers[(size_t)GetRand(i)]); + // Sort newest descriptor first -- recently-active nodes are much more + // likely to be online right now. + sort(fresh.begin(), fresh.end(), + [](const pair& a, const pair& b){ return a.first > b.first; }); + + // Reorder into tiers: known-good (succeeded before) first, untried next, + // known-bad (has a fail count) last -- worst offenders furthest back. + // Freshness order is preserved *within* each tier, so recency still + // matters, but a peer we know is dead no longer gets equal footing with + // a peer we've never tried or one that's worked before. + vector tierGood, tierUntried, tierBad; + CRITICAL_BLOCK(cs_btfPeerState) + { + for (auto& p : fresh) + { + const string& addr = p.second; + if (g_btfPeerEverConnected.count(addr)) + tierGood.push_back(addr); + else if (g_btfPeerFails.find(addr) == g_btfPeerFails.end()) + tierUntried.push_back(addr); + else + tierBad.push_back(addr); + } + // Within known-bad, put fewer-fails peers ahead of chronic offenders. + sort(tierBad.begin(), tierBad.end(), + [](const string& a, const string& b){ return g_btfPeerFails[a] < g_btfPeerFails[b]; }); + } + + vector peers; + peers.reserve(fresh.size()); + peers.insert(peers.end(), tierGood.begin(), tierGood.end()); + peers.insert(peers.end(), tierUntried.begin(), tierUntried.end()); + peers.insert(peers.end(), tierBad.begin(), tierBad.end()); // Pick candidates for this pass (skip those in backoff, cap at budget). + // + // The tiered order above is deliberately biased toward peers we know + // are good -- that's the right default, since dialing untested/bad + // peers wastes attempts. But if every node on the network applies the + // same bias, everyone converges on the same small set of "known good" + // peers as neighbors: that correlates peer sets across the network, + // which is exactly what standard random-graph connectivity results + // assume you avoid. If that shared hub set has a bad day (e.g. one + // relay outage), many nodes lose the same edges at once instead of + // losing independent, uncorrelated edges. + // + // So: fill most of the pass from the reliable/tiered order, but reserve + // a slice for peers picked at random from the *entire* fresh pool + // (not just the front of the sorted list). Same pattern Bitcoin's own + // peer manager and Kademlia-style DHTs use -- sticky reliable peers + // plus a random sample, not one or the other. int64 now = GetTime(); + int nBudget = (int)BTF_DIALS_PER_PASS; + { + int nRoom = (int)BTF_TARGET_CONN - (int)vNodes.size(); + if (nRoom < nBudget) nBudget = nRoom; + } + if (nBudget < 0) nBudget = 0; + int nRandomSlots = (nBudget > 0) ? max(1, nBudget * 3 / 10) : 0; + int nSortedSlots = nBudget - nRandomSlots; + vector candidates; + set setChosen; CRITICAL_BLOCK(cs_btfPeerState) { + // Reliability slice: walk the tiered (good -> untried -> bad) order. for (const string& addr : peers) { if ((int)vNodes.size() >= (int)BTF_TARGET_CONN) break; - if ((int)candidates.size() >= BTF_DIALS_PER_PASS) break; + if ((int)candidates.size() >= nSortedSlots) break; map::iterator bi = g_btfPeerBackoff.find(addr); if (bi != g_btfPeerBackoff.end() && now < bi->second) continue; candidates.push_back(addr); + setChosen.insert(addr); + } + + // Diversity slice: random sample from the full fresh pool, so the + // dial isn't limited to whatever the tiered sort put up front. + vector pool; + for (auto& p : fresh) + { + const string& addr = p.second; + if (setChosen.count(addr)) + continue; + map::iterator bi = g_btfPeerBackoff.find(addr); + if (bi != g_btfPeerBackoff.end() && now < bi->second) + continue; + pool.push_back(addr); + } + int nWant = min(nRandomSlots, (int)pool.size()); + for (int i = 0; i < nWant; i++) + { + if ((int)vNodes.size() + (int)candidates.size() >= (int)BTF_TARGET_CONN) break; + if ((int)candidates.size() >= nBudget) break; + size_t j = i + (size_t)GetRand((uint64)(pool.size() - i)); + std::swap(pool[i], pool[j]); + candidates.push_back(pool[i]); + setChosen.insert(pool[i]); } } if (candidates.empty()) return; - // Launch one thread per candidate — all connect attempts run in parallel. + // Resolve every candidate's descriptor in one batched pass (a handful of + // relay round-trips total, one REQ per relay covering all candidates) + // instead of each dial thread independently walking the relay list on + // its own. That per-thread approach meant a 12-candidate pass could fire + // up to 12 * nNostrRelays near-simultaneous connections at the same + // handful of public relays -- enough to trip rate limits or get + // handshakes refused, which then looks exactly like "dead peers" even + // though the descriptors were perfectly resolvable. + map resolved; + BtfResolveMany(candidates, resolved); + + // Candidates whose descriptor didn't resolve this pass are recorded as a + // failure right away (no point spawning a tunnel thread with nothing to + // dial), same bookkeeping as a failed connect attempt. + vector toDial; + CRITICAL_BLOCK(cs_btfPeerState) + { + for (const string& addr : candidates) + { + if (resolved.count(addr)) + { + toDial.push_back(addr); + continue; + } + int n = ++g_btfPeerFails[addr]; + int64 cap = (n >= BTF_CHRONIC_FAIL_THRESHOLD) ? BTF_BACKOFF_CAP_SECS_DEAD + : BTF_BACKOFF_CAP_SECS; + int64 secs = 30; + for (int k = 1; k < n && secs < cap; k++) secs *= 2; + if (secs > cap) secs = cap; + g_btfPeerBackoff[addr] = GetTime() + secs; + } + } + if (toDial.empty()) return; + + // Launch one thread per resolved candidate -- all connect attempts run in + // parallel, but purely against each peer's own rendezvous meeting node, + // not the shared Nostr relays, so there's no thundering-herd effect here. // Each thread writes its result back via shared state guarded by cs_btfPeerState. - struct DialCtx { string addr; }; + struct DialCtx { string addr; BtfResolvedPeer rp; }; vector ctxs; - for (const string& addr : candidates) + for (const string& addr : toDial) { - DialCtx* ctx = new DialCtx{addr}; + DialCtx* ctx = new DialCtx{addr, resolved[addr]}; ctxs.push_back(ctx); _beginthread([](void* arg) { DialCtx* ctx = (DialCtx*)arg; string addr = ctx->addr; + string meeting = ctx->rp.meetingHostPort; + unsigned char enc_pub[32]; + memcpy(enc_pub, ctx->rp.enc_pub, 32); delete ctx; - CNode* pnode = ConnectNodeBtf(addr); + CNode* pnode = ConnectNodeBtfResolved(addr, meeting, enc_pub); CRITICAL_BLOCK(cs_btfPeerState) { if (pnode) @@ -1138,13 +1513,16 @@ static void ConnectDiscoveredBtfPeers() pnode->Release(); g_btfPeerBackoff.erase(addr); g_btfPeerFails.erase(addr); + g_btfPeerEverConnected.insert(addr); } else { int n = ++g_btfPeerFails[addr]; + int64 cap = (n >= BTF_CHRONIC_FAIL_THRESHOLD) ? BTF_BACKOFF_CAP_SECS_DEAD + : BTF_BACKOFF_CAP_SECS; int64 secs = 30; - for (int k = 1; k < n && secs < 15*60; k++) secs *= 2; - if (secs > 15*60) secs = 15*60; + for (int k = 1; k < n && secs < cap; k++) secs *= 2; + if (secs > cap) secs = cap; g_btfPeerBackoff[addr] = GetTime() + secs; } } @@ -1160,16 +1538,16 @@ static void ConnectDiscoveredBtfPeers() void ThreadNostrSeed(void* parg) { - printf("ThreadNostrSeed started\n"); + LogPrint("nostr", "ThreadNostrSeed started\n"); if (!EnsureNostrKey()) { - printf("Nostr: failed to initialize secp256k1 key\n"); + LogPrint("nostr", "Nostr: failed to initialize secp256k1 key\n"); return; } CNostrKey& key = g_nostrKey; - printf("Nostr: node pubkey = %s\n", key.PubKeyHex().c_str()); - printf("Nostr: node .btf address = %s\n", key.BtfAddress().c_str()); + LogPrint("nostr", "Nostr: node pubkey = %s\n", key.PubKeyHex().c_str()); + LogPrint("nostr", "Nostr: node .btf address = %s\n", key.BtfAddress().c_str()); // One-time self-test of the rendezvous: publish our descriptor to a relay, // then resolve our OWN .btf address back and verify it self-certifies. Proves @@ -1178,17 +1556,20 @@ void ThreadNostrSeed(void* parg) { try { - CWebSocket ws; - if (!ws.Connect(pszNostrRelays[i], 10)) continue; + CRelayLease lease(pszNostrRelays[i], 10); + if (!lease.Connected()) continue; + CWebSocket& ws = lease.Ws(); PublishDescriptor(ws, key); Sleep(700); // let the relay store the replaceable event btf::Descriptor d; if (ResolveDescriptor(ws, key.ctx, key.BtfAddress(), d)) { - printf("Nostr: .btf self-resolve OK via %s (meeting_node=%s, enc=%s...)\n", + LogPrint("nostr", "Nostr: .btf self-resolve OK via %s (meeting_node=%s, enc=%s...)\n", pszNostrRelays[i], d.meeting_node.c_str(), d.enc.substr(0, 16).c_str()); + lease.fOk = true; break; } + lease.fOk = true; } CATCH_PRINT_EXCEPTION("btf self-test") } @@ -1223,8 +1604,9 @@ void ThreadNostrSeed(void* parg) ann.dashboardUrl = strPoolDashboardUrl; ann.feePercent = dPoolFeePercent; uint64 roundShares = 0; - GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares); - ann.hashRate = 0.0; + double totalHashRate = 0.0; + GetPoolOperatorStats(ann.connectedMiners, ann.blocksFound, roundShares, totalHashRate); + ann.hashRate = totalHashRate; ann.createdAt = GetTime(); if (!ann.btfAddress.empty() && !ann.poolName.empty()) BtfPublishPoolAnnouncement(ann); diff --git a/src/nostr.h b/src/nostr.h index 63fc095..37b3b05 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -12,11 +12,17 @@ #include #include +#include // Thread entry point (signature compatible with _beginthread) void ThreadNostrSeed(void* parg); void ThreadBtfPoolAnnouncer(void* parg); +// Set to true to make ThreadBtfPoolAnnouncer publish immediately instead of +// waiting for its ~60s sleep loop (e.g. right after the operator changes pool +// name/fee/dashboard URL in Options). The announcer clears it after publishing. +extern volatile bool gAnnounceNow; + // Public relays used for discovery. Tunable. extern const char* pszNostrRelays[]; extern const int nNostrRelays; @@ -56,6 +62,9 @@ struct BtfPoolAnnouncement } }; +// Number of .btf peers discovered from Nostr relays (includes not-yet-connected). +int GetDiscoveredPeerCount(); + // Current live pool announcements discovered from Nostr relays. void BtfGetPoolAnnouncements(std::vector& out); @@ -78,4 +87,21 @@ std::string BtfLocalAddress(); bool BtfResolve(const std::string& btfAddr, std::string& meetingHostPort, unsigned char enc_pub[32]); +// Result of resolving one .btf address via BtfResolveMany. +struct BtfResolvedPeer +{ + std::string meetingHostPort; + unsigned char enc_pub[32]; +}; + +// Resolve many .btf addresses at once. Batches all still-unresolved addresses +// into a single Nostr REQ per relay (multiple "authors"), instead of opening +// one relay connection per address like BtfResolve does. Use this whenever +// resolving more than a handful of addresses in the same pass -- e.g. before +// dialing several discovered peers in parallel -- to avoid hammering the +// same few public relays with a burst of simultaneous connections. Returns +// true if at least one address resolved; check `out` for which ones. +bool BtfResolveMany(const std::vector& btfAddrs, + std::map& out); + #endif diff --git a/src/rc/addressbook16.bmp b/src/rc/addressbook16.bmp deleted file mode 100644 index c557691..0000000 Binary files a/src/rc/addressbook16.bmp and /dev/null differ diff --git a/src/rc/addressbook16mask.bmp b/src/rc/addressbook16mask.bmp deleted file mode 100644 index d3a478d..0000000 Binary files a/src/rc/addressbook16mask.bmp and /dev/null differ diff --git a/src/rc/addressbook20.bmp b/src/rc/addressbook20.bmp deleted file mode 100644 index 2b33b22..0000000 Binary files a/src/rc/addressbook20.bmp and /dev/null differ diff --git a/src/rc/addressbook20mask.bmp b/src/rc/addressbook20mask.bmp deleted file mode 100644 index 56ce612..0000000 Binary files a/src/rc/addressbook20mask.bmp and /dev/null differ diff --git a/src/rc/bitcoin_bc_original.ico b/src/rc/bitcoin_bc_original.ico deleted file mode 100644 index 88cc240..0000000 Binary files a/src/rc/bitcoin_bc_original.ico and /dev/null differ diff --git a/src/rc/check.ico b/src/rc/check.ico deleted file mode 100644 index 0c4e6e8..0000000 Binary files a/src/rc/check.ico and /dev/null differ diff --git a/src/rc/send16.bmp b/src/rc/send16.bmp deleted file mode 100644 index 676b5c4..0000000 Binary files a/src/rc/send16.bmp and /dev/null differ diff --git a/src/rc/send16mask.bmp b/src/rc/send16mask.bmp deleted file mode 100644 index 06c747f..0000000 Binary files a/src/rc/send16mask.bmp and /dev/null differ diff --git a/src/rc/send16masknoshadow.bmp b/src/rc/send16masknoshadow.bmp deleted file mode 100644 index faf24e0..0000000 Binary files a/src/rc/send16masknoshadow.bmp and /dev/null differ diff --git a/src/rc/send20.bmp b/src/rc/send20.bmp deleted file mode 100644 index 2b90422..0000000 Binary files a/src/rc/send20.bmp and /dev/null differ diff --git a/src/rc/send20mask.bmp b/src/rc/send20mask.bmp deleted file mode 100644 index f124d0d..0000000 Binary files a/src/rc/send20mask.bmp and /dev/null differ diff --git a/src/rpc.cpp b/src/rpc.cpp index 99d182e..1b529e8 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1,14 +1,19 @@ // Copyright (c) 2009 Satoshi Nakamoto / Copyright (c) 2026 Bitflash developers // Distributed under the MIT/X11 software license. // -// Bitflash pool server — .btf-only pool listener. -// Miners connect through rendezvous tunnels; no raw IP:port endpoints remain. -// Pending payouts are held until maturity and shown in the GUI. +// Bitflash pool server -- .btf Stratum pool, operator side. // -// Compiles on both Windows (mingw) and Linux unchanged. -// Launched from ui.cpp (GUI) and headless.cpp (node) via ThreadRPCServer(). +// Architecture: +// PoolEventLoop -- single thread, owns all miner fds, all job/share/payout +// state. No locks needed on any of that data. +// AcceptOneFn -- one thread per relay. Blocks on RvServiceRegister + +// BtfServiceWrap, pushes finished socket to gReadyQueue. +// AcceptLoopFn -- spawns AcceptOneFn threads, re-polls relay list every +// 60s so dynamically discovered relays get picked up. +// +// GUI-visible state is snapshotted under gStatsMutex every 2s. +// Accept queue handoff uses gReadyMutex (held for microseconds). -// nlohmann must be included before headers.h because util.h redefines snprintf #pragma push_macro("snprintf") #undef snprintf #include @@ -20,8 +25,6 @@ #endif #include "btftunnel.h" -// POSIX socket headers — already provided by compat.h on Windows, -// and by the OS directly on Linux. #ifndef _WIN32 #include #include @@ -33,14 +36,15 @@ #include #include #include +#include #include +#include +#include #include #include -#include using json = nlohmann::json; -// Cross-platform socket close / send flags #ifdef _WIN32 #define sock_close(s) closesocket(s) #define SEND_FLAGS 0 @@ -49,660 +53,1260 @@ using json = nlohmann::json; #define SEND_FLAGS MSG_NOSIGNAL #endif -// Cross-platform thread launch: wraps _beginthread (compat.h maps it to -// pthread_create+detach on Linux, native _beginthread on Windows). -// fn must be void(*)(void*). -static void LaunchThread(void (*fn)(void*), void* arg) -{ - _beginthread(fn, 0, arg); -} - -// --------------------------------------------------------------------------- -// Config — set by headless.cpp / ui.cpp before threads launch -// --------------------------------------------------------------------------- -std::string gRpcUser; -std::string gRpcPassword; - -// Pool server running flag — set false to stop server cleanly (all loops check it) volatile bool gPoolRunning = false; -// --------------------------------------------------------------------------- -// Stubs for symbols absent from Bitflash 0.1.0 -// --------------------------------------------------------------------------- -double dHashesPerSec = 0.0; -int64 nHPSTimerStart = 0; - -static bool IsInitialBlockDownload() -{ - return (pindexBest == NULL || nBestHeight < 1); -} - // --------------------------------------------------------------------------- // Hex helpers // --------------------------------------------------------------------------- static std::string ToHex(const void* p, size_t n) { const unsigned char* b = (const unsigned char*)p; - std::string s; s.reserve(n*2); + std::string s; s.reserve(n * 2); static const char* H = "0123456789abcdef"; - for (size_t i = 0; i < n; i++) { s += H[b[i]>>4]; s += H[b[i]&0xf]; } + for (size_t i = 0; i < n; i++) { s += H[b[i] >> 4]; s += H[b[i] & 0xf]; } return s; } + static std::vector FromHex(const std::string& s) { std::vector v; - for (size_t i = 0; i+1 < s.size(); i += 2) { - auto h=[](char c)->int{ - if(c>='0'&&c<='9') return c-'0'; - if(c>='a'&&c<='f') return c-'a'+10; - if(c>='A'&&c<='F') return c-'A'+10; + for (size_t i = 0; i + 1 < s.size(); i += 2) { + auto h = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; return 0; }; - v.push_back((unsigned char)((h(s[i])<<4)|h(s[i+1]))); + v.push_back((unsigned char)((h(s[i]) << 4) | h(s[i + 1]))); } return v; } // --------------------------------------------------------------------------- -// PPLNS share tracking -// --------------------------------------------------------------------------- -struct Share { - std::string minerAddress; - int64 timestamp; - uint64 difficulty; -}; - -struct MinerStats { - std::string address; - std::string worker; - uint64 totalShares = 0; - uint64 roundShares = 0; - int64 lastSeen = 0; -}; -static std::mutex gMinerStatsMutex; -static std::map gMinerStats; - -struct FoundBlock { - int height; - int64 timestamp; - std::string minerAddress; - uint256 hash; -}; -static std::mutex gBlocksMutex; -static std::deque gFoundBlocks; - -struct Miner; -static std::mutex gMinersMutex; -static std::vector gMiners; - -static std::string MinerKey(const std::string& address, const std::string& worker) -{ - return address + "\n" + worker; -} - -void GetPoolWorkerStats(std::vector& out) -{ - out.clear(); - int64 now = GetTime(); - std::lock_guard lk(gMinerStatsMutex); - for (std::map::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) - { - const MinerStats& ms = it->second; - if (ms.lastSeen <= 0) - continue; - if (now - ms.lastSeen > 15 * 60) - continue; - PoolWorkerStatView view; - view.address = ms.address; - view.worker = ms.worker; - view.totalShares = ms.totalShares; - view.roundShares = ms.roundShares; - view.lastSeen = ms.lastSeen; - out.push_back(view); - } - std::sort(out.begin(), out.end(), [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { - if (a.roundShares != b.roundShares) return a.roundShares > b.roundShares; - if (a.totalShares != b.totalShares) return a.totalShares > b.totalShares; - return a.lastSeen > b.lastSeen; - }); -} - -// --------------------------------------------------------------------------- -// Current mining job +// Job -- written and read exclusively by the event loop thread // --------------------------------------------------------------------------- struct StratumJob { std::string jobId; CBlock block; - uint256 target; - uint256 shareTarget; + uint256 target; // full block target; share targets are per-miner via vardiff int height; }; +static StratumJob gCurrentJob; +static bool gHaveJob = false; + +// Compute a share target from a Stratum difficulty value. +// Stratum difficulty 1 must correspond to THIS chain's easiest allowed target +// (bnProofOfWorkLimit = ~uint256(0) >> 12, compact 0x1f0fffff), not Bitcoin's +// SHA256 diff-1 target (0x1d00ffff). Those differ by about 2^20 (~1e6x): +// Bitcoin's diff-1 needs ~2^32 hashes/share on average, while this chain's PoW +// floor needs only ~2^12. Using the Bitcoin constant made every miner's +// "difficulty 1" about a million times harder than the chain's own floor, so a +// worker could hash for minutes without ever finding a share -- and vardiff +// never gets the samples it needs to correct course, because no share ever +// comes in for it to measure. Base off the actual chain limit instead so +// difficulty 1 is genuinely easy and vardiff has real data to retarget from. +// Higher difficulty = smaller (harder) target. +static uint256 ShareTargetFromDifficulty(double difficulty) +{ + if (difficulty <= 0.0) difficulty = 1.0; + static const uint256 diff1 = CBigNum(bnProofOfWorkLimit).getuint256(); + // shareTarget = diff1 / difficulty + CBigNum bn; bn.setuint256(diff1); + // Multiply by 2^16 to keep precision, divide, shift back + bn <<= 16; + bn /= (uint64)(difficulty * 65536.0); + uint256 t = bn.getuint256(); + // Cap at all-ones (maximum, easiest possible) + if (t < diff1 && difficulty < 1.0) t = ~uint256(0); + return t; +} -static std::mutex gJobMutex; -static StratumJob gCurrentJob; -static bool gHaveJob = false; -static std::atomic gJobSeq{0}; - -static uint256 MakeShareTarget(const uint256& blockTarget) +// Expected number of hashes to find one share at a given Stratum difficulty, +// on this chain's scale (difficulty 1 == bnProofOfWorkLimit, which needs on +// average 2^12 = 4096 hashes). Used to turn "shares per second" into H/s. +static const double SHARE_HASHES_AT_DIFF_1 = 4096.0; +static double ExpectedHashesForDifficulty(double difficulty) { - uint256 shareTarget = blockTarget; - shareTarget <<= 8; - if (shareTarget < blockTarget) - shareTarget = ~uint256(0); - return shareTarget; + if (difficulty <= 0.0) difficulty = 1.0; + return difficulty * SHARE_HASHES_AT_DIFF_1; } +// Rebuild the current job from the best chain tip. +// Must be called from the event loop thread (or startup, before the loop). +// Returns true on success. Logs the specific failure reason on false. static bool RebuildJob() { - if (!pindexBest) return false; + CRITICAL_BLOCK(cs_main) + { + if (!pindexBest) { + LogPrint("pool", "RebuildJob: no chain tip (pindexBest is null)\n"); + return false; + } - CBlockIndex* pindexPrev = pindexBest; - unsigned int nBits = GetNextWorkRequired(pindexPrev); + CBlockIndex* pindexPrev = pindexBest; + unsigned int nBits = GetNextWorkRequired(pindexPrev); - CKey key; - key.MakeNewKey(); - static std::atomic sExtra{0}; - uint32_t extraNonce = ++sExtra; + CKey key; key.MakeNewKey(); + static std::atomic sExtra{0}; + uint32_t extraNonce = ++sExtra; - CTransaction txNew; - txNew.vin.resize(1); - txNew.vin[0].prevout.SetNull(); - txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce; - txNew.vout.resize(1); - // Always pay coinbase to a fresh wallet key. DoPayouts() distributes - // shares to miners via SendMoney() after the block is accepted. - txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; + CTransaction txNew; + txNew.vin.resize(1); + txNew.vin[0].prevout.SetNull(); + txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce; + txNew.vout.resize(1); + txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; - CBlock block; - block.vtx.push_back(txNew); + CBlock block; + block.vtx.push_back(txNew); - { - CTxDB txdb("r"); - CRITICAL_BLOCK(cs_mapTransactions) { - map pool; - unsigned int sz = 0; - for (auto& kv : mapTransactions) { - CTransaction& tx = kv.second; - if (tx.IsCoinBase() || !tx.IsFinal()) continue; - int64 nFees = 0; - int64 nMinFee = tx.GetMinFee(block.vtx.size() < 100); - map tmp(pool); - if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, false, true, nMinFee)) continue; - pool = tmp; - block.vtx.push_back(tx); - sz += ::GetSerializeSize(tx, SER_NETWORK); - if (sz > MAX_SIZE/2) break; + CTxDB txdb("r"); + CRITICAL_BLOCK(cs_mapTransactions) + { + map pool; + unsigned int sz = 0; + for (auto& kv : mapTransactions) { + CTransaction& tx = kv.second; + if (tx.IsCoinBase() || !tx.IsFinal()) continue; + int64 nFees = 0; + map tmp(pool); + if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, + false, true, tx.GetMinFee(block.vtx.size() < 100))) + continue; + pool = tmp; + block.vtx.push_back(tx); + sz += ::GetSerializeSize(tx, SER_NETWORK); + if (sz > MAX_SIZE / 2) break; + } } } + + block.vtx[0].vout[0].nValue = block.GetBlockValue(0); + block.hashPrevBlock = pindexPrev->GetBlockHash(); + block.hashMerkleRoot = block.BuildMerkleTree(); + block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast() + 1), + (unsigned int)GetAdjustedTime()); + block.nBits = nBits; + block.nNonce = 1; + AddKey(key); + + StratumJob job; + job.jobId = ToHex(&extraNonce, 4); + job.block = block; + job.target = CBigNum().SetCompact(nBits).getuint256(); + job.height = nBestHeight + 1; + + gCurrentJob = job; + gHaveJob = true; + return true; } - block.vtx[0].vout[0].nValue = block.GetBlockValue(0); - block.hashPrevBlock = pindexPrev->GetBlockHash(); - block.hashMerkleRoot = block.BuildMerkleTree(); - block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast()+1), - (unsigned int)GetAdjustedTime()); - block.nBits = nBits; - block.nNonce = 1; - - AddKey(key); - - StratumJob job; - job.jobId = ToHex(&extraNonce, 4); - job.block = block; - job.target = CBigNum().SetCompact(nBits).getuint256(); - job.shareTarget = MakeShareTarget(job.target); - job.height = nBestHeight + 1; - - std::lock_guard lk(gJobMutex); - gCurrentJob = job; - gHaveJob = true; - ++gJobSeq; - return true; + return false; } // --------------------------------------------------------------------------- -// PPLNS payout -// --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Deferred payouts — coinbase outputs require COINBASE_MATURITY (100) blocks -// before they can be spent. We store the share snapshot when a block is found -// and execute the actual SendMoney() calls once the coinbase matures. +// Payouts -- event loop thread only // --------------------------------------------------------------------------- struct PendingPayout { - int matureAtHeight; // pay out when nBestHeight >= this - std::map amounts; // addr -> BTF amount + int matureAtHeight; + std::map amounts; // address -> satoshis }; +static std::vector gPendingPayouts; -static std::mutex gPayoutMutex; -static std::vector gPendingPayouts; +static std::string PayoutFilePath() +{ + return GetAppDir() + "/pending_payouts.json"; +} -void GetPendingPayouts(std::vector& out) +static void SavePendingPayouts() { - out.clear(); - std::lock_guard lk(gPayoutMutex); - for (const PendingPayout& p : gPendingPayouts) - { - PendingPayoutView view; - view.matureAtHeight = p.matureAtHeight; - view.recipients = (int)p.amounts.size(); - view.totalAmount = 0; - for (const std::map::value_type& kv : p.amounts) - view.totalAmount += kv.second; - out.push_back(view); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& pp : gPendingPayouts) { + nlohmann::json obj; + obj["matureAtHeight"] = pp.matureAtHeight; + nlohmann::json amounts = nlohmann::json::object(); + for (const auto& kv : pp.amounts) + amounts[kv.first] = kv.second; + obj["amounts"] = amounts; + arr.push_back(obj); + } + std::string path = PayoutFilePath(); + FILE* f = fopen(path.c_str(), "w"); + if (f) { + std::string s = arr.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + fclose(f); + } else { + LogPrint("payout", "[payout] WARNING: could not write %s\n", path.c_str()); } } -// Called when a block is found: snapshot shares, schedule payout for maturity. -static void QueuePayouts(int blockHeight) +static void LoadPendingPayouts() { - if (nMineMode != MINE_OPERATOR) return; - - std::map shareCount; - uint64 total = 0; - { - std::lock_guard lk(gMinerStatsMutex); - for (std::map::iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) - { - MinerStats& ms = it->second; - if (ms.roundShares == 0) - continue; - if (ms.address.empty()) - continue; - shareCount[ms.address] += ms.roundShares; - total += ms.roundShares; - ms.roundShares = 0; + std::string path = PayoutFilePath(); + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; // no file yet -- first run, nothing to load + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, f); + fclose(f); + try { + nlohmann::json arr = nlohmann::json::parse(s); + for (const auto& obj : arr) { + PendingPayout pp; + pp.matureAtHeight = obj["matureAtHeight"].get(); + for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) + pp.amounts[it.key()] = it.value().get(); + gPendingPayouts.push_back(pp); } + LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n", + gPendingPayouts.size()); + } catch (const std::exception& e) { + LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", + path.c_str(), e.what()); + } +} + +// Payouts ready to execute are moved here under gPayoutExecMutex. +// A dedicated thread drains this list so SendMoney (which takes cs_main) +// never runs on the event loop thread and can't stall miner I/O. +static std::vector gPayoutExecQueue; +static std::mutex gPayoutExecMutex; +static std::atomic gPayoutThreadRunning{false}; + +static void QueuePayouts(int blockHeight, + const std::map& shareCount, + uint64 total) +{ + if (total == 0) { + LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n", + blockHeight); + return; } - if (total == 0) return; - int64 blockReward = CBlock().GetBlockValue(0); + double feeFrac = dPoolFeePercent / 100.0; + if (feeFrac < 0.0) feeFrac = 0.0; + if (feeFrac > 1.0) feeFrac = 1.0; + + // Compute the block subsidy at the height that was just found. + // We do not include tx fees in miner payout -- those stay in the operator + // wallet as part of the coinbase, which is the operator's cut on top of feeFrac. + int64 nSubsidy = 50 * COIN; + nSubsidy >>= (blockHeight / 210000); // halving schedule + int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); + PendingPayout pp; pp.matureAtHeight = blockHeight + COINBASE_MATURITY; + + int skipped = 0; + int64 burned = 0; // amount owed to miners with bad/empty addresses for (auto& kv : shareCount) { - int64 amount = (int64)(((double)kv.second / (double)total) * (double)blockReward); - if (amount >= CENT) - pp.amounts[kv.first] = amount; + int64 amount = (int64)(((double)kv.second / (double)total) * (double)minerReward); + if (amount < CENT) { + skipped++; + continue; + } + uint160 addrCheck; + if (kv.first.empty() || !AddressToHash160(kv.first, addrCheck)) { + // Address was invalid at authorize time (warned then). Their share + // of the reward cannot be paid -- log the amount so it's visible. + burned += amount; + LogPrint("payout", "[payout] WARNING: %s owed to miner with invalid" + " address '%s' -- cannot pay out, amount forfeited\n", + FormatMoney(amount).c_str(), kv.first.c_str()); + continue; + } + pp.amounts[kv.first] = amount; } - { std::lock_guard lk(gPayoutMutex); gPendingPayouts.push_back(pp); } - - printf("Pool: block %d payout queued for height %d (%zu miners)\n", - blockHeight, pp.matureAtHeight, pp.amounts.size()); + gPendingPayouts.push_back(pp); + SavePendingPayouts(); + + int64 operatorCut = nSubsidy - minerReward; + LogPrint("payout", + "[payout] block %d: subsidy %s BTF, miners get %s" + " (fee %.2f%% = %s to operator)," + " %zu recipients, matures height %d," + " %d below dust%s\n", + blockHeight, + FormatMoney(nSubsidy).c_str(), + FormatMoney(minerReward).c_str(), + dPoolFeePercent, + FormatMoney(operatorCut).c_str(), + pp.amounts.size(), pp.matureAtHeight, skipped, + burned > 0 ? (", " + FormatMoney(burned) + " forfeited (bad address)").c_str() + : ""); } -// Called from JobWatcher every new block: execute any matured payouts. +// Called from event loop: move matured entries to the exec queue (fast). +// Save BEFORE moving to exec queue: if we crash between the save and SendMoney, +// LoadPendingPayouts re-queues them from the exec-queue section of the file. static void FlushMaturePayouts() { - if (nMineMode != MINE_OPERATOR) return; - - std::lock_guard lk(gPayoutMutex); + std::vector ready; for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { if (nBestHeight < it->matureAtHeight) { ++it; continue; } - - printf("Pool: executing payout for block matured at height %d\n", it->matureAtHeight); - for (auto& kv : it->amounts) { - uint160 hash160; - if (!AddressToHash160(kv.first, hash160)) continue; - CScript scriptPubKey; - scriptPubKey << OP_DUP << OP_HASH160 << hash160 << OP_EQUALVERIFY << OP_CHECKSIG; - CWalletTx wtx; - wtx.mapValue["comment"] = strprintf("Pool payout height %d", it->matureAtHeight); - if (SendMoney(scriptPubKey, kv.second, wtx)) - printf("Pool: paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); + LogPrint("payout", "[payout] block height %d matured -- queuing %zu payments\n", + it->matureAtHeight, it->amounts.size()); + ready.push_back(*it); // copy first; erase after save + ++it; + } + if (!ready.empty()) { + // Erase from pending then save before handing off to exec thread, + // so the file never contains an entry that is already being sent. + for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { + if (nBestHeight >= it->matureAtHeight) + it = gPendingPayouts.erase(it); else - printf("Pool: payout FAILED for %s (will not retry)\n", kv.first.c_str()); + ++it; + } + SavePendingPayouts(); + std::lock_guard lk(gPayoutExecMutex); + for (auto& pp : ready) + gPayoutExecQueue.push_back(std::move(pp)); + } +} + +// Payout execution thread: drains gPayoutExecQueue, calls SendMoney. +// Runs independently of the event loop so slow wallet operations +// (SendMoney takes cs_main) never stall miner I/O. +static void PayoutThreadFn(void*) +{ + gPayoutThreadRunning = true; + LogPrint("payout", "[payout] payout thread started\n"); + + while (!fShutdown && gPoolRunning) { + std::vector batch; + { + std::lock_guard lk(gPayoutExecMutex); + batch.swap(gPayoutExecQueue); + } + + for (auto& pp : batch) { + LogPrint("payout", "[payout] executing payments for block matured at %d" + " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); + for (auto& kv : pp.amounts) { + uint160 h160; + if (!AddressToHash160(kv.first, h160)) { + // Should not happen -- bad addresses are filtered in QueuePayouts + LogPrint("payout", "[payout] SKIPPED bad address '%s'\n", + kv.first.c_str()); + continue; + } + CScript sc; + sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + CWalletTx wtx; + wtx.mapValue["comment"] = strprintf("Pool payout height %d", + pp.matureAtHeight); + if (SendMoney(sc, kv.second, wtx)) + LogPrint("payout", "[payout] paid %s to %s\n", + FormatMoney(kv.second).c_str(), kv.first.c_str()); + else + LogPrint("payout", "[payout] FAILED to pay %s to %s" + " (insufficient funds or wallet locked?)\n", + FormatMoney(kv.second).c_str(), kv.first.c_str()); + } } - it = gPendingPayouts.erase(it); + + Sleep(500); // check for new work every 500ms } + + gPayoutThreadRunning = false; + LogPrint("payout", "[payout] payout thread stopped\n"); } // --------------------------------------------------------------------------- -// Stratum protocol +// GUI stats snapshot -- event loop writes, GUI threads read // --------------------------------------------------------------------------- -static json MakeNotifyParams(const StratumJob& job, bool clean) +struct StatsSnapshot { + int authorizedMiners = 0; + int blocksFound = 0; + uint64 roundShares = 0; + double totalHashRate = 0.0; +}; +static StatsSnapshot gStats; +static std::vector gWorkerStats; +static std::vector gPayoutViews; +static std::mutex gStatsMutex; + +void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate) { - CBlock& b = const_cast(job.block); - unsigned char hdr[80]; - memcpy(hdr, &b.nVersion, 4); - memcpy(hdr+4, &b.hashPrevBlock, 32); - memcpy(hdr+36, &b.hashMerkleRoot, 32); - memcpy(hdr+68, &b.nTime, 4); - memcpy(hdr+72, &b.nBits, 4); - memcpy(hdr+76, &b.nNonce, 4); - unsigned char shareTgt[32]; memcpy(shareTgt, &job.shareTarget, 32); - return json::array({job.jobId, ToHex(hdr,80), b.hashPrevBlock.GetHex(), ToHex(shareTgt,32), clean}); + std::lock_guard lk(gStatsMutex); + authorizedMiners = gStats.authorizedMiners; + blocksFound = gStats.blocksFound; + roundShares = gStats.roundShares; + totalHashRate = gStats.totalHashRate; +} + +void GetPoolWorkerStats(std::vector& out) +{ + std::lock_guard lk(gStatsMutex); + out = gWorkerStats; +} + +void GetPendingPayouts(std::vector& out) +{ + std::lock_guard lk(gStatsMutex); + out = gPayoutViews; } +// --------------------------------------------------------------------------- +// Miner state -- event loop thread only +// --------------------------------------------------------------------------- +static std::atomic gNextExtranonce2{1}; + +// Vardiff target: aim for one share every VARDIFF_TARGET_SECS seconds per miner. +// After VARDIFF_SHARES_PER_CHECK shares, recalculate and adjust if rate is off +// by more than 50%. Shares are the stable unit; difficulty floats to match. +static const int64 VARDIFF_TARGET_SECS = 15; // one share every 15s +static const uint64 VARDIFF_SHARES_PER_CHECK = 5; // re-evaluate every 5 shares +static const double VARDIFF_RETARGET_RATIO = 1.5; // adjust if >50% off target +static const double VARDIFF_MIN = 0.001; +static const double VARDIFF_MAX = 1e12; +// If a miner has gone this long since its vardiff window opened with ZERO +// shares, the normal vardiff retarget (which only fires after +// VARDIFF_SHARES_PER_CHECK shares) can never trigger -- it has no samples to +// act on. This is the residual gap the ShareTargetFromDifficulty fix above +// doesn't cover: even with a correctly-easy diff-1 target, a miner stuck in +// RandomX light mode (or otherwise much slower than expected) can go +// indefinitely without a single share, and difficulty would never drop to +// meet it. Force a difficulty cut on a timeout instead of waiting for shares +// that will never come. +static const int64 VARDIFF_STALL_SECS = 45; // 3x share target -- reacts faster without false-triggering on normal variance +static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by this much + struct Miner { - SOCKET fd; - bool authorised; - std::string address; - std::string worker; - std::string readBuf; - int64 connectTime; - uint64 sessionShares; - Miner(SOCKET f) : fd(f), authorised(false), connectTime(GetTime()), sessionShares(0) {} + btf_socket_t fd; + bool authorised = false; + std::string address; + std::string worker; + std::string readBuf; + int64 connectTime; + uint64 sessionShares = 0; + uint64 roundShares = 0; + uint64 totalShares = 0; + int64 lastSeen = 0; + uint32_t extranonce2; + + // Vardiff state + double difficulty = 1.0; // current share difficulty sent to this miner + uint256 shareTarget; // derived from difficulty; what we check against + int64 vardiffWindowStart = 0; // when the current vardiff window opened + uint64 vardiffShares = 0; // shares found in current window + + // Hashrate estimate -- EWMA of (expected hashes per share at current + // difficulty) / (seconds since the previous share). We had all the data + // to compute this (difficulty + share timing) but never actually did, + // so pool/GUI hashrate always showed as zero/missing. + int64 lastShareTime = 0; // GetTime() of the previous accepted share + double hashRate = 0.0; // smoothed H/s estimate + + explicit Miner(btf_socket_t f) + : fd(f), connectTime(GetTime()), + extranonce2(gNextExtranonce2.fetch_add(1)) {} }; -void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares) +static std::vector gMiners; + +struct FoundBlock { int height; int64 ts; std::string addr; }; +static std::deque gFoundBlocks; + +// --------------------------------------------------------------------------- +// Accept queue -- accept threads push; event loop pops at top of each tick +// --------------------------------------------------------------------------- +static std::vector gReadyQueue; +static std::mutex gReadyMutex; + +static void PushReady(btf_socket_t s) { - authorizedMiners = 0; - blocksFound = 0; - roundShares = 0; - { - std::lock_guard lk(gMinersMutex); - for (Miner* m : gMiners) - if (m->authorised) - authorizedMiners++; + std::lock_guard lk(gReadyMutex); + gReadyQueue.push_back(s); +} + +// --------------------------------------------------------------------------- +// I/O helpers -- event loop thread only +// --------------------------------------------------------------------------- + +// Blocking send of a complete JSON line. Called only from the event loop so +// no other thread is writing to the same fd concurrently. +static bool SendLine(btf_socket_t fd, const json& j) +{ + std::string s = j.dump() + "\n"; + int off = 0, total = (int)s.size(); + while (off < total) { + int r = send(fd, s.c_str() + off, total - off, SEND_FLAGS); + if (r <= 0) return false; + off += r; } - { - std::lock_guard lk(gBlocksMutex); - blocksFound = (int)gFoundBlocks.size(); + return true; +} + +// Non-blocking recv: pull whatever bytes are available now into buf, then +// return one complete line if one is ready. Three outcomes: +// true -- line is filled; buf may still contain more lines +// false, errno EAGAIN/EWOULDBLOCK (or FIONREAD==0) -- no data right now +// false, r==0 -- clean disconnect; caller must treat fd as dead +// Caller distinguishes disconnect from "not yet" by checking whether +// line.empty() after a false return (line is "" on both paths, so we +// use a separate out-param for the disconnect signal). +static bool RecvLine(btf_socket_t fd, std::string& buf, + std::string& line, bool& disconnected) +{ + disconnected = false; + char tmp[4096]; + +#ifdef _WIN32 + u_long avail = 0; + ioctlsocket(fd, FIONREAD, &avail); + if (avail > 0) { + int r = recv(fd, tmp, (int)std::min((u_long)(sizeof(tmp)-1), avail), 0); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0){ disconnected = true; return false; } + // r < 0: WSAEWOULDBLOCK -- fall through with what we have } +#else { - std::lock_guard lk(gMinerStatsMutex); - for (std::map::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) - roundShares += it->second.roundShares; + int r = recv(fd, tmp, sizeof(tmp) - 1, MSG_DONTWAIT); + if (r > 0) { tmp[r] = 0; buf += tmp; } + else if (r == 0) { disconnected = true; return false; } + // r < 0: EAGAIN/EWOULDBLOCK -- fall through with what we have } +#endif + + size_t pos = buf.find('\n'); + if (pos == std::string::npos) return false; // no complete line yet + + line = buf.substr(0, pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + buf = buf.substr(pos + 1); + return true; } -static bool SendLine(Miner* m, const json& j) +// --------------------------------------------------------------------------- +// Job helpers +// --------------------------------------------------------------------------- +static CBlock BuildMinerBlock(const StratumJob& job, uint32_t extranonce2) { - std::string s = j.dump() + "\n"; - int off = 0; - while (off < (int)s.size()) { - int n = send(m->fd, s.c_str() + off, (int)s.size() - off, SEND_FLAGS); - if (n <= 0) - return false; - off += n; - } - return true; + CBlock blk = job.block; + blk.vtx[0].vin[0].scriptSig << (CBigNum)extranonce2; + blk.hashMerkleRoot = blk.BuildMerkleTree(); + return blk; +} + +// shareTarget is per-miner (set by vardiff) and passed explicitly so the +// value in p[3] of mining.notify always matches what HandleLine checks on submit. +// This is the single source of truth for share difficulty. +static json MakeNotifyParams(const StratumJob& job, const CBlock& b, + const uint256& shareTarget, bool clean) +{ + unsigned char hdr[80]; + memcpy(hdr, &b.nVersion, 4); + memcpy(hdr+4, &b.hashPrevBlock, 32); + memcpy(hdr+36, &b.hashMerkleRoot, 32); + memcpy(hdr+68, &b.nTime, 4); + memcpy(hdr+72, &b.nBits, 4); + memcpy(hdr+76, &b.nNonce, 4); + unsigned char tgt[32]; memcpy(tgt, &shareTarget, 32); + return json::array({job.jobId, ToHex(hdr, 80), + b.hashPrevBlock.GetHex(), ToHex(tgt, 32), clean}); +} + +static void SendJob(Miner* m, bool clean) +{ + if (!gHaveJob) return; + CBlock mb = BuildMinerBlock(gCurrentJob, m->extranonce2); + // Use this miner's own shareTarget (set by vardiff) in the notify params. + // The participant miner reads p[3] as its share target, and HandleLine + // checks m->shareTarget on submit -- both sides now use the same value. + json n = {{"id",nullptr},{"method","mining.notify"}, + {"params", MakeNotifyParams(gCurrentJob, mb, m->shareTarget, clean)}}; + if (!SendLine(m->fd, n)) + LogPrint("worker", "[pool->worker] job send FAILED for %s -- miner will be dropped\n", + m->address.empty() ? "(not yet authorized)" : m->address.c_str()); } static void BroadcastJob(bool clean) { - StratumJob job; - { std::lock_guard lk(gJobMutex); if(!gHaveJob) return; job=gCurrentJob; } - json n = {{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,clean)}}; - std::lock_guard lk(gMinersMutex); - for (Miner* m : gMiners) - if (m->authorised && !SendLine(m, n)) - printf("Stratum: failed to broadcast job to %s\n", m->address.c_str()); + if (!gHaveJob) return; + int sent = 0; + for (Miner* m : gMiners) { + if (!m->authorised) continue; + SendJob(m, clean); + sent++; + } + LogPrint("pool", "[pool] job %s broadcast to %d miner(s)\n", + gCurrentJob.jobId.c_str(), sent); } -static void HandleLine(Miner* m, const std::string& line) +// --------------------------------------------------------------------------- +// Stratum message handler -- called for each complete line from a miner +// Returns false if the miner should be disconnected immediately. +// --------------------------------------------------------------------------- +static bool HandleLine(Miner* m, const std::string& rawLine, + std::map& roundShareCount, + uint64& roundShareTotal, + int& blocksFoundThisSession) { json req; - try { req = json::parse(line); } catch(...){ return; } + try { req = json::parse(rawLine); } + catch (...) { + LogPrint("worker", "[worker] JSON parse error from %s: %.80s\n", + m->address.empty() ? "(unauth)" : m->address.c_str(), + rawLine.c_str()); + return true; // don't disconnect on bad JSON, just ignore the line + } - json id = req.value("id", json(nullptr)); - std::string method = req.value("method",""); + json id = req.value("id", json(nullptr)); + std::string method = req.value("method", ""); - auto reply = [&](json result, json error=nullptr){ - if (!SendLine(m, json{{"id",id},{"result",result},{"error",error}})) - printf("Stratum: failed to reply to miner %s\n", m->address.c_str()); + // Reply helper: always sends result+error pair as Stratum requires + auto reply = [&](json result, std::string error = "") { + json err = error.empty() ? json(nullptr) : json(error); + if (!SendLine(m->fd, json{{"id",id},{"result",result},{"error",err}})) + LogPrint("worker", "[pool->worker] reply send FAILED for %s (method=%s)\n", + m->address.empty() ? "(unauth)" : m->address.c_str(), + method.c_str()); }; + // ------------------------------------------------------------------ if (method == "mining.subscribe") { std::string sid = ToHex(&m->fd, 4); - printf("Stratum: miner subscribed %s\n", sid.c_str()); + LogPrint("worker", "[worker->pool] subscribe from fd=%d sid=%s\n", + (int)m->fd, sid.c_str()); reply(json::array({json::array({json::array({"mining.notify",sid})}),sid,4})); - return; + return true; } + // ------------------------------------------------------------------ if (method == "mining.authorize") { auto& p = req["params"]; - m->address = (p.size()>0 && p[0].is_string()) ? p[0].get() : ""; - m->worker = (p.size()>1 && p[1].is_string()) ? p[1].get() : "worker"; - m->authorised = true; - printf("Stratum: miner authorized address=%s worker=%s\n", m->address.c_str(), m->worker.c_str()); - reply(true); + m->address = (p.size() > 0 && p[0].is_string()) ? p[0].get() : ""; + m->worker = (p.size() > 1 && p[1].is_string()) ? p[1].get() : "worker"; + m->lastSeen = GetTime(); - uint160 h; bool valid = AddressToHash160(m->address, h); - if (!valid) printf("Stratum: miner %s invalid address '%s' — shares won't pay out\n", - m->worker.c_str(), m->address.c_str()); + // Validate address before authorising + uint160 h160; bool validAddr = AddressToHash160(m->address, h160); - { std::lock_guard lk(gMinerStatsMutex); - auto& ms = gMinerStats[m->address]; - ms.lastSeen = GetTime(); ms.worker = m->worker; } + m->authorised = true; // authorise regardless -- shares just won't pay out + LogPrint("worker", "[worker->pool] authorize address=%s worker=%s addr_valid=%s\n", + m->address.c_str(), m->worker.c_str(), validAddr ? "yes" : "NO"); - if (!SendLine(m, json{{"id",nullptr},{"method","mining.set_difficulty"},{"params",json::array({1})}})) - printf("Stratum: failed to send difficulty to %s\n", m->address.c_str()); + if (!validAddr) + LogPrint("worker", "[pool] WARNING: address '%s' is invalid -- " + "miner will hash but shares cannot be paid out\n", + m->address.c_str()); - StratumJob job; bool have; - { std::lock_guard lk(gJobMutex); have=gHaveJob; if(have) job=gCurrentJob; } - if (have) { - printf("Stratum: sending job %s to %s\n", job.jobId.c_str(), m->address.c_str()); - if (!SendLine(m, json{{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,true)}})) - printf("Stratum: failed to send job %s to %s\n", job.jobId.c_str(), m->address.c_str()); + reply(true); + + // Initialize vardiff state and send initial difficulty + m->difficulty = 1.0; + m->shareTarget = ShareTargetFromDifficulty(m->difficulty); + m->vardiffWindowStart = GetTime(); + m->vardiffShares = 0; + LogPrint("worker", "[pool->worker] initial difficulty=%.4f for %s\n", + m->difficulty, m->address.c_str()); + if (!SendLine(m->fd, json{{"id",nullptr},{"method","mining.set_difficulty"}, + {"params",json::array({m->difficulty})}})) + LogPrint("worker", "[pool->worker] set_difficulty send FAILED for %s\n", + m->address.c_str()); + + if (gHaveJob) { + LogPrint("worker", "[pool->worker] sending job %s to %s\n", + gCurrentJob.jobId.c_str(), m->address.c_str()); + SendJob(m, true); } else { - printf("Stratum: no job available for %s\n", m->address.c_str()); + LogPrint("worker", "[pool->worker] no job available yet for %s" + " -- will be sent one as soon as the pool builds it\n", + m->address.c_str()); } - return; + return true; } - if (method == "mining.extranonce.subscribe") { reply(true); return; } + // ------------------------------------------------------------------ + if (method == "mining.extranonce.subscribe") { reply(true); return true; } + if (method == "mining.get_transactions") { reply(json::array()); return true; } + // ------------------------------------------------------------------ if (method == "mining.submit") { - if (!m->authorised) { reply(false,"not authorised"); return; } + if (!m->authorised) { + LogPrint("worker", "[worker->pool] submit from unauthorized miner (fd=%d)\n", + (int)m->fd); + reply(false, "not authorised"); + return true; + } auto& p = req["params"]; - if (p.size() < 4) { reply(false,"bad params"); return; } + if (p.size() < 3) { + LogPrint("worker", "[worker->pool] submit bad params from %s (size=%zu)\n", + m->address.c_str(), p.size()); + reply(false, "bad params"); + return true; + } - std::string jobId = p[1].get(); - std::string nonceHex = p[2].get(); + std::string submitJobId = p[1].get(); + std::string nonceHex = p[2].get(); - StratumJob job; - { std::lock_guard lk(gJobMutex); - if (!gHaveJob || gCurrentJob.jobId != jobId) { reply(false,"stale job"); return; } - job = gCurrentJob; } + if (!gHaveJob) { + LogPrint("worker", "[worker->pool] submit from %s but pool has no current job\n", + m->address.c_str()); + reply(false, "stale job"); + return true; + } + if (gCurrentJob.jobId != submitJobId) { + LogPrint("worker", "[worker->pool] submit stale job from %s" + " (submitted=%s current=%s)\n", + m->address.c_str(), submitJobId.c_str(), gCurrentJob.jobId.c_str()); + reply(false, "stale job"); + return true; + } auto nb = FromHex(nonceHex); - if (nb.size()<4) { reply(false,"bad nonce"); return; } + if (nb.size() < 4) { + LogPrint("worker", "[worker->pool] submit bad nonce from %s: '%s'\n", + m->address.c_str(), nonceHex.c_str()); + reply(false, "bad nonce"); + return true; + } unsigned int nNonce; memcpy(&nNonce, nb.data(), 4); - CBlock& b = job.block; + // Rebuild this miner's exact block (unique merkle via extranonce2) + CBlock b = BuildMinerBlock(gCurrentJob, m->extranonce2); b.nNonce = nNonce; unsigned char hdr[80]; - memcpy(hdr, &b.nVersion, 4); - memcpy(hdr+4, &b.hashPrevBlock, 32); - memcpy(hdr+36, &b.hashMerkleRoot,32); - memcpy(hdr+68, &b.nTime, 4); - memcpy(hdr+72, &b.nBits, 4); - memcpy(hdr+76, &b.nNonce, 4); + memcpy(hdr, &b.nVersion, 4); + memcpy(hdr+4, &b.hashPrevBlock, 32); + memcpy(hdr+36, &b.hashMerkleRoot, 32); + memcpy(hdr+68, &b.nTime, 4); + memcpy(hdr+72, &b.nBits, 4); + memcpy(hdr+76, &b.nNonce, 4); uint256 powHash = RandomXPoWHash(hdr, 80); - // Record share in the current round. - std::string key = MinerKey(m->address, m->worker); - { std::lock_guard lk(gMinerStatsMutex); - MinerStats& ms = gMinerStats[key]; - ms.address = m->address; - ms.worker = m->worker; - ms.roundShares++; - ms.totalShares++; - ms.lastSeen = GetTime(); } + // Check against this miner's personal share target (set by vardiff) + if (powHash > m->shareTarget) { + LogPrint("worker", "[worker->pool] high-hash from %s" + " nonce=%u (difficulty=%.4f)\n", + m->address.c_str(), nNonce, m->difficulty); + reply(false, "high-hash"); + return true; + } + + // Valid share -- record it m->sessionShares++; + m->roundShares++; + m->totalShares++; + m->vardiffShares++; + int64 shareTime = GetTime(); + m->lastSeen = shareTime; + roundShareCount[m->address]++; + roundShareTotal++; + + // Update hashrate estimate: expected hashes for this share divided by + // the time since the last one, smoothed with an EWMA so a single fast + // or slow share doesn't make the number jump around. + if (m->lastShareTime > 0) { + int64 dt = shareTime - m->lastShareTime; + if (dt <= 0) dt = 1; + double inst = ExpectedHashesForDifficulty(m->difficulty) / (double)dt; + const double alpha = 0.25; + m->hashRate = (m->hashRate <= 0.0) ? inst + : alpha * inst + (1.0 - alpha) * m->hashRate; + } + m->lastShareTime = shareTime; - if (powHash > job.shareTarget) { reply(true); return; } // valid share, not a full block + LogPrint("worker", "[worker->pool] valid share from %s" + " nonce=%u job=%s difficulty=%.4f (session: %llu)\n", + m->address.c_str(), nNonce, + gCurrentJob.jobId.c_str(), m->difficulty, + (unsigned long long)m->sessionShares); + reply(true); + + // Vardiff: after every VARDIFF_SHARES_PER_CHECK shares, check rate + if (m->vardiffShares >= VARDIFF_SHARES_PER_CHECK) { + int64 elapsed = GetTime() - m->vardiffWindowStart; + if (elapsed <= 0) elapsed = 1; + double actualSecsPerShare = (double)elapsed / (double)m->vardiffShares; + double ratio = actualSecsPerShare / (double)VARDIFF_TARGET_SECS; + // ratio < 1 means shares arriving faster than target (too easy) + // ratio > 1 means shares arriving slower than target (too hard) + if (ratio < (1.0 / VARDIFF_RETARGET_RATIO) || + ratio > VARDIFF_RETARGET_RATIO) { + // New difficulty scales inversely with actual share rate + double newDiff = m->difficulty / ratio; + if (newDiff < VARDIFF_MIN) newDiff = VARDIFF_MIN; + if (newDiff > VARDIFF_MAX) newDiff = VARDIFF_MAX; + // Only apply if the change is meaningful (>10%) + if (newDiff / m->difficulty > 1.1 || m->difficulty / newDiff > 1.1) { + m->difficulty = newDiff; + m->shareTarget = ShareTargetFromDifficulty(m->difficulty); + LogPrint("worker", "[pool] vardiff: %s new difficulty=%.4f" + " (%.1fs/share actual vs %ds target)\n", + m->address.c_str(), m->difficulty, + actualSecsPerShare, (int)VARDIFF_TARGET_SECS); + SendLine(m->fd, json{{"id",nullptr}, + {"method","mining.set_difficulty"}, + {"params",json::array({m->difficulty})}}); + // set_difficulty alone doesn't change what the worker + // actually hashes against -- it only reads its share + // target from mining.notify params[3]. Without this, the + // new difficulty wouldn't take effect until the next + // natural job broadcast (new block, or the 60s trickle + // refresh), so push a fresh notify for this miner now. + SendJob(m, false); + } + } + // Reset window + m->vardiffWindowStart = GetTime(); + m->vardiffShares = 0; + } + + if (powHash > gCurrentJob.target) return true; // valid share, not a block + + // Full difficulty met -- submit block to the chain + LogPrint("pool", "[pool] BLOCK CANDIDATE from %s height=%d nNonce=%u -- submitting\n", + m->address.c_str(), gCurrentJob.height, nNonce); - // Full block CBlock* pblock = new CBlock(b); bool accepted = false; CRITICAL_BLOCK(cs_main) { + int heightBefore = nBestHeight; if (ProcessBlock(NULL, pblock)) { - accepted = true; - printf("Pool: BLOCK FOUND by %s height=%d nNonce=%u\n", - m->address.c_str(), job.height, nNonce); - FoundBlock fb{job.height, GetTime(), m->address, pblock->GetHash()}; - { std::lock_guard lk(gBlocksMutex); - gFoundBlocks.push_front(fb); - if (gFoundBlocks.size()>50) gFoundBlocks.pop_back(); } - { std::lock_guard lk(gJobMutex); gHaveJob=false; } + // ProcessBlock returns true for orphans too -- verify the chain + // actually advanced. If nBestHeight didn't increase, the block + // was shelved as an orphan (pool was behind the chain tip) and + // no coinbase was added to the wallet. Do not pay out. + if (nBestHeight > heightBefore) { + accepted = true; + LogPrint("pool", "[pool] BLOCK ACCEPTED height=%d by %s\n", + gCurrentJob.height, m->address.c_str()); + gFoundBlocks.push_front({gCurrentJob.height, GetTime(), m->address}); + if (gFoundBlocks.size() > 50) gFoundBlocks.pop_back(); + blocksFoundThisSession++; + gHaveJob = false; // invalidate job immediately + } else { + LogPrint("pool", "[pool] BLOCK ORPHANED height=%d by %s" + " -- pool was behind chain tip, no payout\n", + gCurrentJob.height, m->address.c_str()); + gHaveJob = false; // job is stale regardless + } } else { + LogPrint("pool", "[pool] BLOCK REJECTED height=%d (ProcessBlock returned false)\n", + gCurrentJob.height); delete pblock; } } - reply(accepted); - if (accepted) { QueuePayouts(job.height); RebuildJob(); BroadcastJob(true); } - return; + + if (accepted) { + // Snapshot height before RebuildJob() (which increments it) + int foundHeight = gCurrentJob.height; + QueuePayouts(foundHeight, roundShareCount, roundShareTotal); + // Reset round shares + roundShareCount.clear(); + roundShareTotal = 0; + for (Miner* mi : gMiners) mi->roundShares = 0; + // Build and broadcast new job immediately + if (RebuildJob()) { + LogPrint("pool", "[pool] new job %s built after block %d\n", + gCurrentJob.jobId.c_str(), foundHeight); + BroadcastJob(true); + } else { + LogPrint("pool", "[pool] WARNING: RebuildJob() failed after block %d" + " -- miners will wait\n", foundHeight); + } + } + return true; } - if (method == "mining.get_transactions") { reply(json::array()); return; } + // Unknown method -- log it (can reveal miner software quirks) + LogPrint("worker", "[worker->pool] unknown method '%s' from %s\n", + method.c_str(), + m->address.empty() ? "(unauth)" : m->address.c_str()); reply(nullptr, "unknown method"); + return true; } -static void MinerThreadFn(void* arg) +// --------------------------------------------------------------------------- +// Stats refresh -- called every 2s from event loop +// --------------------------------------------------------------------------- +static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) { - Miner* m = (Miner*)arg; - while (!fShutdown && gPoolRunning) { - fd_set fds; FD_ZERO(&fds); FD_SET(m->fd, &fds); - struct timeval tv={1,0}; - if (select((int)m->fd+1, &fds, NULL, NULL, &tv) <= 0) continue; - char buf[4096]; - int n = recv(m->fd, buf, sizeof(buf)-1, 0); - if (n <= 0) break; - buf[n]=0; m->readBuf += buf; - size_t pos; - while ((pos = m->readBuf.find('\n')) != std::string::npos) { - std::string line = m->readBuf.substr(0, pos); - m->readBuf = m->readBuf.substr(pos+1); - if (!line.empty() && line.back()=='\r') line.pop_back(); - if (!line.empty()) HandleLine(m, line); - } + std::lock_guard lk(gStatsMutex); + + gStats.authorizedMiners = 0; + gStats.blocksFound = blocksFoundThisSession; + gStats.roundShares = roundShareTotal; + gStats.totalHashRate = 0.0; + for (Miner* m : gMiners) + if (m->authorised) gStats.authorizedMiners++; + + gWorkerStats.clear(); + for (Miner* m : gMiners) { + if (!m->authorised || m->lastSeen <= 0) continue; + PoolWorkerStatView v; + v.address = m->address; + v.worker = m->worker; + v.roundShares = m->roundShares; + v.totalShares = m->totalShares; + v.lastSeen = m->lastSeen; + v.hashRate = m->hashRate; + gWorkerStats.push_back(v); + gStats.totalHashRate += m->hashRate; + } + std::sort(gWorkerStats.begin(), gWorkerStats.end(), + [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { + return a.roundShares != b.roundShares ? a.roundShares > b.roundShares + : a.lastSeen > b.lastSeen; + }); + + // gPendingPayouts is event-loop-only data (no lock needed here since + // RefreshStats is always called from the event loop thread). + gPayoutViews.clear(); + for (const PendingPayout& pp : gPendingPayouts) { + PendingPayoutView pv; + pv.matureAtHeight = pp.matureAtHeight; + pv.recipients = (int)pp.amounts.size(); + pv.totalAmount = 0; + for (auto& kv : pp.amounts) pv.totalAmount += kv.second; + gPayoutViews.push_back(pv); } - sock_close(m->fd); - printf("Stratum: miner %s disconnected (%llu shares)\n", - m->worker.empty()?"unknown":m->worker.c_str(), - (unsigned long long)m->sessionShares); - { std::lock_guard lk(gMinersMutex); - auto it = std::find(gMiners.begin(), gMiners.end(), m); - if (it != gMiners.end()) gMiners.erase(it); } - delete m; } -static void JobWatcherFn(void*) +// --------------------------------------------------------------------------- +// Event loop +// --------------------------------------------------------------------------- +static void PoolEventLoop() { - int lastHeight = -1; + int lastHeight = -1; + int64 lastRefresh = GetTime(); + int64 lastStatsRefresh = 0; + int blocksFoundThisSession = 0; + int64 noJobSince = 0; // 0 = have a job + bool warnedNoJob = false; + int64 lastNoJobRetry = 0; // throttle RebuildJob calls when no tip + + std::map roundShareCount; + uint64 roundShareTotal = 0; + + static const int64 JOB_REFRESH_SECS = 60; // re-stamp nTime periodically + static const int64 NO_JOB_WARN_SECS = 15; // warn after this long with no job + static const int64 NO_JOB_RETRY_SECS = 5; // how often to retry RebuildJob + while (!fShutdown && gPoolRunning) { - Sleep(500); - bool newBlock = false; - CRITICAL_BLOCK(cs_main) - { if (nBestHeight != lastHeight && pindexBest) { lastHeight = nBestHeight; newBlock = true; } } - if (newBlock) { - FlushMaturePayouts(); - if (RebuildJob()) { printf("Stratum: new job height=%d\n", lastHeight+1); BroadcastJob(true); } - } else if (!gHaveJob) { + + // 1. Drain accept queue + { + std::lock_guard lk(gReadyMutex); + for (btf_socket_t fd : gReadyQueue) { + gMiners.push_back(new Miner(fd)); + LogPrint("worker", "[pool] miner fd=%d entered event loop" + " -- awaiting subscribe/authorize\n", (int)fd); + } + gReadyQueue.clear(); + } + + // 2. Detect new block. + // When our own miner found the block, HandleLine already called + // RebuildJob() + BroadcastJob(true) and set gHaveJob=true. + // We still flush mature payouts and update bookkeeping, but skip + // rebuilding if a fresh job already exists for this height. + { + bool newBlock = false; + CRITICAL_BLOCK(cs_main) + { + if (pindexBest && nBestHeight != lastHeight) { + lastHeight = nBestHeight; + newBlock = true; + } + } + if (newBlock) { + FlushMaturePayouts(); + if (!gHaveJob) { + // External block (not found by our miners), or HandleLine's + // RebuildJob failed. Either way this round is over with no + // payout from us, so clear round shares before starting the + // next one -- otherwise they'd keep accumulating across + // rounds that our own miners didn't win. + roundShareCount.clear(); + roundShareTotal = 0; + for (Miner* mi : gMiners) mi->roundShares = 0; + + // Build and broadcast now. + if (RebuildJob()) { + LogPrint("pool", "[pool] new block height=%d -- job %s ready," + " %zu miner(s) connected\n", + lastHeight, gCurrentJob.jobId.c_str(), gMiners.size()); + BroadcastJob(true); + } else { + LogPrint("pool", "[pool] new block height=%d but RebuildJob() failed" + " -- miners will wait\n", lastHeight); + } + } else { + // HandleLine already built and broadcast for this height. + LogPrint("pool", "[pool] new block height=%d -- job already" + " broadcast by submit handler\n", lastHeight); + } + lastRefresh = GetTime(); + noJobSince = 0; + warnedNoJob = false; + } + } + + // 3. No-job watchdog (throttled: only retry every NO_JOB_RETRY_SECS) + if (!gHaveJob) { + int64 now = GetTime(); + if (noJobSince == 0) noJobSince = now; + + if (now - lastNoJobRetry >= NO_JOB_RETRY_SECS) { + lastNoJobRetry = now; + if (RebuildJob()) { + LogPrint("pool", "[pool] job built after %llds without one\n", + (long long)(now - noJobSince)); + BroadcastJob(false); + noJobSince = 0; + warnedNoJob = false; + lastRefresh = now; + } else if (!warnedNoJob && now - noJobSince >= NO_JOB_WARN_SECS) { + LogPrint("pool", "[pool] STUCK: no job for %llds -- %s." + " Miners are waiting. Will keep retrying every %llds.\n", + (long long)(now - noJobSince), + pindexBest ? "RebuildJob() keeps failing (check above for why)" + : "no chain tip yet (node still syncing)", + (long long)NO_JOB_RETRY_SECS); + warnedNoJob = true; + } + } + } else if (GetTime() - lastRefresh >= JOB_REFRESH_SECS) { + // Periodic nTime refresh -- stale timestamps cause valid hashes to be rejected if (RebuildJob()) BroadcastJob(false); + lastRefresh = GetTime(); + } + + // 3.5. Vardiff stall watchdog -- runs independently of share count. + // The normal vardiff check in HandleLine only fires once + // VARDIFF_SHARES_PER_CHECK shares have arrived; a miner that never + // finds a single share never reaches that check, so it can be stuck + // at its initial (or last) difficulty forever. Catch that here. + for (Miner* m : gMiners) { + if (!m->authorised || m->vardiffShares != 0 || m->vardiffWindowStart <= 0) + continue; + int64 stalled = GetTime() - m->vardiffWindowStart; + if (stalled < VARDIFF_STALL_SECS) continue; + + double newDiff = m->difficulty / VARDIFF_STALL_CUT; + if (newDiff < VARDIFF_MIN) newDiff = VARDIFF_MIN; + + if (newDiff < m->difficulty) { + m->difficulty = newDiff; + m->shareTarget = ShareTargetFromDifficulty(m->difficulty); + m->vardiffWindowStart = GetTime(); + LogPrint("worker", "[pool] vardiff STALL: %s sent zero shares in" + " %llds -- forcing difficulty down to %.6f (no share data" + " to retarget from normally)\n", + m->address.c_str(), (long long)stalled, m->difficulty); + SendLine(m->fd, json{{"id",nullptr}, + {"method","mining.set_difficulty"}, + {"params",json::array({m->difficulty})}}); + // Same reasoning as the normal vardiff retarget above: the + // worker only reads its share target from mining.notify, so + // push a fresh job now instead of waiting on the next + // natural broadcast. + SendJob(m, false); + } else { + // Already at the difficulty floor -- this miner's problem isn't + // difficulty, it's something else (slow hashing, bad connection, + // stuck RandomX light mode, etc). Log once per stall window so + // it's visible without spamming. + LogPrint("worker", "[pool] vardiff STALL: %s still at zero shares" + " after %llds at floor difficulty %.6f -- likely a" + " hashrate/connectivity problem, not a difficulty problem\n", + m->address.c_str(), (long long)stalled, m->difficulty); + m->vardiffWindowStart = GetTime(); // avoid re-logging every tick + } + } + + // 4. Select over miner sockets + if (!gMiners.empty()) { + fd_set fds; FD_ZERO(&fds); + int maxfd = 0; + for (Miner* m : gMiners) { + FD_SET(m->fd, &fds); + if ((int)m->fd > maxfd) maxfd = (int)m->fd; + } + struct timeval tv = {0, 50000}; // 50ms + if (select(maxfd + 1, &fds, NULL, NULL, &tv) > 0) { + std::vector dead; + for (Miner* m : gMiners) { + if (!FD_ISSET(m->fd, &fds)) continue; + std::string line; + bool disc = false; + // Drain all complete lines buffered for this miner. + // HandleLine always returns true; disc signals clean disconnect. + while (RecvLine(m->fd, m->readBuf, line, disc)) + HandleLine(m, line, roundShareCount, + roundShareTotal, blocksFoundThisSession); + if (disc) dead.push_back(m); + } + for (Miner* m : dead) { + LogPrint("worker", "[pool] miner %s disconnected" + " (session shares: %llu, round shares: %llu)\n", + m->worker.empty() ? "(never authorized)" : m->worker.c_str(), + (unsigned long long)m->sessionShares, + (unsigned long long)m->roundShares); + sock_close(m->fd); + gMiners.erase(std::find(gMiners.begin(), gMiners.end(), m)); + delete m; + } + } + } else { + Sleep(50); + } + + // 5. Refresh GUI stats every 2s + if (GetTime() - lastStatsRefresh >= 2) { + RefreshStats(blocksFoundThisSession, roundShareTotal); + lastStatsRefresh = GetTime(); } } + + for (Miner* m : gMiners) { sock_close(m->fd); delete m; } + gMiners.clear(); } // --------------------------------------------------------------------------- -// Main Stratum accept loop — entry point called from ui.cpp and headless.cpp -// --------------------------------------------------------------------------- -// .btf Stratum listener — miners connect via rendezvous relay using the -// node's .btf address instead of a raw IP:port. Same Stratum protocol, -// same MinerThreadFn handler, works through CGNAT with no port forwarding. -// -// Keeps one listener registered per relay at all times. When a miner -// connects, a replacement listener is spawned immediately so the next -// miner doesn't have to wait. +// Accept threads // --------------------------------------------------------------------------- +struct AcceptCtx { unsigned char pk[32]; unsigned char sk[32]; std::string relay; }; -struct BtfAcceptCtx { unsigned char pk[32]; unsigned char sk[32]; string relay; }; - -// Track active listener count per relay to avoid pile-up when relay is slow -static std::map gBtfListenerCount; -static std::mutex gBtfListenerMutex; - -static void BtfStratumAcceptOneFn(void* arg) +// One persistent thread per relay. Loops: register -> wrap -> push -> repeat. +static void AcceptOneFn(void* arg) { - BtfAcceptCtx* ctx = (BtfAcceptCtx*)arg; + AcceptCtx* ctx = (AcceptCtx*)arg; unsigned char pk[32], sk[32]; memcpy(pk, ctx->pk, 32); memcpy(sk, ctx->sk, 32); - string relay = ctx->relay; + std::string relay = ctx->relay; delete ctx; - auto decrement = [&]() { - std::lock_guard lk(gBtfListenerMutex); - gBtfListenerCount[relay]--; - }; - size_t colon = relay.rfind(':'); - if (colon == string::npos) { decrement(); return; } - string strHost = relay.substr(0, colon); - int nPort = atoi(relay.substr(colon+1).c_str()); - - btf::RvSocket rv = btf::RvServiceRegister(strHost.c_str(), (unsigned short)nPort, pk); - if (fShutdown || !gPoolRunning) { if (rv != btf::RV_INVALID) btf::RvClose(rv); decrement(); return; } - if (rv == btf::RV_INVALID) { decrement(); return; } - - // Paired: spawn replacement (count transfers to new thread) - if (!fShutdown && gPoolRunning) { - BtfAcceptCtx* next = new BtfAcceptCtx(); - memcpy(next->pk, pk, 32); memcpy(next->sk, sk, 32); - next->relay = relay; - LaunchThread(BtfStratumAcceptOneFn, next); - } else { - decrement(); + if (colon == std::string::npos) { + LogPrint("pool", "[pool] AcceptOneFn: bad relay address '%s'\n", relay.c_str()); + return; } + std::string host = relay.substr(0, colon); + int port = atoi(relay.substr(colon + 1).c_str()); + + LogPrint("pool", "[pool] accept thread started for relay %s\n", relay.c_str()); + + while (!fShutdown && gPoolRunning) { + // Blocks until a miner connects to us at this relay + btf::RvSocket rv = btf::RvServiceRegister(host.c_str(), (unsigned short)port, pk); - btf_socket_t hSocket = btf::BtfServiceWrap(rv, sk); - if (hSocket == INVALID_SOCKET) return; + if (fShutdown || !gPoolRunning) { + if (rv != btf::RV_INVALID) btf::RvClose(rv); + break; + } + if (rv == btf::RV_INVALID) { + LogPrint("pool", "[pool] relay %s: RvServiceRegister failed" + " -- backing off 30s before retry\n", relay.c_str()); + for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000); + continue; + } - printf("Stratum/.btf: miner connected via %s\n", relay.c_str()); - Miner* m = new Miner(hSocket); - { std::lock_guard lk(gMinersMutex); gMiners.push_back(m); } - LaunchThread(MinerThreadFn, m); + // Echan handshake -- blocking but fast (local + relay round-trip) + btf_socket_t fd = btf::BtfServiceWrap(rv, sk); + if (fd == INVALID_SOCKET) { + LogPrint("pool", "[pool] relay %s: BtfServiceWrap failed (echan handshake)" + " -- miner dropped, will accept next\n", relay.c_str()); + continue; + } + + LogPrint("worker", "[pool] miner arrived via relay %s -- pushing to event loop\n", + relay.c_str()); + PushReady(fd); + // Loop immediately: register again so the next miner can connect + } + + LogPrint("pool", "[pool] accept thread stopped for relay %s\n", relay.c_str()); } -static void BtfStratumAcceptFn(void*) +// Spawns one AcceptOneFn per relay. Re-polls relay list every 60s to pick up +// newly discovered relays. Uses a set to avoid spawning duplicate threads. +static void AcceptLoopFn(void*) { - printf("Stratum/.btf: starting \xe2\x80\x94 miners can connect via %s\n", - BtfLocalAddress().c_str()); + LogPrint("pool", "[pool] AcceptLoopFn started -- miners can connect via %s\n", + BtfLocalAddress().c_str()); + // Wait for our identity to be ready unsigned char pk[32], sk[32]; + int waitSecs = 0; while (!BtfGetIdentity(pk, sk)) { if (fShutdown || !gPoolRunning) return; + if (waitSecs == 0) + LogPrint("pool", "[pool] waiting for .btf identity...\n"); + waitSecs++; Sleep(2000); } + LogPrint("pool", "[pool] .btf identity ready after %ds\n", waitSecs * 2); + + std::set spawned; // relays we've already started a thread for while (!fShutdown && gPoolRunning) { vector relays = BtfAllRelays(); - if (relays.empty()) { Sleep(5000); continue; } - - // Spawn one listener per relay, only if none already active - for (const string& relay : relays) { - std::lock_guard lk(gBtfListenerMutex); - if (gBtfListenerCount[relay] == 0) { - gBtfListenerCount[relay]++; - BtfAcceptCtx* ctx = new BtfAcceptCtx(); + if (relays.empty()) { + LogPrint("pool", "[pool] no relays available -- miners cannot connect yet\n"); + } else { + for (const string& relay : relays) { + if (spawned.count(relay)) continue; // already running + AcceptCtx* ctx = new AcceptCtx(); memcpy(ctx->pk, pk, 32); memcpy(ctx->sk, sk, 32); ctx->relay = relay; - LaunchThread(BtfStratumAcceptOneFn, ctx); + uintptr_t tid = _beginthread(AcceptOneFn, 0, ctx); + if (tid == (uintptr_t)-1) { + delete ctx; + LogPrint("pool", "[pool] failed to spawn accept thread for %s\n", + relay.c_str()); + } else { + spawned.insert(relay); + LogPrint("pool", "[pool] accept thread spawned for relay %s" + " (%zu total)\n", relay.c_str(), spawned.size()); + } } } - for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000); + // Re-poll every 60s -- newly discovered relays get picked up + for (int i = 0; i < 60 && !fShutdown && gPoolRunning; i++) Sleep(1000); } } + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- void ThreadRPCServer(void*) { gPoolRunning = true; - printf("Bitflash pool server starting\n"); - printf(" Stratum .btf: %s (no port forwarding needed)\n", - BtfLocalAddress().c_str()); - printf(" Mode: operator\n"); - if (RebuildJob()) - printf("Stratum: initial job seeded height=%d\n", nBestHeight + 1); + LogPrint("pool", "[pool] ============================================\n"); + LogPrint("pool", "[pool] Pool server starting\n"); + LogPrint("pool", "[pool] .btf address: %s\n", BtfLocalAddress().c_str()); + LogPrint("pool", "[pool] fee: %.2f%%\n", dPoolFeePercent); + + if (RebuildJob()) { + LogPrint("pool", "[pool] initial job %s seeded at height %d\n", + gCurrentJob.jobId.c_str(), nBestHeight + 1); + } else { + LogPrint("pool", "[pool] no chain tip yet -- job will build on first block\n"); + } + + LoadPendingPayouts(); + + _beginthread(AcceptLoopFn, 0, NULL); + _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); + _beginthread(PayoutThreadFn, 0, NULL); - LaunchThread(JobWatcherFn, NULL); - LaunchThread(BtfStratumAcceptFn, NULL); - LaunchThread(ThreadBtfPoolAnnouncer, NULL); + PoolEventLoop(); // blocks until gPoolRunning = false or fShutdown - while (!fShutdown && gPoolRunning) - Sleep(1000); - printf("Pool server stopped\n"); + LogPrint("pool", "[pool] pool server stopped\n"); + LogPrint("pool", "[pool] ============================================\n"); + gPoolRunning = false; } diff --git a/src/ui.rc b/src/ui.rc deleted file mode 100644 index ec8b142..0000000 --- a/src/ui.rc +++ /dev/null @@ -1,14 +0,0 @@ -bitcoin ICON "rc/bitcoin.ico" - -#include "wx/msw/wx.rc" - -check ICON "rc/check.ico" -send16 BITMAP "rc/send16.bmp" -send16mask BITMAP "rc/send16mask.bmp" -send16masknoshadow BITMAP "rc/send16masknoshadow.bmp" -send20 BITMAP "rc/send20.bmp" -send20mask BITMAP "rc/send20mask.bmp" -addressbook16 BITMAP "rc/addressbook16.bmp" -addressbook16mask BITMAP "rc/addressbook16mask.bmp" -addressbook20 BITMAP "rc/addressbook20.bmp" -addressbook20mask BITMAP "rc/addressbook20mask.bmp" diff --git a/src/uiproject.fbp b/src/uiproject.fbp deleted file mode 100644 index 57a2950..0000000 --- a/src/uiproject.fbp +++ /dev/null @@ -1,11825 +0,0 @@ - - - - - - C++ - 1 - UTF-8 - connect - uibase - 1000 - none - 0 - - - . - - 1 - 0 - 0 - - wxSYS_COLOUR_BTNFACE - - - 1 - - - - 0 - wxID_MAINFRAME - - - CMainFrameBase - - 705,484 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Bitcoin - - - - wxTAB_TRAVERSAL - 1 - - - - OnClose - - - - - OnIdle - - - - - - - - - - - - OnMouseEvents - - OnPaint - - - - - - - - 240,240,240 - - 1 - - - 0 - wxID_ANY - MyMenuBar - - - m_menubar - protected - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - &File - m_menuFile - protected - - - 0 - 1 - - wxID_ANY - wxITEM_NORMAL - E&xit - m_menuFileExit - none - - - OnMenuFileExit - - - - - &Options - m_menuOptions - public - - - 0 - 1 - - wxID_OPTIONSGENERATEBITCOINS - wxITEM_CHECK - &Generate Coins - m_menuOptionsGenerateBitcoins - none - - - OnMenuOptionsGenerate - - - - - 0 - 1 - - wxID_ANY - wxITEM_NORMAL - &Options... - m_menuOptionsOptions - none - - - OnMenuOptionsOptions - - - - - &Help - m_menuHelp - protected - - - 0 - 1 - - wxID_ANY - wxITEM_NORMAL - &About... - m_menuHelpAbout - none - - - OnMenuHelpAbout - - - - - - - 20,20 - - 1 - - ,90,90,-1,70,0 - 0 - wxID_ANY - - - - m_toolBar - 1 - protected - - 1 - -1,-1 - wxTB_FLAT|wxTB_HORZ_TEXT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - send20; Load From Resource - wxID_BUTTONSEND - wxITEM_NORMAL - &Send Coins - m_tool1 - - - - OnButtonSend - - - - - - addressbook20; Load From Resource - wxID_BUTTONRECEIVE - wxITEM_NORMAL - &Address Book - m_tool2 - - - - OnButtonAddressBook - - - - - - - 240,240,240 - - 1 - - 1 - - 0 - wxID_ANY - - - m_statusBar - protected - - - wxST_SIZEGRIP - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer2 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 2 - protected - 0 - - - - 5 - wxEXPAND|wxRIGHT|wxLEFT - 0 - - - bSizer85 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Your Bitcoin Address: - - - m_staticText32 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - wxSYS_COLOUR_MENU - - 1 - - - 0 - wxID_TEXTCTRLADDRESS - -1,-1 - 0 - -1,-1 - m_textCtrlAddress - protected - - 250,-1 - wxTE_READONLY - - - - - - - - - - OnKeyDown - - - - - - - - - - - OnMouseEventsAddress - - - - - - OnSetFocusAddress - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONCOPY - &Copy to Clipboard - - - m_buttonCopy - protected - - - wxBU_EXACTFIT - - - - - - OnButtonCopy - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONCHANGE - C&hange... - - - m_button91 - protected - - - - - - - - - OnButtonChange - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 0 - protected - 0 - - - - - - 5 - wxEXPAND - 0 - - - bSizer3 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxALIGN_BOTTOM|wxALL - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_panel14 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer66 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Balance: - - - m_staticText41 - protected - - -1,15 - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - 240,240,240 - - 1 - - ,90,90,8,70,0 - 0 - wxID_ANY - - - - m_staticTextBalance - protected - - 120,15 - wxALIGN_RIGHT|wxST_NO_AUTORESIZE - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 0 - protected - 0 - - - - 5 - wxALIGN_BOTTOM|wxTOP|wxRIGHT|wxLEFT - 0 - - - " All" " Sent" " Received" " In Progress" - - 1 - - - 1 - wxID_ANY - - - m_choiceFilter - protected - - 0 - 110,-1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - - - - 1 - - - 0 - wxID_ANY - - - m_notebook - protected - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - All Transactions - 0 - - - - 1 - - - 0 - wxID_ANY - - - m_panel7 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer157 - wxVERTICAL - none - - 5 - wxEXPAND|wxALL - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrl - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT|wxLC_SORT_DESCENDING - - - - - wxALWAYS_SHOW_SB - - - - - - - - - - - - - - - OnListColBeginDrag - - - - - - - - - OnListItemActivatedAllTransactions - - - - - - - - - - - - - OnPaintListCtrl - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - - bSizer_TabsForFutureUse - wxVERTICAL - none - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel9 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer159 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlEscrows - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel8 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer158 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlOrdersSent - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivatedOrdersSent - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel10 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer160 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlProductsSent - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivatedProductsSent - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND | wxALL - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_panel11 - protected - - - - - - - wxTAB_TRAVERSAL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer161 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrlOrdersReceived - public - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivatedOrdersReceived - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CTxDetailsDialogBase - - 620,450 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Transaction Details - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer64 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer66 - wxVERTICAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_htmlWin - protected - - - wxHW_SCROLLBAR_AUTO - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer65 - wxVERTICAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - - m_buttonOK - protected - - 85,25 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - COptionsDialogBase - - 500,261 - wxDEFAULT_DIALOG_STYLE - - Options - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer55 - wxVERTICAL - none - - 5 - wxEXPAND|wxLEFT - 1 - - - bSizer57 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 20 - protected - 0 - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - Optional transaction fee you give to the nodes that process your transactions. - - - m_staticText32 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer56 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Transaction fee: - - - m_staticText31 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_TRANSACTIONFEE - - 0 - - m_textCtrlTransactionFee - protected - - 70,-1 - - - - - - - - - - - - - OnKillFocusTransactionFee - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer58 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - - m_buttonOK - protected - - 85,25 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CAboutDialogBase - - 507,298 - wxDEFAULT_DIALOG_STYLE - - About Bitcoin - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer60 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer62 - wxHORIZONTAL - none - - 5 - wxEXPAND - 0 - - 0 - protected - 60 - - - - 5 - wxEXPAND - 1 - - - bSizer63 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 50 - protected - 0 - - - - 5 - wxEXPAND - 0 - - - bSizer64 - wxHORIZONTAL - none - - 5 - wxALIGN_BOTTOM|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - Tahoma,90,92,10,74,0 - 0 - wxID_ANY - Bitcoin - - - m_staticText40 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_BOTTOM|wxTOP|wxBOTTOM|wxRIGHT - 0 - - - - 1 - - Tahoma,90,90,10,74,0 - 0 - wxID_ANY - version - - - m_staticTextVersion - public - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 4 - protected - 0 - - - - 5 - wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - Copyright © 2009 Satoshi Nakamoto. This is experimental software. Do not rely on it for actual financial transactions. Distributed under the MIT/X11 software license, see the accompanying file license.txt or http://www.opensource.org/licenses/mit-license.php. This product includes software developed by the OpenSSL Project for use in the OpenSSL Toolkit (http://www.openssl.org/) and cryptographic software written by Eric Young (eay@cryptsoft.com). - - - m_staticTextMain - protected - - - - - - - - - 400 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - - - - - 5 - wxALIGN_RIGHT|wxEXPAND - 0 - - - bSizer61 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - - m_buttonOK - protected - - 85,25 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CSendDialogBase - - 675,338 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Send Coins - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer21 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 5 - protected - 0 - - - - 5 - wxEXPAND|wxLEFT - 0 - - 2 - wxBOTH - 1 - - 0 - - fgSizer1 - wxFLEX_GROWMODE_SPECIFIED - none - 3 - 0 - - 5 - wxEXPAND - 0 - - 0 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Enter the recipient's IP address (e.g. 123.45.6.7) for online transfer with comments and confirmation, or bitcoin address (e.g. 1NS17iag9jJgTHD1VXjvLCEnZuQ3rJED9L) if recipient is not online. - - - m_staticText14 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxLEFT - 1 - - 70,-1 - bSizer47 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - check; Load From Icon Resource [-1; -1] - - 1 - - - 0 - wxID_ANY - - - m_bitmapCheckMark - protected - - 16,16 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Pay &To: - - - m_staticText36 - protected - - -1,-1 - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxRIGHT - 1 - - - bSizer19 - wxHORIZONTAL - none - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 1 - - - - 1 - - - 0 - wxID_TEXTCTRLPAYTO - - 0 - - m_textCtrlAddress - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - OnTextAddress - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONPASTE - &Paste - - - m_buttonPaste - protected - - -1,-1 - wxBU_EXACTFIT - - - - - - OnButtonPaste - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONADDRESSBOOK - Address &Book... - - - m_buttonAddress - protected - - - - - - - - - OnButtonAddressBook - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxBOTTOM|wxLEFT|wxALIGN_RIGHT - 0 - - - - 1 - - - 0 - wxID_ANY - &Amount: - - - m_staticText19 - protected - - -1,-1 - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - - - 1 - - ,90,90,-1,70,0 - 0 - wxID_TEXTCTRLAMOUNT - - 20 - - m_textCtrlAmount - protected - - 145,-1 - - - - - - - - - - - OnKeyDown - - OnKillFocusAmount - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - T&ransfer: - - - m_staticText20 - protected - - -1,-1 - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALL - 0 - - - " Standard" - - 1 - - - 0 - wxID_CHOICETRANSFERTYPE - - - m_choiceTransferType - protected - - 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer672 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT - 1 - - - bSizer681 - wxVERTICAL - none - - 5 - wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - &From: - - - m_staticTextFrom - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxLEFT|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlFrom - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - - bSizer67 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxBOTTOM|wxRIGHT|wxLEFT - 1 - - - bSizer68 - wxVERTICAL - none - - 5 - wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - &Message: - - - m_staticTextMessage - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxLEFT - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlMessage - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer23 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - ,90,90,-1,70,0 - 0 - wxID_BUTTONSEND - &Send - - 85,25 - m_buttonSend - protected - - -1,-1 - - - - - - - OnButtonSend - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CSendingDialogBase - - 442,151 - wxDEFAULT_DIALOG_STYLE - - Sending... - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - OnPaint - - - - - - - - - bSizer68 - wxVERTICAL - none - - 8 - wxALIGN_CENTER_VERTICAL|wxEXPAND|wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - - - - m_staticTextSending - protected - - -1,14 - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 10 - wxEXPAND|wxRIGHT|wxLEFT - 1 - - wxSYS_COLOUR_BTNFACE - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlStatus - protected - - - wxTE_CENTRE|wxTE_MULTILINE|wxTE_NO_VSCROLL|wxTE_READONLY - - - Connecting... - - - wxNO_BORDER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer69 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 0 - - - 0 - wxID_ANY - OK - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CYourAddressDialogBase - - 610,390 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Your Bitcoin Address - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer68 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 5 - protected - 0 - - - - 5 - wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - These are your Bitcoin addresses for receiving payments. You may want to give a different one to each sender so you can keep track of who is paying you. - - - m_staticText45 - protected - - - - - - - - - 600 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_LISTCTRL - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT|wxLC_SORT_ASCENDING - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListEndLabelEdit - - OnListItemActivated - - - - - OnListItemSelected - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer69 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONRENAME - &Rename... - - 85,25 - m_buttonRename - protected - - - - - - - - - OnButtonRename - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONNEW - &New Address... - - 110,25 - m_buttonNew - protected - - -1,-1 - - - - - - - OnButtonNew - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONCOPY - &Copy to Clipboard - - 120,25 - m_buttonCopy - protected - - -1,-1 - - - - - - - OnButtonCopy - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CAddressBookDialogBase - - 610,390 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Address Book - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer68 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - 5 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 1 - wxID_ANY - Bitcoin Address - - - m_staticText55 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_LISTCTRL - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT|wxLC_SORT_ASCENDING - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListEndLabelEdit - - OnListItemActivated - - - - - OnListItemSelected - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - - bSizer69 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONEDIT - &Edit... - - 85,25 - m_buttonEdit - protected - - - - - - - - - OnButtonEdit - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONNEW - &New Address... - - 110,25 - m_buttonNew - protected - - - - - - - - - OnButtonNew - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONDELETE - &Delete - - 85,25 - m_buttonDelete - protected - - - - - - - - - OnButtonDelete - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - -1,-1 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - public - - -1,-1 - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CProductsDialogBase - - 708,535 - wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER - - Marketplace - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer22 - wxVERTICAL - none - - 5 - wxEXPAND|wxTOP|wxBOTTOM|wxRIGHT - 0 - - - bSizer23 - wxHORIZONTAL - none - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - "(Any Category)" - - 1 - - - 0 - wxID_ANY - - - m_comboBoxCategory - protected - - 150,-1 - - - - (Any Category) - - - - - OnCombobox - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlSearch - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 0 - 1 - - - 0 - wxID_ANY - &Search - - - m_buttonSearch - protected - - - - - - - - - OnButtonSearch - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivated - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CEditProductDialogBase - - 660,640 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Edit Product - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer20 - wxVERTICAL - none - - 5 - wxEXPAND|wxALL - 1 - - wxSYS_COLOUR_WINDOW - - 1 - - - 0 - wxID_ANY - - - m_scrolledWindow - protected - - 5 - 5 - - - - - - wxHSCROLL|wxTAB_TRAVERSAL|wxVSCROLL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer21 - wxVERTICAL - none - - 5 - wxEXPAND|wxTOP|wxRIGHT|wxLEFT - 0 - - 2 - wxBOTH - 1 - - 0 - - fgSizer8 - wxFLEX_GROWMODE_SPECIFIED - none - 0 - 0 - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Category - - - m_staticText106 - protected - - - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - - 1 - - - 0 - wxID_ANY - - 180,-1 - m_comboBoxCategory - protected - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Title - - - m_staticText108 - protected - - - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlTitle - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxALIGN_RIGHT|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Price - - - m_staticText107 - protected - - - wxALIGN_RIGHT - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 105,-1 - m_textCtrlPrice - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Page 1: Description - - - m_staticText22 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - -1,170 - m_textCtrlDescription - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Page 2: Order Form - - - m_staticText23 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxALL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - -1,120 - m_textCtrlInstructions - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 0 - - 3 - wxBOTH - 1 - - 0 - - fgSizer5 - wxFLEX_GROWMODE_SPECIFIED - public - 0 - 0 - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Label - - - m_staticText24 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Comma separated list of choices, or leave blank for text field - - - m_staticText25 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel0 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField0 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxRIGHT|wxLEFT|wxALIGN_CENTER_VERTICAL - 0 - - - - 0 - 1 - - - 0 - wxID_DEL0 - Delete - - -1,-1 - m_buttonDel0 - protected - - 60,20 - - - - - - - OnButtonDel0 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel1 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField1 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL1 - Delete - - -1,-1 - m_buttonDel1 - protected - - 60,20 - - - - - - - OnButtonDel1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel2 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField2 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL2 - Delete - - -1,-1 - m_buttonDel2 - protected - - 60,20 - - - - - - - OnButtonDel2 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel3 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField3 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL3 - Delete - - -1,-1 - m_buttonDel3 - protected - - 60,20 - - - - - - - OnButtonDel3 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel4 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField4 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL4 - Delete - - -1,-1 - m_buttonDel4 - protected - - 60,20 - - - - - - - OnButtonDel4 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel5 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField5 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL5 - Delete - - -1,-1 - m_buttonDel5 - protected - - 60,20 - - - - - - - OnButtonDel5 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel6 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField6 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL6 - Delete - - -1,-1 - m_buttonDel6 - protected - - 60,20 - - - - - - - OnButtonDel6 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel7 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField7 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL7 - Delete - - -1,-1 - m_buttonDel7 - protected - - 60,20 - - - - - - - OnButtonDel7 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel8 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField8 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL8 - Delete - - -1,-1 - m_buttonDel8 - protected - - 60,20 - - - - - - - OnButtonDel8 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel9 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField9 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL9 - Delete - - -1,-1 - m_buttonDel9 - protected - - 60,20 - - - - - - - OnButtonDel9 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel10 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField10 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL10 - Delete - - -1,-1 - m_buttonDel10 - protected - - 60,20 - - - - - - - OnButtonDel10 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel11 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField11 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL11 - Delete - - -1,-1 - m_buttonDel11 - protected - - 60,20 - - - - - - - OnButtonDel11 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel12 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField12 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL12 - Delete - - -1,-1 - m_buttonDel12 - protected - - 60,20 - - - - - - - OnButtonDel12 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel13 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField13 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL13 - Delete - - -1,-1 - m_buttonDel13 - protected - - 60,20 - - - - - - - OnButtonDel13 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel14 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField14 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL14 - Delete - - -1,-1 - m_buttonDel14 - protected - - 60,20 - - - - - - - OnButtonDel14 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel15 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField15 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL15 - Delete - - -1,-1 - m_buttonDel15 - protected - - 60,20 - - - - - - - OnButtonDel15 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel16 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField16 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL16 - Delete - - -1,-1 - m_buttonDel16 - protected - - 60,20 - - - - - - - OnButtonDel16 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel17 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField17 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL17 - Delete - - -1,-1 - m_buttonDel17 - protected - - 60,20 - - - - - - - OnButtonDel17 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel18 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField18 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL18 - Delete - - -1,-1 - m_buttonDel18 - protected - - 60,20 - - - - - - - OnButtonDel18 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - - 0 - 150,-1 - m_textCtrlLabel19 - protected - - - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_VERTICAL - 0 - - - - 1 - - - 0 - wxID_ANY - -1,-1 - 0 - -1,-1 - m_textCtrlField19 - protected - - -1,-1 - - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_VERTICAL|wxRIGHT|wxLEFT - 0 - - - - 0 - 1 - - - 0 - wxID_DEL19 - Delete - - -1,-1 - m_buttonDel19 - protected - - 60,20 - - - - - - - OnButtonDel19 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_HORIZONTAL - 0 - - - bSizer25 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_ANY - &Add Field - - - m_buttonAddField - protected - - - - - - - - - OnButtonAddField - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer26 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONSEND - &Send - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonSend - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONPREVIEW - &Preview - - 85,25 - m_buttonPreview - protected - - - - - - - - - OnButtonPreview - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CViewProductDialogBase - - 630,520 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Order Form - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer20 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer116 - wxHORIZONTAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 1 - wxID_ANY - - - m_htmlWinReviews - protected - - - wxHW_SCROLLBAR_AUTO - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND|wxALL - 1 - - wxSYS_COLOUR_WINDOW - - 1 - - - 0 - wxID_ANY - - - m_scrolledWindow - protected - - 5 - 5 - - - - - - wxHSCROLL|wxTAB_TRAVERSAL|wxVSCROLL - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer21 - wxVERTICAL - none - - 5 - wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - -1,-1 - m_richTextHeading - protected - - -1,50 - wxTE_READONLY - - - - - wxNO_BORDER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - Order Form instructions here multiple lines 1 2 3 4 5 6 - - - m_staticTextInstructions - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_CENTER_HORIZONTAL - 0 - - - bSizer25 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONSAMPLE - &Submit - - -1,-1 - m_buttonSubmitForm - protected - - - - - - - - - OnButtonSubmitForm - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL2 - Cancel - - - m_buttonCancelForm - protected - - - - - - - - - OnButtonCancelForm - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer26 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 0 - - - 0 - wxID_BUTTONBACK - < &Back - - 85,25 - m_buttonBack - protected - - - - - - - - - OnButtonBack - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_BUTTONNEXT - &Next > - - 85,25 - m_buttonNext - protected - - - - - - - - - OnButtonNext - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CViewOrderDialogBase - - 630,520 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - View Order - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer20 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - - bSizer116 - wxHORIZONTAL - none - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_htmlWin - protected - - - wxHW_SCROLLBAR_AUTO - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer26 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_MENU - - - 1 - - - - 0 - wxID_ANY - - - CEditReviewDialogBase - - 630,440 - wxDEFAULT_FRAME_STYLE|wxRESIZE_BORDER - - Enter Review - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer112 - wxVERTICAL - none - - 5 - - 0 - - 3 - protected - 0 - - - - 5 - wxALL|wxEXPAND - 0 - - - - 1 - - - 0 - wxID_ANY - - - - m_staticTextSeller - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - - 0 - - 3 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Rating - - - m_staticText110 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - " 1 star" " 2 stars" " 3 stars" " 4 stars" " 5 stars" - - 1 - - - 0 - wxID_ANY - - - m_choiceStars - protected - - 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - Review - - - m_staticText43 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND - 1 - - - - 1 - - - 0 - wxID_ANY - - 0 - - m_textCtrlReview - protected - - - wxTE_MULTILINE - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALIGN_RIGHT - 0 - - - bSizer113 - wxHORIZONTAL - none - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_SUBMIT - &Submit - - 85,25 - m_buttonSubmit - protected - - - - - - - - - OnButtonSubmit - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - wxSYS_COLOUR_BTNFACE - - - 1 - - - - 0 - wxID_ANY - - - CPokerLobbyDialogBase - - 586,457 - wxDEFAULT_FRAME_STYLE - - Poker Lobby - - - - wxTAB_TRAVERSAL - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer156 - wxHORIZONTAL - none - - 5 - wxEXPAND|wxTOP|wxBOTTOM|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - - 130,-1 - m_treeCtrl - protected - - - wxTR_HAS_BUTTONS|wxTR_HIDE_ROOT|wxTR_LINES_AT_ROOT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnTreeSelChanged - - - - - - - - 5 - wxEXPAND - 1 - - - bSizer172 - wxVERTICAL - none - - 5 - wxEXPAND|wxALL - 1 - - - - 1 - - - 0 - wxID_ANY - - - m_listCtrl - protected - - - wxLC_NO_SORT_HEADER|wxLC_REPORT - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnListItemActivated - - - - - OnListItemSelected - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OPENNEWTABLE - &Open New Table - - - m_buttonNewTable - protected - - - - - - - - - OnButtonNewTable - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CPokerDialogBase - - 806,550 - wxDEFAULT_FRAME_STYLE|wxFRAME_NO_TASKBAR - - Poker - - - - wxFULL_REPAINT_ON_RESIZE|wxTAB_TRAVERSAL - 1 - - - - OnClose - - - - - - - - - - - - - - - - - OnMouseEvents - - OnPaint - - - - - OnSize - - - - bSizer174 - wxVERTICAL - none - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - Deal Me Out - - - m_checkSitOut - public - - - - - - - - - - OnCheckSitOut - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_DEALHAND - &Deal Hand - - - m_buttonDealHand - protected - - 150,25 - - - - - - - OnButtonDealHand - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_FOLD - &Fold - - - m_buttonFold - protected - - 80,25 - - - - - - - OnButtonFold - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CALL - &Call - - - m_buttonCall - protected - - 80,25 - - - - - - - OnButtonCall - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_RAISE - &Raise - - - m_buttonRaise - protected - - 80,25 - - - - - - - OnButtonRaise - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_LEAVETABLE - &Leave Table - - - m_buttonLeaveTable - protected - - 90,25 - - - - - - - OnButtonLeaveTable - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 1 - - - 0 - wxID_DITCHPLAYER - - 0 - - m_textDitchPlayer - protected - - 45,-1 - wxTE_PROCESS_ENTER - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - OnDitchPlayer - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - FOLD - - - m_checkPreFold - public - - 100,-1 - - - - - - - - OnCheckPreFold - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - CALL - - - m_checkPreCall - public - - 100,-1 - - - - - - - - OnCheckPreCall - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - CALL ANY - - - m_checkPreCallAny - public - - 100,-1 - - - - - - - - OnCheckPreCallAny - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - RAISE - - - m_checkPreRaise - public - - 100,-1 - - - - - - - - OnCheckPreRaise - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - 0 - - 1 - - - 0 - wxID_ANY - RAISE ANY - - - m_checkPreRaiseAny - public - - 100,-1 - - - - - - - - OnCheckPreRaiseAny - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - 1 - - 0 - wxID_ANY - - - m_statusBar - public - - - wxST_SIZEGRIP - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - 0 - wxID_ANY - - - CGetTextFromUserDialogBase - - 403,138 - wxDEFAULT_DIALOG_STYLE - - - - - - - - - - OnClose - - - - - - - - - - - - - - - - - - - - - - - - - - - - - bSizer79 - wxVERTICAL - none - - 10 - wxEXPAND|wxALL - 1 - - - bSizer81 - wxVERTICAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 0 - wxID_ANY - - - - m_staticTextMessage1 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_HORIZONTAL - 0 - - - - 1 - - - 0 - wxID_TEXTCTRL - - 0 - - m_textCtrl1 - protected - - - wxTE_PROCESS_ENTER - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxTOP|wxRIGHT|wxLEFT - 0 - - - - 1 - - - 1 - wxID_ANY - - - - m_staticTextMessage2 - protected - - - - - - - - - -1 - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL|wxEXPAND|wxALIGN_CENTER_HORIZONTAL - 0 - - - - 1 - - - 1 - wxID_TEXTCTRL - - 0 - - m_textCtrl2 - protected - - - wxTE_PROCESS_ENTER - - - - - - - - - - OnKeyDown - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - - - 5 - wxEXPAND - 0 - - - bSizer80 - wxHORIZONTAL - none - - 5 - wxEXPAND - 1 - - 0 - protected - 0 - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_OK - OK - - 85,25 - m_buttonOK - protected - - -1,-1 - - - - - - - OnButtonOK - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 - wxALL - 0 - - - - 0 - 1 - - - 0 - wxID_CANCEL - Cancel - - 85,25 - m_buttonCancel - protected - - - - - - - - - OnButtonCancel - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/util.cpp b/src/util.cpp index 7f56530..68bd18e 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -8,6 +8,99 @@ bool fDebug = false; +// --------------------------------------------------------------------------- +// Categorized, burst-collapsing logging -- see the LogPrint doc comment in +// util.h for the problem this solves. +// --------------------------------------------------------------------------- + +// Categories that explain the pool/worker/payout pipeline are on unconditionally: +// silence there is exactly the "worker stuck, nothing then happens" complaint +// this exists to fix, so it can't be opted out of by forgetting -debug. +// "net" and "nostr" cover the per-address connection/discovery chatter that +// used to dominate debug.log; they're gated behind fDebug like the printf() +// calls they replace, so default output stays quiet unless requested. +static bool LogCategoryDefaultOn(const char* category) +{ + return strcmp(category, "pool") == 0 || + strcmp(category, "worker") == 0 || + strcmp(category, "payout") == 0; +} + +bool LogAcceptsCategory(const char* category) +{ + if (LogCategoryDefaultOn(category)) + return true; + return fDebug; +} + +// Only collapse bursts from the exact same call site within this window; +// a different LogPrint() call (different file/line) is never merged with +// this one, so unrelated messages never get mixed into one summary. +static const int64 DEDUP_WINDOW_SECS = 3; + +struct LogDedupState { + int64 windowStart; // GetTime() when the current window opened + int suppressed; // calls swallowed so far this window + std::string lastMsg; // most recent message text, for the summary line +}; + +static std::map g_logDedup; +static CCriticalSection cs_logDedup; + +void LogPrintDedup(const char* category, const char* file, int line, const std::string& msgIn) +{ + // Call sites follow the existing printf() convention of ending their + // format string with \n; strip it here so the single \n this function + // adds below doesn't turn into a blank line. + std::string msg = msgIn; + while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) + msg.pop_back(); + + char key[512]; + my_snprintf(key, sizeof(key), "%s|%s:%d", category, file, line); + + int64 now = GetTime(); + bool fEmitNow = false; + int suppressedToFlush = 0; + std::string flushedMsg; + + CRITICAL_BLOCK(cs_logDedup) + { + std::map::iterator it = g_logDedup.find(key); + if (it == g_logDedup.end()) + { + // First time this call site has fired (or its last window is long + // gone) -- print it directly and open a fresh window. + g_logDedup[key] = LogDedupState{now, 0, msg}; + fEmitNow = true; + } + else if (now - it->second.windowStart >= DEDUP_WINDOW_SECS) + { + // Window rolled over: flush what was suppressed during it (if + // anything), then start a new window with this call printed. + suppressedToFlush = it->second.suppressed; + flushedMsg = it->second.lastMsg; + it->second.windowStart = now; + it->second.suppressed = 0; + it->second.lastMsg = msg; + fEmitNow = true; + } + else + { + // Still inside the window and already printed one line for it: + // count this occurrence instead of writing another near-duplicate. + it->second.suppressed++; + it->second.lastMsg = msg; + } + } + + if (suppressedToFlush > 0) + OutputDebugStringF("[%s] (%d similar in %llds, last: %s)\n", + category, suppressedToFlush, (long long)DEDUP_WINDOW_SECS, flushedMsg.c_str()); + if (fEmitNow) + OutputDebugStringF("[%s] %s\n", category, msg.c_str()); +} + @@ -200,11 +293,16 @@ void PrintException(std::exception* pex, const char* pszThread) snprintf(pszMessage, sizeof(pszMessage), "UNKNOWN EXCEPTION \n%s in %s \n", pszModule, pszThread); printf("\n\n************************\n%s", pszMessage); - // Suppress the dialog and rethrow during shutdown — threads unwinding on - // exit cause spurious "UNKNOWN EXCEPTION" popups that aren't real errors. - // Show error in GUI via repaint signal — GUI polls g_errorMessage - if (!fShutdown) - throw; + // Do NOT rethrow here. Every caller of PrintException (via + // CATCH_PRINT_EXCEPTION) is a thread wrapped in its own + // "try { ThreadFoo2() } catch(...) { log, sleep, loop again }" self-healing + // retry loop -- that's the whole point of the wrapper. Rethrowing from + // inside that catch block escapes the retry loop entirely (nothing above + // it in the thread catches it) and takes down the whole process with + // std::terminate() on the very first transient error -- a flaky relay + // connection, a malformed message, anything -- instead of letting the + // thread log it, sleep 5s, and try again like it's designed to. + // Show error in GUI via repaint signal -- GUI polls g_errorMessage } diff --git a/src/util.h b/src/util.h index c53293a..641b838 100644 --- a/src/util.h +++ b/src/util.h @@ -26,11 +26,14 @@ typedef unsigned long long uint64; #define UEND(a) ((unsigned char*)&((&(a))[1])) #define ARRAYLEN(array) (sizeof(array)/sizeof((array)[0])) -// Route printf to debug.log (and the console) on the GUI Windows build and on -// the headless Linux build alike. -#if defined(_WINDOWS) || !defined(_WIN32) +// Route printf to debug.log (and the console on non-Windows) everywhere. +// (Previously gated on `defined(_WINDOWS) || !defined(_WIN32)` -- but +// _WINDOWS is only ever defined by the legacy, unused makefile.vc build; +// the real Windows build (makefile.mingw) never defines it, so on actual +// Windows this condition was always false and debug.log was never written. +// OutputDebugStringF() is already platform-safe internally: it always +// writes debug.log, and only echoes to the console when not on Windows.) #define printf OutputDebugStringF -#endif #ifdef snprintf #undef snprintf @@ -70,6 +73,35 @@ inline T& REF(const T& val) extern bool fDebug; +// --------------------------------------------------------------------------- +// Categorized, burst-collapsing logging +// +// Plain printf() (routed to OutputDebugStringF below) writes every call +// unfiltered to debug.log. That's fine for one-off events, but a loop that +// logs once per item -- once per discovered peer, once per connect attempt, +// once per share -- turns debug.log into a wall of near-identical lines that +// differ only by an address, while the line that actually explains a stuck +// flow ("Stratum: no job available for ...") scrolls off screen under the +// noise. LogPrint() is a drop-in replacement for printf() that fixes both +// problems without touching call sites' formatting: +// +// 1. Category tag: every call names the subsystem it belongs to ("pool", +// "worker", "payout", "net", "nostr", ...). LogAcceptsCategory() +// controls what's written (see util.cpp); the pool/worker/payout flow +// the person actually needs visibility into is on by default. +// 2. Burst collapse: repeated LogPrint() calls from the *same source line* +// only emit one line per DEDUP_WINDOW_SECS; if more calls land inside +// that window they're counted instead of reprinted, and the count is +// flushed as a single "(xN similar in Ws)" line the moment the window +// rolls over. A loop discovering 500 peers by address now produces a +// small, bounded number of lines instead of 500 -- log growth is capped +// by elapsed time, not by how many similar events happen to fire. +// --------------------------------------------------------------------------- +bool LogAcceptsCategory(const char* category); +void LogPrintDedup(const char* category, const char* file, int line, const std::string& msg); +#define LogPrint(category, ...) \ + do { if (LogAcceptsCategory(category)) LogPrintDedup((category), __FILE__, __LINE__, strprintf(__VA_ARGS__)); } while (0) + void RandAddSeed(bool fPerfmon=false); int my_snprintf(char* buffer, size_t limit, const char* format, ...); string strprintf(const char* format, ...); @@ -231,7 +263,7 @@ void PrintHex(const T pbegin, const T pend, const char* pszFormat="%s", bool fSp -// Forward declaration — defined in main.cpp +// Forward declaration -- defined in main.cpp string GetAppDir(); inline int OutputDebugStringF(const char* pszFormat, ...) -- cgit v1.2.3 From 0ddcf690bf48075927f9d7596c62ddb41e79b2e0 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 28 Jul 2026 13:58:43 -0300 Subject: Security: anyone can spend anyone's coins, plus three more consensus bugs (#12) * script: fix OP_RETURN spend bypass, disable unbounded opcodes OP_RETURN let anyone spend anyone else's outputs. VerifySignature evaluates scriptSig and scriptPubKey as one concatenated script. OP_RETURN was implemented as 'pc = pend', which jumps past everything that follows, and EvalScript ends with CastToBool(stack.back()). So a scriptSig of skipped the entire scriptPubKey -- OP_CHECKSIG included -- and the final check read the attacker's own OP_TRUE. No signature required, any output spendable. This is the 0.1.0 behaviour Bitcoin fixed in 0.3.5. OP_RETURN now fails the script outright. Also disables the opcodes Bitcoin removed for being unbounded, and caps pushed elements at 520 bytes: OP_CAT grows a stack element with no limit -- OP_DUP OP_CAT repeated doubles it each time, so a script that fits in a transaction exhausts memory on every node that validates it. OP_LSHIFT shifts by an attacker-supplied amount with no ceiling, same effect. There were no element, stack or opcode-count limits of any kind. Nothing on this chain uses these opcodes -- all outputs are standard pay-to-pubkey-hash or coinbase pay-to-pubkey -- so no existing transaction changes validity. * main: reject blocks containing a duplicate transaction (CVE-2012-2459) BuildMerkleTree duplicates the trailing hash whenever a tree level has an odd width. Appending a copy of the last transaction therefore produces a block whose merkle root -- and whose block hash -- match a legitimate block exactly. A node handed the forgery rejects it (the duplicated inputs are already spent) and remembers that hash as bad. When the genuine block arrives carrying the same hash, it is refused too, and the node stops following the real chain. CheckBlock now rejects any block that carries the same transaction twice, which is what the mutation requires. * main: halve the subsidy on the block's height, not the node's tip GetBlockValue derived the halving from nBestHeight -- the validating node's own chain tip -- rather than from the height of the block being valued. The subsidy a block may pay is a property of that block, so reading it off local state makes validity depend on how far the node has synced: across a halving boundary a node still catching up would allow 50 BTF where a synced node allows 25, and the two would follow different chains. Reorgs have the same problem in miniature. Harmless today (every node computes nHeight/210000 == 0 at height ~2400) and it changes no existing block's validity, but it splits the network at height 210000. The height is now passed in explicitly: pindex->nHeight when connecting, tip + 1 when mining or building pool work. --- src/main.cpp | 30 +++++++++++++++++++++++++----- src/main.h | 2 +- src/rpc.cpp | 2 +- src/script.cpp | 31 +++++++++++++++++++++++++++++-- src/script.h | 4 ++++ 5 files changed, 60 insertions(+), 9 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/main.cpp b/src/main.cpp index aff7bab..a7298ea 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -729,12 +729,16 @@ uint256 GetOrphanRoot(const CBlock* pblock) return pblock->GetHash(); } -int64 CBlock::GetBlockValue(int64 nFees) const +int64 CBlock::GetBlockValue(int nHeight, int64 nFees) const { int64 nSubsidy = 50 * COIN; - // Subsidy is cut in half every 4 years - nSubsidy >>= (nBestHeight / 210000); + // Halve on the height of this block, never on nBestHeight. The subsidy a + // block is allowed to pay is a property of that block; deriving it from + // the validating node's own tip makes the answer depend on how far that + // node happens to have synced, so two nodes at different heights would + // disagree about the same block across a halving boundary. + nSubsidy >>= (nHeight / 210000); return nSubsidy + nFees; } @@ -1011,7 +1015,7 @@ bool CBlock::ConnectBlock(CTxDB& txdb, CBlockIndex* pindex) return false; } - if (vtx[0].GetValueOut() > GetBlockValue(nFees)) + if (vtx[0].GetValueOut() > GetBlockValue(pindex->nHeight, nFees)) return false; // Update block index on disk without changing it in memory. @@ -1237,6 +1241,22 @@ bool CBlock::CheckBlock() const if (!tx.CheckTransaction()) return error("CheckBlock() : CheckTransaction failed"); + // Reject blocks carrying the same transaction twice (CVE-2012-2459). + // + // BuildMerkleTree duplicates the last hash when a level has an odd width, + // so appending a copy of the trailing transaction(s) yields a block with + // an identical merkle root -- and therefore an identical block hash -- to + // a legitimate one. The forgery fails validation, the hash gets recorded + // as bad, and the real block is then refused because it hashes the same. + set setTxHashes; + foreach(const CTransaction& tx, vtx) + { + uint256 hashTx = tx.GetHash(); + if (setTxHashes.count(hashTx)) + return error("CheckBlock() : duplicate transaction in block"); + setTxHashes.insert(hashTx); + } + // Check proof of work matches claimed amount (memory-hard RandomX PoW) if (CBigNum().SetCompact(nBits) > bnProofOfWorkLimit) return error("CheckBlock() : nBits below minimum work"); @@ -2803,7 +2823,7 @@ bool BitcoinMiner() } } pblock->nBits = nBits; - pblock->vtx[0].vout[0].nValue = pblock->GetBlockValue(nFees); + pblock->vtx[0].vout[0].nValue = pblock->GetBlockValue(nBestHeight + 1, nFees); if (LogAcceptsCategory("net")) printf("\n\nRunning BitcoinMiner with %d transactions in block\n", pblock->vtx.size()); diff --git a/src/main.h b/src/main.h index 8bb8609..533974c 100644 --- a/src/main.h +++ b/src/main.h @@ -1073,7 +1073,7 @@ public: } - int64 GetBlockValue(int64 nFees) const; + int64 GetBlockValue(int nHeight, int64 nFees) const; bool DisconnectBlock(CTxDB& txdb, CBlockIndex* pindex); bool ConnectBlock(CTxDB& txdb, CBlockIndex* pindex); bool ReadFromDisk(const CBlockIndex* blockindex, bool fReadTransactions); diff --git a/src/rpc.cpp b/src/rpc.cpp index 1b529e8..3b2dae9 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -182,7 +182,7 @@ static bool RebuildJob() } } - block.vtx[0].vout[0].nValue = block.GetBlockValue(0); + block.vtx[0].vout[0].nValue = block.GetBlockValue(pindexPrev->nHeight + 1, 0); block.hashPrevBlock = pindexPrev->GetBlockHash(); block.hashMerkleRoot = block.BuildMerkleTree(); block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast() + 1), diff --git a/src/script.cpp b/src/script.cpp index d2111ed..44eed93 100644 --- a/src/script.cpp +++ b/src/script.cpp @@ -67,6 +67,24 @@ bool EvalScript(const CScript& script, const CTransaction& txTo, unsigned int nI if (!script.GetOp(pc, opcode, vchPushValue)) return false; + if (vchPushValue.size() > MAX_SCRIPT_ELEMENT_SIZE) + return false; + + // Disabled opcodes. Checked before the fExec test, so they invalidate + // the script even inside a branch that is never taken. + // + // These are unbounded: OP_CAT grows a stack element without limit + // ( OP_DUP OP_CAT repeated doubles it each time, so a tiny script + // exhausts memory), and OP_LSHIFT shifts by an attacker-supplied + // amount with no ceiling. Both let any transaction take down every + // node that validates it. Bitcoin removed the whole group for this. + if (opcode == OP_CAT || opcode == OP_SUBSTR || opcode == OP_LEFT || + opcode == OP_RIGHT || opcode == OP_INVERT || opcode == OP_AND || + opcode == OP_OR || opcode == OP_XOR || opcode == OP_2MUL || + opcode == OP_2DIV || opcode == OP_MUL || opcode == OP_DIV || + opcode == OP_MOD || opcode == OP_LSHIFT || opcode == OP_RSHIFT) + return false; + if (fExec && opcode <= OP_PUSHDATA4) stack.push_back(vchPushValue); else if (fExec || (OP_IF <= opcode && opcode <= OP_ENDIF)) @@ -169,9 +187,18 @@ bool EvalScript(const CScript& script, const CTransaction& txTo, unsigned int nI case OP_RETURN: { - pc = pend; + // Must fail the script outright, never just stop executing it. + // + // scriptSig and scriptPubKey are evaluated as one concatenated + // script (see VerifySignature). The 0.1.0 behaviour here was + // "pc = pend", which jumps past everything that follows — so a + // scriptSig of skipped the entire + // scriptPubKey, including OP_CHECKSIG, and the final + // CastToBool(stack.back()) saw the attacker's own OP_TRUE. + // That let anyone spend anyone else's outputs without a + // signature. Bitcoin closed this in 0.3.5. + return false; } - break; // diff --git a/src/script.h b/src/script.h index d52d06b..27c1b13 100644 --- a/src/script.h +++ b/src/script.h @@ -4,6 +4,10 @@ class CTransaction; +// Largest byte vector a script may push onto the stack. Without a ceiling, +// script evaluation allocates whatever the transaction asks it to. +static const unsigned int MAX_SCRIPT_ELEMENT_SIZE = 520; + enum { SIGHASH_ALL = 1, -- cgit v1.2.3 From e0c00d3e1b3192efde01fba85d0831470a8fb89c Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 28 Jul 2026 15:25:02 -0300 Subject: rpc: the payout comment claims a guarantee the code does not provide (#22) It reads: Save BEFORE moving to exec queue: if we crash between the save and SendMoney, LoadPendingPayouts re-queues them from the exec-queue section of the file. There is no exec-queue section. SavePendingPayouts serialises gPendingPayouts and nothing else, and a matured payout is erased from it and written out before ever reaching the queue, which lives only in memory. Crash in between and those miners are never paid, with no record they were owed anything. Only the comment changes here. The behaviour needs idempotent payment -- record the txid, look for it in the wallet on restart -- because simply persisting the queue and re-running it would double-pay anyone already sent to, which is the worse failure. --- src/rpc.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 3b2dae9..6f81153 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -341,8 +341,18 @@ static void QueuePayouts(int blockHeight, } // Called from event loop: move matured entries to the exec queue (fast). -// Save BEFORE moving to exec queue: if we crash between the save and SendMoney, -// LoadPendingPayouts re-queues them from the exec-queue section of the file. +// NOT crash-safe, despite the ordering below looking like it is. +// +// A matured payout is erased from gPendingPayouts, the file is rewritten +// without it, and only then does it reach the exec queue -- which lives in +// memory alone. SavePendingPayouts writes gPendingPayouts and nothing else; +// there is no exec-queue section of the file to recover from. Die anywhere +// between the rewrite and SendMoney and those miners are simply never paid, +// with no record that they were owed anything. +// +// Making this safe needs the payment to be idempotent -- record the txid and +// check the wallet for it on restart -- rather than a reordering, since +// re-queueing blindly would double-pay whoever was already sent to. static void FlushMaturePayouts() { std::vector ready; -- cgit v1.2.3 From 63764708903cf93476b0fa43580e23ff75ffceba Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 28 Jul 2026 15:25:57 -0300 Subject: rpc: bound and guard the Stratum surface (#19) Everything an unauthenticated socket can reach in the pool server, in one pass. A line that never ends. RecvLine appended to the per-miner buffer and looked for a newline; a peer that sends bytes and no newline grew that buffer for as long as it kept writing, before authorizing or subscribing. Capped at 64 KB, which drops the miner. No limit on connections. Every accepted socket became a Miner with its own buffer and nothing bounded the list. Capped at 1000. Malformed requests crashed the node, not just the pool. ThreadRPCServer has no exception handler, so anything thrown from line handling reached the top of the thread and terminated the process. Three ways in, all valid JSON that parses cleanly: [1,2,3] -- value() on a non-object throws {"method": 1} -- value() with the wrong type throws submit with params [1,2,3] -- get() on a number throws mining.authorize had the subtler shape of the same thing: it tested p.size() without testing p.is_array(), and indexing a json object with a number throws while size() happily returns its key count. Each is now rejected on its own terms, and the call is wrapped so a throw drops that miner instead of the node. --- src/rpc.cpp | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 8 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 6f81153..9cd0ed6 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -553,6 +553,12 @@ static bool SendLine(btf_socket_t fd, const json& j) return true; } +// Ceilings on what an unauthenticated peer can make the pool operator hold. +// Stratum lines are a few hundred bytes and a real pool this size will not see +// anything near a thousand miners. +static const size_t MAX_STRATUM_LINE = 64 * 1024; +static const size_t MAX_POOL_MINERS = 1000; + // Non-blocking recv: pull whatever bytes are available now into buf, then // return one complete line if one is ready. Three outcomes: // true -- line is filled; buf may still contain more lines @@ -585,6 +591,16 @@ static bool RecvLine(btf_socket_t fd, std::string& buf, } #endif + // A peer that never sends a newline would otherwise grow this buffer for + // as long as it keeps writing, and it does not have to authorize first -- + // the read happens before any of that. Stratum lines are a few hundred + // bytes; anything past the cap is not a slow line, it is a flood. + if (buf.size() > MAX_STRATUM_LINE) { + disconnected = true; + buf.clear(); + return false; + } + size_t pos = buf.find('\n'); if (pos == std::string::npos) return false; // no complete line yet @@ -668,6 +684,16 @@ static bool HandleLine(Miner* m, const std::string& rawLine, return true; // don't disconnect on bad JSON, just ignore the line } + // value() throws when the document is not an object, and again when a key + // is present with the wrong type -- "[1,2,3]" and {"method":1} are both + // valid JSON that parse cleanly and then blow up on the next two lines. + if (!req.is_object() || (req.contains("method") && !req["method"].is_string())) { + LogPrint("worker", "[worker] malformed request from %s: %.80s\n", + m->address.empty() ? "(unauth)" : m->address.c_str(), + rawLine.c_str()); + return true; + } + json id = req.value("id", json(nullptr)); std::string method = req.value("method", ""); @@ -691,9 +717,13 @@ static bool HandleLine(Miner* m, const std::string& rawLine, // ------------------------------------------------------------------ if (method == "mining.authorize") { + // is_array() matters as much as the element checks: indexing a json + // object with a number throws, and size() on an object returns its key + // count, so "params":{"a":1} would pass a bare size() test. auto& p = req["params"]; - m->address = (p.size() > 0 && p[0].is_string()) ? p[0].get() : ""; - m->worker = (p.size() > 1 && p[1].is_string()) ? p[1].get() : "worker"; + bool fArr = p.is_array(); + m->address = (fArr && p.size() > 0 && p[0].is_string()) ? p[0].get() : ""; + m->worker = (fArr && p.size() > 1 && p[1].is_string()) ? p[1].get() : "worker"; m->lastSeen = GetTime(); // Validate address before authorising @@ -748,9 +778,13 @@ static bool HandleLine(Miner* m, const std::string& rawLine, } auto& p = req["params"]; - if (p.size() < 3) { - LogPrint("worker", "[worker->pool] submit bad params from %s (size=%zu)\n", - m->address.c_str(), p.size()); + // Check the shape as well as the length: a submit carrying + // "params":[1,2,3] would otherwise reach get() and throw + // a json::type_error that nothing above catches, taking the pool down + // with it. Authorising costs an attacker nothing but an address. + if (!p.is_array() || p.size() < 3 || !p[1].is_string() || !p[2].is_string()) { + LogPrint("worker", "[worker->pool] submit bad params from %s\n", + m->address.c_str()); reply(false, "bad params"); return true; } @@ -1010,6 +1044,16 @@ static void PoolEventLoop() { std::lock_guard lk(gReadyMutex); for (btf_socket_t fd : gReadyQueue) { + // Connecting costs nothing and every accepted socket carries a + // read buffer, so an unbounded miner list is an unbounded + // allocation for whoever runs the pool. + if (gMiners.size() >= MAX_POOL_MINERS) { + LogPrint("worker", "[pool] refusing miner fd=%d --" + " already at %zu connections\n", + (int)fd, gMiners.size()); + sock_close(fd); + continue; + } gMiners.push_back(new Miner(fd)); LogPrint("worker", "[pool] miner fd=%d entered event loop" " -- awaiting subscribe/authorize\n", (int)fd); @@ -1154,9 +1198,29 @@ static void PoolEventLoop() bool disc = false; // Drain all complete lines buffered for this miner. // HandleLine always returns true; disc signals clean disconnect. - while (RecvLine(m->fd, m->readBuf, line, disc)) - HandleLine(m, line, roundShareCount, - roundShareTotal, blocksFoundThisSession); + // ThreadRPCServer has no handler of its own, so anything + // thrown here would reach the top of the thread and + // terminate the whole node -- not just the pool. Every + // line arrives from an unauthenticated socket. + while (RecvLine(m->fd, m->readBuf, line, disc)) { + try { + HandleLine(m, line, roundShareCount, + roundShareTotal, blocksFoundThisSession); + } catch (const std::exception& e) { + LogPrint("worker", "[pool] dropping miner %s --" + " exception handling line: %s\n", + m->address.empty() ? "(unauth)" : m->address.c_str(), + e.what()); + disc = true; + break; + } catch (...) { + LogPrint("worker", "[pool] dropping miner %s --" + " unknown exception handling line\n", + m->address.empty() ? "(unauth)" : m->address.c_str()); + disc = true; + break; + } + } if (disc) dead.push_back(m); } for (Miner* m : dead) { -- cgit v1.2.3 From 50b0a91b5dddd38f3094b9e97db77773d8299442 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Thu, 30 Jul 2026 18:31:27 -0300 Subject: pool: put the height in the job builder's coinbase too (#60) #59 changed BitcoinMiner and stopped there. RebuildJob in rpc.cpp builds its own coinbase for the pool operator's job template, and that template is mined and submitted like any other block, so it had the same hole: nothing in the transaction belonged to the block, and a repeated combination of difficulty, counter and key produces a byte-identical coinbase. The claim in #59 that two coinbases at different heights can no longer be identical only becomes true with this. --- src/rpc.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 9cd0ed6..285f11b 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -153,7 +153,12 @@ static bool RebuildJob() CTransaction txNew; txNew.vin.resize(1); txNew.vin[0].prevout.SetNull(); - txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce; + // Height first, for the reason given at the matching line in + // BitcoinMiner: it is what keeps two coinbases at different heights + // from ever being byte-identical. This builder was missed when that + // went in, and it produces real blocks -- a pool operator's template + // is mined and submitted like any other. #58. + txNew.vin[0].scriptSig << (pindexPrev ? pindexPrev->nHeight + 1 : 0) << nBits << (CBigNum)extraNonce; txNew.vout.resize(1); txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; -- cgit v1.2.3 From 59777ee34a5ab6297684b7ab8d5d93facd4ad6f3 Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:05:35 -0300 Subject: net: bound what a peer can make the node allocate (#73) Three independent bounds on what an untrusted peer can make this node allocate: - serialize.h: reject a compact size above MAX_SIZE before reading a string or a vector, instead of discovering it read by read. MAX_SIZE moves from main.h to serialize.h, where the checks live; headers_core.h includes serialize.h first, so every existing user still sees it. - main.cpp: cap an orphan transaction at 10,000 inputs. Orphans are stored and never validated, so mapOrphanTransactionsByPrev grew with one entry per input of every orphan a peer cared to send. - rpc.cpp: drop a Stratum line nested deeper than 20 before json::parse recurses into it. The scan tracks string state, so a worker name containing { or [ is no longer mistaken for nesting. Verified: builds clean on UCRT64; the depth scanner passes a table of legitimate Stratum lines and nesting attacks; a node built from this branch synced the chain from genesis to 4462 with no deserialization failure and no rejected block. --- src/main.cpp | 10 ++++++++++ src/main.h | 1 - src/rpc.cpp | 27 ++++++++++++++++++++++++++- src/serialize.h | 8 +++++++- 4 files changed, 43 insertions(+), 3 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/main.cpp b/src/main.cpp index 4bc9c4d..c3fb33b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -282,6 +282,16 @@ void AddOrphanTx(const CDataStream& vMsg) uint256 hash = tx.GetHash(); if (mapOrphanTransactions.count(hash)) return; + + // Do not allow an orphan to have an unbounded number of inputs. + // 10,000 inputs is enough for massive legitimate sweeps, but bounds the total + // memory in mapOrphanTransactionsByPrev to ~10,000 * MAX_ORPHAN_TRANSACTIONS. + if (tx.vin.size() > 10000) + { + if (LogAcceptsCategory("net")) printf("AddOrphanTx() : orphan with %d inputs rejected\n", (int)tx.vin.size()); + return; + } + CDataStream* pvMsg = mapOrphanTransactions[hash] = new CDataStream(vMsg); foreach(const CTxIn& txin, tx.vin) mapOrphanTransactionsByPrev.insert(make_pair(txin.prevout.hash, pvMsg)); diff --git a/src/main.h b/src/main.h index 3bc1219..7266e3d 100644 --- a/src/main.h +++ b/src/main.h @@ -14,7 +14,6 @@ class CBlockIndex; class CWalletTx; class CKeyItem; -static const unsigned int MAX_SIZE = 0x02000000; // Ceiling on transactions held waiting for a parent that has not arrived. // They cost a peer nothing to send and are never validated, only stored. static const unsigned int MAX_ORPHAN_TRANSACTIONS = 100; diff --git a/src/rpc.cpp b/src/rpc.cpp index 285f11b..3db8b94 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -681,7 +681,32 @@ static bool HandleLine(Miner* m, const std::string& rawLine, int& blocksFoundThisSession) { json req; - try { req = json::parse(rawLine); } + try { + // Fast pre-parse depth check to prevent stack overflow in json::parse. + // Legitimate Stratum requests have a depth of at most 4. + int depth = 0; + bool in_string = false; + bool escape = false; + for (char c : rawLine) { + if (in_string) { + if (escape) escape = false; + else if (c == '\\') escape = true; + else if (c == '"') in_string = false; + } else { + if (c == '"') in_string = true; + else if (c == '{' || c == '[') { + if (++depth > 20) { + LogPrint("worker", "[worker] JSON too deeply nested from %s\n", + m->address.empty() ? "(unauth)" : m->address.c_str()); + return true; + } + } else if (c == '}' || c == ']') { + depth--; + } + } + } + req = json::parse(rawLine); + } catch (...) { LogPrint("worker", "[worker] JSON parse error from %s: %.80s\n", m->address.empty() ? "(unauth)" : m->address.c_str(), diff --git a/src/serialize.h b/src/serialize.h index ce72c60..fa77074 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -20,7 +20,7 @@ class CDataStream; class CAutoFile; static const int VERSION = 101; - +static const unsigned int MAX_SIZE = 0x02000000; @@ -399,6 +399,8 @@ void Unserialize(Stream& is, basic_string& str, int, int) // only string field on a serialized type and RelayMessage casts it away -- // so this is a guard against the next one that does, not a live hole. unsigned int nSize = ReadCompactSize(is); + if (nSize > MAX_SIZE) + throw std::ios_base::failure("String size too large"); str.clear(); unsigned int nMid = 0; while (nMid < nSize) @@ -472,6 +474,8 @@ void Unserialize_impl(Stream& is, std::vector& v, int nType, int nVersion, // Limit size per read so bogus size value won't cause out of memory v.clear(); unsigned int nSize = ReadCompactSize(is); + if (nSize > MAX_SIZE) + throw std::ios_base::failure("Vector size too large"); unsigned int i = 0; while (i < nSize) { @@ -492,6 +496,8 @@ void Unserialize_impl(Stream& is, std::vector& v, int nType, int nVersion, v.clear(); unsigned int nSize = ReadCompactSize(is); + if (nSize > MAX_SIZE) + throw std::ios_base::failure("Vector size too large"); unsigned int i = 0; unsigned int nMid = 0; while (nMid < nSize) -- cgit v1.2.3 From 7131c30c33c980f0ba1d37dfc6a3fbe8c319813e Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 12:56:16 -0300 Subject: wallet: generate keys ahead of use so a backup outlives the next block (#85) The wallet made a key per address and per block mined, so a backup only ever contained the keys that existed the moment it was taken -- mine one block and the coin lands on a key the backup has never heard of. The README warned about it, which is an admission rather than a fix. Keys are now generated a hundred at a time and handed out one at a time, each written to wallet.dat before it can be pointed at. A backup covers the next hundred addresses. Deterministic derivation (#47) stays open; this buys back the distance between a backup and the next mistake. --- src/db.cpp | 8 +++++++ src/db.h | 13 +++++++++++ src/main.cpp | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------- src/main.h | 26 +++++++++++++++++++++ src/rpc.cpp | 9 +++++--- 5 files changed, 118 insertions(+), 13 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/db.cpp b/src/db.cpp index a503cae..3af87fb 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -636,6 +636,14 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) { ssValue >> vchDefaultKeyRet; } + else if (strType == "pool") + { + int64 nIndex; + ssKey >> nIndex; + vector vchPubKey; + ssValue >> vchPubKey; + mapKeyPool[nIndex] = vchPubKey; + } else if (strType == "setting") /// or settings or option or options or config? { string strKey; diff --git a/src/db.h b/src/db.h index 22efc77..f51047e 100644 --- a/src/db.h +++ b/src/db.h @@ -386,6 +386,19 @@ public: return Write(string("defaultkey"), vchPubKey); } + // Keys generated ahead of time and not yet handed out. The private key of + // each is already stored under its own "key" record by AddKey; a "pool" + // record only says that this one is still unspoken for. + bool WritePool(int64 nIndex, const vector& vchPubKey) + { + return Write(make_pair(string("pool"), nIndex), vchPubKey); + } + + bool ErasePool(int64 nIndex) + { + return Erase(make_pair(string("pool"), nIndex)); + } + template bool ReadSetting(const string& strKey, T& value) { diff --git a/src/main.cpp b/src/main.cpp index f63f575..b027cef 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -175,6 +175,58 @@ vector GenerateNewKey() return key.GetPubKey(); } +CCriticalSection cs_keyPool; +map > mapKeyPool; + +void TopUpKeyPool() +{ + CRITICAL_BLOCK(cs_keyPool) + { + while ((int)mapKeyPool.size() < KEYPOOL_SIZE) + { + // Indices only ever go up. Reusing one would mean two pool records + // racing for the same slot after an erase. + int64 nIndex = 1; + if (!mapKeyPool.empty()) + nIndex = (--mapKeyPool.end())->first + 1; + + CKey key; + key.MakeNewKey(); + // AddKey writes the private key. Order matters: the key is in + // wallet.dat before anything can point at it, so a crash between + // the two writes costs an unused key, never a usable one. + if (!AddKey(key)) + return; + vector vchPubKey = key.GetPubKey(); + if (!CWalletDB().WritePool(nIndex, vchPubKey)) + return; + mapKeyPool[nIndex] = vchPubKey; + } + } +} + +vector GetKeyFromPool() +{ + CRITICAL_BLOCK(cs_keyPool) + { + TopUpKeyPool(); + if (!mapKeyPool.empty()) + { + map >::iterator mi = mapKeyPool.begin(); + int64 nIndex = mi->first; + vector vchPubKey = mi->second; + mapKeyPool.erase(mi); + CWalletDB().ErasePool(nIndex); + return vchPubKey; + } + } + // Nothing in the pool and nothing could be added -- disk full, wallet + // locked open elsewhere. Generating on demand is what this code did for + // its whole life, so fall back to it rather than refuse to mine. + printf("GetKeyFromPool() : pool empty, generating a key on demand\n"); + return GenerateNewKey(); +} + @@ -3061,8 +3113,11 @@ bool BitcoinMiner(int nThreadId) RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); } - CKey key; - key.MakeNewKey(); + // Drawn from the pool, so it is already in wallet.dat before a single hash + // is computed. The old code generated a key here and only saved it if this + // thread happened to win a block, which meant a backup taken while mining + // could not spend what the very next block paid. + vector vchPubKey = GetKeyFromPool(); CBigNum bnExtraNonce = 0; while (fGenerateBitcoins) { @@ -3113,7 +3168,7 @@ bool BitcoinMiner(int nThreadId) txNew.vout.resize(1); // Always pay coinbase to own wallet key. In operator mode, the pool // server distributes shares to miners via SendMoney() after each block. - txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; + txNew.vout[0].scriptPubKey << vchPubKey << OP_CHECKSIG; // @@ -3234,13 +3289,13 @@ bool BitcoinMiner(int nThreadId) } else { - // Save key - if (!AddKey(key)) - { - RandomXDestroyMinerVM(rxvm); - return false; - } - key.MakeNewKey(); + // The key that just got paid was written to wallet.dat + // when it left the pool, so there is nothing to save + // here -- only a fresh one to take for the next block. + // Two threads never draw the same key: the pool hands + // them out one at a time under cs_keyPool, which is + // what keeps concurrent coinbases distinct (#58). + vchPubKey = GetKeyFromPool(); // Process this block the same as if we had received it from another node if (!ProcessBlock(NULL, pblock.release())) diff --git a/src/main.h b/src/main.h index a50485f..1d9efac 100644 --- a/src/main.h +++ b/src/main.h @@ -124,6 +124,32 @@ FILE* OpenBlockFile(unsigned int nFile, unsigned int nBlockPos, const char* pszM FILE* AppendBlockFile(unsigned int& nFileRet); bool AddKey(const CKey& key); vector GenerateNewKey(); + +// --- Key pool ------------------------------------------------------------- +// +// A backup of this wallet only ever contained the keys that existed the moment +// it was taken. Mine a block or take a new address afterwards and the coin +// lands on a key the backup has never heard of -- it is on the chain, and the +// file you carefully put away cannot spend it. The LEIA-ME shipped with a +// warning about this, which is an admission, not a fix. +// +// Keys are now generated a hundred at a time and handed out one by one, so a +// backup covers the next hundred addresses this node will use. It is the same +// answer Bitcoin arrived at, and it is not deterministic derivation (#47) -- +// it just buys back the distance between a backup and the next mistake. +static const int KEYPOOL_SIZE = 100; +extern CCriticalSection cs_keyPool; +extern map > mapKeyPool; + +// Fill the pool back up to KEYPOOL_SIZE. Every key it creates is written to +// wallet.dat before it is offered to anybody. +void TopUpKeyPool(); + +// Hand out the oldest unused key. Falls back to generating one on the spot if +// the pool cannot be filled, which is exactly what this code did before the +// pool existed -- a wallet that cannot pre-generate should still be able to +// mine. +vector GetKeyFromPool(); bool AddToWallet(const CWalletTx& wtxIn); void ReacceptWalletTransactions(); // Returns how many wallet transactions the chain corrected. diff --git a/src/rpc.cpp b/src/rpc.cpp index 3db8b94..97d8429 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -146,7 +146,9 @@ static bool RebuildJob() CBlockIndex* pindexPrev = pindexBest; unsigned int nBits = GetNextWorkRequired(pindexPrev); - CKey key; key.MakeNewKey(); + // From the pool, so the template's payout key is in wallet.dat before + // any miner is handed work against it. + vector vchPubKey = GetKeyFromPool(); static std::atomic sExtra{0}; uint32_t extraNonce = ++sExtra; @@ -160,7 +162,7 @@ static bool RebuildJob() // is mined and submitted like any other. #58. txNew.vin[0].scriptSig << (pindexPrev ? pindexPrev->nHeight + 1 : 0) << nBits << (CBigNum)extraNonce; txNew.vout.resize(1); - txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG; + txNew.vout[0].scriptPubKey << vchPubKey << OP_CHECKSIG; CBlock block; block.vtx.push_back(txNew); @@ -194,7 +196,8 @@ static bool RebuildJob() (unsigned int)GetAdjustedTime()); block.nBits = nBits; block.nNonce = 1; - AddKey(key); + // No AddKey here: the pool wrote this key to wallet.dat before it was + // handed out. StratumJob job; job.jobId = ToHex(&extraNonce, 4); -- cgit v1.2.3 From 21c8bb63cb85f3001ddee9237b1d7e689675d293 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Sat, 1 Aug 2026 13:10:23 -0300 Subject: diag: count sockets by where they were created, and whether the close took (#86) A node was found holding 1502 sockets in Windows bound state, one ephemeral port each, 9% of the machine range in nineteen hours. Reading the code did not find it: every connect() failure path here closes its socket. So count. Every socket is tagged at creation and untagged at close, across all ten sites, with a counter for closes that fail. Already rules out three explanations: nothing is created or closed behind the counter (untagged closes are zero), no close is failing, and the leaked sockets are not in the live set -- tracked live matches what the OS reports as established, while the bound pile is something the program already closed successfully. --- src/btfrv.cpp | 12 +++-- src/btftunnel.cpp | 23 ++++----- src/net.cpp | 43 ++++++++++++++--- src/net.h | 2 + src/nostr.cpp | 6 +-- src/rpc.cpp | 4 +- src/sockcount.h | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 199 insertions(+), 28 deletions(-) create mode 100644 src/sockcount.h (limited to 'src/rpc.cpp') diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 7efd38b..6ac3280 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -11,7 +11,7 @@ #ifdef _WIN32 #include #include -#define CLOSESOCK closesocket +#define CLOSESOCK BtfCloseSocket #else #include #include @@ -22,12 +22,14 @@ typedef int SOCKET; #define INVALID_SOCKET (-1) #define SOCKET_ERROR (-1) -#define CLOSESOCK ::close +#define CLOSESOCK BtfCloseSocket #ifndef SD_BOTH #define SD_BOTH SHUT_RDWR #endif #endif +#include "sockcount.h" + #include #include #include @@ -96,7 +98,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) hints.ai_socktype = SOCK_STREAM; char portstr[16]; sprintf(portstr, "%u", (unsigned)port); if (getaddrinfo(host, portstr, &hints, &res) != 0 || !res) return INVALID_SOCKET; - SOCKET s = socket(res->ai_family, res->ai_socktype, res->ai_protocol); + SOCKET s = BtfSocketTag(socket(res->ai_family, res->ai_socktype, res->ai_protocol), SOCK_RV_DIAL); if (s == INVALID_SOCKET) { freeaddrinfo(res); return INVALID_SOCKET; } // Apply a short timeout only for the connect() and initial handshake so an @@ -201,7 +203,7 @@ bool RvRelayRun(unsigned short port) if (!RvInit()) return false; g_stop = false; - g_listen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + g_listen = BtfSocketTag(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP), SOCK_RV_LISTEN); if (g_listen == INVALID_SOCKET) return false; int yes = 1; setsockopt(g_listen, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes)); @@ -218,7 +220,7 @@ bool RvRelayRun(unsigned short port) { struct sockaddr_in cli; socklen_t len = sizeof(cli); - SOCKET s = accept(g_listen, (struct sockaddr*)&cli, &len); + SOCKET s = BtfSocketTag(accept(g_listen, (struct sockaddr*)&cli, &len), SOCK_RV_ACCEPT); if (s == INVALID_SOCKET) { if (g_stop) break; diff --git a/src/btftunnel.cpp b/src/btftunnel.cpp index 65c4b89..63d178b 100644 --- a/src/btftunnel.cpp +++ b/src/btftunnel.cpp @@ -23,6 +23,7 @@ typedef int SOCKET; #define SD_BOTH SHUT_RDWR #endif #endif +#include "sockcount.h" #include #include #include @@ -39,7 +40,7 @@ bool TunnelInit() // Returns app end and pump end; both are ordinary full-duplex TCP sockets. static bool MakeLocalPair(btf_socket_t& appEnd, btf_socket_t& pumpEnd) { - SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + SOCKET listener = BtfSocketTag(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP), SOCK_PAIR_LISTEN); if (listener == INVALID_SOCKET) return false; struct sockaddr_in addr; @@ -47,23 +48,23 @@ static bool MakeLocalPair(btf_socket_t& appEnd, btf_socket_t& pumpEnd) addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); addr.sin_port = 0; // ephemeral - if (bind(listener, (struct sockaddr*)&addr, sizeof(addr)) != 0) { closesocket(listener); return false; } - if (listen(listener, 1) != 0) { closesocket(listener); return false; } + if (bind(listener, (struct sockaddr*)&addr, sizeof(addr)) != 0) { BtfCloseSocket(listener); return false; } + if (listen(listener, 1) != 0) { BtfCloseSocket(listener); return false; } #ifdef _WIN32 int len = sizeof(addr); #else socklen_t len = sizeof(addr); #endif - if (getsockname(listener, (struct sockaddr*)&addr, &len) != 0) { closesocket(listener); return false; } + if (getsockname(listener, (struct sockaddr*)&addr, &len) != 0) { BtfCloseSocket(listener); return false; } - SOCKET a = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); - if (a == INVALID_SOCKET) { closesocket(listener); return false; } - if (connect(a, (struct sockaddr*)&addr, sizeof(addr)) != 0) { closesocket(a); closesocket(listener); return false; } + SOCKET a = BtfSocketTag(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP), SOCK_PAIR_APP); + if (a == INVALID_SOCKET) { BtfCloseSocket(listener); return false; } + if (connect(a, (struct sockaddr*)&addr, sizeof(addr)) != 0) { BtfCloseSocket(a); BtfCloseSocket(listener); return false; } - SOCKET b = accept(listener, NULL, NULL); - closesocket(listener); - if (b == INVALID_SOCKET) { closesocket(a); return false; } + SOCKET b = BtfSocketTag(accept(listener, NULL, NULL), SOCK_PAIR_PUMP); + BtfCloseSocket(listener); + if (b == INVALID_SOCKET) { BtfCloseSocket(a); return false; } appEnd = a; pumpEnd = b; @@ -129,7 +130,7 @@ static btf_socket_t StartTunnel(RvSocket rv, const unsigned char shared[32]) std::thread(PumpOut, pumpEnd, rv, key).detach(); std::thread([pumpEnd, rv, key]() { PumpIn(pumpEnd, rv, key); - closesocket(pumpEnd); + BtfCloseSocket(pumpEnd); RvClose(rv); }).detach(); return appEnd; diff --git a/src/net.cpp b/src/net.cpp index 19893ee..7a13257 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -51,6 +51,33 @@ int64 nBlocksReceived = 0; int64 nBlocksWithoutParent = 0; static int64 nNodeStartTime = 0; +// --- Socket accounting (implementation lives in sockcount.h) -------------- +static const char* pszSockSite[SOCK_SITES] = { + "external-ip probe", "listen socket", "inbound accept", + "rendezvous dial", "rendezvous listen", "rendezvous accept", + "loopback pair listener", "loopback pair app end", "loopback pair pump end", + "nostr relay" +}; + +static string SockAccountingText() +{ + long long nOpened[SOCK_SITES], nClosed[SOCK_SITES], nFailed[SOCK_SITES], nUntagged = 0; + BtfSockSnapshot(nOpened, nClosed, nFailed, &nUntagged); + + string str = "\n sockets by where they were created\n"; + str += " site opened closed live close failed\n"; + for (int i = 0; i < SOCK_SITES; i++) + { + if (nOpened[i] == 0) + continue; + str += strprintf(" %-24s %8lld %8lld %8lld %13lld\n", pszSockSite[i], + nOpened[i], nClosed[i], nOpened[i] - nClosed[i], nFailed[i]); + } + str += strprintf(" %-24s %8s %8lld %8s %13s\n", "closed but never tagged", + "-", nUntagged, "-", "-"); + return str; +} + // Defined further down, next to the counter it reads. The miner keeps its own // live count -- threads bump it on the way in and on the way out -- which is // the honest number here: how many are hashing now, not how many were asked to. @@ -119,6 +146,8 @@ string GetDiagnosticsText() (RandomXFastReady() ? 2080 : 256) + 2 * nMining).c_str() : ", not mining"); + str += SockAccountingText(); + str += "\n peer dir height last recv last send unsent\n"; foreach(CNode* pnode, vCopy) { @@ -157,7 +186,7 @@ bool GetMyExternalIP(unsigned int& ipRet) if (getaddrinfo(svc.host, "80", &hints, &res) != 0 || !res) continue; - SOCKET hSocket = socket(res->ai_family, res->ai_socktype, res->ai_protocol); + SOCKET hSocket = BtfSocketTag(socket(res->ai_family, res->ai_socktype, res->ai_protocol), SOCK_EXTIP); if (hSocket == INVALID_SOCKET) { freeaddrinfo(res); continue; } // 5-second timeout so a dead service doesn't stall startup @@ -173,7 +202,7 @@ bool GetMyExternalIP(unsigned int& ipRet) if (connect(hSocket, res->ai_addr, (int)res->ai_addrlen) != 0) { - freeaddrinfo(res); closesocket(hSocket); continue; + freeaddrinfo(res); BtfCloseSocket(hSocket); continue; } freeaddrinfo(res); @@ -190,7 +219,7 @@ bool GetMyExternalIP(unsigned int& ipRet) response += buf; if (response.size() > 4096) break; } - closesocket(hSocket); + BtfCloseSocket(hSocket); // Find blank line separating headers from body size_t bodyPos = response.find("\r\n\r\n"); @@ -950,7 +979,7 @@ void CNode::Disconnect() { LogPrint("net", "disconnecting node %s\n", addr.ToString().c_str()); - closesocket(hSocket); + BtfCloseSocket(hSocket); // All of a nodes broadcasts and subscriptions are automatically torn down // when it goes down, so a node has to stay up to keep its broadcast going. @@ -1230,7 +1259,7 @@ void ThreadSocketHandler2(void* parg) #else socklen_t len = sizeof(sockaddr); #endif - SOCKET hSocket = accept(hListenSocket, (struct sockaddr*)&sockaddr, &len); + SOCKET hSocket = BtfSocketTag(accept(hListenSocket, (struct sockaddr*)&sockaddr, &len), SOCK_ACCEPT); CAddress addr(sockaddr); if (hSocket == INVALID_SOCKET) { @@ -1251,7 +1280,7 @@ void ThreadSocketHandler2(void* parg) { LogPrint("net", "refusing connection from %s, already at %u\n", addr.ToString().c_str(), nNodes); - closesocket(hSocket); + BtfCloseSocket(hSocket); } else { @@ -1639,7 +1668,7 @@ bool StartNode(string& strError) printf("addrLocalHost = %s\n", addrLocalHost.ToString().c_str()); // Create socket for listening for incoming connections - SOCKET hListenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + SOCKET hListenSocket = BtfSocketTag(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP), SOCK_LISTEN); if (hListenSocket == INVALID_SOCKET) { strError = strprintf("Error: Couldn't open socket for incoming connections (socket returned error %d)", WSAGetLastError()); diff --git a/src/net.h b/src/net.h index bd4eb77..314c709 100644 --- a/src/net.h +++ b/src/net.h @@ -121,6 +121,8 @@ extern int64 nBlocksWithoutParent; // ...of those, how many arrived orphaned // user pastes into an issue is what a developer already knows how to read. string GetDiagnosticsText(); +#include "sockcount.h" + // Defined in main.cpp. Declared here because CNode announces it in the version // message, and net.h is included before main.h. extern int nBestHeight; diff --git a/src/nostr.cpp b/src/nostr.cpp index 17139ad..dc96be2 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -462,7 +462,7 @@ public: { if (ssl) { SSL_shutdown(ssl); SSL_free(ssl); ssl = NULL; } if (sslctx) { SSL_CTX_free(sslctx); sslctx = NULL; } - if (hSocket != INVALID_SOCKET) { closesocket(hSocket); hSocket = INVALID_SOCKET; } + if (hSocket != INVALID_SOCKET) { BtfCloseSocket(hSocket); hSocket = INVALID_SOCKET; } } int RawRead(char* buf, int len) @@ -518,7 +518,7 @@ public: bool fConnected = false; for (rp = res; rp != NULL; rp = rp->ai_next) { - hSocket = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + hSocket = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_NOSTR); if (hSocket == INVALID_SOCKET) continue; @@ -542,7 +542,7 @@ public: break; } - closesocket(hSocket); + BtfCloseSocket(hSocket); hSocket = INVALID_SOCKET; } freeaddrinfo(res); diff --git a/src/rpc.cpp b/src/rpc.cpp index 97d8429..41c0328 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -46,10 +46,10 @@ using json = nlohmann::json; #ifdef _WIN32 -#define sock_close(s) closesocket(s) +#define sock_close(s) BtfCloseSocket(s) #define SEND_FLAGS 0 #else -#define sock_close(s) ::close(s) +#define sock_close(s) BtfCloseSocket(s) #define SEND_FLAGS MSG_NOSIGNAL #endif diff --git a/src/sockcount.h b/src/sockcount.h new file mode 100644 index 0000000..19f0827 --- /dev/null +++ b/src/sockcount.h @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Socket accounting -- which part of the program is holding the sockets. +// +// A node on a 32-core machine was found with 1502 sockets in Windows' "bound" +// state: created, never connected, never closed, each holding a distinct +// ephemeral port. That is 9% of the machine's whole dynamic port range gone in +// nineteen hours, and when the range runs out nothing on that machine can open +// an outbound connection -- not just this program. +// +// Reading the code did not find it. Every connect() failure path in this tree +// closes its socket. So count instead: tag each socket where it is created, +// untag it where it is closed, and report the difference per site. Whichever +// site's live count tracks the leak is the answer. +// +// Header-only on purpose. btfrv.cpp is compiled a second time, on its own, into +// the standalone rendezvous relay -- a separate tree that does not link net.cpp. +// Anything here that needed a .cpp would break that build, and it would break +// it on the VPS rather than on this machine. Include after the platform socket +// headers; each of those files defines SOCKET for itself. + +#ifndef BITFLASH_SOCKCOUNT_H +#define BITFLASH_SOCKCOUNT_H + +#include +#include + +#ifndef _WIN32 +#include +#endif + +enum +{ + SOCK_EXTIP, // GetMyExternalIP probe + SOCK_LISTEN, // the node's own listening socket + SOCK_ACCEPT, // inbound connections + SOCK_RV_DIAL, // outbound to a rendezvous relay + SOCK_RV_LISTEN, // rendezvous listener + SOCK_RV_ACCEPT, // inbound at the rendezvous + SOCK_PAIR_LISTEN, // loopback pair: the temporary listener + SOCK_PAIR_APP, // loopback pair: the app end handed to CNode + SOCK_PAIR_PUMP, // loopback pair: the pump end + SOCK_NOSTR, // outbound to a Nostr relay + SOCK_SITES +}; + +struct BtfSockAccount +{ + std::mutex mtx; + std::map mapSite; + long long nOpened[SOCK_SITES]; + long long nClosed[SOCK_SITES]; + long long nCloseFailed[SOCK_SITES]; + long long nClosedUntagged; + BtfSockAccount() : nClosedUntagged(0) + { + for (int i = 0; i < SOCK_SITES; i++) { nOpened[i] = 0; nClosed[i] = 0; nCloseFailed[i] = 0; } + } +}; + +// One instance across every translation unit: a function-local static in an +// inline function is the same object everywhere it is used. +inline BtfSockAccount& BtfSockAccounting() +{ + static BtfSockAccount account; + return account; +} + +// Record a freshly created socket and return it unchanged, so call sites read +// as they did before. INVALID_SOCKET passes through untouched. +inline SOCKET BtfSocketTag(SOCKET hSocket, int nSite) +{ + if (hSocket == INVALID_SOCKET || nSite < 0 || nSite >= SOCK_SITES) + return hSocket; + BtfSockAccount& a = BtfSockAccounting(); + std::lock_guard lock(a.mtx); + a.mapSite[hSocket] = nSite; + a.nOpened[nSite]++; + return hSocket; +} + +// Close a socket and forget it. Closing one nobody tagged is counted too -- +// that number staying at zero is what says the accounting is complete. +inline int BtfCloseSocket(SOCKET hSocket) +{ + if (hSocket == INVALID_SOCKET) + return 0; + int nSite = -1; + { + BtfSockAccount& a = BtfSockAccounting(); + std::lock_guard lock(a.mtx); + std::map::iterator mi = a.mapSite.find(hSocket); + if (mi == a.mapSite.end()) + a.nClosedUntagged++; + else + { + nSite = mi->second; + a.nClosed[nSite]++; + a.mapSite.erase(mi); + } + } +#ifdef _WIN32 + int nRet = ::closesocket(hSocket); +#else + int nRet = ::close(hSocket); +#endif + // A close that fails leaves the descriptor -- and on Windows the ephemeral + // port it holds -- in place. Counted because a socket the program believes + // it released and the operating system still shows is exactly the shape of + // the leak being chased, and nothing in this codebase has ever looked at + // what closesocket returns. + if (nRet != 0 && nSite >= 0) + { + BtfSockAccount& a = BtfSockAccounting(); + std::lock_guard lock(a.mtx); + a.nCloseFailed[nSite]++; + } + return nRet; +} + +// Copy the counters out. Formatting lives in net.cpp, which has strprintf -- +// and which the relay build does not compile. +inline void BtfSockSnapshot(long long* pOpened, long long* pClosed, long long* pFailed, long long* pUntagged) +{ + BtfSockAccount& a = BtfSockAccounting(); + std::lock_guard lock(a.mtx); + for (int i = 0; i < SOCK_SITES; i++) + { + pOpened[i] = a.nOpened[i]; + pClosed[i] = a.nClosed[i]; + pFailed[i] = a.nCloseFailed[i]; + } + *pUntagged = a.nClosedUntagged; +} + +#endif -- cgit v1.2.3 From 7aaaa7a684e33591125963df1eb4da6da2fa6495 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 4 Aug 2026 15:14:46 -0300 Subject: rpc: build the pool block template the way the solo miner does (#141) Two bugs in RebuildJob(), the operator's Stratum template builder. It is a separate path from BitcoinMiner() and had drifted from it. A single pass over mapTransactions is not enough. That map is keyed by hash and carries no dependency order, so a transaction spending another still-unconfirmed transaction's output -- change from a recent send, usually -- fails ConnectInputs whenever its parent sits later in map order. One pass drops it, and map order does not change between rebuilds, so every later rebuild drops it at the same point. That is deterministic exclusion rather than bad luck: where pool operators mine most blocks, a transaction of that shape can stay unconfirmed for as long as its parent is. Now it retries until a pass adds nothing, which is what the solo builder has always done. And the fees reached nobody. nFees was declared inside the scan loop, so each transaction reset it and the running total was discarded, while the coinbase line passed a hardcoded 0. An operator collected fees into a block and paid none of them out. The block stayed valid, since paying less than allowed always is, so nothing ever complained. Both the diagnosis and the fix are Vic-Nas's, from his fork of this code. He deleted the fork, so this brings the work back rather than letting it go with it. His description of the failure was precise enough that finding the same two defects here took one read of our RebuildJob. --- src/rpc.cpp | 61 ++++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 13 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 41c0328..d73b09c 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -167,29 +167,64 @@ static bool RebuildJob() CBlock block; block.vtx.push_back(txNew); + // Fill the block the same way BitcoinMiner() does, because this is the + // same job: a block that gets mined and submitted for real. + // + // It used to differ in two ways, and both were bugs. + // + // One pass over mapTransactions is not enough. That map is keyed by + // hash, so it has no dependency order, and a transaction spending the + // still-unconfirmed output of another (change from a recent send, most + // commonly) fails ConnectInputs whenever its parent happens to sit + // later in map order. A single pass then drops it -- and because map + // order does not change between rebuilds, every later rebuild drops it + // again at the same point. Deterministic exclusion, not bad luck: if + // pool operators mine most of the blocks, a transaction of that shape + // can stay unconfirmed for as long as its parent does. + // + // Diagnosis and fix both come from Vic-Nas, who found it in his fork + // of this code and described it exactly. The fork has since been + // deleted, so this carries it back rather than losing it. + int64 nFees = 0; { CTxDB txdb("r"); CRITICAL_BLOCK(cs_mapTransactions) { map pool; + vector vfAlreadyAdded(mapTransactions.size()); + bool fFoundSomething = true; unsigned int sz = 0; - for (auto& kv : mapTransactions) { - CTransaction& tx = kv.second; - if (tx.IsCoinBase() || !tx.IsFinal()) continue; - int64 nFees = 0; - map tmp(pool); - if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, - false, true, tx.GetMinFee(block.vtx.size() < 100))) - continue; - pool = tmp; - block.vtx.push_back(tx); - sz += ::GetSerializeSize(tx, SER_NETWORK); - if (sz > MAX_SIZE / 2) break; + while (fFoundSomething && sz < MAX_SIZE / 2) + { + fFoundSomething = false; + unsigned int n = 0; + for (map::iterator mi = mapTransactions.begin(); + mi != mapTransactions.end(); ++mi, ++n) + { + if (vfAlreadyAdded[n]) + continue; + CTransaction& tx = (*mi).second; + if (tx.IsCoinBase() || !tx.IsFinal()) continue; + map tmp(pool); + if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, + false, true, tx.GetMinFee(block.vtx.size() < 100))) + continue; + swap(pool, tmp); + block.vtx.push_back(tx); + sz += ::GetSerializeSize(tx, SER_NETWORK); + vfAlreadyAdded[n] = true; + fFoundSomething = true; + } } } } - block.vtx[0].vout[0].nValue = block.GetBlockValue(pindexPrev->nHeight + 1, 0); + // And the fees actually go to the coinbase now. nFees was declared + // inside the scan loop, so every transaction reset it and the total was + // thrown away, while this line passed a hardcoded 0. A pool operator + // collected fees into a block and paid none of them to itself. Solo + // mining has always passed nFees here; only this builder did not. + block.vtx[0].vout[0].nValue = block.GetBlockValue(pindexPrev->nHeight + 1, nFees); block.hashPrevBlock = pindexPrev->GetBlockHash(); block.hashMerkleRoot = block.BuildMerkleTree(); block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast() + 1), -- cgit v1.2.3 From 0b01d6cfce8be68150f5e06c11e13e0443372c4d Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:25:45 -0300 Subject: pool: make payout ledger crash-safe --- src/rpc.cpp | 247 +++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 196 insertions(+), 51 deletions(-) (limited to 'src/rpc.cpp') 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() // Payouts -- event loop thread only // --------------------------------------------------------------------------- struct PendingPayout { + std::string id; + int blockHeight; int matureAtHeight; std::map amounts; // address -> satoshis + std::map paidTxids; // address -> txid + int64 nextAttempt; + + PendingPayout() : blockHeight(0), matureAtHeight(0), nextAttempt(0) {} }; static std::vector gPendingPayouts; +static std::mutex gPayoutLedgerMutex; static std::string PayoutFilePath() { return GetAppDir() + "/pending_payouts.json"; } -static void SavePendingPayouts() +static std::string PayoutIdForBlock(int blockHeight) +{ + return strprintf("block-%d", blockHeight); +} + +static bool PayoutFullyPaid(const PendingPayout& pp) +{ + for (const auto& kv : pp.amounts) + if (pp.paidTxids.count(kv.first) == 0) + return false; + return true; +} + +static void SavePendingPayoutsLocked() { nlohmann::json arr = nlohmann::json::array(); for (const auto& pp : gPendingPayouts) { nlohmann::json obj; + obj["id"] = pp.id; + obj["blockHeight"] = pp.blockHeight; obj["matureAtHeight"] = pp.matureAtHeight; + obj["nextAttempt"] = pp.nextAttempt; nlohmann::json amounts = nlohmann::json::object(); for (const auto& kv : pp.amounts) amounts[kv.first] = kv.second; obj["amounts"] = amounts; + nlohmann::json paid = nlohmann::json::object(); + for (const auto& kv : pp.paidTxids) + paid[kv.first] = kv.second; + obj["paidTxids"] = paid; arr.push_back(obj); } std::string path = PayoutFilePath(); - FILE* f = fopen(path.c_str(), "w"); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); if (f) { std::string s = arr.dump(2); fwrite(s.c_str(), 1, s.size(), f); - fclose(f); + if (fclose(f) != 0) { + LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); } else { - LogPrint("payout", "[payout] WARNING: could not write %s\n", path.c_str()); + LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); } } @@ -297,11 +333,25 @@ static void LoadPendingPayouts() fclose(f); try { nlohmann::json arr = nlohmann::json::parse(s); + std::lock_guard lk(gPayoutLedgerMutex); for (const auto& obj : arr) { PendingPayout pp; + if (obj.contains("blockHeight")) + pp.blockHeight = obj["blockHeight"].get(); pp.matureAtHeight = obj["matureAtHeight"].get(); + if (obj.contains("id") && obj["id"].is_string()) + pp.id = obj["id"].get(); + if (pp.blockHeight == 0) + pp.blockHeight = pp.matureAtHeight - COINBASE_MATURITY; + if (pp.id.empty()) + pp.id = PayoutIdForBlock(pp.blockHeight); + if (obj.contains("nextAttempt")) + pp.nextAttempt = obj["nextAttempt"].get(); for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) pp.amounts[it.key()] = it.value().get(); + if (obj.contains("paidTxids") && obj["paidTxids"].is_object()) + for (auto it = obj["paidTxids"].begin(); it != obj["paidTxids"].end(); ++it) + pp.paidTxids[it.key()] = it.value().get(); gPendingPayouts.push_back(pp); } LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n", @@ -319,6 +369,35 @@ static std::vector gPayoutExecQueue; static std::mutex gPayoutExecMutex; static std::atomic gPayoutThreadRunning{false}; +static std::set gQueuedPayouts; + +static bool FindRecordedPoolPayout(const std::string& id, + const std::string& address, + int64 amount, + std::string& txidRet) +{ + CRITICAL_BLOCK(cs_mapWallet) + { + for (map::const_iterator it = mapWallet.begin(); + it != mapWallet.end(); ++it) + { + const CWalletTx& wtx = it->second; + map::const_iterator idIt = wtx.mapValue.find("pool_payout_id"); + if (idIt == wtx.mapValue.end() || idIt->second != id) + continue; + map::const_iterator addrIt = wtx.mapValue.find("pool_payout_address"); + if (addrIt == wtx.mapValue.end() || addrIt->second != address) + continue; + map::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount"); + if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount)) + continue; + txidRet = wtx.GetHash().GetHex(); + return true; + } + } + return false; +} + static void QueuePayouts(int blockHeight, const std::map& shareCount, uint64 total) @@ -341,6 +420,8 @@ static void QueuePayouts(int blockHeight, int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); PendingPayout pp; + pp.id = PayoutIdForBlock(blockHeight); + pp.blockHeight = blockHeight; pp.matureAtHeight = blockHeight + COINBASE_MATURITY; int skipped = 0; @@ -364,8 +445,11 @@ static void QueuePayouts(int blockHeight, pp.amounts[kv.first] = amount; } - gPendingPayouts.push_back(pp); - SavePendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(pp); + SavePendingPayoutsLocked(); + } int64 operatorCut = nSubsidy - minerReward; LogPrint("payout", @@ -383,42 +467,33 @@ static void QueuePayouts(int blockHeight, : ""); } -// Called from event loop: move matured entries to the exec queue (fast). -// NOT crash-safe, despite the ordering below looking like it is. -// -// A matured payout is erased from gPendingPayouts, the file is rewritten -// without it, and only then does it reach the exec queue -- which lives in -// memory alone. SavePendingPayouts writes gPendingPayouts and nothing else; -// there is no exec-queue section of the file to recover from. Die anywhere -// between the rewrite and SendMoney and those miners are simply never paid, -// with no record that they were owed anything. -// -// Making this safe needs the payment to be idempotent -- record the txid and -// check the wallet for it on restart -- rather than a reordering, since -// re-queueing blindly would double-pay whoever was already sent to. +// Called from event loop: copy matured entries to the exec queue (fast). +// The ledger entry stays on disk until every recipient has a txid recorded. +// On restart, FindRecordedPoolPayout() checks wallet metadata before sending, +// so a crash after SendMoney but before the ledger update does not double-pay. static void FlushMaturePayouts() { std::vector ready; - for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { - if (nBestHeight < it->matureAtHeight) { ++it; continue; } - LogPrint("payout", "[payout] block height %d matured -- queuing %zu payments\n", - it->matureAtHeight, it->amounts.size()); - ready.push_back(*it); // copy first; erase after save - ++it; - } - if (!ready.empty()) { - // Erase from pending then save before handing off to exec thread, - // so the file never contains an entry that is already being sent. - for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { - if (nBestHeight >= it->matureAtHeight) - it = gPendingPayouts.erase(it); - else - ++it; + { + std::lock_guard ledger(gPayoutLedgerMutex); + std::lock_guard queue(gPayoutExecMutex); + int64 now = GetTime(); + for (const PendingPayout& pp : gPendingPayouts) { + if (nBestHeight < pp.matureAtHeight) + continue; + if (pp.nextAttempt > now) + continue; + if (PayoutFullyPaid(pp)) + continue; + if (gQueuedPayouts.count(pp.id)) + continue; + LogPrint("payout", "[payout] block height %d matured -- queuing payout %s (%zu recipients)\n", + pp.matureAtHeight, pp.id.c_str(), pp.amounts.size()); + ready.push_back(pp); + gQueuedPayouts.insert(pp.id); } - SavePendingPayouts(); - std::lock_guard lk(gPayoutExecMutex); for (auto& pp : ready) - gPayoutExecQueue.push_back(std::move(pp)); + gPayoutExecQueue.push_back(pp); } } @@ -437,10 +512,21 @@ static void PayoutThreadFn(void*) batch.swap(gPayoutExecQueue); } - for (auto& pp : batch) { + for (auto& ppQueued : batch) { LogPrint("payout", "[payout] executing payments for block matured at %d" - " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); - for (auto& kv : pp.amounts) { + " (%zu miners)\n", ppQueued.matureAtHeight, ppQueued.amounts.size()); + bool fAbortBatch = false; + for (auto& kv : ppQueued.amounts) { + bool alreadyPaid = false; + { + std::lock_guard lk(gPayoutLedgerMutex); + for (const PendingPayout& pp : gPendingPayouts) + if (pp.id == ppQueued.id && pp.paidTxids.count(kv.first)) + alreadyPaid = true; + } + if (alreadyPaid) + continue; + uint160 h160; if (!AddressToHash160(kv.first, h160)) { // Should not happen -- bad addresses are filtered in QueuePayouts @@ -451,16 +537,70 @@ static void PayoutThreadFn(void*) CScript sc; sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; CWalletTx wtx; - wtx.mapValue["comment"] = strprintf("Pool payout height %d", - pp.matureAtHeight); - if (SendMoney(sc, kv.second, wtx)) + wtx.mapValue["comment"] = strprintf("Pool payout height %d", ppQueued.matureAtHeight); + wtx.mapValue["pool_payout_id"] = ppQueued.id; + wtx.mapValue["pool_payout_address"] = kv.first; + wtx.mapValue["pool_payout_amount"] = strprintf("%lld", kv.second); + + std::string txid; + if (FindRecordedPoolPayout(ppQueued.id, kv.first, kv.second, txid)) + { + LogPrint("payout", "[payout] recovered recorded payout %s to %s" + " from wallet tx %s\n", + FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str()); + } + else if (SendMoney(sc, kv.second, wtx)) + { + txid = wtx.GetHash().GetHex(); LogPrint("payout", "[payout] paid %s to %s\n", - FormatMoney(kv.second).c_str(), kv.first.c_str()); + FormatMoney(kv.second).c_str(), kv.first.c_str()); + } else + { LogPrint("payout", "[payout] FAILED to pay %s to %s" - " (insufficient funds or wallet locked?)\n", - FormatMoney(kv.second).c_str(), kv.first.c_str()); + " (insufficient funds or wallet locked?)\n", + FormatMoney(kv.second).c_str(), kv.first.c_str()); + std::lock_guard lk(gPayoutLedgerMutex); + for (PendingPayout& pp : gPendingPayouts) + if (pp.id == ppQueued.id) + pp.nextAttempt = GetTime() + 60; + SavePendingPayoutsLocked(); + fAbortBatch = true; + break; + } + + { + std::lock_guard lk(gPayoutLedgerMutex); + for (PendingPayout& pp : gPendingPayouts) { + if (pp.id != ppQueued.id) + continue; + pp.paidTxids[kv.first] = txid; + pp.nextAttempt = 0; + SavePendingPayoutsLocked(); + break; + } + } } + + { + std::lock_guard ledger(gPayoutLedgerMutex); + for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { + if (it->id == ppQueued.id && PayoutFullyPaid(*it)) { + LogPrint("payout", "[payout] payout %s complete -- removing from ledger\n", + it->id.c_str()); + it = gPendingPayouts.erase(it); + SavePendingPayoutsLocked(); + } else { + ++it; + } + } + } + { + std::lock_guard lk(gPayoutExecMutex); + gQueuedPayouts.erase(ppQueued.id); + } + if (fAbortBatch) + continue; } Sleep(500); // check for new work every 500ms @@ -1073,16 +1213,21 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) : a.lastSeen > b.lastSeen; }); - // gPendingPayouts is event-loop-only data (no lock needed here since - // RefreshStats is always called from the event loop thread). gPayoutViews.clear(); + std::lock_guard ledger(gPayoutLedgerMutex); for (const PendingPayout& pp : gPendingPayouts) { PendingPayoutView pv; pv.matureAtHeight = pp.matureAtHeight; - pv.recipients = (int)pp.amounts.size(); + pv.recipients = 0; pv.totalAmount = 0; - for (auto& kv : pp.amounts) pv.totalAmount += kv.second; - gPayoutViews.push_back(pv); + for (auto& kv : pp.amounts) { + if (pp.paidTxids.count(kv.first)) + continue; + pv.recipients++; + pv.totalAmount += kv.second; + } + if (pv.recipients > 0) + gPayoutViews.push_back(pv); } } -- cgit v1.2.3 From 8c2e375600c0eb9bedb8f85ee4fa860825f23521 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:40:46 -0300 Subject: test: cover pool Stratum share accounting --- src/Makefile | 1 + src/makefile.mingw | 1 + src/rpc.cpp | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/selftest.cpp | 6 +++- 4 files changed, 92 insertions(+), 1 deletion(-) (limited to 'src/rpc.cpp') diff --git a/src/Makefile b/src/Makefile index cc52fd6..bc15ef8 100644 --- a/src/Makefile +++ b/src/Makefile @@ -191,5 +191,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits + ./bitflash-node -selftest=pool-stratum .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/makefile.mingw b/src/makefile.mingw index e076c49..b946e50 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -116,3 +116,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits + ./bitflash.exe -selftest=pool-stratum diff --git a/src/rpc.cpp b/src/rpc.cpp index 8f3740d..383769a 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1180,6 +1180,91 @@ static bool HandleLine(Miner* m, const std::string& rawLine, return true; } +int RunPoolStratumSelfTest() +{ + int nFail = 0; + auto check = [&](bool ok, const char* msg) { + fprintf(stdout, " %s %s\n", ok ? "ok" : "FAIL", msg); + if (!ok) nFail++; + }; + + fprintf(stdout, "pool-stratum self-test\n"); + + StratumJob savedJob = gCurrentJob; + bool savedHaveJob = gHaveJob; + + CTransaction coinbase; + coinbase.vin.resize(1); + coinbase.vin[0].prevout.SetNull(); + coinbase.vin[0].scriptSig << 1 << (CBigNum)1; + coinbase.vout.resize(1); + coinbase.vout[0].nValue = 50 * COIN; + + CBlock block; + block.vtx.push_back(coinbase); + block.nVersion = 1; + block.nBits = 0x1f0fffff; + block.nTime = 1; + block.nNonce = 1; + block.hashMerkleRoot = block.BuildMerkleTree(); + + gCurrentJob.jobId = "selftest"; + gCurrentJob.block = block; + gCurrentJob.target = 0; // accepted share, not a block candidate + gCurrentJob.height = 1; + gHaveJob = true; + + Miner miner(INVALID_SOCKET); + miner.authorised = true; + miner.address = "selftest-address"; + miner.worker = "worker"; + miner.shareTarget = ~uint256(0); // every RandomX hash is a valid share + miner.difficulty = 0.001; + miner.vardiffWindowStart = GetTime(); + + std::map roundShareCount; + uint64 roundShareTotal = 0; + int blocksFound = 0; + + uint256 targetFromNotify; + CBlock minerBlock = BuildMinerBlock(gCurrentJob, miner.extranonce2); + json params = MakeNotifyParams(gCurrentJob, minerBlock, miner.shareTarget, true); + std::vector targetBytes = FromHex(params[3].get()); + if (targetBytes.size() == 32) + memcpy(&targetFromNotify, &targetBytes[0], 32); + check(targetFromNotify == miner.shareTarget, + "mining.notify exposes the same share target the pool validates"); + + json submit = { + {"id", 7}, + {"method", "mining.submit"}, + {"params", json::array({miner.address, "selftest", ToHex(&minerBlock.nNonce, 4)})} + }; + HandleLine(&miner, submit.dump(), roundShareCount, roundShareTotal, blocksFound); + + check(roundShareTotal == 1, "fake low-difficulty share increments the round total"); + check(roundShareCount[miner.address] == 1, "fake share is credited to the submitting address"); + check(miner.sessionShares == 1 && miner.roundShares == 1 && miner.totalShares == 1, + "miner share counters advance together"); + check(blocksFound == 0, "share below block target is not counted as a found block"); + + json stale = { + {"id", 8}, + {"method", "mining.submit"}, + {"params", json::array({miner.address, "stale", ToHex(&minerBlock.nNonce, 4)})} + }; + HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); + check(roundShareTotal == 1, "stale submit does not increment shares"); + + gCurrentJob = savedJob; + gHaveJob = savedHaveJob; + + fprintf(stdout, "%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + // --------------------------------------------------------------------------- // Stats refresh -- called every 2s from event loop // --------------------------------------------------------------------------- diff --git a/src/selftest.cpp b/src/selftest.cpp index ff0289b..fadc959 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -10,6 +10,8 @@ #include "selftest.h" #include "walletcmd.h" +extern int RunPoolStratumSelfTest(); + // Test results go to the terminal, not to debug.log. // // printf in this tree is OutputDebugStringF, which writes into the data @@ -1234,8 +1236,10 @@ int RunSelfTest(const std::string& name) return RunNetMessageSelfTest(); if (name == "consensus-limits") return RunConsensusLimitsSelfTest(); + if (name == "pool-stratum") + return RunPoolStratumSelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); return 1; } -- cgit v1.2.3 From 5c2e66b70ab3c15603a6e2d7a4ea9e02f3cd0604 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:30:49 -0300 Subject: pool: write dashboard status JSON --- README.md | 6 ++++ src/main.cpp | 1 + src/main.h | 1 + src/main_gui.cpp | 5 ++++ src/rpc.cpp | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+) (limited to 'src/rpc.cpp') diff --git a/README.md b/README.md index 6746e02..af56081 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,11 @@ Open **Options** from the menu bar. Under Mining Mode: Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. +Pool operators also write a local `pool_status.json` every ten seconds. By +default it is created in the data directory; use `-poolstatusfile=PATH` to write +it somewhere a dashboard or web sync can read. The file is informational only: +nodes still discover pools through Nostr and `.btf`, not through any web domain. + **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. SRBMiner and XMRig connect to operator pools through the `.btf` path: @@ -236,6 +241,7 @@ xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui # node only ./bitflash -nogui -gen # node + solo mining ./bitflash -nogui -gen -operator # pool operator +./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool ``` diff --git a/src/main.cpp b/src/main.cpp index 2610c45..6cb7353 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -104,6 +104,7 @@ bool fMineModeFromCommandLine = false; string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; +string strPoolStatusFile; double dPoolFeePercent = 0.0; static std::atomic gParticipantSharesSent{0}; diff --git a/src/main.h b/src/main.h index c458412..3866855 100644 --- a/src/main.h +++ b/src/main.h @@ -86,6 +86,7 @@ extern bool fMineModeFromCommandLine; extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL +extern string strPoolStatusFile; // operator: optional JSON status output path extern double dPoolFeePercent; // operator: announced fee percent extern bool fSoloMineTest; // /solomine: mine without requiring a peer (local test) // Threads to hash with; 0 = decide from the hardware. Set by /genproclimit. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 875988f..41661b9 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -123,6 +123,7 @@ static void PrintUsage() printf(" /poolname=NAME\n"); printf(" /poolfee=PCT\n"); printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); + printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); printf("\n"); printf(".btf and rendezvous:\n"); printf(" /connectbtf=PEER_BTF_ADDRESS\n"); @@ -239,6 +240,10 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!poolFee.empty()) dPoolFeePercent = atof(poolFee.c_str()); + string poolStatusFile = argval2(argc, argv, "/poolstatusfile", "-poolstatusfile"); + if (!poolStatusFile.empty()) + strPoolStatusFile = poolStatusFile; + string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); if (!btfConnect.empty()) strBtfConnect = btfConnect; diff --git a/src/rpc.cpp b/src/rpc.cpp index 383769a..1aa4f9b 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -623,6 +623,7 @@ static StatsSnapshot gStats; static std::vector gWorkerStats; static std::vector gPayoutViews; static std::mutex gStatsMutex; +static int64 gLastPoolStatusWrite = 0; void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate) { @@ -645,6 +646,82 @@ void GetPendingPayouts(std::vector& out) out = gPayoutViews; } +static std::string PoolStatusFilePath() +{ + if (!strPoolStatusFile.empty()) + return strPoolStatusFile; + return GetAppDir() + "/pool_status.json"; +} + +static void WritePoolStatusJsonLocked() +{ + nlohmann::json j; + j["schema"] = "bitflash-pool-status-1"; + j["network"] = "bitflash"; + j["updatedAt"] = GetTime(); + + j["operator"] = { + {"name", strPoolName}, + {"btfAddress", BtfLocalAddress()}, + {"feePercent", dPoolFeePercent}, + {"dashboardUrl", strPoolDashboardUrl} + }; + + j["node"] = { + {"height", nBestHeight}, + {"poolRunning", (bool)gPoolRunning}, + {"mode", nMineMode} + }; + + j["pool"] = { + {"authorizedMiners", gStats.authorizedMiners}, + {"blocksFoundSession", gStats.blocksFound}, + {"roundShares", gStats.roundShares}, + {"hashRate", gStats.totalHashRate} + }; + + j["workers"] = nlohmann::json::array(); + for (const PoolWorkerStatView& row : gWorkerStats) { + j["workers"].push_back({ + {"address", row.address}, + {"worker", row.worker}, + {"totalShares", row.totalShares}, + {"roundShares", row.roundShares}, + {"lastSeen", row.lastSeen}, + {"hashRate", row.hashRate} + }); + } + + j["pendingPayouts"] = nlohmann::json::array(); + for (const PendingPayoutView& row : gPayoutViews) { + j["pendingPayouts"].push_back({ + {"matureAtHeight", row.matureAtHeight}, + {"recipients", row.recipients}, + {"totalAmountSatoshis", row.totalAmount}, + {"totalAmountBtf", FormatMoney(row.totalAmount)} + }); + } + + std::string path = PoolStatusFilePath(); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("pool", "[pool] WARNING: could not write status file %s\n", tmp.c_str()); + return; + } + std::string s = j.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + if (fclose(f) != 0) { + LogPrint("pool", "[pool] WARNING: could not close status file %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("pool", "[pool] WARNING: could not replace status file %s\n", path.c_str()); +} + // --------------------------------------------------------------------------- // Miner state -- event loop thread only // --------------------------------------------------------------------------- @@ -1314,6 +1391,12 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) if (pv.recipients > 0) gPayoutViews.push_back(pv); } + + int64 now = GetTime(); + if (now - gLastPoolStatusWrite >= 10) { + WritePoolStatusJsonLocked(); + gLastPoolStatusWrite = now; + } } // --------------------------------------------------------------------------- -- cgit v1.2.3 From b1eb8b66b8d7b47c7fb2e85d5d4db86a11823366 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 17:14:36 -0300 Subject: pool: publish round proof ledger --- README.md | 14 +- docs/pool-directory.md | 12 +- scripts/build-pool-index.py | 18 ++- src/main.cpp | 1 + src/main.h | 1 + src/main_gui.cpp | 5 + src/readme.txt | 4 + src/rpc.cpp | 316 +++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 359 insertions(+), 12 deletions(-) (limited to 'src/rpc.cpp') diff --git a/README.md b/README.md index 7f4d9b0..ec8e9c4 100644 --- a/README.md +++ b/README.md @@ -219,10 +219,12 @@ Open **Options** from the menu bar. Under Mining Mode: Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. -Pool operators also write a local `pool_status.json` every ten seconds. By -default it is created in the data directory; use `-poolstatusfile=PATH` to write -it somewhere a dashboard or web sync can read. The file is informational only: -nodes still discover pools through Nostr and `.btf`, not through any web domain. +Pool operators also write a local `pool_status.json` every ten seconds and a +`pool_rounds.json` proof ledger whenever they find blocks or pay miners. By +default both are created in the data directory; use `-poolstatusfile=PATH` and +`-poolroundsfile=PATH` to write them somewhere a dashboard or web sync can read. +The files are informational only: nodes still discover pools through Nostr and +`.btf`, not through any web domain. **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. @@ -243,7 +245,9 @@ xmrig -a rx/0 -o 127.0.0.1:3333 -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui # node only ./bitflash -nogui -gen # node + solo mining ./bitflash -nogui -gen -operator # pool operator -./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json +./bitflash -nogui -gen -operator \ + -poolstatusfile=/var/www/pool_status.json \ + -poolroundsfile=/var/www/pool_rounds.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool ./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS # local bridge for XMRig/SRBMiner ``` 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 @@ Nodes discover pools through Nostr and `.btf`; the website only helps humans compare operators. -Operators can publish the `pool_status.json` file produced by: +Operators can publish the `pool_status.json` and `pool_rounds.json` files +produced by: ```bash -bitflash -nogui -gen -operator -poolstatusfile=/var/www/pool_status.json +bitflash -nogui -gen -operator \ + -poolstatusfile=/var/www/pool_status.json \ + -poolroundsfile=/var/www/pool_rounds.json ``` Build a static directory from one or more status files: @@ -23,6 +26,11 @@ The generated output contains: - `index.html`: human-readable ranking page. - `pools.json`: machine-readable API for `api.bitflash.network/pools.json`. +When a source is an HTTP(S) `pool_status.json`, the directory links to a +`pool_rounds.json` proof file next to it. That file records the block height, +block hash, coinbase txid, coinbase value, pool fee, total shares, per-address +share counts, computed payouts, and payout txids once paid. + Ranking order is intentionally simple: 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 import json import os import time +import urllib.parse import urllib.request @@ -23,6 +24,10 @@ def normalize_pool(source, obj, now): operator = obj.get("operator", {}) pool = obj.get("pool", {}) node = obj.get("node", {}) + rounds_file = operator.get("roundsFile") or "pool_rounds.json" + rounds_url = "" + if source.startswith("http://") or source.startswith("https://"): + rounds_url = urllib.parse.urljoin(source, rounds_file) updated = int(obj.get("updatedAt") or 0) age = max(0, now - updated) if updated else None online = bool(node.get("poolRunning")) and age is not None and age <= 120 @@ -39,6 +44,8 @@ def normalize_pool(source, obj, now): "blocksFoundSession": int(pool.get("blocksFoundSession") or 0), "roundShares": int(pool.get("roundShares") or 0), "hashRate": float(pool.get("hashRate") or 0.0), + "roundsUrl": rounds_url, + "recentRounds": obj.get("recentRounds", []), } @@ -56,6 +63,7 @@ def render_html(pools, generated_at): rows = [] for p in pools: dash = p["dashboardUrl"] + rounds = p.get("roundsUrl", "") # Only linkify http(s). The dashboard URL comes from an operator's # self-published status file, so a "javascript:" or "data:" scheme # would become a clickable script-injection on this public page. @@ -65,6 +73,12 @@ def render_html(pools, generated_at): html.escape(dash, quote=True)) else: dash_html = "-" + rounds_lower = rounds.lower() + if rounds_lower.startswith("http://") or rounds_lower.startswith("https://"): + rounds_html = 'proof'.format( + html.escape(rounds, quote=True)) + else: + rounds_html = "-" rows.append( "" "{status}" @@ -76,6 +90,7 @@ def render_html(pools, generated_at): "{height}" "{age}" "{dash}" + "{rounds}" "".format( status="online" if p["online"] else "stale", name=html.escape(p["name"]), @@ -87,6 +102,7 @@ def render_html(pools, generated_at): height=p["height"], age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-", dash=dash_html, + rounds=rounds_html, ) ) @@ -110,7 +126,7 @@ def render_html(pools, generated_at):

This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.

- + {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 string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; string strPoolStatusFile; +string strPoolRoundsFile; double dPoolFeePercent = 0.0; bool fStratumBridge = false; int nStratumBridgePort = 3333; diff --git a/src/main.h b/src/main.h index 2786a4a..eafbf16 100644 --- a/src/main.h +++ b/src/main.h @@ -87,6 +87,7 @@ extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern string strPoolStatusFile; // operator: optional JSON status output path +extern string strPoolRoundsFile; // operator: optional public round proof path extern double dPoolFeePercent; // operator: announced fee percent extern bool fStratumBridge; // participant helper: local Stratum bridge 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() printf(" /poolfee=PCT\n"); printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); + printf(" /poolroundsfile=PATH (write public pool round proofs JSON)\n"); printf("\n"); printf(".btf and rendezvous:\n"); printf(" /connectbtf=PEER_BTF_ADDRESS\n"); @@ -262,6 +263,10 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!poolStatusFile.empty()) strPoolStatusFile = poolStatusFile; + string poolRoundsFile = argval2(argc, argv, "/poolroundsfile", "-poolroundsfile"); + if (!poolRoundsFile.empty()) + strPoolRoundsFile = poolRoundsFile; + string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); if (!btfConnect.empty()) 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) /poolfee=PCT operator announcement fee percent (example: 0.75) /pooldashboard=URL operator announcement dashboard URL + /poolstatusfile=PATH + write pool status JSON for dashboards + /poolroundsfile=PATH + write public pool round proofs JSON /connectbtf=ADDR keep an outbound .btf connection to ADDR /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 { static std::vector gPendingPayouts; static std::mutex gPayoutLedgerMutex; +struct PoolRoundProof { + std::string id; + int blockHeight; + int matureAtHeight; + int64 createdAt; + std::string blockHash; + std::string coinbaseTxid; + std::string foundBy; + double feePercent; + uint64 totalShares; + int64 coinbaseValue; + int64 subsidy; + int64 minerReward; + int64 operatorReward; + int skippedDust; + int64 forfeitedBadAddress; + std::map shares; + std::map payouts; + std::map paidTxids; + + PoolRoundProof() + : blockHeight(0), matureAtHeight(0), createdAt(0), feePercent(0.0), + totalShares(0), coinbaseValue(0), subsidy(0), minerReward(0), operatorReward(0), + skippedDust(0), forfeitedBadAddress(0) {} +}; +static std::vector gPoolRounds; +static std::mutex gPoolRoundsMutex; +static const size_t MAX_POOL_ROUNDS = 200; + static std::string PayoutFilePath() { return GetAppDir() + "/pending_payouts.json"; } +static std::string PoolRoundsFilePath() +{ + if (!strPoolRoundsFile.empty()) + return strPoolRoundsFile; + return GetAppDir() + "/pool_rounds.json"; +} + static std::string PayoutIdForBlock(int blockHeight) { return strprintf("block-%d", blockHeight); @@ -362,6 +398,167 @@ static void LoadPendingPayouts() } } +static nlohmann::json PoolRoundToJson(const PoolRoundProof& r) +{ + nlohmann::json j; + j["id"] = r.id; + j["blockHeight"] = r.blockHeight; + j["matureAtHeight"] = r.matureAtHeight; + j["createdAt"] = r.createdAt; + j["blockHash"] = r.blockHash; + j["coinbaseTxid"] = r.coinbaseTxid; + j["foundBy"] = r.foundBy; + j["feePercent"] = r.feePercent; + j["totalShares"] = r.totalShares; + j["coinbaseValueSatoshis"] = r.coinbaseValue; + j["coinbaseValueBtf"] = FormatMoney(r.coinbaseValue); + j["subsidySatoshis"] = r.subsidy; + j["subsidyBtf"] = FormatMoney(r.subsidy); + j["minerRewardSatoshis"] = r.minerReward; + j["minerRewardBtf"] = FormatMoney(r.minerReward); + j["operatorRewardSatoshis"] = r.operatorReward; + j["operatorRewardBtf"] = FormatMoney(r.operatorReward); + j["skippedDust"] = r.skippedDust; + j["forfeitedBadAddressSatoshis"] = r.forfeitedBadAddress; + j["forfeitedBadAddressBtf"] = FormatMoney(r.forfeitedBadAddress); + + j["shares"] = nlohmann::json::object(); + for (const auto& kv : r.shares) + j["shares"][kv.first] = kv.second; + + j["payouts"] = nlohmann::json::object(); + for (const auto& kv : r.payouts) { + nlohmann::json p; + p["amountSatoshis"] = kv.second; + p["amountBtf"] = FormatMoney(kv.second); + std::map::const_iterator tx = r.paidTxids.find(kv.first); + if (tx != r.paidTxids.end()) + p["txid"] = tx->second; + else + p["txid"] = nullptr; + j["payouts"][kv.first] = p; + } + return j; +} + +static void SavePoolRoundsLocked() +{ + nlohmann::json root; + root["schema"] = "bitflash-pool-rounds-1"; + root["updatedAt"] = GetTime(); + root["rounds"] = nlohmann::json::array(); + for (const PoolRoundProof& r : gPoolRounds) + root["rounds"].push_back(PoolRoundToJson(r)); + + std::string path = PoolRoundsFilePath(); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); + return; + } + std::string s = root.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + if (fclose(f) != 0) { + LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); +} + +static void LoadPoolRounds() +{ + std::string path = PoolRoundsFilePath(); + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, f); + fclose(f); + + try { + nlohmann::json root = nlohmann::json::parse(s); + nlohmann::json arr = root.is_array() ? root : root["rounds"]; + std::lock_guard lk(gPoolRoundsMutex); + for (const auto& obj : arr) { + PoolRoundProof r; + if (obj.contains("id") && obj["id"].is_string()) r.id = obj["id"].get(); + if (obj.contains("blockHeight")) r.blockHeight = obj["blockHeight"].get(); + if (obj.contains("matureAtHeight")) r.matureAtHeight = obj["matureAtHeight"].get(); + if (obj.contains("createdAt")) r.createdAt = obj["createdAt"].get(); + if (obj.contains("blockHash") && obj["blockHash"].is_string()) r.blockHash = obj["blockHash"].get(); + if (obj.contains("coinbaseTxid") && obj["coinbaseTxid"].is_string()) r.coinbaseTxid = obj["coinbaseTxid"].get(); + if (obj.contains("foundBy") && obj["foundBy"].is_string()) r.foundBy = obj["foundBy"].get(); + if (obj.contains("feePercent")) r.feePercent = obj["feePercent"].get(); + if (obj.contains("totalShares")) r.totalShares = obj["totalShares"].get(); + if (obj.contains("coinbaseValueSatoshis")) r.coinbaseValue = obj["coinbaseValueSatoshis"].get(); + if (obj.contains("subsidySatoshis")) r.subsidy = obj["subsidySatoshis"].get(); + if (obj.contains("minerRewardSatoshis")) r.minerReward = obj["minerRewardSatoshis"].get(); + if (obj.contains("operatorRewardSatoshis")) r.operatorReward = obj["operatorRewardSatoshis"].get(); + if (obj.contains("skippedDust")) r.skippedDust = obj["skippedDust"].get(); + if (obj.contains("forfeitedBadAddressSatoshis")) r.forfeitedBadAddress = obj["forfeitedBadAddressSatoshis"].get(); + if (obj.contains("shares") && obj["shares"].is_object()) + for (auto it = obj["shares"].begin(); it != obj["shares"].end(); ++it) + r.shares[it.key()] = it.value().get(); + if (obj.contains("payouts") && obj["payouts"].is_object()) + for (auto it = obj["payouts"].begin(); it != obj["payouts"].end(); ++it) { + if (it.value().is_object()) { + r.payouts[it.key()] = it.value().value("amountSatoshis", (int64)0); + if (it.value().contains("txid") && it.value()["txid"].is_string()) + r.paidTxids[it.key()] = it.value()["txid"].get(); + } else if (it.value().is_number_integer()) { + r.payouts[it.key()] = it.value().get(); + } + } + if (!r.id.empty()) + gPoolRounds.push_back(r); + } + while (gPoolRounds.size() > MAX_POOL_ROUNDS) + gPoolRounds.erase(gPoolRounds.begin()); + LogPrint("payout", "[payout] loaded %zu pool round proof(s) from disk\n", + gPoolRounds.size()); + } catch (const std::exception& e) { + LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", + path.c_str(), e.what()); + } +} + +static void RecordPoolRound(const PoolRoundProof& round) +{ + std::lock_guard lk(gPoolRoundsMutex); + for (PoolRoundProof& existing : gPoolRounds) { + if (existing.id == round.id) { + existing = round; + SavePoolRoundsLocked(); + return; + } + } + gPoolRounds.push_back(round); + while (gPoolRounds.size() > MAX_POOL_ROUNDS) + gPoolRounds.erase(gPoolRounds.begin()); + SavePoolRoundsLocked(); +} + +static void MarkPoolRoundPaid(const std::string& id, + const std::string& address, + const std::string& txid) +{ + std::lock_guard lk(gPoolRoundsMutex); + for (PoolRoundProof& r : gPoolRounds) { + if (r.id != id) + continue; + r.paidTxids[address] = txid; + SavePoolRoundsLocked(); + return; + } +} + // Payouts ready to execute are moved here under gPayoutExecMutex. // A dedicated thread drains this list so SendMoney (which takes cs_main) // 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, } static void QueuePayouts(int blockHeight, + const std::string& blockHash, + const std::string& coinbaseTxid, + int64 coinbaseValue, + const std::string& foundBy, const std::map& shareCount, uint64 total) { + PoolRoundProof round; + round.id = PayoutIdForBlock(blockHeight); + round.blockHeight = blockHeight; + round.matureAtHeight = blockHeight + COINBASE_MATURITY; + round.createdAt = GetTime(); + round.blockHash = blockHash; + round.coinbaseTxid = coinbaseTxid; + round.coinbaseValue = coinbaseValue; + round.foundBy = foundBy; + round.feePercent = dPoolFeePercent; + round.totalShares = total; + round.shares = shareCount; + if (total == 0) { + RecordPoolRound(round); LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n", blockHeight); return; @@ -418,9 +633,13 @@ static void QueuePayouts(int blockHeight, int64 nSubsidy = 50 * COIN; nSubsidy >>= (blockHeight / 210000); // halving schedule int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); + int64 operatorCut = nSubsidy - minerReward; + round.subsidy = nSubsidy; + round.minerReward = minerReward; + round.operatorReward = operatorCut; PendingPayout pp; - pp.id = PayoutIdForBlock(blockHeight); + pp.id = round.id; pp.blockHeight = blockHeight; pp.matureAtHeight = blockHeight + COINBASE_MATURITY; @@ -443,15 +662,18 @@ static void QueuePayouts(int blockHeight, continue; } pp.amounts[kv.first] = amount; + round.payouts[kv.first] = amount; } + round.skippedDust = skipped; + round.forfeitedBadAddress = burned; { std::lock_guard lk(gPayoutLedgerMutex); gPendingPayouts.push_back(pp); SavePendingPayoutsLocked(); } + RecordPoolRound(round); - int64 operatorCut = nSubsidy - minerReward; LogPrint("payout", "[payout] block %d: subsidy %s BTF, miners get %s" " (fee %.2f%% = %s to operator)," @@ -548,12 +770,14 @@ static void PayoutThreadFn(void*) LogPrint("payout", "[payout] recovered recorded payout %s to %s" " from wallet tx %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str()); + MarkPoolRoundPaid(ppQueued.id, kv.first, txid); } else if (SendMoney(sc, kv.second, wtx)) { txid = wtx.GetHash().GetHex(); LogPrint("payout", "[payout] paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); + MarkPoolRoundPaid(ppQueued.id, kv.first, txid); } else { @@ -664,7 +888,8 @@ static void WritePoolStatusJsonLocked() {"name", strPoolName}, {"btfAddress", BtfLocalAddress()}, {"feePercent", dPoolFeePercent}, - {"dashboardUrl", strPoolDashboardUrl} + {"dashboardUrl", strPoolDashboardUrl}, + {"roundsFile", "pool_rounds.json"} }; j["node"] = { @@ -702,6 +927,30 @@ static void WritePoolStatusJsonLocked() }); } + j["recentRounds"] = nlohmann::json::array(); + { + std::lock_guard lk(gPoolRoundsMutex); + size_t start = gPoolRounds.size() > 10 ? gPoolRounds.size() - 10 : 0; + for (size_t i = start; i < gPoolRounds.size(); i++) { + const PoolRoundProof& r = gPoolRounds[i]; + int unpaid = 0; + for (const auto& kv : r.payouts) + if (r.paidTxids.count(kv.first) == 0) + unpaid++; + j["recentRounds"].push_back({ + {"id", r.id}, + {"blockHeight", r.blockHeight}, + {"blockHash", r.blockHash}, + {"coinbaseTxid", r.coinbaseTxid}, + {"totalShares", r.totalShares}, + {"payouts", (int)r.payouts.size()}, + {"unpaidPayouts", unpaid}, + {"coinbaseValueBtf", FormatMoney(r.coinbaseValue)}, + {"minerRewardBtf", FormatMoney(r.minerReward)} + }); + } + } + std::string path = PoolStatusFilePath(); std::string tmp = path + ".tmp"; FILE* f = fopen(tmp.c_str(), "w"); @@ -1231,7 +1480,9 @@ static bool HandleLine(Miner* m, const std::string& rawLine, if (accepted) { // Snapshot height before RebuildJob() (which increments it) int foundHeight = gCurrentJob.height; - QueuePayouts(foundHeight, roundShareCount, roundShareTotal); + QueuePayouts(foundHeight, b.GetHash().GetHex(), b.vtx[0].GetHash().GetHex(), + b.vtx[0].vout[0].nValue, m->address, + roundShareCount, roundShareTotal); // Reset round shares roundShareCount.clear(); roundShareTotal = 0; @@ -1333,6 +1584,62 @@ int RunPoolStratumSelfTest() HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); check(roundShareTotal == 1, "stale submit does not increment shares"); + std::vector savedRounds = gPoolRounds; + std::string savedRoundsFile = strPoolRoundsFile; + std::string proofPath = "pool_rounds_selftest.json"; + remove(proofPath.c_str()); + remove((proofPath + ".tmp").c_str()); + gPoolRounds.clear(); + strPoolRoundsFile = proofPath; + + PoolRoundProof proof; + proof.id = "selftest-round"; + proof.blockHeight = 42; + proof.matureAtHeight = 42 + COINBASE_MATURITY; + proof.createdAt = GetTime(); + proof.blockHash = block.GetHash().GetHex(); + proof.coinbaseTxid = coinbase.GetHash().GetHex(); + proof.foundBy = miner.address; + proof.feePercent = 1.25; + proof.totalShares = 3; + proof.coinbaseValue = 50 * COIN; + proof.subsidy = 50 * COIN; + proof.minerReward = 49 * COIN; + proof.operatorReward = 1 * COIN; + proof.shares[miner.address] = 3; + proof.payouts[miner.address] = 49 * COIN; + RecordPoolRound(proof); + MarkPoolRoundPaid(proof.id, miner.address, "selftest-txid"); + + bool proofOk = false; + FILE* pf = fopen(proofPath.c_str(), "r"); + if (pf) { + fseek(pf, 0, SEEK_END); + long sz = ftell(pf); + fseek(pf, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, pf); + fclose(pf); + try { + json root = json::parse(s); + proofOk = + root.value("schema", "") == "bitflash-pool-rounds-1" && + root["rounds"].is_array() && + root["rounds"].size() == 1 && + root["rounds"][0].value("id", "") == proof.id && + root["rounds"][0]["shares"].value(miner.address, (uint64)0) == 3 && + root["rounds"][0]["payouts"][miner.address].value("txid", "") == "selftest-txid"; + } catch (...) { + proofOk = false; + } + } + check(proofOk, "pool round proof ledger is written and paid txid is recorded"); + + remove(proofPath.c_str()); + remove((proofPath + ".tmp").c_str()); + strPoolRoundsFile = savedRoundsFile; + gPoolRounds = savedRounds; + gCurrentJob = savedJob; gHaveJob = savedHaveJob; @@ -1754,6 +2061,7 @@ void ThreadRPCServer(void*) } LoadPendingPayouts(); + LoadPoolRounds(); _beginthread(AcceptLoopFn, 0, NULL); _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); -- cgit v1.2.3 From 6e4865c14c858d00c6cfa6967e66a86b31cd12f8 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sat, 8 Aug 2026 02:29:26 +0000 Subject: pool: sync-gate job creation, retry failed payouts; widen nostr relay set - RebuildJob: no jobs with zero peers or a tip older than 30min; mining unsynced produced orphaned blocks and payouts that can never be paid (observed in production: 8 orphan 'found blocks') - payout exec: on SendMoney failure requeue with 60s backoff, up to 30 attempts, instead of silently forfeiting the payment - nostr: add oxtr.dev, nos.social, fmt.wiz.biz, current.fyi, nostr.bg (12 relays total) for announcement redundancy (cherry picked from commit f1986cd7e4a6f69469a2256eb96ff03f3f146792) --- src/btfrv.cpp | 41 ++++--- src/main.cpp | 318 +++++++++++++++++++++++++++++++++++++++++++++++-------- src/main.h | 3 + src/main_gui.cpp | 40 +++++++ src/net.cpp | 36 ++++++- src/nostr.cpp | 5 + src/rpc.cpp | 95 ++++++++++++++++- 7 files changed, 471 insertions(+), 67 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 6ac3280..905717f 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -94,31 +94,42 @@ static SOCKET ConnectTo(const char* host, unsigned short port) { struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; + hints.ai_family = AF_UNSPEC; // IPv4 or IPv6 (some relays are v6-only) hints.ai_socktype = SOCK_STREAM; char portstr[16]; sprintf(portstr, "%u", (unsigned)port); if (getaddrinfo(host, portstr, &hints, &res) != 0 || !res) return INVALID_SOCKET; - SOCKET s = BtfSocketTag(socket(res->ai_family, res->ai_socktype, res->ai_protocol), SOCK_RV_DIAL); - if (s == INVALID_SOCKET) { freeaddrinfo(res); return INVALID_SOCKET; } - // Apply a short timeout only for the connect() and initial handshake so an - // unresponsive relay doesn't block the thread. Once the tunnel is live the - // timeout is cleared -- a forwarding pipe must block indefinitely on recv(). + // Try each resolved address until one connects. + SOCKET s = INVALID_SOCKET; + for (struct addrinfo* rp = res; rp != NULL; rp = rp->ai_next) + { + s = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_RV_DIAL); + if (s == INVALID_SOCKET) + continue; + + // Apply a short timeout only for the connect() and initial handshake so an + // unresponsive relay doesn't block the thread. Once the tunnel is live the + // timeout is cleared -- a forwarding pipe must block indefinitely on recv(). #ifdef _WIN32 - DWORD tvShort = 10000; - setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tvShort, sizeof(tvShort)); - setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tvShort, sizeof(tvShort)); + DWORD tvShort = 10000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tvShort, sizeof(tvShort)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tvShort, sizeof(tvShort)); #else - struct timeval tvShort; tvShort.tv_sec = 10; tvShort.tv_usec = 0; - setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tvShort, sizeof(tvShort)); - setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tvShort, sizeof(tvShort)); + struct timeval tvShort; tvShort.tv_sec = 10; tvShort.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)&tvShort, sizeof(tvShort)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (char*)&tvShort, sizeof(tvShort)); #endif - if (connect(s, res->ai_addr, (int)res->ai_addrlen) != 0) - { - CLOSESOCK(s); freeaddrinfo(res); return INVALID_SOCKET; + if (connect(s, rp->ai_addr, (int)rp->ai_addrlen) != 0) + { + CLOSESOCK(s); + s = INVALID_SOCKET; + continue; + } + break; } freeaddrinfo(res); + if (s == INVALID_SOCKET) return INVALID_SOCKET; // Clear the timeout -- the socket is now a live tunnel and must not // time out when idle. recv() will block until data arrives or peer closes. diff --git a/src/main.cpp b/src/main.cpp index 0342650..32ab952 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -102,6 +102,9 @@ CAddress addrIncoming; int nMineMode = MINE_RELAY; bool fMineModeFromCommandLine = false; string strParticipantPool; // participant mode: pool .btf address +string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) +string strPayoutFile; // participant: /payoutfile with one address per line, rotated per reconnect +int nHashThreads = 0; // participant: /hashthreads override (0 = cores-1) string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; double dPoolFeePercent = 0.0; @@ -3488,6 +3491,102 @@ static bool StratumRecvLine(SOCKET s, std::string& buf, // How long to wait for a mining.notify after authorize before giving up static const int64 STUCK_NO_JOB_TIMEOUT_SECS = 120; +// ------------------------------------------------------------------ +// Multi-threaded pool worker. The original loop hashed a single nonce +// per blocking 50ms socket poll, capping one instance at ~20 H/s no +// matter how many cores the machine has. The socket and job bookkeeping +// stay on the calling thread; N hash threads grind nonce ranges on their +// own RandomX VMs (all sharing the one ~2 GB dataset) and drop found +// shares on a queue the I/O thread drains and submits. +// ------------------------------------------------------------------ +struct ParticipantJobShared +{ + std::mutex mtx; + unsigned char header[80]; + uint256 target; + std::string jobId; + uint64 jobSeq; + bool haveJob; + bool stop; + ParticipantJobShared() : jobSeq(0), haveJob(false), stop(false) + { + memset(header, 0, sizeof(header)); + } +}; + +struct ParticipantShareHit +{ + uint64 jobSeq; + std::string jobId; + unsigned int nonce; +}; + +static void ParticipantHashWorker(ParticipantJobShared* sh, int idx, int nWorkers, + std::vector* hits, + std::mutex* hitsMtx) +{ + void* vm = RandomXCreateMinerVM(); + if (!vm) + { + LogPrint("worker", "[worker] hash thread %d: RandomX VM creation failed\n", idx); + return; + } + + unsigned char header[80]; + uint256 target; + std::string jobId; + uint64 mySeq = 0; + bool active = false; + unsigned int nNonce = 0; + uint64 localHashes = 0; + + while (!fShutdown) + { + { + std::lock_guard lk(sh->mtx); + if (sh->stop) + break; + if (sh->haveJob && sh->jobSeq != mySeq) + { + memcpy(header, sh->header, 80); + target = sh->target; + jobId = sh->jobId; + mySeq = sh->jobSeq; + nNonce = (unsigned int)(idx + 1); + active = true; + } + } + if (!active) + { + Sleep(50); + continue; + } + + memcpy(header + 76, &nNonce, 4); + uint256 hash = RandomXHashWithVM(vm, header, 80); + localHashes++; + if ((localHashes & 0x3ff) == 0) + gParticipantHashes.fetch_add(0x400); + + if (hash <= target) + { + LogPrint("worker", "[worker] share found! nonce=%u job=%s (thread %d)\n", + nNonce, jobId.c_str(), idx); + std::lock_guard lk(*hitsMtx); + hits->push_back(ParticipantShareHit{mySeq, jobId, nNonce}); + } + + unsigned int next = nNonce + (unsigned int)nWorkers; + if (next < nNonce) // wrapped past 2^32: range exhausted, wait for next job + active = false; + else + nNonce = next; + } + + gParticipantHashes.fetch_add(localHashes & 0x3ff); + RandomXDestroyMinerVM(vm); +} + static bool PoolParticipantMiner() { // ------------------------------------------------------------------ @@ -3502,6 +3601,51 @@ static bool PoolParticipantMiner() LogPrint("worker", "[worker] connecting to pool %s\n", strParticipantPool.c_str()); SetParticipantMiningStatus("resolving pool address"); + // ------------------------------------------------------------------ + // Steps 1-2: connect to the pool. Two address forms: + // tcp:HOST:PORT -- plain TCP straight to the pool's stratum port + // (e.g. our own operator node on this machine), + // no .btf resolution or rendezvous tunnel involved + // .btf -- resolve via Nostr, dial through a rendezvous relay + // ------------------------------------------------------------------ + btf_socket_t s = INVALID_SOCKET; + if (strParticipantPool.compare(0, 4, "tcp:") == 0) + { + std::string hp = strParticipantPool.substr(4); + size_t colon = hp.rfind(':'); + if (colon == std::string::npos) { + LogPrint("worker", "[worker] bad tcp pool address '%s' (want tcp:host:port)\n", + strParticipantPool.c_str()); + SetParticipantMiningStatus("bad pool address"); + return false; + } + std::string host = hp.substr(0, colon); + int port = atoi(hp.substr(colon + 1).c_str()); + unsigned long ip = inet_addr(host.c_str()); + if (port <= 0 || port > 65535 || ip == INADDR_NONE) { + LogPrint("worker", "[worker] bad tcp pool endpoint '%s'\n", hp.c_str()); + SetParticipantMiningStatus("bad pool endpoint"); + return false; + } + SetParticipantMiningStatus("connecting to pool (direct tcp)"); + struct sockaddr_in sa; + memset(&sa, 0, sizeof(sa)); + sa.sin_family = AF_INET; + sa.sin_port = htons((unsigned short)port); + sa.sin_addr.s_addr = ip; + SOCKET cs = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (cs == INVALID_SOCKET || connect(cs, (struct sockaddr*)&sa, sizeof(sa)) == SOCKET_ERROR) { + LogPrint("worker", "[worker] direct connect to %s FAILED\n", hp.c_str()); + SetParticipantMiningStatus("pool connect failed"); + if (cs != INVALID_SOCKET) closesocket(cs); + return false; + } + LogPrint("worker", "[worker] connected directly to pool %s\n", hp.c_str()); + s = (btf_socket_t)cs; + goto connected; + } + + { unsigned char target_pubkey[32]; if (!btf::ParseAddress(strParticipantPool, target_pubkey)) { LogPrint("worker", "[worker] invalid .btf address: %s\n", strParticipantPool.c_str()); @@ -3536,11 +3680,11 @@ static bool PoolParticipantMiner() } // ------------------------------------------------------------------ - // Step 2: open encrypted tunnel to pool + // Step 2b: open encrypted tunnel to pool // ------------------------------------------------------------------ SetParticipantMiningStatus("opening tunnel"); - btf_socket_t s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, - target_pubkey, service_enc_pub); + s = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, + target_pubkey, service_enc_pub); if (s == INVALID_SOCKET) { LogPrint("worker", "[worker] tunnel connect FAILED via %s" " (relay unreachable or pool not listening)\n", @@ -3550,11 +3694,48 @@ static bool PoolParticipantMiner() } LogPrint("worker", "[worker] tunnel open via %s\n", meetingHostPort.c_str()); SetParticipantMiningStatus("tunnel open"); + } + +connected: // ------------------------------------------------------------------ - // Step 3: determine our payout address (mining username) + // Step 3: determine our payout address (mining username). /payoutaddr + // wins -- it lets a machine mine to an address whose keys live + // elsewhere, so no wallet file is needed on the mining machine. + // /payoutfile rotates through a list of such addresses: one is picked + // per (re)connection, and the session self-limits (see rotation break + // in the main loop) so consecutive rounds land on fresh addresses. // ------------------------------------------------------------------ - std::string username; + static std::vector vPayoutAddrs; + static size_t nPayoutRot = 0; + if (!strPayoutFile.empty() && vPayoutAddrs.empty()) + { + FILE* f = fopen(strPayoutFile.c_str(), "r"); + if (f) + { + char line[256]; + while (fgets(line, sizeof(line), f)) + { + std::string a = line; + while (!a.empty() && (a.back() == '\n' || a.back() == '\r' || + a.back() == ' ' || a.back() == '\t')) + a.pop_back(); + if (!a.empty()) + vPayoutAddrs.push_back(a); + } + fclose(f); + LogPrint("worker", "[worker] loaded %zu payout addresses from %s\n", + vPayoutAddrs.size(), strPayoutFile.c_str()); + } + else + LogPrint("worker", "[worker] cannot open payout file %s\n", + strPayoutFile.c_str()); + } + + std::string username = strPayoutAddress; + if (username.empty() && !vPayoutAddrs.empty()) + username = vPayoutAddrs[nPayoutRot++ % vPayoutAddrs.size()]; + if (username.empty()) { std::vector vchPubKey; if (CWalletDB("r").ReadDefaultKey(vchPubKey)) @@ -3595,13 +3776,7 @@ static bool PoolParticipantMiner() unsigned int nThreads = std::thread::hardware_concurrency(); RandomXInitDataset(nThreads > 0 ? (int)nThreads : 1); } - void* rxvm = RandomXCreateMinerVM(); - if (!rxvm) { - LogPrint("worker", "[worker] failed to create RandomX VM\n"); - SetParticipantMiningStatus("RandomX init failed"); - closesocket(s); return false; - } - LogPrint("worker", "[worker] RandomX VM ready (%s)\n", + LogPrint("worker", "[worker] RandomX dataset ready (%s)\n", RandomXFastReady() ? "fast 2GB" : "light 256MB"); // ------------------------------------------------------------------ @@ -3619,7 +3794,6 @@ static bool PoolParticipantMiner() {"params",json::array()}})) { LogPrint("worker", "[worker] subscribe send FAILED -- pool dropped connection\n"); SetParticipantMiningStatus("subscribe send failed"); - RandomXDestroyMinerVM(rxvm); closesocket(s); return false; } @@ -3629,10 +3803,29 @@ static bool PoolParticipantMiner() {"params",json::array({username,"x"})}})) { LogPrint("worker", "[worker] authorize send FAILED -- pool dropped connection\n"); SetParticipantMiningStatus("authorize send failed"); - RandomXDestroyMinerVM(rxvm); closesocket(s); return false; } + // ------------------------------------------------------------------ + // Step 6: spawn hash threads (each with its own RandomX VM on the + // shared dataset), then run the I/O loop below: receive pool messages, + // publish jobs to the threads, submit the shares they find. + // ------------------------------------------------------------------ + int nWorkers = nHashThreads > 0 ? nHashThreads + : (int)std::thread::hardware_concurrency(); + if (nWorkers < 2) nWorkers = 2; + if (nHashThreads <= 0) nWorkers -= 1; // leave one core for node + I/O threads + ParticipantJobShared jobShared; + std::vector shareHits; + std::mutex shareHitsMtx; + std::vector hashThreads; + for (int i = 0; i < nWorkers; i++) + hashThreads.emplace_back(ParticipantHashWorker, &jobShared, i, nWorkers, + &shareHits, &shareHitsMtx); + LogPrint("worker", "[worker] spawned %d hash threads on shared RandomX dataset\n", + nWorkers); + const uint64 sessionStartHashes = gParticipantHashes.load(); + // ------------------------------------------------------------------ // Step 6: main loop -- receive messages, hash, submit shares // ------------------------------------------------------------------ @@ -3650,6 +3843,8 @@ static bool PoolParticipantMiner() uint64 sessionSharesSent = 0; // this connection only uint64 sessionSharesAccepted = 0; int64 sampleStart = GetTime(); + int64 connStart = GetTime(); + const int64 PAYOUT_ROTATE_SECS = 300; // reconnect with next /payoutfile address SetParticipantMiningStatus("waiting for pool response"); @@ -3757,6 +3952,14 @@ static bool PoolParticipantMiner() memcpy(&target, tgtBytes.data(), 32); haveJob = true; nNonce = 1; + { + std::lock_guard lk(jobShared.mtx); + memcpy(jobShared.header, header, 80); + jobShared.target = target; + jobShared.jobId = jobId; + jobShared.jobSeq++; + jobShared.haveJob = true; + } LogPrint("worker", "[pool->worker] new job %s received\n", jobId.c_str()); SetParticipantMiningStatus("hashing job " + jobId); @@ -3812,46 +4015,71 @@ static bool PoolParticipantMiner() } } + // -- Payout address rotation: end this session cleanly so the outer + // loop reconnects and authorizes the next /payoutfile address -- + if (!vPayoutAddrs.empty() && GetTime() - connStart > PAYOUT_ROTATE_SECS) + { + LogPrint("worker", "[worker] payout rotation interval reached" + " -- reconnecting with next address\n"); + break; + } + if (!haveJob) { Sleep(100); continue; } - // -- Hash one nonce -- - memcpy(header+76, &nNonce, 4); - uint256 hash = RandomXHashWithVM(rxvm, header, 80); - hashesSinceSample++; - gParticipantHashes.fetch_add(1); - if ((hashesSinceSample & 0x3ff) == 0) - UpdateParticipantHashRate(hashesSinceSample, sampleStart); - - if (hash <= target) { - LogPrint("worker", "[worker] share found! nonce=%u job=%s difficulty=%.4f\n", - nNonce, jobId.c_str(), currentDifficulty); - SetParticipantMiningStatus("share found; submitting"); - int submitId = msgId++; - pendingSubmitIds.insert(submitId); - gParticipantSharesSent.fetch_add(1); - sessionSharesSent++; - json submit = {{"id",submitId},{"method","mining.submit"}, - {"params",json::array({username, jobId, ToHexStr(&nNonce,4)})}}; - if (!StratumSendLine(s, submit)) { - LogPrint("worker", "[worker] share submit send FAILED" - " -- pool connection lost\n"); - break; - } - } + // -- Hash rate accounting (hashing happens on the worker threads) -- + hashesSinceSample = gParticipantHashes.load() - sessionStartHashes; + UpdateParticipantHashRate(hashesSinceSample, sampleStart); - nNonce++; - if (nNonce == 0) { - LogPrint("worker", "[worker] nonce space exhausted for job %s" - " -- waiting for new job\n", jobId.c_str()); - haveJob = false; + // -- Submit any shares the hash threads found -- + bool submitFailed = false; + { + std::vector pending; + { + std::lock_guard lk(shareHitsMtx); + pending.swap(shareHits); + } + for (ParticipantShareHit& hit : pending) + { + if (hit.jobId != jobId) + { + LogPrint("worker", "[worker] dropping stale share for old job %s\n", + hit.jobId.c_str()); + continue; + } + LogPrint("worker", "[worker] share found! nonce=%u job=%s difficulty=%.4f\n", + hit.nonce, hit.jobId.c_str(), currentDifficulty); + SetParticipantMiningStatus("share found; submitting"); + int submitId = msgId++; + pendingSubmitIds.insert(submitId); + gParticipantSharesSent.fetch_add(1); + sessionSharesSent++; + json submit = {{"id",submitId},{"method","mining.submit"}, + {"params",json::array({username, hit.jobId, + ToHexStr(&hit.nonce,4)})}}; + if (!StratumSendLine(s, submit)) { + LogPrint("worker", "[worker] share submit send FAILED" + " -- pool connection lost\n"); + submitFailed = true; + break; + } + } } + if (submitFailed) + break; } // ------------------------------------------------------------------ - // Cleanup + // Cleanup: stop the hash threads, then drop the pool connection // ------------------------------------------------------------------ - RandomXDestroyMinerVM(rxvm); + { + std::lock_guard lk(jobShared.mtx); + jobShared.stop = true; + jobShared.haveJob = false; + } + for (std::thread& t : hashThreads) + t.join(); closesocket(s); + hashesSinceSample = gParticipantHashes.load() - sessionStartHashes; UpdateParticipantHashRate(hashesSinceSample, sampleStart); SetParticipantMiningStatus("disconnected"); LogPrint("worker", "[worker] disconnected from pool" diff --git a/src/main.h b/src/main.h index c458412..307cee5 100644 --- a/src/main.h +++ b/src/main.h @@ -84,6 +84,9 @@ extern int nMineMode; // something a previous session wrote. extern bool fMineModeFromCommandLine; extern string strParticipantPool; // participant: pool .btf address +extern string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) +extern string strPayoutFile; // participant: /payoutfile address list, rotated per reconnect +extern int nHashThreads; // participant: /hashthreads override (0 = cores-1) extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern double dPoolFeePercent; // operator: announced fee percent diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 875988f..0b4e091 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -115,6 +115,9 @@ static void PrintUsage() printf("Mining mode:\n"); printf(" /operator\n"); printf(" /participant=POOL_BTF_ADDRESS\n"); + printf(" /payoutaddr=BTF_ADDRESS (participant: pay shares to this address, no wallet needed)\n"); + printf(" /payoutfile=FILE (participant: rotate payout addresses from file)\n"); + printf(" /hashthreads=N (participant: mining threads, default cores-1)\n"); printf(" /solomine\n"); printf(" /genproclimit=N (mining threads; 0 or absent = every core but one)\n"); printf(" /checkblocks=N (blocks re-verified at startup, default 288, 0 = all)\n"); @@ -225,6 +228,18 @@ static void ParseStartupArguments(int argc, char* argv[]) fMineModeFromCommandLine = true; } + string payoutAddr = argval2(argc, argv, "/payoutaddr", "-payoutaddr"); + if (!payoutAddr.empty()) + strPayoutAddress = payoutAddr; + + string payoutFile = argval2(argc, argv, "/payoutfile", "-payoutfile"); + if (!payoutFile.empty()) + strPayoutFile = payoutFile; + + string hashThreads = argval2(argc, argv, "/hashthreads", "-hashthreads"); + if (!hashThreads.empty()) + nHashThreads = atoi(hashThreads.c_str()); + string poolName = argval2(argc, argv, "/poolname", "-poolname"); if (!poolName.empty()) strPoolName = poolName; @@ -610,6 +625,31 @@ int main(int argc, char* argv[]) ReacceptWalletTransactions(); + // /dumpaddresses=N:FILE -- generate N fresh wallet keys and write their + // addresses (one per line) to FILE, then exit. Keys stay in this wallet; + // the file alone is enough for a miner elsewhere to rotate payout + // addresses via /payoutfile. (No HD/xpub exists in this wallet format.) + string dumpSpec = argval2(argc, argv, "/dumpaddresses", "-dumpaddresses"); + if (!dumpSpec.empty()) + { + size_t colon = dumpSpec.rfind(':'); + if (colon == string::npos) { fprintf(stderr, "usage: /dumpaddresses=N:FILE\n"); return 1; } + int n = atoi(dumpSpec.substr(0, colon).c_str()); + string file = dumpSpec.substr(colon + 1); + FILE* f = fopen(file.c_str(), "w"); + if (!f) { fprintf(stderr, "cannot open %s\n", file.c_str()); return 1; } + for (int i = 0; i < n; i++) + { + CKey key; + key.MakeNewKey(); + if (!AddKey(key)) { fclose(f); fprintf(stderr, "AddKey failed at %d\n", i); return 1; } + fprintf(f, "%s\n", PubKeyToAddress(key.GetPubKey()).c_str()); + } + fclose(f); + printf("wrote %d fresh wallet addresses to %s\n", n, file.c_str()); + return 0; + } + if (!StartNode(strErrors)) { fprintf(stderr,"StartNode: %s\n",strErrors.c_str()); return 1; } if (nMineMode == MINE_OPERATOR) { diff --git a/src/net.cpp b/src/net.cpp index 449d91c..5cf2f8c 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -1054,6 +1054,18 @@ CNode* ConnectNodeBtfResolved(const string& strBtfAddr, const string& strMeeting void ThreadBtfAccept(void* parg) { printf("ThreadBtfAccept started\n"); + // In operator mode the pool server registers this same node identity as a + // stratum service at the rendezvous relays, and a relay only holds ONE + // service registration per pubkey -- peer-accept and pool-accept would + // kick each other off forever, breaking both. The pool's registration is + // the one that must be stable (that's what miners resolve and dial), so + // the node skips inbound rendezvous here and relies on outbound .btf + // connections for block sync. + if (nMineMode == MINE_OPERATOR) + { + printf("ThreadBtfAccept: operator mode -- skipping peer rendezvous registration\n"); + return; + } unsigned char pk[32]; unsigned char sk[32]; while (!BtfGetIdentity(pk, sk)) @@ -1928,11 +1940,29 @@ bool StartNode(string& strError) { int nErr = WSAGetLastError(); if (nErr == WSAEADDRINUSE) - strError = strprintf("Error: Unable to bind to port %s on this computer. The program is probably already running.", addrLocalHost.ToString().c_str()); + { + // Another local instance holds the default port. Fall back to an + // ephemeral port: inbound direct-IP peering is optional, and .btf + // rendezvous connectivity is unaffected by the local listen port. + sockaddr.sin_port = htons(0); + if (bind(hListenSocket, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) == SOCKET_ERROR) + { + strError = strprintf("Error: Unable to bind any port on this computer (bind returned error %d)", WSAGetLastError()); + printf("%s\n", strError.c_str()); + return false; + } + struct sockaddr_in bound; + socklen_t nBoundLen = sizeof(bound); + if (getsockname(hListenSocket, (struct sockaddr*)&bound, &nBoundLen) == 0) + addrLocalHost.port = bound.sin_port; + printf("default port in use by another instance; bound to ephemeral %s instead\n", addrLocalHost.ToString().c_str()); + } else + { strError = strprintf("Error: Unable to bind to port %s on this computer (bind returned error %d)", addrLocalHost.ToString().c_str(), nErr); - printf("%s\n", strError.c_str()); - return false; + printf("%s\n", strError.c_str()); + return false; + } } printf("bound to addrLocalHost = %s\n\n", addrLocalHost.ToString().c_str()); diff --git a/src/nostr.cpp b/src/nostr.cpp index 1fd60ad..21104bc 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -92,6 +92,11 @@ const char* pszNostrRelays[] = { "wss://offchain.pub", "wss://nostr.mom", "wss://relay.primal.net", + "wss://nostr.oxtr.dev", + "wss://relay.nos.social", + "wss://nostr.fmt.wiz.biz", + "wss://relay.current.fyi", + "wss://relay.nostr.bg", }; const int nNostrRelays = ARRAYLEN(pszNostrRelays); diff --git a/src/rpc.cpp b/src/rpc.cpp index 41c0328..98b6208 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -143,6 +143,20 @@ static bool RebuildJob() return false; } + // Don't issue jobs until the chain is synced: mining against a stale + // tip produces blocks the network will orphan, and payout entries + // that can never be paid (their coinbases never exist on the main + // chain). Require at least one peer and a fresh tip. + if (vNodes.empty()) { + LogPrint("pool", "RebuildJob: no peers yet -- not issuing jobs\n"); + return false; + } + if (GetAdjustedTime() - (int64)pindexBest->nTime > 30*60) { + LogPrint("pool", "RebuildJob: tip is %llds old -- still syncing, not issuing jobs\n", + (long long)(GetAdjustedTime() - (int64)pindexBest->nTime)); + return false; + } + CBlockIndex* pindexPrev = pindexBest; unsigned int nBits = GetNextWorkRequired(pindexPrev); @@ -218,6 +232,8 @@ static bool RebuildJob() struct PendingPayout { int matureAtHeight; std::map amounts; // address -> satoshis + int retries = 0; // exec-thread only: SendMoney attempts so far + int64 retryAfter = 0; // exec-thread only: unix time, 0 = ready now }; static std::vector gPendingPayouts; @@ -403,6 +419,11 @@ static void PayoutThreadFn(void*) } for (auto& pp : batch) { + if (pp.retryAfter > GetTime()) { + std::lock_guard lk(gPayoutExecMutex); + gPayoutExecQueue.push_back(pp); + continue; + } LogPrint("payout", "[payout] executing payments for block matured at %d" " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); for (auto& kv : pp.amounts) { @@ -418,13 +439,34 @@ static void PayoutThreadFn(void*) CWalletTx wtx; wtx.mapValue["comment"] = strprintf("Pool payout height %d", pp.matureAtHeight); - if (SendMoney(sc, kv.second, wtx)) + if (SendMoney(sc, kv.second, wtx)) { LogPrint("payout", "[payout] paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); - else + } else if (pp.retries < 30) { + // Transient failures (immature coinbase, reorg, locked + // wallet) must not silently burn the payment -- requeue + // with backoff. Permanent cases (orphaned coinbase) give + // up loudly after 30 tries (~30 min). + PendingPayout rp; + rp.matureAtHeight = pp.matureAtHeight; + rp.amounts[kv.first] = kv.second; + rp.retries = pp.retries + 1; + rp.retryAfter = GetTime() + 60; + { + std::lock_guard lk(gPayoutExecMutex); + gPayoutExecQueue.push_back(std::move(rp)); + } LogPrint("payout", "[payout] FAILED to pay %s to %s" - " (insufficient funds or wallet locked?)\n", - FormatMoney(kv.second).c_str(), kv.first.c_str()); + " -- retry %d/30 in 60s\n", + FormatMoney(kv.second).c_str(), kv.first.c_str(), + pp.retries + 1); + } else { + LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" + " retries (orphaned coinbase or persistent wallet" + " error?) -- payment forfeited\n", + FormatMoney(kv.second).c_str(), kv.first.c_str(), + pp.retries); + } } } @@ -543,6 +585,45 @@ static void PushReady(btf_socket_t s) gReadyQueue.push_back(s); } +// Loopback stratum listener: lets a miner running on the SAME machine reach +// this pool over plain TCP (127.0.0.1:8334), skipping the .btf rendezvous +// entirely. The rendezvous relay only allows one service registration per +// pubkey, so the pool service and the node's own peer-accept fight over the +// node identity at the relay -- loopback avoids that fight for local miners. +static void LocalAcceptFn(void*) +{ + SOCKET ls = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (ls == INVALID_SOCKET) + return; + int yes = 1; + setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(8334); + if (bind(ls, (struct sockaddr*)&addr, sizeof(addr)) != 0 || listen(ls, 16) != 0) + { + LogPrint("pool", "[pool] loopback stratum bind/listen failed on 127.0.0.1:8334\n"); + closesocket(ls); + return; + } + LogPrint("pool", "[pool] loopback stratum listening on 127.0.0.1:8334\n"); + while (!fShutdown && gPoolRunning) + { + SOCKET c = accept(ls, NULL, NULL); + if (c == INVALID_SOCKET) + { + if (fShutdown || !gPoolRunning) + break; + continue; + } + LogPrint("pool", "[pool] miner arrived via loopback -- pushing to event loop\n"); + PushReady((btf_socket_t)c); + } + closesocket(ls); +} + // --------------------------------------------------------------------------- // I/O helpers -- event loop thread only // --------------------------------------------------------------------------- @@ -1307,6 +1388,11 @@ static void AcceptOneFn(void* arg) LogPrint("pool", "[pool] accept thread started for relay %s\n", relay.c_str()); while (!fShutdown && gPoolRunning) { + // Advertise this relay as the pool's meeting node BEFORE registering: + // RvServiceRegister blocks until a miner pairs, and miners can only + // find us if the published descriptor already names a relay where the + // pool (not the node's peer-accept) holds the registration. + BtfSetActiveRelay(relay); // Blocks until a miner connects to us at this relay btf::RvSocket rv = btf::RvServiceRegister(host.c_str(), (unsigned short)port, pk); @@ -1408,6 +1494,7 @@ void ThreadRPCServer(void*) LoadPendingPayouts(); _beginthread(AcceptLoopFn, 0, NULL); + _beginthread(LocalAcceptFn, 0, NULL); _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); _beginthread(PayoutThreadFn, 0, NULL); -- cgit v1.2.3 From bef4f5b3a5a6f5a663b7ea7753dd57e368fd8929 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sat, 8 Aug 2026 05:01:35 +0000 Subject: payout: retry failed SendMoney for 24h, not 30min A pool that just found its first blocks has zero mature balance until 100 blocks later; orphaned coinbases (observed: reorgs orphaned our blocks 2651-2655) make that worse. 30 one-minute retries forfeited payments that would have succeeded hours later. (cherry picked from commit 49e80023ca23baee8a4b80cfb891e7a358dc39ba) --- src/rpc.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 98b6208..3dd05bb 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -442,11 +442,11 @@ static void PayoutThreadFn(void*) if (SendMoney(sc, kv.second, wtx)) { LogPrint("payout", "[payout] paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); - } else if (pp.retries < 30) { + } else if (pp.retries < 1440) { // Transient failures (immature coinbase, reorg, locked // wallet) must not silently burn the payment -- requeue // with backoff. Permanent cases (orphaned coinbase) give - // up loudly after 30 tries (~30 min). + // up loudly after 1440 tries (~24 h). PendingPayout rp; rp.matureAtHeight = pp.matureAtHeight; rp.amounts[kv.first] = kv.second; @@ -457,7 +457,7 @@ static void PayoutThreadFn(void*) gPayoutExecQueue.push_back(std::move(rp)); } LogPrint("payout", "[payout] FAILED to pay %s to %s" - " -- retry %d/30 in 60s\n", + " -- retry %d/1440 in 60s\n", FormatMoney(kv.second).c_str(), kv.first.c_str(), pp.retries + 1); } else { -- cgit v1.2.3 From 857f57e962b24f51621ddac188fa6e42d0ac6f41 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 9 Aug 2026 02:43:44 +0000 Subject: pool: wait to be paired before wrapping the rendezvous socket Upstream #30 split rendezvous registration from waiting for a dial, so RvServiceRegister now returns as soon as the relay lists the service and no longer consumes the relay's 0x01 pairing byte. It updated its own caller in net.cpp and left this one behind. AcceptOneFn went straight from RvServiceRegister to BtfServiceWrap, so BtfServiceWrap read that 0x01 as the first byte of the miner's 32-byte ephemeral key and every byte after it was shifted by one. The X25519 handshake has no confirmation step, so it completed against a garbage shared secret, the tunnel was handed to the Stratum event loop, and nothing failed loudly: the pool logged a miner arriving and then dropped it as "never authorized", while the miner logged a pool that closed on it with gotSubscribeAck=0. Loopback miners were unaffected, which is exactly what made it look like a relay problem. Block in RvServiceWaitPaired before wrapping, and close and re-register if the relay drops us first. test_rvaccept.cpp covers both shapes against a real bitflashd on loopback: the pre-fix order establishes a tunnel that carries no bytes, the fixed order round-trips. Also corrects the btftunnel.h contract comment, which still described the pre-#30 behaviour and is what the old code was written against -- upstream's own test_relayproc.cpp, test_btftunnel.cpp and test_btfrv.cpp are still on that stale contract and fail the same way. (cherry picked from commit 3e3a36b2bf1e6ffb4861507089b743688619377f) --- src/btftunnel.h | 13 ++-- src/rpc.cpp | 28 ++++++-- src/test_rvaccept.cpp | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 src/test_rvaccept.cpp (limited to 'src/rpc.cpp') diff --git a/src/btftunnel.h b/src/btftunnel.h index 1fa4f01..24a7509 100644 --- a/src/btftunnel.h +++ b/src/btftunnel.h @@ -10,8 +10,12 @@ // caller), then BtfClientTunnel connects through the relay, runs the echan // handshake, and returns a local SOCKET to hand a new outbound CNode. // -// Service side: after RvServiceRegister pairs us with a client, BtfServiceWrap -// completes the echan handshake and returns a local SOCKET for an inbound CNode. +// Service side: RvServiceRegister only registers -- callers must then block in +// RvServiceWaitPaired until a client is actually paired to them. BtfServiceWrap +// completes the echan handshake on that paired socket and returns a local +// SOCKET for an inbound CNode. Wrapping a registered-but-unpaired socket reads +// the relay's 0x01 pairing byte as the first byte of the client's ephemeral +// key, which silently corrupts the tunnel instead of failing. #ifndef BITFLASH_BTFTUNNEL_H #define BITFLASH_BTFTUNNEL_H @@ -39,8 +43,9 @@ btf_socket_t BtfClientTunnel(const char* meeting_host, unsigned short port, const unsigned char target_pubkey[32], const unsigned char service_enc_pub[32]); -// Service: wrap an already-paired rendezvous socket (from RvServiceRegister) -// with echan using my static x25519 secret, returning a local plaintext SOCKET +// Service: wrap an already-paired rendezvous socket (RvServiceRegister followed +// by RvServiceWaitPaired -- the pairing byte MUST already be consumed) with +// echan using my static x25519 secret, returning a local plaintext SOCKET // for an inbound CNode. Consumes `rv` (closed when the tunnel tears down). btf_socket_t BtfServiceWrap(RvSocket rv, const unsigned char my_enc_sk[32]); diff --git a/src/rpc.cpp b/src/rpc.cpp index 3dd05bb..21a8d77 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -6,8 +6,8 @@ // Architecture: // PoolEventLoop -- single thread, owns all miner fds, all job/share/payout // state. No locks needed on any of that data. -// AcceptOneFn -- one thread per relay. Blocks on RvServiceRegister + -// BtfServiceWrap, pushes finished socket to gReadyQueue. +// AcceptOneFn -- one thread per relay. Registers, waits to be paired, +// wraps the tunnel, pushes finished socket to gReadyQueue. // AcceptLoopFn -- spawns AcceptOneFn threads, re-polls relay list every // 60s so dynamically discovered relays get picked up. // @@ -1388,12 +1388,11 @@ static void AcceptOneFn(void* arg) LogPrint("pool", "[pool] accept thread started for relay %s\n", relay.c_str()); while (!fShutdown && gPoolRunning) { - // Advertise this relay as the pool's meeting node BEFORE registering: - // RvServiceRegister blocks until a miner pairs, and miners can only - // find us if the published descriptor already names a relay where the + // Advertise this relay as the pool's meeting node BEFORE registering, + // so that miners which resolve our descriptor find a relay where the // pool (not the node's peer-accept) holds the registration. BtfSetActiveRelay(relay); - // Blocks until a miner connects to us at this relay + // Returns as soon as the relay lists us -- registered, not yet paired. btf::RvSocket rv = btf::RvServiceRegister(host.c_str(), (unsigned short)port, pk); if (fShutdown || !gPoolRunning) { @@ -1407,6 +1406,23 @@ static void AcceptOneFn(void* arg) continue; } + // Block for a miner to actually arrive. RvServiceRegister used to do + // this itself and returned with the relay's 0x01 pairing byte already + // consumed; it no longer does. Without waiting here, BtfServiceWrap + // reads that 0x01 as the first byte of the miner's 32-byte ephemeral + // key -- every subsequent byte is shifted by one, so the X25519 + // handshake completes against a garbage shared secret (it has no + // confirmation step to catch it), the tunnel is handed to the event + // loop, and the miner's mining.subscribe never decrypts into a valid + // line. The miner sees the pool close on it; the pool sees a peer that + // never authorized. + if (!btf::RvServiceWaitPaired(rv)) { + btf::RvClose(rv); + LogPrint("pool", "[pool] relay %s: dropped us before any miner dialed" + " -- re-registering\n", relay.c_str()); + continue; + } + // Echan handshake -- blocking but fast (local + relay round-trip) btf_socket_t fd = btf::BtfServiceWrap(rv, sk); if (fd == INVALID_SOCKET) { diff --git a/src/test_rvaccept.cpp b/src/test_rvaccept.cpp new file mode 100644 index 0000000..afa9046 --- /dev/null +++ b/src/test_rvaccept.cpp @@ -0,0 +1,180 @@ +// Local regression test for the pool's rendezvous accept path. +// +// Reproduces the exact failure that took remote miners off the pool: since +// upstream #30, RvServiceRegister returns as soon as the relay lists the +// service and no longer consumes the relay's 0x01 pairing byte. A service that +// goes straight from RvServiceRegister to BtfServiceWrap therefore reads that +// 0x01 as the first byte of the client's 32-byte ephemeral key, shifting the +// whole stream by one. X25519 has no confirmation step, so the handshake +// "succeeds" against a garbage shared secret and the corruption only shows up +// as a tunnel that never carries a valid byte. +// +// Build: g++ -std=gnu++14 -O2 -pthread test_rvaccept.cpp btftunnel.cpp btfrv.cpp \ +// btfchan.cpp -lsodium -o test_rvaccept +// +// Build: g++ -std=gnu++14 -O2 -pthread test_rvaccept.cpp btftunnel.cpp \ +// btfrv.cpp btfchan.cpp -lsodium -o test_rvaccept +// +// Run against a bitflashd relay already listening on 127.0.0.1:. +// test_rvaccept broken -- the pre-fix pool code path, expected to FAIL +// test_rvaccept fixed -- with RvServiceWaitPaired, expected to PASS + +#include "btftunnel.h" +#include "btfrv.h" +#include "btfchan.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace btf; + +static const char* HOST = "127.0.0.1"; +static unsigned short PORT = 18446; +static const char* MSG = "mining.subscribe stand-in payload"; + +static void SetTimeout(int fd, int secs) +{ + struct timeval tv; + tv.tv_sec = secs; + tv.tv_usec = 0; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +} + +static bool RecvN(int fd, void* buf, int n) +{ + char* p = (char*)buf; + int off = 0; + while (off < n) { + int r = recv(fd, p + off, n - off, 0); + if (r <= 0) return false; + off += r; + } + return true; +} + +static bool SendN(int fd, const void* buf, int n) +{ + const char* p = (const char*)buf; + int off = 0; + while (off < n) { + int r = send(fd, p + off, n - off, 0); + if (r <= 0) return false; + off += r; + } + return true; +} + +static bool g_serviceEcho = false; +static std::string g_serviceSaw; + +// The pool's AcceptOneFn, in both shapes. +static void ServiceThread(bool fWaitPaired, + std::vector id, + std::vector enc_sk, + int len) +{ + RvSocket rv = RvServiceRegister(HOST, PORT, id.data()); + if (rv == RV_INVALID) { printf(" service: RvServiceRegister failed\n"); return; } + + if (fWaitPaired) { + if (!RvServiceWaitPaired(rv)) { + printf(" service: RvServiceWaitPaired failed\n"); + RvClose(rv); + return; + } + printf(" service: paired (0x01 consumed by RvServiceWaitPaired)\n"); + } else { + printf(" service: skipped RvServiceWaitPaired (pre-fix behaviour)\n"); + } + + btf_socket_t app = BtfServiceWrap(rv, enc_sk.data()); + if (app == INVALID_SOCKET) { printf(" service: BtfServiceWrap failed\n"); return; } + printf(" service: BtfServiceWrap returned a tunnel\n"); + + SetTimeout(app, 5); + std::vector buf(len); + if (!RecvN(app, buf.data(), len)) { + printf(" service: never received the client's plaintext\n"); + close(app); + return; + } + g_serviceSaw.assign(buf.data(), len); + g_serviceEcho = SendN(app, buf.data(), len); + close(app); +} + +int main(int argc, char* argv[]) +{ + bool fWaitPaired = (argc >= 2 && strcmp(argv[1], "fixed") == 0); + if (argc >= 3) PORT = (unsigned short)atoi(argv[2]); + + if (!TunnelInit()) { printf("TunnelInit failed\n"); return 2; } + + unsigned char enc_pk[32], enc_sk[32]; + ChanKeypair(enc_pk, enc_sk); + + std::vector id(32); + for (int i = 0; i < 32; i++) id[i] = (unsigned char)(i * 7 + 13); + + const std::string msg(MSG); + const int len = (int)msg.size(); + + printf(" mode: %s relay: %s:%u\n", fWaitPaired ? "fixed" : "broken", HOST, PORT); + + std::thread svc(ServiceThread, fWaitPaired, id, + std::vector(enc_sk, enc_sk + 32), len); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + int nFail = 0; + + btf_socket_t app = BtfClientTunnel(HOST, PORT, id.data(), enc_pk); + if (app == INVALID_SOCKET) { + printf(" client: BtfClientTunnel failed\n"); + nFail++; + } else { + printf(" client: tunnel established\n"); + SetTimeout(app, 5); + if (!SendN(app, msg.data(), len)) { printf(" client: send failed\n"); nFail++; } + std::vector echo(len); + if (!RecvN(app, echo.data(), len)) { + printf(" client: no echo came back\n"); + nFail++; + } else if (std::string(echo.data(), len) != msg) { + printf(" client: echo did not match what was sent\n"); + nFail++; + } else { + printf(" client: round-tripped the payload intact\n"); + } + close(app); + } + + svc.join(); + + if (g_serviceSaw != msg) { + printf(" service did not see the correct plaintext (saw %d bytes)\n", + (int)g_serviceSaw.size()); + nFail++; + } + if (!g_serviceEcho) { printf(" service could not echo\n"); nFail++; } + + bool fPassed = (nFail == 0); + printf(" RESULT: %s\n", fPassed ? "PASS -- tunnel carries plaintext end to end" + : "FAIL -- tunnel never carried a valid byte"); + + // A broken run is the expected outcome pre-fix, so exit 0 when the + // observed behaviour matches the expectation for the mode under test. + bool fAsExpected = (fWaitPaired ? fPassed : !fPassed); + printf(" EXPECTED %s -> %s\n", + fWaitPaired ? "PASS" : "FAIL", + fAsExpected ? "as expected" : "UNEXPECTED"); + return fAsExpected ? 0 : 1; +} -- cgit v1.2.3 From 5abe59dd09e7dbc5795f5f6d9ac2ef303d418134 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 9 Aug 2026 03:18:25 +0000 Subject: payout: say what actually failed instead of blaming transaction size SendMoney's "oversized transaction that requires a transaction fee of 0.00" is not an oversized transaction and there is no fee. That branch is taken when nValue + nFeeRequired > GetBalance() -- an empty spendable balance -- and the fee reads 0.00 because CreateTransaction failed before setting one. 3,267 of these in debug.log sent me looking for a transaction-size problem that does not exist. Report what is short, by how much, and against what balance. Name COINBASE_SPENDABLE_DEPTH in main.h and derive GetBlocksToMaturity from it, because the distinction it captures is genuinely surprising: CMerkleTx::GetCredit values a mined coinbase at zero until COINBASE_MATURITY+20 deep, not COINBASE_MATURITY. I first changed the payout gate to that deeper value, on the theory that each payout was firing 20 blocks before its own coinbase could be spent. Measuring it refuted that: across 934 payments, 96% succeeded at depth 100, the moment they were queued, which is only possible because a payout is funded from whatever coin is already mature rather than from its own block. The gate stays at COINBASE_MATURITY, with a comment recording the measurement so the next person does not re-make the same change. The real shortfall is structural and not addressed here: the pool pays out ~99% of each block's subsidy while only matured coinbases are spendable, so it runs with almost no buffer, and any orphaned block it queued a payout for drains that buffer permanently. The 24h retry loop absorbs this -- 0 payouts forfeited -- but the buffer is what needs fixing. (cherry picked from commit e28df01f90d621b0cd818f28d70a20b8ab5d1a8d) --- src/main.cpp | 15 +++++++++++++-- src/main.h | 6 ++++++ src/rpc.cpp | 5 +++++ 3 files changed, 24 insertions(+), 2 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/main.cpp b/src/main.cpp index f84f209..382a008 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1513,7 +1513,7 @@ int CMerkleTx::GetBlocksToMaturity() const { if (!IsCoinBase()) return 0; - return max(0, (COINBASE_MATURITY+20) - GetDepthInMainChain()); + return max(0, COINBASE_SPENDABLE_DEPTH - GetDepthInMainChain()); } @@ -4812,7 +4812,18 @@ bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew) { string strError; if (nValue + nFeeRequired > GetBalance()) - strError = strprintf("Error: This is an oversized transaction that requires a transaction fee of %s ", FormatMoney(nFeeRequired).c_str()); + // Not an oversized transaction -- this branch is simply "the + // spendable balance is too small". Saying otherwise (with a fee + // of 0.00, because CreateTransaction failed before setting one) + // sends anyone reading the log after a failed payout hunting a + // transaction-size problem that does not exist. + strError = strprintf("Error: insufficient spendable balance:" + " need %s (plus fee %s) but only %s is spendable" + " -- mined coin counts as zero until it is %d deep", + FormatMoney(nValue).c_str(), + FormatMoney(nFeeRequired).c_str(), + FormatMoney(GetBalance()).c_str(), + COINBASE_SPENDABLE_DEPTH); else strError = "Error: Transaction creation failed "; return error("SendMoney() : %s\n", strError.c_str()); diff --git a/src/main.h b/src/main.h index 22be3dc..6b1ff0a 100644 --- a/src/main.h +++ b/src/main.h @@ -41,6 +41,12 @@ static const int64 MAX_MONEY = 21000000 * COIN; inline bool MoneyRange(int64 nValue) { return (nValue >= 0 && nValue <= MAX_MONEY); } static const int64 CENT = 1000000; static const int COINBASE_MATURITY = 100; +// Depth at which a coinbase we mined is actually usable. CMerkleTx::GetCredit +// values a coinbase at zero until GetBlocksToMaturity() reaches 0, which is +// this depth -- not COINBASE_MATURITY. Anything that plans to *spend* mined +// coin must gate on this, or it will try to pay out of a balance the wallet +// still reports as zero. Both rules live here so they cannot drift apart. +static const int COINBASE_SPENDABLE_DEPTH = COINBASE_MATURITY + 20; // Bitflash difficulty floor. The PoW is RandomX (memory-hard), ~1000x slower // per hash than SHA-256d, so the target is much easier than Bitcoin's to let a diff --git a/src/rpc.cpp b/src/rpc.cpp index 21a8d77..24b1c88 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -322,6 +322,11 @@ static void QueuePayouts(int blockHeight, int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); PendingPayout pp; + // Deliberately COINBASE_MATURITY, not COINBASE_SPENDABLE_DEPTH: a payout is + // funded from whatever coin is already mature, not from its own block's + // coinbase, so waiting the extra 20 blocks delays every payment without + // making one more likely to succeed. Measured over 934 payments: 96% went + // through at depth 100, the moment they were queued. pp.matureAtHeight = blockHeight + COINBASE_MATURITY; int skipped = 0; -- cgit v1.2.3 From 05e871b3053b0e697820b295d59ab54c7423a7e5 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 9 Aug 2026 05:56:22 +0000 Subject: 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) --- src/rpc.cpp | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 126 insertions(+), 12 deletions(-) (limited to 'src/rpc.cpp') 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() // Payouts -- event loop thread only // --------------------------------------------------------------------------- struct PendingPayout { + // The block that funds this payout, identified by hash and not by height + // alone. A reorg replaces the block at a height without changing the + // height, so a height-keyed obligation outlives the coinbase that was + // supposed to pay it: a 280-block reorg once left ~14.4k BTF of debt + // owed against blocks that no longer existed, which then drained the + // wallet paying fork-era miners out of real-chain coin. + int blockHeight = 0; + uint256 blockHash; // zero = entry predates hash keying int matureAtHeight; std::map amounts; // address -> satoshis int retries = 0; // exec-thread only: SendMoney attempts so far + int deferrals = 0; // exec-thread only: waits for spendable coin int64 retryAfter = 0; // exec-thread only: unix time, 0 = ready now }; static std::vector gPendingPayouts; @@ -247,6 +256,8 @@ static void SavePendingPayouts() nlohmann::json arr = nlohmann::json::array(); for (const auto& pp : gPendingPayouts) { nlohmann::json obj; + obj["blockHeight"] = pp.blockHeight; + obj["blockHash"] = pp.blockHash.ToString(); obj["matureAtHeight"] = pp.matureAtHeight; nlohmann::json amounts = nlohmann::json::object(); for (const auto& kv : pp.amounts) @@ -281,6 +292,13 @@ static void LoadPendingPayouts() for (const auto& obj : arr) { PendingPayout pp; pp.matureAtHeight = obj["matureAtHeight"].get(); + // Entries written before payouts were keyed by hash carry neither + // field. Leave blockHash zero and they are grandfathered past the + // orphan check rather than voided on a guess. + if (obj.contains("blockHash") && obj["blockHash"].is_string()) + pp.blockHash.SetHex(obj["blockHash"].get()); + if (obj.contains("blockHeight") && obj["blockHeight"].is_number()) + pp.blockHeight = obj["blockHeight"].get(); for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) pp.amounts[it.key()] = it.value().get(); gPendingPayouts.push_back(pp); @@ -300,7 +318,27 @@ static std::vector gPayoutExecQueue; static std::mutex gPayoutExecMutex; static std::atomic gPayoutThreadRunning{false}; +// Is the block that funds this payout still on the main chain? +// +// A zero hash means the entry was written before payouts recorded one, so +// there is nothing to check and we assume it is good -- voiding those would +// mean refusing to pay miners on the strength of a missing field. +static bool PayoutBlockStillOnChain(const PendingPayout& pp) +{ + if (pp.blockHash == 0) + return true; + CRITICAL_BLOCK(cs_main) + { + std::map::iterator mi = mapBlockIndex.find(pp.blockHash); + if (mi == mapBlockIndex.end() || mi->second == NULL) + return false; + return mi->second->IsInMainChain(); + } + return false; +} + static void QueuePayouts(int blockHeight, + const uint256& blockHash, const std::map& shareCount, uint64 total) { @@ -322,12 +360,19 @@ static void QueuePayouts(int blockHeight, int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); PendingPayout pp; - // Deliberately COINBASE_MATURITY, not COINBASE_SPENDABLE_DEPTH: a payout is - // funded from whatever coin is already mature, not from its own block's - // coinbase, so waiting the extra 20 blocks delays every payment without - // making one more likely to succeed. Measured over 934 payments: 96% went - // through at depth 100, the moment they were queued. - pp.matureAtHeight = blockHeight + COINBASE_MATURITY; + pp.blockHeight = blockHeight; + pp.blockHash = blockHash; + // COINBASE_SPENDABLE_DEPTH, not COINBASE_MATURITY. Paying at depth 100 + // means the block's own coinbase is still 20 blocks short of spendable, so + // the payment has to come out of some *other* mature coin -- the pool runs + // a permanent ~19-block float and every payout fails until unrelated coin + // matures. The earlier "96% of 934 payments went through at depth 100" + // measurement that justified the shorter wait was taken while this node was + // alone on a private fork paying itself, so it says nothing about a pool + // competing on the real chain. Waiting the extra 20 blocks makes each + // payout funded by construction, and puts the maturity gate past the + // reorg depth that orphaned 280 blocks of obligations. + pp.matureAtHeight = blockHeight + COINBASE_SPENDABLE_DEPTH; int skipped = 0; int64 burned = 0; // amount owed to miners with bad/empty addresses @@ -384,6 +429,23 @@ static void QueuePayouts(int blockHeight, // re-queueing blindly would double-pay whoever was already sent to. static void FlushMaturePayouts() { + // Void obligations whose funding block lost a reorg. The coin that was + // meant to pay them does not exist, so paying anyway spends real-chain + // coin the pool owes to somebody else. + bool voided = false; + for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { + if (PayoutBlockStillOnChain(*it)) { ++it; continue; } + int64 total = 0; + for (auto& kv : it->amounts) total += kv.second; + LogPrint("payout", "[payout] block %d (%s) was orphaned -- voiding %zu" + " payment(s) totalling %s\n", + it->blockHeight, it->blockHash.ToString().substr(0, 14).c_str(), + it->amounts.size(), FormatMoney(total).c_str()); + it = gPendingPayouts.erase(it); + voided = true; + } + if (voided) SavePendingPayouts(); + std::vector ready; for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { if (nBestHeight < it->matureAtHeight) { ++it; continue; } @@ -429,6 +491,18 @@ static void PayoutThreadFn(void*) gPayoutExecQueue.push_back(pp); continue; } + // Re-check here and not only at maturity: an entry can sit in this + // queue for hours across retries, and the reorg that kills its + // funding block can land at any point in that window. + if (!PayoutBlockStillOnChain(pp)) { + int64 total = 0; + for (auto& kv : pp.amounts) total += kv.second; + LogPrint("payout", "[payout] block %d (%s) orphaned while queued" + " -- dropping %zu payment(s) totalling %s\n", + pp.blockHeight, pp.blockHash.ToString().substr(0, 14).c_str(), + pp.amounts.size(), FormatMoney(total).c_str()); + continue; + } LogPrint("payout", "[payout] executing payments for block matured at %d" " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); for (auto& kv : pp.amounts) { @@ -443,7 +517,35 @@ static void PayoutThreadFn(void*) sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; CWalletTx wtx; wtx.mapValue["comment"] = strprintf("Pool payout height %d", - pp.matureAtHeight); + pp.blockHeight); + + // Waiting for coin to mature is not a failure, so it must not + // consume the retry budget. Burning a retry every 60s means a + // payment the pool fully intends to honour can hit GIVING UP + // 24h later purely because the subsidy funding it had not + // matured yet -- the miner is then silently never paid. + int64 nSpendable = GetBalance(); + if (nSpendable < kv.second) { + PendingPayout dp; + dp.blockHeight = pp.blockHeight; + dp.blockHash = pp.blockHash; + dp.matureAtHeight = pp.matureAtHeight; + dp.amounts[kv.first] = kv.second; + dp.retries = pp.retries; // unchanged on purpose + dp.deferrals = pp.deferrals + 1; + dp.retryAfter = GetTime() + 60; + // Hourly, not every minute: a dry pool would otherwise + // write one line per payment per minute forever. + if (dp.deferrals == 1 || dp.deferrals % 60 == 0) + LogPrint("payout", "[payout] deferring %s to %s -- only %s" + " spendable (waiting %d min so far)\n", + FormatMoney(kv.second).c_str(), kv.first.c_str(), + FormatMoney(nSpendable).c_str(), dp.deferrals); + std::lock_guard lk(gPayoutExecMutex); + gPayoutExecQueue.push_back(std::move(dp)); + continue; + } + if (SendMoney(sc, kv.second, wtx)) { LogPrint("payout", "[payout] paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); @@ -453,24 +555,34 @@ static void PayoutThreadFn(void*) // with backoff. Permanent cases (orphaned coinbase) give // up loudly after 1440 tries (~24 h). PendingPayout rp; + rp.blockHeight = pp.blockHeight; + rp.blockHash = pp.blockHash; rp.matureAtHeight = pp.matureAtHeight; rp.amounts[kv.first] = kv.second; rp.retries = pp.retries + 1; + rp.deferrals = pp.deferrals; rp.retryAfter = GetTime() + 60; { std::lock_guard lk(gPayoutExecMutex); gPayoutExecQueue.push_back(std::move(rp)); } LogPrint("payout", "[payout] FAILED to pay %s to %s" - " -- retry %d/1440 in 60s\n", + " (%s spendable at the time) -- retry %d/1440 in 60s\n", FormatMoney(kv.second).c_str(), kv.first.c_str(), + FormatMoney(nSpendable).c_str(), pp.retries + 1); } else { + // Reaching here now means 1440 attempts that were NOT + // insufficient-funds waits (those defer instead) against a + // block still on the main chain -- i.e. a real wallet fault, + // not a maturity or reorg question. LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" - " retries (orphaned coinbase or persistent wallet" - " error?) -- payment forfeited\n", + " retries -- block %d is still on-chain and %s was" + " spendable, so this is a wallet fault, not maturity" + " -- payment forfeited\n", FormatMoney(kv.second).c_str(), kv.first.c_str(), - pp.retries); + pp.retries, pp.blockHeight, + FormatMoney(nSpendable).c_str()); } } } @@ -1065,7 +1177,9 @@ static bool HandleLine(Miner* m, const std::string& rawLine, if (accepted) { // Snapshot height before RebuildJob() (which increments it) int foundHeight = gCurrentJob.height; - QueuePayouts(foundHeight, roundShareCount, roundShareTotal); + // b is the block we submitted, untouched by ProcessBlock (which + // took a copy), so its hash is what the payout must be keyed on. + QueuePayouts(foundHeight, b.GetHash(), roundShareCount, roundShareTotal); // Reset round shares roundShareCount.clear(); roundShareTotal = 0; -- cgit v1.2.3 From ea520850023baf5ce7b9afb67ece574a6be64498 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 9 Aug 2026 12:41:44 +0000 Subject: payout: zero-initialise blockHash; nostr: drop four dead relays Two fixes. uint256's default constructor is empty (uint256.h:514), so the blockHash field added in 6963862 was uninitialised garbage on every PendingPayout built without an explicit hash -- which is every entry loaded from a ledger written before that commit. Those entries were meant to be grandfathered past the orphan check on a zero hash; instead each carried a random value that matched no block in mapBlockIndex, and the first maturity sweep after deploy voided the whole loaded ledger: 100 entries, 4950 BTF. Only one "was orphaned -- voiding" line reached the log because LogPrint collapsed the other 99 as duplicates, so the destruction was nearly silent. Nothing had been paid at that point and the file was restored from a pre-deploy backup, so no obligation was lost. Assigning 0 explicitly is all that was missing. Separately, four of the twelve relays are dead. Probed all twelve from two independent networks (a residential line and the pool's VPS) over v4 and v6, with DNS confirmed against 1.1.1.1: relay.current.fyi, relay.nostr.bg -- NXDOMAIN, no A and no AAAA. The domains are gone. (A getaddrinfo call on the VPS reports an address for them, but that is the VPS's own IPv6 handed back by its local resolver, not a real host.) relay.nostr.band, nostr.fmt.wiz.biz -- DNS resolves and TCP connects, but the TLS handshake never completes, from either network. Whether they are down or refusing non-browser clients, this node cannot use them. The remaining eight all complete a WebSocket upgrade. A dead relay is not free: each holds a connect/retry slot and delays discovery for no added redundancy. (cherry picked from commit 144bc96f150d23507d57d29cb339f177dcd6b092) --- src/nostr.cpp | 16 ++++++++++++---- src/rpc.cpp | 6 +++++- 2 files changed, 17 insertions(+), 5 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/nostr.cpp b/src/nostr.cpp index fbae1a6..b3e4a7c 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -78,9 +78,20 @@ static CCriticalSection cs_btfPeers; // independent relays. Kept only one Cloudflare-fronted relay (damus.io, the // most established) and filled the rest with relays on different hosts // (Hetzner, various independents) so no single upstream provider is a -// single point of failure for peer discovery. 7 relays lands inside the +// single point of failure for peer discovery. 8 relays lands inside the // commonly recommended 5-10 range for redundancy without keeping an // excessive number of persistent sockets open per node. +// +// Four entries were removed after probing all twelve from two independent +// networks (a residential line and the pool's VPS, over both v4 and v6): +// relay.current.fyi, relay.nostr.bg -- no A record, and the AAAA for both +// lands on the same parked host (2a04:c300:1000::381) serving a +// certificate that fails verification. The domains are gone. +// relay.nostr.band, nostr.fmt.wiz.biz -- TCP connects, TLS handshake never +// completes, from both networks. Whether they are down or refusing +// non-browser clients, this node cannot use them. +// A dead relay is not free: each one holds a connect/retry slot and delays +// discovery, so carrying it costs latency for no redundancy. const char* pszNostrRelays[] = { "wss://relay.damus.io", "wss://nos.lol", @@ -95,9 +106,6 @@ const char* pszNostrRelays[] = { "wss://relay.primal.net", "wss://nostr.oxtr.dev", "wss://relay.nos.social", - "wss://nostr.fmt.wiz.biz", - "wss://relay.current.fyi", - "wss://relay.nostr.bg", }; const int nNostrRelays = ARRAYLEN(pszNostrRelays); diff --git a/src/rpc.cpp b/src/rpc.cpp index b12dc2f..11020d8 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -237,7 +237,11 @@ struct PendingPayout { // owed against blocks that no longer existed, which then drained the // wallet paying fork-era miners out of real-chain coin. int blockHeight = 0; - uint256 blockHash; // zero = entry predates hash keying + // Must be explicitly zeroed: uint256's default constructor is empty + // (uint256.h:514), so leaving this unassigned gives every ledger entry + // loaded from an older file a garbage hash, which then matches no block + // and gets voided as "orphaned". That mistake voided a live payout. + uint256 blockHash = 0; // zero = entry predates hash keying int matureAtHeight; std::map amounts; // address -> satoshis int retries = 0; // exec-thread only: SendMoney attempts so far -- cgit v1.2.3 From 78bdfe962a00edc1cdf69c4fb565e7f273f337a3 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 9 Aug 2026 13:09:01 +0000 Subject: payout: make in-flight payments survive a restart The exec queue was memory-only, and FlushMaturePayouts erased a matured entry from pending_payouts.json *before* handing it over. Any restart in that window lost those payments with no record that anyone was owed anything. It was not hypothetical: a container restart on 2026-07-30 silently dropped a backlog of ~1,156 BTF, and there was nothing left to reconstruct it from. Re-queueing blindly would have double-paid whoever had already been sent to, so the payment needs to be idempotent rather than merely persistent. In-flight work is now a persisted list of per-payment records (paying_payouts.json), one per (block, address) -- the granularity SendMoney works at, and therefore the granularity at which a payment can be half-done when the process dies. Records are written, via a temp file and rename so a crash cannot truncate them, *before* the ledger entry is erased, so the debt is always described by at least one of the two files. Each attempt updates the record (retries, deferrals, backoff); settling it removes the record and rewrites the file, which is what stops a restart from paying twice. On startup ReconcilePayouts runs before the payout thread: - a record whose payment the wallet already shows -- matched on the payout comment plus the exact amount and destination -- is dropped, not resent. SendMoney commits the transaction to the wallet before it returns, so a crash immediately after sending is recoverable. - a ledger entry that has already been exploded into records is dropped, which is the crash-in-the-window case where both files describe one debt. The wallet check is deliberately biased towards "already paid": skipping a payment is visible in the log and recoverable, paying twice is neither. Verified live: sampling paying_payouts.json during a payout burst caught 3 records / 18.56 BTF mid-flight, cleared once paid, and the paid lines now carry the txid. (cherry picked from commit d0248f2e65dbe745f2e9b1d09e1f53a4354d3640) --- src/rpc.cpp | 462 ++++++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 338 insertions(+), 124 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 11020d8..ae94289 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -315,25 +315,117 @@ static void LoadPendingPayouts() } } -// Payouts ready to execute are moved here under gPayoutExecMutex. -// A dedicated thread drains this list so SendMoney (which takes cs_main) -// never runs on the event loop thread and can't stall miner I/O. -static std::vector gPayoutExecQueue; -static std::mutex gPayoutExecMutex; -static std::atomic gPayoutThreadRunning{false}; +// A payment in flight: one record per (payout entry, address) pair, which is +// the granularity SendMoney works at and therefore the granularity at which a +// payment can be half-done when the process dies. +// +// This list is persisted. It used to be a memory-only queue, and matured +// entries were erased from pending_payouts.json *before* being handed to it, +// so any restart in between lost those payments with no record that anyone +// was owed anything: a container restart on 2026-07-30 silently dropped +// ~1,156 BTF that way. Writing the records before removing the ledger entry, +// and reconciling against the wallet on startup, is what makes a restart +// survivable. +struct PayingRecord { + int blockHeight = 0; + uint256 blockHash = 0; // zero = predates hash keying, see PendingPayout + int matureAtHeight = 0; + std::string address; + int64 amount = 0; + int retries = 0; + int deferrals = 0; + int64 retryAfter = 0; +}; +static std::vector gPaying; // guarded by gPayoutExecMutex +static std::mutex gPayoutExecMutex; +static std::atomic gPayoutThreadRunning{false}; + +static std::string PayingFilePath() +{ + return GetAppDir() + "/paying_payouts.json"; +} + +// Caller must hold gPayoutExecMutex. +static void SavePayingLocked() +{ + nlohmann::json arr = nlohmann::json::array(); + for (const auto& r : gPaying) { + nlohmann::json obj; + obj["blockHeight"] = r.blockHeight; + obj["blockHash"] = r.blockHash.ToString(); + obj["matureAtHeight"] = r.matureAtHeight; + obj["address"] = r.address; + obj["amount"] = r.amount; + obj["retries"] = r.retries; + obj["deferrals"] = r.deferrals; + arr.push_back(obj); + } + std::string path = PayingFilePath(); + // Write to a temporary file and rename, so a crash mid-write cannot leave + // a truncated list of who is still owed money. + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); + return; + } + std::string s = arr.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + fflush(f); + fclose(f); + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("payout", "[payout] WARNING: could not rename %s -> %s\n", + tmp.c_str(), path.c_str()); +} + +static void LoadPaying() +{ + std::string path = PayingFilePath(); + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, f); + fclose(f); + try { + nlohmann::json arr = nlohmann::json::parse(s); + for (const auto& obj : arr) { + PayingRecord r; + r.address = obj["address"].get(); + r.amount = obj["amount"].get(); + r.matureAtHeight = obj["matureAtHeight"].get(); + if (obj.contains("blockHash") && obj["blockHash"].is_string()) + r.blockHash.SetHex(obj["blockHash"].get()); + if (obj.contains("blockHeight") && obj["blockHeight"].is_number()) + r.blockHeight = obj["blockHeight"].get(); + if (obj.contains("retries") && obj["retries"].is_number()) + r.retries = obj["retries"].get(); + if (obj.contains("deferrals") && obj["deferrals"].is_number()) + r.deferrals = obj["deferrals"].get(); + gPaying.push_back(r); + } + LogPrint("payout", "[payout] loaded %zu in-flight payment(s) from disk\n", + gPaying.size()); + } catch (const std::exception& e) { + LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", + path.c_str(), e.what()); + } +} // Is the block that funds this payout still on the main chain? // // A zero hash means the entry was written before payouts recorded one, so // there is nothing to check and we assume it is good -- voiding those would // mean refusing to pay miners on the strength of a missing field. -static bool PayoutBlockStillOnChain(const PendingPayout& pp) +static bool PayoutBlockStillOnChain(const uint256& blockHash) { - if (pp.blockHash == 0) + if (blockHash == 0) return true; CRITICAL_BLOCK(cs_main) { - std::map::iterator mi = mapBlockIndex.find(pp.blockHash); + std::map::iterator mi = mapBlockIndex.find(blockHash); if (mi == mapBlockIndex.end() || mi->second == NULL) return false; return mi->second->IsInMainChain(); @@ -341,6 +433,99 @@ static bool PayoutBlockStillOnChain(const PendingPayout& pp) return false; } +static std::string PayoutComment(int blockHeight) +{ + return strprintf("Pool payout height %d", blockHeight); +} + +// Did we already send this payment before dying? Looks for a wallet +// transaction of ours carrying this payout's comment and paying exactly this +// amount to this address. +// +// Biased towards reporting "already paid" on any doubt: skipping a payment a +// miner is owed is visible and recoverable from the log, while paying twice +// is neither. +static bool WalletAlreadyPaid(const PayingRecord& r) +{ + uint160 h160; + if (!AddressToHash160(r.address, h160)) + return false; + CScript want; + want << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + const std::string comment = PayoutComment(r.blockHeight); + + CRITICAL_BLOCK(cs_mapWallet) + { + for (std::map::iterator it = mapWallet.begin(); + it != mapWallet.end(); ++it) { + CWalletTx& wtx = it->second; + // The comment is written only by the payout path, so its presence + // already identifies the transaction as one of ours. + std::map::const_iterator ci = + wtx.mapValue.find("comment"); + if (ci == wtx.mapValue.end() || ci->second != comment) + continue; + foreach (const CTxOut& txout, wtx.vout) + if (txout.nValue == r.amount && txout.scriptPubKey == want) + return true; + } + } + return false; +} + +// Startup reconciliation. Drops in-flight records the wallet shows were +// already sent, and drops ledger entries that were already exploded into +// records (which happens if we died between writing the records and rewriting +// the ledger -- both files then describe the same debt). +static void ReconcilePayouts() +{ + std::lock_guard lk(gPayoutExecMutex); + + size_t settled = 0; + int64 settledAmount = 0; + for (auto it = gPaying.begin(); it != gPaying.end(); ) { + if (WalletAlreadyPaid(*it)) { + settledAmount += it->amount; + settled++; + it = gPaying.erase(it); + } else { + ++it; + } + } + if (settled) { + LogPrint("payout", "[payout] %zu in-flight payment(s) totalling %s were" + " already sent before the last shutdown -- not resending\n", + settled, FormatMoney(settledAmount).c_str()); + SavePayingLocked(); + } + + size_t dropped = 0; + for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { + bool exploded = false; + for (const auto& r : gPaying) + if (r.matureAtHeight == it->matureAtHeight && r.blockHash == it->blockHash) { + exploded = true; + break; + } + if (exploded) { + LogPrint("payout", "[payout] ledger entry for block %d is already" + " in flight -- dropping the duplicate\n", it->blockHeight); + it = gPendingPayouts.erase(it); + dropped++; + } else { + ++it; + } + } + if (dropped) SavePendingPayouts(); + + if (!gPaying.empty()) { + int64 owed = 0; + for (const auto& r : gPaying) owed += r.amount; + LogPrint("payout", "[payout] resuming %zu in-flight payment(s) totalling %s\n", + gPaying.size(), FormatMoney(owed).c_str()); + } +} + static void QueuePayouts(int blockHeight, const uint256& blockHash, const std::map& shareCount, @@ -418,19 +603,13 @@ static void QueuePayouts(int blockHeight, : ""); } -// Called from event loop: move matured entries to the exec queue (fast). -// NOT crash-safe, despite the ordering below looking like it is. +// Called from event loop: move matured entries into the in-flight list (fast). // -// A matured payout is erased from gPendingPayouts, the file is rewritten -// without it, and only then does it reach the exec queue -- which lives in -// memory alone. SavePendingPayouts writes gPendingPayouts and nothing else; -// there is no exec-queue section of the file to recover from. Die anywhere -// between the rewrite and SendMoney and those miners are simply never paid, -// with no record that they were owed anything. -// -// Making this safe needs the payment to be idempotent -- record the txid and -// check the wallet for it on restart -- rather than a reordering, since -// re-queueing blindly would double-pay whoever was already sent to. +// Ordering matters and is deliberate: the in-flight records are written and +// fsync'd to their own file *before* the ledger entry is erased, so the debt +// is always described by at least one of the two files. Dying in the window +// where both describe it is handled at startup by ReconcilePayouts, which +// drops the ledger entry it can see is already in flight. static void FlushMaturePayouts() { // Void obligations whose funding block lost a reorg. The coin that was @@ -438,7 +617,7 @@ static void FlushMaturePayouts() // coin the pool owes to somebody else. bool voided = false; for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { - if (PayoutBlockStillOnChain(*it)) { ++it; continue; } + if (PayoutBlockStillOnChain(it->blockHash)) { ++it; continue; } int64 total = 0; for (auto& kv : it->amounts) total += kv.second; LogPrint("payout", "[payout] block %d (%s) was orphaned -- voiding %zu" @@ -459,8 +638,23 @@ static void FlushMaturePayouts() ++it; } if (!ready.empty()) { - // Erase from pending then save before handing off to exec thread, - // so the file never contains an entry that is already being sent. + // Record the individual payments first, and only then drop the ledger + // entries that produced them. + { + std::lock_guard lk(gPayoutExecMutex); + for (const auto& pp : ready) { + for (const auto& kv : pp.amounts) { + PayingRecord r; + r.blockHeight = pp.blockHeight; + r.blockHash = pp.blockHash; + r.matureAtHeight = pp.matureAtHeight; + r.address = kv.first; + r.amount = kv.second; + gPaying.push_back(r); + } + } + SavePayingLocked(); + } for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { if (nBestHeight >= it->matureAtHeight) it = gPendingPayouts.erase(it); @@ -468,13 +662,45 @@ static void FlushMaturePayouts() ++it; } SavePendingPayouts(); - std::lock_guard lk(gPayoutExecMutex); - for (auto& pp : ready) - gPayoutExecQueue.push_back(std::move(pp)); } } -// Payout execution thread: drains gPayoutExecQueue, calls SendMoney. +// Identity of a payment, for finding its record again after SendMoney. +// amounts is a map keyed by address, so (block, address) is already unique. +static bool SamePayment(const PayingRecord& a, const PayingRecord& b) +{ + return a.matureAtHeight == b.matureAtHeight + && a.blockHash == b.blockHash + && a.address == b.address + && a.amount == b.amount; +} + +// Settle a payment: it is either done or abandoned, either way it stops being +// owed. Removing the record and rewriting the file is what makes the payment +// not happen twice after a restart. +static void ForgetPayment(const PayingRecord& r) +{ + std::lock_guard lk(gPayoutExecMutex); + for (auto it = gPaying.begin(); it != gPaying.end(); ++it) + if (SamePayment(*it, r)) { gPaying.erase(it); break; } + SavePayingLocked(); +} + +// Persist an updated attempt count / backoff for a payment still owed. +static void ReschedulePayment(const PayingRecord& r) +{ + std::lock_guard lk(gPayoutExecMutex); + for (auto& e : gPaying) + if (SamePayment(e, r)) { + e.retries = r.retries; + e.deferrals = r.deferrals; + e.retryAfter = r.retryAfter; + break; + } + SavePayingLocked(); +} + +// Payout execution thread: works through gPaying, calls SendMoney. // Runs independently of the event loop so slow wallet operations // (SendMoney takes cs_main) never stall miner I/O. static void PayoutThreadFn(void*) @@ -483,111 +709,94 @@ static void PayoutThreadFn(void*) LogPrint("payout", "[payout] payout thread started\n"); while (!fShutdown && gPoolRunning) { - std::vector batch; + std::vector batch; { std::lock_guard lk(gPayoutExecMutex); - batch.swap(gPayoutExecQueue); + int64 now = GetTime(); + for (const auto& r : gPaying) + if (r.retryAfter <= now) + batch.push_back(r); } - for (auto& pp : batch) { - if (pp.retryAfter > GetTime()) { - std::lock_guard lk(gPayoutExecMutex); - gPayoutExecQueue.push_back(pp); - continue; - } - // Re-check here and not only at maturity: an entry can sit in this - // queue for hours across retries, and the reorg that kills its + for (PayingRecord r : batch) { + // Re-check here and not only at maturity: a payment can sit in this + // list for hours across retries, and the reorg that kills its // funding block can land at any point in that window. - if (!PayoutBlockStillOnChain(pp)) { - int64 total = 0; - for (auto& kv : pp.amounts) total += kv.second; + if (!PayoutBlockStillOnChain(r.blockHash)) { LogPrint("payout", "[payout] block %d (%s) orphaned while queued" - " -- dropping %zu payment(s) totalling %s\n", - pp.blockHeight, pp.blockHash.ToString().substr(0, 14).c_str(), - pp.amounts.size(), FormatMoney(total).c_str()); + " -- dropping %s to %s\n", + r.blockHeight, r.blockHash.ToString().substr(0, 14).c_str(), + FormatMoney(r.amount).c_str(), r.address.c_str()); + ForgetPayment(r); continue; } - LogPrint("payout", "[payout] executing payments for block matured at %d" - " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); - for (auto& kv : pp.amounts) { - uint160 h160; - if (!AddressToHash160(kv.first, h160)) { - // Should not happen -- bad addresses are filtered in QueuePayouts - LogPrint("payout", "[payout] SKIPPED bad address '%s'\n", - kv.first.c_str()); - continue; - } - CScript sc; - sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; - CWalletTx wtx; - wtx.mapValue["comment"] = strprintf("Pool payout height %d", - pp.blockHeight); - - // Waiting for coin to mature is not a failure, so it must not - // consume the retry budget. Burning a retry every 60s means a - // payment the pool fully intends to honour can hit GIVING UP - // 24h later purely because the subsidy funding it had not - // matured yet -- the miner is then silently never paid. - int64 nSpendable = GetBalance(); - if (nSpendable < kv.second) { - PendingPayout dp; - dp.blockHeight = pp.blockHeight; - dp.blockHash = pp.blockHash; - dp.matureAtHeight = pp.matureAtHeight; - dp.amounts[kv.first] = kv.second; - dp.retries = pp.retries; // unchanged on purpose - dp.deferrals = pp.deferrals + 1; - dp.retryAfter = GetTime() + 60; - // Hourly, not every minute: a dry pool would otherwise - // write one line per payment per minute forever. - if (dp.deferrals == 1 || dp.deferrals % 60 == 0) - LogPrint("payout", "[payout] deferring %s to %s -- only %s" - " spendable (waiting %d min so far)\n", - FormatMoney(kv.second).c_str(), kv.first.c_str(), - FormatMoney(nSpendable).c_str(), dp.deferrals); - std::lock_guard lk(gPayoutExecMutex); - gPayoutExecQueue.push_back(std::move(dp)); - continue; - } - if (SendMoney(sc, kv.second, wtx)) { - LogPrint("payout", "[payout] paid %s to %s\n", - FormatMoney(kv.second).c_str(), kv.first.c_str()); - } else if (pp.retries < 1440) { - // Transient failures (immature coinbase, reorg, locked - // wallet) must not silently burn the payment -- requeue - // with backoff. Permanent cases (orphaned coinbase) give - // up loudly after 1440 tries (~24 h). - PendingPayout rp; - rp.blockHeight = pp.blockHeight; - rp.blockHash = pp.blockHash; - rp.matureAtHeight = pp.matureAtHeight; - rp.amounts[kv.first] = kv.second; - rp.retries = pp.retries + 1; - rp.deferrals = pp.deferrals; - rp.retryAfter = GetTime() + 60; - { - std::lock_guard lk(gPayoutExecMutex); - gPayoutExecQueue.push_back(std::move(rp)); - } - LogPrint("payout", "[payout] FAILED to pay %s to %s" - " (%s spendable at the time) -- retry %d/1440 in 60s\n", - FormatMoney(kv.second).c_str(), kv.first.c_str(), - FormatMoney(nSpendable).c_str(), - pp.retries + 1); - } else { - // Reaching here now means 1440 attempts that were NOT - // insufficient-funds waits (those defer instead) against a - // block still on the main chain -- i.e. a real wallet fault, - // not a maturity or reorg question. - LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" - " retries -- block %d is still on-chain and %s was" - " spendable, so this is a wallet fault, not maturity" - " -- payment forfeited\n", - FormatMoney(kv.second).c_str(), kv.first.c_str(), - pp.retries, pp.blockHeight, - FormatMoney(nSpendable).c_str()); - } + uint160 h160; + if (!AddressToHash160(r.address, h160)) { + // Should not happen -- bad addresses are filtered in QueuePayouts + LogPrint("payout", "[payout] SKIPPED bad address '%s'\n", + r.address.c_str()); + ForgetPayment(r); + continue; + } + CScript sc; + sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + + // Waiting for coin to mature is not a failure, so it must not + // consume the retry budget. Burning a retry every 60s means a + // payment the pool fully intends to honour can hit GIVING UP + // 24h later purely because the subsidy funding it had not + // matured yet -- the miner is then silently never paid. + int64 nSpendable = GetBalance(); + if (nSpendable < r.amount) { + r.deferrals++; + r.retryAfter = GetTime() + 60; + // Hourly, not every minute: a dry pool would otherwise + // write one line per payment per minute forever. + if (r.deferrals == 1 || r.deferrals % 60 == 0) + LogPrint("payout", "[payout] deferring %s to %s -- only %s" + " spendable (waiting %d min so far)\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + FormatMoney(nSpendable).c_str(), r.deferrals); + ReschedulePayment(r); + continue; + } + + CWalletTx wtx; + wtx.mapValue["comment"] = PayoutComment(r.blockHeight); + + if (SendMoney(sc, r.amount, wtx)) { + // The wallet holds the transaction before this returns, so a + // crash here is recoverable: ReconcilePayouts finds it by + // comment+address+amount and does not resend. + ForgetPayment(r); + LogPrint("payout", "[payout] paid %s to %s (tx %s)\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + wtx.GetHash().ToString().substr(0, 12).c_str()); + } else if (r.retries < 1440) { + // Transient failures (reorg, locked wallet) must not silently + // burn the payment -- retry with backoff. Insufficient funds + // never lands here; it defers above. + r.retries++; + r.retryAfter = GetTime() + 60; + ReschedulePayment(r); + LogPrint("payout", "[payout] FAILED to pay %s to %s" + " (%s spendable at the time) -- retry %d/1440 in 60s\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + FormatMoney(nSpendable).c_str(), r.retries); + } else { + // Reaching here means 1440 attempts that were NOT + // insufficient-funds waits (those defer instead) against a + // block still on the main chain -- i.e. a real wallet fault, + // not a maturity or reorg question. + LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" + " retries -- block %d is still on-chain and %s was" + " spendable, so this is a wallet fault, not maturity" + " -- payment forfeited\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + r.retries, r.blockHeight, + FormatMoney(nSpendable).c_str()); + ForgetPayment(r); } } @@ -1631,6 +1840,11 @@ void ThreadRPCServer(void*) } LoadPendingPayouts(); + LoadPaying(); + // Must run before the payout thread starts, so nothing is resent that the + // wallet already shows as sent, and nothing is owed twice because we died + // between recording the payments and rewriting the ledger. + ReconcilePayouts(); _beginthread(AcceptLoopFn, 0, NULL); _beginthread(LocalAcceptFn, 0, NULL); -- cgit v1.2.3 From 090628b34d1aefc7029be37d7bc8512b5edb3ca5 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Tue, 11 Aug 2026 13:10:57 +0000 Subject: pool: gate jobs on peer height, not tip age The sync gate compared the tip timestamp against 30 minutes. That cannot distinguish "we are not synced" from "the network is slow", and it failed in both directions on the same day. A pool mining its own fork keeps its tip fresh, so the check passed for the whole of heights 3966-4085 -- 120 consecutive blocks, all orphaned, 5890.50 BTF of payouts voided, and nothing in the log to say so. Then, once the pool stopped finding blocks, the same check refused to issue jobs against a tip that was the network's own best. The pool is ~90% of the hashrate, so nobody else could move the chain either: stale tip, no jobs; no jobs, stale tip. Gate on the height peers announce in their version message instead. Median, so one peer cannot stall us with an absurd claim, and a block of slack to match WarnIfBehind. Peers predating the announcement report -1 and are ignored; with no reports at all we mine our best chain, which is what the node does anyway without a pool attached. (cherry picked from commit 4e76e34f37a82c0885f75db7dfb9e49db410171d) --- src/rpc.cpp | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index ae94289..8a0d112 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -143,17 +143,40 @@ static bool RebuildJob() return false; } - // Don't issue jobs until the chain is synced: mining against a stale - // tip produces blocks the network will orphan, and payout entries - // that can never be paid (their coinbases never exist on the main - // chain). Require at least one peer and a fresh tip. + // Don't issue jobs while we are behind the network: mining against a + // stale tip produces blocks the network will orphan, and payout + // entries that can never be paid (their coinbases never exist on the + // main chain). Require at least one peer, and require that no peer + // claims a longer chain than ours. if (vNodes.empty()) { LogPrint("pool", "RebuildJob: no peers yet -- not issuing jobs\n"); return false; } - if (GetAdjustedTime() - (int64)pindexBest->nTime > 30*60) { - LogPrint("pool", "RebuildJob: tip is %llds old -- still syncing, not issuing jobs\n", - (long long)(GetAdjustedTime() - (int64)pindexBest->nTime)); + // This used to compare the tip's timestamp against 30 minutes, which + // was wrong in both directions and cost real money. + // + // It cannot tell "we are not synced" from "the network is slow". A + // pool mining its own fork keeps its tip timestamp fresh forever, so + // the check passed for the whole of heights 3966-4085 -- 120 + // consecutive blocks, every one of them orphaned, 5890.50 BTF of + // payouts voided. Then, once the pool stopped finding blocks, the + // same check refused to issue jobs against a tip that was perfectly + // valid and was the network's own best, and the pool -- roughly 90% + // of the hashrate -- could not restart the chain it was the majority + // of. Stale tip, so no jobs; no jobs, so the tip stays stale. + // + // The height peers announce in their version message answers the + // question the age check was only guessing at. Median, so one peer + // claiming an absurd height cannot stall us, and one block of slack + // to match WarnIfBehind, because somebody is always mid-relay. A + // peer that predates the announcement reports -1 and is ignored; if + // nobody has told us anything we mine our best chain, which is what + // the node would do without a pool attached. + int nPeerHeight = GetPeerMedianHeight(); + if (nPeerHeight >= 0 && nBestHeight < nPeerHeight - 1) { + LogPrint("pool", "RebuildJob: %d blocks behind the network " + "(height %d, peers report %d) -- not issuing jobs\n", + nPeerHeight - nBestHeight, nBestHeight, nPeerHeight); return false; } -- cgit v1.2.3 From 3d7c30ebb6ac0c586635c693a40bc39180f539b9 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Tue, 11 Aug 2026 20:52:09 +0000 Subject: 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) --- src/main.cpp | 15 +++++++++++++++ src/main.h | 1 + src/main_gui.cpp | 3 +++ src/net.cpp | 23 +++++++++++++++++++++++ src/net.h | 8 ++++++++ src/rpc.cpp | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+) (limited to 'src/rpc.cpp') 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; int nDropMessagesTest = 0; bool fSoloMineTest = false; // /solomine: mine without requiring a peer (local test) bool fPoolNoAnnounce = false; // /noannounce: operator serves miners but never publishes pool announcements +bool fPoolMineBlind = false; // /poolmineblind: issue jobs even when no peer has reported a height // Settings int fGenerateBitcoins; @@ -3172,7 +3173,21 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) pfrom->AddInventoryKnown(inv); if (ProcessBlock(pfrom, pblock.release())) + { mapAlreadyAskedFor.erase(inv); + + // A peer that just handed us a block is demonstrably at least at + // that block's height. Without this, nStartingHeight is a snapshot + // taken once during the version handshake and never touched again, + // so on a long-lived connection it is the peer's height from + // however many hours ago it happened to connect. The pool's + // behind-the-network check compares against the median of those + // numbers, so as connections age it drifts toward "everyone is + // behind us" and quietly stops firing -- the opposite of its job. + map::iterator mi = mapBlockIndex.find(inv.hash); + if (mi != mapBlockIndex.end() && mi->second) + pfrom->nStartingHeight = max(pfrom->nStartingHeight, mi->second->nHeight); + } } diff --git a/src/main.h b/src/main.h index 6b1ff0a..f5409e9 100644 --- a/src/main.h +++ b/src/main.h @@ -93,6 +93,7 @@ extern string strParticipantPool; // participant: pool .btf address extern string strPayoutAddress; // participant: /payoutaddr override (mine without a wallet) extern string strPayoutFile; // participant: /payoutfile address list, rotated per reconnect extern bool fPoolNoAnnounce; // /noannounce: suppress pool announcements +extern bool fPoolMineBlind; // /poolmineblind: mine on even when deaf to every peer extern int nHashThreads; // participant: /hashthreads override (0 = cores-1) extern string strPoolName; // operator: announced pool name 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[]) if (arg(argc,argv,"/noannounce") || arg(argc,argv,"-noannounce")) fPoolNoAnnounce = true; + if (arg(argc,argv,"/poolmineblind") || arg(argc,argv,"-poolmineblind")) + fPoolMineBlind = true; + string poolFee = argval2(argc, argv, "/poolfee", "-poolfee"); if (!poolFee.empty()) 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) } } +// When any peer last told us a height. A wedged node -- one that still has +// peers but has stopped hearing them -- is exactly a node where this stops +// advancing, and that is the one thing the height comparison itself cannot +// see: there is no height to compare. +static int64 nLastPeerHeightSeen = 0; +static int64 nPeerHeightFirstAsked = 0; + int GetPeerMedianHeight() { vector vHeights; @@ -1711,10 +1718,26 @@ int GetPeerMedianHeight() if (vHeights.empty()) return -1; + nLastPeerHeightSeen = GetTime(); sort(vHeights.begin(), vHeights.end()); return vHeights[vHeights.size() / 2]; } +// Seconds since any peer last reported a height. +// +// Before ever hearing one, measure from the first time somebody asked rather +// than from the epoch -- otherwise a node refuses jobs the instant it starts, +// before it has had a chance to complete a single handshake. +int64 GetPeerHeightAgeSecs() +{ + int64 nNow = GetTime(); + if (nPeerHeightFirstAsked == 0) + nPeerHeightFirstAsked = nNow; + if (nLastPeerHeightSeen == 0) + return nNow - nPeerHeightFirstAsked; + return nNow - nLastPeerHeightSeen; +} + // Say out loud when we are behind the network. // diff --git a/src/net.h b/src/net.h index bcd0b2a..e4cfa69 100644 --- a/src/net.h +++ b/src/net.h @@ -120,6 +120,13 @@ static const unsigned int MAX_CONNECTIONS = 125; // this long regardless of what is still buffered. static const int64 DISCONNECT_DRAIN_SECS = 60; +// How long a pool may go without hearing a height from ANY peer before it +// stops issuing jobs. Long enough that ordinary peer churn and a quiet +// handshake window do not trip it; short enough that a deaf pool cannot build +// more than a few blocks of private fork before it is stopped. Heights +// 3966-4085 -- 120 blocks, 5890.50 BTF -- were mined in exactly this state. +static const int64 BTF_NO_PEER_HEIGHT_MAX_SECS = 20 * 60; + // Send a ping after this long with nothing to say, so a peer running the // inactivity check above does not mistake a quiet node for a dead one. Must // stay well under BTF_RECV_TIMEOUT_SECS. @@ -160,6 +167,7 @@ extern int nBestHeight; // has told us anything. Median rather than maximum so one peer claiming an // absurd height cannot move it. int GetPeerMedianHeight(); +int64 GetPeerHeightAgeSecs(); // Lightweight .btf churn accounting. These counters are diagnostics only: they // 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() return false; } + // The comparison above is 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 ("nobody has told us anything, so mine our best chain") let + // the pool build a private fork for as long as the deafness lasted. + // That fallback is right for a node that has simply 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. Time can. + // + // Deliberately not behind a debug category, and deliberately not a + // silent refusal: an operator whose pool has stopped issuing jobs needs + // the reason in the log. /poolmineblind is the escape hatch for a node + // that really is alone -- without one, a genuinely isolated pool that is + // the majority of the hashrate could never restart its own chain, which + // is the deadlock the tip-age check used to cause. + if (nPeerHeight < 0 && !fPoolMineBlind) { + int64 nAge = GetPeerHeightAgeSecs(); + if (nAge >= BTF_NO_PEER_HEIGHT_MAX_SECS) { + static int64 nLastSaid = 0; + if (GetTime() - nLastSaid > 60) { + nLastSaid = GetTime(); + printf("RebuildJob: no peer has reported a height for %d minutes " + "-- not issuing jobs (our height %d, %d peers connected). " + "This node is deaf; mining now would fork it. " + "Pass /poolmineblind to override.\n", + (int)(nAge / 60), nBestHeight, (int)vNodes.size()); + } + return false; + } + } + CBlockIndex* pindexPrev = pindexBest; unsigned int nBits = GetNextWorkRequired(pindexPrev); -- cgit v1.2.3 From 88d12ee18e7a3df489cc600f92c4f68506d307fe Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Thu, 13 Aug 2026 14:50:26 +0000 Subject: consensus: cap block size at 1 MB, and stop assembling blocks we would reject CheckBlock() validated block size against MAX_SIZE, the serializer's generic 32 MB guard, not a block rule. MAX_BLOCK_SIZE did not exist in this tree at all: Bitcoin's 1 MB cap arrived in 0.3.x, after the 0.1.0 this fork came from. A miner could produce a 32 MB block and every node would download, verify and store it. Upstream #111 (from #110 by mtg0001). The cap is a tightening, so a node that does not upgrade is the permissive one. Measured upstream over 5709 blocks: largest 34,290 bytes, median 216, so 1 MB rejects nothing that exists. Upstream shipped the validation half only. Both places that ASSEMBLE a block still filled to MAX_SIZE/2 (16 MB), which was consistent against a 32 MB validation cap and is inverted by a 1 MB one -- the node would solve a block and then reject it itself. Fixed both: main.cpp CreateNewBlock, the node's own miner rpc.cpp RebuildJob, the POOL's job builder -- the one that matters here, because every miner on the pool would be working a template the network rejects, and this pool is ~90% of hashrate Half the cap in both, preserving the original margin: each test runs against a transaction that is already counted, so the loop can overshoot by one. (cherry picked from commit a3be9f04903447fa2741aff4eb529fe45f916877) --- src/main.cpp | 14 +++++++++++++- src/rpc.cpp | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/main.cpp b/src/main.cpp index 5d0b70a..37a15c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4226,7 +4226,19 @@ bool BitcoinMiner(int nThreadId) vector vfAlreadyAdded(mapTransactions.size()); bool fFoundSomething = true; unsigned int nBlockSize = 0; - while (fFoundSomething && nBlockSize < MAX_SIZE/2) + // Assembly must stop below the consensus cap that CheckBlock() + // enforces, or we mine a block our own node then rejects. + // + // This used to read MAX_SIZE/2 (16 MB), which was consistent while + // CheckBlock() also tested against MAX_SIZE (32 MB). Adding + // MAX_BLOCK_SIZE inverted it: assembly could fill 16 MB and + // validation would refuse anything over 1 MB, so a full mempool + // meant solving a block and throwing it away -- with this pool at + // ~90% of hashrate, that is the network stalling, not just us. + // + // Half the cap, keeping the original margin: the test runs before + // a transaction is added, so the loop can overshoot by one tx. + while (fFoundSomething && nBlockSize < MAX_BLOCK_SIZE/2) { fFoundSomething = false; unsigned int n = 0; diff --git a/src/rpc.cpp b/src/rpc.cpp index da22b1d..03d1f44 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -253,7 +253,13 @@ static bool RebuildJob() pool = tmp; block.vtx.push_back(tx); sz += ::GetSerializeSize(tx, SER_NETWORK); - if (sz > MAX_SIZE / 2) break; + // Must stay under the consensus cap CheckBlock() enforces. + // This is the pool's job builder, so a block over the cap + // is not just one wasted solve -- it is every miner on the + // pool working a template the network will reject. The + // transaction is already pushed above, so this can overshoot + // by one; half the cap leaves room for that. + if (sz > MAX_BLOCK_SIZE / 2) break; } } } -- cgit v1.2.3 From 33f2e8a164d0b8a508dc97372a3592d126b96a1a Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:33:57 +0000 Subject: payout: serialise ledger file writes and order them by snapshot version Splitting SavePendingPayoutsLocked() into a serialise step that needs gPayoutLedgerMutex and a write step that does not left the write step with no serialisation at all. The event loop wrote pending_payouts.json from QueuePayouts and the orphan-void path with the ledger mutex HELD; the payout thread wrote it from RecordPayoutTxid and ForfeitPayoutRecipient with the mutex RELEASED. Both fopen()ed the same ".tmp" with independent handles and both rename()d it. Two ways that ends, both live money: - the renamed file is a mixture of two images. LoadPendingPayouts throws inside its parse loop, logs one warning, and the node comes up with a nearly empty ledger -- every obligation not yet exploded into an in-flight record is simply gone, with nothing on disk saying anyone was owed it. - a lost update: the payout thread's older snapshot lands after the event loop's newer one and silently erases a whole round that had just been queued. Neither parent had this. Upstream writes under the mutex everywhere; the pre-merge tree had one writer thread and no ledger mutex. The merge invented the window while fixing a real problem -- the event loop should not block in FlushMaturePayouts behind a per-recipient fsync on the payout thread -- so putting the write back under the ledger mutex is not the fix. Instead: a per-file write mutex that is never held together with a data mutex, plus a version on every snapshot. - serialising happens under the data mutex and stamps a monotonically increasing sequence number (gPayoutLedgerSeq). - publishing happens with the data mutex released, under the file mutex, and drops any blob whose sequence number is not strictly newer than the one already on disk. Because each blob is a whole-state dump of a single in-memory structure and the sequence number is assigned under the mutex guarding that structure, a higher sequence number is by construction a later state -- so discarding an older blob loses nothing, and the file can only move forwards. Writers may queue and complete in any order and the published file is still the newest state anyone has serialised. A failed write deliberately does not advance the published sequence, so the next write still tries. The same treatment is applied to paying_payouts.json and pool_rounds.json. Neither had the interleaving race -- gPayoutExecMutex and gPoolRoundsMutex already serialise their writers -- but both did their I/O under a mutex the event loop takes (FlushMaturePayouts, RecordPoolRound), so both were a place for the event loop to wait on the payout thread's disk. They are now written outside those mutexes on the same versioned terms, which is also what makes adding fsync to these paths safe. pool_status.json keeps a plain atomic write with no version guard: RefreshStats is its only caller and only ever runs on the event loop, so it has a single writer by construction. It shares the same WriteFileAtomic helper so the durability rules live in one place. No on-disk format change. --- src/rpc.cpp | 431 ++++++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 272 insertions(+), 159 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 8af2fbe..76867ab 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -360,6 +360,12 @@ struct PendingPayout { }; static std::vector gPendingPayouts; static std::mutex gPayoutLedgerMutex; +// Bumped on every serialisation, under gPayoutLedgerMutex. See PublishVersioned. +static uint64 gPayoutLedgerSeq = 0; +// Serialises writers of pending_payouts.json, and guards the sequence number +// of the image currently on disk. Never taken while a data mutex is held. +static std::mutex gPayoutLedgerFileMutex; +static uint64 gPayoutLedgerPublishedSeq = 0; struct PoolRoundProof { std::string id; @@ -388,6 +394,9 @@ struct PoolRoundProof { }; static std::vector gPoolRounds; static std::mutex gPoolRoundsMutex; +static uint64 gPoolRoundsSeq = 0; // guarded by gPoolRoundsMutex +static std::mutex gPoolRoundsFileMutex; +static uint64 gPoolRoundsPublishedSeq = 0; // guarded by gPoolRoundsFileMutex static const size_t MAX_POOL_ROUNDS = 200; static std::string PayoutFilePath() @@ -402,6 +411,104 @@ static std::string PoolRoundsFilePath() return GetAppDir() + "/pool_rounds.json"; } +// --------------------------------------------------------------------------- +// Atomic, ordered publication of the payout state files +// --------------------------------------------------------------------------- +// +// Every one of these files is a full dump of an in-memory structure, written +// tmp-then-rename so a crash cannot truncate it. Two things have to be true +// for that to actually be safe, and only the first was: +// +// 1. the rename must be atomic (it is), and +// 2. exactly one thread at a time may be writing the temp file, and a write +// must never publish an OLDER image over a newer one. +// +// (2) was broken for pending_payouts.json. The event loop wrote it with +// gPayoutLedgerMutex held, the payout thread wrote it with the mutex +// released, and nothing serialised the two: both opened the same +// ".tmp" with independent handles and both renamed it. The interleaved +// file that lands is either a mixture of two images (LoadPendingPayouts then +// throws mid-loop and comes up with a nearly empty ledger -- every obligation +// not yet exploded, gone) or a whole newly-queued round silently overwritten +// by an older snapshot that predates it. +// +// The fix is a per-file write mutex, held only across the I/O and never +// across a data-structure mutex, plus a version on the snapshot. Serialising +// happens under the data mutex and stamps a monotonically increasing sequence +// number; publishing happens with the data mutex released and refuses any +// blob whose sequence number is not newer than what is already on disk. So +// writers can queue in any order and finish in any order -- the file only +// ever moves forwards. Crucially, no thread ever waits for I/O while holding +// gPayoutLedgerMutex / gPayoutExecMutex / gPoolRoundsMutex, which is the +// contention the merge was right to remove from the event loop. + +// A serialised file image plus the version of the in-memory state it was +// taken from. +struct VersionedBlob { + std::string blob; + uint64 seq = 0; +}; + +// Replace `path` with `data` atomically. Caller must hold the write mutex for +// `path`: the temp name is derived from the target, so two threads in here at +// once for one path is precisely the race this exists to prevent. +static bool WriteFileAtomic(const std::string& path, const std::string& data, + const char* what) +{ + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("payout", "[payout] WARNING: could not write %s (%s)\n", + tmp.c_str(), what); + return false; + } + if (!data.empty() && fwrite(data.c_str(), 1, data.size(), f) != data.size()) { + LogPrint("payout", "[payout] WARNING: short write to %s (%s)\n", + tmp.c_str(), what); + fclose(f); + return false; + } + // fclose() is where buffered data actually reaches the OS, so a failure + // here means the .tmp is short -- renaming it over the real file would + // destroy the good copy, which is exactly what these files exist to + // prevent. + if (fclose(f) != 0) { + LogPrint("payout", "[payout] WARNING: could not close %s (%s)\n", + tmp.c_str(), what); + return false; + } +#ifdef _WIN32 + // rename() on Windows will not replace an existing file. + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) { + LogPrint("payout", "[payout] WARNING: could not replace %s (%s)\n", + path.c_str(), what); + return false; + } + return true; +} + +// Publish a versioned image of some in-memory state to `path`. +// +// `fileMutex` serialises writers of `path` and guards `publishedSeq`. A blob +// older than what is already published is dropped: these files are whole-state +// dumps, so a higher sequence number is by construction a later state of the +// same structure and publishing it loses nothing. On a write failure +// `publishedSeq` is deliberately left alone, so the next (newer) write still +// gets a chance to land. +static void PublishVersioned(const std::string& path, const VersionedBlob& v, + std::mutex& fileMutex, uint64& publishedSeq, + const char* what) +{ + std::lock_guard lk(fileMutex); + if (v.seq <= publishedSeq) + return; + if (!WriteFileAtomic(path, v.blob, what)) + return; + publishedSeq = v.seq; +} + // Identity of a payout round. Used as the pool-round proof id and, more // importantly, as the `pool_payout_id` written into wallet transaction // metadata and matched by FindRecordedPoolPayout(). @@ -431,14 +538,16 @@ static bool PayoutFullyPaid(const PendingPayout& pp) return true; } -// Serialise the ledger. Caller must hold gPayoutLedgerMutex. +// Serialise the ledger and stamp it with the version it was taken at. Caller +// must hold gPayoutLedgerMutex. // -// Split from the file write so the payout thread can hold the mutex just long -// enough to read the structure and then do the I/O with it released. Upstream -// writes the file inside the per-recipient loop with gPayoutLedgerMutex held, -// while the event loop's FlushMaturePayouts blocks on that same mutex -- one -// slow fsync per recipient, on the thread the miners' I/O shares. -static std::string SerializePendingPayoutsLocked() +// Split from the file write so no thread holds the ledger mutex across the +// I/O. Upstream writes the file inside the per-recipient loop with +// gPayoutLedgerMutex held, while the event loop's FlushMaturePayouts blocks on +// that same mutex -- one slow fsync per recipient, on the thread the miners' +// I/O shares. The sequence number is what lets the write happen later, and +// possibly out of order, without a stale image reaching the disk. +static VersionedBlob SerializePendingPayoutsLocked() { nlohmann::json arr = nlohmann::json::array(); for (const auto& pp : gPendingPayouts) { @@ -457,38 +566,18 @@ static std::string SerializePendingPayoutsLocked() obj["paidTxids"] = paid; arr.push_back(obj); } - return arr.dump(2); -} - -// Write the serialised ledger. Holds no lock; safe to call with the mutex -// released. Atomic tmp+rename so a crash mid-write cannot truncate the record -// of who is still owed money. -static void WritePendingPayoutsFile(const std::string& s) -{ - std::string path = PayoutFilePath(); - std::string tmp = path + ".tmp"; - FILE* f = fopen(tmp.c_str(), "w"); - if (!f) { - LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); - return; - } - fwrite(s.c_str(), 1, s.size(), f); - if (fclose(f) != 0) { - LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); - return; - } -#ifdef _WIN32 - remove(path.c_str()); -#endif - if (rename(tmp.c_str(), path.c_str()) != 0) - LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); + VersionedBlob v; + v.blob = arr.dump(2); + v.seq = ++gPayoutLedgerSeq; + return v; } -// Convenience for the cold paths (startup, queueing) where holding the mutex -// across the write costs nothing. Caller must hold gPayoutLedgerMutex. -static void SavePendingPayoutsLocked() +// Publish a ledger snapshot. Holds no data mutex; safe -- and intended -- to +// call with gPayoutLedgerMutex released. +static void PublishPendingPayouts(const VersionedBlob& v) { - WritePendingPayoutsFile(SerializePendingPayoutsLocked()); + PublishVersioned(PayoutFilePath(), v, gPayoutLedgerFileMutex, + gPayoutLedgerPublishedSeq, "pending payout ledger"); } static void LoadPendingPayouts() @@ -577,6 +666,9 @@ struct PayingRecord { }; static std::vector gPaying; // guarded by gPayoutExecMutex static std::mutex gPayoutExecMutex; +static uint64 gPayingSeq = 0; // guarded by gPayoutExecMutex +static std::mutex gPayingFileMutex; +static uint64 gPayingPublishedSeq = 0; // guarded by gPayingFileMutex static std::atomic gPayoutThreadRunning{false}; static std::string PayingFilePath() @@ -584,8 +676,16 @@ static std::string PayingFilePath() return GetAppDir() + "/paying_payouts.json"; } -// Caller must hold gPayoutExecMutex. -static void SavePayingLocked() +// Serialise the in-flight list. Caller must hold gPayoutExecMutex. +// +// gPayoutExecMutex already serialises every writer of this file, so it never +// had the interleaving race pending_payouts.json had. It is still split the +// same way for a different reason: FlushMaturePayouts runs on the event loop +// and holds gPayoutExecMutex, so writing (and, since this series, fsyncing) +// the file under that mutex parks miner I/O behind the payout thread's disk. +// The version stamp keeps the file monotonic now that the write is outside +// the mutex that orders the writers. +static VersionedBlob SerializePayingLocked() { nlohmann::json arr = nlohmann::json::array(); for (const auto& r : gPaying) { @@ -599,33 +699,17 @@ static void SavePayingLocked() obj["deferrals"] = r.deferrals; arr.push_back(obj); } - std::string path = PayingFilePath(); - // Write to a temporary file and rename, so a crash mid-write cannot leave - // a truncated list of who is still owed money. - std::string tmp = path + ".tmp"; - FILE* f = fopen(tmp.c_str(), "w"); - if (!f) { - LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); - return; - } - std::string s = arr.dump(2); - fwrite(s.c_str(), 1, s.size(), f); - // fclose() is where buffered data actually reaches the OS, so a failure - // here means the .tmp is short -- renaming it over the real file would - // destroy the good copy. Upstream's check, taken because a truncated - // list of who is owed money is exactly what this function exists to - // prevent. rename() on Windows will not replace an existing file, hence - // the remove() first. - if (fclose(f) != 0) { - LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); - return; - } -#ifdef _WIN32 - remove(path.c_str()); -#endif - if (rename(tmp.c_str(), path.c_str()) != 0) - LogPrint("payout", "[payout] WARNING: could not rename %s -> %s\n", - tmp.c_str(), path.c_str()); + VersionedBlob v; + v.blob = arr.dump(2); + v.seq = ++gPayingSeq; + return v; +} + +// Publish an in-flight-list snapshot. Call with gPayoutExecMutex released. +static void PublishPaying(const VersionedBlob& v) +{ + PublishVersioned(PayingFilePath(), v, gPayingFileMutex, + gPayingPublishedSeq, "in-flight payment list"); } static void LoadPaying() @@ -713,7 +797,14 @@ static nlohmann::json PoolRoundToJson(const PoolRoundProof& r) return j; } -static void SavePoolRoundsLocked() +// Serialise the published round proofs. Caller must hold gPoolRoundsMutex. +// +// Like the in-flight list, gPoolRoundsMutex already serialises the writers +// (RecordPoolRound on the event loop, MarkPoolRoundPaid on the payout thread), +// so this file never had the interleaving race either -- but the event loop +// should not be waiting on the payout thread's fsync to publish a round, so +// the write moves outside the mutex on the same versioned terms. +static VersionedBlob SerializePoolRoundsLocked() { nlohmann::json root; root["schema"] = "bitflash-pool-rounds-1"; @@ -722,24 +813,17 @@ static void SavePoolRoundsLocked() for (const PoolRoundProof& r : gPoolRounds) root["rounds"].push_back(PoolRoundToJson(r)); - std::string path = PoolRoundsFilePath(); - std::string tmp = path + ".tmp"; - FILE* f = fopen(tmp.c_str(), "w"); - if (!f) { - LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); - return; - } - std::string s = root.dump(2); - fwrite(s.c_str(), 1, s.size(), f); - if (fclose(f) != 0) { - LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); - return; - } -#ifdef _WIN32 - remove(path.c_str()); -#endif - if (rename(tmp.c_str(), path.c_str()) != 0) - LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); + VersionedBlob v; + v.blob = root.dump(2); + v.seq = ++gPoolRoundsSeq; + return v; +} + +// Publish a round-proof snapshot. Call with gPoolRoundsMutex released. +static void PublishPoolRounds(const VersionedBlob& v) +{ + PublishVersioned(PoolRoundsFilePath(), v, gPoolRoundsFileMutex, + gPoolRoundsPublishedSeq, "pool round proofs"); } static void LoadPoolRounds() @@ -844,6 +928,8 @@ static std::string PayoutComment(int blockHeight) // and the in-flight file being rewritten). The ledger wins. static void ReconcilePayouts() { + VersionedBlob payingSnap; + { std::lock_guard exec(gPayoutExecMutex); std::lock_guard ledger(gPayoutLedgerMutex); @@ -873,7 +959,7 @@ static void ReconcilePayouts() " already recorded paid in the ledger -- not resending\n", settled, FormatMoney(settledAmount).c_str()); } - SavePayingLocked(); + payingSnap = SerializePayingLocked(); if (!gPaying.empty()) { int64 owed = 0; @@ -881,36 +967,53 @@ static void ReconcilePayouts() LogPrint("payout", "[payout] resuming %zu in-flight payment(s) totalling %s\n", gPaying.size(), FormatMoney(owed).c_str()); } + } + PublishPaying(payingSnap); } static void RecordPoolRound(const PoolRoundProof& round) { - std::lock_guard lk(gPoolRoundsMutex); - for (PoolRoundProof& existing : gPoolRounds) { - if (existing.id == round.id) { - existing = round; - SavePoolRoundsLocked(); - return; + VersionedBlob snap; + { + std::lock_guard lk(gPoolRoundsMutex); + bool replaced = false; + for (PoolRoundProof& existing : gPoolRounds) { + if (existing.id == round.id) { + existing = round; + replaced = true; + break; + } } + if (!replaced) { + gPoolRounds.push_back(round); + while (gPoolRounds.size() > MAX_POOL_ROUNDS) + gPoolRounds.erase(gPoolRounds.begin()); + } + snap = SerializePoolRoundsLocked(); } - gPoolRounds.push_back(round); - while (gPoolRounds.size() > MAX_POOL_ROUNDS) - gPoolRounds.erase(gPoolRounds.begin()); - SavePoolRoundsLocked(); + PublishPoolRounds(snap); } static void MarkPoolRoundPaid(const std::string& id, const std::string& address, const std::string& txid) { - std::lock_guard lk(gPoolRoundsMutex); - for (PoolRoundProof& r : gPoolRounds) { - if (r.id != id) - continue; - r.paidTxids[address] = txid; - SavePoolRoundsLocked(); - return; + VersionedBlob snap; + { + std::lock_guard lk(gPoolRoundsMutex); + bool found = false; + for (PoolRoundProof& r : gPoolRounds) { + if (r.id != id) + continue; + r.paidTxids[address] = txid; + found = true; + break; + } + if (!found) + return; + snap = SerializePoolRoundsLocked(); } + PublishPoolRounds(snap); } // Upstream's gPayoutExecQueue/gPayoutExecMutex/gPayoutThreadRunning are @@ -1039,9 +1142,13 @@ static void QueuePayouts(int blockHeight, round.forfeitedBadAddress = burned; { - std::lock_guard lk(gPayoutLedgerMutex); - gPendingPayouts.push_back(pp); - SavePendingPayoutsLocked(); + VersionedBlob snap; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(pp); + snap = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(snap); } RecordPoolRound(round); @@ -1098,6 +1205,7 @@ static void FlushMaturePayouts() } std::vector ready; + VersionedBlob ledgerSnap; { std::lock_guard ledger(gPayoutLedgerMutex); @@ -1119,7 +1227,7 @@ static void FlushMaturePayouts() it = gPendingPayouts.erase(it); voided = true; } - if (voided) SavePendingPayoutsLocked(); + if (voided) ledgerSnap = SerializePendingPayoutsLocked(); int64 now = GetTime(); for (const PendingPayout& pp : gPendingPayouts) { @@ -1132,10 +1240,15 @@ static void FlushMaturePayouts() ready.push_back(pp); } } + // Outside the ledger mutex: publishing is I/O and the event loop is the + // one thread that must never wait on the payout thread's disk. + PublishPendingPayouts(ledgerSnap); if (ready.empty()) return; + VersionedBlob payingSnap; + { std::lock_guard queue(gPayoutExecMutex); size_t added = 0; for (const PendingPayout& pp : ready) { @@ -1163,7 +1276,9 @@ static void FlushMaturePayouts() " (%zu of %zu recipients still owed)\n", pp.matureAtHeight, id.c_str(), n, pp.amounts.size()); } - if (added) SavePayingLocked(); + if (added) payingSnap = SerializePayingLocked(); + } + PublishPaying(payingSnap); } // Identity of a payment, for finding its record again after SendMoney. @@ -1182,49 +1297,60 @@ static bool SamePayment(const PayingRecord& a, const PayingRecord& b) // separately by RecordPayoutTxid(). static void ForgetPayment(const PayingRecord& r) { - std::lock_guard lk(gPayoutExecMutex); - for (auto it = gPaying.begin(); it != gPaying.end(); ++it) - if (SamePayment(*it, r)) { gPaying.erase(it); break; } - - // When the last record for a round is gone the round is no longer in - // flight, so a later flush may explode it again if the ledger still shows - // recipients owed (a give-up, for instance, leaves the entry standing). - bool stillInFlight = false; - for (const auto& e : gPaying) - if (e.blockHash == r.blockHash) { stillInFlight = true; break; } - if (!stillInFlight) - gQueuedPayouts.erase(PayoutIdForBlock(r.blockHeight, r.blockHash)); - - SavePayingLocked(); + VersionedBlob snap; + { + std::lock_guard lk(gPayoutExecMutex); + for (auto it = gPaying.begin(); it != gPaying.end(); ++it) + if (SamePayment(*it, r)) { gPaying.erase(it); break; } + + // When the last record for a round is gone the round is no longer in + // flight, so a later flush may explode it again if the ledger still + // shows recipients owed (a give-up, for instance, leaves the entry + // standing). + bool stillInFlight = false; + for (const auto& e : gPaying) + if (e.blockHash == r.blockHash) { stillInFlight = true; break; } + if (!stillInFlight) + gQueuedPayouts.erase(PayoutIdForBlock(r.blockHeight, r.blockHash)); + + snap = SerializePayingLocked(); + } + PublishPaying(snap); } // Persist an updated attempt count / backoff for a payment still owed. static void ReschedulePayment(const PayingRecord& r) { - std::lock_guard lk(gPayoutExecMutex); - for (auto& e : gPaying) - if (SamePayment(e, r)) { - e.retries = r.retries; - e.deferrals = r.deferrals; - e.retryAfter = r.retryAfter; - // Carry the cleared probe flag back, or every retry re-scans the - // whole wallet -- the cost this merge is specifically removing. - e.fProbeWallet = r.fProbeWallet; - break; - } - SavePayingLocked(); + VersionedBlob snap; + { + std::lock_guard lk(gPayoutExecMutex); + for (auto& e : gPaying) + if (SamePayment(e, r)) { + e.retries = r.retries; + e.deferrals = r.deferrals; + e.retryAfter = r.retryAfter; + // Carry the cleared probe flag back, or every retry re-scans + // the whole wallet -- the cost this merge is specifically + // removing. + e.fProbeWallet = r.fProbeWallet; + break; + } + snap = SerializePayingLocked(); + } + PublishPaying(snap); } // Record that a recipient has been paid, in the ledger, which is the only // durable statement that the debt is settled. // -// The file write happens with gPayoutLedgerMutex RELEASED. Upstream calls -// SavePendingPayoutsLocked() inside the per-recipient loop with the mutex -// held, so every recipient costs the event loop a full serialise+fsync it has -// to wait for in FlushMaturePayouts. +// The file write happens with gPayoutLedgerMutex RELEASED. Upstream writes the +// file inside the per-recipient loop with the mutex held, so every recipient +// costs the event loop a full serialise+fsync it has to wait for in +// FlushMaturePayouts. Ordering against the event loop's own writes is the +// snapshot version's job, not the mutex's -- see PublishVersioned. static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) { - std::string blob; + VersionedBlob blob; bool complete = false; { std::lock_guard lk(gPayoutLedgerMutex); @@ -1243,7 +1369,7 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) } blob = SerializePendingPayoutsLocked(); } - WritePendingPayoutsFile(blob); + PublishPendingPayouts(blob); // The round proof records a txid per address, so this is per recipient and // not only on completion. MarkPoolRoundPaid(PayoutIdForBlock(r.blockHeight, r.blockHash), r.address, txid); @@ -1259,7 +1385,7 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) // is not reported as sent. Once nothing is left owed the entry retires. static void ForfeitPayoutRecipient(const PayingRecord& r) { - std::string blob; + VersionedBlob blob; { std::lock_guard lk(gPayoutLedgerMutex); for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ++it) { @@ -1272,7 +1398,7 @@ static void ForfeitPayoutRecipient(const PayingRecord& r) } blob = SerializePendingPayoutsLocked(); } - WritePendingPayoutsFile(blob); + PublishPendingPayouts(blob); } // Payout execution thread: works through gPaying, calls SendMoney. @@ -1554,24 +1680,11 @@ static void WritePoolStatusJsonLocked() } } - std::string path = PoolStatusFilePath(); - std::string tmp = path + ".tmp"; - FILE* f = fopen(tmp.c_str(), "w"); - if (!f) { - LogPrint("pool", "[pool] WARNING: could not write status file %s\n", tmp.c_str()); - return; - } - std::string s = j.dump(2); - fwrite(s.c_str(), 1, s.size(), f); - if (fclose(f) != 0) { - LogPrint("pool", "[pool] WARNING: could not close status file %s\n", tmp.c_str()); - return; - } -#ifdef _WIN32 - remove(path.c_str()); -#endif - if (rename(tmp.c_str(), path.c_str()) != 0) - LogPrint("pool", "[pool] WARNING: could not replace status file %s\n", path.c_str()); + // No version guard and no write mutex here, deliberately: RefreshStats is + // the only caller and it only ever runs on the event loop, so there is a + // single writer of pool_status.json by construction. It shares + // WriteFileAtomic so the durability rules stay in one place. + WriteFileAtomic(PoolStatusFilePath(), j.dump(2), "pool status"); } // --------------------------------------------------------------------------- -- cgit v1.2.3 From 63740a1b00d78441713a2b909aa40429098184d2 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:35:31 +0000 Subject: payout: stop grandfathered zero-hash rounds answering for each other Every ledger lookup identified an entry by its block hash alone -- ReconcilePayouts, the payout thread's already-paid check, RecordPayoutTxid, ForfeitPayoutRecipient, ForgetPayment's still-in-flight test, and SamePayment. Entries restored from a file written before payouts were keyed by hash all carry hash 0. Two of them therefore compare equal, and the first one found answers for the second: - RecordPayoutTxid writes a txid against the wrong round. That round can then satisfy PayoutFullyPaid and be erased from the ledger while the debt it recorded is still owed, and nothing on disk remembers it. - ReconcilePayouts drops a live in-flight record on startup because a different zero-hash round had already paid that same address. SamePayoutRound() decides it properly: a non-zero hash is unique and settles the question on its own, a zero hash falls back to the block height (which LoadPendingPayouts supplies for any entry able to name its own maturity), and a zero hash never matches a real one. Refusing to match is the safe direction at all six sites, because each treats a match as "this debt is already handled". The orphan-void scan is deliberately left alone: PayoutBlockStillOnChain returns true for a zero hash, so a grandfathered entry can never enter the orphaned list and there is nothing there to cross-match. For the residual case where an entry has neither hash nor height -- which no file this tree has written can produce -- FlushMaturePayouts now refuses to explode it and logs why, rather than paying against an identity that could belong to another round. Not paying is recoverable; recording a payment against the wrong debt is not. PayoutIdForBlock's grandfathered "block-" fallback is unchanged and still safe: that id is only ever matched against pool_payout_id, which no pre-upgrade transaction carries. No on-disk format change. --- src/rpc.cpp | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 7 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 76867ab..b6b26f1 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -530,6 +530,42 @@ static std::string PayoutIdForBlock(int blockHeight, const uint256& blockHash) blockHash.ToString().substr(0, 16).c_str()); } +// Do these two references name the same payout round? +// +// The block hash alone does not decide it, and every ledger lookup used to +// assume it did. Entries grandfathered in from a file written before payouts +// were keyed by hash all carry hash 0, so under a hash-only comparison the +// first zero-hash entry answers for every other one: RecordPayoutTxid writes +// a txid against the wrong round (which is then erased as "fully paid" while +// the real debt still stands), and ReconcilePayouts drops a live in-flight +// record because a *different* zero-hash round happened to pay that address. +// +// A non-zero hash is unique and decides on its own -- that is the whole point +// of keying on it. A zero hash falls back to the height, which +// LoadPendingPayouts supplies for any entry that can name its own maturity. +// Mixed hash-ness never matches: refusing to match is the safe direction for +// every caller here, because each of them treats a match as "this debt is +// already dealt with". +static bool SamePayoutRound(int aHeight, const uint256& aHash, + int bHeight, const uint256& bHash) +{ + if (aHash != bHash) + return false; + if (aHash != 0) + return true; + return aHeight == bHeight; +} + +// An entry with neither a hash nor a height cannot be told apart from any +// other entry in the same state, so nothing may be recorded against it: a +// txid written to the wrong one reports a debt as settled that is still owed. +// Such an entry is never paid and is logged instead -- refusing to act is the +// safe failure, and no file this tree has ever written can produce one. +static bool PayoutRoundIdentifiable(int height, const uint256& hash) +{ + return hash != 0 || height != 0; +} + static bool PayoutFullyPaid(const PendingPayout& pp) { for (const auto& kv : pp.amounts) @@ -938,7 +974,9 @@ static void ReconcilePayouts() for (auto it = gPaying.begin(); it != gPaying.end(); ) { bool paid = false; for (const PendingPayout& pp : gPendingPayouts) - if (pp.blockHash == it->blockHash && pp.paidTxids.count(it->address)) { + if (SamePayoutRound(pp.blockHeight, pp.blockHash, + it->blockHeight, it->blockHash) + && pp.paidTxids.count(it->address)) { paid = true; break; } @@ -1237,6 +1275,18 @@ static void FlushMaturePayouts() continue; if (PayoutFullyPaid(pp)) continue; + // An entry with neither a hash nor a height cannot be told apart + // from another in the same state, so a txid recorded against it + // could settle the wrong debt. Paying it is the unsafe move; + // leaving it standing and saying so is not. + if (!PayoutRoundIdentifiable(pp.blockHeight, pp.blockHash)) { + LogPrint("payout", "[payout] WARNING: ledger entry maturing at" + " height %d names neither a block hash nor a height --" + " refusing to pay %zu recipient(s) that cannot be told" + " apart from another round's\n", + pp.matureAtHeight, pp.amounts.size()); + continue; + } ready.push_back(pp); } } @@ -1285,8 +1335,8 @@ static void FlushMaturePayouts() // amounts is a map keyed by address, so (block, address) is already unique. static bool SamePayment(const PayingRecord& a, const PayingRecord& b) { - return a.matureAtHeight == b.matureAtHeight - && a.blockHash == b.blockHash + return SamePayoutRound(a.blockHeight, a.blockHash, b.blockHeight, b.blockHash) + && a.matureAtHeight == b.matureAtHeight && a.address == b.address && a.amount == b.amount; } @@ -1309,7 +1359,11 @@ static void ForgetPayment(const PayingRecord& r) // standing). bool stillInFlight = false; for (const auto& e : gPaying) - if (e.blockHash == r.blockHash) { stillInFlight = true; break; } + if (SamePayoutRound(e.blockHeight, e.blockHash, + r.blockHeight, r.blockHash)) { + stillInFlight = true; + break; + } if (!stillInFlight) gQueuedPayouts.erase(PayoutIdForBlock(r.blockHeight, r.blockHash)); @@ -1355,7 +1409,8 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) { std::lock_guard lk(gPayoutLedgerMutex); for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ++it) { - if (it->blockHash != r.blockHash) + if (!SamePayoutRound(it->blockHeight, it->blockHash, + r.blockHeight, r.blockHash)) continue; it->paidTxids[r.address] = txid; it->retryAfter = 0; @@ -1389,7 +1444,8 @@ static void ForfeitPayoutRecipient(const PayingRecord& r) { std::lock_guard lk(gPayoutLedgerMutex); for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ++it) { - if (it->blockHash != r.blockHash) + if (!SamePayoutRound(it->blockHeight, it->blockHash, + r.blockHeight, r.blockHash)) continue; it->amounts.erase(r.address); if (it->amounts.empty() || PayoutFullyPaid(*it)) @@ -1443,7 +1499,9 @@ static void PayoutThreadFn(void*) { std::lock_guard lk(gPayoutLedgerMutex); for (const PendingPayout& pp : gPendingPayouts) - if (pp.blockHash == r.blockHash && pp.paidTxids.count(r.address)) { + if (SamePayoutRound(pp.blockHeight, pp.blockHash, + r.blockHeight, r.blockHash) + && pp.paidTxids.count(r.address)) { alreadyPaid = true; break; } -- cgit v1.2.3 From 489be141ee3be1c355650e1429c3f830eb8e442d Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:38:39 +0000 Subject: payout: one in-flight record per payment, and give it a real identity A restart duplicated every in-flight payment, deterministically, and the duplicates were load-bearing. gQueuedPayouts is memory-only. ReconcilePayouts restored gPaying from disk but left that set empty, and FlushMaturePayouts deduped only against it and against the ledger's paidTxids -- never against gPaying itself. So a round that had been exploded but not yet paid got a SECOND PayingRecord for every unpaid recipient on the first flush after every restart. Another restart added a third. The crash test saw exactly that: two byte-identical records for one (block, address). ForgetPayment and ReschedulePayment then found their record with SamePayment(), a field-by-field comparison, which stops being an identity the moment two records are equal. Every update landed in the first slot and the second was never touched: - the duplicate's retryAfter stayed 0, so it ran in every 500 ms batch instead of once a minute; - each pass wrote retries = 0+1 into the FIRST slot, resetting its count, so the 1440-retry budget never completed and forfeit was unreachable; - the duplicate's fProbeWallet was never cleared, so FindRecordedPoolPayout -- a full mapWallet scan under cs_mapWallet -- ran twice a second per stuck recipient, forever, along with GetBalance() and, whenever funds allowed, SendMoney/CreateTransaction under cs_main. That is a permanent hot loop holding cs_mapWallet and cs_main on a pool node, starving RebuildJob() so job refresh and block submission stall. It is reached by the ordinary state of a payment waiting for coin or for an unlocked wallet. It did not double-pay only because a slot can reach SendMoney unprobed only when fProbeWallet is already false, and any successful payment calls ForgetPayment, which erases exactly the one slot that could be in that state. That invariant is emergent, undocumented, and dies the moment anyone erases by index or gives the record a stable identity -- which is precisely what this commit does, so it is replaced by a structural one. Both halves are fixed. Not creating duplicates: - ReconcilePayouts rebuilds gQueuedPayouts from the restored gPaying, so the first flush after a restart no longer re-explodes rounds already in flight; - FlushMaturePayouts additionally refuses to append a record for a (round, address) that gPaying already holds. That check is the invariant proper -- the round-level set is an optimisation that can be stale, gPaying is the list being protected; - LoadPaying collapses duplicates already on disk as it reads them, merging the retry and deferral counts upwards, so a file written by the broken build does not inherit the pathology into the fixed one. Real identity: PayingRecord gains an id, assigned under gPayoutExecMutex, and ForgetPayment/ReschedulePayment match on it instead of on values. SamePayment is deleted. The id is process-local and deliberately not persisted -- gPaying is rebuilt from disk at every start, so it only has to name a slot within one run, and reading ids back would mean trusting a file to contain no duplicates. The exactly-once property is now the conjunction of four checks that each hold on their own, rather than one accident: 1. at most one gPaying record per (round, address), enforced at insert; 2. the ledger's paidTxids, re-checked at the top of every attempt; 3. the wallet probe, once per record, before its first send; 4. state updates addressed by record id, so no slot can be skipped. No on-disk format change: the id is not written, and duplicate records in an existing file are read and collapsed. --- src/rpc.cpp | 118 +++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 97 insertions(+), 21 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index b6b26f1..7badb7c 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -680,6 +680,18 @@ static void LoadPendingPayouts() // and reconciling against the wallet on startup, is what makes a restart // survivable. struct PayingRecord { + // Identity of this SLOT, distinct from the identity of the payment it + // describes. ForgetPayment and ReschedulePayment used to find their record + // by comparing every field, which is only an identity while no two records + // can be equal -- and a restart used to produce byte-identical duplicates + // (see FlushMaturePayouts), so the first match absorbed every update and + // the second was never touched again. + // + // Process-local and deliberately not persisted: gPaying is rebuilt from + // disk at every start, so an id only ever has to name a slot within one + // run, and reading ids back would mean trusting a file to contain no + // duplicates. Assigned under gPayoutExecMutex from gPayingNextId. + uint64 id = 0; int blockHeight = 0; uint256 blockHash = 0; // zero = predates hash keying, see PendingPayout int matureAtHeight = 0; @@ -702,11 +714,37 @@ struct PayingRecord { }; static std::vector gPaying; // guarded by gPayoutExecMutex static std::mutex gPayoutExecMutex; +static uint64 gPayingNextId = 0; // guarded by gPayoutExecMutex static uint64 gPayingSeq = 0; // guarded by gPayoutExecMutex static std::mutex gPayingFileMutex; static uint64 gPayingPublishedSeq = 0; // guarded by gPayingFileMutex static std::atomic gPayoutThreadRunning{false}; +// Upstream's gPayoutExecQueue/gPayoutExecMutex/gPayoutThreadRunning are +// declared above with gPaying: the in-flight list IS the exec queue in this +// tree, at per-recipient rather than per-round granularity, so a second queue +// would be a second answer to "who is owed money". Only the dedupe set is new. +// +// Ids of rounds currently exploded into gPaying, so a flush cannot queue the +// same round twice. Guarded by gPayoutExecMutex. Seeded from the restored +// in-flight list by ReconcilePayouts -- it is memory-only, and leaving it +// empty at startup was half of why a restart duplicated everything in flight. +static std::set gQueuedPayouts; + +// Is this exact payment already in flight? gPaying is keyed on +// (round, address): the ledger holds one amount per address per round, so two +// records for that pair are two standing orders to send the same money. +// Caller must hold gPayoutExecMutex. +static bool PaymentInFlightLocked(int blockHeight, const uint256& blockHash, + const std::string& address) +{ + for (const PayingRecord& e : gPaying) + if (e.address == address + && SamePayoutRound(e.blockHeight, e.blockHash, blockHeight, blockHash)) + return true; + return false; +} + static std::string PayingFilePath() { return GetAppDir() + "/paying_payouts.json"; @@ -774,6 +812,30 @@ static void LoadPaying() r.retries = obj["retries"].get(); if (obj.contains("deferrals") && obj["deferrals"].is_number()) r.deferrals = obj["deferrals"].get(); + // Collapse duplicates as they are read. A build without the + // ReconcilePayouts seeding below wrote a second (and after another + // restart, a third) byte-identical record for every unpaid + // recipient, so a file written by one of those has to be cleaned + // up here or the pathology is inherited. The retry and deferral + // counts are merged upwards so collapsing does not hand the + // survivor a fresh budget. + PayingRecord* dup = NULL; + for (PayingRecord& e : gPaying) + if (e.address == r.address + && SamePayoutRound(e.blockHeight, e.blockHash, + r.blockHeight, r.blockHash)) { + dup = &e; + break; + } + if (dup) { + dup->retries = std::max(dup->retries, r.retries); + dup->deferrals = std::max(dup->deferrals, r.deferrals); + LogPrint("payout", "[payout] collapsing duplicate in-flight" + " record for %s to %s -- one payment, one record\n", + FormatMoney(r.amount).c_str(), r.address.c_str()); + continue; + } + r.id = ++gPayingNextId; gPaying.push_back(r); } LogPrint("payout", "[payout] loaded %zu in-flight payment(s) from disk\n", @@ -962,6 +1024,15 @@ static std::string PayoutComment(int blockHeight) // What is left is the useful part: the in-flight list restored from disk may // contain records the ledger already shows as paid (we died between the send // and the in-flight file being rewritten). The ledger wins. +// +// And the part that was missing: gQueuedPayouts is memory-only, so it came up +// empty while gPaying came up full. FlushMaturePayouts deduped against that +// empty set and against the ledger's paidTxids, and against nothing else -- +// so a round that was exploded but not yet paid got a SECOND record per +// unpaid recipient on the first flush after every restart, deterministically. +// (Observed live: two byte-identical records for one (block, address).) The +// surviving in-flight list is exactly the statement of which rounds are +// already exploded, so it is what the set is rebuilt from. static void ReconcilePayouts() { VersionedBlob payingSnap; @@ -997,6 +1068,13 @@ static void ReconcilePayouts() " already recorded paid in the ledger -- not resending\n", settled, FormatMoney(settledAmount).c_str()); } + + // Rebuild the "already exploded" set from what survived. Without this the + // first flush after a restart re-explodes every round still in flight. + gQueuedPayouts.clear(); + for (const PayingRecord& r : gPaying) + gQueuedPayouts.insert(PayoutIdForBlock(r.blockHeight, r.blockHash)); + payingSnap = SerializePayingLocked(); if (!gPaying.empty()) { @@ -1054,15 +1132,6 @@ static void MarkPoolRoundPaid(const std::string& id, PublishPoolRounds(snap); } -// Upstream's gPayoutExecQueue/gPayoutExecMutex/gPayoutThreadRunning are -// declared above with gPaying: the in-flight list IS the exec queue in this -// tree, at per-recipient rather than per-round granularity, so a second queue -// would be a second answer to "who is owed money". Only the dedupe set is new. -// -// Ids of rounds currently exploded into gPaying, so a flush cannot queue the -// same round twice. Guarded by gPayoutExecMutex. -static std::set gQueuedPayouts; - static bool FindRecordedPoolPayout(const std::string& id, const std::string& address, int64 amount, @@ -1311,7 +1380,17 @@ static void FlushMaturePayouts() // txid is done, even though the entry as a whole is not. if (pp.paidTxids.count(kv.first)) continue; + // The invariant, checked against the thing it is an invariant of: + // at most one in-flight record per (round, address). The round-level + // set above is an optimisation that can be wrong -- it is memory + // only, it is rebuilt from gPaying at startup, and a stale `ready` + // snapshot can race a round finishing -- whereas gPaying is the + // list being protected. A second record here is a second standing + // order to send the same money. + if (PaymentInFlightLocked(pp.blockHeight, pp.blockHash, kv.first)) + continue; PayingRecord r; + r.id = ++gPayingNextId; r.blockHeight = pp.blockHeight; r.blockHash = pp.blockHash; r.matureAtHeight = pp.matureAtHeight; @@ -1331,27 +1410,24 @@ static void FlushMaturePayouts() PublishPaying(payingSnap); } -// Identity of a payment, for finding its record again after SendMoney. -// amounts is a map keyed by address, so (block, address) is already unique. -static bool SamePayment(const PayingRecord& a, const PayingRecord& b) -{ - return SamePayoutRound(a.blockHeight, a.blockHash, b.blockHeight, b.blockHash) - && a.matureAtHeight == b.matureAtHeight - && a.address == b.address - && a.amount == b.amount; -} - // Settle a payment: it is either done or abandoned, either way it stops being // in flight. Note this only retires the working record -- whether the miner is // still OWED the money is the ledger's business, and the ledger is updated // separately by RecordPayoutTxid(). +// +// Erases by record id, not by comparing fields. Value-matching was only an +// identity for as long as no two records could be equal, and the restart bug +// above produced records that were equal in every field: the first match then +// absorbed every erase and every state update, and the second was never +// touched again. The id names the slot the caller took its copy from, so the +// update lands where it belongs whatever else is in the list. static void ForgetPayment(const PayingRecord& r) { VersionedBlob snap; { std::lock_guard lk(gPayoutExecMutex); for (auto it = gPaying.begin(); it != gPaying.end(); ++it) - if (SamePayment(*it, r)) { gPaying.erase(it); break; } + if (it->id == r.id) { gPaying.erase(it); break; } // When the last record for a round is gone the round is no longer in // flight, so a later flush may explode it again if the ledger still @@ -1379,7 +1455,7 @@ static void ReschedulePayment(const PayingRecord& r) { std::lock_guard lk(gPayoutExecMutex); for (auto& e : gPaying) - if (SamePayment(e, r)) { + if (e.id == r.id) { e.retries = r.retries; e.deferrals = r.deferrals; e.retryAfter = r.retryAfter; -- cgit v1.2.3 From c4754fb505be75b8eedee3ce230a8991762e18c5 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:40:33 +0000 Subject: payout: recognise a payment a pre-upgrade build already made FindRecordedPoolPayout matches on pool_payout_id / pool_payout_address / pool_payout_amount. That metadata is written only by this build. Every payout the pool has actually sent, in the wallet right now, carries nothing but a "comment". So the first boot after this upgrade is the dangerous one: swap the binary after an unclean shutdown with payments in flight, and the recovery probe finds no evidence for any of them and pays every one of those recipients a second time. The deleted WalletAlreadyPaid() matched on what the payment WAS -- the comment plus an output paying exactly that amount to exactly that script -- and that evidence is still sitting in the wallet. It just stopped being read. The probe now answers with which kind of evidence it found, and the legacy form is restored as a fallback under three constraints that keep it from answering for a round that is not the one being asked about: 1. it is consulted only when the structured lookup found nothing, so the exact path stays primary and strictly better going forward; 2. any transaction carrying pool_payout_id is excluded from it outright. That key means this build wrote the transaction, and this build's transactions are adjudicated exactly -- so a newer round's payment can never be offered as proof for an older one. Only pre-upgrade transactions, which is the entire population the fallback exists for, are eligible; 3. a txid already credited to a DIFFERENT (round, address) is excluded -- from the ledger's paidTxids, which is durable across restarts, and from what this process has already claimed. One legacy transaction therefore settles at most one debt, so N transactions can never discharge more than N obligations. Two debts with one transaction pays the second, which is the correct reading of the evidence. What cannot be resolved from a comment alone is two PRE-UPGRADE rounds at the same height paying the same address the same amount, because the comment carries only the height. That requires a reorg, and the losing round's ledger entry is voided by the orphan check before it can be paid, so the two cannot both be live. Where the evidence genuinely runs out the fallback does not match and the payment is made: paying a debt that cannot be proven settled is the correct default for a pool, and it is the direction that does not keep a miner's money. Recovery on legacy evidence logs loudly and says exactly what it matched on, because it is a judgement made about a binary that is no longer running. The two lookups share one pass over mapWallet. The structured path used to scan alone; adding a second scan would have doubled a linear walk taken under cs_mapWallet, which the whole node contends on. No on-disk format change. --- src/rpc.cpp | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 18 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 7badb7c..b71457c 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1132,11 +1132,71 @@ static void MarkPoolRoundPaid(const std::string& id, PublishPoolRounds(snap); } -static bool FindRecordedPoolPayout(const std::string& id, - const std::string& address, - int64 amount, - std::string& txidRet) +// Which wallet transaction, if any, already paid this? +// +// Two kinds of evidence, in order of quality. +// +// Structured (pool_payout_id / pool_payout_address / pool_payout_amount) is +// upstream's, and it is exact: the id is derived from the block hash, so it +// names one round and one round only. Every transaction this build sends +// carries it. +// +// But NO transaction this pool has ever sent carries it. Every payout in the +// existing wallet has only a "comment". So on the first boot after this +// upgrade -- precisely the boot where the binary is swapped after an unclean +// shutdown with payments in flight -- a structured-only probe finds nothing +// and every one of those recipients is paid a second time. The deleted +// WalletAlreadyPaid() matched on what the payment WAS, and that evidence is +// still there; it just has to be read. +// +// The legacy fallback is therefore restored, with three constraints that +// together stop it answering for a round that is not this one: +// +// 1. it is only consulted when the structured lookup found nothing; +// 2. it ignores any transaction carrying pool_payout_id. That key means this +// build wrote the transaction, and this build's transactions are +// adjudicated by the exact path -- so a NEW round's payment can never be +// offered as evidence for an old one. Only pre-upgrade transactions, +// which is exactly the population the fallback exists for, are eligible; +// 3. `excluded` holds every txid already credited to some OTHER +// (round, address) pair -- from the ledger's paidTxids, which is durable, +// and from what this process has already claimed. One legacy transaction +// can therefore settle at most one debt, so N transactions can never +// satisfy more than N obligations. +// +// What is left unresolved by construction is two pre-upgrade rounds at the +// SAME height paying the same address the same amount, since the comment +// carries only the height. That needs a reorg, and the losing round's ledger +// entry is voided by the orphan check before it can be paid, so the pair +// cannot both be live. Where the evidence genuinely runs out the fallback +// simply does not match, and the payment is made -- paying a debt we cannot +// prove was settled is the correct default for a pool. +enum PayoutProbeResult { + PAYOUT_PROBE_NONE = 0, // the wallet holds no record of this payment + PAYOUT_PROBE_STRUCTURED, // matched this build's pool_payout_* metadata + PAYOUT_PROBE_LEGACY // matched a pre-upgrade comment and output +}; + +static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, + int blockHeight, + const std::string& address, + int64 amount, + const std::set& excluded, + std::string& txidRet) { + // Built before cs_mapWallet is taken: an invalid address has no script to + // compare against, and the caller has already rejected those. + uint160 h160; + bool fHaveScript = AddressToHash160(address, h160); + CScript want; + if (fHaveScript) + want << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + const std::string comment = PayoutComment(blockHeight); + + std::string legacyTxid; + + // One pass, not two: this scan runs under cs_mapWallet, which the whole + // node contends on, and the merge is already paying for it once per record. CRITICAL_BLOCK(cs_mapWallet) { for (map::const_iterator it = mapWallet.begin(); @@ -1144,19 +1204,85 @@ static bool FindRecordedPoolPayout(const std::string& id, { const CWalletTx& wtx = it->second; map::const_iterator idIt = wtx.mapValue.find("pool_payout_id"); - if (idIt == wtx.mapValue.end() || idIt->second != id) - continue; - map::const_iterator addrIt = wtx.mapValue.find("pool_payout_address"); - if (addrIt == wtx.mapValue.end() || addrIt->second != address) + if (idIt != wtx.mapValue.end()) { + // Written by this build. Adjudicated exactly, and never + // offered to the fallback below. + if (idIt->second != id) + continue; + map::const_iterator addrIt = wtx.mapValue.find("pool_payout_address"); + if (addrIt == wtx.mapValue.end() || addrIt->second != address) + continue; + map::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount"); + if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount)) + continue; + txidRet = wtx.GetHash().GetHex(); + return PAYOUT_PROBE_STRUCTURED; + } + + if (!fHaveScript || !legacyTxid.empty()) continue; - map::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount"); - if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount)) + // The comment is written only by the payout path, so its presence + // already identifies the transaction as one of ours. + map::const_iterator ci = wtx.mapValue.find("comment"); + if (ci == wtx.mapValue.end() || ci->second != comment) continue; - txidRet = wtx.GetHash().GetHex(); - return true; + std::string txid = wtx.GetHash().GetHex(); + if (excluded.count(txid)) + continue; // already credited to a different debt + foreach (const CTxOut& txout, wtx.vout) + if (txout.nValue == amount && txout.scriptPubKey == want) { + legacyTxid = txid; + break; + } } } - return false; + + if (!legacyTxid.empty()) { + txidRet = legacyTxid; + return PAYOUT_PROBE_LEGACY; + } + return PAYOUT_PROBE_NONE; +} + +// txid -> "|
", for payments this run has already credited +// to a debt on legacy evidence. Stops one pre-upgrade transaction being read +// as proof that two different obligations were met. The ledger's paidTxids +// covers the same ground durably; this covers the window before the recovered +// txid reaches the ledger, and the case where the ledger entry has already +// been retired. +static std::map gLegacyClaimedTxids; +static std::mutex gClaimedTxidMutex; + +// Every txid already spoken for by some payment OTHER than this one. Collected +// before cs_mapWallet is taken: the ledger mutex must not be acquired under a +// wallet lock. +static void CollectForeignPayoutTxids(int blockHeight, const uint256& blockHash, + const std::string& address, + const std::string& claimKey, + std::set& out) +{ + { + std::lock_guard lk(gPayoutLedgerMutex); + for (const PendingPayout& pp : gPendingPayouts) { + bool sameRound = SamePayoutRound(pp.blockHeight, pp.blockHash, + blockHeight, blockHash); + for (const auto& kv : pp.paidTxids) + if (!(sameRound && kv.first == address)) + out.insert(kv.second); + } + } + { + std::lock_guard lk(gClaimedTxidMutex); + for (const auto& kv : gLegacyClaimedTxids) + if (kv.second != claimKey) + out.insert(kv.first); + } +} + +static void ClaimLegacyPayoutTxid(const std::string& txid, const std::string& claimKey) +{ + std::lock_guard lk(gClaimedTxidMutex); + gLegacyClaimedTxids[txid] = claimKey; } // blockHash stays a uint256 and not upstream's display string: it is the key @@ -1603,12 +1729,32 @@ static void PayoutThreadFn(void*) // Only asked once per record -- see PayingRecord::fProbeWallet. if (r.fProbeWallet) { std::string recovered; + const std::string claimKey = id + "|" + r.address; + std::set excluded; + CollectForeignPayoutTxids(r.blockHeight, r.blockHash, r.address, + claimKey, excluded); r.fProbeWallet = false; - if (FindRecordedPoolPayout(id, r.address, r.amount, recovered)) { - LogPrint("payout", "[payout] recovered recorded payout %s to %s" - " from wallet tx %s -- not resending\n", - FormatMoney(r.amount).c_str(), r.address.c_str(), - recovered.c_str()); + PayoutProbeResult probe = FindRecordedPoolPayout( + id, r.blockHeight, r.address, r.amount, excluded, recovered); + if (probe != PAYOUT_PROBE_NONE) { + if (probe == PAYOUT_PROBE_LEGACY) { + // Loud on purpose: this is the upgrade path, where the + // only evidence a payment went out is a comment written + // by a binary that is no longer running. + LogPrint("payout", "[payout] %s to %s was already paid by" + " a pre-upgrade build (wallet tx %s carries the" + " payout comment for height %d and an output of" + " exactly this amount, and no structured payout" + " metadata) -- not resending\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + recovered.c_str(), r.blockHeight); + ClaimLegacyPayoutTxid(recovered, claimKey); + } else { + LogPrint("payout", "[payout] recovered recorded payout %s to %s" + " from wallet tx %s -- not resending\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + recovered.c_str()); + } RecordPayoutTxid(r, recovered); ForgetPayment(r); continue; -- cgit v1.2.3 From 5892302449e1299f39812d0bb422c4184224b0f1 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:43:20 +0000 Subject: payout: do not write off a debt the wallet is only temporarily unable to pay ForfeitPayoutRecipient permanently erases a miner's money: the amount leaves the ledger, the operator keeps the coin, and nothing on disk records that it was ever owed. It was reachable from two entirely ordinary conditions. The deferral gate compared GetBalance() against r.amount, ignoring the fee. A balance anywhere between amount and amount+fee therefore passed the gate, failed inside SendMoney, and was counted as a send FAILURE -- a retry. At one a minute, 1440 of those is 24 hours, and then the recipient is gone. A wallet left locked overnight did exactly the same thing: every attempt fails because nothing can be signed, and each failure spent budget. Three changes. The gate now requires amount + nTransactionFee, which is the part of the fee knowable before the transaction exists. It deliberately does not try to reserve GetMinFee(), which depends on the serialised size and so on how many inputs SelectCoins happens to pick: refusing to attempt a payment the wallet could actually have made is its own way of not paying a miner. A locked wallet is now a deferral at the same gate, for the same reason maturity is. Failures are classified after the fact instead of all counting as faults. The balance and lock state are re-read after SendMoney returns -- it holds cs_main and can block for a long time, so both may have moved -- and if the wallet is locked, or the balance no longer covers amount + fee + one fee tier of headroom, the attempt is recorded as a deferral. That headroom is where the size-dependent part of the fee lands, so a fee shortfall is a wait rather than a fault, which is what it is. The retry budget therefore now only counts failures that happened while the payment was funded, the wallet was unlocked, and the funding block was on the main chain. Reaching 1440 of those means the wallet refuses a payment it has every means to make, which is the only state in which the payment can be called impossible rather than merely blocked. The give-up log says which conditions held, rather than implying it. Finally, the forfeit is reconciled with the public record. Dropping the amount from the ledger while pool_rounds.json goes on advertising the address as owed with "txid": null left the pool's two accounts of the same round permanently disagreeing, with the operator holding the difference. PoolRoundProof gains a per-address forfeited/forfeitReason, written by MarkPoolRoundForfeited when the ledger writes the debt off, and pool_status.json counts those separately from genuinely unpaid ones instead of reporting them as still pending. On-disk: pool_rounds.json gains optional "forfeited" and "forfeitReason" keys inside a payout object, and pool_status.json gains "forfeitedPayouts" per round. Both are emitted only when a forfeit has actually happened, both are read back with defaults, and files written by any earlier build load unchanged. --- src/rpc.cpp | 214 +++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 175 insertions(+), 39 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index b71457c..6f6d101 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -386,6 +386,12 @@ struct PoolRoundProof { std::map shares; std::map payouts; std::map paidTxids; + // Recipients the ledger has written off, and why. Without this the public + // round proof goes on advertising an address as owed with "txid": null + // forever, while the ledger has already dropped the obligation -- the two + // records of the same round permanently disagreeing, with the operator + // holding the money. + std::map forfeited; // address -> reason PoolRoundProof() : blockHeight(0), matureAtHeight(0), createdAt(0), feePercent(0.0), @@ -890,6 +896,13 @@ static nlohmann::json PoolRoundToJson(const PoolRoundProof& r) p["txid"] = tx->second; else p["txid"] = nullptr; + // Only emitted when it happened, so the common shape of the file is + // unchanged for anything already parsing it. + std::map::const_iterator fo = r.forfeited.find(kv.first); + if (fo != r.forfeited.end()) { + p["forfeited"] = true; + p["forfeitReason"] = fo->second; + } j["payouts"][kv.first] = p; } return j; @@ -966,6 +979,9 @@ static void LoadPoolRounds() r.payouts[it.key()] = it.value().value("amountSatoshis", (int64)0); if (it.value().contains("txid") && it.value()["txid"].is_string()) r.paidTxids[it.key()] = it.value()["txid"].get(); + if (it.value().value("forfeited", false)) + r.forfeited[it.key()] = + it.value().value("forfeitReason", std::string("unspecified")); } else if (it.value().is_number_integer()) { r.payouts[it.key()] = it.value().get(); } @@ -1132,6 +1148,33 @@ static void MarkPoolRoundPaid(const std::string& id, PublishPoolRounds(snap); } +// Publish the fact that a recipient was written off, so the round proof and +// the ledger agree. The ledger erases the amount (it is not owed any more and +// must not be reported as sent); the proof keeps the amount visible and marks +// it forfeited with a reason, because a miner who was promised a share is +// entitled to see what happened to it. +static void MarkPoolRoundForfeited(const std::string& id, + const std::string& address, + const std::string& reason) +{ + VersionedBlob snap; + { + std::lock_guard lk(gPoolRoundsMutex); + bool found = false; + for (PoolRoundProof& r : gPoolRounds) { + if (r.id != id) + continue; + r.forfeited[address] = reason; + found = true; + break; + } + if (!found) + return; + snap = SerializePoolRoundsLocked(); + } + PublishPoolRounds(snap); +} + // Which wallet transaction, if any, already paid this? // // Two kinds of evidence, in order of quality. @@ -1634,14 +1677,21 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) LogPrint("payout", "[payout] block %d fully settled\n", r.blockHeight); } -// Write a recipient off. Only reached after the full retry budget against a -// block that is still on the main chain with coin available to spend, i.e. a -// wallet fault rather than a maturity or reorg question. +// Write a recipient off. Only reached after a full budget of failures that +// were NOT the wallet being locked or short of coin -- see the classification +// in PayoutThreadFn -- against a block still on the main chain. // // The amount is removed from the entry rather than marked paid, so the money -// is not reported as sent. Once nothing is left owed the entry retires. -static void ForfeitPayoutRecipient(const PayingRecord& r) +// is not reported as sent. Once nothing is left owed the entry retires. The +// round proof is told separately, because dropping the obligation from the +// ledger while pool_rounds.json goes on publishing the address as owed with +// "txid": null leaves the pool's two records of the same round permanently +// disagreeing. +static void ForfeitPayoutRecipient(const PayingRecord& r, const std::string& reason) { + LogPrint("payout", "[payout] writing off %s owed to %s for block %d: %s\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), r.blockHeight, + reason.c_str()); VersionedBlob blob; { std::lock_guard lk(gPayoutLedgerMutex); @@ -1657,6 +1707,8 @@ static void ForfeitPayoutRecipient(const PayingRecord& r) blob = SerializePendingPayoutsLocked(); } PublishPendingPayouts(blob); + MarkPoolRoundForfeited(PayoutIdForBlock(r.blockHeight, r.blockHash), + r.address, reason); } // Payout execution thread: works through gPaying, calls SendMoney. @@ -1766,17 +1818,51 @@ static void PayoutThreadFn(void*) // payment the pool fully intends to honour can hit GIVING UP // 24h later purely because the subsidy funding it had not // matured yet -- the miner is then silently never paid. + // + // The comparison includes the fee. It used to be against the + // amount alone, so a balance anywhere between amount and + // amount+fee sailed past this gate, failed in SendMoney, and was + // counted as a send FAILURE rather than a wait -- 1440 of those is + // 24 hours, and the recipient was erased with the money left in + // the operator's wallet and nothing on disk saying it was owed. + // + // nTransactionFee is the part of the fee that can be known before + // the transaction exists. The rest is GetMinFee(), a function of + // the serialised size and therefore of how many inputs SelectCoins + // ends up picking, which cannot be predicted from here -- so this + // gate deliberately does NOT try to reserve it. Refusing to attempt + // a payment the wallet could actually have made would be its own + // way of not paying a miner. That residual is handled after the + // fact, by classifying the failure below. int64 nSpendable = GetBalance(); - if (nSpendable < r.amount) { + if (nSpendable < r.amount + nTransactionFee) { r.deferrals++; r.retryAfter = GetTime() + 60; // Hourly, not every minute: a dry pool would otherwise // write one line per payment per minute forever. if (r.deferrals == 1 || r.deferrals % 60 == 0) LogPrint("payout", "[payout] deferring %s to %s -- only %s" - " spendable (waiting %d min so far)\n", + " spendable, need %s with fee (waiting %d min so far)\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + FormatMoney(nSpendable).c_str(), + FormatMoney(r.amount + nTransactionFee).c_str(), + r.deferrals); + ReschedulePayment(r); + continue; + } + + // Likewise a locked wallet: it cannot sign, so every attempt fails + // for a reason that has nothing to do with this payment being + // payable. Left to the retry budget, an operator who locks the + // wallet overnight comes back to erased debts. + if (IsWalletLocked()) { + r.deferrals++; + r.retryAfter = GetTime() + 60; + if (r.deferrals == 1 || r.deferrals % 60 == 0) + LogPrint("payout", "[payout] deferring %s to %s -- wallet is" + " locked (waiting %d min so far)\n", FormatMoney(r.amount).c_str(), r.address.c_str(), - FormatMoney(nSpendable).c_str(), r.deferrals); + r.deferrals); ReschedulePayment(r); continue; } @@ -1803,36 +1889,76 @@ static void PayoutThreadFn(void*) LogPrint("payout", "[payout] paid %s to %s (tx %s)\n", FormatMoney(r.amount).c_str(), r.address.c_str(), wtx.GetHash().ToString().substr(0, 12).c_str()); - } else if (r.retries < 1440) { - // Transient failures (reorg, locked wallet) must not silently - // burn the payment -- retry with backoff. Insufficient funds - // never lands here; it defers above. - r.retries++; - r.retryAfter = GetTime() + 60; - ReschedulePayment(r); - LogPrint("payout", "[payout] FAILED to pay %s to %s" - " (%s spendable at the time) -- retry %d/1440 in 60s\n", - FormatMoney(r.amount).c_str(), r.address.c_str(), - FormatMoney(nSpendable).c_str(), r.retries); } else { - // Reaching here means 1440 attempts that were NOT - // insufficient-funds waits (those defer instead) against a - // block still on the main chain -- i.e. a real wallet fault, - // not a maturity or reorg question. - LogPrint("payout", "[payout] GIVING UP on %s to %s after %d" - " retries -- block %d is still on-chain and %s was" - " spendable, so this is a wallet fault, not maturity" - " -- payment forfeited\n", - FormatMoney(r.amount).c_str(), r.address.c_str(), - r.retries, r.blockHeight, - FormatMoney(nSpendable).c_str()); - // Write the debt off in the ledger as well as dropping the - // in-flight record. Under the survives-until-fully-paid model - // the entry would otherwise never complete, and every later - // flush would explode it again -- a forfeit that silently - // becomes an infinite retry. - ForfeitPayoutRecipient(r); - ForgetPayment(r); + // Why did it fail? The answer decides whether this costs a + // retry, and the retry budget is what eventually erases a + // miner's money -- so guessing here is how a pool keeps coin + // it owes. + // + // Re-read the conditions rather than trusting the ones checked + // before the attempt: SendMoney takes cs_main and can block for + // a long time, so the balance and the lock state may both have + // moved underneath it. + // + // Short of coin covers the size-dependent part of the fee that + // the gate above deliberately does not reserve: if the balance + // no longer covers amount + configured fee + one fee tier, the + // failure is a funding shortfall, not a fault. + int64 nAfter = GetBalance(); + bool fLocked = IsWalletLocked(); + bool fUnfunded = nAfter < r.amount + nTransactionFee + CENT; + + if (fLocked || fUnfunded) { + // Conditions are temporarily wrong. Wait; do not spend the + // budget that leads to a permanent write-off. + r.deferrals++; + r.retryAfter = GetTime() + 60; + if (r.deferrals == 1 || r.deferrals % 60 == 0) + LogPrint("payout", "[payout] deferring %s to %s -- send" + " failed with %s (%s spendable, %s needed with" + " fee headroom); waiting %d min so far\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + fLocked ? "the wallet locked" : "too little coin", + FormatMoney(nAfter).c_str(), + FormatMoney(r.amount + nTransactionFee + CENT).c_str(), + r.deferrals); + ReschedulePayment(r); + } else if (r.retries < 1440) { + // A genuine fault: funded, unlocked, block on the main + // chain, and the wallet still would not build or accept + // the transaction. Retry with backoff. + r.retries++; + r.retryAfter = GetTime() + 60; + ReschedulePayment(r); + LogPrint("payout", "[payout] FAILED to pay %s to %s" + " (%s spendable, wallet unlocked) -- retry %d/1440" + " in 60s\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + FormatMoney(nAfter).c_str(), r.retries); + } else { + // 1440 failures that were each individually impossible to + // explain by maturity, funding, the wallet being locked, or + // a reorg. That is the only state in which this payment can + // be called impossible rather than merely blocked. + const std::string reason = strprintf( + "wallet refused this payment %d times while funded" + " (%s spendable), unlocked, and block %d on the main" + " chain", r.retries, FormatMoney(nAfter).c_str(), + r.blockHeight); + LogPrint("payout", "[payout] GIVING UP on %s to %s: %s" + " -- payment forfeited\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + reason.c_str()); + // Write the debt off in the ledger as well as dropping the + // in-flight record. Under the survives-until-fully-paid + // model the entry would otherwise never complete, and every + // later flush would explode it again -- a forfeit that + // silently becomes an infinite retry. The round proof is + // told too, so the public record does not keep claiming the + // address is owed. + ForfeitPayoutRecipient(r, reason); + ForgetPayment(r); + } } } @@ -1942,10 +2068,19 @@ static void WritePoolStatusJsonLocked() size_t start = gPoolRounds.size() > 10 ? gPoolRounds.size() - 10 : 0; for (size_t i = start; i < gPoolRounds.size(); i++) { const PoolRoundProof& r = gPoolRounds[i]; + // A forfeited recipient is not "unpaid and pending" -- the ledger + // has already dropped the obligation. Counting it as unpaid is + // what made the round proof and the ledger disagree for good. int unpaid = 0; - for (const auto& kv : r.payouts) - if (r.paidTxids.count(kv.first) == 0) + int forfeited = 0; + for (const auto& kv : r.payouts) { + if (r.paidTxids.count(kv.first)) + continue; + if (r.forfeited.count(kv.first)) + forfeited++; + else unpaid++; + } j["recentRounds"].push_back({ {"id", r.id}, {"blockHeight", r.blockHeight}, @@ -1954,6 +2089,7 @@ static void WritePoolStatusJsonLocked() {"totalShares", r.totalShares}, {"payouts", (int)r.payouts.size()}, {"unpaidPayouts", unpaid}, + {"forfeitedPayouts", forfeited}, {"coinbaseValueBtf", FormatMoney(r.coinbaseValue)}, {"minerRewardBtf", FormatMoney(r.minerReward)} }); -- cgit v1.2.3 From f8c0b6d9b477739e0699c4972adba68c5ea20000 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:44:48 +0000 Subject: payout: keep one retry clock, the one that is used, and persist it There were two, and each was broken in the opposite direction. PendingPayout::retryAfter was declared, serialised into pending_payouts.json, loaded back, and tested at the flush gate -- and the only assignment to it anywhere in the tree was `= 0`. The gate could never fire. PendingPayout's `retries` and `deferrals` were the same: carried, never written. Dead fields next to live ones in a structure this one is easy to reach for by mistake, and a gate that looks like backoff but is not is worse than no gate at all. PayingRecord::retryAfter is the clock the payout thread actually uses -- and SavePayingLocked did not write it. Every restart therefore reset every backing-off payment to "ready now" and put the entire in-flight set into the very next 500 ms batch instead of once a minute. On a node restarting in a loop that is a continuous retry storm against a wallet that has already said no, each pass taking cs_main. The dead copy is deleted, along with the flush gate that read it and the one `retryAfter = 0` in RecordPayoutTxid. The live one is now persisted and restored. Retry state stays where the failure happens -- per (round, recipient), which is the granularity SendMoney works at. On disk, both directions still read: - pending_payouts.json no longer has "retryAfter" written into it, and no longer reads one. Files that carry it (everything the merged build wrote, always as 0) load unchanged, the key simply ignored. A build that expects it defaults an absent key to 0, which is the only value it has ever held. - paying_payouts.json gains "retryAfter". Older builds ignore unknown keys; this build defaults an absent one to 0, which is exactly the behaviour a file written without it used to get. --- src/rpc.cpp | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 6f6d101..d655ad5 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -352,11 +352,13 @@ struct PendingPayout { // lets an entry stay on disk until every address in `amounts` is paid, // instead of being erased the moment the batch is handed off. std::map paidTxids; // address -> txid - int retries = 0; // exec-thread only: SendMoney attempts so far - int deferrals = 0; // exec-thread only: waits for spendable coin - // Upstream's `nextAttempt` is this field under another name; ours is kept - // so the retry/deferral policy above stays coherent with it. - int64 retryAfter = 0; // exec-thread only: unix time, 0 = ready now + // Retry and backoff state does NOT live here. It used to be declared here + // as well as on PayingRecord, and the copy on this side was never written: + // `retryAfter` was declared, serialised, loaded and tested at the flush + // gate, and the only assignment to it anywhere was `= 0`, so the gate could + // not fire. `retries` and `deferrals` were the same story. The real state + // is per (round, recipient) -- which is the granularity a payment can fail + // at -- and it lives on PayingRecord, which is now what persists it. }; static std::vector gPendingPayouts; static std::mutex gPayoutLedgerMutex; @@ -597,7 +599,11 @@ static VersionedBlob SerializePendingPayoutsLocked() obj["blockHeight"] = pp.blockHeight; obj["blockHash"] = pp.blockHash.ToString(); obj["matureAtHeight"] = pp.matureAtHeight; - obj["retryAfter"] = pp.retryAfter; + // No retryAfter: it was never written with anything but zero, and the + // retry clock that is actually used is on PayingRecord. Files that + // still carry the key load fine (it is ignored), and a build that + // expects it defaults an absent one to 0, which means "ready now" -- + // the same answer it always got. nlohmann::json amounts = nlohmann::json::object(); for (const auto& kv : pp.amounts) amounts[kv.first] = kv.second; @@ -655,10 +661,9 @@ static void LoadPendingPayouts() // still COINBASE_MATURITY. if (pp.blockHeight == 0 && pp.matureAtHeight > COINBASE_MATURITY) pp.blockHeight = pp.matureAtHeight - COINBASE_MATURITY; - // Absent in every file written before this merge; default 0 means - // "ready now", which is the right answer for a restored entry. - if (obj.contains("retryAfter") && obj["retryAfter"].is_number()) - pp.retryAfter = obj["retryAfter"].get(); + // "retryAfter" is deliberately not read. Files written by the + // merged build carry it, always as 0, and there is nowhere left to + // put it -- the retry clock is PayingRecord's. for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) pp.amounts[it.key()] = it.value().get(); if (obj.contains("paidTxids") && obj["paidTxids"].is_object()) @@ -777,6 +782,11 @@ static VersionedBlob SerializePayingLocked() obj["amount"] = r.amount; obj["retries"] = r.retries; obj["deferrals"] = r.deferrals; + // The live retry clock, which used not to be written at all: every + // restart handed every backing-off payment a retryAfter of 0 and put + // the whole set into the very next 500 ms batch, so a node restarted + // in a loop retried continuously instead of once a minute. + obj["retryAfter"] = r.retryAfter; arr.push_back(obj); } VersionedBlob v; @@ -818,6 +828,10 @@ static void LoadPaying() r.retries = obj["retries"].get(); if (obj.contains("deferrals") && obj["deferrals"].is_number()) r.deferrals = obj["deferrals"].get(); + // Absent in files written before this change; 0 means ready now, + // which is exactly the behaviour those files used to get. + if (obj.contains("retryAfter") && obj["retryAfter"].is_number()) + r.retryAfter = obj["retryAfter"].get(); // Collapse duplicates as they are read. A build without the // ReconcilePayouts seeding below wrote a second (and after another // restart, a third) byte-identical record for every unpaid @@ -834,8 +848,9 @@ static void LoadPaying() break; } if (dup) { - dup->retries = std::max(dup->retries, r.retries); - dup->deferrals = std::max(dup->deferrals, r.deferrals); + dup->retries = std::max(dup->retries, r.retries); + dup->deferrals = std::max(dup->deferrals, r.deferrals); + dup->retryAfter = std::max(dup->retryAfter, r.retryAfter); LogPrint("payout", "[payout] collapsing duplicate in-flight" " record for %s to %s -- one payment, one record\n", FormatMoney(r.amount).c_str(), r.address.c_str()); @@ -1505,12 +1520,9 @@ static void FlushMaturePayouts() } if (voided) ledgerSnap = SerializePendingPayoutsLocked(); - int64 now = GetTime(); for (const PendingPayout& pp : gPendingPayouts) { if (nBestHeight < pp.matureAtHeight) continue; - if (pp.retryAfter > now) - continue; if (PayoutFullyPaid(pp)) continue; // An entry with neither a hash nor a height cannot be told apart @@ -1658,7 +1670,6 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) r.blockHeight, r.blockHash)) continue; it->paidTxids[r.address] = txid; - it->retryAfter = 0; if (PayoutFullyPaid(*it)) { LogPrint("payout", "[payout] payout for block %d complete --" " removing from ledger\n", it->blockHeight); -- cgit v1.2.3 From 86b39b952d81c8aa6db56beb293e08befdca296e Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:45:56 +0000 Subject: payout: flush the ledger to the device before the rename that publishes it WritePendingPayoutsFile, SavePayingLocked and SavePoolRoundsLocked all wrote a temp file and renamed it, with no fsync anywhere. That is atomic against the process dying -- which is what the crash test exercised -- and not against the machine dying. A power cut or a hard VM reset can land the rename on the platter ahead of the data it points at, and what comes back is a short or empty file. For pending_payouts.json that is not a corrupt ledger, it is an absent one: LoadPendingPayouts catches the parse error, logs one warning, and the node starts up owing nobody anything. Total silent loss of the unpaid ledger, from a clean-looking boot. WriteFileAtomic now fflush()es and fsync()s the temp file before renaming it, and fsync()s the containing directory afterwards so the new directory entry is durable as well. If the data cannot be made durable the rename does not happen at all: the previous file is still intact and still correct, and the next write will try again. Ordering the sync before the rename is the point -- publishing a name that points at nothing is the exact failure being prevented. The directory sync is best effort; a filesystem that will not let the directory be opened for it is not a reason to refuse to write the ledger. This is only affordable because the preceding commit moved all three writes out from under gPayoutLedgerMutex / gPayoutExecMutex / gPoolRoundsMutex. An fsync under any of those would park the pool event loop on the payout thread's disk, which is the contention the merge was right to remove. Applies to upstream too and is being reported there. No on-disk format change. --- src/rpc.cpp | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index d655ad5..45130ff 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -31,6 +31,9 @@ #include #include #include +#include +#else +#include #endif #include @@ -457,9 +460,50 @@ struct VersionedBlob { uint64 seq = 0; }; -// Replace `path` with `data` atomically. Caller must hold the write mutex for -// `path`: the temp name is derived from the target, so two threads in here at -// once for one path is precisely the race this exists to prevent. +// Push a file's contents out of the C library's buffer AND out of the kernel's +// page cache, so the bytes exist on the device before anything points at them. +static bool SyncFileHandle(FILE* f) +{ + if (fflush(f) != 0) + return false; +#ifdef _WIN32 + return _commit(_fileno(f)) == 0; +#else + return fsync(fileno(f)) == 0; +#endif +} + +// Make the directory entry that rename() created durable too. +// +// A tmp+rename is only atomic with respect to a crash of the process. Against +// a power cut or a hard VM reset the rename can reach the platter while the +// data it points at has not, and the file that comes back is short or empty. +// For pending_payouts.json that is not a corrupt file, it is a silent one: +// LoadPendingPayouts catches the parse error, logs a single warning, and the +// node comes up owing nobody anything. +// +// Best effort -- a filesystem that does not allow the directory to be opened +// for this is not a reason to refuse to write the ledger. +static void SyncDirectoryOf(const std::string& path) +{ +#ifndef _WIN32 + size_t slash = path.find_last_of('/'); + std::string dir = (slash == std::string::npos) ? std::string(".") + : path.substr(0, slash); + if (dir.empty()) + dir = "/"; + int fd = open(dir.c_str(), O_RDONLY); + if (fd < 0) + return; + fsync(fd); + close(fd); +#endif +} + +// Replace `path` with `data` atomically and durably. Caller must hold the +// write mutex for `path`: the temp name is derived from the target, so two +// threads in here at once for one path is precisely the race this exists to +// prevent. static bool WriteFileAtomic(const std::string& path, const std::string& data, const char* what) { @@ -476,6 +520,16 @@ static bool WriteFileAtomic(const std::string& path, const std::string& data, fclose(f); return false; } + // Before the rename, not after: publishing a name that points at nothing + // is the failure this ordering exists to prevent. If the data cannot be + // made durable, do not rename -- the previous file is still intact and + // still correct, and the next write will try again. + if (!SyncFileHandle(f)) { + LogPrint("payout", "[payout] WARNING: could not flush %s to disk (%s)" + " -- keeping the previous file\n", tmp.c_str(), what); + fclose(f); + return false; + } // fclose() is where buffered data actually reaches the OS, so a failure // here means the .tmp is short -- renaming it over the real file would // destroy the good copy, which is exactly what these files exist to @@ -494,6 +548,7 @@ static bool WriteFileAtomic(const std::string& path, const std::string& data, path.c_str(), what); return false; } + SyncDirectoryOf(path); return true; } -- cgit v1.2.3 From cd4e86d431c3f5848183ca362175d1cb3edfa107 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:52:52 +0000 Subject: selftest: cover the payout ledger properties this series fixes A new `payout-ledger` self-test, 40 assertions, wired into RunSelfTest and into `make -C src tests`. It runs against a scratch data directory and an in-memory wallet map: it never opens wallet.dat, never touches a real datadir, and never sends anything. What it asserts, defect by defect: - a restart does not duplicate an in-flight record. A matured round is exploded, the process state is thrown away and reloaded from the files twice, and the in-flight list must still hold exactly one record per recipient. Both guards are checked separately -- once with the round-level set restored, and once with it deliberately emptied so only the per-recipient check against gPaying can save it. A paying file already containing the duplicate the old build wrote must collapse on load, with the attempt counts merged upwards rather than reset. - state updates address a slot, not a value. Two records equal in every field -- exactly what the old code produced -- and rescheduling or forgetting one must land on the one it was given. - the ledger file cannot be published from a stale snapshot. An older snapshot published after a newer one must not appear on disk, and four threads publishing 64 large images of the same file in different orders must leave it parseable and equal to the highest version, whatever order they finish in. - a payment with only a legacy comment is recognised as already paid, and the fallback's limits hold: it requires the right recipient, the exact amount and the right height; one legacy transaction cannot settle a second debt; a transaction carrying another round's structured metadata is never accepted as legacy evidence; and the structured match still wins where it applies. - grandfathered zero-hash rounds do not answer for each other, both as a direct test of the round comparison and end to end -- recording a txid against one zero-hash round must leave the other still owed. - the retry clock that is actually used survives a restart. - a forfeit is published rather than silently dropped: the ledger loses that recipient and only that recipient, and the round proof records it with a reason instead of going on advertising the address as owed. - on-disk compatibility, checked against the real shapes: the live production pending_payouts.json (amounts / blockHash / blockHeight / matureAtHeight and nothing else), the merged build's shape including the retryAfter this build no longer writes, and a round trip through this build. Each assertion was verified to fail when its defect is put back: seven mutations were applied one at a time to a scratch copy and every one was caught. Notably, re-introducing either half of the duplicate-record fix alone is caught by its own assertion while the other half still prevents the duplicate -- which is the defence in depth this series was asked for, checked rather than asserted. --- src/Makefile | 1 + src/rpc.cpp | 671 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/selftest.cpp | 5 +- 3 files changed, 676 insertions(+), 1 deletion(-) (limited to 'src/rpc.cpp') diff --git a/src/Makefile b/src/Makefile index 1166daf..89ba559 100644 --- a/src/Makefile +++ b/src/Makefile @@ -192,6 +192,7 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum + ./bitflash-node -selftest=payout-ledger ./bitflash-node -selftest=parse-money ./bitflash-node -selftest=socks5-proxy diff --git a/src/rpc.cpp b/src/rpc.cpp index 45130ff..cebc349 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -38,6 +38,7 @@ #include #include +#include #include #include #include @@ -2749,6 +2750,676 @@ static bool HandleLine(Miner* m, const std::string& rawLine, return true; } +// --------------------------------------------------------------------------- +// payout-ledger self-test +// +// Regression cover for the payout defects fixed in this series. Everything +// here runs against a scratch data directory and an in-memory wallet map; it +// never opens wallet.dat, never touches a real datadir, and never sends +// anything. +// --------------------------------------------------------------------------- + +static bool PayoutTestReadFile(const std::string& path, std::string& out) +{ + FILE* f = fopen(path.c_str(), "r"); + if (!f) return false; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + out.assign((size_t)(sz > 0 ? sz : 0), '\0'); + if (sz > 0 && fread(&out[0], 1, (size_t)sz, f) != (size_t)sz) { + fclose(f); + return false; + } + fclose(f); + return true; +} + +static bool PayoutTestWriteFile(const std::string& path, const std::string& data) +{ + FILE* f = fopen(path.c_str(), "w"); + if (!f) return false; + bool ok = data.empty() || fwrite(data.c_str(), 1, data.size(), f) == data.size(); + return fclose(f) == 0 && ok; +} + +// Wipe every piece of payout state this test touches, so each section starts +// from the same place and a "restart" can be simulated by reloading from disk. +static void PayoutTestResetState() +{ + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.clear(); + } + { + std::lock_guard lk(gPayoutExecMutex); + gPaying.clear(); + gQueuedPayouts.clear(); + } + { + std::lock_guard lk(gPoolRoundsMutex); + gPoolRounds.clear(); + } + { + std::lock_guard lk(gClaimedTxidMutex); + gLegacyClaimedTxids.clear(); + } +} + +// A BTF address whose hash160 is entirely under the test's control, so the +// output script the probe compares against can be built without a wallet. +static std::string PayoutTestAddress(unsigned char seed) +{ + uint160 h = 0; + for (int i = 0; i < 20; i++) + ((unsigned char*)&h)[i] = (unsigned char)(seed + i); + return Hash160ToAddress(h); +} + +static CScript PayoutTestScriptFor(const std::string& address) +{ + uint160 h160; + CScript sc; + if (AddressToHash160(address, h160)) + sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; + return sc; +} + +// Put a transaction in the wallet map that looks exactly like one this pool +// sent. `nonce` only exists to keep the hashes distinct. +static std::string PayoutTestAddWalletTx(const std::string& comment, + const std::string& address, + int64 amount, + int nonce, + const std::string& structuredId) +{ + CWalletTx wtx; + wtx.vin.resize(1); + wtx.vin[0].prevout.SetNull(); + wtx.vin[0].scriptSig << nonce; + wtx.vout.resize(1); + wtx.vout[0].nValue = amount; + wtx.vout[0].scriptPubKey = PayoutTestScriptFor(address); + if (!comment.empty()) + wtx.mapValue["comment"] = comment; + if (!structuredId.empty()) { + wtx.mapValue["pool_payout_id"] = structuredId; + wtx.mapValue["pool_payout_address"] = address; + wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount); + } + uint256 hash = wtx.GetHash(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet[hash] = wtx; + return hash.GetHex(); +} + +int RunPayoutLedgerSelfTest() +{ + int nFail = 0; + auto check = [&](bool ok, const char* msg) { + fprintf(stdout, " %s %s\n", ok ? "ok" : "FAIL", msg); + if (!ok) nFail++; + }; + + fprintf(stdout, "payout-ledger self-test\n"); + + const std::string savedDataDir = strSetDataDir; + const int savedBestHeight = nBestHeight; + + // Scratch data directory in the current working directory. GetAppDir() + // follows strSetDataDir, so this is what PayoutFilePath() and + // PayingFilePath() resolve against for the rest of the test. + std::string dir = strprintf("payout_selftest_%d", (int)GetTime()); +#ifdef _WIN32 + _mkdir(dir.c_str()); +#else + mkdir(dir.c_str(), 0700); +#endif + strSetDataDir = dir; + fprintf(stdout, " scratch datadir: %s\n", dir.c_str()); + + const std::string ledgerPath = PayoutFilePath(); + const std::string payingPath = PayingFilePath(); + const std::string addrA = PayoutTestAddress(0x11); + const std::string addrB = PayoutTestAddress(0x55); + + // -- 1. A restart must not duplicate an in-flight record ----------------- + // + // The ledger entry is grandfathered (zero hash) on purpose: it keeps + // PayoutBlockStillOnChain out of the picture, which needs a block index, + // and it exercises the height fallback at the same time. + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + remove(payingPath.c_str()); + + PendingPayout pp; + pp.blockHeight = 4242; + pp.blockHash = 0; + pp.matureAtHeight = 4242; + pp.amounts[addrA] = 10 * COIN; + pp.amounts[addrB] = 20 * COIN; + VersionedBlob snap; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(pp); + snap = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(snap); + + nBestHeight = 5000; + FlushMaturePayouts(); + size_t afterFirstFlush = 0; + { + std::lock_guard lk(gPayoutExecMutex); + afterFirstFlush = gPaying.size(); + } + check(afterFirstFlush == 2, + "a matured round explodes into one record per unpaid recipient"); + + // Restart: drop everything in memory and come back from the files. + for (int restart = 1; restart <= 2; restart++) { + PayoutTestResetState(); + LoadPendingPayouts(); + LoadPaying(); + ReconcilePayouts(); + + size_t queued = 0; + { + std::lock_guard lk(gPayoutExecMutex); + queued = gQueuedPayouts.size(); + } + if (restart == 1) + check(queued == 1, + "startup rebuilds the exploded-round set from the restored" + " in-flight list"); + + FlushMaturePayouts(); + size_t n = 0; + { + std::lock_guard lk(gPayoutExecMutex); + n = gPaying.size(); + } + check(n == 2, restart == 1 + ? "a restart does not duplicate an in-flight record" + : "a second restart does not duplicate one either"); + } + + // The other half of the guard: even with the round-level dedupe set + // deliberately emptied, the per-recipient check against gPaying must + // stop a second standing order for the same money. + { + std::lock_guard lk(gPayoutExecMutex); + gQueuedPayouts.clear(); + } + FlushMaturePayouts(); + size_t n = 0; + { + std::lock_guard lk(gPayoutExecMutex); + n = gPaying.size(); + } + check(n == 2, + "the flush refuses a second record for a (round, address) already" + " in flight even with the round set lost"); + } + + // -- 2. Duplicates already on disk are collapsed as they are read -------- + { + PayoutTestResetState(); + nlohmann::json arr = nlohmann::json::array(); + for (int i = 0; i < 2; i++) { + nlohmann::json o; + o["blockHeight"] = 4242; + o["blockHash"] = uint256(0).ToString(); + o["matureAtHeight"] = 4242; + o["address"] = addrA; + o["amount"] = (int64)(10 * COIN); + o["retries"] = i == 0 ? 7 : 0; + o["deferrals"] = i == 0 ? 0 : 3; + arr.push_back(o); + } + check(PayoutTestWriteFile(payingPath, arr.dump(2)), + "wrote a paying file containing the duplicate the old build made"); + LoadPaying(); + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1, + "loading collapses a duplicate in-flight record from disk"); + check(gPaying.size() == 1 && gPaying[0].retries == 7 && gPaying[0].deferrals == 3, + "collapsing merges the attempt counts upwards, not resetting them"); + } + + // -- 3. State updates address a slot, not a value ----------------------- + // + // Build the exact pathology the old code produced -- two records equal in + // every field -- and prove the update lands on the one asked for. Under + // value matching the first record absorbed everything and the second was + // untouchable. + { + PayoutTestResetState(); + PayingRecord a; + a.blockHeight = 4242; a.blockHash = 0; a.matureAtHeight = 4242; + a.address = addrA; a.amount = 10 * COIN; + PayingRecord b = a; + { + std::lock_guard lk(gPayoutExecMutex); + a.id = ++gPayingNextId; + b.id = ++gPayingNextId; + gPaying.push_back(a); + gPaying.push_back(b); + } + PayingRecord upd = b; + upd.retries = 5; + upd.retryAfter = 1234567; + ReschedulePayment(upd); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 2 && + gPaying[0].retries == 0 && gPaying[1].retries == 5, + "rescheduling updates the record it was given, not the first" + " one that looks like it"); + } + ForgetPayment(a); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1 && gPaying[0].id == b.id, + "forgetting erases the record it was given, not the first" + " one that looks like it"); + } + } + + // -- 4. retryAfter survives a restart ------------------------------------ + { + PayoutTestResetState(); + remove(payingPath.c_str()); + PayingRecord r; + r.blockHeight = 77; r.blockHash = 0; r.matureAtHeight = 77; + r.address = addrA; r.amount = 3 * COIN; + r.retryAfter = 1700000000LL; + r.deferrals = 9; + VersionedBlob snap; + { + std::lock_guard lk(gPayoutExecMutex); + r.id = ++gPayingNextId; + gPaying.push_back(r); + snap = SerializePayingLocked(); + } + PublishPaying(snap); + PayoutTestResetState(); + LoadPaying(); + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1 && gPaying[0].retryAfter == 1700000000LL && + gPaying[0].deferrals == 9, + "the retry clock that is actually used survives a restart"); + } + + // -- 5. The ledger file cannot be published from a stale snapshot -------- + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + + PendingPayout one; + one.blockHeight = 100; one.blockHash = 0; one.matureAtHeight = 100; + one.amounts[addrA] = 1 * COIN; + VersionedBlob older, newer; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(one); + older = SerializePendingPayoutsLocked(); + PendingPayout two; + two.blockHeight = 200; two.blockHash = 0; two.matureAtHeight = 200; + two.amounts[addrB] = 2 * COIN; + gPendingPayouts.push_back(two); + newer = SerializePendingPayoutsLocked(); + } + check(newer.seq > older.seq, + "a later serialisation carries a strictly higher version"); + + // The order the race produced: the payout thread's older image + // arriving after the event loop's newer one. + PublishPendingPayouts(newer); + PublishPendingPayouts(older); + std::string onDisk; + check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newer.blob, + "an older snapshot cannot overwrite a newer published ledger"); + + // And the ordinary direction still works. + VersionedBlob newest; + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.clear(); + newest = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(newest); + check(PayoutTestReadFile(ledgerPath, onDisk) && onDisk == newest.blob, + "a newer snapshot does replace the published ledger"); + } + + // -- 6. Concurrent publishers cannot interleave a file ------------------- + // + // Two threads renaming the same ".tmp" over the same target was the + // original defect. The images are made big so a torn write shows up as + // unparseable JSON rather than by luck. + { + const std::string racePath = dir + "/publish_race_selftest.json"; + std::mutex raceMutex; + uint64 racePublished = 0; + + std::vector images; + for (int i = 0; i < 64; i++) { + nlohmann::json arr = nlohmann::json::array(); + for (int k = 0; k < 200; k++) { + nlohmann::json o; + o["image"] = i; + o["entry"] = k; + o["address"] = addrA; + o["amount"] = (int64)(k + 1) * COIN; + arr.push_back(o); + } + VersionedBlob v; + v.blob = arr.dump(2); + v.seq = (uint64)i + 1; + images.push_back(v); + } + + std::vector threads; + for (int t = 0; t < 4; t++) { + threads.push_back(std::thread([&, t]() { + for (size_t i = 0; i < images.size(); i++) { + size_t idx = (i * 7 + (size_t)t * 13) % images.size(); + PublishVersioned(racePath, images[idx], raceMutex, + racePublished, "publish race selftest"); + } + })); + } + for (std::thread& th : threads) + th.join(); + + std::string onDisk; + bool read = PayoutTestReadFile(racePath, onDisk); + bool parses = false; + if (read) { + try { parses = nlohmann::json::parse(onDisk).is_array(); } + catch (...) { parses = false; } + } + check(read && parses, + "four threads publishing the same file never leave it unparseable"); + check(read && onDisk == images.back().blob, + "the published file settles on the highest version, whatever the" + " completion order"); + remove(racePath.c_str()); + remove((racePath + ".tmp").c_str()); + } + + // -- 7. Grandfathered zero-hash rounds do not answer for each other ------ + { + uint256 h1 = 0, h2 = 0; + h1.SetHex("00000000000000000000000000000000000000000000000000000000000000aa"); + h2.SetHex("00000000000000000000000000000000000000000000000000000000000000bb"); + check(!SamePayoutRound(100, 0, 200, 0), + "two zero-hash entries at different heights are different rounds"); + check(SamePayoutRound(100, 0, 100, 0), + "two zero-hash entries at the same height are the same round"); + check(SamePayoutRound(100, h1, 200, h1), + "a real hash identifies the round on its own"); + check(!SamePayoutRound(100, h1, 100, h2), + "different hashes at one height are different rounds"); + check(!SamePayoutRound(100, h1, 100, 0), + "a grandfathered entry never matches a hash-keyed one"); + check(!PayoutRoundIdentifiable(0, 0) && PayoutRoundIdentifiable(0, h1) && + PayoutRoundIdentifiable(7, 0), + "an entry with neither hash nor height is not adjudicable"); + + // End to end: recording a payment against one grandfathered round must + // not settle the other's debt. + PayoutTestResetState(); + remove(ledgerPath.c_str()); + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout first; + first.blockHeight = 100; first.blockHash = 0; first.matureAtHeight = 100; + first.amounts[addrA] = 1 * COIN; + PendingPayout second; + second.blockHeight = 200; second.blockHash = 0; second.matureAtHeight = 200; + second.amounts[addrA] = 1 * COIN; + gPendingPayouts.push_back(first); + gPendingPayouts.push_back(second); + } + PayingRecord r; + r.blockHeight = 200; r.blockHash = 0; r.matureAtHeight = 200; + r.address = addrA; r.amount = 1 * COIN; + RecordPayoutTxid(r, "txid-for-the-second-round"); + { + std::lock_guard lk(gPayoutLedgerMutex); + // The second round was fully paid and retired; the first is + // untouched and still owed. + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 100 && + gPendingPayouts[0].paidTxids.empty(), + "a txid recorded against one zero-hash round leaves the other" + " still owed"); + } + } + + // -- 8. A payment with only a legacy comment is recognised as paid ------- + { + PayoutTestResetState(); + const int height = 8080; + const int64 amount = 7 * COIN; + const std::string id = PayoutIdForBlock(height, 0); + const std::string comment = PayoutComment(height); + std::set none; + std::string found; + + check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "an empty wallet is not evidence that anything was paid"); + + const std::string legacyTxid = + PayoutTestAddWalletTx(comment, addrA, amount, 1, ""); + check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + == PAYOUT_PROBE_LEGACY && found == legacyTxid, + "a payment carrying only a pre-upgrade comment is recognised as" + " already paid"); + + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the output to pay THIS recipient"); + check(FindRecordedPoolPayout(id, height, addrA, amount + COIN, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the output to pay exactly this amount"); + check(FindRecordedPoolPayout(id, height + 1, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "the legacy match requires the comment for this block height"); + + std::set spent; + spent.insert(legacyTxid); + check(FindRecordedPoolPayout(id, height, addrA, amount, spent, found) + == PAYOUT_PROBE_NONE, + "one legacy transaction cannot settle a second debt"); + + // A transaction written by THIS build carries pool_payout_id, and must + // never be offered as legacy evidence for a different round -- that is + // exactly what the structured metadata exists to prevent. + const std::string otherId = PayoutIdForBlock(height, 0) + "-other"; + PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId); + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_NONE, + "another round's structured payment is not legacy evidence for" + " this one"); + + // The structured path still wins where it applies. + const std::string structuredTxid = + PayoutTestAddWalletTx(comment, addrB, amount, 3, id); + check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, + "structured payout metadata is matched in preference to a comment"); + } + + // -- 9. On-disk compatibility with the live production ledger ------------ + { + PayoutTestResetState(); + // Exactly the shape of the file on the pool node: amounts, blockHash, + // blockHeight, matureAtHeight, and nothing else. + const char* live = + "[{\"amounts\":{\"%s\":1234500000}," + "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000cd\"," + "\"blockHeight\":1000," + "\"matureAtHeight\":1120}]"; + check(PayoutTestWriteFile(ledgerPath, strprintf(live, addrA.c_str())), + "wrote a ledger file in the live production shape"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 1000 && + gPendingPayouts[0].matureAtHeight == 1120 && + gPendingPayouts[0].blockHash != 0 && + gPendingPayouts[0].amounts.size() == 1 && + gPendingPayouts[0].amounts[addrA] == 1234500000LL, + "the live production pending_payouts.json still loads"); + } + + // And the shape the merged build writes, retryAfter included, which + // this build no longer writes but must still read past. + PayoutTestResetState(); + const char* merged = + "[{\"amounts\":{\"%s\":100000000}," + "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000ef\"," + "\"blockHeight\":2000,\"matureAtHeight\":2120,\"retryAfter\":0," + "\"paidTxids\":{\"%s\":\"abc123\"}}]"; + check(PayoutTestWriteFile(ledgerPath, + strprintf(merged, addrA.c_str(), addrA.c_str())), + "wrote a ledger file in the merged build's shape"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 2000 && + gPendingPayouts[0].paidTxids.size() == 1 && + gPendingPayouts[0].paidTxids[addrA] == "abc123", + "a ledger file carrying retryAfter and paidTxids still loads"); + } + + // A round trip through this build must not lose anything either. + VersionedBlob snap; + { + std::lock_guard lk(gPayoutLedgerMutex); + snap = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(snap); + PayoutTestResetState(); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].blockHeight == 2000 && + gPendingPayouts[0].matureAtHeight == 2120 && + gPendingPayouts[0].paidTxids[addrA] == "abc123", + "what this build writes, this build reads back unchanged"); + } + } + + // -- 10. A forfeit is published, not just dropped ------------------------ + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + const std::string roundsPath = dir + "/pool_rounds_selftest.json"; + const std::string savedRoundsFile = strPoolRoundsFile; + strPoolRoundsFile = roundsPath; + remove(roundsPath.c_str()); + + const int height = 606; + PoolRoundProof round; + round.id = PayoutIdForBlock(height, 0); + round.blockHeight = height; + round.matureAtHeight = height; + round.payouts[addrA] = 5 * COIN; + round.payouts[addrB] = 6 * COIN; + RecordPoolRound(round); + + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout pp; + pp.blockHeight = height; pp.blockHash = 0; pp.matureAtHeight = height; + pp.amounts[addrA] = 5 * COIN; + pp.amounts[addrB] = 6 * COIN; + gPendingPayouts.push_back(pp); + } + + PayingRecord r; + r.blockHeight = height; r.blockHash = 0; r.matureAtHeight = height; + r.address = addrA; r.amount = 5 * COIN; + ForfeitPayoutRecipient(r, "selftest reason"); + + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 1 && + gPendingPayouts[0].amounts.count(addrA) == 0 && + gPendingPayouts[0].amounts.count(addrB) == 1, + "a forfeit removes only that recipient from the ledger entry"); + } + + std::string onDisk; + bool proofOk = false; + if (PayoutTestReadFile(roundsPath, onDisk)) { + try { + nlohmann::json root = nlohmann::json::parse(onDisk); + const nlohmann::json& p = root["rounds"][0]["payouts"]; + proofOk = p[addrA].value("forfeited", false) && + p[addrA].value("forfeitReason", "") == "selftest reason" && + p[addrA]["txid"].is_null() && + !p[addrB].contains("forfeited"); + } catch (...) { + proofOk = false; + } + } + check(proofOk, + "the published round proof records the forfeit instead of" + " advertising the address as still owed"); + + // And it survives a reload of the proof file. + { + std::lock_guard lk(gPoolRoundsMutex); + gPoolRounds.clear(); + } + LoadPoolRounds(); + { + std::lock_guard lk(gPoolRoundsMutex); + check(gPoolRounds.size() == 1 && + gPoolRounds[0].forfeited.count(addrA) == 1 && + gPoolRounds[0].forfeited[addrA] == "selftest reason", + "the forfeit is read back from pool_rounds.json"); + } + + remove(roundsPath.c_str()); + remove((roundsPath + ".tmp").c_str()); + strPoolRoundsFile = savedRoundsFile; + } + + // -- clean up ------------------------------------------------------------ + PayoutTestResetState(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + remove(ledgerPath.c_str()); + remove((ledgerPath + ".tmp").c_str()); + remove(payingPath.c_str()); + remove((payingPath + ".tmp").c_str()); + remove((dir + "/pool_status.json").c_str()); + remove((dir + "/pool_rounds.json").c_str()); +#ifdef _WIN32 + _rmdir(dir.c_str()); +#else + rmdir(dir.c_str()); +#endif + strSetDataDir = savedDataDir; + nBestHeight = savedBestHeight; + + fprintf(stdout, "%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunPoolStratumSelfTest() { int nFail = 0; diff --git a/src/selftest.cpp b/src/selftest.cpp index eed6779..034e1f7 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -13,6 +13,7 @@ #include "walletcmd.h" extern int RunPoolStratumSelfTest(); +extern int RunPayoutLedgerSelfTest(); // Test results go to the terminal, not to debug.log. // @@ -1573,12 +1574,14 @@ int RunSelfTest(const std::string& name) return RunConsensusLimitsSelfTest(); if (name == "pool-stratum") return RunPoolStratumSelfTest(); + if (name == "payout-ledger") + return RunPayoutLedgerSelfTest(); if (name == "parse-money") return RunParseMoneySelfTest(); if (name == "socks5-proxy") return RunSocks5ProxySelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, payout-ledger, parse-money, socks5-proxy\n"); return 1; } -- cgit v1.2.3 From f95fe2db8f906cf5174b0ca63c8fbe36b16b2ece Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:56:25 +0000 Subject: selftest: leave no scratch directory behind after payout-ledger LogPrint binds debug.log to whatever GetAppDir() resolves to the first time it fires, which for this test is its own scratch data directory -- so removing the files the test knew it had written was never going to empty the directory, and `make tests` left one behind in src/ on every run. Sweep the directory instead of naming its contents, and take the process id the same way the other self-tests do rather than assuming getpid() exists on every target. --- src/rpc.cpp | 55 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 13 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index cebc349..e4939b3 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #else #include #endif @@ -2775,6 +2776,39 @@ static bool PayoutTestReadFile(const std::string& path, std::string& out) return true; } +// Delete the scratch data directory and everything in it. A sweep and not a +// list of known names: LogPrint binds debug.log to whatever GetAppDir() is +// when it first fires, so the test's own log lands in here too, and a +// directory left behind in src/ after `make tests` is litter. +static void PayoutTestRemoveDir(const std::string& dir) +{ +#ifdef _WIN32 + WIN32_FIND_DATAA findData; + HANDLE hFind = FindFirstFileA((dir + "\\*").c_str(), &findData); + if (hFind != INVALID_HANDLE_VALUE) { + do { + std::string name = findData.cFileName; + if (name == "." || name == "..") continue; + DeleteFileA((dir + "\\" + name).c_str()); + } while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + } + RemoveDirectoryA(dir.c_str()); +#else + DIR* d = opendir(dir.c_str()); + if (d) { + struct dirent* ent; + while ((ent = readdir(d)) != NULL) { + std::string name = ent->d_name; + if (name == "." || name == "..") continue; + unlink((dir + "/" + name).c_str()); + } + closedir(d); + } + rmdir(dir.c_str()); +#endif +} + static bool PayoutTestWriteFile(const std::string& path, const std::string& data) { FILE* f = fopen(path.c_str(), "w"); @@ -2869,9 +2903,14 @@ int RunPayoutLedgerSelfTest() // Scratch data directory in the current working directory. GetAppDir() // follows strSetDataDir, so this is what PayoutFilePath() and // PayingFilePath() resolve against for the rest of the test. - std::string dir = strprintf("payout_selftest_%d", (int)GetTime()); #ifdef _WIN32 - _mkdir(dir.c_str()); + unsigned long pid = (unsigned long)GetCurrentProcessId(); +#else + unsigned long pid = (unsigned long)getpid(); +#endif + std::string dir = strprintf("payout_selftest_%d_%lu", (int)GetTime(), pid); +#ifdef _WIN32 + CreateDirectoryA(dir.c_str(), NULL); #else mkdir(dir.c_str(), 0700); #endif @@ -3400,17 +3439,7 @@ int RunPayoutLedgerSelfTest() PayoutTestResetState(); CRITICAL_BLOCK(cs_mapWallet) mapWallet.clear(); - remove(ledgerPath.c_str()); - remove((ledgerPath + ".tmp").c_str()); - remove(payingPath.c_str()); - remove((payingPath + ".tmp").c_str()); - remove((dir + "/pool_status.json").c_str()); - remove((dir + "/pool_rounds.json").c_str()); -#ifdef _WIN32 - _rmdir(dir.c_str()); -#else - rmdir(dir.c_str()); -#endif + PayoutTestRemoveDir(dir); strSetDataDir = savedDataDir; nBestHeight = savedBestHeight; -- cgit v1.2.3 From c9353e8f8a4d95c827497abe5b30ac951dd7dda5 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 07:58:52 +0000 Subject: payout: indent the blocks introduced by the publish/serialise split No behaviour change. Moving the file writes out from under the data mutexes put a new scope around the locked sections of ReconcilePayouts and FlushMaturePayouts, and their bodies were left at the old indentation, which reads as if the lock is not held. Re-indented, with the comments and the concatenated log strings inside them re-wrapped to fit -- `git show -w` on this commit is comments and string line breaks only, and the strings concatenate to the same text. --- src/rpc.cpp | 156 ++++++++++++++++++++++++++++++------------------------------ 1 file changed, 79 insertions(+), 77 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index e4939b3..f853e78 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1125,52 +1125,54 @@ static void ReconcilePayouts() { VersionedBlob payingSnap; { - std::lock_guard exec(gPayoutExecMutex); - std::lock_guard ledger(gPayoutLedgerMutex); + std::lock_guard exec(gPayoutExecMutex); + std::lock_guard ledger(gPayoutLedgerMutex); - size_t settled = 0; - int64 settledAmount = 0; - for (auto it = gPaying.begin(); it != gPaying.end(); ) { - bool paid = false; - for (const PendingPayout& pp : gPendingPayouts) - if (SamePayoutRound(pp.blockHeight, pp.blockHash, - it->blockHeight, it->blockHash) - && pp.paidTxids.count(it->address)) { - paid = true; - break; + size_t settled = 0; + int64 settledAmount = 0; + for (auto it = gPaying.begin(); it != gPaying.end(); ) { + bool paid = false; + for (const PendingPayout& pp : gPendingPayouts) + if (SamePayoutRound(pp.blockHeight, pp.blockHash, + it->blockHeight, it->blockHash) + && pp.paidTxids.count(it->address)) { + paid = true; + break; + } + if (paid) { + settledAmount += it->amount; + settled++; + it = gPaying.erase(it); + } else { + // Every restored record gets one wallet probe before it is + // allowed to send: this is precisely the window where the + // wallet may hold a transaction the ledger has not recorded. + it->fProbeWallet = true; + ++it; } - if (paid) { - settledAmount += it->amount; - settled++; - it = gPaying.erase(it); - } else { - // Every restored record gets one wallet probe before it is allowed - // to send: this is precisely the window where the wallet may hold - // a transaction the ledger has not recorded yet. - it->fProbeWallet = true; - ++it; } - } - if (settled) { - LogPrint("payout", "[payout] %zu in-flight payment(s) totalling %s are" - " already recorded paid in the ledger -- not resending\n", - settled, FormatMoney(settledAmount).c_str()); - } + if (settled) { + LogPrint("payout", "[payout] %zu in-flight payment(s) totalling %s" + " are already recorded paid in the ledger -- not" + " resending\n", + settled, FormatMoney(settledAmount).c_str()); + } - // Rebuild the "already exploded" set from what survived. Without this the - // first flush after a restart re-explodes every round still in flight. - gQueuedPayouts.clear(); - for (const PayingRecord& r : gPaying) - gQueuedPayouts.insert(PayoutIdForBlock(r.blockHeight, r.blockHash)); + // Rebuild the "already exploded" set from what survived. Without this + // the first flush after a restart re-explodes every round in flight. + gQueuedPayouts.clear(); + for (const PayingRecord& r : gPaying) + gQueuedPayouts.insert(PayoutIdForBlock(r.blockHeight, r.blockHash)); - payingSnap = SerializePayingLocked(); + payingSnap = SerializePayingLocked(); - if (!gPaying.empty()) { - int64 owed = 0; - for (const auto& r : gPaying) owed += r.amount; - LogPrint("payout", "[payout] resuming %zu in-flight payment(s) totalling %s\n", - gPaying.size(), FormatMoney(owed).c_str()); - } + if (!gPaying.empty()) { + int64 owed = 0; + for (const auto& r : gPaying) owed += r.amount; + LogPrint("payout", "[payout] resuming %zu in-flight payment(s)" + " totalling %s\n", + gPaying.size(), FormatMoney(owed).c_str()); + } } PublishPaying(payingSnap); } @@ -1606,44 +1608,44 @@ static void FlushMaturePayouts() VersionedBlob payingSnap; { - std::lock_guard queue(gPayoutExecMutex); - size_t added = 0; - for (const PendingPayout& pp : ready) { - const std::string id = PayoutIdForBlock(pp.blockHeight, pp.blockHash); - if (gQueuedPayouts.count(id)) - continue; // already exploded into gPaying and still in flight - size_t n = 0; - for (const auto& kv : pp.amounts) { - // Upstream's per-recipient dedupe: a recipient already carrying a - // txid is done, even though the entry as a whole is not. - if (pp.paidTxids.count(kv.first)) - continue; - // The invariant, checked against the thing it is an invariant of: - // at most one in-flight record per (round, address). The round-level - // set above is an optimisation that can be wrong -- it is memory - // only, it is rebuilt from gPaying at startup, and a stale `ready` - // snapshot can race a round finishing -- whereas gPaying is the - // list being protected. A second record here is a second standing - // order to send the same money. - if (PaymentInFlightLocked(pp.blockHeight, pp.blockHash, kv.first)) - continue; - PayingRecord r; - r.id = ++gPayingNextId; - r.blockHeight = pp.blockHeight; - r.blockHash = pp.blockHash; - r.matureAtHeight = pp.matureAtHeight; - r.address = kv.first; - r.amount = kv.second; - gPaying.push_back(r); - n++; + std::lock_guard queue(gPayoutExecMutex); + size_t added = 0; + for (const PendingPayout& pp : ready) { + const std::string id = PayoutIdForBlock(pp.blockHeight, pp.blockHash); + if (gQueuedPayouts.count(id)) + continue; // already exploded into gPaying and still in flight + size_t n = 0; + for (const auto& kv : pp.amounts) { + // Upstream's per-recipient dedupe: a recipient already carrying + // a txid is done, even though the entry as a whole is not. + if (pp.paidTxids.count(kv.first)) + continue; + // The invariant, checked against the thing it is an invariant + // of: at most one in-flight record per (round, address). The + // round-level set above is an optimisation that can be wrong -- + // it is memory only, it is rebuilt from gPaying at startup, and + // a stale `ready` snapshot can race a round finishing -- + // whereas gPaying is the list being protected. A second record + // here is a second standing order to send the same money. + if (PaymentInFlightLocked(pp.blockHeight, pp.blockHash, kv.first)) + continue; + PayingRecord r; + r.id = ++gPayingNextId; + r.blockHeight = pp.blockHeight; + r.blockHash = pp.blockHash; + r.matureAtHeight = pp.matureAtHeight; + r.address = kv.first; + r.amount = kv.second; + gPaying.push_back(r); + n++; + } + gQueuedPayouts.insert(id); + added += n; + LogPrint("payout", "[payout] block height %d matured -- queuing" + " payout %s (%zu of %zu recipients still owed)\n", + pp.matureAtHeight, id.c_str(), n, pp.amounts.size()); } - gQueuedPayouts.insert(id); - added += n; - LogPrint("payout", "[payout] block height %d matured -- queuing payout %s" - " (%zu of %zu recipients still owed)\n", - pp.matureAtHeight, id.c_str(), n, pp.amounts.size()); - } - if (added) payingSnap = SerializePayingLocked(); + if (added) payingSnap = SerializePayingLocked(); } PublishPaying(payingSnap); } -- cgit v1.2.3 From 6c0ed67a2f6b39b630c5748ecf214f86e9d26a5c Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:22:32 +0000 Subject: payout: leave no temporary file behind, and say so where it will be read Every failure path in WriteFileAtomic returned without removing ".tmp". A disk that filled up once, or a single EIO, left a short file called pending_payouts.json.tmp sitting next to a good ledger for as long as the node ran. That file is worse than no file: it carries the name of a half-finished save, and the operator cleaning up after the incident is precisely the person who will move it into place and publish a ledger missing whatever had not been written yet. The failures also logged under LogPrint("payout", ...). pool_status.json is written through the same function but everything else about it is logged under "pool", so an operator running -debug=pool was told nothing when the status file could not be replaced -- the message existed and was discarded for having the wrong category. They now print unconditionally: a write failure is not a trace of normal operation, it is the file that is about to be read not being there, and gating it on a category chosen before the failure happened is how it went missing. The brief's alternative, a category argument, would still have required the right guess in advance. Properties: no failure of WriteFileAtomic leaves a .tmp behind, and no failure of it is invisible at default log settings. --- src/rpc.cpp | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index f853e78..be265db 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -506,20 +506,35 @@ static void SyncDirectoryOf(const std::string& path) // write mutex for `path`: the temp name is derived from the target, so two // threads in here at once for one path is precisely the race this exists to // prevent. +// +// Every failure removes the temp file before returning. It used to be left +// where it fell, so a disk that filled up once left a short, valid-looking +// "pending_payouts.json.tmp" next to the good ledger for as long as the node +// ran. That file is a trap: it has the name of a half-finished save, and an +// operator cleaning up after an incident is exactly the person who will move +// it into place. +// +// Failures print unconditionally rather than through LogPrint(). These three +// files are written under two different debug categories ("payout" for the +// ledgers, "pool" for the status file), and a write failure is not a trace of +// normal operation -- it is the file the operator is about to read not being +// there. Gating it behind a category that had to be chosen before the failure +// happened is how "could not replace pool_status.json" went missing. static bool WriteFileAtomic(const std::string& path, const std::string& data, const char* what) { std::string tmp = path + ".tmp"; FILE* f = fopen(tmp.c_str(), "w"); if (!f) { - LogPrint("payout", "[payout] WARNING: could not write %s (%s)\n", - tmp.c_str(), what); + printf("[payout] WARNING: could not write %s (%s)\n", + tmp.c_str(), what); return false; } if (!data.empty() && fwrite(data.c_str(), 1, data.size(), f) != data.size()) { - LogPrint("payout", "[payout] WARNING: short write to %s (%s)\n", - tmp.c_str(), what); + printf("[payout] WARNING: short write to %s (%s)\n", + tmp.c_str(), what); fclose(f); + remove(tmp.c_str()); return false; } // Before the rename, not after: publishing a name that points at nothing @@ -527,9 +542,10 @@ static bool WriteFileAtomic(const std::string& path, const std::string& data, // made durable, do not rename -- the previous file is still intact and // still correct, and the next write will try again. if (!SyncFileHandle(f)) { - LogPrint("payout", "[payout] WARNING: could not flush %s to disk (%s)" - " -- keeping the previous file\n", tmp.c_str(), what); + printf("[payout] WARNING: could not flush %s to disk (%s)" + " -- keeping the previous file\n", tmp.c_str(), what); fclose(f); + remove(tmp.c_str()); return false; } // fclose() is where buffered data actually reaches the OS, so a failure @@ -537,8 +553,9 @@ static bool WriteFileAtomic(const std::string& path, const std::string& data, // destroy the good copy, which is exactly what these files exist to // prevent. if (fclose(f) != 0) { - LogPrint("payout", "[payout] WARNING: could not close %s (%s)\n", - tmp.c_str(), what); + printf("[payout] WARNING: could not close %s (%s)\n", + tmp.c_str(), what); + remove(tmp.c_str()); return false; } #ifdef _WIN32 @@ -546,8 +563,9 @@ static bool WriteFileAtomic(const std::string& path, const std::string& data, remove(path.c_str()); #endif if (rename(tmp.c_str(), path.c_str()) != 0) { - LogPrint("payout", "[payout] WARNING: could not replace %s (%s)\n", - path.c_str(), what); + printf("[payout] WARNING: could not replace %s (%s)\n", + path.c_str(), what); + remove(tmp.c_str()); return false; } SyncDirectoryOf(path); -- cgit v1.2.3 From d273a04ff89233f0f01431bd6e3c18d5261818b0 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:31:46 +0000 Subject: payout: build pool_status.json under the locks, write it after releasing them RefreshStats runs on the event loop -- the one thread that reads and writes every miner's socket -- and took gStatsMutex and then gPayoutLedgerMutex at function scope, then called WritePoolStatusJsonLocked() with both still held. Since 86b39b9 that write ends in fsync() on the file and fsync() on its directory. So every ten seconds the event loop performed two synchronous disk flushes holding the mutex the payout thread must take to record that a miner has been paid, and the payout thread waited behind them. Measured here on ext4/LVM/SSD: 3.78 ms mean per publish. On the pool's shared virtual disk it is higher and it has no upper bound under load. That is worse than the code the merge replaced, which wrote the same file under the same locks but buffered and without fsync. It also made 33f2e8a's headline claim -- "no thread waits on I/O holding gPayoutLedgerMutex, gPayoutExecMutex or gPoolRoundsMutex" -- false on the commit that asserted it. Split the same way as the three ledgers: SerializePoolStatusLocked() returns the image, PublishPoolStatus() writes it, and RefreshStats calls the second one after leaving the scope that holds the first. The ledger lock is also scoped to the loop that actually needs it, which is the gPayoutViews rebuild; the serialiser never needed it, since those rows were already copied. No version guard is added and none is needed: RefreshStats is still the only caller of either half, it still runs only on the event loop, and it now calls them in sequence on that one thread, so pool_status.json still has a single writer by construction. The comment saying so has been rechecked against the new shape rather than carried over. The claim is now tested rather than asserted. The self-test points strPoolStatusFile at a path whose ".tmp" is a FIFO, so the publish parks in open(2) until the test chooses to read, and every mutex the write must not be holding is required to be acquirable while it is parked. Against the previous code that test cannot pass: the writer sits inside fopen() holding gStatsMutex and gPayoutLedgerMutex, and nothing releases them until the write is allowed to finish. Verified by reintroducing the defect -- two assertions fail, deterministically. Property: pool_status.json is serialised under the locks and written with all four released, so no payout mutex is ever held across I/O. --- src/rpc.cpp | 290 +++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 237 insertions(+), 53 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index be265db..403cb14 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -451,9 +451,20 @@ static std::string PoolRoundsFilePath() // number; publishing happens with the data mutex released and refuses any // blob whose sequence number is not newer than what is already on disk. So // writers can queue in any order and finish in any order -- the file only -// ever moves forwards. Crucially, no thread ever waits for I/O while holding -// gPayoutLedgerMutex / gPayoutExecMutex / gPoolRoundsMutex, which is the -// contention the merge was right to remove from the event loop. +// ever moves forwards. +// +// The other half of the rule is that no thread waits for I/O while holding +// gPayoutLedgerMutex, gPayoutExecMutex or gPoolRoundsMutex, which is the +// contention the merge was right to remove from the event loop. That was +// claimed here before it was true: pool_status.json is written by the same +// WriteFileAtomic and RefreshStats held gPayoutLedgerMutex at function scope +// across the call, so once the writes gained fsync() the event loop was doing +// two of them every ten seconds with the payout thread's mutex held -- worse +// than the buffered write the merge replaced. The status file now goes through +// the same serialise-under-the-lock / publish-outside-it split as the ledgers, +// and the rule holds for all four files. It is checked, not asserted: see the +// "no payout mutex is held across a publish" section of the payout-ledger +// self-test. // A serialised file image plus the version of the in-memory state it was // taken from. @@ -2100,7 +2111,22 @@ static std::string PoolStatusFilePath() return GetAppDir() + "/pool_status.json"; } -static void WritePoolStatusJsonLocked() +// Serialise the operator status file. Caller must hold gStatsMutex (this reads +// gStats / gWorkerStats / gPayoutViews); gPoolRoundsMutex is taken and +// released inside. +// +// Split from the write for the same reason as the three ledgers, and for a +// worse instance of the same problem. This used to be called from RefreshStats +// with gPayoutLedgerMutex held at function scope, and once the ledger writes +// gained fsync() that call became two fsyncs -- one on the file, one on its +// directory -- performed by the event loop, the thread that owns every miner's +// socket, with the mutex the payout thread needs in its hand. Measured on the +// build host (ext4 on LVM on SSD): 3.78 ms per publish, every 10 seconds, and +// unbounded on a shared virtual disk under load. +// +// Nothing here needs the ledger mutex at all: the pending-payout rows were +// already copied into gPayoutViews under it, further up RefreshStats. +static std::string SerializePoolStatusLocked() { nlohmann::json j; j["schema"] = "bitflash-pool-status-1"; @@ -2184,11 +2210,20 @@ static void WritePoolStatusJsonLocked() } } - // No version guard and no write mutex here, deliberately: RefreshStats is - // the only caller and it only ever runs on the event loop, so there is a - // single writer of pool_status.json by construction. It shares - // WriteFileAtomic so the durability rules stay in one place. - WriteFileAtomic(PoolStatusFilePath(), j.dump(2), "pool status"); + return j.dump(2); +} + +// Publish the operator status file. Call with every payout mutex released. +// +// Still no version guard and no write mutex, deliberately, and the split does +// not change the argument: RefreshStats remains the only caller, it still runs +// only on the event loop, and it now calls this after leaving the scope that +// serialised -- so the serialise and the write stay strictly ordered on one +// thread and pool_status.json still has a single writer by construction. It +// shares WriteFileAtomic so the durability rules stay in one place. +static void PublishPoolStatus(const std::string& blob) +{ + WriteFileAtomic(PoolStatusFilePath(), blob, "pool status"); } // --------------------------------------------------------------------------- @@ -2907,6 +2942,10 @@ static std::string PayoutTestAddWalletTx(const std::string& comment, return hash.GetHex(); } +// Defined below with the event loop; the status-file publication test drives +// it directly. +static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal); + int RunPayoutLedgerSelfTest() { int nFail = 0; @@ -3455,6 +3494,136 @@ int RunPayoutLedgerSelfTest() strPoolRoundsFile = savedRoundsFile; } + // -- 11. No payout mutex is held across the status-file write ------------ + // + // RefreshStats runs on the event loop -- the thread that owns every + // miner's socket -- and used to hold gStatsMutex and gPayoutLedgerMutex at + // function scope across WriteFileAtomic. Once that write gained fsync() on + // the file and on its directory, publishing pool_status.json parked the + // payout thread behind two disk flushes every ten seconds. + // + // Timing assertions would be flaky, so this does not measure anything. It + // makes the write block for as long as the test likes: strPoolStatusFile + // points at a path whose ".tmp" is a FIFO, and open(2) for writing on a + // FIFO does not return until a reader arrives. While RefreshStats is + // parked in there, every mutex it must NOT be holding has to be + // acquirable. Against the previous code this test does not merely fail, it + // cannot pass: the writer is inside fopen() with both mutexes held and + // nothing will release them until the test lets the write finish. +#ifndef _WIN32 + { + PayoutTestResetState(); + const std::string statusPath = dir + "/pool_status_selftest.json"; + const std::string statusTmp = statusPath + ".tmp"; + const std::string savedStatusFile = strPoolStatusFile; + const int64 savedLastWrite = gLastPoolStatusWrite; + remove(statusPath.c_str()); + remove(statusTmp.c_str()); + strPoolStatusFile = statusPath; + gLastPoolStatusWrite = 0; // due a publish on the next refresh + + // Give the status file something to report, so the ledger mutex is + // genuinely taken on the way through rather than skipped over. + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout pp; + pp.blockHeight = 909; pp.blockHash = 0; pp.matureAtHeight = 909; + pp.amounts[addrA] = 3 * COIN; + gPendingPayouts.push_back(pp); + } + + bool fifoOk = (mkfifo(statusTmp.c_str(), 0600) == 0); + check(fifoOk, "a FIFO stands in for the status file's temp file, so the" + " write can be held open"); + if (fifoOk) { + std::atomic done(false); + std::thread writer([&done]() { + RefreshStats(0, 0); + done = true; + }); + + // Settle: the work before the write is a handful of vector + // operations, so anything past this point is inside fopen(). The + // !done check below is what actually proves it -- with no reader + // there is no other way for this thread to still be running. + Sleep(500); + bool blocked = !done; + bool gotLedger = false, gotStats = false, gotExec = false, + gotRounds = false; + for (int i = 0; i < 1000 && blocked; i++) { + if (!gotLedger && gPayoutLedgerMutex.try_lock()) { + gotLedger = true; gPayoutLedgerMutex.unlock(); + } + if (!gotStats && gStatsMutex.try_lock()) { + gotStats = true; gStatsMutex.unlock(); + } + if (!gotExec && gPayoutExecMutex.try_lock()) { + gotExec = true; gPayoutExecMutex.unlock(); + } + if (!gotRounds && gPoolRoundsMutex.try_lock()) { + gotRounds = true; gPoolRoundsMutex.unlock(); + } + if (gotLedger && gotStats && gotExec && gotRounds) + break; + Sleep(2); + } + + check(blocked, + "the status-file write is held open for the duration of this" + " test"); + check(!blocked || gotLedger, + "gPayoutLedgerMutex is free while pool_status.json is being" + " written"); + check(!blocked || (gotExec && gotRounds), + "gPayoutExecMutex and gPoolRoundsMutex are free while" + " pool_status.json is being written"); + check(!blocked || gotStats, + "gStatsMutex is free while pool_status.json is being written"); + + // Let the write finish. Drain to EOF rather than closing early: + // the writer is still filling the pipe and a closed read end would + // deliver it SIGPIPE. + std::thread reader([&statusTmp]() { + FILE* rf = fopen(statusTmp.c_str(), "r"); + if (!rf) return; + char buf[4096]; + while (fread(buf, 1, sizeof(buf), rf) > 0) { } + fclose(rf); + }); + writer.join(); + reader.join(); + } + remove(statusTmp.c_str()); + + // And the ordinary path still produces the file it always did. + gLastPoolStatusWrite = 0; + RefreshStats(0, 0); + std::string onDisk; + bool statusOk = false; + if (PayoutTestReadFile(statusPath, onDisk)) { + try { + nlohmann::json j = nlohmann::json::parse(onDisk); + statusOk = j.value("schema", "") == "bitflash-pool-status-1" && + j["pendingPayouts"].is_array() && + j["pendingPayouts"].size() == 1 && + j["pendingPayouts"][0].value("matureAtHeight", 0) == 909 && + j["pendingPayouts"][0].value("totalAmountSatoshis", + (int64)0) == 3 * COIN; + } catch (...) { + statusOk = false; + } + } + check(statusOk, + "splitting the serialise from the write leaves pool_status.json" + " saying the same thing"); + + remove(statusPath.c_str()); + remove((statusPath + ".tmp").c_str()); + strPoolStatusFile = savedStatusFile; + gLastPoolStatusWrite = savedLastWrite; + } +#endif + // -- clean up ------------------------------------------------------------ PayoutTestResetState(); CRITICAL_BLOCK(cs_mapWallet) @@ -3615,56 +3784,71 @@ int RunPoolStratumSelfTest() // --------------------------------------------------------------------------- static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) { - std::lock_guard lk(gStatsMutex); + std::string statusBlob; + bool fPublish = false; + { + std::lock_guard lk(gStatsMutex); - gStats.authorizedMiners = 0; - gStats.blocksFound = blocksFoundThisSession; - gStats.roundShares = roundShareTotal; - gStats.totalHashRate = 0.0; - for (Miner* m : gMiners) - if (m->authorised) gStats.authorizedMiners++; + gStats.authorizedMiners = 0; + gStats.blocksFound = blocksFoundThisSession; + gStats.roundShares = roundShareTotal; + gStats.totalHashRate = 0.0; + for (Miner* m : gMiners) + if (m->authorised) gStats.authorizedMiners++; - gWorkerStats.clear(); - for (Miner* m : gMiners) { - if (!m->authorised || m->lastSeen <= 0) continue; - PoolWorkerStatView v; - v.address = m->address; - v.worker = m->worker; - v.roundShares = m->roundShares; - v.totalShares = m->totalShares; - v.lastSeen = m->lastSeen; - v.hashRate = m->hashRate; - gWorkerStats.push_back(v); - gStats.totalHashRate += m->hashRate; - } - std::sort(gWorkerStats.begin(), gWorkerStats.end(), - [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { - return a.roundShares != b.roundShares ? a.roundShares > b.roundShares - : a.lastSeen > b.lastSeen; - }); + gWorkerStats.clear(); + for (Miner* m : gMiners) { + if (!m->authorised || m->lastSeen <= 0) continue; + PoolWorkerStatView v; + v.address = m->address; + v.worker = m->worker; + v.roundShares = m->roundShares; + v.totalShares = m->totalShares; + v.lastSeen = m->lastSeen; + v.hashRate = m->hashRate; + gWorkerStats.push_back(v); + gStats.totalHashRate += m->hashRate; + } + std::sort(gWorkerStats.begin(), gWorkerStats.end(), + [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { + return a.roundShares != b.roundShares ? a.roundShares > b.roundShares + : a.lastSeen > b.lastSeen; + }); - gPayoutViews.clear(); - std::lock_guard ledger(gPayoutLedgerMutex); - for (const PendingPayout& pp : gPendingPayouts) { - PendingPayoutView pv; - pv.matureAtHeight = pp.matureAtHeight; - pv.recipients = 0; - pv.totalAmount = 0; - for (auto& kv : pp.amounts) { - if (pp.paidTxids.count(kv.first)) - continue; - pv.recipients++; - pv.totalAmount += kv.second; + // Scoped, not held to the end of the function. At function scope this + // lock covered the status-file write below, so the event loop did two + // fsyncs holding the mutex the payout thread takes to record that a + // miner has been paid. + gPayoutViews.clear(); + { + std::lock_guard ledger(gPayoutLedgerMutex); + for (const PendingPayout& pp : gPendingPayouts) { + PendingPayoutView pv; + pv.matureAtHeight = pp.matureAtHeight; + pv.recipients = 0; + pv.totalAmount = 0; + for (auto& kv : pp.amounts) { + if (pp.paidTxids.count(kv.first)) + continue; + pv.recipients++; + pv.totalAmount += kv.second; + } + if (pv.recipients > 0) + gPayoutViews.push_back(pv); + } } - if (pv.recipients > 0) - gPayoutViews.push_back(pv); - } - int64 now = GetTime(); - if (now - gLastPoolStatusWrite >= 10) { - WritePoolStatusJsonLocked(); - gLastPoolStatusWrite = now; + int64 now = GetTime(); + if (now - gLastPoolStatusWrite >= 10) { + statusBlob = SerializePoolStatusLocked(); + gLastPoolStatusWrite = now; + fPublish = true; + } } + // Every payout mutex is released here, and gStatsMutex with them. This is + // where the two fsyncs happen, and no other thread is waiting behind them. + if (fPublish) + PublishPoolStatus(statusBlob); } // --------------------------------------------------------------------------- -- cgit v1.2.3 From 9d6fcb2058d90e3ecfb011e2168e4eee04e3a90a Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:41:28 +0000 Subject: payout: ask the wallet why a send failed instead of guessing from the balance A payment that the pool could not fund was being recorded as a fault against the payment, and 1440 faults erased the debt with the coin still in the operator's wallet. The classifier read GetBalance() after the failure and asked whether it covered `amount + nTransactionFee + CENT`. Every term of that was wrong. nTransactionFee is 0 on this pool -- it is only ever assigned from the GUI preferences dialog, which a headless node does not have -- so the entire reserve was one CENT. The fee it is meant to reserve for is GetMinFee(fDiscount=true), which is a step function of the serialised size: 0 below 10,000 bytes and (1 + bytes/1000) * CENT at or above it. There is no required-fee value between 1 and 11 CENT, so a one-CENT reserve is never the right number -- below the step nothing needs reserving, at the step it is short by a factor of eleven. Worse, the trigger correlates with the failure. A pool wallet close to broke selects many small inputs BECAUSE it is close to broke, and about 56 signed P2PKH inputs is 10 KB, which a pool that has been paying out accumulates as change. So: balance of amount + 0.05, the pre-send gate passes, CreateTransaction builds a transaction over 10 KB, the fee becomes 0.11, SelectCoins(amount + 0.11) fails, SendMoney returns false -- and (amount + 0.05) < (amount + 0 + 0.01) is false, so "funded", so a fault. The condition is stable, so it recurred every 60 s until the budget ran out and ForfeitPayoutRecipient erased the miner's money. Three changes. The wallet now says what it did. CreateTransaction takes an optional WalletSendResult out-parameter and sets WALLET_SEND_INSUFFICIENT_FUNDS on the one return that means a funding shortfall -- SelectCoins failing to cover nValue plus whatever fee that pass is working with, which on the second and later passes is GetMinFee() of the transaction the first pass actually built. SendMoney gains a four-argument overload returning that plus the fee that was required. The three-argument SendMoney stays, as a separate declaration rather than a default argument, because gui.cpp declares it itself instead of including main.h; walletcmd.cpp and the GUI are unchanged. These are wallet functions, not consensus. ClassifyPayoutSendFailure() then classifies the answer rather than guessing: locked or short of coin defers, anything else is a fault. There is no reserve left to get wrong, and the size-dependent fee is accounted for exactly, because the wallet computed it. And the give-up branch is gone. 1440 failures is not a decidable state, it is a count of how long we have been asking, and this pool reached it by misclassifying a permanent funding shortfall. A payment that keeps failing now stays in flight, keeps its ledger entry, keeps being reported as owed in pool_status.json, and keeps retrying once a minute. A debt that is never written off cannot be written off wrongly. The remaining call to ForfeitPayoutRecipient is the one decidable case -- the ledger names a string that is not a valid address, so no transaction can pay it -- which previously only dropped the in-flight record and left the ledger and the round proof claiming the debt forever. Tested against a real wallet and the real CreateTransaction, not against injected values, because the honest question is whether a wallet holding many small coins reports the state this code assumes. 60 coins of 1 BTF, balance 60.00, amount 59.95: CreateTransaction returns false with WALLET_SEND_INSUFFICIENT_FUNDS and a required fee of 0.11 -- and since GetMinFee returns 0 below 10,000 bytes, a required fee of 11 CENT is itself the measurement that the transaction crossed the step. The same test asserts that the old reserve judged that state "funded", and that the same wallet pays a 3-input transaction with a required fee of 0.00, which is why no fixed reserve is right for both. Property: a send that failed because the wallet could not fund it is deferred, whatever the transaction's size, and no number of failures erases a debt. --- src/main.cpp | 47 ++++++- src/main.h | 45 +++++- src/rpc.cpp | 441 +++++++++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 454 insertions(+), 79 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/main.cpp b/src/main.cpp index 252911b..7b3e555 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4907,9 +4907,17 @@ bool SelectCoins(int64 nTargetValue, set& setCoinsRet) -bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, int64& nFeeRequiredRet) +// presultRet, when given, says WHY this returned false: the caller cannot work +// it out afterwards. "Coin selection came up short" and "the wallet could not +// build the transaction" are indistinguishable from the outside -- both are +// just `false` -- and the difference decides whether a pool defers a payment +// or counts it against the payment's retry budget. See WalletSendResult. +bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, int64& nFeeRequiredRet, + WalletSendResult* presultRet) { nFeeRequiredRet = 0; + if (presultRet) + *presultRet = WALLET_SEND_FAILED; CRITICAL_BLOCK(cs_main) { // txdb must be opened before the mapWallet lock @@ -4940,10 +4948,21 @@ bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, in // fixed this in 107d9e288. int64 nTotalValue = nValue + nFee; - // Choose coins to use + // Choose coins to use. + // + // Failing here is a funding shortfall and nothing else: there + // is not enough spendable coin for nValue plus the fee this + // pass is working with. On the second and later passes that + // fee is GetMinFee() of the transaction the first pass built, + // so this is also how a wallet that can afford the amount but + // not the 11 * CENT charged at 10,000 bytes reports itself. set setCoins; if (!SelectCoins(nTotalValue, setCoins)) + { + if (presultRet) + *presultRet = WALLET_SEND_INSUFFICIENT_FUNDS; return false; + } int64 nValueIn = 0; foreach(CWalletTx* pcoin, setCoins) nValueIn += pcoin->GetCredit(); @@ -5013,6 +5032,8 @@ bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, in } } } + if (presultRet) + *presultRet = WALLET_SEND_OK; return true; } @@ -5075,13 +5096,24 @@ bool CommitTransactionSpent(const CWalletTx& wtxNew) -bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew) +// outcomeRet reports what happened rather than only whether it worked. A +// caller that has to decide what a failure MEANS -- the pool payout thread -- +// cannot reconstruct it from a balance reading afterwards, because the fee +// that was actually required depends on the size of a transaction that was +// never built. See WalletSendResult. +bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, + SendMoneyOutcome& outcomeRet) { + outcomeRet.result = WALLET_SEND_FAILED; + outcomeRet.nFeeRequired = 0; CRITICAL_BLOCK(cs_main) { int64 nFeeRequired; - if (!CreateTransaction(scriptPubKey, nValue, wtxNew, nFeeRequired)) + WalletSendResult createResult = WALLET_SEND_FAILED; + if (!CreateTransaction(scriptPubKey, nValue, wtxNew, nFeeRequired, &createResult)) { + outcomeRet.result = createResult; + outcomeRet.nFeeRequired = nFeeRequired; string strError; if (nValue + nFeeRequired > GetBalance()) // Not an oversized transaction -- this branch is simply "the @@ -5119,6 +5151,13 @@ bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew) } wtxNew.RelayWalletTransaction(); } + outcomeRet.result = WALLET_SEND_OK; MainFrameRepaint(); return true; } + +bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew) +{ + SendMoneyOutcome ignored; + return SendMoney(scriptPubKey, nValue, wtxNew, ignored); +} diff --git a/src/main.h b/src/main.h index 8bb536b..382dd9c 100644 --- a/src/main.h +++ b/src/main.h @@ -342,9 +342,52 @@ struct WalletRecoveryAudit } }; WalletRecoveryAudit GetWalletRecoveryAudit(); -bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& txNew, int64& nFeeRequiredRet); + +// Why a send did not go through. +// +// The pool's payout thread has to decide, on every failed SendMoney, whether +// the failure was the wallet's condition (wait for it to change) or a fault +// (something is wrong with this payment). It used to guess by re-reading +// GetBalance() afterwards and allowing a fixed CENT of headroom for the fee. +// That cannot work: the fee here is a step function of the SERIALISED SIZE of +// a transaction that no longer exists, and the step is from 0 to 11 * CENT at +// 10,000 bytes -- so a one-CENT allowance is either unnecessary or an order of +// magnitude short, never right. A pool wallet close to broke selects many +// small inputs BECAUSE it is close to broke, which is what pushes the +// transaction over 10 KB, so the guess is wrong exactly when the money is +// tight. +// +// The wallet already knows the answer; it just did not say. SelectCoins() +// failing is "there is not enough spendable coin to cover the amount plus +// whatever fee this size of transaction requires", and it is the only reason +// CreateTransaction() can fail that is about funding rather than about the +// wallet being broken. +enum WalletSendResult +{ + WALLET_SEND_OK = 0, + WALLET_SEND_INSUFFICIENT_FUNDS, // coin selection could not cover amount+fee + WALLET_SEND_FAILED // anything else: could not build, sign or accept +}; + +// What the wallet did with a send request. nFeeRequired is the fee the +// transaction turned out to need, which for a failed send is what coin +// selection was asked for on top of the amount and could not find -- 0 if it +// never got that far. +struct SendMoneyOutcome +{ + WalletSendResult result = WALLET_SEND_OK; + int64 nFeeRequired = 0; +}; + +bool CreateTransaction(CScript scriptPubKey, int64 nValue, CWalletTx& txNew, int64& nFeeRequiredRet, + WalletSendResult* presultRet=NULL); bool CommitTransactionSpent(const CWalletTx& wtxNew); +// The three-argument form is kept, and kept as a distinct declaration rather +// than a defaulted argument, because gui.cpp declares SendMoney itself instead +// of including this header. bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew); +bool SendMoney(CScript scriptPubKey, int64 nValue, CWalletTx& wtxNew, + SendMoneyOutcome& outcomeRet); diff --git a/src/rpc.cpp b/src/rpc.cpp index 403cb14..06de7f2 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1700,8 +1700,11 @@ static void ForgetPayment(const PayingRecord& r) // When the last record for a round is gone the round is no longer in // flight, so a later flush may explode it again if the ledger still - // shows recipients owed (a give-up, for instance, leaves the entry - // standing). + // shows recipients owed. That is the intended behaviour and it is now + // the only way a stalled payment ever leaves this list: a payment that + // keeps failing is retried forever rather than given up on, so its + // record stays here, and the flush that would re-explode it is stopped + // by PaymentInFlightLocked. bool stillInFlight = false; for (const auto& e : gPaying) if (SamePayoutRound(e.blockHeight, e.blockHash, @@ -1776,9 +1779,16 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) LogPrint("payout", "[payout] block %d fully settled\n", r.blockHeight); } -// Write a recipient off. Only reached after a full budget of failures that -// were NOT the wallet being locked or short of coin -- see the classification -// in PayoutThreadFn -- against a block still on the main chain. +// Write a recipient off. +// +// Only ever called for a state that is DECIDABLE -- currently one: the ledger +// names an address that is not a valid address, so no transaction paying it +// can be constructed by this or any other build. It is not called, and must +// not be, because a payment has failed some number of times. "The wallet said +// no N times" is not a decidable state, it is a description of how long we +// have been asking; a debt written off on the strength of it is a debt the +// operator keeps, and the arithmetic that decided WHY the wallet said no was +// wrong for a year (see PayoutThreadFn). // // The amount is removed from the entry rather than marked paid, so the money // is not reported as sent. Once nothing is left owed the entry retires. The @@ -1810,6 +1820,60 @@ static void ForfeitPayoutRecipient(const PayingRecord& r, const std::string& rea r.address, reason); } +// What a failed send means for the payment's retry budget. +// +// A deferral is "the wallet's condition is temporarily wrong": wait, and do +// not hold it against this payment. A fault is "the wallet is funded and +// unlocked and still would not do it", which is worth logging loudly and +// retrying with backoff. +// +// This used to be decided by re-reading GetBalance() after the failure and +// comparing it against `amount + nTransactionFee + CENT`. Every term of that +// was wrong: +// +// * nTransactionFee is 0 in production. It is only ever assigned from the +// GUI preferences dialog (gui.cpp), which a headless pool node does not +// have, so the whole reserve was the CENT. +// +// * The fee the reserve is for is GetMinFee(fDiscount=true), a STEP +// function of the serialised size: 0 below 10,000 bytes, and +// (1 + bytes/1000) * CENT at or above it -- so 11 * CENT at the step. +// There is no required-fee value between 1 and 11 CENT. A one-CENT +// reserve is therefore never the right number: below the step nothing +// needs reserving, and at the step it is short by a factor of eleven. +// +// * A pool wallet close to broke selects many small inputs BECAUSE it is +// close to broke, and ~56 signed P2PKH inputs is 10 KB. The trigger and +// the failure are positively correlated: the guess is wrong precisely +// when the pool is nearly out of money. +// +// Concretely, with a balance of amount + 0.05: the pre-send gate passes, +// CreateTransaction builds a >10 KB transaction, the fee becomes 0.11, +// SelectCoins(amount + 0.11) fails, SendMoney returns false -- and the old +// arithmetic said (amount + 0.05) < (amount + 0.01) is false, so "funded", +// so a fault. The condition is stable, so it recurred every 60 s, and 1440 of +// them erased the debt with the coin still in the operator's wallet. +// +// So stop guessing. The wallet is asked to say what it did, and this only +// classifies the answer. WALLET_SEND_INSUFFICIENT_FUNDS is coin selection +// failing to cover amount plus whatever fee that size of transaction +// required, whatever that turned out to be -- no reserve to get wrong. +enum PayoutFailureClass { + PAYOUT_FAILURE_DEFER = 0, // conditions are wrong; wait for them to change + PAYOUT_FAILURE_FAULT // funded and unlocked, and it still would not go +}; + +static PayoutFailureClass ClassifyPayoutSendFailure(WalletSendResult result, + bool fWalletLocked) +{ + // A locked wallet cannot sign. Nothing about this payment is wrong. + if (fWalletLocked) + return PAYOUT_FAILURE_DEFER; + if (result == WALLET_SEND_INSUFFICIENT_FUNDS) + return PAYOUT_FAILURE_DEFER; + return PAYOUT_FAILURE_FAULT; +} + // Payout execution thread: works through gPaying, calls SendMoney. // Runs independently of the event loop so slow wallet operations // (SendMoney takes cs_main) never stall miner I/O. @@ -1867,9 +1931,24 @@ static void PayoutThreadFn(void*) uint160 h160; if (!AddressToHash160(r.address, h160)) { - // Should not happen -- bad addresses are filtered in QueuePayouts - LogPrint("payout", "[payout] SKIPPED bad address '%s'\n", - r.address.c_str()); + // Should not happen -- bad addresses are filtered in + // QueuePayouts. If one is here anyway it is decidable and + // permanent: there is no transaction that pays a string which + // is not an address, so retrying is not waiting for anything. + // + // This used to only ForgetPayment(), which drops the in-flight + // record and leaves the ledger entry claiming the debt and + // pool_rounds.json advertising the recipient as unpaid + // forever, with the next flush re-exploding it. Write it off + // properly and say why. + const std::string reason = + strprintf("'%s' is not a valid address, so no transaction" + " can pay it", r.address.c_str()); + LogPrint("payout", "[payout] SKIPPED bad address '%s' --" + " %s owed for block %d cannot be paid\n", + r.address.c_str(), FormatMoney(r.amount).c_str(), + r.blockHeight); + ForfeitPayoutRecipient(r, reason); ForgetPayment(r); continue; } @@ -1926,13 +2005,17 @@ static void PayoutThreadFn(void*) // the operator's wallet and nothing on disk saying it was owed. // // nTransactionFee is the part of the fee that can be known before - // the transaction exists. The rest is GetMinFee(), a function of - // the serialised size and therefore of how many inputs SelectCoins - // ends up picking, which cannot be predicted from here -- so this - // gate deliberately does NOT try to reserve it. Refusing to attempt - // a payment the wallet could actually have made would be its own - // way of not paying a miner. That residual is handled after the - // fact, by classifying the failure below. + // the transaction exists, and in production it is 0 -- it is only + // ever assigned from the GUI preferences dialog, which a pool node + // does not have -- so including it here changes nothing on this + // pool and is kept only because a GUI operator can set it. The + // rest is GetMinFee(), a function of the serialised size and + // therefore of how many inputs SelectCoins ends up picking, which + // cannot be predicted from here -- so this gate deliberately does + // NOT try to reserve it. Refusing to attempt a payment the wallet + // could actually have made would be its own way of not paying a + // miner. That residual is handled after the fact, by asking the + // wallet why it refused -- see ClassifyPayoutSendFailure. int64 nSpendable = GetBalance(); if (nSpendable < r.amount + nTransactionFee) { r.deferrals++; @@ -1977,7 +2060,8 @@ static void PayoutThreadFn(void*) wtx.mapValue["pool_payout_address"] = r.address; wtx.mapValue["pool_payout_amount"] = strprintf("%lld", r.amount); - if (SendMoney(sc, r.amount, wtx)) { + SendMoneyOutcome outcome; + if (SendMoney(sc, r.amount, wtx, outcome)) { // The wallet holds the transaction before this returns, so a // crash between here and the ledger write is recoverable: the // probe above finds it by id+address+amount and does not @@ -1989,74 +2073,67 @@ static void PayoutThreadFn(void*) FormatMoney(r.amount).c_str(), r.address.c_str(), wtx.GetHash().ToString().substr(0, 12).c_str()); } else { - // Why did it fail? The answer decides whether this costs a - // retry, and the retry budget is what eventually erases a - // miner's money -- so guessing here is how a pool keeps coin - // it owes. + // Why did it fail? The answer used to decide whether the + // payment eventually got erased, and it was decided by + // guessing from a balance snapshot -- see + // ClassifyPayoutSendFailure for the arithmetic and what it + // cost. The wallet now says what it did, and nothing here + // erases anything. // - // Re-read the conditions rather than trusting the ones checked - // before the attempt: SendMoney takes cs_main and can block for - // a long time, so the balance and the lock state may both have - // moved underneath it. - // - // Short of coin covers the size-dependent part of the fee that - // the gate above deliberately does not reserve: if the balance - // no longer covers amount + configured fee + one fee tier, the - // failure is a funding shortfall, not a fault. - int64 nAfter = GetBalance(); - bool fLocked = IsWalletLocked(); - bool fUnfunded = nAfter < r.amount + nTransactionFee + CENT; - - if (fLocked || fUnfunded) { - // Conditions are temporarily wrong. Wait; do not spend the - // budget that leads to a permanent write-off. + // The lock state is re-read rather than trusted from before + // the attempt: SendMoney takes cs_main and can block for a + // long time, so it may have moved underneath us. The funding + // answer does not need re-reading -- it is a statement about + // the attempt that was actually made. + bool fLocked = IsWalletLocked(); + int64 nAfter = GetBalance(); + + if (ClassifyPayoutSendFailure(outcome.result, fLocked) + == PAYOUT_FAILURE_DEFER) { + // Conditions are temporarily wrong. Wait. r.deferrals++; r.retryAfter = GetTime() + 60; if (r.deferrals == 1 || r.deferrals % 60 == 0) LogPrint("payout", "[payout] deferring %s to %s -- send" - " failed with %s (%s spendable, %s needed with" - " fee headroom); waiting %d min so far\n", + " failed with %s (%s spendable; the wallet" + " needed a fee of %s for a transaction this" + " size); waiting %d min so far\n", FormatMoney(r.amount).c_str(), r.address.c_str(), fLocked ? "the wallet locked" : "too little coin", FormatMoney(nAfter).c_str(), - FormatMoney(r.amount + nTransactionFee + CENT).c_str(), + FormatMoney(outcome.nFeeRequired).c_str(), r.deferrals); ReschedulePayment(r); - } else if (r.retries < 1440) { - // A genuine fault: funded, unlocked, block on the main - // chain, and the wallet still would not build or accept - // the transaction. Retry with backoff. + } else { + // Funded, unlocked, block on the main chain, and the + // wallet still would not build, sign or accept the + // transaction. That is worth shouting about; it is not + // grounds to keep the money. + // + // There is deliberately no give-up branch. It used to + // forfeit the debt after 1440 of these, which is not a + // decidable state -- it is a count of how long we have + // been asking, and it was reached by misclassifying a + // funding shortfall as a fault. A debt that is never + // written off cannot be written off wrongly. The payment + // stays in flight, keeps its ledger entry, keeps being + // reported in pool_status.json as owed, and keeps + // retrying once a minute until the operator or the wallet + // makes it possible. r.retries++; r.retryAfter = GetTime() + 60; ReschedulePayment(r); - LogPrint("payout", "[payout] FAILED to pay %s to %s" - " (%s spendable, wallet unlocked) -- retry %d/1440" - " in 60s\n", - FormatMoney(r.amount).c_str(), r.address.c_str(), - FormatMoney(nAfter).c_str(), r.retries); - } else { - // 1440 failures that were each individually impossible to - // explain by maturity, funding, the wallet being locked, or - // a reorg. That is the only state in which this payment can - // be called impossible rather than merely blocked. - const std::string reason = strprintf( - "wallet refused this payment %d times while funded" - " (%s spendable), unlocked, and block %d on the main" - " chain", r.retries, FormatMoney(nAfter).c_str(), - r.blockHeight); - LogPrint("payout", "[payout] GIVING UP on %s to %s: %s" - " -- payment forfeited\n", - FormatMoney(r.amount).c_str(), r.address.c_str(), - reason.c_str()); - // Write the debt off in the ledger as well as dropping the - // in-flight record. Under the survives-until-fully-paid - // model the entry would otherwise never complete, and every - // later flush would explode it again -- a forfeit that - // silently becomes an infinite retry. The round proof is - // told too, so the public record does not keep claiming the - // address is owed. - ForfeitPayoutRecipient(r, reason); - ForgetPayment(r); + // Every minute at first, then hourly: a fault that is not + // going away must not turn the log into one line a minute + // forever, and must not go quiet either. + if (r.retries <= 5 || r.retries % 60 == 0) + LogPrint("payout", "[payout] FAILED to pay %s to %s" + " (%s spendable, wallet unlocked, block %d on" + " the main chain) -- attempt %d, retrying in" + " 60s; this debt stands until it is paid\n", + FormatMoney(r.amount).c_str(), r.address.c_str(), + FormatMoney(nAfter).c_str(), r.blockHeight, + r.retries); } } } @@ -2850,13 +2927,19 @@ static void PayoutTestRemoveDir(const std::string& dir) } RemoveDirectoryA(dir.c_str()); #else + // One level of recursion, because CreateTransaction() opens a Berkeley DB + // environment and BDB puts its logs in a "database" subdirectory. Without + // this the scratch directory cannot be removed and the test leaves one + // behind on every run, which is what d5b3503 was for. DIR* d = opendir(dir.c_str()); if (d) { struct dirent* ent; while ((ent = readdir(d)) != NULL) { std::string name = ent->d_name; if (name == "." || name == "..") continue; - unlink((dir + "/" + name).c_str()); + const std::string path = dir + "/" + name; + if (unlink(path.c_str()) != 0) + PayoutTestRemoveDir(path); } closedir(d); } @@ -2942,6 +3025,48 @@ static std::string PayoutTestAddWalletTx(const std::string& comment, return hash.GetHex(); } +// A real key, in the keystore, so a test wallet's outputs are IsMine() and can +// actually be signed -- both are needed for CreateTransaction to build the +// large transaction the fee schedule is being tested against. Inserted +// straight into the key maps rather than through AddKey(), which would also +// write it to a wallet.dat. +static std::string PayoutTestAddSpendableKey() +{ + CKey key; + key.MakeNewKey(); + std::vector vchPubKey = key.GetPubKey(); + CRITICAL_BLOCK(cs_mapKeys) + { + mapKeys[vchPubKey] = key.GetPrivKey(); + mapPubKeys[Hash160(vchPubKey)] = vchPubKey; + } + return Hash160ToAddress(Hash160(vchPubKey)); +} + +// Give the wallet `n` spendable outputs of `each`, one transaction apiece -- +// which is the granularity SelectCoins works at, and therefore the number of +// inputs the transaction spending them will carry. +// +// Deliberately not coinbases: a null prevout would make CWalletTx::GetCredit +// return 0 until COINBASE_SPENDABLE_DEPTH, and there is no chain here. +static void PayoutTestFundWallet(int n, int64 each, const std::string& address) +{ + const CScript sc = PayoutTestScriptFor(address); + CRITICAL_BLOCK(cs_mapWallet) + { + for (int i = 0; i < n; i++) { + CWalletTx wtx; + wtx.vin.resize(1); + wtx.vin[0].prevout.hash = (uint64)(i + 1); + wtx.vin[0].prevout.n = 0; + wtx.vout.resize(1); + wtx.vout[0].nValue = each; + wtx.vout[0].scriptPubKey = sc; + mapWallet[wtx.GetHash()] = wtx; + } + } +} + // Defined below with the event loop; the status-file publication test drives // it directly. static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal); @@ -2967,11 +3092,32 @@ int RunPayoutLedgerSelfTest() #else unsigned long pid = (unsigned long)getpid(); #endif - std::string dir = strprintf("payout_selftest_%d_%lu", (int)GetTime(), pid); + // Absolute, and the process moves into it. + // + // Both are needed by the CreateTransaction section: Berkeley DB resolves + // its log directory relative to the environment home, so a relative + // datadir makes dbenv.open() fail with DB_RUNRECOVERY; and BDB opens + // db.log relative to the working directory, which is src/ during `make + // tests`. Moving in puts it inside the scratch directory, where it dies + // with everything else. + std::string cwd = "."; + { +#ifdef _WIN32 + char buf[MAX_PATH]; + if (GetCurrentDirectoryA(MAX_PATH, buf) > 0) cwd = buf; +#else + char buf[4096]; + if (getcwd(buf, sizeof(buf)) != NULL) cwd = buf; +#endif + } + std::string dir = cwd + "/" + + strprintf("payout_selftest_%d_%lu", (int)GetTime(), pid); #ifdef _WIN32 CreateDirectoryA(dir.c_str(), NULL); + SetCurrentDirectoryA(dir.c_str()); #else mkdir(dir.c_str(), 0700); + if (chdir(dir.c_str()) != 0) { /* stay put; the paths are absolute anyway */ } #endif strSetDataDir = dir; fprintf(stdout, " scratch datadir: %s\n", dir.c_str()); @@ -3494,7 +3640,141 @@ int RunPayoutLedgerSelfTest() strPoolRoundsFile = savedRoundsFile; } - // -- 11. No payout mutex is held across the status-file write ------------ + // -- 11. A send that failed for want of coin is not a fault -------------- + // + // The defect this replaces: the classifier decided "was this a funding + // problem?" by re-reading GetBalance() and allowing amount + + // nTransactionFee + CENT. nTransactionFee is 0 on a headless node, and + // GetMinFee() is a step function -- 0 below 10,000 bytes, 11 * CENT at it + // -- so the allowance was either unnecessary or eleven times too small, + // and the ledger's own retry budget then wrote the debt off after 1440 + // repeats of the same stable condition. + // + // This is tested against a real wallet and the real CreateTransaction, not + // against injected values: the honest question is not "does my enum + // switch work" but "does a wallet holding many small coins actually report + // the state I think it does". 60 outputs of 1 BTF give ~180 signed P2PKH + // bytes each, which is where the fee schedule steps. Nothing is committed + // or broadcast -- CreateTransaction only builds. + { + PayoutTestResetState(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + + // CreateTransaction opens CTxDB("r"), which throws if blkindex.dat is + // not there. Create it in the scratch datadir first. + bool dbOk = true; + try { + CTxDB txdb("cr"); + } catch (const std::exception&) { + dbOk = false; + } + check(dbOk, "a scratch block index exists for CreateTransaction to open"); + + const std::string walletAddr = PayoutTestAddSpendableKey(); + const std::string payee = PayoutTestAddress(0x77); + const CScript payeeScript = PayoutTestScriptFor(payee); + + // -- the >= 10 KB case: 60 small coins, balance = amount + 0.05 ------ + PayoutTestFundWallet(60, 1 * COIN, walletAddr); + const int64 balance = GetBalance(); + check(balance == 60 * COIN, + "the test wallet holds 60 separately spendable coins"); + + const int64 amount = balance - 5 * CENT; + // The pre-send gate in PayoutThreadFn passes: the balance covers the + // amount and the only fee that can be known in advance. + check(balance >= amount + nTransactionFee, + "the pre-send gate would let this payment through"); + + CWalletTx wtx; + SendMoneyOutcome outcome; + int64 feeRequired = 0; + WalletSendResult result = WALLET_SEND_OK; + bool built = true; + if (dbOk) { + try { + built = CreateTransaction(payeeScript, amount, wtx, feeRequired, + &result); + } catch (const std::exception&) { + built = true; // report as a failure below + } + } + check(dbOk && !built, + "a wallet of many small coins cannot fund amount + the fee its" + " own size demands"); + check(dbOk && result == WALLET_SEND_INSUFFICIENT_FUNDS, + "the wallet reports that as a funding shortfall, not as a" + " failure to build"); + // GetMinFee(fDiscount) returns 0 below 10,000 bytes and + // (1 + bytes/1000) * CENT at or above, so a required fee of 11 CENT or + // more IS the statement that the transaction crossed 10 KB. Measured + // through the real serialiser rather than asserted. + check(dbOk && feeRequired >= 11 * CENT, + "the transaction it built crossed 10,000 bytes, so the fee" + " stepped from 0 to at least 11 CENT"); + + // The defect, stated as arithmetic. The old classifier asked whether + // the balance covered amount + nTransactionFee + CENT; it did, so it + // called a permanent funding shortfall a fault and started counting + // towards a write-off. + check(!(balance < amount + nTransactionFee + CENT), + "the reserve this replaces would have judged this send a fault"); + check(balance < amount + feeRequired, + "while the fee actually required was more than the balance had"); + + outcome.result = result; + outcome.nFeeRequired = feeRequired; + check(dbOk && ClassifyPayoutSendFailure(outcome.result, false) + == PAYOUT_FAILURE_DEFER, + "a >= 10 KB send that ran out of coin is deferred, not counted" + " against the payment"); + + // -- the < 10 KB case: no reserve is needed at all ------------------- + // + // The other half of the arithmetic. Below the step the required fee is + // 0, so a reserve of any size is a reason to refuse a payment the + // wallet could have made -- which is its own way of not paying a + // miner. + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestFundWallet(3, 1 * COIN, walletAddr); + const int64 smallAmount = 3 * COIN - 5 * CENT; + CWalletTx smallTx; + int64 smallFee = -1; + WalletSendResult smallResult = WALLET_SEND_FAILED; + bool smallBuilt = false; + if (dbOk) { + try { + smallBuilt = CreateTransaction(payeeScript, smallAmount, smallTx, + smallFee, &smallResult); + } catch (const std::exception&) { + smallBuilt = false; + } + } + check(dbOk && smallBuilt && smallResult == WALLET_SEND_OK, + "the same balance pays a small transaction with no headroom at" + " all"); + check(dbOk && smallFee == 0, + "because below 10,000 bytes the required fee is zero"); + + // -- and the classification of every other answer -------------------- + check(ClassifyPayoutSendFailure(WALLET_SEND_INSUFFICIENT_FUNDS, false) + == PAYOUT_FAILURE_DEFER && + ClassifyPayoutSendFailure(WALLET_SEND_INSUFFICIENT_FUNDS, true) + == PAYOUT_FAILURE_DEFER && + ClassifyPayoutSendFailure(WALLET_SEND_FAILED, true) + == PAYOUT_FAILURE_DEFER, + "short of coin or unable to sign is always a deferral"); + check(ClassifyPayoutSendFailure(WALLET_SEND_FAILED, false) + == PAYOUT_FAILURE_FAULT, + "funded and unlocked and still refused is a fault"); + + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + } + + // -- 12. No payout mutex is held across the status-file write ------------ // // RefreshStats runs on the event loop -- the thread that owns every // miner's socket -- and used to hold gStatsMutex and gPayoutLedgerMutex at @@ -3628,6 +3908,19 @@ int RunPayoutLedgerSelfTest() PayoutTestResetState(); CRITICAL_BLOCK(cs_mapWallet) mapWallet.clear(); + // Out of the scratch directory before removing it, and out of the key maps + // too: the keys this test generated are process-local, but leaving them in + // the keystore would let a later test think a script was its own. + CRITICAL_BLOCK(cs_mapKeys) + { + mapKeys.clear(); + mapPubKeys.clear(); + } +#ifdef _WIN32 + SetCurrentDirectoryA(cwd.c_str()); +#else + if (chdir(cwd.c_str()) != 0) { /* nothing useful to do about it */ } +#endif PayoutTestRemoveDir(dir); strSetDataDir = savedDataDir; nBestHeight = savedBestHeight; -- cgit v1.2.3 From 3ec75952d9142bb5aa58307fc5a07db264ecdb24 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:44:16 +0000 Subject: payout: require a confirmation before reading a pre-upgrade comment as payment FindRecordedPoolPayout's legacy branch matched any wallet transaction carrying this height's payout comment and an output of exactly this amount to this address, and never asked whether that transaction was in the chain. A pre-upgrade payout that never confirmed is evidence of NON-payment, and it was being read as payment: the recipient is dropped from the in-flight list, a txid is recorded against the ledger entry, and the debt retires having never been settled. The miner is not paid and nothing on disk says they were owed. That is not hypothetical here. This tree has had a 280-block reorg (see the note in FlushMaturePayouts). A payout invalidated by one is still sitting in mapWallet with its comment intact, and the first boot after this upgrade -- the boot where the binary is swapped after an unclean shutdown with payments in flight -- is precisely when the legacy probe runs against it. The structured branch is deliberately left alone. This process sent those transactions moments earlier, and a send that failed leaves nothing behind: SendMoney calls RollbackTransactionSpent, which calls EraseFromWallet. Depth there would only reject payments that really were made. The check cannot be done inside the wallet scan. GetDepthInMainChain() reads mapBlockIndex and pindexBest, which belong to cs_main, and cs_main is taken before cs_mapWallet everywhere else in this tree -- CreateTransaction, ConnectBlock -- so testing it under cs_mapWallet would invert the lock order in the payout thread. Comment matches are collected in the scan and adjudicated afterwards under cs_main then cs_mapWallet, in that order. That also fixes a second thing: the old code stopped at the first comment match, so one unconfirmed transaction could hide a confirmed one behind it. A rejection logs the txid, height, amount and depth, and says the recipient will be paid, because this is the one decision where being wrong is unrecoverable in the direction that costs a miner money. Property: a payment is only ever inferred from a pre-upgrade comment when the transaction carrying it is in the main chain. --- src/rpc.cpp | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 9 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 06de7f2..b0436ba 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1339,7 +1339,16 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, want << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; const std::string comment = PayoutComment(blockHeight); - std::string legacyTxid; + // Candidate legacy matches, adjudicated after cs_mapWallet is released. + // + // The depth check cannot happen inside the scan: GetDepthInMainChain() + // reads mapBlockIndex and pindexBest, which belong to cs_main, and cs_main + // is taken BEFORE cs_mapWallet everywhere else in this tree + // (CreateTransaction, ConnectBlock). Taking it the other way round here + // would be a lock-order inversion in the payout thread. There is at most a + // handful of these: a candidate has to carry this exact height's comment + // and an output of exactly this amount to this address. + std::vector legacyCandidates; // One pass, not two: this scan runs under cs_mapWallet, which the whole // node contends on, and the merge is already paying for it once per record. @@ -1365,27 +1374,63 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, return PAYOUT_PROBE_STRUCTURED; } - if (!fHaveScript || !legacyTxid.empty()) + if (!fHaveScript) continue; // The comment is written only by the payout path, so its presence // already identifies the transaction as one of ours. map::const_iterator ci = wtx.mapValue.find("comment"); if (ci == wtx.mapValue.end() || ci->second != comment) continue; - std::string txid = wtx.GetHash().GetHex(); - if (excluded.count(txid)) + if (excluded.count(wtx.GetHash().GetHex())) continue; // already credited to a different debt foreach (const CTxOut& txout, wtx.vout) if (txout.nValue == amount && txout.scriptPubKey == want) { - legacyTxid = txid; + legacyCandidates.push_back(it->first); break; } } } - if (!legacyTxid.empty()) { - txidRet = legacyTxid; - return PAYOUT_PROBE_LEGACY; + // A legacy match must have CONFIRMED. + // + // The structured path does not need this and deliberately does not have + // it: this process sent that transaction moments ago, and a send that + // failed leaves nothing behind, because SendMoney calls + // RollbackTransactionSpent, which calls EraseFromWallet. + // + // The legacy path is the opposite case in every respect. Those + // transactions are days or weeks old, so whether they confirmed is a + // settled fact rather than an open question, and a pre-upgrade payout that + // never confirmed is evidence of NON-payment about to be read as payment. + // This chain has had a 280-block reorg (see FlushMaturePayouts): a payout + // invalidated by it is still sitting in mapWallet with its comment intact, + // and the first boot after this upgrade is exactly when it gets matched. + // The cost of being wrong here is a miner who is never paid and no record + // that they were owed. + CRITICAL_BLOCK(cs_main) + CRITICAL_BLOCK(cs_mapWallet) + { + for (const uint256& hash : legacyCandidates) { + map::const_iterator it = mapWallet.find(hash); + if (it == mapWallet.end()) + continue; // erased under us; it is not evidence of anything + int depth = it->second.GetDepthInMainChain(); + if (depth < 1) { + // Loud: this is the difference between a miner having been + // paid and a miner about to be paid twice, decided on a fact + // that is easy to check and impossible to recover afterwards. + LogPrint("payout", "[payout] wallet tx %s carries the payout" + " comment for height %d and an output of exactly %s to" + " %s, but it is NOT in the main chain (depth %d) --" + " refusing to read it as payment; this recipient will" + " be paid\n", + hash.GetHex().c_str(), blockHeight, + FormatMoney(amount).c_str(), address.c_str(), depth); + continue; + } + txidRet = hash.GetHex(); + return PAYOUT_PROBE_LEGACY; + } } return PAYOUT_PROBE_NONE; } @@ -2997,13 +3042,47 @@ static CScript PayoutTestScriptFor(const std::string& address) return sc; } +// Block index entries fabricated by PayoutTestAddWalletTx, so they can be +// freed and unhooked when the test finishes. gPayoutTestChainTip is the end of +// that chain: blocks are linked onto it rather than each replacing the tip, +// because IsInMainChain() is "pnext is set, or I am the tip". +static std::vector gPayoutTestBlockIndexes; +static CBlockIndex* gPayoutTestChainTip = NULL; + +static void PayoutTestClearBlockIndex() +{ + CRITICAL_BLOCK(cs_main) + { + for (CBlockIndex* p : gPayoutTestBlockIndexes) { + for (std::map::iterator it = mapBlockIndex.begin(); + it != mapBlockIndex.end(); ) { + if (it->second == p) mapBlockIndex.erase(it++); + else ++it; + } + delete p; + } + gPayoutTestBlockIndexes.clear(); + gPayoutTestChainTip = NULL; + pindexBest = NULL; + } +} + // Put a transaction in the wallet map that looks exactly like one this pool // sent. `nonce` only exists to keep the hashes distinct. +// +// fConfirmed decides what GetDepthInMainChain() will answer for it, which is +// what the legacy probe now gates on. A confirmed one is given a block index +// entry of its own: an empty merkle branch hashes to the transaction itself, +// so a block whose merkle root IS that hash accepts it at index 0, and +// pointing pindexBest at that block puts it in the main chain one deep. An +// unconfirmed one keeps hashBlock 0, which is depth 0 -- the shape a payout +// left behind by a reorg has. static std::string PayoutTestAddWalletTx(const std::string& comment, const std::string& address, int64 amount, int nonce, - const std::string& structuredId) + const std::string& structuredId, + bool fConfirmed = true) { CWalletTx wtx; wtx.vin.resize(1); @@ -3020,6 +3099,32 @@ static std::string PayoutTestAddWalletTx(const std::string& comment, wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount); } uint256 hash = wtx.GetHash(); + if (fConfirmed) { + // A block hash distinct from the transaction's own. + uint256 blockHash = hash; + ((unsigned char*)&blockHash)[0] ^= 0xff; + CRITICAL_BLOCK(cs_main) + { + CBlockIndex* pindex = new CBlockIndex(); + pindex->hashMerkleRoot = hash; + // Linked onto the end of the fabricated chain rather than simply + // becoming the new tip: IsInMainChain() is "pnext is set, or I am + // the tip", so a second confirmed transaction would otherwise take + // the first one out of the chain again. + pindex->pprev = gPayoutTestChainTip; + pindex->nHeight = gPayoutTestChainTip ? gPayoutTestChainTip->nHeight + 1 : 1; + if (gPayoutTestChainTip) + gPayoutTestChainTip->pnext = pindex; + gPayoutTestChainTip = pindex; + std::map::iterator mi = + mapBlockIndex.insert(std::make_pair(blockHash, pindex)).first; + pindex->phashBlock = &mi->first; + pindexBest = pindex; + gPayoutTestBlockIndexes.push_back(pindex); + } + wtx.hashBlock = blockHash; + wtx.nIndex = 0; + } CRITICAL_BLOCK(cs_mapWallet) mapWallet[hash] = wtx; return hash.GetHex(); @@ -3497,6 +3602,46 @@ int RunPayoutLedgerSelfTest() check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, "structured payout metadata is matched in preference to a comment"); + + // A comment-matching transaction that never made it into the chain is + // evidence of NON-payment. This tree has seen a 280-block reorg; a + // pre-upgrade payout invalidated by one is still in mapWallet with its + // comment intact, and the first boot after the upgrade is exactly when + // it would be matched. + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); + const std::string addrC = PayoutTestAddress(0x33); + PayoutTestAddWalletTx(comment, addrC, amount, 4, "", false); + check(FindRecordedPoolPayout(id, height, addrC, amount, none, found) + == PAYOUT_PROBE_NONE, + "a pre-upgrade payout that never confirmed is not read as" + " payment"); + + // The same transaction, confirmed, is. + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + const std::string confirmedTxid = + PayoutTestAddWalletTx(comment, addrC, amount, 4, "", true); + check(FindRecordedPoolPayout(id, height, addrC, amount, none, found) + == PAYOUT_PROBE_LEGACY && found == confirmedTxid, + "the same payout, confirmed, is"); + + // And an unconfirmed one does not shadow a confirmed one that would + // have matched: the probe used to stop at the first comment match. + const std::string alsoConfirmed = + PayoutTestAddWalletTx(comment, addrC, amount, 6, "", true); + PayoutTestAddWalletTx(comment, addrC, amount, 5, "", false); + std::set spentFirst; + spentFirst.insert(confirmedTxid); + check(FindRecordedPoolPayout(id, height, addrC, amount, spentFirst, found) + == PAYOUT_PROBE_LEGACY && found == alsoConfirmed, + "an unconfirmed transaction does not hide a confirmed one behind" + " it"); + + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); } // -- 9. On-disk compatibility with the live production ledger ------------ @@ -3908,6 +4053,7 @@ int RunPayoutLedgerSelfTest() PayoutTestResetState(); CRITICAL_BLOCK(cs_mapWallet) mapWallet.clear(); + PayoutTestClearBlockIndex(); // Out of the scratch directory before removing it, and out of the key maps // too: the keys this test generated are process-local, but leaving them in // the keystore would let a later test think a script was its own. -- cgit v1.2.3 From 9e5dc16941ac34e77157d85692d1774d8698b1b4 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:46:03 +0000 Subject: payout: quarantine a ledger that failed to load instead of overwriting it All three loaders push entries into the live structure inside the try block, so an exception part way down a file leaves a PARTIAL picture in memory: the entries before the bad one, and nothing after it. Both loaders then return normally, having logged one line under a debug category, and the node carries on as though that were the whole ledger. The first publish afterwards writes the partial state over the file. ReconcilePayouts publishes unconditionally during startup, so that is milliseconds later -- before an operator could see the warning, let alone act on it. The only durable record of what the pool owed is then gone, and it is gone in the direction that costs miners money: the entries that failed to parse are the ones that vanish. 86b39b9's own commit message names this failure mode and then fixes only the write side. On any parse exception the file is now renamed to .bad., the path is added to a refusal set, and PublishVersioned declines to write it for the rest of the process. A node that has lost its ledger now comes up visibly missing it rather than quietly appearing to owe nobody anything, and the bytes are still there to reconcile against. Restarting into the same fault does not consume the evidence, because the quarantine name carries the time. Logged with printf rather than LogPrint: an operator who has just lost a payout ledger has to be told at whatever settings they were already running, and the name the file was moved to is the only route back to the data. Property: a payout file that did not parse completely is never overwritten by what did parse. --- src/rpc.cpp | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 8 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index b0436ba..7d7cc8f 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -583,6 +583,55 @@ static bool WriteFileAtomic(const std::string& path, const std::string& data, return true; } +// Paths whose loader threw part-way through, and which must therefore never be +// written again by this process. +// +// All three loaders push entries into the live structure INSIDE the try, so an +// exception half way down a file leaves a PARTIAL ledger in memory -- the +// entries before the bad one, and nothing after it. The first publish after +// that then writes the partial state over the file, and the only record of +// what was owed is gone. ReconcilePayouts publishes unconditionally at +// startup, so "the first publish" is a few milliseconds later, before an +// operator could possibly intervene. a8e2f58 named this failure mode in its +// own commit message and then only fixed the write side. +// +// The file is renamed out of the way instead of being left in place, so the +// evidence survives even if the operator restarts into the same fault, and so +// a node that comes up owing nobody anything is visibly missing its ledger +// rather than quietly appearing to have none. +static std::set gRefusedFiles; +static std::mutex gRefusedFilesMutex; + +static bool FileIsRefused(const std::string& path) +{ + std::lock_guard lk(gRefusedFilesMutex); + return gRefusedFiles.count(path) > 0; +} + +// Quarantine `path` and refuse to write it for the rest of the process. +// +// Prints unconditionally: an operator who has just lost a payout ledger must +// be told at whatever log settings they were already running with, and the +// name it was moved to is the only way back to the data. +static void RefuseFileAfterLoadFailure(const std::string& path, const char* what, + const char* why) +{ + { + std::lock_guard lk(gRefusedFilesMutex); + gRefusedFiles.insert(path); + } + std::string bad = strprintf("%s.bad.%lld", path.c_str(), (long long)GetTime()); + bool moved = (rename(path.c_str(), bad.c_str()) == 0); + printf("[payout] ERROR: %s (%s) did not parse: %s\n" + "[payout] ERROR: what loaded before the error is a PARTIAL picture," + " so this file will not be written again by this process --" + " restart once the cause is fixed\n" + "[payout] ERROR: the file %s\n", + path.c_str(), what, why, + moved ? strprintf("has been kept as %s", bad.c_str()).c_str() + : "could NOT be renamed out of the way -- copy it now"); +} + // Publish a versioned image of some in-memory state to `path`. // // `fileMutex` serialises writers of `path` and guards `publishedSeq`. A blob @@ -595,6 +644,11 @@ static void PublishVersioned(const std::string& path, const VersionedBlob& v, std::mutex& fileMutex, uint64& publishedSeq, const char* what) { + // A file whose loader threw is not a file this process knows the contents + // of. Writing it would replace a full record of what was owed with + // whatever happened to parse before the error. + if (FileIsRefused(path)) + return; std::lock_guard lk(fileMutex); if (v.seq <= publishedSeq) return; @@ -760,8 +814,7 @@ static void LoadPendingPayouts() LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n", gPendingPayouts.size()); } catch (const std::exception& e) { - LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", - path.c_str(), e.what()); + RefuseFileAfterLoadFailure(path, "pending payout ledger", e.what()); } } @@ -948,8 +1001,7 @@ static void LoadPaying() LogPrint("payout", "[payout] loaded %zu in-flight payment(s) from disk\n", gPaying.size()); } catch (const std::exception& e) { - LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", - path.c_str(), e.what()); + RefuseFileAfterLoadFailure(path, "in-flight payment list", e.what()); } } @@ -1095,8 +1147,7 @@ static void LoadPoolRounds() LogPrint("payout", "[payout] loaded %zu pool round proof(s) from disk\n", gPoolRounds.size()); } catch (const std::exception& e) { - LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", - path.c_str(), e.what()); + RefuseFileAfterLoadFailure(path, "pool round proofs", e.what()); } } @@ -3021,6 +3072,12 @@ static void PayoutTestResetState() std::lock_guard lk(gClaimedTxidMutex); gLegacyClaimedTxids.clear(); } + { + // A quarantine is meant to last the life of the process; each section + // here is a fresh process as far as the code under test is concerned. + std::lock_guard lk(gRefusedFilesMutex); + gRefusedFiles.clear(); + } } // A BTF address whose hash160 is entirely under the test's control, so the @@ -3785,7 +3842,107 @@ int RunPayoutLedgerSelfTest() strPoolRoundsFile = savedRoundsFile; } - // -- 11. A send that failed for want of coin is not a fault -------------- + // -- 11. A ledger that failed to load is never written back -------------- + // + // The loaders push entries into the live vector inside the try, so an + // exception part way down a file leaves a PARTIAL ledger in memory: the + // entries before the bad one and nothing after it. Publishing that -- and + // ReconcilePayouts publishes unconditionally, milliseconds into startup -- + // writes the partial state over the file and destroys the only record of + // what was owed. + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + + // Two entries, the second with a matureAtHeight that is not a number. + // The first loads; the second throws where it is read. + const std::string corrupt = strprintf( + "[{\"amounts\":{\"%s\":100000000},\"blockHash\":\"%s\"," + "\"blockHeight\":10,\"matureAtHeight\":10}," + "{\"amounts\":{\"%s\":900000000},\"blockHash\":\"%s\"," + "\"blockHeight\":20,\"matureAtHeight\":\"twenty\"}]", + addrA.c_str(), uint256(0).ToString().c_str(), + addrB.c_str(), uint256(0).ToString().c_str()); + check(PayoutTestWriteFile(ledgerPath, corrupt), + "wrote a ledger whose second entry does not parse"); + + const int64 t0 = GetTime(); + LoadPendingPayouts(); + const int64 t1 = GetTime(); + + size_t loaded = 0; + { + std::lock_guard lk(gPayoutLedgerMutex); + loaded = gPendingPayouts.size(); + } + check(loaded == 1, + "a mid-file parse error does leave a partial ledger in memory --" + " which is the reason for everything below"); + + std::string gone; + check(!PayoutTestReadFile(ledgerPath, gone), + "the file that did not parse is moved out of the way"); + + // The rename stamps GetTime(); it was taken between t0 and t1. + std::string kept, keptPath; + for (int64 t = t0; t <= t1 && keptPath.empty(); t++) { + const std::string candidate = + strprintf("%s.bad.%lld", ledgerPath.c_str(), (long long)t); + if (PayoutTestReadFile(candidate, kept)) + keptPath = candidate; + } + check(!keptPath.empty() && kept == corrupt, + "it is kept, byte for byte, as .bad."); + + // The publish that used to destroy it. + VersionedBlob partial; + { + std::lock_guard lk(gPayoutLedgerMutex); + partial = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(partial); + check(!PayoutTestReadFile(ledgerPath, gone), + "and the partial ledger is not published over it"); + + // The refusal is per path and lasts the process: a second attempt, + // with more state, is refused too. + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout pp; + pp.blockHeight = 30; pp.blockHash = 0; pp.matureAtHeight = 30; + pp.amounts[addrA] = 5 * COIN; + gPendingPayouts.push_back(pp); + partial = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(partial); + check(!PayoutTestReadFile(ledgerPath, gone), + "the refusal is not a one-shot -- the path stays unwritable"); + + if (!keptPath.empty()) + remove(keptPath.c_str()); + PayoutTestResetState(); + remove(ledgerPath.c_str()); + + // And a file that parses is not quarantined, so the ordinary path is + // still the ordinary path. + check(PayoutTestWriteFile(ledgerPath, "[]"), + "wrote a ledger that parses"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout pp; + pp.blockHeight = 40; pp.blockHash = 0; pp.matureAtHeight = 40; + pp.amounts[addrA] = 2 * COIN; + gPendingPayouts.push_back(pp); + partial = SerializePendingPayoutsLocked(); + } + PublishPendingPayouts(partial); + std::string written; + check(PayoutTestReadFile(ledgerPath, written) && written == partial.blob, + "a ledger that loaded cleanly is still published normally"); + } + + // -- 12. A send that failed for want of coin is not a fault -------------- // // The defect this replaces: the classifier decided "was this a funding // problem?" by re-reading GetBalance() and allowing amount + @@ -3919,7 +4076,7 @@ int RunPayoutLedgerSelfTest() mapWallet.clear(); } - // -- 12. No payout mutex is held across the status-file write ------------ + // -- 13. No payout mutex is held across the status-file write ------------ // // RefreshStats runs on the event loop -- the thread that owns every // miner's socket -- and used to hold gStatsMutex and gPayoutLedgerMutex at -- cgit v1.2.3 From c7ada00eeed8ef689b976c7201db2d9c076c17d1 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:48:26 +0000 Subject: payout: refuse to publish a snapshot that was never stamped PublishVersioned drops a default-constructed VersionedBlob only by accident. Every serialiser stamps `++gSeq`, so it never produces 0, and 0 then loses the `seq <= publishedSeq` comparison because publishedSeq starts at 0. Both halves of that are incidental. A serialiser written as `seq = gSeq++` -- one character different, and the natural way to write it -- publishes seq 0 for its first snapshot, which would sail past the comparison on a fresh process and write an empty ledger over a real one. There is also a live path that produces an unstamped blob today: FlushMaturePayouts declares `VersionedBlob ledgerSnap` and only fills it if something was voided, then publishes unconditionally. It is harmless for exactly the reasons above, which is to say for no reason that is written down anywhere. One line, checked explicitly, so the guarantee does not depend on how a future serialiser spells its increment. Property: a blob that was never stamped with a version is never written to disk. --- src/rpc.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 7d7cc8f..c382926 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -644,6 +644,14 @@ static void PublishVersioned(const std::string& path, const VersionedBlob& v, std::mutex& fileMutex, uint64& publishedSeq, const char* what) { + // A default-constructed blob is not a snapshot of anything; it is a + // VersionedBlob nobody filled in. Today they are already dropped, because + // every serialiser stamps ++gSeq and so never produces 0, and 0 fails the + // seq <= publishedSeq test below -- but only for as long as publishedSeq + // starts at 0 and nobody writes `seq = gSeq++`. One character, and the + // ledger file becomes "[]". Say it out loud instead of relying on it. + if (!v.seq) + return; // A file whose loader threw is not a file this process knows the contents // of. Writing it would replace a full record of what was owed with // whatever happened to parse before the error. -- cgit v1.2.3 From a5300386813937e38810a86153811941047ea578 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:48:56 +0000 Subject: payout: take the mutex that guards the in-flight list while loading it LoadPaying appends to gPaying and increments gPayingNextId, and both are annotated as guarded by gPayoutExecMutex. It took neither. Nothing is racing it today: it is called from the pool startup path before the payout thread is spawned, and the self-test calls it on one thread. But that is a fact about its callers, not about the function, and the annotation is what the next person to add a caller will read. The lock is uncontended here by construction, so there is nothing to weigh against taking it. Property: every mutation of gPaying and gPayingNextId in this file happens under the mutex they are documented to be guarded by. --- src/rpc.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index c382926..a9eca8a 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -962,6 +962,14 @@ static void LoadPaying() fclose(f); try { nlohmann::json arr = nlohmann::json::parse(s); + // gPaying and gPayingNextId are both annotated as guarded by + // gPayoutExecMutex and this was mutating them without it. Safe today + // -- LoadPaying only runs at startup, before the payout thread is + // spawned -- but "safe because of where it is called from" is a fact + // about the caller, not about this function, and the annotation is + // what every other reader will trust. The mutex is uncontended here by + // construction, so taking it costs nothing. + std::lock_guard lk(gPayoutExecMutex); for (const auto& obj : arr) { PayingRecord r; r.address = obj["address"].get(); -- cgit v1.2.3 From e960802fa7149c9f997074ec7c1a7af66fb47e0f Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:49:25 +0000 Subject: payout: drop a disjunct in the forfeit path that decides nothing ForfeitPayoutRecipient retired an entry on `it->amounts.empty() || PayoutFullyPaid(*it)`. PayoutFullyPaid iterates amounts looking for a recipient with no txid, so it returns true for an empty map: the first test can only be true where the second already is. Left in, it reads as though the two cases differ, which invites someone to make them differ. The condition is unchanged; only the claim about it is. Property: unchanged behaviour, and the code now says only what it does. --- src/rpc.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index a9eca8a..7b45fea 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1921,7 +1921,10 @@ static void ForfeitPayoutRecipient(const PayingRecord& r, const std::string& rea r.blockHeight, r.blockHash)) continue; it->amounts.erase(r.address); - if (it->amounts.empty() || PayoutFullyPaid(*it)) + // PayoutFullyPaid() returns true for an empty amounts map -- there + // is no recipient in it without a txid -- so the + // `amounts.empty()` disjunct that used to be here decided nothing. + if (PayoutFullyPaid(*it)) gPendingPayouts.erase(it); break; } -- cgit v1.2.3 From 46864cdf3c90d15d5d21a330bb54f43696aa7d3b Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:50:12 +0000 Subject: payout: say why a grandfathered pair is safe, since it is not the orphan check The comment on FindRecordedPoolPayout justified the legacy fallback by saying that where two pre-upgrade rounds share a height, the losing one's ledger entry is voided by the orphan check before it can be paid. That check is PayoutBlockStillOnChain(), which returns true for a zero hash -- and every pre-upgrade entry has a zero hash, because they were written before payouts recorded one. So it is a no-op for exactly the population the fallback exists to serve. The stated reason for a safety property was not the reason. The property does hold, by a different mechanism. SamePayoutRound() treats two zero-hash entries at the same height as the same round, so the pair is indistinguishable to every consumer, not just to this one: FlushMaturePayouts explodes one and skips the other as already queued, PaymentInFlightLocked refuses a second record for that (round, address), and RecordPayoutTxid settles against the first match. The pair presents as one debt and can be paid at most once. The cost of that -- only one of a genuine same-height pair would ever be paid -- is now written down too, rather than left as an implication. Both halves are asserted rather than reasoned about: the self-test checks that the orphan check passes a zero hash, and that two identical zero-hash entries at one height produce exactly one in-flight record. Property: the comment describes the mechanism that is actually load-bearing, and a test fails if either half of it stops being true. --- src/rpc.cpp | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 5 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 7b45fea..7ea0597 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1379,11 +1379,32 @@ static void MarkPoolRoundForfeited(const std::string& id, // // What is left unresolved by construction is two pre-upgrade rounds at the // SAME height paying the same address the same amount, since the comment -// carries only the height. That needs a reorg, and the losing round's ledger -// entry is voided by the orphan check before it can be paid, so the pair -// cannot both be live. Where the evidence genuinely runs out the fallback -// simply does not match, and the payment is made -- paying a debt we cannot -// prove was settled is the correct default for a pool. +// carries only the height. That needs a reorg. +// +// This used to say the losing twin's ledger entry is voided by the orphan +// check before it can be paid. That is wrong: PayoutBlockStillOnChain() +// returns true for a zero hash, and EVERY pre-upgrade entry has a zero hash, +// so the orphan check is a no-op for exactly the population this fallback +// serves. +// +// The outcome is still safe, by a different mechanism. SamePayoutRound() +// treats two zero-hash entries at the same height as the SAME round, so the +// pair is indistinguishable everywhere, not only here: FlushMaturePayouts +// explodes one of them and skips the other as already queued, +// PaymentInFlightLocked refuses a second record for that (round, address), and +// RecordPayoutTxid settles against the first match. The pair therefore +// presents as ONE debt and can be paid at most once -- which is the direction +// that matters for a fallback whose failure mode is treating one payment as +// proof of two. +// +// The cost of that mechanism, stated plainly: only one of a genuine +// same-height pair would ever be paid. It takes a reorg AND both rounds +// predating hash keying, and no file this tree has written since can produce +// one, so it is a property of history rather than a live risk. +// +// Where the evidence genuinely runs out the fallback simply does not match, +// and the payment is made -- paying a debt we cannot prove was settled is the +// correct default for a pool. enum PayoutProbeResult { PAYOUT_PROBE_NONE = 0, // the wallet holds no record of this payment PAYOUT_PROBE_STRUCTURED, // matched this build's pool_payout_* metadata @@ -3594,6 +3615,33 @@ int RunPayoutLedgerSelfTest() PayoutRoundIdentifiable(7, 0), "an entry with neither hash nor height is not adjudicable"); + // What actually makes a grandfathered same-height pair safe, since the + // comment on FindRecordedPoolPayout used to claim it was the orphan + // check. It is not: the orphan check passes every zero-hash entry, and + // every pre-upgrade entry has a zero hash. + check(PayoutBlockStillOnChain(0), + "the orphan check passes every grandfathered entry, so it cannot" + " be what voids a losing twin"); + PayoutTestResetState(); + remove(ledgerPath.c_str()); + remove(payingPath.c_str()); + { + std::lock_guard lk(gPayoutLedgerMutex); + PendingPayout twin; + twin.blockHeight = 300; twin.blockHash = 0; twin.matureAtHeight = 300; + twin.amounts[addrA] = 1 * COIN; + gPendingPayouts.push_back(twin); + gPendingPayouts.push_back(twin); // the losing side of a reorg + } + nBestHeight = 5000; + FlushMaturePayouts(); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.size() == 1, + "two grandfathered rounds at one height are one round, so the" + " pair presents as one debt and is paid at most once"); + } + // End to end: recording a payment against one grandfathered round must // not settle the other's debt. PayoutTestResetState(); -- cgit v1.2.3 From 111fb886c2a38c8ab76fd064c9dbb1f403fde7c1 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:53:20 +0000 Subject: selftest: make a torn publish structurally invalid, not merely unlucky Mutation-tested, the concurrency test did not test concurrency. With the per-file write mutex removed from PublishVersioned, `make tests` still went green 2 runs out of 5, and the assertion named "four threads publishing the same file never leave it unparseable" fired 0 times out of 5. It was asserting something true for reasons unrelated to the code it was pointed at. Two causes. The images were 64 arrays of 200 entries, identical in structure and within bytes of each other in length, so an interleaving of any two of them was still a valid JSON array. There was nothing structural for a torn write to break. They now alternate between one entry and five thousand: a write landing on top of another leaves either a truncated array or a small image followed by the tail of a large one, and neither parses. Worse, almost nothing was being written. Each thread walked a permutation of the images and PublishVersioned drops any blob not newer than what is on disk, so only the record highs in each permutation ever reached the disk -- about ten writes in total across four threads, which is not enough contention to produce a race however the images are shaped. Every publish now stamps its sequence number from a shared counter at the moment it is made, so every publish is newer than the last one issued and every publish writes: 400 of them, half of them 600 KB. And the file is now watched while the writers run rather than inspected once after they stop. "The published file is never observed unparseable" is the real invariant -- rename(2) is atomic, so a reader can only ever see a completely written image -- and sampling it only at the end looks at the one moment a torn file is least likely to have survived. Measured on the build host, five runs with the file mutex removed: 5 detections out of 5, every one of them on the unparseable-file assertion, which is the assertion that used to fire never. Five runs with the mutex in place: 5 passes. The whole payout-ledger self-test takes 1.4 s. Property: removing the serialisation this test exists to check fails the test, on every run. --- src/rpc.cpp | 128 +++++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 96 insertions(+), 32 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 7ea0597..79947df 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -3542,54 +3542,118 @@ int RunPayoutLedgerSelfTest() // -- 6. Concurrent publishers cannot interleave a file ------------------- // - // Two threads renaming the same ".tmp" over the same target was the - // original defect. The images are made big so a torn write shows up as - // unparseable JSON rather than by luck. + // Two threads opening and renaming the same ".tmp" over the same + // target was the original defect. This test was mutation-tested and did + // not catch it: with the file mutex removed the suite still went green 2 + // runs in 5, and the "never leave it unparseable" assertion fired 0 times + // in 5. Two reasons, both fixed here. + // + // First, every image had the same structure and near enough the same + // length, so an interleaving of two of them was still valid JSON. The + // images now alternate between ONE entry and FIVE THOUSAND, so a write + // that lands on top of another leaves either a truncated array or a small + // image followed by the tail of a large one -- neither of which parses. + // + // Second, and worse, almost nothing was being written. Each thread walked + // a permutation of the images, and PublishVersioned drops any blob that is + // not newer than what is on disk, so only the record highs in each + // permutation ever reached the disk -- on the order of ten writes in + // total, four threads, no contention to speak of. Every publish here is + // stamped from a shared counter at the moment it is made, so every one of + // them is newer than the last and every one of them writes. + // + // And the file is watched while the writers run, not only inspected after + // they finish. "The published file is never observed unparseable" is the + // actual invariant -- rename(2) is atomic, so a reader can only ever see a + // completely written image -- and checking it once at the end samples the + // one moment when a torn file is least likely to still be there. { const std::string racePath = dir + "/publish_race_selftest.json"; std::mutex raceMutex; uint64 racePublished = 0; - std::vector images; - for (int i = 0; i < 64; i++) { - nlohmann::json arr = nlohmann::json::array(); - for (int k = 0; k < 200; k++) { - nlohmann::json o; - o["image"] = i; - o["entry"] = k; - o["address"] = addrA; - o["amount"] = (int64)(k + 1) * COIN; - arr.push_back(o); + std::string tiny, huge; + { + nlohmann::json one = nlohmann::json::array(); + nlohmann::json o; + o["image"] = 0; + o["entry"] = 0; + o["address"] = addrA; + o["amount"] = (int64)COIN; + one.push_back(o); + tiny = one.dump(2); + + nlohmann::json many = nlohmann::json::array(); + for (int k = 0; k < 5000; k++) { + nlohmann::json e; + e["image"] = 1; + e["entry"] = k; + e["address"] = addrA; + e["amount"] = (int64)(k + 1) * COIN; + many.push_back(e); } - VersionedBlob v; - v.blob = arr.dump(2); - v.seq = (uint64)i + 1; - images.push_back(v); + huge = many.dump(2); } + const int kThreads = 4; + const int kPerThread = 100; + const uint64 kTotal = (uint64)kThreads * kPerThread; + + std::atomic nextSeq(0); + std::atomic racing(true); + std::atomic tornReads(0); + std::atomic goodReads(0); + + std::thread watcher([&]() { + bool seenOnce = false; + while (racing) { + std::string s; + if (!PayoutTestReadFile(racePath, s)) { + // Only meaningful once the file exists at all. + if (seenOnce) tornReads++; + continue; + } + seenOnce = true; + try { + if (nlohmann::json::parse(s).is_array()) goodReads++; + else tornReads++; + } catch (...) { + tornReads++; + } + } + }); + std::vector threads; - for (int t = 0; t < 4; t++) { - threads.push_back(std::thread([&, t]() { - for (size_t i = 0; i < images.size(); i++) { - size_t idx = (i * 7 + (size_t)t * 13) % images.size(); - PublishVersioned(racePath, images[idx], raceMutex, - racePublished, "publish race selftest"); + for (int t = 0; t < kThreads; t++) { + threads.push_back(std::thread([&]() { + for (int i = 0; i < kPerThread; i++) { + VersionedBlob v; + v.seq = ++nextSeq; + v.blob = (v.seq & 1) ? tiny : huge; + PublishVersioned(racePath, v, raceMutex, racePublished, + "publish race selftest"); } })); } for (std::thread& th : threads) th.join(); - + racing = false; + watcher.join(); + + check(goodReads > 0, + "the race test actually observed the file while it was being" + " written"); + check(tornReads == 0, + "four threads publishing the same file never leave it" + " unparseable, at any instant while they are doing it"); + + // The highest sequence number issued is the one the file must settle + // on: PublishVersioned refuses anything older, so whichever order the + // writes complete in, that image is the last one that can land. + const std::string& expected = (kTotal & 1) ? tiny : huge; std::string onDisk; bool read = PayoutTestReadFile(racePath, onDisk); - bool parses = false; - if (read) { - try { parses = nlohmann::json::parse(onDisk).is_array(); } - catch (...) { parses = false; } - } - check(read && parses, - "four threads publishing the same file never leave it unparseable"); - check(read && onDisk == images.back().blob, + check(read && onDisk == expected, "the published file settles on the highest version, whatever the" " completion order"); remove(racePath.c_str()); -- cgit v1.2.3 From 991853fa55a749d28d6f9e507ce72b289df92785 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Sun, 23 Aug 2026 11:56:31 +0000 Subject: payout: qualify the single-writer claim for pool_status.json The comment justifying the absence of a version guard said RefreshStats "runs only on the event loop". That was true when it was written and is no longer: the payout-ledger self-test drives RefreshStats directly, to check that no payout mutex is held across the write. The conclusion still holds -- the test calls it from one thread at a time with the pool not running, so pool_status.json still has a single writer -- but the reason a reader would check is now stated, along with what would invalidate it. A claim in a comment that has quietly stopped being true is how 33f2e8a came to assert that no thread waits on I/O holding gPayoutLedgerMutex on the commit that made it do so. Property: comment only. --- src/rpc.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 79947df..7a6b6a4 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -2429,11 +2429,18 @@ static std::string SerializePoolStatusLocked() // Publish the operator status file. Call with every payout mutex released. // // Still no version guard and no write mutex, deliberately, and the split does -// not change the argument: RefreshStats remains the only caller, it still runs -// only on the event loop, and it now calls this after leaving the scope that -// serialised -- so the serialise and the write stay strictly ordered on one -// thread and pool_status.json still has a single writer by construction. It -// shares WriteFileAtomic so the durability rules stay in one place. +// not change the argument: RefreshStats remains the only caller of either +// half, in production it runs only on the event loop, and it now calls this +// after leaving the scope that serialised -- so the serialise and the write +// stay strictly ordered on one thread and pool_status.json still has a single +// writer by construction. It shares WriteFileAtomic so the durability rules +// stay in one place. +// +// "In production" is doing real work in that sentence. The payout-ledger +// self-test also calls RefreshStats, from one thread at a time and with the +// pool not running, which is why it is still a single writer there too. A +// second concurrent caller anywhere would invalidate this paragraph and want +// the same versioned publish the ledgers use. static void PublishPoolStatus(const std::string& blob) { WriteFileAtomic(PoolStatusFilePath(), blob, "pool status"); -- cgit v1.2.3 From 8e0d97f7957e570c2e26d79e66efe0cc064cee4b Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Tue, 1 Sep 2026 14:41:09 +0000 Subject: pool: log a connection census, not just the broadcast count "broadcast to N miner(s)" counts the authorized sockets that were sent a job. It says nothing about the rest of the connection table, so a pool holding a pile of half-open sessions and one real miner reads the same as a pool with N miners. #19 was opened on exactly that confusion: ~55 connections served per job while ~2 outside miners actually submitted shares. Log the breakdown every 60s instead: connected, subscribed (completed mining.subscribe), authorized, active (authorized and seen within one payout-rotate cycle), and never-auth-idle (past the grace period without authorizing). The pool category is on by default, so this reaches a production log without /debug, and the burst-collapse window is 3s, so a 60s cadence is never the duplicate this tree has swallowed before. The census also reports how close the single select() loop is to running out of room, which is not the same question on both platforms. On POSIX FD_SETSIZE bounds the value of any descriptor handed to FD_SET, so the highest fd we hold is what matters; on Windows an fd_set is a counted array of SOCKET handles whose values are arbitrary, so the count is what matters. FD_SETSIZE comes from the platform headers either way -- this file must not define it, because redefining it after or winsock2.h changes nothing at all. --- src/rpc.cpp | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 7a6b6a4..4ff09b9 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -2474,6 +2474,7 @@ static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by thi struct Miner { btf_socket_t fd; bool authorised = false; + bool subscribed = false; // completed mining.subscribe (#19 census) std::string address; std::string worker; std::string readBuf; @@ -2582,6 +2583,20 @@ static bool SendLine(btf_socket_t fd, const json& j) static const size_t MAX_STRATUM_LINE = 64 * 1024; static const size_t MAX_POOL_MINERS = 1000; +// #19 instrumentation: the "broadcast to N miner(s)" line counts connected +// sockets, which says nothing about how many are actually mining. These split +// the connection census into connected / subscribed / authorized / recently +// active, so an operator can tell a real workforce from a pile of half-open +// sessions at a glance. +static const int64 POOL_CENSUS_SECS = 60; // how often to log the breakdown +static const int64 POOL_ACTIVE_WINDOW = 300; // a miner is "active" if it + // authorized and was seen within + // this window (one payout-rotate + // cycle) +static const int64 POOL_UNAUTH_GRACE_SECS = 60; // a connection still not + // authorized after this long is + // idle, not merely starting up + // Non-blocking recv: pull whatever bytes are available now into buf, then // return one complete line if one is ready. Three outcomes: // true -- line is filled; buf may still contain more lines @@ -2689,6 +2704,65 @@ static void BroadcastJob(bool clean) gCurrentJob.jobId.c_str(), sent); } +// --------------------------------------------------------------------------- +// #19: connection census. "broadcast to N miner(s)" only counts authorized +// sockets that got a job; it says nothing about the rest of the connection +// table. This logs the full breakdown so an operator can tell a real workforce +// apart from half-open / never-authorized / idle sessions, and flags when the +// single select() loop is running out of room (issue #26). +// +// "Out of room" is not the same question on both platforms, and FD_SETSIZE +// answers a different one on each. On POSIX it bounds the *value* of any +// descriptor handed to FD_SET, so what matters is the highest fd we hold. On +// Windows an fd_set is a counted array of SOCKET handles whose numeric values +// are arbitrary, so what matters is how many we hold, not how large they are. +// FD_SETSIZE comes from or winsock2.h; this file must not define +// it, because redefining it after those headers changes nothing. +// --------------------------------------------------------------------------- +static void LogPoolConnectionCensus() +{ + int connected = 0, subscribed = 0, authorized = 0, active = 0, neverAuthIdle = 0; + int64 now = GetTime(); +#ifndef _WIN32 + int maxfd = 0; +#endif + for (Miner* m : gMiners) { + connected++; +#ifndef _WIN32 + if ((int)m->fd > maxfd) maxfd = (int)m->fd; +#endif + if (m->subscribed) subscribed++; + if (m->authorised) { + authorized++; + if (m->lastSeen > 0 && now - m->lastSeen <= POOL_ACTIVE_WINDOW) + active++; + } else if (now - m->connectTime >= POOL_UNAUTH_GRACE_SECS) { + neverAuthIdle++; // past the grace period and still not authorized + } + } + +#ifndef _WIN32 + int headroom = (int)FD_SETSIZE - maxfd; + LogPrint("pool", "[pool] census: connected=%d subscribed=%d authorized=%d" + " active(last %llds)=%d never-auth-idle=%d" + " (cap=%zu, maxfd=%d, FD_SETSIZE=%d)\n", + connected, subscribed, authorized, (long long)POOL_ACTIVE_WINDOW, + active, neverAuthIdle, MAX_POOL_MINERS, maxfd, (int)FD_SETSIZE); +#else + int headroom = (int)FD_SETSIZE - connected; + LogPrint("pool", "[pool] census: connected=%d subscribed=%d authorized=%d" + " active(last %llds)=%d never-auth-idle=%d" + " (cap=%zu, FD_SETSIZE=%d sockets)\n", + connected, subscribed, authorized, (long long)POOL_ACTIVE_WINDOW, + active, neverAuthIdle, MAX_POOL_MINERS, (int)FD_SETSIZE); +#endif + if (headroom <= 16) + LogPrint("pool", "[pool] WARNING: %d slots left before FD_SETSIZE=%d --" + " the select() loop is about to walk into undefined behaviour" + " (#26). Refuse new miners or rebuild with poll/epoll.\n", + headroom, (int)FD_SETSIZE); +} + // --------------------------------------------------------------------------- // Stratum message handler -- called for each complete line from a miner // Returns false if the miner should be disconnected immediately. @@ -2757,6 +2831,7 @@ static bool HandleLine(Miner* m, const std::string& rawLine, // ------------------------------------------------------------------ if (method == "mining.subscribe") { std::string sid = ToHex(&m->fd, 4); + m->subscribed = true; LogPrint("worker", "[worker->pool] subscribe from fd=%d sid=%s\n", (int)m->fd, sid.c_str()); reply(json::array({json::array({json::array({"mining.notify",sid})}),sid,4})); @@ -4593,6 +4668,7 @@ static void PoolEventLoop() int lastHeight = -1; int64 lastRefresh = GetTime(); int64 lastStatsRefresh = 0; + int64 lastCensus = 0; // #19 connection census throttle int blocksFoundThisSession = 0; int64 noJobSince = 0; // 0 = have a job bool warnedNoJob = false; @@ -4810,6 +4886,14 @@ static void PoolEventLoop() RefreshStats(blocksFoundThisSession, roundShareTotal); lastStatsRefresh = GetTime(); } + + // 6. #19: log the connection census periodically. Cheap (one pass over + // gMiners) and it is the single number an operator reads to judge pool + // health, so it should not be an overstatement. + if (GetTime() - lastCensus >= POOL_CENSUS_SECS) { + LogPoolConnectionCensus(); + lastCensus = GetTime(); + } } for (Miner* m : gMiners) { sock_close(m->fd); delete m; } -- cgit v1.2.3 From 12752bc0f9441ef9f929ee652eeb5d92b9dda4a5 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Tue, 1 Sep 2026 14:41:33 +0000 Subject: pool: refuse a miner the select() loop cannot represent The operator event loop still builds an fd_set by hand (#26), and FD_SET on a descriptor the set cannot represent is undefined -- it writes past the bitmap. MAX_POOL_MINERS caps the miner list at 1000, but that is a count, and a count does not bound descriptor values: a Miner is removed only on clean disconnect, so half-open sessions hold their fds while the OS keeps issuing higher ones. Refuse the connection instead, and refuse on the limit that actually applies. On POSIX that is the descriptor value against FD_SETSIZE. On Windows an fd_set is a counted array of SOCKET handles whose values are arbitrary -- often large from the first accept -- so comparing a handle against FD_SETSIZE there would reject nearly every miner; the bound is how many sockets are held. This is a behaviour change: a pool at the limit now turns miners away with a log line rather than corrupting memory quietly. The real fix is poll/epoll, which #26 tracks. --- src/rpc.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 4ff09b9..a4b53ca 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -4697,6 +4697,31 @@ static void PoolEventLoop() sock_close(fd); continue; } + // #26: select() + FD_SET goes undefined once we exceed what an + // fd_set can carry, and MAX_POOL_MINERS (1000) alone does not + // prevent it -- a Miner is only removed on clean disconnect, so + // half-open zombies accumulate and the OS can hand out fds past + // the limit long before the count reaches 1000. Refuse rather + // than corrupt the fd_set. Which limit applies is platform + // specific: POSIX bounds the descriptor value, Windows the + // number of SOCKET handles an fd_set can hold. +#ifndef _WIN32 + if ((int)fd >= (int)FD_SETSIZE) { + LogPrint("worker", "[pool] refusing miner fd=%d -- at/over" + " FD_SETSIZE=%d, select() would be undefined (#26)\n", + (int)fd, (int)FD_SETSIZE); + sock_close(fd); + continue; + } +#else + if (gMiners.size() >= (size_t)FD_SETSIZE) { + LogPrint("worker", "[pool] refusing miner -- %zu sockets held," + " an fd_set carries at most FD_SETSIZE=%d (#26)\n", + gMiners.size(), (int)FD_SETSIZE); + sock_close(fd); + continue; + } +#endif gMiners.push_back(new Miner(fd)); LogPrint("worker", "[pool] miner fd=%d entered event loop" " -- awaiting subscribe/authorize\n", (int)fd); -- cgit v1.2.3 From d7b1e8a1d54192df2fcf633fbcdffb43389ff8e2 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 05:42:17 +0000 Subject: pool: guard the share-target floor where the old cap branch was dead ShareTargetFromDifficulty ended with if (t < diff1 && difficulty < 1.0) t = ~uint256(0); which can never fire: t = diff1/difficulty, so difficulty < 1.0 implies t > diff1 and the two conditions exclude each other. The branch was reaching for a real hazard, though, so say the hazard directly instead of deleting the guard: diff1 is ~uint256(0) >> 12, a hair under 2^244, so the true share target stops fitting in a uint256 once difficulty reaches 2^-12 (1/4096), and the fixed-point divisor (uint64)(difficulty * 65536.0) truncates to zero -- a division by zero -- for any difficulty under 2^-16. Past the first threshold getuint256() would return the low 256 bits of an oversized quotient, a wrapped target that is arbitrarily HARDER than difficulty 1. Both thresholds sit far below VARDIFF_MIN (0.001), so nothing live comes near this; the guard exists for any caller that bypasses vardiff. At those difficulties every hash is meant to be a share, so return all-ones, the easiest representable target. Boundary verified numerically against the exact fixed-point algorithm: at difficulty exactly 1/4096 the quotient is 2^256 - 4096 (fits), one ulp below it overflows, and below 2^-16 the divisor is zero. Closes #11 --- src/rpc.cpp | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index a4b53ca..82f895b 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -115,15 +115,28 @@ static uint256 ShareTargetFromDifficulty(double difficulty) { if (difficulty <= 0.0) difficulty = 1.0; static const uint256 diff1 = CBigNum(bnProofOfWorkLimit).getuint256(); + // Representability floor. shareTarget = diff1 / difficulty, and diff1 is + // ~uint256(0) >> 12, i.e. a hair under 2^244 -- so the true target stops + // fitting in a uint256 once difficulty reaches 2^-12 (= 1/4096), and the + // fixed-point divide below breaks even earlier in a second way: the + // (uint64)(difficulty * 65536.0) divisor truncates to zero for any + // difficulty under 2^-16. Both thresholds sit far below VARDIFF_MIN + // (0.001), so live operation never comes near this, but a caller that + // bypasses vardiff (or a future VARDIFF_MIN) must not divide by zero or + // hand back getuint256()'s low-256-bit truncation of an oversized + // quotient -- a wrapped target is not merely wrong, it is arbitrarily + // HARDER than difficulty 1. At these difficulties every hash is meant to + // be a share, so the honest answer is the easiest representable target. + // (This replaces a dead branch that read `if (t < diff1 && difficulty < + // 1.0)`: t = diff1/difficulty, so difficulty < 1.0 implies t > diff1 and + // the two conditions could never both hold.) + if (difficulty < 1.0 / 4096.0) return ~uint256(0); // shareTarget = diff1 / difficulty CBigNum bn; bn.setuint256(diff1); // Multiply by 2^16 to keep precision, divide, shift back bn <<= 16; bn /= (uint64)(difficulty * 65536.0); - uint256 t = bn.getuint256(); - // Cap at all-ones (maximum, easiest possible) - if (t < diff1 && difficulty < 1.0) t = ~uint256(0); - return t; + return bn.getuint256(); } // Expected number of hashes to find one share at a given Stratum difficulty, -- cgit v1.2.3 From a3666bded1553b101b631d87e99961bf3ccd0a3e Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 05:44:41 +0000 Subject: pool: refuse a (job, nonce) pair this connection already submitted mining.submit validated the nonce, rebuilt the block, re-hashed it with RandomX -- and never asked whether it had seen this exact (job_id, nonce) pair before. Nothing in that pipeline distinguishes a replay from a first submission: a duplicated valid share passes every check and lands in roundShareCount[address]++ a second time, and roundShareCount is the divisor the payout split uses, so replaying one valid share N times multiplies that address's cut of the round by N. Keep a per-connection set of credited nonces on the Miner, reject a repeat with a "Duplicate share" stratum error before the rebuild and hash it would waste, and record the nonce at the moment the share is credited. The set is keyed to the job id the miner is currently working and cleared inside SendJob -- the single funnel every new-job path (initial authorize, block found, new block, 60s refresh, no-job watchdog, vardiff retargets) goes through -- so legitimate resubmissions across jobs are never refused. The clear is keyed on the id rather than unconditional because the vardiff retarget and stall watchdog re-send the CURRENT job, and an unconditional clear there would reopen the very replay window this closes. Job ids come from a monotonic atomic counter in RebuildJob, so an id is never recycled within a process. Closes #8 --- src/rpc.cpp | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 82f895b..f1a7153 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -2511,6 +2511,16 @@ struct Miner { int64 lastShareTime = 0; // GetTime() of the previous accepted share double hashRate = 0.0; // smoothed H/s estimate + // Replay guard for mining.submit (#8): nonces already credited on this + // connection for the job it is currently working. Keyed to the job id in + // seenNoncesJob and cleared by SendJob whenever that id changes, so a + // resubmission under a NEW job is never turned away, while a replayed + // (job_id, nonce) pair under the same job is refused before it can be + // credited twice into roundShareCount -- the figure the payout split + // divides by. + std::set seenNonces; + std::string seenNoncesJob; + explicit Miner(btf_socket_t f) : fd(f), connectTime(GetTime()), extranonce2(gNextExtranonce2.fetch_add(1)) {} @@ -2693,6 +2703,17 @@ static json MakeNotifyParams(const StratumJob& job, const CBlock& b, static void SendJob(Miner* m, bool clean) { if (!gHaveJob) return; + // Every path that hands a miner a new job funnels through here, so this + // is the one place the replay guard needs to follow the job id: nonces + // credited under the previous job mean nothing under the new one. Keying + // the clear to the id (rather than clearing unconditionally) keeps a + // same-job re-notify -- the vardiff retarget and stall watchdog both + // re-send the CURRENT job -- from reopening the duplicate window it + // exists to close. + if (m->seenNoncesJob != gCurrentJob.jobId) { + m->seenNonces.clear(); + m->seenNoncesJob = gCurrentJob.jobId; + } CBlock mb = BuildMinerBlock(gCurrentJob, m->extranonce2); // Use this miner's own shareTarget (set by vardiff) in the notify params. // The participant miner reads p[3] as its share target, and HandleLine @@ -2951,6 +2972,22 @@ static bool HandleLine(Miner* m, const std::string& rawLine, } unsigned int nNonce; memcpy(&nNonce, nb.data(), 4); + // Reject a replayed (job, nonce) before it costs a rebuild and a + // RandomX hash: nothing about the work below distinguishes a second + // submission of a valid share from the first, so without this check + // it passes every test and is credited again -- and roundShareCount + // is what the payout split divides by, so each replay multiplies the + // address's cut of the round. The set is per-connection and cleared + // by SendJob on every job change, so only a same-job repeat is + // refused. + if (m->seenNonces.count(nNonce)) { + LogPrint("worker", "[worker->pool] duplicate share from %s" + " nonce=%u job=%s -- already credited, not counting again\n", + m->address.c_str(), nNonce, submitJobId.c_str()); + reply(false, "Duplicate share"); + return true; + } + // Rebuild this miner's exact block (unique merkle via extranonce2) CBlock b = BuildMinerBlock(gCurrentJob, m->extranonce2); b.nNonce = nNonce; @@ -2978,6 +3015,7 @@ static bool HandleLine(Miner* m, const std::string& rawLine, m->roundShares++; m->totalShares++; m->vardiffShares++; + m->seenNonces.insert(nNonce); // replay guard: credit each nonce once per job int64 shareTime = GetTime(); m->lastSeen = shareTime; roundShareCount[m->address]++; -- cgit v1.2.3 From 2bca62a6a9984297633af42d9e19d1f328d2f7f2 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 05:46:57 +0000 Subject: pool: decay a miner's hashrate estimate when its shares stop arriving m->hashRate is an EWMA re-sampled only on the valid-share path, with no time-based term: a miner that stops hashing keeps its last estimate for as long as the connection stays open, and RefreshStats sums every such stale estimate into gStats.totalHashRate, which is what the pool status and the nostr announcement publish. The announced pool hashrate ran several times over ground truth because miners that had gone quiet were still contributing their last sample. Decay on time in the event loop's miner sweep. Per miner, compute the expected share interval -- ExpectedHashesForDifficulty(m->difficulty) divided by its current rate estimate -- and give it a grace of four such intervals with no share (at the vardiff target of one share per 15s that is about a minute of silence). Past the grace, decay the estimate with a half-life of one more expected interval, computed from absolute time so the curve does not depend on the loop's tick rate. The interval is clamped to [1s, 1h] so a fast miner at the vardiff floor, whose expected interval is milliseconds, is not decayed for ordinary share-delivery jitter, and a very slow miner's grace cannot stretch past the point where its estimate means anything. Per-share crediting and ExpectedHashesForDifficulty are untouched: this changes only what is reported about a miner that has stopped reporting itself. Closes #7 --- src/rpc.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index f1a7153..23e25ef 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -2484,6 +2484,26 @@ static const double VARDIFF_MAX = 1e12; static const int64 VARDIFF_STALL_SECS = 45; // 3x share target -- reacts faster without false-triggering on normal variance static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by this much +// #7: m->hashRate is an EWMA sampled only when a share arrives, so a miner +// that stops hashing keeps its last estimate forever and RefreshStats sums +// it into the announced pool total for as long as the connection sits open +// -- the published pool hashrate ran several times over ground truth +// because of exactly this. The decay pass in PoolEventLoop applies this +// policy: a miner gets a grace of HASHRATE_IDLE_MISSES expected share +// intervals (expected interval = expected hashes per share at its current +// difficulty / its last rate estimate) with no share, then its estimate +// decays with a half-life of one more such interval, computed from +// absolute time so the result does not depend on how often the loop ticks. +// The interval is clamped to [HASHRATE_MIN_INTERVAL, HASHRATE_MAX_INTERVAL] +// so a fast miner at the vardiff floor (expected interval of milliseconds) +// is not punished for ordinary share-delivery jitter, and a very slow +// miner's grace does not stretch past the point where its estimate means +// anything. At the vardiff target of one share per 15s, a stopped miner's +// announced rate is gone within minutes. +static const double HASHRATE_IDLE_MISSES = 4.0; // grace, in expected share intervals +static const double HASHRATE_MIN_INTERVAL = 1.0; // seconds; floor for the expected interval +static const double HASHRATE_MAX_INTERVAL = 3600.0; // seconds; ceiling for the expected interval + struct Miner { btf_socket_t fd; bool authorised = false; @@ -4900,6 +4920,26 @@ static void PoolEventLoop() } } + // 3.6. Hashrate decay (#7) -- see the policy comment at + // HASHRATE_IDLE_MISSES. The EWMA is only re-sampled on share arrival, + // so without this pass a miner that stops hashing keeps its last + // estimate forever and the summed pool hashrate RefreshStats (and + // the nostr announcement built from it) publishes is fiction. + for (Miner* m : gMiners) { + if (m->hashRate <= 0.0 || m->lastShareTime <= 0) continue; + double expected = ExpectedHashesForDifficulty(m->difficulty) / + std::max(m->hashRate, 1e-9); + if (expected < HASHRATE_MIN_INTERVAL) expected = HASHRATE_MIN_INTERVAL; + if (expected > HASHRATE_MAX_INTERVAL) expected = HASHRATE_MAX_INTERVAL; + double silent = (double)(GetTime() - m->lastShareTime); + double grace = HASHRATE_IDLE_MISSES * expected; + if (silent <= grace) continue; + // Half-life of one expected interval past the grace period. + // Written off absolute time, not per-tick, so the decay curve is + // the same however often this loop runs. + m->hashRate *= pow(0.5, (silent - grace) / expected); + } + // 4. Select over miner sockets if (!gMiners.empty()) { fd_set fds; FD_ZERO(&fds); -- cgit v1.2.3 From f5def7625e6369d117cc8f02b7c0244b70d6f779 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 05:49:27 +0000 Subject: pool: reap miners that have said nothing for ten minutes A Miner left gMiners in exactly one place: RecvLine reporting a clean disconnect. Half-open TCP sessions send no FIN and raise no error, so they lived forever -- on the live pool authorize outpaced disconnect 2:1, over half the table never submitted a share, and the leak grew toward MAX_POOL_MINERS, past which the accept path refuses real miners. Two changes. First, an idle reap in the event loop's miner sweep: any miner whose last activity is older than POOL_IDLE_TIMEOUT_SECS (600s) is closed and erased through exactly the bookkeeping the clean-disconnect path uses -- log, sock_close, erase via std::find, delete -- collected into a side vector first so the sweep's iteration stays valid. Shares an idle miner already earned stay in roundShareCount, just as they do when a miner cleanly disconnects mid-round. To give the reap something honest to measure, lastSeen is now stamped on every complete line received (authorize and valid shares already stamped it; a miner that only subscribes, or sends unrecognised methods, previously looked dead from birth), with connectTime standing in for a connection that never completed a line. Ten minutes is far beyond the 15s vardiff share target and the 45s stall watchdog, so no miner that is actually hashing comes close. Second, SO_KEEPALIVE on sockets accepted by the loopback stratum listener, so a half-open session eventually errors out of recv and lands in the reap path on its own. This is a backstop only: the default cadence before the first probe is hours on most stacks, so the idle timeout fires long before keepalive does. Miners arriving via a relay ride a loopback socket pair whose real transport is the rendezvous socket owned by the tunnel pump threads, which keepalive cannot reach across -- for those the idle reap is also what finally tears the tunnel down, since closing the app end unblocks the pumps. Closes #6 --- src/rpc.cpp | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 23e25ef..df7ca05 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -2596,6 +2596,18 @@ static void LocalAcceptFn(void*) break; continue; } + // #6: let the OS probe a peer that has gone silent, so a half-open + // session eventually errors out of recv and lands in the event + // loop's existing reap path instead of sitting in gMiners forever. + // This is a backstop, not the mechanism: the default cadence before + // the first probe is hours on most stacks, so the idle timeout in + // PoolEventLoop fires long before keepalive does. Miners arriving + // via a relay ride a loopback socket pair whose remote transport is + // the rendezvous socket owned by the tunnel pump threads; keepalive + // cannot reach across that, which is part of why the idle timeout + // exists. + int keepalive = 1; + setsockopt(c, SOL_SOCKET, SO_KEEPALIVE, (char*)&keepalive, sizeof(keepalive)); LogPrint("pool", "[pool] miner arrived via loopback -- pushing to event loop\n"); PushReady((btf_socket_t)c); } @@ -2639,6 +2651,16 @@ static const int64 POOL_ACTIVE_WINDOW = 300; // a miner is "active" if it static const int64 POOL_UNAUTH_GRACE_SECS = 60; // a connection still not // authorized after this long is // idle, not merely starting up +// #6: a Miner used to leave gMiners only when RecvLine reported a clean +// disconnect. A half-open session (miner host died, NAT state expired, +// cable pulled) sends no FIN and reports no error, so it sat in the table +// forever -- authorize outpaced disconnect 2:1 on the live pool, and the +// table grew toward MAX_POOL_MINERS, past which real miners are refused. +// lastSeen is stamped on every complete line received; a miner that has +// said nothing for this long is gone in every way that matters. Ten +// minutes is far beyond the vardiff target (a share every 15s) and the +// stall watchdog (45s), so no miner that is actually hashing comes close. +static const int64 POOL_IDLE_TIMEOUT_SECS = 600; // Non-blocking recv: pull whatever bytes are available now into buf, then // return one complete line if one is ready. Three outcomes: @@ -4940,6 +4962,35 @@ static void PoolEventLoop() m->hashRate *= pow(0.5, (silent - grace) / expected); } + // 3.7. Idle reap (#6). Mirror the clean-disconnect path exactly -- + // log, close, erase from gMiners via std::find, delete -- just with + // a different reason. Shares the miner already earned stay in + // roundShareCount, exactly as they do when a miner cleanly + // disconnects mid-round. connectTime stands in for lastSeen on a + // connection that never completed a line. Erasing inside the loop + // above would invalidate the iteration, so collect then reap. + { + int64 now = GetTime(); + std::vector idle; + for (Miner* m : gMiners) { + int64 last = (m->lastSeen > 0) ? m->lastSeen : m->connectTime; + if (now - last > POOL_IDLE_TIMEOUT_SECS) + idle.push_back(m); + } + for (Miner* m : idle) { + LogPrint("worker", "[pool] dropping idle miner %s -- nothing" + " received in over %llds (session shares: %llu," + " round shares: %llu)\n", + m->worker.empty() ? "(never authorized)" : m->worker.c_str(), + (long long)POOL_IDLE_TIMEOUT_SECS, + (unsigned long long)m->sessionShares, + (unsigned long long)m->roundShares); + sock_close(m->fd); + gMiners.erase(std::find(gMiners.begin(), gMiners.end(), m)); + delete m; + } + } + // 4. Select over miner sockets if (!gMiners.empty()) { fd_set fds; FD_ZERO(&fds); @@ -4962,6 +5013,11 @@ static void PoolEventLoop() // terminate the whole node -- not just the pool. Every // line arrives from an unauthenticated socket. while (RecvLine(m->fd, m->readBuf, line, disc)) { + // Any complete line is proof of life (#6): authorize + // and valid shares already stamp lastSeen themselves, + // but a miner that only ever subscribes (or sends + // unrecognised methods) must count as alive too. + m->lastSeen = GetTime(); try { HandleLine(m, line, roundShareCount, roundShareTotal, blocksFoundThisSession); -- cgit v1.2.3 From 0b566b4869b9e86c90162befd87f9882b6dd143c Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 07:09:45 +0000 Subject: net: a refused thread spawn drops one peer, not the node std::thread throws std::system_error when pthread_create is refused (RLIMIT_NPROC, a container's pids cgroup, memory pressure), and nothing caught it, so the node died by std::terminate. Threads are spawned per peer connection, so the pressure that triggers this scales with peer count, which is attacker-influenced. Observed killing a healthy participant worker mid-mining. Every spawn site in the node build, and what a failure now does: - btftunnel.cpp: both tunnel pumps are guarded. The inbound pump starts first because it owns cleanup of the pump end and rendezvous socket; if the second spawn is refused, shutdown() wakes the first out of its read and it cleans up itself -- closing those descriptors from the failure path instead would race it onto reused descriptors. Both paths return INVALID_SOCKET, which every caller already treats as "this connection failed". - btfrv.cpp: the relay's accept loop drops the connection it cannot serve and stops accepting for a moment -- longer after consecutive failures, capped at 5s -- instead of dying at an arbitrary one. The per-pair second pump now runs on the handling thread itself, leaving exactly one spawn to guard and nothing running yet to race. - main.cpp: a participant that cannot spawn its hash workers stops the ones that did start, drops the pool connection, and returns to the reconnect loop, the same way an ordinary disconnect does. - randomx_pow.cpp: dataset init joins the workers it got, releases the dataset, and stays in light mode -- the same trade the allocation failure path above it already makes. - rpc.cpp, net.cpp: the remaining unchecked _beginthread calls (which report failure by value, they cannot throw) now say precisely what did not start. The pool's accept and payout threads failing silently left the pool running deaf -- nobody accepts, nobody gets paid, and nothing in the log says why. Left alone deliberately: the per-relay accept spawns (rpc.cpp) already check, log and retry on the next poll; startup-critical spawns in net.cpp keep failing hard; the selftest-only std::thread uses may abort a selftest run, which is acceptable where dying over one peer is not. Verified: full node rebuilds clean and links; pool-stratum selftest passes; a scratch harness under RLIMIT_NPROC=1 confirms _beginthread reports -1 and std::thread throws system_error, the premises of every path above. --- src/btfrv.cpp | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- src/btftunnel.cpp | 52 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.cpp | 30 +++++++++++++++++++++++++++--- src/net.cpp | 5 +++-- src/randomx_pow.cpp | 28 ++++++++++++++++++++++------ src/rpc.cpp | 15 +++++++++++---- 6 files changed, 157 insertions(+), 22 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 133eb89..8ab735c 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp @@ -40,6 +40,8 @@ typedef int SOCKET; #include #include #include +#include +#include namespace btf { @@ -209,8 +211,28 @@ static void HandleIncoming(SOCKET s) bool a = WriteN(svc, &ok, 1); bool b = WriteN(s, &ok, 1); if (!a || !b) { CLOSESOCK(svc); CLOSESOCK(s); return; } - std::thread(Forward, s, svc).detach(); - std::thread(Forward, svc, s).detach(); + // One thread pumps s -> svc; this thread pumps svc -> s. Spawning a + // thread per direction left no clean way to fail the second spawn: + // std::thread throws std::system_error when pthread_create is refused + // (NPROC limit, container pids cgroup, memory pressure), the first + // pump was already detached, and closing its sockets from here would + // race it -- so nothing caught the throw and it reached + // std::terminate, killing the relay over one pair it could not serve. + // Running one direction on this thread leaves exactly one spawn to + // guard, and a failure here has nothing running yet to race. + try + { + std::thread(Forward, s, svc).detach(); + } + catch (const std::system_error& e) + { + fprintf(stderr, "rendezvous relay: cannot spawn forward thread (%s), pair dropped\n", + e.what()); + CLOSESOCK(svc); + CLOSESOCK(s); + return; + } + Forward(svc, s); } else { @@ -236,6 +258,7 @@ bool RvRelayRun(unsigned short port) if (bind(g_listen, (struct sockaddr*)&addr, sizeof(addr)) != 0) { CLOSESOCK(g_listen); return false; } if (listen(g_listen, 16) != 0) { CLOSESOCK(g_listen); return false; } + int nSpawnFails = 0; while (!g_stop) { struct sockaddr_in cli; @@ -246,7 +269,27 @@ bool RvRelayRun(unsigned short port) if (g_stop) break; continue; } - std::thread(HandleIncoming, s).detach(); + try + { + std::thread(HandleIncoming, s).detach(); + nSpawnFails = 0; + } + catch (const std::system_error& e) + { + // A refused pthread_create is backpressure, not a reason to die: + // the condition behind it (NPROC, pids cgroup, memory) does not + // clear in milliseconds, and the connection count feeding it is + // attacker-influenced. Drop this one peer, stop accepting for a + // moment -- longer each time, capped -- and keep serving the + // connections already established. + fprintf(stderr, "rendezvous relay: cannot spawn connection thread (%s), connection dropped\n", + e.what()); + CLOSESOCK(s); + nSpawnFails++; + int nWaitMs = nSpawnFails < 5 ? nSpawnFails * 1000 : 5000; + for (int i = 0; i < nWaitMs && !g_stop; i += 100) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } } CLOSESOCK(g_listen); g_listen = INVALID_SOCKET; diff --git a/src/btftunnel.cpp b/src/btftunnel.cpp index 63d178b..2b3f818 100644 --- a/src/btftunnel.cpp +++ b/src/btftunnel.cpp @@ -25,8 +25,10 @@ typedef int SOCKET; #endif #include "sockcount.h" #include +#include #include #include +#include namespace btf { @@ -121,18 +123,60 @@ static void PumpIn(SOCKET pumpEnd, RvSocket rv, std::vector key) // Start the two pumps and hand back the app-facing socket. When both pumps // finish, close the pump end and the rendezvous socket. +// +// Every tunnel is two threads, and a tunnel is one peer connection (a .btf +// dial or accept, a pool miner, a participant's pool session) -- so the +// thread count scales with peer count, which is attacker-influenced. When +// pthread_create is refused std::thread throws std::system_error; uncaught it +// reached std::terminate and took the whole process down over one connection +// it could not serve. Both spawns are guarded, and the failure paths return +// INVALID_SOCKET, which every caller already handles by dropping just that +// connection. static btf_socket_t StartTunnel(RvSocket rv, const unsigned char shared[32]) { btf_socket_t appEnd, pumpEnd; if (!MakeLocalPair(appEnd, pumpEnd)) { RvClose(rv); return INVALID_SOCKET; } std::vector key(shared, shared + 32); - std::thread(PumpOut, pumpEnd, rv, key).detach(); - std::thread([pumpEnd, rv, key]() { - PumpIn(pumpEnd, rv, key); + + // The inbound pump starts first because it owns the cleanup: it closes + // pumpEnd and rv when it exits. That ownership is what makes the second + // spawn's failure path below race-free. + try + { + std::thread([pumpEnd, rv, key]() { + PumpIn(pumpEnd, rv, key); + BtfCloseSocket(pumpEnd); + RvClose(rv); + }).detach(); + } + catch (const std::system_error& e) + { + // Nothing is running yet, so closing everything here is safe. + fprintf(stderr, "btf tunnel: cannot spawn inbound pump (%s), tunnel dropped\n", + e.what()); BtfCloseSocket(pumpEnd); + BtfCloseSocket(appEnd); RvClose(rv); - }).detach(); + return INVALID_SOCKET; + } + + try + { + std::thread(PumpOut, pumpEnd, rv, key).detach(); + } + catch (const std::system_error& e) + { + // The inbound pump is parked in RvReadN(rv). shutdown() wakes it, and + // it closes pumpEnd and rv itself -- closing them here instead would + // race exactly that, and a reused descriptor is how an unrelated + // socket gets closed. + fprintf(stderr, "btf tunnel: cannot spawn outbound pump (%s), tunnel dropped\n", + e.what()); + shutdown((SOCKET)rv, SD_BOTH); + BtfCloseSocket(appEnd); + return INVALID_SOCKET; + } return appEnd; } diff --git a/src/main.cpp b/src/main.cpp index 7b3e555..d154523 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include "bip32.h" #include "btfaddr.h" #include "btftunnel.h" @@ -3899,9 +3900,32 @@ connected: std::vector shareHits; std::mutex shareHitsMtx; std::vector hashThreads; - for (int i = 0; i < nWorkers; i++) - hashThreads.emplace_back(ParticipantHashWorker, &jobShared, i, nWorkers, - &shareHits, &shareHitsMtx); + try + { + for (int i = 0; i < nWorkers; i++) + hashThreads.emplace_back(ParticipantHashWorker, &jobShared, i, nWorkers, + &shareHits, &shareHitsMtx); + } + catch (const std::system_error& e) + { + // A refused pthread_create used to reach std::terminate here and + // kill the worker mid-session -- observed twice on an otherwise + // healthy participant. Stop the workers that did start the same way + // the disconnect path below does, drop this pool connection, and let + // the caller's reconnect loop try again once the pressure clears. + LogPrint("worker", "[worker] cannot spawn hash threads (%s)" + " -- dropping pool connection\n", e.what()); + SetParticipantMiningStatus("hash thread spawn failed"); + { + std::lock_guard lk(jobShared.mtx); + jobShared.stop = true; + jobShared.haveJob = false; + } + for (std::thread& t : hashThreads) + t.join(); + closesocket(s); + return false; + } LogPrint("worker", "[worker] spawned %d hash threads on shared RandomX dataset\n", nWorkers); const uint64 sessionStartHashes = gParticipantHashes.load(); diff --git a/src/net.cpp b/src/net.cpp index fed6123..16038c8 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -2090,7 +2090,7 @@ bool StartNode(string& strError) if (addrIncoming.ip) addrLocalHost.ip = addrIncoming.ip; - _beginthread([](void*) { + if (_beginthread([](void*) { unsigned int ip = addrLocalHost.ip; if (GetMyExternalIP(ip)) { addrLocalHost.ip = ip; @@ -2098,7 +2098,8 @@ bool StartNode(string& strError) CWalletDB().WriteSetting("addrIncoming", addrIncoming); printf("External IP updated: %s\n", addrLocalHost.ToStringIP().c_str()); } - }, 0, NULL); + }, 0, NULL) == (uintptr_t)-1) + printf("Error: _beginthread(GetMyExternalIP) failed\n"); // Peers that answered last time, dialled straight away. The relays are the // only way to find anyone otherwise, and reaching them plus walking their diff --git a/src/randomx_pow.cpp b/src/randomx_pow.cpp index 1d058d4..9c59364 100644 --- a/src/randomx_pow.cpp +++ b/src/randomx_pow.cpp @@ -8,6 +8,7 @@ #include #include #include +#include // Fixed key (seed) that determines the RandomX cache/dataset. Keeping it // constant avoids per-epoch "reseed" logic; for the CPU+RAM fairness goal @@ -101,13 +102,28 @@ bool RandomXInitDataset(int nThreads) std::vector workers; unsigned long per = total / nThreads; - for (int i = 0; i < nThreads; i++) + try { - unsigned long start = i * per; - unsigned long count = (i == nThreads - 1) ? (total - start) : per; - workers.emplace_back([start, count]() { - randomx_init_dataset(g_dataset, g_cache, start, count); - }); + for (int i = 0; i < nThreads; i++) + { + unsigned long start = i * per; + unsigned long count = (i == nThreads - 1) ? (total - start) : per; + workers.emplace_back([start, count]() { + randomx_init_dataset(g_dataset, g_cache, start, count); + }); + } + } + catch (const std::system_error& e) + { + // Same refusal as the allocation failure above: light mode is slow + // but alive, std::terminate is neither. Join the workers that did + // start before releasing the dataset out from under them. + for (auto& w : workers) w.join(); + randomx_release_dataset(g_dataset); + g_dataset = NULL; + printf("RandomX: cannot spawn dataset init threads (%s), staying in light mode\n", + e.what()); + return false; } for (auto& w : workers) w.join(); diff --git a/src/rpc.cpp b/src/rpc.cpp index a4b53ca..13cb6c9 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -5077,10 +5077,17 @@ void ThreadRPCServer(void*) // armed for its one wallet probe before it is allowed to send. ReconcilePayouts(); - _beginthread(AcceptLoopFn, 0, NULL); - _beginthread(LocalAcceptFn, 0, NULL); - _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); - _beginthread(PayoutThreadFn, 0, NULL); + // _beginthread cannot throw (it reports -1), but an unchecked failure + // leaves the pool running deaf: nobody accepts, announces, or pays, and + // nothing in the log says why. + if (_beginthread(AcceptLoopFn, 0, NULL) == (uintptr_t)-1) + LogPrint("pool", "[pool] FATAL: accept loop thread failed to spawn -- miners cannot connect\n"); + if (_beginthread(LocalAcceptFn, 0, NULL) == (uintptr_t)-1) + LogPrint("pool", "[pool] local accept thread failed to spawn\n"); + if (_beginthread(ThreadBtfPoolAnnouncer, 0, NULL) == (uintptr_t)-1) + LogPrint("pool", "[pool] announcer thread failed to spawn -- pool will not be advertised\n"); + if (_beginthread(PayoutThreadFn, 0, NULL) == (uintptr_t)-1) + LogPrint("pool", "[pool] FATAL: payout thread failed to spawn -- no one gets paid\n"); PoolEventLoop(); // blocks until gPoolRunning = false or fShutdown -- cgit v1.2.3 From f094dc2f95df213f8c89d1d6f25dc864062623ae Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 07:15:45 +0000 Subject: pool: refuse to build a job whose coinbase would burn the subsidy GetKeyFromPool() returns an empty vector when the wallet is encrypted and locked, and when key generation itself fails. RebuildJob() fed whatever came back straight into the coinbase, and CScript::operator<< serialises an empty vector as a zero-length push, so the template's coinbase script became ` OP_CHECKSIG` -- satisfiable by nobody. Every block the pool's miners found against such a job burned its whole subsidy: the coins exist and can never move, while QueuePayouts records the full obligation and the miners are then paid out of coin that block never contributed. An operator who locks the wallet overnight while the miners keep finding blocks pays twice for every one of them. BitcoinMiner has exactly this guard ("no wallet key available for coinbase; mining stopped"); this builder was missed. Mirror it: log the reason and return false before any job state is touched. Every caller already treats a false return as "no job this round" -- the submit handler and the new-block path log a warning and leave gHaveJob false, the no-job watchdog goes on retrying every few seconds, and the periodic nTime refresh keeps broadcasting the old job, whose key was drawn while the wallet could still produce one. Once the wallet can hand out a key again the next retry issues a spendable job unprompted. --- src/rpc.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index a4b53ca..cc3fffa 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -223,6 +223,29 @@ static bool RebuildJob() // From the pool, so the template's payout key is in wallet.dat before // any miner is handed work against it. vector vchPubKey = GetKeyFromPool(); + if (vchPubKey.empty()) { + // Empty means the wallet could not produce a key: encrypted and + // locked, or key generation itself failed. Serialised, an empty + // vector is a zero-length push, so the coinbase would become + // ` OP_CHECKSIG` -- a script nobody can satisfy. Every + // block mined against such a job burns its whole subsidy, while + // QueuePayouts still records the full obligation and the miners + // are then paid out of coin that block never contributed. + // BitcoinMiner refuses to mine in this state; refuse to hand the + // pool's miners work in it too. printf and not LogPrint for the + // reason given above the deaf-node refusal, and throttled the + // same way -- the no-job watchdog retries every few seconds for + // as long as the condition lasts. + static int64 nLastSaidNoKey = 0; + if (GetTime() - nLastSaidNoKey > 60) { + nLastSaidNoKey = GetTime(); + printf("RebuildJob: no wallet key available for the coinbase " + "(wallet locked, or key generation failed -- the " + "GetKeyFromPool line above says which) -- not issuing " + "jobs\n"); + } + return false; + } static std::atomic sExtra{0}; uint32_t extraNonce = ++sExtra; -- cgit v1.2.3 From 6c37f59ea136cbace8b0cfccefdad555ff3d619f Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 08:54:01 +0000 Subject: payout: name the funding block in new payout comments A height the pool wins, loses to a reorg, and wins again yields two rounds whose height-only comments are identical -- and identical amounts too when the same miner held both rounds. New sends now write the funding block hash into the comment. The legacy fallback's needle stays frozen at the pre-upgrade format: it exists precisely for transactions that carry nothing else, and a needle in any other format would make every one of those payments look unpaid and pay it again. --- src/rpc.cpp | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 4 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index a4b53ca..f9471c2 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1186,11 +1186,35 @@ static bool PayoutBlockStillOnChain(const uint256& blockHash) return false; } -static std::string PayoutComment(int blockHeight) +// The comment every pre-upgrade payout transaction carries, and therefore the +// ONLY needle the legacy fallback in FindRecordedPoolPayout may search with. +// This format is frozen: change it and every payment those builds made stops +// matching, every one of those debts looks unpaid, and reconciliation pays +// them all a second time. +static std::string LegacyPayoutComment(int blockHeight) { return strprintf("Pool payout height %d", blockHeight); } +// The comment written on new sends. It names the funding block, because the +// height alone cannot: a height that is won, lost to a reorg, and won again +// yields two rounds whose height-only comments are identical -- and identical +// amounts too, whenever the same miner held both rounds. The structured +// pool_payout_* metadata already keys the payment by hash for the code; this +// puts the same fact where an operator reading the wallet can see it, and +// makes a new transaction unmatchable by the legacy needle even if the +// structured keys are ever lost. +// +// A zero hash keeps the legacy form: those records predate hash keying, and +// there is no hash to name. +static std::string PayoutComment(int blockHeight, const uint256& blockHash) +{ + if (blockHash == 0) + return LegacyPayoutComment(blockHeight); + return strprintf("Pool payout height %d block %s", blockHeight, + blockHash.ToString().c_str()); +} + // Startup reconciliation. // // This used to be the crash-recovery mechanism: it scanned the wallet with @@ -1425,7 +1449,7 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, CScript want; if (fHaveScript) want << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; - const std::string comment = PayoutComment(blockHeight); + const std::string comment = LegacyPayoutComment(blockHeight); // Candidate legacy matches, adjudicated after cs_mapWallet is released. // @@ -2186,7 +2210,7 @@ static void PayoutThreadFn(void*) } CWalletTx wtx; - wtx.mapValue["comment"] = PayoutComment(r.blockHeight); + wtx.mapValue["comment"] = PayoutComment(r.blockHeight, r.blockHash); // Upstream's structured metadata. This is what makes the wallet // probe above possible at all -- the old comment-only form could // not distinguish two rounds paying the same address the same @@ -3825,7 +3849,7 @@ int RunPayoutLedgerSelfTest() const int height = 8080; const int64 amount = 7 * COIN; const std::string id = PayoutIdForBlock(height, 0); - const std::string comment = PayoutComment(height); + const std::string comment = LegacyPayoutComment(height); std::set none; std::string found; @@ -4419,6 +4443,71 @@ int RunPayoutLedgerSelfTest() } #endif + // -- 14. The payout comment names the funding block ---------------------- + // + // Same height, two hashes: the shape a reorg leaves when the pool wins a + // height, loses the block, and wins the height again. The legacy needle is + // frozen at the pre-upgrade format, so pre-upgrade payments keep matching; + // a new-format comment never matches it, so a new send can never be read + // as a pre-upgrade payment for a twin round. + { + PayoutTestResetState(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); + + const int height = 9090; + const int64 amount = 3 * COIN; + uint256 hashA = 0, hashB = 0; + hashA.SetHex("00000000000000000000000000000000000000000000000000000000000000aa"); + hashB.SetHex("00000000000000000000000000000000000000000000000000000000000000bb"); + + check(LegacyPayoutComment(height) == + strprintf("Pool payout height %d", height), + "the legacy needle is frozen at the format pre-upgrade builds" + " wrote"); + check(PayoutComment(height, 0) == LegacyPayoutComment(height), + "a grandfathered zero-hash record still writes the legacy form"); + check(PayoutComment(height, hashA) == + strprintf("Pool payout height %d block %s", height, + hashA.ToString().c_str()), + "a hash-keyed record's comment names its funding block"); + check(PayoutComment(height, hashA) != PayoutComment(height, hashB), + "twin rounds at one height carry distinguishable comments"); + + // A transaction wearing the NEW comment format and -- hypothetically + // -- no structured metadata must not be matched by the legacy needle: + // the fallback exists for pre-upgrade transactions, and the new format + // proves this is not one. + const std::string idA = PayoutIdForBlock(height, hashA); + const std::string idB = PayoutIdForBlock(height, hashB); + std::set none; + std::string found; + PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 1, ""); + check(FindRecordedPoolPayout(idB, height, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "a new-format comment is never legacy evidence for a twin round"); + check(FindRecordedPoolPayout(idA, height, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "nor a match channel for its own round -- structured metadata is"); + + // Round A's own payment, sent by this build, is found by its + // structured id. + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); + const std::string paidA = + PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, + 2, idA); + check(FindRecordedPoolPayout(idA, height, addrA, amount, none, found) + == PAYOUT_PROBE_STRUCTURED && found == paidA, + "the winning twin's own payment is found by its structured id"); + + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); + } + // -- clean up ------------------------------------------------------------ PayoutTestResetState(); CRITICAL_BLOCK(cs_mapWallet) -- cgit v1.2.3 From a2e1f27b32442bcea92eeda3ac09432f6c5ec2c5 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 08:56:46 +0000 Subject: payout: record the full funding block hash and require it to agree pool_payout_id keeps only the first sixteen hex digits of the block hash, which is the end proof-of-work fills with zeros -- so twin rounds at one height can carry the SAME id, and one twin's payment then settles the other's debt through the structured probe: the same-height collision the comment match had, one layer down. New sends record the full hash as pool_payout_block and a structured match must agree with it. A transaction without the key predates it and is matched by id as before; anything stricter would make every payment already sent look unpaid and pay it again. --- src/rpc.cpp | 96 ++++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 76 insertions(+), 20 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index f9471c2..dbbf06d 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1373,10 +1373,15 @@ static void MarkPoolRoundForfeited(const std::string& id, // // Two kinds of evidence, in order of quality. // -// Structured (pool_payout_id / pool_payout_address / pool_payout_amount) is -// upstream's, and it is exact: the id is derived from the block hash, so it -// names one round and one round only. Every transaction this build sends -// carries it. +// Structured (pool_payout_id / pool_payout_address / pool_payout_amount, +// plus pool_payout_block since this series) is upstream's, extended. The id +// is derived from the block hash, but only from the FIRST sixteen hex digits +// of it -- and proof-of-work concentrates zeros exactly there, so the id can +// name two same-height rounds identically. New sends therefore record the +// full hash as pool_payout_block, and a structured match must agree with it +// wherever both sides have one; a transaction without the key was sent +// before it existed, and for those the id is the best evidence there is. +// Every transaction this build sends carries all four keys. // // But NO transaction this pool has ever sent carries it. Every payout in the // existing wallet has only a "comment". So on the first boot after this @@ -1437,6 +1442,7 @@ enum PayoutProbeResult { static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, int blockHeight, + const uint256& blockHash, const std::string& address, int64 amount, const std::set& excluded, @@ -1482,6 +1488,19 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, map::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount"); if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount)) continue; + // The id alone cannot tell twin rounds at one height apart + // (see above); the full hash can, where the transaction + // recorded one. Where it did not, the id decides, as it + // always has -- refusing those transactions would make every + // payment sent before the key existed look unpaid and pay it + // again. + if (blockHash != 0) { + map::const_iterator blkIt = + wtx.mapValue.find("pool_payout_block"); + if (blkIt != wtx.mapValue.end() + && blkIt->second != blockHash.ToString()) + continue; // a twin round's payment, not this round's + } txidRet = wtx.GetHash().GetHex(); return PAYOUT_PROBE_STRUCTURED; } @@ -2125,7 +2144,8 @@ static void PayoutThreadFn(void*) claimKey, excluded); r.fProbeWallet = false; PayoutProbeResult probe = FindRecordedPoolPayout( - id, r.blockHeight, r.address, r.amount, excluded, recovered); + id, r.blockHeight, r.blockHash, r.address, r.amount, + excluded, recovered); if (probe != PAYOUT_PROBE_NONE) { if (probe == PAYOUT_PROBE_LEGACY) { // Loud on purpose: this is the upgrade path, where the @@ -2219,6 +2239,11 @@ static void PayoutThreadFn(void*) wtx.mapValue["pool_payout_id"] = id; wtx.mapValue["pool_payout_address"] = r.address; wtx.mapValue["pool_payout_amount"] = strprintf("%lld", r.amount); + // The full hash, not only the id's sixteen-digit prefix: the + // prefix of a proof-of-work hash is where the zeros live, and + // the probe needs to tell twin rounds at one height apart. + if (r.blockHash != 0) + wtx.mapValue["pool_payout_block"] = r.blockHash.ToString(); SendMoneyOutcome outcome; if (SendMoney(sc, r.amount, wtx, outcome)) { @@ -3285,7 +3310,8 @@ static std::string PayoutTestAddWalletTx(const std::string& comment, int64 amount, int nonce, const std::string& structuredId, - bool fConfirmed = true) + bool fConfirmed = true, + const std::string& structuredBlock = "") { CWalletTx wtx; wtx.vin.resize(1); @@ -3300,6 +3326,8 @@ static std::string PayoutTestAddWalletTx(const std::string& comment, wtx.mapValue["pool_payout_id"] = structuredId; wtx.mapValue["pool_payout_address"] = address; wtx.mapValue["pool_payout_amount"] = strprintf("%lld", amount); + if (!structuredBlock.empty()) + wtx.mapValue["pool_payout_block"] = structuredBlock; } uint256 hash = wtx.GetHash(); if (fConfirmed) { @@ -3853,30 +3881,30 @@ int RunPayoutLedgerSelfTest() std::set none; std::string found; - check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount, none, found) == PAYOUT_PROBE_NONE, "an empty wallet is not evidence that anything was paid"); const std::string legacyTxid = PayoutTestAddWalletTx(comment, addrA, amount, 1, ""); - check(FindRecordedPoolPayout(id, height, addrA, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount, none, found) == PAYOUT_PROBE_LEGACY && found == legacyTxid, "a payment carrying only a pre-upgrade comment is recognised as" " already paid"); - check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) == PAYOUT_PROBE_NONE, "the legacy match requires the output to pay THIS recipient"); - check(FindRecordedPoolPayout(id, height, addrA, amount + COIN, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount + COIN, none, found) == PAYOUT_PROBE_NONE, "the legacy match requires the output to pay exactly this amount"); - check(FindRecordedPoolPayout(id, height + 1, addrA, amount, none, found) + check(FindRecordedPoolPayout(id, height + 1, 0, addrA, amount, none, found) == PAYOUT_PROBE_NONE, "the legacy match requires the comment for this block height"); std::set spent; spent.insert(legacyTxid); - check(FindRecordedPoolPayout(id, height, addrA, amount, spent, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount, spent, found) == PAYOUT_PROBE_NONE, "one legacy transaction cannot settle a second debt"); @@ -3885,7 +3913,7 @@ int RunPayoutLedgerSelfTest() // exactly what the structured metadata exists to prevent. const std::string otherId = PayoutIdForBlock(height, 0) + "-other"; PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId); - check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) == PAYOUT_PROBE_NONE, "another round's structured payment is not legacy evidence for" " this one"); @@ -3893,7 +3921,7 @@ int RunPayoutLedgerSelfTest() // The structured path still wins where it applies. const std::string structuredTxid = PayoutTestAddWalletTx(comment, addrB, amount, 3, id); - check(FindRecordedPoolPayout(id, height, addrB, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, "structured payout metadata is matched in preference to a comment"); @@ -3907,7 +3935,7 @@ int RunPayoutLedgerSelfTest() PayoutTestClearBlockIndex(); const std::string addrC = PayoutTestAddress(0x33); PayoutTestAddWalletTx(comment, addrC, amount, 4, "", false); - check(FindRecordedPoolPayout(id, height, addrC, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrC, amount, none, found) == PAYOUT_PROBE_NONE, "a pre-upgrade payout that never confirmed is not read as" " payment"); @@ -3917,7 +3945,7 @@ int RunPayoutLedgerSelfTest() mapWallet.clear(); const std::string confirmedTxid = PayoutTestAddWalletTx(comment, addrC, amount, 4, "", true); - check(FindRecordedPoolPayout(id, height, addrC, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrC, amount, none, found) == PAYOUT_PROBE_LEGACY && found == confirmedTxid, "the same payout, confirmed, is"); @@ -3928,7 +3956,7 @@ int RunPayoutLedgerSelfTest() PayoutTestAddWalletTx(comment, addrC, amount, 5, "", false); std::set spentFirst; spentFirst.insert(confirmedTxid); - check(FindRecordedPoolPayout(id, height, addrC, amount, spentFirst, found) + check(FindRecordedPoolPayout(id, height, 0, addrC, amount, spentFirst, found) == PAYOUT_PROBE_LEGACY && found == alsoConfirmed, "an unconfirmed transaction does not hide a confirmed one behind" " it"); @@ -4484,10 +4512,10 @@ int RunPayoutLedgerSelfTest() std::set none; std::string found; PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 1, ""); - check(FindRecordedPoolPayout(idB, height, addrA, amount, none, found) + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) == PAYOUT_PROBE_NONE, "a new-format comment is never legacy evidence for a twin round"); - check(FindRecordedPoolPayout(idA, height, addrA, amount, none, found) + check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) == PAYOUT_PROBE_NONE, "nor a match channel for its own round -- structured metadata is"); @@ -4499,10 +4527,38 @@ int RunPayoutLedgerSelfTest() const std::string paidA = PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 2, idA); - check(FindRecordedPoolPayout(idA, height, addrA, amount, none, found) + check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) == PAYOUT_PROBE_STRUCTURED && found == paidA, "the winning twin's own payment is found by its structured id"); + // The id is derived from the FIRST sixteen hex digits of the hash -- + // the end proof-of-work fills with zeros -- so twin hashes that agree + // there collide in id, and a payment carrying no pool_payout_block + // answers for either twin. That is the accepted trade for payments + // sent before the key existed, pinned here so it stays a decision. + check(idA == idB, + "twin hashes differing only past the sixteenth hex digit collide" + " in id -- the reason the full hash must decide"); + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) + == PAYOUT_PROBE_STRUCTURED, + "a payment without the block key answers for a colliding twin --" + " the accepted residual for pre-key transactions"); + + // With the full hash recorded -- as every send now records it -- the + // twins are told apart and the re-won height is paid. + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); + const std::string keyedA = + PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, + 3, idA, true, hashA.ToString()); + check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) + == PAYOUT_PROBE_STRUCTURED && found == keyedA, + "a payment carrying the full block hash settles its own round"); + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) + == PAYOUT_PROBE_NONE, + "and is no evidence for the twin, so the re-won height is paid"); + CRITICAL_BLOCK(cs_mapWallet) mapWallet.clear(); PayoutTestClearBlockIndex(); -- cgit v1.2.3 From d8eb3aeec198e9e6753b89a53d25f4f91f8ab3d6 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 08:59:21 +0000 Subject: payout: refuse comment-only evidence for a round that has a proof A pre-upgrade payout comment names only the height, so when a height is won, orphaned, and won again, the old transaction matches the new round's (height, address, amount) exactly and the miner is never paid. Round proofs decide the ambiguity where it is decidable: a proof is written when a round is created, and structured payout metadata (upstream 0b01d6c) predates round proofs (upstream b1eb8b6), so a round with a proof was created by a build whose every payment names its round -- a comment-only match can only be another round's money. Skipping the fallback cannot double-pay: any real payment for such a round is already found by the structured probe. Proofless rounds keep the documented bias. --- src/rpc.cpp | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 139 insertions(+), 22 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index dbbf06d..310c0d4 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1369,6 +1369,24 @@ static void MarkPoolRoundForfeited(const std::string& id, PublishPoolRounds(snap); } +// Does this round have a published round proof? +// +// Existence answers a temporal question the legacy fallback below needs: +// which builds could have paid this round. RecordPoolRound writes the proof +// when the round is created, and structured payout metadata (upstream +// cbdd06d) is an ancestor of round proofs (upstream 83ba822) -- no build +// able to write a proof has ever sent a payment that only a comment can +// identify. A round with a proof therefore cannot have a comment-only +// payment; one that matches it is another round's, colliding on the height. +static bool PoolRoundHasProof(const std::string& id) +{ + std::lock_guard lk(gPoolRoundsMutex); + for (const PoolRoundProof& r : gPoolRounds) + if (r.id == id) + return true; + return false; +} + // Which wallet transaction, if any, already paid this? // // Two kinds of evidence, in order of quality. @@ -1404,11 +1422,22 @@ static void MarkPoolRoundForfeited(const std::string& id, // (round, address) pair -- from the ledger's paidTxids, which is durable, // and from what this process has already claimed. One legacy transaction // can therefore settle at most one debt, so N transactions can never -// satisfy more than N obligations. +// satisfy more than N obligations; +// +// 4. it is not consulted at all for a round that has a round proof +// (fAllowLegacy, decided by the caller from PoolRoundHasProof). Proofs +// are written when a round is created, and only by builds whose every +// payment names its round exactly -- so a comment-only transaction +// cannot be a payment for such a round, only a same-height collision +// from an older one, and skipping the fallback cannot hide a real +// payment: the structured probe above already found any that exist. // -// What is left unresolved by construction is two pre-upgrade rounds at the -// SAME height paying the same address the same amount, since the comment -// carries only the height. That needs a reorg. +// What is left unresolved by construction is a comment-only transaction and +// a PROOFLESS round at the same height paying the same address the same +// amount: two grandfathered zero-hash twins, or a twin pair from the builds +// that keyed the ledger by hash before any metadata reached the wallet. The +// comment carries only the height, so nothing ties the transaction to one +// twin rather than the other. That needs a reorg. // // This used to say the losing twin's ledger entry is voided by the orphan // check before it can be paid. That is wrong: PayoutBlockStillOnChain() @@ -1445,6 +1474,7 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, const uint256& blockHash, const std::string& address, int64 amount, + bool fAllowLegacy, const std::set& excluded, std::string& txidRet) { @@ -1505,6 +1535,8 @@ static PayoutProbeResult FindRecordedPoolPayout(const std::string& id, return PAYOUT_PROBE_STRUCTURED; } + if (!fAllowLegacy) + continue; // comment-only evidence is inadmissible: rule 4 if (!fHaveScript) continue; // The comment is written only by the payout path, so its presence @@ -2142,10 +2174,15 @@ static void PayoutThreadFn(void*) std::set excluded; CollectForeignPayoutTxids(r.blockHeight, r.blockHash, r.address, claimKey, excluded); + // A round with a published proof was created by a build whose + // every payment names its round exactly, so only structured + // evidence can settle it -- a comment match would be another + // round's payment colliding on the height. + const bool fAllowLegacy = !PoolRoundHasProof(id); r.fProbeWallet = false; PayoutProbeResult probe = FindRecordedPoolPayout( id, r.blockHeight, r.blockHash, r.address, r.amount, - excluded, recovered); + fAllowLegacy, excluded, recovered); if (probe != PAYOUT_PROBE_NONE) { if (probe == PAYOUT_PROBE_LEGACY) { // Loud on purpose: this is the upgrade path, where the @@ -3880,31 +3917,34 @@ int RunPayoutLedgerSelfTest() const std::string comment = LegacyPayoutComment(height); std::set none; std::string found; + // No round proof exists for these rounds, so legacy evidence is + // admissible -- computed exactly as the payout thread computes it. + const bool fAllowLegacy = !PoolRoundHasProof(id); - check(FindRecordedPoolPayout(id, height, 0, addrA, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "an empty wallet is not evidence that anything was paid"); const std::string legacyTxid = PayoutTestAddWalletTx(comment, addrA, amount, 1, ""); - check(FindRecordedPoolPayout(id, height, 0, addrA, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_LEGACY && found == legacyTxid, "a payment carrying only a pre-upgrade comment is recognised as" " already paid"); - check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrB, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "the legacy match requires the output to pay THIS recipient"); - check(FindRecordedPoolPayout(id, height, 0, addrA, amount + COIN, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount + COIN, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "the legacy match requires the output to pay exactly this amount"); - check(FindRecordedPoolPayout(id, height + 1, 0, addrA, amount, none, found) + check(FindRecordedPoolPayout(id, height + 1, 0, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "the legacy match requires the comment for this block height"); std::set spent; spent.insert(legacyTxid); - check(FindRecordedPoolPayout(id, height, 0, addrA, amount, spent, found) + check(FindRecordedPoolPayout(id, height, 0, addrA, amount, fAllowLegacy, spent, found) == PAYOUT_PROBE_NONE, "one legacy transaction cannot settle a second debt"); @@ -3913,7 +3953,7 @@ int RunPayoutLedgerSelfTest() // exactly what the structured metadata exists to prevent. const std::string otherId = PayoutIdForBlock(height, 0) + "-other"; PayoutTestAddWalletTx(comment, addrB, amount, 2, otherId); - check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrB, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "another round's structured payment is not legacy evidence for" " this one"); @@ -3921,7 +3961,7 @@ int RunPayoutLedgerSelfTest() // The structured path still wins where it applies. const std::string structuredTxid = PayoutTestAddWalletTx(comment, addrB, amount, 3, id); - check(FindRecordedPoolPayout(id, height, 0, addrB, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrB, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_STRUCTURED && found == structuredTxid, "structured payout metadata is matched in preference to a comment"); @@ -3935,7 +3975,7 @@ int RunPayoutLedgerSelfTest() PayoutTestClearBlockIndex(); const std::string addrC = PayoutTestAddress(0x33); PayoutTestAddWalletTx(comment, addrC, amount, 4, "", false); - check(FindRecordedPoolPayout(id, height, 0, addrC, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrC, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "a pre-upgrade payout that never confirmed is not read as" " payment"); @@ -3945,7 +3985,7 @@ int RunPayoutLedgerSelfTest() mapWallet.clear(); const std::string confirmedTxid = PayoutTestAddWalletTx(comment, addrC, amount, 4, "", true); - check(FindRecordedPoolPayout(id, height, 0, addrC, amount, none, found) + check(FindRecordedPoolPayout(id, height, 0, addrC, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_LEGACY && found == confirmedTxid, "the same payout, confirmed, is"); @@ -3956,7 +3996,7 @@ int RunPayoutLedgerSelfTest() PayoutTestAddWalletTx(comment, addrC, amount, 5, "", false); std::set spentFirst; spentFirst.insert(confirmedTxid); - check(FindRecordedPoolPayout(id, height, 0, addrC, amount, spentFirst, found) + check(FindRecordedPoolPayout(id, height, 0, addrC, amount, fAllowLegacy, spentFirst, found) == PAYOUT_PROBE_LEGACY && found == alsoConfirmed, "an unconfirmed transaction does not hide a confirmed one behind" " it"); @@ -4511,11 +4551,14 @@ int RunPayoutLedgerSelfTest() const std::string idB = PayoutIdForBlock(height, hashB); std::set none; std::string found; + // Neither twin has a round proof here (section 15 covers the gate), + // and their ids collide, so one answer serves both. + const bool fAllowLegacy = !PoolRoundHasProof(idA); PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 1, ""); - check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "a new-format comment is never legacy evidence for a twin round"); - check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) + check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "nor a match channel for its own round -- structured metadata is"); @@ -4527,7 +4570,7 @@ int RunPayoutLedgerSelfTest() const std::string paidA = PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 2, idA); - check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) + check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_STRUCTURED && found == paidA, "the winning twin's own payment is found by its structured id"); @@ -4539,7 +4582,7 @@ int RunPayoutLedgerSelfTest() check(idA == idB, "twin hashes differing only past the sixteenth hex digit collide" " in id -- the reason the full hash must decide"); - check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_STRUCTURED, "a payment without the block key answers for a colliding twin --" " the accepted residual for pre-key transactions"); @@ -4552,10 +4595,10 @@ int RunPayoutLedgerSelfTest() const std::string keyedA = PayoutTestAddWalletTx(PayoutComment(height, hashA), addrA, amount, 3, idA, true, hashA.ToString()); - check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, none, found) + check(FindRecordedPoolPayout(idA, height, hashA, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_STRUCTURED && found == keyedA, "a payment carrying the full block hash settles its own round"); - check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, none, found) + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, fAllowLegacy, none, found) == PAYOUT_PROBE_NONE, "and is no evidence for the twin, so the re-won height is paid"); @@ -4564,6 +4607,80 @@ int RunPayoutLedgerSelfTest() PayoutTestClearBlockIndex(); } + // -- 15. A proof-bearing round rejects comment-only evidence ------------- + // + // The surviving half of the same-height collision: a pre-upgrade + // transaction carries only "Pool payout height H", so if H is won again + // after a reorg, it matches the new round's (height, address, amount) + // exactly and would settle a debt it never paid. The new round has a + // round proof -- written at creation, and only by builds whose payments + // all name their round -- so for it, comment-only evidence is + // inadmissible and the miner is paid. A round without a proof keeps + // today's bias: the evidence is admissible and doubt resolves to + // "already paid". + { + PayoutTestResetState(); + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); + const std::string roundsPath = dir + "/pool_rounds_gate_selftest.json"; + const std::string savedRoundsFile = strPoolRoundsFile; + strPoolRoundsFile = roundsPath; + remove(roundsPath.c_str()); + + const int height = 7171; + const int64 amount = 4 * COIN; + uint256 hashB = 0; + hashB.SetHex("00000000000000000000000000000000000000000000000000000000000000cc"); + const std::string idB = PayoutIdForBlock(height, hashB); + std::set none; + std::string found; + + // The colliding pre-upgrade payment: confirmed, comment-only, same + // height, same address, same amount -- the strongest wrong evidence + // there is. + PayoutTestAddWalletTx(LegacyPayoutComment(height), addrA, amount, 1, ""); + + check(!PoolRoundHasProof(idB), + "a round with no recorded proof does not claim one"); + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, + !PoolRoundHasProof(idB), none, found) + == PAYOUT_PROBE_LEGACY, + "without a proof the doubt is genuine and still resolves to" + " already-paid"); + + PoolRoundProof round; + round.id = idB; + round.blockHeight = height; + round.matureAtHeight = height + COINBASE_SPENDABLE_DEPTH; + round.payouts[addrA] = amount; + RecordPoolRound(round); + check(PoolRoundHasProof(idB), + "recording the round proof marks the round as this lineage's"); + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, + !PoolRoundHasProof(idB), none, found) + == PAYOUT_PROBE_NONE, + "with a proof, a same-height comment-only transaction is not" + " evidence, and the re-won height is paid"); + + // Structured evidence still is: the round's own payment, recovered. + const std::string paidB = + PayoutTestAddWalletTx(PayoutComment(height, hashB), addrA, amount, + 2, idB, true, hashB.ToString()); + check(FindRecordedPoolPayout(idB, height, hashB, addrA, amount, + !PoolRoundHasProof(idB), none, found) + == PAYOUT_PROBE_STRUCTURED && found == paidB, + "the gate does not blind the probe to the round's own structured" + " payment"); + + CRITICAL_BLOCK(cs_mapWallet) + mapWallet.clear(); + PayoutTestClearBlockIndex(); + remove(roundsPath.c_str()); + remove((roundsPath + ".tmp").c_str()); + strPoolRoundsFile = savedRoundsFile; + } + // -- clean up ------------------------------------------------------------ PayoutTestResetState(); CRITICAL_BLOCK(cs_mapWallet) -- cgit v1.2.3 From 7221bb35d50e9bac11ce5e4b3252c34abe03c124 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 09:01:34 +0000 Subject: payout: treat a malformed ledger block hash as corruption, not as a hash SetHex() parses whatever hex prefix it finds and stops, so a corrupt blockHash loaded as a DIFFERENT hash: garbage with a hex-looking prefix became a hash no block ever had, which the orphan check reads as an orphaned block -- and the next flush voided the debt on the strength of file corruption. Garbage with no hex prefix became 0 and skipped the orphan check instead. A field that is not the 64-digit hex string this tree writes now fails the load, and the file is quarantined like any other parse error. --- src/rpc.cpp | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 2 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 310c0d4..be6c80d 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -776,6 +776,29 @@ static void PublishPendingPayouts(const VersionedBlob& v) gPayoutLedgerPublishedSeq, "pending payout ledger"); } +// Parse a persisted block-hash field, refusing to guess. SetHex() reads +// whatever hex prefix it finds and stops at the first character it does not +// recognise, so a corrupt value does not fail to parse -- it parses as a +// DIFFERENT hash. Both shapes of that are wrong in a dangerous direction: a +// value with a hex-looking prefix ("deadbeef...") becomes a hash no block +// ever had, which PayoutBlockStillOnChain reads as an orphaned block and +// FlushMaturePayouts then VOIDS the debt; a value with no hex prefix at all +// becomes 0, which is grandfathered past the orphan check on the strength +// of garbage. A field that is not the 64-digit hex string ToString() wrote +// is corruption, and corruption is a load failure like any other parse +// error -- the file is quarantined, not read around. +static uint256 ParsePayoutBlockHash(const std::string& str) +{ + if (str.size() != 64 || + str.find_first_not_of("0123456789abcdefABCDEF") != std::string::npos) + throw std::runtime_error( + strprintf("blockHash \"%s\" is not a 64-digit hex string", + str.c_str())); + uint256 h; + h.SetHex(str); + return h; +} + static void LoadPendingPayouts() { std::string path = PayoutFilePath(); @@ -797,7 +820,8 @@ static void LoadPendingPayouts() // field. Leave blockHash zero and they are grandfathered past the // orphan check rather than voided on a guess. if (obj.contains("blockHash") && obj["blockHash"].is_string()) - pp.blockHash.SetHex(obj["blockHash"].get()); + pp.blockHash = + ParsePayoutBlockHash(obj["blockHash"].get()); if (obj.contains("blockHeight") && obj["blockHeight"].is_number()) pp.blockHeight = obj["blockHeight"].get(); // Upstream's tolerance for an entry with no usable height, kept @@ -976,7 +1000,8 @@ static void LoadPaying() r.amount = obj["amount"].get(); r.matureAtHeight = obj["matureAtHeight"].get(); if (obj.contains("blockHash") && obj["blockHash"].is_string()) - r.blockHash.SetHex(obj["blockHash"].get()); + r.blockHash = + ParsePayoutBlockHash(obj["blockHash"].get()); if (obj.contains("blockHeight") && obj["blockHeight"].is_number()) r.blockHeight = obj["blockHeight"].get(); if (obj.contains("retries") && obj["retries"].is_number()) @@ -4681,6 +4706,78 @@ int RunPayoutLedgerSelfTest() strPoolRoundsFile = savedRoundsFile; } + // -- 16. A malformed block hash is corruption, not a hash ---------------- + // + // SetHex() parses whatever hex prefix it finds and stops, so a corrupt + // blockHash did not fail to load -- it loaded as a DIFFERENT hash. + // "deadbeef..." became 0xdeadbeef, a hash no block ever had, which the + // orphan check reads as an orphaned block and the next flush VOIDS the + // debt; a value with no hex prefix became 0, which is grandfathered past + // the orphan check instead. Either way a corrupt file silently changed + // what the pool owed. It is a load failure now, handled like every other + // parse error: quarantine, and never rewrite. + { + PayoutTestResetState(); + remove(ledgerPath.c_str()); + remove(payingPath.c_str()); + + const std::string badLedger = strprintf( + "[{\"amounts\":{\"%s\":100000000}," + "\"blockHash\":\"deadbeef oops, not a hash\"," + "\"blockHeight\":10,\"matureAtHeight\":10}]", addrA.c_str()); + check(PayoutTestWriteFile(ledgerPath, badLedger), + "wrote a ledger whose blockHash is not a 64-digit hex string"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.empty(), + "no entry is loaded with a guessed hash"); + } + std::string gone; + check(!PayoutTestReadFile(ledgerPath, gone), + "the ledger with the corrupt hash is quarantined"); + + const std::string badPaying = strprintf( + "[{\"address\":\"%s\",\"amount\":100000000," + "\"blockHash\":\"00000000cafe\",\"blockHeight\":10," + "\"matureAtHeight\":10}]", addrA.c_str()); + check(PayoutTestWriteFile(payingPath, badPaying), + "wrote an in-flight list whose blockHash is truncated"); + LoadPaying(); + { + std::lock_guard lk(gPayoutExecMutex); + check(gPaying.empty(), + "no in-flight record is loaded with a guessed hash"); + } + check(!PayoutTestReadFile(payingPath, gone), + "the in-flight list with the corrupt hash is quarantined"); + + // The two spellings every real file uses still load: the 64-digit + // hash of a keyed round, and the 64 zeros this build writes for a + // grandfathered entry. + PayoutTestResetState(); + remove(ledgerPath.c_str()); + const std::string goodLedger = strprintf( + "[{\"amounts\":{\"%s\":100000000}," + "\"blockHash\":\"00000000000000000000000000000000000000000000000000000000000000cd\"," + "\"blockHeight\":10,\"matureAtHeight\":10}," + "{\"amounts\":{\"%s\":200000000}," + "\"blockHash\":\"%s\"," + "\"blockHeight\":20,\"matureAtHeight\":20}]", + addrA.c_str(), addrB.c_str(), uint256(0).ToString().c_str()); + check(PayoutTestWriteFile(ledgerPath, goodLedger), + "wrote a ledger in both real hash spellings"); + LoadPendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + check(gPendingPayouts.size() == 2 && + gPendingPayouts[0].blockHash != 0 && + gPendingPayouts[1].blockHash == 0, + "a real hash and a serialised zero hash both still load"); + } + remove(ledgerPath.c_str()); + } + // -- clean up ------------------------------------------------------------ PayoutTestResetState(); CRITICAL_BLOCK(cs_mapWallet) -- cgit v1.2.3 From 74c068829967bbff60da3506613844df4da1ae80 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 14:14:48 +0000 Subject: mine: ask whether a transaction fits before appending it The node miner tested its size budget only on the outer while loop. The inner for walked all of mapTransactions with no test, so a single pass could append the entire mempool -- the overshoot was bounded by mempool size, not by one transaction as the comment claimed. The pool builder did test, but only after the push, which bounds the overshoot at one transaction. Nothing caps how big one transaction may be: neither CheckTransaction nor AcceptTransaction limits it, so the mempool will hold one of any size the wire allows and "one transaction" is worth up to MAX_SIZE. A lone 1.5 MB transaction offering a large fee is therefore enough to make every template unmineable, and the fee is never paid because the block never confirms. Both now ask before appending and skip what does not fit, so the overshoot is zero and an oversized transaction costs the template nothing rather than invalidating it. The budget moves to MAX_BLOCK_SIZE_GEN so the two assemblers cannot drift on it again -- drift is what produced this. The test is ordered before ConnectInputs on purpose: ConnectInputs credits nFees by reference on success, so declining a transaction after calling it would leave the coinbase claiming a fee the block does not collect. Assembly only. CheckBlock, CheckTransaction and AcceptTransaction are untouched: this changes what we produce, not what the network accepts. --- src/main.cpp | 41 ++++++++++++++++++++++++++++++++++++----- src/main.h | 20 ++++++++++++++++++++ src/rpc.cpp | 34 +++++++++++++++++++++++++--------- 3 files changed, 81 insertions(+), 14 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/main.cpp b/src/main.cpp index 7b3e555..64eb290 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4470,7 +4470,6 @@ bool BitcoinMiner(int nThreadId) map mapTestPool; vector vfAlreadyAdded(mapTransactions.size()); bool fFoundSomething = true; - unsigned int nBlockSize = 0; // Assembly must stop below the consensus cap that CheckBlock() // enforces, or we mine a block our own node then rejects. // @@ -4481,9 +4480,19 @@ bool BitcoinMiner(int nThreadId) // meant solving a block and throwing it away -- with this pool at // ~90% of hashrate, that is the network stalling, not just us. // - // Half the cap, keeping the original margin: the test runs before - // a transaction is added, so the loop can overshoot by one tx. - while (fFoundSomething && nBlockSize < MAX_BLOCK_SIZE/2) + // The budget test then sat only on this outer while, and the + // comment claimed that bounded the overshoot to a single + // transaction. It did not. The inner for walks all of + // mapTransactions with no test of its own, so one pass could append + // the entire mempool: the overshoot was bounded by mempool size, + // which is to say not bounded at all. Ask per transaction, before + // appending, and the overshoot is zero rather than small. + // + // Seeded with what the block already weighs -- header, tx count, + // coinbase -- because CheckBlock() weighs the whole serialized + // block and not just the transactions we chose to add to it. + unsigned int nBlockSize = ::GetSerializeSize(*pblock, SER_DISK); + while (fFoundSomething && nBlockSize < MAX_BLOCK_SIZE_GEN) { fFoundSomething = false; unsigned int n = 0; @@ -4495,6 +4504,28 @@ bool BitcoinMiner(int nThreadId) if (tx.IsCoinBase() || !tx.IsFinal()) continue; + // Skip, not break: a transaction too big for the room left + // says nothing about the next one, which may still fit. + // + // This is also the only ceiling on a single transaction's + // size anywhere on the path into a block. CheckTransaction + // does not cap it and neither does AcceptTransaction, so + // the mempool will hold one of any size the wire allows; + // without this test a lone 1.5 MB transaction offering a + // fat fee is selected, and every template built from that + // mempool is one CheckBlock() throws away. The fee is never + // paid, because the block never confirms. + // + // Deliberately before ConnectInputs and not after: + // ConnectInputs adds this transaction's fee to nFees by + // reference on success. Passing it and then declining the + // transaction would credit the coinbase for a fee no + // transaction in the block pays, and the block would be + // rejected for a wholly different reason. + unsigned int nTxSize = ::GetSerializeSize(tx, SER_NETWORK); + if (!BlockTemplateFits(nBlockSize, nTxSize)) + continue; + // Transaction fee requirements, mainly only needed for flood control // Under 10K (about 80 inputs) is free for first 100 transactions // Base rate is 0.01 per KB @@ -4506,7 +4537,7 @@ bool BitcoinMiner(int nThreadId) swap(mapTestPool, mapTestPoolTmp); pblock->vtx.push_back(tx); - nBlockSize += ::GetSerializeSize(tx, SER_NETWORK); + nBlockSize += nTxSize; vfAlreadyAdded[n] = true; fFoundSomething = true; } diff --git a/src/main.h b/src/main.h index 382dd9c..1f452b5 100644 --- a/src/main.h +++ b/src/main.h @@ -33,6 +33,26 @@ static const unsigned int MAX_BLOCK_SIGOPS = 20000; // limit (32 MB), not a block rule. Bitcoin 0.1.0 predated the later 1 MB block // cap, so this tree inherited no real block-size consensus limit. static const unsigned int MAX_BLOCK_SIZE = 1000000; +// How much of that cap a template we build may fill. Half, which is what both +// assemblers already aimed at -- this only gives the number a name and one +// place to live. They had already drifted on how it was enforced, which is the +// bug this exists to stop repeating. +// +// NOT a consensus rule. CheckBlock() decides what the network accepts; this +// decides what we are willing to produce, and it has to stay under that. +static const unsigned int MAX_BLOCK_SIZE_GEN = MAX_BLOCK_SIZE / 2; + +// Room for one more transaction in a template that currently weighs +// nBlockSize bytes? +// +// Asked BEFORE the transaction is appended, never after. A template that has +// already overshot is one CheckBlock() rejects, and asking afterwards only +// measures the waste. Both assemblers call this, so neither can drift into a +// different idea of what fits. +inline bool BlockTemplateFits(unsigned int nBlockSize, unsigned int nTxSize) +{ + return (nBlockSize + nTxSize <= MAX_BLOCK_SIZE_GEN); +} static const int64 COIN = 100000000; // Total Bitflash emission: identical to Bitcoin (21 million). // MAX_MONEY guards against the value overflow bug (CVE-2010-5139), which in diff --git a/src/rpc.cpp b/src/rpc.cpp index a4b53ca..7c5ef6c 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -267,16 +267,24 @@ static bool RebuildJob() map pool; vector vfAlreadyAdded(mapTransactions.size()); bool fFoundSomething = true; - unsigned int sz = 0; - // The cap is MAX_BLOCK_SIZE/2, not MAX_SIZE/2. MAX_SIZE is the - // 32 MB serialisation ceiling, so half of it is 16 MB -- this - // builder would happily assemble a template that its own + // The cap is MAX_BLOCK_SIZE_GEN, not MAX_SIZE/2. MAX_SIZE is + // the 32 MB serialisation ceiling, so half of it is 16 MB -- + // this builder would happily assemble a template that its own // CheckBlock() rejects at the 1 MB consensus cap. This is the // pool's job builder, so a block over the cap is not one // wasted solve, it is every miner on the pool working a - // template the network will reject. The inner break bounds the - // overshoot to a single transaction within a pass. - while (fFoundSomething && sz < MAX_BLOCK_SIZE / 2) + // template the network will reject. + // + // The inner break used to bound the overshoot to one + // transaction per pass. One transaction is still too many: + // nothing caps how big a single transaction may be, so that + // bound was worth up to MAX_SIZE. Asking whether each one fits + // before appending it bounds the overshoot at zero instead. + // + // Seeded with the header, tx count and coinbase, because + // CheckBlock() weighs the whole serialized block. + unsigned int sz = ::GetSerializeSize(block, SER_DISK); + while (fFoundSomething && sz < MAX_BLOCK_SIZE_GEN) { fFoundSomething = false; unsigned int n = 0; @@ -287,16 +295,24 @@ static bool RebuildJob() continue; CTransaction& tx = (*mi).second; if (tx.IsCoinBase() || !tx.IsFinal()) continue; + // Skip rather than stop: one transaction too big for + // the room left does not mean the next is. Ordered + // before ConnectInputs because ConnectInputs credits + // nFees by reference on success, and a transaction we + // then decline would leave the coinbase claiming a fee + // this block does not collect. + unsigned int nTxSize = ::GetSerializeSize(tx, SER_NETWORK); + if (!BlockTemplateFits(sz, nTxSize)) + continue; map tmp(pool); if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, false, true, tx.GetMinFee(block.vtx.size() < 100))) continue; swap(pool, tmp); block.vtx.push_back(tx); - sz += ::GetSerializeSize(tx, SER_NETWORK); + sz += nTxSize; vfAlreadyAdded[n] = true; fFoundSomething = true; - if (sz > MAX_BLOCK_SIZE / 2) break; } } } -- cgit v1.2.3 From 03a69ccfb36861948e11b0871bb9af8f0b280709 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 14:14:52 +0000 Subject: payout: never collapse a log line that reports money moving LogPrint()'s burst collapse is right for share and peer chatter and wrong for anything that reports an irreversible change to money. A reorg voided 100 ledger entries totalling 4,950 BTF and the log carried one line, naming one block and 49.49 BTF. The other 99 were counted and never printed, and no "(N similar)" summary appeared either -- a summary was only ever flushed when that same call site fired again, and after the reorg it never did. The missing 4,950 BTF was found by counting entries in pending_payouts.json. Add LogPrintAlways(), which skips the collapse entirely, and move the four payout lines that report money to it: an obligation voided, an obligation dropped, a payment sent, a payment that failed. All four are bursty by nature -- one reorg voids every entry funded by the orphaned block, from a single source line -- which is precisely why one line must not stand in for all of them. Their volume is bounded by how much money moved, which is a number worth the lines. Also sweep expired windows for every call site rather than only the one currently logging, so a burst that stops is still summarised, and report the window's real elapsed time instead of a hardcoded 3s. --- src/rpc.cpp | 10 +++--- src/util.cpp | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------ src/util.h | 22 +++++++++++--- 3 files changed, 114 insertions(+), 17 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index a4b53ca..2e8f4b8 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1732,7 +1732,9 @@ static void FlushMaturePayouts() } int64 total = 0; for (auto& kv : it->amounts) total += kv.second; - LogPrint("payout", "[payout] block %d (%s) was orphaned -- voiding %zu" + // Always: one line per voided entry, never a collapsed summary. + // A reorg voids these in a burst from this one source line. + LogPrintAlways("payout", "[payout] block %d (%s) was orphaned -- voiding %zu" " payment(s) totalling %s\n", it->blockHeight, it->blockHash.ToString().substr(0, 14).c_str(), it->amounts.size(), FormatMoney(total).c_str()); @@ -2036,7 +2038,7 @@ static void PayoutThreadFn(void*) // funding block can land at any point in that window. Upstream has // no equivalent -- it decides once, when the entry matures. if (!PayoutBlockStillOnChain(r.blockHash)) { - LogPrint("payout", "[payout] block %d (%s) orphaned while queued" + LogPrintAlways("payout", "[payout] block %d (%s) orphaned while queued" " -- dropping %s to %s\n", r.blockHeight, r.blockHash.ToString().substr(0, 14).c_str(), FormatMoney(r.amount).c_str(), r.address.c_str()); @@ -2205,7 +2207,7 @@ static void PayoutThreadFn(void*) // described by at least one of them at every instant. RecordPayoutTxid(r, wtx.GetHash().GetHex()); ForgetPayment(r); - LogPrint("payout", "[payout] paid %s to %s (tx %s)\n", + LogPrintAlways("payout", "[payout] paid %s to %s (tx %s)\n", FormatMoney(r.amount).c_str(), r.address.c_str(), wtx.GetHash().ToString().substr(0, 12).c_str()); } else { @@ -2263,7 +2265,7 @@ static void PayoutThreadFn(void*) // going away must not turn the log into one line a minute // forever, and must not go quiet either. if (r.retries <= 5 || r.retries % 60 == 0) - LogPrint("payout", "[payout] FAILED to pay %s to %s" + LogPrintAlways("payout", "[payout] FAILED to pay %s to %s" " (%s spendable, wallet unlocked, block %d on" " the main chain) -- attempt %d, retrying in" " 60s; this debt stands until it is paid\n", diff --git a/src/util.cpp b/src/util.cpp index 0cd0133..b5b145f 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -47,6 +47,61 @@ struct LogDedupState { static std::map g_logDedup; static CCriticalSection cs_logDedup; +// A pending "(N similar)" summary, lifted out of the table while the lock is +// held and written after it is released. +struct LogDedupFlush { + std::string category; + int suppressed; + int64 elapsed; // how long the window that counted them ran + std::string lastMsg; +}; + +static int64 g_logDedupLastSweep = 0; + +static void EmitDedupSummary(const LogDedupFlush& f) +{ + OutputDebugStringF("[%s] (%d similar in %llds, last: %s)\n", + f.category.c_str(), f.suppressed, + (long long)f.elapsed, f.lastMsg.c_str()); +} + +// A window is otherwise only closed out when its own call site fires again, so +// a burst that simply stops -- 100 ledger entries voided in one pass and then +// silence -- leaves its count sitting in the table and the summary is never +// written at all. That is how 99 of 100 voids went unreported, with no "(N +// similar)" line to say anything was missing (issue #3). Sweep every expired +// window instead, so a finished burst is summarised as soon as anything at all +// is logged. +// +// Expired entries are erased rather than reopened: the window is over, so there +// is no state left worth keeping, and the next call from that site then takes +// the "first time" path and prints directly -- exactly what the rollover branch +// below would have done. It also stops the table growing without bound. +// +// Caller holds cs_logDedup; the summaries are written after it is released. +static void SweepExpiredWindowsLocked(int64 now, std::vector& out) +{ + for (std::map::iterator it = g_logDedup.begin(); + it != g_logDedup.end(); ) + { + if (now - it->second.windowStart < DEDUP_WINDOW_SECS) + { + ++it; + continue; + } + if (it->second.suppressed > 0) + { + LogDedupFlush f; + f.category = it->first.substr(0, it->first.find('|')); + f.suppressed = it->second.suppressed; + f.elapsed = now - it->second.windowStart; + f.lastMsg = it->second.lastMsg; + out.push_back(f); + } + g_logDedup.erase(it++); + } +} + void LogPrintDedup(const char* category, const char* file, int line, const std::string& msgIn) { // Call sites follow the existing printf() convention of ending their @@ -61,11 +116,19 @@ void LogPrintDedup(const char* category, const char* file, int line, const std:: int64 now = GetTime(); bool fEmitNow = false; - int suppressedToFlush = 0; - std::string flushedMsg; + std::vector flushes; CRITICAL_BLOCK(cs_logDedup) { + // At most once a second. The table holds one entry per call site that + // has fired in the last few seconds, so the walk is short, but it must + // not run once per line of a burst. + if (now != g_logDedupLastSweep) + { + g_logDedupLastSweep = now; + SweepExpiredWindowsLocked(now, flushes); + } + std::map::iterator it = g_logDedup.find(key); if (it == g_logDedup.end()) { @@ -76,10 +139,18 @@ void LogPrintDedup(const char* category, const char* file, int line, const std:: } else if (now - it->second.windowStart >= DEDUP_WINDOW_SECS) { - // Window rolled over: flush what was suppressed during it (if - // anything), then start a new window with this call printed. - suppressedToFlush = it->second.suppressed; - flushedMsg = it->second.lastMsg; + // Window rolled over within the same second as the last sweep: + // flush what was suppressed during it (if anything), then start a + // new window with this call printed. + if (it->second.suppressed > 0) + { + LogDedupFlush f; + f.category = category; + f.suppressed = it->second.suppressed; + f.elapsed = now - it->second.windowStart; + f.lastMsg = it->second.lastMsg; + flushes.push_back(f); + } it->second.windowStart = now; it->second.suppressed = 0; it->second.lastMsg = msg; @@ -94,13 +165,23 @@ void LogPrintDedup(const char* category, const char* file, int line, const std:: } } - if (suppressedToFlush > 0) - OutputDebugStringF("[%s] (%d similar in %llds, last: %s)\n", - category, suppressedToFlush, (long long)DEDUP_WINDOW_SECS, flushedMsg.c_str()); + for (size_t i = 0; i < flushes.size(); i++) + EmitDedupSummary(flushes[i]); if (fEmitNow) OutputDebugStringF("[%s] %s\n", category, msg.c_str()); } +// LogPrintAlways() -- see the doc comment in util.h. No window, no counting, +// no table entry: every call writes its own line. +void LogPrintNoCollapse(const char* category, const std::string& msgIn) +{ + std::string msg = msgIn; + while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) + msg.pop_back(); + + OutputDebugStringF("[%s] %s\n", category, msg.c_str()); +} + diff --git a/src/util.h b/src/util.h index d72518b..fe605a1 100644 --- a/src/util.h +++ b/src/util.h @@ -106,15 +106,29 @@ extern bool fDebug; // 2. Burst collapse: repeated LogPrint() calls from the *same source line* // only emit one line per DEDUP_WINDOW_SECS; if more calls land inside // that window they're counted instead of reprinted, and the count is -// flushed as a single "(xN similar in Ws)" line the moment the window -// rolls over. A loop discovering 500 peers by address now produces a -// small, bounded number of lines instead of 500 -- log growth is capped -// by elapsed time, not by how many similar events happen to fire. +// flushed as a single "(xN similar in Ws)" line once the window expires +// -- whether or not that call site ever fires again. A loop discovering +// 500 peers by address now produces a small, bounded number of lines +// instead of 500 -- log growth is capped by elapsed time, not by how +// many similar events happen to fire. +// +// Use LogPrintAlways() instead for anything that reports an irreversible +// change to money: an obligation voided, a payment sent, a payment that +// failed. One line standing in for an unknown number of identical events is +// a tidy report of peer chatter and a false report of a ledger. A reorg +// voiding 100 entries hits one source line 100 times inside one window; +// under LogPrint() that read as a single block losing a single 49.49 BTF +// payment, while 4,950 BTF of obligations were actually destroyed, and the +// discrepancy was only found by counting entries in pending_payouts.json +// (issue #3). Bounded log growth is not worth that ambiguity here. // --------------------------------------------------------------------------- bool LogAcceptsCategory(const char* category); void LogPrintDedup(const char* category, const char* file, int line, const std::string& msg); +void LogPrintNoCollapse(const char* category, const std::string& msg); #define LogPrint(category, ...) \ do { if (LogAcceptsCategory(category)) LogPrintDedup((category), __FILE__, __LINE__, strprintf(__VA_ARGS__)); } while (0) +#define LogPrintAlways(category, ...) \ + do { if (LogAcceptsCategory(category)) LogPrintNoCollapse((category), strprintf(__VA_ARGS__)); } while (0) // Every format string in this project goes through one of the four functions // below -- `printf` itself is remapped to OutputDebugStringF just above, and -- cgit v1.2.3 From 2ed8fae6088c99f1a5dfcfd760c1a24c2b2706db Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 14:16:48 +0000 Subject: mine: count sigops while selecting, not only when validating CheckBlock() caps a block at MAX_BLOCK_SIGOPS and neither assembler counted against it. Bytes do not bound what a block costs to verify: every OP_CHECKSIG is an elliptic-curve verification, so a template comfortably inside the size budget can still be one the network refuses for demanding more work than a block is allowed to. Counted with CTransaction::GetSigOpCount -- the same function CheckBlock() sums, not a second implementation of it -- and seeded from the coinbase, because CheckBlock() sums over all of vtx. The comparison matches too: CheckBlock() fails on `> MAX_BLOCK_SIGOPS`, so a block exactly on the limit is valid and selection accepts it. Assembly and validation differing by one is the shape of the bug this series started with. Unlike the size budget this takes no margin. There is no use for headroom in a sigop count, so assembly holds itself to the consensus number exactly. Assembly only; MAX_BLOCK_SIGOPS and the CheckBlock() test are unchanged. --- src/main.cpp | 19 ++++++++++++++++--- src/main.h | 15 ++++++++++++--- src/rpc.cpp | 13 ++++++++++--- 3 files changed, 38 insertions(+), 9 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/main.cpp b/src/main.cpp index 64eb290..4d8031d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4492,6 +4492,17 @@ bool BitcoinMiner(int nThreadId) // coinbase -- because CheckBlock() weighs the whole serialized // block and not just the transactions we chose to add to it. unsigned int nBlockSize = ::GetSerializeSize(*pblock, SER_DISK); + // Sigops are a budget too, and CheckBlock() enforces one that no + // assembler here counted against. Bytes do not bound validation + // cost: every OP_CHECKSIG is an elliptic-curve verification, so a + // template well inside the size limit can still be refused for + // demanding more work than a block is allowed to. Counted with + // CTransaction::GetSigOpCount, the function CheckBlock() itself + // sums, so the two cannot disagree about what a transaction costs. + // + // Seeded from the coinbase because CheckBlock() sums over all of + // vtx, coinbase included. + unsigned int nBlockSigOps = pblock->vtx[0].GetSigOpCount(); while (fFoundSomething && nBlockSize < MAX_BLOCK_SIZE_GEN) { fFoundSomething = false; @@ -4522,8 +4533,9 @@ bool BitcoinMiner(int nThreadId) // transaction would credit the coinbase for a fee no // transaction in the block pays, and the block would be // rejected for a wholly different reason. - unsigned int nTxSize = ::GetSerializeSize(tx, SER_NETWORK); - if (!BlockTemplateFits(nBlockSize, nTxSize)) + unsigned int nTxSize = ::GetSerializeSize(tx, SER_NETWORK); + unsigned int nTxSigOps = tx.GetSigOpCount(); + if (!BlockTemplateFits(nBlockSize, nBlockSigOps, nTxSize, nTxSigOps)) continue; // Transaction fee requirements, mainly only needed for flood control @@ -4537,7 +4549,8 @@ bool BitcoinMiner(int nThreadId) swap(mapTestPool, mapTestPoolTmp); pblock->vtx.push_back(tx); - nBlockSize += nTxSize; + nBlockSize += nTxSize; + nBlockSigOps += nTxSigOps; vfAlreadyAdded[n] = true; fFoundSomething = true; } diff --git a/src/main.h b/src/main.h index 1f452b5..30e25ef 100644 --- a/src/main.h +++ b/src/main.h @@ -43,15 +43,24 @@ static const unsigned int MAX_BLOCK_SIZE = 1000000; static const unsigned int MAX_BLOCK_SIZE_GEN = MAX_BLOCK_SIZE / 2; // Room for one more transaction in a template that currently weighs -// nBlockSize bytes? +// nBlockSize bytes and spends nBlockSigOps signature operations? // // Asked BEFORE the transaction is appended, never after. A template that has // already overshot is one CheckBlock() rejects, and asking afterwards only // measures the waste. Both assemblers call this, so neither can drift into a // different idea of what fits. -inline bool BlockTemplateFits(unsigned int nBlockSize, unsigned int nTxSize) +// +// Size gets a margin (half the cap); sigops do not. There is no cheap way to +// spend the sigop budget usefully and no reason to reserve headroom in it, so +// assembly holds itself to the same number CheckBlock() enforces -- and to the +// same comparison. CheckBlock() fails on `> MAX_BLOCK_SIGOPS`, so a block +// sitting exactly on the limit is valid and this has to accept it too. +// Assembly and validation disagreeing by one is how the size bug started. +inline bool BlockTemplateFits(unsigned int nBlockSize, unsigned int nBlockSigOps, + unsigned int nTxSize, unsigned int nTxSigOps) { - return (nBlockSize + nTxSize <= MAX_BLOCK_SIZE_GEN); + return (nBlockSize + nTxSize <= MAX_BLOCK_SIZE_GEN && + nBlockSigOps + nTxSigOps <= MAX_BLOCK_SIGOPS); } static const int64 COIN = 100000000; // Total Bitflash emission: identical to Bitcoin (21 million). diff --git a/src/rpc.cpp b/src/rpc.cpp index 7c5ef6c..62eec7a 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -284,6 +284,11 @@ static bool RebuildJob() // Seeded with the header, tx count and coinbase, because // CheckBlock() weighs the whole serialized block. unsigned int sz = ::GetSerializeSize(block, SER_DISK); + // And the sigop budget, which CheckBlock() enforces and this + // builder never counted against. Same counting function + // CheckBlock() sums, seeded from the coinbase because it sums + // over all of vtx. + unsigned int nSigOps = block.vtx[0].GetSigOpCount(); while (fFoundSomething && sz < MAX_BLOCK_SIZE_GEN) { fFoundSomething = false; @@ -301,8 +306,9 @@ static bool RebuildJob() // nFees by reference on success, and a transaction we // then decline would leave the coinbase claiming a fee // this block does not collect. - unsigned int nTxSize = ::GetSerializeSize(tx, SER_NETWORK); - if (!BlockTemplateFits(sz, nTxSize)) + unsigned int nTxSize = ::GetSerializeSize(tx, SER_NETWORK); + unsigned int nTxSigOps = tx.GetSigOpCount(); + if (!BlockTemplateFits(sz, nSigOps, nTxSize, nTxSigOps)) continue; map tmp(pool); if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, @@ -310,7 +316,8 @@ static bool RebuildJob() continue; swap(pool, tmp); block.vtx.push_back(tx); - sz += nTxSize; + sz += nTxSize; + nSigOps += nTxSigOps; vfAlreadyAdded[n] = true; fFoundSomething = true; } -- cgit v1.2.3 From 9a409d8ba97251f29ba80913a4014b8a369c52c0 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 14:46:38 +0000 Subject: pool: advertise a rendezvous only once registered, and log the pin Issue #2 reported that /rvrelay does not govern registration. It does: BtfAllRelays() returns vBtfMeetingRelays alone when fBtfRelayPinned, and every relay-choosing site -- the pool's AcceptLoopFn, the node's ThreadBtfAccept, and the dial path -- draws from it. vBtfMeetingRelays has exactly one writer, the pin itself. A pinned pool therefore spawns one accept thread, at the pin, and no discovered relay is registered at. The registration the incident actually caught was not the pool's. The "registered at" line it quotes exists only in ThreadBtfAccept, and 90.156.222.107:8434 is not a stranger's relay -- it is the third hardcoded seed, one of the three ThreadBtfAccept picks between at random when no pin is in force. That relay is only reachable with the pin absent, so the binary that logged it did not have one; both the pin and the operator-mode skip that keeps a pool out of that thread landed two days before the observation. What was genuinely wrong is that none of this could be read off a log. The pool announced no successful registration at all, and retried failures at one flat line, so "is this pool registered where its descriptor says" had no answer. So: - Advertise the relay after registering at it, not before. The optimistic order published a meeting node we were not yet listed at, and a miner resolving the descriptor inside that window spent its dial on a relay holding no registration for us. ThreadBtfAccept has always ordered it this way; the pool had not. - Log the relay registration succeeds at, name the pin when one is in force, and name each discovered relay the pin excludes. - Escalate when a pinned relay keeps refusing: a pin has no fallback, so an unregistered pin means the pool is unreachable, and that now says so instead of scrolling past. - Refuse to advertise a non-pinned relay in BtfSetActiveRelay. No caller can reach it today, but the descriptor is the one thing a miner obeys, so the invariant is enforced at the sink rather than retrusted to every future caller. Does not touch the registration-consumption race: the pool still gives up its registration on each pairing and re-registers after wrap-and-push. Moving the advertise call inside that loop does not widen the window, it only stops the descriptor naming a relay we never reached. --- src/nostr.cpp | 25 ++++++++++++++++++++++++ src/nostr.h | 3 +++ src/rpc.cpp | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 80 insertions(+), 9 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/nostr.cpp b/src/nostr.cpp index e2d839c..0717e12 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -151,6 +151,19 @@ string BtfActiveRelay() void BtfSetActiveRelay(const string& relay) { + // With /rvrelay set, the descriptor must never name anything but the pin. + // No caller can reach here with another relay today -- both registration + // paths draw their list from BtfAllRelays(), which returns the pin alone -- + // but the descriptor is the one thing a miner obeys, so the invariant is + // enforced at the sink instead of being retrusted to every future caller. + if (fBtfRelayPinned && + find(vBtfMeetingRelays.begin(), vBtfMeetingRelays.end(), relay) == vBtfMeetingRelays.end()) + { + LogPrint("pool", "refusing to advertise meeting relay %s: /rvrelay pins %s\n", + relay.c_str(), + vBtfMeetingRelays.empty() ? "an empty list" : vBtfMeetingRelays[0].c_str()); + return; + } CRITICAL_BLOCK(cs_activeRelay) strBtfActiveRelay = relay; } @@ -223,6 +236,18 @@ vector BtfAllRelays() return all; } +// Relays learned from Nostr, whether or not a pin is in force. Reporting only: +// it exists so the pool can name what /rvrelay is excluding. Anything choosing +// a relay to register or dial at must keep using BtfAllRelays(). +vector BtfDiscoveredRelays() +{ + vector out; + CRITICAL_BLOCK(cs_discoveredRelays) + foreach(const string& r, g_discoveredRelays) + out.push_back(r); + return out; +} + // // Utilities diff --git a/src/nostr.h b/src/nostr.h index 6dbbe26..0dd919d 100644 --- a/src/nostr.h +++ b/src/nostr.h @@ -60,6 +60,9 @@ extern bool fBtfRelayPinned; bool LooksLikeRelay(const std::string& r); // Every relay to try: curated seeds + relays discovered from Nostr announcements. std::vector BtfAllRelays(); +// Relays discovered from Nostr, ignoring the pin. For reporting what a pin +// excludes -- never for choosing a relay to register or dial at. +std::vector BtfDiscoveredRelays(); struct BtfPoolAnnouncement { diff --git a/src/rpc.cpp b/src/rpc.cpp index a4b53ca..a3560ff 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -4947,13 +4947,19 @@ static void AcceptOneFn(void* arg) std::string host = relay.substr(0, colon); int port = atoi(relay.substr(colon + 1).c_str()); - LogPrint("pool", "[pool] accept thread started for relay %s\n", relay.c_str()); + LogPrint("pool", "[pool] accept thread started for relay %s%s\n", relay.c_str(), + fBtfRelayPinned ? " (/rvrelay pin)" : ""); + + // Registration health for this relay. A pin has no fallback, so a pin that + // never registers means the pool is simply unreachable -- and that has to + // be loud. The 2026-07-30 incident went unnoticed because this loop logged + // failures at one flat line and success not at all, so "which relay is this + // pool actually registered at" could not be answered from the log. + int nConsecFail = 0; + int64 nFirstFail = 0; + bool fEverRegistered = false; while (!fShutdown && gPoolRunning) { - // Advertise this relay as the pool's meeting node BEFORE registering, - // so that miners which resolve our descriptor find a relay where the - // pool (not the node's peer-accept) holds the registration. - BtfSetActiveRelay(relay); // Returns as soon as the relay lists us -- registered, not yet paired. btf::RvSocket rv = btf::RvServiceRegister(host.c_str(), (unsigned short)port, pk); @@ -4962,12 +4968,36 @@ static void AcceptOneFn(void* arg) break; } if (rv == btf::RV_INVALID) { - LogPrint("pool", "[pool] relay %s: RvServiceRegister failed" - " -- backing off 30s before retry\n", relay.c_str()); + nConsecFail++; + if (nFirstFail == 0) nFirstFail = GetTime(); + LogPrint("pool", "[pool] relay %s: registration failed (attempt %d," + " failing for %llds) -- backing off 30s before retry\n", + relay.c_str(), nConsecFail, (long long)(GetTime() - nFirstFail)); + if (fBtfRelayPinned && (nConsecFail == 3 || nConsecFail % 10 == 0)) + LogPrint("pool", "[pool] WARNING: pinned relay %s refused registration" + " %d times running (%llds); /rvrelay leaves no fallback, so" + " miners cannot reach this pool%s\n", + relay.c_str(), nConsecFail, (long long)(GetTime() - nFirstFail), + fEverRegistered ? "" : " -- it has never registered once"); for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000); continue; } + // Advertise THIS relay only now. Setting it before the call published a + // meeting node we were not yet listed at, so a miner resolving the + // descriptor inside that window burned its dial on a relay holding no + // registration for us. net.cpp's peer-accept has always ordered it this + // way round; the pool had not. + BtfSetActiveRelay(relay); + if (!fEverRegistered || nConsecFail > 0) + LogPrint("pool", "[pool] registered at relay %s%s%s -- advertising it as" + " the pool's meeting node\n", relay.c_str(), + fBtfRelayPinned ? " (/rvrelay pin)" : "", + nConsecFail > 0 ? ", recovered" : ""); + fEverRegistered = true; + nConsecFail = 0; + nFirstFail = 0; + // Block for a miner to actually arrive. RvServiceRegister used to do // this itself and returned with the relay's 0x01 pairing byte already // consumed; it no longer does. Without waiting here, BtfServiceWrap @@ -5022,9 +5052,21 @@ static void AcceptLoopFn(void*) LogPrint("pool", "[pool] .btf identity ready after %ds\n", waitSecs * 2); std::set spawned; // relays we've already started a thread for + std::set skipped; // discovered relays the pin excluded, logged once each while (!fShutdown && gPoolRunning) { + // BtfAllRelays() already honours the pin: with /rvrelay set it returns + // vBtfMeetingRelays alone, so no thread is ever spawned for a + // discovered relay. Name the ones it drops -- a pool registering at a + // stranger's relay is exactly the thing that should not be silent. vector relays = BtfAllRelays(); + if (fBtfRelayPinned) { + for (const string& r : BtfDiscoveredRelays()) + if (std::find(relays.begin(), relays.end(), r) == relays.end() && + skipped.insert(r).second) + LogPrint("pool", "[pool] not registering at discovered relay %s" + " -- /rvrelay pins the relay list\n", r.c_str()); + } if (relays.empty()) { LogPrint("pool", "[pool] no relays available -- miners cannot connect yet\n"); } else { @@ -5040,8 +5082,9 @@ static void AcceptLoopFn(void*) relay.c_str()); } else { spawned.insert(relay); - LogPrint("pool", "[pool] accept thread spawned for relay %s" - " (%zu total)\n", relay.c_str(), spawned.size()); + LogPrint("pool", "[pool] accept thread spawned for relay %s%s" + " (%zu total)\n", relay.c_str(), + fBtfRelayPinned ? " (/rvrelay pin)" : "", spawned.size()); } } } -- cgit v1.2.3 From 07051b2378734d62fb94a4a62819d8150452b661 Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Wed, 2 Sep 2026 15:23:02 +0000 Subject: pool: compute a miner's faded hashrate instead of decaying it in place 2bca62a decayed m->hashRate from the event loop's miner sweep, which turned a figure that is only ever displayed into an accumulator, and got the curve wrong at both ends. Each pass recomputed `expected` -- and so `grace` -- from the estimate it had just decayed. Every decay raised `expected`, which raised the grace that gates the next decay, so `grace` chased `silent` upward and the reported rate settled into a 1/t crawl instead of the half-life the commit's own comment documents: 291x too high at ten half-lives past the grace, and still announcing 0.4% of baseline four hours into a silence. #7 is about the pool over-reporting its hashrate, and this under-corrected it for hours. The far end was worse. Once `expected` reached HASHRATE_MAX_INTERVAL it could not rise any further, the feedback that had been gating each pass was gone, and the decay did begin to compound -- once a second, against the total elapsed silence, because lastShareTime does not advance while a miner is quiet and nothing recorded when the decay had last run. A miner four hours silent fell from 0.4% of its baseline to 1e-11 of it over the next hundred seconds, with nothing in the code or the announced number to explain the cliff. Neither behaviour is worth tuning, so drop the sweep. hashRate is read in exactly one place -- RefreshStats, building the per-worker rows and the total that the pool status and the nostr announcement publish -- and vardiff retargets from share timing alone and never reads it. So leave the stored value as the sample it always was and have RefreshStats ask MinerReportedHashRate() what to publish: a pure function of that sample and the absolute silence since the last share. With no stored decay state there is nothing for a tick rate, a missed pass or a repeated pass to perturb, and the answer is bit-identical from 1/60 Hz to 1000 Hz because nothing accumulates, not because the arithmetic was written carefully. It also keeps the fade out of the estimator: the EWMA on the share path still blends against the value it last computed rather than a faded copy of it, so a miner that resumes recovers exactly as it did before any of this, and there is no decay timestamp for a newly accepted or newly authorised connection to be caught holding unset. RefreshStats takes one GetTime() for the whole sweep, so the rows and the total they are summed into describe a single instant rather than a smear across it. The clamp is written !(expected > MIN) so a NaN lands on the floor instead of sailing through; a negative `silent` from a clock stepping backwards reports the sample unchanged rather than scaling it up; and past HASHRATE_DECAY_CUTOFF half-lives the answer is a flat zero rather than pow() walking down into the denormals. This also drops the std::max(m->hashRate, 1e-9) the old pass carried, which the `hashRate <= 0.0` guard immediately above it had already made dead. pool-stratum gains ten checks over the policy: the full estimate inside the grace, a thousand samples at one instant returning one value and leaving the estimate untouched, a few percent lost one second past the grace rather than the whole estimate, exactly half at one expected interval past it, zero after an hour of silence, no inflation from a backwards clock, zero for a miner that has no rate sample yet, and nothing non-finite anywhere across a long silence. --- src/rpc.cpp | 194 +++++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 160 insertions(+), 34 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index df7ca05..9d98a4d 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -2488,21 +2488,26 @@ static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by thi // that stops hashing keeps its last estimate forever and RefreshStats sums // it into the announced pool total for as long as the connection sits open // -- the published pool hashrate ran several times over ground truth -// because of exactly this. The decay pass in PoolEventLoop applies this +// because of exactly this. MinerReportedHashRate() below applies the // policy: a miner gets a grace of HASHRATE_IDLE_MISSES expected share -// intervals (expected interval = expected hashes per share at its current -// difficulty / its last rate estimate) with no share, then its estimate -// decays with a half-life of one more such interval, computed from -// absolute time so the result does not depend on how often the loop ticks. -// The interval is clamped to [HASHRATE_MIN_INTERVAL, HASHRATE_MAX_INTERVAL] -// so a fast miner at the vardiff floor (expected interval of milliseconds) -// is not punished for ordinary share-delivery jitter, and a very slow -// miner's grace does not stretch past the point where its estimate means -// anything. At the vardiff target of one share per 15s, a stopped miner's -// announced rate is gone within minutes. +// intervals (expected interval = expected hashes per share at its +// difficulty, divided by the estimate itself) with no share, after which +// the figure we report falls off with a half-life of one more such +// interval. The interval is clamped to [HASHRATE_MIN_INTERVAL, +// HASHRATE_MAX_INTERVAL] so a fast miner at the vardiff floor (expected +// interval of milliseconds) is not punished for ordinary share-delivery +// jitter, and a very slow miner's grace does not stretch past the point +// where its estimate means anything. At the vardiff target of one share +// per 15s that is a minute of grace, half the rate reported 75s in, and +// nothing reported after about seventeen minutes of silence. static const double HASHRATE_IDLE_MISSES = 4.0; // grace, in expected share intervals static const double HASHRATE_MIN_INTERVAL = 1.0; // seconds; floor for the expected interval static const double HASHRATE_MAX_INTERVAL = 3600.0; // seconds; ceiling for the expected interval +// Half-lives past the grace beyond which we report a flat zero. 2^-64 of the +// original estimate is not a number anyone needs, and stopping here keeps +// pow() from walking down into the denormals for a miner that has been quiet +// for hours. +static const double HASHRATE_DECAY_CUTOFF = 64.0; struct Miner { btf_socket_t fd; @@ -2546,6 +2551,65 @@ struct Miner { extranonce2(gNextExtranonce2.fetch_add(1)) {} }; +// What we publish as a miner's H/s: the last EWMA sample, faded out if shares +// have stopped arriving (#7, policy at HASHRATE_IDLE_MISSES). +// +// This reads m->hashRate and never writes it. That is the whole design. The +// first attempt decayed the stored estimate in place from the event loop's +// sweep, which put a running accumulator behind a figure that is only ever +// displayed, and the accumulator misbehaved in two ways: +// +// - `expected` was recomputed from the already-decayed estimate on every +// pass, so each decay raised `expected`, which raised `grace`, which is +// the very thing gating the next decay. `grace` simply chased `silent` +// upward and the reported rate settled into a 1/t crawl instead of the +// half-life curve the comment above promises -- 291x too high at ten +// half-lives past the grace. Issue #7 is about the pool over-reporting; +// that under-corrected it for hours. +// - Once `expected` reached HASHRATE_MAX_INTERVAL it could not rise any +// further, the feedback that had been gating each pass was gone, and the +// decay began compounding once a second against the total elapsed +// silence -- taking a miner from 0.4% of its baseline to 1e-11 of it in +// the next hundred seconds, four hours in, for no reason a reader of +// either the code or the announced number could see. +// +// Computing the value instead of accumulating it removes the failure mode +// rather than tuning it: with no stored decay state there is nothing for a +// tick rate, a missed pass or a duplicated pass to perturb, and the answer +// depends only on how long the miner has actually been quiet. It also keeps +// the fade out of the estimator -- the EWMA on the share path still blends +// against the value it last computed, not against a faded copy of it, so a +// miner that resumes recovers on exactly the curve it would have without any +// of this. +// +// `now` is a parameter rather than a GetTime() call so that one refresh fades +// every miner to the same instant, and so the policy is directly testable. +static double MinerReportedHashRate(const Miner* m, int64 now) +{ + // Nothing sampled yet: a fresh connection, or one that has found a single + // share and so has no interval to have been timed over. Nothing to fade, + // and nothing to divide by below. + if (m->hashRate <= 0.0 || m->lastShareTime <= 0) return m->hashRate; + + double expected = ExpectedHashesForDifficulty(m->difficulty) / m->hashRate; + // Written so that a NaN would land on the floor rather than sail through: + // the clamp is what guarantees `expected` is finite and strictly positive + // below, whatever the division produced. + if (!(expected > HASHRATE_MIN_INTERVAL)) expected = HASHRATE_MIN_INTERVAL; + if (expected > HASHRATE_MAX_INTERVAL) expected = HASHRATE_MAX_INTERVAL; + + double silent = (double)(now - m->lastShareTime); + double grace = HASHRATE_IDLE_MISSES * expected; + // grace is at least HASHRATE_IDLE_MISSES seconds, so a clock that has + // stepped backwards gives a negative `silent`, takes this branch, and + // reports the sample unchanged. Nothing here can scale an estimate up. + if (!(silent > grace)) return m->hashRate; + + double halflives = (silent - grace) / expected; + if (halflives >= HASHRATE_DECAY_CUTOFF) return 0.0; + return m->hashRate * pow(0.5, halflives); +} + static std::vector gMiners; struct FoundBlock { int height; int64 ts; std::string addr; }; @@ -4672,6 +4736,82 @@ int RunPoolStratumSelfTest() strPoolRoundsFile = savedRoundsFile; gPoolRounds = savedRounds; + // -- hashrate decay (#7) -------------------------------------------------- + // The first version of this decayed m->hashRate in place from the event + // loop's sweep, which made the reported figure a function of how the loop + // happened to run rather than of how long the miner had been quiet. These + // pin the properties that fixes: sampling changes nothing, and the answer + // depends only on elapsed silence. + { + // A miner exactly on the vardiff target: 4096 hashes per share at + // difficulty 1.0, one share per 15s, so 4096/15 H/s. Its expected + // interval is 15s and its grace is 60s. + const double base = 4096.0 / 15.0; + const int64 t0 = 1766000000; // any plausible non-zero wall clock + Miner decayed(INVALID_SOCKET); + decayed.difficulty = 1.0; + decayed.hashRate = base; + decayed.lastShareTime = t0; + + check(MinerReportedHashRate(&decayed, t0 + 60) == base, + "hashrate decay: a miner inside its grace period reports its full estimate"); + + // Sampling must not consume the estimate. Twenty passes is one second + // of the event loop's old sweep; a thousand is nearly a minute of it. + double once = MinerReportedHashRate(&decayed, t0 + 61); + bool idempotent = (decayed.hashRate == base); + for (int i = 0; i < 1000 && idempotent; i++) + idempotent = (MinerReportedHashRate(&decayed, t0 + 61) == once) && + (decayed.hashRate == base); + check(idempotent, + "hashrate decay: repeated sampling returns one value and leaves the estimate alone"); + + // One second past a 60s grace is 1/15th of a half-life: ~4.5% off, not + // the estimate gone. A miner at the vardiff target overshoots its grace + // roughly 1.8% of the time (e^-4) purely by share-timing variance, and + // must not be zeroed for it. + check(once > 0.94 * base && once < 0.96 * base, + "hashrate decay: one second past grace costs a few percent, not the estimate"); + double three = MinerReportedHashRate(&decayed, t0 + 63); + check(three > 0.86 * base && three < 0.88 * base, + "hashrate decay: three seconds past grace costs ~13%"); + + // One half-life past the grace is exactly half. + double half = MinerReportedHashRate(&decayed, t0 + 75); + check(half > 0.499 * base && half < 0.501 * base, + "hashrate decay: one expected interval past grace halves the estimate"); + + // A miner that has genuinely stopped ends up reported as nothing -- + // the whole point of #7. The old in-place version could not get here: + // its decay fed back into its own grace and settled into a 1/t crawl. + check(MinerReportedHashRate(&decayed, t0 + 3600) == 0.0, + "hashrate decay: a miner silent for an hour is reported as zero"); + + // A clock that steps backwards must never inflate what we announce. + check(MinerReportedHashRate(&decayed, t0 - 3600) == base, + "hashrate decay: a backwards clock reports the estimate unchanged, never higher"); + + // Fresh connection, and a connection with one share and so no interval + // to have timed: both must report nothing rather than divide by zero. + Miner fresh(INVALID_SOCKET); + check(MinerReportedHashRate(&fresh, t0) == 0.0, + "hashrate decay: a miner that never submitted a share reports zero"); + Miner single(INVALID_SOCKET); + single.lastShareTime = t0; + check(MinerReportedHashRate(&single, t0 + 86400) == 0.0, + "hashrate decay: a miner with no rate sample yet reports zero"); + + // Nothing in the swept range may produce a NaN, an infinity, a negative + // rate, or a rate above the estimate it started from. + bool finite = true; + for (int64 s = -100; s < 200000 && finite; s += 37) { + double v = MinerReportedHashRate(&decayed, t0 + s); + finite = !std::isnan(v) && !std::isinf(v) && v >= 0.0 && v <= base; + } + check(finite, + "hashrate decay: stays finite, non-negative and non-amplifying across a long silence"); + } + gCurrentJob = savedJob; gHaveJob = savedHaveJob; @@ -4699,6 +4839,10 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) if (m->authorised) gStats.authorizedMiners++; gWorkerStats.clear(); + // One timestamp for the whole sweep (#7): every miner is faded to the + // same instant, so the per-worker rows and the total they are summed + // into describe one moment rather than a smear across the loop. + int64 nowStats = GetTime(); for (Miner* m : gMiners) { if (!m->authorised || m->lastSeen <= 0) continue; PoolWorkerStatView v; @@ -4707,9 +4851,11 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) v.roundShares = m->roundShares; v.totalShares = m->totalShares; v.lastSeen = m->lastSeen; - v.hashRate = m->hashRate; + // Not m->hashRate: a miner that has stopped submitting shares must + // not keep contributing its last sample to the announced total. + v.hashRate = MinerReportedHashRate(m, nowStats); gWorkerStats.push_back(v); - gStats.totalHashRate += m->hashRate; + gStats.totalHashRate += v.hashRate; } std::sort(gWorkerStats.begin(), gWorkerStats.end(), [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { @@ -4942,27 +5088,7 @@ static void PoolEventLoop() } } - // 3.6. Hashrate decay (#7) -- see the policy comment at - // HASHRATE_IDLE_MISSES. The EWMA is only re-sampled on share arrival, - // so without this pass a miner that stops hashing keeps its last - // estimate forever and the summed pool hashrate RefreshStats (and - // the nostr announcement built from it) publishes is fiction. - for (Miner* m : gMiners) { - if (m->hashRate <= 0.0 || m->lastShareTime <= 0) continue; - double expected = ExpectedHashesForDifficulty(m->difficulty) / - std::max(m->hashRate, 1e-9); - if (expected < HASHRATE_MIN_INTERVAL) expected = HASHRATE_MIN_INTERVAL; - if (expected > HASHRATE_MAX_INTERVAL) expected = HASHRATE_MAX_INTERVAL; - double silent = (double)(GetTime() - m->lastShareTime); - double grace = HASHRATE_IDLE_MISSES * expected; - if (silent <= grace) continue; - // Half-life of one expected interval past the grace period. - // Written off absolute time, not per-tick, so the decay curve is - // the same however often this loop runs. - m->hashRate *= pow(0.5, (silent - grace) / expected); - } - - // 3.7. Idle reap (#6). Mirror the clean-disconnect path exactly -- + // 3.6. Idle reap (#6). Mirror the clean-disconnect path exactly -- // log, close, erase from gMiners via std::find, delete -- just with // a different reason. Shares the miner already earned stay in // roundShareCount, exactly as they do when a miner cleanly -- cgit v1.2.3 From b441687b5344454bea16384fe1280e470964faaa Mon Sep 17 00:00:00 2001 From: bitflash-maintainer Date: Thu, 3 Sep 2026 18:31:32 +0000 Subject: payout: move the last two burst-capable money lines to LogPrintAlways Integration review found two more payout lines still on plain LogPrint, both fired per record inside the batch loop in PayoutThreadFn -- the same one-window burst shape as the four already converted. The "writing off" line in ForfeitPayoutRecipient and the "SKIPPED bad address" line at the write-off in PayoutThreadFn both report obligations being destroyed, which is the class of line one summary must never stand in for (issue #3). Message text is unchanged. --- src/rpc.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src/rpc.cpp') diff --git a/src/rpc.cpp b/src/rpc.cpp index 2e8f4b8..6531fe8 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1933,7 +1933,7 @@ static void RecordPayoutTxid(const PayingRecord& r, const std::string& txid) // disagreeing. static void ForfeitPayoutRecipient(const PayingRecord& r, const std::string& reason) { - LogPrint("payout", "[payout] writing off %s owed to %s for block %d: %s\n", + LogPrintAlways("payout", "[payout] writing off %s owed to %s for block %d: %s\n", FormatMoney(r.amount).c_str(), r.address.c_str(), r.blockHeight, reason.c_str()); VersionedBlob blob; @@ -2082,7 +2082,7 @@ static void PayoutThreadFn(void*) const std::string reason = strprintf("'%s' is not a valid address, so no transaction" " can pay it", r.address.c_str()); - LogPrint("payout", "[payout] SKIPPED bad address '%s' --" + LogPrintAlways("payout", "[payout] SKIPPED bad address '%s' --" " %s owed for block %d cannot be paid\n", r.address.c_str(), FormatMoney(r.amount).c_str(), r.blockHeight); -- cgit v1.2.3
StatusPoolFeeMinersH/sBlocksHeightAgeLink
StatusPoolFeeMinersH/sBlocksHeightAgeLinkProof