-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathtest_web_honesty_regression.cpp
More file actions
606 lines (542 loc) · 30.2 KB
/
Copy pathtest_web_honesty_regression.cpp
File metadata and controls
606 lines (542 loc) · 30.2 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
// SPDX-License-Identifier: AGPL-3.0-or-later
// Web honesty-regression KAT (SAFE-ADDITIVE characterization test).
//
// Charter: "A dashboard must never lie about prod health." (founding bug
// 2026-06-22 -- the getinfo / local_stats surface reported connections=0 and
// poolhashps=0 while the node was fully peered + mining, because the embedded
// prod build runs MiningInterface with an UNPOPULATED m_node (the enhanced_node
// stub whose peer/hashrate/share accessors all return 0). It lied to the
// operator TWICE in one night about contabo LTC+DOGE prod health.)
//
// This test pins the un-stub fix in place: it constructs MiningInterface with
// node == nullptr -- the exact embedded-prod topology that produced the false
// zeros -- and verifies getinfo() sources connections / poolhashps / poolshares
// / difficulty from the live MiningInterface hooks instead of collapsing to the
// historical 0-stubs. If anyone re-introduces the m_node-only path, these
// expectations fail loudly.
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
#include <core/web_server.hpp>
using namespace core;
using nlohmann::json;
// With an empty m_node but live hooks wired, getinfo must report TRUTH, not the
// historical zeros.
TEST(WebHonestyRegression, GetInfoSourcesLiveHooksNotZeroStubs) {
// node == nullptr: the embedded-prod topology where every m_node accessor
// returns 0 -- the founding-bug scenario.
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
// 7 real c2pool share-peers (the prod node ran 6-7 V36 share peers on the
// night of the founding bug while the dashboard said "0").
mi.set_peer_info_fn([] {
json peers = json::array();
for (int i = 0; i < 7; ++i)
peers.push_back(json{{"addr", "10.0.0." + std::to_string(i)}});
return peers;
});
// Real pool hashrate -- 62.2 GH/s was the verified contabo prod truth once
// the stub was fixed (see founding-charter-verified-prod).
mi.set_pool_hashrate_fn([] { return 62.2e9; });
// Live sharechain stats (same truthful source #462 used for stale/DOA).
mi.set_sharechain_stats_fn([] {
return json{{"total_shares", 12345}, {"average_difficulty", 1024.0}};
});
json r = mi.getinfo("kat");
// The four metrics that the founding bug zeroed out:
EXPECT_EQ(r["connections"].get<int>(), 7)
<< "share-peer count must come from m_peer_info_fn, not the 0-stub";
EXPECT_GT(r["poolhashps"].get<double>(), 0.0)
<< "pool hashrate must come from m_pool_hashrate_fn, not the 0-stub";
EXPECT_DOUBLE_EQ(r["poolhashps"].get<double>(), 62.2e9);
EXPECT_EQ(r["poolshares"].get<uint64_t>(), 12345u)
<< "poolshares must come from the live sharechain stats hook";
EXPECT_GT(r["difficulty"].get<double>(), 0.0)
<< "difficulty must come from the live sharechain stats hook";
}
// Belt-and-braces: a nodeless interface with NO hooks still returns a
// well-formed object whose un-stub keys are STRUCTURALLY present (value 0).
// Absent telemetry reporting a structural 0 ("not instrumented") is honest --
// distinct from the founding lie of reporting 0 while live data exists.
TEST(WebHonestyRegression, GetInfoWellFormedWithoutHooks) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
json r = mi.getinfo("kat");
ASSERT_TRUE(r.is_object());
EXPECT_TRUE(r.contains("connections"));
EXPECT_TRUE(r.contains("poolhashps"));
EXPECT_TRUE(r.contains("poolshares"));
EXPECT_TRUE(r.contains("difficulty"));
}
// --- getstats: same founding-bug surface as getinfo --------------------------
// getstats() reported connected_peers=0 / pool_hashrate=0 and zeroed orphan/DOA/
// stale share counts whenever m_node was the empty embedded-prod stub, while
// /stale_rates (the same m_sharechain_stats_fn hook) showed the truth. Pin the
// un-stub: with node==nullptr and live hooks wired, pool_statistics must report
// real peers / hashrate / orphan / DOA / stale.
TEST(WebHonestyRegression, GetStatsSourcesLiveHooksNotZeroStubs) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
mi.set_peer_info_fn([] {
json peers = json::array();
for (int i = 0; i < 7; ++i)
peers.push_back(json{{"addr", "10.0.0." + std::to_string(i)}});
return peers;
});
mi.set_pool_hashrate_fn([] { return 62.2e9; });
// 1000 total shares, 30 orphan + 10 dead -> 40 stale, 4% stale_prop.
mi.set_sharechain_stats_fn([] {
return json{{"total_shares", 1000}, {"orphan_shares", 30}, {"dead_shares", 10}};
});
json r = mi.getstats("kat");
ASSERT_TRUE(r.contains("pool_statistics"));
const json& ps = r["pool_statistics"];
EXPECT_EQ(ps["connected_peers"].get<int>(), 7)
<< "connected_peers must come from m_peer_info_fn, not the 0-stub";
EXPECT_DOUBLE_EQ(ps["pool_hashrate"].get<double>(), 62.2e9)
<< "pool_hashrate must come from m_pool_hashrate_fn, not the 0-stub";
EXPECT_EQ(ps["orphan_shares"].get<uint64_t>(), 30u)
<< "orphan_shares must come from the live sharechain stats hook";
EXPECT_EQ(ps["doa_shares"].get<uint64_t>(), 10u)
<< "doa_shares must come from the live sharechain stats hook";
EXPECT_EQ(ps["stale_shares"].get<uint64_t>(), 40u)
<< "stale_shares = orphan + dead from the live hook";
EXPECT_DOUBLE_EQ(ps["stale_prop"].get<double>(), 0.04)
<< "stale_prop = (orphan+dead)/total from the live hook";
}
// Belt-and-braces: nodeless interface with NO hooks still returns a well-formed
// getstats whose un-stub keys are structurally present (honest 0 = "not
// instrumented", distinct from the founding lie of 0-while-live-data-exists).
TEST(WebHonestyRegression, GetStatsWellFormedWithoutHooks) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
json r = mi.getstats("kat");
ASSERT_TRUE(r.contains("pool_statistics"));
const json& ps = r["pool_statistics"];
EXPECT_TRUE(ps.contains("connected_peers"));
EXPECT_TRUE(ps.contains("pool_hashrate"));
EXPECT_TRUE(ps.contains("orphan_shares"));
EXPECT_TRUE(ps.contains("doa_shares"));
EXPECT_TRUE(ps.contains("stale_shares"));
}
// --- getpeerinfo: the real per-peer list, not the 0-count fallback -----------
// On the embedded-prod build m_node->get_connected_peers_count() returns 0 while
// the node is fully peered. getpeerinfo() must return the real per-peer list
// from m_peer_info_fn -- not the single {connected_peers:0} aggregate fallback.
TEST(WebHonestyRegression, GetPeerInfoReturnsLivePeerListNotZeroStub) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
mi.set_peer_info_fn([] {
json peers = json::array();
for (int i = 0; i < 7; ++i)
peers.push_back(json{{"addr", "10.0.0." + std::to_string(i)}});
return peers;
});
json r = mi.getpeerinfo("kat");
ASSERT_TRUE(r.is_array());
EXPECT_EQ(r.size(), 7u)
<< "getpeerinfo must return the real per-peer list from m_peer_info_fn";
EXPECT_EQ(r[0]["addr"].get<std::string>(), "10.0.0.0");
}
// Without a live hook and without a node, getpeerinfo returns an empty array --
// honest absence (no peers reported because none are observable), never a
// fabricated count.
TEST(WebHonestyRegression, GetPeerInfoEmptyWithoutHooksIsHonest) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
json r = mi.getpeerinfo("kat");
ASSERT_TRUE(r.is_array());
EXPECT_TRUE(r.empty())
<< "no node + no hook -> empty list (honest absence), not a fake count";
}
// --- rest_miner_stats: per-worker share-outcome labels, not the inversion lie -
// Before #467 the per-miner panel reported doa_shares = the stale counter and
// orphan_shares = 0 -- an inversion: stale-template shares are ORPHANs (stale_info
// 253), and there is no per-worker daemon-DOA *share* counter at all (DOA is
// surfaced as dead_hashrate, not a share count). The old labelling told the
// operator a worker had DOA shares it never had, and hid its real orphan rate.
// Also: low-diff/invalid submissions (rejected) were never exposed. Pin the
// corrected mapping so the inversion cannot silently return.
TEST(WebHonestyRegression, MinerStatsLabelsOrphanNotDoaAndExposesRejected) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
// One stratum worker for address "addr1" (worker suffix ".alpha" stripped):
// 100 accepted, 5 rejected (invalid/low-diff), 10 stale (expired template).
core::stratum::WorkerInfo w;
w.username = "addr1.alpha";
w.hashrate = 50e9;
w.dead_hashrate = 2e9; // DOA surfaced as hashrate, not a share count
w.difficulty = 1024.0;
w.accepted = 100;
w.rejected = 5;
w.stale = 10;
mi.register_stratum_worker("sess-1", w);
json r = mi.rest_miner_stats("addr1");
EXPECT_TRUE(r["active"].get<bool>())
<< "a registered worker for this address must read active";
// The corrected #467 mapping: stale -> ORPHAN, DOA share count -> 0.
EXPECT_EQ(r["orphan_shares"].get<uint64_t>(), 10u)
<< "stale-template shares are orphans (stale_info 253), not doa";
EXPECT_EQ(r["doa_shares"].get<uint64_t>(), 0u)
<< "no per-worker DOA share counter -- DOA lives in dead_hashrate, "
"never a copy of the stale counter (the pre-#467 inversion lie)";
EXPECT_EQ(r["dead_shares"].get<uint64_t>(), 10u)
<< "dead = orphan + doa; only orphans are counted per-worker here";
EXPECT_EQ(r["rejected_shares"].get<uint64_t>(), 5u)
<< "invalid/low-diff submissions must be exposed, never counted as shares";
EXPECT_EQ(r["total_shares"].get<uint64_t>(), 110u)
<< "total = accepted + stale (rejected are NOT shares)";
EXPECT_EQ(r["unstale_shares"].get<uint64_t>(), 100u);
EXPECT_DOUBLE_EQ(r["dead_hashrate"].get<double>(), 2e9)
<< "DOA is surfaced honestly as dead_hashrate";
// doa_rate is the stale fraction of submitted work: stale/(accepted+stale).
EXPECT_DOUBLE_EQ(r["doa_rate"].get<double>(), 10.0 / 110.0);
}
// An address with no matching worker reports inactive zeros -- honest absence,
// never fabricated activity.
TEST(WebHonestyRegression, MinerStatsUnknownAddressIsHonestlyInactive) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
json r = mi.rest_miner_stats("nobody");
EXPECT_FALSE(r["active"].get<bool>());
EXPECT_EQ(r["total_shares"].get<uint64_t>(), 0u);
EXPECT_EQ(r["orphan_shares"].get<uint64_t>(), 0u);
EXPECT_EQ(r["doa_shares"].get<uint64_t>(), 0u);
EXPECT_EQ(r["rejected_shares"].get<uint64_t>(), 0u);
}
// --- rest_stratum_security: signal not-instrumented, never a fake all-clear ---
// The old body returned fixed zeros (connections_per_second / potential_ddos /
// blacklisted_ips) so stratum.html painted "0.0 / normal / 0 banned / Normal"
// FOREVER -- a security widget that can never go red, the founding-charter lie
// class. #470 replaced it with an explicit not-instrumented signal so the page
// guard (`secData.error`) trips and the panel shows "-" instead of fake green.
// Pin: the endpoint must self-declare unavailable and must NOT emit the old
// fabricated all-clear fields.
TEST(WebHonestyRegression, StratumSecuritySignalsNotInstrumentedNotFakeGreen) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
json r = mi.rest_stratum_security();
ASSERT_TRUE(r.is_object());
EXPECT_FALSE(r.value("available", true))
<< "the panel must learn the metrics are unavailable";
EXPECT_EQ(r.value("error", std::string{}), "not_instrumented")
<< "the page guard keys off error == not_instrumented to show -";
// The fabricated all-clear fields that could paint permanent green must be
// gone -- their mere presence (even as 0) re-arms the can-never-go-red lie.
EXPECT_FALSE(r.contains("connections_per_second"));
EXPECT_FALSE(r.contains("potential_ddos"));
EXPECT_FALSE(r.contains("blacklisted_ips"));
EXPECT_FALSE(r.contains("threat_level"));
}
// --- charter #3: ratchet / crossing-state honesty ----------------------------
// The dashboard must tell the truth about the V35->V36 cross. Two founding-class
// lies are pinned here:
// (a) currency_info.share_version was hardcoded 36 -- a node still VOTING
// (producing V35 shares) would report 36, making the bundled sharechain-
// explorer misclassify live V35 share cells as V36 (#491).
// (b) v36_status.auto_ratchet.v36_active was a frozen stub -- it must derive
// from the LIVE ratchet latch (m_cached_share_version) so a node that has
// latched to V36 cannot be shown as still "voting", nor vice-versa (#499).
// Both are the same class of lie as the founding 0-stubs: the dashboard claiming
// one thing while the node is actually doing another, during the very crossing
// the operator is coordinating live on LTC prod.
// (a) currency_info.share_version reflects the LIVE ratchet output, never a
// static 36: a VOTING LTC node still mining V35 must report 35.
TEST(WebHonestyRegression, CurrencyInfoShareVersionIsLiveRatchetNotStatic36) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::LITECOIN);
mi.set_cached_share_version(35); // node still VOTING -- producing V35 shares
json r = mi.rest_web_currency_info();
EXPECT_EQ(r["share_version"].get<int64_t>(), 35)
<< "share_version must track m_cached_share_version (live ratchet), never "
"a hardcoded 36 -- reporting 36 while VOTING lies about the cross";
}
// currency_info.share_version follows the latch forward once the node crosses.
TEST(WebHonestyRegression, CurrencyInfoShareVersionFollowsLatchToV36) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::LITECOIN);
mi.set_cached_share_version(36); // node has latched to V36
json r = mi.rest_web_currency_info();
EXPECT_EQ(r["share_version"].get<int64_t>(), 36)
<< "share_version must follow the live latch to 36 once crossed";
}
// DASH is non-ratcheting on the LTC AutoRatchet path: its share_version stays
// the static protocol v16 even if the cached ratchet value is something else --
// the LTC ratchet cache must never bleed into a Dash dashboard.
TEST(WebHonestyRegression, CurrencyInfoDashShareVersionStaysStatic16) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::DASH);
mi.set_cached_share_version(35); // would be a lie to surface on Dash
json r = mi.rest_web_currency_info();
EXPECT_EQ(r["share_version"].get<int64_t>(), 16)
<< "Dash share_version is the static protocol v16, not the LTC ratchet cache";
}
// (b) v36_status.auto_ratchet.v36_active is derived from the LIVE latch
// (m_cached_share_version >= 36), not a frozen stub. With no sharechain stats
// wired, version_signaling returns {} and the chain-derived branch is skipped --
// isolating the latch derivation, which is exactly what #499 surfaces as ground
// truth so a transient sampling dip cannot misreport the crossing state.
TEST(WebHonestyRegression, V36StatusActiveLatchTracksLiveShareVersion) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::LITECOIN);
// Pre-cross: still on V35.
mi.set_cached_share_version(35);
json voting = mi.rest_v36_status();
ASSERT_TRUE(voting.contains("auto_ratchet"));
EXPECT_EQ(voting["auto_ratchet"]["live_share_version"].get<int64_t>(), 35);
EXPECT_FALSE(voting["auto_ratchet"]["v36_active"].get<bool>())
<< "v36_active must be false while the live latch is still V35";
// Post-cross: latched to V36.
mi.set_cached_share_version(36);
json active = mi.rest_v36_status();
ASSERT_TRUE(active.contains("auto_ratchet"));
EXPECT_EQ(active["auto_ratchet"]["live_share_version"].get<int64_t>(), 36);
EXPECT_TRUE(active["auto_ratchet"]["v36_active"].get<bool>())
<< "v36_active must latch true once the live ratchet reaches V36";
}
// (c) v36_status.share_chain.{v35_shares,v36_shares,v36_percentage} must reflect
// the DESIRED-version tally the AutoRatchet keys on (get_desired_version_weights),
// NOT the per-share FORMAT flag. During the graded g2 ratchet the .157 seed relays
// V35-FORMAT shares over p2p even while their miners vote V36, so a format-flag
// count reads 0 v36 straight through a real crossing -- the founding 0-stub class
// of lie, in the exact fields the operator curls to watch the vote climb. This pins
// the #288-style swap: build a sharechain-stats snapshot whose FORMAT is 100% V35
// yet whose DESIRED-version tally is ~33% V36 (one rig flipped), and assert the
// crossing counter tracks the vote, never the frozen format zero.
TEST(WebHonestyRegression, V36StatusShareChainTracksDesiredVersionNotFormatFlag) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::LITECOIN);
// Snapshot mirroring rig #1 (.37) flipped to V36 while .38/.39 stay V35 and the
// seed relays V35-format shares: FORMAT all V35, DESIRED-version ~1/3 V36, and a
// work-weighted sampling signal of ~33.33%.
mi.set_sharechain_stats_fn([]() {
nlohmann::json sc = nlohmann::json::object();
sc["total_shares"] = 400;
sc["chain_height"] = 400;
sc["chain_length"] = 400;
sc["shares_by_version"] = {{"35", 400}}; // FORMAT: 100% V35
sc["shares_by_desired_version"] = {{"35", 268}, {"36", 132}}; // VOTE: 132/400 = 33%
sc["sampling_desired_version"] = {{"35", 2.0}, {"36", 1.0}}; // work-weighted: 33.33%
return sc;
});
nlohmann::json v = mi.rest_v36_status();
ASSERT_TRUE(v.contains("share_chain"));
const auto& scj = v["share_chain"];
// The format-flag count IS zero here -- that is the pre-fix stub value the
// crossing counter used to echo. Pin it on the parent version_signaling object
// (its true source) so a refactor that reverts share_chain to the format tally
// is caught: the bug is real precisely because format says 0 while vote says 33.
nlohmann::json sig = mi.rest_version_signaling();
EXPECT_EQ(sig.value("overall_v36_shares", -1), 0)
<< "guard: this snapshot is the crossing where FORMAT is still 100% V35";
EXPECT_EQ(scj["v36_shares"].get<int>(), 132)
<< "v36_shares must be the desired-version vote count (132), not the "
"format-flag count (0) that lies through the crossing";
EXPECT_EQ(scj["v35_shares"].get<int>(), 268)
<< "v35_shares is the desired-version remainder (400-132), not 400";
EXPECT_NEAR(scj["v36_percentage"].get<double>(), 33.33, 0.01)
<< "v36_percentage must be the work-weighted sampling signal (the journal "
"N% old version complement the integrator reads), never 0";
}
// --- charter #3: crossing-banner coin coverage (de-allowlist, #496) ----------
// The V35->V36 crossing banner must surface on EVERY v36-ratcheting coin, not
// just LTC/DOGE. Before #496 a hardcoded {LITECOIN,DOGECOIN} allowlist gated
// rest_version_signaling(), so a BTC/DGB/BCH node mid-cross showed NO crossing
// banner -- the dashboard hid the very state the operator most needs to see
// during the upgrade. #496 replaced the allowlist with a single DASH (static
// v16, non-ratcheting) exclusion: every other coin derives the banner from real
// vote data and falls through to an empty result only when there is no real
// crossing state yet. These pins lock that in.
// A coin the OLD allowlist would have SUPPRESSED (BTC) must still surface the
// live crossing state from real vote data.
TEST(WebHonestyRegression, VersionSignalingSurfacesOnRatchetingBtcNotAllowlistSuppressed) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::BITCOIN);
// 100 shares: still producing V35, but 70% are VOTING V36.
mi.set_sharechain_stats_fn([] {
return json{
{"total_shares", 100},
{"chain_height", 8640},
{"chain_length", 8640},
{"shares_by_version", {{"35", 100}}},
{"shares_by_desired_version", {{"35", 30}, {"36", 70}}},
};
});
json r = mi.rest_version_signaling();
ASSERT_FALSE(r.empty())
<< "BTC is v36-ratcheting -- the pre-#496 {LTC,DOGE} allowlist must no "
"longer suppress its crossing banner";
EXPECT_EQ(r["target_version"].get<int>(), 36);
EXPECT_EQ(r["overall_v36_votes"].get<int>(), 70)
<< "vote tally must come from the live sharechain stats hook";
EXPECT_DOUBLE_EQ(r["overall_v36_vote_pct"].get<double>(), 70.0);
}
// DGB (scrypt, also v36-ratcheting) is likewise no longer suppressed.
TEST(WebHonestyRegression, VersionSignalingSurfacesOnRatchetingDgb) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::DIGIBYTE);
mi.set_sharechain_stats_fn([] {
return json{
{"total_shares", 50},
{"shares_by_version", {{"35", 50}}},
{"shares_by_desired_version", {{"36", 50}}},
};
});
json r = mi.rest_version_signaling();
ASSERT_FALSE(r.empty()) << "DGB is v36-ratcheting -- banner must not be suppressed";
EXPECT_EQ(r["target_version"].get<int>(), 36);
EXPECT_DOUBLE_EQ(r["overall_v36_vote_pct"].get<double>(), 100.0);
}
// DASH is non-ratcheting (static v16) -- the ONE coin #496 keeps excluded. Even
// with full vote data wired, its crossing banner stays hidden: surfacing a
// V35->V36 transition on a chain that has no such transition would be a lie.
TEST(WebHonestyRegression, VersionSignalingSuppressedOnNonRatchetingDash) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::DASH);
mi.set_sharechain_stats_fn([] {
return json{
{"total_shares", 100},
{"shares_by_version", {{"35", 100}}},
{"shares_by_desired_version", {{"36", 80}}},
};
});
EXPECT_TRUE(mi.rest_version_signaling().empty())
<< "Dash is static v16 (non-ratcheting) -- no V35->V36 crossing exists, so "
"the banner must stay hidden regardless of wired vote data";
}
// Honest-empty: a ratcheting coin with too little chain (<10 shares) has no real
// crossing state yet -- the banner stays hidden until there IS truth to show,
// rather than rendering a misleading transition.
TEST(WebHonestyRegression, VersionSignalingEmptyUntilRealCrossingState) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::BITCOIN);
mi.set_sharechain_stats_fn([] {
return json{{"total_shares", 5}, {"shares_by_desired_version", {{"36", 5}}}};
});
EXPECT_TRUE(mi.rest_version_signaling().empty())
<< "fewer than 10 shares -- no real crossing state, so no banner (honest)";
}
// ---------------------------------------------------------------------------
// Charter #2 (per-node truthful topology) regression-lock.
//
// rest_node_topology() must reflect THIS node's REAL shape -- config-driven /
// auto-detected -- never a baked-in coin list, and must NEVER fabricate an
// embedded-daemon "synced" flag in the auto-detect fallback. The only
// authoritative sync source is the per-coin StatsProvider hook
// (m_node_topology_fn): the explorer_chaininfo_fn reports SHARECHAIN height, a
// different thing, so deriving "synced" from the fallback would re-introduce
// the founding class of lie (dashboard asserting health it cannot see).
// ---------------------------------------------------------------------------
// Fallback path (no StatsProvider hook): the primary coin is always present and
// the object self-labels auto_detected, but it must OMIT "synced" rather than
// guess it. Omission is honest; a fabricated true/false is the founding lie.
TEST(WebHonestyRegression, NodeTopologyAutoDetectOmitsSyncedRatherThanGuess) {
// Default blockchain is LITECOIN -> node_symbol() == "LTC".
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
// No node_topology_fn, no coin_peers_fn: the bare auto-detect fallback.
json t = mi.rest_node_topology();
EXPECT_EQ(t.value("node_symbol", std::string{}), "LTC")
<< "primary symbol must come from the node's configured chain, not blank";
EXPECT_TRUE(t.value("auto_detected", false))
<< "fallback must self-label as auto-detected, not silently authoritative";
ASSERT_TRUE(t.contains("coins") && t["coins"].is_array());
bool saw_primary = false;
for (const auto& c : t["coins"]) {
if (c.value("coin", std::string{}) == "LTC") {
saw_primary = true;
EXPECT_TRUE(c.value("primary", false));
EXPECT_FALSE(c.contains("synced"))
<< "auto-detect fallback must NOT fabricate an embedded-daemon "
"synced flag -- omit-rather-than-guess (charter #2)";
}
}
EXPECT_TRUE(saw_primary) << "primary chain must always be present";
}
// The embedded/aux coin set is discovered from the live coin-peer map keys
// (e.g. an LTC node also running embedded DOGE), uppercased -- the node's REAL
// shape, never a hardcoded {ltc,doge} assumption.
TEST(WebHonestyRegression, NodeTopologyAutoDetectsEmbeddedCoinSetFromPeerMap) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
mi.set_coin_peers_fn([] {
return json{
{"ltc", json::array({json{{"addr", "10.0.0.1"}},
json{{"addr", "10.0.0.2"}}})},
{"doge", json::array({json{{"addr", "10.0.0.3"}}})},
};
});
json t = mi.rest_node_topology();
ASSERT_TRUE(t.contains("coins") && t["coins"].is_array());
int ltc_peers = -1, doge_peers = -1;
bool doge_primary = true;
for (const auto& c : t["coins"]) {
if (c.value("coin", std::string{}) == "LTC") ltc_peers = c.value("peers", -1);
if (c.value("coin", std::string{}) == "DOGE") {
doge_peers = c.value("peers", -1);
doge_primary = c.value("primary", false);
}
}
EXPECT_EQ(ltc_peers, 2) << "primary LTC peer count from the live map, uppercased";
EXPECT_EQ(doge_peers, 1) << "embedded DOGE auto-detected from the map, not baked in";
EXPECT_FALSE(doge_primary) << "only the configured chain is primary";
}
// When the wiring layer feeds the per-coin StatsProvider hook, its richer
// authoritative payload (real per-coin synced/tip) is returned verbatim -- the
// fallback must not override or strip it.
TEST(WebHonestyRegression, NodeTopologyPrefersStatsProviderHookWhenWired) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
mi.set_node_topology_fn([] {
return json{
{"node_symbol", "LTC"},
{"auto_detected", false},
{"coins", json::array({
json{{"coin", "LTC"}, {"primary", true},
{"peers", 30}, {"synced", true}, {"height", 2710001}},
})},
};
});
json t = mi.rest_node_topology();
EXPECT_FALSE(t.value("auto_detected", true))
<< "the StatsProvider hook is authoritative, not auto-detected";
ASSERT_TRUE(t.contains("coins") && !t["coins"].empty());
const auto& ltc = t["coins"][0];
EXPECT_TRUE(ltc.contains("synced") && ltc["synced"].get<bool>())
<< "a real per-coin synced flag from the hook must survive verbatim";
EXPECT_EQ(ltc.value("height", 0), 2710001)
<< "the hook's real embedded-daemon tip must survive verbatim";
}
// /patron_sendmany/<total> is an UNIMPLEMENTED payout-split helper. The charter
// rule is not "every endpoint must be real" -- it is "never let an unreal one
// read as real." This endpoint stays honest by SELF-LABELLING: it carries an
// explicit "patron_sendmany stub" note and an EMPTY destinations object, so the
// operator can never mistake it for a computed payout lane. This pins that
// disclosure: if someone fills destinations with fabricated splits, they must
// also drop the stub label (and wire real data) or this fails loudly. A
// non-empty destinations while still flagged a stub is exactly the silent-lie
// regression we forbid.
TEST(WebHonestyRegression, PatronSendmanySelfLabelsAsStubNeverFabricatesPayouts) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr);
json p = mi.rest_patron_sendmany("12.5");
ASSERT_TRUE(p.contains("destinations"));
const bool labelled_stub =
p.value("note", std::string{}).find("stub") != std::string::npos;
const bool has_payouts =
p["destinations"].is_object() && !p["destinations"].empty();
EXPECT_TRUE(labelled_stub)
<< "an unimplemented payout helper must self-label as a stub";
EXPECT_FALSE(has_payouts)
<< "a stub must not present fabricated payout destinations as real";
// The forbidden state: data shown WHILE still flagged a stub.
EXPECT_FALSE(labelled_stub && has_payouts)
<< "never label-as-stub while surfacing payout splits -- silent lie";
EXPECT_EQ(p.value("total", std::string{}), "12.5")
<< "the echoed total must be the operator-supplied value, not invented";
}
// ── Stratum-URL external_ip override (DASH NAT/port-mapped nodes) ──────────
// The dashboard Stratum-URL card renders nodeInfo.external_ip
// (dashboard.html:2889-2892). When a node NATs out through a shared gateway
// (both hotel DASH nodes: LAN 192.168.1.x, one public 31.172.65.125), the
// auto-detected OUTBOUND IP is NOT the address miners dial -- they reach the
// external-mapped hosts (109.161.57.3 / 109.161.52.148). c2pool-dash exposes
// --external-ip (alias --stratum-advertise / --public-host) which feeds
// set_external_ip(); rest_node_info() must then SERVE that operator-supplied
// host verbatim so the Stratum URL is truthful. Unset must stay honest-absent
// ("0.0.0.0"), leaving the auto-detect / window.location.hostname fallback --
// no regression. Pins the flag -> served-external_ip plumbing.
TEST(WebHonestyRegression, NodeInfoExternalIpUnsetIsHonestlyUnspecified) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::DASH);
json ni = mi.rest_node_info();
EXPECT_EQ(ni.value("external_ip", std::string{}), "0.0.0.0")
<< "unset external_ip must serve the honest-absent sentinel so the "
"dashboard falls back to auto-detect / window.location.hostname";
}
TEST(WebHonestyRegression, NodeInfoExternalIpServesOperatorAdvertisedHost) {
MiningInterface mi(/*testnet=*/true, /*node=*/nullptr, Blockchain::DASH);
// Operator advertises the real miner-facing external-mapped host (primary
// hotel node), NOT the auto-detected 31.172.65.125 NAT gateway.
mi.set_external_ip("109.161.57.3");
json ni = mi.rest_node_info();
EXPECT_EQ(ni.value("external_ip", std::string{}), "109.161.57.3")
<< "served external_ip must be the operator-advertised miner-facing "
"host so the dashboard Stratum URL is not the wrong NAT IP";
}