merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
386
app/scam_classifier.py
Normal file
386
app/scam_classifier.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""
|
||||
SENTINEL AI — Self-Training Scam Classifier
|
||||
============================================
|
||||
|
||||
Turns 5,000+ historical token scans into a self-improving ML model.
|
||||
Every new scan feeds back into training. The model gets smarter over time.
|
||||
|
||||
Architecture:
|
||||
Historical scans → Feature extraction → XGBoost training → Model on disk
|
||||
New scan → Feature extraction → Model prediction → AI risk score (0-100)
|
||||
Confirmed scam → Weight boost → Retrain trigger
|
||||
|
||||
Features extracted from all 45 enrichments + market data + SENTINEL modules.
|
||||
The model learns which COMBINATIONS of signals predict scams, catching
|
||||
patterns that static rules miss entirely.
|
||||
|
||||
Premium feature: "AI-Powered Risk Score" — ML confidence alongside rules-based score.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger("sentinel.ai")
|
||||
|
||||
MODEL_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "models")
|
||||
MODEL_PATH = os.path.join(MODEL_DIR, "scam_classifier_xgb.pkl")
|
||||
FEATURE_NAMES_PATH = os.path.join(MODEL_DIR, "scam_classifier_features.json")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Feature Extraction — 80+ features from enrichment data
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_features(scan_result: dict[str, Any]) -> dict[str, float]:
|
||||
"""Extract ML features from a scan result. Returns dict of feature_name → value."""
|
||||
f = {}
|
||||
free = scan_result.get("free", scan_result) if isinstance(scan_result, dict) else {}
|
||||
|
||||
# ── Market data features (16 features) ──
|
||||
f["price_usd"] = float(free.get("price_usd", 0) or 0)
|
||||
f["volume_24h"] = float(free.get("volume_24h", 0) or 0)
|
||||
f["liquidity_usd"] = float(free.get("liquidity_usd", 0) or 0)
|
||||
f["market_cap"] = float(free.get("market_cap", 0) or 0)
|
||||
f["age_hours"] = float(free.get("age_hours", 0) or 0)
|
||||
f["price_change_5m"] = float(free.get("price_change_5m", 0) or 0)
|
||||
f["price_change_1h"] = float(free.get("price_change_1h", 0) or 0)
|
||||
f["price_change_24h"] = float(free.get("price_change_24h", 0) or 0)
|
||||
f["tx_count"] = float(free.get("tx_count", 0) or 0)
|
||||
f["volume_to_liquidity_ratio"] = f["volume_24h"] / max(f["liquidity_usd"], 1)
|
||||
f["market_cap_to_liquidity_ratio"] = f["market_cap"] / max(f["liquidity_usd"], 1)
|
||||
f["has_price_data"] = 1.0 if f["price_usd"] > 0 else 0.0
|
||||
f["has_liquidity"] = 1.0 if f["liquidity_usd"] > 0 else 0.0
|
||||
f["is_very_new"] = 1.0 if f["age_hours"] < 1 else 0.0
|
||||
f["is_new"] = 1.0 if f["age_hours"] < 24 else 0.0
|
||||
f["price_volatility"] = abs(f["price_change_1h"]) + abs(f["price_change_24h"])
|
||||
|
||||
# ── Holder features (8 features) ──
|
||||
holders = free.get("holders", {}) or {}
|
||||
if isinstance(holders, dict):
|
||||
f["holder_count"] = float(holders.get("total", holders.get("count", 0)) or 0)
|
||||
f["top10_pct"] = float(holders.get("top_10_percentage", holders.get("top10_pct", 0)) or 0)
|
||||
f["top1_pct"] = float(holders.get("top_1_percentage", holders.get("top1_pct", 0)) or 0)
|
||||
f["holder_concentration_high"] = 1.0 if f["top10_pct"] > 80 else 0.0
|
||||
f["holder_concentration_critical"] = 1.0 if f["top10_pct"] > 95 else 0.0
|
||||
f["has_holder_data"] = 1.0 if f["holder_count"] > 0 else 0.0
|
||||
f["few_holders"] = 1.0 if 0 < f["holder_count"] < 20 else 0.0
|
||||
f["many_holders"] = 1.0 if f["holder_count"] > 500 else 0.0
|
||||
|
||||
# ── Honeypot / trade simulation (8 features) ──
|
||||
sim = free.get("simulation", {}) or {}
|
||||
if isinstance(sim, dict):
|
||||
f["honeypot_detected"] = 1.0 if sim.get("is_honeypot") or sim.get("honeypot") else 0.0
|
||||
f["buy_success"] = 1.0 if sim.get("buy_success", True) else 0.0
|
||||
f["sell_success"] = 1.0 if sim.get("sell_success", True) else 0.0
|
||||
f["buy_tax_pct"] = float(sim.get("buy_tax_pct", 0) or 0)
|
||||
f["sell_tax_pct"] = float(sim.get("sell_tax_pct", 0) or 0)
|
||||
f["tax_high"] = 1.0 if f["sell_tax_pct"] > 10 else 0.0
|
||||
f["tax_extreme"] = 1.0 if f["sell_tax_pct"] > 50 else 0.0
|
||||
f["has_simulation"] = 1.0 if sim else 0.0
|
||||
|
||||
# ── Contract / authority features (6 features) ──
|
||||
contract = free.get("contract_verification", {}) or {}
|
||||
if isinstance(contract, dict):
|
||||
f["contract_verified"] = 1.0 if contract.get("verified") else 0.0
|
||||
f["contract_unverified"] = 1.0 if not contract.get("verified") else 0.0
|
||||
f["is_proxy"] = 1.0 if contract.get("is_proxy") or contract.get("proxy") else 0.0
|
||||
f["mint_authority_renounced"] = 1.0 if free.get("mint_authority") == "renounced" else 0.0
|
||||
f["freeze_authority_exists"] = 1.0 if free.get("freeze_authority") else 0.0
|
||||
f["has_contract_data"] = 1.0 if contract else 0.0
|
||||
|
||||
# ── Deployer features (8 features) ──
|
||||
deployer = free.get("deployer", {}) or {}
|
||||
deep = free.get("deep_deployer", {}) or {}
|
||||
if isinstance(deployer, dict) or isinstance(deep, dict):
|
||||
d = {**deployer, **deep} if isinstance(deep, dict) else deployer
|
||||
f["deployer_known"] = 1.0 if d.get("address") or deployer.get("address") else 0.0
|
||||
f["deployer_risk_score"] = float(d.get("risk_score", 0) or 0)
|
||||
f["deployer_scam_count"] = float(d.get("scam_count", 0) or 0)
|
||||
f["deployer_high_risk"] = 1.0 if f["deployer_risk_score"] > 70 else 0.0
|
||||
f["deployer_critical"] = 1.0 if f["deployer_risk_score"] > 90 else 0.0
|
||||
f["multi_chain_deployer"] = 1.0 if free.get("cross_chain", {}).get("chain_count", 0) > 1 else 0.0
|
||||
f["deployer_chain_count"] = float(
|
||||
free.get("cross_chain", {}).get("chain_count", 1) if isinstance(free.get("cross_chain"), dict) else 1
|
||||
)
|
||||
f["has_deployer_data"] = 1.0 if f["deployer_known"] else 0.0
|
||||
|
||||
# ── LP / liquidity lock (5 features) ──
|
||||
lp = free.get("lp_lock_multi", {}) or {}
|
||||
if isinstance(lp, dict):
|
||||
f["lp_locked"] = 1.0 if lp.get("locked") or lp.get("is_locked") else 0.0
|
||||
f["lp_lock_unconfirmed"] = 1.0 if not f["lp_locked"] and f["liquidity_usd"] > 0 else 0.0
|
||||
f["lp_lock_pct"] = float(lp.get("lock_percentage", lp.get("locked_pct", 0)) or 0)
|
||||
f["lp_has_data"] = 1.0 if lp else 0.0
|
||||
f["no_lp"] = 1.0 if f["liquidity_usd"] == 0 else 0.0
|
||||
|
||||
# ── External API signals (12 features) ──
|
||||
# Birdeye
|
||||
birdeye = free.get("birdeye", {}) or {}
|
||||
f["birdeye_honeypot"] = 1.0 if isinstance(birdeye, dict) and birdeye.get("honeypot") else 0.0
|
||||
f["birdeye_rugpull"] = 1.0 if isinstance(birdeye, dict) and birdeye.get("rugpull") else 0.0
|
||||
|
||||
# Honeypot.is
|
||||
hp = free.get("honeypot_is", {}) or {}
|
||||
f["honeypot_is_detected"] = 1.0 if isinstance(hp, dict) and hp.get("is_honeypot") else 0.0
|
||||
|
||||
# Token Sniffer
|
||||
ts = free.get("token_sniffer", {}) or {}
|
||||
f["tokensniffer_scam"] = 1.0 if isinstance(ts, dict) and ts.get("is_scam") else 0.0
|
||||
f["tokensniffer_score"] = float(ts.get("score", 0) or 0) if isinstance(ts, dict) else 0.0
|
||||
|
||||
# ChainAware AI
|
||||
ca = free.get("chainaware_ai", {}) or {}
|
||||
f["chainaware_rug_risk"] = float(ca.get("rug_probability", 0) or 0) if isinstance(ca, dict) else 0.0
|
||||
|
||||
# GoPlus
|
||||
gp = free.get("goplus", {}) or {}
|
||||
f["goplus_honeypot"] = 1.0 if isinstance(gp, dict) and gp.get("is_honeypot") else 0.0
|
||||
|
||||
# De.Fi
|
||||
df = free.get("defi_scanner", {}) or {}
|
||||
f["defi_honeypot"] = 1.0 if isinstance(df, dict) and df.get("honeypot") else 0.0
|
||||
f["defi_ai_score"] = float(df.get("aiScore", 50) or 50) if isinstance(df, dict) else 50.0
|
||||
|
||||
# Copycat
|
||||
cc = free.get("copycat_check", {}) or {}
|
||||
f["is_copycat"] = 1.0 if isinstance(cc, dict) and cc.get("is_copycat") else 0.0
|
||||
|
||||
# Volume anomaly
|
||||
va = free.get("volume_anomaly", {}) or {}
|
||||
f["volume_manipulation"] = 1.0 if isinstance(va, dict) and va.get("manipulation_detected") else 0.0
|
||||
|
||||
# ── RAG / scam database features (6 features) ──
|
||||
rag = free.get("rag_scam_check", {}) or {}
|
||||
if isinstance(rag, dict):
|
||||
f["rag_scam_match"] = 1.0 if rag.get("match_found") or rag.get("is_scam") else 0.0
|
||||
f["rag_similarity"] = float(rag.get("similarity", 0) or 0)
|
||||
f["rag_similarity_high"] = 1.0 if f["rag_similarity"] > 0.8 else 0.0
|
||||
f["rag_matches_count"] = float(rag.get("match_count", 0) or 0)
|
||||
f["known_scam_address"] = 1.0 if rag.get("is_known_scam") else 0.0
|
||||
f["has_rag_data"] = 1.0 if rag else 0.0
|
||||
|
||||
# ── Social / sentiment (5 features) ──
|
||||
sentiment = free.get("sentiment", {}) or {}
|
||||
f["social_volume"] = float(sentiment.get("mention_count", 0) or 0) if isinstance(sentiment, dict) else 0.0
|
||||
f["sentiment_score"] = float(sentiment.get("score", 0) or 0) if isinstance(sentiment, dict) else 0.0
|
||||
santiment = free.get("santiment", {}) or {}
|
||||
f["santiment_hype"] = 1.0 if isinstance(santiment, dict) and santiment.get("hype_detected") else 0.0
|
||||
f["santiment_viral_low_vol"] = 1.0 if isinstance(santiment, dict) and santiment.get("viral_low_volume") else 0.0
|
||||
f["has_social_data"] = 1.0 if sentiment or santiment else 0.0
|
||||
|
||||
# ── Chain/network features (4 features) ──
|
||||
chain = scan_result.get("chain", "").lower()
|
||||
f["chain_solana"] = 1.0 if chain == "solana" else 0.0
|
||||
f["chain_ethereum"] = 1.0 if chain == "ethereum" else 0.0
|
||||
f["chain_bsc"] = 1.0 if chain == "bsc" else 0.0
|
||||
f["chain_base"] = 1.0 if chain == "base" else 0.0
|
||||
|
||||
# ── Nansen / Arkham (4 features) ──
|
||||
nansen = free.get("nansen", {}) or {}
|
||||
f["nansen_low_holders"] = 1.0 if isinstance(nansen, dict) and nansen.get("low_holders") else 0.0
|
||||
f["nansen_net_outflow"] = 1.0 if isinstance(nansen, dict) and nansen.get("net_outflow") else 0.0
|
||||
arkham = free.get("arkham", {}) or {}
|
||||
f["arkham_scam_entity"] = 1.0 if isinstance(arkham, dict) and arkham.get("scam_entity") else 0.0
|
||||
f["has_premium_data"] = 1.0 if nansen or arkham else 0.0
|
||||
|
||||
return f
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Model Training
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ScamClassifier:
|
||||
"""XGBoost classifier trained on historical scan data."""
|
||||
|
||||
def __init__(self):
|
||||
self.model = None
|
||||
self.feature_names: list[str] = []
|
||||
self.training_samples: int = 0
|
||||
self.accuracy: float = 0.0
|
||||
self.last_trained: str | None = None
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
"""Load model from disk if available."""
|
||||
if os.path.exists(MODEL_PATH):
|
||||
try:
|
||||
with open(MODEL_PATH, "rb") as f:
|
||||
self.model = pickle.load(f)
|
||||
if os.path.exists(FEATURE_NAMES_PATH):
|
||||
with open(FEATURE_NAMES_PATH) as f:
|
||||
meta = json.load(f)
|
||||
self.feature_names = meta.get("features", [])
|
||||
self.training_samples = meta.get("samples", 0)
|
||||
self.accuracy = meta.get("accuracy", 0.0)
|
||||
self.last_trained = meta.get("trained_at")
|
||||
logger.info(
|
||||
f"Loaded scam classifier: {self.training_samples} samples, "
|
||||
f"{len(self.feature_names)} features, {self.accuracy:.1%} accuracy"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load classifier: {e}")
|
||||
self.model = None
|
||||
|
||||
def _save(self):
|
||||
"""Persist model and metadata to disk."""
|
||||
os.makedirs(MODEL_DIR, exist_ok=True)
|
||||
with open(MODEL_PATH, "wb") as f:
|
||||
pickle.dump(self.model, f)
|
||||
with open(FEATURE_NAMES_PATH, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"features": self.feature_names,
|
||||
"samples": self.training_samples,
|
||||
"accuracy": self.accuracy,
|
||||
"trained_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
f,
|
||||
)
|
||||
logger.info(f"Saved classifier: {self.training_samples} samples, {self.accuracy:.1%} accuracy")
|
||||
|
||||
def train(self, scans: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Train on historical scan data. Each scan must have 'is_scam' label."""
|
||||
try:
|
||||
from xgboost import XGBClassifier
|
||||
except ImportError:
|
||||
logger.warning("XGBoost not installed. Install with: pip install xgboost")
|
||||
return {"status": "error", "error": "xgboost not installed"}
|
||||
|
||||
# Extract features and labels
|
||||
X_list = []
|
||||
y_list = []
|
||||
|
||||
for scan in scans:
|
||||
features = extract_features(scan)
|
||||
is_scam = scan.get("is_scam", False) or scan.get("verdict") == "scam"
|
||||
|
||||
if not X_list: # First sample — record feature names
|
||||
self.feature_names = sorted(features.keys())
|
||||
|
||||
# Build feature vector in consistent order
|
||||
row = [features.get(name, 0.0) for name in self.feature_names]
|
||||
X_list.append(row)
|
||||
y_list.append(1 if is_scam else 0)
|
||||
|
||||
if len(X_list) < 10:
|
||||
return {"status": "error", "error": f"Need at least 10 samples, got {len(X_list)}"}
|
||||
|
||||
if sum(y_list) < 2:
|
||||
return {"status": "error", "error": "Need at least 2 scam samples for training"}
|
||||
|
||||
X = np.array(X_list, dtype=np.float32)
|
||||
y = np.array(y_list, dtype=np.int32)
|
||||
|
||||
# Handle NaN/Inf
|
||||
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
# Train
|
||||
self.model = XGBClassifier(
|
||||
n_estimators=100,
|
||||
max_depth=4,
|
||||
learning_rate=0.1,
|
||||
subsample=0.8,
|
||||
colsample_bytree=0.8,
|
||||
scale_pos_weight=(len(y) - sum(y)) / max(sum(y), 1), # Handle class imbalance
|
||||
random_state=42,
|
||||
use_label_encoder=False,
|
||||
eval_metric="logloss",
|
||||
)
|
||||
self.model.fit(X, y)
|
||||
|
||||
# Evaluate
|
||||
train_pred = self.model.predict(X)
|
||||
self.accuracy = float((train_pred == y).mean())
|
||||
self.training_samples = len(X_list)
|
||||
self.last_trained = datetime.now(UTC).isoformat()
|
||||
|
||||
# Feature importance
|
||||
importance = {}
|
||||
if hasattr(self.model, "feature_importances_"):
|
||||
for name, imp in zip(self.feature_names, self.model.feature_importances_, strict=False):
|
||||
importance[name] = round(float(imp), 4)
|
||||
|
||||
self._save()
|
||||
|
||||
return {
|
||||
"status": "trained",
|
||||
"samples": self.training_samples,
|
||||
"scam_samples": int(sum(y)),
|
||||
"features": len(self.feature_names),
|
||||
"accuracy": round(self.accuracy, 3),
|
||||
"feature_importance": dict(sorted(importance.items(), key=lambda x: -x[1])[:15]),
|
||||
}
|
||||
|
||||
def predict(self, scan_result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Predict scam probability for a new scan."""
|
||||
if self.model is None:
|
||||
return {"status": "no_model", "probability": None, "confidence": 0}
|
||||
|
||||
features = extract_features(scan_result)
|
||||
row = np.array([[features.get(name, 0.0) for name in self.feature_names]], dtype=np.float32)
|
||||
row = np.nan_to_num(row, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
try:
|
||||
proba = self.model.predict_proba(row)[0]
|
||||
scam_prob = float(proba[1]) if len(proba) > 1 else float(proba[0])
|
||||
|
||||
# Confidence based on training data quality
|
||||
confidence = min(self.accuracy * 100, 95) if self.accuracy > 0 else 50
|
||||
|
||||
# Get top contributing features
|
||||
contributions = {}
|
||||
if hasattr(self.model, "feature_importances_"):
|
||||
for name, imp in zip(self.feature_names, self.model.feature_importances_, strict=False):
|
||||
if features.get(name, 0) != 0 and imp > 0.01:
|
||||
contributions[name] = {
|
||||
"value": features.get(name, 0),
|
||||
"importance": round(float(imp), 3),
|
||||
}
|
||||
|
||||
# Risk level
|
||||
if scam_prob > 0.8:
|
||||
risk_level = "critical"
|
||||
elif scam_prob > 0.6:
|
||||
risk_level = "high"
|
||||
elif scam_prob > 0.4:
|
||||
risk_level = "medium"
|
||||
elif scam_prob > 0.2:
|
||||
risk_level = "low"
|
||||
else:
|
||||
risk_level = "safe"
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"probability": round(scam_prob, 4),
|
||||
"risk_level": risk_level,
|
||||
"ai_risk_score": round(scam_prob * 100),
|
||||
"confidence": round(confidence, 1),
|
||||
"model_samples": self.training_samples,
|
||||
"model_accuracy": round(self.accuracy, 3),
|
||||
"top_signals": dict(sorted(contributions.items(), key=lambda x: -x[1]["importance"])[:8]),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Prediction failed: {e}")
|
||||
return {"status": "error", "probability": None, "error": str(e)}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Singleton
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
_classifier: ScamClassifier | None = None
|
||||
|
||||
|
||||
def get_classifier() -> ScamClassifier:
|
||||
global _classifier
|
||||
if _classifier is None:
|
||||
_classifier = ScamClassifier()
|
||||
return _classifier
|
||||
Loading…
Add table
Add a link
Reference in a new issue