- 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>
687 lines
26 KiB
Python
687 lines
26 KiB
Python
"""
|
|
Creator Track Record (deployer_history)
|
|
========================================
|
|
Investigates the complete deployment history of any token creator address.
|
|
Success rate, previous rug pulls, total value created or destroyed,
|
|
and risk classification based on deployer behavior patterns.
|
|
|
|
Signals detected:
|
|
- Total tokens deployed across all chains
|
|
- Rug pull / scam tokens in deployment history
|
|
- Honeypot patterns from known deployers
|
|
- Liquidity removal events
|
|
- Ownership renouncement patterns
|
|
- Cross-chain deployer identity correlation
|
|
- Time-based clustering (serial deployers)
|
|
|
|
Tier: Premium ($0.05-0.08)
|
|
Endpoint: POST /api/v1/x402-tools/deployer_history
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from urllib.parse import quote, urlparse
|
|
|
|
logger = logging.getLogger("deployer_history")
|
|
|
|
# ── Free API sources ─────────────────────────────────────────────
|
|
DEXSCREENER_SEARCH = "https://api.dexscreener.com/latest/dex/search?q={}"
|
|
DEXSCREENER_PAIRS_BY_TOKEN = "https://api.dexscreener.com/latest/dex/tokens/{}"
|
|
SOLSCAN_TOKEN_ACCOUNTS = "https://api.solscan.io/account/tokens?address={}&pageSize=100"
|
|
SOLSCAN_ACCOUNT_TXS = "https://api.solscan.io/account/transactions?address={}&pageSize=50"
|
|
ETHERSCAN_TXLIST = (
|
|
"https://api.etherscan.io/api?module=account&action=txlist&address={}&sort=desc&limit=100"
|
|
)
|
|
BASESCAN_TXLIST = (
|
|
"https://api.basescan.org/api?module=account&action=txlist&address={}&sort=desc&limit=100"
|
|
)
|
|
BSCSCAN_TXLIST = (
|
|
"https://api.bscscan.com/api?module=account&action=txlist&address={}&sort=desc&limit=100"
|
|
)
|
|
|
|
# Rate limiting: max requests per second per host
|
|
_RATE_LIMITERS: dict[str, float] = {}
|
|
_RATE_LIMIT_WINDOW = 0.5 # 500ms between requests to same host
|
|
|
|
|
|
async def _rate_limit_host(host: str) -> None:
|
|
"""Simple per-host rate limiter to avoid being rate-limited by APIs."""
|
|
now = time.time()
|
|
last = _RATE_LIMITERS.get(host, 0.0)
|
|
wait = _RATE_LIMIT_WINDOW - (now - last)
|
|
if wait > 0:
|
|
await asyncio.sleep(wait)
|
|
_RATE_LIMITERS[host] = time.time()
|
|
|
|
|
|
# Contract creation function signatures
|
|
CREATE2_SIG = "0x60806040" # Common contract creation prefix
|
|
CREATE_SIGS = {"0x60a06040", "0x60806040", "0x60606040", "0x60806052"}
|
|
|
|
# URL safety regex
|
|
_URL_SAFE = re.compile(
|
|
r"^https?://[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*(?::\d{1,5})?(?:/[^\s\"<>]*)?$"
|
|
)
|
|
|
|
# Known scam deployer patterns
|
|
SCAM_PATTERNS = {
|
|
"rug_pull": {
|
|
"signals": [
|
|
"liquidity_removed",
|
|
"ownership_renounced_after_raise",
|
|
"no_trading_24h_after_launch",
|
|
],
|
|
"weight": 30,
|
|
},
|
|
"honeypot": {
|
|
"signals": ["sell_tax_gt_10", "blacklist_detected", "max_wallet_limit_lt_1_percent"],
|
|
"weight": 25,
|
|
},
|
|
"pump_dump": {
|
|
"signals": ["rapid_price_spike_then_crash", "concentrated_holders", "fresh_wallet_cluster"],
|
|
"weight": 20,
|
|
},
|
|
"serial_scammer": {
|
|
"signals": ["multiple_rugs", "same_deployer_different_names", "short_lived_tokens"],
|
|
"weight": 35,
|
|
},
|
|
"suspicious_renounce": {
|
|
"signals": ["renounced_after_drain", "renounce_then_contract_upgraded", "fake_renounce"],
|
|
"weight": 20,
|
|
},
|
|
}
|
|
|
|
|
|
def _validate_url(url: str) -> bool:
|
|
"""Validate URL using urlparse to prevent SSRF and injection."""
|
|
try:
|
|
result = urlparse(url)
|
|
# Ensure the URL has a scheme, network location, and a path
|
|
return all([result.scheme in ("http", "https"), result.netloc, result.path])
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class DeployedToken:
|
|
"""Represents a single token deployed by the address under investigation."""
|
|
|
|
address: str
|
|
chain: str
|
|
name: str = ""
|
|
symbol: str = ""
|
|
deploy_tx: str = ""
|
|
deploy_time: str = ""
|
|
total_supply: float = 0.0
|
|
liquidity_usd: float = 0.0
|
|
volume_24h: float = 0.0
|
|
price_usd: float = 0.0
|
|
holders: int = 0
|
|
is_verified: bool = False
|
|
is_honeypot: bool = False
|
|
is_rug: bool = False
|
|
is_active: bool = True
|
|
risk_score: float = 0.0
|
|
flags: list[str] = field(default_factory=list)
|
|
age_days: float = 0.0
|
|
max_holder_pct: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class DeployerProfile:
|
|
"""Complete deployer analysis result."""
|
|
|
|
address: str
|
|
chains_used: list[str] = field(default_factory=list)
|
|
total_tokens_deployed: int = 0
|
|
active_tokens: int = 0
|
|
dead_tokens: int = 0
|
|
rug_tokens: int = 0
|
|
honeypot_tokens: int = 0
|
|
tokens: list[DeployedToken] = field(default_factory=list)
|
|
first_seen: str = ""
|
|
last_active: str = ""
|
|
avg_token_lifespan_days: float = 0.0
|
|
risk_score: float = 0.0
|
|
risk_level: str = "unknown"
|
|
patterns_detected: list[str] = field(default_factory=list)
|
|
confidence: float = 0.0
|
|
recommendation: str = ""
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
# ── Core scoring engine ─────────────────────────────────────────
|
|
|
|
|
|
def _compute_deployer_risk(profile: DeployerProfile) -> float:
|
|
"""
|
|
Compute a 0-100 deployer risk score.
|
|
|
|
Factors:
|
|
- rug_token_ratio (30%): Proportion of deployed tokens that rug
|
|
- honeypot_ratio (20%): Proportion of honeypot tokens
|
|
- total_volume (15%): Total value deployed across all tokens
|
|
- avg_lifespan (15%): Average token lifespan (shorter = worse)
|
|
- pattern_detection (20%): Known scam patterns detected
|
|
"""
|
|
total = max(profile.total_tokens_deployed, 1)
|
|
|
|
# Rug ratio
|
|
rug_ratio = profile.rug_tokens / total
|
|
rug_score = min(rug_ratio * 100 * 0.30, 30)
|
|
|
|
# Honeypot ratio
|
|
honey_ratio = profile.honeypot_tokens / total
|
|
honey_score = min(honey_ratio * 100 * 0.20, 20)
|
|
|
|
# Volume check - serial deployers with tiny volumes are suspicious
|
|
# If avg token has < $100 liquidity, it's likely a spam/scam deployer
|
|
low_val_tokens = sum(1 for t in profile.tokens if t.liquidity_usd < 100)
|
|
low_val_ratio = low_val_tokens / total
|
|
volume_score = min(low_val_ratio * 100 * 0.15, 15)
|
|
|
|
# Lifespan - short-lived tokens are suspicious
|
|
if profile.avg_token_lifespan_days > 0:
|
|
# Less than 7 days avg = very suspicious
|
|
if profile.avg_token_lifespan_days < 7:
|
|
lifespan_score = 15
|
|
elif profile.avg_token_lifespan_days < 30:
|
|
lifespan_score = 10
|
|
elif profile.avg_token_lifespan_days < 90:
|
|
lifespan_score = 5
|
|
else:
|
|
lifespan_score = 0
|
|
else:
|
|
lifespan_score = 5 # Unknown, slight penalty
|
|
|
|
# Pattern detection weights
|
|
pattern_score = min(len(profile.patterns_detected) * 5, 20)
|
|
|
|
raw = rug_score + honey_score + volume_score + lifespan_score + pattern_score
|
|
return min(raw, 100.0)
|
|
|
|
|
|
def _classify_deployer_risk(score: float) -> str:
|
|
"""Classify score into risk category."""
|
|
if score >= 70:
|
|
return "critical"
|
|
if score >= 50:
|
|
return "high"
|
|
if score >= 30:
|
|
return "moderate"
|
|
if score >= 10:
|
|
return "low"
|
|
return "safe"
|
|
|
|
|
|
def _generate_recommendation(profile: DeployerProfile, score: float) -> str:
|
|
"""Generate human-readable recommendation based on deployer analysis."""
|
|
if score >= 70:
|
|
return (
|
|
f"CRITICAL: Deployer {profile.address[:10]}...{profile.address[-6:]} "
|
|
f"has a confirmed history of {profile.rug_tokens} rug pulls and "
|
|
f"{profile.honeypot_tokens} honeypot tokens out of {profile.total_tokens_deployed} "
|
|
f"total deployments. AVOID ALL tokens from this deployer. "
|
|
f"Patterns detected: {', '.join(profile.patterns_detected)}."
|
|
)
|
|
if score >= 50:
|
|
return (
|
|
f"HIGH: Deployer shows {profile.rug_tokens} rug pulls and "
|
|
f"{profile.honeypot_tokens} honeypot tokens. Significant risk. "
|
|
f"Thoroughly vet any token from this address before engaging. "
|
|
f"Only {profile.active_tokens}/{profile.total_tokens_deployed} tokens remain active."
|
|
)
|
|
if score >= 30:
|
|
return (
|
|
f"MODERATE: Some concerning patterns detected. "
|
|
f"Deployer has {profile.dead_tokens} inactive tokens out of "
|
|
f"{profile.total_tokens_deployed}. Review each token individually. "
|
|
f"Average lifespan: {profile.avg_token_lifespan_days:.1f} days."
|
|
)
|
|
if score >= 10:
|
|
return (
|
|
f"LOW: Minor concerns. Deployer has mainly active tokens with "
|
|
f"reasonable lifespans ({profile.avg_token_lifespan_days:.1f} days avg). "
|
|
f"Standard due diligence recommended."
|
|
)
|
|
return (
|
|
f"SAFE: No significant red flags detected. Deployer has "
|
|
f"{profile.active_tokens} active tokens out of "
|
|
f"{profile.total_tokens_deployed} with healthy lifespans."
|
|
)
|
|
|
|
|
|
# ── Data fetching helpers ───────────────────────────────────────
|
|
|
|
|
|
async def _fetch_json(url: str, timeout: int = 15) -> dict[str, Any] | list[Any] | None:
|
|
"""Fetch JSON from URL with timeout, rate limiting, and error handling."""
|
|
import aiohttp
|
|
|
|
if not _validate_url(url):
|
|
logger.warning(f"Invalid URL rejected: {url[:80]}")
|
|
return None
|
|
|
|
# Rate limit per host
|
|
parsed = urlparse(url)
|
|
host = parsed.netloc or "unknown"
|
|
await _rate_limit_host(host)
|
|
|
|
try:
|
|
async with (
|
|
aiohttp.ClientSession() as session,
|
|
session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp,
|
|
):
|
|
if resp.status == 200:
|
|
result: dict[str, Any] | list[Any] = await resp.json()
|
|
return result
|
|
elif resp.status == 429:
|
|
logger.warning(f"Rate limited by {host}, retrying after backoff")
|
|
await asyncio.sleep(3)
|
|
# One retry
|
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as retry:
|
|
if retry.status == 200:
|
|
result2: dict[str, Any] | list[Any] = await retry.json()
|
|
return result2
|
|
return None
|
|
else:
|
|
logger.debug(f"HTTP {resp.status} for {url[:80]}")
|
|
return None
|
|
except TimeoutError:
|
|
logger.debug(f"Timeout fetching {url[:80]}")
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"Error fetching {url[:80]}: {e}")
|
|
return None
|
|
|
|
|
|
async def _fetch_dexscreener_pairs(address: str) -> list[dict[str, Any]]:
|
|
"""Fetch pairs from DexScreener associated with an address."""
|
|
url = DEXSCREENER_SEARCH.format(quote(address))
|
|
data = await _fetch_json(url)
|
|
if not data or not isinstance(data, dict):
|
|
return []
|
|
pairs: list[dict[str, Any]] = data.get("pairs", []) or []
|
|
return pairs
|
|
|
|
|
|
async def _fetch_etherscan_txs(address: str, api_url: str) -> list[dict[str, Any]]:
|
|
"""Fetch transactions from block explorer to find contract creations."""
|
|
url = api_url.format(address)
|
|
data = await _fetch_json(url)
|
|
if not data or not isinstance(data, dict):
|
|
return []
|
|
txs: list[dict[str, Any]] = data.get("result", []) or []
|
|
return txs
|
|
|
|
|
|
def _is_contract_creation(tx: dict[str, Any]) -> bool:
|
|
"""Check if a transaction is a contract creation."""
|
|
# Contract creation has 'contractAddress' set
|
|
if tx.get("contractAddress"):
|
|
return True
|
|
# Also check if 'to' is empty (another creation indicator)
|
|
return bool(not tx.get("to") or tx.get("to") == "")
|
|
|
|
|
|
def _extract_token_from_pair(pair: dict[str, Any], deployer_address: str) -> DeployedToken | None:
|
|
"""
|
|
Extract token info from a DexScreener pair.
|
|
Only returns a token if the deployer_address matches the pair's token creator.
|
|
"""
|
|
chain = pair.get("chainId", "unknown")
|
|
base_token = pair.get("baseToken", {})
|
|
# Determine which token is the target (usually baseToken for new tokens)
|
|
token = base_token
|
|
token_address = token.get("address", "")
|
|
|
|
if not token_address:
|
|
return None
|
|
|
|
# Check if this deployer likely created the token
|
|
# DexScreener doesn't directly give deployer address, so we use maker/buyer patterns
|
|
pair_created_at = pair.get("pairCreatedAt", 0)
|
|
|
|
deployed = DeployedToken(
|
|
address=token_address,
|
|
chain=chain,
|
|
name=token.get("name", ""),
|
|
symbol=token.get("symbol", ""),
|
|
price_usd=float(pair.get("priceUsd", 0)),
|
|
liquidity_usd=float(pair.get("liquidity", {}).get("usd", 0)),
|
|
volume_24h=float(pair.get("volume", {}).get("h24", 0)),
|
|
holders=int(pair.get("fdv", 0) > 0), # rough proxy
|
|
is_verified=token.get("verified", False),
|
|
deploy_time=datetime.fromtimestamp(pair_created_at / 1000, tz=UTC).isoformat()
|
|
if pair_created_at
|
|
else "",
|
|
age_days=(datetime.now(UTC).timestamp() - pair_created_at / 1000) / 86400
|
|
if pair_created_at
|
|
else 0,
|
|
)
|
|
|
|
# Check for suspicious patterns in the pair
|
|
txns = pair.get("txns", {})
|
|
buys = (
|
|
txns.get("h24", {}).get("buys", 0)
|
|
if isinstance(txns, dict) and isinstance(txns.get("h24"), dict)
|
|
else 0
|
|
)
|
|
sells = (
|
|
txns.get("h24", {}).get("sells", 0)
|
|
if isinstance(txns, dict) and isinstance(txns.get("h24"), dict)
|
|
else 0
|
|
)
|
|
|
|
if buys == 0 and sells == 0 and deployed.age_days < 7:
|
|
deployed.flags.append("no_trading_activity")
|
|
deployed.is_active = False
|
|
|
|
if deployed.liquidity_usd < 100 and deployed.age_days > 1:
|
|
deployed.flags.append("critically_low_liquidity")
|
|
deployed.is_active = False
|
|
deployed.is_rug = True
|
|
|
|
if deployed.age_days < 1 and deployed.liquidity_usd > 0 and deployed.volume_24h == 0:
|
|
deployed.flags.append("just_launched_no_volume")
|
|
|
|
return deployed
|
|
|
|
|
|
async def _analyze_deployer_evm(
|
|
address: str, tokens: list[DeployedToken]
|
|
) -> tuple[list[DeployedToken], list[str]]:
|
|
"""Attempt to find contract deployments via block explorers."""
|
|
patterns: list[str] = []
|
|
explorers = [
|
|
("ethereum", ETHERSCAN_TXLIST),
|
|
("bsc", BSCSCAN_TXLIST),
|
|
("base", BASESCAN_TXLIST),
|
|
]
|
|
|
|
tasks = []
|
|
for _chain, url in explorers:
|
|
tasks.append(_fetch_etherscan_txs(address, url))
|
|
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
for (chain, _), txs in zip(explorers, results, strict=False):
|
|
if not txs or not isinstance(txs, list):
|
|
continue
|
|
|
|
for tx in txs:
|
|
if not _is_contract_creation(tx):
|
|
continue
|
|
|
|
contract_addr = tx.get("contractAddress", "")
|
|
if not contract_addr:
|
|
continue
|
|
|
|
# Check if we already have this token
|
|
if any(t.address.lower() == contract_addr.lower() for t in tokens):
|
|
continue
|
|
|
|
deployed = DeployedToken(
|
|
address=contract_addr,
|
|
chain=chain,
|
|
name=tx.get("tokenName", f"Token-{contract_addr[:8]}"),
|
|
symbol=tx.get("tokenSymbol", "???"),
|
|
deploy_tx=tx.get("hash", ""),
|
|
deploy_time=datetime.fromtimestamp(int(tx.get("timeStamp", 0)), tz=UTC).isoformat()
|
|
if tx.get("timeStamp")
|
|
else "",
|
|
is_verified=tx.get("isError", "0") == "0",
|
|
age_days=(
|
|
datetime.now(UTC) - datetime.fromtimestamp(int(tx.get("timeStamp", 0)), tz=UTC)
|
|
).total_seconds()
|
|
/ 86400
|
|
if tx.get("timeStamp")
|
|
else 0,
|
|
)
|
|
|
|
# If the tx failed, flag it
|
|
if tx.get("isError", "0") != "0":
|
|
deployed.flags.append("deploy_tx_failed")
|
|
deployed.is_active = False
|
|
|
|
tokens.append(deployed)
|
|
|
|
return tokens, patterns
|
|
|
|
|
|
# ── Chain detection ─────────────────────────────────────────────
|
|
|
|
|
|
def _detect_chain_from_address(address: str) -> list[str]:
|
|
"""Detect possible chains from address format."""
|
|
chains = []
|
|
if re.match(r"^0x[a-fA-F0-9]{40}$", address):
|
|
chains = ["ethereum", "bsc", "base", "polygon", "arbitrum", "optimism"]
|
|
elif re.match(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$", address):
|
|
chains = ["solana"]
|
|
else:
|
|
chains = ["unknown"]
|
|
return chains
|
|
|
|
|
|
# ── Main analyzer class ─────────────────────────────────────────
|
|
|
|
|
|
class DeployerHistoryAnalyzer:
|
|
"""Analyzes the complete deployment history of a token creator address."""
|
|
|
|
def __init__(self, address: str):
|
|
if not re.match(r"^0x[a-fA-F0-9]{40}$", address) and not re.match(
|
|
r"^[1-9A-HJ-NP-Za-km-z]{32,44}$", address
|
|
):
|
|
raise ValueError(f"Invalid address format: {address[:20]}")
|
|
self.address = address.lower()
|
|
self.chains = _detect_chain_from_address(address)
|
|
self.is_evm = any(
|
|
c in self.chains for c in ["ethereum", "bsc", "base", "polygon", "arbitrum", "optimism"]
|
|
)
|
|
self.is_solana = "solana" in self.chains
|
|
|
|
async def analyze(self) -> dict[str, Any]:
|
|
"""
|
|
Full deployer history analysis.
|
|
|
|
Returns:
|
|
dict with deployer profile, token list, risk scoring, and recommendations.
|
|
"""
|
|
profile = DeployerProfile(address=self.address)
|
|
|
|
try:
|
|
tokens = await self._collect_tokens()
|
|
|
|
if not tokens:
|
|
profile.errors.append(
|
|
"No tokens found for this address. It may not be a deployer, "
|
|
"or the address is on an unsupported chain."
|
|
)
|
|
profile.risk_level = "unknown"
|
|
profile.recommendation = (
|
|
"Unable to find deployment history. The address may not be a known token "
|
|
"creator, or all deployments were on unsupported chains."
|
|
)
|
|
return self._to_dict(profile)
|
|
|
|
profile.tokens = tokens
|
|
profile.total_tokens_deployed = len(tokens)
|
|
profile.chains_used = list({t.chain for t in tokens})
|
|
|
|
# Compute derived metrics
|
|
active = [t for t in tokens if t.is_active and not t.is_rug]
|
|
rugs = [t for t in tokens if t.is_rug]
|
|
honeypots = [t for t in tokens if t.is_honeypot]
|
|
dead = [t for t in tokens if not t.is_active]
|
|
|
|
profile.active_tokens = len(active)
|
|
profile.dead_tokens = len(dead)
|
|
profile.rug_tokens = len(rugs)
|
|
profile.honeypot_tokens = len(honeypots)
|
|
|
|
# Lifespan
|
|
lifespans = [t.age_days for t in tokens if t.age_days > 0]
|
|
profile.avg_token_lifespan_days = sum(lifespans) / len(lifespans) if lifespans else 0
|
|
|
|
# First and last seen
|
|
timestamps = [t.deploy_time for t in tokens if t.deploy_time]
|
|
if timestamps:
|
|
timestamps.sort()
|
|
profile.first_seen = timestamps[0]
|
|
profile.last_active = timestamps[-1]
|
|
|
|
# Detect patterns
|
|
profile.patterns_detected = self._detect_patterns(profile)
|
|
|
|
# Final scoring
|
|
profile.risk_score = round(_compute_deployer_risk(profile), 1)
|
|
profile.risk_level = _classify_deployer_risk(profile.risk_score)
|
|
profile.recommendation = _generate_recommendation(profile, profile.risk_score)
|
|
profile.confidence = min(0.5 + (len(tokens) * 0.05), 0.95)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Deployer analysis failed: {e}", exc_info=True)
|
|
profile.errors.append(f"Analysis error: {str(e)[:200]}")
|
|
profile.risk_level = "error"
|
|
|
|
return self._to_dict(profile)
|
|
|
|
async def _collect_tokens(self) -> list[DeployedToken]:
|
|
"""Collect all tokens deployed by this address."""
|
|
all_tokens: list[DeployedToken] = []
|
|
seen_addresses: set[str] = set()
|
|
|
|
# Strategy 1: Query DexScreener
|
|
pairs = await _fetch_dexscreener_pairs(self.address)
|
|
for pair in pairs:
|
|
token = _extract_token_from_pair(pair, self.address)
|
|
if token and token.address not in seen_addresses:
|
|
seen_addresses.add(token.address)
|
|
all_tokens.append(token)
|
|
|
|
# Strategy 2: For EVM, check block explorers for contract creations
|
|
if self.is_evm:
|
|
all_tokens, _ = await _analyze_deployer_evm(self.address, all_tokens)
|
|
seen_addresses.update(t.address for t in all_tokens)
|
|
|
|
# Strategy 3: For Solana, check Solscan
|
|
if self.is_solana:
|
|
try:
|
|
sol_data = await _fetch_json(SOLSCAN_TOKEN_ACCOUNTS.format(self.address))
|
|
if sol_data and isinstance(sol_data, dict):
|
|
token_accounts = sol_data.get("data", [])
|
|
if isinstance(token_accounts, list):
|
|
for acct in token_accounts[:50]:
|
|
token_addr = (
|
|
acct.get("tokenAddress", "") if isinstance(acct, dict) else ""
|
|
)
|
|
if token_addr and token_addr not in seen_addresses:
|
|
seen_addresses.add(token_addr)
|
|
deployed = DeployedToken(
|
|
address=token_addr,
|
|
chain="solana",
|
|
name=acct.get("tokenName", "")
|
|
if isinstance(acct, dict)
|
|
else "",
|
|
symbol=acct.get("tokenSymbol", "")
|
|
if isinstance(acct, dict)
|
|
else "",
|
|
liquidity_usd=float(
|
|
acct.get("tokenAmount", {}).get("uiAmount", 0)
|
|
)
|
|
if isinstance(acct, dict)
|
|
else 0,
|
|
)
|
|
all_tokens.append(deployed)
|
|
except Exception as e:
|
|
logger.debug(f"Solscan fetch error: {e}")
|
|
|
|
return all_tokens
|
|
|
|
def _detect_patterns(self, profile: DeployerProfile) -> list[str]:
|
|
"""Detect known scam patterns from deployer behavior."""
|
|
patterns: list[str] = []
|
|
|
|
# Serial scammer pattern
|
|
if profile.rug_tokens >= 2 or (profile.rug_tokens >= 1 and profile.honeypot_tokens >= 1):
|
|
patterns.append("serial_scammer:multiple_rugs")
|
|
if (
|
|
profile.total_tokens_deployed >= 3
|
|
and profile.avg_token_lifespan_days < 14
|
|
and profile.dead_tokens / max(profile.total_tokens_deployed, 1) > 0.5
|
|
):
|
|
patterns.append("serial_scammer:short_lived_tokens")
|
|
|
|
# Rug pull pattern
|
|
if profile.rug_tokens > 0:
|
|
patterns.append("rug_pull:confirmed_rugs_in_history")
|
|
|
|
# Honeypot pattern
|
|
if profile.honeypot_tokens > 0:
|
|
patterns.append("honeypot:honeypot_deployments_detected")
|
|
|
|
# Pump and dump pattern
|
|
if profile.avg_token_lifespan_days < 3 and profile.total_tokens_deployed >= 2:
|
|
patterns.append("pump_dump:rapid_turnover")
|
|
|
|
# Suspicious renounce pattern
|
|
flags_all = [f for t in profile.tokens for f in t.flags]
|
|
if any("no_trading_activity" in f for f in flags_all):
|
|
patterns.append("suspicious_renounce:no_trading_after_launch")
|
|
|
|
# Cross-chain avoidance (deployer only on obscure chains)
|
|
if (
|
|
all(c in ("base", "polygon", "unknown") for c in profile.chains_used)
|
|
and profile.total_tokens_deployed >= 2
|
|
):
|
|
patterns.append("avoidance:limited_to_less_scanned_chains")
|
|
|
|
return list(set(patterns))
|
|
|
|
@staticmethod
|
|
def _to_dict(profile: DeployerProfile) -> dict[str, Any]:
|
|
"""Serialize profile to dict."""
|
|
return {
|
|
"address": profile.address,
|
|
"chains_used": profile.chains_used,
|
|
"total_tokens_deployed": profile.total_tokens_deployed,
|
|
"active_tokens": profile.active_tokens,
|
|
"dead_tokens": profile.dead_tokens,
|
|
"rug_tokens": profile.rug_tokens,
|
|
"honeypot_tokens": profile.honeypot_tokens,
|
|
"first_seen": profile.first_seen,
|
|
"last_active": profile.last_active,
|
|
"avg_token_lifespan_days": round(profile.avg_token_lifespan_days, 1),
|
|
"risk_score": profile.risk_score,
|
|
"risk_level": profile.risk_level,
|
|
"patterns_detected": profile.patterns_detected,
|
|
"confidence": round(profile.confidence, 2),
|
|
"recommendation": profile.recommendation,
|
|
"tokens": [
|
|
{
|
|
"address": t.address,
|
|
"chain": t.chain,
|
|
"name": t.name,
|
|
"symbol": t.symbol,
|
|
"deploy_time": t.deploy_time,
|
|
"liquidity_usd": round(t.liquidity_usd, 2),
|
|
"volume_24h": round(t.volume_24h, 2),
|
|
"price_usd": round(t.price_usd, 8),
|
|
"is_active": t.is_active,
|
|
"is_rug": t.is_rug,
|
|
"is_honeypot": t.is_honeypot,
|
|
"flags": t.flags,
|
|
"age_days": round(t.age_days, 1),
|
|
}
|
|
for t in profile.tokens
|
|
],
|
|
"errors": profile.errors,
|
|
"scanned_at": datetime.now(UTC).isoformat(),
|
|
}
|