""" 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 from typing import List, Optional from urllib.parse import urlencode import httpx from app.domain.labels.models import Label, LabelSource logger = logging.getLogger(__name__) class MBALService: """MBAL API Integration for multi-blockchain wallet labels""" def __init__(self, api_key: Optional[str] = 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_type = 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: return False # Global instance _mbalsy_instance: Optional[MBALService] = 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