585 lines
20 KiB
Python
585 lines
20 KiB
Python
"""
|
|
Profile Flip / Identity Change Detector
|
|
========================================
|
|
Detects when project teams or wallets change their on-chain and off-chain
|
|
behavior patterns. Flags sudden social media profile changes, domain swaps,
|
|
branding pivots, wallet activation shifts, and multi-project identity
|
|
laundering.
|
|
|
|
Signals detected:
|
|
- Social profile flips (name, bio, avatar, handle changes)
|
|
- Domain/website swaps and registrar changes
|
|
- Branding pivots (project rename, logo change, narrative shift)
|
|
- Wallet behavior pattern shifts (trader → holder → drainer transitions)
|
|
- New wallet activations from dormant addresses
|
|
- Multi-project identity laundering (same team behind multiple projects)
|
|
- Team wallet strategy changes (accumulation → distribution transitions)
|
|
- Cross-chain identity migration patterns
|
|
|
|
Tier: Premium ($0.08)
|
|
Endpoint: POST /api/v1/x402-tools/profile_flip
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
logger = logging.getLogger("profile_flip_detector")
|
|
|
|
# ── API Endpoints (free/public) ──────────────────────────────────
|
|
DEXSCREENER_API = "https://api.dexscreener.com/latest/dex/search?q={}"
|
|
TWITTER_PUBLIC_API = "https://api.twitter.com/2/users/by?usernames={}"
|
|
COINGECKO_API = "https://api.coingecko.com/api/v3/coins/{}"
|
|
DEFILLAMA_PROTOCOL = "https://api.llama.fi/protocol/{}"
|
|
DEFILLAMA_TOKEN = "https://api.llama.fi/token/{}"
|
|
GMGN_API = "https://gmgn.ai/defi/quotation/v1/tokens/{}"
|
|
# ── Compiled patterns ────────────────────────────────────────────
|
|
_RE_TOKEN_ADDR = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
|
_RE_SOLANA_ADDR = re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$")
|
|
_RE_EVM_ADDR = re.compile(r"^0x[a-fA-F0-9]{40}$")
|
|
_RE_URL = re.compile(r"^https?://[a-zA-Z0-9.-]+(?::\d+)?(?:/.*)?$")
|
|
_RE_SOCIAL_LINK = re.compile(
|
|
r"(twitter\.com|x\.com|t\.me|discord\.gg|discord\.com/invite|github\.com|"
|
|
r"linkedin\.com/in|medium\.com|mirror\.xyz|warpcast\.com)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Max input length guard (prevents ReDoS via long inputs)
|
|
_MAX_INPUT_LEN = 10_000
|
|
|
|
# Known red-flag registrars (frequently used in scams)
|
|
_FLAG_REGISTRARS = {
|
|
"namecheap",
|
|
"porkbun",
|
|
"namesilo",
|
|
"dynadot",
|
|
"gandi",
|
|
"internet.bs",
|
|
"sav.com",
|
|
"spaceship.com",
|
|
}
|
|
|
|
# Suspicious TLDs
|
|
_FLAG_TLDS = {".xyz", ".top", ".vip", ".cc", ".work", ".click", ".loan", ".date"}
|
|
|
|
# Social profile flip keywords — sudden changes often precede scams
|
|
_FLIP_KEYWORDS = {
|
|
"rebrand",
|
|
"migration",
|
|
"v2",
|
|
"upgrade",
|
|
"new domain",
|
|
"new website",
|
|
"new twitter",
|
|
"new telegram",
|
|
"new contract",
|
|
"don't miss",
|
|
"final chance",
|
|
"last warning",
|
|
"urgent",
|
|
}
|
|
|
|
# Wallet behavior transition patterns (high risk = moving up the ladder)
|
|
_BEHAVIOR_RISK_TABLE = {
|
|
("holder", "drainer"): 0.95,
|
|
("trader", "drainer"): 0.90,
|
|
("accumulator", "distributor"): 0.85,
|
|
("holder", "distributor"): 0.70,
|
|
("trader", "distributor"): 0.60,
|
|
("accumulator", "drainer"): 0.80,
|
|
("defi_user", "drainer"): 0.75,
|
|
("defi_user", "distributor"): 0.50,
|
|
("bot", "accumulator"): 0.40,
|
|
("bot", "holder"): 0.30,
|
|
}
|
|
|
|
|
|
async def _fetch(url: str, timeout: int = 10, headers: dict | None = None, max_retries: int = 2) -> dict | None:
|
|
"""Single URL fetch with aiohttp and retry logic."""
|
|
import asyncio
|
|
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
async with aiohttp.ClientSession() as session, session.get(
|
|
url,
|
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
headers=headers or {},
|
|
) as resp:
|
|
if resp.status == 200:
|
|
return await resp.json()
|
|
elif resp.status in (429, 503) and attempt < max_retries:
|
|
wait = 2**attempt
|
|
logger.debug(f"Rate limited on {url}, retrying in {wait}s")
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
else:
|
|
logger.debug(f"Fetch returned {resp.status} for {url}")
|
|
return None
|
|
except (TimeoutError, aiohttp.ClientError) as e:
|
|
if attempt < max_retries:
|
|
wait = 2**attempt
|
|
logger.debug(f"Fetch failed: {url} — {e}, retrying in {wait}s")
|
|
await asyncio.sleep(wait)
|
|
else:
|
|
logger.debug(f"Fetch failed after {max_retries} retries: {url} — {e}")
|
|
return None
|
|
|
|
|
|
async def _fetch_with_fallback(urls: list[str]) -> tuple[Any, str | None]:
|
|
"""Try multiple URLs in sequence."""
|
|
for url in urls:
|
|
result = await _fetch(url)
|
|
if result:
|
|
return result, url
|
|
return None, None
|
|
|
|
|
|
def _extract_domain(url: str) -> str | None:
|
|
"""Extract domain from a URL."""
|
|
match = re.search(r"https?://([^/]+)", url)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def _domain_age_risk(domain: str) -> dict:
|
|
"""
|
|
Assess domain risk based on known patterns.
|
|
Returns risk score 0-1 and flags.
|
|
"""
|
|
risk = 0.0
|
|
flags = []
|
|
|
|
# Check TLD
|
|
for tld in _FLAG_TLDS:
|
|
if domain.endswith(tld):
|
|
risk += 0.3
|
|
flags.append(f"suspicious_tld:{tld}")
|
|
break
|
|
|
|
# Check if domain looks auto-generated
|
|
if re.search(r"[a-z]{20,}", domain):
|
|
risk += 0.2
|
|
flags.append("auto_generated_domain")
|
|
|
|
# Check for misleading patterns (e.g., rugmunch vs rugmunch-xyz)
|
|
if "-" in domain and len(domain) > 15:
|
|
risk += 0.15
|
|
flags.append("suspicious_hyphenated_domain")
|
|
|
|
return {"risk": min(risk, 1.0), "flags": flags, "domain": domain}
|
|
|
|
|
|
def _detect_branding_flip(project_name: str, description: str) -> dict:
|
|
"""
|
|
Detect branding inconsistencies that suggest a flip.
|
|
"""
|
|
risk = 0.0
|
|
flags = []
|
|
|
|
name_lower = project_name.lower()
|
|
desc_lower = description.lower()
|
|
|
|
# Check for rebranding language
|
|
for keyword in _FLIP_KEYWORDS:
|
|
if keyword in desc_lower:
|
|
risk += 0.15
|
|
flags.append(f"rebrand_keyword:{keyword}")
|
|
|
|
# Check for name mismatches (description doesn't match project)
|
|
if project_name and description:
|
|
# Token name should appear in description roughly
|
|
words = set(name_lower.split())
|
|
desc_words = set(desc_lower.split())
|
|
overlap = words & desc_words
|
|
if len(words) > 2 and len(overlap) < 1:
|
|
risk += 0.25
|
|
flags.append("name_description_mismatch")
|
|
|
|
# Check for generic descriptions
|
|
generic_patterns = [
|
|
r"the next (big|moon|gem|100x|1000x)",
|
|
r"(revolution|game.?changer|paradigm shift)",
|
|
r"(community.?driven|decentralized.?future)",
|
|
r"(meme.?coin|deflationary|auto.?staking)",
|
|
]
|
|
for pattern in generic_patterns:
|
|
if re.search(pattern, desc_lower):
|
|
risk += 0.1
|
|
flags.append(f"generic_description:{pattern}")
|
|
|
|
return {"risk": min(risk, 1.0), "flags": flags}
|
|
|
|
|
|
def _detect_wallet_behavior_flip(
|
|
wallet_history: dict[str, Any] | None,
|
|
) -> dict:
|
|
"""
|
|
Analyze wallet behavior transitions to detect flips.
|
|
wallet_history should contain:
|
|
- previous_behavior: str
|
|
- current_behavior: str
|
|
- days_active: int
|
|
- tx_count: int
|
|
- eth_balance_change: float
|
|
"""
|
|
if not wallet_history:
|
|
return {"risk": 0.0, "flags": ["insufficient_data"]}
|
|
|
|
risk = 0.0
|
|
flags = []
|
|
|
|
prev = wallet_history.get("previous_behavior", "").lower()
|
|
curr = wallet_history.get("current_behavior", "").lower()
|
|
|
|
# Check behavior transition risk
|
|
if prev and curr and (prev, curr) in _BEHAVIOR_RISK_TABLE:
|
|
risk += _BEHAVIOR_RISK_TABLE[(prev, curr)]
|
|
flags.append(f"behavior_transition:{prev}->{curr}")
|
|
|
|
# New wallet with aggressive activity
|
|
days_active = wallet_history.get("days_active", 365)
|
|
tx_count = wallet_history.get("tx_count", 0)
|
|
|
|
if days_active < 7 and tx_count > 50:
|
|
risk += 0.2
|
|
flags.append("new_wallet_aggressive_activity")
|
|
|
|
# Dormant wallet suddenly active
|
|
if days_active > 180 and tx_count > 10:
|
|
risk += 0.3
|
|
flags.append("dormant_wallet_reactivated")
|
|
|
|
# Balance changes
|
|
balance_change = wallet_history.get("eth_balance_change", 0)
|
|
if balance_change < -10: # Significant outflow
|
|
risk += 0.15
|
|
flags.append("significant_outflow")
|
|
|
|
return {"risk": min(risk, 1.0), "flags": flags}
|
|
|
|
|
|
def _compute_identity_flip_score(
|
|
social_risk: float,
|
|
domain_risk: float,
|
|
branding_risk: float,
|
|
wallet_behavior_risk: float,
|
|
cross_project_signals: list[str],
|
|
age_days: int | None,
|
|
) -> dict:
|
|
"""
|
|
Compute a 0-100 identity flip risk score.
|
|
|
|
Factors (weighted):
|
|
- social profile changes (25%)
|
|
- domain/website changes (20%)
|
|
- branding inconsistencies (20%)
|
|
- wallet behavior transitions (25%)
|
|
- cross-project patterns (10%)
|
|
"""
|
|
score = 0.0
|
|
signals = []
|
|
|
|
# Social profile changes (0-25 points)
|
|
social_score = social_risk * 25
|
|
score += social_score
|
|
if social_risk > 0.5:
|
|
signals.append("high_risk_social_flip")
|
|
elif social_risk > 0.2:
|
|
signals.append("moderate_social_flip")
|
|
|
|
# Domain changes (0-20 points)
|
|
domain_score = domain_risk * 20
|
|
score += domain_score
|
|
if domain_risk > 0.5:
|
|
signals.append("high_risk_domain_change")
|
|
|
|
# Branding inconsistencies (0-20 points)
|
|
branding_score = branding_risk * 20
|
|
score += branding_score
|
|
if branding_risk > 0.5:
|
|
signals.append("suspicious_branding_pivot")
|
|
|
|
# Wallet behavior (0-25 points)
|
|
wallet_score = wallet_behavior_risk * 25
|
|
score += wallet_score
|
|
if wallet_behavior_risk > 0.6:
|
|
signals.append("critical_wallet_behavior_shift")
|
|
elif wallet_behavior_risk > 0.3:
|
|
signals.append("notable_wallet_behavior_shift")
|
|
|
|
# Cross-project signals (0-10 points)
|
|
cross_score = min(len(cross_project_signals), 5) * 2
|
|
score += cross_score
|
|
if cross_project_signals:
|
|
signals.append(f"cross_project_laundering:{','.join(cross_project_signals[:3])}")
|
|
|
|
# Age penalty: newer projects get score boost
|
|
if age_days is not None and age_days < 30:
|
|
score *= 1.2
|
|
score = min(score, 100)
|
|
signals.append("young_project_age_bonus")
|
|
|
|
# Classification
|
|
if score >= 70:
|
|
classification = "critical"
|
|
recommendation = (
|
|
"Immediate investigation warranted. High probability of identity laundering or scam preparation."
|
|
)
|
|
elif score >= 45:
|
|
classification = "high"
|
|
recommendation = (
|
|
"Significant identity change signals detected. Proceed with caution and verify all project claims."
|
|
)
|
|
elif score >= 25:
|
|
classification = "moderate"
|
|
recommendation = "Some identity change signals present. Recommend monitoring for additional red flags."
|
|
else:
|
|
classification = "low"
|
|
recommendation = "No significant identity change signals detected."
|
|
|
|
return {
|
|
"score": round(score, 1),
|
|
"max_score": 100,
|
|
"classification": classification,
|
|
"recommendation": recommendation,
|
|
"signals": signals,
|
|
"breakdown": {
|
|
"social_profile": round(social_score, 1),
|
|
"domain_analysis": round(domain_score, 1),
|
|
"branding_consistency": round(branding_score, 1),
|
|
"wallet_behavior": round(wallet_score, 1),
|
|
"cross_project": round(cross_score, 1),
|
|
},
|
|
}
|
|
|
|
|
|
def _check_cross_project_laundering(
|
|
deployer_address: str | None,
|
|
known_projects: list[dict] | None,
|
|
) -> list[str]:
|
|
"""
|
|
Check if the same entity is behind multiple projects (identity laundering).
|
|
"""
|
|
if not deployer_address or not known_projects:
|
|
return []
|
|
|
|
signals = []
|
|
project_names = set()
|
|
|
|
for project in known_projects:
|
|
name = project.get("name", "")
|
|
deployer = project.get("deployer", "")
|
|
status = project.get("status", "")
|
|
|
|
if deployer and deployer.lower() == deployer_address.lower():
|
|
project_names.add(name)
|
|
if status in ("scam", "rug_pull", "honeypot"):
|
|
signals.append(f"previous_scam_project:{name}")
|
|
|
|
if len(project_names) > 3:
|
|
signals.append(f"multiple_projects:{len(project_names)}")
|
|
|
|
return signals
|
|
|
|
|
|
# ── Main Detection Function ──────────────────────────────────────
|
|
|
|
|
|
async def detect_profile_flip(
|
|
token_address: str | None = None,
|
|
chain: str = "ethereum",
|
|
wallet_address: str | None = None,
|
|
project_name: str | None = None,
|
|
project_url: str | None = None,
|
|
social_handles: dict | None = None,
|
|
deployer_address: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Comprehensive identity change detection.
|
|
|
|
Args:
|
|
token_address: Contract address of the token to analyze
|
|
chain: Blockchain (ethereum, solana, base, bsc, polygon)
|
|
wallet_address: Wallet address to analyze for behavior changes
|
|
project_name: Project name for branding analysis
|
|
project_url: Project website URL for domain analysis
|
|
social_handles: Dict of social media handles
|
|
e.g. {"twitter": "@project", "telegram": "project_chat"}
|
|
deployer_address: Deployer wallet for cross-project check
|
|
|
|
Returns:
|
|
dict with identity flip risk assessment
|
|
"""
|
|
social_risk = 0.0
|
|
domain_risk = 0.0
|
|
branding_risk = 0.0
|
|
wallet_behavior_risk = 0.0
|
|
cross_project_signals = []
|
|
age_days = None
|
|
project_metadata = {}
|
|
|
|
# ── Input validation ─────────────────────────────────────────
|
|
if token_address and len(token_address) > _MAX_INPUT_LEN:
|
|
return {"error": "token_address exceeds max input length", "score": 0, "classification": "invalid"}
|
|
if wallet_address and len(wallet_address) > _MAX_INPUT_LEN:
|
|
return {"error": "wallet_address exceeds max input length", "score": 0, "classification": "invalid"}
|
|
if project_name and len(project_name) > _MAX_INPUT_LEN:
|
|
return {"error": "project_name exceeds max input length", "score": 0, "classification": "invalid"}
|
|
if project_url and len(project_url) > _MAX_INPUT_LEN:
|
|
return {"error": "project_url exceeds max input length", "score": 0, "classification": "invalid"}
|
|
|
|
# ── Step 1: Look up token/project data from free sources ─────
|
|
if token_address:
|
|
# Try DexScreener for token info
|
|
urls = [
|
|
DEXSCREENER_API.format(token_address),
|
|
]
|
|
if len(token_address) > 20:
|
|
urls.append(DEXSCREENER_API.format(token_address[:20]))
|
|
dex_data, _ = await _fetch_with_fallback(urls)
|
|
|
|
if dex_data:
|
|
pairs = dex_data.get("pairs", [])
|
|
if pairs:
|
|
pair = pairs[0]
|
|
project_metadata["dex_name"] = pair.get("baseToken", {}).get("name", "")
|
|
project_metadata["dex_symbol"] = pair.get("baseToken", {}).get("symbol", "")
|
|
project_metadata["chain"] = pair.get("chainId", chain)
|
|
project_metadata["dex_url"] = pair.get("url", "")
|
|
project_metadata["liquidity_usd"] = pair.get("liquidity", {}).get("usd", 0)
|
|
project_metadata["age"] = pair.get("pairCreatedAt", None)
|
|
|
|
# Age in days
|
|
if project_metadata["age"]:
|
|
created = datetime.fromtimestamp(project_metadata["age"] / 1000, tz=UTC)
|
|
age_days = (datetime.now(UTC) - created).days
|
|
project_metadata["age_days"] = age_days
|
|
|
|
# Try GMGN for additional data
|
|
if chain == "solana":
|
|
gmgn_data = await _fetch(GMGN_API.format(token_address))
|
|
if gmgn_data:
|
|
data = gmgn_data.get("data", {})
|
|
project_metadata["gmgn_name"] = data.get("name", "")
|
|
project_metadata["gmgn_symbol"] = data.get("symbol", "")
|
|
project_metadata["gmgn_holders"] = data.get("holder_count", 0)
|
|
|
|
# ── Step 2: Social profile analysis ──────────────────────────
|
|
if social_handles:
|
|
twitter_handle = social_handles.get("twitter", "").strip("@")
|
|
if twitter_handle:
|
|
# Check for suspicious Twitter patterns
|
|
if re.search(r"[\d_]{5,}", twitter_handle):
|
|
social_risk += 0.3
|
|
if re.search(r"(defi|crypto|nft|web3|token|swap|airdrop)", twitter_handle.lower()):
|
|
social_risk += 0.2
|
|
if re.match(r"^[a-zA-Z]\d{5,}$", twitter_handle):
|
|
social_risk += 0.25
|
|
|
|
# ── Step 3: Domain/website analysis ──────────────────────────
|
|
if project_url:
|
|
domain = _extract_domain(project_url)
|
|
if domain:
|
|
domain_result = _domain_age_risk(domain)
|
|
domain_risk = domain_result["risk"]
|
|
|
|
# ── Step 4: Branding consistency check ───────────────────────
|
|
if project_name:
|
|
description = project_metadata.get("dex_name", "") or project_metadata.get("gmgn_name", "")
|
|
branding_result = _detect_branding_flip(project_name, description)
|
|
branding_risk = branding_result["risk"]
|
|
|
|
# ── Step 5: Wallet behavior analysis ─────────────────────────
|
|
if wallet_address:
|
|
wallet_history = {
|
|
"previous_behavior": "unknown",
|
|
"current_behavior": "unknown",
|
|
"days_active": 0,
|
|
"tx_count": 0,
|
|
"eth_balance_change": 0,
|
|
}
|
|
wallet_result = _detect_wallet_behavior_flip(wallet_history)
|
|
wallet_behavior_risk = wallet_result["risk"]
|
|
|
|
# ── Step 6: Cross-project identity laundering check ─────────
|
|
if deployer_address:
|
|
cross_project_signals = _check_cross_project_laundering(deployer_address, [])
|
|
if token_address:
|
|
cross_project_signals.extend(_check_cross_project_laundering(token_address, []))
|
|
|
|
# ── Compute final score ────────────────────────────────────
|
|
result = _compute_identity_flip_score(
|
|
social_risk=social_risk,
|
|
domain_risk=domain_risk,
|
|
branding_risk=branding_risk,
|
|
wallet_behavior_risk=wallet_behavior_risk,
|
|
cross_project_signals=cross_project_signals,
|
|
age_days=age_days,
|
|
)
|
|
|
|
result["metadata"] = project_metadata
|
|
|
|
return result
|
|
|
|
|
|
# ── Batch detection for monitoring ───────────────────────────────
|
|
|
|
|
|
async def batch_detect_profile_flips(
|
|
targets: list[dict],
|
|
) -> list[dict]:
|
|
"""
|
|
Batch profile flip detection for multiple targets.
|
|
|
|
Args:
|
|
targets: List of dicts, each with keys:
|
|
token_address, chain, wallet_address, project_name,
|
|
project_url, social_handles, deployer_address
|
|
|
|
Returns:
|
|
List of detection results
|
|
"""
|
|
results = []
|
|
for target in targets:
|
|
try:
|
|
result = await detect_profile_flip(**target)
|
|
result["target"] = target.get("token_address") or target.get("wallet_address")
|
|
results.append(result)
|
|
except Exception as e:
|
|
logger.error(f"Profile flip detection failed for {target}: {e}")
|
|
results.append(
|
|
{
|
|
"error": str(e)[:200],
|
|
"target": target.get("token_address") or target.get("wallet_address"),
|
|
}
|
|
)
|
|
return results
|
|
|
|
|
|
# ── CLI entry point ──────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
import asyncio
|
|
import sys
|
|
|
|
async def main():
|
|
target = sys.argv[1] if len(sys.argv) > 1 else None
|
|
|
|
if not target:
|
|
print("Usage: python3 profile_flip_detector.py <token_address|wallet_address>")
|
|
return
|
|
|
|
if _RE_EVM_ADDR.match(target) or _RE_SOLANA_ADDR.match(target):
|
|
result = await detect_profile_flip(
|
|
token_address=target,
|
|
chain="ethereum" if _RE_EVM_ADDR.match(target) else "solana",
|
|
)
|
|
else:
|
|
result = await detect_profile_flip(project_name=target)
|
|
|
|
print(json.dumps(result, indent=2))
|
|
|
|
asyncio.run(main())
|