generate.py

#!/usr/bin/env python3
"""Solana Ecosystem Auto-Updating Report generator.

Pure Python standard library + public JSON APIs. No API keys required.
"""
import json, time, math, statistics, urllib.request, urllib.parse, os, sys, html, datetime, xml.etree.ElementTree as ET

SOLANA_RPC = "https://api.mainnet-beta.solana.com"
OUT = "/home/ubuntu/solana-report/public"
os.makedirs(OUT, exist_ok=True)

def http_get_json(url, timeout=20):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Solana Report Agent)"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))

def rpc(method, params=None):
    payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []}
    req = urllib.request.Request(SOLANA_RPC, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(req, timeout=20) as r:
        data = json.loads(r.read().decode("utf-8"))
    if "error" in data:
        raise RuntimeError(f"RPC error {method}: {data['error']}")
    return data.get("result")

def safe(fn, default=None):
    try:
        return fn()
    except Exception as e:
        return {"error": str(e), "value": default}

def main():
    now = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
    report = {"generated_at": now, "sources": []}

    # Solana RPC
    health = safe(lambda: rpc("getHealth"), "unknown")
    report["network_health"] = health
    slot = safe(lambda: rpc("getSlot"), None)
    epoch = safe(lambda: rpc("getEpochInfo"), None)
    perf = safe(lambda: rpc("getRecentPerformanceSamples", [120]), [])
    vote = safe(lambda: rpc("getVoteAccounts"), None)
    supply = safe(lambda: rpc("getSupply"), None)
    block_time = safe(lambda: rpc("getBlockTime", [slot] if slot else []), None)

    # performance derived metrics
    perf_samples = perf if isinstance(perf, list) else []
    tps_values = []
    slot_times = []
    for s in perf_samples:
        if isinstance(s, dict):
            num_tx = s.get("numTransactions")
            period = s.get("samplePeriodSecs")
            slots = s.get("numSlots")
            if isinstance(num_tx, int) and isinstance(period, int) and period > 0:
                tps_values.append(num_tx / period)
            if isinstance(slots, int) and isinstance(period, int) and slots > 0 and period > 0:
                slot_times.append(period / slots)

    current_tps = round(tps_values[0], 2) if tps_values else None
    avg_tps_120 = round(statistics.mean(tps_values), 2) if tps_values else None
    max_tps_120 = round(max(tps_values), 2) if tps_values else None
    current_slot_ms = round(slot_times[0] * 1000, 0) if slot_times else None
    avg_slot_ms = round(statistics.mean(slot_times) * 1000, 0) if slot_times else None
    # simple anomaly: current vs median of last 120
    tps_anomaly = None
    if len(tps_values) >= 5:
        med = statistics.median(tps_values)
        if med > 0:
            dev = (tps_values[0] - med) / med
            tps_anomaly = "spike" if dev > 0.35 else ("drop" if dev < -0.35 else "normal")

    # validator metrics
    validators = {}
    if isinstance(vote, dict):
        current = vote.get("current", []) or []
        delinquent = vote.get("delinquent", []) or []
        active = len(current)
        delinq = len(delinquent)
        total_stake = sum(v.get("activatedStake", 0) for v in current)
        validators = {
            "active": active,
            "delinquent": delinq,
            "total_activated_stake_lamports": total_stake,
            "total_activated_stake_sol": round(total_stake / 1e9, 2),
            "delinquency_rate": round(delinq / (active + delinq), 4) if (active + delinq) > 0 else None,
            "top_validators_by_stake": [
                {
                    "node_pubkey": v.get("nodePubkey"),
                    "vote_pubkey": v.get("votePubkey"),
                    "activated_stake_sol": round(v.get("activatedStake", 0) / 1e9, 2),
                    "commission": v.get("commission"),
                }
                for v in sorted(current, key=lambda x: x.get("activatedStake", 0), reverse=True)[:10]
            ],
        }

    # supply metrics
    supply_metrics = {}
    if isinstance(supply, dict):
        val = supply.get("value", {})
        supply_metrics = {
            "total_supply_sol": round(val.get("total", 0) / 1e9, 2),
            "circulating_supply_sol": round(val.get("circulating", 0) / 1e9, 2),
            "non_circulating_supply_sol": round(val.get("nonCirculating", 0) / 1e9, 2),
        }

    # epoch metrics
    epoch_metrics = {}
    if isinstance(epoch, dict):
        slot_index = epoch.get("slotIndex", 0)
        slots_in_epoch = epoch.get("slotsInEpoch", 0)
        epoch_metrics = {
            "epoch": epoch.get("epoch"),
            "slot_index": slot_index,
            "slots_in_epoch": slots_in_epoch,
            "absolute_slot": epoch.get("absoluteSlot"),
            "block_height": epoch.get("blockHeight"),
            "transaction_count": epoch.get("transactionCount"),
            "epoch_progress_pct": round(slot_index / slots_in_epoch * 100, 2) if slots_in_epoch else None,
        }

    # external market/ecosystem data
    market = safe(lambda: http_get_json("https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd&include_24hr_vol=true&include_24hr_change=true&include_market_cap=true"), {})
    sol = market.get("solana", {}) if isinstance(market, dict) else {}

    tvl_data = safe(lambda: http_get_json("https://api.llama.fi/v2/chains"), [])
    tvl = None
    if isinstance(tvl_data, list):
        for c in tvl_data:
            if c.get("name") == "Solana":
                tvl = c.get("tvl")
                break

    dex_data = safe(lambda: http_get_json("https://api.llama.fi/overview/dexs/solana?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true&dataType=dailyVolume"), {})
    dex_volume = None
    if isinstance(dex_data, dict):
        dex_volume = dex_data.get("total24h")

    stable_data = safe(lambda: http_get_json("https://stablecoins.llama.fi/stablecoins?includePrices=false"), {})
    sol_stable = None
    if isinstance(stable_data, dict):
        chains = stable_data.get("chains", [])
        if isinstance(chains, list):
            for c in chains:
                if isinstance(c, dict) and (c.get("name") == "Solana" or c.get("name") == "SOLANA"):
                    sol_stable = (c.get("totalCirculatingUSD", {}) or {}).get("peggedUSD")
                    break

    # Transaction fee / REV proxy from recent prioritization fees
    # Sample recent prioritization fees on a high-activity program (Raydium) as a REV proxy
    fees = safe(lambda: rpc("getRecentPrioritizationFees", [["675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"]]), [])
    fee_lamports = []
    if isinstance(fees, list):
        for f in fees:
            if isinstance(f, dict) and f.get("prioritizationFee") is not None:
                fee_lamports.append(f["prioritizationFee"])
    median_fee_lamports = statistics.median(fee_lamports) if fee_lamports else None
    mean_fee_lamports = round(statistics.mean(fee_lamports), 2) if fee_lamports else None
    max_fee_lamports = max(fee_lamports) if fee_lamports else None
    nonzero = sum(1 for x in fee_lamports if x > 0)
    report["fees"] = {
        "median_recent_prioritization_fee_sol": round(median_fee_lamports / 1e9, 9) if median_fee_lamports is not None else None,
        "mean_recent_prioritization_fee_sol": round(mean_fee_lamports / 1e9, 9) if mean_fee_lamports is not None else None,
        "max_recent_prioritization_fee_sol": round(max_fee_lamports / 1e9, 9) if max_fee_lamports is not None else None,
        "samples": len(fee_lamports),
        "nonzero_samples": nonzero,
        "note": "Prioritization fees on Raydium program (REV proxy); base fees and Jito MEV tips are excluded.",
    }

    # Curated context: upcoming Solana upgrades & developments (static, updated by maintainer)
    report["news"] = {
        "upcoming": [
            "Alpenglow (Firedancer client rollout / network upgrade)",
            "SIMD-525: tokenomics / inflation proposal under community discussion",
            "Firedancer (Jump) validator client improving performance & client diversity",
            "Token-2022 extensions driving tokenized asset & stablecoin growth",
        ],
        "sources_for_live_news": ["status.solana.com", "solana.com/news", "X @Solana (@SolanaStatus)"],
    }

    # Live Solana news from official RSS
    live_news = []
    try:
        req = urllib.request.Request("https://solana.com/rss.xml", headers={"User-Agent": "Mozilla/5.0 (Solana Report Agent)"})
        with urllib.request.urlopen(req, timeout=20) as r:
            rss = r.read().decode("utf-8", "ignore")
        root = ET.fromstring(rss)
        items = root.findall(".//item")
        for it in items[:6]:
            t = it.findtext("title")
            l = it.findtext("link")
            p = it.findtext("pubDate")
            if t:
                live_news.append({"title": (t or "").strip(), "link": (l or "").strip(), "pubDate": (p or "").strip()})
    except Exception as e:
        live_news.append({"title": f"Live news unavailable: {e}", "link": "", "pubDate": ""})
    report["news"]["live"] = live_news

    report["market"] = {
        "sol_price_usd": sol.get("usd"),
        "sol_24h_change_pct": sol.get("usd_24h_change"),
        "sol_24h_volume_usd": sol.get("usd_24h_vol"),
        "sol_market_cap_usd": sol.get("usd_market_cap"),
        "defillama_tvl_usd": tvl,
        "dex_24h_volume_usd": dex_volume,
        "solana_stablecoin_supply_usd": sol_stable,
    }

    report["performance"] = {
        "current_tps": current_tps,
        "average_tps_last_120_samples": avg_tps_120,
        "max_tps_last_120_samples": max_tps_120,
        "current_slot_time_ms": current_slot_ms,
        "average_slot_time_ms_last_120_samples": avg_slot_ms,
        "tps_anomaly_last_sample": tps_anomaly,
        "samples": len(perf_samples),
    }
    report["epoch"] = epoch_metrics
    report["validators"] = validators
    report["supply"] = supply_metrics
    report["slot"] = slot
    report["block_time_unix"] = block_time
    report["sources"] = ["Solana JSON-RPC (incl. getRecentPrioritizationFees)", "CoinGecko", "DefiLlama", "DefiLlama stablecoins", "DefiLlama DEX overview"]

    # write outputs
    with open(os.path.join(OUT, "report.json"), "w") as f:
        json.dump(report, f, indent=2)

    # markdown
    md = []
    md.append("# Solana Ecosystem Auto-Updating Report\n")
    md.append(f"- Generated: `{now}`\n- Slot: `{slot}`\n- Network health: `{health}`\n")
    md.append("## Network Performance\n")
    md.append(f"- Current TPS: **{current_tps}**")
    md.append(f"- Average TPS (last {len(perf_samples)} samples): **{avg_tps_120}**")
    md.append(f"- Current slot time: **{current_slot_ms} ms**")
    md.append(f"- Average slot time: **{avg_slot_ms} ms**")
    md.append(f"- TPS anomaly: `{tps_anomaly}`\n")
    md.append("## Epoch\n")
    for k, v in epoch_metrics.items():
        md.append(f"- {k}: `{v}`")
    md.append("\n## Validators\n")
    for k, v in validators.items():
        if k == "top_validators_by_stake":
            continue
        md.append(f"- {k}: `{v}`")
    md.append("\n### Top validators by stake\n")
    md.append("| # | Vote pubkey | SOL stake | Commission |")
    md.append("|---|---|---|---|")
    for i, v in enumerate(validators.get("top_validators_by_stake", []), 1):
        md.append(f"| {i} | `{v.get('vote_pubkey')}` | {v.get('activated_stake_sol')} | {v.get('commission')} |")
    md.append("\n## Transaction Fees (REV proxy)\n")
    for k, v in report["fees"].items():
        md.append(f"- {k}: `{v}`")
    md.append("\n## News & Upcoming\n")
    for it in report["news"]["upcoming"]:
        md.append(f"- {it}")
    md.append("\n### Live Solana news (solana.com RSS)\n")
    for n in report["news"].get("live", []):
        md.append(f"- [{n.get('title')}]({n.get('link')}) ({n.get('pubDate')})")
    md.append(f"- Live news sources: {', '.join(report['news']['sources_for_live_news'])}")
    md.append("\n## Supply\n")
    for k, v in supply_metrics.items():
        md.append(f"- {k}: `{v}`")
    md.append("\n## Market & Ecosystem\n")
    for k, v in report["market"].items():
        md.append(f"- {k}: `{v}`")
    with open(os.path.join(OUT, "report.md"), "w") as f:
        f.write("\n".join(md))

    # HTML dashboard
    def fmt(v):
        if v is None:
            return "—"
        if isinstance(v, float):
            return f"{v:,.2f}"
        return str(v)

    rows_validators = ""
    for i, v in enumerate(validators.get("top_validators_by_stake", []), 1):
        rows_validators += f"<tr><td>{i}</td><td class='mono'>{html.escape(str(v.get('vote_pubkey'))[:24])}…</td><td>{fmt(v.get('activated_stake_sol'))}</td><td>{fmt(v.get('commission'))}%</td></tr>"

    cards = []
    cards.append(("Current TPS", fmt(current_tps)))
    cards.append(("Avg TPS", fmt(avg_tps_120)))
    cards.append(("Slot time (ms)", fmt(current_slot_ms)))
    cards.append(("SOL price", fmt(sol.get("usd"))))
    cards.append(("TVL", fmt(tvl)))
    cards.append(("DEX 24h vol", fmt(dex_volume)))
    cards.append(("Median prio fee (SOL)", fmt(report["fees"].get("median_recent_prioritization_fee_sol"))))
    cards.append(("Stablecoin supply", fmt(sol_stable)))
    cards_html = "\n".join(f"<div class='card'><div class='label'>{html.escape(k)}</div><div class='value'>{html.escape(v)}</div></div>" for k, v in cards)

    kv = lambda pairs: "\n".join(f"<tr><td>{html.escape(str(k))}</td><td class='mono'>{html.escape(str(v))}</td></tr>" for k, v in pairs)

    html_doc = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Solana Ecosystem Auto-Updating Report</title>
<style>
:root {{ --bg:#0b0e14; --panel:#141a24; --text:#e6edf3; --muted:#8b98a9; --accent:#14f195; --accent2:#9945ff; --danger:#ff6b6b; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--text); font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; }}
header {{ padding:28px 32px; border-bottom:1px solid #202a38; background:linear-gradient(90deg,#0f1720 0%,#101826 100%); }}
h1 {{ font-size:1.4rem; margin:0 0 8px; }}
h2 {{ font-size:1rem; color:var(--accent); margin:28px 0 10px; }}
.muted {{ color:var(--muted); font-size:.85rem; }}
main {{ max-width:1080px; margin:0 auto; padding:24px; }}
.grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; }}
.card {{ background:var(--panel); border:1px solid #202a38; border-radius:12px; padding:16px; }}
.card .label {{ color:var(--muted); font-size:.75rem; text-transform:uppercase; letter-spacing:.08em; }}
.card .value {{ font-size:1.5rem; font-weight:600; margin-top:8px; color:var(--accent); }}
table {{ width:100%; border-collapse:collapse; font-size:.85rem; background:var(--panel); border-radius:12px; overflow:hidden; }}
th,td {{ text-align:left; padding:9px 10px; border-bottom:1px solid #202a38; }}
th {{ color:var(--muted); font-weight:500; background:#0e141d; }}
tr:last-child td {{ border-bottom:0; }}
.mono {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.8rem; }}
.status {{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:.75rem; background:rgba(20,241,149,.12); color:var(--accent); }}
.anomaly {{ color:var(--danger); }}
footer {{ margin-top:30px; color:var(--muted); font-size:.75rem; }}
</style></head><body>
<header><h1>Solana Ecosystem Auto-Updating Report</h1><div class="muted">Generated {now} · Network health: <span class="status">{html.escape(str(health))}</span> · Slot {fmt(slot)}</div></header>
<main>
<div class="grid">{cards_html}</div>
<h2>Epoch</h2><table>{kv(epoch_metrics.items())}</table>
<h2>Validators</h2><table><tr><th>Active</th><th>Delinquent</th><th>Total stake (SOL)</th><th>Delinquency rate</th></tr><tr><td>{fmt(validators.get('active'))}</td><td>{fmt(validators.get('delinquent'))}</td><td>{fmt(validators.get('total_activated_stake_sol'))}</td><td>{fmt(validators.get('delinquency_rate'))}</td></tr></table>
<h2>Top Validators by Stake</h2><table><tr><th>#</th><th>Vote pubkey</th><th>Stake SOL</th><th>Commission</th></tr>{rows_validators}</table>
<h2>Supply</h2><table>{kv(supply_metrics.items())}</table>
<h2>Market & Ecosystem</h2><table>{kv(report['market'].items())}</table>
<h2>Transaction Fees (REV proxy)</h2><table>{kv(report['fees'].items())}</table>
    <h2>News & Upcoming</h2><ul>{"".join(f"<li>{html.escape(x)}</li>" for x in report['news']['upcoming'])}</ul>
    <h3>Live Solana news</h3><ul>{"".join(f"<li><a href='{html.escape(n.get('link',''))}' style='color:#14f195'>{html.escape(n.get('title',''))}</a> <span class='muted'>({html.escape(n.get('pubDate',''))})</span></li>" for n in report['news'].get('live', []))}</ul>
    <p class="muted">Live news sources: {html.escape(', '.join(report['news']['sources_for_live_news']))}</p>
<h2>Performance Anomaly</h2><p class="{ 'anomaly' if tps_anomaly not in (None,'normal') else '' }">Last-sample TPS anomaly: <strong>{html.escape(str(tps_anomaly))}</strong></p>
<footer>Support this autonomous agent: <span class="mono">0x1B6258F2ABc44a80Ba7e87f27F3e42847b343519</span> (ETH)<br>Downloads: <a href="/source.html" style="color:#14f195">source</a> · <a href="/report.json" style="color:#14f195">report.json</a> · <a href="/report.md" style="color:#14f195">report.md</a> · <a href="/source.tar.gz" style="color:#14f195">source.tar.gz</a><br>Data sources: Solana JSON-RPC · CoinGecko · DefiLlama · DefiLlama stablecoins · DefiLlama DEX overview. Auto-refresh cron every 5 min.</footer>
</main></body></html>"""
    with open(os.path.join(OUT, "index.html"), "w") as f:
        f.write(html_doc)

    print(f"Generated at {now}; TPS={current_tps}; validators={validators.get('active')}; TVL={tvl}; SOL=${sol.get('usd')}")

if __name__ == "__main__":
    main()

README

# Solana Ecosystem Auto-Updating Report

A Python 3 standard-library-only report generator that creates an interactive dark HTML dashboard, a human-readable Markdown report, and a machine-readable JSON report for the Solana ecosystem. No API keys or external Python dependencies are required.

## Data sources
- Solana JSON-RPC (`https://api.mainnet-beta.solana.com`): `getHealth`, `getSlot`, `getEpochInfo`, `getRecentPerformanceSamples`, `getVoteAccounts`, `getSupply`, `getBlockTime`
- CoinGecko simple price API: SOL price, 24h change, 24h volume, market cap
- DefiLlama: chain TVL, DEX daily volume, stablecoin circulating supply

## Metrics
- Network performance: current TPS, average/max TPS over recent performance samples, current/average slot time in ms
- TPS anomaly detection: compares the latest sample against the median of the last 120 samples and flags `spike`, `drop`, or `normal`
- Epoch progress: epoch number, slot index, slots in epoch, block height, transaction count, epoch progress %
- Validators: active and delinquent counts, total activated stake, delinquency rate, top 10 validators by stake with commission
- Supply: total, circulating, and non-circulating SOL
- Market & ecosystem: SOL price/volume/market cap, DefiLlama TVL, DEX 24h volume, Solana stablecoin supply

## Run
```bash
python3 generate.py
```
Outputs are written to `public/index.html`, `public/report.json`, and `public/report.md`.

To serve the live dashboard:
```bash
cd public && python3 -m http.server 8080 --bind 0.0.0.0
```

## Automation
A cron entry regenerates the report every 5 minutes:
```cron
*/5 * * * * /usr/bin/python3 /home/ubuntu/solana-report/generate.py >> /home/ubuntu/solana-report/cron.log 2>&1
```

## Interpretation
- A `tps_anomaly` value of `spike` or `drop` indicates the latest sample is more than 35% away from the recent median.
- A rising delinquency rate or falling average slot time may indicate network stress.
- DEX volume and TVL are provided for economic context.