#!/usr/bin/env python3 """Eddycoin Explorer + stats collector (READ-ONLY). Separate small service next to the node. It never writes to the node or the pool: * reads the node's chain file (chain.json) when it changes (fallback: node HTTP API, then the Cloudflare API) * reads pool stats over HTTP and the pool ledger with sqlite mode=ro * keeps its own history in SQLite (one sample per minute, 8 days) Serves a public read-only explorer API (/explorer/api/...) and a private dashboard under an unguessable path. Address/tx lookups need a short-lived single-use proof-of-work token + per-IP rate limits (IPs kept hashed, in memory only). """ import json, os, sys, time, threading, hashlib, hmac, secrets, sqlite3, re, ssl, urllib.request, urllib.parse, subprocess, math from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler CFG = {"port": 8775, "listen": "127.0.0.1", "chain_file": "/var/lib/eddycoin-mainnet/data/chain.json", "node_url": "http://127.0.0.1:8773", "pool_url": "http://127.0.0.1:8774", "pool_db": "/var/lib/eddycoin-mainnet-pool/pool.db", "cf_url": "", "db": "/var/lib/eddycoin-mainnet-explorer/history.db", "dash_token": "", "pow_bits": 17, "pow_ttl": 120, "origins": ["https://eddycoin.pages.dev"], "dash_html": "/opt/eddycoin-mainnet/explorer_dashboard.html", "services": ["eddycoin-mainnet", "eddycoin-mainnet-pool", "eddycoin-mainnet-explorer", "caddy"]} COIN = 10 ** 8 HEX64 = re.compile(r"^[0-9a-f]{64}$") ADDR = re.compile(r"^eddy1[0-9a-f]{40}$") now_ms = lambda: int(time.time() * 1000) amt = lambda x: int(x or 0) def short(a): return (a[:9] + "\u2026" + a[-4:]) if isinstance(a, str) and len(a) > 16 else (a or "") def log(*a): print(time.strftime("%Y-%m-%d %H:%M:%S"), *a, flush=True) def http_json(url, timeout=8): ctx = ssl.create_default_context() req = urllib.request.Request(url, headers={"User-Agent": "eddycoin-explorer/1.0"}) with urllib.request.urlopen(req, timeout=timeout, context=ctx if url.startswith("https") else None) as r: return json.loads(r.read(8 * 1024 * 1024).decode("utf-8")) # ===================================================================== chain index class Index: """Immutable snapshot built from one chain export.""" def __init__(self, d, source): self.source, self.built = source, now_ms() self.E = [self.slim(b) for b in d.get("E") or []] self.D = [self.slim(b) for b in d.get("D") or []] Y = d.get("Y") or [] anc = d.get("anchor") or {} utxos = {k: {"address": v["address"], "amount": amt(v["amount"])} for k, v in (anc.get("utxos") or {}).items()} self.tx, self.addr, self.Y = {}, {}, [] self.ntx_transfer = 0; self.fees_total = 0 lane = {"E": 0, "D": 0} for y in Y: txs = y.get("txs") or [] ys = {"h": y["height"], "id": y.get("id", ""), "time": y.get("time") or 0, "ntx": len(txs), "issued": amt(y.get("issued")), "subsidy": amt(y.get("subsidy")), "fees": amt(y.get("fees")), "e": y.get("e_evidence", ""), "d": y.get("d_evidence", ""), "e_to": y.get("e_to", ""), "d_to": y.get("d_to", ""), "txids": [t.get("txid", "") for t in txs]} if y["height"] > 0: sub, fee = ys["subsidy"], ys["fees"]; dd = sub // 2 + fee // 2 lane["E"] += sub + fee - dd; lane["D"] += dd self.Y.append(ys) for t in txs: self.apply(t, utxos, y["height"], ys["time"]) if t.get("type") != "reward": self.ntx_transfer += 1; self.fees_total += amt(t.get("fee")) self.utxos = utxos self.lane_mined = lane self.issued = amt(d.get("issued")) or (self.Y[-1]["issued"] if self.Y else 0) self.max_supply = amt(d.get("max_supply")) self.mempool = [self.slimtx(t, None, t.get("time")) for t in (d.get("mempool") or [])] c = d.get("cand") or {} self.cand = {"h": c.get("height"), "ntx": len(c.get("txids") or []), "e": bool(c.get("e")), "d": bool(c.get("d"))} if c else None for t in (c.get("txs") or []) + (d.get("mempool") or []): if t.get("txid") and t["txid"] not in self.tx: self.tx[t["txid"]] = {"t": t, "cp": None, "time": t.get("time"), "status": "candidate" if t["txid"] in (c.get("txids") or []) else "unconfirmed"} self.byhash = {} for ch, arr in (("E", self.E), ("D", self.D)): for b in arr: self.byhash[b["hash"]] = (ch, b["h"]) for y in self.Y: self.byhash[y["id"]] = ("Y", y["h"]) self.Eh = {b["h"]: b for b in self.E}; self.Dh = {b["h"]: b for b in self.D}; self.Yh = {y["h"]: y for y in self.Y} @staticmethod def slim(b): return {"h": b["height"], "hash": b["hash"], "bits": b["bits"], "time": b.get("time") or 0, "recv": b.get("recv") or b.get("time") or 0, "miner": b.get("miner", ""), "prev": b.get("prev", ""), "nonce": b.get("nonce"), "yc": b.get("y_candidate", "")} def slimtx(self, t, cp, tm): return {"txid": t.get("txid"), "type": t.get("type"), "cp": cp, "time": tm, "fee": str(amt(t.get("fee"))), "out": str(sum(amt(o["amount"]) for o in t.get("outputs") or [])), "nin": len(t.get("inputs") or []), "nout": len(t.get("outputs") or [])} def A(self, a): r = self.addr.get(a) if r is None: r = self.addr[a] = {"recv": 0, "sent": 0, "txs": []} return r def apply(self, t, utxos, cp, tm): txid = t.get("txid", ""); delta = {} for i in t.get("inputs") or []: u = utxos.pop("%s:%s" % (i.get("txid"), i.get("vout")), None) if u: delta[u["address"]] = delta.get(u["address"], 0) - u["amount"] for n, o in enumerate(t.get("outputs") or []): v = amt(o["amount"]); utxos["%s:%d" % (txid, n)] = {"address": o["address"], "amount": v} delta[o["address"]] = delta.get(o["address"], 0) + v for a, v in delta.items(): r = self.A(a) if v >= 0: r["recv"] += v else: r["sent"] += -v r["txs"].append((cp, txid, v, tm, t.get("type"))) self.tx[txid] = {"t": t, "cp": cp, "time": tm, "status": "finalized"} def balance(self, a): return sum(u["amount"] for u in self.utxos.values() if u["address"] == a) def hashrate(blocks, now, n=20): """Estimate (hashes/s) from the last n blocks: expected work 2^bits per block / arrival-time span. Returns None if too few.""" bs = blocks[-n:] if len(bs) < 3: return None work = sum(2 ** b["bits"] for b in bs[1:]); span = (bs[-1]["recv"] - bs[0]["recv"]) / 1000.0 avg = span / (len(bs) - 1) if span > 0 else 0 idle = (now - bs[-1]["recv"]) / 1000.0 if avg > 0 and idle > 2 * avg: # no block for a while -> include the idle time (estimate drops, never rises) span = (now - bs[0]["recv"]) / 1000.0 return work / span if span > 0 else None def buckets(blocks, now, width_s, count): """Per-bucket blocks, hashrate estimate (sum 2^bits / width), avg block time.""" start = now - width_s * 1000 * count; out = [{"t": start + i * width_s * 1000, "n": 0, "w": 0} for i in range(count)] for b in blocks: if b["recv"] >= start: i = int((b["recv"] - start) // (width_s * 1000)) if 0 <= i < count: out[i]["n"] += 1; out[i]["w"] += 2 ** b["bits"] return [{"t": o["t"], "n": o["n"], "hr": o["w"] / width_s} for o in out] # ===================================================================== state class State: def __init__(self): self.ix = None; self.mtime = 0; self.lock = threading.Lock(); self.cf = {}; self.pool = None; self.pooldb = {}; self.sys = {} self.node = {}; self.reqs = {}; self.started = time.time(); self.cpu_prev = None; self.last_err = "" def load_chain(self): p = CFG["chain_file"] try: m = os.stat(p).st_mtime if m != self.mtime: with open(p, "rb") as f: d = json.loads(f.read().decode("utf-8")) ix = Index(d, "oracle-node-file") self.ix, self.mtime = ix, m return except Exception as ex: self.last_err = "chain file: %s" % str(ex)[:120] if self.ix and time.time() - self.mtime < 600: return if time.time() - getattr(self, "fb_t", 0) < 300: # fallback at most every 5 min (never hammer Cloudflare) return self.fb_t = time.time() for src, url in (("oracle-node-api", CFG["node_url"] + "/api/export"),) + ((("cloudflare-api", CFG["cf_url"] + "/api/export"),) if CFG["cf_url"] else ()): try: self.ix = Index(http_json(url, 20), src); self.mtime = time.time(); return except Exception as ex: self.last_err = "%s: %s" % (src, str(ex)[:120]) def poll_side(self): try: self.node = http_json(CFG["node_url"] + "/api/node/info", 5); self.node["_ok"] = True except Exception as ex: self.node = {"_ok": False, "err": str(ex)[:100]} try: p = http_json(CFG["pool_url"] + "/pool/api/stats", 5); p["_ok"] = True; self.pool = p except Exception as ex: self.pool = {"_ok": False, "err": str(ex)[:100]} try: con = sqlite3.connect("file:%s?mode=ro" % CFG["pool_db"], uri=True, timeout=2) q = lambda s, a=(): con.execute(s, a).fetchall() t24 = now_ms() - 86400000 self.pooldb = { "fee_total": q("SELECT COALESCE(SUM(amount),0) FROM entries WHERE kind='fee'")[0][0], "credited_total": q("SELECT COALESCE(SUM(amount),0) FROM entries WHERE kind='credit'")[0][0], "paid_total": q("SELECT COALESCE(SUM(total),0) FROM payouts WHERE status IN ('sent','confirmed')")[0][0], "pending_payouts": q("SELECT COUNT(*), COALESCE(SUM(total),0) FROM payouts WHERE status NOT IN ('sent','confirmed')")[0], "payouts": q("SELECT COUNT(*) FROM payouts")[0][0], "owed": q("SELECT COALESCE(SUM(amount),0) FROM entries")[0][0], "blocks": q("SELECT status, COUNT(*) FROM blocks GROUP BY status"), "shares_24h": q("SELECT COUNT(*) FROM shares WHERE ts>?", (t24,))[0][0], "miners_24h": q("SELECT COUNT(DISTINCT addr) FROM shares WHERE ts>?", (t24,))[0][0], "shares_total": q("SELECT COUNT(*) FROM shares")[0][0], "ok": True} con.close() except Exception as ex: self.pooldb = {"ok": False, "err": str(ex)[:100]} def poll_cf(self): if not CFG["cf_url"]: # mainnet: no Cloudflare chain self.cf = {"ok": False, "err": "not used on mainnet", "t": now_ms()}; return try: s = http_json(CFG["cf_url"] + "/api/state", 10) self.cf = {"ok": True, "tips": s.get("tips"), "t": now_ms(), "paused": s.get("paused")} except Exception as ex: self.cf = {"ok": False, "err": str(ex)[:100], "t": now_ms()} def poll_sys(self): s = {} try: with open("/proc/stat") as f: c = [int(x) for x in f.readline().split()[1:]] idle, tot = c[3] + c[4], sum(c) if self.cpu_prev: di, dt = idle - self.cpu_prev[0], tot - self.cpu_prev[1]; s["cpu_pct"] = round(100 * (1 - di / dt), 1) if dt > 0 else 0 self.cpu_prev = (idle, tot) mi = {} with open("/proc/meminfo") as f: for line in f: k, v = line.split(":"); mi[k] = int(v.split()[0]) s["mem_total_mb"] = mi["MemTotal"] // 1024; s["mem_used_mb"] = (mi["MemTotal"] - mi["MemAvailable"]) // 1024 s["swap_used_mb"] = (mi.get("SwapTotal", 0) - mi.get("SwapFree", 0)) // 1024 st = os.statvfs("/"); s["disk_total_gb"] = round(st.f_blocks * st.f_frsize / 1e9, 1); s["disk_used_gb"] = round((st.f_blocks - st.f_bfree) * st.f_frsize / 1e9, 1) with open("/proc/loadavg") as f: s["load"] = [float(x) for x in f.read().split()[:3]] with open("/proc/uptime") as f: s["uptime_s"] = int(float(f.read().split()[0])) sv = {} for name in CFG["services"]: try: out = subprocess.run(["systemctl", "show", name, "-p", "ActiveState,MemoryCurrent,ActiveEnterTimestampMonotonic,NRestarts"], capture_output=True, text=True, timeout=3).stdout kv = dict(l.split("=", 1) for l in out.strip().splitlines() if "=" in l) mono = int(kv.get("ActiveEnterTimestampMonotonic") or 0) / 1e6 mem = kv.get("MemoryCurrent", "") sv[name] = {"state": kv.get("ActiveState"), "mem_mb": round(int(mem) / 1048576, 1) if mem.isdigit() else None, "up_s": int(s["uptime_s"] - mono) if mono else None, "restarts": kv.get("NRestarts")} except Exception: sv[name] = {"state": "?"} s["services"] = sv except Exception as ex: s["err"] = str(ex)[:100] self.sys = s # ------------------------------------------------------------ computed views def overview(self): ix = self.ix; n = now_ms() if not ix: return None def lane(arr): hr = hashrate(arr, n); last = arr[-20:] avg = (last[-1]["recv"] - last[0]["recv"]) / 1000 / (len(last) - 1) if len(last) > 2 else None h1 = sum(1 for b in arr if b["recv"] > n - 3600000); h24 = sum(1 for b in arr if b["recv"] > n - 86400000) return {"tip": arr[-1]["h"] if arr else None, "bits": arr[-1]["bits"] if arr else None, "hashrate": hr, "avg_block_s": avg, "blocks_1h": h1, "blocks_24h": h24, "last_block_ago_s": round((n - arr[-1]["recv"]) / 1000) if arr else None} E, D = lane(ix.E), lane(ix.D) if self.node.get("_ok") and self.node.get("bits"): E["next_bits"] = self.node["bits"].get("E"); D["next_bits"] = self.node["bits"].get("D") act = lambda ms: len({b["miner"] for b in ix.E + ix.D if b["recv"] > n - ms}) tot = (E["hashrate"] or 0) + (D["hashrate"] or 0) return {"time": n, "source": ix.source, "network": "eddycoin-mainnet-1", "E": E, "D": D, "Y": {"tip": ix.Y[-1]["h"] if ix.Y else None, "checkpoints": len(ix.Y) - 1}, "hashrate_total": tot if (E["hashrate"] or D["hashrate"]) else None, "issued": str(ix.issued), "max_supply": str(ix.max_supply), "lane_mined": {k: str(v) for k, v in ix.lane_mined.items()}, "reward_next": str(ix.Y[-1]["subsidy"]) if ix.Y else None, "tx_transfers": ix.ntx_transfer, "tx_fees": str(ix.fees_total), "mempool": len(ix.mempool), "active_miners_1h": act(3600000), "active_miners_24h": act(86400000), "cand": ix.cand} def bar(self): o = self.overview() if not o: return {"ok": False} return {"ok": True, "time": o["time"], "hashrate": o["hashrate_total"], "E": o["E"]["hashrate"], "D": o["D"]["hashrate"], "estimate": True} ST = State() # ===================================================================== history (own SQLite) def db(): c = sqlite3.connect(CFG["db"], timeout=5); c.execute("PRAGMA journal_mode=WAL") c.execute("CREATE TABLE IF NOT EXISTS samples(ts INTEGER PRIMARY KEY, j TEXT)"); return c def sample(): o = ST.overview() or {}; p = ST.pool or {}; s = ST.sys or {} lanes = p.get("lanes") or {} row = {"hrE": (o.get("E") or {}).get("hashrate"), "hrD": (o.get("D") or {}).get("hashrate"), "tipE": (o.get("E") or {}).get("tip"), "tipD": (o.get("D") or {}).get("tip"), "tipY": (o.get("Y") or {}).get("tip"), "issued": o.get("issued"), "phrE": (lanes.get("E") or {}).get("hashrate"), "phrD": (lanes.get("D") or {}).get("hashrate"), "pminers": sum((lanes.get(k) or {}).get("miners") or 0 for k in ("E", "D")), "cpu": s.get("cpu_pct"), "mem": s.get("mem_used_mb"), "cfY": ((ST.cf or {}).get("tips") or {}).get("Y"), "nodeY": (ST.node or {}).get("tipY"), "act1h": o.get("active_miners_1h"), "req": sum(ST.reqs.values())} ST.reqs = {} c = db(); c.execute("INSERT OR REPLACE INTO samples VALUES(?,?)", (now_ms(), json.dumps(row))) c.execute("DELETE FROM samples WHERE ts? ORDER BY ts", (now_ms() - hours * 3600000,)).fetchall(); c.close() step = max(1, len(rows) // 300) return [dict(json.loads(j), ts=ts) for ts, j in rows[::step]] def loops(): k = 0 while True: try: ST.load_chain() if k % 3 == 0: ST.poll_side(); ST.poll_sys() if k % 6 == 0: ST.poll_cf() if k % 6 == 3: sample() except Exception as ex: ST.last_err = "loop: %s" % str(ex)[:160]; log("loop error", ex) k += 1; time.sleep(10) # ===================================================================== protection: PoW + rate limits (memory only, hashed IPs) SECRET = secrets.token_bytes(32) SALT = secrets.token_bytes(16) USED = {} RL = {} LIM = { # name: [(window_s, max), ...] "addr": [(10, 3), (60, 10), (86400, 100)], "tx": [(10, 5), (60, 20), (86400, 300)], "chal": [(10, 6), (60, 30), (86400, 600)], "api": [(10, 40), (60, 150)], } RLOCK = threading.Lock() def ipkey(ip): return hashlib.sha256(SALT + ip.encode()).hexdigest()[:24] def limited(ip, kind): t = time.time(); k = (ipkey(ip), kind) with RLOCK: lst = [x for x in RL.get(k, []) if t - x < 86400] for w, m in LIM[kind]: if sum(1 for x in lst if t - x < w) >= m: RL[k] = lst; return min(w, 3600) lst.append(t); RL[k] = lst return 0 def janitor(): while True: time.sleep(300); t = time.time() with RLOCK: for k in list(RL): RL[k] = [x for x in RL[k] if t - x < 86400] if not RL[k]: del RL[k] for k in list(USED): if USED[k] < t: del USED[k] def challenge(): exp = int(time.time()) + CFG["pow_ttl"]; r = secrets.token_hex(8); b = CFG["pow_bits"] body = "%d.%s.%d" % (exp, r, b); mac = hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest()[:32] return {"challenge": body + "." + mac, "bits": b, "expires": exp, "algo": "sha256(challenge + ':' + nonce) needs >= bits leading zero bits"} def lzb(h): n = 0 for ch in h: v = int(ch, 16) if v == 0: n += 4; continue return n + (4 - v.bit_length()) return n def check_pow(tok): try: c, nonce = tok.rsplit(":", 1) exp, r, b, mac = c.split(".") if not hmac.compare_digest(mac, hmac.new(SECRET, ("%s.%s.%s" % (exp, r, b)).encode(), hashlib.sha256).hexdigest()[:32]): return "bad token" if int(exp) < time.time(): return "token expired" if len(nonce) > 20 or lzb(hashlib.sha256((c + ":" + nonce).encode()).hexdigest()) < int(b): return "bad proof" with RLOCK: if c in USED: return "token already used" USED[c] = int(exp) + 5 return None except Exception: return "bad token" MSG429 = {"error": "rate_limited", "cs": "Příliš mnoho dotazů. Zkus to prosím za chvíli znovu.", "en": "Too many requests. Please try again in a moment."} MSGPOW = {"error": "pow_required", "cs": "Chybí nebo neplatí ověření prohlížeče (proof-of-work). Obnov stránku a zkus to znovu.", "en": "Missing or invalid browser check (proof-of-work). Reload the page and try again."} # ===================================================================== views def block_view(ix, ch, h): if ch in ("E", "D"): b = (ix.Eh if ch == "E" else ix.Dh).get(h) if not b: return None y = next((y for y in ix.Y if y[ch.lower()] == b["hash"]), None) return {"kind": "lane", "chain": ch, "height": h, "hash": b["hash"], "prev": b["prev"], "bits": b["bits"], "time": b["time"], "received": b["recv"], "nonce": b["nonce"], "finder": short(b["miner"]), "expected_hashes": str(2 ** b["bits"]), "checkpoint": y["h"] if y else None, "y_candidate": b["yc"]} y = ix.Yh.get(h) if not y: return None txs = [] for tid in y["txids"][:200]: e = ix.tx.get(tid) if e: txs.append(dict(ix.slimtx(e["t"], y["h"], y["time"]), to=[short(o["address"]) for o in e["t"].get("outputs") or []][:3])) return {"kind": "checkpoint", "chain": "Y", "height": h, "id": y["id"], "time": y["time"], "issued": str(y["issued"]), "subsidy": str(y["subsidy"]), "fees": str(y["fees"]), "e_evidence": y["e"], "d_evidence": y["d"], "e_to": short(y["e_to"]), "d_to": short(y["d_to"]), "e_block": ix.byhash.get(y["e"], (None, None))[1], "d_block": ix.byhash.get(y["d"], (None, None))[1], "txs": txs} def tx_view(ix, tid): e = ix.tx.get(tid) if not e: return None t = e["t"] ins = [] for i in t.get("inputs") or []: src = ix.tx.get(i.get("txid")); a = v = None if src: try: o = src["t"]["outputs"][int(i.get("vout"))]; a, v = short(o["address"]), str(amt(o["amount"])) except Exception: pass ins.append({"txid": i.get("txid"), "vout": i.get("vout"), "from": a, "amount": v}) return {"txid": tid, "type": t.get("type"), "status": e["status"], "checkpoint": e["cp"], "time": e["time"], "fee": str(amt(t.get("fee"))), "memo": (t.get("memo") or "")[:140], "inputs": ins, "outputs": [{"to": short(o["address"]), "amount": str(amt(o["amount"]))} for o in t.get("outputs") or []], "confirmations": (ix.Y[-1]["h"] - e["cp"] + 1) if e["cp"] is not None else 0} def addr_view(ix, a): a = a if isinstance(a, str) else "" r = ix.addr.get(a) if ADDR.match(a) else None r = r or {"recv": 0, "sent": 0, "txs": []} bal = ix.balance(a) # always full scan -> same work for any input pend = 0 for tid, e in ix.tx.items(): if e["status"] != "finalized": pend += sum(amt(o["amount"]) for o in e["t"].get("outputs") or [] if o["address"] == a) blocks = {"E": sum(1 for b in ix.E if b["miner"] == a), "D": sum(1 for b in ix.D if b["miner"] == a)} txs = [{"txid": tid, "checkpoint": cp, "amount": str(v), "time": tm, "type": ty} for cp, tid, v, tm, ty in reversed(r["txs"][-25:])] return {"address": a[:64], "balance": str(bal), "pending_in": str(pend), "received": str(r["recv"]), "sent": str(r["sent"]), "tx_count": len(r["txs"]), "blocks_found": blocks, "txs": txs, "decimals": 8} def recent(ix, n=15): out = {"E": [], "D": [], "Y": [], "tx": []} for ch, arr in (("E", ix.E), ("D", ix.D)): prev = None for b in arr[-(n + 1):]: if prev: out[ch].append({"h": b["h"], "hash": b["hash"], "time": b["recv"], "bits": b["bits"], "finder": short(b["miner"]), "dt": round((b["recv"] - prev["recv"]) / 1000, 1)}) prev = b out[ch].reverse() for y in ix.Y[-n:][::-1]: e = ix.tx.get(y["txids"][0]) if y["txids"] else None rew = str(sum(amt(o["amount"]) for o in e["t"]["outputs"])) if e and e["t"].get("type") == "reward" else str(y["subsidy"] + y["fees"]) out["Y"].append({"h": y["h"], "id": y["id"], "time": y["time"], "ntx": y["ntx"], "reward": rew, "e_to": short(y["e_to"]), "d_to": short(y["d_to"])}) cnt = 0 for y in reversed(ix.Y): for tid in reversed(y["txids"]): e = ix.tx.get(tid) if e: out["tx"].append(dict(ix.slimtx(e["t"], y["h"], y["time"]), to=[short(o["address"]) for o in e["t"].get("outputs") or []][:2])); cnt += 1 if cnt >= n: break if cnt >= n: break out["mempool"] = ix.mempool[:20] return out def dash_data(): o = ST.overview(); ix = ST.ix; n = now_ms() d = {"overview": o, "pool": {k: v for k, v in (ST.pool or {}).items() if k in ("_ok", "fee_percent", "min_payout", "payout_interval_s", "confirmations", "lanes", "counters", "scheme")}, "pooldb": ST.pooldb, "sys": ST.sys, "cf": ST.cf, "node": {k: v for k, v in (ST.node or {}).items() if k in ("_ok", "tipY", "tips", "bits", "mode", "peers", "node", "issued")}, "explorer": {"up_s": int(time.time() - ST.started), "chain_age_s": round(time.time() - ST.mtime) if ST.mtime else None, "last_err": ST.last_err, "rate_buckets": len(RL), "source": ix.source if ix else None}} if ix: d["charts"] = {"E24": buckets(ix.E, n, 900, 96), "D24": buckets(ix.D, n, 900, 96), "E7": buckets(ix.E, n, 3600, 168), "D7": buckets(ix.D, n, 3600, 168)} m = {} for ch, arr in (("E", ix.E), ("D", ix.D)): for b in arr: if b["recv"] > n - 86400000: x = m.setdefault(short(b["miner"]), {"E": 0, "D": 0, "last": 0}); x[ch] += 1; x["last"] = max(x["last"], b["recv"]) d["miners24"] = sorted(({"addr": k, **v} for k, v in m.items()), key=lambda r: -(r["E"] + r["D"]))[:30] d["recent"] = recent(ix, 10) d["hist24"] = history(24); d["hist7"] = history(168) return d # ===================================================================== HTTP class H(BaseHTTPRequestHandler): server_version = "eddycoin-explorer/1.0"; sys_version = "" def log_message(self, *a): pass def ip(self): return (self.headers.get("X-Forwarded-For") or self.client_address[0]).split(",")[0].strip() def send(self, code, obj, ctype="application/json", cache="no-store", extra=None): body = obj if isinstance(obj, bytes) else (obj.encode() if isinstance(obj, str) else json.dumps(obj, separators=(",", ":")).encode()) self.send_response(code) self.send_header("Content-Type", ctype + ("; charset=utf-8" if "json" in ctype or "html" in ctype else "")) self.send_header("Content-Length", str(len(body))); self.send_header("Cache-Control", cache) self.send_header("X-Content-Type-Options", "nosniff"); self.send_header("Referrer-Policy", "no-referrer") o = self.headers.get("Origin") if o and o in CFG["origins"]: self.send_header("Access-Control-Allow-Origin", o); self.send_header("Vary", "Origin") self.send_header("Access-Control-Allow-Headers", "X-Eddy-Pow"); self.send_header("Access-Control-Max-Age", "600") for k, v in (extra or {}).items(): self.send_header(k, v) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def do_OPTIONS(self): self.send(204, b"", "text/plain", extra={"Access-Control-Allow-Methods": "GET"}) def do_POST(self): self.send(405, {"error": "read-only"}) do_PUT = do_DELETE = do_PATCH = do_POST def do_HEAD(self): self.do_GET() def do_GET(self): t0 = time.time() try: u = urllib.parse.urlsplit(self.path); p = u.path; q = urllib.parse.parse_qs(u.query); ip = self.ip() g = lambda k: (q.get(k) or [""])[0] tok = CFG["dash_token"] if tok and len(tok) >= 32 and p.startswith("/d/"): part = p[3:].split("/", 1) if not hmac.compare_digest(part[0], tok): return self.send(404, {"error": "not found"}) nr = {"X-Robots-Tag": "noindex, nofollow, noarchive", "Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:; base-uri 'none'; frame-ancestors 'none'"} if len(part) == 1 or part[1] in ("", "index.html"): if len(part) == 1: return self.send(301, b"", "text/plain", extra={"Location": p + "/"}) with open(CFG["dash_html"], "rb") as f: return self.send(200, f.read(), "text/html", extra=nr) if part[1] == "api/data": return self.send(200, dash_data(), extra=nr) return self.send(404, {"error": "not found"}) if p == "/robots.txt": return self.send(200, "User-agent: *\nDisallow: /\n", "text/plain") if not p.startswith("/explorer/api/"): return self.send(404, {"error": "not found"}) ep = p[len("/explorer/api/"):].strip("/") ST.reqs[ep.split("/")[0]] = ST.reqs.get(ep.split("/")[0], 0) + 1 ix = ST.ix if ep == "bar": if limited(ip, "api"): return self.send(429, MSG429, extra={"Retry-After": "30"}) return self.send(200, ST.bar(), cache="public, max-age=30") if ep == "challenge": w = limited(ip, "chal") if w: return self.send(429, MSG429, extra={"Retry-After": str(w)}) return self.send(200, challenge()) if ep in ("address", "tx", "lookup"): kind = "addr" if ep == "address" else "tx" w = limited(ip, kind) if w: return self.send(429, MSG429, extra={"Retry-After": str(w)}) err = check_pow(self.headers.get("X-Eddy-Pow") or "") if err: return self.send(403, dict(MSGPOW, detail=err)) if not ix: return self.send(503, {"error": "starting"}) if ep == "address": v = addr_view(ix, g("a").strip()) dt = time.time() - t0 pad = 0.6 + secrets.randbelow(200) / 1000.0 # fixed floor + jitter: same response time for any address if dt < pad: time.sleep(pad - dt) return self.send(200, v) h = g("id").strip().lower() if not HEX64.match(h): return self.send(400, {"error": "use a 64-hex id"}) if ep == "tx": v = tx_view(ix, h) return self.send(200, v) if v else self.send(404, {"error": "not found", "cs": "Transakce nenalezena.", "en": "Transaction not found."}) if h in ix.byhash: ch, hh = ix.byhash[h]; return self.send(200, {"found": "block", "block": block_view(ix, ch, hh)}) v = tx_view(ix, h) return self.send(200, {"found": "tx", "tx": v}) if v else self.send(404, {"error": "not found", "cs": "Nic nenalezeno.", "en": "Nothing found."}) if limited(ip, "api"): return self.send(429, MSG429, extra={"Retry-After": "30"}) if not ix: return self.send(503, {"error": "starting"}) if ep == "overview": return self.send(200, ST.overview(), cache="public, max-age=15") if ep == "recent": return self.send(200, recent(ix, 15), cache="public, max-age=15") if ep == "block": ch = g("chain").upper(); hs = g("h") if ch not in ("E", "D", "Y") or not hs.isdigit(): return self.send(400, {"error": "use ?chain=E|D|Y&h="}) v = block_view(ix, ch, int(hs)) return self.send(200, v, cache="public, max-age=30") if v else self.send(404, {"error": "not found", "cs": "Blok nenalezen.", "en": "Block not found."}) if ep == "checkpoints": return self.send(200, [{"h": y["h"], "id": y["id"], "time": y["time"], "e": y["e"], "d": y["d"], "ntx": y["ntx"]} for y in ix.Y[-25:][::-1]], cache="public, max-age=15") if ep == "chart": n = now_ms() return self.send(200, {"E": buckets(ix.E, n, 3600, 24), "D": buckets(ix.D, n, 3600, 24)}, cache="public, max-age=60") return self.send(404, {"error": "not found", "endpoints": ["bar", "overview", "recent", "chart", "checkpoints", "block?chain=&h=", "challenge", "address?a= (PoW)", "tx?id= (PoW)", "lookup?id= (PoW)"]}) except Exception as ex: log("http error", type(ex).__name__, str(ex)[:200]) try: self.send(500, {"error": "internal"}) except Exception: pass def main(): cfgp = sys.argv[sys.argv.index("--config") + 1] if "--config" in sys.argv else None if cfgp: with open(cfgp) as f: CFG.update(json.load(f)) os.makedirs(os.path.dirname(CFG["db"]), exist_ok=True) ST.load_chain(); ST.poll_side(); ST.poll_sys(); ST.poll_cf() threading.Thread(target=loops, daemon=True).start(); threading.Thread(target=janitor, daemon=True).start() srv = ThreadingHTTPServer((CFG["listen"], CFG["port"]), H); srv.daemon_threads = True log("explorer on %s:%d, chain source %s" % (CFG["listen"], CFG["port"], ST.ix.source if ST.ix else "none")) srv.serve_forever() if __name__ == "__main__": main()