-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
1926 lines (1778 loc) · 123 KB
/
Copy pathserver.py
File metadata and controls
1926 lines (1778 loc) · 123 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
#!/usr/bin/env python3
"""
SoloLuck : public-facing solo Bitcoin mining pool landing + stats.
Asia's community solo Bitcoin pool. Mine to your OWN address; if YOU strike a
block YOU keep the whole reward minus a flat 2% fee. Non-custodial — no account,
no KYC, we never hold your coins. Transparent: real hashrate, real odds, real
blocks, real fee.
PUBLIC / INTERNET-FACING. Bind 127.0.0.1:8201 only; nginx is wired in front of
this separately. Stdlib only — no external assets, no trackers, no cookies.
Data sources (read-only, already-public):
- pool stats JSON
- solved-block markers from the pool log (+ optional blocks file).
SANITIZATION (this is public):
* NEVER expose any node internals (version/peers/mempool/sync/halving/
retarget) -> we never call RPC and never read node state here.
* NEVER expose the operator payout/fee BTC address.
* NEVER dump the worker fleet as "our miners". Only pool-WIDE aggregates are
shown on the landing page. Per-address worker detail is only returned for an
address explicitly queried at /users/<addr>.
* No RPC creds, no internal IPs/hostnames, no infra/hosting jargon, no
operator/admin identifiers anywhere in user-visible output.
"""
import json
import math
import re
import html
import time
import os
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ---------------------------------------------------------------------------
# Config (public-safe constants only)
# ---------------------------------------------------------------------------
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 8201
CKPOOL_STATS_URL = os.getenv("SOLOLUCK_CKPOOL_STATS_URL", "http://127.0.0.1:8888/api/stats")
CKPOOL_LOG = "/var/log/ckpool/ckpool.log"
CKPOOL_BLOCKS_FILE = "/var/log/ckpool/blocks"
# Public-facing connection details (the public stratum endpoint IS public).
POOL_NAME = "SoloLuck"
POOL_TAGLINE = "Asia's community solo Bitcoin pool"
POOL_PITCH = ("Mine to your own address. Strike a block and you keep the whole "
"reward minus a flat 2% fee — paid straight to you on-chain. "
"Non-custodial: no account, no KYC, we never hold your coins.")
POOL_FEE_PCT = 2
STRATUM_HOST = os.getenv("SOLOLUCK_STRATUM_HOST", "127.0.0.1") # your public stratum host/IP
STRATUM_PORT_GENERAL = 3333
STRATUM_PORT_HIGHDIFF = 4334
# Connection tiers (public-safe). The Standard tier is gated by STANDARD_LIVE:
# while False we never advertise a usable stratum URL there (Copy disabled,
# marked 'coming soon') so we never point miners at a port that does not accept
# stratum.
STANDARD_LIVE = True
STRATUM_PORT_STANDARD = 8081
STATS_TIMEOUT = 4 # seconds
# A bare BTC-address shape. We only ever *match* an address the visitor supplied;
# we never enumerate or reveal any address ourselves.
BTC_ADDR_RE = re.compile(r"^(bc1[a-z0-9]{20,90}|[13][a-km-zA-HJ-NP-Z1-9]{20,40})$")
# ---------------------------------------------------------------------------
# Hashrate helpers
# ---------------------------------------------------------------------------
_HASH_UNITS = {"": 1, "K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12, "P": 1e15, "E": 1e18}
def parse_hashrate(s):
"""ckpool gives e.g. '22.4T' -> float hashes/s. Returns 0.0 on failure."""
if s is None:
return 0.0
if isinstance(s, (int, float)):
return float(s)
s = str(s).strip()
m = re.match(r"^([0-9.]+)\s*([KMGTPE]?)", s, re.I)
if not m:
return 0.0
try:
val = float(m.group(1))
except ValueError:
return 0.0
return val * _HASH_UNITS.get(m.group(2).upper(), 1)
def _words(n):
n = float(n or 0)
if n >= 1e12: return "%.1f trillion" % (n / 1e12)
if n >= 1e9: return "%.1f billion" % (n / 1e9)
if n >= 1e6: return "%.1f million" % (n / 1e6)
if n >= 1e3: return "%.0f thousand" % (n / 1e3)
return "%.0f" % n
def _one_in(fr):
return ("1 in %s" % _words(1.0 / fr)) if (fr and fr > 0) else "--"
def _years_words(s):
if not s or s <= 0 or not math.isfinite(s):
return "--"
y = s / 31557600.0
if y < 1:
d = s / 86400.0
return ("%d days" % round(d)) if d >= 1 else ("%d hours" % round(s / 3600.0))
return "≈ %s years" % _words(y)
def _human_pct(p):
p = float(p or 0)
if p <= 0: return "0%"
if p >= 1: return "%.2f%%" % p
d = min(12, max(2, 2 - int(math.floor(math.log10(p)))))
return ("%.*f" % (d, p)).rstrip("0").rstrip(".") + "%"
# Network context (difficulty / network hashrate / subsidy) from the local admin
# dashboard, cached — so each public page hit doesn't re-query.
_ADMIN_STATE = {"t": 0.0, "v": None}
def _admin_state():
"""Cached full snapshot from the internal :8200 dashboard. The network
context AND the rolling pool-hashrate history both live in this payload, so a
single (cached) fetch feeds both. Returns {} on failure. Nothing from this is
served raw to the public — callers pick out only sanitized fields."""
now = time.time()
if _ADMIN_STATE["v"] is not None and now - _ADMIN_STATE["t"] < 60:
return _ADMIN_STATE["v"]
d = {}
try:
with urllib.request.urlopen(os.getenv("SOLOLUCK_ADMIN_STATE_URL", "http://127.0.0.1:8200/api/state"), timeout=5) as r:
d = json.loads(r.read().decode("utf-8", "replace"))
except Exception:
d = {}
_ADMIN_STATE["v"] = d
_ADMIN_STATE["t"] = now
return d
_NETCTX = {"t": 0.0, "v": None}
def network_ctx():
now = time.time()
if _NETCTX["v"] is not None and now - _NETCTX["t"] < 60:
return _NETCTX["v"]
ctx = {}
try:
d = _admin_state()
n = d.get("node", {}) or {}
L = d.get("lottery", {}) or {}
ctx = {"difficulty": n.get("difficulty") or L.get("difficulty"),
"nethash": L.get("network_hashrate") or n.get("networkhashps"),
"subsidy": n.get("subsidy")}
except Exception:
ctx = {}
_NETCTX["v"] = ctx
_NETCTX["t"] = now
return ctx
# ── pool hashrate history (powers the landing-page chart) ─────────────────────
# A self-contained, 10-minute-bucketed ring of pool-WIDE hashrate kept for 24h.
# Seeded once from the internal sampler's fine-grained history (so the chart is
# populated instantly), then maintained by our own 60s sampler thread. The public
# series exposes ONLY aggregate {t, hr, w} — no operator address, no node
# internals, no per-IP data. Buckets store a running MEAN of instantaneous
# hashrate, so the in-progress bucket is valid even when partial (unlike a
# share-accumulation bucket) and the chart can stay live to the latest point.
POOL_HISTORY_PATH = os.getenv("SOLOLUCK_POOL_HISTORY",
"/opt/coregrid-pool-public/pool_history.json")
HIST_BUCKET_SEC = 600 # 10-minute buckets (matches public-pool.io)
HIST_MAX_BUCKETS = 144 # 144 x 10min = 24h
_hist_lock = threading.Lock()
def _bucket_key(t):
return int(t // HIST_BUCKET_SEC) * HIST_BUCKET_SEC
def _load_pool_history():
try:
with open(POOL_HISTORY_PATH) as f:
h = json.load(f)
return h if isinstance(h, list) else []
except Exception:
return []
def _save_pool_history(h):
tmp = POOL_HISTORY_PATH + ".tmp"
try:
with open(tmp, "w") as f:
json.dump(h, f)
os.replace(tmp, POOL_HISTORY_PATH)
except Exception:
pass
def _record_pool_sample(ts, hr, w):
"""Fold one (timestamp, hashrate H/s, worker-count) reading into the ring,
updating the in-progress 10-min bucket in place (running mean)."""
try:
hr = float(hr or 0)
except (TypeError, ValueError):
return
if hr <= 0:
return
try:
w = int(w or 0)
except (TypeError, ValueError):
w = 0
key = _bucket_key(ts)
with _hist_lock:
h = _load_pool_history()
if h and h[-1].get("t") == key:
b = h[-1]
n = b.get("n", 1)
b["hr"] = (b.get("hr", hr) * n + hr) / (n + 1)
b["n"] = n + 1
b["w"] = max(b.get("w", 0), w)
else:
h.append({"t": key, "hr": hr, "w": w, "n": 1})
if len(h) > HIST_MAX_BUCKETS:
h = h[-HIST_MAX_BUCKETS:]
_save_pool_history(h)
def _backfill_pool_history():
"""One-time seed of the 10-min ring from the internal dashboard's fine (10s)
rolling history, so the chart shows real data from the first page view."""
if len(_load_pool_history()) >= 3:
return
pts = (_admin_state() or {}).get("history") or []
buckets = {}
for p in pts:
try:
t = int(p["t"]); hr = float(p["hr"]); w = int(p.get("w") or 0)
except (KeyError, TypeError, ValueError):
continue
if hr <= 0:
continue
k = _bucket_key(t)
b = buckets.get(k)
if b is None:
buckets[k] = {"t": k, "hr": hr, "w": w, "n": 1}
else:
b["hr"] = (b["hr"] * b["n"] + hr) / (b["n"] + 1)
b["n"] += 1
b["w"] = max(b["w"], w)
seeded = [buckets[k] for k in sorted(buckets)][-HIST_MAX_BUCKETS:]
if seeded:
with _hist_lock:
if len(_load_pool_history()) < 3:
_save_pool_history(seeded)
def pool_history():
"""Public, sanitized hashrate series for the chart: list of {t, hr, w}
oldest->newest, <=144 points. Keeps the in-progress bucket (our buckets are
running means, valid even when partial) so the chart stays live."""
return [{"t": int(b.get("t", 0)),
"hr": round(float(b.get("hr", 0)), 2),
"w": int(b.get("w", 0))}
for b in _load_pool_history() if b.get("hr")]
# --- per-address 24h history (bounded) -------------------------------------
ADDR_HISTORY_PATH = os.getenv("SOLOLUCK_ADDR_HISTORY",
"/opt/coregrid-pool-public/addr_history.json")
ADDR_HIST_MAX_ADDRS = 300 # cap distinct addresses tracked (bounds file size)
_addr_lock = threading.Lock()
def _load_addr_history():
try:
with open(ADDR_HISTORY_PATH) as f:
h = json.load(f)
return h if isinstance(h, dict) else {}
except Exception:
return {}
def _save_addr_history(h):
tmp = ADDR_HISTORY_PATH + ".tmp"
try:
with open(tmp, "w") as f:
json.dump(h, f)
os.replace(tmp, ADDR_HISTORY_PATH)
except Exception:
pass
def _record_addr_samples(ts, agg):
"""agg: {address: (hashrate_sum H/s, worker_count)}. Fold each into its own
10-min running-mean ring (same bucketing as the pool ring). Bounded to
<=144 buckets/address and <=ADDR_HIST_MAX_ADDRS addresses (evict least-recent)."""
if not agg:
return
key = _bucket_key(ts)
cutoff = key - HIST_MAX_BUCKETS * HIST_BUCKET_SEC
with _addr_lock:
h = _load_addr_history()
for addr, (hr, w) in agg.items():
try:
hr = float(hr or 0)
except (TypeError, ValueError):
continue
if hr <= 0:
continue
try:
w = int(w or 0)
except (TypeError, ValueError):
w = 0
ring = h.get(addr) or []
if ring and ring[-1].get("t") == key:
b = ring[-1]; n = b.get("n", 1)
b["hr"] = (b.get("hr", hr) * n + hr) / (n + 1)
b["n"] = n + 1
b["w"] = max(b.get("w", 0), w)
else:
ring.append({"t": key, "hr": hr, "w": w, "n": 1})
h[addr] = [b for b in ring if b.get("t", 0) > cutoff][-HIST_MAX_BUCKETS:]
h = {a: r for a, r in h.items() if r} # drop emptied rings
if len(h) > ADDR_HIST_MAX_ADDRS: # evict least-recently-active
ranked = sorted(h.items(), key=lambda kv: kv[1][-1].get("t", 0), reverse=True)
h = dict(ranked[:ADDR_HIST_MAX_ADDRS])
_save_addr_history(h)
def addr_history(address):
"""Sanitized per-address series {t,hr,w} oldest->newest for the SSR sparkline."""
ring = _load_addr_history().get(address) or []
return [{"t": int(b.get("t", 0)), "hr": round(float(b.get("hr", 0)), 2),
"w": int(b.get("w", 0))} for b in ring if b.get("hr")]
def _pool_history_sampler():
"""Background thread: backfill once, then sample pool hashrate every 60s."""
try:
_backfill_pool_history()
except Exception:
pass
while True:
try:
stats = fetch_stats()
sd = stats if isinstance(stats, dict) else {}
pool = sd.get("pool", {})
now = time.time()
_record_pool_sample(now,
parse_hashrate(pool.get("hashrate1m")),
pool.get("Workers"))
# aggregate the same worker list per base-address (no extra fetch)
agg = {}
for wk in (sd.get("workers") or []):
name = str(wk.get("workername") or wk.get("name") or "")
base = name.split(".", 1)[0]
if not base:
continue
hr, wc = agg.get(base, (0.0, 0))
agg[base] = (hr + parse_hashrate(wk.get("hashrate1m")), wc + 1)
_record_addr_samples(now, agg)
except Exception:
pass
time.sleep(60)
def fmt_hashrate(h):
"""float hashes/s -> human string like '22.4 TH/s'."""
h = float(h or 0)
for unit, scale in (("EH/s", 1e18), ("PH/s", 1e15), ("TH/s", 1e12),
("GH/s", 1e9), ("MH/s", 1e6), ("KH/s", 1e3)):
if h >= scale:
return "%.2f %s" % (h / scale, unit)
return "%.0f H/s" % h
# ---------------------------------------------------------------------------
# Data acquisition (read-only)
# ---------------------------------------------------------------------------
def fetch_stats():
"""Fetch ckpool stats JSON. Returns dict or None."""
try:
req = urllib.request.Request(CKPOOL_STATS_URL,
headers={"User-Agent": "sololuck-public"})
with urllib.request.urlopen(req, timeout=STATS_TIMEOUT) as r:
return json.loads(r.read().decode("utf-8", "replace"))
except Exception:
return None
# Cache for read_solved_blocks(): scanning the multi-MB ckpool.log on every
# request was the entire page latency (~1.6s) and caused pile-ups/timeouts under
# concurrent load. Solved blocks change essentially never, so a short TTL is safe.
_BLOCKS_CACHE = {"t": 0.0, "v": None}
_BLOCKS_TTL = 120 # seconds
def read_solved_blocks():
"""
Return a list of pool-SOLVED blocks (public, celebratory info only):
[{"height": int|None, "hash": str|None, "when": str|None}, ...]
We scan ckpool's own solved-block markers. We deliberately IGNORE
'ZMQ block hash' / 'Block hash changed' lines -- those are network block
notifications, NOT blocks this pool found.
Result is cached for _BLOCKS_TTL seconds to avoid re-scanning the (large,
ever-growing) ckpool log on every page hit.
"""
now = time.time()
if _BLOCKS_CACHE["v"] is not None and (now - _BLOCKS_CACHE["t"]) < _BLOCKS_TTL:
return _BLOCKS_CACHE["v"]
blocks = []
seen = set()
# 1) Optional dedicated blocks file (ckpool writes one when configured).
try:
with open(CKPOOL_BLOCKS_FILE, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
rec = {"height": None, "hash": None, "when": None}
try:
obj = json.loads(line)
rec["height"] = obj.get("height") or obj.get("blockheight")
rec["hash"] = obj.get("hash") or obj.get("blockhash")
rec["when"] = obj.get("when") or obj.get("createdate")
except Exception:
hh = re.search(r"\b(\d{6,8})\b", line)
if hh:
rec["height"] = int(hh.group(1))
hx = re.search(r"\b([0-9a-f]{64})\b", line)
if hx:
rec["hash"] = hx.group(1)
key = rec["hash"] or rec["height"]
if key and key not in seen:
seen.add(key)
blocks.append(rec)
except FileNotFoundError:
pass
except Exception:
pass
# 2) Canonical solved-block lines in the main ckpool log.
# ckpool emits things like "Solved and confirmed block 840000 by ..." /
# "BLOCK ACCEPTED!" when this pool solves a block.
solve_re = re.compile(
r"(BLOCK ACCEPTED|Solved and confirmed block|Block solve|Solved block)",
re.I)
try:
with open(CKPOOL_LOG, "r", encoding="utf-8", errors="replace") as f:
for line in f:
if not solve_re.search(line):
continue
ts = re.match(r"\[([^\]]+)\]", line)
hh = re.search(r"block\s+(\d{6,8})", line, re.I)
hx = re.search(r"\b([0-9a-f]{64})\b", line)
rec = {
"height": int(hh.group(1)) if hh else None,
"hash": hx.group(1) if hx else None,
"when": ts.group(1) if ts else None,
}
key = rec["hash"] or rec["height"] or line.strip()
if key not in seen:
seen.add(key)
blocks.append(rec)
except FileNotFoundError:
pass
except Exception:
pass
_BLOCKS_CACHE["v"] = blocks
_BLOCKS_CACHE["t"] = now
return blocks
# ---------------------------------------------------------------------------
# Public-safe view models
# ---------------------------------------------------------------------------
def build_public_view():
"""
Assemble ONLY public-safe, pool-WIDE data. No per-worker fleet detail,
no operator address, no node internals.
"""
stats = fetch_stats()
pool = (stats or {}).get("pool", {}) if isinstance(stats, dict) else {}
def gp(k, default=None):
return pool.get(k, default)
view = {
"pool_name": POOL_NAME,
"tagline": POOL_TAGLINE,
"fee_pct": POOL_FEE_PCT,
"online": stats is not None,
"hashrate": {
"1m": gp("hashrate1m"),
"5m": gp("hashrate5m"),
"1h": gp("hashrate1hr"),
"1d": gp("hashrate1d"),
},
"workers": gp("Workers"),
# bestshare is a difficulty number, not identifying anyone -> safe.
"bestshare": gp("bestshare"),
# network share difficulty target context, if ckpool exposes it.
"network_diff": None, # was ckpool share-progress (0.01), misleading as network diff -> hidden
"network": (lambda nc: {"difficulty": nc.get("difficulty"),
"hashrate": nc.get("nethash"),
"subsidy": nc.get("subsidy")})(network_ctx()),
"blocks": read_solved_blocks(),
"stratum": {
"host": STRATUM_HOST,
"port_general": STRATUM_PORT_GENERAL,
},
"generated_at": (stats or {}).get("generated_at") if isinstance(stats, dict) else None,
# rolling 24h pool-wide hashrate series for the landing-page chart
"history": pool_history(),
}
return view
def build_address_view(address):
"""
Per-address detail. ONLY returned for the address the visitor explicitly
requested. In true-solo mode usernames are 'address' or 'address.worker'.
Returns dict {found: bool, address, workers: [...], totals: {...}}.
"""
out = {"found": False, "address": address, "workers": [], "totals": {}}
stats = fetch_stats()
if not isinstance(stats, dict):
return out
workers = stats.get("workers") or []
addr_lower = address.lower()
matched = []
for w in workers:
name = str(w.get("workername") or w.get("name") or "")
# Match the worker's username against the queried address. A worker is
# this address's iff name == address OR name == address.worker.
base = name.split(".", 1)[0]
if base.lower() == addr_lower:
matched.append(w)
if not matched:
return out
out["found"] = True
total_1m = total_5m = total_1h = total_1d = 0.0
total_shares = 0
best_ever = 0.0
last_share = 0
for w in matched:
name = str(w.get("workername") or w.get("name") or "")
# Label only the worker suffix the visitor themselves chose; if bare,
# show '(default)'. We never decorate with anything internal.
if "." in name:
label = name.split(".", 1)[1]
else:
label = "(default)"
h1m = parse_hashrate(w.get("hashrate1m"))
h5m = parse_hashrate(w.get("hashrate5m"))
h1h = parse_hashrate(w.get("hashrate1hr"))
h1d = parse_hashrate(w.get("hashrate1d"))
shares = int(w.get("shares") or 0)
bev = float(w.get("bestever") or w.get("bestshare") or 0)
ls = int(w.get("lastshare") or 0)
online = bool(w.get("online", h1m > 0))
total_1m += h1m
total_5m += h5m
total_1h += h1h
total_1d += h1d
total_shares += shares
best_ever = max(best_ever, bev)
last_share = max(last_share, ls)
out["workers"].append({
"worker": label,
"hashrate1m": fmt_hashrate(h1m),
"hashrate5m": fmt_hashrate(h5m),
"hashrate1h": fmt_hashrate(h1h),
"hashrate1d": fmt_hashrate(h1d),
"shares": shares,
"bestever": int(bev),
"lastshare": ls,
"online": online,
})
out["totals"] = {
"hashrate1m": fmt_hashrate(total_1m),
"hashrate5m": fmt_hashrate(total_5m),
"hashrate1h": fmt_hashrate(total_1h),
"hashrate1d": fmt_hashrate(total_1d),
"shares": total_shares,
"bestever": int(best_ever),
"lastshare": last_share,
"worker_count": len(matched),
}
# Per-miner solo odds — computed for THIS address's hashrate alone (true solo).
nc = network_ctx()
diff = nc.get("difficulty"); nethash = nc.get("nethash"); subsidy = nc.get("subsidy")
uhr = total_1h or total_5m or total_1m # steadiest available hashrate
odds = {"eta": "--", "closest": "--", "share": "--", "yield": "--",
"luck": "--", "nethash": fmt_hashrate(nethash) if nethash else "--"}
if diff and uhr > 0:
eta = diff * (2 ** 32) / uhr
odds["eta"] = _years_words(eta)
if subsidy:
sats = subsidy * 86400.0 / eta * 1e8
odds["yield"] = ("{:,} sats/day".format(int(round(sats))) if sats >= 1
else "%d sats/mo" % int(round(sats * 30)))
if nethash and uhr > 0:
odds["share"] = _one_in(uhr / nethash)
if diff and best_ever > 0:
odds["closest"] = _one_in(best_ever / diff)
if diff and total_shares > 0:
odds["luck"] = _human_pct(total_shares / diff * 100.0)
out["odds"] = odds
return out
# ---------------------------------------------------------------------------
# HTML / CSS / JS (rendering layer — minimal-credible, Direction #1)
# ---------------------------------------------------------------------------
PAGE_CSS = """
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:#0b0e14;color:#dfe6f0;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
line-height:1.55;overflow-wrap:anywhere}
a{color:#f7931a;text-decoration:none}a:hover{text-decoration:underline}
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.wrap{max-width:860px;margin:0 auto;padding:30px 20px 60px}
header{text-align:center;padding:18px 0 4px}
h1{font-size:30px;margin:0 0 8px;letter-spacing:.4px;font-weight:700}
h1 .b{color:#f7931a}
h1.brand{font-size:40px;margin:0 0 2px;letter-spacing:-.5px}
.tagline{color:#f7931a;font-size:15px;font-weight:600;letter-spacing:.3px;margin:0 0 10px}
.pitch{color:#9fb0c5;max-width:640px;margin:0 auto;font-size:15px}
/* live status strip */
.statusbar{margin:16px auto 4px;font-size:14px;color:#9fb0c5;max-width:680px}
.statusbar .hl{color:#dfe6f0;font-weight:600}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#3ad17a;
margin-right:6px;vertical-align:middle;animation:pulse 1.6s infinite}
.dot.off{background:#d1593a;animation:none}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.trust{margin:10px auto 0;max-width:640px;color:#8295ad;font-size:13px}
/* cards */
.card{background:#131826;border:1px solid #1c2436;border-radius:12px;
padding:18px 20px;margin:18px 0}
.card h2{margin:0 0 14px;font-size:17px;color:#f7931a;font-weight:600}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.stat{background:#0e1320;border:1px solid #1c2436;border-radius:10px;padding:12px 14px}
.stat .k{font-size:11px;color:#8295ad;text-transform:uppercase;letter-spacing:.7px}
.stat .v{font-size:21px;font-weight:600;margin-top:4px}
.stat .sub{font-size:11px;color:#8295ad;margin-top:3px;line-height:1.3}
.muted{color:#8295ad;font-size:13px}
.note{color:#9fb0c5;font-size:14px;margin:0 0 14px}
/* tiers */
.tiers{display:grid;grid-template-columns:1fr;gap:14px}
@media(min-width:720px){.tiers{grid-template-columns:repeat(3,1fr)}}
.tier{position:relative;background:#0e1320;border:1px solid #1c2436;border-radius:10px;
padding:16px 16px 14px;border-left:3px solid #2a3550}
.tier.lite{border-left-color:#6f86b8}
.tier.pro{border-left-color:#f7931a}
.tier.std{border:1px solid #f7931a;border-left:3px solid #f7931a;
background:linear-gradient(180deg,rgba(247,147,26,.07),rgba(247,147,26,.02))}
.tier .tname{font-weight:700;font-size:15px;color:#dfe6f0;margin:0 0 2px}
.tier .role{color:#9fb0c5;font-size:13px;margin:0 0 10px}
.tier .urlrow{display:flex;gap:6px;align-items:stretch;margin:0 0 10px}
.tier .url{flex:1;min-width:0;background:#0b0e14;border:1px solid #1c2436;border-radius:7px;
padding:8px 9px;font-size:12px;color:#7fd1a8;word-break:break-all}
.tier .url.dim{color:#5d6b80}
.copy{flex:none;background:#1c2436;color:#dfe6f0;border:1px solid #2a3550;border-radius:7px;
padding:0 11px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
.copy:hover{background:#222c3f}
.copy[disabled]{opacity:.4;cursor:not-allowed}
.tier .meta{font-size:12.5px;color:#c2cee0;margin:2px 0}
.tier .meta b{color:#dfe6f0}
.tier .egs{font-size:12px;color:#8295ad;margin:8px 0 0}
.ribbon{position:absolute;top:-9px;right:12px;background:#f7931a;color:#0b0e14;
font-size:10px;font-weight:700;letter-spacing:.6px;border-radius:20px;padding:3px 9px}
.soon{position:absolute;top:-9px;left:12px;background:#2a3550;color:#9fb0c5;
font-size:10px;font-weight:700;letter-spacing:.6px;border-radius:20px;padding:3px 9px}
.login{margin:16px 0 0;padding:13px 15px;background:#0b0e14;border:1px solid #1c2436;
border-radius:9px;font-size:13.5px;color:#c2cee0}
.login code{color:#7fd1a8}
/* lookup */
.lookform{display:flex;flex-wrap:wrap;gap:9px;margin-top:6px}
input{background:#0e1320;border:1px solid #2a3550;color:#dfe6f0;border-radius:8px;
padding:10px 12px;font-family:ui-monospace,monospace;font-size:13px;flex:1;min-width:200px}
input:focus{border-color:#f7931a}
a:focus-visible,button:focus-visible,summary:focus-visible,input:focus-visible,.langtoggle a:focus-visible{outline:2px solid #f7931a;outline-offset:2px;border-radius:4px}
.btn{background:#f7931a;color:#0b0e14;border:none;border-radius:8px;padding:10px 18px;
font-weight:700;cursor:pointer;font-size:14px}
.btn:hover{opacity:.92}
.cta{display:inline-block;text-decoration:none;font-size:15px;padding:11px 22px}
.cta:hover{text-decoration:none}
/* fee */
.feebig{font-size:46px;font-weight:700;color:#f7931a;line-height:1;margin:2px 0 4px}
/* blocks + tables */
.blocks{margin:6px 0;padding-left:20px}
.blocks li{font-family:ui-monospace,monospace;font-size:13px;margin:4px 0;color:#c2cee0}
ul.bullets{margin:6px 0;padding-left:20px}
ul.bullets li{margin:5px 0;color:#c2cee0}
table{width:100%;border-collapse:collapse;font-size:13px}
.tblwrap{overflow-x:auto}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid #1c2436;white-space:nowrap}
th{color:#8295ad;font-weight:500;font-size:11px;text-transform:uppercase}
footer{text-align:center;color:#5d6b80;font-size:12px;margin-top:32px}
details{border-bottom:1px solid #1c2436;padding:10px 0}
details:last-child{border-bottom:0}
summary{cursor:pointer;font-weight:600;color:#dfe6f0;list-style:none}
summary::-webkit-details-marker{display:none}
summary:before{content:"+ ";color:#f7931a;font-weight:700}
details[open] summary:before{content:"– "}
details p{margin:8px 0 2px}
#calcUnit{background:#0b0e14;color:#dfe6f0;border:1px solid #1c2436;border-radius:8px}
.langtoggle{display:flex;gap:6px;justify-content:flex-end;margin:0 0 8px}
.langtoggle{flex-wrap:wrap}
.langtoggle a{background:#131a26;border:1px solid #1c2436;border-radius:7px;padding:3px 8px;cursor:pointer;font-size:17px;line-height:1.1;text-decoration:none;filter:grayscale(.5);opacity:.7;transition:all .15s}
.langtoggle a:hover{filter:none;opacity:1}
.langtoggle a.on{background:#f7931a;border-color:#f7931a;filter:none;opacity:1;box-shadow:0 0 0 2px rgba(247,147,26,.3)}
/* pool hashrate chart */
.hrwrap{position:relative;margin-top:4px}
#hrSvg{width:100%;height:auto;display:block;touch-action:none;cursor:crosshair}
.hrgrid line{stroke:#1b2233;stroke-width:1}
.hraxis text{fill:#7a8699;font-size:11px}
.hr-empty{position:absolute;top:50%;left:0;right:0;text-align:center;transform:translateY(-50%);color:#5d6b80;font-size:13px;pointer-events:none}
.hr-tip{position:absolute;pointer-events:none;z-index:5;background:#0e1320;border:1px solid #243049;border-radius:8px;padding:7px 10px;font-size:12px;color:#dfe6f0;min-width:128px;box-shadow:0 6px 22px rgba(0,0,0,.45)}
.hr-tip .t{color:#8295ad}
.hr-tip .hv{font-size:15px;font-weight:700;color:#f7931a;margin:1px 0}
.hr-legend{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:center;margin-top:10px;color:#9fb0c5;font-size:12px}
.hr-legend i.sw{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:middle}
"""
def fmt_unix(ts):
if not ts:
return "—"
try:
import datetime
return datetime.datetime.utcfromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M UTC")
except Exception:
return str(ts)
def fmt_int(n):
"""Thousands-separated integer, or em-dash."""
if n is None or n == "":
return "—"
try:
return "{:,}".format(int(float(n)))
except (ValueError, TypeError):
return html.escape(str(n))
def _connect_card():
"""
Three-tier connect section. Standard (:8081) is gated by STANDARD_LIVE:
while False we never advertise a usable stratum URL there (Copy disabled,
marked 'coming soon') because the port does not yet accept stratum.
"""
lite_url = "stratum+tcp://%s:%d" % (STRATUM_HOST, STRATUM_PORT_GENERAL)
pro_url = "stratum+tcp://%s:%d" % (STRATUM_HOST, STRATUM_PORT_HIGHDIFF)
std_url = "stratum+tcp://%s:%d" % (STRATUM_HOST, STRATUM_PORT_STANDARD)
# Standard tier varies by whether the port is genuinely live.
if STANDARD_LIVE:
std_badge = "<span class='ribbon'>START HERE</span>"
std_url_cls = "url"
std_copy = ("<button class='copy' type='button' "
"data-copy='%s'>Copy</button>" % html.escape(std_url))
std_url_txt = html.escape(std_url)
else:
std_badge = ("<span class='ribbon'>START HERE</span>"
"<span class='soon'>COMING SOON</span>")
std_url_cls = "url dim"
std_copy = "<button class='copy' type='button' disabled>Soon</button>"
std_url_txt = html.escape(std_url) + " — not live yet"
return """
<div class="card" id="connect">
<h2>Connect — pick the port that matches your gear.</h2>
<p class="note">The port only sets your <b>starting</b> difficulty. Vardiff tunes it automatically afterward, so just pick the closest match.</p>
<div class="tiers">
<div class="tier lite">
<p class="tname">Lite</p>
<p class="role">Tiny & hobby rigs, < ~2 TH/s</p>
<div class="urlrow">
<div class="url mono">%(lite_url)s</div>
<button class="copy" type="button" data-copy="%(lite_url)s">Copy</button>
</div>
<p class="meta">Start difficulty <b>1,024</b> · vardiff</p>
<p class="egs">USB sticks · NerdMiner · Bitaxe · single Avalon Nano</p>
</div>
<div class="tier std">
%(std_badge)s
<p class="tname">Standard</p>
<p class="role">Home & small-farm ASICs, ~2–200 TH/s</p>
<div class="urlrow">
<div class="%(std_url_cls)s mono">%(std_url_txt)s</div>
%(std_copy)s
</div>
<p class="meta">Start difficulty <b>131,072</b> · vardiff</p>
<p class="egs">Antminer S9/S19 · Whatsminer · Avalon 12xx</p>
</div>
<div class="tier pro">
<p class="tname">Pro</p>
<p class="role">Modern & clustered ASICs, 200 TH/s+</p>
<div class="urlrow">
<div class="url mono">%(pro_url)s</div>
<button class="copy" type="button" data-copy="%(pro_url)s">Copy</button>
</div>
<p class="meta">Start difficulty <b>1,048,576</b> · vardiff</p>
<p class="egs">S21/S21 XP · M60 series · multi-rig farms</p>
</div>
</div>
<div class="login">
<b>How to log in.</b> Username = your own BTC address (optionally
<code>address.workername</code> per rig). Password = anything, e.g.
<code>x</code>. The port only sets your STARTING difficulty — vardiff tunes
it automatically after that, so just pick the closest match. Invalid
addresses are rejected. Solve a block and the reward is paid straight to
your address on-chain.
</div>
<div class="login" style="margin-top:10px">
<b>On a Bitaxe (AxeOS), NerdQAxe or Avalon?</b> Those take the host and port
in <i>separate</i> fields — with no <code>stratum+tcp://</code> prefix. Use
Host <code>%(host)s</code>
<button class="copy" type="button" data-copy="%(host)s">Copy</button>
and Port <code>3333</code> Lite / <code>8081</code> Standard /
<code>4334</code> Pro.
</div>
</div>""" % {
"host": html.escape(STRATUM_HOST),
"lite_url": html.escape(lite_url),
"pro_url": html.escape(pro_url),
"std_badge": std_badge,
"std_url_cls": std_url_cls,
"std_url_txt": std_url_txt,
"std_copy": std_copy,
}
# Multi-language support for the public landing page. English render is produced
# normally; other languages are made by post-process phrase replacement (keys match
# the RENDERED HTML, incl. entities like & / '). Crypto/technical terms
# (hashrate, stratum, BTC, ASIC, KYC, vardiff, TH/s, SoloLuck) stay in English by
# design — that's how Asian mining communities read them, and it keeps it natural.
# Translations are a solid first pass; a native speaker can refine any phrase by
# editing the tuple here.
SUPPORTED_LANGS = ["en", "id", "ms", "ja", "th", "ko", "zh", "vi", "tl", "hi"]
LANG_BTNS = [("en", "🇬🇧", "English"), ("id", "🇮🇩", "Bahasa Indonesia"),
("ms", "🇲🇾", "Bahasa Melayu"), ("ja", "🇯🇵", "日本語"), ("th", "🇹🇭", "ไทย"),
("ko", "🇰🇷", "한국어"), ("zh", "🇨🇳", "中文"), ("vi", "🇻🇳", "Tiếng Việt"),
("tl", "🇵🇭", "Filipino"), ("hi", "🇮🇳", "हिन्दी")]
_LO = ["id", "ja", "th", "ko", "zh", "vi"] # translation column order
# hreflang code per lang (BCP-47) + og:locale + a per-language meta/og/twitter
# description (one string used for all three tags so SERP snippets and social
# cards localize together). Translations are a solid first pass.
HREFLANG = {"en": "en", "id": "id", "ms": "ms", "ja": "ja", "th": "th",
"ko": "ko", "zh": "zh-Hans", "vi": "vi", "tl": "fil", "hi": "hi"}
OG_LOCALE = {"en": "en_US", "id": "id_ID", "ms": "ms_MY", "ja": "ja_JP",
"th": "th_TH", "ko": "ko_KR", "zh": "zh_CN", "vi": "vi_VN",
"tl": "fil_PH", "hi": "hi_IN"}
DESCRIPTIONS = {
"en": "SoloLuck — Asia's community solo Bitcoin pool. Mine to your own address; strike a block and keep the whole reward minus a flat 2% fee. Non-custodial: no account, no KYC.",
"id": "SoloLuck — pool solo Bitcoin komunitas Asia. Menambang ke alamat Anda sendiri; temukan blok dan simpan seluruh hadiahnya dikurangi biaya tetap 2%. Non-kustodian: tanpa akun, tanpa KYC.",
"ms": "SoloLuck — pool solo Bitcoin komuniti Asia. Lombong ke alamat anda sendiri; jumpa blok dan simpan seluruh ganjaran tolak yuran tetap 2%. Bukan kustodian: tiada akaun, tiada KYC.",
"ja": "SoloLuck — アジアのコミュニティ・ソロ Bitcoin プール。自分のアドレスにマイニングし、ブロックを掘り当てれば一律2%の手数料を除いた報酬すべてが自分のものに。ノンカストディアル、アカウント不要、KYC 不要。",
"th": "SoloLuck — พูลขุด Bitcoin แบบโซโลของชุมชนเอเชีย ขุดเข้าที่อยู่ของคุณเอง เจอบล็อกแล้วได้รางวัลทั้งหมดหักค่าธรรมเนียมคงที่ 2% ไม่ดูแลเหรียญแทน ไม่ต้องสมัคร ไม่ต้อง KYC",
"ko": "SoloLuck — 아시아 커뮤니티 솔로 비트코인 풀. 본인 주소로 채굴하고, 블록을 찾으면 고정 2% 수수료를 뺀 전체 보상이 내 것. 비수탁형, 계정 불필요, KYC 불필요.",
"zh": "SoloLuck — 亚洲社区单人 Bitcoin 矿池。挖矿至你自己的地址;挖到区块即可保留全部奖励,仅扣除固定 2% 费用。非托管:无需账户,无需 KYC。",
"vi": "SoloLuck — pool đào Bitcoin solo của cộng đồng châu Á. Đào về địa chỉ của chính bạn; tìm được khối là giữ trọn phần thưởng trừ phí cố định 2%. Phi lưu ký: không tài khoản, không KYC.",
"tl": "SoloLuck — community solo Bitcoin pool ng Asia. Mag-mine sa sarili mong address; makahanap ng block at panatilihin ang buong reward bawas ang flat na 2% fee. Non-custodial: walang account, walang KYC.",
"hi": "SoloLuck — एशिया का कम्युनिटी सोलो Bitcoin पूल। अपने ही address पर माइन करें; ब्लॉक मिलने पर सिर्फ़ 2% फ़ीस घटाकर पूरा इनाम आपका। नॉन-कस्टोडियल: कोई अकाउंट नहीं, कोई KYC नहीं।",
}
def _head_i18n(lang):
"""Per-language SEO head fragments: self-referential canonical, full
hreflang cluster (+ x-default), og:locale (+ alternates), description."""
def url(L):
return "https://sololuck.io/" if L == "en" else ("https://sololuck.io/?lang=%s" % L)
hreflang = "".join('<link rel="alternate" hreflang="%s" href="%s">'
% (HREFLANG.get(L, L), url(L)) for L in SUPPORTED_LANGS)
hreflang += '<link rel="alternate" hreflang="x-default" href="https://sololuck.io/">'
alt = "".join('<meta property="og:locale:alternate" content="%s">' % OG_LOCALE[L]
for L in SUPPORTED_LANGS if L != lang)
return {"canon": url(lang), "hreflang": hreflang,
"desc": html.escape(DESCRIPTIONS.get(lang, DESCRIPTIONS["en"]), quote=True),
"oglocale": OG_LOCALE.get(lang, "en_US"), "oglocale_alt": alt}
_FAQ_LD = [
("What is solo mining?",
"You mine for whole blocks on your own. No small steady payouts, but if your miner solves a block the entire reward (about 3.125 BTC plus fees) is yours, paid straight to your address. A lottery with a very big prize."),
("Why SoloLuck instead of going solo at home?",
"We keep a fast, well-connected node close to Asia, so a block you find reaches the network instantly (less orphan risk). You skip running and syncing your own node, just point your miner at us."),
("How does the 2% fee work?",
"Only if you solve a block. The 2% is taken once, inside that block's own coinbase, on-chain and in the open. No block, no fee, and we never hold your coins."),
("What username and password do I use?",
"Your own BTC address (bech32 bc1q...) as the username. Add .workername to track multiple rigs. The password can be anything."),
("What hardware works?",
"Any SHA-256 ASIC: Bitaxe, NerdQAxe, Avalon, Antminer and the like. Pick the port that matches your hashrate; vardiff tunes the rest. About 100 GH/s is a sensible minimum."),
("Is it safe and non-custodial?",
"Yes. We never hold your coins, no balance, no withdrawal. A found block pays directly to the address you mine with. No account, no KYC, no trackers."),
("When and how do I get paid?",
"The instant you solve a block, the network pays its coinbase straight to your address. That is the only payout, solo is all-or-nothing."),
]
def _json_ld():
"""Organization + WebSite + FAQPage JSON-LD for rich snippets. Built from
the same FAQ copy the page renders so they never drift. English-only (kept
out of the post-process translation path to avoid corrupting the JSON)."""
blocks = [
{"@context": "https://schema.org", "@type": "Organization", "name": "SoloLuck",
"url": "https://sololuck.io/", "logo": "https://sololuck.io/favicon.svg",
"description": DESCRIPTIONS["en"]},
{"@context": "https://schema.org", "@type": "WebSite", "name": "SoloLuck",
"url": "https://sololuck.io/"},
{"@context": "https://schema.org", "@type": "FAQPage",
"mainEntity": [{"@type": "Question", "name": q,
"acceptedAnswer": {"@type": "Answer", "text": a}}
for q, a in _FAQ_LD]},
]
return "".join('<script type="application/ld+json">%s</script>'
% json.dumps(b, ensure_ascii=False) for b in blocks)
# (english_in_rendered_html, (id, ja, th, ko, zh, vi))
_TR_RAW = [
# --- hero ---
("Asia's community solo Bitcoin pool",
("Pool solo Bitcoin komunitas Asia", "アジアのコミュニティ・ソロ Bitcoin プール",
"พูลโซโล Bitcoin ของชุมชนเอเชีย", "아시아 커뮤니티 솔로 비트코인 풀",
"亚洲社区单独挖矿比特币矿池", "Pool solo Bitcoin cộng đồng châu Á")),
("Asia's community solo Bitcoin pool",
("Pool solo Bitcoin komunitas Asia", "アジアのコミュニティ・ソロ Bitcoin プール",
"พูลโซโล Bitcoin ของชุมชนเอเชีย", "아시아 커뮤니티 솔로 비트코인 풀",
"亚洲社区单独挖矿比特币矿池", "Pool solo Bitcoin cộng đồng châu Á")),
(POOL_PITCH,
("Tambang ke alamat Anda sendiri. Temukan satu blok dan seluruh hadiahnya jadi milik Anda — dikurangi biaya flat 2%, dibayar langsung ke Anda secara on-chain. Non-kustodial: tanpa akun, tanpa KYC, kami tidak pernah memegang koin Anda.",
"自分のアドレスで採掘。ブロックを見つければ報酬は丸ごとあなたのもの — 一律2%の手数料を引いた額がオンチェーンで直接支払われます。ノンカストディアル:アカウント不要・KYC不要・あなたのコインを預かりません。",
"ขุดไปยังที่อยู่ของคุณเอง เจอบล็อกแล้วรางวัลทั้งหมดเป็นของคุณ — หักค่าธรรมเนียมคงที่ 2% จ่ายตรงถึงคุณบนเชน ไม่เก็บรักษาเหรียญ ไม่ต้องมีบัญชี ไม่ต้อง KYC เราไม่เคยถือเหรียญของคุณ",
"자신의 주소로 채굴하세요. 블록을 찾으면 보상 전체가 당신의 것입니다 — 일률 2% 수수료만 제외하고 온체인으로 바로 지급됩니다. 비수탁: 계정·KYC 없음, 당신의 코인을 보관하지 않습니다.",
"用你自己的地址挖矿。挖到区块,全部奖励归你 — 仅扣固定 2% 费用,直接在链上支付给你。非托管:无需账户、无需 KYC,我们从不保管你的币。",
"Đào về địa chỉ của chính bạn. Tìm được một khối thì toàn bộ phần thưởng là của bạn — trừ phí cố định 2%, trả thẳng cho bạn on-chain. Không giữ hộ: không tài khoản, không KYC, không bao giờ giữ coin của bạn.")),
("No account. No KYC. No custodian — your address is your payout. Honest stats, real odds, real blocks.",
("Tanpa akun. Tanpa KYC. Tanpa kustodian — alamat Anda adalah pembayaran Anda. Statistik jujur, peluang nyata, blok nyata.",
"アカウント不要。KYC不要。カストディアンなし — あなたのアドレスが支払先です。正直な統計、本物の確率、本物のブロック。",
"ไม่ต้องมีบัญชี ไม่ต้อง KYC ไม่มีผู้ดูแลเหรียญ — ที่อยู่ของคุณคือที่รับเงิน สถิติจริง โอกาสจริง บล็อกจริง",
"계정 없음. KYC 없음. 수탁자 없음 — 당신의 주소가 곧 지급처입니다. 정직한 통계, 진짜 확률, 진짜 블록.",
"无需账户。无需 KYC。无托管方 — 你的地址就是收款地址。真实数据、真实概率、真实区块。",
"Không tài khoản. Không KYC. Không bên giữ hộ — địa chỉ của bạn là nơi nhận. Số liệu thật, xác suất thật, khối thật.")),
# --- headings ---
("Live pool stats", ("Statistik pool langsung", "ライブ統計", "สถิติพูลแบบสด", "실시간 풀 통계", "实时矿池统计", "Thống kê trực tiếp")),
("Solo odds calculator", ("Kalkulator peluang solo", "ソロ確率計算ツール", "เครื่องคำนวณโอกาสโซโล", "솔로 확률 계산기", "单独挖矿概率计算器", "Máy tính xác suất solo")),
("Why SoloLuck", ("Kenapa SoloLuck", "SoloLuck を選ぶ理由", "ทำไมต้อง SoloLuck", "SoloLuck를 선택하는 이유", "为什么选择 SoloLuck", "Vì sao chọn SoloLuck")),
("Track an address", ("Lacak alamat", "アドレスを追跡", "ติดตามที่อยู่", "주소 추적", "追踪地址", "Theo dõi địa chỉ")),
("The fee — flat 2%, nothing hidden", ("Biaya — flat 2%, tanpa yang tersembunyi", "手数料 — 一律2%、隠れた費用なし", "ค่าธรรมเนียม — คงที่ 2% ไม่มีค่าซ่อนเร้น", "수수료 — 일률 2%, 숨김 없음", "费用 — 固定 2%,无隐藏费用", "Phí — cố định 2%, không ẩn phí")),
("Found Blocks", ("Blok yang Ditemukan", "発見したブロック", "บล็อกที่พบ", "발견한 블록", "已找到的区块", "Khối đã tìm thấy")),
("Rules & guidance", ("Aturan & panduan", "ルールと案内", "กฎและคำแนะนำ", "규칙 및 안내", "规则与指南", "Quy tắc & hướng dẫn")),
("Connect — pick the port that matches your gear.",
("Hubungkan — pilih port sesuai perangkat Anda.", "接続 — 機材に合うポートを選んでください。",
"เชื่อมต่อ — เลือกพอร์ตที่ตรงกับอุปกรณ์ของคุณ", "연결 — 장비에 맞는 포트를 선택하세요.",
"连接 — 选择匹配你设备的端口。", "Kết nối — chọn cổng phù hợp thiết bị của bạn.")),
# --- stat labels ---
("Miners online", ("Penambang online", "オンラインのマイナー", "นักขุดออนไลน์", "온라인 채굴자", "在线矿工", "Thợ đào trực tuyến")),
("Best share ever", ("Share terbaik", "最高シェア", "แชร์ที่ดีที่สุด", "최고 셰어", "历史最佳 share", "Share tốt nhất")),
("Network hashrate", ("Hashrate jaringan", "ネットワーク hashrate", "Hashrate เครือข่าย", "네트워크 hashrate", "全网 hashrate", "Hashrate mạng lưới")),
("Blocks found", ("Blok ditemukan", "発見ブロック数", "บล็อกที่พบ", "발견 블록 수", "已找到区块", "Số khối tìm được")),
# --- calculator ---
("Enter your gear's hashrate to see your real solo odds at the <b>current</b> network difficulty. Honest math — solo is a lottery, not a salary.",
("Masukkan hashrate perangkat Anda untuk melihat peluang solo nyata pada tingkat kesulitan jaringan <b>saat ini</b>. Matematika jujur — solo itu lotre, bukan gaji.",
"機材の hashrate を入力すると、<b>現在の</b>ネットワーク難易度での本当のソロ確率がわかります。正直な計算 — ソロは宝くじであり給料ではありません。",
"ใส่ hashrate ของอุปกรณ์เพื่อดูโอกาสโซโลจริงที่ความยากของเครือข่าย<b>ปัจจุบัน</b> คณิตศาสตร์ที่ซื่อสัตย์ — โซโลคือลอตเตอรี ไม่ใช่เงินเดือน",
"장비의 hashrate를 입력하면 <b>현재</b> 네트워크 난이도 기준 실제 솔로 확률을 볼 수 있습니다. 정직한 계산 — 솔로는 복권이지 월급이 아닙니다.",
"输入你设备的 hashrate,查看在<b>当前</b>全网难度下的真实单独挖矿概率。诚实的计算 — 单独挖矿是彩票,不是工资。",
"Nhập hashrate thiết bị để xem xác suất solo thật ở độ khó mạng <b>hiện tại</b>. Tính toán trung thực — solo là xổ số, không phải lương.")),
("Expected time to your block", ("Perkiraan waktu untuk blok Anda", "ブロックまでの予想時間", "เวลาที่คาดว่าจะได้บล็อก", "블록까지 예상 시간", "预计挖到区块时间", "Thời gian dự kiến tới khối")),
("Your share of the network", ("Bagian Anda dari jaringan", "ネットワークに占める割合", "ส่วนแบ่งของคุณในเครือข่าย", "네트워크에서 당신의 비중", "你在全网的占比", "Tỷ lệ của bạn trong mạng")),
("Expected yield", ("Perkiraan hasil", "予想収益", "ผลตอบแทนที่คาดหวัง", "예상 수익", "预期收益", "Lợi nhuận dự kiến")),