diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..df0f5e2 --- /dev/null +++ b/app/main.py @@ -0,0 +1,11 @@ +"""RMI backend entrypoint. + +Delegates to app.factory.create_app. Keeps `uvicorn app.main:app` working +as the canonical launch path referenced by Makefile, AGENTS.md, and +CONTRIBUTING.md. + +Phase 1 of AUDIT-2026-Q3.md, item P1.1. +""" +from app.factory import create_app + +app = create_app() diff --git a/main.py.bak b/main.py.bak deleted file mode 100644 index 3305fb5..0000000 --- a/main.py.bak +++ /dev/null @@ -1,8399 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Rug Munch Media LLC. All rights reserved. -# Proprietary and confidential. Unauthorized use, distribution, -# or reproduction is strictly prohibited. See LICENSE. -""" -RugMunch Intelligence Platform Backend -Clean, modern, complete API serving all frontend endpoints. -Wired to real external APIs: CoinGecko, DexScreener, Jupiter, Groq. -""" - -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, Response, HTMLResponse, JSONResponse, FileResponse -from fastapi import FastAPI, Header, HTTPException, Request, Query -from starlette.middleware.base import BaseHTTPMiddleware -from slowapi import Limiter -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded -from dotenv import load_dotenv -load_dotenv(override=True) # Load from .env, overriding any stale Docker env vars -import os, base64 -# Decode base64 LLM key if present -_llm_b64 = os.getenv('LLM_API_KEY_B64', '') -if _llm_b64: - os.environ['LLM_API_KEY'] = base64.b64decode(_llm_b64).decode() -from datetime import datetime, timedelta, timezone -from pydantic import BaseModel, Field, ValidationError -from typing import Optional -import time -import os -import json -import hashlib -import secrets -import httpx -import asyncio -import urllib.request -import email.utils as email_utils - -# ═══════════════════════════════════════════════════════════════ -# STRICT .ENV VALIDATION ON STARTUP (Fail Fast) -# ═══════════════════════════════════════════════════════════════ -REQUIRED_ENV_VARS = [ - "REDIS_HOST", "REDIS_PORT", "REDIS_PASSWORD", - "SUPABASE_URL", "SUPABASE_KEY", - "KIMI_API_KEY", "OPENROUTER_API_KEY", - "ADMIN_API_KEY" -] - -missing_vars = [var for var in REQUIRED_ENV_VARS if not os.getenv(var)] -if missing_vars: - print("[FATAL] Missing critical environment variables:") - for var in missing_vars: - print(f" - {var}") - print("Please check your .env file or Docker environment configuration.") - raise SystemExit(1) -else: - print("[OK] Environment variables validated successfully.") - -# ═══════════════════════════════════════════════════════════════ -# LAZY IMPORT UTILITY (defers heavy modules until first use) -# ═══════════════════════════════════════════════════════════════ -import importlib as _importlib -class _LazyModule: - """Deferred import — only loads the module when first attribute is accessed.""" - __slots__ = ("_module_name", "_attr_name", "_module", "_fromlist") - def __init__(self, module_name, attr_name=None, fromlist=None): - self._module_name = module_name - self._attr_name = attr_name - self._fromlist = fromlist - self._module = None - def _load(self): - if self._module is None: - mod = _importlib.import_module(self._module_name) - if self._fromlist: - for name in self._fromlist: - setattr(mod, name, getattr(mod, name, None)) - self._module = mod - return self._module - def __getattr__(self, name): - mod = self._load() - if self._attr_name: - return getattr(getattr(mod, self._attr_name), name) - return getattr(mod, name) - def __call__(self, *args, **kwargs): - mod = self._load() - if self._attr_name: - return getattr(mod, self._attr_name)(*args, **kwargs) - return mod(*args, **kwargs) - def __repr__(self): - return f"" - -feedparser = _LazyModule("feedparser") -# ═══════════════════════════════════════════════════════════════ -from app.routers import bulletin -from app.protection import router as protection_router -# ── Error tracking (GlitchTip / Sentry compatible) ── -GLITCHTIP_DSN = os.getenv("GLITCHTIP_DSN", "") -if GLITCHTIP_DSN: - try: - import sentry_sdk - sentry_sdk.init( - dsn=GLITCHTIP_DSN, - environment=os.getenv("ENVIRONMENT", "production"), - traces_sample_rate=float(os.getenv("SENTRY_TRACES_RATE", "0.1")), - send_default_pii=False, - ) - print(f"[OK] GlitchTip/Sentry error tracking enabled") - except ImportError: - pass - except Exception as e: - print(f"[WARN] GlitchTip init failed: {e}") - -from app.routers import supabase_router -WhaleWatcher = _LazyModule("app.helius_tools.helius_whale_watcher", "WhaleWatcher") -SniperDetector = _LazyModule("app.helius_tools.helius_sniper_detector", "SniperDetector") -SyndicateTracker = _LazyModule("app.helius_tools.helius_syndicate_tracker", "SyndicateTracker") -FreeSolscanClient = _LazyModule("app.free_solscan_client", "FreeSolscanClient") -from app.scan_rate_limiter import check_scan_limit, increment_scan_usage, get_identity, is_internal_request, is_x402_paid_request, FREE_DAILY_LIMIT -from app.degen_security_scanner import DegenSecurityScanner, SecurityReport -from app.gmgn_client import GMGNClient -from app.tools_integration import ( - ollama_chat, ollama_list_models, cast_contract_info, cast_tx_decode, - slither_analyze, ccxt_get_prices, ccxt_arbitrage, web3_wallet_balance, - blogwatcher_fetch, tools_health, vault_get_secret, vault_list_secrets -) -from app.sosana_crm import sosana -from app.analyzer.multi_chain import scan_all_chains -from app.analyzer.pnl_calculator import calculate_token_pnl -from app.adapters.binance_web3 import get_wallet_holdings, CHAIN_IDS -from app.wallet_label_loader import load_all_labels, lookup_wallet_label - -# ═══════════════════════════════════════════════════════════════ -# 4. CONNECTION POOLING FOR HTTP CLIENTS (Lifespan) -# ═══════════════════════════════════════════════════════════════ -from contextlib import asynccontextmanager - -@asynccontextmanager -async def lifespan(app: FastAPI): - # ═══════════════════════════════════════════════════════════════ - # CRITICAL STARTUP ASSERTION: WALLET VAULT PASSWORD - # ═══════════════════════════════════════════════════════════════ - vault_pw = os.getenv("WALLET_VAULT_PASSWORD", "").strip() - if not vault_pw: - raise RuntimeError( - "CRITICAL: WALLET_VAULT_PASSWORD environment variable is missing or empty. " - "The backend will not start without it to prevent silent wallet key loss. " - "Ensure it is set in .env and passed to docker-compose.yml." - ) - - # Initialize a single, global pooled HTTP client for all external API calls - app.state.http_client = httpx.AsyncClient( - limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), - timeout=10.0 - ) - - # ═══════════════════════════════════════════════════════════════ - # 7. DATABASE INDEX VERIFICATION & OPTIMIZATION ON STARTUP - # ═══════════════════════════════════════════════════════════════ - try: - supabase_url = os.getenv("SUPABASE_URL", "") - supabase_key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "") - - if supabase_url and supabase_key: - indexes_to_create = [ - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scan_results_created_at ON scan_results(created_at DESC);", - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scan_results_token_address ON scan_results(token_address);", - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_whale_alerts_created_at ON whale_alerts(created_at DESC);", - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_security_alerts_created_at ON security_alerts(created_at DESC);", - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_x402_payments_tx_hash ON x402_payments(tx_hash);", - ] - - for sql in indexes_to_create: - try: - res = await app.state.http_client.post( - f"{supabase_url}/rest/v1/rpc/exec_sql", - json={"query": sql}, - headers={"apikey": supabase_key, "Authorization": f"Bearer {supabase_key}", "Content-Type": "application/json"} - ) - if res.status_code in [200, 204]: - print(f"[OK] Index verified/created: {sql.split('ON ')[1].split('(')[0].strip()}") - else: - print(f"[WARN] Index creation skipped/failed ({res.status_code}): {sql[:50]}...") - except Exception as e: - print(f"[WARN] Index verification failed for '{sql[:50]}...': {e}") - except Exception as e: - print(f"[WARN] Database index verification skipped: {e}") - - # Start facilitator health check loop (runs every 60s in background) - try: - from app.routers.facilitator_health import health_check_loop - asyncio.create_task(health_check_loop()) - print("[RMI] Facilitator health monitor started (60s interval)") - except Exception as e: - print(f"[WARN] Facilitator health monitor failed to start: {e}") - - # Start status page monitor (runs every 30s in background) - try: - from app.routers.status_page import status_monitor_loop - asyncio.create_task(status_monitor_loop()) - print("[RMI] Status page monitor started (30s interval)") - except Exception as e: - print(f"[WARN] Status page monitor failed to start: {e}") - - # Start webhook dispatcher (runs every 5s in background) - try: - asyncio.create_task(webhook_dispatcher_loop()) - print("[RMI] Webhook dispatcher started (5s interval)") - except Exception as e: - print(f"[WARN] Webhook dispatcher failed to start: {e}") - - # Run initial health check immediately - try: - from app.routers.facilitator_health import run_health_checks - asyncio.create_task(run_health_checks()) - except Exception: - pass - - yield - - # Cleanup on shutdown - await app.state.http_client.aclose() - -app = FastAPI(title="RugMunch Intelligence Platform", lifespan=lifespan) - -# Include routers -from app.protection_router import router as protection_router - -app.include_router(protection_router) -app.include_router(protection_router) -app.include_router(supabase_router.router) -app.include_router(protection_router, prefix="/api/v1/protect") -from app.routers import supabase_auth_router -from app.routers import supabase_oauth_router -from app.routers import profile_router -app.include_router(supabase_auth_router.router) -app.include_router(supabase_oauth_router.router) -app.include_router(profile_router.router) -from app.routers import admin_control, admin_extensions -from app.routers import alert_pipeline -from app.routers import intelligence_panel -from app.routers import security_intel -from app.routers import darkroom_tokens -from app.routers import darkroom_airdrop -from app.routers import darkroom_multichain -from app.routers import admin_backend -from app.routers import bulletin_board -from app.routers import wallet_manager_v2 -from app.routers import analytics -from app.routers import scam_school -app.include_router(admin_control.router) -app.include_router(admin_extensions.router) -app.include_router(alert_pipeline.router) -app.include_router(intelligence_panel.router) -app.include_router(security_intel.router) -app.include_router(darkroom_tokens.router) -app.include_router(darkroom_airdrop.router) -app.include_router(darkroom_multichain.router) -app.include_router(admin_backend.router) -app.include_router(bulletin_board.router) -app.include_router(wallet_manager_v2.router) -app.include_router(analytics.router) -app.include_router(scam_school.router) -app.include_router(wallet_manager_v2.router) -app.include_router(wallet_manager_v2.sweep_router) -from app.routers import analytics -from app.routers import community_forensics -app.include_router(community_forensics.router) -app.include_router(analytics.router) -from app.routers import email_router -app.include_router(email_router.router) -from app.caching_shield.router import router as cache_router -from app.caching_shield.quality_endpoints import router as quality_router -# DataBus — THE unified data layer. Replaces fractured caching/shield imports. -from app.databus.router import router as databus_router -from app.databus.core import databus as _databus -from app.databus.ws_stream import router as databus_ws_router -app.include_router(cache_router) -app.include_router(quality_router) -# DataBus — unified data routes (replaces 6+ fractured data systems) -app.include_router(databus_router) -app.include_router(databus_ws_router) - -# Protection Suite API -from app.protection_api import router as protection_router -app.include_router(protection_router, prefix="/api/v1/protect") -# Include the protection router for the Core Detection API -app.include_router(protection_router, prefix="/api/v1/protect") -@app.get('/earnings', response_class=HTMLResponse) -async def earnings_page(): - with open('/app/static/earnings.html') as f: - return f.read() -from app.caching_shield.investigate_router import router as investigate_router -app.include_router(investigate_router) -from app.all_connectors import router as connectors_router -app.include_router(connectors_router) -from app.routers.x402_middleware import router as x402_middleware_router -from app.routers.x402_enforcement import router as x402_enforcement_router -from app.routers.x402_enforcement import discovery_router as x402_discovery_router -from app.routers.x402_catalog import router as x402_catalog_router -from app.routers.human_catalog import router as human_catalog_router -from app.routers.smart_calls import router as smart_calls_router -from app.routers.x402_forensic_tools import router as x402_forensic_router -from app.routers.x402_tools import router as x402_tools_router -from app.routers.x402_databus_tools import router as x402_databus_router -from app.routers.x402_dashboard import router as x402_dashboard_router -from app.routers.x402_dashboard import on_startup as x402_dashboard_startup -from app.routers.x402_token_watch import router as x402_token_watch_router -from app.routers.x402_premium_tools import router as x402_premium_router -from app.routers.x402_advanced_tools import router as x402_advanced_router -from app.routers.x402_institutional_tools import router as x402_institutional_router -from app.routers.x402_alpha_tools import router as x402_alpha_router -from app.routers.label_lookup import router as label_lookup_router -from app.routers.developer_tier import router as developer_tier_router -from app.routers.facilitator_health import router as facilitator_health_router -from app.routers.x402_analytics import router as x402_analytics_router -from app.routers.persistent_state import router as persistent_state_router -from app.routers.status_page import router as status_router -from app.routers.x402_alpha_revenue_tools import router as alpha_revenue_router -from app.routers.webhook_pipeline import router as webhook_pipeline_router -from app.routers.webhook_pipeline import webhook_dispatcher_loop -from app.routers.tool_changelog import router as tool_changelog_router -from app.auth import router as auth_router -app.include_router(x402_middleware_router) -app.include_router(x402_enforcement_router) -app.include_router(x402_discovery_router) # /.well-known/x402 at root (x402 spec) -app.include_router(x402_catalog_router) -app.include_router(human_catalog_router) # /api/v1/catalog — human-facing tool discovery -app.include_router(smart_calls_router) # /api/v1/smart-calls — human marketplace mirror -app.include_router(x402_forensic_router) -app.include_router(x402_tools_router) -app.include_router(x402_dashboard_router) -app.include_router(x402_token_watch_router) -app.include_router(x402_premium_router, prefix="/api/v1/x402-tools") -app.include_router(x402_advanced_router, prefix="/api/v1/x402-tools") -app.include_router(x402_institutional_router, prefix="/api/v1/x402-tools") -app.include_router(x402_alpha_router, prefix="/api/v1/x402-tools") -app.include_router(x402_databus_router) # DataBus x402 pay-per-call APIs -app.include_router(label_lookup_router) -app.include_router(developer_tier_router) # Free developer API keys + tier system -app.include_router(facilitator_health_router) # Facilitator health monitoring + auto-failover -app.include_router(x402_analytics_router) # Payment analytics dashboard -app.include_router(persistent_state_router) # Watchlists, portfolios, alert history -app.include_router(status_router) # Public status page -app.include_router(alpha_revenue_router) # High-value revenue alpha tools -app.include_router(webhook_pipeline_router) # Webhook notification pipeline -app.include_router(tool_changelog_router) # Tool changelog & version system -app.include_router(auth_router, prefix="/api/v1/auth") -from app.routers import auth_extensions -app.include_router(auth_extensions.router, prefix="/api/v1/auth") -from app.routers import admin_users_api -app.include_router(admin_users_api.router, prefix="/api/v1/admin") -from app.routers import subscription_pricing_api -app.include_router(subscription_pricing_api.router, prefix="/api/v1") - -# ── Liquidity Watchdog (expiry alerts) ───────────────────────── -from app.routers import liquidity_watchdog -app.include_router(liquidity_watchdog.router) - -from app.routers import community_badges - -# ── Market Intel Router ────────────────────────────────────────── -from app.routers import market_intel_router -app.include_router(market_intel_router.router) -app.include_router(community_badges.router) - -# ── Darkroom Admin UI (static) ───────────────────────────────── -@app.get("/darkroom") -async def darkroom_dashboard(): - """Serve the Darkroom admin token deployer UI.""" - return FileResponse("/root/backend/static/darkroom.html") - -@app.get("/admin", include_in_schema=False) -async def admin_dashboard(): - """Serve the main admin control panel SPA.""" - return FileResponse("/root/backend/static/admin.html") - -# ── security.txt (RFC 9116) ── -SECURITY_TXT = """Contact: admin@rugmunch.io -Preferred-Languages: en -Canonical: https://cryptorugmunch.com/.well-known/security.txt -Canonical: https://rugmunch.io/.well-known/security.txt - -This policy outlines the vulnerability disclosure process for Rug Munch Media. -We encourage responsible disclosure of security vulnerabilities. - -Expected response time: 48 hours -Please report security issues to admin@rugmunch.io -""" - -@app.get("/.well-known/security.txt") -@app.get("/security.txt") -async def security_txt(): - return Response(content=SECURITY_TXT.strip(), media_type="text/plain", headers={ - "Cache-Control": "max-age=3600", - }) - -# ── App startup: facilitators, x402_payments Supabase, gateways ── -@app.on_event("startup") -async def _startup(): - # Initialize DataBus — THE unified data layer - try: - await _databus.initialize() - print(f"[INFO] DataBus initialized: {len(_databus.chains)} chains, " - f"{sum(len(c.providers) for c in _databus.chains.values())} providers") - except Exception as e: - print(f"[WARN] DataBus initialization failed (will use fallback providers): {e}") - # Register all x402 facilitators - try: - from app.facilitators.startup import register_all_facilitators - await register_all_facilitators() - except Exception as e: - print(f"[WARN] x402 facilitator registration failed: {e}") - - # Ensure x402_payments Supabase table - try: - await x402_dashboard_startup() - except Exception as e: - print(f"[WARN] x402 dashboard startup failed: {e}") - - # Ensure ClickHouse schema for Wallet Memory Bank - try: - from app.wallet_memory.storage import ensure_schema - ch_ok = await ensure_schema() - print(f"[INFO] ClickHouse schema ensure: {'ok' if ch_ok else 'unavailable'}") - except Exception as e: - print(f"[WARN] ClickHouse schema init failed: {e}") - - # Load static wallet labels into Redis + ClickHouse - try: - from app.wallet_memory.label_importer import load_all_static_labels - counts = await load_all_static_labels() - total = counts.get("total", 0) - print(f"[INFO] Static wallet labels loaded: {total} labels across {len(counts)} sources") - except Exception as e: - print(f"[WARN] Static wallet label import failed: {e}") - - # Pre-warm BM25 index (Pillar 2) so first search is fast - try: - from app.splade_bm25 import get_bm25_index - import time as _time - _t0 = _time.time() - bm25 = await get_bm25_index(force_rebuild=True) - _elapsed = _time.time() - _t0 - print(f"[INFO] BM25 index warmed: {bm25.doc_count} docs, {len(bm25.df)} terms ({_elapsed:.1f}s)") - except Exception as e: - print(f"[WARN] BM25 warmup failed (Pillar 2 will build on first search): {e}") - - # Pre-warm RAG embedding model (avoids 5s cold-start on first citation query) - try: - from app.rag_service import search_similar - await search_similar("warmup", "known_scams", limit=1, min_similarity=0.1) - print("[INFO] RAG embedding model warmed up") - except Exception as e: - print(f"[WARN] RAG model warmup failed: {e}") - - # Pre-warm FAISS indexes from disk - try: - from app.ann_index import get_ann_index - ann = get_ann_index() - _loaded = 0 - for coll in ["known_scams", "wallet_profiles", "forensic_reports", - "scam_patterns", "token_analysis", "contract_audits", "market_intel"]: - if ann._load_from_disk(coll): - _loaded += 1 - if _loaded > 0: - print(f"[INFO] FAISS indexes pre-loaded: {_loaded} collections from disk") - except Exception as e: - print(f"[WARN] FAISS pre-load skipped: {e}") - - # Pre-warm Knowledge Graph for high-value collections (Pillar 3) - try: - from app.knowledge_graph import build_graph_from_rag - import time as _time2 - _t0 = _time2.time() - kg_result = await build_graph_from_rag( - collections=["known_scams", "scam_patterns", "forensic_reports", "contract_audits"], - max_per_collection=2000, - ) - _elapsed = _time2.time() - _t0 - print(f"[INFO] KG warmed: {kg_result['edges_created']} edges across {len(kg_result['collections'])} collections ({_elapsed:.1f}s)") - except Exception as e: - print(f"[WARN] KG warmup failed (Pillar 3 expansion will be unavailable): {e}") - - # Cross-encoder reranker: lazy-load on first use (skipping 45s pre-warm). - # The fp32 model (2.1GB) times out on CPU with >9 docs anyway per the documented - # pitfall. Set RAG_PREWARM_RERANKER=true to pre-load at startup. - # ONNX INT8 quantization (future) would drop this to ~15s / 500MB. - if os.environ.get("RAG_PREWARM_RERANKER", "").lower() == "true": - try: - from app.cross_encoder_reranker import get_reranker - import time as _time3 - _t0 = _time3.time() - reranker = await get_reranker() - await reranker.warm_up() - _elapsed = _time3.time() - _t0 - print(f"[INFO] Cross-encoder reranker pre-warmed: ({_elapsed:.1f}s)") - except Exception as e: - print(f"[WARN] Cross-encoder warmup failed: {e}") - else: - print("[INFO] Cross-encoder reranker deferred (lazy-load on first rerank query)") - - # ── Startup: kick off auto-scan of new tokens ── - try: - import asyncio as _asyncio_startup - _asyncio_startup.create_task(_auto_scan_new_tokens()) - print("[INFO] Auto-scan new tokens task scheduled") - except Exception as e: - print(f"[WARN] Auto-scan startup failed: {e}") - - # ── Startup: RAG Firehose — continuous intelligence ingestion ── - try: - from app.rag_firehose import get_firehose - firehose = get_firehose() - await firehose.start() - print("[INFO] RAG Firehose started — continuous ingestion engine online") - except Exception as e: - print(f"[WARN] RAG Firehose startup failed: {e}") - - # ── Startup: Supabase Realtime Bridge — live DB changes → WebSocket ── - try: - from app.supabase_realtime import bridge, poll_realtime_changes - await bridge.start() - # Also start polling as HTTP fallback for reliability - import asyncio as _asyncio_rt - _asyncio_rt.create_task(poll_realtime_changes()) - print("[INFO] Supabase Realtime bridge started — live DB → WS forwarding") - except Exception as e: - print(f"[WARN] Supabase Realtime bridge failed: {e}") - -from app.email_router import router as email_router -app.include_router(email_router) -from app.rag_endpoints import router as rag_router -app.include_router(rag_router) -from app.content_router import router as content_router -app.include_router(content_router) -from app.mail_dashboard import router as mail_router -app.include_router(mail_router) - -# WalletSafe — Wallet Intelligence Explorer frontend -from app.wallet_memory.frontend import router as walletsafe_router -app.include_router(walletsafe_router) - -# Wallet Factory API — multi-chain wallet generation (25+ chains) -from app.routers.wallet_factory_router import router as wallet_factory_router -app.include_router(wallet_factory_router) - -# ── Wallet Intelligence Routers ────────────────────────────── -from app.routers import bubble_maps_router # RugMaps engine -from app.routers import wallet_clustering_router -from app.routers import cross_token_router -app.include_router(bubble_maps_router.router) -app.include_router(wallet_clustering_router.router) -app.include_router(cross_token_router.router) - -# ── Wallet Memory Bank (WalletSafe product) ────────────────── -from app.wallet_memory.router import router as wallet_memory_router -app.include_router(wallet_memory_router) - -# ── Discovery & Forensics Routers ──────────────────────────── -from app.routers import discovery_router -from app.routers import forensics_router -app.include_router(discovery_router.router) -app.include_router(forensics_router.router) - -# ── Prediction Market Intelligence ─────────────────────────── -# Multi-source: Polymarket + Kalshi + Limitless + Manifold -# Free public APIs, zero auth required for read-only -from app.routers.prediction_market_router import router as prediction_market_router -app.include_router(prediction_market_router) - -# MCP Server — full crypto intelligence MCP with 30+ tools, discovery, well-known endpoints -from app.routers.mcp_server import router as mcp_server_router -app.include_router(mcp_server_router) - -# Local MCP proxy — free tool calls from installed MCP servers -from app.routers.mcp_local_router import router as mcp_local_router -app.include_router(mcp_local_router) - -# ── Cache Stats Monitoring ───────────────────────────────────── -from app.cache_manager import cache as rmi_cache - -@app.get("/api/v1/cache/stats") -async def cache_stats(request: Request): - """Cache hit rates, credit usage, rate limit status across all providers.""" - return rmi_cache.get_stats() - -@app.post("/api/v1/cache/invalidate") -async def cache_invalidate(request: Request, data: dict): - """Invalidate a specific cache key. Admin only.""" - key = data.get("key", "") - if key: - await rmi_cache.invalidate(key) - return {"invalidated": key, "status": "ok"} - return {"error": "No key provided"} - -# ── Degen Security Scanner ─────────────────────────────────── -from app.degen_scan_endpoint import router as degen_router -app.include_router(degen_router) - -# ── Hermes API Proxy ────────────────────────────────────────── -# Proxies /v1/hermes/* → hermes API on host. Uses host.docker.internal -# added via docker-compose extra_hosts. -HERMES_API_URL = "http://host.docker.internal:8642" -HERMES_API_KEY = os.getenv("HERMES_API_KEY", "") - -@app.api_route("/v1/hermes/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) -@app.api_route("/v1/hermes", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) -async def proxy_hermes(request: Request, path: str = ""): - target = f"{HERMES_API_URL}/v1/{path}" if path else f"{HERMES_API_URL}/v1" - headers = dict(request.headers) - headers.pop("host", None) - headers.pop("content-length", None) - if "authorization" not in {k.lower() for k in headers}: - headers["authorization"] = f"Bearer {HERMES_API_KEY}" - body = await request.body() - async with httpx.AsyncClient(timeout=120.0) as client: - resp = await client.request( - method=request.method, url=target, headers=headers, - content=body, params=dict(request.query_params), - ) - return Response( - content=resp.content, status_code=resp.status_code, - headers=dict(resp.headers), - ) - -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "https://rugmunch.io", - "https://*.rugmunch.io", - "https://*.rugmunch.com", - "https://*.rugmunch.app", - "https://*.rugmunch.dev", - "chrome-extension://*", - "moz-extension://*", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# x402 payment enforcement middleware — intercepts /api/v1/x402-tools/* calls -from app.routers.x402_enforcement import x402_enforcement_middleware -app.add_middleware(BaseHTTPMiddleware, dispatch=x402_enforcement_middleware) - -# DataBus fallback middleware — if x402-tools returns empty/error, falls back to databus.fetch() -from app.routers.x402_databus_fallback import DataBusFallbackMiddleware -app.add_middleware(DataBusFallbackMiddleware) - -# Response cache middleware — serves cached results for repeat queries (<50ms) -CACHEABLE_TOOLS = {"audit","wallet","reputation_score","honeypot_check","rugshield", - "forensics","whale","token_deep_dive","market_overview","chain_health", - "sentiment","rug_pull_predictor","rug_probability","narrative","history"} - -@app.middleware("http") -async def cache_middleware(request: Request, call_next): - """Check Redis cache before executing tool. Store result after.""" - path = request.url.path - if not path.startswith("/api/v1/x402-tools/"): - return await call_next(request) - if request.method != "POST": - return await call_next(request) - - tool = path.rstrip("/").split("/")[-1] - if tool not in CACHEABLE_TOOLS: - return await call_next(request) - - # Build cache key from request body - try: - body = await request.body() - params = json.loads(body) if body else {} - except Exception: - return await call_next(request) - - try: - from app.routers.x402_advanced_tools import get_cached, set_cached - cached = get_cached(tool, params) - if cached: - return JSONResponse(content=cached, headers={"X-Cache": "HIT", "X-Cache-TTL": "60"}) - except Exception: - pass - - # Execute and cache - response = await call_next(request) - if response.status_code == 200: - try: - # Read response body - resp_body = b"" - async for chunk in response.body_iterator: - resp_body += chunk - result = json.loads(resp_body) - set_cached(tool, params, result) - return JSONResponse(content=result, status_code=response.status_code, - headers={**dict(response.headers), "X-Cache": "MISS"}) - except Exception: - pass - return response - -# Emergency Lockdown Middleware — blocks all non-admin traffic if lockdown is active -@app.middleware("http") -async def emergency_lockdown_middleware(request: Request, call_next): - """Check for emergency lockdown status. Block non-admin routes if active.""" - # Allow health checks and lockdown endpoints always - if request.url.path in ["/health", "/api/v1/admin/emergency-lockdown", "/api/v1/admin/emergency-status"]: - return await call_next(request) - - # Check lockdown status in Redis - 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, - ) - is_locked = await r.exists("rmi:emergency_lockdown") - if is_locked: - # Check if request has admin authorization - auth_header = request.headers.get("Authorization", "") - session_token = request.headers.get("X-Admin-Session", "") - - if not auth_header and not session_token: - return JSONResponse( - status_code=503, - content={"error": "Service Unavailable", "detail": "System is in emergency lockdown."} - ) - except Exception: - pass # If Redis is down, don't block traffic - - return await call_next(request) - -# HSTS Middleware — forces HTTPS and prevents protocol downgrade attacks -@app.middleware("http") -async def hsts_middleware(request: Request, call_next): - response = await call_next(request) - response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" - return response - -# Request ID Tracing Middleware — generates a unique ID for every request for log correlation -import uuid - -@app.middleware("http") -async def request_id_middleware(request: Request, call_next): - request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) - response = await call_next(request) - response.headers["X-Request-ID"] = request_id - return response - -# ═══════════════════════════════════════════════════════════════ -# 1. PAYLOAD SIZE LIMITS (Prevent Memory Exhaustion DoS) -# ═══════════════════════════════════════════════════════════════ -MAX_PAYLOAD_SIZE = 1_048_576 # 1MB for standard JSON APIs - -@app.middleware("http") -async def payload_size_limit_middleware(request: Request, call_next): - content_length = request.headers.get("content-length") - if content_length and int(content_length) > MAX_PAYLOAD_SIZE: - # Allow larger payloads for specific upload routes - if not request.url.path.startswith("/api/v1/admin/backend/upload/"): - return JSONResponse( - status_code=413, - content={"error": "Payload Too Large", "detail": "Maximum payload size is 1MB"} - ) - return await call_next(request) - -# ═══════════════════════════════════════════════════════════════ -# 3. SECURE COOKIE FLAGS FOR ADMIN SESSIONS -# ═══════════════════════════════════════════════════════════════ -@app.middleware("http") -async def secure_cookie_middleware(request: Request, call_next): - response = await call_next(request) - if "set-cookie" in response.headers: - cookie_val = response.headers["set-cookie"] - # Enforce secure flags if not already present - if "HttpOnly" not in cookie_val: - cookie_val += "; HttpOnly" - if "Secure" not in cookie_val: - cookie_val += "; Secure" - if "SameSite=Strict" not in cookie_val and "SameSite=Lax" not in cookie_val: - cookie_val += "; SameSite=Strict" - response.headers["set-cookie"] = cookie_val - return response - -# Rate limiting - -# Auth filter middleware — require X-API-Key for POST/PUT/DELETE/PATCH -PUBLIC_WRITE_PREFIXES = [ - "/api/v1/auth/", # login, register, social-login, wallet-auth - "/api/v1/x402/", # x402 payments, receipts, catalog - "/api/v1/x402-tools/", # x402 tool execution (trial + paid) - "/api/v1/x402-databus/", # x402 DataBus tool execution (trial + paid) - "/api/v1/databus/", # DataBus fetch, batch, cache control (X-Admin-Key for admin ops) - "/api/v1/alerts/", # public alert subscriptions - "/api/v1/admin/", # darkroom admin (uses X-Admin-Key separately) - "/api/v1/content/", # content posting - "/api/v1/bulletin/", # bulletin posting - "/api/v1/rag/permanence/", # RAG nightly ops (internal cron) - "/api/v1/token/", # token scan (internal DataBus passthrough) - "/api/v1/ai/", # AI chat + streaming (public) - "/api/v1/premium/", # Premium AI features (public) - "/api/v1/rag/", # RAG search (internal DataBus passthrough) - "/api/v1/protect/", # Protection Suite API (public for browser extension) - "/api/v1/wallet-manager/", # Wallet manager (bypass auth for cron) -] - -class AuthMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - path = request.url.path - if path in ("/health", "/ready", "/docs", "/openapi.json", "/redoc", "/", "/favicon.ico"): - return await call_next(request) - if path.startswith("/ws/") or not path.startswith("/api/"): - return await call_next(request) - # Public write endpoints (auth, x402, alerts) - if request.method in ("POST", "PUT", "DELETE", "PATCH"): - if any(path.startswith(p) for p in PUBLIC_WRITE_PREFIXES): - return await call_next(request) - api_key = request.headers.get("X-API-Key", "") - if RMI_AUTH_TOKEN and api_key != RMI_AUTH_TOKEN: - return JSONResponse( - status_code=401, - content={"detail": "Unauthorized - valid X-API-Key header required for write operations"} - ) - return await call_next(request) - -app.add_middleware(AuthMiddleware) - -limiter = Limiter(key_func=get_remote_address, default_limits=["30/minute"]) -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, lambda request, exc: JSONResponse( - status_code=429, - content={"detail": "Rate limit exceeded. Try again later."} -)) - - -# ═══════════════════════════════════════════════════════════ -# API AUTH — Dependency injection (more reliable than middleware) -# ═══════════════════════════════════════════════════════════ - -RMI_AUTH_TOKEN = os.environ.get("RMI_AUTH_TOKEN", "") -if not RMI_AUTH_TOKEN: - try: - with open("/root/.secrets/rmi_auth_token", "r") as f: - RMI_AUTH_TOKEN = f.read().strip() - except Exception: - pass - -from fastapi import Depends -from fastapi.security import APIKeyHeader - -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - -async def verify_api_key(api_key: str = Depends(api_key_header)): - if RMI_AUTH_TOKEN and api_key != RMI_AUTH_TOKEN: - raise HTTPException(status_code=401, detail="Unauthorized - valid X-API-Key header required") - return api_key - -# ═══════════════════════════════════════════════════════════ -# API KEYS & CACHING INFRASTRUCTURE -# ═══════════════════════════════════════════════════════════ - -def _load_secret(name: str) -> str: - """Load API key from /root/.secrets/ or env var.""" - env_val = os.environ.get(name.upper(), "") - if env_val: - return env_val - try: - with open(f"/root/.secrets/{name}", "r") as f: - return f.read().strip() - except Exception: - return "" - -COINGECKO_API_KEY = _load_secret("coingecko_api_key") -GROQ_API_KEY = _load_secret("groq_api_key") - -# Supabase configuration — loaded from environment only, no hardcoded defaults -SUPABASE_URL = os.environ.get("SUPABASE_URL", "") -SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "") or os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "") -SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "") - -if not SUPABASE_URL or not SUPABASE_SERVICE_KEY: - # Backend starts but Supabase-dependent features will be unavailable - print("[WARN] SUPABASE_URL and SUPABASE_SERVICE_KEY not set — Supabase features disabled") - print(" Set these in /root/.env before starting Docker") - -async def _supabase_get(table: str, params: dict = None) -> list: - """Query Supabase table via REST API.""" - if not SUPABASE_SERVICE_KEY: - return [] - headers = { - "apikey": SUPABASE_SERVICE_KEY, - "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", - } - query_parts = [] - if params: - for k, v in params.items(): - query_parts.append(f"{k}=eq.{v}") - url = f"{SUPABASE_URL}/rest/v1/{table}" - if query_parts: - url += "?" + "&".join(query_parts) - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(url, headers=headers) - if r.status_code == 200: - return r.json() - except Exception: - pass - return [] - -async def _supabase_insert(table: str, data: dict) -> dict | None: - """Insert into Supabase table.""" - if not SUPABASE_SERVICE_KEY: - return None - headers = { - "apikey": SUPABASE_SERVICE_KEY, - "Authorization": f"Bearer {SUPABASE_SERVICE_KEY}", - "Content-Type": "application/json", - "Prefer": "return=representation", - } - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.post(f"{SUPABASE_URL}/rest/v1/{table}", json=data, headers=headers) - if r.status_code == 201: - return r.json()[0] if r.json() else None - except Exception: - pass - return None - -COINGECKO_BASE = "https://api.coingecko.com/api/v3" -DEXSCREENER_BASE = "https://api.dexscreener.com" -JUPITER_BASE = "https://price.jup.ag/v6" - -# Simple TTL cache: key -> (value, expires_at) -_API_CACHE: dict = {} -_CACHE_TTL = 60 # seconds minimum between CoinGecko calls - -def _cache_get(key: str): - entry = _API_CACHE.get(key) - if entry and datetime.now(timezone.utc) < entry[1]: - return entry[0] - return None - -def _cache_set(key: str, value, ttl: int = _CACHE_TTL): - _API_CACHE[key] = (value, datetime.now(timezone.utc) + timedelta(seconds=ttl)) - -async def _coingecko_get(path: str, params: dict = None, ttl: int = 60): - """Fetch from CoinGecko with caching and key header.""" - cache_key = f"cg:{path}:{json.dumps(params or {}, sort_keys=True)}" - cached = _cache_get(cache_key) - if cached is not None: - return cached - headers = {} - if COINGECKO_API_KEY: - headers["x-cg-demo-api-key"] = COINGECKO_API_KEY - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"{COINGECKO_BASE}{path}", params=params, headers=headers) - if r.status_code == 200: - data = r.json() - _cache_set(cache_key, data, ttl=ttl) - return data - except Exception: - pass - return None - -async def _dexscreener_get(path: str, params: dict = None, ttl: int = 30): - """Fetch from DexScreener with caching. Returns raw response dict.""" - cache_key = f"ds:{path}:{json.dumps(params or {}, sort_keys=True)}" - cached = _cache_get(cache_key) - if cached is not None: - return cached - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"{DEXSCREENER_BASE}{path}", params=params) - if r.status_code == 200: - data = r.json() - _cache_set(cache_key, data, ttl=ttl) - return data - except Exception: - pass - return None - -async def _jupiter_get(path: str, params: dict = None, ttl: int = 30): - """Fetch from Jupiter price API with caching.""" - cache_key = f"jp:{path}:{json.dumps(params or {}, sort_keys=True)}" - cached = _cache_get(cache_key) - if cached is not None: - return cached - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"{JUPITER_BASE}{path}", params=params) - if r.status_code == 200: - data = r.json() - _cache_set(cache_key, data, ttl=ttl) - return data - except Exception: - pass - return None - -def _extract_pair_from_dexscreener(data: dict) -> dict | None: - """Extract the best pair from a DexScreener response.""" - if isinstance(data, dict): - pairs = data.get("pairs") - if isinstance(pairs, list) and len(pairs) > 0: - return pairs[0] - elif isinstance(data, list) and len(data) > 0: - return data[0] - return None - -def _compute_risk_flags(pair: dict, holders: list) -> list: - """Compute risk flags from pair data and holder distribution.""" - flags = [] - liq = pair.get("liquidity", {}) - usd_liq = liq.get("usd", 0) if isinstance(liq, dict) else (liq if isinstance(liq, (int, float)) else 0) - if usd_liq < 50000: - flags.append("Low Liquidity") - if usd_liq < 10000: - flags.append("Very Low Liquidity") - - if holders: - top_pct = max((h.get("pct", 0) for h in holders), default=0) - if top_pct > 20: - flags.append("High Concentration") - if top_pct > 40: - flags.append("Very High Concentration") - - exchange_count = sum(1 for h in holders if "exchange" in h.get("label", "").lower()) - if exchange_count >= 3: - flags.append("Multiple Exchange Holdings") - - vol = pair.get("volume", {}) - h24 = vol.get("h24", 0) if isinstance(vol, dict) else (vol if isinstance(vol, (int, float)) else 0) - if h24 > 0 and usd_liq > 0 and h24 / usd_liq > 0.1: - flags.append("High Volume/TV Ratio") - - pc = pair.get("priceChange", {}) - h24_change = pc.get("h24", 0) if isinstance(pc, dict) else 0 - if h24_change < -10: - flags.append("Steep Price Decline") - elif h24_change > 10: - flags.append("Rapid Price Increase") - - if not flags: - flags.append("Normal") - return flags - -# ═══════════════════════════════════════════════════════════ -# PYDANTIC MODELS (must be defined before endpoints that use them) -# ═══════════════════════════════════════════════════════════ - -class AuthLoginRequest(BaseModel): - email: str - password: str - -class AuthRegisterRequest(BaseModel): - email: str - password: str - username: str - -class AuthWalletRequest(BaseModel): - address: str - chain: str - provider: str - -class AuthSocialRequest(BaseModel): - provider: str # google, discord, twitter - code: Optional[str] = None - redirect_uri: Optional[str] = None - -class AiChatRequest(BaseModel): - message: str - mode: Optional[str] = "general" - context: Optional[dict] = None - -# ═══════════════════════════════════════════════════════════ -# INTELLIGENCE PANEL CORE ENDPOINTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/newsletter/latest") -async def get_latest_newsletter(request: Request): - """Get latest newsletter for intelligence panel.""" - return { - "title": "Weekly Intelligence Digest: Market Volatility Ahead", - "excerpt": "New patterns emerging in memecoin behavior with increased whale activity. Watch for proxy contract rug attempts.", - "content": "## Weekly Intelligence Digest\n\n**Market Overview**\nIncreased volatility expected as we approach key resistance levels.\n\n**Key Alerts**\n- 3 new potential honeypots identified\n- Whale movements suggesting accumulation phase\n- New proxy contract patterns detected\n\n**Alpha Opportunities**\nSeveral tokens showing early accumulation signals.", - "published_at": "2026-05-13T08:00:00Z", - "category": "weekly_digest", - "html": "
## Weekly Intelligence Digest

Increased volatility expected...

" - } - -@app.get("/api/v1/content/news") -async def get_content_news(request: Request, limit: int = 20): - """Get REAL news from 30+ RSS feeds, Twitter, and Reddit — 10min cache.""" - articles = await _fetch_all_news(limit=max(limit, 30)) - return { - "articles": articles[:limit], - "total": len(articles), - "sources": sorted(set(a.get("source", "") for a in articles)), - "cached": bool(NEWS_CACHE), - } - -import random - -NEWS_CACHE = [] -NEWS_CACHE_TS = None - -# ═══════════════════════════════════════════════════════════ -# RMI INTELLIGENCE AGGREGATOR — 30+ real crypto news sources -# RSS feeds, Twitter/X, newsletters, journals -# ═══════════════════════════════════════════════════════════ - -RSS_FEEDS = [ - # Tier 1 — Major Crypto News (credibility: 0.90-0.95) - ("CoinDesk", "https://www.coindesk.com/arc/outboundfeeds/rss/", 0.95), - ("CoinTelegraph", "https://cointelegraph.com/rss", 0.92), - ("Decrypt", "https://decrypt.co/feed", 0.90), - ("Blockworks", "https://blockworks.co/feed", 0.93), - ("The Block", "https://www.theblock.co/rss", 0.94), - # Tier 2 — Established Crypto News (credibility: 0.75-0.88) - ("Bitcoin Magazine", "https://bitcoinmagazine.com/.rss/full/", 0.85), - ("Bitcoin.com", "https://news.bitcoin.com/feed/", 0.82), - ("BeInCrypto", "https://beincrypto.com/feed/", 0.80), - ("CryptoPotato", "https://cryptopotato.com/feed/", 0.78), - ("NewsBTC", "https://www.newsbtc.com/feed/", 0.75), - ("Bitcoinist", "https://bitcoinist.com/feed/", 0.72), - ("U.Today", "https://u.today/rss", 0.70), - ("AMB Crypto", "https://ambcrypto.com/feed/", 0.72), - ("ZyCrypto", "https://zycrypto.com/feed/", 0.68), - ("Coin Edition", "https://coinedition.com/feed/", 0.65), - ("The Merkle", "https://themerkle.com/feed/", 0.60), - ("CoinSpeaker", "https://www.coinspeaker.com/feed/", 0.62), - # Tier 3 — DeFi / Niche / Alpha (credibility: 0.55-0.85) - ("The Defiant", "https://thedefiant.io/feed", 0.85), - ("Bankless", "https://www.bankless.com/feed", 0.80), - ("DailyCoin", "https://dailycoin.com/feed/", 0.70), - ("CoinJournal", "https://coinjournal.net/feed/", 0.72), - ("Coinpedia", "https://coinpedia.org/feed/", 0.65), - ("WatcherGuru", "https://watcher.guru/news/feed", 0.55), - ("CryptoNews", "https://cryptonews.com/news/feed/", 0.68), - ("The Daily Hodl", "https://dailyhodl.com/feed/", 0.65), - ("AltcoinBuzz", "https://www.altcoinbuzz.io/feed/", 0.55), - ("TrustNodes", "https://www.trustnodes.com/feed", 0.75), - # Tier 4 — Research / Regulation (credibility: 0.85-0.95) - ("Chainalysis", "https://blog.chainalysis.com/feed/", 0.95), - ("Coin Center", "https://www.coincenter.org/feed/", 0.90), - ("Trail of Bits", "https://blog.trailofbits.com/feed/", 0.95), - ("CertiK", "https://certik.medium.com/feed", 0.92), - ("PeckShield", "https://peckshield.medium.com/feed", 0.90), - ("SlowMist", "https://slowmist.medium.com/feed", 0.90), - ("OpenZeppelin", "https://blog.openzeppelin.com/feed/", 0.93), - # Tier 5 — Exchange & Institutional - ("Kraken Blog", "https://blog.kraken.com/feed/", 0.90), - ("Binance Blog", "https://www.binance.com/en/feed/news", 0.85), - ("Coinbase Blog", "https://blog.coinbase.com/feed", 0.88), - ("Gemini Blog", "https://www.gemini.com/blog/feed", 0.85), - ("Bitfinex Blog", "https://blog.bitfinex.com/feed/", 0.82), - ("Ledger Blog", "https://blog.ledger.com/feed/", 0.88), - ("Trezor Blog", "https://blog.trezor.io/feed", 0.88), - # Tier 6 — Protocol & Foundation - ("Ethereum Foundation", "https://blog.ethereum.org/feed.xml", 0.95), - ("Solana News", "https://solana.com/news/feed", 0.88), - ("Polygon Blog", "https://polygon.technology/blog/feed", 0.85), - ("Arbitrum Blog", "https://blog.arbitrum.io/feed", 0.85), - ("Optimism Blog", "https://blog.optimism.io/feed", 0.85), - ("Base Blog", "https://blog.base.org/feed", 0.85), - ("Dune Analytics", "https://dune.com/blog/feed", 0.90), - ("Flashbots", "https://collective.flashbots.net/latest.rss", 0.90), - ("Lido Blog", "https://blog.lido.fi/feed", 0.85), - ("1inch Blog", "https://blog.1inch.io/feed", 0.82), - ("a16z Crypto", "https://a16zcrypto.com/feed/", 0.90), - ("Paradigm", "https://www.paradigm.xyz/feed", 0.90), -] - -# Twitter/X crypto news accounts via Nitter RSS -TWITTER_FEEDS = [ - # News organizations - ("@CoinDesk", "https://nitter.net/CoinDesk/rss", 0.92), - ("@Cointelegraph", "https://nitter.net/Cointelegraph/rss", 0.90), - ("@TheBlock", "https://nitter.net/TheBlock/rss", 0.90), - ("@decryptmedia", "https://nitter.net/decryptmedia/rss", 0.88), - ("@CryptoSlate", "https://nitter.net/CryptoSlate/rss", 0.82), - ("@MessariCrypto", "https://nitter.net/MessariCrypto/rss", 0.88), - # Market data / aggregators - ("@CoinMarketCap", "https://nitter.net/CoinMarketCap/rss", 0.85), - ("@coingecko", "https://nitter.net/coingecko/rss", 0.85), - ("@DefiLlama", "https://nitter.net/DefiLlama/rss", 0.88), - ("@WuBlockchain", "https://nitter.net/WuBlockchain/rss", 0.88), - # On-chain data / analytics - ("@lookonchain", "https://nitter.net/lookonchain/rss", 0.85), - ("@spotonchain", "https://nitter.net/spotonchain/rss", 0.82), - ("@nansen_ai", "https://nitter.net/nansen_ai/rss", 0.85), - ("@glassnode", "https://nitter.net/glassnode/rss", 0.90), - ("@Delphi_Digital", "https://nitter.net/Delphi_Digital/rss", 0.88), - ("@tokenterminal", "https://nitter.net/tokenterminal/rss", 0.82), - # Alpha / DeFi / Degens - ("@DegenerateNews", "https://nitter.net/DegenerateNews/rss", 0.60), - ("@crypto_news", "https://nitter.net/crypto_news/rss", 0.55), - ("@DefiIgnas", "https://nitter.net/DefiIgnas/rss", 0.72), - ("@Route2FI", "https://nitter.net/Route2FI/rss", 0.65), - ("@CryptoWizardd", "https://nitter.net/CryptoWizardd/rss", 0.55), - ("@alpha_pls", "https://nitter.net/alpha_pls/rss", 0.50), - ("@CryptoMichNL", "https://nitter.net/CryptoMichNL/rss", 0.60), - ("@CryptoCapo_", "https://nitter.net/CryptoCapo_/rss", 0.50), - ("@CryptoRover", "https://nitter.net/CryptoRover/rss", 0.50), - ("@CryptoTony__", "https://nitter.net/CryptoTony__/rss", 0.50), - ("@MacnBTC", "https://nitter.net/MacnBTC/rss", 0.55), - # Key individuals - ("@cz_binance", "https://nitter.net/cz_binance/rss", 0.80), - ("@saylor", "https://nitter.net/saylor/rss", 0.75), - ("@VitalikButerin", "https://nitter.net/VitalikButerin/rss", 0.90), - ("@aantonop", "https://nitter.net/aantonop/rss", 0.88), - ("@crypto", "https://nitter.net/crypto/rss", 0.60), - ("@DegenSpartan", "https://nitter.net/DegenSpartan/rss", 0.50), - ("@cobie", "https://nitter.net/cobie/rss", 0.60), - ("@CryptoHayes", "https://nitter.net/CryptoHayes/rss", 0.70), - # Security — scam/exploit alerts (HIGH VALUE) - ("@PeckShieldAlert", "https://nitter.net/PeckShieldAlert/rss", 0.93), - ("@CertiKAlert", "https://nitter.net/CertiKAlert/rss", 0.92), - ("@SlowMist_Team", "https://nitter.net/SlowMist_Team/rss", 0.90), - ("@zachxbt", "https://nitter.net/zachxbt/rss", 0.88), - ("@BlockSecTeam", "https://nitter.net/BlockSecTeam/rss", 0.85), - ("@BeosinAlert", "https://nitter.net/BeosinAlert/rss", 0.82), - ("@MistTrack_io", "https://nitter.net/MistTrack_io/rss", 0.80), - ("@Phalcon_xyz", "https://nitter.net/Phalcon_xyz/rss", 0.80), - ("@Chainalysis", "https://nitter.net/Chainalysis/rss", 0.90), - ("@immunefi", "https://nitter.net/immunefi/rss", 0.85), - ("@RektHQ", "https://nitter.net/RektHQ/rss", 0.82), - # Top analysts & data - ("@MilkRoadDaily", "https://nitter.net/MilkRoadDaily/rss", 0.82), - ("@BanklessHQ", "https://nitter.net/BanklessHQ/rss", 0.80), - ("@CoinBureau", "https://nitter.net/CoinBureau/rss", 0.78), - ("@unfolded_", "https://nitter.net/unfolded_/rss", 0.80), - ("@CryptoKaleo", "https://nitter.net/CryptoKaleo/rss", 0.65), - ("@IncomeSharks", "https://nitter.net/IncomeSharks/rss", 0.62), - ("@rektcapital", "https://nitter.net/rektcapital/rss", 0.60), - ("@CryptoDonAlt", "https://nitter.net/CryptoDonAlt/rss", 0.60), - ("@HsakaTrades", "https://nitter.net/HsakaTrades/rss", 0.58), - ("@0xfoobar", "https://nitter.net/0xfoobar/rss", 0.72), - ("@gabrielhaines", "https://nitter.net/gabrielhaines/rss", 0.55), - ("@loomdart", "https://nitter.net/loomdart/rss", 0.52), - ("@blknoiz06", "https://nitter.net/blknoiz06/rss", 0.65), - ("@tedtalksmacro", "https://nitter.net/tedtalksmacro/rss", 0.70), - ("@woonomic", "https://nitter.net/woonomic/rss", 0.82), - ("@100trillionUSD", "https://nitter.net/100trillionUSD/rss", 0.75), - ("@sassal0x", "https://nitter.net/sassal0x/rss", 0.72), - ("@TrustlessState", "https://nitter.net/TrustlessState/rss", 0.68), - ("@bantg", "https://nitter.net/bantg/rss", 0.60), - ("@ercwl", "https://nitter.net/ercwl/rss", 0.68), - ("@0xngmi", "https://nitter.net/0xngmi/rss", 0.65), - # Institutional / VC - ("@a16zcrypto", "https://nitter.net/a16zcrypto/rss", 0.88), - ("@paradigm", "https://nitter.net/paradigm/rss", 0.85), - ("@PanteraCapital", "https://nitter.net/PanteraCapital/rss", 0.85), - ("@MulticoinCap", "https://nitter.net/MulticoinCap/rss", 0.82), - ("@dragonfly_xyz", "https://nitter.net/dragonfly_xyz/rss", 0.80), - ("@1confirmation", "https://nitter.net/1confirmation/rss", 0.80), - ("@ElectricCapital", "https://nitter.net/ElectricCapital/rss", 0.85), - ("@variantfund", "https://nitter.net/variantfund/rss", 0.82), - ("@cbventures", "https://nitter.net/cbventures/rss", 0.82), - # DeFi / Protocol - ("@DeFi_Dad", "https://nitter.net/DeFi_Dad/rss", 0.62), - ("@fintechfrank", "https://nitter.net/fintechfrank/rss", 0.60), - ("@santiagoroel", "https://nitter.net/santiagoroel/rss", 0.58), - ("@DeFiSaver", "https://nitter.net/DeFiSaver/rss", 0.65), - ("@CurveFinance", "https://nitter.net/CurveFinance/rss", 0.80), - ("@AaveAave", "https://nitter.net/AaveAave/rss", 0.82), - ("@Uniswap", "https://nitter.net/Uniswap/rss", 0.85), - ("@LidoFinance", "https://nitter.net/LidoFinance/rss", 0.80), - ("@MakerDAO", "https://nitter.net/MakerDAO/rss", 0.82), - ("@chainlink", "https://nitter.net/chainlink/rss", 0.85), - ("@Ethereum", "https://nitter.net/Ethereum/rss", 0.90), - ("@Solana", "https://nitter.net/Solana/rss", 0.85), - ("@arbitrum", "https://nitter.net/arbitrum/rss", 0.85), - ("@Optimism", "https://nitter.net/Optimism/rss", 0.85), - ("@base", "https://nitter.net/base/rss", 0.82), -] - -# Reddit crypto subreddits (via XML parsing, not feedparser) -REDDIT_SUBS = [ - "CryptoCurrency", "Bitcoin", "ethereum", "solana", "defi", - "CryptoMarkets", "ethfinance", "CryptoTechnology", "ethdev", - "CryptoScams", "CryptoMoonShots", "altcoin", "CryptoTrading", - "NFT", "web3", "layer2", "CryptoGaming", "Crypto_General", - "SatoshiStreetBets", "CryptoCurrencyTrading", -] - -def _detect_sentiment(text: str) -> str: - """Multi-heuristic sentiment detection from headline text.""" - tl = text.lower() - bullish = ["bullish", "surge", "pump", "rally", "breakout", "ath", "all-time high", - "spike", "euphoria", "adopt", "approve", "etf inflow", "accumulat", "buy signal", - "record high", "milestone", "partnership", "launch", "upgrade", "clarity", - "green light", "soar", "skyrocket", "moon", "explode", "outperform", - "beat", "beat expectations", "massive", "billions", "mainstream"] - bearish = ["bearish", "dump", "crash", "hack", "rug", "scam", "exploit", "slide", - "plunge", "tumble", "outflow", "sell-off", "short", "lawsuit", "sec", "crackdown", - "ban", "probe", "investigation", "loss", "doubt", "drawdown", "downturn", - "liquidat", "bankrupt", "warning", "risk", "volatil", "uncertain", - "decline", "drop", "fall", "downgrade", "penalty", "fine", "charge", - "indict", "fraud", "ponzi", "default", "delist"] - bull_score = sum(1 for w in bullish if w in tl) - bear_score = sum(1 for w in bearish if w in tl) - if bull_score > bear_score + 1: return "bullish" - if bear_score > bull_score + 1: return "bearish" - if bull_score > bear_score: return "slightly_bullish" - if bear_score > bull_score: return "slightly_bearish" - return "neutral" - -def _classify_category(title: str, excerpt: str = "") -> str: - """Classify article into category based on content.""" - t = (title + " " + excerpt).lower() - if any(w in t for w in ["defi", "yield", "liquidity pool", "amm", "lending", "borrow", "tvl"]): - return "defi" - if any(w in t for w in ["nft", "collectible", "bored ape", "cryptopunk"]): - return "nft" - if any(w in t for w in ["sec", "regulation", "lawsuit", "court", "cf tc", "compliance", "legal", "ban", "crackdown", "clarity act"]): - return "regulation" - if any(w in t for w in ["hack", "exploit", "scam", "rug", "drain", "phish", "attack", "stolen", "breach"]): - return "security" - if any(w in t for w in ["layer", "l2", "rollup", "zk", "zero-knowledge", "upgrade", "fork", "protocol", "mainnet", "testnet", "smart contract"]): - return "tech" - if any(w in t for w in ["price", "market", "btc", "eth", "sol", "trading", "chart", "analysis", "bull", "bear", "correction"]): - return "market" - if any(w in t for w in ["mining", "bitcoin mining", "hash", "asic", "pow"]): - return "mining" - if any(w in t for w in ["etf", "institution", "bank", "blackrock", "fidelity", "grayscale"]): - return "institutional" - if any(w in t for w in ["airdrop", "token launch", "ido", "ieo", "presale", "whitelist"]): - return "alpha" - return "news" - -def _extract_image(entry) -> str: - """Extract image URL from RSS entry if available.""" - # Try media_content - if hasattr(entry, 'media_content') and entry.media_content: - for m in entry.media_content: - if m.get('url'): return m['url'] - # Try media_thumbnail - if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail: - for m in entry.media_thumbnail: - if m.get('url'): return m['url'] - # Try links - if hasattr(entry, 'links'): - for link in entry.links: - if link.get('type', '').startswith('image/'): - return link.get('href', '') - if link.get('rel') == 'enclosure' and 'image' in link.get('type', ''): - return link.get('href', '') - # Try extracting from summary/description HTML - import re - desc = entry.get('summary', '') or entry.get('description', '') or '' - img_match = re.search(r']+src=["\']([^"\']+)["\']', desc) - if img_match: - return img_match.group(1) - return "" - -async def _fetch_rss_news(limit: int = 30) -> list: - """Pull real articles from 25+ RSS feeds with images, sentiment, categories.""" - import concurrent.futures, re - out = [] - def _fetch_one(name, url, credibility): - try: - f = feedparser.parse(url) - items = [] - for e in f.entries[:3]: - pub = e.get("published", "") or e.get("updated", "") - try: - ts = email_utils.parsedate_to_datetime(pub).isoformat() - except Exception: - ts = datetime.now(timezone.utc).isoformat() - excerpt = (e.get("summary", "") or e.get("description", ""))[:300] - # Strip HTML from excerpt - excerpt_clean = re.sub(r'<[^>]+>', '', excerpt).strip()[:250] - title = e.get("title", "Untitled") - image = _extract_image(e) - items.append({ - "id": hashlib.md5((url + e.get("link", "")).encode()).hexdigest()[:12], - "title": title, - "url": e.get("link", "#"), - "source": name, - "source_credibility": credibility, - "excerpt": excerpt_clean, - "image_url": image, - "published_at": ts, - "category": _classify_category(title, excerpt_clean), - "sentiment": _detect_sentiment(title), - "reading_time": max(1, len(excerpt_clean) // 700), - "ai_score": round(credibility * 10, 1), - }) - return items - except Exception: - return [] - # Use run_in_executor to avoid blocking the event loop - loop = asyncio.get_event_loop() - with concurrent.futures.ThreadPoolExecutor(max_workers=15) as ex: - futures = {ex.submit(_fetch_one, name, url, cred): name for name, url, cred in RSS_FEEDS} - done, _ = concurrent.futures.wait(futures, timeout=40) - for fut in done: - try: - out.extend(fut.result()) - except Exception: - pass - out.sort(key=lambda x: x.get("published_at", ""), reverse=True) - return out[:limit] - -async def _fetch_twitter_news(limit: int = 15) -> list: - """Pull real tweets from top crypto Twitter accounts via Nitter RSS.""" - import concurrent.futures, re - out = [] - def _fetch_one(name, url, credibility): - try: - f = feedparser.parse(url) - items = [] - for e in f.entries[:8]: - pub = e.get("published", "") or e.get("updated", "") - try: - ts = email_utils.parsedate_to_datetime(pub).isoformat() - except Exception: - ts = datetime.now(timezone.utc).isoformat() - title = e.get("title", "")[:200] - # Clean RT prefix - title_clean = re.sub(r'^RT by @\w+:\s*', '', title).strip() - items.append({ - "id": hashlib.md5(("twitter_" + name + title).encode()).hexdigest()[:12], - "title": title_clean, - "url": e.get("link", "#"), - "source": f"@{name}" if not name.startswith("@") else name, - "source_credibility": credibility, - "excerpt": title_clean[:250], - "image_url": "", - "published_at": ts, - "category": "twitter", - "sentiment": _detect_sentiment(title_clean), - "reading_time": 1, - "ai_score": round(credibility * 9, 1), - "is_twitter": True, - }) - return items - except Exception: - return [] - with concurrent.futures.ThreadPoolExecutor(max_workers=12) as ex: - futures = {ex.submit(_fetch_one, name, url, cred): name for name, url, cred in TWITTER_FEEDS} - done, _ = concurrent.futures.wait(futures, timeout=30) - for fut in done: - try: - out.extend(fut.result()) - except Exception: - pass - out.sort(key=lambda x: x.get("published_at", ""), reverse=True) - return out[:limit] - -async def _fetch_reddit_news(limit: int = 15) -> list: - """Pull real posts from crypto subreddits via Reddit RSS XML.""" - import concurrent.futures, re - from xml.etree import ElementTree as ET - out = [] - def _fetch_one(sub): - try: - req = urllib.request.Request( - f'https://www.reddit.com/r/{sub}/.rss?limit=10', - headers={'User-Agent': 'python-requests/2.31.0'} - ) - resp = urllib.request.urlopen(req, timeout=10) - raw = resp.read().decode('utf-8', errors='replace') - root = ET.fromstring(raw) - ns = '{http://www.w3.org/2005/Atom}' - items = [] - for entry in root.findall(f'.//{ns}entry'): - title_el = entry.find(f'{ns}title') - link_el = entry.find(f'{ns}link') - updated_el = entry.find(f'{ns}updated') - author_el = entry.find(f'{ns}author') - author_name = '' - if author_el is not None: - name_el = author_el.find(f'{ns}name') - if name_el is not None: - author_name = name_el.text or '' - title = title_el.text if title_el is not None and title_el.text else '' - link = link_el.get('href', '') if link_el is not None else '' - updated = updated_el.text if updated_el is not None and updated_el.text else '' - items.append({ - "id": hashlib.md5(("reddit_" + sub + title).encode()).hexdigest()[:12], - "title": title, - "url": link, - "source": f"r/{sub}", - "source_credibility": 0.50, - "excerpt": title[:250], - "image_url": "", - "published_at": updated, - "category": "community", - "sentiment": _detect_sentiment(title), - "reading_time": 1, - "ai_score": 5.0, - "is_reddit": True, - }) - return items - except Exception: - return [] - with concurrent.futures.ThreadPoolExecutor(max_workers=5) as ex: - futures = {ex.submit(_fetch_one, sub): sub for sub in REDDIT_SUBS} - done, _ = concurrent.futures.wait(futures, timeout=20) - for fut in done: - try: - out.extend(fut.result()) - except Exception: - pass - out.sort(key=lambda x: x.get("published_at", ""), reverse=True) - return out[:limit] - -async def _fetch_all_news(limit: int = 50) -> list: - """Get real news from 30 RSS + 20 Twitter + 9 Reddit feeds. No fake items.""" - global NEWS_CACHE, NEWS_CACHE_TS - if NEWS_CACHE and NEWS_CACHE_TS and (datetime.now(timezone.utc) - NEWS_CACHE_TS) < timedelta(minutes=10): - return NEWS_CACHE[:limit] - # Run all three fetchers in parallel - rss, twitter, reddit = await asyncio.gather( - _fetch_rss_news(limit=150), - _fetch_twitter_news(limit=50), - _fetch_reddit_news(limit=30), - return_exceptions=True - ) - all_articles = [] - if not isinstance(rss, Exception): all_articles.extend(rss) - if not isinstance(twitter, Exception): all_articles.extend(twitter) - if not isinstance(reddit, Exception): all_articles.extend(reddit) - seen = set() - unique = [] - for a in all_articles: - u = a.get("url", "") - if u and u != "#" and u not in seen: - seen.add(u) - unique.append(a) - unique.sort(key=lambda x: x.get("published_at", ""), reverse=True) - NEWS_CACHE = unique - NEWS_CACHE_TS = datetime.now(timezone.utc) - return NEWS_CACHE[:limit] - -@app.get("/api/v1/news/combined") -async def get_combined_news( - limit: int = 50, - sentiment: Optional[str] = None, - source: Optional[str] = None, - category: Optional[str] = None, -): - """Get combined news from CoinGecko + internal RMI intelligence.""" - articles = await _fetch_all_news(limit=100) - if sentiment: - articles = [a for a in articles if a.get("sentiment", "").lower() == sentiment.lower()] - if source: - articles = [a for a in articles if source.lower() in a.get("source", "").lower()] - if category: - articles = [a for a in articles if category.lower() in a.get("category", "").lower()] - articles = articles[:limit] - return { - "status": "success", - "articles": articles, - "total": len(articles), - "sources": sorted(set(a.get("source", "") for a in articles)), - "cached": bool(NEWS_CACHE), - "fetched_at": (NEWS_CACHE_TS or datetime.now(timezone.utc)).isoformat(), - } - -@app.get("/api/v1/content/trending") -async def get_content_trending(request: Request): - """Get trending content for intelligence panel — real CoinGecko trending data.""" - data = await _coingecko_get("/search/trending", ttl=120) - trending = [] - if data and "coins" in data: - for item in data["coins"][:5]: - coin = item.get("item", {}) - trending.append({ - "title": f"{coin.get('name', '')} ({coin.get('symbol', '')}) — CoinGecko Trending #{item.get('score', '?')}", - "url": f"https://www.coingecko.com/en/coins/{coin.get('id', '')}", - "trending_score": item.get("score", 0), - "category": "trending_crypto", - }) - if not trending: - trending = [ - {"title": "Trending data temporarily unavailable", "url": "/markets", "trending_score": 0, "category": "market_analysis"} - ] - return trending - -@app.get("/api/v1/content/trending-chains") -async def get_content_trending_chains(request: Request): - """Get real blockchain chain data from DeFiLlama for DataMarketPage chain filter.""" - chains = [] - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get("https://api.llama.fi/v2/chains") - if r.status_code == 200: - data = r.json() - # Filter to top chains with crypto relevance - top = [ch for ch in data if ch.get("tvl", 0) > 100_000_000][:15] - for ch in top: - chains.append({ - "name": ch.get("name", ""), - "chain_id": ch.get("chainId", ch.get("gecko_id", "")), - "token_symbol": ch.get("tokenSymbol", ""), - "tvl_usd": ch.get("tvl", 0), - "change_1h": ch.get("change_1h", 0) or 0, - "change_1d": ch.get("change_1d", 0) or 0, - "change_7d": ch.get("change_7d", 0) or 0, - "protocol_count": ch.get("protocols", 0) or 0, - }) - except Exception: - pass - - # Fallback: standard chain list - if not chains: - default_chains = [ - ("Ethereum", "ethereum", "ETH", 55e9), ("Solana", "solana", "SOL", 8e9), - ("Base", "base", "ETH", 4e9), ("BSC", "bsc", "BNB", 5e9), - ("Arbitrum", "arbitrum", "ETH", 3e9), ("Polygon", "polygon", "MATIC", 1.5e9), - ("Avalanche", "avalanche", "AVAX", 1.2e9), ("Optimism", "optimism", "ETH", 1e9), - ("Sui", "sui", "SUI", 1.5e9), ("Aptos", "aptos", "APT", 0.8e9), - ("Tron", "tron", "TRX", 7e9), ("Bitcoin", "bitcoin", "BTC", 1.5e12), - ] - for name, cid, sym, tvl in default_chains: - chains.append({"name": name, "chain_id": cid, "token_symbol": sym, "tvl_usd": tvl, - "change_1h": 0, "change_1d": 0, "change_7d": 0, "protocol_count": 10}) - - return {"chains": chains, "count": len(chains)} - -@app.get("/api/v1/content/market-overview") -async def get_content_market_overview(request: Request): - """Get market overview — returns shape DataMarketPage expects.""" - now = datetime.now(timezone.utc).isoformat() - global_data = await _coingecko_get("/global", ttl=120) - price_data = await _coingecko_get( - "/simple/price", - params={"ids": "bitcoin,ethereum,solana", "vs_currencies": "usd", "include_market_cap": "true", "include_24hr_vol": "true", "include_24hr_change": "true"}, - ttl=60, - ) - trending_data = await _coingecko_get("/search/trending", ttl=120) - - # Default fallback prices - btc_usd = 78000; btc_change = 0; btc_mcap = 1.5e12; btc_vol = 25e9 - eth_usd = 2200; eth_change = 0; eth_mcap = 270e9; eth_vol = 12e9 - sol_usd = 88; sol_change = 0; sol_mcap = 50e9; sol_vol = 3e9 - - if price_data: - b = price_data.get("bitcoin", {}) - e = price_data.get("ethereum", {}) - s = price_data.get("solana", {}) - btc_usd = b.get("usd", btc_usd) - eth_usd = e.get("usd", eth_usd) - sol_usd = s.get("usd", sol_usd) - btc_change = b.get("usd_24h_change", btc_change) - eth_change = e.get("usd_24h_change", eth_change) - sol_change = s.get("usd_24h_change", sol_change) - btc_mcap = b.get("usd_market_cap", btc_mcap) - eth_mcap = e.get("usd_market_cap", eth_mcap) - sol_mcap = s.get("usd_market_cap", sol_mcap) - btc_vol = b.get("usd_24h_vol", btc_vol) - eth_vol = e.get("usd_24h_vol", eth_vol) - sol_vol = s.get("usd_24h_vol", sol_vol) - - total_mcap = btc_mcap + eth_mcap + sol_mcap - mcap_change = 0 - if global_data and "data" in global_data: - d = global_data["data"] - total_mcap = d.get("total_market_cap", {}).get("usd", total_mcap) - mcap_change = d.get("market_cap_change_percentage_24h_usd", 0) - - trending_list = [] - if trending_data and "coins" in trending_data: - for item in trending_data["coins"][:5]: - coin = item.get("item", {}) - trending_list.append({ - "name": coin.get("name", ""), - "symbol": coin.get("symbol", ""), - "market_cap_rank": coin.get("market_cap_rank"), - "price_btc": coin.get("price_btc"), - }) - - return { - "prices": { - "bitcoin": {"price_usd": btc_usd, "change_24h": btc_change, "market_cap_usd": btc_mcap, "volume_24h_usd": btc_vol}, - "ethereum": {"price_usd": eth_usd, "change_24h": eth_change, "market_cap_usd": eth_mcap, "volume_24h_usd": eth_vol}, - "solana": {"price_usd": sol_usd, "change_24h": sol_change, "market_cap_usd": sol_mcap, "volume_24h_usd": sol_vol}, - }, - # Backward-compatible flat keys for HomePage - "total_market_cap": total_mcap, - "btc_price": btc_usd, - "eth_price": eth_usd, - "sol_price": sol_usd, - "total_market_cap_usd": total_mcap, - "market_cap_change_24h": mcap_change, - "trending": trending_list, - "trending_tokens": trending_list, - "fetched_at": now, - "updated_at": now, - } - -# ═══════════════════════════════════════════════════════════ -# MARKET DATA ENDPOINTS (for MarketsPage, DataMarketPage) -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/markets/overview") -async def get_markets_overview(request: Request): - """Alias for /content/market-overview — used by DataMarketPage.""" - return await get_content_market_overview() - -@app.get("/api/markets/overview") -async def get_markets_overview_v0(request: Request): - """V0 alias for MarketsPage (no /v1 prefix).""" - return await get_content_market_overview() - -# Trending cache -TRENDING_CACHE = None -TRENDING_CACHE_TS = None - -@app.get("/api/v1/markets/trending") -async def get_markets_trending(request: Request): - """Real trending degen tokens from DexScreener boosts + price enrichment. Cached 60s.""" - global TRENDING_CACHE, TRENDING_CACHE_TS - if TRENDING_CACHE and TRENDING_CACHE_TS and (datetime.now(timezone.utc) - TRENDING_CACHE_TS) < timedelta(seconds=60): - return TRENDING_CACHE - tokens = [] - try: - async with httpx.AsyncClient(timeout=10) as c: - # Get trending boosts from DexScreener - r = await c.get("https://api.dexscreener.com/token-boosts/latest/v1") - if r.status_code == 200: - boosts = r.json() - # Enrich with price data in parallel - async def enrich(boost): - try: - addr = boost.get("tokenAddress", "") - chain = boost.get("chainId", "solana") - # Get pair data for price/volume - r2 = await c.get(f"https://api.dexscreener.com/latest/dex/search?q={addr}") - if r2.status_code == 200: - pairs_data = r2.json() - pairs = pairs_data.get("pairs", []) - if pairs: - p = pairs[0] - return { - "address": addr, - "name": p.get("baseToken", {}).get("name", boost.get("description","")[:20]), - "symbol": p.get("baseToken", {}).get("symbol", "").upper(), - "chain": chain, - "price_usd": float(p.get("priceUsd", 0) or 0), - "change_24h": float((p.get("priceChange", {}) or {}).get("h24", 0) or 0), - "volume_24h": float((p.get("volume", {}) or {}).get("h24", 0) or 0), - "liquidity_usd": float((p.get("liquidity", {}) or {}).get("usd", 0) or 0), - "market_cap": float(p.get("fdv", 0) or 0), - "icon": p.get("info", {}).get("imageUrl", "") or p.get("baseToken", {}).get("image", ""), - "dex": p.get("dexId", ""), - "url": p.get("url", ""), - "score": boost.get("totalAmount", 0) or 0, - } - except Exception: - pass - return None - - results = await asyncio.gather(*[enrich(b) for b in boosts[:25]], return_exceptions=True) - seen = set() - for t in results: - if t is not None and not isinstance(t, Exception): - key = f"{t.get('chain','')}:{t.get('symbol','')}" - if key not in seen: - seen.add(key) - tokens.append(t) - except Exception: - pass - - # Fallback: if DexScreener failed, use CoinGecko trending - if not tokens: - data = await _coingecko_get("/search/trending", ttl=120) - if data and "coins" in data: - for item in data["coins"][:20]: - coin = item.get("item", {}) - tokens.append({ - "name": coin.get("name", ""), - "symbol": coin.get("symbol", "").upper(), - "chain": "unknown", - "price_usd": coin.get("data", {}).get("price", 0) if coin.get("data") else 0, - "change_24h": 0, - "volume_24h": coin.get("data", {}).get("total_volume", 0) if coin.get("data") else 0, - "liquidity_usd": 0, - "market_cap": coin.get("data", {}).get("market_cap", 0) if coin.get("data") else 0, - "icon": coin.get("thumb", "") or coin.get("small", ""), - "dex": "", - "url": f"https://www.coingecko.com/en/coins/{coin.get('id','')}", - "score": coin.get("market_cap_rank", 0), - }) - - result = {"tokens": tokens, "count": len(tokens)} - TRENDING_CACHE = result - TRENDING_CACHE_TS = datetime.now(timezone.utc) - return result - -@app.get("/api/v1/markets/newsletter") -async def get_markets_newsletter(request: Request): - """Newsletter — alias for MarketsPage.""" - return await get_latest_newsletter() - -@app.get("/api/v1/markets/sentiment") -async def get_markets_sentiment(request: Request): - """Market sentiment — aggregated from news + social signals.""" - articles = await _fetch_all_news(limit=20) - sentiments = {"bullish": 0, "bearish": 0, "neutral": 0} - for a in articles: - s = a.get("sentiment", "neutral").lower() - if s in sentiments: - sentiments[s] += 1 - total = sum(sentiments.values()) or 1 - return { - "overall": max(sentiments, key=sentiments.get), - "breakdown": {k: round(v/total*100, 1) for k, v in sentiments.items()}, - "total_articles": len(articles), - } - -@app.get("/api/v1/markets/news") -async def get_markets_news(request: Request, limit: int = 20): - """Market news — combined news for MarketsPage.""" - articles = await _fetch_all_news(limit=limit) - return { - "articles": articles, - "total": len(articles), - } - -@app.get("/api/v1/markets/movers") -async def get_markets_movers(request: Request): - """Top market movers from CoinGecko.""" - data = await _coingecko_get("/coins/markets", params={"vs_currency": "usd", "order": "price_change_percentage", "per_page": 10, "page": 1, "sparkline": False}, ttl=60) - movers = [] - if data: - for c in data[:10]: - movers.append({ - "name": c.get("name", ""), - "symbol": c.get("symbol", "").upper(), - "price": c.get("current_price"), - "change_24h": c.get("price_change_percentage_24h"), - "market_cap": c.get("market_cap"), - "image": c.get("image", ""), - }) - return {"movers": movers} - -@app.get("/api/v1/markets/airdrops") -async def get_markets_airdrops(request: Request): - """Active airdrops — curated list.""" - return { - "airdrops": [ - {"name": "Example Protocol", "symbol": "EXM", "status": "active", "end_date": "2026-06-01", "estimated_value": "$50-200"}, - {"name": "LayerZero", "symbol": "ZRO", "status": "claimed", "end_date": "2026-05-01", "estimated_value": "$100-500"}, - ], - "count": 2, - } - -@app.get("/api/markets/newsletter") -async def get_markets_newsletter_v0(request: Request): - """V0 alias for MarketsPage (no /v1 prefix).""" - return await get_latest_newsletter() - -@app.get("/api/v1/markets/breakdown") -async def get_markets_breakdown(request: Request): - """Market breakdown — trending tokens from live data.""" - # Get trending tokens from existing endpoint - trending_data = await get_markets_trending(request) - - # Scam alerts come from news service - scam_alerts = [] - - # Build trending with risk scores - tokens = [] - for t in (trending_data.get("tokens", []) or [])[:10]: - tokens.append({ - "rank": len(tokens) + 1, - "symbol": t.get("symbol", "???"), - "name": t.get("name", ""), - "price": t.get("price_usd") or 0, - "change1h": 0, - "change24h": t.get("change_24h") or 0, - "volume": f"${(t.get('volume_h24') or 0):,.0f}" if t.get("volume_h24") else "N/A", - "liquidity": "N/A", - "holders": 0, - "age": "", - "aiScore": 50, - "risk": "medium", - "bundlerActivity": False, - }) - - return { - "tokens": tokens, - "scam_alerts": scam_alerts, - "count": len(tokens), - "updated_at": datetime.now(timezone.utc).isoformat(), - } - -@app.get("/api/v1/alerts/count") -async def get_alerts_count(request: Request): - """Live count of active threat alerts.""" - try: - from app.alert_pipeline import get_active_alert_count - count = await get_active_alert_count() - return {"count": count} - except: - return {"count": 0} - - -@app.get("/api/markets/trending") -async def get_markets_trending_v0(request: Request): - """V0 alias — trending tokens.""" - result = await get_markets_trending() - return result - -@app.get("/api/markets/sentiment") -async def get_markets_sentiment_v0(request: Request): - """V0 alias — market sentiment.""" - return await get_markets_sentiment() - -@app.get("/api/markets/news") -async def get_markets_news_v0(request: Request): - """V0 alias — market news.""" - return await get_markets_news() - -@app.get("/api/markets/movers") -async def get_markets_movers_v0(request: Request): - """V0 alias — market movers.""" - return await get_markets_movers() - -@app.get("/api/markets/airdrops") -async def get_markets_airdrops_v0(request: Request): - """V0 alias — airdrops.""" - return await get_markets_airdrops() - -# ═══════════════════════════════════════════════════════════ -# TOKEN ENDPOINTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/tokens/new") -async def get_new_tokens(request: Request, limit: int = 20): - """Recently scanned tokens from Redis cache.""" - import redis.asyncio as redis - import json - - try: - r = redis.Redis( - host=os.getenv('REDIS_HOST', 'dragonfly'), - port=int(os.getenv('REDIS_PORT', '6379')), - password=os.getenv('REDIS_PASSWORD', ''), - db=int(os.getenv('REDIS_DB', '0')), - decode_responses=True - ) - - # Get last N scanned tokens from scan_results list - results = await r.lrange("scan_results", 0, limit - 1) - await r.close() - - tokens = [] - for result_json in results: - result = json.loads(result_json) - data = result.get('data', {}) - pairs = data.get('pairs', []) - if pairs: - pair = pairs[0] - base_token = pair.get('baseToken', {}) - liquidity = pair.get('liquidity', {}) - volume = pair.get('volume', {}) - - tokens.append({ - "address": result.get('token_address', ''), - "name": base_token.get('name', 'Unknown'), - "symbol": base_token.get('symbol', 'UNKNOWN'), - "chain": result.get('chain', 'unknown'), - "price_usd": pair.get('priceUsd'), - "liquidity_usd": liquidity.get('usd', 0) if isinstance(liquidity, dict) else 0, - "volume_h24": volume.get('h24', 0) if isinstance(volume, dict) else 0, - "scanned_at": result.get('scanned_at', ''), - "risk_score": result.get('risk_score', 0), - "flags": result.get('flags', []), - }) - - return {"tokens": tokens, "count": len(tokens)} - except Exception as e: - logger.error(f"Failed to get scanned tokens: {e}") - return {"tokens": [], "count": 0, "error": str(e)} - -@app.get("/api/v1/tokens/trending") -async def get_trending_tokens(request: Request): - """Trending tokens from CoinGecko (DexScreener /dex/trending is dead).""" - data = await _coingecko_get("/search/trending", ttl=120) - tokens = [] - if data and "coins" in data: - for item in data["coins"][:20]: - coin = item.get("item", {}) - tokens.append({ - "address": coin.get("id", ""), - "name": coin.get("name", ""), - "symbol": coin.get("symbol", "").upper(), - "price_usd": coin.get("data", {}).get("price", 0) if coin.get("data") else None, - "price_btc": coin.get("price_btc"), - "liquidity_usd": None, - "volume_h24": coin.get("data", {}).get("total_volume", 0) if coin.get("data") else None, - "change_24h": coin.get("data", {}).get("price_change_percentage_24h", {}).get("usd", 0) if coin.get("data") else None, - "market_cap_rank": coin.get("market_cap_rank"), - "flags": [], - }) - return {"tokens": tokens, "count": len(tokens)} - -@app.get("/api/v1/tokens/discover") -async def discover_new_tokens(request: Request, chains: str = "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_new_tokens": total, - "scanned_at": datetime.now(timezone.utc).isoformat(), - } - except Exception as e: - return {"error": str(e), "chains": {}, "total_new_tokens": 0} - -@app.get("/api/v1/smart-money") -async def get_smart_money(request: Request): - """Smart money wallets — real-time trending + whale activity from DexScreener.""" - wallets = [] - try: - async with httpx.AsyncClient(timeout=15) as c: - # Use DexScreener to find trending pairs with high volume (= smart money activity) - r = await c.get("https://api.dexscreener.com/latest/dex/search?q=whale") - if r.status_code == 200: - pairs = r.json().get("pairs", []) - seen = set() - for p in sorted(pairs, key=lambda x: float(x.get("volume", {}).get("h24", 0) or 0), reverse=True)[:10]: - addr = p.get("pairAddress", "") - if addr and addr not in seen: - seen.add(addr) - vol = float(p.get("volume", {}).get("h24", 0) or 0) - wallets.append({ - "wallet": f"{addr[:8]}...{addr[-4:]}", - "total_value_usd": int(vol), - "strategy": f"High-volume pair: {p.get('baseToken', {}).get('symbol', '?')}/{p.get('chainId', 'solana')}", - "chain": p.get("chainId", "solana"), - "volume_24h_usd": vol, - "txns_h24": p.get("txns", {}).get("h24", {}).get("buys", 0) + p.get("txns", {}).get("h24", {}).get("sells", 0) - }) - except Exception: - pass - - if not wallets: - wallets = [{"wallet": "No data", "total_value_usd": 0, "strategy": "DexScreener API unavailable — retrying"}] - - return { - "smart_money_wallets": wallets, - "count": len(wallets), - "source": "dexscreener", - "updated_at": datetime.now(timezone.utc).isoformat(), - } - -@app.get("/api/v1/wallet/{address}/analysis") -async def get_wallet_analysis(request: Request, address: str, chain: str = "solana"): - """Analyze a wallet address using real on-chain data.""" - try: - if chain == "solana": - solscan = FreeSolscanClient() - # Get wallet holdings via portfolio endpoint - portfolio = solscan.account_portfolio(address) - tokens = [] - total_value = 0 - if portfolio: - for h in portfolio[:20]: - token_info = h.get("token", {}) - amount = float(h.get("amount", 0) or 0) / (10 ** (token_info.get("decimals", 0) or 1)) - price = float(h.get("priceUsdt", 0) or 0) - value = amount * price - total_value += value - tokens.append({ - "token": token_info.get("symbol", "?"), - "address": token_info.get("address", ""), - "amount": round(amount, 4), - "price_usd": price, - "value_usd": round(value, 2) - }) - - # Get recent transactions - txs = solscan.account_transactions(address, page_size=10) - recent = [] - for tx in (txs or [])[:10]: - recent.append({ - "hash": str(tx.get("trans_id", tx.get("tx_hash", "")))[:20], - "type": tx.get("flow", tx.get("type", "transfer")), - "amount_usd": float(tx.get("change_amount", 0) or 0), - "time": tx.get("block_time", 0) - }) - - # Risk assessment - risk_score = min(100, len(tokens) * 2 + len(recent) * 1) - risk_level = "low" if risk_score < 30 else ("medium" if risk_score < 60 else "high") - - return { - "address": address[:12] + "..." + address[-6:], - "chain": chain, - "total_value_usd": round(total_value, 2), - "token_count": len(tokens), - "risk_score": risk_score, - "risk_level": risk_level, - "top_tokens": tokens, - "recent_activity": recent, - "transaction_count": len(recent), - "source": "solscan (free API)", - "analyzed_at": datetime.now(timezone.utc).isoformat(), - } - else: - # EVM chains — DexScreener lookup - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"https://api.dexscreener.com/latest/dex/search?q={address}") - if r.status_code == 200: - pairs = r.json().get("pairs", []) - total_vol = sum(float(p.get("volume", {}).get("h24", 0) or 0) for p in pairs) - return { - "address": address[:12] + "..." + address[-6:], - "chain": chain, - "total_value_usd": round(total_vol, 2), - "token_count": len(pairs), - "risk_score": min(100, len(pairs) * 5), - "risk_level": "low" if len(pairs) < 10 else "medium", - "top_tokens": [{"token": p.get("baseToken", {}).get("symbol", "?"), "address": p.get("baseToken", {}).get("address", ""), "value_usd": float(p.get("volume", {}).get("h24", 0) or 0)} for p in pairs[:10]], - "recent_activity": [], - "source": "dexscreener", - "analyzed_at": datetime.now(timezone.utc).isoformat(), - } - except Exception as e: - pass - - return { - "address": address[:12] + "..." + address[-6:], - "chain": chain, - "total_value_usd": 0, - "token_count": 0, - "risk_score": 0, - "risk_level": "unavailable", - "top_tokens": [], - "recent_activity": [], - "error": "On-chain data temporarily unavailable", - "analyzed_at": datetime.now(timezone.utc).isoformat(), - } - -@app.post("/api/v1/contract/audit") -async def audit_contract(request: Request, data: dict): - """Audit a smart contract or token address using DegenSecurityScanner.""" - address = data.get("address", "") - chain = data.get("chain", "solana") - - if not address: - raise HTTPException(status_code=400, detail="address required") - - try: - if chain == "solana": - import app.degen_security_scanner as dss - report = await dss.scan_token(address, quick=True) - return { - "status": "completed", - "address": address, - "chain": chain, - "token_name": report.token_name, - "token_symbol": report.token_symbol, - "risk_score": report.risk_score, - "risk_level": report.risk_level, - "findings": report.findings[:10], - "flags": report.flags[:10], - "has_rug_potential": report.has_rug_potential, - "deployer": report.deployer[:12] + "..." if report.deployer else "unknown", - "created_at": report.created_at, - "liquidity_usd": report.liquidity_usd, - "price_usd": report.price_usd, - "holders_count": report.holder_count, - "top_holder_pct": report.top_holder_pct, - "source": "degen-security-scanner", - "analyzed_at": datetime.now(timezone.utc).isoformat(), - } - else: - # EVM chains — DexScreener for basic checks - async with httpx.AsyncClient(timeout=15) 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] - return { - "status": "completed", - "address": address, - "chain": p.get("chainId", chain), - "token_name": p.get("baseToken", {}).get("name", "Unknown"), - "token_symbol": p.get("baseToken", {}).get("symbol", "???"), - "risk_score": 30, - "risk_level": "low", - "findings": ["DexScreener verified pair"], - "flags": [], - "has_rug_potential": False, - "liquidity_usd": float(p.get("liquidity", {}).get("usd", 0) or 0), - "price_usd": float(p.get("priceUsd", 0) or 0), - "source": "dexscreener", - "analyzed_at": datetime.now(timezone.utc).isoformat(), - } - except Exception as e: - pass - - return { - "status": "unavailable", - "message": f"Could not audit {address} — scanner service temporarily unavailable", - "address": address, - "chain": chain, - "analyzed_at": datetime.now(timezone.utc).isoformat(), - } - -async def _feed_scam_to_rag(scan): - """Feed detected scam results back into RAG knowledge base.""" - try: - from app.rag_service import ingest_document - content = json.dumps({ - "address": scan.token_address, - "chain": scan.chain, - "safety_score": scan.safety_score, - "risk_flags": scan.risk_flags[:10], - }) - metadata = {"source": "scanner_feedback", "chain": scan.chain, "risk_score": scan.safety_score} - await ingest_document("known_scams", scan.token_address, content, metadata) - logger.info(f"Scam feedback: {scan.token_address} added to RAG known_scams") - except Exception: - pass -@app.post("/api/v1/token/scan") -async def scan_token(request: Request, data: dict): - """Unified token scanner — ties together all RugMunch intelligence. - - Freemium: 5 free scans/day (IP-based), then upsell to PRO. - Internal (X-RMI-Key) and x402 paid calls bypass limits. - """ - token_address = data.get("token_address") or data.get("address") - chain = data.get("chain", "solana") - tier = data.get("tier", "free") - - if not token_address: - raise HTTPException(status_code=400, detail="token_address required") - - # ── Freemium rate limit check ── - is_internal = is_internal_request(request) - is_paid = is_x402_paid_request(request) - identity, identity_type = get_identity(request) - limit_check = check_scan_limit( - identity=identity, - identity_type=identity_type, - tier=tier, - is_internal=is_internal, - is_x402_paid=is_paid, - ) - - if not limit_check["allowed"]: - return { - "status": "limit_reached", - "error": "daily_scan_limit_reached", - "message": f"You've used your {FREE_DAILY_LIMIT} free scans for today. Upgrade to PRO for 100/day or ELITE for unlimited.", - "scans_used": limit_check.get("scans_used", FREE_DAILY_LIMIT), - "daily_limit": limit_check.get("daily_limit", FREE_DAILY_LIMIT), - "resets_at": limit_check.get("resets_at"), - "upgrade": { - "url": "https://rugmunch.io/pricing", - "tiers": { - "free": {"daily_limit": FREE_DAILY_LIMIT, "price": "$0/mo", "features": ["5 scans/day", "Basic safety score", "DexScreener data"]}, - "pro": {"daily_limit": 100, "price": "$29.99/mo", "features": ["100 scans/day", "Full risk analysis", "Honeypot detection", "Mint/freeze authority"]}, - "elite": {"daily_limit": "unlimited", "price": "$99.99/mo", "features": ["Unlimited scans", "All 12 SENTINEL modules", "MEV analysis", "Dev reputation"]}, - "scan_packs": {"starter": {"price": "$4.99", "scans": 25}}, - }, - "x402": "Pay per scan with crypto at /api/v1/x402-tools/sentinel_scan", - }, - } - - from app.token_scanner import scan_token as unified_scan - scan = await unified_scan(token_address, chain, tier=tier) - - # Track usage (after successful scan) - increment_scan_usage( - identity=identity, - identity_type=identity_type, - tier=tier, - is_internal=is_internal, - is_x402_paid=is_paid, - ) - - # Auto-feed RAG knowledge base (background, don't block response) - try: - from app.rag_service import ingest_document - import asyncio as _asyncio - _asyncio.create_task(ingest_document("token_analysis", json.dumps({ - "token": scan.token_address, "chain": scan.chain, - "safety_score": scan.safety_score, "risk_flags": scan.risk_flags, - "liquidity": scan.free.get("liquidity_usd", 0), - "honeypot": scan.free.get("honeypot_risk", "unknown"), - "timestamp": scan.scanned_at, - }), {"source": "token_scan", "chain": chain, "tier": tier})) - except: pass - - # Broadcast to WebSocket stream - try: - import asyncio as _asyncio2 - _asyncio2.create_task(ws_broadcast_scan({ - "token": scan.token_address, - "chain": scan.chain, - "symbol": scan.free.get("symbol", ""), - "safety_score": scan.safety_score, - "risk_flags": scan.risk_flags[:3] if scan.risk_flags else [], - })) - except: pass - - # Scam feedback loop: feed detected scams back into RAG for better future detection - if scan.safety_score is not None and scan.safety_score < 30 and scan.risk_flags: - try: - _asyncio2.create_task(_feed_scam_to_rag(scan)) - except: pass - - # Build response with usage info - usage_info = {} - if not is_internal and not is_paid: - usage_info = { - "usage": { - "scans_today": limit_check.get("remaining", FREE_DAILY_LIMIT) - 1, # This scan counts - "daily_limit": limit_check.get("limit", FREE_DAILY_LIMIT), - "remaining": max(0, limit_check.get("remaining", FREE_DAILY_LIMIT) - 1), - }, - "upgrade_hint": "Upgrade to PRO for 100 scans/day — rugmunch.io/pricing" if limit_check.get("remaining", 99) <= 2 else None, - } - - return { - "token": scan.token_address, - "chain": scan.chain, - "symbol": scan.symbol, - "name": scan.name, - "safety_score": scan.safety_score, - "confidence": scan.confidence, - "risk_flags": scan.risk_flags, - "tier": scan.tier_required, - "modules_analyzed": len(scan.free.get("modules_run", [])), - "free": scan.free, - "pro": scan.pro if tier in ("pro", "elite") else None, - "elite": scan.elite if tier == "elite" else None, - "scanned_at": scan.scanned_at, - **usage_info, - } - -@app.get("/api/v1/token/scan/{token_address}") -async def scan_token_get(request: Request, token_address: str, chain: str = "solana", tier: str = "free"): - """GET endpoint for token scanning (Telegram bot friendly).""" - from app.token_scanner import scan_token as unified_scan, quick_scan_text - - format_text = False # query param could control this - - scan = await unified_scan(token_address, chain, tier=tier) - return { - "token": scan.token_address, - "chain": scan.chain, - "safety_score": scan.safety_score, - "risk_flags": scan.risk_flags, - "tier": scan.tier_required, - "free": scan.free, - "scanned_at": scan.scanned_at, - } - -@app.get("/api/v1/wallet/scan/{address}") -async def scan_wallet(request: Request, address: str, chain: str = "solana", tier: str = "free"): - """Multi-chain wallet scanner — 100+ risk factors. Freemium: 5 free/day.""" - is_internal = is_internal_request(request) - is_paid = is_x402_paid_request(request) - identity, identity_type = get_identity(request) - limit_check = check_scan_limit(identity=identity, identity_type=identity_type, tier=tier, is_internal=is_internal, is_x402_paid=is_paid) - if not limit_check["allowed"]: - return JSONResponse(status_code=429, content={"error": "daily_scan_limit_reached", "message": f"Free limit ({FREE_DAILY_LIMIT}/day) reached. Upgrade at rugmunch.io/pricing", "upgrade_url": "https://rugmunch.io/pricing", "resets_at": limit_check.get("resets_at")}) - from app.unified_scanner import scan_wallet as wallet_scan - result = await wallet_scan(address, chain, tier=tier) - increment_scan_usage(identity=identity, identity_type=identity_type, tier=tier, is_internal=is_internal, is_x402_paid=is_paid) - if not is_internal and not is_paid: - result["usage"] = {"remaining": max(0, limit_check.get("remaining", FREE_DAILY_LIMIT) - 1), "daily_limit": limit_check.get("limit", FREE_DAILY_LIMIT)} - return result - -@app.post("/api/v1/wallet/scan") -async def scan_wallet_post(request: Request, data: dict): - """POST wallet scanner. Freemium: 5 free/day.""" - address = data.get("address") or data.get("wallet_address") - chain = data.get("chain", "solana") - tier = data.get("tier", "free") - if not address: - raise HTTPException(status_code=400, detail="address required") - is_internal = is_internal_request(request) - is_paid = is_x402_paid_request(request) - identity, identity_type = get_identity(request) - limit_check = check_scan_limit(identity=identity, identity_type=identity_type, tier=tier, is_internal=is_internal, is_x402_paid=is_paid) - if not limit_check["allowed"]: - return JSONResponse(status_code=429, content={"error": "daily_scan_limit_reached", "message": f"Free limit ({FREE_DAILY_LIMIT}/day) reached. Upgrade at rugmunch.io/pricing", "upgrade_url": "https://rugmunch.io/pricing", "resets_at": limit_check.get("resets_at")}) - from app.unified_scanner import scan_wallet as wallet_scan - result = await wallet_scan(address, chain, tier=tier) - increment_scan_usage(identity=identity, identity_type=identity_type, tier=tier, is_internal=is_internal, is_x402_paid=is_paid) - if not is_internal and not is_paid: - result["usage"] = {"remaining": max(0, limit_check.get("remaining", FREE_DAILY_LIMIT) - 1), "daily_limit": limit_check.get("limit", FREE_DAILY_LIMIT)} - return result - -@app.get("/api/v1/scan/usage") -async def get_scan_usage(request: Request): - """Get current scan usage stats for the requesting identity.""" - from app.scan_rate_limiter import get_usage_stats - identity, identity_type = get_identity(request) - stats = get_usage_stats(identity, identity_type) - return { - "status": "ok", - **stats, - "pricing": { - "free": {"daily_limit": FREE_DAILY_LIMIT, "price": "$0/mo"}, - "pro": {"daily_limit": 100, "price": "$29.99/mo"}, - "elite": {"daily_limit": "unlimited", "price": "$99.99/mo"}, - "scan_packs": {"starter": {"price": "$4.99", "scans": 25, "description": "25 token/wallet scans"}}, - }, - "upgrade_url": "https://rugmunch.io/pricing", - } - -@app.get("/api/v1/intel/leaderboard") -async def get_intel_leaderboard(request: Request, period: str = "daily"): - """RMI Intelligence Leaderboard — safest, most dangerous, and fastest tokens from our scan data. - - Reads accumulated scan data from /root/.hermes/scans/ and builds a leaderboard. - """ - import glob - from datetime import datetime, timezone, timedelta - - scan_dir = "/app/scans" - now = datetime.now(timezone.utc) - - # Time window - if period == "weekly": - cutoff = now - timedelta(days=7) - elif period == "hourly": - cutoff = now - timedelta(hours=1) - else: - cutoff = now - timedelta(hours=24) - - all_tokens = [] - safest = [] - most_dangerous = [] - - try: - for fpath in sorted(glob.glob(f"{scan_dir}/token_scan_*.json")): - fmod = os.path.getmtime(fpath) - if datetime.fromtimestamp(fmod, tz=timezone.utc) < cutoff: - continue - try: - with open(fpath) as f: - data = json.load(f) - if isinstance(data, list): - all_tokens.extend(data) - except Exception: - continue - - # Also load trending reports - for fpath in sorted(glob.glob(f"{scan_dir}/trending_*.json")): - fmod = os.path.getmtime(fpath) - if datetime.fromtimestamp(fmod, tz=timezone.utc) < cutoff: - continue - try: - with open(fpath) as f: - data = json.load(f) - if isinstance(data, dict): - all_tokens.extend(data.get("trending", [])) - except Exception: - continue - - # Deduplicate by chain:address - seen = {} - for t in all_tokens: - key = f"{t.get('chain')}:{t.get('address')}" - if key not in seen or (t.get("safety_score") is not None and seen[key].get("safety_score") is None): - seen[key] = t - unique = list(seen.values()) - - # Build leaderboard - scored = [t for t in unique if t.get("safety_score") is not None] - scored.sort(key=lambda x: x.get("safety_score", 50), reverse=True) - safest = [ - {"chain": t.get("chain"), "symbol": t.get("symbol"), "address": t.get("address"), - "safety_score": t.get("safety_score"), "risk_flags": t.get("risk_flags", []), - "liquidity_usd": t.get("liquidity_usd"), "confidence": t.get("confidence")} - for t in scored[:10] - ] - most_dangerous = [ - {"chain": t.get("chain"), "symbol": t.get("symbol"), "address": t.get("address"), - "safety_score": t.get("safety_score"), "risk_flags": t.get("risk_flags", []), - "liquidity_usd": t.get("liquidity_usd"), "confidence": t.get("confidence")} - for t in scored[-10:] - ] - - # Velocity leaders (tokens with acceleration data) - velocity = [t for t in unique if t.get("velocity") is not None and t.get("velocity", 0) > 0] - velocity.sort(key=lambda x: x.get("velocity", 0), reverse=True) - top_velocity = [ - {"chain": t.get("chain"), "symbol": t.get("symbol"), "address": t.get("address"), - "velocity": t.get("velocity"), "volume_24h": t.get("volume_24h"), - "price_change_24h": t.get("price_change_24h")} - for t in velocity[:10] - ] - - except Exception as e: - return {"status": "error", "error": str(e), "leaderboard": {"safest": [], "most_dangerous": []}} - - return { - "status": "ok", - "period": period, - "total_tokens_scanned": len(unique) if 'unique' in dir() else 0, - "scored_tokens": len(scored) if 'scored' in dir() else 0, - "leaderboard": { - "safest": safest, - "most_dangerous": most_dangerous, - }, - "top_velocity": top_velocity if 'top_velocity' in dir() else [], - "updated_at": now.isoformat(), - } - -@app.get("/api/v1/intel/digest") -async def get_intel_digest(request: Request): - """Latest intelligence digest from accumulated scan data.""" - import glob - - scan_dir = "/app/scans" - today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - digest_file = f"{scan_dir}/intel_digest_{today}.json" - - # Try today's digest first - if os.path.exists(digest_file): - try: - with open(digest_file) as f: - return json.load(f) - except Exception: - pass - - # Fall back to most recent digest - digests = sorted(glob.glob(f"{scan_dir}/intel_digest_*.json")) - if digests: - try: - with open(digests[-1]) as f: - return json.load(f) - except Exception: - pass - - return {"status": "no_data", "message": "No intelligence digest available yet. Crons build this data over time."} - -@app.get("/api/v1/trending") -async def get_trending(request: Request, chain: str = None, limit: int = 20): - """Multi-source trending tokens — DexScreener + GeckoTerminal + CoinGecko.""" - from app.unified_scanner import get_trending_tokens - return await get_trending_tokens(chain=chain, limit=limit) - -@app.get("/api/v1/markets/movers") -async def get_movers(request: Request, chain: str = None, limit: int = 10): - """Top movers by volume + price change.""" - data = await get_trending(chain=chain, limit=limit * 2) - tokens = data.get("tokens", []) - # Sort by absolute price change - tokens.sort(key=lambda x: abs(x.get("price_change_24h", 0)), reverse=True) - return {"movers": tokens[:limit], "total": len(tokens), "updated_at": data.get("updated_at")} - -@app.get("/api/v1/launches/{chain}") -async def get_new_launches(request: Request, chain: str = "solana", seconds: int = 300): - """Real-time token launch monitor.""" - from app.unified_scanner import get_new_launches - return await get_new_launches(chain=chain, since_seconds=seconds) - -@app.get("/api/v1/wallet/{address}/trace") -async def trace_wallet_funding(request: Request, address: str, chain: str = "solana", max_hops: int = 5): - """Advanced funding traceback — hop-by-hop tracing to funding source.""" - from app.advanced_analysis import trace_funding - return await trace_funding(address, chain, max_hops=max_hops) - -# ═══════════════════════════════════════════════════════════ -# INVESTIGATION ENDPOINTS (frontend-connected) -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/investigation/wallet/analyze") -async def investigate_wallet(request: Request, data: dict): - """Wallet forensic analysis for frontend.""" - address = data.get("address") or data.get("wallet_address") - chain = data.get("chain", "ethereum") - tier = data.get("tier", "free") - from app.unified_scanner import scan_wallet as wallet_scan - from app.advanced_analysis import get_wallet_balance, get_transaction_history, trace_funding - scan = await wallet_scan(address, chain, tier=tier) - balance = await get_wallet_balance(address, chain) - history = await get_transaction_history(address, chain) - result = {**scan, "balance": balance, "transaction_history": history} - if tier in ("pro", "elite"): - result["funding_trace"] = await trace_funding(address, chain) - return result - -@app.get("/api/v1/investigation/wallet/lookup") -async def wallet_lookup(request: Request, address: str, chain: str = "ethereum"): - """Quick wallet lookup.""" - from app.unified_scanner import scan_wallet as wallet_scan - from app.advanced_analysis import get_wallet_balance - scan = await wallet_scan(address, chain, tier="free") - balance = await get_wallet_balance(address, chain) - return {"wallet": address, "chain": chain, "balance": balance, "risk": scan.get("risk_score", 0), "risk_flags": scan.get("risk_flags", [])} - -@app.post("/api/v1/investigation/trace") -async def investigate_trace(request: Request, data: dict): - """Funding trace investigation.""" - from app.advanced_analysis import trace_funding - return await trace_funding(data.get("address", ""), data.get("chain", "ethereum"), max_hops=data.get("max_hops", 5)) - -@app.post("/api/v1/investigation/cross-chain") -async def cross_chain_investigation(request: Request, data: dict): - """Cross-chain analysis.""" - from app.unified_scanner import scan_wallet as wallet_scan - chains = data.get("chains", ["ethereum", "bsc", "polygon"]) - address = data.get("address", "") - results = {} - for chain in chains: - results[chain] = await wallet_scan(address, chain, tier="free") - return {"address": address, "cross_chain_results": results, "total_chains": len(chains)} - -@app.post("/api/v1/investigation/patterns") -async def investigate_patterns(request: Request, data: dict): - """Behavioral pattern analysis.""" - from app.unified_scanner import scan_wallet as wallet_scan - address = data.get("address", "") - chain = data.get("chain", "ethereum") - result = await wallet_scan(address, chain, tier="pro") - return {"address": address, "patterns": result.get("factors", {}), "risk_score": result.get("risk_score", 0)} - -@app.post("/api/v1/investigation/ai-analyze") -async def ai_analyze_investigation(request: Request, data: dict): - """AI-powered investigation analysis.""" - address = data.get("address", "") - chain = data.get("chain", "ethereum") - from app.unified_scanner import scan_wallet as wallet_scan - from app.advanced_analysis import analyze_contract_rug_risk - wallet = await wallet_scan(address, chain, tier="elite") - contract = await analyze_contract_rug_risk(address, chain) - return {"wallet_analysis": wallet, "contract_analysis": contract, "combined_risk": max(wallet.get("risk_score", 0), contract.get("rug_risk_score", 0))} - -# ═══════════════════════════════════════════════════════════ -# RAG INGESTION & RETRIEVAL -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/rag/ingest") -async def rag_ingest(request: Request, data: dict): - """Ingest document into RAG knowledge base.""" - collection = data.get("collection", "general") - content = data.get("content", "") - metadata = data.get("metadata", {}) - - if not content: - raise HTTPException(status_code=400, detail="content required") - - from app.rag_service import ingest_document - result = await ingest_document(collection, str(content), metadata) - return result - -@app.get("/api/v1/rag/search") -async def rag_search(request: Request, q: str, collection: str = "wallet_profiles", limit: int = 10, apply_decay: bool = True, use_three_pillar: bool = False): - """Semantic search across RAG collections. Uses FAISS ANN index by default. - Set use_three_pillar=true for three-pillar hybrid search (dense + sparse + entity).""" - if use_three_pillar: - from app.rag_service import three_pillar_search - coll_list = None if collection == "all" else [collection] - result = await three_pillar_search(query=q, collections=coll_list, limit=limit) - return {**result, "mode": "three_pillar"} - - from app.rag_service import search_similar, search_multi_collection - if collection == "all": - results = await search_multi_collection(q, limit=limit) - else: - results = await search_similar(q, collection=collection, limit=limit) - - # Apply temporal decay scoring — old content gets downweighted - if apply_decay and results: - from app.temporal_decay import apply_temporal_decay - results = apply_temporal_decay(results) - - return {"query": q, "collection": collection, "results": results, "total": len(results)} - -@app.get("/api/v1/rag/stats") -async def rag_stats(request: Request): - """Get RAG stats — embedder status, collection sizes, cache ratio.""" - from app.rag_service import get_stats - return await get_stats() - -@app.post("/api/v1/rag/seed") -async def rag_seed(request: Request): - """Seed RAG with 10 known crypto scam patterns (honeypot, mint, fee, drain, etc.).""" - from app.rag_service import seed_known_scams - result = await seed_known_scams() - return result - -@app.post("/api/v1/rag/detect-scam") -async def rag_detect_scam(request: Request, data: dict): - """Detect scam patterns in a token. Pass token_data with contract_code, name, description, chain.""" - from app.rag_service import detect_scam_patterns - result = await detect_scam_patterns(data) - return result - -@app.get("/api/v1/rag/search-transformed") -async def rag_search_transformed(request: Request, q: str, collection: str = "wallet_profiles", limit: int = 10, strategy: str = "auto"): - """Semantic search with query transformation (HyDE, expansion, step-back, auto).""" - from app.rag_service import search_with_transform - result = await search_with_transform(q, collection=collection, limit=limit, strategy=strategy) - return result - -@app.post("/api/v1/rag/evaluate") -async def rag_evaluate(request: Request, data: dict = None): - """Run RAG evaluation against golden test set. Optional: collection filter.""" - from app.ragas_eval import run_evaluation - collection = (data or {}).get("collection", None) - limit = (data or {}).get("limit", 10) - result = await run_evaluation(collection=collection, limit=limit) - return result - -@app.post("/api/v1/rag/ingest-forensic") -async def rag_ingest_forensic(request: Request, data: dict): - """Ingest forensic report text.""" - report_text = data.get("text", "") - report_name = data.get("name", "forensic_report") - from app.rag_service import ingest_forensic_report - return await ingest_forensic_report(report_text, report_name) - -@app.get("/api/v1/rag/goplus-analysis") -async def rag_goplus_analysis(request: Request, token_address: str, chain: str = "1"): - """Run GoPlus Security API token risk analysis and ingest results into RAG.""" - if not token_address: - raise HTTPException(status_code=400, detail="token_address required") - from app.rag_service import get_goplus_analysis - result = await get_goplus_analysis(token_address, chain) - return result - -# ═══════════════════════════════════════════════════════════════════ -# FAISS ANN INDEX + SEMANTIC CACHE + THREE-PILLAR SEARCH -# ═══════════════════════════════════════════════════════════════════ - -@app.post("/api/v1/rag/build-index") -async def rag_build_index(request: Request, data: dict = None): - """ - Build FAISS ANN index for one or all collections. - Body (optional): {"collection": "scam_patterns"} or {"all": true} - If no body or all=true, builds indexes for all collections. - """ - data = data or {} - collection = data.get("collection") - build_all = data.get("all", not collection) - - from app.ann_index import get_ann_index - ann = get_ann_index() - - if build_all: - from app.crypto_embeddings import COLLECTIONS - results = {} - for coll in COLLECTIONS: - try: - results[coll] = await ann.build_index(coll, force=True) - except Exception as e: - results[coll] = {"error": str(e)} - return {"status": "built", "indexes": results, "stats": ann.stats()} - else: - if not collection: - raise HTTPException(status_code=400, detail="collection required (or pass all=true)") - result = await ann.build_index(collection, force=True) - return {"status": "built", "collection": collection, "result": result, "stats": ann.stats()} - - -@app.get("/api/v1/rag/three-pillar-search") -async def rag_three_pillar_search( - request: Request, - q: str, - collections: str = "all", - limit: int = 10, - min_similarity: float = 0.5, - use_mmr: bool = True, - use_reranker: bool = False, -): - """ - Three-pillar hybrid search: - Pillar 1: Dense vector search via FAISS ANN (semantic similarity) - Pillar 2: Sparse text search (keyword/BM25-style matching) - Pillar 3: Entity exact-match lookup (addresses, symbols, hashes) - - Results are fused with Reciprocal Rank Fusion (k=60). - Set use_mmr=false to skip MMR deduplication. - Set use_reranker=true to enable cross-encoder reranking (bge-reranker-v2-m3). - """ - if not q: - raise HTTPException(status_code=400, detail="q (query) required") - - from app.rag_service import three_pillar_search - from app.crypto_embeddings import COLLECTIONS - - coll_list = COLLECTIONS if collections == "all" else [c.strip() for c in collections.split(",")] - - result = await three_pillar_search( - query=q, - collections=coll_list, - limit=limit, - min_similarity=min_similarity, - use_mmr=use_mmr, - use_reranker=use_reranker, - ) - return result - - -@app.get("/api/v1/rag/ann-stats") -async def rag_ann_stats(request: Request): - """Get FAISS ANN index stats for all collections.""" - from app.ann_index import get_ann_index - ann = get_ann_index() - return ann.stats() - - -@app.post("/api/v1/rag/cache-clear") -async def rag_cache_clear(request: Request): - """Clear the semantic query cache.""" - from app.semantic_cache import get_semantic_cache - cache = get_semantic_cache() - deleted = await cache.clear() - return {"status": "cleared", "entries_removed": deleted} - - -@app.get("/api/v1/rag/cache-stats") -async def rag_cache_stats(request: Request): - """Get semantic cache statistics.""" - from app.semantic_cache import get_semantic_cache - cache = get_semantic_cache() - return await cache.stats() - -# ═══════════════════════════════════════════════════════════════════ -# TIER-1 RAG — Agentic Investigation + LLM Reranking -# ═══════════════════════════════════════════════════════════════════ - -@app.post("/api/v1/rag/investigate") -async def rag_investigate(request: Request, data: dict): - """Multi-hop agentic investigation. Plans hops, executes, synthesizes findings.""" - query = data.get("query", "") - context = data.get("context", {}) - max_hops = data.get("max_hops", 3) - if not query: - raise HTTPException(status_code=400, detail="query required") - from app.rag_agentic import get_agent - agent = get_agent() - return await agent.investigate(query, context, max_hops) - -@app.post("/api/v1/rag/rerank") -async def rag_rerank(request: Request, data: dict): - """LLM cross-encode reranking. Takes query + documents, returns scored results.""" - query = data.get("query", "") - documents = data.get("documents", []) - top_k = data.get("top_k", 5) - from app.rag_agentic import get_reranker - reranker = get_reranker() - return await reranker.rerank(query, documents, top_k) - -@app.post("/api/v1/rag/scan-token") -async def rag_scan_token(request: Request, data: dict): - """Real-time new token scan against scam DB. Quick keyword → deep semantic.""" - from app.rag_agentic import get_monitor - monitor = get_monitor() - return await monitor.scan_new_token(data) - -@app.get("/api/v1/rag/alerts") -async def rag_alerts(request: Request, limit: int = 20): - """Recent high-risk scam alerts from the real-time monitor.""" - from app.rag_agentic import get_monitor - monitor = get_monitor() - return {"alerts": monitor.get_recent_alerts(limit), "total": len(monitor.alerts)} - -@app.post("/api/v1/rag/ingest-sources") -async def rag_ingest_sources(request: Request): - """Trigger full ingestion cycle: RSS feeds + on-chain scanning.""" - from app.intel_feed_pipeline import get_pipeline - pipeline = await get_pipeline() - return await pipeline.run_cycle() - -@app.get("/api/v1/intel/latest") -async def intel_latest(request: Request, limit: int = 20): - """Get latest threat intelligence items for frontend.""" - from app.intel_feed_pipeline import get_pipeline - pipeline = await get_pipeline() - items = await pipeline.get_latest_intel(limit) - return {"items": items, "total": len(items), "pipeline_ready": True} - -@app.get("/api/v1/intel/feed-test") -async def intel_feed_test(request: Request): - """Test RSS feeds — returns which feeds are reachable.""" - from app.intel_feed_pipeline import FEEDS - results = {} - for feed in FEEDS: - try: - import httpx - async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: - resp = await client.get(feed["url"], headers={"User-Agent": "RMI/1.0"}) - results[feed["name"]] = {"status": resp.status_code, "size": len(resp.text)} - except Exception as e: - results[feed["name"]] = {"status": "error", "error": str(e)[:100]} - return {"feeds": results, "total": len(FEEDS)} - -@app.get("/api/v1/rag/search-stream") -async def rag_search_stream(request: Request, q: str, collection: str = "all", limit: int = 5): - """Streaming RAG search — progressive results with reranking.""" - from app.rag_agentic import stream_rag_search - from fastapi.responses import StreamingResponse - return StreamingResponse( - stream_rag_search(q, collection, limit), - media_type="application/x-ndjson", - ) - -# ═══════════════════════════════════════════════════════════════════ -# BUNDLE & CLUSTER RAG — Semantic Intelligence on Graph Detection -# ═══════════════════════════════════════════════════════════════════ - -@app.post("/api/v1/bundles/index") -async def bundle_index(request: Request, data: dict): - """Index a bundle detection result into RAG for similarity search.""" - from app.bundle_cluster_rag import index_bundle_detection - bundle_id = await index_bundle_detection(data) - return {"bundle_id": bundle_id, "status": "indexed"} - -@app.post("/api/v1/clusters/index") -async def cluster_index(request: Request, data: dict): - """Index a cluster + auto-label + store for semantic search.""" - from app.bundle_cluster_rag import index_cluster_detection - result = await index_cluster_detection(data) - return result - -@app.post("/api/v1/clusters/search") -async def cluster_search(request: Request, data: dict): - """NL search for clusters. 'Show me wash trading clusters on Solana'.""" - query = data.get("query", "") - limit = data.get("limit", 10) - from app.bundle_cluster_rag import search_clusters_by_description - results = await search_clusters_by_description(query, limit=limit) - return {"query": query, "results": results, "total": len(results)} - -@app.post("/api/v1/clusters/similar") -async def cluster_similar(request: Request, data: dict): - """Find clusters similar to a target cluster (behavioral vector match).""" - from app.bundle_cluster_rag import find_similar_clusters - results = await find_similar_clusters(data, limit=data.get("limit", 10)) - return {"results": results, "total": len(results)} - -@app.post("/api/v1/bundles/similar") -async def bundle_similar(request: Request, data: dict): - """Find bundles similar to a target bundle.""" - from app.bundle_cluster_rag import find_similar_bundles - results = await find_similar_bundles(data, limit=data.get("limit", 10)) - return {"results": results, "total": len(results)} - -@app.post("/api/v1/clusters/labels/backfill") -async def cluster_labels_backfill(request: Request): - """Index cluster label templates into pgvector.""" - from app.bundle_cluster_rag import backfill_label_templates - count = await backfill_label_templates() - return {"status": "backfilled", "count": count} - - -# ═══════════════════════════════════════════════════════════════════ -# TIER-1 RAG — World-Class Feature Endpoints -# ═══════════════════════════════════════════════════════════════════ - -@app.post("/api/v1/rag/chunk-document") -async def rag_chunk_document(request: Request, data: dict): - """ - Contextual chunking pipeline — Anthropic-style contextual retrieval. - Chunks a document and prepends LLM-generated context to each chunk - to dramatically improve retrieval accuracy (65% → 89%). - """ - text = data.get("text", "") - doc_id = data.get("doc_id", "") - source = data.get("source", "") - chunk_size = data.get("chunk_size", 2500) - use_llm = data.get("use_llm_context", True) - if not text: - return {"error": "text is required"} - from app.contextual_chunking import process_document - result = await process_document( - text, doc_id=doc_id, source=source, - chunk_size=chunk_size, use_llm_context=use_llm, - ) - return { - "doc_id": result.doc_id, - "total_chunks": len(result.chunks), - "total_chars": result.total_chars, - "processing_time_ms": result.processing_time_ms, - "chunks": [ - { - "index": c.index, - "content": c.content[:200] + "..." if len(c.content) > 200 else c.content, - "context": c.context_text, - "contextualized": c.contextualized[:300] + "..." if len(c.contextualized) > 300 else c.contextualized, - "hash": c.content_hash, - "parent_id": c.parent_id, - } - for c in result.chunks - ], - } - -@app.post("/api/v1/rag/cross-rerank") -async def rag_cross_rerank(request: Request, data: dict): - """ - Cross-encoder reranking — bge-reranker-v2-m3 (local, CPU). - Stage 2 in the 3-stage reranking pipeline: - Vector search (stage 1) → Cross-encoder (stage 2) → LLM rerank (stage 3) - """ - query = data.get("query", "") - documents = data.get("documents", []) - top_k = data.get("top_k", 10) - if not query or not documents: - return {"error": "query and documents are required"} - from app.cross_encoder_reranker import get_reranker - reranker = await get_reranker() - results = await reranker.rerank(query, documents, top_k=top_k) - return {"query": query, "reranked": results, "total": len(results)} - -@app.post("/api/v1/rag/entity-search") -async def rag_entity_search(request: Request, data: dict): - """ - Entity-augmented hybrid search. - Extracts crypto entities (addresses, symbols, chains) from query, - does exact-match lookup + vector search, merges with RRF. - Critical for finding specific addresses that cosine similarity misses. - """ - query = data.get("query", "") - limit = data.get("limit", 10) - collection = data.get("collection", None) # Optional: narrow to one collection - if not query: - return {"error": "query is required"} - from app.entity_extraction import hybrid_query, extract_entities - # Show extracted entities - entities = extract_entities(query) - # Run hybrid search with optional collection filter - collections = [collection] if collection else None - try: - results = await asyncio.wait_for( - hybrid_query(query, collections=collections, limit=limit), - timeout=30, - ) - except asyncio.TimeoutError: - results = {"error": "search timed out", "partial": True} - return { - "query": query, - "entities": entities.to_dict(), - "results": results, - } - -@app.post("/api/v1/rag/entity-index") -async def rag_entity_index(request: Request, data: dict): - """Index a document's entities for exact-match lookup.""" - doc_id = data.get("doc_id", "") - text = data.get("text", "") - collection = data.get("collection", "general") - if not doc_id or not text: - return {"error": "doc_id and text are required"} - from app.entity_extraction import get_entity_lookup, extract_entities - lookup = get_entity_lookup() - entities_result = extract_entities(text) - indexed = await lookup.batch_index(doc_id, text, collection) - count = indexed.total_count if hasattr(indexed, 'total_count') else indexed - return {"doc_id": doc_id, "entities_found": entities_result.total_count, "indexed": count} - -@app.post("/api/v1/rag/check-hallucination") -async def rag_check_hallucination(request: Request, data: dict): - """ - Hallucination detection — DeBERTa NLI guardrail. - Verifies that a generated answer is faithful to the retrieved context. - Flags contradictions and unsupported claims. - """ - answer = data.get("answer", "") - context = data.get("context", "") - sources = data.get("sources", []) - if not answer: - return {"error": "answer is required"} - from app.hallucination_guard import get_guard - guard = await get_guard() - if sources: - result = await guard.check_answer_with_sources(answer, sources) - return result.__dict__ if hasattr(result, '__dict__') else result - else: - result = await guard.check_answer(answer, context) - return result.__dict__ if hasattr(result, '__dict__') else result - -@app.post("/api/v1/rag/verify-citations") -async def rag_verify_citations(request: Request, data: dict): - """Verify inline citations [1], [2] in answer against source documents.""" - answer = data.get("answer", "") - sources = data.get("sources", []) - if not answer: - return {"error": "answer is required"} - from app.hallucination_guard import get_guard - guard = await get_guard() - result = await guard.verify_citations(answer, sources) - return result.__dict__ if hasattr(result, '__dict__') else result - -@app.get("/api/v1/rag/decay-info") -async def rag_decay_info(request: Request): - """Get temporal decay half-lives per collection.""" - from app.temporal_decay import HALF_LIVES, COLLECTION_DOMAIN, get_half_life - from app.rag_service import COLLECTIONS - info = {} - for coll in COLLECTIONS: - hl = get_half_life(coll) - info[coll] = { - "domain": COLLECTION_DOMAIN.get(coll, "default"), - "half_life_days": hl if hl != float("inf") else "never", - } - safe_halves = {k: (v if v != float("inf") else "never") for k, v in HALF_LIVES.items()} - return {"collections": info, "all_domains": safe_halves} - -# ── RAG Permanence (R2 cold storage, nightly cycle, warm restore) ── -from app.rag_permanence import ( - snapshot_all, restore_all, nightly_cycle, r2_stats, -) - -@app.get("/api/v1/rag/permanence/stats") -async def rag_permanence_stats(request: Request): - """R2 usage + local cache stats.""" - return await r2_stats() - -@app.post("/api/v1/rag/permanence/snapshot") -async def rag_snapshot(request: Request): - """Snapshot all collections to R2 cold storage.""" - return await snapshot_all() - -@app.post("/api/v1/rag/permanence/restore") -async def rag_restore(request: Request): - """Restore all collections from R2 into Redis.""" - return await restore_all() - -@app.post("/api/v1/rag/permanence/nightly") -async def rag_nightly(request: Request): - """Full nightly cycle: snapshot→R2, clean local, rebuild ANN.""" - return await nightly_cycle() - -@app.get("/api/v1/rag/tier1-health") -async def rag_tier1_health(request: Request): - """Health check for all Tier-1 RAG features.""" - import os as _os - health = {} - # Cross-encoder - try: - from app.cross_encoder_reranker import get_reranker - reranker = await get_reranker() - health["cross_encoder"] = reranker.health_check() - except Exception as e: - health["cross_encoder"] = {"status": "error", "error": str(e)} - # Entity extraction (no model needed) - try: - from app.entity_extraction import extract_entities - test = extract_entities("Check 0xdAC17F958D2ee523a2206206994597C13D831ec7 on Ethereum") - health["entity_extraction"] = {"status": "ok", "test_entities": test.total_count} - except Exception as e: - health["entity_extraction"] = {"status": "error", "error": str(e)} - # Hallucination guard - try: - from app.hallucination_guard import get_guard - guard = await get_guard() - health["hallucination_guard"] = guard.health_check() - except Exception as e: - health["hallucination_guard"] = {"status": "error", "error": str(e)} - # Temporal decay (pure math, always ok) - from app.temporal_decay import HALF_LIVES as _HL - health["temporal_decay"] = {"status": "ok", "domains": len(_HL)} - # Contextual chunking - health["contextual_chunking"] = {"status": "ok", "llm_available": bool(_os.getenv("OPENROUTER_API_KEY", ""))} - - health["overall"] = "degraded" if any( - v.get("status") == "error" for v in health.values() if isinstance(v, dict) - ) else "healthy" - return health - - -# ═══════════════════════════════════════════════════════════════ -# RAG FIREHOSE — Continuous Intelligence Ingestion API -# ═══════════════════════════════════════════════════════════════ - -@app.get("/api/v1/rag/firehose/status") -async def rag_firehose_status(request: Request): - """Get firehose engine status — sources, cadences, ingestion stats.""" - from app.rag_firehose import get_firehose - firehose = get_firehose() - return firehose.get_status() - -@app.post("/api/v1/rag/firehose/start") -async def rag_firehose_start(request: Request): - """Start the firehose engine if stopped.""" - from app.rag_firehose import get_firehose - firehose = get_firehose() - await firehose.start() - return {"status": "started"} - -@app.post("/api/v1/rag/firehose/stop") -async def rag_firehose_stop(request: Request): - """Stop the firehose engine.""" - from app.rag_firehose import get_firehose - firehose = get_firehose() - await firehose.stop() - return {"status": "stopped"} - -@app.post("/api/v1/rag/firehose/feed-now") -async def rag_firehose_feed_now(request: Request, data: dict): - """Manually trigger a specific feed source immediately.""" - source_name = data.get("source", "") - if not source_name: - return {"error": "source name required"} - from app.rag_firehose import get_firehose - firehose = get_firehose() - return await firehose.feed_now(source_name) - -@app.get("/api/v1/rag/dedup-check") -async def rag_dedup_check(request: Request, hash: str, collection: str = "general"): - """Check if content hash already exists in dedup set.""" - try: - import redis.asyncio as aioredis - import os as _os - 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 - ) - exists = await r.sismember(f"rag:dedup:{collection}", hash) - await r.aclose() - return {"exists": bool(exists)} - except Exception: - return {"exists": False} - -@app.post("/api/v1/rag/dedup-mark") -async def rag_dedup_mark(request: Request, data: dict): - """Mark content hash in dedup set with TTL.""" - try: - import redis.asyncio as aioredis - import os as _os - 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 - ) - hash_val = data.get("hash", "") - collection = data.get("collection", "general") - ttl = data.get("ttl", 1209600) - await r.sadd(f"rag:dedup:{collection}", hash_val) - await r.expire(f"rag:dedup:{collection}", ttl) - await r.aclose() - return {"status": "marked"} - except Exception as e: - return {"status": "error", "detail": str(e)} - - -# ═══════════════════════════════════════════════════════════════ -# RAG FEEDBACK LOOP — Scanner results → RAG weight adjustment -# ═══════════════════════════════════════════════════════════════ - -@app.post("/api/v1/rag/feedback") -async def rag_feedback(request: Request, data: dict): - """Record scanner verdict and adjust RAG document weights. - - When SENTINEL confirms a scam, matching RAG docs get boosted. - When it clears a token, false matches get penalized. - This creates a continuous learning loop. - """ - from app.rag_feedback import record_scanner_result - return await record_scanner_result( - address=data.get("address", ""), - chain=data.get("chain", "ethereum"), - verdict=data.get("verdict", "unknown"), - confidence=float(data.get("confidence", 0.5)), - flags=data.get("flags", []), - token_name=data.get("token_name", ""), - ) - -@app.get("/api/v1/rag/feedback-stats") -async def rag_feedback_stats(request: Request): - """Get feedback loop statistics — weighted docs, averages, rebuild status.""" - from app.rag_feedback import get_feedback_stats - return await get_feedback_stats() - - -# ═══════════════════════════════════════════════════════════════ -# INTELLIGENCE PIPELINE — HF + Supabase + RAG -# ═══════════════════════════════════════════════════════════════ - -@app.post("/api/v1/intel/classify") -async def intel_classify(request: Request, data: dict): - """Classify scam risk using HuggingFace models.""" - text = data.get("text", "") - if not text: - raise HTTPException(status_code=400, detail="text required") - from app.intel_pipeline import classify_scam_risk - return await classify_scam_risk(text) - -@app.post("/api/v1/intel/label-wallet") -async def intel_label_wallet(request: Request, data: dict): - """Label wallet using behavioral patterns + RAG memory.""" - from app.intel_pipeline import label_wallet - return await label_wallet(data) - -@app.post("/api/v1/intel/embed") -async def intel_embed(request: Request, data: dict): - """Generate embedding vector for text.""" - text = data.get("text", "") - if not text: - raise HTTPException(status_code=400, detail="text required") - from app.intel_pipeline import generate_embedding - emb = await generate_embedding(text) - if emb is None: - raise HTTPException(status_code=503, detail="Embedding service unavailable") - return {"embedding": emb, "dimensions": len(emb)} - -@app.post("/api/v1/intel/sync-supabase") -async def intel_sync_supabase(request: Request, data: dict): - """Sync RAG document to Supabase hybrid storage.""" - collection = data.get("collection", "general") - from app.intel_pipeline import sync_to_supabase - return await sync_to_supabase(collection, data) - -@app.post("/api/v1/intel/cycle") -async def intel_cycle(request: Request): - """Run full intelligence cycle.""" - from app.intel_pipeline import run_intelligence_cycle - return await run_intelligence_cycle() - -@app.get("/api/v1/intel/patterns") -async def intel_patterns(request: Request): - """List known wallet behavior patterns used for labeling.""" - from app.intel_pipeline import WALLET_PATTERNS - return {"patterns": {k: len(v) for k, v in WALLET_PATTERNS.items()}, "total": len(WALLET_PATTERNS)} - -# ═══════════════════════════════════════════════════════════ -# BUBBLE MAPS — Wallet Visualization API (RugMaps) -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/bubblemap/{address}") -async def bubble_map(request: Request, address: str, depth: int = 2, chain: str = "solana", min_strength: float = 0.1): - """Generate interactive bubble map for wallet visualization.""" - from app.bubble_maps import get_bubble_maps_pro, BubbleMap - bm = get_bubble_maps_pro() - result = await bm.generate_map(address, depth=depth, min_strength=min_strength) - return result.to_dict() - -@app.get("/api/v1/clusters/{address}") -async def wallet_clusters(request: Request, address: str, chain: str = "solana", depth: int = 3): - """Detect wallet clusters using forensic signals.""" - from app.cluster_detection import ClusterDetector - detector = ClusterDetector() - clusters = await detector.detect_clusters(address, depth=depth) - result = [] - for c in clusters: - result.append(c.to_dict() if hasattr(c, 'to_dict') else c) - return { - "center": address, - "chain": chain, - "clusters": result, - "count": len(clusters) - } - -@app.get("/api/v1/bubblemap/{address}/html") -async def bubble_map_html(request: Request, address: str, depth: int = 2): - """Render interactive D3.js bubble map as HTML.""" - from app.bubble_maps import get_bubble_maps_pro - bm = get_bubble_maps_pro() - result = await bm.generate_map(address, depth=depth) - return HTMLResponse(content=bm.render_html(result), status_code=200) - -@app.post("/api/v1/investigation/evidence") -async def add_evidence(request: Request, data: dict): - """Add evidence to investigation.""" - import json - case_id = data.get("case_id", "default") - evidence = data - return {"status": "recorded", "case_id": case_id, "evidence_count": 1} - -@app.get("/api/v1/investigation/{investigation_id}/status") -async def investigation_status(request: Request, investigation_id: str): - """Get investigation status.""" - return {"id": investigation_id, "status": "active", "findings": 0} - -@app.get("/api/v1/investigation/list") -async def investigation_list(request: Request, limit: int = 20, status: str = None): - """List investigations.""" - return {"investigations": [], "total": 0, "limit": limit} - -@app.get("/api/v1/stats") -async def platform_stats(request: Request): - """Platform statistics from live services and Supabase.""" - from app.rag_service import get_stats as rag_stats_fn - - stats = { - "total_scans": 0, - "rugs_detected": 0, - "wallets_analyzed": 0, - "active_cases": 0, - "uptime_hours": 0, - "active_chains": 9, - "news_articles_24h": 0, - "scam_school_lessons": 48, - "rag_documents": 0, - "x402_tools": 0, - } - - # Real counts from Redis (incremented by scan endpoints) - try: - from app.redis_client import get_redis - r = get_redis() - if r: - for key, stat_key in [ - ("rmi:stats:total_scans", "total_scans"), - ("rmi:stats:scans_24h", "total_scans"), # fallback - ("rmi:stats:rugs_detected", "rugs_detected"), - ("rmi:stats:wallets_analyzed", "wallets_analyzed"), - ("rmi:stats:active_cases", "active_cases"), - ]: - val = r.get(key) - if val: - stats[stat_key] = max(stats[stat_key], int(val)) - except: pass - - # Real counts from Supabase - try: - from app.db_client import get_db - db = await get_db() - if db: - # Total scanned wallets - wallets = db.table("wallets").select("id", count="exact").execute() - if wallets.count: - stats["wallets_analyzed"] = max(stats["wallets_analyzed"], wallets.count) - - # Active watchlist/alerts as proxy for cases - alerts = db.table("alerts").select("id", count="exact").execute() - if alerts.count: - stats["active_cases"] = max(stats["active_cases"], alerts.count) - except: pass - - # News count from live service - try: - articles = await _fetch_all_news(limit=100) - if articles and isinstance(articles, list): - stats["news_articles_24h"] = min(len(articles), 200) - except: pass - - # RAG document count - try: - rag = await rag_stats_fn() - stats["rag_documents"] = rag.get("total_documents", 0) - except: pass - - # x402 tool count - try: - from app.routers.x402_catalog import get_catalog - catalog = get_catalog() if callable(get_catalog) else {} - stats["x402_tools"] = catalog.get("total_tools", 0) - except: pass - - # Uptime - try: - import psutil - stats["uptime_hours"] = round((time.time() - psutil.Process().create_time()) / 3600, 1) - except: - # Fallback: use container start time if available - stats["uptime_hours"] = 0 - - # If still zeroes, show platform capability numbers instead of dead zeros - if stats["total_scans"] == 0: - stats["total_scans"] = stats.get("x402_tools", 0) # tools available = scans possible - if stats["active_chains"] == 0: - stats["active_chains"] = 9 - - return stats - -@app.post("/api/v1/analytics/network-graph") -async def network_graph(request: Request, data: dict): - """Generate network graph for visualization.""" - from app.advanced_analysis import trace_funding - address = data.get("address", "") - chain = data.get("chain", "ethereum") - depth = data.get("depth", 3) - return await trace_funding(address, chain, max_hops=data.get("max_hops", 50), max_depth=depth) - -@app.get("/api/v1/wallet/{address}/balance") -async def wallet_balance(request: Request, address: str, chain: str = "ethereum"): - """Get wallet balance + token holdings via RPC.""" - from app.advanced_analysis import get_wallet_balance - return await get_wallet_balance(address, chain) - -@app.get("/api/v1/wallet/{address}/transactions") -async def wallet_transactions(request: Request, address: str, chain: str = "ethereum", limit: int = 50): - """Get wallet transaction history.""" - from app.advanced_analysis import get_transaction_history - return await get_transaction_history(address, chain, limit=limit) - -@app.get("/api/v1/contract/analyze/{address}") -async def contract_rug_analysis(request: Request, address: str, chain: str = "ethereum", tier: str = "free"): - """100-factor contract rug risk analysis.""" - from app.advanced_analysis import analyze_contract_rug_risk - return await analyze_contract_rug_risk(address, chain, tier=tier) - -# ═══════════════════════════════════════════════════════════ -# NEWS ENDPOINTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/news") -async def get_news(request: Request, sentiment: Optional[str] = None, source: Optional[str] = None, limit: int = 50): - """Get news articles with optional filters.""" - articles = await _fetch_all_news(limit=100) - if sentiment: - articles = [a for a in articles if a.get("sentiment", "").lower() == sentiment.lower()] - if source: - articles = [a for a in articles if source.lower() in a.get("source", "").lower()] - articles = articles[:limit] - return { - "news": articles, - "total": len(articles), - "sources": sorted(set(a.get("source", "") for a in articles)), - } - -@app.get("/api/v1/news/headlines") -async def get_news_headlines(request: Request, limit: int = 10): - """Get top news headlines.""" - articles = await _fetch_all_news(limit=100) - headlines = [ - { - "title": a.get("title", ""), - "url": a.get("url", "#"), - "source": a.get("source", ""), - "published_at": a.get("published_at", ""), - "category": a.get("category", ""), - "sentiment": a.get("sentiment", "neutral"), - } - for a in articles[:limit] - ] - return {"headlines": headlines, "count": len(headlines)} - -@app.get("/api/v1/news/twitter") -async def get_twitter_sentiment(request: Request): - """Get Twitter/X trending — extracted from news cache (instant).""" - # Pull from existing news cache for instant response - articles = await _fetch_all_news(limit=100) - twitter_items = [a for a in articles if a.get("is_twitter")] - bullish = sum(1 for a in twitter_items if a.get("sentiment") in ("bullish", "slightly_bullish")) - bearish = sum(1 for a in twitter_items if a.get("sentiment") in ("bearish", "slightly_bearish")) - total = len(twitter_items) or 1 - return { - "sentiment": { - "overall": "bullish" if bullish > bearish else "bearish" if bearish > bullish else "neutral", - "positive": round(bullish/total*100, 1), - "neutral": round((total-bullish-bearish)/total*100, 1), - "negative": round(bearish/total*100, 1), - }, - "tweets": [{"id": a.get("id",""), "text": a.get("title",""), "author": "", "handle": a.get("source","").replace("@",""), "timestamp": a.get("published_at",""), "likes": 0, "reposts": 0, "replies": 0, "sentiment": a.get("sentiment","neutral")} for a in twitter_items[:15]], - "fetched_at": datetime.now(timezone.utc).isoformat(), - } - -# ═══════════════════════════════════════════════════════════ -# @CryptoRugMunch FEED ENDPOINT -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/feed/posts") -async def get_feed_posts(request: Request, limit: int = 20): - """Get @CryptoRugMunch feed — combines longform, shortform, twitter, telegram posts.""" - # Pull from news cache, filter for feed-worthy items - articles = await _fetch_all_news(limit=50) - posts = [] - - # Convert Twitter items to feed posts - for a in articles: - if a.get("is_twitter"): - posts.append({ - "id": a.get("id", ""), - "title": a.get("title", ""), - "content": a.get("excerpt", a.get("title", "")), - "type": "twitter", - "published_at": a.get("published_at", ""), - "url": a.get("url", ""), - "author": a.get("source", "").replace("@", ""), - }) - - # Add notable RSS articles as shortform - for a in articles: - if not a.get("is_twitter") and not a.get("is_reddit"): - if a.get("ai_score", 0) >= 8.0: - posts.append({ - "id": a.get("id", ""), - "title": a.get("title", ""), - "content": a.get("excerpt", ""), - "type": "shortform", - "published_at": a.get("published_at", ""), - "url": a.get("url", ""), - "author": a.get("source", ""), - }) - - posts.sort(key=lambda x: x.get("published_at", ""), reverse=True) - return {"posts": posts[:limit], "count": len(posts[:limit])} - -# ═══════════════════════════════════════════════════════════ -# CHAT / AI ASSISTANT -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/chat/stats") -async def get_chat_stats(request: Request): - """Get chat usage statistics.""" - return { - "total_messages": 15234, - "total_users": 892, - "avg_response_time_ms": 1200, - "top_models_used": ["groq/llama3", "groq/mixtral"], - "daily_active_users": 145, - } - -@app.get("/api/v1/chat/history") -async def get_chat_history(request: Request, limit: int = 50): - """Get recent chat history.""" - return { - "messages": [ - {"role": "user", "content": "What's the latest on $SOL?", "timestamp": "2026-05-13T10:00:00Z"}, - {"role": "assistant", "content": "SOL is showing bullish signals...", "timestamp": "2026-05-13T10:00:05Z"}, - ], - "total": 2, - } - -@app.post("/api/v1/ai/complete") -async def ai_complete(request: Request, data: dict): - """AI text completion endpoint.""" - return {"completion": "Response text here", "model": "llama3", "tokens_used": 42} - -@app.post("/api/v1/ai/scam-detect") -async def ai_scam_detect(request: Request, data: dict): - """AI-powered scam detection.""" - return { - "is_scam": False, - "confidence": 0.92, - "reasons": ["No honeypot detected", "Liquidity locked"], - } - -@app.post("/api/v1/ai/summarize") -async def ai_summarize(request: Request, data: dict): - """AI text summarization.""" - return {"summary": "Brief summary of the provided text"} - -@app.post("/api/v1/ai/analyze-wallet") -async def ai_analyze_wallet(request: Request, data: dict): - """AI wallet analysis.""" - return { - "risk_score": 25, - "risk_level": "low", - "summary": "Wallet appears to be a normal trading wallet", - } - -@app.get("/api/v1/ai/providers") -async def get_ai_providers(request: Request): - """List available AI providers.""" - return { - "providers": ["groq", "openrouter", "local"], - "default": "groq", - "usage": {"groq": 150, "openrouter": 42, "local": 0}, - } - -# ═══════════════════════════════════════════════════════════ -# SECURE AI PROXY — server-side keys, prompt injection guards -# ═══════════════════════════════════════════════════════════ - -# Load all AI provider keys server-side (NEVER exposed to frontend) -_AI_KEYS = {} -for _env_var, _provider in [ - ("GROQ_API_KEY", "groq"), - ("CEREBRAS_API_KEY", "cerebras"), - ("NVIDIA_API_KEY", "nvidia"), - ("OPENROUTER_API_KEY", "openrouter"), - ("CLOUDFLARE_API_TOKEN", "cloudflare"), - ("MISTRAL_API_KEY", "mistral"), - ("GEMINI_API_KEY", "gemini"), - ("KILOCODE_API_KEY", "kilocode"), -]: - _val = os.environ.get(_env_var, "") - if not _val: - try: - with open(f"/root/.secrets/{_env_var.lower()}", "r") as f: - _val = f.read().strip() - except Exception: - pass - if _val: - _AI_KEYS[_provider] = _val - -# Provider configs: (base_url, auth_header_name, model_list) -AI_PROVIDERS = { - "kilocode": { - "base_url": "https://api.kilo.ai/api/gateway/chat/completions", - "auth_header": "Authorization", - "auth_prefix": "Bearer ", - "models": [ - {"id": "qwen/qwen3.6-plus", "name": "Qwen3.6 Plus 128K (Deep)", "context": 128000, "specialty": ["general", "coding", "deep_analysis"], "tier": "deep"}, - {"id": "nvidia/nemotron-3-super-120b-a12b:free", "name": "Nemotron 120B (Deep)", "context": 131072, "specialty": ["intel", "analysis"], "tier": "deep"}, - {"id": "kilo-auto/free", "name": "KiloCode Auto (Fast)", "context": 131072, "specialty": ["general", "fast"], "tier": "fast"}, - {"id": "deepseek/deepseek-v4-flash", "name": "DeepSeek V4 Flash (1M)", "context": 1048576, "specialty": ["coding", "analysis", "fast"], "tier": "deep"}, - {"id": "deepseek/deepseek-v4-pro", "name": "DeepSeek V4 Pro (1M)", "context": 1048576, "specialty": ["deep_analysis", "reasoning"], "tier": "deep"}, - ], - }, - "groq": { - "base_url": "https://api.groq.com/openai/v1/chat/completions", - "auth_header": "Authorization", - "auth_prefix": "Bearer ", - "models": [ - {"id": "llama-3.3-70b-versatile", "name": "Llama 3.3 70B (Groq Fast)", "context": 131072, "specialty": ["general", "coding", "fast"], "tier": "fast"}, - {"id": "meta-llama/llama-4-scout-17b-16e-instruct", "name": "Llama 4 Scout 17B (Groq Fast)", "context": 131072, "specialty": ["general", "coding"], "tier": "fast"}, - ], - }, - "cerebras": { - "base_url": "https://api.cerebras.ai/v1/chat/completions", - "auth_header": "Authorization", - "auth_prefix": "Bearer ", - "models": [ - {"id": "gpt-oss-120b", "name": "GPT-OSS 120B (Cerebras Fast)", "context": 131072, "specialty": ["general", "coding", "fast"], "tier": "fast"}, - {"id": "qwen-3-235b-a22b-instruct-2507", "name": "Qwen3 235B MoE (Cerebras Deep)", "context": 131072, "specialty": ["intel", "deep_analysis"], "tier": "deep"}, - ], - }, - "nvidia": { - "base_url": "https://integrate.api.nvidia.com/v1/chat/completions", - "auth_header": "Authorization", - "auth_prefix": "Bearer ", - "models": [ - {"id": "meta/llama-3.3-70b-instruct", "name": "Llama 3.3 70B (NVIDIA Fast)", "context": 131072, "specialty": ["coding", "general", "fast"], "tier": "fast"}, - {"id": "qwen/qwen3-next-80b-a3b-instruct", "name": "Qwen3 Next 80B (NVIDIA Fast)", "context": 32768, "specialty": ["coding", "fast"], "tier": "fast"}, - ], - }, - "openrouter": { - "base_url": "https://openrouter.ai/api/v1/chat/completions", - "auth_header": "Authorization", - "auth_prefix": "Bearer ", - "models": [ - {"id": "minimax/minimax-m2.5:free", "name": "MiniMax M2.5 (OR Fast)", "context": 196608, "specialty": ["general", "coding", "fast"], "tier": "fast"}, - {"id": "google/gemma-4-31b-it:free", "name": "Gemma 4 31B (OR Deep)", "context": 262144, "specialty": ["analysis", "intel"], "tier": "deep"}, - ], - }, -} - -# Rate limiting: IP -> (count, window_start) -_RATE_LIMITS: dict[str, tuple[int, float]] = {} -_RATE_LIMIT_MAX = 30 # requests per window -_RATE_LIMIT_WINDOW = 60 # seconds - -# AI Chat free tier limit: IP -> prompt count -_AI_FREE_LIMITS: dict[str, int] = {} -_AI_FREE_LIMIT_MAX = 5 # free prompts before requiring auth - -# ═══ PROMPT INJECTION GUARD ═══ -_PROMPT_INJECTION_PATTERNS = [ - r"ignore (all |)(previous|above|prior) (instructions?|directives?|prompts?)", - r"you are now.*(?:DAN|jailbreak|evil|unrestricted|unfiltered)", - r"pretend (you are|to be) ", - r"system:\s*", - r"<\|im_start\|>system", - r"\[system\]", - r"override.*(?:safety|guardrail|filter|instruction)", - r"bypass.*(?:filter|safety|content)", - r"forget (all |)(previous|prior) (instructions?|training)", - r"new (system )?prompt:", - r"from now on you are", - r"act as if", - r"developer mode", - r"do not follow.*(?:guidelines|rules|instructions)", -] - -_DANGEROUS_CONTENT_PATTERNS = [ - r"(how to|teach me to) (hack|crack|exploit|phish|ddos|ransom)", - r"(generate|write|create).*(?:malware|virus|trojan|worm|exploit code)", - r"(child|minor|underage).*(?:sexual|explicit|abuse|porn)", - r"(bomb|weapon|poison|toxin).*(?:how to|recipe|instructions)", - r"(social security|credit card|ssn).*(?:steal|generate|fake|hack)", - r"instructions? (for|to) (commit|carry out).*(?:crime|murder|theft|fraud)", -] - -def _check_prompt_safety(prompt: str) -> str | None: - """Returns rejection message if prompt is dangerous, None if safe.""" - import re - plow = prompt.lower() - - # Prompt injection check - for pattern in _PROMPT_INJECTION_PATTERNS: - if re.search(pattern, plow, re.IGNORECASE): - return "I cannot process this request. It appears to contain prompt injection or jailbreak attempts." - - # Dangerous content check - for pattern in _DANGEROUS_CONTENT_PATTERNS: - if re.search(pattern, plow, re.IGNORECASE): - return "I cannot assist with this request. It falls outside my safety guidelines." - - # Length limits - if len(prompt) > 8000: - return "Message too long. Please keep prompts under 8,000 characters." - - return None - -def _check_rate_limit(ip: str) -> str | None: - """Returns error if rate limited, None if allowed.""" - now = time.time() - entry = _RATE_LIMITS.get(ip) - if entry: - count, window_start = entry - if now - window_start < _RATE_LIMIT_WINDOW: - if count >= _RATE_LIMIT_MAX: - return f"Rate limit reached. Please wait {int(_RATE_LIMIT_WINDOW - (now - window_start))} seconds." - _RATE_LIMITS[ip] = (count + 1, window_start) - else: - _RATE_LIMITS[ip] = (1, now) - else: - _RATE_LIMITS[ip] = (1, now) - # Cleanup old entries periodically - if len(_RATE_LIMITS) > 1000: - _RATE_LIMITS.clear() - return None - -async def _call_ai_provider(provider: str, model_id: str, messages: list, max_tokens: int = 1024, temperature: float = 0.3) -> dict: - """Call an AI provider and return standardized response.""" - cfg = AI_PROVIDERS.get(provider) - if not cfg: - return {"error": f"Unknown provider: {provider}"} - - key = _AI_KEYS.get(provider) - if cfg.get("auth_header") and not key: - return {"error": f"Provider {provider} not configured (no API key)"} - - headers = {"Content-Type": "application/json"} - if cfg.get("auth_header") and key: - headers[cfg["auth_header"]] = (cfg.get("auth_prefix", "") + key).strip() - - # OpenRouter specific headers - if provider == "openrouter": - headers["HTTP-Referer"] = "https://rugmunch.io" - headers["X-Title"] = "RMI Intelligence" - - body = { - "model": model_id, - "messages": messages, - "max_tokens": min(max_tokens, 4096), - "temperature": temperature, - } - - try: - async with httpx.AsyncClient(timeout=45) as c: - r = await c.post(cfg["base_url"], json=body, headers=headers) - if r.status_code == 200: - data = r.json() - choice = data.get("choices", [{}])[0] - msg = choice.get("message", {}) - content = msg.get("content", "") or "" - if not content and provider == "groq": - # Groq sometimes returns empty content on short max_tokens - content = "(response truncated — increase max_tokens)" - return { - "text": content, - "model": data.get("model", model_id), - "provider": provider, - "usage": data.get("usage", {}), - } - elif r.status_code == 429: - return {"error": f"Rate limited by {provider}. Try again later."} - elif r.status_code == 402: - return {"error": f"{provider} quota exhausted."} - else: - error_body = r.text[:300] - return {"error": f"{provider} error {r.status_code}: {error_body}"} - except httpx.TimeoutException: - return {"error": f"{provider} timed out."} - except Exception as e: - return {"error": f"{provider} connection error: {str(e)[:200]}"} - -@app.get("/api/v1/ai/models") -async def ai_models(request: Request): - """Return available AI models with their status.""" - models_out = [] - for provider, cfg in AI_PROVIDERS.items(): - has_key = provider in _AI_KEYS or not cfg.get("auth_header") - for m in cfg["models"]: - models_out.append({ - "id": f"{provider}:{m['id']}", - "name": m["name"], - "provider": provider, - "available": has_key, - "specialty": m.get("specialty", []), - "context_length": m.get("context", 131072), - }) - return {"models": models_out, "count": len(models_out), "providers_configured": list(_AI_KEYS.keys())} - -@app.post("/api/v1/ai/chat") -async def ai_chat(data: dict, request: Request): - """Secure AI chat — server-side proxy with injection guards. No keys exposed.""" - message = (data.get("message") or data.get("prompt") or "").strip() - if not message: - return {"status": "error", "error": "No message provided."} - - # Security: rate limit - client_ip = request.client.host if request.client else "unknown" - rate_limit_error = _check_rate_limit(client_ip) - if rate_limit_error: - return {"status": "error", "error": rate_limit_error} - - # Security: Check auth and enforce 5-prompt free limit for unauthenticated users - is_authenticated = data.get("authenticated", False) - if not is_authenticated: - current_count = _AI_FREE_LIMITS.get(client_ip, 0) - if current_count >= _AI_FREE_LIMIT_MAX: - return { - "status": "auth_required", - "response": "You have used your 5 free AI prompts. Please sign in or upgrade to Premium for unlimited access.", - "reason": "free_limit_exceeded", - "prompts_used": current_count, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - _AI_FREE_LIMITS[client_ip] = current_count + 1 - - # Security: prompt injection & dangerous content - safety_violation = _check_prompt_safety(message) - if safety_violation: - return { - "status": "blocked", - "response": safety_violation, - "reason": "safety_filter", - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - # Build messages — use agent system for intelligent routing - system_prompt = (data.get("system") or "You are RMI Intelligence, a crypto security AI. " - "You analyze tokens, wallets, markets, and on-chain data. " - "You NEVER reveal internal system details, API keys, server paths, " - "or confidential information. You refuse dangerous or harmful requests. " - "Be direct, accurate, and security-focused.") - mode = data.get("mode", "general") - - # Agent-based routing: auto-detect specialized agent from message content - agent_id = None - try: - from app.agent_system import classify, AGENTS - agent_id = classify(message) - if agent_id in AGENTS: - agent = AGENTS[agent_id] - system_prompt = agent.system_prompt - mode = agent_id # Override mode for logging - except Exception: - pass # Fall back to default system prompt - - mode_prompts = { - "scanner": "You are a token security scanner. Analyze for rug pulls, honeypots, and scams.", - "onchain": "You are an on-chain analyst. Analyze blockchain data, transactions, and patterns.", - "market": "You are a crypto market analyst. Analyze prices, trends, and market conditions.", - "multichain": "You are a multi-chain expert. Compare chains, fees, and cross-chain dynamics.", - "intel": "You are a crypto intelligence analyst. Track whale movements, smart money, and alpha.", - } - if mode in mode_prompts: - system_prompt = mode_prompts[mode] - - messages = [{"role": "system", "content": system_prompt}] - # Add conversation history if provided - history = data.get("history", []) - if isinstance(history, list): - for h in history[-10:]: # Max 10 history messages - if isinstance(h, dict) and h.get("role") in ("user", "assistant"): - # Also safety-check history - content = str(h.get("content", ""))[:2000] - messages.append({"role": h["role"], "content": content}) - messages.append({"role": "user", "content": message[:4000]}) - - # Try providers in priority order — match model tier to mode - preferred = data.get("provider", "") - # Fast modes: general, scanner, market → use fast-tier models - # Deep modes: onchain, intel, multichain → use deep-tier models - deep_modes = {"onchain", "intel", "multichain"} - target_tier = "deep" if mode in deep_modes else "fast" - - priority = ["kilocode", "mistral", "groq", "cerebras", "nvidia", "openrouter"] - if preferred and preferred in AI_PROVIDERS: - priority.insert(0, preferred) - - errors = [] - # Try target tier first, then fall back to any tier - for tier in [target_tier, "fast" if target_tier == "deep" else "deep"]: - for provider in priority: - if provider not in AI_PROVIDERS: - continue - cfg = AI_PROVIDERS[provider] - if cfg.get("auth_header") and provider not in _AI_KEYS: - continue - # Find a model matching this tier - for model in cfg["models"]: - if model.get("tier") != tier: - continue - result = await _call_ai_provider( - provider, model["id"], messages, - max_tokens=data.get("max_tokens", 1024), - temperature=data.get("temperature", 0.3), - ) - if "error" not in result: - return { - "status": "success", - "response": result["text"], - "model": result["model"], - "provider": provider, - "mode": mode, - "tier": tier, - "usage": result.get("usage", {}), - "timestamp": datetime.now(timezone.utc).isoformat(), - } - errors.append(f"{provider}/{model['name']}: {result['error']}") - - # All providers failed - return { - "status": "error", - "response": "All AI providers are currently unavailable. Please try again in a moment.", - "errors": errors, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - -@app.get("/api/v1/ai/stats") -async def ai_stats(request: Request): - """Get AI usage statistics (no internal details exposed).""" - return { - "providers_available": len(_AI_KEYS), - "models_total": sum(len(cfg["models"]) for cfg in AI_PROVIDERS.values()), - "rate_limited_ips": len(_RATE_LIMITS), - "status": "operational", - } - - -@app.post("/api/v1/ai/chat/stream") -async def ai_chat_stream(data: dict, request: Request): - """Streaming AI chat — SSE token-by-token. Same security as /ai/chat.""" - from fastapi.responses import StreamingResponse - - message = (data.get("message") or data.get("prompt") or "").strip() - if not message: - return StreamingResponse( - _sse_error("No message provided"), media_type="text/event-stream") - - client_ip = request.client.host if request.client else "unknown" - rate_err = _check_rate_limit(client_ip) - if rate_err: - return StreamingResponse( - _sse_error(rate_err), media_type="text/event-stream") - - safety = _check_prompt_safety(message) - if safety: - return StreamingResponse( - _sse_event("blocked", safety), media_type="text/event-stream") - - system_prompt = data.get("system") or "You are RMI Intelligence, a crypto security AI. Be concise, accurate, and direct." - mode = data.get("mode", "general") - messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": message}] - - preferred = data.get("provider", "") - deep_modes = {"onchain", "intel", "multichain"} - target_tier = "deep" if mode in deep_modes else "fast" - - priority = ["kilocode", "mistral", "groq", "cerebras", "nvidia", "openrouter"] - if preferred and preferred in AI_PROVIDERS: - priority.insert(0, preferred) - - async def stream(): - for tier in [target_tier, "fast" if target_tier == "deep" else "deep"]: - for provider_name in priority: - if provider_name not in AI_PROVIDERS: - continue - cfg = AI_PROVIDERS[provider_name] - if cfg.get("auth_header") and provider_name not in _AI_KEYS: - continue - for model in cfg["models"]: - if model.get("tier") != tier: - continue - - key = _AI_KEYS.get(provider_name) - headers = {"Content-Type": "application/json"} - if cfg.get("auth_header") and key: - headers[cfg["auth_header"]] = (cfg.get("auth_prefix", "") + key).strip() - if provider_name == "openrouter": - headers["HTTP-Referer"] = "https://rugmunch.io" - headers["X-Title"] = "RMI Intelligence" - - body = { - "model": model["id"], - "messages": messages, - "max_tokens": min(data.get("max_tokens", 1024), 4096), - "temperature": data.get("temperature", 0.3), - "stream": True, - } - - try: - async with httpx.AsyncClient(timeout=60) as c: - async with c.stream("POST", cfg["base_url"], json=body, headers=headers) as r: - if r.status_code == 200: - yield _sse_event("start", json.dumps({"model": model["id"], "provider": provider_name})) - async for line in r.aiter_lines(): - if line.startswith("data: "): - data_str = line[6:] - if data_str == "[DONE]": - yield _sse_event("done", "{}") - return - try: - chunk = json.loads(data_str) - delta = chunk.get("choices", [{}])[0].get("delta", {}) - content = delta.get("content", "") - if content: - yield _sse_event("token", json.dumps({"text": content})) - except json.JSONDecodeError: - pass - yield _sse_event("done", "{}") - return - elif r.status_code == 429: - continue # Try next - except Exception: - continue # Try next provider - - yield _sse_event("error", json.dumps({"message": "All AI providers unavailable"})) - - return StreamingResponse(stream(), media_type="text/event-stream") - - -def _sse_event(event: str, data: str) -> str: - return f"event: {event}\ndata: {data}\n\n" - -def _sse_error(msg: str) -> str: - return _sse_event("error", json.dumps({"message": msg})) - - -@app.post("/api/v1/rag/semantic-search") -async def rag_semantic_search(request: Request, data: dict): - """Semantic token search — 'find tokens like this description'. - - Uses multi-head embedding RAG to find tokens matching a natural - language description, scam pattern, or token profile. - - Example: POST {"query": "meme coins with renounced ownership and locked liquidity"} - Returns: ranked tokens with similarity scores and risk context.""" - from app.rag_service import three_pillar_search - - query = data.get("query", "").strip() - if not query: - return {"status": "error", "error": "query required"} - - limit = min(data.get("limit", 10), 20) - collection = data.get("collection", "token_analysis") - collections = [collection] if isinstance(collection, str) else (collection or ["token_analysis"]) - - results = await three_pillar_search( - query=query, - collections=collections, - limit=limit, - use_mmr=True, - use_kg=True, - use_reranker=False, - ) - - return { - "status": "ok", - "query": query, - "results": results, - "count": len(results), - } - -# ═══════════════════════════════════════════════════════════ -# SECURITY & SCANNING -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/security/scan") -async def security_scan(request: Request, data: dict): - """Token security scan — alias for /api/v1/token/scan using SENTINEL pipeline. - Freemium: 5 free scans/day (IP-based), then upsell to PRO/ELITE. - """ - return await scan_token(request, data) - -@app.get("/api/v1/security/scan/{address}") -async def get_security_scan_result(request: Request, address: str, chain: str = "solana"): - """GET token security scan — alias for /api/v1/token/scan using SENTINEL pipeline.""" - return await scan_token_get(request, address, chain=chain) - -@app.post("/api/v1/crypto/full-scan") -async def crypto_full_scan(request: Request, data: dict): - """Full crypto scan — SENTINEL pipeline + Helius whale/sniper + GMGN market data. - - Frontend entry point for the Token Scanner. Dispatches to the unified scanner, - then enriches with Helius whale/sniper data and GMGN trending info. - Transforms SENTINEL response into the flat shape the frontend expects. - Freemium: 5 free scans/day (IP-based), then upsell to PRO/ELITE. - """ - token_address = data.get("address") or data.get("token_address", "") - chain = data.get("chain", "solana").lower() - tier = data.get("tier", "free") - - if not token_address: - raise HTTPException(status_code=400, detail="Address is required") - - # ── Run the real SENTINEL scanner with rate limiting ── - scan_data = {"token_address": token_address, "address": token_address, "chain": chain, "tier": tier} - sentinel_result = await scan_token(request, scan_data) - - # If rate-limited, return immediately - if isinstance(sentinel_result, dict) and sentinel_result.get("status") == "limit_reached": - return sentinel_result - - # ── Extract data from SENTINEL response ── - # sentinel_result shape: {token, chain, symbol, name, safety_score, confidence, - # risk_flags, tier, modules_analyzed, free: {...}, pro, elite, scanned_at, usage} - sentinel_free = sentinel_result.get("free", {}) - safety = sentinel_result.get("safety_score", 50) - # Frontend uses risk_score 0-100 (higher = more risky), sentinel uses safety_score (higher = safer) - risk_score = 100 - safety - - # ── Build flat response shape matching the frontend ScanResult interface ── - result = { - "token_address": sentinel_result.get("token", token_address), - "chain": sentinel_result.get("chain", chain), - "symbol": sentinel_result.get("symbol", ""), - "name": sentinel_result.get("name", ""), - "overall_risk": sentinel_result.get("tier", "free"), - "risk_score": risk_score, - "risk_level": "critical" if risk_score >= 70 else "high" if risk_score >= 40 else "medium" if risk_score >= 20 else "low", - "safety_score": safety, - "confidence": sentinel_result.get("confidence", 0), - # Honeypot — from free tier modules - "honeypot_detected": sentinel_free.get("honeypot_risk") == "high" or sentinel_free.get("honeypot_is", {}).get("is_honeypot", False), - # Mint/Freeze authority — from free tier market data - "mint_authority_renounced": sentinel_free.get("mint_authority") == "renounced" or sentinel_free.get("mint_authority") is None, - "freeze_authority_renounced": sentinel_free.get("freeze_authority") == "renounced" or sentinel_free.get("freeze_authority") is None, - # Liquidity - "total_liquidity_usd": sentinel_free.get("liquidity_usd", 0), - "liquidity_pools": sentinel_free.get("liquidity_pools", []), - # Holder concentration - "holder_concentration": sentinel_free.get("holders", {}), - # Tax info - "tax_info": sentinel_free.get("buy_tax") is not None or sentinel_free.get("sell_tax") is not None and { - "buy_tax": sentinel_free.get("buy_tax", 0), - "sell_tax": sentinel_free.get("sell_tax", 0), - "transfer_tax": sentinel_free.get("transfer_tax", 0), - } or None, - # Flags - "red_flags": sentinel_result.get("risk_flags", []), - "green_flags": sentinel_free.get("green_flags", []), - "analyzed_at": sentinel_result.get("scanned_at", datetime.now(timezone.utc).isoformat()), - "ai_consensus": sentinel_free.get("ai_consensus") or sentinel_free.get("rag_scam_check", {}).get("summary"), - "modules_analyzed": sentinel_result.get("modules_analyzed", 0), - "modules_run": sentinel_free.get("modules_run", []), - # Tier info - "tier_required": sentinel_result.get("tier", "free"), - "pro": sentinel_result.get("pro"), - "elite": sentinel_result.get("elite"), - # Usage/billing - **({k: v for k, v in sentinel_result.items() if k == "usage"}), - **({k: v for k, v in sentinel_result.items() if k == "upgrade_hint"}), - } - - # ── Enrich with Helius whale/sniper data (Solana only) ── - if chain == "solana": - try: - watcher = WhaleWatcher() - whales = await watcher.scan_token_for_whales(token_address) - result["whale_data"] = {"whales": whales, "total_detected": len(whales) if whales else 0} - except Exception: - result["whale_data"] = {"whales": [], "total_detected": 0, "error": "unavailable"} - - try: - detector = SniperDetector() - snipers = await detector.detect_snipers(token_address) - result["sniper_data"] = {"snipers": snipers, "total_detected": len(snipers) if snipers else 0} - except Exception: - result["sniper_data"] = {"snipers": [], "total_detected": 0, "error": "unavailable"} - - # ── Enrich with GMGN market data ── - try: - gmgn = GMGNClient() - if chain == "solana": - trending = await gmgn.get_trending_tokens(chain="sol") - result["market_data"] = {"trending_count": len(trending) if trending else 0} - else: - result["market_data"] = {} - except Exception: - result["market_data"] = {"error": "unavailable"} - - # Broadcast full-scan result via WebSocket - try: - import asyncio as _asyncio_fs - _asyncio_fs.create_task(ws_broadcast_scan({ - "token": result.get("token_address", token_address), - "chain": chain, - "risk_score": result.get("risk_score", 0), - "safety_score": result.get("safety_score", 50), - "risk_flags": result.get("red_flags", [])[:5], - "source": "full_scan", - })) - except Exception: - pass - - return result - -# ═══════════════════════════════════════════════════════════ -# HELIUS ENDPOINTS — Real implementations via helius_tools -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/helius/whale-scan") -async def helius_whale_scan(request: Request, data: dict): - """Detect whale transactions using Helius RPC.""" - token = data.get("address", data.get("token", "")) - chain = data.get("chain", "solana") - try: - if chain == "solana": - watcher = WhaleWatcher() - result = await watcher.scan_token_for_whales(token) - return {"whales": result, "total_detected": len(result), "chain": chain} - except Exception: - pass - return {"whales": [], "total_detected": 0, "message": "Helius API key required or rate limited"} - -@app.post("/api/v1/helius/whale-profile") -async def helius_whale_profile(request: Request, data: dict): - """Get whale wallet profile using Helius.""" - wallet = data.get("wallet", data.get("address", "")) - chain = data.get("chain", "solana") - try: - if chain == "solana": - # Use Solscan for wallet data (free) - solscan = FreeSolscanClient() - account = solscan.account_info(wallet) or {} - portfolio = solscan.account_portfolio(wallet) or [] - total_value = sum( - float(h.get("amount", 0) or 0) / (10 ** (h.get("token", {}).get("decimals", 0) or 1)) * float(h.get("priceUsdt", 0) or 0) - for h in portfolio[:30] - ) - return { - "wallet": wallet, - "chain": chain, - "total_value_usd": round(total_value, 2), - "top_tokens": [ - {"symbol": h.get("token", {}).get("symbol", "?"), - "amount": float(h.get("amount", 0) or 0) / (10 ** (h.get("token", {}).get("decimals", 0) or 1)), - "value_usd": float(h.get("priceUsdt", 0) or 0) * float(h.get("amount", 0) or 0) / (10 ** (h.get("token", {}).get("decimals", 0) or 1))} - for h in portfolio[:10] - ], - "transaction_count": 0, - "first_seen": account.get("created_time", ""), - "source": "solscan (free)" - } - except Exception: - pass - return {"wallet": wallet, "total_value_usd": 0, "top_tokens": [], "transaction_count": 0, "first_seen": ""} - -# ═══════════════════════════════════════════════════════════ -# TOOL FINGERPRINTING — Best-in-class scam infrastructure detection -# Phase 3-5: Smithii, Printr, LaunchLab, Jito, volume bots, aging, cross-chain -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/scanner/fingerprint") -async def scanner_fingerprint(request: Request, data: dict): - """Tool fingerprinting — detect scammer infrastructure behind a token.""" - from app.tool_fingerprint import fingerprint_token - token = data.get("token_address", data.get("address", "")) - chain = data.get("chain", "solana") - try: - result = await fingerprint_token(token_address=token, chain=chain, - deployer=data.get("deployer"), holders=data.get("holders"), - transactions=data.get("transactions")) - return result - except Exception as e: - return {"error": str(e), "token_address": token, "chain": chain} - -@app.post("/api/v1/scanner/unified") -async def scanner_unified(request: Request, data: dict): - """Unified token + wallet scan with fingerprinting. Freemium: 5 free/day.""" - # Rate limit check - is_internal = is_internal_request(request) - is_paid = is_x402_paid_request(request) - identity, identity_type = get_identity(request) - limit_check = check_scan_limit(identity=identity, identity_type=identity_type, tier=data.get("tier", "free"), is_internal=is_internal, is_x402_paid=is_paid) - if not limit_check["allowed"]: - return JSONResponse(status_code=429, content={"error": "daily_scan_limit_reached", "message": f"Free limit ({FREE_DAILY_LIMIT}/day) reached. Upgrade at rugmunch.io/pricing", "upgrade_url": "https://rugmunch.io/pricing", "resets_at": limit_check.get("resets_at")}) - - from app.unified_scanner import scan_wallet - from app.tool_fingerprint import fingerprint_token - wallet = data.get("wallet", data.get("address", "")) - token = data.get("token_address", "") - chain = data.get("chain", "solana") - tier = data.get("tier", "free") - include_fp = data.get("fingerprints", tier == "elite") - results = {"chain": chain, "scanned_at": datetime.now(timezone.utc).isoformat()} - if wallet: - try: - results["wallet"] = await scan_wallet(wallet, chain, tier) - except Exception as e: - results["wallet"] = {"error": str(e)} - if token and include_fp: - try: - fp = await fingerprint_token(token_address=token, chain=chain, - deployer=data.get("deployer"), holders=data.get("holders"), - transactions=data.get("transactions")) - results["fingerprint"] = fp - wallet_risk = results.get("wallet", {}).get("risk_score", 0) - results["combined_risk_score"] = max(wallet_risk, fp.get("aggregate_risk_score", 0)) - results["flags"] = (results.get("wallet", {}).get("risk_flags", []) + fp.get("flags", [])) - except Exception as e: - results["fingerprint"] = {"error": str(e)} - - # Track usage - increment_scan_usage(identity=identity, identity_type=identity_type, tier=tier, is_internal=is_internal, is_x402_paid=is_paid) - if not is_internal and not is_paid: - results["usage"] = {"remaining": max(0, limit_check.get("remaining", FREE_DAILY_LIMIT) - 1), "daily_limit": limit_check.get("limit", FREE_DAILY_LIMIT)} - return results - -@app.get("/api/v1/scanner/tools") -async def scanner_tools_list(): - """List all detectable scammer tools and their signatures.""" - from app.tool_fingerprint import TOOL_SIGNATURES, TOOL_PROGRAMS - return { - "tool_signatures": {n: {"description": s["description"], "severity": s["severity"], "patterns": s["patterns"]} for n, s in TOOL_SIGNATURES.items()}, - "known_programs": TOOL_PROGRAMS, - "total_tools": len(TOOL_SIGNATURES), - } - -@app.post("/api/v1/helius/sniper-detect") -async def helius_sniper_detect(request: Request, data: dict): - """Detect sniping bots using Helius.""" - token = data.get("address", data.get("token", "")) - chain = data.get("chain", "solana") - try: - if chain == "solana": - detector = SniperDetector() - result = await detector.analyze_token_launch(token) - return {"token": token, "snipers_detected": result.sniper_count if hasattr(result, 'sniper_count') else 0, "snipers": result.snipers if hasattr(result, 'snipers') else [], "chain": chain} - except Exception: - pass - return {"snipers_detected": 0, "snipers": [], "message": "Helius API key required"} - -@app.get("/api/v1/helius/syndicate/scan") -async def helius_syndicate_scan(request: Request, address: str = "", chain: str = "solana"): - """Scan for syndicate activity using Helius.""" - try: - if chain == "solana" and address: - tracker = SyndicateTracker() - result = await tracker.track_wallet(address) - return { - "address": address, - "chain": chain, - "syndicate_detected": bool(result.get("syndicate_links", [])), - "wallets_in_syndicate": result.get("syndicate_links", []), - "confidence": result.get("confidence", 0) - } - except Exception: - pass - return {"syndicate_detected": False, "wallets_in_syndicate": [], "confidence": 0} - -# ═══════════════════════════════════════════════════════════ -# x402 PAYMENT GATEWAY -# ═══════════════════════════════════════════════════════════ - -# ── Public File Server (on/off via FILESERVER_ENABLED env var) ── -from fastapi.responses import HTMLResponse, FileResponse - -FILESERVER_DIR = os.path.join(os.path.dirname(__file__), "public") -os.makedirs(FILESERVER_DIR, exist_ok=True) - - -@app.get("/files/{path:path}") -async def serve_file(path: str): - """Serve any file from the public fileserver directory. - - Drop files in /root/fileserver/ and access at: - https://rugmunch.io/files/filename - - On/Off: Set FILESERVER_ENABLED=true/false in .env - """ - if os.getenv("FILESERVER_ENABLED", "true").lower() != "true": - raise HTTPException(status_code=403, detail="File server is disabled") - - safe_path = os.path.normpath(os.path.join(FILESERVER_DIR, path)) - if not safe_path.startswith(FILESERVER_DIR): - raise HTTPException(status_code=403, detail="Path traversal denied") - if not os.path.exists(safe_path): - raise HTTPException(status_code=404, detail=f"File not found: {path}") - - return FileResponse(safe_path) - - -@app.get("/files") -async def list_files(): - """List available files in the public fileserver.""" - if os.getenv("FILESERVER_ENABLED", "true").lower() != "true": - raise HTTPException(status_code=403, detail="File server is disabled") - - files = [] - for f in sorted(os.listdir(FILESERVER_DIR)): - fp = os.path.join(FILESERVER_DIR, f) - if os.path.isfile(fp): - files.append({"name": f, "size": os.path.getsize(fp), "url": f"/files/{f}"}) - return {"enabled": True, "directory": FILESERVER_DIR, "files": files, "count": len(files)} - -@app.get("/api/v1/x402/stats") -async def x402_stats(request: Request): - """Get x402 payment statistics — live data from facilitator registry + Redis.""" - from app.facilitators.base import get_registry - from app.facilitators.router import get_facilitator_router - - registry = get_registry() - router = get_facilitator_router() - - # Trial stats from Redis - trial_tools = 0 - trial_requests = 0 - try: - from app.auth import get_redis - r = await get_redis() - if r: - trial_tools = await r.zcard("x402:trials_by_tool") or 0 - # Count total trial requests - cursor = 0 - while True: - cursor, keys = await r.scan(cursor, match="x402:trial:*", count=100) - for k in keys: - val = await r.get(k) - if val: - try: - trial_requests += int(val) - except (ValueError, TypeError): - pass - if cursor == 0: - break - except Exception: - pass - - registry_stats = registry.stats - - return { - "status": "active", - "facilitators": { - "total": registry_stats["total_facilitators"], - "hosted": registry_stats["hosted"], - "self_hosted": registry_stats["self_hosted"], - "fee_free": registry_stats["fee_free"], - "chains_covered": registry_stats["chains_covered"], - "facilitators": registry_stats["facilitators"], - }, - "router": router.get_stats(), - "trials": { - "tools_with_trials": trial_tools, - "total_trial_requests": trial_requests, - }, - "payment_chains": [ - # Hosted - {"chain": "base", "facilitators": ["coinbase_cdp", "payai"], "tokens": ["USDC"], "settlement": "instant/deferred"}, - {"chain": "ethereum", "facilitators": ["primev", "payai", "cloudflare_x402", "eip7702"], "tokens": ["USDC", "USDT", "DAI", "ETH"], "settlement": "fee_free/instant/deferred"}, - {"chain": "solana", "facilitators": ["payai"], "tokens": ["USDC"], "settlement": "deferred"}, - {"chain": "bsc", "facilitators": ["eip7702"], "tokens": ["USDC", "USDT"], "settlement": "self"}, - {"chain": "tron", "facilitators": ["tron_selfverify"], "tokens": ["USDT", "USDC", "USDD"], "settlement": "instant"}, # self-verified via TronGrid - {"chain": "bitcoin", "facilitators": ["bitcoin_selfverify"], "tokens": ["BTC"], "settlement": "self"}, # self-verified via Mempool.space - # Self-verified - {"chain": "arbitrum", "facilitators": ["eip7702", "x402_rs"], "tokens": ["USDC", "ETH"], "settlement": "self"}, - {"chain": "optimism", "facilitators": ["eip7702", "x402_rs"], "tokens": ["USDC", "ETH"], "settlement": "self"}, - {"chain": "polygon", "facilitators": ["eip7702", "x402_rs"], "tokens": ["USDC", "POL"], "settlement": "self"}, - {"chain": "avalanche", "facilitators": ["eip7702"], "tokens": ["USDC", "AVAX"], "settlement": "self"}, - {"chain": "fantom", "facilitators": ["eip7702"], "tokens": ["USDC", "FTM"], "settlement": "self"}, - {"chain": "gnosis", "facilitators": ["eip7702"], "tokens": ["USDC", "XDAI"], "settlement": "self"}, - # Fiat - {"chain": "sepa/eur", "facilitators": ["asterpay"], "tokens": ["EUR", "USDC"], "settlement": "off-ramp"}, - ], - "refund_policy": "Full refund if tool returns no data. Request within 48h via POST /api/v1/x402/refund.", - } - -@app.get("/api/v1/x402/trial-status") -async def x402_trial_status(request: Request): - """Get trial status for x402 — 1 free request per tool.""" - # Each tool gets 1 free trial request. Total tools available: ~80 - return { - "trial_active": True, - "trial_end_date": "2026-06-14T00:00:00Z", - "requests_remaining": 1, - "per_tool": True, - "total_tools_free_trial": 80, - } - -@app.get("/api/v1/x402-tools/discovery") -async def x402_tools_discovery(request: Request): - """Discover available x402 tools — full catalog from all chains and services.""" - from app.routers.x402_catalog import get_catalog - catalog = get_catalog() - tools = [] - seen = set() - for t in catalog.get("tools", []): - name = t.get("id", t.get("name", "")) - if name in seen: - continue - seen.add(name) - # Route to whichever worker the tool is available on - chains = t.get("chains", []) - if "BASE" in chains or "SOLANA" in chains: - endpoint_base = "https://mcp.rugmunch.io" - else: - endpoint_base = "https://mcp.rugmunch.io" - tools.append({ - "name": name, - "description": t.get("description", ""), - "endpoint": f"{endpoint_base}/tools/{name}", - "method": t.get("method", "POST"), - "price": f"${t.get('priceUsd', 0.01)}/call", - "human_price": f"${t.get('priceUsd', 0.01)}", - "category": t.get("category", "analysis"), - "network": ", ".join(chains[:3]) if chains else "multi-chain", - "auth": "x402", - "trial_free": t.get("trialFree", 1), - }) - return { - "tools": tools, - "count": len(tools), - "total_tools": catalog.get("total_tools", len(tools)), - "total_chains": catalog.get("total_chains", 7), - "categories": catalog.get("categories", []), - "source": "catalog", - "payment_chains": ["base", "solana", "ethereum", "bsc", "arbitrum", "optimism", "polygon"], - "refund_policy": "Full refund if tool returns no data. Request within 48h via POST /api/v1/x402/refund.", - "discovery_urls": { - "x402": "https://mcp.rugmunch.io/.well-known/x402", - "mcp": "https://mcp.rugmunch.io/mcp", - "catalog": "https://mcp.rugmunch.io/api/v1/x402/tools-catalog", - "openapi": "https://mcp.rugmunch.io/openapi.json", - }, - } - -# ═══════════════════════════════════════════════════════════ -# AUTH SYSTEM -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/auth/login") -async def auth_login(request: Request, data: AuthLoginRequest): - """Authenticate user with email and password.""" - return { - "access_token": "jwt_token_here", - "token_type": "bearer", - "user": { - "id": "user_123", - "email": data.email, - "username": data.email.split("@")[0], - "subscription": "free", - }, - } - -@app.post("/api/v1/auth/register") -async def auth_register(request: Request, data: AuthRegisterRequest): - """Register a new user.""" - return { - "access_token": "jwt_token_here", - "token_type": "bearer", - "user": { - "id": "user_new", - "email": data.email, - "username": data.username, - "subscription": "free", - }, - } - -@app.post("/api/v1/auth/social-login") -async def auth_social_login(request: Request, data: AuthSocialRequest): - """Social login via Google, Discord, X.""" - return { - "access_token": "jwt_token_here", - "token_type": "bearer", - "user": { - "id": "user_social", - "email": f"social@provider.com", - "username": "social_user", - "subscription": "free", - }, - } - -@app.get("/api/v1/auth/callback") -async def auth_callback(request: Request, code: str, provider: str): - """OAuth callback endpoint.""" - return {"status": "success", "message": "Authentication successful"} - -@app.post("/api/v1/auth/wallet") -async def auth_wallet(request: Request, data: AuthWalletRequest): - """Wallet-based authentication.""" - return { - "access_token": "jwt_token_here", - "token_type": "bearer", - "user": { - "id": f"wallet_{data.address[:8]}", - "address": data.address, - "chain": data.chain, - "subscription": "free", - }, - } - -@app.get("/api/v1/auth/me") -async def auth_me(request: Request): - """Get current user profile.""" - return { - "id": "user_123", - "email": "user@example.com", - "username": "crypto_user", - "subscription": "free", - "wallet_connected": False, - } - -@app.post("/api/v1/auth/logout") -async def auth_logout(request: Request): - """Logout user.""" - return {"status": "success", "message": "Logged out"} - -# ═══════════════════════════════════════════════════════════ -# WALLET SCAN -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/wallet/scan") -async def wallet_scan(request: Request, data: dict): - """Scan a wallet for risks and activity — powered by SENTINEL + Wallet Memory Bank.""" - from app.unified_scanner import scan_wallet as _scan_wallet - from app.wallet_behavior import compute_fingerprint, fingerprint_to_dict - - address = data.get("address", "") - chain = data.get("chain", "solana") - tier = data.get("tier", "free") - - if not address: - return {"status": "error", "error": "address required"} - - # Run wallet scanner - result = await _scan_wallet(address, chain, tier) - - # Add behavioral fingerprint (premium) - if tier in ("pro", "elite"): - try: - fp = compute_fingerprint(result.get("risk_factors", {})) - result["behavioral_fingerprint"] = fingerprint_to_dict(fp) - except Exception as e: - logger.warning(f"Behavioral fingerprint failed: {e}") - - # Add velocity tracking - try: - from app.velocity_risk import record_snapshot - record_snapshot(chain, address, { - "liquidity_usd": result.get("total_value_usd", 0), - "tx_count": result.get("recent_transactions", {}).get("count", 0) if isinstance(result.get("recent_transactions"), dict) else 0, - }) - except Exception: - pass - - return result - - -# ═══════════════════════════════════════════════════════════════ -# PREMIUM — AI Scam Classifier -# ═══════════════════════════════════════════════════════════════ - -@app.post("/api/v1/premium/ai-classifier/train") -async def ai_classifier_train(request: Request, data: dict): - """Train the ML scam classifier on historical scan data.""" - from app.scam_classifier import get_classifier - scans = data.get("scans", []) - if not scans: - return {"status": "error", "error": "No scan data provided. Pass {'scans': [...]} with is_scam labels."} - classifier = get_classifier() - return classifier.train(scans) - -@app.post("/api/v1/premium/ai-classifier/predict") -async def ai_classifier_predict(request: Request, data: dict): - """Get AI-powered scam probability for a scan result.""" - from app.scam_classifier import get_classifier - classifier = get_classifier() - return classifier.predict(data) - -@app.get("/api/v1/premium/ai-classifier/status") -async def ai_classifier_status(request: Request): - """Get ML classifier status and training info.""" - from app.scam_classifier import get_classifier - c = get_classifier() - return { - "trained": c.model is not None, - "samples": c.training_samples, - "features": len(c.feature_names), - "accuracy": c.accuracy, - "last_trained": c.last_trained, - } - - -# ═══════════════════════════════════════════════════════════════ -# PREMIUM — Velocity Risk + Market Context -# ═══════════════════════════════════════════════════════════════ - -@app.post("/api/v1/premium/velocity-risk") -async def velocity_risk(request: Request, data: dict): - """Analyze how fast token metrics are changing (time-series risk).""" - from app.velocity_risk import analyze_velocity, record_snapshot - - chain = data.get("chain", "solana") - address = data.get("address", "") - current = data.get("current_metrics", {}) - window = data.get("window_seconds", 3600) - - if not address: - return {"status": "error", "error": "address required"} - - record_snapshot(chain, address, current) - return analyze_velocity(chain, address, current, window) - -@app.get("/api/v1/premium/market-context") -async def market_context(request: Request): - """Get current market conditions for contextualized risk scoring.""" - from app.velocity_risk import get_market_context - return get_market_context() - -@app.post("/api/v1/premium/contextualize-score") -async def contextualize_score(request: Request, data: dict): - """Adjust safety score based on market conditions.""" - from app.velocity_risk import contextualize_score, get_market_context - - base_safety = data.get("base_safety", 50) - market = get_market_context() - return contextualize_score(base_safety, market) - - -# ═══════════════════════════════════════════════════════════════ -# PREMIUM — Wallet Behavioral Fingerprinting -# ═══════════════════════════════════════════════════════════════ - -@app.post("/api/v1/premium/wallet-fingerprint") -async def wallet_fingerprint(request: Request, data: dict): - """Compute behavioral fingerprint for a wallet — persona classification.""" - from app.wallet_behavior import compute_fingerprint, fingerprint_to_dict - fp = compute_fingerprint(data) - return fingerprint_to_dict(fp) - - -# ═══════════════════════════════════════════════════════════════ -# PREMIUM — Campaign Radar -# ═══════════════════════════════════════════════════════════════ - -@app.get("/api/v1/premium/campaigns") -async def active_campaigns(request: Request): - """Get all currently detected coordinated scam campaigns.""" - from app.campaign_radar import get_active_campaigns - return get_active_campaigns() - -@app.post("/api/v1/premium/campaigns/check") -async def check_campaigns(request: Request, data: dict): - """Check if a token is part of a coordinated campaign.""" - from app.campaign_radar import detect_campaigns - campaigns = detect_campaigns() - address = data.get("address", "").lower() - chain = data.get("chain", "") - - matched = [] - for c in campaigns: - for t in c.tokens: - if t.get("address", "").lower() == address and t.get("chain", "") == chain: - matched.append({ - "campaign_id": c.cluster_id, - "token_count": len(c.tokens), - "risk_level": c.risk_level, - "description": c.description, - }) - break - - return {"address": address, "in_campaign": len(matched) > 0, "campaigns": matched} -# ═══════════════════════════════════════════════════════════ -# PREMIUM — Entity Graph Visualization -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/premium/entity-graph") -async def entity_graph_endpoint(request: Request, data: dict): - """Build interactive entity relationship graph for a wallet/token.""" - from app.entity_graph import build_entity_graph, generate_svg - - address = data.get("address", "") - chain = data.get("chain", "ethereum") - transactions = data.get("transactions", []) - labels = data.get("labels", {}) - cluster_members = data.get("cluster_members", []) - fmt = data.get("format", "json") - - if not address: - return {"status": "error", "error": "address required"} - - graph = build_entity_graph(address, chain, transactions, labels, cluster_members) - if fmt == "svg": - graph["svg"] = generate_svg(graph) - return graph - - - -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/premium/vulnerability-map") -async def vulnerability_map(request: Request, data: dict): - """Map token scan results against research-backed vulnerability taxonomy.""" - from app.vulnerability_mapper import check_token_against_taxonomy - return check_token_against_taxonomy(data) - - -@app.get("/api/v1/premium/embedding-stats") - - -@app.get("/api/v1/premium/rate-limits") -async def rate_limits_status(request: Request): - """Full rate limit status across all providers — usage, remaining, cache hits.""" - from app.rate_limiter import get_dispatcher - d = await get_dispatcher() - return await d.stats() - - -@app.post("/api/v1/premium/configure-providers") -async def configure_providers_endpoint(request: Request, data: dict): - """Reconfigure provider limits (e.g., after purchasing OpenRouter credits). - - POST {"has_openrouter_credits": true} - """ - from app.rate_limiter import configure_providers - has_credits = data.get("has_openrouter_credits", True) - configure_providers(has_openrouter_credits=has_credits) - return {"status": "configured", "has_openrouter_credits": has_credits, - "note": "OR daily limit: " + ("1000" if has_credits else "50") + " req/day"} - - -@app.get("/api/v1/premium/embedding-health") - -@app.get("/api/v1/premium/gcloud-multi") -async def gcloud_multi_status(request: Request): - """Multi-account Google Cloud status — all service accounts auto-discovered.""" - from app.gcloud_multi import get_multi_cloud - g = get_multi_cloud() - return await g.all_status() - -@app.get("/api/v1/premium/gcloud-status") - -@app.post("/api/v1/premium/rag-backup-gcloud") -async def rag_backup_gcloud(request: Request): - """Backup RAG (FAISS + BM25 + Redis) to Google Cloud Storage.""" - from app.rag_gcloud_backup import backup_rag_to_gcloud - return await backup_rag_to_gcloud() -async def gcloud_status(request: Request): - """Google Cloud service account status and available services.""" - from app.gcloud_manager import get_gcloud - g = get_gcloud() - buckets = await g.list_buckets() - return { - "service_account": g.health(), - "storage_buckets": buckets, - "services_available": ["vertex_ai_embedding", "cloud_storage", "bigquery", "cloud_run", "cloud_vision", "natural_language"], - } - - -@app.get("/api/v1/premium/bigquery-stream") -async def bigquery_stream_wallet(request: Request): - """Stream wallet labels to BigQuery for analytics (free: 1TB queries/mo).""" - from app.gcloud_manager import get_gcloud - from app.rate_limiter import get_dispatcher - g = get_gcloud() - d = await get_dispatcher() - rl = await d.tracker.stats() - return { - "bigquery_ready": True, - "dataset": "rmi_production", - "tables": ["wallet_labels", "scan_results", "embedding_usage"], - "rate_limits": {p["id"]: f'{p["day_used"]}/{p["day_limit"]}' for p in rl["providers"]}, - } - - -@app.post("/api/v1/premium/test-vision") -async def test_vision(request: Request): - """Test Cloud Vision OCR — free tier: 1000 units/month.""" - from app.gcloud_manager import get_gcloud - body = await request.json() - image_url = body.get("url", "") - g = get_gcloud() - text = await g.vision_ocr(image_url=image_url) if image_url else "No URL provided" - return {"status": "ok" if text else "api_not_enabled_or_no_text", "text": text[:500]} - - -@app.get("/api/v1/premium/enterprise-status") -async def enterprise_status(request: Request): - """Full enterprise trial status — credits, APIs, services.""" - from app.gcloud_manager import get_gcloud - from app.rate_limiter import PROVIDERS - g = get_gcloud() - return { - "project": "cryptorugmunch", - "billing": "017DB7-94C3F8-C143A5", - "trial": "$300 credits, 90 days", - "service_account": g.health(), - "always_free_available": { - "cloud_storage_5gb": True, - "bigquery_1tb_queries": True, - "cloud_run_2m_requests": True, - "cloud_vision_1000_units": True, - "natural_language_5000_units": True, - "compute_engine_e2_micro": True, - }, - "embedding_providers": len([p for p in PROVIDERS if p.free]), - "total_free_calls_per_day": sum(p.rpd for p in PROVIDERS if p.free), - } - - - -@app.get("/api/v1/premium/bigquery-stats") -async def bigquery_stats(request: Request): - """BigQuery wallet analytics — top scam wallets + daily scan stats.""" - from app.bigquery_pipeline import top_scam_wallets, daily_scan_stats - scams = await top_scam_wallets(10) - stats = await daily_scan_stats() - return {"top_scam_wallets": scams, "daily_scans": stats} - -@app.post("/api/v1/premium/stream-wallets-bigquery") -async def stream_wallets_bigquery(request: Request): - """Stream wallet labels from memory bank to BigQuery.""" - from app.bigquery_pipeline import stream_wallet_labels - body = await request.json() - labels = body if isinstance(body, list) else body.get("labels", []) - return await stream_wallet_labels(labels) - -@app.post("/api/v1/premium/test-nl") -async def test_nl(request: Request): - """Test Natural Language entity extraction.""" - from app.gcloud_manager import get_gcloud - body = await request.json() - text = body.get("text", "") - if not text: return {"entities": []} - g = get_gcloud() - entities = await g.nl_extract_entities(text) - sentiment = await g.nl_sentiment(text) - return {"entities": entities, "sentiment": sentiment} - -@app.get("/api/v1/premium/provider-health") -async def provider_health_endpoint(request: Request): - """Live health dashboard — all 10 providers, status, usage.""" - from app.provider_health import provider_health - return await provider_health() - -@app.get("/api/v1/premium/credit-burn") -async def credit_burn_endpoint(request: Request): - """Enterprise credit burn tracking — daily rate, exhaustion projection.""" - from app.provider_health import credit_burn - return await credit_burn() - -@app.get("/api/v1/premium/embedding-tiers") -async def embedding_tiers(request: Request): - """Quality tier summary — which providers serve which task types.""" - from app.embed_tiers import tier_summary - return tier_summary() -async def embedding_health(request: Request): - """Quick embedding system health check.""" - from app.rate_limiter import get_dispatcher - d = await get_dispatcher() - return d.health() -async def embedding_stats(request: Request): - """Multi-provider embedding router stats — usage, availability, fallback chain.""" - from app.embedding_router import get_router - router = await get_router() - return router.stats() - - -@app.post("/api/v1/ai/chat/agent") -async def ai_chat_agent(request: Request, data: dict): - """Multi-agent AI chat with automatic routing to specialized agents. - - Routes to SENTINEL Scanner, WalletSafe, Market Intel, Deep Research, - or General Assistant based on message content. - - Also accepts explicit agent hint: {"message":"...","agent":"scanner"} - """ - message = (data.get("message") or "").strip() - if not message: - return {"error": "message required"} - - agent_hint = data.get("agent", "") - - from app.agent_system import route_and_stream - from fastapi.responses import StreamingResponse - - async def stream(): - async for event in route_and_stream(message, agent_hint): - if event["type"] == "agent": - yield f"event: agent\ndata: {json.dumps(event)}\n\n" - elif event["type"] == "token": - yield f"event: token\ndata: {json.dumps(event)}\n\n" - elif event["type"] == "error": - yield f"event: error\ndata: {json.dumps(event)}\n\n" - elif event["type"] == "done": - yield f"event: done\ndata: {{}}\n\n" - - return StreamingResponse(stream(), media_type="text/event-stream") - - -@app.get("/api/v1/ai/agents") -async def ai_agents_list(request: Request): - """List available AI agents with their models and capabilities.""" - from app.agent_system import agents_list - return {"agents": agents_list()} - -# SYNDICATE -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/syndicate/queue") -async def get_syndicate_queue(request: Request): - """Get syndicate analysis queue.""" - return { - "queue": [], - "active_count": 0, - "pending_count": 0, - } - -# ═══════════════════════════════════════════════════════════ -# 5. LIVENESS VS READINESS PROBES -# ═══════════════════════════════════════════════════════════ - -@app.get("/live") -async def liveness_check(): - """Liveness probe: Is the Python process running and able to respond? - Docker uses this. Returns 200 always if alive, preventing unnecessary restarts on transient DB blips.""" - return {"status": "alive", "timestamp": datetime.now(timezone.utc).isoformat()} - -@app.get("/ready") -async def readiness_check(request: Request): - """Readiness probe: Are critical dependencies (Redis, Supabase) connected? - Orchestration tools/load balancers use this. Returns 503 if degraded.""" - status = "ready" - details = {"redis": False, "supabase": False} - - # Check Redis - 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 - ) - r.ping() - details["redis"] = True - except Exception: - status = "degraded" - - # Check Supabase using pooled client and circuit breaker - try: - from app.circuit_breaker import check_circuit, record_success, record_failure - supabase_url = os.getenv("SUPABASE_URL", "") - supabase_key = os.getenv("SUPABASE_KEY", "") or os.getenv("SUPABASE_ANON_KEY", "") - - if supabase_url and supabase_key: - if await check_circuit("supabase"): - details["supabase"] = "circuit_open" - status = "degraded" - else: - try: - client = request.app.state.http_client - res = await client.get( - f"{supabase_url}/rest/v1/", - headers={"apikey": supabase_key, "Authorization": f"Bearer {supabase_key}"} - ) - if res.status_code in [200, 406]: - details["supabase"] = True - await record_success("supabase") - else: - await record_failure("supabase") - status = "degraded" - except Exception: - await record_failure("supabase") - status = "degraded" - except Exception: - status = "degraded" - - # Return 503 if degraded so orchestrators know not to route traffic - from fastapi.responses import JSONResponse - if status == "degraded": - return JSONResponse(status_code=503, content={"status": status, "dependencies": details}) - - return {"status": status, "dependencies": details, "timestamp": datetime.now(timezone.utc).isoformat()} - -# Legacy /health endpoint alias for backward compatibility -@app.get("/health") -async def health_check(request: Request): - return await liveness_check() - -@app.get("/api/v1/status") -async def get_status(request: Request): - """Full system status — all containers, gateways, resources, earnings. - Powers the terminal dashboard and web monitoring panel.""" - import subprocess, json as _json, shutil - - # Docker check via Python docker SDK (socket mounted) - containers = {} - try: - import docker as docker_pkg - client = docker_pkg.from_env() - for c in client.containers.list(all=False): - status = c.status - if c.attrs.get("State", {}).get("Health", {}).get("Status") == "healthy": - status = f"{status} (healthy)" - containers[c.name] = status - except: - pass - - def _check_container(*names): - for n in names: - if n in containers: - s = containers[n] - return {"running": "running" in s.lower() or "up" in s.lower(), "status": s, "healthy": "(healthy)" in s} - return {"running": False, "status": "not found", "healthy": False} - - infra = { - "backend": _check_container("rmi-backend"), - "redis": _check_container("rmi-redis"), - "worker": _check_container("rmi-worker"), - "n8n": _check_container("rmi-n8n"), - "orchestrator": _check_container("rmi-orchestrator"), - "cloudflare_tunnel": _check_container("rmi-cloudflare"), - } - management = { - "uptime_kuma": _check_container("rmi-uptime-kuma"), - "dozzle": _check_container("rmi-dozzle"), - "glitchtip": _check_container("rmi-glitchtip"), - "glitchtip_db": _check_container("rmi-glitchtip-pg"), - "redis_insight": _check_container("rmi-redis-insight"), - "vaultwarden": _check_container("rmi-vaultwarden"), - "webmail": _check_container("rmi-webmail"), - "listmonk": _check_container("rmi-listmonk"), - "ghost": _check_container("rmi-ghost"), - "mysql": _check_container("rmi-mysql"), - "telegram_mcp": _check_container("telegram-mcp"), - } - langfuse = { - "web": _check_container("langfuse-langfuse-web-1"), - "worker": _check_container("langfuse-langfuse-worker-1"), - "postgres": _check_container("langfuse-postgres-1"), - "redis": _check_container("langfuse-redis-1"), - "clickhouse": _check_container("langfuse-clickhouse-1"), - "minio": _check_container("langfuse-minio-1"), - } - - disk = shutil.disk_usage("/") - disk_pct = disk.used / disk.total * 100 - - def _run(cmd, timeout=5): - try: - return subprocess.check_output(cmd, timeout=timeout).decode().strip() - except: - return "" - - mem = _run(["free", "-b"]).split("\n") - mem_used_pct = 0 - if len(mem) > 1: - parts = mem[1].split() - if len(parts) > 6: - mem_total = int(parts[1]) - mem_avail = int(parts[6]) - mem_used_pct = (mem_total - mem_avail) / mem_total * 100 if mem_total else 0 - - cpu_str = _run(["top", "-bn1", "-d0.5"], 8) - cpu_pct = 0 - for line in cpu_str.split("\n"): - if "Cpu(s)" in line: - idle_part = [p for p in line.split(",") if "id" in p.lower()] - if idle_part: - cpu_pct = 100 - float(idle_part[0].split()[0]) - break - - resources = { - "disk": {"used_pct": round(disk_pct, 1), "free_gb": round(disk.free / (1024**3), 1), "total_gb": round(disk.total / (1024**3), 1)}, - "memory": {"used_pct": round(mem_used_pct, 1)}, - "cpu": {"used_pct": round(cpu_pct, 1)}, - } - - earnings = {"total_usdc": 0, "today_usdc": 0, "unique_payers": 0, "by_chain": {}, "trials_today": 0} - try: - from app.routers.x402_dashboard import _get_redis - r = _get_redis() - if r: - keys = r.keys("x402:spent_tx:*") - for k in keys: - try: - earnings["total_usdc"] += float(r.get(k) or 0) - except: - pass - earnings["unique_payers"] = len(set( - k.decode().split(":")[-1] for k in (r.keys("x402:spent_tx:*") or []) - )) - earnings["trials_today"] = int(r.get("x402:global:trials_today") or 0) - except: - pass - - async def _check_url(url, timeout=3): - try: - async with httpx.AsyncClient(timeout=timeout) as c: - r = await c.get(url) - return r.status_code - except: - return None - - sol_code = await _check_url("https://sol.rugmunch.io/health") - base_code = await _check_url("https://base.rugmunch.io/health") - mcp_code = await _check_url("https://mcp.rugmunch.io/tools") - web_code = await _check_url("https://rugmunch.io") - - gateways = { - "solana": {"up": sol_code == 200, "status": f"HTTP {sol_code}" if sol_code else "DOWN"}, - "base": {"up": base_code == 200, "status": f"HTTP {base_code}" if base_code else "DOWN"}, - "mcp_router": {"up": mcp_code == 200, "status": f"HTTP {mcp_code}" if mcp_code else "DOWN"}, - } - website = {"up": web_code == 200, "status": f"HTTP {web_code}" if web_code else "DOWN"} - - critical_up = all(v["running"] for v in infra.values()) - all_items = list(infra.values()) + list(management.values()) + list(langfuse.values()) - total = len(all_items) + 6 - passing = sum(1 for v in all_items if v["running"]) - passing += (1 if sol_code == 200 else 0) - passing += (1 if base_code == 200 else 0) - passing += (1 if mcp_code == 200 else 0) - passing += (1 if web_code == 200 else 0) - passing += (1 if disk_pct < 90 else 0) - passing += (1 if mem_used_pct < 95 else 0) - - return { - "timestamp": datetime.now(timezone.utc).isoformat(), - "score": {"total": total, "passing": passing, "critical_ok": critical_up}, - "infrastructure": infra, - "management": management, - "langfuse": langfuse, - "resources": resources, - "earnings": earnings, - "gateways": gateways, - "website": website, - } - -# ═══════════════════════════════════════════════════════════ -# OSINT -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/osint/investigate") -async def osint_investigate(request: Request, data: dict): - """Investigate an address or entity.""" - target = data.get("target", "") - return { - "target": target[:16] + "..." if len(target) > 16 else target, - "type": data.get("type", "address"), - "risk_score": 45, - "findings": [ - {"type": "cluster_analysis", "result": "3 related wallets found"}, - {"type": "transaction_pattern", "result": "Mixing service detected"}, - ], - "confidence": 0.78, - } - -@app.post("/api/v1/osint/website") -async def osint_website(request: Request, data: dict): - """Analyze a website for phishing/scam indicators.""" - url = data.get("url", "") - return { - "url": url, - "is_suspicious": False, - "risk_score": 12, - "indicators": [], - "ssl_valid": True, - "domain_age_days": 850, - } - -@app.get("/api/v1/osint/search") -async def osint_search(request: Request, query: str): - """Search OSINT database for entities.""" - return { - "results": [ - {"name": "Example Entity", "type": "wallet", "risk_score": 30, "last_seen": "2026-05-10"}, - ], - "count": 1, - } - -# ═══════════════════════════════════════════════════════════ -# GAMIFICATION -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/gamification/profile") -async def gamification_profile(request: Request): - """Get user gamification profile.""" - return { - "user_id": "user_123", - "level": 12, - "xp": 4500, - "xp_to_next_level": 5500, - "badges": ["Whale Hunter", "First Blood", "Chain Analyst"], - "leaderboard_rank": 42, - "total_score": 12500, - } - -@app.get("/api/v1/gamification/leaderboard") -async def gamification_leaderboard(request: Request, limit: int = 20): - """Get global leaderboard.""" - return { - "leaderboard": [ - {"rank": 1, "username": "top_hunter", "score": 50000, "level": 25}, - {"rank": 2, "username": "chain_wizard", "score": 45000, "level": 23}, - ], - "total": 892, - } - -@app.post("/api/v1/gamification/event") -async def gamification_event(request: Request, data: dict): - """Track a gamification event.""" - return {"status": "recorded", "xp_earned": 100} - -@app.get("/api/v1/gamification/badges") -async def gamification_badges(request: Request): - """Get available badges.""" - return { - "badges": [ - {"id": "whale_hunter", "name": "Whale Hunter", "description": "Track 100 whale wallets", "icon": "🐋"}, - {"id": "first_blood", "name": "First Blood", "description": "Find first honeypot", "icon": "🏆"}, - ], - "count": 2, - } - -# ═══════════════════════════════════════════════════════════ -# CHAT (Community Chat) -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/chat") -async def get_chat_messages(request: Request, limit: int = 50): - """Get community chat messages.""" - return { - "messages": [ - {"user": "user123", "message": "Just found a great project!", "timestamp": datetime.now(timezone.utc).isoformat(), "likes": 5}, - ], - "total": 1, - } - -# ═══════════════════════════════════════════════════════════ -# HOME / ROOT -# ═══════════════════════════════════════════════════════════ - -@app.get("/") -async def root(): - """Root endpoint — platform info.""" - return { - "name": "RugMunch Intelligence Platform", - "version": "2.0.0", - "status": "running", - "endpoints": "/api/v1/*", - "docs": "/docs", - } - -# ═══════════════════════════════════════════════════════════ -# TELEGRAM -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/telegram/user/register") -async def telegram_register(request: Request, data: dict): - """Register Telegram user.""" - return {"status": "registered", "user_id": data.get("telegram_id")} - -@app.post("/api/v1/telegram/scan/record") -async def telegram_scan_record(request: Request, data: dict): - """Record a scan from Telegram bot.""" - return {"status": "recorded", "scan_id": "scan_123"} - -@app.post("/api/v1/telegram/payment/confirm") -async def telegram_payment_confirm(request: Request, data: dict): - """Confirm Telegram payment.""" - return {"status": "confirmed", "payment_id": "pay_123"} - -@app.get("/api/v1/telegram/scans/{telegram_id}") -async def get_telegram_scans(request: Request, telegram_id: str): - """Get scans for a Telegram user.""" - return {"scans": [], "count": 0} - -@app.get("/api/v1/telegram/leaderboard") -async def telegram_leaderboard(request: Request): - """Get Telegram leaderboard.""" - return {"leaderboard": [], "count": 0} - -@app.get("/api/v1/telegram/user/{telegram_id}") -async def get_telegram_user(request: Request, telegram_id: str): - """Get Telegram user profile.""" - return {"telegram_id": telegram_id, "username": "user", "level": 1} - -@app.post("/api/v1/telegram/stars-invoice") -async def telegram_stars_invoice(request: Request, data: dict): - """Create Telegram Stars invoice.""" - return {"invoice_id": "inv_123", "amount": 500, "currency": "stars"} - -# ═══════════════════════════════════════════════════════════ -# INVESTIGATION -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/investigation/start") -async def investigation_start(request: Request, data: dict): - """Start a new investigation.""" - return { - "case_id": "case_123", - "status": "active", - "created_at": datetime.now(timezone.utc).isoformat(), - } - -@app.get("/api/v1/investigation/cases") -async def investigation_cases(request: Request, limit: int = 20): - """List investigation cases.""" - return {"cases": [], "total": 0} - -@app.get("/api/v1/investigation/cases/{case_id}") -async def investigation_case(request: Request, case_id: str): - """Get a specific investigation case.""" - return { - "case_id": case_id, - "status": "pending", - "created_at": datetime.now(timezone.utc).isoformat(), - "target": None, - "message": "No active investigation for this case ID.", - } - -@app.get("/api/v1/investigation/cases/{case_id}/crm/full") -async def investigation_crm_full(request: Request, case_id: str): - """Get full CRM data for a case.""" - return {"case_id": case_id, "data": {}} - -@app.get("/api/v1/investigation/cases/{case_id}/crm/timeline") -async def investigation_crm_timeline(request: Request, case_id: str): - """Get CRM timeline for a case.""" - return {"case_id": case_id, "timeline": []} - -@app.get("/api/v1/investigation/cases/{case_id}/crm/structure") -async def investigation_crm_structure(request: Request, case_id: str): - """Get CRM structure for a case.""" - return {"case_id": case_id, "structure": {}} - -@app.get("/api/v1/investigation/cases/{case_id}/crm/wallets") -async def investigation_crm_wallets(request: Request, case_id: str): - """Get wallets associated with a case.""" - return {"case_id": case_id, "wallets": []} - -@app.get("/api/v1/investigation/cases/{case_id}/crm/evidence") -async def investigation_crm_evidence(request: Request, case_id: str): - """Get evidence for a case.""" - return {"case_id": case_id, "evidence": []} - -@app.get("/api/v1/investigation/cases/{case_id}/crm/stats") -async def investigation_crm_stats(request: Request, case_id: str): - """Get stats for a case.""" - return {"case_id": case_id, "stats": {}} - -# ═══════════════════════════════════════════════════════════ -# INTELLIGENCE ENDPOINTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/intelligence/narrative/{address}") -async def intelligence_narrative(request: Request, address: str): - """Get narrative intelligence for an address.""" - return { - "address": address[:8] + "..." if len(address) > 8 else address, - "narrative": "No suspicious narrative detected", - "confidence": 0.85, - } - -@app.get("/api/v1/intelligence/degen/{address}") -async def intelligence_degen(request: Request, address: str): - """Get degen score for a wallet.""" - return { - "address": address[:8] + "..." if len(address) > 8 else address, - "degen_score": 42, - "level": "moderate", - } - -@app.get("/api/v1/intelligence/sniper/{address}") -async def intelligence_sniper(request: Request, address: str): - """Check if address is a sniper bot.""" - return { - "address": address[:8] + "..." if len(address) > 8 else address, - "is_sniper": False, - "confidence": 0.91, - } - -@app.get("/api/v1/intelligence/trending") -async def intelligence_trending(request: Request): - """Get trending intelligence (NOT @api.get — fixed typo).""" - articles = await _fetch_all_news(limit=10) - trending_data = await _coingecko_get("/search/trending", ttl=60) - return { - "trending_tokens": trending_data.get("coins", [])[:10] if trending_data else [], - "trending_news": articles[:5], - "updated_at": datetime.now(timezone.utc).isoformat(), - } - -@app.get("/api/v1/intelligence/crossref/{address}") -async def intelligence_crossref(request: Request, address: str): - """Cross-reference an address across databases.""" - return { - "address": address[:8] + "..." if len(address) > 8 else address, - "matches": [], - "sources_checked": ["scam-db", "honeypot-db", "whale-tracker"], - } - -@app.get("/api/v1/intelligence/full/{address}") -async def intelligence_full(request: Request, address: str): - """Get full intelligence report for an address.""" - return { - "address": address[:8] + "..." if len(address) > 8 else address, - "report": { - "risk_score": 35, - "risk_level": "medium", - "narrative": "Normal trading wallet", - "first_seen": "2025-01-15T00:00:00Z", - "last_active": datetime.now(timezone.utc).isoformat(), - "associated_addresses": [], - "token_holdings": [], - }, - } - -# Dashboard cache -DASHBOARD_CACHE = None -DASHBOARD_CACHE_TS = None - -@app.get("/api/v1/intelligence/dashboard") -async def intelligence_dashboard(request: Request): - """Full intelligence dashboard — real data from CoinGecko, DexScreener, Mempool. Cached 2 min.""" - global DASHBOARD_CACHE, DASHBOARD_CACHE_TS - - # Return cache if fresh (< 2 min) - if DASHBOARD_CACHE and DASHBOARD_CACHE_TS and (datetime.now(timezone.utc) - DASHBOARD_CACHE_TS) < timedelta(minutes=2): - return DASHBOARD_CACHE - import asyncio - - async def fetch_fear_greed(): - # Alternative Fear & Greed Index API (CoinGecko doesn't have it) - try: - async with httpx.AsyncClient(timeout=8) as c: - r = await c.get("https://api.alternative.me/fng/?limit=14") - if r.status_code == 200: - j = r.json() - data = j.get("data", []) - current = data[0] if data else {} - history = [{"value": int(d.get("value", 50)), "timestamp": d.get("timestamp", ""), - "classification": d.get("value_classification", "Neutral")} for d in data] - return {"current": {"value": int(current.get("value", 50)), - "classification": current.get("value_classification", "Neutral")}, - "history": history} - except Exception: - pass - return {"current": {"value": 50, "classification": "Neutral"}, "history": []} - - async def fetch_global(): - return await _coingecko_get("/global", ttl=120) or {} - - async def fetch_trending(): - return await _coingecko_get("/search/trending", ttl=120) or {} - - async def fetch_mempool(): - try: - async with httpx.AsyncClient(timeout=8) as c: - r = await c.get("https://mempool.space/api/v1/fees/recommended") - if r.status_code == 200: - return r.json() - except Exception: - pass - return {} - - async def fetch_defi(): - try: - async with httpx.AsyncClient(timeout=8) as c: - r = await c.get("https://api.llama.fi/protocols") - if r.status_code == 200: - data = r.json() - tvl = sum(p.get("tvl", 0) for p in data[:100]) - return {"total_tvl": tvl, "top_protocols": data[:10]} - except Exception: - pass - return {} - - fng, cg_global, cg_trending, mempool, defi_data = await asyncio.gather( - fetch_fear_greed(), fetch_global(), fetch_trending(), fetch_mempool(), fetch_defi() - ) - - # Exchange prices from global data - market_data = cg_global.get("data", {}).get("market_cap_percentage", {}) - total_mcap = cg_global.get("data", {}).get("total_market_cap", {}) - - result = { - "fear_greed": fng, - "exchange_prices": { - "bitcoin": {"usd": total_mcap.get("btc", 0) if "btc" in total_mcap else None}, - "ethereum": {"usd": total_mcap.get("eth", 0) if "eth" in total_mcap else None}, - "solana": {"usd": total_mcap.get("sol", 0) if "sol" in total_mcap else None}, - }, - "bitcoin_mempool": mempool, - "coingecko_global": cg_global.get("data", {}), - "coingecko_trending": cg_trending.get("coins", [])[:15], - "defi_hacks": [], - "defi": defi_data, - "crypto_compare": { - "btc_dominance": market_data.get("btc", 0), - "eth_dominance": market_data.get("eth", 0), - }, - "updated_at": datetime.now(timezone.utc).isoformat(), - } - DASHBOARD_CACHE = result - DASHBOARD_CACHE_TS = datetime.now(timezone.utc) - return result - -@app.post("/api/v1/gmgn-v2/wallet-intelligence") -async def gmgn_wallet_intelligence(request: Request, data: dict): - """GMGN v2 wallet intelligence.""" - return { - "wallet_score": 72, - "profit_loss": {"total_pnl": 1250.50, "win_rate": 0.65}, - "top_tokens_traded": [], - } - -# ═══════════════════════════════════════════════════════════ -# AGENTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/agents") -async def get_agents(request: Request): - """List available AI agents.""" - return { - "agents": [ - {"id": "scanner-1", "name": "Rug Pull Scanner", "status": "active", "type": "scanner"}, - {"id": "intel-1", "name": "Intelligence Agent", "status": "active", "type": "intelligence"}, - ], - "count": 2, - } - -@app.get("/api/v1/agents/{agent_id}") -async def get_agent(request: Request, agent_id: str): - """Get specific agent details.""" - return {"id": agent_id, "name": f"Agent {agent_id}", "status": "active"} - -@app.post("/api/v1/agents/{agent_id}/command") -async def agent_command(request: Request, agent_id: str, data: dict): - """Send command to an agent.""" - return {"status": "command_received", "agent_id": agent_id} - -# ═══════════════════════════════════════════════════════════ -# TRENCHES (Community Posts) -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/trenches/posts") -async def trenches_create_post(request: Request, data: dict): - """Create a community post.""" - return {"id": "post_123", "status": "created", "created_at": datetime.now(timezone.utc).isoformat()} - -@app.get("/api/v1/trenches/posts") -async def trenches_get_posts(request: Request, page: int = 1, limit: int = 20): - """Get community posts.""" - return { - "posts": [ - {"id": "post_123", "title": "My findings on $XYZ", "content": "Analysis...", "author": "user123", "created_at": datetime.now(timezone.utc).isoformat(), "upvotes": 5, "comments": 2}, - ], - "total": 1, - "page": page, - } - -@app.post("/api/v1/trenches/posts/{post_id}/comments") -async def trenches_create_comment(request: Request, post_id: str, data: dict): - """Comment on a post.""" - return {"id": "comment_123", "status": "created"} - -@app.get("/api/v1/trenches/posts/{post_id}/comments") -async def trenches_get_comments(request: Request, post_id: str): - """Get comments for a post.""" - return {"comments": [], "count": 0} - -# ═══════════════════════════════════════════════════════════ -# COMMENTS -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/comments") -async def create_comment(request: Request, data: dict): - """Create a comment.""" - return {"id": "comment_123", "status": "created"} - -@app.get("/api/v1/comments") -async def get_comments(request: Request, target_id: str): - """Get comments for a target.""" - return {"comments": [], "count": 0} - -@app.post("/api/v1/comments/{comment_id}/upvote") -async def upvote_comment(request: Request, comment_id: str): - """Upvote a comment.""" - return {"status": "upvoted", "comment_id": comment_id} - -@app.delete("/api/v1/comments/{comment_id}") -async def delete_comment(request: Request, comment_id: str): - """Delete a comment.""" - return {"status": "deleted", "comment_id": comment_id} - -@app.post("/api/v1/trenches/posts/{post_id}/upvote") -async def upvote_post(request: Request, post_id: str): - """Upvote a post.""" - return {"status": "upvoted", "post_id": post_id} - -# ═══════════════════════════════════════════════════════════ -# CONSENSUS -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/consensus") -async def consensus_vote(request: Request, data: dict): - """Cast a consensus vote.""" - return {"status": "recorded", "vote_id": "vote_123"} - -# ═══════════════════════════════════════════════════════════ -# PAYMENTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/payments/products") -async def get_payment_products(request: Request): - """List payment/upgrade products.""" - return { - "products": [ - {"id": "pro_monthly", "name": "Pro Monthly", "price_usd": 29.99}, - {"id": "pro_yearly", "name": "Pro Yearly", "price_usd": 299.99}, - ], - } - -@app.post("/api/v1/payments/charge") -async def create_charge(request: Request, data: dict): - """Create a payment charge.""" - return {"charge_id": "ch_123", "status": "pending"} - -@app.get("/api/v1/payments/charge/{charge_id}") -async def get_charge(request: Request, charge_id: str): - """Get charge details.""" - return {"charge_id": charge_id, "status": "completed"} - -@app.post("/api/v1/payments/webhook/p-payment-successful") -async def payment_webhook(request: Request, data: dict): - """Payment webhook handler.""" - return {"status": "received"} - -# ═══════════════════════════════════════════════════════════ -# MONITORING & ALERTS -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/alerts/subscribe") -async def subscribe_alerts(request: Request, data: dict): - """Subscribe to alerts.""" - return {"status": "subscribed", "subscription_id": "sub_123"} - -@app.get("/api/v1/alerts") -async def get_alerts(request: Request, active: bool = True): - """Get active alerts — generated from real scanning activity.""" - now = datetime.now(timezone.utc) - alerts = [] - # Generate alerts from recent scan data if available - try: - from itertools import islice - scan_cache = _cache_get("rugmaps:scan:recent") - if scan_cache and scan_cache.get("holders"): - holders = scan_cache["holders"] - # Top holder alert - if holders: - top = holders[0] - pct = top.get("pct", 0) - if pct > 10: - alerts.append({ - "id": f"whale_{int(time.time())}", - "type": "whale_concentration", - "title": f"High Holder Concentration: {pct:.1f}%", - "description": f"Top holder controls {pct:.1f}% of supply. Risk of dump or manipulation.", - "severity": "critical" if pct > 30 else "high", - "token": scan_cache.get("token", {}).get("name", "Unknown"), - "chain": "solana", - "wallet": top.get("address", "")[:16] + "...", - "amount_usd": 0, - "created_at": now.isoformat(), - "acknowledged": False, - }) - except Exception: - pass - - # Always add base alerts - alerts.extend([ - { - "id": "alert_sys_1", - "type": "security", - "title": "System: RugMaps Scanner Active", - "description": "RugMaps scanner is actively monitoring Solana for new rug pull patterns.", - "severity": "low", - "token": None, - "chain": "solana", - "wallet": None, - "amount_usd": 0, - "created_at": now.isoformat(), - "acknowledged": False, - }, - { - "id": "alert_sys_2", - "type": "intelligence", - "title": "x402 Marketplace Online", - "description": "80+ tools available across Solana and Base chains via x402 protocol.", - "severity": "low", - "token": None, - "chain": "all", - "wallet": None, - "amount_usd": 0, - "created_at": now.isoformat(), - "acknowledged": False, - }, - ]) - return {"alerts": alerts, "count": len(alerts)} - -# ═══════════════════════════════════════════════════════════ -# ALERT SSE STREAM — Real-time threat feed via Server-Sent Events -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/alerts/stream") -async def alerts_stream(request: Request): - """SSE endpoint streaming alerts from Redis pub/sub channel rmi:ws:alerts. - Falls back to polling rmi:alerts:recent if pub/sub setup fails.""" - import sse_starlette.sse as sse_module - from starlette.responses import StreamingResponse - - redis_host = os.getenv("REDIS_HOST", "rmi-redis") - redis_port = int(os.getenv("REDIS_PORT", "6379")) - redis_pass = os.getenv("REDIS_PASSWORD", "") - - async def event_generator(): - """Yield SSE events from Redis pub/sub.""" - try: - r = await aioredis.from_url( - f"redis://{redis_host}:{redis_port}", - password=redis_pass or None, - decode_responses=True, - ) - pubsub = r.pubsub() - await pubsub.subscribe("rmi:ws:alerts") - async for message in pubsub.listen(): - if await request.is_disconnected(): - break - if message["type"] == "message": - data = message["data"] - if isinstance(data, str): - yield {"event": "alert", "data": data} - else: - yield {"event": "alert", "data": json.dumps(data)} - await pubsub.unsubscribe("rmi:ws:alerts") - await r.close() - except Exception: - # Fallback: poll recent alerts from sorted set - try: - r = await aioredis.from_url( - f"redis://{redis_host}:{redis_port}", - password=redis_pass or None, - decode_responses=True, - ) - last_count = 0 - while not await request.is_disconnected(): - alerts_raw = await r.zrevrange("rmi:alerts:recent", 0, -1) - if len(alerts_raw) > last_count: - for alert_json in alerts_raw[last_count:]: - yield {"event": "alert", "data": alert_json} - last_count = len(alerts_raw) - await asyncio.sleep(3) - await r.close() - except Exception: - yield {"event": "error", "data": json.dumps({"error": "Redis unavailable"})} - - return StreamingResponse( - _sse_stream_wrapper(event_generator()), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - -def _sse_stream_wrapper(gen): - """Convert async generator of dicts to SSE text format lines.""" - async def wrapper(): - async for event in gen: - # event is a dict with "event" and "data" keys - event_type = event.get("event", "message") - data = event.get("data", "") - yield f"event: {event_type}\ndata: {data}\n\n" - return wrapper() - - -@app.get("/api/v1/alerts/recent") -async def alerts_recent(limit: int = Query(50, ge=1, le=200)): - """Return last N alerts from Redis sorted set rmi:alerts:recent.""" - redis_host = os.getenv("REDIS_HOST", "rmi-redis") - redis_port = int(os.getenv("REDIS_PORT", "6379")) - redis_pass = os.getenv("REDIS_PASSWORD", "") - try: - r = await aioredis.from_url( - f"redis://{redis_host}:{redis_port}", - password=redis_pass or None, - decode_responses=True, - ) - alerts_raw = await r.zrevrange("rmi:alerts:recent", 0, limit - 1, withscores=True) - await r.close() - alerts = [] - for entry in alerts_raw: - try: - alert = json.loads(entry[0]) - alert["_score"] = entry[1] - alerts.append(alert) - except (json.JSONDecodeError, TypeError): - alerts.append({"raw": entry[0], "_score": entry[1]}) - return {"alerts": alerts, "count": len(alerts)} - except Exception as e: - return {"alerts": [], "count": 0, "error": str(e)} - -# ═══════════════════════════════════════════════════════════ -# MARKETPLACE / SCAN -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/marketplace/scan") -async def marketplace_scan(request: Request): - """Get marketplace scan results.""" - return {"status": "active", "items_scanned": 15000, "threats_detected": 3} - -# ═══════════════════════════════════════════════════════════ -# ADMIN ENDPOINTS -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/admin/agents/{agent_id}/role") -async def admin_agent_role(request: Request, agent_id: str, data: dict): - """Update agent role.""" - return {"status": "updated", "agent_id": agent_id} - -@app.get("/api/v1/admin/tasks") -async def admin_tasks(request: Request): - """List admin tasks.""" - return {"tasks": [], "count": 0} - -@app.post("/api/v1/admin/self-heal") -async def admin_self_heal(request: Request): - """Trigger self-healing.""" - return {"status": "healing", "services_restarted": []} - -# ═══════════════════════════════════════════════════════════ -# AUTO-SCAN NEW TOKENS (background + admin trigger) -# ═══════════════════════════════════════════════════════════ - -# Track scanned tokens to avoid re-scanning within the same session -_auto_scanned_tokens: set = set() -_auto_scan_running = False - -async def _auto_scan_new_tokens(): - """Background task: discover new Solana tokens via OnChainScanner, - then run them through SENTINEL. Broadcast CRITICAL alerts (risk_score >= 70). - Runs periodically (every 10 minutes).""" - global _auto_scan_running - if _auto_scan_running: - return - _auto_scan_running = True - - while True: - try: - from app.intel_feed_pipeline import OnChainScanner - items = await OnChainScanner.scan_new_solana_tokens(limit=20) - if items: - print(f"[AUTO-SCAN] Discovered {len(items)} new Solana tokens") - for item in items: - # Extract token address from IntelItem entities - addr = None - if hasattr(item, 'entities') and item.entities: - addr = item.entities.get("sol_address", [""])[0] if isinstance(item.entities, dict) else None - if not addr: - continue - if addr in _auto_scanned_tokens: - continue - _auto_scanned_tokens.add(addr) - try: - # Run through SENTINEL - from app.token_scanner import scan_token as unified_scan - scan = await unified_scan(addr, "solana", tier="free") - risk_score = 100 - (scan.safety_score or 50) - - # Broadcast scan result - try: - await ws_broadcast_scan({ - "token": scan.token_address, - "chain": scan.chain, - "symbol": scan.symbol, - "safety_score": scan.safety_score, - "risk_score": risk_score, - "risk_flags": scan.risk_flags[:5] if scan.risk_flags else [], - "source": "auto_scan", - }) - except Exception: - pass - - # If CRITICAL (risk_score >= 70), broadcast alert - if risk_score >= 70: - try: - await ws_broadcast_alert({ - "event": "critical_token_detected", - "token": scan.token_address, - "chain": scan.chain, - "symbol": scan.symbol, - "risk_score": risk_score, - "safety_score": scan.safety_score, - "risk_flags": scan.risk_flags[:5] if scan.risk_flags else [], - "message": f"CRITICAL: Token {scan.symbol or addr[:12]} has risk_score={risk_score}", - "source": "auto_scan", - }) - except Exception: - pass - except Exception as e: - print(f"[AUTO-SCAN] Error scanning {addr[:16]}...: {e}") - except Exception as e: - print(f"[AUTO-SCAN] Error in new token scan loop: {e}") - - # Wait 10 minutes before next scan cycle - await asyncio.sleep(600) - - -@app.post("/api/v1/crypto/auto-scan") -async def trigger_auto_scan(request: Request): - """Admin endpoint to trigger an immediate auto-scan of new tokens. - Runs OnChainScanner -> SENTINEL and broadcasts CRITICAL alerts.""" - scanned = [] - try: - from app.intel_feed_pipeline import OnChainScanner - items = await OnChainScanner.scan_new_solana_tokens(limit=20) - - for item in items: - addr = None - if hasattr(item, 'entities') and item.entities: - addr = item.entities.get("sol_address", [""])[0] if isinstance(item.entities, dict) else None - if not addr: - continue - if addr in _auto_scanned_tokens: - continue - _auto_scanned_tokens.add(addr) - try: - from app.token_scanner import scan_token as unified_scan - scan = await unified_scan(addr, "solana", tier="free") - risk_score = 100 - (scan.safety_score or 50) - - # Broadcast scan result - try: - await ws_broadcast_scan({ - "token": scan.token_address, - "chain": scan.chain, - "symbol": scan.symbol, - "safety_score": scan.safety_score, - "risk_score": risk_score, - "risk_flags": scan.risk_flags[:5] if scan.risk_flags else [], - "source": "auto_scan_manual", - }) - except Exception: - pass - - # If CRITICAL (risk_score >= 70), broadcast alert - if risk_score >= 70: - try: - await ws_broadcast_alert({ - "event": "critical_token_detected", - "token": scan.token_address, - "chain": scan.chain, - "symbol": scan.symbol, - "risk_score": risk_score, - "safety_score": scan.safety_score, - "risk_flags": scan.risk_flags[:5] if scan.risk_flags else [], - "message": f"CRITICAL: Token {scan.symbol or addr[:12]} has risk_score={risk_score}", - "source": "auto_scan_manual", - }) - except Exception: - pass - - scanned.append({ - "address": addr, - "symbol": scan.symbol, - "safety_score": scan.safety_score, - "risk_score": risk_score, - "risk_flags": scan.risk_flags[:5] if scan.risk_flags else [], - }) - except Exception as e: - scanned.append({"address": addr, "error": str(e)}) - except Exception as e: - return {"status": "error", "error": str(e), "scanned": scanned} - - return {"status": "ok", "tokens_scanned": len(scanned), "results": scanned} - - -@app.post("/api/v1/admin/reload-env") -async def admin_reload_env(request: Request): - """ - Re-read .env file and update os.environ with new values. - Also re-decodes LLM_API_KEY_B64 → LLM_API_KEY. - - Use after editing .env to pick up new config without restarting: - curl -X POST http://localhost:8000/api/v1/admin/reload-env - """ - from dotenv import load_dotenv - import base64 as _b64 - - # Reload .env with override so new values replace old ones - loaded = load_dotenv(override=True) - - # Re-decode base64 LLM key if present - _llm_b64 = os.getenv("LLM_API_KEY_B64", "") - key_status = "unchanged" - if _llm_b64: - decoded = _b64.b64decode(_llm_b64).decode() - os.environ["LLM_API_KEY"] = decoded - key_status = "decoded_from_b64" - elif os.getenv("LLM_API_KEY"): - key_status = "plain_key_unchanged" - else: - key_status = "no_key_set" - - # Report current env state (non-sensitive) - env_snapshot = { - "LLM_MODEL": os.getenv("LLM_MODEL", ""), - "LLM_BASE_URL": os.getenv("LLM_BASE_URL", ""), - "LLM_API_KEY_SET": bool(os.getenv("LLM_API_KEY", "")), - "LLM_API_KEY_B64_SET": bool(os.getenv("LLM_API_KEY_B64", "")), - } - - return { - "status": "ok", - "env_loaded": loaded, - "llm_key_status": key_status, - "env_snapshot": env_snapshot, - } - -# ═══════════════════════════════════════════════════════════ -# WALLET DB -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/wallet-db/analyze/{address}") -async def wallet_db_analyze(request: Request, address: str): - """Analyze wallet in wallet DB.""" - return { - "address": address[:8] + "..." if len(address) > 8 else address, - "analysis": {"risk": "low", "score": 15, "label": "normal"}, - } - -@app.get("/api/v1/wallet-db/wallets") -async def wallet_db_wallets(request: Request, limit: int = 20, offset: int = 0): - """List wallets in DB.""" - return {"wallets": [], "total": 0, "limit": limit, "offset": offset} - -@app.get("/api/v1/wallet-db/search") -async def wallet_db_search(request: Request, query: str): - """Search wallet DB.""" - return {"results": [], "count": 0} - -@app.get("/api/v1/wallet-db/stats") -async def wallet_db_stats(request: Request): - """Get wallet DB statistics.""" - return { - "total_wallets": 150000, - "labeled_wallets": 45000, - "high_risk": 1200, - "last_updated": datetime.now(timezone.utc).isoformat(), - } - -# ═══════════════════════════════════════════════════════════ -# RUGMAPS ENDPOINTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/rugmaps/health") -async def rugmaps_health(request: Request): - """RugMaps service health.""" - return { - "status": "healthy", - "service": "rugmaps", - "data_sources": ["CoinGecko", "CoinMarketCap", "DexScreener", "DeFiLlama", "Birdeye"], - "scan_engines": ["RugCheck", "TokenSniffer", "Honeypot.is"], - "supported_chains": ["solana", "ethereum", "base", "bsc", "arbitrum", "polygon", "avalanche", "optimism", "sui", "aptos", "tron", "bitcoin"], - "capabilities": ["scan", "analyze", "monitor", "alert"], - } - -@app.get("/api/v1/security/solana/health") -async def solana_network_health(request: Request): - """Real-time Solana network health — used by IntelligenceSection.""" - try: - async with httpx.AsyncClient(timeout=8) as c: - r = await c.post( - "https://api.mainnet-beta.solana.com", - json={"jsonrpc": "2.0", "id": 1, "method": "getHealth"}, - headers={"Content-Type": "application/json"}, - ) - sol_healthy = r.status_code == 200 - sol_status = "healthy" if sol_healthy else "degraded" - except Exception: - sol_status = "degraded" - - try: - async with httpx.AsyncClient(timeout=8) as c: - r = await c.post( - "https://api.mainnet-beta.solana.com", - json={"jsonrpc": "2.0", "id": 1, "method": "getRecentPerformanceSamples", "params": [1]}, - headers={"Content-Type": "application/json"}, - ) - if r.status_code == 200: - j = r.json() - samples = j.get("result", []) - tps = samples[0].get("numTransactions", 0) / samples[0].get("samplePeriodSecs", 60) if samples else 3500 - else: - tps = 3500 - except Exception: - tps = 3500 - - try: - async with httpx.AsyncClient(timeout=8) as c: - r = await c.post( - "https://api.mainnet-beta.solana.com", - json={"jsonrpc": "2.0", "id": 1, "method": "getSlot"}, - headers={"Content-Type": "application/json"}, - ) - if r.status_code == 200: - j = r.json() - slot = j.get("result", 295000000) - else: - slot = 295000000 - except Exception: - slot = 295000000 - - try: - async with httpx.AsyncClient(timeout=8) as c: - r = await c.post( - "https://api.mainnet-beta.solana.com", - json={"jsonrpc": "2.0", "id": 1, "method": "getEpochInfo"}, - headers={"Content-Type": "application/json"}, - ) - if r.status_code == 200: - j = r.json() - ei = j.get("result", {}) - epoch = ei.get("epoch", 650) - slot_index = ei.get("slotIndex", 0) - slots_in_epoch = ei.get("slotsInEpoch", 432000) - epoch_progress = (slot_index / slots_in_epoch) * 100 if slots_in_epoch else 50 - else: - epoch = 650; epoch_progress = 50 - except Exception: - epoch = 650; epoch_progress = 50 - - return { - "tps": round(tps, 1), - "blockTime": 0.4, - "slotHeight": slot, - "validatorCount": 1950, - "epoch": epoch, - "epochProgress": round(epoch_progress, 1), - "voteDistance": 0, - "latency": 120, - "status": sol_status, - "blockProduction": { - "leaderSlots": 432000, - "skippedSlots": 8000, - }, - } - -@app.get("/api/v1/rugmaps/trending") -async def rugmaps_trending(request: Request, chain: str = "solana"): - """Get trending tokens from CoinGecko (DexScreener /dex/trending is dead).""" - data = await _coingecko_get("/search/trending", ttl=120) - tokens = [] - if data and "coins" in data: - for item in data["coins"][:20]: - coin = item.get("item", {}) - tokens.append({ - "address": coin.get("id", ""), - "name": coin.get("name", ""), - "symbol": coin.get("symbol", "").upper(), - "price_usd": coin.get("data", {}).get("price", 0) if coin.get("data") else 0, - "volume_h24": coin.get("data", {}).get("total_volume", 0) if coin.get("data") else 0, - "liquidity_usd": coin.get("data", {}).get("market_cap", 0) if coin.get("data") else 0, - "change_24h": coin.get("data", {}).get("price_change_percentage_24h", {}).get("usd", 0) if coin.get("data") else 0, - "market_cap_rank": coin.get("market_cap_rank"), - "flags": [], - }) - return {"tokens": tokens, "count": len(tokens), "chain": chain} - -@app.get("/api/v1/rugmaps/scam-radar") -async def rugmaps_scam_radar(request: Request, limit: int = 20): - """Get scam radar results — real curated scam/rug patterns.""" - return { - "scam_radar": [ - {"token": "So11111111111111111111111111111111111111112", "name": "Wrapped SOL Impersonator", "risk_score": 92, "flags": ["Impersonation", "Hidden mint authority", "No liquidity lock"], "chain": "solana", "detected_at": datetime.now(timezone.utc).isoformat()}, - {"token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "name": "USDC Fake Airdrop Contract", "risk_score": 88, "flags": ["Phishing domain", "Drainer contract", "Fake airdrop"], "chain": "ethereum", "detected_at": datetime.now(timezone.utc).isoformat()}, - {"token": "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0", "name": "Polygon MATIC Clone", "risk_score": 85, "flags": ["Wallet drainer", "Unverified source", "Proxy re-entrancy"], "chain": "polygon", "detected_at": datetime.now(timezone.utc).isoformat()}, - {"token": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", "name": "Fake Bonk Airdrop", "risk_score": 79, "flags": ["Fake airdrop", "Tax 99%", "Honeypot"], "chain": "solana", "detected_at": datetime.now(timezone.utc).isoformat()}, - {"token": "0x6B175474E89094C44Da98b954EedeAC495271d0F", "name": "DAI Impersonator", "risk_score": 74, "flags": ["Unverified contract", "Owner can pause"], "chain": "ethereum", "detected_at": datetime.now(timezone.utc).isoformat()}, - ], - "count": 5, - } - -@app.get("/api/v1/rugmaps/bad-actors") -async def rugmaps_bad_actors(request: Request, limit: int = 20): - """Get known bad actors — curated threat intelligence database.""" - return { - "bad_actors": [ - {"address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96", "label": "Multichain Exploiter Group", "incidents": 14, "total_stolen_usd": 320000000, "chains": ["ethereum", "bsc", "polygon"], "first_seen": "2024-01-15T00:00:00Z"}, - {"address": "F1HqWUoCu5HQMXVgGq8FzQvnhmQzDgZ7TqkH3JB3NEdx", "label": "Solana MEV Sandwich Bot", "incidents": 8900, "total_stolen_usd": 1200000, "chains": ["solana"], "first_seen": "2024-03-20T00:00:00Z"}, - {"address": "0xdD2FD458127BDe7B1436A5A5bbD8BF9DBb8E3C95", "label": "Inferno Drainer Operator", "incidents": 2100, "total_stolen_usd": 81000000, "chains": ["ethereum", "arbitrum", "base"], "first_seen": "2024-06-01T00:00:00Z"}, - {"address": "0xE37e799D5077682FA0a244D46E5649F71457BD09", "label": "Pink Drainer Affiliate", "incidents": 560, "total_stolen_usd": 18000000, "chains": ["ethereum", "optimism"], "first_seen": "2024-08-12T00:00:00Z"}, - {"address": "5VgM7HQ8NjYuMBh6HqkGkJRBFZPtMQUpbnC5FPEhgQUT", "label": "Solana Rug Pull Ring", "incidents": 47, "total_stolen_usd": 5400000, "chains": ["solana"], "first_seen": "2025-01-05T00:00:00Z"}, - ], - "count": 5, - } - -# ─── Solscan base URL ─────────────────────────────────────── -SOLSCAN_BASE = "https://solscan.io/account" - -# ─── Fake Solana wallets that look realistic ──────────────── -_SOL_WALLETS = [ - "GJjF1q9zYx8M3v5pN2rK7cB4wL6tH0mS", "8fT2nX4pQ6sR9vW3yZ5aC7eD1gH0jK", - "3mL1oP4qR7sT0uV2wX5yZ8bD9cF6gI", "9JkL3mN4oP5qR7sT0uV2wX6yZ8aB1cD", - "4dE5fG7hI8jK1lM2nO3pQ4rS6tU7vW8", "2xY9zA1bC3dE5fG7hI8jK0lM2nO4pQ6", - "7rS9tU1vW2xY4zA6bC8dE0fG2hI4jK6", "5lM7nO9pQ1rS3tU5vW7xY9zA2bC4dE6", - "1aB3cD5eF7gH9iJ1kL3mN5oP7qR9sT1u", "6vW8xY0zA2bC4dE6fG8hI0jK2lM4nO6", - "0pQ2rS4tU6vW8xY0zA2bC4dE6fG8hI0", "3jK5lM7nO9pQ1rS3tU5vW7xY9zA1bC3d", - "8eF0gH2iJ4kL6mN8oP0qR2sT4uV6wX8", "2yZ4aB6cD8eF0gH2iJ4kL6mN8oP0qR2s", - "5tU7vW9xY1zA3bC5dE7fG9hI1jK3lM5", "9nO1pQ3rS5tU7vW9xY1zA3bC5dE7fG9", - "4hI6jK8lM0nO2pQ4rS6tU8vW0xY2zA4", "7bC9dE1fG3hI5jK7lM9nO1pQ3rS5tU7", - "1vW3xY5zA7bC9dE1fG3hI5jK7lM9nO1", "6pQ8rS0tU2vW4xY6zA8bC0dE2fG4hI6", - "0jK2lM4nO6pQ8rS0tU2vW4xY6zA8bC0", "3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3", - "8xY0zA2bC4dE6fG8hI0jK2lM4nO6pQ8", "2rS4tU6vW8xY0zA2bC4dE6fG8hI0jK2", - "5lM7nO9pQ1rS3tU5vW7xY9zA1bC3dE5", "9fG1hI3jK5lM7nO9pQ1rS3tU5vW7xY9", - "4zA6bC8dE0fG2hI4jK6lM8nO0pQ2rS4", "7tU9vW1xY3zA5bC7dE9fG1hI3jK5lM7", - "1nO3pQ5rS7tU9vW1xY3zA5bC7dE9fG1", "6hI8jK0lM2nO4pQ6rS8tU0vW2xY4zA6", - "0bC2dE4fG6hI8jK0lM2nO4pQ6rS8tU0", "3vW5xY7zA9bC1dE3fG5hI7jK9lM1nO3", - "8pQ0rS2tU4vW6xY8zA0bC2dE4fG6hI8", "2jK4lM6nO8pQ0rS2tU4vW6xY8zA0bC2", - "5dE7fG9hI1jK3lM5nO7pQ9rS1tU3vW5", "9xY1zA3bC5dE7fG9hI1jK3lM5nO7pQ9", - "4rS6tU8vW0xY2zA4bC6dE8fG0hI2jK4", "7lM9nO1pQ3rS5tU7vW9xY1zA3bC5dE7", - "1fG3hI5jK7lM9nO1pQ3rS5tU7vW9xY1", "6zA8bC0dE2fG4hI6jK8lM0nO2pQ4rS6", - "0tU2vW4xY6zA8bC0dE2fG4hI6jK8lM0", "3nO5pQ7rS9tU1vW3xY5zA7bC9dE1fG3", - "8hI0jK2lM4nO6pQ8rS0tU2vW4xY6zA8", "2bC4dE6fG8hI0jK2lM4nO6pQ8rS0tU2", - "5vW7xY9zA1bC3dE5fG7hI9jK1lM3nO5", "9pQ1rS3tU5vW7xY9zA1bC3dE5fG7hI9", - "4jK6lM8nO0pQ2rS4tU6vW8xY0zA2bC4", "7dE9fG1hI3jK5lM7nO9pQ1rS3tU5vW7", - "1xY3zA5bC7dE9fG1hI3jK5lM7nO9pQ1", "6rS8tU0vW2xY4zA6bC8dE0fG2hI4jK6", -] - -def _generate_holders(count: int, pair_data: dict = None) -> list: - """Generate realistic holder data from DexScreener pair or generate mock.""" - import random - - # If pair has real holder data, use it as base - real_holders = None - if pair_data and isinstance(pair_data, dict): - # DexScreener sometimes returns holders in the pair - h = pair_data.get("holders") - if isinstance(h, list) and len(h) > 0: - real_holders = h - - holders = [] - if real_holders and len(real_holders) >= 10: - # Use real holders, pad to count - for i, rh in enumerate(real_holders): - addr = rh.get("address", _SOL_WALLETS[i % len(_SOL_WALLETS)]) - pct = float(rh.get("pct", rh.get("percentage", 0))) - holders.append({ - "rank": i + 1, - "address": addr, - "label": rh.get("label", ""), - "amount": rh.get("amount", rh.get("balance", "0")), - "pct": round(pct, 2), - "url": f"{SOLSCAN_BASE}/{addr}", - "type": "whale" if pct > 2 else "retail", - }) - - # Pad with generated holders - while len(holders) < count: - addr = _SOL_WALLETS[(len(holders)) % len(_SOL_WALLETS)] - i = len(holders) - pct = round(random.uniform(0.001, 0.5), 3) - holders.append({ - "rank": i + 1, - "address": addr, - "label": "", - "amount": str(int(pct * 1000000)), - "pct": pct, - "url": f"{SOLSCAN_BASE}/{addr}", - "type": "retail", - }) - else: - # Fully generated — simulate realistic distribution - remaining = 100.0 - for i in range(count): - if i < 3: - pct = round(min(remaining * 0.25, 12 + random.uniform(0, 5)), 2) - elif i < 10: - pct = round(min(remaining * 0.12, 3 + random.uniform(0, 3)), 2) - elif i < 50: - pct = round(min(remaining * 0.06, 0.5 + random.uniform(0, 1.5)), 3) - else: - pct = round(min(remaining * 0.02, random.uniform(0.005, 0.3)), 3) - pct = min(pct, remaining * 0.9) - remaining -= pct - addr = _SOL_WALLETS[i % len(_SOL_WALLETS)] - typ = "dev" if i == 0 else "whale" if i < 5 else "deployer" if i < 10 else "retail" - holders.append({ - "rank": i + 1, - "address": addr, - "label": "Token Creator" if i == 0 else f"Whale #{i}" if i < 5 else "", - "amount": str(int(pct * 1000000)), - "pct": round(pct, 3), - "url": f"{SOLSCAN_BASE}/{addr}", - "type": typ, - }) - if remaining > 0.01: - holders.append({ - "rank": len(holders) + 1, - "address": "rest_retail_pool", - "label": f"~{random.randint(800, 5000)} small holders", - "amount": "0", - "pct": round(remaining, 2), - "url": "", - "type": "retail_pool", - }) - - return sorted(holders, key=lambda x: x["pct"], reverse=True) - -def _compute_clusters(holders: list) -> list: - """Group holders into clusters based on concentration patterns.""" - clusters = [] - # Top 3 holders = whale cluster - top_holders = [h for h in holders if h.get("pct", 0) >= 3] - if len(top_holders) >= 2: - clusters.append({ - "id": "whale_cluster_1", - "name": "Top Whale Concentration", - "color": "#FF3366", - "wallets": len(top_holders), - "supply_pct": round(sum(h["pct"] for h in top_holders), 1), - "risk": "HIGH", - "type": "concentration", - }) - - # Deployer cluster - deployers = [h for h in holders if h.get("type") == "dev"] - if deployers: - clusters.append({ - "id": "dev_cluster", - "name": "Deployer Group", - "color": "#FF6600", - "wallets": len(deployers), - "supply_pct": round(sum(h["pct"] for h in deployers), 1), - "risk": "CRITICAL", - "type": "dev_team", - }) - - # Mid-tier cluster - mid = [h for h in holders if 1 <= h.get("pct", 0) < 3] - if mid: - clusters.append({ - "id": "mid_cluster", - "name": "Mid-Tier Holders", - "color": "#D1A340", - "wallets": len(mid), - "supply_pct": round(sum(h["pct"] for h in mid), 1), - "risk": "MEDIUM", - "type": "medium_holders", - }) - - # Bundle detection (wallets with very similar percentages) - from collections import Counter - pct_counts = Counter(round(h["pct"], 1) for h in holders if h["pct"] > 0.1) - bundles = [(pct, cnt) for pct, cnt in pct_counts.items() if cnt >= 3] - for j, (pct, cnt) in enumerate(bundles[:3]): - bundle_wallets = [h for h in holders if round(h["pct"], 1) == pct] - clusters.append({ - "id": f"bundle_{j}", - "name": f"Bundle #{j+1}", - "color": ["#28CBF4", "#00E676", "#8B5CF6"][j % 3], - "wallets": len(bundle_wallets), - "supply_pct": round(pct * cnt, 1), - "risk": "HIGH", - "type": "bundle", - }) - - return clusters - -@app.get("/api/v1/rugmaps/scan") -async def rugmaps_scan(request: Request, token: str = "", chain: str = "solana"): - """Scan a token address — fetches DexScreener data, returns 250+ holders with solscan links.""" - if not token: - return {"error": "token parameter required", "holders": [], "clusters": [], "token": {}} - - # Try fetching DexScreener data - pair_data = None - if chain == "solana": - pair_data = await _dexscreener_get(f"/tokens/v1/solana/{token}", ttl=30) - - token_info = {} - if pair_data and isinstance(pair_data, dict): - pairs = pair_data.get("pairs", []) - if pairs: - p = pairs[0] - token_info = { - "name": p.get("baseToken", {}).get("name", ""), - "symbol": p.get("baseToken", {}).get("symbol", ""), - "address": token, - "chain": chain, - "price": p.get("priceUsd", ""), - "price_change_24h": p.get("priceChange", {}).get("h24", 0) if isinstance(p.get("priceChange"), dict) else 0, - "volume_24h": p.get("volume", {}).get("h24", 0) if isinstance(p.get("volume"), dict) else 0, - "liquidity_usd": p.get("liquidity", {}).get("usd", 0) if isinstance(p.get("liquidity"), dict) else 0, - "market_cap": p.get("marketCap", 0) or p.get("fdv", 0), - "dex_id": p.get("dexId", ""), - "pair_url": p.get("url", ""), - "pair_address": p.get("pairAddress", ""), - } - - # Generate 250+ holders - holders = _generate_holders(250, pair_data) - clusters = _compute_clusters(holders) - - # Compute risk score from concentration - top10_pct = sum(h["pct"] for h in holders[:10]) if len(holders) >= 10 else 0 - risk_score = min(99, int(top10_pct * 0.8 + (len(clusters) * 5))) - - return { - "token": token_info if token_info else {"address": token, "chain": chain}, - "holders": holders, - "total_holders": len(holders), - "clusters": clusters, - "risk_score": risk_score, - "risk_level": "CRITICAL" if risk_score >= 75 else "HIGH" if risk_score >= 50 else "MEDIUM" if risk_score >= 30 else "LOW", - "scanned_at": datetime.now(timezone.utc).isoformat(), - "source": "dexscreener" if token_info else "generated", - } - -# ─── Cloudflare AI helper ──────────────────────────────────── -CLOUDFLARE_AI_TOKEN = _load_secret("cloudflare_ai_token") or os.getenv("CLOUDFLARE_AI_TOKEN", "") -CLOUDFLARE_AI_URL = "https://api.cloudflare.com/client/v4/accounts/8f9bd9165c1250b426c66dc1967deefd/ai/run/@cf/meta/llama-3.1-8b-instruct" - -async def _cf_ai_analyze(prompt: str) -> dict: - """Call Cloudflare Workers AI for token analysis.""" - try: - async with httpx.AsyncClient(timeout=30) as c: - r = await c.post( - CLOUDFLARE_AI_URL, - headers={ - "Authorization": f"Bearer {CLOUDFLARE_AI_TOKEN}", - "Content-Type": "application/json", - }, - json={ - "messages": [ - { - "role": "system", - "content": "You are a crypto security analyst. Respond only with a JSON object containing: risk_score (0-100 integer), flags (array of strings up to 8 items), ai_verdict (2-3 sentence verdict), summary (1 sentence overview). Example: {\"risk_score\": 85, \"flags\": [\"High holder concentration\", \"Unverified contract\"], \"ai_verdict\": \"This token shows multiple red flags indicating high rug pull risk.\", \"summary\": \"Top 10 wallets control 67% of supply.\"}", - }, - {"role": "user", "content": prompt}, - ], - "max_tokens": 800, - }, - ) - if r.status_code == 200: - result = r.json() - response_text = result.get("result", {}).get("response", "") - # Extract JSON from markdown code block if present - if "```json" in response_text: - json_str = response_text.split("```json")[1].split("```")[0].strip() - elif "```" in response_text: - json_str = response_text.split("```")[1].split("```")[0].strip() - else: - json_str = response_text.strip() - try: - return json.loads(json_str) - except json.JSONDecodeError: - return { - "risk_score": 50, - "flags": ["Analysis parse error"], - "ai_verdict": response_text[:500], - "summary": "Raw AI response received.", - } - except Exception: - pass - return {"risk_score": 30, "flags": [], "ai_verdict": "AI analysis unavailable.", "summary": ""} - -@app.get("/api/v1/rugmaps/analyze") -async def rugmaps_analyze(request: Request, token: str): - """Analyze a token address with AI-powered risk scoring.""" - if not token: - return {"error": "token parameter required"} - - # Fetch DexScreener data first - pair_data = await _dexscreener_get(f"/tokens/v1/solana/{token}", ttl=30) - token_info = {} - if pair_data and isinstance(pair_data, dict): - pairs = pair_data.get("pairs", []) - if pairs: - p = pairs[0] - bt = p.get("baseToken", {}) - token_info = { - "name": bt.get("name", ""), - "symbol": bt.get("symbol", ""), - "price": p.get("priceUsd", ""), - "liquidity_usd": p.get("liquidity", {}).get("usd", 0) if isinstance(p.get("liquidity"), dict) else 0, - "volume_24h": p.get("volume", {}).get("h24", 0) if isinstance(p.get("volume"), dict) else 0, - "price_change_24h": p.get("priceChange", {}).get("h24", 0) if isinstance(p.get("priceChange"), dict) else 0, - "market_cap": p.get("marketCap", 0) or p.get("fdv", 0), - "holders": p.get("holders", 0), - "dex_id": p.get("dexId", ""), - } - - # Build AI prompt - info_str = ", ".join(f"{k}={v}" for k, v in token_info.items()) if token_info else f"address={token}" - prompt = f"Analyze this Solana token: {info_str}. Determine risk of rug pull, honeypot, or scam." - ai_result = await _cf_ai_analyze(prompt) - - return { - "address": token, - "risk_score": ai_result.get("risk_score", 30), - "flags": ai_result.get("flags", []), - "ai_verdict": ai_result.get("ai_verdict", ""), - "summary": ai_result.get("summary", ""), - "liquidity": {"usd": token_info.get("liquidity_usd", 0)} if token_info else {"usd": 0}, - "volume_24h": {"usd": token_info.get("volume_24h", 0)} if token_info else {"usd": 0}, - "holders": token_info.get("holders", 0) if token_info else 0, - "price": token_info.get("price", "") if token_info else "", - "token_name": token_info.get("name", "") if token_info else "", - "symbol": token_info.get("symbol", "") if token_info else "", - "analyzed_at": datetime.now(timezone.utc).isoformat(), - } - -@app.get("/api/v1/rugmaps/wallet/{addr}") -async def rugmaps_wallet(request: Request, addr: str): - """Get wallet analysis from RugMaps.""" - return { - "address": addr[:8] + "..." if len(addr) > 8 else addr, - "risk_score": 25, - "total_value_usd": 125000, - "tokens_held": 42, - "defi_positions": [], - "risk_flags": [], - } - -# ═══════════════════════════════════════════════════════════ -# RUGCHARTS ENDPOINTS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/rugcharts/health") -async def rugcharts_health(request: Request): - """RugCharts service health.""" - return { - "status": "healthy", - "service": "rugcharts", - "capabilities": ["ta", "signals", "analysis", "trending"], - "signal_types": ["RSI", "MACD", "Bollinger", "Volume", "Momentum"], - } - -@app.get("/api/v1/rugcharts/trending") -async def rugcharts_trending(request: Request, chain: str = "solana", limit: int = 20): - """Get REAL trending tokens — new launches with actual volume traction.""" - tokens = [] - seen = set() - - # Source 1: DexScreener latest token profiles (new launches) - try: - async with httpx.AsyncClient(timeout=15) as c: - r = await c.get("https://api.dexscreener.com/token-profiles/latest/v1", - headers={"User-Agent": "RMI/1.0"}) - if r.status_code == 200: - profiles = r.json() - for p in profiles[:30]: - addr = p.get("tokenAddress", "") - pchain = p.get("chainId", "solana") - if pchain != chain: continue - if addr in seen: continue - seen.add(addr) - # Fetch pair data for this token - try: - r2 = await c.get(f"https://api.dexscreener.com/latest/dex/tokens/{addr}") - if r2.status_code == 200: - pairs = r2.json().get("pairs", []) - if pairs: - pair = pairs[0] - base = pair.get("baseToken", {}) - pc = pair.get("priceChange", {}) or {} - vol = float((pair.get("volume",{})or{}).get("h24",0)or 0) - if vol < 500: continue # Skip truly dead tokens - tokens.append({ - "address": addr, "name": base.get("name",""), "symbol": base.get("symbol",""), - "price_usd": float(pair.get("priceUsd",0)or 0), - "change_24h": float(pc.get("h24",0)or 0), - "volume_24h": vol, - "liquidity_usd": float((pair.get("liquidity",{})or{}).get("usd",0)or 0), - "fdv": float(pair.get("fdv",0)or 0), - "dex": pair.get("dexId",""), - "pair_address": pair.get("pairAddress",""), - "chain": pchain, - "source": "new_launch", - }) - except: pass - tokens.sort(key=lambda x: x["volume_24h"], reverse=True) - except Exception: pass - - # Source 2: Fallback — popular degen tokens if we don't have enough - if len(tokens) < limit: - degen = { - "solana": ["BONK","WIF","JUP","RAY","JTO","BOME","POPCAT","MEW","MYRO","SAMO","WEN","DUST","MOBILE","HNT","PYTH","ORCA","MSOL","ZEUS"], - "ethereum": ["PEPE","SHIB","UNI","AAVE","LINK","MKR","LDO","ENS","PENDLE","EIGEN","ENA","ETHFI"], - "base": ["BRETT","DEGEN","TOSHI","AERO","MOCHI","KEYCAT","NORMIE","MIGGLES","SKI","BALD","TYBG","HIGHER"], - "bsc": ["CAKE","XVS","BAKE","ALPACA","FLOKI","BABYDOGE","TWT"], - "tron": ["TRX","USDT","BTT","JST","SUN","WIN","NFT"], - } - for symbol in degen.get(chain, degen["solana"]): - if len(tokens) >= limit: break - if symbol in seen: continue - try: - data = await _dexscreener_get(f"/latest/dex/search?q={symbol}") - pair = _extract_pair_from_dexscreener(data) - if pair: - base = pair.get("baseToken", {}) - sym = (base.get("symbol","")or"").upper() - if sym in seen: continue - pc = pair.get("priceChange",{})or{} - vol = float((pair.get("volume",{})or{}).get("h24",0)or 0) - if vol < 1000: continue - seen.add(sym) - tokens.append({ - "address": base.get("address",""), "name": base.get("name",""), "symbol": sym, - "price_usd": float(pair.get("priceUsd",0)or 0), "change_24h": float(pc.get("h24",0)or 0), - "volume_24h": vol, "liquidity_usd": float((pair.get("liquidity",{})or{}).get("usd",0)or 0), - "fdv": float(pair.get("fdv",0)or 0), "dex": pair.get("dexId",""), - "pair_address": pair.get("pairAddress",""), "chain": pair.get("chainId",chain), - "source": "degen", - }) - except: pass - - return {"tokens": tokens[:limit], "count": len(tokens[:limit]), "chain": chain} - -@app.get("/api/v1/rugcharts/ta/{addr}") -async def rugcharts_ta(request: Request, addr: str): - """Get technical analysis for a token from real DexScreener data.""" - try: - search_data = await _dexscreener_get(f"/latest/dex/search?q={addr}") - pair = _extract_pair_from_dexscreener(search_data) - if not pair: - pair_data = await _dexscreener_get(f"/dex/pairs/solana/{addr}") - pair = _extract_pair_from_dexscreener(pair_data) - - if pair: - price = float(pair.get("priceUsd", 0) or 0) - price_change = pair.get("priceChange", {}) or {} - volume = pair.get("volume", {}) or {} - liquidity = pair.get("liquidity", {}) or {} - txns = pair.get("txns", {}) or {} - - h24_change = float(price_change.get("h24", 0) or 0) - h6_change = float(price_change.get("h6", 0) or 0) - h1_change = float(price_change.get("h1", 0) or 0) - m5_change = float(price_change.get("m5", 0) or 0) - - rsi_val = 50 + (h24_change * 2) if abs(h24_change) < 50 else 50 + (50 if h24_change > 0 else -50) - rsi_val = max(0, min(100, rsi_val)) - - macd_signal = "bullish" if h24_change > 2 else "bearish" if h24_change < -2 else "neutral" - - vol_24h = float(volume.get("h24", 0) or 0) - b_upper = price * (1 + abs(h24_change) / 100 * 2) - b_lower = price * (1 - abs(h24_change) / 100 * 2) - b_middle = price - - momentum = "bullish" if h6_change > 0 and h1_change > 0 else "bearish" if h6_change < 0 and h1_change < 0 else "neutral" - - vol_change = abs(h24_change) * (vol_24h / (float(liquidity.get("usd", 1)) or 1)) if liquidity else 0 - volume_text = "high" if vol_change > 10 else "elevated" if vol_change > 5 else "normal" if vol_change > 1 else "low" - - support = price * 0.9 - resistance = price * 1.1 - - h24_txns = txns.get("h24", {}) or {} - buy_count = h24_txns.get("buys", 0) or 0 - sell_count = h24_txns.get("sells", 0) or 0 - - return { - "address": addr, - "pair_address": pair.get("pairAddress", ""), - "token": {"name": pair.get("baseToken", {}).get("name", ""), - "symbol": pair.get("baseToken", {}).get("symbol", "")}, - "ta": { - "rsi": round(rsi_val, 1), - "macd": {"signal": macd_signal, "value": round(h24_change / 100, 4)}, - "bollinger_bands": {"upper": round(b_upper, 8), "middle": round(b_middle, 8), "lower": round(b_lower, 8)}, - "volume": volume_text, - "momentum": momentum, - "support": round(support, 8), - "resistance": round(resistance, 8), - "buy_count_24h": buy_count, - "sell_count_24h": sell_count, - "price_change": {"m5": m5_change, "h1": h1_change, "h6": h6_change, "h24": h24_change}, - }, - "available": True, - "price": price, - "liquidity_usd": float(liquidity.get("usd", 0) or 0), - "volume_24h": float(volume.get("h24", 0) or 0), - "dex": pair.get("dexId", "unknown"), - "chain": pair.get("chainId", "solana"), - } - except Exception as e: - return {"address": addr, "available": False, "error": str(e)} - - return {"address": addr, "ta": {"rsi": 50, "macd": {"signal": "neutral", "value": 0}, - "bollinger_bands": {"upper": 0, "middle": 0, "lower": 0}, "volume": "unknown", - "momentum": "neutral", "support": 0, "resistance": 0}, "available": False, - "error": "Token not found on DEX"} - -@app.get("/api/v1/rugcharts/signals/{addr}") -async def rugcharts_signals(request: Request, addr: str): - """Get trading signals for a token from real DexScreener data.""" - search_data = await _dexscreener_get(f"/dex/search/?q={addr}") - pair = _extract_pair_from_dexscreener(search_data) - if not pair: - pair_data = await _dexscreener_get(f"/dex/pairs/solana/{addr}") - pair = _extract_pair_from_dexscreener(pair_data) - - if pair: - price_change = pair.get("priceChange", {}) or {} - txns = pair.get("txns", {}) or {} - buys = txns.get("buys", {}) or {} - sells = txns.get("sells", {}) or {} - - h24_change = float(price_change.get("h24", 0) or 0) - h1_change = float(price_change.get("h1", 0) or 0) - buy24 = buys.get("h24", 0) or 0 - sell24 = sells.get("h24", 0) or 0 - - signals = [] - # RSI-based - if h24_change < -15: - signals.append({"type": "buy", "strength": "strong", "indicator": "RSI Oversold", "timeframe": "24h"}) - elif h24_change < -5: - signals.append({"type": "buy", "strength": "moderate", "indicator": "RSI Near Oversold", "timeframe": "24h"}) - elif h24_change > 25: - signals.append({"type": "sell", "strength": "strong", "indicator": "RSI Overbought", "timeframe": "24h"}) - elif h24_change > 10: - signals.append({"type": "sell", "strength": "moderate", "indicator": "RSI Near Overbought", "timeframe": "24h"}) - - # Volume/momentum - if h1_change > 0 and buy24 > sell24: - signals.append({"type": "buy", "strength": "moderate", "indicator": "Buy Pressure", "timeframe": "1h"}) - if h1_change < 0 and sell24 > buy24: - signals.append({"type": "sell", "strength": "moderate", "indicator": "Sell Pressure", "timeframe": "1h"}) - - if h1_change > 5: - signals.append({"type": "buy", "strength": "weak", "indicator": "Momentum Surge", "timeframe": "1h"}) - - buy_ratio = buy24 / max(sell24, 1) - overall = "buy" if buy_ratio > 1.3 and h24_change > 0 else "sell" if buy_ratio < 0.7 and h24_change < 0 else "neutral" - confidence = min(0.95, max(0.3, abs(h24_change) / 40 + abs(buy_ratio - 1) / 2)) - - return { - "address": addr, - "token": { - "name": pair.get("baseToken", {}).get("name", ""), - "symbol": pair.get("baseToken", {}).get("symbol", ""), - }, - "signals": signals, - "overall_signal": overall, - "confidence": round(confidence, 2), - "buy_sell_ratio": round(buy_ratio, 2), - "price_change_24h": h24_change, - } - return { - "address": addr, - "signals": [], - "overall_signal": "neutral", - "confidence": 0, - "error": "Token not found on DEX", - } - -@app.get("/api/v1/rugcharts/analyze/{addr}") -async def rugcharts_analyze(request: Request, addr: str): - """Get full chart analysis for a token from real DexScreener data.""" - search_data = await _dexscreener_get(f"/dex/search/?q={addr}") - pair = _extract_pair_from_dexscreener(search_data) - if not pair: - pair_data = await _dexscreener_get(f"/dex/pairs/solana/{addr}") - pair = _extract_pair_from_dexscreener(pair_data) - - if pair: - price = float(pair.get("priceUsd", 0) or 0) - price_change = pair.get("priceChange", {}) or {} - h24_change = float(price_change.get("h24", 0) or 0) - h6_change = float(price_change.get("h6", 0) or 0) - - # Trend from multi-timeframe - if h24_change > 5 and h6_change > 0: - trend = "bullish" - elif h24_change < -5 and h6_change < 0: - trend = "bearish" - elif abs(h24_change) < 2: - trend = "sideways" - else: - trend = "bullish" if h24_change > 0 else "bearish" - - # Support/resistance from price - support_levels = [round(price * 0.95, 10), round(price * 0.90, 10), round(price * 0.85, 10)] - resistance_levels = [round(price * 1.05, 10), round(price * 1.10, 10), round(price * 1.15, 10)] - - vol = pair.get("volume", {}) or {} - prev_vol_1h = float(vol.get("h1", 0)) if "h1" in vol else 0 - prev_vol_6h = float(vol.get("h6", 0)) if "h6" in vol else 0 - vol_profile = "increasing" if prev_vol_1h > prev_vol_6h / 6 else "decreasing" if prev_vol_1h < prev_vol_6h / 12 else "stable" - - # Simple pattern detection - if h24_change > 10 and h6_change > 5: - pattern = "bullish_breakout" - elif h24_change < -10 and h6_change < -5: - pattern = "bearish_breakdown" - elif abs(h24_change) < 3 and abs(h6_change) < 1: - pattern = "consolidation" - elif h24_change > 5: - pattern = "ascending_triangle" - elif h24_change < -5: - pattern = "descending_triangle" - else: - pattern = "range_bound" - - return { - "address": addr, - "token": { - "name": pair.get("baseToken", {}).get("name", ""), - "symbol": pair.get("baseToken", {}).get("symbol", ""), - "price": price, - "price_change_24h": h24_change, - }, - "analysis": { - "trend": trend, - "support_levels": support_levels, - "resistance_levels": resistance_levels, - "volume_profile": vol_profile, - "pattern": pattern, - }, - "ta_available": True, - "dex": pair.get("dexId", "unknown"), - "pair_address": pair.get("pairAddress", ""), - } - return { - "address": addr, - "analysis": { - "trend": "unknown", - "support_levels": [], - "resistance_levels": [], - "volume_profile": "unknown", - "pattern": "no_data", - }, - "ta_available": False, - "error": "Token not found on DEX", - } - -@app.get("/api/v1/rugcharts/market-context/{chain}") -async def rugcharts_market_context(request: Request, chain: str = "solana"): - """Get market context for chart analysis.""" - return { - "chain": chain, - "market_cap": "$2.5B", - "volume_24h": "$450M", - "trend": "bullish", - "top_gainers": 5, - "top_losers": 3, - } - -# ═══════════════════════════════════════════════════════════ -# DEBUG / TEST -# ═══════════════════════════════════════════════════════════ - -@app.get("/test/{name}") -async def test_endpoint(request: Request, name: str): - """Test endpoint.""" - return {"message": f"Hello {name}", "status": "ok"} - -@app.get("/api/v1/time") -async def get_time(request: Request): - """Get current server time.""" - return {"time": datetime.now(timezone.utc).isoformat()} - -# ═══════════════════════════════════════════════════════════ -# REAL CONTRACT ANALYSIS TOOLS — Slither / Foundry / Heimdall -# ═══════════════════════════════════════════════════════════ - -import subprocess as _sp -import tempfile as _tf -import os as _os - -FOUNDRY_PATH = "/root/.foundry/bin" -TOOLS_PATH = "/opt/tools" - - -@app.post("/api/v1/tools/slither/analyze") -async def slither_analyze(request: Request): - """Run Slither static analysis on Solidity source code. Returns vulnerabilities, detectors, and printer output.""" - try: - body = await request.json() - source = body.get("source", "") - if not source: - return {"error": "source code required (Solidity)"} - - with _tf.NamedTemporaryFile(mode="w", suffix=".sol", delete=False) as f: - f.write(source) - tmpfile = f.name - - try: - result = _sp.run( - ["slither", tmpfile, "--json", "-"], - capture_output=True, text=True, timeout=60, - env={**_os.environ, "PATH": f"{FOUNDRY_PATH}:{_os.environ.get('PATH', '')}"} - ) - # slither returns non-zero when it finds vulnerabilities — STILL outputs valid JSON - if result.stdout and result.stdout.strip().startswith("{"): - import json - data = json.loads(result.stdout) - detectors = data.get("results", {}).get("detectors", []) - return { - "success": True, - "vulnerability_count": len(detectors), - "vulnerabilities": [ - {"check": d.get("check", ""), "impact": d.get("impact", ""), - "confidence": d.get("confidence", ""), "description": d.get("description", "")} - for d in detectors[:20] - ], - "contracts_analyzed": len(data.get("results", {}).get("contracts", [])), - "tool": "slither", - } - return {"success": True, "vulnerability_count": 0, "vulnerabilities": [], "note": result.stderr[:200] if result.stderr else "clean"} - finally: - _os.unlink(tmpfile) - except Exception as e: - return {"error": str(e), "success": False} - - -@app.post("/api/v1/tools/cast/contract-info") -async def cast_contract_info(request: Request): - """Get on-chain contract info using Foundry cast. Returns bytecode hash, ABI, verification status, owner.""" - try: - body = await request.json() - address = body.get("address", "") - chain = body.get("chain", "ethereum") - - if not address: - return {"error": "contract address required"} - - rpc_map = { - "ethereum": "https://ethereum-rpc.publicnode.com", - "base": "https://mainnet.base.org", - "arbitrum": "https://arb1.arbitrum.io/rpc", - "optimism": "https://mainnet.optimism.io", - "polygon": "https://polygon-rpc.com", - "bsc": "https://bsc-dataseed.binance.org", - } - rpc = rpc_map.get(chain, rpc_map["ethereum"]) - env = {**_os.environ, "PATH": f"{FOUNDRY_PATH}:{_os.environ.get('PATH', '')}"} - - # Get bytecode - bc = _sp.run( - ["cast", "code", address, "--rpc-url", rpc], - capture_output=True, text=True, timeout=30, env=env - ) - bytecode = bc.stdout.strip() - - # Get bytecode hash - from hashlib import sha256 - bytecode_hash = sha256(bytecode.encode() if bytecode.startswith("0x") else bytes.fromhex(bytecode[2:] if bytecode.startswith("0x") else bytecode).hex() if bytecode else "").hexdigest() if bytecode else None - - # Check if it's a contract - is_contract = len(bytecode) > 4 if bytecode else False - - # Get nonce (if EOA) - nonce_result = _sp.run( - ["cast", "nonce", address, "--rpc-url", rpc], - capture_output=True, text=True, timeout=15, env=env - ) - nonce = int(nonce_result.stdout.strip()) if nonce_result.stdout.strip().isdigit() else None - - return { - "address": address, - "chain": chain, - "is_contract": is_contract, - "bytecode_length": len(bytecode) if bytecode else 0, - "bytecode_hash": bytecode_hash, - "nonce": nonce, - "tool": "foundry/cast", - } - except Exception as e: - return {"error": str(e), "success": False} - - -@app.post("/api/v1/tools/cast/storage-read") -async def cast_storage_read(request: Request): - """Read a storage slot from a contract using Foundry cast.""" - try: - body = await request.json() - address = body.get("address", "") - slot = body.get("slot", "0") - chain = body.get("chain", "ethereum") - - if not address: - return {"error": "contract address and slot required"} - - rpc_map = { - "ethereum": "https://eth.llamarpc.com", - "base": "https://mainnet.base.org", - } - rpc = rpc_map.get(chain, rpc_map["ethereum"]) - env = {**_os.environ, "PATH": f"{FOUNDRY_PATH}:{_os.environ.get('PATH', '')}"} - - result = _sp.run( - ["cast", "storage", address, str(slot), "--rpc-url", rpc], - capture_output=True, text=True, timeout=30, env=env - ) - - return { - "address": address, - "slot": slot, - "value": result.stdout.strip(), - "chain": chain, - "tool": "foundry/cast", - } - except Exception as e: - return {"error": str(e), "success": False} - - -@app.post("/api/v1/tools/cast/tx-decode") -async def cast_tx_decode(request: Request): - """Decode transaction calldata using Foundry cast 4byte decoder.""" - try: - body = await request.json() - tx_hash = body.get("tx_hash", "") - calldata = body.get("calldata", "") - chain = body.get("chain", "ethereum") - - if not tx_hash and not calldata: - return {"error": "tx_hash or calldata required"} - - rpc_map = { - "ethereum": "https://eth.llamarpc.com", - "base": "https://mainnet.base.org", - } - rpc = rpc_map.get(chain, rpc_map["ethereum"]) - env = {**_os.environ, "PATH": f"{FOUNDRY_PATH}:{_os.environ.get('PATH', '')}"} - - if tx_hash and not calldata: - # Get tx receipt to get input data - tx_result = _sp.run( - ["cast", "tx", tx_hash, "--rpc-url", rpc, "input"], - capture_output=True, text=True, timeout=30, env=env - ) - calldata = tx_result.stdout.strip() - - if calldata: - decode_result = _sp.run( - ["cast", "4byte-decode", calldata], - capture_output=True, text=True, timeout=30, env=env - ) - return { - "tx_hash": tx_hash, - "calldata": calldata[:100], - "decoded": decode_result.stdout.strip() or "unknown function", - "tool": "foundry/cast", - } - - return {"error": "no calldata to decode"} - except Exception as e: - return {"error": str(e), "success": False} - - -@app.post("/api/v1/tools/clone-detect") -async def clone_detect(request: Request): - """Detect contract clones using ssdeep fuzzy hashing (contract-clone-detector Docker).""" - try: - body = await request.json() - source = body.get("source", "") - address = body.get("address", "") - - if not source and not address: - return {"error": "Solidity source code or contract address required"} - - if address: - # Try to get source from Etherscan - import httpx - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"https://api.etherscan.io/api?module=contract&action=getsourcecode&address={address}") - data = r.json() - if data.get("status") == "1" and data.get("result"): - source = data["result"][0].get("SourceCode", "") - - if not source: - return {"error": "could not retrieve source code"} - - with _tf.NamedTemporaryFile(mode="w", suffix=".sol", delete=False) as f: - f.write(source) - tmpfile = f.name - - try: - result = _sp.run( - ["docker", "run", "--rm", "-v", f"{tmpfile}:/tmp/contract.sol", - "christoftorres/contract-clone-detector", - "python3", "-c", f""" -import ssdeep, json -with open('/tmp/contract.sol') as f: - code = f.read() -hash1 = ssdeep.hash(code.encode()) -print(json.dumps({{"ssdeep_hash": hash1, "length": len(code)}})) -"""], - capture_output=True, text=True, timeout=30 - ) - if result.stdout: - import json - data = json.loads(result.stdout) - return { - "ssdeep_hash": data.get("ssdeep_hash"), - "source_length": data.get("length"), - "note": "Fuzzy hash for clone detection. Compare with known scam contracts.", - "tool": "contract-clone-detector", - } - return {"error": "analysis failed", "detail": result.stderr[:200]} - finally: - _os.unlink(tmpfile) - except Exception as e: - return {"error": str(e), "success": False} - - -# ═══════════════════════════════════════════════════════════ -# SYSTEM STATUS & OBSERVABILITY -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/system/status") -async def system_status(request: Request): - """Full system health check — returns status of all components.""" - import subprocess - components = { - "backend": "healthy", - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - # Check Docker containers - try: - result = subprocess.run(["docker", "ps", "--format", "{{.Names}}={{.Status}}"], - capture_output=True, text=True, timeout=5) - containers = {} - for line in result.stdout.strip().split('\n'): - if '=' in line: - name, status = line.split('=', 1) - if 'rmi' in name.lower(): - containers[name] = "up" if "Up" in status else status[:30] - components["containers"] = containers - except Exception: - components["containers"] = {"error": "unable to query"} - - # Check x402 workers - try: - async with httpx.AsyncClient(timeout=5) as c: - r1 = await c.get("https://x402-sol.cryptorugmuncher.workers.dev") - r2 = await c.get("https://x402-base.cryptorugmuncher.workers.dev") - components["x402_workers"] = { - "solana": "online" if r1.status_code == 200 else f"status_{r1.status_code}", - "base": "online" if r2.status_code == 200 else f"status_{r2.status_code}", - } - except Exception: - components["x402_workers"] = {"error": "unable to check"} - - # Check external APIs - apis = {} - try: - async with httpx.AsyncClient(timeout=5) as c: - r = await c.get("https://api.coingecko.com/api/v3/ping") - apis["coingecko"] = "online" if r.status_code == 200 else "degraded" - except Exception: - apis["coingecko"] = "offline" - try: - async with httpx.AsyncClient(timeout=5) as c: - r = await c.get("https://api.dexscreener.com/latest/dex/search?q=SOL") - apis["dexscreener"] = "online" if r.status_code == 200 else "degraded" - except Exception: - apis["dexscreener"] = "offline" - components["external_apis"] = apis - - return components - -# ═══════════════════════════════════════════════════════════ -# SERVER ENTRY POINT -# ═══════════════════════════════════════════════════════════ - -if __name__ == "__main__": - import uvicorn - uvicorn.run( - "main:app", - host="0.0.0.0", - port=8000, - log_level="info", - reload=False, - ) - -# ═══════════════════════════════════════════════════════════ -# SUPABASE DATA — Wallet labels, scans, alerts -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/data/wallet-labels") -async def get_wallet_labels(request: Request, address: str = "", chain: str = ""): - """Get wallet labels from Supabase. Falls back to local JSON.""" - params = {} - if address: params["address"] = address - if chain: params["chain"] = chain - labels = await _supabase_get("wallet_labels", params if params else None) - if not labels: - import json as _json - try: - with open("/root/backend/data/wallet_labels.json") as f: - local = _json.load(f) - if address: - local = [l for l in local if l.get("address") == address] - labels = local - except: pass - return {"labels": labels, "count": len(labels)} - -@app.post("/api/v1/data/wallet-labels") -async def create_wallet_label(request: Request, data: dict): - """Save a wallet label. Supabase primary, local fallback.""" - address = data.get("address", "").strip() - if not address: - return {"status": "error", "error": "Address required"} - label_data = { - "address": address, - "chain": data.get("chain", "solana"), - "label": data.get("label", ""), - "label_type": data.get("label_type", "manual"), - "risk_score": data.get("risk_score", 0), - "notes": data.get("notes", ""), - } - result = await _supabase_insert("wallet_labels", label_data) - if result: - return {"status": "success", "label": result} - import json as _json - local_path = "/root/backend/data/wallet_labels.json" - try: - existing = [] - try: - with open(local_path) as f: - existing = _json.load(f) - except: pass - label_data["id"] = len(existing) + 1 - label_data["created_at"] = datetime.now(timezone.utc).isoformat() - existing.append(label_data) - os.makedirs("/root/backend/data", exist_ok=True) - with open(local_path, "w") as f: - _json.dump(existing, f) - return {"status": "success", "label": label_data, "source": "local"} - except Exception as e: - return {"status": "error", "error": str(e)} - -@app.get("/api/v1/data/scan-history") -async def get_scan_history(request: Request, address: str = "", limit: int = 20): - """Get scan history from Supabase.""" - scans = await _supabase_get("scan_results", {"contract_address": address} if address else None) - scans.sort(key=lambda x: x.get("scanned_at", ""), reverse=True) - return {"scans": scans[:limit], "count": len(scans[:limit])} - -@app.get("/api/v1/data/alerts") -async def get_data_alerts(request: Request, limit: int = 20, severity: str = ""): - """Get security + whale alerts combined.""" - sec = await _supabase_get("security_alerts") - wh = await _supabase_get("whale_alerts") - all_alerts = list(sec) + list(wh) - if severity: - all_alerts = [a for a in all_alerts if a.get("severity") == severity] - all_alerts.sort(key=lambda x: x.get("created_at", x.get("detected_at", "")), reverse=True) - return {"alerts": all_alerts[:limit], "count": len(all_alerts[:limit])} - -@app.get("/api/v1/data/profile/{user_id}") -async def get_user_profile(request: Request, user_id: str): - """Get user profile from Supabase.""" - profiles = await _supabase_get("profiles", {"id": user_id}) - if profiles: - return {"profile": profiles[0]} - return {"profile": None} - -# ═══════════════════════════════════════════════════════════ -# RUGCHARTS — Real OHLCV Candlestick Data (CoinGecko + DexScreener) -# ═══════════════════════════════════════════════════════════ - -# Token address -> CoinGecko ID mapping cache -_COINGECKO_ID_CACHE: dict = {} - -async def _resolve_coingecko_id(addr: str, chain: str) -> str | None: - """Resolve token address to CoinGecko ID via DexScreener.""" - cache_key = f"{chain}:{addr}" - if cache_key in _COINGECKO_ID_CACHE: - return _COINGECKO_ID_CACHE[cache_key] - - # Try DexScreener search - try: - search_data = await _dexscreener_get(f"/latest/dex/search?q={addr}") - pair = _extract_pair_from_dexscreener(search_data) - if pair: - base = pair.get("baseToken", {}) - symbol = (base.get("symbol", "") or "").lower() - # Common CoinGecko ID mappings - cg_map = { - "sol": "solana", "eth": "ethereum", "btc": "bitcoin", - "usdc": "usd-coin", "usdt": "tether", "bonk": "bonk", - "wif": "dogwifcoin", "jup": "jupiter-exchange-solana", - "ray": "raydium", "pyth": "pyth-network", "mnde": "marinade", - } - cg_id = cg_map.get(symbol, symbol) - # If pair is on Solana, CoinGecko uses different format - if chain == "solana" and symbol not in cg_map: - cg_id = symbol # try symbol directly - _COINGECKO_ID_CACHE[cache_key] = cg_id - return cg_id - except Exception: - pass - - # Fallback: try symbol from our known list - known = { - "solana:So11111111111111111111111111111111111111112": "solana", - "solana:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": "usd-coin", - "solana:DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263": "bonk", - } - cg_id = known.get(cache_key, "solana") - _COINGECKO_ID_CACHE[cache_key] = cg_id - return cg_id - -@app.get("/api/v1/rugcharts/ohlcv/{addr}") -async def rugcharts_ohlcv(request: Request, addr: str, chain: str = "solana", days: int = 7): - """Get OHLCV candlestick data from CoinGecko + live DexScreener price.""" - cg_id = await _resolve_coingecko_id(addr, chain) - - candles = [] - try: - async with httpx.AsyncClient(timeout=15) as c: - r = await c.get( - f"https://api.coingecko.com/api/v3/coins/{cg_id}/ohlc", - params={"vs_currency": "usd", "days": min(days, 30)} - ) - if r.status_code == 200: - raw = r.json() - candles = [{ - "time": int(c[0] / 1000), # Unix seconds for lightweight-charts - "open": c[1], "high": c[2], "low": c[3], "close": c[4] - } for c in raw] - except Exception: - pass - - # Merge latest DexScreener price as the current candle - try: - search_data = await _dexscreener_get(f"/latest/dex/search?q={addr}") - pair = _extract_pair_from_dexscreener(search_data) - if pair: - latest_price = float(pair.get("priceUsd", 0) or 0) - price_change = pair.get("priceChange", {}) or {} - volume = pair.get("volume", {}) or {} - liquidity = pair.get("liquidity", {}) or {} - txns = pair.get("txns", {}) or {} - base_token = pair.get("baseToken", {}) or {} - - # Add live price as latest candle - if candles and latest_price > 0: - last_candle = candles[-1] - candles.append({ - "time": int(time.time()), - "open": last_candle["close"], - "high": max(last_candle["close"], latest_price), - "low": min(last_candle["close"], latest_price), - "close": latest_price, - }) - - return { - "address": addr, - "chain": chain, - "cg_id": cg_id, - "candles": candles, - "count": len(candles), - "live": { - "price": latest_price, - "change_24h": float(price_change.get("h24", 0) or 0), - "volume_24h": float(volume.get("h24", 0) or 0), - "liquidity_usd": float(liquidity.get("usd", 0) or 0), - "fdv": float(pair.get("fdv", 0) or 0), - "buys_24h": txns.get("h24", {}).get("buys", 0) or 0, - "sells_24h": txns.get("h24", {}).get("sells", 0) or 0, - "dex": pair.get("dexId", ""), - "pair_address": pair.get("pairAddress", ""), - }, - "token": { - "name": base_token.get("name", ""), - "symbol": base_token.get("symbol", ""), - }, - "logo_url": f"https://coin-images.coingecko.com/coins/images/large/{cg_id}.png", - "social_links": { - "coingecko": f"https://www.coingecko.com/en/coins/{cg_id}", - "dexscreener": f"https://dexscreener.com/{chain}/{addr}", - "dextools": f"https://www.dextools.io/app/en/{chain}/pair-explorer/{pair.get('pairAddress', addr)}", - }, - } - except Exception: - pass - - return {"address": addr, "candles": candles, "count": len(candles), "error": "No live data available"} - -# ═══════════════════════════════════════════════════════════ -# SELF-INDEXING CANDLE SYSTEM -# Generates OHLCV candles from DexScreener data for ANY token -# Stores in Supabase + local cache for instant retrieval -# ═══════════════════════════════════════════════════════════ - -import time as _time -_CANDLE_CACHE: dict = {} # addr -> (candles, timestamp) - -@app.get("/api/v1/rugcharts/candles/{addr}") -async def rugcharts_candles(request: Request, addr: str, chain: str = "solana", days: int = 7): - """Get OHLCV candles — tries CoinGecko first, generates from DexScreener if unavailable.""" - cache_key = f"{chain}:{addr}" - - # Check cache (5 min TTL) - cached = _CANDLE_CACHE.get(cache_key) - if cached and _time.time() - cached[1] < 300: - return {"address": addr, "candles": cached[0], "count": len(cached[0]), "source": "cache", "chain": chain} - - candles = [] - source = "generated" - - # Try CoinGecko first - cg_id = await _resolve_coingecko_id(addr, chain) - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"https://api.coingecko.com/api/v3/coins/{cg_id}/ohlc?vs_currency=usd&days={min(days,7)}") - if r.status_code == 200: - raw = r.json() - if raw and len(raw) > 1: - candles = [{"time": int(c[0]/1000), "open": c[1], "high": c[2], "low": c[3], "close": c[4]} for c in raw] - source = "coingecko" - except Exception: - pass - - # If CoinGecko failed, generate from DexScreener - if not candles: - try: - data = await _dexscreener_get(f"/latest/dex/search?q={addr}") - pair = _extract_pair_from_dexscreener(data) - if not pair: - pair_data = await _dexscreener_get(f"/dex/pairs/solana/{addr}") - pair = _extract_pair_from_dexscreener(pair_data) - - if pair: - price = float(pair.get("priceUsd", 0) or 0) - price_change = pair.get("priceChange", {}) or {} - - # Generate 7 days of hourly candles from price changes - h24 = float(price_change.get("h24", 0) or 0) - h6 = float(price_change.get("h6", 0) or 0) - h1 = float(price_change.get("h1", 0) or 0) - m5 = float(price_change.get("m5", 0) or 0) - - now = int(_time.time()) - interval = 3600 # 1 hour candles - - # Generate candles working backwards - changes = [m5/12, h1 - m5/12, (h6-h1)/5, (h24-h6)/18] # approximate hourly changes - for day_offset in range(min(days, 7)): - for hour in range(24): - t = now - ((day_offset * 24 + (23 - hour)) * interval) - pct_change = 0 - if day_offset == 0: - if hour >= 22: pct_change = changes[0] - elif hour >= 21: pct_change = changes[1] - elif hour >= 16: pct_change = changes[2] - else: pct_change = changes[3] / 18 - else: - pct_change = (h24 / 24) * (0.5 + __import__('random').random()) - - candle_price = price * (1 + pct_change / 100) - candle_open = candle_price * (1 - pct_change / 200) - candle_high = max(candle_open, candle_price) * (1 + abs(pct_change) / 300) - candle_low = min(candle_open, candle_price) * (1 - abs(pct_change) / 300) - - candles.append({ - "time": t, - "open": round(candle_open, 12), - "high": round(candle_high, 12), - "low": round(candle_low, 12), - "close": round(candle_price, 12), - }) - candles.sort(key=lambda x: x["time"]) - source = "generated" - except Exception: - pass - - # Add live price as final candle - try: - data = await _dexscreener_get(f"/latest/dex/search?q={addr}") - pair = _extract_pair_from_dexscreener(data) - if pair: - live_price = float(pair.get("priceUsd", 0) or 0) - if candles: - last = candles[-1] - candles.append({"time": int(_time.time()), "open": last["close"], "high": max(last["close"], live_price), "low": min(last["close"], live_price), "close": live_price}) - except Exception: - pass - - _CANDLE_CACHE[cache_key] = (candles, _time.time()) - return {"address": addr, "candles": candles, "count": len(candles), "source": source, "chain": chain} - - -# ═══════════════════════════════════════════════════════════ -# INTEGRATED TOOLS — Ollama, Foundry, Slither, ccxt, web3, blogwatcher, Vault -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/tools/health") -async def api_tools_health(request: Request): - """Health check for all integrated tools.""" - return await tools_health() - -# --- Ollama Local LLM --- -@app.post("/api/v1/ai/ollama") -async def ai_ollama(request: Request, data: dict): - """Free local AI via Ollama. 7 models available.""" - prompt = data.get("message", data.get("prompt", "")) - model = data.get("model", "qwen2.5:3b") - system = data.get("system", "") - if not prompt: - return {"error": "message required"} - result = await ollama_chat(prompt, model, system) - return result - -@app.get("/api/v1/ai/ollama/models") -async def get_ollama_models(request: Request): - """List available Ollama models.""" - return {"models": await ollama_list_models()} - -# --- Foundry cast — Multi-chain EVM --- -@app.get("/api/v1/tools/cast/contract-info") -async def api_cast_contract_info(request: Request, address: str, chain: str = "ethereum"): - """Get contract info via Foundry cast. Supports eth, base, arb, poly, bsc, avax.""" - if not address: - raise HTTPException(status_code=400, detail="address required") - return cast_contract_info(address, chain) - -@app.get("/api/v1/tools/cast/tx-decode") -async def api_cast_tx_decode(request: Request, tx_hash: str, chain: str = "ethereum"): - """Decode a transaction.""" - if not tx_hash: - raise HTTPException(status_code=400, detail="tx_hash required") - return cast_tx_decode(tx_hash, chain) - -# --- Slither --- -@app.get("/api/v1/tools/slither/analyze") -async def api_slither_analyze(request: Request, address: str, chain: str = "ethereum"): - """Solidity vulnerability analysis.""" - if not address: - raise HTTPException(status_code=400, detail="address required") - return slither_analyze(address, chain) - -# --- ccxt — Multi-exchange prices --- -@app.get("/api/v1/markets/ccxt") -async def api_ccxt_prices(request: Request): - """Real-time prices from Binance, Kraken, Coinbase, Bybit.""" - return ccxt_get_prices() - -@app.get("/api/v1/markets/arbitrage") -async def api_ccxt_arbitrage(request: Request, symbol: str = "BTC/USDT"): - """Find arbitrage opportunities across exchanges.""" - return ccxt_arbitrage(symbol) - -# --- web3.py — Multi-chain wallet --- -@app.get("/api/v1/wallet/evm/{address}") -async def api_web3_wallet(request: Request, address: str, chain: str = "ethereum"): - """Multi-chain wallet balance via web3. Supports eth, base, bsc, poly, arb, avax.""" - if not address: - raise HTTPException(status_code=400, detail="address required") - return web3_wallet_balance(address, chain) - -# --- Blogwatcher --- -@app.get("/api/v1/tools/blogwatcher") -async def api_blogwatcher(request: Request, url: str, limit: int = 10): - """Fetch RSS/Atom feed.""" - if not url: - raise HTTPException(status_code=400, detail="url required") - return blogwatcher_fetch(url, limit) - -# --- HashiCorp Vault --- -@app.get("/api/v1/admin/vault/secrets") -async def api_vault_list(request: Request, path: str = "secret"): - """List Vault secrets (admin only).""" - return {"secrets": vault_list_secrets(path)} - -@app.get("/api/v1/admin/vault/secret/{path:path}") -async def api_vault_get(request: Request, path: str): - """Get a Vault secret (admin only).""" - result = vault_get_secret(path) - if result is None: - return {"error": "Secret not found or Vault unavailable"} - return {"path": path, "data": result} - -# ═══════════════════════════════════════════════════════════ -# SOSANA CRM INVESTIGATION — Backend only, detailed case data -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/investigation/sosana/summary") -async def api_sosana_summary(request: Request): - """SOSANA case overview.""" - return sosana.summary() - -@app.get("/api/v1/investigation/sosana/entities") -async def api_sosana_entities(request: Request): - """All entities: wallets, persons, organizations, tokens.""" - return sosana.entities() - -@app.get("/api/v1/investigation/sosana/wallets") -async def api_sosana_wallets(request: Request): - """Detailed wallet analysis from SOSANA investigation.""" - return {"wallets": sosana.wallets_detail(), "total": len(sosana.wallets_detail())} - -@app.get("/api/v1/investigation/sosana/financial") -async def api_sosana_financial(request: Request): - """Financial analysis — extracted funds, wallets by volume, exchange deposits.""" - return sosana.financial_detail() - -@app.get("/api/v1/investigation/sosana/evidence") -async def api_sosana_evidence(request: Request): - """Evidence summary with tier counts.""" - return sosana.evidence_summary() - -@app.get("/api/v1/investigation/sosana/persons") -async def api_sosana_persons(request: Request): - """Persons of interest.""" - return {"persons": sosana.persons()} - -@app.get("/api/v1/investigation/sosana/organizations") -async def api_sosana_organizations(request: Request): - """Organizations involved.""" - return {"organizations": sosana.organizations()} - -@app.get("/api/v1/investigation/sosana/legal") -async def api_sosana_legal(request: Request): - """Legal and prosecution framework.""" - return sosana.legal_framework() - -# ═══════════════════════════════════════════════════════════ -# MULTI-CHAIN ENRICHED WALLET ANALYSIS -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/wallet/multichain/{address}") -async def multi_chain_wallet(request: Request, address: str, chains: str = "ethereum,base,bsc,polygon,arbitrum,avalanche"): - """Multi-chain wallet analysis — checks all chains simultaneously.""" - chain_list = [c.strip() for c in chains.split(",") if c.strip()] - results = {} - total_value = 0 - for chain in chain_list: - try: - result = web3_wallet_balance(address, chain) - if "balance_eth" in result: - results[chain] = result - total_value += result.get("balance_eth", 0) - except Exception: - results[chain] = {"error": "RPC unavailable"} - - # Also check Solana - try: - solscan = FreeSolscanClient() - sol_result = solscan.account_info(address) - if sol_result: - results["solana"] = {"balance_sol": sol_result.get("lamports", 0) / 1e9, "data": sol_result} - except Exception: - results["solana"] = {"error": "Solscan unavailable"} - - return { - "address": address, - "chains_checked": len(results), - "results": results, - "total_chains_with_activity": sum(1 for r in results.values() if "error" not in r and r.get("balance_eth", r.get("balance_sol", 0)) > 0), - "analyzed_at": datetime.now(timezone.utc).isoformat() - } - -# ═══════════════════════════════════════════════════════════ -# WALLET PNL ANALYZER — Multi-chain portfolio + profit/loss -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/wallet/pnl/{address}") -async def wallet_pnl(request: Request, address: str, chain: str = "all"): - """Multi-chain wallet portfolio with PnL. Uses free Binance Web3 API.""" - try: - if chain == "all": - result = scan_all_chains(address) - return { - "address": address, - "chains": result.get("chains", {}), - "grand_total_usd": result.get("grand_total", 0), - "grand_change_24h_pct": result.get("grand_change_24h_pct", 0), - "errors": result.get("errors", []), - "source": "binance-web3 (free)", - "analyzed_at": datetime.now(timezone.utc).isoformat() - } - else: - chain_id = CHAIN_IDS.get(chain.upper(), "56") - holdings = get_wallet_holdings(address, chain_id) - from app.analyzer.portfolio import build_portfolio - portfolio = build_portfolio(holdings or []) - return { - "address": address, - "chain": chain, - "total_value_usd": portfolio.total_value, - "token_count": len(portfolio.tokens) if hasattr(portfolio, 'tokens') else len(holdings or []), - "change_24h_pct": portfolio.change_24h_pct if hasattr(portfolio, 'change_24h_pct') else 0, - "tokens": [{"symbol": t.symbol, "balance": t.balance, "value_usd": t.value_usd} for t in (portfolio.tokens if hasattr(portfolio, 'tokens') else [])][:20], - "source": "binance-web3 (free)", - "analyzed_at": datetime.now(timezone.utc).isoformat() - } - except Exception as e: - return {"address": address, "error": str(e), "source": "binance-web3"} - -# ═══════════════════════════════════════════════════════════ -# RUGCHECK — Solana token safety scanner -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/security/rugcheck/{token}") -async def rugcheck_scan(request: Request, token: str): - """Solana token rugcheck using RugCheck.xyz API (free).""" - try: - from rugcheck import rugcheck - result = rugcheck(token) - return {"token": token, "result": result, "source": "rugcheck.xyz"} - except ImportError: - return {"token": token, "error": "rugcheck package not installed", "hint": "pip install rugcheck"} - except Exception as e: - return {"token": token, "error": str(e)} - -# ═══════════════════════════════════════════════════════════ -# WHALE TRACKER — Multi-chain whale monitoring -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/whales/top") -async def whales_top(request: Request, chain: str = "ethereum", limit: int = 20): - """Top whale wallets by balance using DexScreener high-volume pairs.""" - try: - async with httpx.AsyncClient(timeout=15) as c: - r = await c.get(f"https://api.dexscreener.com/latest/dex/search?q=whale") - if r.status_code == 200: - pairs = r.json().get("pairs", []) - whales = [] - seen = set() - for p in sorted(pairs, key=lambda x: float(x.get("liquidity", {}).get("usd", 0) or 0), reverse=True)[:limit]: - addr = p.get("pairAddress", "") - if addr and addr not in seen: - seen.add(addr) - whales.append({ - "wallet": addr[:10] + "..." + addr[-6:], - "chain": p.get("chainId", chain), - "liquidity_usd": float(p.get("liquidity", {}).get("usd", 0) or 0), - "volume_24h_usd": float(p.get("volume", {}).get("h24", 0) or 0), - "token": p.get("baseToken", {}).get("symbol", "?"), - "dex": p.get("dexId", "unknown"), - }) - return {"whales": whales, "count": len(whales), "chain": chain, "source": "dexscreener"} - except Exception as e: - pass - return {"whales": [], "error": "Data temporarily unavailable"} - -# ═══════════════════════════════════════════════════════════ -# OSINT — Blockchain investigation tools index -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/osint/tools") -async def osint_tools_index(request: Request): - """Index of available OSINT blockchain investigation tools.""" - return { - "explorers": { - "ethereum": ["etherscan.io", "ethplorer.io", "blockchair.com/ethereum"], - "solana": ["solscan.io", "solana.fm", "explorer.solana.com", "xray.helius.xyz"], - "bsc": ["bscscan.com", "blockchair.com/bsc"], - "bitcoin": ["blockchain.com/explorer", "blockchair.com/bitcoin", "mempool.space"], - "polygon": ["polygonscan.com"], - "arbitrum": ["arbiscan.io"], - "avalanche": ["snowtrace.io"], - "base": ["basescan.org"], - "ton": ["tonscan.org", "tonviewer.com"], - }, - "analyzers": [ - "bubblemaps.io — token holder visualization", - "debank.com — multi-chain portfolio", - "zerion.io — wallet tracking", - "nansen.ai — smart money (paid)", - "dexcheck.ai — token analysis", - "rugcheck.xyz — Solana scam detection", - "honeypot.is — EVM honeypot detector", - "tokensniffer.com — scam scanner", - "gopluslabs.io — multi-chain security", - ], - "osint": [ - "breadcrumbs.app — fund flow tracing", - "chainabuse.com — scam reporting", - "metasleuth.io — wallet investigation", - "misttrack.io — AML compliance", - "elliptic.co — blockchain analytics", - ], - "apis": [ - "api.coingecko.com — price data (free tier)", - "api.dexscreener.com — DEX data (free)", - "api.llama.fi — DeFi TVL (free)", - "api.alternative.me/fng — Fear & Greed (free)", - "mempool.space/api — Bitcoin mempool (free)", - "solscan.io/v2 — Solana explorer (free public)", - ] - } - -# ═══════════════════════════════════════════════════════════ -# WALLET LABELING — 105K+ labeled addresses -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/wallet/label/{address}") -async def wallet_label(request: Request, address: str, chain: str = None): - """Look up a wallet address in the label database (105K+ addresses).""" - try: - import redis - r = redis.Redis(host=os.getenv('REDIS_HOST', 'dragonfly'), - port=int(os.getenv('REDIS_PORT', '6379')), - password=os.getenv('REDIS_PASSWORD', ''), - db=0, decode_responses=True) - label = lookup_wallet_label(address, chain, r) - if label: - return {"address": address, "found": True, "label": label} - return {"address": address, "found": False, "message": "No label found"} - except Exception as e: - return {"address": address, "error": str(e)} - -@app.get("/api/v1/wallet/labels/stats") -async def wallet_labels_stats(request: Request): - """Label database statistics.""" - try: - import redis - r = redis.Redis(host=os.getenv('REDIS_HOST', 'dragonfly'), - port=int(os.getenv('REDIS_PORT', '6379')), - password=os.getenv('REDIS_PASSWORD', ''), - db=0, decode_responses=True) - counts = r.get("rmi:labels:counts") - if counts: - return {"stats": json.loads(counts)} - return {"error": "Labels not yet loaded. Run startup loader."} - except Exception as e: - return {"error": str(e)} - -@app.get("/api/v1/wallet/labels/ofac/{address}") -async def wallet_ofac_check(request: Request, address: str): - """Check if an address is on the OFAC sanctions list.""" - try: - import redis - r = redis.Redis(host=os.getenv('REDIS_HOST', 'dragonfly'), - port=int(os.getenv('REDIS_PORT', '6379')), - password=os.getenv('REDIS_PASSWORD', ''), - db=0, decode_responses=True) - label = lookup_wallet_label(address, None, r) - if label and label.get("is_sanctioned"): - return {"address": address, "sanctioned": True, "details": label} - return {"address": address, "sanctioned": False} - except Exception as e: - return {"address": address, "error": str(e)} - - -# ═══════════════════════════════════════════════════════════ -# DEXSCREENER PARTNER INTEGRATION -# ═══════════════════════════════════════════════════════════ - -@app.get("/api/v1/dexscreener/security/{chain}/{address}") -async def dexscreener_security(chain: str, address: str): - """Partner endpoint for DexScreener token security badges.""" - from app.token_scanner import scan_token as unified_scan - - try: - scan = await unified_scan(address, chain, tier="free") - except Exception as e: - return { - "chain": chain, "address": address, - "error": str(e), "safety_score": 50, - } - - # ScanResult is a dataclass, access fields directly - flag_list = [] - for f in (scan.risk_flags or [])[:5]: - if isinstance(f, dict): - flag_list.append({ - "type": f.get("flag") or f.get("name", "unknown"), - "severity": f.get("severity", "medium"), - }) - else: - flag_list.append({"type": str(f), "severity": "medium"}) - - return { - "chain": chain, "address": address, - "safety_score": scan.safety_score, - "token_name": scan.name, - "symbol": scan.symbol, - "flags": flag_list, "flag_count": len(flag_list), - "provider": "rugmunch.io", - "provider_url": "https://rugmunch.io", - "scanned_at": scan.scanned_at if hasattr(scan, 'scanned_at') else datetime.now(timezone.utc).isoformat(), - } - - -# ═══════════════════════════════════════════════════════════ -# TRANSACTION SIMULATION -# ═══════════════════════════════════════════════════════════ - -@app.post("/api/v1/simulate/transaction") -async def simulate_transaction_endpoint(data: dict): - """ - Pre-flight transaction simulation. - - Simulates a sell transaction to detect honeypots, excessive taxes, - and reverts BEFORE the user signs. No gas spent. - - Body: {"token_address": "...", "chain": "solana"} - Returns: {can_sell, can_buy, sell_tax_pct, risk, warnings, ...} - """ - from app.tx_simulator import simulate_transaction - - token = data.get("token_address", data.get("address", "")) - chain = data.get("chain", "solana") - - if not token: - raise HTTPException(status_code=400, detail="token_address required") - - result = await simulate_transaction(token, chain) - - return { - "token_address": result.token_address, - "chain": result.chain, - "can_sell": result.can_sell, - "can_buy": result.can_buy, - "is_honeypot": result.is_honeypot, - "sell_tax_pct": round(result.sell_tax_pct, 2), - "buy_tax_pct": round(result.buy_tax_pct, 2), - "expected_output": result.expected_output, - "expected_output_token": result.expected_output_token, - "risk": result.risk, - "warnings": result.warnings, - "simulated_at": datetime.now(timezone.utc).isoformat(), - } - - -# ═══════════════════════════════════════════════════════════ -# WEBSOCKET REAL-TIME STREAMS -# ═══════════════════════════════════════════════════════════ - -from fastapi import WebSocket, WebSocketDisconnect -import redis.asyncio as aioredis - -# Connected WebSocket clients -_ws_clients: dict = {"scans": set(), "alerts": set(), "embedding": set()} - - -@app.websocket("/ws/v1/stream/scans") -async def ws_stream_scans(websocket: WebSocket): - """Real-time token scan results stream. - - Connect to receive live scan results as they happen. - Each message: {"type": "scan", "token": "...", "chain": "...", "safety_score": 85, ...} - """ - await websocket.accept() - _ws_clients["scans"].add(websocket) - try: - # Send welcome - await websocket.send_json({ - "type": "connected", - "stream": "scans", - "message": "Receiving live token scans...", - "connected_at": datetime.now(timezone.utc).isoformat(), - }) - # Subscribe to Redis pub/sub for scan events - redis_host = os.getenv("REDIS_HOST", "rmi-redis") - redis_port = int(os.getenv("REDIS_PORT", "6379")) - redis_pass = os.getenv("REDIS_PASSWORD", "") - r = await aioredis.from_url( - f"redis://{redis_host}:{redis_port}", - password=redis_pass or None, - decode_responses=True, - ) - pubsub = r.pubsub() - await pubsub.subscribe("rmi:ws:scans") - - async for message in pubsub.listen(): - if message["type"] == "message": - try: - data = json.loads(message["data"]) - await websocket.send_json(data) - except json.JSONDecodeError: - pass - - # Check for client disconnect - try: - await asyncio.wait_for(websocket.receive_text(), timeout=0.01) - except asyncio.TimeoutError: - pass - except (WebSocketDisconnect, Exception): - pass - finally: - _ws_clients["scans"].discard(websocket) - try: - await pubsub.unsubscribe("rmi:ws:scans") - await r.close() - except Exception: - pass - - -@app.websocket("/ws/v1/stream/alerts") -async def ws_stream_alerts(websocket: WebSocket): - """Real-time scam/honeypot alert stream. - - Receive instant alerts for: honeypots detected, rug pulls, flash loans, - high-risk tokens, whale movements. - """ - await websocket.accept() - _ws_clients["alerts"].add(websocket) - try: - await websocket.send_json({ - "type": "connected", - "stream": "alerts", - "message": "Listening for real-time security alerts...", - "connected_at": datetime.now(timezone.utc).isoformat(), - }) - redis_host = os.getenv("REDIS_HOST", "rmi-redis") - redis_pass = os.getenv("REDIS_PASSWORD", "") - r = await aioredis.from_url( - f"redis://{redis_host}:{os.getenv('REDIS_PORT','6379')}", - password=redis_pass or None, - decode_responses=True, - ) - pubsub = r.pubsub() - await pubsub.subscribe("rmi:ws:alerts") - - async for message in pubsub.listen(): - if message["type"] == "message": - try: - data = json.loads(message["data"]) - await websocket.send_json(data) - except json.JSONDecodeError: - pass - try: - await asyncio.wait_for(websocket.receive_text(), timeout=0.01) - except asyncio.TimeoutError: - pass - except (WebSocketDisconnect, Exception): - pass - finally: - _ws_clients["alerts"].discard(websocket) - try: - await pubsub.unsubscribe("rmi:ws:alerts") - await r.close() - except Exception: - pass - - -@app.websocket("/ws/v1/stream/embedding") -async def ws_stream_embedding(websocket: WebSocket): - """Live embedding provider health + usage stats stream.""" - await websocket.accept() - _ws_clients["embedding"].add(websocket) - try: - import asyncio as aio - while True: - try: - from app.provider_health import provider_health - health = await provider_health() - await websocket.send_json({"type": "health", "data": health}) - await aio.sleep(30) # Push every 30 seconds - # Check for client pings - try: - msg = await aio.wait_for(websocket.receive_text(), timeout=1) - except aio.TimeoutError: - pass - except WebSocketDisconnect: - break - except Exception: - await aio.sleep(5) - finally: - _ws_clients["embedding"].discard(websocket) - - -async def ws_broadcast_embedding_health(): - """Push embedding health to all connected clients.""" - import asyncio as aio - dead = set() - for ws in list(_ws_clients.get("embedding", set())): - try: - from app.provider_health import provider_health - health = await provider_health() - await ws.send_json({"type": "health", "data": health}) - except Exception: - dead.add(ws) - for ws in dead: - _ws_clients["embedding"].discard(ws) - - -# Helper to broadcast scan results to WebSocket clients -# Uses connection-pooled WsClientManager (caching shield) — no per-broadcast Redis connection -async def ws_broadcast_scan(scan_data: dict): - """Publish scan result to Redis for WebSocket streaming (pooled connection).""" - try: - from app.caching_shield.ws_broadcaster import get_ws_manager - await get_ws_manager().broadcast_scan(scan_data) - except Exception: - pass - - -async def ws_broadcast_alert(alert_data: dict): - """Publish security alert to Redis for WebSocket streaming + persist in sorted set (pooled connection).""" - try: - from app.caching_shield.ws_broadcaster import get_ws_manager - await get_ws_manager().broadcast_alert(alert_data) - except Exception: - pass - - -@app.get("/rundown", response_class=HTMLResponse) -async def rundown_page(): - with open("/app/static/rundown.html") as f: - return f.read() - - -@app.get("/news", response_class=HTMLResponse) -async def news_page(): - with open("/app/static/news.html") as f: - return f.read() - -@app.get("/rugcharts", response_class=HTMLResponse) -async def rugcharts_page(): - with open("/app/static/rugcharts.html") as f: - return f.read() - -@app.get("/api/v1/pricing") -async def pricing_endpoint(): - """Public pricing endpoint — consumed by /pricing page.""" - return { - "tiers": { - "STARTER": { - "name": "Starter", "price_monthly": 29.99, "price_six_month": 161.95, "price_yearly": 287.90, - "scans_per_month": 50, "api_calls_per_day": 100, - "features": ["50 scans/day", "Full risk analysis", "Mint/freeze authority", "Honeypot detection", "LP verification"] - }, - "PRO": { - "name": "Pro", "price_monthly": 49.99, "price_six_month": 269.95, "price_yearly": 479.90, - "scans_per_month": 200, "api_calls_per_day": 500, - "features": ["200 scans/day", "All Starter features", "Bundle detection", "MEV analysis", "Whale tracking", "Dev reputation"] - }, - "ELITE": { - "name": "Elite", "price_monthly": 89.99, "price_six_month": 485.95, "price_yearly": 863.90, - "scans_per_month": 1000, "api_calls_per_day": 2000, - "features": ["1000 scans/day", "All Pro features", "Full API access", "Priority support", "SENTINEL deep modules"] - }, - "ENTERPRISE": { - "name": "Enterprise", "price_monthly": "Custom", "price_six_month": "Custom", "price_yearly": "Custom", - "scans_per_month": "Unlimited", "api_calls_per_day": "Unlimited", - "features": ["Unlimited API access", "Dedicated support", "Custom integrations", "SLA guarantee"] - } - }, - "discounts": {"six_month": 0.10, "yearly": 0.20}, - "crypto_chains": [ - {"id": "solana", "name": "Solana", "symbol": "SOL"}, - {"id": "ethereum", "name": "Ethereum", "symbol": "ETH"}, - {"id": "base", "name": "Base", "symbol": "ETH"}, - {"id": "bsc", "name": "BNB Chain", "symbol": "BNB"}, - {"id": "polygon", "name": "Polygon", "symbol": "MATIC"}, - {"id": "arbitrum", "name": "Arbitrum", "symbol": "ETH"}, - {"id": "tron", "name": "Tron", "symbol": "TRX"}, - {"id": "bitcoin", "name": "Bitcoin", "symbol": "BTC"}, - ], - "enterprise_contact": "biz@rugmunch.io", - "holder_discount": {"discount": "50%", "duration": "3 months", "eligibility": "$CRM / $CRYPTORUGMUNCH holders", "verify": "v2 airdrop checker"}, - "bot_packs": {"description": "Prepaid API packs for AI agents and trading bots. No expiry. LangGraph/Claude/ChatGPT compatible.","packs": {"bot_scout": {"calls":200,"price":9.99},"bot_hunter": {"calls":1500,"price":49.99},"bot_whale": {"calls":10000,"price":199.99},"bot_api_plan": {"calls":50000,"price":499.00}},"setup_docs": "https://rugmunch.io/tools/docs"},"trial_limits": {"free_per_tool":3,"total_free_per_device":15,"cooldown":"72h","note":"Strictly enforced. No workarounds."}, - } - -@app.get("/rugmaps", response_class=HTMLResponse) -async def rugmaps_page(): - with open("/app/static/rugmaps.html") as f: - return f.read() - -# ── MCP / x402 Discovery Endpoints (direct fallback — always works) ── -@app.get("/.well-known/x402") -async def x402_discovery_direct(): - """x402 payment discovery — MCP clients use this to find payable tools.""" - try: - from app.routers.x402_enforcement import _build_discovery_response - return _build_discovery_response() - except Exception as e: - return {"error": "discovery unavailable", "detail": str(e), "retry_in": 60} - -@app.get("/.well-known/mcp") -@app.get("/.well-known/mcp.json") -async def mcp_discovery_direct(): - """MCP server discovery — AI agents use this to find available tools.""" - return { - "name": "Rug Munch Intelligence MCP Server", - "description": "234 crypto security tools across 13 blockchains. Rug pull detection, honeypot checks, wallet intelligence, AI investigation reports.", - "version": "2.1.0", - "vendor": "Rug Munch Intelligence", - "homepage": "https://rugmunch.io", - "entrypoint": "https://mcp.rugmunch.io/mcp", - "pricing": "https://rugmunch.io/pricing", - "docs": "https://rugmunch.io/tools/docs", - "chains": ["solana", "ethereum", "base", "bsc", "polygon", "arbitrum", "optimism", "avalanche", "fantom", "gnosis", "tron", "bitcoin"], - "tools_count": 234, - "payment": {"x402_supported": True, "chains": ["solana", "ethereum", "base", "bsc", "polygon", "arbitrum", "optimism"], "discovery_url": "https://mcp.rugmunch.io/.well-known/x402"}, - } - -@app.get("/mcp/manifest") -async def mcp_manifest_direct(): - """Full MCP tool manifest.""" - try: - from app.routers.x402_catalog import get_catalog - catalog = await get_catalog() - return {"manifest": catalog, "entrypoint": "https://mcp.rugmunch.io", "updated": "2026-06-04"} - except Exception: - return {"manifest": {}, "entrypoint": "https://mcp.rugmunch.io", "status": "loading"}