- 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>
189 lines
7 KiB
Python
189 lines
7 KiB
Python
#!/usr/bin/env python3
|
|
"""#18 - Rug Recovery Indexer. When a rug is detected by SENTINEL, tracks all outbound
|
|
txs from deployer. Builds trace graph of stolen funds. Premium: notify affected addresses."""
|
|
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Query
|
|
from pydantic import BaseModel
|
|
|
|
router = APIRouter(prefix="/api/v1/rug-recovery", tags=["rug-recovery"])
|
|
|
|
BACKEND = os.environ.get("BACKEND_URL", "http://localhost:8000")
|
|
DATABUS = os.environ.get("DATABUS_URL", "http://localhost:8000/api/v1/databus")
|
|
|
|
|
|
class RugTraceResult(BaseModel):
|
|
rug_token: str
|
|
chain: str
|
|
deployer: str
|
|
total_stolen_usd: float
|
|
fund_hops: int
|
|
destination_addresses: list[dict]
|
|
cex_deposits: list[dict]
|
|
mixer_usage: list[dict]
|
|
recovery_possible: bool
|
|
trace_graph: dict[str, Any]
|
|
|
|
|
|
async def _trace_outbound_txs(address: str, chain: str, max_hops: int = 3) -> list[dict]:
|
|
"""Trace outbound transactions from an address across hops."""
|
|
txs: list[dict] = []
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
f"{DATABUS}/{chain}/transactions/{address}", params={"direction": "out", "limit": 50}
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json().get("data", {})
|
|
txs = data.get("transactions", [])[:50]
|
|
except Exception:
|
|
pass
|
|
return txs
|
|
|
|
|
|
async def _check_cex_deposit(address: str, chain: str) -> dict | None:
|
|
"""Check if an address is a known CEX deposit address."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
resp = await client.get(f"{BACKEND}/api/v1/eth-labels/lookup/{address}")
|
|
if resp.status_code == 200:
|
|
for label in resp.json().get("labels", []):
|
|
lbl = (label.get("label", "") or "").lower()
|
|
if any(w in lbl for w in ["exchange", "cex", "binance", "coinbase", "kraken", "okx", "bybit"]):
|
|
return {"address": address, "cex": label.get("label"), "chain": chain}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def _check_mixer(address: str, chain: str) -> dict | None:
|
|
"""Check if an address is a known mixer/tumbler."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as client:
|
|
resp = await client.get(f"{BACKEND}/api/v1/eth-labels/lookup/{address}")
|
|
if resp.status_code == 200:
|
|
for label in resp.json().get("labels", []):
|
|
lbl = (label.get("label", "") or "").lower()
|
|
if any(w in lbl for w in ["mixer", "tornado", "tumbler"]):
|
|
return {"address": address, "mixer": label.get("label"), "chain": chain}
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
@router.get("/trace/{token_address}")
|
|
async def trace_rug(token_address: str, chain: str = Query("ethereum"), max_hops: int = Query(3, le=5)):
|
|
"""Trace stolen funds from a rugged token."""
|
|
# Step 1: Get deployer from SENTINEL
|
|
deployer = ""
|
|
total_stolen = 0.0
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
f"{BACKEND}/api/v1/token/scan",
|
|
json={"token_address": token_address, "chain": chain},
|
|
headers={"X-RMI-Key": "rmi-internal-2026"},
|
|
)
|
|
if resp.status_code == 200:
|
|
scan = resp.json()
|
|
deployer = scan.get("pro", {}).get("deployer_address", "")
|
|
total_stolen = scan.get("free", {}).get("liquidity_usd", 0) or 0
|
|
except Exception:
|
|
pass
|
|
|
|
if not deployer:
|
|
return {"error": "Could not identify deployer", "token_address": token_address, "chain": chain}
|
|
|
|
# Step 2: Trace outbound txs from deployer
|
|
direct_txs = await _trace_outbound_txs(deployer, chain)
|
|
|
|
# Step 3: Check destinations for CEX/mixer involvement
|
|
destinations = set()
|
|
for tx in direct_txs:
|
|
to_addr = tx.get("to", "")
|
|
if to_addr:
|
|
destinations.add(to_addr)
|
|
|
|
cex_hits: list[dict] = []
|
|
mixer_hits: list[dict] = []
|
|
for addr in list(destinations)[:20]:
|
|
cex = await _check_cex_deposit(addr, chain)
|
|
if cex:
|
|
cex_hits.append(cex)
|
|
mixer = await _check_mixer(addr, chain)
|
|
if mixer:
|
|
mixer_hits.append(mixer)
|
|
|
|
recovery_possible = len(cex_hits) > 0
|
|
|
|
# Build trace graph
|
|
nodes = [{"id": deployer[:16], "label": f"Deployer {deployer[:10]}", "type": "deployer"}]
|
|
edges: list[dict] = []
|
|
for tx in direct_txs[:20]:
|
|
to_id = tx.get("to", "")[:16]
|
|
if to_id:
|
|
nodes.append({"id": to_id, "label": f"Wallet {to_id}", "type": "wallet"})
|
|
edges.append(
|
|
{
|
|
"source": deployer[:16],
|
|
"target": to_id,
|
|
"amount_usd": float(tx.get("value_usd", 0) or 0),
|
|
"tx_hash": tx.get("hash", "?")[:16],
|
|
}
|
|
)
|
|
|
|
return {
|
|
"rug_token": token_address,
|
|
"chain": chain,
|
|
"deployer": deployer,
|
|
"total_stolen_usd": round(total_stolen, 2),
|
|
"fund_hops": 1,
|
|
"destination_addresses": [
|
|
{"address": a, "tx_count": sum(1 for tx in direct_txs if tx.get("to") == a)}
|
|
for a in list(destinations)[:10]
|
|
],
|
|
"cex_deposits": cex_hits,
|
|
"mixer_usage": mixer_hits,
|
|
"recovery_possible": recovery_possible,
|
|
"trace_graph": {"nodes": nodes, "edges": edges},
|
|
"recommendation": (
|
|
"Funds traced to known CEX addresses. Contact exchange with tx hashes and police report."
|
|
if recovery_possible
|
|
else "No CEX deposits detected yet. Monitor deployer wallet for future CEX transfers."
|
|
),
|
|
}
|
|
|
|
|
|
@router.get("/victims/{token_address}")
|
|
async def get_affected_victims(token_address: str, chain: str = Query("ethereum")):
|
|
"""Get list of addresses that interacted with (lost money to) a rug token."""
|
|
victims: list[dict] = []
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(f"{DATABUS}/{chain}/token/{token_address}/holders")
|
|
if resp.status_code == 200:
|
|
data = resp.json().get("data", {})
|
|
holders = data.get("holders", [])
|
|
for h in holders[:50]:
|
|
if float(h.get("pnl_usd", 0) or 0) < -1:
|
|
victims.append(
|
|
{
|
|
"address": h.get("address", "")[:20] + "...",
|
|
"loss_usd": round(abs(float(h.get("pnl_usd", 0) or 0)), 2),
|
|
"tokens_held": h.get("balance", 0),
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
victims.sort(key=lambda v: v["loss_usd"], reverse=True)
|
|
return {
|
|
"token_address": token_address,
|
|
"chain": chain,
|
|
"victim_count": len(victims),
|
|
"total_loss_usd": round(sum(v["loss_usd"] for v in victims), 2),
|
|
"victims": victims[:20],
|
|
}
|