- 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>
1261 lines
44 KiB
Python
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
|