- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
|
RugMaps AI Analysis - DeepSeek Flash powered token investigation.
|
|
Uses our own data: FAISS indexes, wallet labels, clustering engine, RAG cache.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# LLM config - DeepSeek Flash for fast, cheap analysis
|
|
DEEPSEEK_KEY = os.getenv("DEEPSEEK_API_KEY", "") or os.getenv("LLM_API_KEY", "") or os.getenv("OPENROUTER_API_KEY", "")
|
|
DEEPSEEK_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1/chat/completions")
|
|
AI_MODEL = os.getenv("LLM_MODEL", "deepseek-chat") # Flash by default
|
|
|
|
# Analysis cache (in-memory + Redis if available)
|
|
_analysis_cache: dict[str, dict] = {}
|
|
CACHE_TTL = 3600 # 1 hour
|
|
|
|
|
|
def _cache_key(token: str, chain: str) -> str:
|
|
return hashlib.sha256(f"{chain}:{token}".encode()).hexdigest()[:16]
|
|
|
|
|
|
async def get_cached_analysis(token: str, chain: str) -> dict | None:
|
|
"""Check if we have a cached analysis for this token."""
|
|
key = _cache_key(token, chain)
|
|
# In-memory cache
|
|
if key in _analysis_cache:
|
|
entry = _analysis_cache[key]
|
|
if time.time() - entry.get("_ts", 0) < CACHE_TTL:
|
|
logger.info(f"RugMaps cache HIT for {token[:12]}...")
|
|
return entry
|
|
return None
|
|
|
|
|
|
def set_cached_analysis(token: str, chain: str, analysis: dict):
|
|
"""Cache analysis result."""
|
|
key = _cache_key(token, chain)
|
|
analysis["_ts"] = time.time()
|
|
analysis["_cached"] = True
|
|
_analysis_cache[key] = analysis
|
|
# Prune old entries if memory grows
|
|
if len(_analysis_cache) > 500:
|
|
oldest = sorted(_analysis_cache.items(), key=lambda x: x[1].get("_ts", 0))[:100]
|
|
for k, _ in oldest:
|
|
del _analysis_cache[k]
|
|
|
|
|
|
async def get_token_holders(token_address: str, chain: str) -> list[dict]:
|
|
"""Get token holders from our data sources."""
|
|
try:
|
|
from app.bubble_maps import get_bubble_maps_pro
|
|
|
|
bm = get_bubble_maps_pro()
|
|
result = await bm.generate_map(center_wallet=token_address, depth=2, min_strength=0.1)
|
|
d = result.to_dict()
|
|
return d.get("nodes", [])
|
|
except Exception as e:
|
|
logger.warning(f"Holder fetch failed: {e}")
|
|
return []
|
|
|
|
|
|
async def get_wallet_labels(addresses: list[str]) -> dict[str, dict]:
|
|
"""Get wallet labels from our ClickHouse/FAISS database."""
|
|
labels = {}
|
|
try:
|
|
from app.bubble_maps import get_bubble_maps_pro
|
|
|
|
bm = get_bubble_maps_pro()
|
|
for addr in addresses[:50]:
|
|
try:
|
|
info = await bm._get_entity_info(addr)
|
|
risk, level = await bm._calculate_risk(addr)
|
|
labels[addr] = {
|
|
"entity": info or {},
|
|
"risk_score": risk,
|
|
"risk_level": level,
|
|
}
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning(f"Label fetch error: {e}")
|
|
return labels
|
|
|
|
|
|
async def search_similar_scams(token_address: str, chain: str) -> list[dict]:
|
|
"""Search FAISS indexes for similar scam patterns."""
|
|
similar = []
|
|
try:
|
|
from app.ann_index import get_ann_index
|
|
from app.crypto_embeddings import CryptoEmbedder
|
|
|
|
ann = get_ann_index()
|
|
embedder = CryptoEmbedder()
|
|
|
|
# Embed token info
|
|
query_text = f"token {token_address} on {chain} rug pull scam honeypot"
|
|
embedding = await embedder.embed_query(query_text)
|
|
|
|
# Search scam patterns index
|
|
if "scam_patterns" in ann.indexes:
|
|
results = ann.search("scam_patterns", embedding, k=5)
|
|
for r in results:
|
|
similar.append(
|
|
{
|
|
"pattern": r.get("metadata", {}).get("pattern", ""),
|
|
"description": r.get("metadata", {}).get("description", ""),
|
|
"similarity": float(r.get("score", 0)),
|
|
}
|
|
)
|
|
|
|
# Search known scams
|
|
if "known_scams" in ann.indexes:
|
|
results = ann.search("known_scams", embedding, k=5)
|
|
for r in results:
|
|
similar.append(
|
|
{
|
|
"scam_type": r.get("metadata", {}).get("type", ""),
|
|
"description": r.get("metadata", {}).get("description", ""),
|
|
"similarity": float(r.get("score", 0)),
|
|
}
|
|
)
|
|
|
|
# Search wallet profiles
|
|
if "wallet_profiles" in ann.indexes:
|
|
results = ann.search("wallet_profiles", embedding, k=5)
|
|
for r in results:
|
|
similar.append(
|
|
{
|
|
"wallet_type": r.get("metadata", {}).get("type", ""),
|
|
"description": r.get("metadata", {}).get("label", ""),
|
|
"similarity": float(r.get("score", 0)),
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"FAISS search failed: {e}")
|
|
|
|
# Deduplicate and sort by similarity
|
|
seen = set()
|
|
unique = []
|
|
for s in sorted(similar, key=lambda x: x.get("similarity", 0), reverse=True):
|
|
desc = s.get("description", "")
|
|
if desc and desc not in seen:
|
|
seen.add(desc)
|
|
unique.append(s)
|
|
|
|
return unique[:10]
|
|
|
|
|
|
async def generate_ai_analysis(
|
|
token_address: str,
|
|
chain: str,
|
|
graph_data: dict,
|
|
holders: list[dict],
|
|
labels: dict[str, dict],
|
|
similar_scams: list[dict],
|
|
) -> dict:
|
|
"""Generate AI analysis using DeepSeek Flash with our own data."""
|
|
import httpx
|
|
|
|
# Build a data-rich prompt from our own systems
|
|
holder_summary = []
|
|
for h in holders[:10]:
|
|
addr = h.get("address", "")[:12]
|
|
pct = h.get("supply_pct", h.get("pct", "?"))
|
|
label = labels.get(h.get("address", ""), {}).get("entity", {}).get("name", "")
|
|
risk = labels.get(h.get("address", ""), {}).get("risk_score", "?")
|
|
holder_summary.append(f" {addr}... - {pct}% supply - label: {label or 'unknown'} - risk: {risk}/100")
|
|
|
|
similar_summary = (
|
|
"\n".join(
|
|
[
|
|
f" - {s.get('description', '')[:100]} (similarity: {s.get('similarity', 0):.2f})"
|
|
for s in similar_scams[:5]
|
|
]
|
|
)
|
|
or "No similar scam patterns found in our database."
|
|
)
|
|
|
|
graph_stats = graph_data.get("stats", {})
|
|
node_count = graph_data.get("node_count", len(holders))
|
|
link_count = graph_data.get("link_count", 0)
|
|
risk_score = graph_data.get("risk_score", 0)
|
|
|
|
prompt = f"""You are RugMunch Intelligence's AI security analyst. Analyze this token using our proprietary on-chain data.
|
|
|
|
TOKEN: {token_address} on {chain}
|
|
|
|
GRAPH DATA:
|
|
- {node_count} connected wallets
|
|
- {link_count} transaction links
|
|
- Graph risk score: {risk_score}/100
|
|
- Graph stats: {json.dumps(graph_stats)}
|
|
|
|
TOP HOLDERS (from our systems):
|
|
{chr(10).join(holder_summary) if holder_summary else "No holder data available"}
|
|
|
|
SIMILAR SCAM PATTERNS (from our FAISS vector database):
|
|
{similar_summary}
|
|
|
|
WALLET LABELS (from our 393K-label database):
|
|
{json.dumps({a[:12]: line.get("entity", {}).get("name") or line.get("entity", {}).get("label") for a, line in list(labels.items())[:10]})}
|
|
|
|
Based on our data, provide a JSON analysis with these fields:
|
|
- "verdict": (SAFE / SUSPICIOUS / HIGH_RISK / SCAM)
|
|
- "risk_score": (0-100)
|
|
- "summary": (2-3 sentence analysis for users)
|
|
- "red_flags": (list of specific concerning findings, empty if none)
|
|
- "green_flags": (list of positive indicators, empty if none)
|
|
- "dev_wallets": (list of addresses likely belonging to developer/team)
|
|
- "bundle_wallets": (list of addresses in coordinated bundles)
|
|
- "recommendation": (one sentence: what should a user do?)
|
|
- "confidence": (0-100, how confident are you in this analysis?)
|
|
|
|
Return ONLY valid JSON, no markdown, no code blocks."""
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
DEEPSEEK_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {DEEPSEEK_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": AI_MODEL,
|
|
"messages": [
|
|
{
|
|
"role": "system",
|
|
"content": "You are a crypto security expert. Return only valid JSON.",
|
|
},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
"temperature": 0.3,
|
|
"max_tokens": 800,
|
|
},
|
|
)
|
|
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "{}")
|
|
|
|
# Try to parse the JSON response
|
|
try:
|
|
# Strip any markdown code blocks
|
|
if "```" in content:
|
|
content = content.split("```")[1]
|
|
if content.startswith("json"):
|
|
content = content[4:]
|
|
analysis = json.loads(content.strip())
|
|
return analysis
|
|
except json.JSONDecodeError:
|
|
# Fallback: return raw text as summary
|
|
return {
|
|
"verdict": "UNKNOWN",
|
|
"risk_score": risk_score,
|
|
"summary": content[:300],
|
|
"red_flags": [],
|
|
"green_flags": [],
|
|
"recommendation": "Manual review recommended - AI response was malformed.",
|
|
"confidence": 30,
|
|
}
|
|
|
|
logger.warning(f"DeepSeek API returned {resp.status_code}: {resp.text[:200]}")
|
|
except Exception as e:
|
|
logger.error(f"AI analysis failed: {e}")
|
|
|
|
# Fallback analysis from our own data (no AI needed)
|
|
return generate_fallback_analysis(risk_score, holders, labels, similar_scams, node_count, link_count)
|
|
|
|
|
|
def generate_fallback_analysis(
|
|
risk_score: int,
|
|
holders: list[dict],
|
|
labels: dict[str, dict],
|
|
similar_scams: list[dict],
|
|
node_count: int,
|
|
link_count: int,
|
|
) -> dict:
|
|
"""Generate analysis purely from our own data when AI is unavailable."""
|
|
|
|
red_flags = []
|
|
green_flags = []
|
|
dev_wallets = []
|
|
bundle_wallets = []
|
|
|
|
# Check for high concentration
|
|
top_holder_pct = sum(h.get("supply_pct", h.get("pct", 0)) for h in holders[:3])
|
|
if top_holder_pct > 50:
|
|
red_flags.append(f"Top 3 holders control {top_holder_pct:.0f}% of supply - extreme concentration risk")
|
|
elif top_holder_pct < 20:
|
|
green_flags.append(f"Well distributed: top 3 holders only control {top_holder_pct:.0f}%")
|
|
|
|
# Check labels
|
|
high_risk_count = 0
|
|
for addr, label_data in labels.items():
|
|
entity = label_data.get("entity", {})
|
|
risk = label_data.get("risk_score", 0)
|
|
|
|
if entity.get("type") in ("scammer", "phishing", "exploiter"):
|
|
red_flags.append(f"Known malicious entity: {entity.get('name', addr[:12])}")
|
|
high_risk_count += 1
|
|
|
|
if risk >= 70:
|
|
red_flags.append(f"High risk wallet ({risk}/100): {addr[:12]}... {entity.get('name', '')}")
|
|
high_risk_count += 1
|
|
dev_wallets.append(addr)
|
|
|
|
if entity.get("type") == "exchange":
|
|
green_flags.append(f"Exchange wallet detected: {entity.get('name', addr[:12])}")
|
|
|
|
# Similar scam patterns
|
|
if similar_scams:
|
|
top_match = similar_scams[0]
|
|
if top_match.get("similarity", 0) > 0.7:
|
|
red_flags.append(f"Matches known pattern: {top_match.get('description', '')[:80]}")
|
|
else:
|
|
green_flags.append("No matches in our scam pattern database")
|
|
|
|
# Risk assessment
|
|
if risk_score >= 80 or high_risk_count >= 3:
|
|
verdict = "SCAM"
|
|
elif risk_score >= 50 or high_risk_count >= 1:
|
|
verdict = "HIGH_RISK"
|
|
elif risk_score >= 25 or top_holder_pct > 40:
|
|
verdict = "SUSPICIOUS"
|
|
else:
|
|
verdict = "SAFE"
|
|
|
|
confidence = min(90, 40 + len(red_flags) * 10 + len(green_flags) * 5)
|
|
|
|
return {
|
|
"verdict": verdict,
|
|
"risk_score": risk_score,
|
|
"summary": f"Analysis based on {node_count} connected wallets across {link_count} transaction links. "
|
|
f"Top holders control {top_holder_pct:.0f}% of supply. "
|
|
f"{len(red_flags)} red flags, {len(green_flags)} green flags detected.",
|
|
"red_flags": red_flags,
|
|
"green_flags": green_flags,
|
|
"dev_wallets": dev_wallets[:5],
|
|
"bundle_wallets": bundle_wallets[:5],
|
|
"recommendation": (
|
|
"DO NOT INVEST - clear scam indicators"
|
|
if verdict == "SCAM"
|
|
else "Extreme caution - multiple high-risk signals"
|
|
if verdict == "HIGH_RISK"
|
|
else "Proceed with caution - some suspicious patterns"
|
|
if verdict == "SUSPICIOUS"
|
|
else "No major concerns detected from our analysis"
|
|
),
|
|
"confidence": confidence,
|
|
"source": "rmi_data_engine", # Indicates this came from our own data
|
|
}
|