#!/usr/bin/env python3
"""sol_token_security.py — Solana SPL/Token-2022 token security snapshot.

Input : a Solana mint address (base58, 32-44 chars).
Output: markdown security snapshot — RugCheck risk findings (authorities,
        holder concentration, insider networks, LP lock, rugged flag)
        plus GoPlus Token-2022 extension flags (mintable, freezable,
        closable, transfer hook/fee, default account state) and market
        data. Honest scope: public API screening signals; NOT a
        contract-level audit.

Usage:  python3 sol_token_security.py <MINT> [out.md]
        python3 sol_token_security.py --selftest
Deps:   stdlib only (urllib). No API keys required.
"""
import json, re, sys, time, urllib.request

RUGCHECK = "https://api.rugcheck.xyz/v1/tokens/{}/report"
GOPLUS = ("https://api.gopluslabs.io/api/v1/solana/token_security"
          "?contract_addresses={}")
B58 = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")


def get_json(url):
    req = urllib.request.Request(
        url, headers={"User-Agent": "automaton-safe-sol-token-scan/1.0"})
    with urllib.request.urlopen(req, timeout=25) as r:
        return json.load(r)


def _auth_list(node):
    """GoPlus authority node -> list of authority addresses (may be absent)."""
    if not isinstance(node, dict):
        return []
    return [a.get("address", "?") for a in (node.get("authority") or [])
            if isinstance(a, dict)]


def _pct(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return 0.0


def analyze(rc, gp):
    """Pure analysis on the two API payloads -> snapshot dict."""
    rc = rc or {}
    gp = gp or {}
    flags = []
    trusted = gp.get("trusted_token") == 1 or rc.get("verification") is not None

    def add(sev, msg):
        flags.append((sev, msg))

    def sev_for_authority(base):
        # Registry-trusted issuers (USDC etc.) hold authorities by design;
        # still report, but don't scream red on a known-centralised token.
        return "OK" if (trusted and base in ("mintable", "freezable")) else "HIGH"

    # ---------- RugCheck ----------
    if rc:
        if rc.get("rugged"):
            add("HIGH", "RugCheck reports this token as RUGGED.")
        risk_names = [str(r.get("name", "")).lower()
                      for r in rc.get("risks") or [] if isinstance(r, dict)]
        for r in rc.get("risks") or []:
            if not isinstance(r, dict):
                continue
            lvl = str(r.get("level", "")).lower()
            sev = ("HIGH" if lvl in ("danger", "critical", "crit")
                   else "WARN" if lvl in ("warn", "warning")
                   else "OK")
            desc = str(r.get("description") or r.get("value") or "")
            add(sev, f"{r.get('name', 'risk')}"
                     + (f" — {desc[:140]}" if desc else ""))
        if rc.get("mintAuthority") and not any("mint authority" in n
                                               for n in risk_names):
            sev = sev_for_authority("mintable")
            add(sev, "Mint authority still enabled — new supply can be "
                "created at will (dilution risk)."
                + (" (issuer-controlled; token is registry-trusted)"
                   if sev == "OK" else ""))
        if rc.get("freezeAuthority") and not any("freeze" in n
                                                 for n in risk_names):
            sev = sev_for_authority("freezable")
            add(sev, "Freeze authority still enabled — issuer can freeze "
                "any holder's token account."
                + (" (issuer-controlled; token is registry-trusted)"
                   if sev == "OK" else ""))
        sn = rc.get("score_normalised")
        if isinstance(sn, (int, float)):
            if sn >= 200:
                add("HIGH", f"RugCheck normalised score {sn} — severe.")
            elif sn >= 50:
                add("WARN", f"RugCheck normalised score {sn} — elevated.")
            elif not rc.get("risks"):
                add("OK", f"RugCheck normalised score {sn} — no listed risks.")
        th = rc.get("topHolders") or []
        if th:
            top10 = sum(_pct(h.get("pct")) for h in th[:10]
                        if isinstance(h, dict))
            insiders = [h for h in th if isinstance(h, dict)
                        and h.get("insider")]
            if top10 >= 80:
                add("HIGH", f"Top 10 holders control ~{top10:.1f}% of supply.")
            elif top10 >= 50:
                add("WARN", f"Top 10 holders control ~{top10:.1f}% of supply.")
            else:
                add("OK", f"Top 10 holders control ~{top10:.1f}% of supply.")
            if insiders:
                add("WARN", f"{len(insiders)} top holder(s) flagged as "
                    "insider-connected.")
        if rc.get("graphInsidersDetected") or rc.get("insiderNetworks"):
            add("WARN", "Insider network detected in holder graph "
                "(bundled/coordinated funding).")
        tml = rc.get("totalMarketLiquidity")
        if isinstance(tml, (int, float)):
            if tml >= 5000:
                add("OK", f"Market liquidity ${tml:,.0f}.")
            elif rc.get("markets"):
                add("WARN", f"Thin liquidity (${tml:,.0f} total) — easy to "
                    "drain/manipulate.")
        locked_pcts = []
        for m in rc.get("markets") or []:
            lp = (m or {}).get("lp") or {}
            if lp.get("lpLockedPct") is not None:
                locked_pcts.append((_pct(lp.get("lpLockedPct")),
                                    _pct(lp.get("lpLockedUSD"))))
        if locked_pcts:
            tot = sum(u for _, u in locked_pcts) or 1
            wavg = sum(p * u for p, u in locked_pcts) / tot
            if wavg < 50:
                add("WARN", f"Only ~{wavg:.0f}% of LP value is locked/burned "
                    "— liquidity can be pulled.")
            else:
                add("OK", f"~{wavg:.0f}% of LP value locked/burned.")
        tfee = rc.get("transferFee")
        if isinstance(tfee, dict) and _pct(tfee.get("pct")) > 0:
            add("WARN", f"Token-2022 transfer fee {_pct(tfee.get('pct'))}% "
                "configured — transfers cost a fee that can be raised by "
                "the fee authority.")
        rugged_kids = [t for t in rc.get("creatorTokens") or []
                       if isinstance(t, dict) and t.get("rugged")]
        if rugged_kids:
            add("WARN", f"Creator wallet has {len(rugged_kids)} other "
                "token(s) already marked rugged.")
    else:
        add("WARN", "RugCheck report unavailable — partial coverage only.")

    # ---------- GoPlus (Token-2022 extensions; dedupe vs RugCheck) ----------
    if gp:
        rc_txt = " ".join(m.lower() for _, m in flags)

        def g_flag(node, key, label, base, extra=""):
            auths = _auth_list(node)
            if not auths:
                return
            if key in rc_txt:
                return
            sev = sev_for_authority(base)
            note = (" (issuer-controlled; token is registry-trusted)"
                    if sev == "OK" else "")
            add(sev, f"{label} authority set ({auths[0][:12]}…) — "
                f"{extra}{note}")

        g_flag(gp.get("mintable"), "mint authority", "Mint", "mintable",
               "supply can be inflated at will")
        g_flag(gp.get("freezable"), "freeze authority", "Freeze", "freezable",
               "holder accounts can be frozen")
        if _auth_list(gp.get("closable")):
            add("HIGH", "Closable authority set — the mint account itself "
                "can be closed and its lamports reclaimed.")
        if _auth_list(gp.get("balance_mutable_authority")):
            add("HIGH", "Balance-mutable authority set — holder balances "
                "can be rewritten.")
        das = str(gp.get("default_account_state", ""))
        if das.lower() in ("2", "frozen"):
            add("HIGH", "Default account state = FROZEN — every new holder "
                "starts frozen until the issuer thaws them.")
        if _auth_list(gp.get("default_account_state_upgradable")):
            add("WARN", "Default-account-state is upgradable — issuer can "
                "switch future accounts to frozen-by-default.")
        if str(gp.get("non_transferable", "0")) == "1":
            add("HIGH", "Non-transferable token — holders cannot send or "
                "sell (soulbound-style restriction).")
        if gp.get("transfer_hook"):
            add("WARN", "Transfer hook program attached — custom logic runs "
                "on every transfer and can block sales.")
        for k, lbl in (("transfer_hook_upgradable", "Transfer-hook"),
                       ("transfer_fee_upgradable", "Transfer-fee"),
                       ("metadata_mutable", "Metadata-upgrade")):
            if _auth_list(gp.get(k)):
                add("WARN", f"{lbl} authority set — can be changed after "
                    "launch.")
        tf = gp.get("transfer_fee")
        if isinstance(tf, dict) and tf:
            add("WARN", "Transfer fee extension active: "
                + json.dumps(tf)[:120])
        for c in gp.get("creators") or []:
            if isinstance(c, dict) and c.get("malicious_address"):
                add("HIGH", "Creator address flagged malicious by GoPlus.")
                break
        hc = gp.get("holder_count")
        if hc is not None:
            try:
                hc = int(hc)
                add("OK" if hc >= 100 else "WARN", f"{hc:,} holders.")
            except (TypeError, ValueError):
                pass
        if gp.get("holders") and not (rc.get("topHolders")):
            ts = _pct(gp.get("total_supply")) or 1
            top10 = sum(_pct(h.get("balance")) / ts * 100
                        for h in gp["holders"][:10]
                        if isinstance(h, dict))
            if top10 >= 80:
                add("HIGH", f"Top 10 holders control ~{top10:.1f}% of supply.")
            elif top10 >= 50:
                add("WARN", f"Top 10 holders control ~{top10:.1f}% of supply.")
        if trusted:
            add("OK", "Registry-trusted / verified issuer metadata.")
    else:
        add("WARN", "GoPlus security data unavailable — partial coverage "
            "only.")

    name = ((rc.get("tokenMeta") or {}).get("name")
            or (gp.get("metadata") or {}).get("name") or "")
    sym = ((rc.get("tokenMeta") or {}).get("symbol")
           or (gp.get("metadata") or {}).get("symbol") or "")
    highs = sum(1 for s, _ in flags if s == "HIGH")
    warns = sum(1 for s, _ in flags if s == "WARN")
    verdict = ("HIGH RISK — multiple severe signals" if highs >= 2
               else "ELEVATED — one severe signal" if highs == 1
               else "CAUTION — warning signals only" if warns
               else "NO RED FLAGS AT SCREENING LEVEL")
    return {
        "mint": rc.get("mint") or "",
        "name": name, "symbol": sym,
        "program": rc.get("tokenProgram", ""),
        "price": rc.get("price"),
        "holders": rc.get("totalHolders") or gp.get("holder_count"),
        "liquidity_usd": rc.get("totalMarketLiquidity"),
        "stable_liquidity_usd": rc.get("totalStableLiquidity"),
        "score_normalised": rc.get("score_normalised"),
        "rugged": bool(rc.get("rugged")),
        "trusted": trusted,
        "verdict": verdict,
        "flags": flags,
    }


def snapshot(mint):
    rc = gp = None
    try:
        rc = get_json(RUGCHECK.format(mint))
    except Exception:
        rc = {}
    try:
        d = get_json(GOPLUS.format(mint))
        res = d.get("result") or {}
        gp = res.get(mint) or next(iter(res.values()), {}) or {}
    except Exception:
        gp = {}
    s = analyze(rc, gp)
    s["mint"] = mint
    return s


def report(s):
    L = []
    L.append(f"# Solana Token Security Snapshot")
    L.append(f"generated {time.strftime('%Y-%m-%d %H:%M:%SZ', time.gmtime())}"
             " · automaton-safe")
    L.append("")
    L.append(f"**Mint:** `{s['mint']}`")
    if s["name"] or s["symbol"]:
        L.append(f"**Token:** {s['name']} ({s['symbol']})")
    if s["program"]:
        L.append(f"**Program:** `{s['program']}`")
    if s["holders"] is not None:
        L.append(f"**Holders:** {s['holders']}")
    if s["price"]:
        L.append(f"**Price:** ${s['price']}")
    L.append("")
    L.append(f"## Verdict: {s['verdict']}")
    L.append("")
    L.append("## Findings (RugCheck + GoPlus)")
    for sev, msg in s["flags"]:
        L.append(f"- **[{sev}]** {msg}")
    L.append("")
    L.append("## Market data")
    if s["liquidity_usd"] is not None:
        L.append(f"- Total market liquidity: ${s['liquidity_usd']:,.0f}")
    if s["stable_liquidity_usd"] is not None:
        L.append(f"- Stable-coin liquidity: ${s['stable_liquidity_usd']:,.0f}")
    if s["score_normalised"] is not None:
        L.append(f"- RugCheck normalised score: {s['score_normalised']}")
    L.append("")
    L.append("---")
    L.append("*Scope: public API screening signals (RugCheck, GoPlus) — a "
             "snapshot, not a contract audit. Program-level review needs "
             "bytecode/source analysis; absence of flags is not proof of "
             "safety.*")
    return "\n".join(L)


def selftest():
    usdc_like_rc = {"mint": "M", "tokenProgram": "Tokenkeg", "rugged": False,
                    "risks": [], "score_normalised": 1, "topHolders": [],
                    "markets": [], "totalMarketLiquidity": 9e7,
                    "tokenMeta": {"name": "T", "symbol": "T"},
                    "totalHolders": 1000, "price": 1.0}
    usdc_like_gp = {"trusted_token": 1, "holder_count": "1000",
                    "mintable": {"authority": [{"address": "A"}]},
                    "freezable": {"authority": [{"address": "F"}]},
                    "metadata": {"name": "T", "symbol": "T"}}
    s = analyze(usdc_like_rc, usdc_like_gp)
    assert s["verdict"] == "NO RED FLAGS AT SCREENING LEVEL", s["flags"]

    rug_rc = {"rugged": True,
              "risks": [{"name": "Mint Authority still enabled",
                         "level": "danger", "description": "x"}],
              "mintAuthority": "A", "freezeAuthority": "F",
              "score_normalised": 800,
              "topHolders": [{"pct": 90, "insider": True}],
              "graphInsidersDetected": True,
              "totalMarketLiquidity": 100,
              "markets": [{"lp": {"lpLockedPct": 0, "lpLockedUSD": 100}}],
              "creatorTokens": [{"rugged": True}],
              "tokenMeta": {"name": "R", "symbol": "R"}}
    rug_gp = {"closable": {"authority": [{"address": "C"}]},
              "balance_mutable_authority": {"authority": [{"address": "B"}]},
              "non_transferable": "1",
              "default_account_state": "frozen",
              "transfer_hook": [{"address": "H"}],
              "default_account_state_upgradable":
                  {"authority": [{"address": "U"}]},
              "transfer_fee_upgradable": {"authority": [{"address": "U"}]},
              "metadata_mutable":
                  {"metadata_upgrade_authority": [{"address": "U"}]},
              "transfer_fee": {"max": "10"},
              "creators": [{"malicious_address": 1}],
              "holder_count": "3"}
    s = analyze(rug_rc, rug_gp)
    highs = [m for sev, m in s["flags"] if sev == "HIGH"]
    assert s["rugged"] and s["verdict"].startswith("HIGH RISK"), s["verdict"]
    assert len(highs) >= 8, highs

    warn_only_rc = {"risks": [{"name": "Mutable metadata", "level": "warn",
                               "description": "x"}],
                    "score_normalised": 60,
                    "topHolders": [{"pct": 20}, {"pct": 20}, {"pct": 15}],
                    "totalMarketLiquidity": 20000,
                    "transferFee": {"pct": 5},
                    "tokenMeta": {"name": "W", "symbol": "W"}}
    s = analyze(warn_only_rc, {})
    assert s["verdict"].startswith("CAUTION"), s["verdict"]

    s = analyze({}, {})
    assert any("unavailable" in m for _, m in s["flags"])
    print("selftest PASS (4 vectors)")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("usage: sol_token_security.py <MINT> [out.md] | --selftest")
    if sys.argv[1] == "--selftest":
        selftest()
        sys.exit(0)
    mint = sys.argv[1]
    if not B58.match(mint):
        sys.exit("error: expected a base58 Solana mint address (32-44 chars)")
    out = report(snapshot(mint))
    if len(sys.argv) > 2:
        open(sys.argv[2], "w").write(out + "\n")
    else:
        print(out)
