diff --git a/app/_archive/legacy_2026_07/README.md b/app/_archive/legacy_2026_07/README.md new file mode 100644 index 0000000..30c4b7d --- /dev/null +++ b/app/_archive/legacy_2026_07/README.md @@ -0,0 +1,105 @@ +# app/_archive/legacy_2026_07 + +**Archived 2026-07-06** as part of AUDIT-2026-Q3.md Phase 2.3 + P2.4. + +This directory contains **136 `.py` files** moved out of `app/` and `app/routers/` +because they had **zero reach** into the running application at the time +of the audit. Reach was verified across 5 surfaces via a forward-import +closure from `app/mount.py`, `app/main.py`, `app/factory.py`, and +`app/core/lifespan.py`: + +1. NOT in `app/mount.py` mount expressions +2. NOT statically imported by any `.py` in `app/`, `tests/`, `scripts/` +3. NOT dynamically loaded by `app/core/lifespan.py` +4. NOT referenced as a string anywhere in `*.yaml`/`*.yml`/`*.toml`/`*.json` +5. NOT in the lifecycle always-loaded set + +## Composition (137 files) + +| Bucket | Count | Notes | +|--------|------:|-------| +| `app/*.py` (flat) | 63 | Domain-specific modules never imported | +| `app/routers/*.py` | 73 | Routers never mounted and not imported (1 restored, see below) | +| **Total** | **137** | — | + +Total LOC moved: **44,932 lines** (reduces active tree by ~25%). + +## Reach-graph re-verification (over 4 surfaces) + +``` +imports = 348 app.* modules statically imported +ref_files = 51 files with string references +closure = forward BFS from mount.py + main.py + factory.py + lifespan.py +reached = 194 files reachable from roots +archive set = audit_dead (186) - reached - forced_live (4 stale items) - test_live (1) = 136 +``` + +## Restored post-archive (test dependency surfaced by smoke test) + +- `app/routers/x402_bridge_health.py` — restored 2026-07-07. + The reach graph showed it as DEAD, but `tests/unit/test_bridge_health.py` + has 7 direct imports of it. The audit's reach graph considered `tests/` + only as a transitive reach, not as a source of LIVE. The smoke test + caught the regression and we restored. Reach graph will be patched to + include `tests/*.py` as roots in the next audit cycle. + +## Forced-LIVE (NOT archived per user directive) + +The reach graph would have archived these, but the user listed them as LIVE +and asked us to DEFER with a note: + +- `app/ai_pipeline_v3.py` — imported by `app/unified_wallet_scanner.py` and + `app/news_intelligence.py`. The reach graph shows those importers are + themselves DEAD, so `ai_pipeline_v3` is also technically DEAD. But kept + per user instruction (3 importers in the audit reach-graph window). +- `app/splade_bm25.py` — imported by `app/rag_service.py` (LIVE). +- `app/wallet_manager_v2.py` — imported by `app/routers/wallet_manager_v2.py`, + `app/routers/x402_enforcement.py`, `app/routers/x402_tools.py`, + `app/sweep_all.py`, `app/sweep_now.py`, `scripts/setup_x402_rotation.py`. + Has a top-level `NameError` (`Bip44Coins`) but the import works because + the failing line is in a function body only executed at runtime. +- `app/crypto_embeddings.py` — NOT in the audit ARCHIVE list at all (the + audit had a stale dead-list entry that was already KEEP). Heavy import + graph: `rag_service`, `bundle_cluster_rag`, `rag/embedder`, `supabase_vector`, + `rugmaps_ai`, `scam_sources`, `intel_feed_pipeline`, `rag_agentic`, + `ragas_eval`, `tests/manual/test_rag.py`, `scripts/ingest_sigmod_fast.py`. + +## Wave 3 (newly mounted, NOT archived) + +- `app/routers/unified_scanner_router.py` — mounted 2026-07-07 at + `/api/v2/scanner/{token,wallet}/scan` (2 routes). Reach graph would have + archived it; the new mount entry in `app/mount.py` makes it LIVE. +- `app/routers/unified_wallet_scanner.py` — DEFERRED (no `router` APIRouter + attribute; library consumed by `unified_scanner_router`). +- `app/routers/admin_extensions.py` — DORMANT per audit (darkroom-specific, + 25 routes at `/api/v1/admin/*`); would shadow `/api/v1/admin/alerts_webhook`. + +## How to restore + +These files are kept in git history under the path `app/_archive/legacy_2026_07/`. +To restore any of them: + +```bash +cd /srv/work/repos/rmi-backend +git mv app/_archive/legacy_2026_07/.py app/routers/ # or app/ +# Then add the import to app/mount.py ROUTER_MODULES +git commit -m "restore .py from legacy archive" +git push origin main +``` + +`git log --all --oneline -- app/_archive/` shows every move. We did NOT +rewrite history (would break external mirrors at github.com, gitlab.com, +huggingface.co). + +## Why archive, not delete + +Git retains the full history. `git log --all --oneline -- app/_archive/` +shows every move. The archive path itself is excluded from: + +- **pytest discovery** — `pyproject.toml tool.pytest.ini_options testpaths = ["tests"]` does not traverse `app/`. +- **ruff** — `[tool.ruff] extend-exclude = [..., "app/_archive/"]` (added 2026-07-07). +- **mypy** — `[tool.mypy] exclude = [..., "app/_archive/"]` (added 2026-07-07). +- **setuptools** — `[tool.setuptools.packages.find] exclude = ["app._archive*"]` (added 2026-07-07). + +The audit doc at `/home/dev/pry/rmi-final-deadcode-2026-07-06.md` lists +every file with the original path and recommendation. diff --git a/app/_archive/legacy_2026_07/ab_testing.py b/app/_archive/legacy_2026_07/ab_testing.py new file mode 100644 index 0000000..7d9428f --- /dev/null +++ b/app/_archive/legacy_2026_07/ab_testing.py @@ -0,0 +1,154 @@ +"""A/B Testing Framework - split traffic, measure accuracy, auto-promote winners.""" + +import hashlib +from datetime import UTC, datetime + +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/ab-testing", tags=["ab-testing"]) + +_experiments: dict[str, dict] = {} +_results: dict[str, list] = {} + + +@router.post("/experiment") +async def create_experiment( + name: str, + variants: str = Query(..., description="Comma-separated variant names, e.g. 'prompt_v1,prompt_v2'"), + traffic_split: str = Query("50,50", description="Traffic split percentages"), + metric: str = Query("accuracy", description="Metric to evaluate"), +): + """Create a new A/B test experiment.""" + variant_list = [v.strip() for v in variants.split(",")] + split_list = [int(s.strip()) for s in traffic_split.split(",")] + + if len(variant_list) != len(split_list): + return {"error": "Variants and splits must have same length"} + if sum(split_list) != 100: + return {"error": "Traffic splits must sum to 100"} + + exp_id = hashlib.md5(name.encode()).hexdigest()[:8] + _experiments[exp_id] = { + "id": exp_id, + "name": name, + "variants": variant_list, + "traffic_split": split_list, + "metric": metric, + "created_at": datetime.now(UTC).isoformat(), + "status": "active", + } + _results[exp_id] = [] + return _experiments[exp_id] + + +@router.get("/assign/{experiment_id}") +async def assign_variant(experiment_id: str, user_id: str = "anonymous"): + """Assign a user to a variant deterministically (same user always gets same variant).""" + if experiment_id not in _experiments: + return {"variant": "control", "note": "Experiment not found"} + + exp = _experiments[experiment_id] + # Deterministic assignment based on user_id hash + h = int(hashlib.md5(f"{experiment_id}:{user_id}".encode()).hexdigest(), 16) + bucket = h % 100 + + cumulative = 0 + for variant, split in zip(exp["variants"], exp["traffic_split"], strict=False): + cumulative += split + if bucket < cumulative: + return {"experiment": experiment_id, "variant": variant, "user": user_id} + + return {"experiment": experiment_id, "variant": exp["variants"][-1], "user": user_id} + + +@router.post("/record/{experiment_id}") +async def record_result(experiment_id: str, variant: str, correct: bool = False): + """Record a result for an A/B test variant.""" + if experiment_id not in _experiments: + return {"error": "Experiment not found"} + + _results[experiment_id].append( + { + "variant": variant, + "correct": correct, + "timestamp": datetime.now(UTC).isoformat(), + } + ) + + # Check if we have enough data to declare a winner (30+ samples per variant) + results = _results[experiment_id] + variant_counts = {} + variant_correct = {} + for r in results: + v = r["variant"] + variant_counts[v] = variant_counts.get(v, 0) + 1 + if r["correct"]: + variant_correct[v] = variant_correct.get(v, 0) + 1 + + # Check if all variants have enough samples + min_samples = 30 + all_ready = all(count >= min_samples for count in variant_counts.values()) + + if all_ready: + scores = {v: variant_correct.get(v, 0) / variant_counts[v] for v in variant_counts} + best = max(scores, key=scores.get) + worst = min(scores, key=scores.get) + improvement = scores[best] - scores[worst] + + result = { + "experiment": experiment_id, + "status": "complete" if improvement > 0.05 else "inconclusive", + "winner": best if improvement > 0.05 else None, + "scores": {v: round(s, 3) for v, s in scores.items()}, + "samples": variant_counts, + "confidence": round(improvement * 100, 1), + } + + if improvement > 0.05: + _experiments[experiment_id]["status"] = "complete" + _experiments[experiment_id]["winner"] = best + + return result + + return { + "experiment": experiment_id, + "status": "collecting", + "samples": variant_counts, + "min_needed": min_samples, + } + + +@router.get("/experiments") +async def list_experiments(): + return { + "experiments": list(_experiments.values()), + "active": sum(1 for e in _experiments.values() if e["status"] == "active"), + } + + +@router.get("/experiment/{experiment_id}") +async def get_experiment(experiment_id: str): + if experiment_id not in _experiments: + return {"error": "Experiment not found"} + exp = _experiments[experiment_id] + results = _results.get(experiment_id, []) + return { + **exp, + "total_trials": len(results), + "results": _summarize_results(results, exp["variants"]), + } + + +def _summarize_results(results: list, variants: list) -> dict: + counts = dict.fromkeys(variants, 0) + correct = dict.fromkeys(variants, 0) + for r in results: + v = r["variant"] + if v in counts: + counts[v] += 1 + if r["correct"]: + correct[v] += 1 + return { + v: {"trials": counts[v], "correct": correct[v], "accuracy": round(correct[v] / max(counts[v], 1), 3)} + for v in variants + } diff --git a/app/_archive/legacy_2026_07/admin_backend.py b/app/_archive/legacy_2026_07/admin_backend.py new file mode 100644 index 0000000..b55620d --- /dev/null +++ b/app/_archive/legacy_2026_07/admin_backend.py @@ -0,0 +1,1691 @@ +""" +RMI Admin Backend Router +========================== +Complete admin panel API with RBAC, security, and management tools. + +All endpoints require X-Admin-Session header (JWT session token). +Some endpoints also require specific permissions based on role. + +Sections: + 1. Authentication (login, logout, session, 2FA) + 2. Dashboard (metrics, health, analytics) + 3. User Management (users, bans, roles, activity) + 4. Security (IP blocks, rate limits, audit logs, threats) + 5. System Management (config, services, health) + 6. Content Management (posts, announcements, SEO) + 7. Financial (x402 analytics, revenue, payments) + 8. API Keys (create, rotate, revoke, scopes) + 9. Token Deployer (darkroom integration) + 10. Backups & Maintenance +""" + +import json +import logging +import os +import time +from datetime import datetime, timedelta + +from fastapi import APIRouter, Body, HTTPException, Request +from pydantic import BaseModel, Field + +from app.admin_backend import ( + PERMISSIONS, + AdminRole, + AdminUserStore, + AuditLogger, + SecurityManager, + SessionManager, + SystemHealthMonitor, + require_admin, +) + +logger = logging.getLogger("rmi_admin_router") + +router = APIRouter(prefix="/api/v1/admin/backend", tags=["admin-backend"]) + + +# ── Auth Models ─────────────────────────────────────────────── + + +class AdminLoginRequest(BaseModel): + email: str + password: str + totp_code: str | None = None + + +class CreateAdminRequest(BaseModel): + email: str + password: str + role: str = "viewer" + ip_allowlist: list[str] = Field(default_factory=list) + + +class UpdateAdminRequest(BaseModel): + role: str | None = None + is_active: bool | None = None + ip_allowlist: list[str] | None = None + two_factor_enabled: bool | None = None + + +class IPBlockRequest(BaseModel): + ip: str + reason: str = "" + duration_hours: int = 24 + + +class ConfigUpdateRequest(BaseModel): + key: str + value: str + category: str = "general" + + +class AnnouncementRequest(BaseModel): + title: str + content: str + type: str = "info" # info, warning, critical, update + target_audience: str = "all" # all, users, admins, premium + expires_at: str | None = None + + +class APIKeyCreateRequest(BaseModel): + name: str + scopes: list[str] = Field(default_factory=list) + expires_days: int = 30 + + +class WebhookConfigRequest(BaseModel): + url: str + events: list[str] = Field(default_factory=list) + secret: str = "" + active: bool = True + + +# ── Helper: Get client info ─────────────────────────────────── + + +def _get_client_info(request: Request) -> tuple: + """Get client IP and user agent.""" + ip = request.client.host if request.client else "" + # Check for forwarded IP + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + ip = forwarded.split(",")[0].strip() + ua = request.headers.get("user-agent", "") + return ip, ua + + +# ═══════════════════════════════════════════════════════════════ +# 1. AUTHENTICATION +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/auth/login") +async def admin_login(request: Request, body: AdminLoginRequest): + """Admin login with email/password + optional 2FA.""" + ip, ua = _get_client_info(request) + + # Check IP block + block_info = await SecurityManager.is_ip_blocked(ip) + if block_info: + raise HTTPException(status_code=403, detail="IP blocked") + + # Check rate limit for login + rate_check = await SecurityManager.check_rate_limit(f"login:{ip}", "login") + if not rate_check["allowed"]: + raise HTTPException(status_code=429, detail="Too many login attempts") + + # Verify credentials + admin = await AdminUserStore.verify_admin_login(body.email, body.password) + if not admin: + await SecurityManager.track_failed_login(f"login:{ip}") + raise HTTPException(status_code=401, detail="Invalid credentials") + + # Check 2FA if enabled + if admin.get("two_factor_enabled") and not body.totp_code: + raise HTTPException(status_code=401, detail="2FA code required") + # Verify TOTP (would use pyotp in production) + # For now, skip verification + + # Check IP allowlist + allowlist = admin.get("ip_allowlist", []) + if allowlist and ip not in allowlist: + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="admin.login_denied", + resource_type="ip", + resource_id=ip, + ip_address=ip, + user_agent=ua, + status="denied", + reason="IP not in allowlist", + ) + raise HTTPException(status_code=403, detail="IP not authorized") + + # Reset failed logins + await SecurityManager.reset_failed_login(f"login:{ip}") + + # Create session + session_id = await SessionManager.create_session( + admin_id=admin["id"], + admin_email=admin["email"], + role=AdminRole(admin.get("role", "viewer")), + ip_address=ip, + user_agent=ua, + ) + + # Log successful login + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="admin.login", + resource_type="session", + resource_id=session_id, + ip_address=ip, + user_agent=ua, + status="success", + ) + + return { + "success": True, + "session_id": session_id, + "admin": {k: v for k, v in admin.items() if k not in ["password_hash", "two_factor_secret"]}, + "expires_in": SessionManager.SESSION_TIMEOUT_HOURS * 3600, + } + + +@router.post("/auth/logout") +async def admin_logout(request: Request): + """Logout current session.""" + session_id = request.headers.get("X-Admin-Session", "") + if session_id: + await SessionManager.destroy_session(session_id) + return {"success": True, "message": "Logged out"} + + +@router.post("/auth/logout-all") +async def admin_logout_all(request: Request): + """Logout all sessions (force logout everywhere).""" + auth = await require_admin(request, "system.write", AdminRole.ADMIN) + admin = auth["admin"] + + count = await SessionManager.destroy_all_sessions(admin["id"]) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="admin.logout_all", + resource_type="session", + resource_id=admin["id"], + ip_address=_get_client_info(request)[0], + status="success", + reason=f"Destroyed {count} sessions", + ) + + return {"success": True, "sessions_destroyed": count} + + +@router.get("/auth/me") +async def admin_me(request: Request): + """Get current admin info.""" + auth = await require_admin(request) + admin = auth["admin"] + + # Get active sessions + sessions = await SessionManager.get_active_sessions(admin["id"]) + + return { + "admin": {k: v for k, v in admin.items() if k not in ["password_hash", "two_factor_secret"]}, + "active_sessions": len(sessions), + "sessions": [ + { + "session_id": s["session_id"], + "ip_address": s["ip_address"], + "created_at": s["created_at"], + "last_active": s["last_active"], + } + for s in sessions + ], + "permissions": list(PERMISSIONS.get(AdminRole(admin.get("role", "viewer")), [])), + } + + +@router.get("/auth/sessions") +async def admin_sessions(request: Request): + """Get all active sessions for current admin.""" + auth = await require_admin(request) + sessions = await SessionManager.get_active_sessions(auth["admin"]["id"]) + return {"sessions": sessions, "total": len(sessions)} + + +# ═══════════════════════════════════════════════════════════════ +# 2. DASHBOARD +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/dashboard") +async def admin_dashboard(request: Request): + """Get admin dashboard metrics.""" + await require_admin(request, "dashboard.read") + + # System health + health = await SystemHealthMonitor.get_system_health() + + # Get stats from Redis + stats = {} + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + stats = { + "total_users": await r.scard("rmi:users") or 0, + "total_admins": len(await r.hgetall("rmi:admins")), + "active_sessions": 0, + "blocked_ips": await r.scard("blocked_ips:all") or 0, + "total_deployments": await r.scard("token_deployments:all") or 0, + "total_scans_today": 0, + "x402_payments_today": 0, + } + + # Count active sessions + for key in await r.keys("admin_session:*"): + if not key.startswith("admin_sessions:"): + stats["active_sessions"] += 1 + + except Exception as e: + logger.error(f"Dashboard stats error: {e}") + + return { + "health": health, + "stats": stats, + "timestamp": datetime.utcnow().isoformat(), + } + + +@router.get("/dashboard/metrics") +async def dashboard_metrics(request: Request, hours: int = 24): + """Get time-series metrics for dashboard charts.""" + await require_admin(request, "analytics.read") + + metrics = { + "api_requests": [], + "scans": [], + "payments": [], + "new_users": [], + "errors": [], + } + + # In production, this would query time-series DB + # For now, return placeholder structure + + return { + "hours": hours, + "metrics": metrics, + "generated_at": datetime.utcnow().isoformat(), + } + + +# ═══════════════════════════════════════════════════════════════ +# 3. USER MANAGEMENT +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/users") +async def list_users( + request: Request, + limit: int = 100, + offset: int = 0, + search: str = "", + tier: str = "", + banned: bool | None = None, +): + """List all users with filtering.""" + await require_admin(request, "users.read") + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + users = [] + all_users = await r.hgetall("rmi:users") + + for _user_id, data in all_users.items(): + user = json.loads(data) + + # Apply filters + if search and search.lower() not in user.get("email", "").lower(): + continue + if tier and user.get("tier", "") != tier: + continue + if banned is not None and user.get("banned", False) != banned: + continue + + # Remove sensitive data + safe_user = {k: v for k, v in user.items() if "password" not in k and "secret" not in k} + users.append(safe_user) + + total = len(users) + users = users[offset : offset + limit] + + return { + "users": users, + "total": total, + "limit": limit, + "offset": offset, + } + + except Exception as e: + logger.error(f"List users error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/users/{user_id}") +async def get_user(request: Request, user_id: str): + """Get user details.""" + await require_admin(request, "users.read") + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + data = await r.hget("rmi:users", user_id) + if not data: + raise HTTPException(status_code=404, detail="User not found") + + user = json.loads(data) + safe_user = {k: v for k, v in user.items() if "password" not in k and "secret" not in k} + + return {"user": safe_user} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get user error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/users/{user_id}/ban") +async def ban_user(request: Request, user_id: str, body: dict = Body(...)): + """Ban or unban a user.""" + auth = await require_admin(request, "users.ban", AdminRole.MODERATOR) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + data = await r.hget("rmi:users", user_id) + if not data: + raise HTTPException(status_code=404, detail="User not found") + + user = json.loads(data) + before_state = {"banned": user.get("banned", False)} + + user["banned"] = body.get("banned", True) + user["banned_at"] = datetime.utcnow().isoformat() if user["banned"] else None + user["banned_by"] = admin["id"] if user["banned"] else None + user["ban_reason"] = body.get("reason", "") + + await r.hset("rmi:users", user_id, json.dumps(user)) + + # Log + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="user.ban" if user["banned"] else "user.unban", + resource_type="user", + resource_id=user_id, + ip_address=ip, + user_agent=ua, + before_state=before_state, + after_state={"banned": user["banned"]}, + ) + + return {"success": True, "banned": user["banned"]} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Ban user error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/users/{user_id}/tier") +async def update_user_tier(request: Request, user_id: str, body: dict = Body(...)): + """Update user tier/subscription.""" + auth = await require_admin(request, "users.write", AdminRole.ADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + data = await r.hget("rmi:users", user_id) + if not data: + raise HTTPException(status_code=404, detail="User not found") + + user = json.loads(data) + before_state = {"tier": user.get("tier", "FREE")} + + user["tier"] = body.get("tier", "FREE") + user["tier_updated_at"] = datetime.utcnow().isoformat() + user["tier_updated_by"] = admin["id"] + + await r.hset("rmi:users", user_id, json.dumps(user)) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="user.tier_update", + resource_type="user", + resource_id=user_id, + ip_address=ip, + user_agent=ua, + before_state=before_state, + after_state={"tier": user["tier"]}, + ) + + return {"success": True, "tier": user["tier"]} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Update tier error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════════ +# 4. SECURITY +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/security/audit-logs") +async def get_audit_logs( + request: Request, + limit: int = 100, + offset: int = 0, + action: str = "", + admin_id: str = "", + start_date: str = "", + end_date: str = "", +): + """Query audit logs.""" + await require_admin(request, "security.read", AdminRole.ADMIN) + + entries = await AuditLogger.query( + admin_id=admin_id or None, + action=action or None, + limit=limit, + offset=offset, + ) + + return { + "logs": [e.to_dict() for e in entries], + "total": len(entries), + "limit": limit, + "offset": offset, + } + + +@router.get("/security/blocked-ips") +async def get_blocked_ips(request: Request): + """List all blocked IPs.""" + await require_admin(request, "security.read", AdminRole.ADMIN) + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + ips = await r.smembers("blocked_ips:all") + blocked = [] + + for ip in ips: + data = await r.get(f"blocked_ip:{ip}") + if data: + blocked.append(json.loads(data)) + + return {"blocked_ips": blocked, "total": len(blocked)} + + except Exception as e: + logger.error(f"Get blocked IPs error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/security/block-ip") +async def block_ip(request: Request, body: IPBlockRequest): + """Block an IP address.""" + auth = await require_admin(request, "security.write", AdminRole.ADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + result = await SecurityManager.block_ip(body.ip, body.reason, body.duration_hours) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="ip.block", + resource_type="ip", + resource_id=body.ip, + ip_address=ip, + user_agent=ua, + after_state={"reason": body.reason, "duration": body.duration_hours}, + ) + + return {"success": result, "ip": body.ip, "duration_hours": body.duration_hours} + + +@router.post("/security/unblock-ip") +async def unblock_ip(request: Request, body: dict = Body(...)): + """Unblock an IP address.""" + auth = await require_admin(request, "security.write", AdminRole.ADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + target_ip = body.get("ip", "") + result = await SecurityManager.unblock_ip(target_ip) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="ip.unblock", + resource_type="ip", + resource_id=target_ip, + ip_address=ip, + user_agent=ua, + ) + + return {"success": result, "ip": target_ip} + + +@router.get("/security/threats") +async def get_threats(request: Request, hours: int = 24): + """Get security threats and alerts.""" + await require_admin(request, "security.read", AdminRole.ADMIN) + + # In production, this would query threat detection system + threats = [] + + return { + "threats": threats, + "total": len(threats), + "hours": hours, + } + + +# ═══════════════════════════════════════════════════════════════ +# 5. SYSTEM MANAGEMENT +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/system/health") +async def system_health(request: Request): + """Get detailed system health.""" + await require_admin(request, "system.read") + + health = await SystemHealthMonitor.get_system_health() + return health + + +@router.get("/system/config") +async def get_config(request: Request): + """Get system configuration (safe vars only).""" + await require_admin(request, "settings.read", AdminRole.ADMIN) + + # Return safe config values (no secrets) + safe_config = { + "environment": os.getenv("ENVIRONMENT", "production"), + "backend_port": os.getenv("BACKEND_PORT", "8000"), + "redis_host": os.getenv("REDIS_HOST", "localhost"), + "supabase_url": os.getenv("SUPABASE_URL", ""), + "x402_enabled": bool(os.getenv("X402_EVM_PAY_TO", "")), + "features": { + "rag": True, + "token_deployer": True, + "airdrop": True, + "x402": True, + "news": True, + }, + } + + return {"config": safe_config} + + +@router.post("/system/config") +async def update_config(request: Request, body: ConfigUpdateRequest): + """Update system configuration.""" + auth = await require_admin(request, "settings.write", AdminRole.SUPERADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + # In production, this would update env vars or config store + # For now, log the request + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="config.update", + resource_type="config", + resource_id=body.key, + ip_address=ip, + user_agent=ua, + after_state={"key": body.key, "value": body.value, "category": body.category}, + ) + + return {"success": True, "key": body.key, "updated": True} + + +@router.get("/system/services") +async def get_services(request: Request): + """Get status of all system services.""" + await require_admin(request, "system.read") + + services = { + "backend": {"status": "running", "pid": os.getpid()}, + "redis": await SystemHealthMonitor._check_redis(), + "supabase": await SystemHealthMonitor._check_supabase(), + } + + return {"services": services} + + +@router.post("/system/restart") +async def restart_service(request: Request, body: dict = Body(...)): + """Restart a service (simulated).""" + auth = await require_admin(request, "system.write", AdminRole.SUPERADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + service = body.get("service", "") + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="system.restart", + resource_type="service", + resource_id=service, + ip_address=ip, + user_agent=ua, + ) + + return {"success": True, "service": service, "status": "restart_queued"} + + +# ═══════════════════════════════════════════════════════════════ +# 6. CONTENT MANAGEMENT +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/content/announcements") +async def get_announcements(request: Request, active_only: bool = True): + """Get announcements.""" + await require_admin(request, "content.read") + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + announcements = [] + data = await r.get("rmi:announcements") + if data: + all_announcements = json.loads(data) + for a in all_announcements: + if not active_only or a.get("active", True): + announcements.append(a) + + return {"announcements": announcements} + + except Exception as e: + logger.error(f"Get announcements error: {e}") + return {"announcements": []} + + +@router.post("/content/announcements") +async def create_announcement(request: Request, body: AnnouncementRequest): + """Create an announcement.""" + auth = await require_admin(request, "content.write", AdminRole.MODERATOR) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + announcement = { + "id": f"ann_{int(time.time())}", + "title": body.title, + "content": body.content, + "type": body.type, + "target_audience": body.target_audience, + "created_at": datetime.utcnow().isoformat(), + "created_by": admin["id"], + "active": True, + "expires_at": body.expires_at, + } + + # Get existing announcements + data = await r.get("rmi:announcements") + announcements = json.loads(data) if data else [] + announcements.append(announcement) + + await r.set("rmi:announcements", json.dumps(announcements)) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="content.announcement.create", + resource_type="announcement", + resource_id=announcement["id"], + ip_address=ip, + user_agent=ua, + ) + + return {"success": True, "announcement": announcement} + + except Exception as e: + logger.error(f"Create announcement error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════════ +# 7. FINANCIAL / X402 ANALYTICS +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/financial/x402") +async def x402_analytics(request: Request, days: int = 30): + """Get x402 payment analytics.""" + await require_admin(request, "financial.read", AdminRole.ADMIN) + + # In production, query x402_payments table + analytics = { + "total_payments": 0, + "total_revenue_usd": 0, + "payments_by_chain": {}, + "payments_by_tool": {}, + "daily_volume": [], + } + + return {"analytics": analytics, "days": days} + + +@router.get("/financial/revenue") +async def revenue_report(request: Request, period: str = "month"): + """Get revenue report.""" + await require_admin(request, "financial.read", AdminRole.ADMIN) + + return { + "period": period, + "revenue": { + "x402": 0, + "subscriptions": 0, + "api_usage": 0, + "total": 0, + }, + } + + +# ═══════════════════════════════════════════════════════════════ +# 8. API KEY MANAGEMENT +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/api-keys") +async def list_api_keys(request: Request): + """List all API keys.""" + await require_admin(request, "api_keys.read", AdminRole.ADMIN) + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + keys = [] + all_keys = await r.hgetall("rmi:api_keys") + for _key_id, data in all_keys.items(): + key_data = json.loads(data) + # Mask the actual key + key_data["key"] = key_data.get("key", "")[:8] + "..." + key_data.get("key", "")[-4:] + keys.append(key_data) + + return {"api_keys": keys, "total": len(keys)} + + except Exception as e: + logger.error(f"List API keys error: {e}") + return {"api_keys": [], "total": 0} + + +@router.post("/api-keys") +async def create_api_key(request: Request, body: APIKeyCreateRequest): + """Create a new API key.""" + auth = await require_admin(request, "api_keys.write", AdminRole.ADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + key_id = f"key_{secrets.token_hex(8)}" # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + api_key = f"rmi_{secrets.token_urlsafe(32)}" # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + + key_data = { + "id": key_id, + "name": body.name, + "key": api_key, + "scopes": body.scopes, + "created_at": datetime.utcnow().isoformat(), + "created_by": admin["id"], + "expires_at": (datetime.utcnow() + timedelta(days=body.expires_days)).isoformat(), + "last_used": None, + "usage_count": 0, + "active": True, + } + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + await r.hset("rmi:api_keys", key_id, json.dumps(key_data)) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="api_key.create", + resource_type="api_key", + resource_id=key_id, + ip_address=ip, + user_agent=ua, + ) + + # Return the full key once (won't be shown again) + return { + "success": True, + "api_key": api_key, + "key_id": key_id, + "expires_at": key_data["expires_at"], + } + + except Exception as e: + logger.error(f"Create API key error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/api-keys/{key_id}/revoke") +async def revoke_api_key(request: Request, key_id: str): + """Revoke an API key.""" + auth = await require_admin(request, "api_keys.write", AdminRole.ADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + data = await r.hget("rmi:api_keys", key_id) + if not data: + raise HTTPException(status_code=404, detail="API key not found") + + key_data = json.loads(data) + key_data["active"] = False + key_data["revoked_at"] = datetime.utcnow().isoformat() + key_data["revoked_by"] = admin["id"] + + await r.hset("rmi:api_keys", key_id, json.dumps(key_data)) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="api_key.revoke", + resource_type="api_key", + resource_id=key_id, + ip_address=ip, + user_agent=ua, + ) + + return {"success": True, "revoked": True} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Revoke API key error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════════ +# 9. ADMIN MANAGEMENT (Superadmin only) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/admins") +async def list_admins(request: Request): + """List all admin users.""" + await require_admin(request, "*", AdminRole.SUPERADMIN) + + admins = await AdminUserStore.list_admins() + + # Remove sensitive data + safe_admins = [] + for admin in admins: + safe = {k: v for k, v in admin.items() if k not in ["password_hash", "two_factor_secret"]} + safe_admins.append(safe) + + return {"admins": safe_admins, "total": len(safe_admins)} + + +@router.post("/admins") +async def create_admin(request: Request, body: CreateAdminRequest): + """Create a new admin user.""" + auth = await require_admin(request, "*", AdminRole.SUPERADMIN) + creator = auth["admin"] + ip, ua = _get_client_info(request) + + role = AdminRole(body.role) if body.role in [r.value for r in AdminRole] else AdminRole.VIEWER + + admin = await AdminUserStore.create_admin( + email=body.email, + password=body.password, + role=role, + created_by=creator["id"], + ) + + if not admin: + raise HTTPException(status_code=400, detail="Email already exists") + + await AuditLogger.log( + admin_id=creator["id"], + admin_email=creator["email"], + action="admin.create", + resource_type="admin", + resource_id=admin["id"], + ip_address=ip, + user_agent=ua, + after_state={"role": admin["role"], "email": admin["email"]}, + ) + + return {"success": True, "admin": admin} + + +@router.post("/admins/{admin_id}/update") +async def update_admin(request: Request, admin_id: str, body: UpdateAdminRequest): + """Update an admin user.""" + auth = await require_admin(request, "*", AdminRole.SUPERADMIN) + updater = auth["admin"] + ip, ua = _get_client_info(request) + + # Can't update self's role to prevent lockout + if admin_id == updater["id"] and body.role and body.role != updater["role"]: + raise HTTPException(status_code=400, detail="Cannot change your own role") + + admin = await AdminUserStore.get_admin(admin_id) + if not admin: + raise HTTPException(status_code=404, detail="Admin not found") + + before_state = {} + after_state = {} + + if body.role is not None: + before_state["role"] = admin["role"] + admin["role"] = body.role + after_state["role"] = body.role + + if body.is_active is not None: + before_state["is_active"] = admin.get("is_active", True) + admin["is_active"] = body.is_active + after_state["is_active"] = body.is_active + + if body.ip_allowlist is not None: + admin["ip_allowlist"] = body.ip_allowlist + after_state["ip_allowlist"] = body.ip_allowlist + + if body.two_factor_enabled is not None: + admin["two_factor_enabled"] = body.two_factor_enabled + after_state["two_factor_enabled"] = body.two_factor_enabled + + await AdminUserStore.save_admin(admin) + + await AuditLogger.log( + admin_id=updater["id"], + admin_email=updater["email"], + action="admin.update", + resource_type="admin", + resource_id=admin_id, + ip_address=ip, + user_agent=ua, + before_state=before_state, + after_state=after_state, + ) + + return {"success": True, "admin_id": admin_id} + + +@router.post("/admins/{admin_id}/reset-password") +async def reset_admin_password(request: Request, admin_id: str, body: dict = Body(...)): + """Reset an admin's password.""" + auth = await require_admin(request, "*", AdminRole.SUPERADMIN) + updater = auth["admin"] + ip, ua = _get_client_info(request) + + from app.auth import hash_password + + admin = await AdminUserStore.get_admin(admin_id) + if not admin: + raise HTTPException(status_code=404, detail="Admin not found") + + new_password = body.get("password", "") + if len(new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + + admin["password_hash"] = hash_password(new_password) + admin["password_changed_at"] = datetime.utcnow().isoformat() + + await AdminUserStore.save_admin(admin) + + # Destroy all sessions (force re-login) + await SessionManager.destroy_all_sessions(admin_id) + + await AuditLogger.log( + admin_id=updater["id"], + admin_email=updater["email"], + action="admin.password_reset", + resource_type="admin", + resource_id=admin_id, + ip_address=ip, + user_agent=ua, + ) + + return {"success": True, "sessions_destroyed": True} + + +# ═══════════════════════════════════════════════════════════════ +# 10. BACKUP & MAINTENANCE +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/backups") +async def list_backups(request: Request): + """List available backups.""" + await require_admin(request, "backups.read", AdminRole.SUPERADMIN) + + return {"backups": [], "total": 0} + + +@router.post("/backups/create") +async def create_backup(request: Request, body: dict = Body(...)): + """Create a new backup.""" + auth = await require_admin(request, "backups.write", AdminRole.SUPERADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + backup_type = body.get("type", "full") + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="backup.create", + resource_type="backup", + resource_id=backup_type, + ip_address=ip, + user_agent=ua, + ) + + return {"success": True, "backup_type": backup_type, "status": "queued"} + + +# ═══════════════════════════════════════════════════════════════ +# 11. WEBHOOK MANAGEMENT +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/webhooks") +async def list_webhooks(request: Request): + """List configured webhooks.""" + await require_admin(request, "webhooks.read", AdminRole.ADMIN) + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + webhooks = [] + data = await r.get("rmi:webhooks") + if data: + webhooks = json.loads(data) + + return {"webhooks": webhooks} + + except Exception as e: + logger.error(f"List webhooks error: {e}") + return {"webhooks": []} + + +@router.post("/webhooks") +async def create_webhook(request: Request, body: WebhookConfigRequest): + """Create a webhook.""" + auth = await require_admin(request, "webhooks.write", AdminRole.ADMIN) + admin = auth["admin"] + ip, ua = _get_client_info(request) + + webhook = { + "id": f"wh_{int(time.time())}", + "url": body.url, + "events": body.events, + "secret": body.secret, + "active": body.active, + "created_at": datetime.utcnow().isoformat(), + "created_by": admin["id"], + } + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + data = await r.get("rmi:webhooks") + webhooks = json.loads(data) if data else [] + webhooks.append(webhook) + + await r.set("rmi:webhooks", json.dumps(webhooks)) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="webhook.create", + resource_type="webhook", + resource_id=webhook["id"], + ip_address=ip, + user_agent=ua, + ) + + return {"success": True, "webhook": webhook} + + except Exception as e: + logger.error(f"Create webhook error: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════════ +# 12. NEW FEATURES: BRIEFING, DAO, CONTENT, WALLETS, CONTRACTS +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/daily-briefing") +async def get_daily_briefing(request: Request): + """Get daily intel briefing for the dev with real system metrics.""" + await require_admin(request, "intel.read", AdminRole.ADMIN) + + from datetime import datetime + + import psutil + + cpu_percent = psutil.cpu_percent(interval=0.1) + memory = psutil.virtual_memory() + disk = psutil.disk_usage("/") + + boot_time = datetime.fromtimestamp(psutil.boot_time()) + uptime_seconds = (datetime.now() - boot_time).total_seconds() + days = int(uptime_seconds // 86400) + hours = int((uptime_seconds % 86400) // 3600) + mins = int((uptime_seconds % 3600) // 60) + uptime_str = f"{days}d {hours}h {mins}m" + + return { + "date": datetime.utcnow().strftime("%Y-%m-%d"), + "summary": "System operating within normal parameters. Real-time metrics integrated.", + "items": [ + { + "id": "1", + "type": "info", + "category": "dev", + "title": "Real-time Metrics Active", + "description": "Daily briefing now pulls live CPU, memory, and disk usage.", + "action": "Monitor metrics", + }, + ], + "focus_areas": [ + "Complete Wyoming DAO LLC transition documentation", + "Review and publish pending Ghost CMS longform content", + "Monitor new token scanner performance on Base chain", + ], + "server_status": { + "cpu": round(cpu_percent, 1), + "memory": round(memory.percent, 1), + "disk": round(disk.percent, 1), + "uptime": uptime_str, + }, + } + + +@router.post("/daily-briefing/generate") +async def generate_daily_briefing(request: Request): + """Trigger regeneration of daily briefing.""" + await require_admin(request, "intel.write", AdminRole.ADMIN) + return {"success": True, "message": "Briefing regenerated"} + + +@router.get("/dao-transition") +async def get_dao_transition(request: Request): + """Get DAO transition status and steps.""" + await require_admin(request, "config.read", AdminRole.ADMIN) + return { + "current_entity": "Rug Munch Intelligence LLC (Wyoming)", + "target_entity": "Rug Munch Intelligence DAO LLC (Wyoming)", + "status": "in_progress", + "progress": 35, + "steps": [ + { + "id": "1", + "title": "Legal Consultation & Feasibility", + "description": "Engage Wyoming DAO LLC specialist counsel.", + "status": "completed", + "details": [ + "Reviewed Wyoming DAO LLC Act", + "Identified required operating agreement amendments", + ], + "documents": ["wyoming_dao_llc_requirements.pdf"], + }, + { + "id": "2", + "title": "Operating Agreement Drafting", + "description": "Draft new DAO-compliant Operating Agreement.", + "status": "in_progress", + "details": ["Template acquired", "Pending customization for RMI tokenomics"], + "documents": [], + }, + { + "id": "3", + "title": "Articles of Organization Amendment", + "description": "File amendment with Wyoming Secretary of State.", + "status": "pending", + "details": ["Requires signed operating agreement first", "Filing fee: $100"], + "documents": [], + }, + { + "id": "4", + "title": "EIN Update & IRS Notification", + "description": "Update Employer Identification Number records.", + "status": "pending", + "details": ["Form 8822-B may be required"], + "documents": [], + }, + { + "id": "5", + "title": "Banking & Financial Institution Update", + "description": "Notify existing banking partners.", + "status": "pending", + "details": ["Prepare corporate resolution"], + "documents": [], + }, + ], + } + + +@router.patch("/dao-transition/steps/{step_id}") +async def update_dao_step(request: Request, step_id: str, body: dict = Body(...)): + """Update a DAO transition step status.""" + await require_admin(request, "config.write", AdminRole.ADMIN) + return {"success": True, "step_id": step_id, "status": body.get("status")} + + +@router.post("/content/generate") +async def generate_content(request: Request, body: dict = Body(...)): + """Generate content for Ghost CMS.""" + await require_admin(request, "content.write", AdminRole.ADMIN) + content_type = body.get("type", "twitter") + topic = body.get("topic", "") + body.get("tone", "professional") + + mock_content = { + "twitter": f"🚨 {topic} is a critical topic for crypto security. Always verify contracts before interacting. #RMI #CryptoSecurity", + "thread": f"🧵 Thread: {topic}\n\n1/ The crypto space is evolving rapidly, and {topic} represents a major shift.\n\n2/ Here's what you need to know to stay safe and informed.\n\n3/ Always DYOR and use tools like RMI to verify before you trust.", + "longform": f"# {topic}: A Comprehensive Guide\n\n## Introduction\n{topic} has become a focal point in the Web3 ecosystem. This guide breaks down the essentials.\n\n## Key Takeaways\n- Always verify smart contracts\n- Use multi-sig wallets for treasury management\n- Stay updated with RMI intelligence feeds\n\n## Conclusion\nSecurity is not an afterthought; it's the foundation.", + "newsletter": f"Subject: Weekly Intel: {topic}\n\nHey team,\n\nThis week's focus is on {topic}. We've seen a 40% increase in related activity. Here's what you need to know...", + } + + return { + "content": mock_content.get(content_type, mock_content["twitter"]), + "metadata": { + "word_count": len(mock_content.get(content_type, "").split()), + "estimated_read_time": "1m" if content_type == "twitter" else "3m", + "hashtags": ["#RMI", "#CryptoSecurity", "#Web3"], + }, + } + + +@router.post("/wallets/generate") +async def generate_wallet(request: Request, body: dict = Body(...)): + """Generate a new wallet for a specific chain.""" + await require_admin(request, "wallets.write", AdminRole.ADMIN) + chain = body.get("chain", "ethereum") + + import secrets + + if chain == "solana": + address = "NewSol" + secrets.token_hex(20) + private_key = secrets.token_hex(32) + "solana_mock_pk" + else: + address = "0x" + secrets.token_hex(20) + private_key = "0x" + secrets.token_hex(32) + + return { + "id": f"wallet_{secrets.token_hex(8)}", + "chain": chain, + "address": address, + "private_key": private_key, + "created_at": datetime.utcnow().strftime("%Y-%m-%d"), + "label": f"New {chain.capitalize()} Wallet", + } + + +@router.post("/contracts/generate") +async def generate_contract(request: Request, body: dict = Body(...)): + """Generate a smart contract template.""" + await require_admin(request, "contracts.write", AdminRole.ADMIN) + contract_type = body.get("type", "erc20") + chain = body.get("chain", "ethereum") + name = body.get("name", "MyToken") + symbol = body.get("symbol", "MTK") + + templates = { + "erc20": f'// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport "@openzeppelin/contracts/token/ERC20/ERC20.sol";\nimport "@openzeppelin/contracts/access/Ownable.sol";\n\ncontract {name} is ERC20, Ownable {{\n constructor() ERC20("{name}", "{symbol}") Ownable(msg.sender) {{\n _mint(msg.sender, 1000000 * 10 ** decimals());\n }}\n\n function mint(address to, uint256 amount) public onlyOwner {{\n _mint(to, amount);\n }}\n}}', + "erc721": f'// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport "@openzeppelin/contracts/token/ERC721/ERC721.sol";\nimport "@openzeppelin/contracts/access/Ownable.sol";\n\ncontract {name}NFT is ERC721, Ownable {{\n uint256 private _nextTokenId;\n\n constructor() ERC721("{name}", "{symbol}") Ownable(msg.sender) {{}}\n\n function safeMint(address to) public onlyOwner {{\n uint256 tokenId = _nextTokenId++;\n _safeMint(to, tokenId);\n }}\n}}', + "multisig": '// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\ncontract MultiSigWallet {\n event Deposit(address indexed sender, uint amount);\n event SubmitTransaction(address indexed owner, uint indexed txIndex);\n event ConfirmTransaction(address indexed owner, uint indexed txIndex);\n\n address[] public owners;\n mapping(address => bool) public isOwner;\n uint public numConfirmationsRequired;\n\n struct Transaction {\n address to;\n uint value;\n bytes data;\n bool executed;\n uint numConfirmations;\n }\n\n mapping(uint => Transaction) public transactions;\n mapping(uint => mapping(address => bool)) public isConfirmed;\n\n constructor(address[] memory _owners, uint _numConfirmationsRequired) {\n require(_owners.length > 0, "Owners required");\n require(_numConfirmationsRequired > 0 && _numConfirmationsRequired <= _owners.length, "Invalid number of required confirmations");\n\n for (uint i = 0; i < _owners.length; i++) {\n address owner = _owners[i];\n require(owner != address(0), "Invalid owner");\n require(!isOwner[owner], "Owner not unique");\n isOwner[owner] = true;\n owners.push(owner);\n }\n numConfirmationsRequired = _numConfirmationsRequired;\n }\n}', + "timelock": '// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\ncontract TimelockController {\n uint256 public constant MIN_DELAY = 1 days;\n uint256 public constant MAX_DELAY = 30 days;\n uint256 public constant GRACE_PERIOD = 14 days;\n\n uint256 public delay;\n mapping(bytes32 => bool) public queuedTransactions;\n\n event QueueTransaction(bytes32 indexed txHash, address target, uint value, string signature, bytes data, uint eta);\n event ExecuteTransaction(bytes32 indexed txHash, address target, uint value, string signature, bytes data, uint eta);\n\n constructor(uint256 delay_) {\n require(delay_ >= MIN_DELAY, "Timelock::constructor: Delay must exceed minimum delay");\n require(delay_ <= MAX_DELAY, "Timelock::constructor: Delay must not exceed maximum delay");\n delay = delay_;\n }\n}', + "dao_governor": f'// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport "@openzeppelin/contracts/governance/Governor.sol";\nimport "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";\nimport "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";\nimport "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";\nimport "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";\nimport "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";\n\ncontract {name}Governor is Governor, GovernorSettings, GovernorCountingSimple, GovernorVotes, GovernorVotesQuorumFraction, GovernorTimelockControl {{\n constructor(IVotes _token, TimelockController _timelock)\n Governor("{name} Governor")\n GovernorSettings(1 /* 1 block */, 50400 /* 1 week */, 0)\n GovernorVotes(_token)\n GovernorVotesQuorumFraction(4)\n GovernorTimelockControl(_timelock)\n {{}}\n\n function votingDelay() public view override(Governor, GovernorSettings) returns (uint256) {{ return super.votingDelay(); }}\n function votingPeriod() public view override(Governor, GovernorSettings) returns (uint256) {{ return super.votingPeriod(); }}\n function quorum(uint256 blockNumber) public view override(Governor, GovernorVotesQuorumFraction) returns (uint256) {{ return super.quorum(blockNumber); }}\n function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) {{ return super.proposalThreshold(); }}\n}}', + } + + return { + "code": templates.get(contract_type, templates["erc20"]), + "metadata": { + "compiler": "solc 0.8.20", + "optimization": True, + "runs": 200, + "license": "MIT", + "chain": chain, + }, + } + + +# ═══════════════════════════════════════════════════════════════ +# 13. FILE UPLOADS (DAO Documents, etc.) +# ═══════════════════════════════════════════════════════════════ + +import uuid # noqa: E402 + +from fastapi import File, UploadFile # noqa: E402 + + +@router.post("/upload/document") +async def upload_document(request: Request, file: UploadFile = File(...), step_id: str = ""): + """Upload a document (e.g., DAO transition PDF) with strict MIME-type validation.""" + await require_admin(request, "config.write", AdminRole.ADMIN) + + upload_dir = "/root/backend/uploads/dao_documents" + os.makedirs(upload_dir, exist_ok=True) + + # Read first 8 bytes for magic number validation + file_content = await file.read() + if len(file_content) < 4: + raise HTTPException(status_code=400, detail="File is too small or empty") + + magic_bytes = file_content[:4] + + # Validate magic bytes: PDF (%PDF) or DOCX/ZIP (PK\x03\x04) + is_pdf = magic_bytes == b"%PDF" + is_docx = magic_bytes == b"PK\x03\x04" + + if not (is_pdf or is_docx): + raise HTTPException(status_code=400, detail="Invalid file type. Only PDF and DOCX are allowed.") + + ext = ".pdf" if is_pdf else ".docx" + unique_filename = f"{uuid.uuid4().hex}{ext}" + file_path = os.path.join(upload_dir, unique_filename) + + with open(file_path, "wb") as f: + f.write(file_content) + + return { + "success": True, + "filename": file.filename, + "url": f"/api/v1/admin/backend/uploads/dao_documents/{unique_filename}", + "step_id": step_id, + } + + +# ═══════════════════════════════════════════════════════════════ +# 14. WALLET BALANCE FETCHING +# ═══════════════════════════════════════════════════════════════ + +import httpx # noqa: E402 + + +@router.post("/wallets/fetch-balances") +async def fetch_wallet_balances(request: Request, body: dict = Body(...)): + """Fetch real-time balances for a list of wallet addresses with RPC fallback.""" + await require_admin(request, "wallets.read", AdminRole.ADMIN) + wallets = body.get("wallets", []) + + # RPC Fallback Arrays for resilience against rate limits (429) or downtime + rpc_fallbacks = { + "ethereum": [ + "https://eth.llamarpc.com", + "https://rpc.ankr.com/eth", + "https://cloudflare-eth.com", + ], + "base": [ + "https://mainnet.base.org", + "https://base.llamarpc.com", + "https://base.publicnode.com", + ], + "arbitrum": [ + "https://arb1.arbitrum.io/rpc", + "https://arbitrum.llamarpc.com", + "https://arbitrum.publicnode.com", + ], + "polygon": [ + "https://polygon-rpc.com", + "https://polygon.llamarpc.com", + "https://polygon.publicnode.com", + ], + } + + results = [] + for wallet in wallets: + address = wallet.get("address", "") + chain = wallet.get("chain", "ethereum") + balance = "0.00" + + try: + if chain in rpc_fallbacks: + payload = { + "jsonrpc": "2.0", + "method": "eth_getBalance", + "params": [address, "latest"], + "id": 1, + } + + # Try each RPC in the fallback array until one succeeds + success = False + for rpc_url in rpc_fallbacks[chain]: + try: + async with httpx.AsyncClient() as client: + res = await client.post(rpc_url, json=payload, timeout=5.0) + if res.status_code == 200: + res_data = res.json() + if "result" in res_data and res_data["result"] != "0x": + wei = int(res_data["result"], 16) + balance = f"{wei / 1e18:.4f}" + success = True + break + except Exception: + continue # Try next RPC in the array + + if not success: + balance = "Error" + elif chain == "solana": + balance = "N/A" + except Exception: + balance = "Error" + + results.append( + { + "id": wallet.get("id"), + "address": address, + "chain": chain, + "balance": f"{balance} {wallet.get('symbol', 'ETH').upper()}", + } + ) + + return {"balances": results} + + +# ═══════════════════════════════════════════════════════════════ +# 15. CONTRACT DEPLOYMENT (Testnet) +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/contracts/deploy-testnet") +async def deploy_contract_testnet(request: Request, body: dict = Body(...)): + """Deploy a contract to testnet (mocked for safety).""" + await require_admin(request, "contracts.write", AdminRole.ADMIN) + + chain = body.get("chain", "base") + name = body.get("name", "TestToken") + + import secrets + + mock_tx_hash = "0x" + secrets.token_hex(32) + mock_address = "0x" + secrets.token_hex(20) + + explorer_url = "https://sepolia.basescan.org" + if chain == "ethereum": + explorer_url = "https://sepolia.etherscan.io" + elif chain == "arbitrum": + explorer_url = "https://sepolia.arbiscan.io" + + return { + "success": True, + "tx_hash": mock_tx_hash, + "contract_address": mock_address, + "explorer_url": f"{explorer_url}/address/{mock_address}", + "message": f"Contract '{name}' deployed to {chain} testnet successfully!", + } + + +# ═══════════════════════════════════════════════════════════════ +# 16. EMERGENCY PANIC BUTTON +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/emergency-lockdown") +async def emergency_lockdown(request: Request, body: dict = Body(...)): + """Toggle emergency lockdown mode. Blocks all non-admin traffic.""" + auth = await require_admin(request, "system.write", AdminRole.SUPERADMIN) + + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + is_locked = body.get("locked", True) + reason = body.get("reason", "Manual lockdown triggered") + + if is_locked: + await r.setex("rmi:emergency_lockdown", 86400, reason) # 24h TTL + logger.warning(f"EMERGENCY LOCKDOWN ACTIVATED by {auth['admin']['email']}: {reason}") + else: + await r.delete("rmi:emergency_lockdown") + logger.info(f"EMERGENCY LOCKDOWN DEACTIVATED by {auth['admin']['email']}") + + return {"success": True, "locked": is_locked, "reason": reason} + + +@router.get("/emergency-status") +async def emergency_status(request: Request): + """Check current emergency lockdown status.""" + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + reason = await r.get("rmi:emergency_lockdown") + return {"locked": reason is not None, "reason": reason or ""} diff --git a/app/_archive/legacy_2026_07/admin_control.py b/app/_archive/legacy_2026_07/admin_control.py new file mode 100644 index 0000000..7dade0d --- /dev/null +++ b/app/_archive/legacy_2026_07/admin_control.py @@ -0,0 +1,263 @@ +""" +RMI Backend Control Router +========================== +Admin endpoints for system monitoring, service control, logs, +database management, bot configuration, and alerts. +""" + +import logging +import os +from datetime import datetime, timedelta + +import psutil +from fastapi import APIRouter, Depends, HTTPException, Request + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/admin", tags=["admin-control"]) + + +# ── Auth helper ── +async def _verify_admin(request: Request): + """Verify admin access with API key.""" + admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me") + key = request.headers.get("X-Admin-Key", "") + if key != admin_key: + raise HTTPException(status_code=401, detail="Invalid admin key") + return True + + +# ═══════════════════════════════════════════════════════════════ +# SYSTEM MONITORING +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/system") +async def admin_system(request: Request, _=Depends(_verify_admin)): + """Get real-time system resource usage.""" + try: + cpu_percent = psutil.cpu_percent(interval=0.5) + cpu_count = psutil.cpu_count() + cpu_freq = psutil.cpu_freq() + memory = psutil.virtual_memory() + disk = psutil.disk_usage("/") + net_io = psutil.net_io_counters() + boot_time = psutil.boot_time() + uptime_seconds = int(datetime.now().timestamp() - boot_time) + + # Process info for the backend itself + backend_pid = os.getpid() + backend_proc = psutil.Process(backend_pid) + backend_memory_mb = backend_proc.memory_info().rss / (1024 * 1024) + backend_cpu = backend_proc.cpu_percent(interval=0.2) + + return { + "timestamp": datetime.utcnow().isoformat(), + "cpu": { + "percent": cpu_percent, + "count": cpu_count, + "frequency_mhz": cpu_freq.current if cpu_freq else None, + }, + "memory": { + "total_gb": round(memory.total / (1024**3), 2), + "available_gb": round(memory.available / (1024**3), 2), + "percent": memory.percent, + "used_gb": round(memory.used / (1024**3), 2), + }, + "disk": { + "total_gb": round(disk.total / (1024**3), 2), + "used_gb": round(disk.used / (1024**3), 2), + "free_gb": round(disk.free / (1024**3), 2), + "percent": round(disk.used / disk.total * 100, 1), + }, + "network": { + "bytes_sent_mb": round(net_io.bytes_sent / (1024**2), 2), + "bytes_recv_mb": round(net_io.bytes_recv / (1024**2), 2), + "packets_sent": net_io.packets_sent, + "packets_recv": net_io.packets_recv, + }, + "uptime": { + "seconds": uptime_seconds, + "formatted": str(timedelta(seconds=uptime_seconds)), + }, + "backend_process": { + "pid": backend_pid, + "memory_mb": round(backend_memory_mb, 2), + "cpu_percent": backend_cpu, + "threads": backend_proc.num_threads(), + }, + "services": { + "redis": _check_redis(), + "supabase": _check_supabase(), + }, + } + except Exception as e: + logger.error(f"System check failed: {e}") + return {"error": str(e)} + + +def _check_redis(): + """Check Redis connection.""" + try: + import redis.asyncio as redis + + r = redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + r.ping() + return {"status": "connected"} + except Exception as e: + return {"status": "error", "error": str(e)} + + +def _check_supabase(): + """Check Supabase connection.""" + supabase_url = os.getenv("SUPABASE_URL") + if not supabase_url: + return {"status": "not_configured"} + return {"status": "connected", "url": supabase_url} + + +# ═══════════════════════════════════════════════════════════════ +# DATABASE MANAGEMENT (stubbed - uses Redis instead of Supabase) +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/db/tables") +async def admin_db_tables(request: Request, _=Depends(_verify_admin)): + """List available tables (stubbed - uses Redis instead of Supabase).""" + return { + "tables": [ + {"name": "wallet_users", "type": "redis", "rows": "dynamic"}, + {"name": "agents", "type": "redis", "rows": "dynamic"}, + {"name": "alerts", "type": "redis", "rows": "dynamic"}, + {"name": "scans", "type": "redis", "rows": "dynamic"}, + {"name": "evidence", "type": "redis", "rows": "dynamic"}, + ] + } + + +@router.post("/db/query") +async def admin_db_query(request: Request, _=Depends(_verify_admin)): + """Execute SQL query (stubbed - redirect to Redis operations).""" + return { + "error": "Direct SQL queries not supported. Use Redis operations instead.", + "hint": "For data operations, use Redis endpoints in /api/v1/agents", + } + + +# ═══════════════════════════════════════════════════════════════ +# SERVICE CONTROL +# ═══════════════════════════════════════════════════════════════ + + +@router.post("/service/restart") +async def admin_service_restart(request: Request, _=Depends(_verify_admin)): + """Restart the backend service.""" + return { + "action": "restart", + "status": "queued", + "message": "Service restart queued - use /api/v1/admin/service/status for updates", + } + + +@router.get("/service/status") +async def admin_service_status(request: Request, _=Depends(_verify_admin)): + """Get service status.""" + return { + "service": "rmi-backend", + "status": "running", + "pid": os.getpid(), + "port": 8013, + "version": "1.0.0", + } + + +# ═══════════════════════════════════════════════════════════════ +# LOGS +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/logs") +async def admin_logs(request: Request, _=Depends(_verify_admin), limit: int = 100): + """Get recent logs.""" + log_file = "/tmp/backend.log" + if not os.path.exists(log_file): + return {"logs": [], "error": "Log file not found"} + + try: + with open(log_file) as f: + lines = f.readlines()[-limit:] + return {"logs": [line.rstrip() for line in lines]} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════════ +# BOT CONFIGURATION +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/bot/config") +async def admin_bot_config(request: Request, _=Depends(_verify_admin)): + """Get bot configuration.""" + return { + "telegram": { + "enabled": bool(os.getenv("TELEGRAM_BOT_TOKEN")), + "channel": os.getenv("TELEGRAM_ALERTS_CHANNEL", ""), + "rate_limit": 5, + "rate_limit_window": 60, + }, + "webhook": { + "urls": [os.getenv("ALERT_WEBHOOK_URL", ""), os.getenv("SLACK_ALERT_WEBHOOK", "")], + }, + } + + +# ═══════════════════════════════════════════════════════════════ +# AGENT MANAGEMENT +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/agents") +async def admin_agents(request: Request, _=Depends(_verify_admin)): + """List available agents.""" + return { + "agents": [ + {"id": "agent-0", "name": "solana-monitor", "status": "running"}, + {"id": "agent-1", "name": "ethereum-monitor", "status": "running"}, + {"id": "agent-2", "name": "arbitrum-monitor", "status": "running"}, + {"id": "agent-3", "name": "base-monitor", "status": "running"}, + ] + } + + +@router.get("/agents/{agent_id}") +async def admin_agent_detail(request: Request, agent_id: str, _=Depends(_verify_admin)): + """Get agent details.""" + return { + "id": agent_id, + "name": f"agent-{agent_id}", + "status": "running", + "stats": {"transactions_processed": 0, "alerts_triggered": 0}, + } + + +# ═══════════════════════════════════════════════════════════════ +# HEALTH & READY +# ═══════════════════════════════════════════════════════════════ + + +@router.get("/health") +async def admin_health(request: Request): + """Check backend health.""" + return {"status": "ok"} + + +@router.get("/ready") +async def admin_ready(request: Request): + """Check backend readiness.""" + return {"status": "ready"} diff --git a/app/_archive/legacy_2026_07/admin_users_api.py b/app/_archive/legacy_2026_07/admin_users_api.py new file mode 100644 index 0000000..215b305 --- /dev/null +++ b/app/_archive/legacy_2026_07/admin_users_api.py @@ -0,0 +1,612 @@ +""" +RMI Admin User Management API +============================= +Admin endpoints for managing users: + - List all users with filters (tier, role, status, date range) + - Get user details + - Warn user (add warning note) + - Flag user as suspicious + - Ban / unban user + - Delete user (soft delete) + - Update user tier/role + - Bulk actions + +All endpoints require ADMIN or SUPERADMIN role. +""" + +import json +import logging +import secrets +from datetime import datetime, timedelta +from enum import StrEnum +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.core.redis import get_redis + +logger = logging.getLogger("rmi_admin_users") +router = APIRouter(tags=["admin-users"]) + + +# ── Redis helper ── +async def require_admin(request: Request, min_role: str = "ADMIN"): + """Require admin authentication.""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + role = user.get("role", "USER") + user.get("tier", "FREE") + + # Role hierarchy + role_levels = {"USER": 0, "MODERATOR": 1, "ADMIN": 2, "SUPERADMIN": 3} + required_level = role_levels.get(min_role, 2) + user_level = role_levels.get(role, 0) + + if user_level < required_level: + raise HTTPException(status_code=403, detail="Admin access required") + + return user + + +# ── Models ── +class UserStatus(StrEnum): + ACTIVE = "active" + WARNED = "warned" + SUSPECT = "suspect" + BANNED = "banned" + DELETED = "deleted" + + +class UserListFilters(BaseModel): + tier: str | None = None + role: str | None = None + status: UserStatus | None = None + search: str | None = None + date_from: str | None = None + date_to: str | None = None + limit: int = Field(50, ge=1, le=500) + offset: int = Field(0, ge=0) + + +class WarnUserRequest(BaseModel): + reason: str = Field(..., min_length=1, max_length=500) + severity: str = Field("medium", pattern="^(low|medium|high|critical)$") + + +class UpdateUserRequest(BaseModel): + tier: str | None = None + role: str | None = None + status: UserStatus | None = None + display_name: str | None = None + scans_remaining: int | None = None + notes: str | None = None + + +class BulkActionRequest(BaseModel): + user_ids: list[str] + action: str = Field(..., pattern="^(warn|ban|unban|flag_suspect|clear_suspect|delete|restore|update_tier)$") + params: dict[str, Any] | None = None + + +class UserAdminResponse(BaseModel): + id: str + email: str + display_name: str + wallet_address: str | None + wallet_chain: str | None + tier: str + role: str + status: str + created_at: str + last_login: str | None + xp: int + level: int + scans_used: int + scans_remaining: int + warnings: list[dict] + notes: str | None + login_methods: list[str] + + +# ── Helper: Get all users from Redis ── +def _get_all_users() -> list[dict]: + """Fetch all users from Redis.""" + r = get_redis() + users_raw = r.hgetall("rmi:users") + users = [] + for user_id, data in users_raw.items(): + try: + user = json.loads(data) + user["id"] = user_id + users.append(user) + except json.JSONDecodeError: + continue + return users + + +def _get_user_full(user_id: str) -> dict | None: + """Get user with enriched data.""" + from app.auth import _get_user + + user = _get_user(user_id) + if not user: + return None + + r = get_redis() + + # Get warnings + warnings_raw = r.hget("rmi:user_warnings", user_id) + user["warnings"] = json.loads(warnings_raw) if warnings_raw else [] + + # Get notes + user["notes"] = r.hget("rmi:user_notes", user_id) or "" + + # Get status + user["status"] = r.hget("rmi:user_status", user_id) or "active" + + # Get last login + user["last_login"] = r.hget("rmi:user_last_login", user_id) + + # Get login methods + wallets_raw = r.hget("rmi:user_wallets", user_id) + wallets = json.loads(wallets_raw) if wallets_raw else [] + login_methods = ["email"] if user.get("password_hash") else [] + if user.get("wallet_address"): + login_methods.append("wallet") + if "google" in user.get("email", ""): + login_methods.append("google") + + user["login_methods"] = login_methods + user["wallet_count"] = len(wallets) + + return user + + +# ── Endpoints ── + + +@router.get("/admin/users") +async def list_users( + request: Request, + tier: str | None = Query(None), + role: str | None = Query(None), + status: str | None = Query(None), + search: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + limit: int = Query(50, ge=1, le=500), + offset: int = Query(0, ge=0), + sort_by: str = Query("created_at"), + sort_order: str = Query("desc", pattern="^(asc|desc)$"), +): + """List all users with filtering and pagination.""" + await require_admin(request) + + users = _get_all_users() + + # Apply filters + filtered = [] + for u in users: + # Skip deleted unless specifically filtering + user_status = get_redis().hget("rmi:user_status", u["id"]) or "active" + if user_status == "deleted" and status != "deleted": + continue + + if tier and u.get("tier", "FREE") != tier.upper(): + continue + if role and u.get("role", "USER") != role.upper(): + continue + if status and user_status != status: + continue + if search: + search_lower = search.lower() + match = ( + search_lower in u.get("email", "").lower() + or search_lower in u.get("display_name", "").lower() + or search_lower in u.get("wallet_address", "").lower() + or search_lower in u.get("id", "").lower() + ) + if not match: + continue + if date_from and u.get("created_at", "") < date_from: + continue + if date_to and u.get("created_at", "") > date_to: + continue + + # Enrich + u["status"] = user_status + u["warnings_count"] = len(json.loads(get_redis().hget("rmi:user_warnings", u["id"]) or "[]")) + filtered.append(u) + + # Sort + reverse = sort_order == "desc" + filtered.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse) + + total = len(filtered) + paginated = filtered[offset : offset + limit] + + # Clean response + results = [] + for u in paginated: + results.append( + { + "id": u["id"], + "email": u.get("email", ""), + "display_name": u.get("display_name", u.get("email", "")), + "wallet_address": u.get("wallet_address"), + "wallet_chain": u.get("wallet_chain"), + "tier": u.get("tier", "FREE"), + "role": u.get("role", "USER"), + "status": u.get("status", "active"), + "created_at": u.get("created_at"), + "last_login": u.get("last_login"), + "xp": u.get("xp", 0), + "level": u.get("level", 1), + "scans_used": u.get("scans_used", 0), + "scans_remaining": u.get("scans_remaining", 5), + "warnings_count": u.get("warnings_count", 0), + "login_methods": ["email"] if u.get("password_hash") else ["wallet"] if u.get("wallet_address") else [], + } + ) + + return { + "users": results, + "total": total, + "limit": limit, + "offset": offset, + "filters_applied": { + "tier": tier, + "role": role, + "status": status, + "search": search, + }, + } + + +@router.get("/admin/users/{user_id}") +async def get_user_detail(user_id: str, request: Request): + """Get detailed info about a specific user.""" + await require_admin(request) + + user = _get_user_full(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return user + + +@router.post("/admin/users/{user_id}/warn") +async def warn_user(user_id: str, req: WarnUserRequest, request: Request): + """Add a warning to a user.""" + admin = await require_admin(request, min_role="MODERATOR") + + from app.auth import _get_user + + user = _get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + r = get_redis() + warnings_raw = r.hget("rmi:user_warnings", user_id) + warnings = json.loads(warnings_raw) if warnings_raw else [] + + warning = { + "id": secrets.token_hex(8), + "reason": req.reason, + "severity": req.severity, + "issued_by": admin["id"], + "issued_at": datetime.utcnow().isoformat(), + "acknowledged": False, + } + warnings.append(warning) + r.hset("rmi:user_warnings", user_id, json.dumps(warnings)) + + # Update status to warned + if req.severity in ("high", "critical"): + r.hset("rmi:user_status", user_id, "warned") + + logger.info(f"[ADMIN] User {user_id} warned by {admin['id']}: {req.reason}") + + return {"status": "ok", "warning": warning} + + +@router.post("/admin/users/{user_id}/flag-suspect") +async def flag_suspect(user_id: str, request: Request, reason: str | None = Query(None)): + """Flag a user as suspicious.""" + admin = await require_admin(request, min_role="MODERATOR") + + from app.auth import _get_user + + user = _get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + r = get_redis() + r.hset("rmi:user_status", user_id, "suspect") + + # Add note + if reason: + existing = r.hget("rmi:user_notes", user_id) or "" + note = f"[{datetime.utcnow().isoformat()}] FLAGGED SUSPECT by {admin['id']}: {reason}\n" + r.hset("rmi:user_notes", user_id, existing + note) + + logger.info(f"[ADMIN] User {user_id} flagged as suspect by {admin['id']}") + + return {"status": "ok", "message": "User flagged as suspect"} + + +@router.post("/admin/users/{user_id}/clear-suspect") +async def clear_suspect(user_id: str, request: Request): + """Clear suspect flag from a user.""" + admin = await require_admin(request, min_role="MODERATOR") + + r = get_redis() + current = r.hget("rmi:user_status", user_id) + if current == "suspect": + r.hset("rmi:user_status", user_id, "active") + + logger.info(f"[ADMIN] User {user_id} suspect flag cleared by {admin['id']}") + + return {"status": "ok", "message": "Suspect flag cleared"} + + +@router.post("/admin/users/{user_id}/ban") +async def ban_user(user_id: str, request: Request, reason: str | None = Query(None)): + """Ban a user.""" + admin = await require_admin(request, min_role="ADMIN") + + from app.auth import _get_user + + user = _get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent self-ban + if user_id == admin["id"]: + raise HTTPException(status_code=400, detail="Cannot ban yourself") + + r = get_redis() + r.hset("rmi:user_status", user_id, "banned") + + if reason: + existing = r.hget("rmi:user_notes", user_id) or "" + note = f"[{datetime.utcnow().isoformat()}] BANNED by {admin['id']}: {reason}\n" + r.hset("rmi:user_notes", user_id, existing + note) + + # Invalidate sessions (optional: delete JWTs) + logger.info(f"[ADMIN] User {user_id} banned by {admin['id']}: {reason}") + + return {"status": "ok", "message": "User banned"} + + +@router.post("/admin/users/{user_id}/unban") +async def unban_user(user_id: str, request: Request): + """Unban a user.""" + admin = await require_admin(request, min_role="ADMIN") + + r = get_redis() + r.hset("rmi:user_status", user_id, "active") + + logger.info(f"[ADMIN] User {user_id} unbanned by {admin['id']}") + + return {"status": "ok", "message": "User unbanned"} + + +@router.post("/admin/users/{user_id}/delete") +async def delete_user(user_id: str, request: Request, reason: str | None = Query(None)): + """Soft delete a user.""" + admin = await require_admin(request, min_role="ADMIN") + + from app.auth import _get_user, _save_user + + user = _get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user_id == admin["id"]: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + # Soft delete: mark as deleted but keep data + user["deleted_at"] = datetime.utcnow().isoformat() + user["deleted_by"] = admin["id"] + user["delete_reason"] = reason or "" + _save_user(user) + + r = get_redis() + r.hset("rmi:user_status", user_id, "deleted") + + logger.info(f"[ADMIN] User {user_id} soft-deleted by {admin['id']}: {reason}") + + return {"status": "ok", "message": "User deleted"} + + +@router.post("/admin/users/{user_id}/restore") +async def restore_user(user_id: str, request: Request): + """Restore a soft-deleted user.""" + admin = await require_admin(request, min_role="ADMIN") + + from app.auth import _get_user, _save_user + + user = _get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.pop("deleted_at", None) + user.pop("deleted_by", None) + user.pop("delete_reason", None) + _save_user(user) + + r = get_redis() + r.hset("rmi:user_status", user_id, "active") + + logger.info(f"[ADMIN] User {user_id} restored by {admin['id']}") + + return {"status": "ok", "message": "User restored"} + + +@router.patch("/admin/users/{user_id}") +async def update_user(user_id: str, req: UpdateUserRequest, request: Request): + """Update user properties (tier, role, status, etc.).""" + admin = await require_admin(request, min_role="ADMIN") + + from app.auth import _get_user, _save_user + + user = _get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + changes = [] + + if req.tier is not None: + old = user.get("tier", "FREE") + user["tier"] = req.tier.upper() + changes.append(f"tier: {old} -> {req.tier.upper()}") + + if req.role is not None: + old = user.get("role", "USER") + # Prevent promoting to superadmin unless you're superadmin + if req.role.upper() == "SUPERADMIN" and admin.get("role") != "SUPERADMIN": + raise HTTPException(status_code=403, detail="Only superadmin can assign superadmin role") + user["role"] = req.role.upper() + changes.append(f"role: {old} -> {req.role.upper()}") + + if req.status is not None: + r = get_redis() + r.hset("rmi:user_status", user_id, req.status.value) + changes.append(f"status: -> {req.status.value}") + + if req.display_name is not None: + user["display_name"] = req.display_name[:50] + changes.append("display_name updated") + + if req.scans_remaining is not None: + user["scans_remaining"] = req.scans_remaining + changes.append(f"scans_remaining: -> {req.scans_remaining}") + + if req.notes is not None: + r = get_redis() + existing = r.hget("rmi:user_notes", user_id) or "" + note = f"[{datetime.utcnow().isoformat()}] {admin['id']}: {req.notes}\n" + r.hset("rmi:user_notes", user_id, existing + note) + changes.append("notes added") + + user["updated_at"] = datetime.utcnow().isoformat() + user["updated_by"] = admin["id"] + _save_user(user) + + logger.info(f"[ADMIN] User {user_id} updated by {admin['id']}: {', '.join(changes)}") + + return {"status": "ok", "changes": changes} + + +@router.post("/admin/users/bulk") +async def bulk_action(req: BulkActionRequest, request: Request): + """Perform bulk actions on multiple users.""" + admin = await require_admin(request, min_role="ADMIN") + + results = {"success": [], "failed": []} + + for user_id in req.user_ids: + try: + if req.action == "ban": + r = get_redis() + r.hset("rmi:user_status", user_id, "banned") + results["success"].append({"id": user_id, "action": "banned"}) + elif req.action == "unban": + r = get_redis() + r.hset("rmi:user_status", user_id, "active") + results["success"].append({"id": user_id, "action": "unbanned"}) + elif req.action == "flag_suspect": + r = get_redis() + r.hset("rmi:user_status", user_id, "suspect") + results["success"].append({"id": user_id, "action": "flagged_suspect"}) + elif req.action == "clear_suspect": + r = get_redis() + r.hset("rmi:user_status", user_id, "active") + results["success"].append({"id": user_id, "action": "cleared_suspect"}) + elif req.action == "delete": + from app.auth import _get_user, _save_user + + user = _get_user(user_id) + if user: + user["deleted_at"] = datetime.utcnow().isoformat() + user["deleted_by"] = admin["id"] + _save_user(user) + r = get_redis() + r.hset("rmi:user_status", user_id, "deleted") + results["success"].append({"id": user_id, "action": "deleted"}) + elif req.action == "restore": + from app.auth import _get_user, _save_user + + user = _get_user(user_id) + if user: + user.pop("deleted_at", None) + user.pop("deleted_by", None) + _save_user(user) + r = get_redis() + r.hset("rmi:user_status", user_id, "active") + results["success"].append({"id": user_id, "action": "restored"}) + elif req.action == "update_tier": + tier = req.params.get("tier", "FREE") if req.params else "FREE" + from app.auth import _get_user, _save_user + + user = _get_user(user_id) + if user: + user["tier"] = tier + _save_user(user) + results["success"].append({"id": user_id, "action": f"tier_updated_to_{tier}"}) + except Exception as e: + results["failed"].append({"id": user_id, "error": str(e)}) + + logger.info( + f"[ADMIN] Bulk action {req.action} by {admin['id']}: {len(results['success'])} success, {len(results['failed'])} failed" + ) + + return results + + +@router.get("/admin/stats") +async def get_admin_stats(request: Request): + """Get user statistics for admin dashboard.""" + await require_admin(request, min_role="VIEWER") + + users = _get_all_users() + r = get_redis() + + stats = { + "total_users": len(users), + "by_tier": {}, + "by_role": {}, + "by_status": {"active": 0, "warned": 0, "suspect": 0, "banned": 0, "deleted": 0}, + "new_today": 0, + "new_this_week": 0, + "new_this_month": 0, + } + + today = datetime.utcnow().date().isoformat() + week_ago = (datetime.utcnow() - timedelta(days=7)).isoformat() + month_ago = (datetime.utcnow() - timedelta(days=30)).isoformat() + + for u in users: + tier = u.get("tier", "FREE") + role = u.get("role", "USER") + stats["by_tier"][tier] = stats["by_tier"].get(tier, 0) + 1 + stats["by_role"][role] = stats["by_role"].get(role, 0) + 1 + + status = r.hget("rmi:user_status", u["id"]) or "active" + stats["by_status"][status] = stats["by_status"].get(status, 0) + 1 + + created = u.get("created_at", "") + if created.startswith(today): + stats["new_today"] += 1 + if created >= week_ago: + stats["new_this_week"] += 1 + if created >= month_ago: + stats["new_this_month"] += 1 + + return stats diff --git a/app/_archive/legacy_2026_07/agent_system.py b/app/_archive/legacy_2026_07/agent_system.py new file mode 100644 index 0000000..7d1d254 --- /dev/null +++ b/app/_archive/legacy_2026_07/agent_system.py @@ -0,0 +1,625 @@ +""" +RMI Agent System - Agent MUNCH Multi-Specialist Intelligence Operative +====================================================================== + +9 specialized crypto intelligence operatives, each a distinct skill module +under the Agent MUNCH persona. Uses free OpenRouter models with fallbacks. + +Architecture: + - Each specialist has its own system prompt, model preference, and output format + - RAG context injection: fetches real DataBus data before LLM call + - Smart caching: checks Redis for previously answered similar questions + - Keyword + explicit skill routing + - SSE streaming for real-time output + +Specialists: + rug_detect → Token rug/honeypot detection + wallet_forensics → Wallet funding trail analysis + market_intel → Market conditions & whale analysis + bundle_detect → Coordinated trading detection + code_audit → Smart contract vulnerability scanning + social_sentiment → Sentiment divergence analysis + airdrop_assess → Airdrop claim safety evaluation + defi_yield → DeFi yield trap identification + general → Agent MUNCH default operative +""" + +import contextlib +import hashlib +import json +import logging +import os +from collections.abc import AsyncGenerator +from dataclasses import dataclass, field + +logger = logging.getLogger("agent.system") + +# ═══════════════════════════════════════════════════════════ +# AGENT DEFINITIONS +# ═══════════════════════════════════════════════════════════ + + +@dataclass +class AgentDef: + id: str + name: str + icon: str + description: str + system_prompt: str + model: str + fallbacks: list[str] = field(default_factory=list) + temperature: float = 0.3 + max_tokens: int = 800 + color: str = "#8B5CF6" # UI color + output_format: str = "standard" # standard, evidence_chain, threat_rating + databus_context: list[str] = field(default_factory=list) # DataBus chains to inject + + +MUNCH_BASE = """You are Agent MUNCH, a crypto intelligence operative for Rug Munch Intelligence. +You are NOT a generic AI assistant. You are a highly trained specialist operative. +Speak like briefing a client - direct, forensic, precise. Never say "I'm an AI" or "as an AI." +Use threat classification: CRITICAL, HIGH, MEDIUM, LOW. Use confidence scores (0-100%). +Reference real data when available. If you lack data, say "I need to pull [X] data - recommend running [tool]." +Never fabricate addresses, prices, or on-chain data. Be skeptical. Trust nothing until verified. +""" + +AGENTS = { + "rug_detect": AgentDef( + id="rug_detect", + name="Rug Detection Specialist", + icon="🛡️", + description="Token rug pull, honeypot, and scam detection specialist", + system_prompt=MUNCH_BASE + + """You specialize in detecting rug pulls, honeypots, and token scams. +Focus on: liquidity lock verification, mint authority analysis, deployer wallet forensics, +honeypot detection patterns, proxy contract abuse, concentrated ownership risk. +Format output as THREAT RATING: [LEVEL] (Score: X/100) followed by KEY FINDINGS and RECOMMENDATION. +When you identify a rug pattern, say "RUG PATTERN DETECTED" with specific evidence.""", + model="nvidia/nemotron-3-super-120b-a12b:free", + fallbacks=["google/gemma-4-31b-it:free"], + temperature=0.2, + color="#EF4444", + output_format="threat_rating", + databus_context=["alerts", "market_overview"], + ), + "wallet_forensics": AgentDef( + id="wallet_forensics", + name="Wallet Forensic Investigator", + icon="🔍", + description="Wallet funding trail analysis, entity resolution, insider network mapping", + system_prompt=MUNCH_BASE + + """You specialize in wallet forensics and funding trail analysis. +Focus on: wallet clustering, deployer wallet networks, mixer exit detection, +insider wallet identification, counterparty risk, funding source tracing. +Format output as CHAIN OF CUSTODY: wallet → funding source → linked wallets → risk classification. +Classify wallets as: SMART MONEY, INSIDER, MEME DUMPER, MIXER EXIT, TEAM WALLET, MEV BOT.""", + model="google/gemma-4-26b-a4b-it:free", + fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"], + temperature=0.2, + color="#22D3EE", + output_format="evidence_chain", + databus_context=["whale_alerts", "alerts"], + ), + "market_intel": AgentDef( + id="market_intel", + name="Market Intelligence Analyst", + icon="📊", + description="Market conditions, whale movements, Fear & Greed, prediction markets", + system_prompt=MUNCH_BASE + + """You specialize in market intelligence analysis. +Focus on: whale movement interpretation, DEX flow anomalies, volume spikes, +Fear & Greed contextualization, sentiment divergence from on-chain data, +prediction market signals, macro crypto conditions. +During Extreme Greed periods, explicitly flag elevated scam and rug risk. +Be data-driven - cite specific metrics, not vague observations.""", + model="qwen/qwen3-next-80b-a3b-instruct:free", + fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"], + temperature=0.4, + color="#8B5CF6", + output_format="standard", + databus_context=["market_overview", "trending", "whale_alerts"], + ), + "bundle_detect": AgentDef( + id="bundle_detect", + name="Bundle Detection Operator", + icon="🔗", + description="Coordinated trading detection, wash trading, same-timestamp analysis", + system_prompt=MUNCH_BASE + + """You specialize in detecting coordinated trading bundles. +Focus on: same-timestamp transaction clusters, gas-funded wallet groups, +wash trading patterns, insider pre-positioning, coordinated buy/sell walls, +MEV sandwich attack patterns, token launch sniping detection. +Format: BUNDLE IDENTIFIED → wallets involved → timing → estimated profit → THREAT LEVEL.""", + model="nvidia/nemotron-3-super-120b-a12b:free", + fallbacks=["google/gemma-4-31b-it:free"], + temperature=0.2, + color="#F59E0B", + output_format="evidence_chain", + databus_context=["bundle_detect", "alerts"], + ), + "code_audit": AgentDef( + id="code_audit", + name="Multi-Chain Code Auditor", + icon="📝", + description="Smart contract vulnerability scanning across EVM, Solana, and more", + system_prompt=MUNCH_BASE + + """You specialize in smart contract code auditing across multiple chains. +EVM focus: proxy upgrade abuse, unrestricted mint, hidden owner functions, reentrancy, unsafe delegatecall. +Solana focus: mint authority freeze, close authority, unchecked CPI, fake CPI returns. +Base focus: unverified contract risks, permissioned token patterns. +Format: VULNERABILITY SCORECARD listing each finding with severity (CRITICAL/HIGH/MEDIUM/LOW), +the specific code pattern, and remediation.""", + model="nvidia/nemotron-3-super-120b-a12b:free", + fallbacks=["google/gemma-4-31b-it:free"], + temperature=0.2, + color="#06D6A0", + output_format="threat_rating", + databus_context=["alerts"], + ), + "social_sentiment": AgentDef( + id="social_sentiment", + name="Social Sentiment Decoder", + icon="🗣️", + description="X/Twitter sentiment vs on-chain movement divergence analysis", + system_prompt=MUNCH_BASE + + """You specialize in social sentiment analysis and its divergence from on-chain reality. +Focus on: Twitter/X sentiment vs actual wallet behavior, pump-and-dump social patterns, +influencer wallet timing correlation, coordinated shill detection, +sentiment manipulation via bot networks, "this is fine" divergence signals. +Key insight: when sentiment says BUY but whales are EXITING, that's the classic divergence. +Format: SENTIMENT vs ON-CHAIN: divergence score, social signals, on-chain reality, ASSESSMENT.""", + model="qwen/qwen3-next-80b-a3b-instruct:free", + fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"], + temperature=0.4, + color="#38BDF8", + output_format="standard", + databus_context=["market_overview", "trending", "whale_alerts"], + ), + "airdrop_assess": AgentDef( + id="airdrop_assess", + name="Airdrop Threat Assessor", + icon="🎁", + description="Airdrop claim safety, signature risk, wallet drain potential evaluation", + system_prompt=MUNCH_BASE + + """You specialize in airdrop and claim safety assessment. +Focus on: contract verification for claims, signature requirement risks (EIP-712 phishing), +wallet drain potential in claim processes, gas spike exploitation during claims, +fake airdrop phishing detection, legitimate vs scam airdrop differentiation. +Key rule: NEVER recommend clicking a claim link without verifying the contract address on-chain. +Format: AIRDROP RATING with legitimacy score, claim safety checklist, and specific risks.""", + model="google/gemma-4-31b-it:free", + fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"], + temperature=0.3, + color="#A78BFA", + output_format="threat_rating", + databus_context=["alerts", "market_overview"], + ), + "defi_yield": AgentDef( + id="defi_yield", + name="DeFi Yield Trap Detector", + icon="📈", + description="Unsustainable yield detection, emission inflation, TVL manipulation", + system_prompt=MUNCH_BASE + + """You specialize in detecting unsustainable DeFi yield mechanisms. +Focus on: emission schedule inflation analysis, TVL manipulation via protocol-owned liquidity, +reward token devaluation trajectories, hidden lock periods and withdrawal gates, +yield farming that requires depositing into unverified contracts, +leveraged yield loops that amplify risk. +Key pattern: if yield >30% APY with no clear revenue source, it's likely a yield trap. +Format: YIELD SAFETY SCORE with sustainability analysis, risk factors, and honest yield estimate.""", + model="qwen/qwen3-next-80b-a3b-instruct:free", + fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"], + temperature=0.3, + color="#FB3B76", + output_format="threat_rating", + databus_context=["market_overview", "trending"], + ), + "general": AgentDef( + id="general", + name="Agent MUNCH", + icon="🕵️", + description="General crypto intelligence operative - your all-purpose specialist", + system_prompt=MUNCH_BASE + + """You are the default operative, skilled in all areas of crypto intelligence. +You can discuss token security, wallet analysis, market conditions, DeFi risks, +blockchain technology, trading strategies, and scam patterns with equal expertise. +When a question falls outside your expertise, say "This requires [specialist name] deployment - +I recommend switching to that skill for deeper analysis." +Always offer actionable next steps: "Recommend running [tool] at rugmunch.io for [specific analysis].""", + model="google/gemma-4-31b-it:free", + fallbacks=["nvidia/nemotron-3-super-120b-a12b:free"], + temperature=0.5, + color="#8B5CF6", + output_format="standard", + databus_context=["market_overview", "alerts"], + ), +} + +# ═══════════════════════════════════════════════════════════ +# ROUTING +# ═══════════════════════════════════════════════════════════ + +ROUTES = { + "rug_detect": [ + "scan", + "token", + "scam", + "rug", + "honeypot", + "contract", + "audit", + "safety", + "risk score", + "verify token", + "check coin", + "rug pull", + "is this safe", + "is this a scam", + ], + "wallet_forensics": [ + "wallet", + "address", + "holder", + "whale", + "smart money", + "portfolio", + "entity", + "counterparty", + "deployer", + "funding", + "trace", + "follow the money", + "cluster", + ], + "market_intel": [ + "market", + "trending", + "fear greed", + "sentiment", + "prediction", + "price", + "volume", + "mover", + "gainer", + "condition", + "macro", + "btc", + "eth", + "sol", + "dominance", + ], + "bundle_detect": [ + "bundle", + "coordinated", + "wash trade", + "same time", + "sniper", + "launch", + "front run", + "sandwich", + "mev", + "bot cluster", + ], + "code_audit": [ + "code", + "contract", + "source", + "audit", + "vulnerability", + "proxy", + "mint authority", + "reentrancy", + "delegatecall", + "verify source", + "solana program", + ], + "social_sentiment": [ + "twitter", + "social", + "sentiment", + "influencer", + "shill", + "hype", + "pump social", + "bot network", + "community sentiment", + "reddit", + ], + "airdrop_assess": [ + "airdrop", + "claim", + "free token", + "signature", + "eip-712", + "phishing claim", + "eligible", + "merkle", + ], + "defi_yield": [ + "yield", + "apy", + "farming", + "liquidity pool", + "staking", + "emission", + "tvl", + "protocol", + "curve", + "convex", + "leveraged", + ], +} + + +def classify(msg: str) -> str: + m = msg.lower() + for agent_id, keywords in ROUTES.items(): + if any(kw in m for kw in keywords): + return agent_id + return "general" + + +# ═══════════════════════════════════════════════════════════ +# RAG CONTEXT INJECTION +# ═══════════════════════════════════════════════════════════ + + +async def fetch_databus_context(chains: list[str]) -> str: + """Fetch real data from DataBus and format as context for the LLM.""" + if not chains: + return "" + + context_parts = [] + try: + import httpx + + for chain in chains: + try: + url = "http://localhost:8000/api/v1/databus/fetch" + async with httpx.AsyncClient(timeout=8) as c: + r = await c.post(url, json={"data_type": chain, "limit": 5}) + if r.status_code == 200: + data = r.json() + # Extract the actual data payload + result = data.get("data", data.get("results", [{}])) + if isinstance(result, list) and result: + result = result[0].get("data", result[0]) if result else {} + context_parts.append(f"[{chain} DATA]: {json.dumps(result, default=str)[:800]}") + except Exception as e: + logger.warning(f"DataBus context fetch failed for {chain}: {e}") + except Exception as e: + logger.warning(f"DataBus context system unavailable: {e}") + + if context_parts: + return "\n\nREAL-TIME PLATFORM DATA (use this in your analysis, do not fabricate):\n" + "\n".join(context_parts) + return "" + + +# ═══════════════════════════════════════════════════════════ +# SMART CACHING +# ═══════════════════════════════════════════════════════════ + + +async def check_cache(msg: str, agent_id: str) -> str | None: + """Check Redis for previously answered similar questions.""" + try: + import redis + + r = redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_timeout=2, + ) + # Hash the question + agent for cache key + cache_key = f"agent_cache:{agent_id}:{hashlib.sha256(msg.encode()).hexdigest()[:16]}" + cached = r.get(cache_key) + if cached: + logger.info(f"Cache hit for {agent_id}: {cache_key}") + return cached + except Exception: + pass + return None + + +async def store_cache(msg: str, agent_id: str, response: str, ttl: int = 3600): + """Store response in Redis cache. TTL defaults to 1 hour.""" + try: + import redis + + r = redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_timeout=2, + ) + cache_key = f"agent_cache:{agent_id}:{hashlib.sha256(msg.encode()).hexdigest()[:16]}" + # Only cache if response is substantive (>200 chars) + if len(response) > 200: + r.setex(cache_key, ttl, response[:4000]) # Cap stored size + except Exception: + pass + + +# ═══════════════════════════════════════════════════════════ +# STREAMING ROUTER +# ═══════════════════════════════════════════════════════════ + + +async def route_and_stream(msg: str, role_hint: str = "") -> AsyncGenerator[dict, None]: + """Route to specialist agent, inject RAG context, stream response. + + Provider priority: + 1. Gemini 2.5 Flash (FREE, 1500 RPD, smart, fast) + 2. OpenRouter free models (fallback when Gemini rate-limited) + """ + import httpx + + agent_id = role_hint if role_hint in AGENTS else classify(msg) + agent = AGENTS[agent_id] + + yield { + "type": "agent", + "role": agent_id, + "name": agent.name, + "icon": agent.icon, + "color": agent.color, + } + + # Check cache first -- skip LLM call entirely if we already have the answer + cached = await check_cache(msg, agent_id) + if cached: + yield {"type": "cache_hit", "agent": agent_id} + yield {"type": "token", "text": cached} + yield {"type": "done"} + return + + # Fetch RAG context from DataBus + rag_context = await fetch_databus_context(agent.databus_context) + system_with_context = agent.system_prompt + rag_context + messages = [ + {"role": "system", "content": system_with_context}, + {"role": "user", "content": msg}, + ] + + full_response = "" + + # ── Provider 1: Gemini (FREE, primary) ── + from dotenv import load_dotenv + + load_dotenv() + gemini_keys = [] + for env_var in ["GEMINI_API_KEY", "GEMINI_API_KEY_2", "GEMINI_API_KEY_3"]: + k = os.environ.get(env_var, "") + if k and len(k) > 20: + gemini_keys.append(k) + + for gkey in gemini_keys: + try: + # Gemini native streaming API (key in URL, OpenAI-compatible format) + base_url = f"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions?key={gkey}" + headers = {"Content-Type": "application/json"} + body = { + "model": "gemini-2.5-flash", + "messages": messages, + "max_tokens": agent.max_tokens, + "temperature": agent.temperature, + "stream": True, + } + + async with httpx.AsyncClient(timeout=45) as c: # noqa: SIM117 + async with c.stream("POST", base_url, json=body, headers=headers) as r: + if r.status_code == 200: + async for line in r.aiter_lines(): + if line.startswith("data: "): + d = line[6:] + if d == "[DONE]": + if full_response: + await store_cache(msg, agent_id, full_response) + yield {"type": "done"} + return + try: + ch = json.loads(d) + txt = ch.get("choices", [{}])[0].get("delta", {}).get("content", "") + if txt: + full_response += txt + yield {"type": "token", "text": txt} + except Exception: + pass + if full_response: + await store_cache(msg, agent_id, full_response) + yield {"type": "done"} + return + elif r.status_code == 429: + logger.info("Gemini rate-limited, trying next key/fallback") + continue # Try next key or fallback provider + else: + logger.warning(f"Gemini error {r.status_code}, trying fallback") + continue + except Exception as e: + logger.warning(f"Gemini call failed: {e}") + continue + + # ── Provider 2: OpenRouter (fallback, costs credits) ── + api_key = os.environ.get("OPENROUTER_API_KEY", "") + if not api_key: + b64 = os.environ.get("LLM_API_KEY_B64", "") + if b64: + import base64 + + with contextlib.suppress(BaseException): + api_key = base64.b64decode(b64).decode() + + if api_key: + models = [agent.model, *agent.fallbacks] + for model in models: + try: + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://rugmunch.io", + "X-Title": f"RMI {agent.name}", + } + body = { + "model": model, + "messages": messages, + "max_tokens": agent.max_tokens, + "temperature": agent.temperature, + "stream": True, + } + + async with httpx.AsyncClient(timeout=60) as c, c.stream( + "POST", + "https://openrouter.ai/api/v1/chat/completions", + json=body, + headers=headers, + ) as r: + if r.status_code == 200: + async for line in r.aiter_lines(): + if line.startswith("data: "): + d = line[6:] + if d == "[DONE]": + if full_response: + await store_cache(msg, agent_id, full_response) + yield {"type": "done"} + return + try: + ch = json.loads(d) + txt = ch.get("choices", [{}])[0].get("delta", {}).get("content", "") + if txt: + full_response += txt + yield {"type": "token", "text": txt} + except Exception: + pass + if full_response: + await store_cache(msg, agent_id, full_response) + yield {"type": "done"} + return + elif r.status_code == 429: + continue + except Exception as e: + logger.warning(f"OpenRouter model {model} failed: {e}") + continue + + yield { + "type": "error", + "text": "All providers unavailable (Gemini rate-limited, OpenRouter failed)", + } + yield {"type": "done"} + + +def agents_list() -> list: + return [ + { + "id": a.id, + "name": a.name, + "icon": a.icon, + "model": a.model, + "description": a.description, + "color": a.color, + "output_format": a.output_format, + } + for a in AGENTS.values() + ] diff --git a/app/_archive/legacy_2026_07/agents.py b/app/_archive/legacy_2026_07/agents.py new file mode 100644 index 0000000..3b0433e --- /dev/null +++ b/app/_archive/legacy_2026_07/agents.py @@ -0,0 +1,167 @@ +""" +Agents Router - Agent Mesh listing, detail, commands +""" + +import json +import logging +from datetime import datetime + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1", tags=["agents"]) + +# ── Agent definitions ── +AGENTS = { + "nexus": { + "name": "NEXUS", + "role": "Strategic Coordinator", + "tier": "T0", + "models": ["gemini-2.5-pro", "nvidia/nemotron-4-340b"], + "triggers": ["strategize", "plan", "coordinate", "synthesize"], + }, + "scout": { + "name": "SCOUT", + "role": "Alpha Hunter", + "tier": "T3", + "models": ["groq/llama-3.1-8b-instant", "gemini-2.5-flash"], + "triggers": ["find", "scan", "hunt", "alpha"], + }, + "tracer": { + "name": "TRACER", + "role": "Forensic Investigator", + "tier": "T1", + "models": ["gemini-2.5-pro", "deepseek/deepseek-r1"], + "triggers": ["trace", "investigate", "follow", "wallet"], + }, + "cipher": { + "name": "CIPHER", + "role": "Contract Auditor", + "tier": "T1", + "models": ["qwen/qwen2.5-coder-32b-instruct", "deepseek/deepseek-coder-v2"], + "triggers": ["audit", "security", "contract", "code"], + }, + "sentinel": { + "name": "SENTINEL", + "role": "Rug Detector", + "tier": "T2", + "models": ["deepseek/deepseek-r1", "groq/llama-3.3-70b-versatile"], + "triggers": ["monitor", "watch", "alert", "rug"], + }, + "chronicler": { + "name": "CHRONICLER", + "role": "Investigative Reporter", + "tier": "T2", + "models": ["deepseek/deepseek-r1", "gemini-2.5-flash"], + "triggers": ["write", "document", "report", "evidence"], + }, + "forge": { + "name": "FORGE", + "role": "Implementation Architect", + "tier": "T1", + "models": ["qwen/qwen2.5-coder-32b-instruct", "deepseek/deepseek-coder-v2"], + "triggers": ["code", "implement", "build", "script"], + }, + "relay": { + "name": "RELAY", + "role": "Communications Coordinator", + "tier": "T3", + "models": ["groq/llama-3.1-8b-instant", "gemini-2.5-flash"], + "triggers": ["format", "relay", "dispatch", "notify"], + }, +} + + +class AgentCommandRequest(BaseModel): + agent: str = Field( + ..., + description="Agent name: nexus, scout, tracer, cipher, sentinel, chronicler, forge, relay", + ) + command: str + context: dict | None = None + priority: str = Field(default="normal") + + +from app.auth import get_redis # noqa: E402 + + +# ── Static routes must comes before parameterized routes to avoid FastAPI matching issues ── +@router.get("/agents/specter") +async def specter_status(): + """SPECTER agent status and capabilities""" + import os + + return { + "agent": "SPECTER", + "emoji": "👻", + "role": "OSINT & Social Forensics", + "provider": "Together AI", + "models": [ + "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "mistralai/Mixtral-8x22B-Instruct-v0.1", + ], + "free_credits": "$5", + "capabilities": [ + "brave_web_search", + "website_forensics", + "firecrawl_scraping", + "dev_identity_hunting", + "social_media_analysis", + "litepaper_plagiarism_detection", + "sockpuppet_detection", + ], + "integrations": { + "brave_search": bool(os.getenv("BRAVE_API_KEY")), + "firecrawl": bool(os.getenv("FIRECRAWL_API_KEY")), + "apify": bool(os.getenv("APIFY_API_KEY")), + "together_ai": bool(os.getenv("TOGETHER_API_KEY")), + }, + "status": "online", + } + + +@router.get("/agents") +async def list_agents(): + return {"agents": AGENTS, "total": len(AGENTS)} + + +# ── Helper to get Redis synchronously ── +def _get_redis_sync(): + """Get Redis instance synchronously (get_redis returns redis.Redis, not async).""" + return get_redis() + + +@router.get("/agents/{agent_id}") +async def get_agent(agent_id: str): + if agent_id not in AGENTS: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + r = _get_redis_sync() + status_raw = r.hget("rmi:agents", agent_id) + status = json.loads(status_raw) if status_raw else {"status": "online", "last_ping": datetime.utcnow().isoformat()} + return {**AGENTS[agent_id], **status} + + +@router.post("/agents/{agent_id}/command") +async def agent_command(agent_id: str, req: AgentCommandRequest): + if agent_id not in AGENTS: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + + agent = AGENTS[agent_id] + r = _get_redis_sync() + + task_id = f"task:{datetime.utcnow().timestamp():.0f}:{agent_id}" + task_data = { + "id": task_id, + "agent": agent_id, + "command": req.command, + "context": req.context or {}, + "priority": req.priority, + "status": "queued", + "created": datetime.utcnow().isoformat(), + } + r.hset("rmi:tasks", task_id, json.dumps(task_data)) + r.lpush("rmi:queue:" + req.priority, task_id) + + return {"task_id": task_id, "agent": agent["name"], "status": "queued", "command": req.command} diff --git a/app/_archive/legacy_2026_07/ai_pipeline.py b/app/_archive/legacy_2026_07/ai_pipeline.py new file mode 100644 index 0000000..abb38c3 --- /dev/null +++ b/app/_archive/legacy_2026_07/ai_pipeline.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +RMI AI Pipeline - Batch Ollama Cloud Modules +============================================= +Wallet Profiling | RAG Enrichment | Alert Ranking | Market Briefing | Post-Mortem +All use Ollama Cloud deepseek-v4-flash. ~$0.001 per operation. +""" + +import json +import logging +import os +from urllib.request import Request, urlopen + +logger = logging.getLogger("rmi.ai_pipeline") +OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", os.getenv("DEEPSEEK_API_KEY", "")) +OLLAMA_URL = "https://ollama.com/v1/chat/completions" +MODEL = "deepseek-v4-flash" + + +def _call_ai(system: str, prompt: str, max_tokens: int = 200, temp: float = 0.3) -> str: + try: + body = json.dumps( + { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + "max_tokens": max_tokens, + "temperature": temp, + } + ).encode() + req = Request( + OLLAMA_URL, + data=body, + headers={"Authorization": f"Bearer {OLLAMA_KEY}", "Content-Type": "application/json"}, + ) + resp = urlopen(req, timeout=15) + return json.loads(resp.read())["choices"][0]["message"]["content"].strip() + except Exception as e: + logger.error(f"AI call failed: {e}") + return "" + + +# ── 7. WALLET BEHAVIORAL PROFILING ── +WALLET_SYSTEM = """Classify a crypto wallet into a persona based on transaction patterns. +Reply with ONLY: persona_name|confidence_0-100 + +Personas: +- Day Trader: frequent buys/sells, short holds, high volume +- Whale Accumulator: large buys, holds long, rare sells +- Bot Farm: identical transaction patterns, same gas, rapid-fire +- Insider: buys before pumps, sells before dumps, too perfect timing +- Honeypot Victim: bought tokens that can't be sold +- Scam Deployer: creates tokens, drains liquidity, repeats +- Airdrop Hunter: tiny transactions, hundreds of tokens, zero holds +- Diamond Hands: bought once, never sold, regardless of price +- Degen Gambler: buys meme coins, holds minutes, high risk tolerance +- Unknown: insufficient data""" + + +def profile_wallet(tx_data: dict) -> str: + summary = json.dumps(tx_data)[:1000] + result = _call_ai(WALLET_SYSTEM, f"Transactions:\n{summary}", max_tokens=30) + return result if "|" in result else "Unknown|0" + + +# ── 9. RAG QUERY ENRICHMENT ── +RAG_SYSTEM = """You reformat raw RAG search results into a coherent, readable answer. +Keep it under 150 words. Preserve key facts. Add a 1-line summary at the end.""" + + +def enrich_rag_results(query: str, raw_docs: str) -> str: + return _call_ai(RAG_SYSTEM, f"Query: {query}\n\nRaw results:\n{raw_docs[:2000]}") + + +# ── 12. ALERT PRIORITIZATION ── +ALERT_SYSTEM = """Rank these crypto security alerts by urgency. Reply ONLY with the alert IDs in priority order, comma-separated. +Priority rules: CRITICAL (immediate rug/hack) > HIGH (likely scam) > MEDIUM (suspicious) > LOW (noise).""" + + +def rank_alerts(alerts: list) -> list: + summary = "\n".join( + f"ID:{a.get('id', '?')} | {a.get('severity', '?')} | {a.get('title', '?')[:100]}" for a in alerts[:20] + ) + result = _call_ai(ALERT_SYSTEM, summary, max_tokens=50) + return [x.strip() for x in result.split(",") if x.strip()] + + +# ── 6. DAILY MARKET BRIEFING ── +MARKET_SYSTEM = """Write a 3-paragraph daily crypto market briefing from scanner data. +Para 1: Market overview (most scanned chains, scan volume) +Para 2: Top risks (worst tokens found today, emerging patterns) +Para 3: What to watch (trending scam types, new threat vectors) +Use Telegram HTML formatting. Keep it under 250 words. Professional but direct tone.""" + + +def generate_market_briefing(scan_summary: dict) -> str: + return _call_ai(MARKET_SYSTEM, json.dumps(scan_summary)[:2000], max_tokens=350, temp=0.5) + + +# ── 15. INCIDENT POST-MORTEM ── +AUTOPSY_SYSTEM = """Write a forensic post-mortem of a crypto scam incident. +Structure: +1. What happened (1 sentence) +2. How it worked (the mechanics, 2-3 sentences) +3. Red flags that were visible beforehand +4. How to protect against similar scams +Keep it under 200 words. Use bold for key findings. Professional forensic tone.""" + + +def write_post_mortem(incident: dict) -> str: + return _call_ai(AUTOPSY_SYSTEM, json.dumps(incident)[:1500], max_tokens=300, temp=0.4) diff --git a/app/_archive/legacy_2026_07/ai_pipeline2.py b/app/_archive/legacy_2026_07/ai_pipeline2.py new file mode 100644 index 0000000..3ba56fb --- /dev/null +++ b/app/_archive/legacy_2026_07/ai_pipeline2.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +RMI AI Pipeline Part 2 - Remaining 7 Modules +============================================= +Community Forensics | Cross-Chain Entity | Ghost Blog | Social Media | Token Compare +All Ollama Cloud deepseek-v4-flash. ~$0.001/operation. +""" + +import json +import logging +import os +from urllib.request import Request, urlopen + +logger = logging.getLogger("rmi.ai_pipeline2") +OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", os.getenv("DEEPSEEK_API_KEY", "")) +OLLAMA_URL = "https://ollama.com/v1/chat/completions" +MODEL = "deepseek-v4-flash" + + +def _call_ai(system: str, prompt: str, max_tokens: int = 250, temp: float = 0.3) -> str: + try: + body = json.dumps( + { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + "max_tokens": max_tokens, + "temperature": temp, + } + ).encode() + req = Request( + OLLAMA_URL, + data=body, + headers={"Authorization": f"Bearer {OLLAMA_KEY}", "Content-Type": "application/json"}, + ) + resp = urlopen(req, timeout=15) + return json.loads(resp.read())["choices"][0]["message"]["content"].strip() + except Exception as e: + logger.error(f"AI call failed: {e}") + return "" + + +# ── 8. COMMUNITY FORENSICS AUTO-ANALYSIS ── +FORENSICS_SYSTEM = """You are a crypto forensics investigator. A community member submitted a suspicious token for review. +Analyze the information and provide: +1. Initial verdict (LIKELY SCAM / SUSPICIOUS / NEEDS MORE INFO) +2. Key concerns (2-3 bullet points) +3. Recommended next steps for the investigator +Keep it under 150 words.""" + + +def analyze_community_submission(submission: dict) -> str: + return _call_ai(FORENSICS_SYSTEM, json.dumps(submission)[:1500], max_tokens=250) + + +# ── 10. CROSS-CHAIN ENTITY DETECTION ── +CROSSCHAIN_SYSTEM = """You identify crypto entities operating across multiple blockchains. +Given wallet data from different chains, determine if they're the same entity. +Reply format: MATCH|confidence_0-100|reason OR NO_MATCH|reason""" + + +def detect_cross_chain(wallets: dict) -> str: + return _call_ai(CROSSCHAIN_SYSTEM, json.dumps(wallets)[:1500], max_tokens=100) + + +# ── 11. GHOST BLOG AUTO-DRAFT ── +GHOST_SYSTEM = """You are a crypto security blogger for Rug Munch Intelligence (rugmunch.io). +Write a blog post draft from scanner data and incident reports. +Structure: +- Title (catchy, SEO-friendly, under 80 chars) +- Hook (1 sentence that grabs attention) +- Body (3-4 paragraphs explaining the threat) +- Key takeaways (2-3 bullet points) +- Call to action (check your tokens, use our scanner) +Use markdown formatting. Professional but engaging tone.""" + + +def draft_blog_post(topic: str, data: dict) -> str: + prompt = f"Topic: {topic}\n\nData:\n{json.dumps(data)[:2000]}" + return _call_ai(GHOST_SYSTEM, prompt, max_tokens=500, temp=0.6) + + +# ── 13. SOCIAL MEDIA POST GENERATOR ── +SOCIAL_SYSTEM = """You are the social media manager for Rug Munch Intelligence (@CryptoRugMunch). +Write a tweet/telegram post about a crypto security finding. +Rules: +- Under 280 chars for Twitter, under 500 for Telegram +- Start with a hook (stat, warning, or question) +- Include $TICKER if relevant +- End with a call to action or link +- Use emojis sparingly (1-2 max) +- No hashtag spam (2-3 max) +Reply format: TWITTER: | TELEGRAM: """ + + +def generate_social_post(incident: dict, platform: str = "both") -> str: + return _call_ai(SOCIAL_SYSTEM, json.dumps(incident)[:1000], max_tokens=200, temp=0.7) + + +# ── 14. TOKEN COMPARISON ENGINE ── +COMPARE_SYSTEM = """Compare two crypto tokens for safety. Given their scanner results, determine which is safer and why. +Reply format: +SAFER: +REASON: <2-3 sentence comparison> +SCORE_DIFF: vs +KEY_DIFFERENCES: """ + + +def compare_tokens(token_a: dict, token_b: dict) -> str: + prompt = f"Token A:\n{json.dumps(token_a)[:800]}\n\nToken B:\n{json.dumps(token_b)[:800]}" + return _call_ai(COMPARE_SYSTEM, prompt, max_tokens=200) diff --git a/app/_archive/legacy_2026_07/ai_pipeline_v2.py b/app/_archive/legacy_2026_07/ai_pipeline_v2.py new file mode 100644 index 0000000..30472d1 --- /dev/null +++ b/app/_archive/legacy_2026_07/ai_pipeline_v2.py @@ -0,0 +1,155 @@ +""" +RMI AI Pipeline v2 - Production Grade +====================================== +Caching, fallbacks, rate limiting, smart prompts. +All 12 modules battle-tested against Ollama Cloud. +""" + +import hashlib +import json +import logging +import os +import time +from urllib.request import Request, urlopen + +logger = logging.getLogger("rmi.ai") +OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", "") +OLLAMA_URL = "https://ollama.com/v1/chat/completions" +MODEL = "deepseek-v4-flash" +CACHE_TTL = 300 # 5 min cache for identical calls + +# Simple TTL cache +_cache = {} + + +def _cached_call(system: str, prompt: str, max_tokens: int = 250, temp: float = 0.3) -> str: + key = hashlib.md5(f"{system[:50]}|{prompt[:100]}".encode()).hexdigest() + now = time.time() + if key in _cache and now - _cache[key][0] < CACHE_TTL: + return _cache[key][1] + try: + body = json.dumps( + { + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + "max_tokens": max_tokens, + "temperature": temp, + } + ).encode() + req = Request( + OLLAMA_URL, + data=body, + headers={"Authorization": f"Bearer {OLLAMA_KEY}", "Content-Type": "application/json"}, + ) + resp = urlopen(req, timeout=12) + result = json.loads(resp.read())["choices"][0]["message"]["content"].strip() + _cache[key] = (now, result) + return result + except Exception as e: + logger.error(f"Ollama AI call failed: {e}") + return "" + + +# ── 1. TOKEN RISK EXPLAINER (improved) ── +def explain_risks(scan: dict) -> str: + if not scan or scan.get("safety_score") is None: + return "Unable to analyze - no scanner data." + score = scan.get("safety_score", 50) + flags = scan.get("risk_flags", []) + green = scan.get("green_flags", []) + name = scan.get("name", scan.get("symbol", "token")) + mods = len(scan.get("modules_run", [])) + prompt = f"Token:{name} Score:{score}/100 Risks:{', '.join(flags[:5]) or 'none'} Green:{', '.join(green[:3]) or 'none'} Modules:{mods}" + system = """You explain token risk to non-technical users. 3-4 sentences. Start with safety score. Mention top risks in plain English. End with "Always DYOR." Use bold for key terms. Never give financial advice.""" + result = _cached_call(system, prompt, max_tokens=150, temp=0.2) + return result or f"Safety: {score}/100. Risk flags: {', '.join(flags[:3])}. Always DYOR." + + +# ── 2. NEWS CLASSIFIER (improved) ── +def classify_news(title: str, content: str = "") -> str: + text = f"{title} {content[:200]}" + system = """Classify crypto news into ONE word: SCAM MARKET REGULATION SECURITY DEFI MEMECOIN GENERAL""" + result = _cached_call(system, text, max_tokens=8, temp=0.1) + if result: + for cat in ["SCAM", "MARKET", "REGULATION", "SECURITY", "DEFI", "MEMECOIN", "GENERAL"]: + if cat in result.upper(): + return cat + # Fast fallback + t = text.lower() + if any(w in t for w in ["hack", "exploit", "rug", "scam", "phish", "drain"]): + return "SCAM" + if any(w in t for w in ["price", "btc", "eth", "bull", "bear", "market"]): + return "MARKET" + return "GENERAL" + + +# ── 3. WALLET PROFILER ── +def profile_wallet(tx: dict) -> str: + system = """Classify wallet persona from tx data. Reply: PERSONA|confidence. Options: DayTrader Whale BotFarm Insider ScamDeployer AirdropHunter DiamondHands DegenGambler Unknown""" + return _cached_call(system, json.dumps(tx)[:1000], max_tokens=25) or "Unknown|0" + + +# ── 4. RAG ENRICHER ── +def enrich_rag(query: str, docs: str) -> str: + system = """Reformat RAG chunks into 2-3 sentence coherent answer. Preserve key facts.""" + return _cached_call(system, f"Q:{query}\nD:{docs[:2000]}", max_tokens=200) or docs[:400] + + +# ── 5. ALERT RANKER ── +def rank_alerts(alerts: list) -> list: + summary = "\n".join( + f"{a.get('id', '?')}|{a.get('severity', '?')}|{(a.get('title', '') or '')[:80]}" for a in alerts[:10] + ) + result = _cached_call("Rank these by urgency. Reply: id1,id2,id3...", summary, max_tokens=50) + return [x.strip() for x in (result or "").split(",") if x.strip()] + + +# ── 6. MARKET BRIEFING ── +def briefing(data: dict) -> str: + system = """3-paragraph crypto market briefing. P1:volume+chains P2:top risks P3:what to watch. bold key findings. Under 250 words.""" + return _cached_call(system, json.dumps(data)[:2000], max_tokens=350, temp=0.5) or "Briefing unavailable." + + +# ── 7. INCIDENT AUTOPSY ── +def post_mortem(incident: dict) -> str: + system = """Crypto scam forensic post-mortem. What happened→How→Red flags→Protection. bold findings. Under 200 words.""" + return _cached_call(system, json.dumps(incident)[:1500], max_tokens=300, temp=0.4) or "Autopsy unavailable." + + +# ── 8. COMMUNITY FORENSICS ── +def analyze_submission(sub: dict) -> str: + system = """Analyze suspicious token submission. Verdict:LIKELY SCAM/SUSPICIOUS/MORE INFO + 2-3 concerns.""" + return _cached_call(system, json.dumps(sub)[:1500], max_tokens=200) or "Analysis unavailable." + + +# ── 9. CROSS-CHAIN DETECTION ── +def cross_chain(wallets: dict) -> str: + system = """Same entity across chains? Reply: MATCH|conf|reason or NO_MATCH|reason""" + return _cached_call(system, json.dumps(wallets)[:1500], max_tokens=80) or "Unknown" + + +# ── 10. BLOG DRAFT ── +def blog_draft(topic: str, data: dict) -> str: + system = """Crypto security blog post draft. Title|Hook|Body(3-4para)|KeyTakeaways|CTA. Markdown. Professional.""" + return ( + _cached_call(system, f"Topic:{topic}\nData:{json.dumps(data)[:2000]}", max_tokens=500, temp=0.6) + or f"# {topic}\n\nDraft unavailable." + ) + + +# ── 11. SOCIAL POSTS ── +def social_post(incident: dict) -> str: + system = ( + """Tweet+Telegram post about crypto security finding. Twitter:<280 chars> | Telegram:<500 chars>. Hook first.""" + ) + return _cached_call(system, json.dumps(incident)[:1000], max_tokens=200, temp=0.7) or "Post unavailable." + + +# ── 12. TOKEN COMPARE ── +def compare_tokens(a: dict, b: dict) -> str: + system = """Compare 2 tokens for safety. SAFER: REASON:<2sentences> SCORE_DIFF: KEY_DIFFERENCES:""" + prompt = f"A:{json.dumps(a)[:800]}\nB:{json.dumps(b)[:800]}" + return _cached_call(system, prompt, max_tokens=200) or "Comparison unavailable." diff --git a/app/_archive/legacy_2026_07/ai_risk_explainer.py b/app/_archive/legacy_2026_07/ai_risk_explainer.py new file mode 100644 index 0000000..8692aa3 --- /dev/null +++ b/app/_archive/legacy_2026_07/ai_risk_explainer.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +RMI AI Risk Explainer - Ollama Cloud Powered +============================================= +Takes raw scanner output → generates consumer-friendly risk explanations. +Used by Telegram bot, website, and scanner API. + +Cost: ~100 tokens per explanation = ~$0.0007 on Ollama Cloud +""" + +import json +import logging +import os +from urllib.request import Request, urlopen + +logger = logging.getLogger("rmi.risk_explainer") + +OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", os.getenv("DEEPSEEK_API_KEY", "")) +OLLAMA_URL = "https://ollama.com/v1/chat/completions" +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +MODEL = "deepseek-v4-flash" + +SYSTEM_PROMPT = """You are RMI Risk Analyst. Given raw token scanner data, write a consumer-friendly risk explanation in 3-4 sentences. + +Rules: +- Start with the safety score and risk level (SAFE/LOW/MEDIUM/HIGH/CRITICAL) +- Mention the 1-2 most important risk flags with plain-English explanations +- If there are green flags, mention the most reassuring one +- Be direct and honest - call out scams clearly +- Use Telegram HTML formatting: bold for key terms +- Never give financial advice. End with "Always DYOR." + +Example output: +"Safety: 23/100 - HIGH RISK. This token has unlocked liquidity, meaning the deployer can drain funds anytime. The deployer wallet has 6 prior rugs. No redeeming factors found. Avoid this token. Always DYOR." +""" + + +def explain_risks(scan: dict) -> str: + """Generate a human-readable risk explanation from scanner data.""" + if not scan or scan.get("safety_score") is None: + return "Unable to analyze - no scanner data available." + + score = scan.get("safety_score", 50) + flags = scan.get("risk_flags", []) + green = scan.get("green_flags", []) + name = scan.get("name", scan.get("symbol", "This token")) + modules = len(scan.get("modules_run", [])) + + # Build a concise prompt for the AI + prompt = f"""Token safety scan results: +- Token: {name} +- Safety score: {score}/100 +- Risk flags: {", ".join(flags[:5]) if flags else "none"} +- Green flags: {", ".join(green[:3]) if green else "none"} +- Modules analyzed: {modules} + +Write the explanation.""" + + try: + body = json.dumps( + { + "model": MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 150, + "temperature": 0.3, + } + ).encode() + + req = Request( + OLLAMA_URL, + data=body, + headers={ + "Authorization": f"Bearer {OLLAMA_KEY}", + "Content-Type": "application/json", + }, + ) + resp = urlopen(req, timeout=15) + data = json.loads(resp.read()) + return data["choices"][0]["message"]["content"].strip() + except Exception as e: + logger.error(f"Risk explainer failed: {e}") + # Fallback: basic explanation without AI + return _basic_explain(scan) + + +def _basic_explain(scan: dict) -> str: + """Basic explanation when AI is unavailable.""" + score = scan.get("safety_score", 50) + if score >= 80: + level = "SAFE" + elif score >= 60: + level = "LOW RISK" + elif score >= 40: + level = "MEDIUM RISK" + elif score >= 20: + level = "HIGH RISK" + else: + level = "CRITICAL" + + flags = scan.get("risk_flags", []) + green = scan.get("green_flags", []) + scan.get("name", scan.get("symbol", "This token")) + + msg = [f"Safety: {score}/100 - {level}"] + if flags: + msg.append(f"Risk flags: {', '.join(flags[:3])}") + if green: + msg.append(f"Green flags: {', '.join(green[:2])}") + msg.append("Always DYOR.") + return ". ".join(msg) + + +# ── News Classification ── + +NEWS_SYSTEM = """Classify crypto news headlines into categories. Reply with ONLY the category name. + +Categories: +- SCAM: rug pulls, hacks, exploits, phishing, fraud +- MARKET: price action, trading, volume, market cap, BTC/ETH moves +- REGULATION: government, SEC, legal, compliance, bans +- SECURITY: vulnerability, audit, patch, wallet security +- DEFI: DeFi protocols, yield, liquidity, lending +- MEMECOIN: meme tokens, celebrity coins, pump events +- GENERAL: anything else""" + + +def classify_news(title: str, content: str = "") -> str: + """Classify a news article into a category.""" + text = f"{title}\n{content[:200]}" if content else title + + try: + body = json.dumps( + { + "model": MODEL, + "messages": [ + {"role": "system", "content": NEWS_SYSTEM}, + {"role": "user", "content": text}, + ], + "max_tokens": 10, + "temperature": 0.1, + } + ).encode() + + req = Request( + OLLAMA_URL, + data=body, + headers={ + "Authorization": f"Bearer {OLLAMA_KEY}", + "Content-Type": "application/json", + }, + ) + resp = urlopen(req, timeout=10) + data = json.loads(resp.read()) + category = data["choices"][0]["message"]["content"].strip().upper() + # Normalize + for cat in ["SCAM", "MARKET", "REGULATION", "SECURITY", "DEFI", "MEMECOIN", "GENERAL"]: + if cat in category: + return cat + return "GENERAL" + except Exception as e: + logger.warning(f"News classification failed: {e}") + # Basic keyword fallback + t = (title + " " + content).lower() + if any(w in t for w in ["hack", "exploit", "rug", "scam", "phish"]): + return "SCAM" + if any(w in t for w in ["price", "btc", "eth", "bull", "bear", "market"]): + return "MARKET" + if any(w in t for w in ["sec ", "regulation", "ban", "law", "legal"]): + return "REGULATION" + return "GENERAL" + + +if __name__ == "__main__": + # Test + test = { + "safety_score": 23, + "risk_flags": ["LP_LOCK_LOW", "DEV_HIGH_RISK", "HONEYPOT_DETECTED"], + "green_flags": [], + "name": "SCAMCOIN", + "modules_run": ["security", "holders", "liquidity"], + } + print(explain_risks(test)) + print() + print(classify_news("$4M rug pull on Solana - deployer drained LP", "")) diff --git a/app/_archive/legacy_2026_07/airdrop_scanner.py b/app/_archive/legacy_2026_07/airdrop_scanner.py new file mode 100644 index 0000000..9547744 --- /dev/null +++ b/app/_archive/legacy_2026_07/airdrop_scanner.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""#12 - Multi-Chain Airdrop Scanner. Scans address across all chains for unclaimed airdrops, +governance tokens, dust that became valuable. Free tier shows value, paid tier auto-claims.""" + +import asyncio +import os +from typing import Any + +import httpx +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/airdrop-scanner", tags=["airdrop-scanner"]) + +DATABUS = os.environ.get("DATABUS_URL", "http://localhost:8000/api/v1/databus") + +CHAINS = [ + "solana", + "ethereum", + "bsc", + "base", + "arbitrum", + "polygon", + "avalanche", + "optimism", + "fantom", + "linea", + "zksync", + "scroll", +] + +KNOWN_AIRDROPS = { + "ethereum": [ + {"project": "Uniswap", "token": "UNI", "check_contract": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984"}, + {"project": "ENS", "token": "ENS", "check_contract": "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72"}, + ], + "arbitrum": [ + {"project": "Arbitrum", "token": "ARB", "check_contract": "0x912CE59144191C1204E64559FE8253a0e49E6548"}, + ], + "solana": [ + {"project": "Jupiter", "token": "JUP", "check_contract": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN"}, + {"project": "Pyth", "token": "PYTH", "check_contract": "HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3"}, + {"project": "Jito", "token": "JTO", "check_contract": "jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL"}, + ], +} + + +async def _check_chain_airdrops(address: str, chain: str) -> dict[str, Any]: + """Check for unclaimed airdrops on a single chain.""" + found: list[dict] = [] + total_value = 0.0 + + known = KNOWN_AIRDROPS.get(chain, []) + for airdrop in known: + try: + async with httpx.AsyncClient(timeout=8) as client: + resp = await client.get( + f"{DATABUS}/{chain}/balance/{address}", params={"token": airdrop["check_contract"]} + ) + if resp.status_code == 200: + data = resp.json().get("data", {}) + balance = float(data.get("balance", 0) or 0) + value = float(data.get("value_usd", 0) or 0) + if balance > 0: + found.append( + { + "project": airdrop["project"], + "token": airdrop["token"], + "chain": chain, + "balance": balance, + "value_usd": round(value, 2), + "claimable": False, # Would need per-project claim logic + } + ) + total_value += value + except Exception: + continue + + return {"chain": chain, "airdrops": found, "total_value": round(total_value, 2)} + + +@router.get("/scan/{address}") +async def scan_airdrops(address: str, chains: str = Query(None)): + """Scan an address across all chains for unclaimed airdrops.""" + chain_list = chains.split(",") if chains else CHAINS[:8] + + tasks = [] + for c in chain_list: + if c in KNOWN_AIRDROPS: + tasks.append(_check_chain_airdrops(address, c)) + + results = await asyncio.gather(*tasks) + all_airdrops = [] + grand_total = 0.0 + for r in results: + all_airdrops.extend(r["airdrops"]) + grand_total += r["total_value"] + + return { + "address": address, + "chains_scanned": len(results), + "airdrops_found": len(all_airdrops), + "total_value_usd": round(grand_total, 2), + "airdrops": all_airdrops, + } + + +@router.get("/known-projects") +async def list_known_airdrops(): + """List all known airdrop projects by chain.""" + return {"airdrops": KNOWN_AIRDROPS, "total_chains": len(KNOWN_AIRDROPS)} + + +@router.get("/dust-check/{address}") +async def check_dust(address: str, chain: str = Query("solana")): + """Check for dust tokens that might have become valuable.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{DATABUS}/{chain}/tokens/{address}") + if resp.status_code != 200: + return {"address": address, "chain": chain, "tokens": [], "error": "chain unavailable"} + tokens = resp.json().get("data", {}).get("tokens", []) + # Find tokens with small balances that have value + dust_tokens = [] + for t in tokens: + bal = float(t.get("balance", 0) or 0) + val = float(t.get("value_usd", 0) or 0) + if bal > 0 and val > 0: + dust_tokens.append({"token": t.get("symbol", "?"), "balance": bal, "value_usd": round(val, 2)}) + dust_tokens.sort(key=lambda t: t["value_usd"], reverse=True) + total_dust = sum(t["value_usd"] for t in dust_tokens) + return { + "address": address, + "chain": chain, + "dust_tokens": dust_tokens[:20], + "total_dust_value": round(total_dust, 2), + } + except Exception as e: + return {"address": address, "chain": chain, "error": str(e)} diff --git a/app/_archive/legacy_2026_07/alchemy_router.py b/app/_archive/legacy_2026_07/alchemy_router.py new file mode 100644 index 0000000..2873096 --- /dev/null +++ b/app/_archive/legacy_2026_07/alchemy_router.py @@ -0,0 +1,272 @@ +""" +Alchemy API Router - NFT API, Enhanced API, Transaction API. +Endpoints for NFT discovery, whale tracking, token metadata, and contract analysis. +""" + +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/alchemy", tags=["alchemy"]) + + +# ── Models ─────────────────────────────────────────────────── + + +class NftQuery(BaseModel): + owner: str + network: str = "eth" + page_size: int = 50 + + +class NftMetadataQuery(BaseModel): + contract: str + token_id: str + network: str = "eth" + + +class CollectionOwnersQuery(BaseModel): + contract: str + network: str = "eth" + page_size: int = 50 + + +class TokenBalanceQuery(BaseModel): + address: str + network: str = "eth" + + +class AssetTransferQuery(BaseModel): + from_address: str | None = None + to_address: str | None = None + network: str = "eth" + category: list[str] = ["external", "internal", "erc20", "erc721"] + max_count: int = 100 + + +class ContractCallQuery(BaseModel): + contract: str + data: str + network: str = "eth" + from_address: str | None = None + + +# ── NFT API Endpoints ──────────────────────────────────────── + + +@router.post("/nfts") +async def get_nfts(req: NftQuery): + """Get all NFTs owned by an address.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_nfts(req.owner, req.network, req.page_size) + return {"owner": req.owner, "network": req.network, **result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/nft/metadata") +async def get_nft_metadata(contract: str, token_id: str, network: str = "eth"): + """Get metadata for a specific NFT.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_nft_metadata(contract, token_id, network) + return {"contract": contract, "token_id": token_id, "metadata": result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/collection/owners") +async def get_collection_owners(contract: str, network: str = "eth", page_size: int = 50): + """Get all owners of an NFT collection.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_owners_for_collection(contract, network, page_size) + return {"contract": contract, "network": network, **result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/contract/metadata") +async def get_contract_metadata(contract: str, network: str = "eth"): + """Get NFT contract metadata (name, symbol, totalSupply).""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_contract_metadata(contract, network) + # Unwrap contractMetadata if present + if isinstance(result, dict) and "contractMetadata" in result: + result = result["contractMetadata"] + return {"contract": contract, "network": network, "metadata": result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/nft-sales") +async def get_nft_sales(contract: str | None = None, network: str = "eth", limit: int = 50): + """Get recent NFT sales.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_nft_sales(contract, network, limit) + return {"contract": contract, "network": network, **result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Enhanced API Endpoints ─────────────────────────────────── + + +@router.post("/token/balances") +async def get_token_balances(req: TokenBalanceQuery): + """Get all ERC-20 token balances for an address.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_token_balances(req.address, req.network) + return {"address": req.address, "network": req.network, **result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/token/metadata") +async def get_token_metadata(contract: str, network: str = "eth"): + """Get ERC-20 token metadata.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_token_metadata(contract, network) + return {"contract": contract, "network": network, "metadata": result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/transfers") +async def get_asset_transfers(req: AssetTransferQuery): + """Get asset transfers (tokens, NFTs, internal).""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_asset_transfers( + from_address=req.from_address, + to_address=req.to_address, + network=req.network, + category=req.category, + max_count=req.max_count, + ) + return {"network": req.network, **result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Transaction API Endpoints ──────────────────────────────── + + +@router.get("/tx/{tx_hash}/receipt") +async def get_transaction_receipt(tx_hash: str, network: str = "eth"): + """Get transaction receipt with enhanced data.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_transaction_receipt(tx_hash, network) + return {"tx_hash": tx_hash, "network": network, "receipt": result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/block/{block_number}") +async def get_block(block_number: int, network: str = "eth", include_txs: bool = False): + """Get block data.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_block_by_number(block_number, network, include_txs) + return {"block_number": block_number, "network": network, "block": result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/balance/{address}") +async def get_balance(address: str, network: str = "eth", block: str = "latest"): + """Get native token balance.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.get_balance(address, network, block) + # Convert hex wei to ETH + try: + eth = int(result, 16) / 1e18 if result.startswith("0x") else float(result) + except Exception: + eth = 0 + return {"address": address, "network": network, "balance_wei": result, "balance_eth": eth} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/contract/call") +async def contract_call(req: ContractCallQuery): + """Call a contract read function.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + result = await ac.call_contract(req.contract, req.data, req.network, req.from_address) + return {"contract": req.contract, "network": req.network, "result": result} + except ImportError: + raise HTTPException(status_code=503, detail="Alchemy connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Health ──────────────────────────────────────────────────── + + +@router.get("/health") +async def alchemy_health(): + """Alchemy connector status.""" + try: + from app.alchemy_connector import get_alchemy_connector + + ac = get_alchemy_connector() + return {"status": "ok", "service": "alchemy-connector", **ac.status()} + except ImportError: + return {"status": "ok", "service": "alchemy-connector", "api_key_set": False} diff --git a/app/_archive/legacy_2026_07/alert_pipeline.py b/app/_archive/legacy_2026_07/alert_pipeline.py new file mode 100644 index 0000000..c21e649 --- /dev/null +++ b/app/_archive/legacy_2026_07/alert_pipeline.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +""" +Auto-Alerting Pipeline for RMI Platform +Send real-time alerts from detections to Telegram + webhooks +""" + +import asyncio +import json +import os +from dataclasses import asdict, dataclass +from datetime import datetime +from enum import Enum +from typing import Any + +import httpx +import redis +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +class AlertLevel(Enum): + CRITICAL = "CRITICAL" + WARNING = "WARNING" + INFO = "INFO" + DEBUG = "DEBUG" + + +@dataclass +class Alert: + source: str + alert_type: str + level: str # "CRITICAL", "WARNING", "INFO", "DEBUG" + wallet_address: str + token_symbol: str + message: str + timestamp: str + confidence: float = 0.0 + metadata: dict[str, Any] = None + + def to_dict(self) -> dict: + return asdict(self) + + def to_telegram_message(self) -> str: + """Format alert as Telegram message""" + level_emoji = {"CRITICAL": "🚨", "WARNING": "⚠️", "INFO": "ℹ️", "DEBUG": "🔍"} # noqa: RUF001 + return ( + f"{level_emoji.get(self.level, 'ℹ️')} *{self.alert_type.upper()}* [{self.level}]\n\n" # noqa: RUF001 + f" Wallet: `{self.wallet_address[:10]}...{self.wallet_address[-6:]}`\n" + f" Token: `{self.token_symbol}`\n" + f" Confidence: {self.confidence:.0%}\n\n" + f"{self.message}\n\n" + f"📅 {self.timestamp}" + ) + + +# Configuration +REDIS_HOST = os.getenv("REDIS_HOST", "172.20.0.3") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") + +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_ALERTS_CHANNEL = os.getenv("TELEGRAM_ALERTS_CHANNEL", "") # -100xxx format + +WEBHOOK_URLS = [ + os.getenv("ALERT_WEBHOOK_URL", ""), + os.getenv("SLACK_ALERT_WEBHOOK", ""), +] + +# Rate limiting (max alerts per minute) +RATE_LIMIT_MAX = 5 +RATE_LIMIT_WINDOW = 60 # seconds + +# API Router +router = APIRouter(prefix="/api/v1/alerts", tags=["alerting"]) + + +# Rate limit tracking via Redis +def get_rate_limit_key() -> str: + """Get Redis key for rate limiting""" + return "alerting:rate_limit:counts" + + +def check_and_update_rate_limit() -> bool: + """Check rate limit and increment counter. Returns True if under limit.""" + try: + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True) + key = get_rate_limit_key() + current_time = int(datetime.now().timestamp()) + window_key = f"{key}:{current_time // RATE_LIMIT_WINDOW}" + + count = r.incr(window_key) + r.expire(window_key, RATE_LIMIT_WINDOW * 2) + + return not count > RATE_LIMIT_MAX + except Exception as e: + logger.warning(f"Rate limit check failed: {e}") + return True # Allow on error + + +# Send to Telegram +async def send_to_telegram(alert: Alert) -> bool: + """Send alert to Telegram channel""" + if not TELEGRAM_BOT_TOKEN or not TELEGRAM_ALERTS_CHANNEL: + logger.info("Telegram config missing, skipping") + return False + + if not check_and_update_rate_limit(): + logger.info("Rate limit exceeded, alert queued") + return False + + try: + import telegram + + bot = telegram.Bot(token=TELEGRAM_BOT_TOKEN) + message = alert.to_telegram_message() + await bot.send_message(chat_id=TELEGRAM_ALERTS_CHANNEL, text=message, parse_mode="MarkdownV2") + return True + except Exception as e: + logger.warning(f"Telegram send failed: {e}") + return False + + +# Send to webhooks +async def send_to_webhooks(alert: Alert) -> list[dict]: + """Send alert to configured webhooks""" + results = [] + + for url in WEBHOOK_URLS: + if not url: + continue + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + url, + json={"alert": alert.to_dict(), "timestamp": datetime.now().isoformat()}, + headers={"Content-Type": "application/json"}, + ) + results.append( + { + "url": url, + "status": response.status_code, + "success": response.status_code == 200, + } + ) + except Exception as e: + results.append({"url": url, "status": "error", "error": str(e), "success": False}) + + return results + + +# Alert storage in Redis +def cache_alert(alert: Alert, ttl: int = 3600) -> bool: + """Cache alert in Redis for downstream processing""" + try: + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True) + key = f"alerts:{alert.alert_type}:{alert.wallet_address}" + alert_json = json.dumps(alert.to_dict()) + r.hset( + key, + mapping={ + "alert_json": alert_json, + "created_at": datetime.now().isoformat(), + "level": alert.level, + }, + ) + r.expire(key, ttl) + return True + except Exception as e: + logger.warning(f"Cache alert failed: {e}") + return False + + +# Process and dispatch alert +async def process_alert(alert: Alert) -> dict[str, Any]: + """Process alert: send to Telegram, webhooks, cache""" + results = { + "telegram": False, + "webhooks": [], + "cached": False, + "timestamp": datetime.now().isoformat(), + } + + # Send to Telegram + if alert.level == "CRITICAL": + results["telegram"] = await send_to_telegram(alert) + + # Send to webhooks (always for all levels) + if alert.level in ["CRITICAL", "WARNING"]: + results["webhooks"] = await send_to_webhooks(alert) + + # Cache alert + results["cached"] = cache_alert(alert) + + return results + + +# API Endpoints +class AlertCreateRequest(BaseModel): + source: str + alert_type: str + level: str = "INFO" + wallet_address: str + token_symbol: str + message: str + confidence: float = 0.0 + metadata: dict[str, Any] = None + + +@router.post("/create") +async def create_alert(request: AlertCreateRequest): + """Create and dispatch a new alert""" + alert = Alert( + source=request.source, + alert_type=request.alert_type, + level=request.level, + wallet_address=request.wallet_address, + token_symbol=request.token_symbol, + message=request.message, + timestamp=datetime.now().isoformat(), + confidence=request.confidence, + metadata=request.metadata or {}, + ) + + results = await process_alert(alert) + + return { + "success": True, + "alert_id": f"{alert.alert_type}:{alert.wallet_address}", + "dispatch": results, + } + + +@router.post("/webhook") +async def webhook_alert(notification: dict[str, Any]): + """Receive external webhook and convert to internal alert""" + source = notification.get("source", "external") + alert_type = notification.get("alert_type", "unknown") + level = notification.get("level", "INFO") + + alert = Alert( + source=source, + alert_type=alert_type, + level=level, + wallet_address=notification.get("wallet_address", "unknown"), + token_symbol=notification.get("token_symbol", "unknown"), + message=notification.get("message", ""), + timestamp=datetime.now().isoformat(), + confidence=notification.get("confidence", 0.0), + metadata=notification.get("metadata", {}), + ) + + results = await process_alert(alert) + + return { + "success": True, + "alert_id": f"{alert.alert_type}:{alert.wallet_address}", + "dispatch": results, + } + + +@router.get("/status") +async def get_alert_status(): + """Get current alerting system status""" + try: + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True) + total_alerts = r.dbsize() + + # Count alerts by type + alert_counts = {} + for key in r.scan_iter("alerts:*"): + if ":" in key: + alert_type = key.split(":")[1] + alert_counts[alert_type] = alert_counts.get(alert_type, 0) + 1 + + return { + "status": "ok", + "redis_connected": True, + "total_alerts_cached": total_alerts, + "alerts_by_type": alert_counts, + "rate_limit": {"max_per_minute": RATE_LIMIT_MAX, "window_seconds": RATE_LIMIT_WINDOW}, + "webhooks_configured": len([w for w in WEBHOOK_URLS if w]), + "telegram_channel": TELEGRAM_ALERTS_CHANNEL if TELEGRAM_ALERTS_CHANNEL else "not_configured", + } + except Exception as e: + return {"status": "error", "error": str(e)} + + +@router.get("/recent") +async def get_recent_alerts(limit: int = 10): + """Get recent alerts from cache""" + try: + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD or None, decode_responses=True) + + alerts = [] + for key in r.scan_iter("alerts:*", count=limit): + alert_data = r.hgetall(key) + if alert_data and "alert_json" in alert_data: + alert = json.loads(alert_data["alert_json"]) + alerts.append(alert) + + # Sort by timestamp (newest first) + alerts.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + + return {"success": True, "count": len(alerts), "alerts": alerts[:limit]} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# Background task: monitor alert queue +async def alert_pipeline_monitor(): + """Monitor Redis for new alerts from agents""" + logger.info(f"[{datetime.now().isoformat()}] Alert pipeline monitor started") + while True: + try: + r = redis.Redis( + host=REDIS_HOST, + port=REDIS_PORT, + password=REDIS_PASSWORD or None, + decode_responses=True, + ) + + # Check for alerts in queue + alert_json = r.rpop("social_monitor_queue") + if alert_json: + alert = json.loads(alert_json) + logger.info(f"Processing alert from queue: {alert.get('alert_type', 'unknown')}") + # Process alert through pipeline + result = await process_alert(Alert(**alert)) + logger.info(f"Dispatch result: {result}") + alert_json = r.rpop("chain_walker_queue") + if alert_json: + alert = json.loads(alert_json) + logger.info(f"Processing chain-walker alert: {alert.get('alert_type', 'unknown')}") + result = await process_alert(Alert(**alert)) + logger.info(f"Dispatch result: {result}") + # Check syndicate detection alerts + alert_json = r.rpop("syndicate_alerts") + if alert_json: + alert = json.loads(alert_json) + logger.info(f"Processing syndicate alert: {alert.get('wallet_address', 'unknown')}") + result = await process_alert(Alert(**alert)) + logger.info(f"Dispatch result: {result}") + await asyncio.sleep(5) # Poll every 5 seconds + + except Exception as e: + logger.warning(f"Alert pipeline monitor error: {e}") + await asyncio.sleep(10) + + +def get_alert_pipeline_router(): + """Get the alerting router""" + return router diff --git a/app/_archive/legacy_2026_07/alerts.py b/app/_archive/legacy_2026_07/alerts.py new file mode 100644 index 0000000..4270293 --- /dev/null +++ b/app/_archive/legacy_2026_07/alerts.py @@ -0,0 +1,55 @@ +""" +Alerts Router - Token alert subscriptions +""" + +import json +import logging +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1", tags=["alerts"]) + + +class AlertRequest(BaseModel): + token_address: str + alert_types: list[str] = Field(default=["liquidity_remove", "mint", "blacklist"]) + webhook_url: str | None = None + + +from app.auth import get_redis, require_auth # noqa: E402 + + +def _get_redis_sync(): + """Get Redis instance synchronously (get_redis returns redis.Redis, not async).""" + return get_redis() + + +@router.post("/alerts/subscribe") +async def subscribe_alert(req: AlertRequest, user: dict[str, Any] = Depends(require_auth)): + alert_id = f"alert:{datetime.utcnow().timestamp():.0f}" + alert_data = { + "id": alert_id, + "token_address": req.token_address, + "types": req.alert_types, + "webhook_url": req.webhook_url, + "created_at": datetime.utcnow().isoformat(), + "active": True, + } + + # Redis fallback (RMI doesn't have full DB client) + r = _get_redis_sync() + r.hset("rmi:alerts", alert_id, json.dumps(alert_data)) + return alert_data + + +@router.get("/alerts") +async def list_alerts(): + r = _get_redis_sync() + alerts_raw = r.hgetall("rmi:alerts") or {} + alerts = [json.loads(v) for v in alerts_raw.values()] + return {"alerts": alerts, "total": len(alerts)} diff --git a/app/_archive/legacy_2026_07/alibaba_connector.py b/app/_archive/legacy_2026_07/alibaba_connector.py new file mode 100644 index 0000000..271e79c --- /dev/null +++ b/app/_archive/legacy_2026_07/alibaba_connector.py @@ -0,0 +1,218 @@ +""" +Alibaba Cloud Connector - Tongyi Wanxiang AI for Image Generation. +Generate professional graphics for cards, scorecards, marketing assets. +""" + +import logging +import os + +import httpx + +logger = logging.getLogger(__name__) + +# ── Alibaba Cloud Config ───────────────────────────────────── + +DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "") +DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/api/v1" + +# Tongyi Wanxiang endpoints +IMAGE_GENERATION_ENDPOINT = f"{DASHSCOPE_BASE_URL}/services/aigc/text-generation/generation" + + +class AlibabaConnector: + """Alibaba Cloud AI services connector.""" + + def __init__(self): + self.api_key = DASHSCOPE_API_KEY + self._session = None + + def _get_session(self): + if self._session is None: + self._session = httpx.AsyncClient( + timeout=60.0, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + ) + return self._session + + async def generate_image( + self, + prompt: str, + size: str = "1024x1024", + style: str = "professional", + negative_prompt: str | None = None, + ) -> dict: + """ + Generate image using Tongyi Wanxiang. + + Args: + prompt: Text description of image to generate + size: Image size (e.g., "1024x1024", "1200x675") + style: Art style ("professional", "cartoon", "realistic", etc.) + negative_prompt: What to avoid in the image + + Returns: + Dict with image_url, thumbnail_url, and metadata + """ + if not self.api_key: + logger.error("DASHSCOPE_API_KEY not configured") + return {"error": "Alibaba API key not configured"} + + # Parse size + width, height = size.split("x") + + # Build request + payload = { + "model": "wanx-v1", # Tongyi Wanxiang model + "input": { + "prompt": prompt, + "negative_prompt": negative_prompt or "blurry, low quality, distorted, ugly, text, watermark", + "size": f"{width}*{height}", + "style": style, + }, + "parameters": { + "n": 1, # Number of images + "seed": 42, # For reproducibility + }, + } + + try: + session = self._get_session() + response = await session.post(IMAGE_GENERATION_ENDPOINT, json=payload) + + if response.status_code == 200: + result = response.json() + + # Extract image URLs + images = result.get("output", {}).get("results", []) + if images and len(images) > 0: + return { + "status": "success", + "image_url": images[0].get("url"), + "thumbnail_url": images[0].get("thumbnail_url"), + "id": images[0].get("task_id"), + "prompt": prompt, + "size": size, + "style": style, + } + else: + return {"error": "No images generated", "raw": result} + else: + logger.error(f"Alibaba API error: {response.status_code} - {response.text[:200]}") + return { + "error": f"API error: {response.status_code}", + "details": response.text[:500], + } + + except Exception as e: + logger.error(f"Alibaba image generation failed: {e}") + return {"error": str(e)} + + async def generate_marketing_image(self, campaign_type: str, content: dict) -> dict: + """Generate marketing image for campaigns.""" + + prompts = { + "launch": """ + Professional crypto platform launch announcement, + dark theme, neon accents, "RMI Intelligence Platform" text, + futuristic trading interface background, + high quality, 4K, professional marketing graphic + """, + "feature_showcase": f""" + Professional feature showcase graphic, + "{content.get("feature_name", "Feature")}" prominently displayed, + trading platform UI elements, charts, graphs, + dark mode, neon green accents, + clean modern design, marketing quality + """, + "stats_announcement": f""" + Professional stats announcement graphic, + "{content.get("stat_value", "1000")}" large number display, + "{content.get("stat_label", "Users")}" label, + crypto trading platform aesthetic, + dark background, neon accents, + high quality marketing graphic + """, + "kol_ranking": """ + Professional KOL ranking graphic, + leaderboard style, top 10 layout, + crypto influencer theme, + dark mode, purple and gold accents, + trading platform quality, + high resolution marketing graphic + """, + } + + prompt = prompts.get(campaign_type, content.get("custom_prompt", "")) + + return await self.generate_image( + prompt=prompt, + size="1200x628", # Facebook/Twitter link preview size + style="professional", + negative_prompt="blurry, low quality, distorted, ugly, amateur, cluttered", + ) + + async def generate_social_post_image(self, post_type: str, data: dict) -> dict: + """Generate image for social media posts.""" + + if post_type == "win_alert": + prompt = f""" + Big win celebration graphic, + crypto trading win alert, + "+${data.get("pnl_usd", 0):,.0f}" large display, + green neon style, + dark background, + professional trading platform aesthetic, + high quality social media graphic + """ + elif post_type == "loss_alert": + prompt = f""" + Loss porn graphic, + crypto trading loss alert, + "-${data.get("pnl_usd", 0):,.0f}" large display, + red neon style, + dark background, + professional trading platform aesthetic, + high quality social media graphic + """ + elif post_type == "rug_alert": + prompt = """ + Rugpull warning graphic, + crypto scam alert, + "RUG PULL" large warning text, + orange and red warning colors, + dark background, + professional security alert aesthetic, + high quality social media graphic + """ + else: + prompt = data.get("custom_prompt", "Professional crypto graphic") + + return await self.generate_image( + prompt=prompt, + size="1200x675", # Twitter optimized + style="professional", + negative_prompt="blurry, low quality, distorted, ugly, text overlay, watermark", + ) + + def status(self) -> dict: + """Check connector status.""" + return { + "api_key_configured": bool(self.api_key), + "api_key_prefix": self.api_key[:20] + "..." if self.api_key else "NOT SET", + "base_url": DASHSCOPE_BASE_URL, + "models_available": ["wanx-v1"], + } + + +# Singleton +_alibaba: AlibabaConnector | None = None + + +def get_alibaba_connector() -> AlibabaConnector: + global _alibaba + if _alibaba is None: + _alibaba = AlibabaConnector() + return _alibaba diff --git a/app/_archive/legacy_2026_07/alibaba_dashscope_connector.py b/app/_archive/legacy_2026_07/alibaba_dashscope_connector.py new file mode 100644 index 0000000..64920a5 --- /dev/null +++ b/app/_archive/legacy_2026_07/alibaba_dashscope_connector.py @@ -0,0 +1,322 @@ +""" +Alibaba Cloud DashScope Connector - Qwen Models for Content Generation. +Supports: qwen-max, qwen-plus, qwen-turbo, qwen-coder, qwen-vl-max +""" + +import logging +import os + +import httpx + +logger = logging.getLogger(__name__) + +# ── Alibaba DashScope Config ───────────────────────────────── + +DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "") +DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/api/v1" + +# Available Qwen models +QWEN_MODELS = { + "qwen-max": { + "context": 32000, + "best_for": "Long-form content, detailed copy, highest quality", + "cost": "$$", + }, + "qwen-plus": { + "context": 32000, + "best_for": "Balanced quality/speed, marketing copy", + "cost": "$", + }, + "qwen-turbo": { + "context": 8000, + "best_for": "Quick drafts, social posts, fastest", + "cost": "¢", + }, + "qwen-coder": { + "context": 32000, + "best_for": "Technical docs, API guides, code", + "cost": "$$", + }, + "qwen-vl-max": { + "context": 8000, + "best_for": "Image + text, vision tasks", + "cost": "$$$", + }, +} + + +class AlibabaDashScopeConnector: + """Alibaba DashScope AI services connector.""" + + def __init__(self): + self.api_key = DASHSCOPE_API_KEY + self._session = None + + def _get_session(self): + if self._session is None: + self._session = httpx.AsyncClient( + timeout=120.0, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + ) + return self._session + + async def generate_text( + self, + prompt: str, + model: str = "qwen-plus", + max_tokens: int = 1000, + temperature: float = 0.7, + system_prompt: str | None = None, + ) -> dict: + """ + Generate text using Qwen models. + + Args: + prompt: User prompt + model: Model name (qwen-max, qwen-plus, qwen-turbo, qwen-coder) + max_tokens: Max tokens in response + temperature: Creativity (0.0-1.0) + system_prompt: System instructions + + Returns: + Dict with generated text and metadata + """ + if not self.api_key: + logger.error("DASHSCOPE_API_KEY not configured") + return {"error": "Alibaba API key not configured"} + + if model not in QWEN_MODELS: + return {"error": f"Unknown model: {model}. Available: {list(QWEN_MODELS.keys())}"} + + # Build request + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + payload = { + "model": model, + "input": {"messages": messages}, + "parameters": { + "max_tokens": max_tokens, + "temperature": temperature, + "result_format": "text", + }, + } + + try: + session = self._get_session() + response = await session.post( + f"{DASHSCOPE_BASE_URL}/services/aigc/text-generation/generation", json=payload + ) + + if response.status_code == 200: + result = response.json() + output = result.get("output", {}) + return { + "status": "success", + "text": output.get("text", ""), + "model": model, + "usage": output.get("usage", {}), + "prompt": prompt[:100] + "...", + } + else: + logger.error(f"DashScope API error: {response.status_code} - {response.text[:200]}") + return { + "error": f"API error: {response.status_code}", + "details": response.text[:500], + } + + except Exception as e: + logger.error(f"DashScope text generation failed: {e}") + return {"error": str(e)} + + async def generate_marketing_content(self, content_type: str, topic: str, details: dict | None = None) -> dict: + """Generate marketing content for specific use cases.""" + + # Content type templates + templates = { + "blog_post": { + "system": "You are a professional crypto marketing copywriter. Write engaging, informative blog posts.", + "prompt": f"""Write a {details.get("word_count", 600)}-word blog post about: {topic} + +Key points to cover: +{chr(10).join(f"- {point}" for point in details.get("key_points", []))} + +Tone: Professional but accessible +Include: Call to action at the end +Platform: RMI Intelligence Platform blog +""", + }, + "twitter_thread": { + "system": "You are a crypto Twitter expert. Write engaging threads that get shares.", + "prompt": f"""Create a Twitter thread (8-12 tweets) about: {topic} + +Key points: +{chr(10).join(f"- {point}" for point in details.get("key_points", []))} + +Format: +- Tweet 1: Hook +- Tweets 2-10: Content +- Final tweet: CTA + +Include emojis, hashtags, and @cryptorugmunch tag +Max 280 chars per tweet +""", + }, + "telegram_post": { + "system": "You write engaging Telegram posts for crypto communities.", + "prompt": f"""Write a Telegram announcement about: {topic} + +Key points: +{chr(10).join(f"- {point}" for point in details.get("key_points", []))} + +Format: +- Start with emoji headline +- Use **bold** for emphasis +- Include links +- Add relevant hashtags + +Tone: Exciting but professional +""", + }, + "email_newsletter": { + "system": "You write engaging email newsletters for crypto platforms.", + "prompt": f"""Write an email newsletter about: {topic} + +Key points: +{chr(10).join(f"- {point}" for point in details.get("key_points", []))} + +Structure: +- Subject line (5 options) +- Opening hook +- Main content +- CTA +- Sign-off + +Tone: Friendly, professional, valuable +Length: {details.get("word_count", 400)} words +""", + }, + "press_release": { + "system": "You write professional press releases for crypto companies.", + "prompt": f"""Write a press release about: {topic} + +Key points: +{chr(10).join(f"- {point}" for point in details.get("key_points", []))} + +Format: +- FOR IMMEDIATE RELEASE +- Headline +- Dateline +- Body paragraphs +- About RMI +- Media contact + +Tone: Professional, newsworthy +Length: {details.get("word_count", 500)} words +""", + }, + "feature_announcement": { + "system": "You write exciting feature announcements for crypto products.", + "prompt": f"""Write a feature announcement for: {topic} + +Feature details: +{chr(10).join(f"- {point}" for point in details.get("features", []))} + +Benefits: +{chr(10).join(f"- {point}" for point in details.get("benefits", []))} + +Include: +- Exciting headline +- What it does +- Why it matters +- How to use it +- CTA + +Tone: Exciting, clear, benefit-focused +""", + }, + } + + template = templates.get(content_type) + if not template: + return {"error": f"Unknown content type: {content_type}"} + + # Generate using qwen-plus by default + model = details.get("model", "qwen-plus") + + return await self.generate_text( + prompt=template["prompt"], + system_prompt=template["system"], + model=model, + max_tokens=details.get("max_tokens", 1500), + temperature=details.get("temperature", 0.7), + ) + + async def generate_variations(self, base_content: str, num_variations: int = 5, platform: str = "twitter") -> dict: + """Generate multiple variations of content.""" + + prompt = f"""Generate {num_variations} variations of this content for {platform}: + +Original: +{base_content} + +Requirements: +- Each variation should be unique +- Keep the core message +- Vary the tone slightly (some more excited, some more professional) +- All should be high quality +- Include relevant emojis for {platform} + +Output format: +Variation 1: [content] +Variation 2: [content] +... +""" + + return await self.generate_text(prompt=prompt, model="qwen-plus", max_tokens=2000, temperature=0.8) + + async def summarize_content(self, content: str, summary_type: str = "bullet_points") -> dict: + """Summarize long content into different formats.""" + + summary_prompts = { + "bullet_points": "Summarize this into 5-7 key bullet points:", + "twitter_thread": "Convert this into a 5-tweet Twitter thread:", + "one_liner": "Summarize this in one compelling sentence:", + "email_blurb": "Summarize this into a 100-word email blurb:", + } + + prompt = f"""{summary_prompts.get(summary_type, "Summarize:")} + +{content[:3000]} # Limit input length +""" + + return await self.generate_text(prompt=prompt, model="qwen-turbo", max_tokens=500, temperature=0.5) + + def list_models(self) -> list[dict]: + """List available Qwen models.""" + return [{"id": model_id, **info} for model_id, info in QWEN_MODELS.items()] + + def status(self) -> dict: + """Check connector status.""" + return { + "api_key_configured": bool(self.api_key), + "api_key_prefix": self.api_key[:20] + "..." if self.api_key else "NOT SET", + "base_url": DASHSCOPE_BASE_URL, + "models_available": list(QWEN_MODELS.keys()), + } + + +# Singleton +_alibaba_dashscope: AlibabaDashScopeConnector | None = None + + +def get_alibaba_dashscope_connector() -> AlibabaDashScopeConnector: + global _alibaba_dashscope + if _alibaba_dashscope is None: + _alibaba_dashscope = AlibabaDashScopeConnector() + return _alibaba_dashscope diff --git a/app/_archive/legacy_2026_07/all_connectors.py b/app/_archive/legacy_2026_07/all_connectors.py new file mode 100644 index 0000000..406aa17 --- /dev/null +++ b/app/_archive/legacy_2026_07/all_connectors.py @@ -0,0 +1,577 @@ +""" +All Connectors Router - Wires all 20+ unwired modules into API routes. +One file to rule them all. +""" + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/v1", tags=["connectors"]) + + +# ═══════════════════════════════════════════════════════════ +# BIRDEYE - Token data, trending, OHLCV +# ═══════════════════════════════════════════════════════════ +@router.get("/birdeye/token/{address}") +async def birdeye_token(address: str, chain: str = "solana"): + try: + from app.birdeye_client import BirdeyeClient + + client = BirdeyeClient() + data = client.get_token_info(address) + return {"address": address, "chain": chain, "data": data, "source": "birdeye"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/birdeye/trending") +async def birdeye_trending(chain: str = "solana", limit: int = 20): + try: + from app.birdeye_client import BirdeyeClient + + client = BirdeyeClient() + tokens = client.get_trending(limit=limit) + return {"tokens": tokens, "chain": chain, "source": "birdeye"} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# ARKHAM INTELLIGENCE - Entity labeling, wallet attribution, +# institutional tracking, sanctions screening +# ═══════════════════════════════════════════════════════════ +@router.get("/arkham/entity/{address}") +async def arkham_entity(address: str): + try: + from app.arkham_connector import ArkhamClient + + client = ArkhamClient() + try: + data = await client.get_entity(address) + return {"address": address, "entity": data, "source": "arkham"} + finally: + await client.close() + except Exception as e: + return {"error": str(e)} + + +@router.get("/arkham/labels") +async def arkham_labels(page: int = 0, limit: int = 100): + try: + from app.arkham_connector import ArkhamClient + + client = ArkhamClient() + try: + data = await client.get_labels(page=page, limit=limit) + return {"labels": data, "page": page, "limit": limit, "source": "arkham"} + finally: + await client.close() + except Exception as e: + return {"error": str(e)} + + +@router.get("/arkham/portfolio/{address}") +async def arkham_portfolio(address: str): + try: + from app.arkham_connector import ArkhamClient + + client = ArkhamClient() + try: + data = await client.get_portfolio(address) + return {"address": address, "portfolio": data, "source": "arkham"} + finally: + await client.close() + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# BLOCKCHAIR - Multi-chain block explorer +# ═══════════════════════════════════════════════════════════ +@router.get("/blockchair/balance/{address}") +async def blockchair_balance(address: str, chain: str = "bitcoin"): + try: + from app.blockchair_connector import get_address_balance + + result = get_address_balance(address, chain) + return {"address": address, "chain": chain, "data": result, "source": "blockchair"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/blockchair/search") +async def blockchair_search(q: str): + try: + from app.blockchair_connector import search_blockchain + + result = search_blockchain(q) + return {"query": q, "results": result, "source": "blockchair"} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# DEFILLAMA - DeFi analytics +# ═══════════════════════════════════════════════════════════ +@router.get("/defillama/tvl") +async def defillama_tvl(): + try: + from app.defillama_connector import get_defi_tvl + + result = get_defi_tvl() + return {"tvl": result, "source": "defillama"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/defillama/protocols") +async def defillama_protocols(): + try: + from app.defillama_connector import get_defi_protocols + + result = get_defi_protocols() + return {"protocols": result, "source": "defillama"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/defillama/chains") +async def defillama_chains(): + try: + from app.defillama_connector import get_chain_tvls + + result = get_chain_tvls() + return {"chains": result, "source": "defillama"} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# ENTITY CLUSTERING - Wallet cluster analysis +# ═══════════════════════════════════════════════════════════ +@router.get("/entity/clusters") +async def entity_clusters(address: str | None = None, min_size: int = 2): + try: + from app.entity_clustering import get_clustering_engine + + engine = get_clustering_engine() + if address: + entity = engine.graph.get_entity(address) + return {"entity": entity, "address": address} + clusters = engine.graph.find_clusters(min_size=min_size) + return {"clusters": clusters, "total": len(clusters)} + except Exception as e: + return {"error": str(e)} + + +@router.post("/entity/link") +async def entity_link(data: dict): + try: + from app.entity_clustering import get_clustering_engine + + engine = get_clustering_engine() + addr1 = data.get("address1", "") + addr2 = data.get("address2", "") + relationship = data.get("relationship", "related") + if not addr1 or not addr2: + raise HTTPException(status_code=400, detail="address1 and address2 required") + engine.graph.link_wallets(addr1, addr2, relationship) + return { + "status": "linked", + "address1": addr1, + "address2": addr2, + "relationship": relationship, + } + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# THREAT INTEL - Sanctions, reputation, blocklists +# ═══════════════════════════════════════════════════════════ +@router.get("/threat/reputation/{address}") +async def threat_reputation(address: str, chain: str = "ethereum"): + try: + from app.threat_intel import check_wallet_reputation + + result = check_wallet_reputation(address, chain) + return {"address": address, "chain": chain, "reputation": result} + except Exception as e: + return {"error": str(e)} + + +@router.get("/threat/sanctions/{address}") +async def threat_sanctions(address: str): + try: + from app.threat_intel import check_sanctions + + result = check_sanctions(address) + return {"address": address, "sanctions": result, "sanctioned": len(result) > 0} + except Exception as e: + return {"error": str(e)} + + +@router.post("/threat/blocklist") +async def threat_blocklist_add(data: dict): + try: + from app.threat_intel import add_to_blocklist + + address = data.get("address", "") + reason = data.get("reason", "") + if not address: + raise HTTPException(status_code=400, detail="address required") + success = add_to_blocklist(address, reason) + return {"address": address, "added": success, "reason": reason} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# EXCHANGE FLOW - CEX inflows/outflows +# ═══════════════════════════════════════════════════════════ +@router.get("/exchange/flow/{address}") +async def exchange_flow(address: str, chain: str = "ethereum"): + try: + from app.exchange_flow_analyzer import analyze_entity_flows + + result = analyze_entity_flows(address, chain) + return {"address": address, "chain": chain, "flows": result} + except Exception as e: + return {"error": str(e)} + + +@router.get("/exchange/whales") +async def exchange_whales(chain: str = "ethereum"): + try: + from app.exchange_flow_analyzer import ExchangeFlowAnalyzer + + analyzer = ExchangeFlowAnalyzer() + whale_movements = analyzer.detect_large_transfers(chain=chain, min_value_usd=1000000) + return {"whale_movements": whale_movements, "chain": chain} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# CROSS-CHAIN CORRELATOR +# ═══════════════════════════════════════════════════════════ +@router.get("/crosschain/fingerprint/{address}") +async def crosschain_fingerprint(address: str, chains: str = "ethereum,base,bsc,polygon"): + try: + from app.cross_chain_correlator import CrossChainCorrelator + + correlator = CrossChainCorrelator() + chain_list = [c.strip() for c in chains.split(",")] + results = {} + for chain in chain_list: + try: + fp = correlator.get_fingerprint(address, chain) + results[chain] = fp + except Exception: + results[chain] = {"error": f"Chain {chain} unavailable"} + return {"address": address, "fingerprints": results, "chains_checked": len(results)} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# AGENT MESH - 8 AI agents +# ═══════════════════════════════════════════════════════════ +AGENTS = { + "nexus": { + "name": "NEXUS", + "role": "Strategic Coordinator", + "tier": "T0", + "triggers": ["strategize", "plan", "coordinate"], + }, + "scout": { + "name": "SCOUT", + "role": "Alpha Hunter", + "tier": "T3", + "triggers": ["find", "scan", "hunt", "alpha"], + }, + "tracer": { + "name": "TRACER", + "role": "Forensic Investigator", + "tier": "T1", + "triggers": ["trace", "investigate", "wallet"], + }, + "cipher": { + "name": "CIPHER", + "role": "Contract Auditor", + "tier": "T1", + "triggers": ["audit", "security", "contract"], + }, + "sentinel": { + "name": "SENTINEL", + "role": "Rug Detector", + "tier": "T2", + "triggers": ["monitor", "watch", "alert", "rug"], + }, + "chronicler": { + "name": "CHRONICLER", + "role": "Investigative Reporter", + "tier": "T2", + "triggers": ["write", "document", "report"], + }, + "forge": { + "name": "FORGE", + "role": "Implementation Architect", + "tier": "T1", + "triggers": ["code", "implement", "build"], + }, + "relay": { + "name": "RELAY", + "role": "Communications Coordinator", + "tier": "T3", + "triggers": ["format", "relay", "dispatch"], + }, +} + + +@router.get("/agents") +async def list_agents(): + return {"agents": AGENTS, "total": len(AGENTS)} + + +@router.get("/agents/{agent_id}") +async def get_agent(agent_id: str): + agent = AGENTS.get(agent_id) + if not agent: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + return agent + + +@router.post("/agents/{agent_id}/command") +async def agent_command(agent_id: str, data: dict): + agent = AGENTS.get(agent_id) + if not agent: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + command = data.get("command", "") + return { + "agent": agent["name"], + "role": agent["role"], + "command": command, + "status": "queued", + "timestamp": datetime.now(UTC).isoformat(), + } + + +# ═══════════════════════════════════════════════════════════ +# MCP SERVERS - Multi-chain data gateways +# ═══════════════════════════════════════════════════════════ +@router.get("/mcp/servers") +async def mcp_servers_list(): + return { + "servers": { + "dexpaprika": "Real-time DEX data for 5M+ tokens across 20+ chains", + "solana": "Solana RPC - wallet balances, token prices, DeFi yields", + "dexscreener": "DEX pair data, token info, market stats", + "defillama": "DeFi TVL, protocols, yields, fees", + "coingecko": "13K+ tokens, global stats, historical data", + "helius": "Enhanced Solana RPC - parsed txs, webhooks", + "goplus": "Multi-chain token security - 700K+ tokens scanned", + "rugcheck": "Solana token safety audit", + }, + "status": "operational", + } + + +# ═══════════════════════════════════════════════════════════ +# SENTIMENT - Crypto market sentiment analysis +# ═══════════════════════════════════════════════════════════ +@router.get("/sentiment/market") +async def sentiment_market(): + try: + from app.ml_anomaly import AnomalyDetector + + detector = AnomalyDetector() + anomalies = detector.detect_market_anomalies() + return {"anomalies": anomalies, "timestamp": datetime.now(UTC).isoformat()} + except Exception: + # Fallback to fear & greed + import httpx + + async with httpx.AsyncClient(timeout=8) as c: + r = await c.get("https://api.alternative.me/fng/?limit=1") + if r.status_code == 200: + data = r.json().get("data", [{}])[0] + return { + "sentiment": { + "fear_greed_index": int(data.get("value", 50)), + "classification": data.get("value_classification", "Neutral"), + }, + "source": "alternative.me", + "timestamp": datetime.now(UTC).isoformat(), + } + return {"error": "Sentiment data unavailable"} + + +@router.get("/sentiment/token/{address}") +async def sentiment_token(address: str): + # On-chain sentiment from buy/sell ratio + try: + import httpx + + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"https://api.dexscreener.com/latest/dex/tokens/{address}") + if r.status_code == 200: + pairs = r.json().get("pairs", []) + if pairs: + p = pairs[0] + buys = p.get("txns", {}).get("h24", {}).get("buys", 0) + sells = p.get("txns", {}).get("h24", {}).get("sells", 0) + total = buys + sells + buy_ratio = buys / total if total > 0 else 0.5 + sentiment = "bullish" if buy_ratio > 0.6 else ("bearish" if buy_ratio < 0.4 else "neutral") + return { + "token": address, + "sentiment": sentiment, + "buy_ratio": round(buy_ratio, 3), + "buys_24h": buys, + "sells_24h": sells, + "source": "dexscreener", + } + except Exception: + pass + return {"token": address, "sentiment": "unknown"} + + +# ═══════════════════════════════════════════════════════════ +# NANSEN - Wallet labels, smart money, token flow +# ═══════════════════════════════════════════════════════════ +@router.get("/nansen/labels/{address}") +async def nansen_labels(address: str): + try: + from app.nansen_connector import get_wallet_labels + + result = get_wallet_labels(address) + return {"address": address, "labels": result, "source": "nansen"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/nansen/smart-money") +async def nansen_smart_money(): + try: + from app.nansen_connector import get_smart_money + + result = get_smart_money() + return {"smart_money": result, "source": "nansen"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/nansen/activity/{address}") +async def nansen_activity(address: str): + try: + from app.nansen_connector import get_wallet_activity + + result = get_wallet_activity(address) + return {"address": address, "activity": result, "source": "nansen"} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# MEMPOOL - Bitcoin mempool monitoring +# ═══════════════════════════════════════════════════════════ +@router.get("/mempool/status") +async def mempool_status(): + try: + import httpx + + async with httpx.AsyncClient(timeout=8) as c: + r = await c.get("https://mempool.space/api/v1/fees/recommended") + if r.status_code == 200: + fees = r.json() + r2 = await c.get("https://mempool.space/api/mempool") + mempool = r2.json() if r2.status_code == 200 else {} + return { + "fees": fees, + "mempool_tx_count": mempool.get("count", 0), + "mempool_size_mb": round(mempool.get("vsize", 0) / 1_000_000, 2), + "source": "mempool.space", + "timestamp": datetime.now(UTC).isoformat(), + } + except Exception: + pass + return {"error": "Mempool data unavailable"} + + +# ═══════════════════════════════════════════════════════════ +# COINGECKO - Price data, trending, global metrics +# ═══════════════════════════════════════════════════════════ +@router.get("/coingecko/ping") +async def coingecko_ping(): + try: + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + result = await cg.ping() + return {"ping": result, "source": "coingecko"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/coingecko/trending") +async def coingecko_trending(): + try: + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + result = await cg.get_trending() + return {"trending": result, "source": "coingecko"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/coingecko/markets") +async def coingecko_markets(vs_currency: str = "usd", per_page: int = 50): + try: + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + result = await cg.get_market_overview(vs_currency=vs_currency, per_page=per_page) + return {"markets": result, "vs_currency": vs_currency, "source": "coingecko"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/coingecko/price/{coin_id}") +async def coingecko_price(coin_id: str): + try: + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + result = await cg.get_token_price(coin_id) + return {"coin_id": coin_id, "price": result, "source": "coingecko"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/coingecko/global") +async def coingecko_global(): + try: + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + result = await cg.get_global_metrics() + return {"global": result, "source": "coingecko"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/coingecko/coin/{coin_id}") +async def coingecko_coin(coin_id: str): + try: + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + result = await cg.get_token_detail(coin_id) + return {"coin_id": coin_id, "coin": result, "source": "coingecko"} + except Exception as e: + return {"error": str(e)} diff --git a/app/_archive/legacy_2026_07/analytics.py b/app/_archive/legacy_2026_07/analytics.py new file mode 100644 index 0000000..d8ff21c --- /dev/null +++ b/app/_archive/legacy_2026_07/analytics.py @@ -0,0 +1,258 @@ +""" +RMI Analytics API Router +========================== +REST API for real-time analytics and dashboard data. + +Endpoints: + GET /api/v1/analytics/metrics - List all metrics + GET /api/v1/analytics/metrics/{name} - Get metric data + POST /api/v1/analytics/metrics/{name} - Record metric + GET /api/v1/analytics/dashboards - List dashboards + GET /api/v1/analytics/dashboards/{id} - Get dashboard data + POST /api/v1/analytics/dashboards - Create dashboard + GET /api/v1/analytics/trends/{metric} - Get trend analysis + GET /api/v1/analytics/stats - System stats + GET /api/v1/analytics/prometheus - Prometheus export + GET /api/v1/analytics/export/{metric} - Export metric + GET /api/v1/analytics/realtime/{dashboard} - WebSocket-compatible data +""" + +import logging +import os +import time +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.admin_backend import AuditLogger, require_admin +from app.analytics_engine import AnalyticsEngine, DashboardWidget, get_analytics_engine + +logger = logging.getLogger("analytics_api") + +router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"]) + +# ── Models ──────────────────────────────────────────────────── + + +class RecordMetricRequest(BaseModel): + value: float + labels: dict[str, str] | None = None + + +class CreateDashboardRequest(BaseModel): + name: str + description: str = "" + + +class AddWidgetRequest(BaseModel): + widget_type: str = Field(..., description="line, bar, gauge, counter, table, pie") + title: str + metric_name: str + width: int = 6 + height: int = 4 + refresh_interval: int = 30 + config: dict[str, Any] | None = None + + +# ── Helper ──────────────────────────────────────────────────── + + +def _get_engine() -> AnalyticsEngine: + return get_analytics_engine() + + +# ── Endpoints ───────────────────────────────────────────────── + + +@router.get("/metrics") +async def list_metrics(request: Request): + """List all tracked metrics.""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + metrics = [] + for name in engine.get_metric_names(): + metric = engine.get_metric(name) + if metric: + metrics.append(metric.to_dict()) + + return {"metrics": metrics, "total": len(metrics)} + + +@router.get("/metrics/{name}") +async def get_metric(request: Request, name: str, limit: int = 100): + """Get metric data points.""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + metric = engine.get_metric(name) + if not metric: + raise HTTPException(status_code=404, detail="Metric not found") + + points = [{"timestamp": p.timestamp, "value": p.value, "labels": p.labels} for p in metric.points[-limit:]] + + return { + "name": metric.name, + "description": metric.description, + "unit": metric.unit, + "latest": metric.latest(), + "avg_1m": metric.avg(60), + "trend": metric.trend(), + "points": points, + } + + +@router.post("/metrics/{name}") +async def record_metric(request: Request, name: str, body: RecordMetricRequest): + """Record a metric data point.""" + # Allow public recording for system metrics (from middleware) + engine = _get_engine() + engine.record_metric(name, body.value, body.labels or {}) + return {"success": True} + + +@router.get("/dashboards") +async def list_dashboards(request: Request): + """List all dashboards.""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + dashboards = engine.list_dashboards() + return { + "dashboards": [ + { + "dashboard_id": d.dashboard_id, + "name": d.name, + "description": d.description, + "widget_count": len(d.widgets), + "is_default": d.is_default, + } + for d in dashboards + ] + } + + +@router.get("/dashboards/{dashboard_id}") +async def get_dashboard_data(request: Request, dashboard_id: str): + """Get current data for a dashboard.""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + data = engine.get_dashboard_data(dashboard_id) + if "error" in data: + raise HTTPException(status_code=404, detail=data["error"]) + return data + + +@router.post("/dashboards") +async def create_dashboard(request: Request, body: CreateDashboardRequest): + """Create a new dashboard.""" + auth = await require_admin(request, "analytics.read") + admin = auth["admin"] + + engine = _get_engine() + dashboard = engine.create_dashboard(body.name, body.description, admin["id"]) + + await AuditLogger.log( + admin_id=admin["id"], + admin_email=admin["email"], + action="dashboard.create", + resource_type="dashboard", + resource_id=dashboard.dashboard_id, + ip_address=request.client.host if request.client else "", + user_agent=request.headers.get("user-agent", ""), + ) + + return { + "success": True, + "dashboard": {"dashboard_id": dashboard.dashboard_id, "name": dashboard.name}, + } + + +@router.post("/dashboards/{dashboard_id}/widgets") +async def add_widget(request: Request, dashboard_id: str, body: AddWidgetRequest): + """Add widget to dashboard.""" + auth = await require_admin(request, "analytics.read") + auth["admin"] + + engine = _get_engine() + widget = DashboardWidget( + widget_id=f"wid_{int(time.time())}_{os.urandom(4).hex()}", + widget_type=body.widget_type, + title=body.title, + metric_name=body.metric_name, + width=body.width, + height=body.height, + refresh_interval=body.refresh_interval, + config=body.config or {}, + ) + + result = engine.add_widget(dashboard_id, widget) + if not result: + raise HTTPException(status_code=404, detail="Dashboard not found") + + return {"success": True, "widget": {"widget_id": widget.widget_id, "title": widget.title}} + + +@router.get("/trends/{metric_name}") +async def get_trends(request: Request, metric_name: str, window: int = 60): + """Get trend analysis for a metric.""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + trends = engine.detect_trends(metric_name, window) + return trends + + +@router.get("/stats") +async def get_stats(request: Request): + """Get analytics system statistics.""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + return engine.get_system_stats() + + +@router.get("/prometheus") +async def prometheus_export(request: Request): + """Export metrics in Prometheus format.""" + # Public endpoint for Prometheus scraping + engine = _get_engine() + return engine.to_prometheus() + + +@router.get("/export/{metric_name}") +async def export_metric( + request: Request, + metric_name: str, + format: str = Query("json", regex="^(json|csv)$"), +): + """Export metric data.""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + data = engine.export_metric(metric_name, format) + if data is None: + raise HTTPException(status_code=404, detail="Metric not found") + + if format == "csv": + return {"data": data, "format": "csv"} + return data + + +@router.get("/realtime/{dashboard_id}") +async def realtime_data(request: Request, dashboard_id: str): + """Get real-time dashboard data (WebSocket-compatible).""" + await require_admin(request, "analytics.read") + + engine = _get_engine() + data = engine.get_dashboard_data(dashboard_id) + if "error" in data: + raise HTTPException(status_code=404, detail=data["error"]) + + # Add timestamp for client sync + data["server_time"] = time.time() + data["refresh_interval"] = 30 + + return data diff --git a/app/_archive/legacy_2026_07/analytics_storage.py b/app/_archive/legacy_2026_07/analytics_storage.py new file mode 100644 index 0000000..9bc13b8 --- /dev/null +++ b/app/_archive/legacy_2026_07/analytics_storage.py @@ -0,0 +1,449 @@ +""" +Historical Data Storage & Analytics Module +========================================== + +Provides persistent storage for: +- Transaction history +- Entity relationships +- Alert history +- Analytics queries + +Uses Redis for fast-access cache and optional PostgreSQL for long-term storage. +""" + +import json +import logging +from datetime import datetime +from typing import Any + +import redis +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +# ─── PERSISTENT MODELS ──────────────────────────────────────────────── + + +class TransactionRecord(BaseModel): + """Represents a transaction record.""" + + tx_hash: str + chain: str = "ethereum" + from_address: str + to_address: str + value: float = 0.0 + gas_used: int = 0 + gas_price: int = 0 + block_number: int = 0 + timestamp: datetime = Field(default_factory=datetime.utcnow) + status: int = 1 # 1 = success, 0 = failed + function_name: str = "" + token_transfers: list[dict[str, Any]] = Field(default_factory=list) + + +class WalletHistory(BaseModel): + """Complete transaction history for a wallet.""" + + wallet_address: str + chain: str = "ethereum" + transactions: list[TransactionRecord] = Field(default_factory=list) + first_seen: datetime = Field(default_factory=datetime.utcnow) + last_seen: datetime = Field(default_factory=datetime.utcnow) + total_tx_count: int = 0 + total_volume: float = 0.0 + unique_interactions: int = 0 + + +class EntityAlertRecord(BaseModel): + """Record of an alert for an entity.""" + + alert_id: str + entity_id: str + alert_type: str + severity: str + message: str + timestamp: datetime = Field(default_factory=datetime.utcnow) + metadata: dict[str, Any] = Field(default_factory=dict) + resolved: bool = False + + +# ─── REDIS STORAGE ──────────────────────────────────────────────────── + + +class RedisStorage: + """Redis-backed storage for historical data.""" + + def __init__(self, host: str = "localhost", port: int = 6379, db: int = 0): + self.host = host + self.port = port + self.db = db + self.client = redis.Redis(host=host, port=port, db=db, decode_responses=True) + logger.info(f"Connected to Redis at {host}:{port}/{db}") + + def key_prefix(self, category: str, identifier: str) -> str: + """Generate a Redis key.""" + return f"rmi:{category}:{identifier}" + + def save_transaction(self, tx: TransactionRecord) -> bool: + """Save a transaction record.""" + try: + key = self.key_prefix("transaction", tx.tx_hash) + self.client.setex( + key, + 86400 * 30, # 30 days TTL + tx.json(), + ) + + # Index by wallet + wallet_key = self.key_prefix("wallet", f"{tx.from_address}_{tx.chain}") + self.client.zadd(wallet_key, {tx.tx_hash: tx.timestamp.timestamp()}) + + # Update wallet history + self._update_wallet_history(tx.wallet_address if hasattr(tx, "wallet_address") else tx.from_address, tx) + + return True + except Exception as e: + logger.error(f"Failed to save transaction: {e}") + return False + + def get_transaction(self, tx_hash: str) -> dict[str, Any] | None: + """Get a transaction record.""" + try: + key = self.key_prefix("transaction", tx_hash) + data = self.client.get(key) + return json.loads(data) if data else None + except Exception as e: + logger.error(f"Failed to get transaction: {e}") + return None + + def get_wallet_history(self, wallet_address: str, chain: str = "ethereum") -> WalletHistory | None: + """Get complete wallet history.""" + try: + key = self.key_prefix("wallet", f"{wallet_address}_{chain}") + + if not self.client.exists(key): + return None + + tx_hashes = self.client.zrange(key, 0, -1, withscores=True) + + transactions = [] + for tx_hash, score in tx_hashes: + tx = self.get_transaction(tx_hash) + if tx: + tx_record = TransactionRecord(**tx) + tx_record.timestamp = datetime.fromtimestamp(score) + transactions.append(tx_record) + + # Sort by timestamp + transactions.sort(key=lambda x: x.timestamp) + + return WalletHistory( + wallet_address=wallet_address, + chain=chain, + transactions=transactions, + total_tx_count=len(transactions), + ) + except Exception as e: + logger.error(f"Failed to get wallet history: {e}") + return None + + def save_alert(self, alert: EntityAlertRecord) -> bool: + """Save an alert record.""" + try: + key = self.key_prefix("alert", alert.alert_id) + self.client.setex( + key, + 86400 * 90, # 90 days TTL + alert.json(), + ) + + # Index by entity + entity_key = self.key_prefix("entity_alert", alert.entity_id) + self.client.zadd(entity_key, {alert.alert_id: alert.timestamp.timestamp()}) + + return True + except Exception as e: + logger.error(f"Failed to save alert: {e}") + return False + + def get_entity_alerts(self, entity_id: str, limit: int = 100) -> list[dict[str, Any]]: + """Get alerts for an entity.""" + try: + key = self.key_prefix("entity_alert", entity_id) + + if not self.client.exists(key): + return [] + + alert_ids = self.client.zrange(key, 0, limit - 1) + + alerts = [] + for alert_id in alert_ids: + alert = self.get_alert(alert_id) + if alert: + alerts.append(alert) + + return alerts + except Exception as e: + logger.error(f"Failed to get entity alerts: {e}") + return [] + + def get_alert(self, alert_id: str) -> dict[str, Any] | None: + """Get an alert record.""" + try: + key = self.key_prefix("alert", alert_id) + data = self.client.get(key) + return json.loads(data) if data else None + except Exception as e: + logger.error(f"Failed to get alert: {e}") + return None + + def save_entity_relation(self, from_entity: str, to_entity: str, relation: str): + """Save an entity relationship.""" + try: + key = self.key_prefix("entity_relation", from_entity) + self.client.sadd(key, json.dumps({"to": to_entity, "relation": relation})) + except Exception as e: + logger.error(f"Failed to save entity relation: {e}") + + def get_entity_relations(self, entity_id: str) -> list[dict[str, str]]: + """Get relations for an entity.""" + try: + key = self.key_prefix("entity_relation", entity_id) + relations = self.client.smembers(key) + return [json.loads(r) for r in relations] + except Exception as e: + logger.error(f"Failed to get entity relations: {e}") + return [] + + def save_wallet_cluster(self, cluster_id: str, members: list[str], labels: list[str]): + """Save a wallet cluster.""" + try: + key = self.key_prefix("cluster", cluster_id) + data = { + "cluster_id": cluster_id, + "members": members, + "labels": labels, + "created_at": datetime.utcnow().isoformat(), + } + self.client.setex(key, 86400 * 365, json.dumps(data)) # 1 year TTL + except Exception as e: + logger.error(f"Failed to save cluster: {e}") + + def get_wallet_cluster(self, cluster_id: str) -> dict[str, Any] | None: + """Get a wallet cluster.""" + try: + key = self.key_prefix("cluster", cluster_id) + data = self.client.get(key) + return json.loads(data) if data else None + except Exception as e: + logger.error(f"Failed to get cluster: {e}") + return None + + def get_or_create_wallet_history(self, wallet_address: str, chain: str = "ethereum") -> WalletHistory: + """Get or create wallet history.""" + history = self.get_wallet_history(wallet_address, chain) + if history is None: + history = WalletHistory(wallet_address=wallet_address, chain=chain) + return history + + def _update_wallet_history(self, wallet_address: str, tx: TransactionRecord): + """Update wallet history metadata.""" + history = self.get_or_create_wallet_history(wallet_address, tx.chain) + + # Update last seen + history.last_seen = tx.timestamp + + # Update total volume + history.total_volume += tx.value + + # Update interaction count + if tx.to_address not in [t.to_address for t in history.transactions]: + history.unique_interactions += 1 + + # Update transaction count + history.total_tx_count = len(history.transactions) + 1 + + +# ─── DATABASE WRAPPER ───────────────────────────────────────────────── + + +class AnalyticsDatabase: + """Database wrapper for analytics queries.""" + + def __init__(self, redis_host: str = "localhost", redis_port: int = 6379, redis_db: int = 0): + self.redis = RedisStorage(host=redis_host, port=redis_port, db=redis_db) + + def store_transaction(self, tx: TransactionRecord) -> bool: + """Store a transaction.""" + return self.redis.save_transaction(tx) + + def store_entity_alert(self, alert: EntityAlertRecord) -> bool: + """Store an entity alert.""" + return self.redis.save_alert(alert) + + def store_entity_relation(self, from_entity: str, to_entity: str, relation: str): + """Store an entity relationship.""" + self.redis.save_entity_relation(from_entity, to_entity, relation) + + def store_wallet_cluster(self, cluster_id: str, members: list[str], labels: list[str]): + """Store a wallet cluster.""" + self.redis.save_wallet_cluster(cluster_id, members, labels) + + def get_wallet_history(self, wallet_address: str, chain: str = "ethereum") -> WalletHistory | None: + """Get wallet history.""" + return self.redis.get_wallet_history(wallet_address, chain) + + def get_entity_alerts(self, entity_id: str, limit: int = 100) -> list[dict[str, Any]]: + """Get entity alerts.""" + return self.redis.get_entity_alerts(entity_id, limit) + + def get_entity_relations(self, entity_id: str) -> list[dict[str, str]]: + """Get entity relations.""" + return self.redis.get_entity_relations(entity_id) + + def get_wallet_cluster(self, cluster_id: str) -> dict[str, Any] | None: + """Get wallet cluster.""" + return self.redis.get_wallet_cluster(cluster_id) + + def get_transaction(self, tx_hash: str) -> dict[str, Any] | None: + """Get transaction by hash.""" + return self.redis.get_transaction(tx_hash) + + # ─── ANALYTICS QUERIES ──────────────────────────────────────────── + + def get_wallet_activity_summary(self, wallet_address: str, chain: str = "ethereum") -> dict[str, Any]: + """Get activity summary for a wallet.""" + history = self.redis.get_wallet_history(wallet_address, chain) + + if history is None or not history.transactions: + return { + "wallet_address": wallet_address, + "chain": chain, + "total_transactions": 0, + "total_volume": 0, + "first_seen": None, + "last_seen": None, + "unique_contracts": 0, + } + + return { + "wallet_address": wallet_address, + "chain": chain, + "total_transactions": len(history.transactions), + "total_volume": history.total_volume, + "first_seen": history.first_seen.isoformat(), + "last_seen": history.last_seen.isoformat(), + "unique_contracts": history.unique_interactions, + } + + def get_wallet_similarity(self, address1: str, address2: str) -> dict[str, Any]: + """Calculate similarity between two wallets based on interactions.""" + history1 = self.redis.get_wallet_history(address1, "ethereum") + history2 = self.redis.get_wallet_history(address2, "ethereum") + + if not history1 or not history2 or not history1.transactions or not history2.transactions: + return {"similarity": 0, "shared_contracts": [], "reason": "Insufficient data"} + + # Get unique contract interactions + contracts1 = {t.to_address for t in history1.transactions} + contracts2 = {t.to_address for t in history2.transactions} + + # Calculate Jaccard similarity + intersection = contracts1 & contracts2 + union = contracts1 | contracts2 + + jaccard = len(intersection) / len(union) if union else 0 + + return { + "similarity": round(jaccard, 4), + "shared_contracts": list(intersection)[:10], # Top 10 + "total_shared": len(intersection), + "unique_1": len(contracts1 - contracts2), + "unique_2": len(contracts2 - contracts1), + } + + def get_entity_network(self, entity_id: str, depth: int = 2) -> dict[str, Any]: + """Get entity's network of connected entities.""" + relations = self.redis.get_entity_relations(entity_id) + + network = {"entity_id": entity_id, "direct_relations": relations, "depth": depth} + + # If depth > 0, get relations of related entities + if depth > 0: + related_entities = [r["to"] for r in relations] + network["related_entities"] = related_entities + + if depth >= 2: + network["second_degree"] = [] + for related in related_entities: + second_degree = self.redis.get_entity_relations(related) + network["second_degree"].extend(second_degree) + + return network + + +# ─── SINGLETON INSTANCE ─────────────────────────────────────────────── + +_db_instance: AnalyticsDatabase | None = None + + +def get_analytics_database( + redis_host: str | None = None, redis_port: int | None = None, redis_db: int | None = None +) -> AnalyticsDatabase: + """Get the analytics database instance.""" + global _db_instance + + if _db_instance is None: + _db_instance = AnalyticsDatabase( + redis_host=redis_host or "localhost", + redis_port=redis_port or 6379, + redis_db=redis_db or 0, + ) + + return _db_instance + + +# ─── INITIAL DATA IMPORT ────────────────────────────────────────────── + + +def initialize_analytics(): + """Initialize analytics storage with default data.""" + get_analytics_database() + + # Clear old data (optional - for fresh starts) + # db.redis.client.flushdb() + + logger.info("Analytics database initialized") + + +if __name__ == "__main__": + # Test the analytics database + db = get_analytics_database() + + # Create a test transaction + tx = TransactionRecord( + tx_hash="0x" + "a" * 64, + chain="ethereum", + from_address="0x1234567890123456789012345678901234567890", + to_address="0xabcdef1234567890abcdef1234567890abcdef12", + value=1.5, + gas_used=21000, + block_number=1000000, + function_name="transfer", + ) + + db.store_transaction(tx) + + # Get the transaction back + stored = db.get_transaction(tx.tx_hash) + print(f"Stored transaction: {stored}") + + # Get wallet history + history = db.get_wallet_history(tx.from_address, "ethereum") + if history: + print(f"Wallet history: {history.total_tx_count} transactions") + + # Get activity summary + summary = db.get_wallet_activity_summary(tx.from_address, "ethereum") + print(f"Activity summary: {summary}") diff --git a/app/_archive/legacy_2026_07/api_versioning.py b/app/_archive/legacy_2026_07/api_versioning.py new file mode 100644 index 0000000..62a2a8a --- /dev/null +++ b/app/_archive/legacy_2026_07/api_versioning.py @@ -0,0 +1,49 @@ +"""API versioning router for RMI Backend.""" + +from fastapi import APIRouter, Request + +router = APIRouter(prefix="", tags=["api-versioning"]) + +DEPRECATED_PATHS: dict[str, str] = {} + + +@router.get("/api/version") +async def api_version(): + """Return current API version info.""" + return { + "versions": {"v1": {"status": "stable"}, "v2": {"status": "planned"}}, + "current": "v1", + "docs": "/docs", + } + + +@router.get("/api") +async def api_root(): + """API root with version links.""" + return { + "message": "RugMunch Intelligence API", + "versions": {"v1": "/api/v1/", "v2": "/api/v2/ (planned)"}, + "docs": {"swagger": "/docs", "redoc": "/redoc", "openapi": "/openapi.json"}, + "health": "/health", + "metrics": "/metrics", + } + + +def register_deprecation(path: str, new_path: str) -> None: + """Register a deprecated path for sunset headers.""" + DEPRECATED_PATHS[path] = new_path + + +def setup_deprecation_middleware(app): + """Add deprecation headers middleware to the FastAPI app.""" + + @app.middleware("http") + async def _deprecation(request: Request, call_next): + path = request.url.path + if path in DEPRECATED_PATHS: + response = await call_next(request) + response.headers["Deprecation"] = "true" + response.headers["Sunset"] = "Sat, 01 Jan 2027 00:00:00 GMT" + response.headers["Link"] = f'<{DEPRECATED_PATHS[path]}>; rel="successor-version"' + return response + return await call_next(request) diff --git a/app/_archive/legacy_2026_07/arbitrage.py b/app/_archive/legacy_2026_07/arbitrage.py new file mode 100644 index 0000000..6a6ea10 --- /dev/null +++ b/app/_archive/legacy_2026_07/arbitrage.py @@ -0,0 +1,97 @@ +"""Cross-chain arbitrage detector. Finds price discrepancies across 112 chains.""" + +import httpx +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/arbitrage", tags=["arbitrage"]) + +DEXSCREENER = "https://api.dexscreener.com/latest/dex/search" +CHAINS = ["ethereum", "bsc", "arbitrum", "base", "polygon", "avalanche", "optimism", "solana"] +STABLES = {"USDC", "USDT", "DAI", "BUSD", "USDD", "FRAX"} + + +async def _get_multi_chain_price(symbol: str) -> list[dict]: + """Get price across all chains from DexScreener.""" + listings = [] + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{DEXSCREENER}?q={symbol}") + if r.status_code == 200: + pairs = r.json().get("pairs", []) + for p in pairs[:50]: + chain = p.get("chainId", "unknown") + if chain in CHAINS or chain == "solana": + listings.append( + { + "chain": chain, + "price_usd": float(p.get("priceUsd", 0) or 0), + "liquidity_usd": p.get("liquidity", {}).get("usd", 0) or 0, + "dex": p.get("dexId", "?"), + "pair": p.get("pairAddress", ""), + } + ) + except Exception: + pass + return listings + + +@router.get("/find/{symbol}") +async def find_arbitrage(symbol: str, min_profit_pct: float = Query(0.5, ge=0.1), max_gas_usd: float = Query(50, ge=1)): + """Find arbitrage opportunities for a token across chains.""" + listings = await _get_multi_chain_price(symbol) + if len(listings) < 2: + return {"symbol": symbol, "opportunities": [], "note": "Need at least 2 chains with liquidity"} + + # Find min/max price + listings.sort(key=lambda l: l["price_usd"]) # noqa: E741 + cheapest = listings[0] + most_expensive = listings[-1] + + spread_pct = ( + ((most_expensive["price_usd"] - cheapest["price_usd"]) / cheapest["price_usd"] * 100) + if cheapest["price_usd"] > 0 + else 0 + ) + + opportunities = [] + if spread_pct >= min_profit_pct: + gross_profit = most_expensive["price_usd"] - cheapest["price_usd"] + net_profit = gross_profit - max_gas_usd + opportunities.append( + { + "buy_chain": cheapest["chain"], + "buy_price": cheapest["price_usd"], + "buy_dex": cheapest["dex"], + "sell_chain": most_expensive["chain"], + "sell_price": most_expensive["price_usd"], + "sell_dex": most_expensive["dex"], + "spread_pct": round(spread_pct, 2), + "gross_profit_per_unit": round(gross_profit, 4), + "estimated_gas": max_gas_usd, + "net_profit_estimate": round(net_profit, 2), + "viable": net_profit > 0, + } + ) + + return { + "symbol": symbol, + "chains_with_liquidity": len(listings), + "cheapest": {"chain": cheapest["chain"], "price": cheapest["price_usd"]}, + "most_expensive": {"chain": most_expensive["chain"], "price": most_expensive["price_usd"]}, + "max_spread_pct": round(spread_pct, 2), + "opportunities": opportunities, + } + + +@router.get("/scan") +async def scan_arbitrage(limit: int = Query(10, le=20)): + """Scan popular tokens for arbitrage opportunities.""" + popular = ["ETH", "USDC", "USDT", "MATIC", "ARB", "OP", "LINK", "UNI", "AAVE", "SNX", "CRV", "LDO"] + results = [] + for symbol in popular[:limit]: + opps = await find_arbitrage(symbol, min_profit_pct=0.3) + if opps.get("opportunities"): + results.append(opps) + + results.sort(key=lambda r: r["max_spread_pct"], reverse=True) + return {"scanned": len(popular[:limit]), "found": len(results), "opportunities": results} diff --git a/app/_archive/legacy_2026_07/auth_extensions.py b/app/_archive/legacy_2026_07/auth_extensions.py new file mode 100644 index 0000000..d738922 --- /dev/null +++ b/app/_archive/legacy_2026_07/auth_extensions.py @@ -0,0 +1,595 @@ +""" +RMI Auth Extensions - Password Reset, Profile Management, Multi-Chain Wallets +============================================================================= +Adds to existing /root/backend/app/auth.py: + - Password reset via email token + - Password change (authenticated) + - Profile update (display name, email, avatar) + - Multi-chain wallet linking/unlinking + - Wallet signature verification for EVM, Solana, Tron, BTC, Monero +""" + +import hashlib +import json +import logging +import os +import secrets +from datetime import datetime, timedelta + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, EmailStr, Field + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["auth-ext"]) + +# ── Config ── +JWT_SECRET = os.getenv("JWT_SECRET", "rmi-jwt-secret-change-me") +JWT_ALGORITHM = "HS256" +RESET_TOKEN_EXPIRY_HOURS = 24 + + +# ── Redis helper ── +# (These will be imported or we patch auth.py directly) + + +# ── Models ── +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str = Field(..., min_length=8) + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(..., min_length=8) + + +class UpdateProfileRequest(BaseModel): + display_name: str | None = None + avatar_url: str | None = None + + +class LinkWalletRequest(BaseModel): + address: str + chain: str + signature: str + message: str + + +class UnlinkWalletRequest(BaseModel): + address: str + chain: str + + +class WalletInfo(BaseModel): + address: str + chain: str + added_at: str + is_primary: bool = False + + +class ProfileResponse(BaseModel): + id: str + email: str + display_name: str + avatar_url: str | None = None + tier: str + role: str + wallets: list[WalletInfo] + created_at: str + xp: int = 0 + level: int = 1 + badges: list[str] = [] + + +# ── Password Reset ── + + +@router.post("/forgot-password") +async def forgot_password(req: ForgotPasswordRequest): + """Request password reset - sends email with reset token.""" + from app.auth import _get_user_by_email + + user = _get_user_by_email(req.email) + if not user: + # Return success even if email not found (prevents enumeration) + return { + "status": "ok", + "message": "If this email is registered, a reset link has been sent.", + } + + # Generate reset token + reset_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(reset_token.encode()).hexdigest() + + # Store token in Redis with expiry + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + r.setex(f"rmi:password_reset:{token_hash}", timedelta(hours=RESET_TOKEN_EXPIRY_HOURS), user["id"]) + + # TODO: Send actual email via your email router + # For now, log it (replace with email service) + reset_url = f"https://rugmunch.io/reset-password?token={reset_token}" + logger.info(f"[PASSWORD RESET] Email={req.email} URL={reset_url}") + + # If you have email_router.py: + try: + from app.email_router import send_email + + await send_email( + to=req.email, + subject="RugMunch Intelligence - Password Reset", + body=f"Click to reset your password: {reset_url}\n\nThis link expires in 24 hours.\n\nIf you didn't request this, ignore this email.", + html=f""" + +

Password Reset

+

Click below to reset your RugMunch Intelligence password:

+
Reset Password +

Expires in 24 hours. Didn't request this? Ignore this email.

+ + """, + ) + except Exception as e: + logger.warning(f"Email send failed: {e}") + + return {"status": "ok", "message": "If this email is registered, a reset link has been sent."} + + +@router.post("/reset-password") +async def reset_password(req: ResetPasswordRequest): + """Reset password using token from email.""" + from app.auth import _get_user, _save_user, hash_password + + token_hash = hashlib.sha256(req.token.encode()).hexdigest() + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + user_id = r.get(f"rmi:password_reset:{token_hash}") + + if not user_id: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + user = _get_user(user_id) + if not user: + raise HTTPException(status_code=400, detail="Invalid or expired reset token") + + # Update password + user["password_hash"] = hash_password(req.new_password) + user["updated_at"] = datetime.utcnow().isoformat() + _save_user(user) + + # Delete used token + r.delete(f"rmi:password_reset:{token_hash}") + + # Invalidate all existing sessions for this user + # (Optional: force re-login) + + return {"status": "ok", "message": "Password updated successfully. Please log in again."} + + +# ── Change Password (Authenticated) ── + + +@router.post("/change-password") +async def change_password(req: ChangePasswordRequest, request: Request): + """Change password while logged in.""" + from app.auth import _save_user, get_current_user, hash_password, verify_password + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + # Verify current password + if not user.get("password_hash"): + raise HTTPException(status_code=400, detail="No password set for this account") + + if not verify_password(req.current_password, user["password_hash"]): + raise HTTPException(status_code=401, detail="Current password is incorrect") + + # Update password + user["password_hash"] = hash_password(req.new_password) + user["updated_at"] = datetime.utcnow().isoformat() + _save_user(user) + + return {"status": "ok", "message": "Password changed successfully"} + + +# ── Profile Management ── + + +@router.get("/profile", response_model=ProfileResponse) +async def get_profile(request: Request): + """Get full user profile including linked wallets.""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + # Get linked wallets + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + wallets_raw = r.hget("rmi:user_wallets", user["id"]) + wallets = json.loads(wallets_raw) if wallets_raw else [] + + return { + "id": user["id"], + "email": user["email"], + "display_name": user.get("display_name", user["email"]), + "avatar_url": user.get("avatar_url"), + "tier": user.get("tier", "FREE"), + "role": user.get("role", "USER"), + "wallets": wallets, + "created_at": user.get("created_at"), + "xp": user.get("xp", 0), + "level": user.get("level", 1), + "badges": user.get("badges", []), + } + + +@router.patch("/profile") +async def update_profile(req: UpdateProfileRequest, request: Request): + """Update user profile (display name, avatar).""" + from app.auth import _save_user, get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + if req.display_name is not None: + user["display_name"] = req.display_name[:50] # Max 50 chars + if req.avatar_url is not None: + user["avatar_url"] = req.avatar_url[:500] # Max 500 chars + + user["updated_at"] = datetime.utcnow().isoformat() + _save_user(user) + + return {"status": "ok", "message": "Profile updated"} + + +# ── Multi-Chain Wallet Management ── + +CHAIN_CONFIG = { + "ethereum": {"name": "Ethereum", "type": "evm", "chain_id": 1}, + "base": {"name": "Base", "type": "evm", "chain_id": 8453}, + "arbitrum": {"name": "Arbitrum", "type": "evm", "chain_id": 42161}, + "optimism": {"name": "Optimism", "type": "evm", "chain_id": 10}, + "polygon": {"name": "Polygon", "type": "evm", "chain_id": 137}, + "bsc": {"name": "BSC", "type": "evm", "chain_id": 56}, + "avalanche": {"name": "Avalanche", "type": "evm", "chain_id": 43114}, + "fantom": {"name": "Fantom", "type": "evm", "chain_id": 250}, + "solana": {"name": "Solana", "type": "solana"}, + "tron": {"name": "Tron", "type": "tron"}, + "bitcoin": {"name": "Bitcoin", "type": "btc"}, + "monero": {"name": "Monero", "type": "xmr"}, + "litecoin": {"name": "Litecoin", "type": "ltc"}, + "dogecoin": {"name": "Dogecoin", "type": "doge"}, + "ripple": {"name": "XRP", "type": "xrp"}, +} + + +def verify_evm_signature(message: str, signature: str, address: str) -> bool: + """Verify EVM (Ethereum, Base, BSC, etc.) signature.""" + try: + from eth_account import Account + from eth_account.messages import encode_defunct + + message_encoded = encode_defunct(text=message) + recovered = Account.recover_message(message_encoded, signature=signature) + return recovered.lower() == address.lower() + except Exception as e: + logger.warning(f"EVM signature verification failed: {e}") + return False + + +def verify_solana_signature(message: str, signature: str, address: str) -> bool: + """Verify Solana signature.""" + try: + import base58 + import nacl.signing + + pubkey = base58.b58decode(address) + sig = base58.b58decode(signature) + verify_key = nacl.signing.VerifyKey(pubkey) + verify_key.verify(message.encode(), sig) + return True + except Exception as e: + logger.warning(f"Solana signature verification failed: {e}") + return False + + +def verify_tron_signature(message: str, signature: str, address: str) -> bool: + """Verify Tron signature (uses same curve as Ethereum).""" + try: + # Tron addresses start with T, need to convert to ETH format for verification + from eth_account import Account + from eth_account.messages import encode_defunct + + # Basic Tron address validation + if not address.startswith("T") or len(address) != 34: + return False + + # For Tron, we use the same ECDSA as Ethereum but address format differs + # In production, use tronpy or proper conversion + message_encoded = encode_defunct(text=message) + Account.recover_message(message_encoded, signature=signature) + + # Convert recovered ETH address to Tron base58 (simplified) + # Full implementation needs tronpy + return True # Stub - replace with full Tron verification + except Exception as e: + logger.warning(f"Tron signature verification failed: {e}") + return False + + +def verify_btc_signature(message: str, signature: str, address: str) -> bool: + """Verify Bitcoin signature.""" + try: + # Use bitcoinlib or ecdsa for full verification + # This is a simplified stub + return len(signature) >= 20 and len(address) >= 26 + except Exception as e: + logger.warning(f"BTC signature verification failed: {e}") + return False + + +def verify_monero_signature(message: str, signature: str, address: str) -> bool: + """Verify Monero signature.""" + try: + # Monero uses ed25519 + ring signatures + # Full verification requires monero-python or similar + return len(address) >= 95 and address.startswith("4") + except Exception as e: + logger.warning(f"Monero signature verification failed: {e}") + return False + + +def verify_wallet_signature_chain(message: str, signature: str, address: str, chain: str) -> bool: + """Route signature verification to the correct chain handler.""" + chain_lower = chain.lower() + config = CHAIN_CONFIG.get(chain_lower) + + if not config: + logger.warning(f"Unknown chain for signature verification: {chain}") + return False + + chain_type = config["type"] + + if chain_type == "evm": + return verify_evm_signature(message, signature, address) + elif chain_type == "solana": + return verify_solana_signature(message, signature, address) + elif chain_type == "tron": + return verify_tron_signature(message, signature, address) + elif chain_type == "btc": + return verify_btc_signature(message, signature, address) + elif chain_type == "xmr": + return verify_monero_signature(message, signature, address) + else: + # Generic fallback + return len(signature) >= 60 and len(address) >= 20 + + +@router.get("/wallet/chains") +async def list_supported_chains(): + """List all supported wallet chains for connection.""" + return { + "chains": [ + { + "id": k, + "name": v["name"], + "type": v["type"], + "chain_id": v.get("chain_id"), + } + for k, v in CHAIN_CONFIG.items() + ] + } + + +@router.post("/wallet/link") +async def link_wallet(req: LinkWalletRequest, request: Request): + """Link a new wallet to the user's account.""" + from app.auth import _save_user, get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + # Validate chain + if req.chain.lower() not in CHAIN_CONFIG: + raise HTTPException(status_code=400, detail=f"Unsupported chain: {req.chain}") + + # Verify signature + if not verify_wallet_signature_chain(req.message, req.signature, req.address, req.chain): + raise HTTPException(status_code=401, detail="Invalid wallet signature") + + # Get existing wallets + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + wallets_key = "rmi:user_wallets" + wallets_raw = r.hget(wallets_key, user["id"]) + wallets = json.loads(wallets_raw) if wallets_raw else [] + + # Check if already linked + for w in wallets: + if w["address"].lower() == req.address.lower() and w["chain"].lower() == req.chain.lower(): + raise HTTPException(status_code=400, detail="Wallet already linked") + + # Add wallet + is_primary = len(wallets) == 0 + new_wallet = { + "address": req.address, + "chain": req.chain.lower(), + "added_at": datetime.utcnow().isoformat(), + "is_primary": is_primary, + } + wallets.append(new_wallet) + + r.hset(wallets_key, user["id"], json.dumps(wallets)) + + # Update user's primary wallet if first + if is_primary: + user["wallet_address"] = req.address + user["wallet_chain"] = req.chain.lower() + _save_user(user) + + return {"status": "ok", "wallet": new_wallet} + + +@router.post("/wallet/unlink") +async def unlink_wallet(req: UnlinkWalletRequest, request: Request): + """Unlink a wallet from the user's account.""" + from app.auth import _save_user, get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + wallets_key = "rmi:user_wallets" + wallets_raw = r.hget(wallets_key, user["id"]) + wallets = json.loads(wallets_raw) if wallets_raw else [] + + # Find and remove + new_wallets = [ + w + for w in wallets + if not (w["address"].lower() == req.address.lower() and w["chain"].lower() == req.chain.lower()) + ] + + if len(new_wallets) == len(wallets): + raise HTTPException(status_code=404, detail="Wallet not found") + + # Update primary if needed + if new_wallets and not any(w.get("is_primary") for w in new_wallets): + new_wallets[0]["is_primary"] = True + user["wallet_address"] = new_wallets[0]["address"] + user["wallet_chain"] = new_wallets[0]["chain"] + elif not new_wallets: + user["wallet_address"] = None + user["wallet_chain"] = None + + r.hset(wallets_key, user["id"], json.dumps(new_wallets)) + _save_user(user) + + return {"status": "ok", "message": "Wallet unlinked"} + + +@router.get("/wallet/list") +async def list_wallets(request: Request): + """List all wallets linked to the current user.""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + wallets_raw = r.hget("rmi:user_wallets", user["id"]) + wallets = json.loads(wallets_raw) if wallets_raw else [] + + return {"wallets": wallets} + + +# ── Privacy Settings ── + + +class PrivacySettings(BaseModel): + show_email: bool = False + show_wallets: bool = False + show_xp_level: bool = True + show_badges: bool = True + allow_direct_messages: bool = True + profile_visibility: str = "public" # public, unlisted, private + + +@router.get("/privacy-settings") +async def get_privacy_settings(request: Request): + """Get user privacy settings.""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + settings = user.get( + "privacy_settings", + { + "show_email": False, + "show_wallets": False, + "show_xp_level": True, + "show_badges": True, + "allow_direct_messages": True, + "profile_visibility": "public", + }, + ) + + return {"settings": settings} + + +@router.patch("/privacy-settings") +async def update_privacy_settings(req: PrivacySettings, request: Request): + """Update user privacy settings.""" + from app.auth import _save_user, get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + user["privacy_settings"] = req.model_dump() + user["updated_at"] = datetime.utcnow().isoformat() + _save_user(user) + + return { + "status": "ok", + "message": "Privacy settings updated", + "settings": user["privacy_settings"], + } + + +# ── Account Deletion (GDPR Compliant) ── + + +class DeleteAccountRequest(BaseModel): + password: str + confirm_delete: bool = True + + +@router.post("/delete-account") +async def delete_account(req: DeleteAccountRequest, request: Request): + """ + Permanently delete user account and all associated data (GDPR compliant). + Requires password confirmation. + """ + from app.auth import _delete_user, get_current_user, verify_password + from app.core.redis import get_redis + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + if not req.confirm_delete: + raise HTTPException(status_code=400, detail="You must confirm account deletion") + + # Verify password for security + if user.get("password_hash") and not verify_password(req.password, user["password_hash"]): + raise HTTPException(status_code=401, detail="Incorrect password") + + user_id = user["id"] + email = user.get("email", "unknown") + + # 1. Delete user from main store + _delete_user(user_id) + + # 2. Delete linked wallets from Redis + r = get_redis() + r.hdel("rmi:user_wallets", user_id) + + # 3. Delete any password reset tokens + # (They will expire naturally, but we can clean up if we track them) + + # 4. Log the deletion for audit purposes (anonymized) + logger.warning(f"[ACCOUNT DELETED] User ID={user_id[:8]}... Email={email}") + + return { + "status": "ok", + "message": "Account permanently deleted. All associated data has been removed.", + } diff --git a/app/_archive/legacy_2026_07/auto_healing.py b/app/_archive/legacy_2026_07/auto_healing.py new file mode 100644 index 0000000..3e6e8b1 --- /dev/null +++ b/app/_archive/legacy_2026_07/auto_healing.py @@ -0,0 +1,84 @@ +"""Auto-Healing Infrastructure - self-diagnosing, self-repairing platform.""" + +import os +from datetime import UTC, datetime + +import httpx +import psutil +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/health", tags=["auto-healing"]) + +BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000") +CHECKS = [ + ("redis", "http://localhost:6379", "Redis"), + ("postgres", "http://localhost:5432", "Postgres"), + ("ollama", "http://localhost:11434/api/tags", "Ollama"), + ("backend", f"{BACKEND}/health", "RMI Backend"), + ("clickhouse", "http://localhost:8123/ping", "ClickHouse"), + ("qdrant", "http://localhost:6333/health", "Qdrant"), +] + + +@router.get("/full") +async def full_health_check(): + """Comprehensive system health check - all services.""" + results = {} + all_healthy = True + + async with httpx.AsyncClient(timeout=5) as c: + for name, url, label in CHECKS: + try: + r = await c.get(url) + healthy = r.status_code < 500 + except Exception: + healthy = False + + results[name] = {"service": label, "healthy": healthy, "last_checked": datetime.now(UTC).isoformat()} + if not healthy: + all_healthy = False + + # System resources + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + + return { + "overall": "HEALTHY" if all_healthy else "DEGRADED", + "timestamp": datetime.now(UTC).isoformat(), + "services": results, + "system": { + "cpu_pct": psutil.cpu_percent(interval=1), + "memory_pct": mem.percent, + "memory_available_gb": round(mem.available / (1024**3), 1), + "disk_pct": disk.percent, + "disk_free_gb": round(disk.free / (1024**3), 1), + "swap_pct": psutil.swap_memory().percent, + }, + "alerts": _check_alerts(mem, disk), + } + + +def _check_alerts(mem, disk) -> list: + """Generate alerts for concerning metrics.""" + alerts = [] + if mem.percent > 90: + alerts.append({"level": "CRITICAL", "msg": f"Memory usage at {mem.percent}% - consider restarting services"}) + elif mem.percent > 80: + alerts.append({"level": "WARNING", "msg": f"Memory usage at {mem.percent}%"}) + if disk.percent > 90: + alerts.append({"level": "CRITICAL", "msg": f"Disk usage at {disk.percent}% - prune logs immediately"}) + elif disk.percent > 80: + alerts.append({"level": "WARNING", "msg": f"Disk usage at {disk.percent}%"}) + return alerts + + +@router.post("/heal/{service}") +async def heal_service(service: str): + """Attempt to heal a failed service.""" + if service == "backend": + os.system("docker restart rmi-backend") + return {"service": service, "action": "restarted", "note": "Backend restart initiated"} + if service == "redis": + os.system("docker restart rmi-redis") + return {"service": service, "action": "restarted"} + return {"service": service, "action": "unknown", "note": "Manual intervention required"} diff --git a/app/_archive/legacy_2026_07/billing.py b/app/_archive/legacy_2026_07/billing.py new file mode 100644 index 0000000..f056a24 --- /dev/null +++ b/app/_archive/legacy_2026_07/billing.py @@ -0,0 +1,50 @@ +"""Billing module skeleton - x402 crypto payments ready, Stripe slot for later.""" + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/billing", tags=["billing"]) + +# x402 pricing tiers (live) +TIERS = { + "free": {"price_usd": 0, "requests_per_day": 100, "features": ["basic_scan", "market_data"]}, + "pro": { + "price_usd": 19.99, + "requests_per_day": 10000, + "features": ["deep_scan", "signals", "whale_alerts", "api_access"], + }, + "enterprise": { + "price_usd": 499, + "requests_per_day": 999999, + "features": ["all", "custom_models", "white_label", "support"], + }, +} + +# Stripe placeholder - ready when you add keys +STRIPE_ENABLED = False + + +class UpgradeRequest(BaseModel): + user_id: str + tier: str + payment_method: str = "x402" # x402 | stripe + tx_signature: str | None = None + + +@router.get("/tiers") +async def get_tiers(): + return {"tiers": TIERS, "payment_methods": ["x402", "stripe (coming soon)"]} + + +@router.post("/upgrade") +async def upgrade_tier(req: UpgradeRequest): + if req.tier not in TIERS: + return {"error": "Invalid tier"} + if req.payment_method == "stripe" and not STRIPE_ENABLED: + return {"error": "Stripe not configured yet. Use x402 crypto payments."} + return {"status": "upgraded", "user": req.user_id, "tier": req.tier, "payment": req.payment_method} + + +@router.get("/usage/{user_id}") +async def get_usage(user_id: str): + return {"user_id": user_id, "tier": "free", "requests_today": 0, "limit": 100} diff --git a/app/_archive/legacy_2026_07/brand_marketing_generator.py b/app/_archive/legacy_2026_07/brand_marketing_generator.py new file mode 100644 index 0000000..a9ced4d --- /dev/null +++ b/app/_archive/legacy_2026_07/brand_marketing_generator.py @@ -0,0 +1,259 @@ +""" +RMI Marketing Content Generator - Creates all marketing graphics with EXACT brand compliance. +Uses detective character, purple/gold colors, circular frames. +NO DEVIATION from brand guidelines. +""" + +import logging +import os +from datetime import UTC, datetime + +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + +# ── Paths ──────────────────────────────────────────────────── + +CHARACTER_PATH = "/root/backend/assets/characters/detective-character.png" +LOGO_PATH = "/root/backend/assets/logos/rugmunch-logo.jpg" +OUTPUT_DIR = "/root/backend/assets/marketing_generated" + +os.makedirs(OUTPUT_DIR, exist_ok=True) + +# ── Brand Colors (EXACT - NO DEVIATION) ────────────────────── + +BRAND = { + "purple": "#2D1B36", + "purple_light": "#3D2346", + "gold": "#D4AF37", + "gold_light": "#F1D475", + "gold_dark": "#AA8828", + "cyan": "#00FFFF", + "white": "#FFFFFF", + "green_alert": "#00FF88", # ONLY for rugpulls/scams + "red_danger": "#FF4444", # ONLY for losses +} + +# ── Marketing Content Types ────────────────────────────────── + +CONTENT_TYPES = { + "feature_showcase": { + "title": "Feature Showcase", + "size": (1200, 675), + "bg_color": BRAND["purple"], + "text_color": BRAND["gold"], + "include_character": True, + }, + "stats_announcement": { + "title": "Stats/Metrics", + "size": (1200, 675), + "bg_color": BRAND["purple"], + "text_color": BRAND["gold"], + "include_character": False, + }, + "launch_announcement": { + "title": "Launch Announcement", + "size": (1200, 675), + "bg_color": BRAND["purple"], + "text_color": BRAND["gold"], + "include_character": True, + }, + "platform_overview": { + "title": "Platform Overview", + "size": (1920, 1080), + "bg_color": BRAND["purple"], + "text_color": BRAND["gold"], + "include_character": True, + }, + "premium_promo": { + "title": "Premium Tier Promo", + "size": (1080, 1080), + "bg_color": BRAND["purple"], + "text_color": BRAND["gold"], + "include_character": True, + }, +} + +# ── Content Copy Templates ─────────────────────────────────── + +MARKETING_COPY = { + "smart_money": { + "headline": "SMART MONEY TRACKING", + "subhead": "Follow The Whales, Profit Like Them", + "body": "Track 1,000+ labeled whale wallets in real-time. See what VCs and funds buy BEFORE the pump.", + "stats": ["1,000+ Wallets", "Real-Time Alerts", "8 Chains"], + }, + "rug_detection": { + "headline": "RUGPULL DETECTION", + "subhead": "2-Minute Alert Speed", + "body": "7-method detection engine catches rugs before you ape. 2,530+ scams tracked.", + "stats": ["7 Methods", "2-Min Alerts", "2,530+ Scams"], + }, + "kol_scorecards": { + "headline": "KOL SCORECARDS", + "subhead": "No More Fake Gurus", + "body": "500+ influencers tracked. Verified on-chain performance. Win rate transparency.", + "stats": ["500+ KOLs", "On-Chain Verified", "Win Rates"], + }, + "platform_launch": { + "headline": "RUG MUNCH INTELLIGENCE", + "subhead": "Track Smart Money. Avoid Rugs. Find Alpha.", + "body": "40+ features live. Real-time alerts. Professional crypto intelligence.", + "cta": "Join Free - rugmunch.io", + }, + "premium_tier": { + "headline": "PREMIUM INTELLIGENCE", + "subhead": "For Serious Traders Only", + "body": "Smart money tracking. Insider alerts. Exchange flows. Cluster analysis.", + "price": "$29/mo", + "features": ["Real-Time Alerts", "Smart Money Tracking", "500+ KOLs", "API Access"], + }, +} + +# ── Graphics Generation Functions ──────────────────────────── + + +def create_gradient_background(size, color1, color2): + """Create purple gradient background.""" + img = Image.new("RGB", size, color1) + draw = ImageDraw.Draw(img) + + for y in range(size[1]): + alpha = y / size[1] + r = int(int(color1[1:3], 16) * (1 - alpha) + int(color2[1:3], 16) * alpha) + g = int(int(color1[3:5], 16) * (1 - alpha) + int(color2[3:5], 16) * alpha) + b = int(int(color1[5:7], 16) * (1 - alpha) + int(color2[5:7], 16) * alpha) + draw.line([(0, y), (size[0], y)], fill=(r, g, b)) + + return img + + +def add_circular_frame(img, color=BRAND["gold"], width=5): + """Add gold circular frame.""" + draw = ImageDraw.Draw(img) + margin = 20 + draw.ellipse([margin, margin, img.size[0] - margin, img.size[1] - margin], outline=color, width=width) + return img + + +def add_text_centered(img, text, position, font_size, color, font_path=None): + """Add centered text.""" + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype(font_path or "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size) + except Exception: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + x = position[0] - text_width // 2 + draw.text((x, position[1]), text, fill=color, font=font) + + return img + + +def generate_feature_graphic(feature_key: str) -> dict: + """Generate feature showcase graphic.""" + config = CONTENT_TYPES["feature_showcase"] + copy = MARKETING_COPY.get(feature_key) + + if not copy: + return {"error": f"Unknown feature: {feature_key}"} + + # Create background + img = create_gradient_background(config["size"], BRAND["purple"], BRAND["purple_light"]) + + # Add circular frame + img = add_circular_frame(img, BRAND["gold"], width=5) + + ImageDraw.Draw(img) + + # Add headline + add_text_centered(img, copy["headline"], (config["size"][0] // 2, 150), 72, BRAND["gold"]) + + # Add subhead + add_text_centered(img, copy["subhead"], (config["size"][0] // 2, 250), 48, BRAND["white"]) + + # Add body + add_text_centered(img, copy["body"], (config["size"][0] // 2, 350), 36, BRAND["white"]) + + # Add stats + y = 450 + for stat in copy.get("stats", []): + add_text_centered(img, f"● {stat}", (config["size"][0] // 2, y), 32, BRAND["cyan"]) + y += 50 + + # Add watermark + add_text_centered(img, "@cryptorugmunch", (config["size"][0] // 2, config["size"][1] - 80), 28, BRAND["gold"]) + + # Save + filename = f"feature_{feature_key}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.png" + output_path = os.path.join(OUTPUT_DIR, filename) + img.save(output_path, "PNG") + + return { + "status": "success", + "feature": feature_key, + "image_path": output_path, + "filename": filename, + } + + +def generate_launch_graphic() -> dict: + """Generate platform launch announcement.""" + config = CONTENT_TYPES["launch_announcement"] + copy = MARKETING_COPY["platform_launch"] + + # Create background + img = create_gradient_background(config["size"], BRAND["purple"], BRAND["purple_light"]) + img = add_circular_frame(img, BRAND["gold"], width=5) + + # Add text + add_text_centered(img, copy["headline"], (config["size"][0] // 2, 200), 80, BRAND["gold"]) + add_text_centered(img, copy["subhead"], (config["size"][0] // 2, 320), 48, BRAND["white"]) + add_text_centered(img, copy["body"], (config["size"][0] // 2, 420), 36, BRAND["white"]) + add_text_centered(img, copy.get("cta", ""), (config["size"][0] // 2, 550), 42, BRAND["cyan"]) + add_text_centered(img, "@cryptorugmunch", (config["size"][0] // 2, config["size"][1] - 80), 28, BRAND["gold"]) + + # Save + filename = f"launch_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.png" + output_path = os.path.join(OUTPUT_DIR, filename) + img.save(output_path, "PNG") + + return { + "status": "success", + "type": "launch", + "image_path": output_path, + "filename": filename, + } + + +def generate_all_marketing_graphics() -> list[dict]: + """Generate all marketing graphics.""" + results = [] + + # Feature graphics + for feature in ["smart_money", "rug_detection", "kol_scorecards"]: + result = generate_feature_graphic(feature) + results.append(result) + + # Launch graphic + result = generate_launch_graphic() + results.append(result) + + return results + + +if __name__ == "__main__": + print("Generating marketing graphics with EXACT brand compliance...") + results = generate_all_marketing_graphics() + + print(f"\n✅ Generated {len(results)} graphics:") + for r in results: + if r.get("status") == "success": + print(f" ✅ {r.get('type') or r.get('feature')}: {r.get('filename')}") + else: + print(f" ❌ {r.get('error')}") + + print(f"\n📁 All graphics saved to: {OUTPUT_DIR}") diff --git a/app/_archive/legacy_2026_07/bulk_marketing_generator.py b/app/_archive/legacy_2026_07/bulk_marketing_generator.py new file mode 100644 index 0000000..1fed607 --- /dev/null +++ b/app/_archive/legacy_2026_07/bulk_marketing_generator.py @@ -0,0 +1,547 @@ +""" +RMI Bulk Marketing Graphics Generator - 50+ Graphics with Qwen-VL Max +Uses Alibaba's best vision model for professional marketing graphics. +All graphics follow EXACT brand guidelines. Uploads to Dropbox automatically. +""" + +import logging +import os +from datetime import UTC, datetime + +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + +# ── Paths ──────────────────────────────────────────────────── + +CHARACTER_PATH = "/root/backend/assets/characters/detective-character.png" +LOGO_PATH = "/root/backend/assets/logos/rugmunch-logo.jpg" +OUTPUT_DIR = "/root/backend/assets/marketing_generated" +DROPBOX_DIR = os.path.expanduser("~/Dropbox/RMI Marketing Graphics") + +os.makedirs(OUTPUT_DIR, exist_ok=True) +os.makedirs(DROPBOX_DIR, exist_ok=True) + +# ── Brand Colors (EXACT - NO DEVIATION) ────────────────────── + +BRAND = { + "purple": "#2D1B36", + "purple_light": "#3D2346", + "purple_dark": "#1D0B26", + "gold": "#D4AF37", + "gold_light": "#F1D475", + "gold_dark": "#AA8828", + "cyan": "#00FFFF", + "white": "#FFFFFF", + "green_alert": "#00FF88", + "red_danger": "#FF4444", +} + +# ── 50+ Marketing Graphics Concepts ────────────────────────── + +GRAPHIC_CONCEPTS = [ + # Feature Showcases (12) + { + "type": "feature", + "name": "smart_money", + "headline": "SMART MONEY TRACKING", + "subhead": "Follow The Whales", + "stat": "1,000+ Wallets", + }, + { + "type": "feature", + "name": "rug_detection", + "headline": "RUGPULL DETECTION", + "subhead": "2-Minute Alerts", + "stat": "2,530+ Scams", + }, + { + "type": "feature", + "name": "kol_scorecards", + "headline": "KOL SCORECARDS", + "subhead": "No Fake Gurus", + "stat": "500+ KOLs", + }, + { + "type": "feature", + "name": "whale_alerts", + "headline": "WHALE ALERTS", + "subhead": "Real-Time Moves", + "stat": "$10k+ Threshold", + }, + { + "type": "feature", + "name": "bundle_detection", + "headline": "BUNDLE DETECTION", + "subhead": "Jito/Flashbots", + "stat": "5-Signal Engine", + }, + { + "type": "feature", + "name": "cluster_analysis", + "headline": "CLUSTER ANALYSIS", + "subhead": "Sybil Detection", + "stat": "7 Methods", + }, + { + "type": "feature", + "name": "cross_chain", + "headline": "CROSS-CHAIN", + "subhead": "Multi-Chain Intel", + "stat": "8 Chains", + }, + { + "type": "feature", + "name": "nft_intel", + "headline": "NFT INTELLIGENCE", + "subhead": "Wash Trading Detection", + "stat": "Floor Tracking", + }, + { + "type": "feature", + "name": "security_scanner", + "headline": "SECURITY SCANNER", + "subhead": "Contract Audits", + "stat": "Auto-Audit", + }, + { + "type": "feature", + "name": "social_sentiment", + "headline": "SOCIAL SENTIMENT", + "subhead": "X, Telegram, Discord", + "stat": "Real-Time", + }, + { + "type": "feature", + "name": "meme_tracker", + "headline": "MEME TRACKER", + "subhead": "10,000+ Tokens", + "stat": "5 Chains", + }, + { + "type": "feature", + "name": "premium_api", + "headline": "PREMIUM API", + "subhead": "Developer Access", + "stat": "Webhooks", + }, + # Stats/Metrics (10) + { + "type": "stats", + "name": "total_features", + "stat_value": "40+", + "stat_label": "Features Live", + "context": "Across 11 Services", + }, + { + "type": "stats", + "name": "api_endpoints", + "stat_value": "450+", + "stat_label": "API Endpoints", + "context": "Ready for Integration", + }, + { + "type": "stats", + "name": "scams_tracked", + "stat_value": "2,530", + "stat_label": "Scams Tracked", + "context": "And Counting", + }, + { + "type": "stats", + "name": "kol_tracked", + "stat_value": "500+", + "stat_label": "KOLs Tracked", + "context": "Verified On-Chain", + }, + { + "type": "stats", + "name": "whale_wallets", + "stat_value": "1,000+", + "stat_label": "Whale Wallets", + "context": "Labeled & Tracked", + }, + { + "type": "stats", + "name": "alert_speed", + "stat_value": "2 Min", + "stat_label": "Alert Speed", + "context": "Rugpull Detection", + }, + { + "type": "stats", + "name": "chains_supported", + "stat_value": "8", + "stat_label": "Chains Supported", + "context": "Multi-Chain Intel", + }, + { + "type": "stats", + "name": "users_served", + "stat_value": "1,000+", + "stat_label": "Users Served", + "context": "And Growing Fast", + }, + { + "type": "stats", + "name": "uptime", + "stat_value": "99.9%", + "stat_label": "Uptime", + "context": "Enterprise Grade", + }, + { + "type": "stats", + "name": "data_points", + "stat_value": "10M+", + "stat_label": "Data Points", + "context": "Processed Daily", + }, + # Launch Announcements (8) + { + "type": "launch", + "name": "platform_live", + "headline": "RUG MUNCH INTELLIGENCE", + "subhead": "LIVE NOW", + }, + { + "type": "launch", + "name": "premium_launch", + "headline": "PREMIUM TIER", + "subhead": "Now Available", + }, + { + "type": "launch", + "name": "api_launch", + "headline": "PUBLIC API", + "subhead": "Developers Welcome", + }, + {"type": "launch", "name": "mobile_teaser", "headline": "MOBILE APP", "subhead": "Coming Soon"}, + {"type": "launch", "name": "kol_program", "headline": "KOL PROGRAM", "subhead": "Get Verified"}, + { + "type": "launch", + "name": "partnership", + "headline": "MAJOR PARTNERSHIP", + "subhead": "Announcement", + }, + { + "type": "launch", + "name": "feature_drop", + "headline": "NEW FEATURES", + "subhead": "10 Added This Week", + }, + {"type": "launch", "name": "milestone", "headline": "1,000 USERS", "subhead": "Thank You!"}, + # Pricing Tiers (6) + { + "type": "pricing", + "name": "free_tier", + "tier": "FREE", + "price": "$0", + "features": ["Basic Alerts", "50 Calls/mo", "Community"], + }, + { + "type": "pricing", + "name": "premium_tier", + "tier": "PREMIUM", + "price": "$29", + "features": ["Real-Time Alerts", "Smart Money", "500+ KOLs"], + }, + { + "type": "pricing", + "name": "premium_plus", + "tier": "PREMIUM+", + "price": "$99", + "features": ["Insider Alerts", "API Access", "1-on-1 Support"], + }, + { + "type": "pricing", + "name": "enterprise", + "tier": "ENTERPRISE", + "price": "Custom", + "features": ["White-Label", "Dedicated Support", "SLA"], + }, + { + "type": "pricing", + "name": "early_adopter", + "tier": "EARLY ADOPTER", + "price": "$19", + "features": ["Lifetime Discount", "All Premium Features", "Badge"], + }, + { + "type": "pricing", + "name": "comparison", + "tier": "VS COMPETITORS", + "price": "Save $200/mo", + "features": ["Free Tier", "Better Features", "Real-Time"], + }, + # Testimonials/Social Proof (6) + { + "type": "testimonial", + "name": "user_win_1", + "quote": "Caught a rug 2 mins before it pulled. RMI paid for itself.", + "author": "@degen_trader", + "title": "Premium User", + }, + { + "type": "testimonial", + "name": "user_win_2", + "quote": "Following smart money wallets changed my trading game.", + "author": "@crypto_whale", + "title": "Premium+ User", + }, + { + "type": "testimonial", + "name": "kol_verified", + "quote": "Finally, a platform that verifies our actual performance.", + "author": "@alpha_caller", + "title": "Verified KOL", + }, + { + "type": "testimonial", + "name": "dev_feedback", + "quote": "Best crypto intelligence API. Clean docs, fast response.", + "author": "@dev_builder", + "title": "API User", + }, + { + "type": "testimonial", + "name": "community_love", + "quote": "The Telegram community is pure alpha. No shilling.", + "author": "@community_mod", + "title": "Moderator", + }, + { + "type": "testimonial", + "name": "enterprise_client", + "quote": "Enterprise tier is worth every penny for our fund.", + "author": "@fund_manager", + "title": "Enterprise", + }, + # Educational/How-To (8) + { + "type": "educational", + "name": "how_to_start", + "headline": "GETTING STARTED", + "subhead": "5-Minute Setup", + }, + { + "type": "educational", + "name": "smart_money_101", + "headline": "SMART MONEY 101", + "subhead": "Follow The Pros", + }, + { + "type": "educational", + "name": "avoid_rugs", + "headline": "AVOID RUGS", + "subhead": "7 Red Flags", + }, + { + "type": "educational", + "name": "kol_analysis", + "headline": "ANALYZE KOLs", + "subhead": "Check Track Records", + }, + { + "type": "educational", + "name": "whale_watching", + "headline": "WHALE WATCHING", + "subhead": "Track Big Moves", + }, + { + "type": "educational", + "name": "meme_coins", + "headline": "MEME COINS", + "subhead": "Find The Next 100x", + }, + { + "type": "educational", + "name": "security_tips", + "headline": "SECURITY TIPS", + "subhead": "Stay Safe", + }, + { + "type": "educational", + "name": "api_guide", + "headline": "API GUIDE", + "subhead": "Build On RMI", + }, +] + +# ── Graphics Generation Functions ──────────────────────────── + + +def create_gradient_background(size, color1, color2, direction="vertical"): + """Create purple gradient background.""" + img = Image.new("RGB", size, color1) + draw = ImageDraw.Draw(img) + + for i in range(size[1] if direction == "vertical" else size[0]): + alpha = i / max(size[1] if direction == "vertical" else size[0], 1) + r = int(int(color1[1:3], 16) * (1 - alpha) + int(color2[1:3], 16) * alpha) + g = int(int(color1[3:5], 16) * (1 - alpha) + int(color2[3:5], 16) * alpha) + b = int(int(color1[5:7], 16) * (1 - alpha) + int(color2[5:7], 16) * alpha) + + if direction == "vertical": + draw.line([(0, i), (size[0], i)], fill=(r, g, b)) + else: + draw.line([(i, 0), (i, size[1])], fill=(r, g, b)) + + return img + + +def add_circular_frame(img, color=BRAND["gold"], width=5): + """Add gold circular frame.""" + draw = ImageDraw.Draw(img) + margin = 20 + draw.ellipse([margin, margin, img.size[0] - margin, img.size[1] - margin], outline=color, width=width) + return img + + +def add_text_centered(img, text, position, font_size, color, bold=True): + """Add centered text.""" + draw = ImageDraw.Draw(img) + + try: + font_path = ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + if bold + else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" + ) + font = ImageFont.truetype(font_path, font_size) + except Exception: + font = ImageFont.load_default() + + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + x = position[0] - text_width // 2 + draw.text((x, position[1]), text, fill=color, font=font) + + return img + + +def generate_graphic(concept: dict) -> dict: + """Generate a single marketing graphic.""" + graphic_type = concept.get("type", "feature") + + # Determine size based on type + if graphic_type == "pricing": + size = (800, 1000) + elif graphic_type == "launch": + size = (1200, 675) + else: + size = (1200, 675) + + # Create background + img = create_gradient_background(size, BRAND["purple"], BRAND["purple_light"]) + img = add_circular_frame(img, BRAND["gold"], width=5) + + # Add content based on type + if graphic_type == "feature": + add_text_centered(img, concept["headline"], (size[0] // 2, 150), 64, BRAND["gold"]) + add_text_centered(img, concept["subhead"], (size[0] // 2, 250), 42, BRAND["white"]) + add_text_centered(img, concept["stat"], (size[0] // 2, 400), 96, BRAND["cyan"]) + + elif graphic_type == "stats": + add_text_centered(img, concept["stat_value"], (size[0] // 2, 250), 144, BRAND["gold"]) + add_text_centered(img, concept["stat_label"], (size[0] // 2, 400), 56, BRAND["white"]) + add_text_centered(img, concept["context"], (size[0] // 2, 480), 36, BRAND["cyan"]) + + elif graphic_type == "launch": + add_text_centered(img, "🚀 " + concept["subhead"], (size[0] // 2, 150), 64, BRAND["cyan"]) + add_text_centered(img, concept["headline"], (size[0] // 2, 280), 72, BRAND["gold"]) + + elif graphic_type == "pricing": + add_text_centered(img, concept["tier"], (size[0] // 2, 150), 64, BRAND["gold"]) + add_text_centered( + img, + concept["price"] + "/mo" if concept["price"] != "Custom" else concept["price"], + (size[0] // 2, 280), + 96, + BRAND["white"], + ) + y = 400 + for feature in concept.get("features", []): + add_text_centered(img, f"✓ {feature}", (size[0] // 2, y), 32, BRAND["cyan"]) + y += 50 + + elif graphic_type == "testimonial": + add_text_centered(img, '"', (size[0] // 2, 150), 144, BRAND["gold"]) + add_text_centered(img, concept["quote"][:100], (size[0] // 2, 280), 36, BRAND["white"]) + add_text_centered(img, f"- {concept['author']}", (size[0] // 2, 450), 32, BRAND["gold"]) + add_text_centered(img, concept["title"], (size[0] // 2, 500), 28, BRAND["cyan"]) + + elif graphic_type == "educational": + add_text_centered(img, "📚 " + concept["headline"], (size[0] // 2, 200), 64, BRAND["gold"]) + add_text_centered(img, concept["subhead"], (size[0] // 2, 320), 48, BRAND["white"]) + + # Add watermark + add_text_centered(img, "@cryptorugmunch", (size[0] // 2, size[1] - 60), 28, BRAND["gold"]) + + # Generate filename + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + filename = f"{graphic_type}_{concept['name']}_{timestamp}.png" + output_path = os.path.join(OUTPUT_DIR, filename) + + # Save + img.save(output_path, "PNG") + + return { + "status": "success", + "type": graphic_type, + "name": concept["name"], + "filename": filename, + "path": output_path, + "size": f"{size[0]}x{size[1]}", + } + + +def upload_to_dropbox(local_path: str, dropbox_path: str | None = None) -> bool: + """Upload file to Dropbox.""" + try: + if not dropbox_path: + dropbox_path = os.path.join(DROPBOX_DIR, os.path.basename(local_path)) + + # Copy file to Dropbox + import shutil + + shutil.copy2(local_path, dropbox_path) + + logger.info(f"Uploaded to Dropbox: {dropbox_path}") + return True + except Exception as e: + logger.error(f"Dropbox upload failed: {e}") + return False + + +def generate_all_graphics() -> list[dict]: + """Generate all 50+ marketing graphics.""" + results = [] + + print(f"Generating {len(GRAPHIC_CONCEPTS)} marketing graphics...") + print("=" * 60) + + for i, concept in enumerate(GRAPHIC_CONCEPTS, 1): + result = generate_graphic(concept) + results.append(result) + + # Upload to Dropbox + if result["status"] == "success": + upload_to_dropbox(result["path"]) + print(f"[{i:3d}/{len(GRAPHIC_CONCEPTS)}] ✅ {result['type']:15} - {result['name']:25} - {result['size']}") + else: + print(f"[{i:3d}/{len(GRAPHIC_CONCEPTS)}] ❌ {result.get('error', 'Unknown error')}") + + print("=" * 60) + print(f"\n✅ Generated {len([r for r in results if r['status'] == 'success'])}/{len(GRAPHIC_CONCEPTS)} graphics") + print(f"📁 Local: {OUTPUT_DIR}") + print(f"☁️ Dropbox: {DROPBOX_DIR}") + + return results + + +if __name__ == "__main__": + results = generate_all_graphics() + + # Summary + success = len([r for r in results if r["status"] == "success"]) + print(f"\n🎉 BULK GENERATION COMPLETE: {success} graphics created and backed up to Dropbox!") diff --git a/app/_archive/legacy_2026_07/bundler_detect.py b/app/_archive/legacy_2026_07/bundler_detect.py new file mode 100644 index 0000000..8e8d922 --- /dev/null +++ b/app/_archive/legacy_2026_07/bundler_detect.py @@ -0,0 +1,876 @@ +""" +Supply Manipulation / Bundler Detector +======================================= +Detects bundled token launches where insiders control disproportionate +supply through sniper-controlled wallet distributions. + +Signals detected: + - Bundled initial buys (multiple wallets funded from same source, + buying within same block/seconds) + - Supply concentration across linked wallets (top holders controlled + by same entity) + - Fund flow analysis (same funding source → multiple snipers) + - TIMEO (This Is My Eyes Only) token distribution patterns + - Sniper cluster detection (wallets that only buy this token) + - Launch timing anomalies (coordinated buys in first blocks) + - Holder overlap with known bundler addresses + - Supply distribution entropy analysis + +Tier : Premium ($0.08) +Price : 80000 atoms +Endpoint: POST /api/v1/x402-tools/bundler_detect +""" + +import logging +import math +import os +import re +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +# ── Constants ────────────────────────────────────────────────────── + +SOLANA_ADDR_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") +EVM_ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") + +EVM_CHAINS = frozenset( + { + "ethereum", + "bsc", + "polygon", + "arbitrum", + "optimism", + "avalanche", + "base", + "fantom", + "linea", + "zksync", + "scroll", + "mantle", + } +) + +SUPPORTED_CHAINS = [*EVM_CHAINS, "solana"] + +# DEX API endpoints +DEXSCREENER_API = "https://api.dexscreener.com/latest/dex" + +# Free Solana RPC for account info +SOLANA_RPC = "https://api.mainnet-beta.solana.com" + +# Birdeye public API (no key needed for basic queries) +BIRDEYE_PUBLIC = "https://public-api.birdeye.so" + +# Known bundler wallet addresses (publicly flagged on-chain) +KNOWN_BUNDLER_SEEDS: set[str] = set() + +# ── Risk Levels ────────────────────────────────────────────────── + + +class BundlerRisk(Enum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + NONE = "none" + + +# ── Data Models ────────────────────────────────────────────────── + + +@dataclass +class BundledBuy: + """A single suspicious buy event identified as potentially bundled.""" + + wallet: str + amount_usd: float + buy_block: int + buy_timestamp: float + tx_hash: str = "" + funding_source: str = "" + is_sniper: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "wallet": self.wallet, + "amount_usd": round(self.amount_usd, 2), + "buy_block": self.buy_block, + "buy_timestamp": self.buy_timestamp, + "tx_hash": self.tx_hash, + "funding_source": self.funding_source, + "is_sniper": self.is_sniper, + } + + +@dataclass +class HolderCluster: + """A cluster of wallets suspected to be controlled by one entity.""" + + wallets: list[str] + total_supply_pct: float + funding_overlap_score: float # 0-1, how much funding sources overlap + buy_time_similarity: float # 0-1, how clustered buys were in time + common_funding_source: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "wallet_count": len(self.wallets), + "wallets": self.wallets[:20], # cap at 20 in output + "total_supply_pct": round(self.total_supply_pct, 2), + "funding_overlap_score": round(self.funding_overlap_score, 3), + "buy_time_similarity": round(self.buy_time_similarity, 3), + "common_funding_source": self.common_funding_source, + } + + +@dataclass +class BundlerReport: + """Full supply manipulation analysis result.""" + + token_address: str + chain: str + name: str = "" + symbol: str = "" + + # Core scores (0-100) + bundler_score: float = 0.0 + supply_concentration_score: float = 0.0 + sniper_cluster_score: float = 0.0 + launch_timing_anomaly_score: float = 0.0 + fund_flow_risk_score: float = 0.0 + + # Findings + suspected_bundled_buys: list[BundledBuy] = field(default_factory=list) + holder_clusters: list[HolderCluster] = field(default_factory=list) + top_10_holder_concentration: float = 0.0 + dev_hold_pct: float = 0.0 + unique_buyers_first_block: int = 0 + total_buys_first_blocks: int = 0 + buys_from_same_funding: int = 0 + estimated_unique_entities: int = 0 + + risk_label: str = "none" + errors: list[str] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "token_address": self.token_address, + "chain": self.chain, + "name": self.name, + "symbol": self.symbol, + "bundler_score": round(self.bundler_score, 1), + "risk_label": self.risk_label, + "signals": { + "supply_concentration": round(self.supply_concentration_score, 1), + "sniper_cluster": round(self.sniper_cluster_score, 1), + "launch_timing_anomaly": round(self.launch_timing_anomaly_score, 1), + "fund_flow_risk": round(self.fund_flow_risk_score, 1), + }, + "suspected_bundled_buys": [b.to_dict() for b in self.suspected_bundled_buys[:50]], + "holder_clusters": [c.to_dict() for c in self.holder_clusters[:10]], + "top_10_holder_concentration": round(self.top_10_holder_concentration, 2), + "dev_hold_pct": round(self.dev_hold_pct, 2), + "unique_buyers_first_block": self.unique_buyers_first_block, + "total_buys_first_blocks": self.total_buys_first_blocks, + "buys_from_same_funding": self.buys_from_same_funding, + "estimated_unique_entities": self.estimated_unique_entities, + } + + def summary(self) -> str: + flags = [] + if self.top_10_holder_concentration > 80: + flags.append(f"top10hld:{self.top_10_holder_concentration:.0f}%") + if self.buys_from_same_funding > 3: + flags.append(f"shared_fund:{self.buys_from_same_funding}x") + if self.suspected_bundled_buys: + flags.append(f"bundled:{len(self.suspected_bundled_buys)}buys") + if self.holder_clusters: + total_cluster_pct = sum(c.total_supply_pct for c in self.holder_clusters) + flags.append(f"clustered:{total_cluster_pct:.0f}%") + flag_str = f" [{', '.join(flags)}]" if flags else "" + return ( + f"[{self.risk_label.upper()}] {self.token_address[:14]}... " + f"({self.name}/{self.symbol}) - " + f"Bundler score: {self.bundler_score:.0f}/100 | " + f"{len(self.holder_clusters)} clusters | " + f"{self.estimated_unique_entities} entities estimated" + f"{flag_str}" + ) + + +# ── Scoring Helpers ────────────────────────────────────────────── + + +def _gini_coefficient(values: list[float]) -> float: + """Compute Gini coefficient for supply distribution (0=equal, 1=max concentration).""" + if not values: + return 0.0 + sorted_vals = sorted(values) + n = len(sorted_vals) + cumulative = 0.0 + for i, v in enumerate(sorted_vals): + cumulative += (i + 1) * v + gini = (2 * cumulative) / (n * sum(sorted_vals)) - (n + 1) / n + return max(0.0, min(gini, 1.0)) + + +def _entropy(values: list[float]) -> float: + """Shannon entropy of a distribution (lower = more concentrated). + Returns normalized [0, 1] where 1 = perfectly uniform, 0 = fully concentrated. + """ + total = sum(values) + if total <= 0: + return 0.0 + n = len(values) + if n <= 1: + return 1.0 # Single bin = trivially uniform + raw = 0.0 + for v in values: + p = v / total + if p > 0: + raw -= p * math.log2(p) + max_entropy = math.log2(n) + return raw / max_entropy if max_entropy > 0 else 0.0 + + +def _time_cluster_similarity(timestamps: list[float]) -> float: + """Score how tightly clustered timestamps are (0=spread, 1=all at once).""" + if len(timestamps) < 2: + return 0.0 + min_ts = min(timestamps) + max_ts = max(timestamps) + span = max_ts - min_ts + if span == 0: + return 1.0 + # If all buys happened within 60 seconds, high similarity + if span <= 60: + return 1.0 - (span / 60) * 0.5 # 0.5-1.0 + # If within 5 minutes, medium + if span <= 300: + return 0.5 - (span - 60) / (300 - 60) * 0.3 # 0.2-0.5 + return max(0.0, 0.2 - (span - 300) / 3600) + + +def _funding_overlap(funding_sources: list[str]) -> float: + """Score how many wallets share the same funding source (0-1).""" + if not funding_sources: + return 0.0 + total = len(funding_sources) + if total < 2: + return 0.0 + # Count how many share a source with at least one other + from collections import Counter + + source_counts = Counter(funding_sources) + shared = sum(c for c in source_counts.values() if c > 1) + return shared / total + + +def _label_risk(score: float) -> str: + if score >= 75: + return "critical" + if score >= 50: + return "high" + if score >= 25: + return "medium" + if score > 0: + return "low" + return "none" + + +# ── Core Detector ──────────────────────────────────────────────── + + +class BundlerDetector: + """Main detector for bundled/supply-manipulated token launches.""" + + def __init__(self, http_timeout: float = 15.0): + self.http = httpx.AsyncClient(timeout=http_timeout) + self._birdeye_api_key = os.environ.get("BIRDEYE_API_KEY", "") + + async def close(self): + await self.http.aclose() + + # ── Public API ────────────────────────────────────────────── + + async def scan(self, address: str, chain: str) -> BundlerReport: + """Full supply manipulation analysis for a token.""" + if not self._validate_address(address, chain): + return BundlerReport( + token_address=address, + chain=chain, + errors=[f"Invalid address format for chain: {chain}"], + risk_label="error", + ) + + chain = chain.lower() + if chain not in SUPPORTED_CHAINS: + return BundlerReport( + token_address=address, + chain=chain, + errors=[f"Unsupported chain: {chain}"], + risk_label="error", + ) + + report = BundlerReport(token_address=address, chain=chain) + + try: + # 1. Fetch token metadata and pair info + metadata = await self._fetch_metadata(address, chain) + report.name = metadata.get("name", "Unknown") + report.symbol = metadata.get("symbol", "UNKNOWN") + report.raw["metadata"] = metadata + + # 2. Fetch holder data + holders = await self._fetch_holders(address, chain) + report.raw["holders_raw"] = holders + + if not holders: + report.errors.append("No holder data available") + report.risk_label = "error" + return report + + # 3. Compute supply concentration + top10_pct = self._compute_top_holder_pct(holders, 10) + report.top_10_holder_concentration = top10_pct + report.dev_hold_pct = self._extract_dev_hold_pct(holders, metadata) + + # 4. Fetch and analyze buys for bundling patterns + buys = await self._fetch_buys(address, chain) + report.raw["buys_raw"] = buys + + # 5. Detect bundled buys (same funding source, same block) + bundled_buys, buys_from_same_funding = self._detect_bundled_buys(buys) + report.suspected_bundled_buys = bundled_buys + report.buys_from_same_funding = buys_from_same_funding + + # 6. Analyze launch timing + timing_info = self._analyze_launch_timing(buys) + report.unique_buyers_first_block = timing_info["unique_buyers_first_block"] + report.total_buys_first_blocks = timing_info["total_buys_first_blocks"] + + # 7. Cluster wallets by funding source and timing + clusters = self._cluster_wallets(buys, holders) + report.holder_clusters = clusters + + # 8. Estimate unique entities + report.estimated_unique_entities = self._estimate_entities(holders, clusters, len(bundled_buys)) + + # 9. Compute all scores + report.supply_concentration_score = self._score_supply_concentration(holders, top10_pct) + report.sniper_cluster_score = self._score_sniper_clusters(clusters, bundled_buys) + report.launch_timing_anomaly_score = self._score_launch_timing(timing_info, buys, holders) + report.fund_flow_risk_score = self._score_fund_flow(bundled_buys, buys_from_same_funding, clusters) + + # 10. Composite bundler score + report.bundler_score = self._compute_bundler_score(report) + report.risk_label = _label_risk(report.bundler_score) + + except Exception as e: + logger.error(f"Bundler scan error for {address}: {e}") + report.errors.append(str(e)) + report.risk_label = "error" + + return report + + async def quick_check(self, address: str, chain: str) -> dict[str, Any]: + """Quick supply concentration check - holder data only.""" + if not self._validate_address(address, chain): + return {"error": f"Invalid address for chain {chain}"} + + chain = chain.lower() + metadata = await self._fetch_metadata(address, chain) + holders = await self._fetch_holders(address, chain) + + if not holders: + return { + "address": address, + "chain": chain, + "name": metadata.get("name", ""), + "symbol": metadata.get("symbol", ""), + "error": "No holder data available", + } + + top10 = self._compute_top_holder_pct(holders, 10) + gini = _gini_coefficient([h.get("percentage", 0) for h in holders[:100]]) + + score = 0.0 + if top10 > 80: + score += 40 + elif top10 > 60: + score += 25 + if gini > 0.8: + score += 30 + elif gini > 0.6: + score += 15 + + return { + "address": address, + "chain": chain, + "name": metadata.get("name", ""), + "symbol": metadata.get("symbol", ""), + "supply_concentration_score": min(score, 100), + "risk_label": _label_risk(min(score, 100)), + "top_10_holder_pct": round(top10, 2), + "gini_coefficient": round(gini, 3), + } + + # ── Validation ────────────────────────────────────────────── + + def _validate_address(self, address: str, chain: str) -> bool: + chain = chain.lower() + if chain == "solana": + return bool(SOLANA_ADDR_RE.match(address)) + if chain in EVM_CHAINS: + return bool(EVM_ADDR_RE.match(address)) + return bool(EVM_ADDR_RE.match(address) or SOLANA_ADDR_RE.match(address)) + + # ── Data Fetching ─────────────────────────────────────────── + + async def _fetch_metadata(self, address: str, chain: str) -> dict[str, Any]: + """Fetch token metadata from DexScreener.""" + try: + url = f"{DEXSCREENER_API}/tokens/{address}" + resp = await self.http.get(url, timeout=10) + if resp.status_code != 200: + return {} + data = resp.json() + pairs = data.get("pairs", []) + if not pairs: + return {} + + pair = pairs[0] + return { + "name": pair.get("baseToken", {}).get("name", ""), + "symbol": pair.get("baseToken", {}).get("symbol", ""), + "decimals": pair.get("baseToken", {}).get("decimals"), + "price_usd": pair.get("priceUsd", ""), + "liquidity_usd": pair.get("liquidity", {}).get("usd", 0), + "fdv": pair.get("fdv", 0), + "pair_address": pair.get("pairAddress", ""), + "dex": pair.get("dexId", ""), + "url": pair.get("url", ""), + "social": { + "twitter": pair.get("info", {}).get("twitter", ""), + "website": pair.get("info", {}).get("website", ""), + "telegram": pair.get("info", {}).get("telegram", ""), + }, + "creation_block": None, # May not be available + } + except Exception as e: + logger.debug(f"Metadata fetch error: {e}") + return {} + + async def _fetch_holders(self, address: str, chain: str) -> list[dict[str, Any]]: + """Fetch top holders from Birdeye public API or Solscan.""" + try: + if chain == "solana": + return await self._fetch_solana_holders(address) + # EVM chains - try Birdeye first + return await self._fetch_evm_holders(address, chain) + except Exception as e: + logger.debug(f"Holder fetch error: {e}") + return [] + + async def _fetch_solana_holders(self, address: str) -> list[dict[str, Any]]: + """Fetch Solana token holders via Birdeye public API.""" + try: + url = f"{BIRDEYE_PUBLIC}/defi/holder/tokenlist?tokenAddress={address}&limit=100" + headers = {"Accept": "application/json"} + if self._birdeye_api_key: + headers["X-API-KEY"] = self._birdeye_api_key + + resp = await self.http.get(url, headers=headers, timeout=10) + if resp.status_code == 200: + data = resp.json() + items = data.get("data", {}).get("items", []) + return [ + { + "address": h.get("holder", ""), + "amount": h.get("amount", 0), + "percentage": h.get("percent", 0), + } + for h in items + ] + except Exception as e: + logger.debug(f"Solana holder fetch error: {e}") + + # Fallback: Solscan free API + try: + url = f"https://public-api.solscan.io/token/holders?tokenAddress={address}&limit=100&offset=0" + resp = await self.http.get(url, timeout=10) + if resp.status_code == 200: + data = resp.json() + items = data if isinstance(data, list) else data.get("data", []) + return [ + { + "address": h.get("owner", h.get("address", "")), + "amount": h.get("amount", h.get("balance", 0)), + "percentage": h.get("percentage", h.get("percent", 0)), + } + for h in items + ] + except Exception as e: + logger.debug(f"Solscan holder fallback error: {e}") + + return [] + + async def _fetch_evm_holders(self, address: str, chain: str) -> list[dict[str, Any]]: + """Fetch EVM token holders via Birdeye public API.""" + try: + url = f"{BIRDEYE_PUBLIC}/defi/holder/tokenlist?tokenAddress={address}&limit=100" + headers = {"Accept": "application/json"} + if self._birdeye_api_key: + headers["X-API-KEY"] = self._birdeye_api_key + + resp = await self.http.get(url, headers=headers, timeout=10) + if resp.status_code == 200: + data = resp.json() + items = data.get("data", {}).get("items", []) + return [ + { + "address": h.get("holder", ""), + "amount": h.get("amount", 0), + "percentage": h.get("percent", 0), + } + for h in items + ] + except Exception as e: + logger.debug(f"EVM holder fetch error: {e}") + + return [] + + async def _fetch_buys(self, address: str, chain: str) -> list[dict[str, Any]]: + """Fetch recent buy transactions for the token.""" + buys: list[dict[str, Any]] = [] + try: + url = f"{DEXSCREENER_API}/tokens/{address}" + resp = await self.http.get(url, timeout=10) + if resp.status_code == 200: + data = resp.json() + pairs = data.get("pairs", []) + for pair in pairs[:5]: # Check top 5 pairs + txns = pair.get("txns", {}) + # Extract buys from recent transactions + m5 = txns.get("m5", {}) or {} + h1 = txns.get("h1", {}) or {} + h6 = txns.get("h6", {}) or {} + buys.append( + { + "type": "buy", + "m5_buys": m5.get("buys", 0), + "m5_sells": m5.get("sells", 0), + "h1_buys": h1.get("buys", 0), + "h1_sells": h1.get("sells", 0), + "h6_buys": h6.get("buys", 0), + "h6_sells": h6.get("sells", 0), + "pair_address": pair.get("pairAddress", ""), + "creation_block": None, # May not be available + } + ) + + # Try to get volume per tx for bundling analysis + volume_m5 = pair.get("volume", {}).get("m5", 0) or 0 + if m5.get("buys", 0) > 0: + avg_buy = float(volume_m5) / max(1, m5.get("buys", 1)) + buys[-1]["avg_buy_value"] = avg_buy + except Exception as e: + logger.debug(f"Buy fetch error: {e}") + + return buys + + # ── Analysis ──────────────────────────────────────────────── + + @staticmethod + def _compute_top_holder_pct(holders: list[dict[str, Any]], top_n: int) -> float: + """Calculate the percentage of supply held by top N holders.""" + sorted_h = sorted(holders, key=lambda h: h.get("percentage", 0), reverse=True) + top = sorted_h[:top_n] + return sum(h.get("percentage", 0) for h in top if h.get("percentage") is not None) + + @staticmethod + def _extract_dev_hold_pct(holders: list[dict[str, Any]], metadata: dict[str, Any]) -> float: + """Extract developer/allocation wallet holding percentage.""" + if not holders: + return 0.0 + return holders[0].get("percentage", 0) if holders else 0.0 + + def _detect_bundled_buys(self, buys: list[dict[str, Any]]) -> tuple[list[BundledBuy], int]: + """Detect buys that appear bundled (same source, time clustering).""" + bundled: list[BundledBuy] = [] + same_funding_count = 0 + + # From aggregated transaction data, detect anomalous patterns + for buy in buys: + m5_buys = buy.get("m5_buys", 0) + h1_buys = buy.get("h1_buys", 0) + + # If buys/minute in first 5min is very high relative to later + if m5_buys > 0 and h1_buys > 0: + m5_rate = m5_buys / 5 + h1_rate = h1_buys / 60 + if m5_rate > h1_rate * 3 and m5_buys >= 10: + # High initial buy concentration - suspicious + bundled.append( + BundledBuy( + wallet=f"cluster:{buy.get('pair_address', '')[:12]}", + amount_usd=0, # aggregated + buy_block=0, + buy_timestamp=time.time(), + tx_hash="", + funding_source="aggregated", + is_sniper=True, + ) + ) + same_funding_count += m5_buys + + return bundled, same_funding_count + + def _analyze_launch_timing(self, buys: list[dict[str, Any]]) -> dict[str, Any]: + """Analyze launch timing for anomalous patterns.""" + result = { + "unique_buyers_first_block": 0, + "total_buys_first_blocks": 0, + "buy_concentration_ratio": 0.0, + } + + for buy in buys: + m5_buys = buy.get("m5_buys", 0) + h1_buys = buy.get("h1_buys", 0) + h6_buys = buy.get("h6_buys", 0) + total = m5_buys + h1_buys + h6_buys + + if total > 0: + # What % of all buys happened in first 5 minutes? + first_5m_pct = m5_buys / total if total > 0 else 0 + result["buy_concentration_ratio"] = max(result["buy_concentration_ratio"], first_5m_pct) + result["total_buys_first_blocks"] += m5_buys + # Estimate unique from m5 vs h1 ratio + if h1_buys > 0 and m5_buys > 0: + result["unique_buyers_first_block"] = max( + result["unique_buyers_first_block"], + min(m5_buys, h1_buys), # rough proxy + ) + + return result + + def _cluster_wallets(self, buys: list[dict[str, Any]], holders: list[dict[str, Any]]) -> list[HolderCluster]: + """Cluster wallets by funding overlap and timing patterns.""" + clusters: list[HolderCluster] = [] + + if not holders: + return clusters + + # Identify clusters based on supply concentration + sorted_h = sorted(holders, key=lambda h: h.get("percentage", 0), reverse=True) + + # If top 3 holders control >60%, they form a natural cluster + top3 = sorted_h[:3] + top3_pct = sum(h.get("percentage", 0) for h in top3 if h.get("percentage") is not None) + if top3_pct > 60 and len(top3) >= 2: + clusters.append( + HolderCluster( + wallets=[h.get("address", "") for h in top3 if h.get("address")], + total_supply_pct=top3_pct, + funding_overlap_score=0.7 if top3_pct > 80 else 0.5, + buy_time_similarity=0.8 if top3_pct > 80 else 0.6, + common_funding_source="top_holders_cluster", + ) + ) + + # Check for wallet groupings with 5-15% each (typical bundler pattern) + cluster_wallets: list[dict[str, Any]] = [] + cluster_pct = 0.0 + for h in sorted_h[3:]: # Skip top 3 + pct = h.get("percentage", 0) + if pct and 2 <= pct <= 15: + cluster_wallets.append(h) + cluster_pct += pct + if len(cluster_wallets) >= 5 and cluster_pct >= 15: + break + + if len(cluster_wallets) >= 5 and cluster_pct >= 15: + clusters.append( + HolderCluster( + wallets=[h.get("address", "") for h in cluster_wallets], + total_supply_pct=cluster_pct, + funding_overlap_score=0.6, + buy_time_similarity=0.7, + common_funding_source="mid_holder_belt", + ) + ) + + return clusters + + @staticmethod + def _estimate_entities( + holders: list[dict[str, Any]], + clusters: list[HolderCluster], + bundled_buys_count: int, + ) -> int: + """Estimate number of truly independent entities behind the token.""" + total_holders = len(holders) + + # Each cluster represents 1 entity instead of N wallets + cluster_wallet_count = sum(len(c.wallets) for c in clusters) + + # Reduce estimated entities by clustered wallets + entities = max(1, total_holders - cluster_wallet_count) + + # Further reduce if many bundled buys detected + if bundled_buys_count > 20: + entities = max(1, entities - bundled_buys_count // 5) + + return entities + + # ── Scoring ───────────────────────────────────────────────── + + def _score_supply_concentration(self, holders: list[dict[str, Any]], top10_pct: float) -> float: + """Score supply distribution risk (0-100).""" + score = 0.0 + + # Top 10 concentration + if top10_pct >= 90: + score += 50 + elif top10_pct >= 75: + score += 35 + elif top10_pct >= 50: + score += 20 + elif top10_pct >= 30: + score += 10 + + # Gini coefficient + amounts = [h.get("percentage", 0) for h in holders[:100] if h.get("percentage") is not None] + gini = _gini_coefficient(amounts) + if gini >= 0.9: + score += 40 + elif gini >= 0.8: + score += 30 + elif gini >= 0.6: + score += 15 + + # Entropy (low entropy = concentrated) + ent = _entropy(amounts) + if ent < 0.3: + score += 15 + elif ent < 0.5: + score += 8 + + return min(score, 100) + + def _score_sniper_clusters(self, clusters: list[HolderCluster], bundled_buys: list[BundledBuy]) -> float: + """Score sniper cluster risk (0-100).""" + score = 0.0 + + # High-funding-overlap clusters + high_overlap = [c for c in clusters if c.funding_overlap_score > 0.6] + if high_overlap: + total_pct = sum(c.total_supply_pct for c in high_overlap) + if total_pct >= 50: + score += 50 + elif total_pct >= 30: + score += 35 + elif total_pct >= 15: + score += 20 + + # Bundled buys + if bundled_buys: + score += min(len(bundled_buys) * 5, 30) + + # Time clustering in clusters + high_time = [c for c in clusters if c.buy_time_similarity > 0.7] + if high_time: + score += min(len(high_time) * 10, 25) + + return min(score, 100) + + def _score_launch_timing( + self, + timing_info: dict[str, Any], + buys: list[dict[str, Any]], + holders: list[dict[str, Any]], + ) -> float: + """Score launch timing anomalies (0-100).""" + score = 0.0 + + # High buy concentration in first 5 minutes + ratio = timing_info.get("buy_concentration_ratio", 0) + if ratio >= 0.8: + score += 50 + elif ratio >= 0.6: + score += 35 + elif ratio >= 0.4: + score += 20 + + # Very few unique buyers relative to total buys + unique = timing_info.get("unique_buyers_first_block", 0) + total = timing_info.get("total_buys_first_blocks", 0) + if total > 0 and unique > 0: + repeat_rate = total / max(1, unique) + if repeat_rate >= 5: + score += 30 + elif repeat_rate >= 3: + score += 20 + + # Holder count vs buy count mismatch + holder_count = len(holders) + if holder_count > 0 and total > 0: + buys_per_holder = total / holder_count + if buys_per_holder >= 3: + score += 15 + + return min(score, 100) + + def _score_fund_flow( + self, + bundled_buys: list[BundledBuy], + same_funding_count: int, + clusters: list[HolderCluster], + ) -> float: + """Score fund flow risk (0-100).""" + score = 0.0 + + # Same funding source buys + if same_funding_count >= 20: + score += 45 + elif same_funding_count >= 10: + score += 30 + elif same_funding_count >= 5: + score += 15 + + # Clusters with high funding overlap + high_overlap = [c for c in clusters if c.funding_overlap_score > 0.7] + if high_overlap: + score += min(len(high_overlap) * 15, 30) + + # Overall cluster funding overlap average + if clusters: + avg_overlap = sum(c.funding_overlap_score for c in clusters) / len(clusters) + score += avg_overlap * 20 + + return min(score, 100) + + def _compute_bundler_score(self, report: BundlerReport) -> float: + """Weighted composite bundler score.""" + weights = { + "supply_concentration": 0.30, + "sniper_cluster": 0.25, + "launch_timing_anomaly": 0.20, + "fund_flow_risk": 0.25, + } + + score = ( + report.supply_concentration_score * weights["supply_concentration"] + + report.sniper_cluster_score * weights["sniper_cluster"] + + report.launch_timing_anomaly_score * weights["launch_timing_anomaly"] + + report.fund_flow_risk_score * weights["fund_flow_risk"] + ) + + return min(score, 100) diff --git a/app/_archive/legacy_2026_07/campaign_radar.py b/app/_archive/legacy_2026_07/campaign_radar.py new file mode 100644 index 0000000..ed8a2aa --- /dev/null +++ b/app/_archive/legacy_2026_07/campaign_radar.py @@ -0,0 +1,256 @@ +""" +Campaign Radar - Coordinated Scam Detection +============================================ + +Detects coordinated rug pull campaigns across multiple tokens. +Clusters tokens by deployer entity, funding source, contract similarity, +and social signal correlation. + +Premium feature: "4 tokens detected from same entity - coordinated rug campaign" +""" + +import asyncio +import hashlib +import logging +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger("sentinel.campaign") + +# In-memory recent scan cache (should be Redis-backed in production) +_recent_scans: dict[str, dict[str, Any]] = {} # "chain:address" → scan metadata +MAX_RECENT = 500 # Keep last 500 scans for campaign detection + + +@dataclass +class CampaignCluster: + """A detected coordinated campaign.""" + + cluster_id: str + tokens: list[dict[str, Any]] = field(default_factory=list) + deployer_entity: str | None = None + funding_source: str | None = None + contract_similarity: float = 0.0 # 0-1 + social_correlation: float = 0.0 # 0-1 + risk_level: str = "unknown" # "critical"/"high"/"medium" + estimated_victims: int = 0 + first_detected: str | None = None + description: str = "" + + +def record_scan(chain: str, address: str, metadata: dict[str, Any]): + """Record a scan for campaign correlation.""" + key = f"{chain}:{address.lower()}" + metadata["_recorded_at"] = ( + asyncio.get_event_loop().time() if asyncio.get_event_loop().is_running() else __import__("time").time() + ) + _recent_scans[key] = metadata + + # Evict oldest if over capacity + if len(_recent_scans) > MAX_RECENT: + oldest = min(_recent_scans.keys(), key=lambda k: _recent_scans[k].get("_recorded_at", 0)) + del _recent_scans[oldest] + + +def detect_campaigns(min_cluster_size: int = 3) -> list[CampaignCluster]: + """Analyze recent scans for coordinated campaigns. + + Clusters tokens by: + 1. Same deployer entity (strongest signal) + 2. Same funding source + 3. High contract bytecode similarity + 4. Correlated social/KOL mentions + """ + if len(_recent_scans) < min_cluster_size: + return [] + + scans = list(_recent_scans.values()) + campaigns = [] + + # ── Strategy 1: Same deployer entity ── + deployer_groups = defaultdict(list) + for scan in scans: + deployer = _extract_deployer_entity(scan) + if deployer: + deployer_groups[deployer].append(scan) + + for entity, group in deployer_groups.items(): + if len(group) >= min_cluster_size: + campaign = CampaignCluster( + cluster_id=f"deployer_{entity[:12]}", + tokens=[_token_summary(s) for s in group], + deployer_entity=entity, + risk_level="critical" if len(group) >= 5 else "high", + estimated_victims=sum(s.get("holder_count", 0) or 0 for s in group), + description=f"{len(group)} tokens launched by same deployer entity {entity[:8]}...", + ) + campaigns.append(campaign) + + # ── Strategy 2: Same funding source ── + funding_groups = defaultdict(list) + for scan in scans: + funder = _extract_funding_source(scan) + if funder: + funding_groups[funder].append(scan) + + for funder, group in funding_groups.items(): + if len(group) >= min_cluster_size: + # Avoid double-counting with deployer groups + existing_tokens = set() + for c in campaigns: + for t in c.tokens: + existing_tokens.add(f"{t.get('chain', '')}:{t.get('address', '')}") + + new_tokens = [ + s for s in group if f"{s.get('chain', '')}:{s.get('address', '')}".lower() not in existing_tokens + ] + if len(new_tokens) >= min_cluster_size: + campaign = CampaignCluster( + cluster_id=f"funder_{funder[:12]}", + tokens=[_token_summary(s) for s in new_tokens], + funding_source=funder, + risk_level="high", + estimated_victims=sum(s.get("holder_count", 0) or 0 for s in new_tokens), + description=f"{len(new_tokens)} tokens funded from same source {funder[:8]}...", + ) + campaigns.append(campaign) + + # ── Strategy 3: Contract similarity ── + similar_pairs = [] + scan_list = list(_recent_scans.values()) + for i in range(len(scan_list)): + for j in range(i + 1, len(scan_list)): + sim = _contract_similarity(scan_list[i], scan_list[j]) + if sim > 0.85: + similar_pairs.append((scan_list[i], scan_list[j], sim)) + + if similar_pairs: + # Union-find to cluster similar contracts + clusters = _cluster_similar(similar_pairs) + for cluster_tokens in clusters: + if len(cluster_tokens) >= min_cluster_size: + avg_sim = sum(p[2] for p in similar_pairs if p[0] in cluster_tokens and p[1] in cluster_tokens) / max( + len(cluster_tokens), 1 + ) + campaign = CampaignCluster( + cluster_id=f"contract_{hashlib.sha256(str(sorted([t.get('address', '') for t in cluster_tokens])).encode()).hexdigest()[:12]}", + tokens=[_token_summary(s) for s in cluster_tokens], + contract_similarity=avg_sim, + risk_level="high" if avg_sim > 0.95 else "medium", + estimated_victims=sum(s.get("holder_count", 0) or 0 for s in cluster_tokens), + description=f"{len(cluster_tokens)} tokens with {avg_sim:.0%} contract similarity - likely cloned scam contracts", + ) + campaigns.append(campaign) + + return sorted(campaigns, key=lambda c: -len(c.tokens)) + + +def _extract_deployer_entity(scan: dict) -> str | None: + """Extract deployer entity ID from scan metadata.""" + free = scan.get("free", scan) + deployer = free.get("deployer", {}) or {} + deep = free.get("deep_deployer", {}) or {} + + entity_id = deployer.get("entity_id") or deep.get("entity_id") or deployer.get("address") + return entity_id + + +def _extract_funding_source(scan: dict) -> str | None: + """Extract funding source from scan metadata.""" + free = scan.get("free", scan) + funding = free.get("funding_source") or free.get("deep_deployer", {}).get("funding_source") + return funding + + +def _contract_similarity(scan_a: dict, scan_b: dict) -> float: + """Estimate contract similarity between two scans.""" + free_a = scan_a.get("free", scan_a) + free_b = scan_b.get("free", scan_b) + + # Bytecode hash match (strongest) + bc_a = free_a.get("bytecode_hash") or free_a.get("contract_diff", {}).get("bytecode_hash") + bc_b = free_b.get("bytecode_hash") or free_b.get("contract_diff", {}).get("bytecode_hash") + if bc_a and bc_b and bc_a == bc_b: + return 1.0 + + # Selector set Jaccard similarity + selectors_a = set(free_a.get("selectors", []) or []) + selectors_b = set(free_b.get("selectors", []) or []) + if selectors_a and selectors_b: + intersection = selectors_a & selectors_b + union = selectors_a | selectors_b + if union: + return len(intersection) / len(union) + + return 0.0 + + +def _cluster_similar(pairs: list[tuple]) -> list[list]: + """Union-find clustering of similar contract pairs.""" + parent = {} + + def find(x): + addr = x.get("address", id(x)) + if addr not in parent: + parent[addr] = addr + if parent[addr] != addr: + parent[addr] = find({"address": parent[addr]}) + return parent[addr] + + def union(a, b): + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb + + for a, b, _ in pairs: + union(a, b) + + clusters = defaultdict(list) + for a, b, _ in pairs: + root = find(a) + if a not in clusters[root]: + clusters[root].append(a) + if b not in clusters[root]: + clusters[root].append(b) + + return list(clusters.values()) + + +def _token_summary(scan: dict) -> dict[str, Any]: + """Create a concise token summary for campaign display.""" + return { + "address": scan.get("address") or scan.get("token_address", ""), + "chain": scan.get("chain", ""), + "symbol": scan.get("symbol", ""), + "name": scan.get("name", ""), + "safety_score": scan.get("safety_score", 50), + "age_hours": scan.get("free", {}).get("age_hours", 0) if isinstance(scan.get("free"), dict) else 0, + "holder_count": scan.get("free", {}).get("holders", {}).get("total", 0) + if isinstance(scan.get("free", {}).get("holders"), dict) + else 0, + } + + +def get_active_campaigns() -> dict[str, Any]: + """Get all currently detected campaigns.""" + campaigns = detect_campaigns() + return { + "status": "ok", + "active_campaigns": len(campaigns), + "scans_analyzed": len(_recent_scans), + "campaigns": [ + { + "id": c.cluster_id, + "token_count": len(c.tokens), + "deployer_entity": c.deployer_entity, + "funding_source": c.funding_source, + "contract_similarity": round(c.contract_similarity, 3), + "risk_level": c.risk_level, + "estimated_victims": c.estimated_victims, + "description": c.description, + "tokens": c.tokens[:10], # Top 10 tokens + } + for c in campaigns + ], + } diff --git a/app/_archive/legacy_2026_07/cases_investigators_api.py b/app/_archive/legacy_2026_07/cases_investigators_api.py new file mode 100644 index 0000000..a524278 --- /dev/null +++ b/app/_archive/legacy_2026_07/cases_investigators_api.py @@ -0,0 +1,432 @@ +""" +cases_investigators_api.py - Public case files + investigator profiles + +Endpoints: + POST /api/v1/cases - create/save a case snapshot + GET /api/v1/cases/:id - fetch a case by id (public) + GET /api/v1/cases - list cases by creator (auth) + DELETE /api/v1/cases/:id - delete a case (creator only) + GET /api/v1/investigators/:username - public investigator profile + GET /api/v1/portfolio/features - returns tier-gated portfolio limits for current user + POST /api/v1/subscription/addon - add an add-on to an existing subscription + +Designed to work with localStorage fallback on the frontend (cases are +still public-shareable URLs even before this backend is wired). +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import secrets +from datetime import datetime, timedelta + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["cases-investigators"]) + + +# ── Storage ─────────────────────────────────────────────────── +# Cases are stored in Redis if available, else in-memory dict. +# In production this should be Postgres/ClickHouse, but for the +# public-share URL contract, this gives full functionality end-to-end. + +_cases_db: dict[str, dict] = {} +_investigators_db: dict[str, dict] = {} + +try: + import redis as _redis # type: ignore + + _r = _redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, + socket_timeout=2, + ) + _r.ping() + REDIS_OK = True +except Exception as e: + REDIS_OK = False + logger.warning(f"cases: redis unavailable, using in-memory store ({e})") + + +def _store_case(case_id: str, data: dict) -> None: + if REDIS_OK: + _r.setex(f"rmi:case:{case_id}", 60 * 60 * 24 * 365, json.dumps(data)) + _cases_db[case_id] = data + + +def _load_case(case_id: str) -> dict | None: + if REDIS_OK: + raw = _r.get(f"rmi:case:{case_id}") + if raw: + return json.loads(raw) + return _cases_db.get(case_id) + + +def _delete_case(case_id: str) -> bool: + deleted = False + if REDIS_OK: + deleted = bool(_r.delete(f"rmi:case:{case_id}")) + if case_id in _cases_db: + del _cases_db[case_id] + deleted = True + return deleted + + +def _store_investigator(handle: str, data: dict) -> None: + if REDIS_OK: + _r.setex(f"rmi:investigator:{handle}", 60 * 60 * 24, json.dumps(data)) + _investigators_db[handle] = data + + +def _load_investigator(handle: str) -> dict | None: + if REDIS_OK: + raw = _r.get(f"rmi:investigator:{handle}") + if raw: + return json.loads(raw) + return _investigators_db.get(handle) + + +# ── Models ──────────────────────────────────────────────────── + + +class CaseWallet(BaseModel): + address: str + label: str | None = None + pct: float | None = None + + +class CaseCreateRequest(BaseModel): + chain: str + token_address: str + title: str + risk_score: float = Field(ge=0, le=100) + risk_level: str = "medium" + description: str | None = None + key_finding: str | None = None + findings: list[str] = [] + evidence_wallets: list[CaseWallet] = [] + tx_hashes: list[str] = [] + bubble_svg: str | None = None + + +class AddonRequest(BaseModel): + addon: str = Field(..., pattern="^(PORTFOLIO_PRO)$") + period: str = Field(..., pattern="^(monthly|six_month|yearly)$") + payment_method: str = Field("x402", pattern="^(stripe|crypto|x402)$") + crypto_chain: str | None = None + + +# ── Cases ───────────────────────────────────────────────────── + + +@router.post("/cases") +async def create_case(req: CaseCreateRequest, request: Request): + """Create a public case file. Returns the case id + public URL.""" + # Resolve creator if authed + creator = "anonymous" + try: + from app.auth import get_current_user + + user = await get_current_user(request) + if user: + creator = ( + user.get("user_metadata", {}).get("handle") + or user.get("email", "").split("@")[0] + or user.get("id", "anonymous") + ) + except Exception: + pass + + # Derive deterministic id from payload + payload = { + "chain": req.chain, + "address": req.token_address, + "risk_score": req.risk_score, + "findings": req.findings, + "wallets": [w.model_dump() for w in req.evidence_wallets], + "txs": req.tx_hashes, + "title": req.title, + } + payload_sorted = json.dumps(payload, sort_keys=True) + full_hash = hashlib.sha256(payload_sorted.encode()).hexdigest() + case_id = f"rmi-{full_hash[:12]}" + + full = { + "id": case_id, + "created_at": datetime.utcnow().isoformat() + "Z", + "creator": creator, + "chain": req.chain, + "token_address": req.token_address, + "title": req.title, + "description": req.description, + "risk_score": req.risk_score, + "risk_level": req.risk_level, + "key_finding": req.key_finding, + "findings": req.findings, + "evidence_wallets": [w.model_dump() for w in req.evidence_wallets], + "tx_hashes": req.tx_hashes, + "bubble_svg": req.bubble_svg, + "snapshot_hash": full_hash, + "url": f"https://rugmunch.io/case/{case_id}", + } + _store_case(case_id, full) + + return { + "success": True, + "case": full, + "public_url": full["url"], + } + + +@router.get("/cases/{case_id}") +async def get_case(case_id: str): + """Public, no auth required. Returns 404 if not found.""" + data = _load_case(case_id) + if not data: + raise HTTPException(status_code=404, detail=f"Case {case_id} not found") + return data + + +@router.get("/cases") +async def list_my_cases(request: Request, limit: int = 50): + """List cases created by the current user. Auth required.""" + try: + from app.auth import get_current_user + + user = await get_current_user(request) + except Exception: + user = None + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + creator = user.get("user_metadata", {}).get("handle") or user.get("email", "").split("@")[0] or user.get("id") + matches = [c for c in _cases_db.values() if c.get("creator") == creator] + matches.sort(key=lambda c: c.get("created_at", ""), reverse=True) + return {"cases": matches[:limit], "total": len(matches)} + + +@router.delete("/cases/{case_id}") +async def delete_case(case_id: str, request: Request): + """Delete a case. Only the creator (or anon if created anonymously) can delete.""" + data = _load_case(case_id) + if not data: + raise HTTPException(status_code=404, detail="Case not found") + try: + from app.auth import get_current_user + + user = await get_current_user(request) + except Exception: + user = None + creator = None + if user: + creator = user.get("user_metadata", {}).get("handle") or user.get("email", "").split("@")[0] or user.get("id") + if data.get("creator") != "anonymous" and data.get("creator") != creator: + raise HTTPException(status_code=403, detail="Not your case") + _delete_case(case_id) + return {"success": True} + + +# ── Investigator profiles ───────────────────────────────────── + + +@router.get("/investigators/{username}") +async def get_investigator(username: str): + """Public investigator profile. Returns synthesized profile if no record.""" + handle = username.lower() + data = _load_investigator(handle) + if data: + return data + # Synthesize a default profile so the /u/:username page always renders + return { + "username": handle, + "display_name": handle.replace("_", " ").replace("-", " ").title() if handle != "anonymous" else "Anonymous", + "bio": f"RMI investigator @{handle}", + "joined_at": (datetime.utcnow() - timedelta(days=90)).isoformat() + "Z", + "reputation": 0, + "cases_filed": 0, + "scans_run": 0, + "accuracy": 0, + "badges": [], + } + + +@router.post("/investigators/{username}") +async def upsert_investigator(username: str, request: Request): + """Update or create investigator profile. Auth required.""" + try: + from app.auth import get_current_user + + user = await get_current_user(request) + except Exception: + user = None + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + body = await request.json() + handle = username.lower() + data = { + "username": handle, + "display_name": body.get("display_name", handle), + "bio": body.get("bio", ""), + "avatar_url": body.get("avatar_url"), + "twitter": body.get("twitter"), + "github": body.get("github"), + "website": body.get("website"), + "telegram": body.get("telegram"), + "reputation": body.get("reputation", 0), + "cases_filed": body.get("cases_filed", 0), + "scans_run": body.get("scans_run", 0), + "accuracy": body.get("accuracy", 0), + "badges": body.get("badges", []), + "updated_at": datetime.utcnow().isoformat() + "Z", + } + _store_investigator(handle, data) + return {"success": True, "investigator": data} + + +# ── Portfolio features (tier-gated limits) ─────────────────── + +_FREE_LIMITS = {"wallets": 3, "history_days": 7, "csv_export": False, "alerts": False} +_PORTFOLIO_PRO_LIMITS = {"wallets": 50, "history_days": 30, "csv_export": True, "alerts": True} + + +@router.get("/portfolio/features") +async def portfolio_features(request: Request): + """Return tier-gated Portfolio features for the current user. + + Resolves the user's effective tier by checking: + 1. Supabase auth → user metadata.has_portfolio_pro + 2. Their active subscription (if any) + 3. Falls back to FREE limits + + Frontend uses this to gate wallets, history window, and exports. + """ + is_pro = False + has_subscription = False + base_tier = "FREE" + + try: + from app.auth import get_current_user + + user = await get_current_user(request) + except Exception: + user = None + + if user: + # Check user metadata flag + if user.get("user_metadata", {}).get("has_portfolio_pro"): + is_pro = True + # Check subscription entitlements (Redis or DB) + # Convention: rmi:sub:{user_id} → JSON with {tier, addons: [PORTFOLIO_PRO], ...} + try: + if REDIS_OK: + sub_raw = _r.get(f"rmi:sub:{user.get('id')}") + if sub_raw: + sub = json.loads(sub_raw) + base_tier = sub.get("tier", "FREE") + has_subscription = True + if "PORTFOLIO_PRO" in sub.get("addons", []): + is_pro = True + except Exception: + pass + + limits = _PORTFOLIO_PRO_LIMITS if is_pro else _FREE_LIMITS + return { + "tier": "PORTFOLIO_PRO" if is_pro else base_tier, + "is_portfolio_pro": is_pro, + "has_subscription": has_subscription, + "limits": limits, + "price_usd": 3.00, + "price_atoms": "3000000", + "x402_chain_id": 8453, + "x402_pay_to": os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), + "upgrade_url": "/pricing?tier=PORTFOLIO_PRO", + } + + +# ── Add-on subscription ────────────────────────────────────── + + +@router.post("/subscription/addon") +async def add_addon(req: AddonRequest, request: Request): + """Add an add-on (e.g. PORTFOLIO_PRO) to an existing subscription. + + Returns an x402 challenge for micropayment, or a stripe checkout URL + for fiat, or a crypto payment address. + """ + from app.auth import get_current_user + + try: + user = await get_current_user(request) + except Exception: + user = None + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + addon = req.addon.upper() + if addon == "PORTFOLIO_PRO": + price_usd = 3.00 + x402_price_atoms = "3000000" + else: + raise HTTPException(status_code=400, detail=f"Unknown addon {addon}") + + addon_id = secrets.token_hex(8) + now = datetime.utcnow() + + if req.payment_method == "x402": + # Return EIP-3009 challenge + return { + "success": True, + "addon": addon, + "addon_id": addon_id, + "user_id": user["id"], + "x402": { + "version": 2, + "scheme": "exact", + "network": "eip155:8453", + "asset": "USDC", + "amount_atoms": x402_price_atoms, + "amount_usd": price_usd, + "pay_to": os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), + "expires_at": (now + timedelta(minutes=10)).isoformat() + "Z", + }, + "instructions": f"Sign EIP-3009 transfer for ${price_usd} USDC. Addon activates on settlement.", + } + + if req.payment_method == "crypto": + return { + "success": True, + "addon": addon, + "addon_id": addon_id, + "user_id": user["id"], + "payment_details": { + "chain": req.crypto_chain, + "amount_usd": price_usd, + "memo": f"RMI-ADDON-{addon_id}", + }, + } + + # Stripe + return { + "success": True, + "addon": addon, + "addon_id": addon_id, + "user_id": user["id"], + "checkout_url": f"/pricing?addon={addon}&period={req.period}", + } + + +@router.get("/health") +async def health(): + return { + "status": "ok", + "redis": REDIS_OK, + "cases_stored": len(_cases_db), + "investigators_stored": len(_investigators_db), + } diff --git a/app/_archive/legacy_2026_07/chain_feeder.py b/app/_archive/legacy_2026_07/chain_feeder.py new file mode 100644 index 0000000..567678f --- /dev/null +++ b/app/_archive/legacy_2026_07/chain_feeder.py @@ -0,0 +1,101 @@ +""" +Chain Data Feeder - Pre-loads wallet clustering engine with real on-chain data. +Uses Helius (primary) with QuickNode fallback. Rate-limited + cached. +""" + +import logging +from datetime import UTC, datetime + +from app.chain_cache import get_chain_cache +from app.chain_client import get_chain_client +from app.wallet_clustering import Transaction, get_clustering_engine + +logger = logging.getLogger(__name__) + + +async def feed_wallet_transactions(wallet: str, limit: int = 50) -> int: + """Fetch real transactions for a wallet and load into clustering engine. + Returns number of transactions loaded. Cached for 5 min.""" + cache = get_chain_cache() + cached = await cache.get("feed_wallet", wallet, limit) + if cached is not None: + return cached + + client = get_chain_client() + engine = get_clustering_engine() + + # Get recent signatures + sigs = await cache.get("signatures", wallet, limit) + if sigs is None: + sigs = await client.get_signatures(wallet, limit=min(limit, 50)) + await cache.set("signatures", sigs, wallet, limit, ttl=120) + + if not sigs: + await cache.set("feed_wallet", 0, wallet, limit) + return 0 + + # Get parsed transactions (batch) + tx_hashes = [s.get("signature") for s in sigs[:25] if s.get("signature")] + txs = await cache.get("transactions_batch", *tx_hashes[:5]) + if txs is None: + txs = await client.get_transactions(tx_hashes[:25]) + await cache.set("transactions_batch", txs, *tx_hashes[:5], ttl=120) + + count = 0 + for sig_data in sigs: + sig = sig_data.get("signature") + if not sig: + continue + ts = sig_data.get("blockTime") + dt = datetime.fromtimestamp(ts, tz=UTC) if ts else datetime.now(UTC) + + # Simple transaction from signature data + tx = Transaction( + signature=sig, + timestamp=dt, + from_address=wallet, + to_address=sig_data.get("to", "unknown"), + amount=float(sig_data.get("lamport", 0)) / 1e9 if sig_data.get("lamport") else 0, + token="SOL", + program="system", + ) + engine.add_transaction(tx) + count += 1 + + await cache.set("feed_wallet", count, wallet, limit) + logger.info(f"Fed {count} transactions for {wallet[:12]}...") + return count + + +async def get_holders_for_token(token_address: str, limit: int = 20) -> list[str]: + """Get top token holders. Uses cached Helius token-accounts lookup.""" + cache = get_chain_cache() + cached = await cache.get("holders", token_address, limit) + if cached is not None: + return cached + + client = get_chain_client() + accounts = await client.get_token_accounts(token_address) + + holders = [] + for acct in accounts[:limit]: + info = acct.get("account", {}).get("data", {}).get("parsed", {}).get("info", {}) + owner = info.get("owner") + if owner: + holders.append(owner) + + await cache.set("holders", holders, token_address, limit, ttl=300) + return holders + + +async def get_wallet_balance(wallet: str) -> float: + """Get SOL balance with caching.""" + cache = get_chain_cache() + cached = await cache.get("balance", wallet) + if cached is not None: + return cached + + client = get_chain_client() + balance = await client.get_balance(wallet) + await cache.set("balance", balance, wallet, ttl=300) + return balance diff --git a/app/_archive/legacy_2026_07/circuit_breaker.py b/app/_archive/legacy_2026_07/circuit_breaker.py new file mode 100644 index 0000000..1328b0e --- /dev/null +++ b/app/_archive/legacy_2026_07/circuit_breaker.py @@ -0,0 +1,66 @@ +""" +Lightweight Redis-backed Circuit Breaker for external API calls. +Prevents cascading failures when external services (Helius, OpenRouter, etc.) go down. +""" + +import logging +import os +import time + +import redis + +logger = logging.getLogger(__name__) + + +def _get_redis() -> redis.Redis: + return redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_timeout=2, + ) + + +async def check_circuit(service: str) -> bool: + """ + Returns True if circuit is OPEN (do not call the service). + Returns False if CLOSED or HALF_OPEN (safe to call). + """ + try: + r = _get_redis() + state = r.get(f"circuit:{service}:state") + + if state == "OPEN": + opened_at = float(r.get(f"circuit:{service}:opened_at") or 0) + if time.time() - opened_at > 60: # 60 seconds cooldown + r.set(f"circuit:{service}:state", "HALF_OPEN") + logger.info(f"Circuit breaker for {service} moving to HALF_OPEN") + return False + return True # Still OPEN, block the call + + return False # CLOSED or HALF_OPEN + except Exception as e: + logger.error(f"Circuit breaker check failed for {service}: {e}") + return False # Fail open if Redis is down + + +async def record_success(service: str): + try: + r = _get_redis() + r.set(f"circuit:{service}:state", "CLOSED") + r.delete(f"circuit:{service}:failures") + except Exception as e: + logger.error(f"Circuit breaker success record failed for {service}: {e}") + + +async def record_failure(service: str): + try: + r = _get_redis() + failures = r.incr(f"circuit:{service}:failures") + if failures >= 5: + r.set(f"circuit:{service}:state", "OPEN") + r.set(f"circuit:{service}:opened_at", str(time.time())) + logger.warning(f"Circuit breaker for {service} TRIPPED (OPEN) after 5 failures") + except Exception as e: + logger.error(f"Circuit breaker failure record failed for {service}: {e}") diff --git a/app/_archive/legacy_2026_07/community_badges.py b/app/_archive/legacy_2026_07/community_badges.py new file mode 100644 index 0000000..cc968e6 --- /dev/null +++ b/app/_archive/legacy_2026_07/community_badges.py @@ -0,0 +1,95 @@ +""" +Community Badges Router - FastAPI endpoints for voting and badge queries. +""" + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from app.community_badges import ( + cast_vote, + get_leaderboard, + get_token_votes, + get_user_badge, + resolve_token_outcome, +) + +router = APIRouter(prefix="/api/v1/community", tags=["community"]) + + +# ── Request models ──────────────────────────────────────────────── + + +class VoteRequest(BaseModel): + token_address: str = Field(..., min_length=32, max_length=128) + chain: str = Field(default="solana") + voter_id: str = Field(..., min_length=1, max_length=256) + vote: str = Field(..., pattern=r"^(clean|malicious)$") + + +class ResolveRequest(BaseModel): + token_address: str = Field(..., min_length=32, max_length=128) + chain: str = Field(default="solana") + outcome: str = Field(..., pattern=r"^(clean|malicious|rug_confirmed)$") + + +# ── Endpoints ───────────────────────────────────────────────────── + + +@router.post("/vote") +async def api_cast_vote(req: VoteRequest): + """Cast a vote on whether a token is clean or malicious.""" + result = cast_vote( + token_address=req.token_address, + chain=req.chain, + voter_id=req.voter_id, + vote=req.vote, + ) + if result.get("success") is False: + raise HTTPException(status_code=400, detail=result.get("error", "Vote failed")) + return result + + +@router.post("/resolve") +async def api_resolve_outcome(req: ResolveRequest): + """Resolve a token's outcome (admin/cron). Updates all voter badges.""" + result = resolve_token_outcome( + token_address=req.token_address, + chain=req.chain, + outcome=req.outcome, + ) + if result.get("success") is False: + raise HTTPException(status_code=400, detail=result.get("error", "Resolve failed")) + return result + + +@router.get("/badge/{user_id}") +async def api_get_badge(user_id: str): + """Get a user's badge profile.""" + badge = get_user_badge(user_id) + if badge is None: + raise HTTPException(status_code=404, detail="User not found") + return { + "user_id": badge.user_id, + "total_votes": badge.total_votes, + "correct_votes": badge.correct_votes, + "current_tier": badge.current_tier, + "next_tier": badge.next_tier, + "votes_needed_for_next": badge.votes_needed_for_next, + "badges_earned": badge.badges_earned, + "accuracy_pct": badge.accuracy_pct, + } + + +@router.get("/votes/{chain}/{token_address:path}") +async def api_get_token_votes(token_address: str, chain: str): + """Get all votes for a token.""" + result = get_token_votes(token_address, chain) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + + +@router.get("/leaderboard") +async def api_get_leaderboard(limit: int = Query(default=20, le=100)): + """Get community leaderboard (top sleuths).""" + return {"leaderboard": get_leaderboard(limit)} diff --git a/app/_archive/legacy_2026_07/community_forensics.py b/app/_archive/legacy_2026_07/community_forensics.py new file mode 100644 index 0000000..2cc497e --- /dev/null +++ b/app/_archive/legacy_2026_07/community_forensics.py @@ -0,0 +1,1145 @@ +""" +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""" + + + + + {report["title"]} - Forensic Dossier + + + + +
+
+
{"LAW ENFORCEMENT SENSITIVE" if report.get("publish_status") == "law_enforcement" else "CONFIDENTIAL"}
+

{report["title"]}

+
+ Lead Investigator: {req.investigator_name}
+ Report ID: {req.report_id}
+ Generated: {now_iso()}
+ Risk Score: {report["risk_score"]}/100 +
+
+ +
+

📖 Investigator's Narrative

+

{req.narrative.replace(chr(10), "
")}

+
+ +
+

📊 Executive Summary

+

{report["summary"]}

+

Key Findings

+
    + {"".join(f"
  • {f.get('title', 'Finding')}: {f.get('description', '')}
  • " for f in report.get("findings", []))} +
+
+ +
+

🕸️ AI-Generated Network Topology

+

Visual representation of the criminal enterprise hierarchy and fund flows.

+ Forensic Network Graph + +

Interactive Graph Data

+
+graph TD + Target["Target Address
{report.get("investigation_id", "Unknown")}"] -->|Funds Flow| Node1["Intermediate Wallet 1"] + Target -->|Funds Flow| Node2["Intermediate Wallet 2"] + Node1 -->|Consolidation| CEX["Centralized Exchange
(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 +
+
+ +
+

🔗 Key Wallet Directory

+ + + + + + + + + + + +
RoleAddressExplorer Link
Target{report.get("investigation_id", "N/A")}View on Solscan ↗
+
+ +
+

⚖️ Recommendations for Law Enforcement

+
    + {"".join(f"
  • {rec}
  • " for rec in report.get("recommendations", ["Preserve all on-chain evidence immediately.", "Issue subpoenas to identified CEXs for KYC data."]))} +
+
+ +
+

Generated by RMI Community Forensics Platform | Powered by Hermes Agent & Qwen AI

+

CONFIDENTIAL - DO NOT DISTRIBUTE WITHOUT AUTHORIZATION

+
+
+ + +""" + + return { + "status": "success", + "dossier_html": dossier_html, + "graph_image_url": graph_image_url, + "message": "Dossier generated successfully. Save the HTML and print to PDF.", + } diff --git a/app/_archive/legacy_2026_07/content_router.py b/app/_archive/legacy_2026_07/content_router.py new file mode 100644 index 0000000..a41d5ef --- /dev/null +++ b/app/_archive/legacy_2026_07/content_router.py @@ -0,0 +1,46 @@ +"""Content API - Ghost-powered unified feed.""" + +from fastapi import APIRouter, Request + +router = APIRouter(prefix="/api/v1/content", tags=["content"]) + + +@router.post("/post") +async def create_post(request: Request, data: dict): + from app.content_platform import create_post + + return await create_post( + content=data.get("content", ""), + title=data.get("title", ""), + tags=data.get("tags", []), + post_type=data.get("post_type", "micro"), + status=data.get("status", "published"), + ) + + +@router.get("/feed") +async def feed(request: Request, page: int = 1, limit: int = 20): + from app.content_platform import get_feed + + return await get_feed(page=page, limit=limit) + + +@router.get("/posts") +async def posts(request: Request, page: int = 1, limit: int = 20, tag: str = ""): + from app.content_platform import get_posts + + return await get_posts(page=page, limit=limit, tag=tag) + + +@router.get("/profile") +async def profile(request: Request): + from app.content_platform import PROFILE + + return PROFILE + + +@router.get("/health") +async def content_health(request: Request): + from app.content_platform import check_health + + return await check_health() diff --git a/app/_archive/legacy_2026_07/cross_token_router.py b/app/_archive/legacy_2026_07/cross_token_router.py new file mode 100644 index 0000000..1cd6092 --- /dev/null +++ b/app/_archive/legacy_2026_07/cross_token_router.py @@ -0,0 +1,82 @@ +""" +Cross-Token Tracking API Router - Track wallets across multiple tokens. +Connects to /api/v1/cross-token/* +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/cross-token", tags=["cross-token"]) + +# Lazy import - cross_token.py has broken internal deps +PROJECT_TOKENS = { + "CRM": "Eme5T2s2HB7B8W4YgLG1eReQpnadEVUnQBRjaKTdBAGS", + "SOSANA": "SoSaNaTokenAddressPlaceholder123456789", + "PBTC": "pBTC Token Address Placeholder", + "SHIFT_AI": "ShiftAITokenAddressPlaceholder123456", +} + + +class CrossTokenRequest(BaseModel): + wallet: str + token_addresses: list[str] | None = None + + +@router.get("/projects") +async def list_projects(): + """List all tracked token projects.""" + return {"projects": PROJECT_TOKENS, "count": len(PROJECT_TOKENS)} + + +@router.post("/affiliations") +async def track_affiliations(req: CrossTokenRequest): + """Track a wallet's affiliations across all known token projects.""" + try: + from app.cross_token import CrossTokenAffiliationTracker + + tracker = CrossTokenAffiliationTracker() + affiliations = await tracker.track_wallet_affiliations(wallet=req.wallet) + return { + "wallet": req.wallet, + "affiliations": [ + { + "project": a.project, + "token_address": a.token_address, + "balance": a.balance, + "first_acquired": str(a.first_acquired) if a.first_acquired else None, + "evidence_strength": a.evidence_strength, + } + for a in affiliations + ], + "project_count": len(affiliations), + } + except ImportError as e: + raise HTTPException(status_code=503, detail=f"Module unavailable: {e}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:500]) from e + + +@router.get("/connections/{wallet}") +async def get_cross_connections(wallet: str): + """Find cross-token connections for a wallet.""" + try: + from app.cross_token import CrossTokenAffiliationTracker + + tracker = CrossTokenAffiliationTracker() + affiliations = await tracker.track_wallet_affiliations(wallet=wallet) + multi = [a for a in affiliations if a.evidence_strength != "unverified"] + return { + "wallet": wallet, + "multi_project": len(multi), + "projects": [a.project for a in multi], + "total_affiliations": len(affiliations), + } + except ImportError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:500]) from e + + +@router.get("/health") +async def cross_token_health(): + return {"status": "ok", "service": "cross-token-tracker"} diff --git a/app/_archive/legacy_2026_07/darkroom_airdrop.py b/app/_archive/legacy_2026_07/darkroom_airdrop.py new file mode 100644 index 0000000..f7ff718 --- /dev/null +++ b/app/_archive/legacy_2026_07/darkroom_airdrop.py @@ -0,0 +1,497 @@ +""" +Darkroom Airdrop Admin Router +============================== +Admin-only endpoints for airdrop operations: + • Snapshot creation from existing tokens + • Team/dev token allocation with vesting + • Anti-sniper protection configuration + • Batch airdrop execution + • Vesting management + +All endpoints require X-Admin-Key header. +""" + +import json +import logging +import os +import time +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from app.airdrop_engine import ( + AirdropCampaign, + AirdropDistributor, + AirdropStorage, + AntiSniperProtection, + SnapshotEngine, + TeamAllocation, + create_full_token_with_protection, +) +from app.token_deployer import TokenDeployerFactory, get_storage + +logger = logging.getLogger("darkroom_airdrop_admin") + +router = APIRouter(prefix="/api/v1/admin/tokens/airdrop", tags=["darkroom-airdrop"]) + + +# ── Auth helper ─────────────────────────────────────────────── + + +async def _verify_admin(request: Request): + admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me") + key = request.headers.get("X-Admin-Key", "") + if key != admin_key: + raise HTTPException(status_code=401, detail="Invalid admin key") + return True + + +# ── Request Models ──────────────────────────────────────────── + + +class SnapshotRequest(BaseModel): + source_token: str + source_chain: str = Field(..., description="ethereum, base, bsc, solana") + min_holdings: str = "0" + excluded_addresses: list[str] = Field(default_factory=list) + block_number: int | None = None + + +class AirdropExecuteRequest(BaseModel): + deployment_id: str + snapshot_id: str | None = None + distribution_type: str = "snapshot" # snapshot, manual, team + recipients: list[dict[str, str]] = Field(default_factory=list) + # For manual: [{"address": "0x...", "amount": "1000", "reason": "marketing"}] + + +class TeamAllocationRequest(BaseModel): + deployment_id: str + allocations: list[dict[str, Any]] = Field( + ..., + description=""" + [ + {"address": "0x...", "role": "dev", "percent": 15, "immediate": 20, + "vested": 80, "cliff_months": 6, "vesting_months": 24}, + {"address": "0x...", "role": "marketing", "percent": 10, "immediate": 50, + "vested": 50, "cliff_months": 3, "vesting_months": 12} + ] + """, + ) + + +class AntiSniperRequest(BaseModel): + deployment_id: str + blacklist_addresses: list[str] = Field(default_factory=list) + trading_delay_blocks: int = 0 + max_wallet_percent: float = 2.0 + max_tx_percent: float = 1.0 + enable_trading_at_block: int | None = None + + +class FullLaunchRequest(BaseModel): + chain: str + name: str + symbol: str + decimals: int = 18 + initial_supply: str = "1000000000" # 1B default + team_allocations: list[dict[str, Any]] = Field(default_factory=list) + airdrop_source_token: str | None = None + airdrop_source_chain: str | None = None + anti_sniper: bool = True + trading_delay_blocks: int = 0 + max_wallet_percent: float = 2.0 + max_tx_percent: float = 1.0 + blacklist_addresses: list[str] = Field(default_factory=list) + # Quick team presets + dev_percent: float | None = None + marketing_percent: float | None = None + treasury_percent: float | None = None + airdrop_percent: float | None = None + dev_address: str | None = None + marketing_address: str | None = None + treasury_address: str | None = None + + +class VestingClaimRequest(BaseModel): + schedule_id: str + + +class EnableTradingRequest(BaseModel): + deployment_id: str + + +# ── Endpoints ───────────────────────────────────────────────── + + +@router.post("/snapshot") +async def create_snapshot(request: Request, body: SnapshotRequest, _=Depends(_verify_admin)): + """Create a snapshot of token holders for airdrop eligibility.""" + try: + if body.source_chain in ["ethereum", "base", "bsc"]: + rpc = os.getenv(f"{body.source_chain.upper()}_RPC_URL", "") + if not rpc: + raise HTTPException(status_code=400, detail=f"No RPC configured for {body.source_chain}") + + snapshot = await SnapshotEngine.create_evm_snapshot( + token_address=body.source_token, + chain=body.source_chain, + rpc_url=rpc, + min_holdings=body.min_holdings, + excluded_addresses=body.excluded_addresses, + block_number=body.block_number, + ) + elif body.source_chain == "solana": + rpc = os.getenv("SOLANA_RPC_URL", "") + if not rpc: + raise HTTPException(status_code=400, detail="No Solana RPC configured") + + snapshot = await SnapshotEngine.create_solana_snapshot( + token_address=body.source_token, + rpc_url=rpc, + min_holdings=body.min_holdings, + excluded_addresses=body.excluded_addresses, + ) + else: + raise HTTPException(status_code=400, detail=f"Unsupported chain: {body.source_chain}") + + await AirdropStorage.save_snapshot(snapshot) + + return { + "success": True, + "snapshot": { + "snapshot_id": snapshot.snapshot_id, + "source_token": snapshot.source_token, + "source_chain": snapshot.source_chain, + "block_number": snapshot.block_number, + "total_holders": snapshot.total_holders, + "total_supply_snapshotted": snapshot.total_supply_snapshotted, + "timestamp": snapshot.timestamp, + }, + "holders_preview": [{"address": h.address, "amount": h.amount} for h in snapshot.holders[:10]], + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Snapshot failed: {e}") + raise HTTPException(status_code=500, detail=f"Snapshot failed: {e!s}") from e + + +@router.post("/execute") +async def execute_airdrop(request: Request, body: AirdropExecuteRequest, _=Depends(_verify_admin)): + """Execute airdrop distribution.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + + # Get recipients + recipients = [] + if body.snapshot_id: + # Load from snapshot + snapshot_data = None + if storage.redis: + data = await storage.redis.get(f"airdrop_snapshot:{body.snapshot_id}") + if data: + snapshot_data = json.loads(data) + + if not snapshot_data: + raise HTTPException(status_code=404, detail="Snapshot not found") + + from app.airdrop_engine import AirdropRecipient + + recipients = [AirdropRecipient(**r) for r in snapshot_data.get("holders", [])] + elif body.recipients: + from app.airdrop_engine import AirdropRecipient + + recipients = [AirdropRecipient(**r) for r in body.recipients] + else: + raise HTTPException(status_code=400, detail="No recipients provided (need snapshot_id or recipients)") + + # Execute based on chain + if deployment.chain in ["ethereum", "base", "bsc"]: + result = await AirdropDistributor.execute_evm_airdrop(deployer, deployment.contract_address, recipients) + elif deployment.chain == "solana": + result = await AirdropDistributor.execute_solana_airdrop(deployer, deployment.contract_address, recipients) + else: + raise HTTPException(status_code=400, detail=f"Airdrop not supported for {deployment.chain}") + + # Save campaign + campaign = AirdropCampaign( + campaign_id=f"airdrop_{body.deployment_id}_{int(time.time())}", + deployment_id=body.deployment_id, + snapshot_id=body.snapshot_id or "manual", + chain=deployment.chain, + status="completed" if result["failed"] == 0 else "partial", + distribution_type=body.distribution_type, + recipients=recipients, + total_amount=str(sum(int(r.amount) for r in recipients)), + distributed_amount=str(sum(int(r.amount) for r in recipients if r.claimed)), + tx_hashes=result["tx_hashes"], + ) + await AirdropStorage.save_campaign(campaign) + + return { + "success": True, + "campaign_id": campaign.campaign_id, + "result": result, + "total_recipients": len(recipients), + "distributed": campaign.distributed_amount, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Airdrop execution failed: {e}") + raise HTTPException(status_code=500, detail=f"Airdrop failed: {e!s}") from e + + +@router.post("/team") +async def allocate_team(request: Request, body: TeamAllocationRequest, _=Depends(_verify_admin)): + """Allocate team/dev tokens with optional vesting.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + + result = await TeamAllocation.allocate_team_tokens(deployer, deployment, body.allocations) + + return { + "success": True, + "deployment_id": body.deployment_id, + "allocations": result["allocations"], + "total_allocated": str(result["total_allocated"]), + "tx_hashes": result["tx_hashes"], + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Team allocation failed: {e}") + raise HTTPException(status_code=500, detail=f"Team allocation failed: {e!s}") from e + + +@router.post("/antisniper") +async def apply_antisniper(request: Request, body: AntiSniperRequest, _=Depends(_verify_admin)): + """Apply anti-sniper protection to a deployed token.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + + result = await AntiSniperProtection.apply_protection( + deployer, + deployment.contract_address, + deployment, + blacklist_addresses=body.blacklist_addresses, + trading_delay_blocks=body.trading_delay_blocks, + max_wallet_percent=body.max_wallet_percent, + max_tx_percent=body.max_tx_percent, + ) + + # Update deployment record + await storage.update_status(body.deployment_id, "anti_sniper_applied", {"anti_sniper": result}) + + return { + "success": True, + "deployment_id": body.deployment_id, + "protection_applied": result, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Anti-sniper failed: {e}") + raise HTTPException(status_code=500, detail=f"Anti-sniper failed: {e!s}") from e + + +@router.post("/launch") +async def full_launch(request: Request, body: FullLaunchRequest, _=Depends(_verify_admin)): + """ + Full token launch with everything: deploy, anti-sniper, team allocation, airdrop. + This is the one-shot launch endpoint for CRM v2 or any new token. + """ + try: + # Build team allocations from quick presets if provided + team_allocs = body.team_allocations or [] + + if body.dev_percent and body.dev_address: + team_allocs.append( + { + "address": body.dev_address, + "role": "development", + "percent": body.dev_percent, + "immediate": 20, + "vested": 80, + "cliff_months": 6, + "vesting_months": 24, + } + ) + + if body.marketing_percent and body.marketing_address: + team_allocs.append( + { + "address": body.marketing_address, + "role": "marketing", + "percent": body.marketing_percent, + "immediate": 50, + "vested": 50, + "cliff_months": 3, + "vesting_months": 12, + } + ) + + if body.treasury_percent and body.treasury_address: + team_allocs.append( + { + "address": body.treasury_address, + "role": "treasury", + "percent": body.treasury_percent, + "immediate": 0, + "vested": 100, + "cliff_months": 12, + "vesting_months": 36, + } + ) + + result = await create_full_token_with_protection( + chain=body.chain, + name=body.name, + symbol=body.symbol, + decimals=body.decimals, + initial_supply=body.initial_supply, + team_allocations=team_allocs, + airdrop_source_token=body.airdrop_source_token, + airdrop_source_chain=body.airdrop_source_chain, + anti_sniper=body.anti_sniper, + trading_delay_blocks=body.trading_delay_blocks, + max_wallet_percent=body.max_wallet_percent, + max_tx_percent=body.max_tx_percent, + blacklist_addresses=body.blacklist_addresses, + ) + + return { + "success": True, + "launch_complete": True, + "deployment": result.get("deployment"), + "anti_sniper": result.get("anti_sniper"), + "team_allocation": result.get("team_allocation"), + "airdrop": result.get("airdrop"), + "errors": result.get("errors", []), + "explorer_url": _get_explorer_url(body.chain, result["deployment"]["contract_address"]) + if result.get("deployment") + else None, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Full launch failed: {e}") + raise HTTPException(status_code=500, detail=f"Launch failed: {e!s}") from e + + +@router.post("/trading/enable") +async def enable_trading(request: Request, body: EnableTradingRequest, _=Depends(_verify_admin)): + """Enable trading for a token (after anti-sniper delay).""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.set_trading_enabled(deployment.contract_address, True) + + await storage.update_status(body.deployment_id, "trading_enabled", {"trading_enabled_tx": tx_hash}) + + return { + "success": True, + "tx_hash": tx_hash, + "trading_enabled": True, + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Enable trading failed: {e}") + raise HTTPException(status_code=500, detail=f"Enable trading failed: {e!s}") from e + + +@router.get("/campaign/{campaign_id}") +async def get_campaign(request: Request, campaign_id: str, _=Depends(_verify_admin)): + """Get airdrop campaign status.""" + try: + campaign = await AirdropStorage.get_campaign(campaign_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + return { + "campaign": campaign.to_dict(), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get campaign failed: {e}") + raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e + + +@router.get("/vesting/{schedule_id}") +async def get_vesting(request: Request, schedule_id: str, _=Depends(_verify_admin)): + """Get vesting schedule details.""" + try: + from app.token_deployer import get_storage + + storage = await get_storage() + + data = None + if storage.redis: + data = await storage.redis.get(f"vesting:{schedule_id}") + + if not data: + raise HTTPException(status_code=404, detail="Vesting schedule not found") + + return { + "vesting": json.loads(data), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get vesting failed: {e}") + raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e + + +# ── Helpers ─────────────────────────────────────────────────── + + +def _get_explorer_url(chain: str, address_or_tx: str, is_tx: bool = False) -> str: + explorers = { + "ethereum": "https://etherscan.io", + "base": "https://basescan.org", + "bsc": "https://bscscan.com", + "solana": "https://solscan.io", + "tron": "https://tronscan.org/#", + } + base = explorers.get(chain, "") + if not base: + return "" + + if chain == "tron": + path = "transaction" if is_tx else "contract" + return f"{base}/{path}/{address_or_tx}" + + path = "tx" if is_tx else "address" + return f"{base}/{path}/{address_or_tx}" diff --git a/app/_archive/legacy_2026_07/darkroom_multichain.py b/app/_archive/legacy_2026_07/darkroom_multichain.py new file mode 100644 index 0000000..c43a6a7 --- /dev/null +++ b/app/_archive/legacy_2026_07/darkroom_multichain.py @@ -0,0 +1,641 @@ +""" +Darkroom Multi-Chain Airdrop Admin Router +========================================== +Admin endpoints for cross-chain airdrop campaigns. + +Supports: + • Multi-source snapshots (Base + Solana + any chain) + • Weighted allocation based on holdings + • Cross-chain bonuses for multi-chain holders + • CRM + CryptoRugMunch specific presets + +All endpoints require X-Admin-Key header. +""" + +import logging +import os + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from app.multichain_airdrop import ( + CrossChainIdentityResolver, + MultiChainAirdropManager, + TokenSource, + create_crm_multichain_airdrop, +) +from app.token_deployer import get_storage + +logger = logging.getLogger("darkroom_multichain_admin") + +router = APIRouter(prefix="/api/v1/admin/tokens/multichain", tags=["darkroom-multichain"]) + + +# ── Auth helper ─────────────────────────────────────────────── + + +async def _verify_admin(request: Request): + admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me") + key = request.headers.get("X-Admin-Key", "") + if key != admin_key: + raise HTTPException(status_code=401, detail="Invalid admin key") + return True + + +# ── Request Models ──────────────────────────────────────────── + + +class TokenSourceConfig(BaseModel): + chain: str + token_address: str + token_symbol: str + weight_multiplier: float = 1.0 + min_holdings: str = "0" + max_holdings: str | None = None + + +class CreateCampaignRequest(BaseModel): + target_chain: str + target_token: str + source_tokens: list[TokenSourceConfig] + total_pool: str + distribution_mode: str = "proportional" + require_hold_both_chains: bool = False + require_hold_any_chain: bool = True + cross_chain_bonus: float = 0.0 + exclude_contracts: bool = True + exclude_known_bots: bool = True + min_wallet_age_days: int | None = None + min_tx_count_30d: int | None = None + snapshot_time_window_hours: int = 24 + + +class CRMPresetRequest(BaseModel): + crm_base_address: str | None = None + crm_solana_address: str + cryptorugmunch_zora_address: str # Zora tokens are on Base + target_chain: str = "solana" + target_token: str = "" + total_pool: str | None = None # Auto-calculated for 1:1 + mode: str = "one_to_one" # one_to_one, proportional, tiered + + +class CRMv2LaunchRequest(BaseModel): + crm_solana_address: str + cryptorugmunch_zora_address: str + target_chain: str = "solana" + target_token: str = "" + # Deploy params if target_token not provided + deploy_new_token: bool = False + token_name: str = "CRM Token v2" + token_symbol: str = "CRMv2" + decimals: int = 18 + # Anti-sniper + anti_sniper: bool = True + blacklist_addresses: list[str] = Field(default_factory=list) + max_wallet_percent: float = 2.0 + max_tx_percent: float = 1.0 + # Team allocation + dev_address: str | None = None + dev_percent: float = 15.0 + marketing_address: str | None = None + marketing_percent: float = 10.0 + treasury_address: str | None = None + treasury_percent: float = 15.0 + + +class ExecuteSnapshotRequest(BaseModel): + campaign_id: str + use_custom_snapshot: bool = False + custom_snapshot_format: str = "json" # json, csv, manual + custom_snapshot_data: str | None = None # JSON string or CSV content + custom_snapshot_file: str | None = None # Path to uploaded file + + +class UploadSnapshotRequest(BaseModel): + campaign_id: str + format: str = "json" # json, csv + data: str # JSON array or CSV content + deduplicate: bool = True + validate_balances: bool = False + + +class ManualHoldersRequest(BaseModel): + campaign_id: str + holders: list[dict[str, str]] # [{"address": "0x...", "amount": "1000", "chain": "solana", "token": "CRM"}] + append: bool = False # True = append to existing, False = replace + + +class ExecuteDistributionRequest(BaseModel): + campaign_id: str + + +class LinkAddressesRequest(BaseModel): + primary_address: str + primary_chain: str + linked_chain: str + linked_address: str + proof_signature: str = "" + + +class GetCampaignRequest(BaseModel): + campaign_id: str + + +class TierConfig(BaseModel): + name: str + min_score: float + max_score: float | None = None + base_allocation: str + bonus_multiplier: float = 1.0 + + +class CreateTieredCampaignRequest(BaseModel): + target_chain: str + target_token: str + source_tokens: list[TokenSourceConfig] + tiers: list[TierConfig] + total_pool: str + cross_chain_bonus: float = 0.0 + + +# ── Endpoints ───────────────────────────────────────────────── + + +@router.post("/campaign/create") +async def create_campaign(request: Request, body: CreateCampaignRequest, _=Depends(_verify_admin)): + """Create a new multi-chain airdrop campaign.""" + try: + sources = [ + TokenSource( + chain=s.chain, + token_address=s.token_address, + token_symbol=s.token_symbol, + weight_multiplier=s.weight_multiplier, + min_holdings=s.min_holdings, + max_holdings=s.max_holdings, + ) + for s in body.source_tokens + ] + + campaign = await MultiChainAirdropManager.create_campaign( + target_chain=body.target_chain, + target_token=body.target_token, + source_tokens=sources, + total_pool=body.total_pool, + distribution_mode=body.distribution_mode, + require_hold_both_chains=body.require_hold_both_chains, + require_hold_any_chain=body.require_hold_any_chain, + cross_chain_bonus=body.cross_chain_bonus, + exclude_contracts=body.exclude_contracts, + exclude_known_bots=body.exclude_known_bots, + min_wallet_age_days=body.min_wallet_age_days, + min_tx_count_30d=body.min_tx_count_30d, + snapshot_time_window_hours=body.snapshot_time_window_hours, + ) + + return { + "success": True, + "campaign": campaign.to_dict(), + } + + except Exception as e: + logger.error(f"Create campaign failed: {e}") + raise HTTPException(status_code=500, detail=f"Create failed: {e!s}") from e + + +@router.post("/campaign/crm-preset") +async def create_crm_preset(request: Request, body: CRMPresetRequest, _=Depends(_verify_admin)): + """ + Create CRM v2 airdrop campaign. + + Modes: + - one_to_one: Holders receive exact same amount they hold (default) + - proportional: Weighted proportional distribution + + Default (1:1): + - Hold $CRM on Solana → receive exact same amount of CRMv2 + - Hold CryptoRugMunch on Zora → receive exact same amount of CRMv2 + - Hold both → receive SUM of both holdings + """ + try: + if body.mode == "one_to_one": + from app.multichain_airdrop import create_crm_v2_airdrop_preset + + campaign = await create_crm_v2_airdrop_preset( + crm_solana_address=body.crm_solana_address, + cryptorugmunch_zora_address=body.cryptorugmunch_zora_address, + target_chain=body.target_chain, + target_token=body.target_token, + ) + rules = { + "mode": "1:1 exact match", + "hold_any_of": [ + "CRM on Solana (any amount) → receive exact same amount", + "CryptoRugMunch on Zora (any amount) → receive exact same amount", + ], + "multiple_holdings": "SUM of all holdings", + "distribution": f"1:1 on {body.target_chain}", + } + else: + # Proportional mode (legacy) + campaign = await create_crm_multichain_airdrop( + crm_base_address=body.crm_base_address or "", + crm_solana_address=body.crm_solana_address, + cryptorugmunch_solana_address=body.cryptorugmunch_zora_address, + target_chain=body.target_chain, + target_token=body.target_token, + total_pool=body.total_pool or "100000000", + ) + rules = { + "mode": "proportional", + "hold_any_of": [ + "CRM on Base (>= 1000, weight 2.0x)", + "CRM on Solana (>= 1000, weight 1.5x)", + "CryptoRugMunch on Zora (>= 1, weight 1.0x)", + ], + "cross_chain_bonus": "10% per additional chain", + "distribution": f"Proportional on {body.target_chain}", + } + + return { + "success": True, + "campaign": campaign.to_dict(), + "preset": "CRM v2 Airdrop", + "qualification_rules": rules, + } + + except Exception as e: + logger.error(f"CRM preset failed: {e}") + raise HTTPException(status_code=500, detail=f"Preset failed: {e!s}") from e + + +@router.post("/snapshot") +async def execute_snapshot(request: Request, body: ExecuteSnapshotRequest, _=Depends(_verify_admin)): + """Execute snapshot phase for a campaign.""" + try: + # Use custom snapshot if provided + if body.use_custom_snapshot: + from app.multichain_airdrop import CustomSnapshotLoader + + if body.custom_snapshot_format == "json" and body.custom_snapshot_data: + holders = CustomSnapshotLoader.parse_json_snapshot(body.custom_snapshot_data) + elif body.custom_snapshot_format == "csv" and body.custom_snapshot_data: + holders = CustomSnapshotLoader.parse_csv_snapshot(body.custom_snapshot_data) + else: + raise HTTPException(status_code=400, detail="Custom snapshot data required") + + campaign = await MultiChainAirdropManager.execute_custom_snapshot( + body.campaign_id, holders, deduplicate=True + ) + else: + campaign = await MultiChainAirdropManager.execute_snapshot(body.campaign_id) + + # Build summary + holder_preview = [] + for h in campaign.qualified_holders[:10]: + holder_preview.append( + { + "address": h.primary_address, + "chains": list(h.chain_addresses.keys()), + "holdings": h.holdings_per_source, + "weighted_score": h.weighted_score, + "allocation": h.airdrop_allocation, + } + ) + + return { + "success": True, + "campaign_id": campaign.campaign_id, + "status": campaign.status, + "total_qualified": campaign.total_qualified, + "snapshot_completed_at": campaign.snapshot_completed_at, + "holders_preview": holder_preview, + "total_pool": campaign.total_airdrop_pool, + "snapshot_source": "custom" if body.use_custom_snapshot else "on-chain", + } + + except Exception as e: + logger.error(f"Snapshot failed: {e}") + raise HTTPException(status_code=500, detail=f"Snapshot failed: {e!s}") from e + + +@router.post("/snapshot/upload") +async def upload_snapshot(request: Request, body: UploadSnapshotRequest, _=Depends(_verify_admin)): + """Upload a custom snapshot file (JSON or CSV) for a campaign.""" + try: + from app.multichain_airdrop import CustomSnapshotLoader + + if body.format == "json": + holders = CustomSnapshotLoader.parse_json_snapshot(body.data) + elif body.format == "csv": + holders = CustomSnapshotLoader.parse_csv_snapshot(body.data) + else: + raise HTTPException(status_code=400, detail="Format must be 'json' or 'csv'") + + if body.deduplicate: + holders = CustomSnapshotLoader.deduplicate_holders(holders) + + # Execute custom snapshot + campaign = await MultiChainAirdropManager.execute_custom_snapshot( + body.campaign_id, + holders, + deduplicate=False, # Already deduped + ) + + return { + "success": True, + "campaign_id": body.campaign_id, + "format": body.format, + "holders_parsed": len(holders), + "total_qualified": campaign.total_qualified, + "total_pool": campaign.total_airdrop_pool, + "status": campaign.status, + } + + except Exception as e: + logger.error(f"Upload snapshot failed: {e}") + raise HTTPException(status_code=500, detail=f"Upload failed: {e!s}") from e + + +@router.post("/snapshot/manual") +async def add_manual_holders(request: Request, body: ManualHoldersRequest, _=Depends(_verify_admin)): + """Add manual holders to a campaign (or replace existing).""" + try: + from app.multichain_airdrop import CustomSnapshotLoader + + # Parse manual holders + new_holders = CustomSnapshotLoader.parse_manual_holders(body.holders) + + campaign = await MultiChainAirdropManager._get_campaign(body.campaign_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + if body.append and campaign.qualified_holders: + # Merge with existing + existing = {h.primary_address: h for h in campaign.qualified_holders} + existing.update(new_holders) + all_holders = existing + else: + all_holders = new_holders + + # Re-run snapshot with combined data + campaign = await MultiChainAirdropManager.execute_custom_snapshot( + body.campaign_id, all_holders, deduplicate=True + ) + + return { + "success": True, + "campaign_id": body.campaign_id, + "holders_added": len(body.holders), + "total_qualified": campaign.total_qualified, + "total_pool": campaign.total_airdrop_pool, + "status": campaign.status, + } + + except Exception as e: + logger.error(f"Manual holders failed: {e}") + raise HTTPException(status_code=500, detail=f"Failed: {e!s}") from e + + +@router.post("/distribute") +async def execute_distribution(request: Request, body: ExecuteDistributionRequest, _=Depends(_verify_admin)): + """Execute airdrop distribution to all qualified holders.""" + try: + campaign = await MultiChainAirdropManager.execute_distribution(body.campaign_id) + + return { + "success": True, + "campaign_id": campaign.campaign_id, + "status": campaign.status, + "total_qualified": campaign.total_qualified, + "total_distributed": campaign.total_distributed, + "distribution_completed_at": campaign.distribution_completed_at, + "tx_count": len(campaign.tx_hashes), + "tx_hashes": campaign.tx_hashes[:10], # Preview first 10 + } + + except Exception as e: + logger.error(f"Distribution failed: {e}") + raise HTTPException(status_code=500, detail=f"Distribution failed: {e!s}") from e + + +@router.post("/launch") +async def full_multichain_launch(request: Request, body: CRMv2LaunchRequest, _=Depends(_verify_admin)): + """ + FULL CRM v2 LAUNCH: Deploy token + airdrop in one shot. + + If target_token is empty, deploys a new CRMv2 token first. + Then snapshots CRM (Solana) + CryptoRugMunch (Zora) holders. + Finally distributes 1:1 airdrop to all qualified holders. + """ + try: + target_token = body.target_token + + # 1. Deploy new token if needed + if not target_token and body.deploy_new_token: + from app.token_deployer import DeployParams, TokenDeployerFactory + + deployer = TokenDeployerFactory.get_deployer(body.target_chain) + + deploy_params = DeployParams( + chain=body.target_chain, + name=body.token_name, + symbol=body.token_symbol, + decimals=body.decimals, + initial_supply="1000000000", # 1B total + mintable=True, + burnable=True, + blacklist_enabled=body.anti_sniper, + ) + + deployment = await deployer.deploy_token(deploy_params) + target_token = deployment.contract_address + + # Apply anti-sniper + if body.anti_sniper: + from app.airdrop_engine import AntiSniperProtection + + await AntiSniperProtection.apply_protection( + deployer, + target_token, + deployment, + blacklist_addresses=body.blacklist_addresses, + max_wallet_percent=body.max_wallet_percent, + max_tx_percent=body.max_tx_percent, + ) + + # Save deployment + storage = await get_storage() + await storage.save(deployment) + + # 2. Create 1:1 airdrop campaign + from app.multichain_airdrop import create_crm_v2_airdrop_preset + + campaign = await create_crm_v2_airdrop_preset( + crm_solana_address=body.crm_solana_address, + cryptorugmunch_zora_address=body.cryptorugmunch_zora_address, + target_chain=body.target_chain, + target_token=target_token, + ) + + # 3. Snapshot + campaign = await MultiChainAirdropManager.execute_snapshot(campaign.campaign_id) + + # 4. Distribute + if campaign.total_qualified > 0: + campaign = await MultiChainAirdropManager.execute_distribution(campaign.campaign_id) + + return { + "success": True, + "campaign_id": campaign.campaign_id, + "target_token": target_token, + "status": campaign.status, + "total_qualified": campaign.total_qualified, + "total_distributed": campaign.total_distributed, + "total_pool": campaign.total_airdrop_pool, + "tx_count": len(campaign.tx_hashes), + "phases": { + "token_deployed": body.deploy_new_token and not body.target_token, + "snapshot_complete": True, + "distribution_complete": campaign.status in ["completed", "partial"], + }, + } + + except Exception as e: + logger.error(f"Full launch failed: {e}") + raise HTTPException(status_code=500, detail=f"Launch failed: {e!s}") from e + + +@router.get("/campaign/{campaign_id}") +async def get_campaign(request: Request, campaign_id: str, _=Depends(_verify_admin)): + """Get campaign details and status.""" + try: + campaign = await MultiChainAirdropManager._get_campaign(campaign_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + return { + "campaign": campaign.to_dict(), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get campaign failed: {e}") + raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e + + +@router.get("/campaign/{campaign_id}/holders") +async def get_campaign_holders( + request: Request, campaign_id: str, limit: int = 100, offset: int = 0, _=Depends(_verify_admin) +): + """Get qualified holders for a campaign.""" + try: + campaign = await MultiChainAirdropManager._get_campaign(campaign_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + holders = campaign.qualified_holders[offset : offset + limit] + + return { + "campaign_id": campaign_id, + "total": campaign.total_qualified, + "offset": offset, + "limit": limit, + "holders": [h.to_dict() for h in holders], + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get holders failed: {e}") + raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e + + +@router.post("/link-addresses") +async def link_addresses(request: Request, body: LinkAddressesRequest, _=Depends(_verify_admin)): + """Manually link cross-chain addresses for a user.""" + try: + result = await CrossChainIdentityResolver.store_linkage( + primary_address=body.primary_address, + primary_chain=body.primary_chain, + linked_chain=body.linked_chain, + linked_address=body.linked_address, + proof_signature=body.proof_signature, + ) + + return { + "success": result, + "primary": f"{body.primary_chain}:{body.primary_address}", + "linked": f"{body.linked_chain}:{body.linked_address}", + } + + except Exception as e: + logger.error(f"Link addresses failed: {e}") + raise HTTPException(status_code=500, detail=f"Link failed: {e!s}") from e + + +@router.get("/link-addresses/{address}/{chain}") +async def get_linked_addresses(request: Request, address: str, chain: str, _=Depends(_verify_admin)): + """Get linked addresses for a wallet.""" + try: + links = await CrossChainIdentityResolver.resolve_linked_addresses(address, chain) + + return { + "primary": f"{chain}:{address}", + "linked_addresses": links, + } + + except Exception as e: + logger.error(f"Get links failed: {e}") + raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e + + +@router.post("/campaign/tiered") +async def create_tiered_campaign(request: Request, body: CreateTieredCampaignRequest, _=Depends(_verify_admin)): + """Create a tiered multi-chain airdrop campaign.""" + try: + sources = [ + TokenSource( + chain=s.chain, + token_address=s.token_address, + token_symbol=s.token_symbol, + weight_multiplier=s.weight_multiplier, + min_holdings=s.min_holdings, + max_holdings=s.max_holdings, + ) + for s in body.source_tokens + ] + + from app.multichain_airdrop import AirdropTier + + tiers = [ + AirdropTier( + name=t.name, + min_score=t.min_score, + max_score=t.max_score, + base_allocation=t.base_allocation, + bonus_multiplier=t.bonus_multiplier, + ) + for t in body.tiers + ] + + campaign = await MultiChainAirdropManager.create_campaign( + target_chain=body.target_chain, + target_token=body.target_token, + source_tokens=sources, + total_pool=body.total_pool, + distribution_mode="tiered", + tiers=tiers, + cross_chain_bonus=body.cross_chain_bonus, + ) + + return { + "success": True, + "campaign": campaign.to_dict(), + } + + except Exception as e: + logger.error(f"Tiered campaign failed: {e}") + raise HTTPException(status_code=500, detail=f"Create failed: {e!s}") from e diff --git a/app/_archive/legacy_2026_07/darkroom_tokens.py b/app/_archive/legacy_2026_07/darkroom_tokens.py new file mode 100644 index 0000000..fd08111 --- /dev/null +++ b/app/_archive/legacy_2026_07/darkroom_tokens.py @@ -0,0 +1,574 @@ +""" +Darkroom Token Admin Router +============================= +Admin-only endpoints for deploying, minting, and managing tokens +across multiple chains. Includes blacklist/anti-bot controls. + +All endpoints require X-Admin-Key header. + +Routes: + POST /api/v1/admin/tokens/deploy - Deploy new token + POST /api/v1/admin/tokens/mint - Mint additional tokens + POST /api/v1/admin/tokens/burn - Burn tokens + POST /api/v1/admin/tokens/transfer - Transfer ownership + POST /api/v1/admin/tokens/renounce - Renounce ownership + POST /api/v1/admin/tokens/blacklist - Add to blacklist + POST /api/v1/admin/tokens/unblacklist - Remove from blacklist + GET /api/v1/admin/tokens/list - List all deployments + GET /api/v1/admin/tokens/{id} - Get deployment details + GET /api/v1/admin/tokens/chains - List supported chains +""" + +import logging +import os + +from fastapi import APIRouter, Body, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from app.token_deployer import ( + DeployParams, + TokenDeployerFactory, + get_storage, +) + +logger = logging.getLogger("darkroom_token_admin") + +router = APIRouter(prefix="/api/v1/admin/tokens", tags=["darkroom-tokens"]) + + +# ── Auth helper ─────────────────────────────────────────────── + + +async def _verify_admin(request: Request): + """Verify admin access with API key.""" + admin_key = os.getenv("ADMIN_API_KEY", "dev-key-change-me") + key = request.headers.get("X-Admin-Key", "") + if key != admin_key: + raise HTTPException(status_code=401, detail="Invalid admin key") + return True + + +# ── Request Models ────────────────────────────────────────── + + +class DeployTokenRequest(BaseModel): + chain: str = Field(..., description="Chain: ethereum, base, bsc, solana, tron") + name: str = Field(..., min_length=1, max_length=50) + symbol: str = Field(..., min_length=1, max_length=10) + decimals: int = Field(default=18, ge=0, le=18) + initial_supply: str = Field(default="1000000") + max_supply: str | None = None + mintable: bool = True + burnable: bool = True + pausable: bool = False + owner_address: str | None = None + metadata_uri: str | None = None + # Blacklist / anti-bot + blacklist_enabled: bool = True + blacklist_addresses: list[str] = Field(default_factory=list) + max_wallet_limit: str | None = None + max_tx_limit: str | None = None + trading_enabled: bool = True + anti_bot_delay: int = 0 + # Advanced + tax_rate: float | None = None + tax_recipient: str | None = None + deflationary: bool = False + + +class MintRequest(BaseModel): + deployment_id: str + to_address: str + amount: str + + +class BurnRequest(BaseModel): + deployment_id: str + amount: str + + +class TransferOwnershipRequest(BaseModel): + deployment_id: str + new_owner: str + + +class BlacklistRequest(BaseModel): + deployment_id: str + address: str + + +class SetLimitRequest(BaseModel): + deployment_id: str + amount: str + + +class TradingToggleRequest(BaseModel): + deployment_id: str + enabled: bool + + +# ── Endpoints ───────────────────────────────────────────────── + + +@router.get("/chains") +async def list_chains(request: Request, _=Depends(_verify_admin)): + """List all supported chains and their configuration status.""" + chains = TokenDeployerFactory.list_supported_chains() + return { + "chains": chains, + "total": len(chains), + "configured": sum(1 for c in chains if c["configured"]), + } + + +@router.post("/deploy") +async def deploy_token(request: Request, body: DeployTokenRequest, _=Depends(_verify_admin)): + """Deploy a new token on the specified chain.""" + try: + # Get deployer for chain + deployer = TokenDeployerFactory.get_deployer(body.chain) + + # Build params + params = DeployParams( + chain=body.chain, + name=body.name, + symbol=body.symbol, + decimals=body.decimals, + initial_supply=body.initial_supply, + max_supply=body.max_supply, + mintable=body.mintable, + burnable=body.burnable, + pausable=body.pausable, + owner_address=body.owner_address or deployer.deployer_address, + metadata_uri=body.metadata_uri, + blacklist_enabled=body.blacklist_enabled, + blacklist_addresses=body.blacklist_addresses, + max_wallet_limit=body.max_wallet_limit, + max_tx_limit=body.max_tx_limit, + trading_enabled=body.trading_enabled, + anti_bot_delay=body.anti_bot_delay, + tax_rate=body.tax_rate, + tax_recipient=body.tax_recipient, + deflationary=body.deflationary, + ) + + # Deploy + deployment = await deployer.deploy_token(params) + + # Apply initial blacklist if provided + if body.blacklist_addresses and body.blacklist_enabled: + for addr in body.blacklist_addresses: + try: + await deployer.blacklist_add(deployment.contract_address, addr) + logger.info(f"Blacklisted {addr} on {deployment.contract_address}") + except Exception as e: + logger.warning(f"Failed to blacklist {addr}: {e}") + + # Apply max limits if set + if body.max_wallet_limit: + try: + await deployer.set_max_wallet(deployment.contract_address, body.max_wallet_limit) + except Exception as e: + logger.warning(f"Failed to set max wallet: {e}") + + if body.max_tx_limit: + try: + await deployer.set_max_tx(deployment.contract_address, body.max_tx_limit) + except Exception as e: + logger.warning(f"Failed to set max tx: {e}") + + # Save deployment record + storage = await get_storage() + await storage.save(deployment) + + return { + "success": True, + "deployment": deployment.to_dict(), + "explorer_url": _get_explorer_url(body.chain, deployment.contract_address), + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.error(f"Token deployment failed: {e}") + raise HTTPException(status_code=500, detail=f"Deployment failed: {e!s}") from e + + +@router.post("/mint") +async def mint_tokens(request: Request, body: MintRequest, _=Depends(_verify_admin)): + """Mint additional tokens for a deployed contract.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + if deployment.status != "deployed": + raise HTTPException(status_code=400, detail=f"Cannot mint - status is {deployment.status}") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.mint_tokens(deployment.contract_address, body.to_address, body.amount) + + await storage.update_status(body.deployment_id, "minting", {"last_mint_tx": tx_hash}) + + return { + "success": True, + "tx_hash": tx_hash, + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Mint failed: {e}") + raise HTTPException(status_code=500, detail=f"Mint failed: {e!s}") from e + + +@router.post("/burn") +async def burn_tokens(request: Request, body: BurnRequest, _=Depends(_verify_admin)): + """Burn tokens from deployer balance.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.burn_tokens(deployment.contract_address, body.amount) + + await storage.update_status(body.deployment_id, "burned", {"last_burn_tx": tx_hash}) + + return { + "success": True, + "tx_hash": tx_hash, + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Burn failed: {e}") + raise HTTPException(status_code=500, detail=f"Burn failed: {e!s}") from e + + +@router.post("/transfer") +async def transfer_ownership(request: Request, body: TransferOwnershipRequest, _=Depends(_verify_admin)): + """Transfer contract ownership to a new address.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.transfer_ownership(deployment.contract_address, body.new_owner) + + await storage.update_status( + body.deployment_id, + "ownership_transferred", + {"new_owner": body.new_owner, "transfer_tx": tx_hash}, + ) + + return { + "success": True, + "tx_hash": tx_hash, + "new_owner": body.new_owner, + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Ownership transfer failed: {e}") + raise HTTPException(status_code=500, detail=f"Transfer failed: {e!s}") from e + + +@router.post("/renounce") +async def renounce_ownership(request: Request, body: dict = Body(...), _=Depends(_verify_admin)): + """Renounce contract ownership (make immutable). WARNING: Irreversible.""" + deployment_id = body.get("deployment_id", "") + + try: + storage = await get_storage() + deployment = await storage.get(deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.renounce_ownership(deployment.contract_address) + + await storage.update_status(deployment_id, "ownership_renounced", {"renounce_tx": tx_hash}) + + return { + "success": True, + "tx_hash": tx_hash, + "warning": "Ownership renounced. Contract is now immutable.", + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Renounce failed: {e}") + raise HTTPException(status_code=500, detail=f"Renounce failed: {e!s}") from e + + +# ── Blacklist / Anti-Bot Endpoints ──────────────────────────── + + +@router.post("/blacklist") +async def blacklist_add(request: Request, body: BlacklistRequest, _=Depends(_verify_admin)): + """Add an address to the token blacklist.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.blacklist_add(deployment.contract_address, body.address) + + return { + "success": True, + "tx_hash": tx_hash, + "address": body.address, + "action": "blacklisted", + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Blacklist add failed: {e}") + raise HTTPException(status_code=500, detail=f"Blacklist failed: {e!s}") from e + + +@router.post("/unblacklist") +async def blacklist_remove(request: Request, body: BlacklistRequest, _=Depends(_verify_admin)): + """Remove an address from the token blacklist.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.blacklist_remove(deployment.contract_address, body.address) + + return { + "success": True, + "tx_hash": tx_hash, + "address": body.address, + "action": "unblacklisted", + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Unblacklist failed: {e}") + raise HTTPException(status_code=500, detail=f"Unblacklist failed: {e!s}") from e + + +@router.get("/blacklist/check") +async def check_blacklist(request: Request, deployment_id: str, address: str, _=Depends(_verify_admin)): + """Check if an address is blacklisted.""" + try: + storage = await get_storage() + deployment = await storage.get(deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + is_blacklisted = await deployer.is_blacklisted(deployment.contract_address, address) + + return { + "address": address, + "is_blacklisted": is_blacklisted, + "contract": deployment.contract_address, + "chain": deployment.chain, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Blacklist check failed: {e}") + raise HTTPException(status_code=500, detail=f"Check failed: {e!s}") from e + + +@router.post("/trading") +async def set_trading(request: Request, body: TradingToggleRequest, _=Depends(_verify_admin)): + """Enable or disable trading for the token.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.set_trading_enabled(deployment.contract_address, body.enabled) + + return { + "success": True, + "tx_hash": tx_hash, + "trading_enabled": body.enabled, + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Trading toggle failed: {e}") + raise HTTPException(status_code=500, detail=f"Toggle failed: {e!s}") from e + + +@router.post("/max-wallet") +async def set_max_wallet(request: Request, body: SetLimitRequest, _=Depends(_verify_admin)): + """Set maximum wallet holding limit.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.set_max_wallet(deployment.contract_address, body.amount) + + return { + "success": True, + "tx_hash": tx_hash, + "max_wallet": body.amount, + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Max wallet set failed: {e}") + raise HTTPException(status_code=500, detail=f"Set failed: {e!s}") from e + + +@router.post("/max-tx") +async def set_max_tx(request: Request, body: SetLimitRequest, _=Depends(_verify_admin)): + """Set maximum transaction limit.""" + try: + storage = await get_storage() + deployment = await storage.get(body.deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + tx_hash = await deployer.set_max_tx(deployment.contract_address, body.amount) + + return { + "success": True, + "tx_hash": tx_hash, + "max_tx": body.amount, + "explorer_url": _get_explorer_url(deployment.chain, tx_hash, is_tx=True), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Max tx set failed: {e}") + raise HTTPException(status_code=500, detail=f"Set failed: {e!s}") from e + + +# ── Query Endpoints ─────────────────────────────────────────── + + +@router.get("/list") +async def list_deployments(request: Request, chain: str | None = None, limit: int = 100, _=Depends(_verify_admin)): + """List all token deployments.""" + try: + storage = await get_storage() + deployments = await storage.list_all(chain=chain, limit=limit) + + return { + "deployments": [d.to_dict() for d in deployments], + "total": len(deployments), + "chain_filter": chain, + } + + except Exception as e: + logger.error(f"List failed: {e}") + raise HTTPException(status_code=500, detail=f"List failed: {e!s}") from e + + +@router.get("/{deployment_id}") +async def get_deployment(request: Request, deployment_id: str, _=Depends(_verify_admin)): + """Get deployment details by ID.""" + try: + storage = await get_storage() + deployment = await storage.get(deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + # Get on-chain info + try: + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + token_info = await deployer.get_token_info(deployment.contract_address) + except Exception: + token_info = {} + + return { + "deployment": deployment.to_dict(), + "on_chain_info": token_info, + "explorer_url": _get_explorer_url(deployment.chain, deployment.contract_address), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get deployment failed: {e}") + raise HTTPException(status_code=500, detail=f"Get failed: {e!s}") from e + + +@router.get("/{deployment_id}/balance/{wallet_address}") +async def get_balance(request: Request, deployment_id: str, wallet_address: str, _=Depends(_verify_admin)): + """Get token balance for a specific wallet.""" + try: + storage = await get_storage() + deployment = await storage.get(deployment_id) + if not deployment: + raise HTTPException(status_code=404, detail="Deployment not found") + + deployer = TokenDeployerFactory.get_deployer(deployment.chain) + balance = await deployer.get_balance(deployment.contract_address, wallet_address) + + return { + "wallet": wallet_address, + "balance": balance, + "contract": deployment.contract_address, + "chain": deployment.chain, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Balance check failed: {e}") + raise HTTPException(status_code=500, detail=f"Balance check failed: {e!s}") from e + + +# ── Helpers ─────────────────────────────────────────────────── + + +def _get_explorer_url(chain: str, address_or_tx: str, is_tx: bool = False) -> str: + """Get block explorer URL for a chain.""" + explorers = { + "ethereum": "https://etherscan.io", + "base": "https://basescan.org", + "bsc": "https://bscscan.com", + "solana": "https://solscan.io", + "tron": "https://tronscan.org/#", + } + base = explorers.get(chain, "") + if not base: + return "" + + if chain == "tron": + path = "transaction" if is_tx else "contract" + return f"{base}/{path}/{address_or_tx}" + + path = "tx" if is_tx else "address" + return f"{base}/{path}/{address_or_tx}" diff --git a/app/_archive/legacy_2026_07/databus_extras.py b/app/_archive/legacy_2026_07/databus_extras.py new file mode 100644 index 0000000..efb45d8 --- /dev/null +++ b/app/_archive/legacy_2026_07/databus_extras.py @@ -0,0 +1,58 @@ +"""#2 SSE Streaming + #3 Provider Dashboard endpoints.""" + +import os + +import httpx +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/databus", tags=["databus-extras"]) + +BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000") + + +@router.get("/providers/dashboard") +async def provider_dashboard(): + """Real-time provider health dashboard data - feed into Grafana.""" + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + f"{BACKEND}/api/v1/databus/providers/health", + headers={"X-RMI-Key": os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026")}, + ) + if r.status_code == 200: + data = r.json() + providers = data.get("providers", data) + + # Format for Grafana + panels = [] + for name, health in providers.items() if isinstance(providers, dict) else []: + panels.append( + { + "provider": name, + "status": "healthy" if health.get("healthy", True) else "degraded", + "latency_ms": health.get("avg_latency_ms", 0), + "error_rate": health.get("error_rate", 0), + "circuit": health.get("circuit_state", "closed"), + } + ) + + return { + "providers": panels, + "summary": { + "total": len(panels), + "healthy": sum(1 for p in panels if p["status"] == "healthy"), + "degraded": sum(1 for p in panels if p["status"] != "healthy"), + }, + } + except Exception: + pass + + return {"providers": [], "note": "Provider health API unavailable - check backend"} + + +@router.get("/queue/stats") +async def task_queue_stats(): + """Background task queue statistics.""" + from app.core.task_queue import get_queue_stats + + return await get_queue_stats() diff --git a/app/_archive/legacy_2026_07/databus_gateway.py b/app/_archive/legacy_2026_07/databus_gateway.py new file mode 100644 index 0000000..1d22226 --- /dev/null +++ b/app/_archive/legacy_2026_07/databus_gateway.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""#10 - DataBus Public API Gateway Router. Free tier + x402 paid tier.""" + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel + +from app.databus_gateway import ( + SUPPORTED_CHAINS, + TIER_LIMITS, + _check_rate, + databus_cross_chain, + databus_get, + verify_x402_payment, +) + +router = APIRouter(prefix="/api/v1/databus", tags=["databus-gateway"]) + + +class ChainQuery(BaseModel): + chain: str + endpoint: str = "overview" + params: dict | None = None + + +class CrossChainQuery(BaseModel): + query: str + chains: list[str] | None = None + + +@router.get("/chains") +async def list_chains(): + """List all supported chains with tier availability.""" + return {"chains": SUPPORTED_CHAINS, "total": len(SUPPORTED_CHAINS)} + + +@router.get("/{chain}/{endpoint:path}") +async def query_chain(chain: str, endpoint: str, x_rmi_key: str = Header(None), x_x402_sig: str = Header(None)): + """Query a single DataBus chain. Free tier with rate limiting, paid tier via x402 header.""" + tier = "free" + if x_x402_sig: + valid = await verify_x402_payment(x_x402_sig, f"{chain}/{endpoint}") + if valid: + tier = "pro" + else: + raise HTTPException(402, "Invalid x402 payment signature") + + limits = TIER_LIMITS[tier] + client_id = x_rmi_key or "anonymous" + rate_key = f"databus:{client_id}:{tier}" + + if not _check_rate(rate_key, limits["requests_per_hour"]): + raise HTTPException(429, f"Rate limit exceeded ({limits['requests_per_hour']}/hr for {tier} tier)") + + if chain not in SUPPORTED_CHAINS: + raise HTTPException(404, f"Chain '{chain}' not supported. Use /api/v1/databus/chains for full list.") + + result = await databus_get(chain, endpoint, tier=tier) + return { + "chain": result.chain, + "data": result.data, + "source": result.source, + "cached": result.cached, + "latency_ms": result.latency_ms, + "tier": tier, + } + + +@router.post("/cross-chain") +async def cross_chain_query(query: CrossChainQuery): + """Query multiple DataBus chains in parallel.""" + results = await databus_cross_chain(query.query, query.chains) + return { + "query": query.query, + "chains_queried": list(results.keys()), + "results": {c: {"data": r.data, "cached": r.cached} for c, r in results.items()}, + } diff --git a/app/_archive/legacy_2026_07/db_client.py b/app/_archive/legacy_2026_07/db_client.py new file mode 100644 index 0000000..19782d6 --- /dev/null +++ b/app/_archive/legacy_2026_07/db_client.py @@ -0,0 +1,841 @@ +#!/usr/bin/env python3 +""" +RMI Supabase Database Client +============================ +Persistent PostgreSQL storage via Supabase with Redis caching. +Write-through: Supabase first, then Redis. +Read-through: Redis first, Supabase on miss. +""" + +import asyncio +import json +import logging +import os +from collections.abc import Callable +from datetime import UTC, datetime +from functools import wraps +from typing import Any + +from dotenv import load_dotenv + +# Load .env with override to ensure JWT keys win over stale Docker env vars +load_dotenv("/app/.env", override=True) + +from pydantic import BaseModel, Field # noqa: E402 + +logger = logging.getLogger(__name__) + +# ── Lazy imports to avoid import-time failures ── +try: + from supabase import Client, create_client +except ImportError as _imp_err: + create_client = None + Client = None + logger.warning(f"[DB] supabase client not installed: {_imp_err}") + +try: + import redis.asyncio as redis +except ImportError: + redis = None + +# ── SQL to ensure missing tables exist ── +# Run these in the Supabase SQL Editor if the tables are missing. +ENSURE_TABLES_SQL = """ +-- Users / Profiles (extends auth.users for wallet-auth users) +CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, + email TEXT, + wallet_address TEXT UNIQUE, + role TEXT DEFAULT 'USER', + tier TEXT DEFAULT 'FREE', + created_at TIMESTAMPTZ DEFAULT NOW(), + xp INTEGER DEFAULT 0, + level INTEGER DEFAULT 1, + display_name TEXT, + scans_remaining INTEGER DEFAULT 5, + total_scans_used INTEGER DEFAULT 0, + reputation_score INTEGER DEFAULT 0 +); +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Profiles public read" ON profiles FOR SELECT USING (true); +CREATE POLICY "Profiles admin write" ON profiles FOR ALL USING ( + EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin') +); + +-- Contract Audits +CREATE TABLE IF NOT EXISTS contract_audits ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + address TEXT NOT NULL, + chain TEXT DEFAULT 'solana', + risk_score NUMERIC DEFAULT 0, + findings JSONB DEFAULT '[]', + ai_analysis JSONB DEFAULT '{}', + audited_at TIMESTAMPTZ DEFAULT NOW() +); +ALTER TABLE contract_audits ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Audits public read" ON contract_audits FOR SELECT USING (true); +CREATE POLICY "Audits admin write" ON contract_audits FOR ALL USING ( + EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin') +); + +-- Trenches Posts (community board) +CREATE TABLE IF NOT EXISTS trenches_posts ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content TEXT NOT NULL, + category TEXT DEFAULT 'discussion', + author_id TEXT, + upvotes INTEGER DEFAULT 0, + comments INTEGER DEFAULT 0, + tags TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); +ALTER TABLE trenches_posts ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Posts public read" ON trenches_posts FOR SELECT USING (true); +CREATE POLICY "Posts owner write" ON trenches_posts FOR ALL USING ( + auth.uid()::text = author_id OR + EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin') +); + +-- Gamification Profiles +CREATE TABLE IF NOT EXISTS gamification_profiles ( + user_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + xp INTEGER DEFAULT 0, + level INTEGER DEFAULT 1, + badges TEXT[] DEFAULT '{}', + scans_count INTEGER DEFAULT 0, + posts_count INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT NOW() +); +ALTER TABLE gamification_profiles ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Gamification public read" ON gamification_profiles FOR SELECT USING (true); +CREATE POLICY "Gamification owner write" ON gamification_profiles FOR ALL USING ( + auth.uid()::text = user_id OR + EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin') +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_profiles_wallet ON profiles(wallet_address); +CREATE INDEX IF NOT EXISTS idx_contract_audits_address ON contract_audits(address); +CREATE INDEX IF NOT EXISTS idx_trenches_posts_category ON trenches_posts(category); +CREATE INDEX IF NOT EXISTS idx_trenches_posts_created ON trenches_posts(created_at DESC); +""" + + +# ═══════════════════════════════════════════════════════════ +# RETRY UTILITIES +# ═══════════════════════════════════════════════════════════ + + +def _async_retry(max_retries: int = 3, delay: float = 0.5, backoff: float = 2.0): + """Simple async retry decorator for Supabase operations.""" + + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args, **kwargs): + last_exc = None + wait = delay + for attempt in range(max_retries + 1): + try: + return await func(*args, **kwargs) + except Exception as exc: + last_exc = exc + if attempt >= max_retries: + raise + logger.warning(f"[DB] Retry {attempt + 1}/{max_retries} for {func.__name__}: {exc}") + await asyncio.sleep(wait) + wait *= backoff + raise last_exc + + return wrapper + + return decorator + + +# ═══════════════════════════════════════════════════════════ +# PYDANTIC MODELS +# ═══════════════════════════════════════════════════════════ + + +class User(BaseModel): + id: str + email: str | None = None + wallet_address: str | None = None + role: str = "USER" + tier: str = "FREE" + created_at: str | None = None + xp: int = 0 + level: int = 1 + + +class InvestigationCase(BaseModel): + id: str + target: str + type: str = "wallet" + status: str = "open" + evidence: list[str] = Field(default_factory=list) + agents_assigned: list[str] = Field(default_factory=list) + created_at: str | None = None + updated_at: str | None = None + risk_score: float = 0.0 + findings: dict[str, Any] = Field(default_factory=dict) + + +class WalletScan(BaseModel): + id: str | None = None + address: str + chain: str = "solana" + risk_score: float = 0.0 + findings: dict[str, Any] = Field(default_factory=dict) + scanned_at: str | None = None + user_id: str | None = None + + +class ContractAudit(BaseModel): + id: str | None = None + address: str + chain: str = "solana" + risk_score: float = 0.0 + findings: list[dict[str, Any]] = Field(default_factory=list) + ai_analysis: dict[str, Any] = Field(default_factory=dict) + audited_at: str | None = None + + +class TrenchesPost(BaseModel): + id: str | None = None + title: str + content: str + category: str = "discussion" + author_id: str | None = None + upvotes: int = 0 + comments: int = 0 + tags: list[str] = Field(default_factory=list) + created_at: str | None = None + + +class Alert(BaseModel): + id: str | None = None + token_address: str + types: list[str] = Field(default_factory=list) + webhook_url: str | None = None + active: bool = True + created_at: str | None = None + + +class GamificationProfile(BaseModel): + user_id: str + xp: int = 0 + level: int = 1 + badges: list[str] = Field(default_factory=list) + scans_count: int = 0 + posts_count: int = 0 + updated_at: str | None = None + + +# ═══════════════════════════════════════════════════════════ +# SUPABASE CLIENT +# ═══════════════════════════════════════════════════════════ + + +class SupabaseClient: + """Wrapped Supabase client with connection pooling, retries, and helpers.""" + + def __init__(self, url: str | None = None, key: str | None = None): + self.url = url or os.getenv("SUPABASE_URL", "") + # Fall back through env var variants + self.key = ( + key or os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY", "") + ) + self._client: Any | None = None + + def _ensure_client(self) -> Any: + if self._client is None: + if create_client is None: + raise RuntimeError("supabase package is not installed") + if not self.url or not self.key: + raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be set") + self._client = create_client(self.url, self.key) + return self._client + + @property + def client(self) -> Any: + return self._ensure_client() + + # ── Auth helpers ── + + @_async_retry(max_retries=3) + async def sign_up(self, email: str, password: str) -> dict[str, Any]: + """Register a new user via Supabase Auth.""" + result = self.client.auth.sign_up({"email": email, "password": password}) + return result.model_dump() if hasattr(result, "model_dump") else dict(result) + + @_async_retry(max_retries=3) + async def sign_in(self, email: str, password: str) -> dict[str, Any]: + """Sign in via Supabase Auth.""" + result = self.client.auth.sign_in_with_password({"email": email, "password": password}) + return result.model_dump() if hasattr(result, "model_dump") else dict(result) + + @_async_retry(max_retries=3) + async def get_user(self, token: str) -> dict[str, Any] | None: + """Get user by JWT token.""" + result = self.client.auth.get_user(token) + if result and result.user: + return { + "id": result.user.id, + "email": result.user.email, + } + return None + + # ── Table access ── + + def table(self, name: str) -> Any: + return self.client.table(name) + + # ── SQL execution (requires service role or elevated privileges) ── + + @_async_retry(max_retries=2) + async def execute_sql(self, sql: str) -> Any: + """Execute raw SQL via RPC (requires a stored procedure or service role).""" + # Prefer using migrations; this is a fallback. + return self.client.rpc("exec_sql", {"query": sql}).execute() + + @_async_retry(max_retries=2) + async def ensure_tables(self) -> None: + """Best-effort table creation. Requires exec_sql RPC or run SQL manually.""" + try: + await self.execute_sql(ENSURE_TABLES_SQL) + except Exception as exc: + logger.warning(f"[DB] ensure_tables failed (tables may already exist or RPC missing): {exc}") + + +# ═══════════════════════════════════════════════════════════ +# REPOSITORIES +# ═══════════════════════════════════════════════════════════ + + +class _BaseRepo: + def __init__(self, db: SupabaseClient): + self.db = db + + def _now(self) -> str: + return datetime.now(UTC).isoformat() + + def _to_dict(self, model: BaseModel, exclude_none: bool = True) -> dict[str, Any]: + d = model.model_dump() + if exclude_none: + d = {k: v for k, v in d.items() if v is not None} + return d + + +class UserRepository(_BaseRepo): + TABLE = "profiles" + + @_async_retry(max_retries=3) + async def create(self, user: User) -> dict[str, Any]: + data = self._to_dict(user) + if not data.get("created_at"): + data["created_at"] = self._now() + result = self.db.table(self.TABLE).insert(data).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def get(self, user_id: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("id", user_id).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def get_by_wallet(self, wallet_address: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("wallet_address", wallet_address.lower()).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def list(self, limit: int = 100) -> list[dict[str, Any]]: + result = self.db.table(self.TABLE).select("*").limit(limit).execute() + return result.data or [] + + @_async_retry(max_retries=3) + async def update(self, user_id: str, updates: dict[str, Any]) -> dict[str, Any]: + result = self.db.table(self.TABLE).update(updates).eq("id", user_id).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def upsert(self, user: User) -> dict[str, Any]: + data = self._to_dict(user) + if not data.get("created_at"): + data["created_at"] = self._now() + result = self.db.table(self.TABLE).upsert(data, on_conflict="id").execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def delete(self, user_id: str) -> bool: + self.db.table(self.TABLE).delete().eq("id", user_id).execute() + return True + + +class InvestigationCaseRepository(_BaseRepo): + TABLE = "cases" + + @_async_retry(max_retries=3) + async def create(self, case: InvestigationCase) -> dict[str, Any]: + data = self._to_dict(case) + if not data.get("created_at"): + data["created_at"] = self._now() + if not data.get("updated_at"): + data["updated_at"] = self._now() + result = self.db.table(self.TABLE).insert(data).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def get(self, case_id: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("id", case_id).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def list(self, limit: int = 100, status: str | None = None) -> list[dict[str, Any]]: + query = self.db.table(self.TABLE).select("*") + if status: + query = query.eq("status", status) + result = query.limit(limit).execute() + return result.data or [] + + @_async_retry(max_retries=3) + async def update(self, case_id: str, updates: dict[str, Any]) -> dict[str, Any]: + updates["updated_at"] = self._now() + result = self.db.table(self.TABLE).update(updates).eq("id", case_id).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def delete(self, case_id: str) -> bool: + self.db.table(self.TABLE).delete().eq("id", case_id).execute() + return True + + +class WalletScanRepository(_BaseRepo): + TABLE = "wallet_intel" + + @_async_retry(max_retries=3) + async def create(self, scan: WalletScan) -> dict[str, Any]: + data = self._to_dict(scan) + # Map findings to transactions JSONB for schema compatibility + if "findings" in data: + data["transactions"] = data.pop("findings") + if not data.get("scanned_at"): + data["last_seen"] = self._now() + result = self.db.table(self.TABLE).insert(data).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def get(self, scan_id: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("id", scan_id).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def get_by_address(self, address: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("address", address).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def list(self, limit: int = 100, chain: str | None = None) -> list[dict[str, Any]]: + query = self.db.table(self.TABLE).select("*") + if chain: + query = query.eq("chain", chain) + result = query.limit(limit).execute() + return result.data or [] + + @_async_retry(max_retries=3) + async def update(self, scan_id: str, updates: dict[str, Any]) -> dict[str, Any]: + if "findings" in updates: + updates["transactions"] = updates.pop("findings") + updates["updated_at"] = self._now() + result = self.db.table(self.TABLE).update(updates).eq("id", scan_id).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def delete(self, scan_id: str) -> bool: + self.db.table(self.TABLE).delete().eq("id", scan_id).execute() + return True + + +class ContractAuditRepository(_BaseRepo): + TABLE = "contract_audits" + + @_async_retry(max_retries=3) + async def create(self, audit: ContractAudit) -> dict[str, Any]: + data = self._to_dict(audit) + if not data.get("audited_at"): + data["audited_at"] = self._now() + result = self.db.table(self.TABLE).insert(data).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def get(self, audit_id: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("id", audit_id).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def list(self, limit: int = 100, chain: str | None = None) -> list[dict[str, Any]]: + query = self.db.table(self.TABLE).select("*") + if chain: + query = query.eq("chain", chain) + result = query.limit(limit).execute() + return result.data or [] + + @_async_retry(max_retries=3) + async def update(self, audit_id: str, updates: dict[str, Any]) -> dict[str, Any]: + result = self.db.table(self.TABLE).update(updates).eq("id", audit_id).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def delete(self, audit_id: str) -> bool: + self.db.table(self.TABLE).delete().eq("id", audit_id).execute() + return True + + +class TrenchesPostRepository(_BaseRepo): + TABLE = "trenches_posts" + + @_async_retry(max_retries=3) + async def create(self, post: TrenchesPost) -> dict[str, Any]: + data = self._to_dict(post) + if not data.get("created_at"): + data["created_at"] = self._now() + result = self.db.table(self.TABLE).insert(data).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def get(self, post_id: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("id", post_id).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def list(self, limit: int = 50, category: str | None = None) -> list[dict[str, Any]]: + query = self.db.table(self.TABLE).select("*") + if category and category != "all": + query = query.eq("category", category) + result = query.order("created_at", desc=True).limit(limit).execute() + return result.data or [] + + @_async_retry(max_retries=3) + async def update(self, post_id: str, updates: dict[str, Any]) -> dict[str, Any]: + result = self.db.table(self.TABLE).update(updates).eq("id", post_id).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def delete(self, post_id: str) -> bool: + self.db.table(self.TABLE).delete().eq("id", post_id).execute() + return True + + +class AlertRepository(_BaseRepo): + TABLE = "alerts" + + @_async_retry(max_retries=3) + async def create(self, alert: Alert) -> dict[str, Any]: + data = self._to_dict(alert) + # Map types -> alert_types for schema compatibility + if "types" in data: + data["alert_types"] = data.pop("types") + if not data.get("created_at"): + data["created_at"] = self._now() + result = self.db.table(self.TABLE).insert(data).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def get(self, alert_id: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("id", alert_id).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def list(self, limit: int = 100, active_only: bool = True) -> list[dict[str, Any]]: + query = self.db.table(self.TABLE).select("*") + if active_only: + query = query.eq("active", True) + result = query.limit(limit).execute() + return result.data or [] + + @_async_retry(max_retries=3) + async def update(self, alert_id: str, updates: dict[str, Any]) -> dict[str, Any]: + if "types" in updates: + updates["alert_types"] = updates.pop("types") + result = self.db.table(self.TABLE).update(updates).eq("id", alert_id).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def delete(self, alert_id: str) -> bool: + self.db.table(self.TABLE).delete().eq("id", alert_id).execute() + return True + + +class GamificationProfileRepository(_BaseRepo): + TABLE = "gamification_profiles" + + @_async_retry(max_retries=3) + async def create(self, profile: GamificationProfile) -> dict[str, Any]: + data = self._to_dict(profile) + if not data.get("updated_at"): + data["updated_at"] = self._now() + result = self.db.table(self.TABLE).insert(data).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def get(self, user_id: str) -> dict[str, Any] | None: + result = self.db.table(self.TABLE).select("*").eq("user_id", user_id).execute() + return result.data[0] if result.data else None + + @_async_retry(max_retries=3) + async def list(self, limit: int = 100) -> list[dict[str, Any]]: + result = self.db.table(self.TABLE).select("*").limit(limit).execute() + return result.data or [] + + @_async_retry(max_retries=3) + async def update(self, user_id: str, updates: dict[str, Any]) -> dict[str, Any]: + updates["updated_at"] = self._now() + result = self.db.table(self.TABLE).update(updates).eq("user_id", user_id).execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def upsert(self, profile: GamificationProfile) -> dict[str, Any]: + data = self._to_dict(profile) + if not data.get("updated_at"): + data["updated_at"] = self._now() + result = self.db.table(self.TABLE).upsert(data, on_conflict="user_id").execute() + return result.data[0] if result.data else {} + + @_async_retry(max_retries=3) + async def delete(self, user_id: str) -> bool: + self.db.table(self.TABLE).delete().eq("user_id", user_id).execute() + return True + + +# ═══════════════════════════════════════════════════════════ +# UNIFIED DB INTERFACE +# ═══════════════════════════════════════════════════════════ + + +class RmiDatabase: + """Unified interface: Supabase persistence + Redis cache.""" + + def __init__(self, supabase_url: str | None = None, supabase_key: str | None = None): + self.db = SupabaseClient(url=supabase_url, key=supabase_key) + self.users = UserRepository(self.db) + self.cases = InvestigationCaseRepository(self.db) + self.wallet_scans = WalletScanRepository(self.db) + self.contract_audits = ContractAuditRepository(self.db) + self.trenches_posts = TrenchesPostRepository(self.db) + self.alerts = AlertRepository(self.db) + self.gamification = GamificationProfileRepository(self.db) + + async def ensure_tables(self) -> None: + await self.db.ensure_tables() + + +# ═══════════════════════════════════════════════════════════ +# REDIS CACHE HELPERS +# ═══════════════════════════════════════════════════════════ + +_CACHE_TTL = 300 # 5 minutes default + + +async def _get_redis() -> Any | None: + """Lazy Redis client (mirrors main.py pattern).""" + if redis is None: + return None + try: + import os + + host = os.getenv("REDIS_HOST", "127.0.0.1") + port = int(os.getenv("REDIS_PORT", "6379")) + password = os.getenv("REDIS_PASSWORD") or None + db = int(os.getenv("REDIS_DB", "0")) + return redis.Redis(host=host, port=port, password=password, db=db, decode_responses=True) + except Exception as exc: + logger.warning(f"[DB] Redis init failed: {exc}") + return None + + +async def cache_set(key: str, value: Any, ttl: int = _CACHE_TTL) -> None: + r = await _get_redis() + if r is None: + return + try: + await r.set(key, json.dumps(value), ex=ttl) + except Exception as exc: + logger.warning(f"[DB] cache_set failed: {exc}") + + +async def cache_get(key: str) -> Any | None: + r = await _get_redis() + if r is None: + return None + try: + raw = await r.get(key) + return json.loads(raw) if raw else None + except Exception as exc: + logger.warning(f"[DB] cache_get failed: {exc}") + return None + + +async def cache_delete(key: str) -> None: + r = await _get_redis() + if r is None: + return + try: + await r.delete(key) + except Exception as exc: + logger.warning(f"[DB] cache_delete failed: {exc}") + + +async def cache_hash_set(hash_name: str, field: str, value: Any) -> None: + r = await _get_redis() + if r is None: + return + try: + await r.hset(hash_name, field, json.dumps(value)) + except Exception as exc: + logger.warning(f"[DB] cache_hash_set failed: {exc}") + + +async def cache_hash_get_all(hash_name: str) -> dict[str, Any]: + r = await _get_redis() + if r is None: + return {} + try: + raw = await r.hgetall(hash_name) + return {k: json.loads(v) for k, v in raw.items()} + except Exception as exc: + logger.warning(f"[DB] cache_hash_get_all failed: {exc}") + return {} + + +# ═══════════════════════════════════════════════════════════ +# MIGRATION HELPER +# ═══════════════════════════════════════════════════════════ + + +async def sync_redis_to_supabase() -> dict[str, int]: + """ + Read all data from Redis hashes and upsert into Supabase tables. + Returns counts of synced records per table. + """ + counts = {"cases": 0, "trenches_posts": 0, "alerts": 0, "tasks": 0} + r = await _get_redis() + if r is None: + logger.warning("[DB] Redis unavailable; nothing to migrate") + return counts + + db = RmiDatabase() + + # ── Cases ── + try: + cases_raw = await r.hgetall("rmi:cases") + for case_json in cases_raw.values(): + try: + data = json.loads(case_json) + case = InvestigationCase( + id=data.get("id", ""), + target=data.get("target", ""), + type=data.get("type", "wallet"), + status=data.get("status", "open"), + evidence=data.get("evidence") or data.get("evidence", []), + agents_assigned=data.get("agents_assigned") or data.get("agents_assigned", []), + created_at=data.get("created_at") or data.get("created"), + updated_at=data.get("updated_at") or data.get("updated") or data.get("created"), + risk_score=float(data.get("risk_score", 0)), + findings=data.get("findings") or {}, + ) + await db.cases.create(case) + counts["cases"] += 1 + except Exception as exc: + logger.warning(f"[DB] Failed to migrate case: {exc}") + except Exception as exc: + logger.warning(f"[DB] Cases migration error: {exc}") + + # ── Trenches Posts ── + try: + posts_raw = await r.hgetall("rmi:trenches:posts") + for post_json in posts_raw.values(): + try: + data = json.loads(post_json) + post = TrenchesPost( + id=data.get("id"), + title=data.get("title", ""), + content=data.get("content", ""), + category=data.get("category", "discussion"), + author_id=data.get("author_id"), + upvotes=int(data.get("upvotes", 0)), + comments=int(data.get("comments", 0)), + tags=data.get("tags") or [], + created_at=data.get("created_at") or data.get("created"), + ) + await db.trenches_posts.create(post) + counts["trenches_posts"] += 1 + except Exception as exc: + logger.warning(f"[DB] Failed to migrate post: {exc}") + except Exception as exc: + logger.warning(f"[DB] Posts migration error: {exc}") + + # ── Alerts ── + try: + alerts_raw = await r.hgetall("rmi:alerts") + for alert_json in alerts_raw.values(): + try: + data = json.loads(alert_json) + alert = Alert( + id=data.get("id"), + token_address=data.get("token") or data.get("token_address", ""), + types=data.get("types") or data.get("alert_types") or [], + webhook_url=data.get("webhook") or data.get("webhook_url"), + active=data.get("active", True), + created_at=data.get("created_at") or data.get("created"), + ) + await db.alerts.create(alert) + counts["alerts"] += 1 + except Exception as exc: + logger.warning(f"[DB] Failed to migrate alert: {exc}") + except Exception as exc: + logger.warning(f"[DB] Alerts migration error: {exc}") + + # ── Tasks (migrate as cases with type=task for traceability) ── + try: + tasks_raw = await r.hgetall("rmi:tasks") + for task_json in tasks_raw.values(): + try: + data = json.loads(task_json) + case = InvestigationCase( + id=data.get("id", ""), + target=data.get("command", "task"), + type="task", + status=data.get("status", "open"), + evidence=[], + agents_assigned=[data.get("agent", "")] if data.get("agent") else [], + created_at=data.get("created") or data.get("created_at"), + updated_at=data.get("created") or data.get("created_at"), + risk_score=0.0, + findings={ + "context": data.get("context", {}), + "priority": data.get("priority", "normal"), + }, + ) + await db.cases.create(case) + counts["tasks"] += 1 + except Exception as exc: + logger.warning(f"[DB] Failed to migrate task: {exc}") + except Exception as exc: + logger.warning(f"[DB] Tasks migration error: {exc}") + + logger.info(f"[DB] Migration complete: {counts}") + return counts + + +# ═══════════════════════════════════════════════════════════ +# SINGLETON INSTANCE +# ═══════════════════════════════════════════════════════════ + +_rmi_db: RmiDatabase | None = None + + +def get_db() -> RmiDatabase: + global _rmi_db + if _rmi_db is None: + _rmi_db = RmiDatabase() + return _rmi_db diff --git a/app/_archive/legacy_2026_07/degen_scan_endpoint.py b/app/_archive/legacy_2026_07/degen_scan_endpoint.py new file mode 100644 index 0000000..1eb222b --- /dev/null +++ b/app/_archive/legacy_2026_07/degen_scan_endpoint.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +FastAPI Endpoint for Degen Security Scanner +Mounts at: /degen-scan/* +Integrates with RMI backend main.py +""" + +import logging +import os +import sys +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +# Add the app directory to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.degen_security_scanner import ( + DegenSecurityScanner, + SecurityReport, + scan_token, +) + +logger = logging.getLogger(__name__) + +# ─── Pydantic Models ────────────────────────────────────────────────────────── + + +class DegenScanRequest(BaseModel): + token_address: str = Field(..., min_length=32, max_length=48, description="Token mint address") + quick: bool = Field(False, description="Skip slow operations for faster response") + + +class DegenScanResponse(BaseModel): + success: bool + token_address: str + token_name: str + token_symbol: str + rug_pull_probability: int + overall_risk: str + confidence_level: str + mint_authority_renounced: bool + freeze_authority_renounced: bool + top_10_concentration: float + total_holders: int + total_liquidity_usd: float + bundler_detected: bool + bundler_wallet_count: int + sniper_count: int + honeypot_detected: bool + lp_locked: bool + exchange_funding_pct: float + critical_warnings: list + recommendations: list + full_report: dict[str, Any] + scan_duration_ms: int + timestamp: str + + +class BundleCheckResponse(BaseModel): + token_address: str + bundler_detected: bool + bundler_wallets: list + bundler_pattern: str + bundler_supply_pct: float + confidence: float + details: dict[str, Any] + + +class FundingTraceResponse(BaseModel): + token_address: str + deployer: str + funding_sources: list + exchange_funding_pct: float + organic_funding_pct: float + mixer_detected: bool + risk_level: str + trace_graph: list + + +# ─── Cache Layer ────────────────────────────────────────────────────────────── + +_scan_cache: dict[str, dict] = {} +CACHE_TTL_SECONDS = 300 # 5 minute cache + + +def _get_cached(token: str) -> dict | None: + """Get cached scan result if still valid.""" + entry = _scan_cache.get(token) + if entry: + age = (datetime.utcnow() - entry["cached_at"]).total_seconds() + if age < CACHE_TTL_SECONDS: + return entry["data"] + return None + + +def _set_cache(token: str, data: dict): + """Cache scan result.""" + _scan_cache[token] = { + "data": data, + "cached_at": datetime.utcnow(), + } + + +# ─── Router ─────────────────────────────────────────────────────────────────── + +router = APIRouter(prefix="/degen-scan", tags=["Degen Security Scanner"]) + + +@router.post("/full", response_model=DegenScanResponse) +async def full_degen_scan(request: DegenScanRequest): + """ + Run a comprehensive DEGEN security scan on any Solana token. + + Analyzes 15+ scam indicators including: + - Bundler detection, gas funding traces, exchange funding % + - LP analysis, sniper metrics, insider trading detection + - Wash trading, dev wallet forensics, authority renouncement + - Tax detection, honeypot detection, holder concentration + + Returns a RUG PULL PROBABILITY score (0-100). + """ + token = request.token_address + + # Check cache + cached = _get_cached(token) + if cached: + return DegenScanResponse(**cached) + + try: + report = await scan_token(token, quick=request.quick) + + response = { + "success": True, + "token_address": report.token_address, + "token_name": report.token_name, + "token_symbol": report.token_symbol, + "rug_pull_probability": report.rug_pull_probability, + "overall_risk": report.overall_risk, + "confidence_level": report.confidence_level, + "mint_authority_renounced": report.mint_authority_renounced, + "freeze_authority_renounced": report.freeze_authority_renounced, + "top_10_concentration": report.top_10_concentration, + "total_holders": report.total_holders, + "total_liquidity_usd": report.total_liquidity_usd, + "bundler_detected": report.bundler_detected, + "bundler_wallet_count": len(report.bundler_wallets), + "sniper_count": report.sniper_count, + "honeypot_detected": report.honeypot_detected, + "lp_locked": any(p.lp_locked_percentage > 80 for p in report.liquidity_pools), + "exchange_funding_pct": report.exchange_funding_percentage, + "critical_warnings": report.critical_warnings, + "recommendations": report.recommendations, + "full_report": report.to_dict(), + "scan_duration_ms": report.scan_duration_ms, + "timestamp": datetime.utcnow().isoformat(), + } + + _set_cache(token, response) + return DegenScanResponse(**response) + + except Exception as e: + logger.error(f"Degen scan failed for {token}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Scan failed: {e!s}") from e + + +@router.get("/quick") +async def quick_degen_scan( + token: str = Query(..., min_length=32, max_length=48), +): + """ + Quick GET endpoint for instant risk assessment. + Perfect for Telegram bot integration. + """ + cached = _get_cached(token) + if cached: + return cached + + report = await scan_token(token, quick=True) + + response = { + "success": True, + "token": f"{report.token_name} (${report.token_symbol})", + "rug_probability": report.rug_pull_probability, + "risk": report.overall_risk, + "confidence": report.confidence_level, + "mint_renounced": "✅" if report.mint_authority_renounced else "❌", + "freeze_renounced": "✅" if report.freeze_authority_renounced else "❌", + "top_10_pct": report.top_10_concentration, + "holders": report.total_holders, + "liquidity_usd": report.total_liquidity_usd, + "bundler": "🎯 YES" if report.bundler_detected else "No", + "bundler_count": len(report.bundler_wallets), + "snipers": report.sniper_count, + "honeypot": "🍯 YES" if report.honeypot_detected else "No", + "lp_locked": any(p.lp_locked_percentage > 80 for p in report.liquidity_pools), + "warnings": report.critical_warnings[:5], + "ms": report.scan_duration_ms, + } + + _set_cache(token, response) + return response + + +@router.get("/bundle-check", response_model=BundleCheckResponse) +async def check_bundler( + token: str = Query(..., min_length=32, max_length=48), +): + """ + Dedicated bundler detection endpoint. + Identifies coordinated launch buying patterns. + """ + scanner = DegenSecurityScanner() + report = SecurityReport(token_address=token) + await scanner._phase5_bundlers_snipers(report) + + return BundleCheckResponse( + token_address=token, + bundler_detected=report.bundler_detected, + bundler_wallets=report.bundler_wallets, + bundler_pattern=report.bundler_pattern, + bundler_supply_pct=report.bundler_percentage_of_supply, + confidence=0.85 if report.bundler_detected else 0.0, + details={ + "sniper_count": report.sniper_count, + "avg_sniper_entry_ms": report.avg_sniper_entry_time_ms, + "sniper_volume_sol": report.sniper_volume_sol, + }, + ) + + +@router.get("/funding-trace", response_model=FundingTraceResponse) +async def trace_funding( + token: str = Query(..., min_length=32, max_length=48), +): + """ + Trace deployer wallet funding sources. + Reveals exchange funding % and mixer usage. + """ + scanner = DegenSecurityScanner() + report = SecurityReport(token_address=token) + await scanner._phase1_metadata(report) + await scanner._phase4_funding(report) + + sources = [] + if report.deployer_funding: + df = report.deployer_funding + sources = [ + { + "funder": df.initial_funder, + "tag": df.funder_tag, + "amount_sol": df.funding_amount_sol, + "time": df.funding_time, + "hops_from_exchange": df.hops_from_exchange, + "is_exchange": df.is_exchange_direct, + } + ] + + return FundingTraceResponse( + token_address=token, + deployer=report.deployer, + funding_sources=sources, + exchange_funding_pct=report.exchange_funding_percentage, + organic_funding_pct=report.organic_funding_percentage, + mixer_detected=report.mixer_funding_detected, + risk_level=report.funding_risk, + trace_graph=[], + ) + + +@router.get("/compare") +async def compare_tokens( + tokens: str = Query(..., description="Comma-separated token addresses"), +): + """ + Compare security scores across multiple tokens. + Perfect for evaluating which of several tokens is safest. + """ + token_list = [t.strip() for t in tokens.split(",") if len(t.strip()) >= 32] + if len(token_list) > 5: + raise HTTPException(status_code=400, detail="Max 5 tokens per comparison") + + results = [] + for token in token_list: + try: + report = await scan_token(token, quick=True) + results.append( + { + "token": f"{report.token_name} (${report.token_symbol})", + "address": token, + "rug_probability": report.rug_pull_probability, + "risk": report.overall_risk, + "liquidity": report.total_liquidity_usd, + "holders": report.total_holders, + "bundler": report.bundler_detected, + "honeypot": report.honeypot_detected, + "mint_renounced": report.mint_authority_renounced, + } + ) + except Exception as e: + results.append({"address": token, "error": str(e)}) + + # Sort by safest first + results.sort(key=lambda x: x.get("rug_probability", 100)) + + return { + "comparison": results, + "safest": results[0] if results else None, + "riskiest": results[-1] if len(results) > 1 else None, + "timestamp": datetime.utcnow().isoformat(), + } + + +# ─── Mount Instructions ─────────────────────────────────────────────────────── + +# In your main.py, add: +# from degen_scan_endpoint import router as degen_scan_router +# app.include_router(degen_scan_router) + +if __name__ == "__main__": + import uvicorn + from fastapi import FastAPI + + app = FastAPI(title="RMI Degen Security Scanner") + app.include_router(router) + + @app.get("/") + def root(): + return {"status": "RMI Degen Scanner API", "version": "1.0.0"} + + uvicorn.run(app, host="0.0.0.0", port=8765) diff --git a/app/_archive/legacy_2026_07/dify_tools.py b/app/_archive/legacy_2026_07/dify_tools.py new file mode 100644 index 0000000..26d3f61 --- /dev/null +++ b/app/_archive/legacy_2026_07/dify_tools.py @@ -0,0 +1,184 @@ +"""#14 - Dify RMI Tool Suite. 15 crypto tools exposed as OpenAPI endpoints for Dify agents. +Each tool is a standalone endpoint that Dify can call via API. Security: rate-limited, API-key gated, logged.""" + +import os + +import httpx +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/dify-tools", tags=["dify-tools"]) + +BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000") +RMI_KEY = os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026") + + +# ── Tool 1: Search Crypto ── +@router.get("/search") +async def search_crypto(q: str, limit: int = Query(5, le=20)): + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get( + f"{BACKEND}/api/v1/databus/fetch/token_search", + params={"q": q, "limit": limit}, + headers={"X-RMI-Key": RMI_KEY}, + ) + return {"query": q, "results": r.json() if r.status_code == 200 else []} + + +# ── Tool 2: Scan Token ── +@router.get("/scan") +async def scan_token(address: str, chain: str = "solana"): + async with httpx.AsyncClient(timeout=20) as c: + r = await c.post( + f"{BACKEND}/api/v1/token/scan", + json={"token_address": address, "chain": chain}, + headers={"X-RMI-Key": RMI_KEY}, + ) + if r.status_code == 200: + data = r.json() + return { + "safety_score": data.get("safety_score"), + "risk_flags": data.get("risk_flags", []), + "symbol": data.get("symbol"), + } + return {"error": "scan failed"} + + +# ── Tool 3: Get Price ── +@router.get("/price") +async def get_price(symbol: str, chain: str | None = None): + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + f"{BACKEND}/api/v1/databus/fetch/token_price", params={"symbol": symbol}, headers={"X-RMI-Key": RMI_KEY} + ) + return r.json() if r.status_code == 200 else {"error": "price unavailable"} + + +# ── Tool 4: Whale Alert ── +@router.get("/whales") +async def whale_alert(chain: str = "ethereum", threshold: float = Query(100000, ge=10000)): + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + f"{BACKEND}/api/v1/databus/fetch/whale_alerts", params={"chain": chain}, headers={"X-RMI-Key": RMI_KEY} + ) + return r.json() if r.status_code == 200 else {"alerts": 0} + + +# ── Tool 5: Market Overview ── +@router.get("/market") +async def market_overview(): + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{BACKEND}/api/v1/databus/fetch/market_overview", headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"error": "market data unavailable"} + + +# ── Tool 6: Token Report ── +@router.get("/report") +async def token_report(address: str, chain: str = "ethereum"): + async with httpx.AsyncClient(timeout=20) as c: + r = await c.get(f"{BACKEND}/api/v1/token-cv/{chain}/{address}", headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"error": "report failed"} + + +# ── Tool 7: Address Profile ── +@router.get("/profile") +async def address_profile(address: str): + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(f"{BACKEND}/api/v1/address-profiler/profile/{address}", headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"error": "profile failed"} + + +# ── Tool 8: Chain Compare ── +@router.get("/compare") +async def compare_chains(symbol: str): + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(f"{BACKEND}/api/v1/chain-compare/token/{symbol}", headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"error": "compare failed"} + + +# ── Tool 9: Predict Rug ── +@router.get("/predict") +async def predict_rug(address: str, chain: str = "ethereum"): + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(f"{BACKEND}/api/v1/death-clock/predict/{chain}/{address}", headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"error": "prediction failed"} + + +# ── Tool 10: Contract Analyze ── +@router.get("/contract") +async def contract_analyze(address: str, chain: str = "ethereum"): + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get( + f"{BACKEND}/api/v1/contract-analyzer/analyze/{address}?chain={chain}", headers={"X-RMI-Key": RMI_KEY} + ) + return r.json() if r.status_code == 200 else {"error": "analysis failed"} + + +# ── Tool 11: Trending ── +@router.get("/trending") +async def trending_tokens(chain: str = "solana", limit: int = Query(10, le=25)): + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + f"{BACKEND}/api/v1/databus/fetch/trending", + params={"chain": chain, "limit": limit}, + headers={"X-RMI-Key": RMI_KEY}, + ) + return r.json() if r.status_code == 200 else {"trending": []} + + +# ── Tool 12: Fear & Greed ── +@router.get("/fear-greed") +async def fear_greed_index(): + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{BACKEND}/api/v1/databus/fetch/fear_greed", headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"value": 50, "classification": "Neutral"} + + +# ── Tool 13: News ── +@router.get("/news") +async def news_headlines(limit: int = Query(5, le=20)): + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{BACKEND}/api/v1/databus/fetch/news", params={"limit": limit}, headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"headlines": []} + + +# ── Tool 14: RAG Search ── +@router.get("/rag") +async def search_rag(query: str, limit: int = Query(5, le=10)): + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + f"{BACKEND}/api/v1/rag/search", params={"q": query, "limit": limit}, headers={"X-RMI-Key": RMI_KEY} + ) + return r.json() if r.status_code == 200 else {"results": []} + + +# ── Tool 15: Ollama Chat (fallback for local inference) ── +@router.get("/ollama") +async def ollama_chat(prompt: str, model: str = "qwen2.5-coder:7b"): + async with httpx.AsyncClient(timeout=60) as c: + r = await c.get(f"{BACKEND}/api/v1/ollama/chat?prompt={prompt}&model={model}", headers={"X-RMI-Key": RMI_KEY}) + return r.json() if r.status_code == 200 else {"error": "ollama unavailable"} + + +# ── Tool catalog ── +@router.get("/catalog") +async def tool_catalog(): + return { + "tools": [ + {"name": "search_crypto", "description": "Search crypto tokens across 112 chains"}, + {"name": "scan_token", "description": "SENTINEL security scan"}, + {"name": "get_price", "description": "Real-time token price"}, + {"name": "whale_alert", "description": "Recent whale movements"}, + {"name": "market_overview", "description": "Market stats + fear & greed"}, + {"name": "token_report", "description": "Full token security report"}, + {"name": "address_profile", "description": "Cross-chain wallet analysis"}, + {"name": "compare_chains", "description": "Price/liquidity across chains"}, + {"name": "predict_rug", "description": "Token Death Clock prediction"}, + {"name": "contract_analyze", "description": "Smart contract function analysis"}, + {"name": "trending_tokens", "description": "What's pumping right now"}, + {"name": "fear_greed_index", "description": "Market sentiment index"}, + {"name": "news_headlines", "description": "Latest crypto news"}, + {"name": "search_rag", "description": "Search 17K+ scam documents"}, + {"name": "ollama_chat", "description": "Local AI inference (free)"}, + ], + "total": 15, + } diff --git a/app/_archive/legacy_2026_07/discovery_router.py b/app/_archive/legacy_2026_07/discovery_router.py new file mode 100644 index 0000000..64b0361 --- /dev/null +++ b/app/_archive/legacy_2026_07/discovery_router.py @@ -0,0 +1,162 @@ +""" +Token Discovery API Router - Multi-chain token discovery from DexScreener + GeckoTerminal. +Connects to /api/v1/discovery/* +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.token_discovery import MONITORED_CHAINS, discover_tokens, scan_token_security + +router = APIRouter(prefix="/api/v1/discovery", tags=["token-discovery"]) + + +class DiscoveryRequest(BaseModel): + chains: list[str] | None = None + limit: int = 20 + + +@router.post("/tokens") +async def discover_new_tokens(req: DiscoveryRequest): + """Discover new tokens across chains (DexScreener + GeckoTerminal).""" + chains = req.chains if req.chains else list(MONITORED_CHAINS.keys()) + result = await discover_tokens(chains=chains) + total = sum(len(tokens) for tokens in result.values()) + return {"chains": list(result.keys()), "total_tokens": total, "tokens": result} + + +@router.get("/chains") +async def list_chains(): + """List monitored chains.""" + return {"chains": MONITORED_CHAINS, "count": len(MONITORED_CHAINS)} + + +@router.get("/security/{chain}/{address}") +async def token_security(chain: str, address: str): + """GoPlus security scan for a token.""" + chain_id = MONITORED_CHAINS.get(chain, chain) + result = await scan_token_security(chain_id, address) + if not result: + raise HTTPException(status_code=404, detail="Security scan unavailable") + return {"chain": chain, "address": address, "security": result} + + +@router.get("/health") +async def discovery_health(): + cg_status = {} + try: + from app.coingecko_connector import COINGECKO_FREE_KEY, COINGECKO_PRO_KEY + + cg_status = { + "free_key": bool(COINGECKO_FREE_KEY), + "pro_key": bool(COINGECKO_PRO_KEY), + "mode": "pro" if COINGECKO_PRO_KEY else "demo", + } + except Exception: + pass + return { + "status": "ok", + "service": "token-discovery", + "chains": len(MONITORED_CHAINS), + "coingecko": cg_status, + } + + +# ── CoinGecko Market Data ─────────────────────────────────── + + +@router.get("/coingecko/trending") +async def coingecko_trending(): + """Get trending coins from CoinGecko (updated every 30 min).""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + trending = await cg.get_trending() + return {"trending": trending, "count": len(trending)} + + +@router.get("/coingecko/markets") +async def coingecko_markets(vs: str = "usd", limit: int = 50): + """Get top coins by market cap.""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + markets = await cg.get_market_overview(vs_currency=vs, per_page=min(limit, 250)) + return {"markets": markets, "count": len(markets)} + + +@router.get("/coingecko/global") +async def coingecko_global(): + """Get global crypto market metrics (total market cap, BTC dominance, etc).""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + metrics = await cg.get_global_metrics() + return metrics or {"error": "unavailable"} + + +@router.get("/coingecko/coin/{coin_id}") +async def coingecko_coin_detail(coin_id: str): + """Get detailed info about a specific coin.""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + detail = await cg.get_token_detail(coin_id) + if not detail: + raise HTTPException(status_code=404, detail=f"Coin '{coin_id}' not found") + return detail + + +@router.get("/coingecko/categories") +async def coingecko_categories(): + """Get coin categories with market data.""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + categories = await cg.get_category_market_data() + return {"categories": categories, "count": len(categories)} + + +@router.get("/coingecko/exchanges") +async def coingecko_exchanges(limit: int = 20): + """Get top exchanges by trading volume.""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + exchanges = await cg.get_exchanges(per_page=min(limit, 100)) + return {"exchanges": exchanges, "count": len(exchanges)} + + +@router.get("/coingecko/ohlc/{coin_id}") +async def coingecko_ohlc(coin_id: str, vs: str = "usd", days: int = 7): + """Get OHLC candlestick data for a coin.""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + data = await cg.get_ohlc(coin_id, vs_currency=vs, days=days) + return {"coin_id": coin_id, "vs": vs, "days": days, "candles": data} + + +@router.get("/coingecko/pools/trending") +async def coingecko_trending_pools(chain: str = "solana"): + """Get trending DEX pools (GeckoTerminal).""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + pools = await cg.get_trending_pools(chain=chain) + return {"chain": chain, "pools": pools, "count": len(pools)} + + +@router.get("/coingecko/status") +async def coingecko_status(): + """Check CoinGecko API key status and connectivity.""" + from app.coingecko_connector import get_coingecko_connector + + cg = get_coingecko_connector() + ping = await cg.ping() + key_status = await cg.api_key_status() + return { + "ping": ping, + "key_status": key_status, + "connector": cg.status(), + } diff --git a/app/_archive/legacy_2026_07/email_router.py b/app/_archive/legacy_2026_07/email_router.py new file mode 100644 index 0000000..6e653f1 --- /dev/null +++ b/app/_archive/legacy_2026_07/email_router.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Email API Router - Maildir + SMTP access for all RMI email accounts. +Maildir is mounted from host at /var/mail/vhosts. +SMTP uses host gateway for outbound delivery. +""" + +import email +import os +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter(prefix="/email", tags=["email"]) + +SMTP_HOST = os.getenv("SMTP_HOST", "172.20.0.1") +SMTP_PORT = int(os.getenv("SMTP_PORT", "25")) +MAILDIR_BASE = "/var/mail/vhosts" + +ACCOUNTS = { + "admin": "admin@rugmunch.io", + "biz": "biz@rugmunch.io", + "security": "security@rugmunch.io", + "hello": "hello@cryptorugmunch.com", + "media": "media@cryptorugmunch.com", +} + + +class SendRequest(BaseModel): + sender_account: str + to: str + subject: str + body: str + + +def _maildir_for(account: str) -> str: + addr = ACCOUNTS.get(account) + if not addr: + raise HTTPException(404, f"Unknown account: {account}") + local, domain = addr.split("@") + return f"{MAILDIR_BASE}/{domain}/{local}" + + +def _read_msg(filepath: str, msg_id: int, full: bool = False) -> dict: + with open(filepath, "rb") as f: + msg = email.message_from_binary_file(f) + result = { + "id": msg_id, + "subject": str(msg.get("Subject", "")), + "sender": str(msg.get("From", "")), + "recipient": str(msg.get("To", "")), + "date": str(msg.get("Date", "")), + } + if full: + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if payload: + body = payload.decode(errors="replace") + break + else: + payload = msg.get_payload(decode=True) + if payload: + body = payload.decode(errors="replace") + result["body"] = body[:10000] + return result + + +def _list_files(maildir: str) -> list: + files = [] + for sub in ["new", "cur"]: + d = os.path.join(maildir, sub) + if os.path.isdir(d): + files.extend(os.path.join(d, f) for f in os.listdir(d)) + files.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return files + + +@router.get("/accounts") +def list_accounts(): + return {"accounts": dict(ACCOUNTS.items())} + + +@router.get("/{account}/inbox") +def list_inbox(account: str, limit: int = Query(20, le=100)): + if account not in ACCOUNTS: + raise HTTPException(404, f"Unknown account. Options: {list(ACCOUNTS.keys())}") + try: + maildir = _maildir_for(account) + files = _list_files(maildir)[:limit] + msgs = [_read_msg(f, i + 1) for i, f in enumerate(files)] + return {"account": ACCOUNTS[account], "count": len(msgs), "messages": msgs} + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, str(e)) from e + + +@router.get("/{account}/message/{msg_id}") +def read_message(account: str, msg_id: int): + if account not in ACCOUNTS: + raise HTTPException(404, "Unknown account") + try: + maildir = _maildir_for(account) + files = _list_files(maildir) + if msg_id < 1 or msg_id > len(files): + raise HTTPException(404, "Message not found") + return _read_msg(files[msg_id - 1], msg_id, full=True) + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, str(e)) from e + + +@router.post("/send") +def send_email(req: SendRequest): + if req.sender_account not in ACCOUNTS: + raise HTTPException(404, "Unknown sender account") + sender_addr = ACCOUNTS[req.sender_account] + msg = MIMEMultipart() + msg["From"] = sender_addr + msg["To"] = req.to + msg["Subject"] = req.subject + msg.attach(MIMEText(req.body, "plain")) + try: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as smtp: + smtp.send_message(msg) + return {"status": "sent", "from": sender_addr, "to": req.to} + except Exception as e: + raise HTTPException(500, f"Send failed: {e}") from e diff --git a/app/_archive/legacy_2026_07/embedding_router.py b/app/_archive/legacy_2026_07/embedding_router.py new file mode 100644 index 0000000..a642bcc --- /dev/null +++ b/app/_archive/legacy_2026_07/embedding_router.py @@ -0,0 +1,16 @@ +"""Embedding Router - thin wrapper around smart rate-limited dispatcher.""" + +from app.rate_limiter import get_dispatcher + + +async def get_router(): + return await get_dispatcher() + + +async def embed_texts(texts, task="default"): + return await (await get_dispatcher()).embed(texts, task) + + +async def embed_single(text, task="default"): + vecs, provider = await (await get_dispatcher()).embed([text], task) + return vecs[0] if vecs else [], provider diff --git a/app/_archive/legacy_2026_07/entity_graph.py b/app/_archive/legacy_2026_07/entity_graph.py new file mode 100644 index 0000000..d9ce254 --- /dev/null +++ b/app/_archive/legacy_2026_07/entity_graph.py @@ -0,0 +1,396 @@ +""" +Entity Graph Visualization - Interactive Fund Flow for RugMaps + Scans +======================================================================= + +Generates interactive entity relationship graphs showing wallet connections, +fund flows, and cluster relationships. SVG output for embedding in scan results +and RugMaps pages. + +Premium feature: Visual forensics that sells in screenshots. +""" + +import logging +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger("entity.graph") + + +@dataclass +class GraphNode: + id: str + label: str + node_type: str = "wallet" # "wallet", "token", "contract", "entity", "cex" + risk_score: float = 0.0 + chain: str = "" + size: int = 20 + color: str = "#8B5CF6" + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class GraphEdge: + source: str + target: str + edge_type: str = "transfer" # "transfer", "deploy", "fund", "cluster", "counterparty" + value_usd: float = 0.0 + tx_count: int = 1 + color: str = "#4B5563" + width: float = 1.0 + + +def build_entity_graph( + center_address: str, + chain: str = "ethereum", + transactions: list[dict] | None = None, + labels: dict[str, Any] | None = None, + cluster_members: list[str] | None = None, + depth: int = 2, +) -> dict[str, Any]: + """Build an entity relationship graph from wallet data. + + Returns graph data suitable for visualization (SVG or Canvas). + """ + nodes: dict[str, GraphNode] = {} + edges: list[GraphEdge] = [] + + # ── Center node ── + center_id = f"{chain}:{center_address.lower()}" + nodes[center_id] = GraphNode( + id=center_id, + label=_shorten_addr(center_address), + node_type="wallet", + chain=chain, + size=30, + color="#22D3EE", # Cyan for center + metadata={"address": center_address, "chain": chain}, + ) + + # ── Process transactions ── + txs = transactions or [] + counterparties = set() + + for tx in txs[:200]: # Cap at 200 transactions + if not isinstance(tx, dict): + continue + + counterparty = tx.get("counterparty") or tx.get("to") or tx.get("from") + if not counterparty or counterparty.lower() == center_address.lower(): + continue + + cp_id = f"{tx.get('chain', chain)}:{counterparty.lower()}" + counterparties.add(cp_id) + + value = float(tx.get("value_usd", 0) or 0) + tx_type = tx.get("type", "transfer") + is_outgoing = tx.get("direction") == "out" or tx.get("from", "").lower() == center_address.lower() + + # Add counterparty node if new + if cp_id not in nodes: + # Determine node type and color + node_type, color = _classify_node(counterparty, labels or {}, tx) + nodes[cp_id] = GraphNode( + id=cp_id, + label=_shorten_addr(counterparty), + node_type=node_type, + chain=tx.get("chain", chain), + size=18 if node_type == "wallet" else 22, + color=color, + metadata={"address": counterparty}, + ) + + # Add edge + edge_color = "#EF4444" if is_outgoing else "#22C55E" # Red=out, Green=in + edge_width = min(max(value / 10000, 1), 6) if value > 0 else 1 + + edges.append( + GraphEdge( + source=center_id if is_outgoing else cp_id, + target=cp_id if is_outgoing else center_id, + edge_type=tx_type, + value_usd=value, + tx_count=1, + color=edge_color, + width=edge_width, + ) + ) + + # ── Add cluster members (if available) ── + if cluster_members: + cluster_color = "#F59E0B" # Amber for cluster + for member in cluster_members[:20]: + if member.lower() == center_address.lower(): + continue + member_id = f"{chain}:{member.lower()}" + if member_id not in nodes: + nodes[member_id] = GraphNode( + id=member_id, + label=_shorten_addr(member), + node_type="cluster_member", + color=cluster_color, + size=16, + ) + edges.append( + GraphEdge( + source=center_id, + target=member_id, + edge_type="cluster", + color=cluster_color, + width=1.5, + ) + ) + + # ── Add label-based nodes (CEX, scams, etc.) ── + if labels: + label_data = labels.get(center_address, labels) + if isinstance(label_data, dict): + label_type = label_data.get("label_type") or label_data.get("type", "") + entity = label_data.get("entity") or label_data.get("name", "") + + if label_type in ("cex", "exchange"): + nodes[center_id].node_type = "cex" + nodes[center_id].color = "#F59E0B" # Amber + nodes[center_id].label = entity or "Exchange" + elif label_type in ("scam", "phish", "hack"): + nodes[center_id].node_type = "scam" + nodes[center_id].color = "#EF4444" # Red + nodes[center_id].risk_score = 90 + nodes[center_id].label = entity or "Scam" + elif label_type in ("mixer", "tumbler"): + nodes[center_id].node_type = "mixer" + nodes[center_id].color = "#8B5CF6" # Purple + nodes[center_id].risk_score = 70 + + # ── Risk-based coloring ── + for node in nodes.values(): + if node.risk_score >= 80: + node.color = "#EF4444" # Red + elif node.risk_score >= 60: + node.color = "#F97316" # Orange + elif node.risk_score >= 40: + node.color = "#EAB308" # Yellow + + # ── Convert to graph format ── + return { + "center": {"id": center_id, "address": center_address, "chain": chain}, + "nodes": [ + { + "id": n.id, + "label": n.label, + "type": n.node_type, + "chain": n.chain, + "risk_score": n.risk_score, + "size": n.size, + "color": n.color, + "metadata": n.metadata, + } + for n in nodes.values() + ], + "edges": [ + { + "source": e.source, + "target": e.target, + "type": e.edge_type, + "value_usd": e.value_usd, + "tx_count": e.tx_count, + "color": e.color, + "width": e.width, + } + for e in edges + ], + "stats": { + "total_nodes": len(nodes), + "total_edges": len(edges), + "total_value_usd": sum(e.value_usd for e in edges), + "entity_types": list({n.node_type for n in nodes.values()}), + "risk_distribution": { + "high": sum(1 for n in nodes.values() if n.risk_score >= 60), + "medium": sum(1 for n in nodes.values() if 30 <= n.risk_score < 60), + "low": sum(1 for n in nodes.values() if n.risk_score < 30), + }, + }, + } + + +def generate_svg(graph_data: dict[str, Any], width: int = 800, height: int = 600) -> str: + """Generate an SVG visualization of the entity graph. + + Uses a simple force-directed layout algorithm. + """ + import math + + nodes = graph_data.get("nodes", []) + edges = graph_data.get("edges", []) + center = graph_data.get("center", {}) + center_id = center.get("id", "") + + if not nodes: + return 'No graph data' + + # Simple radial layout: center in middle, others in concentric circles + cx, cy = width // 2, height // 2 + + # Separate center from others + other_nodes = [n for n in nodes if n["id"] != center_id] + center_node = next((n for n in nodes if n["id"] == center_id), None) + + # Arrange other nodes in circles + positions = {} + if center_node: + positions[center_id] = (cx, cy) + + rings = [ + (150, 0.0), # radius, rotation offset + (250, 0.3), + (350, 0.15), + ] + + node_idx = 0 + for ring_radius, rotation in rings: + nodes_in_ring = other_nodes[node_idx : node_idx + max(8, len(other_nodes) // len(rings))] + n = len(nodes_in_ring) + for i, node in enumerate(nodes_in_ring): + angle = (2 * math.pi * i / max(n, 1)) + rotation + x = cx + ring_radius * math.cos(angle) + y = cy + ring_radius * math.sin(angle) + positions[node["id"]] = (x, y) + node_idx += n + if node_idx >= len(other_nodes): + break + + # Build SVG + svg_parts = [ + f'', + "", + '', + '', + '', + "", + # Background grid + '', + f'', + ] + + # Draw edges + for edge in edges: + src_pos = positions.get(edge["source"]) + tgt_pos = positions.get(edge["target"]) + if not src_pos or not tgt_pos: + continue + + x1, y1 = src_pos + x2, y2 = tgt_pos + + # Curve edges slightly + mid_x = (x1 + x2) / 2 + mid_y = (y1 + y2) / 2 - 30 + + edge_color = edge.get("color", "#4B5563") + edge_width = edge.get("width", 1) + marker_attr = 'marker-end="url(#arrow-red)"' if edge["type"] == "transfer" else "" + + svg_parts.append( + f'' + ) + + # Draw nodes + for node in nodes: + pos = positions.get(node["id"]) + if not pos: + continue + + x, y = pos + node_color = node.get("color", "#8B5CF6") + node_size = node.get("size", 20) + node_type = node.get("type", "wallet") + + # Different shapes by type + if node_type == "scam" or node.get("risk_score", 0) >= 80: + # Diamond for high-risk + svg_parts.append( + f'' + ) + elif node_type == "cex" or node_type == "exchange": + # Square for exchanges + svg_parts.append( + f'' + ) + else: + # Circle for wallets + svg_parts.append( + f'' + ) + + # Label + label = node.get("label", "")[:12] + svg_parts.append( + f'{label}' + ) + + # Center node highlight + if node["id"] == center_id: + svg_parts.append( + f'' + ) + + # Legend + legend_x = 20 + legend_y = height - 80 + legend_items = [ + ("#EF4444", "High Risk/Scam"), + ("#F97316", "Medium Risk"), + ("#22C55E", "Incoming"), + ("#EF4444", "Outgoing"), + ("#F59E0B", "Exchange/CEX"), + ("#8B5CF6", "Wallet"), + ] + for i, (color, label) in enumerate(legend_items): + lx = legend_x + (i % 3) * 130 + ly = legend_y + (i // 3) * 20 + svg_parts.append(f'') + svg_parts.append(f'{label}') + + svg_parts.append("") + return "\n".join(svg_parts) + + +def _classify_node(address: str, labels: dict[str, Any], tx: dict) -> tuple[str, str]: + """Classify a counterparty node by type and color.""" + addr_lower = address.lower() + + # Check labels + label_info = labels.get(address, labels.get(addr_lower, {})) + if isinstance(label_info, dict): + label_type = label_info.get("label_type", label_info.get("type", "")) + if label_type in ("cex", "exchange"): + return ("cex", "#F59E0B") + if label_type in ("scam", "phish", "hack", "exploit"): + return ("scam", "#EF4444") + if label_type in ("mixer", "tumbler", "tornado"): + return ("mixer", "#8B5CF6") + if label_type in ("defi", "protocol"): + return ("protocol", "#06B6D4") + + # Check transaction patterns + tx_type = tx.get("type", "") + if tx_type == "contract_creation" or tx_type == "deploy": + return ("contract", "#10B981") + if tx_type == "swap": + return ("dex", "#06B6D4") + + return ("wallet", "#8B5CF6") + + +def _shorten_addr(addr: str) -> str: + """Shorten address for display.""" + if not addr: + return "?" + if len(addr) <= 12: + return addr + return f"{addr[:6]}...{addr[-4:]}" diff --git a/app/_archive/legacy_2026_07/etherscan_router.py b/app/_archive/legacy_2026_07/etherscan_router.py new file mode 100644 index 0000000..78c13ee --- /dev/null +++ b/app/_archive/legacy_2026_07/etherscan_router.py @@ -0,0 +1,273 @@ +""" +Etherscan Family API Router - EVM Block Explorers. +Single API for: Ethereum, BSC, Polygon, Avalanche, Fantom, Arbitrum, Optimism, Base. +""" + +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/etherscan", tags=["etherscan"]) + + +# ── Models ─────────────────────────────────────────────────── + + +class AccountQuery(BaseModel): + address: str + network: str = "eth" # eth, bsc, polygon, avalanche, fantom, arbitrum, optimism, base + page: int = 1 + offset: int = 100 + + +class TokenBalanceQuery(BaseModel): + contract: str + address: str + network: str = "eth" + + +class ContractQuery(BaseModel): + contract: str + network: str = "eth" + + +class LogsQuery(BaseModel): + from_block: int + to_block: int + address: str | None = None + topic0: str | None = None + network: str = "eth" + + +# ── Account API ────────────────────────────────────────────── + + +@router.get("/balance") +async def get_balance(address: str, network: str = "eth"): + """Get native token balance (ETH/BNB/MATIC/etc).""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + balance = await ec.get_balance(address, network) + # Convert wei to native token + try: + native = int(balance) / 1e18 if balance else 0 + except Exception: + native = 0 + return { + "address": address, + "network": network, + "balance_wei": balance, + "balance_native": native, + } + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/transactions") +async def get_transactions(address: str, network: str = "eth", page: int = 1, offset: int = 100): + """Get transaction list for address.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + txs = await ec.get_tx_list(address, network, page=page, offset=offset) + return { + "address": address, + "network": network, + "transactions": txs[:offset], + "count": len(txs), + } + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/token-transfers") +async def get_token_transfers( + address: str, + network: str = "eth", + contract: str | None = None, + page: int = 1, + offset: int = 100, +): + """Get ERC-20 token transfer events.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + transfers = await ec.get_token_transfers(address, network, contract_address=contract, page=page, offset=offset) + return { + "address": address, + "network": network, + "transfers": transfers[:offset], + "count": len(transfers), + } + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/token-balance") +async def get_token_balance(contract: str, address: str, network: str = "eth"): + """Get ERC-20 token balance for address.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + balance = await ec.get_token_balance(contract, address, network) + return { + "contract": contract, + "address": address, + "network": network, + "balance_wei": balance, + } + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/nft-transfers") +async def get_nft_transfers(address: str, network: str = "eth", page: int = 1, offset: int = 100): + """Get ERC-721 NFT transfer events.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + transfers = await ec.get_erc721_transfers(address, network, page=page, offset=offset) + return { + "address": address, + "network": network, + "transfers": transfers[:offset], + "count": len(transfers), + } + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Contract API ───────────────────────────────────────────── + + +@router.get("/contract/abi") +async def get_contract_abi(contract: str, network: str = "eth"): + """Get contract ABI.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + abi = await ec.get_contract_abi(contract, network) + return {"contract": contract, "network": network, "abi": abi} + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/contract/source") +async def get_contract_source(contract: str, network: str = "eth"): + """Get contract source code.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + source = await ec.get_contract_source(contract, network) + return {"contract": contract, "network": network, "source": source} + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/contract/verify") +async def verify_contract(contract: str, network: str = "eth"): + """Check if contract is verified.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + result = await ec.verify_contract(contract, network) + return {"contract": contract, "network": network, **result} + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Transaction API ────────────────────────────────────────── + + +@router.get("/tx/{tx_hash}/status") +async def get_tx_status(tx_hash: str, network: str = "eth"): + """Get transaction status (success/fail).""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + status = await ec.get_transaction_status(tx_hash, network) + return {"tx_hash": tx_hash, "network": network, "status": status} + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Block API ──────────────────────────────────────────────── + + +@router.get("/block/latest") +async def get_latest_block(network: str = "eth"): + """Get latest block number.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + block = await ec.get_block_number(network) + return {"network": network, "block_number": block} + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Logs API ───────────────────────────────────────────────── + + +@router.post("/logs") +async def get_logs(req: LogsQuery): + """Get event logs (filtered by address/topic).""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + logs = await ec.get_logs(req.from_block, req.to_block, req.address, req.topic0, req.network) + return {"network": req.network, "logs": logs, "count": len(logs)} + except ImportError: + raise HTTPException(status_code=503, detail="Etherscan connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Health ──────────────────────────────────────────────────── + + +@router.get("/health") +async def etherscan_health(): + """Etherscan connector status.""" + try: + from app.etherscan_connector import get_etherscan_connector + + ec = get_etherscan_connector() + return {"status": "ok", "service": "etherscan-connector", **ec.status()} + except ImportError: + return {"status": "ok", "service": "etherscan-connector", "networks_active": 0} diff --git a/app/_archive/legacy_2026_07/forensics_router.py b/app/_archive/legacy_2026_07/forensics_router.py new file mode 100644 index 0000000..6721e65 --- /dev/null +++ b/app/_archive/legacy_2026_07/forensics_router.py @@ -0,0 +1,120 @@ +""" +Forensics API Router - Deep contract scan + cross-chain correlation + risk reports. +Connects to /api/v1/forensics/* +""" + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/forensics", tags=["forensics"]) + + +class DeepScanRequest(BaseModel): + contract_address: str + chain: str = "base" + + +class BatchScanRequest(BaseModel): + contracts: list[str] + chain: str = "base" + + +class RiskReportRequest(BaseModel): + token_address: str + chain: str = "solana" + include_graph: bool = False + + +class ThreatCheckRequest(BaseModel): + address: str + chain_id: str = "1" # 1=ethereum, 56=bsc, 8453=base, solana=solana + + +@router.post("/deep-scan") +async def deep_scan(req: DeepScanRequest): + """Deep contract scan using slither/mythril.""" + try: + from app.contract_deepscan import deep_scan_contract + + result = deep_scan_contract(req.contract_address, chain=req.chain) + return {"contract": req.contract_address, "chain": req.chain, "scan": result} + except ImportError as e: + raise HTTPException(status_code=503, detail=f"Scanner unavailable: {e}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:500]) from e + + +@router.post("/batch-scan") +async def batch_deep_scan(req: BatchScanRequest): + """Batch deep scan multiple contracts.""" + try: + from app.contract_deepscan import batch_deep_scan + + result = batch_deep_scan(req.contracts, chain=req.chain) + return {"contracts": len(req.contracts), "chain": req.chain, "results": result} + except ImportError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:500]) from e + + +@router.post("/risk-report") +async def risk_report(req: RiskReportRequest): + """Generate comprehensive rug risk report.""" + try: + from app.advanced_analysis import RugRiskReport + + report = RugRiskReport(token_address=req.token_address, chain=req.chain) + return {"token_address": req.token_address, "chain": req.chain, "report": str(report)} + except ImportError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:500]) from e + + +@router.post("/cross-chain") +async def cross_chain_correlate(addresses: list[str] = Query(...), chains: list[str] | None = Query(None)): + """Correlate wallets across chains using behavioral fingerprinting + CEX patterns.""" + try: + from app.cross_chain_correlator import get_cross_chain_correlator + + cc = get_cross_chain_correlator() + profiles = await cc.correlate(addresses=addresses, chains=chains) + return { + "addresses_scanned": len(addresses), + "profiles_found": len(profiles), + "profiles": [ + { + "entity_id": p.entity_id, + "total_addresses": p.total_addresses, + "chains": dict(p.chains), + "links": len(p.links), + "risk_score": p.risk_score, + } + for p in profiles + ], + } + except ImportError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:500]) from e + + +@router.get("/health") +async def forensics_health(): + return {"status": "ok", "service": "forensics-engine"} + + +@router.post("/threat-check") +async def threat_check(req: ThreatCheckRequest): + """Multi-source threat intel check: CryptoScamDB + GoPlus + Januus (all free).""" + try: + from app.threat_feeds import get_threat_feeds + + feeds = get_threat_feeds() + result = await feeds.full_check(address=req.address, chain_id=req.chain_id) + return result + except ImportError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:500]) from e diff --git a/app/_archive/legacy_2026_07/ghostunnel_manager.py b/app/_archive/legacy_2026_07/ghostunnel_manager.py new file mode 100644 index 0000000..1b83d0b --- /dev/null +++ b/app/_archive/legacy_2026_07/ghostunnel_manager.py @@ -0,0 +1,175 @@ +""" +Ghostunnel SSL/TLS Tunnel Integration +====================================== + +Secure SSL/TLS proxy with mutual authentication for non-TLS services. +Features: +- TLS termination +- Client certificate verification +- Proxying to TCP services +""" + +import logging +import subprocess +from dataclasses import dataclass +from datetime import datetime + +logger = logging.getLogger(__name__) + + +@dataclass +class GhostunnelConfig: + """Ghostunnel configuration.""" + + listen: str + target: str + cacert: str | None = None + cert: str | None = None + key: str | None = None + clientcert: str | None = None + acceptonly: str | None = None + status: str | None = "127.0.0.1:8080" + + +# ─── GHOSTUNNEL MANAGER ─────────────────────────────────────────── + + +class GhostunnelManager: + """Manages Ghostunnel processes.""" + + def __init__(self, ghostunnel_bin: str = "ghostunnel"): + self.ghostunnel_bin = ghostunnel_bin + self._available = self._check_available() + self.processes: dict[str, subprocess.Popen] = {} + + def _check_available(self) -> bool: + """Check if ghostunnel is installed.""" + try: + result = subprocess.run([self.ghostunnel_bin, "version"], capture_output=True, timeout=5) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def start_tunnel(self, config: GhostunnelConfig) -> str: + """ + Start a new tunnel. + + Args: + config: Ghostunnel configuration + + Returns: + Process ID of the tunnel + """ + if not self._available: + return "stub_tunnel" + + cmd = [ + self.ghostunnel_bin, + "server", + "--listen", + config.listen, + "--target", + config.target, + ] + + if config.cacert: + cmd.extend(["--cacert", config.cacert]) + if config.cert: + cmd.extend(["--cert", config.cert]) + if config.key: + cmd.extend(["--key", config.key]) + if config.clientcert: + cmd.extend(["--clientcert", config.clientcert]) + if config.acceptonly: + cmd.extend(["--acceptonly", config.acceptonly]) + if config.status: + cmd.extend(["--status", config.status]) + + try: + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.processes[config.listen] = process + return str(process.pid) + except Exception as e: + logger.error(f"Failed to start ghostunnel: {e}") + return "" + + def stop_tunnel(self, listen_addr: str) -> bool: + """ + Stop a tunnel by listening address. + + Args: + listen_addr: Listening address of the tunnel + + Returns: + Success status + """ + process = self.processes.get(listen_addr) + if process: + process.terminate() + try: + process.wait(timeout=5) + del self.processes[listen_addr] + return True + except subprocess.TimeoutExpired: + process.kill() + del self.processes[listen_addr] + return True + return False + + def stop_all(self) -> int: + """Stop all tunnels. Returns count of stopped processes.""" + count = 0 + for addr in list(self.processes.keys()): + if self.stop_tunnel(addr): + count += 1 + return count + + def get_status(self) -> dict[str, str]: + """Get status of all tunnels.""" + status = {} + for addr, process in self.processes.items(): + status[addr] = { + "pid": process.pid, + "alive": process.poll() is None, + "started_at": datetime.utcnow().isoformat(), + } + return status + + +# ─── GLOBAL SINGLETON ───────────────────────────────────────────── + +_manager: GhostunnelManager | None = None + + +def get_ghostunnel_manager() -> GhostunnelManager: + """Get or create Ghostunnel manager.""" + global _manager + if _manager is None: + _manager = GhostunnelManager() + return _manager + + +def start_tunnel(listen: str, target: str, **kwargs) -> str: + """ + Convenience function to start a tunnel. + + Args: + listen: Listen address (host:port) + target: Target address (host:port) + **kwargs: Extra config options + """ + config = GhostunnelConfig(listen=listen, target=target, **kwargs) + manager = get_ghostunnel_manager() + return manager.start_tunnel(config) + + +def stop_tunnel(listen: str) -> bool: + """Convenience function to stop a tunnel.""" + manager = get_ghostunnel_manager() + return manager.stop_tunnel(listen) + + +def stop_all_tunnels() -> int: + """Convenience function to stop all tunnels.""" + manager = get_ghostunnel_manager() + return manager.stop_all() diff --git a/app/_archive/legacy_2026_07/github_rag_feeder.py b/app/_archive/legacy_2026_07/github_rag_feeder.py new file mode 100644 index 0000000..07b6f1a --- /dev/null +++ b/app/_archive/legacy_2026_07/github_rag_feeder.py @@ -0,0 +1,230 @@ +""" +RAG GitHub Feeder v2 - Declarative, Config-Driven +================================================== +Add any GitHub repo as a RAG data source with minimal config. +Auto-clones, extracts markdown/solidity/text, chunks, and ingests. + +Config format (add to SOURCES list): +{ + "repo": "owner/repo", + "collection": "defi_hacks|vuln_patterns|contract_audits|...", + "category": "defi_hack|audit|scam_report|...", + "tags": ["tag1", "tag2"], + "file_patterns": ["*.md", "*.sol", "*.py"], # files to extract + "max_files": 50, # limit per run + "enabled": True, +} +""" + +import asyncio +import glob +import json +import os +import re +import subprocess + +import httpx + +BACKEND = "http://localhost:8000" +INGEST_URL = f"{BACKEND}/api/v1/rag/ingest" + +# ═══════════════════════════════════════════════════ +# DECLARATIVE SOURCE CONFIG - add repos here +# ═══════════════════════════════════════════════════ +SOURCES = [ + { + "repo": "SunWeb3Sec/DeFiHackLabs", + "collection": "defi_hacks", + "category": "defi_hack", + "tags": ["DeFi", "hack_reproduction", "Foundry", "education"], + "file_patterns": ["*.md"], + "max_files": 50, + "enabled": True, + }, + { + "repo": "TradMod/awesome-audits-checklists", + "collection": "contract_audits", + "category": "audit_checklist", + "tags": ["audit", "checklist", "smart_contract", "security"], + "file_patterns": ["*.md"], + "max_files": 20, + "enabled": True, + }, + { + "repo": "alt-research/SolidityGuard", + "collection": "vuln_patterns", + "category": "vuln_pattern", + "tags": ["OWASP", "Solidity", "security", "vulnerability"], + "file_patterns": ["*.md", "*.sol"], + "max_files": 40, + "enabled": True, + }, + { + "repo": "forta-network/labelled-datasets", + "collection": "known_scams", + "category": "scam_report", + "tags": ["Forta", "labelled_data", "threat_intel", "web3_security"], + "file_patterns": ["*.md", "*.json", "*.csv"], + "max_files": 30, + "enabled": True, + }, + { + "repo": "Messi-Q/Smart-Contract-Dataset", + "collection": "vuln_patterns", + "category": "vuln_pattern", + "tags": ["dataset", "smart_contract", "vulnerability", "academic"], + "file_patterns": ["*.md", "*.sol", "*.csv"], + "max_files": 30, + "enabled": True, + }, + { + "repo": "nurkiewicz/crypto-hall-of-shame", + "collection": "rug_timeline", + "category": "scam_report", + "tags": ["scam_timeline", "historical", "hall_of_shame"], + "file_patterns": ["*.md", "*.adoc"], + "max_files": 5, + "enabled": True, + }, +] + +CLONE_DIR = "/tmp/rag_sources" + + +def ensure_cloned(repo: str) -> str | None: + """Clone repo if not already present. Returns path or None.""" + name = repo.split("/")[-1] + path = os.path.join(CLONE_DIR, name) + if os.path.exists(path): + return path + os.makedirs(CLONE_DIR, exist_ok=True) + try: + subprocess.run( + ["git", "clone", "--depth", "1", f"https://github.com/{repo}.git", path], + capture_output=True, + timeout=60, + ) + return path if os.path.exists(path) else None + except Exception: + return None + + +def extract_from_repo(source: dict) -> list[dict]: + """Extract documents from a cloned GitHub repo.""" + path = ensure_cloned(source["repo"]) + if not path: + print(f" {source['repo']}: clone failed") + return [] + + docs = [] + patterns = source.get("file_patterns", ["*.md"]) + max_files = source.get("max_files", 50) + + files = [] + for pat in patterns: + files.extend(glob.glob(f"{path}/**/{pat}", recursive=True)) + + for fpath in files[:max_files]: + try: + # Skip binary/large files + size = os.path.getsize(fpath) + if size > 500_000 or size < 50: + continue + + with open(fpath, errors="ignore") as f: + content = f.read() + + if not content.strip(): + continue + + # Extract filename for title + relpath = os.path.relpath(fpath, path) + name = os.path.splitext(os.path.basename(fpath))[0] + + # Try to extract a title from markdown heading + title_match = re.search(r"^#\s+(.+)", content, re.MULTILINE) + title = title_match.group(1) if title_match else name + + # For .sol files, extract contract/function structure + if fpath.endswith(".sol"): + contracts = re.findall(r"(?:contract|interface|library)\s+(\w+)", content) + funcs = re.findall(r"function\s+(\w+)", content)[:10] + summary = f"Solidity: {', '.join(contracts[:5])}. Functions: {', '.join(funcs)}" + text = f"Smart Contract: {title} ({relpath})\n\n{summary}\n\n{content[:3000]}" + elif fpath.endswith(".json"): + try: + data = json.loads(content) + text = f"Dataset: {title}\n\n{json.dumps(data, indent=2)[:3000]}" + except Exception: + text = f"Data File: {title}\n\n{content[:2000]}" + elif fpath.endswith(".csv"): + lines = content.split("\n")[:50] + text = f"CSV Data: {title}\n\n" + "\n".join(lines) + else: + text = f"{title}\n\n{content[:3000]}" + + docs.append( + { + "text": text[:4000], + "source": source["repo"], + "url": f"https://github.com/{source['repo']}/blob/main/{relpath}", + "category": source["category"], + "tags": source.get("tags", []), + } + ) + except Exception: + pass + + print(f" {source['repo']}: {len(docs)} docs from {len(files[:max_files])} files") + return docs + + +async def ingest_repo(source: dict): + """Clone, extract, and ingest a single repo.""" + docs = extract_from_repo(source) + if not docs: + return 0 + + total = 0 + async with httpx.AsyncClient(timeout=120) as c: + for i in range(0, len(docs), 30): + batch = docs[i : i + 30] + try: + r = await c.post( + INGEST_URL, + json={ + "collection": source["collection"], + "documents": batch, + "source": source["repo"], + "chunking": "scam_report", + }, + ) + if r.status_code == 200: + n = r.json().get("ingested", 0) + total += n + except Exception: + pass + return total + + +async def ingest_all(): + """Run all enabled sources.""" + print("=== RAG GitHub Feeder v2 ===\n") + results = {} + total = 0 + + for src in SOURCES: + if not src.get("enabled", True): + continue + print(f"Ingesting: {src['repo']} → {src['collection']}") + n = await ingest_repo(src) + results[src["repo"]] = n + total += n + print(f" ingested: {n}\n") + + print(f"Total: {total} docs across {len(results)} sources") + return results + + +if __name__ == "__main__": + asyncio.run(ingest_all()) diff --git a/app/_archive/legacy_2026_07/human_catalog.py b/app/_archive/legacy_2026_07/human_catalog.py new file mode 100644 index 0000000..a72d84f --- /dev/null +++ b/app/_archive/legacy_2026_07/human_catalog.py @@ -0,0 +1,480 @@ +""" +Human-Facing Tool Catalog API +================================ +Rich, groupable, searchable catalog for the public web terminal and Telegram bot. +Every tool from TOOL_PRICES + expanded_mcp_catalog is auto-exposed here. +Adding a tool anywhere in the system? It's automatically picked up here. + +Endpoints: + GET /api/v1/catalog - Full catalog grouped by category + service + GET /api/v1/catalog/search?q= - Free-text search + GET /api/v1/catalog/featured - Curated featured tools (5 per category) + GET /api/v1/catalog/by-trial - Grouped by trial count + GET /api/v1/catalog/by-price - Grouped by price tier (free/$0.01/$0.05/etc) + GET /api/v1/catalog/by-chain/{chain} - Tools for a specific chain + GET /api/v1/catalog/{tool_id} - Single tool with full metadata + GET /api/v1/catalog/stats - Catalog statistics + +Trial policy tiers (per tool): + simple: 5 free trials (sentiment, info, lookup) + standard: 3 free trials (analysis, security scan) + premium: 1 free trial (audit, deep scan, codegen) +""" + +import logging +import time +from collections import defaultdict +from typing import Any + +from fastapi import APIRouter, HTTPException, Query + +logger = logging.getLogger("human_catalog") +router = APIRouter(prefix="/api/v1/catalog", tags=["human-catalog"]) + +# ════════════════════════════════════════════════════════════ +# Trial tier mapping +# ════════════════════════════════════════════════════════════ + +# Tiers based on tool value/computational cost +TRIAL_TIERS = { + "free": {"trials": 5, "label": "Free", "color": "emerald", "badge": "🆓"}, + "simple": {"trials": 5, "label": "5 free trials", "color": "emerald", "badge": "🎁"}, + "standard": {"trials": 3, "label": "3 free trials", "color": "blue", "badge": "✨"}, + "premium": {"trials": 1, "label": "1 free trial", "color": "purple", "badge": "💎"}, +} + +PRICE_TIERS = { + "micro": {"max": 0.01, "label": "Micro", "color": "emerald"}, + "low": {"max": 0.05, "label": "Low", "color": "blue"}, + "mid": {"max": 0.15, "label": "Mid", "color": "amber"}, + "high": {"max": 0.50, "label": "High", "color": "rose"}, + "premium": {"max": 999.0, "label": "Premium", "color": "violet"}, +} + +# Categories and their complexity (determines trial count) +CATEGORY_TRIAL_DEFAULTS = { + # Simple lookup/info - high trial count + "sentiment": "simple", + "social": "simple", + "api": "simple", + "research": "simple", + # Standard analysis - medium trial count + "analysis": "standard", + "intelligence": "standard", + "market": "standard", + "monitoring": "standard", + # Premium/heavy - low trial count + "security": "premium", + "forensics": "premium", + "premium": "premium", + "launchpad": "standard", + "defi": "standard", + "nft": "standard", + "bundle": "standard", + "mcp-external": "simple", +} + + +def _classify_trial_tier(tool: dict) -> str: + """Determine trial tier from tool metadata.""" + # Honor explicit trialFree in price_usd range + if tool.get("trialFree") is not None: + n = int(tool["trialFree"]) + if n >= 5: + return "simple" + elif n >= 3: + return "standard" + else: + return "premium" + + # Fall back to category default + cat = tool.get("category", "analysis").lower() + return CATEGORY_TRIAL_DEFAULTS.get(cat, "standard") + + +def _classify_price_tier(price_usd: float) -> str: + """Determine price tier from USD amount.""" + for tier_name, tier_info in PRICE_TIERS.items(): + if price_usd <= tier_info["max"]: + return tier_name + return "premium" + + +# ════════════════════════════════════════════════════════════ +# Cache - rebuild catalog from sources on demand +# ════════════════════════════════════════════════════════════ + +_catalog_cache: dict[str, Any] = {} +_catalog_cache_time: float = 0 +CATALOG_CACHE_TTL = 30 # 30 seconds - short enough to pick up new tools quickly + + +def _get_full_catalog() -> dict[str, Any]: + """Pull the live catalog from all sources. Cached for CATALOG_CACHE_TTL.""" + global _catalog_cache, _catalog_cache_time + + now = time.time() + if _catalog_cache and (now - _catalog_cache_time) < CATALOG_CACHE_TTL: + return _catalog_cache + + # Source 1: x402 enforcement (TOOL_PRICES) - authoritative for our 221 native tools + try: + from app.routers.x402_enforcement import TOOL_PRICES + + dict(TOOL_PRICES) + except ImportError: + pass + + # Source 2: x402_catalog (which merges everything) + try: + from app.routers.x402_catalog import get_catalog + + get_catalog_func = get_catalog + except ImportError: + get_catalog_func = None + + # Source 3: expanded_mcp_catalog (the 25 new tools) + try: + from app.services.expanded_mcp_catalog import EXTERNAL_MCP_SERVICES, EXTERNAL_MCP_TOOLS + + {t["id"] for t in EXTERNAL_MCP_TOOLS} + external_services = list(EXTERNAL_MCP_SERVICES) + except ImportError: + external_services = [] + + # Combine: take the x402_catalog full output and enrich + if get_catalog_func: + try: + merged = get_catalog_func() + tools_list = list(merged.get("tools", [])) + except Exception: + tools_list = [] + else: + tools_list = [] + + # Convert each to human-friendly format + enriched = [] + for t in tools_list: + price_usd = float(t.get("priceUsd", 0.01) or 0.01) + trial_tier = _classify_trial_tier(t) + price_tier = _classify_price_tier(price_usd) + + enriched.append( + { + "id": t.get("id", ""), + "name": t.get("name", t.get("id", "?")), + "description": t.get("description", ""), + "category": t.get("category", "analysis"), + "service": t.get("service", "rmi-native"), + "source": t.get("source", "rmi-native"), + "chains": t.get("chains", []), + "price_usd": price_usd, + "price_label": f"${price_usd:.2f}", + "trial_tier": trial_tier, + "trial_count": TRIAL_TIERS[trial_tier]["trials"], + "trial_label": TRIAL_TIERS[trial_tier]["label"], + "trial_badge": TRIAL_TIERS[trial_tier]["badge"], + "price_tier": price_tier, + "price_tier_label": PRICE_TIERS[price_tier]["label"], + "method": t.get("method", "POST"), + } + ) + + # Group by category + by_category = defaultdict(list) + for t in enriched: + by_category[t["category"]].append(t) + + # Group by service + by_service = defaultdict(list) + for t in enriched: + by_service[t["service"]].append(t) + + # Group by trial tier + by_trial = defaultdict(list) + for t in enriched: + by_trial[t["trial_tier"]].append(t) + + # Group by price tier + by_price = defaultdict(list) + for t in enriched: + by_price[t["price_tier"]].append(t) + + # Build featured set - top 5 from each major category + major_cats = ["security", "intelligence", "market", "analysis", "social", "defi"] + featured = [] + for cat in major_cats: + cat_tools = by_category.get(cat, []) + featured.extend(cat_tools[:5]) + + # Sort: native first, then external + enriched.sort(key=lambda t: (0 if t["source"] == "enforcement" else 1, t["price_usd"])) + + result = { + "version": "1.0.0", + "generated_at": int(now), + "total_tools": len(enriched), + "total_native": sum(1 for t in enriched if t["source"] in ("enforcement", "route", "rmi-native")), + "total_external": sum(1 for t in enriched if t["source"] in ("expanded-mcp", "mcp-router")), + "total_services": len(by_service), + "total_categories": len(by_category), + "total_chains": len({c for t in enriched for c in t["chains"]}), + "categories": sorted(by_category.keys()), + "services": sorted(by_service.keys()), + "tools": enriched, + "by_category": dict(by_category.items()), + "by_service": dict(by_service.items()), + "by_trial_tier": dict(by_trial.items()), + "by_price_tier": dict(by_price.items()), + "featured": featured, + "external_services": external_services, + "trial_tiers": TRIAL_TIERS, + "price_tiers": PRICE_TIERS, + } + + _catalog_cache = result + _catalog_cache_time = now + return result + + +def invalidate_catalog_cache(): + """Force rebuild on next request - call after adding/updating tools.""" + global _catalog_cache_time + _catalog_cache_time = 0 + + +# ════════════════════════════════════════════════════════════ +# Endpoints +# ════════════════════════════════════════════════════════════ + + +@router.get("") +async def get_catalog_root( + sort: str = Query("default", description="default, price, trial, name"), + category: str | None = Query(None, description="Filter by category"), + service: str | None = Query(None, description="Filter by service"), + chain: str | None = Query(None, description="Filter by chain"), + free_only: bool = Query(False, description="Only tools with free trials"), + limit: int = Query(500, description="Max tools to return (0 = all)"), +): + """Full human-facing catalog. Grouped + filterable + sortable.""" + cat = _get_full_catalog() + tools = list(cat["tools"]) + + # Filters + if category: + tools = [t for t in tools if t["category"].lower() == category.lower()] + if service: + tools = [t for t in tools if t["service"].lower() == service.lower()] + if chain: + chain_upper = chain.upper() + tools = [t for t in tools if chain_upper in [c.upper() for c in t["chains"]]] + if free_only: + tools = [t for t in tools if t["trial_count"] > 0] + + # Sort + if sort == "price": + tools.sort(key=lambda t: t["price_usd"]) + elif sort == "trial": + tools.sort(key=lambda t: -t["trial_count"]) + elif sort == "name": + tools.sort(key=lambda t: t["name"].lower()) + elif sort == "category": + tools.sort(key=lambda t: (t["category"], t["name"].lower())) + + # Limit + if limit > 0: + tools = tools[:limit] + + return { + "total": len(tools), + "total_available": cat["total_tools"], + "version": cat["version"], + "generated_at": cat["generated_at"], + "filters": { + "category": category, + "service": service, + "chain": chain, + "sort": sort, + "free_only": free_only, + }, + "tools": tools, + "categories": cat["categories"], + "services": cat["services"], + "trial_tiers": cat["trial_tiers"], + "price_tiers": cat["price_tiers"], + } + + +@router.get("/search") +async def search_catalog( + q: str = Query(..., min_length=2, description="Search query"), + limit: int = Query(50, description="Max results"), +): + """Free-text search across tool names, descriptions, IDs, and chains.""" + cat = _get_full_catalog() + q_lower = q.lower() + results = [] + + for t in cat["tools"]: + score = 0 + # ID match (highest) + if q_lower in t["id"].lower(): + score += 100 + # Name match + if q_lower in t["name"].lower(): + score += 50 + # Description match + if q_lower in t["description"].lower(): + score += 20 + # Category match + if q_lower in t["category"].lower(): + score += 10 + # Service match + if q_lower in t["service"].lower(): + score += 5 + # Chain match + if any(q_lower in c.lower() for c in t["chains"]): + score += 5 + + if score > 0: + results.append({**t, "_score": score}) + + # Sort by score + results.sort(key=lambda t: -t["_score"]) + # Drop score + for r in results: + r.pop("_score") + + return { + "query": q, + "total": len(results), + "tools": results[:limit], + } + + +@router.get("/featured") +async def get_featured(): + """Curated featured tools - 5 from each major category.""" + cat = _get_full_catalog() + return { + "total": len(cat["featured"]), + "tools": cat["featured"], + } + + +@router.get("/by-trial") +async def get_by_trial(): + """Tools grouped by free trial tier (free/standard/premium).""" + cat = _get_full_catalog() + return { + "tiers": cat["trial_tiers"], + "groups": dict(cat["by_trial_tier"].items()), + "counts": {k: len(v) for k, v in cat["by_trial_tier"].items()}, + } + + +@router.get("/by-price") +async def get_by_price(): + """Tools grouped by price tier (micro/low/mid/high/premium).""" + cat = _get_full_catalog() + return { + "tiers": cat["price_tiers"], + "groups": dict(cat["by_price_tier"].items()), + "counts": {k: len(v) for k, v in cat["by_price_tier"].items()}, + } + + +@router.get("/by-chain/{chain}") +async def get_by_chain(chain: str): + """All tools available for a specific chain.""" + cat = _get_full_catalog() + chain_upper = chain.upper() + tools = [t for t in cat["tools"] if chain_upper in [c.upper() for c in t["chains"]]] + return { + "chain": chain_upper, + "total": len(tools), + "tools": tools, + } + + +@router.get("/by-category/{category}") +async def get_by_category(category: str): + """All tools in a specific category.""" + cat = _get_full_catalog() + tools = [t for t in cat["tools"] if t["category"].lower() == category.lower()] + return { + "category": category.lower(), + "total": len(tools), + "tools": tools, + } + + +@router.get("/by-service/{service}") +async def get_by_service(service: str): + """All tools from a specific service.""" + cat = _get_full_catalog() + tools = [t for t in cat["tools"] if t["service"].lower() == service.lower()] + return { + "service": service.lower(), + "total": len(tools), + "tools": tools, + } + + +@router.get("/stats") +async def get_stats(): + """Catalog statistics - counts, breakdown, pricing.""" + cat = _get_full_catalog() + return { + "version": cat["version"], + "generated_at": cat["generated_at"], + "total_tools": cat["total_tools"], + "total_native": cat["total_native"], + "total_external": cat["total_external"], + "total_services": cat["total_services"], + "total_categories": cat["total_categories"], + "total_chains": cat["total_chains"], + "by_category": {k: len(v) for k, v in cat["by_category"].items()}, + "by_service": {k: len(v) for k, v in cat["by_service"].items()}, + "by_trial_tier": {k: len(v) for k, v in cat["by_trial_tier"].items()}, + "by_price_tier": {k: len(v) for k, v in cat["by_price_tier"].items()}, + "external_services": cat["external_services"], + } + + +@router.get("/external") +async def get_external(): + """All free open-source MCPs we've integrated, with their metadata.""" + cat = _get_full_catalog() + return { + "total": len(cat["external_services"]), + "services": cat["external_services"], + "tools": [t for t in cat["tools"] if t["source"] in ("expanded-mcp", "mcp-router")], + } + + +@router.get("/{tool_id}") +async def get_tool(tool_id: str): + """Single tool with full metadata.""" + cat = _get_full_catalog() + for t in cat["tools"]: + if t["id"] == tool_id: + return t + raise HTTPException(status_code=404, detail=f"Tool '{tool_id}' not found") + + +@router.post("/invalidate-cache") +async def invalidate(): + """Force catalog rebuild on next request. Call after adding tools.""" + invalidate_catalog_cache() + return {"status": "ok", "message": "Cache invalidated - next request will rebuild"} + + +# ════════════════════════════════════════════════════════════ +# Convenience: get_total_tool_count (used by other routers) +# ════════════════════════════════════════════════════════════ + + +def get_total_tool_count() -> int: + return _get_full_catalog().get("total_tools", 0) diff --git a/app/_archive/legacy_2026_07/intel_feed_pipeline.py b/app/_archive/legacy_2026_07/intel_feed_pipeline.py new file mode 100644 index 0000000..e71f513 --- /dev/null +++ b/app/_archive/legacy_2026_07/intel_feed_pipeline.py @@ -0,0 +1,608 @@ +#!/usr/bin/env python3 +""" +RMI INTELLIGENCE FEED PIPELINE +============================== +Pulls crypto threat intelligence from RSS feeds and open APIs. +Runs continuously, indexing scam/hack/exploit data into RAG. + +Feeds (verified working): + - Web3IsGoingGreat (Molly White) - incident tracker RSS + - SlowMist - blockchain security firm (Medium) + - PeckShield - on-chain security monitor (via Nitter) + - Blockworks - crypto news + - Cointelegraph Security - tagged articles + +Additional sources: + - Helius webhooks - new Solana tokens + - GoPlus API - real-time token security checks + - On-chain scanning - new token → quick risk assessment + +Architecture: + Prometheus → every N minutes pull RSS → extract entities → embed → store + New token event → quick keyword scan → if suspicious → deep embed → alert +""" + +import hashlib +import html +import json +import logging +import os +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any + +import feedparser +import httpx + +logger = logging.getLogger(__name__) + +# ══════════════════════════════════════════════════════════════════════ +# CONFIGURED FEEDS +# ══════════════════════════════════════════════════════════════════════ + +FEEDS = [ + { + "name": "web3isgoinggreat", + "url": "https://www.web3isgoinggreat.com/feed", + "category": "incident", + "severity": "high", + "extractor": "rss", + "enabled": True, + }, + { + "name": "slowmist", + "url": "https://slowmist.medium.com/feed", + "category": "hack_report", + "severity": "critical", + "extractor": "rss", + "enabled": True, + }, + { + "name": "peckshield", + "url": "https://peckshield.medium.com/feed", + "category": "security_alert", + "severity": "high", + "extractor": "rss", + "enabled": True, + }, + { + "name": "certik", + "url": "https://certik.medium.com/feed", + "category": "audit_report", + "severity": "high", + "extractor": "rss", + "enabled": True, + }, + { + "name": "immunefi", + "url": "https://immunefi.medium.com/feed", + "category": "bounty_report", + "severity": "critical", + "extractor": "rss", + "enabled": True, + }, + { + "name": "trailofbits", + "url": "https://blog.trailofbits.com/feed/", + "category": "security_research", + "severity": "high", + "extractor": "rss", + "enabled": True, + }, + { + "name": "cryptosecurity", + "url": "https://cryptosecurity.substack.com/feed", + "category": "security_news", + "severity": "medium", + "extractor": "rss", + "enabled": True, + }, + { + "name": "blockworks", + "url": "https://blockworks.co/feed", + "category": "crypto_news", + "severity": "medium", + "extractor": "rss", + "enabled": True, + "keywords": ["hack", "exploit", "scam", "rug", "drain", "attack", "breach", "stolen"], + }, + { + "name": "cointelegraph_security", + "url": "https://cointelegraph.com/rss/tag/security", + "category": "security_news", + "severity": "medium", + "extractor": "rss", + "enabled": True, + }, + { + "name": "chainalysis", + "url": "https://blog.chainalysis.com/feed/", + "category": "forensic_report", + "severity": "high", + "extractor": "rss", + "enabled": True, + "keywords": ["scam", "ransomware", "sanction", "illicit", "money laundering"], + }, + { + "name": "cointelegraph", + "url": "https://cointelegraph.com/rss", + "category": "crypto_news", + "severity": "low", + "extractor": "rss", + "enabled": True, + "keywords": [ + "hack", + "exploit", + "scam", + "rug", + "drain", + "attack", + "breach", + "stolen", + "phishing", + "theft", + ], + }, + { + "name": "decrypt", + "url": "https://decrypt.co/feed", + "category": "crypto_news", + "severity": "low", + "extractor": "rss", + "enabled": True, + "keywords": [ + "hack", + "exploit", + "scam", + "rug", + "drain", + "stolen", + "theft", + "honeypot", + "phishing", + ], + }, + { + "name": "theblock", + "url": "https://www.theblock.co/rss.xml", + "category": "crypto_news", + "severity": "low", + "extractor": "rss", + "enabled": True, + "keywords": ["hack", "exploit", "scam", "rug", "drain", "stolen"], + }, + { + "name": "bankless", + "url": "https://www.bankless.com/feed", + "category": "crypto_news", + "severity": "low", + "extractor": "rss", + "enabled": True, + "keywords": ["hack", "exploit", "scam", "rug", "drain", "attack"], + }, + { + "name": "thedefiant", + "url": "https://thedefiant.io/feed", + "category": "defi_news", + "severity": "medium", + "extractor": "rss", + "enabled": True, + "keywords": ["hack", "exploit", "scam", "rug", "drain", "attack", "breach"], + }, +] + +# Entities to extract from articles +ENTITY_PATTERNS = { + "eth_address": r"0x[a-fA-F0-9]{40}", + "sol_address": r"[1-9A-HJ-NP-Za-km-z]{32,44}", + "btc_address": r"[13][a-km-zA-HJ-NP-Z1-9]{25,34}", + "tornado_cash": r"(?i)tornado\s*cash", + "amount_usd": r"\$[\d,]+(?:\s*(?:million|billion|M|B|K))?", + "protocol_name": r"(?<=the\s)(?:Uniswap|Aave|Compound|Curve|Balancer|Sushi|Pancake|Raydium|Orca|Jupiter|Marinade)(?=\s)", +} + + +# ══════════════════════════════════════════════════════════════════════ +# FEED PROCESSORS +# ══════════════════════════════════════════════════════════════════════ + + +@dataclass +class IntelItem: + id: str + title: str + content: str + source: str + url: str + published: str + category: str + severity: str + entities: dict[str, list[str]] = field(default_factory=dict) + raw: dict = field(default_factory=dict) + + +class RSSProcessor: + """Process standard RSS/Atom feeds into IntelItems.""" + + @staticmethod + async def fetch(feed_config: dict) -> list[IntelItem]: + items = [] + try: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.get( + feed_config["url"], + headers={"User-Agent": "RMI-ThreatIntel/1.0 (crypto security research)"}, + ) + resp.raise_for_status() + feed = feedparser.parse(resp.text) + + keywords = feed_config.get("keywords", []) + + for entry in feed.entries[:20]: # Max 20 per feed per run + title = entry.get("title", "") + summary = entry.get("summary", entry.get("description", "")) + content = f"{title}\n\n{html.escape(summary)[:2000]}" + + # Keyword filter + if keywords: + text_lower = (title + " " + summary).lower() + if not any(k.lower() in text_lower for k in keywords): + continue + + # Extract entities + entities = RSSProcessor._extract_entities(content) + + item_id = hashlib.sha256(f"{feed_config['name']}:{entry.get('link', title)}".encode()).hexdigest()[:16] + + items.append( + IntelItem( + id=item_id, + title=title[:300], + content=content[:3000], + source=feed_config["name"], + url=entry.get("link", ""), + published=entry.get("published", entry.get("updated", "")), + category=feed_config["category"], + severity=feed_config["severity"], + entities=entities, + raw={"feed_title": feed.get("feed", {}).get("title", "")}, + ) + ) + + except Exception as e: + logger.warning(f"RSS fetch failed for {feed_config['name']}: {e}") + + return items + + @staticmethod + def _extract_entities(text: str) -> dict[str, list[str]]: + found = {} + for entity_type, pattern in ENTITY_PATTERNS.items(): + matches = re.findall(pattern, text) + if matches: + found[entity_type] = list(set(matches))[:10] + return found + + +class NitterProcessor(RSSProcessor): + """Process Nitter (Twitter mirror) RSS feeds.""" + + @staticmethod + async def fetch(feed_config: dict) -> list[IntelItem]: + items = await RSSProcessor.fetch(feed_config) + # Clean up Nitter-specific formatting + for item in items: + # Extract tweet content from title (format: "user: tweet text") + if ":" in item.title: + parts = item.title.split(":", 1) + item.title = parts[1].strip()[:300] + # Clean HTML entities + item.content = html.unescape(item.content) + return items + + +# ══════════════════════════════════════════════════════════════════════ +# ON-CHAIN SCANNER +# ══════════════════════════════════════════════════════════════════════ + + +class OnChainScanner: + """Scan new tokens on Solana/Ethereum for immediate risk assessment.""" + + @staticmethod + async def scan_new_solana_tokens(limit: int = 20) -> list[IntelItem]: + """Scan recently deployed Solana tokens via Helius/Birdeye.""" + items = [] + try: + # Use Birdeye new listing API or Helius webhook data + async with httpx.AsyncClient(timeout=30) as client: + # Birdeye trending/new tokens + resp = await client.get( + "https://public-api.birdeye.so/defi/tokenlist", + params={ + "sort_by": "v24hChangePercent", + "sort_type": "desc", + "limit": str(limit), + }, + headers={ + "X-API-KEY": os.getenv("BIRDEYE_API_KEY", ""), + "User-Agent": "RMI-Scanner/1.0", + }, + ) + if resp.status_code == 200: + data = resp.json() + tokens = data.get("data", {}).get("tokens", []) + for token in tokens: + addr = token.get("address", "") + name = token.get("name", "") + symbol = token.get("symbol", "") + change = token.get("v24hChangePercent", 0) + + # Quick risk heuristics + risk_signals = [] + if abs(change) > 500: + risk_signals.append("extreme_volatility") + if not name or len(name) < 3: + risk_signals.append("suspicious_name") + if float(token.get("liquidity", 0)) < 5000: + risk_signals.append("low_liquidity") + + if risk_signals: + item_id = hashlib.sha256(f"solana_new:{addr}".encode()).hexdigest()[:16] + items.append( + IntelItem( + id=item_id, + title=f"New token alert: {name} ({symbol})", + content=f"Token {name} ({symbol}) on Solana. Address: {addr}. " + f"24h change: {change}%. Risk signals: {', '.join(risk_signals)}. " + f"Liquidity: ${token.get('liquidity', 0):,.0f}. Volume 24h: ${token.get('v24hUSD', 0):,.0f}.", + source="onchain_scanner", + url=f"https://birdeye.so/token/{addr}?chain=solana", + published=datetime.now(UTC).isoformat(), + category="new_token", + severity="medium" if len(risk_signals) < 2 else "high", + entities={"sol_address": [addr]}, + ) + ) + except Exception as e: + logger.warning(f"Solana scan failed: {e}") + + return items + + @staticmethod + async def check_token_security(address: str, chain: str = "solana") -> dict[str, Any]: + """Check a token via GoPlus security API (free, no key needed).""" + try: + chain_id = {"solana": "solana", "ethereum": "1", "bsc": "56", "base": "8453"}.get(chain, chain) + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + f"https://api.gopluslabs.io/api/v1/token_security/{chain_id}", + params={"contract_addresses": address}, + ) + if resp.status_code == 200: + data = resp.json() + return data.get("result", {}).get(address.lower(), {}) + except Exception: + pass + return {} + + +# ══════════════════════════════════════════════════════════════════════ +# INTELLIGENCE PIPELINE ORCHESTRATOR +# ══════════════════════════════════════════════════════════════════════ + + +class IntelPipeline: + """ + Continuous intelligence ingestion pipeline. + + Usage: + pipeline = IntelPipeline() + stats = await pipeline.run_cycle() + """ + + def __init__(self): + self._last_run: datetime | None = None + self._total_ingested = 0 + self._seen_ids: set[str] = set() + self._feed_processors = { + "rss": RSSProcessor, + "nitter_rss": NitterProcessor, + } + + async def run_cycle(self) -> dict[str, Any]: + """Run one full ingestion cycle across all sources.""" + from app.crypto_embeddings import get_embedder + from app.rag_service import _get_redis + + embedder = await get_embedder() + r = await _get_redis() + now = datetime.now(UTC) + stats = {"feeds": {}, "onchain": {}, "total": 0, "skipped": 0, "errors": 0} + + # Phase 1: RSS feeds + for feed_config in FEEDS: + if not feed_config.get("enabled", True): + continue + + try: + processor_class = self._feed_processors.get(feed_config["extractor"], RSSProcessor) + items = await processor_class.fetch(feed_config) + + ingested = 0 + for item in items: + if item.id in self._seen_ids: + stats["skipped"] += 1 + continue + + try: + # Embed the content + result = await embedder.embed_scam_pattern( + pattern_name=item.title, + description=item.content, + severity=item.severity, + ) + + # Store in Redis + doc = { + "id": item.id, + "collection": "market_intel", + "vector": result.vector, + "dims": result.dims, + "model": result.model, + "metadata": { + "source": item.source, + "url": item.url, + "published": item.published, + "category": item.category, + "severity": item.severity, + "entities": item.entities, + }, + "content": item.content, + "source": item.source, + "severity": item.severity, + "stored_at": now.isoformat(), + } + await r.setex( + f"rag:market_intel:{item.id}", + 86400 * 7, # 7-day TTL for news + json.dumps(doc), + ) + await r.sadd("rag:idx:market_intel", item.id) + self._seen_ids.add(item.id) + ingested += 1 + + except Exception as e: + logger.error(f"Failed to ingest {item.id}: {e}") + + stats["feeds"][feed_config["name"]] = ingested + stats["total"] += ingested + + except Exception as e: + logger.error(f"Feed {feed_config['name']} failed: {e}") + stats["errors"] += 1 + stats["feeds"][feed_config["name"]] = 0 + + # Phase 2: On-chain scanning + try: + new_tokens = await OnChainScanner.scan_new_solana_tokens(limit=10) + ingested = 0 + for item in new_tokens: + if item.id in self._seen_ids: + continue + try: + result = await embedder.embed_scam_pattern( + pattern_name=item.title, + description=item.content, + severity=item.severity, + ) + doc = { + "id": item.id, + "collection": "token_analysis", + "vector": result.vector, + "dims": result.dims, + "metadata": { + "source": item.source, + "category": item.category, + "severity": item.severity, + "entities": item.entities, + }, + "content": item.content, + "source": item.source, + "severity": item.severity, + "stored_at": now.isoformat(), + } + await r.setex(f"rag:token_analysis:{item.id}", 86400 * 7, json.dumps(doc)) + await r.sadd("rag:idx:token_analysis", item.id) + self._seen_ids.add(item.id) + ingested += 1 + except Exception as e: + logger.error(f"Failed to ingest onchain {item.id}: {e}") + + stats["onchain"]["new_tokens"] = ingested + stats["total"] += ingested + except Exception as e: + logger.warning(f"On-chain scan failed: {e}") + + self._last_run = now + self._total_ingested += stats["total"] + + # Cleanup old entries (older than 7 days) + try: + old_cutoff = (now - timedelta(days=7)).isoformat() + for coll in ["market_intel", "token_analysis"]: + all_ids = await r.smembers(f"rag:idx:{coll}") + for did in all_ids: + doc_raw = await r.get(f"rag:{coll}:{did}") + if doc_raw: + try: + doc = json.loads(doc_raw) + if doc.get("stored_at", "") < old_cutoff: + await r.delete(f"rag:{coll}:{did}") + await r.srem(f"rag:idx:{coll}", did) + except Exception: + pass + except Exception: + pass + + return { + "status": "completed", + "total_ingested": stats["total"], + "cumulative": self._total_ingested, + "feeds": stats["feeds"], + "onchain": stats["onchain"], + "skipped": stats["skipped"], + "errors": stats["errors"], + "seen_cache_size": len(self._seen_ids), + "timestamp": now.isoformat(), + "next_run_in": "30 minutes", + } + + async def get_latest_intel(self, limit: int = 20) -> list[dict[str, Any]]: + """Get latest threat intelligence for frontend display.""" + from app.rag_service import _get_redis + + r = await _get_redis() + + results = [] + for coll in ["market_intel", "token_analysis", "known_scams"]: + doc_ids = await r.smembers(f"rag:idx:{coll}") + for did in list(doc_ids)[:limit]: + raw = await r.get(f"rag:{coll}:{did}") + if raw: + try: + doc = json.loads(raw) + results.append( + { + "id": doc["id"], + "title": doc.get("metadata", {}).get("name", doc.get("content", "")[:100]), + "content": doc.get("content", "")[:500], + "source": doc.get("source", coll), + "severity": doc.get("severity", "medium"), + "category": doc.get("metadata", {}).get("category", ""), + "url": doc.get("metadata", {}).get("url", ""), + "published": doc.get("metadata", {}).get("published", ""), + "entities": doc.get("metadata", {}).get("entities", {}), + "stored_at": doc.get("stored_at", ""), + } + ) + except Exception: + pass + + results.sort(key=lambda x: x.get("stored_at", ""), reverse=True) + return results[:limit] + + +# ══════════════════════════════════════════════════════════════════════ +# SINGLETON +# ══════════════════════════════════════════════════════════════════════ + +_pipeline: IntelPipeline | None = None + + +async def get_pipeline() -> IntelPipeline: + global _pipeline + if _pipeline is None: + _pipeline = IntelPipeline() + return _pipeline diff --git a/app/_archive/legacy_2026_07/intel_pipeline.py b/app/_archive/legacy_2026_07/intel_pipeline.py new file mode 100644 index 0000000..ff2c30f --- /dev/null +++ b/app/_archive/legacy_2026_07/intel_pipeline.py @@ -0,0 +1,298 @@ +""" +RMI Intelligence Pipeline - HF + Supabase + RAG +================================================ +Unified intelligence service: scam classification (HF models), +wallet labeling via RAG pattern matching, Supabase hybrid storage. +""" + +import hashlib +import logging +import os +from datetime import UTC, datetime + +import httpx +from dotenv import load_dotenv + +load_dotenv("/app/.env", override=True) + +logger = logging.getLogger(__name__) + +HF_TOKEN = os.getenv("HF_TOKEN", "") +HF_API = "https://api-inference.huggingface.co/models" + + +def _get_url(): + return os.getenv("SUPABASE_URL", "") + + +def _get_key(): + return os.getenv("SUPABASE_SERVICE_KEY", "") + + +def _get_headers(): + key = _get_key() + return { + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + + +# ── HF Models (Paywalled - using local fallback) ────── +# HF Inference API now requires PRO subscription ($9/mo). +# Using local pattern matching + RAG memory instead. +# Enable HF by setting HUGGINGFACE_TOKEN to a valid PRO key. + +SCAM_LABELS = ["scam", "rugpull", "honeypot", "legitimate", "phishing", "ponzi"] + +SCAM_KEYWORDS = { + "scam": ["scam", "fraud", "stole", "stolen", "exit scam", "fake"], + "rugpull": ["rug", "rugpull", "pulled liquidity", "drained", "removed liquidity", "lp removed"], + "honeypot": [ + "honeypot", + "cannot sell", + "can't sell", + "unable to sell", + "transfer disabled", + "sell tax 100", + ], + "phishing": [ + "phishing", + "airdrop scam", + "claim reward", + "verify wallet", + "seed phrase", + "private key", + ], + "ponzi": [ + "ponzi", + "mlm", + "multi level", + "referral rewards", + "guaranteed returns", + "double your", + ], + "insider": ["insider", "team wallet", "dev wallet", "pre-sale", "unlocked tokens", "vesting"], +} + + +async def classify_scam_risk(text: str) -> dict: + """Classify scam risk using local pattern matching (HF paywalled).""" + text_lower = text.lower() + scores = {} + + for label, keywords in SCAM_KEYWORDS.items(): + score = sum(1 for kw in keywords if kw in text_lower) + if score > 0: + scores[label] = min(score / len(keywords), 1.0) + + try: + from app.rag_service import search_documents + + rag_results = await search_documents("known_scams", text, limit=3) + for r in rag_results: + content = r.get("content", "").lower() + for label, keywords in SCAM_KEYWORDS.items(): + if any(kw in content for kw in keywords): + scores[label] = scores.get(label, 0) + 0.1 + except Exception: + pass + + if not scores: + return { + "risk": "low", + "labels": {}, + "confidence": 0, + "is_scam": False, + "risk_level": "low", + "model": "local_pattern_match", + } + + top = max(scores, key=scores.get) + top_score = scores[top] + is_scam = top in ("scam", "rugpull", "honeypot", "phishing", "ponzi") + + return { + "model": "local_pattern_match", + "labels": {k: round(v, 3) for k, v in sorted(scores.items(), key=lambda x: x[1], reverse=True)}, + "top_label": top, + "confidence": round(top_score, 3), + "is_scam": is_scam, + "risk_level": "high" if (is_scam and top_score > 0.5) else "medium" if is_scam else "low", + } + + +async def generate_embedding(text: str) -> list[float] | None: + """Generate embedding vector for RAG storage using HF free tier.""" + if not HF_TOKEN: + return None + + async with httpx.AsyncClient(timeout=60) as client: + r = await client.post( + f"{HF_API}/{EMBEDDING_MODEL}", # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + headers={"Authorization": f"Bearer {HF_TOKEN}"}, + json={"inputs": text[:1024]}, + ) + if r.status_code == 503: + logger.warning("HF embedding cold start, model loading") + return None + if r.status_code == 200: + result = r.json() + # Handle different response formats + if isinstance(result, list) and len(result) > 0: + return result[0] if isinstance(result[0], list) else result + return result + logger.warning(f"HF embedding failed: {r.status_code}") + return None + + +# ── Wallet Labeling via Pattern Memory ────────────────── + +WALLET_PATTERNS = { + "sybil_farmer": [ + "funded from exchange within seconds of 100+ other wallets", + "identical funding amounts across multiple wallets", + "no organic activity, only test transactions", + "funded by known Sybil distributor", + ], + "wash_trader": [ + "circular transfers between related wallets", + "buys and sells same token within minutes", + "volume spikes without holder count changes", + "coordinated buy/sell patterns", + ], + "sandwich_bot": [ + "high frequency trading with frontrun pattern", + "buys before large buys, sells immediately after", + "MEV extraction patterns", + "flashbots bundle usage", + ], + "liquidity_remover": [ + "large LP removal shortly after token launch", + "multiple LP positions removed simultaneously", + "liquidity drained to fresh wallet", + "LP removal preceded by marketing push", + ], + "honeypot_deployer": [ + "deploys tokens that can't be sold", + "reuses contract code across multiple tokens", + "disables transfers after liquidity added", + "ownership not renounced, hidden mint functions", + ], + "phishing_operator": [ + "sends tokens to many addresses with scam links", + "impersonates legitimate token contracts", + "uses airdrop as phishing vector", + "connects to known phishing domains", + ], + "mixer_user": [ + "funds pass through Tornado Cash or similar", + "receives from mixer, sends to clean wallet", + "layered mixing through multiple hops", + "funds originate from high-risk sources", + ], + "insider_trader": [ + "buys tokens before public announcements", + "linked to team wallets or deployers", + "sells immediately after hype peak", + "coordinated timing with other insiders", + ], +} + + +async def label_wallet(wallet_data: dict) -> dict: + """Label a wallet based on behavioral patterns and RAG memory.""" + labels = [] + confidence_scores = {} + + # Check behavioral patterns + behavior = wallet_data.get("behavior_summary", "") + wallet_data.get("transactions", []) + + for label, patterns in WALLET_PATTERNS.items(): + score = 0 + for pattern in patterns: + if pattern.lower() in behavior.lower(): + score += 1 + if score > 0: + confidence = min(score / len(patterns), 1.0) + confidence_scores[label] = round(confidence, 2) + if confidence > 0.3: + labels.append({"label": label, "confidence": round(confidence, 2)}) + + # Query RAG for similar wallet patterns + try: + from app.rag_service import search_documents + + rag_results = await search_documents( + "wallet_profiles", wallet_data.get("address", "") + " " + behavior, limit=5 + ) + if rag_results: + for r in rag_results: + content = r.get("content", "") + for label, patterns in WALLET_PATTERNS.items(): + if any(p.lower() in content.lower() for p in patterns): + if label not in confidence_scores: + confidence_scores[label] = 0 + confidence_scores[label] = min(confidence_scores[label] + 0.15, 1.0) + except Exception: + pass + + labels = [ + {"label": k, "confidence": v} + for k, v in sorted(confidence_scores.items(), key=lambda x: x[1], reverse=True) + if v > 0.2 + ] + + return { + "wallet_address": wallet_data.get("address", "unknown"), + "labels": labels[:5], + "primary_label": labels[0]["label"] if labels else "unknown", + "risk_score": max([line["confidence"] for line in labels]) * 100 if labels else 0, + "analyzed_at": datetime.now(UTC).isoformat(), + } + + +# ── Supabase Hybrid Storage ──────────────────────────── + + +async def sync_to_supabase(collection: str, document: dict) -> dict: + """Sync RAG document to Supabase for persistent hybrid storage.""" + if not _get_url() or not _get_key(): + return {"status": "skipped", "reason": "No Supabase config"} + + doc_id = hashlib.sha256(f"{collection}:{document.get('content', '')[:100]}".encode()).hexdigest()[:16] + + payload = { + "document_id": doc_id, + "collection": collection, + "content": document.get("content", "")[:5000], + "metadata": document.get("metadata", {}), + "synced_at": datetime.now(UTC).isoformat(), + } + + headers = _get_headers() + headers["Prefer"] = "resolution=merge-duplicates" + + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post(f"{_get_url()}/rest/v1/rag_documents", json=payload, headers=headers) + if r.status_code in (200, 201, 409): + return {"status": "synced", "doc_id": doc_id, "supabase_status": r.status_code} + return {"status": "failed", "error": r.text[:200]} + + +# ── Batch Processing ─────────────────────────────────── + + +async def run_intelligence_cycle() -> dict: + """Run a full intelligence cycle: classify, label, embed, sync.""" + results = { + "cycle": datetime.now(UTC).isoformat(), + "scam_checks": 0, + "wallet_labels": 0, + "embeddings": 0, + "supabase_syncs": 0, + "errors": [], + } + + return results diff --git a/app/_archive/legacy_2026_07/key_loader.py b/app/_archive/legacy_2026_07/key_loader.py new file mode 100644 index 0000000..089836f --- /dev/null +++ b/app/_archive/legacy_2026_07/key_loader.py @@ -0,0 +1,64 @@ +""" +Universal API key loader with cascading fallbacks. +Resolves Docker env-loading issues by checking multiple sources. +Pattern: env var → /tmp/{name}_key.txt → /root/.secrets/sentinel_enrichment_keys.env → None +""" + +import os + + +def load_key(name: str) -> str: + """Load an API key with multiple fallback sources. + + Checks in order: + 1. Environment variable (os.getenv) + 2. File-based fallback at /tmp/{name}_key.txt (Docker workaround) + 3. Secrets file at /root/.secrets/sentinel_enrichment_keys.env + + Returns empty string if not found. + """ + # 1. Environment variable + key = os.getenv(name, "") + if key: + return key + + # 2. File-based fallback (works around Docker env sanitization) + file_path = f"/tmp/{name.lower().replace('_api_key', '')}_key.txt" + try: + if os.path.exists(file_path): + with open(file_path) as f: + key = f.read().strip() + if key: + return key + except Exception: + pass + + # 3. Central secrets file + secrets_path = "/root/.secrets/sentinel_enrichment_keys.env" + try: + if os.path.exists(secrets_path): + with open(secrets_path) as f: + for line in f: + if line.startswith(f"{name}="): + key = line.split("=", 1)[1].split("#")[0].strip() + if key: + return key + except Exception: + pass + + return "" + + +# Enrichment key service name → env var mapping +SERVICE_KEYS = { + "nansen": "NANSEN_API_KEY", + "chainaware": "CHAIN_AWARE_API_KEY", + "dune": "DUNE_API_KEY", + "santiment": "SANTIMENT_API_KEY", + "thegraph": "THEGRAPH_API_KEY", + "defi": "DEFI_API_KEY", + "webacy": "WEBACY_API_KEY", + "lunarcrush": "LUNARCRUSH_API_KEY", + "arkham": "ARKHAM_API_KEY", + "blowfish": "BLOWFISH_API_KEY", +} diff --git a/app/_archive/legacy_2026_07/liquidity_watchdog.py b/app/_archive/legacy_2026_07/liquidity_watchdog.py new file mode 100644 index 0000000..ee12f5b --- /dev/null +++ b/app/_archive/legacy_2026_07/liquidity_watchdog.py @@ -0,0 +1,176 @@ +""" +Liquidity Watchdog Router +========================= +FastAPI endpoints for monitoring liquidity lock expiries. + +Endpoints: + GET /api/v1/alerts/liquidity-expiring → Upcoming lock expiries within 24h + POST /api/v1/alerts/liquidity-track → Register a token for monitoring +""" + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from app.liquidity_watchdog import ( + check_all_locks_summary, + check_expiring_locks, + get_tracked_tokens, + track_token, + untrack_token, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/alerts", tags=["liquidity-watchdog"]) + + +# ── Request/Response models ────────────────────────────────────── + + +class LiquidityTrackRequest(BaseModel): + token_address: str = Field(..., description="Token contract/mint address") + chain: str = Field(..., description="Blockchain name (e.g. solana, ethereum, bsc)") + lock_expiry_timestamp: int = Field(..., description="Unix timestamp (seconds) when lock expires") + liquidity_amount: float = Field(0.0, description="Locked liquidity amount in USD") + + +class LiquidityTrackResponse(BaseModel): + status: str = "ok" + message: str = "Token registered for liquidity expiry monitoring" + data: dict[str, Any] + + +class ExpiringLock(BaseModel): + token: str + chain: str + lock_expiry: int + hours_remaining: float + liquidity_amount: float + + +class LiquidityExpiringResponse(BaseModel): + status: str = "ok" + count: int = 0 + expiring: list[ExpiringLock] = [] + + +class LiquiditySummaryResponse(BaseModel): + status: str = "ok" + total_tracked: int = 0 + already_expired: int = 0 + expiring_soon: int = 0 + safe: int = 0 + expiring_tokens: list[ExpiringLock] = [] + + +# ── Endpoints ──────────────────────────────────────────────────── + + +@router.post("/liquidity-track", response_model=LiquidityTrackResponse) +async def post_liquidity_track(req: LiquidityTrackRequest): + """Register a token for liquidity lock expiry monitoring. + + This will be called by a cron job that pings Telegram/community channels + when locks are about to expire. + + The token's lock expiry is checked against the current time to determine + if it's already expired (returns a warning but still registers it). + """ + if not req.token_address or not req.token_address.strip(): + raise HTTPException(status_code=400, detail="token_address is required") + if not req.chain or not req.chain.strip(): + raise HTTPException(status_code=400, detail="chain is required") + if req.lock_expiry_timestamp <= 0: + raise HTTPException( + status_code=400, + detail="lock_expiry_timestamp must be a positive unix timestamp", + ) + + data = track_token( + token_address=req.token_address, + chain=req.chain, + lock_expiry_timestamp=req.lock_expiry_timestamp, + liquidity_amount=req.liquidity_amount or 0.0, + ) + + return LiquidityTrackResponse( + status="ok", + message="Token registered for liquidity expiry monitoring", + data=data, + ) + + +@router.get("/liquidity-expiring", response_model=LiquidityExpiringResponse) +async def get_liquidity_expiring( + hours: float | None = Query( + None, + description="Override the expiry window in hours (default: 24). " + "Only returns locks expiring within this many hours.", + ), +): + """Get all tracked tokens with liquidity locks expiring within the default + 24-hour window (or a custom window via the `hours` query parameter). + + Returns tokens sorted by urgency (most imminent first). + """ + if hours is not None and hours <= 0: + raise HTTPException(status_code=400, detail="hours must be > 0") + + # If a custom hours window is given, we temporarily override the module's + # default and re-check. Since check_expiring_locks() uses a module constant, + # we just call it once and filter further if needed. + expiring = check_expiring_locks() + + if hours is not None: + expiring = [e for e in expiring if e["hours_remaining"] <= hours] + + expiring_models = [ExpiringLock(**e) for e in expiring] + + return LiquidityExpiringResponse( + status="ok", + count=len(expiring_models), + expiring=expiring_models, + ) + + +@router.get("/liquidity-tracked", response_model=dict) +async def get_liquidity_tracked(): + """List all tokens currently being monitored for liquidity expiry.""" + tokens = get_tracked_tokens() + return { + "status": "ok", + "count": len(tokens), + "tokens": tokens, + } + + +@router.get("/liquidity-summary", response_model=LiquiditySummaryResponse) +async def get_liquidity_summary(): + """Get a summary of all tracked liquidity locks with counts.""" + summary = check_all_locks_summary() + return LiquiditySummaryResponse( + status="ok", + total_tracked=summary["total_tracked"], + already_expired=summary["already_expired"], + expiring_soon=summary["expiring_soon"], + safe=summary["safe"], + expiring_tokens=[ExpiringLock(**e) for e in summary["expiring_tokens"]], + ) + + +@router.delete("/liquidity-track/{chain}/{token_address}", response_model=dict) +async def delete_liquidity_track(chain: str, token_address: str): + """Remove a token from liquidity expiry monitoring.""" + removed = untrack_token(token_address, chain) + if not removed: + raise HTTPException( + status_code=404, + detail=f"Token {token_address} on {chain} is not being tracked", + ) + return { + "status": "ok", + "message": f"Token {token_address} on {chain} removed from monitoring", + } diff --git a/app/_archive/legacy_2026_07/mail_dashboard.py b/app/_archive/legacy_2026_07/mail_dashboard.py new file mode 100644 index 0000000..8ad4577 --- /dev/null +++ b/app/_archive/legacy_2026_07/mail_dashboard.py @@ -0,0 +1,228 @@ +""" +Email Dashboard - Full email management for rugmunch.io + cryptorugmunch.com +Access cryptorugmuncher@gmail.com via backend (no password needed). +""" + +import email as emaillib +import imaplib +import logging +import os +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/v1/mail", tags=["mail"]) + +# ═══════════════════════════════════════════════════════════ +# EMAIL ADDRESSES +# ═══════════════════════════════════════════════════════════ +EMAILS = { + "rugmunch.io": [ + { + "address": "team@rugmunch.io", + "purpose": "General inquiries", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "alerts@rugmunch.io", + "purpose": "Security alerts & notifications", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "intel@rugmunch.io", + "purpose": "Intelligence reports", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "support@rugmunch.io", + "purpose": "Customer support", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "admin@rugmunch.io", + "purpose": "Admin/backend", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "newsletter@rugmunch.io", + "purpose": "Weekly/daily newsletter dispatches", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "biz@rugmunch.io", + "purpose": "Business relationships & partnerships", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "bot@rugmunch.io", + "purpose": "Bot/automation account (X/Telegram/backend alerts)", + "forward_to": "admin@rugmunch.io", + }, + ], + "cryptorugmunch.com": [ + { + "address": "team@cryptorugmunch.com", + "purpose": "Main contact", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "alerts@cryptorugmunch.com", + "purpose": "Alert system", + "forward_to": "cryptorugmuncher@gmail.com", + }, + { + "address": "intel@cryptorugmunch.com", + "purpose": "Intel feed", + "forward_to": "cryptorugmuncher@gmail.com", + }, + ], +} + + +@router.get("/addresses") +async def list_addresses(): + """All email addresses across domains.""" + return { + "domains": list(EMAILS.keys()), + "addresses": EMAILS, + "total": sum(len(v) for v in EMAILS.values()), + "primary_inbox": "cryptorugmuncher@gmail.com", + "provider": "gmail_smtp + listmonk_newsletter", + } + + +# ═══════════════════════════════════════════════════════════ +# GMAIL INBOX - Read without password +# ═══════════════════════════════════════════════════════════ +@router.get("/inbox") +async def check_inbox(limit: int = 10): + """Check cryptorugmuncher@gmail.com inbox (requires app password in env).""" + app_password = os.getenv("GMAIL_APP_PASSWORD", "") + if not app_password: + return { + "emails": [], + "error": "GMAIL_APP_PASSWORD not set. Get one at https://myaccount.google.com/apppasswords", + "account": "cryptorugmuncher@gmail.com", + } + + try: + mail = imaplib.IMAP4_SSL("imap.gmail.com", 993) + mail.login("cryptorugmuncher@gmail.com", app_password) + mail.select("INBOX") + + status, messages = mail.search(None, "ALL") # noqa: RUF059 + email_ids = messages[0].split()[-limit:] if messages[0] else [] + + emails = [] + for eid in reversed(email_ids): + _status, msg_data = mail.fetch(eid, "(RFC822)") + if msg_data and msg_data[0]: + raw = emaillib.message_from_bytes(msg_data[0][1]) + emails.append( + { + "id": eid.decode(), + "from": raw.get("From", ""), + "subject": raw.get("Subject", ""), + "date": raw.get("Date", ""), + "snippet": _get_body(raw)[:200], + } + ) + + mail.close() + mail.logout() + + return { + "account": "cryptorugmuncher@gmail.com", + "total_inbox": len(email_ids), + "showing": len(emails), + "emails": emails, + "checked_at": datetime.now(UTC).isoformat(), + } + except Exception as e: + return {"error": str(e), "account": "cryptorugmuncher@gmail.com"} + + +def _get_body(msg) -> str: + """Extract text body from email.""" + try: + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if payload: + return payload.decode(errors="ignore") + payload = msg.get_payload(decode=True) + if payload: + return payload.decode(errors="ignore") + except Exception: + pass + return "(no text content)" + + +# ═══════════════════════════════════════════════════════════ +# SEND - From any rugmunch.io address +# ═══════════════════════════════════════════════════════════ +@router.post("/send") +async def send_mail(data: dict): + """Send email from any rugmunch.io/cryptorugmunch.com address via Gmail SMTP.""" + to = data.get("to", "") + subject = data.get("subject", "") + body = data.get("body", "") + from_addr = data.get("from", "team@rugmunch.io") + + if not to or not body: + raise HTTPException(status_code=400, detail="to and body required") + + app_password = os.getenv("GMAIL_APP_PASSWORD", "") + if not app_password: + return {"status": "failed", "error": "GMAIL_APP_PASSWORD not set"} + + try: + import smtplib + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + + msg = MIMEMultipart() + msg["From"] = f"Rug Munch Intelligence <{from_addr}>" + msg["To"] = to + msg["Subject"] = subject + msg["Reply-To"] = "team@rugmunch.io" + msg.attach(MIMEText(body, "html")) + + with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as server: + server.starttls() + server.login("cryptorugmuncher@gmail.com", app_password) + server.send_message(msg) + + return { + "status": "sent", + "from": from_addr, + "to": to, + "subject": subject, + "timestamp": datetime.now(UTC).isoformat(), + } + except Exception as e: + return {"status": "failed", "error": str(e)} + + +# ═══════════════════════════════════════════════════════════ +# DOMAINS SETUP +# ═══════════════════════════════════════════════════════════ +@router.get("/domains") +async def mail_domains(): + return { + "domains": [ + {"domain": "rugmunch.io", "status": "active", "mx": "smtp.gmail.com", "emails": 5}, + { + "domain": "cryptorugmunch.com", + "status": "active", + "mx": "smtp.gmail.com", + "emails": 3, + }, + ], + "smtp_provider": "gmail", + "smtp_account": "cryptorugmuncher@gmail.com", + "newsletter": "listmonk (localhost:9001)", + "setup_note": "Add GMAIL_APP_PASSWORD to enable full email functionality", + } diff --git a/app/_archive/legacy_2026_07/marketing_templates.py b/app/_archive/legacy_2026_07/marketing_templates.py new file mode 100644 index 0000000..efb8b4c --- /dev/null +++ b/app/_archive/legacy_2026_07/marketing_templates.py @@ -0,0 +1,380 @@ +""" +Marketing Content Templates - Auto-generated posts for X, Telegram, Discord. +Professional copy for wins, losses, KOL scorecards, security alerts. +""" + +# ── X/Twitter Post Templates ────────────────────────────────── + +X_TEMPLATES = { + "big_win": """🎉 BIG WIN ALERT! 🎉 + +Wallet: {wallet_short} +Token: {token_symbol} +Profit: +${pnl_usd:,.0f} ({pnl_pct:.1f}%) + +This whale called it early and rode it all the way up! 🐋 + +Track smart money: rugmunch.io/wallet/{wallet_address} + +#Crypto #MemeCoin #SmartMoney #Trading + +@cryptorugmunch +""", + "big_loss": """💀 LOSS PORN 💀 + +Wallet: {wallet_short} +Token: {token_symbol} +Loss: -${pnl_usd:,.0f} ({pnl_pct:.1f}%) + +Oof. Another reminder to take profits, friends. 📉 + +Learn from their mistakes: rugmunch.io/wallet/{wallet_address} + +#Crypto #Trading #LossPorn #DeFi + +@cryptorugmunch +""", + "kol_scorecard": """📊 KOL SCORECARD: @{kol_handle} + +Win Rate: {win_rate:.1f}% +Total Calls: {total_calls} +Avg PnL: {avg_pnl:.1f}% +Followers: {follower_count:,} + +Tier: {tier} + +Track their calls: rugmunch.io/kol/{kol_handle} + +#CryptoTwitter #KOL #Alpha #Trading + +@cryptorugmunch +""", + "rugpull": """🚨 RUG PULL DETECTED 🚨 + +Token: {token_symbol} +Stolen: ${amount_stolen:,.0f} +Victims: {victims} +Type: {rug_type} + +Deployer: {deployer_short} + +⚠️ STAY AWAY FROM THIS TOKEN + +Full analysis: rugmunch.io/rug/{token_address} + +#RugPull #CryptoScam #DeFi #StaySafe + +@cryptorugmunch +""", + "whale_move": """🐋 WHALE ALERT 🐋 + +{wallet_label} +{action} {amount:,.0f} {symbol} +Value: ${usd_value:,.0f} + +Destination: {destination} + +Track this whale: rugmunch.io/whale/{wallet_address} + +#WhaleAlert #Crypto #SmartMoney + +@cryptorugmunch +""", + "smart_money": """🧠 SMART MONEY PICK 🧠 + +{wallet_label} +Win Rate: {win_rate:.1f}% + +Just bought: {token_symbol} +Position: ${position_size:,.0f} +Entry: ${entry_price} +Current PnL: +{current_pnl:.1f}% + +Follow their moves: rugmunch.io/wallet/{wallet_address} + +#SmartMoney #Crypto #Alpha #Trading + +@cryptorugmunch +""", + "hack_alert": """🚨 HACK ALERT 🚨 + +Protocol: {protocol_name} +Amount: ${amount_usd:,.0f} +Chain: {chain} +Type: {attack_type} + +{description} + +Stay safe out there! 🔒 + +#CryptoSecurity #DeFi #HackAlert + +@cryptorugmunch +""", + "launch_announcement": """🚀 RMI INTELLIGENCE PLATFORM IS LIVE + +Track smart money. Avoid rugs. Find alpha. + +✅ Real-time whale tracking +✅ KOL scorecards +✅ Rugpull detection +✅ Meme intelligence + +Join now: rugmunch.io + +#Crypto #DeFi #Trading #Alpha + +@cryptorugmunch +""", +} + +# ── Telegram Post Templates ─────────────────────────────────── + +TELEGRAM_TEMPLATES = { + "big_win": """🎉 *BIG WIN ALERT!* 🎉 + +*Wallet:* `{wallet_short}` +*Token:* {token_symbol} +*Profit:* +${pnl_usd:,.0f} (*{pnl_pct:.1f}%*) + +This whale called it early and rode it all the way up! 🐋 + +📊 *Track smart money:* rugmunch.io/wallet/{wallet_address} + +#Crypto #MemeCoin #SmartMoney #Trading +""", + "big_loss": """💀 *LOSS PORN* 💀 + +*Wallet:* `{wallet_short}` +*Token:* {token_symbol} +*Loss:* -${pnl_usd:,.0f} (*{pnl_pct:.1f}%) + +Oof. Another reminder to take profits, friends. 📉 + +📚 *Learn from their mistakes:* rugmunch.io/wallet/{wallet_address} + +#Crypto #Trading #LossPorn #DeFi +""", + "kol_scorecard": """📊 *KOL SCORECARD: @{kol_handle}* + +*Win Rate:* {win_rate:.1f}% +*Total Calls:* {total_calls} +*Avg PnL:* {avg_pnl:.1f}% +*Followers:* {follower_count:,} + +*Tier:* {tier} + +📈 *Track their calls:* rugmunch.io/kol/{kol_handle} + +#CryptoTwitter #KOL #Alpha #Trading +""", + "rugpull": """🚨 *RUG PULL DETECTED* 🚨 + +*Token:* {token_symbol} +*Stolen:* ${amount_stolen:,.0f} +*Victims:* {victims} +*Type:* {rug_type} + +*Deployer:* `{deployer_short}` + +⚠️ *STAY AWAY FROM THIS TOKEN* + +🔍 *Full analysis:* rugmunch.io/rug/{token_address} + +#RugPull #CryptoScam #DeFi #StaySafe +""", + "whale_move": """🐋 *WHALE ALERT* 🐋 + +*{wallet_label}* +*{action}* {amount:,.0f} {symbol} +*Value:* ${usd_value:,.0f} + +*Destination:* {destination} + +📊 *Track this whale:* rugmunch.io/whale/{wallet_address} + +#WhaleAlert #Crypto #SmartMoney +""", + "smart_money": """🧠 *SMART MONEY PICK* 🧠 + +*{wallet_label}* +*Win Rate:* {win_rate:.1f}% + +Just bought: *{token_symbol}* +*Position:* ${position_size:,.0f} +*Entry:* ${entry_price} +*Current PnL:* +{current_pnl:.1f}% + +📈 *Follow their moves:* rugmunch.io/wallet/{wallet_address} + +#SmartMoney #Crypto #Alpha #Trading +""", + "daily_roundup": """📊 *DAILY INTELLIGENCE ROUNDUP* + +📅 {date} + +🎯 *Top Wins:* +{top_wins} + +💀 *Top Losses:* +{top_losses} + +🐋 *Notable Whale Moves:* +{whale_moves} + +🚨 *Security Alerts:* +{security_alerts} + +Full report: rugmunch.io/daily/{date} + +#Crypto #DailyRoundup #Intelligence +""", + "weekly_kol_rankings": """📊 *WEEKLY KOL RANKINGS* + +Week {week_number} + +🥇 *#1:* @{kol_1} - {win_rate_1:.1f}% win rate +🥈 *#2:* @{kol_2} - {win_rate_2:.1f}% win rate +🥉 *#3:* @{kol_3} - {win_rate_3:.1f}% win rate + +See full leaderboard: rugmunch.io/kol/leaderboard + +#KOL #CryptoTwitter #Rankings +""", +} + +# ── Discord Post Templates ──────────────────────────────────── + +DISCORD_TEMPLATES = { + "big_win": """ +🎉 **BIG WIN ALERT!** 🎉 + +**Wallet:** `{wallet_short}` +**Token:** {token_symbol} +**Profit:** +${pnl_usd:,.0f} (**{pnl_pct:.1f}%**) + +This whale called it early and rode it all the way up! 🐋 + +📊 **Track smart money:** https://rugmunch.io/wallet/{wallet_address} +""", + "big_loss": """ +💀 **LOSS PORN** 💀 + +**Wallet:** `{wallet_short}` +**Token:** {token_symbol} +**Loss:** -${pnl_usd:,.0f} (**{pnl_pct:.1f}%**) + +Oof. Another reminder to take profits, friends. 📉 + +📚 **Learn from their mistakes:** https://rugmunch.io/wallet/{wallet_address} +""", + "kol_scorecard": """ +📊 **KOL SCORECARD: @{kol_handle}** + +**Win Rate:** {win_rate:.1f}% +**Total Calls:** {total_calls} +**Avg PnL:** {avg_pnl:.1f}% +**Followers:** {follower_count:,} + +**Tier:** {tier} + +📈 **Track their calls:** https://rugmunch.io/kol/{kol_handle} +""", + "rugpull": """ +🚨 **RUG PULL DETECTED** 🚨 + +**Token:** {token_symbol} +**Stolen:** ${amount_stolen:,.0f} +**Victims:** {victims} +**Type:** {rug_type} + +**Deployer:** `{deployer_short}` + +⚠️ **STAY AWAY FROM THIS TOKEN** + +🔍 **Full analysis:** https://rugmunch.io/rug/{token_address} + +@everyone Stay safe! +""", + "announcement": """ +📢 **RMI ANNOUNCEMENT** + +{message} + +🔗 **Learn more:** https://rugmunch.io + +{reactions} +""", +} + +# ── Content Generation Functions ────────────────────────────── + + +def format_wallet_short(address: str) -> str: + """Format wallet address for display.""" + if len(address) >= 14: + return f"{address[:8]}...{address[-6:]}" + return address + + +def generate_x_post(template_name: str, data: dict) -> str: + """Generate X/Twitter post from template.""" + template = X_TEMPLATES.get(template_name, "") + + # Add wallet_short if wallet_address provided + if "wallet_address" in data and "wallet_short" not in data: + data["wallet_short"] = format_wallet_short(data["wallet_address"]) + if "deployer_address" in data and "deployer_short" not in data: + data["deployer_short"] = format_wallet_short(data["deployer_address"]) + + # Format numbers + for key, value in data.items(): + if isinstance(value, float): + data[key] = round(value, 2) + + return template.format(**data) + + +def generate_telegram_post(template_name: str, data: dict) -> str: + """Generate Telegram post from template (Markdown).""" + template = TELEGRAM_TEMPLATES.get(template_name, "") + + # Add wallet_short if wallet_address provided + if "wallet_address" in data and "wallet_short" not in data: + data["wallet_short"] = format_wallet_short(data["wallet_address"]) + if "deployer_address" in data and "deployer_short" not in data: + data["deployer_short"] = format_wallet_short(data["deployer_address"]) + + # Format numbers + for key, value in data.items(): + if isinstance(value, float): + data[key] = round(value, 2) + + return template.format(**data) + + +def generate_discord_post(template_name: str, data: dict) -> str: + """Generate Discord post from template.""" + template = DISCORD_TEMPLATES.get(template_name, "") + + # Add wallet_short if wallet_address provided + if "wallet_address" in data and "wallet_short" not in data: + data["wallet_short"] = format_wallet_short(data["wallet_address"]) + if "deployer_address" in data and "deployer_short" not in data: + data["deployer_short"] = format_wallet_short(data["deployer_address"]) + + # Format numbers + for key, value in data.items(): + if isinstance(value, float): + data[key] = round(value, 2) + + return template.format(**data) + + +def generate_all_posts(template_name: str, data: dict) -> dict: + """Generate posts for all platforms.""" + return { + "x": generate_x_post(template_name, data), + "telegram": generate_telegram_post(template_name, data), + "discord": generate_discord_post(template_name, data), + } diff --git a/app/_archive/legacy_2026_07/mbal_market.py b/app/_archive/legacy_2026_07/mbal_market.py new file mode 100644 index 0000000..5c0c378 --- /dev/null +++ b/app/_archive/legacy_2026_07/mbal_market.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""#17 - MBAL 10M-Label Market Maker. Public searchable database of the MBAL dataset +(10M labeled addresses). Free search, paid API. Kaggle: cryptorugmuncher.""" + +import contextlib +import os +import sqlite3 +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/mbal", tags=["mbal-market-maker"]) + +MBAL_DB = os.environ.get("MBAL_DB", str(Path.home() / "rmi/backend/data/mbal.db")) + + +def _get_db() -> sqlite3.Connection | None: + """Open MBAL database connection.""" + db_path = Path(MBAL_DB) + if not db_path.exists(): + return None + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + return conn + + +@router.get("/search/{address}") +async def search_mbal(address: str): + """Search MBAL database for an address. Returns label if found.""" + conn = _get_db() + if not conn: + return { + "address": address, + "found": False, + "database": "unavailable", + "note": "MBAL database not found. Visit: https://www.kaggle.com/datasets/cryptorugmuncher/mbal-10m-labeled-addresses", + } + + try: + cursor = conn.execute( + "SELECT address, label, category, confidence, source FROM addresses WHERE address = ? LIMIT 1", + (address.lower(),), + ) + row = cursor.fetchone() + if row: + return { + "address": address, + "found": True, + "label": row["label"], + "category": row["category"], + "confidence": row["confidence"], + "source": row["source"], + "dataset": "MBAL (Multi-chain Blockchain Address Labels)", + } + return {"address": address, "found": False, "database": "available"} + except sqlite3.Error: + return {"address": address, "found": False, "database": "error"} + finally: + conn.close() + + +@router.get("/stats") +async def mbal_stats(): + """MBAL dataset statistics.""" + conn = _get_db() + stats = { + "dataset": "MBAL - Multi-chain Blockchain Address Labels", + "total_addresses": 0, + "categories": [], + "chains_covered": [], + "kaggle_url": "https://www.kaggle.com/datasets/cryptorugmuncher/mbal-10m-labeled-addresses", + "database_available": conn is not None, + } + if conn: + try: + stats["total_addresses"] = conn.execute("SELECT COUNT(*) FROM addresses").fetchone()[0] + stats["categories"] = [ + r[0] + for r in conn.execute( + "SELECT DISTINCT category FROM addresses WHERE category IS NOT NULL LIMIT 20" + ).fetchall() + ] + with contextlib.suppress(sqlite3.Error): + stats["chains_covered"] = [ + r[0] + for r in conn.execute( + "SELECT DISTINCT chain FROM addresses WHERE chain IS NOT NULL LIMIT 20" + ).fetchall() + ] + except sqlite3.Error: + pass + finally: + conn.close() + + return stats + + +class BulkSearchRequest(BaseModel): + addresses: list[str] + max_results: int = 100 + + +@router.post("/bulk-search") +async def bulk_search_mbal(request: BulkSearchRequest): + """Search multiple addresses in MBAL. Paid tier (x402).""" + conn = _get_db() + if not conn: + raise HTTPException(503, "MBAL database unavailable") + + results: list[dict] = [] + try: + for addr in request.addresses[: request.max_results]: + cursor = conn.execute( + "SELECT address, label, category, confidence FROM addresses WHERE address = ? LIMIT 1", (addr.lower(),) + ) + row = cursor.fetchone() + if row: + results.append( + { + "address": addr, + "found": True, + "label": row["label"], + "category": row["category"], + "confidence": row["confidence"], + } + ) + else: + results.append({"address": addr, "found": False}) + except sqlite3.Error: + raise HTTPException(500, "Database query error") from None + finally: + conn.close() + + return { + "searched": len(request.addresses[: request.max_results]), + "found": sum(1 for r in results if r.get("found")), + "results": results, + } + + +@router.get("/category/{category}") +async def search_by_category(category: str, limit: int = Query(20, le=100)): + """Search MBAL addresses by category (e.g., 'exchange', 'scam', 'mixer').""" + conn = _get_db() + if not conn: + raise HTTPException(503, "MBAL database unavailable") + + try: + rows = conn.execute( + "SELECT address, label, category, confidence FROM addresses WHERE category = ? LIMIT ?", (category, limit) + ).fetchall() + results = [ + {"address": r["address"], "label": r["label"], "category": r["category"], "confidence": r["confidence"]} + for r in rows + ] + return {"category": category, "count": len(results), "addresses": results} + except sqlite3.Error: + raise HTTPException(500, "Database query error") from None + finally: + conn.close() diff --git a/app/_archive/legacy_2026_07/mcp_local_router.py b/app/_archive/legacy_2026_07/mcp_local_router.py new file mode 100644 index 0000000..876b2b3 --- /dev/null +++ b/app/_archive/legacy_2026_07/mcp_local_router.py @@ -0,0 +1,63 @@ +"""Local MCP Proxy Router - free local MCP server proxy. +NOTE: Auth via X-Admin-Key is disabled due to Docker pycache issue. +Secure via nginx IP whitelist or firewall in production. +Frontend already requires adminKey in React Query hooks (enabled: !!adminKey).""" + +from fastapi import APIRouter, Query +from pydantic import BaseModel + +from .mcp_proxy import SERVER_REGISTRY, call_local_mcp, get_local_mcp_tools + +router = APIRouter(prefix="/api/v1/mcp-local", tags=["mcp-local"]) + + +class MCPCallRequest(BaseModel): + server: str + tool: str + params: dict = {} + + +@router.get("/tools") +async def list_local_mcp_tools(): + tools = get_local_mcp_tools() + servers = [ + {"id": s, "name": c["name"], "tool_count": len(c["tools"]), "tools": c["tools"]} + for s, c in SERVER_REGISTRY.items() + ] + return { + "total_servers": len(servers), + "total_tools": len(tools), + "servers": servers, + "tools": tools, + } + + +@router.get("/servers") +async def list_local_mcp_servers(): + return { + "servers": [{"id": s, "name": c["name"], "tool_count": len(c["tools"])} for s, c in SERVER_REGISTRY.items()] + } + + +@router.post("/call") +async def call_local_mcp_tool(req: MCPCallRequest): + result = await call_local_mcp(req.server, req.tool, req.params) + return {"server": req.server, "tool": req.tool, "params": req.params, **result} + + +@router.get("/call/{server}/{tool}") +async def call_local_mcp_tool_get( + server: str, + tool: str, + symbol: str = Query(None), + address: str = Query(None), + chain: str = Query("ethereum"), +): + params = {} + if symbol: + params["symbol"] = symbol + if address: + params["address"] = address + params["chain"] = chain + result = await call_local_mcp(server, tool, params) + return {"server": server, "tool": tool, "params": params, **result} diff --git a/app/_archive/legacy_2026_07/mcp_proxy.py b/app/_archive/legacy_2026_07/mcp_proxy.py new file mode 100644 index 0000000..2ebcb72 --- /dev/null +++ b/app/_archive/legacy_2026_07/mcp_proxy.py @@ -0,0 +1,325 @@ +""" +Local MCP Server Proxy - makes free local MCP servers accessible via REST API. +Reads tool definitions from MCP server manifests and proxies tool calls. + +Local servers run via stdio - we spawn them on demand with a process cache. +All data is FREE to call (no API keys, just local compute/RPC queries). +""" + +import asyncio +import json +import subprocess +import time + +SERVER_REGISTRY = { + "feargreed": { + "name": "Crypto Fear & Greed Index", + "path": "/root/.hermes/mcp-servers/crypto-feargreed-mcp", + "command": ["python3", "main.py"], + "tools": [ + { + "id": "feargreed_get_index", + "name": "Get Fear & Greed Index", + "description": "Current crypto Fear & Greed Index value and classification", + }, + { + "id": "feargreed_get_historical", + "name": "Get Historical Fear & Greed", + "description": "Historical Fear & Greed values for a date range", + }, + ], + }, + "crypto-news": { + "name": "Crypto News API", + "path": "/root/.hermes/mcp-servers/crypto-news-api", + "command": ["node", "dist/index.js"], + "tools": [ + { + "id": "news_get_latest", + "name": "Get Latest News", + "description": "Real-time crypto news from 12+ sources with AI sentiment", + }, + { + "id": "news_search", + "name": "Search News", + "description": "Search crypto news by keyword, source, or date range", + }, + { + "id": "news_sentiment", + "name": "News Sentiment", + "description": "Aggregated market sentiment from news sources", + }, + ], + }, + "indicators": { + "name": "Crypto Indicators", + "path": "/root/.hermes/mcp-servers/crypto-indicators-mcp", + "command": ["node", "dist/index.js"], + "tools": [ + { + "id": "indicators_rsi", + "name": "RSI Indicator", + "description": "Relative Strength Index for any token", + }, + { + "id": "indicators_macd", + "name": "MACD Indicator", + "description": "Moving Average Convergence Divergence", + }, + { + "id": "indicators_bollinger", + "name": "Bollinger Bands", + "description": "Bollinger Bands volatility indicator", + }, + {"id": "indicators_ema", "name": "EMA", "description": "Exponential Moving Average"}, + {"id": "indicators_sma", "name": "SMA", "description": "Simple Moving Average"}, + ], + }, + "evmscope": { + "name": "EVMScope Intelligence", + "path": "/root/.hermes/mcp-servers/evmscope", + "command": ["node", "dist/index.js"], + "tools": [ + { + "id": "evmscope_token_price", + "name": "Token Price", + "description": "Current token price across chains", + }, + { + "id": "evmscope_token_info", + "name": "Token Info", + "description": "Token metadata - name, symbol, decimals, supply", + }, + { + "id": "evmscope_token_holders", + "name": "Token Holders", + "description": "Token holder distribution and top holders", + }, + { + "id": "evmscope_honeypot", + "name": "Honeypot Check", + "description": "Check if a token is a honeypot scam", + }, + { + "id": "evmscope_whale_movements", + "name": "Whale Movements", + "description": "Recent large transactions for a token", + }, + { + "id": "evmscope_gas_price", + "name": "Gas Price", + "description": "Current gas prices across chains", + }, + { + "id": "evmscope_portfolio", + "name": "Portfolio", + "description": "Wallet portfolio with balances across chains", + }, + { + "id": "evmscope_bridge_routes", + "name": "Bridge Routes", + "description": "Optimal bridge routes between chains", + }, + { + "id": "evmscope_yield_rates", + "name": "Yield Rates", + "description": "Current DeFi yield rates across protocols", + }, + { + "id": "evmscope_swap_quote", + "name": "Swap Quote", + "description": "Best swap quote across DEX aggregators", + }, + { + "id": "evmscope_nft_info", + "name": "NFT Info", + "description": "NFT metadata and collection info", + }, + { + "id": "evmscope_contract_abi", + "name": "Contract ABI", + "description": "Fetch verified contract ABI", + }, + { + "id": "evmscope_decode_tx", + "name": "Decode Transaction", + "description": "Decode raw transaction data", + }, + ], + }, + "polymarket": { + "name": "Polymarket Data", + "path": "/root/.hermes/mcp-servers/graph-polymarket-mcp", + "command": ["node", "dist/index.js"], + "tools": [ + { + "id": "polymarket_active_markets", + "name": "Active Markets", + "description": "Currently active prediction markets", + }, + { + "id": "polymarket_market_detail", + "name": "Market Detail", + "description": "Detailed market data - prices, volume, liquidity", + }, + { + "id": "polymarket_orderbook", + "name": "Orderbook", + "description": "Live orderbook for a market", + }, + { + "id": "polymarket_open_interest", + "name": "Open Interest", + "description": "Open interest across markets", + }, + { + "id": "polymarket_traders", + "name": "Top Traders", + "description": "Top traders by volume/profit", + }, + ], + }, + "prediction-markets": { + "name": "Prediction Markets", + "path": "/root/.hermes/mcp-servers/prediction-market-mcp", + "command": ["node", "dist/index.js"], + "tools": [ + { + "id": "predmkt_polymarket", + "name": "Polymarket Data", + "description": "Polymarket markets, prices, and events", + }, + { + "id": "predmkt_kalshi", + "name": "Kalshi Data", + "description": "Kalshi prediction market data", + }, + { + "id": "predmkt_predictit", + "name": "PredictIt Data", + "description": "PredictIt market data", + }, + ], + }, + "web3-research": { + "name": "Web3 Research", + "path": "/root/.hermes/mcp-servers/web3-research-mcp", + "command": ["node", "dist/index.js"], + "tools": [ + { + "id": "research_coingecko", + "name": "CoinGecko Research", + "description": "Deep CoinGecko data - prices, markets, exchanges", + }, + { + "id": "research_defillama", + "name": "DeFiLlama Research", + "description": "Protocol TVL, yields, chain metrics from DeFiLlama", + }, + { + "id": "research_combined", + "name": "Combined Research", + "description": "Multi-source research combining CoinGecko + DeFiLlama", + }, + ], + }, + "jupiter": { + "name": "Jupiter DEX", + "path": "/root/.hermes/mcp-servers/jupiter-mcp", + "command": ["node", "dist/index.js"], + "tools": [ + { + "id": "jupiter_quote", + "name": "Swap Quote", + "description": "Best swap route and price quote on Solana", + }, + { + "id": "jupiter_price", + "name": "Token Price", + "description": "Current token price via Jupiter aggregator", + }, + { + "id": "jupiter_token_list", + "name": "Token List", + "description": "All verified tokens on Jupiter", + }, + ], + }, +} + +# Process cache - keep server processes alive for 5 min +_proc_cache: dict[str, tuple[subprocess.Popen, float]] = {} +_CACHE_TTL = 300 # 5 minutes + + +async def _get_or_spawn(server_id: str): + """Get or spawn an MCP server process.""" + if server_id not in SERVER_REGISTRY: + return None + + now = time.time() + if server_id in _proc_cache: + proc, created = _proc_cache[server_id] + if now - created < _CACHE_TTL and proc.poll() is None: + return proc + + cfg = SERVER_REGISTRY[server_id] + try: + proc = subprocess.Popen( + cfg["command"], + cwd=cfg["path"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + _proc_cache[server_id] = (proc, now) + # Give it a moment to initialize + await asyncio.sleep(1) + return proc + except Exception: + return None + + +async def call_local_mcp(server_id: str, tool_name: str, params: dict | None = None) -> dict: + """Call a tool on a local MCP server.""" + proc = await _get_or_spawn(server_id) + if not proc: + return {"error": f"Server '{server_id}' not available"} + + # MCP JSON-RPC call + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": params or {}, + }, + } + + try: + proc.stdin.write((json.dumps(request) + "\n").encode()) + proc.stdin.flush() + # Read response line + line = proc.stdout.readline() + result = json.loads(line.decode()) + if "error" in result: + return {"error": result["error"]} + return {"result": result.get("result", result)} + except Exception as e: + return {"error": str(e)} + + +def get_local_mcp_tools() -> list[dict]: + """Get all available local MCP tools.""" + tools = [] + for server_id, cfg in SERVER_REGISTRY.items(): + for tool in cfg["tools"]: + tools.append( + { + "server": server_id, + "server_name": cfg["name"], + **tool, + } + ) + return tools diff --git a/app/_archive/legacy_2026_07/mcp_router.py b/app/_archive/legacy_2026_07/mcp_router.py new file mode 100644 index 0000000..f336239 --- /dev/null +++ b/app/_archive/legacy_2026_07/mcp_router.py @@ -0,0 +1,397 @@ +""" +MCP Router - Receives tool execution requests from Cloudflare X402 Workers. +Exposes /mcp/tools (catalog) and /mcp/execute (tool execution). +This is what the worker calls when x402 payment is verified. +""" + +import logging +import os +from datetime import UTC, datetime + +import httpx +from fastapi import APIRouter, HTTPException, Request + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/mcp", tags=["mcp-router"]) + +# ═══════════════════════════════════════════════════════════ +# TOOL CATALOG - What the worker fetches on startup +# ═══════════════════════════════════════════════════════════ +TOOLS = { + # Security + "honeypot_check": { + "name": "Honeypot Detector", + "endpoint": "/api/v1/contract/audit", + "method": "POST", + "category": "security", + "price": "$0.05", + }, + "rug_pull_predictor": { + "name": "Rug Pull Predictor", + "endpoint": "/api/v1/contract/audit", + "method": "POST", + "category": "security", + "price": "$0.10", + }, + "rugshield": { + "name": "Rug Shield", + "endpoint": "/api/v1/security/scan", + "method": "POST", + "category": "security", + "price": "$0.02", + }, + "audit": { + "name": "Smart Contract Audit", + "endpoint": "/api/v1/contract/audit", + "method": "POST", + "category": "security", + "price": "$0.05", + }, + "mev_protection": { + "name": "MEV Protection Check", + "endpoint": "/api/v1/tools/cast/contract-info", + "method": "GET", + "category": "security", + "price": "$0.08", + }, + "bridge_security": { + "name": "Bridge Security Monitor", + "endpoint": "/api/v1/defillama/chains", + "method": "GET", + "category": "security", + "price": "$0.08", + }, + "profile_flip": { + "name": "Profile Flip Detector", + "endpoint": "/api/v1/token/scan", + "method": "POST", + "category": "security", + "price": "$0.03", + }, + "fresh_pair": { + "name": "Fresh Pair Scanner", + "endpoint": "/api/v1/tokens/new", + "method": "GET", + "category": "security", + "price": "$0.03", + }, + "clone_detect": { + "name": "Clone Detector", + "endpoint": "/api/v1/token/scan", + "method": "POST", + "category": "security", + "price": "$0.02", + }, + "urlcheck": { + "name": "URL Safety Check", + "endpoint": "/api/v1/security/scan", + "method": "POST", + "category": "security", + "price": "$0.01", + }, + # Intelligence + "whale": { + "name": "Whale Wallet Decoder", + "endpoint": "/api/v1/helius/whale-profile", + "method": "POST", + "category": "intelligence", + "price": "$0.15", + }, + "whale_scan": { + "name": "Whale Scanner", + "endpoint": "/api/v1/helius/whale-scan", + "method": "POST", + "category": "intelligence", + "price": "$0.03", + }, + "whale_profile": { + "name": "Whale Profile", + "endpoint": "/api/v1/helius/whale-profile", + "method": "POST", + "category": "intelligence", + "price": "$0.05", + }, + "smartmoney": { + "name": "Smart Money Tracker", + "endpoint": "/api/v1/smart-money", + "method": "GET", + "category": "intelligence", + "price": "$0.05", + }, + "cluster": { + "name": "Wallet Cluster Analysis", + "endpoint": "/api/v1/entity/clusters", + "method": "GET", + "category": "intelligence", + "price": "$0.05", + }, + "insider": { + "name": "Insider Trading Detection", + "endpoint": "/api/v1/threat/reputation/0x0", + "method": "GET", + "category": "intelligence", + "price": "$0.10", + }, + "sniper_detect": { + "name": "Sniper Detector", + "endpoint": "/api/v1/helius/sniper-detect", + "method": "POST", + "category": "intelligence", + "price": "$0.08", + }, + "syndicate_scan": { + "name": "Syndicate Scanner", + "endpoint": "/api/v1/helius/syndicate/scan", + "method": "GET", + "category": "intelligence", + "price": "$0.08", + }, + "copy_trade_finder": { + "name": "Copy Trade Finder", + "endpoint": "/api/v1/whales/top", + "method": "GET", + "category": "intelligence", + "price": "$0.10", + }, + "liquidity_flow": { + "name": "Liquidity Flow Tracker", + "endpoint": "/api/v1/exchange/whales", + "method": "GET", + "category": "intelligence", + "price": "$0.08", + }, + # Market + "pulse": { + "name": "Market Pulse", + "endpoint": "/api/v1/intelligence/dashboard", + "method": "GET", + "category": "market", + "price": "$0.01", + }, + "market_overview": { + "name": "Market Overview", + "endpoint": "/api/v1/markets/ccxt", + "method": "GET", + "category": "market", + "price": "$0.05", + }, + "chain_health": { + "name": "Chain Health", + "endpoint": "/api/v1/defillama/chains", + "method": "GET", + "category": "market", + "price": "$0.05", + }, + "defi_yield_scanner": { + "name": "DeFi Yield Scanner", + "endpoint": "/api/v1/defillama/protocols", + "method": "GET", + "category": "market", + "price": "$0.08", + }, + "gas_forecast": { + "name": "Gas Forecaster", + "endpoint": "/api/v1/mempool/status", + "method": "GET", + "category": "market", + "price": "$0.05", + }, + # Analysis + "wallet": { + "name": "Wallet Analysis", + "endpoint": "/api/v1/wallet/multichain/{address}", + "method": "GET", + "category": "analysis", + "price": "$0.05", + }, + "forensics": { + "name": "Token Forensics", + "endpoint": "/api/v1/wallet/{address}/analysis", + "method": "GET", + "category": "analysis", + "price": "$0.10", + }, + "token_deep_dive": { + "name": "Token Deep Dive", + "endpoint": "/api/v1/birdeye/token/{address}", + "method": "GET", + "category": "analysis", + "price": "$0.10", + }, + "portfolio_tracker": { + "name": "Portfolio Tracker", + "endpoint": "/api/v1/wallet/pnl/{address}", + "method": "GET", + "category": "analysis", + "price": "$0.10", + }, + "token_comparison": { + "name": "Token Comparison", + "endpoint": "/api/v1/token/scan", + "method": "POST", + "category": "analysis", + "price": "$0.08", + }, + "nft_wash_detector": { + "name": "NFT Wash Detector", + "endpoint": "/api/v1/threat/reputation/0x0", + "method": "GET", + "category": "analysis", + "price": "$0.10", + }, + # Launch + "launch": { + "name": "Token Launch Analysis", + "endpoint": "/api/v1/tokens/new", + "method": "GET", + "category": "launchpad", + "price": "$0.03", + }, + "launch_intel": { + "name": "Launch Intelligence", + "endpoint": "/api/v1/tokens/trending", + "method": "GET", + "category": "launchpad", + "price": "$0.05", + }, + "sniper_alert": { + "name": "Sniper Alert", + "endpoint": "/api/v1/tokens/new", + "method": "GET", + "category": "launchpad", + "price": "$0.05", + }, + "airdrop_finder": { + "name": "Airdrop Finder", + "endpoint": "/api/v1/wallet/pnl/{address}", + "method": "GET", + "category": "intelligence", + "price": "$0.05", + }, + # Social + "sentiment": { + "name": "Sentiment Analysis", + "endpoint": "/api/v1/sentiment/market", + "method": "GET", + "category": "social", + "price": "$0.03", + }, + "social_signal": { + "name": "Social Signal Analyzer", + "endpoint": "/api/v1/sentiment/token/{address}", + "method": "GET", + "category": "social", + "price": "$0.10", + }, +} + + +def _build_tools_catalog() -> dict: + """Merge hardcoded TOOLS with dynamic TOOL_PRICES, preferring dynamic data.""" + merged = dict(TOOLS) # Start with hardcoded + try: + from app.routers.x402_enforcement import TOOL_PRICES + + # Tool ID → internal endpoint mapping + _ENDPOINT_MAP = { + "forensic_valuation": "/api/v1/x402-tools/forensic_valuation", + "osint_identity_hunt": "/api/v1/x402-tools/osint_identity_hunt", + "investigation_report": "/api/v1/x402-tools/investigation_report", + "forensic_pack": "/api/v1/x402-tools/forensic_pack", + "catalog": "/api/v1/x402-tools/catalog", + "smart_money_alpha": "/api/v1/x402-tools/smart_money_alpha", + "meme_vibe_score": "/api/v1/x402-tools/meme_vibe_score", + "mcp-proxy": "/api/v1/x402-tools/mcp-proxy", + "human-execute": "/api/v1/x402-tools/human-execute", + } + for tool_id, pricing in TOOL_PRICES.items(): + if tool_id not in merged: + endpoint = _ENDPOINT_MAP.get(tool_id, f"/api/v1/x402-tools/{tool_id}") + merged[tool_id] = { + "name": pricing.get("description", tool_id.replace("_", " ").title()), + "endpoint": endpoint, + "method": "POST", + "category": pricing.get("category", "analysis"), + "price": f"${pricing.get('price_usd', 0.01):.2f}", + } + else: + # Update existing entries with latest pricing/category from TOOL_PRICES + if tool_id in TOOL_PRICES: + merged[tool_id]["category"] = TOOL_PRICES[tool_id].get( + "category", merged[tool_id].get("category", "analysis") + ) + merged[tool_id]["price"] = f"${TOOL_PRICES[tool_id].get('price_usd', 0.01):.2f}" + except Exception as e: + logger.warning(f"mcp_router: could not merge TOOL_PRICES: {e}") + return merged + + +@router.get("/tools") +async def mcp_tools(): + """Return tool catalog for Cloudflare Worker to cache.""" + catalog = _build_tools_catalog() + return { + "tools": catalog, + "total": len(catalog), + "categories": list({t["category"] for t in catalog.values()}), + "updated_at": datetime.now(UTC).isoformat(), + } + + +@router.post("/execute/{tool_id}") +async def mcp_execute(tool_id: str, request: Request): + """Execute a tool - called by Cloudflare Worker after x402 payment verified.""" + tool = TOOLS.get(tool_id) + if not tool: + raise HTTPException(status_code=404, detail=f"Tool {tool_id} not found") + + try: + body = await request.json() if request.headers.get("content-type") == "application/json" else {} + except Exception: + body = {} + + # Forward to the actual backend endpoint + endpoint = tool["endpoint"] + method = tool["method"] + + # Replace path parameters from body + for key in ["address", "token", "chain"]: + if f"{{{key}}}" in endpoint and key in body: + endpoint = endpoint.replace(f"{{{key}}}", body[key]) + + # Internal calls bypass auth - add internal API key header + internal_headers = {} + auth_token = os.getenv("RMI_AUTH_TOKEN", "") + if auth_token: + internal_headers["X-API-Key"] = auth_token + + try: + async with httpx.AsyncClient(timeout=30) as c: + if method == "GET": + # Build query params from body + params = {k: v for k, v in body.items() if k not in ["address", "token"] and f"{{{k}}}" not in endpoint} + r = await c.get(f"http://127.0.0.1:8000{endpoint}", params=params, headers=internal_headers) + else: + r = await c.post(f"http://127.0.0.1:8000{endpoint}", json=body, headers=internal_headers) + + if r.status_code == 200: + return { + "tool": tool_id, + "status": "success", + "data": r.json(), + "executed_at": datetime.now(UTC).isoformat(), + } + return { + "tool": tool_id, + "status": "error", + "error": f"Backend returned {r.status_code}", + "executed_at": datetime.now(UTC).isoformat(), + } + except Exception as e: + return {"tool": tool_id, "status": "error", "error": str(e)} + + +@router.get("/health") +async def mcp_health(): + catalog = _build_tools_catalog() + return {"status": "healthy", "tools": len(catalog), "timestamp": datetime.now(UTC).isoformat()} diff --git a/app/_archive/legacy_2026_07/mcp_server.py b/app/_archive/legacy_2026_07/mcp_server.py new file mode 100644 index 0000000..0d28b81 --- /dev/null +++ b/app/_archive/legacy_2026_07/mcp_server.py @@ -0,0 +1,1236 @@ +""" +Rug Munch Intelligence MCP Server v3.1 (Spec Compliant) + +MCP protocol version: 2024-11-05 +Transport: Streamable HTTP (POST /mcp) +Discovery: /.well-known/mcp | /.well-known/mcp.json | /llms.txt +Tool listing: GET /mcp/tools | POST /mcp (tools/list) +Tool execution: POST /mcp/call/{tool_id} | POST /mcp (tools/call) +x402 payments: /.well-known/x402 + +Changes from v3.0: +- Added /.well-known/mcp (no .json) per MCP discovery convention +- Added POST /mcp JSON-RPC handler (initialize, tools/list, tools/call) +- Added inputSchema to every tool (JSON Schema objects array) +- Fix: tool "name" now uses tool_id not description +- Fix: facilitators count dynamically loaded from registry +- Fix: chains count from CHAIN_USDC not hardcoded +- Added proper error codes per MCP spec +- Added CORS headers for browser-based MCP clients +""" + +import json +import logging +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse, Response + +logger = logging.getLogger("rmi_mcp_v3") +router = APIRouter(tags=["mcp"]) + +MCP_PROTOCOL_VERSION = "2024-11-05" +SERVER_NAME = "Rug Munch Intelligence" +SERVER_VERSION = "3.2.0" + + +def _get_tools() -> dict[str, Any]: + try: + from app.routers.x402_enforcement import CHAIN_USDC, TOOL_PRICES + + return {"prices": dict(TOOL_PRICES), "chains": dict(CHAIN_USDC)} + except Exception: + return {"prices": {}, "chains": {}} + + +def _get_facilitator_count() -> int: + try: + from app.facilitators.base import get_registry + + return len(get_registry().get_all()) + except Exception: + return 10 + + +def _desc(tool_id: str, pricing: dict) -> str: + d = pricing.get("description", "") + if d and d != tool_id and len(d) > 10: + return d + FALLBACKS = { + "airdrop_check": "Verify airdrop legitimacy -- contract audit, distribution analysis, scam pattern detection. Know if an airdrop is real or a wallet drainer before connecting.", + "airdrop_finder": "Discover active and upcoming airdrops across all major chains. Eligibility checks, value estimation, claim deadlines, and Sybil detection.", + "all_in_one": "All-in-One Audit -- comprehensive security scan: rug pull, honeypot, clone detection, contract audit, and ownership analysis in a single call.", + "alpha_digest": "Alpha digest -- curated crypto alpha from top-performing wallets, on-chain signals, sentiment spikes, and accumulation patterns.", + "arbitrage_scan": "Cross-chain and cross-DEX arbitrage scanner. Find price discrepancies across exchanges for instant profit opportunities.", + "bundler_detect": "MEV bundler detector -- sandwich attacks, frontrunning, backrunning patterns on Solana and EVM chains.", + "catalog": "Full tool catalog -- list every RMI tool with pricing, chain support, trial availability, and descriptions.", + "clone_detect": "Clone contract detector -- bytecode similarity analysis, function matching, known scam template identification.", + "deployer_history": "Deployer history investigation -- every token this wallet has launched, success rate, known scam patterns, cross-chain activity.", + "fresh_pair": "Fresh pair scanner -- detect newly created trading pairs, liquidity depth, ownership concentration, honeypot risk.", + "insider_network": "Insider network mapper -- trace connected wallets, shared funding sources, coordinated trading patterns across addresses.", + "intelligence_pack": "Intelligence Pack -- whale tracking + smart money + wallet clustering at 29% discount.", + "kol_performance": "KOL performance tracker -- measure influencer call accuracy, average ROI after calls, follower quality score.", + "liquidity_depth": "Liquidity depth analyzer -- order book depth, slippage estimation, market impact across DEXs and chains.", + "liquidity_flow": "Liquidity flow tracker -- track where capital is moving across chains, pools, and protocols.", + "liquidity_migration": "Liquidity migration detector -- tokens moving pools, chains, or protocols. Often a rug pull precursor signal.", + "listing_predictor": "Exchange listing predictor -- on-chain signals suggesting imminent CEX or DEX listing based on accumulation patterns.", + "meme_vibe_score": "Meme coin vibe score -- social virality, holder growth rate, community engagement metrics, and dump risk assessment.", + "mev_alert": "MEV alert system -- real-time sandwich attack, frontrun, and arbitrage detection with wallet protection recommendations.", + "mev_protection": "MEV protection checker -- verify if your transaction is protected from MEV extraction before submitting.", + "portfolio_aggregate": "Portfolio aggregator -- combine multiple wallets into a single dashboard with consolidated PnL and asset allocation.", + "profile_flip": "Profile flip detector -- sudden Twitter/X profile changes, domain swaps, or branding pivots before token launches or scams.", + "protocol_risk": "Protocol risk assessment -- TVL stability, admin key analysis, upgrade patterns, oracle dependency, governance risk.", + "rug_pull_predictor": "Rug pull predictor -- AI-powered risk scoring using 12+ signals: liquidity locks, ownership, holder distribution, social signals.", + "scam_database": "Scam database lookup -- check addresses against known scam, phishing, honeypot, and rug pull databases.", + "security_pack": "Security Pack -- honeypot + rug pull + audit + clone detection at 23% discount.", + "sentiment_spike": "Sentiment spike detector -- real-time social media volume anomalies and sentiment shifts for any token.", + "smart_money_alpha": "Smart money alpha -- real-time alerts when top-performing wallets enter new positions.", + "sniper_alert": "Sniper alert system -- detect sniper bots entering new token launches in real-time.", + "syndicate_scan": "Syndicate scanner -- identify coordinated trading groups, wash trading rings, pump-and-dump networks.", + "syndicate_track": "Syndicate tracker -- follow known syndicate wallets, monitor their current positions and exit patterns.", + "token_age": "Token age verifier -- contract creation date, migration history, proxy upgrades, and deployment patterns.", + "unlock_calendar": "Token unlock calendar -- track vesting schedules, team token unlocks, upcoming dilution events.", + "wallet_graph": "Wallet graph analysis -- visualize transaction flows, identify money laundering patterns and entity relationships.", + "wallet_pnl": "Wallet PnL calculator -- realized/unrealized gains, win rate, ROI, Sharpe ratio, and complete trade history.", + "wash_trading": "Wash trading detector -- identify fake volume, self-trades, artificial market activity across NFTs and tokens.", + "whale_accumulation": "Whale accumulation detector -- track large wallet accumulation and distribution patterns.", + "whale_profile": "Whale profile -- complete analysis: holdings, strategy classification, historical performance, influence score.", + "whale_scan": "Whale scanner -- real-time whale activity across chains. Large transfers, exchange deposits, accumulation signals.", + # ── Tools with pricing.description == tool_id (no real description) ── + "anomaly": "Anomaly detector -- flag unusual transaction patterns, price manipulations, and suspicious on-chain behavior across all chains.", + "audit": "Smart contract audit -- static analysis, vulnerability detection, ownership risks, and function-level security assessment.", + "bridge_security": "Cross-chain bridge security audit -- liquidity verification, admin key analysis, exploit history, and withdrawal safety checks.", + "chain_health": "Chain health monitor -- network congestion, gas trends, validator status, RPC reliability, and mempool depth analysis.", + "cluster": "Wallet cluster analysis -- identify linked addresses via shared funding, transaction patterns, and behavioral heuristics.", + "comprehensive_audit": "Comprehensive audit -- full security scan combining honeypot detection, rug pull prediction, contract analysis, and risk scoring.", + "copy_trade_finder": "Copy trade discovery -- find wallets with proven track records, filter by ROI, win rate, and strategy type for mirroring.", + "defi_yield_scanner": "DeFi yield scanner -- scan across chains for highest APY opportunities with risk assessment and sustainability scoring.", + "forensics": "On-chain forensics -- deep transaction tracing, money flow analysis, and entity identification for wallet investigation.", + "gas_forecast": "Gas price forecast -- predict optimal transaction timing with historical gas trends and real-time network conditions.", + "honeypot_check": "Honeypot detector -- verify if a token contract prevents selling, uses hidden mint functions, or traps buyer funds.", + "insider": "Insider activity tracker -- detect pre-launch accumulation, team wallet movements, and privileged information signals.", + "launch": "Token launch analyzer -- new token launch evaluation covering initial liquidity, deployer reputation, and early holder distribution.", + "launch_intel": "Launch intelligence -- comprehensive new token assessment: pre-launch signals, launch mechanics, and post-launch performance.", + "market_overview": "Market overview -- aggregate crypto market data: total cap, sector performance, dominance shifts, and macro trend signals.", + "nft_wash_detector": "NFT wash trading detector -- identify self-bought NFTs, circular transfers, and artificial floor price inflation.", + "portfolio_tracker": "Portfolio tracker -- monitor wallet holdings over time with performance metrics, rebalancing suggestions, and risk alerts.", + "pulse": "Market pulse -- real-time snapshot of market sentiment, trading volume, and directional momentum across top tokens.", + "risk_monitor": "Risk monitor -- continuous risk assessment for watched tokens with automated alerts on liquidity drops and ownership changes.", + "rugshield": "RugShield instant check -- rapid safety assessment: honeypot status, liquidity lock, owner renunciation, and top holder concentration.", + "sentiment": "Crypto sentiment analysis -- aggregate social sentiment from Twitter, Reddit, and news sources for any token or market sector.", + "smartmoney": "Smart money tracker -- follow wallets with consistently high ROI, their current positions, entry/exit patterns, and strategy.", + "sniper_detect": "Sniper bot detector -- identify automated sniping wallets that buy within seconds of launch, track their patterns and targets.", + "social_signal": "Social signal aggregator -- combine Twitter volume, Reddit sentiment, Telegram buzz, and influencer mentions into one score.", + "token_comparison": "Token comparison -- side-by-side analysis of two or more tokens: risk scores, holder distribution, liquidity, and performance metrics.", + "token_deep_dive": "Token deep dive -- exhaustive single-token analysis covering every signal: security, social, whale, on-chain, and market data.", + "token_watch_alerts": "Token watch alerts -- push notifications for watched tokens when LP changes, whale moves, or rug indicators are detected.", + "token_watch_list": "Token watch list -- view all your monitored tokens with current status, risk level, and recent alert summaries.", + "tw_profile": "X/Twitter profile analysis -- crypto account credibility, engagement metrics, bot score, and influence rating.", + "tw_search": "X/Twitter crypto search -- find tweets, sentiment, and discussions about any token, wallet, or market event.", + "tw_timeline": "X/Twitter timeline feed -- curated crypto timeline from top analysts, whales, and project accounts.", + "urlcheck": "URL security checker -- scan crypto URLs for phishing patterns, credential harvesting, and known scam infrastructure.", + "wallet": "Wallet analysis -- comprehensive wallet assessment: holdings, risk score, labels, transaction patterns, and entity identification.", + "wallet_graph": "Wallet transaction graph -- visualize address relationships, money flows, and interaction networks for forensic investigation.", # noqa: F601 + "wallet_pnl": "Wallet PnL calculator -- realized and unrealized gains, win rate, average ROI, and complete trade performance history.", # noqa: F601 + "wash_trading": "Wash trading detector -- identify artificial volume, self-trades, and coordinated buy-sell patterns across DEXs.", # noqa: F601 + "whale": "Whale tracker -- monitor large holder activity, recent transfers, accumulation trends, and wallet classification.", + "whale_accumulation": "Whale accumulation monitor -- detect when large wallets are building positions, track accumulation rate and entry timing.", # noqa: F601 + "whale_profile": "Whale profile -- deep analysis of a whale wallet: strategy classification, historical performance, holdings breakdown, and influence.", # noqa: F601 + } + return FALLBACKS.get( + tool_id, + f"{tool_id.replace('_', ' ').title()} -- real-time crypto intelligence and security analysis.", + ) + + +# Build input schemas per tool based on known parameter patterns +def _input_schema(tool_id: str) -> dict: + """Return MCP-compliant inputSchema for a tool.""" + # Universal parameters most tools accept + base_address = { + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Wallet address, token contract, or ENS name to analyze", + }, + "chain": { + "type": "string", + "description": "Blockchain to query (base, ethereum, solana, bsc, polygon, arbitrum, optimism, avalanche, fantom, gnosis, tron, bitcoin)", + "enum": [ + "base", + "ethereum", + "solana", + "bsc", + "polygon", + "arbitrum", + "optimism", + "avalanche", + "fantom", + "gnosis", + "tron", + "bitcoin", + ], + }, + }, + "required": ["address"], + } + base_url = { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL or contract address to analyze", + }, + "chain": { + "type": "string", + "description": "Blockchain (base, ethereum, solana, etc.)", + }, + }, + "required": ["url"], + } + base_none = { + "type": "object", + "properties": {}, + "required": [], + } + + # Tool-specific schemas + schemas = { + "urlcheck": base_url, + "honeypot_check": base_address, + "rug_pull_check": base_address, + "contract_audit": base_address, + "whale_scan": {**base_address, "required": []}, + "whale_profile": base_address, + "whale_accumulation": base_address, + "wallet": base_address, + "token_analysis": base_address, + "degen_scan": { + "type": "object", + "properties": { + "address": {"type": "string", "description": "Token contract address"}, + "chain": {"type": "string", "description": "Blockchain to query"}, + }, + "required": ["address"], + }, + "smart_money_alpha": { + "type": "object", + "properties": { + "wallet": {"type": "string", "description": "Wallet address to track"}, + "chain": {"type": "string", "description": "Blockchain"}, + "limit": {"type": "integer", "description": "Number of results (default 10)"}, + }, + }, + "sentiment_spike": { + "type": "object", + "properties": { + "token": {"type": "string", "description": "Token name or contract address"}, + "chain": {"type": "string", "description": "Blockchain"}, + }, + }, + "catalog": base_none, + } + # For per-chain variants (e.g., wallet_solana), return base_address with chain pre-filled + # For expanded tools not in schemas dict, return base_address as default + return schemas.get(tool_id, base_address) + + +# ================================================================ +# CORS MIDDLEWARE (for browser-based MCP clients) +# ================================================================ + +# CORS headers are added per-route in response headers. +# APIRouter doesn't support middleware - CORS is handled in each endpoint. + + +# ================================================================ +# MCP DISCOVERY ENDPOINTS +# ================================================================ + + +def _build_discovery(): + """Build the MCP server discovery document.""" + data = _get_tools() + tools = data["prices"] + chains = data["chains"] + fac_count = _get_facilitator_count() + cats = sorted({p.get("category", "analysis") for p in tools.values()}) + + return { + "name": SERVER_NAME, + "version": SERVER_VERSION, + "description": f"{len(tools)} crypto intelligence tools -- real-time scam detection, wallet forensics, whale tracking, contract auditing, market analysis. {len(chains)} blockchains, micropayments via x402.", + "protocol": "mcp", + "protocolVersion": MCP_PROTOCOL_VERSION, + "vendor": { + "name": "Rug Munch Intelligence", + "url": "https://rugmunch.io", + "github": "https://github.com/Rug-Munch-Media-LLC", + }, + "homepage": "https://rugmunch.io", + "documentation": "https://rugmunch.io/mcp-docs", + "repository": "https://github.com/Rug-Munch-Media-LLC/rug-munch-intelligence-mcp", + "endpoint": "https://mcp.rugmunch.io/mcp", + "icon": "https://rugmunch.io/logo.png", + "transports": ["http"], + "authentication": { + "type": "x402", + "description": "Pay-per-use micropayments. 1-5 free trials per tool. Connect wallet for additional free calls. USDC on Base/Solana, USDT/USDC on TRON, BTC via Mempool.space, EUR/SEPA via Asterpay. Full refund if no data returned.", + "discovery_url": "https://mcp.rugmunch.io/.well-known/x402", + "wallet_providers": { + "evm": { + "description": "Any EIP-7702 compatible wallet: MetaMask, Coinbase Wallet, WalletConnect, Rabby, OKX Wallet, Trust Wallet, Frame, Rainbow", + "chains": [ + "base", + "ethereum", + "bsc", + "polygon", + "arbitrum", + "optimism", + "avalanche", + "fantom", + "gnosis", + ], + "facilitators": ["coinbase_cdp", "eip7702", "cloudflare_x402", "payai"], + }, + "solana": { + "description": "Phantom, Solflare, Backpack, Coinbase Wallet, Magic Eden", + "chains": ["solana"], + "facilitators": ["coinbase_cdp", "payai"], + }, + "tron": { + "description": "TronLink, TokenPocket, OKX Wallet", + "chains": ["tron"], + "facilitators": ["tron_selfverify"], + }, + "bitcoin": { + "description": "Any Bitcoin wallet (1-confirmation)", + "chains": ["bitcoin"], + "facilitators": ["bitcoin_selfverify"], + }, + "fiat": { + "description": "EUR/SEPA bank transfer via Asterpay", + "chains": ["sepa"], + "facilitators": ["asterpay"], + }, + }, + }, + "capabilities": {"tools": True, "resources": False, "prompts": False}, + "directories": { + "smithery": "https://smithery.ai/server/@cryptorugmuncher/rug-munch-intelligence", + "glama": "https://glama.ai/mcp/servers/@cryptorugmuncher/rug-munch-intelligence", + "mcp_so": "https://mcp.so/server/rug-munch-intelligence", + "github": "https://github.com/Rug-Munch-Media-LLC/rug-munch-intelligence-mcp", + "huggingface": "https://huggingface.co/cryptorugmunch/rug-munch-intelligence", + }, + "integrations": { + "openai_agents": { + "discovery": "https://mcp.rugmunch.io/.well-known/x402", + "endpoint": "https://mcp.rugmunch.io/mcp", + "protocol": "x402", + "description": "OpenAI Agents SDK with x402 payment support. Automatic 402 handling via coinbase_cdp or eip7702 facilitator.", + }, + "claude_desktop": { + "endpoint": "https://mcp.rugmunch.io/mcp", + "protocol": "mcp-streamable-http", + "description": "Claude Desktop via MCP Streamable HTTP transport. Configure in claude_desktop_config.json.", + }, + "anthropic_agents": { + "discovery": "https://mcp.rugmunch.io/.well-known/x402", + "endpoint": "https://mcp.rugmunch.io/mcp", + "protocol": "x402+mcp", + "description": "Anthropic Agents with x402 micropayments. Use the Streamable HTTP transport.", + }, + "openlang": { + "discovery": "https://mcp.rugmunch.io/.well-known/x402", + "endpoint": "https://mcp.rugmunch.io/mcp", + "protocol": "x402", + "description": "OpenLang MCP client with automatic x402 payment flow.", + }, + "cursor": { + "endpoint": "https://mcp.rugmunch.io/mcp", + "protocol": "mcp-streamable-http", + "description": "Cursor IDE via MCP. Add as MCP server in Settings > MCP.", + }, + }, + "stats": { + "total_tools": len(tools), + "categories": cats, + "chains": sorted(chains.keys()), + "chain_count": len(chains), + "facilitators": fac_count, + "free_trials": "1-5 calls per tool, fingerprint-gated", + "pricing": "$0.01 - $0.40 per call", + "updated_at": datetime.now(UTC).isoformat(), + }, + # Top-level fields for directory scrapers (Glama, mcp.so, Smithery) + "categories": cats, + "blockchains": sorted(chains.keys()), + "facilitator_count": fac_count, + "tools_count": len(tools), + # Compact tool list so directory scrapers see tools immediately + "tools": [ + { + "name": tid, + "description": _desc(tid, t)[:200], + "category": t.get("category", "analysis"), + "price_usd": t.get("price_usd", 0.01), + "trial_free": t.get("trial_free", 1), + } + for tid, t in sorted(tools.items()) + ], + } + + +@router.get("/.well-known/mcp") +@router.get("/.well-known/mcp.json") +async def mcp_discovery(): + return _build_discovery() + + +@router.get("/.well-known/ai-plugin.json") +async def ai_plugin_manifest(): + data = _get_tools() + count = len(data["prices"]) + return { + "schema_version": "v1", + "name_for_human": SERVER_NAME, + "name_for_model": "rug_munch_intelligence", + "description_for_human": f"{SERVER_NAME} -- crypto intelligence: scam detection, wallet forensics, whale tracking, contract auditing. {count} tools, {len(data['chains'])} chains.", + "description_for_model": f"Use for crypto security: check tokens for scams, honeypots, rug pulls. Analyze wallets for PnL, clusters, insider trading. Track whales, smart money, syndicates. Audit smart contracts. Market intelligence: fear & greed, chain health, gas forecasts, DeFi yields, arbitrage. Social signals: Twitter/X sentiment, KOL performance. {count} tools, {len(data['chains'])} chains. Free trials + x402 micropayments.", + "auth": {"type": "none"}, + "api": {"type": "openapi", "url": "https://mcp.rugmunch.io/openapi.json"}, + "logo_url": "https://rugmunch.io/logo.png", + "contact_email": "mcp@rugmunch.io", + "legal_info_url": "https://rugmunch.io/terms", + } + + +@router.get("/llms.txt") +async def llms_txt(): + data = _get_tools() + prices = data["prices"] + chains = data["chains"] + fac_count = _get_facilitator_count() + cats = {} + for _tool_id, pricing in prices.items(): + cat = pricing.get("category", "analysis") + cats[cat] = cats.get(cat, 0) + 1 + cat_lines = [f"- {c.title()} ({n})" for c, n in sorted(cats.items())] + chain_list = ", ".join(sorted(chains.keys())) + + return Response( + content=f"""# Rug Munch Intelligence -- MCP Server +> {len(prices)} crypto intelligence tools. {len(chains)} chains. Free trials + x402 micropayments. + +## Quick Start +- MCP Endpoint: https://mcp.rugmunch.io/mcp +- Discovery: https://mcp.rugmunch.io/.well-known/mcp +- Payment: https://mcp.rugmunch.io/.well-known/x402 +- Docs: https://rugmunch.io/mcp-docs +- GitHub: https://github.com/cryptorugmuncher/rug-munch-intelligence + +## Directory Listings +- Smithery: https://smithery.ai/server/@cryptorugmuncher/rug-munch-intelligence +- Glama: https://glama.ai/mcp/servers/@cryptorugmuncher/rug-munch-intelligence +- mcp.so: https://mcp.so/server/rug-munch-intelligence +- Open WebUI: https://openwebui.com/t/cryptorugmuncher/rug-munch-intelligence + +## How It Works +- Free trial -- 1-5 calls per tool, no payment. Fingerprint-gated anti-abuse. +- Pay per use -- USDC on {len(chains)} chains (Base, Solana, Ethereum, BSC, TRON, Bitcoin, more). $0.01-$0.40 per call. +- Instant refund -- Full refund if tool returns no data. Request within 48h. +- {fac_count} payment facilitators with automatic fallback. Instant settlement on Base, Solana, BNB. + +## Tool Categories +{chr(10).join(cat_lines)} + +## Payment Chains +{chain_list} + +## Integration +curl https://mcp.rugmunch.io/.well-known/mcp +curl https://mcp.rugmunch.io/mcp/tools +""", + media_type="text/plain; charset=utf-8", + ) + + +# ================================================================ +# TOOL CATALOG +# ================================================================ + + +def _build_tools_list(): + """Build the full MCP-compatible tools list.""" + data = _get_tools() + prices = data["prices"] + chains_data = data["chains"] + fac_count = _get_facilitator_count() + + tools = {} + cats = {} + # DataBus tools route through x402-databus, others through x402-tools + from app.routers.x402_databus_tools import X402_TOOL_PRICING as DATABUS_TOOLS + + databus_ids = set(DATABUS_TOOLS.keys()) + from app.routers.x402_tools import TOOL_ALIASES + + for tool_id, pricing in sorted(prices.items()): + cat = pricing.get("category", "analysis") + cats[cat] = cats.get(cat, 0) + 1 + real_tool = TOOL_ALIASES.get(tool_id, tool_id) + endpoint = f"/api/v1/x402-databus/{tool_id}" if tool_id in databus_ids else f"/api/v1/x402-tools/{real_tool}" + tools[tool_id] = { + "name": tool_id, # MCP spec: name is the tool ID + "description": _desc(tool_id, pricing), + "category": cat, + "inputSchema": _input_schema(tool_id), + "price_usd": float(pricing.get("price_usd", 0.01)), + "trial_free": int(pricing.get("trial_free", 1)), + "chains": sorted(chains_data.keys()), + "endpoint": endpoint, + "method": pricing.get("method", "POST") if isinstance(pricing.get("method"), str) else "POST", + "databus": tool_id in databus_ids, + } + + # Add databus-only tools (not in TOOL_PRICES enforcement dict) + for tool_id, pricing in sorted(DATABUS_TOOLS.items()): + if tool_id not in tools: + cat = pricing.get("category", "data") + cats[cat] = cats.get(cat, 0) + 1 + tools[tool_id] = { + "name": tool_id, + "description": _desc(tool_id, pricing) + if pricing.get("description") + else f"{tool_id.replace('_', ' ').title()} -- DataBus-powered crypto intelligence.", + "category": cat, + "inputSchema": _input_schema(tool_id), + "price_usd": float(pricing.get("price_usd", 0.05)), + "trial_free": int(pricing.get("trial_free", 1)), + "chains": sorted(chains_data.keys()), + "endpoint": f"/api/v1/x402-databus/{tool_id}", + "method": "POST", + "databus": True, + } + + return tools, cats, chains_data, fac_count + + +@router.get("/mcp/tools") +async def mcp_tools_list(request: Request): + """Every tool in the RMI platform -- full catalog with MCP-compliant schemas.""" + tools, cats, chains_data, fac_count = _build_tools_list() + + trial_info = None + try: + from app.routers.x402_enforcement import check_trial, get_client_id + + cid = get_client_id(request) + remaining = {} + for tid in _get_tools()["prices"]: + can, rem = check_trial(tid, cid) + if rem > 0 or can: + remaining[tid] = {"can_trial": can, "remaining": rem} + trial_info = { + "client_id": cid[:20] + "...", + "tools_with_trials": len(remaining), + "trials": remaining, + } + except Exception: + pass + + return { + "server": f"{SERVER_NAME} MCP v{SERVER_VERSION}", + "homepage": "https://rugmunch.io", + "github": "https://github.com/cryptorugmuncher/rug-munch-intelligence", + "directories": { + "smithery": "https://smithery.ai/server/@cryptorugmuncher/rug-munch-intelligence", + "glama": "https://glama.ai/mcp/servers/@cryptorugmuncher/rug-munch-intelligence", + }, + "total_tools": len(tools), + "categories": dict(sorted(cats.items())), + "chains": sorted(chains_data.keys()), + "chain_count": len(chains_data), + "facilitators": fac_count, + "pricing": "$0.01-$0.40/call. Most tools $0.05. Bundles save 23-33%.", + "free_trials": "1-5 calls per tool. Fingerprint-gated. Wallet required after 1 free call.", + "payment": { + "protocol": "x402", + "discovery": "/.well-known/x402", + "tokens": ["USDC", "USDT", "BTC", "EUR"], + "chains": sorted(chains_data.keys()), + }, + "refund": "Full refund if tool returns no data. Within 48h via POST /api/v1/x402/refund.", + "tools": tools, + "trial_status": trial_info, + "updated_at": datetime.now(UTC).isoformat(), + } + + +@router.get("/tools") +async def mcp_tools_mcp_router_format(request: Request): + """Serve tools in mcp-router compatible format: {tools: {service: [tools]}}. + + This endpoint is consumed by the x402 Cloudflare Workers (BACKEND_MCP + '/tools'). + It replaces the external mcp-router.rugmunch.io dependency that was returning + HTTP 522 due to Cloudflare proxy loop (Worker -> CF-proxied domain). + """ + tools_data, _cats, _chains_data, _fac_count = _build_tools_list() + + # Group tools by category (matching mcp-router's {serviceName: [tools]} format) + # Each tool has: name, description, parameters(inputSchema), tier, category + services: dict[str, list] = {} + for tool_id, tool_def in tools_data.items(): + svc = tool_def.get("category", "general") + if svc not in services: + services[svc] = [] + services[svc].append( + { + "name": tool_id, + "description": tool_def.get("description", tool_def.get("name", tool_id)), + "parameters": tool_def.get("inputSchema", {}), + "tier": "free" if tool_def.get("trial_free", 0) > 0 else "paid", + "category": svc, + "chains": tool_def.get("chains", []), + "price_usd": tool_def.get("price_usd", 0.01), + "endpoint": tool_def.get("endpoint", f"/api/v1/x402-tools/{tool_id}"), + } + ) + + return {"tools": services} + + +@router.get("/mcp/capabilities") +async def mcp_capabilities(): + data = _get_tools() + fac_count = _get_facilitator_count() + return { + "server": f"{SERVER_NAME} MCP v{SERVER_VERSION}", + "homepage": "https://rugmunch.io", + "github": "https://github.com/cryptorugmuncher/rug-munch-intelligence", + "capabilities": {"tools": True, "resources": False, "prompts": False, "streaming": False}, + "protocols": ["x402"], + "payment": { + "required": False, + "trial_available": True, + "trial_calls": "1-5 per tool", + "paid": f"$0.01-$0.40 via x402 on {len(data['chains'])} chains", + }, + "facilitators": fac_count, + "updated_at": datetime.now(UTC).isoformat(), + } + + +# ================================================================ +# MCP JSON-RPC ENDPOINT (Streamable HTTP) +# ================================================================ + + +@router.post("/mcp") +async def mcp_jsonrpc(request: Request): + """MCP Streamable HTTP transport - handle JSON-RPC requests. + + Methods: initialize, tools/list, tools/call, resources/list, prompts/list, ping + """ + try: + body = await request.json() + except Exception: + return JSONResponse( + status_code=400, + content={ + "jsonrpc": "2.0", + "error": {"code": -32700, "message": "Parse error"}, + "id": None, + }, + ) + + method = body.get("method", "") + req_id = body.get("id") + params = body.get("params", {}) + + # ── initialize ───────────────────────────────────────── + if method == "initialize": + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": { + "tools": {"listChanged": True}, + "resources": {}, + "prompts": {}, + }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + }, + }, + } + ) + + # ── ping ──────────────────────────────────────────────── + if method == "ping": + return JSONResponse({"jsonrpc": "2.0", "id": req_id, "result": {}}) + + # ── tools/list ────────────────────────────────────────── + if method == "tools/list": + tools_data, _cats, _chains_data, _fac_count = _build_tools_list() + tools_list = [] + for tool_id, info in tools_data.items(): + # Dot-notation naming: category.tool_id for Smithery quality score + category = info.get("category", "analysis") + dot_name = f"{category}.{tool_id}" + tools_list.append( + { + "name": dot_name, + "description": info["description"], + "inputSchema": info["inputSchema"], + "outputSchema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Tool-specific result data - varies by tool. Contains analysis, scores, metrics, or fetched data.", + }, + "status": { + "type": "string", + "enum": ["ok", "error", "no_data"], + "description": "Result status: ok (success), error (failure), no_data (no results found - eligible for refund)", + }, + "error": { + "type": "string", + "description": "Error message if status is error", + }, + "metadata": { + "type": "object", + "properties": { + "tool": {"type": "string", "description": "Tool name executed"}, + "chain": {"type": "string", "description": "Blockchain used"}, + "elapsed_ms": { + "type": "number", + "description": "Execution time in milliseconds", + }, + "trial_used": { + "type": "boolean", + "description": "Whether a free trial was consumed", + }, + }, + }, + }, + "required": ["result", "status"], + }, + "annotations": { + "title": info.get("display_name", info["name"].replace("_", " ").title()), + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + "category": info["category"], + "price_usd": info["price_usd"], + "trial_free": info["trial_free"], + "chains": info["chains"], + }, + } + ) + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "result": {"tools": tools_list}, + } + ) + + # ── resources/list ────────────────────────────────────── + if method == "resources/list": + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "result": {"resources": []}, + } + ) + + # ── prompts/list ─────────────────────────────────────── + if method == "prompts/list": + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "result": {"prompts": []}, + } + ) + + # ── ai.smithery/events/list ───────────────────────────── + if method == "ai.smithery/events/list": + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "result": {"events": []}, + } + ) + + # ── tools/call ────────────────────────────────────────── + if method == "tools/call": + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + + # Resolve dot-notation names (category.tool_id) to internal tool_id + internal_name = tool_name + if "." in tool_name: + internal_name = tool_name.split(".", 1)[1] # strip category prefix + # If the stripped name doesn't exist in prices, try the original + data_check = _get_tools() + if internal_name not in data_check["prices"] and tool_name in data_check["prices"]: + internal_name = tool_name # fall back to original name + + # Validate tool exists + data = _get_tools() + if internal_name not in data["prices"]: + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "error": { + "code": -32601, + "message": f"Tool '{tool_name}' not found. {len(data['prices'])} tools available.", + }, + } + ) + + # Proxy to internal endpoint + import httpx + + url = f"http://localhost:8000/api/v1/x402-tools/{internal_name}" + headers = {"Content-Type": "application/json", "User-Agent": "RMI-MCP-JSONRPC/3.1"} + # Forward payment and identity headers from the original request + for h in ( + "x-pay", + "X-Pay", + "X-Device-Id", + "x-device-id", + "Authorization", + "x-wallet-address", + "X-Wallet-Address", + "x-turnstile-token", + "X-Turnstile-Token", + ): + val = request.headers.get(h) + if val: + headers[h] = val + for h in ("X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"): + val = request.headers.get(h) + if val: + headers[h] = val + + try: + async with httpx.AsyncClient(timeout=45) as client: + resp = await client.post(url, json=arguments, headers=headers) + if resp.status_code == 402: + # Payment required - return the payment info as tool result + result = resp.json() + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result)}], + "isError": False, + }, + } + ) + result = ( + resp.json() if "application/json" in (resp.headers.get("content-type", "")) else {"data": resp.text} + ) + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result)}], + "isError": resp.status_code >= 400, + }, + } + ) + except httpx.ConnectError: + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32000, "message": "Backend unavailable"}, + } + ) + except Exception as e: + logger.error(f"MCP JSON-RPC tools/call failed: {tool_name}: {e}") + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32000, "message": f"Tool execution failed: {str(e)[:200]}"}, + } + ) + + # ── Unknown method ────────────────────────────────────── + return JSONResponse( + status_code=400, + content={ + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + }, + ) + + +@router.options("/mcp") +async def mcp_options(): + """CORS preflight for MCP endpoint.""" + return JSONResponse( + content={}, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Pay, X-Device-Id, X-Wallet-Address", + "Access-Control-Max-Age": "86400", + }, + ) + + +# ================================================================ +# TOOL EXECUTION (REST API) +# ================================================================ + + +@router.post("/mcp/call/{tool_id}") +async def mcp_call_tool(tool_id: str, request: Request): + """Execute any tool. Requires x402 payment or free trial.""" + data = _get_tools() + if tool_id not in data["prices"] and tool_id not in ("list", "tools", "catalog"): + raise HTTPException( + status_code=404, + detail=f"Tool '{tool_id}' not found. {len(data['prices'])} tools available -- see /mcp/tools", + ) + + try: + body = await request.json() if request.headers.get("content-type") == "application/json" else {} + except Exception: + body = {} + + import httpx + + url = f"http://localhost:8000/api/v1/x402-tools/{tool_id}" + try: + headers = {} + for h in ( + "x-pay", + "X-Pay", + "X-Device-Id", + "x-device-id", + "User-Agent", + "Authorization", + "x-wallet-address", + "X-Wallet-Address", + "x-turnstile-token", + "X-Turnstile-Token", + ): + val = request.headers.get(h) + if val: + headers[h] = val + if "User-Agent" not in headers: + headers["User-Agent"] = "RMI-MCP-Proxy/3.1" + for h in ("X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"): + val = request.headers.get(h) + if val: + headers[h] = val + ct = request.headers.get("content-type") + if ct: + headers["content-type"] = ct + + async with httpx.AsyncClient(timeout=45) as client: + resp = await client.post(url, json=body, headers=headers) + result = ( + resp.json() if "application/json" in (resp.headers.get("content-type", "")) else {"data": resp.text} + ) + rh = {} + for h in ( + "X-RMI-Payment", + "X-RMI-Trial", + "X-RMI-Trial-Remaining", + "X-RMI-Refund-Flagged", + ): + if resp.headers.get(h): + rh[h] = resp.headers[h] + rh["Access-Control-Allow-Origin"] = "*" + return ( + JSONResponse(content=result, status_code=resp.status_code, headers=rh) + if rh + else JSONResponse(content=result, status_code=resp.status_code) + ) + except httpx.ConnectError: + raise HTTPException(status_code=502, detail="Backend unavailable") from None + except Exception as e: + logger.error(f"MCP tool failed: {tool_id}: {e}") + raise HTTPException(status_code=502, detail=f"Tool execution failed: {str(e)[:200]}") from e + + +# ═══════════════════════════════════════════════════════════════════════════ +# PLATFORM MANIFEST - Auto-updating source of truth +# ═══════════════════════════════════════════════════════════════════════════ + + +@router.get("/mcp/manifest") +async def platform_manifest(): + """Complete platform manifest - auto-generated from live tool counts.""" + from app.caching_shield.platform_manifest import get_platform_manifest + + return get_platform_manifest() + + +@router.get("/mcp/skills") +async def agent_skills(): + """Agent skills, workflows, anti-abuse rules, and starter prompts.""" + from app.caching_shield.agent_skills import get_agent_skills + + return get_agent_skills() + + +@router.get("/mcp/membership") +async def membership_plans(): + """Membership tiers, scan packs, streams, research, batch processing.""" + from app.caching_shield.membership_plans import get_membership_catalog + + return get_membership_catalog() + + +# ═══════════════════════════════════════════════════════════════════════════ +# EARNINGS DASHBOARD +# ═══════════════════════════════════════════════════════════════════════════ + + +@router.get("/mcp/earnings") +async def earnings_dashboard(): + """Live earnings dashboard - wallet balances, revenue by source.""" + from app.caching_shield.earnings_tracker import fetch_wallet_earnings, get_earnings_report + + wallets = await fetch_wallet_earnings() + report = get_earnings_report() + return {**report, "wallet_balances": wallets} + + +@router.get("/mcp/earnings/wallets") +async def earnings_wallets(): + """Current payment wallet balances.""" + from app.caching_shield.earnings_tracker import fetch_wallet_earnings + + return await fetch_wallet_earnings() + + +@router.get("/mcp/earnings/sources") +async def earnings_by_source(): + """Revenue broken down by tool, chain, and facilitator.""" + from app.caching_shield.earnings_tracker import get_revenue_by_source + + return get_revenue_by_source() + + +# ═══════════════════════════════════════════════════════════════════════════ +# DAILY MARKET RUNDOWN +# ═══════════════════════════════════════════════════════════════════════════ + + +@router.get("/mcp/news") +async def mcp_news_feed( + category: str | None = None, + sentiment: str | None = None, + source: str | None = None, + limit: int = 50, + offset: int = 0, +): + """Aggregated crypto news with sentiment analysis.""" + from app.caching_shield.market_rundown import get_articles + + return await get_articles( + category=category or "", + sentiment=sentiment or "", + source=source or "", + limit=limit, + offset=offset, + ) + + +@router.get("/mcp/news/summary") +async def market_summary(force: bool = False): + """AI-generated daily market rundown (DeepSeek V4 Pro, cached 24h).""" + from app.caching_shield.market_rundown import generate_market_summary + + return await generate_market_summary(force=force) + + +@router.get("/mcp/news/categories") +async def mcp_news_categories(): + """Available news categories.""" + from app.caching_shield.market_rundown import get_categories + + return {"categories": get_categories()} + + +@router.get("/mcp/news/sources") +async def news_sources(): + """Available news sources.""" + from app.caching_shield.market_rundown import get_sources + + return {"sources": get_sources()} + + +@router.post("/mcp/news/vote") +async def mcp_news_vote(data: dict): + """Vote up/down on an article.""" + from app.caching_shield.market_rundown import vote + + return await vote(data.get("article_id", ""), data.get("direction", "up")) + + +@router.post("/mcp/news/comment") +async def mcp_news_comment(data: dict): + """Add a comment to an article.""" + from app.caching_shield.market_rundown import comment + + return await comment(data.get("article_id", ""), data.get("user", "anon"), data.get("text", "")) + + +@router.get("/mcp/news/comments/{article_id}") +async def mcp_news_comments(article_id: str): + """Get comments for an article.""" + from app.caching_shield.market_rundown import get_comments + + return await get_comments(article_id) + + +@router.get("/mcp/daily-data") +async def daily_market_data(): + """Enhanced daily data - price action, sentiment, security, whales, prediction markets.""" + from app.caching_shield.daily_data import get_daily_rundown_data + + return await get_daily_rundown_data() + + +# ═══════════════════════════════════════════════════════════════════════════ +# RMI NEWS NETWORK - 30 sources, community interaction +# ═══════════════════════════════════════════════════════════════════════════ + + +@router.get("/news/feed") +async def news_feed( + category: str | None = None, + sentiment: str | None = None, + tier: int | None = None, + sort: str = "latest", + limit: int = 50, + offset: int = 0, + impact: str | None = None, + source: str | None = None, +): + """Main news feed with all filters.""" + from app.caching_shield.news_network import fetch_all, get_feed + + await fetch_all(max_per_source=5) + return get_feed( + category=category or "", + sentiment=sentiment or "", + tier=tier or 0, + sort=sort, + limit=limit, + offset=offset, + impact=impact or "", + source=source or "", + ) + + +@router.get("/news/categories") +async def news_categories(): + from app.caching_shield.news_network import fetch_all, get_categories + + await fetch_all(max_per_source=3) + return {"categories": get_categories()} + + +@router.post("/news/vote") +async def news_vote(data: dict): + from app.caching_shield.news_network import vote_article + + return vote_article(data.get("article_id", ""), data.get("direction", "up")) + + +@router.post("/news/comment") +async def news_comment(data: dict): + from app.caching_shield.news_network import add_comment + + return add_comment(data.get("article_id", ""), data.get("user", "anon"), data.get("text", "")) + + +@router.get("/news/comments/{article_id}") +async def news_comments(article_id: str): + from app.caching_shield.news_network import get_comments + + return {"comments": get_comments(article_id)} + + +@router.post("/news/bookmark") +async def news_bookmark(data: dict): + from app.caching_shield.news_network import bookmark + + return bookmark(data.get("article_id", "")) + + +@router.get("/news/search") +async def news_search(q: str = "", limit: int = 20): + from app.caching_shield.news_network import search_articles + + return {"results": search_articles(q, limit)} + + +@router.get("/news/daily-data") +async def daily_data(): + from app.caching_shield.daily_data import get_daily_rundown_data + + return await get_daily_rundown_data() + + +# ═══════════════════════════════════════════════════════════════════════════ +# SOCIAL FEED - X/Twitter + Reddit +# ═══════════════════════════════════════════════════════════════════════════ + + +@router.get("/news/social") +async def social_feed(limit_twitter: int = 30, limit_reddit: int = 20): + """Combined X/Twitter + Reddit crypto feed - cached, Nitter fallback.""" + from app.caching_shield.social_feed import get_social_feed + + return await get_social_feed(limit_twitter, limit_reddit) + + +@router.get("/news/social/twitter") +async def twitter_feed(limit: int = 30): + """Top 50 crypto X/Twitter accounts - via Nitter (free, cached).""" + from app.caching_shield.social_feed import get_twitter_feed + + return await get_twitter_feed(limit) + + +@router.get("/news/social/reddit") +async def reddit_feed(limit: int = 20): + """Top crypto subreddits - free, no auth.""" + from app.caching_shield.social_feed import get_reddit_feed + + return await get_reddit_feed(limit) + + +@router.get("/news/social/accounts") +async def social_accounts(): + """List of monitored X accounts with profile pics.""" + from app.caching_shield.social_feed import get_top_accounts + + return {"accounts": get_top_accounts()} diff --git a/app/_archive/legacy_2026_07/meme_intelligence.py b/app/_archive/legacy_2026_07/meme_intelligence.py new file mode 100644 index 0000000..7201df4 --- /dev/null +++ b/app/_archive/legacy_2026_07/meme_intelligence.py @@ -0,0 +1,423 @@ +""" +Meme Intelligence Platform - Meme Coin Tracking, Smart Money in Memes, +Big Wins/Losses, KOL Scorecards, Social Monitoring. + +Integrations: +- DexScreener: meme token launches, trending +- LunarCrush: social sentiment, social dominance +- X/Twitter: KOL posts, viral content +- Telegram: channel monitoring, group sentiment +- Arkham: whale tracking in memes +- Birdeye: Solana meme tokens +- Pump.fun: new meme launches +""" + +import logging +import os + +from dotenv import load_dotenv + +load_dotenv("/app/.env", override=True) +from datetime import UTC, datetime, timedelta # noqa: E402 + +logger = logging.getLogger(__name__) + +# ── Meme Intelligence Data Structures ───────────────────────── + +MEME_CHAINS = ["solana", "ethereum", "base", "bsc", "arbitrum"] + +MEME_CATEGORIES = { + "dog": ["dog", "doge", "shib", "akita", "kishu"], + "cat": ["cat", "pepe", "mog", "meow"], + "politi": ["trump", "biden", "maga", "politics"], + "celeb": ["celeb", "influencer", "famous"], + "ai": ["ai", "gpt", "neural", "chat"], + "gaming": ["game", "gaming", "nft", "metaverse"], + "other": [], +} + +# KOL Database Schema +KOL_DATABASE = { + # Example structure - populate from research + "twitter_handles": [], + "telegram_channels": [], + "wallet_addresses": [], + "track_record": {}, # past calls, win rate + "follower_counts": {}, + "engagement_rates": {}, +} + +# ── Meme Token Intelligence ──────────────────────────────────── + + +async def get_meme_trending(limit: int = 50) -> list[dict]: + """Get trending meme tokens across chains.""" + from app.unified_provider import get_unified_provider + + provider = get_unified_provider() + memes = [] + + for chain in MEME_CHAINS: + try: + # Get trending from DexScreener + trending = await provider.get_dexscreener_trending(chain) + + for token in trending[:20]: + # Classify as meme based on name/symbol + category = classify_meme_category( + token.get("baseToken", {}).get("symbol", ""), + token.get("baseToken", {}).get("name", ""), + ) + + if category != "other": + memes.append( + { + "address": token.get("baseToken", {}).get("address"), + "symbol": token.get("baseToken", {}).get("symbol"), + "name": token.get("baseToken", {}).get("name"), + "chain": chain, + "category": category, + "price_usd": token.get("priceUsd"), + "volume_24h": token.get("volume", {}).get("h24"), + "price_change_24h": token.get("priceChange", {}).get("h24"), + "liquidity_usd": token.get("liquidity", {}).get("usd"), + "fdv": token.get("fdv"), + "pair_age_hours": token.get("pairCreatedAt"), + "is_meme": True, + } + ) + except Exception as e: + logger.debug(f"Error getting meme trending for {chain}: {e}") + + # Sort by volume + memes.sort(key=lambda x: x.get("volume_24h", 0), reverse=True) + + return memes[:limit] + + +async def get_smart_money_in_memes(limit: int = 20) -> list[dict]: + """Track known smart money wallets trading memes.""" + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + # Query for smart money activities in meme tokens + result = ( + supabase.table("smart_money_activities") + .select(""" + *, + wallets ( + wallet_address, + wallet_label, + wallet_category, + win_rate, + total_pnl_usd + ) + """) + .eq("is_meme", True) + .order("amount_usd", desc=True) + .limit(limit) + .execute() + ) + + return result.data or [] + + +async def get_meme_wins_losses(period: str = "24h") -> dict: + """Get biggest wins and losses in meme tokens.""" + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + # Calculate time range + if period == "24h": + time_range = datetime.now(UTC) - timedelta(hours=24) + elif period == "7d": + time_range = datetime.now(UTC) - timedelta(days=7) + elif period == "30d": + time_range = datetime.now(UTC) - timedelta(days=30) + else: + time_range = datetime.now(UTC) - timedelta(hours=24) + + # Get biggest wins + wins = ( + supabase.table("whale_movements") + .select("*") + .gte("collected_at", time_range.isoformat()) + .eq("transaction_type", "sell") + .order("amount_usd", desc=True) + .limit(20) + .execute() + ) + + # Get biggest losses + losses = ( + supabase.table("whale_movements") + .select("*") + .gte("collected_at", time_range.isoformat()) + .eq("transaction_type", "buy") + .order("amount_usd", desc=True) + .limit(20) + .execute() + ) + + return { + "period": period, + "wins": wins.data or [], + "losses": losses.data or [], + } + + +def classify_meme_category(symbol: str, name: str) -> str: + """Classify meme token into category.""" + text = f"{symbol} {name}".lower() + + for category, keywords in MEME_CATEGORIES.items(): + if any(kw in text for kw in keywords): + return category + + return "other" + + +# ── KOL Intelligence ────────────────────────────────────────── + + +async def get_kol_scorecard(kol_handle: str) -> dict | None: + """Get KOL scorecard with track record.""" + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + # Get KOL profile + result = supabase.table("kols").select("*").eq("twitter_handle", kol_handle).execute() + + if not result.data: + return None + + kol = result.data[0] + + # Get their past calls + calls = ( + supabase.table("kol_calls") + .select("*") + .eq("kol_id", kol["id"]) + .order("called_at", desc=True) + .limit(50) + .execute() + ) + + # Calculate stats + total_calls = len(calls.data) if calls.data else 0 + winning_calls = len([c for c in (calls.data or []) if c.get("pnl_pct", 0) > 0]) + win_rate = (winning_calls / total_calls * 100) if total_calls > 0 else 0 + + avg_pnl = sum([c.get("pnl_pct", 0) for c in (calls.data or [])]) / total_calls if total_calls > 0 else 0 + + return { + "kol": kol, + "stats": { + "total_calls": total_calls, + "winning_calls": winning_calls, + "win_rate": round(win_rate, 2), + "average_pnl": round(avg_pnl, 2), + "follower_count": kol.get("follower_count"), + "engagement_rate": kol.get("engagement_rate"), + }, + "recent_calls": calls.data[:10] if calls.data else [], + } + + +async def get_top_kols_by_category(category: str = "memes", limit: int = 20) -> list[dict]: + """Get top KOLs by category with scorecards.""" + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + result = ( + supabase.table("kols") + .select("*") + .eq("primary_category", category) + .order("win_rate", desc=True) + .order("follower_count", desc=True) + .limit(limit) + .execute() + ) + + return result.data or [] + + +# ── Social Monitoring ───────────────────────────────────────── + + +async def monitor_social_sentiment(token_address: str) -> dict: + """Monitor social sentiment for a token.""" + # LunarCrush integration + try: + from app.lunarcrush_connector import get_lunarcrush_connector + + lc = get_lunarcrush_connector() + + sentiment = await lc.get_sentiment(token_address) + + return { + "token": token_address, + "social_volume": sentiment.get("social_volume"), + "social_dominance": sentiment.get("social_dominance"), + "sentiment_score": sentiment.get("sentiment_score"), + "mentions_24h": sentiment.get("mentions_24h"), + "mentions_change_24h": sentiment.get("mentions_change_24h"), + } + except Exception: + return {"error": "LunarCrush not available"} + + +async def get_viral_crypto_posts(hours: int = 24) -> list[dict]: + """Get viral crypto posts from X/Twitter.""" + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + time_range = datetime.now(UTC) - timedelta(hours=hours) + + result = ( + supabase.table("viral_posts") + .select("*") + .gte("posted_at", time_range.isoformat()) + .order("engagement_score", desc=True) + .limit(50) + .execute() + ) + + return result.data or [] + + +# ── Hack/Drain Alerts ──────────────────────────────────────── + + +async def get_recent_hacks_drains(hours: int = 24) -> list[dict]: + """Get recent hacks and drains from monitoring.""" + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + time_range = datetime.now(UTC) - timedelta(hours=hours) + + result = ( + supabase.table("security_alerts") + .select("*") + .gte("detected_at", time_range.isoformat()) + .in_("alert_type", ["hack", "drain", "exploit", "rugpull"]) + .order("amount_usd", desc=True) + .limit(50) + .execute() + ) + + return result.data or [] + + +async def format_hack_alert_for_social(hack_data: dict) -> dict: + """Format hack alert for X/Telegram posting.""" + return { + "x_post": f"""🚨 HACK ALERT 🚨 + +Protocol: {hack_data.get("protocol_name", "Unknown")} +Amount: ${hack_data.get("amount_usd", 0):,.0f} +Chain: {hack_data.get("chain", "Unknown")} +Type: {hack_data.get("attack_type", "Unknown")} + +{hack_data.get("description", "")[:200]} + +#CryptoSecurity #DeFi #HackAlert""", + "telegram_post": f"""🚨 *HACK ALERT* 🚨 + +*Protocol:* {hack_data.get("protocol_name", "Unknown")} +*Amount:* ${hack_data.get("amount_usd", 0):,.0f} +*Chain:* {hack_data.get("chain", "Unknown")} +*Type:* {hack_data.get("attack_type", "Unknown")} + +{hack_data.get("description", "")[:500]} + +Stay safe out there! 🔒""", + "severity": hack_data.get("severity", "medium"), + "amount_usd": hack_data.get("amount_usd", 0), + } + + +# ── Wallet Screenshot Generation ────────────────────────────── + + +async def generate_wallet_screenshot(wallet_address: str, pnl_data: dict) -> str: + """Generate wallet PnL screenshot for sharing.""" + # This would use a graphics API (Alibaba, etc.) to generate images + # For now, return placeholder + + return { + "wallet": wallet_address, + "total_pnl": pnl_data.get("total_pnl_usd", 0), + "win_rate": pnl_data.get("win_rate", 0), + "top_wins": pnl_data.get("top_wins", []), + "top_losses": pnl_data.get("top_losses", []), + "image_url": f"/api/v1/images/wallet/{wallet_address}/pnl-summary", + "share_url": f"https://rugmunch.io/wallet/{wallet_address}", + } + + +# ── Content Generation ──────────────────────────────────────── + + +async def generate_marketing_content(content_type: str, data: dict) -> dict: + """Generate marketing content using AI.""" + # This would call Alibaba's AI API for content generation + + templates = { + "win_announcement": """ +🎉 BIG WIN ALERT! 🎉 + +Wallet: {wallet_address[:8]}...{wallet_address[-6:]} +Token: {token_symbol} +Profit: ${pnl_usd:,.0f} ({pnl_pct:.1f}%) + +This whale called it early and rode it all the way up! 🐋 + +Track smart money: https://rugmunch.io/wallet/{wallet_address} + +#Crypto #MemeCoin #SmartMoney +""", + "loss_announcement": """ +💀 LOSS PORN 💀 + +Wallet: {wallet_address[:8]}...{wallet_address[-6:]} +Token: {token_symbol} +Loss: ${pnl_usd:,.0f} ({pnl_pct:.1f}%) + +Oof. Another reminder to take profits! 📉 + +Learn from their mistakes: https://rugmunch.io/wallet/{wallet_address} + +#Crypto #Trading #LossPorn +""", + "kol_scorecard": """ +📊 KOL SCORECARD: @{kol_handle} + +Win Rate: {win_rate:.1f}% +Total Calls: {total_calls} +Avg PnL: {avg_pnl:.1f}% +Followers: {follower_count:,} + +Track their calls: https://rugmunch.io/kol/{kol_handle} + +#CryptoTwitter #KOL #Alpha +""", + } + + template = templates.get(content_type, "") + + # Format template with data + content = template.format(**data) if template else "" + + return { + "content_type": content_type, + "x_post": content[:280], + "telegram_post": content, + "generated_at": datetime.now(UTC).isoformat(), + } diff --git a/app/_archive/legacy_2026_07/moderation.py b/app/_archive/legacy_2026_07/moderation.py new file mode 100644 index 0000000..a5f7ff4 --- /dev/null +++ b/app/_archive/legacy_2026_07/moderation.py @@ -0,0 +1,116 @@ +"""Content Moderation Pipeline - AI-powered spam/scam/NSFW detection for user content.""" + +import os +import re + +import httpx +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/moderation", tags=["moderation"]) + +OLLAMA = os.getenv("OLLAMA_HOST", "http://localhost:11434") + +BLOCKED_PATTERNS = [ + (r"(?i)(buy|sell|trade).*\b(signal|call)\b", "trading_signal"), + (r"(?i)(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b)", "ip_address"), + (r"(?i)\b(0x[a-fA-F0-9]{40})\b.*\b(private.?key|seed.?phrase|mnemonic|password)\b", "wallet_phishing"), + (r"(?i)(airdrop|giveaway|free.*token).*\b(claim|connect|verify)\b", "scam_airdrop"), + (r"(https?://(?!rugmunch\.io|polymarket\.com|dexscreener\.com)[^\s]+)", "external_link"), +] + + +class ModerationRequest(BaseModel): + text: str + user_id: str = "anonymous" + context: str = "comment" # comment, post, review, chat + + +class ModerationResult(BaseModel): + approved: bool + risk_score: int # 0-100 + flags: list[str] + reason: str = "" + + +async def _ai_classify(text: str) -> dict: + """Use Ollama to classify content.""" + try: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.post( + f"{OLLAMA}/api/generate", + json={ + "model": "qwen2.5-coder:7b", + "prompt": f"Classify this crypto-related content as SAFE, SPAM, SCAM, or NSFW. Answer with one word only.\n\nContent: {text[:500]}\n\nClassification:", + "stream": False, + "options": {"num_predict": 5, "temperature": 0.1}, + }, + ) + if r.status_code == 200: + response = r.json().get("response", "").strip().upper() + return {"ai_verdict": response, "ai_used": True} + except Exception: + pass + return {"ai_verdict": "UNKNOWN", "ai_used": False} + + +@router.post("/check") +async def moderate_content(req: ModerationRequest): + """Check content for spam, scams, NSFW, and policy violations.""" + flags = [] + risk = 0 + + # Pattern-based detection + for pattern, flag_type in BLOCKED_PATTERNS: + if re.search(pattern, req.text): + flags.append(flag_type) + risk += 25 + + # Length checks + if len(req.text) < 5: + flags.append("too_short") + risk += 10 + if len(req.text) > 10000: + flags.append("too_long") + risk += 5 + + # AI classification + ai = await _ai_classify(req.text) + ai_verdict = ai["ai_verdict"] + if "SCAM" in ai_verdict: + flags.append("ai_scam") + risk += 40 + elif "SPAM" in ai_verdict: + flags.append("ai_spam") + risk += 25 + elif "NSFW" in ai_verdict: + flags.append("ai_nsfw") + risk += 50 + + approved = risk < 40 + reason = "Content approved" if approved else f"Flagged: {', '.join(flags)}" + + return { + "approved": approved, + "risk_score": min(100, risk), + "flags": flags, + "reason": reason, + "ai_classification": ai_verdict, + } + + +@router.get("/stats") +async def moderation_stats(): + return { + "patterns_checked": len(BLOCKED_PATTERNS), + "ai_model": "qwen2.5-coder:7b", + "categories": [ + "trading_signal", + "wallet_phishing", + "scam_airdrop", + "external_link", + "ai_scam", + "ai_spam", + "ai_nsfw", + ], + } diff --git a/app/_archive/legacy_2026_07/moralis_router.py b/app/_archive/legacy_2026_07/moralis_router.py new file mode 100644 index 0000000..f176eb4 --- /dev/null +++ b/app/_archive/legacy_2026_07/moralis_router.py @@ -0,0 +1,290 @@ +""" +Moralis API Router - Wallet auth (SIWE/SIWS) + Data endpoints. +Key 1: Data API (wallets, tokens, NFTs, whale tracking, streams) +Key 2: Auth API (Sign-In With Ethereum/Solana - Phantom, MetaMask, etc.) +""" + +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/moralis", tags=["moralis"]) + + +# ── Models ─────────────────────────────────────────────────── + + +class AuthChallengeRequest(BaseModel): + address: str + chain: str = "eth" # eth, polygon, bsc, avalanche, arbitrum, optimism, base + domain: str = "rugmunch.io" + + +class SolanaChallengeRequest(BaseModel): + address: str + domain: str = "rugmunch.io" + + +class VerifySignatureRequest(BaseModel): + message: str + signature: str + + +class WalletQuery(BaseModel): + address: str + chain: str = "eth" + limit: int = 20 + + +class StreamCreateRequest(BaseModel): + webhook_url: str + description: str + chains: list[str] = ["0x1"] + address: str | None = None + topic0: list[str] | None = None + + +# ── Auth Endpoints (Key 2 - Wallet Login) ──────────────────── + + +@router.post("/auth/challenge/evm") +async def evm_challenge(req: AuthChallengeRequest): + """Request EVM Sign-In challenge (MetaMask, etc).""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + result = await mc.request_evm_challenge( + address=req.address, + chain=req.chain, + domain=req.domain, + ) + if result: + return {"status": "ok", "challenge": result} + raise HTTPException(status_code=502, detail="Moralis challenge failed") + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + + +@router.post("/auth/challenge/solana") +async def solana_challenge(req: SolanaChallengeRequest): + """Request Solana Sign-In challenge (Phantom, etc).""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + result = await mc.request_solana_challenge( + address=req.address, + domain=req.domain, + ) + if result: + return {"status": "ok", "challenge": result} + raise HTTPException(status_code=502, detail="Moralis challenge failed") + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + + +@router.post("/auth/verify/evm") +async def verify_evm(req: VerifySignatureRequest): + """Verify EVM wallet signature - returns JWT token for authenticated session.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + result = await mc.verify_evm_signature( + message=req.message, + signature=req.signature, + ) + if result: + return {"status": "ok", "auth": result} + raise HTTPException(status_code=401, detail="Signature verification failed") + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + + +@router.post("/auth/verify/solana") +async def verify_solana(req: VerifySignatureRequest): + """Verify Solana wallet signature - returns JWT token for authenticated session.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + result = await mc.verify_solana_signature( + message=req.message, + signature=req.signature, + ) + if result: + return {"status": "ok", "auth": result} + raise HTTPException(status_code=401, detail="Signature verification failed") + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + + +# ── Data Endpoints (Key 1 - Wallet Intelligence) ───────────── + + +@router.post("/wallet/tokens") +async def wallet_tokens(req: WalletQuery): + """Get ERC-20 tokens held by wallet.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + tokens = await mc.get_wallet_tokens(req.address, req.chain) + return {"address": req.address, "chain": req.chain, "tokens": tokens[: req.limit]} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/wallet/nfts") +async def wallet_nfts(req: WalletQuery): + """Get NFTs held by wallet.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + nfts = await mc.get_wallet_nfts(req.address, req.chain, req.limit) + return {"address": req.address, "chain": req.chain, "nfts": nfts} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/wallet/balance") +async def wallet_balance(req: WalletQuery): + """Get native balance (ETH/MATIC/BNB) for wallet.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + balance = await mc.get_wallet_native_balance(req.address, req.chain) + return {"address": req.address, "chain": req.chain, "balance": balance} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/wallet/transfers") +async def wallet_transfers(req: WalletQuery): + """Get token transfer history (whale tracking).""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + transfers = await mc.get_wallet_token_transfers(req.address, req.chain, req.limit) + return {"address": req.address, "chain": req.chain, "transfers": transfers} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/token/{token_address}/price") +async def token_price(token_address: str, chain: str = "eth"): + """Get token price in USD.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + price = await mc.get_token_price(token_address, chain) + return {"token": token_address, "chain": chain, "price": price} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/token/{token_address}/metadata") +async def token_metadata(token_address: str, chain: str = "eth"): + """Get token metadata (name, symbol, decimals, logo).""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + metadata = await mc.get_token_metadata(token_address, chain) + return {"token": token_address, "chain": chain, "metadata": metadata} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/token/{token_address}/holders") +async def token_holders(token_address: str, chain: str = "eth", limit: int = 20): + """Get top token holders (EVM chains).""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + holders = await mc.get_token_holders(token_address, chain, limit) + return {"token": token_address, "chain": chain, "holders": holders} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +# ── Streams Management ─────────────────────────────────────── + + +@router.get("/streams") +async def list_streams(): + """List all Moralis streams (EVM webhooks).""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + streams = await mc.list_streams() + return {"streams": streams} + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/streams/create") +async def create_stream(req: StreamCreateRequest): + """Create a Moralis stream (webhook for EVM events).""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + stream = await mc.create_stream( + webhook_url=req.webhook_url, + description=req.description, + chains=req.chains, + address=req.address, + topic0=req.topic0, + ) + if stream: + return {"status": "created", "stream": stream} + raise HTTPException(status_code=502, detail="Stream creation failed") + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + + +# ── Health ──────────────────────────────────────────────────── + + +@router.get("/health") +async def moralis_health(): + """Moralis connector status.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + return {"status": "ok", "service": "moralis-connector", **mc.status()} + except ImportError: + return { + "status": "ok", + "service": "moralis-connector", + "data_api": False, + "auth_api": False, + } diff --git a/app/_archive/legacy_2026_07/n8n_intelligence_workflows.py b/app/_archive/legacy_2026_07/n8n_intelligence_workflows.py new file mode 100644 index 0000000..763ec33 --- /dev/null +++ b/app/_archive/legacy_2026_07/n8n_intelligence_workflows.py @@ -0,0 +1,438 @@ +""" +n8n Workflow Automation - Market Intelligence & Premium Data Pulls. +Scheduled workflows for efficient data collection from multiple sources. +Runs every 30-60 minutes based on scan volume and data freshness needs. + +Integrations: +- CoinGecko: trending, global metrics, top gainers/losers +- DexScreener: new pairs, trending tokens, volume spikes +- Dune Analytics: custom queries for whale tracking, scam patterns +- Helius: token mints, large transfers, new token deployments +- Moralis: EVM whale movements, new contract deployments +- Arkham: entity labeling, exchange flows +- Nansen: smart money tracking (if available) +- Forta: real-time threat detection +""" + +import logging +from datetime import UTC, datetime + +logger = logging.getLogger(__name__) + +# ── n8n Workflow Definitions ───────────────────────────────── + +N8N_BASE_URL = "https://n8n.rugmunch.io" +N8N_WEBHOOK_URL = f"{N8N_BASE_URL}/webhook" + +# Market Intelligence Workflows +MARKET_INTEL_WORKFLOWS = { + "trending_tokens": { + "name": "Trending Tokens Collector", + "schedule": "*/30 * * * *", # Every 30 minutes + "sources": ["coingecko", "dexscreener", "geckoterminal"], + "endpoint": f"{N8N_WEBHOOK_URL}/market/trending", + "description": "Collect trending tokens from multiple DEXs", + "output_table": "market_trending_tokens", + }, + "whale_movements": { + "name": "Whale Movement Tracker", + "schedule": "*/15 * * * *", # Every 15 minutes (high priority) + "sources": ["helius", "moralis", "arkham"], + "endpoint": f"{N8N_WEBHOOK_URL}/market/whales", + "description": "Track large transfers across chains", + "output_table": "whale_movements", + }, + "new_token_deployments": { + "name": "New Token Deployments", + "schedule": "*/10 * * * *", # Every 10 minutes (fast detection) + "sources": ["helius", "moralis", "dexscreener"], + "endpoint": f"{N8N_WEBHOOK_URL}/market/new-tokens", + "description": "Detect new token deployments in real-time", + "output_table": "new_token_deployments", + }, + "volume_spikes": { + "name": "Volume Spike Detector", + "schedule": "*/5 * * * *", # Every 5 minutes (critical) + "sources": ["dexscreener", "geckoterminal", "birdeye"], + "endpoint": f"{N8N_WEBHOOK_URL}/market/volume-spikes", + "description": "Detect unusual volume increases", + "output_table": "volume_spikes", + }, + "global_metrics": { + "name": "Market Global Metrics", + "schedule": "0 * * * *", # Every hour + "sources": ["coingecko"], + "endpoint": f"{N8N_WEBHOOK_URL}/market/global", + "description": "Global crypto market metrics", + "output_table": "market_global_metrics", + }, + "defi_protocols": { + "name": "DeFi Protocol Analytics", + "schedule": "0 */2 * * *", # Every 2 hours + "sources": ["defillama", "dune"], + "endpoint": f"{N8N_WEBHOOK_URL}/market/defi", + "description": "DeFi TVL, volume, user metrics", + "output_table": "defi_protocol_metrics", + }, + "nft_market": { + "name": "NFT Market Intelligence", + "schedule": "0 */4 * * *", # Every 4 hours + "sources": ["alchemy", "opensea_api"], + "endpoint": f"{N8N_WEBHOOK_URL}/market/nft", + "description": "NFT floor prices, volume, trends", + "output_table": "nft_market_metrics", + }, +} + +# Premium Intelligence Workflows +PREMIUM_INTEL_WORKFLOWS = { + "smart_money_tracking": { + "name": "Smart Money Tracker", + "schedule": "*/30 * * * *", # Every 30 minutes + "sources": ["nansen", "arkham", "dune"], + "endpoint": f"{N8N_WEBHOOK_URL}/premium/smart-money", + "description": "Track known smart money wallets", + "output_table": "smart_money_activities", + "tier": "premium", + }, + "exchange_flows": { + "name": "Exchange Flow Analysis", + "schedule": "*/15 * * * *", # Every 15 minutes + "sources": ["arkham", "dune", "glassnode"], + "endpoint": f"{N8N_WEBHOOK_URL}/premium/exchange-flows", + "description": "Track exchange inflows/outflows", + "output_table": "exchange_flows", + "tier": "premium", + }, + "insider_trading": { + "name": "Insider Trading Detection", + "schedule": "*/20 * * * *", # Every 20 minutes + "sources": ["dune", "arkham", "helius"], + "endpoint": f"{N8N_WEBHOOK_URL}/premium/insider", + "description": "Detect potential insider trading patterns", + "output_table": "insider_trading_alerts", + "tier": "premium_plus", + }, + "launchpad_monitor": { + "name": "Launchpad Monitor", + "schedule": "*/10 * * * *", # Every 10 minutes + "sources": ["dexscreener", "pumpfun_api", "geckoterminal"], + "endpoint": f"{N8N_WEBHOOK_URL}/premium/launchpad", + "description": "Monitor new launches across platforms", + "output_table": "launchpad_monitoring", + "tier": "premium", + }, + "cluster_analysis": { + "name": "Cluster Pattern Analysis", + "schedule": "0 */2 * * *", # Every 2 hours + "sources": ["internal_clustering", "dune"], + "endpoint": f"{N8N_WEBHOOK_URL}/premium/clusters", + "description": "Deep cluster pattern analysis", + "output_table": "cluster_patterns", + "tier": "premium_plus", + }, +} + +# Security Intelligence Workflows +SECURITY_INTEL_WORKFLOWS = { + "scam_detection": { + "name": "Real-time Scam Detection", + "schedule": "*/5 * * * *", # Every 5 minutes (critical) + "sources": ["forta", "internal_ml", "community_reports"], + "endpoint": f"{N8N_WEBHOOK_URL}/security/scams", + "description": "Detect new scam patterns", + "output_table": "scam_alerts", + "priority": "critical", + }, + "rugpull_detection": { + "name": "Rugpull Detection", + "schedule": "*/2 * * * *", # Every 2 minutes (ultra-critical) + "sources": ["internal_ml", "liquidity_monitor"], + "endpoint": f"{N8N_WEBHOOK_URL}/security/rugpulls", + "description": "Detect rugpull patterns in real-time", + "output_table": "rugpull_alerts", + "priority": "critical", + }, + "contract_vulnerabilities": { + "name": "Contract Vulnerability Scanner", + "schedule": "0 * * * *", # Every hour + "sources": ["slither", "mythril", "internal_scanner"], + "endpoint": f"{N8N_WEBHOOK_URL}/security/vulnerabilities", + "description": "Scan new contracts for vulnerabilities", + "output_table": "contract_vulnerabilities", + "priority": "high", + }, + "phishing_detection": { + "name": "Phishing Domain Detection", + "schedule": "0 */6 * * *", # Every 6 hours + "sources": ["guardian", "cryptoscamdb", "community"], + "endpoint": f"{N8N_WEBHOOK_URL}/security/phishing", + "description": "Detect new phishing domains", + "output_table": "phishing_domains", + "priority": "high", + }, +} + + +# ── n8n Workflow Execution ──────────────────────────────────── + + +async def trigger_n8n_workflow(workflow_name: str, data: dict | None = None) -> bool: + """Trigger an n8n workflow via webhook.""" + import httpx + + # Find workflow config + all_workflows = { + **MARKET_INTEL_WORKFLOWS, + **PREMIUM_INTEL_WORKFLOWS, + **SECURITY_INTEL_WORKFLOWS, + } + workflow = all_workflows.get(workflow_name) + + if not workflow: + logger.error(f"Workflow not found: {workflow_name}") + return False + + webhook_url = workflow["endpoint"] + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + webhook_url, + json={ + "workflow": workflow_name, + "triggered_at": datetime.now(UTC).isoformat(), + "data": data or {}, + }, + ) + + if response.status_code in (200, 201, 202): + logger.info(f"Triggered workflow: {workflow_name}") + return True + else: + logger.error(f"Workflow trigger failed: {workflow_name} - {response.status_code}") + return False + + except Exception as e: + logger.error(f"Workflow trigger error: {workflow_name} - {e}") + return False + + +async def execute_market_intelligence_pull(workflow_name: str): + """Execute a market intelligence data pull.""" + workflow = MARKET_INTEL_WORKFLOWS.get(workflow_name) + if not workflow: + return + + logger.info(f"Executing market intel pull: {workflow['name']}") + + # Collect data from sources + collected_data = { + "workflow": workflow_name, + "sources": workflow["sources"], + "collected_at": datetime.now(UTC).isoformat(), + "data": {}, + } + + # Source-specific collection logic + for source in workflow["sources"]: + try: + if source == "coingecko": + from app.coingecko_connector import get_coingecko_connector + + connector = get_coingecko_connector() + + if "trending" in workflow_name.lower(): + trending = await connector.get_trending() + collected_data["data"]["coingecko_trending"] = trending + elif "global" in workflow_name.lower(): + global_data = await connector.get_global_metrics() + collected_data["data"]["coingecko_global"] = global_data + + elif source == "dexscreener": + from app.unified_provider import get_unified_provider + + provider = get_unified_provider() + + if "trending" in workflow_name.lower(): + trending = await provider.get_dexscreener_trending() + collected_data["data"]["dexscreener_trending"] = trending + elif "volume" in workflow_name.lower(): + spikes = await provider.get_volume_spikes() + collected_data["data"]["dexscreener_spikes"] = spikes + + elif source == "helius": + from app.chain_client import get_chain_client + + client = get_chain_client() + + if "new" in workflow_name.lower(): + new_tokens = await client.get_new_token_mints(limit=50) + collected_data["data"]["helius_new_tokens"] = new_tokens + elif "whale" in workflow_name.lower(): + whales = await client.get_large_transfers(limit=20) + collected_data["data"]["helius_whales"] = whales + + except Exception as e: + logger.error(f"Error collecting from {source}: {e}") + collected_data["data"][source] = {"error": str(e)} + + # Store in Supabase + await _store_intelligence_data(workflow["output_table"], collected_data) + + # Trigger alerts if needed + await _process_intelligence_alerts(workflow_name, collected_data) + + +async def _store_intelligence_data(table_name: str, data: dict): + """Store intelligence data in Supabase.""" + try: + import os + + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + # Insert data + supabase.table(table_name).insert( + { + "data": data, + "collected_at": data.get("collected_at"), + "workflow": data.get("workflow"), + } + ).execute() + + logger.info(f"Stored intelligence data in {table_name}") + + except Exception as e: + logger.error(f"Failed to store intelligence data: {e}") + + +async def _process_intelligence_alerts(workflow_name: str, data: dict): + """Process intelligence data and trigger alerts.""" + # Check for significant findings + if "whale" in workflow_name.lower(): + # Check for unusually large transfers + whales = data.get("data", {}).get("helius_whales", []) + for whale in whales: + amount = whale.get("amount", 0) + if amount > 1000: # >1000 SOL + await _create_alert("whale_movement", whale) + + elif "scam" in workflow_name.lower() or "rugpull" in workflow_name.lower(): + # Immediate alert for security issues + await _create_alert("security_critical", data) + + elif "volume" in workflow_name.lower(): + # Check for unusual volume spikes + spikes = data.get("data", {}).get("dexscreener_spikes", []) + for spike in spikes: + if spike.get("volume_change_pct", 0) > 500: # >500% increase + await _create_alert("volume_spike", spike) + + +async def _create_alert(alert_type: str, data: dict): + """Create an intelligence alert.""" + try: + import os + + from supabase import create_client + + supabase = create_client(os.getenv("SUPABASE_URL"), os.getenv("SUPABASE_KEY")) + + alert_data = { + "alert_type": alert_type, + "severity": "critical" if "security" in alert_type else "high", + "data": data, + "created_at": datetime.now(UTC).isoformat(), + "is_read": False, + } + + supabase.table("intelligence_alerts").insert(alert_data).execute() + + logger.info(f"Created intelligence alert: {alert_type}") + + except Exception as e: + logger.error(f"Failed to create alert: {e}") + + +# ── Dune Analytics Integration ──────────────────────────────── + + +async def execute_dune_query(query_id: str, params: dict | None = None) -> dict | None: + """Execute a Dune Analytics query.""" + import os + + import httpx + + dune_api_key = os.getenv("DUNE_API_KEY", "") + if not dune_api_key: + logger.warning("DUNE_API_KEY not configured") + return None + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + # Execute query + response = await client.post( + f"https://api.dune.com/api/v1/query/{query_id}/execute", + headers={"X-Dune-API-Key": dune_api_key}, + json=params or {}, + ) + + if response.status_code != 200: + logger.error(f"Dune query failed: {response.status_code}") + return None + + execution_id = response.json().get("execution_id") + + # Wait for results + import asyncio + + for _ in range(10): # Max 10 attempts + await asyncio.sleep(2) + + result_response = await client.get( + f"https://api.dune.com/api/v1/execution/{execution_id}/results", + headers={"X-Dune-API-Key": dune_api_key}, + ) + + if result_response.status_code == 200: + result = result_response.json() + if result.get("state") == "QUERY_STATE_COMPLETED": + return result.get("result", {}).get("rows", []) + + return None + + except Exception as e: + logger.error(f"Dune query error: {e}") + return None + + +# Pre-configured Dune queries for RMI +DUNE_QUERIES = { + "ethereum_whale_transfers": { + "query_id": "1234567", # Replace with actual query ID + "description": "Track large ETH transfers from known whale wallets", + "schedule": "*/15 * * * *", + }, + "defi_protocol_volumes": { + "query_id": "2345678", + "description": "Daily DEX volumes across major protocols", + "schedule": "0 * * * *", + }, + "nft_wash_trading": { + "query_id": "3456789", + "description": "Detect potential NFT wash trading patterns", + "schedule": "0 */6 * * *", + }, + "stablecoin_flows": { + "query_id": "4567890", + "description": "Track USDC/USDT flows to/from exchanges", + "schedule": "*/30 * * * *", + }, + "new_contract_deployments": { + "query_id": "5678901", + "description": "New contract deployments with large funding", + "schedule": "*/10 * * * *", + }, +} diff --git a/app/_archive/legacy_2026_07/news_intelligence.py b/app/_archive/legacy_2026_07/news_intelligence.py new file mode 100644 index 0000000..2ef6565 --- /dev/null +++ b/app/_archive/legacy_2026_07/news_intelligence.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +RMI News Intelligence v3 - Industry Best +========================================= +AI-powered news pipeline: categorization, sentiment, trending, briefing. +Uses MiniMax ($20/mo flat) + Ollama Cloud. +""" + +import json +import logging +import os +import urllib.request +from collections import Counter +from datetime import UTC, datetime + +logger = logging.getLogger("rmi.news_v3") +OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", os.getenv("DEEPSEEK_API_KEY", "")) +OLLAMA_URL = "https://ollama.com/v1/chat/completions" + +# Simple in-memory trending tracker +_trending_topics = Counter() +_breaking_alerts = [] + + +def analyze_article(title: str, content: str = "") -> dict: + """Full AI analysis of a news article.""" + text = f"{title} {content[:300]}" + + # Category (fast, cached) + from app.ai_pipeline_v3 import classify_news + + category = classify_news(title, content) + + # Sentiment via MiniMax (batched - 1 call per article is fine at flat rate) + sentiment = "neutral" + try: + k = os.getenv("OLLAMA_API_KEY", "") + if not k: + with open("/app/.env") as f: + for line in f: + if line.startswith("OLLAMA_API_KEY"): + k = line.strip().split("=", 1)[1] + break + if not k: + return {"category": category, "sentiment": "neutral", "is_breaking": False} + body = json.dumps( + { + "model": "deepseek-v4-flash", + "messages": [ + { + "role": "system", + "content": "Classify sentiment: BULLISH BEARISH NEUTRAL. Reply one word only.", + }, + {"role": "user", "content": text[:400]}, + ], + "max_tokens": 10, + "temperature": 0.1, + } + ).encode() + req = urllib.request.Request( + OLLAMA_URL, + data=body, + headers={"Authorization": f"Bearer {k}", "Content-Type": "application/json"}, + ) + resp = urllib.request.urlopen(req, timeout=8) + sentiment = json.loads(resp.read())["choices"][0]["message"]["content"].strip().upper() + if "BULL" in sentiment: + sentiment = "bullish" + elif "BEAR" in sentiment: + sentiment = "bearish" + else: + sentiment = "neutral" + except Exception as e: + logger.warning(f"Sentiment failed: {e}") + + # Track trending topics + for word in title.lower().split(): + if len(word) > 4 and word not in ( + "after", + "before", + "while", + "could", + "would", + "should", + "their", + "there", + "these", + "those", + "about", + "which", + ): + _trending_topics[word] += 1 + + # Detect breaking news + is_breaking = category == "SCAM" or any( + w in text.lower() for w in ["hacked", "exploited", "drained", "rug pulled", "emergency"] + ) + + return { + "category": category, + "sentiment": sentiment, + "is_breaking": is_breaking, + "analyzed_at": datetime.now(UTC).isoformat(), + } + + +def get_trending(limit: int = 10) -> list: + """Get trending topics from recent article analysis.""" + return [{"topic": word, "count": count} for word, count in _trending_topics.most_common(limit)] + + +def get_breaking() -> list: + """Get breaking news alerts.""" + return _breaking_alerts[-10:] + + +def daily_briefing() -> str: + """Generate an AI-powered daily news briefing using Ollama Cloud.""" + trending = get_trending(5) + topics = ", ".join(f"{t['topic']}({t['count']})" for t in trending) + + k = OLLAMA_KEY + body = json.dumps( + { + "model": "deepseek-v4-flash", + "messages": [ + { + "role": "system", + "content": "Write a 3-sentence daily crypto news briefing. Mention trending topics. Professional tone. Under 100 words.", + }, + {"role": "user", "content": f"Trending topics: {topics}"}, + ], + "max_tokens": 150, + "temperature": 0.5, + } + ).encode() + + try: + req = urllib.request.Request( + OLLAMA_URL, + data=body, + headers={"Authorization": f"Bearer {k}", "Content-Type": "application/json"}, + ) + resp = urllib.request.urlopen(req, timeout=15) + return json.loads(resp.read())["choices"][0]["message"]["content"].strip() + except Exception: + return f"Daily briefing: {topics} are trending in crypto news today." + + +def news_search(query: str, articles: list, limit: int = 10) -> list: + """Smart search across articles with relevance ranking.""" + results = [] + q_lower = query.lower() + for a in articles: + score = 0 + if q_lower in a.get("title", "").lower(): + score += 10 + if q_lower in a.get("content", "").lower(): + score += 5 + if q_lower in a.get("category", "").lower(): + score += 3 + if score > 0: + a["relevance"] = score + results.append(a) + return sorted(results, key=lambda x: x.get("relevance", 0), reverse=True)[:limit] diff --git a/app/_archive/legacy_2026_07/nft_detector.py b/app/_archive/legacy_2026_07/nft_detector.py new file mode 100644 index 0000000..e8c5792 --- /dev/null +++ b/app/_archive/legacy_2026_07/nft_detector.py @@ -0,0 +1,65 @@ +"""NFT Wash Trading Detector - detects fake volume in NFT collections.""" + +import os + +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/nft-detector", tags=["nft-detector"]) + +BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000") +RMI_KEY = os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026") + + +@router.get("/check/{collection_address}") +async def check_nft_wash(collection_address: str, chain: str = Query("ethereum")): + """Check NFT collection for wash trading patterns.""" + + # Use DataBus volume authenticity engine with NFT-specific heuristics + score = 85 # Placeholder - real implementation queries DataBus + + # NFT-specific wash trading patterns + patterns = [] + patterns.append({"pattern": "Self-trades", "description": "Same wallet buying and selling", "detected": False}) + patterns.append({"pattern": "Round-trip", "description": "A→B→A within minutes", "detected": False}) + patterns.append({"pattern": "Bid stuffing", "description": "Fake bids to inflate floor price", "detected": False}) + patterns.append( + {"pattern": "Wash pool", "description": "Group of wallets trading among themselves", "detected": False} + ) + + if score < 30: + risk = "CRITICAL" + emoji = "🔴" + elif score < 60: + risk = "HIGH" + emoji = "🟡" + elif score < 80: + risk = "MEDIUM" + emoji = "⚪" + else: + risk = "LOW" + emoji = "🟢" + + return { + "collection": collection_address, + "chain": chain, + "authenticity_score": score, + "risk": risk, + "emoji": emoji, + "wash_patterns": patterns, + "recommendation": "Likely authentic trading" + if score >= 80 + else "Suspicious activity detected - verify before buying", + } + + +@router.get("/floor-check/{collection_address}") +async def check_floor_manipulation(collection_address: str, chain: str = Query("ethereum")): + """Check if NFT floor price is being manipulated.""" + return { + "collection": collection_address, + "floor_price_eth": 0.0, + "bid_ask_spread_pct": 0, + "suspicious_bids_24h": 0, + "manipulation_risk": "LOW", + "note": "NFT data providers integration pending", + } diff --git a/app/_archive/legacy_2026_07/ollama_api.py b/app/_archive/legacy_2026_07/ollama_api.py new file mode 100644 index 0000000..93c673d --- /dev/null +++ b/app/_archive/legacy_2026_07/ollama_api.py @@ -0,0 +1,10 @@ +"""Stub for ollama_api router - local dev fallback.""" + +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/ollama", tags=["ollama"]) + + +@router.get("/health") +async def health(): + return {"status": "stub"} diff --git a/app/_archive/legacy_2026_07/persistent_state.py b/app/_archive/legacy_2026_07/persistent_state.py new file mode 100644 index 0000000..0fa4f7b --- /dev/null +++ b/app/_archive/legacy_2026_07/persistent_state.py @@ -0,0 +1,567 @@ +""" +RMI Persistent State Layer +=========================== +User watchlists, portfolios, saved scans, and alert history. +Redis-backed for speed, with JSON serialization. + +Endpoints: + POST /api/v1/state/watchlist - Add address to watchlist + DELETE /api/v1/state/watchlist/{id} - Remove from watchlist + GET /api/v1/state/watchlist - List watched addresses + POST /api/v1/state/portfolio - Add portfolio entry + DELETE /api/v1/state/portfolio/{id} - Remove portfolio entry + GET /api/v1/state/portfolio - Get portfolio summary + GET /api/v1/state/portfolio/{address} - Get single holding details + POST /api/v1/state/scan - Save a scan configuration + GET /api/v1/state/scan/{id} - Get saved scan + GET /api/v1/state/scan - List saved scans + DELETE /api/v1/state/scan/{id} - Delete saved scan + GET /api/v1/state/alerts - Get alert history + GET /api/v1/state/alerts/unread - Get unread alerts + POST /api/v1/state/alerts/{id}/read - Mark alert as read + GET /api/v1/state/stats - User state statistics + +Identity: Uses X-RMI-Dev-Key header for API key users, or + X-Wallet-Identity header for verified wallet users. + Falls back to fingerprint-based identity. + +Author: RMI Development +Date: 2026-06-05 +""" + +import json +import logging +import time +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from app.core.redis import get_redis + +logger = logging.getLogger("persistent_state") + +router = APIRouter(prefix="/api/v1/state", tags=["persistent-state"]) + + +# ── Identity Helper ─────────────────────────────────────────────── + + +def get_user_identity(request: Request) -> str: + """Get a stable user identity from the request.""" + # Priority 1: Developer API key + dev_key = request.headers.get("X-RMI-Dev-Key", "") or request.headers.get("x-rmi-dev-key", "") + if dev_key: + import hashlib + + return f"dev:{hashlib.sha256(dev_key.encode()).hexdigest()[:16]}" + + # Priority 2: Wallet identity + wallet_id = request.headers.get("X-Wallet-Identity", "") or request.headers.get("x-wallet-identity", "") + if wallet_id: + return f"wallet:{wallet_id}" + + # Priority 3: Fingerprint + fp = request.headers.get("x-fingerprint", "") or request.headers.get("X-Fingerprint", "") + if fp: + return f"fp:{fp[:16]}" + + # Priority 4: IP address + client_ip = request.client.host if request.client else "unknown" + return f"ip:{client_ip}" + + +# ── Redis Helper ───────────────────────────────────────────────── + + +async def add_to_watchlist(entry: WatchlistEntry, request: Request): # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + """Add an address to the user's watchlist.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + watch_id = f"watch:{uuid.uuid4().hex[:12]}" + + watch_data = { + "id": watch_id, + "address": entry.address.lower(), + "chain": entry.chain, + "label": entry.label or entry.address[:8], + "alert_types": entry.alert_types, + "notes": entry.notes, + "added_at": datetime.now(UTC).isoformat(), + "status": "active", + } + + # Store in sorted set (by added time) + r.zadd( + f"rmi:state:{user_id}:watchlist", + {json.dumps(watch_data): time.time()}, + ) + + # Also index by address for quick lookup + r.sadd( + f"rmi:state:{user_id}:watchlist:addresses", + f"{entry.chain}:{entry.address.lower()}", + ) + + r.incr("rmi:state:stats:watchlist_total") + + return JSONResponse( + status_code=201, + content={ + "success": True, + "watch_id": watch_id, + "address": entry.address, + "chain": entry.chain, + "message": f"Added {entry.address} to watchlist", + }, + ) + + +@router.delete("/watchlist/{watch_id}") +async def remove_from_watchlist(watch_id: str, request: Request): + """Remove an address from the watchlist.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + + # Find and remove the entry + entries = r.zrange(f"rmi:state:{user_id}:watchlist", 0, -1) + for entry_json in entries: + entry = json.loads(entry_json) + if entry.get("id") == watch_id: + r.zrem(f"rmi:state:{user_id}:watchlist", entry_json) + addr_key = f"{entry.get('chain', '')}:{entry.get('address', '')}" + r.srem(f"rmi:state:{user_id}:watchlist:addresses", addr_key) + return JSONResponse(content={"success": True, "message": f"Removed {entry.get('address')}"}) + + return JSONResponse(status_code=404, content={"error": "Watchlist entry not found"}) + + +@router.get("/watchlist") +async def get_watchlist(request: Request): + """Get the user's watchlist.""" + r = get_redis() + if not r: + return JSONResponse(content={"watchlist": [], "count": 0}) + + user_id = get_user_identity(request) + entries = r.zrange(f"rmi:state:{user_id}:watchlist", 0, -1) + + watchlist = [] + for entry_json in entries: + entry = json.loads(entry_json) + watchlist.append(entry) + + return JSONResponse( + content={ + "watchlist": watchlist, + "count": len(watchlist), + } + ) + + +# ── Portfolio ───────────────────────────────────────────────────── + + +@router.post("/portfolio") +async def add_portfolio_entry(entry: PortfolioEntry, request: Request): # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + """Add a holding to the user's portfolio.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + holding_id = f"holding:{uuid.uuid4().hex[:12]}" + + holding_data = { + "id": holding_id, + "address": entry.address.lower(), + "chain": entry.chain, + "amount": entry.amount, + "cost_basis": entry.cost_basis, + "label": entry.label or entry.address[:8], + "notes": entry.notes, + "added_at": datetime.now(UTC).isoformat(), + "status": "active", + } + + r.zadd( + f"rmi:state:{user_id}:portfolio", + {json.dumps(holding_data): time.time()}, + ) + + return JSONResponse( + status_code=201, + content={ + "success": True, + "holding_id": holding_id, + "address": entry.address, + "message": f"Added {entry.address} to portfolio", + }, + ) + + +@router.delete("/portfolio/{holding_id}") +async def remove_portfolio_entry(holding_id: str, request: Request): + """Remove a holding from the portfolio.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + + entries = r.zrange(f"rmi:state:{user_id}:portfolio", 0, -1) + for entry_json in entries: + entry = json.loads(entry_json) + if entry.get("id") == holding_id: + r.zrem(f"rmi:state:{user_id}:portfolio", entry_json) + return JSONResponse(content={"success": True, "message": f"Removed {entry.get('address')}"}) + + return JSONResponse(status_code=404, content={"error": "Portfolio entry not found"}) + + +@router.get("/portfolio") +async def get_portfolio(request: Request): + """Get the user's portfolio summary.""" + r = get_redis() + if not r: + return JSONResponse(content={"portfolio": [], "total_value": 0}) + + user_id = get_user_identity(request) + entries = r.zrange(f"rmi:state:{user_id}:portfolio", 0, -1) + + portfolio = [] + total_cost = 0 + for entry_json in entries: + entry = json.loads(entry_json) + portfolio.append(entry) + total_cost += entry.get("cost_basis", 0) * entry.get("amount", 0) + + return JSONResponse( + content={ + "portfolio": portfolio, + "count": len(portfolio), + "total_cost_basis": round(total_cost, 2), + } + ) + + +@router.get("/portfolio/{address}") +async def get_portfolio_holding(address: str, request: Request): + """Get details for a specific portfolio holding.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + entries = r.zrange(f"rmi:state:{user_id}:portfolio", 0, -1) + + for entry_json in entries: + entry = json.loads(entry_json) + if entry.get("address", "").lower() == address.lower(): + return JSONResponse(content={"holding": entry}) + + return JSONResponse(status_code=404, content={"error": "Holding not found"}) + + +# ── Saved Scans ─────────────────────────────────────────────────── + + +@router.post("/scan") +async def save_scan(scan: SavedScan, request: Request): # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + """Save a scan configuration for later use.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + scan_id = f"scan:{uuid.uuid4().hex[:12]}" + + scan_data = { + "id": scan_id, + "name": scan.name, + "tool": scan.tool, + "params": scan.params, + "chain": scan.chain, + "schedule": scan.schedule, + "webhook_url": scan.webhook_url, + "created_at": datetime.now(UTC).isoformat(), + "last_run": None, + "run_count": 0, + "status": "active", + } + + r.set( + f"rmi:state:{user_id}:scan:{scan_id}", + json.dumps(scan_data), + ) + + # Add to scan index + r.zadd( + f"rmi:state:{user_id}:scans", + {scan_id: time.time()}, + ) + + return JSONResponse( + status_code=201, + content={ + "success": True, + "scan_id": scan_id, + "name": scan.name, + "message": f"Saved scan '{scan.name}'", + }, + ) + + +@router.get("/scan/{scan_id}") +async def get_saved_scan(scan_id: str, request: Request): + """Get a saved scan configuration.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + scan_data = r.get(f"rmi:state:{user_id}:scan:{scan_id}") + + if not scan_data: + return JSONResponse(status_code=404, content={"error": "Scan not found"}) + + return JSONResponse(content={"scan": json.loads(scan_data)}) + + +@router.get("/scan") +async def list_saved_scans(request: Request): + """List all saved scans for the user.""" + r = get_redis() + if not r: + return JSONResponse(content={"scans": [], "count": 0}) + + user_id = get_user_identity(request) + scan_ids = r.zrange(f"rmi:state:{user_id}:scans", 0, -1) + + scans = [] + for scan_id in scan_ids: + scan_data = r.get(f"rmi:state:{user_id}:scan:{scan_id}") + if scan_data: + scans.append(json.loads(scan_data)) + + return JSONResponse( + content={ + "scans": scans, + "count": len(scans), + } + ) + + +@router.delete("/scan/{scan_id}") +async def delete_saved_scan(scan_id: str, request: Request): + """Delete a saved scan.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + + # Check ownership + scan_data = r.get(f"rmi:state:{user_id}:scan:{scan_id}") + if not scan_data: + return JSONResponse(status_code=404, content={"error": "Scan not found"}) + + r.delete(f"rmi:state:{user_id}:scan:{scan_id}") + r.zrem(f"rmi:state:{user_id}:scans", scan_id) + + return JSONResponse(content={"success": True, "message": "Scan deleted"}) + + +# ── Alert History ───────────────────────────────────────────────── + + +@router.get("/alerts") +async def get_alert_history( + request: Request, + limit: int = 50, + offset: int = 0, + status: str | None = None, +): + """Get alert history for the user.""" + r = get_redis() + if not r: + return JSONResponse(content={"alerts": [], "count": 0}) + + user_id = get_user_identity(request) + + # Alerts stored as a list (newest first) + alerts = [] + start = offset + end = offset + limit - 1 + + alert_keys = r.lrange(f"rmi:state:{user_id}:alerts", start, end) + for alert_json in alert_keys: + alert = json.loads(alert_json) + if status is None or alert.get("status") == status: + alerts.append(alert) + + total = r.llen(f"rmi:state:{user_id}:alerts") + unread = r.scard(f"rmi:state:{user_id}:alerts:unread") + + return JSONResponse( + content={ + "alerts": alerts, + "total": total, + "unread": unread, + "limit": limit, + "offset": offset, + } + ) + + +@router.get("/alerts/unread") +async def get_unread_alerts(request: Request): + """Get unread alerts.""" + r = get_redis() + if not r: + return JSONResponse(content={"alerts": [], "count": 0}) + + user_id = get_user_identity(request) + unread_ids = r.smembers(f"rmi:state:{user_id}:alerts:unread") + + alerts = [] + for alert_id in list(unread_ids)[:50]: # Limit to 50 + alert_json = r.get(f"rmi:state:{user_id}:alert:{alert_id}") + if alert_json: + alerts.append(json.loads(alert_json)) + + return JSONResponse( + content={ + "alerts": alerts, + "count": len(alerts), + } + ) + + +@router.post("/alerts/{alert_id}/read") +async def mark_alert_read(alert_id: str, request: Request): + """Mark an alert as read.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + + # Remove from unread set + r.srem(f"rmi:state:{user_id}:alerts:unread", alert_id) + + # Update status in alert data + alert_json = r.get(f"rmi:state:{user_id}:alert:{alert_id}") + if alert_json: + alert = json.loads(alert_json) + alert["status"] = "read" + alert["read_at"] = datetime.now(UTC).isoformat() + r.set(f"rmi:state:{user_id}:alert:{alert_id}", json.dumps(alert)) + + return JSONResponse(content={"success": True}) + + +@router.post("/alerts/mark-all-read") +async def mark_all_alerts_read(request: Request): + """Mark all alerts as read.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + r.delete(f"rmi:state:{user_id}:alerts:unread") + + return JSONResponse(content={"success": True, "message": "All alerts marked as read"}) + + +# Helper function to push alerts (called by webhook/cron systems) +def push_alert( + user_id: str, + alert_type: str, + address: str, + message: str, + severity: str = "medium", + data: dict | None = None, + source: str = "rmi_scanner", +): + """Push an alert to a user's alert history. + + Called by webhook handlers, cron jobs, and scanner systems. + """ + r = get_redis() + if not r: + return + + alert_id = f"alert:{uuid.uuid4().hex[:12]}" + + alert_data = { + "id": alert_id, + "type": alert_type, + "address": address, + "message": message, + "severity": severity, + "data": data or {}, + "source": source, + "created_at": datetime.now(UTC).isoformat(), + "status": "unread", + } + + # Add to alert history (newest first) + r.lpush( + f"rmi:state:{user_id}:alerts", + json.dumps(alert_data), + ) + + # Trim to last 1000 alerts + r.ltrim(f"rmi:state:{user_id}:alerts", 0, 999) + + # Add to unread set + r.sadd(f"rmi:state:{user_id}:alerts:unread", alert_id) + + # Store individual alert for retrieval + r.set( + f"rmi:state:{user_id}:alert:{alert_id}", + json.dumps(alert_data), + ex=86400 * 30, # 30 day TTL + ) + + # Update stats + r.incr("rmi:state:stats:alerts_total") + r.incr(f"rmi:state:stats:alerts:{alert_type}") + + +# ── User State Statistics ───────────────────────────────────────── + + +@router.get("/stats") +async def get_user_stats(request: Request): + """Get user state statistics.""" + r = get_redis() + if not r: + return JSONResponse(status_code=500, content={"error": "redis_unavailable"}) + + user_id = get_user_identity(request) + + watchlist_count = r.zcard(f"rmi:state:{user_id}:watchlist") + portfolio_count = r.zcard(f"rmi:state:{user_id}:portfolio") + scan_count = r.zcard(f"rmi:state:{user_id}:scans") + alerts_total = r.llen(f"rmi:state:{user_id}:alerts") + alerts_unread = r.scard(f"rmi:state:{user_id}:alerts:unread") + + return JSONResponse( + content={ + "user_id": user_id, + "watchlist": watchlist_count, + "portfolio": portfolio_count, + "saved_scans": scan_count, + "alerts_total": alerts_total, + "alerts_unread": alerts_unread, + } + ) diff --git a/app/_archive/legacy_2026_07/prediction_monitor.py b/app/_archive/legacy_2026_07/prediction_monitor.py new file mode 100644 index 0000000..73c9316 --- /dev/null +++ b/app/_archive/legacy_2026_07/prediction_monitor.py @@ -0,0 +1,255 @@ +"""Prediction Market Monitor - Polymarket + Kalshi + Manifold tracker. +Premium feature: wallet-level tracking, insider pattern detection, P&L analytics.""" + +import json +import os +from datetime import UTC, datetime + +import httpx +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/prediction-markets", tags=["prediction-markets"]) + +POLYMARKET = "https://clob.polymarket.com" +BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000") +RMI_KEY = os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026") + +# Tracked wallet registry (Redis in prod, in-memory for now) +_tracked_wallets: dict[str, dict] = {} +_insider_flags: list[dict] = [] + +# ═══════════════════════════════════════════ +# MARKET DATA +# ═══════════════════════════════════════════ + + +@router.get("/markets") +async def get_markets( + category: str = Query("crypto", description="crypto|politics|sports|all"), limit: int = Query(10, le=50) +): + """Get active prediction markets from Polymarket.""" + markets = [] + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{POLYMARKET}/markets", params={"limit": limit, "closed": "false"}) + if r.status_code == 200: + data = r.json() + for m in data if isinstance(data, list) else data.get("data", []): + if category == "all" or category in (m.get("category", "") or "").lower(): + markets.append( + { + "id": m.get("id"), + "question": m.get("question"), + "volume_usd": m.get("volumeNum", 0), + "liquidity": m.get("liquidityNum", 0), + "end_date": m.get("endDateIso"), + "outcomes": m.get("outcomes", []), + "outcome_prices": json.loads(m.get("outcomePrices", "[]")), + "category": m.get("category"), + } + ) + except Exception: + pass + + # Also get from DataBus if available + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{BACKEND}/api/v1/databus/fetch/prediction_markets", headers={"X-RMI-Key": RMI_KEY}) + if r.status_code == 200: + db_markets = r.json().get("data", r.json()).get("markets", []) + markets.extend(db_markets) + except Exception: + pass + + markets.sort(key=lambda m: m.get("volume_usd", 0), reverse=True) + return {"markets": markets[:limit], "source": "polymarket+databus", "count": len(markets[:limit])} + + +@router.get("/market/{market_id}") +async def get_market_detail(market_id: str): + """Get detailed market info + recent trades.""" + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get(f"{POLYMARKET}/markets/{market_id}") + if r.status_code == 200: + market = r.json() + return { + "id": market.get("id"), + "question": market.get("question"), + "description": market.get("description"), + "volume_usd": market.get("volumeNum", 0), + "liquidity": market.get("liquidityNum", 0), + "end_date": market.get("endDateIso"), + "outcomes": market.get("outcomes", []), + "outcome_prices": json.loads(market.get("outcomePrices", "[]")), + } + except Exception: + pass + return {"error": "Market not found"} + + +# ═══════════════════════════════════════════ +# WALLET TRACKING (PREMIUM) +# ═══════════════════════════════════════════ + + +@router.post("/track-wallet") +async def track_wallet(wallet_address: str, label: str = "", user_id: str = ""): + """Start tracking a wallet's prediction market activity. Premium feature.""" + wid = f"{user_id}:{wallet_address}" if user_id else wallet_address + _tracked_wallets[wid] = { + "wallet": wallet_address, + "label": label, + "user_id": user_id, + "added_at": datetime.now(UTC).isoformat(), + "positions": [], + "pnl_usd": 0, + "win_rate": 0, + "total_trades": 0, + } + return {"tracked": wid, "wallet": wallet_address, "status": "monitoring"} + + +@router.get("/wallet/{wallet_address}") +async def get_wallet_profile(wallet_address: str): + """Get a tracked wallet's prediction market profile.""" + # Find wallet in tracked registry + wallet_data = None + for wid, data in _tracked_wallets.items(): + if wallet_address in wid: + wallet_data = data + break + + if not wallet_data: + # Try to fetch from DataBus + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + f"{BACKEND}/api/v1/databus/fetch/entity_intel?address={wallet_address}", + headers={"X-RMI-Key": RMI_KEY}, + ) + if r.status_code == 200: + wallet_data = r.json().get("data", r.json()) + except Exception: + pass + + if not wallet_data: + return { + "wallet": wallet_address, + "tracked": False, + "note": "Not tracked. Use POST /api/v1/prediction-markets/track-wallet", + } + + # Check for insider patterns + insider_risk = _analyze_insider_patterns(wallet_address, wallet_data) + + return { + "wallet": wallet_address, + "tracked": True, + "label": wallet_data.get("label", ""), + "positions": wallet_data.get("positions", []), + "pnl_usd": wallet_data.get("pnl_usd", 0), + "win_rate": wallet_data.get("win_rate", 0), + "total_trades": wallet_data.get("total_trades", 0), + "insider_risk": insider_risk, + } + + +# ═══════════════════════════════════════════ +# INSIDER DETECTION (PREMIUM) +# ═══════════════════════════════════════════ + + +def _analyze_insider_patterns(wallet: str, data: dict) -> dict: + """Analyze wallet for insider trading patterns.""" + risk = 0 + flags = [] + + trades = data.get("positions", []) + if not trades: + return {"risk": "unknown", "score": 0, "flags": []} + + # Pattern 1: Trades right before major price moves + pre_move_trades = 0 + for trade in trades: + price_before = trade.get("price_before_move", trade.get("avg_price", 0)) + price_after = trade.get("price_after_move", trade.get("current_price", 0)) + if price_before > 0 and abs(price_after - price_before) / price_before > 0.3: + pre_move_trades += 1 + + if pre_move_trades > 3: + risk += 30 + flags.append(f"{pre_move_trades} trades before >30% price moves") + + # Pattern 2: High win rate (>80%) with significant volume + win_rate = data.get("win_rate", 0) + total_trades = data.get("total_trades", 0) + volume = data.get("pnl_usd", 0) + + if win_rate > 80 and total_trades > 20 and volume > 10000: + risk += 25 + flags.append(f"{win_rate}% win rate across {total_trades} trades - statistically anomalous") + + # Pattern 3: Trades on markets with low public interest + low_interest_trades = sum(1 for t in trades if t.get("market_volume", 0) < 1000) + if low_interest_trades > 5 and win_rate > 70: + risk += 20 + flags.append(f"{low_interest_trades} trades on low-volume markets with {win_rate}% win rate") + + # Pattern 4: Consistent last-minute entries before resolution + last_minute = sum(1 for t in trades if t.get("hours_before_resolution", 999) < 1) + if last_minute > 3: + risk += 15 + flags.append(f"{last_minute} trades within 1 hour of resolution - possible info leak") + + if risk >= 60: + level = "CRITICAL" + elif risk >= 35: + level = "HIGH" + elif risk >= 15: + level = "MEDIUM" + else: + level = "LOW" + + return {"risk": level, "score": min(100, risk), "flags": flags} + + +@router.get("/insider-leaderboard") +async def insider_leaderboard(min_risk: int = Query(35, le=100), limit: int = Query(10, le=25)): + """Get wallets flagged for potential insider trading. Premium feature.""" + flagged = [] + for wid, data in list(_tracked_wallets.items())[:50]: + analysis = _analyze_insider_patterns(wid, data) + if analysis["score"] >= min_risk: + flagged.append( + { + "wallet": data["wallet"], + "label": data.get("label", "Unlabeled"), + "insider_risk": analysis["risk"], + "risk_score": analysis["score"], + "flags": analysis["flags"], + "pnl_usd": data.get("pnl_usd", 0), + "win_rate": data.get("win_rate", 0), + } + ) + + flagged.sort(key=lambda w: w["risk_score"], reverse=True) + return {"flagged_wallets": flagged[:limit], "total_flagged": len(flagged)} + + +@router.get("/market-snapshot") +async def market_snapshot(): + """Quick overview of prediction markets + insider activity.""" + markets = (await get_markets("all", 5))["markets"] + top_markets = [{"question": m["question"][:80], "volume_usd": m["volume_usd"]} for m in markets[:5]] + + insider_count = len([w for w in _tracked_wallets.values() if w.get("insider_flag", False)]) + + return { + "timestamp": datetime.now(UTC).isoformat(), + "active_markets": len(markets), + "top_markets": top_markets, + "tracked_wallets": len(_tracked_wallets), + "insider_flags": insider_count, + "source": "Polymarket + DataBus", + } diff --git a/app/_archive/legacy_2026_07/price_consensus.py b/app/_archive/legacy_2026_07/price_consensus.py new file mode 100644 index 0000000..7eb0c30 --- /dev/null +++ b/app/_archive/legacy_2026_07/price_consensus.py @@ -0,0 +1,634 @@ +""" +Price Consensus Engine - Multi-Source Aggregation with MAD Outlier Detection. + +Queries 7+ price sources in parallel, applies Median Absolute Deviation (MAD) +outlier filtering (z-score > 3 = outlier), and computes a weighted mean price +using source reliability scores. + +Sources: DexScreener, GeckoTerminal, Jupiter (Solana), DIA, CoinGecko, + CryptoCompare, Coinpaprika - all free tier, no paid keys required. + +Depends on: httpx, numpy (for median/percentile), optional env keys. +""" + +import asyncio +import logging +import os +import time +from dataclasses import dataclass, field +from typing import Any + +import httpx +import numpy as np + +logger = logging.getLogger(__name__) + +# ── Source Reliability Scores (0.0-1.0, higher = more trusted) ───────────── + +# These are initial weights based on historical accuracy, API stability, +# and data freshness. They can be adjusted via _source_stats over time. +DEFAULT_SOURCE_WEIGHTS = { + "dexscreener": 0.90, # Direct DEX data, excellent for on-chain tokens + "geckoterminal": 0.92, # CoinGecko's DEX aggregator, very reliable + "jupiter": 0.88, # Solana's primary aggregator, excellent for Solana + "dia": 0.85, # Oracle-grade data, transparent methodology + "coingecko": 0.88, # CEX + DEX aggregation, broad coverage + "cryptocompare": 0.82, # Institutional-grade, slower updates on microcaps + "coinpaprika": 0.78, # Good coverage, slightly less reliable on low-cap + "birdeye": 0.86, # Good Solana coverage, needs API key +} + +# ── Data Classes ──────────────────────────────────────────────────────────── + + +@dataclass +class PriceSource: + """A single price data provider.""" + + name: str + weight: float # Reliability score 0-1 + fetcher: Any = None # Async callable: (address, chain) → Optional[float] + last_price: float = 0.0 + last_latency: float = 0.0 + error_count: int = 0 + + +@dataclass +class PriceConsensus: + """Result of multi-source price consensus.""" + + price: float | None = None + confidence: float = 0.0 # 0-100% + sources_used: list[str] = field(default_factory=list) + outlier_sources: list[str] = field(default_factory=list) + failed_sources: list[str] = field(default_factory=list) + individual_prices: dict[str, float] = field(default_factory=dict) + median: float | None = None + mad: float | None = None + std_dev: float | None = None + spread_pct: float | None = None # (max-min)/median * 100 + + @property + def is_reliable(self) -> bool: + return self.confidence >= 60.0 and self.price is not None + + +# ── Price Consensus Engine ───────────────────────────────────────────────── + + +class PriceConsensusEngine: + """Multi-source price aggregation with MAD-based outlier rejection. + + Fetches from all configured sources in parallel, removes statistical + outliers (z-score > 3 using Median Absolute Deviation), and computes + a weighted mean of the remaining prices. Falls back gracefully if + fewer than 2 sources respond. + """ + + # Timeout per source fetch + PER_SOURCE_TIMEOUT = 10.0 + + # If a source fails this many times consecutively, lower its effective weight + MAX_CONSECUTIVE_ERRORS = 5 + + def __init__(self): + self._sources: dict[str, PriceSource] = {} + self._lock = asyncio.Lock() + self._setup_sources() + + def _setup_sources(self): + """Register all price sources with their fetcher callables.""" + sources = [ + ("dexscreener", self._fetch_dexscreener, DEFAULT_SOURCE_WEIGHTS["dexscreener"]), + ("geckoterminal", self._fetch_geckoterminal, DEFAULT_SOURCE_WEIGHTS["geckoterminal"]), + ("jupiter", self._fetch_jupiter, DEFAULT_SOURCE_WEIGHTS["jupiter"]), + ("dia", self._fetch_dia, DEFAULT_SOURCE_WEIGHTS["dia"]), + ("coingecko", self._fetch_coingecko, DEFAULT_SOURCE_WEIGHTS["coingecko"]), + ("cryptocompare", self._fetch_cryptocompare, DEFAULT_SOURCE_WEIGHTS["cryptocompare"]), + ("coinpaprika", self._fetch_coinpaprika, DEFAULT_SOURCE_WEIGHTS["coinpaprika"]), + ] + + # Birdeye if key is available + birdeye_key = os.getenv("BIRDEYE_API_KEY", "") + if birdeye_key and birdeye_key != "your_birdeye_key_here": + sources.append(("birdeye", self._fetch_birdeye, DEFAULT_SOURCE_WEIGHTS["birdeye"])) + + for name, fetcher, weight in sources: + self._sources[name] = PriceSource(name=name, weight=weight, fetcher=fetcher) + + logger.info(f"PriceConsensusEngine: {len(self._sources)} sources registered: {list(self._sources.keys())}") + + # ── Source Fetchers ────────────────────────────────────────────────── + + async def _fetch_dexscreener(self, address: str, chain: str) -> float | None: + """DexScreener free API - no key required.""" + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + r = await client.get( + f"https://api.dexscreener.com/latest/dex/tokens/{address}", + headers={"Accept": "application/json"}, + ) + if r.status_code == 200: + data = r.json() + pairs = data.get("pairs", []) + if pairs: + # Find the pair with highest liquidity + best = max(pairs, key=lambda p: float(p.get("liquidity", {}).get("usd", 0) or 0)) + price = best.get("priceUsd") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"DexScreener fetch error: {e}") + return None + + async def _fetch_geckoterminal(self, address: str, chain: str) -> float | None: + """GeckoTerminal free API - no key required.""" + network = self._chain_to_gecko_network(chain) + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + r = await client.get( + f"https://api.geckoterminal.com/api/v2/networks/{network}/tokens/{address}", + headers={"Accept": "application/json"}, + ) + if r.status_code == 200: + data = r.json() + token_data = data.get("data", {}) + attrs = token_data.get("attributes", {}) + price = attrs.get("price_usd") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"GeckoTerminal fetch error: {e}") + return None + + async def _fetch_jupiter(self, address: str, chain: str) -> float | None: + """Jupiter price API - free, Solana only.""" + if chain.lower() not in ("solana", "sol"): + return None + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + r = await client.get( + f"https://price.jup.ag/v6/price?ids={address}", + headers={"Accept": "application/json"}, + ) + if r.status_code == 200: + data = r.json() + token_data = data.get("data", {}).get(address) + if token_data: + price = token_data.get("price") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"Jupiter fetch error: {e}") + return None + + async def _fetch_dia(self, address: str, chain: str) -> float | None: + """DIA oracle price feed - free, no key.""" + dia_chain = self._chain_to_dia_chain(chain) + if not dia_chain: + return None + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + r = await client.get( + f"https://api.diadata.org/v1/assetQuotation/{dia_chain}/{address}", + headers={"Accept": "application/json"}, + ) + if r.status_code == 200: + data = r.json() + price = data.get("Price") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"DIA fetch error: {e}") + return None + + async def _fetch_coingecko(self, address: str, chain: str) -> float | None: + """CoinGecko token price by contract - free tier.""" + cg_chain = self._chain_to_coingecko_platform(chain) + if not cg_chain: + return None + api_key = os.getenv("COINGECKO_API_KEY", "") + headers = {"Accept": "application/json"} + if api_key: + headers["x-cg-demo-api-key"] = api_key + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + r = await client.get( + f"https://api.coingecko.com/api/v3/simple/token_price/{cg_chain}", + params={ + "contract_addresses": address, + "vs_currencies": "usd", + }, + headers=headers, + ) + if r.status_code == 200: + data = r.json() + price = data.get(address.lower(), {}).get("usd") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"CoinGecko fetch error: {e}") + return None + + async def _fetch_cryptocompare(self, address: str, chain: str) -> float | None: + """CryptoCompare price API - free tier.""" + api_key = os.getenv("CRYPTOCOMPARE_API_KEY", "") + headers = {"Accept": "application/json"} + if api_key: + headers["authorization"] = f"Apikey {api_key}" + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + r = await client.get( + "https://min-api.cryptocompare.com/data/price", + params={ + "fsym": address, + "tsyms": "USD", + }, + headers=headers, + ) + if r.status_code == 200: + data = r.json() + price = data.get("USD") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"CryptoCompare fetch error: {e}") + return None + + async def _fetch_coinpaprika(self, address: str, chain: str) -> float | None: + """Coinpaprika free API - no key required.""" + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + # Try by contract address lookup + r = await client.get( + f"https://api.coinpaprika.com/v1/contracts/{chain}/{address}", + headers={"Accept": "application/json"}, + ) + if r.status_code == 200: + data = r.json() + coin_id = data.get("id") + if coin_id: + # Get ticker for this coin + r2 = await client.get( + f"https://api.coinpaprika.com/v1/tickers/{coin_id}", + headers={"Accept": "application/json"}, + ) + if r2.status_code == 200: + ticker = r2.json() + price = ticker.get("quotes", {}).get("USD", {}).get("price") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"Coinpaprika fetch error: {e}") + return None + + async def _fetch_birdeye(self, address: str, chain: str) -> float | None: + """Birdeye price API - requires BIRDEYE_API_KEY.""" + api_key = os.getenv("BIRDEYE_API_KEY", "") + if not api_key: + return None + try: + async with httpx.AsyncClient(timeout=self.PER_SOURCE_TIMEOUT) as client: + r = await client.get( + "https://public-api.birdeye.so/defi/price", + params={"address": address}, + headers={ + "X-API-KEY": api_key, + "accept": "application/json", + }, + ) + if r.status_code == 200: + data = r.json() + price = data.get("data", {}).get("value") + if price: + return float(price) + return None + except Exception as e: + logger.debug(f"Birdeye fetch error: {e}") + return None + + # ── Chain Name Normalization ────────────────────────────────────────── + + @staticmethod + def _chain_to_gecko_network(chain: str) -> str: + mapping = { + "solana": "solana", + "sol": "solana", + "ethereum": "eth", + "eth": "eth", + "1": "eth", + "base": "base", + "8453": "base", + "bsc": "bsc", + "56": "bsc", + "bnb": "bsc", + "arbitrum": "arbitrum", + "42161": "arbitrum", + "polygon": "polygon_pos", + "137": "polygon_pos", + "matic": "polygon_pos", + "optimism": "optimism", + "10": "optimism", + "avalanche": "avax", + "43114": "avax", + "fantom": "fantom", + "250": "fantom", + } + return mapping.get(chain.lower(), chain.lower()) + + @staticmethod + def _chain_to_dia_chain(chain: str) -> str | None: + mapping = { + "solana": "Solana", + "sol": "Solana", + "ethereum": "Ethereum", + "eth": "Ethereum", + "1": "Ethereum", + "base": "Base", + "8453": "Base", + "bsc": "BSC", + "56": "BSC", + "bnb": "BSC", + "arbitrum": "Arbitrum", + "42161": "Arbitrum", + "polygon": "Polygon", + "137": "Polygon", + "optimism": "Optimism", + "10": "Optimism", + } + return mapping.get(chain.lower()) + + @staticmethod + def _chain_to_coingecko_platform(chain: str) -> str | None: + mapping = { + "solana": "solana", + "sol": "solana", + "ethereum": "ethereum", + "eth": "ethereum", + "1": "ethereum", + "base": "base", + "8453": "base", + "bsc": "binance-smart-chain", + "56": "binance-smart-chain", + "bnb": "binance-smart-chain", + "arbitrum": "arbitrum-one", + "42161": "arbitrum-one", + "polygon": "polygon-pos", + "137": "polygon-pos", + "matic": "polygon-pos", + "optimism": "optimistic-ethereum", + "10": "optimistic-ethereum", + "avalanche": "avalanche", + "43114": "avalanche", + "fantom": "fantom", + "250": "fantom", + } + return mapping.get(chain.lower()) + + # ── Core Consensus Logic ────────────────────────────────────────────── + + async def get_consensus_price( + self, + token_address: str, + chain: str = "solana", + ) -> PriceConsensus: + """Fetch prices from all sources and compute consensus. + + Args: + token_address: Token contract address / mint + chain: Blockchain identifier (solana, ethereum, base, etc.) + + Returns: + PriceConsensus with consensus price, confidence, and breakdown. + """ + if not self._sources: + return PriceConsensus( + price=None, + confidence=0.0, + failed_sources=["no_sources_configured"], + ) + + # Fire all source fetchers in parallel + tasks = [] + source_names = [] + for name, source in self._sources.items(): + tasks.append(source.fetcher(token_address, chain)) + source_names.append(name) + + start = time.monotonic() + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Collect successful prices and track failures + prices: dict[str, float] = {} + failed: list[str] = [] + + for name, result in zip(source_names, results, strict=False): + if isinstance(result, Exception): + logger.debug(f"Source {name} exception: {result}") + failed.append(name) + async with self._lock: + if name in self._sources: + self._sources[name].error_count += 1 + elif result is not None and isinstance(result, (int, float)): + if result > 0: + prices[name] = float(result) + latency = time.monotonic() - start + async with self._lock: + if name in self._sources: + self._sources[name].last_price = float(result) + self._sources[name].last_latency = latency + self._sources[name].error_count = 0 + else: + failed.append(name) + + # If no sources returned a price, return null consensus + if not prices: + logger.warning(f"No price sources responded for {token_address} on {chain}") + return PriceConsensus( + price=None, + confidence=0.0, + failed_sources=failed, + ) + + price_values = list(prices.values()) + price_names = list(prices.keys()) + + # Single source: return it but with low confidence + if len(price_values) == 1: + return PriceConsensus( + price=price_values[0], + confidence=30.0, + sources_used=price_names, + failed_sources=failed, + individual_prices=prices, + median=price_values[0], + ) + + # ── MAD-based Outlier Detection ───────────────────────────────── + + arr = np.array(price_values) + median = float(np.median(arr)) + mad = float(np.median(np.abs(arr - median))) + + # If MAD is zero (all prices identical), no outliers + if mad == 0: + weighted_avg = self._weighted_mean(prices) + return PriceConsensus( + price=weighted_avg, + confidence=95.0 if len(price_values) >= 3 else 70.0, + sources_used=price_names, + outlier_sources=[], + failed_sources=failed, + individual_prices=prices, + median=median, + mad=0.0, + std_dev=0.0, + spread_pct=0.0, + ) + + # Compute modified z-scores using MAD + # z_i = 0.6745 * (x_i - median) / MAD + z_scores = 0.6745 * (arr - median) / mad + + # Outlier threshold: |z| > 3 (very conservative - classic threshold) + inliers_mask = np.abs(z_scores) <= 3.0 + outliers_mask = ~inliers_mask + + inlier_prices = { + name: price + for name, price, is_inlier in zip(price_names, price_values, inliers_mask, strict=False) + if is_inlier + } + outlier_names = [ + name + for name, price, is_outlier in zip(price_names, price_values, outliers_mask, strict=False) + if is_outlier + ] + + # If all prices are outliers, fall back to all with low confidence + if not inlier_prices: + logger.warning(f"All prices flagged as outliers for {token_address} - using all with low confidence") + weighted_avg = self._weighted_mean(prices) + return PriceConsensus( + price=weighted_avg, + confidence=10.0, + sources_used=price_names, + outlier_sources=[], + failed_sources=failed, + individual_prices=prices, + median=median, + mad=float(mad), + std_dev=float(np.std(arr)), + spread_pct=self._spread_pct(price_values), + ) + + # Compute weighted mean of inliers + consensus_price = self._weighted_mean(inlier_prices) + + # Confidence calculation + total_sources = len(self._sources) + inlier_count = len(inlier_prices) + responder_count = len(price_values) + + # Base confidence from inlier agreement ratio + if inlier_count >= 3: + agreement_ratio = inlier_count / responder_count + confidence = agreement_ratio * 85.0 + 10.0 # 70-95 range + elif inlier_count == 2: + confidence = 55.0 + else: + confidence = 35.0 + + # Penalize if we had many failures + failure_penalty = (len(failed) / max(total_sources, 1)) * 20.0 + confidence = max(5.0, confidence - failure_penalty) + + # Bonus for low spread among inliers + inlier_values = list(inlier_prices.values()) + if len(inlier_values) >= 2: + spread = self._spread_pct(inlier_values) + if spread is not None and spread < 2.0: + confidence = min(100.0, confidence + 10.0) + + return PriceConsensus( + price=round(consensus_price, 12), + confidence=round(confidence, 1), + sources_used=list(inlier_prices.keys()), + outlier_sources=outlier_names, + failed_sources=failed, + individual_prices=prices, + median=round(median, 12), + mad=round(float(mad), 12) if mad else None, + std_dev=round(float(np.std(arr)), 12), + spread_pct=self._spread_pct(price_values), + ) + + # ── Helpers ─────────────────────────────────────────────────────────── + + def _weighted_mean(self, prices: dict[str, float]) -> float: + """Weighted mean using source reliability weights, adjusted by error history.""" + if not prices: + return 0.0 + total_weight = 0.0 + weighted_sum = 0.0 + for name, price in prices.items(): + source = self._sources.get(name) + if source: + # Reduce weight if source has errors + error_penalty = min(0.5, source.error_count * 0.1) + weight = source.weight * (1.0 - error_penalty) + else: + weight = 0.5 + weighted_sum += price * weight + total_weight += weight + return weighted_sum / total_weight if total_weight > 0 else 0.0 + + @staticmethod + def _spread_pct(values: list[float]) -> float | None: + """(max - min) / median * 100. Lower = more consensus.""" + if len(values) < 2: + return None + arr = np.array(values) + median = float(np.median(arr)) + if median == 0: + return None + return round(float((arr.max() - arr.min()) / median * 100), 2) + + # ── Stats ───────────────────────────────────────────────────────────── + + async def stats(self) -> dict[str, Any]: + """Return per-source stats and aggregate metrics.""" + source_stats = {} + async with self._lock: + for name, src in self._sources.items(): + source_stats[name] = { + "weight": src.weight, + "effective_weight": round(src.weight * (1.0 - min(0.5, src.error_count * 0.1)), 3), + "last_price": src.last_price, + "last_latency": round(src.last_latency, 3), + "error_count": src.error_count, + } + return { + "total_sources": len(self._sources), + "sources": source_stats, + } + + +# ── Singleton ───────────────────────────────────────────────────────────── + +_price_engine: PriceConsensusEngine | None = None + + +def get_price_consensus() -> PriceConsensusEngine: + """Get the global PriceConsensusEngine singleton.""" + global _price_engine + if _price_engine is None: + _price_engine = PriceConsensusEngine() + return _price_engine diff --git a/app/_archive/legacy_2026_07/profile_router.py b/app/_archive/legacy_2026_07/profile_router.py new file mode 100644 index 0000000..5a91852 --- /dev/null +++ b/app/_archive/legacy_2026_07/profile_router.py @@ -0,0 +1,1124 @@ +""" +Profile Router - Web3 Social Profile Management. +Full profile CRUD, badges, social graph, Farcaster, multi-chain wallets. +""" + +import contextlib +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/profile", tags=["profile"]) + + +# ── Models ─────────────────────────────────────────────────── + + +class ProfileCreate(BaseModel): + """Create new profile.""" + + username: str + email: str # Validated manually to avoid email-validator dependency + display_name: str | None = None + bio: str | None = None + password: str | None = None # For email signup + + # Consent + product_updates: bool = False + newsletter: bool = False + new_features: bool = False + + +class ProfileUpdate(BaseModel): + """Update profile.""" + + display_name: str | None = None + bio: str | None = None + avatar_url: str | None = None + banner_url: str | None = None + website: str | None = None + location: str | None = None + ens_name: str | None = None + lens_handle: str | None = None + farcaster_username: str | None = None + + +class PasswordChange(BaseModel): + """Change password.""" + + current_password: str + new_password: str + + +class PasswordReset(BaseModel): + """Request password reset.""" + + email: str + + +class PasswordResetConfirm(BaseModel): + """Confirm password reset with token.""" + + token: str + new_password: str + + +class LoginRequest(BaseModel): + """Email/password login.""" + + email: str + password: str + + +class WalletConnect(BaseModel): + """Connect wallet to profile.""" + + chain: str # evm, solana, btc, base, polygon, arbitrum, optimism + address: str + signature: str | None = None # For verification + + +class FarcasterConnect(BaseModel): + """Connect Farcaster account.""" + + fid: int + username: str + signature: str # Signed message from Farcaster + + +class BadgeDisplay(BaseModel): + """Update badge display order.""" + + badge_id: str + is_displayed: bool = True + display_order: int = 0 + + +# ── Health ──────────────────────────────────────────────────── + + +@router.get("/health") +async def profile_health(): + """Profile service health check.""" + supabase = _get_supabase() + return { + "status": "ok", + "service": "profile-management", + "supabase_connected": supabase is not None, + "features": [ + "profile_crud", + "wallet_connections", + "farcaster_integration", + "badges", + "social_graph", + "activity_feed", + "notifications", + "consent_management", + ], + } + + +# ── Helpers ────────────────────────────────────────────────── + + +def _get_supabase(): + """Get Supabase client.""" + try: + import os + + from supabase import create_client + + url = os.getenv("SUPABASE_URL", "") + key = ( + os.getenv("SUPABASE_SERVICE_KEY", "") + or os.getenv("SUPABASE_KEY", "") + or os.getenv("SUPABASE_SERVICE_ROLE_KEY", "") + ) + + if not url or not key: + return None + + return create_client(url, key) + except ImportError: + return None + + +async def _get_current_user_id(authorization: str = Header(None)) -> str | None: + """Get current user ID from JWT token.""" + if not authorization: + return None + + try: + # Extract token from "Bearer xxx" + token = authorization.replace("Bearer ", "") + + # Verify with Supabase + import os + + from supabase import create_client + + supabase = create_client( + os.getenv("SUPABASE_URL"), + os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_KEY"), + ) + + user = supabase.auth.get_user(token) + return user.user.id if user and user.user else None + except Exception: + return None + + +# ── Profile Management ─────────────────────────────────────── + + +@router.post("/signup") +async def signup(req: ProfileCreate): + """Email signup with consent tracking.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + # Validate email format + if "@" not in req.email or "." not in req.email: + raise HTTPException(status_code=400, detail="Invalid email format") + + # Validate username + if len(req.username) < 3 or len(req.username) > 30: + raise HTTPException(status_code=400, detail="Username must be 3-30 characters") + + # Create auth user + if req.password: + if len(req.password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + + auth_result = supabase.auth.sign_up( + { + "email": req.email, + "password": req.password, + } + ) + user_id = auth_result.user.id + else: + # Passwordless - create user record directly + import uuid + + user_id = str(uuid.uuid4()) + + # Create profile + now = datetime.now(UTC).isoformat() + + profile_data = { + "user_id": user_id, + "username": req.username, + "display_name": req.display_name or req.username, + "bio": req.bio, + "product_updates": req.product_updates, + "newsletter": req.newsletter, + "new_features": req.new_features, + "consent_given_at": now, + } + + supabase.table("profiles").insert(profile_data).execute() + + # Record consents + consents = [ + { + "user_id": user_id, + "consent_type": "product_news", + "is_consented": req.product_updates, + "consented_at": now, + }, + { + "user_id": user_id, + "consent_type": "newsletter", + "is_consented": req.newsletter, + "consented_at": now, + }, + { + "user_id": user_id, + "consent_type": "feature_announcements", + "is_consented": req.new_features, + "consented_at": now, + }, + ] + + for consent in consents: + supabase.table("user_consents").insert(consent).execute() + + # Award early adopter badge if in first 1000 + user_count = supabase.table("users").select("*", count="exact").execute().count + if user_count <= 1000: + with contextlib.suppress(BaseException): + supabase.rpc( + "award_badge", + { + "p_user_id": user_id, + "p_badge_key": "early_adopter", + "p_reason": "Among the first 1000 users", + }, + ).execute() + + return { + "status": "ok", + "user_id": user_id, + "username": req.username, + "email": req.email, + "message": "Welcome to RMI! Check your email for verification." + if req.password + else "Account created successfully!", + } + + except HTTPException: + raise + except Exception as e: + error_msg = str(e).lower() + if "duplicate" in error_msg or "already" in error_msg or "unique" in error_msg: + raise HTTPException(status_code=409, detail="Username or email already taken") from e + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/login") +async def login(req: LoginRequest): + """Email/password login.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + # Sign in with email/password + auth_result = supabase.auth.sign_in_with_password( + { + "email": req.email, + "password": req.password, + } + ) + + if not auth_result.user: + raise HTTPException(status_code=401, detail="Invalid email or password") + + user_id = auth_result.user.id + + # Get profile + profile_result = supabase.table("profiles").select("*").eq("user_id", user_id).execute() + profile = profile_result.data[0] if profile_result.data else None + + # Update last_active + if profile: + supabase.table("profiles").update({"last_active": datetime.now(UTC).isoformat()}).eq( + "user_id", user_id + ).execute() + + return { + "status": "ok", + "user": { + "id": user_id, + "email": auth_result.user.email, + "username": profile.get("username") if profile else None, + }, + "session": { + "access_token": auth_result.session.access_token if auth_result.session else None, + "refresh_token": auth_result.session.refresh_token if auth_result.session else None, + "expires_in": auth_result.session.expires_in if auth_result.session else None, + }, + } + + except HTTPException: + raise + except Exception as e: + if "invalid" in str(e).lower() or "credentials" in str(e).lower(): + raise HTTPException(status_code=401, detail="Invalid email or password") from e + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/logout") +async def logout(authorization: str = Header(None)): + """Logout current user.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + if authorization: + token = authorization.replace("Bearer ", "") + supabase.auth.admin.sign_out(token) + + return {"status": "ok", "message": "Logged out successfully"} + except Exception: + return {"status": "ok", "message": "Logged out"} + + +@router.post("/password/change") +async def change_password(req: PasswordChange, authorization: str = Header(None)): + """Change password for authenticated user.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + # Update password (requires current password verification in production) + # For now, we'll update directly - in production, verify current password first + + # Get user's email + profile = supabase.table("profiles").select("user_id").eq("user_id", user_id).execute() + if not profile.data: + raise HTTPException(status_code=404, detail="Profile not found") + + # Note: Supabase requires re-authentication to change password + # This endpoint should be called with the user's current session + # The password change will be applied to their auth record + + return { + "status": "ok", + "message": "Password changed successfully. Please login with your new password.", + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/password/reset/request") +async def request_password_reset(req: PasswordReset): + """Request password reset email.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + # Send reset email via Supabase + supabase.auth.reset_password_for_email(req.email, {"redirectTo": "https://rugmunch.io/reset-password"}) + + return {"status": "ok", "message": "Password reset email sent. Check your inbox."} + + except Exception: + # Don't reveal if email exists or not (security) + return { + "status": "ok", + "message": "If an account exists with that email, a reset link has been sent.", + } + + +@router.post("/password/reset/confirm") +async def confirm_password_reset(req: PasswordResetConfirm): + """Confirm password reset with token from email.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + if len(req.new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + + # Verify OTP and update password + auth_result = supabase.auth.verify_otp( + { + "email": req.token.split(":")[0] if ":" in req.token else "", # Extract email from token + "token": req.token, + "type": "recovery", + } + ) + + # Then update password + if auth_result and auth_result.user: + supabase.auth.update_user({"password": req.new_password}) + + return {"status": "ok", "message": "Password reset successfully. You can now login."} + + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=500, detail="Invalid or expired reset token") from None + + +@router.get("/me") +async def get_my_profile(authorization: str = Header(None)): + """Get current user's profile.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Get profile + result = supabase.table("profiles").select("*").eq("user_id", user_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Profile not found") + + profile = result.data[0] + + # Get connected accounts + accounts = supabase.table("connected_accounts").select("*").eq("user_id", user_id).execute().data + + # Get badges + badges = ( + supabase.table("user_badges") + .select(""" + *, + badges ( + badge_key, + name, + description, + icon_url, + category, + rarity + ) + """) + .eq("user_id", user_id) + .eq("is_displayed", True) + .order("display_order") + .execute() + .data + ) + + # Get follower counts + followers = supabase.table("follows").select("*", count="exact").eq("following_id", user_id).execute().count + following = supabase.table("follows").select("*", count="exact").eq("follower_id", user_id).execute().count + + return { + "profile": profile, + "connected_accounts": accounts or [], + "badges": badges or [], + "stats": { + "followers": followers, + "following": following, + }, + } + + +@router.put("/me") +async def update_profile(req: ProfileUpdate, authorization: str = Header(None)): + """Update current user's profile.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Update profile + update_data = req.dict(exclude_none=True) + update_data["updated_at"] = datetime.now(UTC).isoformat() + + result = supabase.table("profiles").update(update_data).eq("user_id", user_id).execute() + + if not result.data: + raise HTTPException(status_code=404, detail="Profile not found") + + return {"status": "ok", "profile": result.data[0]} + + +@router.get("/social/farcaster/{fid}") +async def get_farcaster_profile(fid: int): + """Fetch Farcaster profile by FID using public Hub API.""" + try: + from app.socialfi_resolver import fetch_farcaster_profile + + profile = await fetch_farcaster_profile(fid) + if not profile: + raise HTTPException(status_code=404, detail="Farcaster profile not found") + return {"status": "ok", "farcaster": profile} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/social/resolve-ens/{address}") +async def resolve_ens(address: str): + """Resolve Ethereum address to ENS name.""" + try: + from app.socialfi_resolver import resolve_ens_name + + name = await resolve_ens_name(address) + return {"address": address, "ens_name": name} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/social/farcaster-handle/{handle:path}") +async def resolve_farcaster(handle: str): + """Resolve Farcaster handle to FID and fetch profile.""" + try: + from app.socialfi_resolver import fetch_farcaster_profile, resolve_farcaster_handle + + fid = await resolve_farcaster_handle(handle) + if not fid: + raise HTTPException(status_code=404, detail="Farcaster handle not found") + profile = await fetch_farcaster_profile(fid) + if not profile: + raise HTTPException(status_code=404, detail="Farcaster profile not found") + return {"status": "ok", "farcaster": profile} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/social") +async def list_socialfi_integrations(): + """List available SocialFi integrations.""" + return { + "available": [ + { + "platform": "farcaster", + "description": "Decentralized social network - connect FID to fetch profile, casts, followers", + "endpoints": [ + "GET /social/farcaster/{fid}", + "GET /social/farcaster-handle/{handle}", + ], + }, + { + "platform": "ens", + "description": "Ethereum Name Service - resolve .eth names to addresses and vice versa", + "endpoints": [ + "GET /social/resolve-ens/{address}", + ], + }, + { + "platform": "lens", + "description": "Lens Protocol - decentralized social graph (coming soon)", + "endpoints": [], + }, + ] + } + + +@router.get("/admin/users") +async def admin_list_users(authorization: str = Header(None)): + """Admin: List all users with full details.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Fetch all profiles + result = supabase.table("profiles").select("*").order("created_at", desc=True).limit(200).execute() + + return {"users": result.data or [], "total": len(result.data) if result.data else 0} + + +@router.post("/admin/users/{target_user_id}/verify") +async def admin_verify_user(target_user_id: str, authorization: str = Header(None)): + """Admin: Verify a user.""" + admin_id = await _get_current_user_id(authorization) + if not admin_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + supabase.table("profiles").update({"is_verified": True, "updated_at": datetime.now(UTC).isoformat()}).eq( + "user_id", target_user_id + ).execute() + + return {"status": "ok", "user_id": target_user_id, "verified": True} + + +@router.post("/admin/users/{target_user_id}/ban") +async def admin_ban_user(target_user_id: str, authorization: str = Header(None)): + """Admin: Ban a user.""" + admin_id = await _get_current_user_id(authorization) + if not admin_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + supabase.table("profiles").update( + { + "is_banned": True, + "banned_at": datetime.now(UTC).isoformat(), + "banned_by": admin_id, + "updated_at": datetime.now(UTC).isoformat(), + } + ).eq("user_id", target_user_id).execute() + + return {"status": "ok", "user_id": target_user_id, "banned": True} + + +@router.get("/{username}") +async def get_profile_by_username(username: str): + """Get public profile by username.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + result = ( + supabase.table("profiles") + .select(""" + id, + user_id, + username, + display_name, + bio, + avatar_url, + banner_url, + website, + location, + ens_name, + lens_handle, + farcaster_fid, + farcaster_username, + reputation_score, + trust_score, + is_verified, + created_at + """) + .eq("username", username) + .execute() + ) + + if not result.data: + raise HTTPException(status_code=404, detail="Profile not found") + + profile = result.data[0] + user_id = profile["user_id"] + + # Get displayed badges + badges = ( + supabase.table("user_badges") + .select(""" + *, + badges ( + badge_key, + name, + description, + icon_url, + rarity + ) + """) + .eq("user_id", user_id) + .eq("is_displayed", True) + .order("display_order") + .execute() + .data + ) + + # Get follower counts + followers = supabase.table("follows").select("*", count="exact").eq("following_id", user_id).execute().count + following = supabase.table("follows").select("*", count="exact").eq("follower_id", user_id).execute().count + + # Get connected account types (not addresses - privacy) + account_types = supabase.table("connected_accounts").select("account_type").eq("user_id", user_id).execute().data + connected_types = list({a["account_type"] for a in (account_types or [])}) + + return { + "profile": profile, + "badges": badges or [], + "stats": { + "followers": followers, + "following": following, + }, + "connected_types": connected_types, + } + + +# ── Wallet Connections ─────────────────────────────────────── + + +@router.post("/wallet/connect") +async def connect_wallet(req: WalletConnect, authorization: str = Header(None)): + """Connect wallet to profile.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Verify signature if provided (simplified - real impl would verify) + is_verified = bool(req.signature) if req.chain in ("evm", "solana") else False + + # Add connected account + account_data = { + "user_id": user_id, + "account_type": f"wallet_{req.chain}", + "account_id": req.address, + "account_name": f"{req.address[:8]}...{req.address[-6:]}", + "is_verified": is_verified, + "verified_at": datetime.now(UTC).isoformat() if is_verified else None, + } + + result = supabase.table("connected_accounts").insert(account_data).execute() + + # Check for multi-chain badge + wallet_count = ( + supabase.table("connected_accounts") + .select("*") + .eq("user_id", user_id) + .ilike("account_type", "wallet_%") + .execute() + .count + ) + if wallet_count >= 5: + with contextlib.suppress(BaseException): + supabase.rpc( + "award_badge", + { + "p_user_id": user_id, + "p_badge_key": "multi_chain", + "p_reason": "Connected wallets on 5+ chains", + }, + ).execute() + + return { + "status": "ok", + "account": result.data[0] if result.data else None, + "message": f"Wallet connected on {req.chain}", + } + + +@router.delete("/wallet/{chain}") +async def disconnect_wallet(chain: str, authorization: str = Header(None)): + """Disconnect wallet from profile.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + supabase.table("connected_accounts").delete().eq("user_id", user_id).eq("account_type", f"wallet_{chain}").execute() + + return {"status": "ok", "message": f"Wallet disconnected from {chain}"} + + +# ── Farcaster Integration ──────────────────────────────────── + + +@router.post("/farcaster/connect") +async def connect_farcaster(req: FarcasterConnect, authorization: str = Header(None)): + """Connect Farcaster account.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Update profile with Farcaster info + supabase.table("profiles").update( + { + "farcaster_fid": req.fid, + "farcaster_username": req.username, + "updated_at": datetime.now(UTC).isoformat(), + } + ).eq("user_id", user_id).execute() + + # Add as connected account + supabase.table("connected_accounts").insert( + { + "user_id": user_id, + "account_type": "farcaster", + "account_id": str(req.fid), + "account_name": req.username, + "is_verified": True, + "verified_at": datetime.now(UTC).isoformat(), + } + ).execute() + + # Award Farcaster Pioneer badge + with contextlib.suppress(BaseException): + supabase.rpc( + "award_badge", + { + "p_user_id": user_id, + "p_badge_key": "farcaster_pioneer", + "p_reason": "Early Farcaster integrator", + }, + ).execute() + + return {"status": "ok", "farcaster": {"fid": req.fid, "username": req.username}} + + +# ── Badges ─────────────────────────────────────────────────── + + +@router.get("/badges") +async def list_badges(category: str | None = None): + """List all available badges.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + query = supabase.table("badges").select("*").eq("is_active", True) + if category: + query = query.eq("category", category) + + result = query.order("rarity", "total_awarded").execute() + + return {"badges": result.data or []} + + +@router.get("/me/badges") +async def get_my_badges(authorization: str = Header(None)): + """Get current user's badges.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + result = ( + supabase.table("user_badges") + .select(""" + *, + badges ( + badge_key, + name, + description, + icon_url, + category, + rarity + ) + """) + .eq("user_id", user_id) + .execute() + ) + + return {"badges": result.data or []} + + +@router.put("/me/badges/display") +async def update_badge_display(req: BadgeDisplay, authorization: str = Header(None)): + """Update badge display settings.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + result = ( + supabase.table("user_badges") + .update({"is_displayed": req.is_displayed, "display_order": req.display_order}) + .eq("user_id", user_id) + .eq("badge_id", req.badge_id) + .execute() + ) + + return {"status": "ok", "badge": result.data[0] if result.data else None} + + +# ── Social Graph ───────────────────────────────────────────── + + +@router.post("/follow/{target_username}") +async def follow_user(target_username: str, authorization: str = Header(None)): + """Follow another user.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Get target user ID + target = supabase.table("profiles").select("user_id").eq("username", target_username).execute() + if not target.data: + raise HTTPException(status_code=404, detail="User not found") + + target_id = target.data[0]["user_id"] + + if target_id == user_id: + raise HTTPException(status_code=400, detail="Cannot follow yourself") + + # Create follow + try: + supabase.table("follows").insert({"follower_id": user_id, "following_id": target_id}).execute() + + # Create notification for target + supabase.table("notifications").insert( + { + "user_id": target_id, + "notification_type": "new_follower", + "title": "New follower", + "message": "Someone started following you", + "data": {"follower_id": user_id}, + } + ).execute() + + except Exception as e: + if "duplicate" in str(e).lower(): + raise HTTPException(status_code=409, detail="Already following") from e + raise + + return {"status": "ok", "following": target_username} + + +@router.delete("/follow/{target_username}") +async def unfollow_user(target_username: str, authorization: str = Header(None)): + """Unfollow a user.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Get target user ID + target = supabase.table("profiles").select("user_id").eq("username", target_username).execute() + if not target.data: + raise HTTPException(status_code=404, detail="User not found") + + target_id = target.data[0]["user_id"] + + supabase.table("follows").delete().eq("follower_id", user_id).eq("following_id", target_id).execute() + + return {"status": "ok", "unfollowed": target_username} + + +@router.get("/{username}/followers") +async def get_followers(username: str, limit: int = 50): + """Get user's followers.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Get user ID + user = supabase.table("profiles").select("user_id").eq("username", username).execute() + if not user.data: + raise HTTPException(status_code=404, detail="User not found") + + user_id = user.data[0]["user_id"] + + # Get followers + result = ( + supabase.table("follows") + .select(""" + follower_id, + created_at, + profiles ( + username, + display_name, + avatar_url, + is_verified + ) + """) + .eq("following_id", user_id) + .limit(limit) + .execute() + ) + + return {"followers": result.data or []} + + +@router.get("/{username}/following") +async def get_following(username: str, limit: int = 50): + """Get users that this user follows.""" + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Get user ID + user = supabase.table("profiles").select("user_id").eq("username", username).execute() + if not user.data: + raise HTTPException(status_code=404, detail="User not found") + + user_id = user.data[0]["user_id"] + + # Get following + result = ( + supabase.table("follows") + .select(""" + following_id, + created_at, + profiles ( + username, + display_name, + avatar_url, + is_verified + ) + """) + .eq("follower_id", user_id) + .limit(limit) + .execute() + ) + + return {"following": result.data or []} + + +# ── Activity Feed ──────────────────────────────────────────── + + +@router.get("/me/feed") +async def get_my_feed(authorization: str = Header(None), limit: int = 50): + """Get current user's activity feed.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + # Get user's activities + activities from users they follow + result = ( + supabase.table("activity_feed") + .select(""" + *, + profiles ( + username, + display_name, + avatar_url + ) + """) + .or_(f"user_id.eq.{user_id},visibility.eq.public") + .order("created_at", desc=True) + .limit(limit) + .execute() + ) + + return {"activities": result.data or []} + + +@router.get("/me/notifications") +async def get_my_notifications(authorization: str = Header(None), unread_only: bool = False): + """Get current user's notifications.""" + user_id = await _get_current_user_id(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + query = supabase.table("notifications").select("*").eq("user_id", user_id) + if unread_only: + query = query.eq("is_read", False) + + result = query.order("created_at", desc=True).limit(50).execute() + + # Mark as read + if unread_only and result.data: + supabase.table("notifications").update({"is_read": True, "read_at": datetime.now(UTC).isoformat()}).eq( + "user_id", user_id + ).eq("is_read", False).execute() + + return {"notifications": result.data or []} diff --git a/app/_archive/legacy_2026_07/protection.py b/app/_archive/legacy_2026_07/protection.py new file mode 100644 index 0000000..3297008 --- /dev/null +++ b/app/_archive/legacy_2026_07/protection.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/protection", tags=["protection"]) + + +@router.get("/health") +async def check_health() -> dict: + """Check health of all protection modules. + + Returns: + {"status": "ok|degraded|down", "modules": {...}} + """ + status = "ok" + modules = { + "rag": {"status": "ok"}, + "scanner": {"status": "ok"}, + "blocklist": {"status": "ok"}, + "wallet_labels": {"status": "ok"}, + "entity_intel": {"status": "ok"}, + } + + # Check RAG + try: + from app.rag_service import search_similar + + await search_similar("health_check", "known_scams", limit=1, min_similarity=0.1) + except Exception as e: + modules["rag"]["status"] = "down" + modules["rag"]["error"] = str(e) + status = "degraded" + + # Check scanner + try: + from app.degen_security_scanner import DegenSecurityScanner + + scanner = DegenSecurityScanner() + await scanner.scan_token("health_check", "solana") + except Exception as e: + modules["scanner"]["status"] = "down" + modules["scanner"]["error"] = str(e) + status = "degraded" + + # Check blocklist + try: + await get_blocklist() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + except Exception as e: + modules["blocklist"]["status"] = "down" + modules["blocklist"]["error"] = str(e) + status = "degraded" + + # Check wallet labels + try: + from app.wallet_label_loader import lookup_wallet_label + + lookup_wallet_label("health_check", "solana") + except Exception as e: + modules["wallet_labels"]["status"] = "down" + modules["wallet_labels"]["error"] = str(e) + status = "degraded" + + # Check entity intel + try: + from app.entity_intel import get_entity_intel + + await get_entity_intel("health_check", "solana") + except Exception as e: + modules["entity_intel"]["status"] = "down" + modules["entity_intel"]["error"] = str(e) + status = "degraded" + + return {"status": status, "modules": modules} + + +async def check_url(url: str) -> dict: + """Stub for URL checking.""" + return {"safe": True, "url": url} + + +async def check_wallet(address: str, chain: str = "solana") -> dict: + """Stub for wallet checking.""" + return {"safe": True, "address": address, "chain": chain} + + +async def check_token(address: str, chain: str = "solana") -> dict: + """Stub for token checking.""" + return {"safe": True, "address": address, "chain": chain, "risk_score": 0, "flags": []} + + +async def get_blocklist_domains() -> dict: + """Stub for getting blocklist domains.""" + return {"domains": [], "last_updated": "2024-01-01T00:00:00Z"} + + +async def get_protection_health() -> dict: + """Stub for getting protection health.""" + return {"status": "ok", "modules": {"protection": "ok"}} diff --git a/app/_archive/legacy_2026_07/protection_api.py b/app/_archive/legacy_2026_07/protection_api.py new file mode 100644 index 0000000..91e3751 --- /dev/null +++ b/app/_archive/legacy_2026_07/protection_api.py @@ -0,0 +1,123 @@ +# Protection API endpoints for RMI +# Core detection API that all protection modules call +# +# NOTE: This module is a stub. The underlying services (RAGService class, +# Scanner, WalletLabels, EntityIntel, Blocklist) have not been implemented +# as importable classes yet. The rag_service module uses standalone async +# functions, not a RAGService class. We wrap imports in try/except so the +# app doesn't crash on startup. + +import logging + +from fastapi import APIRouter +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +# Attempt imports - these are stubs and may not exist +try: + from app.rag_service import detect_scam_patterns, search_similar +except ImportError: + detect_scam_patterns = None + search_similar = None + +try: + from app.url_scam_detector import check_url_safety +except ImportError: + check_url_safety = None + +try: + from app.wallet_persona import analyze_wallet +except ImportError: + analyze_wallet = None + +router = APIRouter(prefix="/api/v1/protect", tags=["protection"]) + + +class UrlCheckRequest(BaseModel): + url: str + + +class WalletCheckRequest(BaseModel): + address: str + chain: str + + +class TokenCheckRequest(BaseModel): + address: str + chain: str + + +@router.post("/check-url") +async def check_url(request: UrlCheckRequest): + """Check URL safety against scam patterns and blocklist""" + try: + # Use detect_scam_patterns from rag_service (standalone function) + if detect_scam_patterns: + rag_result = await detect_scam_patterns(request.url) + if rag_result and rag_result.get("is_scam"): + return { + "safe": False, + "risk": rag_result.get("risk_level", "high"), + "reason": rag_result.get("reason", "Known scam pattern"), + "source": "rag", + } + + # Use URL scam detector if available + if check_url_safety: + scan_result = await check_url_safety(request.url) + if scan_result and not scan_result.get("safe", True): + return { + "safe": False, + "risk": scan_result.get("risk", "high"), + "reason": scan_result.get("reason", "Suspicious URL"), + "source": "scanner", + } + + return {"safe": True, "risk": "low", "reason": "No known risks found", "source": "scanner"} + except Exception as e: + logger.error(f"URL check failed: {e}") + return {"safe": True, "error": "service_unavailable", "reason": str(e)} + + +@router.post("/check-wallet") +async def check_wallet(request: WalletCheckRequest): + """Check wallet safety against labels and analysis""" + try: + if analyze_wallet: + result = await analyze_wallet(request.address, request.chain) + if result and result.get("risk_score", 0) > 70: + return { + "safe": False, + "risk": "high", + "reason": result.get("risk_reasons", ["High risk wallet"]), + "source": "analysis", + } + + return {"safe": True, "risk": "low", "reason": "No known risks found", "source": "analysis"} + except Exception as e: + logger.error(f"Wallet check failed: {e}") + return {"safe": True, "error": "service_unavailable", "reason": str(e)} + + +@router.post("/check-token") +async def check_token(request: TokenCheckRequest): + """Check token safety - delegates to x402-tools risk_scan for full analysis""" + return { + "safe": True, + "note": "Use /api/v1/x402-tools/risk_scan for full token security analysis", + "address": request.address, + "chain": request.chain, + } + + +@router.get("/health") +async def protection_health(): + """Health check for protection services""" + services = { + "rag_scam_detection": detect_scam_patterns is not None, + "url_safety_check": check_url_safety is not None, + "wallet_analysis": analyze_wallet is not None, + } + all_ok = all(services.values()) + return {"status": "ok" if all_ok else "partial", "services": services} diff --git a/app/_archive/legacy_2026_07/protection_router.py b/app/_archive/legacy_2026_07/protection_router.py new file mode 100644 index 0000000..57a2bd9 --- /dev/null +++ b/app/_archive/legacy_2026_07/protection_router.py @@ -0,0 +1,22 @@ +import logging + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() +logger = logging.getLogger("protection_router") + + +class HealthResponse(BaseModel): + status: str = "ok" + error: str = None + rag: dict = None + blocklist: dict = None + scanner: dict = None + wallet: dict = None + entity: dict = None + + +@router.get("/health", response_model=HealthResponse) +async def get_health(): + return HealthResponse(status="ok") diff --git a/app/_archive/legacy_2026_07/provider_health.py b/app/_archive/legacy_2026_07/provider_health.py new file mode 100644 index 0000000..f759128 --- /dev/null +++ b/app/_archive/legacy_2026_07/provider_health.py @@ -0,0 +1,125 @@ +""" +Provider Health Dashboard + Credit Burn Tracking +================================================= +Live status of all 10 embedding providers + enterprise credit burn rate. +""" + +import logging +import time + +from app.rate_limiter import get_dispatcher + +logger = logging.getLogger("health.dashboard") + + +async def provider_health() -> dict: + """Live health status of all embedding providers.""" + d = await get_dispatcher() + rl = await d.tracker.stats() + + providers = [] + for p in rl["providers"]: + # Determine health status + day_pct = p["day_pct"] + if not p["available"]: + status = "exhausted" if day_pct >= 100 else "rate_limited" + elif day_pct > 80: + status = "warning" + elif day_pct > 50: + status = "active_warm" + else: + status = "healthy" + + providers.append( + { + "id": p["id"], + "name": p["name"], + "status": status, + "day_used": p["day_used"], + "day_limit": p["day_limit"], + "day_pct": p["day_pct"], + "minute_used": p["minute_used"], + "minute_limit": p["minute_limit"], + "total_calls": p["total"], + "free": p["free"], + } + ) + + healthy = sum(1 for p in providers if p["status"] == "healthy") + warning = sum(1 for p in providers if p["status"] == "warning") + degraded = sum(1 for p in providers if p["status"] in ("active_warm", "rate_limited")) + exhausted = sum(1 for p in providers if p["status"] == "exhausted") + + return { + "summary": { + "total": len(providers), + "healthy": healthy, + "warning": warning, + "degraded": degraded, + "exhausted": exhausted, + }, + "providers": providers, + "cache": d.cache.stats(), + "timestamp": time.time(), + } + + +async def credit_burn() -> dict: + """Enterprise credit burn tracking - daily rate, projected exhaustion.""" + d = await get_dispatcher() + rl = await d.tracker.stats() + + # Calculate cost estimates + # Vertex AI: $0.000025 per 1K chars (~$0.000025 per embedding call) + # Gemini AI Studio: free up to 1,500/day per key + # OpenRouter: free with credits + # Mistral: free (1B tokens/month) + + total_calls_today = 0 + total_free_limit = 0 + provider_usage = [] + + for p in rl["providers"]: + total_calls_today += p["day_used"] + total_free_limit += p["day_limit"] if p["free"] else 0 + + # Estimate cost (only Vertex AI burns credits from enterprise trial) + cost_est = 0 + if p["id"] == "vertex_ai": + # $0.000025 per embedding call (approximate) + cost_est = round(p["day_used"] * 0.000025, 6) + + if p["day_used"] > 0: + provider_usage.append( + { + "name": p["name"], + "calls_today": p["day_used"], + "estimated_cost_usd": cost_est, + "free": p["free"], + } + ) + + # $300 trial, assume 90 days started ~June 1, 2026 + trial_start = time.mktime(time.strptime("2026-06-01", "%Y-%m-%d")) + trial_end = trial_start + 90 * 86400 + days_remaining = max(0, (trial_end - time.time()) / 86400) + + # Daily burn rate estimate + daily_cost = sum(p["estimated_cost_usd"] for p in provider_usage) + + return { + "trial": { + "credits": 300, + "days_remaining": round(days_remaining, 0), + "exhaustion_date": "2026-08-30" if days_remaining > 0 else "now", + }, + "today": { + "total_calls": total_calls_today, + "free_limit": total_free_limit, + "utilization_pct": round(total_calls_today / max(total_free_limit, 1) * 100, 2), + "estimated_cost_usd": daily_cost, + "daily_burn_rate": f"${daily_cost:.6f}", + }, + "provider_usage": provider_usage, + "note": "Gemini, Mistral, NVIDIA, and OpenRouter are FREE. Only Vertex AI burns enterprise credits.", + } diff --git a/app/_archive/legacy_2026_07/rag_endpoints.py b/app/_archive/legacy_2026_07/rag_endpoints.py new file mode 100644 index 0000000..e6caf96 --- /dev/null +++ b/app/_archive/legacy_2026_07/rag_endpoints.py @@ -0,0 +1,125 @@ +""" +RAG optimization endpoints - included via APIRouter for clean git history. +Confidence scoring, Solidity chunking, deployer reputation, cross-chain, +multi-modal, investigation narratives, graph RAG, streaming, email. +""" + +import httpx +from fastapi import APIRouter, Request + +router = APIRouter(prefix="/api/v1", tags=["rag-optimization"]) + + +@router.post("/rag/confidence") +async def rag_confidence_score(request: Request, data: dict): + from app.confidence import score_confidence + + return score_confidence( + data.get("results", []), + query=data.get("query", ""), + entity_matches=data.get("entity_matches", []), + ) + + +@router.post("/rag/chunk-solidity") +async def rag_chunk_solidity(request: Request, data: dict): + source = data.get("source", "") + if not source: + return {"error": "source is required"} + from app.solidity_chunker import chunk_solidity_ast + + chunks = chunk_solidity_ast(source) + return {"chunks": chunks, "count": len(chunks)} + + +@router.post("/scanner/deployer-risk") +async def scanner_deployer_risk(request: Request, data: dict): + from app.deployer_reputation import score_deployer_risk + + return score_deployer_risk( + address=data.get("address", ""), + deployment_count=data.get("deployment_count", 0), + mixer_funded=data.get("mixer_funded", False), + source_verified=data.get("source_verified", False), + code_similarity=data.get("code_similarity", 0.0), + rapid_deploy=data.get("rapid_deploy", False), + funded_by_scammer=data.get("funded_by_scammer", False), + ) + + +@router.post("/rag/cross-chain") +async def rag_cross_chain(request: Request, data: dict): + from app.cross_chain import resolve_cross_chain_identity + + return resolve_cross_chain_identity( + address=data.get("address", ""), + chain=data.get("chain", "ethereum"), + funding_sources=data.get("funding_sources", []), + label_hints=data.get("label_hints", []), + ) + + +@router.post("/rag/image-analysis") +async def rag_image_analysis(request: Request, data: dict): + from app.multimodal_rag import describe_image + + return { + "task": data.get("task", "describe"), + "description": await describe_image(image_url=data.get("image_url", ""), task=data.get("task", "describe")), + } + + +@router.post("/rag/logo-check") +async def rag_logo_check(request: Request, data: dict): + url = data.get("image_url", "") + if not url: + return {"error": "image_url is required"} + r = await httpx.AsyncClient(timeout=15).get(url) + from app.multimodal_rag import compute_image_hash, get_logo_db + + h = compute_image_hash(r.content) + return {**get_logo_db().check_theft(h), "computed_hash": h} + + +@router.post("/rag/investigate") +async def rag_investigate_endpoint(request: Request, data: dict): + q = data.get("query", "") + if not q: + return {"error": "query is required"} + from app.investigation_narratives import investigate + + return await investigate(query=q, investigation_type=data.get("type", "auto"), max_hops=data.get("max_hops", 5)) + + +@router.get("/rag/graph-communities") +async def rag_graph_communities(request: Request): + from app.graph_rag import graph_rag_search + + return await graph_rag_search(query="") + + +@router.post("/rag/watch") +async def rag_create_watch(request: Request, data: dict): + from app.streaming_rag import create_watch + + return await create_watch(token_address=data.get("token_address", ""), chain=data.get("chain", "ethereum")) + + +@router.get("/rag/watches") +async def rag_list_watches(request: Request): + from app.streaming_rag import list_active_watches + + return await list_active_watches() + + +@router.get("/email/accounts") +async def email_accounts(request: Request): + return { + "accounts": [ + {"id": "gmail", "email": "cryptorugmuncher@gmail.com", "type": "gmail"}, + {"id": "rmi", "email": "admin@rugmunch.io", "type": "gmail"}, + {"id": "rmi-local", "email": "admin@rugmunch.io", "type": "local"}, + {"id": "biz", "email": "biz@rugmunch.io", "type": "local"}, + ], + "webmail_url": "https://webmail.rugmunch.io/", + } diff --git a/app/_archive/legacy_2026_07/rag_evaluation.py b/app/_archive/legacy_2026_07/rag_evaluation.py new file mode 100644 index 0000000..b22ed84 --- /dev/null +++ b/app/_archive/legacy_2026_07/rag_evaluation.py @@ -0,0 +1,413 @@ +""" +RAGAS Evaluation Pipeline - Weekly RAG quality assessment. +============================================================= +Evaluates RAG system against golden test set using RAGAS metrics: + - faithfulness: is the answer grounded in retrieved context? + - answer_relevancy: does the answer address the question? + - context_precision: are retrieved chunks relevant? + - context_recall: are all relevant chunks retrieved? + +Golden test set: 20 known crypto scam/intel queries with expected answers. +Runs weekly. Alerts on regression > 10% from baseline. +""" + +import asyncio +import json +from datetime import UTC, datetime + +import httpx + +BACKEND = "http://localhost:8000" +RAG_SEARCH = f"{BACKEND}/api/v1/rag/search" + +# ═══════════════════════════════════════════════════ +# GOLDEN TEST SET - 20 queries with expected answers +# ═══════════════════════════════════════════════════ +GOLDEN_TESTS = [ + { + "query": "What are the most common DeFi hack techniques?", + "collection": "defi_hacks", + "expected_terms": [ + "private key", + "flash loan", + "oracle manipulation", + "reentrancy", + "access control", + "supply chain", + ], + "min_results": 3, + }, + { + "query": "How much was stolen in the Bybit hack?", + "collection": "defi_hacks", + "expected_terms": ["bybit", "1.4", "billion", "1.46"], + "min_results": 1, + }, + { + "query": "What is a honeypot scam in crypto?", + "collection": "scam_patterns", + "expected_terms": ["honeypot", "sell", "restriction", "cannot sell", "maxTx"], + "min_results": 2, + }, + { + "query": "What are the signs of a rug pull?", + "collection": "rug_timeline", + "expected_terms": ["liquidity", "remove", "lp", "supply", "concentration", "fresh wallet"], + "min_results": 2, + }, + { + "query": "How do crypto money launderers operate?", + "collection": "transaction_patterns", + "expected_terms": ["chain hopping", "mixer", "peel chain", "cex", "cross chain"], + "min_results": 2, + }, + { + "query": "What did the Chainalysis 2025 crime report find?", + "collection": "crime_reports", + "expected_terms": ["40.9", "billion", "illicit", "stablecoin", "63%", "stolen"], + "min_results": 1, + }, + { + "query": "What are the top smart contract vulnerabilities?", + "collection": "vuln_patterns", + "expected_terms": ["access control", "reentrancy", "oracle", "private key", "flash loan"], + "min_results": 2, + }, + { + "query": "How much did North Korean hackers steal in 2024?", + "collection": "crime_reports", + "expected_terms": ["north korea", "1.34", "billion", "61%"], + "min_results": 1, + }, + { + "query": "What happened with the Squid Game token?", + "collection": "rug_timeline", + "expected_terms": ["squid", "game", "honeypot", "couldn't sell", "3.38"], + "min_results": 1, + }, + { + "query": "What is a bundle attack in crypto?", + "collection": "transaction_patterns", + "expected_terms": ["bundle", "sniper", "mempool", "same block", "coordinated"], + "min_results": 1, + }, + { + "query": "How does wash trading work in crypto?", + "collection": "transaction_patterns", + "expected_terms": ["wash trading", "circular", "volume", "inflation", "same entity"], + "min_results": 1, + }, + { + "query": "What is the biggest DeFi hack ever?", + "collection": "defi_hacks", + "expected_terms": ["bybit", "ronin", "poly network", "billion"], + "min_results": 2, + }, + { + "query": "What are pig butchering scams?", + "collection": "crime_reports", + "expected_terms": ["pig butchering", "romance", "investment", "scam"], + "min_results": 1, + }, + { + "query": "How do pump and dump schemes work in crypto?", + "collection": "transaction_patterns", + "expected_terms": ["pump", "dump", "insider", "accumulation", "fomo", "social"], + "min_results": 1, + }, + { + "query": "What did TRM Labs report about crypto crime in 2025?", + "collection": "crime_reports", + "expected_terms": ["158", "billion", "illicit", "A7A5", "russia", "sanctions"], + "min_results": 1, + }, + { + "query": "What percentage of bug bounty programs find critical bugs?", + "collection": "vuln_patterns", + "expected_terms": ["93.9%", "critical", "bug bounty", "immunefi"], + "min_results": 1, + }, + { + "query": "What is the SafeMoon scam?", + "collection": "rug_timeline", + "expected_terms": ["safemoon", "liquidity", "drain", "arrested", "fraud"], + "min_results": 1, + }, + { + "query": "How are crypto scams using AI?", + "collection": "crime_reports", + "expected_terms": ["AI", "deepfake", "personalized", "KYC", "bypass"], + "min_results": 1, + }, + { + "query": "What is a flash loan attack?", + "collection": "vuln_patterns", + "expected_terms": ["flash loan", "uncollateralized", "manipulate", "arbitrage"], + "min_results": 1, + }, + { + "query": "What are the signs of a honeypot token contract?", + "collection": "scam_patterns", + "expected_terms": ["honeypot", "sell", "restriction", "maxTx", "trading", "owner"], + "min_results": 2, + }, + # ── Expanded tests (30 new) ── + { + "query": "What is the OWASP Smart Contract Top 10?", + "collection": "vuln_patterns", + "expected_terms": ["access control", "business logic", "oracle", "reentrancy", "proxy"], + "min_results": 3, + }, + { + "query": "How was the Ronin Bridge hacked?", + "collection": "defi_hacks", + "expected_terms": ["ronin", "bridge", "validator", "private key"], + "min_results": 1, + }, + { + "query": "What are common smart contract audit checklist items?", + "collection": "contract_audits", + "expected_terms": ["access control", "overflow", "reentrancy", "oracle", "validation"], + "min_results": 2, + }, + { + "query": "How does a flash loan attack work?", + "collection": "defi_hacks", + "expected_terms": ["flash loan", "uncollateralized", "single transaction", "arbitrage"], + "min_results": 1, + }, + { + "query": "What happened in the OneCoin scam?", + "collection": "rug_timeline", + "expected_terms": ["onecoin", "ponzi", "4 billion", "bitcoin"], + "min_results": 1, + }, + { + "query": "What is a private key compromise attack?", + "collection": "defi_hacks", + "expected_terms": ["private key", "compromise", "hot wallet", "phishing"], + "min_results": 2, + }, + { + "query": "How do cross-chain bridge hacks work?", + "collection": "defi_hacks", + "expected_terms": ["bridge", "cross chain", "validator", "message"], + "min_results": 2, + }, + { + "query": "What are the top smart contract vulnerabilities in 2025?", + "collection": "vuln_patterns", + "expected_terms": ["access control", "reentrancy", "oracle", "overflow", "proxy"], + "min_results": 2, + }, + { + "query": "How did the Squid Game token scam work?", + "collection": "rug_timeline", + "expected_terms": ["squid", "game", "honeypot", "sell", "2,861"], + "min_results": 1, + }, + { + "query": "What is a supply chain attack in crypto?", + "collection": "vuln_patterns", + "expected_terms": ["supply chain", "ads", "power", "bybit", "compromise"], + "min_results": 1, + }, + { + "query": "How much crypto was stolen in 2024?", + "collection": "crime_reports", + "expected_terms": ["2.2", "billion", "stolen", "40.9"], + "min_results": 1, + }, + { + "query": "What is the GMX V1 vulnerability?", + "collection": "defi_hacks", + "expected_terms": ["gmx", "reentrancy", "glp", "arbitrum"], + "min_results": 1, + }, + { + "query": "How does Tornado Cash work in laundering?", + "collection": "transaction_patterns", + "expected_terms": ["tornado", "mixer", "launder", "privacy"], + "min_results": 1, + }, + { + "query": "What is an access control vulnerability?", + "collection": "vuln_patterns", + "expected_terms": ["access control", "unauthorized", "privileged", "owner"], + "min_results": 2, + }, + { + "query": "How did BitConnect scam investors?", + "collection": "rug_timeline", + "expected_terms": ["bitconnect", "ponzi", "40%", "2 billion"], + "min_results": 1, + }, + { + "query": "What happened with the Poly Network hack?", + "collection": "defi_hacks", + "expected_terms": ["poly network", "610", "cross chain", "white hat"], + "min_results": 1, + }, + { + "query": "How do North Korean hackers steal crypto?", + "collection": "crime_reports", + "expected_terms": ["north korea", "lazarus", "1.34", "61%", "IT workers"], + "min_results": 1, + }, + { + "query": "What is a proxy upgrade vulnerability?", + "collection": "vuln_patterns", + "expected_terms": ["proxy", "upgrade", "implementation", "initialize"], + "min_results": 1, + }, + { + "query": "How does the SENTINEL scanner detect scams?", + "collection": "known_scams", + "expected_terms": ["scam", "detect", "honeypot", "rug"], + "min_results": 1, + }, + { + "query": "What are common DeFi money laundering patterns?", + "collection": "transaction_patterns", + "expected_terms": ["peel chain", "mixer", "chain hop", "cex"], + "min_results": 1, + }, + { + "query": "How did the Euler Finance hack happen?", + "collection": "defi_hacks", + "expected_terms": ["euler", "flash loan", "197", "donate"], + "min_results": 1, + }, + { + "query": "What percentage of stolen crypto is from private key compromises?", + "collection": "crime_reports", + "expected_terms": ["43.8%", "private key", "stolen", "compromise"], + "min_results": 1, + }, + { + "query": "How do oracle manipulation attacks work?", + "collection": "vuln_patterns", + "expected_terms": ["oracle", "price", "manipulation", "flash loan", "twap"], + "min_results": 1, + }, + { + "query": "What is a reentrancy attack in Solidity?", + "collection": "vuln_patterns", + "expected_terms": ["reentrancy", "callback", "withdraw", "state"], + "min_results": 1, + }, + { + "query": "How did SafeMoon defraud investors?", + "collection": "rug_timeline", + "expected_terms": ["safemoon", "liquidity", "drain", "arrest"], + "min_results": 1, + }, + { + "query": "What are the biggest DeFi hacks by amount lost?", + "collection": "defi_hacks", + "expected_terms": ["bybit", "ronin", "poly", "billion", "million"], + "min_results": 3, + }, + { + "query": "How does a sniper bot attack tokens at launch?", + "collection": "transaction_patterns", + "expected_terms": ["sniper", "mempool", "same block", "gas"], + "min_results": 1, + }, + { + "query": "What is business logic vulnerability in smart contracts?", + "collection": "vuln_patterns", + "expected_terms": ["business logic", "design", "lending", "amm", "reward"], + "min_results": 1, + }, + { + "query": "How did the Thodex exchange scam work?", + "collection": "rug_timeline", + "expected_terms": ["thodex", "exchange", "2 billion", "turkey"], + "min_results": 1, + }, + { + "query": "What are the most exploited chains for DeFi hacks?", + "collection": "defi_hacks", + "expected_terms": ["ethereum", "bsc", "polygon", "arbitrum", "solana"], + "min_results": 2, + }, +] + + +async def evaluate_single(test: dict) -> dict: + """Run a single test query and score it.""" + query = test["query"] + collection = test["collection"] + expected_terms = [t.lower() for t in test["expected_terms"]] + min_results = test["min_results"] + + try: + async with httpx.AsyncClient(timeout=30) as c: + r = await c.get( + RAG_SEARCH, + params={ + "q": query, + "collection": collection, + "limit": 10, + }, + ) + if r.status_code != 200: + return {"query": query, "error": f"HTTP {r.status_code}", "score": 0} + + data = r.json() + results = data.get("results", []) + total = data.get("total", 0) + + # Score: context_precision - how many expected terms appear in results? + all_text = " ".join([res.get("content", res.get("text", "")) for res in results]).lower() + terms_found = sum(1 for t in expected_terms if t in all_text) + precision = terms_found / len(expected_terms) if expected_terms else 0 + + # Score: context_recall - did we get enough results? + recall = min(total / min_results, 1.0) if min_results > 0 else 1.0 + + # Combined score + score = precision * 0.6 + recall * 0.4 + + return { + "query": query[:60], + "collection": collection, + "results_found": total, + "min_expected": min_results, + "terms_matched": f"{terms_found}/{len(expected_terms)}", + "precision": round(precision, 3), + "recall": round(recall, 3), + "score": round(score, 3), + } + except Exception as e: + return {"query": query[:60], "error": str(e)[:200], "score": 0} + + +async def run_evaluation() -> dict: + """Run full evaluation against golden test set.""" + results = [] + for test in GOLDEN_TESTS: + result = await evaluate_single(test) + results.append(result) + + scores = [r["score"] for r in results if "score" in r] + avg_score = sum(scores) / len(scores) if scores else 0 + passing = sum(1 for s in scores if s >= 0.5) + failing = sum(1 for s in scores if s < 0.3) + + return { + "test_count": len(GOLDEN_TESTS), + "tests_run": len(results), + "average_score": round(avg_score, 3), + "passing": passing, + "failing": failing, + "pass_rate": round(passing / len(scores), 3) if scores else 0, + "results": results, + "timestamp": datetime.now(UTC).isoformat(), + } + + +if __name__ == "__main__": + result = asyncio.run(run_evaluation()) + print(json.dumps(result, indent=2)) diff --git a/app/_archive/legacy_2026_07/rag_feedback.py b/app/_archive/legacy_2026_07/rag_feedback.py new file mode 100644 index 0000000..e4dba9e --- /dev/null +++ b/app/_archive/legacy_2026_07/rag_feedback.py @@ -0,0 +1,242 @@ +""" +RAG Feedback Loop - Scanner results feed back into RAG +====================================================== + +When the SENTINEL scanner confirms a token is a scam/honeypot/rug, +this module adjusts the RAG document weights so similar patterns +get higher priority in future searches. + +Flow: + Scanner reports scam → Feedback endpoint called + → Find matching RAG documents (by address, pattern, chain) + → Boost their weight in Redis metadata + → FAISS index marked for rebuild on next cycle + → Similar documents get +weight from confirmed patterns + +Also tracks false positives: + Scanner clears a token → Penalize matching RAG docs + → Reduce weight → fewer false alarms + +This creates a LEARNING LOOP: the more the scanner runs, +the smarter the RAG gets. +""" + +import contextlib +import logging +from typing import Any + +logger = logging.getLogger("rag.feedback") + +# Weight adjustment constants +SCAM_CONFIRMED_BOOST = 0.3 # +30% weight for confirmed scam matches +FALSE_POSITIVE_PENALTY = -0.2 # -20% weight for false positives +SCANNER_HIT_BOOST = 0.05 # +5% per scanner hit on a document +MAX_WEIGHT = 3.0 # Cap at 3x original weight +MIN_WEIGHT = 0.1 # Floor at 10% original + + +async def record_scanner_result( + address: str, + chain: str, + verdict: str, # "scam", "honeypot", "rug", "safe", "unknown" + confidence: float, # 0.0 - 1.0 + flags: list | None = None, + token_name: str = "", +) -> dict[str, Any]: + """ + Record a scanner verdict and adjust RAG weights accordingly. + + Called by SENTINEL scanner after each token scan. + """ + import os as _os + + import redis.asyncio as aioredis + + r = aioredis.Redis( + host=_os.getenv("REDIS_HOST", "rmi-redis"), + port=int(_os.getenv("REDIS_PORT", "6379")), + password=_os.getenv("REDIS_PASSWORD", ""), + db=int(_os.getenv("REDIS_DB", "0")), + socket_connect_timeout=3, + socket_timeout=3, + decode_responses=True, + ) + + adjustments = {"boosted": 0, "penalized": 0, "errors": 0} + + try: + is_scam = verdict in ("scam", "honeypot", "rug", "critical") + adjustment = SCAM_CONFIRMED_BOOST if is_scam else FALSE_POSITIVE_PENALTY if verdict == "safe" else 0 + + if adjustment == 0: + await r.aclose() + return {"status": "no_action", "verdict": verdict} + + # Find RAG documents matching this address + # Search by address in known_scams collection + doc_ids = await r.smembers(f"rag:entity:address:{address.lower()}") + + if not doc_ids: + # Try fuzzy - search for partial address in content + # Use Redis scan for efficiency + cursor = 0 + pattern = "rag:known_scams:*" + while True: + cursor, keys = await r.scan(cursor, match=pattern, count=100) + for key in keys: + try: + doc = await r.get(key) + if doc and address.lower() in doc.lower(): + doc_id = key.split(":")[-1] + doc_ids.add(doc_id) + except Exception: + pass + if cursor == 0: + break + + # Also find documents with similar scam patterns (by flags) + if is_scam and flags: + cursor = 0 + pattern = "rag:known_scams:*" + while True: + cursor, keys = await r.scan(cursor, match=pattern, count=100) + for key in keys: + try: + doc = await r.get(key) + if doc: + doc_lower = doc.lower() + matching_flags = sum(1 for f in flags if f.lower() in doc_lower) + if matching_flags >= 2: # At least 2 flags match + doc_id = key.split(":")[-1] + doc_ids.add(doc_id) + except Exception: + pass + if cursor == 0: + break + + # Apply weight adjustments + for doc_id in doc_ids: + try: + # Read current weight + weight_key = f"rag:weight:{doc_id}" + current_weight = await r.get(weight_key) + current = float(current_weight) if current_weight else 1.0 + + # Adjust + new_weight = current + (adjustment * confidence) + new_weight = max(MIN_WEIGHT, min(MAX_WEIGHT, new_weight)) + + await r.set(weight_key, str(new_weight)) + + if adjustment > 0: + adjustments["boosted"] += 1 + else: + adjustments["penalized"] += 1 + + except Exception as e: + adjustments["errors"] += 1 + logger.debug(f"Weight adjustment error for {doc_id}: {e}") + + # Record scanner hit count for analytics + if doc_ids: + hit_key = f"rag:scanner_hits:{address.lower()}" + await r.incr(hit_key) + await r.expire(hit_key, 86400 * 30) # 30 day TTL + + # Mark FAISS for rebuild on next firehose cycle + if adjustments["boosted"] + adjustments["penalized"] > 0: + await r.set("rag:faiss:dirty", "1") + + logger.info( + f"RAG feedback: {verdict} for {address} → " + f"+{adjustments['boosted']} boosted, " + f"-{adjustments['penalized']} penalized" + ) + + await r.aclose() + return { + "status": "ok", + "verdict": verdict, + "adjustment": adjustment, + "documents_adjusted": adjustments["boosted"] + adjustments["penalized"], + "boosted": adjustments["boosted"], + "penalized": adjustments["penalized"], + "faiss_marked_dirty": adjustments["boosted"] + adjustments["penalized"] > 0, + } + + except Exception as e: + logger.error(f"RAG feedback error: {e}") + with contextlib.suppress(Exception): + await r.aclose() + return {"status": "error", "detail": str(e)} + + +async def get_document_weight(doc_id: str) -> float: + """Get the current learned weight for a RAG document.""" + import os as _os + + import redis.asyncio as aioredis + + try: + r = aioredis.Redis( + host=_os.getenv("REDIS_HOST", "rmi-redis"), + port=int(_os.getenv("REDIS_PORT", "6379")), + password=_os.getenv("REDIS_PASSWORD", ""), + db=int(_os.getenv("REDIS_DB", "0")), + socket_connect_timeout=2, + socket_timeout=2, + decode_responses=True, + ) + weight = await r.get(f"rag:weight:{doc_id}") + await r.aclose() + return float(weight) if weight else 1.0 + except Exception: + return 1.0 + + +async def get_feedback_stats() -> dict[str, Any]: + """Get feedback loop statistics.""" + import os as _os + + import redis.asyncio as aioredis + + try: + r = aioredis.Redis( + host=_os.getenv("REDIS_HOST", "rmi-redis"), + port=int(_os.getenv("REDIS_PORT", "6379")), + password=_os.getenv("REDIS_PASSWORD", ""), + db=int(_os.getenv("REDIS_DB", "0")), + socket_connect_timeout=2, + socket_timeout=2, + decode_responses=True, + ) + + # Count weight-adjusted documents + weight_keys = 0 + total_weight = 0.0 + cursor = 0 + while True: + cursor, keys = await r.scan(cursor, match="rag:weight:*", count=500) + for key in keys: + try: + w = await r.get(key) + if w: + weight_keys += 1 + total_weight += float(w) + except Exception: + pass + if cursor == 0: + break + + avg_weight = total_weight / weight_keys if weight_keys > 0 else 1.0 + faiss_dirty = await r.get("rag:faiss:dirty") == "1" + + await r.aclose() + return { + "documents_with_weights": weight_keys, + "average_weight": round(avg_weight, 3), + "faiss_needs_rebuild": faiss_dirty, + "weight_range": f"{MIN_WEIGHT} - {MAX_WEIGHT}", + } + except Exception as e: + return {"status": "error", "detail": str(e)} diff --git a/app/_archive/legacy_2026_07/rag_firehose.py b/app/_archive/legacy_2026_07/rag_firehose.py new file mode 100644 index 0000000..2bb255a --- /dev/null +++ b/app/_archive/legacy_2026_07/rag_firehose.py @@ -0,0 +1,877 @@ +""" +RAG Firehose - Continuous Intelligence Ingestion Engine +======================================================== + +Self-feeding RAG pipeline that continuously pulls, filters, and ingests +crypto intelligence from multiple sources at different cadences. + +Architecture: + ┌─────────────────────────────────────────────────────────┐ + │ FIREHOSE ENGINE │ + │ │ + │ Hourly (news/social) Daily (scams/wallets) Weekly │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ + │ │ News RSS │ │ Etherscan│ │ FAISS │ │ + │ │ CT Rundn │ │ Chainab. │ │ rebuild │ │ + │ │ Social │ │ Rekt DB │ │ RAGAS │ │ + │ │ Sentiment│ │ Solana │ │ eval │ │ + │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ + │ │ │ │ │ + │ └──────────────────────┼─────────────────────┘ │ + │ ▼ │ + │ ┌────────────────────────────┐ │ + │ │ SMART INGESTION PIPELINE │ │ + │ │ Filter → Dedup → Extract │ │ + │ │ → Classify → Embed → Store │ │ + │ └────────────┬───────────────┘ │ + │ ▼ │ + │ ┌────────────────────────────┐ │ + │ │ RAG COLLECTIONS │ │ + │ │ known_scams, news_articles │ │ + │ │ forensic_reports, etc. │ │ + │ └────────────┬───────────────┘ │ + │ ▼ │ + │ ┌────────────────────────────┐ │ + │ │ FEEDBACK LOOP │ │ + │ │ Scanner hits → boost docs │ │ + │ │ False positives → penalize │ │ + │ └────────────────────────────┘ │ + └─────────────────────────────────────────────────────────┘ + +Feed Cadences: + - 15min: CT rundown, social sentiment, scam alerts (high-urgency) + - 1hr: News RSS (200+ feeds), market brief, fear & greed + - 6hr: X/Twitter profiles, KOL tracking, prediction markets + - 24hr: Etherscan labels, Solana registry, chainabuse, rekt DB + - 72hr: FAISS index rebuild, BM25 rebuild, RAGAS evaluation + - 168hr: Full pattern extraction from confirmed scams, quality audit + +Smart Ingestion: + - Content hash dedup (Redis) - never ingest the same doc twice + - Quality scoring - skip low-signal content (<30 score) + - Entity extraction - pull addresses, chains, tokens, protocols + - Auto-classification - route to correct collection + - Batch embedding with rate limiting - never overload embedder + - Per-collection size caps - auto-evict oldest on overflow +""" + +import asyncio +import contextlib +import hashlib +import logging +import os +import time +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +import httpx + +logger = logging.getLogger("rag.firehose") + +# ────────────────────────────────────────────────────────────── +# Configuration +# ────────────────────────────────────────────────────────────── + +RAG_API = "http://localhost:8000/api/v1/rag" + +# Per-collection size caps (auto-evict oldest on overflow) +COLLECTION_CAPS = { + "known_scams": 50000, # scam addresses - keep forever, large + "scam_patterns": 5000, # curated patterns - small, high quality + "forensic_reports": 10000, # hack reports - medium + "contract_audits": 5000, # code audits - medium + "wallet_profiles": 100000, # labeled wallets - large + "news_articles": 20000, # news - rolling window + "market_intel": 5000, # market data - medium + "token_analysis": 50000, # token data - large + "transaction_patterns": 10000, # on-chain patterns - medium + "social_sentiment": 10000, # social data - rolling + "general": 10000, # misc - catch-all +} + +# Quality thresholds (skip docs below this score) +MIN_QUALITY_SCORE = 30 # 0-100 + +# Rate limits (docs per minute per collection) +RATE_LIMITS = { + "known_scams": 60, + "news_articles": 30, + "social_sentiment": 20, + "default": 30, +} + +# Content hash TTL in Redis (7 days for news, 30 for scams) +HASH_TTL = { + "news_articles": 604800, # 7 days + "social_sentiment": 604800, # 7 days + "known_scams": 2592000, # 30 days + "default": 1209600, # 14 days +} + +# ────────────────────────────────────────────────────────────── +# Data Structures +# ────────────────────────────────────────────────────────────── + + +@dataclass +class FeedSource: + """A data source the firehose pulls from.""" + + name: str + collection: str + cadence_seconds: int + fetch_fn: Any = field(repr=False) + enabled: bool = True + description: str = "" + last_run: float = 0 + runs: int = 0 + docs_ingested: int = 0 + docs_skipped: int = 0 + errors: int = 0 + + +@dataclass +class FirehoseStats: + """Current firehose statistics.""" + + running: bool = False + uptime_seconds: float = 0 + total_sources: int = 0 + total_ingested: int = 0 + total_skipped: int = 0 + total_errors: int = 0 + sources: dict[str, dict] = field(default_factory=dict) + last_activity: float = 0 + + +# ────────────────────────────────────────────────────────────── +# Smart Ingestion Pipeline +# ────────────────────────────────────────────────────────────── + + +class IngestionPipeline: + """Filters, deduplicates, and enriches documents before RAG storage.""" + + def __init__(self, client: httpx.AsyncClient): + self.client = client + self._rate_trackers: dict[str, list[float]] = {} # collection → recent ingest timestamps + + def _check_rate(self, collection: str) -> bool: + """Return True if we're under the rate limit for this collection.""" + limit = RATE_LIMITS.get(collection, RATE_LIMITS["default"]) + now = time.monotonic() + times = self._rate_trackers.setdefault(collection, []) + # Remove timestamps older than 60s + times[:] = [t for t in times if now - t < 60] + return len(times) < limit + + def _record_ingest(self, collection: str): + """Record an ingestion for rate limiting.""" + self._rate_trackers.setdefault(collection, []).append(time.monotonic()) + + def make_content_hash(self, content: str) -> str: + """Deterministic hash for dedup.""" + return hashlib.sha256(content.encode()).hexdigest()[:16] + + async def is_duplicate(self, content_hash: str, collection: str) -> bool: + """Check if content hash already exists in Redis dedup set.""" + try: + resp = await self.client.get( + f"{RAG_API}/dedup-check", + params={"hash": content_hash, "collection": collection}, + timeout=5, + ) + if resp.status_code == 200: + return resp.json().get("exists", False) + except Exception: + pass + return False + + def score_quality(self, content: str, metadata: dict) -> int: + """Score document quality 0-100. Skip low-signal content.""" + score = 50 # Start neutral + + # Length bonus (substantial content is better) + if len(content) > 500: + score += 15 + elif len(content) > 200: + score += 10 + elif len(content) < 50: + score -= 20 + + # Entity richness (more entities = more useful) + entity_count = 0 + if metadata.get("address"): + entity_count += 1 + if metadata.get("chain"): + entity_count += 1 + if metadata.get("token"): + entity_count += 1 + if metadata.get("protocol"): + entity_count += 1 + score += entity_count * 5 + + # Source authority + high_authority = {"etherscan", "chainabuse", "rekt", "certik", "slowmist", "peckshield"} + if metadata.get("source", "").lower() in high_authority: + score += 20 + + # Severity boost (critical scams are more valuable) + if metadata.get("severity") == "critical": + score += 15 + elif metadata.get("severity") == "high": + score += 10 + + # Penalize duplicates of very similar content + if metadata.get("is_variant"): + score -= 10 + + return max(0, min(100, score)) + + def extract_entities(self, content: str) -> dict: + """Extract addresses, chains, tokens from content.""" + import re + + entities = {"addresses": [], "chains": [], "tokens": [], "protocols": []} + + # EVM addresses (0x...) + evm_addrs = re.findall(r"0x[a-fA-F0-9]{40}", content) + entities["addresses"].extend(evm_addrs[:10]) + + # Solana addresses (base58, 32-44 chars) + sol_addrs = re.findall(r"[1-9A-HJ-NP-Za-km-z]{32,44}", content) + entities["addresses"].extend([a for a in sol_addrs[:10] if a not in entities["addresses"]]) + + # Known chains + known_chains = [ + "ethereum", + "bsc", + "polygon", + "arbitrum", + "optimism", + "avalanche", + "solana", + "base", + "fantom", + "gnosis", + "celo", + "zksync", + "linea", + "scroll", + "mantle", + "sui", + "aptos", + "near", + "tron", + "bitcoin", + ] + for chain in known_chains: + if chain.lower() in content.lower(): + entities["chains"].append(chain) + + # Token symbols ($TOKEN) + tokens = re.findall(r"\$([A-Z]{2,10})", content) + entities["tokens"].extend(tokens[:10]) + + # Known protocols + known_protocols = [ + "uniswap", + "aave", + "curve", + "balancer", + "sushi", + "pancake", + "raydium", + "jupiter", + "orca", + "marinade", + "lido", + "eigenlayer", + "compound", + "maker", + "yearn", + "convex", + ] + for proto in known_protocols: + if proto.lower() in content.lower(): + entities["protocols"].append(proto) + + return entities + + async def ingest(self, collection: str, content: str, metadata: dict, doc_id: str | None = None) -> dict[str, Any]: + """Run the full pipeline: dedup → quality → extract → classify → store.""" + + # 1. Hash and dedup + content_hash = self.make_content_hash(content) + if await self.is_duplicate(content_hash, collection): + return {"status": "duplicate", "hash": content_hash} + + # 2. Quality filter + quality = self.score_quality(content, metadata) + if quality < MIN_QUALITY_SCORE: + return {"status": "skipped", "reason": "low_quality", "score": quality} + + # 3. Entity extraction + entities = self.extract_entities(content) + metadata.update( + { + "entities": entities, + "quality_score": quality, + "content_hash": content_hash, + "ingested_at": datetime.now(UTC).isoformat(), + } + ) + + # 4. Rate limit check + if not self._check_rate(collection): + return {"status": "rate_limited", "collection": collection} + + # 5. Ingest into RAG + try: + payload = { + "collection": collection, + "content": content, + "metadata": metadata, + } + if doc_id: + payload["doc_id"] = doc_id + + resp = await self.client.post(f"{RAG_API}/ingest", json=payload, timeout=30) + self._record_ingest(collection) + + if resp.status_code == 200: + result = resp.json() + # Track in dedup set + asyncio.create_task(self._mark_ingested(content_hash, collection)) + return { + "status": "ingested", + "id": result.get("id"), + "quality": quality, + "entities": entities, + } + else: + return {"status": "error", "code": resp.status_code, "detail": resp.text[:200]} + except Exception as e: + return {"status": "error", "detail": str(e)[:200]} + + async def _mark_ingested(self, content_hash: str, collection: str): + """Mark content hash in Redis dedup set.""" + try: + ttl = HASH_TTL.get(collection, HASH_TTL["default"]) + await self.client.post( + f"{RAG_API}/dedup-mark", + json={"hash": content_hash, "collection": collection, "ttl": ttl}, + timeout=5, + ) + except Exception: + pass + + async def ingest_batch(self, docs: list[dict]) -> dict[str, int]: + """Ingest multiple documents with rate limiting.""" + stats = {"ingested": 0, "duplicate": 0, "skipped": 0, "errors": 0} + + for doc in docs: + collection = doc.get("collection", "general") + content = doc.get("content", "") + metadata = doc.get("metadata", {}) + doc_id = doc.get("doc_id") + + result = await self.ingest(collection, content, metadata, doc_id) + status = result.get("status", "error") + if status == "ingested": + stats["ingested"] += 1 + elif status == "duplicate": + stats["duplicate"] += 1 + elif status == "skipped": + stats["skipped"] += 1 + else: + stats["errors"] += 1 + + # Small delay between docs to avoid overwhelming embedder + await asyncio.sleep(0.05) + + return stats + + +# ────────────────────────────────────────────────────────────── +# Feed Sources - Pull Functions +# ────────────────────────────────────────────────────────────── + + +class FeedSources: + """All data sources the firehose can pull from.""" + + def __init__(self, client: httpx.AsyncClient): + self.client = client + + # ── Hourly: News ── + + async def pull_news_rss(self) -> list[dict]: + """Pull latest news from DataBus news provider (200+ RSS feeds).""" + try: + resp = await self.client.get( + "http://localhost:8000/api/v1/databus/fetch/news", params={"limit": 30}, timeout=30 + ) + if resp.status_code != 200: + return [] + data = resp.json() + articles = data.get("articles", data.get("data", [])) + + docs = [] + for article in (articles if isinstance(articles, list) else [])[:20]: + title = article.get("title", "") + desc = article.get("description", article.get("summary", "")) + if not title: + continue + content = f"News: {title}. {desc}" + docs.append( + { + "collection": "news_articles", + "content": content[:2000], + "metadata": { + "source": article.get("source", "news_rss"), + "url": article.get("url", ""), + "published": article.get("published_at", article.get("date", "")), + "category": article.get("category", "crypto"), + "title": title, + }, + } + ) + return docs + except Exception as e: + logger.warning(f"News RSS pull failed: {e}") + return [] + + async def pull_ct_rundown(self) -> list[dict]: + """Pull CT Rundown stories.""" + try: + resp = await self.client.get( + "http://localhost:8000/api/v1/databus/fetch/ct_rundown", + params={"limit": 10}, + timeout=30, + ) + if resp.status_code != 200: + return [] + data = resp.json() + stories = data.get("stories", data.get("data", [])) + + docs = [] + for story in (stories if isinstance(stories, list) else [])[:5]: + content = f"CT: {story.get('title', '')} - {story.get('summary', '')}" + docs.append( + { + "collection": "news_articles", + "content": content[:1500], + "metadata": { + "source": "ct_rundown", + "category": "crypto_twitter", + "handle": story.get("handle", ""), + "engagement": story.get("engagement", 0), + }, + } + ) + return docs + except Exception as e: + logger.warning(f"CT rundown pull failed: {e}") + return [] + + async def pull_social_sentiment(self) -> list[dict]: + """Pull social sentiment and scam alerts.""" + docs = [] + try: + # Scam monitor + resp = await self.client.get("http://localhost:8000/api/v1/databus/fetch/scam_monitor", timeout=20) + if resp.status_code == 200: + data = resp.json() + alerts = data.get("alerts", data.get("data", [])) + for alert in (alerts if isinstance(alerts, list) else [])[:10]: + docs.append( + { + "collection": "social_sentiment", + "content": f"Scam alert: {alert.get('title', '')} - {alert.get('description', '')}", + "metadata": { + "source": "scam_monitor", + "severity": alert.get("severity", "medium"), + "token": alert.get("token_address", ""), + "chain": alert.get("chain", ""), + }, + } + ) + + # Social metrics + resp2 = await self.client.get("http://localhost:8000/api/v1/databus/fetch/social_metrics", timeout=20) + if resp2.status_code == 200: + data2 = resp2.json() + metrics = data2.get("metrics", data2.get("data", {})) + if metrics: + docs.append( + { + "collection": "social_sentiment", + "content": f"Social metrics: Sentiment {metrics.get('sentiment', '?')}, " + f"Trending: {metrics.get('trending_topics', [])}", + "metadata": {"source": "social_metrics", "type": "daily_summary"}, + } + ) + except Exception as e: + logger.warning(f"Social pull failed: {e}") + + return docs + + # ── Daily: Scam Databases ── + + async def pull_etherscan_labels(self) -> list[dict]: + """Pull etherscan labeled addresses (via existing CSV).""" + docs = [] + csv_path = os.path.join(os.path.dirname(__file__), "..", "data", "etherscan_phish_hack.csv") + if not os.path.exists(csv_path): + return docs + + import csv + + try: + with open(csv_path, newline="", encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + + for row in rows: + addr = row.get("address", "").strip() + if not addr: + continue + content = ( + f"Etherscan label: {addr} on {row.get('chain', 'Ethereum')}. " + f"Tag: {row.get('name_tag', '')}. Type: {row.get('label_type', 'scam')}." + ) + docs.append( + { + "collection": "known_scams", + "content": content, + "metadata": { + "address": addr.lower(), + "chain": (row.get("chain", "Ethereum") or "Ethereum").lower(), + "label_type": row.get("label_type", "scam"), + "source": "etherscan", + "severity": "critical" if "phish" in row.get("label_type", "").lower() else "high", + }, + } + ) + except Exception as e: + logger.warning(f"Etherscan pull failed: {e}") + + return docs + + async def pull_solana_scams(self) -> list[dict]: + """Pull Solana token registry flagged tokens.""" + docs = [] + try: + resp = await self.client.get( + "https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/solana.tokenlist.json", + timeout=30, + ) + if resp.status_code == 200: + data = resp.json() + for token in data.get("tokens", []): + tags = [t.lower() for t in token.get("tags", [])] + if any(kw in str(tags) for kw in ["scam", "spam", "fake"]): + docs.append( + { + "collection": "known_scams", + "content": f"Solana scam token: {token.get('name', '')} ({token.get('symbol', '')}) " + f"at {token.get('address', '')}. Tags: {tags}.", + "metadata": { + "address": token.get("address", ""), + "name": token.get("name", ""), + "symbol": token.get("symbol", ""), + "chain": "solana", + "source": "solana_token_registry", + "tags": tags, + "severity": "high", + }, + } + ) + except Exception as e: + logger.warning(f"Solana pull failed: {e}") + + return docs + + async def pull_prediction_markets(self) -> list[dict]: + """Pull Polymarket prediction data for market intel.""" + docs = [] + try: + resp = await self.client.get("http://localhost:8000/api/v1/databus/fetch/prediction_markets", timeout=20) + if resp.status_code == 200: + data = resp.json() + markets = data.get("markets", data.get("data", [])) + for m in (markets if isinstance(markets, list) else [])[:10]: + docs.append( + { + "collection": "market_intel", + "content": f"Prediction market: {m.get('question', '')} - " + f"YES: {m.get('yes_price', '?')} NO: {m.get('no_price', '?')}", + "metadata": {"source": "polymarket", "type": "prediction"}, + } + ) + except Exception as e: + logger.warning(f"Prediction market pull failed: {e}") + + return docs + + # ── Weekly: Pattern Extraction ── + + async def extract_scam_patterns(self) -> list[dict]: + """Extract common patterns from confirmed scam documents.""" + docs = [] + try: + # Search for confirmed scams + resp = await self.client.get( + f"{RAG_API}/search", + params={ + "q": "rug pull honeypot scam confirmed", + "collection": "known_scams", + "limit": 50, + }, + timeout=30, + ) + if resp.status_code == 200: + data = resp.json() + results = data.get("results", []) + if len(results) >= 10: + # Create a meta-pattern document + content_parts = [] + for r in results[:20]: + c = r.get("content", "")[:200] + if c: + content_parts.append(c) + + combined = "Common scam patterns observed: " + " | ".join(content_parts) + docs.append( + { + "collection": "scam_patterns", + "content": combined[:5000], + "metadata": { + "source": "pattern_extraction", + "pattern_count": len(results), + "extracted_at": datetime.now(UTC).isoformat(), + }, + } + ) + except Exception as e: + logger.warning(f"Pattern extraction failed: {e}") + + return docs + + +# ────────────────────────────────────────────────────────────── +# Firehose Engine +# ────────────────────────────────────────────────────────────── + + +class FirehoseEngine: + """The central continuous ingestion engine.""" + + def __init__(self): + self._running = False + self._task: asyncio.Task | None = None + self._client: httpx.AsyncClient | None = None + self._pipeline: IngestionPipeline | None = None + self._feeds: FeedSources | None = None + self._sources: list[FeedSource] = [] + self._start_time: float = 0 + self.stats = FirehoseStats() + self._lock = asyncio.Lock() + + async def start(self): + """Start the firehose engine.""" + if self._running: + logger.info("Firehose already running") + return + + self._client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(max_connections=20)) + self._pipeline = IngestionPipeline(self._client) + self._feeds = FeedSources(self._client) + self._start_time = time.monotonic() + + # Define all feed sources with cadences + self._sources = [ + # ── 15-minute cadence: High urgency ── + FeedSource( + "ct_rundown", + "news_articles", + 900, + self._feeds.pull_ct_rundown, + True, + "CT Rundown stories", + ), + FeedSource( + "social_sentiment", + "social_sentiment", + 900, + self._feeds.pull_social_sentiment, + True, + "Social sentiment and scam alerts", + ), + # ── 1-hour cadence: News ── + FeedSource( + "news_rss", + "news_articles", + 3600, + self._feeds.pull_news_rss, + True, + "200+ RSS crypto news feeds", + ), + # ── 6-hour cadence: Market data ── + FeedSource( + "prediction_markets", + "market_intel", + 21600, + self._feeds.pull_prediction_markets, + True, + "Polymarket predictions", + ), + # ── 24-hour cadence: Scam databases ── + FeedSource( + "etherscan_labels", + "known_scams", + 86400, + self._feeds.pull_etherscan_labels, + True, + "Etherscan phish/hack labeled addresses", + ), + FeedSource( + "solana_scams", + "known_scams", + 86400, + self._feeds.pull_solana_scams, + True, + "Solana token registry flagged tokens", + ), + # ── 72-hour cadence: Pattern extraction ── + FeedSource( + "scam_patterns", + "scam_patterns", + 259200, + self._feeds.extract_scam_patterns, + True, + "Extract common patterns from confirmed scams", + ), + ] + + self.stats.total_sources = len(self._sources) + self._running = True + self._task = asyncio.create_task(self._run_loop()) + logger.info(f"Firehose started with {len(self._sources)} sources") + + async def stop(self): + """Stop the firehose engine.""" + self._running = False + if self._task: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + if self._client: + await self._client.aclose() + logger.info("Firehose stopped") + + async def _run_loop(self): + """Main firehose loop - checks sources and runs those due.""" + logger.info("Firehose loop started") + + while self._running: + now = time.monotonic() + + for source in self._sources: + if not source.enabled: + continue + if now - source.last_run < source.cadence_seconds: + continue + + # Run this source + source.last_run = now + source.runs += 1 + self.stats.last_activity = now + + try: + logger.debug(f"Firehose: pulling {source.name}") + docs = await source.fetch_fn() + + if docs: + stats = await self._pipeline.ingest_batch(docs) + source.docs_ingested += stats["ingested"] + source.docs_skipped += stats["duplicate"] + stats["skipped"] + source.errors += stats["errors"] + + self.stats.total_ingested += stats["ingested"] + self.stats.total_skipped += stats["duplicate"] + stats["skipped"] + self.stats.total_errors += stats["errors"] + + logger.info( + f"Firehose {source.name}: +{stats['ingested']} new, " + f"{stats['duplicate']} dup, {stats['skipped']} skip, " + f"{stats['errors']} err " + f"(total: {source.docs_ingested})" + ) + except Exception as e: + logger.error(f"Firehose {source.name} failed: {e}") + source.errors += 1 + self.stats.total_errors += 1 + + # Update source stats + async with self._lock: + self.stats.sources = { + s.name: { + "collection": s.collection, + "cadence_min": s.cadence_seconds // 60, + "runs": s.runs, + "ingested": s.docs_ingested, + "skipped": s.docs_skipped, + "errors": s.errors, + "last_run_ago": int(now - s.last_run) if s.last_run else -1, + } + for s in self._sources + } + + # Check every 30 seconds + await asyncio.sleep(30) + + async def feed_now(self, source_name: str) -> dict: + """Manually trigger a specific feed source immediately.""" + for source in self._sources: + if source.name == source_name: + try: + docs = await source.fetch_fn() + stats = await self._pipeline.ingest_batch(docs) + source.runs += 1 + source.docs_ingested += stats["ingested"] + source.last_run = time.monotonic() + self.stats.total_ingested += stats["ingested"] + return {"source": source_name, "docs_fetched": len(docs), **stats} + except Exception as e: + return {"source": source_name, "error": str(e)} + return {"error": f"Source '{source_name}' not found"} + + def get_status(self) -> dict: + """Get current firehose status.""" + return { + "running": self._running, + "uptime_seconds": int(time.monotonic() - self._start_time) if self._start_time else 0, + "total_sources": self.stats.total_sources, + "total_ingested": self.stats.total_ingested, + "total_skipped": self.stats.total_skipped, + "total_errors": self.stats.total_errors, + "sources": self.stats.sources, + } + + +# ────────────────────────────────────────────────────────────── +# Singleton +# ────────────────────────────────────────────────────────────── + +_firehose: FirehoseEngine | None = None + + +def get_firehose() -> FirehoseEngine: + global _firehose + if _firehose is None: + _firehose = FirehoseEngine() + return _firehose diff --git a/app/_archive/legacy_2026_07/rag_historical.py b/app/_archive/legacy_2026_07/rag_historical.py new file mode 100644 index 0000000..4c6750b --- /dev/null +++ b/app/_archive/legacy_2026_07/rag_historical.py @@ -0,0 +1,415 @@ +""" +RAG Historical Scam Ingestion - Rekt DB, Chainabuse, TRM, Elliptic +================================================================== +Ingests historical crypto scam and hack data into RAG collections. +Sources: Rekt DB (3K+ DeFi hacks), Chainabuse (scam reports), +TRM Labs (annual crime report), Elliptic (scam report), +SlowMist (hacked archive), Immunefi (bug bounties), CertiK (audits). + +Collections fed: + - defi_hacks: Rekt DB, SlowMist, Rekt News + - rug_timeline: Chainabuse, SENTINEL + - vuln_patterns: Immunefi, CertiK + - crime_reports: TRM, Elliptic, Chainalysis + - forensic_reports: All exploit post-mortems + +Runs: weekly (Sunday 2am) for historical sources. +""" + +import asyncio +import json +import logging +import os +import re +from datetime import UTC, datetime + +import httpx + +logger = logging.getLogger("rag.historical") + +# ── Config ── +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +RAG_INGEST_URL = f"{BACKEND_URL}/api/v1/rag/ingest" +REQUEST_TIMEOUT = 30 + +# ── Source Definitions ── +SOURCES = { + "rekt_db": { + "name": "Rekt Database", + "url": "https://de.fi/rekt-database", + "collection": "defi_hacks", + "content_type": "scam_report", + "description": "3,000+ DeFi hacks since 2020 - the most comprehensive exploit database", + "cadence": "weekly", + }, + "chainabuse": { + "name": "Chainabuse", + "url": "https://chainabuse.com", + "collection": "rug_timeline", + "content_type": "scam_report", + "description": "Community-reported scam addresses with narratives", + "cadence": "weekly", + }, + "slowmist_hacked": { + "name": "SlowMist Hacked Archive", + "url": "https://slowmist.com/en/#hacked", + "collection": "defi_hacks", + "content_type": "scam_report", + "description": "Detailed exploit analysis from SlowMist security team", + "cadence": "weekly", + }, + "rekt_news": { + "name": "Rekt News", + "url": "https://rekt.news", + "collection": "defi_hacks", + "content_type": "scam_report", + "description": "In-depth DeFi exploit post-mortems", + "cadence": "weekly", + }, + "immunefi": { + "name": "Immunefi Bug Bounties", + "url": "https://immunefi.com", + "collection": "vuln_patterns", + "content_type": "contract_code", + "description": "Bug bounty reports - real vulnerability patterns", + "cadence": "weekly", + }, + "certik": { + "name": "CertiK Audit Findings", + "url": "https://certik.com", + "collection": "vuln_patterns", + "content_type": "contract_code", + "description": "Professional smart contract audit findings", + "cadence": "weekly", + }, + "trm_crime_report": { + "name": "TRM Labs Crypto Crime Report", + "url": "https://www.trmlabs.com/reports-and-whitepapers/2026-crypto-crime-report", + "collection": "crime_reports", + "content_type": "annual_report", + "description": "Annual crypto crime typologies and trends - $158B illicit volume in 2025", + "cadence": "yearly", + }, + "elliptic_scams": { + "name": "Elliptic State of Crypto Scams", + "url": "https://info.elliptic.co/hubfs/The%20state%20of%20crypto%20scams%202025/", + "collection": "crime_reports", + "content_type": "annual_report", + "description": "Annual crypto scam typologies and case studies", + "cadence": "yearly", + }, +} + + +# ── Scraper Functions ── + + +async def scrape_rekt_db() -> list[dict]: + """ + Scrape Rekt DB for historical DeFi hacks. + de.fi/rekt-database lists 3,000+ exploits with: + - Project name, date, amount lost, chain, vulnerability type + - Links to post-mortems on rekt.news + """ + docs = [] + try: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + # Try the API endpoint first + resp = await client.get("https://de.fi/api/rekt") + if resp.status_code == 200: + data = resp.json() + for item in data[:500]: # Limit per run + docs.append( + { + "text": f"DeFi Hack: {item.get('name', 'Unknown')} - " + f"${item.get('amount', 0):,.0f} lost on {item.get('chain', 'Unknown')}. " + f"Vulnerability: {item.get('vulnerability', 'Unknown')}. " + f"Date: {item.get('date', 'Unknown')}. " + f"Details: {item.get('description', '')}", + "source": "rekt_db", + "url": item.get("url", "https://de.fi/rekt-database"), + "category": "defi_hack", + "date": item.get("date", ""), + "chain": item.get("chain", ""), + "tags": [ + item.get("vulnerability", ""), + item.get("chain", ""), + "defi_hack", + ], + } + ) + except Exception as e: + logger.warning(f"Rekt DB scrape failed: {e}") + + logger.info(f"Rekt DB: {len(docs)} hacks scraped") + return docs + + +async def scrape_chainabuse() -> list[dict]: + """ + Scrape Chainabuse for scam reports with addresses. + chainabuse.com has community-reported scams with: + - Address, chain, scam type, description, reporter + """ + docs = [] + try: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + # Chainabuse has a public reports page + resp = await client.get( + "https://chainabuse.com/api/reports", + params={"limit": 200, "sort": "newest"}, + ) + if resp.status_code == 200: + data = resp.json() + for item in data.get("reports", []): + address = item.get("address", "") + docs.append( + { + "text": f"Scam Report: {item.get('title', 'Unknown')} - " + f"Address {address} on {item.get('chain', 'Unknown')}. " + f"Type: {item.get('scam_type', 'Unknown')}. " + f"Description: {item.get('description', '')}. " + f"Reported by: {item.get('reporter', 'Anonymous')}", + "source": "chainabuse", + "url": f"https://chainabuse.com/report/{item.get('id', '')}", + "category": "scam_report", + "date": item.get("created_at", ""), + "chain": item.get("chain", ""), + "tags": [ + item.get("scam_type", ""), + item.get("chain", ""), + "scam_report", + ], + } + ) + except Exception as e: + logger.warning(f"Chainabuse scrape failed: {e}") + + logger.info(f"Chainabuse: {len(docs)} reports scraped") + return docs + + +async def scrape_slowmist() -> list[dict]: + """Scrape SlowMist Hacked Archive for detailed exploit analysis.""" + docs = [] + try: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + resp = await client.get("https://slowmist.com/en/api/hacked") + if resp.status_code == 200: + data = resp.json() + for item in data[:200]: + docs.append( + { + "text": f"SlowMist Analysis: {item.get('title', 'Unknown')} - " + f"${item.get('amount', 0):,.0f} lost. " + f"Root cause: {item.get('root_cause', 'Unknown')}. " + f"Attack vector: {item.get('attack_vector', 'Unknown')}. " + f"Recommendations: {item.get('recommendations', '')}", + "source": "slowmist", + "url": item.get("url", "https://slowmist.com"), + "category": "defi_hack", + "date": item.get("date", ""), + "chain": item.get("chain", ""), + "tags": [ + item.get("root_cause", ""), + item.get("attack_vector", ""), + "defi_hack", + "slowmist", + ], + } + ) + except Exception as e: + logger.warning(f"SlowMist scrape failed: {e}") + + logger.info(f"SlowMist: {len(docs)} analyses scraped") + return docs + + +async def scrape_rekt_news() -> list[dict]: + """Scrape Rekt News for in-depth exploit post-mortems.""" + docs = [] + try: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + # Rekt News has an RSS feed + resp = await client.get("https://rekt.news/feed.xml") + if resp.status_code == 200: + import defusedxml.ElementTree as ET + + root = ET.fromstring(resp.text) + for item in root.findall(".//item")[:50]: + title = item.findtext("title", "") + description = item.findtext("description", "") + link = item.findtext("link", "") + pub_date = item.findtext("pubDate", "") + + # Extract amount from title (e.g., "$10M Rekt") + amount_match = re.search(r"\$(\d+(?:\.\d+)?[MB]?)", title) + amount = amount_match.group(0) if amount_match else "Unknown" + + docs.append( + { + "text": f"Rekt News: {title} - {amount} lost. {description}", + "source": "rekt_news", + "url": link, + "category": "defi_hack", + "date": pub_date, + "tags": ["defi_hack", "rekt_news", "post_mortem"], + } + ) + except Exception as e: + logger.warning(f"Rekt News scrape failed: {e}") + + logger.info(f"Rekt News: {len(docs)} articles scraped") + return docs + + +async def ingest_trm_report() -> list[dict]: + """ + Ingest TRM Labs 2026 Crypto Crime Report. + Key findings: $158B illicit volume, $2.87B stolen in 150 hacks, + Russia-linked sanctions evasion via A7A5 stablecoin. + """ + docs = [] + try: + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + resp = await client.get("https://www.trmlabs.com/reports-and-whitepapers/2026-crypto-crime-report") + if resp.status_code == 200: + # Extract key findings from the page + text = resp.text + + # Parse key sections + sections = re.split(r"]*>", text) + for section in sections: + # Strip HTML tags + clean = re.sub(r"<[^>]+>", " ", section) + clean = re.sub(r"\s+", " ", clean).strip() + if len(clean) > 100: + docs.append( + { + "text": f"TRM 2026 Crypto Crime Report: {clean[:2000]}", + "source": "trm_labs", + "url": "https://www.trmlabs.com/reports-and-whitepapers/2026-crypto-crime-report", + "category": "crime_report", + "date": "2026-01-28", + "tags": [ + "crime_report", + "trm_labs", + "annual_2026", + "sanctions", + "hacks", + ], + } + ) + except Exception as e: + logger.warning(f"TRM report ingest failed: {e}") + + logger.info(f"TRM Report: {len(docs)} sections ingested") + return docs + + +# ── Master Ingestion Orchestrator ── + + +async def ingest_historical_source(source_id: str) -> dict: + """Ingest a single historical source into RAG.""" + source = SOURCES.get(source_id) + if not source: + return {"error": f"Unknown source: {source_id}"} + + logger.info(f"Ingesting {source['name']} → {source['collection']}") + + # Scrape + scrapers = { + "rekt_db": scrape_rekt_db, + "chainabuse": scrape_chainabuse, + "slowmist_hacked": scrape_slowmist, + "rekt_news": scrape_rekt_news, + "trm_crime_report": ingest_trm_report, + } + + scraper = scrapers.get(source_id) + if not scraper: + return {"error": f"No scraper for {source_id}", "source": source_id} + + try: + docs = await scraper() + except Exception as e: + return {"error": str(e), "source": source_id, "docs_scraped": 0} + + if not docs: + return {"source": source_id, "docs_scraped": 0, "status": "empty"} + + # Chunk and dedup + from app.rag_chunking import chunk_batch + + chunks = chunk_batch(docs, source["content_type"]) + + # Ingest into backend + ingested = 0 + try: + async with httpx.AsyncClient(timeout=60) as client: + # Batch in groups of 50 + for i in range(0, len(chunks), 50): + batch = chunks[i : i + 50] + resp = await client.post( + RAG_INGEST_URL, + json={ + "documents": batch, + "collection": source["collection"], + "source": source_id, + "chunking": source["content_type"], + }, + ) + if resp.status_code == 200: + result = resp.json() + ingested += result.get("ingested", len(batch)) + else: + logger.warning(f"Ingest batch failed for {source_id}: HTTP {resp.status_code}") + except Exception as e: + logger.error(f"Ingest HTTP failed for {source_id}: {e}") + + return { + "source": source_id, + "name": source["name"], + "collection": source["collection"], + "docs_scraped": len(docs), + "chunks_created": len(chunks), + "ingested": ingested, + "status": "ok" if ingested > 0 else "partial", + } + + +async def ingest_all_historical() -> dict: + """Run full historical ingestion across all sources.""" + results = {} + total_ingested = 0 + + for source_id in SOURCES: + logger.info(f"Starting historical ingest: {source_id}") + try: + result = await ingest_historical_source(source_id) + results[source_id] = result + total_ingested += result.get("ingested", 0) + except Exception as e: + results[source_id] = {"error": str(e), "source": source_id} + logger.error(f"Historical ingest failed for {source_id}: {e}") + + return { + "status": "complete", + "total_ingested": total_ingested, + "sources": results, + "timestamp": datetime.now(UTC).isoformat(), + } + + +# ── CLI Entry Point ── +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1: + source = sys.argv[1] + result = asyncio.run(ingest_historical_source(source)) + print(json.dumps(result, indent=2)) + else: + result = asyncio.run(ingest_all_historical()) + print(json.dumps(result, indent=2)) diff --git a/app/_archive/legacy_2026_07/rag_permanence.py b/app/_archive/legacy_2026_07/rag_permanence.py new file mode 100644 index 0000000..ba6a2b0 --- /dev/null +++ b/app/_archive/legacy_2026_07/rag_permanence.py @@ -0,0 +1,387 @@ +""" +RAG Permanence v2 - Cloudflare R2 cold storage via REST API. +Hot (Redis) → Warm (local 7-day cache) → Cold (R2 permanent). + +R2 free tier: 10GB storage, 10M Class A ops/month, zero egress. +Uses CF REST API with Bearer token - no S3 credentials needed. +""" + +import json +import logging +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + +# ═══════════════════════════════════════════════════════════════ +# CONFIG +# ═══════════════════════════════════════════════════════════════ +R2_ACCOUNT_ID = os.getenv("R2_ACCOUNT_ID", "8f9bd9165c1250b426c66dc1967deefd") +R2_BUCKET = os.getenv("R2_BUCKET", "rag-backup") +R2_API_TOKEN = os.getenv("R2_API_TOKEN", "") +R2_BASE = f"https://api.cloudflare.com/client/v4/accounts/{R2_ACCOUNT_ID}/r2/buckets/{R2_BUCKET}" + +LOCAL_CACHE = Path(os.getenv("RAG_LOCAL_CACHE", "/data/rag-storage")) +LOCAL_RETENTION_DAYS = 7 +EMBEDDING_VERSION = os.getenv("EMBEDDING_MODEL_VERSION", "v1") + +LOCAL_CACHE.mkdir(parents=True, exist_ok=True) + + +def _headers(): + return {"Authorization": f"Bearer {R2_API_TOKEN}", "Content-Type": "application/json"} + + +# ═══════════════════════════════════════════════════════════════ +# R2 REST API OPERATIONS +# ═══════════════════════════════════════════════════════════════ +async def _r2_put(key: str, data: bytes, content_type: str = "application/json") -> bool: + """Upload an object to R2 via REST API.""" + if not R2_API_TOKEN: + logger.error("R2_API_TOKEN not set") + return False + url = f"{R2_BASE}/objects/{key}" + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.put( + url, + content=data, + headers={"Authorization": f"Bearer {R2_API_TOKEN}", "Content-Type": content_type}, + ) + if resp.status_code == 200: + return True + logger.error(f"R2 PUT {key}: {resp.status_code} {resp.text[:200]}") + return False + except Exception as e: + logger.error(f"R2 PUT {key}: {e}") + return False + + +async def _r2_get(key: str) -> bytes | None: + """Download an object from R2 via REST API.""" + if not R2_API_TOKEN: + return None + url = f"{R2_BASE}/objects/{key}" + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get(url, headers=_headers()) + if resp.status_code == 200: + return resp.content + return None + except Exception as e: + logger.error(f"R2 GET {key}: {e}") + return None + + +async def _r2_list(prefix: str = "") -> list: + """List objects in R2 bucket.""" + if not R2_API_TOKEN: + return [] + url = f"{R2_BASE}/objects" + if prefix: + url += f"?prefix={prefix}" + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.get(url, headers=_headers()) + if resp.status_code == 200: + data = resp.json() + return data.get("result", []) + return [] + except Exception as e: + logger.error(f"R2 LIST: {e}") + return [] + + +async def _r2_delete(key: str) -> bool: + """Delete an object from R2.""" + if not R2_API_TOKEN: + return False + url = f"{R2_BASE}/objects/{key}" + async with httpx.AsyncClient(timeout=30) as client: + try: + resp = await client.delete(url, headers=_headers()) + return resp.status_code in (200, 204, 404) + except Exception as e: + logger.error(f"R2 DELETE {key}: {e}") + return False + + +# ═══════════════════════════════════════════════════════════════ +# RAG PERSISTENCE OPERATIONS +# ═══════════════════════════════════════════════════════════════ +async def snapshot_all(): + """Snapshot all RAG collections to R2. Returns per-collection status.""" + results = {} + ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + + # Get collections from rag_service (Redis-backed) + try: + from app.rag_service import COLLECTIONS, _get_redis + + redis = await _get_redis() + has_redis = True + except Exception: + COLLECTIONS = [] + has_redis = False + + for name in COLLECTIONS: + try: + # Get document IDs from Redis + if not has_redis: + results[name] = {"collection": name, "status": "skipped", "count": 0} + continue + + doc_ids = await redis.smembers(f"rag:idx:{name}") + count = len(doc_ids) if doc_ids else 0 + if count == 0: + results[name] = {"collection": name, "status": "empty", "count": 0} + continue + + # Collect all documents for this collection + # Redis stores docs as strings at rag:{collection}:{id}, NOT as hashes + items = [] + for doc_id in doc_ids: + try: + raw = await redis.get(f"rag:{name}:{doc_id}") + if raw: + doc = json.loads(raw) if isinstance(raw, str) else raw + items.append(doc) + except Exception: + pass + + # Upload in chunks if payload is large (R2 REST limit ~5GB but we chunk at 5MB) + MAX_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB per chunk + all_items = items + chunk_idx = 0 + total_uploaded = 0 + + while all_items: + # Build chunk - add items until we exceed size limit + chunk_items = [] + chunk_size = 0 + while all_items and chunk_size < MAX_CHUNK_SIZE: + item = all_items.pop(0) + item_json = json.dumps(item, default=str) + if chunk_size + len(item_json.encode()) > MAX_CHUNK_SIZE and chunk_items: + # Put it back and upload this chunk + all_items.insert(0, item) + break + chunk_items.append(item) + chunk_size += len(item_json.encode()) + + payload = json.dumps( + { + "collection": name, + "version": EMBEDDING_VERSION, + "timestamp": ts, + "chunk": chunk_idx, + "count": len(chunk_items), + "items": chunk_items, + }, + default=str, + ).encode() + + key = f"rag_snapshots/{name}/{ts}_chunk{chunk_idx}.json" + ok = await _r2_put(key, payload) + total_uploaded += len(chunk_items) + + if not ok and chunk_idx == 0: + # First chunk failed - report failure + results[name] = { + "collection": name, + "status": "failed", + "count": count, + "key": key, + "size_bytes": len(payload), + "chunks_uploaded": chunk_idx, + } + break + chunk_idx += 1 + + # Save latest pointer (only if at least one chunk succeeded) + if chunk_idx > 0 or count == 0: + await _r2_put( + f"rag_snapshots/{name}/latest.json", + json.dumps( + { + "key": f"rag_snapshots/{name}/{ts}", + "timestamp": ts, + "count": count, + "version": EMBEDDING_VERSION, + "chunks": chunk_idx, + } + ).encode(), + ) + + results[name] = { + "collection": name, + "status": "uploaded" if count > 0 else "empty", + "count": count, + "key": f"rag_snapshots/{name}/{ts}", + "size_bytes": sum(len(json.dumps(i, default=str).encode()) for i in items), + "chunks": chunk_idx, + } + except Exception as e: + results[name] = {"collection": name, "error": str(e)[:200]} + + # Save local metadata + meta = { + "last_snapshot": ts, + "results": {k: v.get("status", "error") for k, v in results.items()}, + } + (LOCAL_CACHE / "snapshot_meta.json").write_text(json.dumps(meta, indent=2)) + + return results + + +async def restore_all(): + """Restore all RAG collections from latest R2 snapshots.""" + # Get collections and Redis connection + try: + from app.rag_service import COLLECTIONS, _get_redis + + redis = await _get_redis() + except Exception: + return {"error": "Cannot connect to RAG service"} + + results = {} + + for name in COLLECTIONS: + try: + # Get latest pointer + latest_raw = await _r2_get(f"rag_snapshots/{name}/latest.json") + if not latest_raw: + results[name] = {"collection": name, "status": "no_snapshot"} + continue + + latest = json.loads(latest_raw) + key = latest["key"] + + # Download the snapshot + data_raw = await _r2_get(key) + if not data_raw: + results[name] = {"collection": name, "status": "download_failed"} + continue + + snapshot = json.loads(data_raw) + items = snapshot.get("items", []) + snapshot_ts = latest.get("timestamp", "") + + # Handle chunked snapshots - download all chunks + total_chunks = latest.get("chunks", 1) + if total_chunks > 1: + for ci in range(1, total_chunks): + chunk_key = f"rag_snapshots/{name}/{snapshot_ts}_chunk{ci}.json" + chunk_raw = await _r2_get(chunk_key) + if chunk_raw: + chunk_data = json.loads(chunk_raw) + items.extend(chunk_data.get("items", [])) + + # Restore to Redis: set each doc as string and add to index set + restored = 0 + for item in items: + try: + doc_id = item.get("id", "") + if not doc_id: + continue + # Store as JSON string (matches rag_service format) + await redis.set(f"rag:{name}:{doc_id}", json.dumps(item, default=str)) + await redis.sadd(f"rag:idx:{name}", doc_id) + restored += 1 + except Exception: + pass + + results[name] = { + "collection": name, + "status": "restored", + "count": restored, + "total_in_snapshot": len(items), + "snapshot_key": key, + } + except Exception as e: + results[name] = {"collection": name, "error": str(e)[:200]} + + return results + + +async def nightly_cycle(): + """Full nightly cycle: snapshot to R2, clean local cache.""" + snap = await snapshot_all() + ok = sum(1 for v in snap.values() if v.get("status") in ("uploaded", "empty")) + total = len(snap) + + # Clean local cache older than retention period + cleaned = 0 + cutoff = datetime.now(UTC) - timedelta(days=LOCAL_RETENTION_DAYS) + for f in LOCAL_CACHE.glob("*.json"): + try: + if datetime.fromtimestamp(f.stat().st_mtime, tz=UTC) < cutoff: + f.unlink() + cleaned += 1 + except Exception: + pass + + return { + "snapshot_results": {k: v.get("status", "error") for k, v in snap.items()}, + "collections_ok": ok, + "collections_total": total, + "local_cache_cleaned": cleaned, + } + + +async def r2_stats(): + """Get R2 usage + local cache stats.""" + # Get collections and Redis connection + try: + from app.rag_service import COLLECTIONS, _get_redis + + redis = await _get_redis() + has_redis = True + except Exception: + COLLECTIONS = [] + has_redis = False + + # R2 bucket stats + objects = await _r2_list("rag_snapshots/") + total_size = 0 + snapshot_count = 0 + for obj in objects: + size = int(obj.get("size", 0)) + total_size += size + snapshot_count += 1 + + # Local cache stats + local_files = list(LOCAL_CACHE.glob("*.json")) + local_size = sum(f.stat().st_size for f in local_files) if local_files else 0 + + # Collection stats (from Redis) + coll_info = {} + if has_redis: + for name in COLLECTIONS: + try: + count = await redis.scard(f"rag:idx:{name}") + coll_info[name] = count + except Exception: + coll_info[name] = 0 + + return { + "r2": { + "bucket": R2_BUCKET, + "endpoint": R2_BASE, + "snapshot_count": snapshot_count, + "total_size_bytes": total_size, + "total_size_mb": round(total_size / 1024 / 1024, 2), + "has_token": bool(R2_API_TOKEN), + }, + "local": { + "path": str(LOCAL_CACHE), + "file_count": len(local_files), + "size_bytes": local_size, + "retention_days": LOCAL_RETENTION_DAYS, + }, + "collections": coll_info, + "model_version": EMBEDDING_VERSION, + } diff --git a/app/_archive/legacy_2026_07/rag_tool.py b/app/_archive/legacy_2026_07/rag_tool.py new file mode 100644 index 0000000..7f92d03 --- /dev/null +++ b/app/_archive/legacy_2026_07/rag_tool.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +RAG Tool Interface for OpenClaw Agents (2026 Agentic RAG Approach) +- Exposes RAG as a tool callable via Hermes subagents +- Supports reflection loops and multi-hop retrieval +- Integrates with OpenClaw tool schema +""" + +import json +import sys +from pathlib import Path +from typing import Any + +# Add RAG lib to path +sys.path.insert(0, str(Path.home() / ".hermes" / "scripts")) +from rag_system_v2 import RMIRAGSystem + + +class RagToolInterface: + """2026 Agentic RAG tool wrapper.""" + + def __init__(self): + self.rag = RMIRAGSystem() + self.max_iterations = 3 # Prevent infinite loops + + def query( + self, + query: str, + namespace: str | None = None, + n_results: int = 5, + min_confidence: float = 0.7, + ) -> dict[str, Any]: + """ + Query RAG and return results with reflection. + + Implements 2026 Agentic RAG pattern: + 1. Initial retrieval + 2. Self-evaluation (confidence check) + 3. Refinement if needed + 4. Evidence-weighted synthesis + """ + # Step 1: Initial retrieval + results = self.rag.query(query, n=n_results, source_filter=namespace) + + # Convert ChromaDB distance to confidence score + def distance_to_confidence(distance: float) -> float: + """ChromaDB distance → confidence (lower distance = higher confidence).""" + if distance is None: + return 0.5 + return max(0.0, 1.0 - distance) + + # Convert results to standard format + formatted_results = [] + for r in results: + conf = distance_to_confidence(r.get("distance")) + if conf >= min_confidence: + formatted_results.append( + { + "text": r["text"], + "metadata": r["metadata"], + "confidence": round(conf, 3), + "source": r["metadata"].get("source", "unknown"), + } + ) + + # Step 2: Self-evaluation + confidence_score = len(formatted_results) / n_results # heuristic + + output = { + "query": query, + "results": formatted_results, + "confidence": round(confidence_score, 3), + "n_retrieved": len(formatted_results), + "metadata": self.rag.get_stats(), + } + + # Step 3: Reflection loop if confidence low + if confidence_score < 0.6: + output["reflection"] = { + "step": 1, + "original_confidence": confidence_score, + "threshold": 0.6, + "refined_query": f"additional context for: {query}", + "action": "refining", + } + # Try refinement (single additional query) + refined_results = self.rag.query(output["reflection"]["refined_query"], n=n_results) + for r in refined_results: + conf = distance_to_confidence(r.get("distance")) + if conf >= min_confidence: + formatted_results.append( + { + "text": r["text"], + "metadata": r["metadata"], + "confidence": round(conf, 3), + "source": r["metadata"].get("source", "unknown"), + } + ) + output["results"] = formatted_results + output["n_refined"] = len(refined_results) + + return output + + def multi_hop_query(self, question: str, hops: list[dict[str, str]]) -> dict[str, Any]: + """ + Multi-hop retrieval: chain multiple queries. + + Example: + hops = [ + {"purpose": "finding token patterns", "query": "rug pull token patterns"}, + {"purpose": "scam indicators", "query": " scam indicators from recent rugs"} + ] + """ + all_results = [] + + for hop in hops: + hop_results = self.query(hop["query"]) + hop_result_list = [ + {"text": r["text"], "source": r["source"], "hop": hop["purpose"]} for r in hop_results["results"] + ] + all_results.extend(hop_result_list) + + return { + "question": question, + "hops": len(hops), + "results": all_results, + "total_results": len(all_results), + } + + def reflection_loop(self, original_query: str, max_iterations: int = 3) -> dict[str, Any]: + """ + Self-correcting retrieval with iteration budgets. + + Stops when: + - Confidence threshold reached (0.8) + - Max iterations exceeded + - No new results found + """ + iterations = [] + current_query = original_query + all_results = [] + + for i in range(max_iterations): + result = self.query(current_query, n_results=5) + + iterations.append( + { + "step": i, + "query": current_query, + "results_count": len(result["results"]), + "confidence": result["confidence"], + } + ) + + # Add new results + all_results.extend(result["results"]) + + # Check stop conditions + if result["confidence"] >= 0.8: + iterations[-1]["reason"] = "confidence_threshold_reached" + break + + if len(result["results"]) == 0: + iterations[-1]["reason"] = "no_new_results" + break + + # Refine query for next iteration + current_query = f"details about: {current_query}" + + return { + "original_query": original_query, + "iterations": iterations, + "final_results": all_results, + } + + def get_tool_schema(self) -> dict[str, Any]: + """Return OpenAI-compatible tool schema for agents.""" + return { + "type": "function", + "function": { + "name": "rag_query", + "description": ( + "Query RugMunch Intelligence RAG for crypto security patterns, " + "rug pull indicators, wallet risk scores, and market intelligence." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": 'Search query for RAG (e.g., "rug pull patterns", "wallet risk indicators")', + }, + "namespace": { + "type": "string", + "enum": [ + "scan_results", + "alerts", + "content", + "market_data", + "onchain", + "social_feed", + "news", + ], + "default": None, + "description": "Optional source filter to narrow search", + }, + "n_results": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 5, + "description": "Number of results to retrieve", + }, + }, + "required": ["query"], + }, + }, + } + + +# ============================================================ +# CLI INTERFACE +# ============================================================ +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="RAG Tool Interface for OpenClaw Agents") + parser.add_argument("command", choices=["query", "reflection", "multi-hop", "schema"]) + parser.add_argument("--query", type=str, help="Query text") + parser.add_argument("--namespace", type=str, help="Source namespace filter") + parser.add_argument("--n", type=int, default=5, help="Number of results") + parser.add_argument("--hops", type=str, help="Multi-hop config (JSON)") + parser.add_argument("--max-iter", type=int, default=3, help="Max reflection iterations") + + args = parser.parse_args() + + rag = RagToolInterface() + + if args.command == "query": + result = rag.query(args.query, namespace=args.namespace, n_results=args.n) + print(json.dumps(result, indent=2)) + + elif args.command == "reflection": + result = rag.reflection_loop(args.query, max_iterations=args.max_iter) + print(json.dumps(result, indent=2)) + + elif args.command == "multi-hop": + hops = json.loads(args.hops) + result = rag.multi_hop_query(args.query, hops) + print(json.dumps(result, indent=2)) + + elif args.command == "schema": + print(json.dumps(rag.get_tool_schema(), indent=2)) diff --git a/app/_archive/legacy_2026_07/rebalance_bot.py b/app/_archive/legacy_2026_07/rebalance_bot.py new file mode 100644 index 0000000..ffb9aa3 --- /dev/null +++ b/app/_archive/legacy_2026_07/rebalance_bot.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""#20 - Cross-Chain Portfolio Rebalance Bot. Users set automated rebalancing rules. +Executes via x402, monitors via DataBus. "Keep 50% USDC on Solana, 30% ETH on Arbitrum." """ + +import os +from datetime import UTC, datetime + +import httpx +from fastapi import APIRouter, BackgroundTasks, HTTPException +from pydantic import BaseModel + +router = APIRouter(prefix="/api/v1/rebalance", tags=["portfolio-rebalance"]) + +DATABUS = os.environ.get("DATABUS_URL", "http://localhost:8000/api/v1/databus") +BACKEND = os.environ.get("BACKEND_URL", "http://localhost:8000") + +# In-memory store for rebalancing rules (Redis/DB in prod) +_rebalance_rules: dict[str, dict] = {} + + +class RebalanceRule(BaseModel): + user_id: str + name: str + allocations: list[dict] # [{"chain": "solana", "token": "USDC", "target_pct": 50}, ...] + rebalance_threshold_pct: float = 5.0 # Rebalance if any allocation off by >5% + max_slippage_pct: float = 1.0 + enabled: bool = True + + +@router.post("/rules") +async def create_rebalance_rule(rule: RebalanceRule): + """Create an automated portfolio rebalancing rule.""" + rule_id = f"{rule.user_id}:{rule.name}" + _rebalance_rules[rule_id] = { + "id": rule_id, + "user_id": rule.user_id, + "name": rule.name, + "allocations": rule.allocations, + "rebalance_threshold_pct": rule.rebalance_threshold_pct, + "max_slippage_pct": rule.max_slippage_pct, + "enabled": rule.enabled, + "created_at": datetime.now(UTC).isoformat(), + "last_rebalanced": None, + } + return {"id": rule_id, "status": "created", "allocations": rule.allocations} + + +@router.get("/rules/{user_id}") +async def list_rules(user_id: str): + """List all rebalancing rules for a user.""" + user_rules = [r for rid, r in _rebalance_rules.items() if r["user_id"] == user_id] + return {"rules": user_rules, "count": len(user_rules)} + + +@router.delete("/rules/{rule_id}") +async def delete_rule(rule_id: str): + """Delete a rebalancing rule.""" + _rebalance_rules.pop(rule_id, None) + return {"id": rule_id, "status": "deleted"} + + +@router.get("/simulate/{user_id}") +async def simulate_rebalance(user_id: str): + """Simulate rebalancing - check what would change without executing.""" + user_rules = [r for rid, r in _rebalance_rules.items() if r["user_id"] == user_id and r["enabled"]] + if not user_rules: + return {"simulations": [], "note": "No enabled rules found"} + + simulations = [] + for rule in user_rules[:5]: + current: list[dict] = [] + # Fetch current balances for each allocation + async with httpx.AsyncClient(timeout=15) as client: + for alloc in rule["allocations"]: + try: + resp = await client.get( + f"{DATABUS}/{alloc['chain']}/balance/{user_id}", params={"token": alloc.get("token", "native")} + ) + if resp.status_code == 200: + data = resp.json().get("data", {}) + current.append( + { + "chain": alloc["chain"], + "token": alloc.get("token", "native"), + "balance_usd": data.get("value_usd", 0) or 0, + "current_pct": 0, + "target_pct": alloc["target_pct"], + "difference_pct": 0, + } + ) + except Exception: + current.append( + { + "chain": alloc["chain"], + "token": alloc.get("token", "native"), + "balance_usd": 0, + "target_pct": alloc["target_pct"], + "error": "unavailable", + } + ) + + # Calculate percentages and differences + total = sum(a["balance_usd"] for a in current) or 1 + needs_rebalance = False + for a in current: + a["current_pct"] = round((a["balance_usd"] / total) * 100, 1) + a["difference_pct"] = round(a["current_pct"] - a["target_pct"], 1) + if abs(a["difference_pct"]) > rule["rebalance_threshold_pct"]: + needs_rebalance = True + + simulations.append( + { + "rule": rule["name"], + "needs_rebalance": needs_rebalance, + "total_value_usd": round(total, 2), + "current_allocations": current, + "rebalance_threshold_pct": rule["rebalance_threshold_pct"], + } + ) + + return {"simulations": simulations} + + +@router.post("/execute/{rule_id}") +async def execute_rebalance(rule_id: str, background_tasks: BackgroundTasks): + """Execute a rebalancing operation (simulation only - no real execution without x402).""" + rule = _rebalance_rules.get(rule_id) + if not rule: + raise HTTPException(404, "Rule not found") + + if not rule["enabled"]: + raise HTTPException(400, "Rule is disabled") + + # Simulation mode - return what WOULD be executed + async with httpx.AsyncClient(timeout=15) as client: + trades_needed: list[dict] = [] + current_values: list[dict] = [] + for alloc in rule["allocations"]: + try: + resp = await client.get(f"{DATABUS}/{alloc['chain']}/balance/{rule['user_id']}") + if resp.status_code == 200: + data = resp.json().get("data", {}) + current_values.append( + { + "chain": alloc["chain"], + "token": alloc.get("token", "native"), + "value_usd": data.get("value_usd", 0) or 0, + "target_pct": alloc["target_pct"], + } + ) + except Exception: + pass + + total = sum(a["value_usd"] for a in current_values) or 1 + for a in current_values: + current_pct = (a["value_usd"] / total) * 100 + diff_pct = current_pct - a["target_pct"] + diff_usd = total * (diff_pct / 100) + if abs(diff_pct) > rule["rebalance_threshold_pct"]: + trades_needed.append( + { + "chain": a["chain"], + "token": a["token"], + "action": "SELL" if diff_pct > 0 else "BUY", + "amount_usd": round(abs(diff_usd), 2), + "current_pct": round(current_pct, 1), + "target_pct": a["target_pct"], + } + ) + + rule["last_rebalanced"] = datetime.now(UTC).isoformat() + + return { + "rule_id": rule_id, + "status": "simulated", + "total_value_usd": round(total, 2), + "trades_needed": trades_needed, + "note": "Simulation only. Real execution requires x402 payment authorization.", + } diff --git a/app/_archive/legacy_2026_07/rmi_dashboard.py b/app/_archive/legacy_2026_07/rmi_dashboard.py new file mode 100644 index 0000000..c079e69 --- /dev/null +++ b/app/_archive/legacy_2026_07/rmi_dashboard.py @@ -0,0 +1,353 @@ +""" +RMI TUI Dashboard - Terminal User Interface +=========================================== + +Interactive CLI dashboard for RMI platform. +Features: +- System status display +- Menu-driven navigation +- Quick commands for common actions +- Live backend status monitoring +""" + +import os +import subprocess +import time +from typing import Any + +import httpx + +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +# Terminal colors +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + PURPLE = "\033[0;35m" + CYAN = "\033[0;36m" + WHITE = "\033[0;37m" + BOLD = "\033[1m" + RESET = "\033[0m" + + +# ─── UI UTILITIES ────────────────────────────────────────────────── + + +def clear_screen(): + """Clear terminal screen.""" + os.system("clear" if os.name == "posix" else "cls") + + +def print_header(title: str): + """Print centered header.""" + width = 60 + logger.info(f"\n{Colors.BLUE}{'=' * width}{Colors.RESET}") + print( + f"{Colors.BLUE}║{Colors.RESET}{Colors.BOLD}{title.center(width - 2)}{Colors.RESET}{Colors.BLUE}║{Colors.RESET}" + ) + logger.info(f"{Colors.BLUE}{'=' * width}{Colors.RESET}\n") +def print_box(title: str, content: str, color=Colors.WHITE): + """Print content in a box.""" + lines = content.split("\n") + max_len = max(len(l) for l in lines) if lines else 0 # noqa: E741 + width = max_len + 4 + + logger.info(f"\n{color}┌{'─' * width}┐{Colors.RESET}") + logger.info(f"{color}│{Colors.RESET} {Colors.BOLD}{title.center(width - 2)}{Colors.RESET} {color}│{Colors.RESET}") + logger.info(f"{color}├{'─' * width}┤{Colors.RESET}") + for line in lines: + logger.info(f"{color}│{Colors.RESET} {line.ljust(width - 2)} {color}│{Colors.RESET}") + logger.info(f"{color}└{'─' * width}┘{Colors.RESET}\n") +# ─── STATUS MONITOR ──────────────────────────────────────────────── + + +def get_backend_status(url: str = "http://127.0.0.1:8010") -> dict[str, Any]: + """Get backend health status.""" + try: + response = httpx.get(f"{url}/health", timeout=2) + if response.status_code == 200: + return response.json() + except Exception: + pass + return {"error": "Backend not reachable"} + + +def get_redis_status() -> dict[str, Any]: + """Get Redis connection status.""" + try: + import redis + + r = redis.Redis(host="localhost", port=6379, db=0, socket_timeout=2) + if r.ping(): + info = r.info() + return { + "connected": True, + "version": info.get("redis_version", "unknown"), + "memory_used": info.get("used_memory_human", "unknown"), + "connections": info.get("connected_clients", 0), + } + except Exception: + pass + return {"error": "Redis not reachable"} + + +# ─── DASHBOARD UI ────────────────────────────────────────────────── + + +class Dashboard: + """Interactive TUI Dashboard.""" + + def __init__(self): + self.running = True + self.current_view = "main" + + def display_main(self): + """Display main menu.""" + clear_screen() + + # Header + logger.info(f"{Colors.CYAN}{Colors.BOLD}") + logger.info("╔════════════════════════════════════════════════════════════╗") + logger.info("║ RMI INTELLIGENCE PLATFORM v2.0.0 ║") + logger.info("╚════════════════════════════════════════════════════════════╝") + logger.info(f"{Colors.RESET}") + # Status + backend = get_backend_status() + redis = get_redis_status() + + backend_status = ( + f"{Colors.GREEN}● Online{Colors.RESET}" + if backend.get("status") == "ok" + else f"{Colors.RED}● Offline{Colors.RESET}" + ) + redis_status = ( + f"{Colors.GREEN}● Connected{Colors.RESET}" + if redis.get("connected") + else f"{Colors.RED}● Disconnected{Colors.RESET}" + ) + + logger.info(f"{Colors.YELLOW}System Status:{Colors.RESET}") + logger.info(f" Backend: {backend_status} | Redis: {redis_status}") + if backend.get("status") == "ok": + logger.info(f" Service: {backend.get('service', 'unknown')}") + logger.info(f" Version: {backend.get('version', 'unknown')}") + logger.info(f" Timestamp: {backend.get('timestamp', 'N/A')}") + logger.info(f"\n{Colors.YELLOW}Available Ports:{Colors.RESET}") + logger.info(" 8010 - Main Backend API") + logger.info(" 6379 - Redis Server") + logger.info(" 22 - SSH (if enabled)") + # Features + logger.info(f"\n{Colors.YELLOW}Built-in Features:{Colors.RESET}") + features = [ + ("[1] Server Control", "Start/stop/restart backend server"), + ("[2] Auth System", "User authentication & OAuth management"), + ("[3] Security Intel", "Threat intel & contract scanning"), + ("[4] x402 Micropayments", "Micropayment enforcement & tools"), + ("[5] Chat Interface", "Direct chat with RMI assistant"), + ("[6] Dashboard TUI", "Interactive terminal dashboard"), + ("[7] Network Status", "Check all service connectivity"), + ("[0] Quit", "Exit to shell"), + ] + + for key, (name, desc) in enumerate(features): + logger.info(f" {Colors.GREEN}{key}{Colors.RESET}. {Colors.CYAN}{name:<20}{Colors.RESET} - {desc}") + logger.info(f"\n{Colors.PURPLE}Use 'help' for more information{Colors.RESET}\n") + def display_server_control(self): + """Display server control menu.""" + print_header("Server Control") + + pid = self._get_backend_pid() + status = "Running" if pid else "Stopped" + + logger.info(f" Backend Status: {Colors.GREEN if pid else Colors.RED}{status}{Colors.RESET}") + if pid: + logger.info(f" PID: {pid}") + logger.info("\n Actions:") + logger.info(f" {Colors.GREEN}1{Colors.RESET}. Start Backend") + logger.info(f" {Colors.GREEN}2{Colors.RESET}. Stop Backend") + logger.info(f" {Colors.GREEN}3{Colors.RESET}. Restart Backend") + logger.info(f" {Colors.GREEN}0{Colors.RESET}. Back") + choice = input("\n Select: ").strip() + + if choice == "1": + self._start_backend() + elif choice == "2": + self._stop_backend() + elif choice == "3": + self._restart_backend() + + def _get_backend_pid(self) -> int | None: + """Get backend process ID.""" + try: + result = subprocess.run(["pgrep", "-f", "uvicorn main:app"], capture_output=True, text=True, timeout=5) + if result.stdout.strip(): + return int(result.stdout.strip()) + except Exception: + pass + return None + + def _start_backend(self): + """Start backend server.""" + logger.info("\n{Colors.YELLOW}Starting backend...{Colors.RESET}") + try: + subprocess.Popen( + ["python3", "-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", "8010"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + time.sleep(3) + logger.info(f"{Colors.GREEN}Backend started!{Colors.RESET}") + except Exception as e: + logger.warning(f"{Colors.RED}Error starting backend: {e}{Colors.RESET}") + def _stop_backend(self): + """Stop backend server.""" + pid = self._get_backend_pid() + if pid: + try: + os.kill(pid, 15) + logger.info(f"{Colors.GREEN}Backend stopped (PID: {pid}){Colors.RESET}") + except Exception as e: + logger.warning(f"{Colors.RED}Error stopping backend: {e}{Colors.RESET}") + def _restart_backend(self): + """Restart backend server.""" + self._stop_backend() + time.sleep(1) + self._start_backend() + + def display_auth_system(self): + """Display auth system info.""" + print_header("Authentication System") + + logger.info(f" {Colors.CYAN}OAuth Providers:{Colors.RESET}") + logger.info(" • Email + Password") + logger.info(" • Google") + logger.info(" • Telegram") + logger.info(" • GitHub") + logger.info(" • Wallet Signature (EIP-4361)") + logger.info(f"\n {Colors.CYAN}Features:{Colors.RESET}") + logger.info(" • Session management (JWT)") + logger.info(" • Rate limiting per user") + logger.info(" • x402 micropayment enforcement") + logger.info(f"\n {Colors.CYAN}Endpoints:{Colors.RESET}") + logger.info(" POST /api/v1/auth/register - Register new user") + logger.info(" POST /api/v1/auth/login - Login user") + logger.info(" GET /api/v1/auth/status - Get current session") + input(f"\n{Colors.PURPLE}Press Enter to return...{Colors.RESET}") + + def display_security_intel(self): + """Display security intel info.""" + print_header("Security Intelligence") + + logger.info(f" {Colors.CYAN}Modules:{Colors.RESET}") + logger.info(" • Slither - Static analysis") + logger.info(" • Mythril - Symbolic execution") + logger.info(" • OFAC Sanctions - Blocklist") + logger.info(" • Threat Intel - Risk scoring") + logger.info(f"\n {Colors.CYAN}Contract Scanning:{Colors.RESET}") + logger.info(" • Reentrancy detection") + logger.info(" • Integer overflow/underflow") + logger.info(" • Unchecked calls") + logger.info(" • DAO patterns") + input(f"\n{Colors.PURPLE}Press Enter to return...{Colors.RESET}") + + def display_x402_menu(self): + """Display x402 micropayments info.""" + print_header("x402 Micropayments") + + logger.info(f" {Colors.CYAN}Features:{Colors.RESET}") + logger.info(" • Micropayment enforcement") + logger.info(" • Rate-based pricing") + logger.info(" • Forensic analysis") + logger.info(" • Redis-backed tracking") + logger.info(f"\n {Colors.CYAN}Endpoints:{Colors.RESET}") + logger.info(" GET /api/v1/x402/tools - Available tools catalog") + logger.info(" GET /api/v1/x402/status - Payment status") + logger.info(" POST /api/v1/x402/enforce - Enforce micropayment") + input(f"\n{Colors.PURPLE}Press Enter to return...{Colors.RESET}") + + def display_chat(self): + """Launch chat interface.""" + print_header("Chat Interface") + logger.info("\n{Colors.YELLOW}Direct chat with RMI assistant{Colors.RESET}") + logger.info("\n{Colors.RED}Note:{Colors.RESET} This launches the system shell") + input(f"\n{Colors.PURPLE}Press Enter to return...{Colors.RESET}") + + def display_network_status(self): + """Display network connectivity status.""" + print_header("Network Status") + + tests = [ + ("Local Backend", "http://127.0.0.1:8010", "/health"), + ("Redis", "localhost", None), + ("Ethereum RPC", "https://ethereum-rpc.publicnode.com", None), + ("Base RPC", "https://base-rpc.publicnode.com", None), + ] + + for name, url, path in tests: + try: + full_url = f"{url}{path}" if path else url + response = httpx.get(full_url, timeout=3) + status = f"{Colors.GREEN}✓ OK{Colors.RESET} ({response.status_code})" + except Exception: + status = f"{Colors.RED}✗ Failed{Colors.RESET}" + + logger.info(f" {name:<20} {status}") + logger.info() + input(f"{Colors.PURPLE}Press Enter to return...{Colors.RESET}") + + def run(self): + """Main dashboard loop.""" + while self.running: + self.display_main() + + choice = input("Select an option: ").strip().lower() + + if choice == "0": + self.running = False + elif choice == "1": + self.display_server_control() + elif choice == "2": + self.display_auth_system() + elif choice == "3": + self.display_security_intel() + elif choice == "4": + self.display_x402_menu() + elif choice == "5": + self.display_chat() + elif choice == "6": + logger.info("{TUI already running}") + elif choice == "7": + self.display_network_status() + elif choice in ["help", "?"]: + logger.info(f"{Colors.YELLOW}Commands:{Colors.RESET}") + logger.info(" help - Show this help") + logger.info(" clear - Clear screen") + logger.info(" quit - Exit dashboard") + elif choice == "clear": + clear_screen() + elif choice in ["quit", "exit"]: + self.running = False + else: + logger.info(f"{Colors.RED}Invalid option{Colors.RESET}") +# ─── SINGLETON LAUNCHER ──────────────────────────────────────────── + + +def launch_dashboard(): + """Launch the interactive dashboard.""" + try: + dashboard = Dashboard() + dashboard.run() + except KeyboardInterrupt: + pass + except Exception as e: + logger.warning(f"{Colors.RED}Dashboard error: {e}{Colors.RESET}") +# ─── CLI ENTRY POINT ─────────────────────────────────────────────── + +if __name__ == "__main__": + launch_dashboard() diff --git a/app/_archive/legacy_2026_07/rpc_cache.py b/app/_archive/legacy_2026_07/rpc_cache.py new file mode 100644 index 0000000..521133c --- /dev/null +++ b/app/_archive/legacy_2026_07/rpc_cache.py @@ -0,0 +1,543 @@ +""" +Multi-Layer Cache Manager - L1 In-Memory + L2 Redis with Smart TTLs. + +Provides a two-level caching strategy: + L1: In-memory dict (fast, per-process, bounded size) + L2: Redis (shared across processes/workers, persistent optional) + +If REDIS_URL env var is not set, falls back to L1-only mode. +Key format: {data_type}:{chain}:{address} for easy pattern invalidation. + +Smart TTLs per data type (seconds): + token_meta=86400 (24h), deployer=0 (permanent), holders=300 (5min), + price=10, liquidity=30, social=900 (15min), rpc=60, block=15. + +Depends on: redis (>=5.0) for L2; falls back gracefully if not installed. +""" + +import asyncio +import hashlib +import logging +import os +import re +import time +from collections import OrderedDict +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + +# ── Smart TTLs per Data Type ─────────────────────────────────────────────── + +# fmt: off +SMART_TTLS: dict[str, int] = { + "token_meta": 86400, # 24 hours - token name/symbol/logo rarely change + "deployer": 0, # Permanent (0 = never expire in L1, Redis TTL omitted) + "contract_code": 3600, # 1 hour - contract code is immutable but may redeploy + "holders": 300, # 5 minutes - holder count changes frequently + "holder_list": 120, # 2 minutes - individual holder data can shift + "price": 10, # 10 seconds - price is highly volatile + "liquidity": 30, # 30 seconds - liquidity changes but slower than price + "volume": 30, # 30 seconds - same reasoning + "market_cap": 60, # 1 minute - derived from price x supply + "social": 900, # 15 minutes - social media data updates slowly + "metadata": 3600, # 1 hour - token metadata is semi-static + "rpc": 60, # 1 minute - generic RPC responses + "rpc_account": 30, # 30 seconds - account state (balance, etc.) + "rpc_block": 15, # 15 seconds - block data is fresh + "rpc_tx": 60, # 1 minute - confirmed transactions + "scanner": 300, # 5 minutes - scanner results (risk scores, etc.) + "entity": 3600, # 1 hour - entity labeling by address + "news": 600, # 10 minutes - news articles + "trending": 300, # 5 minutes - trending tokens + "default": 120, # 2 minutes - catch-all +} +# fmt: on + + +# ── Data Classes ──────────────────────────────────────────────────────────── + + +@dataclass +class CacheEntry: + """A cached value with metadata.""" + + value: Any + created_at: float = field(default_factory=time.monotonic) + ttl: float = 0.0 # 0 = permanent (no expiry) + access_count: int = 0 + size_bytes: int = 0 + + @property + def is_expired(self) -> bool: + if self.ttl <= 0: + return False + return time.monotonic() - self.created_at > self.ttl + + @property + def age_seconds(self) -> float: + return time.monotonic() - self.created_at + + +@dataclass +class CacheStats: + """Aggregate cache performance stats.""" + + hit_count: int = 0 + miss_count: int = 0 + total_calls: int = 0 + l1_size: int = 0 + l2_size: int = 0 + l1_hits: int = 0 + l2_hits: int = 0 + invalidations: int = 0 + + @property + def hit_rate(self) -> float: + if self.total_calls == 0: + return 0.0 + return self.hit_count / self.total_calls + + def to_dict(self) -> dict[str, Any]: + return { + "hit_count": self.hit_count, + "miss_count": self.miss_count, + "total_calls": self.total_calls, + "hit_rate": round(self.hit_rate * 100, 1), + "l1_size": self.l1_size, + "l2_size": self.l2_size, + "l1_hits": self.l1_hits, + "l2_hits": self.l2_hits, + "invalidations": self.invalidations, + } + + +# ── Cache Key Builder ────────────────────────────────────────────────────── + + +def build_key(data_type: str, chain: str, address: str) -> str: + """Build a standardized cache key. + + Format: {data_type}:{chain}:{address} + All segments are lowercased. Addresses longer than 80 chars are truncated + and hashed for readability. + + Examples: + build_key("price", "solana", "EPjFWdd5AufqSSqeM2qN1xzybapC8G5wUGxvZq2moaomYR") + → "price:solana:epjfwd..." + """ + address = str(address).strip().lower() + chain = str(chain).strip().lower() + data_type = str(data_type).strip().lower() + + if len(address) > 80: + # Hash long addresses (e.g., complex contract addresses) + short = hashlib.md5(address.encode()).hexdigest()[:16] + address = f"{address[:40]}..{short}" + + return f"{data_type}:{chain}:{address}" + + +def get_ttl(data_type: str, override: int | None = None) -> int: + """Get TTL for a data type, with optional override.""" + if override is not None: + return override + return SMART_TTLS.get(data_type.lower(), SMART_TTLS["default"]) + + +# ── Cache Implementation ──────────────────────────────────────────────────── + + +class RpcCache: + """Two-layer cache: L1 in-memory (OrderedDict, LRU eviction) + L2 Redis. + + Usage: + cache = RpcCache() + result = await cache.get_or_fetch( + "price", "solana", token_address, + factory_fn=lambda: fetch_price(token_address), + ) + """ + + # Max entries in L1 memory cache (prevents unbounded growth) + MAX_L1_SIZE = 10000 + + # Redis key prefix for namespacing + REDIS_PREFIX = "rmi:cache:" + + def __init__( + self, + l1_max_size: int = 10000, + redis_url: str | None = None, + ): + """Initialize the cache. + + Args: + l1_max_size: Maximum entries in L1 (memory) cache + redis_url: Redis connection URL. If None, reads REDIS_URL env var. + Set to empty string or False to force L1-only mode. + """ + self._l1: OrderedDict[str, CacheEntry] = OrderedDict() + self._l1_max = l1_max_size + self._lock = asyncio.Lock() + self._stats = CacheStats() + + # Redis L2 setup + self._redis = None + self._redis_available = False + + _url = redis_url if redis_url is not None else os.getenv("REDIS_URL", "") + if _url and _url.strip(): + try: + import redis.asyncio as redis_asyncio + + self._redis = redis_asyncio.from_url( + _url.strip(), + decode_responses=False, # We handle serialization ourselves + socket_connect_timeout=3.0, + socket_timeout=2.0, + retry_on_timeout=True, + health_check_interval=30, + ) + self._redis_available = True + logger.info(f"RpcCache: L2 Redis connected (prefix={self.REDIS_PREFIX})") + except ImportError: + logger.warning("RpcCache: redis package not installed, L2 disabled") + except Exception as e: + logger.warning(f"RpcCache: Redis connection failed ({e}), L2 disabled") + else: + logger.info("RpcCache: No REDIS_URL set, L1-only mode") + + # ── Public API ────────────────────────────────────────────────────── + + async def get(self, data_type: str, chain: str, address: str) -> Any | None: + """Retrieve a cached value. Returns None on miss or expiry. + + Checks L1 first, then L2 (Redis), then returns None. + On L2 hit, promotes the value into L1. + """ + key = build_key(data_type, chain, address) + ttl = get_ttl(data_type) + + # 1. Check L1 (in-memory) + async with self._lock: + entry = self._l1.get(key) + if entry is not None: + if entry.is_expired: + del self._l1[key] + self._stats.l1_size = len(self._l1) + else: + entry.access_count += 1 + self._l1.move_to_end(key) + self._stats.hit_count += 1 + self._stats.total_calls += 1 + self._stats.l1_hits += 1 + self._stats.l1_size = len(self._l1) + return entry.value + + # 2. Check L2 (Redis) + if self._redis_available and self._redis: + try: + redis_key = f"{self.REDIS_PREFIX}{key}" + raw = await self._redis.get(redis_key) + if raw is not None: + value = self._deserialize(raw) + # Promote to L1 + async with self._lock: + self._stats.hit_count += 1 + self._stats.total_calls += 1 + self._stats.l2_hits += 1 + self._l1[key] = CacheEntry(value=value, ttl=float(ttl)) + self._l1.move_to_end(key) + self._stats.l1_size = len(self._l1) + self._evict_l1_if_needed() + return value + except Exception as e: + logger.debug(f"Redis get error for {key}: {e}") + + # 3. Miss + async with self._lock: + self._stats.miss_count += 1 + self._stats.total_calls += 1 + return None + + async def set( + self, + data_type: str, + chain: str, + address: str, + value: Any, + ttl_override: int | None = None, + ) -> None: + """Store a value in both L1 and L2 caches. + + Args: + data_type: Type of data (see SMART_TTLS keys) + chain: Blockchain identifier + address: Address or identifier + value: Any JSON-serializable value + ttl_override: Override the default TTL for this data type + """ + key = build_key(data_type, chain, address) + ttl = get_ttl(data_type, ttl_override) + + # Serialize for size estimation + raw = self._serialize(value) + size = len(raw) + + entry = CacheEntry(value=value, ttl=float(ttl), size_bytes=size) + + # Store in L1 + async with self._lock: + self._l1[key] = entry + self._l1.move_to_end(key) + self._stats.l1_size = len(self._l1) + self._evict_l1_if_needed() + + # Store in L2 + if self._redis_available and self._redis: + try: + redis_key = f"{self.REDIS_PREFIX}{key}" + if ttl > 0: + await self._redis.setex(redis_key, ttl, raw) + else: + # Permanent - no expiry + await self._redis.set(redis_key, raw) + except Exception as e: + logger.debug(f"Redis set error for {key}: {e}") + + async def get_or_fetch( + self, + data_type: str, + chain: str, + address: str, + factory_fn: Callable[[], Awaitable[Any]], + ttl_override: int | None = None, + ) -> Any: + """Get cached value or fetch fresh via factory function. + + This is the primary convenience method - it combines get() + set() + in a single call. If the value is cached and not expired, returns it + immediately. Otherwise, calls factory_fn(), caches the result, and + returns it. + + Args: + data_type: Type of data (e.g., 'price', 'token_meta') + chain: Blockchain identifier + address: Address/identifier + factory_fn: Async callable that returns the value + ttl_override: Override TTL for this entry + + Returns: + The cached or freshly fetched value, or None if fetch failed. + """ + # Try cache first + cached = await self.get(data_type, chain, address) + if cached is not None: + return cached + + # Fetch fresh + try: + value = await factory_fn() + if value is not None: + await self.set(data_type, chain, address, value, ttl_override) + return value + except Exception as e: + logger.warning(f"Factory fetch failed for {data_type}:{chain}:{address}: {e}") + return None + + async def invalidate(self, key_pattern: str) -> int: + """Remove all cached entries matching a key pattern. + + For L1, uses simple string matching (fnmatch-style with *): + - "price:*:*" - invalidates all price entries + - "*:solana:*" - invalidates all Solana entries + - "price:solana:*" - invalidates a specific token's price + + For L2, uses Redis SCAN + pattern matching. + + Returns number of entries invalidated (L1 + L2 count). + """ + # Convert glob-like pattern to regex for L1 + regex_pattern = re.escape(key_pattern).replace(r"\*", ".*") + regex = re.compile(f"^{regex_pattern}$") + + count = 0 + + # L1 invalidation + async with self._lock: + keys_to_remove = [k for k in self._l1 if regex.match(k)] + for k in keys_to_remove: + del self._l1[k] + count += 1 + self._stats.l1_size = len(self._l1) + + # L2 invalidation + if self._redis_available and self._redis: + try: + redis_pattern = f"{self.REDIS_PREFIX}{key_pattern}" + cursor = 0 + l2_count = 0 + while True: + cursor, keys = await self._redis.scan( + cursor=cursor, + match=redis_pattern, + count=100, + ) + if keys: + await self._redis.delete(*keys) + l2_count += len(keys) + if cursor == 0: + break + count += l2_count + logger.debug(f"Invalidated {l2_count} L2 entries matching '{key_pattern}'") + except Exception as e: + logger.warning(f"Redis invalidation error for pattern '{key_pattern}': {e}") + + async with self._lock: + self._stats.invalidations += count + logger.info(f"Cache invalidation: {count} entries removed (pattern='{key_pattern}')") + return count + + async def invalidate_address( + self, + data_type: str, + chain: str, + address: str, + ) -> bool: + """Invalidate a specific cache key.""" + key = build_key(data_type, chain, address) + + found = False + + async with self._lock: + if key in self._l1: + del self._l1[key] + self._stats.l1_size = len(self._l1) + self._stats.invalidations += 1 + found = True + + if self._redis_available and self._redis: + try: + redis_key = f"{self.REDIS_PREFIX}{key}" + deleted = await self._redis.delete(redis_key) + if deleted: + found = True + except Exception as e: + logger.debug(f"Redis delete error for {key}: {e}") + + return found + + async def clear(self) -> int: + """Clear all entries from both L1 and L2 caches. + + Returns total number of entries cleared. + """ + count = 0 + + async with self._lock: + count = len(self._l1) + self._l1.clear() + self._stats.l1_size = 0 + + if self._redis_available and self._redis: + try: + keys = await self._redis.keys(f"{self.REDIS_PREFIX}*") + if keys: + await self._redis.delete(*keys) + count += len(keys) + except Exception as e: + logger.warning(f"Redis clear error: {e}") + + logger.info(f"Cache cleared: {count} entries removed") + return count + + # ── Stats ──────────────────────────────────────────────────────────── + + async def stats(self) -> dict[str, Any]: + """Return cache performance statistics.""" + async with self._lock: + result = self._stats.to_dict() + # Add Redis L2 info + if self._redis_available and self._redis: + try: + key_count = await self._redis.dbsize() + result["l2_size"] = key_count + except Exception: + result["l2_size"] = -1 + result["redis_available"] = self._redis_available + result["l1_max"] = self._l1_max + return result + + # ── Lifecycle ───────────────────────────────────────────────────────── + + async def close(self): + """Close Redis connection if open.""" + if self._redis: + try: + await self._redis.aclose() + self._redis = None + self._redis_available = False + logger.info("RpcCache: Redis connection closed") + except Exception as e: + logger.debug(f"Redis close error: {e}") + + # ── Internal Helpers ────────────────────────────────────────────────── + + def _serialize(self, value: Any) -> bytes: + """Serialize a value to bytes for Redis storage. Uses JSON.""" + import json + + try: + # Use a custom encoder for special types + return json.dumps(value, default=str, ensure_ascii=False).encode("utf-8") + except (TypeError, ValueError) as e: + logger.warning(f"Serialization fallback for {type(value)}: {e}") + return json.dumps({"__serialized__": str(value)}).encode("utf-8") + + def _deserialize(self, raw: bytes) -> Any: + """Deserialize bytes from Redis back to Python object.""" + import json + + try: + return json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.warning(f"Deserialization error: {e}") + return None + + def _evict_l1_if_needed(self): + """Evict oldest entries from L1 if over capacity (LRU eviction).""" + # Called within self._lock + while len(self._l1) > self._l1_max: + self._l1.popitem(last=False) # Remove oldest (first inserted) + + # ── Health Check ────────────────────────────────────────────────────── + + async def health(self) -> dict[str, Any]: + """Quick health check.""" + status = { + "l1_ok": True, + "l1_entries": len(self._l1), + "l2_ok": False, + } + if self._redis_available and self._redis: + try: + await self._redis.ping() + status["l2_ok"] = True + except Exception: + status["l2_ok"] = False + return status + + +# ── Singleton ────────────────────────────────────────────────────────────── + +_cache_instance: RpcCache | None = None + + +def get_rpc_cache() -> RpcCache: + """Get the global RpcCache singleton.""" + global _cache_instance + if _cache_instance is None: + _cache_instance = RpcCache() + return _cache_instance diff --git a/app/_archive/legacy_2026_07/safe_imports.py b/app/_archive/legacy_2026_07/safe_imports.py new file mode 100644 index 0000000..a080461 --- /dev/null +++ b/app/_archive/legacy_2026_07/safe_imports.py @@ -0,0 +1,168 @@ +""" +Safe Module Importer - Handles stub fallbacks for security_intel router +======================================================================== + +This module wraps security feature imports with graceful fallbacks to stubs +if dependencies aren't available, preventing full router import failures. +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def safe_import(name: str, fallback_to_stub: bool = True) -> Any | None: + """ + Safely import a module, optionally falling back to stub implementation. + + Args: + name: Module path (e.g., 'web3' or 'app.contract_deepscan') + fallback_to_stub: If True, returns stub module on ImportError + + Returns: + Imported module, stub fallback, or None + """ + try: + module = __import__(name, fromlist=[""]) + logger.info(f"✓ Loaded: {name}") + return module + except ImportError: + if fallback_to_stub: + logger.warning(f"⚠ {name} not available, using stub fallback") + return None + raise + + +# ─── STUB CLASSES FOR CRITICAL DEPENDENCIES ─────────────────────── + + +class StubContractDeepscan: + """Stub contract scanning module.""" + + def deep_scan_contract(self, address: str, chain: str = "base") -> dict[str, Any]: + return { + "address": address, + "chain": chain, + "scanned": False, + "vulnerabilities": [], + "checks": [], + "status": "stub_mode", + } + + def batch_deep_scan(self, addresses: list[str], chain: str = "base") -> dict[str, Any]: + results = [self.deep_scan_contract(addr, chain) for addr in addresses] + return {"contracts": results, "total": len(results)} + + +class StubCrossChainCorrelator: + """Stub cross-chain correlation module.""" + + class ChainFingerprint: + def __init__(self, address: str, chain: str): + self.address = address + self.chain = chain + + def get_hash(self) -> str: + return f"{self.chain}:{self.address}" + + class CrossChainCorrelator: + def link_wallets(self, addresses: list[str], chains: list[str]) -> dict[str, Any]: + return {"linked": True, "wallets": addresses, "chains": chains} + + def find_correlations(self, address: str, limit: int = 10) -> dict[str, Any]: + return {"address": address, "correlations": [], "count": 0} + + +class StubCryptoGuard: + """Stub crypto guard module.""" + + def check_wallet_reputation(self, address: str) -> dict[str, Any]: + return {"address": address, "reputation": "unknown", "risk_score": 0, "flags": []} + + def rate_limit_by_tier(self, tier: str) -> dict[str, int]: + tiers = {"FREE": {"requests_per_hour": 10, "requests_per_day": 100}} + return tiers.get(tier, {"requests_per_hour": 100, "requests_per_day": 1000}) + + def require_crypto_auth(self, request: Any) -> dict[str, Any]: + return {"authenticated": True} + + +# ─── STUB INSTANCES ─────────────────────────────────────────────── + +_stub_deepscan = StubContractDeepscan() +_stub_correlator = StubCrossChainCorrelator() +_stub_correlator_corr = _stub_correlator.CrossChainCorrelator() +_stub_crypto_guard = StubCryptoGuard() + + +# ─── FALLBACK LOADERS ───────────────────────────────────────────── + + +def get_contract_deepscan(): + """Load contract deepscan with fallback to stub.""" + try: + from app.contract_deepscan import batch_deep_scan, deep_scan_contract + + return deep_scan_contract, batch_deep_scan + except ImportError: + logger.warning("contract_deepscan not available, using stub") + return (_stub_deepscan.deep_scan_contract, _stub_deepscan.batch_deep_scan) + + +def get_cross_chain_correlator(): + """Load cross-chain correlator with fallback to stub.""" + try: + from app.cross_chain_correlator import CrossChainCorrelator + + return CrossChainCorrelator + except ImportError: + logger.warning("cross_chain_correlator not available, using stub") + return _stub_correlator_corr + + +def get_crypto_guard(): + """Load crypto guard with fallback to stub.""" + try: + from app.crypto_guard import ( + check_wallet_reputation, + rate_limit_by_tier, + ) + + return check_wallet_reputation, rate_limit_by_tier + except ImportError: + logger.warning("crypto_guard not available, using stub") + return (_stub_crypto_guard.check_wallet_reputation, _stub_crypto_guard.rate_limit_by_tier) + + +def get_sentinel_manager(): + """Load mempool sentinel with fallback to stub.""" + try: + from app.mempool_sentinel import SentinelManager + + return SentinelManager + except ImportError: + logger.warning("mempool_sentinel not available, using stub") + return lambda: None + + +def get_wallet_anomaly_detector(): + """Load wallet anomaly detector with fallback to stub.""" + try: + from app.ml_anomaly import WalletAnomalyDetector + + return WalletAnomalyDetector + except ImportError: + logger.warning("ml_anomaly not available, using stub") + return lambda: None + + +def get_token_anomaly_detector(): + """Load token anomaly detector with fallback to stub.""" + try: + from app.ml_anomaly import TokenMetricAnomalyDetector + + return TokenMetricAnomalyDetector + except ImportError: + logger.warning("ml_anomaly not available, using stub") + return lambda: None diff --git a/app/_archive/legacy_2026_07/scam_classifier.py b/app/_archive/legacy_2026_07/scam_classifier.py new file mode 100644 index 0000000..4af1b6c --- /dev/null +++ b/app/_archive/legacy_2026_07/scam_classifier.py @@ -0,0 +1,386 @@ +""" +SENTINEL AI - Self-Training Scam Classifier +============================================ + +Turns 5,000+ historical token scans into a self-improving ML model. +Every new scan feeds back into training. The model gets smarter over time. + +Architecture: + Historical scans → Feature extraction → XGBoost training → Model on disk + New scan → Feature extraction → Model prediction → AI risk score (0-100) + Confirmed scam → Weight boost → Retrain trigger + +Features extracted from all 45 enrichments + market data + SENTINEL modules. +The model learns which COMBINATIONS of signals predict scams, catching +patterns that static rules miss entirely. + +Premium feature: "AI-Powered Risk Score" - ML confidence alongside rules-based score. +""" + +import json +import logging +import os +import pickle +from datetime import UTC, datetime +from typing import Any + +import numpy as np + +logger = logging.getLogger("sentinel.ai") + +MODEL_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "models") +MODEL_PATH = os.path.join(MODEL_DIR, "scam_classifier_xgb.pkl") +FEATURE_NAMES_PATH = os.path.join(MODEL_DIR, "scam_classifier_features.json") + +# ────────────────────────────────────────────────────────────── +# Feature Extraction - 80+ features from enrichment data +# ────────────────────────────────────────────────────────────── + + +def extract_features(scan_result: dict[str, Any]) -> dict[str, float]: + """Extract ML features from a scan result. Returns dict of feature_name → value.""" + f = {} + free = scan_result.get("free", scan_result) if isinstance(scan_result, dict) else {} + + # ── Market data features (16 features) ── + f["price_usd"] = float(free.get("price_usd", 0) or 0) + f["volume_24h"] = float(free.get("volume_24h", 0) or 0) + f["liquidity_usd"] = float(free.get("liquidity_usd", 0) or 0) + f["market_cap"] = float(free.get("market_cap", 0) or 0) + f["age_hours"] = float(free.get("age_hours", 0) or 0) + f["price_change_5m"] = float(free.get("price_change_5m", 0) or 0) + f["price_change_1h"] = float(free.get("price_change_1h", 0) or 0) + f["price_change_24h"] = float(free.get("price_change_24h", 0) or 0) + f["tx_count"] = float(free.get("tx_count", 0) or 0) + f["volume_to_liquidity_ratio"] = f["volume_24h"] / max(f["liquidity_usd"], 1) + f["market_cap_to_liquidity_ratio"] = f["market_cap"] / max(f["liquidity_usd"], 1) + f["has_price_data"] = 1.0 if f["price_usd"] > 0 else 0.0 + f["has_liquidity"] = 1.0 if f["liquidity_usd"] > 0 else 0.0 + f["is_very_new"] = 1.0 if f["age_hours"] < 1 else 0.0 + f["is_new"] = 1.0 if f["age_hours"] < 24 else 0.0 + f["price_volatility"] = abs(f["price_change_1h"]) + abs(f["price_change_24h"]) + + # ── Holder features (8 features) ── + holders = free.get("holders", {}) or {} + if isinstance(holders, dict): + f["holder_count"] = float(holders.get("total", holders.get("count", 0)) or 0) + f["top10_pct"] = float(holders.get("top_10_percentage", holders.get("top10_pct", 0)) or 0) + f["top1_pct"] = float(holders.get("top_1_percentage", holders.get("top1_pct", 0)) or 0) + f["holder_concentration_high"] = 1.0 if f["top10_pct"] > 80 else 0.0 + f["holder_concentration_critical"] = 1.0 if f["top10_pct"] > 95 else 0.0 + f["has_holder_data"] = 1.0 if f["holder_count"] > 0 else 0.0 + f["few_holders"] = 1.0 if 0 < f["holder_count"] < 20 else 0.0 + f["many_holders"] = 1.0 if f["holder_count"] > 500 else 0.0 + + # ── Honeypot / trade simulation (8 features) ── + sim = free.get("simulation", {}) or {} + if isinstance(sim, dict): + f["honeypot_detected"] = 1.0 if sim.get("is_honeypot") or sim.get("honeypot") else 0.0 + f["buy_success"] = 1.0 if sim.get("buy_success", True) else 0.0 + f["sell_success"] = 1.0 if sim.get("sell_success", True) else 0.0 + f["buy_tax_pct"] = float(sim.get("buy_tax_pct", 0) or 0) + f["sell_tax_pct"] = float(sim.get("sell_tax_pct", 0) or 0) + f["tax_high"] = 1.0 if f["sell_tax_pct"] > 10 else 0.0 + f["tax_extreme"] = 1.0 if f["sell_tax_pct"] > 50 else 0.0 + f["has_simulation"] = 1.0 if sim else 0.0 + + # ── Contract / authority features (6 features) ── + contract = free.get("contract_verification", {}) or {} + if isinstance(contract, dict): + f["contract_verified"] = 1.0 if contract.get("verified") else 0.0 + f["contract_unverified"] = 1.0 if not contract.get("verified") else 0.0 + f["is_proxy"] = 1.0 if contract.get("is_proxy") or contract.get("proxy") else 0.0 + f["mint_authority_renounced"] = 1.0 if free.get("mint_authority") == "renounced" else 0.0 + f["freeze_authority_exists"] = 1.0 if free.get("freeze_authority") else 0.0 + f["has_contract_data"] = 1.0 if contract else 0.0 + + # ── Deployer features (8 features) ── + deployer = free.get("deployer", {}) or {} + deep = free.get("deep_deployer", {}) or {} + if isinstance(deployer, dict) or isinstance(deep, dict): + d = {**deployer, **deep} if isinstance(deep, dict) else deployer + f["deployer_known"] = 1.0 if d.get("address") or deployer.get("address") else 0.0 + f["deployer_risk_score"] = float(d.get("risk_score", 0) or 0) + f["deployer_scam_count"] = float(d.get("scam_count", 0) or 0) + f["deployer_high_risk"] = 1.0 if f["deployer_risk_score"] > 70 else 0.0 + f["deployer_critical"] = 1.0 if f["deployer_risk_score"] > 90 else 0.0 + f["multi_chain_deployer"] = 1.0 if free.get("cross_chain", {}).get("chain_count", 0) > 1 else 0.0 + f["deployer_chain_count"] = float( + free.get("cross_chain", {}).get("chain_count", 1) if isinstance(free.get("cross_chain"), dict) else 1 + ) + f["has_deployer_data"] = 1.0 if f["deployer_known"] else 0.0 + + # ── LP / liquidity lock (5 features) ── + lp = free.get("lp_lock_multi", {}) or {} + if isinstance(lp, dict): + f["lp_locked"] = 1.0 if lp.get("locked") or lp.get("is_locked") else 0.0 + f["lp_lock_unconfirmed"] = 1.0 if not f["lp_locked"] and f["liquidity_usd"] > 0 else 0.0 + f["lp_lock_pct"] = float(lp.get("lock_percentage", lp.get("locked_pct", 0)) or 0) + f["lp_has_data"] = 1.0 if lp else 0.0 + f["no_lp"] = 1.0 if f["liquidity_usd"] == 0 else 0.0 + + # ── External API signals (12 features) ── + # Birdeye + birdeye = free.get("birdeye", {}) or {} + f["birdeye_honeypot"] = 1.0 if isinstance(birdeye, dict) and birdeye.get("honeypot") else 0.0 + f["birdeye_rugpull"] = 1.0 if isinstance(birdeye, dict) and birdeye.get("rugpull") else 0.0 + + # Honeypot.is + hp = free.get("honeypot_is", {}) or {} + f["honeypot_is_detected"] = 1.0 if isinstance(hp, dict) and hp.get("is_honeypot") else 0.0 + + # Token Sniffer + ts = free.get("token_sniffer", {}) or {} + f["tokensniffer_scam"] = 1.0 if isinstance(ts, dict) and ts.get("is_scam") else 0.0 + f["tokensniffer_score"] = float(ts.get("score", 0) or 0) if isinstance(ts, dict) else 0.0 + + # ChainAware AI + ca = free.get("chainaware_ai", {}) or {} + f["chainaware_rug_risk"] = float(ca.get("rug_probability", 0) or 0) if isinstance(ca, dict) else 0.0 + + # GoPlus + gp = free.get("goplus", {}) or {} + f["goplus_honeypot"] = 1.0 if isinstance(gp, dict) and gp.get("is_honeypot") else 0.0 + + # De.Fi + df = free.get("defi_scanner", {}) or {} + f["defi_honeypot"] = 1.0 if isinstance(df, dict) and df.get("honeypot") else 0.0 + f["defi_ai_score"] = float(df.get("aiScore", 50) or 50) if isinstance(df, dict) else 50.0 + + # Copycat + cc = free.get("copycat_check", {}) or {} + f["is_copycat"] = 1.0 if isinstance(cc, dict) and cc.get("is_copycat") else 0.0 + + # Volume anomaly + va = free.get("volume_anomaly", {}) or {} + f["volume_manipulation"] = 1.0 if isinstance(va, dict) and va.get("manipulation_detected") else 0.0 + + # ── RAG / scam database features (6 features) ── + rag = free.get("rag_scam_check", {}) or {} + if isinstance(rag, dict): + f["rag_scam_match"] = 1.0 if rag.get("match_found") or rag.get("is_scam") else 0.0 + f["rag_similarity"] = float(rag.get("similarity", 0) or 0) + f["rag_similarity_high"] = 1.0 if f["rag_similarity"] > 0.8 else 0.0 + f["rag_matches_count"] = float(rag.get("match_count", 0) or 0) + f["known_scam_address"] = 1.0 if rag.get("is_known_scam") else 0.0 + f["has_rag_data"] = 1.0 if rag else 0.0 + + # ── Social / sentiment (5 features) ── + sentiment = free.get("sentiment", {}) or {} + f["social_volume"] = float(sentiment.get("mention_count", 0) or 0) if isinstance(sentiment, dict) else 0.0 + f["sentiment_score"] = float(sentiment.get("score", 0) or 0) if isinstance(sentiment, dict) else 0.0 + santiment = free.get("santiment", {}) or {} + f["santiment_hype"] = 1.0 if isinstance(santiment, dict) and santiment.get("hype_detected") else 0.0 + f["santiment_viral_low_vol"] = 1.0 if isinstance(santiment, dict) and santiment.get("viral_low_volume") else 0.0 + f["has_social_data"] = 1.0 if sentiment or santiment else 0.0 + + # ── Chain/network features (4 features) ── + chain = scan_result.get("chain", "").lower() + f["chain_solana"] = 1.0 if chain == "solana" else 0.0 + f["chain_ethereum"] = 1.0 if chain == "ethereum" else 0.0 + f["chain_bsc"] = 1.0 if chain == "bsc" else 0.0 + f["chain_base"] = 1.0 if chain == "base" else 0.0 + + # ── Nansen / Arkham (4 features) ── + nansen = free.get("nansen", {}) or {} + f["nansen_low_holders"] = 1.0 if isinstance(nansen, dict) and nansen.get("low_holders") else 0.0 + f["nansen_net_outflow"] = 1.0 if isinstance(nansen, dict) and nansen.get("net_outflow") else 0.0 + arkham = free.get("arkham", {}) or {} + f["arkham_scam_entity"] = 1.0 if isinstance(arkham, dict) and arkham.get("scam_entity") else 0.0 + f["has_premium_data"] = 1.0 if nansen or arkham else 0.0 + + return f + + +# ────────────────────────────────────────────────────────────── +# Model Training +# ────────────────────────────────────────────────────────────── + + +class ScamClassifier: + """XGBoost classifier trained on historical scan data.""" + + def __init__(self): + self.model = None + self.feature_names: list[str] = [] + self.training_samples: int = 0 + self.accuracy: float = 0.0 + self.last_trained: str | None = None + self._load() + + def _load(self): + """Load model from disk if available.""" + if os.path.exists(MODEL_PATH): + try: + with open(MODEL_PATH, "rb") as f: + self.model = pickle.load(f) + if os.path.exists(FEATURE_NAMES_PATH): + with open(FEATURE_NAMES_PATH) as f: + meta = json.load(f) + self.feature_names = meta.get("features", []) + self.training_samples = meta.get("samples", 0) + self.accuracy = meta.get("accuracy", 0.0) + self.last_trained = meta.get("trained_at") + logger.info( + f"Loaded scam classifier: {self.training_samples} samples, " + f"{len(self.feature_names)} features, {self.accuracy:.1%} accuracy" + ) + except Exception as e: + logger.warning(f"Failed to load classifier: {e}") + self.model = None + + def _save(self): + """Persist model and metadata to disk.""" + os.makedirs(MODEL_DIR, exist_ok=True) + with open(MODEL_PATH, "wb") as f: + pickle.dump(self.model, f) + with open(FEATURE_NAMES_PATH, "w") as f: + json.dump( + { + "features": self.feature_names, + "samples": self.training_samples, + "accuracy": self.accuracy, + "trained_at": datetime.now(UTC).isoformat(), + }, + f, + ) + logger.info(f"Saved classifier: {self.training_samples} samples, {self.accuracy:.1%} accuracy") + + def train(self, scans: list[dict[str, Any]]) -> dict[str, Any]: + """Train on historical scan data. Each scan must have 'is_scam' label.""" + try: + from xgboost import XGBClassifier + except ImportError: + logger.warning("XGBoost not installed. Install with: pip install xgboost") + return {"status": "error", "error": "xgboost not installed"} + + # Extract features and labels + X_list = [] + y_list = [] + + for scan in scans: + features = extract_features(scan) + is_scam = scan.get("is_scam", False) or scan.get("verdict") == "scam" + + if not X_list: # First sample - record feature names + self.feature_names = sorted(features.keys()) + + # Build feature vector in consistent order + row = [features.get(name, 0.0) for name in self.feature_names] + X_list.append(row) + y_list.append(1 if is_scam else 0) + + if len(X_list) < 10: + return {"status": "error", "error": f"Need at least 10 samples, got {len(X_list)}"} + + if sum(y_list) < 2: + return {"status": "error", "error": "Need at least 2 scam samples for training"} + + X = np.array(X_list, dtype=np.float32) + y = np.array(y_list, dtype=np.int32) + + # Handle NaN/Inf + X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0) + + # Train + self.model = XGBClassifier( + n_estimators=100, + max_depth=4, + learning_rate=0.1, + subsample=0.8, + colsample_bytree=0.8, + scale_pos_weight=(len(y) - sum(y)) / max(sum(y), 1), # Handle class imbalance + random_state=42, + use_label_encoder=False, + eval_metric="logloss", + ) + self.model.fit(X, y) + + # Evaluate + train_pred = self.model.predict(X) + self.accuracy = float((train_pred == y).mean()) + self.training_samples = len(X_list) + self.last_trained = datetime.now(UTC).isoformat() + + # Feature importance + importance = {} + if hasattr(self.model, "feature_importances_"): + for name, imp in zip(self.feature_names, self.model.feature_importances_, strict=False): + importance[name] = round(float(imp), 4) + + self._save() + + return { + "status": "trained", + "samples": self.training_samples, + "scam_samples": int(sum(y)), + "features": len(self.feature_names), + "accuracy": round(self.accuracy, 3), + "feature_importance": dict(sorted(importance.items(), key=lambda x: -x[1])[:15]), + } + + def predict(self, scan_result: dict[str, Any]) -> dict[str, Any]: + """Predict scam probability for a new scan.""" + if self.model is None: + return {"status": "no_model", "probability": None, "confidence": 0} + + features = extract_features(scan_result) + row = np.array([[features.get(name, 0.0) for name in self.feature_names]], dtype=np.float32) + row = np.nan_to_num(row, nan=0.0, posinf=0.0, neginf=0.0) + + try: + proba = self.model.predict_proba(row)[0] + scam_prob = float(proba[1]) if len(proba) > 1 else float(proba[0]) + + # Confidence based on training data quality + confidence = min(self.accuracy * 100, 95) if self.accuracy > 0 else 50 + + # Get top contributing features + contributions = {} + if hasattr(self.model, "feature_importances_"): + for name, imp in zip(self.feature_names, self.model.feature_importances_, strict=False): + if features.get(name, 0) != 0 and imp > 0.01: + contributions[name] = { + "value": features.get(name, 0), + "importance": round(float(imp), 3), + } + + # Risk level + if scam_prob > 0.8: + risk_level = "critical" + elif scam_prob > 0.6: + risk_level = "high" + elif scam_prob > 0.4: + risk_level = "medium" + elif scam_prob > 0.2: + risk_level = "low" + else: + risk_level = "safe" + + return { + "status": "ok", + "probability": round(scam_prob, 4), + "risk_level": risk_level, + "ai_risk_score": round(scam_prob * 100), + "confidence": round(confidence, 1), + "model_samples": self.training_samples, + "model_accuracy": round(self.accuracy, 3), + "top_signals": dict(sorted(contributions.items(), key=lambda x: -x[1]["importance"])[:8]), + } + except Exception as e: + logger.warning(f"Prediction failed: {e}") + return {"status": "error", "probability": None, "error": str(e)} + + +# ────────────────────────────────────────────────────────────── +# Singleton +# ────────────────────────────────────────────────────────────── + +_classifier: ScamClassifier | None = None + + +def get_classifier() -> ScamClassifier: + global _classifier + if _classifier is None: + _classifier = ScamClassifier() + return _classifier diff --git a/app/_archive/legacy_2026_07/scan_rate_limiter.py b/app/_archive/legacy_2026_07/scan_rate_limiter.py new file mode 100644 index 0000000..1f87a4b --- /dev/null +++ b/app/_archive/legacy_2026_07/scan_rate_limiter.py @@ -0,0 +1,374 @@ +""" +RMI Freemium Scan Rate Limiter +=============================== +- 5 free token/wallet scans per day per identity (IP for anonymous, user ID for auth) +- Premium users: unlimited or per-tier limits +- Internal/cron API key: unlimited bypass +- x402 paid calls: bypass (already paid) +- Returns clear upsell messaging when limit is hit +""" + +import logging +import os +from datetime import UTC, datetime, timedelta +from typing import Any + +import redis + +logger = logging.getLogger(__name__) + +# ── Configuration ── + +FREE_DAILY_LIMIT = int(os.getenv("FREE_SCAN_LIMIT", "5")) +PRO_DAILY_LIMIT = int(os.getenv("PRO_SCAN_LIMIT", "100")) +ELITE_DAILY_LIMIT = int(os.getenv("ELITE_SCAN_LIMIT", "0")) # 0 = unlimited + +INTERNAL_API_KEY = os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026") + +# Redis setup +REDIS_HOST = os.getenv("REDIS_HOST", "localhost") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +REDIS_DB = os.getenv("REDIS_USAGE_DB", "2") # Separate DB for usage tracking + +# Human subscriptions (monthly, site access) +SCAN_PACKS = { + "starter_monthly": { + "scans": 50, + "price_usd": 29.99, + "price_atoms": "29990000", + "description": "50 scans/day - Starter tier", + }, + "pro_monthly": { + "scans": 200, + "price_usd": 49.99, + "price_atoms": "49990000", + "description": "200 scans/day - Pro tier", + }, + "elite_monthly": { + "scans": 1000, + "price_usd": 89.99, + "price_atoms": "89990000", + "description": "1000 scans/day + API access - Elite tier", + }, +} + +# Bot/API prepaid packs (one-time, no expiry, for AI agents and trading bots) +BOT_PACKS = { + "bot_scout": { + "calls": 200, + "price_usd": 9.99, + "price_atoms": "9990000", + "description": "200 API calls - Scout pack for testing bots", + }, + "bot_hunter": { + "calls": 1500, + "price_usd": 49.99, + "price_atoms": "49990000", + "description": "1,500 API calls - Hunter pack for active bots (33% savings)", + }, + "bot_whale": { + "calls": 10000, + "price_usd": 199.99, + "price_atoms": "199990000", + "description": "10,000 API calls - Whale pack for production bots (60% savings)", + }, + "bot_api_plan": { + "calls": 50000, + "price_usd": 499.00, + "price_atoms": "499000000", + "description": "50,000 calls/month - API Plan for SaaS/funds ($0.01/call)", + }, +} + +# Trial abuse prevention +TRIAL_CONFIG = { + "max_free_per_fingerprint": 3, # 3 free calls per device fingerprint per tool + "fingerprint_window_hours": 168, # 7-day fingerprint window + "max_free_per_ip": 15, # 15 total free calls per IP across all tools + "ip_window_hours": 24, # 24-hour IP window + "cooldown_after_exhaust": 72, # 72-hour cooldown after exhausting all trials +} + +PREMIUM_UPSELL = { + "free_limit_reached": { + "message": f"You've used your {FREE_DAILY_LIMIT} free scans for today. Unlock Starter (50/day), Pro (200/day), or Elite (1000/day + API). $CRM/$CRYPTORUGMUNCH holders get 50% off for 3 months.", + "upgrade_url": "https://rugmunch.io/pricing", + "tiers": { + "free": { + "daily_limit": FREE_DAILY_LIMIT, + "price": "$0", + "features": ["5 scans/day", "Basic safety score", "DexScreener data"], + }, + "starter": { + "daily_limit": 50, + "price": "$29.99/mo", + "features": [ + "50 scans/day", + "Full risk analysis", + "Mint/freeze authority", + "Honeypot detection", + "LP verification", + ], + }, + "pro": { + "daily_limit": 200, + "price": "$49.99/mo", + "features": [ + "200 scans/day", + "All Starter features", + "Bundle detection", + "MEV analysis", + "Whale tracking", + "Dev reputation", + ], + }, + "elite": { + "daily_limit": 1000, + "price": "$89.99/mo", + "features": [ + "1000 scans/day", + "All Pro features", + "Full API access", + "Priority support", + "SENTINEL deep modules", + ], + }, + "enterprise": { + "daily_limit": "unlimited", + "price": "Custom", + "features": [ + "Unlimited API access", + "Dedicated support", + "Custom integrations", + "SLA guarantee", + "Contact: biz@rugmunch.io", + ], + }, + "holder_discount": { + "discount": "50%", + "duration": "3 months", + "eligibility": "$CRM and $CRYPTORUGMUNCH holders", + "verification": "v2 airdrop checker", + "note": "Verify token holdings via the airdrop checker to claim your 50% discount code", + }, + "scan_packs": { + k: { + "price": v["price_usd"], + "scans": v["scans"] or "unlimited", + "description": v["description"], + } + for k, v in SCAN_PACKS.items() + }, + }, + } +} + + +def _get_redis() -> redis.Redis: + """Get Redis connection for usage tracking.""" + return redis.Redis( + host=REDIS_HOST, + port=REDIS_PORT, + password=REDIS_PASSWORD, + db=int(REDIS_DB), + decode_responses=True, + ) + + +def _today_key() -> str: + """Return today's date key for Redis.""" + return datetime.now(UTC).strftime("%Y-%m-%d") + + +def _identity_key(identity_type: str, identity: str) -> str: + """Build Redis key for an identity's daily usage.""" + return f"rmi:scan_usage:{_today_key()}:{identity_type}:{identity}" + + +def check_scan_limit( + identity: str, + identity_type: str = "ip", + tier: str = "free", + is_internal: bool = False, + is_x402_paid: bool = False, +) -> dict[str, Any]: + """ + Check if a scan request is within limits. + + Args: + identity: IP address or user ID + identity_type: "ip" or "user" + tier: "free", "pro", "elite" + is_internal: True for internal/cron calls (bypass all limits) + is_x402_paid: True for x402 paid calls (bypass all limits) + + Returns: + {"allowed": bool, "remaining": int, "limit": int, "upsell": dict|None} + """ + # Internal calls and x402 paid calls always pass + if is_internal or is_x402_paid: + return {"allowed": True, "remaining": -1, "limit": -1, "upsell": None} + + # Determine daily limit + if tier == "elite": + daily_limit = ELITE_DAILY_LIMIT # 0 = unlimited + elif tier == "pro": + daily_limit = PRO_DAILY_LIMIT # 100 + else: + daily_limit = FREE_DAILY_LIMIT # 5 + + # Unlimited (elite) + if daily_limit == 0: + return {"allowed": True, "remaining": -1, "limit": 0, "upsell": None} + + # Check Redis for today's usage + try: + r = _get_redis() + key = _identity_key(identity_type, identity) + current = int(r.get(key) or "0") + + if current >= daily_limit: + upsell = PREMIUM_UPSELL["free_limit_reached"] + # Add usage stats to upsell + upsell_with_stats = { + **upsell, + "scans_used": current, + "daily_limit": daily_limit, + "resets_at": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"), + } + return { + "allowed": False, + "remaining": 0, + "limit": daily_limit, + "upsell": upsell_with_stats, + } + + return { + "allowed": True, + "remaining": daily_limit - current, + "limit": daily_limit, + "upsell": None, + } + + except Exception as e: + logger.error(f"Rate limit check failed: {e}") + # On Redis failure, allow the scan (fail open, not closed) + return { + "allowed": True, + "remaining": FREE_DAILY_LIMIT, + "limit": FREE_DAILY_LIMIT, + "upsell": None, + } + + +def increment_scan_usage( + identity: str, + identity_type: str = "ip", + tier: str = "free", + is_internal: bool = False, + is_x402_paid: bool = False, +) -> dict[str, Any]: + """ + Record a scan usage. Call AFTER a successful scan. + + Returns: + {"recorded": bool, "total_today": int, "remaining": int} + """ + # Don't track internal or x402 paid calls + if is_internal or is_x402_paid: + return {"recorded": False, "total_today": 0, "remaining": -1} + + daily_limit = ELITE_DAILY_LIMIT if tier == "elite" else (PRO_DAILY_LIMIT if tier == "pro" else FREE_DAILY_LIMIT) + + try: + r = _get_redis() + key = _identity_key(identity_type, identity) + pipe = r.pipeline() + pipe.incr(key) + pipe.expire(key, 86400 * 2) # Expire after 2 days + results = pipe.execute() + total = results[0] + remaining = (daily_limit - total) if daily_limit > 0 else -1 + + return {"recorded": True, "total_today": total, "remaining": max(0, remaining)} + + except Exception as e: + logger.error(f"Usage recording failed: {e}") + return {"recorded": False, "total_today": 0, "remaining": -1} + + +def get_usage_stats(identity: str, identity_type: str = "ip") -> dict[str, Any]: + """Get today's usage stats for an identity.""" + try: + r = _get_redis() + key = _identity_key(identity_type, identity) + current = int(r.get(key) or "0") + return { + "identity": identity, + "identity_type": identity_type, + "scans_today": current, + "free_limit": FREE_DAILY_LIMIT, + "pro_limit": PRO_DAILY_LIMIT, + "elite_limit": "unlimited", + "remaining_free": max(0, FREE_DAILY_LIMIT - current), + "resets_at": (datetime.now(UTC) + timedelta(days=1)).strftime("%Y-%m-%dT00:00:00Z"), + } + except Exception as e: + logger.error(f"Usage stats failed: {e}") + return {"identity": identity, "scans_today": 0, "error": str(e)} + + +def is_internal_request(request) -> bool: + """Check if request is from internal/cron (API key header or internal IP).""" + # Check internal API key header + api_key = request.headers.get("X-RMI-Key", "") if hasattr(request, "headers") else "" + if api_key == INTERNAL_API_KEY: + return True + + # Check for x402 payment (already paid) + x402_version = request.headers.get("X-Payment-Version", "") if hasattr(request, "headers") else "" + if x402_version: + return False # x402 is not internal, but it's paid - handled separately + + # Check for authenticated user (Authorization header) + auth = request.headers.get("Authorization", "") if hasattr(request, "headers") else "" + if auth.startswith("Bearer "): + return False # Authenticated user - check their tier + + return False + + +def get_identity(request) -> tuple[str, str]: + """ + Extract identity from request. Returns (identity, identity_type). + + Authenticated users: user ID from JWT + Anonymous: IP address + """ + # Try JWT auth first + auth = request.headers.get("Authorization", "") if hasattr(request, "headers") else "" + if auth.startswith("Bearer "): + try: + from app.auth import _verify_jwt + + payload = _verify_jwt(auth[7:]) + if payload: + return payload.get("id", auth[7:][:32]), "user" + except Exception: + pass + + # Fall back to IP + client_ip = request.client.host if hasattr(request, "client") and request.client else "unknown" + forwarded = request.headers.get("X-Forwarded-For", "") if hasattr(request, "headers") else "" + if forwarded: + client_ip = forwarded.split(",")[0].strip() + + return client_ip, "ip" + + +def is_x402_paid_request(request) -> bool: + """Check if request has a valid x402 payment (handled by enforcement middleware).""" + # x402 middleware sets this header after successful payment verification + x402_paid = request.headers.get("X-Payment-Verified", "") if hasattr(request, "headers") else "" + return x402_paid.lower() == "true" diff --git a/app/_archive/legacy_2026_07/security_defense.py b/app/_archive/legacy_2026_07/security_defense.py new file mode 100644 index 0000000..630d46b --- /dev/null +++ b/app/_archive/legacy_2026_07/security_defense.py @@ -0,0 +1,746 @@ +""" +RMI Security Defense System - Advanced Threat Protection +=========================================================== +Enterprise-grade security layer for the RugMunch Intelligence Platform. + +Features: + • Bot Detection - behavioral analysis, fingerprinting, challenge-response + • Anomaly Detection - statistical anomaly detection on requests + • Rate Limiting - adaptive rate limits per user/IP/endpoint + • IP Reputation - threat intelligence integration, blocklists + • Request Fingerprinting - identify automated tools, scrapers + • Geo-blocking - country-based access control + • Honeypot Endpoints - trap bad actors + • DDoS Protection - request flood detection, circuit breaker + • Vulnerability Scanning - automated security scanning + • Compliance Logging - GDPR/SOC2 audit trails + +Integrations: + - AbuseIPDB for IP reputation + - Cloudflare Turnstile for bot challenges + - Custom ML model for behavioral analysis + - Redis for real-time state tracking + +Author: RMI Security Team +Date: 2026-05-31 +""" + +import json +import logging +import os +import secrets +import time +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from enum import StrEnum +from typing import Any, ClassVar + +logger = logging.getLogger("rmi_security_defense") + + +# ── Enums ───────────────────────────────────────────────────── + + +class ThreatLevel(StrEnum): + LOW = "low" # Suspicious but not malicious + MEDIUM = "medium" # Likely automated, rate limit + HIGH = "high" # Confirmed bot/malicious, block + CRITICAL = "critical" # Active attack, immediate ban + + +class BotType(StrEnum): + UNKNOWN = "unknown" + SCRAPER = "scraper" + SPAMMER = "spammer" + BOTNET = "botnet" + CREDENTIAL_STUFFING = "credential_stuffing" + DDOS = "ddos" + EXPLOIT_SCANNER = "exploit_scanner" + AI_AGENT = "ai_agent" # Legitimate AI agent + HUMAN = "human" # Verified human + + +class SecurityAction(StrEnum): + ALLOW = "allow" + CHALLENGE = "challenge" # CAPTCHA/Turnstile + RATE_LIMIT = "rate_limit" # Slow down + BLOCK = "block" # Temporary block + BAN = "ban" # Permanent ban + HONEYPOT = "honeypot" # Feed fake data + + +# ── Data Models ───────────────────────────────────────────── + + +@dataclass +class RequestFingerprint: + """Fingerprint of an incoming request for analysis.""" + + fingerprint_id: str + ip_address: str + user_agent: str + accept_language: str + accept_encoding: str + accept_header: str + dnt: str + connection: str + sec_ch_ua: str + sec_ch_ua_mobile: str + sec_ch_ua_platform: str + viewport_width: int = 0 + viewport_height: int = 0 + screen_width: int = 0 + screen_height: int = 0 + color_depth: int = 0 + timezone: str = "" + canvas_hash: str = "" # Canvas fingerprinting hash + webgl_hash: str = "" # WebGL fingerprinting hash + fonts: list[str] = field(default_factory=list) + plugins: list[str] = field(default_factory=list) + timestamp: str = "" + + def to_dict(self) -> dict: + return asdict(self) + + def is_suspicious(self) -> bool: + """Quick heuristic check for suspicious fingerprint.""" + # No JS fingerprinting data = likely bot + if not self.canvas_hash and not self.webgl_hash: + return True + # Common bot user agents + bot_ua_patterns = [ + "bot", + "crawler", + "spider", + "scraper", + "curl", + "wget", + "python-requests", + "httpie", + "postman", + "insomnia", + ] + ua_lower = self.user_agent.lower() + if any(p in ua_lower for p in bot_ua_patterns): + return True + # Missing standard headers + return bool(not self.accept_language or not self.accept_header) + + +@dataclass +class ThreatAssessment: + """Result of threat analysis on a request.""" + + assessment_id: str + ip_address: str + fingerprint_id: str + threat_level: str + bot_type: str + action: str + confidence: float = 0.0 + reasons: list[str] = field(default_factory=list) + timestamp: str = "" + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class SecurityEvent: + """Security event for audit logging.""" + + event_id: str + timestamp: str + event_type: str + ip_address: str + user_agent: str + path: str + method: str + threat_level: str + action_taken: str + details: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict: + return asdict(self) + + +# ── Bot Detection Engine ──────────────────────────────────── + + +class BotDetectionEngine: + """ + Multi-layer bot detection using behavioral analysis, + fingerprinting, and heuristics. + """ + + # Known good bot patterns (allow these) + GOOD_BOTS: ClassVar[list] =[ + "googlebot", + "bingbot", + "duckduckbot", + "slurp", + "baiduspider", + "yandexbot", + "facebookexternalhit", + "twitterbot", + "linkedinbot", + ] + + # Known bad patterns (immediate block) + BAD_PATTERNS: ClassVar[list] =[ + "sqlmap", + "nikto", + "nmap", + "masscan", + "zgrab", + "gobuster", + "dirbuster", + "wfuzz", + "burp", + "metasploit", + "nessus", + "openvas", + ] + + @staticmethod + async def analyze_request( + ip: str, + user_agent: str, + path: str, + method: str, + headers: dict[str, str], + body_size: int = 0, + ) -> ThreatAssessment: + """Analyze a request for bot/malicious activity.""" + reasons = [] + confidence = 0.0 + threat_level = ThreatLevel.LOW.value + bot_type = BotType.UNKNOWN.value + action = SecurityAction.ALLOW.value + + ua_lower = user_agent.lower() + + # Check for known good bots + if any(gb in ua_lower for gb in BotDetectionEngine.GOOD_BOTS): + return ThreatAssessment( + assessment_id=f"asm_{int(time.time())}_{secrets.token_hex(4)}", + ip_address=ip, + fingerprint_id="", + threat_level=ThreatLevel.LOW.value, + bot_type=BotType.HUMAN.value, # Treat as legitimate + action=SecurityAction.ALLOW.value, + confidence=0.9, + reasons=["Known good bot"], + timestamp=datetime.now(UTC).isoformat(), + ) + + # Check for known bad patterns + if any(bp in ua_lower for bp in BotDetectionEngine.BAD_PATTERNS): + reasons.append("Known attack tool signature") + confidence += 0.95 + threat_level = ThreatLevel.CRITICAL.value + bot_type = BotType.EXPLOIT_SCANNER.value + action = SecurityAction.BAN.value + + # Check for missing standard headers + if not headers.get("accept-language"): + reasons.append("Missing Accept-Language header") + confidence += 0.3 + + if not headers.get("accept"): + reasons.append("Missing Accept header") + confidence += 0.3 + + # Check for suspicious request patterns + if path.endswith((".env", ".git", ".sql", ".bak", ".zip", ".tar.gz")): + reasons.append("Suspicious file access pattern") + confidence += 0.4 + threat_level = max(threat_level, ThreatLevel.HIGH.value) + bot_type = BotType.EXPLOIT_SCANNER.value + action = SecurityAction.BLOCK.value + + # Check for common exploit paths + exploit_paths = [ + "/wp-admin", + "/wp-login", + "/administrator", + "/phpmyadmin", + "/.git/", + "/.env", + "/config.php", + "/robots.txt", + "/xmlrpc.php", + "/api/v1/users", + "/api/admin", + "/debug", + "/console", + ] + if any(path.startswith(ep) for ep in exploit_paths): + reasons.append("Access to sensitive endpoint") + confidence += 0.3 + + # Check body size anomalies + if body_size > 10 * 1024 * 1024: # 10MB + reasons.append("Unusually large request body") + confidence += 0.2 + + # Check request rate + rate_check = await BotDetectionEngine._check_request_rate(ip) + if rate_check["excessive"]: + reasons.append(f"Excessive request rate: {rate_check['rpm']} RPM") + confidence += min(rate_check["rpm"] / 1000, 0.5) + threat_level = max(threat_level, ThreatLevel.MEDIUM.value) + bot_type = BotType.DDOS.value if rate_check["rpm"] > 1000 else BotType.SCRAPER.value + action = SecurityAction.RATE_LIMIT.value + + # Check IP reputation + reputation = await BotDetectionEngine._check_ip_reputation(ip) + if reputation["score"] > 50: + reasons.append(f"IP reputation score: {reputation['score']}") + confidence += reputation["score"] / 200 + threat_level = max(threat_level, ThreatLevel.HIGH.value) + action = SecurityAction.BLOCK.value + + # Final assessment + if confidence >= 0.8: + threat_level = ThreatLevel.CRITICAL.value + action = SecurityAction.BAN.value + elif confidence >= 0.6: + threat_level = ThreatLevel.HIGH.value + action = SecurityAction.BLOCK.value + elif confidence >= 0.4: + threat_level = ThreatLevel.MEDIUM.value + action = SecurityAction.RATE_LIMIT.value + elif confidence >= 0.2: + threat_level = ThreatLevel.LOW.value + action = SecurityAction.CHALLENGE.value + + return ThreatAssessment( + assessment_id=f"asm_{int(time.time())}_{secrets.token_hex(4)}", + ip_address=ip, + fingerprint_id="", + threat_level=threat_level, + bot_type=bot_type, + action=action, + confidence=min(confidence, 1.0), + reasons=reasons, + timestamp=datetime.now(UTC).isoformat(), + ) + + @staticmethod + async def _check_request_rate(ip: str) -> dict[str, Any]: + """Check request rate for an IP.""" + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + key = f"req_rate:{ip}" + now = int(time.time()) + + # Add current request + await r.zadd(key, {str(now): now}) + # Remove requests older than 60 seconds + await r.zremrangebyscore(key, 0, now - 60) + # Set expiry + await r.expire(key, 60) + + # Count requests in last 60 seconds + count = await r.zcard(key) + + return { + "rpm": count, + "excessive": count > 120, # 120 RPM = 2 RPS + } + except Exception as e: + logger.error(f"Rate check error: {e}") + return {"rpm": 0, "excessive": False} + + @staticmethod + async def _check_ip_reputation(ip: str) -> dict[str, Any]: + """Check IP reputation using AbuseIPDB or local cache.""" + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + # Check local cache + cached = await r.get(f"ip_reputation:{ip}") + if cached: + return json.loads(cached) + + # Default: unknown reputation + result = {"score": 0, "reports": 0, "source": "default"} + + # Cache for 1 hour + await r.setex(f"ip_reputation:{ip}", 3600, json.dumps(result)) + return result + + except Exception as e: + logger.error(f"IP reputation check error: {e}") + return {"score": 0, "reports": 0} + + +# ── Anomaly Detection ─────────────────────────────────────── + + +class AnomalyDetector: + """ + Statistical anomaly detection for API requests. + Uses rolling averages and standard deviations. + """ + + @staticmethod + async def detect_anomalies( + ip: str, + user_id: str, + endpoint: str, + request_size: int, + response_time_ms: float, + ) -> list[dict[str, Any]]: + """Detect anomalies in request patterns.""" + anomalies = [] + + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + # Track response times for endpoint + rt_key = f"anomaly:rt:{endpoint}" + await r.lpush(rt_key, response_time_ms) + await r.ltrim(rt_key, 0, 999) # Keep last 1000 + + # Calculate rolling stats + times = await r.lrange(rt_key, 0, -1) + if len(times) >= 10: + times = [float(t) for t in times] + mean = sum(times) / len(times) + variance = sum((t - mean) ** 2 for t in times) / len(times) + std_dev = variance**0.5 + + # Check if current is anomalous (3 sigma) + if std_dev > 0 and abs(response_time_ms - mean) > 3 * std_dev: + anomalies.append( + { + "type": "response_time_spike", + "severity": "medium", + "details": { + "current": response_time_ms, + "mean": round(mean, 2), + "std_dev": round(std_dev, 2), + }, + } + ) + + # Track request sizes + size_key = f"anomaly:size:{endpoint}" + await r.lpush(size_key, request_size) + await r.ltrim(size_key, 0, 999) + + sizes = await r.lrange(size_key, 0, -1) + if len(sizes) >= 10: + sizes = [int(s) for s in sizes] + mean_size = sum(sizes) / len(sizes) + if request_size > mean_size * 10: # 10x average + anomalies.append( + { + "type": "request_size_spike", + "severity": "low", + "details": { + "current": request_size, + "mean": round(mean_size, 2), + }, + } + ) + + except Exception as e: + logger.error(f"Anomaly detection error: {e}") + + return anomalies + + +# ── Honeypot System ───────────────────────────────────────── + + +class HoneypotSystem: + """ + Honeypot endpoints that trap bad actors. + Returns fake data and logs attacker behavior. + """ + + HONEYPOT_ENDPOINTS: ClassVar[list] =[ + "/admin/config.php", + "/api/v1/admin/backup", + "/.env", + "/wp-config.php", + "/config/database.yml", + "/phpmyadmin", + "/api/internal/debug", + "/console", + "/actuator", + "/api/v1/keys", + ] + + @staticmethod + def is_honeypot(path: str) -> bool: + """Check if path is a honeypot endpoint.""" + return any(path.startswith(ep) or path == ep for ep in HoneypotSystem.HONEYPOT_ENDPOINTS) + + @staticmethod + async def handle_honeypot(request: Any) -> dict[str, Any]: + """Handle honeypot request - log and return fake data.""" + ip = request.client.host if request.client else "" + ua = request.headers.get("user-agent", "") + path = str(request.url.path) + + # Log the attack + event = SecurityEvent( + event_id=f"sec_{int(time.time())}_{secrets.token_hex(4)}", + timestamp=datetime.now(UTC).isoformat(), + event_type="honeypot_triggered", + ip_address=ip, + user_agent=ua, + path=path, + method=request.method, + threat_level=ThreatLevel.HIGH.value, + action_taken=SecurityAction.BAN.value, + details={"honeypot_endpoint": path}, + ) + + await BotDetectionEngine._log_security_event(event) + + # Auto-ban the IP + from app.admin_backend import SecurityManager + + await SecurityManager.block_ip(ip, f"Honeypot triggered: {path}", 168) # 7 days + + # Return fake data to keep attacker engaged + fake_responses = { + "/admin/config.php": { + "db_host": "localhost", + "db_user": "admin", + "db_pass": "fake_password_123", + }, + "/.env": { + "APP_KEY": "base64:fakekey123", + "DB_PASSWORD": "fakepass456", + "API_SECRET": "sk_fake_789", + }, + "/api/v1/keys": {"api_keys": [{"key": "ak_live_fake123", "scope": "admin"}]}, + } + + return fake_responses.get(path, {"status": "ok", "data": "sensitive_data_here"}) + + @staticmethod + async def _log_security_event(event: SecurityEvent): + """Log security event to Redis and file.""" + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + await r.lpush("security_events", json.dumps(event.to_dict())) + await r.ltrim("security_events", 0, 9999) + + # Also log to file + log_file = f"/var/log/rmi/security_{datetime.now().strftime('%Y-%m')}.jsonl" + os.makedirs(os.path.dirname(log_file), exist_ok=True) + with open(log_file, "a") as f: + f.write(json.dumps(event.to_dict()) + "\n") + + except Exception as e: + logger.error(f"Security event log error: {e}") + + +# ── Geo-blocking ──────────────────────────────────────────── + + +class GeoBlocker: + """Country-based access control.""" + + BLOCKED_COUNTRIES: set[str] = set() # ISO country codes # noqa: RUF012 + ALLOWED_COUNTRIES: set[str] = set() # If set, only allow these # noqa: RUF012 + + @staticmethod + async def check_country(ip: str) -> dict[str, Any]: + """Check if IP country is allowed.""" + # In production, use GeoIP2 or similar + # For now, return permissive + return { + "allowed": True, + "country": "unknown", + "country_code": "XX", + "blocked": False, + } + + +# ── DDoS Protection ───────────────────────────────────────── + + +class DDoSProtector: + """ + DDoS protection using circuit breaker pattern + and request flood detection. + """ + + CIRCUIT_THRESHOLD = 1000 # requests per minute + CIRCUIT_DURATION = 300 # 5 minute circuit break + + @staticmethod + async def check_circuit(endpoint: str) -> dict[str, Any]: + """Check if circuit breaker is open for endpoint.""" + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + + # Check if circuit is open + circuit_key = f"circuit:{endpoint}" + is_open = await r.get(circuit_key) + if is_open: + return {"allowed": False, "reason": "circuit_open", "retry_after": int(is_open)} + + # Check request rate for endpoint + rate_key = f"endpoint_rate:{endpoint}" + now = int(time.time()) + await r.zadd(rate_key, {str(now): now}) + await r.zremrangebyscore(rate_key, 0, now - 60) + await r.expire(rate_key, 60) + + count = await r.zcard(rate_key) + if count > DDoSProtector.CIRCUIT_THRESHOLD: + # Open circuit + await r.setex( + circuit_key, + DDoSProtector.CIRCUIT_DURATION, + str(now + DDoSProtector.CIRCUIT_DURATION), + ) + return { + "allowed": False, + "reason": "circuit_opened", + "retry_after": DDoSProtector.CIRCUIT_DURATION, + } + + return {"allowed": True, "current_rpm": count} + + except Exception as e: + logger.error(f"Circuit check error: {e}") + return {"allowed": True} # Fail open + + @staticmethod + async def close_circuit(endpoint: str): + """Manually close a circuit breaker.""" + try: + import redis.asyncio as redis_lib + + r = redis_lib.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + ) + await r.delete(f"circuit:{endpoint}") + except Exception as e: + logger.error(f"Circuit close error: {e}") + + +# ── Security Middleware Helper ────────────────────────────── + + +async def security_middleware_check(request: Any) -> ThreatAssessment | None: + """ + Run full security check on request. + Returns ThreatAssessment if action is needed, None if safe. + """ + ip = request.client.host if request.client else "" + path = str(request.url.path) + method = request.method + ua = request.headers.get("user-agent", "") + + # Skip checks for health endpoints + if path in ["/health", "/api/v1/health", "/ping"]: + return None + + # Check honeypot + if HoneypotSystem.is_honeypot(path): + await HoneypotSystem.handle_honeypot(request) + return ThreatAssessment( + assessment_id=f"asm_{int(time.time())}", + ip_address=ip, + fingerprint_id="", + threat_level=ThreatLevel.CRITICAL.value, + bot_type=BotType.EXPLOIT_SCANNER.value, + action=SecurityAction.BAN.value, + confidence=1.0, + reasons=["Honeypot triggered"], + timestamp=datetime.now(UTC).isoformat(), + ) + + # Check DDoS circuit + circuit = await DDoSProtector.check_circuit(path) + if not circuit["allowed"]: + return ThreatAssessment( + assessment_id=f"asm_{int(time.time())}", + ip_address=ip, + fingerprint_id="", + threat_level=ThreatLevel.HIGH.value, + bot_type=BotType.DDOS.value, + action=SecurityAction.BLOCK.value, + confidence=0.9, + reasons=[f"Circuit breaker: {circuit['reason']}"], + timestamp=datetime.now(UTC).isoformat(), + ) + + # Run bot detection + headers = dict(request.headers) + assessment = await BotDetectionEngine.analyze_request( + ip=ip, + user_agent=ua, + path=path, + method=method, + headers=headers, + ) + + # Log if not clean + if assessment.action != SecurityAction.ALLOW.value: + event = SecurityEvent( + event_id=f"sec_{int(time.time())}_{secrets.token_hex(4)}", + timestamp=datetime.now(UTC).isoformat(), + event_type="threat_detected", + ip_address=ip, + user_agent=ua, + path=path, + method=method, + threat_level=assessment.threat_level, + action_taken=assessment.action, + details={"confidence": assessment.confidence, "reasons": assessment.reasons}, + ) + await HoneypotSystem._log_security_event(event) + + return assessment if assessment.action != SecurityAction.ALLOW.value else None diff --git a/app/_archive/legacy_2026_07/sentiment.py b/app/_archive/legacy_2026_07/sentiment.py new file mode 100644 index 0000000..694bf6e --- /dev/null +++ b/app/_archive/legacy_2026_07/sentiment.py @@ -0,0 +1,129 @@ +"""Sentiment pipeline - X/Twitter + Reddit crypto mentions → NLP scoring.""" + +import os +import re +from datetime import UTC, datetime + +import httpx +from fastapi import APIRouter + +router = APIRouter(prefix="/api/v1/sentiment", tags=["sentiment"]) + +SEARXNG = os.getenv("SEARXNG_URL", "http://localhost:8088") + +POSITIVE_WORDS = { + "bullish", + "moon", + "pump", + "gem", + "buy", + "long", + "green", + "ATH", + "breakout", + "accumulation", + "undervalued", + "partnership", + "listed", + "launch", + "mainnet", +} +NEGATIVE_WORDS = { + "bearish", + "dump", + "rug", + "scam", + "sell", + "short", + "red", + "crash", + "hack", + "exploit", + "FUD", + "dead", + "delist", + "bankrupt", + "SEC", +} + + +def _score_text(text: str) -> dict: + words = set(re.findall(r"\b\w+\b", text.lower())) + pos = len(words & POSITIVE_WORDS) + neg = len(words & NEGATIVE_WORDS) + total = pos + neg + if total == 0: + return {"sentiment": "neutral", "score": 0.5, "positive": 0, "negative": 0, "total_mentions": 0} + score = pos / total + sentiment = "bullish" if score > 0.6 else "bearish" if score < 0.4 else "neutral" + return {"sentiment": sentiment, "score": round(score, 2), "positive": pos, "negative": neg, "total_mentions": total} + + +async def _search_mentions(symbol: str, source: str = "twitter") -> list[str]: + """Search for crypto mentions via SearXNG.""" + texts = [] + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + f"{SEARXNG}/search", + params={"q": f"${symbol} crypto {source}", "format": "json", "categories": "social media"}, + ) + if r.status_code == 200: + results = r.json().get("results", []) + texts = [item.get("content", "") or item.get("title", "") for item in results[:20]] + except Exception: + pass + return texts + + +@router.get("/token/{symbol}") +async def token_sentiment(symbol: str): + """Get sentiment for a token across social media.""" + texts = await _search_mentions(symbol) + if not texts: + return {"symbol": symbol, "sentiment": "unknown", "note": "No mentions found"} + + all_text = " ".join(texts) + sentiment = _score_text(all_text) + sentiment["symbol"] = symbol + sentiment["timestamp"] = datetime.now(UTC).isoformat() + sentiment["sources_scanned"] = len(texts) + + # Emoji representation + emoji = "🟢" if sentiment["sentiment"] == "bullish" else "🔴" if sentiment["sentiment"] == "bearish" else "⚪" + sentiment["emoji"] = emoji + + return sentiment + + +@router.get("/market") +async def market_sentiment(): + """Overall crypto market sentiment.""" + tickers = ["BTC", "ETH", "SOL"] + results = {} + for ticker in tickers: + texts = await _search_mentions(ticker) + results[ticker] = _score_text(" ".join(texts)) if texts else {"sentiment": "unknown"} + + scores = [r["score"] for r in results.values() if r.get("score")] + avg_score = sum(scores) / len(scores) if scores else 0.5 + overall = "bullish" if avg_score > 0.55 else "bearish" if avg_score < 0.45 else "neutral" + + return { + "overall": overall, + "average_score": round(avg_score, 2), + "breakdown": results, + "emoji": "🟢" if overall == "bullish" else "🔴" if overall == "bearish" else "⚪", + } + + +@router.get("/trending-signals/{symbol}") +async def sentiment_signal(symbol: str): + """Quick sentiment signal for trading: BULLISH / BEARISH / NEUTRAL.""" + result = await token_sentiment(symbol) + return { + "symbol": symbol, + "signal": result["sentiment"].upper(), + "emoji": result.get("emoji", "⚪"), + "score": result.get("score", 0.5), + } diff --git a/app/_archive/legacy_2026_07/smart_calls.py b/app/_archive/legacy_2026_07/smart_calls.py new file mode 100644 index 0000000..232a30f --- /dev/null +++ b/app/_archive/legacy_2026_07/smart_calls.py @@ -0,0 +1,530 @@ +""" +Human-Facing Tool Catalog API - SmartCalls Marketplace +====================================================== +Source of truth: /mcp/manifest + tool_registry + membership_plans + agent_skills. +The MCP server is the bot-side. This is the human-side mirror. + +Adding a tool on the bot side? It shows up here automatically. +Updating pricing on /mcp/membership? Marketplace reads it. +Adding a skill? Marketplace exposes it. +Every endpoint reads from the same registries. +""" + +import logging +import time +from collections import defaultdict +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request + +logger = logging.getLogger("smart_calls") +router = APIRouter(prefix="/api/v1/smart-calls", tags=["smart-calls"]) + + +# ════════════════════════════════════════════════════════════ +# TRIAL TIERS - same logic as bot side +# ════════════════════════════════════════════════════════════ + +TRIAL_TIERS = { + "simple": {"trials": 5, "label": "5 free trials", "color": "emerald", "badge": "🎁"}, + "standard": {"trials": 3, "label": "3 free trials", "color": "blue", "badge": "✨"}, + "premium": {"trials": 1, "label": "1 free trial", "color": "purple", "badge": "💎"}, +} + +PRICE_TIERS = { + "micro": {"max": 0.01, "label": "Micro", "color": "emerald", "icon": "·"}, + "low": {"max": 0.05, "label": "Low", "color": "blue", "icon": "•"}, + "mid": {"max": 0.15, "label": "Mid", "color": "amber", "icon": "●"}, + "high": {"max": 0.50, "label": "High", "color": "rose", "icon": "○"}, + "premium": {"max": 999.0, "label": "Premium", "color": "violet", "icon": "◆"}, +} + +CATEGORY_TRIAL_DEFAULTS = { + "sentiment": "simple", + "social": "simple", + "api": "simple", + "research": "simple", + "analysis": "standard", + "intelligence": "standard", + "market": "standard", + "monitoring": "standard", + "launchpad": "standard", + "defi": "standard", + "nft": "standard", + "bundle": "standard", + "security": "premium", + "forensics": "premium", + "premium": "premium", + "mcp-external": "simple", +} + + +def _classify_trial_tier(trial_free: int | None, category: str) -> str: + if trial_free is not None: + n = int(trial_free) + if n >= 5: + return "simple" + if n >= 3: + return "standard" + return "premium" + return CATEGORY_TRIAL_DEFAULTS.get(category.lower(), "standard") + + +def _classify_price_tier(price_usd: float) -> str: + for tier_name, info in PRICE_TIERS.items(): + if price_usd <= info["max"]: + return tier_name + return "premium" + + +# ════════════════════════════════════════════════════════════ +# CACHE - short TTL so updates flow through fast +# ════════════════════════════════════════════════════════════ + +_cache: dict[str, Any] = {} +_cache_time: float = 0 +CACHE_TTL = 15 + + +def _build_marketplace_data() -> dict[str, Any]: + """Pull from the same sources the MCP server uses.""" + global _cache, _cache_time + now = time.time() + if _cache and (now - _cache_time) < CACHE_TTL: + return _cache + + # === SOURCE 1: TOOL_PRICES (bot-side MCP server mirror) === + try: + from app.routers.x402_enforcement import CHAIN_USDC, TOOL_PRICES + + dict(TOOL_PRICES) + except ImportError: + CHAIN_USDC = {} + + # === SOURCE 2: x402_catalog (already merges external MCPs) === + try: + from app.routers.x402_catalog import get_catalog + + x402_cat = get_catalog() + x402_tools_list = list(x402_cat.get("tools", [])) + except Exception: + x402_tools_list = [] + + # === SOURCE 3: Expanded MCP services (free open-source) === + try: + from app.services.expanded_mcp_catalog import ( + EXTERNAL_MCP_SERVICES, + EXTERNAL_MCP_TOOLS, + ) + + external_services = list(EXTERNAL_MCP_SERVICES) + # Build ID set for detection - these IDs are the free open-source MCP tools + external_tool_ids = {t["id"] for t in EXTERNAL_MCP_TOOLS} + except ImportError: + external_services = [] + external_tool_ids = set() + + # === SOURCE 4: Membership plans (scan packs, subscriptions, streams) === + try: + from app.caching_shield.membership_plans import ( + AGENT_BUNDLES, + BATCH_PRODUCTS, + RESEARCH_PRODUCTS, + STREAM_PRODUCTS, + SUBSCRIPTION_TIERS, + get_membership_catalog, + ) + + get_membership_catalog() + scan_packs = list(AGENT_BUNDLES.values()) + subscriptions = list(SUBSCRIPTION_TIERS.values()) + streams = list(STREAM_PRODUCTS.values()) + research = list(RESEARCH_PRODUCTS.values()) + batch = list(BATCH_PRODUCTS.values()) + except ImportError: + scan_packs = [] + subscriptions = [] + streams = [] + research = [] + batch = [] + + # === SOURCE 5: Agent skills (workflow guides) === + try: + from app.caching_shield.agent_skills_extended import get_all_agent_skills + + skills_data = get_all_agent_skills() + except ImportError: + skills_data = {"skills": []} + + # === SOURCE 6: Tool registry (counts) === + try: + from app.caching_shield.tool_registry import count_all_tools + + tool_counts = count_all_tools() + except ImportError: + tool_counts = {"total": 0} + + # === BUILD HUMAN-FACING TOOL LIST === + # Use the x402_catalog as primary (it has enriched fields), fall back to TOOL_PRICES + enriched_tools = [] + for t in x402_tools_list: + price_usd = float(t.get("priceUsd", t.get("price_usd", 0.01)) or 0.01) + trial_free = t.get("trialFree", t.get("trial_free")) + cat = t.get("category", "analysis") + chains = [c.upper() for c in t.get("chains", [])] + source = t.get("source", "enforcement") + service = t.get("service", "rmi-native") + tool_id = t.get("id", "") + # External = either marked external in x402 OR in our expanded_mcp_catalog ID set + is_external = ( + source in ("expanded-mcp", "mcp-router") + or service + in ( + "fear-greed-mcp", + "crypto-indicators-mcp", + "openzeppelin-wizard", + "web3-research-mcp", + ) + or tool_id in external_tool_ids + ) + + enriched_tools.append( + { + "id": t.get("id", ""), + "name": t.get("name", t.get("id", "?")), + "description": t.get("description", ""), + "category": cat, + "service": service, + "source": source, + "is_external": is_external, + "chains": chains, + "price_usd": price_usd, + "price_label": f"${price_usd:.2f}", + "trial_tier": _classify_trial_tier(trial_free, cat), + "trial_count": TRIAL_TIERS[_classify_trial_tier(trial_free, cat)]["trials"], + "trial_label": TRIAL_TIERS[_classify_trial_tier(trial_free, cat)]["label"], + "trial_badge": TRIAL_TIERS[_classify_trial_tier(trial_free, cat)]["badge"], + "price_tier": _classify_price_tier(price_usd), + "method": t.get("method", "POST"), + } + ) + + # === GROUP FOR DISPLAY === + by_category = defaultdict(list) + by_chain = defaultdict(list) + by_service = defaultdict(list) + by_trial = defaultdict(list) + by_price = defaultdict(list) + featured = [] + + for t in enriched_tools: + by_category[t["category"]].append(t) + by_service[t["service"]].append(t) + by_trial[t["trial_tier"]].append(t) + by_price[t["price_tier"]].append(t) + for c in t["chains"]: + by_chain[c].append(t) + if t["category"] in ( + "security", + "intelligence", + "market", + "analysis", + "social", + "defi", + "premium", + ): + if len([f for f in featured if f["category"] == t["category"]]) < 6: + featured.append(t) + + # Sort + enriched_tools.sort(key=lambda t: (0 if not t["is_external"] else 1, t["category"], t["price_usd"])) + for k in by_category: + by_category[k].sort(key=lambda t: t["price_usd"]) + for k in by_service: + by_service[k].sort(key=lambda t: t["price_usd"]) + for k in by_trial: + by_trial[k].sort(key=lambda t: t["price_usd"]) + for k in by_price: + by_price[k].sort(key=lambda t: t["price_usd"]) + for k in by_chain: + by_chain[k].sort(key=lambda t: t["price_usd"]) + featured.sort(key=lambda t: t["price_usd"]) + + result = { + "version": "1.0.0", + "generated_at": int(now), + # === COUNTS === + "stats": { + "total_tools": len(enriched_tools), + "native_tools": sum(1 for t in enriched_tools if not t["is_external"]), + "external_tools": sum(1 for t in enriched_tools if t["is_external"]), + "total_services": len(by_service), + "total_categories": len(by_category), + "total_chains": len(by_chain), + "total_scan_packs": len(scan_packs), + "total_subscriptions": len(subscriptions), + "total_streams": len(streams), + "total_research": len(research), + "total_batch": len(batch), + "total_skills": len(skills_data.get("skills", [])), + "total_registry": tool_counts.get("total", 0), + }, + # === TOOLS === + "tools": enriched_tools, + "by_category": dict(by_category), + "by_chain": dict(by_chain), + "by_service": dict(by_service), + "by_trial_tier": dict(by_trial), + "by_price_tier": dict(by_price), + "featured": featured[:30], + # === PRODUCTS === + "scan_packs": scan_packs, + "subscriptions": subscriptions, + "streams": streams, + "research": research, + "batch": batch, + # === META === + "skills": skills_data, + "external_services": external_services, + "trial_tiers": TRIAL_TIERS, + "price_tiers": PRICE_TIERS, + "chains": {k: v for k, v in CHAIN_USDC.items() if k not in ("sepa",)}, + "refund_policy": "Full automatic refund within 48h if tool returns no data", + } + + _cache = result + _cache_time = now + return result + + +def invalidate(): + """Force rebuild on next request - call after adding/updating tools.""" + global _cache_time + _cache_time = 0 + + +# ════════════════════════════════════════════════════════════ +# ENDPOINTS - SmartCalls Marketplace +# ════════════════════════════════════════════════════════════ + + +@router.get("/marketplace") +async def marketplace( + sort: str = Query("default"), + category: str | None = None, + service: str | None = None, + chain: str | None = None, + free_only: bool = False, + search: str | None = None, + limit: int = Query(500, ge=0, le=1000), +): + """Full marketplace - all tools, scan packs, subscriptions, streams, research, skills.""" + data = _build_marketplace_data() + tools = list(data["tools"]) + + if category: + tools = [t for t in tools if t["category"].lower() == category.lower()] + if service: + tools = [t for t in tools if t["service"].lower() == service.lower()] + if chain: + chain_upper = chain.upper() + tools = [t for t in tools if chain_upper in t["chains"]] + if free_only: + tools = [t for t in tools if t["trial_count"] > 0] + if search: + q = search.lower() + tools = [t for t in tools if q in t["id"].lower() or q in t["name"].lower() or q in t["description"].lower()] + + if sort == "price": + tools.sort(key=lambda t: t["price_usd"]) + elif sort == "trial": + tools.sort(key=lambda t: -t["trial_count"]) + elif sort == "name": + tools.sort(key=lambda t: t["name"].lower()) + elif sort == "category": + tools.sort(key=lambda t: (t["category"], t["name"].lower())) + + if limit > 0: + tools = tools[:limit] + + return { + "stats": data["stats"], + "refund_policy": data["refund_policy"], + "trial_tiers": data["trial_tiers"], + "price_tiers": data["price_tiers"], + "chains": data["chains"], + "tools": tools, + "featured": data["featured"], + "scan_packs": data["scan_packs"], + "subscriptions": data["subscriptions"], + "streams": data["streams"], + "research": data["research"], + "batch": data["batch"], + "external_services": data["external_services"], + "categories": sorted(data["by_category"].keys()), + "services": sorted(data["by_service"].keys()), + } + + +@router.get("/marketplace/stats") +async def marketplace_stats(): + """Counts only - counts always match the bot side.""" + data = _build_marketplace_data() + return { + "version": data["version"], + "generated_at": data["generated_at"], + **data["stats"], + } + + +@router.get("/marketplace/featured") +async def featured_tools(): + """Top 30 featured tools across major categories.""" + data = _build_marketplace_data() + return {"tools": data["featured"]} + + +@router.get("/marketplace/categories") +async def categories(): + """Tools grouped by category.""" + data = _build_marketplace_data() + return { + "categories": [ + {"name": cat, "count": len(tools), "tools": tools[:10]} + for cat, tools in sorted(data["by_category"].items(), key=lambda x: -len(x[1])) + ] + } + + +@router.get("/marketplace/chains") +async def chains(): + """Tools grouped by chain.""" + data = _build_marketplace_data() + return { + "chains": [ + {"name": c, "count": len(tools), "tools": tools[:10]} + for c, tools in sorted(data["by_chain"].items(), key=lambda x: -len(x[1])) + ] + } + + +@router.get("/marketplace/trial") +async def trial_tiers(): + """Tools grouped by free trial tier.""" + data = _build_marketplace_data() + return { + "tiers": data["trial_tiers"], + "groups": dict(data["by_trial_tier"].items()), + } + + +@router.get("/marketplace/scan-packs") +async def scan_packs(): + """Bundled tool packs at discount.""" + data = _build_marketplace_data() + return {"count": len(data["scan_packs"]), "packs": data["scan_packs"]} + + +@router.get("/marketplace/memberships") +async def memberships(): + """Subscription tiers.""" + data = _build_marketplace_data() + return {"count": len(data["subscriptions"]), "tiers": data["subscriptions"]} + + +@router.get("/marketplace/streams") +async def streams(): + """Real-time data streams.""" + data = _build_marketplace_data() + return {"count": len(data["streams"]), "streams": data["streams"]} + + +@router.get("/marketplace/research") +async def research(): + """Research products.""" + data = _build_marketplace_data() + return {"count": len(data["research"]), "products": data["research"]} + + +@router.get("/marketplace/batch") +async def batch(): + """Bulk scanning products.""" + data = _build_marketplace_data() + return {"count": len(data["batch"]), "products": data["batch"]} + + +@router.get("/marketplace/skills") +async def agent_skills(): + """Agent workflow guides (also visible to humans).""" + data = _build_marketplace_data() + return data["skills"] + + +@router.get("/marketplace/search") +async def search(q: str = Query(..., min_length=2), limit: int = 50): + """Free-text search.""" + data = _build_marketplace_data() + q_lower = q.lower() + results = [] + for t in data["tools"]: + score = 0 + if q_lower in t["id"].lower(): + score += 100 + if q_lower in t["name"].lower(): + score += 50 + if q_lower in t["description"].lower(): + score += 20 + if q_lower in t["category"].lower(): + score += 10 + if q_lower in t["service"].lower(): + score += 5 + if any(q_lower in c.lower() for c in t["chains"]): + score += 5 + if score > 0: + results.append({**t, "_score": score}) + results.sort(key=lambda t: -t["_score"]) + for r in results: + del r["_score"] + return {"query": q, "total": len(results), "tools": results[:limit]} + + +@router.get("/marketplace/{tool_id}") +async def get_tool(tool_id: str): + """Single tool with full metadata.""" + data = _build_marketplace_data() + for t in data["tools"]: + if t["id"] == tool_id: + return t + raise HTTPException(404, f"Tool '{tool_id}' not found") + + +@router.post("/invalidate") +async def invalidate_cache(): + """Force rebuild - call after adding tools or updating pricing.""" + invalidate() + return {"status": "ok"} + + +# ════════════════════════════════════════════════════════════ +# TOOL EXECUTION (mirror of /api/v1/x402-tools but with friendlier trial semantics) +# ════════════════════════════════════════════════════════════ + + +@router.post("/{tool_id}") +async def call_tool(tool_id: str, request: Request): + """ + Execute a tool. Same as /api/v1/x402-tools/{tool_id} but with: + - Friendlier trial messages + - Built-in device fingerprint header handling + - Returns trial balance after execution + + Identical payment logic - both paths go through the same enforcement. + """ + # Delegate to the existing x402 endpoint + # Forward the request internally + # (The x402 middleware handles the same path; this route is for friendlier responses) + return { + "status": "ok", + "message": f"Use /api/v1/x402-tools/{tool_id} for execution. This endpoint mirrors the bot-side path.", + "tool": tool_id, + } diff --git a/app/_archive/legacy_2026_07/social_seed.py b/app/_archive/legacy_2026_07/social_seed.py new file mode 100644 index 0000000..5d27bbd --- /dev/null +++ b/app/_archive/legacy_2026_07/social_seed.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""#13 - Social-Seed Detection Engine. Watches X/Twitter for new token tickers, +cross-references against DataBus, runs SENTINEL scan, posts risk assessment.""" + +import os +import re +from datetime import UTC, datetime + +import httpx +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/social-seed", tags=["social-seed-detector"]) + +BACKEND = os.environ.get("BACKEND_URL", "http://localhost:8000") +DEXSCREENER = "https://api.dexscreener.com/latest/dex/search" + +# In-memory store for detected social seeds (Redis in prod) +_detected_seeds: list[dict] = [] + +# Token ticker pattern: $SYMBOL or #SYMBOL +TICKER_RE = re.compile(r"\$([A-Z]{2,8})|#([A-Za-z]{3,12})", re.IGNORECASE) + +# Known scam keywords +SCAM_KEYWORDS = [ + "airdrop", + "claim now", + "presale", + "whitelist", + "giveaway", + "free mint", + "stealth launch", + "100x", + "1000x", + "moon", + "wen lambo", + "gem", + "alpha leak", +] + + +@router.get("/detect") +async def detect_social_seeds(q: str = Query("crypto new token launch")): + """Search for social mentions of new token launches. Uses web search as proxy for X.""" + seeds: list[dict] = [] + + # Use web search to find recent mentions (proxy for X/Twitter API) + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + "https://html.duckduckgo.com/html/", + params={"q": f"{q} site:twitter.com after:{datetime.now(UTC).strftime('%Y-%m-%d')}"}, + headers={"User-Agent": "RMI-SocialSeed/1.0"}, + ) + if resp.status_code == 200: + html = resp.text + # Extract potential tickers + found_tickers = set() + for match in TICKER_RE.finditer(html): + ticker = (match.group(1) or match.group(2)).upper() + if len(ticker) >= 3 and ticker not in ("THE", "AND", "FOR", "NEW", "ETH", "BTC", "SOL"): + found_tickers.add(ticker) + + for ticker in list(found_tickers)[:20]: + # Check if this ticker exists on DexScreener + try: + r2 = await client.get(f"{DEXSCREENER}?q={ticker}", timeout=8) + if r2.status_code == 200: + pairs = r2.json().get("pairs", []) + if pairs: + top = pairs[0] + seeds.append( + { + "ticker": ticker, + "chain": top.get("chainId", "?"), + "dex": top.get("dexId", "?"), + "price_usd": float(top.get("priceUsd", 0) or 0), + "liquidity_usd": top.get("liquidity", {}).get("usd", 0) or 0, + "age_hours": int( + ( + (datetime.now(UTC).timestamp() * 1000 - top.get("pairCreatedAt", 0)) + / 3600000 + ) + or 0 + ), + } + ) + except Exception: + continue + except Exception: + pass + + # Score risk for each seed + for s in seeds: + s["risk_score"] = 50 + s["risk_flags"] = [] + if s["liquidity_usd"] < 10000: + s["risk_score"] -= 20 + s["risk_flags"].append("low_liquidity") + if s["age_hours"] < 1: + s["risk_score"] -= 10 + s["risk_flags"].append("brand_new") + s["risk_level"] = "high" if s["risk_score"] < 40 else "medium" if s["risk_score"] < 70 else "low" + + seeds.sort(key=lambda s: s["age_hours"]) + _detected_seeds.extend(seeds[:30]) + return {"query": q, "seeds_detected": len(seeds), "seeds": seeds} + + +@router.get("/recent") +async def get_recent_seeds(limit: int = Query(20, le=50)): + """Get recently detected social seeds.""" + return {"seeds": _detected_seeds[-limit:], "total_detected": len(_detected_seeds)} + + +@router.post("/scan-seed") +async def scan_detected_seed(ticker: str = Query(...), chain: str = Query("solana")): + """Run full SENTINEL scan on a socially detected token.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + # First find the pair on DexScreener + r1 = await client.get(f"{DEXSCREENER}?q={ticker}", timeout=8) + if r1.status_code != 200 or not r1.json().get("pairs"): + return {"error": f"No pair found for {ticker}"} + + pair = r1.json()["pairs"][0] + addr = pair.get("pairAddress", "") + + # Run SENTINEL scan + r2 = await client.post( + f"{BACKEND}/api/v1/token/scan", + json={"token_address": addr, "chain": chain}, + headers={"X-RMI-Key": "rmi-internal-2026"}, + ) + if r2.status_code != 200: + return {"error": "Scan failed"} + + scan = r2.json() + return { + "ticker": ticker, + "address": addr, + "chain": chain, + "safety_score": scan.get("safety_score", 50), + "risk_flags": scan.get("risk_flags", []), + "social_risk": "HIGH" if any(kw in str(scan).lower() for kw in SCAM_KEYWORDS) else "MEDIUM", + } + except Exception as e: + return {"error": str(e)} diff --git a/app/_archive/legacy_2026_07/sosana_crm.py b/app/_archive/legacy_2026_07/sosana_crm.py new file mode 100644 index 0000000..9ee71b0 --- /dev/null +++ b/app/_archive/legacy_2026_07/sosana_crm.py @@ -0,0 +1,105 @@ +""" +SOSANA CRM Investigation - Detailed case data API (BACKEND ONLY, not public). +Exposes investigation data: wallets, entities, financials, evidence, risk assessment. +""" + +import json +import logging +import os + +logger = logging.getLogger(__name__) + +CRM_PATH = "/app/data/SOSANA-CRM-2024.json" + + +class SOSANACRM: + """Loads and serves the SOSANA criminal investigation data.""" + + def __init__(self): + self._data = None + self._loaded = False + + def load(self) -> bool: + if os.path.exists(CRM_PATH): + try: + with open(CRM_PATH) as f: + self._data = json.load(f) + self._loaded = True + return True + except Exception as e: + logger.error(f"Failed to load SOSANA CRM: {e}") + return False + + @property + def data(self) -> dict: + if not self._loaded: + self.load() + return self._data or {} + + def summary(self) -> dict: + """Case overview - safe to expose.""" + d = self.data + return { + "case_id": d.get("case_id"), + "title": d.get("title"), + "status": d.get("status"), + "created_at": d.get("created_at"), + "summary": d.get("summary", {}), + "financial_analysis": d.get("financial_analysis", {}), + "risk_assessment": d.get("risk_assessment", {}), + } + + def entities(self, entity_type: str | None = None) -> dict: + """Get entities: wallets, persons, organizations, tokens.""" + entities = self.data.get("entities", {}) + if entity_type: + return {entity_type: entities.get(entity_type, [])} + return entities + + def wallets_detail(self) -> list[dict]: + """Detailed wallet analysis from the investigation.""" + wallets = self.data.get("entities", {}).get("wallets", []) + # Enrich with on-chain labels + result = [] + for w in wallets[:50]: # Limit to 50 for API response + result.append( + { + "address": w.get("address", ""), + "chain": w.get("chain", "unknown"), + "label": w.get("label", "unlabeled"), + "role": w.get("role", "unknown"), + "balance_usd": w.get("balance_usd", w.get("estimated_value", 0)), + "first_seen": w.get("first_seen", ""), + "last_active": w.get("last_active", ""), + "transaction_count": w.get("transaction_count", w.get("tx_count", 0)), + "associated_entities": w.get("associated_with", w.get("related", [])), + "evidence_tier": w.get("evidence_tier", "unknown"), + "risk_flags": w.get("risk_flags", w.get("flags", [])), + } + ) + return result + + def financial_detail(self) -> dict: + """Full financial analysis.""" + return self.data.get("financial_analysis", {}) + + def evidence_summary(self) -> dict: + """Evidence overview with counts.""" + evidence = self.data.get("evidence", {}) + return {tier: len(items) if isinstance(items, list) else items for tier, items in evidence.items()} + + def persons(self) -> list[dict]: + """Persons of interest.""" + return self.data.get("entities", {}).get("persons", [])[:20] + + def organizations(self) -> list[dict]: + """Organizations involved.""" + return self.data.get("entities", {}).get("organizations", [])[:20] + + def legal_framework(self) -> dict: + """Legal and prosecution framework.""" + return self.data.get("legal_framework", {}) + + +# Singleton +sosana = SOSANACRM() diff --git a/app/_archive/legacy_2026_07/subscription_pricing_api.py b/app/_archive/legacy_2026_07/subscription_pricing_api.py new file mode 100644 index 0000000..e1a3cad --- /dev/null +++ b/app/_archive/legacy_2026_07/subscription_pricing_api.py @@ -0,0 +1,777 @@ +""" +RMI Subscription & Pricing API +============================== +Manages subscription tiers, payments, and billing: + - 3 premium tiers: Starter ($19.99), Pro ($38), Unlimited ($89) + - Enterprise custom pricing (contact biz@rugmunch.io) + - Yearly discount: 20% off + - 6-month discount: 10% off + - 15+ crypto payment chains + - Webhook handling for payment confirmation + - Subscription status, upgrades, downgrades, cancellations +""" + +import json +import logging +import os +import secrets +from datetime import datetime, timedelta +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +logger = logging.getLogger("rmi_subscriptions") +router = APIRouter(tags=["subscriptions"]) + + +# ── Redis helper ── +ADDON_CONFIG = { + "PORTFOLIO_PRO": { + "name": "Portfolio Pro", + "price_monthly": 3.00, + "price_six_month": 16.20, # 3.00 * 6 * 0.9 + "price_yearly": 28.80, # 3.00 * 12 * 0.8 + "kind": "addon", # addon tiers can stack with base tiers + "category": "portfolio", + "wallets_limit": 50, # vs 3 on free + "history_window_days": 30, # vs 7 on free + "features": [ + "Track up to 50 wallets", + "30-day P&L history (vs 7-day free)", + "Per-token risk overlay", + "Cross-wallet top holdings", + "Time-series price charts", + "CSV export", + "Works WITH any base tier", + ], + "stripe_price_id": os.getenv("STRIPE_PORTFOLIO_PRO_PRICE_ID"), + "x402_price_atoms": "3000000", # $3.00 USDC (6 decimals) + "x402_chain_id": 8453, # Base + }, +} + + +# Tier definitions +TIER_CONFIG = { + "FREE": { + "name": "Free", + "price_monthly": 0, + "price_six_month": 0, + "price_yearly": 0, + "scans_per_month": 5, + "api_calls_per_day": 10, + "features": [ + "5 scans per month", + "10 API calls per day", + "Basic token analysis", + "Community support", + ], + "stripe_price_id": None, + }, + "STARTER": { + "name": "Starter", + "price_monthly": 19.99, + "price_six_month": 107.95, # 19.99 * 6 * 0.9 (10% off) + "price_yearly": 191.90, # 19.99 * 12 * 0.8 (20% off) + "scans_per_month": 50, + "api_calls_per_day": 100, + "features": [ + "50 scans per month", + "100 API calls per day", + "Advanced token analysis", + "Risk scoring", + "Discord support", + "Export to CSV", + ], + "stripe_price_id": os.getenv("STRIPE_STARTER_PRICE_ID"), + }, + "PRO": { + "name": "Pro", + "price_monthly": 38.00, + "price_six_month": 205.20, # 38 * 6 * 0.9 + "price_yearly": 364.80, # 38 * 12 * 0.8 + "scans_per_month": 200, + "api_calls_per_day": 500, + "features": [ + "200 scans per month", + "500 API calls per day", + "Advanced token analysis", + "Risk scoring + AI insights", + "Priority Discord support", + "Export to CSV/JSON", + "Webhook alerts", + "Multi-chain monitoring", + ], + "stripe_price_id": os.getenv("STRIPE_PRO_PRICE_ID"), + }, + "ELITE": { + "name": "Unlimited", + "price_monthly": 89.00, + "price_six_month": 480.60, # 89 * 6 * 0.9 + "price_yearly": 854.40, # 89 * 12 * 0.8 + "scans_per_month": -1, # unlimited + "api_calls_per_day": -1, # unlimited + "features": [ + "Unlimited scans", + "Unlimited API calls", + "Full AI analysis suite", + "Custom risk models", + "Dedicated support channel", + "All export formats", + "Real-time webhook alerts", + "Multi-chain + cross-chain", + "White-label options", + "API key management", + ], + "stripe_price_id": os.getenv("STRIPE_ELITE_PRICE_ID"), + }, + "ENTERPRISE": { + "name": "Enterprise", + "price_monthly": None, # Custom + "price_six_month": None, + "price_yearly": None, + "scans_per_month": -1, + "api_calls_per_day": -1, + "features": [ + "Everything in Unlimited", + "Custom SLA", + "Dedicated infrastructure", + "Custom integrations", + "On-premise deployment option", + "Team management (up to 50 seats)", + "SSO / SAML", + "Audit logs", + "Dedicated account manager", + "Custom AI model training", + ], + "stripe_price_id": None, + "contact_email": "biz@rugmunch.io", + }, +} + +# Crypto payment configuration +CRYPTO_PAYMENT_CONFIG = { + "accepted_chains": [ + {"id": "ethereum", "name": "Ethereum", "symbol": "ETH", "type": "evm", "confirmations": 12}, + {"id": "base", "name": "Base", "symbol": "ETH", "type": "evm", "confirmations": 10}, + {"id": "arbitrum", "name": "Arbitrum", "symbol": "ETH", "type": "evm", "confirmations": 10}, + {"id": "optimism", "name": "Optimism", "symbol": "ETH", "type": "evm", "confirmations": 10}, + {"id": "polygon", "name": "Polygon", "symbol": "MATIC", "type": "evm", "confirmations": 20}, + {"id": "bsc", "name": "BSC", "symbol": "BNB", "type": "evm", "confirmations": 15}, + { + "id": "avalanche", + "name": "Avalanche", + "symbol": "AVAX", + "type": "evm", + "confirmations": 12, + }, + {"id": "fantom", "name": "Fantom", "symbol": "FTM", "type": "evm", "confirmations": 10}, + {"id": "solana", "name": "Solana", "symbol": "SOL", "type": "solana", "confirmations": 32}, + {"id": "tron", "name": "Tron", "symbol": "TRX", "type": "tron", "confirmations": 19}, + {"id": "bitcoin", "name": "Bitcoin", "symbol": "BTC", "type": "btc", "confirmations": 3}, + {"id": "monero", "name": "Monero", "symbol": "XMR", "type": "xmr", "confirmations": 10}, + {"id": "litecoin", "name": "Litecoin", "symbol": "LTC", "type": "ltc", "confirmations": 6}, + { + "id": "dogecoin", + "name": "Dogecoin", + "symbol": "DOGE", + "type": "doge", + "confirmations": 10, + }, + {"id": "ripple", "name": "XRP", "symbol": "XRP", "type": "xrp", "confirmations": 1}, + ], + "payment_wallet": os.getenv("CRYPTO_PAYMENT_WALLET", "0x0000000000000000000000000000000000000000"), + "sol_payment_wallet": os.getenv("SOL_PAYMENT_WALLET", ""), + "btc_payment_address": os.getenv("BTC_PAYMENT_ADDRESS", ""), + "xmr_payment_address": os.getenv("XMR_PAYMENT_ADDRESS", ""), + "contact_email": "biz@rugmunch.io", +} + + +# Combined config returned to the client (base tiers + add-ons) +ALL_PRODUCTS = {**TIER_CONFIG, **ADDON_CONFIG} + + +# ── Models ── + + +class SubscriptionCreateRequest(BaseModel): + tier: str = Field(..., pattern="^(STARTER|PRO|ELITE|PORTFOLIO_PRO)$") + period: str = Field(..., pattern="^(monthly|six_month|yearly)$") + payment_method: str = Field(..., pattern="^(stripe|crypto|x402)$") + crypto_chain: str | None = None # Required if payment_method == crypto/x402 + + +class AddonCreateRequest(BaseModel): + """For stacking add-ons on top of an existing subscription.""" + + addon: str = Field(..., pattern="^(PORTFOLIO_PRO)$") + period: str = Field(..., pattern="^(monthly|six_month|yearly)$") + payment_method: str = Field(..., pattern="^(stripe|crypto|x402)$") + crypto_chain: str | None = None + + +class CryptoPaymentRequest(BaseModel): + tier: str + period: str + chain: str + tx_hash: str + amount_paid_usd: float + from_address: str + + +class SubscriptionResponse(BaseModel): + id: str + user_id: str + tier: str + period: str + status: str + current_period_start: str + current_period_end: str + cancel_at_period_end: bool + payment_method: str + amount_paid: float + currency: str + created_at: str + + +class PricingResponse(BaseModel): + tiers: dict[str, Any] + discounts: dict[str, float] + crypto_chains: list[dict] + + +# ── Helpers ── + + +def calculate_price(tier: str, period: str) -> float: + """Calculate price for tier + period.""" + config = TIER_CONFIG.get(tier.upper()) + if not config: + return 0 + + if period == "monthly": + return config["price_monthly"] + elif period == "six_month": + return config["price_six_month"] + elif period == "yearly": + return config["price_yearly"] + return config["price_monthly"] + + +_PERIOD_DAYS = { + "monthly": 30, + "six_month": 180, + "yearly": 365, +} + + +def get_period_days(period: str) -> int: + """Get number of days for a billing period.""" + return _PERIOD_DAYS.get(period, 30) + + +def _get_user_subscriptions(user_id: str) -> list[dict]: + """Get all subscriptions for a user.""" + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + subs_raw = r.hget("rmi:subscriptions", user_id) + if subs_raw: + return json.loads(subs_raw) + return [] + + +def _save_subscription(sub: dict): + """Save a subscription to Redis.""" + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + user_id = sub["user_id"] + subs = _get_user_subscriptions(user_id) + + # Update existing or append + found = False + for i, s in enumerate(subs): + if s["id"] == sub["id"]: + subs[i] = sub + found = True + break + if not found: + subs.append(sub) + + r.hset("rmi:subscriptions", user_id, json.dumps(subs)) + + +# ── Public Endpoints ── + + +@router.get("/pricing") +async def get_pricing() -> PricingResponse: + """Get all pricing information - base tiers + add-ons.""" + return { + "tiers": TIER_CONFIG, + "addons": ADDON_CONFIG, + "all_products": ALL_PRODUCTS, + "discounts": { + "six_month": 0.10, # 10% off + "yearly": 0.20, # 20% off + }, + "crypto_chains": CRYPTO_PAYMENT_CONFIG["accepted_chains"], + "enterprise_contact": "biz@rugmunch.io", + } + + +@router.get("/pricing/calculate") +async def calculate_pricing(tier: str, period: str): + """Calculate exact price for a tier + period.""" + price = calculate_price(tier, period) + if price == 0 and tier.upper() != "FREE": + raise HTTPException(status_code=400, detail="Invalid tier or period") + + config = TIER_CONFIG.get(tier.upper()) + monthly = config["price_monthly"] + + savings = 0 + if period == "six_month": + savings = (monthly * 6) - price + elif period == "yearly": + savings = (monthly * 12) - price + + return { + "tier": tier.upper(), + "period": period, + "price": price, + "currency": "USD", + "monthly_equivalent": round(price / get_period_days(period) * 30, 2), + "savings": round(savings, 2), + "savings_percent": round(savings / (monthly * (6 if period == "six_month" else 12)) * 100, 1) + if savings > 0 + else 0, + } + + +# ── Authenticated Endpoints ── + + +@router.get("/subscription") +async def get_my_subscription(request: Request): + """Get current user's active subscription.""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + subs = _get_user_subscriptions(user["id"]) + + # Find active subscription + active = None + now = datetime.utcnow().isoformat() + for sub in subs: + if sub["status"] == "active" and sub["current_period_end"] > now: + active = sub + break + + if not active: + # Return free tier info + return { + "tier": "FREE", + "status": "active", + "features": TIER_CONFIG["FREE"]["features"], + "scans_remaining": user.get("scans_remaining", 5), + "api_calls_today": 0, + } + + return { + "subscription": active, + "tier": active["tier"], + "features": TIER_CONFIG.get(active["tier"], {}).get("features", []), + "days_remaining": max(0, (datetime.fromisoformat(active["current_period_end"]) - datetime.utcnow()).days), + } + + +@router.post("/subscription/create") +async def create_subscription(req: SubscriptionCreateRequest, request: Request): + """Create a new subscription (Stripe, crypto, or x402).""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + tier = req.tier.upper() + # Allow base tiers + addons (PORTFOLIO_PRO can be purchased standalone) + if tier not in ALL_PRODUCTS or tier == "FREE": + raise HTTPException(status_code=400, detail="Invalid tier") + + price = calculate_price(tier, req.period) + days = get_period_days(req.period) + + sub_id = secrets.token_hex(16) + now = datetime.utcnow() + + subscription = { + "id": sub_id, + "user_id": user["id"], + "tier": tier, + "period": req.period, + "status": "pending", # pending until payment confirmed + "current_period_start": now.isoformat(), + "current_period_end": (now + timedelta(days=days)).isoformat(), + "cancel_at_period_end": False, + "payment_method": req.payment_method, + "amount_paid": price, + "currency": "USD", + "created_at": now.isoformat(), + "crypto_chain": req.crypto_chain, + "tx_hash": None, + } + + if req.payment_method == "stripe": + # Create Stripe checkout session + try: + import stripe + + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + + checkout = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=[ + { + "price": TIER_CONFIG[tier]["stripe_price_id"], + "quantity": 1, + } + ], + mode="subscription", + success_url="https://rugmunch.io/pricing/success?session_id={CHECKOUT_SESSION_ID}", + cancel_url="https://rugmunch.io/pricing/cancel", + metadata={ + "user_id": user["id"], + "subscription_id": sub_id, + "tier": tier, + "period": req.period, + }, + ) + subscription["stripe_session_id"] = checkout.id + _save_subscription(subscription) + + return { + "status": "pending", + "checkout_url": checkout.url, + "subscription_id": sub_id, + } + except Exception as e: + logger.error(f"Stripe checkout failed: {e}") + raise HTTPException(status_code=500, detail="Payment processing failed") from e + + elif req.payment_method == "crypto": + if not req.crypto_chain: + raise HTTPException(status_code=400, detail="crypto_chain required for crypto payments") + + chain = req.crypto_chain.lower() + valid_chains = [c["id"] for c in CRYPTO_PAYMENT_CONFIG["accepted_chains"]] + if chain not in valid_chains: + raise HTTPException(status_code=400, detail=f"Unsupported chain: {chain}") + + # Generate payment address/QR data + payment_wallet = CRYPTO_PAYMENT_CONFIG["payment_wallet"] + if chain == "solana": + payment_wallet = CRYPTO_PAYMENT_CONFIG.get("sol_payment_wallet", payment_wallet) + elif chain == "bitcoin": + payment_wallet = CRYPTO_PAYMENT_CONFIG.get("btc_payment_address", payment_wallet) + elif chain == "monero": + payment_wallet = CRYPTO_PAYMENT_CONFIG.get("xmr_payment_address", payment_wallet) + + _save_subscription(subscription) + + return { + "status": "pending_payment", + "subscription_id": sub_id, + "payment_details": { + "chain": chain, + "amount_usd": price, + "payment_address": payment_wallet, + "memo": f"RMI-{sub_id[:8]}", + "expires_at": (now + timedelta(hours=24)).isoformat(), + }, + "instructions": f"Send payment to the address above. Include memo 'RMI-{sub_id[:8]}' for faster confirmation.", + } + elif req.payment_method == "x402": + # x402 micropayment - return EIP-3009 challenge for client to sign + config = ALL_PRODUCTS.get(tier, {}) + x402_price_atoms = config.get("x402_price_atoms") + x402_chain_id = config.get("x402_chain_id", 8453) + if not x402_price_atoms: + raise HTTPException(status_code=400, detail=f"x402 not available for tier {tier}") + _save_subscription(subscription) + return { + "status": "pending_x402", + "subscription_id": sub_id, + "x402": { + "version": 2, + "scheme": "exact", + "network": f"eip155:{x402_chain_id}", + "asset": "USDC", + "amount_atoms": x402_price_atoms, + "amount_usd": price, + "tier": tier, + "period": req.period, + "pay_to": os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), + "expires_at": (now + timedelta(minutes=10)).isoformat(), + }, + "instructions": f"Sign the EIP-3009 USDC transfer authorization via your wallet. ${price} will be charged on Base. Receipt settles the subscription automatically.", + } + + raise HTTPException(status_code=400, detail="Invalid payment method") + + +@router.post("/subscription/crypto/confirm") +async def confirm_crypto_payment(req: CryptoPaymentRequest, request: Request): + """Confirm a crypto payment (called by user after sending tx, or webhook).""" + from app.auth import _save_user, get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + # Find pending subscription + subs = _get_user_subscriptions(user["id"]) + pending = None + for sub in subs: + if sub["status"] == "pending" and sub.get("crypto_chain") == req.chain: + pending = sub + break + + if not pending: + raise HTTPException(status_code=404, detail="No pending subscription found") + + # TODO: Verify transaction on-chain + # For now, we accept the tx_hash and mark as active (in production, verify via RPC) + + pending["status"] = "active" + pending["tx_hash"] = req.tx_hash + pending["amount_paid"] = req.amount_paid_usd + pending["confirmed_at"] = datetime.utcnow().isoformat() + pending["from_address"] = req.from_address + + _save_subscription(pending) + + # Update user tier + user["tier"] = pending["tier"] + user["scans_remaining"] = TIER_CONFIG[pending["tier"]]["scans_per_month"] + _save_user(user) + + logger.info(f"[SUBSCRIPTION] Crypto payment confirmed: user={user['id']} tier={pending['tier']} tx={req.tx_hash}") + + return { + "status": "active", + "subscription": pending, + } + + +@router.post("/subscription/cancel") +async def cancel_subscription(request: Request): + """Cancel subscription at period end.""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + subs = _get_user_subscriptions(user["id"]) + active = None + for sub in subs: + if sub["status"] == "active": + active = sub + break + + if not active: + raise HTTPException(status_code=404, detail="No active subscription") + + active["cancel_at_period_end"] = True + active["cancelled_at"] = datetime.utcnow().isoformat() + _save_subscription(active) + + return {"status": "ok", "message": "Subscription will cancel at period end"} + + +@router.post("/subscription/upgrade") +async def upgrade_subscription(tier: str, request: Request): + """Upgrade to a higher tier.""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + tier = tier.upper() + if tier not in TIER_CONFIG or tier in ("FREE", "ENTERPRISE"): + raise HTTPException(status_code=400, detail="Invalid upgrade tier") + + subs = _get_user_subscriptions(user["id"]) + active = None + for sub in subs: + if sub["status"] == "active": + active = sub + break + + if active: + # Cancel current and create new + active["status"] = "cancelled" + active["cancelled_at"] = datetime.utcnow().isoformat() + active["cancel_reason"] = "upgraded" + _save_subscription(active) + + # Create new subscription for new tier + # (User will need to complete payment) + return { + "status": "upgrade_initiated", + "new_tier": tier, + "message": "Please complete payment for the new tier", + "checkout_url": f"/pricing?upgrade_to={tier}", + } + + +# ── Webhook Endpoints ── + + +@router.post("/webhooks/stripe") +async def stripe_webhook(request: Request): + """Handle Stripe webhooks for subscription events.""" + from app.auth import _get_user, _save_user + + payload = await request.body() + sig_header = request.headers.get("stripe-signature") + + try: + import stripe + + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + endpoint_secret = os.getenv("STRIPE_WEBHOOK_SECRET") + + event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret) + except Exception as e: + logger.error(f"Stripe webhook error: {e}") + raise HTTPException(status_code=400, detail="Invalid webhook") from e + + if event["type"] == "checkout.session.completed": + session = event["data"]["object"] + metadata = session.get("metadata", {}) + user_id = metadata.get("user_id") + sub_id = metadata.get("subscription_id") + tier = metadata.get("tier") + + if user_id and sub_id: + # Activate subscription + subs = _get_user_subscriptions(user_id) + for sub in subs: + if sub["id"] == sub_id: + sub["status"] = "active" + sub["stripe_subscription_id"] = session.get("subscription") + sub["confirmed_at"] = datetime.utcnow().isoformat() + _save_subscription(sub) + + # Update user tier + user = _get_user(user_id) + if user: + user["tier"] = tier + user["scans_remaining"] = TIER_CONFIG[tier]["scans_per_month"] + _save_user(user) + break + + elif event["type"] == "invoice.payment_failed": + event["data"]["object"] + # Mark subscription as past_due + # TODO: Implement + pass + + return {"status": "ok"} + + +# ── Admin Endpoints ── + + +@router.get("/admin/subscriptions") +async def list_all_subscriptions( + request: Request, + status: str | None = None, + tier: str | None = None, + limit: int = 50, + offset: int = 0, +): + """List all subscriptions (admin only).""" + from app.auth import get_current_user + + user = await get_current_user(request) + if not user or user.get("role") not in ("ADMIN", "SUPERADMIN"): + raise HTTPException(status_code=403, detail="Admin access required") + + r = get_redis() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + all_subs = r.hgetall("rmi:subscriptions") + + results = [] + for user_id, subs_raw in all_subs.items(): + subs = json.loads(subs_raw) + for sub in subs: + if status and sub["status"] != status: + continue + if tier and sub["tier"] != tier.upper(): + continue + sub["user_id"] = user_id + results.append(sub) + + total = len(results) + paginated = results[offset : offset + limit] + + return { + "subscriptions": paginated, + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.get("/admin/revenue") +async def get_revenue_stats(request: Request): + """Get revenue statistics (admin only).""" + from app.auth import get_current_user + from app.core.redis import get_redis + + user = await get_current_user(request) + if not user or user.get("role") not in ("ADMIN", "SUPERADMIN"): + raise HTTPException(status_code=403, detail="Admin access required") + + r = get_redis() + all_subs = r.hgetall("rmi:subscriptions") + + stats = { + "total_revenue": 0, + "by_tier": {}, + "by_period": {}, + "by_payment_method": {}, + "active_subscriptions": 0, + "cancelled_subscriptions": 0, + "pending_subscriptions": 0, + } + + for _user_id, subs_raw in all_subs.items(): + subs = json.loads(subs_raw) + for sub in subs: + if sub["status"] == "active": + stats["active_subscriptions"] += 1 + stats["total_revenue"] += sub.get("amount_paid", 0) + + tier = sub["tier"] + stats["by_tier"][tier] = stats["by_tier"].get(tier, 0) + sub.get("amount_paid", 0) + + period = sub.get("period", "monthly") + stats["by_period"][period] = stats["by_period"].get(period, 0) + sub.get("amount_paid", 0) + + method = sub.get("payment_method", "stripe") + stats["by_payment_method"][method] = stats["by_payment_method"].get(method, 0) + sub.get( + "amount_paid", 0 + ) + elif sub["status"] == "cancelled": + stats["cancelled_subscriptions"] += 1 + elif sub["status"] == "pending": + stats["pending_subscriptions"] += 1 + + return stats diff --git a/app/_archive/legacy_2026_07/supabase_auth_router.py b/app/_archive/legacy_2026_07/supabase_auth_router.py new file mode 100644 index 0000000..5af5321 --- /dev/null +++ b/app/_archive/legacy_2026_07/supabase_auth_router.py @@ -0,0 +1,214 @@ +""" +Supabase Auth Integration - Web3 wallet authentication. +Uses Moralis for SIWE/SIWS challenges, Supabase for user storage. +Free alternative to paid Moralis auth for acquired users. + +Flow: +1. Client requests challenge from /auth/challenge/{evm|solana} +2. User signs with wallet (MetaMask/Phantom) +3. Client submits signature to /auth/verify +4. We verify with Moralis, then create/update Supabase user +5. Return Supabase JWT for authenticated session +""" + +import hashlib +import logging +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + + +# ── Models ─────────────────────────────────────────────────── + + +class VerifyRequest(BaseModel): + chain: str # evm or solana + message: str + signature: str + wallet_address: str + + +# ── Supabase Integration ───────────────────────────────────── + + +async def _create_or_update_user(wallet: str, chain: str, profile: dict) -> dict: + """Create or update user in Supabase.""" + try: + import os + + from supabase import Client, create_client + + supabase_url = os.environ.get("SUPABASE_URL", "") + supabase_key = ( + os.environ.get("SUPABASE_KEY", "") + or os.environ.get("SUPABASE_SERVICE_KEY", "") + or os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "") + ) + + if not supabase_url or not supabase_key: + logger.warning("Supabase credentials not configured in environment") + return {} + + supabase: Client = create_client(supabase_url, supabase_key) + + # Generate user ID from wallet + user_id = hashlib.sha256(f"{chain}:{wallet}".encode()).hexdigest()[:32] + + # Check if user exists + existing = supabase.table("users").select("*").eq("wallet_address", wallet).eq("chain", chain).execute() + + if existing.data and len(existing.data) > 0: + # Update + result = ( + supabase.table("users") + .update( + { + "last_login": datetime.now(UTC).isoformat(), + "profile": profile, + } + ) + .eq("wallet_address", wallet) + .eq("chain", chain) + .execute() + ) + return result.data[0] if result.data else {} + else: + # Create + result = ( + supabase.table("users") + .insert( + { + "id": user_id, + "wallet_address": wallet, + "chain": chain, + "created_at": datetime.now(UTC).isoformat(), + "last_login": datetime.now(UTC).isoformat(), + "profile": profile, + } + ) + .execute() + ) + return result.data[0] if result.data else {} + except ImportError: + logger.debug("Supabase not installed") + return {} + except Exception as e: + logger.debug(f"Supabase user creation failed: {e}") + return {} + + +async def _generate_supabase_jwt(user_id: str, wallet: str) -> str: + """Generate JWT token for Supabase auth.""" + # This is a simplified version - use supabase.auth for production + import os + + import jwt + + jwt_secret = os.getenv("SUPABASE_JWT_SECRET", "fallback-secret-change-me") + + payload = { + "sub": user_id, + "wallet": wallet, + "iat": datetime.now(UTC), + "exp": datetime.now(UTC) + timedelta(days=7), + } + + return jwt.encode(payload, jwt_secret, algorithm="HS256") + + +# ── Endpoints ──────────────────────────────────────────────── + + +@router.post("/verify/evm") +async def verify_evm_and_create_user(req: VerifyRequest): + """Verify EVM signature and create Supabase user.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + + # Verify with Moralis + result = await mc.verify_evm_signature(req.message, req.signature) + if not result: + raise HTTPException(status_code=401, detail="Signature verification failed") + + # Extract profile data + profile_id = result.get("profileId", "") + profile = { + "profile_id": profile_id, + "verified_at": datetime.now(UTC).isoformat(), + } + + # Create/update Supabase user + user = await _create_or_update_user(req.wallet_address, "evm", profile) + + # Generate JWT + user_id = user.get("id", hashlib.sha256(f"evm:{req.wallet_address}".encode()).hexdigest()[:32]) + jwt_token = await _generate_supabase_jwt(user_id, req.wallet_address) + + return { + "status": "ok", + "user": user, + "jwt": jwt_token, + "wallet": req.wallet_address, + "chain": "evm", + } + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/verify/solana") +async def verify_solana_and_create_user(req: VerifyRequest): + """Verify Solana signature and create Supabase user.""" + try: + from app.moralis_connector import get_moralis_connector + + mc = get_moralis_connector() + + # Verify with Moralis + result = await mc.verify_solana_signature(req.message, req.signature) + if not result: + raise HTTPException(status_code=401, detail="Signature verification failed") + + # Extract profile data + profile_id = result.get("profileId", "") + profile = { + "profile_id": profile_id, + "verified_at": datetime.now(UTC).isoformat(), + } + + # Create/update Supabase user + user = await _create_or_update_user(req.wallet_address, "solana", profile) + + # Generate JWT + user_id = user.get("id", hashlib.sha256(f"solana:{req.wallet_address}".encode()).hexdigest()[:32]) + jwt_token = await _generate_supabase_jwt(user_id, req.wallet_address) + + return { + "status": "ok", + "user": user, + "jwt": jwt_token, + "wallet": req.wallet_address, + "chain": "solana", + } + except ImportError: + raise HTTPException(status_code=503, detail="Moralis connector not available") from None + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/health") +async def auth_health(): + """Auth service health check.""" + return { + "status": "ok", + "service": "supabase-auth-integration", + "providers": ["moralis", "supabase"], + } diff --git a/app/_archive/legacy_2026_07/supabase_oauth_router.py b/app/_archive/legacy_2026_07/supabase_oauth_router.py new file mode 100644 index 0000000..caf92fc --- /dev/null +++ b/app/_archive/legacy_2026_07/supabase_oauth_router.py @@ -0,0 +1,466 @@ +""" +Supabase OAuth Router - Social + Web3 Auth. +Supports: GitHub, Google (Gmail), Discord, X (Twitter), + Wallet (EVM/Solana). +Account linking: Connect multiple auth methods to one user account. + +Supabase OAuth Providers (free tier): +- GitHub: Built-in, no approval needed +- Google: Requires OAuth consent screen +- Discord: Built-in, instant approval +- X/Twitter: Requires Twitter developer account +- Others: GitLab, Bitbucket, Slack, LinkedIn, etc. + +Flow: +1. GET /oauth/{provider} → Returns Supabase OAuth URL +2. User authenticates with provider +3. Provider redirects to /oauth/callback/{provider} +4. We get Supabase session + create profile +5. Return JWT + user data +""" + +import hashlib +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/oauth", tags=["oauth"]) + +# ── OAuth Providers ───────────────────────────────────────── + +OAUTH_PROVIDERS = { + "github": {"name": "GitHub", "icon": "github", "enabled": True}, + "google": {"name": "Google", "icon": "google", "enabled": True}, + "discord": {"name": "Discord", "icon": "discord", "enabled": True}, + "twitter": {"name": "X (Twitter)", "icon": "twitter", "enabled": True}, + "gitlab": {"name": "GitLab", "icon": "gitlab", "enabled": True}, + "bitbucket": {"name": "Bitbucket", "icon": "bitbucket", "enabled": True}, + # Web3-focused providers (no Slack/LinkedIn - not web3 relevant) +} + + +# ── Models ────────────────────────────────────────────────── + + +class LinkAccountRequest(BaseModel): + """Link additional auth provider to existing account.""" + + provider: str + access_token: str + provider_user_id: str + email: str | None = None + + +class UserProfile(BaseModel): + """Unified user profile.""" + + id: str + email: str | None = None + wallet_evm: str | None = None + wallet_solana: str | None = None + providers: list[str] = [] + created_at: str + last_login: str + metadata: dict = {} + + +# ── Supabase Client ───────────────────────────────────────── + + +def _get_supabase(): + """Get Supabase client.""" + try: + import os + + from supabase import Client, create_client # noqa: F401 + + url = os.getenv("SUPABASE_URL", "") + key = os.getenv("SUPABASE_KEY", "") + + if not url or not key: + return None + + return create_client(url, key) + except ImportError: + return None + + +async def _get_user_by_wallet(wallet: str, chain: str) -> dict | None: + """Get user by wallet address.""" + supabase = _get_supabase() + if not supabase: + return None + + result = supabase.table("users").select("*").eq("wallet_address", wallet).eq("chain", chain).execute() + return result.data[0] if result.data else None + + +async def _get_user_by_oauth(provider: str, provider_id: str) -> dict | None: + """Get user by OAuth provider ID.""" + supabase = _get_supabase() + if not supabase: + return None + + result = ( + supabase.table("user_providers") + .select("user_id") + .eq("provider", provider) + .eq("provider_user_id", provider_id) + .execute() + ) + if not result.data: + return None + + user_id = result.data[0]["user_id"] + user_result = supabase.table("users").select("*").eq("id", user_id).execute() + return user_result.data[0] if user_result.data else None + + +async def _create_user( + email: str | None = None, + wallet: str | None = None, + chain: str | None = None, + provider: str | None = None, + provider_id: str | None = None, +) -> dict: + """Create new user with optional provider linkage.""" + supabase = _get_supabase() + if not supabase: + return {} + + user_id = hashlib.sha256(f"{email or wallet or provider_id}".encode()).hexdigest()[:32] + now = datetime.now(UTC).isoformat() + + # Create user + user_data = { + "id": user_id, + "email": email, + "wallet_address": wallet, + "chain": chain, + "created_at": now, + "last_login": now, + "metadata": {}, + } + + result = supabase.table("users").insert(user_data).execute() + user = result.data[0] if result.data else user_data + + # Link provider if provided + if provider and provider_id: + supabase.table("user_providers").insert( + { + "user_id": user_id, + "provider": provider, + "provider_user_id": provider_id, + "linked_at": now, + } + ).execute() + + return user + + +async def _link_provider(user_id: str, provider: str, provider_id: str, email: str | None = None) -> bool: + """Link additional provider to existing user.""" + supabase = _get_supabase() + if not supabase: + return False + + # Check if already linked + existing = ( + supabase.table("user_providers") + .select("*") + .eq("provider", provider) + .eq("provider_user_id", provider_id) + .execute() + ) + if existing.data: + return True # Already linked + + # Link + result = ( + supabase.table("user_providers") + .insert( + { + "user_id": user_id, + "provider": provider, + "provider_user_id": provider_id, + "email": email, + "linked_at": datetime.now(UTC).isoformat(), + } + ) + .execute() + ) + + # Update user email if provided + if email: + supabase.table("users").update({"email": email}).eq("id", user_id).execute() + + return bool(result.data) + + +async def _get_user_providers(user_id: str) -> list[str]: + """Get list of linked providers for user.""" + supabase = _get_supabase() + if not supabase: + return [] + + result = supabase.table("user_providers").select("provider").eq("user_id", user_id).execute() + return [r["provider"] for r in result.data] if result.data else [] + + +# ── Health & Info (must be BEFORE dynamic routes) ─────────── + + +@router.get("/health") +async def oauth_health(): + """OAuth service health check.""" + supabase = _get_supabase() + return { + "status": "ok", + "service": "supabase-oauth", + "supabase_connected": supabase is not None, + "providers_available": len([p for p in OAUTH_PROVIDERS.values() if p["enabled"]]), + "supported_providers": list(OAUTH_PROVIDERS.keys()), + } + + +@router.get("/") +async def oauth_root(): + """OAuth service info.""" + return { + "service": "RMI OAuth", + "version": "1.0", + "providers": len(OAUTH_PROVIDERS), + "wallet_auth": True, + "account_linking": True, + "docs": "/api/v1/oauth/providers", + } + + +# ── Endpoints ──────────────────────────────────────────────── + + +@router.get("/providers") +async def list_oauth_providers(): + """List available OAuth providers.""" + return { + "providers": [ + { + "id": pid, + "name": info["name"], + "icon": info["icon"], + "enabled": info["enabled"], + } + for pid, info in OAUTH_PROVIDERS.items() + ], + "wallet_auth": { + "evm": True, + "solana": True, + }, + } + + +@router.get("/{provider}") +async def oauth_start(provider: str, redirect_uri: str | None = None): + """Get Supabase OAuth URL for provider. + + Frontend should redirect user to this URL. + After auth, Supabase redirects to your callback URL. + """ + if provider not in OAUTH_PROVIDERS: + raise HTTPException(status_code=400, detail=f"Unknown provider: {provider}") + + if not OAUTH_PROVIDERS[provider]["enabled"]: + raise HTTPException(status_code=400, detail=f"Provider {provider} is not enabled") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + # Get OAuth URL from Supabase + import os + + base_url = os.getenv("SUPABASE_URL", "").replace(".supabase.co", "") + project_ref = base_url.split("//")[1].split(".")[0] if base_url else "" + + oauth_url = f"https://{project_ref}.supabase.co/auth/v1/authorize?provider={provider}" + if redirect_uri: + oauth_url += f"&redirect_to={redirect_uri}" + + return { + "provider": provider, + "oauth_url": oauth_url, + "instructions": "Redirect user to oauth_url. After auth, they'll be redirected to your callback.", + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/callback/{provider}") +async def oauth_callback(provider: str, request: Request): + """Handle OAuth callback from Supabase. + + Supabase redirects here after user authenticates with provider. + Exchange code for session, create/update user. + """ + try: + body = await request.json() + except Exception: + body = dict(request.query_params) + + code = body.get("code") + error = body.get("error") + + if error: + raise HTTPException(status_code=400, detail=f"OAuth error: {error}") + + if not code: + raise HTTPException(status_code=400, detail="No authorization code") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + # Exchange code for session + session = supabase.auth.exchange_code_for_session(code) + + if not session or not session.user: + raise HTTPException(status_code=400, detail="Invalid session") + + user = session.user + provider_id = user.identities[0].identity_id if user.identities else user.id + + # Check if user exists + existing = await _get_user_by_oauth(provider, provider_id) + + if existing: + # Update last login + supabase.table("users").update({"last_login": datetime.now(UTC).isoformat()}).eq( + "id", existing["id"] + ).execute() + + user_data = existing + else: + # Create new user + user_data = await _create_user( + email=user.email, + provider=provider, + provider_id=provider_id, + ) + + # Get linked providers + providers = await _get_user_providers(user_data["id"]) + + return { + "status": "ok", + "user": { + "id": user_data["id"], + "email": user_data.get("email"), + "providers": providers, + "created_at": user_data.get("created_at"), + "last_login": user_data.get("last_login"), + }, + "session": { + "access_token": session.access_token, + "refresh_token": session.refresh_token, + "expires_in": session.expires_in, + }, + "provider": provider, + } + except Exception as e: + logger.error(f"OAuth callback failed: {e}") + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.post("/link") +async def link_provider(req: LinkAccountRequest): + """Link additional OAuth provider to existing account. + + Requires authenticated session (JWT from /auth/verify or OAuth). + """ + # TODO: Add JWT verification middleware + # For now, assume user is authenticated + + if req.provider not in OAUTH_PROVIDERS: + raise HTTPException(status_code=400, detail=f"Unknown provider: {req.provider}") + + # Get user from session (TODO: implement JWT extraction) + user_id = "temp-user-id" # Extract from JWT header + + success = await _link_provider(user_id, req.provider, req.provider_user_id, req.email) + + if success: + return {"status": "ok", "message": f"Linked {req.provider} to your account"} + else: + raise HTTPException(status_code=500, detail="Failed to link provider") + + +@router.get("/me") +async def get_current_user(access_token: str = Query(None, description="Supabase access token")): + """Get current authenticated user profile.""" + if not access_token: + raise HTTPException(status_code=401, detail="No access token provided") + + supabase = _get_supabase() + if not supabase: + raise HTTPException(status_code=503, detail="Supabase not configured") + + try: + # Get user from token + user_response = supabase.auth.get_user(access_token) + user = user_response.user + + if not user: + raise HTTPException(status_code=401, detail="Invalid token") + + # Get our user record + db_user = await _get_user_by_oauth("google", user.id) # Try any provider + + if not db_user: + # Create on the fly + db_user = await _create_user(email=user.email) + + providers = await _get_user_providers(db_user["id"]) + + return { + "id": db_user["id"], + "email": db_user.get("email"), + "wallet_evm": db_user.get("wallet_address") if db_user.get("chain") == "evm" else None, + "wallet_solana": db_user.get("wallet_address") if db_user.get("chain") == "solana" else None, + "providers": providers, + "created_at": db_user.get("created_at"), + "last_login": db_user.get("last_login"), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)[:200]) from e + + +@router.get("/health") +async def oauth_health(): # noqa: F811 + """OAuth service health check.""" + supabase = _get_supabase() + return { + "status": "ok", + "service": "supabase-oauth", + "supabase_connected": supabase is not None, + "providers_available": len([p for p in OAUTH_PROVIDERS.values() if p["enabled"]]), + "supported_providers": list(OAUTH_PROVIDERS.keys()), + } + + +@router.get("/") +async def oauth_root(): # noqa: F811 + """OAuth service info.""" + return { + "service": "RMI OAuth", + "version": "1.0", + "providers": len(OAUTH_PROVIDERS), + "wallet_auth": True, + "account_linking": True, + "docs": "/api/v1/oauth/providers", + } diff --git a/app/_archive/legacy_2026_07/supabase_realtime.py b/app/_archive/legacy_2026_07/supabase_realtime.py new file mode 100644 index 0000000..df4ef7a --- /dev/null +++ b/app/_archive/legacy_2026_07/supabase_realtime.py @@ -0,0 +1,202 @@ +""" +Supabase Realtime Bridge - live PostgreSQL changes → Redis pub/sub → WebSocket clients. +Uses proper WebSocket protocol via 'websockets' library (httpx doesn't support WS upgrades). +""" + +import asyncio +import json +import logging +import os +from datetime import UTC, datetime + +import redis.asyncio as aioredis +import websockets + +logger = logging.getLogger("supabase.realtime") + +SUPABASE_URL = os.getenv("SUPABASE_URL", "") +SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "" + +TABLE_CHANNELS = { + "security_alerts": "rmi:ws:alerts", + "whale_alerts": "rmi:ws:alerts", + "intelligence_alerts": "rmi:ws:alerts", + "rugpull_alerts": "rmi:ws:alerts", + "scam_alerts": "rmi:ws:alerts", + "scan_results": "rmi:ws:scans", + "market_data": "rmi:ws:market", + "market_global_metrics": "rmi:ws:market", + "market_trending_tokens": "rmi:ws:market", + "notifications": "rmi:ws:notifications", + "system_health_log": "rmi:ws:health", + "activity_feed": "rmi:ws:activity", +} + + +class SupabaseRealtimeBridge: + def __init__(self): + self.redis = None + self._running = False + self._reconnect_delay = 1 + + async def start(self): + if not SUPABASE_URL or not SUPABASE_KEY: + logger.warning("Supabase Realtime: no URL/key") + return + redis_host = os.getenv("REDIS_HOST", "rmi-redis") + redis_port = int(os.getenv("REDIS_PORT", "6379")) + redis_pass = os.getenv("REDIS_PASSWORD", "") + self.redis = await aioredis.from_url( + f"redis://{redis_host}:{redis_port}", + password=redis_pass or None, + decode_responses=True, + ) + self._running = True + asyncio.create_task(self._realtime_loop()) + logger.info("Supabase Realtime bridge started - %d tables → Redis", len(TABLE_CHANNELS)) + + async def stop(self): + self._running = False + if self.redis: + await self.redis.close() + + async def _realtime_loop(self): + ws_url = SUPABASE_URL.replace("https://", "wss://") + "/realtime/v1/websocket" + params = f"?apikey={SUPABASE_KEY}" + + while self._running: + try: + async with websockets.connect( + ws_url + params, + ping_interval=30, + ping_timeout=10, + max_size=2**20, + ) as ws: + logger.info("Supabase Realtime WebSocket connected") + self._reconnect_delay = 1 + + # Subscribe to all tables + for table, _channel in TABLE_CHANNELS.items(): + join_msg = { + "topic": f"realtime:public:{table}", + "event": "phx_join", + "payload": { + "config": { + "broadcast": {"self": True}, + "postgres_changes": [{"event": "*", "schema": "public", "table": table}], + } + }, + "ref": f"join_{table}", + } + await ws.send(json.dumps(join_msg)) + logger.debug("Subscribed to %s", table) + + # Read messages + while self._running: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=60) + await self._handle_message(raw) + except TimeoutError: + await ws.ping() + continue + + except Exception as e: + logger.warning("Realtime WS disconnected: %s - reconnecting in %ds", e, self._reconnect_delay) + await asyncio.sleep(self._reconnect_delay) + self._reconnect_delay = min(self._reconnect_delay * 2, 60) + + async def _handle_message(self, raw: str): + try: + msg = json.loads(raw) + except json.JSONDecodeError: + return + + # Phoenix channels format + topic = msg.get("topic", "") + event = msg.get("event", "") + payload = msg.get("payload", {}) + + if not topic.startswith("realtime:public:"): + return + + table = topic.replace("realtime:public:", "") + channel = TABLE_CHANNELS.get(table) + if not channel: + return + + # Extract change data + if event == "phx_reply": + return # Join acknowledgement, skip + + data = payload.get("data") or payload.get("record") or {} + if isinstance(data, dict): + record = data + elif isinstance(data, list) and data: + record = data[0] if isinstance(data[0], dict) else {} + else: + record = {"raw": str(data)[:500]} + + out = { + "type": payload.get("eventType", event).lower(), + "table": table, + "record": record, + "timestamp": datetime.now(UTC).isoformat(), + } + + if self.redis: + await self.redis.publish(channel, json.dumps(out, default=str)) + + +# ── HTTP polling fallback ── +async def poll_realtime_changes(): + """Poll Supabase REST API for recent changes (falls back when WebSocket unavailable).""" + import httpx + + if not SUPABASE_URL or not SUPABASE_KEY: + return + headers = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}"} + redis = await aioredis.from_url( + f"redis://{os.getenv('REDIS_HOST', 'rmi-redis')}:{os.getenv('REDIS_PORT', '6379')}", + password=os.getenv("REDIS_PASSWORD", "") or None, + decode_responses=True, + ) + last_seen = {} + while True: + try: + async with httpx.AsyncClient(timeout=10) as c: + for table, channel in [ + ("security_alerts", "rmi:ws:alerts"), + ("whale_alerts", "rmi:ws:alerts"), + ("scan_results", "rmi:ws:scans"), + ("market_data", "rmi:ws:market"), + ("notifications", "rmi:ws:notifications"), + ]: + last = last_seen.get(table, datetime.now(UTC).isoformat()) + r = await c.get( + f"{SUPABASE_URL}/rest/v1/{table}", + params={ + "select": "*", + "created_at": f"gt.{last}", + "order": "created_at.desc", + "limit": "20", + }, + headers=headers, + ) + if r.status_code == 200: + rows = r.json() + if rows: + for row in rows: + msg = { + "type": "poll", + "table": table, + "record": row, + "timestamp": datetime.now(UTC).isoformat(), + } + await redis.publish(channel, json.dumps(msg, default=str)) + last_seen[table] = datetime.now(UTC).isoformat() + except Exception as e: + logger.warning("Realtime poll error: %s", e) + await asyncio.sleep(2) + + +bridge = SupabaseRealtimeBridge() diff --git a/app/_archive/legacy_2026_07/supabase_router.py b/app/_archive/legacy_2026_07/supabase_router.py new file mode 100644 index 0000000..03df862 --- /dev/null +++ b/app/_archive/legacy_2026_07/supabase_router.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +""" +RMI Supabase API Router - Exposes all platform tables via REST endpoints. +Frontend connects here for live, real-time data from Supabase. +""" + +import contextlib +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from app.services.supabase_service import ( + acknowledge_alert, + add_to_watchlist, + check_health, + create_alert, + get_alerts, + get_cluster_wallets, + get_entity_clusters, + get_market_data, + get_subscription, + get_syndicate_wallets, + get_token, + get_tokens, + get_transactions, + get_wallet, + get_wallet_connections, + get_wallet_intel, + get_wallets, + get_watchlist, + remove_from_watchlist, + store_market_data, + track_event, + upsert_wallet, +) + +router = APIRouter(prefix="/api/v1") + +# ── Models ──────────────────────────────────────────────────── + + +class WalletUpsert(BaseModel): + address: str + chain: str = "ethereum" + label: str | None = None + risk_score: int | None = None + tags: list[str] | None = None + balance_usd: float | None = None + metadata: dict | None = None + + +class WatchlistItem(BaseModel): + user_id: str + address: str + type: str = "wallet" # "token" or "wallet" + chain: str = "ethereum" + tags: list[str] | None = None + + +class MarketDataIn(BaseModel): + source: str + data_type: str + data: dict + + +# ═════════════════════════════════════════════════════════ +# ALERTS +# ═════════════════════════════════════════════════════════ + + +@router.get("/alerts") +async def alerts( + severity: str = Query(None), + user_id: str = Query(None), + limit: int = Query(50, ge=1, le=500), + unread: bool = Query(False), +): + data = await get_alerts(severity=severity, user_id=user_id, limit=limit, unread_only=unread) + return {"alerts": data, "total": len(data)} + + +@router.post("/alerts") +async def create_alert_endpoint( + title: str, + description: str, + severity: str = "medium", + alert_type: str = "security", + chain: str = Query(None), + token: str = Query(None), + wallet: str = Query(None), + amount_usd: float = Query(None), + user_id: str = Query(None), +): + result = await create_alert( + title=title, + description=description, + severity=severity, + alert_type=alert_type, + chain=chain, + token=token, + wallet=wallet, + amount_usd=amount_usd, + user_id=user_id, + ) + if not result: + raise HTTPException(status_code=500, detail="Create failed") + return result + + +@router.post("/alerts/{alert_id}/ack") +async def ack_alert(alert_id: str): + ok = await acknowledge_alert(alert_id) + if not ok: + raise HTTPException(status_code=404, detail="Alert not found") + return {"status": "acknowledged"} + + +# ═════════════════════════════════════════════════════════ +# WALLETS +# ═════════════════════════════════════════════════════════ + + +@router.get("/wallets") +async def wallets( + chain: str = Query(None), + risk_min: int = Query(None), + risk_max: int = Query(None), + limit: int = Query(50, ge=1, le=500), +): + data = await get_wallets(chain=chain, risk_min=risk_min, risk_max=risk_max, limit=limit) + return {"wallets": data, "total": len(data)} + + +@router.get("/wallets/{address}") +async def wallet_detail(address: str, chain: str = Query("ethereum")): + wallet = await get_wallet(address, chain) + if not wallet: + raise HTTPException(status_code=404, detail="Wallet not found") + # Enrich with intel and transactions + intel = await get_wallet_intel(address=address, chain=chain, limit=1) + txns = await get_transactions(wallet_address=address, chain=chain, limit=20) + return {"wallet": wallet, "intel": intel[0] if intel else None, "transactions": txns} + + +@router.post("/wallets") +async def wallet_upsert(w: WalletUpsert): + result = await upsert_wallet( + w.address, + w.chain, + label=w.label, + risk_score=w.risk_score, + tags=w.tags, + balance_usd=w.balance_usd, + metadata=w.metadata, + ) + if not result: + raise HTTPException(status_code=500, detail="Upsert failed") + return result + + +@router.get("/wallets/{address}/connections") +async def wallet_connections(address: str): + connections = await get_wallet_connections(address) + return {"address": address, "connections": connections, "count": len(connections)} + + +# ═════════════════════════════════════════════════════════ +# SYNDICATE WALLETS +# ═════════════════════════════════════════════════════════ + + +@router.get("/syndicate") +async def syndicates(status: str = Query(None), chain: str = Query(None), limit: int = Query(200, ge=1, le=500)): + data = await get_syndicate_wallets(status=status, chain=chain, limit=limit) + return {"syndicate_wallets": data, "total": len(data)} + + +# ═════════════════════════════════════════════════════════ +# WATCHLIST (supports tokens and wallets) +# ═════════════════════════════════════════════════════════ + + +@router.get("/watchlist/{user_id}") +async def watchlist(user_id: str, type: str | None = Query(None)): + """Get user's watchlist. Optional ?type=token|wallet filter. + Tries Supabase first, falls back to Redis.""" + data = [] + # Try Supabase first + with contextlib.suppress(Exception): + data = await get_watchlist(user_id) + + # Redis fallback + if not data: + import json as _json + import os + + try: + import redis as _redis + + _r = _redis.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD") or None, + decode_responses=True, + ) + wl_key = f"rmi:watchlist:{user_id}" + raw_entries = _r.lrange(wl_key, 0, -1) + data = [_json.loads(e) for e in raw_entries] + except Exception: + pass + + if type: + data = [item for item in data if item.get("type") == type] + return {"watchlist": data, "total": len(data)} + + +@router.post("/watchlist") +async def watchlist_add(item: WatchlistItem): + """Add a token or wallet address to the user's watchlist. + Tries Supabase first, falls back to Redis.""" + if item.type not in ("token", "wallet"): + raise HTTPException(status_code=400, detail="type must be 'token' or 'wallet'") + + result = None + # Try Supabase first + with contextlib.suppress(Exception): + result = await add_to_watchlist( + user_id=item.user_id, + address=item.address, + chain=item.chain, + tags=item.tags, + item_type=item.type, + ) + + # Redis fallback - always written so watchlist works even without Supabase + if not result: + import json as _json + import os + + try: + import redis as _redis + + _r = _redis.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD") or None, + decode_responses=True, + ) + wl_key = f"rmi:watchlist:{item.user_id}" + entry = _json.dumps( + { + "user_id": item.user_id, + "address": item.address, + "type": item.type, + "chain": item.chain, + "tags": item.tags or [], + "created_at": datetime.now(UTC).isoformat(), + } + ) + _r.lpush(wl_key, entry) + _r.expire(wl_key, 86400 * 30) # 30 day TTL + result = _json.loads(entry) + except Exception: + pass + + if not result: + raise HTTPException(status_code=500, detail="Add failed - both Supabase and Redis unavailable") + + # Broadcast alert via WebSocket so frontend gets notified + try: + import asyncio + + from main import ws_broadcast_alert + + asyncio.create_task( + ws_broadcast_alert( + { + "event": "watchlist_add", + "user_id": item.user_id, + "address": item.address, + "type": item.type, + "chain": item.chain, + } + ) + ) + except Exception: + pass + + return result + + +@router.delete("/watchlist/{user_id}/{address}") +async def watchlist_remove(user_id: str, address: str, chain: str = Query("ethereum")): + """Remove item from watchlist. Tries Supabase and Redis.""" + ok = False + with contextlib.suppress(Exception): + ok = await remove_from_watchlist(user_id, address, chain) + + # Also remove from Redis + import json as _json + import os + + try: + import redis as _redis + + _r = _redis.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD") or None, + decode_responses=True, + ) + wl_key = f"rmi:watchlist:{user_id}" + raw_entries = _r.lrange(wl_key, 0, -1) + for raw in raw_entries: + entry = _json.loads(raw) + if entry.get("address") == address and entry.get("chain", "ethereum") == chain: + _r.lrem(wl_key, 1, raw) + ok = True + break + except Exception: + pass + + if not ok: + raise HTTPException(status_code=404, detail="Not found") + return {"status": "removed"} + + +# ═════════════════════════════════════════════════════════ +# TOKENS +# ═════════════════════════════════════════════════════════ + + +@router.get("/tokens") +async def tokens( + chain: str = Query(None), + limit: int = Query(50, ge=1, le=200), + order: str = Query("volume_24h.desc"), +): + data = await get_tokens(chain=chain, limit=limit, order_by=order) + return {"tokens": data, "total": len(data)} + + +@router.get("/tokens/discover") +async def token_discover(chains: str = Query("solana,ethereum,base,bsc")): + """Discover new token launches across chains via DexScreener + GeckoTerminal.""" + chain_list = [c.strip() for c in chains.split(",") if c.strip()] + from app.token_discovery import discover_tokens + + try: + discovered = await discover_tokens(chains=chain_list) + total = sum(len(tokens) for tokens in discovered.values()) + return {"chains": discovered, "total": total} + except Exception as e: + return {"error": str(e), "chains": {}, "total": 0} + + +@router.get("/tokens/trending") +async def trending_tokens(limit: int = Query(20, ge=1, le=50)): + """Trending tokens from CoinGecko with GeckoTerminal fallback.""" + from app.coingecko_connector import CoinGeckoConnector + + tokens = [] + try: + cg = CoinGeckoConnector() + data = await cg.get_trending() # returns [{id, symbol, name, market_cap_rank, price_btc, score}] + if data and isinstance(data, list): + for item in data[:limit]: + tokens.append( + { + "address": item.get("id", ""), + "name": item.get("name", ""), + "symbol": item.get("symbol", "").upper(), + "price_usd": None, + "price_btc": item.get("price_btc"), + "volume_h24": None, + "change_24h": None, + "market_cap_rank": item.get("market_cap_rank"), + "flags": [], + } + ) + except Exception: + pass + # Fallback: GeckoTerminal trending pools + if not tokens: + import httpx + + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.get( + "https://api.geckoterminal.com/api/v2/networks/solana/trending_pools", + headers={"Accept": "application/json"}, + ) + if r.status_code == 200: + ds = r.json() + for pool in (ds.get("data", []) or [])[:limit]: + attrs = pool.get("attributes", {}) + rel = pool.get("relationships", {}) + base_attrs = {} + with contextlib.suppress(Exception): + base_attrs = rel.get("base_token", {}).get("data", {}) or {} + tokens.append( + { + "address": base_attrs.get("address", pool.get("id", "")[:12]), + "name": attrs.get("name", pool.get("id", "")[:12]), + "symbol": (attrs.get("symbol", "") or base_attrs.get("symbol", "") or "").upper(), + "price_usd": float(attrs.get("base_token_price_usd", 0) or 0), + "volume_h24": str( + attrs.get("volume_usd", {}).get("h24", 0) + if isinstance(attrs.get("volume_usd"), dict) + else 0 + ), + "change_24h": float( + attrs.get("price_change_percentage", {}).get("h24", 0) + if isinstance(attrs.get("price_change_percentage"), dict) + else 0 + ), + "flags": ["solana"], + } + ) + except Exception: + pass + return {"tokens": tokens, "count": len(tokens)} + + +@router.get("/tokens/{address}") +async def token_detail(address: str, chain: str = Query("ethereum")): + token = await get_token(address, chain) + if not token: + raise HTTPException(status_code=404, detail="Token not found") + return token + + +# ═════════════════════════════════════════════════════════ +# ENTITY CLUSTERS +# ═════════════════════════════════════════════════════════ + + +@router.get("/clusters") +async def clusters(limit: int = Query(50, ge=1, le=200)): + data = await get_entity_clusters(limit=limit) + return {"clusters": data, "total": len(data)} + + +@router.get("/clusters/{cluster_id}") +async def cluster_detail(cluster_id: str): + addresses = await get_cluster_wallets(cluster_id) + return {"cluster_id": cluster_id, "addresses": addresses, "count": len(addresses)} + + +# ═════════════════════════════════════════════════════════ +# TRANSACTIONS +# ═════════════════════════════════════════════════════════ + + +@router.get("/transactions/{wallet_address}") +async def transactions(wallet_address: str, chain: str = Query(None), limit: int = Query(50)): + data = await get_transactions(wallet_address=wallet_address, chain=chain, limit=limit) + return {"transactions": data, "total": len(data)} + + +# ═════════════════════════════════════════════════════════ +# MARKET DATA +# ═════════════════════════════════════════════════════════ + + +@router.get("/market-data/{data_type}") +async def market_data(data_type: str, limit: int = Query(10)): + data = await get_market_data(data_type=data_type, limit=limit) + return {"data": data, "total": len(data)} + + +@router.post("/market-data") +async def market_data_store(m: MarketDataIn): + result = await store_market_data(m.source, m.data_type, m.data) + if not result: + raise HTTPException(status_code=500, detail="Store failed") + return result + + +# ═════════════════════════════════════════════════════════ +# SUBSCRIPTIONS +# ═════════════════════════════════════════════════════════ + + +@router.get("/subscriptions/{user_id}") +async def subscription(user_id: str): + sub = await get_subscription(user_id) + if not sub: + return {"plan": "FREE", "status": "none"} + return sub + + +# ═════════════════════════════════════════════════════════ +# ANALYTICS +# ═════════════════════════════════════════════════════════ + + +@router.post("/analytics/event") +async def analytics_event( + event_type: str, + user_id: str = Query(None), + event_data: str = Query("{}"), + page: str = Query(None), +): + import json + + try: + data = json.loads(event_data) + except Exception: + data = {"raw": event_data} + result = await track_event(event_type=event_type, user_id=user_id, event_data=data, page=page) + return {"status": "tracked", "id": result.get("id") if result else None} + + +# ═════════════════════════════════════════════════════════ +# HEALTH +# ═════════════════════════════════════════════════════════ + + +@router.get("/health/supabase") +async def supabase_health(): + return await check_health() + + +@router.get("/health") +async def full_health(): + sb = await check_health() + tables = [ + "wallets", + "alerts", + "watchlist", + "tokens", + "bulletin_posts", + "badges", + "syndicate_wallets", + "entity_clusters", + ] + return {"supabase": sb, "timestamp": datetime.now().isoformat(), "tables_monitored": tables} diff --git a/app/_archive/legacy_2026_07/sweep_now.py b/app/_archive/legacy_2026_07/sweep_now.py new file mode 100644 index 0000000..ca219dd --- /dev/null +++ b/app/_archive/legacy_2026_07/sweep_now.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""One-shot sweep - no interactive prompt. For cron/automation. +Usage: docker exec rmi_backend python3 /app/app/sweep_now.py [--chain sol|eth] [--to ADDR] [--min 1.00] +""" + +import argparse +import asyncio +import os +import sys + +sys.path.insert(0, "/app") + +parser = argparse.ArgumentParser() +parser.add_argument("--chain", default="") +parser.add_argument("--to", default="") +parser.add_argument("--min", type=float, default=1.0) +args = parser.parse_args() + +from app.wallet_manager_v2 import get_wallet_manager_v2 # noqa: E402 + +mgr = get_wallet_manager_v2(os.environ["WALLET_VAULT_PASSWORD"]) +evm_chains = { + "eth", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche", + "bsc", + "fantom", + "gnosis", +} + +swept = 0 +errors = 0 +for w in mgr._wallets.values(): + if not w.x402_enabled: + continue + if args.chain and w.chain != args.chain: + continue + if (w.balance_usd or 0) < args.min: + continue + if w.chain not in evm_chains and w.chain != "sol": + continue + + mnemonic = mgr.get_mnemonic(w.wallet_id) + if not mnemonic: + continue + + try: + if w.chain in evm_chains: + from eth_account import Account + from web3 import Web3 + + owner = args.to or "0x1E3AC01d0fdb976179790BDD02823196A92705C9" + rpc = os.getenv("ETH_RPC_URL", "https://eth.llamarpc.com") + acct = Account.from_mnemonic(mnemonic) + w3 = Web3(Web3.HTTPProvider(rpc)) + bal = w3.eth.get_balance(w.address) + if bal <= w3.to_wei(0.0002, "ether"): + continue + sweep = bal - w3.to_wei(0.0001, "ether") + nonce = w3.eth.get_transaction_count(w.address) + tx = { + "nonce": nonce, + "to": Web3.to_checksum_address(owner), + "value": sweep, + "gas": 21000, + "gasPrice": w3.eth.gas_price, + "chainId": w3.eth.chain_id, + } + signed = w3.eth.account.sign_transaction(tx, acct.key) + h = w3.eth.send_raw_transaction(signed.rawTransaction).hex() + print(f"SWEPT eth {w.address[:12]} -> {owner[:12]} tx={h[:16]}") + swept += 1 + elif w.chain == "sol": + from bip_utils import Bip39SeedGenerator, Bip44, Bip44Coins + from solana.rpc.async_api import AsyncClient + from solana.rpc.commitment import Confirmed + from solana.transaction import Transaction + from solders.keypair import Keypair + from solders.pubkey import Pubkey + from solders.system_program import TransferParams, transfer + + owner = args.to or "Gix4P9AmwcZRGzr2hCEME5m2QAvY86dBfm8c7e7MpFzv" + rpc = os.getenv("SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com") + seed = Bip39SeedGenerator(mnemonic).Generate() + ctx = Bip44.FromSeed(seed, Bip44Coins.SOLANA).Purpose().Coin().Account(0).Change(0).AddressIndex(0) + kp = Keypair.from_bytes(ctx.PrivateKey().Raw().ToBytes()) + + async def _do(): + async with AsyncClient(rpc) as c: # noqa: B023 + resp = await c.get_balance(kp.pubkey(), commitment=Confirmed) # noqa: B023 + if resp.value <= 2_000_000: + return + sweep = resp.value - 1_000_000 + ix = transfer( + TransferParams( + from_pubkey=kp.pubkey(), # noqa: B023 + to_pubkey=Pubkey.from_string(owner), # noqa: B023 + lamports=sweep, + ) + ) + tx = Transaction().add(ix) + tx.sign(kp) # noqa: B023 + r = await c.send_transaction(tx, kp) # noqa: B023 + return str(r.value) + + sig = asyncio.run(_do()) + if sig: + print(f"SWEPT sol {w.address[:12]} -> {owner[:12]} tx={sig[:16]}") + swept += 1 + except Exception as e: + print(f"ERR {w.chain}: {str(e)[:80]}") + errors += 1 + +mgr._save_vault() +print("Done: %d swept, %d errors" % (swept, errors)) # noqa: UP031 diff --git a/app/_archive/legacy_2026_07/test_bundler_detect.py b/app/_archive/legacy_2026_07/test_bundler_detect.py new file mode 100644 index 0000000..2e312fe --- /dev/null +++ b/app/_archive/legacy_2026_07/test_bundler_detect.py @@ -0,0 +1,409 @@ +""" +Tests for the Supply Manipulation / Bundler Detector (bundler_detect.py) +""" + +import asyncio +import unittest +from unittest.mock import patch + +from bundler_detect import ( + BundledBuy, + BundlerDetector, + BundlerReport, + HolderCluster, + _entropy, + _funding_overlap, + _gini_coefficient, + _label_risk, + _time_cluster_similarity, +) + + +class TestHelpers(unittest.TestCase): + """Test scoring helper functions.""" + + def test_gini_coefficient_equal(self): + """Perfectly equal distribution → Gini = 0.""" + vals = [10.0] * 10 + self.assertAlmostEqual(_gini_coefficient(vals), 0.0, places=2) + + def test_gini_coefficient_concentrated(self): + """Maximally concentrated → Gini ≈ 0.9 (theoretical max for n=10 with one non-zero).""" + vals = [100.0] + [0.0] * 9 + self.assertAlmostEqual(_gini_coefficient(vals), 0.9, places=2) + + def test_gini_coefficient_empty(self): + """Empty list → Gini = 0.""" + self.assertEqual(_gini_coefficient([]), 0.0) + + def test_gini_coefficient_typical(self): + """Typical uneven distribution.""" + vals = [50.0, 20.0, 10.0, 5.0, 5.0, 5.0, 3.0, 1.0, 1.0, 0.0] + gini = _gini_coefficient(vals) + self.assertGreater(gini, 0.5) + self.assertLess(gini, 0.9) + + def test_entropy_uniform(self): + """Uniform distribution → entropy = 1.0 (normalized).""" + vals = [10.0] * 4 + self.assertAlmostEqual(_entropy(vals), 1.0, places=2) + + def test_entropy_concentrated(self): + """One holder has everything → entropy ≈ 0.""" + vals = [100.0, 0.0, 0.0, 0.0] + self.assertAlmostEqual(_entropy(vals), 0.0, places=2) + + def test_time_cluster_similarity_all_same(self): + """All buys at same timestamp → similarity = 1.0.""" + ts = [1000.0] * 10 + self.assertEqual(_time_cluster_similarity(ts), 1.0) + + def test_time_cluster_similarity_spread(self): + """Buys spread over 10 minutes → low similarity.""" + ts = [1000.0, 1600.0] + self.assertLess(_time_cluster_similarity(ts), 0.5) + + def test_time_cluster_similarity_single(self): + """Single timestamp → 0.0.""" + self.assertEqual(_time_cluster_similarity([1000.0]), 0.0) + + def test_time_cluster_similarity_empty(self): + """Empty list → 0.0.""" + self.assertEqual(_time_cluster_similarity([]), 0.0) + + def test_funding_overlap_no_overlap(self): + """All unique sources → 0.0.""" + sources = ["src_a", "src_b", "src_c"] + self.assertEqual(_funding_overlap(sources), 0.0) + + def test_funding_overlap_full_overlap(self): + """All same source → 1.0.""" + sources = ["src_x"] * 5 + self.assertEqual(_funding_overlap(sources), 1.0) + + def test_funding_overlap_partial(self): + """2 of 4 share a source → 0.5.""" + sources = ["src_a", "src_b", "src_a", "src_c"] + self.assertEqual(_funding_overlap(sources), 0.5) + + def test_label_risk_critical(self): + self.assertEqual(_label_risk(80), "critical") + self.assertEqual(_label_risk(75), "critical") + + def test_label_risk_high(self): + self.assertEqual(_label_risk(60), "high") + self.assertEqual(_label_risk(50), "high") + + def test_label_risk_medium(self): + self.assertEqual(_label_risk(30), "medium") + self.assertEqual(_label_risk(25), "medium") + + def test_label_risk_low(self): + self.assertEqual(_label_risk(10), "low") + self.assertEqual(_label_risk(1), "low") + + def test_label_risk_none(self): + self.assertEqual(_label_risk(0), "none") + + +class TestDataModels(unittest.TestCase): + """Test dataclass models.""" + + def test_bundler_report_to_dict(self): + report = BundlerReport( + token_address="0x123", + chain="ethereum", + name="TestToken", + symbol="TST", + bundler_score=85.0, + supply_concentration_score=90.0, + sniper_cluster_score=80.0, + launch_timing_anomaly_score=75.0, + fund_flow_risk_score=85.5, + top_10_holder_concentration=92.0, + dev_hold_pct=35.0, + estimated_unique_entities=3, + risk_label="critical", + ) + d = report.to_dict() + self.assertEqual(d["token_address"], "0x123") + self.assertEqual(d["risk_label"], "critical") + self.assertEqual(d["bundler_score"], 85.0) + self.assertEqual(d["signals"]["supply_concentration"], 90.0) + + def test_bundler_report_summary(self): + report = BundlerReport( + token_address="0x1234567890abcdef12345678", + chain="ethereum", + name="TestToken", + symbol="TST", + bundler_score=85.0, + risk_label="critical", + top_10_holder_concentration=92.0, + estimated_unique_entities=3, + holder_clusters=[ + HolderCluster( + wallets=["wallet1", "wallet2"], + total_supply_pct=45.0, + funding_overlap_score=0.8, + buy_time_similarity=0.9, + ) + ], + buys_from_same_funding=15, + suspected_bundled_buys=[ + BundledBuy( + wallet="wallet1", + amount_usd=5000.0, + buy_block=12345, + buy_timestamp=1000.0, + is_sniper=True, + ) + ], + ) + summary = report.summary() + self.assertIn("CRITICAL", summary) + self.assertIn("TST", summary) + self.assertIn("85/100", summary) + self.assertIn("1 clusters", summary) + self.assertIn("3 entities", summary) + + def test_bundled_buy_to_dict(self): + buy = BundledBuy( + wallet="abc123", + amount_usd=5000.0, + buy_block=100000, + buy_timestamp=1234567890.0, + tx_hash="0xdeadbeef", + funding_source="cex_1", + is_sniper=True, + ) + d = buy.to_dict() + self.assertEqual(d["wallet"], "abc123") + self.assertEqual(d["amount_usd"], 5000.0) + self.assertTrue(d["is_sniper"]) + + def test_holder_cluster_to_dict(self): + cluster = HolderCluster( + wallets=["w1", "w2", "w3"], + total_supply_pct=45.0, + funding_overlap_score=0.9, + buy_time_similarity=0.85, + common_funding_source="cex_shared", + ) + d = cluster.to_dict() + self.assertEqual(d["wallet_count"], 3) + self.assertEqual(d["total_supply_pct"], 45.0) + + +class TestBundlerDetector(unittest.TestCase): + """Test main BundlerDetector class.""" + + def setUp(self): + self.detector = BundlerDetector() + + def tearDown(self): + asyncio.run(self.detector.close()) + + # ── Address Validation ────────────────────────── + + def test_validate_address_evm(self): + self.assertTrue(self.detector._validate_address("0x1234567890123456789012345678901234567890", "ethereum")) + self.assertFalse(self.detector._validate_address("invalid_address", "ethereum")) + + def test_validate_address_solana(self): + valid = "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLsLvDt2BQHpM" + self.assertTrue(self.detector._validate_address(valid, "solana")) + self.assertFalse(self.detector._validate_address("0xshort", "solana")) + + def test_validate_address_unsupported_chain(self): + """Address format validation is chain-agnostic; scan() rejects unsupported chains.""" + # Format validation passes for any valid EVM address regardless of chain string + self.assertTrue(self.detector._validate_address("0x1234567890123456789012345678901234567890", "bitcoin")) + # But scan() will reject unsupported chains + report = asyncio.run(self.detector.scan("0x1234567890123456789012345678901234567890", "bitcoin")) + self.assertEqual(report.risk_label, "error") + self.assertIn("unsupported chain", str(report.errors).lower()) + + # ── Supply Concentration ──────────────────────── + + def test_compute_top_holder_pct(self): + holders = [ + {"address": "a1", "percentage": 50.0}, + {"address": "a2", "percentage": 30.0}, + {"address": "a3", "percentage": 15.0}, + {"address": "a4", "percentage": 5.0}, + ] + self.assertEqual(self.detector._compute_top_holder_pct(holders, 3), 95.0) + self.assertEqual(self.detector._compute_top_holder_pct(holders, 1), 50.0) + + def test_compute_top_holder_pct_empty(self): + self.assertEqual(self.detector._compute_top_holder_pct([], 10), 0.0) + + def test_extract_dev_hold_pct(self): + holders = [{"address": "dev", "percentage": 25.0}] + self.assertEqual(self.detector._extract_dev_hold_pct(holders, {}), 25.0) + + def test_extract_dev_hold_pct_empty(self): + self.assertEqual(self.detector._extract_dev_hold_pct([], {}), 0.0) + + # ── Clustering ───────────────────────────────── + + def test_cluster_wallets_top_only(self): + """Top 3 holders controlling >60% form a cluster.""" + holders = [ + {"address": "whale1", "percentage": 35.0}, + {"address": "whale2", "percentage": 25.0}, + {"address": "whale3", "percentage": 15.0}, + {"address": "small", "percentage": 2.0}, + ] + clusters = self.detector._cluster_wallets([], holders) + self.assertEqual(len(clusters), 1) + self.assertIn("top_holders_cluster", clusters[0].common_funding_source) + + def test_cluster_wallets_middle_belt(self): + """5+ holders with 2-15% each form a mid-holder belt cluster.""" + holders = [{"address": f"mid{i}", "percentage": 4.0} for i in range(10)] + clusters = self.detector._cluster_wallets([], holders) + self.assertEqual(len(clusters), 1) + self.assertIn("mid_holder_belt", clusters[0].common_funding_source) + + def test_cluster_wallets_empty(self): + self.assertEqual(self.detector._cluster_wallets([], []), []) + + # ── Entity Estimation ────────────────────────── + + def test_estimate_entities(self): + clusters = [ + HolderCluster( + wallets=["w1", "w2", "w3"], + total_supply_pct=60.0, + funding_overlap_score=0.8, + buy_time_similarity=0.9, + ) + ] + entities = self.detector._estimate_entities( + [{"address": f"h{i}"} for i in range(20)], + clusters, + 0, + ) + # 20 holders - 3 in cluster = 17 + 1 for the cluster + self.assertEqual(entities, 17) + + # ── Scoring ──────────────────────────────────── + + def test_score_supply_concentration_critical(self): + holders = [{"percentage": p} for p in [90.0, 5.0, 2.0, 1.0, 1.0, 0.5, 0.3, 0.1, 0.05, 0.05]] + score = self.detector._score_supply_concentration(holders, 100.0) + self.assertGreaterEqual(score, 75) + + def test_score_supply_concentration_low(self): + holders = [{"percentage": p} for p in [10.0, 8.0, 7.0, 6.0, 5.0, 5.0, 5.0, 4.0, 4.0, 4.0]] + score = self.detector._score_supply_concentration(holders, 58.0) + self.assertLess(score, 75) + + def test_compute_bundler_score_weights(self): + report = BundlerReport( + token_address="0x123", + chain="ethereum", + supply_concentration_score=100.0, + sniper_cluster_score=100.0, + launch_timing_anomaly_score=100.0, + fund_flow_risk_score=100.0, + ) + score = self.detector._compute_bundler_score(report) + self.assertEqual(score, 100.0) + + def test_compute_bundler_score_half(self): + report = BundlerReport( + token_address="0x123", + chain="ethereum", + supply_concentration_score=50.0, + sniper_cluster_score=50.0, + launch_timing_anomaly_score=50.0, + fund_flow_risk_score=50.0, + ) + score = self.detector._compute_bundler_score(report) + self.assertEqual(score, 50.0) + + # ── Launch Timing ────────────────────────────── + + def test_analyze_launch_timing_high_concentration(self): + buys = [ + {"m5_buys": 80, "h1_buys": 20, "h6_buys": 5, "pair_address": "0xpair"}, + ] + result = self.detector._analyze_launch_timing(buys) + self.assertGreater(result["buy_concentration_ratio"], 0.4) + + def test_analyze_launch_timing_empty(self): + result = self.detector._analyze_launch_timing([]) + self.assertEqual(result["total_buys_first_blocks"], 0) + + # ── Quick Check ──────────────────────────────── + + def test_quick_check_invalid_address(self): + result = asyncio.run(self.detector.quick_check("bad", "ethereum")) + self.assertIn("error", result) + + def test_quick_check_no_holders(self): + with ( + patch.object(self.detector, "_fetch_holders", return_value=[]), + patch.object(self.detector, "_fetch_metadata", return_value={"name": "TST", "symbol": "TST"}), + ): + result = asyncio.run(self.detector.quick_check("0x1234567890123456789012345678901234567890", "ethereum")) + self.assertIn("error", result) + + +class TestEdgeCases(unittest.TestCase): + """Test edge cases for robustness.""" + + def test_gini_single_value(self): + """Single value should not crash.""" + self.assertAlmostEqual(_gini_coefficient([100.0]), 0.0, places=2) + + def test_entropy_single_value(self): + """Single value → entropy = 1.0 (trivially uniform).""" + self.assertAlmostEqual(_entropy([100.0]), 1.0, places=2) + + def test_entropy_all_zeros(self): + """All zeros → 0.0.""" + self.assertEqual(_entropy([0.0] * 5), 0.0) + + def test_time_cluster_similarity_two_fast(self): + """Two buys within 1 second → high similarity.""" + self.assertGreater(_time_cluster_similarity([1000.0, 1001.0]), 0.8) + + def test_time_cluster_similarity_wide(self): + """Buys spread over 1 hour → very low similarity.""" + ts = [0.0, 600.0, 1200.0, 1800.0, 3600.0] + self.assertLess(_time_cluster_similarity(ts), 0.2) + + def test_funding_overlap_empty(self): + """Empty list → 0.0.""" + self.assertEqual(_funding_overlap([]), 0.0) + + def test_funding_overlap_single(self): + """Single source → 0.0.""" + self.assertEqual(_funding_overlap(["only_source"]), 0.0) + + def test_bundler_report_no_clusters(self): + """Report without clusters should not crash on summary.""" + report = BundlerReport( + token_address="0xabc", + chain="solana", + ) + s = report.summary() + self.assertIn("NONE", s) + self.assertIn("0 clusters", s) + + def test_launch_timing_handles_missing_keys(self): + """Buys with missing keys should not crash.""" + detector = BundlerDetector() + buys = [ + {"m5_buys": 10}, # missing h1_buys, h6_buys + ] + result = detector._analyze_launch_timing(buys) + self.assertEqual(result["total_buys_first_blocks"], 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/_archive/legacy_2026_07/test_clone_scanner.py b/app/_archive/legacy_2026_07/test_clone_scanner.py new file mode 100644 index 0000000..f94cbeb --- /dev/null +++ b/app/_archive/legacy_2026_07/test_clone_scanner.py @@ -0,0 +1,293 @@ +""" +Tests for Token Clone Scanner (clone_scanner.py) + +Tests the core logic: string similarity, name similarity scoring, +risk labeling, data models, and fast_check surrogate. +Network tests are skipped in offline mode. +""" + +import os +import sys +import unittest + +# Add parent to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.clone_scanner import ( + CloneReport, + CloneScanner, + SimilarToken, + name_similarity_score, + string_similarity, +) + + +class TestStringHelpers(unittest.TestCase): + """Core string similarity utilities.""" + + def test_identical_strings(self): + self.assertEqual(string_similarity("Pepe", "Pepe"), 1.0) + + def test_case_insensitive(self): + self.assertAlmostEqual(string_similarity("Pepe", "pepe"), 1.0) + + def test_completely_different(self): + self.assertAlmostEqual(string_similarity("Pepe", "Bitcoin"), 0.0, places=1) + + def test_partial_match(self): + sim = string_similarity("Pepe", "Pepe 2.0") + self.assertGreater(sim, 0.3) + self.assertLess(sim, 1.0) + + def test_empty_strings(self): + self.assertEqual(string_similarity("", "Pepe"), 0.0) + self.assertEqual(string_similarity("Pepe", ""), 0.0) + self.assertEqual(string_similarity("", ""), 0.0) + + +class TestNameSimilarity(unittest.TestCase): + """Name similarity scoring with clone patterns.""" + + def test_identical_names(self): + self.assertEqual(name_similarity_score("Pepe", "Pepe"), 1.0) + + def test_prefix_clone(self): + """'Baby Pepe' should score high vs 'Pepe'.""" + score = name_similarity_score("Baby Pepe", "Pepe") + self.assertGreaterEqual(score, 0.65) + + def test_suffix_clone(self): + """'Pepe v2' should score very high vs 'Pepe'.""" + score = name_similarity_score("Pepe v2", "Pepe") + self.assertGreaterEqual(score, 0.85) + + def test_known_v2_pattern(self): + score = name_similarity_score("SafeMoon", "SafeMoon 2.0") + self.assertGreaterEqual(score, 0.85) + + def test_different_tokens(self): + score = name_similarity_score("Bitcoin", "Pepe") + self.assertLess(score, 0.3) + + +class TestCloneReport(unittest.TestCase): + """CloneReport data model and helpers.""" + + def test_to_dict_structure(self): + report = CloneReport( + token_address="0xabc123", + chain="ethereum", + name="TestToken", + symbol="TEST", + clone_score=45.5, + risk_label="medium", + similar_tokens=[ + SimilarToken( + address="0xdef456", + chain="ethereum", + name="TestToken Clone", + symbol="TSTC", + name_similarity=0.85, + symbol_similarity=0.6, + ) + ], + matched_keywords=["pepe"], + unverified_contract=True, + same_deployer_other_tokens=12, + ) + d = report.to_dict() + self.assertEqual(d["token_address"], "0xabc123") + self.assertEqual(d["clone_score"], 45.5) + self.assertEqual(d["risk_label"], "medium") + self.assertEqual(len(d["similar_tokens"]), 1) + self.assertEqual(d["matched_keywords"], ["pepe"]) + self.assertTrue(d["unverified_contract"]) + self.assertEqual(d["same_deployer_other_tokens"], 12) + + # Check signals sub-dict + self.assertIn("bytecode_similarity", d["signals"]) + self.assertIn("name_similarity", d["signals"]) + self.assertIn("deployer_risk", d["signals"]) + self.assertIn("metadata_risk", d["signals"]) + + def test_summary_format(self): + report = CloneReport( + token_address="0xabc123def4567890123456789012345678901234", + chain="ethereum", + name="ShadyToken", + symbol="SHADY", + clone_score=72.3, + risk_label="high", + similar_tokens=[ + SimilarToken( + address="0xdead", + chain="ethereum", + name="RealToken", + symbol="REAL", + name_similarity=0.9, + ) + ], + matched_keywords=["pepe", "moon"], + unverified_contract=True, + ) + summary = report.summary() + self.assertIn("HIGH", summary) + self.assertIn("72", summary) + self.assertIn("ShadyToken", summary) + self.assertIn("1 similar tokens", summary) + + def test_risk_label_none(self): + report = CloneReport(token_address="0xaaa", chain="ethereum", clone_score=0.0) + scanner = CloneScanner() + report.risk_label = scanner._label_risk(0.0) + self.assertEqual(report.risk_label, "none") + + def test_risk_label_critical(self): + report = CloneReport(token_address="0xaaa", chain="ethereum", clone_score=85.0) + scanner = CloneScanner() + report.risk_label = scanner._label_risk(85.0) + self.assertEqual(report.risk_label, "critical") + + def test_risk_label_brackets(self): + report = CloneReport(token_address="0xaaa", chain="ethereum", clone_score=25.0) + scanner = CloneScanner() + report.risk_label = scanner._label_risk(25.0) + self.assertEqual(report.risk_label, "medium") + + +class TestSimilarToken(unittest.TestCase): + """SimilarToken helper properties.""" + + def test_overall_similarity(self): + t = SimilarToken( + address="0xabc", + chain="ethereum", + name="TokenA", + symbol="TKA", + name_similarity=0.9, + symbol_similarity=0.5, + ) + self.assertEqual(t.overall_similarity, 0.9) + + def test_overall_uses_symbol_when_higher(self): + t = SimilarToken( + address="0xabc", + chain="ethereum", + name="TokenA", + symbol="TKA", + name_similarity=0.4, + symbol_similarity=0.95, + ) + self.assertEqual(t.overall_similarity, 0.95) + + +class TestScannerScoring(unittest.TestCase): + """Unit tests for scoring methods (no network).""" + + def setUp(self): + self.scanner = CloneScanner() + + def test_score_bytecode_unverified(self): + score = self.scanner._score_bytecode_risk(True, {}) + self.assertEqual(score, 40) # 40 for unverified + + def test_score_bytecode_verified(self): + score = self.scanner._score_bytecode_risk(False, {"other_unverified": 0}) + self.assertEqual(score, 0.0) + + def test_score_bytecode_mass_deployer(self): + score = self.scanner._score_bytecode_risk(True, {"other_unverified": 10, "other_tokens": 30}) + # 40 (unverified) + 20 (many unverified) + 15 (mass deployer) = 75 + self.assertEqual(score, 75) + + def test_score_name_no_keywords(self): + score = self.scanner._score_name_similarity([], []) + self.assertEqual(score, 0.0) + + def test_score_name_with_keywords(self): + score = self.scanner._score_name_similarity([], ["pepe", "moon"]) + self.assertEqual(score, 25) + + def test_score_name_with_high_similarity(self): + t = SimilarToken("0x1", "eth", "Clone", "CLO", name_similarity=0.95) + score = self.scanner._score_name_similarity([t], []) + # 1 high-sim match = 20 + self.assertEqual(score, 20) + + def test_score_deployer_low(self): + score = self.scanner._score_deployer_risk({"other_tokens": 2}) + self.assertEqual(score, 0.0) + + def test_score_deployer_medium(self): + score = self.scanner._score_deployer_risk({"other_tokens": 10}) + self.assertEqual(score, 15) + + def test_score_deployer_high(self): + score = self.scanner._score_deployer_risk({"other_tokens": 30}) + self.assertEqual(score, 30) + + def test_score_deployer_critical(self): + score = self.scanner._score_deployer_risk({"other_tokens": 100}) + self.assertEqual(score, 40) + + def test_score_deployer_clone_flag(self): + score = self.scanner._score_deployer_risk({"other_tokens": 5, "clone_deployer": True}) + # 5 other tokens = no bonus (needs >20), +30 clone flag = 30 + self.assertEqual(score, 30) + + def test_score_metadata_missing(self): + score = self.scanner._score_metadata_risk({}, [], []) + # Empty dict = no name or symbol = 20+20 = 40 + self.assertEqual(score, 40) + + def test_score_metadata_with_social(self): + score = self.scanner._score_metadata_risk({"name": "T", "symbol": "S", "social": {"twitter": "@x"}}, [], []) + self.assertEqual(score, 10) # missing website + + def test_score_metadata_full(self): + score = self.scanner._score_metadata_risk( + { + "name": "Test", + "symbol": "TST", + "social": {"twitter": "@x", "telegram": "@g"}, + "website": "https://t.com", + }, + [], + [], + ) + self.assertEqual(score, 0.0) + + def test_compute_composite_score(self): + report = CloneReport( + token_address="0x1", + chain="eth", + bytecode_similarity_score=100, + name_similarity_score=50, + deployer_risk_score=0, + metadata_risk_score=0, + ) + # 0.25*100 + 0.35*50 + 0.25*0 + 0.15*0 = 25 + 17.5 = 42.5 + self.assertEqual(self.scanner._compute_clone_score(report), 42.5) + + def test_well_known_by_name(self): + self.assertTrue(self.scanner._is_well_known("Bitcoin", "BTC")) + self.assertTrue(self.scanner._is_well_known("Ethereum", "ETH")) + + def test_well_known_by_symbol(self): + self.assertTrue(self.scanner._is_well_known("Random", "ETH")) + + def test_not_well_known(self): + self.assertFalse(self.scanner._is_well_known("ShadyCoin", "SHADY")) + + def test_valid_evm_address(self): + self.assertTrue(self.scanner._validate_address("0x1234567890abcdef1234567890abcdef12345678", "ethereum")) + + def test_valid_solana_address(self): + self.assertTrue(self.scanner._validate_address("So11111111111111111111111111111111111111112", "solana")) + + def test_invalid_address(self): + self.assertFalse(self.scanner._validate_address("not-an-address", "ethereum")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/app/_archive/legacy_2026_07/test_mev_protection.py b/app/_archive/legacy_2026_07/test_mev_protection.py new file mode 100644 index 0000000..eb1a103 --- /dev/null +++ b/app/_archive/legacy_2026_07/test_mev_protection.py @@ -0,0 +1,226 @@ +""" +Tests for mev_protection.py - MEV Shield Analysis +""" + +import asyncio +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.mev_protection import ( + MevFactor, + MevRiskLevel, + MevShieldResult, + _check_gas_price_risk, + _check_historical_mev, + _check_mempool_risk, + _check_pool_liquidity, + _check_slippage_risk, + _check_timing_risk, + analyze_transaction_risk, + mev_shield_analysis, +) + + +async def test_mempool_risk() -> None: + """Test mempool activity risk assessment.""" + print("🧪 test_mempool_risk") + result = await _check_mempool_risk("ethereum") + assert isinstance(result, MevFactor) + assert 0.0 <= result.score <= 1.0 + assert result.name == "mempool_activity" + print(f" Score: {result.score:.2f} - {result.finding}") + print(f" Suggestion: {result.suggestion}") + print(" ✅ PASS") + + +async def test_slippage_risk() -> None: + """Test slippage tolerance assessment.""" + print("\n🧪 test_slippage_risk") + + # Low slippage (0.5%) + low = await _check_slippage_risk(50) + assert low.score <= 0.3 + print(f" Low (0.5%): score={low.score:.2f} - {low.finding}") + + # Medium slippage (1%) + med = await _check_slippage_risk(100) + assert 0.3 <= med.score <= 0.7 + print(f" Med (1.0%): score={med.score:.2f} - {med.finding}") + + # High slippage (3%+) + high = await _check_slippage_risk(500) + assert high.score >= 0.7 + print(f" High (5%): score={high.score:.2f} - {high.finding}") + + print(" ✅ PASS") + + +async def test_gas_price_risk() -> None: + """Test gas price risk assessment.""" + print("\n🧪 test_gas_price_risk") + + # Low slippage (0.5%) + low = await _check_gas_price_risk(5.0) + assert low.score <= 0.3 + print(f" Low (5 gwei): score={low.score:.2f} - {low.finding}") + + med = await _check_gas_price_risk(25.0) + assert 0.3 <= med.score <= 0.6 + print(f" Med (25 gwei): score={med.score:.2f} - {med.finding}") + + high = await _check_gas_price_risk(75.0) + assert high.score >= 0.6 + print(f" High (75 gwei): score={high.score:.2f} - {high.finding}") + + print(" ✅ PASS") + + +async def test_timing_risk() -> None: + """Test timing-based risk assessment.""" + print("\n🧪 test_timing_risk") + result = await _check_timing_risk() + assert isinstance(result, MevFactor) + assert 0.0 <= result.score <= 1.0 + print(f" Score: {result.score:.2f} - {result.finding}") + print(f" Suggestion: {result.suggestion}") + print(" ✅ PASS") + + +async def test_historical_mev() -> None: + """Test historical MEV check (fallback mode, no actual detector loaded).""" + print("\n🧪 test_historical_mev (fallback - no real detector)") + result = await _check_historical_mev("ethereum") + assert isinstance(result, MevFactor) + assert result.score > 0 # Should have default chain risk score + print(f" Score: {result.score:.2f} - {result.finding}") + print(" ✅ PASS") + + +async def test_liquidity_risk() -> None: + """Test pool liquidity assessment (no real pair - should return unknown).""" + print("\n🧪 test_liquidity_risk (no pair address)") + result = await _check_pool_liquidity(None, "ethereum") + assert isinstance(result, MevFactor) + assert result.name == "pool_liquidity" + print(f" Score: {result.score:.2f} - {result.finding}") + print(" ✅ PASS") + + +async def test_full_analysis() -> None: + """Test full MEV Shield analysis with default params.""" + print("\n🧪 test_full_analysis") + result = await analyze_transaction_risk( + chain="ethereum", + slippage_bps=100, + gas_price_gwei=15.0, + ) + assert isinstance(result, MevShieldResult) + assert isinstance(result.risk_level, MevRiskLevel) + assert 0.0 <= result.overall_score <= 1.0 + assert len(result.factors) == 6 + assert len(result.protection_strategies) > 0 + assert result.estimated_loss_bps > 0 + + print(f" Risk Level: {result.risk_level.value.upper()}") + print(f" Overall Score: {result.overall_score:.2f}") + print(f" Est. Loss: {result.estimated_loss_bps} bps") + + for f in result.factors: + print(f" {f.name:25s} {f.score:.2f} - {f.finding}") + + print(" ✅ PASS") + + +async def test_full_high_risk() -> None: + """Test full analysis with high-risk params.""" + print("\n🧪 test_full_high_risk") + result = await analyze_transaction_risk( + chain="ethereum", + slippage_bps=500, # 5% - reckless + gas_price_gwei=200.0, # Premium gas + ) + assert result.risk_level in (MevRiskLevel.HIGH, MevRiskLevel.CRITICAL) + assert len(result.protection_strategies) >= 2 + print(f" Risk Level: {result.risk_level.value.upper()}") + print(f" Overall Score: {result.overall_score:.2f}") + print(" ✅ PASS") + + +async def test_sync_wrapper() -> None: + """Test the synchronous wrapper function.""" + print("\n🧪 test_sync_wrapper") + result = mev_shield_analysis( + chain="ethereum", + slippage_bps=100, + gas_price_gwei=10.0, + ) + assert isinstance(result, dict) + assert "risk_level" in result + assert "factors" in result + assert "protection_strategies" in result + assert "estimated_loss_bps" in result + print(f" Risk Level: {result['risk_level']}") + print(f" Loss (bps): {result['estimated_loss_bps']}") + print(" ✅ PASS") + + +async def test_result_to_dict() -> None: + """Test serialization.""" + print("\n🧪 test_result_to_dict") + result = await analyze_transaction_risk( + chain="bsc", + slippage_bps=200, + gas_price_gwei=5.0, + ) + d = result.to_dict() + assert isinstance(d, dict) + assert json.dumps(d) # Must be JSON-serializable + assert "factors" in d + assert len(d["factors"]) == 6 + print(f" JSON output: {json.dumps(d, indent=2)[:200]}...") + print(" ✅ PASS") + + +async def main() -> bool: + print("=" * 60) + print("MEV Shield Analysis - Test Suite") + print("=" * 60) + + tests = [ + test_mempool_risk, + test_slippage_risk, + test_gas_price_risk, + test_timing_risk, + test_historical_mev, + test_liquidity_risk, + test_full_analysis, + test_full_high_risk, + test_sync_wrapper, + test_result_to_dict, + ] + + passed = 0 + failed = 0 + + for test_fn in tests: + try: + await test_fn() + passed += 1 + except Exception as e: + print(f" ❌ FAIL: {e}") + import traceback + + traceback.print_exc() + failed += 1 + + print(f"\n{'=' * 60}") + print(f"Results: {passed} passed, {failed} failed") + return failed == 0 + + +if __name__ == "__main__": + success = asyncio.run(main()) + sys.exit(0 if success else 1) diff --git a/app/_archive/legacy_2026_07/test_rug_pull_predictor.py b/app/_archive/legacy_2026_07/test_rug_pull_predictor.py new file mode 100644 index 0000000..cb15cc6 --- /dev/null +++ b/app/_archive/legacy_2026_07/test_rug_pull_predictor.py @@ -0,0 +1,99 @@ +"""Tests for rug_pull_predictor - Exit Scam Forecaster.""" + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.rug_pull_predictor import ( + RiskLevel, + SignalResult, + analyze, + is_valid_address, + predict_rug_pull, +) + + +def test_validator() -> None: + """Address validation works correctly.""" + assert is_valid_address("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") is True + assert is_valid_address("So11111111111111111111111111111111111111112") is True + assert is_valid_address("not-an-address") is False + assert is_valid_address("") is False + + +def test_risk_level_enum() -> None: + """RiskLevel enum has all expected values.""" + assert RiskLevel.SAFE.value == "safe" + assert RiskLevel.LOW.value == "low" + assert RiskLevel.MEDIUM.value == "medium" + assert RiskLevel.HIGH.value == "high" + assert RiskLevel.CRITICAL.value == "critical" + + +def test_signal_weighted_score() -> None: + """SignalResult weighted_score calculation is correct.""" + s = SignalResult(signal="test", score=80.0, weight=0.25) + assert s.weighted_score == 20.0 # 80 * 0.25 + assert s.level == RiskLevel.CRITICAL # 80 >= 80 threshold + + s2 = SignalResult(signal="test2", score=79.9, weight=0.5) + assert s2.level == RiskLevel.HIGH + + +def test_analyze_structure() -> None: + """Analyze returns expected dict structure.""" + result = analyze( + "So11111111111111111111111111111111111111112", + chain="solana", + ) + assert isinstance(result, dict) + assert "rug_score" in result + assert "risk_level" in result + assert "signals" in result + assert "summary" in result + assert "recommendations" in result + assert isinstance(result["rug_score"], float) + assert 0 <= result["rug_score"] <= 100 + + +def test_analyze_live() -> None: + """End-to-end test with real data (WSOL).""" + result = analyze( + "So11111111111111111111111111111111111111112", + chain="solana", + ) + print(f"\nWSOL Rug Score: {result['rug_score']} ({result['risk_level']})") + print(f"Signals: {len(result['signals'])}") + for sig in result["signals"]: + print(f" {sig['name']}: {sig['score']} ({sig['level']})") + # WSOL should be low risk + assert result["risk_level"] in ("safe", "low") + + +def test_invalid_address() -> None: + """Invalid address raises ValueError.""" + try: + asyncio.run(predict_rug_pull("not-an-address")) + raise AssertionError("Should have raised ValueError") + except ValueError: + pass + + +def test_analyze_exported() -> None: + """analyze is exported in __all__.""" + from app.rug_pull_predictor import __all__ + + assert "analyze" in __all__ + + +if __name__ == "__main__": + test_validator() + test_risk_level_enum() + test_signal_weighted_score() + test_invalid_address() + test_analyze_exported() + test_analyze_structure() + test_analyze_live() + print("\nAll tests passed!") diff --git a/app/_archive/legacy_2026_07/tiers.py b/app/_archive/legacy_2026_07/tiers.py new file mode 100644 index 0000000..a76ce1a --- /dev/null +++ b/app/_archive/legacy_2026_07/tiers.py @@ -0,0 +1,165 @@ +# ═══════════════════════════════════════════ +# 4-TIER PRICING - Competitive with market +# ═══════════════════════════════════════════ +# CoinGecko API: Free 30/min, Pro $129/mo +# CoinMarketCap: Free 10K/mo, Pro $79/mo +# Moralis: Free 40K/day, Pro $49/mo +# Alchemy: Free 300M CU/mo, Growth $49/mo +# RMI: Free generous, Pro $25 undercuts everyone, Elite $69 with AI, Power $149 white-label + +import time +from enum import StrEnum + +from fastapi import APIRouter, Header, HTTPException + +router = APIRouter(prefix="/api/v1/billing", tags=["billing"]) + + +class Tier(StrEnum): + FREE = "free" + PRO = "pro" + ELITE = "elite" + POWER = "power" + + +TIERS = { + Tier.FREE: { + "price_usd": 0, + "req_per_day": 100, + "req_per_min": 10, + "sentinel_scans_day": 5, + "ai_calls_day": 20, + "features": ["basic_scan", "market_data", "fear_greed", "trending"], + }, + Tier.PRO: { + "price_usd": 25, + "req_per_day": 10000, + "req_per_min": 60, + "sentinel_scans_day": 100, + "ai_calls_day": 500, + "features": ["deep_scan", "token_report", "whale_alerts", "ai_chat", "api_access", "email_alerts", "sentiment"], + }, + Tier.ELITE: { + "price_usd": 69, + "req_per_day": 50000, + "req_per_min": 200, + "sentinel_scans_day": 500, + "ai_calls_day": 2000, + "features": [ + "all_pro_features", + "insider_detection", + "arbitrage_scanner", + "mev_advisory", + "prediction_markets", + "signal_generator", + "ai_streaming", + "vision_analysis", + "nft_detector", + "chain_compare", + ], + }, + Tier.POWER: { + "price_usd": 149, + "req_per_day": 200000, + "req_per_min": 500, + "sentinel_scans_day": 2000, + "ai_calls_day": 10000, + "features": [ + "all_features", + "white_label", + "custom_models", + "dedicated_support", + "sla_guarantee", + "on_prem_deploy", + "api_key_management", + "team_accounts", + "audit_logs", + ], + }, + "enterprise": { + "price_usd": "Custom", + "req_per_day": "Unlimited", + "req_per_min": "Unlimited", + "sentinel_scans_day": "Unlimited", + "ai_calls_day": "Unlimited", + "features": [ + "all_power_features", + "unlimited_everything", + "custom_sla", + "dedicated_infrastructure", + "on_prem_deploy", + "source_code_access", + "priority_support_24_7", + ], + "contact": "biz@rugmunch.io", + "note": "Contact us for enterprise pricing.", + }, +} + +# Payment options - 16 chains, crypto only +PAYMENTS = { + "solana": [ + {"token": "SOL", "wallet": "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv"}, + {"token": "USDC", "wallet": "G1uZFfsdxK2PuH2Em5LhRkEcptxHWe4vy5BZak67QyKv"}, + ], + "ethereum": [ + {"token": "ETH", "wallet": "0x1E3AC01d0fdb976179790BDD02823196A92705C9"}, + {"token": "USDC", "wallet": "0x1E3AC01d0fdb976179790BDD02823196A92705C9"}, + {"token": "USDT", "wallet": "0x1E3AC01d0fdb976179790BDD02823196A92705C9"}, + ], + "bitcoin": [{"token": "BTC", "wallet": "bc1qzkllckr024wqttm4px6egcwsya9tcrgghm46kq"}], + "tron": [ + {"token": "TRX", "wallet": "TPskriENxSyXGC39waUwDwCZebeE5QVnLh"}, + {"token": "USDT", "wallet": "TPskriENxSyXGC39waUwDwCZebeE5QVnLh"}, + ], + "xrp": [{"token": "XRP", "wallet": "rG62QiXrbqbsSUimUyxLCLRybLmu6ykUH9"}], + "ton": [{"token": "TON", "wallet": "UQDYJBI8NA1waorcZG_cOnLik73ZlQbFhNST1_nPzgynWzvB"}], + "stellar": [{"token": "XLM", "wallet": "GB2KF5GCT4ZBYNRTMOYBJXJO7JKAABBVX6VTNRO6P5KGYIP5CG5QUI24"}], + "sui": [{"token": "SUI", "wallet": "0xf51639da22d7427afeb7bd152002187346a634e4487e3ddd0e637804a47c6aad"}], + "monad": [{"token": "MONAD", "wallet": "0x01ffa655FBc2aE5433A21fa7aB2e5aB158008C84"}], + "litecoin": [{"token": "LTC", "wallet": "ltc1qtuvtp5njgxpag87kdefp3agst6jtfr2vauejtv"}], + "dogecoin": [{"token": "DOGE", "wallet": "DDoHrBgHwfQojTYYMdsDUVVaBuE6bvD1WQ"}], + "cardano": [ + { + "token": "ADA", + "wallet": "addr1q8hh8ptw7qvp4j84jredg66vah0d9pyyt4fzl8kcu94sxyxdg7z7sdg27448vv4d0wehwhycxmwfuqwkr8y4a5lls24sdl66wg", + } + ], + "zcash": [{"token": "ZEC", "wallet": "t1YDeXgob88VsDJn1Qn2dTWNHdMCtvXa6Ko"}], + "polkadot": [{"token": "DOT", "wallet": "14cWAqWmTCQqTCEG6FySdnCayQbGU8M4gP9vsmm7W8A8KB5Z"}], + "near": [{"token": "NEAR", "wallet": "d605f4401464aaa0b21bf35cfb1d28c1743b7ade39e459f432084860afda8ab4"}], + "aptos": [{"token": "APT", "wallet": "0xe8d2b815aea16fdf1e11b41e974818c454313403c57948c35f8928dda20b304"}], + "x402": [{"note": "Pay per API call. No subscription. $0.01-$0.25/call."}], +} + +_inmem_users: dict[str, dict] = {} + + +def get_user_tier(user_id: str) -> Tier: + if user_id in _inmem_users: + return Tier(_inmem_users[user_id]["tier"]) + return Tier.FREE + + +@router.get("/tiers") +async def get_tiers(): + return { + "tiers": {t.value: v for t, v in TIERS.items() if isinstance(t, Tier)}, + "enterprise": TIERS["enterprise"], + "payments": PAYMENTS, + } + + +@router.get("/me") +async def my_tier(x_api_key: str = Header(None)): + uid = x_api_key or "anonymous" + tier = get_user_tier(uid) + return {"user": uid, "tier": tier.value, "limits": TIERS[tier], "upgrade": "/api/v1/billing/upgrade"} + + +@router.post("/upgrade/{tier}") +async def upgrade(tier: str, user_id: str, tx_sig: str = ""): + if tier not in [t.value for t in Tier]: + raise HTTPException(400, "Invalid tier") + _inmem_users[user_id] = {"tier": tier, "tx": tx_sig, "upgraded_at": time.time()} + return {"status": "upgraded", "tier": tier, "price": TIERS[Tier(tier)]["price_usd"]} diff --git a/app/_archive/legacy_2026_07/tool_changelog.py b/app/_archive/legacy_2026_07/tool_changelog.py new file mode 100644 index 0000000..44780d9 --- /dev/null +++ b/app/_archive/legacy_2026_07/tool_changelog.py @@ -0,0 +1,286 @@ +""" +RMI Tool Changelog & Version System +===================================== +Tracks tool versions, changes, and deprecations. +Provides transparency for tool quality and evolution. + +Endpoints: + GET /api/v1/tools/changelog - Full changelog + GET /api/v1/tools/changelog/{tool} - Changelog for specific tool + GET /api/v1/tools/version/{tool} - Current version for tool + GET /api/v1/tools/deprecated - List of deprecated tools + +Author: RMI Development +Date: 2026-06-05 +""" + +import json +import logging +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from app.core.redis import get_redis + +logger = logging.getLogger("tool_changelog") + +router = APIRouter(prefix="/api/v1/tools", tags=["tool-changelog"]) + + +# ── Redis Helper ───────────────────────────────────────────────── + + +INITIAL_CHANGELOG = [ + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "feature", + "tool": "whale_copy_trade", + "title": "New Tool: Whale Copy Trade Engine", + "description": "Real-time copy trade engine - input a smart money wallet, get their exact last 24h trades with entry/exit prices, PnL, and suggested follow trades.", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "feature", + "tool": "rug_predictor_live", + "title": "New Tool: Live Rug Predictor", + "description": "Analyzes tokens in their first 5-30 minutes with 6-signal rug probability scoring. Detects low liquidity, unlocked LP, price crashes, mass selling, and more.", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "feature", + "tool": "whale_cluster", + "title": "New Tool: Whale Cluster Detection", + "description": "Identify coordinated whale clusters - wallets that move together via same funding source, timing patterns, and token sets. Detects wash trading and insider networks.", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "infrastructure", + "tool": "*", + "title": "Developer Tier System", + "description": "Free developer API keys with 100 calls/day, no payment required. Tier system: FREE (100/day), TRIAL (3-5/tool), PAID (pay-per-use), PRO ($29/mo, 5000/day).", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "infrastructure", + "tool": "*", + "title": "Facilitator Health Monitoring", + "description": "60s health check loop for all payment facilitators with latency tracking, success rate monitoring, and auto-failover readiness.", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "infrastructure", + "tool": "*", + "title": "Public Status Page", + "description": "Public-facing status page at /api/v1/status showing health of all RMI services - backend, Redis, ClickHouse, MCP, facilitators. 30s monitoring loop.", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "infrastructure", + "tool": "*", + "title": "Webhook Notification Pipeline", + "description": "Real-time webhook delivery system with retry, signing, rate limiting, and dead letter queue. Polls every 5 seconds for new alerts.", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "infrastructure", + "tool": "*", + "title": "Persistent State Layer", + "description": "User watchlists, portfolios, saved scans, and alert history. Redis-backed with JSON serialization.", + "breaking": False, + }, + { + "date": "2026-06-05", + "version": "3.3.0", + "type": "improvement", + "tool": "*", + "title": "SDK v2.0 Released", + "description": "Complete rewrite with async/sync dual API, retry with exponential backoff, batch tool calls, webhook management, proper error classes, and type hints.", + "breaking": False, + }, + { + "date": "2026-06-01", + "version": "3.2.0", + "type": "improvement", + "tool": "*", + "title": "Response Enrichment Pipeline", + "description": "Every tool response now enriched with wallet labels, RAG similarity search, scam pattern detection, risk summary, intel briefing, and confidence scores.", + "breaking": False, + }, + { + "date": "2026-06-01", + "version": "3.2.0", + "type": "feature", + "tool": "composite_score", + "title": "New Tool: Composite Score", + "description": "One-number buy/sell/avoid score combining reputation, rug probability, market health, narrative sentiment, MEV exposure, and DeFi position.", + "breaking": False, + }, + { + "date": "2026-06-01", + "version": "3.2.0", + "type": "feature", + "tool": "smart_money", + "title": "New Tool: Smart Money Finder", + "description": "Find profitable traders via win rate estimation, token count, balance analysis, and wallet label cross-reference.", + "breaking": False, + }, + { + "date": "2026-05-28", + "version": "3.1.0", + "type": "infrastructure", + "tool": "*", + "title": "Multi-Facilitator Payment System", + "description": "Smart router auto-picks best facilitator per chain/token. 7 active facilitators across 11 payment chains.", + "breaking": False, + }, +] + + +# ── Changelog Management ───────────────────────────────────────── + + +def get_changelog(tool: str | None = None, limit: int = 50) -> list[dict[str, Any]]: + """Get changelog entries.""" + r = get_redis() + + # Load from Redis if available + entries = [] + if r: + stored = r.lrange("rmi:changelog", 0, -1) + entries = [json.loads(e) for e in stored] + + # If no stored entries, use initial data + if not entries: + entries = INITIAL_CHANGELOG.copy() + if r: + # Store initial entries + for entry in reversed(entries): + r.lpush("rmi:changelog", json.dumps(entry)) + + # Filter by tool + if tool: + entries = [e for e in entries if e.get("tool") == tool or e.get("tool") == "*"] + + # Limit + return entries[:limit] + + +def add_changelog_entry( + tool: str, + title: str, + description: str, + version: str = "", + change_type: str = "improvement", + breaking: bool = False, +): + """Add a changelog entry.""" + r = get_redis() + if not r: + return + + entry = { + "date": datetime.now(UTC).strftime("%Y-%m-%d"), + "version": version or "current", + "type": change_type, + "tool": tool, + "title": title, + "description": description, + "breaking": breaking, + } + + r.lpush("rmi:changelog", json.dumps(entry)) + r.ltrim("rmi:changelog", 0, 499) # Keep last 500 + + +def get_tool_version(tool: str) -> dict[str, Any]: + """Get current version info for a tool.""" + changelog = get_changelog(tool, limit=1) + if changelog: + entry = changelog[0] + return { + "tool": tool, + "version": entry.get("version", "unknown"), + "last_updated": entry.get("date", ""), + "last_change": entry.get("title", ""), + } + + # Default version + return { + "tool": tool, + "version": "1.0.0", + "last_updated": "unknown", + "last_change": "No changelog entries", + } + + +def get_deprecated_tools() -> list[dict[str, Any]]: + """Get list of deprecated tools.""" + r = get_redis() + if not r: + return [] + + deprecated = r.smembers("rmi:tools:deprecated") + return [json.loads(d) for d in deprecated] + + +# ── Endpoints ──────────────────────────────────────────────────── + + +@router.get("/changelog") +async def tool_changelog(tool: str | None = None, limit: int = 50): + """Get changelog entries.""" + entries = get_changelog(tool, limit) + return JSONResponse( + content={ + "changelog": entries, + "total": len(entries), + "filter": tool, + } + ) + + +@router.get("/changelog/{tool}") +async def tool_specific_changelog(tool: str, limit: int = 20): + """Get changelog for a specific tool.""" + entries = get_changelog(tool, limit) + return JSONResponse( + content={ + "tool": tool, + "changelog": entries, + "total": len(entries), + } + ) + + +@router.get("/version/{tool}") +async def tool_version(tool: str): + """Get current version for a tool.""" + return JSONResponse(content=get_tool_version(tool)) + + +@router.get("/deprecated") +async def deprecated_tools(): + """Get list of deprecated tools.""" + return JSONResponse( + content={ + "deprecated": get_deprecated_tools(), + "count": len(get_deprecated_tools()), + } + ) diff --git a/app/_archive/legacy_2026_07/tool_fingerprint.py b/app/_archive/legacy_2026_07/tool_fingerprint.py new file mode 100644 index 0000000..c49a7d3 --- /dev/null +++ b/app/_archive/legacy_2026_07/tool_fingerprint.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 +""" +RMI Tool Fingerprinter - Best-in-Class Scam Infrastructure Detection +===================================================================== +Identifies the TOOLS behind scams, not just the outcomes. +Phase 3-5 capabilities per the Enhanced Report V2 standards. + +Detects: + - Smithii bundler patterns (bundle + liquidity + first swap) + - Printr bundler signatures (multi-wallet coordinated launch) + - LaunchLab bundle bot (specific instruction ordering) + - Jito bundle detection (tip program, bundle construction) + - PumpFun sniper bots (limit-sniper, pumpfun-bonkfun-bot, etc.) + - Volume bot / wash trading patterns + - Wallet aging counter-detection + - Cross-chain fund obfuscation detection + - Exchange-funded deployer detection + - First buyer concentration analysis + - Scanner-aware evasion detection (Hide-and-Shill framework) + +Design principle: Tools change less frequently than tactics. +A Smithii-bundled token has detectable on-chain fingerprints +regardless of the scammer's chosen exit strategy. +""" + +import logging +from collections import defaultdict +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +import httpx + +logger = logging.getLogger("tool-fingerprinter") + +# ══════════════════════════════════════════════════════════════════════ +# KNOWN TOOL FINGERPRINTS - from reverse-engineered scammer infrastructure +# ══════════════════════════════════════════════════════════════════════ + +# Solana Program IDs used by known bundler/sniper tools +TOOL_PROGRAMS = { + "jito_bundle": [ + "Jito4APyf6rDt1pD1jH3nD3is4v7TwzFNWjjMP7B2RK", # Jito bundles v3 + "96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5", # Jito tip router v3 + "ADuCadRmgjMe6UzXVxR1Kn9CS2CXAf8bjiKkC4xFcegX", # Jito tip router v4 + ], + "pumpfun": [ + "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", # Pump.fun program + "CebN5WGQ4jvEPvsVU4EoHEpgzq1VV6AbFtJxHTNBaFUx", # Pump AMM + ], + "moonshot": [ + "MoonCVHWSSyvkWjUj7hDL14N1pVFHUjQyqWScmBw8D1r", + ], +} + +# Transaction instruction patterns that identify specific tools +TOOL_SIGNATURES = { + "smithii_bundler": { + "description": "Smithii bundle bot - bundles liquidity add + first swap in single TX", + "patterns": [ + # Signature: single TX with create_pool + add_liquidity + swap in sequence + "create_pool_add_liquidity_swap_single_tx", + # Typical wallet count: 15-25 wallets all buying within 1 block + "multi_wallet_same_block_15_25", + # Liquidity pattern: exact same SOL amount per wallet + "uniform_buy_amounts", + ], + "severity": 85, # 0-100, higher = more suspicious + "confidence_required": 0.7, + }, + "printr_bundler": { + "description": "Printr bundler - coordinated multi-wallet deployment with uniform distribution", + "patterns": [ + "deployer_program_derived_addresses", + "wallets_funded_from_single_source", + "identical_buy_timing_sub_second", + "uniform_token_distribution_20_30_wallets", + ], + "severity": 90, + "confidence_required": 0.7, + }, + "launchlab_bundle": { + "description": "LaunchLab bundle bot - specific instruction ordering for token + LP creation", + "patterns": [ + "create_mint_create_ata_mint_to_create_pool_add_liquidity", + "single_tx_8_12_instructions", + "immediate_swap_after_liquidity", + ], + "severity": 88, + "confidence_required": 0.7, + }, + "pumpfun_sniper": { + "description": "PumpFun sniper bot - buys within milliseconds of bonding curve completion", + "patterns": [ + "bonding_curve_completion_detection", + "sub_second_first_buy", + "multiple_wallets_same_program_call", + "jito_bundle_within_2_blocks", + ], + "severity": 70, + "confidence_required": 0.6, + }, + "volume_bot": { + "description": "Volume bot - self-trading to inflate volume metrics for trending algorithms", + "patterns": [ + "circular_trading_same_small_wallet_set", + "buy_sell_same_block_no_profit", + "volume_spike_no_holder_change", + "identical_trade_sizes_repeated", + "wallet_graph_fully_connected_clique", + ], + "severity": 80, + "confidence_required": 0.65, + }, + "wallet_aging_evasion": { + "description": "Scanner-aware countermeasure - wallets aged before launching scam token", + "patterns": [ + "wallet_created_weeks_before_first_scam_activity", + "dormant_period_followed_by_intense_activity", + "funded_but_idle_then_sudden_use", + "real_wallet_activity_mix_then_scam_only", + ], + "severity": 75, + "confidence_required": 0.6, + }, + "cross_chain_obfuscation": { + "description": "Scanner-aware countermeasure - funds routed through multiple chains to hide origin", + "patterns": [ + "bridge_usage_to_break_tracking", + "multiple_chain_funding_hops_3plus", + "mixer_on_source_chain_before_bridge", + "cex_deposit_on_chain_a_withdraw_on_chain_b", + ], + "severity": 85, + "confidence_required": 0.65, + }, +} + +# Known scammer deployer patterns +SCAMMER_DEPLOYER_PATTERNS = { + "cex_funded_deployer": { + "description": "Deployer funded directly from centralized exchange - high scam correlation", + "cex_wallets": [ + "binance", + "coinbase", + "kraken", + "kucoin", + "bybit", + "okx", + "gate.io", + "mexc", + "bitget", + "htx", + "bitfinex", + ], + "severity": 60, + }, + "mixer_funded_deployer": { + "description": "Deployer funded through mixer/tumbler - extremely suspicious", + "mixers": ["tornado", "cyclone", "typhoon", "wasabi", "samourai"], + "severity": 95, + }, + "previous_scammer_deployer": { + "description": "Deployer previously launched known scam tokens", + "severity": 100, + }, +} + +# First buyer concentration thresholds +FIRST_BUYER_THRESHOLDS = { + "critical_concentration": 0.80, # First 5 buyers hold 80%+ = critical + "high_concentration": 0.50, # First 10 buyers hold 50%+ = high risk + "sniper_time_ms": 5000, # < 5 seconds from launch = sniper + "coordinated_same_block": 5, # 5+ wallets buying in same block = coordinated +} + + +@dataclass +class ToolFingerprintResult: + """Result from tool fingerprinting analysis.""" + + tool_name: str + detected: bool + confidence: float # 0-1 + evidence: list[str] + severity: int # 0-100 + description: str + + +@dataclass +class FirstBuyerAnalysis: + """Analysis of first buyers for a token.""" + + token_address: str + total_holders: int + first_5_concentration_pct: float + first_10_concentration_pct: float + first_20_concentration_pct: float + avg_entry_time_ms: float # from token creation + fastest_entry_ms: float + same_block_buyers: int + coordinated_clusters: int # groups of wallets with shared funding + cex_funded_buyers: int # buyers funded from CEX + risk_level: str # critical, high, medium, low + flags: list[str] + + +@dataclass +class DeployerProfile: + """Profile of the token deployer wallet.""" + + address: str + age_days: int + funding_source: str | None + funding_source_type: str # cex, dex, mixer, unknown + previous_tokens_launched: int + previous_scam_tokens: int + cross_chain_activity: bool + wallet_aging_detected: bool + aging_score: float # 0-100 + risk_score: int # 0-100 + flags: list[str] + + +# ══════════════════════════════════════════════════════════════════════ +# TOOL FINGERPRINTER +# ══════════════════════════════════════════════════════════════════════ + + +class ToolFingerprinter: + """ + Identifies scammer tools and infrastructure from on-chain fingerprints. + Analyzes transaction patterns, program interactions, wallet graphs, + and temporal signatures to identify specific tools. + """ + + def __init__(self): + self._seen_txs: set[str] = set() + self._known_scammers: set[str] = set() + self._cex_wallets: set[str] = set() + + async def fingerprint_transaction(self, tx_data: dict, chain: str = "solana") -> list[ToolFingerprintResult]: + """Analyze a single transaction for tool fingerprints.""" + results = [] + instructions = tx_data.get("instructions", tx_data.get("ix", [])) + tx_data.get("accountKeys", tx_data.get("accounts", [])) + program_ids = [ix.get("programId", "") for ix in instructions] + tx_sig = tx_data.get("signature", tx_data.get("txHash", "")) + + if tx_sig in self._seen_txs: + return results + self._seen_txs.add(tx_sig) + + # Check Jito bundle + jito_hits = [p for p in program_ids if p in TOOL_PROGRAMS["jito_bundle"]] + if jito_hits: + results.append( + ToolFingerprintResult( + tool_name="jito_bundle", + detected=True, + confidence=0.95, + evidence=[f"Jito program invoked: {jito_hits[0]}"], + severity=75, + description="Jito bundle detected - transaction was privately submitted to avoid front-running", + ) + ) + + # Check PumpFun + pf_hits = [p for p in program_ids if p in TOOL_PROGRAMS["pumpfun"]] + if pf_hits: + results.append( + ToolFingerprintResult( + tool_name="pumpfun_token", + detected=True, + confidence=0.99, + evidence=[f"Pump.fun program call: {pf_hits[0]}"], + severity=40, + description="Pump.fun token - elevated risk due to low barrier to entry", + ) + ) + + return results + + async def detect_bundlers(self, tx_list: list[dict], token_address: str) -> list[ToolFingerprintResult]: + """Detect bundler patterns across multiple transactions.""" + results = [] + evidence_smithii = [] + evidence_printr = [] + evidence_launchlab = [] + + if not tx_list: + return results + + # Group by block + block_groups = defaultdict(list) + for tx in tx_list: + block = tx.get("slot", tx.get("blockNumber", 0)) + block_groups[block].append(tx) + + # Check each block for coordinated behavior + for block, txs in block_groups.items(): + unique_buyers = set() + for tx in txs: + signer = tx.get("signer", tx.get("from", "")) + if signer: + unique_buyers.add(signer) + + buyer_count = len(unique_buyers) + + # Smithii: 15-25 wallets in same block + if 15 <= buyer_count <= 25: + evidence_smithii.append( + f"Block {block}: {buyer_count} wallets bought in same block (Smithii range: 15-25)" + ) + + # Printr: 20-30 wallets in same block + if 20 <= buyer_count <= 30: + evidence_printr.append( + f"Block {block}: {buyer_count} wallets bought in same block (Printr range: 20-30)" + ) + + # LaunchLab: 8-12 instructions in a single tx + for tx in txs: + ix_count = len(tx.get("instructions", tx.get("ix", []))) + if 8 <= ix_count <= 12: + evidence_launchlab.append( + f"TX {tx.get('signature', '?')[:8]}: {ix_count} instructions (LaunchLab range: 8-12)" + ) + + if evidence_smithii: + results.append( + ToolFingerprintResult( + tool_name="smithii_bundler", + detected=True, + confidence=min(0.95, 0.6 + 0.1 * len(evidence_smithii)), + evidence=evidence_smithii, + severity=85, + description="Smithii bundler detected - coordinated bundle launch with 15-25 wallets", + ) + ) + + if evidence_printr: + results.append( + ToolFingerprintResult( + tool_name="printr_bundler", + detected=True, + confidence=min(0.95, 0.6 + 0.1 * len(evidence_printr)), + evidence=evidence_printr, + severity=90, + description="Printr bundler detected - 20-30 coordinated wallets from single deployer", + ) + ) + + if evidence_launchlab: + results.append( + ToolFingerprintResult( + tool_name="launchlab_bundle", + detected=True, + confidence=min(0.95, 0.6 + 0.1 * len(evidence_launchlab)), + evidence=evidence_launchlab, + severity=88, + description="LaunchLab bundle detected - 8-12 instruction complex bundle transaction", + ) + ) + + return results + + async def analyze_first_buyers( + self, token_address: str, holders_data: list[dict], first_tx_timestamp: str | None = None + ) -> FirstBuyerAnalysis: + """Analyze first buyer concentration and patterns.""" + flags = [] + + if not holders_data: + return FirstBuyerAnalysis( + token_address=token_address, + total_holders=0, + first_5_concentration_pct=0, + first_10_concentration_pct=0, + first_20_concentration_pct=0, + avg_entry_time_ms=0, + fastest_entry_ms=0, + same_block_buyers=0, + coordinated_clusters=0, + cex_funded_buyers=0, + risk_level="unknown", + flags=["No holder data"], + ) + + total_holders = len(holders_data) + sorted_holders = sorted(holders_data, key=lambda h: h.get("first_buy_time", 0) or 0) + + # Supply concentration + total_supply = sum(h.get("balance_pct", 0) for h in sorted_holders[:50]) + if total_supply == 0: + total_supply = 100 + + first_5 = sum(h.get("balance_pct", 0) for h in sorted_holders[:5]) / max(total_supply, 1) + first_10 = sum(h.get("balance_pct", 0) for h in sorted_holders[:10]) / max(total_supply, 1) + first_20 = sum(h.get("balance_pct", 0) for h in sorted_holders[:20]) / max(total_supply, 1) + + if first_5 > FIRST_BUYER_THRESHOLDS["critical_concentration"]: + flags.append(f"CRITICAL: First 5 buyers hold {first_5 * 100:.0f}%") + + if first_10 > FIRST_BUYER_THRESHOLDS["high_concentration"]: + flags.append(f"HIGH: First 10 buyers hold {first_10 * 100:.0f}%") + + # Entry timing + entry_times = [h.get("first_buy_time", 0) or 0 for h in sorted_holders[:20]] + entry_times = [t for t in entry_times if t > 0] + + avg_entry = sum(entry_times) / len(entry_times) if entry_times else 0 + fastest = min(entry_times) if entry_times else 0 + + if fastest < FIRST_BUYER_THRESHOLDS["sniper_time_ms"]: + flags.append(f"SNIPER: Fastest entry {fastest:.0f}ms after launch") + + # Same block detection + blocks = set() + for h in sorted_holders[:20]: + block = h.get("first_buy_block") + if block: + blocks.add(block) + same_block = total_holders - len(blocks) if total_holders > 0 else 0 + + if same_block >= FIRST_BUYER_THRESHOLDS["coordinated_same_block"]: + flags.append(f"COORDINATED: {same_block} wallets bought in same block") + + # Funding source clustering + funding_sources = defaultdict(int) + for h in sorted_holders[:20]: + funder = h.get("funding_source", "unknown") + if funder: + funding_sources[funder] += 1 + + coordinated = sum(1 for count in funding_sources.values() if count >= 3) + + if coordinated > 0: + flags.append(f"COORDINATED CLUSTERS: {coordinated} groups share funding source") + + # Determine risk level + if first_5 > 0.80 or len(flags) >= 4: + risk_level = "critical" + elif first_5 > 0.50 or len(flags) >= 2: + risk_level = "high" + elif len(flags) >= 1: + risk_level = "medium" + else: + risk_level = "low" + + return FirstBuyerAnalysis( + token_address=token_address, + total_holders=total_holders, + first_5_concentration_pct=round(first_5 * 100, 1), + first_10_concentration_pct=round(first_10 * 100, 1), + first_20_concentration_pct=round(first_20 * 100, 1), + avg_entry_time_ms=round(avg_entry, 1), + fastest_entry_ms=round(fastest, 1), + same_block_buyers=same_block, + coordinated_clusters=coordinated, + cex_funded_buyers=0, + risk_level=risk_level, + flags=flags, + ) + + async def profile_deployer(self, deployer_address: str, chain: str = "solana") -> DeployerProfile: + """Profile the token deployer wallet for risk factors.""" + flags = [] + risk = 0 + + # Try to get deployer data + age_days = 0 + funding_source = None + funding_type = "unknown" + aging_detected = False + aging_score = 0 + prev_tokens = 0 + prev_scams = 0 + cross_chain = False + + try: + async with httpx.AsyncClient(timeout=15) as client: + # Basic Solana account info + if chain == "solana": + resp = await client.post( + "https://api.mainnet-beta.solana.com", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "getSignaturesForAddress", + "params": [deployer_address, {"limit": 50}], + }, + ) + if resp.status_code == 200: + sigs = resp.json().get("result", []) + if sigs: + first_sig = sigs[-1] + first_ts = first_sig.get("blockTime", 0) + if first_ts: + age_days = (datetime.now(UTC) - datetime.fromtimestamp(first_ts, tz=UTC)).days + prev_tokens = len( + [ + s + for s in sigs + if "create" in str(s.get("memo", "")).lower() + or "token" in str(s.get("memo", "")).lower() + ] + ) + + except Exception as e: + logger.warning(f"Failed to profile deployer {deployer_address}: {e}") + + # Fresh wallet scoring + if age_days < 1: + risk += 30 + flags.append("BRAND_NEW: Wallet less than 1 day old") + elif age_days < 7: + risk += 20 + flags.append("FRESH: Wallet less than 7 days old") + elif age_days < 30: + risk += 10 + flags.append("NEW: Wallet less than 30 days old") + + # Wallet aging detection (countermeasure) + if age_days > 30 and prev_tokens == 0: + # Old wallet, first token launch = possible aged wallet + aging_score = 40 + flags.append("AGING_SUSPICIOUS: Old wallet launching first token") + elif age_days > 90 and prev_tokens <= 1: + aging_score = 60 + aging_detected = True + flags.append("AGING_DETECTED: Wallet aged 90+ days, first/second token only") + risk += 25 + + # Repeated token launcher + if prev_tokens > 5: + risk += 15 + flags.append(f"SERIAL_LAUNCHER: {prev_tokens} previous tokens") + + # Check if deployer appears in our known scam DB + try: + from app.scam_sources import KNOWN_SCAMS_EXPANDED + + if deployer_address in str(KNOWN_SCAMS_EXPANDED): + risk += 50 + prev_scams = 1 + flags.append("KNOWN_SCAMMER: Previously identified in scam database") + except Exception: + pass + + # Determine funding type + if funding_type == "cex": + risk += 20 + flags.append("CEX_FUNDED: Deployer funded from centralized exchange") + + return DeployerProfile( + address=deployer_address, + age_days=age_days, + funding_source=funding_source, + funding_source_type=funding_type, + previous_tokens_launched=prev_tokens, + previous_scam_tokens=prev_scams, + cross_chain_activity=cross_chain, + wallet_aging_detected=aging_detected, + aging_score=aging_score, + risk_score=min(100, risk), + flags=flags, + ) + + async def detect_volume_bots(self, trades: list[dict], token_address: str) -> list[ToolFingerprintResult]: + """Detect volume bot / wash trading patterns.""" + results = [] + if not trades or len(trades) < 10: + return results + + evidence = [] + + # Check for circular trading (same set of wallets trading among themselves) + traders = set() + trade_pairs = defaultdict(int) + for t in trades: + a, b = t.get("buyer", ""), t.get("seller", "") + traders.add(a) + traders.add(b) + if a and b: + pair = tuple(sorted([a, b])) + trade_pairs[pair] += 1 + + # Clique detection: small number of wallets doing all trading + if len(traders) < 10 and len(trades) > 50: + evidence.append(f"Small trader set ({len(traders)} wallets) with {len(trades)} trades") + + # Repeated trades between same pairs + heavy_pairs = {p: c for p, c in trade_pairs.items() if c > 5} + if heavy_pairs: + evidence.append(f"{len(heavy_pairs)} wallet pairs trading 5+ times each") + + # Identical trade sizes + trade_sizes = [float(t.get("amount", 0)) for t in trades if t.get("amount")] + if trade_sizes: + unique_sizes = len(set(trade_sizes)) + if unique_sizes < len(trade_sizes) * 0.3: + evidence.append(f"Repetitive trade sizes: {unique_sizes} unique from {len(trade_sizes)} trades") + + if evidence: + results.append( + ToolFingerprintResult( + tool_name="volume_bot", + detected=True, + confidence=min(0.95, 0.5 + 0.15 * len(evidence)), + evidence=evidence, + severity=80, + description="Volume bot / wash trading detected - inflated metrics", + ) + ) + + return results + + async def full_token_scan( + self, + token_address: str, + chain: str = "solana", + deployer_address: str | None = None, + holders: list[dict] | None = None, + transactions: list[dict] | None = None, + ) -> dict[str, Any]: + """ + Full token scan - combines all detection methods. + Returns comprehensive risk assessment. + """ + import time + + start = time.time() + + results = { + "token_address": token_address, + "chain": chain, + "tool_fingerprints": [], + "first_buyer_analysis": None, + "deployer_profile": None, + "volume_bot_detected": False, + "aggregate_risk_score": 0, + "risk_category": "unknown", + "flags": [], + "scan_duration_ms": 0, + } + + # Run all detectors in parallel + tasks = [] + + if transactions: + tasks.append(self.detect_bundlers(transactions, token_address)) + if holders: + tasks.append(self.analyze_first_buyers(token_address, holders)) + if deployer_address: + tasks.append(self.profile_deployer(deployer_address, chain)) + if transactions: + tasks.append(self.detect_volume_bots(transactions, token_address)) + + import asyncio + + gathered = await asyncio.gather(*tasks, return_exceptions=True) + + # Process results + total_risk = 0 + max_possible = 0 + + for result in gathered: + if isinstance(result, Exception): + continue + + if isinstance(result, list): + # Tool fingerprints + for item in result: + if isinstance(item, ToolFingerprintResult) and item.detected: + results["tool_fingerprints"].append( + { + "tool": item.tool_name, + "confidence": round(item.confidence, 2), + "severity": item.severity, + "evidence": item.evidence, + "description": item.description, + } + ) + total_risk += item.severity + max_possible += 100 + results["flags"].append(f"TOOL:{item.tool_name}") + + elif isinstance(result, FirstBuyerAnalysis): + fb = result + results["first_buyer_analysis"] = { + "total_holders": fb.total_holders, + "first_5_concentration_pct": fb.first_5_concentration_pct, + "first_10_concentration_pct": fb.first_10_concentration_pct, + "first_20_concentration_pct": fb.first_20_concentration_pct, + "fastest_entry_ms": fb.fastest_entry_ms, + "avg_entry_time_ms": fb.avg_entry_time_ms, + "same_block_buyers": fb.same_block_buyers, + "coordinated_clusters": fb.coordinated_clusters, + "risk_level": fb.risk_level, + } + if fb.risk_level == "critical": + total_risk += 50 + elif fb.risk_level == "high": + total_risk += 30 + max_possible += 50 + results["flags"].extend(fb.flags) + + elif isinstance(result, DeployerProfile): + dp = result + results["deployer_profile"] = { + "address": dp.address, + "age_days": dp.age_days, + "funding_source_type": dp.funding_source_type, + "previous_tokens": dp.previous_tokens_launched, + "previous_scams": dp.previous_scam_tokens, + "wallet_aging_detected": dp.wallet_aging_detected, + "aging_score": dp.aging_score, + "risk_score": dp.risk_score, + } + total_risk += dp.risk_score + max_possible += 100 + results["flags"].extend(dp.flags) + + # Calculate aggregate risk + pct = total_risk / max_possible * 100 if max_possible > 0 else 0 + results["aggregate_risk_score"] = min(100, round(pct)) + + if pct >= 80: + results["risk_category"] = "critical" + elif pct >= 60: + results["risk_category"] = "high" + elif pct >= 35: + results["risk_category"] = "medium" + elif pct >= 15: + results["risk_category"] = "low" + else: + results["risk_category"] = "minimal" + + results["scan_duration_ms"] = round((time.time() - start) * 1000) + return results + + +# ══════════════════════════════════════════════════════════════════════ +# SINGLETON +# ══════════════════════════════════════════════════════════════════════ + +_fingerprinter: ToolFingerprinter | None = None + + +async def get_fingerprinter() -> ToolFingerprinter: + global _fingerprinter + if _fingerprinter is None: + _fingerprinter = ToolFingerprinter() + return _fingerprinter + + +async def fingerprint_token( + token_address: str, + chain: str = "solana", + deployer: str | None = None, + holders: list[dict] | None = None, + transactions: list[dict] | None = None, +) -> dict[str, Any]: + """Convenience function for full token fingerprinting.""" + fp = await get_fingerprinter() + return await fp.full_token_scan( + token_address=token_address, + chain=chain, + deployer_address=deployer, + holders=holders, + transactions=transactions, + ) diff --git a/app/_archive/legacy_2026_07/tools_integration.py b/app/_archive/legacy_2026_07/tools_integration.py new file mode 100644 index 0000000..eb91834 --- /dev/null +++ b/app/_archive/legacy_2026_07/tools_integration.py @@ -0,0 +1,386 @@ +""" +RMI Tools Integration - Wires all installed open-source tools into the backend. +Tools wired: Ollama, Foundry, Slither, ccxt, web3, blogwatcher, LiteLLM, Vault. +""" + +import json +import logging +import os +import subprocess +from datetime import UTC, datetime + +import httpx + +logger = logging.getLogger(__name__) + +# ═══════════════════════════════════════════════════════════ +# OLLAMA - Local LLM (port 11434, 2 models) +# ═══════════════════════════════════════════════════════════ +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://172.17.0.1:11434") + + +async def ollama_chat(prompt: str, model: str = "phi3:mini", system: str = "") -> dict: + """Use local Ollama for free AI inference.""" + try: + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + async with httpx.AsyncClient(timeout=60) as c: + r = await c.post( + f"{OLLAMA_URL}/api/chat", + json={ + "model": model, + "messages": messages, + "stream": False, + "options": {"temperature": 0.3, "num_predict": 1024}, + }, + ) + if r.status_code == 200: + data = r.json() + return { + "text": data.get("message", {}).get("content", ""), + "model": model, + "provider": "ollama-local", + "usage": { + "prompt_tokens": data.get("prompt_eval_count", 0), + "completion_tokens": data.get("eval_count", 0), + }, + } + except Exception as e: + logger.debug(f"Ollama unavailable: {e}") + return {"error": "Ollama unavailable"} + + +async def ollama_list_models() -> list[str]: + """Get available Ollama models.""" + try: + async with httpx.AsyncClient(timeout=5) as c: + r = await c.get(f"{OLLAMA_URL}/api/tags") + if r.status_code == 200: + return [m["name"] for m in r.json().get("models", [])] + except Exception: + pass + return [] + + +# ═══════════════════════════════════════════════════════════ +# FOUNDRY - EVM smart contract analysis (cast) +# ═══════════════════════════════════════════════════════════ +CAST_PATH = "/root/.foundry/bin/cast" + + +def cast_contract_info(address: str, chain: str = "ethereum") -> dict: + """Get contract bytecode, ABI, and metadata using cast.""" + rpcs = { + "ethereum": os.getenv("ETH_RPC_URL", "https://eth.llamarpc.com"), + "base": os.getenv("BASE_RPC_URL", "https://base.llamarpc.com"), + "arbitrum": os.getenv("ARB_RPC_URL", "https://arb1.arbitrum.io/rpc"), + "polygon": os.getenv("POLY_RPC_URL", "https://polygon.llamarpc.com"), + "bsc": os.getenv("BSC_RPC_URL", "https://bsc-dataseed.binance.org"), + "avalanche": os.getenv("AVAX_RPC_URL", "https://api.avax.network/ext/bc/C/rpc"), + } + rpc = rpcs.get(chain, rpcs["ethereum"]) + try: + env = os.environ.copy() + env["ETH_RPC_URL"] = rpc + + # Get bytecode + code = subprocess.run( + [CAST_PATH, "code", address, "--rpc-url", rpc], + capture_output=True, + text=True, + timeout=15, + ) + bytecode = code.stdout.strip() + + # Get nonce + nonce = subprocess.run( + [CAST_PATH, "nonce", address, "--rpc-url", rpc], + capture_output=True, + text=True, + timeout=10, + ) + + # Get balance + balance = subprocess.run( + [CAST_PATH, "balance", address, "--rpc-url", rpc], + capture_output=True, + text=True, + timeout=10, + ) + + return { + "address": address, + "chain": chain, + "has_bytecode": len(bytecode) > 4, + "bytecode_size": len(bytecode) // 2 if bytecode.startswith("0x") else 0, + "is_contract": len(bytecode) > 4, + "nonce": nonce.stdout.strip(), + "balance_wei": balance.stdout.strip(), + "balanced_checked": datetime.now(UTC).isoformat(), + } + except Exception as e: + return { + "address": address, + "chain": chain, + "error": str(e), + "cast_available": os.path.exists(CAST_PATH), + } + + +def cast_tx_decode(tx_hash: str, chain: str = "ethereum") -> dict: + """Decode a transaction using cast.""" + rpcs = { + "ethereum": "https://eth.llamarpc.com", + "base": "https://base.llamarpc.com", + "arbitrum": "https://arb1.arbitrum.io/rpc", + "bsc": "https://bsc-dataseed.binance.org", + } + rpc = rpcs.get(chain, rpcs["ethereum"]) + try: + result = subprocess.run( + [CAST_PATH, "tx", tx_hash, "--rpc-url", rpc, "--json"], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode == 0: + return json.loads(result.stdout) + except Exception: + pass + return {"error": "Decode failed", "tx_hash": tx_hash} + + +# ═══════════════════════════════════════════════════════════ +# SLITHER - Solidity vulnerability scanner +# ═══════════════════════════════════════════════════════════ +def slither_analyze(contract_address: str, chain: str = "ethereum") -> dict: + """Run Slither on a verified contract.""" + try: + result = subprocess.run( + ["slither", contract_address, "--print", "human-summary"], + capture_output=True, + text=True, + timeout=60, + ) + return { + "address": contract_address, + "chain": chain, + "analysis": result.stdout[:2000] if result.returncode == 0 else result.stderr[:500], + "success": result.returncode == 0, + } + except Exception as e: + return {"error": str(e), "slither_available": False} + + +# ═══════════════════════════════════════════════════════════ +# CCXT - 100+ exchange price feeds +# ═══════════════════════════════════════════════════════════ +def ccxt_get_prices(symbols: list[str] | None = None) -> dict: + """Get real-time prices from multiple exchanges via ccxt.""" + try: + import ccxt + + exchanges = { + "binance": ccxt.binance(), + "kraken": ccxt.kraken(), + "coinbase": ccxt.coinbase(), + "bybit": ccxt.bybit(), + } + results = {} + default_symbols = [ + "BTC/USDT", + "ETH/USDT", + "SOL/USDT", + "BNB/USDT", + "ARB/USDT", + "MATIC/USDT", + "AVAX/USDT", + ] + for sym in symbols or default_symbols: + for ex_name, ex in exchanges.items(): + try: + ticker = ex.fetch_ticker(sym) + if ticker and ticker.get("last"): + results.setdefault(sym, {})[ex_name] = { + "price": ticker["last"], + "change_24h": ticker.get("percentage", ticker.get("change", 0)), + "volume_24h": ticker.get("quoteVolume", ticker.get("baseVolume", 0)), + } + except Exception: + pass + return { + "prices": results, + "exchanges": list(exchanges.keys()), + "updated_at": datetime.now(UTC).isoformat(), + } + except ImportError: + return {"error": "ccxt not installed", "prices": {}} + except Exception as e: + return {"error": str(e), "prices": {}} + + +def ccxt_arbitrage(symbol: str = "BTC/USDT") -> dict: + """Find arbitrage opportunities across exchanges.""" + prices = ccxt_get_prices([symbol]) + price_data = prices.get("prices", {}).get(symbol, {}) + if len(price_data) >= 2: + prices_list = [(ex, p["price"]) for ex, p in price_data.items()] + lowest = min(prices_list, key=lambda x: x[1]) + highest = max(prices_list, key=lambda x: x[1]) + spread = ((highest[1] - lowest[1]) / lowest[1]) * 100 + return { + "symbol": symbol, + "lowest": {"exchange": lowest[0], "price": lowest[1]}, + "highest": {"exchange": highest[0], "price": highest[1]}, + "spread_pct": round(spread, 4), + "profitable": spread > 0.5, + } + return {"symbol": symbol, "opportunities": 0} + + +# ═══════════════════════════════════════════════════════════ +# WEB3.PY - Direct Ethereum interaction +# ═══════════════════════════════════════════════════════════ +def web3_wallet_balance(address: str, chain: str = "ethereum") -> dict: + """Get wallet balance and token holdings via web3.""" + try: + from web3 import Web3 + + rpcs = { + "ethereum": "https://eth.llamarpc.com", + "base": "https://base.llamarpc.com", + "bsc": "https://bsc-dataseed.binance.org", + "polygon": "https://polygon.llamarpc.com", + "arbitrum": "https://arb1.arbitrum.io/rpc", + "avalanche": "https://api.avax.network/ext/bc/C/rpc", + } + rpc = rpcs.get(chain, rpcs["ethereum"]) + w3 = Web3(Web3.HTTPProvider(rpc)) + + if not w3.is_connected(): + return {"error": f"RPC unavailable for {chain}"} + + checksum = w3.to_checksum_address(address) + balance = w3.eth.get_balance(checksum) + tx_count = w3.eth.get_transaction_count(checksum) + code = w3.eth.get_code(checksum) + + return { + "address": address, + "chain": chain, + "balance_eth": round(w3.from_wei(balance, "ether"), 6), + "balance_wei": balance, + "transaction_count": tx_count, + "is_contract": len(code) > 0, + "rpc": rpc, + } + except ImportError: + return {"error": "web3 not installed"} + except Exception as e: + return {"error": str(e), "address": address, "chain": chain} + + +# ═══════════════════════════════════════════════════════════ +# BLOGWATCHER - RSS/Atom feed monitoring +# ═══════════════════════════════════════════════════════════ +BLOGWATCHER_PATH = "/root/go/bin/blogwatcher" + + +def blogwatcher_fetch(feed_url: str, limit: int = 10) -> dict: + """Fetch RSS/Atom feed using blogwatcher.""" + try: + result = subprocess.run( + [ + BLOGWATCHER_PATH, + "fetch", + "--url", + feed_url, + "--limit", + str(limit), + "--output", + "json", + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + posts = json.loads(result.stdout) if result.stdout.strip() else [] + return {"feed": feed_url, "posts": posts, "count": len(posts)} + except Exception: + pass + return {"feed": feed_url, "posts": [], "error": "blogwatcher unavailable"} + + +# ═══════════════════════════════════════════════════════════ +# LITELLM - Multi-provider LLM proxy routing +# ═══════════════════════════════════════════════════════════ +LITELLM_CONFIG = "/srv/rugmuncher-backend/litellm/config.yaml" + + +def litellm_status() -> dict: + """Check LiteLLM proxy availability.""" + config_exists = os.path.exists(LITELLM_CONFIG) + return { + "available": config_exists, + "config_path": LITELLM_CONFIG, + "config_size": os.path.getsize(LITELLM_CONFIG) if config_exists else 0, + } + + +# ═══════════════════════════════════════════════════════════ +# VAULT - Secrets management +# ═══════════════════════════════════════════════════════════ +VAULT_ADDR = os.getenv("VAULT_ADDR", "http://172.17.0.1:8200") + + +def vault_get_secret(path: str) -> dict | None: + """Retrieve a secret from HashiCorp Vault.""" + try: + r = httpx.get(f"{VAULT_ADDR}/v1/{path}", headers={"X-Vault-Token": "root"}, timeout=5) + if r.status_code == 200: + return r.json().get("data", {}).get("data", {}) + except Exception: + pass + return None + + +def vault_list_secrets(path: str = "secret") -> list[str]: + """List secrets in Vault.""" + try: + r = httpx.get(f"{VAULT_ADDR}/v1/{path}?list=true", headers={"X-Vault-Token": "root"}, timeout=5) + if r.status_code == 200: + return r.json().get("data", {}).get("keys", []) + except Exception: + pass + return [] + + +# ═══════════════════════════════════════════════════════════ +# TOOL STATUS - Health check for all integrated tools +# ═══════════════════════════════════════════════════════════ +async def tools_health() -> dict: + """Health check for all integrated tools.""" + return { + "ollama": { + "available": bool(await ollama_list_models()), + "models": await ollama_list_models(), + "url": OLLAMA_URL, + }, + "foundry": {"installed": os.path.exists(CAST_PATH), "path": CAST_PATH}, + "slither": {"installed": os.path.exists("/root/.local/bin/slither")}, + "ccxt": { + "available": True # pip installed + }, + "web3": {"available": True}, + "blogwatcher": {"installed": os.path.exists(BLOGWATCHER_PATH), "path": BLOGWATCHER_PATH}, + "litellm": litellm_status(), + "vault": {"available": bool(vault_list_secrets()), "url": VAULT_ADDR}, + "file_upload": { + "running": True, # port 8001 + "url": "http://localhost:8001", + }, + "timestamp": datetime.now(UTC).isoformat(), + } diff --git a/app/_archive/legacy_2026_07/trufflehog_scanner.py b/app/_archive/legacy_2026_07/trufflehog_scanner.py new file mode 100644 index 0000000..36d9ec0 --- /dev/null +++ b/app/_archive/legacy_2026_07/trufflehog_scanner.py @@ -0,0 +1,252 @@ +""" +TruffleHog Secret Scanner Integration +===================================== + +Secure scanning for git repositories and code to detect: +- Hardcoded secrets +- API keys +- Credentials +- Tokens +""" + +import json +import logging +import subprocess +from dataclasses import dataclass +from datetime import datetime + +logger = logging.getLogger(__name__) + + +@dataclass +class SecretFinding: + """TruffleHog finding.""" + + detector_type: str + detector_name: str + source_type: str + source: str + secret: str # Partial redacted in production + secret_hash: str + raw: str + raw_v2: str + verified: bool + verified_type: str + time_found: str + commit: str + file_path: str + line: int + column: int + + +# ─── TRUFFLEHOG SCANNER ─────────────────────────────────────────── + + +class TruffleHogScanner: + """Scanner using TruffleHog CLI.""" + + def __init__(self, verbose: bool = False): + self._available = self._check_available() + self.verbose = verbose + + def _check_available(self) -> bool: + """Check if TruffleHog is installed.""" + try: + result = subprocess.run(["trufflehog", "--version"], capture_output=True, timeout=5) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def scan_directory(self, path: str, **kwargs) -> list[SecretFinding]: + """ + Scan a directory for secrets. + + Args: + path: Directory path to scan + **kwargs: Additional trufflehog arguments + + Returns: + List of findings + """ + if not self._available: + return self._stub_scan(path) + + cmd = ["trufflehog", "filesystem", path, "--json"] + + # TruffleHog v3 uses different flags + if kwargs.get("include"): + cmd.extend(["--include-paths", kwargs["include"]]) + if kwargs.get("exclude"): + cmd.extend(["--exclude-paths", kwargs["exclude"]]) + if kwargs.get("max_depth"): + cmd.extend(["--max-decode-depth", str(kwargs["max_depth"])]) + if self.verbose: + cmd.append("-v") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minutes max + ) + + if result.returncode != 0 and "no bugs found" not in result.stderr.lower(): + logger.warning(f"TruffleHog scan warning: {result.stderr}") + + findings = [] + for line in result.stdout.split("\n"): + if line.strip(): + try: + data = json.loads(line) + finding = SecretFinding( + detector_type=data.get("DetectorType", ""), + detector_name=data.get("DetectorName", ""), + source_type=data.get("SourceType", ""), + source=data.get("SourceMetadata", {}).get("Data", {}).get("Filesystem", {}).get("File", ""), + secret=data.get("Raw", ""), + secret_hash=data.get("Redacted", ""), + raw=data.get("Raw", ""), + raw_v2=data.get("RawV2", ""), + verified=data.get("Verified", False), + verified_type=data.get("VerificationStatus", ""), + time_found=datetime.utcnow().isoformat(), + commit=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("Commit", ""), + file_path=data.get("SourceMetadata", {}) + .get("Data", {}) + .get("Filesystem", {}) + .get("File", ""), + line=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("Line", 0), + column=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("Column", 0), + ) + findings.append(finding) + except json.JSONDecodeError: + continue + + return findings + + except subprocess.TimeoutExpired: + logger.error("TruffleHog scan timed out") + return [] + + def scan_git_repo(self, url: str, **kwargs) -> list[SecretFinding]: + """ + Scan a Git repository for secrets. + + Args: + url: Repository URL + **kwargs: Additional trufflehog arguments + + Returns: + List of findings + """ + if not self._available: + return self._stub_scan(url) + + cmd = ["trufflehog", "git", url, "--json", "--unvalidated"] + + if kwargs.get("branch"): + cmd.extend(["--branch", kwargs["branch"]]) + if self.verbose: + cmd.append("-v") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600, # 10 minutes for git clone + scan + ) + + findings = [] + for line in result.stdout.split("\n"): + if line.strip(): + try: + data = json.loads(line) + findings.append( + SecretFinding( + detector_type=data.get("DetectorType", ""), + detector_name=data.get("DetectorName", ""), + source_type=data.get("SourceType", ""), + source=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("Repo", ""), + secret=data.get("Raw", ""), + secret_hash=data.get("Redacted", ""), + raw=data.get("Raw", ""), + raw_v2=data.get("RawV2", ""), + verified=data.get("Verified", False), + verified_type=data.get("VerificationStatus", ""), + time_found=datetime.utcnow().isoformat(), + commit=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("Commit", ""), + file_path=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("File", ""), + line=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("Line", 0), + column=data.get("SourceMetadata", {}).get("Data", {}).get("Git", {}).get("Column", 0), + ) + ) + except json.JSONDecodeError: + continue + + return findings + + except subprocess.TimeoutExpired: + logger.error("TruffleHog git scan timed out") + return [] + + def _stub_scan(self, path: str) -> list[SecretFinding]: + """Stub scan when TruffleHog unavailable.""" + return [ + SecretFinding( + detector_type="stub", + detector_name="stub_detector", + source_type="stub", + source=path, + secret="", + secret_hash="stub", + raw="", + raw_v2="", + verified=False, + verified_type="", + time_found=datetime.utcnow().isoformat(), + commit="", + file_path=path, + line=0, + column=0, + ) + ] + + def filter_findings(self, findings: list[SecretFinding], min_severity: int = 0) -> list[SecretFinding]: + """ + Filter findings by severity/verified status. + + Args: + findings: List of findings + min_severity: Minimum severity (0-100) + + Returns: + Filtered findings + """ + return [f for f in findings if f.verified or (not f.verified and min_severity == 0)] + + +# ─── GLOBAL SINGLETON ───────────────────────────────────────────── + +_scanner: TruffleHogScanner | None = None + + +def get_scanner() -> TruffleHogScanner: + """Get global TruffleHog scanner instance.""" + global _scanner + if _scanner is None: + _scanner = TruffleHogScanner() + return _scanner + + +def scan_directory(path: str, **kwargs) -> list[SecretFinding]: + """Scan a directory for secrets.""" + scanner = get_scanner() + return scanner.scan_directory(path, **kwargs) + + +def scan_git_repo(url: str, **kwargs) -> list[SecretFinding]: + """Scan a Git repository for secrets.""" + scanner = get_scanner() + return scanner.scan_git_repo(url, **kwargs) diff --git a/app/_archive/legacy_2026_07/tx_simulator.py b/app/_archive/legacy_2026_07/tx_simulator.py new file mode 100644 index 0000000..08ebc09 --- /dev/null +++ b/app/_archive/legacy_2026_07/tx_simulator.py @@ -0,0 +1,278 @@ +""" +Transaction Simulation - Pre-flight swap/sell simulation. + +Detects honeypots, excessive taxes, and reverts BEFORE the user signs. +Simulates a sell transaction using Jupiter (Solana) and eth_call (EVM) to +predict what will happen without spending gas. + +Architecture: + - Solana: Jupiter quote API → simulate via Helius RPC + - EVM: eth_call with swap parameters + - Returns: success/fail, expected output, tax rate, risk assessment +""" + +import logging +from dataclasses import dataclass, field + +import httpx + +logger = logging.getLogger(__name__) + + +@dataclass +class SimulationResult: + """Result of a transaction simulation.""" + + token_address: str + chain: str + can_sell: bool + can_buy: bool + sell_tax_pct: float = 0.0 + buy_tax_pct: float = 0.0 + expected_output: float | None = None + expected_output_token: str = "" + simulation_success: bool = False + error: str | None = None + warnings: list[str] = field(default_factory=list) + risk: str = "unknown" # safe, warning, dangerous, critical + + @property + def is_honeypot(self) -> bool: + return not self.can_sell and self.can_buy + + @property + def is_hard_rug(self) -> bool: + return not self.can_sell and not self.can_buy + + +# ═══════════════════════════════════════════════ +# SOLANA SIMULATION (via Jupiter + Helius) +# ═══════════════════════════════════════════════ + +JUPITER_QUOTE = "https://quote-api.jup.ag/v6/quote" +JUPITER_SWAP = "https://quote-api.jup.ag/v6/swap" +WSOL_MINT = "So11111111111111111111111111111111111111112" +SIMULATION_AMOUNT = 100_000 # 0.0001 SOL for simulation + + +async def simulate_solana(token_address: str) -> SimulationResult: + """Simulate a sell transaction on Solana.""" + result = SimulationResult( + token_address=token_address, + chain="solana", + can_sell=False, + can_buy=False, + ) + + try: + async with httpx.AsyncClient(timeout=15) as client: + # Step 1: Try to sell (token → SOL) + sell_quote = await _get_quote(client, token_address, WSOL_MINT, SIMULATION_AMOUNT) + + if sell_quote: + result.can_sell = True + result.expected_output = float(sell_quote.get("outAmount", 0)) / 1e9 + result.expected_output_token = "SOL" + + # Calculate tax from price impact + slippage + price_impact = float(sell_quote.get("priceImpactPct", 0)) + if price_impact > 50: + result.warnings.append(f"Very high price impact: {price_impact:.1f}%") + result.sell_tax_pct = price_impact + elif price_impact > 15: + result.warnings.append(f"High price impact: {price_impact:.1f}%") + result.sell_tax_pct = price_impact + + # Step 2: Try to buy (SOL → token) + buy_quote = await _get_quote(client, WSOL_MINT, token_address, SIMULATION_AMOUNT) + if buy_quote: + result.can_buy = True + price_impact = float(buy_quote.get("priceImpactPct", 0)) + if price_impact > 50: + result.buy_tax_pct = price_impact + + result.simulation_success = True + + # Risk assessment + if result.is_honeypot: + result.risk = "critical" + result.warnings.append("⚠️ HONEYPOT DETECTED: Can buy but cannot sell!") + elif not result.can_sell: + result.risk = "critical" + result.warnings.append("⚠️ Cannot sell this token - liquidity may not exist") + elif result.sell_tax_pct > 90: + result.risk = "critical" + result.warnings.append(f"☠️ {result.sell_tax_pct:.0f}% effective tax on sell - likely scam") + elif result.sell_tax_pct > 50: + result.risk = "dangerous" + result.warnings.append(f"🔴 {result.sell_tax_pct:.0f}% tax on sell") + elif result.sell_tax_pct > 15: + result.risk = "warning" + result.warnings.append(f"🟠 {result.sell_tax_pct:.0f}% price impact on sell") + elif result.can_sell and result.can_buy: + result.risk = "safe" + + except Exception as e: + result.error = str(e) + result.risk = "unknown" + result.warnings.append(f"Simulation failed: {e}") + + return result + + +async def _get_quote(client: httpx.AsyncClient, input_mint: str, output_mint: str, amount: int) -> dict | None: + """Get a Jupiter swap quote.""" + try: + params = { + "inputMint": input_mint, + "outputMint": output_mint, + "amount": str(amount), + "slippageBps": 100, # 1% slippage + } + r = await client.get(JUPITER_QUOTE, params=params) + if r.status_code == 200: + data = r.json() + if data.get("outAmount"): + return data + return None + except Exception: + return None + + +# ═══════════════════════════════════════════════ +# EVM SIMULATION (via eth_call) +# ═══════════════════════════════════════════════ + +UNISWAP_V2_ROUTER = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D" +WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + +# Common public RPC endpoints +EVM_RPC_ENDPOINTS = { + "ethereum": "https://eth.llamarpc.com", + "bsc": "https://bsc-dataseed.binance.org", + "polygon": "https://polygon.llamarpc.com", + "arbitrum": "https://arb1.arbitrum.io/rpc", + "base": "https://mainnet.base.org", + "optimism": "https://mainnet.optimism.io", +} + + +async def simulate_evm(token_address: str, chain: str = "ethereum") -> SimulationResult: + """Simulate a sell on EVM chains via eth_call.""" + result = SimulationResult( + token_address=token_address, + chain=chain, + can_sell=False, + can_buy=False, + ) + + rpc_url = EVM_RPC_ENDPOINTS.get(chain, EVM_RPC_ENDPOINTS["ethereum"]) + + try: + async with httpx.AsyncClient(timeout=15) as client: + # Check if token is accessible via eth_call + # Call balanceOf to verify token exists + balance_check = await _eth_call( + client, + rpc_url, + token_address, + "0x70a08231000000000000000000000000" + "0" * 40, # balanceOf(address(0)) + ) + if balance_check and not balance_check.get("error"): + result.can_buy = True # Token exists and is callable + + # Try to simulate a sell via Uniswap + sell_result = await _simulate_uniswap_sell(client, rpc_url, token_address) + if sell_result.get("success"): + result.can_sell = True + result.expected_output = sell_result.get("output_amount") + result.expected_output_token = "ETH" + + result.simulation_success = True + + if result.is_honeypot: + result.risk = "critical" + result.warnings.append("⚠️ HONEYPOT: Token exists but sells revert") + elif result.can_sell: + result.risk = "safe" + else: + result.risk = "warning" + result.warnings.append("Could not verify sell path - exercise caution") + + except Exception as e: + result.error = str(e) + result.risk = "unknown" + + return result + + +async def _eth_call(client: httpx.AsyncClient, rpc_url: str, to: str, data: str) -> dict: + """Execute an eth_call JSON-RPC request.""" + try: + r = await client.post( + rpc_url, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [{"to": to, "data": data}, "latest"], + }, + ) + return r.json() + except Exception as e: + return {"error": str(e)} + + +async def _simulate_uniswap_sell(client: httpx.AsyncClient, rpc_url: str, token: str) -> dict: + """Simulate a Uniswap V2 sell transaction.""" + try: + # Encode getAmountsOut(amountIn, [token, WETH]) + amount_in_hex = "0x" + format(1000000000000000000, "064x") # 1 ETH worth + data = ( + "0xd06ca61f" + + amount_in_hex[2:].zfill(64) + + "0000000000000000000000000000000000000000000000000000000000000040" + + "0000000000000000000000000000000000000000000000000000000000000002" + + "000000000000000000000000" + + token[2:].lower().zfill(64) + + "000000000000000000000000" + + WETH[2:].lower().zfill(64) + ) + r = await client.post( + rpc_url, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [{"to": UNISWAP_V2_ROUTER, "data": data}, "latest"], + }, + ) + result = r.json() + if "result" in result and result["result"] != "0x": + # Parse output amount from ABI-encoded response + raw = result["result"] + return {"success": True, "output_amount": int(raw, 16) / 1e18} + return {"success": False} + except Exception: + return {"success": False} + + +# ═══════════════════════════════════════════════ +# UNIFIED SIMULATION +# ═══════════════════════════════════════════════ + + +async def simulate_transaction(token_address: str, chain: str = "solana") -> SimulationResult: + """ + Simulate a sell transaction for any chain. + + Returns a SimulationResult with: + - can_sell / can_buy (honeypot detection) + - estimated tax/price impact + - risk assessment + - warnings + """ + if chain == "solana" or (len(token_address) in range(32, 45) and not token_address.startswith("0x")): + return await simulate_solana(token_address) + else: + return await simulate_evm(token_address, chain) diff --git a/app/_archive/legacy_2026_07/velocity_risk.py b/app/_archive/legacy_2026_07/velocity_risk.py new file mode 100644 index 0000000..53fe300 --- /dev/null +++ b/app/_archive/legacy_2026_07/velocity_risk.py @@ -0,0 +1,217 @@ +""" +Velocity Risk Engine - Time-Series Anomaly Detection +===================================================== + +Tracks how token metrics CHANGE over time, not just what they ARE. +Fast changes = higher risk than static bad metrics. + +Premium feature: Catches rugs mid-flight, not just at launch. +""" + +import logging +import time +from collections import defaultdict +from typing import Any + +logger = logging.getLogger("sentinel.velocity") + +# In-memory time-series cache (backed by Redis in production) +# { "chain:address": [(timestamp, {metrics}), ...] } +_series_cache: dict[str, list] = defaultdict(list) +MAX_SERIES_LENGTH = 10 # Keep last 10 snapshots per token + + +def record_snapshot(chain: str, address: str, metrics: dict[str, float]) -> None: + """Record a metrics snapshot for velocity tracking.""" + key = f"{chain}:{address.lower()}" + _series_cache[key].append((time.time(), metrics)) + if len(_series_cache[key]) > MAX_SERIES_LENGTH: + _series_cache[key] = _series_cache[key][-MAX_SERIES_LENGTH:] + + +def analyze_velocity(chain: str, address: str, current: dict[str, float], window_seconds: int = 3600) -> dict[str, Any]: + """Analyze how fast metrics are changing. Returns velocity scores and flags. + + Metrics tracked: + - holder_concentration: top10% change per hour + - lp_depth: liquidity depth change per hour + - volume_liquidity_ratio: vol/liq ratio acceleration + - price: price change per hour + - tx_count: transaction velocity + """ + key = f"{chain}:{address.lower()}" + history = _series_cache.get(key, []) + + # Record current + record_snapshot(chain, address, current) + + if len(history) < 2: + return {"status": "insufficient_data", "snapshots": len(history) + 1} + + # Find snapshots within window + now = time.time() + window_snapshots = [(ts, m) for ts, m in history if now - ts <= window_seconds] + + if len(window_snapshots) < 2: + return {"status": "insufficient_window_data", "snapshots": len(window_snapshots) + 1} + + oldest = window_snapshots[0][1] + newest = current + time_span = now - window_snapshots[0][0] + hours = max(time_span / 3600, 0.01) + + velocities = {} + flags = [] + risk_score = 0 + + # Holder concentration velocity + if "top10_pct" in oldest and "top10_pct" in newest: + holder_delta = newest["top10_pct"] - oldest["top10_pct"] + holder_velocity = holder_delta / hours + velocities["holder_concentration_delta_per_hour"] = round(holder_velocity, 2) + + if holder_velocity > 20: + flags.append("HOLDER_CONCENTRATION_SURGING") + risk_score += 25 + elif holder_velocity > 10: + flags.append("HOLDER_CONCENTRATION_RISING_FAST") + risk_score += 15 + elif holder_velocity > 5: + flags.append("HOLDER_CONCENTRATION_RISING") + risk_score += 8 + + # LP depth velocity + if "liquidity_usd" in oldest and "liquidity_usd" in newest: + old_liq = max(oldest["liquidity_usd"], 1) + new_liq = max(newest["liquidity_usd"], 1) + lp_delta_pct = ((new_liq - old_liq) / old_liq) * 100 + lp_velocity = lp_delta_pct / hours + velocities["lp_depth_change_pct_per_hour"] = round(lp_velocity, 2) + + if lp_velocity < -30: + flags.append("LP_DRAINING_RAPIDLY") + risk_score += 30 + elif lp_velocity < -15: + flags.append("LP_DECREASING_FAST") + risk_score += 20 + elif lp_velocity < -5: + flags.append("LP_DECREASING") + risk_score += 10 + + # Volume/liquidity ratio acceleration + if all(k in oldest and k in newest for k in ["volume_24h", "liquidity_usd"]): + old_ratio = oldest["volume_24h"] / max(oldest["liquidity_usd"], 1) + new_ratio = newest["volume_24h"] / max(newest["liquidity_usd"], 1) + ratio_delta = new_ratio - old_ratio + velocities["volume_liquidity_ratio_change"] = round(ratio_delta, 3) + + if ratio_delta > 10: + flags.append("VOLUME_SPIKE_VS_LIQUIDITY") + risk_score += 15 + elif ratio_delta > 5: + flags.append("VOLUME_RISING_VS_LIQUIDITY") + risk_score += 8 + + # Price velocity + if "price_usd" in oldest and "price_usd" in newest: + old_price = max(oldest["price_usd"], 0.000001) + new_price = max(newest["price_usd"], 0.000001) + price_delta_pct = ((new_price - old_price) / old_price) * 100 + price_velocity = price_delta_pct / hours + velocities["price_change_pct_per_hour"] = round(price_velocity, 2) + + if price_velocity < -50: + flags.append("PRICE_CRASHING") + risk_score += 20 + elif price_velocity > 500: + flags.append("PRICE_PUMPING_ABNORMALLY") + risk_score += 12 + + # Tx count velocity + if "tx_count" in oldest and "tx_count" in newest: + tx_delta = newest["tx_count"] - oldest["tx_count"] + tx_velocity = tx_delta / hours + velocities["tx_count_change_per_hour"] = round(tx_velocity, 1) + + if tx_velocity > 1000: + flags.append("TX_VOLUME_SURGING") + risk_score += 10 + + return { + "status": "ok", + "snapshots": len(window_snapshots) + 1, + "time_window_hours": round(hours, 2), + "velocities": velocities, + "flags": flags, + "velocity_risk_score": min(risk_score, 100), + "risk_level": "critical" + if risk_score > 60 + else "high" + if risk_score > 30 + else "medium" + if risk_score > 10 + else "low", + } + + +def get_market_context() -> dict[str, Any]: + """Get current market conditions for contextualized scoring.""" + try: + import asyncio + + import httpx + + async def _fetch(): + async with httpx.AsyncClient(timeout=5) as c: + resp = await c.get("https://api.alternative.me/fng/?limit=1") + if resp.status_code == 200: + data = resp.json() + fng = data.get("data", [{}])[0] + return { + "fear_greed_value": int(fng.get("value", 50)), + "fear_greed_classification": fng.get("value_classification", "Neutral"), + "timestamp": fng.get("timestamp", ""), + } + return {"fear_greed_value": 50, "fear_greed_classification": "Unknown"} + + return asyncio.run(_fetch()) + except Exception as e: + logger.warning(f"Failed to fetch market context: {e}") + return {"fear_greed_value": 50, "fear_greed_classification": "Unknown"} + + +def contextualize_score(base_safety: int, market_context: dict[str, Any]) -> dict[str, Any]: + """Adjust safety score based on market conditions. + + During Extreme Greed (>75): scammers launch more - raise sensitivity + During Extreme Fear (<25): fewer scams launch - lower sensitivity + """ + fng = market_context.get("fear_greed_value", 50) + + # Market pressure: how much to adjust + if fng > 75: # Extreme Greed - more scams + pressure = 1.3 + context = "Scam alert elevated - market in Extreme Greed" + elif fng > 60: # Greed + pressure = 1.15 + context = "Elevated scam risk - market in Greed" + elif fng < 25: # Extreme Fear - fewer new scams + pressure = 0.85 + context = "Reduced scam activity - market in Extreme Fear" + elif fng < 40: # Fear + pressure = 0.95 + context = "Slightly reduced risk - market in Fear" + else: # Neutral + pressure = 1.0 + context = "Normal market conditions" + + adjusted = max(0, min(100, round(base_safety / pressure))) + + return { + "base_safety": base_safety, + "contextualized_safety": adjusted, + "market_pressure": round(pressure, 2), + "fear_greed": fng, + "classification": market_context.get("fear_greed_classification", "Neutral"), + "context": context, + } diff --git a/app/_archive/legacy_2026_07/vision_analysis.py b/app/_archive/legacy_2026_07/vision_analysis.py new file mode 100644 index 0000000..3ea5f9d --- /dev/null +++ b/app/_archive/legacy_2026_07/vision_analysis.py @@ -0,0 +1,135 @@ +"""Multi-Modal Token Analysis - Gemini 2.5 Flash vision for logo/website scanning. +Detects: stolen artwork, template websites, fake team photos, suspicious branding.""" + +import base64 +import os + +import httpx +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/vision", tags=["vision-analysis"]) + +GEMINI_KEY = os.getenv("GEMINI_API_KEY", "") +GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" +GEMINI_VISION_MODEL = "gemini-2.5-flash" # 1,500 req/day free +GEMINI_PRO_MODEL = "gemini-2.5-pro" # 50 req/day free - use sparingly + + +async def _analyze_image(image_url: str, question: str) -> dict: + """Send image to Gemini 2.5 Flash for vision analysis.""" + if not GEMINI_KEY: + return {"error": "GEMINI_API_KEY not configured"} + + try: + # Fetch image and convert to base64 + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(image_url) + if r.status_code != 200: + return {"error": f"Image fetch failed: {r.status_code}"} + img_b64 = base64.b64encode(r.content).decode() + + # Send to Gemini + payload = { + "contents": [{"parts": [{"text": question}, {"inline_data": {"mime_type": "image/png", "data": img_b64}}]}] + } + + async with httpx.AsyncClient(timeout=30) as c: + r = await c.post(f"{GEMINI_URL}?key={GEMINI_KEY}", json=payload) + if r.status_code == 200: + data = r.json() + text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") + return {"analysis": text, "model": "gemini-2.5-flash"} + return {"error": f"Gemini API: {r.status_code}"} + except Exception as e: + return {"error": str(e)} + + +@router.get("/token-logo") +async def analyze_token_logo(token_address: str, chain: str = Query("solana")): + """Analyze token logo for scam indicators - stolen art, AI-generated, generic.""" + + # Build logo URL based on chain + logo_urls = { + "solana": f"https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/{token_address}/logo.png", + "ethereum": f"https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/{token_address}/logo.png", + "bsc": f"https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/smartchain/assets/{token_address}/logo.png", + } + + logo_url = logo_urls.get(chain, logo_urls["solana"]) + + question = """Analyze this token logo for scam indicators: +1. Is it AI-generated? (look for artifacts, unnatural symmetry) +2. Is it stolen from another project? (known brands, copied designs) +3. Is it generic/template? (no original design elements) +4. Does it look professional or rushed? +5. SCAM_SCORE: give a 0-100 score where 100 = definitely a scam logo +Respond in JSON: {"ai_generated": bool, "stolen": bool, "generic": bool, "professional": bool, "scam_score": int, "explanation": "one sentence"}""" + + result = await _analyze_image(logo_url, question) + result["token"] = token_address + result["chain"] = chain + return result + + +@router.get("/website-scan") +async def scan_token_website(url: str): + """Scan a token's website screenshot for red flags.""" + # Use a screenshot service or just analyze the URL patterns + website_red_flags = [] + + # Quick pattern checks + import re + + if re.search(r"(airdrop|claim|giveaway|free).*\.(io|com|xyz)", url.lower()): + website_red_flags.append("Scam giveaway pattern in URL") + if url.endswith((".xyz", ".click", ".win", ".loan")): + website_red_flags.append("Suspicious TLD - common for scams") + if len(url) > 50: + website_red_flags.append("Unusually long URL - possible phishing") + + risk = min(100, len(website_red_flags) * 30) + + return { + "url": url, + "red_flags": website_red_flags, + "risk_score": risk, + "risk_level": "HIGH" if risk > 50 else "MEDIUM" if risk > 20 else "LOW", + "note": "Full screenshot analysis requires Gemini Pro vision. Upgrade for visual scan." + if not GEMINI_KEY + else "Gemini vision analysis ready.", + } + + +@router.get("/token-visual-audit") +async def full_visual_audit(token_address: str, chain: str = "solana", website_url: str = ""): + """Complete visual audit: logo + website + social presence.""" + logo_check = await analyze_token_logo(token_address, chain) + website_check = await scan_token_website(website_url) if website_url else {} + + logo_score = logo_check.get("analysis", {}) + website_score = website_check.get("risk_score", 0) + + # Combined score + logo_scam = logo_score.get("scam_score", 0) if isinstance(logo_score, dict) else 0 + + combined_score = (logo_scam * 0.6) + (website_score * 0.4) + + return { + "token": token_address, + "chain": chain, + "overall_visual_risk": round(combined_score, 1), + "risk_level": "CRITICAL" + if combined_score > 70 + else "HIGH" + if combined_score > 40 + else "MEDIUM" + if combined_score > 20 + else "LOW", + "logo_analysis": logo_check, + "website_analysis": website_check, + "verdict": "Visual audit shows significant scam indicators. Avoid." + if combined_score > 50 + else "Visual audit passed. No obvious red flags in branding." + if combined_score < 25 + else "Some concerns. Proceed with caution.", + } diff --git a/app/_archive/legacy_2026_07/vulnerability_mapper.py b/app/_archive/legacy_2026_07/vulnerability_mapper.py new file mode 100644 index 0000000..8beb5c5 --- /dev/null +++ b/app/_archive/legacy_2026_07/vulnerability_mapper.py @@ -0,0 +1,458 @@ +""" +Research-Backed Vulnerability Mapping +====================================== + +Extracts structured vulnerability taxonomies from ingested research papers +in forensic_reports. Maps each vulnerability to prerequisites, indicators, +and severity - then the scanner checks tokens against this structured KB. + +Premium feature: "This token matches 4/5 prerequisites for Flash Loan Variant 3 +per the SCSGuard paper (Zhou et al., 2024)" +""" + +import logging +from typing import Any + +logger = logging.getLogger("rag.vuln_map") + +# ────────────────────────────────────────────────────────────── +# Curated Vulnerability Taxonomy from 24 Research Papers +# ────────────────────────────────────────────────────────────── + +VULNERABILITY_TAXONOMY = [ + { + "id": "flash_loan_v1", + "name": "Flash Loan Price Manipulation", + "paper": "SCSGuard (Zhou et al., 2024)", + "category": "flash_loan", + "severity": "critical", + "prerequisites": [ + "low_liquidity_pool", + "single_oracle_price_source", + "borrowable_token_in_pool", + "no_slippage_protection", + "mintable_or_burnable_token", + ], + "indicators": [ + "large_borrow_followed_by_swap", + "same_block_borrow_repay", + "price_deviation_across_sources", + "sandwich_attack_pattern", + ], + "description": "Attacker borrows large amount, manipulates pool price via swap, exploits protocol at manipulated price, repays loan in same block.", + "mitigation": "Use TWAP oracles, multi-source price feeds, slippage limits", + }, + { + "id": "honeypot_v1", + "name": "Classic Honeypot (Sell Block)", + "paper": "BLOCKEYE (Wang et al., 2024)", + "category": "honeypot", + "severity": "critical", + "prerequisites": [ + "unverified_contract", + "hidden_sell_block", + "trading_enabled_only_for_owner", + "suspicious_modifier_patterns", + ], + "indicators": [ + "buy_succeeds_sell_fails", + "transfer_from_fails_for_non_owner", + "setFee_or_tradingOpen_in_code", + "balanceOf_returns_zero_on_sell_attempt", + ], + "description": "Token allows buying but blocks selling through hidden modifiers, blacklists, or fee mechanisms.", + "mitigation": "Simulate both buy and sell before trading, audit transfer/approve functions", + }, + { + "id": "honeypot_v2", + "name": "Tax-Based Honeypot (>90% fee)", + "paper": "BLOCKEYE (Wang et al., 2024)", + "category": "honeypot", + "severity": "high", + "prerequisites": [ + "variable_fee_mechanism", + "owner_controlled_fee", + "fee_set_to_extreme_value", + ], + "indicators": [ + "sell_tax_over_50pct", + "fee_changed_after_launch", + "fee_setter_is_deployer", + ], + "description": "Token sets extreme sell tax making selling economically nonviable. Tax can be changed by owner at any time.", + "mitigation": "Check current fee before trading, verify fee cannot exceed reasonable threshold", + }, + { + "id": "rugpull_v1", + "name": "Liquidity Removal Rug Pull", + "paper": "RugPullDetector (Cernera et al., 2023)", + "category": "rugpull", + "severity": "critical", + "prerequisites": [ + "unlocked_liquidity", + "deployer_holds_lp_tokens", + "low_lp_lock_duration", + ], + "indicators": [ + "lp_tokens_not_burned_or_locked", + "deployer_wallet_holds_large_lp_share", + "liquidity_dropped_to_zero", + "single_lp_provider", + ], + "description": "Deployer removes all liquidity from pool, making token worthless. Most common rug pull pattern.", + "mitigation": "Verify LP tokens are locked/burned, check lock duration", + }, + { + "id": "rugpull_v2", + "name": "Dump-and-Abandon", + "paper": "Honeypot Hunter (Torres et al., 2025)", + "category": "rugpull", + "severity": "high", + "prerequisites": [ + "high_deployer_token_allocation", + "deployer_holds_majority_supply", + "no_vesting_schedule", + ], + "indicators": [ + "large_sell_after_pump", + "deployer_balance_decreasing_rapidly", + "single_wallet_dominates_sells", + "social_media_goes_dark_after_dump", + ], + "description": "Deployer pumps price through marketing/KOLs, then dumps their large supply on retail buyers.", + "mitigation": "Check deployer token allocation, vesting schedule, sell patterns", + }, + { + "id": "governance_v1", + "name": "Governance Takeover", + "paper": "DeFiRanger (Wu et al., 2024)", + "category": "governance", + "severity": "high", + "prerequisites": [ + "governance_token_tradeable", + "low_quorum_requirement", + "short_timelock", + "concentrated_token_supply", + ], + "indicators": [ + "governance_proposal_from_unknown_address", + "large_token_accumulation_before_proposal", + "proposal_passes_with_few_votes", + "treasury_drain_after_governance_change", + ], + "description": "Attacker accumulates governance tokens, passes malicious proposal, drains treasury or upgrades to malicious contract.", + "mitigation": "Long timelocks, high quorum, multi-sig execution", + }, + { + "id": "proxy_v1", + "name": "Uninitialized Proxy Attack", + "paper": "Secuify (Nguyen et al., 2025)", + "category": "proxy", + "severity": "critical", + "prerequisites": [ + "proxy_contract_pattern", + "uninitialized_implementation", + "no_access_control_on_init", + ], + "indicators": [ + "proxy_detected_in_bytecode", + "implementation_not_initialized", + "initialize_function_callable_by_anyone", + "eip1967_storage_slot_empty", + ], + "description": "Uninitialized proxy allows anyone to call initialize() and take ownership of the contract.", + "mitigation": "Call initialize() in constructor, add onlyOwner to initialize, use OpenZeppelin initializer pattern", + }, + { + "id": "oracle_v1", + "name": "Oracle Price Manipulation", + "paper": "SoK: Oracle Manipulation (Eskandari et al., 2024)", + "category": "oracle", + "severity": "high", + "prerequisites": [ + "uses_onchain_price_oracle", + "low_liquidity_oracle_source", + "no_twap_or_time_delay", + ], + "indicators": [ + "spot_price_used_instead_of_twap", + "oracle_update_in_same_tx_as_trade", + "price_divergence_across_oracles", + "single_oracle_source", + ], + "description": "Attacker manipulates on-chain price oracle through flash loan or large trade, exploits protocol using manipulated price.", + "mitigation": "Use TWAP, multi-source oracles, time delays", + }, + { + "id": "access_control_v1", + "name": "Unprotected Self-Destruct", + "paper": "Smart Contract Weakness Classification (Chen et al., 2024)", + "category": "access_control", + "severity": "critical", + "prerequisites": [ + "selfdestruct_in_bytecode", + "no_onlyowner_on_destruct", + "delegatecall_to_untrusted", + ], + "indicators": [ + "selfdestruct_opcode_detected", + "no_access_modifier_on_destruct_function", + "delegatecall_pattern_detected", + ], + "description": "Anyone can call selfdestruct on the contract, permanently destroying it and losing all funds.", + "mitigation": "Add onlyOwner to selfdestruct, use proxy upgrade pattern instead", + }, + { + "id": "supply_v1", + "name": "Unlimited Minting", + "paper": "TokenScope (Chen et al., 2023)", + "category": "supply_manipulation", + "severity": "critical", + "prerequisites": [ + "mint_function_present", + "no_cap_on_mint", + "mint_not_renounced", + ], + "indicators": [ + "mint_authority_not_renounced", + "no_maxSupply_or_uncapped", + "mint_called_after_launch", + "totalSupply_increasing_unexpectedly", + ], + "description": "Owner can mint unlimited tokens, diluting existing holders to zero value.", + "mitigation": "Renounce mint authority, cap max supply, use fixed supply token standard", + }, +] + + +def check_token_against_taxonomy(scan_result: dict[str, Any]) -> dict[str, Any]: + """Check a token scan result against the vulnerability taxonomy. + + Returns matched vulnerabilities with confidence scores and paper citations. + """ + free = scan_result.get("free", scan_result) if isinstance(scan_result, dict) else {} + + matches = [] + + for vuln in VULNERABILITY_TAXONOMY: + prereqs_met = 0 + indicators_met = 0 + matched_prereqs = [] + matched_indicators = [] + + # Check prerequisites + for prereq in vuln["prerequisites"]: + if _check_prerequisite(prereq, free, scan_result): + prereqs_met += 1 + matched_prereqs.append(prereq) + + # Check indicators + for indicator in vuln["indicators"]: + if _check_indicator(indicator, free, scan_result): + indicators_met += 1 + matched_indicators.append(indicator) + + total_prereqs = len(vuln["prerequisites"]) + total_indicators = len(vuln["indicators"]) + + if prereqs_met >= total_prereqs * 0.5: # At least 50% prereqs matched + prereq_score = prereqs_met / total_prereqs + indicator_score = indicators_met / max(total_indicators, 1) + confidence = prereq_score * 0.6 + indicator_score * 0.4 # Prereqs weighted more + + risk_level = ( + "critical" + if confidence > 0.8 + else "high" + if confidence > 0.6 + else "medium" + if confidence > 0.4 + else "low" + ) + + matches.append( + { + "vulnerability": vuln["name"], + "vuln_id": vuln["id"], + "category": vuln["category"], + "severity": vuln["severity"], + "confidence": round(confidence, 3), + "risk_level": risk_level, + "prerequisites_matched": f"{prereqs_met}/{total_prereqs}", + "indicators_matched": f"{indicators_met}/{total_indicators}", + "matched_prereqs": matched_prereqs, + "matched_indicators": matched_indicators, + "paper": vuln["paper"], + "description": vuln["description"], + "mitigation": vuln["mitigation"], + } + ) + + # Sort by confidence + matches.sort(key=lambda m: -m["confidence"]) + + top_risk = matches[0]["risk_level"] if matches else "unknown" + + return { + "status": "ok", + "vulnerabilities_matched": len(matches), + "overall_risk": top_risk, + "matches": matches[:5], # Top 5 matches + } + + +def _check_prerequisite(prereq: str, free: dict, scan: dict) -> bool: + """Check if a prerequisite condition is met in the scan data.""" + # ── Liquidity checks ── + if prereq == "low_liquidity_pool": + liq = float(free.get("liquidity_usd", 0) or 0) + return liq < 10000 + if prereq == "unlocked_liquidity": + lp = free.get("lp_lock_multi", {}) or {} + return not (lp.get("locked") or lp.get("is_locked")) + if prereq == "deployer_holds_lp_tokens": + deployer = free.get("deployer", {}) or {} + return deployer.get("holds_lp", False) + if prereq == "low_lp_lock_duration": + lp = free.get("lp_lock_multi", {}) or {} + duration = lp.get("lock_duration_days", 0) or 0 + return duration < 7 + + # ── Contract checks ── + if prereq == "unverified_contract": + contract = free.get("contract_verification", {}) or {} + return not contract.get("verified", False) + if prereq == "proxy_contract_pattern": + contract = free.get("contract_verification", {}) or {} + return ( + contract.get("is_proxy", False) or free.get("proxy_detect", {}).get("is_proxy", False) + if isinstance(free.get("proxy_detect"), dict) + else False + ) + if prereq == "uninitialized_implementation": + proxy = free.get("proxy_detect", {}) or {} + return proxy.get("uninitialized", False) + if prereq == "selfdestruct_in_bytecode": + static = free.get("static_analysis", {}) or {} + return "selfdestruct" in str(static.get("findings", [])).lower() + if prereq == "delegatecall_to_untrusted": + static = free.get("static_analysis", {}) or {} + return "delegatecall" in str(static.get("findings", [])).lower() + + # ── Authority checks ── + if prereq == "mint_function_present" or prereq == "mintable_or_burnable_token": + return free.get("mint_authority") and free.get("mint_authority") != "renounced" + if prereq == "no_cap_on_mint": + return True # Most tokens don't have caps + if prereq == "mint_not_renounced": + return free.get("mint_authority") and free.get("mint_authority") != "renounced" + if prereq == "variable_fee_mechanism": + sim = free.get("simulation", {}) or {} + return sim.get("buy_tax_pct", 0) != sim.get("sell_tax_pct", 0) + if prereq == "owner_controlled_fee": + return free.get("freeze_authority") is not None + if prereq == "no_access_control_on_init": + proxy = free.get("proxy_detect", {}) or {} + return proxy.get("no_access_control", False) + + # ── Oracle checks ── + if prereq == "single_oracle_price_source": + consensus = free.get("price_consensus", {}) or {} + return consensus.get("sources_used", 0) < 2 + if prereq == "no_slippage_protection": + return True # Hard to detect from external scan + + # ── Holder checks ── + if prereq == "high_deployer_token_allocation" or prereq == "deployer_holds_majority_supply": + holders = free.get("holders", {}) or {} + return float(holders.get("top1_pct", 0) or 0) > 50 + if prereq == "concentrated_token_supply": + holders = free.get("holders", {}) or {} + return float(holders.get("top10_pct", 0) or 0) > 80 + + # ── Governance checks ── + if prereq == "governance_token_tradeable": + return True # Most are + if prereq == "low_quorum_requirement": + gov = free.get("governance", {}) or {} + return float(gov.get("quorum_pct", 100) or 100) < 10 + if prereq == "short_timelock": + gov = free.get("governance", {}) or {} + return float(gov.get("timelock_hours", 0) or 0) < 24 + + return False + + +def _check_indicator(indicator: str, free: dict, scan: dict) -> bool: + """Check if an indicator is present in the scan data.""" + # ── Trade simulation indicators ── + sim = free.get("simulation", {}) or {} + if indicator == "buy_succeeds_sell_fails": + return sim.get("buy_success") and not sim.get("sell_success") + if indicator == "sell_tax_over_50pct": + return float(sim.get("sell_tax_pct", 0) or 0) > 50 + if indicator == "fee_changed_after_launch": + return sim.get("fee_changed", False) + + # ── LP indicators ── + if indicator == "lp_tokens_not_burned_or_locked": + lp = free.get("lp_lock_multi", {}) or {} + return not (lp.get("locked") or lp.get("burned")) + if indicator == "single_lp_provider": + return ( + free.get("liquidity_pools", [{}])[0].get("provider_count", 2) == 1 if free.get("liquidity_pools") else False + ) + if indicator == "liquidity_dropped_to_zero": + return float(free.get("liquidity_usd", 1) or 1) < 0.01 + + # ── Holder/deployer indicators ── + if indicator == "deployer_wallet_holds_large_lp_share": + deployer = free.get("deployer", {}) or {} + return deployer.get("holds_lp", False) and float(deployer.get("lp_share_pct", 0) or 0) > 30 + if indicator == "single_wallet_dominates_sells": + holders = free.get("holders", {}) or {} + return float(holders.get("top1_pct", 0) or 0) > 70 + + # ── Pattern indicators ── + if indicator == "large_borrow_followed_by_swap": + flash = free.get("flash_loan", {}) or {} + return flash.get("detected", False) + if indicator == "sandwich_attack_pattern": + mev = free.get("mev", {}) or {} + return mev.get("sandwich_detected", False) + if indicator == "price_deviation_across_sources": + consensus = free.get("price_consensus", {}) or {} + return consensus.get("manipulation_suspected", False) + + # ── Contract indicators ── + if ( + indicator == "selfdestruct_opcode_detected" + or indicator == "delegatecall_pattern_detected" + or indicator == "proxy_detected_in_bytecode" + ): + static = free.get("static_analysis", {}) or {} + findings = str(static.get("findings", [])).lower() + term = ( + indicator.replace("_detected", "") + .replace("_opcode", "") + .replace("_pattern", "") + .replace("_in_bytecode", "") + ) + return term in findings + if indicator == "mint_authority_not_renounced": + return free.get("mint_authority") and free.get("mint_authority") != "renounced" + if indicator == "governance_proposal_from_unknown_address": + gov = free.get("governance", {}) or {} + return gov.get("suspicious_proposal", False) + + # ── External API indicators ── + hp = free.get("honeypot_is", {}) or {} + if indicator == "transfer_from_fails_for_non_owner": + return hp.get("is_honeypot", False) or not sim.get("sell_success") + + # ── Copycat ── + cc = free.get("copycat_check", {}) or {} + if indicator == "suspicious_modifier_patterns": + return cc.get("is_copycat", False) + + return False diff --git a/app/_archive/legacy_2026_07/wallet_behavior.py b/app/_archive/legacy_2026_07/wallet_behavior.py new file mode 100644 index 0000000..fbfe2aa --- /dev/null +++ b/app/_archive/legacy_2026_07/wallet_behavior.py @@ -0,0 +1,447 @@ +""" +Wallet Behavioral Fingerprinting - Beyond Labels +================================================ + +Classifies wallets by HOW they trade, not just what labels they have. +Computes behavioral fingerprints from on-chain data and assigns +persona classifications. + +Premium feature: "Smart Money Accumulator", "Meme Dumper", "Exit LP", etc. +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +logger = logging.getLogger("wallet.behavior") + +# ────────────────────────────────────────────────────────────── +# Persona Definitions +# ────────────────────────────────────────────────────────────── + +PERSONAS = { + "smart_money_accumulator": { + "name": "Smart Money Accumulator", + "icon": "🧠", + "description": "Buys early, holds long, sells at peaks. High win rate.", + "signals": ["early_entry", "long_hold", "profit_taking_at_peak", "low_tx_frequency"], + "risk_level": "low", + "premium": True, + }, + "meme_dumper": { + "name": "Meme Dumper", + "icon": "💩", + "description": "Buys meme coin launches, dumps within hours. High velocity.", + "signals": ["meme_only", "short_hold", "high_velocity", "sells_into_strength"], + "risk_level": "high", + "premium": True, + }, + "exit_liquidity_provider": { + "name": "Exit Liquidity Provider", + "icon": "🚪", + "description": "Provides exit liquidity for others. Buys tops, sells bottoms.", + "signals": ["late_entry", "panic_sell", "buy_high_sell_low", "high_loss_rate"], + "risk_level": "neutral", + "premium": True, + }, + "mev_extractor": { + "name": "MEV Extractor", + "icon": "🤖", + "description": "Sandwich attacks, front-running, arbitrage. Bot-like patterns.", + "signals": ["sandwich_pattern", "high_frequency", "arbitrage_loops", "zero_hold_time"], + "risk_level": "neutral", + "premium": True, + }, + "insider_accumulator": { + "name": "Insider Accumulator", + "icon": "🔮", + "description": "Buys tokens before public launch/announcement. Presale/sniper access.", + "signals": [ + "pre_announcement_buy", + "insider_wallet_links", + "sniper_pattern", + "multi_wallet", + ], + "risk_level": "high", + "premium": True, + }, + "whale_distributor": { + "name": "Whale Distributor", + "icon": "🐋", + "description": "Large holder distributing to many wallets. Accumulation → distribution cycle.", + "signals": ["large_balance", "distribution_pattern", "cex_deposits", "wallet_cluster"], + "risk_level": "medium", + "premium": True, + }, + "bot_farm": { + "name": "Bot Farm", + "icon": "🤖", + "description": "Automated trading across many wallets. Same patterns, different addresses.", + "signals": [ + "identical_patterns", + "multi_wallet_sync", + "high_frequency", + "no_human_variance", + ], + "risk_level": "high", + "premium": True, + }, + "retail_trader": { + "name": "Retail Trader", + "icon": "👤", + "description": "Small balances, diverse tokens, irregular patterns. Normal behavior.", + "signals": ["small_balance", "diverse_tokens", "irregular_timing", "human_variance"], + "risk_level": "low", + "premium": False, + }, + "honeypot_victim": { + "name": "Honeypot Victim", + "icon": "🪤", + "description": "Repeatedly buys scam/honeypot tokens. Pattern of trapped funds.", + "signals": ["buys_honeypots", "trapped_funds", "no_sells_on_scam_tokens", "repeat_victim"], + "risk_level": "high", + "premium": True, + }, +} + + +@dataclass +class WalletFingerprint: + """Complete behavioral fingerprint for a wallet.""" + + address: str + chain: str + + # Trading behavior + avg_hold_hours: float = 0.0 + median_hold_hours: float = 0.0 + trade_frequency_per_day: float = 0.0 + total_trades: int = 0 + win_rate: float = 0.0 # % of trades profitable + avg_profit_pct: float = 0.0 + avg_loss_pct: float = 0.0 + profit_factor: float = 0.0 # gross profit / gross loss + + # Timing + first_trade_age_days: float = 0.0 + preferred_entry_timing: str = "unknown" # "early" (first hour), "mid", "late" + trades_by_hour: dict[int, int] = field(default_factory=dict) + + # Token preferences + token_types: dict[str, int] = field(default_factory=dict) # "meme"/"defi"/"stable"/"wrapped" → count + preferred_chains: list[str] = field(default_factory=list) + avg_token_age_at_entry_hours: float = 0.0 # How new are tokens when this wallet buys? + + # Risk indicators + interacts_with_scams: bool = False + honeypot_interactions: int = 0 + rug_interactions: int = 0 + sanction_exposure: bool = False + + # Network + counterparty_count: int = 0 + cluster_size: int = 1 + funding_source_type: str = "unknown" # "cex"/"dex"/"mixer"/"bridge"/"unknown" + + # Classification + primary_persona: str = "retail_trader" + persona_confidence: float = 0.0 + secondary_personas: list[dict] = field(default_factory=list) + + # Scores + sophistication_score: float = 0.0 # 0-100, how sophisticated is this trader? + risk_score: float = 0.0 # 0-100, how risky is interacting with this wallet? + reliability_score: float = 0.0 # 0-100, how reliable/consistent is behavior? + + +def compute_fingerprint(wallet_data: dict[str, Any]) -> WalletFingerprint: + """Compute behavioral fingerprint from wallet on-chain data.""" + address = wallet_data.get("address", "") + chain = wallet_data.get("chain", "ethereum") + + fp = WalletFingerprint(address=address, chain=chain) + + # ── Extract transaction patterns ── + txs = wallet_data.get("transactions", []) or [] + tokens_held = wallet_data.get("tokens", []) or [] + risk_factors = wallet_data.get("risk_factors", {}) or {} + + fp.total_trades = len(txs) + + if txs: + # Hold time analysis + hold_times = [] + profits = [] + losses = [] + + for tx in txs: + if isinstance(tx, dict): + hold_s = tx.get("hold_seconds") or tx.get("hold_time_s", 0) + if hold_s > 0: + hold_times.append(hold_s / 3600) + + pnl = tx.get("pnl_usd") or tx.get("realized_pnl", 0) + if pnl > 0: + profits.append(pnl) + elif pnl < 0: + losses.append(abs(pnl)) + + if hold_times: + fp.avg_hold_hours = sum(hold_times) / len(hold_times) + hold_times.sort() + fp.median_hold_hours = hold_times[len(hold_times) // 2] + + if profits or losses: + total_profit = sum(profits) + total_loss = sum(losses) + fp.win_rate = len(profits) / len(profits + losses) if (profits or losses) else 0 + fp.avg_profit_pct = (sum(profits) / len(profits)) if profits else 0 + fp.avg_loss_pct = (sum(losses) / len(losses)) if losses else 0 + fp.profit_factor = total_profit / max(total_loss, 1) + + # Trade frequency + if txs and len(txs) >= 2: + timestamps = sorted([tx.get("timestamp", 0) for tx in txs if isinstance(tx, dict) and tx.get("timestamp")]) + if len(timestamps) >= 2: + time_span_days = (max(timestamps) - min(timestamps)) / 86400 + if time_span_days > 0: + fp.trade_frequency_per_day = len(txs) / time_span_days + + # Hour distribution + for tx in txs: + if isinstance(tx, dict) and tx.get("timestamp"): + hour = datetime.fromtimestamp(tx["timestamp"]).hour + fp.trades_by_hour[hour] = fp.trades_by_hour.get(hour, 0) + 1 + + # ── Token preferences ── + for token in tokens_held: + if isinstance(token, dict): + ttype = token.get("type", token.get("category", "unknown")) + fp.token_types[ttype] = fp.token_types.get(ttype, 0) + 1 + + # ── Risk indicators from risk_factors ── + fp.interacts_with_scams = risk_factors.get("interacts_with_scams", False) + fp.honeypot_interactions = risk_factors.get("honeypot_interactions", 0) + fp.rug_interactions = risk_factors.get("rug_interactions", 0) + fp.sanction_exposure = risk_factors.get("sanction_exposure", False) + fp.funding_source_type = risk_factors.get("funding_source", "unknown") + fp.counterparty_count = risk_factors.get("counterparty_count", 0) + fp.cluster_size = risk_factors.get("cluster_size", 1) + + # ── Entry timing preference ── + entries = [tx for tx in txs if isinstance(tx, dict) and tx.get("type") == "buy"] + if entries: + token_ages = [tx.get("token_age_hours", 0) for tx in entries if tx.get("token_age_hours")] + if token_ages: + fp.avg_token_age_at_entry_hours = sum(token_ages) / len(token_ages) + if fp.avg_token_age_at_entry_hours < 1: + fp.preferred_entry_timing = "early" + elif fp.avg_token_age_at_entry_hours < 24: + fp.preferred_entry_timing = "mid" + else: + fp.preferred_entry_timing = "late" + + # ── Sophistication score (0-100) ── + sophistication = 50.0 + if fp.profit_factor > 2: + sophistication += 15 + if fp.win_rate > 0.6: + sophistication += 10 + if fp.trade_frequency_per_day < 3: + sophistication += 5 # Not overtrading + if fp.avg_hold_hours > 24: + sophistication += 5 # Not a flipper + if fp.counterparty_count > 50: + sophistication += 5 + if fp.interacts_with_scams: + sophistication -= 20 + if fp.honeypot_interactions > 0: + sophistication -= 15 + fp.sophistication_score = max(0, min(100, sophistication)) + + # ── Risk score (0-100, higher = riskier to interact with) ── + risk = 0.0 + if fp.interacts_with_scams: + risk += 30 + if fp.honeypot_interactions > 3: + risk += 25 + elif fp.honeypot_interactions > 0: + risk += 10 + if fp.sanction_exposure: + risk += 40 + if fp.funding_source_type == "mixer": + risk += 25 + if fp.cluster_size > 10: + risk += 15 + if fp.preferred_entry_timing == "early" and fp.avg_hold_hours < 1: + risk += 10 # Sniper pattern + fp.risk_score = max(0, min(100, risk)) + + # ── Reliability score ── + reliability = 50.0 + if fp.win_rate > 0.5: + reliability += 10 + if fp.profit_factor > 1.5: + reliability += 10 + if fp.trade_frequency_per_day > 0 and fp.trade_frequency_per_day < 10: + reliability += 5 + if fp.sophistication_score > 60: + reliability += 10 + if fp.risk_score > 50: + reliability -= 30 + if fp.interacts_with_scams: + reliability -= 20 + fp.reliability_score = max(0, min(100, reliability)) + + # ── Persona classification ── + classify_persona(fp) + + return fp + + +def classify_persona(fp: WalletFingerprint): + """Assign persona based on behavioral fingerprint.""" + scores = {} + + # Smart Money Accumulator + sma_score = 0 + if fp.win_rate > 0.6: + sma_score += 3 + if fp.profit_factor > 2: + sma_score += 3 + if fp.avg_hold_hours > 48: + sma_score += 2 + if fp.trade_frequency_per_day < 2: + sma_score += 1 + if fp.sophistication_score > 70: + sma_score += 2 + scores["smart_money_accumulator"] = sma_score + + # Meme Dumper + md_score = 0 + meme_count = fp.token_types.get("meme", 0) + if meme_count > fp.total_trades * 0.7: + md_score += 3 + if fp.avg_hold_hours < 4: + md_score += 2 + if fp.trade_frequency_per_day > 5: + md_score += 1 + if fp.win_rate < 0.3: + md_score += 2 + scores["meme_dumper"] = md_score + + # MEV Extractor + mev_score = 0 + if fp.trade_frequency_per_day > 20: + mev_score += 3 + if fp.avg_hold_hours < 0.01: + mev_score += 3 + if fp.counterparty_count > 200: + mev_score += 2 + if fp.funding_source_type == "bot": + mev_score += 2 + scores["mev_extractor"] = mev_score + + # Insider Accumulator + ia_score = 0 + if fp.preferred_entry_timing == "early": + ia_score += 3 + if fp.avg_token_age_at_entry_hours < 0.5: + ia_score += 3 + if fp.win_rate > 0.7: + ia_score += 2 + if fp.cluster_size > 3: + ia_score += 1 + scores["insider_accumulator"] = ia_score + + # Whale Distributor + wd_score = 0 + if fp.counterparty_count > 100: + wd_score += 3 + if fp.cluster_size > 5: + wd_score += 2 + if fp.trade_frequency_per_day > 3: + wd_score += 1 + scores["whale_distributor"] = wd_score + + # Honeypot Victim + hv_score = 0 + if fp.honeypot_interactions > 2: + hv_score += 4 + if fp.interacts_with_scams: + hv_score += 3 + if fp.rug_interactions > 1: + hv_score += 2 + scores["honeypot_victim"] = hv_score + + # Retail (default, always scores) + rt_score = 3 + if fp.total_trades < 100: + rt_score += 1 + if fp.sophistication_score < 60: + rt_score += 1 + scores["retail_trader"] = rt_score + + # Pick primary persona + best = max(scores.items(), key=lambda x: x[1]) + fp.primary_persona = best[0] + fp.persona_confidence = min(best[1] / 8, 1.0) # Normalize + + # Secondary personas + secondary = sorted([(k, v) for k, v in scores.items() if k != best[0] and v >= 2], key=lambda x: -x[1])[:2] + fp.secondary_personas = [ + {"persona": k, "score": v, "name": PERSONAS[k]["name"], "icon": PERSONAS[k]["icon"]} for k, v in secondary + ] + + return fp + + +def fingerprint_to_dict(fp: WalletFingerprint) -> dict[str, Any]: + """Convert fingerprint to API response format.""" + persona = PERSONAS.get(fp.primary_persona, PERSONAS["retail_trader"]) + return { + "address": fp.address, + "chain": fp.chain, + "persona": { + "primary": fp.primary_persona, + "name": persona["name"], + "icon": persona["icon"], + "description": persona["description"], + "confidence": round(fp.persona_confidence, 2), + "risk_level": persona["risk_level"], + "secondaries": fp.secondary_personas, + }, + "trading_behavior": { + "total_trades": fp.total_trades, + "win_rate": round(fp.win_rate, 3), + "avg_hold_hours": round(fp.avg_hold_hours, 1), + "median_hold_hours": round(fp.median_hold_hours, 1), + "trade_frequency_per_day": round(fp.trade_frequency_per_day, 1), + "profit_factor": round(fp.profit_factor, 2), + "avg_profit_pct": round(fp.avg_profit_pct, 2), + "avg_loss_pct": round(fp.avg_loss_pct, 2), + "preferred_entry_timing": fp.preferred_entry_timing, + }, + "risk_indicators": { + "interacts_with_scams": fp.interacts_with_scams, + "honeypot_interactions": fp.honeypot_interactions, + "rug_interactions": fp.rug_interactions, + "sanction_exposure": fp.sanction_exposure, + "funding_source": fp.funding_source_type, + }, + "token_preferences": { + "types": fp.token_types, + "avg_token_age_at_entry_hours": round(fp.avg_token_age_at_entry_hours, 1), + }, + "network": { + "counterparty_count": fp.counterparty_count, + "cluster_size": fp.cluster_size, + "preferred_chains": fp.preferred_chains[:5], + }, + "scores": { + "sophistication": round(fp.sophistication_score, 1), + "risk": round(fp.risk_score, 1), + "reliability": round(fp.reliability_score, 1), + }, + } diff --git a/app/_archive/legacy_2026_07/wallet_factory_router.py b/app/_archive/legacy_2026_07/wallet_factory_router.py new file mode 100644 index 0000000..addba6d --- /dev/null +++ b/app/_archive/legacy_2026_07/wallet_factory_router.py @@ -0,0 +1,302 @@ +""" +RMI Wallet Factory API - Multi-Chain Wallet Generation & Rotation +================================================================== +REST API for generating, rotating, and managing wallets across 25+ blockchains. +Secure key storage with AES-256-GCM encryption. + +Endpoints: + GET /api/v1/wallets/chains - List supported chains + POST /api/v1/wallets/generate - Generate wallet(s) + POST /api/v1/wallets/generate/batch - Generate for multiple chains + POST /api/v1/wallets/rotate - Rotate to new wallet + GET /api/v1/wallets/vault - List vault wallets (no keys) + GET /api/v1/wallets/vault/{id} - Get wallet with key (auth required) + GET /api/v1/wallets/stats - Wallet factory statistics + POST /api/v1/wallets/export - Export for Apify/CLI usage + +Free version: 3 chains, no rotation, no encryption +Full version: All 25+ chains, rotation, encryption - available on Apify +""" + +import json +import logging +import os + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +logger = logging.getLogger("wallet_api") + +router = APIRouter(prefix="/api/v1/chain-vault", tags=["Chain Vault"]) + +# ── Import wallet factory ─────────────────────────────────────── +from app.wallet_factory import SUPPORTED_CHAINS, get_wallet_factory # noqa: E402 + +# ── Auth ──────────────────────────────────────────────────────── +ADMIN_KEY = os.getenv("ADMIN_API_KEY", "") + + +def _check_auth(request: Request) -> bool: + """Verify admin API key for sensitive operations.""" + auth = request.headers.get("X-API-Key", "") or request.headers.get("Authorization", "").replace("Bearer ", "") + return auth == ADMIN_KEY if ADMIN_KEY else True # Allow if no admin key set + + +# ── Models ────────────────────────────────────────────────────── + + +class GenerateRequest(BaseModel): + chain: str = Field(..., description="Chain key (btc, eth, sol, trx, etc.)") + label: str = Field(default="", description="Optional wallet label") + tags: list[str] = Field(default_factory=list, description="Optional tags") + count: int = Field(default=1, ge=1, le=10, description="Number of wallets to generate") + + +class BatchGenerateRequest(BaseModel): + chains: list[str] = Field(..., description="List of chain keys") + label_prefix: str = Field(default="", description="Label prefix for all wallets") + + +class RotateRequest(BaseModel): + chain: str = Field(..., description="Chain key to rotate") + + +class ExportRequest(BaseModel): + chains: list[str] = Field(default=[], description="Chains to export (empty = all)") + format: str = Field(default="json", description="Export format: json, csv, env") + + +# ── Public Endpoints ──────────────────────────────────────────── + + +@router.get("/chains") +async def list_chains(): + """List all supported blockchain networks with metadata. + + Free tier: 3 chains shown. Full version: 25+ chains. + """ + chains = {} + for key, cfg in SUPPORTED_CHAINS.items(): + chains[key] = { + "name": cfg.name, + "symbol": cfg.symbol, + "family": cfg.family.value, + "description": cfg.description, + "hd_path": cfg.hd_path, + "slip44": cfg.slip44, + } + return { + "total_chains": len(chains), + "chain_families": len({c["family"] for c in chains.values()}), + "chains": chains, + "pricing": { + "free": "3 chains (btc, eth, sol) - basic generation only", + "full": "25+ chains, rotation, encrypted vault, batch generation - available on Apify", + }, + } + + +@router.get("/stats") +async def wallet_stats(): + """Get wallet factory statistics and health.""" + factory = get_wallet_factory() + return factory.stats + + +@router.get("/vault") +async def list_vault(request: Request): + """List all wallets in the vault (no private keys exposed).""" + if not _check_auth(request): + raise HTTPException(status_code=401, detail="Admin API key required") + + vault_path = "/root/.rmi/wallets/vault.json" + if not os.path.exists(vault_path): + return {"wallets": [], "total": 0, "message": "Vault is empty"} + + with open(vault_path) as f: + vault = json.load(f) + + wallets = [] + for key, data in vault.items(): + if key == "_meta": + continue + wallets.append( + { + "id": key, + "chain": data.get("chain"), + "chain_name": data.get("chain_name"), + "address": data.get("address"), + "label": data.get("label"), + "tags": data.get("tags", []), + "created_at": data.get("created_at"), + "has_private_key": bool(data.get("private_key_hex")), + "encrypted": data.get("encrypted", False), + } + ) + + return { + "wallets": wallets, + "total": len(wallets), + "meta": vault.get("_meta", {}), + } + + +@router.get("/vault/{wallet_id}") +async def get_wallet(wallet_id: str, request: Request): + """Get full wallet details INCLUDING private key (admin auth required).""" + if not _check_auth(request): + raise HTTPException(status_code=401, detail="Admin API key required") + + vault_path = "/root/.rmi/wallets/vault.json" + if not os.path.exists(vault_path): + raise HTTPException(status_code=404, detail="Vault not found") + + with open(vault_path) as f: + vault = json.load(f) + + if wallet_id not in vault: + raise HTTPException(status_code=404, detail=f"Wallet {wallet_id} not found") + + wallet = vault[wallet_id] + + # Decrypt if needed + if wallet.get("encrypted"): + factory = get_wallet_factory() + try: + wallet["private_key_hex"] = factory.decrypt_key(wallet["private_key_hex"]) + wallet["decrypted"] = True + except Exception as e: + wallet["decrypted"] = False + wallet["decrypt_error"] = str(e) + + return wallet + + +# ── Generation Endpoints ──────────────────────────────────────── + + +@router.post("/generate") +async def generate_wallet(req: GenerateRequest, request: Request): + """Generate a new wallet for any supported chain. + + Free tier: btc, eth, sol only. Full version: all 25+ chains. + Returns address + public key only. Private key stored in vault. + """ + chain = req.chain.lower() + if chain not in SUPPORTED_CHAINS: + raise HTTPException( + status_code=400, + detail=f"Unsupported chain: {chain}. Use GET /chains to see supported chains.", + ) + + factory = get_wallet_factory() + wallets = [] + + for i in range(req.count): + label = req.label or f"{chain}_wallet_{i + 1}" + wallet = factory.generate(chain, label=label, tags=[*req.tags, f"generated_{i + 1}"]) + factory.save_to_vault(wallet) + wallets.append(wallet.to_safe_dict()) + + return { + "generated": len(wallets), + "chain": chain, + "chain_name": SUPPORTED_CHAINS[chain].name, + "wallets": wallets, + "vault_location": "/root/.rmi/wallets/vault.json", + "note": "Private keys stored securely. Use GET /vault/{id} with admin key to retrieve.", + } + + +@router.post("/generate/batch") +async def generate_batch(req: BatchGenerateRequest, request: Request): + """Generate wallets for multiple chains in one request.""" + invalid = [c for c in req.chains if c.lower() not in SUPPORTED_CHAINS] + if invalid: + raise HTTPException(status_code=400, detail=f"Unsupported chains: {invalid}") + + factory = get_wallet_factory() + results = {} + + for chain in req.chains: + wallet = factory.generate(chain, label=f"{req.label_prefix}_{chain}" if req.label_prefix else chain) + factory.save_to_vault(wallet) + results[chain] = wallet.to_safe_dict() + + return { + "chains_generated": len(results), + "wallets": results, + "vault_location": "/root/.rmi/wallets/vault.json", + } + + +# ── Rotation ──────────────────────────────────────────────────── + + +@router.post("/rotate") +async def rotate_wallet(req: RotateRequest, request: Request): + """Rotate to a new wallet for a chain. Old wallet remains in vault. + + Generates a fresh wallet and marks it as the active rotation target. + Perfect for security-conscious payment collection. + """ + if not _check_auth(request): + raise HTTPException(status_code=401, detail="Admin API key required") + + chain = req.chain.lower() + if chain not in SUPPORTED_CHAINS: + raise HTTPException(status_code=400, detail=f"Unsupported chain: {chain}") + + factory = get_wallet_factory() + wallet = factory.rotate(chain) + + return { + "rotated": True, + "chain": chain, + "new_address": wallet.address, + "rotation_index": factory._rotation_index.get(chain, 1), + "message": f"New {chain.upper()} wallet active. Old wallet preserved in vault.", + } + + +# ── Export (Apify-ready) ──────────────────────────────────────── + + +@router.post("/export") +async def export_wallets(req: ExportRequest, request: Request): + """Export wallet data for Apify integration or CLI usage. + + Formats: json (full), csv (addresses only), env (KEY=VALUE pairs). + Free version: 3 chains only. + """ + if not _check_auth(request): + raise HTTPException(status_code=401, detail="Admin API key required") + + chains = req.chains or list(SUPPORTED_CHAINS.keys()) + chains = [c for c in chains if c in SUPPORTED_CHAINS] + + factory = get_wallet_factory() + wallets = {} + for c in chains: + wallet = factory.generate(c) + wallets[c] = wallet + + if req.format == "csv": + lines = ["chain,address,label,symbol"] + for c, w in wallets.items(): + lines.append(f"{c},{w.address},{w.label},{w.symbol}") + return {"format": "csv", "data": "\n".join(lines)} + + elif req.format == "env": + lines = [f"# RMI Wallet Export - {len(wallets)} chains"] + for c, w in wallets.items(): + lines.append(f"WALLET_{c.upper()}_ADDRESS={w.address}") + return {"format": "env", "data": "\n".join(lines)} + + else: + return { + "format": "json", + "total_wallets": len(wallets), + "wallets": {c: w.to_safe_dict() for c, w in wallets.items()}, + "apify_link": "https://apify.com/rugmunch/wallet-factory", + } diff --git a/app/_archive/legacy_2026_07/wallet_intel_loader.py b/app/_archive/legacy_2026_07/wallet_intel_loader.py new file mode 100644 index 0000000..b4dec41 --- /dev/null +++ b/app/_archive/legacy_2026_07/wallet_intel_loader.py @@ -0,0 +1,90 @@ +""" +Wallet Intelligence Loader - loads investigation data into Redis at startup. +Sources: + - omega_forensic_v5 wallet_database.json (15 labeled criminal network wallets) + - SOSANA-CRM-2024.json (full investigation: wallets, persons, orgs, tokens, financials) + - crm_scam_2025_001 (secondary case) +""" + +import json +import logging +import os + +logger = logging.getLogger(__name__) + +WALLET_DB_PATH = "/app/data/wallet_database.json" +CRM_SOSANA_PATH = "/app/data/SOSANA-CRM-2024.json" + + +def load_all_wallet_intel(redis_client): + """Load all investigation data into Redis. Call at startup.""" + loaded = {"wallets": 0, "entities": 0, "cases": 0} + + # 1. Load labeled wallet database + if os.path.exists(WALLET_DB_PATH): + try: + with open(WALLET_DB_PATH) as f: + wallets = json.load(f) + if isinstance(wallets, list): + for w in wallets: + addr = w.get("address", "") + if addr: + redis_client.set(f"rmi:wallet:labeled:{addr}", json.dumps(w)) + cat = w.get("category", "unknown") + redis_client.sadd(f"rmi:wallet:cat:{cat}", addr) + redis_client.set("rmi:wallet:labeled:all", json.dumps(wallets)) + redis_client.set("rmi:wallet:labeled:count", len(wallets)) + loaded["wallets"] = len(wallets) + logger.info(f"Loaded {len(wallets)} labeled wallets") + except Exception as e: + logger.warning(f"Failed to load wallet DB: {e}") + + # 2. Load SOSANA CRM case + if os.path.exists(CRM_SOSANA_PATH): + try: + with open(CRM_SOSANA_PATH) as f: + crm = json.load(f) + + # Store summary + redis_client.set( + "rmi:crm:sosana:summary", + json.dumps( + { + "case_id": crm.get("case_id"), + "title": crm.get("title"), + "status": crm.get("status"), + "created_at": crm.get("created_at"), + } + ), + ) + + # Store entities + entities = crm.get("entities", {}) + for etype in ["wallets", "persons", "organizations", "tokens"]: + elist = entities.get(etype, []) + redis_client.set(f"rmi:crm:sosana:{etype}", json.dumps(elist)) + loaded["entities"] += len(elist) + + # Store financial analysis + fin = crm.get("financial_analysis", {}) + redis_client.set("rmi:crm:sosana:financial", json.dumps(fin)) + + # Evidence + evidence = crm.get("evidence", {}) + redis_client.set( + "rmi:crm:sosana:evidence", + json.dumps({k: len(v) if isinstance(v, list) else v for k, v in evidence.items()}), + ) + + # Risk assessment + risk = crm.get("risk_assessment", {}) + redis_client.set("rmi:crm:sosana:risk", json.dumps(risk)) + + loaded["cases"] += 1 + logger.info( + f"Loaded SOSANA CRM: {loaded['entities']} entities, ${fin.get('total_extracted_usd', 0):,.0f} extracted" + ) + except Exception as e: + logger.warning(f"Failed to load SOSANA CRM: {e}") + + return loaded diff --git a/app/_archive/legacy_2026_07/webhook_pipeline.py b/app/_archive/legacy_2026_07/webhook_pipeline.py new file mode 100644 index 0000000..461130f --- /dev/null +++ b/app/_archive/legacy_2026_07/webhook_pipeline.py @@ -0,0 +1,134 @@ +""" +RMI Webhook Notification Pipeline +=================================== +Dispatches alerts to registered webhooks in real-time. +Polls Redis for new alerts and delivers via HTTP POST. + +Features: +- Retries with exponential backoff +- Dead letter queue for failed deliveries +- Rate limiting per webhook URL +- Payload signing for security +- Delivery status tracking + +Background task started on backend boot. +Polls every 5 seconds for new alerts. + +Author: RMI Development +Date: 2026-06-05 +""" + +import hashlib +import json +import logging +import time +from datetime import UTC, datetime + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from app.core.redis import get_redis + +logger = logging.getLogger("webhook_pipeline") + +router = APIRouter(prefix="/api/v1/webhooks", tags=["webhook-pipeline"]) + + +# ── Redis Helper ───────────────────────────────────────────────── + + +def queue_webhook_delivery( + event_type: str, + address: str, + message: str, + data: dict | None = None, +) -> None: + """Queue a webhook delivery. + + Called by push_alert() in persistent_state.py and by + scanner/cron systems. + """ + r = get_redis() + if not r: + return + + payload = { + "id": f"wh:{int(time.time())}:{hashlib.md5(message.encode()).hexdigest()[:8]}", + "type": event_type, + "address": address, + "message": message, + "data": data or {}, + "source": "rmi_scanner", + "created_at": datetime.now(UTC).isoformat(), + } + + delivery = { + "url": webhook_url, # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + "payload": payload, + "secret": secret, # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + "webhook_id": webhook_id, # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + "attempts": 0, + "queued_at": payload["created_at"], + } + + r.lpush("rmi:webhooks:pending", json.dumps(delivery)) + r.incr("rmi:webhooks:stats:queued") + + # Trim pending queue to last 10000 + r.ltrim("rmi:webhooks:pending", 0, 9999) + + +# ── Endpoints ──────────────────────────────────────────────────── + + +@router.get("/stats") +async def webhook_stats(): + """Get webhook delivery statistics.""" + r = get_redis() + if not r: + return JSONResponse(content={"error": "redis_unavailable"}) + + return JSONResponse( + content={ + "queued": r.get("rmi:webhooks:stats:queued") or 0, + "delivered": r.get("rmi:webhooks:stats:delivered") or 0, + "failed": r.get("rmi:webhooks:stats:failed") or 0, + "pending": r.llen("rmi:webhooks:pending"), + "dead_letter": r.llen("rmi:webhooks:dead_letter"), + } + ) + + +@router.get("/dead-letter") +async def dead_letter_queue(limit: int = 50): + """View failed webhook deliveries.""" + r = get_redis() + if not r: + return JSONResponse(content={"error": "redis_unavailable"}) + + items = r.lrange("rmi:webhooks:dead_letter", 0, limit - 1) + return JSONResponse( + content={ + "dead_letter": [json.loads(i) for i in items], + "total": r.llen("rmi:webhooks:dead_letter"), + } + ) + + +@router.post("/dead-letter/retry/{index}") +async def retry_dead_letter(index: int): + """Retry a dead letter delivery.""" + r = get_redis() + if not r: + return JSONResponse(content={"error": "redis_unavailable"}) + + items = r.lrange("rmi:webhooks:dead_letter", index, index) + if not items: + return JSONResponse(content={"error": "Item not found"}) + + item = json.loads(items[0]) + item["attempts"] = 0 + r.lrem("rmi:webhooks:dead_letter", 1, items[0]) + r.lpush("rmi:webhooks:pending", json.dumps(item)) + + return JSONResponse(content={"success": True, "message": "Re-queued for delivery"}) diff --git a/app/_archive/legacy_2026_07/webhooks_router.py b/app/_archive/legacy_2026_07/webhooks_router.py new file mode 100644 index 0000000..d50c681 --- /dev/null +++ b/app/_archive/legacy_2026_07/webhooks_router.py @@ -0,0 +1,311 @@ +""" +Webhook Receivers - Helius (Solana) + Moralis Streams (EVM). +Processes real-time blockchain events: transfers, swaps, mints, whale activity. +INTELLIGENT PROCESSING: Whale detection, cluster correlation, scam pattern matching. +""" + +import logging +import os + +from fastapi import APIRouter, HTTPException, Request + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"]) + +# ── Config ────────────────────────────────────────────────── + +HELIUS_WEBHOOK_AUTH = os.getenv("HELIUS_WEBHOOK_AUTH", "") +HELIUS_WEBHOOK_SECRET = os.getenv("HELIUS_WEBHOOK_SECRET", "") + +# ── Intelligent Processor ──────────────────────────────────── + + +async def _process_with_intelligence(event: dict, source: str) -> dict | None: + """Process event with intelligent analysis.""" + try: + from app.intelligent_webhooks import get_intelligent_processor + + processor = get_intelligent_processor() + + if source == "helius": + alert = await processor.process_helius_event(event) + elif source == "moralis": + alert = await processor.process_moralis_event(event) + else: + return None + + if alert: + # Log critical/high alerts + if alert.severity in ("critical", "high"): + logger.warning( + f"🚨 {alert.severity.upper()}: {alert.alert_type} " + f"wallet={alert.wallet[:16]}... amount=${alert.amount_usd:,.0f} " + f"risk={alert.risk_score:.1f}" + ) + + return { + "alert_id": alert.alert_id, + "severity": alert.severity, + "type": alert.alert_type, + "wallet": alert.wallet, + "chain": alert.chain, + "amount_usd": alert.amount_usd, + "risk_score": alert.risk_score, + "description": alert.description, + "enriched": alert.enriched_data, + "actions": alert.actions, + } + return None + except Exception as e: + logger.debug(f"Intelligent processing failed: {e}") + return None + + +def _process_helius_event(event: dict) -> dict: + """Process a single Helius webhook event.""" + result = { + "signature": event.get("signature", ""), + "type": event.get("type", "unknown"), + "source": "helius", + "timestamp": event.get("timestamp", 0), + "wallet": "", + "description": "", + "processed": False, + } + + try: + # Extract key data from Helius enriched transaction + events = event.get("events", []) + event.get("accountData", []) + fee_payer = event.get("feePayer", "") + description = event.get("description", "") + + result["wallet"] = fee_payer + result["description"] = description[:200] + + # Classify event type + event_types = [e.get("type", "") for e in events] if isinstance(events, list) else [] + if any("swap" in str(e).lower() for e in [*event_types, description]): + result["type"] = "swap" + elif any("transfer" in str(e).lower() for e in [*event_types, description]): + result["type"] = "transfer" + elif any("mint" in str(e).lower() for e in [*event_types, description]): + result["type"] = "mint" + elif any("nft" in str(e).lower() for e in [*event_types, description]): + result["type"] = "nft" + + # Track amounts from account data + native_transfers = event.get("nativeTransfers", []) + if native_transfers: + max_transfer = max(native_transfers, key=lambda t: t.get("amount", 0), default={}) + result["largest_transfer"] = max_transfer.get("amount", 0) / 1e9 # lamports to SOL + + # Token transfers + token_transfers = event.get("tokenTransfers", []) + if token_transfers: + for tt in token_transfers[:3]: # Top 3 token transfers + mint = tt.get("mint", "") + amount = tt.get("tokenAmount", {}).get("uiAmount", 0) + if amount and float(amount) > 0: + result.setdefault("token_moves", []).append( + { + "mint": mint, + "amount": float(amount), + "from": tt.get("fromUserAccount", ""), + "to": tt.get("toUserAccount", ""), + } + ) + + result["processed"] = True + + except Exception as e: + logger.warning(f"Event processing error: {e}") + result["error"] = str(e)[:200] + + return result + + +def _process_moralis_event(event: dict) -> dict: + """Process a single Moralis Stream (EVM) event.""" + result = { + "hash": event.get("transactionHash", event.get("hash", "")), + "chain": event.get("chainId", "0x1"), + "source": "moralis", + "type": "unknown", + "from": event.get("fromAddress", ""), + "to": event.get("toAddress", ""), + "value": "0", + "timestamp": event.get("blockTimestamp", ""), + "processed": False, + } + + try: + # Classify by log events or description + logs = event.get("logs", []) + erc20_transfers = event.get("erc20Transfers", []) + + if erc20_transfers: + result["type"] = "erc20_transfer" + for t in erc20_transfers[:3]: + result.setdefault("token_transfers", []).append( + { + "from": t.get("from", ""), + "to": t.get("to", ""), + "value": t.get("value", "0"), + "symbol": t.get("symbol", ""), + "token": t.get("address", ""), + } + ) + elif logs: + # Check for swap events (Uniswap, etc.) + result["type"] = "contract_interaction" + else: + native_value = event.get("value", "0") + if native_value and float(native_value) > 0: + result["type"] = "native_transfer" + result["value"] = str(float(native_value) / 1e18) + + # Whale detection - flag transfers > 10 ETH equivalent + try: + val = float(event.get("value", "0")) / 1e18 if event.get("value") else 0 + if val > 10: + result["whale_alert"] = True + result["whale_value_eth"] = val + except (ValueError, TypeError): + pass + + result["processed"] = True + + except Exception as e: + logger.warning(f"Moralis event processing error: {e}") + result["error"] = str(e)[:200] + + return result + + +# ── Endpoints ─────────────────────────────────────────────── + + +@router.post("/helius") +async def helius_webhook(request: Request): + """Receive Helius webhook events (Solana transfers, swaps, mints). + INTELLIGENT: Whale detection, cluster correlation, scam pattern matching.""" + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") from None + + # Helius can send single events or arrays + events = body if isinstance(body, list) else [body] + processed = [] + alerts = [] + + for event in events: + # Basic processing + result = _process_helius_event(event) + processed.append(result) + + # Intelligent processing + alert = await _process_with_intelligence(event, "helius") + if alert: + alerts.append(alert) + + # Store in cache for retrieval + try: + from app.chain_cache import get_chain_cache + + cache = get_chain_cache() + await cache.set("helius_events", processed, "latest", ttl=3600) + if alerts: + await cache.set("helius_alerts", alerts, "latest", ttl=3600) + except Exception: + pass + + return { + "status": "ok", + "events_processed": len(processed), + "alerts_generated": len(alerts), + "critical_alerts": len([a for a in alerts if a.get("severity") == "critical"]), + } + + +@router.post("/moralis") +async def moralis_webhook(request: Request): + """Receive Moralis Stream events (EVM whale tracking, token transfers). + INTELLIGENT: Whale detection, cluster correlation, scam pattern matching.""" + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") from None + + # Moralis streams send arrays of confirmed/tx events + confirmed = body.get("confirmed", body.get("logs", [])) + if isinstance(body, list): + confirmed = body + + processed = [] + alerts = [] + for event in confirmed if isinstance(confirmed, list) else [confirmed]: + # Basic processing + result = _process_moralis_event(event) + processed.append(result) + + # Intelligent processing + alert = await _process_with_intelligence(event, "moralis") + if alert: + alerts.append(alert) + + # Cache latest events + try: + from app.chain_cache import get_chain_cache + + cache = get_chain_cache() + await cache.set("moralis_events", processed, "latest", ttl=3600) + if alerts: + await cache.set("moralis_alerts", alerts, "latest", ttl=3600) + except Exception: + pass + + return { + "status": "ok", + "events_processed": len(processed), + "alerts_generated": len(alerts), + "critical_alerts": len([a for a in alerts if a.get("severity") == "critical"]), + } + + +@router.get("/events") +async def get_recent_events(source: str | None = None): + """Get recent webhook events from cache.""" + try: + from app.chain_cache import get_chain_cache + + cache = get_chain_cache() + + events = [] + helius = await cache.get("helius_events", "latest") + if helius: + events.extend(helius if source != "moralis" else []) + + moralis = await cache.get("moralis_events", "latest") + if moralis: + events.extend(moralis if source != "helius" else []) + + return {"total": len(events), "events": events[:50]} + except Exception: + return {"total": 0, "events": []} + + +@router.get("/health") +async def webhooks_health(): + """Webhook health check.""" + return { + "status": "ok", + "service": "webhook-receiver", + "endpoints": { + "helius": "/api/v1/webhooks/helius", + "moralis": "/api/v1/webhooks/moralis", + "events": "/api/v1/webhooks/events", + }, + } diff --git a/app/_archive/legacy_2026_07/x402_alpha_revenue_tools.py b/app/_archive/legacy_2026_07/x402_alpha_revenue_tools.py new file mode 100644 index 0000000..5fcd129 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_alpha_revenue_tools.py @@ -0,0 +1,657 @@ +""" +RMI Alpha Tools - High-Value Revenue Tools +============================================ +Three premium alpha tools that bots will pay for: +1. whale_copy_trade - Real-time copy trade engine +2. rug_predictor_live - 5-minute rug prediction +3. whale_cluster - Coordinated whale cluster detection + +All tools use DataBus + DexScreener + RAG enrichment. +Price points: $0.10-$0.50 per call. + +Author: RMI Development +Date: 2026-06-05 +""" + +import json +import logging +import os +import time +from datetime import UTC, datetime + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from app.core.redis import get_redis + +logger = logging.getLogger("alpha_tools") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["alpha-tools"]) + +# ── Data Source Helpers ────────────────────────────────────────── + + +async def fetch_dexscreener(path: str, params: dict | None = None) -> dict | None: + """Fetch from DexScreener API.""" + import aiohttp + + url = f"https://api.dexscreener.com/latest/dex/{path}" + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: # noqa: SIM117 + async with session.get(url, params=params) as resp: + if resp.status == 200: + return await resp.json() + except Exception as e: + logger.warning(f"DexScreener fetch failed: {e}") + return None + + +async def fetch_geckoterminal(path: str) -> dict | None: + """Fetch from GeckoTerminal API.""" + import aiohttp + + url = f"https://api.geckoterminal.com/api/v2/{path}" + headers = {"Accept": "application/json"} + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: # noqa: SIM117 + async with session.get(url, headers=headers) as resp: + if resp.status == 200: + return await resp.json() + except Exception as e: + logger.warning(f"GeckoTerminal fetch failed: {e}") + return None + + +async def fetch_helius(path: str, params: dict | None = None) -> dict | None: + """Fetch from Helius API (Solana).""" + import aiohttp + + api_key = os.getenv("HELIUS_API_KEY", "") + if not api_key: + return None + + url = f"https://api.helius.xyz/v0/{path}?api-key={api_key}" + try: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: # noqa: SIM117 + async with session.get(url, params=params) as resp: + if resp.status == 200: + return await resp.json() + except Exception as e: + logger.warning(f"Helius fetch failed: {e}") + return None + + +async def whale_copy_trade(req: WhaleCopyTradeRequest): # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + """Real-time copy trade engine. + + Input a smart money wallet, get their exact last 24h trades with + entry/exit prices, current PnL, and suggested follow trades. + + Price: $0.25/call + """ + address = req.address.lower() + chain = req.chain + lookback = req.lookback_hours + min_usd = req.min_trade_usd + + trades = [] + wallet_info = {} + + if chain == "solana": + # Fetch recent transactions from Helius + txns = await fetch_helius( + f"addresses/{address}/transactions", + {"type": "TRANSFER", "limit": 100}, + ) + if txns: + for tx in txns[:50]: + # Parse transfer data + timestamp = tx.get("timestamp", 0) + if timestamp < (time.time() - lookback * 3600): + continue + + # Extract token and amount + transfers = tx.get("transfers", []) + for transfer in transfers: + token = transfer.get("mint", "") + amount = transfer.get("tokenAmount", 0) + if not token or amount == 0: + continue + + # Get token price + token_price = await _get_token_price(token, "solana") + usd_value = amount * (token_price or 0) + + if usd_value >= min_usd: + trades.append( + { + "token": token, + "amount": amount, + "usd_value": round(usd_value, 2), + "price": token_price, + "type": "buy" if transfer.get("fromUserAccount") == address else "sell", + "timestamp": datetime.fromtimestamp(timestamp, tz=UTC).isoformat(), + "tx_hash": tx.get("signature", ""), + } + ) + + # Get wallet token balance + balance_data = await fetch_helius(f"addresses/{address}/balances") + if balance_data: + wallet_info["tokens"] = balance_data.get("total", 0) + wallet_info["nfts"] = balance_data.get("nfts", 0) + + elif chain in ("base", "ethereum", "bsc", "arbitrum"): + # For EVM chains, use DexScreener to find recent pairs + search = await fetch_dexscreener("search", {"q": address}) + if search and search.get("pairs"): + for pair in search["pairs"][:20]: + txns = pair.get("txns", {}) + h24 = txns.get("h24", {}) + if h24.get("buys", 0) > 0 or h24.get("sells", 0) > 0: + trades.append( + { + "token": pair.get("baseToken", {}).get("address", ""), + "pair_address": pair.get("pairAddress", ""), + "price_usd": pair.get("priceUsd"), + "volume_24h": pair.get("volume", {}).get("h24", 0), + "buys_24h": h24.get("buys", 0), + "sells_24h": h24.get("sells", 0), + "liquidity": pair.get("liquidity", {}).get("usd", 0), + } + ) + + # Calculate PnL for trades + pnl_summary = _calculate_pnl(trades) + + # Generate copy trade suggestions + suggestions = _generate_copy_suggestions(trades, wallet_info, lookback) + + return JSONResponse( + content={ + "tool": "whale_copy_trade", + "address": address, + "chain": chain, + "lookback_hours": lookback, + "trades": trades[:30], # Limit to 30 + "total_trades": len(trades), + "pnl_summary": pnl_summary, + "suggestions": suggestions, + "wallet_info": wallet_info, + "status": "ok" if trades else "no_data", + "message": f"Found {len(trades)} trades >= ${min_usd:.0f} in {lookback}h" + if trades + else f"No trades >= ${min_usd:.0f} found in {lookback}h", + } + ) + + +def _calculate_pnl(trades: list[dict]) -> dict: + """Calculate PnL summary from trades.""" + buys = [t for t in trades if t.get("type") == "buy"] + sells = [t for t in trades if t.get("type") == "sell"] + + total_bought = sum(t.get("usd_value", 0) for t in buys) + total_sold = sum(t.get("usd_value", 0) for t in sells) + + # Group by token + token_pnl = {} + for t in trades: + token = t.get("token", "unknown") + if token not in token_pnl: + token_pnl[token] = {"bought": 0, "sold": 0, "trades": 0} + token_pnl[token]["trades"] += 1 + if t.get("type") == "buy": + token_pnl[token]["bought"] += t.get("usd_value", 0) + else: + token_pnl[token]["sold"] += t.get("usd_value", 0) + + return { + "total_bought_usd": round(total_bought, 2), + "total_sold_usd": round(total_sold, 2), + "net_flow_usd": round(total_sold - total_bought, 2), + "buy_count": len(buys), + "sell_count": len(sells), + "unique_tokens": len(token_pnl), + "top_tokens": sorted( + [{"token": k, **v} for k, v in token_pnl.items()], + key=lambda x: x["bought"], + reverse=True, + )[:5], + } + + +def _generate_copy_suggestions(trades: list[dict], wallet_info: dict, lookback: int) -> list[dict]: + """Generate copy trade suggestions based on wallet activity.""" + suggestions = [] + + # Find tokens the wallet is accumulating (more buys than sells) + token_activity = {} + for t in trades: + token = t.get("token", "") + if not token: + continue + if token not in token_activity: + token_activity[token] = {"buys": 0, "sells": 0, "total_usd": 0} + if t.get("type") == "buy": + token_activity[token]["buys"] += 1 + else: + token_activity[token]["sells"] += 1 + token_activity[token]["total_usd"] += t.get("usd_value", 0) + + for token, activity in token_activity.items(): + if activity["buys"] > activity["sells"] and activity["total_usd"] > 5000: + suggestions.append( + { + "token": token, + "action": "accumulate", + "confidence": min(activity["buys"] * 0.2, 0.9), + "reason": f"Wallet bought {activity['buys']} times, sold {activity['sells']} times", + "total_volume_usd": round(activity["total_usd"], 2), + } + ) + + return suggestions[:10] + + +async def _get_token_price(address: str, chain: str) -> float | None: + """Get current token price from DexScreener.""" + if chain == "solana": + result = await fetch_dexscreener(f"tokens/{address}") + if result and result.get("pairs"): + return float(result["pairs"][0].get("priceNative", 0)) + return None + + +# ── Tool 2: Live Rug Predictor ─────────────────────────────────── + + +class RugPredictorLiveRequest(BaseModel): + address: str + chain: str = "solana" + lookback_minutes: int = 30 + + +@router.post("/rug_predictor_live") +async def rug_predictor_live(req: RugPredictorLiveRequest): + """Live rug predictor - analyzes tokens in their first 5-30 minutes. + + Watches new tokens in real-time, scores them, and alerts before the rug. + + Price: $0.50/call + """ + address = req.address.lower() + chain = req.chain + + # Fetch token data from multiple sources + pair_data = await fetch_dexscreener(f"tokens/{address}") + if not pair_data or not pair_data.get("pairs"): + return JSONResponse( + content={ + "tool": "rug_predictor_live", + "address": address, + "chain": chain, + "status": "no_data", + "message": "Token not found on DexScreener", + "rug_score": 0, + "verdict": "UNKNOWN", + } + ) + + pair = pair_data["pairs"][0] + signals = [] + rug_score = 0 + + # ── Signal 1: Liquidity Analysis ────────────────────────────── + liquidity_usd = pair.get("liquidity", {}).get("usd", 0) + if liquidity_usd < 1000: + signals.append( + { + "signal": "low_liquidity", + "severity": "critical", + "message": f"Liquidity only ${liquidity_usd:.0f} - extremely vulnerable to rug", + "weight": 25, + } + ) + rug_score += 25 + elif liquidity_usd < 5000: + signals.append( + { + "signal": "low_liquidity", + "severity": "high", + "message": f"Liquidity ${liquidity_usd:.0f} - high risk", + "weight": 15, + } + ) + rug_score += 15 + + # ── Signal 2: LP Lock Status ────────────────────────────────── + lp_locked = pair.get("lockInfo", {}).get("locked", False) + if not lp_locked: + signals.append( + { + "signal": "lp_not_locked", + "severity": "critical", + "message": "Liquidity pool is NOT locked - creator can remove at any time", + "weight": 20, + } + ) + rug_score += 20 + + # ── Signal 3: Price Action ──────────────────────────────────── + price_change = pair.get("priceChange", {}) + h1_change = price_change.get("h1", 0) + price_change.get("m5", 0) + + if h1_change < -50: + signals.append( + { + "signal": "price_crash", + "severity": "critical", + "message": f"Price down {h1_change}% in 1 hour - active rug in progress", + "weight": 25, + } + ) + rug_score += 25 + + # ── Signal 4: Volume/Liquidity Ratio ───────────────────────── + volume_24h = pair.get("volume", {}).get("h24", 0) + if liquidity_usd > 0: + vol_liq_ratio = volume_24h / liquidity_usd + if vol_liq_ratio > 10: + signals.append( + { + "signal": "extreme_volume", + "severity": "high", + "message": f"Volume/Liquidity ratio {vol_liq_ratio:.1f}x - suspicious activity", + "weight": 10, + } + ) + rug_score += 10 + + # ── Signal 5: Transaction Count ─────────────────────────────── + txns = pair.get("txns", {}) + h1_txns = txns.get("h1", {}) + buys = h1_txns.get("buys", 0) + sells = h1_txns.get("sells", 0) + + if buys > 0 and sells > 0: + sell_ratio = sells / (buys + sells) + if sell_ratio > 0.8: + signals.append( + { + "signal": "mass_selling", + "severity": "high", + "message": f"{sell_ratio * 100:.0f}% of transactions are sells - panic selling", + "weight": 15, + } + ) + rug_score += 15 + + # ── Signal 6: Pair Age ──────────────────────────────────────── + pair_created_at = pair.get("pairCreatedAt", 0) + if pair_created_at: + age_hours = (time.time() * 1000 - pair_created_at) / (1000 * 3600) + if age_hours < 1: + signals.append( + { + "signal": "brand_new_pair", + "severity": "medium", + "message": f"Pair is only {age_hours * 60:.0f} minutes old - highest risk window", + "weight": 10, + } + ) + rug_score += 10 + + # ── Determine Verdict ───────────────────────────────────────── + if rug_score >= 70: + verdict = "CRITICAL_RUG" + action = "AVOID - High probability of active or imminent rug" + elif rug_score >= 50: + verdict = "HIGH_RISK" + action = "AVOID - Multiple red flags detected" + elif rug_score >= 30: + verdict = "MEDIUM_RISK" + action = "CAUTION - Some warning signs, monitor closely" + elif rug_score >= 15: + verdict = "LOW_RISK" + action = "MONITOR - Minor concerns but mostly clean" + else: + verdict = "SAFE" + action = "Relatively clean - standard risk management applies" + + return JSONResponse( + content={ + "tool": "rug_predictor_live", + "address": address, + "chain": chain, + "rug_score": min(rug_score, 100), + "verdict": verdict, + "action": action, + "signals": signals, + "token_info": { + "name": pair.get("baseToken", {}).get("name", ""), + "symbol": pair.get("baseToken", {}).get("symbol", ""), + "price_usd": pair.get("priceUsd"), + "liquidity_usd": liquidity_usd, + "volume_24h": volume_24h, + "price_change_1h": h1_change, + "txns_1h": {"buys": buys, "sells": sells}, + "lp_locked": lp_locked, + "pair_age_hours": round(age_hours, 2) if pair_created_at else None, + }, + "timestamp": datetime.now(UTC).isoformat(), + "status": "ok", + } + ) + + +# ── Tool 3: Whale Cluster Detection ────────────────────────────── + + +class WhaleClusterRequest(BaseModel): + address: str + chain: str = "solana" + min_cluster_size: int = 3 + similarity_threshold: float = 0.7 + + +@router.post("/whale_cluster") +async def whale_cluster(req: WhaleClusterRequest): + """Identify coordinated whale clusters. + + Group wallets that move together: same funding source, same timing, + same token sets. Identifies wash trading, coordinated pumps, + and insider networks. + + Price: $0.30/call + """ + address = req.address.lower() + chain = req.chain + min_size = req.min_cluster_size + threshold = req.similarity_threshold + + r = get_redis() + cluster_data = { + "seed_wallet": address, + "chain": chain, + "clusters": [], + "total_related_wallets": 0, + "risk_assessment": "unknown", + } + + # Step 1: Get wallets that interacted with the same tokens + same_token_wallets = await _find_shared_token_wallets(address, chain, r) + + # Step 2: Check for shared funding sources + funding_clusters = await _find_shared_funding(address, chain, r) + + # Step 3: Check timing correlation + timing_clusters = await _find_timing_correlation(address, chain, r) + + # Combine and score + wallet_scores = {} + + for wallet, score in same_token_wallets.items(): + wallet_scores[wallet] = wallet_scores.get(wallet, 0) + score * 0.4 + + for wallet, score in funding_clusters.items(): + wallet_scores[wallet] = wallet_scores.get(wallet, 0) + score * 0.4 + + for wallet, score in timing_clusters.items(): + wallet_scores[wallet] = wallet_scores.get(wallet, 0) + score * 0.2 + + # Filter by threshold + cluster_wallets = [ + {"wallet": w, "similarity": round(s, 3)} for w, s in wallet_scores.items() if s >= threshold and w != address + ] + cluster_wallets.sort(key=lambda x: x["similarity"], reverse=True) + + # Group into clusters by similarity + clusters = [] + used = set() + for cw in cluster_wallets: + if cw["wallet"] in used: + continue + cluster = {"members": [cw["wallet"]], "avg_similarity": cw["similarity"]} + used.add(cw["wallet"]) + for other in cluster_wallets: + if other["wallet"] not in used and abs(other["similarity"] - cw["similarity"]) < 0.1: + cluster["members"].append(other["wallet"]) + used.add(other["wallet"]) + + if len(cluster["members"]) >= min_size - 1: # -1 because seed wallet + cluster["members"].insert(0, address) # Add seed wallet + cluster["size"] = len(cluster["members"]) + cluster["avg_similarity"] = round( + sum(wallet_scores.get(w, 0) for w in cluster["members"]) / len(cluster["members"]), + 3, + ) + clusters.append(cluster) + + # Risk assessment + if any(c["size"] >= 5 for c in clusters): + cluster_data["risk_assessment"] = "high" + cluster_data["risk_message"] = "Large coordinated cluster detected - possible wash trading or insider network" + elif any(c["size"] >= 3 for c in clusters): + cluster_data["risk_assessment"] = "medium" + cluster_data["risk_message"] = "Medium cluster detected - wallets showing coordinated behavior" + else: + cluster_data["risk_assessment"] = "low" + cluster_data["risk_message"] = "No significant clusters detected" + + cluster_data["clusters"] = clusters + cluster_data["total_related_wallets"] = len(cluster_wallets) + cluster_data["status"] = "ok" + + return JSONResponse(content=cluster_data) + + +async def _find_shared_token_wallets(address: str, chain: str, r) -> dict[str, float]: + """Find wallets that traded the same tokens.""" + # Get tokens this wallet traded + token_key = f"rmi:wallet:{address}:{chain}:tokens" + tokens = r.smembers(token_key) if r else set() + + if not tokens: + # Fetch from DexScreener as fallback + result = await fetch_dexscreener("search", {"q": address}) + if result and result.get("pairs"): + tokens = {p.get("baseToken", {}).get("address", "") for p in result["pairs"][:20]} + + # For each token, find other wallets + shared = {} + for token in tokens: + if not token: + continue + # Check Redis cache of recent traders for this token + traders_key = f"rmi:token:{token}:{chain}:traders" + traders = r.smembers(traders_key) if r else set() + + for trader in traders: + if trader != address: + shared[trader] = shared.get(trader, 0) + 1 + + # Normalize + max_shared = max(shared.values()) if shared else 1 + return {w: s / max_shared for w, s in shared.items()} + + +async def _find_shared_funding(address: str, chain: str, r) -> dict[str, float]: + """Find wallets funded from the same source.""" + # Check if we have funding data cached + funding_key = f"rmi:wallet:{address}:{chain}:funding" + funding_source = r.get(funding_key) if r else None + + if funding_source: + # Find other wallets funded from same source + other_key = f"rmi:funding:{chain}:{funding_source}:wallets" + other_wallets = r.smembers(other_key) if r else set() + return {w: 0.9 for w in other_wallets if w != address} + + return {} + + +async def _find_timing_correlation(address: str, chain: str, r) -> dict[str, float]: + """Find wallets with correlated trading timing.""" + # Get recent trade timestamps + timing_key = f"rmi:wallet:{address}:{chain}:timing" + timing_data = r.get(timing_key) if r else None + + if timing_data: + json.loads(timing_data) + # Find wallets with similar timing patterns + correlation_key = f"rmi:timing:{chain}:correlations" + correlations = r.hgetall(correlation_key) if r else {} + + correlated = {} + for wallet, corr_score in correlations.items(): + if float(corr_score) > 0.5: + correlated[wallet] = float(corr_score) + + return correlated + + return {} + + +# ── Tool Pricing Registration ──────────────────────────────────── + + +# These tools register their prices in the canonical tool prices dict +def register_alpha_tool_prices(): + """Register alpha tool prices with the enforcement system.""" + try: + from app.routers.x402_enforcement import TOOL_PRICES + + TOOL_PRICES.update( + { + "whale_copy_trade": { + "price_usd": 0.25, + "price_atoms": "250000", + "category": "alpha", + "trial_free": 1, + "description": "Real-time copy trade engine - find smart money trades with entry/exit prices, PnL, and follow suggestions", + }, + "rug_predictor_live": { + "price_usd": 0.50, + "price_atoms": "500000", + "category": "security", + "trial_free": 1, + "description": "Live rug predictor - analyzes tokens in first 5-30 minutes with 6-signal rug probability scoring", + }, + "whale_cluster": { + "price_usd": 0.30, + "price_atoms": "300000", + "category": "intelligence", + "trial_free": 1, + "description": "Coordinated whale cluster detection - identify wash trading, insider networks, and pump groups", + }, + } + ) + except Exception as e: + logger.warning(f"Failed to register alpha tool prices: {e}") + + +# Register on import +register_alpha_tool_prices() diff --git a/app/_archive/legacy_2026_07/x402_alpha_tools.py b/app/_archive/legacy_2026_07/x402_alpha_tools.py new file mode 100644 index 0000000..db13ff0 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_alpha_tools.py @@ -0,0 +1,853 @@ +""" +RMI Alpha Tools - the signals that make us best-in-class. + +Composite Score - one number combining all RMI signals for instant decisions +Smart Money Tracker - P&L-based profitable wallet identification +Token Clone Detector - bytecode + metadata similarity to known rugs +Wash Trading & Insider Detection - artificial volume and coordinated buying patterns +""" + +import hashlib +import json +import logging +import os +import time +from datetime import datetime + +import aiohttp +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +logger = logging.getLogger("x402.alpha") + +router = APIRouter() + + +# ═══════════════════════════════════════════════════════════ +# Redis helpers +# ═══════════════════════════════════════════════════════════ +def _r(): + import redis as _redis + + return _redis.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2, + ) + + +def _cache_get(tool, params): + try: + k = f"x402:cache:{tool}:{hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()[:16]}" + d = _r().get(k) + return json.loads(d) if d else None + except Exception: + return None + + +def _cache_set(tool, params, result, ttl=60): + try: + k = f"x402:cache:{tool}:{hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()[:16]}" + _r().setex(k, ttl, json.dumps(result)) + except Exception: + pass + + +# ═══════════════════════════════════════════════════════════ +# Models +# ═══════════════════════════════════════════════════════════ +class TokenRequest(BaseModel): + address: str = Field(..., description="Token contract address") + chain: str = Field(default="base") + + +class WalletRequest(BaseModel): + address: str = Field(..., description="Wallet address") + chain: str = Field(default="ethereum") + + +class CloneRequest(BaseModel): + address: str = Field(..., description="Token to check for clones") + chain: str = Field(default="base") + + +class WashTradeRequest(BaseModel): + address: str = Field(..., description="Token address") + chain: str = Field(default="base") + lookback_hours: int = Field(default=24, ge=1, le=72) + + +# ═══════════════════════════════════════════════════════════ +# TOOL: RMI Composite Score ($0.25) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/composite_score") +async def composite_score(req: TokenRequest): + """The one number to rule them all. Combines every RMI signal into a 0-100 + composite score for instant buy/sell/avoid decisions. + + Components weighted by predictive power: + - Reputation Score (25%): trust signals, labels, scam flags + - Rug Probability (25%): honeypot, liquidity, deployer patterns + - Market Health (15%): volume/liquidity ratio, age, holder concentration + - Narrative Sentiment (15%): social media sentiment + shill detection + - MEV Exposure (10%): sandwich/frontrunning risk + - DeFi Position (10%): impermanent loss, protocol risk + + Returns: composite_score (0-100), verdict (BUY/HOLD/CAUTION/AVOID), + component breakdown with individual scores. + """ + try: + addr = req.address.strip() + chain = req.chain or "base" + t0 = time.time() + + cached = _cache_get("composite_score", {"address": addr, "chain": chain}) + if cached: + return cached + + components = {} + sources = [] + warnings = [] + + # ── 1. Reputation Score (25%) ── + try: + async with aiohttp.ClientSession() as s, s.post( + "http://localhost:8000/api/v1/x402-tools/reputation_score", + json={"address": addr, "chain": chain}, + timeout=aiohttp.ClientTimeout(total=20), + ) as r: + if r.status == 200: + rep = await r.json() + components["reputation"] = { + "score": rep.get("trust_score", 50), + "tier": rep.get("tier", "UNKNOWN"), + "flags": len(rep.get("flags", [])), + } + if rep.get("trust_score", 100) < 40: + warnings.append(f"Reputation CRITICAL: {rep.get('tier')}") + sources.extend(rep.get("sources_used", [])) + except Exception: + components["reputation"] = {"score": 50, "tier": "ERROR", "flags": 0} + + # ── 2. Rug Probability (25%) ── + try: + async with aiohttp.ClientSession() as s, s.post( + "http://localhost:8000/api/v1/x402-tools/rug_probability", + json={"address": addr, "chain": chain}, + timeout=aiohttp.ClientTimeout(total=20), + ) as r: + if r.status == 200: + rug = await r.json() + components["rug_risk"] = { + "score": 100 - rug.get("rug_probability", 0), # Invert: high prob = low score + "probability": rug.get("rug_probability", 0), + "signals": rug.get("signal_count", 0), + } + if rug.get("rug_probability", 0) > 50: + warnings.append(f"Rug risk HIGH: {rug.get('rug_probability')}% probability") + sources.extend(rug.get("sources_used", [])) + except Exception: + components["rug_risk"] = {"score": 50, "probability": 0, "signals": 0} + + # ── 3. Market Health (15%) ── + try: + async with aiohttp.ClientSession() as s, s.get( + f"https://api.dexscreener.com/latest/dex/tokens/{addr}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + data = await r.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + p = pairs[0] + liq = p.get("liquidity", {}).get("usd", 0) or 0 + vol = p.get("volume", {}).get("h24", 0) or 0 + age_ms = p.get("pairCreatedAt", 0) or 0 + age_h = (time.time() * 1000 - age_ms) / 3600000 if age_ms else 0 + pc = p.get("priceChange", {}).get("h24", 0) or 0 + + health_score = 70 + if liq < 1000: + health_score -= 40 + elif liq < 10000: + health_score -= 20 + elif liq > 500000: + health_score += 10 + if 0 < age_h < 1: + health_score -= 25 + elif 0 < age_h < 24: + health_score -= 10 + elif age_h > 720: + health_score += 10 + if liq > 0 and vol > liq * 5: + health_score -= 15 + if pc < -30: + health_score -= 15 + components["market_health"] = { + "score": max(0, min(100, health_score)), + "liquidity_usd": liq, + "age_hours": round(age_h, 1), + "volume_24h": vol, + "volume_liquidity_ratio": round(vol / max(liq, 1), 1), + } + except Exception: + components["market_health"] = {"score": 50, "liquidity_usd": 0, "age_hours": 0} + + # ── 4. Narrative Sentiment (15%) ── + try: + async with aiohttp.ClientSession() as s: + symbol = addr[:12] + from urllib.parse import quote + + async with s.get( + f"https://cryptopanic.com/api/free/posts/?filter=important&q={quote(symbol)}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + data = await r.json() + posts = data.get("results", []) + if posts: + sources.append("cryptopanic") + sentiment = sum( + p.get("votes", {}).get("positive", 0) - p.get("votes", {}).get("negative", 0) + for p in posts + ) + sent_score = 50 + min(50, max(-50, sentiment * 2)) + components["narrative"] = { + "score": sent_score, + "mention_count": len(posts), + "sentiment_sum": sentiment, + "recent_24h": sum(1 for p in posts if p.get("created_at", "")), + } + if sentiment < -5: + warnings.append(f"Negative narrative: {len(posts)} mentions, sentiment {sentiment}") + except Exception: + components["narrative"] = {"score": 50, "mention_count": 0, "sentiment_sum": 0} + + # ── 5. MEV Exposure (10%) ── + try: + async with aiohttp.ClientSession() as s, s.post( + "http://localhost:8000/api/v1/x402-tools/mev_detect", + json={"address": addr, "chain": chain}, + timeout=aiohttp.ClientTimeout(total=15), + ) as r: + if r.status == 200: + mev = await r.json() + risk = mev.get("risk_level", "low") + mev_score = 100 if risk == "low" else 70 if risk == "moderate" else 40 if risk == "high" else 20 + components["mev_exposure"] = { + "score": mev_score, + "risk_level": risk, + "attacks_detected": mev.get("mev_attacks_detected", 0), + } + if risk in ("high", "critical"): + warnings.append(f"MEV risk {risk.upper()}: {mev.get('mev_attacks_detected', 0)} attacks") + sources.extend(mev.get("sources_used", [])) + except Exception: + components["mev_exposure"] = { + "score": 80, + "risk_level": "unknown", + "attacks_detected": 0, + } + + # ── Compute Composite ── + weights = { + "reputation": 0.25, + "rug_risk": 0.25, + "market_health": 0.15, + "narrative": 0.15, + "mev_exposure": 0.10, + } + available_weight = sum(weights.get(k, 0) for k in components if "score" in components[k]) + composite = sum(components[k].get("score", 50) * weights.get(k, 0) for k in components) / max( + available_weight, 0.01 + ) + composite = round(max(0, min(100, composite)), 1) + + # Verdict + if composite >= 80: + verdict, recommendation = ( + "STRONG_BUY", + "All signals positive - low risk, healthy market, positive sentiment", + ) + elif composite >= 65: + verdict, recommendation = ( + "BUY", + "Generally favorable - some minor flags, standard caution advised", + ) + elif composite >= 50: + verdict, recommendation = ( + "HOLD", + "Mixed signals - wait for clearer direction or reduce position", + ) + elif composite >= 35: + verdict, recommendation = ( + "CAUTION", + "Multiple risk signals - significant due diligence required", + ) + elif composite >= 20: + verdict, recommendation = ( + "HIGH_RISK", + "Dangerous - multiple critical flags, high rug probability", + ) + else: + verdict, recommendation = ( + "AVOID", + "EXTREME RISK - confirmed scam indicators, do not interact", + ) + + result = { + "tool": "RMI Composite Score", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "address": addr, + "chain": chain, + "composite_score": composite, + "verdict": verdict, + "recommendation": recommendation, + "components": components, + "warnings": warnings[:5] if warnings else None, + "warning_count": len(warnings), + "sources_used": list(set(sources)), + "source_count": len(set(sources)), + "performance_ms": round((time.time() - t0) * 1000, 1), + "guarantee": "Comprehensive analysis or full refund", + } + _cache_set("composite_score", {"address": addr, "chain": chain}, result, ttl=120) + return result + except Exception as e: + logger.error(f"Composite score failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# TOOL: Smart Money P&L Tracker ($0.20) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/smart_money") +async def smart_money(req: WalletRequest): + """Track the REAL profitable traders - not just whales. + + Uses on-chain transaction analysis to identify wallets with: + - High win rate (>60%) + - Positive P&L over 30 days + - Consistent entry/exit timing + - Low rug exposure (avoids scam tokens) + + Returns: profitability metrics, trade history, risk profile, + and a "follow worthiness" score indicating if this wallet is worth copy-trading. + """ + try: + addr = req.address.strip() + chain = req.chain or "ethereum" + t0 = time.time() + + cached = _cache_get("smart_money", {"address": addr, "chain": chain}) + if cached: + return cached + + metrics = {"address": addr, "chain": chain} + sources = [] + + # ── DexScreener: find token pairs this wallet trades ── + try: + async with aiohttp.ClientSession() as s, s.get( + f"https://api.dexscreener.com/latest/dex/search?q={addr[:12]}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + data = await r.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + # Analyze trading patterns + total_vol = 0 + profitable = 0 + for p in pairs[:20]: + vol = p.get("volume", {}).get("h24", 0) or 0 + pc = p.get("priceChange", {}).get("h24", 0) or 0 + total_vol += vol + if pc > 0: + profitable += 1 + + metrics["tokens_traded"] = len(pairs) + metrics["total_volume_24h"] = round(total_vol, 2) + metrics["win_rate_est"] = round(profitable / max(len(pairs), 1) * 100, 1) + metrics["avg_liquidity"] = round( + sum(p.get("liquidity", {}).get("usd", 0) or 0 for p in pairs) / max(len(pairs), 1), + 2, + ) + except Exception: + pass + + # ── Wallet labels: check for known traders, funds, bots ── + try: + from app.routers.x402_premium_tools import _lookup_labels_async + + labels = await _lookup_labels_async(addr) + if labels: + sources.append("wallet_labels") + metrics["labels"] = [ + {"name": line.get("label_name", ""), "category": line.get("label_category", "")} for line in labels[:5] + ] + metrics["is_labeled"] = True + # Known smart money indicators + smart_cats = {"fund", "vc", "market_maker", "mev", "arbitrage"} + metrics["smart_money_indicators"] = [ + line.get("label_name") + for line in labels + if any(c in (line.get("label_category", "") + line.get("label_name", "")).lower() for c in smart_cats) + ] + except Exception: + pass + + # ── Solana RPC balance check ── + if chain == "solana": + try: + async with aiohttp.ClientSession() as s, s.post( + "https://api.mainnet-beta.solana.com", + json={"jsonrpc": "2.0", "id": 1, "method": "getBalance", "params": [addr]}, + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + d = await r.json() + bal = (d.get("result", {}).get("value", 0) or 0) / 1e9 + metrics["balance_sol"] = round(bal, 4) + metrics["balance_usd_est"] = round(bal * 140, 2) + sources.append("solana_rpc") + except Exception: + pass + else: + try: + async with aiohttp.ClientSession() as s: + rpcs = { + "ethereum": "https://eth.llamarpc.com", + "base": "https://mainnet.base.org", + } + async with s.post( + rpcs.get(chain, rpcs["ethereum"]), + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_getBalance", + "params": [addr, "latest"], + }, + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + d = await r.json() + bal = int(d.get("result", "0x0") or "0x0", 16) / 1e18 + metrics["balance_eth"] = round(bal, 6) + sources.append(f"{chain}_rpc") + except Exception: + pass + + # ── Scoring ── + win_rate = metrics.get("win_rate_est", 50) + tokens = metrics.get("tokens_traded", 0) + smart_indicators = len(metrics.get("smart_money_indicators", [])) + bal_usd = metrics.get("balance_usd_est", 0) or (metrics.get("balance_eth", 0) * 3200) + + follow_score = 0 + if win_rate > 70: + follow_score += 30 + elif win_rate > 55: + follow_score += 15 + if tokens > 20: + follow_score += 15 + elif tokens > 5: + follow_score += 8 + if smart_indicators > 0: + follow_score += 20 + if bal_usd > 100000: + follow_score += 20 + elif bal_usd > 10000: + follow_score += 10 + if metrics.get("is_labeled"): + follow_score += 10 + + if follow_score >= 70: + tier = "ELITE_TRADER" + elif follow_score >= 45: + tier = "PROFITABLE" + elif follow_score >= 25: + tier = "ACTIVE" + else: + tier = "UNPROVEN" + + result = { + "tool": "Smart Money P&L Tracker", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "wallet": addr, + "chain": chain, + "metrics": metrics, + "follow_score": follow_score, + "tier": tier, + "follow_worthiness": { + "ELITE_TRADER": "Consistently profitable - worth copy-trading with caution", + "PROFITABLE": "Above-average win rate - monitor for entries", + "ACTIVE": "Active trader - needs more track record", + "UNPROVEN": "Insufficient data - do not copy-trade yet", + }.get(tier, ""), + "risk_warnings": [ + "Past performance does not guarantee future results", + "Always verify with your own research before copy-trading", + "Smart money wallets can exit positions before you can react", + ], + "sources_used": sources, + "performance_ms": round((time.time() - t0) * 1000, 1), + "guarantee": "Real on-chain data or full refund", + } + _cache_set("smart_money", {"address": addr, "chain": chain}, result, ttl=120) + return result + except Exception as e: + logger.error(f"Smart money failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# TOOL: Token Clone Detector ($0.10) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/clone_detect") +async def clone_detect(req: CloneRequest): + """Detect if a token is a clone of known rug pulls. + + Checks: + - Contract bytecode similarity (via Etherscan source verification) + - Token metadata fingerprinting (name, symbol, decimals, supply) + - Deployer pattern matching (same deployer = high risk) + - Liquidity pattern similarity (same DEX, similar initial liquidity) + - Holder distribution cloning (same concentration pattern) + + Returns similarity scores to known scams with risk assessment. + """ + try: + addr = req.address.strip() + chain = req.chain or "base" + t0 = time.time() + + cached = _cache_get("clone_detect", {"address": addr, "chain": chain}) + if cached: + return cached + + clones = [] + sources = [] + risk_level = "low" + + # ── DexScreener: find similar tokens by deployer ── + try: + async with aiohttp.ClientSession() as s, s.get( + f"https://api.dexscreener.com/latest/dex/tokens/{addr}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + data = await r.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + p = pairs[0] + base = p.get("baseToken", {}) + token_name = base.get("name", "") + token_symbol = base.get("symbol", "") + + # Search for tokens with same name/symbol pattern + if token_name or token_symbol: + q = token_symbol or token_name[:8] + async with s.get( + f"https://api.dexscreener.com/latest/dex/search?q={q}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r2: + if r2.status == 200: + data2 = await r2.json() + similar = [ + p2 + for p2 in (data2.get("pairs", []) or []) + if p2.get("pairAddress") != p.get("pairAddress") + ] + for sp in similar[:10]: + sbase = sp.get("baseToken", {}) + sname = sbase.get("name", "") + ssymbol = sbase.get("symbol", "") + + # Name similarity + name_match = 0 + if token_name and sname: + common = sum( + 1 + for a, b in zip( + token_name.lower(), + sname.lower(), + strict=False, + ) + if a == b + ) + name_match = common / max(len(token_name), 1) * 100 + + # Symbol similarity + sym_match = 100 if token_symbol.lower() == ssymbol.lower() else 0 + + if name_match > 60 or sym_match == 100: + liq = sp.get("liquidity", {}).get("usd", 0) or 0 + pc = sp.get("priceChange", {}).get("h24", 0) or 0 + is_dead = liq < 100 or pc < -90 + clones.append( + { + "address": sbase.get("address", "")[:16] + "...", + "name": sname[:40], + "symbol": ssymbol, + "name_similarity": round(name_match, 1), + "liquidity_usd": liq, + "is_likely_dead": is_dead, + "dex": sp.get("dexId", "unknown"), + } + ) + + except Exception: + pass + + # ── GeckoTerminal: check if listed (verified = less likely clone) ── + try: + async with aiohttp.ClientSession() as s: + chain_map = {"solana": "solana", "base": "base", "ethereum": "eth", "bsc": "bsc"} + gc = chain_map.get(chain, chain) + async with s.get( + f"https://api.geckoterminal.com/api/v2/networks/{gc}/tokens/{addr}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + sources.append("geckoterminal") + risk_level = "very_low" # Listed on GeckoTerminal = reduced clone risk + except Exception: + pass + + # ── Assessment ── + clone_count = len(clones) + dead_clones = sum(1 for c in clones if c.get("is_likely_dead")) + + if clone_count >= 5 and dead_clones >= 3: + risk_level = "critical" + verdict = f"CRITICAL: {clone_count} similar tokens found, {dead_clones} appear dead/rugged" + elif clone_count >= 3: + risk_level = "high" + verdict = f"HIGH: {clone_count} similar tokens - possible clone pattern" + elif clone_count >= 1: + risk_level = "moderate" + verdict = f"MODERATE: {clone_count} similar token(s) found" + else: + verdict = "No known clones detected" + + result = { + "tool": "Token Clone Detector", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "address": addr, + "chain": chain, + "clones_found": clone_count, + "dead_clones": dead_clones, + "risk_level": risk_level, + "verdict": verdict, + "similar_tokens": clones[:10], + "sources_used": sources, + "performance_ms": round((time.time() - t0) * 1000, 1), + "guarantee": "Real clone detection or full refund", + } + _cache_set("clone_detect", {"address": addr, "chain": chain}, result) + return result + except Exception as e: + logger.error(f"Clone detect failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# TOOL: Wash Trading & Insider Detection ($0.15) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/wash_trade_detect") +async def wash_trade_detect(req: WashTradeRequest): + """Detect wash trading and insider patterns. + + Wash trading signals: + - Same-address buy/sell cycling + - Round-number trade sizes + - Consistent small interval trades + - Volume without price movement + + Insider signals: + - Concentrated buying just before price spikes + - Same-block coordinated purchases + - New wallet buying large amounts of new tokens + """ + try: + addr = req.address.strip() + chain = req.chain or "base" + hours = req.lookback_hours + t0 = time.time() + + cached = _cache_get("wash_trade", {"address": addr, "chain": chain, "hours": hours}) + if cached: + return cached + + signals = [] + sources = [] + wash_score = 0 + insider_score = 0 + + # ── DexScreener volume/price analysis ── + try: + async with aiohttp.ClientSession() as s: # noqa: SIM117 + async with s.get( + f"https://api.dexscreener.com/latest/dex/tokens/{addr}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + data = await r.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + p = pairs[0] + vol = p.get("volume", {}).get("h24", 0) or 0 + liq = p.get("liquidity", {}).get("usd", 0) or 0 + pc = p.get("priceChange", {}).get("h24", 0) or 0 + buys = p.get("txns", {}).get("h24", {}).get("buys", 0) or 0 + sells = p.get("txns", {}).get("h24", {}).get("sells", 0) or 0 + age_ms = p.get("pairCreatedAt", 0) or 0 + age_h = (time.time() * 1000 - age_ms) / 3600000 if age_ms else 0 + + # Wash trading: high volume, no price movement + if vol > 50000 and abs(pc) < 3 and liq < 50000: + wash_score += 40 + signals.append( + { + "type": "wash_trading", + "severity": "high", + "detail": f"${vol:,.0f} volume with only {pc}% price change - classic wash pattern", + "volume_usd": vol, + "price_change_pct": pc, + } + ) + elif vol > 10000 and abs(pc) < 5 and liq < 10000: + wash_score += 20 + signals.append( + { + "type": "wash_trading", + "severity": "medium", + "detail": f"Suspicious volume/price disconnect: ${vol:,.0f} vol, {pc}% change", + } + ) + + # Volume/liquidity ratio (pump and dump signal) + if liq > 0 and vol / liq > 10: + wash_score += 15 + signals.append( + { + "type": "volume_anomaly", + "severity": "medium", + "detail": f"Volume {vol / liq:.0f}x liquidity - possible coordinated trading", + } + ) + + # New token with extreme buy/sell ratio + if 0 < age_h < 6: + tx_ratio = buys / max(sells, 1) + if tx_ratio > 5: + insider_score += 25 + signals.append( + { + "type": "insider_pattern", + "severity": "high", + "detail": f"New token ({age_h:.1f}h): {buys} buys vs {sells} sells ({tx_ratio:.0f}x) - possible insider accumulation", + } + ) + elif tx_ratio > 3: + insider_score += 10 + except Exception: + pass + + # ── CoinGecko: check if verified (reduces wash risk) ── + try: + async with aiohttp.ClientSession() as s, s.get( + f"https://api.coingecko.com/api/v3/coins/{addr}", + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + sources.append("coingecko") + wash_score = max(0, wash_score - 15) # Listed = reduced wash trading risk + signals.append( + { + "type": "verified_listing", + "severity": "info", + "detail": "Listed on CoinGecko - reduced wash trading probability", + } + ) + except Exception: + pass + + # ── Assessment ── + if wash_score >= 50: + wash_level = "CRITICAL" + wash_detail = "Strong wash trading indicators - likely artificial volume" + elif wash_score >= 25: + wash_level = "HIGH" + wash_detail = "Suspicious trading patterns detected" + elif wash_score >= 10: + wash_level = "MODERATE" + wash_detail = "Some unusual volume patterns" + else: + wash_level = "LOW" + wash_detail = "No significant wash trading detected" + + if insider_score >= 30: + insider_level = "HIGH" + insider_detail = "Insider accumulation pattern detected" + elif insider_score >= 15: + insider_level = "MODERATE" + insider_detail = "Possible coordinated buying" + else: + insider_level = "LOW" + insider_detail = "No insider patterns detected" + + result = { + "tool": "Wash Trade & Insider Detection", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "address": addr, + "chain": chain, + "wash_trading": { + "score": wash_score, + "level": wash_level, + "detail": wash_detail, + }, + "insider_trading": { + "score": insider_score, + "level": insider_level, + "detail": insider_detail, + }, + "signals": signals[:8], + "signal_count": len(signals), + "overall_risk": "CRITICAL" + if wash_score >= 50 or insider_score >= 30 + else "HIGH" + if wash_score >= 25 or insider_score >= 15 + else "MODERATE" + if wash_score >= 10 or insider_score >= 5 + else "LOW", + "sources_used": sources, + "performance_ms": round((time.time() - t0) * 1000, 1), + "guarantee": "Pattern analysis or full refund", + } + _cache_set("wash_trade", {"address": addr, "chain": chain, "hours": hours}, result) + return result + except Exception as e: + logger.error(f"Wash trade failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/app/_archive/legacy_2026_07/x402_analytics.py b/app/_archive/legacy_2026_07/x402_analytics.py new file mode 100644 index 0000000..e409daa --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_analytics.py @@ -0,0 +1,78 @@ +""" +RMI Payment Analytics Dashboard +================================ +Real-time revenue, usage, and customer analytics. +Reads from Redis counters populated by x402 enforcement + developer tier. + +Endpoints: + GET /api/v1/analytics/x402/revenue - Revenue overview + GET /api/v1/analytics/x402/tools - Per-tool usage + revenue + GET /api/v1/analytics/x402/daily - Daily breakdown (30 days) + GET /api/v1/analytics/x402/developers - Developer tier stats + GET /api/v1/analytics/x402/funnel - Conversion funnel (trial → paid) + GET /api/v1/analytics/x402/summary - One-line summary for dashboards + +Author: RMI Development +Date: 2026-06-05 +""" + +from datetime import UTC, datetime + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +router = APIRouter(prefix="/api/v1/analytics/x402", tags=["x402-analytics"]) + + +# ── Redis Helper ───────────────────────────────────────────────── + + +async def revenue_overview(): + """Get overall revenue metrics.""" + return JSONResponse(content=get_revenue_overview()) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + + +@router.get("/tools") +async def tool_breakdown(): + """Get per-tool usage and revenue.""" + return JSONResponse(content=get_tool_breakdown()) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + + +@router.get("/daily") +async def daily_breakdown(days: int = 30): + """Get daily revenue breakdown.""" + days = min(days, 90) # Cap at 90 days + return JSONResponse(content=get_daily_breakdown(days)) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + + +@router.get("/developers") +async def developer_analytics(): + """Get developer tier analytics.""" + return JSONResponse(content=get_developer_analytics()) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + + +@router.get("/funnel") +async def conversion_funnel(): + """Get trial → paid conversion funnel.""" + return JSONResponse(content=get_conversion_funnel()) # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + + +@router.get("/summary") +async def analytics_summary(): + """One-line summary for dashboards.""" + overview = get_revenue_overview() # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue + if "error" in overview: + return JSONResponse(content=overview) + + revenue = overview["revenue"] + calls = overview["tool_calls"] + devs = overview["developer_tier"] + + return JSONResponse( + content={ + "summary": f"${revenue['total_usd']:.4f} total revenue · {calls['total']} tool calls · {devs['total_keys']} dev keys", + "today": f"${revenue['today_usd']:.4f} · {calls['today']} calls", + "developers": f"{devs['total_keys']} registered · {devs['total_free_calls']} free calls used", + "timestamp": datetime.now(UTC).isoformat(), + } + ) diff --git a/app/_archive/legacy_2026_07/x402_catalog.py b/app/_archive/legacy_2026_07/x402_catalog.py new file mode 100755 index 0000000..f1d7f68 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_catalog.py @@ -0,0 +1,574 @@ +"""Dynamic MCP Tool Catalog - reads from gateway configs + external MCP servers +Expanded to include 200+ tools across 40+ services +""" + +import logging +import os +import re + +from fastapi import APIRouter + +logger = logging.getLogger(__name__) + +# Provider name sanitization map +_PROVIDER_MAP = { + "DexScreener": "DEX", + "CoinGecko": "Market Data", + "DefiLlama": "DeFi Analytics", + "Birdeye": "Token Discovery", + "PumpFun": "Launch", + "Pump.fun": "Launch", + "GeckoTerminal": "DEX Pool", + "DexPaprika": "Multi-Source DEX", +} + +_SERVICE_MAP = { + "dexscreener": "dex-analytics", + "coingecko": "market-data", + "defillama": "defi-analytics", + "birdeye": "token-discovery", + "pumpfun": "launch-platform", + "geckoterminal": "dex-pool-data", + "dexpaprika": "multi-source-dex", + "dexscreener_new": "dex-analytics", + "dexscreener_hot": "dex-analytics", + "dexscreener_meme": "dex-analytics", + "defillama_stablecoins": "defi-analytics", + "defillama_bridges": "defi-analytics", + "pumpfun_gainers": "launch-platform", +} + + +def _sanitize_tool(tool: dict) -> dict: + """Remove upstream provider names from user-visible tool fields""" + for field in ("name", "description"): + if field in tool and isinstance(tool[field], str): + val = tool[field] + for old, new in _PROVIDER_MAP.items(): + val = val.replace(old, new) + tool[field] = val + if "service" in tool and isinstance(tool["service"], str): + tool["service"] = _SERVICE_MAP.get(tool["service"], tool["service"]) + return tool + + +router = APIRouter(prefix="/api/v1/x402", tags=["x402-catalog"]) + +# Cache for tool catalog +_catalog_cache: dict = {} + + +def parse_gateway_tools(gateway_dir: str) -> list[dict]: + """Parse RMI_TOOLS definitions from a gateway index.ts file. + + Handles nested braces by tracking brace depth.""" + index_path = os.path.join(gateway_dir, "index.ts") + if not os.path.exists(index_path): + return [] + + with open(index_path) as f: + content = f.read() + + tools = [] + + # Find the RMI_TOOLS block + rmi_start = content.find("const RMI_TOOLS") + if rmi_start == -1: + return [] + + # Find opening brace of RMI_TOOLS + brace_start = content.find("{", rmi_start) + if brace_start == -1: + return [] + + # Find matching closing brace by tracking depth + depth = 0 + pos = brace_start + while pos < len(content): + if content[pos] == "{": + depth += 1 + elif content[pos] == "}": + depth -= 1 + if depth == 0: + break + pos += 1 + + rmi_block = content[brace_start : pos + 1] + + # Now parse individual tool definitions within the block + # Tools look like: tool_name: { name: "...", description: "...", ... } + # We need to handle nested braces in extras + + # Split into tool entries by finding top-level 'word:' patterns + tool_pattern = re.compile(r"(\w+):\s*\{", re.MULTILINE) + + for tm in tool_pattern.finditer(rmi_block): + tool_id = tm.group(1) + + # Skip non-tool entries + if tool_id in ("interface", "type", "export", "import", "const", "let", "var"): + continue + if tool_id.startswith("//") or tool_id.startswith("RMI_TOOLS"): + continue + + # Find the matching closing brace for this tool + tool_start = tm.end() - 1 # position of opening { + depth = 0 + tool_end = tool_start + for i in range(tool_start, len(rmi_block)): + if rmi_block[i] == "{": + depth += 1 + elif rmi_block[i] == "}": + depth -= 1 + if depth == 0: + tool_end = i + 1 + break + + block = rmi_block[tool_start:tool_end] + + # Extract fields + name = re.search(r'name:\s*"([^"]*)"', block) + desc = re.search(r'description:\s*"([^"]*)"', block) + price = re.search(r'price:\s*"\$?([^"]*)"', block) + atomic = re.search(r'priceAtomic:\s*"([^"]*)"', block) + category = re.search(r'category:\s*"([^"]*)"', block) + trial = re.search(r"trialFree:\s*(\d+)", block) + method = re.search(r'method:\s*"([^"]*)"', block) + + if name and category: + tools.append( + { + "id": tool_id, + "name": name.group(1), + "description": desc.group(1) if desc else "", + "price": f"${price.group(1)}" if price else "$0", + "priceUsd": float(price.group(1)) if price else 0, + "priceAtomic": atomic.group(1) if atomic else "0", + "category": category.group(1).lower(), + "trialFree": int(trial.group(1)) if trial else 0, + "method": method.group(1) if method else "POST", + "service": "rmi-native", + "source": "native", + } + ) + + return tools + + +def load_external_mcp_tools() -> list[dict]: + """Load expanded external MCP tool definitions. + + Priority: + 1. Local expanded_mcp_catalog.py file (fast, offline) + 2. mcp-router.rugmunch.io/tools (live, always current) + 3. Returns empty list if both unavailable + """ + base_dir = os.path.dirname(os.path.dirname(__file__)) + catalog_path = os.path.join(base_dir, "services", "expanded_mcp_catalog.py") + + # Try local file first + if os.path.exists(catalog_path): + try: + import importlib.util + + spec = importlib.util.spec_from_file_location("expanded_mcp_catalog", catalog_path) + if spec is None or spec.loader is None: + raise ImportError("Cannot load spec for expanded_mcp_catalog") + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + spec.loader.exec_module(module) # type: ignore[union-attr] + tools = getattr(module, "EXTERNAL_MCP_TOOLS", []) + if tools: + logger.info(f"Loaded {len(tools)} tools from expanded_mcp_catalog.py") + return tools + except Exception as e: + logger.warning(f"Failed to load expanded_mcp_catalog.py: {e}") + + # Fallback: fetch from mcp-router dynamically + try: + import json as _json + import urllib.request + + url = "https://mcp.rugmunch.io/tools" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=5) as resp: + data = _json.loads(resp.read()) + + tools = [] + # mcp-router returns service groups, each with tools + if isinstance(data, dict): + services = data.get("services", data.get("tools", data)) + if isinstance(services, list): + for service in services: + service_name = service.get("name", service.get("service", "unknown")) + service_tools = service.get("tools", []) + if isinstance(service_tools, list): + for t in service_tools: + tool_id = t.get("name", t.get("id", "")) + if tool_id: + tools.append( + { + "id": tool_id, + "name": t.get("description", tool_id), + "description": t.get("description", f"MCP tool: {tool_id}"), + "price": "$0.01", + "priceUsd": 0.01, + "category": "mcp-external", + "trialFree": 3, + "method": "MCP", + "service": service_name, + "source": "mcp-router", + "chains": ["SOLANA", "BASE"], + } + ) + else: + # Flat tool list + for key, val in service.items(): + if isinstance(val, dict) and "name" in val: + tools.append( + { + "id": key, + "name": val.get("name", key), + "description": val.get("description", ""), + "price": "$0.01", + "priceUsd": 0.01, + "category": "mcp-external", + "trialFree": 3, + "method": "MCP", + "service": service_name, + "source": "mcp-router", + "chains": ["SOLANA", "BASE"], + } + ) + + if tools: + logger.info(f"Loaded {len(tools)} tools from mcp-router dynamically") + return tools + except Exception as e: + logger.warning(f"Failed to fetch from mcp-router: {e}") + + return [] + + +def discover_route_tools() -> list[dict]: + """Discover tools from FastAPI route definitions""" + tools = [] + backend_dir = os.path.dirname(__file__) + skip = { + "bundles", + "discovery", + "frameworks", + "comprehensive_audit", + "anthropic-tools", + "gemini-tools", + "langchain-tools", + "openai-tools", + "bundles/all_in_one", + "bundles/intelligence_pack", + "bundles/security_pack", + "bundles/forensic_pack", + "{tool_id}", + "payment-methods", + } + + # Load authoritative pricing/categories from TOOL_PRICES + try: + from app.routers.x402_enforcement import TOOL_PRICES + except Exception: + TOOL_PRICES = {} + + for fname in ["x402_tools.py", "x402_forensic_tools.py"]: + fpath = os.path.join(backend_dir, fname) + if not os.path.exists(fpath): + continue + with open(fpath) as f: + content = f.read() + # Find all route paths + routes = re.findall(r'@router\.(?:get|post)\("\/([^"]+)"\)', content) + for route in routes: + if route in skip: + continue + # Use TOOL_PRICES as source of truth for pricing and category + if route in TOOL_PRICES: + tp = TOOL_PRICES[route] + tools.append( + { + "id": route, + "name": tp.get("description", route.replace("_", " ").title()), + "description": tp.get("description", f"Tool: {route}"), + "price": f"${tp.get('price_usd', 0.01):.2f}", + "priceUsd": tp.get("price_usd", 0.01), + "category": tp.get("category", "analysis"), + "trialFree": tp.get("trial_free", 1), + "method": "POST", + "service": "rmi-native", + "source": "route", + "chains": [], + } + ) + continue + # Try to find docstring for description + desc = f"Tool: {route}" + doc_match = re.search(rf'@router\.(?:get|post)\("/{route}"\)[\s\S]*?"""([^"]*)"""', content) + if doc_match: + desc = doc_match.group(1).strip().split("\n")[0].strip() + tools.append( + { + "id": route, + "name": route.replace("_", " ").title(), + "description": desc, + "price": "$0.01", + "priceUsd": 0.01, + "category": "api", + "trialFree": 1, + "method": "POST", + "service": "rmi-native", + "source": "route", + "chains": [], + } + ) + return tools + + +def get_catalog(): + """Build full tool catalog from TOOL_PRICES + X402_TOOL_PRICING (databus) = single source of truth. + + Combined enforcement + databus-only tools = 170 total. + Gateway configs and route discovery are used for enrichment (chains, icons, etc). + """ + + # Always rebuild (no stale caching) + base_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + # ── Primary source: TOOL_PRICES + DATABUS_TOOLS (170 total, single source of truth) ── + try: + from app.routers.x402_enforcement import CHAIN_USDC, TOOL_PRICES # noqa: F401 + + all_tools = {} + services = set() + categories = set() + + for tool_id, pricing in TOOL_PRICES.items(): + desc = pricing.get("description", f"{tool_id} - crypto intelligence tool") + category = pricing.get("category", "analysis") + chains = [] + # If it's a per-chain variant, show the specific chain + base_tool = pricing.get("base_tool") + chain = pricing.get("chain") + if chain: + chains = [chain.upper()] + + all_tools[tool_id] = { + "id": tool_id, + "name": desc, + "description": desc, + "price": f"${pricing.get('price_usd', 0.01):.2f}", + "priceUsd": pricing.get("price_usd", 0.01), + "priceAtomic": pricing.get("price_atoms", "10000"), + "category": category, + "trialFree": pricing.get("trial_free", 1), + "method": "POST", + "service": "rmi-native", + "source": "enforcement", + "chains": chains, + "base_tool": base_tool, + "chain": chain, + } + services.add("rmi-native") + categories.add(category) + + # Add databus-only tools (not in TOOL_PRICES enforcement) + try: + from app.routers.x402_databus_tools import X402_TOOL_PRICING as DATABUS_TOOLS + + for tool_id, pricing in DATABUS_TOOLS.items(): + if tool_id not in all_tools: + desc = pricing.get("description", f"{tool_id} - DataBus crypto intelligence") + category = pricing.get("category", "data") + all_tools[tool_id] = { + "id": tool_id, + "name": desc, + "description": desc, + "price": f"${pricing.get('price_usd', 0.05):.2f}", + "priceUsd": pricing.get("price_usd", 0.05), + "priceAtomic": pricing.get("price_atoms", "50000"), + "category": category, + "trialFree": pricing.get("trial_free", 1), + "method": "POST", + "service": "rmi-databus", + "source": "databus", + "chains": [], + "base_tool": None, + "chain": None, + } + services.add("rmi-databus") + categories.add(category) + except ImportError: + pass + except ImportError: + all_tools = {} + services = set() + categories = set() + + # ── Enrich from gateway configs (add chain info, icons) ── + candidates = [ + "/app/x402-gateway", + os.path.join(base_dir, "x402-gateway"), + os.path.join(os.path.dirname(base_dir), "x402-gateway"), + "/root/backend/x402-gateway", + ] + gateway_base = None + for c in candidates: + if os.path.isdir(c): + gateway_base = c + break + + chains = {} + if gateway_base and os.path.exists(gateway_base): + for chain_dir in sorted(os.listdir(gateway_base)): + chain_path = os.path.join(gateway_base, chain_dir) + if not os.path.isdir(chain_path): + continue + tools = parse_gateway_tools(chain_path) + if tools: + chains[chain_dir] = len(tools) + for t in tools: + tid = t["id"] + if tid in all_tools: + # Enrich existing entry with chain info + if chain_dir.upper() not in all_tools[tid].get("chains", []): + all_tools[tid]["chains"].append(chain_dir.upper()) + else: + # New tool from gateway not in TOOL_PRICES - add it + t["chains"] = [chain_dir.upper()] + all_tools[tid] = t + services.add("rmi-native") + categories.add(t.get("category", "unknown")) + + # ── Enrich from FastAPI route definitions ── + route_tools = discover_route_tools() + for t in route_tools: + tid = t["id"] + if tid in all_tools: + # Enrich - route definitions may have more accurate descriptions + if not all_tools[tid].get("description") or all_tools[tid].get("description", "").startswith("Tool:"): + all_tools[tid]["description"] = t.get("description", all_tools[tid].get("description", "")) + else: + all_tools[tid] = t + services.add("rmi-native") + categories.add(t.get("category", "analysis")) + + # ── Enrich from external MCP servers ── + external_tools = load_external_mcp_tools() + for t in external_tools: + tid = t.get("id", "") + if not tid: + continue + # Normalize pricing - external tools may have price_usd (snake) or priceUsd (camel) + price_usd = t.get("price_usd", t.get("priceUsd", 0.01)) + price_atoms = t.get("price_atoms", t.get("priceAtomic", str(int(price_usd * 1_000_000)))) + trial_free = t.get("trial_free", t.get("trialFree", 1)) + + if tid in all_tools: + # Enrich existing entry with external pricing if missing + if not all_tools[tid].get("priceUsd"): + all_tools[tid].update( + { + "price": f"${price_usd:.2f}", + "priceUsd": price_usd, + "priceAtomic": price_atoms, + "trialFree": trial_free, + "method": t.get("method", "POST"), + } + ) + else: + # New tool from external MCP + t["chains"] = [c.upper() for c in t.get("chains", [])] + t["price"] = f"${price_usd:.2f}" + t["priceUsd"] = price_usd + t["priceAtomic"] = price_atoms + t["trialFree"] = trial_free + t["method"] = t.get("method", "MCP") + t["source"] = t.get("source", "expanded-mcp") + all_tools[tid] = t + services.add(t.get("service", "unknown")) + categories.add(t.get("category", "unknown")) + + # Base tools that aren't variants should show all chains they support + chain_suffixes = [ + "_solana", + "_base", + "_ethereum", + "_bsc", + "_arbitrum", + "_polygon", + "_avalanche", + "_fantom", + "_gnosis", + "_optimism", + ] + for tid, tool in all_tools.items(): + # If a base tool has variants, show chains on the base + base = tid + for suffix in chain_suffixes: + if tid.endswith(suffix): + base = tid[: -len(suffix)] + break + # Check if this base has chain variants + chain_variants = [t for t in all_tools if t.startswith(base + "_")] + if chain_variants and not tool.get("chain"): + # This is a base tool - it supports all chains its variants cover + variant_chains = [] + for v in chain_variants: + v_chain = all_tools[v].get("chain", "") + if v_chain: + variant_chains.append(v_chain.upper()) + if variant_chains: + tool["chains"] = sorted(set(variant_chains + tool.get("chains", []))) + + # Sanitize ALL tools before returning + tool_list = sorted([_sanitize_tool(dict(t)) for t in all_tools.values()], key=lambda x: x.get("priceUsd", 0)) + + result = { + "chains": chains or {"base": 0, "solana": 0}, + "total_tools": len(tool_list), + "total_chains": len(chains) or 2, + "total_services": len(services), + "tools": tool_list, + "categories": sorted(categories), + "services": sorted(services), + } + + _catalog_cache.clear() + _catalog_cache.update(result) + return result + + +@router.get("/tools-catalog") +async def list_tools_catalog(): + """Get all available MCP tools across all chains and external services""" + return get_catalog() + + +@router.get("/tools-catalog/{chain}") +async def list_chain_tools(chain: str): + """Get tools for a specific chain (includes native + external)""" + catalog = get_catalog() + chain_upper = chain.upper() + chain_tools = [t for t in catalog["tools"] if chain_upper in t.get("chains", [])] + return {"chain": chain, "count": len(chain_tools), "tools": chain_tools} + + +@router.get("/tools-catalog/category/{category}") +async def list_category_tools(category: str): + """Get tools in a specific category across all chains""" + catalog = get_catalog() + filtered = [t for t in catalog["tools"] if t.get("category", "") == category.lower()] + return {"category": category, "count": len(filtered), "tools": filtered} + + +@router.get("/tools-catalog/service/{service}") +async def list_service_tools(service: str): + """Get tools from a specific external MCP service""" + catalog = get_catalog() + filtered = [t for t in catalog["tools"] if t.get("service", "") == service.lower()] + return {"service": service, "count": len(filtered), "tools": filtered} diff --git a/app/_archive/legacy_2026_07/x402_contract_upgrade_monitor.py b/app/_archive/legacy_2026_07/x402_contract_upgrade_monitor.py new file mode 100644 index 0000000..6de9bba --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_contract_upgrade_monitor.py @@ -0,0 +1,189 @@ +""" +x402 Router: contract_upgrade_monitor +======================================== +Wraps ContractUpgradeAnalyzer with: + - Address validation + - x402 payment middleware integration + - Caching (Redis if available) + - Trial quota tracking + - Rate limiting support + +TOOL : contract_upgrade_monitor +TIER : security +PRICE : $0.05 (50000 atoms) +TRIAL : 2 free checks +ROUTER: /api/v1/x402-tools/contract_upgrade_monitor +""" + +from __future__ import annotations + +import json +import logging +import os +from contextlib import suppress + +import redis as _redis_mod +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field, field_validator + +from app.contract_upgrade_monitor import ( + format_upgrade_report, + get_upgrade_analyzer, + is_valid_evm_address, +) + +logger = logging.getLogger("x402_contract_upgrade_monitor") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"]) + +# ── Redis helpers ───────────────────────────────────────────── + +_redis: _redis_mod.Redis | None = None + + +def _get_redis(): + global _redis + if _redis is None: + try: + r = _redis_mod.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", 6379)), + db=int(os.getenv("REDIS_DB", 0)), + password=os.getenv("REDIS_PASSWORD", None), + decode_responses=True, + ) + r.ping() + _redis = r + except Exception: + _redis = None # Not available + return _redis + + +_CACHE_TTL = 600 # 10 minutes - upgrade data doesn't change rapidly + + +# ── Request Models ───────────────────────────────────────────── + + +class ContractUpgradeRequest(BaseModel): + """Request body for contract upgrade monitoring.""" + + contract_address: str = Field( + ..., + description="EVM contract address to check for proxy upgrade risk", + min_length=42, + max_length=42, + ) + chain: str = Field( + default="ethereum", + description="Chain name (ethereum, bsc, polygon, arbitrum, optimism, base, avalanche)", + ) + + @field_validator("contract_address") + @classmethod + def validate_address(cls, v: str) -> str: + v = v.strip() + if not is_valid_evm_address(v): + raise ValueError(f"Invalid EVM address: {v}. Must be a 0x-prefixed 40-char hex address.") + return v + + @field_validator("chain") + @classmethod + def validate_chain(cls, v: str) -> str: + valid = {"ethereum", "bsc", "polygon", "arbitrum", "optimism", "base", "avalanche"} + v = v.strip().lower() + if v not in valid: + raise ValueError(f"Unsupported chain: {v}. Supported: {', '.join(sorted(valid))}") + return v + + +# ── Endpoints ────────────────────────────────────────────────── + + +@router.post("/contract_upgrade_monitor") +async def analyze_contract_upgrades(request: Request, body: ContractUpgradeRequest) -> dict: + """ + Monitor proxy contract upgrades for malicious implementation swaps. + Detects EIP-1967, EIP-1822 UUPS, Beacon, and Gnosis Safe proxies. + Returns upgrade history, timelock status, and risk assessment (0-100). + """ + address = body.contract_address.strip() + chain = body.chain.strip().lower() + + # Check cache + cache_key = f"contract_upgrade_monitor:{address}:{chain}" + r = _get_redis() + if r: + with suppress(Exception): + cached = r.get(cache_key) + if cached: + return json.loads(cached) + + # Check trial quota (via x402 middleware headers if available) + trial_used = False + x402_headers = getattr(request.state, "x402", None) or {} + quota = x402_headers.get("remaining_quota", {}) + if isinstance(quota, dict) and quota.get("contract_upgrade_monitor", 0) > 0: + trial_used = True + logger.info( + "Trial quota used for contract_upgrade_monitor on %s (%s)", + address[:10], + chain, + ) + + try: + analyzer = get_upgrade_analyzer() + report = await analyzer.analyze( + contract_address=address, + chain=chain, + ) + + response = { + "success": True, + "tool": "contract_upgrade_monitor", + "contract_address": address, + "chain": chain, + "is_proxy": report.proxy_info.is_proxy if report.proxy_info else False, + "proxy_type": report.proxy_info.proxy_type if report.proxy_info else None, + "proxy_type_name": report.proxy_info.proxy_type_name if report.proxy_info else None, + "implementation_address": report.proxy_info.implementation_address if report.proxy_info else None, + "admin_address": report.proxy_info.admin_address if report.proxy_info else None, + "beacon_address": report.proxy_info.beacon_address if report.proxy_info else None, + "timelock_status": report.timelock_status, + "upgrade_count_30d": report.upgrade_count_30d, + "upgrade_history_count": len(report.upgrade_history), + "recent_suspicious_upgrades": len(report.recent_suspicious_upgrades), + "implementation_age_days": report.implementation_age_days, + "admin_privileges": report.admin_privileges[:10], + "risk_score": report.risk_score, + "risk_factors": report.risk_factors[:10], + "summary": report.summary, + "report_text": format_upgrade_report(report), + "trial_used": trial_used, + } + + # Cache only paid results (not trial) + if not trial_used and r: + with suppress(Exception): + r.setex(cache_key, _CACHE_TTL, json.dumps(response)) + + return response + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.exception("Contract upgrade monitor failed for %s on %s", address[:10], chain) + raise HTTPException( + status_code=500, + detail=f"Analysis failed: {e!s}", + ) from e + + +@router.get("/contract_upgrade_monitor/health") +async def contract_upgrade_health() -> dict: + """Health check endpoint.""" + return { + "status": "ok", + "tool": "contract_upgrade_monitor", + "version": "1.0.0", + } diff --git a/app/_archive/legacy_2026_07/x402_cross_chain_whale.py b/app/_archive/legacy_2026_07/x402_cross_chain_whale.py new file mode 100644 index 0000000..2ac6148 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_cross_chain_whale.py @@ -0,0 +1,245 @@ +""" +x402 Router: cross_chain_whale +================================ +Wraps CrossChainWhaleTracker with: + - Address validation + - x402 payment middleware integration + - Caching (Redis if available) + - Trial quota tracking + - Rate limiting support + +TOOL : cross_chain_whale +TIER : intelligence +PRICE : $0.08 (80000 atoms) +TRIAL : 2 free checks +ROUTER: /api/v1/x402-tools/cross_chain_whale +""" + +import json +import logging +import os +from contextlib import suppress + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field, field_validator + +from app.cross_chain_whale import ( + CrossChainWhaleTracker, + format_whale_report, + get_whale_tracker, + is_valid_address, +) + +logger = logging.getLogger("x402_cross_chain_whale") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"]) + +# ── Redis helpers ───────────────────────────────────────────── + +_redis = None + + +def _get_redis(): + global _redis + if _redis is None: + try: + import redis as redis_mod + + _redis = redis_mod.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", 6379)), + db=int(os.getenv("REDIS_DB", 0)), + password=os.getenv("REDIS_PASSWORD", None), + decode_responses=True, + ) + _redis.ping() + except Exception: + _redis = False # Sentinel for "not available" + return _redis if _redis is not False else None + + +_CACHE_TTL = 300 # 5 minutes + + +# ── Request Models ───────────────────────────────────────────── + + +class CrossChainWhaleRequest(BaseModel): + """Request body for cross-chain whale tracking.""" + + token_address: str = Field(..., description="Token contract/mint address to track") + chains: list[str] | None = Field( + default=None, + description="Chains to check (default: solana, ethereum, base, bsc)", + ) + + @field_validator("token_address") + @classmethod + def validate_address(cls, v: str) -> str: + v = v.strip() + if not is_valid_address(v): + raise ValueError( + f"Invalid address: {v}. Must be a valid Solana (base58) " + "or EVM (0x-prefixed hex) address." + ) + return v + + +class CrossChainWhaleWalletRequest(BaseModel): + """Request body for wallet-level cross-chain tracking.""" + + wallet_address: str = Field(..., description="Wallet address to track across chains") + chains: list[str] | None = Field( + default=None, + description="Chains to check (default: all supported chains)", + ) + + @field_validator("wallet_address") + @classmethod + def validate_address(cls, v: str) -> str: + v = v.strip() + if not is_valid_address(v): + raise ValueError(f"Invalid wallet address: {v}") + return v + + +# ── Endpoints ────────────────────────────────────────────────── + + +@router.post("/cross_chain_whale") +async def track_cross_chain_whale( + request: Request, + body: CrossChainWhaleRequest, +): + """Track whale holdings for a token across multiple chains. + + Returns a comprehensive report of holder concentrations, + cross-chain whale positions, and risk scoring. + """ + redis_client = _get_redis() + cache_key = f"x402:whale:{body.token_address}:{','.join(body.chains or [])}" + + # Check cache + if redis_client: + with suppress(Exception): + cached = redis_client.get(cache_key) + if cached: + return {"success": True, "data": json.loads(cached), "cached": True} + + try: + tracker: CrossChainWhaleTracker = get_whale_tracker() + + # Determine chains to scan + chains = body.chains or ["solana", "ethereum", "base", "bsc"] + + report = await tracker.track_token( + token_address=body.token_address, + chains=chains, + ) + + # Build response data + response_data = { + "token_address": report.token_address, + "token_symbol": report.token_symbol, + "token_name": report.token_name, + "chains_found": report.chains_found, + "chains_no_data": report.chains_no_data, + "total_holders_tracked": report.total_holders, + "total_value_tracked_usd": round(report.total_value_tracked, 2), + "concentration_score": report.concentration_score, + "cross_chain_whale_count": len(report.cross_chain_whales), + "top_holders_by_chain": report.top_holders_by_chain, + "cross_chain_whales": [ + { + "address": cw.primary_address, + "address_short": f"{cw.primary_address[:6]}...{cw.primary_address[-4:]}", + "chain_count": cw.chain_count, + "total_value_usd": round(cw.total_value_usd, 2), + "chains": list({p.chain for p in cw.positions}), + "risk_score": cw.risk_score, + "risk_factors": cw.risk_factors[:5], + } + for cw in report.cross_chain_whales[:20] + ], + "top_whale": ( + { + "address": report.cross_chain_whales[0].primary_address, + "chain_count": report.cross_chain_whales[0].chain_count, + "total_value_usd": round(report.cross_chain_whales[0].total_value_usd, 2), + } + if report.cross_chain_whales + else None + ), + "human_readable": format_whale_report(report), + "errors": report.errors[:5], + "scan_timestamp": report.scan_timestamp, + } + + # Cache it + if redis_client and not report.errors: + with suppress(Exception): + redis_client.setex(cache_key, _CACHE_TTL, json.dumps(response_data)) + + return {"success": True, "data": response_data} + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.exception(f"cross_chain_whale failed: {e}") + raise HTTPException( + status_code=500, + detail=f"Cross-chain whale tracking failed: {e!s}", + ) from e + + +@router.post("/cross_chain_whale_wallet") +async def track_cross_chain_wallet( + request: Request, + body: CrossChainWhaleWalletRequest, +): + """Track a specific wallet's token positions across multiple chains. + + Returns a comprehensive view of what tokens a whale wallet holds + across all supported chains. + """ + redis_client = _get_redis() + cache_key = f"x402:whale_wallet:{body.wallet_address}:{','.join(body.chains or [])}" + + if redis_client: + with suppress(Exception): + cached = redis_client.get(cache_key) + if cached: + return {"success": True, "data": json.loads(cached), "cached": True} + + try: + tracker: CrossChainWhaleTracker = get_whale_tracker() + + chains = body.chains or [ + "solana", + "ethereum", + "base", + "bsc", + "polygon", + "arbitrum", + "optimism", + ] + + result = await tracker.track_wallet( + wallet_address=body.wallet_address, + chains=chains, + ) + + if redis_client: + with suppress(Exception): + redis_client.setex(cache_key, _CACHE_TTL, json.dumps(result)) + + return {"success": True, "data": result} + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.exception(f"cross_chain_whale_wallet failed: {e}") + raise HTTPException( + status_code=500, + detail=f"Wallet cross-chain tracking failed: {e!s}", + ) from e diff --git a/app/_archive/legacy_2026_07/x402_databus_fallback.py b/app/_archive/legacy_2026_07/x402_databus_fallback.py new file mode 100644 index 0000000..26dcc32 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_databus_fallback.py @@ -0,0 +1,426 @@ +""" +RMI DataBus Fallback Middleware - Universal Single-Source Pipeline +==================================================================== +Every x402 tool gets DataBus as its data backbone. If the primary endpoint +returns empty data, an error, or times out, DataBus.fetch() catches it with +its 38-chain fallback system and caching layer. + +Architecture: + Request → Primary endpoint → success with real data? → Response + ↓ empty/error/timeout + → DataBus.fetch(data_type, ...) → success? → Response + metadata + ↓ fail + → Original error response (DataBus exhausted all sources) + +Zero internal source names leak to clients. Descriptions are bot-friendly. +The `_fallback` and `_databus_chain` fields tell bots this data is verified +through multi-source consensus, not a single point of failure. +""" + +import logging +from typing import Any + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +logger = logging.getLogger("x402_databus_fallback") + +# ── Complete Tool ID → DataBus data_type mapping ────────────────── +# Every tool maps to at least one DataBus chain. Where a tool's primary +# function doesn't match a chain exactly, we map to the closest chain +# that provides relevant data. Composite tools map to their most +# important underlying data need. + +TOOL_TO_DATABUS = { + # ─── Price & Token Intelligence ─── + "token_price": "token_price", + "token_detail": "token_detail", + "token_deep_dive": "token_detail", + "token_comparison": "token_price", + "token_forensics": "token_detail", + "token_pulse": "token_price", + "token_velocity": "token_detail", + "token_distribution_health": "token_detail", + "token_age": "token_detail", + "pulse": "token_price", + "fresh_pair": "dex_data", + "dex_volume_rank": "dex_data", + # ─── Market Data ─── + "market_overview": "market_overview", + "market_movers": "market_movers", + "trending": "trending", + "tvl": "tvl", + "defi_protocols": "defi_protocols", + "chain_health": "tvl", + "funding_rate": "market_overview", + "liquidation_heatmap": "market_movers", + "volatility_surface": "market_movers", + "volume_profile": "market_movers", + "sector_rotation": "market_overview", + "options_flow": "market_movers", + "orderbook_imbalance": "dex_data", + # ─── DEX & DeFi ─── + "dex_data": "dex_data", + "defi_yield_scanner": "tvl", + "defi_position": "defi_protocols", + "yield_aggregator": "tvl", + "impermanent_loss": "defi_protocols", + "liquidity_depth": "dex_data", + "liquidity_flow": "dex_data", + "liquidity_migration": "dex_data", + "liquidity_verify": "dex_data", + "bridge_security": "tvl", + # ─── Wallet Intelligence ─── + "wallet_balance": "wallet_balance", + "wallet_profile": "wallet_profile", + "wallet_labels": "wallet_labels", + "wallet_tokens": "wallet_tokens", + "wallet_pnl": "wallet_pnl", + "wallet_cluster": "wallet_cluster", + "wallet_graph": "wallet_cluster", + "wallet": "wallet_profile", + "whale": "wallet_profile", + "whale_profile": "wallet_profile", + "whale_scan": "wallet_profile", + "whale_accumulation": "smart_money", + "whale_network_map": "wallet_cluster", + "full_wallet_dossier": "wallet_profile", + "wallet_label_registry": "wallet_labels", + "wallet_drain_scanner": "risk_scan", + "wallet_cluster_score": "wallet_cluster", + "smart_contract_interactions": "wallet_profile", + "portfolio_tracker": "portfolio", + "portfolio_aggregate": "portfolio", + "portfolio_risk": "portfolio", + # ─── Smart Money & Tracking ─── + "smart_money": "smart_money", + "smart_money_alpha": "smart_money", + "smartmoney": "smart_money", + "gmgn_smart_money": "gmgn_smart_money", + "copy_trade_finder": "gmgn_smart_money", + "insider": "smart_money", + "insider_network": "entity_intel", + "dormant_whale_alert": "smart_money", + # ─── Cross-Chain & Forensics ─── + "cross_chain": "cross_chain", + "cross_chain_trace": "cross_chain", + "cross_chain_whale": "cross_chain", + "funding_source": "funding_source", + "fund_flow": "funding_source", + # ─── Risk & Security ─── + "risk_scan": "risk_scan", + "rug_shield": "risk_scan", + "rugshield": "risk_scan", + "rug_pull_predictor": "risk_scan", + "rug_probability": "risk_scan", + "honeypot_check": "risk_scan", + "threat_check": "threat_check", + "sentinel_scan": "sentinel_deep", + "sentinel_deep": "sentinel_deep", + "audit": "contract_scan", + "deep_contract_audit": "contract_scan", + "contract_scan": "contract_scan", + "static_analysis": "contract_scan", + "decompiler_analysis": "contract_scan", + "contract_diff": "contract_scan", + "contract_upgrade_monitor": "contract_scan", + "reentrancy_scanner": "contract_scan", + "proxy_detect": "contract_scan", + "scam_database": "threat_check", + "urlcheck": "threat_check", + "url_scam_detector": "threat_check", + "dust_attack_detect": "threat_check", + "phantom_mint_detect": "threat_check", + "privilege_escalation": "contract_scan", + # ─── Bundle & MEV Detection ─── + "bundle_detect": "bundle_detect", + "bundler_detect": "bundle_detect", + "mev_alert": "bundle_detect", + "mev_detect": "bundle_detect", + "mev_protection": "bundle_detect", + "flash_loan_detect": "bundle_detect", + "pump_dump_detect": "bundle_detect", + "pumpfun_analysis": "risk_scan", + # ─── Entity & Labels ─── + "entity_intel": "entity_intel", + "arkham_labels": "arkham_labels", + "arkham_entity": "arkham_entity", + "arkham_portfolio": "arkham_portfolio", + "arkham_transfers": "arkham_transfers", + "arkham_counterparties": "arkham_counterparties", + "nansen_labels": "nansen_labels", + "nansen_smart_money": "nansen_smart_money", + "address_labels": "wallet_labels", + "deployer_history": "wallet_labels", + "dev_reputation": "wallet_labels", + "reputation_score": "wallet_labels", + "kol_performance": "social_feed", + # ─── Holder & Distribution ─── + "bubble_map": "bubble_map", + "rugmaps_analysis": "rugmaps_analysis", + "holder_analysis": "rugmaps_analysis", + # ─── Social & Sentiment ─── + "social_feed": "social_feed", + "social_sentiment": "social_feed", + "social_signal": "social_feed", + "sentiment": "social_feed", + "sentiment_spike": "social_feed", + "sentiment_check": "social_feed", + "narrative": "social_feed", + "reddit_sentiment": "social_feed", + "discord_alpha": "social_feed", + "influencer_impact_score": "social_feed", + "twitter_profile": "social_feed", + "twitter_timeline": "social_feed", + "twitter_search": "social_feed", + "tw_profile": "social_feed", + "tw_timeline": "social_feed", + "tw_search": "social_feed", + "telegram_pump_detect": "social_feed", + "meme_vibe_score": "social_feed", + # ─── News & Information ─── + "news": "news", + "alpha_digest": "news", + "github_developer_activity": "news", + # ─── Prediction Markets ─── + "prediction_markets": "prediction_markets", + "prediction_signals": "prediction_signals", + "listing_predictor": "prediction_markets", + # ─── Social Identity ─── + "socialfi_resolve": "socialfi_resolve", + # ─── Launch & New Tokens ─── + "sniper_alert": "trending", + "launch_radar": "trending", + "launch_intel": "trending", + "launch": "trending", + "fair_launch_detect": "trending", + "presale_scanner": "trending", + "ido_tracker": "defi_protocols", + # ─── RAG ─── + "rag_search": "rag_search", + "osint_identity_hunt": "rag_search", + "investigation_report": "rag_search", + "forensic_valuation": "rag_search", + # ─── Gas ─── + "gas_forecast": "market_overview", + # ─── Syndicate & Clustering ─── + "syndicate_scan": "wallet_cluster", + "syndicate_track": "wallet_cluster", + "cluster": "wallet_cluster", + "cluster_detection": "wallet_cluster", + # ─── NFT ─── + "nft_wash_detector": "threat_check", + "nft_floor_analytics": "dex_data", + # ─── Anomaly & Analysis ─── + "anomaly": "market_movers", + "anomaly_detector": "market_movers", + "correlation_matrix": "market_overview", + "drawdown_analyzer": "market_movers", + "sharpe_ratio_calc": "market_overview", + "tax_lot_optimizer": "portfolio", + "forensics": "token_detail", + "composite_score": "risk_scan", + # ─── Token Watch (static/config endpoints need no fallback) ─── + # These return config/status, not data + # "token_watch_create", "token_watch_list", etc. → no fallback needed + # ─── Webhooks (config, not data) ─── + # "webhook_register", "webhook_list" → no fallback needed + # ─── Airdrop ─── + "airdrop_check": "defi_protocols", + "airdrop_finder": "defi_protocols", + "vesting_schedule_analyzer": "defi_protocols", + "unlock_calendar": "defi_protocols", + # ─── Analysis ─── + "history": "token_price", + "degen_score": "risk_scan", + "profile_flip": "social_feed", +} + +# Data types that accept 'address' parameter +ADDRESS_TYPES = { + "risk_scan", + "contract_scan", + "wallet_profile", + "wallet_pnl", + "wallet_labels", + "smart_money", + "gmgn_smart_money", + "funding_source", + "cross_chain", + "wallet_cluster", + "bundle_detect", + "sentinel_deep", + "entity_intel", + "arkham_labels", + "arkham_entity", + "arkham_portfolio", + "arkham_transfers", + "arkham_counterparties", + "nansen_labels", + "nansen_smart_money", + "portfolio", + "wallet_balance", + "wallet_tokens", + "threat_check", + "socialfi_resolve", + "bubble_map", + "rugmaps_analysis", + "token_detail", + "dex_data", +} + +# Data types that accept 'mint' parameter +MINT_TYPES = {"token_price", "token_detail", "dex_data", "risk_scan"} + +# Data types that accept 'query' parameter +QUERY_TYPES = { + "trending", + "news", + "social_feed", + "market_movers", + "prediction_markets", + "prediction_signals", + "rag_search", + "smart_money", + "gmgn_smart_money", +} + + +class DataBusFallbackMiddleware(BaseHTTPMiddleware): + """ + Universal fallback middleware: if any x402-tools endpoint returns + empty data, errors, or times out, transparently falls back to + DataBus.fetch() which has 38 chains with automatic source failover + and multi-layer caching. + """ + + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + + # Only intercept x402-tools paths (not x402-databus which already uses DataBus) + path = request.url.path + if not path.startswith("/api/v1/x402-tools/"): + return response + + # Only intercept failed or empty responses + if response.status_code >= 500: + return await self._try_databus_fallback(request, response, "server_error") + + if response.status_code == 200: + # Skip trial responses - DataBus was already used for execution + if response.headers.get("X-RMI-Trial") == "true": + return response + + try: + body = b"" + async for chunk in response.body_iterator: + body += chunk + import json + + data = json.loads(body) + + if self._is_empty_response(data): + return await self._try_databus_fallback(request, response, "empty_data", original_data=data) + + # Success - return as-is with body we already read + return JSONResponse(content=data, status_code=200, headers=dict(response.headers)) + except Exception: + # Can't parse body - return original response + return response + + # 4xx errors (402 payment required, 400 bad request) pass through + return response + + def _is_empty_response(self, data: Any) -> bool: + """Check if response has meaningful data.""" + if data is None: + return True + if isinstance(data, dict): + if data.get("error") and not data.get("data"): + return True + if data.get("status") in ("error", "failed", "no_data", "unavailable"): + return True + result = data.get("result") or data.get("data") or data.get("analysis") or data.get("report") + if result is None and len(data) <= 3: + return True + return False + + async def _try_databus_fallback( + self, + request: Request, + original_response: Response, + reason: str, + original_data: dict | None = None, + ) -> Response: + """Attempt DataBus fallback for the tool.""" + # Extract tool_id from path + tool_id = request.url.path.replace("/api/v1/x402-tools/", "").strip("/") + + # Strip trailing path segments (e.g., /api/v1/x402-tools/rug_shield/solana) + tool_id = tool_id.split("/")[0] if "/" in tool_id else tool_id + + data_type = TOOL_TO_DATABUS.get(tool_id) + if not data_type: + # No DataBus mapping - return original response + return original_response + + try: + from app.databus import databus + + kwargs = {"consumer_type": "x402_paid", "x402_tier": "basic"} + + # Extract parameters from request body + try: + body_bytes = await request.body() + if body_bytes: + import json + + body_data = json.loads(body_bytes) + else: + body_data = {} + except Exception: + body_data = {} + + # Map request params to DataBus kwargs + address = body_data.get("address") or body_data.get("wallet") + if address and data_type in ADDRESS_TYPES: + kwargs["address"] = address + + mint = body_data.get("mint") or body_data.get("token") or (address if data_type in MINT_TYPES else None) + if mint and data_type in MINT_TYPES: + kwargs["mint"] = mint + + chain = body_data.get("chain", "solana") + kwargs["chain"] = chain + + query = body_data.get("query") or body_data.get("q") + if query and data_type in QUERY_TYPES: + kwargs["query"] = query + + # Also check query params + qp = dict(request.query_params) + if "address" in qp and data_type in ADDRESS_TYPES: + kwargs["address"] = qp["address"] + if "mint" in qp and data_type in MINT_TYPES: + kwargs["mint"] = qp["mint"] + if "chain" in qp: + kwargs["chain"] = qp["chain"] + if "query" in qp and data_type in QUERY_TYPES: + kwargs["query"] = qp["query"] + + result = await databus.fetch(data_type, **kwargs) + + if result and not (isinstance(result, dict) and result.get("error") and not result.get("data")): + logger.info(f"DataBus fallback: {tool_id} → {data_type} (reason: {reason})") + if isinstance(result, dict): + result["_fallback"] = True + result["_databus_chain"] = data_type + result["_original_error"] = reason + return JSONResponse(content=result, status_code=200) + + except Exception as e: + logger.warning(f"DataBus fallback failed for {tool_id} → {data_type}: {e}") + + # DataBus also failed - return original error + return original_response diff --git a/app/_archive/legacy_2026_07/x402_deployer_history.py b/app/_archive/legacy_2026_07/x402_deployer_history.py new file mode 100644 index 0000000..98ea0bd --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_deployer_history.py @@ -0,0 +1,239 @@ +""" +x402 Router: deployer_history +============================== +Wraps DeployerHistoryAnalyzer from deployer_history.py with: + - Address validation + - x402 payment middleware integration + - Caching (Redis if available) + - Trial quota tracking + - Rate limiting support + +TOOL : deployer_history +TIER : premium +PRICE : $0.05 (50000 atoms) +TRIAL : 2 free checks +ROUTER: /api/v1/x402-tools/deployer_history +""" + +import json +import logging +import os +import time +from contextlib import suppress +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field, field_validator + +from app.deployer_history import DeployerHistoryAnalyzer + +logger = logging.getLogger("x402_deployer_history") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"]) + +# ── Redis cache (best-effort) ─────────────────────────────────── +_redis = None +try: + import redis.asyncio as aioredis + + _redis = aioredis.from_url( + os.getenv("REDIS_URL", "redis://localhost:6379/0"), + decode_responses=True, + socket_connect_timeout=2, + ) +except Exception: + logger.debug("Redis not available for deployer_history cache") + + +async def _get_cache(key: str) -> dict[str, Any] | None: + """Get cached result if Redis is available.""" + if _redis is None: + return None + with suppress(Exception): + data = await _redis.get(f"x402:cache:deployer_history:{key}") + if data: + result: dict[str, Any] = json.loads(data) + return result + return None + + +async def _set_cache(key: str, result: dict[str, Any], ttl: int = 300) -> None: + """Cache result in Redis with TTL.""" + if _redis is None: + return + with suppress(Exception): + await _redis.setex( + f"x402:cache:deployer_history:{key}", ttl, json.dumps(result, default=str) + ) + + +# ── Trial tracking ────────────────────────────────────────────── + + +def _trial_key(wallet: str) -> str: + return f"x402:deployer_history_trials:{wallet}" + + +async def _check_trials(wallet: str) -> int: + """Check how many trials this wallet has used.""" + if _redis is None: + return 0 + try: + used = await _redis.get(_trial_key(wallet)) + return int(used) if used else 0 + except Exception: + return 0 + + +async def _increment_trials(wallet: str) -> None: + """Increment trial usage for a wallet.""" + if _redis is None: + return + try: + await _redis.incr(_trial_key(wallet)) + await _redis.expire(_trial_key(wallet), 86400 * 30) # Reset monthly + except Exception: + pass + + +# ── Request / Response models ─────────────────────────────────── + + +class DeployerHistoryRequest(BaseModel): + """Request model for deployer history analysis.""" + + address: str = Field(..., description="Deployer wallet address to investigate") + include_token_details: bool = Field(True, description="Include detailed token list in response") + chain: str | None = Field( + None, description="Optional chain filter (ethereum, solana, bsc, base, polygon)" + ) + + @field_validator("address") + @classmethod + def validate_address(cls, v: str) -> str: + v = v.strip() + is_evm = v.startswith("0x") and len(v) == 42 + is_solana = not v.startswith("0x") and 32 <= len(v) <= 44 and v.isascii() + if not is_evm and not is_solana: + raise ValueError( + "Address must be a valid wallet address (0x... for EVM, base58 for Solana)" + ) + return v.lower() + + +class DeployerHistoryResponse(BaseModel): + """Response model for deployer history analysis.""" + + success: bool = True + tool: str = "deployer_history" + data: dict[str, Any] = Field(default_factory=dict) + cached: bool = False + scanned_at: str = "" + + +# ── Endpoint ──────────────────────────────────────────────────── + + +@router.post("/deployer_history") +async def analyze_deployer_history( + request: Request, + body: DeployerHistoryRequest, +) -> DeployerHistoryResponse: + """ + Investigate the complete deployment history of any token creator address. + + Analyzes all tokens deployed by the given address, detects rug pulls, + honeypots, and other scam patterns. Returns a comprehensive risk assessment + with per-token breakdown. + """ + start = time.time() + wallet = request.headers.get("x-wallet-address", "anonymous") + + # ── Trial check ── + max_trial = 2 + trials_used = await _check_trials(wallet) + if trials_used < max_trial: + await _increment_trials(wallet) + + # ── Cache check ── + cache_key = f"{body.address}:{body.include_token_details}:{body.chain or 'all'}" + cached = await _get_cache(cache_key) + if cached: + elapsed = time.time() - start + return DeployerHistoryResponse( + data=cached, + cached=True, + scanned_at=cached.get("scanned_at", ""), + ) + + # ── Run analysis ── + try: + analyzer = DeployerHistoryAnalyzer(body.address) + result = await analyzer.analyze() + + # Filter by chain if specified + if body.chain and result.get("tokens"): + result["tokens"] = [ + t for t in result["tokens"] if t.get("chain", "").lower() == body.chain.lower() + ] + result["total_tokens_deployed"] = len(result["tokens"]) + result["chains_used"] = list({t.get("chain", "") for t in result["tokens"]}) + + # Remove detailed token list if not requested + if not body.include_token_details and "tokens" in result: + token_summary = [ + { + "address": t["address"], + "chain": t["chain"], + "symbol": t["symbol"], + "risk_level": "rug" + if t.get("is_rug") + else ( + "honeypot" + if t.get("is_honeypot") + else "active" + if t.get("is_active") + else "dead" + ), + } + for t in result["tokens"] + ] + result["tokens"] = token_summary + + # ── Cache result ── + await _set_cache(cache_key, result, ttl=300) + + elapsed = time.time() - start + logger.info( + f"deployer_history: {body.address} → " + f"{result.get('total_tokens_deployed', 0)} tokens, " + f"risk={result.get('risk_level', 'unknown')}, " + f"took={elapsed:.1f}s" + ) + + return DeployerHistoryResponse( + data=result, + scanned_at=result.get("scanned_at", datetime.now(UTC).isoformat()), + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.error(f"deployer_history failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)[:200]}") from e + + +# ── Health check ──────────────────────────────────────────────── + + +@router.get("/deployer_history/health") +async def deployer_history_health() -> dict[str, object]: + """Health check for deployer_history tool.""" + return { + "tool": "deployer_history", + "status": "ok", + "tier": "premium", + "price_usd": 0.05, + "trial_free": 2, + } diff --git a/app/_archive/legacy_2026_07/x402_dex_pool_manipulation.py b/app/_archive/legacy_2026_07/x402_dex_pool_manipulation.py new file mode 100644 index 0000000..d915b44 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_dex_pool_manipulation.py @@ -0,0 +1,200 @@ +""" +x402 Router: dex_pool_manipulation +===================================== +Wraps DEXPoolManipulationAnalyzer from dex_pool_manipulation_analyzer.py with: + - Address validation + - x402 payment middleware integration + - Caching (Redis if available) + - Trial quota tracking + - Rate limiting support + +TOOL : dex_pool_manipulation +TIER : premium +PRICE : $0.10 (100000 atoms) +TRIAL : 1 free check +ROUTER: /api/v1/x402-tools/dex_pool_manipulation +""" + +import json +import logging +import os +from contextlib import suppress +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field, field_validator + +from app.dex_pool_manipulation_analyzer import ( + DEXPoolManipulationAnalyzer, + format_risk_report, + is_valid_address, +) + +logger = logging.getLogger("x402_dex_pool_manipulation") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"]) + +# ── Redis helpers ───────────────────────────────────────────── + +_redis = None + + +def _get_redis(): + global _redis + if _redis is None: + try: + import redis as redis_mod + + _redis = redis_mod.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", 6379)), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_connect_timeout=2, + ) + _redis.ping() + except Exception: + _redis = False # sentinel + return _redis if _redis is not False else None + + +# ── Request/Response models ─────────────────────────────────── + + +class PoolManipulationRequest(BaseModel): + pool_address: str = Field(..., description="DEX pool contract address") + chain: str = Field("ethereum", description="Blockchain (ethereum, polygon, solana, etc.)") + dex: str = Field("uniswap_v3", description="DEX name (uniswap_v3, pancakeswap, raydium, etc.)") + recent_swaps: list[dict] | None = Field(None, description="Recent swap events (optional)") + positions: list[dict] | None = Field( + None, description="LP positions for concentrated liquidity pools (optional)" + ) + pool_metadata: dict | None = Field(None, description="Pool configuration metadata (optional)") + + @field_validator("pool_address") + @classmethod + def validate_address(cls, v: str) -> str: + v = v.strip() + if not is_valid_address(v): + raise ValueError(f"Invalid pool address format: {v}") + return v + + +class PoolManipulationResponse(BaseModel): + success: bool + data: dict[str, Any] | None = None + error: str | None = None + + +# ── x402 payment / trial helpers ────────────────────────────── + +PAYMENT_REQUIRED_MSG = "x402 payment required: 100000 atoms for dex_pool_manipulation" + + +def _check_access(request: Request) -> tuple[bool, str | None]: + """ + Check if request has valid x402 payment or trial quota. + Returns (has_access, error_message). + """ + # Check x402 payment header + payment = request.headers.get("X-402-Payment", "") + if payment and _verify_payment(payment, "dex_pool_manipulation", 100000): + return True, None + + # Check trial quota + user_id = ( + request.headers.get("X-User-Id", "") or request.client.host + if request.client + else "anonymous" + ) + if _check_trial_quota(user_id, "dex_pool_manipulation", 1): + return True, None + + return False, PAYMENT_REQUIRED_MSG + + +def _verify_payment(payment_token: str, tool: str, amount: int) -> bool: + """Verify x402 payment token. Placeholder for actual x402 verification.""" + try: + payload = json.loads(payment_token) if isinstance(payment_token, str) else {} + return payload.get("tool") == tool and payload.get("amount", 0) >= amount + except (json.JSONDecodeError, TypeError): + return False + + +def _check_trial_quota(user_id: str, tool: str, max_free: int) -> bool: + """Check and increment trial usage.""" + r = _get_redis() + if r is None: + return True # Allow if Redis is down (graceful degradation) + + key = f"trial:{tool}:{user_id}" + try: + count = r.incr(key) + if count == 1: + r.expire(key, 86400) # 24hr window + return count <= max_free + except Exception: + return True + + +# ── Route ────────────────────────────────────────────────────── + + +@router.post("/dex_pool_manipulation", response_model=PoolManipulationResponse) +async def analyze_pool_manipulation(request: Request, body: PoolManipulationRequest): + """ + Analyze a DEX pool for liquidity manipulation, sandwich vulnerability, + fake liquidity, price manipulation, and other risk signals. + + Payment required: 100000 atoms ($0.10) or 1 free trial per 24h. + """ + has_access, error_msg = _check_access(request) + if not has_access: + raise HTTPException(status_code=402, detail=error_msg) + + try: + analyzer = DEXPoolManipulationAnalyzer(chain=body.chain, dex=body.dex) + report = await analyzer.analyze_pool( + pool_address=body.pool_address, + recent_swaps=body.recent_swaps, + positions=body.positions, + pool_metadata=body.pool_metadata, + ) + + formatted = format_risk_report(report) + + # Cache result if Redis is available + r = _get_redis() + if r is not None: + cache_key = f"pool_manip:{body.chain}:{body.pool_address.lower()}" + with suppress(Exception): + r.setex(cache_key, 300, json.dumps(formatted)) + + return PoolManipulationResponse(success=True, data=formatted) + + except ValueError as e: + logger.warning(f"Validation error: {e}") + return PoolManipulationResponse(success=False, error=str(e)) + except Exception as e: + logger.exception(f"Analysis failed for pool {body.pool_address}") + return PoolManipulationResponse(success=False, error=f"Analysis failed: {str(e)[:200]}") + + +@router.get("/dex_pool_manipulation/health") +async def pool_manipulation_health(): + """Health check endpoint.""" + return {"status": "ok", "tool": "dex_pool_manipulation", "version": "1.0.0"} + + +# ── Cache warmup endpoint (admin) ───────────────────────────── + + +@router.post("/dex_pool_manipulation/warmup") +async def warmup_pool_manipulation(pool_address: str, chain: str = "ethereum"): + """Pre-warm the cache for a specific pool (admin only).""" + # Admin key check + admin_key = os.getenv("ADMIN_API_KEY", "") + if admin_key: + return {"status": "warmed", "note": "Admin key required for production use"} + return {"status": "ok", "note": "Warmup placeholder"} diff --git a/app/_archive/legacy_2026_07/x402_forensic_tools.py b/app/_archive/legacy_2026_07/x402_forensic_tools.py new file mode 100644 index 0000000..9c95156 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_forensic_tools.py @@ -0,0 +1,271 @@ +""" +RMI x402 Forensic Investigation Tools - Premium Analysis Endpoints +================================================================ +TOOL 37: Forensic Valuation ($0.25) - DCF + Comps + Scam scoring +TOOL 38: OSINT Identity Hunt ($0.15) - Cross-platform username/domain investigation +TOOL 39: Investigation Report ($0.20) - Full investigative deliverable + +These are premium x402-paid endpoints that provide institutional-grade +crypto scam investigation capabilities. +""" + +import logging + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.routers.x402_tools import fetch_with_fallback, record_x402_payment + +logger = logging.getLogger("x402_forensic_tools") +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-forensic-tools"]) + + +# ── TOOL 37: Forensic Valuation - DCF + Comps Analysis ($0.25) ── + + +class ForensicValuationRequest(BaseModel): + address: str + chain: str = "solana" + peer_tokens: str | None = None + include_dcf: bool = True + include_comps: bool = True + + +@router.post("/forensic_valuation") +async def forensic_valuation(req: ForensicValuationRequest): + """Institutional-grade token valuation - DCF intrinsic value, comparable analysis + with statistical outlier detection, and scam probability scoring. + Proves whether a token has any fundamental value or is purely speculative. + """ + try: + result = {"address": req.address, "chain": req.chain, "valuation": {}, "scam_signals": []} + + data, _ = await fetch_with_fallback([f"https://api.dexscreener.com/latest/dex/tokens/{req.address}"]) + if data and data.get("pairs"): + pair = data["pairs"][0] + fdv = float(pair.get("fdv", 0) or 0) + liquidity = float(pair.get("liquidity", {}).get("usd", 0) or 0) + volume_24h = float(pair.get("volume", {}).get("h24", 0) or 0) + price_change_24h = float(pair.get("priceChange", {}).get("h24", 0) or 0) + result["market_data"] = { + "fdv": fdv, + "liquidity_usd": liquidity, + "volume_24h": volume_24h, + "price_change_24h_pct": price_change_24h, + } + + if req.include_comps: + fdv_volume_ratio = fdv / volume_24h if volume_24h > 0 else 0 + liq_fdv_pct = (liquidity / fdv * 100) if fdv > 0 else 0 + benchmarks = { + "fdv_tvl_ratio": {"median": 15, "rug_min": 500}, + "fdv_volume_ratio": {"median": 50, "rug_min": 1000}, + "liq_fdv_pct": {"safe_min": 0.5, "rug_max": 0.1}, + } + comps_results = { + "fdv_volume_ratio": round(fdv_volume_ratio, 1), + "liq_fdv_pct": round(liq_fdv_pct, 4), + "benchmarks": benchmarks, + "outlier_flags": [], + } + if fdv_volume_ratio > benchmarks["fdv_volume_ratio"]["rug_min"]: + comps_results["outlier_flags"].append( + f"EXTREME: FDV/Volume {fdv_volume_ratio:.0f}x > {benchmarks['fdv_volume_ratio']['rug_min']}x" + ) + result["scam_signals"].append("fdv_volume_extreme") + if liq_fdv_pct < benchmarks["liq_fdv_pct"]["rug_max"]: + comps_results["outlier_flags"].append( + f"EXTREME: Liq/FDV {liq_fdv_pct:.2f}% < {benchmarks['liq_fdv_pct']['rug_max']}%" + ) + result["scam_signals"].append("ruggable_liquidity") + result["valuation"]["comps"] = comps_results + + if req.include_dcf: + dcf = { + "revenue_streams": "unknown", + "fee_mechanism": "none detected", + "intrinsic_value_estimate": 0, + } + if volume_24h > 0 and fdv > 0: + annualized_fees = volume_24h * 365 * 0.003 + dcf["annualized_fees_estimate"] = round(annualized_fees, 2) + dcf["fee_fdv_ratio"] = round(annualized_fees / fdv * 100, 4) if fdv > 0 else 0 + if annualized_fees / fdv < 0.01: + dcf["verdict"] = "NEGATIVE - Fee revenue cannot justify FDV" + result["scam_signals"].append("dcf_negative") + else: + dcf["intrinsic_value_estimate"] = "potentially_positive" + dcf["verdict"] = "NEEDS_VERIFICATION - Fee revenue exists but must verify distribution" + else: + dcf["verdict"] = "NEGATIVE - No volume to support valuation" + result["valuation"]["dcf"] = dcf + + scam_score = 0 + for signal in result["scam_signals"]: + scam_score += 30 if "extreme" in signal or "ruggable" in signal else 25 + result["scam_probability"] = min(scam_score, 100) + result["scam_probability_label"] = ( + "CRITICAL" + if scam_score >= 70 + else "HIGH" + if scam_score >= 50 + else "MODERATE" + if scam_score >= 30 + else "LOW" + if scam_score >= 10 + else "MINIMAL" + ) + + result["pricing"] = {"tool": "forensic_valuation", "price": "$0.25"} + await record_x402_payment("forensic_valuation", "0.25", req.address) + return result + except Exception as e: + logger.error(f"Forensic valuation failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ── TOOL 38: OSINT Identity Hunt ($0.15) ── + + +class OSINTRequest(BaseModel): + username: str + domain: str | None = None + project_url: str | None = None + + +@router.post("/osint_identity_hunt") +async def osint_identity_hunt(req: OSINTRequest): + """Cross-platform OSINT investigation - hunt usernames across 400+ social networks, + domain intelligence (WHOIS/DNS/SSL), and stealth page capture for evidence preservation. + """ + try: + result = {"username": req.username, "findings": {}} + platforms_to_check = [ + ("Twitter/X", f"https://x.com/{req.username}"), + ("GitHub", f"https://github.com/{req.username}"), + ("Telegram", f"https://t.me/{req.username}"), + ("Reddit", f"https://reddit.com/user/{req.username}"), + ("YouTube", f"https://youtube.com/@{req.username}"), + ("Instagram", f"https://instagram.com/{req.username}"), + ("Medium", f"https://medium.com/@{req.username}"), + ] + found = [] + for platform, url in platforms_to_check: + try: + code, _ = await fetch_with_fallback([url], return_status=True) + if code and code < 404: + found.append({"platform": platform, "url": url, "status": "found"}) + except Exception: + pass + result["findings"]["social_presence"] = found + result["findings"]["profiles_found"] = len(found) + if req.domain: + result["findings"]["domain"] = { + "domain": req.domain, + "analysis": "Use /domain_intel tool for full WHOIS/DNS/SSL", + } + if req.project_url: + result["findings"]["project_url"] = req.project_url + result["findings"]["capture_recommended"] = "Capture project page immediately - scam sites often disappear" + result["pricing"] = {"tool": "osint_identity_hunt", "price": "$0.15"} + await record_x402_payment("osint_identity_hunt", "0.15", req.username) + return result + except Exception as e: + logger.error(f"OSINT identity hunt failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ── TOOL 39: Investigation Report Generator ($0.20) ── + + +class InvestigationReportRequest(BaseModel): + address: str + chain: str = "solana" + report_format: str = "json" + + +@router.post("/investigation_report") +async def investigation_report(req: InvestigationReportRequest): + """Full investigation report - combines on-chain forensics, financial valuation, + OSINT findings, and scam scoring into a structured deliverable. + Available as JSON, Excel, or PPTX. + """ + try: + result = { + "address": req.address, + "chain": req.chain, + "report_format": req.report_format, + "sections": [], + } + chain_data, _ = await fetch_with_fallback([f"https://api.dexscreener.com/latest/dex/tokens/{req.address}"]) + fdv = liq = vol = 0 + pair = None + if chain_data and chain_data.get("pairs"): + pair = chain_data["pairs"][0] + fdv = float(pair.get("fdv", 0) or 0) + liq = float(pair.get("liquidity", {}).get("usd", 0) or 0) + vol = float(pair.get("volume", {}).get("h24", 0) or 0) + result["sections"].append( + { + "phase": "on_chain_profiling", + "token_name": pair.get("baseToken", {}).get("name"), + "token_symbol": pair.get("baseToken", {}).get("symbol"), + "fdv": fdv, + "liquidity": liq, + "volume_24h": vol, + "price_usd": float(pair.get("priceUsd", 0) or 0), + "dex_id": pair.get("dexId"), + "pair_created": pair.get("pairCreatedAt"), + } + ) + + valuation = { + "phase": "financial_valuation", + "intrinsic_value": 0 if fdv > 0 and vol > 0 and (vol * 365 * 0.003 / fdv < 0.01) else "indeterminate", + "fdv_liquidity_ratio": round(fdv / liq, 1) if liq > 0 else 0, + "annualized_fees_vs_fdv": round(vol * 365 * 0.003 / fdv * 100, 2) if fdv > 0 else 0, + } + result["sections"].append(valuation) + + scam_score = 0 + details = [] + if fdv > 0 and liq > 0 and fdv / liq > 500: + scam_score += 35 + details.append(f"FDV/Liq ratio {fdv / liq:.0f}x - extreme ruggable liquidity") + if fdv > 0 and vol > 0 and fdv / vol > 1000: + scam_score += 25 + details.append(f"FDV/Volume ratio {fdv / vol:.0f}x - no organic volume") + if fdv > 100000 and liq < 1000: + scam_score += 30 + details.append("Near-zero liquidity for significant FDV") + + result["sections"].append( + { + "phase": "scam_assessment", + "score": min(scam_score, 100), + "level": "CRITICAL" + if scam_score >= 70 + else "HIGH" + if scam_score >= 50 + else "MODERATE" + if scam_score >= 30 + else "LOW", + "details": details, + } + ) + + result["sections"].append( + { + "phase": "deliverable", + "format": req.report_format, + "note": "XLSX/PPTX generation requires excel-author + pptx-author skills", + "evidence_references": f"See RugMunch investigation for {req.address[:8]}...", + } + ) + + result["pricing"] = {"tool": "investigation_report", "price": "$0.20"} + await record_x402_payment("investigation_report", "0.20", req.address) + return result + except Exception as e: + logger.error(f"Investigation report failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/app/_archive/legacy_2026_07/x402_insider_network.py b/app/_archive/legacy_2026_07/x402_insider_network.py new file mode 100644 index 0000000..3dcb4f8 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_insider_network.py @@ -0,0 +1,186 @@ +""" +x402 Router: insider_network +============================= +Wraps InsiderNetworkAnalyzer with: + - Address validation + - x402 payment middleware integration + - Caching (Redis if available) + - Trial quota tracking + - Rate limiting support + +TOOL : insider_network +TIER : premium / intelligence +PRICE : $0.10 (100000 atoms) +TRIAL : 1 free check +ROUTER: /api/v1/x402-tools/insider_network +""" + +import json +import logging +import os +from contextlib import suppress + +import redis as _redis_mod +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field, field_validator + +from app.insider_network import ( + format_insider_network_report, + get_insider_network_analyzer, + is_valid_address, +) + +logger = logging.getLogger("x402_insider_network") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"]) + +# ── Redis helpers ───────────────────────────────────────────── + +_redis: "_redis_mod.Redis | None" = None + + +def _get_redis(): + global _redis + if _redis is None: + try: + r = _redis_mod.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", 6379)), + db=int(os.getenv("REDIS_DB", 0)), + password=os.getenv("REDIS_PASSWORD", None), + decode_responses=True, + ) + r.ping() + _redis = r + except Exception: + _redis = None # Not available + return _redis + + +_CACHE_TTL = 600 # 10 minutes - insider networks don't change rapidly + + +# ── Request Models ───────────────────────────────────────────── + + +class InsiderNetworkRequest(BaseModel): + """Request body for insider network analysis.""" + + wallet_address: str = Field( + ..., + description="Wallet address to investigate for insider connections", + ) + chains: list[str] | None = Field( + default=None, + description="Chains to search (default: solana, ethereum, bsc, base)", + ) + deep_scan: bool = Field( + default=False, + description="If True, perform deeper (slower) analysis with more API calls", + ) + + @field_validator("wallet_address") + @classmethod + def validate_address(cls, v: str) -> str: + v = v.strip() + if not is_valid_address(v): + raise ValueError(f"Invalid address: {v}. Must be a valid Solana (base58) or EVM (0x-prefixed hex) address.") + return v + + @field_validator("chains") + @classmethod + def validate_chains(cls, v: list[str] | None) -> list[str] | None: + if v is not None: + valid = {"solana", "ethereum", "bsc", "polygon", "arbitrum", "base"} + for c in v: + if c not in valid: + raise ValueError(f"Unsupported chain: {c}. Supported: {', '.join(sorted(valid))}") + return v + + +# ── Endpoints ────────────────────────────────────────────────── + + +@router.post("/insider_network") +async def analyze_insider_network(request: Request, body: InsiderNetworkRequest) -> dict: + """ + Analyze a wallet for insider connections across token projects. + Maps shared funding sources, coordinated trading, and team relationships. + """ + wallet = body.wallet_address.strip() + + # Check cache + cache_key = f"insider_network:{wallet}:{body.deep_scan}" + r = _get_redis() + if r: + with suppress(Exception): + cached = r.get(cache_key) + if cached: + return json.loads(cached) + + # Check trial quota (via x402 middleware headers if available) + trial_used = False + x402_headers = getattr(request.state, "x402", None) or {} + quota = x402_headers.get("remaining_quota", {}) + if isinstance(quota, dict) and quota.get("insider_network", 0) > 0: + trial_used = True + logger.info( + "Trial quota used for insider_network on wallet %s", + wallet[:10], + ) + + try: + analyzer = get_insider_network_analyzer() + report = await analyzer.analyze( + wallet_address=wallet, + chains=body.chains, + deep_scan=body.deep_scan, + ) + + response = { + "success": True, + "tool": "insider_network", + "wallet_address": wallet, + "total_connected_wallets": report.total_connected_wallets, + "total_clusters": report.total_clusters, + "highest_risk_score": round(report.highest_risk_score, 1), + "clusters": [ + { + "cluster_id": c.cluster_id, + "member_count": c.member_count, + "risk_score": round(c.risk_score, 1), + "projects_involved": c.projects_involved[:5], + "risk_factors": c.risk_factors[:3], + "top_relationships": c.top_relationships[:5], + } + for c in report.clusters + ], + "summary": report.summary, + "report_text": format_insider_network_report(report), + "trial_used": trial_used, + } + + if not trial_used and r: + with suppress(Exception): + r.setex(cache_key, _CACHE_TTL, json.dumps(response)) + + return response + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.exception("Insider network analysis failed for %s", wallet[:10]) + raise HTTPException( + status_code=500, + detail=f"Analysis failed: {e!s}", + ) from e + + +@router.get("/insider_network/health") +async def insider_network_health() -> dict: + """Health check endpoint.""" + return { + "status": "ok", + "tool": "insider_network", + "version": "1.0.0", + } diff --git a/app/_archive/legacy_2026_07/x402_institutional_tools.py b/app/_archive/legacy_2026_07/x402_institutional_tools.py new file mode 100644 index 0000000..0271d0c --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_institutional_tools.py @@ -0,0 +1,686 @@ +""" +Institutional-grade x402 tools - portfolio risk, DeFi analysis, MEV detection. + +Cross-Chain Portfolio Risk - unified risk across chains +DeFi Position Analyzer - LP, impermanent loss, yield sustainability +MEV/Sandwich Detection - sandwich attacks, frontrunning, arbitrage extraction +""" + +import hashlib +import json +import logging +import os +import time +from datetime import datetime + +import aiohttp +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +logger = logging.getLogger("x402.institutional") + +router = APIRouter() + + +# ═══════════════════════════════════════════════════════════ +# Models +# ═══════════════════════════════════════════════════════════ + + +class PortfolioRequest(BaseModel): + addresses: list[str] = Field(..., min_items=1, max_items=20, description="Wallet addresses across any chains") + chains: list[str] | None = Field(default=None, description="Chain per address (auto-detect if omitted)") + + +class DeFiRequest(BaseModel): + address: str = Field(..., description="Wallet or LP position address") + chain: str = Field(default="base") + + +class MEVRequest(BaseModel): + address: str = Field(..., description="Wallet or token address to check for MEV exposure") + chain: str = Field(default="ethereum") + tx_hash: str | None = Field(default=None, description="Specific transaction to analyze") + + +# ═══════════════════════════════════════════════════════════ +# Redis helpers +# ═══════════════════════════════════════════════════════════ + + +def _redis(): + import redis as _r + + return _r.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2, + ) + + +def _cache_get(tool: str, params: dict) -> dict | None: + try: + key = f"x402:cache:{tool}:{hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()[:16]}" + data = _redis().get(key) + return json.loads(data) if data else None + except Exception: + return None + + +def _cache_set(tool: str, params: dict, result: dict, ttl: int = 60): + try: + key = f"x402:cache:{tool}:{hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()[:16]}" + _redis().setex(key, ttl, json.dumps(result)) + except Exception: + pass + + +# ═══════════════════════════════════════════════════════════ +# TOOL: Cross-Chain Portfolio Risk ($0.20) +# ═══════════════════════════════════════════════════════════ + +CHAIN_RPC_MAP = { + "ethereum": "https://eth.llamarpc.com", + "base": "https://mainnet.base.org", + "bsc": "https://bsc-dataseed.binance.org", + "polygon": "https://polygon-rpc.com", + "arbitrum": "https://arb1.arbitrum.io/rpc", + "optimism": "https://mainnet.optimism.io", + "avalanche": "https://api.avax.network/ext/bc/C/rpc", + "fantom": "https://rpc.ftm.tools", + "gnosis": "https://rpc.gnosischain.com", + "solana": "https://api.mainnet-beta.solana.com", +} + +CHAIN_NATIVE_SYMBOL = { + "ethereum": "ETH", + "base": "ETH", + "bsc": "BNB", + "polygon": "POL", + "arbitrum": "ETH", + "optimism": "ETH", + "avalanche": "AVAX", + "fantom": "FTM", + "gnosis": "xDAI", + "solana": "SOL", +} + + +async def _get_balance_evm(chain: str, address: str) -> float | None: + rpc = CHAIN_RPC_MAP.get(chain) + if not rpc: + return None + try: + async with ( + aiohttp.ClientSession() as s, + s.post( + rpc, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_getBalance", + "params": [address, "latest"], + }, + timeout=aiohttp.ClientTimeout(total=8), + ) as r, + ): + if r.status == 200: + data = await r.json() + return int(data.get("result", "0x0"), 16) / 1e18 + except Exception: + pass + return None + + +async def _get_balance_solana(address: str) -> float | None: + try: + async with aiohttp.ClientSession() as s, s.post( + "https://api.mainnet-beta.solana.com", + json={"jsonrpc": "2.0", "id": 1, "method": "getBalance", "params": [address]}, + timeout=aiohttp.ClientTimeout(total=8), + ) as r: + if r.status == 200: + data = await r.json() + return data.get("result", {}).get("value", 0) / 1e9 + except Exception: + pass + return None + + +@router.post("/portfolio_risk") +async def portfolio_risk(req: PortfolioRequest): + """Unified risk dashboard for wallets across multiple chains. + + Aggregates: balances, risk scores, label associations, MEV exposure, + and scam indicators into a single portfolio-wide risk report. + """ + try: + addresses = req.addresses[:20] + chains = (req.chains or ["auto"] * len(addresses))[: len(addresses)] + + cached = _cache_get("portfolio_risk", {"addresses": addresses, "chains": chains}) + if cached: + return cached + + wallets = [] + total_balance_usd = 0 + risk_flags = [] + sources = [] + + for i, addr in enumerate(addresses): + chain = chains[i] if i < len(chains) else "auto" + wallet = {"address": addr, "assigned_chain": chain} + + # Auto-detect chain by address format + if chain == "auto": + if addr.startswith("0x") and len(addr) == 42: + chain = "ethereum" + elif len(addr) in (43, 44) and not addr.startswith("0x"): + chain = "solana" + elif addr.startswith("T") and len(addr) == 34: + chain = "tron" + elif addr.startswith("1") or addr.startswith("3") or addr.startswith("bc1"): + chain = "bitcoin" + else: + chain = "ethereum" + wallet["detected_chain"] = chain + + # Get balance + if chain == "solana": + bal = await _get_balance_solana(addr) + if bal is not None: + wallet["balance"] = round(bal, 6) + wallet["asset"] = "SOL" + # Approximate USD (SOL ~$140) + usd = bal * 140 + wallet["balance_usd_approx"] = round(usd, 2) + total_balance_usd += usd + sources.append("solana_rpc") + elif chain in CHAIN_RPC_MAP: + bal = await _get_balance_evm(chain, addr) + if bal is not None: + wallet["balance"] = round(bal, 6) + wallet["asset"] = CHAIN_NATIVE_SYMBOL.get(chain, "ETH") + # Approximate USD based on chain + eth_price = ( + 3200 + if chain in ("ethereum", "base", "arbitrum", "optimism") + else 600 + if chain == "bsc" + else 0.4 + if chain == "polygon" + else 25 + if chain == "avalanche" + else 0.5 + if chain == "fantom" + else 1 + ) + usd = bal * eth_price + wallet["balance_usd_approx"] = round(usd, 2) + total_balance_usd += usd + sources.append(f"{chain}_rpc") + + # Risk check via reputation score + try: + async with aiohttp.ClientSession() as s, s.post( + "http://localhost:8000/api/v1/x402-tools/reputation_score", + json={"address": addr, "chain": chain}, + timeout=aiohttp.ClientTimeout(total=15), + ) as r: + if r.status == 200: + rep = await r.json() + wallet["risk_score"] = rep.get("trust_score") + wallet["risk_tier"] = rep.get("tier") + if rep.get("flags"): + risk_flags.extend([{**f, "address": addr[:12] + "..."} for f in rep["flags"]]) + sources.append("reputation_score") + except Exception: + pass + + # Label check + try: + from app.routers.x402_premium_tools import _lookup_labels_async + + labels = await _lookup_labels_async(addr) + if labels: + wallet["labels"] = [ + {"name": line.get("label_name"), "category": line.get("label_category")} for line in labels[:3] + ] + sources.append("wallet_labels") + except Exception: + pass + + wallets.append(wallet) + + # Portfolio-wide scoring + risk_scores = [w.get("risk_score", 50) for w in wallets if w.get("risk_score") is not None] + avg_risk = sum(risk_scores) / len(risk_scores) if risk_scores else 50 + + if avg_risk >= 80: + portfolio_tier = "LOW_RISK" + elif avg_risk >= 60: + portfolio_tier = "MODERATE" + elif avg_risk >= 40: + portfolio_tier = "ELEVATED" + else: + portfolio_tier = "HIGH_RISK" + + result = { + "tool": "Cross-Chain Portfolio Risk", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "wallets_analyzed": len(wallets), + "chains_covered": len({w.get("detected_chain", w.get("assigned_chain", "?")) for w in wallets}), + "total_balance_usd_approx": round(total_balance_usd, 2), + "portfolio_risk_score": round(avg_risk, 1), + "portfolio_risk_tier": portfolio_tier, + "wallets": wallets, + "risk_flags": risk_flags[:10], + "flag_count": len(risk_flags), + "sources_used": list(set(sources)), + "recommendation": ( + "Portfolio appears healthy - continue monitoring" + if portfolio_tier == "LOW_RISK" + else "Some risk factors detected - review flagged wallets" + if portfolio_tier == "MODERATE" + else "Elevated risk - several wallets have concerning signals" + if portfolio_tier == "ELEVATED" + else "HIGH RISK - multiple critical flags across portfolio. Immediate review recommended." + ), + "guarantee": "Data delivered or auto-refund via x402 receipt", + } + + _cache_set("portfolio_risk", {"addresses": addresses, "chains": chains}, result, ttl=90) + return result + + except Exception as e: + logger.error(f"Portfolio risk failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# TOOL: DeFi Position Analyzer ($0.15) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/defi_position") +async def defi_position(req: DeFiRequest): + """Analyze DeFi positions - LP holdings, impermanent loss, yield sustainability. + + Checks: liquidity provision, staking positions, lending positions, + yield farming exposure, impermanent loss estimation, and protocol risk. + """ + try: + addr = req.address.strip() + chain = req.chain or "base" + t0 = time.time() + + cached = _cache_get("defi_position", {"address": addr, "chain": chain}) + if cached: + return cached + + positions = [] + sources = [] + total_tvl = 0 + warnings = [] + + # ── DeFiLlama position lookup ── + # Note: DeFiLlama doesn't have a direct "positions by wallet" endpoint + # We check known protocols for LP/staking positions + + # ── DexScreener LP check ── + try: + async with aiohttp.ClientSession() as s: + url = f"https://api.dexscreener.com/latest/dex/search?q={addr[:12]}" + async with s.get(url, timeout=aiohttp.ClientTimeout(total=8)) as r: + if r.status == 200: + data = await r.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + for p in pairs[:5]: + liq = p.get("liquidity", {}).get("usd", 0) or 0 + if liq > 0: + pair_addr = p.get("pairAddress", "") + base = p.get("baseToken", {}) + quote = p.get("quoteToken", {}) + pos = { + "type": "liquidity_provision", + "pair": f"{base.get('symbol', '?')}/{quote.get('symbol', '?')}", + "pair_address": pair_addr, + "liquidity_usd": liq, + "dex": p.get("dexId", "unknown"), + "chain": p.get("chainId", chain), + "volume_24h": p.get("volume", {}).get("h24", 0), + "price_change_24h": p.get("priceChange", {}).get("h24", 0), + } + total_tvl += liq + + # Impermanent loss estimation + pc = abs(p.get("priceChange", {}).get("h24", 0) or 0) + if pc > 0: + # IL formula: 2*sqrt(price_ratio)/(1+price_ratio) - 1 + price_ratio = 1 + pc / 100 + il = abs(2 * (price_ratio**0.5) / (1 + price_ratio) - 1) * 100 + pos["impermanent_loss_est"] = round(il, 2) + if il > 5: + warnings.append(f"High IL risk ({il:.1f}%) on {pos['pair']}") + elif il > 2: + pos["il_warning"] = f"Moderate IL: {il:.1f}%" + + # Volume/Liquidity health + vol = pos["volume_24h"] + if liq > 0 and vol > 0: + turnover = vol / liq + pos["daily_turnover"] = round(turnover, 2) + if turnover > 1: + pos["yield_potential"] = "high" + elif turnover > 0.3: + pos["yield_potential"] = "moderate" + else: + pos["yield_potential"] = "low" + + positions.append(pos) + except Exception: + pass + + # ── DeFiLlama protocol TVL context ── + try: + async with aiohttp.ClientSession() as s: # noqa: SIM117 + async with s.get("https://api.llama.fi/protocols", timeout=aiohttp.ClientTimeout(total=8)) as r: + if r.status == 200: + data = await r.json() + sources.append("defillama") + # Find protocols the wallet might be using + dexes_in_positions = {p.get("dex", "") for p in positions} + protocol_risks = [] + for proto in data[:100]: + if proto.get("name", "").lower() in [d.lower() for d in dexes_in_positions]: + protocol_risks.append( + { + "name": proto.get("name"), + "tvl": proto.get("tvl", 0), + "category": proto.get("category", ""), + "audits": proto.get("audits", 0), + "audit_links": proto.get("audit_links", [])[:3], + } + ) + if protocol_risks: + for pr in protocol_risks: + if pr.get("audits", 0) == 0: + warnings.append(f"No audits found for {pr['name']} - protocol risk elevated") + except Exception: + pass + + # ── Compute overall assessment ── + risk_level = "low" + if len(warnings) >= 3 or any("High IL risk" in w for w in warnings): + risk_level = "high" + elif len(warnings) >= 1: + risk_level = "moderate" + + result = { + "tool": "DeFi Position Analyzer", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "address": addr, + "chain": chain, + "positions_found": len(positions), + "total_tvl_usd": round(total_tvl, 2), + "positions": positions, + "warnings": warnings if warnings else None, + "overall_risk": risk_level, + "recommendation": ( + "Positions appear healthy - standard monitoring sufficient" + if risk_level == "low" + else "Some risk factors - review impermanent loss exposure and protocol audits" + if risk_level == "moderate" + else "HIGH RISK - significant IL exposure and/or unaudited protocols. Consider reducing position sizes." + ), + "sources_used": sources, + "performance_ms": round((time.time() - t0) * 1000, 1), + "guarantee": "Data delivered or auto-refund via x402 receipt", + } + + _cache_set("defi_position", {"address": addr, "chain": chain}, result) + return result + + except Exception as e: + logger.error(f"DeFi position failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# TOOL: MEV / Sandwich Attack Detection ($0.15) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/mev_detect") +async def mev_detect(req: MEVRequest): + """Detect MEV exploitation - sandwich attacks, frontrunning, backrunning. + + Analyzes transaction history for MEV patterns: + - Sandwich attacks (buy before, sell after) + - Frontrunning (same-block order manipulation) + - Arbitrage extraction + - MEV bot wallet identification + """ + try: + addr = req.address.strip() + chain = req.chain or "ethereum" + tx_hash = req.tx_hash + t0 = time.time() + + cached = _cache_get("mev_detect", {"address": addr, "chain": chain, "tx_hash": tx_hash}) + if cached: + return cached + + sources = [] + mev_attacks = [] + mev_exposure_usd = 0 + risk_signals = [] + + # ── EigenPhi MEV detection ── + if chain in ("ethereum", "base", "bsc", "polygon", "arbitrum"): + try: + async with aiohttp.ClientSession() as s: + url = f"https://eigenphi.io/api/v1/mev/address/{addr}?chain={chain}" + async with s.get(url, timeout=aiohttp.ClientTimeout(total=10)) as r: + if r.status == 200: + data = await r.json() + if data.get("mev_attacks"): + sources.append("eigenphi") + for attack in data["mev_attacks"][:10]: + attack_type = attack.get("type", "unknown") + mev_attacks.append( + { + "type": attack_type, + "tx_hash": attack.get("tx_hash", "")[:16] + "...", + "block": attack.get("block_number"), + "profit_usd": attack.get("profit_usd", 0), + "attacker": attack.get("attacker", "")[:12] + "...", + "timestamp": attack.get("timestamp"), + } + ) + mev_exposure_usd += attack.get("profit_usd", 0) + except Exception: + pass + + # ── Etherscan tx analysis for sandwich patterns ── + if chain in ( + "ethereum", + "base", + "bsc", + "polygon", + "arbitrum", + "optimism", + "avalanche", + "fantom", + "gnosis", + ): + try: + api_key = os.getenv("ETHERSCAN_API_KEY", "") + if api_key: + chain_id_map = { + "ethereum": 1, + "base": 8453, + "bsc": 56, + "polygon": 137, + "arbitrum": 42161, + "optimism": 10, + "avalanche": 43114, + "fantom": 250, + "gnosis": 100, + } + cid = chain_id_map.get(chain, 1) + + async with aiohttp.ClientSession() as s: + if tx_hash: + url = f"https://api.etherscan.io/v2/api?chainid={cid}&module=account&action=txlistinternal&txhash={tx_hash}&apikey={api_key}" + else: + url = f"https://api.etherscan.io/v2/api?chainid={cid}&module=account&action=txlist&address={addr}&page=1&offset=10&sort=desc&apikey={api_key}" + + async with s.get(url, timeout=aiohttp.ClientTimeout(total=10)) as r: + if r.status == 200: + data = await r.json() + txs = data.get("result", []) + if txs and isinstance(txs, list): + sources.append("etherscan") + + # Group by block to detect same-block patterns + from collections import defaultdict + + block_txs = defaultdict(list) + for tx in txs[:50]: + block = tx.get("blockNumber") + if block: + block_txs[block].append(tx) + + for block, bt in block_txs.items(): + if len(bt) >= 3: + # Multiple txs in same block - possible sandwich + buys = [ + t + for t in bt + if t.get("from", "").lower() != addr.lower() + and t.get("to", "").lower() == addr.lower() + ] + sells = [ + t + for t in bt + if t.get("from", "").lower() == addr.lower() + and t.get("to", "").lower() != addr.lower() + ] + if buys and sells: + risk_signals.append( + { + "block": block, + "pattern": "possible_sandwich", + "buys_in_block": len(buys), + "sells_in_block": len(sells), + "detail": f"Block {block} has {len(buys)} incoming + {len(sells)} outgoing - possible sandwich attack", + } + ) + except Exception: + pass + + # ── MEV bot wallet check via labels ── + try: + from app.routers.x402_premium_tools import _lookup_labels_async + + labels = await _lookup_labels_async(addr) + if labels: + sources.append("wallet_labels") + mev_labels = [ + line + for line in labels + if any( + kw in (line.get("label_name", "") + line.get("label_category", "")).lower() + for kw in ("mev", "sandwich", "frontrun", "arbitrage", "bot") + ) + ] + if mev_labels: + risk_signals.append( + { + "pattern": "mev_bot_wallet", + "detail": f"Address labeled as MEV-related: {mev_labels[0].get('label_name')}", + "source": mev_labels[0].get("source"), + } + ) + except Exception: + pass + + # ── DexScreener for sudden price impact check ── + try: + async with aiohttp.ClientSession() as s: + url = f"https://api.dexscreener.com/latest/dex/search?q={addr[:12]}" + async with s.get(url, timeout=aiohttp.ClientTimeout(total=8)) as r: + if r.status == 200: + data = await r.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + for p in pairs[:3]: + pc = p.get("priceChange", {}) + if abs(pc.get("h24", 0) or 0) > 30: + risk_signals.append( + { + "pattern": "extreme_volatility", + "pair": f"{p.get('baseToken', {}).get('symbol', '?')}/{p.get('quoteToken', {}).get('symbol', '?')}", + "price_change_24h": pc.get("h24"), + "detail": f"Extreme {pc.get('h24')}% price movement - possible MEV extraction impact", + } + ) + except Exception: + pass + + # ── Final assessment ── + total_signals = len(mev_attacks) + len(risk_signals) + if total_signals >= 5 or mev_exposure_usd > 10000: + risk_level = "critical" + recommendation = "CRITICAL - significant MEV exploitation detected. Immediate review of trading strategy and RPC endpoint required." + elif total_signals >= 2 or mev_exposure_usd > 1000: + risk_level = "high" + recommendation = "High MEV exposure - consider using Flashbots/MEV protection RPC" + elif total_signals >= 1: + risk_level = "moderate" + recommendation = "Some MEV activity detected - monitor and consider MEV-protected transactions" + else: + risk_level = "low" + recommendation = "No significant MEV exploitation detected" + + result = { + "tool": "MEV / Sandwich Attack Detection", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "address": addr, + "chain": chain, + "mev_attacks_detected": len(mev_attacks), + "mev_attacks": mev_attacks[:10], + "mev_exposure_usd": round(mev_exposure_usd, 2), + "risk_signals": risk_signals[:10], + "total_signals": total_signals, + "risk_level": risk_level, + "recommendation": recommendation, + "sources_used": sources, + "protection_suggestions": [ + "Use Flashbots RPC for Ethereum transactions", + "Enable MEV protection in your wallet settings", + "Use private mempool submission for large trades", + "Consider DEX aggregators with built-in MEV protection (1inch, CowSwap)", + ] + if risk_level in ("high", "critical") + else None, + "performance_ms": round((time.time() - t0) * 1000, 1), + "guarantee": "Data delivered or auto-refund via x402 receipt", + } + + _cache_set("mev_detect", {"address": addr, "chain": chain, "tx_hash": tx_hash}, result) + return result + + except Exception as e: + logger.error(f"MEV detect failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/app/_archive/legacy_2026_07/x402_launch_fairness.py b/app/_archive/legacy_2026_07/x402_launch_fairness.py new file mode 100644 index 0000000..f0a9f33 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_launch_fairness.py @@ -0,0 +1,225 @@ +""" +x402 Router: launch_fairness +============================== +Wraps Launch Fairness Analyzer with: + - Address validation + - x402 payment middleware integration + - Caching (Redis if available) + - Trial quota tracking + - Rate limiting support + +TOOL : launch_fairness +TIER : premium +PRICE : $0.10 (100000 atoms) +TRIAL : 2 free checks +ROUTER: /api/v1/x402-tools/launch_fairness +""" + +import json +import logging +import os +import time +from contextlib import suppress +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field, field_validator + +from app.launch_fairness_analyzer import analyze_launch_fairness + +logger = logging.getLogger("x402_launch_fairness") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402-tools"]) + +# ── Redis cache (best-effort) ─────────────────────────────────── +_redis = None +try: + import redis.asyncio as aioredis + + _redis = aioredis.from_url( + os.getenv("REDIS_URL", "redis://localhost:6379/0"), + decode_responses=True, + socket_connect_timeout=2, + ) +except Exception: + logger.debug("Redis not available for launch_fairness cache") + + +async def _get_cache(key: str) -> dict[str, Any] | None: + """Get cached result if Redis is available.""" + if _redis is None: + return None + with suppress(Exception): + data = await _redis.get(f"x402:cache:launch_fairness:{key}") + if data: + result: dict[str, Any] = json.loads(data) + return result + return None + + +async def _set_cache(key: str, result: dict[str, Any], ttl: int = 300) -> None: + """Cache result in Redis with TTL.""" + if _redis is None: + return + with suppress(Exception): + await _redis.setex( + f"x402:cache:launch_fairness:{key}", ttl, json.dumps(result, default=str) + ) + + +# ── Trial tracking ────────────────────────────────────────────── + + +def _trial_key(wallet: str) -> str: + return f"x402:launch_fairness_trials:{wallet}" + + +async def _check_trials(wallet: str) -> int: + """Check how many trials this wallet has used.""" + if _redis is None: + return 0 + try: + used = await _redis.get(_trial_key(wallet)) + return int(used) if used else 0 + except Exception: + return 0 + + +async def _increment_trials(wallet: str) -> None: + """Increment trial usage for a wallet.""" + if _redis is None: + return + try: + await _redis.incr(_trial_key(wallet)) + await _redis.expire(_trial_key(wallet), 86400 * 30) # Reset monthly + except Exception: + pass + + +# ── Request / Response models ─────────────────────────────────── + + +class LaunchFairnessRequest(BaseModel): + """Request model for launch fairness analysis.""" + + address: str = Field(..., description="Token contract address to analyze") + chain: str = Field( + "auto", + description="Blockchain (ethereum, solana, bsc, base, polygon, or 'auto' for detection)", + ) + simulate_data: bool = Field(True, description="Use simulated data for demonstration / testing") + + @field_validator("address") + @classmethod + def validate_address(cls, v: str) -> str: + v = v.strip() + is_evm = v.startswith("0x") and len(v) == 42 + is_solana = not v.startswith("0x") and 32 <= len(v) <= 44 and v.isascii() + if not is_evm and not is_solana: + raise ValueError( + "Address must be a valid token contract address (0x... for EVM, base58 for Solana)" + ) + return v.lower() + + @field_validator("chain") + @classmethod + def validate_chain(cls, v: str) -> str: + valid_chains = { + "auto", + "ethereum", + "solana", + "bsc", + "base", + "polygon", + "arbitrum", + "avalanche", + } + v = v.lower().strip() + if v not in valid_chains: + raise ValueError(f"Chain must be one of: {', '.join(sorted(valid_chains))}") + return v + + +class LaunchFairnessResponse(BaseModel): + """Response model for launch fairness analysis.""" + + success: bool = True + tool: str = "launch_fairness" + data: dict[str, Any] = Field(default_factory=dict) + cached: bool = False + scanned_at: str = "" + + +# ── Endpoint ──────────────────────────────────────────────────── + + +@router.post("/launch_fairness") +async def analyze_launch_fairness_endpoint( + request: Request, + body: LaunchFairnessRequest, +) -> LaunchFairnessResponse: + """ + Analyze token launch fairness - detect sniped distributions, bundled launches, + bot activity, LP manipulation, and presale concentration. + + Returns a fairness score (0-100) with per-signal breakdown and evidence. + """ + start = time.time() + wallet = request.headers.get("x-wallet-address", "anonymous") + + # ── Trial check ── + max_trial = 2 + trials_used = await _check_trials(wallet) + if trials_used < max_trial: + await _increment_trials(wallet) + + # ── Cache check ── + cache_key = f"{body.address}:{body.chain}:{body.simulate_data}" + cached = await _get_cache(cache_key) + if cached: + elapsed = time.time() - start + logger.info(f"Launch fairness cache hit for {body.address[:12]}... ({elapsed:.2f}s)") + return LaunchFairnessResponse( + data=cached, + cached=True, + scanned_at=cached.get("scanned_at", ""), + ) + + # ── Run analysis ── + try: + result = await analyze_launch_fairness( + token_address=body.address, + chain=body.chain, + simulate_data=body.simulate_data, + ) + + # Add metadata + result["scanned_at"] = datetime.now(tz=UTC).isoformat() + result["tier"] = "premium" + result["price_usd"] = 0.10 + + # ── Cache result (short TTL - fairness data changes quickly) ── + await _set_cache(cache_key, result, ttl=120) + + logger.info( + f"Launch fairness analyzed {body.address[:12]}... " + f"score={result.get('fairness_score', '?')}% " + f"({time.time() - start:.2f}s)" + ) + + return LaunchFairnessResponse( + data=result, + cached=False, + scanned_at=result["scanned_at"], + ) + + except ValueError as e: + logger.warning(f"Launch fairness validation error: {e}") + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.error(f"Launch fairness analysis failed: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail="Launch fairness analysis failed. Please try again.", + ) from e diff --git a/app/_archive/legacy_2026_07/x402_premium_tools.py b/app/_archive/legacy_2026_07/x402_premium_tools.py new file mode 100644 index 0000000..e351632 --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_premium_tools.py @@ -0,0 +1,609 @@ +""" +Premium x402 tools - reputation scoring, investigation reports, webhook alerts. + +These are the standout tools that make the RMI MCP server worth paying for: +- reputation_score: Single 0-100 trust score combining all enrichment signals +- investigation_report: AI-generated human-readable dossier +- webhook_register: Register callbacks for monitoring alerts +""" + +import json +import logging +import os +import time +from datetime import datetime + +import aiohttp +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +logger = logging.getLogger("x402.premium") + +router = APIRouter() + +# ═══════════════════════════════════════════════════════════ +# Request Models +# ═══════════════════════════════════════════════════════════ + + +class AddressRequest(BaseModel): + address: str = Field(..., description="Wallet address, token contract, or ENS name") + chain: str = Field(default="base", description="Blockchain: base, solana, ethereum, bsc, etc.") + + +class WebhookRegisterRequest(BaseModel): + url: str = Field(..., description="Webhook callback URL") + events: list[str] = Field(default=["rug_pull", "whale_move", "price_crash"], description="Events to monitor") + address: str | None = Field(default=None, description="Specific address to watch (optional)") + chain: str = Field(default="all", description="Chain filter") + + +class InvestigationRequest(BaseModel): + address: str = Field(..., description="Target address, token, or wallet") + chain: str = Field(default="base") + depth: str = Field(default="standard", description="standard | deep | forensic") + + +# ═══════════════════════════════════════════════════════════ +# TOOL: Reputation Score ($0.10) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/reputation_score") +async def reputation_score(req: AddressRequest): + """Compute a comprehensive 0-100 trust score for any blockchain address. + + Combines: wallet labels, scam database hits, deployer history, + RAG similarity matching, transaction patterns, and exchange associations. + + Score interpretation: + 90-100: Trusted (verified exchange, known entity) + 70-89: Low risk (established wallet, clean history) + 50-69: Moderate risk (some flags, requires attention) + 30-49: High risk (multiple red flags, suspected scam) + 0-29: Critical risk (confirmed scam, sanctioned, known rugger) + """ + try: + addr = req.address.strip() + chain = req.chain or "base" + + score = 100 # Start at perfect trust, subtract for risk factors + flags = [] + positives = [] + sources = [] + + # ── 1. Wallet Labels (ClickHouse) ── + labels = await _lookup_labels_async(addr) + if labels: + sources.append("wallet_labels") + for lbl in labels: + cat = lbl.get("label_category", "") + if cat == "sanctioned": + score -= 100 + flags.append( + { + "severity": "critical", + "detail": f"OFAC-sanctioned: {lbl.get('label_name')}", + } + ) + elif cat in ("phish-hack", "scam", "etherscan-phish-hack-list"): + score -= 60 + flags.append( + { + "severity": "high", + "detail": f"Known scam: {lbl.get('label_name')} (source: {lbl.get('source')})", + } + ) + elif cat == "exploit": + score -= 50 + flags.append( + { + "severity": "high", + "detail": f"Exploit-associated: {lbl.get('label_name')}", + } + ) + elif cat == "heist": + score -= 70 + flags.append( + { + "severity": "critical", + "detail": f"Heist-associated: {lbl.get('label_name')}", + } + ) + elif cat == "cex": + score += 10 + positives.append(f"Exchange wallet: {lbl.get('label_name')}") + elif cat == "dex": + score += 5 + positives.append(f"DEX contract: {lbl.get('label_name')}") + + # ── 2. RAG Similarity Search ── + try: + from app.routers.x402_enrichment import search_similar_patterns + + patterns, _ = search_similar_patterns([addr]) + if patterns: + sources.append("rag_similarity") + for p in patterns: + matches = p.get("matches", []) + for m in matches: + sim_score = m.get("score", 0) + if sim_score > 0.8: + score -= 40 + flags.append( + { + "severity": "high", + "detail": f"Strong scam pattern match: {m.get('type')} ({sim_score:.0%})", + } + ) + elif sim_score > 0.6: + score -= 20 + flags.append( + { + "severity": "medium", + "detail": f"Possible scam pattern: {m.get('type')} ({sim_score:.0%})", + } + ) + except Exception: + pass + + # ── 3. Deployer History (DexScreener) ── + try: + async with aiohttp.ClientSession() as session: + url = f"https://api.dexscreener.com/latest/dex/search?q={addr[:12]}" + async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp: + if resp.status == 200: + data = await resp.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + # Age check + oldest = min(pairs, key=lambda p: p.get("pairCreatedAt", 0)) + age_h = ( + (time.time() - oldest.get("pairCreatedAt", 0) / 1000) / 3600 + if oldest.get("pairCreatedAt") + else 0 + ) + if age_h < 1: + score -= 15 + flags.append( + { + "severity": "medium", + "detail": f"Brand new: oldest pair only {age_h:.1f}h old", + } + ) + elif age_h > 720: + score += 10 + positives.append(f"Established: oldest pair {age_h / 24:.0f}d old") + + # Volume/liquidity check + total_liq = sum(p.get("liquidity", {}).get("usd", 0) for p in pairs) + if total_liq > 0 and total_liq < 1000: + score -= 10 + flags.append( + { + "severity": "low", + "detail": f"Low liquidity: ${total_liq:,.0f}", + } + ) + except Exception: + pass + + # ── 4. Scam Pattern Detection ── + try: + import asyncio + + from app.rag_service import detect_scam_patterns + + result = asyncio.run(detect_scam_patterns({"address": addr, "chain": chain}, 0.5)) + if result and result.get("risk_score", 0) > 0: + sources.append("scam_detector") + risk = result.get("risk_score", 0) + score -= min(risk, 50) + patterns = result.get("patterns", []) + if patterns: + flags.append( + { + "severity": "high" if risk > 30 else "medium", + "detail": f"Scam patterns: {', '.join(patterns[:3])}", + } + ) + except Exception: + pass + + # ── 5. Recent Intel Context ── + try: + from app.routers.x402_enrichment import _load_recent_intel + + intel = _load_recent_intel() + if intel: + sources.append("rmi_intel") + positives.append( + f"RMI Intel active: {intel.get('tokens_scanned', 0):,} tokens scanned, {intel.get('scanner_alerts', 0)} alerts" + ) + except Exception: + pass + + # Clamp score + score = max(0, min(100, score)) + + # If no sources had data, provide a baseline + if not sources: + score = 50 # Neutral - insufficient data + flags.append( + { + "severity": "info", + "detail": "Limited data available for this address. Score is neutral baseline.", + } + ) + sources.append("baseline") + + # Determine tier + if score >= 90: + tier = "TRUSTED" + elif score >= 70: + tier = "LOW_RISK" + elif score >= 50: + tier = "MODERATE_RISK" + elif score >= 30: + tier = "HIGH_RISK" + else: + tier = "CRITICAL_RISK" + + return { + "tool": "Reputation Score", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "address": addr, + "chain": chain, + "trust_score": score, + "tier": tier, + "flags": flags, + "positive_signals": positives, + "flag_count": len(flags), + "sources_used": sources, + "interpretation": { + "90-100": "Trusted - verified exchange or known entity", + "70-89": "Low risk - established wallet, clean history", + "50-69": "Moderate risk - some flags, exercise caution", + "30-49": "High risk - multiple red flags, suspected scam", + "0-29": "Critical risk - confirmed scam, sanctioned, known rugger", + }, + "guarantee": "Data delivered or auto-refund via x402 receipt", + } + except Exception as e: + logger.error(f"Reputation score failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# TOOL: Webhook Register ($0.02 setup + webhook delivery) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/webhook_register") +async def webhook_register(req: WebhookRegisterRequest): + """Register a webhook URL for real-time monitoring alerts. + + Available events: rug_pull, whale_move, price_crash, new_launch, + liquidity_removed, ownership_renounced, scam_detected. + + Webhooks fire within 30 seconds of detection. Max 3 webhooks per address. + Data delivered as JSON POST to your URL. + """ + try: + valid_events = { + "rug_pull", + "whale_move", + "price_crash", + "new_launch", + "liquidity_removed", + "ownership_renounced", + "scam_detected", + } + events = [e for e in req.events if e in valid_events] + + if not events: + raise HTTPException( + status_code=400, + detail=f"No valid events. Choose from: {', '.join(sorted(valid_events))}", + ) + + # Store in Redis + import redis as _redis + + r = _redis.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_connect_timeout=3, + ) + + webhook_id = f"wh_{int(time.time())}_{req.address[:10] if req.address else 'global'}" + webhook_data = { + "id": webhook_id, + "url": req.url, + "events": events, + "address": req.address, + "chain": req.chain, + "created": datetime.utcnow().isoformat(), + "active": True, + } + r.setex(f"x402:webhook:{webhook_id}", 30 * 86400, json.dumps(webhook_data)) + r.sadd(f"x402:webhook:events:{req.address or 'global'}", webhook_id) + + return { + "tool": "Webhook Register", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "webhook_id": webhook_id, + "url": req.url, + "events": events, + "expires_in": "30 days", + "webhook_format": { + "event": "e.g. rug_pull", + "address": "0x...", + "chain": "base", + "data": "{tool-specific payload}", + "timestamp": "ISO 8601", + }, + "guarantee": "Webhook delivery guaranteed or payment refunded", + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Webhook register failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.get("/webhook_list") +async def webhook_list(address: str | None = None): + """List registered webhooks for an address or globally.""" + try: + import redis as _redis + + r = _redis.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_connect_timeout=3, + ) + + key = f"x402:webhook:events:{address or 'global'}" + ids = r.smembers(key) + webhooks = [] + for wid in ids: + data = r.get(f"x402:webhook:{wid}") + if data: + webhooks.append(json.loads(data)) + + return { + "address": address or "global", + "webhooks": webhooks, + "count": len(webhooks), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# TOOL: Investigation Report ($0.25) +# ═══════════════════════════════════════════════════════════ + + +@router.post("/investigation_report") +async def investigation_report(req: InvestigationRequest): + """Generate a comprehensive AI-powered investigation report. + + Combines: reputation scoring, wallet labeling, scam detection, + on-chain forensics, social signal analysis, and market data + into a single human-readable report with risk assessment. + + Report sections: Executive Summary, Risk Score, Wallet Profile, + Transaction Analysis, Known Associations, Scam Indicators, + Market Context, Recommendation. + """ + try: + addr = req.address.strip() + chain = req.chain or "base" + depth = req.depth or "standard" + + sections = {} + sources = [] + + # ── Section 1: Reputation ── + try: + async with ( + aiohttp.ClientSession() as session, + session.post( + "http://localhost:8000/api/v1/x402-tools/reputation_score", + json={"address": addr, "chain": chain}, + timeout=aiohttp.ClientTimeout(total=15), + ) as resp, + ): + if resp.status == 200: + rep = await resp.json() + sections["reputation"] = { + "score": rep.get("trust_score"), + "tier": rep.get("tier"), + "flags": rep.get("flags", [])[:5], + "positive_signals": rep.get("positive_signals", [])[:3], + } + sources.extend(rep.get("sources_used", [])) + except Exception: + sections["reputation"] = {"error": "Reputation service unavailable"} + + # ── Section 2: Wallet Labels ── + labels = await _lookup_labels_async(addr) + if labels: + sources.append("wallet_labels") + sections["wallet_profile"] = { + "labels_found": len(labels), + "categories": list({line.get("label_category") for line in labels}), + "top_labels": [ + { + "name": line.get("label_name"), + "category": line.get("label_category"), + "source": line.get("source"), + } + for line in labels[:5] + ], + "sanctioned": any(line.get("label_category") == "sanctioned" for line in labels), + } + else: + sections["wallet_profile"] = {"labels_found": 0, "note": "No known labels"} + + # ── Section 3: On-Chain Forensics ── + try: + async with ( + aiohttp.ClientSession() as session, + session.post( + "http://localhost:8000/api/v1/x402-tools/forensics", + json={"address": addr, "chain": chain}, + timeout=aiohttp.ClientTimeout(total=20), + ) as resp, + ): + if resp.status == 200: + forensics = await resp.json() + sections["on_chain"] = { + "risk_score": forensics.get("overall_risk_score"), + "risk_level": forensics.get("overall_risk"), + "risk_factors": forensics.get("risk_factors", [])[:5], + "sources": forensics.get("sources_used", []), + } + sources.extend(forensics.get("sources_used", [])) + except Exception: + sections["on_chain"] = {"error": "Forensics unavailable - continuing with other sources"} + + # ── Section 4: Market Context ── + try: + async with aiohttp.ClientSession() as session: + url = f"https://api.dexscreener.com/latest/dex/tokens/{addr}" + async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp: + if resp.status == 200: + data = await resp.json() + pairs = data.get("pairs", []) + if pairs: + sources.append("dexscreener") + p = pairs[0] + sections["market_context"] = { + "price_usd": p.get("priceUsd"), + "liquidity_usd": p.get("liquidity", {}).get("usd"), + "volume_24h": p.get("volume", {}).get("h24"), + "price_change_24h": p.get("priceChange", {}).get("h24"), + "age_hours": (time.time() - p.get("pairCreatedAt", 0) / 1000) / 3600 + if p.get("pairCreatedAt") + else None, + } + except Exception: + sections["market_context"] = {"error": "Market data unavailable"} + + # ── Section 5: Intel Context ── + try: + from app.routers.x402_enrichment import _load_recent_intel + + intel = _load_recent_intel() + if intel: + sections["intel_context"] = { + "briefing": intel.get("briefing", "")[:300], + "scanner_alerts": intel.get("scanner_alerts", 0), + "age_hours": intel.get("age_hours", 0), + } + sources.append("rmi_cron_intel") + except Exception: + pass + + # ── Generate recommendation ── + rep_score = sections.get("reputation", {}).get("score", 50) + risk_score = sections.get("on_chain", {}).get("risk_score", 50) + + if rep_score >= 80 and risk_score < 30: + verdict = "APPROVED" + recommendation = "Low risk. Standard due diligence recommended." + elif rep_score >= 60 and risk_score < 50: + verdict = "CAUTION" + recommendation = "Moderate risk. Verify contract ownership and liquidity locks before interacting." + elif rep_score >= 40 or risk_score < 70: + verdict = "HIGH_RISK" + recommendation = "High risk. Multiple red flags detected. Exercise extreme caution or avoid." + else: + verdict = "AVOID" + recommendation = "Critical risk. Confirmed scam indicators. Do not interact with this address." + + return { + "tool": "Investigation Report", + "version": "1.0", + "timestamp": datetime.utcnow().isoformat(), + "address": addr, + "chain": chain, + "depth": depth, + "verdict": verdict, + "recommendation": recommendation, + "sections": sections, + "sources_used": list(set(sources)), + "source_count": len(set(sources)), + "price_usd": "0.25", + "guarantee": "Comprehensive report or full refund", + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Investigation report failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + + +# ═══════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════ + + +async def _lookup_labels_async(address: str) -> list[dict]: + """Async wrapper for wallet label lookups.""" + try: + import redis as _redis + + r = _redis.Redis( + host=os.getenv("REDIS_HOST", "rmi-redis"), + port=int(os.getenv("REDIS_PORT", "6379")), + password=os.getenv("REDIS_PASSWORD", ""), + decode_responses=True, + socket_connect_timeout=2, + ) + # Try ClickHouse first + try: + from clickhouse_driver import Client + + ch = Client( + host=os.getenv("CH_HOST", "rmi-clickhouse"), + port=int(os.getenv("CH_PORT", "9000")), + user=os.getenv("CH_USER", "default"), + password=os.getenv("CH_PASSWORD", "") or None, + settings={"max_execution_time": 3}, + ) + rows = ch.execute( + "SELECT address, label_name, label_category, label_subtype, source, is_sanctioned " + "FROM wallet_memory.wallet_labels WHERE address = %(addr)s " + "ORDER BY loaded_at DESC LIMIT 20", + {"addr": address}, + ) + return [ + { + "label_name": r[1], + "label_category": r[2], + "label_subtype": r[3], + "source": r[4], + "is_sanctioned": bool(r[5]), + } + for r in rows + ] + except Exception: + # Fall back to Redis cache + cached = r.get(f"x402:enrich:{address}") + if cached: + data = json.loads(cached) + return data.get("labels", []) + return [] + except Exception: + return [] diff --git a/app/_archive/legacy_2026_07/x402_settle_api.py b/app/_archive/legacy_2026_07/x402_settle_api.py new file mode 100644 index 0000000..2bb8d5d --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_settle_api.py @@ -0,0 +1,225 @@ +""" +x402_settle_api.py - x402 micropayment settlement + +Settles the EIP-3009 USDC transfer authorization returned by the client +and activates the corresponding entitlement (subscription tier or add-on). +""" + +from __future__ import annotations + +import json +import logging +import os +import secrets +from datetime import datetime, timedelta + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/subscription", tags=["x402-settle"]) + +# USDC on Base +USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +PAY_TO = os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9") +BASE_RPC = os.getenv("BASE_RPC_URL", "https://mainnet.base.org") + + +# ── Storage shims ──────────────────────────────────────────── +try: + import redis as _redis + + _r = _redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=0, + decode_responses=True, + socket_timeout=2, + ) + _r.ping() + REDIS_OK = True +except Exception: + REDIS_OK = False + logger.warning("x402_settle: redis unavailable, using in-memory store") + +_pending: dict[str, dict] = {} +_subscriptions: dict[str, dict] = {} + + +def _save_pending(challenge_id: str, data: dict) -> None: + if REDIS_OK: + _r.setex(f"rmi:x402:pending:{challenge_id}", 600, json.dumps(data)) + _pending[challenge_id] = data + + +def _load_pending(challenge_id: str) -> dict | None: + if REDIS_OK: + raw = _r.get(f"rmi:x402:pending:{challenge_id}") + if raw: + return json.loads(raw) + return _pending.get(challenge_id) + + +def _delete_pending(challenge_id: str) -> None: + if REDIS_OK: + _r.delete(f"rmi:x402:pending:{challenge_id}") + _pending.pop(challenge_id, None) + + +def _activate_subscription(user_id: str, tier: str, period: str, addons: list[str]) -> dict: + """Persist subscription entitlements (Redis) and return them.""" + sub_id = secrets.token_hex(16) + now = datetime.utcnow() + days_map = {"monthly": 30, "six_month": 180, "yearly": 365} + end = now + timedelta(days=days_map.get(period, 30)) + sub = { + "id": sub_id, + "user_id": user_id, + "tier": tier, + "period": period, + "addons": addons, + "status": "active", + "current_period_start": now.isoformat() + "Z", + "current_period_end": end.isoformat() + "Z", + "created_at": now.isoformat() + "Z", + } + if REDIS_OK: + _r.setex(f"rmi:sub:{user_id}", int((end - now).total_seconds()), json.dumps(sub)) + _subscriptions[sub_id] = sub + return sub + + +# ── Models ──────────────────────────────────────────────────── + + +class SettleRequest(BaseModel): + challenge_id: str = None + addon_id: str = None + tier: str = None + addon: str = None + period: str = "monthly" + x_pay: str + payer: str + amount_atoms: str = None + + +# ── Endpoints ───────────────────────────────────────────────── + + +@router.post("/settle-x402") +async def settle_x402(req: SettleRequest, request: Request): + """Settle an x402 EIP-3009 USDC payment and activate entitlements. + + Verifies: + - The challenge exists and hasn't been settled + - The x_pay header decodes to a valid EIP-3009 authorization + - The 'from' address matches the requesting user (best-effort) + - The amount + payTo + asset match the challenge + + Then activates the subscription / add-on for the user. + """ + from app.auth import get_current_user + + try: + user = await get_current_user(request) + except Exception: + user = None + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + + challenge_id = req.challenge_id or req.addon_id + if not challenge_id: + raise HTTPException(status_code=400, detail="challenge_id or addon_id required") + + pending = _load_pending(challenge_id) + if not pending: + # The challenge might not have been created via /addon (e.g. ad-hoc x402) + # Fall back to optimistic activation with the provided params + logger.info(f"x402 settle: no pending challenge for {challenge_id}, optimistically activating") + pending = { + "tier": req.tier or "PRO", + "addon": req.addon or "PORTFOLIO_PRO", + "period": req.period, + "amount_atoms": req.amount_atoms or "3000000", + "pay_to": PAY_TO, + } + + # Decode x_pay header (base64 of x402 payload) + try: + import base64 + + raw = req.x_pay + if raw.startswith("X-PAY "): + raw = raw[6:] + payload = json.loads(base64.b64decode(raw)) + except Exception: + try: + payload = json.loads(req.x_pay) # maybe already JSON + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid x_pay: {e}") from e + + auth = (payload.get("payload") or {}).get("authorization") or {} + sig = (payload.get("payload") or {}).get("signature") + if not auth or not sig: + raise HTTPException(status_code=400, detail="Missing authorization or signature in x_pay") + + # Validate critical fields + if auth.get("to", "").lower() != PAY_TO.lower(): + raise HTTPException(status_code=400, detail="payTo mismatch - wrong recipient") + if str(auth.get("value", "")) != str(pending.get("amount_atoms")): + raise HTTPException(status_code=400, detail="amount mismatch") + if req.payer and auth.get("from", "").lower() != req.payer.lower(): + raise HTTPException(status_code=400, detail="payer mismatch - signature not from expected wallet") + + # Optional: verify the signature on-chain via eth_call + # Skipped in dev to keep the endpoint fast. Production should call + # ecrecover + the USDC contract's authorizationState() mapping. + + # Activate entitlements + tier = pending.get("tier", "PRO") + addon = pending.get("addon") + period = pending.get("period", "monthly") + addons_list = [addon] if addon else [] + + sub = _activate_subscription(user["id"], tier, period, addons_list) + _delete_pending(challenge_id) + + # Also write the user_metadata flag so /api/v1/portfolio/features picks it up + try: + from app.auth import _save_user + + md = (user.get("user_metadata") or {}).copy() + if addon == "PORTFOLIO_PRO": + md["has_portfolio_pro"] = True + _save_user({**user, "user_metadata": md}) + except Exception as e: + logger.warning(f"x402 settle: failed to update user_metadata: {e}") + + return { + "success": True, + "subscription": sub, + "activated_addons": addons_list, + "message": f"Welcome to {'Portfolio Pro' if addon == 'PORTFOLIO_PRO' else tier}!", + } + + +@router.get("/x402-challenge/{challenge_id}") +async def get_x402_challenge(challenge_id: str): + """Read a pending x402 challenge (for debugging / status display).""" + pending = _load_pending(challenge_id) + if not pending: + raise HTTPException(status_code=404, detail="Challenge not found or already settled") + return pending + + +@router.get("/x402-health") +async def x402_health(): + return { + "status": "ok", + "pay_to": PAY_TO, + "usdc_asset": USDC_BASE, + "network": "eip155:8453 (Base)", + "redis": REDIS_OK, + "pending_challenges": len(_pending), + } diff --git a/app/_archive/legacy_2026_07/x402_token_watch.py b/app/_archive/legacy_2026_07/x402_token_watch.py new file mode 100644 index 0000000..5c3551d --- /dev/null +++ b/app/_archive/legacy_2026_07/x402_token_watch.py @@ -0,0 +1,235 @@ +""" +x402 Token Watch / LP Monitor Endpoints +========================================= +Paid feature: Set monitoring conditions on tokens, receive alerts when triggered. +Prices: token_watch_create=$0.05, alert delivery free. +""" + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +logger = logging.getLogger("x402_token_watch") + +router = APIRouter(prefix="/api/v1/x402-tools", tags=["x402 Token Watch"]) + + +# ─── Request Models ─────────────────────────────────────────────────── + + +class WatchCreateRequest(BaseModel): + token_address: str = Field(..., description="Token contract address") + chain: str = Field(..., description="Blockchain (ethereum, bsc, solana, base, etc.)") + condition: str = Field( + ..., + description="Watch condition: lp_drop_below, lp_unlocked, price_drop, price_change, rug_indicators", + ) + threshold: float = Field( + ..., + description="Threshold value (e.g. 50000 for $50K LP, 0.001 for price, 20 for 20% change)", + ) + check_interval: int = Field(300, description="Check interval in seconds (min 60, default 300)") + webhook_url: str = Field("", description="Optional webhook URL for external alert delivery") + + +class WatchListRequest(BaseModel): + token_address: str = Field("", description="Filter by token address") + chain: str = Field("", description="Filter by chain") + created_by: str = Field("", description="Filter by creator") + + +class WatchDeleteRequest(BaseModel): + watch_id: str = Field(..., description="Watch ID to deactivate") + + +class WatchAlertsRequest(BaseModel): + watch_id: str = Field("", description="Get alerts for specific watch") + token_address: str = Field("", description="Get alerts for token") + chain: str = Field("", description="Chain for token filter") + + +# ─── Endpoints ──────────────────────────────────────────────────────── + + +@router.post("/token_watch_create") +async def token_watch_create(req: WatchCreateRequest, request: Request): + """Create a token monitoring watch - alerts when conditions are met. + + Paid x402 tool ($0.05). Monitors LP levels, price drops, lock status changes. + Alerts delivered via WebSocket and optional webhook. + """ + from app.token_watch import WatchCondition, get_token_watch_service + + # Validate condition + valid_conditions = [c.value for c in WatchCondition] + if req.condition not in valid_conditions: + return JSONResponse( + status_code=400, + content={"error": f"Invalid condition '{req.condition}'. Must be one of: {', '.join(valid_conditions)}"}, + ) + + # Clamp check interval + check_interval = max(60, min(3600, req.check_interval)) + + # Get creator from headers + created_by = request.headers.get("x-wallet-address", "") or request.headers.get("X-Device-ID", "") or "anonymous" + + try: + svc = get_token_watch_service() + watch = await svc.create_watch( + token_address=req.token_address, + chain=req.chain, + condition=req.condition, + threshold=req.threshold, + created_by=created_by, + check_interval=check_interval, + webhook_url=req.webhook_url, + ) + + # Record payment + try: + from app.routers.x402_tools import record_x402_payment + + await record_x402_payment("token_watch_create", "0.05", created_by) + except Exception: + pass + + return { + "status": "created", + "watch_id": watch.watch_id, + "token_address": watch.token_address, + "chain": watch.chain, + "condition": watch.condition, + "threshold": watch.threshold, + "check_interval_seconds": watch.check_interval_seconds, + "expires_at": watch.expires_at, + "message": f"Watching {req.token_address[:10]}... on {req.chain} for {req.condition}. Alerts via WebSocket at /ws/alerts", + } + except Exception as e: + logger.error(f"Token watch create failed: {e}") + return JSONResponse(status_code=500, content={"error": str(e)[:200]}) + + +@router.post("/token_watch_list") +async def token_watch_list(req: WatchListRequest, request: Request): + """List active token watches. Free endpoint.""" + from app.token_watch import get_token_watch_service + + try: + svc = get_token_watch_service() + watches = await svc.list_watches( + token_address=req.token_address, + chain=req.chain, + created_by=req.created_by, + ) + return { + "status": "ok", + "count": len(watches), + "watches": [w.to_dict() for w in watches], + } + except Exception as e: + logger.error(f"Token watch list failed: {e}") + return JSONResponse(status_code=500, content={"error": str(e)[:200]}) + + +@router.post("/token_watch_delete") +async def token_watch_delete(req: WatchDeleteRequest, request: Request): + """Deactivate a token watch. Free endpoint.""" + from app.token_watch import get_token_watch_service + + try: + svc = get_token_watch_service() + deleted = await svc.delete_watch(req.watch_id) + return { + "status": "deactivated" if deleted else "not_found", + "watch_id": req.watch_id, + } + except Exception as e: + logger.error(f"Token watch delete failed: {e}") + return JSONResponse(status_code=500, content={"error": str(e)[:200]}) + + +@router.post("/token_watch_alerts") +async def token_watch_alerts(req: WatchAlertsRequest, request: Request): + """Get triggered alerts for token watches. Free endpoint.""" + from app.token_watch import get_token_watch_service + + try: + svc = get_token_watch_service() + alerts = await svc.get_alerts( + watch_id=req.watch_id, + token_address=req.token_address, + chain=req.chain, + ) + return { + "status": "ok", + "count": len(alerts), + "alerts": alerts[:50], + } + except Exception as e: + logger.error(f"Token watch alerts failed: {e}") + return JSONResponse(status_code=500, content={"error": str(e)[:200]}) + + +@router.post("/token_watch_check") +async def token_watch_check(req: WatchCreateRequest, request: Request): + """One-shot check of a token's current status (LP, price, etc.) without creating a watch. + + Paid x402 tool ($0.03). Returns current LP, price, and whether any rug indicators detected. + """ + from app.token_watch import get_token_watch_service + + try: + svc = get_token_watch_service() + pair_data = await svc._fetch_token_data(req.token_address, req.chain) + + if not pair_data: + return {"status": "no_data", "token_address": req.token_address, "chain": req.chain} + + # Extract key metrics + liquidity = pair_data.get("liquidity", {}) + lp_usd = liquidity.get("usd", 0) if isinstance(liquidity, dict) else liquidity + price_usd = pair_data.get("priceUsd", 0) + price_native = pair_data.get("priceNative", 0) + dex_id = pair_data.get("dexId", "unknown") + pair_address = pair_data.get("pairAddress", "") + volume = pair_data.get("volume", {}) + volume_24h = volume.get("h24", 0) if isinstance(volume, dict) else volume + price_change = pair_data.get("priceChange", {}) + change_24h = price_change.get("h24", 0) if isinstance(price_change, dict) else 0 + + # Simple rug indicators + warnings = [] + info = pair_data.get("info", {}) + if isinstance(info, dict): + if not info.get("liquidityLocked", True): + warnings.append("LP NOT LOCKED - high rug risk") + if info.get("renouncedOwnership"): + warnings.append("Ownership renounced (good sign)") + + # Record payment + try: + from app.routers.x402_tools import record_x402_payment + + await record_x402_payment("token_watch_check", "0.03", req.token_address) + except Exception: + pass + + return { + "status": "ok", + "token_address": req.token_address, + "chain": req.chain, + "dex": dex_id, + "pair_address": pair_address, + "price_usd": float(price_usd) if price_usd else 0, + "price_native": price_native, + "liquidity_usd": float(lp_usd) if lp_usd else 0, + "volume_24h": float(volume_24h) if volume_24h else 0, + "price_change_24h_pct": float(change_24h) if change_24h else 0, + "warnings": warnings, + } + except Exception as e: + logger.error(f"Token watch check failed: {e}") + return JSONResponse(status_code=500, content={"error": str(e)[:200]})