-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathstratum_server.cpp
More file actions
1716 lines (1550 loc) · 75.9 KB
/
Copy pathstratum_server.cpp
File metadata and controls
1716 lines (1550 loc) · 75.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-License-Identifier: AGPL-3.0-or-later
#include "stratum_server.hpp"
#include "web_server.hpp" // MiningInterface
#include "address_utils.hpp" // address utilities
#include <core/hash.hpp>
#include <core/target_utils.hpp>
#include <btclibs/util/strencodings.h>
#include <crypto/scrypt.h>
#include <iomanip>
#include <sstream>
#include <ctime>
#include <chrono>
#include <cmath>
#include <cstring>
#include <boost/algorithm/string.hpp>
namespace core {
// ─── RateMonitor implementation (p2pool: util.math.RateMonitor) ───
void RateMonitor::prune_locked() {
double start_time = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count() - max_lookback_time_;
while (!datums_.empty() && datums_.front().timestamp <= start_time)
datums_.pop_front();
}
void RateMonitor::add_datum(double work, const std::array<uint8_t, 20>& pubkey_hash,
const std::string& user, bool dead) {
std::lock_guard<std::mutex> lock(mutex_);
prune_locked();
double t = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (first_timestamp_ == 0.0) {
// p2pool: first datum sets first_timestamp but is NOT added to datums
first_timestamp_ = t;
} else {
datums_.push_back({t, work, pubkey_hash, user, dead});
}
}
std::pair<std::vector<RateMonitor::Datum>, double>
RateMonitor::get_datums_in_last(double dt) const {
std::lock_guard<std::mutex> lock(mutex_);
const_cast<RateMonitor*>(this)->prune_locked();
if (dt <= 0) dt = max_lookback_time_;
double now = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count();
double cutoff = now - dt;
std::vector<Datum> result;
for (const auto& d : datums_) {
if (d.timestamp > cutoff)
result.push_back(d);
}
double effective_dt = (first_timestamp_ > 0.0)
? std::min(dt, now - first_timestamp_)
: 0.0;
return {std::move(result), effective_dt};
}
/// Static member definition
std::atomic<uint64_t> StratumSession::job_counter_{0};
StratumServer::StratumServer(net::io_context& ioc, const std::string& address, uint16_t port, std::shared_ptr<IWorkSource> mining_interface)
: ioc_(ioc)
, acceptor_(ioc)
, mining_interface_(mining_interface)
, bind_address_(address)
, port_(port)
, running_(false)
{
}
StratumServer::~StratumServer()
{
stop();
}
bool StratumServer::start()
{
try {
auto const address = net::ip::make_address(bind_address_);
tcp::endpoint endpoint{address, port_};
acceptor_.open(endpoint.protocol());
acceptor_.set_option(net::socket_base::reuse_address(true));
acceptor_.bind(endpoint);
acceptor_.listen(net::socket_base::max_listen_connections);
running_ = true;
accept_connections();
LOG_INFO << "StratumServer started on " << bind_address_ << ":" << port_;
return true;
} catch (const std::exception& e) {
LOG_ERROR << "Failed to start StratumServer: " << e.what();
return false;
}
}
void StratumServer::stop()
{
if (!running_) return;
try {
// Stop accepting new connections first — no more sessions can spawn
// after this point, so the snapshot below is exhaustive.
boost::system::error_code ec;
acceptor_.cancel(ec);
acceptor_.close(ec);
running_ = false;
// Snapshot + clear the live-sessions set under the mutex, then close
// each one OUTSIDE the lock. cancel_timers() does asio operations
// (timer.cancel + socket.close) that should not run with our
// sessions_mutex_ held — the read-error handler will fire on the
// io_context thread and try to acquire sessions_mutex_ via
// unregister_session.
std::set<std::shared_ptr<StratumSession>> snapshot;
{
std::lock_guard<std::mutex> lk(sessions_mutex_);
snapshot = std::move(sessions_);
sessions_.clear();
}
for (auto& session : snapshot) {
try {
session->shutdown();
} catch (const std::exception& e) {
LOG_WARNING << "StratumSession shutdown threw: " << e.what();
}
}
LOG_INFO << "StratumServer stopped (closed " << snapshot.size()
<< " active session" << (snapshot.size() == 1 ? "" : "s") << ")";
} catch (const std::exception& e) {
LOG_ERROR << "Error stopping StratumServer: " << e.what();
}
}
void StratumServer::accept_connections()
{
acceptor_.async_accept(
[this](boost::system::error_code ec, tcp::socket socket)
{
handle_accept(ec, std::move(socket));
});
}
void StratumServer::handle_accept(boost::system::error_code ec, tcp::socket socket)
{
if (!ec) {
// ── STRICT per-node miner cap (hotel interim fix #5) ──
// Admission control BEFORE register_session: if the node already holds
// max_stratum_connections live sessions, close the new socket cleanly,
// WARN, bump the refused counter, and keep accepting. 0 = unlimited.
// Dead sessions are normally pruned lazily in notify_all(); prune here
// too when at cap so lingering closed sockets never starve real miners.
const size_t cap = mining_interface_
? mining_interface_->get_stratum_config().max_stratum_connections
: 0;
size_t live = 0;
if (cap > 0) {
std::lock_guard<std::mutex> lock(sessions_mutex_);
if (sessions_.size() >= cap) {
for (auto it = sessions_.begin(); it != sessions_.end(); ) {
if (!(*it)->is_connected())
it = sessions_.erase(it);
else
++it;
}
}
live = sessions_.size();
}
if (cap > 0 && live >= cap) {
refused_connections_.fetch_add(1);
boost::system::error_code ep_ec;
const auto ep = socket.remote_endpoint(ep_ec);
LOG_WARNING << "Stratum connection refused: node at miner cap "
<< live << "/" << cap << " (max_stratum_connections)"
<< (ep_ec ? std::string{} : " peer=" + ep.address().to_string()
+ ":" + std::to_string(ep.port()))
<< " refused_total=" << refused_connections_.load();
boost::system::error_code ignore;
socket.shutdown(tcp::socket::shutdown_both, ignore);
socket.close(ignore);
} else {
// Create, register, and start Stratum session
auto session = std::make_shared<StratumSession>(std::move(socket), mining_interface_, this);
register_session(session);
session->start();
}
} else {
LOG_ERROR << "Stratum accept error: " << ec.message();
}
// Continue accepting new connections
if (running_) {
accept_connections();
}
}
void StratumServer::register_session(std::shared_ptr<StratumSession> s)
{
std::lock_guard<std::mutex> lock(sessions_mutex_);
sessions_.insert(std::move(s));
}
void StratumServer::unregister_session(std::shared_ptr<StratumSession> s)
{
std::lock_guard<std::mutex> lock(sessions_mutex_);
sessions_.erase(s);
}
size_t StratumServer::get_session_count() const
{
std::lock_guard<std::mutex> lock(sessions_mutex_);
return sessions_.size();
}
std::pair<size_t, size_t> StratumServer::get_job_payload_stats() const
{
// Snapshot sessions under the mutex, count outside it (session job maps
// are io-thread state — see header comment: call quiesced).
std::vector<std::shared_ptr<StratumSession>> snapshot;
{
std::lock_guard<std::mutex> lock(sessions_mutex_);
snapshot.assign(sessions_.begin(), sessions_.end());
}
size_t distinct = 0, total = 0;
for (const auto& s : snapshot) {
distinct += s->distinct_job_payloads();
total += s->active_job_count();
}
return {distinct, total};
}
double StratumServer::get_total_hashrate() const
{
// Sum all hashrate from the addr rate monitor (consistent with get_local_addr_rates)
auto [datums, dt] = local_addr_rate_monitor_.get_datums_in_last();
if (dt <= 0) return 0.0;
double total_work = 0.0;
for (const auto& d : datums)
total_work += d.work;
return total_work / dt;
}
AddrRateMap StratumServer::get_local_addr_rates() const
{
// p2pool: get_local_addr_rates() with 2-second cache (work.py:1975-1990).
// Uses RateMonitor (records ALL pseudoshares at VARDIFF target) instead of
// summing per-session hashrate (which only counted pool-quality shares).
double now = std::chrono::duration<double>(
std::chrono::system_clock::now().time_since_epoch()).count();
{
std::lock_guard<std::mutex> lock(cache_mutex_);
if (now - addr_rates_cache_ts_ < 2.0)
return addr_rates_cache_;
}
auto [datums, dt] = local_addr_rate_monitor_.get_datums_in_last();
AddrRateMap rates;
if (dt > 0) {
for (const auto& d : datums)
rates[d.pubkey_hash] += d.work / dt;
}
{
std::lock_guard<std::mutex> lock(cache_mutex_);
addr_rates_cache_ = rates;
addr_rates_cache_ts_ = now;
}
return rates;
}
std::pair<std::unordered_map<std::string, double>,
std::unordered_map<std::string, double>>
StratumServer::get_local_rates() const
{
// p2pool: get_local_rates() (work.py:1965-1973)
auto [datums, dt] = local_rate_monitor_.get_datums_in_last();
std::unordered_map<std::string, double> hash_rates, dead_hash_rates;
if (dt > 0) {
for (const auto& d : datums) {
hash_rates[d.user] += d.work / dt;
if (d.dead)
dead_hash_rates[d.user] += d.work / dt;
}
}
return {hash_rates, dead_hash_rates};
}
StratumServer::RateStats StratumServer::get_rate_stats() const
{
auto [datums, dt] = local_rate_monitor_.get_datums_in_last();
RateStats stats;
stats.effective_dt = dt;
stats.total_datums = static_cast<int>(datums.size());
if (dt > 0) {
double total_work = 0;
for (const auto& d : datums) {
total_work += d.work;
if (d.dead) ++stats.dead_datums;
}
stats.hashrate = total_work / dt;
}
return stats;
}
void StratumServer::record_pseudoshare(double work, const std::array<uint8_t, 20>& pubkey_hash,
const std::string& user, bool dead)
{
local_rate_monitor_.add_datum(work, pubkey_hash, user, dead);
local_addr_rate_monitor_.add_datum(work, pubkey_hash, user, dead);
}
void StratumServer::notify_all()
{
// ── Lock hierarchy: m_work_mutex (1) > sessions_mutex_ (2) ──
// send_notify_work() acquires m_work_mutex internally.
// We must NOT hold sessions_mutex_ while calling it, otherwise:
// Thread A: sessions_mutex_ → m_work_mutex (here)
// Thread B: m_work_mutex → sessions_mutex_ (build_connection_coinbase → get_local_addr_rates)
// = ABBA deadlock.
//
// Pattern: snapshot session shared_ptrs under lock, release, then notify.
// Sessions are shared_ptr so they stay alive even if unregistered mid-iteration.
// Freeze best_share ONCE for the entire cycle — matches p2pool's single-threaded
// reactor where best_share_var.value can't change during a _send_work() loop.
// Without this, a share arriving mid-iteration causes each subsequent miner to
// see a different best_share → triggers PPLNS recomputation per miner.
uint256 frozen_best;
if (auto fn = mining_interface_->get_best_share_hash_fn())
frozen_best = fn();
std::vector<std::shared_ptr<StratumSession>> snapshot;
{
std::lock_guard<std::mutex> lock(sessions_mutex_);
snapshot.reserve(sessions_.size());
for (auto& s : sessions_)
snapshot.push_back(s);
// Prune dead sessions while we hold the lock
for (auto it = sessions_.begin(); it != sessions_.end(); ) {
if (!(*it)->is_connected())
it = sessions_.erase(it);
else
++it;
}
}
// sessions_mutex_ RELEASED — safe to call into m_work_mutex territory
for (auto& s : snapshot) {
try {
if (s->is_connected() && s->is_subscribed())
s->send_notify_work(true, &frozen_best); // clean_jobs=true: miner switches to new prev_share immediately (p2pool behavior)
} catch (...) {}
}
}
/// StratumSession Implementation
StratumSession::StratumSession(tcp::socket socket, std::shared_ptr<IWorkSource> mining_interface,
StratumServer* server)
: socket_(std::move(socket))
, mining_interface_(mining_interface)
, server_(server)
, connected_at_(std::chrono::steady_clock::now())
, work_push_timer_(socket_.get_executor())
{
subscription_id_ = generate_subscription_id();
extranonce1_ = generate_extranonce1();
session_id_ = subscription_id_; // unique session key
// Apply stratum tuning from MiningInterface config (populated from CLI/YAML)
const auto& cfg = mining_interface_->get_stratum_config();
hashrate_tracker_.set_difficulty_bounds(cfg.min_difficulty, cfg.max_difficulty);
hashrate_tracker_.set_target_time_per_mining_share(cfg.target_time);
if (cfg.vardiff_enabled)
hashrate_tracker_.enable_vardiff();
}
void StratumSession::start()
{
boost::system::error_code ec;
auto ep = socket_.remote_endpoint(ec);
if (ec) return; // socket closed before start() was dispatched
LOG_INFO << "StratumSession started for client: " << ep;
read_message();
}
std::string StratumSession::generate_subscription_id()
{
static std::atomic<uint64_t> subscription_counter{0};
return "sub_" + std::to_string(subscription_counter.fetch_add(1));
}
void StratumSession::read_message()
{
auto self = shared_from_this();
boost::asio::async_read_until(socket_, buffer_, '\n',
[self](boost::system::error_code ec, std::size_t bytes_read)
{
if (!ec) {
self->process_message(bytes_read);
self->read_message(); // Continue reading
} else {
// Session disconnected — cancel timers to break prevent zombie callbacks,
// then unregister from worker tracking.
self->cancel_timers();
if (self->authorized_ && self->mining_interface_) {
self->mining_interface_->unregister_stratum_worker(self->session_id_);
}
LOG_INFO << "StratumSession ended: " << ec.message();
}
});
}
void StratumSession::process_message(std::size_t bytes_read)
{
try {
std::istream is(&buffer_);
std::string line;
std::getline(is, line);
if (!line.empty() && line.back() == '\r') {
line.pop_back(); // Remove \r if present
}
LOG_TRACE << "[Stratum] Received: " << line;
auto request = nlohmann::json::parse(line);
std::string method = request.value("method", "");
auto params = request.value("params", nlohmann::json::array());
auto id = request.value("id", nlohmann::json{});
nlohmann::json response;
if (method == "mining.subscribe") {
response = handle_subscribe(params, id);
} else if (method == "mining.authorize") {
response = handle_authorize(params, id);
} else if (method == "mining.submit") {
response = handle_submit(params, id);
} else if (method == "mining.configure") {
response = handle_configure(params, id);
} else if (method == "mining.extranonce.subscribe") {
response = handle_extranonce_subscribe(params, id);
} else if (method == "mining.suggest_difficulty") {
response = handle_suggest_difficulty(params, id);
} else if (method == "mining.set_merged_addresses") {
response = handle_set_merged_addresses(params, id);
} else {
// Unknown method
send_error(-1, "Unknown method", id);
return;
}
send_response(response);
// After subscribe response is sent, follow up with difficulty + work.
// p2pool: pseudoshare difficulty starts low and VARDIFF ramps it up.
// Do NOT floor at pool share_bits — that defeats VARDIFF.
if (method == "mining.subscribe") {
double initial_diff = (suggested_difficulty_ > 0.0)
? suggested_difficulty_
: hashrate_tracker_.get_current_difficulty();
send_set_difficulty(initial_diff);
send_notify_work();
}
} catch (const std::exception& e) {
LOG_ERROR << "Error processing Stratum message: " << e.what();
send_error(-2, "Invalid JSON", nlohmann::json{});
}
}
nlohmann::json StratumSession::handle_subscribe(const nlohmann::json& params, const nlohmann::json& request_id)
{
subscribed_ = true;
nlohmann::json response;
response["id"] = request_id;
response["result"] = nlohmann::json::array({
nlohmann::json::array({
nlohmann::json::array({"mining.set_difficulty", subscription_id_}),
nlohmann::json::array({"mining.notify", subscription_id_})
}),
extranonce1_,
4 // extranonce2_size = 4 bytes
});
response["error"] = nullptr;
LOG_INFO << "Mining subscription successful for: " << subscription_id_;
// NOTE: set_difficulty + notify are sent from process_message()
// AFTER this response is written, so the miner gets the subscribe
// reply (with extranonce1) before any work notifications.
return response;
}
nlohmann::json StratumSession::handle_authorize(const nlohmann::json& params, const nlohmann::json& request_id)
{
if (params.size() >= 1 && params[0].is_string()) {
username_ = params[0];
authorized_ = true;
// ─── Step 1: Strip fixed difficulty suffix (+N) before any parsing ───
// Format: "ADDRESS+1024" or "ADDR,ADDR+512" → suggested_difficulty_=N
{
auto plus_pos = username_.rfind('+');
if (plus_pos != std::string::npos && plus_pos + 1 < username_.size()) {
std::string diff_str = username_.substr(plus_pos + 1);
try {
double fixed_diff = std::stod(diff_str);
if (fixed_diff > 0.0) {
suggested_difficulty_ = fixed_diff;
hashrate_tracker_.set_difficulty_hint(fixed_diff);
LOG_INFO << "[Stratum] Fixed difficulty from username: " << fixed_diff;
}
} catch (...) {}
username_ = username_.substr(0, plus_pos);
}
}
// ─── Step 2: Strip worker name suffix ───
// Separators: "." and "_" (Python compat)
// Worker name is always AFTER the last address, so strip from the end.
auto dot_pos = username_.rfind('.');
if (dot_pos != std::string::npos && dot_pos > 20) {
worker_name_ = username_.substr(dot_pos + 1);
username_ = username_.substr(0, dot_pos);
}
auto underscore_pos = username_.rfind('_');
if (underscore_pos != std::string::npos && underscore_pos > 20) {
if (worker_name_.empty())
worker_name_ = username_.substr(underscore_pos + 1);
username_ = username_.substr(0, underscore_pos);
}
// ─── Step 3: Parse multi-chain addresses ───
// Supported separator formats for merged mining addresses:
// Slash+colon: PRIMARY/CHAIN_ID:ADDR/CHAIN_ID:ADDR (explicit chain IDs)
// Comma: PRIMARY,MERGED_ADDR (standard Stratum)
// Pipe: PRIMARY|MERGED_ADDR (Vnish firmware)
// Space: PRIMARY MERGED_ADDR (some web UIs)
// Semicolon: PRIMARY;MERGED_ADDR (alternative separator)
//
// Vnish firmware and some ASIC control software cannot use commas in the
// login field, so we support pipe (|) and space as alternatives.
// ─── Merged chain address identification table ───
// Each entry: {chain_id, bech32_hrps, base58_version_bytes}
// Used to auto-detect which chain a merged address belongs to.
struct ChainAddrInfo {
uint32_t chain_id;
std::vector<std::string> hrps;
std::vector<uint8_t> versions;
};
static const std::vector<ChainAddrInfo> MERGED_CHAINS = {
// DOGE: D... (0x1e), 9/A... (0x16), testnet n... (0x71)
{98, {}, {0x1e, 0x16, 0x71}},
// PEP: P... (0x38), testnet n... (0x71)
{63, {}, {0x38, 0x16, 0x71}},
// BELLS: B... (0x19), bech32 "bel"/"tbel"
{16, {"bel", "tbel"}, {0x19, 0x1e, 0x21}},
// LKY: L... (0x2f), testnet n... (0x71)
{8211, {}, {0x2f, 0x05, 0x71}},
// JKC: 7... (0x10), testnet m/n... (0x6f)
{8224, {}, {0x10, 0x05, 0x6f}},
// SHIC: S... (0x3f), testnet n... (0x71)
{74, {}, {0x3f, 0x16, 0x71}},
// DINGO: same versions as DOGE (fork), chain_id=98 (conflicts with DOGE!)
// Cannot be used simultaneously with DOGE. Omitted from auto-detect.
};
// LTC identification (for swap detection)
static const std::vector<std::string> LTC_HRPS = {"ltc", "tltc"};
static const std::vector<uint8_t> LTC_VERSIONS = {0x30, 0x32, 0x05, 0x6f, 0xc4, 0x3a};
std::string merged_addr_raw;
parse_address_separators(username_, merged_addr_raw);
// Resolve simple-separator merged address to chain_id
if (!merged_addr_raw.empty()) {
bool resolved = false;
// Try each configured merged chain
for (const auto& chain : MERGED_CHAINS) {
if (mining_interface_ && mining_interface_->has_merged_chain(chain.chain_id) &&
is_address_for_chain(merged_addr_raw, chain.hrps, chain.versions)) {
merged_addresses_[chain.chain_id] = merged_addr_raw;
resolved = true;
break;
}
}
// Swap detection: first addr matches a merged chain, second is LTC
if (!resolved) {
for (const auto& chain : MERGED_CHAINS) {
if (mining_interface_ && mining_interface_->has_merged_chain(chain.chain_id) &&
is_address_for_chain(username_, chain.hrps, chain.versions) &&
is_address_for_chain(merged_addr_raw, LTC_HRPS, LTC_VERSIONS)) {
LOG_WARNING << "[Stratum] Detected swapped address order — auto-correcting.";
std::string merged_addr = username_;
username_ = merged_addr_raw;
merged_addresses_[chain.chain_id] = merged_addr;
resolved = true;
break;
}
}
}
// Fallback: assign to first configured merged chain
if (!resolved) {
std::string mtype;
auto mh = address_to_hash160(merged_addr_raw, mtype);
if (mh.size() == 40 && mining_interface_) {
for (const auto& chain : MERGED_CHAINS) {
if (mining_interface_->has_merged_chain(chain.chain_id)) {
merged_addresses_[chain.chain_id] = merged_addr_raw;
LOG_INFO << "[Stratum] Assigned merged address to chain_id=" << chain.chain_id;
resolved = true;
break;
}
}
}
}
}
if (!merged_addresses_.empty())
LOG_INFO << "[Stratum] Merged addresses from login: " << merged_addresses_.size() << " chain(s)";
// Case 5: primary address belongs to a merged chain (e.g., DOGE), not LTC.
// Common when miners enter their DOGE address without a comma separator.
// The pubkey_hash is preserved — LTC payout uses P2PKH with same hash160.
if (merged_addr_raw.empty() && !username_.empty() && mining_interface_) {
bool is_ltc = is_address_for_chain(username_, LTC_HRPS, LTC_VERSIONS);
if (!is_ltc) {
for (const auto& chain : MERGED_CHAINS) {
if (mining_interface_->has_merged_chain(chain.chain_id) &&
is_address_for_chain(username_, chain.hrps, chain.versions)) {
merged_addresses_[chain.chain_id] = username_;
LOG_WARNING << "[Stratum] Case 5: Primary address is chain_id="
<< chain.chain_id << " (not LTC) — LTC payout will use "
<< "reverse-derived P2PKH from same pubkey_hash";
break;
}
}
}
}
LOG_INFO << "[Stratum] Mining authorization successful for: " << username_;
// Auto-derive: for each configured merged chain without an explicit address,
// reuse the primary address hash160 (works because chains share secp256k1 keys).
if (mining_interface_) {
std::string atype;
auto h160 = address_to_hash160(username_, atype);
// Store pubkey_hash for per-address hashrate aggregation
// p2pool: get_local_addr_rates uses pubkey_hash as key
if (h160.size() == 40) {
auto h160_bytes = ParseHex(h160);
if (h160_bytes.size() == 20)
std::copy(h160_bytes.begin(), h160_bytes.end(), pubkey_hash_.begin());
}
if (h160.size() == 40 && (atype == "p2pkh" || atype == "p2wpkh" || atype == "p2sh")) {
for (const auto& chain : MERGED_CHAINS) {
if (mining_interface_->has_merged_chain(chain.chain_id) &&
merged_addresses_.find(chain.chain_id) == merged_addresses_.end()) {
merged_addresses_[chain.chain_id] = username_;
LOG_INFO << "[Stratum] Auto-derived merged address for chain_id="
<< chain.chain_id << " from primary " << atype << " address";
}
}
}
// Cases 7-9: P2WSH/P2TR addresses have 32-byte witness programs that
// cannot be converted to 20-byte hash160 for merged chain addresses.
else if (atype == "p2wsh" || atype == "p2tr") {
LOG_WARNING << "[Stratum] Primary address is " << atype
<< " — cannot auto-derive merged mining addresses "
<< "(non-convertible witness program)";
}
}
// Resend work after a successful authorize. The job sent right after
// subscribe was built before authorize ran, so it carried neither the
// miner's primary payout address (username_ was empty -> empty
// payout_script -> degenerate value-0 OP_RETURN coinbase) nor any merged
// addresses. Two distinct primary classes need this resend, and NEITHER
// populates merged_addresses_:
// (1) Standalone parent coins (BCH/BTC, no merged chains) never
// populate merged_addresses_ at all.
// (2) A P2WSH/P2TR primary (Cases 7-9) carries a 32-byte witness
// program that cannot reduce to a 20-byte hash160, so the
// auto-derive above is skipped and merged_addresses_ stays empty
// even for a merged coin (LTC/DOGE) whose username_ IS set.
// Gating the resend on merged_addresses_ alone left BOTH classes mining
// the payout-less coinbase -> BCHN bad-txns/BIP30 on a won block. Resend
// whenever the now-known username_ or merged addrs can change the
// coinbase: address_to_script(username_) yields the real witness/script
// payout for (2) exactly as it yields the P2PKH payout for (1).
// force_clean so the miner drops the stale job.
if (mining_interface_ && (!username_.empty() || !merged_addresses_.empty())) {
send_notify_work(true);
}
// Start periodic work push (every SHARE_PERIOD) to keep miner on fresh work
start_periodic_work_push();
// Register this session in the worker tracker
if (mining_interface_) {
MiningInterface::WorkerInfo wi;
wi.username = username_;
wi.worker_name = worker_name_;
wi.difficulty = hashrate_tracker_.get_current_difficulty();
wi.connected_at = connected_at_;
try {
wi.remote_endpoint = socket_.remote_endpoint().address().to_string()
+ ":" + std::to_string(socket_.remote_endpoint().port());
} catch (...) {}
mining_interface_->register_stratum_worker(session_id_, wi);
}
nlohmann::json response;
response["id"] = request_id;
response["result"] = true;
response["error"] = nullptr;
return response;
} else {
nlohmann::json response;
response["id"] = request_id;
response["result"] = false;
response["error"] = nlohmann::json::array({21, "Invalid username", nullptr});
return response;
}
}
// ═══════════════════════════════════════════════════════════════════
// BIP 310: mining.configure — extension negotiation
// ═══════════════════════════════════════════════════════════════════
// params: [["version-rolling", "subscribe-extranonce", ...], {"version-rolling.mask": "1fffe000", ...}]
// Returns: {"version-rolling": true, "version-rolling.mask": "1fffe000", "subscribe-extranonce": true}
//
// Coinbase safety: version-rolling only touches block header version bits.
// subscribe-extranonce enables mining.set_extranonce notifications —
// extranonce1/2 are in the OP_RETURN output (last 8 bytes), completely
// separate from the scriptSig where merged mining markers (fabe6d6d) live.
nlohmann::json StratumSession::handle_configure(const nlohmann::json& params, const nlohmann::json& request_id)
{
nlohmann::json result = nlohmann::json::object();
if (params.size() < 2 || !params[0].is_array() || !params[1].is_object()) {
nlohmann::json response;
response["id"] = request_id;
response["result"] = result;
response["error"] = nullptr;
return response;
}
const auto& extensions = params[0];
const auto& ext_params = params[1];
for (const auto& ext : extensions) {
if (!ext.is_string()) continue;
std::string ext_name = ext.get<std::string>();
if (ext_name == "version-rolling") {
// Miner provides its mask and optional min-bit-count
std::string miner_mask_hex = "ffffffff";
if (ext_params.contains("version-rolling.mask") && ext_params["version-rolling.mask"].is_string())
miner_mask_hex = ext_params["version-rolling.mask"].get<std::string>();
uint32_t miner_mask = 0;
try {
miner_mask = static_cast<uint32_t>(std::stoul(miner_mask_hex, nullptr, 16));
} catch (...) {
LOG_WARNING << "[Stratum] Invalid version-rolling.mask from miner: " << miner_mask_hex;
miner_mask = 0;
}
// Negotiated mask = intersection of pool and miner masks
version_rolling_mask_ = POOL_VERSION_MASK & miner_mask;
version_rolling_enabled_ = true;
std::ostringstream mask_ss;
mask_ss << std::hex << std::setw(8) << std::setfill('0') << version_rolling_mask_;
result["version-rolling"] = true;
result["version-rolling.mask"] = mask_ss.str();
LOG_INFO << "[Stratum] Version-rolling enabled, negotiated mask: " << mask_ss.str()
<< " (pool=" << std::hex << POOL_VERSION_MASK
<< ", miner=" << miner_mask << ")" << std::dec;
} else if (ext_name == "subscribe-extranonce") {
// BIP 310 extranonce subscription — enables mining.set_extranonce notifications
extranonce_subscribe_ = true;
result["subscribe-extranonce"] = true;
LOG_INFO << "[Stratum] Extranonce subscription enabled (BIP 310)";
} else if (ext_name == "minimum-difficulty") {
// Optional extension — not implemented but acknowledge it
result["minimum-difficulty"] = false;
}
}
nlohmann::json response;
response["id"] = request_id;
response["result"] = result;
response["error"] = nullptr;
return response;
}
// ═══════════════════════════════════════════════════════════════════
// NiceHash: mining.extranonce.subscribe — extranonce change subscription
// ═══════════════════════════════════════════════════════════════════
// params: [] (no params)
// Returns: true
// After this, the server may send mining.set_extranonce notifications.
nlohmann::json StratumSession::handle_extranonce_subscribe(const nlohmann::json& params, const nlohmann::json& request_id)
{
extranonce_subscribe_ = true;
LOG_INFO << "[Stratum] Extranonce subscription enabled (NiceHash protocol)";
nlohmann::json response;
response["id"] = request_id;
response["result"] = true;
response["error"] = nullptr;
return response;
}
// ═══════════════════════════════════════════════════════════════════
// mining.suggest_difficulty — miner suggests initial difficulty
// ═══════════════════════════════════════════════════════════════════
// params: [difficulty] (number)
// Returns: true
// The pool may use this as a hint for the initial set_difficulty.
// If received BEFORE subscribe, we use it as starting difficulty.
// If received AFTER, it feeds into VARDIFF as a hint.
nlohmann::json StratumSession::handle_suggest_difficulty(const nlohmann::json& params, const nlohmann::json& request_id)
{
double suggested = 0.0;
if (!params.empty()) {
if (params[0].is_number())
suggested = params[0].get<double>();
else if (params[0].is_string()) {
try { suggested = std::stod(params[0].get<std::string>()); } catch (...) {}
}
}
if (suggested > 0.0) {
// Miners send wire difficulty scaled by the per-network multiplier
// (p2pool net.DUMB_SCRYPT_DIFF): 2^16 for scrypt nets (LTC/DOGE), 1 for
// SHA256d nets (bitcoin). Invert the SAME config-driven factor used by
// send_set_difficulty() to recover internal difficulty -- otherwise a
// SHA256d miner suggestion is divided by 65536 and collapses to ~0.
const double diff_multiplier =
mining_interface_->get_stratum_config().set_difficulty_multiplier;
double internal_diff = suggested / diff_multiplier;
suggested_difficulty_ = internal_diff;
// If already subscribed, apply immediately via VARDIFF hint
if (subscribed_) {
hashrate_tracker_.set_difficulty_hint(internal_diff);
send_set_difficulty(hashrate_tracker_.get_current_difficulty());
}
LOG_INFO << "[Stratum] Miner suggested difficulty: " << suggested
<< " (internal: " << internal_diff << ")";
}
nlohmann::json response;
response["id"] = request_id;
response["result"] = true;
response["error"] = nullptr;
return response;
}
// ═══════════════════════════════════════════════════════════════════
// mining.set_extranonce notification (server → client)
// ═══════════════════════════════════════════════════════════════════
// Sent to miners that subscribed via mining.configure(subscribe-extranonce)
// or mining.extranonce.subscribe.
//
// Coinbase safety analysis:
// - extranonce1/2 occupy the LAST 8 bytes of the OP_RETURN output
// - They are NOT in the scriptSig (where merged mining fabe6d6d markers live)
// - Changing extranonce1 requires rebuilding coinb1/coinb2 (which includes ref_hash)
// - We MUST send clean_jobs=true after set_extranonce to invalidate old jobs
// - The atomic counter in generate_extranonce1() prevents collisions
void StratumSession::send_set_extranonce(const std::string& extranonce1, int extranonce2_size)
{
nlohmann::json notification;
notification["id"] = nullptr;
notification["method"] = "mining.set_extranonce";
notification["params"] = nlohmann::json::array({extranonce1, extranonce2_size});
send_response(notification);
}
// mining.set_merged_addresses extension
// params: [{ "chain_id": "address", ... }] — keys are chain_id as strings
// Example: [{"98": "DQkwFoo...", "2": "1btcAddr..."}]
nlohmann::json StratumSession::handle_set_merged_addresses(const nlohmann::json& params, const nlohmann::json& request_id)
{
if (params.empty() || !params[0].is_object()) {
nlohmann::json response;
response["id"] = request_id;
response["result"] = false;
response["error"] = nlohmann::json::array({20, "Expected object param {chain_id: address}", nullptr});
return response;
}
for (auto& [key, val] : params[0].items()) {
if (val.is_string()) {
try {
uint32_t chain_id = static_cast<uint32_t>(std::stoul(key));
merged_addresses_[chain_id] = val.get<std::string>();
} catch (...) {
// skip malformed
}
}
}
LOG_INFO << "Set merged addresses for " << username_ << ": " << merged_addresses_.size() << " chain(s)";
nlohmann::json response;
response["id"] = request_id;
response["result"] = true;
response["error"] = nullptr;
return response;
}
nlohmann::json StratumSession::handle_submit(const nlohmann::json& params, const nlohmann::json& request_id)
{
if (!authorized_) {
nlohmann::json response;
response["id"] = request_id;
response["result"] = false;
response["error"] = nlohmann::json::array({24, "Unauthorized", nullptr});
return response;
}
// Extract Stratum submit parameters: [username, job_id, extranonce2, ntime, nonce]
if (params.size() < 5 || !params[1].is_string() || !params[2].is_string()
|| !params[3].is_string() || !params[4].is_string()) {
++rejected_shares_;
nlohmann::json response;
response["id"] = request_id;
response["result"] = false;
response["error"] = nlohmann::json::array({20, "Invalid parameters", nullptr});
return response;
}
std::string job_id = params[1].get<std::string>();
std::string extranonce2 = params[2].get<std::string>();
std::string ntime = params[3].get<std::string>();
std::string nonce = params[4].get<std::string>();
// ASICBoost: optional 6th parameter is version_bits (hex mask of rolled bits)
std::string version_bits;
if (params.size() >= 6 && params[5].is_string())
version_bits = params[5].get<std::string>();
// Stale detection: check if job_id is still active.
auto job_it = active_jobs_.find(job_id);
bool is_stale = (job_it == active_jobs_.end());
if (is_stale) {
++stale_shares_;
LOG_INFO << "[Stratum] Stale share from " << username_ << " for expired job " << job_id
<< " (active_jobs=" << active_jobs_.size() << ")";
// Job data evicted — can't reconstruct block or share.
// Return stale response to the miner.
nlohmann::json response;
response["id"] = request_id;
response["result"] = false;
response["error"] = nlohmann::json::array({21, "Stale share", nullptr});
return response;
}
// DOA detection: if GBT previousblockhash changed since job creation,
// this share is for an old block. Track for statistics but do NOT modify
// job.stale_info — that field is part of ref_hash (frozen at template time).
// Changing it at submit time would break ref_hash consistency → GENTX-FAIL.
// p2pool computes stale_info at get_work() time (job creation), not at submit.
{
auto current_prevhash = mining_interface_->get_current_gbt_prevhash();
if (!current_prevhash.empty() && !job_it->second.gbt_prevhash.empty()
&& current_prevhash != job_it->second.gbt_prevhash)
{
LOG_INFO << "[Stratum] DOA share from " << username_
<< ": block template changed (job=" << job_id
<< " job_prev=" << job_it->second.gbt_prevhash.substr(0, 16)
<< " current_prev=" << current_prevhash.substr(0, 16) << ")";
++doa_shares_;
// DON'T set job.stale_info here — it would break ref_hash.
// The share is still created and broadcast (matches p2pool behavior).