- 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>
154 lines
5.3 KiB
Python
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]}
|