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
189
app/routers/rug_recovery.py
Normal file
189
app/routers/rug_recovery.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
#!/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],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue