upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/rpc.cpp
diff options
context:
space:
mode:
authorBitflash <btf01@proton.me>2026-07-28 12:55:04 -0300
committerGitHub <noreply@github.com>2026-07-28 12:55:04 -0300
commit663784eae9b04ec71e3e1eebf445910e5a8add42 (patch)
tree1482b0778a11c1b46cfbd476a5d91e12ef31204b /src/rpc.cpp
parent1d0314c0d220231afc56193ba66b70234fa24092 (diff)
Merge PR #6 (large refactor), preserving main's fixes (#9)
* 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 <relay>' 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 <nasci.victorio@gmail.com> Co-authored-by: Bitflash Pool Dev <pool@bitflash> Co-authored-by: Victorio Nascimento <155357371+Vic-Nas@users.noreply.github.com> Co-authored-by: Claude <claude@anthropic.com>
Diffstat (limited to 'src/rpc.cpp')
-rw-r--r--src/rpc.cpp1544
1 files changed, 1074 insertions, 470 deletions
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 @@
1// Copyright (c) 2009 Satoshi Nakamoto / Copyright (c) 2026 Bitflash developers 1// Copyright (c) 2009 Satoshi Nakamoto / Copyright (c) 2026 Bitflash developers
2// Distributed under the MIT/X11 software license. 2// Distributed under the MIT/X11 software license.
3// 3//
4// Bitflash pool server — .btf-only pool listener. 4// Bitflash pool server -- .btf Stratum pool, operator side.
5// Miners connect through rendezvous tunnels; no raw IP:port endpoints remain.
6// Pending payouts are held until maturity and shown in the GUI.
7// 5//
8// Compiles on both Windows (mingw) and Linux unchanged. 6// Architecture:
9// Launched from ui.cpp (GUI) and headless.cpp (node) via ThreadRPCServer(). 7// PoolEventLoop -- single thread, owns all miner fds, all job/share/payout
8// state. No locks needed on any of that data.
9// AcceptOneFn -- one thread per relay. Blocks on RvServiceRegister +
10// BtfServiceWrap, pushes finished socket to gReadyQueue.
11// AcceptLoopFn -- spawns AcceptOneFn threads, re-polls relay list every
12// 60s so dynamically discovered relays get picked up.
13//
14// GUI-visible state is snapshotted under gStatsMutex every 2s.
15// Accept queue handoff uses gReadyMutex (held for microseconds).
10 16
11// nlohmann must be included before headers.h because util.h redefines snprintf
12#pragma push_macro("snprintf") 17#pragma push_macro("snprintf")
13#undef snprintf 18#undef snprintf
14#include <nlohmann/json.hpp> 19#include <nlohmann/json.hpp>
@@ -20,8 +25,6 @@
20#endif 25#endif
21#include "btftunnel.h" 26#include "btftunnel.h"
22 27
23// POSIX socket headers — already provided by compat.h on Windows,
24// and by the OS directly on Linux.
25#ifndef _WIN32 28#ifndef _WIN32
26#include <sys/socket.h> 29#include <sys/socket.h>
27#include <sys/select.h> 30#include <sys/select.h>
@@ -33,14 +36,15 @@
33#include <atomic> 36#include <atomic>
34#include <mutex> 37#include <mutex>
35#include <map> 38#include <map>
39#include <set>
36#include <deque> 40#include <deque>
41#include <vector>
42#include <algorithm>
37#include <sstream> 43#include <sstream>
38#include <iomanip> 44#include <iomanip>
39#include <ctime>
40 45
41using json = nlohmann::json; 46using json = nlohmann::json;
42 47
43// Cross-platform socket close / send flags
44#ifdef _WIN32 48#ifdef _WIN32
45#define sock_close(s) closesocket(s) 49#define sock_close(s) closesocket(s)
46#define SEND_FLAGS 0 50#define SEND_FLAGS 0
@@ -49,660 +53,1260 @@ using json = nlohmann::json;
49#define SEND_FLAGS MSG_NOSIGNAL 53#define SEND_FLAGS MSG_NOSIGNAL
50#endif 54#endif
51 55
52// Cross-platform thread launch: wraps _beginthread (compat.h maps it to
53// pthread_create+detach on Linux, native _beginthread on Windows).
54// fn must be void(*)(void*).
55static void LaunchThread(void (*fn)(void*), void* arg)
56{
57 _beginthread(fn, 0, arg);
58}
59
60// ---------------------------------------------------------------------------
61// Config — set by headless.cpp / ui.cpp before threads launch
62// ---------------------------------------------------------------------------
63std::string gRpcUser;
64std::string gRpcPassword;
65
66// Pool server running flag — set false to stop server cleanly (all loops check it)
67volatile bool gPoolRunning = false; 56volatile bool gPoolRunning = false;
68 57
69// --------------------------------------------------------------------------- 58// ---------------------------------------------------------------------------
70// Stubs for symbols absent from Bitflash 0.1.0
71// ---------------------------------------------------------------------------
72double dHashesPerSec = 0.0;
73int64 nHPSTimerStart = 0;
74
75static bool IsInitialBlockDownload()
76{
77 return (pindexBest == NULL || nBestHeight < 1);
78}
79
80// ---------------------------------------------------------------------------
81// Hex helpers 59// Hex helpers
82// --------------------------------------------------------------------------- 60// ---------------------------------------------------------------------------
83static std::string ToHex(const void* p, size_t n) 61static std::string ToHex(const void* p, size_t n)
84{ 62{
85 const unsigned char* b = (const unsigned char*)p; 63 const unsigned char* b = (const unsigned char*)p;
86 std::string s; s.reserve(n*2); 64 std::string s; s.reserve(n * 2);
87 static const char* H = "0123456789abcdef"; 65 static const char* H = "0123456789abcdef";
88 for (size_t i = 0; i < n; i++) { s += H[b[i]>>4]; s += H[b[i]&0xf]; } 66 for (size_t i = 0; i < n; i++) { s += H[b[i] >> 4]; s += H[b[i] & 0xf]; }
89 return s; 67 return s;
90} 68}
69
91static std::vector<unsigned char> FromHex(const std::string& s) 70static std::vector<unsigned char> FromHex(const std::string& s)
92{ 71{
93 std::vector<unsigned char> v; 72 std::vector<unsigned char> v;
94 for (size_t i = 0; i+1 < s.size(); i += 2) { 73 for (size_t i = 0; i + 1 < s.size(); i += 2) {
95 auto h=[](char c)->int{ 74 auto h = [](char c) -> int {
96 if(c>='0'&&c<='9') return c-'0'; 75 if (c >= '0' && c <= '9') return c - '0';
97 if(c>='a'&&c<='f') return c-'a'+10; 76 if (c >= 'a' && c <= 'f') return c - 'a' + 10;
98 if(c>='A'&&c<='F') return c-'A'+10; 77 if (c >= 'A' && c <= 'F') return c - 'A' + 10;
99 return 0; 78 return 0;
100 }; 79 };
101 v.push_back((unsigned char)((h(s[i])<<4)|h(s[i+1]))); 80 v.push_back((unsigned char)((h(s[i]) << 4) | h(s[i + 1])));
102 } 81 }
103 return v; 82 return v;
104} 83}
105 84
106// --------------------------------------------------------------------------- 85// ---------------------------------------------------------------------------
107// PPLNS share tracking 86// Job -- written and read exclusively by the event loop thread
108// ---------------------------------------------------------------------------
109struct Share {
110 std::string minerAddress;
111 int64 timestamp;
112 uint64 difficulty;
113};
114
115struct MinerStats {
116 std::string address;
117 std::string worker;
118 uint64 totalShares = 0;
119 uint64 roundShares = 0;
120 int64 lastSeen = 0;
121};
122static std::mutex gMinerStatsMutex;
123static std::map<std::string, MinerStats> gMinerStats;
124
125struct FoundBlock {
126 int height;
127 int64 timestamp;
128 std::string minerAddress;
129 uint256 hash;
130};
131static std::mutex gBlocksMutex;
132static std::deque<FoundBlock> gFoundBlocks;
133
134struct Miner;
135static std::mutex gMinersMutex;
136static std::vector<Miner*> gMiners;
137
138static std::string MinerKey(const std::string& address, const std::string& worker)
139{
140 return address + "\n" + worker;
141}
142
143void GetPoolWorkerStats(std::vector<PoolWorkerStatView>& out)
144{
145 out.clear();
146 int64 now = GetTime();
147 std::lock_guard<std::mutex> lk(gMinerStatsMutex);
148 for (std::map<std::string, MinerStats>::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it)
149 {
150 const MinerStats& ms = it->second;
151 if (ms.lastSeen <= 0)
152 continue;
153 if (now - ms.lastSeen > 15 * 60)
154 continue;
155 PoolWorkerStatView view;
156 view.address = ms.address;
157 view.worker = ms.worker;
158 view.totalShares = ms.totalShares;
159 view.roundShares = ms.roundShares;
160 view.lastSeen = ms.lastSeen;
161 out.push_back(view);
162 }
163 std::sort(out.begin(), out.end(), [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) {
164 if (a.roundShares != b.roundShares) return a.roundShares > b.roundShares;
165 if (a.totalShares != b.totalShares) return a.totalShares > b.totalShares;
166 return a.lastSeen > b.lastSeen;
167 });
168}
169
170// ---------------------------------------------------------------------------
171// Current mining job
172// --------------------------------------------------------------------------- 87// ---------------------------------------------------------------------------
173struct StratumJob { 88struct StratumJob {
174 std::string jobId; 89 std::string jobId;
175 CBlock block; 90 CBlock block;
176 uint256 target; 91 uint256 target; // full block target; share targets are per-miner via vardiff
177 uint256 shareTarget;
178 int height; 92 int height;
179}; 93};
94static StratumJob gCurrentJob;
95static bool gHaveJob = false;
96
97// Compute a share target from a Stratum difficulty value.
98// Stratum difficulty 1 must correspond to THIS chain's easiest allowed target
99// (bnProofOfWorkLimit = ~uint256(0) >> 12, compact 0x1f0fffff), not Bitcoin's
100// SHA256 diff-1 target (0x1d00ffff). Those differ by about 2^20 (~1e6x):
101// Bitcoin's diff-1 needs ~2^32 hashes/share on average, while this chain's PoW
102// floor needs only ~2^12. Using the Bitcoin constant made every miner's
103// "difficulty 1" about a million times harder than the chain's own floor, so a
104// worker could hash for minutes without ever finding a share -- and vardiff
105// never gets the samples it needs to correct course, because no share ever
106// comes in for it to measure. Base off the actual chain limit instead so
107// difficulty 1 is genuinely easy and vardiff has real data to retarget from.
108// Higher difficulty = smaller (harder) target.
109static uint256 ShareTargetFromDifficulty(double difficulty)
110{
111 if (difficulty <= 0.0) difficulty = 1.0;
112 static const uint256 diff1 = CBigNum(bnProofOfWorkLimit).getuint256();
113 // shareTarget = diff1 / difficulty
114 CBigNum bn; bn.setuint256(diff1);
115 // Multiply by 2^16 to keep precision, divide, shift back
116 bn <<= 16;
117 bn /= (uint64)(difficulty * 65536.0);
118 uint256 t = bn.getuint256();
119 // Cap at all-ones (maximum, easiest possible)
120 if (t < diff1 && difficulty < 1.0) t = ~uint256(0);
121 return t;
122}
180 123
181static std::mutex gJobMutex; 124// Expected number of hashes to find one share at a given Stratum difficulty,
182static StratumJob gCurrentJob; 125// on this chain's scale (difficulty 1 == bnProofOfWorkLimit, which needs on
183static bool gHaveJob = false; 126// average 2^12 = 4096 hashes). Used to turn "shares per second" into H/s.
184static std::atomic<uint32_t> gJobSeq{0}; 127static const double SHARE_HASHES_AT_DIFF_1 = 4096.0;
185 128static double ExpectedHashesForDifficulty(double difficulty)
186static uint256 MakeShareTarget(const uint256& blockTarget)
187{ 129{
188 uint256 shareTarget = blockTarget; 130 if (difficulty <= 0.0) difficulty = 1.0;
189 shareTarget <<= 8; 131 return difficulty * SHARE_HASHES_AT_DIFF_1;
190 if (shareTarget < blockTarget)
191 shareTarget = ~uint256(0);
192 return shareTarget;
193} 132}
194 133
134// Rebuild the current job from the best chain tip.
135// Must be called from the event loop thread (or startup, before the loop).
136// Returns true on success. Logs the specific failure reason on false.
195static bool RebuildJob() 137static bool RebuildJob()
196{ 138{
197 if (!pindexBest) return false; 139 CRITICAL_BLOCK(cs_main)
140 {
141 if (!pindexBest) {
142 LogPrint("pool", "RebuildJob: no chain tip (pindexBest is null)\n");
143 return false;
144 }
198 145
199 CBlockIndex* pindexPrev = pindexBest; 146 CBlockIndex* pindexPrev = pindexBest;
200 unsigned int nBits = GetNextWorkRequired(pindexPrev); 147 unsigned int nBits = GetNextWorkRequired(pindexPrev);
201 148
202 CKey key; 149 CKey key; key.MakeNewKey();
203 key.MakeNewKey(); 150 static std::atomic<uint32_t> sExtra{0};
204 static std::atomic<uint32_t> sExtra{0}; 151 uint32_t extraNonce = ++sExtra;
205 uint32_t extraNonce = ++sExtra;
206 152
207 CTransaction txNew; 153 CTransaction txNew;
208 txNew.vin.resize(1); 154 txNew.vin.resize(1);
209 txNew.vin[0].prevout.SetNull(); 155 txNew.vin[0].prevout.SetNull();
210 txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce; 156 txNew.vin[0].scriptSig << nBits << (CBigNum)extraNonce;
211 txNew.vout.resize(1); 157 txNew.vout.resize(1);
212 // Always pay coinbase to a fresh wallet key. DoPayouts() distributes 158 txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG;
213 // shares to miners via SendMoney() after the block is accepted.
214 txNew.vout[0].scriptPubKey << key.GetPubKey() << OP_CHECKSIG;
215 159
216 CBlock block; 160 CBlock block;
217 block.vtx.push_back(txNew); 161 block.vtx.push_back(txNew);
218 162
219 {
220 CTxDB txdb("r");
221 CRITICAL_BLOCK(cs_mapTransactions)
222 { 163 {
223 map<uint256,CTxIndex> pool; 164 CTxDB txdb("r");
224 unsigned int sz = 0; 165 CRITICAL_BLOCK(cs_mapTransactions)
225 for (auto& kv : mapTransactions) { 166 {
226 CTransaction& tx = kv.second; 167 map<uint256, CTxIndex> pool;
227 if (tx.IsCoinBase() || !tx.IsFinal()) continue; 168 unsigned int sz = 0;
228 int64 nFees = 0; 169 for (auto& kv : mapTransactions) {
229 int64 nMinFee = tx.GetMinFee(block.vtx.size() < 100); 170 CTransaction& tx = kv.second;
230 map<uint256,CTxIndex> tmp(pool); 171 if (tx.IsCoinBase() || !tx.IsFinal()) continue;
231 if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, false, true, nMinFee)) continue; 172 int64 nFees = 0;
232 pool = tmp; 173 map<uint256, CTxIndex> tmp(pool);
233 block.vtx.push_back(tx); 174 if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees,
234 sz += ::GetSerializeSize(tx, SER_NETWORK); 175 false, true, tx.GetMinFee(block.vtx.size() < 100)))
235 if (sz > MAX_SIZE/2) break; 176 continue;
177 pool = tmp;
178 block.vtx.push_back(tx);
179 sz += ::GetSerializeSize(tx, SER_NETWORK);
180 if (sz > MAX_SIZE / 2) break;
181 }
236 } 182 }
237 } 183 }
184
185 block.vtx[0].vout[0].nValue = block.GetBlockValue(0);
186 block.hashPrevBlock = pindexPrev->GetBlockHash();
187 block.hashMerkleRoot = block.BuildMerkleTree();
188 block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast() + 1),
189 (unsigned int)GetAdjustedTime());
190 block.nBits = nBits;
191 block.nNonce = 1;
192 AddKey(key);
193
194 StratumJob job;
195 job.jobId = ToHex(&extraNonce, 4);
196 job.block = block;
197 job.target = CBigNum().SetCompact(nBits).getuint256();
198 job.height = nBestHeight + 1;
199
200 gCurrentJob = job;
201 gHaveJob = true;
202 return true;
238 } 203 }
239 block.vtx[0].vout[0].nValue = block.GetBlockValue(0); 204 return false;
240 block.hashPrevBlock = pindexPrev->GetBlockHash();
241 block.hashMerkleRoot = block.BuildMerkleTree();
242 block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast()+1),
243 (unsigned int)GetAdjustedTime());
244 block.nBits = nBits;
245 block.nNonce = 1;
246
247 AddKey(key);
248
249 StratumJob job;
250 job.jobId = ToHex(&extraNonce, 4);
251 job.block = block;
252 job.target = CBigNum().SetCompact(nBits).getuint256();
253 job.shareTarget = MakeShareTarget(job.target);
254 job.height = nBestHeight + 1;
255
256 std::lock_guard<std::mutex> lk(gJobMutex);
257 gCurrentJob = job;
258 gHaveJob = true;
259 ++gJobSeq;
260 return true;
261} 205}
262 206
263// --------------------------------------------------------------------------- 207// ---------------------------------------------------------------------------
264// PPLNS payout 208// Payouts -- event loop thread only
265// ---------------------------------------------------------------------------
266// ---------------------------------------------------------------------------
267// Deferred payouts — coinbase outputs require COINBASE_MATURITY (100) blocks
268// before they can be spent. We store the share snapshot when a block is found
269// and execute the actual SendMoney() calls once the coinbase matures.
270// --------------------------------------------------------------------------- 209// ---------------------------------------------------------------------------
271struct PendingPayout { 210struct PendingPayout {
272 int matureAtHeight; // pay out when nBestHeight >= this 211 int matureAtHeight;
273 std::map<std::string, int64> amounts; // addr -> BTF amount 212 std::map<std::string, int64> amounts; // address -> satoshis
274}; 213};
214static std::vector<PendingPayout> gPendingPayouts;
275 215
276static std::mutex gPayoutMutex; 216static std::string PayoutFilePath()
277static std::vector<PendingPayout> gPendingPayouts; 217{
218 return GetAppDir() + "/pending_payouts.json";
219}
278 220
279void GetPendingPayouts(std::vector<PendingPayoutView>& out) 221static void SavePendingPayouts()
280{ 222{
281 out.clear(); 223 nlohmann::json arr = nlohmann::json::array();
282 std::lock_guard<std::mutex> lk(gPayoutMutex); 224 for (const auto& pp : gPendingPayouts) {
283 for (const PendingPayout& p : gPendingPayouts) 225 nlohmann::json obj;
284 { 226 obj["matureAtHeight"] = pp.matureAtHeight;
285 PendingPayoutView view; 227 nlohmann::json amounts = nlohmann::json::object();
286 view.matureAtHeight = p.matureAtHeight; 228 for (const auto& kv : pp.amounts)
287 view.recipients = (int)p.amounts.size(); 229 amounts[kv.first] = kv.second;
288 view.totalAmount = 0; 230 obj["amounts"] = amounts;
289 for (const std::map<std::string, int64>::value_type& kv : p.amounts) 231 arr.push_back(obj);
290 view.totalAmount += kv.second; 232 }
291 out.push_back(view); 233 std::string path = PayoutFilePath();
234 FILE* f = fopen(path.c_str(), "w");
235 if (f) {
236 std::string s = arr.dump(2);
237 fwrite(s.c_str(), 1, s.size(), f);
238 fclose(f);
239 } else {
240 LogPrint("payout", "[payout] WARNING: could not write %s\n", path.c_str());
292 } 241 }
293} 242}
294 243
295// Called when a block is found: snapshot shares, schedule payout for maturity. 244static void LoadPendingPayouts()
296static void QueuePayouts(int blockHeight)
297{ 245{
298 if (nMineMode != MINE_OPERATOR) return; 246 std::string path = PayoutFilePath();
299 247 FILE* f = fopen(path.c_str(), "r");
300 std::map<std::string, uint64> shareCount; 248 if (!f) return; // no file yet -- first run, nothing to load
301 uint64 total = 0; 249 fseek(f, 0, SEEK_END);
302 { 250 long sz = ftell(f);
303 std::lock_guard<std::mutex> lk(gMinerStatsMutex); 251 fseek(f, 0, SEEK_SET);
304 for (std::map<std::string, MinerStats>::iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) 252 std::string s(sz, '\0');
305 { 253 fread(&s[0], 1, sz, f);
306 MinerStats& ms = it->second; 254 fclose(f);
307 if (ms.roundShares == 0) 255 try {
308 continue; 256 nlohmann::json arr = nlohmann::json::parse(s);
309 if (ms.address.empty()) 257 for (const auto& obj : arr) {
310 continue; 258 PendingPayout pp;
311 shareCount[ms.address] += ms.roundShares; 259 pp.matureAtHeight = obj["matureAtHeight"].get<int>();
312 total += ms.roundShares; 260 for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it)
313 ms.roundShares = 0; 261 pp.amounts[it.key()] = it.value().get<int64>();
262 gPendingPayouts.push_back(pp);
314 } 263 }
264 LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n",
265 gPendingPayouts.size());
266 } catch (const std::exception& e) {
267 LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n",
268 path.c_str(), e.what());
269 }
270}
271
272// Payouts ready to execute are moved here under gPayoutExecMutex.
273// A dedicated thread drains this list so SendMoney (which takes cs_main)
274// never runs on the event loop thread and can't stall miner I/O.
275static std::vector<PendingPayout> gPayoutExecQueue;
276static std::mutex gPayoutExecMutex;
277static std::atomic<bool> gPayoutThreadRunning{false};
278
279static void QueuePayouts(int blockHeight,
280 const std::map<std::string, uint64>& shareCount,
281 uint64 total)
282{
283 if (total == 0) {
284 LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n",
285 blockHeight);
286 return;
315 } 287 }
316 if (total == 0) return;
317 288
318 int64 blockReward = CBlock().GetBlockValue(0); 289 double feeFrac = dPoolFeePercent / 100.0;
290 if (feeFrac < 0.0) feeFrac = 0.0;
291 if (feeFrac > 1.0) feeFrac = 1.0;
292
293 // Compute the block subsidy at the height that was just found.
294 // We do not include tx fees in miner payout -- those stay in the operator
295 // wallet as part of the coinbase, which is the operator's cut on top of feeFrac.
296 int64 nSubsidy = 50 * COIN;
297 nSubsidy >>= (blockHeight / 210000); // halving schedule
298 int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac));
299
319 PendingPayout pp; 300 PendingPayout pp;
320 pp.matureAtHeight = blockHeight + COINBASE_MATURITY; 301 pp.matureAtHeight = blockHeight + COINBASE_MATURITY;
302
303 int skipped = 0;
304 int64 burned = 0; // amount owed to miners with bad/empty addresses
321 for (auto& kv : shareCount) { 305 for (auto& kv : shareCount) {
322 int64 amount = (int64)(((double)kv.second / (double)total) * (double)blockReward); 306 int64 amount = (int64)(((double)kv.second / (double)total) * (double)minerReward);
323 if (amount >= CENT) 307 if (amount < CENT) {
324 pp.amounts[kv.first] = amount; 308 skipped++;
309 continue;
310 }
311 uint160 addrCheck;
312 if (kv.first.empty() || !AddressToHash160(kv.first, addrCheck)) {
313 // Address was invalid at authorize time (warned then). Their share
314 // of the reward cannot be paid -- log the amount so it's visible.
315 burned += amount;
316 LogPrint("payout", "[payout] WARNING: %s owed to miner with invalid"
317 " address '%s' -- cannot pay out, amount forfeited\n",
318 FormatMoney(amount).c_str(), kv.first.c_str());
319 continue;
320 }
321 pp.amounts[kv.first] = amount;
325 } 322 }
326 323
327 { std::lock_guard<std::mutex> lk(gPayoutMutex); gPendingPayouts.push_back(pp); } 324 gPendingPayouts.push_back(pp);
328 325 SavePendingPayouts();
329 printf("Pool: block %d payout queued for height %d (%zu miners)\n", 326
330 blockHeight, pp.matureAtHeight, pp.amounts.size()); 327 int64 operatorCut = nSubsidy - minerReward;
328 LogPrint("payout",
329 "[payout] block %d: subsidy %s BTF, miners get %s"
330 " (fee %.2f%% = %s to operator),"
331 " %zu recipients, matures height %d,"
332 " %d below dust%s\n",
333 blockHeight,
334 FormatMoney(nSubsidy).c_str(),
335 FormatMoney(minerReward).c_str(),
336 dPoolFeePercent,
337 FormatMoney(operatorCut).c_str(),
338 pp.amounts.size(), pp.matureAtHeight, skipped,
339 burned > 0 ? (", " + FormatMoney(burned) + " forfeited (bad address)").c_str()
340 : "");
331} 341}
332 342
333// Called from JobWatcher every new block: execute any matured payouts. 343// Called from event loop: move matured entries to the exec queue (fast).
344// Save BEFORE moving to exec queue: if we crash between the save and SendMoney,
345// LoadPendingPayouts re-queues them from the exec-queue section of the file.
334static void FlushMaturePayouts() 346static void FlushMaturePayouts()
335{ 347{
336 if (nMineMode != MINE_OPERATOR) return; 348 std::vector<PendingPayout> ready;
337
338 std::lock_guard<std::mutex> lk(gPayoutMutex);
339 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { 349 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) {
340 if (nBestHeight < it->matureAtHeight) { ++it; continue; } 350 if (nBestHeight < it->matureAtHeight) { ++it; continue; }
341 351 LogPrint("payout", "[payout] block height %d matured -- queuing %zu payments\n",
342 printf("Pool: executing payout for block matured at height %d\n", it->matureAtHeight); 352 it->matureAtHeight, it->amounts.size());
343 for (auto& kv : it->amounts) { 353 ready.push_back(*it); // copy first; erase after save
344 uint160 hash160; 354 ++it;
345 if (!AddressToHash160(kv.first, hash160)) continue; 355 }
346 CScript scriptPubKey; 356 if (!ready.empty()) {
347 scriptPubKey << OP_DUP << OP_HASH160 << hash160 << OP_EQUALVERIFY << OP_CHECKSIG; 357 // Erase from pending then save before handing off to exec thread,
348 CWalletTx wtx; 358 // so the file never contains an entry that is already being sent.
349 wtx.mapValue["comment"] = strprintf("Pool payout height %d", it->matureAtHeight); 359 for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) {
350 if (SendMoney(scriptPubKey, kv.second, wtx)) 360 if (nBestHeight >= it->matureAtHeight)
351 printf("Pool: paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); 361 it = gPendingPayouts.erase(it);
352 else 362 else
353 printf("Pool: payout FAILED for %s (will not retry)\n", kv.first.c_str()); 363 ++it;
364 }
365 SavePendingPayouts();
366 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
367 for (auto& pp : ready)
368 gPayoutExecQueue.push_back(std::move(pp));
369 }
370}
371
372// Payout execution thread: drains gPayoutExecQueue, calls SendMoney.
373// Runs independently of the event loop so slow wallet operations
374// (SendMoney takes cs_main) never stall miner I/O.
375static void PayoutThreadFn(void*)
376{
377 gPayoutThreadRunning = true;
378 LogPrint("payout", "[payout] payout thread started\n");
379
380 while (!fShutdown && gPoolRunning) {
381 std::vector<PendingPayout> batch;
382 {
383 std::lock_guard<std::mutex> lk(gPayoutExecMutex);
384 batch.swap(gPayoutExecQueue);
385 }
386
387 for (auto& pp : batch) {
388 LogPrint("payout", "[payout] executing payments for block matured at %d"
389 " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size());
390 for (auto& kv : pp.amounts) {
391 uint160 h160;
392 if (!AddressToHash160(kv.first, h160)) {
393 // Should not happen -- bad addresses are filtered in QueuePayouts
394 LogPrint("payout", "[payout] SKIPPED bad address '%s'\n",
395 kv.first.c_str());
396 continue;
397 }
398 CScript sc;
399 sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG;
400 CWalletTx wtx;
401 wtx.mapValue["comment"] = strprintf("Pool payout height %d",
402 pp.matureAtHeight);
403 if (SendMoney(sc, kv.second, wtx))
404 LogPrint("payout", "[payout] paid %s to %s\n",
405 FormatMoney(kv.second).c_str(), kv.first.c_str());
406 else
407 LogPrint("payout", "[payout] FAILED to pay %s to %s"
408 " (insufficient funds or wallet locked?)\n",
409 FormatMoney(kv.second).c_str(), kv.first.c_str());
410 }
354 } 411 }
355 it = gPendingPayouts.erase(it); 412
413 Sleep(500); // check for new work every 500ms
356 } 414 }
415
416 gPayoutThreadRunning = false;
417 LogPrint("payout", "[payout] payout thread stopped\n");
357} 418}
358 419
359// --------------------------------------------------------------------------- 420// ---------------------------------------------------------------------------
360// Stratum protocol 421// GUI stats snapshot -- event loop writes, GUI threads read
361// --------------------------------------------------------------------------- 422// ---------------------------------------------------------------------------
362static json MakeNotifyParams(const StratumJob& job, bool clean) 423struct StatsSnapshot {
424 int authorizedMiners = 0;
425 int blocksFound = 0;
426 uint64 roundShares = 0;
427 double totalHashRate = 0.0;
428};
429static StatsSnapshot gStats;
430static std::vector<PoolWorkerStatView> gWorkerStats;
431static std::vector<PendingPayoutView> gPayoutViews;
432static std::mutex gStatsMutex;
433
434void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate)
363{ 435{
364 CBlock& b = const_cast<CBlock&>(job.block); 436 std::lock_guard<std::mutex> lk(gStatsMutex);
365 unsigned char hdr[80]; 437 authorizedMiners = gStats.authorizedMiners;
366 memcpy(hdr, &b.nVersion, 4); 438 blocksFound = gStats.blocksFound;
367 memcpy(hdr+4, &b.hashPrevBlock, 32); 439 roundShares = gStats.roundShares;
368 memcpy(hdr+36, &b.hashMerkleRoot, 32); 440 totalHashRate = gStats.totalHashRate;
369 memcpy(hdr+68, &b.nTime, 4); 441}
370 memcpy(hdr+72, &b.nBits, 4); 442
371 memcpy(hdr+76, &b.nNonce, 4); 443void GetPoolWorkerStats(std::vector<PoolWorkerStatView>& out)
372 unsigned char shareTgt[32]; memcpy(shareTgt, &job.shareTarget, 32); 444{
373 return json::array({job.jobId, ToHex(hdr,80), b.hashPrevBlock.GetHex(), ToHex(shareTgt,32), clean}); 445 std::lock_guard<std::mutex> lk(gStatsMutex);
446 out = gWorkerStats;
447}
448
449void GetPendingPayouts(std::vector<PendingPayoutView>& out)
450{
451 std::lock_guard<std::mutex> lk(gStatsMutex);
452 out = gPayoutViews;
374} 453}
375 454
455// ---------------------------------------------------------------------------
456// Miner state -- event loop thread only
457// ---------------------------------------------------------------------------
458static std::atomic<uint32_t> gNextExtranonce2{1};
459
460// Vardiff target: aim for one share every VARDIFF_TARGET_SECS seconds per miner.
461// After VARDIFF_SHARES_PER_CHECK shares, recalculate and adjust if rate is off
462// by more than 50%. Shares are the stable unit; difficulty floats to match.
463static const int64 VARDIFF_TARGET_SECS = 15; // one share every 15s
464static const uint64 VARDIFF_SHARES_PER_CHECK = 5; // re-evaluate every 5 shares
465static const double VARDIFF_RETARGET_RATIO = 1.5; // adjust if >50% off target
466static const double VARDIFF_MIN = 0.001;
467static const double VARDIFF_MAX = 1e12;
468// If a miner has gone this long since its vardiff window opened with ZERO
469// shares, the normal vardiff retarget (which only fires after
470// VARDIFF_SHARES_PER_CHECK shares) can never trigger -- it has no samples to
471// act on. This is the residual gap the ShareTargetFromDifficulty fix above
472// doesn't cover: even with a correctly-easy diff-1 target, a miner stuck in
473// RandomX light mode (or otherwise much slower than expected) can go
474// indefinitely without a single share, and difficulty would never drop to
475// meet it. Force a difficulty cut on a timeout instead of waiting for shares
476// that will never come.
477static const int64 VARDIFF_STALL_SECS = 45; // 3x share target -- reacts faster without false-triggering on normal variance
478static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by this much
479
376struct Miner { 480struct Miner {
377 SOCKET fd; 481 btf_socket_t fd;
378 bool authorised; 482 bool authorised = false;
379 std::string address; 483 std::string address;
380 std::string worker; 484 std::string worker;
381 std::string readBuf; 485 std::string readBuf;
382 int64 connectTime; 486 int64 connectTime;
383 uint64 sessionShares; 487 uint64 sessionShares = 0;
384 Miner(SOCKET f) : fd(f), authorised(false), connectTime(GetTime()), sessionShares(0) {} 488 uint64 roundShares = 0;
489 uint64 totalShares = 0;
490 int64 lastSeen = 0;
491 uint32_t extranonce2;
492
493 // Vardiff state
494 double difficulty = 1.0; // current share difficulty sent to this miner
495 uint256 shareTarget; // derived from difficulty; what we check against
496 int64 vardiffWindowStart = 0; // when the current vardiff window opened
497 uint64 vardiffShares = 0; // shares found in current window
498
499 // Hashrate estimate -- EWMA of (expected hashes per share at current
500 // difficulty) / (seconds since the previous share). We had all the data
501 // to compute this (difficulty + share timing) but never actually did,
502 // so pool/GUI hashrate always showed as zero/missing.
503 int64 lastShareTime = 0; // GetTime() of the previous accepted share
504 double hashRate = 0.0; // smoothed H/s estimate
505
506 explicit Miner(btf_socket_t f)
507 : fd(f), connectTime(GetTime()),
508 extranonce2(gNextExtranonce2.fetch_add(1)) {}
385}; 509};
386 510
387void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares) 511static std::vector<Miner*> gMiners;
512
513struct FoundBlock { int height; int64 ts; std::string addr; };
514static std::deque<FoundBlock> gFoundBlocks;
515
516// ---------------------------------------------------------------------------
517// Accept queue -- accept threads push; event loop pops at top of each tick
518// ---------------------------------------------------------------------------
519static std::vector<btf_socket_t> gReadyQueue;
520static std::mutex gReadyMutex;
521
522static void PushReady(btf_socket_t s)
388{ 523{
389 authorizedMiners = 0; 524 std::lock_guard<std::mutex> lk(gReadyMutex);
390 blocksFound = 0; 525 gReadyQueue.push_back(s);
391 roundShares = 0; 526}
392 { 527
393 std::lock_guard<std::mutex> lk(gMinersMutex); 528// ---------------------------------------------------------------------------
394 for (Miner* m : gMiners) 529// I/O helpers -- event loop thread only
395 if (m->authorised) 530// ---------------------------------------------------------------------------
396 authorizedMiners++; 531
532// Blocking send of a complete JSON line. Called only from the event loop so
533// no other thread is writing to the same fd concurrently.
534static bool SendLine(btf_socket_t fd, const json& j)
535{
536 std::string s = j.dump() + "\n";
537 int off = 0, total = (int)s.size();
538 while (off < total) {
539 int r = send(fd, s.c_str() + off, total - off, SEND_FLAGS);
540 if (r <= 0) return false;
541 off += r;
397 } 542 }
398 { 543 return true;
399 std::lock_guard<std::mutex> lk(gBlocksMutex); 544}
400 blocksFound = (int)gFoundBlocks.size(); 545
546// Non-blocking recv: pull whatever bytes are available now into buf, then
547// return one complete line if one is ready. Three outcomes:
548// true -- line is filled; buf may still contain more lines
549// false, errno EAGAIN/EWOULDBLOCK (or FIONREAD==0) -- no data right now
550// false, r==0 -- clean disconnect; caller must treat fd as dead
551// Caller distinguishes disconnect from "not yet" by checking whether
552// line.empty() after a false return (line is "" on both paths, so we
553// use a separate out-param for the disconnect signal).
554static bool RecvLine(btf_socket_t fd, std::string& buf,
555 std::string& line, bool& disconnected)
556{
557 disconnected = false;
558 char tmp[4096];
559
560#ifdef _WIN32
561 u_long avail = 0;
562 ioctlsocket(fd, FIONREAD, &avail);
563 if (avail > 0) {
564 int r = recv(fd, tmp, (int)std::min((u_long)(sizeof(tmp)-1), avail), 0);
565 if (r > 0) { tmp[r] = 0; buf += tmp; }
566 else if (r == 0){ disconnected = true; return false; }
567 // r < 0: WSAEWOULDBLOCK -- fall through with what we have
401 } 568 }
569#else
402 { 570 {
403 std::lock_guard<std::mutex> lk(gMinerStatsMutex); 571 int r = recv(fd, tmp, sizeof(tmp) - 1, MSG_DONTWAIT);
404 for (std::map<std::string, MinerStats>::const_iterator it = gMinerStats.begin(); it != gMinerStats.end(); ++it) 572 if (r > 0) { tmp[r] = 0; buf += tmp; }
405 roundShares += it->second.roundShares; 573 else if (r == 0) { disconnected = true; return false; }
574 // r < 0: EAGAIN/EWOULDBLOCK -- fall through with what we have
406 } 575 }
576#endif
577
578 size_t pos = buf.find('\n');
579 if (pos == std::string::npos) return false; // no complete line yet
580
581 line = buf.substr(0, pos);
582 if (!line.empty() && line.back() == '\r') line.pop_back();
583 buf = buf.substr(pos + 1);
584 return true;
407} 585}
408 586
409static bool SendLine(Miner* m, const json& j) 587// ---------------------------------------------------------------------------
588// Job helpers
589// ---------------------------------------------------------------------------
590static CBlock BuildMinerBlock(const StratumJob& job, uint32_t extranonce2)
410{ 591{
411 std::string s = j.dump() + "\n"; 592 CBlock blk = job.block;
412 int off = 0; 593 blk.vtx[0].vin[0].scriptSig << (CBigNum)extranonce2;
413 while (off < (int)s.size()) { 594 blk.hashMerkleRoot = blk.BuildMerkleTree();
414 int n = send(m->fd, s.c_str() + off, (int)s.size() - off, SEND_FLAGS); 595 return blk;
415 if (n <= 0) 596}
416 return false; 597
417 off += n; 598// shareTarget is per-miner (set by vardiff) and passed explicitly so the
418 } 599// value in p[3] of mining.notify always matches what HandleLine checks on submit.
419 return true; 600// This is the single source of truth for share difficulty.
601static json MakeNotifyParams(const StratumJob& job, const CBlock& b,
602 const uint256& shareTarget, bool clean)
603{
604 unsigned char hdr[80];
605 memcpy(hdr, &b.nVersion, 4);
606 memcpy(hdr+4, &b.hashPrevBlock, 32);
607 memcpy(hdr+36, &b.hashMerkleRoot, 32);
608 memcpy(hdr+68, &b.nTime, 4);
609 memcpy(hdr+72, &b.nBits, 4);
610 memcpy(hdr+76, &b.nNonce, 4);
611 unsigned char tgt[32]; memcpy(tgt, &shareTarget, 32);
612 return json::array({job.jobId, ToHex(hdr, 80),
613 b.hashPrevBlock.GetHex(), ToHex(tgt, 32), clean});
614}
615
616static void SendJob(Miner* m, bool clean)
617{
618 if (!gHaveJob) return;
619 CBlock mb = BuildMinerBlock(gCurrentJob, m->extranonce2);
620 // Use this miner's own shareTarget (set by vardiff) in the notify params.
621 // The participant miner reads p[3] as its share target, and HandleLine
622 // checks m->shareTarget on submit -- both sides now use the same value.
623 json n = {{"id",nullptr},{"method","mining.notify"},
624 {"params", MakeNotifyParams(gCurrentJob, mb, m->shareTarget, clean)}};
625 if (!SendLine(m->fd, n))
626 LogPrint("worker", "[pool->worker] job send FAILED for %s -- miner will be dropped\n",
627 m->address.empty() ? "(not yet authorized)" : m->address.c_str());
420} 628}
421 629
422static void BroadcastJob(bool clean) 630static void BroadcastJob(bool clean)
423{ 631{
424 StratumJob job; 632 if (!gHaveJob) return;
425 { std::lock_guard<std::mutex> lk(gJobMutex); if(!gHaveJob) return; job=gCurrentJob; } 633 int sent = 0;
426 json n = {{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,clean)}}; 634 for (Miner* m : gMiners) {
427 std::lock_guard<std::mutex> lk(gMinersMutex); 635 if (!m->authorised) continue;
428 for (Miner* m : gMiners) 636 SendJob(m, clean);
429 if (m->authorised && !SendLine(m, n)) 637 sent++;
430 printf("Stratum: failed to broadcast job to %s\n", m->address.c_str()); 638 }
639 LogPrint("pool", "[pool] job %s broadcast to %d miner(s)\n",
640 gCurrentJob.jobId.c_str(), sent);
431} 641}
432 642
433static void HandleLine(Miner* m, const std::string& line) 643// ---------------------------------------------------------------------------
644// Stratum message handler -- called for each complete line from a miner
645// Returns false if the miner should be disconnected immediately.
646// ---------------------------------------------------------------------------
647static bool HandleLine(Miner* m, const std::string& rawLine,
648 std::map<std::string, uint64>& roundShareCount,
649 uint64& roundShareTotal,
650 int& blocksFoundThisSession)
434{ 651{
435 json req; 652 json req;
436 try { req = json::parse(line); } catch(...){ return; } 653 try { req = json::parse(rawLine); }
654 catch (...) {
655 LogPrint("worker", "[worker] JSON parse error from %s: %.80s\n",
656 m->address.empty() ? "(unauth)" : m->address.c_str(),
657 rawLine.c_str());
658 return true; // don't disconnect on bad JSON, just ignore the line
659 }
437 660
438 json id = req.value("id", json(nullptr)); 661 json id = req.value("id", json(nullptr));
439 std::string method = req.value("method",""); 662 std::string method = req.value("method", "");
440 663
441 auto reply = [&](json result, json error=nullptr){ 664 // Reply helper: always sends result+error pair as Stratum requires
442 if (!SendLine(m, json{{"id",id},{"result",result},{"error",error}})) 665 auto reply = [&](json result, std::string error = "") {
443 printf("Stratum: failed to reply to miner %s\n", m->address.c_str()); 666 json err = error.empty() ? json(nullptr) : json(error);
667 if (!SendLine(m->fd, json{{"id",id},{"result",result},{"error",err}}))
668 LogPrint("worker", "[pool->worker] reply send FAILED for %s (method=%s)\n",
669 m->address.empty() ? "(unauth)" : m->address.c_str(),
670 method.c_str());
444 }; 671 };
445 672
673 // ------------------------------------------------------------------
446 if (method == "mining.subscribe") { 674 if (method == "mining.subscribe") {
447 std::string sid = ToHex(&m->fd, 4); 675 std::string sid = ToHex(&m->fd, 4);
448 printf("Stratum: miner subscribed %s\n", sid.c_str()); 676 LogPrint("worker", "[worker->pool] subscribe from fd=%d sid=%s\n",
677 (int)m->fd, sid.c_str());
449 reply(json::array({json::array({json::array({"mining.notify",sid})}),sid,4})); 678 reply(json::array({json::array({json::array({"mining.notify",sid})}),sid,4}));
450 return; 679 return true;
451 } 680 }
452 681
682 // ------------------------------------------------------------------
453 if (method == "mining.authorize") { 683 if (method == "mining.authorize") {
454 auto& p = req["params"]; 684 auto& p = req["params"];
455 m->address = (p.size()>0 && p[0].is_string()) ? p[0].get<std::string>() : ""; 685 m->address = (p.size() > 0 && p[0].is_string()) ? p[0].get<std::string>() : "";
456 m->worker = (p.size()>1 && p[1].is_string()) ? p[1].get<std::string>() : "worker"; 686 m->worker = (p.size() > 1 && p[1].is_string()) ? p[1].get<std::string>() : "worker";
457 m->authorised = true; 687 m->lastSeen = GetTime();
458 printf("Stratum: miner authorized address=%s worker=%s\n", m->address.c_str(), m->worker.c_str());
459 reply(true);
460 688
461 uint160 h; bool valid = AddressToHash160(m->address, h); 689 // Validate address before authorising
462 if (!valid) printf("Stratum: miner %s invalid address '%s' — shares won't pay out\n", 690 uint160 h160; bool validAddr = AddressToHash160(m->address, h160);
463 m->worker.c_str(), m->address.c_str());
464 691
465 { std::lock_guard<std::mutex> lk(gMinerStatsMutex); 692 m->authorised = true; // authorise regardless -- shares just won't pay out
466 auto& ms = gMinerStats[m->address]; 693 LogPrint("worker", "[worker->pool] authorize address=%s worker=%s addr_valid=%s\n",
467 ms.lastSeen = GetTime(); ms.worker = m->worker; } 694 m->address.c_str(), m->worker.c_str(), validAddr ? "yes" : "NO");
468 695
469 if (!SendLine(m, json{{"id",nullptr},{"method","mining.set_difficulty"},{"params",json::array({1})}})) 696 if (!validAddr)
470 printf("Stratum: failed to send difficulty to %s\n", m->address.c_str()); 697 LogPrint("worker", "[pool] WARNING: address '%s' is invalid -- "
698 "miner will hash but shares cannot be paid out\n",
699 m->address.c_str());
471 700
472 StratumJob job; bool have; 701 reply(true);
473 { std::lock_guard<std::mutex> lk(gJobMutex); have=gHaveJob; if(have) job=gCurrentJob; } 702
474 if (have) { 703 // Initialize vardiff state and send initial difficulty
475 printf("Stratum: sending job %s to %s\n", job.jobId.c_str(), m->address.c_str()); 704 m->difficulty = 1.0;
476 if (!SendLine(m, json{{"id",nullptr},{"method","mining.notify"},{"params",MakeNotifyParams(job,true)}})) 705 m->shareTarget = ShareTargetFromDifficulty(m->difficulty);
477 printf("Stratum: failed to send job %s to %s\n", job.jobId.c_str(), m->address.c_str()); 706 m->vardiffWindowStart = GetTime();
707 m->vardiffShares = 0;
708 LogPrint("worker", "[pool->worker] initial difficulty=%.4f for %s\n",
709 m->difficulty, m->address.c_str());
710 if (!SendLine(m->fd, json{{"id",nullptr},{"method","mining.set_difficulty"},
711 {"params",json::array({m->difficulty})}}))
712 LogPrint("worker", "[pool->worker] set_difficulty send FAILED for %s\n",
713 m->address.c_str());
714
715 if (gHaveJob) {
716 LogPrint("worker", "[pool->worker] sending job %s to %s\n",
717 gCurrentJob.jobId.c_str(), m->address.c_str());
718 SendJob(m, true);
478 } else { 719 } else {
479 printf("Stratum: no job available for %s\n", m->address.c_str()); 720 LogPrint("worker", "[pool->worker] no job available yet for %s"
721 " -- will be sent one as soon as the pool builds it\n",
722 m->address.c_str());
480 } 723 }
481 return; 724 return true;
482 } 725 }
483 726
484 if (method == "mining.extranonce.subscribe") { reply(true); return; } 727 // ------------------------------------------------------------------
728 if (method == "mining.extranonce.subscribe") { reply(true); return true; }
729 if (method == "mining.get_transactions") { reply(json::array()); return true; }
485 730
731 // ------------------------------------------------------------------
486 if (method == "mining.submit") { 732 if (method == "mining.submit") {
487 if (!m->authorised) { reply(false,"not authorised"); return; } 733 if (!m->authorised) {
734 LogPrint("worker", "[worker->pool] submit from unauthorized miner (fd=%d)\n",
735 (int)m->fd);
736 reply(false, "not authorised");
737 return true;
738 }
488 739
489 auto& p = req["params"]; 740 auto& p = req["params"];
490 if (p.size() < 4) { reply(false,"bad params"); return; } 741 if (p.size() < 3) {
742 LogPrint("worker", "[worker->pool] submit bad params from %s (size=%zu)\n",
743 m->address.c_str(), p.size());
744 reply(false, "bad params");
745 return true;
746 }
491 747
492 std::string jobId = p[1].get<std::string>(); 748 std::string submitJobId = p[1].get<std::string>();
493 std::string nonceHex = p[2].get<std::string>(); 749 std::string nonceHex = p[2].get<std::string>();
494 750
495 StratumJob job; 751 if (!gHaveJob) {
496 { std::lock_guard<std::mutex> lk(gJobMutex); 752 LogPrint("worker", "[worker->pool] submit from %s but pool has no current job\n",
497 if (!gHaveJob || gCurrentJob.jobId != jobId) { reply(false,"stale job"); return; } 753 m->address.c_str());
498 job = gCurrentJob; } 754 reply(false, "stale job");
755 return true;
756 }
757 if (gCurrentJob.jobId != submitJobId) {
758 LogPrint("worker", "[worker->pool] submit stale job from %s"
759 " (submitted=%s current=%s)\n",
760 m->address.c_str(), submitJobId.c_str(), gCurrentJob.jobId.c_str());
761 reply(false, "stale job");
762 return true;
763 }
499 764
500 auto nb = FromHex(nonceHex); 765 auto nb = FromHex(nonceHex);
501 if (nb.size()<4) { reply(false,"bad nonce"); return; } 766 if (nb.size() < 4) {
767 LogPrint("worker", "[worker->pool] submit bad nonce from %s: '%s'\n",
768 m->address.c_str(), nonceHex.c_str());
769 reply(false, "bad nonce");
770 return true;
771 }
502 unsigned int nNonce; memcpy(&nNonce, nb.data(), 4); 772 unsigned int nNonce; memcpy(&nNonce, nb.data(), 4);
503 773
504 CBlock& b = job.block; 774 // Rebuild this miner's exact block (unique merkle via extranonce2)
775 CBlock b = BuildMinerBlock(gCurrentJob, m->extranonce2);
505 b.nNonce = nNonce; 776 b.nNonce = nNonce;
506 unsigned char hdr[80]; 777 unsigned char hdr[80];
507 memcpy(hdr, &b.nVersion, 4); 778 memcpy(hdr, &b.nVersion, 4);
508 memcpy(hdr+4, &b.hashPrevBlock, 32); 779 memcpy(hdr+4, &b.hashPrevBlock, 32);
509 memcpy(hdr+36, &b.hashMerkleRoot,32); 780 memcpy(hdr+36, &b.hashMerkleRoot, 32);
510 memcpy(hdr+68, &b.nTime, 4); 781 memcpy(hdr+68, &b.nTime, 4);
511 memcpy(hdr+72, &b.nBits, 4); 782 memcpy(hdr+72, &b.nBits, 4);
512 memcpy(hdr+76, &b.nNonce, 4); 783 memcpy(hdr+76, &b.nNonce, 4);
513 784
514 uint256 powHash = RandomXPoWHash(hdr, 80); 785 uint256 powHash = RandomXPoWHash(hdr, 80);
515 786
516 // Record share in the current round. 787 // Check against this miner's personal share target (set by vardiff)
517 std::string key = MinerKey(m->address, m->worker); 788 if (powHash > m->shareTarget) {
518 { std::lock_guard<std::mutex> lk(gMinerStatsMutex); 789 LogPrint("worker", "[worker->pool] high-hash from %s"
519 MinerStats& ms = gMinerStats[key]; 790 " nonce=%u (difficulty=%.4f)\n",
520 ms.address = m->address; 791 m->address.c_str(), nNonce, m->difficulty);
521 ms.worker = m->worker; 792 reply(false, "high-hash");
522 ms.roundShares++; 793 return true;
523 ms.totalShares++; 794 }
524 ms.lastSeen = GetTime(); } 795
796 // Valid share -- record it
525 m->sessionShares++; 797 m->sessionShares++;
798 m->roundShares++;
799 m->totalShares++;
800 m->vardiffShares++;
801 int64 shareTime = GetTime();
802 m->lastSeen = shareTime;
803 roundShareCount[m->address]++;
804 roundShareTotal++;
805
806 // Update hashrate estimate: expected hashes for this share divided by
807 // the time since the last one, smoothed with an EWMA so a single fast
808 // or slow share doesn't make the number jump around.
809 if (m->lastShareTime > 0) {
810 int64 dt = shareTime - m->lastShareTime;
811 if (dt <= 0) dt = 1;
812 double inst = ExpectedHashesForDifficulty(m->difficulty) / (double)dt;
813 const double alpha = 0.25;
814 m->hashRate = (m->hashRate <= 0.0) ? inst
815 : alpha * inst + (1.0 - alpha) * m->hashRate;
816 }
817 m->lastShareTime = shareTime;
526 818
527 if (powHash > job.shareTarget) { reply(true); return; } // valid share, not a full block 819 LogPrint("worker", "[worker->pool] valid share from %s"
820 " nonce=%u job=%s difficulty=%.4f (session: %llu)\n",
821 m->address.c_str(), nNonce,
822 gCurrentJob.jobId.c_str(), m->difficulty,
823 (unsigned long long)m->sessionShares);
824 reply(true);
825
826 // Vardiff: after every VARDIFF_SHARES_PER_CHECK shares, check rate
827 if (m->vardiffShares >= VARDIFF_SHARES_PER_CHECK) {
828 int64 elapsed = GetTime() - m->vardiffWindowStart;
829 if (elapsed <= 0) elapsed = 1;
830 double actualSecsPerShare = (double)elapsed / (double)m->vardiffShares;
831 double ratio = actualSecsPerShare / (double)VARDIFF_TARGET_SECS;
832 // ratio < 1 means shares arriving faster than target (too easy)
833 // ratio > 1 means shares arriving slower than target (too hard)
834 if (ratio < (1.0 / VARDIFF_RETARGET_RATIO) ||
835 ratio > VARDIFF_RETARGET_RATIO) {
836 // New difficulty scales inversely with actual share rate
837 double newDiff = m->difficulty / ratio;
838 if (newDiff < VARDIFF_MIN) newDiff = VARDIFF_MIN;
839 if (newDiff > VARDIFF_MAX) newDiff = VARDIFF_MAX;
840 // Only apply if the change is meaningful (>10%)
841 if (newDiff / m->difficulty > 1.1 || m->difficulty / newDiff > 1.1) {
842 m->difficulty = newDiff;
843 m->shareTarget = ShareTargetFromDifficulty(m->difficulty);
844 LogPrint("worker", "[pool] vardiff: %s new difficulty=%.4f"
845 " (%.1fs/share actual vs %ds target)\n",
846 m->address.c_str(), m->difficulty,
847 actualSecsPerShare, (int)VARDIFF_TARGET_SECS);
848 SendLine(m->fd, json{{"id",nullptr},
849 {"method","mining.set_difficulty"},
850 {"params",json::array({m->difficulty})}});
851 // set_difficulty alone doesn't change what the worker
852 // actually hashes against -- it only reads its share
853 // target from mining.notify params[3]. Without this, the
854 // new difficulty wouldn't take effect until the next
855 // natural job broadcast (new block, or the 60s trickle
856 // refresh), so push a fresh notify for this miner now.
857 SendJob(m, false);
858 }
859 }
860 // Reset window
861 m->vardiffWindowStart = GetTime();
862 m->vardiffShares = 0;
863 }
864
865 if (powHash > gCurrentJob.target) return true; // valid share, not a block
866
867 // Full difficulty met -- submit block to the chain
868 LogPrint("pool", "[pool] BLOCK CANDIDATE from %s height=%d nNonce=%u -- submitting\n",
869 m->address.c_str(), gCurrentJob.height, nNonce);
528 870
529 // Full block
530 CBlock* pblock = new CBlock(b); 871 CBlock* pblock = new CBlock(b);
531 bool accepted = false; 872 bool accepted = false;
532 CRITICAL_BLOCK(cs_main) 873 CRITICAL_BLOCK(cs_main)
533 { 874 {
875 int heightBefore = nBestHeight;
534 if (ProcessBlock(NULL, pblock)) { 876 if (ProcessBlock(NULL, pblock)) {
535 accepted = true; 877 // ProcessBlock returns true for orphans too -- verify the chain
536 printf("Pool: BLOCK FOUND by %s height=%d nNonce=%u\n", 878 // actually advanced. If nBestHeight didn't increase, the block
537 m->address.c_str(), job.height, nNonce); 879 // was shelved as an orphan (pool was behind the chain tip) and
538 FoundBlock fb{job.height, GetTime(), m->address, pblock->GetHash()}; 880 // no coinbase was added to the wallet. Do not pay out.
539 { std::lock_guard<std::mutex> lk(gBlocksMutex); 881 if (nBestHeight > heightBefore) {
540 gFoundBlocks.push_front(fb); 882 accepted = true;
541 if (gFoundBlocks.size()>50) gFoundBlocks.pop_back(); } 883 LogPrint("pool", "[pool] BLOCK ACCEPTED height=%d by %s\n",
542 { std::lock_guard<std::mutex> lk(gJobMutex); gHaveJob=false; } 884 gCurrentJob.height, m->address.c_str());
885 gFoundBlocks.push_front({gCurrentJob.height, GetTime(), m->address});
886 if (gFoundBlocks.size() > 50) gFoundBlocks.pop_back();
887 blocksFoundThisSession++;
888 gHaveJob = false; // invalidate job immediately
889 } else {
890 LogPrint("pool", "[pool] BLOCK ORPHANED height=%d by %s"
891 " -- pool was behind chain tip, no payout\n",
892 gCurrentJob.height, m->address.c_str());
893 gHaveJob = false; // job is stale regardless
894 }
543 } else { 895 } else {
896 LogPrint("pool", "[pool] BLOCK REJECTED height=%d (ProcessBlock returned false)\n",
897 gCurrentJob.height);
544 delete pblock; 898 delete pblock;
545 } 899 }
546 } 900 }
547 reply(accepted); 901
548 if (accepted) { QueuePayouts(job.height); RebuildJob(); BroadcastJob(true); } 902 if (accepted) {
549 return; 903 // Snapshot height before RebuildJob() (which increments it)
904 int foundHeight = gCurrentJob.height;
905 QueuePayouts(foundHeight, roundShareCount, roundShareTotal);
906 // Reset round shares
907 roundShareCount.clear();
908 roundShareTotal = 0;
909 for (Miner* mi : gMiners) mi->roundShares = 0;
910 // Build and broadcast new job immediately
911 if (RebuildJob()) {
912 LogPrint("pool", "[pool] new job %s built after block %d\n",
913 gCurrentJob.jobId.c_str(), foundHeight);
914 BroadcastJob(true);
915 } else {
916 LogPrint("pool", "[pool] WARNING: RebuildJob() failed after block %d"
917 " -- miners will wait\n", foundHeight);
918 }
919 }
920 return true;
550 } 921 }
551 922
552 if (method == "mining.get_transactions") { reply(json::array()); return; } 923 // Unknown method -- log it (can reveal miner software quirks)
924 LogPrint("worker", "[worker->pool] unknown method '%s' from %s\n",
925 method.c_str(),
926 m->address.empty() ? "(unauth)" : m->address.c_str());
553 reply(nullptr, "unknown method"); 927 reply(nullptr, "unknown method");
928 return true;
554} 929}
555 930
556static void MinerThreadFn(void* arg) 931// ---------------------------------------------------------------------------
932// Stats refresh -- called every 2s from event loop
933// ---------------------------------------------------------------------------
934static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal)
557{ 935{
558 Miner* m = (Miner*)arg; 936 std::lock_guard<std::mutex> lk(gStatsMutex);
559 while (!fShutdown && gPoolRunning) { 937
560 fd_set fds; FD_ZERO(&fds); FD_SET(m->fd, &fds); 938 gStats.authorizedMiners = 0;
561 struct timeval tv={1,0}; 939 gStats.blocksFound = blocksFoundThisSession;
562 if (select((int)m->fd+1, &fds, NULL, NULL, &tv) <= 0) continue; 940 gStats.roundShares = roundShareTotal;
563 char buf[4096]; 941 gStats.totalHashRate = 0.0;
564 int n = recv(m->fd, buf, sizeof(buf)-1, 0); 942 for (Miner* m : gMiners)
565 if (n <= 0) break; 943 if (m->authorised) gStats.authorizedMiners++;
566 buf[n]=0; m->readBuf += buf; 944
567 size_t pos; 945 gWorkerStats.clear();
568 while ((pos = m->readBuf.find('\n')) != std::string::npos) { 946 for (Miner* m : gMiners) {
569 std::string line = m->readBuf.substr(0, pos); 947 if (!m->authorised || m->lastSeen <= 0) continue;
570 m->readBuf = m->readBuf.substr(pos+1); 948 PoolWorkerStatView v;
571 if (!line.empty() && line.back()=='\r') line.pop_back(); 949 v.address = m->address;
572 if (!line.empty()) HandleLine(m, line); 950 v.worker = m->worker;
573 } 951 v.roundShares = m->roundShares;
952 v.totalShares = m->totalShares;
953 v.lastSeen = m->lastSeen;
954 v.hashRate = m->hashRate;
955 gWorkerStats.push_back(v);
956 gStats.totalHashRate += m->hashRate;
957 }
958 std::sort(gWorkerStats.begin(), gWorkerStats.end(),
959 [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) {
960 return a.roundShares != b.roundShares ? a.roundShares > b.roundShares
961 : a.lastSeen > b.lastSeen;
962 });
963
964 // gPendingPayouts is event-loop-only data (no lock needed here since
965 // RefreshStats is always called from the event loop thread).
966 gPayoutViews.clear();
967 for (const PendingPayout& pp : gPendingPayouts) {
968 PendingPayoutView pv;
969 pv.matureAtHeight = pp.matureAtHeight;
970 pv.recipients = (int)pp.amounts.size();
971 pv.totalAmount = 0;
972 for (auto& kv : pp.amounts) pv.totalAmount += kv.second;
973 gPayoutViews.push_back(pv);
574 } 974 }
575 sock_close(m->fd);
576 printf("Stratum: miner %s disconnected (%llu shares)\n",
577 m->worker.empty()?"unknown":m->worker.c_str(),
578 (unsigned long long)m->sessionShares);
579 { std::lock_guard<std::mutex> lk(gMinersMutex);
580 auto it = std::find(gMiners.begin(), gMiners.end(), m);
581 if (it != gMiners.end()) gMiners.erase(it); }
582 delete m;
583} 975}
584 976
585static void JobWatcherFn(void*) 977// ---------------------------------------------------------------------------
978// Event loop
979// ---------------------------------------------------------------------------
980static void PoolEventLoop()
586{ 981{
587 int lastHeight = -1; 982 int lastHeight = -1;
983 int64 lastRefresh = GetTime();
984 int64 lastStatsRefresh = 0;
985 int blocksFoundThisSession = 0;
986 int64 noJobSince = 0; // 0 = have a job
987 bool warnedNoJob = false;
988 int64 lastNoJobRetry = 0; // throttle RebuildJob calls when no tip
989
990 std::map<std::string, uint64> roundShareCount;
991 uint64 roundShareTotal = 0;
992
993 static const int64 JOB_REFRESH_SECS = 60; // re-stamp nTime periodically
994 static const int64 NO_JOB_WARN_SECS = 15; // warn after this long with no job
995 static const int64 NO_JOB_RETRY_SECS = 5; // how often to retry RebuildJob
996
588 while (!fShutdown && gPoolRunning) { 997 while (!fShutdown && gPoolRunning) {
589 Sleep(500); 998
590 bool newBlock = false; 999 // 1. Drain accept queue
591 CRITICAL_BLOCK(cs_main) 1000 {
592 { if (nBestHeight != lastHeight && pindexBest) { lastHeight = nBestHeight; newBlock = true; } } 1001 std::lock_guard<std::mutex> lk(gReadyMutex);
593 if (newBlock) { 1002 for (btf_socket_t fd : gReadyQueue) {
594 FlushMaturePayouts(); 1003 gMiners.push_back(new Miner(fd));
595 if (RebuildJob()) { printf("Stratum: new job height=%d\n", lastHeight+1); BroadcastJob(true); } 1004 LogPrint("worker", "[pool] miner fd=%d entered event loop"
596 } else if (!gHaveJob) { 1005 " -- awaiting subscribe/authorize\n", (int)fd);
1006 }
1007 gReadyQueue.clear();
1008 }
1009
1010 // 2. Detect new block.
1011 // When our own miner found the block, HandleLine already called
1012 // RebuildJob() + BroadcastJob(true) and set gHaveJob=true.
1013 // We still flush mature payouts and update bookkeeping, but skip
1014 // rebuilding if a fresh job already exists for this height.
1015 {
1016 bool newBlock = false;
1017 CRITICAL_BLOCK(cs_main)
1018 {
1019 if (pindexBest && nBestHeight != lastHeight) {
1020 lastHeight = nBestHeight;
1021 newBlock = true;
1022 }
1023 }
1024 if (newBlock) {
1025 FlushMaturePayouts();
1026 if (!gHaveJob) {
1027 // External block (not found by our miners), or HandleLine's
1028 // RebuildJob failed. Either way this round is over with no
1029 // payout from us, so clear round shares before starting the
1030 // next one -- otherwise they'd keep accumulating across
1031 // rounds that our own miners didn't win.
1032 roundShareCount.clear();
1033 roundShareTotal = 0;
1034 for (Miner* mi : gMiners) mi->roundShares = 0;
1035
1036 // Build and broadcast now.
1037 if (RebuildJob()) {
1038 LogPrint("pool", "[pool] new block height=%d -- job %s ready,"
1039 " %zu miner(s) connected\n",
1040 lastHeight, gCurrentJob.jobId.c_str(), gMiners.size());
1041 BroadcastJob(true);
1042 } else {
1043 LogPrint("pool", "[pool] new block height=%d but RebuildJob() failed"
1044 " -- miners will wait\n", lastHeight);
1045 }
1046 } else {
1047 // HandleLine already built and broadcast for this height.
1048 LogPrint("pool", "[pool] new block height=%d -- job already"
1049 " broadcast by submit handler\n", lastHeight);
1050 }
1051 lastRefresh = GetTime();
1052 noJobSince = 0;
1053 warnedNoJob = false;
1054 }
1055 }
1056
1057 // 3. No-job watchdog (throttled: only retry every NO_JOB_RETRY_SECS)
1058 if (!gHaveJob) {
1059 int64 now = GetTime();
1060 if (noJobSince == 0) noJobSince = now;
1061
1062 if (now - lastNoJobRetry >= NO_JOB_RETRY_SECS) {
1063 lastNoJobRetry = now;
1064 if (RebuildJob()) {
1065 LogPrint("pool", "[pool] job built after %llds without one\n",
1066 (long long)(now - noJobSince));
1067 BroadcastJob(false);
1068 noJobSince = 0;
1069 warnedNoJob = false;
1070 lastRefresh = now;
1071 } else if (!warnedNoJob && now - noJobSince >= NO_JOB_WARN_SECS) {
1072 LogPrint("pool", "[pool] STUCK: no job for %llds -- %s."
1073 " Miners are waiting. Will keep retrying every %llds.\n",
1074 (long long)(now - noJobSince),
1075 pindexBest ? "RebuildJob() keeps failing (check above for why)"
1076 : "no chain tip yet (node still syncing)",
1077 (long long)NO_JOB_RETRY_SECS);
1078 warnedNoJob = true;
1079 }
1080 }
1081 } else if (GetTime() - lastRefresh >= JOB_REFRESH_SECS) {
1082 // Periodic nTime refresh -- stale timestamps cause valid hashes to be rejected
597 if (RebuildJob()) BroadcastJob(false); 1083 if (RebuildJob()) BroadcastJob(false);
1084 lastRefresh = GetTime();
1085 }
1086
1087 // 3.5. Vardiff stall watchdog -- runs independently of share count.
1088 // The normal vardiff check in HandleLine only fires once
1089 // VARDIFF_SHARES_PER_CHECK shares have arrived; a miner that never
1090 // finds a single share never reaches that check, so it can be stuck
1091 // at its initial (or last) difficulty forever. Catch that here.
1092 for (Miner* m : gMiners) {
1093 if (!m->authorised || m->vardiffShares != 0 || m->vardiffWindowStart <= 0)
1094 continue;
1095 int64 stalled = GetTime() - m->vardiffWindowStart;
1096 if (stalled < VARDIFF_STALL_SECS) continue;
1097
1098 double newDiff = m->difficulty / VARDIFF_STALL_CUT;
1099 if (newDiff < VARDIFF_MIN) newDiff = VARDIFF_MIN;
1100
1101 if (newDiff < m->difficulty) {
1102 m->difficulty = newDiff;
1103 m->shareTarget = ShareTargetFromDifficulty(m->difficulty);
1104 m->vardiffWindowStart = GetTime();
1105 LogPrint("worker", "[pool] vardiff STALL: %s sent zero shares in"
1106 " %llds -- forcing difficulty down to %.6f (no share data"
1107 " to retarget from normally)\n",
1108 m->address.c_str(), (long long)stalled, m->difficulty);
1109 SendLine(m->fd, json{{"id",nullptr},
1110 {"method","mining.set_difficulty"},
1111 {"params",json::array({m->difficulty})}});
1112 // Same reasoning as the normal vardiff retarget above: the
1113 // worker only reads its share target from mining.notify, so
1114 // push a fresh job now instead of waiting on the next
1115 // natural broadcast.
1116 SendJob(m, false);
1117 } else {
1118 // Already at the difficulty floor -- this miner's problem isn't
1119 // difficulty, it's something else (slow hashing, bad connection,
1120 // stuck RandomX light mode, etc). Log once per stall window so
1121 // it's visible without spamming.
1122 LogPrint("worker", "[pool] vardiff STALL: %s still at zero shares"
1123 " after %llds at floor difficulty %.6f -- likely a"
1124 " hashrate/connectivity problem, not a difficulty problem\n",
1125 m->address.c_str(), (long long)stalled, m->difficulty);
1126 m->vardiffWindowStart = GetTime(); // avoid re-logging every tick
1127 }
1128 }
1129
1130 // 4. Select over miner sockets
1131 if (!gMiners.empty()) {
1132 fd_set fds; FD_ZERO(&fds);
1133 int maxfd = 0;
1134 for (Miner* m : gMiners) {
1135 FD_SET(m->fd, &fds);
1136 if ((int)m->fd > maxfd) maxfd = (int)m->fd;
1137 }
1138 struct timeval tv = {0, 50000}; // 50ms
1139 if (select(maxfd + 1, &fds, NULL, NULL, &tv) > 0) {
1140 std::vector<Miner*> dead;
1141 for (Miner* m : gMiners) {
1142 if (!FD_ISSET(m->fd, &fds)) continue;
1143 std::string line;
1144 bool disc = false;
1145 // Drain all complete lines buffered for this miner.
1146 // HandleLine always returns true; disc signals clean disconnect.
1147 while (RecvLine(m->fd, m->readBuf, line, disc))
1148 HandleLine(m, line, roundShareCount,
1149 roundShareTotal, blocksFoundThisSession);
1150 if (disc) dead.push_back(m);
1151 }
1152 for (Miner* m : dead) {
1153 LogPrint("worker", "[pool] miner %s disconnected"
1154 " (session shares: %llu, round shares: %llu)\n",
1155 m->worker.empty() ? "(never authorized)" : m->worker.c_str(),
1156 (unsigned long long)m->sessionShares,
1157 (unsigned long long)m->roundShares);
1158 sock_close(m->fd);
1159 gMiners.erase(std::find(gMiners.begin(), gMiners.end(), m));
1160 delete m;
1161 }
1162 }
1163 } else {
1164 Sleep(50);
1165 }
1166
1167 // 5. Refresh GUI stats every 2s
1168 if (GetTime() - lastStatsRefresh >= 2) {
1169 RefreshStats(blocksFoundThisSession, roundShareTotal);
1170 lastStatsRefresh = GetTime();
598 } 1171 }
599 } 1172 }
1173
1174 for (Miner* m : gMiners) { sock_close(m->fd); delete m; }
1175 gMiners.clear();
600} 1176}
601 1177
602// --------------------------------------------------------------------------- 1178// ---------------------------------------------------------------------------
603// Main Stratum accept loop — entry point called from ui.cpp and headless.cpp 1179// Accept threads
604// ---------------------------------------------------------------------------
605// .btf Stratum listener — miners connect via rendezvous relay using the
606// node's .btf address instead of a raw IP:port. Same Stratum protocol,
607// same MinerThreadFn handler, works through CGNAT with no port forwarding.
608//
609// Keeps one listener registered per relay at all times. When a miner
610// connects, a replacement listener is spawned immediately so the next
611// miner doesn't have to wait.
612// --------------------------------------------------------------------------- 1180// ---------------------------------------------------------------------------
1181struct AcceptCtx { unsigned char pk[32]; unsigned char sk[32]; std::string relay; };
613 1182
614struct BtfAcceptCtx { unsigned char pk[32]; unsigned char sk[32]; string relay; }; 1183// One persistent thread per relay. Loops: register -> wrap -> push -> repeat.
615 1184static void AcceptOneFn(void* arg)
616// Track active listener count per relay to avoid pile-up when relay is slow
617static std::map<string,int> gBtfListenerCount;
618static std::mutex gBtfListenerMutex;
619
620static void BtfStratumAcceptOneFn(void* arg)
621{ 1185{
622 BtfAcceptCtx* ctx = (BtfAcceptCtx*)arg; 1186 AcceptCtx* ctx = (AcceptCtx*)arg;
623 unsigned char pk[32], sk[32]; 1187 unsigned char pk[32], sk[32];
624 memcpy(pk, ctx->pk, 32); memcpy(sk, ctx->sk, 32); 1188 memcpy(pk, ctx->pk, 32); memcpy(sk, ctx->sk, 32);
625 string relay = ctx->relay; 1189 std::string relay = ctx->relay;
626 delete ctx; 1190 delete ctx;
627 1191
628 auto decrement = [&]() {
629 std::lock_guard<std::mutex> lk(gBtfListenerMutex);
630 gBtfListenerCount[relay]--;
631 };
632
633 size_t colon = relay.rfind(':'); 1192 size_t colon = relay.rfind(':');
634 if (colon == string::npos) { decrement(); return; } 1193 if (colon == std::string::npos) {
635 string strHost = relay.substr(0, colon); 1194 LogPrint("pool", "[pool] AcceptOneFn: bad relay address '%s'\n", relay.c_str());
636 int nPort = atoi(relay.substr(colon+1).c_str()); 1195 return;
637
638 btf::RvSocket rv = btf::RvServiceRegister(strHost.c_str(), (unsigned short)nPort, pk);
639 if (fShutdown || !gPoolRunning) { if (rv != btf::RV_INVALID) btf::RvClose(rv); decrement(); return; }
640 if (rv == btf::RV_INVALID) { decrement(); return; }
641
642 // Paired: spawn replacement (count transfers to new thread)
643 if (!fShutdown && gPoolRunning) {
644 BtfAcceptCtx* next = new BtfAcceptCtx();
645 memcpy(next->pk, pk, 32); memcpy(next->sk, sk, 32);
646 next->relay = relay;
647 LaunchThread(BtfStratumAcceptOneFn, next);
648 } else {
649 decrement();
650 } 1196 }
1197 std::string host = relay.substr(0, colon);
1198 int port = atoi(relay.substr(colon + 1).c_str());
1199
1200 LogPrint("pool", "[pool] accept thread started for relay %s\n", relay.c_str());
1201
1202 while (!fShutdown && gPoolRunning) {
1203 // Blocks until a miner connects to us at this relay
1204 btf::RvSocket rv = btf::RvServiceRegister(host.c_str(), (unsigned short)port, pk);
651 1205
652 btf_socket_t hSocket = btf::BtfServiceWrap(rv, sk); 1206 if (fShutdown || !gPoolRunning) {
653 if (hSocket == INVALID_SOCKET) return; 1207 if (rv != btf::RV_INVALID) btf::RvClose(rv);
1208 break;
1209 }
1210 if (rv == btf::RV_INVALID) {
1211 LogPrint("pool", "[pool] relay %s: RvServiceRegister failed"
1212 " -- backing off 30s before retry\n", relay.c_str());
1213 for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000);
1214 continue;
1215 }
654 1216
655 printf("Stratum/.btf: miner connected via %s\n", relay.c_str()); 1217 // Echan handshake -- blocking but fast (local + relay round-trip)
656 Miner* m = new Miner(hSocket); 1218 btf_socket_t fd = btf::BtfServiceWrap(rv, sk);
657 { std::lock_guard<std::mutex> lk(gMinersMutex); gMiners.push_back(m); } 1219 if (fd == INVALID_SOCKET) {
658 LaunchThread(MinerThreadFn, m); 1220 LogPrint("pool", "[pool] relay %s: BtfServiceWrap failed (echan handshake)"
1221 " -- miner dropped, will accept next\n", relay.c_str());
1222 continue;
1223 }
1224
1225 LogPrint("worker", "[pool] miner arrived via relay %s -- pushing to event loop\n",
1226 relay.c_str());
1227 PushReady(fd);
1228 // Loop immediately: register again so the next miner can connect
1229 }
1230
1231 LogPrint("pool", "[pool] accept thread stopped for relay %s\n", relay.c_str());
659} 1232}
660 1233
661static void BtfStratumAcceptFn(void*) 1234// Spawns one AcceptOneFn per relay. Re-polls relay list every 60s to pick up
1235// newly discovered relays. Uses a set to avoid spawning duplicate threads.
1236static void AcceptLoopFn(void*)
662{ 1237{
663 printf("Stratum/.btf: starting \xe2\x80\x94 miners can connect via %s\n", 1238 LogPrint("pool", "[pool] AcceptLoopFn started -- miners can connect via %s\n",
664 BtfLocalAddress().c_str()); 1239 BtfLocalAddress().c_str());
665 1240
1241 // Wait for our identity to be ready
666 unsigned char pk[32], sk[32]; 1242 unsigned char pk[32], sk[32];
1243 int waitSecs = 0;
667 while (!BtfGetIdentity(pk, sk)) { 1244 while (!BtfGetIdentity(pk, sk)) {
668 if (fShutdown || !gPoolRunning) return; 1245 if (fShutdown || !gPoolRunning) return;
1246 if (waitSecs == 0)
1247 LogPrint("pool", "[pool] waiting for .btf identity...\n");
1248 waitSecs++;
669 Sleep(2000); 1249 Sleep(2000);
670 } 1250 }
1251 LogPrint("pool", "[pool] .btf identity ready after %ds\n", waitSecs * 2);
1252
1253 std::set<std::string> spawned; // relays we've already started a thread for
671 1254
672 while (!fShutdown && gPoolRunning) { 1255 while (!fShutdown && gPoolRunning) {
673 vector<string> relays = BtfAllRelays(); 1256 vector<string> relays = BtfAllRelays();
674 if (relays.empty()) { Sleep(5000); continue; } 1257 if (relays.empty()) {
675 1258 LogPrint("pool", "[pool] no relays available -- miners cannot connect yet\n");
676 // Spawn one listener per relay, only if none already active 1259 } else {
677 for (const string& relay : relays) { 1260 for (const string& relay : relays) {
678 std::lock_guard<std::mutex> lk(gBtfListenerMutex); 1261 if (spawned.count(relay)) continue; // already running
679 if (gBtfListenerCount[relay] == 0) { 1262 AcceptCtx* ctx = new AcceptCtx();
680 gBtfListenerCount[relay]++;
681 BtfAcceptCtx* ctx = new BtfAcceptCtx();
682 memcpy(ctx->pk, pk, 32); memcpy(ctx->sk, sk, 32); 1263 memcpy(ctx->pk, pk, 32); memcpy(ctx->sk, sk, 32);
683 ctx->relay = relay; 1264 ctx->relay = relay;
684 LaunchThread(BtfStratumAcceptOneFn, ctx); 1265 uintptr_t tid = _beginthread(AcceptOneFn, 0, ctx);
1266 if (tid == (uintptr_t)-1) {
1267 delete ctx;
1268 LogPrint("pool", "[pool] failed to spawn accept thread for %s\n",
1269 relay.c_str());
1270 } else {
1271 spawned.insert(relay);
1272 LogPrint("pool", "[pool] accept thread spawned for relay %s"
1273 " (%zu total)\n", relay.c_str(), spawned.size());
1274 }
685 } 1275 }
686 } 1276 }
687 for (int i = 0; i < 30 && !fShutdown && gPoolRunning; i++) Sleep(1000); 1277 // Re-poll every 60s -- newly discovered relays get picked up
1278 for (int i = 0; i < 60 && !fShutdown && gPoolRunning; i++) Sleep(1000);
688 } 1279 }
689} 1280}
1281
1282// ---------------------------------------------------------------------------
1283// Entry point
1284// ---------------------------------------------------------------------------
690void ThreadRPCServer(void*) 1285void ThreadRPCServer(void*)
691{ 1286{
692 gPoolRunning = true; 1287 gPoolRunning = true;
693 printf("Bitflash pool server starting\n");
694 printf(" Stratum .btf: %s (no port forwarding needed)\n",
695 BtfLocalAddress().c_str());
696 printf(" Mode: operator\n");
697 1288
698 if (RebuildJob()) 1289 LogPrint("pool", "[pool] ============================================\n");
699 printf("Stratum: initial job seeded height=%d\n", nBestHeight + 1); 1290 LogPrint("pool", "[pool] Pool server starting\n");
1291 LogPrint("pool", "[pool] .btf address: %s\n", BtfLocalAddress().c_str());
1292 LogPrint("pool", "[pool] fee: %.2f%%\n", dPoolFeePercent);
1293
1294 if (RebuildJob()) {
1295 LogPrint("pool", "[pool] initial job %s seeded at height %d\n",
1296 gCurrentJob.jobId.c_str(), nBestHeight + 1);
1297 } else {
1298 LogPrint("pool", "[pool] no chain tip yet -- job will build on first block\n");
1299 }
1300
1301 LoadPendingPayouts();
1302
1303 _beginthread(AcceptLoopFn, 0, NULL);
1304 _beginthread(ThreadBtfPoolAnnouncer, 0, NULL);
1305 _beginthread(PayoutThreadFn, 0, NULL);
700 1306
701 LaunchThread(JobWatcherFn, NULL); 1307 PoolEventLoop(); // blocks until gPoolRunning = false or fShutdown
702 LaunchThread(BtfStratumAcceptFn, NULL);
703 LaunchThread(ThreadBtfPoolAnnouncer, NULL);
704 1308
705 while (!fShutdown && gPoolRunning) 1309 LogPrint("pool", "[pool] pool server stopped\n");
706 Sleep(1000); 1310 LogPrint("pool", "[pool] ============================================\n");
707 printf("Pool server stopped\n"); 1311 gPoolRunning = false;
708} 1312}