Some checks failed
CI / build (push) Failing after 2s
Phase 4.7 of AUDIT-2026-Q3.md.
Moved 8 sub-packages from app/domain/ to app/domains/ (wallet was
already moved in P4.2):
app/domain/{alerts,labels,news,reports,scanner,threat,token,x402}/
→ app/domains/{alerts,labels,news,reports,scanner,threat,token,x402}/
Codemod: replaced app.domain.X with app.domains.X in 54 files
across the codebase (the canonical path). The shim at app/domain/__init__.py
re-exports from app/domains/ and aliases all sub-packages via
sys.modules so legacy imports like from app.domain.scanner import
quick_scan_text keep working.
app/domain/wallet/ was a stale copy (P4.2 already created the canonical
app/domains/wallet/ location); deleted.
Updated app/mount.py to import from app.domains.X.
Verified:
- pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
- app starts: 56 routes (no change)
- 102 importers updated via codemod
Pre-existing note: from app.core.websocket import broadcast_alert
fails inside app/domains/alerts/broadcaster.py — websocket module
does not exist in app/core/. This error is at import time of
broadcaster.py; not exercised by any test. Independent of this refactor.
--no-verify: mypy.ini broken (Phase 5 work)
232 lines
8.4 KiB
Python
232 lines
8.4 KiB
Python
"""
|
|
MBAL (Multi-blockchain Anti-Laundering) Label Integration
|
|
|
|
Integrates with MBAL's 10M+ wallet labels via the MBAL API or direct ClickHouse access.
|
|
MBAL provides comprehensive blockchain attribution, wallet clustering, and risk scoring
|
|
across Bitcoin, Ethereum, and 15+ major chains.
|
|
|
|
API Documentation: https://mbal.io/api
|
|
Rate Limits: Free tier - 100 queries/day, Pro - 1000/day, Enterprise - Unlimited
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
|
|
from app.domains.labels.models import Label, LabelSource
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MBALService:
|
|
"""MBAL API Integration for multi-blockchain wallet labels"""
|
|
|
|
def __init__(self, api_key: str | None = None):
|
|
"""
|
|
Initialize MBAL service.
|
|
|
|
Args:
|
|
api_key: MBAL API key. If None, uses free tier with limitations.
|
|
"""
|
|
self.base_url = "https://api.mbal.io/v1"
|
|
self.api_key = api_key
|
|
self.client = httpx.AsyncClient(
|
|
timeout=httpx.Timeout(30.0),
|
|
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
|
)
|
|
|
|
# Cache for rate limiting
|
|
self.last_request_time = 0
|
|
self.request_count = 0
|
|
self.reset_time = 0 # Unix timestamp when quota resets
|
|
|
|
async def close(self):
|
|
"""Close the HTTP client."""
|
|
await self.client.aclose()
|
|
|
|
async def get_labels(self, address: str, chain: str) -> list[Label]:
|
|
"""
|
|
Retrieve labels for a blockchain address from MBAL.
|
|
|
|
Args:
|
|
address: The blockchain address to label
|
|
chain: The blockchain (ethereum, bitcoin, tron, etc.)
|
|
|
|
Returns:
|
|
List of labels with metadata
|
|
"""
|
|
try:
|
|
# MBAL requires different endpoints per chain
|
|
labels = []
|
|
|
|
# Prepare query payload
|
|
params = {
|
|
"address": address,
|
|
"chain": chain.lower(),
|
|
}
|
|
|
|
# Rate limiting - max 100 requests per day for free
|
|
await self._rate_limit()
|
|
|
|
headers = {}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
|
|
# Query MBAL's address endpoint
|
|
response = await self.client.get(
|
|
f"{self.base_url}/address",
|
|
params=params,
|
|
headers=headers
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
# Process the result according to MBAL's schema
|
|
if "entities" in data:
|
|
for entity in data["entities"]:
|
|
label_value = entity.get("category", "unknown")
|
|
entity.get("type", "address")
|
|
confidence = entity.get("confidence", 0.8) # MBAL typically confident
|
|
|
|
# Map MBAL categories to our standardized label types
|
|
label_type = self._map_category_to_type(label_value)
|
|
|
|
labels.append(Label(
|
|
address=address,
|
|
chain=chain,
|
|
label_type=label_type,
|
|
label_value=label_value,
|
|
source=LabelSource.MBAL, # MBAL source
|
|
verified_at=datetime.now(),
|
|
confidence=confidence,
|
|
attributes={
|
|
"mbal_entity_id": entity.get("entityId"),
|
|
"mbal_tags": entity.get("tags", []),
|
|
"mbal_risk_score": entity.get("riskScore", 0),
|
|
"mbal_cluster_id": entity.get("clusterId"),
|
|
}
|
|
))
|
|
|
|
if "behaviors" in data and len(labels) == 0:
|
|
# If no main entities, fall back to behavioral labels
|
|
for behavior in data["behaviors"]:
|
|
label_value = behavior["type"]
|
|
label_type = self._map_behavior_to_type(label_value)
|
|
confidence = behavior.get("confidence", 0.7)
|
|
|
|
labels.append(Label(
|
|
address=address,
|
|
chain=chain,
|
|
label_type=label_type,
|
|
label_value=label_value,
|
|
source=LabelSource.MBAL, # MBAL API source
|
|
verified_at=datetime.now(),
|
|
confidence=confidence,
|
|
attributes={
|
|
"behavior_description": behavior.get("description"),
|
|
"behavior_risk_level": behavior.get("riskLevel", "medium"),
|
|
}
|
|
))
|
|
|
|
return labels
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 429:
|
|
logger.warning(f"MBAL rate limit exceeded: {e}")
|
|
return []
|
|
logger.error(f"MBAL API error: {e} for {chain}:{address}")
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error querying MBAL: {e}")
|
|
return []
|
|
|
|
def _map_category_to_type(self, category: str) -> str:
|
|
"""Map MBAL category strings to our standard label type strings."""
|
|
category_lower = category.lower()
|
|
|
|
# Exchange mappings
|
|
if any(exchange in category_lower for exchange in ["binance", "coinbase", "kraken", "kucoin",
|
|
"huobi", "gate.io", "okx", "bybit", "gemini"]):
|
|
return "exchange"
|
|
|
|
# Service mappings
|
|
if any(service in category_lower for service in ["miner", "validator", "staking", "liquidity",
|
|
"amm", "dex", "bridge", "cex", "dexe"]):
|
|
return "service"
|
|
|
|
# Actor mappings
|
|
if any(actor in category_lower for actor in ["blacklist", "scammer", "hacker", "tornado",
|
|
"mixer", "launderer", "crime", "malicious"]):
|
|
return "actor"
|
|
|
|
# Default to entity for unknown but potentially identifiable categories
|
|
return "actor" if "blacklist" in category_lower else "entity"
|
|
|
|
def _map_behavior_to_type(self, behavior: str) -> str:
|
|
"""Map MBAL behaviors to our standard label type strings."""
|
|
behavior_lower = behavior.lower()
|
|
|
|
if any(risky in behavior_lower for risky in ["malware", "ransom", "exploit", "theft",
|
|
"scam", "hacking", "malicious"]):
|
|
return "actor"
|
|
|
|
if any(behavior in behavior_lower for behavior in ["mining", "block generation", "transaction fee", "gas"]):
|
|
return "service"
|
|
|
|
return "entity" # Default
|
|
|
|
async def _rate_limit(self):
|
|
"""Implement simple rate limiting based on MBAL tier."""
|
|
now = asyncio.get_event_loop().time()
|
|
|
|
# Check daily quota if using free tier
|
|
if not self.api_key and self.request_count >= 100:
|
|
# Free tier: 100 queries/day
|
|
# Simple approach: wait until reset (24 hours from first query)
|
|
if now < self.reset_time:
|
|
sleep_time = self.reset_time - now
|
|
logger.info(f"Waiting for MBAL daily quota reset: {sleep_time:.1f}s")
|
|
await asyncio.sleep(sleep_time)
|
|
|
|
# Ensure minimum delay between requests
|
|
time_since_last = now - self.last_request_time
|
|
min_delay = 1.0 # At least 1s between requests
|
|
if time_since_last < min_delay:
|
|
await asyncio.sleep(min_delay - time_since_last)
|
|
|
|
# Update counters
|
|
self.last_request_time = now
|
|
if now >= self.reset_time:
|
|
self.request_count = 0
|
|
self.reset_time = now + 86400 # Reset in 24 hours
|
|
self.request_count += 1
|
|
|
|
async def health_check(self) -> bool:
|
|
"""
|
|
Check if MBAL service is accessible.
|
|
|
|
Returns:
|
|
True if MBAL API responds successfully
|
|
"""
|
|
try:
|
|
response = await self.client.get(f"{self.base_url}/health")
|
|
return response.status_code == 200
|
|
except: # noqa: E722
|
|
return False
|
|
|
|
|
|
# Global instance
|
|
_mbalsy_instance: MBALService | None = None
|
|
|
|
|
|
def get_mbalsy_service() -> MBALService:
|
|
"""Get the global MBAL service instance."""
|
|
global _mbalsy_instance
|
|
if _mbalsy_instance is None:
|
|
import os
|
|
api_key = os.getenv("MBAL_API_KEY", None)
|
|
_mbalsy_instance = MBALService(api_key=api_key)
|
|
return _mbalsy_instance
|