159 lines
6.0 KiB
Python
159 lines
6.0 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Montana explorer JSON API — читает state files docker-volume montana-data."""
|
||
|
|
import os, sys, json, struct, glob, time
|
||
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||
|
|
from urllib.parse import urlparse, parse_qs
|
||
|
|
|
||
|
|
DATA_DIR = os.environ.get("MT_DATA_DIR", "/var/lib/docker/volumes/montana-data/_data")
|
||
|
|
PROPOSALS = os.path.join(DATA_DIR, "proposals")
|
||
|
|
ACCOUNTS_BIN = os.path.join(DATA_DIR, "accounts.bin")
|
||
|
|
NODES_BIN = os.path.join(DATA_DIR, "nodes.bin")
|
||
|
|
CANDIDATES_BIN = os.path.join(DATA_DIR, "candidates.bin")
|
||
|
|
META_LAST = os.path.join(DATA_DIR, "meta_last_cemented.bin")
|
||
|
|
CURRENT_WIN = os.path.join(DATA_DIR, "current_window.bin")
|
||
|
|
|
||
|
|
ACCOUNT_SZ = 2059
|
||
|
|
NODE_SZ = 2098
|
||
|
|
CANDIDATE_SZ = 2082
|
||
|
|
PROPOSAL_SZ = 3722 # header; cemented envelope may be larger
|
||
|
|
|
||
|
|
def read_u64_le(path):
|
||
|
|
try:
|
||
|
|
with open(path, "rb") as f:
|
||
|
|
return struct.unpack("<Q", f.read(8))[0]
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
def parse_proposal_header(buf):
|
||
|
|
if len(buf) < PROPOSAL_SZ:
|
||
|
|
return None
|
||
|
|
return {
|
||
|
|
"prev_proposal_hash": buf[0:32].hex(),
|
||
|
|
"window_index": struct.unpack("<Q", buf[32:40])[0],
|
||
|
|
"protocol_version": struct.unpack("<I", buf[40:44])[0],
|
||
|
|
"control_root": buf[44:76].hex(),
|
||
|
|
"node_root": buf[76:108].hex(),
|
||
|
|
"candidate_root": buf[108:140].hex(),
|
||
|
|
"account_root": buf[140:172].hex(),
|
||
|
|
"state_root": buf[172:204].hex(),
|
||
|
|
"timechain_value": buf[204:236].hex(),
|
||
|
|
"included_bundles_root": buf[236:268].hex(),
|
||
|
|
"included_reveals_root": buf[268:300].hex(),
|
||
|
|
"winner_endpoint": buf[300:332].hex(),
|
||
|
|
"winner_id": buf[332:364].hex(),
|
||
|
|
"proposer_node_id": buf[364:396].hex(),
|
||
|
|
"target": int.from_bytes(buf[396:412], "little"),
|
||
|
|
"fallback_depth": buf[412],
|
||
|
|
"envelope_size": len(buf),
|
||
|
|
"bundle_count": (struct.unpack("<H", buf[3722:3724])[0] if len(buf) >= 3724 else 0),
|
||
|
|
}
|
||
|
|
|
||
|
|
def parse_node_record(buf):
|
||
|
|
if len(buf) < NODE_SZ:
|
||
|
|
return None
|
||
|
|
return {
|
||
|
|
"node_id": buf[0:32].hex(),
|
||
|
|
"suite_id": struct.unpack("<H", buf[1984:1986])[0],
|
||
|
|
"operator_account_id": buf[1986:2018].hex(),
|
||
|
|
"start_window": struct.unpack("<Q", buf[2018:2026])[0],
|
||
|
|
"chain_length": struct.unpack("<Q", buf[2026:2034])[0],
|
||
|
|
"chain_length_snapshot": struct.unpack("<Q", buf[2034:2042])[0],
|
||
|
|
"last_confirmation_window": struct.unpack("<Q", buf[2090:2098])[0],
|
||
|
|
}
|
||
|
|
|
||
|
|
def api_status():
|
||
|
|
last_cem = read_u64_le(META_LAST) or 0
|
||
|
|
cur_win = read_u64_le(CURRENT_WIN) or 0
|
||
|
|
n_acc = os.path.getsize(ACCOUNTS_BIN) // ACCOUNT_SZ if os.path.exists(ACCOUNTS_BIN) else 0
|
||
|
|
n_node = os.path.getsize(NODES_BIN) // NODE_SZ if os.path.exists(NODES_BIN) else 0
|
||
|
|
n_cand = os.path.getsize(CANDIDATES_BIN) // CANDIDATE_SZ if os.path.exists(CANDIDATES_BIN) else 0
|
||
|
|
props = sorted(glob.glob(os.path.join(PROPOSALS, "*.bin")))
|
||
|
|
return {
|
||
|
|
"current_window": cur_win,
|
||
|
|
"last_cemented_window": last_cem,
|
||
|
|
"accounts": n_acc,
|
||
|
|
"nodes": n_node,
|
||
|
|
"candidates": n_cand,
|
||
|
|
"proposals_archived": len(props),
|
||
|
|
"ts": int(time.time()),
|
||
|
|
}
|
||
|
|
|
||
|
|
def api_nodes():
|
||
|
|
if not os.path.exists(NODES_BIN):
|
||
|
|
return {"nodes": []}
|
||
|
|
with open(NODES_BIN, "rb") as f:
|
||
|
|
data = f.read()
|
||
|
|
out = []
|
||
|
|
for i in range(0, len(data), NODE_SZ):
|
||
|
|
rec = parse_node_record(data[i:i + NODE_SZ])
|
||
|
|
if rec:
|
||
|
|
out.append(rec)
|
||
|
|
return {"nodes": out}
|
||
|
|
|
||
|
|
def api_proposals(limit=30):
|
||
|
|
files = sorted(glob.glob(os.path.join(PROPOSALS, "*.bin")), reverse=True)[:limit]
|
||
|
|
out = []
|
||
|
|
for fp in files:
|
||
|
|
try:
|
||
|
|
with open(fp, "rb") as f:
|
||
|
|
buf = f.read()
|
||
|
|
hdr = parse_proposal_header(buf)
|
||
|
|
if hdr:
|
||
|
|
out.append({
|
||
|
|
"window_index": hdr["window_index"],
|
||
|
|
"proposer_node_id": hdr["proposer_node_id"],
|
||
|
|
"winner_id": hdr["winner_id"],
|
||
|
|
"state_root": hdr["state_root"],
|
||
|
|
"envelope_size": hdr["envelope_size"],
|
||
|
|
"bundle_count": hdr["bundle_count"],
|
||
|
|
})
|
||
|
|
except Exception:
|
||
|
|
continue
|
||
|
|
return {"proposals": out, "count": len(out)}
|
||
|
|
|
||
|
|
def api_proposal(window):
|
||
|
|
fp = os.path.join(PROPOSALS, f"{window:020d}.bin")
|
||
|
|
if not os.path.exists(fp):
|
||
|
|
return {"error": "not found"}, 404
|
||
|
|
with open(fp, "rb") as f:
|
||
|
|
buf = f.read()
|
||
|
|
hdr = parse_proposal_header(buf)
|
||
|
|
if not hdr:
|
||
|
|
return {"error": "parse error"}, 500
|
||
|
|
return hdr
|
||
|
|
|
||
|
|
class H(BaseHTTPRequestHandler):
|
||
|
|
def log_message(self, *a, **k): pass
|
||
|
|
def _send(self, body, code=200):
|
||
|
|
body_b = json.dumps(body, ensure_ascii=False, indent=2).encode("utf-8")
|
||
|
|
self.send_response(code)
|
||
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||
|
|
self.send_header("Content-Length", str(len(body_b)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(body_b)
|
||
|
|
def do_GET(self):
|
||
|
|
u = urlparse(self.path)
|
||
|
|
q = parse_qs(u.query)
|
||
|
|
try:
|
||
|
|
if u.path == "/api/status":
|
||
|
|
return self._send(api_status())
|
||
|
|
if u.path == "/api/nodes":
|
||
|
|
return self._send(api_nodes())
|
||
|
|
if u.path == "/api/proposals":
|
||
|
|
limit = min(int(q.get("limit", [30])[0]), 200)
|
||
|
|
return self._send(api_proposals(limit))
|
||
|
|
if u.path.startswith("/api/proposal/"):
|
||
|
|
w = int(u.path.split("/")[-1])
|
||
|
|
r = api_proposal(w)
|
||
|
|
if isinstance(r, tuple): return self._send(r[0], r[1])
|
||
|
|
return self._send(r)
|
||
|
|
self._send({"error": "unknown route", "path": u.path}, 404)
|
||
|
|
except Exception as e:
|
||
|
|
self._send({"error": str(e)}, 500)
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
port = int(os.environ.get("MT_EXPLORER_PORT", "5010"))
|
||
|
|
print(f"montana-explorer listening on :{port} reading {DATA_DIR}", flush=True)
|
||
|
|
HTTPServer(("0.0.0.0", port), H).serve_forever()
|