rmi-backend/app/domains/intelligence/alert_pipeline.py
cryptorugmunch 0a8c73d99b feat(domains): consolidate bulletin, intelligence, markets, admin, referral, mcp + mypy gate
- 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.
2026-07-07 16:43:49 +07:00

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")