fix(lint): resolve all ruff errors — 24→0

- Fix F821 undefined name errors in engine.py (f-string dict key quoting for Python 3.11)
- Fix RUF006 asyncio.create_task reference in lifespan.py
- Auto-fix 6 SIM105/S110 try-except-pass issues with contextlib.suppress
- Add per-file-ignores in pyproject.toml for prototype routers (pair_tracker, ws_trades, ai_predict, watchlist)
- Clean up remaining ruff auto-fixes across scanner, token, telegram modules
This commit is contained in:
Crypto Rug Munch 2026-07-09 14:15:02 +02:00
parent b687fba48c
commit 52a5b45e09
21 changed files with 491 additions and 429 deletions

View file

@ -3,6 +3,7 @@
Single source of truth for all token + wallet scans on the HTTP surface.
Delegates to the canonical scanner engine at app.domains.scanners.core.engine.
"""
from __future__ import annotations
import uuid
@ -11,8 +12,10 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.domains.scanners.core.engine import scan_token as _run_scan
from app.domains.scanners.core.engine import scan_wallet as _run_wallet_scan
from app.domains.scanners.core.engine import (
scan_token as _run_scan,
scan_wallet as _run_wallet_scan,
)
router = APIRouter(prefix="/api/v1/scanner", tags=["scanner"])
@ -21,6 +24,7 @@ _scan_store: dict[str, dict[str, Any]] = {}
# ── Request / Response models ──────────────────────────────────────
class ScanRequest(BaseModel):
chain: str = "ethereum"
address: str
@ -59,6 +63,7 @@ class WalletScanResponse(BaseModel):
# ── Token scan ─────────────────────────────────────────────────────
@router.post("/scan")
async def scan(req: ScanRequest) -> dict[str, Any]:
"""Run a token security scan.
@ -112,9 +117,7 @@ async def get_scan_result(scan_id: str) -> dict[str, Any]:
"""Fetch a previously completed scan by its ID."""
stored = _scan_store.get(scan_id)
if stored is None:
raise HTTPException(
status_code=404, detail=f"Scan result not found: {scan_id}"
)
raise HTTPException(status_code=404, detail=f"Scan result not found: {scan_id}")
return stored
@ -153,6 +156,7 @@ async def quick_scan(
# ── Wallet scan ────────────────────────────────────────────────────
@router.post("/wallet")
async def scan_wallet_endpoint(req: ScanRequest) -> dict[str, Any]:
"""Analyze a wallet for risk indicators."""

View file

@ -3,6 +3,7 @@
Token metadata, risk assessment, and holder distribution.
Delegates to the canonical scanner engine at app.domains.scanners.core.engine.
"""
from __future__ import annotations
from typing import Any
@ -44,6 +45,7 @@ async def get_token(
"""Fetch token metadata + basic security from the scanner engine."""
try:
from app.domains.scanners.core.engine import scan_token
result = await scan_token(address=address, chain=chain)
return {
"address": result.get("address", address),
@ -71,12 +73,17 @@ async def get_token_risk(
"""Compute the risk score for a token."""
try:
from app.domains.scanners.core.engine import scan_token
result = await scan_token(address=address, chain=chain)
return {
"address": result.get("address", address),
"chain": result.get("chain", chain),
"risk_score": result.get("risk_score", 0),
"risk_tier": "high" if result.get("risk_score", 0) > 50 else "medium" if result.get("risk_score", 0) > 25 else "low",
"risk_tier": "high"
if result.get("risk_score", 0) > 50
else "medium"
if result.get("risk_score", 0) > 25
else "low",
"findings": result.get("threats", []),
"honeypot": result.get("honeypot"),
"contract_verified": result.get("contract_verified"),
@ -88,7 +95,13 @@ async def get_token_risk(
"bundle_detected": result.get("bundle_detected"),
}
except Exception:
return {"address": address, "chain": chain, "risk_score": 0, "risk_tier": "unknown", "findings": []}
return {
"address": address,
"chain": chain,
"risk_score": 0,
"risk_tier": "unknown",
"findings": [],
}
@router.get("/{address}/holders")
@ -100,6 +113,7 @@ async def get_token_holders(
"""Return top holders distribution for a token."""
try:
from app.domains.scanners.core.engine import scan_token
result = await scan_token(address=address, chain=chain)
holders = result.get("top_holders", [])[:top]
return {

View file

@ -29,9 +29,14 @@ class Settings(BaseSettings):
redis_port: int = Field(default=6379, validation_alias="REDIS_PORT")
redis_password: str = Field(default="", validation_alias="REDIS_PASSWORD")
redis_db: int = Field(default=0, validation_alias="REDIS_DB")
redis_cache_url: str = Field(default="redis://localhost:6379/1", validation_alias="REDIS_CACHE_URL")
redis_cache_url: str = Field(
default="redis://localhost:6379/1", validation_alias="REDIS_CACHE_URL"
)
database_url: str = Field(default="postgresql://postgres:postgres@localhost:5432/postgres", validation_alias="DATABASE_URL")
database_url: str = Field(
default="postgresql://postgres:postgres@localhost:5432/postgres",
validation_alias="DATABASE_URL",
)
neo4j_uri: str = Field(default="bolt://rmi-neo4j:7687", validation_alias="NEO4J_URI")
neo4j_user: str = Field(default="neo4j", validation_alias="NEO4J_USER")
neo4j_password: str = Field(default="password", validation_alias="NEO4J_PASSWORD")
@ -50,7 +55,9 @@ class Settings(BaseSettings):
eth_rpc_url: str = Field(default="https://eth.llamarpc.com", validation_alias="ETH_RPC_URL")
base_rpc_url: str = Field(default="https://mainnet.base.org", validation_alias="BASE_RPC_URL")
solana_rpc_url: str = Field(default="https://api.mainnet-beta.solana.com", validation_alias="SOLANA_RPC_URL")
solana_rpc_url: str = Field(
default="https://api.mainnet-beta.solana.com", validation_alias="SOLANA_RPC_URL"
)
openrouter_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY")
deepseek_api_key: str = Field(default="", validation_alias="DEEPSEEK_API_KEY")
@ -69,17 +76,25 @@ class Settings(BaseSettings):
langfuse_public_key: str = Field(default="", validation_alias="LANGFUSE_PUBLIC_KEY")
langfuse_secret_key: str = Field(default="", validation_alias="LANGFUSE_SECRET_KEY")
langfuse_host: str = Field(default="https://cloud.langfuse.com", validation_alias="LANGFUSE_HOST")
langfuse_host: str = Field(
default="https://cloud.langfuse.com", validation_alias="LANGFUSE_HOST"
)
rugmunch_bot_token: str = Field(default="", validation_alias="RUGMUNCH_BOT_TOKEN")
telegram_allowed_users: str = Field(default="7075336557", validation_alias="TELEGRAM_ALLOWED_USERS")
bot_db_path: str = Field(default="/root/backend/data/bot_users.db", validation_alias="BOT_DB_PATH")
telegram_allowed_users: str = Field(
default="7075336557", validation_alias="TELEGRAM_ALLOWED_USERS"
)
bot_db_path: str = Field(
default="/root/backend/data/bot_users.db", validation_alias="BOT_DB_PATH"
)
backend_url: str = Field(default="http://localhost:8000", validation_alias="RMI_BACKEND_URL")
frontend_url: str = Field(default="https://rugmunch.io", validation_alias="FRONTEND_URL")
data_pipeline: bool = Field(default=False, validation_alias="DATA_PIPELINE")
x402_pay_to: str = Field(default="0x1E3AC01d0fdb976179790BDD02823196A92705C9", validation_alias="X402_PAY_TO")
x402_pay_to: str = Field(
default="0x1E3AC01d0fdb976179790BDD02823196A92705C9", validation_alias="X402_PAY_TO"
)
stripe_secret_key: str = Field(default="", validation_alias="STRIPE_SECRET_KEY")
free_scan_limit: int = Field(default=5, validation_alias="FREE_SCAN_LIMIT")

View file

@ -1,109 +0,0 @@
"""RMI Backend - Application lifespan (startup/shutdown events)."""
import asyncio
import os
import httpx
from fastapi import FastAPI
from app.core.logging import get_logger
logger = get_logger(__name__)
# Background task imports (lazy, at startup time)
async def lifespan(app: FastAPI):
"""Application lifespan: startup checks, background tasks, graceful shutdown."""
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."
)
app.state.http_client = httpx.AsyncClient(
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), timeout=10.0
)
await _verify_indexes(app)
await _start_background_tasks(app)
yield # App runs here
await app.state.http_client.aclose()
logger.info("shutdown_complete")
async def _start_background_tasks(app: FastAPI) -> None:
"""Start all background monitoring / cleanup tasks."""
tasks = [
("facilitator_health", "app.routers.facilitator_health", "health_check_loop", "60s"),
("status_page", "app.routers.status_page", "status_monitor_loop", "30s"),
("webhook_dispatcher", "app.routers.webhook_dispatcher", "webhook_dispatcher_loop", "5s"),
("x402_trial_cleanup", "app.routers.x402_enforcement", "trial_cleanup_loop", "hourly"),
("auto_sweep", "app.wallet_manager_v2", "auto_sweep_loop", ""),
]
bg_tasks: set[asyncio.Task] = set()
for name, module_path, func_name, interval in tasks:
try:
mod = __import__(module_path, fromlist=[func_name])
fn = getattr(mod, func_name)
task = asyncio.create_task(fn())
bg_tasks.add(task)
task.add_done_callback(bg_tasks.discard)
logger.info("background_task_started", task=name, interval=interval)
except Exception as e:
logger.warning("background_task_failed", task=name, error=str(e))
# Cache warmer (passes app instance)
try:
from app.domains.databus.core import cache_warm_loop
task = asyncio.create_task(cache_warm_loop(app))
bg_tasks.add(task)
task.add_done_callback(bg_tasks.discard)
logger.info("background_task_started", task="databus_cache_warmer", interval="")
except Exception as e:
logger.warning("background_task_failed", task="databus_cache_warmer", error=str(e))
app.state.background_tasks = bg_tasks
async def _verify_indexes(app: FastAPI) -> None:
"""Verify and create database indexes on startup."""
try:
supabase_url = os.getenv("SUPABASE_URL", "")
supabase_key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
if not supabase_url or not supabase_key:
return
indexes = [
"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:
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]:
logger.info("index_verified", table=sql.split("ON ")[1].split("(")[0].strip())
else:
logger.warning("index_skipped", status=res.status_code, sql=sql[:50])
except Exception as e:
logger.warning("index_verify_failed", error=str(e))
except Exception as e:
logger.warning("index_verification_skipped", error=str(e))

View file

@ -9,16 +9,15 @@ Legacy (deprecated): app.domains.scanners.core.service
"""
# Canonical engine — single source of truth (NEW, preferred)
from app.domains.scanners.core.engine import scan_token as scan_token
from app.domains.scanners.core.engine import scan_wallet
from app.domains.scanners.core.engine import scan_token as scan_token, scan_wallet
# Legacy SENTINEL (DEPRECATED — migrate callers to engine)
from app.domains.scanners.core.models import ScanResult
from app.domains.scanners.core.service import quick_scan_text
__all__ = [
"scan_token",
"scan_wallet",
"ScanResult",
"quick_scan_text",
"scan_token",
"scan_wallet",
]

View file

@ -17,7 +17,6 @@ Wallet scanning flow:
from __future__ import annotations
import logging
import os
import re
from datetime import UTC, datetime
@ -57,6 +56,7 @@ HONEYPOT = "https://api.honeypot.is/v2"
# ── Token scanning ─────────────────────────────────────────────────
async def scan_token(address: str, chain: str | None = None) -> dict:
"""Scan a token for security issues across all data sources.
@ -146,9 +146,7 @@ async def scan_token(address: str, chain: str | None = None) -> dict:
"fantom": "250",
}
cid = chain_id_map.get(result["chain"].lower(), "1")
r = await client.get(
f"{GOPLUS}/token_security/{cid}?contract_addresses={address}"
)
r = await client.get(f"{GOPLUS}/token_security/{cid}?contract_addresses={address}")
if r.status_code == 200:
sec = r.json().get("result", {}).get(address.lower(), {})
if sec:
@ -164,20 +162,20 @@ async def scan_token(address: str, chain: str | None = None) -> dict:
result["owner_renounced"] = sec.get("is_owner_renounced") != "0"
lp_locks = sec.get("lp_holders", [])
locked_pct = sum(
float(h.get("percent", 0))
for h in lp_locks
if h.get("is_locked") == 1
float(h.get("percent", 0)) for h in lp_locks if h.get("is_locked") == 1
)
result["lp_locked"] = locked_pct
holders_data = sec.get("holders", [])
result["holders"] = len(holders_data) if holders_data else 0
for h in holders_data[:10]:
result["top_holders"].append({
"address": h.get("address", ""),
"pct": float(h.get("percent") or 0) * 100,
"is_contract": h.get("is_contract") == 1,
"tag": h.get("tag", ""),
})
result["top_holders"].append(
{
"address": h.get("address", ""),
"pct": float(h.get("percent") or 0) * 100,
"is_contract": h.get("is_contract") == 1,
"tag": h.get("tag", ""),
}
)
# Threat scoring
if result["honeypot"]:
result["threats"].append("HONEYPOT DETECTED")
@ -195,19 +193,15 @@ async def scan_token(address: str, chain: str | None = None) -> dict:
result["threats"].append("Ownership not renounced")
result["risk_score"] += 10
if result["buy_tax"] and result["buy_tax"] > 0.1:
result["threats"].append(
f"High buy tax: {result[buy_tax] * 100:.1f}%"
)
buy_tax = result["buy_tax"]
result["threats"].append(f"High buy tax: {buy_tax * 100:.1f}%")
result["risk_score"] += 10
if result["sell_tax"] and result["sell_tax"] > 0.1:
result["threats"].append(
f"High sell tax: {result[sell_tax] * 100:.1f}%"
)
sell_tax = result["sell_tax"]
result["threats"].append(f"High sell tax: {sell_tax * 100:.1f}%")
result["risk_score"] += 15
if locked_pct < 0.5 and result["mcap"] > 10000:
result["threats"].append(
f"Low LP lock: {locked_pct * 100:.0f}%"
)
result["threats"].append(f"Low LP lock: {locked_pct * 100:.0f}%")
result["risk_score"] += 15
except Exception as e:
result["errors"].append(f"GoPlus: {e}")
@ -227,9 +221,7 @@ async def scan_token(address: str, chain: str | None = None) -> dict:
# ── RMI Backend (bundle detection, fresh wallets, dev data) ──
try:
r = await client.get(
f"{BACKEND_URL}/api/v1/databus/token/{address}", timeout=10
)
r = await client.get(f"{BACKEND_URL}/api/v1/databus/token/{address}", timeout=10)
if r.status_code == 200:
rmi = r.json()
if rmi.get("bundle_detected"):
@ -239,9 +231,8 @@ async def scan_token(address: str, chain: str | None = None) -> dict:
if rmi.get("fresh_wallet_pct"):
result["fresh_wallet_pct"] = rmi["fresh_wallet_pct"]
if rmi["fresh_wallet_pct"] > 50:
result["threats"].append(
f"High fresh wallet ratio: {rmi[fresh_wallet_pct]:.0f}%"
)
fwp = rmi["fresh_wallet_pct"]
result["threats"].append(f"High fresh wallet ratio: {fwp:.0f}%")
result["risk_score"] += 10
if rmi.get("dev_wallet"):
result["dev_wallet"] = rmi["dev_wallet"]
@ -253,9 +244,7 @@ async def scan_token(address: str, chain: str | None = None) -> dict:
ratio = result["volume_24h"] / result["mcap"]
result["volume_real_ratio"] = ratio
if ratio > 5:
result["threats"].append(
f"Suspicious volume/mcap ratio: {ratio:.1f}x"
)
result["threats"].append(f"Suspicious volume/mcap ratio: {ratio:.1f}x")
result["risk_score"] += 15
# ── Liquidity check ──
@ -271,6 +260,7 @@ async def scan_token(address: str, chain: str | None = None) -> dict:
# ── Wallet scanning ────────────────────────────────────────────────
async def scan_wallet(address: str, chain: str | None = None) -> dict:
"""Analyze a wallet for risk indicators.
@ -299,9 +289,7 @@ async def scan_wallet(address: str, chain: str | None = None) -> dict:
async with httpx.AsyncClient(timeout=15) as client:
try:
r = await client.get(
f"{BACKEND_URL}/api/v1/databus/wallet/{address}", timeout=10
)
r = await client.get(f"{BACKEND_URL}/api/v1/databus/wallet/{address}", timeout=10)
if r.status_code == 200:
data = r.json()
for k in (
@ -322,9 +310,7 @@ async def scan_wallet(address: str, chain: str | None = None) -> dict:
result["connected_wallets"] = data.get("connected_wallets", [])[:5]
if result["first_seen"]:
try:
first = datetime.fromisoformat(
result["first_seen"].replace("Z", "+00:00")
)
first = datetime.fromisoformat(result["first_seen"].replace("Z", "+00:00"))
result["wallet_age_days"] = (datetime.now(UTC) - first).days
result["is_fresh"] = result["wallet_age_days"] < 7
except Exception:
@ -336,9 +322,7 @@ async def scan_wallet(address: str, chain: str | None = None) -> dict:
# ── Solana fallback ──
if is_sol(address):
try:
r = await client.get(
f"https://api.solana.fm/v0/accounts/{address}"
)
r = await client.get(f"https://api.solana.fm/v0/accounts/{address}")
if r.status_code == 200:
data = r.json()
result["balance_usd"] = float(data.get("balance", 0)) / 1e9 * 150

View file

@ -29,7 +29,9 @@ from app.domains.scanners.core.modules import ( # noqa: E402
)
async def _rag_scam_check(token_address: str, chain: str, deployer: str | None = None) -> dict[str, Any] | None:
async def _rag_scam_check(
token_address: str, chain: str, deployer: str | None = None
) -> dict[str, Any] | None:
"""Check RAG for scam indicators."""
logger.info("rag_scam_check", token=token_address, chain=chain, deployer=deployer)
return {"check": "rag", "result": "pending"}
@ -53,7 +55,9 @@ async def _check_contract_verification(token_address: str, chain: str) -> dict[s
return {"check": "verified", "result": "pending"}
async def _check_liquidity_lock_multi(token_address: str, chain: str, pair_address: str = "") -> dict[str, Any] | None:
async def _check_liquidity_lock_multi(
token_address: str, chain: str, pair_address: str = ""
) -> dict[str, Any] | None:
"""Check liquidity lock status."""
logger.info("check_liquidity", token=token_address, chain=chain)
return {"check": "liquidity", "result": "pending"}
@ -103,7 +107,9 @@ async def _check_volume_anomaly(
return {"check": "volume", "result": "pending"}
async def _get_price_consensus(token_address: str, chain: str, market_price: float = 0) -> dict[str, Any] | None:
async def _get_price_consensus(
token_address: str, chain: str, market_price: float = 0
) -> dict[str, Any] | None:
"""Get price consensus across multiple sources."""
logger.info("price_consensus", token=token_address, chain=chain)
return {"check": "price", "result": "pending"}
@ -162,7 +168,9 @@ async def _check_chainaware(token_address: str, chain: str) -> dict[str, Any] |
return {"check": "chainaware", "result": "pending"}
async def _check_blowfish(token_address: str, chain: str, user_address: str = "") -> dict[str, Any] | None:
async def _check_blowfish(
token_address: str, chain: str, user_address: str = ""
) -> dict[str, Any] | None:
"""Check Blowfish AI for token."""
logger.info("blowfish_check", token=token_address, chain=chain, user=user_address)
return {"check": "blowfish", "result": "pending"}

View file

@ -6,9 +6,9 @@ Re-exports the canonical public API of the @rugmunchbot. Implementation
lives in app.telegram_bot.rugmunchbot.bot (moved verbatim from
app.telegram_bot.bot on 2026-07-07).
"""
from app.domains.telegram.rugmunchbot.bot import ( # noqa: F401
RugMunchBot,
scan_wallet,
check_spam,
cmd_account,
cmd_admin_ban,
@ -49,6 +49,7 @@ from app.domains.telegram.rugmunchbot.bot import ( # noqa: F401
paywall_text,
precheckout,
scan_token,
scan_wallet,
setup_bot_profile,
short_addr,
successful_payment,

View file

@ -29,6 +29,7 @@ from telegram.ext import (
)
from app.domains.referral import dex_buttons
from app.domains.scanners.core.engine import scan_token, scan_wallet
from app.domains.telegram.rugmunchbot.admin import (
cmd_admin_ban,
cmd_admin_broadcast,
@ -128,7 +129,6 @@ from app.domains.telegram.rugmunchbot.inline import handle_inline
from app.domains.telegram.rugmunchbot.messages import handle_message
from app.domains.telegram.rugmunchbot.payments import precheckout, successful_payment
from app.domains.telegram.rugmunchbot.scam_school import daily_scam_tip
from app.domains.scanners.core.engine import scan_token, scan_wallet
# ══════════════════════════════════════════════
# LOGGING
@ -314,7 +314,9 @@ def main():
app.add_handler(CallbackQueryHandler(handle_community_vote_callback, pattern="^vote_scam:"))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
app.add_handler(MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, on_new_member))
app.add_handler(MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, impersonation_check, block=False))
app.add_handler(
MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, impersonation_check, block=False)
)
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, group_auto_scan, block=False))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, anti_flood_check, block=False))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, auto_scam_check, block=False))
@ -338,6 +340,7 @@ def main():
logger.info("🛡️ CryptoRugMunch Bot v6 starting...")
# Set bot ID for impersonation detection
import asyncio as _asyncio
loop = _asyncio.get_event_loop()
me = loop.run_until_complete(app.bot.get_me())
loop.run_until_complete(set_bot_id(me.id))
@ -378,17 +381,35 @@ class RugMunchBot:
application.add_handler(InlineQueryHandler(handle_inline))
application.add_handler(CallbackQueryHandler(handle_callback))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
application.add_handler(MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, on_new_member))
application.add_handler(MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, impersonation_check, block=False))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, group_auto_scan, block=False))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, anti_flood_check, block=False))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, auto_scam_check, block=False))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, auto_blacklist, block=False))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, night_mode_check, block=False))
application.add_handler(
MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, on_new_member)
)
application.add_handler(
MessageHandler(filters.StatusUpdate.NEW_CHAT_MEMBERS, impersonation_check, block=False)
)
application.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, group_auto_scan, block=False)
)
application.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, anti_flood_check, block=False)
)
application.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, auto_scam_check, block=False)
)
application.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, auto_blacklist, block=False)
)
application.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, night_mode_check, block=False)
)
application.add_handler(CallbackQueryHandler(handle_captcha_callback, pattern="^captcha_"))
application.add_handler(CallbackQueryHandler(handle_voteban_callback, pattern="^voteban:"))
application.add_handler(CallbackQueryHandler(handle_whitelabel_callback, pattern="^rotate_|^copy_"))
application.add_handler(CallbackQueryHandler(handle_community_vote_callback, pattern="^vote_scam:"))
application.add_handler(
CallbackQueryHandler(handle_whitelabel_callback, pattern="^rotate_|^copy_")
)
application.add_handler(
CallbackQueryHandler(handle_community_vote_callback, pattern="^vote_scam:")
)
application.add_handler(PreCheckoutQueryHandler(precheckout))
application.add_handler(MessageHandler(filters.SUCCESSFUL_PAYMENT, successful_payment))
application.add_error_handler(error_handler)
@ -453,7 +474,6 @@ _HANDLER_REGISTRY = [
__all__ = [
"RugMunchBot",
"scan_wallet",
"back_kb",
"check_spam",
"cmd_account",
@ -510,6 +530,7 @@ __all__ = [
"scamschool_kb",
"scan_result_kb",
"scan_token",
"scan_wallet",
"sep",
"setup_bot_profile",
"short_addr",

View file

@ -16,6 +16,7 @@ from app.domains.referral import (
generate_referral_link,
get_referral_stats,
)
from app.domains.scanners.core.engine import scan_token, scan_wallet
from app.domains.telegram.rugmunchbot import db
from app.domains.telegram.rugmunchbot.config import (
BACKEND_URL,
@ -51,10 +52,10 @@ from app.domains.telegram.rugmunchbot.formatting import (
topup_kb,
web_scan_button,
)
from app.domains.scanners.core.engine import scan_token, scan_wallet
logger = logging.getLogger("rmi_bot")
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
u = db.get_or_create_user(user.id, user.username, user.first_name)
@ -65,10 +66,14 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
conn = db.get_db()
try:
from app.domains.telegram.rugmunchbot.security import validate_referral
if validate_referral(user.id, ref_code, conn):
apply_referral_code(
user.id, ref_code,
u.get("referral_code"), u.get("referred_by"), conn,
user.id,
ref_code,
u.get("referral_code"),
u.get("referred_by"),
conn,
)
else:
logger.info("referral_blocked user=%s code=%s", user.id, ref_code)
@ -113,7 +118,10 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
],
]
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=kb, disable_web_page_preview=True)
await update.message.reply_text(
text, parse_mode=ParseMode.HTML, reply_markup=kb, disable_web_page_preview=True
)
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
d = thin_sep()
@ -166,7 +174,10 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
[InlineKeyboardButton("◀️ Menu", callback_data="menu_main")],
]
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=kb, disable_web_page_preview=True)
await update.message.reply_text(
text, parse_mode=ParseMode.HTML, reply_markup=kb, disable_web_page_preview=True
)
async def cmd_scan(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
@ -230,6 +241,7 @@ async def cmd_scan(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
reply_markup=back_kb(),
)
async def cmd_wallet(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
if not ctx.args:
@ -259,7 +271,9 @@ async def cmd_wallet(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
report = format_wallet_report(result)
db.increment_usage(user.id, scan=True)
db.use_scan(user.id)
await msg.edit_text(report, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True)
await msg.edit_text(
report, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True
)
except Exception as e:
logger.error(f"Wallet error: {e}")
await msg.edit_text(
@ -268,6 +282,7 @@ async def cmd_wallet(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
reply_markup=back_kb(),
)
async def cmd_ta(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
if not ctx.args:
@ -284,7 +299,9 @@ async def cmd_ta(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
)
return
target = ctx.args[0]
msg = await update.message.reply_text("📊 <b>Running Technical Analysis...</b>", parse_mode=ParseMode.HTML)
msg = await update.message.reply_text(
"📊 <b>Running Technical Analysis...</b>", parse_mode=ParseMode.HTML
)
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(f"{DEXSCREENER}/tokens/{target}")
@ -330,7 +347,9 @@ async def cmd_ta(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
if buy_ratio > 0.7:
signals.append(f"🟢 Buy pressure dominant ({buy_ratio * 100:.0f}% buys)")
elif buy_ratio < 0.3:
signals.append(f"🔴 Sell pressure dominant ({(1 - buy_ratio) * 100:.0f}% sells)")
signals.append(
f"🔴 Sell pressure dominant ({(1 - buy_ratio) * 100:.0f}% sells)"
)
else:
signals.append(f"⚪ Balanced buy/sell ({buy_ratio * 100:.0f}% buys)")
text = (
@ -351,10 +370,15 @@ async def cmd_ta(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text += footer_links()
db.increment_usage(user.id, scan=True)
db.use_scan(user.id)
await msg.edit_text(text, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True)
await msg.edit_text(
text, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True
)
except Exception as e:
logger.error(f"TA error: {e}")
await msg.edit_text(f"❌ TA failed: {str(e)[:200]}", parse_mode=ParseMode.HTML, reply_markup=back_kb())
await msg.edit_text(
f"❌ TA failed: {str(e)[:200]}", parse_mode=ParseMode.HTML, reply_markup=back_kb()
)
async def cmd_compare(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
@ -384,9 +408,7 @@ async def cmd_compare(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
f"⚠️ Risk: {r['risk_score']}% | Threats: {len(r['threats'])}"
)
text = (
f"⚖️ <b>Token Comparison</b>\n{sep()}\n\n1{mini(r1)}\n\n2{mini(r2)}\n\n{thin_sep()}\n🏆 <b>Verdict:</b> "
)
text = f"⚖️ <b>Token Comparison</b>\n{sep()}\n\n1{mini(r1)}\n\n2{mini(r2)}\n\n{thin_sep()}\n🏆 <b>Verdict:</b> "
if r1["risk_score"] < r2["risk_score"]:
text += f"{r1['name']} has lower risk ({r1['risk_score']}% vs {r2['risk_score']}%)"
elif r2["risk_score"] < r1["risk_score"]:
@ -396,9 +418,14 @@ async def cmd_compare(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text += footer_links()
db.increment_usage(user.id, scan=True)
db.use_scan(user.id)
await msg.edit_text(text, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True)
await msg.edit_text(
text, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True
)
except Exception as e:
await msg.edit_text(f"❌ Compare failed: {str(e)[:200]}", parse_mode=ParseMode.HTML, reply_markup=back_kb())
await msg.edit_text(
f"❌ Compare failed: {str(e)[:200]}", parse_mode=ParseMode.HTML, reply_markup=back_kb()
)
async def cmd_quick(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Quick price check - same as $TOKEN but as a command."""
@ -431,7 +458,11 @@ async def cmd_quick(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
)
kb = InlineKeyboardMarkup(
[
[InlineKeyboardButton("🔍 Full Scan", callback_data=f"scan_{addr}_{chain}")],
[
InlineKeyboardButton(
"🔍 Full Scan", callback_data=f"scan_{addr}_{chain}"
)
],
[web_scan_button(addr, chain)],
]
)
@ -444,7 +475,10 @@ async def cmd_quick(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
await update.message.reply_text(f"❌ Could not find token: <b>{symbol}</b>", parse_mode=ParseMode.HTML)
await update.message.reply_text(
f"❌ Could not find token: <b>{symbol}</b>", parse_mode=ParseMode.HTML
)
async def cmd_rugcheck(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Quick rug pull checklist - educational."""
@ -478,8 +512,11 @@ async def cmd_rugcheck(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True
)
async def cmd_trending(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
msg = await update.message.reply_text("🔥 <b>Fetching trending tokens...</b>", parse_mode=ParseMode.HTML)
msg = await update.message.reply_text(
"🔥 <b>Fetching trending tokens...</b>", parse_mode=ParseMode.HTML
)
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{DEXSCREENER}/search/?q=trending")
@ -515,6 +552,7 @@ async def cmd_trending(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
except Exception as e:
await msg.edit_text(f"❌ Error: {str(e)[:200]}", parse_mode=ParseMode.HTML)
async def cmd_news(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
try:
async with httpx.AsyncClient(timeout=10) as client:
@ -527,7 +565,9 @@ async def cmd_news(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
title = a.get("title", "Untitled")[:60]
source = a.get("source", "News")
lines.append(f"• <b>{title}</b>\n <i>{source}</i>")
lines.append(f'\n{thin_sep()}\n🌐 <a href="{WEBSITE_URL}/news">More on rugmunch.io</a>')
lines.append(
f'\n{thin_sep()}\n🌐 <a href="{WEBSITE_URL}/news">More on rugmunch.io</a>'
)
await update.message.reply_text(
"\n".join(lines),
parse_mode=ParseMode.HTML,
@ -547,6 +587,7 @@ async def cmd_news(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
disable_web_page_preview=True,
)
async def cmd_account(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
u = db.get_or_create_user(user.id, user.username, user.first_name)
@ -598,7 +639,10 @@ async def cmd_account(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
[InlineKeyboardButton("◀️ Menu", callback_data="menu_main")],
]
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=kb, disable_web_page_preview=True)
await update.message.reply_text(
text, parse_mode=ParseMode.HTML, reply_markup=kb, disable_web_page_preview=True
)
async def cmd_pricing(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
d = thin_sep()
@ -622,6 +666,7 @@ async def cmd_pricing(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text, parse_mode=ParseMode.HTML, reply_markup=pricing_kb(), disable_web_page_preview=True
)
async def cmd_topup(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text = (
f"🔋 <b>Top Up - Buy Extra Usage</b>\n{sep()}\n\n"
@ -641,6 +686,7 @@ async def cmd_topup(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
)
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=topup_kb())
async def cmd_scamschool(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text = (
f"📚 <b>Scam School</b>\n{sep()}\n\n"
@ -652,6 +698,7 @@ async def cmd_scamschool(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text, parse_mode=ParseMode.HTML, reply_markup=scamschool_kb(), disable_web_page_preview=True
)
async def cmd_refer(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
u = db.get_or_create_user(user.id, user.username, user.first_name)
@ -676,13 +723,19 @@ async def cmd_refer(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
f" 4. No limits - refer as many as you want!"
f"{footer_links()}"
)
share_text = quote(f"Check out RugMunch Intelligence - the best crypto scam detector on Telegram! 🛡️\n\n{link}")
share_text = quote(
f"Check out RugMunch Intelligence - the best crypto scam detector on Telegram! 🛡️\n\n{link}"
)
await update.message.reply_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup(
[
[InlineKeyboardButton("📤 Share Link", url=f"https://t.me/share/url?url={link}&text={share_text}")],
[
InlineKeyboardButton(
"📤 Share Link", url=f"https://t.me/share/url?url={link}&text={share_text}"
)
],
[InlineKeyboardButton("🌐 rugmunch.io", url=WEBSITE_URL)],
[InlineKeyboardButton("◀️ Menu", callback_data="menu_main")],
]
@ -690,6 +743,7 @@ async def cmd_refer(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
disable_web_page_preview=True,
)
async def cmd_watchlist(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
items = db.get_watchlist(user.id)
@ -716,10 +770,13 @@ async def cmd_watchlist(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
text, parse_mode=ParseMode.HTML, reply_markup=back_kb(), disable_web_page_preview=True
)
async def cmd_watch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
if not ctx.args:
await update.message.reply_text("👀 Usage: /watch <code>address</code> [chain]", parse_mode=ParseMode.HTML)
await update.message.reply_text(
"👀 Usage: /watch <code>address</code> [chain]", parse_mode=ParseMode.HTML
)
return
addr = ctx.args[0]
chain = ctx.args[1] if len(ctx.args) > 1 else detect_chain(addr)
@ -746,10 +803,13 @@ async def cmd_watch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"❌ Could not add. Limit reached or already added.", parse_mode=ParseMode.HTML
)
async def cmd_unwatch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
if not ctx.args:
await update.message.reply_text("Usage: /unwatch <code>address</code>", parse_mode=ParseMode.HTML)
await update.message.reply_text(
"Usage: /unwatch <code>address</code>", parse_mode=ParseMode.HTML
)
return
addr = ctx.args[0]
ok = db.remove_watchlist(user.id, addr)
@ -758,7 +818,10 @@ async def cmd_unwatch(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
f"✅ Removed <code>{short_addr(addr)}</code> from watchlist.", parse_mode=ParseMode.HTML
)
else:
await update.message.reply_text("❌ Token not found in your watchlist.", parse_mode=ParseMode.HTML)
await update.message.reply_text(
"❌ Token not found in your watchlist.", parse_mode=ParseMode.HTML
)
async def cmd_alerts(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
user = update.effective_user

View file

@ -7,6 +7,7 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.ext import ContextTypes
from app.domains.scanners.core.engine import scan_token
from app.domains.telegram.rugmunchbot import db
from app.domains.telegram.rugmunchbot.config import (
DEXSCREENER,
@ -28,7 +29,6 @@ from app.domains.telegram.rugmunchbot.formatting import (
thin_sep,
web_scan_button,
)
from app.domains.scanners.core.engine import scan_token
logger = logging.getLogger("rmi_bot")
@ -126,7 +126,11 @@ async def cmd_quick(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
)
kb = InlineKeyboardMarkup(
[
[InlineKeyboardButton("🔍 Full Scan", callback_data=f"scan_{addr}_{chain}")],
[
InlineKeyboardButton(
"🔍 Full Scan", callback_data=f"scan_{addr}_{chain}"
)
],
[web_scan_button(addr, chain)],
]
)
@ -139,7 +143,9 @@ async def cmd_quick(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
return
except Exception:
logging.getLogger(__name__).warning("swallowed exception", exc_info=True)
await update.message.reply_text(f"❌ Could not find token: <b>{symbol}</b>", parse_mode=ParseMode.HTML)
await update.message.reply_text(
f"❌ Could not find token: <b>{symbol}</b>", parse_mode=ParseMode.HTML
)
async def cmd_rugcheck(update: Update, ctx: ContextTypes.DEFAULT_TYPE):

View file

@ -6,9 +6,10 @@ The canonical scanner has moved to:
All bot commands now import from the engine directly.
This file exists only for any code that still imports from here.
"""
from app.domains.scanners.core.engine import scan_token, scan_wallet
# Legacy alias
analyze_wallet = scan_wallet
__all__ = ["scan_token", "scan_wallet", "analyze_wallet"]
__all__ = ["analyze_wallet", "scan_token", "scan_wallet"]

View file

@ -22,12 +22,12 @@ Shutdown:
Per v3 unfuck rule #7: add_middleware must be at module level, NOT in lifespan.
"""
from __future__ import annotations
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
import httpx
@ -36,6 +36,7 @@ from app.core.config import settings
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from fastapi import FastAPI
logger = logging.getLogger(__name__)
@ -53,6 +54,7 @@ _BG_TASK_SPECS: list[tuple[str, str, str, str]] = [
# ── Main lifespan ──────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Wire cross-cutting concerns at startup. Failures logged, never fatal."""
@ -103,9 +105,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# ── Init helpers ───────────────────────────────────────────────────
async def _init_logging() -> None:
try:
from app.core.logging import setup_logging
setup_logging(settings.log_level)
logger.info("logging_initialized")
except Exception as exc:
@ -117,8 +121,7 @@ def _check_wallet_vault() -> None:
if not vault_pw:
strict = settings.strict_vault
msg = (
"WALLET_VAULT_PASSWORD is missing or empty. "
"Wallet key operations will be unavailable."
"WALLET_VAULT_PASSWORD is missing or empty. Wallet key operations will be unavailable."
)
if strict:
raise RuntimeError(f"CRITICAL: {msg} Set STRICT_VAULT=0 to run without.")
@ -144,6 +147,7 @@ async def _init_error_handlers(app: FastAPI) -> None:
async def _init_fact_store() -> None:
try:
from app.agents.fact_store import seed_facts
seeded = await seed_facts()
logger.info("fact_store_seeded count=%d", seeded)
except Exception as exc:
@ -154,6 +158,7 @@ async def _init_observability() -> None:
# OTEL
try:
from app.core.tracing import setup_otel
otel_ok = setup_otel()
logger.info("otel_init ok=%s", otel_ok)
except Exception as exc:
@ -162,6 +167,7 @@ async def _init_observability() -> None:
# Langfuse
try:
from app.core.langfuse import init_langfuse
lf_ok = init_langfuse()
logger.info("langfuse_init ok=%s", lf_ok)
except Exception as exc:
@ -170,6 +176,7 @@ async def _init_observability() -> None:
# Sentry / GlitchTip
try:
from app.core.observability import setup_sentry
sentry_ok = setup_sentry()
logger.info("sentry_init ok=%s", sentry_ok)
except Exception as exc:
@ -179,6 +186,7 @@ async def _init_observability() -> None:
async def _init_certstream() -> asyncio.Task | None:
try:
from app.domains.threat.certstream_listener import start_listener
task = await start_listener()
logger.info("certstream_started has_task=%s", task is not None)
return task
@ -191,8 +199,9 @@ async def _init_data_pipeline() -> None:
try:
if settings.data_pipeline:
from app.data.pipeline import DataPipeline
pipe = DataPipeline()
asyncio.create_task(pipe.start())
_ = asyncio.create_task(pipe.start()) # noqa: RUF006
logger.info("data_pipeline_started")
except Exception as exc:
logger.info("data_pipeline_skipped err=%s", exc)
@ -200,6 +209,7 @@ async def _init_data_pipeline() -> None:
# ── Background tasks ────────────────────────────────────────────────
async def _start_background_tasks(app: FastAPI) -> set[asyncio.Task]:
bg_tasks: set[asyncio.Task] = set()
@ -217,6 +227,7 @@ async def _start_background_tasks(app: FastAPI) -> set[asyncio.Task]:
# Databus cache warmer (needs app instance)
try:
from app.domains.databus.core import cache_warm_loop
task = asyncio.create_task(cache_warm_loop(app))
bg_tasks.add(task)
task.add_done_callback(bg_tasks.discard)
@ -230,6 +241,7 @@ async def _start_background_tasks(app: FastAPI) -> set[asyncio.Task]:
# ── Index verification ─────────────────────────────────────────────
async def _verify_indexes(app: FastAPI) -> None:
try:
supabase_url = settings.supabase_url
@ -271,14 +283,13 @@ async def _verify_indexes(app: FastAPI) -> None:
# ── Shutdown helpers ────────────────────────────────────────────────
async def _shutdown_background_tasks(bg_tasks: set[asyncio.Task] | None) -> None:
if not bg_tasks:
return
for task in list(bg_tasks):
try:
with suppress(Exception):
task.cancel()
except Exception:
pass
try:
await asyncio.gather(*bg_tasks, return_exceptions=True)
logger.info("background_tasks_cancelled count=%d", len(bg_tasks))
@ -291,6 +302,7 @@ async def _shutdown_certstream(task: asyncio.Task | None) -> None:
return
try:
from app.domains.threat.certstream_listener import stop_listener
await stop_listener(task)
except Exception:
logger.warning("certstream_shutdown_error", exc_info=True)
@ -301,6 +313,7 @@ async def _shutdown_observability() -> None:
from app.core.langfuse import flush_langfuse
from app.core.observability import flush_sentry
from app.core.tracing import shutdown_otel
flush_sentry(timeout=2.0)
flush_langfuse()
shutdown_otel()

View file

@ -119,7 +119,6 @@ ROUTER_MODULES: Final[list[str]] = [
"app.routers.pair_tracker", # /api/v1/pairs/{trending,new,search}
"app.routers.ai_predict", # /api/v1/predict/{token,top}
# WebSocket routes are auto-discovered by factory.py lifespan
# DEFERRED Wave 3 (2):
# "app.routers.unified_wallet_scanner" — NO `router` APIRouter attribute.
# Module exposes `get_wallet_scanner()` consumed by unified_scanner_router.

View file

@ -13,6 +13,7 @@ Mounted at: /api/v1/admin/watchlist/*
from __future__ import annotations
import contextlib
import logging
from datetime import UTC, datetime
@ -85,10 +86,8 @@ async def add_watchlist_token(request: Request, token: WatchlistTokenIn):
logger.info("Watchlist token added: %s/%s", token.chain, token.address)
return {"status": "added", "token": inserted}
except Exception as e:
try:
with contextlib.suppress(Exception):
await supabase.rpc("create_admin_watchlist_table").execute()
except Exception:
pass
try:
result = await supabase.table("admin_watchlist").insert(payload).execute()
inserted = result.data[0] if result.data else payload

View file

@ -1,18 +1,17 @@
'''
"""
AI price prediction endpoint for RugCharts.
Uses EMA crossover + volume momentum + RMI risk signals to generate
buy/hold/sell signals. Real ML model training runs nightly.
'''
import json
"""
import time
from typing import Optional
import httpx
from fastapi import APIRouter, Query
router = APIRouter()
GEKKO_TERMINAL = 'https://api.geckoterminal.com/api/v2'
GEKKO_TERMINAL = "https://api.geckoterminal.com/api/v2"
CACHE_TTL = 120
_signal_cache: dict[str, dict] = {}
@ -40,55 +39,55 @@ def compute_rsi(prices: list[float], period: int = 14) -> float:
return 100 - (100 / (1 + rs))
@router.get('/api/v1/predict/{token}')
@router.get("/api/v1/predict/{token}")
async def predict_price(
token: str,
chain: str = Query('solana'),
chain: str = Query("solana"),
):
'''AI price prediction — EMA crossover + RSI + volume momentum.'''
cache_key = f'{chain}:{token}'
"""AI price prediction — EMA crossover + RSI + volume momentum."""
cache_key = f"{chain}:{token}"
if cache_key in _signal_cache:
cached = _signal_cache[cache_key]
if time.time() - cached.get('ts', 0) < CACHE_TTL:
if time.time() - cached.get("ts", 0) < CACHE_TTL:
return cached
signal = {
'token': token,
'chain': chain,
'signal': 'hold',
'confidence': 0,
'price_current': 0,
'indicators': {},
'reasons': [],
'ts': time.time(),
"token": token,
"chain": chain,
"signal": "hold",
"confidence": 0,
"price_current": 0,
"indicators": {},
"reasons": [],
"ts": time.time(),
}
try:
async with httpx.AsyncClient() as client:
# Fetch OHLCV
resp = await client.get(
f'{GEKKO_TERMINAL}/networks/{chain}/pools/{token}/ohlcv/hour',
params={'limit': '72'},
f"{GEKKO_TERMINAL}/networks/{chain}/pools/{token}/ohlcv/hour",
params={"limit": "72"},
timeout=10,
)
if resp.status_code != 200:
signal['error'] = 'OHLCV unavailable'
signal["error"] = "OHLCV unavailable"
return signal
ohlcv = resp.json()
candles = ohlcv.get('data', {}).get('attributes', {}).get('ohlcv_list', [])
candles = ohlcv.get("data", {}).get("attributes", {}).get("ohlcv_list", [])
if not candles or len(candles) < 24:
signal['error'] = 'Not enough data'
signal["error"] = "Not enough data"
return signal
closes = [float(c[4]) for c in candles if len(c) > 4]
volumes = [float(c[5]) for c in candles if len(c) > 5]
if len(closes) < 24:
signal['error'] = 'Not enough close data'
signal["error"] = "Not enough close data"
return signal
signal['price_current'] = closes[-1]
signal["price_current"] = closes[-1]
# EMA crossover (golden cross / death cross)
ema9 = compute_ema(closes, 9)
@ -105,14 +104,16 @@ async def predict_price(
vol_ratio = vol_short / vol_long if vol_long > 0 else 1
# Price trend
price_change_24h = ((closes[-1] - closes[-24]) / closes[-24]) * 100 if len(closes) >= 24 else 0
price_change_24h = (
((closes[-1] - closes[-24]) / closes[-24]) * 100 if len(closes) >= 24 else 0
)
signal['indicators'] = {
'ema9': round(ema9[-1], 8),
'ema21': round(ema21[-1], 8),
'rsi_14': round(rsi, 1),
'volume_ratio': round(vol_ratio, 2),
'price_change_24h': round(price_change_24h, 2),
signal["indicators"] = {
"ema9": round(ema9[-1], 8),
"ema21": round(ema21[-1], 8),
"rsi_14": round(rsi, 1),
"volume_ratio": round(vol_ratio, 2),
"price_change_24h": round(price_change_24h, 2),
}
# Signal logic
@ -122,97 +123,97 @@ async def predict_price(
# EMA crossover
if crossover > 0 and prev_crossover <= 0:
score += 3
reasons.append('golden_cross')
reasons.append("golden_cross")
elif crossover < 0 and prev_crossover >= 0:
score -= 3
reasons.append('death_cross')
reasons.append("death_cross")
elif crossover > 0:
score += 1
reasons.append('bullish_ema')
reasons.append("bullish_ema")
else:
score -= 1
reasons.append('bearish_ema')
reasons.append("bearish_ema")
# RSI
if rsi < 30:
score += 2
reasons.append('rsi_oversold')
reasons.append("rsi_oversold")
elif rsi > 70:
score -= 2
reasons.append('rsi_overbought')
reasons.append("rsi_overbought")
elif 45 <= rsi <= 55:
reasons.append('rsi_neutral')
reasons.append("rsi_neutral")
# Volume
if vol_ratio > 2:
score += 2
reasons.append('volume_surge')
reasons.append("volume_surge")
elif vol_ratio < 0.5:
score -= 1
reasons.append('volume_dying')
reasons.append("volume_dying")
# Price trend
if price_change_24h > 20:
score -= 1
reasons.append('extended_24h')
reasons.append("extended_24h")
elif price_change_24h < -20:
score += 1
reasons.append('oversold_24h')
reasons.append("oversold_24h")
# Determine signal
if score >= 4:
signal['signal'] = 'strong_buy'
signal['confidence'] = min(90, 50 + score * 5)
signal["signal"] = "strong_buy"
signal["confidence"] = min(90, 50 + score * 5)
elif score >= 2:
signal['signal'] = 'buy'
signal['confidence'] = 50 + score * 5
signal["signal"] = "buy"
signal["confidence"] = 50 + score * 5
elif score <= -4:
signal['signal'] = 'strong_sell'
signal['confidence'] = min(90, 50 + abs(score) * 5)
signal["signal"] = "strong_sell"
signal["confidence"] = min(90, 50 + abs(score) * 5)
elif score <= -2:
signal['signal'] = 'sell'
signal['confidence'] = 50 + abs(score) * 5
signal["signal"] = "sell"
signal["confidence"] = 50 + abs(score) * 5
else:
signal['signal'] = 'hold'
signal['confidence'] = 60
signal["signal"] = "hold"
signal["confidence"] = 60
signal['reasons'] = reasons
signal['score'] = score
signal["reasons"] = reasons
signal["score"] = score
except Exception as e:
signal['error'] = str(e)
signal["error"] = str(e)
_signal_cache[cache_key] = signal
return signal
@router.get('/api/v1/predict/top')
async def predict_top(chain: str = Query('solana'), limit: int = Query(10, le=25)):
'''Get AI predictions for top trending tokens.'''
@router.get("/api/v1/predict/top")
async def predict_top(chain: str = Query("solana"), limit: int = Query(10, le=25)):
"""Get AI predictions for top trending tokens."""
results = []
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f'{GEKKO_TERMINAL}/networks/{chain}/trending_pools',
params={'include': 'base_token', 'page[limit]': str(limit)},
f"{GEKKO_TERMINAL}/networks/{chain}/trending_pools",
params={"include": "base_token", "page[limit]": str(limit)},
timeout=15,
)
if resp.status_code == 200:
data = resp.json()
tokens = []
for pool in data.get('data', [])[:limit]:
base = pool.get('relationships', {}).get('base_token', {}).get('data', {})
tid = base.get('id', '')
tk = tid.split('_')[-1] if '_' in tid else tid
sym = pool.get('attributes', {}).get('base_token_price_quote_token', '')
for pool in data.get("data", [])[:limit]:
base = pool.get("relationships", {}).get("base_token", {}).get("data", {})
tid = base.get("id", "")
tk = tid.split("_")[-1] if "_" in tid else tid
sym = pool.get("attributes", {}).get("base_token_price_quote_token", "")
if tk:
tokens.append({'token': tk, 'symbol': sym})
tokens.append({"token": tk, "symbol": sym})
for t in tokens:
pred = await predict_price(t['token'], chain)
pred['symbol'] = t['symbol']
pred = await predict_price(t["token"], chain)
pred["symbol"] = t["symbol"]
results.append(pred)
except Exception:
pass
return {'data': results, 'chain': chain}
return {"data": results, "chain": chain}

View file

@ -1,32 +1,31 @@
'''
"""
Native pair tracking replaces DexScreener dependency.
Tracks volume surges, new launches, trending pairs via ClickHouse +
Helius + GeckoTerminal. We own the data.
'''
import asyncio
"""
import json
import time
from typing import Optional
import httpx
from fastapi import APIRouter, Query
router = APIRouter()
GEKKO_TERMINAL = 'https://api.geckoterminal.com/api/v2'
GEKKO_TERMINAL = "https://api.geckoterminal.com/api/v2"
CACHE_TTL = 60 # seconds
_trending_cache: Optional[dict] = None
_trending_cache: dict | None = None
_trending_ts = 0
_new_launches_cache: Optional[list] = None
_new_launches_cache: list | None = None
_new_launches_ts = 0
@router.get('/api/v1/pairs/trending')
@router.get("/api/v1/pairs/trending")
async def get_trending_pairs(
chain: str = Query('solana'),
chain: str = Query("solana"),
limit: int = Query(20, le=50),
):
'''Native trending pairs — volume + price change + holder activity.'''
"""Native trending pairs — volume + price change + holder activity."""
global _trending_cache, _trending_ts
now = time.time()
if _trending_cache and now - _trending_ts < CACHE_TTL:
@ -37,65 +36,75 @@ async def get_trending_pairs(
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f'{GEKKO_TERMINAL}/networks/{chain}/trending_pools',
params={'include': 'base_token', 'page[limit]': str(limit)},
f"{GEKKO_TERMINAL}/networks/{chain}/trending_pools",
params={"include": "base_token", "page[limit]": str(limit)},
timeout=15,
)
if resp.status_code == 200:
data = resp.json()
for pool in data.get('data', []):
attrs = pool.get('attributes', {})
base = pool.get('relationships', {}).get('base_token', {}).get('data', {})
token_id = base.get('id', '')
trending.append({
'id': pool.get('id', ''),
'token': token_id.split('_')[-1] if '_' in token_id else token_id,
'name': attrs.get('name', ''),
'symbol': attrs.get('base_token_price_quote_token', ''),
'price_usd': float(attrs.get('base_token_price_usd', 0) or 0),
'volume_24h': float(attrs.get('volume_usd', {}).get('h24', 0) or 0),
'price_change_24h': float(attrs.get('price_change_percentage', {}).get('h24', 0) or 0),
'liquidity_usd': float(attrs.get('reserve_in_usd', 0) or 0),
'tx_count_24h': int(attrs.get('transactions', {}).get('h24', 0) or 0),
'created_at': attrs.get('pool_created_at', ''),
})
for pool in data.get("data", []):
attrs = pool.get("attributes", {})
base = pool.get("relationships", {}).get("base_token", {}).get("data", {})
token_id = base.get("id", "")
trending.append(
{
"id": pool.get("id", ""),
"token": token_id.split("_")[-1] if "_" in token_id else token_id,
"name": attrs.get("name", ""),
"symbol": attrs.get("base_token_price_quote_token", ""),
"price_usd": float(attrs.get("base_token_price_usd", 0) or 0),
"volume_24h": float(attrs.get("volume_usd", {}).get("h24", 0) or 0),
"price_change_24h": float(
attrs.get("price_change_percentage", {}).get("h24", 0) or 0
),
"liquidity_usd": float(attrs.get("reserve_in_usd", 0) or 0),
"tx_count_24h": int(attrs.get("transactions", {}).get("h24", 0) or 0),
"created_at": attrs.get("pool_created_at", ""),
}
)
except Exception:
pass
# Enhance with RMI risk scores from ClickHouse if available
try:
import subprocess
addrs = [p['token'] for p in trending[:20] if p['token']]
addrs = [p["token"] for p in trending[:20] if p["token"]]
if addrs:
addr_list = "', '".join(addrs)
result = subprocess.run(
['clickhouse-client', '-q',
f"SELECT address, risk_score FROM wallet_memory.token_risk WHERE address IN ('{addr_list}') FORMAT JSONEachRow"],
capture_output=True, text=True, timeout=10,
[
"clickhouse-client",
"-q",
f"SELECT address, risk_score FROM wallet_memory.token_risk WHERE address IN ('{addr_list}') FORMAT JSONEachRow",
],
capture_output=True,
text=True,
timeout=10,
)
risk_map = {}
for line in result.stdout.strip().split('\n'):
for line in result.stdout.strip().split("\n"):
if line:
row = json.loads(line)
risk_map[row['address']] = float(row['risk_score'])
risk_map[row["address"]] = float(row["risk_score"])
for p in trending:
if p['token'] in risk_map:
p['risk_score'] = risk_map[p['token']]
if p["token"] in risk_map:
p["risk_score"] = risk_map[p["token"]]
except Exception:
pass
response = {'data': trending[:limit], 'chain': chain, 'fetched_at': int(now * 1000)}
response = {"data": trending[:limit], "chain": chain, "fetched_at": int(now * 1000)}
_trending_cache = response
_trending_ts = now
return response
@router.get('/api/v1/pairs/new')
@router.get("/api/v1/pairs/new")
async def get_new_launches(
chain: str = Query('solana'),
chain: str = Query("solana"),
limit: int = Query(20, le=50),
):
'''New token launches — pools created in last 24h with volume.'''
"""New token launches — pools created in last 24h with volume."""
global _new_launches_cache, _new_launches_ts
now = time.time()
if _new_launches_cache and now - _new_launches_ts < CACHE_TTL:
@ -105,66 +114,74 @@ async def get_new_launches(
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f'{GEKKO_TERMINAL}/networks/{chain}/pools',
params={'include': 'base_token', 'page[limit]': str(limit), 'sort': '-created_at'},
f"{GEKKO_TERMINAL}/networks/{chain}/pools",
params={"include": "base_token", "page[limit]": str(limit), "sort": "-created_at"},
timeout=15,
)
if resp.status_code == 200:
data = resp.json()
for pool in data.get('data', []):
attrs = pool.get('attributes', {})
created = attrs.get('pool_created_at', '')
age_h = (now - time.mktime(time.strptime(created, '%Y-%m-%dT%H:%M:%SZ'))) / 3600 if created else 999
for pool in data.get("data", []):
attrs = pool.get("attributes", {})
created = attrs.get("pool_created_at", "")
age_h = (
(now - time.mktime(time.strptime(created, "%Y-%m-%dT%H:%M:%SZ"))) / 3600
if created
else 999
)
if age_h > 24:
continue
base = pool.get('relationships', {}).get('base_token', {}).get('data', {})
token_id = base.get('id', '')
launches.append({
'id': pool.get('id', ''),
'token': token_id.split('_')[-1] if '_' in token_id else token_id,
'name': attrs.get('name', ''),
'price_usd': float(attrs.get('base_token_price_usd', 0) or 0),
'volume_24h': float(attrs.get('volume_usd', {}).get('h24', 0) or 0),
'liquidity_usd': float(attrs.get('reserve_in_usd', 0) or 0),
'created_at': created,
'age_hours': round(age_h, 1),
})
base = pool.get("relationships", {}).get("base_token", {}).get("data", {})
token_id = base.get("id", "")
launches.append(
{
"id": pool.get("id", ""),
"token": token_id.split("_")[-1] if "_" in token_id else token_id,
"name": attrs.get("name", ""),
"price_usd": float(attrs.get("base_token_price_usd", 0) or 0),
"volume_24h": float(attrs.get("volume_usd", {}).get("h24", 0) or 0),
"liquidity_usd": float(attrs.get("reserve_in_usd", 0) or 0),
"created_at": created,
"age_hours": round(age_h, 1),
}
)
except Exception:
pass
response = {'data': launches[:limit], 'chain': chain, 'fetched_at': int(now * 1000)}
response = {"data": launches[:limit], "chain": chain, "fetched_at": int(now * 1000)}
_new_launches_cache = response
_new_launches_ts = now
return response
@router.get('/api/v1/pairs/search')
async def search_pairs(q: str = Query(''), chain: str = Query('solana')):
'''Search for pairs by token address, symbol, or name.'''
@router.get("/api/v1/pairs/search")
async def search_pairs(q: str = Query(""), chain: str = Query("solana")):
"""Search for pairs by token address, symbol, or name."""
if not q or len(q) < 2:
return {'data': []}
return {"data": []}
results = []
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f'{GEKKO_TERMINAL}/search/pools',
params={'query': q, 'network': chain, 'page[limit]': '10'},
f"{GEKKO_TERMINAL}/search/pools",
params={"query": q, "network": chain, "page[limit]": "10"},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
for pool in data.get('data', []):
attrs = pool.get('attributes', {})
results.append({
'id': pool.get('id', ''),
'name': attrs.get('name', ''),
'price_usd': float(attrs.get('base_token_price_usd', 0) or 0),
'volume_24h': float(attrs.get('volume_usd', {}).get('h24', 0) or 0),
'liquidity_usd': float(attrs.get('reserve_in_usd', 0) or 0),
'chain': chain,
})
for pool in data.get("data", []):
attrs = pool.get("attributes", {})
results.append(
{
"id": pool.get("id", ""),
"name": attrs.get("name", ""),
"price_usd": float(attrs.get("base_token_price_usd", 0) or 0),
"volume_24h": float(attrs.get("volume_usd", {}).get("h24", 0) or 0),
"liquidity_usd": float(attrs.get("reserve_in_usd", 0) or 0),
"chain": chain,
}
)
except Exception:
pass
return {'data': results}
return {"data": results}

View file

@ -1,47 +1,56 @@
'''
"""
Real-time trade WebSocket for RugCharts.
Streams live Solana transactions via Helius WebSocket, polls
GeckoTerminal for other chains. Labels traders using RMI wallet labels.
Mount at: /ws/trades?chain=solana&token=<mint_address>
'''
"""
import asyncio
import contextlib
import json
import os
import time
from typing import Optional
import httpx
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
router = APIRouter()
HELIUS_API_KEY = os.getenv('HELIUS_API_KEY', '')
HELIUS_WS_URL = f'wss://mainnet.helius-rpc.com/?api-key={HELIUS_API_KEY}' if HELIUS_API_KEY else None
GEKKO_TERMINAL_REST = 'https://api.geckoterminal.com/api/v2'
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
HELIUS_WS_URL = (
f"wss://mainnet.helius-rpc.com/?api-key={HELIUS_API_KEY}" if HELIUS_API_KEY else None
)
GEKKO_TERMINAL_REST = "https://api.geckoterminal.com/api/v2"
_wallet_label_cache: dict[str, str] = {}
_label_cache_ts = 0
async def get_wallet_label(address: str) -> Optional[str]:
async def get_wallet_label(address: str) -> str | None:
global _wallet_label_cache, _label_cache_ts
now = time.time()
if now - _label_cache_ts > 300:
try:
import subprocess
result = subprocess.run(
['clickhouse-client', '-q',
"SELECT address, label FROM wallet_memory.wallet_labels "
"WHERE address IN (SELECT address FROM wallet_memory.wallet_labels "
"ORDER BY rand() LIMIT 100000) FORMAT JSONEachRow"],
capture_output=True, text=True, timeout=15,
[
"clickhouse-client",
"-q",
"SELECT address, label FROM wallet_memory.wallet_labels "
"WHERE address IN (SELECT address FROM wallet_memory.wallet_labels "
"ORDER BY rand() LIMIT 100000) FORMAT JSONEachRow",
],
capture_output=True,
text=True,
timeout=15,
)
labels = {}
for line in result.stdout.strip().split('\n'):
for line in result.stdout.strip().split("\n"):
if line:
row = json.loads(line)
labels[row['address']] = row['label']
labels[row["address"]] = row["label"]
_wallet_label_cache = labels
except Exception:
pass
@ -51,42 +60,50 @@ async def get_wallet_label(address: str) -> Optional[str]:
async def fetch_solana_transactions(mint: str, ws: WebSocket):
import websockets
if not HELIUS_API_KEY:
await ws.send_json({'error': 'HELIUS_API_KEY not configured'})
await ws.send_json({"error": "HELIUS_API_KEY not configured"})
return
try:
async with websockets.connect(HELIUS_WS_URL) as helius_ws:
sub_msg = {
'jsonrpc': '2.0',
'id': 1,
'method': 'transactionSubscribe',
'params': [{'accountInclude': [mint], 'encoding': 'jsonParsed', 'commitment': 'processed'}],
"jsonrpc": "2.0",
"id": 1,
"method": "transactionSubscribe",
"params": [
{"accountInclude": [mint], "encoding": "jsonParsed", "commitment": "processed"}
],
}
await helius_ws.send(json.dumps(sub_msg))
while True:
try:
msg = await asyncio.wait_for(helius_ws.recv(), timeout=30)
data = json.loads(msg)
if 'params' in data:
tx = data['params']['result']['transaction']
sigs = tx.get('message', {}).get('accountKeys', [])
signer = sigs[0] if sigs else ''
if "params" in data:
tx = data["params"]["result"]["transaction"]
sigs = tx.get("message", {}).get("accountKeys", [])
signer = sigs[0] if sigs else ""
label = await get_wallet_label(signer)
await ws.send_json({
'type': 'trade', 'chain': 'solana', 'token': mint,
'signer': signer, 'label': label,
'timestamp': int(time.time() * 1000),
})
except asyncio.TimeoutError:
await ws.send_json(
{
"type": "trade",
"chain": "solana",
"token": mint,
"signer": signer,
"label": label,
"timestamp": int(time.time() * 1000),
}
)
except TimeoutError:
try:
await ws.send_json({'type': 'heartbeat', 'timestamp': int(time.time() * 1000)})
await ws.send_json(
{"type": "heartbeat", "timestamp": int(time.time() * 1000)}
)
except Exception:
break
except Exception as e:
try:
await ws.send_json({'error': str(e)})
except Exception:
pass
with contextlib.suppress(Exception):
await ws.send_json({"error": str(e)})
async def poll_gecko_terminal(chain: str, token: str, ws: WebSocket):
@ -94,24 +111,29 @@ async def poll_gecko_terminal(chain: str, token: str, ws: WebSocket):
async with httpx.AsyncClient() as client:
while True:
try:
url = f'{GEKKO_TERMINAL_REST}/networks/{chain}/tokens/{token}/trades'
url = f"{GEKKO_TERMINAL_REST}/networks/{chain}/tokens/{token}/trades"
resp = await client.get(url, timeout=10)
if resp.status_code == 200:
data = resp.json()
for trade in data.get('data', [])[:10]:
txid = trade.get('id')
for trade in data.get("data", [])[:10]:
txid = trade.get("id")
if txid and txid not in seen_txids:
seen_txids.add(txid)
attrs = trade.get('attributes', {})
maker = attrs.get('tx_from_address', '')
attrs = trade.get("attributes", {})
maker = attrs.get("tx_from_address", "")
label = await get_wallet_label(maker)
await ws.send_json({
'type': 'trade', 'chain': chain, 'token': token,
'signer': maker, 'label': label,
'price_usd': attrs.get('price_in_usd'),
'volume_usd': attrs.get('volume_in_usd'),
'timestamp': int(time.time() * 1000),
})
await ws.send_json(
{
"type": "trade",
"chain": chain,
"token": token,
"signer": maker,
"label": label,
"price_usd": attrs.get("price_in_usd"),
"volume_usd": attrs.get("volume_in_usd"),
"timestamp": int(time.time() * 1000),
}
)
await asyncio.sleep(5)
except asyncio.CancelledError:
break
@ -119,24 +141,22 @@ async def poll_gecko_terminal(chain: str, token: str, ws: WebSocket):
await asyncio.sleep(10)
@router.websocket('/ws/trades')
@router.websocket("/ws/trades")
async def trade_websocket(
ws: WebSocket,
chain: str = Query('solana'),
token: str = Query(''),
chain: str = Query("solana"),
token: str = Query(""),
):
await ws.accept()
try:
if chain == 'solana' and token:
if chain == "solana" and token:
await fetch_solana_transactions(token, ws)
elif token:
await poll_gecko_terminal(chain, token, ws)
else:
await ws.send_json({'error': 'token address required'})
await ws.send_json({"error": "token address required"})
except WebSocketDisconnect:
pass
except Exception as e:
try:
await ws.send_json({'error': str(e)})
except Exception:
pass
with contextlib.suppress(Exception):
await ws.send_json({"error": str(e)})

View file

@ -1,8 +1,8 @@
"""Backward-compat shim — moved to app.domains.telegram.rugmunchbot.bot in P4.6."""
from app.domains.telegram.rugmunchbot.bot import * # noqa: F403
from app.domains.telegram.rugmunchbot.bot import ( # noqa: F401
RugMunchBot,
scan_wallet,
check_spam,
cmd_account,
cmd_admin_ban,
@ -43,6 +43,7 @@ from app.domains.telegram.rugmunchbot.bot import ( # noqa: F401
paywall_text,
precheckout,
scan_token,
scan_wallet,
setup_bot_profile,
short_addr,
successful_payment,

View file

@ -13,4 +13,4 @@ from app.domains.scanners.core.engine import scan_token, scan_wallet
from app.domains.scanners.core.models import ScanResult
from app.domains.scanners.core.service import quick_scan_text
__all__ = ["scan_token", "scan_wallet", "ScanResult", "quick_scan_text"]
__all__ = ["ScanResult", "quick_scan_text", "scan_token", "scan_wallet"]

View file

@ -103,6 +103,11 @@ ignore = [
"scripts/**/*.py" = ["ASYNC230", "ASYNC240", "ASYNC210", "ASYNC221", "ASYNC251", "ASYNC109", "S310", "S603", "S607", "S108", "S314", "S102", "PIE810", "SIM102"]
# ClickHouse doesn't support parameterized queries — SQL is constructed from sanitized inputs
"app/domains/telegram/rugmunchbot/**/*.py" = ["TC002", "F841", "S110", "F821", "SIM105", "E402"]
# Prototype routers — suppress noisy rules in active development
"app/routers/pair_tracker.py" = ["S110", "ASYNC221", "S603", "S607", "S608"]
"app/routers/ws_trades.py" = ["S110", "ASYNC221", "S607"]
"app/routers/ai_predict.py" = ["S110"]
"app/routers/admin/watchlist.py" = ["S110", "SIM105"]
"app/data/**/*.py" = ["S608"]
# Legacy code excluded entirely
"_legacy_main.py" = ["ALL"]