""" Token Clone Scanner =================== Detects cloned, copycat, and impersonation tokens by analyzing: - Contract bytecode similarity (code reuse / unverified contract suspicion) - Name/symbol fuzzy matching (brand impersonation) - Deployer history (same deployer creating lookalike tokens) - Metadata scraping (stolen logos, websites, social links) - Launch time proximity to high-value token launches TOOL : clone_detect TIER : premium PRICE : $0.08 (80000 atoms) TRIAL : 2 free checks Endpoints: POST /api/v1/x402-tools/clone/scan - full clone analysis POST /api/v1/x402-tools/clone/detect - fast similarity check only Data Sources (all free): - DexScreener - token info, pairs, prices across chains - Birdeye public - holder stats, token metadata - Solscan (free) - Solana contract verification status - Etherscan family - EVM contract source code - Jupiter - Solana token list Usage: from app.clone_scanner import CloneScanner, CloneReport scanner = CloneScanner() report = await scanner.scan("0x...", "ethereum") print(report.clone_score, report.risk_label) """ import asyncio import logging import os import re import time from dataclasses import dataclass, field from difflib import SequenceMatcher from enum import Enum from typing import Any import httpx logger = logging.getLogger(__name__) # ── Patterns ────────────────────────────────────────────────────── EVM_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") SOLANA_ADDRESS_RE = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$") # Chain → address format mapping for chain-specific validation EVM_CHAINS = frozenset( { "ethereum", "bsc", "polygon", "arbitrum", "optimism", "avalanche", "base", "fantom", "linea", "zksync", "scroll", "mantle", } ) CLONE_INDICATOR_KEYWORDS = [ "pepe", "bonk", "doge", "shib", "woof", "moon", "safe", "baby", "elon", "chatgpt", "ai", "gpt", "biden", "trump", "floki", "samoyed", "husky", "corgi", "kitty", "cat", ] # ── APIs (free) ─────────────────────────────────────────────────── DEXSCREENER_API = "https://api.dexscreener.com/latest/dex" BIRDEYE_PUBLIC = "https://public-api.birdeye.so" JUPITER_TOKEN_LIST = "https://tokens.jup.ag/all" # Etherscan-like API bases ETHERSCAN_BASES: dict[str, str] = { "ethereum": "https://api.etherscan.io/api", "bsc": "https://api.bscscan.com/api", "polygon": "https://api.polygonscan.com/api", "arbitrum": "https://api.arbiscan.io/api", "optimism": "https://api-optimistic.etherscan.io/api", "avalanche": "https://api.snowtrace.io/api", "base": "https://api.basescan.org/api", "fantom": "https://api.ftmscan.com/api", "linea": "https://api.lineascan.build/api", "zksync": "https://api-era.zksync.network/api", "scroll": "https://api.scrollscan.com/api", "mantle": "https://api.mantlescan.xyz/api", } # Known legitimate token registries for reference (name -> (symbol, chain)) WELL_KNOWN_TOKENS: dict[str, tuple[str, str]] = { "Bitcoin": ("BTC", "ethereum"), "Ethereum": ("ETH", "ethereum"), "Tether": ("USDT", "ethereum"), "USD Coin": ("USDC", "ethereum"), "BNB": ("BNB", "bsc"), "Solana": ("SOL", "solana"), "XRP": ("XRP", "ethereum"), "Dogecoin": ("DOGE", "ethereum"), "Pepe": ("PEPE", "ethereum"), "Bonk": ("BONK", "solana"), } class CloneRisk(Enum): CRITICAL = "critical" HIGH = "high" MEDIUM = "medium" LOW = "low" NONE = "none" SUPPORTED_CHAINS = [ "ethereum", "bsc", "polygon", "arbitrum", "optimism", "avalanche", "base", "fantom", "linea", "zksync", "scroll", "mantle", "solana", ] # ── Data Models ─────────────────────────────────────────────────── @dataclass class SimilarToken: """A token found to be similar to the target.""" address: str chain: str name: str symbol: str name_similarity: float = 0.0 symbol_similarity: float = 0.0 deployer_match: bool = False age_days: float = 0.0 @property def overall_similarity(self) -> float: return max(self.name_similarity, self.symbol_similarity) @dataclass class CloneReport: """Full clone analysis result.""" token_address: str chain: str name: str = "" symbol: str = "" clone_score: float = 0.0 # 0-100 # Per-category scores bytecode_similarity_score: float = 0.0 name_similarity_score: float = 0.0 deployer_risk_score: float = 0.0 metadata_risk_score: float = 0.0 # Findings similar_tokens: list[SimilarToken] = field(default_factory=list) matched_keywords: list[str] = field(default_factory=list) unverified_contract: bool = False same_deployer_other_tokens: int = 0 is_verified_known_token: bool = False risk_label: str = "none" errors: list[str] = field(default_factory=list) raw: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: return { "token_address": self.token_address, "chain": self.chain, "name": self.name, "symbol": self.symbol, "clone_score": self.clone_score, "risk_label": self.risk_label, "signals": { "bytecode_similarity": self.bytecode_similarity_score, "name_similarity": self.name_similarity_score, "deployer_risk": self.deployer_risk_score, "metadata_risk": self.metadata_risk_score, }, "similar_tokens": [ { "address": t.address, "chain": t.chain, "name": t.name, "symbol": t.symbol, "similarity": round(t.overall_similarity, 2), "deployer_match": t.deployer_match, "age_days": round(t.age_days, 1), } for t in self.similar_tokens ], "matched_keywords": self.matched_keywords, "unverified_contract": self.unverified_contract, "same_deployer_other_tokens": self.same_deployer_other_tokens, "is_verified_known_token": self.is_verified_known_token, } def summary(self) -> str: """Human-readable one-line summary.""" flags = [] if self.unverified_contract: flags.append("unverified") if self.matched_keywords: flags.append(f"keywords:{','.join(self.matched_keywords[:3])}") if self.same_deployer_other_tokens > 0: flags.append(f"{self.same_deployer_other_tokens}x other tokens") if self.is_verified_known_token: flags.append("known_legitimate") flag_str = f" [{', '.join(flags)}]" if flags else "" return ( f"[{self.risk_label.upper()}] {self.token_address[:12]}... " f"({self.name}/{self.symbol}) - " f"Clone score: {self.clone_score:.0f}/100 | " f"{len(self.similar_tokens)} similar tokens found" f"{flag_str}" ) # ── Helper: String similarity ───────────────────────────────────── def string_similarity(a: str, b: str) -> float: """Fuzzy string similarity (0-1) using SequenceMatcher. Truncates inputs at 200 chars to prevent O(n²) blowup.""" if not a or not b: return 0.0 a, b = a.lower().strip(), b.lower().strip() if a == b: return 1.0 # Truncate very long strings to keep SequenceMatcher performant if len(a) > 200 or len(b) > 200: a, b = a[:200], b[:200] return SequenceMatcher(None, a, b).ratio() def name_similarity_score(name: str, target_name: str) -> float: """Score name similarity considering common clone patterns.""" base = string_similarity(name, target_name) # Boost if one name contains the other (e.g. "Baby Pepe" contains "Pepe") n_lower = name.lower().strip() t_lower = target_name.lower().strip() if n_lower and t_lower: if n_lower in t_lower or t_lower in n_lower: base = max(base, 0.65) # Check prefix/suffix patterns common in clones for prefix in ["baby ", "mini ", "super ", "mega ", "ultra ", "real ", "new ", "true ", "safe ", "moon "]: if (n_lower.startswith(prefix) and n_lower[len(prefix) :] == t_lower) or ( t_lower.startswith(prefix) and t_lower[len(prefix) :] == n_lower ): base = max(base, 0.85) for suffix in [" v2", " v3", " pro", " x", " 2.0", " 3.0"]: if n_lower == t_lower + suffix or t_lower == n_lower + suffix: base = max(base, 0.90) return min(base, 1.0) # ── Core Scanner ────────────────────────────────────────────────── class CloneScanner: """Main scanner for token clone detection.""" def __init__(self, http_timeout: float = 15.0): self.http = httpx.AsyncClient(timeout=http_timeout) self._jupiter_tokens: list[dict[str, Any]] | None = None self._birdeye_api_key = os.environ.get("BIRDEYE_API_KEY", "") async def close(self): await self.http.aclose() # ── Public API ────────────────────────────────────────────── async def scan(self, address: str, chain: str) -> CloneReport: """Full clone analysis for a token.""" if not self._validate_address(address, chain): return CloneReport( token_address=address, chain=chain, errors=[f"Invalid address for chain {chain}"], risk_label="error", ) chain = chain.lower() if chain not in SUPPORTED_CHAINS: return CloneReport( token_address=address, chain=chain, errors=[f"Unsupported chain: {chain}. Supported: {SUPPORTED_CHAINS}"], risk_label="error", ) report = CloneReport(token_address=address, chain=chain) try: # 1. Fetch token metadata metadata = await self._fetch_metadata(address, chain) report.name = metadata.get("name", "Unknown") report.symbol = metadata.get("symbol", "UNKNOWN") report.raw["metadata"] = metadata # 2. Check if it's a well-known legitimate token if self._is_well_known(report.name, report.symbol): report.is_verified_known_token = True report.risk_label = "none" return report # 3. Check contract verification status report.unverified_contract = await self._check_verification(address, chain) # 4. Search for similar named tokens tasks = [ self._search_similar_names(report.name, report.symbol, address, chain), self._check_deployer_history(address, chain), self._check_keyword_matches(report.name, report.symbol), ] results = await asyncio.gather(*tasks, return_exceptions=True) similar_tokens = results[0] if not isinstance(results[0], Exception) else [] deployer_info = results[1] if not isinstance(results[1], Exception) else {} keywords = results[2] if not isinstance(results[2], Exception) else [] report.similar_tokens = similar_tokens report.same_deployer_other_tokens = deployer_info.get("other_tokens", 0) report.matched_keywords = keywords # 5. Compute scores report.bytecode_similarity_score = self._score_bytecode_risk(report.unverified_contract, deployer_info) report.name_similarity_score = self._score_name_similarity(similar_tokens, keywords) report.deployer_risk_score = self._score_deployer_risk(deployer_info) report.metadata_risk_score = self._score_metadata_risk(metadata, keywords, similar_tokens) # 6. Composite clone score report.clone_score = self._compute_clone_score(report) report.risk_label = self._label_risk(report.clone_score) except Exception as e: logger.error(f"Clone scan error for {address}: {e}") report.errors.append(str(e)) report.risk_label = "error" return report async def fast_check(self, address: str, chain: str) -> dict[str, Any]: """Quick similarity check - name/symbol only, no bytecode.""" if not self._validate_address(address, chain): return {"error": f"Invalid address for chain {chain}"} chain = chain.lower() metadata = await self._fetch_metadata(address, chain) name = metadata.get("name", "") symbol = metadata.get("symbol", "") result = { "address": address, "chain": chain, "name": name, "symbol": symbol, } # Check well-known if self._is_well_known(name, symbol): result["clone_risk"] = "none" result["reason"] = "Known legitimate token" return result # Check keywords keywords = self._check_keyword_matches(name, symbol) keywords = [k for k in keywords if k not in CLONE_INDICATOR_KEYWORDS[:3]] result["matched_keywords"] = keywords # Simple score score = 0 if keywords: score += 30 if any(k.lower() in name.lower() for k in [" v2", " v3", " 2.0", "pro"]): score += 15 if not name or not symbol: score += 10 result["clone_score"] = min(score, 100) result["clone_risk"] = self._label_risk(result["clone_score"]) return result # ── Validation ────────────────────────────────────────────── def _validate_address(self, address: str, chain: str) -> bool: """Validate address format against chain type.""" chain = chain.lower() if chain == "solana": return bool(SOLANA_ADDRESS_RE.match(address)) if chain in EVM_CHAINS: return bool(EVM_ADDRESS_RE.match(address)) # Unknown chain - try either format return bool(EVM_ADDRESS_RE.match(address) or SOLANA_ADDRESS_RE.match(address)) def _is_well_known(self, name: str, symbol: str) -> bool: key = name.strip() sym = symbol.strip().upper() if key in WELL_KNOWN_TOKENS: ref_sym, _ = WELL_KNOWN_TOKENS[key] if sym == ref_sym: return True # Also check by symbol alone for major tokens return sym in ("BTC", "ETH", "USDT", "USDC", "BNB", "SOL", "XRP", "ADA", "DOGE") @staticmethod def _label_risk(score: float) -> str: if score >= 75: return "critical" if score >= 50: return "high" if score >= 25: return "medium" if score > 0: return "low" return "none" # ── Scoring ───────────────────────────────────────────────── def _score_bytecode_risk(self, unverified: bool, deployer_info: dict[str, Any]) -> float: score = 0.0 if unverified: score += 40 # Unverified contracts are high risk for clones if deployer_info.get("other_unverified", 0) > 5: score += 20 if deployer_info.get("other_tokens", 0) > 20: score += 15 # Mass deployer pattern return min(score, 100) def _score_name_similarity(self, similar_tokens: list[SimilarToken], keywords: list[str]) -> float: score = 0.0 if keywords: score += 25 high_sim = [t for t in similar_tokens if t.overall_similarity > 0.8] if high_sim: score += min(len(high_sim) * 20, 50) med_sim = [t for t in similar_tokens if 0.5 < t.overall_similarity <= 0.8] if med_sim: score += min(len(med_sim) * 10, 25) return min(score, 100) def _score_deployer_risk(self, deployer_info: dict[str, Any]) -> float: score = 0.0 other = deployer_info.get("other_tokens", 0) if other > 50: score += 40 elif other > 20: score += 30 elif other > 5: score += 15 if deployer_info.get("clone_deployer", False): score += 30 return min(score, 100) def _score_metadata_risk( self, metadata: dict[str, Any], keywords: list[str], similar_tokens: list[SimilarToken] ) -> float: score = 0.0 # Missing metadata is suspicious if not metadata.get("name") or not metadata.get("symbol"): score += 20 # Empty socials or website social = metadata.get("social", {}) or {} if not social.get("twitter") and not social.get("telegram"): score += 10 if not metadata.get("website"): score += 10 # High number of similar tokens with matching metadata patterns if len(similar_tokens) > 5: score += 15 return min(score, 100) def _compute_clone_score(self, report: CloneReport) -> float: """Weighted composite score from all signals.""" weights = { "bytecode": 0.25, "name": 0.35, "deployer": 0.25, "metadata": 0.15, } score = ( weights["bytecode"] * report.bytecode_similarity_score + weights["name"] * report.name_similarity_score + weights["deployer"] * report.deployer_risk_score + weights["metadata"] * report.metadata_risk_score ) return round(score, 1) # ── Data Fetching ─────────────────────────────────────────── async def _fetch_metadata(self, address: str, chain: str) -> dict[str, Any]: """Fetch token metadata from available free sources.""" result: dict[str, Any] = {} # DexScreener gives name, symbol, pairs try: url = f"{DEXSCREENER_API}/tokens/{address}" resp = await self.http.get(url) if resp.status_code == 200: data = resp.json() pairs = data.get("pairs", []) if pairs: pair = pairs[0] result["name"] = pair.get("baseToken", {}).get("name", "") result["symbol"] = pair.get("baseToken", {}).get("symbol", "") result["price_usd"] = pair.get("priceUsd", "") result["liquidity_usd"] = pair.get("liquidity", {}).get("usd", 0) result["pair_created_at"] = pair.get("pairCreatedAt", 0) result["dex_id"] = pair.get("dexId", "") # Social links from info info = pair.get("info", {}) if isinstance(info, dict): result["social"] = { "twitter": info.get("twitter", ""), "telegram": info.get("telegram", ""), "website": info.get("website", ""), } except Exception as e: logger.debug(f"DexScreener fetch failed for {address}: {e}") # Jupiter token list for Solana if chain == "solana" and not result.get("name"): try: if self._jupiter_tokens is None: resp = await self.http.get(JUPITER_TOKEN_LIST) if resp.status_code == 200: self._jupiter_tokens = resp.json() if self._jupiter_tokens: for token in self._jupiter_tokens: if token.get("address", "").lower() == address.lower(): result.setdefault("name", token.get("name", "")) result.setdefault("symbol", token.get("symbol", "")) break except Exception as e: logger.debug(f"Jupiter fetch failed: {e}") # Birdeye fallback if not result.get("name"): try: headers = {"Accept": "application/json"} if self._birdeye_api_key: headers["X-API-KEY"] = self._birdeye_api_key url = f"{BIRDEYE_PUBLIC}/defi/token_overview?address={address}" resp = await self.http.get(url, headers=headers) if resp.status_code == 200: data = resp.json().get("data", {}) if data: result.setdefault("name", data.get("name", "")) result.setdefault("symbol", data.get("symbol", "")) result.setdefault("price_usd", data.get("price", "")) except Exception as e: logger.debug(f"Birdeye fetch failed: {e}") return result async def _check_verification(self, address: str, chain: str) -> bool: """Check if contract source code is verified on block explorer.""" if chain == "solana": return False # Solana doesn't have a simple verified/unverified check via free API base = ETHERSCAN_BASES.get(chain) if not base: return True # Unknown chain - assume unverified try: # Use free-tier etherscan API (no key needed for basic queries) url = f"{base}?module=contract&action=getsourcecode&address={address}" resp = await self.http.get(url) if resp.status_code == 200: data = resp.json() source = data.get("result", [{}]) if isinstance(source, list) and source: source_code = source[0].get("SourceCode", "") return not bool(source_code and source_code.strip()) return True # Assume unverified on error except Exception: return True async def _fetch_deployer(self, address: str, chain: str) -> str | None: """Get deployer address for a contract.""" if chain == "solana": return None # Free Solana API doesn't expose deployer easily base = ETHERSCAN_BASES.get(chain) if not base: return None try: # Get internal txns for creation - free endpoint url = f"{base}?module=account&action=txlistinternal&address={address}&sort=asc&limit=1" resp = await self.http.get(url) if resp.status_code == 200: data = resp.json() txs = data.get("result", []) if isinstance(txs, list) and txs: return txs[0].get("from", None) return None except Exception: return None async def _check_deployer_history(self, address: str, chain: str) -> dict[str, Any]: """Check if deployer created many tokens (mass deployer pattern).""" result: dict[str, Any] = {"other_tokens": 0, "clone_deployer": False} deployer = await self._fetch_deployer(address, chain) if not deployer: return result # Fetch recent tokens from this deployer via DexScreener try: url = f"{DEXSCREENER_API}/tokens/{deployer}" resp = await self.http.get(url) if resp.status_code == 200: data = resp.json() pairs = data.get("pairs", []) tokens = set() for p in pairs: bt = p.get("baseToken", {}) tok_addr = bt.get("address", "") if tok_addr and tok_addr.lower() != address.lower(): tokens.add(tok_addr) result["other_tokens"] = len(tokens) # Flag if deployer created > 20 tokens result["clone_deployer"] = len(tokens) > 20 except Exception: pass return result async def _search_similar_names(self, name: str, symbol: str, address: str, chain: str) -> list[SimilarToken]: """Search DexScreener for tokens with similar names.""" if not name and not symbol: return [] similar: list[SimilarToken] = [] seen = set() # Search by name queries = [] if name: queries.append(name.replace(" ", "%20")[:30]) if symbol: queries.append(symbol[:10]) for query in queries[:2]: try: url = f"{DEXSCREENER_API}/search?q={query}" resp = await self.http.get(url) if resp.status_code != 200: continue data = resp.json() pairs = data.get("pairs", []) for pair in pairs: bt = pair.get("baseToken", {}) tok_addr = bt.get("address", "").lower() tok_name = bt.get("name", "") tok_symbol = bt.get("symbol", "") tok_chain = pair.get("chainId", "").lower() # Skip self if tok_addr == address.lower(): continue # Skip duplicates dedup_key = f"{tok_chain}:{tok_addr}" if dedup_key in seen: continue seen.add(dedup_key) # Skip well-known tokens if self._is_well_known(tok_name, tok_symbol): continue name_sim = name_similarity_score(tok_name, name) sym_sim = string_similarity(tok_symbol, symbol) # Estimate age from pair creation timestamp created_at = pair.get("pairCreatedAt", 0) age_days = 0.0 if created_at and isinstance(created_at, int | float) and created_at > 0: age_days = (time.time() - created_at / 1000) / 86400 if name_sim > 0.4 or sym_sim > 0.4: similar.append( SimilarToken( address=tok_addr, chain=tok_chain, name=tok_name, symbol=tok_symbol, name_similarity=name_sim, symbol_similarity=sym_sim, age_days=age_days, ) ) except Exception as e: logger.debug(f"Name search failed for '{query}': {e}") # Sort by similarity descending, take top 20 similar.sort(key=lambda t: t.overall_similarity, reverse=True) return similar[:20] def _check_keyword_matches(self, name: str, symbol: str) -> list[str]: """Check for clone-indicator keywords in name/symbol.""" matches = [] combined = f"{name} {symbol}".lower() for kw in CLONE_INDICATOR_KEYWORDS: if kw in combined: matches.append(kw) return matches # ── Module-level helpers ────────────────────────────────────────── async def scan_token(address: str, chain: str) -> CloneReport: """Convenience function - create scanner, scan, close.""" scanner = CloneScanner() try: return await scanner.scan(address, chain) finally: await scanner.close() async def fast_check(address: str, chain: str) -> dict[str, Any]: """Convenience function for fast check.""" scanner = CloneScanner() try: return await scanner.fast_check(address, chain) finally: await scanner.close()