"""Federated label API — orchestrates all 6 sources. Per RMIV5 §T11: queries every configured source in parallel via asyncio.gather(return_exceptions=True). Failed sources are logged but don't fail the whole query — graceful degradation. Deduplication: labels from different sources for the same (address, chain, label_type, label_value) get merged, keeping the highest-confidence version and tagging all contributing sources. Caching: Redis-backed cache with 1h TTL keyed by (address, chain). On cache hit, skip source queries entirely. """ from __future__ import annotations import asyncio import logging from typing import Any from app.domain.labels.models import Label, LabelSource log = logging.getLogger(__name__) # Default sources to query. Subclasses can override. DEFAULT_SOURCES: list[LabelSource] = [ LabelSource.ETH_LABELS_DB, LabelSource.ETHERSCAN_CSV, LabelSource.ETHEREUM_LABELS_CSV, LabelSource.SOLANA_LABELS_CSV, LabelSource.CLICKHOUSE, LabelSource.METASLEUTH, LabelSource.MBAL, ] class FederatedLabelAPI: """Orchestrates wallet label queries across all configured sources. Usage: api = FederatedLabelAPI() labels = await api.get_labels("0x...", "ethereum") # Returns list[Label] aggregated from all sources sources_checked = api.sources_queried_last_call # for observability """ def __init__( self, sources: list[LabelSource] | None = None, cache_ttl_seconds: int = 3600, per_source_timeout: float = 5.0, max_concurrent: int = 6, ) -> None: self._sources = sources or DEFAULT_SOURCES self._cache_ttl = cache_ttl_seconds self._per_source_timeout = per_source_timeout self._semaphore = asyncio.Semaphore(max_concurrent) self._sources_queried_last_call: list[str] = [] async def get_labels( self, address: str, chain: str = "ethereum", *, use_cache: bool = True, include_low_confidence: bool = False, ) -> list[Label]: """Get all labels for an address from all sources. Args: address: Wallet address (0x... for EVM, base58 for Solana, etc.) chain: Chain name (ethereum, solana, base, bitcoin, etc.) use_cache: If True, check Redis cache first (default True) include_low_confidence: If False (default), filter labels with confidence < 0.3 to reduce noise Returns: Deduplicated list of labels, sorted by confidence desc. """ # Cache check if use_cache: cached = await self._cache_get(address, chain) if cached is not None: return cached # Query all sources in parallel tasks = [ self._query_source_safe(source, address, chain) for source in self._sources ] results = await asyncio.gather(*tasks, return_exceptions=True) # Collect labels + track which sources worked all_labels: list[Label] = [] sources_queried: list[str] = [] for source, result in zip(self._sources, results, strict=False): if isinstance(result, Exception): log.warning( "federated_source_fail source=%s err=%s: %s", source.value, type(result).__name__, result, ) continue if result: sources_queried.append(source.value) all_labels.extend(result) # Deduplicate (merge same label from multiple sources) deduped = self._dedupe(all_labels) # Filter low confidence unless requested if not include_low_confidence: deduped = [line for line in deduped if line.confidence >= 0.3] # Sort by confidence desc deduped.sort(key=lambda line: -line.confidence) # Track for observability self._sources_queried_last_call = sources_queried # Cache if use_cache and deduped: await self._cache_set(address, chain, deduped) log.info( "federated_labels_query address=%s chain=%s sources=%d labels=%d", address[:10] + "..." if len(address) > 10 else address, chain, len(sources_queried), len(deduped), ) return deduped async def get_labels_dict( self, address: str, chain: str = "ethereum", **kwargs: Any, ) -> dict[str, Any]: """Get labels as a dict (for JSON API responses). Returns: { "address": "0x...", "chain": "ethereum", "labels": [...], # list of label dicts "sources_queried": [...], # list of source names that worked "total": N, "verified_at": "2026-06-22T..." } """ labels = await self.get_labels(address, chain, **kwargs) return { "address": address, "chain": chain, "labels": [line.to_dict() for line in labels], "sources_queried": self._sources_queried_last_call, "total": len(labels), } async def _query_source_safe( self, source: LabelSource, address: str, chain: str, ) -> list[Label]: """Query a single source with timeout + semaphore + exception handling.""" async with self._semaphore: try: return await asyncio.wait_for( self._query_source(source, address, chain), timeout=self._per_source_timeout, ) except TimeoutError: log.warning("federated_source_timeout source=%s address=%s", source.value, address[:10]) return [] except Exception as e: log.warning( "federated_source_error source=%s err=%s: %s", source.value, type(e).__name__, e, ) return [] async def _query_source( self, source: LabelSource, address: str, chain: str, ) -> list[Label]: """Dispatch to the correct source adapter. Each adapter handles its own logic.""" if source == LabelSource.ETH_LABELS_DB: from app.domain.labels.sources.eth_labels_db import query_eth_labels_db return await query_eth_labels_db(address, chain) if source == LabelSource.ETHERSCAN_CSV: from app.domain.labels.sources.etherscan_csv import query_etherscan_csv return await query_etherscan_csv(address, chain) if source == LabelSource.ETHEREUM_LABELS_CSV: from app.domain.labels.sources.ethereum_labels_csv import query_ethereum_labels_csv return await query_ethereum_labels_csv(address, chain) if source == LabelSource.SOLANA_LABELS_CSV: from app.domain.labels.sources.solana_labels_csv import query_solana_labels_csv return await query_solana_labels_csv(address, chain) if source == LabelSource.CLICKHOUSE: from app.domain.labels.sources.clickhouse_labels import query_clickhouse_labels return await query_clickhouse_labels(address, chain) if source == LabelSource.METASLEUTH: from app.domain.labels.sources.metasleuth import query_metasleuth return await query_metasleuth(address, chain) if source == LabelSource.OPEN_LABELS: from app.domain.labels.sources.open_labels import query_open_labels return await query_open_labels(address, chain) if source == LabelSource.CHAINBASE: from app.domain.labels.sources.chainbase import query_chainbase return await query_chainbase(address, chain) if source == LabelSource.INTERNAL: from app.domain.labels.sources.internal_labels import query_internal return await query_internal(address, chain) if source == LabelSource.MBAL: from app.domain.labels.sources.mbal import query_mbal return await query_mbal(address, chain) log.warning("federated_source_unknown source=%s", source.value) return [] def _dedupe(self, labels: list[Label]) -> list[Label]: """Merge labels with same dedup_key into one (keeping highest confidence).""" by_key: dict[tuple, Label] = {} for label in labels: key = Label.dedupe_key(label.address, label.chain, label.label_type, label.label_value) if key not in by_key: by_key[key] = label else: by_key[key] = Label.merge([by_key[key], label]) return list(by_key.values()) async def _cache_get(self, address: str, chain: str) -> list[Label] | None: """Get cached labels from Redis. Returns None on miss/error.""" try: import json as _json from app.core.redis import get_redis r = get_redis() key = self._cache_key(address, chain) raw = r.get(key) if not raw: return None data = _json.loads(raw) # Reconstruct Label objects (don't restore full provenance) return [ Label( address=line["address"], chain=line["chain"], label_type=line["label_type"], label_value=line["label_value"], source=LabelSource(line["source"]) if line["source"] in [s.value for s in LabelSource] else LabelSource.INTERNAL, confidence=line["confidence"], verified_at=line["verified_at"], attributes=line.get("attributes", {}), entity=line.get("entity"), entity_category=line.get("entity_category"), ) for line in data ] except Exception as e: log.debug("federated_cache_get_fail: %s", e) return None async def _cache_set(self, address: str, chain: str, labels: list[Label]) -> None: """Cache labels to Redis with TTL.""" try: import json as _json from app.core.redis import get_redis r = get_redis() key = self._cache_key(address, chain) data = [line.to_dict() for line in labels] r.setex(key, self._cache_ttl, _json.dumps(data)) except Exception as e: log.debug("federated_cache_set_fail: %s", e) def _cache_key(self, address: str, chain: str) -> str: """Redis key for cached labels. Includes a hash of the address (privacy).""" from app.core.rate_limiter import _hash_identifier return f"labels:{chain}:{_hash_identifier(address.lower())}" _default_instance: FederatedLabelAPI | None = None def get_federated_api() -> FederatedLabelAPI: """Get a process-wide FederatedLabelAPI instance.""" global _default_instance if _default_instance is None: _default_instance = FederatedLabelAPI() return _default_instance