rmi-backend/app/domains/scanners/address_labeler.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

689 lines
25 KiB
Python

"""
SENTINEL - Address Labeler
==========================
Resolves wallet/contract addresses to known labels from multiple sources:
1. Etherscan API - public address labels
2. RolodETH - public GitHub label database
3. walletLabels.xyz - public address label API
4. bluepages.fyi - public address label API
5. Internal RAG - known_scams and wallet_profiles databases
Combines all label sources into a unified response with risk assessment.
"""
import asyncio
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import httpx
from app.chain_registry import CHAINS, is_evm
from app.domains.scanners.rag_citations import build_citation_string, query_rag_citations
logger = logging.getLogger("address_labeler")
# ── RolodETH cache (avoid re-fetching on every call) ──────────────────
_ROLODETH_CACHE: dict[str, list[dict]] | None = None
_ROLODETH_LAST_FETCH: float = 0.0
_ROLODETH_TTL = 3600 # 1 hour cache
# ── Known scam patterns / addresses (built-in database) ─────────────
BUILTIN_KNOWN_SCAMS: dict[str, dict] = {
# Notorious rug-pull deployers and scam addresses
"0x0000000000000000000000000000000000000000": {"label": "Null address", "category": "burn"},
"0xdead000000000000000000000000000000000000": {"label": "Dead address", "category": "burn"},
}
# Known exchange deposit addresses (used by is_exchange check)
# We already have cex_hot_wallets in chain_registry, but this provides
# additional context for label matching
BUILTIN_EXCHANGE_LABELS: dict[str, str] = {
# Common EOA labels
}
# ── Report dataclass ─────────────────────────────────────────────────
@dataclass
class LabelEntry:
"""A single label from a specific source."""
source: str
label: str
category: str = "" # exchange, mev, scam, defi, wallet, contract, bridge, etc.
confidence: float = 1.0
@dataclass
class AddressLabelReport:
"""Aggregated address label report."""
address: str
chain: str
labels: list[dict[str, Any]] = field(default_factory=list)
primary_label: str = ""
primary_category: str = ""
# Flags
is_exchange: bool = False
is_mev_bot: bool = False
is_known_scam: bool = False
is_defi_protocol: bool = False
is_bridge: bool = False
isDeployer: bool = False
# Risk
risk_score: float = 0.0
risk_level: str = "LOW"
warnings: list[str] = field(default_factory=list)
red_flags: list[str] = field(default_factory=list)
citations: list[dict[str, Any]] = field(default_factory=list)
# ── Labeler class ────────────────────────────────────────────────────
class AddressLabeler:
"""Resolve addresses to known labels from multiple sources in parallel."""
def __init__(self):
self._http = httpx.AsyncClient(timeout=15.0)
self._etherscan_key = os.getenv("ETHERSCAN_API_KEY", "")
# ── Source 1: Etherscan ─────────────────────────────────────────
async def _query_etherscan(self, address: str, chain: str) -> list[LabelEntry]:
"""Fetch address label from Etherscan account API."""
entries = []
chain_cfg = CHAINS.get(chain)
if not chain_cfg or not chain_cfg.explorer_api_url:
return entries
key = chain_cfg.get_explorer_api_key() or self._etherscan_key
if not key:
return entries
try:
# Etherscan account API returns public name/tag for labeled addresses
url = f"{chain_cfg.explorer_api_url}?module=account&action=addressinfo&address={address}&apikey={key}"
resp = await self._http.get(url)
if resp.status_code == 200:
body = resp.json()
result = body.get("result", {})
if isinstance(result, dict):
name = result.get("name", "")
tag = result.get("tag", "")
if name:
entries.append(
LabelEntry(
source="etherscan",
label=name,
category=self._infer_category(name + " " + tag),
)
)
if tag and tag != name:
entries.append(
LabelEntry(
source="etherscan_tag",
label=tag,
category=self._infer_category(tag),
)
)
except Exception as e:
logger.debug(f"Etherscan label query failed for {address}: {e}")
return entries
# ── Source 2: RolodETH ──────────────────────────────────────────
async def _fetch_rolodeth(self) -> dict[str, list[dict]]:
"""Fetch and cache RolodETH labels from GitHub."""
global _ROLODETH_CACHE, _ROLODETH_LAST_FETCH
import time
now = time.monotonic()
if _ROLODETH_CACHE and (now - _ROLODETH_LAST_FETCH) < _ROLODETH_TTL:
return _ROLODETH_CACHE
try:
url = "https://raw.githubusercontent.com/exdx/rolodETH/main/data/labels.json"
resp = await self._http.get(url, timeout=30.0)
if resp.status_code == 200:
data = resp.json()
# Build address-indexed dict: { "0x...": [{label, category, ...}] }
index: dict[str, list[dict]] = {}
if isinstance(data, list):
for entry in data:
addr = entry.get("address", "").lower()
if addr:
index.setdefault(addr, []).append(
{
"label": entry.get("label", ""),
"category": entry.get("category", entry.get("type", "")),
}
)
elif isinstance(data, dict):
# Could be address → label mapping
for addr, info in data.items():
addr_norm = addr.lower()
if isinstance(info, str):
index.setdefault(addr_norm, []).append(
{
"label": info,
"category": "",
}
)
elif isinstance(info, dict):
index.setdefault(addr_norm, []).append(
{
"label": info.get("label", info.get("name", "")),
"category": info.get("category", info.get("type", "")),
}
)
elif isinstance(info, list):
for sub in info:
if isinstance(sub, dict):
index.setdefault(addr_norm, []).append(
{
"label": sub.get("label", sub.get("name", "")),
"category": sub.get("category", sub.get("type", "")),
}
)
_ROLODETH_CACHE = index
_ROLODETH_LAST_FETCH = now
return index
except Exception as e:
logger.debug(f"RolodETH fetch failed: {e}")
return _ROLODETH_CACHE or {}
async def _query_rolodeth(self, address: str) -> list[LabelEntry]:
"""Query RolodETH for address labels."""
entries = []
index = await self._fetch_rolodeth()
addr_norm = address.lower()
labels = index.get(addr_norm, [])
for lbl in labels:
entries.append(
LabelEntry(
source="rolodeth",
label=lbl.get("label", ""),
category=lbl.get("category", ""),
)
)
return entries
# ── Source 3: walletLabels.xyz ─────────────────────────────────
async def _query_walletlabels(self, address: str) -> list[LabelEntry]:
"""Query walletLabels.xyz API for address labels."""
entries = []
try:
url = f"https://api.walletlabels.xyz/v1/address/{address}"
resp = await self._http.get(url, timeout=10.0)
if resp.status_code == 200:
body = resp.json()
# Parse response - format may vary
labels = body.get("labels", body.get("data", []))
if isinstance(labels, list):
for lbl in labels:
if isinstance(lbl, dict):
entries.append(
LabelEntry(
source="walletlabels",
label=lbl.get("label", lbl.get("name", "")),
category=lbl.get("category", lbl.get("type", "")),
confidence=lbl.get("confidence", 1.0),
)
)
elif isinstance(lbl, str):
entries.append(
LabelEntry(
source="walletlabels",
label=lbl,
)
)
elif isinstance(labels, dict):
label = labels.get("label", labels.get("name", ""))
if label:
entries.append(
LabelEntry(
source="walletlabels",
label=label,
category=labels.get("category", ""),
)
)
except Exception as e:
logger.debug(f"walletLabels.xyz query failed for {address}: {e}")
return entries
# ── Source 4: bluepages.fyi ────────────────────────────────────
async def _query_bluepages(self, address: str) -> list[LabelEntry]:
"""Query bluepages.fyi API for address labels."""
entries = []
try:
url = f"https://api.bluepages.fyi/api/v1/labels/{address}"
resp = await self._http.get(url, timeout=10.0)
if resp.status_code == 200:
body = resp.json()
# bluepages may return a list of labels or single label
items = body if isinstance(body, list) else [body]
for item in items:
if isinstance(item, dict):
label = item.get("label", item.get("name", ""))
if label:
entries.append(
LabelEntry(
source="bluepages",
label=label,
category=item.get("category", item.get("type", "")),
confidence=item.get("confidence", 1.0),
)
)
except Exception as e:
logger.debug(f"bluepages.fyi query failed for {address}: {e}")
return entries
# ── Source 5: Internal RAG ────────────────────────────────────
async def _query_internal_rag(self, address: str, chain: str) -> list[LabelEntry]:
"""Query internal RAG service for known scam and wallet profile data."""
entries = []
try:
from app.rag_service import RAGService
rag = RAGService()
# Query known_scams collection
results = await rag.query(
f"address:{address} chain:{chain}",
collection="known_scams",
limit=5,
)
if results:
for r in results:
label = r.get("label", r.get("name", r.get("project", "")))
if label:
entries.append(
LabelEntry(
source="rag_known_scams",
label=label,
category="scam",
)
)
# Query wallet_profiles collection
results = await rag.query(
f"address:{address}",
collection="wallet_profiles",
limit=5,
)
if results:
for r in results:
label = r.get("label", r.get("name", r.get("alias", "")))
if label:
entries.append(
LabelEntry(
source="rag_wallet_profiles",
label=label,
category=r.get("category", "wallet"),
)
)
except ImportError:
logger.debug("RAG service not available for address labeling")
except Exception as e:
logger.debug(f"Internal RAG query failed for {address}: {e}")
return entries
# ── Source 6: Built-in database ────────────────────────────────
def _query_builtin(self, address: str, chain: str) -> list[LabelEntry]:
"""Check built-in known databases for address labels."""
entries = []
addr_lower = address.lower()
# Known scams
if addr_lower in BUILTIN_KNOWN_SCAMS:
info = BUILTIN_KNOWN_SCAMS[addr_lower]
entries.append(
LabelEntry(
source="builtin",
label=info["label"],
category=info["category"],
)
)
# CEX hot wallets from chain_registry
chain_cfg = CHAINS.get(chain)
if chain_cfg:
for cex_name, wallets in chain_cfg.cex_hot_wallets.items():
for w in wallets:
if w.lower() == addr_lower:
entries.append(
LabelEntry(
source="builtin_cex",
label=cex_name,
category="exchange",
)
)
break
return entries
# ── Category inference ─────────────────────────────────────────
def _infer_category(self, text: str) -> str:
"""Infer label category from text content."""
text_lower = text.lower()
if any(
w in text_lower
for w in (
"exchange",
"binance",
"coinbase",
"kraken",
"okx",
"bybit",
"kucoin",
"bitfinex",
"gemini",
"bitget",
"mexc",
"huobi",
"htx",
"gate.io",
"upbit",
"bitstamp",
"poloniex",
)
):
return "exchange"
if any(w in text_lower for w in ("mev", "sandwich", "flashbot", "jit", "front-run", "arbitrageur", "solver")):
return "mev"
if any(w in text_lower for w in ("rug", "scam", "exploit", "hack", "phish", "drainer", "impersonator", "fake")):
return "scam"
if any(w in text_lower for w in ("bridge", "hop", "across", "synapse", "stargate", "wormhole", "portal")):
return "bridge"
if any(
w in text_lower
for w in (
"uniswap",
"sushiswap",
"curve",
"aave",
"compound",
"maker",
"lido",
"yearn",
"balancer",
"1inch",
"pancakeswap",
"raydium",
)
):
return "defi"
if any(w in text_lower for w in ("deployer", "creator", "factory", "contract")):
return "contract"
return ""
# ── Risk computation ────────────────────────────────────────────
def _compute_risk(self, report: AddressLabelReport) -> None:
"""Compute risk score from collected labels."""
score = 0.0
warnings = []
red_flags = []
# Known scam = very high risk
if report.is_known_scam:
score += 60
red_flags.append("CRITICAL: Address is labeled as known scam/exploit")
# Exchange addresses are generally safe
if report.is_exchange:
score -= 10 # reduce risk (exchanges are established entities)
# But don't go below 0
# MEV bot = moderate concern
if report.is_mev_bot:
score += 15
warnings.append("MEDIUM: Address identified as MEV bot")
# DeFi protocol = context-dependent
if report.is_defi_protocol:
# DeFi protocols are neutral - no risk adjustment
pass
# Bridge addresses are neutral
if report.is_bridge:
pass
# Multiple scam labels = escalate
scam_labels = [line for line in report.labels if line.get("category") == "scam"]
if len(scam_labels) >= 2:
score += 25
red_flags.append(f"CRITICAL: {len(scam_labels)} sources flag address as scam")
# No labels found = unknown entity (slightly suspicious)
if not report.labels:
score += 10
warnings.append("LOW: No known labels found for address - unknown entity")
# Negative risk from exchange doesn't go below 0
score = max(0.0, min(100.0, score))
report.risk_score = score
if score >= 70:
report.risk_level = "CRITICAL"
elif score >= 45:
report.risk_level = "HIGH"
elif score >= 25:
report.risk_level = "MEDIUM"
else:
report.risk_level = "LOW"
report.warnings = warnings
report.red_flags = red_flags
# ── Main analysis entry point ──────────────────────────────────
async def analyze(self, token_address: str, chain: str) -> AddressLabelReport:
"""Resolve address labels from all sources in parallel.
This method analyzes the token_address (contract) itself.
For standalone address lookups, use lookup_address() instead.
"""
return await self.lookup_address(token_address, chain)
async def lookup_address(self, address: str, chain: str) -> AddressLabelReport:
"""Resolve any address to known labels from multiple sources.
Fetches from all 6 sources in parallel, then combines results.
"""
report = AddressLabelReport(address=address, chain=chain)
# Source 6: Built-in database (sync)
builtin_entries = self._query_builtin(address, chain)
# Run all async label sources in parallel
async def _empty() -> list[LabelEntry]:
return []
tasks = []
# Source 1: Etherscan (EVM only)
if is_evm(chain):
tasks.append(self._query_etherscan(address, chain))
else:
tasks.append(_empty())
# Source 2: RolodETH (EVM only, mainly Ethereum mainnet addresses)
if is_evm(chain):
tasks.append(self._query_rolodeth(address))
else:
tasks.append(_empty())
# Source 3: walletLabels.xyz
tasks.append(self._query_walletlabels(address))
# Source 4: bluepages.fyi
tasks.append(self._query_bluepages(address))
# Source 5: Internal RAG
tasks.append(self._query_internal_rag(address, chain))
# Gather results
try:
results = await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.warning(f"Label gathering failed for {address}: {e}")
results = []
# Collect all label entries
all_entries = list(builtin_entries)
for result in results:
if isinstance(result, Exception):
logger.debug(f"Label source failed: {result}")
continue
if isinstance(result, list):
all_entries.extend(result)
# Deduplicate and convert to dicts
seen_labels = set()
unique_entries = []
for entry in all_entries:
key = (entry.source, entry.label.lower())
if key not in seen_labels and entry.label:
seen_labels.add(key)
unique_entries.append(entry)
report.labels.append(
{
"source": entry.source,
"label": entry.label,
"category": entry.category,
"confidence": entry.confidence,
}
)
# Determine flags from labels
for lbl in report.labels:
cat = lbl.get("category", "").lower()
label_text = lbl.get("label", "").lower()
if cat == "exchange" or self._is_exchange_label(label_text):
report.is_exchange = True
if cat == "mev" or self._is_mev_label(label_text):
report.is_mev_bot = True
if cat == "scam" or self._is_scam_label(label_text):
report.is_known_scam = True
if cat == "defi":
report.is_defi_protocol = True
if cat == "bridge":
report.is_bridge = True
if cat == "contract" and "deployer" in label_text:
report.isDeployer = True
# Also check built-in CEX wallets
chain_cfg = CHAINS.get(chain)
if chain_cfg:
addr_norm = address.lower()
for _cex_name, wallets in chain_cfg.cex_hot_wallets.items():
if addr_norm in [w.lower() for w in wallets]:
report.is_exchange = True
# Set primary label
if report.labels:
# Prefer non-empty category labels
categorized = [line for line in report.labels if line.get("category")]
primary = categorized[0] if categorized else report.labels[0]
report.primary_label = primary["label"]
report.primary_category = primary.get("category", "")
# Compute risk
self._compute_risk(report)
# RAG citations for credibility
try:
rag_cits = await query_rag_citations(
topic=f"known scam address wallet label {chain}",
chain=chain,
address=address,
scanner_type="address_labeling",
)
report.citations = rag_cits
for i, w in enumerate(report.warnings):
if rag_cits:
report.warnings[i] = build_citation_string(rag_cits, w)
except Exception:
pass
return report
def _is_exchange_label(self, label: str) -> bool:
"""Check if label text indicates an exchange."""
exchange_keywords = [
"binance",
"coinbase",
"kraken",
"okx",
"bybit",
"kucoin",
"bitfinex",
"gemini",
"bitget",
"mexc",
"huobi",
"htx",
"gate.io",
"upbit",
"bitstamp",
"poloniex",
"bitmex",
"crypto.com",
"bithumb",
"korbit",
"lbank",
"bitmart",
"exchange",
"hot wallet",
"deposit",
"withdrawal",
]
return any(kw in label for kw in exchange_keywords)
def _is_mev_label(self, label: str) -> bool:
"""Check if label text indicates an MEV bot."""
mev_keywords = [
"mev",
"sandwich",
"flashbot",
"jit",
"front-run",
"arbitrageur",
"solver",
"searcher",
"builder",
]
return any(kw in label for kw in mev_keywords)
def _is_scam_label(self, label: str) -> bool:
"""Check if label text indicates a scam/rug."""
scam_keywords = [
"rug",
"scam",
"exploit",
"hack",
"phish",
"drainer",
"impersonator",
"fake",
"impersonation",
"suspicious",
]
return any(kw in label for kw in scam_keywords)