rmi-backend/app/routers/laundry_matcher.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- 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>
2026-07-06 15:43:20 +02:00

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}") from 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]}