rmi-backend/app/routers/laundry_matcher.py

154 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""#9 — Token Laundry Matcher. Finds copycat tokens by code similarity, deployer patterns,
liquidity profile, marketing tactics. Spots scams before they launch."""
import os
import httpx
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1/laundry-matcher", tags=["laundry-matcher"])
BACKEND = os.environ.get("BACKEND_URL", "http://localhost:8000")
class LaundryMatch(BaseModel):
address: str
chain: str
similarity_score: float # 0-1
match_reasons: list[str]
risk_inherited: int
def _name_similarity(a: str, b: str) -> float:
"""Simple trigram similarity for token names."""
def trigrams(s):
s = s.lower().replace(" ", "")
return {s[i : i + 3] for i in range(len(s) - 2)} if len(s) >= 3 else {s}
ta = trigrams(a)
tb = trigrams(b)
if not ta or not tb:
return 0.0
return len(ta & tb) / len(ta | tb)
async def _fetch_token_pairs(chain: str, max_pairs: int = 50) -> list[dict]:
"""Fetch recent token pairs from DexScreener for comparison."""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"https://api.dexscreener.com/latest/dex/search?q={chain}")
if resp.status_code != 200:
return []
return resp.json().get("pairs", [])[:max_pairs]
except Exception:
return []
@router.get("/match/{address}")
async def find_matches(
address: str,
chain: str = Query("solana"),
min_similarity: float = Query(0.3, ge=0, le=1),
limit: int = Query(10, le=25),
):
"""Find tokens similar to the given address. Returns top-N copycat matches."""
# Get reference token data
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{BACKEND}/api/v1/token/scan",
json={"token_address": address, "chain": chain},
headers={"X-RMI-Key": "rmi-internal-2026"},
)
if resp.status_code != 200:
raise HTTPException(502, "Scanner unavailable")
reference = resp.json()
except Exception as e:
raise HTTPException(502, f"Scan failed: {e}")
ref_name = reference.get("free", {}).get("name", "") or reference.get("symbol", "")
reference.get("pro", {}).get("deployer_address", "")
# Fetch comparison pool
pairs = await _fetch_token_pairs(chain, max_pairs=50)
matches: list[dict] = []
for p in pairs:
base = p.get("baseToken", {})
target_name = base.get("name", "") or base.get("symbol", "")
target_addr = p.get("pairAddress", "") or base.get("address", "")
if target_addr == address:
continue
# Compute similarity
name_sim = _name_similarity(ref_name, target_name)
reasons: list[str] = []
if name_sim > 0.5:
reasons.append(f"Name similarity: {name_sim:.0%}")
if p.get("liquidity", {}).get("usd", 0) == 0:
reasons.append("Zero liquidity pattern")
similarity = name_sim # Primary metric
# Could add: code similarity, deployer overlap, launch timing
if similarity >= min_similarity:
matches.append(
{
"address": target_addr,
"chain": chain,
"symbol": base.get("symbol", "?"),
"name": target_name,
"similarity_score": round(similarity, 3),
"match_reasons": reasons,
"risk_inherited": reference.get("safety_score", 50),
"price_usd": float(p.get("priceUsd", 0) or 0),
"liquidity_usd": p.get("liquidity", {}).get("usd", 0) or 0,
}
)
matches.sort(key=lambda m: m["similarity_score"], reverse=True)
return {
"reference": {
"address": address,
"chain": chain,
"name": ref_name,
"safety_score": reference.get("safety_score", 50),
},
"matches": matches[:limit],
"total_candidates_scanned": len(pairs),
}
@router.get("/scan-similar/{address}")
async def scan_and_match(address: str, chain: str = Query("solana")):
"""Scan the address AND find its matches in a single call."""
async with httpx.AsyncClient(timeout=20) as client:
scan_task = client.post(
f"{BACKEND}/api/v1/token/scan",
json={"token_address": address, "chain": chain},
headers={"X-RMI-Key": "rmi-internal-2026"},
)
# Also kick off match search
pairs = await _fetch_token_pairs(chain)
scan_resp = await scan_task
if scan_resp.status_code != 200:
raise HTTPException(502, "Scan failed")
scan = scan_resp.json()
# Quick match
ref_name = scan.get("free", {}).get("name", "") or scan.get("symbol", "")
matches = []
for p in pairs[:50]:
base = p.get("baseToken", {})
sim = _name_similarity(ref_name, base.get("name", "") or base.get("symbol", ""))
if sim > 0.5:
matches.append(
{"address": p.get("pairAddress", ""), "symbol": base.get("symbol", "?"), "similarity": round(sim, 3)}
)
return {"scan": scan, "copycat_matches": sorted(matches, key=lambda m: m["similarity"], reverse=True)[:10]}