Repository navigation
Expand file tree
/
Copy pathvalidator.cpp
More file actions
3033 lines (2827 loc) · 198 KB
/
Copy pathvalidator.cpp
File metadata and controls
3033 lines (2827 loc) · 198 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
#include <graphene/protocol/block_vote.hpp>
#include <graphene/plugins/validator/validator.hpp>
#include <graphene/plugins/snapshot/plugin.hpp>
#include <graphene/chain/database_exceptions.hpp>
#include <graphene/chain/account_object.hpp>
#include <graphene/chain/chain_objects.hpp>
#include <graphene/chain/chain_object_types.hpp>
#include <graphene/chain/validator_objects.hpp>
#include <graphene/chain/hardfork.hpp>
#include <graphene/time/time.hpp>
#include <graphene/utilities/key_conversion.hpp>
#include <fc/smart_ref_impl.hpp>
// Boost 1.87 dropped the deprecated Asio interface from <boost/asio.hpp>.
// deadline_timer, which production_timer_ below is, still exists but has to be
// included directly now.
#include <boost/asio/deadline_timer.hpp>
#include <cstdio>
#include <fstream>
#include <limits>
#include <map>
#include <memory>
#include <mutex>
#include <thread>
#include <boost/asio.hpp>
#include <boost/date_time/posix_time/posix_time.hpp>
using std::string;
using std::vector;
using namespace graphene::chain;
using namespace graphene::protocol;
namespace bpo = boost::program_options;
void new_chain_banner(const graphene::chain::database &db) {
std::cerr << "\n"
"********************************\n"
"* *\n"
"* ------- NEW CHAIN ------ *\n"
"* - Welcome to VIZ! - *\n"
"* ------------------------ *\n"
"* *\n"
"********************************\n"
"\n";
return;
}
template<typename T>
T dejsonify(const string &s) {
return fc::json::from_string(s).as<T>();
}
#define DEFAULT_VALUE_VECTOR(value) default_value({fc::json::to_string(value)}, fc::json::to_string(value))
#define LOAD_VALUE_SET(options, name, container, type) \
if( options.count(name) ) { \
const std::vector<std::string>& ops = options[name].as<std::vector<std::string>>(); \
std::transform(ops.begin(), ops.end(), std::inserter(container, container.end()), &dejsonify<type>); \
}
namespace graphene {
namespace plugins {
namespace validator_plugin {
namespace asio = boost::asio;
namespace posix_time = boost::posix_time;
namespace system = boost::system;
struct validator_plugin::impl final {
uint32_t skip_diag_head = 0;
const char *skip_diag_why = nullptr;
impl():
p2p_(appbase::app().get_plugin<graphene::plugins::p2p::p2p_plugin>()),
chain_(appbase::app().get_plugin<graphene::plugins::chain::plugin>()),
snapshot_(appbase::app().get_plugin<graphene::plugins::snapshot::snapshot_plugin>()),
production_timer_(production_io_service_) {
// Keep production_io_service_ alive while the thread runs,
// then start the dedicated thread. This io_service is intentionally
// separate from appbase's shared io_service so that P2P network
// activity (peer disconnects, send-queue drains) cannot delay the
// production timer callback and cause missed-slot lag.
production_io_work_ = std::make_unique<asio::executor_work_guard<asio::io_context::executor_type>>(
asio::make_work_guard(production_io_service_));
production_io_thread_ = std::thread([this]() { production_io_service_.run(); });
}
~impl() {
production_io_service_.stop();
if (production_io_thread_.joinable())
production_io_thread_.join();
}
graphene::chain::database& database() {
return chain_.db();
}
graphene::chain::database& database() const {
return chain_.db();
}
graphene::plugins::chain::plugin& chain() {
return chain_;
}
graphene::plugins::chain::plugin& chain() const {
return chain_;
}
graphene::plugins::p2p::p2p_plugin& p2p(){
return p2p_;
};
graphene::plugins::p2p::p2p_plugin& p2p() const {
return p2p_;
};
graphene::plugins::snapshot::snapshot_plugin& snapshot() {
return snapshot_;
}
graphene::plugins::snapshot::snapshot_plugin& snapshot() const {
return snapshot_;
}
graphene::plugins::p2p::p2p_plugin& p2p_;
graphene::plugins::chain::plugin& chain_;
graphene::plugins::snapshot::snapshot_plugin& snapshot_;
void schedule_production_loop();
block_validation_condition::block_validation_condition_enum block_validation_loop();
/// Print the node's QC state while the head stays stale. A stall that only
/// says "Waiting for QC of head" per slot cannot be told apart from one
/// where the QC is held but no longer verifies, or is for another block.
void report_if_head_stale();
// Rate limit of the stalled-head report (see report_if_head_stale).
fc::time_point _last_stall_report;
uint32_t _last_stall_report_head = 0;
block_validation_condition::block_validation_condition_enum maybe_validate_block(fc::mutable_variant_object &capture);
boost::program_options::variables_map _options;
uint32_t _required_validator_participation = 33 * CHAIN_1_PERCENT;
std::atomic<uint64_t> head_block_num_;
block_id_type head_block_id_ = block_id_type();
std::atomic<uint64_t> total_hashes_;
fc::time_point hash_start_time_;
uint32_t _production_skip_flags = graphene::chain::database::skip_nothing;
// Dedicated io_service for the production timer — must be declared
// before production_timer_ so it is initialized first.
asio::io_context production_io_service_;
std::unique_ptr<asio::executor_work_guard<asio::io_context::executor_type>> production_io_work_;
std::thread production_io_thread_;
asio::deadline_timer production_timer_;
std::map<public_key_type, fc::ecc::private_key> _private_keys;
std::set<string> _validators;
// Signing key of each configured validator as committed at the head
// block. chainbase's session-visible validator object may already
// contain a pending validator_update that changes the key, but a block
// header is validated against the pre-block (committed) key
// (validate_block_header). Production must therefore sign with the
// committed key, not with the session-visible one.
//
// Seeded at startup and refreshed synchronously at the committed
// chain boundary, before pending transactions are restored. Reading
// applied block contents misses rotations reverted by a fork switch;
// reading the DB in deferred applied_block sees pending state.
std::map<string, public_key_type> _committed_signing_keys;
uint64_t _committed_keys_block_num = 0;
block_id_type _committed_keys_block_id;
std::mutex _committed_keys_mutex;
/** Key the next block header must be signed with (the key committed
* at head), given the session-visible value from the validator index. */
public_key_type effective_signing_key(const string &name, const public_key_type &visible) {
if (visible == public_key_type())
return visible; // blanked / disabled: production is skipped anyway
public_key_type committed;
uint64_t snapshot_block = 0;
block_id_type snapshot_id;
{
std::lock_guard<std::mutex> lk(_committed_keys_mutex);
auto it = _committed_signing_keys.find(name);
if (it == _committed_signing_keys.end() || it->second == visible)
return visible;
committed = it->second;
snapshot_block = _committed_keys_block_num;
snapshot_id = _committed_keys_block_id;
}
// DB access outside the lock: a writer must never wait on it.
if (snapshot_block != database().head_block_num() || snapshot_id != database().head_block_id())
return visible; // snapshot behind head: trust the chain
// Snapshot is for head and differs from the session-visible key:
// the difference comes from a pending validator_update. Keep
// producing with the committed key; the update lands in the next
// block and takes effect for the one after it.
return committed;
}
/** Called under the DB write lock, with no pending session. */
void update_committed_signing_keys() {
auto &d = database();
std::map<string, public_key_type> keys;
for (const auto &name : _validators) {
const auto *validator = d.find_validator(name);
if (validator)
keys[name] = validator->signing_key;
}
const auto head_num = d.head_block_num();
const auto head_id = d.head_block_id();
std::lock_guard<std::mutex> lk(_committed_keys_mutex);
_committed_signing_keys.swap(keys);
_committed_keys_block_num = head_num;
_committed_keys_block_id = head_id;
}
fc::time_point last_block_post_validation_time;
// ── one post-validation signature per height ──────────────────────────────
// Block-vote safety rule (Jolteon): a validator votes for a block of round r
// (round = slot of the block) only when r is above the highest round it has
// voted in. One vote per round follows, and so does "never vote for an older
// round after a newer one" -- the property that makes two conflicting QCs
// need an equivocating quorum. Unlike the earlier one-signature-per-height
// guard it lets a validator vote for a different block at the same height in
// a LATER round, which is what lets a split vote resolve instead of stalling.
// Persisted, so a restart does not forget the round. Re-signing the SAME
// block of the highest round (the tick re-broadcasts every 250 ms) is allowed.
struct vote_record { uint32_t round = 0; uint32_t num = 0; block_id_type id; };
std::string block_vote_state_path_;
std::map<std::string, vote_record> block_vote_state_;
// Keyed by block id, not height: a livelock rebuilds one height over and over,
// and a height key logged only the first variant (stand 22.10, #86 x70 silent).
std::set<std::pair<std::string, block_id_type>> block_vote_refused_logged_;
// HF16 diagnostics: why our validator casts no vote for head (once per head).
std::set<std::pair<std::string, block_id_type>> head_vote_diag_logged_;
void load_block_vote_state() {
if (block_vote_state_path_.empty()) return;
std::ifstream in(block_vote_state_path_);
std::string name, id;
uint32_t round = 0, num = 0;
size_t n = 0;
// Format: "<validator> <round> <block_num> <block_id>" per line.
while (in >> name >> round >> num >> id) {
try {
block_vote_state_[name] = vote_record{round, num, block_id_type(id)};
++n;
} catch (...) {
wlog("Skipping malformed block-vote record for ${w} at round ${r}", ("w", name)("r", round));
}
}
if (n) ilog("Restored ${n} block-vote round record(s) from ${p}", ("n", n)("p", block_vote_state_path_));
}
bool save_block_vote_state() {
if (block_vote_state_path_.empty()) return true;
const std::string tmp = block_vote_state_path_ + ".tmp";
{
std::ofstream out(tmp, std::ios::trunc);
if (!out) {
elog("Cannot write ${p}; not voting until it is writable", ("p", block_vote_state_path_));
return false;
}
for (const auto &v : block_vote_state_)
out << v.first << ' ' << v.second.round << ' ' << v.second.num << ' ' << v.second.id.str() << '\n';
out.flush();
if (!out) {
elog("Failed writing ${p}", ("p", block_vote_state_path_));
std::remove(tmp.c_str());
return false;
}
}
if (std::rename(tmp.c_str(), block_vote_state_path_.c_str()) != 0) {
elog("Failed replacing ${p}", ("p", block_vote_state_path_));
std::remove(tmp.c_str());
return false;
}
return true;
}
// True when `validator` may vote for `id` (height `num`) in `round`. The
// record is written BEFORE the signature leaves: a vote whose round could
// not be persisted is not sent, or a restart could vote that round again.
bool may_vote(const std::string &validator, uint32_t round, uint32_t num,
const block_id_type &id, uint32_t lib) {
if (block_vote_refused_logged_.size() > 4096) block_vote_refused_logged_.clear();
auto it = block_vote_state_.find(validator);
if (it != block_vote_state_.end()) {
const auto &last = it->second;
if (round == last.round && id == last.id) return true;
if (round <= last.round) {
// Older rounds are routinely skipped (the list carries recent
// blocks), so log only blocks above LIB+1, once per block id.
// last.id zero = the round was timed out, not voted.
if (num > lib + 1 && block_vote_refused_logged_.insert({validator, id}).second)
wlog("Not voting for ${id} at #${n} round ${r} for ${w}: last ${what} round ${lr} (${old})",
("id", id)("n", num)("r", round)("w", validator)
("what", last.id == block_id_type() ? "timed-out" : "voted")("lr", last.round)("old", last.id));
return false;
}
}
const auto prev = it != block_vote_state_.end() ? fc::optional<vote_record>(it->second) : fc::optional<vote_record>();
block_vote_state_[validator] = vote_record{round, num, id};
if (!save_block_vote_state()) {
if (prev) block_vote_state_[validator] = *prev; else block_vote_state_.erase(validator);
return false;
}
return true;
}
// HF16 timeout: `validator` gives up on `round` (head without a QC).
// Jolteon safety: after timing out round r we never vote for a block of
// round r, so the round is recorded (block id zero) BEFORE the timeout
// leaves, same as a vote. Having already voted for the block of round r
// does not forbid the timeout. False = do not send.
bool may_timeout(const std::string &validator, uint32_t round) {
auto sent = timeout_sent_.find(validator);
if (sent != timeout_sent_.end() && sent->second >= round) return false; // once per round
auto it = block_vote_state_.find(validator);
if (it != block_vote_state_.end() && it->second.round > round) return false; // moved on
if (it == block_vote_state_.end() || it->second.round < round) {
const auto prev = it != block_vote_state_.end() ? fc::optional<vote_record>(it->second) : fc::optional<vote_record>();
block_vote_state_[validator] = vote_record{round, 0, block_id_type()};
if (!save_block_vote_state()) {
if (prev) block_vote_state_[validator] = *prev; else block_vote_state_.erase(validator);
return false;
}
}
timeout_sent_[validator] = round;
return true;
}
std::map<std::string, uint32_t> timeout_sent_; // in memory: a restart may resend, which is harmless
// Own block votes not yet in a QC are re-gossiped with backoff (1, 2, 4,
// capped at 8 slots; at most VOTE_RESEND_MAX times). A vote lost on the
// way (peer dropped, queue lost on reconnect) otherwise never came back:
// with 15 live of 21 every vote is needed, so one loss froze the height.
struct vote_resend { uint32_t sent = 0; fc::time_point next; };
static constexpr uint32_t VOTE_RESEND_MAX = 8;
std::map<std::pair<std::string, uint32_t>, vote_resend> vote_resend_;
// Fork collision resolution state
uint32_t fork_collision_defer_count_ = 0;
uint32_t _fork_collision_timeout_blocks = 21; // one full validator round (21 slots: 63 s before HF16, 21 s after)
// Minority fork recovery state: tracks when we rolled back to
// LIB and are waiting for P2P sync to catch up before
// re-enabling block production.
bool _minority_fork_recovering = false;
fc::time_point _minority_fork_recovery_start;
// DLT minority-fork ESCALATION state — what resync_from_lib() alone could
// not do. Measured 31.10.2026: the detector fired on every 250 ms production
// tick for an hour, each time "resetting to LIB", while the head never moved.
// A rewind stays inside the same state, so a state that keeps re-deriving the
// same fork is rewound into itself forever; the way out is to REPLACE the
// state with a FRESH snapshot from a trusted peer (our own snapshots are
// always behind our head, so they replay back into the same state). Bounded:
// the head must stay put for FORK_ESCALATION_SEC, the download is subject to
// the usual "must be above max(head, LIB)" floor guard, and each stuck head
// escalates at most once.
uint32_t _fork_loop_head = 0;
fc::time_point _fork_loop_since;
bool _fork_escalated = false;
// Ask the snapshot plugin to REPLACE the state with a fresh one from trusted
// peers. One question, two askers: the slave path below (head stuck after a
// rewind) and the emergency master (still behind peer LIB after a rewind).
// Guarded here rather than at each call site: an unregistered std::function
// throws bad_function_call, and that exception would escape into block
// processing. Returns whether a recovery could actually be started.
bool request_forced_snapshot_recovery() {
bool recovery_started = false;
try {
if (chain().snapshot_force_recovery_callback)
recovery_started = chain().snapshot_force_recovery_callback();
} catch (const fc::exception& e) {
elog("Forced snapshot recovery could not be requested: ${e}",
("e", e.to_detail_string()));
} catch (...) {
elog("Forced snapshot recovery could not be requested: unknown exception");
}
return recovery_started;
}
// Emergency-master peer-LIB gate (see maybe_validate_block). The master is
// exempt from the isolation scan by design, so this is the only place that
// can notice the network moved to another chain while we kept producing.
// Two peers must make the claim before it counts: one peer's LIB can be a
// stale snapshot from before its own reorg, two agreeing ones cannot be
// produced by a single node with a key.
static constexpr uint32_t EMERGENCY_PEER_LIB_QUORUM = 2;
// A head that keeps coming back after a rewind needs the state REPLACED, not
// rewound (same reasoning as FORK_ESCALATION_SEC below).
static constexpr int64_t EMERGENCY_PEER_LIB_ESCALATION_SEC = 60;
uint32_t _master_gate_head = 0; // head the quorum was first seen on
fc::time_point _master_gate_since; // when that head became stuck
bool _master_gate_escalated = false; // one forced snapshot per stuck head
// Startup witness gate. The peer-LIB gate needs peers to say something, so a
// master that opens its state and reaches its slot before the first seed has
// answered sails through on "nobody objects". 03.11.2026 03:05Z, api.viz.world:
// restarted with LIB #84961415 while the network sat thousands of blocks
// ahead, produced committee #84961416 four seconds later with zero peers, and
// forked. Until a seed/trusted witness has reported a LIB that is NOT above
// ours, the master does not sign — at startup one witness is enough to make
// it wait (waiting is the safe direction; the quorum of two below guards the
// opposite case, a running master told to stop). After the grace period a
// genuinely isolated master may produce again, so a dead seed list cannot
// halt the chain forever.
static constexpr int64_t EMERGENCY_STARTUP_WITNESS_GRACE_SEC = 300;
fc::time_point _startup_time; // set in plugin_startup()
bool _startup_cleared = false; // latched: a witness confirmed we are not behind
// Operator override (testnet / single-operator fork): when set via
// 'disable-minority-fork-detection', both the standard and the DLT
// minority-fork checks are skipped entirely. On a fork where the
// operator controls ALL validators, "last 21 blocks all ours" is the
// normal healthy state, not an isolation symptom — the detector would
// otherwise loop forever resetting to LIB. Unlike enable-stale-production,
// this flag is never auto-cleared by the healthy-participation path.
bool _disable_minority_fork_detection = false;
// P18: slot=0 stall detection — tracks consecutive
// not_time_yet returns to detect NTP/clock issues.
uint32_t _slot_zero_streak = 0;
fc::time_point _slot_zero_streak_start;
// Track consecutive not_my_turn results to detect when our validator
// is in the schedule but slots keep belonging to other validators.
// Helps diagnose "silent miss" issues where the validator should have
// produced but didn't (schedule misalignment, timing issues, etc).
uint32_t _not_my_turn_streak = 0;
fc::time_point _not_my_turn_streak_start;
std::string _last_scheduled_validator; // last validator that got the slot
// Production watchdog: tracks when we last produced a block
// so the watchdog can fire if the emergency master goes silent.
bool _ever_produced = false;
fc::time_point _last_production_time;
// Last result from a slot > 0 iteration (not_time_yet filtered out so
// the watchdog shows a meaningful failure code, not between-slot noise).
int _last_slot_result = -1;
// Tracks the scheduled_time of the last lag condition. Used to skip
// ahead past a missed slot and avoid spinning in a tight lag loop
// where every 250ms tick rechecks the same missed slot.
fc::time_point_sec _last_lag_slot_time;
// Watchdog debug mode: set to true when the watchdog first fires,
// enabling verbose DEBUG_CRASH logs automatically to help diagnose
// why production stopped. Reset to false when production resumes
// so verbose logging does not persist indefinitely.
bool _watchdog_debug_enabled = false;
// Remember the user's --debug-block-production config so the
// watchdog can restore the original value after auto-enabling
// debug logging for diagnosis.
bool _debug_block_production_config = false;
// Slot hijack detection: counts consecutive blocks where committee
// filled a slot that was assigned to our validator in the shuffled
// schedule. In DLT emergency mode the emergency master may blank
// our signing key and produce committee blocks in our slots; this
// counter makes the condition visible in watchdog diagnostics.
uint32_t _slot_hijack_count = 0;
uint32_t _slot_hijack_height = 0; // last block height where hijack was detected
// Track last applied block number to detect missed blocks.
// Updated in the applied_block signal handler.
uint64_t _last_applied_block_num = 0;
boost::signals2::connection _applied_block_connection;
boost::signals2::scoped_connection _committed_chain_state_connection;
// Protects cross-thread diagnostic fields shared between
// production_io_thread_ and the P2P thread (on_block_applied /
// get_production_diagnostics). Never held during database() calls.
std::mutex _diag_mutex;
// applied_block signal handler: detects when incoming blocks
// reveal missed slots, and if our validator was scheduled for
// any of them, dumps full plugin state for diagnosis.
void on_block_applied(const graphene::chain::signed_block &block);
};
void validator_plugin::set_program_options(
boost::program_options::options_description &command_line_options,
boost::program_options::options_description &config_file_options) {
string validator_id_example = "initvalidator";
command_line_options.add_options()
("enable-stale-production", bpo::value<bool>()->implicit_value(true) , "Enable block production, even if the chain is stale.")
("disable-minority-fork-detection", bpo::value<bool>()->implicit_value(true) , "Disable minority-fork detection (testnet / single-operator fork where this node controls all validators). Never auto-cleared by healthy participation.")
("required-participation", bpo::value<uint32_t>()->default_value(33 * CHAIN_1_PERCENT), "Percent of validators (0-99) that must be participating in order to produce blocks")
("validator,v", bpo::value<vector<string>>()->composing()->multitoken(), ("name of validator controlled by this node (e.g. " + validator_id_example + " )").c_str())
("witness,w", bpo::value<vector<string>>()->composing()->multitoken(), "[DEPRECATED] Use --validator. Name of validator controlled by this node (legacy 'witness' option, kept for config.ini backward compatibility).")
("private-key", bpo::value<vector<string>>()->composing()->multitoken(), "WIF PRIVATE KEY to be used by one or more validators")
("emergency-private-key", bpo::value<vector<string>>()->composing()->multitoken(),
"WIF PRIVATE KEY for emergency consensus block production. "
"Only used when the network enters emergency consensus mode "
"(no blocks for >1 hour since last irreversible block). "
"Multiple nodes can safely have this key.")
("ntp-server", bpo::value<vector<string>>()->composing()->multitoken(),
"NTP server to use for time synchronization (host or host:port). "
"Can be specified multiple times. Leave unset to use the built-in defaults "
"(pool.ntp.org, time.google.com, time.cloudflare.com).")
("ntp-request-interval", bpo::value<uint32_t>()->default_value(900),
"How often to request a time update from NTP servers, in seconds (default: 900 = 15 min).")
("ntp-retry-interval", bpo::value<uint32_t>()->default_value(300),
"Retry interval in seconds when NTP has not replied (default: 300 = 5 min).")
("ntp-round-trip-threshold", bpo::value<uint32_t>()->default_value(150),
"Round-trip delay threshold in milliseconds; NTP replies slower than this are discarded (default: 150).")
("ntp-history-size", bpo::value<uint32_t>()->default_value(5),
"Moving-average history window size for NTP delta smoothing (default: 5).")
("ntp-rejection-threshold-pct", bpo::value<uint32_t>()->default_value(50),
"Rejection threshold as a percentage of the absolute moving average; deltas deviating more are rejected (default: 50).")
("ntp-rejection-min-threshold", bpo::value<uint32_t>()->default_value(5),
"Minimum rejection threshold in milliseconds, applied regardless of the percentage rule (default: 5).")
("fork-collision-timeout-blocks", bpo::value<uint32_t>()->default_value(21),
"Number of consecutive fork-collision deferrals (block slots) before forcing production. "
"One full validator schedule round is 21 blocks (63 seconds). Default: 21.")
("debug-block-production", bpo::value<bool>()->default_value(false),
"Enable verbose debug logging for block production and chain internals. Default: false.")
;
config_file_options.add(command_line_options);
}
using std::vector;
using std::pair;
using std::string;
void validator_plugin::plugin_initialize(const boost::program_options::variables_map &options) {
try {
ilog("validator plugin: plugin_initialize() begin");
pimpl = std::make_unique<validator_plugin::impl>();
pimpl->total_hashes_.store(0, std::memory_order_relaxed);
pimpl->_options = &options;
pimpl->block_vote_state_path_ =
(appbase::app().data_dir() / "block-vote-round.txt").string();
pimpl->load_block_vote_state();
LOAD_VALUE_SET(options, "validator", pimpl->_validators, string)
if (options.count("witness")) {
// Deprecated: --witness / config 'witness' is kept for backward compatibility.
wlog("Config option 'witness' is deprecated, use 'validator' instead.");
LOAD_VALUE_SET(options, "witness", pimpl->_validators, string)
}
edump((pimpl->_validators));
if(options.count("enable-stale-production")){
if (options["enable-stale-production"].as<bool>()) {
pimpl->_production_skip_flags |= graphene::chain::database::skip_undo_history_check;
}
}
if(options.count("disable-minority-fork-detection")){
if (options["disable-minority-fork-detection"].as<bool>()) {
pimpl->_disable_minority_fork_detection = true;
wlog("Minority-fork detection DISABLED by operator config. "
"Use only on a testnet / single-operator fork where this node "
"controls all validators.");
}
}
if(options.count("required-participation")){
pimpl->_required_validator_participation = options["required-participation"].as<uint32_t>();
}
if (options.count("private-key")) {
const std::vector<std::string> keys = options["private-key"].as<std::vector<std::string>>();
for (const std::string &wif_key : keys) {
fc::optional<fc::ecc::private_key> private_key = graphene::utilities::wif_to_key(wif_key);
FC_ASSERT(private_key.valid(), "unable to parse private key");
pimpl->_private_keys[private_key->get_public_key()] = *private_key;
}
}
if (options.count("emergency-private-key")) {
const std::vector<std::string> keys = options["emergency-private-key"].as<std::vector<std::string>>();
for (const std::string &wif_key : keys) {
fc::optional<fc::ecc::private_key> private_key = graphene::utilities::wif_to_key(wif_key);
FC_ASSERT(private_key.valid(), "unable to parse emergency private key");
pimpl->_private_keys[private_key->get_public_key()] = *private_key;
}
// Add the committee account to our validator set so we produce blocks
// when the schedule assigns committee slots during emergency mode
pimpl->_validators.insert(CHAIN_EMERGENCY_VALIDATOR_ACCOUNT);
ilog("Emergency private key loaded. Will produce blocks during emergency consensus mode.");
}
// Build and store NTP configuration so it is applied when the NTP service starts.
{
graphene::time::ntp_config ntp_cfg;
if (options.count("ntp-server"))
ntp_cfg.servers = options["ntp-server"].as<std::vector<std::string>>();
ntp_cfg.request_interval_sec = options["ntp-request-interval"].as<uint32_t>();
ntp_cfg.retry_interval_sec = options["ntp-retry-interval"].as<uint32_t>();
ntp_cfg.round_trip_threshold_ms = options["ntp-round-trip-threshold"].as<uint32_t>();
ntp_cfg.history_size = options["ntp-history-size"].as<uint32_t>();
ntp_cfg.rejection_threshold_pct = options["ntp-rejection-threshold-pct"].as<uint32_t>();
ntp_cfg.rejection_min_threshold_ms = options["ntp-rejection-min-threshold"].as<uint32_t>();
graphene::time::configure_ntp(ntp_cfg);
}
if (options.count("fork-collision-timeout-blocks")) {
pimpl->_fork_collision_timeout_blocks = options["fork-collision-timeout-blocks"].as<uint32_t>();
}
if (options.count("debug-block-production")) {
pimpl->_debug_block_production_config = options["debug-block-production"].as<bool>();
pimpl->chain().db()._debug_block_production = pimpl->_debug_block_production_config;
if (pimpl->chain().db()._debug_block_production) {
ilog("Debug block production logging ENABLED");
}
}
ilog("validator plugin: plugin_initialize() end");
} FC_LOG_AND_RETHROW()
}
void validator_plugin::plugin_startup() {
try {
ilog("validator plugin: plugin_startup() begin");
auto &d = pimpl->database();
pimpl->_startup_time = fc::time_point::now();
//Start NTP time client
graphene::time::now();
// Force NTP sync before first production tick to minimize the
// window where get_slot_at_time() returns 0 due to unsynchronized
// NTP on restart.
graphene::time::update_ntp_time();
// Log validator configuration for post-crash diagnostics
ilog("Validator config: ${n} validators, ${k} private keys",
("n", pimpl->_validators.size())("k", pimpl->_private_keys.size()));
for (const auto& w : pimpl->_validators) {
ilog(" configured validator: ${w}", ("w", w));
}
// Seed the committed signing-key snapshot from head state. At
// startup there is no pending transaction session yet, so the
// index read is committed state.
for (const auto& w : pimpl->_validators) {
try {
pimpl->_committed_signing_keys[w] = d.get_validator(w).signing_key;
} catch (const fc::exception& e) {
wlog("Validator ${w} is not on chain yet; its committed signing key "
"will be learned from committed chain state", ("w", w));
}
}
pimpl->_committed_keys_block_num = d.head_block_num();
pimpl->_committed_keys_block_id = d.head_block_id();
if (!pimpl->_validators.empty()) {
ilog("Launching block production for ${n} validators.", ("n", pimpl->_validators.size()));
pimpl->p2p().set_block_production(true);
pimpl->_committed_chain_state_connection = d.committed_chain_state.connect([this]() {
pimpl->update_committed_signing_keys();
});
// Connect to applied_block signal to detect missed slots
// that belong to our validators and log diagnostic state.
pimpl->_last_applied_block_num = d.head_block_num();
pimpl->_applied_block_connection = d.applied_block.connect([this](const graphene::chain::signed_block &block) {
pimpl->on_block_applied(block);
});
if (pimpl->_production_skip_flags & graphene::chain::database::skip_undo_history_check) {
if (d.head_block_num() == 0) {
new_chain_banner(d);
}
}
pimpl->schedule_production_loop();
} else
elog("No validators configured! Please add validator names and private keys to configuration.");
ilog("validator plugin: plugin_startup() end");
} FC_CAPTURE_AND_RETHROW()
}
void validator_plugin::plugin_shutdown() {
graphene::time::shutdown_ntp_time();
if (!pimpl->_validators.empty()) {
pimpl->_applied_block_connection.disconnect();
pimpl->_committed_chain_state_connection.disconnect();
ilog("shutting downing production timer");
// Stop the dedicated io_service so the production thread exits.
// io_service::stop() is thread-safe; it causes run() to return
// after the currently executing handler (if any) completes.
pimpl->production_io_service_.stop();
if (pimpl->production_io_thread_.joinable())
pimpl->production_io_thread_.join();
}
}
void validator_plugin::plugin_for_each_dependency(plugin_processor&& l) {
l(appbase::app().register_plugin<graphene::plugins::chain::plugin>());
l(appbase::app().register_plugin<graphene::plugins::p2p::p2p_plugin>());
l(appbase::app().register_plugin<graphene::plugins::snapshot::snapshot_plugin>());
}
validator_plugin::validator_plugin() {}
validator_plugin::~validator_plugin() {}
bool validator_plugin::is_validator_scheduled_soon() const {
try {
if (!pimpl || pimpl->_validators.empty() || pimpl->_private_keys.empty()) {
return false;
}
auto& db = pimpl->database();
auto op_guard = db.make_operation_guard();
fc::time_point now_fine = graphene::time::now();
fc::time_point_sec now = now_fine + fc::microseconds(250000);
uint32_t slot = db.get_slot_at_time(now);
if (slot == 0) {
slot = 1;
}
// Check 5 upcoming slots (~15 seconds) to cover snapshot creation time (~10s) + safety margin
for (uint32_t s = slot; s <= slot + 4; ++s) {
string scheduled_validator = db.get_scheduled_validator(s);
if (pimpl->_validators.find(scheduled_validator) == pimpl->_validators.end()) {
continue;
}
const auto& validator_by_name = db.get_index<graphene::chain::validator_index>().indices().get<graphene::chain::by_name>();
auto itr = validator_by_name.find(scheduled_validator);
if (itr == validator_by_name.end()) {
continue;
}
graphene::protocol::public_key_type scheduled_key =
pimpl->effective_signing_key(scheduled_validator, itr->signing_key);
if (scheduled_key == graphene::protocol::public_key_type()) {
continue; // Disabled validator (zero key)
}
if (pimpl->_private_keys.find(scheduled_key) != pimpl->_private_keys.end()) {
op_guard.release();
return true; // We have the private key and are scheduled soon
}
}
} catch (const fc::exception& e) {
wlog("is_validator_scheduled_soon check failed: ${e}", ("e", e.to_detail_string()));
} catch (...) {
wlog("is_validator_scheduled_soon check failed with unknown exception");
}
return false;
}
fc::time_point_sec validator_plugin::get_next_validator_slot_time() const {
try {
if (!pimpl || pimpl->_validators.empty() || pimpl->_private_keys.empty()) {
return fc::time_point_sec();
}
auto& db = pimpl->database();
auto op_guard = db.make_operation_guard();
fc::time_point now_fine = graphene::time::now();
fc::time_point_sec now = now_fine + fc::microseconds(250000);
uint32_t slot = db.get_slot_at_time(now);
if (slot == 0) {
slot = 1;
}
for (uint32_t s = slot; s <= slot + 4; ++s) {
string scheduled_validator = db.get_scheduled_validator(s);
if (pimpl->_validators.find(scheduled_validator) == pimpl->_validators.end()) {
continue;
}
const auto& validator_by_name = db.get_index<graphene::chain::validator_index>().indices().get<graphene::chain::by_name>();
auto itr = validator_by_name.find(scheduled_validator);
if (itr == validator_by_name.end()) {
continue;
}
graphene::protocol::public_key_type scheduled_key =
pimpl->effective_signing_key(scheduled_validator, itr->signing_key);
if (scheduled_key == graphene::protocol::public_key_type()) {
continue; // Disabled validator (zero key)
}
if (pimpl->_private_keys.find(scheduled_key) != pimpl->_private_keys.end()) {
fc::time_point_sec slot_time = db.get_slot_time(s);
op_guard.release();
return slot_time;
}
}
} catch (const fc::exception& e) {
wlog("get_next_validator_slot_time check failed: ${e}", ("e", e.to_detail_string()));
} catch (...) {
wlog("get_next_validator_slot_time check failed with unknown exception");
}
return fc::time_point_sec();
}
bool validator_plugin::is_emergency_master() const {
try {
if (!pimpl || pimpl->_validators.empty()) {
return false;
}
// Condition 1: we hold the emergency-private-key.
// CHAIN_EMERGENCY_VALIDATOR_ACCOUNT is added to _validators only
// when --emergency-private-key is configured (see plugin_initialize).
if (pimpl->_validators.find(CHAIN_EMERGENCY_VALIDATOR_ACCOUNT) == pimpl->_validators.end()) {
return false;
}
// Condition 2: the committee account is in the current validator schedule.
auto& db = pimpl->database();
return db.with_weak_read_lock([&]() -> bool {
const validator_schedule_object& wso = db.get_validator_schedule_object();
for (int i = 0; i < wso.num_scheduled_validators; i += CHAIN_BLOCK_VALIDATOR_REPEAT) {
if (wso.current_shuffled_validators[i] == CHAIN_EMERGENCY_VALIDATOR_ACCOUNT) {
return true;
}
}
return false;
});
} catch (const fc::exception& e) {
wlog("is_emergency_master check failed: ${e}", ("e", e.to_detail_string()));
} catch (...) {
wlog("is_emergency_master check failed with unknown exception");
}
return false;
}
bool validator_plugin::is_emergency_key_configured() const {
try {
if (!pimpl || pimpl->_validators.empty()) {
return false;
}
// CHAIN_EMERGENCY_VALIDATOR_ACCOUNT is added to _validators only
// when --emergency-private-key is configured (see plugin_initialize).
return pimpl->_validators.find(CHAIN_EMERGENCY_VALIDATOR_ACCOUNT) != pimpl->_validators.end();
} catch (...) {
return false;
}
}
std::string validator_plugin::get_production_diagnostics() const {
try {
if (!pimpl) return "validator=no_pimpl";
std::lock_guard<std::mutex> lk(pimpl->_diag_mutex);
std::string s = "prod_skip_flags=";
s += std::to_string(pimpl->_production_skip_flags);
s += " catching_up=";
try { s += pimpl->p2p().is_catching_up_after_pause() ? "1" : "0"; } catch (...) { s += "?"; }
try { s += " head=#" + std::to_string(pimpl->database().head_block_num()); } catch (...) {}
if (pimpl->_ever_produced) {
auto ago = (fc::time_point::now() - pimpl->_last_production_time).count() / 1000000;
s += " last_prod=" + std::to_string(ago) + "s_ago";
} else {
s += " last_prod=never";
}
s += " minority_rcv=";
s += pimpl->_minority_fork_recovering ? "1" : "0";
s += " slot_hijacks=";
s += std::to_string(pimpl->_slot_hijack_count);
return "validator[" + s + "]";
} catch (...) {
return "validator=err";
}
}
void validator_plugin::impl::on_block_applied(const graphene::chain::signed_block &block) {
try {
std::lock_guard<std::mutex> _diag_lk(_diag_mutex);
uint64_t block_num = block.block_num();
uint64_t prev_num = _last_applied_block_num;
_last_applied_block_num = block_num;
// === SLOT-HIJACK DETECTION (runs for every block) ===
// In DLT emergency mode, the emergency master may blank our validator's
// signing_key and fill our scheduled slots with committee blocks. This
// detection runs for every incoming block and checks whether the slot
// that was just filled was assigned to our validator but produced by someone
// else (committee / emergency account). The counter is included in
// watchdog diagnostics so the operator can see the hijack pattern.
const auto& dgp_hijack = database().get_dynamic_global_properties();
if (database()._dlt_mode && !_validators.empty()
&& prev_num > 0 && block_num == prev_num + 1
&& dgp_hijack.emergency_consensus_active) {
const auto& wso_sj = database().get_validator_schedule_object();
uint32_t nsw_sj = wso_sj.num_scheduled_validators;
if (nsw_sj > 0) {
// apply_block increments current_aslot to the applied block's slot
// before this callback fires, so current_aslot IS the applied slot.
uint64_t slot_idx = dgp_hijack.current_aslot % nsw_sj;
const std::string& expected_validator =
wso_sj.current_shuffled_validators[slot_idx];
bool was_our_slot = _validators.count(expected_validator) > 0;
// True hijack only if the actual producer is NOT also one of our validators.
bool producer_is_ours = _validators.count(block.validator) > 0;
if (was_our_slot && !producer_is_ours && block.validator != expected_validator) {
// External validator (committee / emergency) produced at our slot.
_slot_hijack_count++;
_slot_hijack_height = static_cast<uint32_t>(block_num);
// Log the first 3 hijacks, then once per minute.
static fc::time_point _last_hijack_log;
auto _now_sj = fc::time_point::now();
if (_slot_hijack_count <= 3 ||
(_now_sj - _last_hijack_log).count() > 60000000) {
_last_hijack_log = _now_sj;
elog("SLOT-HIJACK: block #${bn} by '${wit}' but slot was assigned "
"to our validator '${exp}' (hijack #${cnt}). "
"head=#${head} aslot=${aslot} num_sched=${nsched}",
("bn", block_num)("wit", block.validator)("exp", expected_validator)
("cnt", _slot_hijack_count)
("head", dgp_hijack.head_block_number)
("aslot", (uint64_t)dgp_hijack.current_aslot)
("nsched", nsw_sj));
}
} else if (was_our_slot && (block.validator == expected_validator || producer_is_ours)) {
// Our validator (expected or another of ours) produced — reset hijack counter.
if (_slot_hijack_count > 0) {
wlog("SLOT-HIJACK-RESOLVED: our validator '${wit}' produced "
"block #${bn} after ${cnt} hijacked slot(s).",
("wit", block.validator)("bn", block_num)("cnt", _slot_hijack_count));
}
_slot_hijack_count = 0;
}
}
}
// No gap, first block, or emergency mode not active — nothing to check
if (prev_num == 0 || block_num <= prev_num + 1) {
return;
}
uint32_t missed_count = static_cast<uint32_t>(block_num - prev_num - 1);
// By the time applied_block fires, current_aslot has already been
// updated: new_aslot = old_aslot + missed_count + 1.
// The missed slots had absolute slot indices:
// old_aslot + 1, old_aslot + 2, ..., old_aslot + missed_count
// So: old_aslot = current_aslot - missed_count - 1
// Missed slot i (0-based): abs_slot = current_aslot - missed_count + i
const auto &dgp = database().get_dynamic_global_properties();
const auto &wso = database().get_validator_schedule_object();
uint64_t cur_aslot = dgp.current_aslot;
uint32_t num_validators = wso.num_scheduled_validators;
if (num_validators == 0) return;
// Check each missed slot to see if our validator was scheduled
bool our_validator_missed = false;
std::string missed_validators_list;
for (uint32_t i = 0; i < missed_count && i < 100; ++i) {
uint64_t abs_slot = cur_aslot - missed_count + i;
const std::string &wname = wso.current_shuffled_validators[abs_slot % num_validators];
if (!missed_validators_list.empty()) missed_validators_list += ",";
missed_validators_list += wname;
if (_validators.count(wname) > 0) {
our_validator_missed = true;
}
}
if (!our_validator_missed) {
// Not our problem — other validators missed their slots
return;
}
// Our validator missed a slot! Dump full diagnostic state.
fc::time_point_sec now_ts = graphene::time::now();
int64_t ntp_us = 0;
try { ntp_us = graphene::time::ntp_error().count(); } catch (...) {}
bool catching_up = false;
try { catching_up = p2p().is_catching_up_after_pause(); } catch (...) {}
bool dlt_syncing = false;
try { dlt_syncing = chain().is_syncing(); } catch (...) {}
std::string validator_names;
for (const auto &w : _validators) {
if (!validator_names.empty()) validator_names += ",";
validator_names += w;