- 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>
767 lines
28 KiB
Python
767 lines
28 KiB
Python
"""
|
|
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()
|