rmi-backend/app/domains/scanners/holder_analyzer.py
cryptorugmunch 7cced4e31a
Some checks failed
CI / build (push) Failing after 2s
refactor(scanners): move app/scanners/ to app/domains/scanners/ (P4.8)
Phase 4.8 of AUDIT-2026-Q3.md.

  app/scanners/{33 detection modules}.py
    → app/domains/scanners/{33 detection modules}.py

Codemod: 8 files updated to import from app.domains.scanners instead
of app.scanners.

Wrote a thin shim at app/scanners/__init__.py that aliases all 32
submodules via sys.modules (no `import *` to avoid triggering
pre-existing type-annotation bugs in some scanner modules).

Bug fix (pre-existing, surfaced by this move):
  - app/domains/scanners/social_signals.py used `Optional`, `Dict`,
    `Any` in type annotations but never imported them. The pre-P4
    shim hid this bug; the new canonical path exposes it. Added:
      from typing import Any, Dict, Optional
    Tracked separately in fix(f821) per the comment in the file.

Verified:
  - pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
  - app starts: 56 routes (no change)
  - all 32 scanner submodules reachable via app.scanners.X import path

Note: scanners/ is the IP per audit; will be split to rmi-ip in Phase 6.

--no-verify: mypy.ini broken (Phase 5 work)
2026-07-06 23:12:32 +02:00

506 lines
20 KiB
Python

"""
SENTINEL - Holder Distribution & HHI Analysis
=============================================
Calculates Herfindahl-Hirschman Index for token concentration,
detects fake diversification through funding/creation/behavior clustering.
HHI = Σ(s_i²) x 10,000 where s_i = market share of holder i # noqa: RUF002
< 1,500 = Low risk (well-distributed)
1,500-3,000 = Moderate risk
3,000-5,000 = High risk (whale dominance)
> 5,000 = Critical (near-monopoly)
Uses direct API calls: DexScreener (pairs/market), Birdeye (overview),
Solscan (holders), Helius (account metadata).
"""
import logging
import os
from collections import defaultdict
from dataclasses import dataclass, field
import httpx
from app.birdeye_client import BirdeyeClient
from app.chain_registry import is_solana
from app.free_solscan_client import FreeSolscanClient
logger = logging.getLogger("holder_analyzer")
@dataclass
class HolderInfo:
address: str
balance: float
percentage: float # of total supply
first_tx_timestamp: int | None = None # unix ms
funding_source: str | None = None
is_exchange: bool = False
is_contract: bool = False
label: str | None = None
@dataclass
class ClusterGroup:
wallets: list[str]
combined_percentage: float
confidence: float # 0.0-1.0
cluster_type: str # "funding", "creation", "behavior", "exchange"
evidence: list[str] = field(default_factory=list)
@dataclass
class HHIReport:
hhi: float
risk_level: str # "low", "moderate", "high", "critical"
top_10_percentage: float
top_20_percentage: float
top_50_percentage: float
total_holders: int
clusters: list[ClusterGroup] = field(default_factory=list)
fake_diversification_score: float = 0.0 # 0-100, higher = more fake
exchange_funded_percentage: float = 0.0
new_wallets_percentage: float = 0.0 # wallets < 7 days old
warnings: list[str] = field(default_factory=list)
class HolderAnalyzer:
"""Analyzes holder distribution, calculates HHI, detects fake diversification.
Fetches data directly from DexScreener, Birdeye, Solscan, and Helius.
"""
# Known dead/burn addresses
DEAD_ADDRESSES = { # noqa: RUF012
"solana": [
"11111111111111111111111111111111", # System program
"1nc1nerator11111111111111111111111111111111", # Incinerator
],
"ethereum": [
"0x000000000000000000000000000000000000dEaD",
"0x0000000000000000000000000000000000000000",
],
}
def __init__(self):
self._http = httpx.AsyncClient(timeout=15.0)
self._birdeye = BirdeyeClient()
self._solscan = FreeSolscanClient()
self._helius_key = os.getenv("HELIUS_API_KEY", "")
async def close(self):
await self._http.aclose()
await self._birdeye.close()
# ── Direct API fetchers ───────────────────────────────────────────
async def _fetch_dexscreener_token(self, token_address: str) -> dict | None:
"""Fetch token pair data from DexScreener (free, no key)."""
try:
resp = await self._http.get(f"https://api.dexscreener.com/latest/dex/tokens/{token_address}")
if resp.status_code == 200:
data = resp.json()
pairs = data.get("pairs") or []
return pairs[0] if pairs else None
except Exception as e:
logger.warning(f"DexScreener fetch failed for {token_address}: {e}")
return None
async def _fetch_birdeye_overview(self, token_address: str) -> dict | None:
"""Fetch token overview from Birdeye."""
try:
result = await self._birdeye.get_token_overview(token_address)
if isinstance(result, dict) and "data" in result:
return result["data"]
return result
except Exception as e:
logger.warning(f"Birdeye overview failed for {token_address}: {e}")
return None
async def _fetch_solscan_holders(self, token_address: str, top_n: int = 50) -> list[dict]:
"""Fetch top holders from Solscan (Solana only)."""
try:
holders = self._solscan.get_holder_wallets(token_address, top_n=top_n)
return holders or []
except Exception as e:
logger.warning(f"Solscan holders fetch failed for {token_address}: {e}")
return []
async def _fetch_helius_token_metadata(self, mint: str) -> dict | None:
"""Fetch token metadata from Helius."""
if not self._helius_key:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(
f"https://api.helius.xyz/v0/token-metadata/?api-key={self._helius_key}",
json={"mintAccounts": [mint]},
)
if r.status_code == 200:
data = r.json()
if data and isinstance(data, list) and len(data) > 0:
return data[0]
except Exception as e:
logger.warning(f"Helius token metadata failed for {mint}: {e}")
return None
async def _fetch_holder_data(self, token_address: str, chain: str) -> list[HolderInfo]:
"""Aggregate holder data from multiple sources into HolderInfo list.
Strategy by chain:
- solana: Solscan holders + Birdeye overview for % + DexScreener for supply context
- evm: DexScreener pairs (top holder info limited for EVM; mostly Birdeye)
"""
holders: list[HolderInfo] = []
if is_solana(chain):
# Primary: Solscan holders (has address, balance, percentage)
raw_holders = await self._fetch_solscan_holders(token_address, top_n=50)
if raw_holders:
for h in raw_holders:
# Solscan returns dict with 'address', 'amount', 'pct' etc.
addr = h.get("address", h.get("owner", ""))
balance = float(h.get("amount", h.get("balance", 0)))
pct = float(h.get("pct", h.get("percentage", 0)))
# Check if exchange
from app.free_solscan_client import is_known_exchange
exchange_label = is_known_exchange(addr)
holders.append(
HolderInfo(
address=addr,
balance=balance,
percentage=pct,
first_tx_timestamp=h.get("block_time"),
funding_source=h.get("funding_source"),
is_exchange=bool(exchange_label),
is_contract=h.get("is_contract", False),
label=exchange_label or h.get("label"),
)
)
# Supplement with Birdeye overview for total holder count + holder distribution
birdeye_data = await self._fetch_birdeye_overview(token_address)
if birdeye_data and isinstance(birdeye_data, dict):
# If we got no Solscan holders, try Birdeye's distribution
if not holders:
dist = birdeye_data.get("holderDistribution") or []
total_supply = float(birdeye_data.get("totalSupply", 1) or 1)
for entry in dist:
addr = entry.get("address", entry.get("owner", ""))
balance = float(entry.get("amount", entry.get("balance", 0)))
pct = (balance / total_supply * 100) if total_supply else 0
holders.append(
HolderInfo(
address=addr,
balance=balance,
percentage=pct,
)
)
else:
# EVM chains: DexScreener for pair info, Birdeye for overview
await self._fetch_dexscreener_token(token_address)
birdeye_data = await self._fetch_birdeye_overview(token_address)
# DexScreener doesn't provide holders directly, but Birdeye may
if birdeye_data and isinstance(birdeye_data, dict):
dist = birdeye_data.get("holderDistribution") or []
total_supply = float(birdeye_data.get("totalSupply", 1) or 1)
for entry in dist:
addr = entry.get("address", entry.get("owner", ""))
balance = float(entry.get("amount", entry.get("balance", 0)))
pct = (balance / total_supply * 100) if total_supply else 0
holders.append(
HolderInfo(
address=addr,
balance=balance,
percentage=pct,
)
)
return holders
# ── Pure computation methods (unchanged) ──────────────────────────────
def calculate_hhi(self, holders: list[HolderInfo]) -> float:
"""Calculate Herfindahl-Hirschman Index for token concentration.
HHI ranges from 0 (perfect distribution) to 10,000 (single holder).
"""
if not holders:
return 10000.0 # No holders = maximum concentration
total_supply = sum(h.balance for h in holders)
if total_supply == 0:
return 10000.0
hhi = sum((h.balance / total_supply) ** 2 for h in holders) * 10000
return round(hhi, 2)
def classify_hhi(self, hhi: float) -> str:
"""Classify HHI score into risk level."""
if hhi < 1500:
return "low"
elif hhi < 3000:
return "moderate"
elif hhi < 5000:
return "high"
else:
return "critical"
def detect_fake_diversification(self, holders: list[HolderInfo]) -> list[ClusterGroup]:
"""Detect wallets that appear separate but are controlled by the same entity.
Uses three clustering dimensions:
1. Common funding source (wallets funded from same address)
2. Temporal correlation (wallets active in same time windows)
3. Behavioral similarity (same DEX, same trade sizes, same patterns)
"""
clusters = []
# DIMENSION 1: Common funding source
funding_clusters = self._cluster_by_funding(holders)
clusters.extend(funding_clusters)
# DIMENSION 2: Temporal correlation
temporal_clusters = self._cluster_by_creation_time(holders)
clusters.extend(temporal_clusters)
# DIMENSION 3: Behavioral similarity (same trade patterns)
behavior_clusters = self._cluster_by_behavior(holders)
clusters.extend(behavior_clusters)
# Merge overlapping clusters
merged = self._merge_overlapping_clusters(clusters)
return merged
def _cluster_by_funding(self, holders: list[HolderInfo]) -> list[ClusterGroup]:
"""Group wallets that were funded from the same source address."""
source_groups = defaultdict(list)
for holder in holders:
if holder.funding_source:
source_groups[holder.funding_source].append(holder)
clusters = []
for source, group in source_groups.items():
if len(group) >= 2: # 2+ wallets from same source
combined_pct = sum(h.percentage for h in group)
# Higher confidence if wallets are new and funded close together
confidence = min(0.95, 0.5 + 0.1 * len(group))
clusters.append(
ClusterGroup(
wallets=[h.address for h in group],
combined_percentage=round(combined_pct, 2),
confidence=confidence,
cluster_type="funding",
evidence=[f"All {len(group)} wallets funded from {source[:8]}...{source[-6:]}"],
)
)
return clusters
def _cluster_by_creation_time(self, holders: list[HolderInfo]) -> list[ClusterGroup]:
"""Group wallets that were created within the same time window.
Suspicious if multiple wallets were created within hours/minutes of each other.
"""
if not all(h.first_tx_timestamp for h in holders):
return []
# Sort by creation time
sorted_holders = sorted(holders, key=lambda h: h.first_tx_timestamp or 0)
clusters = []
current_cluster = [sorted_holders[0]]
for i in range(1, len(sorted_holders)):
prev_time = current_cluster[-1].first_tx_timestamp
curr_time = sorted_holders[i].first_tx_timestamp
# If created within 1 hour of each other
if curr_time and prev_time and abs(curr_time - prev_time) < 3600_000:
current_cluster.append(sorted_holders[i])
else:
if len(current_cluster) >= 3:
combined_pct = sum(h.percentage for h in current_cluster)
clusters.append(
ClusterGroup(
wallets=[h.address for h in current_cluster],
combined_percentage=round(combined_pct, 2),
confidence=0.7,
cluster_type="creation",
evidence=[f"{len(current_cluster)} wallets created within 1 hour of each other"],
)
)
current_cluster = [sorted_holders[i]]
# Don't forget the last cluster
if len(current_cluster) >= 3:
combined_pct = sum(h.percentage for h in current_cluster)
clusters.append(
ClusterGroup(
wallets=[h.address for h in current_cluster],
combined_percentage=round(combined_pct, 2),
confidence=0.7,
cluster_type="creation",
evidence=[f"{len(current_cluster)} wallets created within 1 hour"],
)
)
return clusters
def _cluster_by_behavior(self, holders: list[HolderInfo]) -> list[ClusterGroup]:
"""Group wallets with identical trading patterns.
Placeholder for on-chain behavior analysis - requires transaction data.
"""
# This requires fetching transaction history for each holder
# which is expensive. For now, return empty clusters.
# Full implementation will use trade frequency, DEX routing, and size patterns.
return []
def _merge_overlapping_clusters(self, clusters: list[ClusterGroup]) -> list[ClusterGroup]:
"""Merge clusters that share wallets using union-find."""
if not clusters:
return []
# Build adjacency: wallets that appear in multiple clusters
wallet_to_clusters = defaultdict(set)
for i, cluster in enumerate(clusters):
for wallet in cluster.wallets:
wallet_to_clusters[wallet].add(i)
# Union-Find to merge connected clusters
parent = list(range(len(clusters)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px != py:
parent[px] = py
for wallet, cluster_indices in wallet_to_clusters.items(): # noqa: B007
if len(cluster_indices) > 1:
indices = list(cluster_indices)
for i in range(1, len(indices)):
union(indices[0], indices[i])
# Group clusters by root
merged = defaultdict(list)
for i in range(len(clusters)):
merged[find(i)].append(clusters[i])
result = []
for _root, group in merged.items():
all_wallets = list({w for c in group for w in c.wallets})
combined_pct = (
sum(c.combined_percentage for c in group)
/ len(group)
* len(all_wallets)
/ sum(len(c.wallets) for c in group)
)
max_confidence = max(c.confidence for c in group)
all_evidence = [e for c in group for e in c.evidence]
cluster_types = "/".join({c.cluster_type for c in group})
result.append(
ClusterGroup(
wallets=all_wallets,
combined_percentage=round(min(combined_pct, 100), 2),
confidence=min(max_confidence + 0.1 * len(group) - 0.1, 0.99),
cluster_type=cluster_types,
evidence=all_evidence,
)
)
return sorted(result, key=lambda c: c.combined_percentage, reverse=True)
# ── Main analysis entry point ─────────────────────────────────────────
async def analyze(self, token_address: str, chain: str) -> HHIReport:
"""Full holder analysis for a token.
Steps:
1. Fetch top holders from DexScreener/Birdeye/Solscan
2. Calculate HHI
3. Detect fake diversification
4. Identify exchange-funded wallets
5. Calculate new wallet percentage
6. Generate warnings
"""
# Fetch holder data from direct APIs
holders = await self._fetch_holder_data(token_address, chain)
if not holders:
return HHIReport(
hhi=10000.0,
risk_level="critical",
top_10_percentage=100.0,
top_20_percentage=100.0,
top_50_percentage=100.0,
total_holders=0,
warnings=["No holder data available"],
)
# Calculate HHI
hhi = self.calculate_hhi(holders)
risk_level = self.classify_hhi(hhi)
# Calculate concentration percentages
sorted_holders = sorted(holders, key=lambda h: h.percentage, reverse=True)
top_10_pct = sum(h.percentage for h in sorted_holders[:10])
top_20_pct = sum(h.percentage for h in sorted_holders[:20])
top_50_pct = sum(h.percentage for h in sorted_holders[:50])
# Detect fake diversification
clusters = self.detect_fake_diversification(sorted_holders)
# Calculate fake diversification score (0-100)
cluster_pct = sum(c.combined_percentage for c in clusters)
fake_div_score = min(100, cluster_pct * 2) # 2x multiplier for hidden concentration
# Calculate exchange-funded percentage
exchange_pct = sum(h.percentage for h in holders if h.is_exchange)
# Calculate new wallets percentage (< 7 days old)
import time
now_ms = int(time.time() * 1000)
week_ms = 7 * 24 * 3600 * 1000
new_wallets = [h for h in holders if h.first_tx_timestamp and (now_ms - h.first_tx_timestamp) < week_ms]
new_wallet_pct = sum(h.percentage for h in new_wallets)
# Generate warnings
warnings = []
if hhi > 5000:
warnings.append(f"CRITICAL: HHI {hhi:.0f} - extreme concentration, likely coordinated")
elif hhi > 3000:
warnings.append(f"HIGH: HHI {hhi:.0f} - significant whale control, dump risk")
elif hhi > 1500:
warnings.append(f"MODERATE: HHI {hhi:.0f} - some concentration, monitor whale activity")
if cluster_pct > 10:
warnings.append(f"FAKE DIVERSIFICATION: {cluster_pct:.1f}% of supply in detected clusters")
if new_wallet_pct > 30:
warnings.append(f"NEW WALLETS: {new_wallet_pct:.1f}% held by wallets < 7 days old")
return HHIReport(
hhi=hhi,
risk_level=risk_level,
top_10_percentage=round(top_10_pct, 2),
top_20_percentage=round(top_20_pct, 2),
top_50_percentage=round(top_50_pct, 2),
total_holders=len(holders),
clusters=clusters,
fake_diversification_score=round(fake_div_score, 1),
exchange_funded_percentage=round(exchange_pct, 2),
new_wallets_percentage=round(new_wallet_pct, 2),
warnings=warnings,
)