- Make app/domains/auth/ and app/core/redis.py mypy-clean under strict. - Add mypy-gate.ini and Makefile mypy-gate target; promote typecheck-gate in CI. - Consolidate domains into app/domains/: bulletin, admin, intelligence, markets. - Extract referral domain incl. DeFi partner DEX ref links; keep Telegram bot wired. - Move app/mcp/ package and app/api/v1/mcp/router into app/domains/mcp/. - Archive dead app/mcp_router.py.
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""
|
|
RMI Alert Pipeline - Real-time threat intelligence from scanners.
|
|
=================================================================
|
|
Feeds the Live Intel panel, WebSocket streams, and alert endpoints.
|
|
|
|
Data sources:
|
|
- SENTINEL scanner (risk scores, scam detection)
|
|
- GoPlus security API (honeypot, tax, proxy checks)
|
|
- DexScreener new pairs (fresh launches to scan)
|
|
- Our own RAG scam patterns
|
|
- Wallet label cross-references
|
|
|
|
Alert flow:
|
|
Scanner → alert_pipeline.push_alert() → Redis sorted set + pub/sub
|
|
Homepage reads from /api/v1/alerts/recent
|
|
Sidebar reads from /api/v1/alerts/count
|
|
WebSocket streams to connected clients
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from datetime import UTC, datetime
|
|
|
|
logger = logging.getLogger("alert_pipeline")
|
|
|
|
REDIS_HOST = os.getenv("REDIS_HOST", "rmi-redis")
|
|
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
|
REDIS_PW = os.getenv("REDIS_PASSWORD", "")
|
|
|
|
ALERT_KEY = "rmi:alerts:recent"
|
|
ALERT_COUNT_KEY = "rmi:alerts:count:active"
|
|
ALERT_MAX = 500 # Keep last 500 alerts
|
|
|
|
|
|
async def _get_redis():
|
|
"""Get async Redis connection."""
|
|
import redis.asyncio as aioredis
|
|
|
|
return aioredis.Redis(
|
|
host=REDIS_HOST,
|
|
port=REDIS_PORT,
|
|
password=REDIS_PW or None,
|
|
decode_responses=True,
|
|
)
|
|
|
|
|
|
async def get_active_alert_count() -> int:
|
|
"""Get count of active (unacknowledged) alerts."""
|
|
try:
|
|
r = await _get_redis()
|
|
count = await r.zcard(ALERT_KEY)
|
|
await r.close()
|
|
return count
|
|
except Exception as e:
|
|
logger.debug(f"Alert count error: {e}")
|
|
return 0
|
|
|
|
|
|
async def push_alert(
|
|
alert_type: str,
|
|
title: str,
|
|
description: str = "",
|
|
severity: str = "high",
|
|
chain: str = "unknown",
|
|
token: str = "",
|
|
token_symbol: str = "",
|
|
wallet: str = "",
|
|
risk_score: int = 0,
|
|
metadata: dict | None = None,
|
|
) -> str:
|
|
"""
|
|
Push a new alert to the pipeline.
|
|
Returns alert_id.
|
|
"""
|
|
alert_id = f"alt_{int(time.time())}_{os.urandom(4).hex()}"
|
|
|
|
alert = {
|
|
"id": alert_id,
|
|
"type": alert_type,
|
|
"title": title,
|
|
"description": description,
|
|
"severity": severity,
|
|
"chain": chain,
|
|
"token": token,
|
|
"token_symbol": token_symbol,
|
|
"wallet": wallet,
|
|
"risk_score": risk_score,
|
|
"acknowledged": False,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
**(metadata or {}),
|
|
}
|
|
|
|
try:
|
|
r = await _get_redis()
|
|
score = time.time()
|
|
await r.zadd(ALERT_KEY, {json.dumps(alert): score})
|
|
# Trim old alerts
|
|
await r.zremrangebyrank(ALERT_KEY, 0, -(ALERT_MAX + 1))
|
|
# Pub/sub for WebSocket streaming
|
|
await r.publish("rmi:ws:alerts", json.dumps(alert))
|
|
await r.close()
|
|
logger.info(f"Alert pushed: {alert_type} | {title[:60]}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to push alert: {e}")
|
|
|
|
return alert_id
|
|
|
|
|
|
async def get_recent_alerts(limit: int = 20, severity: str = "") -> list[dict]:
|
|
"""Get recent alerts with optional severity filter. Normalizes old/new formats."""
|
|
try:
|
|
r = await _get_redis()
|
|
raw = await r.zrevrange(ALERT_KEY, 0, limit * 2 - 1)
|
|
await r.close()
|
|
|
|
alerts = []
|
|
for entry in raw:
|
|
try:
|
|
alert = json.loads(entry)
|
|
|
|
# Normalize old alert format to new
|
|
if "title" not in alert:
|
|
alert["title"] = alert.get("message", alert.get("event", "Unknown alert"))
|
|
if "description" not in alert:
|
|
desc_parts = []
|
|
if alert.get("symbol"):
|
|
desc_parts.append(f"Token: {alert['symbol']}")
|
|
if alert.get("risk_score"):
|
|
desc_parts.append(f"Risk: {alert['risk_score']}/100")
|
|
flags = alert.get("risk_flags", [])
|
|
if flags:
|
|
desc_parts.append("; ".join(str(f)[:80] for f in flags[:2]))
|
|
alert["description"] = " | ".join(desc_parts)
|
|
if "severity" not in alert:
|
|
score = alert.get("risk_score", 50)
|
|
alert["severity"] = "critical" if score >= 85 else "high" if score >= 65 else "medium"
|
|
if "chain" not in alert:
|
|
alert["chain"] = alert.get("chain", "unknown")
|
|
|
|
if severity and alert.get("severity") != severity:
|
|
continue
|
|
alerts.append(alert)
|
|
if len(alerts) >= limit:
|
|
break
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
return alerts
|
|
except Exception as e:
|
|
logger.error(f"get_recent_alerts error: {e}")
|
|
return []
|
|
|
|
|
|
# ── Alert generators - called by cron jobs or on-demand ──────────
|
|
|
|
|
|
async def scan_solana_new_pairs(limit: int = 5) -> int:
|
|
"""
|
|
Scan latest Solana pairs from DexScreener for scam patterns.
|
|
Pushes alerts for high-risk tokens.
|
|
Returns number of alerts generated.
|
|
"""
|
|
import httpx
|
|
|
|
pushed = 0
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.get("https://api.dexscreener.com/latest/dex/search", params={"q": "SOL USDC"})
|
|
if resp.status_code != 200:
|
|
return 0
|
|
|
|
data = resp.json()
|
|
pairs = data.get("pairs", [])[:limit]
|
|
|
|
for pair in pairs:
|
|
token_addr = pair.get("baseToken", {}).get("address", "")
|
|
token_name = pair.get("baseToken", {}).get("name", "Unknown")
|
|
token_symbol = pair.get("baseToken", {}).get("symbol", "???")
|
|
liquidity = float(pair.get("liquidity", {}).get("usd", 0) or 0)
|
|
volume = float(pair.get("volume", {}).get("h24", 0) or 0)
|
|
price_change = float(pair.get("priceChange", {}).get("h24", 0) or 0)
|
|
created_at = pair.get("pairCreatedAt", 0)
|
|
age_hours = (time.time() - created_at / 1000) / 3600 if created_at else 999
|
|
|
|
# Risk heuristics
|
|
risk_flags = []
|
|
|
|
if liquidity < 1000:
|
|
risk_flags.append("low_liquidity")
|
|
if volume == 0:
|
|
risk_flags.append("no_volume")
|
|
if price_change < -80:
|
|
risk_flags.append(f"crashed_{abs(price_change):.0f}%")
|
|
if age_hours < 1 and liquidity < 5000:
|
|
risk_flags.append("fresh_launch_low_liq")
|
|
if price_change > 500:
|
|
risk_flags.append(f"pumped_{price_change:.0f}%")
|
|
|
|
if risk_flags:
|
|
await push_alert(
|
|
alert_type="new_pair_risk",
|
|
title=f"{token_symbol}: {' | '.join(risk_flags[:2])}",
|
|
description=f"New pair {token_name} ({token_symbol}) on Solana. "
|
|
f"Liquidity: ${liquidity:,.0f}, Age: {age_hours:.1f}h, "
|
|
f"24h change: {price_change:+.1f}%",
|
|
severity="critical" if len(risk_flags) >= 3 else "high",
|
|
chain="solana",
|
|
token=token_addr,
|
|
token_symbol=token_symbol,
|
|
risk_score=min(90, len(risk_flags) * 25),
|
|
metadata={
|
|
"risk_flags": risk_flags,
|
|
"liquidity_usd": liquidity,
|
|
"age_hours": age_hours,
|
|
},
|
|
)
|
|
pushed += 1
|
|
|
|
return pushed
|
|
except Exception as e:
|
|
logger.warning(f"Solana scan error: {e}")
|
|
return pushed
|
|
|
|
|
|
async def scan_known_scams(limit: int = 3) -> int:
|
|
"""
|
|
Check our RAG known_scams collection for recently added entries.
|
|
Pushes alerts for new scam patterns detected.
|
|
"""
|
|
pushed = 0
|
|
try:
|
|
r = await _get_redis()
|
|
# Check for recent scam pattern additions
|
|
scam_ids = await r.smembers("rag:idx:known_scams")
|
|
|
|
recent_count = 0
|
|
for sid in list(scam_ids)[:20]:
|
|
doc = await r.get(f"rag:known_scams:{sid}")
|
|
if doc:
|
|
try:
|
|
data = json.loads(doc)
|
|
added = data.get("metadata", {}).get("added_at", "")
|
|
if added:
|
|
age_h = (time.time() - datetime.fromisoformat(added.replace("Z", "+00:00")).timestamp()) / 3600
|
|
if age_h < 24:
|
|
recent_count += 1
|
|
except Exception:
|
|
pass
|
|
|
|
await r.close()
|
|
|
|
if recent_count > 0:
|
|
await push_alert(
|
|
alert_type="scam_pattern_update",
|
|
title=f"{recent_count} new scam patterns detected in last 24h",
|
|
description="New rug pull, honeypot, or phishing patterns added to the knowledge base.",
|
|
severity="high",
|
|
chain="multi",
|
|
risk_score=85,
|
|
)
|
|
pushed += 1
|
|
|
|
return pushed
|
|
except Exception as e:
|
|
logger.warning(f"Known scams scan error: {e}")
|
|
return pushed
|
|
|
|
|
|
async def run_alert_scan() -> dict[str, int]:
|
|
"""
|
|
Run a full alert scan across all sources.
|
|
Called by cron job every 15 minutes.
|
|
"""
|
|
results = {}
|
|
|
|
# Scan Solana new pairs
|
|
results["solana_pairs"] = await scan_solana_new_pairs(limit=8)
|
|
|
|
# Scan known scams
|
|
results["known_scams"] = await scan_known_scams()
|
|
|
|
total = sum(results.values())
|
|
logger.info(f"Alert scan complete: {total} new alerts ({results})")
|
|
return results
|
|
|
|
|
|
# ── Seed some initial alerts if Redis is empty ────────────────────
|
|
|
|
|
|
async def seed_initial_alerts():
|
|
"""Seed baseline alerts so the system isn't empty on first run."""
|
|
r = await _get_redis()
|
|
existing = await r.zcard(ALERT_KEY)
|
|
await r.close()
|
|
|
|
if existing > 0:
|
|
return # Already has alerts
|
|
|
|
seeds = [
|
|
(
|
|
"honeypot",
|
|
"Honeypot detected on Base: 0xdead...",
|
|
"Token has sell restrictions and blacklist. Buyers cannot exit.",
|
|
"critical",
|
|
"base",
|
|
),
|
|
(
|
|
"whale",
|
|
"Whale moved 5M USDC to Binance",
|
|
"Wallet 0xABCD... transferred $5M USDC to Binance hot wallet. Possible sell pressure.",
|
|
"high",
|
|
"ethereum",
|
|
),
|
|
(
|
|
"rugpull",
|
|
"Liquidity removed from $SCAM token",
|
|
"100% of liquidity pool withdrawn by deployer. Token is now worthless.",
|
|
"critical",
|
|
"solana",
|
|
),
|
|
(
|
|
"bundler",
|
|
"Sniper bundle detected: $NEWLAUNCH",
|
|
"Coordinated wallet cluster bought 60% of supply in first block.",
|
|
"high",
|
|
"solana",
|
|
),
|
|
(
|
|
"contract",
|
|
"Unverified proxy contract found",
|
|
"Token uses upgradeable proxy with unverified implementation. Owner can change logic.",
|
|
"high",
|
|
"base",
|
|
),
|
|
(
|
|
"concentration",
|
|
"90% supply held by 3 wallets on $DANGER",
|
|
"Extreme holder concentration. Classic rug pull setup.",
|
|
"critical",
|
|
"ethereum",
|
|
),
|
|
(
|
|
"wash_trade",
|
|
"Wash trading detected on $FAKEVOL",
|
|
"95% of volume is self-trading between linked wallets.",
|
|
"high",
|
|
"bsc",
|
|
),
|
|
(
|
|
"phishing",
|
|
"Fake airdrop targeting $BONK holders",
|
|
"Phishing site detected posing as official BONK airdrop. Users losing funds.",
|
|
"critical",
|
|
"solana",
|
|
),
|
|
]
|
|
|
|
for alert_type, title, desc, severity, chain in seeds:
|
|
await push_alert(
|
|
alert_type=alert_type,
|
|
title=title,
|
|
description=desc,
|
|
severity=severity,
|
|
chain=chain,
|
|
)
|
|
|
|
logger.info(f"Seeded {len(seeds)} initial alerts")
|