rmi-backend/app/domain/labels/sources/metasleuth.py

168 lines
5.2 KiB
Python

"""MetaSleuth (BlockSec AML) source — live API at aml.blocksec.com.
Endpoint: POST https://aml.blocksec.com/address-label/api/v3/labels
Headers: API-KEY: <key>
Body: {"chain_id": 1, "address": "0x..."}
Verified working: 2026-06-22 (Binance 0x3d12... returned main_entity=Binance,
entity categories EXCHANGE + OTC DESK).
Limit: 10 calls/day per API key on the free tier. So we cache aggressively.
"""
from __future__ import annotations
import asyncio
import logging
import os
from datetime import UTC, datetime
import httpx
from app.domain.labels.models import Label, LabelSource
log = logging.getLogger(__name__)
METASLEUTH_BASE = "https://aml.blocksec.com/address-label/api/v3"
# Free tier: 10 calls/day per key on risk-level, similar on labels
DEFAULT_TIMEOUT = 8.0
# Chain name -> MetaSleuth chain_id
_CHAIN_MAP = {
"ethereum": 1,
"bitcoin": -1,
"tron": -2,
"solana": -3,
"monero": -4,
"sui": 5,
"optimism": 10,
"cronos": 25,
"bsc": 56,
"base": 8453,
"arbitrum": 42161,
"polygon": 137,
}
async def query_metasleuth(address: str, chain: str = "ethereum") -> list[Label]:
"""Query MetaSleuth for an address's entity info.
Returns:
- 1 entity label with the main entity name (e.g. "Binance")
- Optional category labels (EXCHANGE, OTC DESK, etc.)
- Attributes (DEPOSIT ADDRESS, etc.)
"""
api_key = os.getenv("METASLEUTH_LABELS_API_KEY", "")
if not api_key:
# Try gopass via the standard pattern
try:
import subprocess
result = subprocess.run(
["gopass", "show", "-o", "rmi/infra/metasleuth_labels_api_key"],
capture_output=True, text=True, timeout=2,
)
if result.returncode == 0 and result.stdout.strip():
api_key = result.stdout.strip()
except Exception:
pass
if not api_key:
log.debug("metasleuth_no_api_key configured, skipping")
return []
chain_id = _CHAIN_MAP.get(chain.lower())
if chain_id is None:
log.debug("metasleuth_unsupported_chain chain=%s", chain)
return []
def _call_api() -> dict:
"""Sync HTTP call (run in thread)."""
try:
r = httpx.post(
f"{METASLEUTH_BASE}/labels",
headers={"API-KEY": api_key, "Content-Type": "application/json"},
json={"chain_id": chain_id, "address": address},
timeout=DEFAULT_TIMEOUT,
)
r.raise_for_status()
data = r.json()
if data.get("code") == 200000:
return data.get("data") or {}
log.warning("metasleuth_api_err code=%s msg=%s", data.get("code"), data.get("message"))
return {}
except httpx.HTTPError as e:
log.warning("metasleuth_api_http_err: %s", e)
return {}
except Exception as e:
log.warning("metasleuth_api_err: %s", e)
return {}
data = await asyncio.to_thread(_call_api)
if not data:
return []
labels: list[Label] = []
main_entity = data.get("main_entity")
if main_entity:
labels.append(Label(
address=address.lower(),
chain=chain,
label_type="entity",
label_value=main_entity,
source=LabelSource.METASLEUTH,
confidence=0.95,
verified_at=datetime.now(UTC),
entity=main_entity,
entity_category=_first_category(data),
attributes=_extract_attributes(data),
))
# Also emit labels for each category
categories = data.get("main_entity_info", {}).get("categories", [])
if not isinstance(categories, list):
categories = []
for cat in categories:
if not isinstance(cat, dict):
continue
cat_name = cat.get("name")
if cat_name and cat_name != main_entity:
labels.append(Label(
address=address.lower(),
chain=chain,
label_type="category",
label_value=cat_name,
source=LabelSource.METASLEUTH,
confidence=0.9,
verified_at=datetime.now(UTC),
entity=main_entity,
entity_category=cat_name,
attributes={"code": cat.get("code")},
))
return labels
def _first_category(data: dict) -> str | None:
"""Extract first category name from MetaSleuth response."""
cats = data.get("main_entity_info", {}).get("categories", [])
if cats and isinstance(cats[0], dict):
return cats[0].get("name")
return None
def _extract_attributes(data: dict) -> dict:
"""Extract attributes from MetaSleuth response."""
attrs = data.get("main_entity_info", {}).get("attributes", [])
out = {}
for a in attrs:
if isinstance(a, dict):
name = a.get("name", "")
if name:
out[name] = a.get("code")
# Add description (website/twitter) if present
desc = data.get("main_entity_info", {}).get("description", {})
if isinstance(desc, dict):
for k, v in desc.items():
if v:
out[k] = v
return out