merge: chore/cleanup-remove-bloat-and-secrets into main

This commit is contained in:
Crypto Rug Munch 2026-07-02 01:24:22 +07:00
commit bde2f3a97d
1173 changed files with 437609 additions and 0 deletions

View file

@ -0,0 +1,34 @@
"""RMI v5 §T11 — Federated wallet labels domain.
Aggregates wallet labels from 6 sources in parallel:
1. eth-labels.db 115K EVM labels (local SQLite)
2. Etherscan CSV 51K EVM labels (local CSV via DuckDB)
3. Ethereum labels CSV 51K EVM labels (local CSV via DuckDB)
4. Solana labels CSV 103K SOL labels (local CSV via DuckDB)
5. ClickHouse wallet_memory.wallet_labels (live API)
6. MetaSleuth live API at aml.blocksec.com (works)
Each source has its own adapter in app/domain/labels/sources/.
The FederatedLabelAPI orchestrator queries all sources concurrently
with asyncio.gather(return_exceptions=True), deduplicates by
(address, chain, label_type), and tags each label with provenance.
Graceful degradation: if a source is unreachable, we log + skip it
and return labels from whatever did work.
Per RMIV5 v4.0 §T28 (P1): biggest moat 5K internal labels 200K+
via federation.
"""
from app.domain.labels.federated import (
FederatedLabelAPI,
get_federated_api,
)
from app.domain.labels.models import Label, LabelSource
__all__ = [
"FederatedLabelAPI",
"Label",
"LabelSource",
"get_federated_api",
]

View file

@ -0,0 +1,296 @@
"""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

View file

@ -0,0 +1,98 @@
"""Label data models for the federated label API.
A Label represents one piece of information about an address from one
source. Multiple labels from different sources for the same address
get merged into a unified view.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
class LabelSource(StrEnum):
"""Which source a label came from. Used for provenance + dedup."""
ETH_LABELS_DB = "eth-labels-db" # local SQLite, 115K EVM labels
ETHERSCAN_CSV = "etherscan-combined" # local CSV, 51K EVM labels
ETHEREUM_LABELS_CSV = "ethereum-labels" # local CSV, 51K EVM labels
SOLANA_LABELS_CSV = "solana-labels" # local CSV, 103K SOL labels
CLICKHOUSE = "clickhouse-wallet-memory" # live CH wallet_labels table
METASLEUTH = "metasleuth" # BlockSec AML API
OPEN_LABELS = "open-labels-initiative" # OLI REST API (TBD)
CHAINBASE = "chainbase" # Chainbase API (TBD, needs key)
INTERNAL = "internal" # our own growing set
MBAL = "mbal" # Multi-blockchain Anti-Laundering source
@dataclass
class Label:
"""A single label for an address.
Multiple labels can exist for the same (address, chain, label_type)
from different sources. Dedup logic keeps the highest-confidence one.
"""
address: str
chain: str # "ethereum" | "solana" | "base" | "bitcoin" | etc.
label_type: str # "entity" | "tag" | "category" | "risk" | "custom"
label_value: str # "Binance" | "Exchange" | "Sanctioned" | etc.
source: LabelSource
confidence: float = 0.5 # 0.0-1.0
verified_at: datetime = field(default_factory=lambda: datetime.now(UTC))
attributes: dict[str, Any] = field(default_factory=dict)
# Optional entity info (for MetaSleuth etc.)
entity: str | None = None
entity_category: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize for API responses."""
return {
"address": self.address,
"chain": self.chain,
"label_type": self.label_type,
"label_value": self.label_value,
"source": self.source.value,
"confidence": self.confidence,
"verified_at": self.verified_at.isoformat(),
"attributes": self.attributes,
"entity": self.entity,
"entity_category": self.entity_category,
}
@classmethod
def dedupe_key(cls, address: str, chain: str, label_type: str, label_value: str) -> tuple:
"""Generate dedup key. Same key = same label from different sources."""
return (address.lower(), chain.lower(), label_type.lower(), label_value.lower())
@classmethod
def merge(cls, labels: list[Label]) -> Label:
"""Merge multiple labels with same dedup_key into one.
Keeps highest confidence, unions attributes, aggregates sources.
"""
if not labels:
raise ValueError("Cannot merge empty label list")
# Sort by confidence desc, take best as base
sorted_labels = sorted(labels, key=lambda l: -l.confidence)
base = sorted_labels[0]
sources = list({l.source for l in labels})
merged_attrs = dict(base.attributes)
for l in labels[1:]:
merged_attrs.update(l.attributes)
# Update verified_at to most recent
verified_at = max(l.verified_at for l in labels)
return cls(
address=base.address,
chain=base.chain,
label_type=base.label_type,
label_value=base.label_value,
source=base.source, # keep highest-confidence source as primary
confidence=base.confidence,
verified_at=verified_at,
attributes={**merged_attrs, "sources_count": len(sources), "all_sources": [s.value for s in sources]},
entity=base.entity,
entity_category=base.entity_category,
)

View file

@ -0,0 +1,81 @@
"""T11 — Federated labels router.
Endpoint:
GET /api/v1/labels/{address}?chain=ethereum
Returns:
{
"address": "0x...",
"chain": "ethereum",
"labels": [...], # list of label dicts
"sources_queried": [...], # which sources worked
"total": N,
}
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from app.domain.labels import get_federated_api
log = logging.getLogger(__name__)
router = APIRouter(prefix="/labels", tags=["labels"])
class LabelsResponse(BaseModel):
"""Response for GET /api/v1/labels/{address}."""
address: str
chain: str
labels: list[dict]
sources_queried: list[str]
total: int
@router.get("/{address}", response_model=LabelsResponse)
async def get_labels(
address: str,
chain: str = Query(
"ethereum",
description="Blockchain (ethereum, solana, base, arbitrum, etc.)",
),
use_cache: bool = Query(True, description="Use Redis cache (1h TTL)"),
include_low_confidence: bool = Query(False, description="Include labels with confidence < 0.3"),
) -> LabelsResponse:
"""Get all wallet labels from 6 federated sources in parallel.
Sources queried:
1. eth-labels.db 115K local labels (SQLite)
2. Etherscan CSV 51K labels (DuckDB)
3. Ethereum labels CSV 51K labels (DuckDB)
4. Solana labels CSV 103K labels (DuckDB)
5. ClickHouse wallet_memory.wallet_labels
6. MetaSleuth BlockSec AML API
Graceful degradation: failed sources are skipped, not raised.
"""
if not address or len(address) < 8:
raise HTTPException(
status_code=400,
detail=f"Address too short (got {len(address)} chars, need >= 8)",
)
try:
api = get_federated_api()
result = await api.get_labels_dict(
address,
chain=chain,
use_cache=use_cache,
include_low_confidence=include_low_confidence,
)
return LabelsResponse(**result)
except Exception as e:
log.exception("labels_endpoint_fail address=%s", address[:10])
raise HTTPException(
status_code=500,
detail=f"Label fetch failed: {type(e).__name__}: {str(e)[:200]}",
)

View file

@ -0,0 +1,16 @@
"""Source adapters for the federated label API.
Each adapter implements `query(address, chain) -> list[Label]`.
To add a new source:
1. Create a new module in this package (e.g., `my_source.py`)
2. Implement `async def query_my_source(address: str, chain: str) -> list[Label]`
3. Register it in LabelSource enum (models.py)
4. Wire it in federated.py:_query_source dispatcher
All adapters follow these conventions:
- Return [] on no match (not None)
- Raise exceptions on infrastructure errors (handled by caller)
- All addresses are normalized to lowercase before query
- Each label has source=LabelSource.MY_SOURCE
"""

View file

@ -0,0 +1,21 @@
"""Chainbase source — placeholder, requires API key.
TODO: add CHAINBASE_API_KEY to gopass + env. Free tier: 50K calls/month.
For now returns []. Stub.
"""
from __future__ import annotations
import logging
from app.domain.labels.models import Label
log = logging.getLogger(__name__)
async def query_chainbase(address: str, chain: str = "ethereum") -> list[Label]:
"""Query Chainbase for an address's labels.
Returns [] until CHAINBASE_API_KEY is configured.
"""
return []

View file

@ -0,0 +1,41 @@
"""ClickHouse wallet_memory.wallet_labels source — currently empty.
Schema (verified):
address String
chain_id String
label_name String
label_category String
source String
is_sanctioned UInt8 DEFAULT 0
loaded_at DateTime DEFAULT now()
label_subtype String DEFAULT ''
Returns [] until labels are imported. The plan is to bulk-import
the CSVs (etherscan_combined, ethereum labels, solana labels) via
the warehouse_etl jobs. Until then, this adapter is a no-op.
"""
from __future__ import annotations
import logging
from app.domain.labels.models import Label
log = logging.getLogger(__name__)
async def query_clickhouse_labels(address: str, chain: str = "ethereum") -> list[Label]:
"""Query ClickHouse wallet_memory.wallet_labels table.
Currently returns [] table is empty until CSVs are bulk-imported.
Real implementation will use HTTP to clickhouse client over
native protocol via app.core.duckdb_analytics.query_postgres-style attach.
Chain mapping: clickhouse stores chain_id as String. We translate
our 'ethereum'/'solana' names to the chain_id values used in the table.
"""
# TODO: when wallet_memory.wallet_labels is populated, use:
# from app.core.duckdb_analytics import DuckDBAnalytics
# d = DuckDBAnalytics()
# rows = d.query_postgres(...) # if we ever swap CH for Postgres
# OR use httpx against CH HTTP interface at http://localhost:8123
return []

View file

@ -0,0 +1,92 @@
"""eth-labels.db source — 115K EVM labels from local SQLite.
Schema (verified):
accounts(id, chain_id, address, label, name_tag, created_at, updated_at)
chain_id mapping:
1 = ethereum, 42161 = arbitrum, 137 = polygon, etc.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from app.domain.labels.models import Label, LabelSource
log = logging.getLogger(__name__)
DB_PATH = "/home/dev/rmi/eth-labels.db"
# chain_id -> chain name mapping (from EVM chains list)
_CHAIN_ID_MAP = {
1: "ethereum",
42161: "arbitrum",
10: "optimism",
137: "polygon",
56: "bsc",
43114: "avalanche",
250: "fantom",
100: "gnosis",
}
async def query_eth_labels_db(address: str, chain: str = "ethereum") -> list[Label]:
"""Query the local eth-labels.db SQLite for an address.
Args:
address: EVM address (0x...)
chain: Chain name (only EVM chains in this DB)
Returns:
List of labels found (one per chain_id that has a match).
"""
def _query() -> list[Label]:
import sqlite3
try:
conn = sqlite3.connect(DB_PATH, timeout=2.0)
conn.row_factory = sqlite3.Row
try:
cursor = conn.execute(
"SELECT chain_id, label, name_tag, updated_at FROM accounts WHERE address = ? LIMIT 50",
(address.lower(),),
)
labels = []
for row in cursor:
chain_id = row["chain_id"]
chain_name = _CHAIN_ID_MAP.get(chain_id, f"chain-{chain_id}")
label_value = row["name_tag"] or row["label"]
if not label_value:
continue
labels.append(
Label(
address=address.lower(),
chain=chain_name,
label_type="entity",
label_value=label_value,
source=LabelSource.ETH_LABELS_DB,
confidence=0.9, # eth-labels.db is curated
verified_at=_parse_date(row["updated_at"]),
attributes={"chain_id": chain_id, "raw_label": row["label"]},
)
)
return labels
finally:
conn.close()
except Exception as e:
log.warning("eth_labels_db_query_fail address=%s err=%s", address[:10], e)
raise
return await asyncio.to_thread(_query)
def _parse_date(s: str | None) -> datetime:
"""Parse SQLite date string to datetime."""
if not s:
return datetime.now(UTC)
try:
# Format: '2024-06-30 21:58:54'
return datetime.fromisoformat(s).replace(tzinfo=UTC)
except Exception:
return datetime.now(UTC)

View file

@ -0,0 +1,67 @@
"""Ethereum wallet labels CSV source — 51K EVM labels via DuckDB."""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from app.domain.labels.models import Label, LabelSource
log = logging.getLogger(__name__)
CSV_PATH = "/home/dev/rmi/backend/data/wallet-labels-clean/wallet_labels_ethereum.csv"
async def query_ethereum_labels_csv(address: str, chain: str = "ethereum") -> list[Label]:
"""Query the ethereum wallet labels CSV via DuckDB.
Schema:
address,chain,source,name,entity_type,threat_group,risk_level,verified
"""
def _query() -> list[Label]:
from app.core.duckdb_analytics import DuckDBAnalytics
try:
d = DuckDBAnalytics()
try:
rows = d.query_parquet(
CSV_PATH,
"""
SELECT address, chain, source, name, entity_type, threat_group, risk_level, verified
FROM data
WHERE LOWER(address) = ?
LIMIT 20
""",
[address.lower()],
)
labels = []
for row in rows:
label_value = row.get("name") or row.get("entity_type")
if not label_value:
continue
chain_name = row.get("chain", chain).lower()
labels.append(
Label(
address=row["address"].lower(),
chain=chain_name,
label_type="entity",
label_value=label_value,
source=LabelSource.ETHEREUM_LABELS_CSV,
confidence=0.85,
verified_at=datetime.now(UTC),
attributes={
"entity_type": row.get("entity_type"),
"threat_group": row.get("threat_group"),
"risk_level": row.get("risk_level"),
"verified": row.get("verified"),
},
)
)
return labels
finally:
d.close()
except Exception as e:
log.warning("ethereum_labels_csv_query_fail address=%s err=%s", address[:10], e)
raise
return await asyncio.to_thread(_query)

View file

@ -0,0 +1,70 @@
"""Etherscan combined labels CSV source — 51K EVM labels via DuckDB."""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from app.domain.labels.models import Label, LabelSource
log = logging.getLogger(__name__)
CSV_PATH = "/home/dev/rmi/backend/data/etherscan_combined_labels.csv"
async def query_etherscan_csv(address: str, chain: str = "ethereum") -> list[Label]:
"""Query the Etherscan combined labels CSV via DuckDB.
Schema:
address,chain,source,name,label_type,label_subtype,project,entity_type,entity_types
Returns:
List of labels (usually 1 per matching row).
"""
def _query() -> list[Label]:
from app.core.duckdb_analytics import DuckDBAnalytics
try:
d = DuckDBAnalytics()
try:
rows = d.query_parquet(
CSV_PATH,
"""
SELECT address, chain, source, name, label_type, label_subtype, project, entity_type
FROM data
WHERE LOWER(address) = ?
LIMIT 20
""",
[address.lower()],
)
labels = []
for row in rows:
label_value = row.get("name") or row.get("label_type") or row.get("source")
if not label_value:
continue
chain_name = row.get("chain", chain).lower()
labels.append(
Label(
address=row["address"].lower(),
chain=chain_name,
label_type="entity",
label_value=label_value,
source=LabelSource.ETHERSCAN_CSV,
confidence=0.85,
verified_at=datetime.now(UTC),
attributes={
"raw_label_type": row.get("label_type"),
"label_subtype": row.get("label_subtype"),
"project": row.get("project"),
"entity_type": row.get("entity_type"),
},
)
)
return labels
finally:
d.close()
except Exception as e:
log.warning("etherscan_csv_query_fail address=%s err=%s", address[:10], e)
raise
return await asyncio.to_thread(_query)

View file

@ -0,0 +1,22 @@
"""Internal labels source — our own growing set in Postgres/Redis.
Currently returns []. The schema for internal labels (community
contributions, RMI-curated tags) is being designed separately.
TODO: query Postgres internal_labels table once schema lands.
"""
from __future__ import annotations
import logging
from app.domain.labels.models import Label
log = logging.getLogger(__name__)
async def query_internal(address: str, chain: str = "ethereum") -> list[Label]:
"""Query internal labels database.
Returns [] until internal_labels table is provisioned.
"""
return []

View file

@ -0,0 +1,31 @@
"""
MBAL query adapter for the federated label API.
Implements the MBAL source API for the federated label system.
Usage (called by federated.py):
labels = await query_mbal("0x...", "ethereum")
Pattern: async function that returns list[Label]
"""
from typing import List
from app.domain.labels.models import Label
from app.domain.labels.sources.mbal_source import get_mbalsy_service
async def query_mbal(address: str, chain: str) -> List[Label]:
"""
Query MBAL for labels for an address.
Args:
address: The wallet address to look up
chain: The blockchain network (ethereum, bitcoin, etc.)
Returns:
List of labels from MBAL
"""
# Get or initialize the MBAL service
service = get_mbalsy_service()
return await service.get_labels(address, chain)

View file

@ -0,0 +1,234 @@
"""
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

View 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

View file

@ -0,0 +1,22 @@
"""Open Labels Initiative (OLI) source — placeholder.
TODO: wire up when OLI endpoint is confirmed. GitHub repo:
https://github.com/openlabelsinitiative/OLI
For now returns []. Stub.
"""
from __future__ import annotations
import logging
from app.domain.labels.models import Label
log = logging.getLogger(__name__)
async def query_open_labels(address: str, chain: str = "ethereum") -> list[Label]:
"""Query OLI for an address's labels.
Returns [] until OLI endpoint is confirmed and we have API access.
"""
return []

View file

@ -0,0 +1,74 @@
"""Solana wallet labels CSV source — 103K SOL labels via DuckDB."""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from app.domain.labels.models import Label, LabelSource
log = logging.getLogger(__name__)
CSV_PATH = "/home/dev/rmi/backend/data/wallet-labels-clean/wallet_labels_solana.csv"
async def query_solana_labels_csv(address: str, chain: str = "solana") -> list[Label]:
"""Query the Solana wallet labels CSV via DuckDB.
Schema (likely): address,source,name,entity_type,threat_group,risk_level
Only matches if chain == "solana" returns [] for EVM chains.
"""
if chain != "solana":
return []
def _query() -> list[Label]:
from app.core.duckdb_analytics import DuckDBAnalytics
try:
d = DuckDBAnalytics()
try:
rows = d.query_parquet(
CSV_PATH,
"""
SELECT *
FROM data
WHERE LOWER(address) = ?
LIMIT 20
""",
[address.lower()],
)
labels = []
for row in rows:
# Field names may differ — try common names
label_value = (
row.get("name")
or row.get("label")
or row.get("entity_type")
or row.get("source")
)
if not label_value:
continue
labels.append(
Label(
address=row.get("address", address).lower(),
chain="solana",
label_type="entity",
label_value=label_value,
source=LabelSource.SOLANA_LABELS_CSV,
confidence=0.8,
verified_at=datetime.now(UTC),
attributes={
k: v for k, v in row.items()
if k not in ("address", "name", "label") and v
},
)
)
return labels
finally:
d.close()
except Exception as e:
log.warning("solana_labels_csv_query_fail address=%s err=%s", address[:10], e)
raise
return await asyncio.to_thread(_query)