rmi-backend/app/wallet_memory/risk_scoring.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

211 lines
7.1 KiB
Python

"""
Wallet Risk Scoring - Composite risk across all intelligence sources.
=====================================================================
Score range: 0-100
Components:
Label risk (0-40) - from label categories (scam, sanctioned, etc.)
Cluster risk (0-30) - from entity membership (linked scam wallets)
Deployer history (0-30) - from past token deployments (rug rate, lifespan)
Risk levels:
0-19 minimal
20-39 low
40-59 medium
60-79 high
80-100 critical
"""
import logging
from typing import Any
logger = logging.getLogger("wallet_memory.risk_scoring")
class RiskScorer:
"""
Composite risk scorer combining label, cluster, and deployer signals.
"""
def __init__(self, storage=None, labels=None, clustering=None):
self._storage = storage
self._labels = labels
self._clustering = clustering
async def score(self, address: str, chain: str, intel: dict | None = None) -> dict[str, Any]:
"""
Calculate composite risk score for a wallet.
Args:
address: wallet address
chain: chain ID
intel: pre-fetched intelligence dict (avoids duplicate lookups)
Returns:
{"score": float 0-100, "level": str, "components": {...}, "warnings": [...]}
"""
address.lower().strip()
intel = intel or {}
components = {
"label_risk": 0.0,
"cluster_risk": 0.0,
"deployer_risk": 0.0,
}
warnings = []
# ── Component 1: Label risk (0-40) ──────────────────────────
label_risk = intel.get("risk_contribution", 0)
if label_risk == 0:
# Recalculate from labels if not provided
label_risk = self._score_labels(intel)
components["label_risk"] = min(40, label_risk)
if any(s.get("type") in ("sanctioned",) for s in intel.get("scam_associations", [])):
components["label_risk"] = 40
warnings.append("Address is OFAC sanctioned")
if any(s.get("type") == "known_scam" for s in intel.get("scam_associations", [])):
components["label_risk"] = max(components["label_risk"], 30)
warnings.append("Address flagged as known scam")
# ── Component 2: Cluster risk (0-30) ──────────────────────
cluster_risk = self._score_cluster(intel)
components["cluster_risk"] = min(30, cluster_risk)
linked = intel.get("linked_wallets", [])
if len(linked) >= 5:
warnings.append(f"Linked to {len(linked)} other wallets in entity")
# ── Component 3: Deployer history risk (0-30) ─────────────
deployer_risk = self._score_deployer_history(intel)
components["deployer_risk"] = min(30, deployer_risk)
history = intel.get("deployer_history", [])
scam_tokens = [t for t in history if t.get("is_scam_related")]
if scam_tokens:
warnings.append(f"Deployer has {len(scam_tokens)} flagged token(s)")
# ── Composite ────────────────────────────────────────────
total = components["label_risk"] + components["cluster_risk"] + components["deployer_risk"]
total = max(0, min(100, total))
level = self._level(total)
return {
"score": round(total, 1),
"level": level,
"components": components,
"warnings": warnings,
}
def _score_labels(self, intel: dict) -> float:
"""Score label risk from intelligence dict."""
score = 0.0
labels = intel.get("labels", [])
for lbl in labels:
cat = lbl.get("category", "").lower()
if cat == "sanctioned":
score += 40
elif cat in ("scam", "phishing", "drainer"):
score += 20
elif cat == "mev":
score += 5
# Scam associations
for _assoc in intel.get("scam_associations", []):
score += 15
return min(40, score)
def _score_cluster(self, intel: dict) -> float:
"""Score risk from entity/cluster membership."""
score = 0.0
linked = intel.get("linked_wallets", [])
if not linked:
return 0.0
# More linked wallets = higher entity complexity
# But it only adds risk if any linked wallet has scam association
for _w in linked:
# If any linked wallet appears in scam_associations
pass # We'd need to recursively check each linked wallet
# Heuristic: entity size increases risk slightly
entity_count = len(linked)
if entity_count >= 10:
score += 10
elif entity_count >= 5:
score += 5
elif entity_count >= 3:
score += 2
# Confidence of entity membership adds to risk
confidence = intel.get("confidence", 0)
if confidence >= 0.8:
score += 10 # High-confidence entity membership
elif confidence >= 0.6:
score += 5
# Cross-chain presence adds risk (same entity on multiple chains)
cross_chain = intel.get("cross_chain_presence", [])
if len(cross_chain) >= 2:
score += 10
# Could indicate a sophisticated operator
elif len(cross_chain) == 1:
score += 3
return min(30, score)
def _score_deployer_history(self, intel: dict) -> float:
"""Score risk from deployer history."""
history = intel.get("deployer_history", [])
if not history:
return 0.0
total_deployed = len(history)
scam_count = sum(1 for t in history if t.get("is_scam_related"))
high_risk_count = sum(1 for t in history if t.get("scan_risk_score", 0) >= 0.7)
score = 0.0
# Rug rate
if total_deployed > 0:
rug_rate = (scam_count + high_risk_count) / total_deployed
if rug_rate >= 0.8 and total_deployed >= 3:
score += 25 # Serial rugger
elif rug_rate >= 0.5:
score += 15 # High risk deployer
elif rug_rate >= 0.3:
score += 8 # Suspicious
# Average lifespan
avg_lifespan = 0
lifespans = [t.get("lifespan_days", 0) for t in history if t.get("lifespan_days", 0) > 0]
if lifespans:
avg_lifespan = sum(lifespans) / len(lifespans)
if avg_lifespan > 0 and avg_lifespan < 7 and total_deployed >= 2:
score += 10 # Short-lived tokens = rug pattern
elif avg_lifespan < 30 and total_deployed >= 3:
score += 5
# Volume of deployments
if total_deployed >= 10:
score += 5 # Prolific deployer
return min(30, score)
@staticmethod
def _level(score: float) -> str:
if score >= 80:
return "critical"
elif score >= 60:
return "high"
elif score >= 40:
return "medium"
elif score >= 20:
return "low"
return "minimal"