168 lines
5.2 KiB
Python
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
|