rmi-backend/app/wallet_memory/label_importer.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

1261 lines
44 KiB
Python

"""
Static Label Importer - Async boot-time loader for 105K+ wallet labels.
========================================================================
Replaces the sync Redis-only wallet_label_loader.py with an async module that:
1. Downloads/locates crypto-wallet-address-labels datasets (Solana CEX, DApp, DeFi)
2. Loads etherscan CSVs from /root/backend/data/
3. Fetches OFAC crypto-sanctions-list
4. Stores everything through WalletStorage (Redis cache + ClickHouse)
Sources:
- crypto-wallet-address-labels GitHub (105K+ Solana)
- etherscan_combined_labels.csv (50K rows)
- etherscan_phish_hack.csv (111 rows)
- OFAC crypto-sanctions-list
"""
import contextlib
import csv
import json
import logging
import os
import time
from datetime import UTC, datetime
from typing import Any
import httpx
logger = logging.getLogger("wallet_memory.label_importer")
# ── Paths ──────────────────────────────────────────────────────────────
DATA_DIR = os.getenv("RMI_DATA_DIR", "/app/data")
LABELS_DIR = os.path.join(DATA_DIR, "wallet-labels")
ETHERSCAN_COMBINED = os.path.join(DATA_DIR, "etherscan_combined_labels.csv")
ETHERSCAN_PHISH_HACK = os.path.join(DATA_DIR, "etherscan_phish_hack.csv")
# GitHub base for Solana label CSVs
SOLANA_LABELS_REPO = (
"https://raw.githubusercontent.com/ImMike/"
"crypto-wallet-address-labels/main/datasets/solana-wallet-and-program-labels"
)
# Additional ImMike repo dataset paths
BSC_LABELS_REPO = (
"https://raw.githubusercontent.com/ImMike/crypto-wallet-address-labels/main/datasets/bsc-ethereum-address-tags"
)
ETH_EXCHANGE_LABELS_REPO = (
"https://raw.githubusercontent.com/ImMike/"
"crypto-wallet-address-labels/main/datasets/ethereum-exchange-and-dex-labels"
)
ETH_CLUSTER_LABELS_REPO = (
"https://raw.githubusercontent.com/ImMike/"
"crypto-wallet-address-labels/main/datasets/ethereum-cluster-exchange-labels"
)
ETH_CONTRACT_LABELS_REPO = (
"https://raw.githubusercontent.com/ImMike/crypto-wallet-address-labels/main/datasets/ethereum-contract-name-labels"
)
# OFAC sanctions URL
OFAC_URL = "https://raw.githubusercontent.com/OFAC/crypto-sanctions-list/main/crypto-sanctions-list.json"
# Solana label file specs: (filename, chain, address_col, label_col_map)
SOLANA_CSV_SPECS = [
(
"solana_cex_labels.csv",
"solana",
"address",
{
"label_type": "label_type",
"label_subtype": "label_subtype",
"name": "address_name",
"project": "project_name",
},
),
(
"solana_dapp_labels.csv",
"solana",
"address",
{
"name": "dapp_name",
},
),
(
"solana_defi_labels.csv",
"solana",
"address",
{
"name": "protocol_name",
},
),
]
# Additional Ethereum phishing/scam CSV specs from the same repo
ETH_CSV_SPECS = [
(
"phishing_scams.csv",
"ethereum",
"address",
{
"name": "etherscan_tag",
"label_type": "etherscan_labels",
},
),
(
"etherscan_malicious_labels.csv",
"ethereum",
"banned_address",
{
"name": "wallet_tag",
"label_type": "data_source",
},
),
(
"malicious_smart_contracts.csv",
"ethereum",
"contract_address",
{
"name": "contract_tag",
"label_type": "source",
},
),
]
# ClickHouse DDL for wallet_labels table (added to schema on first run)
WALLET_LABELS_DDL = """
CREATE TABLE IF NOT EXISTS wallet_labels (
address String,
chain_id LowCardinality(String),
label_name String DEFAULT '',
label_category LowCardinality(String) DEFAULT '',
label_subtype String DEFAULT '',
source LowCardinality(String) DEFAULT '',
is_sanctioned UInt8 DEFAULT 0,
loaded_at DateTime DEFAULT now()
) ENGINE = ReplacingMergeTree(loaded_at)
ORDER BY (address, chain_id, source);
"""
# ── Download helpers ────────────────────────────────────────────────────
_http_client: httpx.AsyncClient | None = None
async def _get_http() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(
timeout=30.0,
follow_redirects=True,
headers={"User-Agent": "RMI-LabelImporter/1.0"},
)
return _http_client
async def _download_file(url: str, dest: str) -> bool:
"""Download a URL to dest path. Returns True on success."""
try:
client = await _get_http()
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"Download failed {url}: HTTP {resp.status_code}")
return False
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(resp.content)
logger.info(f"Downloaded {url} -> {dest} ({len(resp.content)} bytes)")
return True
except Exception as e:
logger.warning(f"Download error {url}: {e}")
return False
async def _download_json(url: str) -> Any | None:
"""Download and parse JSON from URL. Returns None on failure."""
try:
client = await _get_http()
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"JSON download failed {url}: HTTP {resp.status_code}")
return None
return resp.json()
except Exception as e:
logger.warning(f"JSON download error {url}: {e}")
return None
# ── Solana labels ──────────────────────────────────────────────────────
async def _ensure_solana_labels() -> dict[str, int]:
"""Download Solana label CSVs if not present. Returns {filename: size}."""
os.makedirs(LABELS_DIR, exist_ok=True)
results = {}
for filename, _, _, _ in SOLANA_CSV_SPECS:
dest = os.path.join(LABELS_DIR, filename)
if os.path.exists(dest) and os.path.getsize(dest) > 0:
size = os.path.getsize(dest)
logger.info(f"Solana labels already present: {filename} ({size} bytes)")
results[filename] = size
else:
url = f"{SOLANA_LABELS_REPO}/{filename}"
ok = await _download_file(url, dest)
results[filename] = os.path.getsize(dest) if ok else 0
if not ok:
logger.warning(f"Failed to download {filename} - skipping")
return results
async def _ensure_eth_scam_labels() -> dict[str, int]:
"""Download ETH phishing/scam CSVs if not present."""
eth_scam_repo = (
"https://raw.githubusercontent.com/ImMike/"
"crypto-wallet-address-labels/main/datasets/ethereum-phishing-and-scam-labels"
)
os.makedirs(LABELS_DIR, exist_ok=True)
results = {}
for filename, _, _, _ in ETH_CSV_SPECS:
dest = os.path.join(LABELS_DIR, filename)
if os.path.exists(dest) and os.path.getsize(dest) > 0:
size = os.path.getsize(dest)
logger.info(f"ETH scam labels already present: {filename} ({size} bytes)")
results[filename] = size
else:
url = f"{eth_scam_repo}/{filename}"
ok = await _download_file(url, dest)
results[filename] = os.path.getsize(dest) if ok else 0
if not ok:
logger.warning(f"Failed to download {filename} - skipping")
return results
# ── OFAC sanctions ─────────────────────────────────────────────────────
async def _load_ofac_sanctions(storage) -> int:
"""
Load OFAC crypto-sanctions-list into storage.
Tries local file first, then downloads from GitHub.
"""
# Try local cached copy first
local_ofac = os.path.join(LABELS_DIR, "ofac_sanctions.json")
sanctions = None
if os.path.exists(local_ofac) and os.path.getsize(local_ofac) > 0:
try:
with open(local_ofac, encoding="utf-8") as f:
sanctions = json.load(f)
logger.info(f"Loaded OFAC from local cache ({len(sanctions)} entries)")
except Exception as e:
logger.warning(f"Local OFAC file corrupt: {e}")
sanctions = None
if sanctions is None:
sanctions = await _download_json(OFAC_URL)
if sanctions is None:
logger.warning("OFAC sanctions download failed - trying fallback")
# Fallback: try alternate URL formats
alt_urls = [
"https://raw.githubusercontent.com/OFAC/crypto-sanctions-list/master/crypto-sanctions-list.json",
"https://raw.githubusercontent.com/OFAC/crypto-sanctions-list/main/crypto_sanctions_list.json",
]
for alt in alt_urls:
sanctions = await _download_json(alt)
if sanctions is not None:
break
if sanctions is None:
logger.error("OFAC sanctions unavailable from all sources")
return 0
# Save local cache for next boot
try:
os.makedirs(LABELS_DIR, exist_ok=True)
with open(local_ofac, "w", encoding="utf-8") as f:
json.dump(sanctions, f)
except Exception:
pass
# Store in Redis and ClickHouse
count = 0
ch_rows = []
for entry in sanctions:
addr = ""
if isinstance(entry, dict):
addr = entry.get("address", entry.get("Address", ""))
if not addr or len(str(addr).strip()) < 10:
continue
addr_lower = str(addr).strip().lower()
entry_json = json.dumps(entry)
# Redis: rmi:label:ofac:{address}
await storage.redis_set(f"rmi:label:ofac:{addr_lower}", entry_json)
await storage.redis_sadd("rmi:labels:ofac", addr_lower)
# ClickHouse row
name = ""
if isinstance(entry, dict):
name = entry.get("name", entry.get("Name", entry.get("entity", "")))
ch_rows.append(
(
addr_lower,
"",
str(name) if name else "OFAC Sanctioned",
"sanctioned",
"",
"ofac",
1,
datetime.now(UTC),
)
)
count += 1
# Batch insert to ClickHouse
if ch_rows and storage._ch:
try:
storage._ch.execute(
"""INSERT INTO wallet_labels
(address, chain_id, label_name, label_category, label_subtype,
source, is_sanctioned, loaded_at) VALUES""",
ch_rows,
)
except Exception as e:
logger.warning(f"ClickHouse OFAC batch insert failed: {e}")
await storage.redis_set("rmi:labels:ofac:count", str(count))
logger.info(f"Loaded {count} OFAC sanctions")
return count
# ── CSV loading ─────────────────────────────────────────────────────────
async def _load_csv_labels(
storage,
path: str,
chain: str,
addr_col: str,
label_cols: dict[str, str],
source_name: str = "",
) -> int:
"""
Load a CSV of labeled addresses into Redis + ClickHouse via WalletStorage.
Returns count of addresses loaded.
"""
if not os.path.exists(path):
logger.warning(f"CSV not found: {path}")
return 0
src = source_name or os.path.basename(path)
# Skip if source already has labels in ClickHouse (prevents re-import)
if storage._ch:
try:
existing = storage._ch.execute("SELECT count() FROM wallet_labels WHERE source = %(src)s", {"src": src})
if existing and existing[0][0] > 100:
logger.info(f"Source '{src}' already has {existing[0][0]:,} labels in CH - skipping re-import")
return existing[0][0]
except Exception:
pass # CH unavailable - import anyway for Redis
count = 0
ch_rows = []
redis_pipe_ops = [] # Collect for batch Redis ops
try:
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
# Normalize headers to lowercase for case-insensitive matching
if reader.fieldnames:
reader.fieldnames = [h.lower().strip() for h in reader.fieldnames]
for row in reader:
addr = row.get(addr_col, "").strip().lower()
if not addr or len(addr) < 10:
continue
# Build label data dict
label = {"chain": chain, "address": addr, "source": src}
for key, col in label_cols.items():
val = row.get(col, "").strip()
if val:
label[key] = val
# Also pick up any extra columns we recognise
for extra in ("name", "label", "label_type", "label_subtype"):
if extra not in label:
val = row.get(extra, "").strip()
if val:
label[extra] = val
label_json = json.dumps(label)
# Redis: rmi:label:{chain}:{address} (matching wallet_label_loader format)
redis_key = f"rmi:label:{chain}:{addr}"
redis_pipe_ops.append((redis_key, label_json, addr))
# ClickHouse row
name = label.get(
"address_name",
label.get("dapp_name", label.get("protocol_name", label.get("name", ""))),
)
category = label.get("label_type", label.get("label", ""))
subtype = label.get("label_subtype", "")
ch_rows.append(
(
addr,
chain,
str(name) if name else "",
str(category) if category else "",
str(subtype) if subtype else "",
src,
0,
datetime.now(UTC),
)
)
count += 1
# Flush in batches of 5000
if count % 5000 == 0:
await _flush_batch(storage, redis_pipe_ops, ch_rows, chain)
redis_pipe_ops = []
ch_rows = []
except Exception as e:
logger.warning(f"Failed to load CSV {path}: {e}")
# Flush remaining
if redis_pipe_ops or ch_rows:
await _flush_batch(storage, redis_pipe_ops, ch_rows, chain)
logger.info(f"Loaded {count} labels from {src} (chain={chain})")
return count
async def _flush_batch(
storage,
redis_ops: list[tuple[str, str, str]],
ch_rows: list[tuple],
chain: str,
):
"""Flush a batch of Redis writes and ClickHouse inserts."""
# Batch Redis writes
if storage._redis and redis_ops:
try:
async with storage._redis.pipeline() as pipe:
for redis_key, label_json, addr in redis_ops:
pipe.set(redis_key, label_json)
pipe.sadd(f"rmi:labels:{chain}", addr)
await pipe.execute()
except Exception as e:
# Fallback: individual writes
logger.debug(f"Redis pipeline failed, falling back: {e}")
for redis_key, label_json, addr in redis_ops:
await storage.redis_set(redis_key, label_json)
await storage.redis_sadd(f"rmi:labels:{chain}", addr)
# Batch ClickHouse insert
if ch_rows and storage._ch:
try:
storage._ch.execute(
"""INSERT INTO wallet_labels
(address, chain_id, label_name, label_category, label_subtype,
source, is_sanctioned, loaded_at) VALUES""",
ch_rows,
)
except Exception as e:
logger.warning(f"ClickHouse batch insert failed: {e}")
# ── Etherscan combined labels ──────────────────────────────────────────
async def _load_etherscan_combined(storage) -> int:
"""
Load etherscan_combined_labels.csv.
Columns: address, name, label, chain, source
"""
if not os.path.exists(ETHERSCAN_COMBINED):
logger.warning(f"Etherscan combined labels not found: {ETHERSCAN_COMBINED}")
return 0
count = 0
ch_rows = []
redis_ops = []
try:
with open(ETHERSCAN_COMBINED, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
addr = row.get("address", "").strip().lower()
if not addr or len(addr) < 10:
continue
chain_raw = row.get("chain", "ethereum").strip().lower()
# Normalize chain names
chain = _normalize_chain(chain_raw)
name = row.get("name", "").strip()
label = row.get("label", "").strip()
row.get("source", "etherscan").strip()
label_data = {
"chain": chain,
"address": addr,
"address_name": name,
"label_type": label,
"source": "etherscan_combined",
}
label_json = json.dumps(label_data)
redis_key = f"rmi:label:{chain}:{addr}"
redis_ops.append((redis_key, label_json, addr))
ch_rows.append(
(
addr,
chain,
name,
label,
"",
"etherscan_combined",
0,
datetime.now(UTC),
)
)
count += 1
if count % 5000 == 0:
await _flush_batch(storage, redis_ops, ch_rows, chain)
redis_ops = []
ch_rows = []
except Exception as e:
logger.warning(f"Failed to load etherscan combined: {e}")
if redis_ops or ch_rows:
await _flush_batch(storage, redis_ops, ch_rows, "ethereum")
logger.info(f"Loaded {count} etherscan combined labels")
return count
# ── Etherscan phishing/hack labels ─────────────────────────────────────
async def _load_etherscan_phish_hack(storage) -> int:
"""
Load etherscan_phish_hack.csv.
Columns: address, name_tag, label_type, chain, balance, txn_count, source
"""
if not os.path.exists(ETHERSCAN_PHISH_HACK):
logger.warning(f"Etherscan phish/hack not found: {ETHERSCAN_PHISH_HACK}")
return 0
count = 0
ch_rows = []
redis_ops = []
try:
with open(ETHERSCAN_PHISH_HACK, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
addr = row.get("address", "").strip().lower()
if not addr or len(addr) < 10:
continue
chain_raw = row.get("chain", "ethereum").strip().lower()
chain = _normalize_chain(chain_raw)
name_tag = row.get("name_tag", "").strip()
label_type = row.get("label_type", "").strip()
balance = row.get("balance", "").strip()
txn_count = row.get("txn_count", "").strip()
label_data = {
"chain": chain,
"address": addr,
"address_name": name_tag,
"label_type": label_type,
"source": "etherscan_phish_hack",
}
if balance:
label_data["balance"] = balance
if txn_count:
label_data["txn_count"] = txn_count
label_json = json.dumps(label_data)
redis_key = f"rmi:label:{chain}:{addr}"
redis_ops.append((redis_key, label_json, addr))
# These are phishing/hack labels - also store in scam_addresses
is_scam = 1 if label_type.lower() in ("scam", "phishing", "hack") else 0
ch_rows.append(
(
addr,
chain,
name_tag,
label_type,
"",
"etherscan_phish_hack",
is_scam,
datetime.now(UTC),
)
)
# Also add to the scam_addresses ClickHouse table
if is_scam and storage._ch:
with contextlib.suppress(Exception):
storage._ch.execute(
"""INSERT INTO scam_addresses
(address, chain_id, source, threat_type, confidence,
first_seen_at, evidence) VALUES""",
[
(
addr,
chain,
"etherscan_phish_hack",
label_type,
0.95,
datetime.now(UTC),
json.dumps({"name_tag": name_tag, "balance": balance}),
)
],
)
count += 1
except Exception as e:
logger.warning(f"Failed to load etherscan phish/hack: {e}")
if redis_ops or ch_rows:
await _flush_batch(storage, redis_ops, ch_rows, "ethereum")
logger.info(f"Loaded {count} etherscan phish/hack labels")
return count
# ── BSC address tags ────────────────────────────────────────────────────
async def _ensure_bsc_labels() -> bool:
"""Download BSC detail CSV if not present."""
dest = os.path.join(LABELS_DIR, "2024-01-31-detail.csv")
if os.path.exists(dest) and os.path.getsize(dest) > 0:
logger.info(f"BSC detail labels already present ({os.path.getsize(dest)} bytes)")
return True
url = f"{BSC_LABELS_REPO}/2024-01-31-detail.csv"
return await _download_file(url, dest)
async def _load_bsc_detail(storage) -> int:
"""Load BSC address tag detail CSV. 15K BSC addresses with entity labels."""
path = os.path.join(LABELS_DIR, "2024-01-31-detail.csv")
if not os.path.exists(path):
logger.warning(f"BSC detail CSV not found: {path}")
return 0
count = 0
ch_rows = []
redis_ops = []
try:
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
addr = row.get("address", "").strip().lower()
if not addr or len(addr) < 10:
continue
label_name = row.get("label_name", "").strip()
name_tag = row.get("name_tag", "").strip()
addr_type = row.get("address_type", "").strip()
chain_code = row.get("chain_code", "BSC").strip().lower()
chain = _normalize_chain(chain_code)
category = (
"exchange"
if "exchange" in (label_name + name_tag).lower()
else "defi"
if any(w in (label_name + name_tag).lower() for w in ("protocol", "swap", "defi", "lend"))
else "bridge"
if "bridge" in (label_name + name_tag).lower()
else "wallet"
)
label_data = {
"chain": chain,
"address": addr,
"address_name": label_name,
"label_type": name_tag or label_name,
"label_subtype": addr_type,
"source": "bsc_detail",
}
label_json = json.dumps(label_data)
redis_key = f"rmi:label:{chain}:{addr}"
redis_ops.append((redis_key, label_json, addr))
ch_rows.append(
(
addr,
chain,
name_tag or label_name,
category,
addr_type,
"bsc_detail",
0,
datetime.now(UTC),
)
)
count += 1
if count % 5000 == 0:
await _flush_batch(storage, redis_ops, ch_rows, chain)
redis_ops = []
ch_rows = []
except Exception as e:
logger.warning(f"Failed to load BSC detail: {e}")
if redis_ops or ch_rows:
await _flush_batch(storage, redis_ops, ch_rows, "bsc")
logger.info(f"Loaded {count} BSC detail labels")
return count
# ── Exchange cluster labels ─────────────────────────────────────────────
async def _ensure_exchange_cluster_labels() -> bool:
"""Download exchange cluster CSV."""
dest = os.path.join(LABELS_DIR, "exchanges_cluster.csv")
if os.path.exists(dest) and os.path.getsize(dest) > 0:
return True
url = f"{ETH_CLUSTER_LABELS_REPO}/exchanges.csv"
return await _download_file(url, dest)
async def _load_exchange_clusters(storage) -> int:
"""Load exchange cluster entity labels. ~200 exchange addresses."""
path = os.path.join(LABELS_DIR, "exchanges_cluster.csv")
# Also support direct-fetched path
if not os.path.exists(path):
path = os.path.join(LABELS_DIR, "exchanges.csv")
if not os.path.exists(path):
logger.warning("Exchange cluster CSV not found")
return 0
count = 0
ch_rows = []
redis_ops = []
try:
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
addr = row.get("address", "").strip().lower()
if not addr or len(addr) < 10:
continue
name = row.get("name", "").strip()
acct_type = row.get("accountType", "").strip()
entity_type = row.get("type", "").strip()
chain = "ethereum"
label_data = {
"chain": chain,
"address": addr,
"address_name": name,
"label_type": entity_type,
"label_subtype": acct_type,
"source": "exchange_cluster",
}
label_json = json.dumps(label_data)
redis_key = f"rmi:label:{chain}:{addr}"
redis_ops.append((redis_key, label_json, addr))
ch_rows.append(
(
addr,
chain,
name,
entity_type.lower(),
acct_type,
"exchange_cluster",
0,
datetime.now(UTC),
)
)
count += 1
except Exception as e:
logger.warning(f"Failed to load exchange clusters: {e}")
if redis_ops or ch_rows:
await _flush_batch(storage, redis_ops, ch_rows, "ethereum")
logger.info(f"Loaded {count} exchange cluster labels")
return count
# ── Contract name labels (name_dict.txt) ────────────────────────────────
async def _ensure_contract_name_labels() -> bool:
"""Download contract name dict."""
dest = os.path.join(LABELS_DIR, "name_dict.txt")
if os.path.exists(dest) and os.path.getsize(dest) > 0:
return True
url = f"{ETH_CONTRACT_LABELS_REPO}/name_dict.txt"
return await _download_file(url, dest)
async def _load_contract_names(storage) -> int:
"""Load contract name labels from name_dict.txt. ~900 Ethereum contracts."""
path = os.path.join(LABELS_DIR, "name_dict.txt")
if not os.path.exists(path):
logger.warning("Contract name dict not found")
return 0
count = 0
ch_rows = []
redis_ops = []
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
# Format: "0xADDRESS - Name"
parts = line.split(" - ", 1)
if len(parts) < 2:
continue
addr = parts[0].strip().lower()
name = parts[1].strip()
if not addr.startswith("0x") or len(addr) < 10:
continue
chain = "ethereum"
label_data = {
"chain": chain,
"address": addr,
"address_name": name,
"label_type": "contract",
"source": "contract_name_labels",
}
label_json = json.dumps(label_data)
redis_key = f"rmi:label:{chain}:{addr}"
redis_ops.append((redis_key, label_json, addr))
ch_rows.append(
(
addr,
chain,
name,
"contract",
"",
"contract_name_labels",
0,
datetime.now(UTC),
)
)
count += 1
except Exception as e:
logger.warning(f"Failed to load contract names: {e}")
if redis_ops or ch_rows:
await _flush_batch(storage, redis_ops, ch_rows, "ethereum")
logger.info(f"Loaded {count} contract name labels")
return count
# ── Exchange/DEX wallet labels ──────────────────────────────────────────
_EXCHANGE_WALLET_FILES = [
("exchange.csv", "exchange_wallets", "ethereum"),
("dex.csv", "dex_wallets", "ethereum"),
("icowallets.csv", "ico_wallets", "ethereum"),
("mining1.csv", "mining_wallets_part1", "ethereum"),
("mining2.csv", "mining_wallets_part2", "ethereum"),
("walletapp.csv", "walletapp_wallets", "ethereum"),
]
async def _ensure_exchange_wallet_labels() -> dict[str, int]:
"""Download all exchange/DeFi wallet label CSVs."""
os.makedirs(LABELS_DIR, exist_ok=True)
results = {}
for filename, _, _ in _EXCHANGE_WALLET_FILES:
dest = os.path.join(LABELS_DIR, filename)
if os.path.exists(dest) and os.path.getsize(dest) > 0:
results[filename] = os.path.getsize(dest)
else:
url = f"{ETH_EXCHANGE_LABELS_REPO}/{filename}"
ok = await _download_file(url, dest)
results[filename] = os.path.getsize(dest) if ok else 0
return results
async def _load_exchange_wallets(storage) -> int:
"""Load exchange/dex/ico/mining wallet labels. ~350 labeled wallets."""
# Files that have NO headers (address,entity,balance,txcount format)
HEADERLESS_FILES = {"exchange.csv", "mining1.csv", "mining2.csv"}
total = 0
for filename, _, chain in _EXCHANGE_WALLET_FILES:
path = os.path.join(LABELS_DIR, filename)
if not os.path.exists(path):
logger.warning(f"Exchange wallet CSV not found: {filename}")
continue
count = 0
ch_rows = []
redis_ops = []
try:
if filename in HEADERLESS_FILES:
# Headerless CSV: address,entity,balance,txcount
with open(path, encoding="utf-8-sig") as f:
reader = csv.reader(f)
for row in reader:
if len(row) < 2:
continue
addr = row[0].strip().lower()
entity = row[1].strip() if len(row) > 1 else ""
balance = row[2].strip().strip('"') if len(row) > 2 else ""
txcount = row[3].strip().strip('"') if len(row) > 3 else ""
if not addr or len(addr) < 10:
continue
# Infer category
cat = "exchange"
if "mining" in filename:
cat = "mining"
label_data = {
"chain": chain,
"address": addr,
"address_name": entity,
"label_type": cat,
"source": "exchange_wallets",
}
if balance:
label_data["balance"] = balance
if txcount:
label_data["txn_count"] = txcount
label_json = json.dumps(label_data)
redis_key = f"rmi:label:{chain}:{addr}"
redis_ops.append((redis_key, label_json, addr))
ch_rows.append(
(
addr,
chain,
entity,
cat,
"",
"exchange_wallets",
0,
datetime.now(UTC),
)
)
count += 1
else:
# Standard CSVs with headers
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
addr = row.get("address", "") or row.get("from_address", "")
addr = addr.strip().lower()
if not addr or len(addr) < 10:
continue
entity = row.get("entity", row.get("name", "")).strip()
balance = row.get("balance", "")
txcount = row.get("txcount", row.get("txn_count", ""))
cat = "exchange"
if "dex" in filename:
cat = "defi"
elif "ico" in filename:
cat = "ico"
elif "walletapp" in filename:
cat = "wallet"
label_data = {
"chain": chain,
"address": addr,
"address_name": entity,
"label_type": cat,
"source": "exchange_wallets",
}
if balance:
label_data["balance"] = balance
if txcount:
label_data["txn_count"] = txcount
label_json = json.dumps(label_data)
redis_key = f"rmi:label:{chain}:{addr}"
redis_ops.append((redis_key, label_json, addr))
ch_rows.append(
(
addr,
chain,
entity,
cat,
"",
"exchange_wallets",
0,
datetime.now(UTC),
)
)
count += 1
except Exception as e:
logger.warning(f"Failed to load {filename}: {e}")
continue
if redis_ops or ch_rows:
await _flush_batch(storage, redis_ops, ch_rows, chain)
logger.info(f"Loaded {count} labels from {filename}")
total += count
logger.info(f"Loaded {total} total exchange wallet labels")
return total
# ── Chain normalization ─────────────────────────────────────────────────
_CHAIN_ALIASES = {
"eth": "ethereum",
"eth_mainnet": "ethereum",
"bsc": "bsc",
"bnb": "bsc",
"bnb_chain": "bsc",
"polygon": "polygon",
"matic": "polygon",
"arbitrum": "arbitrum",
"arb": "arbitrum",
"optimism": "optimism",
"op": "optimism",
"avalanche": "avalanche",
"avax": "avalanche",
"fantom": "fantom",
"ftm": "fantom",
"base": "base",
"sol": "solana",
"solana": "solana",
}
def _normalize_chain(chain: str) -> str:
"""Normalize chain name to canonical form."""
return _CHAIN_ALIASES.get(chain.lower().strip(), chain.lower().strip()) or "ethereum"
# ── ClickHouse schema ───────────────────────────────────────────────────
async def _ensure_wallet_labels_table(storage):
"""Create the wallet_labels table in ClickHouse if it doesn't exist."""
if not storage._ch:
return
try:
storage._ch.execute("CREATE DATABASE IF NOT EXISTS wallet_memory")
storage._ch.execute(WALLET_LABELS_DDL)
logger.info("wallet_labels table ensured in ClickHouse")
except Exception as e:
logger.warning(f"Failed to create wallet_labels table: {e}")
# ── Main entry point ────────────────────────────────────────────────────
_loading = False
_last_load_counts: dict[str, int] = {}
async def load_all_static_labels() -> dict[str, int]:
"""
Load all static wallet label datasets on boot.
Sources:
1. Solana CEX labels (crypto-wallet-address-labels GitHub)
2. Solana DApp labels
3. Solana DeFi labels
4. Etherscan combined labels (50K rows)
5. Etherscan phishing/hack labels (111 rows)
6. OFAC crypto sanctions
7. BSC address tags (15K rows)
8. Exchange cluster labels (~200)
9. Contract name labels (~900)
10. Exchange/DEX/ICO/Mining wallet labels (~350)
Returns counts per source.
"""
global _loading, _last_load_counts
if _loading:
logger.info("Label import already in progress - skipping")
return _last_load_counts
_loading = True
start = time.time()
counts: dict[str, int] = {}
try:
from .storage import get_storage
storage = get_storage()
# Ensure connections
await storage._ensure_connections()
logger.info(
f"Storage connections: redis={'ok' if storage._redis else 'no'}, clickhouse={'ok' if storage._ch else 'no'}"
)
# Ensure ClickHouse table exists
await _ensure_wallet_labels_table(storage)
# 1. Download Solana label CSVs if needed
await _ensure_solana_labels()
# 2. Load Solana CSVs
for filename, chain, addr_col, label_cols in SOLANA_CSV_SPECS:
path = os.path.join(LABELS_DIR, filename)
key = filename.replace(".csv", "").replace("_labels", "")
if os.path.exists(path):
c = await _load_csv_labels(storage, path, chain, addr_col, label_cols, source_name=key)
counts[key] = c
else:
counts[key] = 0
logger.warning(f"Solana labels missing: {filename}")
# 2b. Load additional ETH phishing/scam CSVs from wallet-labels dir
for filename, chain, addr_col, label_cols in ETH_CSV_SPECS:
path = os.path.join(LABELS_DIR, filename)
key = filename.replace(".csv", "").replace("_labels", "")
if os.path.exists(path):
c = await _load_csv_labels(storage, path, chain, addr_col, label_cols, source_name=key)
counts[key] = c
else:
counts[key] = 0
# 3. Load Etherscan combined labels
counts["etherscan_combined"] = await _load_etherscan_combined(storage)
# 4. Load Etherscan phishing/hack labels
counts["etherscan_phish_hack"] = await _load_etherscan_phish_hack(storage)
# 5. Load OFAC sanctions
counts["ofac"] = await _load_ofac_sanctions(storage)
# === NEW SOURCES ===
# 6. Download and load BSC address tags
await _ensure_bsc_labels()
counts["bsc_detail"] = await _load_bsc_detail(storage)
# 7. Download and load exchange cluster labels
await _ensure_exchange_cluster_labels()
counts["exchange_cluster"] = await _load_exchange_clusters(storage)
# 8. Download and load contract name labels
await _ensure_contract_name_labels()
counts["contract_names"] = await _load_contract_names(storage)
# 9. Download and load exchange/DEX wallet labels
await _ensure_exchange_wallet_labels()
counts["exchange_wallets"] = await _load_exchange_wallets(storage)
# Store aggregate counts in Redis
total = sum(counts.values())
counts["total"] = total
await storage.redis_set("rmi:labels:counts", json.dumps(counts))
elapsed = time.time() - start
logger.info(f"Static label import complete: {total} labels in {elapsed:.1f}s - {counts}")
except Exception as e:
logger.error(f"Static label import failed: {e}", exc_info=True)
counts["error"] = str(e)
finally:
_loading = False
_last_load_counts = counts
return counts
def get_last_load_counts() -> dict[str, int]:
"""Return counts from the most recent label import."""
return _last_load_counts
def is_loading() -> bool:
"""Check if a label import is currently in progress."""
return _loading
# ── Clean Data Loader (from label quality fixer) ─────────────────────
CLEAN_DIR = os.path.join(DATA_DIR, "wallet-labels-clean")
async def load_clean_labels(storage) -> dict:
"""Load pre-cleaned, deduplicated labels from the quality fixer.
Returns stats dict with counts."""
import csv
stats = {"ethereum": 0, "solana": 0, "errors": 0}
for chain in ["ethereum", "solana"]:
path = os.path.join(CLEAN_DIR, f"wallet_labels_{chain}.csv")
if not os.path.exists(path):
logger.warning(f"Clean labels file not found: {path}")
continue
labels = []
with open(path) as f:
reader = csv.DictReader(f)
for row in reader:
labels.append(
{
"address": row["address"],
"chain": row["chain"],
"source": row.get("source", "clean_import"),
"name": row.get("name", ""),
"label_type": row.get("label_type", ""),
"label_subtype": row.get("label_subtype", ""),
"project": row.get("project", ""),
"entity_type": row.get("entity_type", "unknown"),
}
)
if labels and storage:
try:
await storage.bulk_import(labels, source=f"clean_{chain}")
stats[chain] = len(labels)
logger.info(f"Loaded {len(labels):,} clean {chain} labels")
except Exception as e:
logger.error(f"Failed to import clean {chain} labels: {e}")
stats["errors"] += 1
logger.info(f"Clean label import complete: {stats}")
return stats