rmi-backend/app/domains/labels/sources/mbal_source.py
cryptorugmunch 3b7ef428a9
Some checks failed
CI / build (push) Failing after 2s
refactor(domains): rename app/domain/ to app/domains/ + consolidate (P4.7)
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)
2026-07-06 23:08:17 +02:00

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