File size: 9,561 Bytes
bde2f3a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | #!/usr/bin/env python3
"""
Etherscan Label Ingestion Script
=================================
Reads consolidated Etherscan label CSVs and ingests each labeled address
into the RAG vector store using the same approach as the rekt/curated scripts.
- etherscan_phish_hack.csv → "known_scams" collection
- etherscan_combined_labels.csv → "known_scams" (scam-adjacent labels) or
"wallet_profiles" (all other labels)
Usage:
python scripts/ingest_etherscan_labels.py [--phish-only] [--combined-only] [--limit N]
Options:
--phish-only Only ingest the phish-hack CSV (known_scams)
--combined-only Only ingest the combined labels CSV
--limit N Max rows to ingest per CSV (for testing)
"""
import argparse
import asyncio
import csv
import hashlib
import logging
import os
import sys
# Add parent dir to path so we can import app modules
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("ingest_etherscan_labels")
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data")
# Scam-adjacent label keywords — these go to known_scams instead of wallet_profiles
SCAM_LABEL_KEYWORDS = [
"phish",
"hack",
"exploit",
"scam",
"drain",
"attack",
"heist",
"malicious",
"fraud",
"rug",
"compromis",
"whale-alert",
]
def is_scam_label(label: str) -> bool:
"""Check if a label category is scam-adjacent."""
label_lower = label.lower()
return any(kw in label_lower for kw in SCAM_LABEL_KEYWORDS)
def make_doc_id(collection: str, address: str, label: str = "", chain: str = "") -> str:
"""Stable doc ID based on collection + address + label (dedup safe)."""
raw = f"{collection}:{address.lower()}:{label}:{chain}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
async def ingest_via_api(collection: str, content: str, metadata: dict, doc_id: str) -> dict:
"""
Ingest a document via the backend HTTP API (same as rekt script pattern).
Falls back to direct import if the API is unavailable.
"""
try:
import httpx
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
"http://localhost:8000/api/v1/rag/ingest",
json={
"collection": collection,
"content": content,
"metadata": metadata,
},
)
if resp.status_code == 200:
return resp.json()
else:
logger.warning(f"API ingest failed ({resp.status_code}), falling back to direct")
except Exception as e:
logger.debug(f"API unavailable ({e}), falling back to direct import")
# Direct import fallback
from app.rag_service import ingest_document
return await ingest_document(collection, content, metadata, doc_id)
async def ingest_phish_hack_csv(limit: int = 0) -> dict[str, int]:
"""Read etherscan_phish_hack.csv and ingest into known_scams collection."""
csv_path = os.path.join(DATA_DIR, "etherscan_phish_hack.csv")
if not os.path.exists(csv_path):
logger.error(f"Phish-hack CSV not found: {csv_path}")
return {"ingested": 0, "errors": 0}
stats = {"ingested": 0, "errors": 0}
rows = []
with open(csv_path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append(row)
if limit > 0:
rows = rows[:limit]
logger.info(f"Ingesting {len(rows)} phish-hack rows into known_scams...")
for i, row in enumerate(rows):
address = row["address"].strip()
name_tag = row.get("name_tag", "").strip()
label_type = row.get("label_type", "phish-hack").strip()
chain = row.get("chain", "Ethereum").strip()
balance = row.get("balance", "").strip()
txn_count = row.get("txn_count", "").strip()
if not address:
continue
content = (
f"Etherscan {label_type} label: {address} on {chain}. "
f"Tag: {name_tag}. Balance: {balance}. Txns: {txn_count}."
)
metadata = {
"address": address.lower(),
"label_type": label_type,
"name_tag": name_tag,
"chain": chain,
"balance": balance,
"txn_count": txn_count,
"source": "etherscan",
"severity": "high" if "phish" in label_type.lower() else "critical",
}
doc_id = make_doc_id("known_scams", address, label_type, chain)
try:
await ingest_via_api("known_scams", content, metadata, doc_id)
stats["ingested"] += 1
if (i + 1) % 20 == 0 or (i + 1) == len(rows):
logger.info(f" Progress: {i + 1}/{len(rows)} phish-hack rows ingested")
except Exception as e:
logger.error(f"Failed to ingest {address}: {e}")
stats["errors"] += 1
return stats
async def ingest_combined_labels_csv(limit: int = 0) -> dict[str, int]:
"""Read etherscan_combined_labels.csv and route to known_scams or wallet_profiles."""
csv_path = os.path.join(DATA_DIR, "etherscan_combined_labels.csv")
if not os.path.exists(csv_path):
logger.error(f"Combined labels CSV not found: {csv_path}")
return {"known_scams": 0, "wallet_profiles": 0, "errors": 0}
stats = {"known_scams": 0, "wallet_profiles": 0, "errors": 0}
rows = []
with open(csv_path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
rows.append(row)
if limit > 0:
rows = rows[:limit]
# Count what goes where
scam_rows = [r for r in rows if is_scam_label(r.get("label", ""))]
wallet_rows = [r for r in rows if not is_scam_label(r.get("label", ""))]
logger.info(
f"Ingesting {len(rows)} combined label rows: "
f"{len(scam_rows)} → known_scams, {len(wallet_rows)} → wallet_profiles"
)
for i, row in enumerate(rows):
address = row["address"].strip()
name = row.get("name", "").strip()
label = row.get("label", "").strip()
chain = row.get("chain", "Ethereum").strip()
if not address or not label:
continue
# Route to collection
if is_scam_label(label):
collection = "known_scams"
content = (
f"Etherscan scam label: {address} ({name}) on {chain}. "
f"Category: {label}. Flagged as scam/phishing/exploit address."
)
metadata = {
"address": address.lower(),
"label": label,
"name": name,
"chain": chain,
"source": "etherscan",
"severity": "high",
"label_type": "scam",
}
else:
collection = "wallet_profiles"
content = f"Etherscan labeled address: {address} ({name}) on {chain}. Category: {label}."
metadata = {
"address": address.lower(),
"label": label,
"name": name,
"chain": chain,
"source": "etherscan",
"label_type": "profile",
}
doc_id = make_doc_id(collection, address, label, chain)
try:
await ingest_via_api(collection, content, metadata, doc_id)
stats[collection] += 1
except Exception as e:
logger.error(f"Failed to ingest {address} ({label}): {e}")
stats["errors"] += 1
# Progress reporting
if (i + 1) % 1000 == 0 or (i + 1) == len(rows):
logger.info(
f" Progress: {i + 1}/{len(rows)} | "
f"known_scams: {stats['known_scams']}, "
f"wallet_profiles: {stats['wallet_profiles']}, "
f"errors: {stats['errors']}"
)
# Yield to event loop periodically to avoid blocking
if (i + 1) % 500 == 0:
await asyncio.sleep(0)
return stats
async def main():
parser = argparse.ArgumentParser(description="Ingest Etherscan labels into RAG")
parser.add_argument("--phish-only", action="store_true", help="Only ingest phish-hack CSV")
parser.add_argument("--combined-only", action="store_true", help="Only ingest combined labels CSV")
parser.add_argument("--limit", type=int, default=0, help="Max rows per CSV (0=all)")
args = parser.parse_args()
results = {}
if not args.combined_only:
logger.info("=" * 60)
logger.info("INGESTING PHISH-HACK LABELS → known_scams")
logger.info("=" * 60)
phish_stats = await ingest_phish_hack_csv(limit=args.limit)
results["phish_hack"] = phish_stats
logger.info(f"Phish-hack complete: {phish_stats}")
if not args.phish_only:
logger.info("=" * 60)
logger.info("INGESTING COMBINED LABELS → known_scams + wallet_profiles")
logger.info("=" * 60)
combined_stats = await ingest_combined_labels_csv(limit=args.limit)
results["combined_labels"] = combined_stats
logger.info(f"Combined labels complete: {combined_stats}")
logger.info("=" * 60)
logger.info(f"ALL INGESTION COMPLETE: {results}")
logger.info("=" * 60)
return results
if __name__ == "__main__":
asyncio.run(main())
|