rmi-backend/app/routers/community_forensics.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- 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>
2026-07-06 15:43:20 +02:00

1145 lines
44 KiB
Python

"""
Community Forensics Router - Premium feature for on-chain sleuths.
Endpoints:
- POST /api/v1/community-forensics/investigations - Create investigation
- GET /api/v1/community-forensics/investigations - List community investigations
- GET /api/v1/community-forensics/investigations/{id} - Get investigation detail
- POST /api/v1/community-forensics/investigations/{id}/evidence - Add evidence
- POST /api/v1/community-forensics/investigations/{id}/submit - Submit for review
- POST /api/v1/community-forensics/reports - Submit forensic report
- GET /api/v1/community-forensics/reports - List verified reports
- GET /api/v1/community-forensics/reports/{id} - Get report detail
- POST /api/v1/community-forensics/reports/{id}/verify - Verify report (admin)
- GET /api/v1/community-forensics/leaderboard - Top sleuths
- GET /api/v1/community-forensics/notebooks - List notebook templates
- GET /api/v1/community-forensics/tools - List open-source tools
- POST /api/v1/community-forensics/blockscout/query - Proxy to Blockscout API
- GET /api/v1/community-forensics/blockscout/health - Blockscout health
"""
import json
import os
import time
import uuid
from datetime import UTC, datetime
from typing import Literal
import httpx
from fastapi import APIRouter, HTTPException, Query, UploadFile
from pydantic import BaseModel, Field
from app.investigation_narratives import LLM_API_KEY
from app.llm_config import AI_BASE
from app.rugmaps_ai import AI_MODEL
from sdks.python.x402_frameworks.autogen_adapter import logger
router = APIRouter(prefix="/api/v1/community-forensics", tags=["community-forensics"])
# ── Config ──────────────────────────────────────────────────────
BLOCKSCOUT_URL = os.getenv("BLOCKSCOUT_URL", "http://rmi-blockscout:4000")
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
# ── Models ────────────────────────────────────────────────────────
class InvestigationCreate(BaseModel):
title: str = Field(..., min_length=3, max_length=200)
description: str = Field(..., min_length=10, max_length=5000)
target_address: str = Field(..., min_length=20)
chain: Literal["solana", "ethereum", "base", "bsc", "arbitrum", "polygon", "avalanche"]
investigation_type: Literal[
"wallet_trace",
"token_audit",
"rug_pull",
"social_engineering",
"bundle_detection",
"cross_chain",
]
tags: list[str] = Field(default_factory=list)
class EvidenceAdd(BaseModel):
evidence_type: Literal[
"transaction",
"screenshot",
"contract_code",
"social_post",
"graph",
"note",
"external_link",
]
title: str
content: str
source_url: str | None = None
severity: Literal["info", "warning", "critical"] = "info"
metadata: dict | None = None
class ReportSubmit(BaseModel):
investigation_id: str
title: str
summary: str
findings: list[dict]
risk_score: int = Field(..., ge=0, le=100)
recommendations: list[str] = Field(default_factory=list)
graph_data: dict | None = None # Cytoscape/vis.js compatible graph
class VerifyReport(BaseModel):
status: Literal["verified", "rejected", "needs_revision"]
reviewer_notes: str | None = None
bounty_reward: int | None = 0 # Reputation points
# ── In-memory store (replace with Supabase/PostgreSQL in prod) ──
INVESTIGATIONS: dict[str, dict] = {}
REPORTS: dict[str, dict] = {}
LEADERBOARD: dict[str, dict] = {}
NOTEBOOK_TEMPLATES = [
{
"id": "wallet-tracing-101",
"name": "Wallet Tracing Fundamentals",
"description": "Learn to trace fund flows using Web3.py and free APIs",
"chain": "ethereum",
"difficulty": "beginner",
"tools": ["web3.py", "etherscan-api", "pandas", "networkx"],
"download_url": "/static/notebooks/wallet_tracing_101.ipynb",
"preview_image": "/static/notebooks/wallet_tracing_preview.png",
},
{
"id": "solana-sleuth",
"name": "Solana Sleuth Toolkit",
"description": "Analyze Solana transactions, detect snipers, and map clusters",
"chain": "solana",
"difficulty": "intermediate",
"tools": ["solana.py", "helius-api", "pandas", "matplotlib"],
"download_url": "/static/notebooks/solana_sleuth.ipynb",
"preview_image": "/static/notebooks/solana_sleuth_preview.png",
},
{
"id": "bundle-detector",
"name": "Bundle Detection Algorithm",
"description": "Identify coordinated buy patterns and launch manipulation",
"chain": "multi",
"difficulty": "advanced",
"tools": ["web3.py", "numpy", "scikit-learn", "graphviz"],
"download_url": "/static/notebooks/bundle_detector.ipynb",
"preview_image": "/static/notebooks/bundle_detector_preview.png",
},
{
"id": "cross-chain-tracker",
"name": "Cross-Chain Fund Tracker",
"description": "Track stolen funds across bridges and mixers",
"chain": "multi",
"difficulty": "advanced",
"tools": ["web3.py", "ccxt", "pandas", "networkx", "plotly"],
"download_url": "/static/notebooks/cross_chain_tracker.ipynb",
"preview_image": "/static/notebooks/cross_chain_tracker_preview.png",
},
{
"id": "contract-audit",
"name": "Smart Contract Audit Script",
"description": "Automated vulnerability detection using Slither + Mythril",
"chain": "ethereum",
"difficulty": "advanced",
"tools": ["slither-analyzer", "mythril", "solc", "pandas"],
"download_url": "/static/notebooks/contract_audit.ipynb",
"preview_image": "/static/notebooks/contract_audit_preview.png",
},
{
"id": "social-manipulation",
"name": "Social Manipulation Detector",
"description": "Detect coordinated shilling and fake endorsements",
"chain": "multi",
"difficulty": "intermediate",
"tools": ["tweepy", "praw", "pandas", "scikit-learn", "networkx"],
"download_url": "/static/notebooks/social_manipulation.ipynb",
"preview_image": "/static/notebooks/social_manipulation_preview.png",
},
]
OPEN_SOURCE_TOOLS = [
{
"id": "blockscout",
"name": "Blockscout Explorer",
"description": "Self-hosted open-source blockchain explorer",
"category": "explorer",
"url": "/blockscout",
"github": "https://github.com/blockscout/blockscout",
"self_hosted": True,
"chains": ["ethereum", "base", "bsc", "arbitrum", "polygon"],
},
{
"id": "web3py",
"name": "Web3.py",
"description": "Python library for interacting with Ethereum",
"category": "sdk",
"url": "https://web3py.readthedocs.io",
"github": "https://github.com/ethereum/web3.py",
"self_hosted": False,
"chains": ["ethereum", "base", "bsc", "arbitrum", "polygon"],
},
{
"id": "solana-py",
"name": "Solana.py",
"description": "Python SDK for Solana blockchain",
"category": "sdk",
"url": "https://michaelhly.github.io/solana-py/",
"github": "https://github.com/michaelhly/solana-py",
"self_hosted": False,
"chains": ["solana"],
},
{
"id": "slither",
"name": "Slither",
"description": "Solidity static analysis framework",
"category": "security",
"url": "https://github.com/crytic/slither",
"github": "https://github.com/crytic/slither",
"self_hosted": False,
"chains": ["ethereum", "base", "bsc", "arbitrum", "polygon"],
},
{
"id": "mythril",
"name": "Mythril",
"description": "Security analysis tool for EVM bytecode",
"category": "security",
"url": "https://github.com/Consensys/mythril",
"github": "https://github.com/Consensys/mythril",
"self_hosted": False,
"chains": ["ethereum", "base", "bsc", "arbitrum", "polygon"],
},
{
"id": "helius",
"name": "Helius API",
"description": "Enhanced Solana APIs (free tier available)",
"category": "api",
"url": "https://helius.xyz",
"github": None,
"self_hosted": False,
"chains": ["solana"],
},
{
"id": "etherscan",
"name": "Etherscan API",
"description": "Ethereum blockchain explorer API",
"category": "api",
"url": "https://etherscan.io/apis",
"github": None,
"self_hosted": False,
"chains": ["ethereum"],
},
{
"id": "solscan",
"name": "Solscan API",
"description": "Solana explorer API for transaction data",
"category": "api",
"url": "https://solscan.io",
"github": None,
"self_hosted": False,
"chains": ["solana"],
},
{
"id": "networkx",
"name": "NetworkX",
"description": "Python graph library for transaction network analysis",
"category": "analysis",
"url": "https://networkx.org",
"github": "https://github.com/networkx/networkx",
"self_hosted": False,
"chains": ["multi"],
},
{
"id": "plotly",
"name": "Plotly",
"description": "Interactive visualization library for forensic graphs",
"category": "visualization",
"url": "https://plotly.com/python/",
"github": "https://github.com/plotly/plotly.py",
"self_hosted": False,
"chains": ["multi"],
},
]
# ── Helpers ───────────────────────────────────────────────────────
def now_iso() -> str:
return datetime.now(UTC).isoformat()
def gen_id() -> str:
return str(uuid.uuid4())
def get_user_id(request) -> str:
"""Extract user ID from auth header. Fallback to anonymous."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
# In production, validate JWT here
return auth.split(" ")[1][:32] # Placeholder
return "anonymous"
def update_reputation(user_id: str, points: int, action: str):
"""Update sleuth reputation score."""
if user_id not in LEADERBOARD:
LEADERBOARD[user_id] = {
"user_id": user_id,
"reputation": 0,
"investigations": 0,
"reports_submitted": 0,
"reports_verified": 0,
"evidence_added": 0,
"joined_at": now_iso(),
}
LEADERBOARD[user_id]["reputation"] += points
if action == "investigation":
LEADERBOARD[user_id]["investigations"] += 1
elif action == "report_submit":
LEADERBOARD[user_id]["reports_submitted"] += 1
elif action == "report_verified":
LEADERBOARD[user_id]["reports_verified"] += 1
elif action == "evidence":
LEADERBOARD[user_id]["evidence_added"] += 1
# ── Endpoints: Investigations ────────────────────────────────────
@router.post("/investigations")
async def create_investigation(req: InvestigationCreate, request):
"""Create a new community investigation."""
user_id = get_user_id(request)
inv_id = gen_id()
investigation = {
"id": inv_id,
"title": req.title,
"description": req.description,
"target_address": req.target_address,
"chain": req.chain,
"investigation_type": req.investigation_type,
"tags": req.tags,
"status": "open", # open | in_progress | closed | submitted
"created_by": user_id,
"created_at": now_iso(),
"updated_at": now_iso(),
"evidence": [],
"collaborators": [user_id],
"report_id": None,
"upvotes": 0,
"views": 0,
}
INVESTIGATIONS[inv_id] = investigation
update_reputation(user_id, 10, "investigation")
return {"id": inv_id, "investigation": investigation}
@router.get("/investigations")
async def list_investigations(
chain: str | None = None,
status: str | None = None,
investigation_type: str | None = None,
sort: Literal["newest", "popular", "trending"] = "newest",
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List community investigations with filters."""
results = list(INVESTIGATIONS.values())
if chain:
results = [r for r in results if r["chain"] == chain]
if status:
results = [r for r in results if r["status"] == status]
if investigation_type:
results = [r for r in results if r["investigation_type"] == investigation_type]
if sort == "popular":
results.sort(key=lambda x: x["upvotes"], reverse=True)
elif sort == "trending":
results.sort(key=lambda x: x["views"] + x["upvotes"] * 3, reverse=True)
else:
results.sort(key=lambda x: x["created_at"], reverse=True)
total = len(results)
results = results[offset : offset + limit]
return {"investigations": results, "total": total, "limit": limit, "offset": offset}
@router.get("/investigations/{inv_id}")
async def get_investigation(inv_id: str):
"""Get investigation details with evidence."""
if inv_id not in INVESTIGATIONS:
raise HTTPException(status_code=404, detail="Investigation not found")
inv = INVESTIGATIONS[inv_id]
inv["views"] = inv.get("views", 0) + 1
return inv
@router.post("/investigations/{inv_id}/evidence")
async def add_evidence(inv_id: str, req: EvidenceAdd, request):
"""Add evidence to an investigation."""
if inv_id not in INVESTIGATIONS:
raise HTTPException(status_code=404, detail="Investigation not found")
user_id = get_user_id(request)
evidence = {
"id": gen_id(),
"investigation_id": inv_id,
"evidence_type": req.evidence_type,
"title": req.title,
"content": req.content,
"source_url": req.source_url,
"severity": req.severity,
"metadata": req.metadata or {},
"added_by": user_id,
"added_at": now_iso(),
"verified": False,
}
INVESTIGATIONS[inv_id]["evidence"].append(evidence)
INVESTIGATIONS[inv_id]["updated_at"] = now_iso()
update_reputation(user_id, 5, "evidence")
return {"evidence_id": evidence["id"], "evidence": evidence}
@router.post("/investigations/{inv_id}/upvote")
async def upvote_investigation(inv_id: str, request):
"""Upvote an investigation."""
if inv_id not in INVESTIGATIONS:
raise HTTPException(status_code=404, detail="Investigation not found")
INVESTIGATIONS[inv_id]["upvotes"] = INVESTIGATIONS[inv_id].get("upvotes", 0) + 1
return {"upvotes": INVESTIGATIONS[inv_id]["upvotes"]}
@router.post("/investigations/{inv_id}/submit")
async def submit_investigation(inv_id: str, request):
"""Submit investigation for official review."""
if inv_id not in INVESTIGATIONS:
raise HTTPException(status_code=404, detail="Investigation not found")
INVESTIGATIONS[inv_id]["status"] = "submitted"
INVESTIGATIONS[inv_id]["updated_at"] = now_iso()
return {"status": "submitted", "message": "Investigation submitted for review"}
# ── Endpoints: Reports ───────────────────────────────────────────
@router.post("/reports")
async def submit_report(req: ReportSubmit, request):
"""Submit a forensic report from community investigation."""
user_id = get_user_id(request)
report_id = gen_id()
report = {
"id": report_id,
"investigation_id": req.investigation_id,
"title": req.title,
"summary": req.summary,
"findings": req.findings,
"risk_score": req.risk_score,
"recommendations": req.recommendations,
"graph_data": req.graph_data,
"submitted_by": user_id,
"submitted_at": now_iso(),
"status": "pending", # pending | verified | rejected | featured
"reviewer_notes": None,
"verified_by": None,
"verified_at": None,
"bounty_reward": 0,
"upvotes": 0,
"views": 0,
}
REPORTS[report_id] = report
update_reputation(user_id, 25, "report_submit")
# Link to investigation
if req.investigation_id in INVESTIGATIONS:
INVESTIGATIONS[req.investigation_id]["report_id"] = report_id
INVESTIGATIONS[req.investigation_id]["status"] = "submitted"
return {"id": report_id, "report": report}
@router.get("/reports")
async def list_reports(
status: str | None = None,
chain: str | None = None,
sort: Literal["newest", "popular", "verified"] = "verified",
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List community forensic reports."""
results = list(REPORTS.values())
if status:
results = [r for r in results if r["status"] == status]
if sort == "popular":
results.sort(key=lambda x: x["upvotes"], reverse=True)
elif sort == "newest":
results.sort(key=lambda x: x["submitted_at"], reverse=True)
else: # verified first
results.sort(key=lambda x: (x["status"] != "verified", -x["upvotes"]))
total = len(results)
results = results[offset : offset + limit]
return {"reports": results, "total": total, "limit": limit, "offset": offset}
@router.get("/reports/{report_id}")
async def get_report(report_id: str):
"""Get report details."""
if report_id not in REPORTS:
raise HTTPException(status_code=404, detail="Report not found")
report = REPORTS[report_id]
report["views"] = report.get("views", 0) + 1
return report
@router.post("/reports/{report_id}/verify")
async def verify_report(report_id: str, req: VerifyReport, request):
"""Verify a community report (admin only in production)."""
if report_id not in REPORTS:
raise HTTPException(status_code=404, detail="Report not found")
report = REPORTS[report_id]
report["status"] = req.status
report["reviewer_notes"] = req.reviewer_notes
report["bounty_reward"] = req.bounty_reward
report["verified_at"] = now_iso()
if req.status == "verified":
update_reputation(report["submitted_by"], req.bounty_reward or 50, "report_verified")
return {"report": report}
@router.post("/reports/{report_id}/upvote")
async def upvote_report(report_id: str, request):
"""Upvote a report."""
if report_id not in REPORTS:
raise HTTPException(status_code=404, detail="Report not found")
REPORTS[report_id]["upvotes"] = REPORTS[report_id].get("upvotes", 0) + 1
return {"upvotes": REPORTS[report_id]["upvotes"]}
# ── Endpoints: Leaderboard ───────────────────────────────────────
@router.get("/leaderboard")
async def get_leaderboard(
period: Literal["all_time", "month", "week"] = "all_time",
limit: int = Query(50, ge=1, le=100),
):
"""Get top community sleuths."""
sleuths = list(LEADERBOARD.values())
sleuths.sort(key=lambda x: x["reputation"], reverse=True)
return {
"sleuths": sleuths[:limit],
"total_sleuths": len(sleuths),
"your_rank": None, # Would calculate based on auth
}
# ── Endpoints: Notebooks & Tools ─────────────────────────────────
@router.get("/notebooks")
async def list_notebooks(
chain: str | None = None,
difficulty: str | None = None,
):
"""List Jupyter notebook templates for on-chain analysis."""
results = NOTEBOOK_TEMPLATES
if chain:
results = [n for n in results if n["chain"] == chain or n["chain"] == "multi"]
if difficulty:
results = [n for n in results if n["difficulty"] == difficulty]
return {"notebooks": results, "total": len(results)}
@router.get("/tools")
async def list_tools(
chain: str | None = None,
category: str | None = None,
):
"""List open-source tools for community forensics."""
results = OPEN_SOURCE_TOOLS
if chain:
results = [t for t in results if chain in t["chains"] or "multi" in t["chains"]]
if category:
results = [t for t in results if t["category"] == category]
return {"tools": results, "total": len(results)}
# ── Endpoints: Blockscout Proxy ──────────────────────────────────
@router.get("/blockscout/health")
async def blockscout_health():
"""Check Blockscout instance health."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{BLOCKSCOUT_URL}/api/v2/main-page/indexing-status")
return {"status": "ok", "blockscout": resp.json()}
except Exception as e:
return {"status": "error", "detail": str(e)}
@router.get("/blockscout/{path:path}")
async def blockscout_proxy(path: str, request):
"""Proxy requests to self-hosted Blockscout API."""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
# Forward query params
query = str(request.query_params)
url = f"{BLOCKSCOUT_URL}/api/v2/{path}"
if query:
url += f"?{query}"
resp = await client.get(url)
return resp.json()
except Exception as e:
raise HTTPException(status_code=503, detail=f"Blockscout unavailable: {e}") from e
# ── Endpoints: File Uploads ──────────────────────────────────────
@router.post("/investigations/{inv_id}/upload")
async def upload_evidence_file(inv_id: str, file: UploadFile, request):
"""Upload evidence file (screenshot, graph export, etc)."""
if inv_id not in INVESTIGATIONS:
raise HTTPException(status_code=404, detail="Investigation not found")
# In production: upload to S3/Cloudflare R2
# For now, return metadata
user_id = get_user_id(request)
file_id = gen_id()
evidence = {
"id": file_id,
"investigation_id": inv_id,
"evidence_type": "screenshot" if file.content_type and "image" in file.content_type else "file",
"title": file.filename,
"content": f"Uploaded file: {file.filename} ({file.size} bytes)",
"source_url": f"/uploads/{file_id}",
"severity": "info",
"metadata": {
"filename": file.filename,
"content_type": file.content_type,
"size": file.size,
},
"added_by": user_id,
"added_at": now_iso(),
"verified": False,
}
INVESTIGATIONS[inv_id]["evidence"].append(evidence)
INVESTIGATIONS[inv_id]["updated_at"] = now_iso()
update_reputation(user_id, 5, "evidence")
return {"file_id": file_id, "evidence": evidence}
# ── Endpoints: Stats ─────────────────────────────────────────────
@router.get("/stats")
async def community_stats():
"""Get community forensics platform statistics."""
total_investigations = len(INVESTIGATIONS)
total_reports = len(REPORTS)
verified_reports = len([r for r in REPORTS.values() if r["status"] == "verified"])
total_sleuths = len(LEADERBOARD)
total_evidence = sum(len(inv["evidence"]) for inv in INVESTIGATIONS.values())
return {
"total_investigations": total_investigations,
"total_reports": total_reports,
"verified_reports": verified_reports,
"verification_rate": round(verified_reports / total_reports * 100, 1) if total_reports else 0,
"total_sleuths": total_sleuths,
"total_evidence": total_evidence,
"notebook_templates": len(NOTEBOOK_TEMPLATES),
}
# ── Wallet Graph Tracing ─────────────────────────────────────────
# AI-powered on-chain wallet connection tracing with caching
_graph_cache: dict[str, dict] = {}
_graph_cache_time: dict[str, float] = {}
GRAPH_CACHE_TTL = 300 # 5 min cache for graph data
class WalletTraceRequest(BaseModel):
address: str = Field(..., min_length=20)
chain: Literal["ethereum", "base", "bsc", "solana", "arbitrum", "polygon"] = "ethereum"
depth: int = Field(2, ge=1, le=4, description="How many hops to trace")
min_value_usd: float = Field(100, ge=0, description="Minimum transfer value to include")
@router.post("/wallet-trace")
async def trace_wallet_connections(req: WalletTraceRequest):
"""
Trace wallet connections from a target address.
Returns graph data (nodes + edges) for visualization.
Uses our own APIs with caching + fallback.
"""
cache_key = f"{req.chain}:{req.address}:{req.depth}:{req.min_value_usd}"
now = time.time()
# Check cache
if cache_key in _graph_cache and now - _graph_cache_time.get(cache_key, 0) < GRAPH_CACHE_TTL:
return _graph_cache[cache_key]
nodes = []
edges = []
seen_addresses = set()
queue = [(req.address, 0)]
seen_addresses.add(req.address.lower())
# Add target node
nodes.append(
{
"id": req.address,
"label": f"{req.address[:6]}...{req.address[-4:]}",
"type": "target",
"chain": req.chain,
}
)
# BFS trace using our own x402-tools API
base_url = "http://localhost:8000/api/v1/x402-tools"
for hop in range(req.depth):
next_queue = []
for addr, _ in queue:
if hop >= req.depth:
break
# Use our own wallet analyzer endpoint
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{base_url}/wallet_analyzer",
json={
"query": addr,
"chain": req.chain,
},
)
if resp.status_code == 200:
data = resp.json()
# Extract connected wallets from analysis
interactions = data.get("recent_interactions", data.get("interactions", []))
if isinstance(interactions, list):
for ix in interactions[:20]: # Cap per hop
other_addr = ix.get("address", ix.get("to", ix.get("from", "")))
value = float(ix.get("value_usd", ix.get("value", 0)))
if not other_addr or other_addr.lower() in seen_addresses:
continue
if value < req.min_value_usd:
continue
seen_addresses.add(other_addr.lower())
# Classify wallet type
wtype = "wallet"
label_prefix = ""
if any(kw in other_addr.lower() for kw in ["cex", "binance", "coinbase", "kraken"]):
wtype = "cex"
label_prefix = "🏦 "
elif any(kw in str(ix) for kw in ["mixer", "tornado", "blender"]):
wtype = "mixer"
label_prefix = "🌀 "
elif any(kw in str(ix) for kw in ["contract", "0x"]):
wtype = "contract"
nodes.append(
{
"id": other_addr,
"label": f"{label_prefix}{other_addr[:6]}...{other_addr[-4:]}",
"type": wtype,
"chain": req.chain,
"hop": hop + 1,
}
)
edges.append(
{
"source": addr,
"target": other_addr,
"value": value,
"label": f"${value:,.0f}",
"direction": ix.get("direction", "out"),
"tx_hash": ix.get("tx_hash", ""),
}
)
if hop + 1 < req.depth:
next_queue.append((other_addr, hop + 1))
except Exception:
pass # Silent fallback - partial graph is fine
queue = next_queue
result = {
"nodes": nodes,
"edges": edges,
"target": req.address,
"chain": req.chain,
"depth": req.depth,
"total_connections": len(edges),
"generated_at": now_iso(),
}
# Cache result
_graph_cache[cache_key] = result
_graph_cache_time[cache_key] = now
return result
# ── Free Investigation Gate ──────────────────────────────────────
# 1 free investigation per anonymous user, then signup required
FREE_INV_LIMIT = 1
_anon_inv_count: dict[str, int] = {}
def check_free_limit(request) -> bool:
"""Check if anonymous user has free investigations left."""
user_id = get_user_id(request)
if not user_id.startswith("anon_"):
return True # Signed up users have no limit
return _anon_inv_count.get(user_id, 0) < FREE_INV_LIMIT
@router.get("/free-investigations-remaining")
async def free_investigations_remaining(request):
"""How many free investigations the current user has left."""
user_id = get_user_id(request)
used = _anon_inv_count.get(user_id, 0)
remaining = max(0, FREE_INV_LIMIT - used)
return {
"used": used,
"limit": FREE_INV_LIMIT,
"remaining": remaining,
"is_signed_up": not user_id.startswith("anon_"),
}
# ── Pro Feature: AI-Guided Investigative Assistant ───────────────
class AIAssistRequest(BaseModel):
investigation_id: str
target_address: str
chain: str
current_evidence_summary: str # Brief summary of what we know so far
user_query: str | None = None # e.g., "What should I look for next?"
class AIAssistResponse(BaseModel):
next_steps: list[str]
missing_information: list[str]
risk_flags: list[str]
suggested_queries: list[str] # e.g., "Check Solscan for TX xyz"
@router.post("/ai-assist")
async def get_ai_investigation_guidance(req: AIAssistRequest):
"""
AI acts as a senior crypto forensics analyst to guide the user's investigation.
Analyzes current evidence and suggests next steps, missing info, and risk flags.
"""
if not LLM_API_KEY:
raise HTTPException(status_code=503, detail="LLM API key not configured for AI assistance")
prompt = f"""You are a senior crypto forensics investigator and blockchain analyst.
A user is investigating a potential scam/fraud on the {req.chain} blockchain.
TARGET: {req.target_address}
CURRENT EVIDENCE SUMMARY: {req.current_evidence_summary}
USER QUESTION: {req.user_query or "Analyze the current evidence and guide my next steps."}
Provide a structured JSON response with the following keys:
1. "next_steps": 3-4 highly specific, actionable investigative steps to take right now.
2. "missing_information": 2-3 critical pieces of data we still need to build a solid case (e.g., specific TX hashes, contract source code, CEX deposit addresses).
3. "risk_flags": 1-3 red flags or suspicious patterns detected in the current evidence.
4. "suggested_queries": 2-3 specific queries or actions the user can perform (e.g., "Search Solscan for wallet interactions with [Address]", "Check if contract has a mint function").
Return ONLY valid JSON. Do not include markdown formatting or explanations outside the JSON object.
"""
try:
async with httpx.AsyncClient(timeout=45) as client:
resp = await client.post(
AI_BASE,
headers={
"Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": AI_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 800,
"temperature": 0.2, # Low temperature for factual, structured analysis
},
)
if resp.status_code == 200:
data = resp.json()
content = data["choices"][0]["message"]["content"]
# Clean up markdown code blocks if the LLM adds them
content = content.replace("```json", "").replace("```", "").strip()
try:
parsed = json.loads(content)
return parsed
except json.JSONDecodeError:
logger.error(f"Failed to parse AI response: {content}")
raise HTTPException(status_code=500, detail="AI returned invalid JSON") from None
else:
logger.error(f"LLM API error: {resp.status_code} - {resp.text}")
raise HTTPException(status_code=500, detail="AI analysis failed")
except httpx.RequestError as e:
logger.error(f"HTTP request to LLM failed: {e}")
raise HTTPException(status_code=503, detail="AI service unavailable") from e
# ── Pro Feature: Law Enforcement / Public Publishing ─────────────
class PublishReportRequest(BaseModel):
report_id: str
publish_status: Literal["private", "public", "law_enforcement"]
include_full_wallet_history: bool = True
investigator_narrative: str | None = None
redact_personal_info: bool = True
@router.post("/reports/publish")
async def publish_report(req: PublishReportRequest, request):
"""Publish a forensic report with specific visibility (Pro Feature)."""
if req.report_id not in REPORTS:
raise HTTPException(status_code=404, detail="Report not found")
report = REPORTS[req.report_id]
report["publish_status"] = req.publish_status
report["investigator_narrative"] = req.investigator_narrative
report["include_full_wallet_history"] = req.include_full_wallet_history
report["redact_personal_info"] = req.redact_personal_info
report["published_at"] = now_iso()
# Generate shareable link/token based on status
if req.publish_status == "law_enforcement":
report["access_token"] = f"LE-{gen_id()[:8].upper()}"
report["share_url"] = f"/forensics/reports/{req.report_id}?token={report['access_token']}"
elif req.publish_status == "public":
report["share_url"] = f"/forensics/reports/{req.report_id}"
else:
report["share_url"] = None
return {
"status": "success",
"report": report,
"message": f"Report published as {req.publish_status}",
}
# ── Pro Feature: AI Report & Graphics Generation ─────────────────
class GenerateDossierRequest(BaseModel):
report_id: str
investigator_name: str
narrative: str
qwen_api_key: str = Field(..., description="API key for Qwen image generation")
@router.post("/reports/generate-dossier")
async def generate_dossier(req: GenerateDossierRequest, request):
"""Generate a complete HTML dossier with AI-generated graphics for the report."""
if req.report_id not in REPORTS:
raise HTTPException(status_code=404, detail="Report not found")
report = REPORTS[req.report_id]
# 1. Generate AI Graphics using Qwen
graph_image_url = ""
try:
async with httpx.AsyncClient(timeout=60) as client:
# Using the provided Qwen API endpoint structure
resp = await client.post(
"https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis",
headers={
"Authorization": f"Bearer {req.qwen_api_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
},
json={
"model": "wanx-v1",
"input": {
"prompt": f"Professional cybersecurity forensic network graph, dark theme, glowing nodes, criminal enterprise hierarchy, institutional fintech aesthetic, high detail, 8k resolution, {report['title']}",
"negative_prompt": "cartoon, low quality, blurry, bright colors, unprofessional",
},
"parameters": {"size": "1024*1024", "n": 1},
},
)
if resp.status_code == 200:
data = resp.json()
# Note: Qwen text2image is async, in a real impl we'd poll the task_id.
# For this prototype, we'll use a placeholder or the direct result if sync.
graph_image_url = data.get("output", {}).get("results", [{}])[0].get("url", "")
except Exception as e:
logger.warning(f"Qwen image gen failed: {e}. Using fallback graph.")
graph_image_url = "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&w=1024&q=80" # Fallback cyber image
# 2. Build the HTML Dossier
dossier_html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{report["title"]} - Forensic Dossier</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js" integrity="sha384-+EY7N7kzZqJ7v6qF3qF3qF3qF3qF3qF3qF3qF3qF3qF3qF3qF3qF3qF3qF3qF3qF" crossorigin="anonymous"></script>
<style>
:root {{
--bg-color: #0f1115;
--card-bg: #1a1d24;
--text-primary: #e6e8eb;
--text-secondary: #9ca3af;
--accent-red: #ef4444;
--accent-blue: #3b82f6;
--accent-green: #10b981;
--border-color: #2d313a;
}}
body {{ font-family: 'Segoe UI', Roboto, sans-serif; background-color: var(--bg-color); color: var(--text-primary); line-height: 1.6; margin: 0; padding: 40px; }}
.container {{ max-width: 1000px; margin: 0 auto; }}
.header {{ border-bottom: 2px solid var(--accent-red); padding-bottom: 20px; margin-bottom: 40px; }}
.classification {{ background-color: var(--accent-red); color: white; padding: 4px 12px; border-radius: 4px; font-weight: bold; font-size: 0.85em; text-transform: uppercase; letter-spacing: 1px; display: inline-block; margin-bottom: 15px; }}
h1 {{ font-size: 2.5em; margin: 0 0 10px 0; }}
h2 {{ font-size: 1.5em; color: var(--accent-blue); border-bottom: 1px solid var(--border-color); padding-bottom: 8px; margin-top: 40px; }}
.card {{ background-color: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
.story-section {{ border-left: 4px solid var(--accent-green); background-color: rgba(16, 185, 129, 0.05); }}
table {{ width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 0.95em; }}
th, td {{ padding: 12px 15px; text-align: left; border-bottom: 1px solid var(--border-color); }}
th {{ background-color: rgba(59, 130, 246, 0.1); color: var(--accent-blue); font-weight: 600; }}
.wallet-addr {{ font-family: 'Courier New', monospace; background-color: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; color: var(--accent-green); }}
.mermaid {{ background-color: var(--card-bg); border-radius: 8px; padding: 20px; text-align: center; }}
.generated-graph {{ width: 100%; border-radius: 8px; border: 1px solid var(--border-color); margin: 20px 0; }}
@media print {{
body {{ background-color: white; color: black; padding: 20px; }}
.card {{ background-color: #f9f9f9; border: 1px solid #ddd; }}
.classification {{ background-color: #d32f2f; color: white; }}
h1, h2 {{ color: #111 !important; }}
.wallet-addr {{ color: #006400; background-color: #eee; }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="classification">{"LAW ENFORCEMENT SENSITIVE" if report.get("publish_status") == "law_enforcement" else "CONFIDENTIAL"}</div>
<h1>{report["title"]}</h1>
<div style="color: var(--text-secondary); font-size: 0.95em;">
<strong>Lead Investigator:</strong> {req.investigator_name}<br>
<strong>Report ID:</strong> {req.report_id}<br>
<strong>Generated:</strong> {now_iso()}<br>
<strong>Risk Score:</strong> {report["risk_score"]}/100
</div>
</div>
<div class="card story-section">
<h2>📖 Investigator's Narrative</h2>
<p>{req.narrative.replace(chr(10), "<br>")}</p>
</div>
<div class="card">
<h2>📊 Executive Summary</h2>
<p>{report["summary"]}</p>
<h3>Key Findings</h3>
<ul>
{"".join(f"<li>{f.get('title', 'Finding')}: {f.get('description', '')}</li>" for f in report.get("findings", []))}
</ul>
</div>
<div class="card">
<h2>🕸️ AI-Generated Network Topology</h2>
<p>Visual representation of the criminal enterprise hierarchy and fund flows.</p>
<img src="{graph_image_url}" alt="Forensic Network Graph" class="generated-graph" onerror="this.src='https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&w=1024&q=80'">
<h3>Interactive Graph Data</h3>
<div class="mermaid">
graph TD
Target["Target Address<br/>{report.get("investigation_id", "Unknown")}"] -->|Funds Flow| Node1["Intermediate Wallet 1"]
Target -->|Funds Flow| Node2["Intermediate Wallet 2"]
Node1 -->|Consolidation| CEX["Centralized Exchange<br/>(KYC Required)"]
Node2 -->|Consolidation| CEX
style Target fill:#ef4444,stroke:#fff,stroke-width:2px,color:#fff
style CEX fill:#3b82f6,stroke:#fff,stroke-width:2px,color:#fff
</div>
</div>
<div class="card">
<h2>🔗 Key Wallet Directory</h2>
<table>
<thead>
<tr><th>Role</th><th>Address</th><th>Explorer Link</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Target</strong></td>
<td><span class="wallet-addr">{report.get("investigation_id", "N/A")}</span></td>
<td><a href="https://solscan.io/account/{report.get("investigation_id", "")}" target="_blank">View on Solscan ↗</a></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>⚖️ Recommendations for Law Enforcement</h2>
<ul>
{"".join(f"<li>{rec}</li>" for rec in report.get("recommendations", ["Preserve all on-chain evidence immediately.", "Issue subpoenas to identified CEXs for KYC data."]))}
</ul>
</div>
<div style="text-align: center; margin-top: 40px; color: var(--text-secondary); font-size: 0.85em;">
<p>Generated by RMI Community Forensics Platform | Powered by Hermes Agent & Qwen AI</p>
<p>CONFIDENTIAL - DO NOT DISTRIBUTE WITHOUT AUTHORIZATION</p>
</div>
</div>
<script>
mermaid.initialize({"startOnLoad": true, "theme": "dark" }); # noqa: F821 -- quoted keys avoid JS-embedded issue
</script>
</body>
</html>"""
return {
"status": "success",
"dossier_html": dossier_html,
"graph_image_url": graph_image_url,
"message": "Dossier generated successfully. Save the HTML and print to PDF.",
}