merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
168
app/domain/labels/sources/metasleuth.py
Normal file
168
app/domain/labels/sources/metasleuth.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue