| """ |
| Real-CATS and MBAL Dataset Providers |
| ===================================== |
| Two massive free datasets for AML detection and address labeling. |
| |
| 1. Real-CATS β 153,121 addresses (50,943 criminal + 102,178 benign) |
| with full transaction profiles. Ideal for risk scoring and AML. |
| Source: https://github.com/sjdseu/Real-CATS |
| |
| 2. MBAL β 10 million annotated crypto addresses across 5 chains |
| with 62 categories. The largest free label dataset available. |
| Source: https://www.kaggle.com/datasets/yidongchaintoolai/mbal-10m-crypto-address-label-dataset |
| NOTE: Requires manual Kaggle download. Place files in ~/rmi/mbal/ |
| """ |
|
|
| import csv |
| import logging |
| import os |
|
|
| logger = logging.getLogger("databus.dataset_providers") |
|
|
| |
| |
| |
|
|
| REAL_CATS_PATHS = [ |
| "/tmp/Real-CATS", |
| "/app/Real-CATS", |
| os.path.expanduser("~/rmi/Real-CATS"), |
| os.path.expanduser("~/rmi/datasets/Real-CATS"), |
| ] |
|
|
| |
| REAL_CATS_FILE_LABELS = { |
| "CB.tsv": "criminal", |
| "CE.tsv": "criminal", |
| "BB.tsv": "benign", |
| "BE.tsv": "benign", |
| "Sup-CATS.tsv": "criminal", |
| "TI_B.tsv": "benign", |
| "TI_M.tsv": "criminal", |
| "Identifier.tsv": "mixed", |
| } |
|
|
|
|
| def _find_real_cats_dir() -> str | None: |
| for p in REAL_CATS_PATHS: |
| if os.path.isdir(p) and any(f.endswith(".tsv") for f in os.listdir(p)): |
| return p |
| return None |
|
|
|
|
| def _load_real_cats() -> dict: |
| """Load Real-CATS dataset into memory.""" |
| base = _find_real_cats_dir() |
| if not base: |
| return { |
| "error": "Real-CATS dataset not found. Clone from https://github.com/sjdseu/Real-CATS" |
| } |
|
|
| result = {"criminal": [], "benign": [], "stats": {}} |
|
|
| for filename, label in REAL_CATS_FILE_LABELS.items(): |
| filepath = os.path.join(base, filename) |
| if not os.path.exists(filepath): |
| continue |
|
|
| is_criminal = label == "criminal" |
| is_benign = label == "benign" |
|
|
| try: |
| with open(filepath, encoding="utf-8") as f: |
| reader = csv.DictReader(f, delimiter="\t") |
| for row in reader: |
| addr = row.get("address", "").strip() |
| if not addr: |
| continue |
|
|
| entry = { |
| "address": addr, |
| "label": row.get("label", "criminal" if is_criminal else "benign"), |
| "chain": "bitcoin" if filename.startswith("B") else "ethereum", |
| "source_file": filename, |
| } |
| |
| for k in ( |
| "balance", |
| "total_received_BTC", |
| "total_sent_BTC", |
| "total_received_USD", |
| "total_sent_USD", |
| "transaction_number", |
| "first_time", |
| "last_time", |
| "lifetime", |
| ): |
| if k in row: |
| entry[k] = row[k] |
|
|
| if is_criminal: |
| result["criminal"].append(entry) |
| elif is_benign: |
| result["benign"].append(entry) |
| else: |
| |
| if ( |
| "scam" in (row.get("label", "") or "").lower() |
| or "criminal" in (row.get("label", "") or "").lower() |
| ): |
| result["criminal"].append(entry) |
| else: |
| result["benign"].append(entry) |
|
|
| except Exception as e: |
| logger.warning(f"Real-CATS: failed to parse {filepath}: {e}") |
|
|
| result["stats"] = { |
| "criminal_count": len(result["criminal"]), |
| "benign_count": len(result["benign"]), |
| "total": len(result["criminal"]) + len(result["benign"]), |
| "source": "Real-CATS (GitHub)", |
| "url": "https://github.com/sjdseu/Real-CATS", |
| "paper": "https://arxiv.org/html/2501.15553v1", |
| "files_loaded": sum( |
| 1 for f in REAL_CATS_FILE_LABELS if os.path.exists(os.path.join(base, f)) |
| ), |
| } |
|
|
| return result |
|
|
|
|
| async def fetch_real_cats( |
| address: str | None = None, category: str = "all", limit: int = 50 |
| ) -> dict: |
| """Query Real-CATS β check if address is criminal, or list criminal/benign addresses.""" |
| data = _load_real_cats() |
|
|
| if "error" in data: |
| return data |
|
|
| if address: |
| addr = address.lower() |
| |
| for entry in data["criminal"] + data["benign"]: |
| if entry["address"].lower() == addr: |
| return { |
| "address": address, |
| "match": entry, |
| "is_criminal": entry["label"] == "criminal", |
| "source": "Real-CATS", |
| } |
| return {"address": address, "match": None, "found": False, "source": "Real-CATS"} |
|
|
| if category == "criminal": |
| results = data["criminal"][:limit] |
| elif category == "benign": |
| results = data["benign"][:limit] |
| else: |
| results = data["criminal"][: limit // 2] + data["benign"][: limit // 2] |
|
|
| return { |
| "category": category, |
| "results": results, |
| "stats": data["stats"], |
| "source": "Real-CATS", |
| } |
|
|
|
|
| |
| |
| |
|
|
| MBAL_PATHS = [ |
| os.path.expanduser("~/rmi/mbal"), |
| "/app/mbal", |
| os.path.expanduser("~/rmi/datasets/mbal"), |
| "/tmp/mbal", |
| ] |
|
|
| MBAL_README = """ |
| MBAL: 10 Million Crypto Address Label Dataset |
| ============================================== |
| Source: https://www.kaggle.com/datasets/yidongchaintoolai/mbal-10m-crypto-address-label-dataset |
| |
| To use: |
| 1. Download from Kaggle (requires free account) |
| 2. Place CSV/parquet files in ~/rmi/mbal/ |
| 3. The provider auto-loads them |
| |
| Chains: Bitcoin, Ethereum, BNB Chain, Polygon, Avalanche |
| Categories: 62 distinct classifications |
| """ |
|
|
|
|
| def _find_mbal_dir() -> str | None: |
| for p in MBAL_PATHS: |
| if os.path.isdir(p) and os.listdir(p): |
| return p |
| return None |
|
|
|
|
| def _get_mbal_install_instructions() -> str: |
| return MBAL_README |
|
|
|
|
| async def fetch_mbal( |
| address: str | None = None, |
| chain: str | None = None, |
| category: str | None = None, |
| limit: int = 20, |
| ) -> dict: |
| """Query MBAL β 10M labeled addresses. Schema: chain,address,categories,entity,source""" |
| base = _find_mbal_dir() |
|
|
| if not base: |
| return { |
| "error": "MBAL dataset not installed", |
| "instructions": _get_mbal_install_instructions(), |
| "download_url": "https://www.kaggle.com/datasets/yidongchaintoolai/mbal-10m-crypto-address-label-dataset", |
| "source": "MBAL (Kaggle)", |
| } |
|
|
| |
| main_file = None |
| for f in sorted(os.listdir(base)): |
| if f.startswith("dataset_10m") and f.endswith(".csv"): |
| main_file = os.path.join(base, f) |
| break |
|
|
| if not main_file: |
| |
| csv_files = [f for f in os.listdir(base) if f.endswith(".csv")] |
| if csv_files: |
| main_file = os.path.join(base, csv_files[0]) |
|
|
| if not main_file: |
| return {"error": "No CSV files found", "path": base, "source": "MBAL"} |
|
|
| try: |
| results = [] |
| with open(main_file, encoding="utf-8") as f: |
| reader = csv.DictReader(f) |
|
|
| for row in reader: |
| match = True |
|
|
| |
| if address and address.lower() not in row.get("address", "").lower(): |
| match = False |
|
|
| |
| if chain and chain.lower() not in row.get("chain", "").lower(): |
| match = False |
|
|
| |
| if category and category.lower() not in row.get("categories", "").lower(): |
| match = False |
|
|
| if match: |
| results.append( |
| { |
| "chain": row.get("chain", ""), |
| "address": row.get("address", ""), |
| "categories": row.get("categories", ""), |
| "entity": row.get("entity", ""), |
| "source": row.get("source", ""), |
| } |
| ) |
|
|
| if len(results) >= limit: |
| break |
|
|
| |
| total_estimate = "10,000,023 rows" |
|
|
| return { |
| "results": results, |
| "match_count": len(results), |
| "filters": {"address": address, "chain": chain, "category": category}, |
| "source": "MBAL β 10M annotated addresses (Kaggle)", |
| "total_estimate": total_estimate, |
| "categories": "62 distinct: cex, dex, l2, bridge, mixer, scam, gambling, nft, defi, ...", |
| "chains_covered": [ |
| "bitcoin_mainnet", |
| "ethereum_mainnet", |
| "bsc_mainnet", |
| "polygon_mainnet", |
| "avalanche", |
| ], |
| } |
|
|
| except Exception as e: |
| logger.warning(f"MBAL query failed: {e}") |
| return {"error": str(e), "source": "MBAL"} |
|
|