merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
408
app/routers/wallet_clustering_router.py
Normal file
408
app/routers/wallet_clustering_router.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
"""
|
||||
Wallet Clustering API Router — Cluster detection, funding paths, risk analysis.
|
||||
Connects to /api/v1/wallet-clusters/*
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/v1/wallet-clusters", tags=["wallet-clustering"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClusterRequest(BaseModel):
|
||||
wallets: list[str]
|
||||
min_confidence: float = 0.3
|
||||
include_sleepers: bool = True
|
||||
|
||||
|
||||
class FundingPathRequest(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
max_depth: int = 5
|
||||
|
||||
|
||||
class BubblemapRequest(BaseModel):
|
||||
center_wallet: str
|
||||
depth: int = 2
|
||||
max_wallets: int = 250
|
||||
ai_deep_dive: bool = False
|
||||
|
||||
|
||||
class ContractScanRequest(BaseModel):
|
||||
contract_address: str
|
||||
chain: str = "solana"
|
||||
detect_clusters: bool = True
|
||||
detect_bundles: bool = True
|
||||
|
||||
|
||||
def _get_detector():
|
||||
try:
|
||||
from app.cluster_detection import ClusterDetectionPro
|
||||
|
||||
return ClusterDetectionPro()
|
||||
except ImportError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Module unavailable: {e}")
|
||||
|
||||
|
||||
def _get_engine():
|
||||
try:
|
||||
from app.wallet_clustering import get_clustering_engine
|
||||
|
||||
return get_clustering_engine()
|
||||
except ImportError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Module unavailable: {e}")
|
||||
|
||||
|
||||
@router.post("/detect")
|
||||
async def detect_clusters(req: ClusterRequest):
|
||||
"""Detect wallet clusters from a list of addresses (7 methods)."""
|
||||
if len(req.wallets) < 2:
|
||||
raise HTTPException(status_code=400, detail="Need at least 2 wallets")
|
||||
if len(req.wallets) > 100:
|
||||
raise HTTPException(status_code=400, detail="Max 100 wallets per request")
|
||||
|
||||
detector = _get_detector()
|
||||
clusters = await detector.detect_clusters(
|
||||
wallets=req.wallets,
|
||||
min_confidence=req.min_confidence,
|
||||
include_sleepers=req.include_sleepers,
|
||||
)
|
||||
return {
|
||||
"total_wallets": len(req.wallets),
|
||||
"clusters_found": len(clusters),
|
||||
"clusters": [c.to_dict() for c in clusters],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/funding-path")
|
||||
async def trace_funding_path(req: FundingPathRequest):
|
||||
"""Trace the funding path between two wallets (BFS)."""
|
||||
detector = _get_detector()
|
||||
path = await detector.trace_funding_path(source=req.source, target=req.target, max_depth=req.max_depth)
|
||||
if path:
|
||||
return {"source": req.source, "target": req.target, "path": path.to_dict()}
|
||||
return {"source": req.source, "target": req.target, "path": None, "note": "No path found"}
|
||||
|
||||
|
||||
@router.post("/scan")
|
||||
async def scan_all():
|
||||
"""Run all 4 clustering methods and return merged results."""
|
||||
engine = _get_engine()
|
||||
clusters = engine.find_all_clusters()
|
||||
return {"clusters": [c.to_dict() for c in clusters], "total_clusters": len(clusters)}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def clustering_health():
|
||||
cache_stats = {}
|
||||
gnn_status = {}
|
||||
spam_stats = {}
|
||||
try:
|
||||
from app.chain_cache import get_chain_cache
|
||||
|
||||
cache_stats = await get_chain_cache().stats()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from app.fraud_gnn import get_fraud_gnn
|
||||
|
||||
gnn_status = get_fraud_gnn().status()
|
||||
except Exception:
|
||||
gnn_status = {"error": "unavailable"}
|
||||
try:
|
||||
from app.spam_registry import get_spam_registry
|
||||
|
||||
spam_stats = get_spam_registry().stats()
|
||||
except Exception:
|
||||
spam_stats = {"error": "unavailable"}
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "wallet-clustering-engine",
|
||||
"cache": cache_stats,
|
||||
"gnn": gnn_status,
|
||||
"spam_registry": spam_stats,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report/{cluster_id}")
|
||||
async def get_cluster_report(cluster_id: str):
|
||||
"""Get detailed report for a specific cluster."""
|
||||
engine = _get_engine()
|
||||
report = engine.get_cluster_report(cluster_id)
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/bubble-map-data")
|
||||
async def get_bubble_map_data(req: BubblemapRequest):
|
||||
"""Generate bubble map data for a wallet.
|
||||
Supports up to 250 wallets deep. Optional AI deep dive for advanced forensics."""
|
||||
engine = _get_engine()
|
||||
depth = min(req.depth, 10) # Cap depth at 10 to prevent infinite loops, but max_wallets handles the 250 limit
|
||||
data = engine.generate_bubble_map_data(
|
||||
center_wallet=req.center_wallet, depth=depth, max_wallets=min(req.max_wallets, 250)
|
||||
)
|
||||
|
||||
# AI Deep Dive: If requested and cluster is large/suspicious, trigger AI analysis
|
||||
if req.ai_deep_dive and data.get("nodes") and len(data["nodes"]) >= 5:
|
||||
try:
|
||||
from app.ai_router import router as ai_router
|
||||
|
||||
# Extract top suspicious wallets for AI context
|
||||
suspicious_wallets = [
|
||||
n for n in data["nodes"] if n.get("type") in ["scammer", "exchange"] or n.get("volume", 0) > 10000
|
||||
][:10]
|
||||
|
||||
prompt = f"""Analyze this wallet cluster for {req.center_wallet}.
|
||||
Center wallet: {req.center_wallet}
|
||||
Total connected wallets: {len(data["nodes"])}
|
||||
Suspicious nodes: {suspicious_wallets}
|
||||
|
||||
Provide a 3-bullet point forensic breakdown:
|
||||
1. Primary risk vector (honeypot, bundle, wash trading, etc.)
|
||||
2. Key suspicious wallets and their roles
|
||||
3. Recommended action for the user
|
||||
|
||||
Be concise, professional, and direct."""
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are an elite blockchain forensics analyst. Provide concise, actionable intelligence.",
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
result = await ai_router.chat_completion( # type: ignore
|
||||
messages=messages, tier="T2", temperature=0.2, max_tokens=400, timeout=15.0
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
data["ai_deep_dive_analysis"] = f"AI analysis failed: {result['error']}"
|
||||
else:
|
||||
data["ai_deep_dive_analysis"] = result.get("content", "AI analysis completed but returned empty.")
|
||||
except Exception as e:
|
||||
logger.warning(f"AI deep dive failed: {e}")
|
||||
data["ai_deep_dive_analysis"] = "AI analysis temporarily unavailable."
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/ai-forensic-breakdown")
|
||||
async def get_ai_forensic_breakdown(req: BubblemapRequest):
|
||||
"""
|
||||
Premium AI-driven forensic breakdown that dynamically pulls deeper if necessary.
|
||||
|
||||
This is a unique feature that analyzes the initial cluster (up to 250 wallets) for
|
||||
risk vectors. If complex layering, high-risk patterns, or obfuscation tactics are
|
||||
detected, it automatically expands the search depth (up to 1000 wallets) to provide
|
||||
a comprehensive forensic breakdown that competitors lack.
|
||||
"""
|
||||
engine = _get_engine()
|
||||
|
||||
# Use the new AI forensic breakdown method
|
||||
breakdown = engine.generate_ai_forensic_breakdown(
|
||||
center_wallet=req.center_wallet,
|
||||
initial_depth=req.depth,
|
||||
initial_max_wallets=min(req.max_wallets, 250),
|
||||
max_expansion_depth=5,
|
||||
absolute_max_wallets=1000,
|
||||
)
|
||||
|
||||
# If AI deep dive is explicitly requested, enrich with LLM analysis
|
||||
if req.ai_deep_dive and breakdown.get("total_wallets_analyzed", 0) > 0:
|
||||
try:
|
||||
from app.ai_router import router as ai_router
|
||||
|
||||
prompt = f"""You are an elite blockchain forensics analyst. Analyze this wallet cluster.
|
||||
|
||||
CENTER WALLET: {req.center_wallet}
|
||||
ANALYSIS MODE: {breakdown.get("analysis_mode")}
|
||||
RISK SCORE: {breakdown.get("risk_score")}/1.0
|
||||
RISK VECTORS: {", ".join(breakdown.get("risk_vectors", ["None"]))}
|
||||
TOTAL WALLETS ANALYZED: {breakdown.get("total_wallets_analyzed")}
|
||||
TOTAL CONNECTIONS: {breakdown.get("total_connections_analyzed")}
|
||||
|
||||
WALLET PROFILES (top 10 by volume):
|
||||
{chr(10).join([f"- {w['address']}: {w['total_volume']} vol, {w['total_transactions']} txs" for w in breakdown.get("wallet_profiles", [])[:10]])}
|
||||
|
||||
Provide a concise, professional forensic breakdown:
|
||||
1. Primary risk vector and obfuscation tactics used
|
||||
2. Key suspicious wallets and their likely roles (funder, mule, cash-out)
|
||||
3. Clear, actionable recommendation for the user
|
||||
|
||||
Be direct and avoid fluff."""
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are an elite blockchain forensics analyst. Provide concise, actionable intelligence.",
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
result = await ai_router.chat_completion( # type: ignore
|
||||
messages=messages, tier="T2", temperature=0.2, max_tokens=600, timeout=20.0
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
breakdown["ai_llm_analysis"] = f"AI analysis failed: {result['error']}"
|
||||
else:
|
||||
breakdown["ai_llm_analysis"] = result.get("content", "AI analysis completed but returned empty.")
|
||||
except Exception as e:
|
||||
logger.warning(f"AI LLM deep dive failed: {e}")
|
||||
breakdown["ai_llm_analysis"] = (
|
||||
"AI LLM analysis temporarily unavailable, but heuristic forensic data is provided."
|
||||
)
|
||||
|
||||
return breakdown
|
||||
|
||||
|
||||
@router.post("/contract-scan")
|
||||
async def scan_contract_clusters(req: ContractScanRequest):
|
||||
"""Scan a contract for wallet clusters among its holders.
|
||||
Finds real holder wallets via Helius, then detects clusters."""
|
||||
engine = _get_engine()
|
||||
detector = _get_detector()
|
||||
|
||||
# Step 1: Get real holders from chain (multi-source fallback)
|
||||
holders: list[str] = []
|
||||
try:
|
||||
from app.unified_provider import get_unified_provider
|
||||
|
||||
provider = get_unified_provider()
|
||||
holder_data = await provider.get_token_holders(req.contract_address, limit=30)
|
||||
holders = [h.get("address") for h in holder_data if h.get("address")]
|
||||
|
||||
# Filter out exchange/DeFi infrastructure
|
||||
excluded_entities = []
|
||||
try:
|
||||
from app.entity_registry import get_entity_registry
|
||||
|
||||
registry = get_entity_registry()
|
||||
holders, excluded_entities = registry.filter_infrastructure(holders)
|
||||
except Exception as e:
|
||||
logger.debug(f"Entity filter skipped: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Provider failed: {e}")
|
||||
|
||||
# Step 2: Feed transactions for holders into engine
|
||||
if holders and req.detect_clusters:
|
||||
try:
|
||||
from app.unified_provider import get_unified_provider
|
||||
|
||||
provider = get_unified_provider()
|
||||
for h in holders[:5]: # Limit API calls — feed top 5 holders
|
||||
txs = await provider.get_wallet_transactions(h, limit=10)
|
||||
from datetime import datetime
|
||||
|
||||
from app.wallet_clustering import Transaction
|
||||
|
||||
for t in txs:
|
||||
ts = t.get("timestamp")
|
||||
dt = datetime.fromtimestamp(ts, tz=UTC) if ts else datetime.now(UTC)
|
||||
engine.add_transaction(
|
||||
Transaction(
|
||||
signature=t.get("signature", ""),
|
||||
timestamp=dt,
|
||||
from_address=h,
|
||||
to_address="unknown",
|
||||
amount=0,
|
||||
token="SOL",
|
||||
program="system",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Feed failed: {e}")
|
||||
|
||||
result = {
|
||||
"contract": req.contract_address,
|
||||
"chain": req.chain,
|
||||
"total_holders": len(holders) + len(excluded_entities),
|
||||
"holders": holders[:10],
|
||||
"excluded_infrastructure": [
|
||||
{"address": e.address, "entity": e.entity_name, "type": e.entity_type} for e in excluded_entities
|
||||
],
|
||||
"holders_labeled": sum(1 for h in holders if engine._get_known_scammer_wallets()),
|
||||
"spam_check": {},
|
||||
}
|
||||
|
||||
# Spam/sanctions check
|
||||
try:
|
||||
from app.spam_registry import get_spam_registry
|
||||
|
||||
sr = get_spam_registry()
|
||||
result["spam_check"] = sr.check_token(req.contract_address, req.chain)
|
||||
except Exception as e:
|
||||
logger.debug(f"Spam check skipped: {e}")
|
||||
|
||||
# Step 3: Detect clusters
|
||||
if req.detect_clusters and len(holders) >= 2:
|
||||
clusters = await detector.detect_clusters(wallets=holders[:50], min_confidence=0.3, include_sleepers=True)
|
||||
result["clusters_found"] = len(clusters)
|
||||
result["clusters"] = [c.to_dict() for c in clusters]
|
||||
|
||||
# GNN fraud scoring on cluster wallets
|
||||
if clusters:
|
||||
try:
|
||||
from app.fraud_gnn import get_fraud_gnn
|
||||
|
||||
gnn = get_fraud_gnn()
|
||||
all_wallets = [w for c in clusters for w in c.wallets]
|
||||
fps = [{"address": w, "tx_count": 1} for w in all_wallets[:20]]
|
||||
gnn_scores = gnn.score_wallets(fps)
|
||||
high_risk = [s for s in gnn_scores if s.get("fraud_probability", 0) >= 0.5]
|
||||
result["gnn_scoring"] = {
|
||||
"wallets_scored": len(gnn_scores),
|
||||
"high_risk_count": len(high_risk),
|
||||
"avg_fraud_probability": round(
|
||||
sum(s.get("fraud_probability", 0) for s in gnn_scores) / max(len(gnn_scores), 1),
|
||||
4,
|
||||
),
|
||||
"model": gnn.status().get("model_type", "unknown"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"GNN scoring skipped: {e}")
|
||||
|
||||
# Step 4: Bundle detection
|
||||
if req.detect_bundles and holders:
|
||||
try:
|
||||
from app.bundle_detector import get_bundle_detector
|
||||
|
||||
bd = get_bundle_detector()
|
||||
bundle = await bd.detect(
|
||||
token_address=req.contract_address,
|
||||
chain=req.chain,
|
||||
holders=holder_data,
|
||||
transactions=None, # TODO: feed real tx data
|
||||
)
|
||||
result["bundle_detection"] = {
|
||||
"is_bundled": bundle.is_bundled,
|
||||
"confidence": bundle.confidence,
|
||||
"risk_label": bundle.risk_label,
|
||||
"signals": {
|
||||
"atomic_block": bundle.atomic_block_score,
|
||||
"common_funder": bundle.common_funder_score,
|
||||
"temporal": bundle.temporal_score,
|
||||
"distribution_anomaly": bundle.distribution_anomaly_score,
|
||||
"concentration": bundle.concentration_score,
|
||||
},
|
||||
"details": {
|
||||
"top10_pct": bundle.top10_holder_pct,
|
||||
"top3_pct": bundle.top3_holder_pct,
|
||||
"identical_amounts": bundle.identical_amount_count,
|
||||
"round_amounts": bundle.round_amount_count,
|
||||
"holder_count": bundle.holder_count,
|
||||
"common_funder": bundle.common_funder_address,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Bundle detection failed: {e}")
|
||||
|
||||
return result
|
||||
Loading…
Add table
Add a link
Reference in a new issue