- 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>
391 lines
12 KiB
Python
391 lines
12 KiB
Python
"""
|
|
RMI Investigative Framework - FastAPI Router v2
|
|
Solana + EVM funding tracing, risk scanning, token analysis.
|
|
All backed by the unified data fallback engine.
|
|
"""
|
|
|
|
import time
|
|
|
|
from fastapi import APIRouter
|
|
from pydantic import BaseModel
|
|
|
|
router = APIRouter(prefix="/api/v1/investigate", tags=["investigate"])
|
|
|
|
|
|
class TraceRequest(BaseModel):
|
|
address: str
|
|
chain_id: int | None = 1
|
|
chain: str | None = "ethereum"
|
|
max_depth: int = 2
|
|
|
|
|
|
class ScanRequest(BaseModel):
|
|
address: str
|
|
chain_id: int | None = None
|
|
chain: str | None = "solana"
|
|
|
|
|
|
class GraphRequest(BaseModel):
|
|
address: str
|
|
chain: str | None = "solana"
|
|
depth: int = 3
|
|
|
|
|
|
CHAIN_NAMES = {
|
|
"ethereum": 1,
|
|
"bsc": 56,
|
|
"polygon": 137,
|
|
"base": 8453,
|
|
"arbitrum": 42161,
|
|
"optimism": 10,
|
|
"avalanche": 43114,
|
|
"fantom": 250,
|
|
"gnosis": 100,
|
|
"solana": 0,
|
|
}
|
|
|
|
|
|
@router.get("/chains")
|
|
async def list_chains():
|
|
return {
|
|
"chains": [
|
|
{"name": "Solana", "id": 0},
|
|
{"name": "Ethereum", "id": 1},
|
|
{"name": "BSC", "id": 56},
|
|
{"name": "Polygon", "id": 137},
|
|
{"name": "Base", "id": 8453},
|
|
{"name": "Arbitrum", "id": 42161},
|
|
{"name": "Optimism", "id": 10},
|
|
{"name": "Avalanche", "id": 43114},
|
|
{"name": "Fantom", "id": 250},
|
|
{"name": "Gnosis", "id": 100},
|
|
],
|
|
"default": "solana",
|
|
}
|
|
|
|
|
|
@router.post("/trace")
|
|
async def trace_wallet_funding(req: TraceRequest):
|
|
"""Trace funding source for any wallet (Solana or EVM)."""
|
|
chain = req.chain.lower()
|
|
chain_id = req.chain_id or CHAIN_NAMES.get(chain, 1)
|
|
|
|
if chain == "solana":
|
|
return await _trace_solana(req.address)
|
|
else:
|
|
return await _trace_evm(req.address, chain_id, req.max_depth)
|
|
|
|
|
|
@router.post("/scan")
|
|
async def full_investigation(req: ScanRequest):
|
|
"""Full investigation: trace + risk + labels."""
|
|
chain = req.chain.lower()
|
|
chain_id = req.chain_id or CHAIN_NAMES.get(chain, 1)
|
|
|
|
from app.caching_shield.data_fallback import DataQueryType, get_data_engine
|
|
|
|
engine = get_data_engine()
|
|
|
|
# Risk scan (works for both Solana and EVM)
|
|
risk = await engine.query(DataQueryType.RISK_SCAN, address=req.address, chain=chain)
|
|
|
|
# Labels
|
|
labels = None
|
|
try:
|
|
from app.wallet_label_loader import lookup_wallet_label
|
|
|
|
label = await lookup_wallet_label(req.address)
|
|
labels = [{"address": req.address, "label": label}] if label else []
|
|
except Exception:
|
|
labels = []
|
|
|
|
# Trace
|
|
if chain == "solana":
|
|
trace = await _trace_solana(req.address)
|
|
else:
|
|
trace = await _trace_evm(req.address, chain_id, 3)
|
|
|
|
return {
|
|
"wallet": req.address,
|
|
"chain": chain,
|
|
"trace": trace,
|
|
"risk": risk,
|
|
"labels": labels,
|
|
"queried_at": time.time(),
|
|
}
|
|
|
|
|
|
@router.get("/health")
|
|
async def fallback_health():
|
|
"""Check fallback engine status."""
|
|
from app.caching_shield.data_fallback import get_data_engine
|
|
|
|
return get_data_engine().health()
|
|
|
|
|
|
@router.post("/graph")
|
|
async def investigation_graph(req: GraphRequest):
|
|
"""
|
|
Return knowledge graph subgraph as cytoscape.js-compatible JSON.
|
|
Centers on the given address, explores outward up to `depth` hops.
|
|
Returns nodes + edges with type-specific colors and metadata.
|
|
"""
|
|
from app.knowledge_graph import NodeType, get_knowledge_graph
|
|
|
|
kg = await get_knowledge_graph()
|
|
|
|
# Try multiple node types to find the address
|
|
node_type = NodeType.WALLET
|
|
node_id = req.address
|
|
node = None
|
|
|
|
for nt in [NodeType.WALLET, NodeType.TOKEN, NodeType.ADDRESS, NodeType.CONTRACT]:
|
|
node = await kg.get_node(nt, node_id)
|
|
if node:
|
|
node_type = nt
|
|
break
|
|
|
|
if not node:
|
|
# Try with address casing variations
|
|
for nt in [NodeType.WALLET, NodeType.TOKEN, NodeType.ADDRESS]:
|
|
node = await kg.get_node(nt, node_id.lower())
|
|
if node:
|
|
node_type = nt
|
|
node_id = node_id.lower()
|
|
break
|
|
|
|
# If node still not found, create a placeholder
|
|
if not node:
|
|
node_type = NodeType.ADDRESS
|
|
node = {"label": node_id[:12] + "...", "type": NodeType.ADDRESS, "metadata": {}}
|
|
|
|
# Get subgraph
|
|
raw = await kg.get_subgraph(
|
|
node_type=node_type,
|
|
node_id=node_id,
|
|
depth=min(req.depth, 5),
|
|
max_nodes=80,
|
|
)
|
|
|
|
# ── Convert to cytoscape.js JSON ──────────────────────────
|
|
NODE_COLORS = {
|
|
"wallet": "#3498db",
|
|
"token": "#2ecc71",
|
|
"contract": "#e67e22",
|
|
"scam": "#e74c3c",
|
|
"entity": "#9b59b6",
|
|
"chain": "#1abc9c",
|
|
"address": "#95a5a6",
|
|
}
|
|
NODE_SHAPES = {
|
|
"wallet": "ellipse",
|
|
"token": "round-rectangle",
|
|
"contract": "rectangle",
|
|
"scam": "triangle",
|
|
"entity": "diamond",
|
|
"chain": "hexagon",
|
|
"address": "ellipse",
|
|
}
|
|
|
|
cy_nodes = []
|
|
cy_edges = []
|
|
|
|
for key, nd in raw.get("nodes", {}).items():
|
|
ntype = nd.get("type", "address")
|
|
cy_nodes.append(
|
|
{
|
|
"data": {
|
|
"id": key,
|
|
"label": nd.get("label", nd.get("id", "")),
|
|
"type": ntype,
|
|
"color": NODE_COLORS.get(ntype, "#95a5a6"),
|
|
"shape": NODE_SHAPES.get(ntype, "ellipse"),
|
|
"size": 45 if key == raw.get("center") else 30,
|
|
"metadata": nd.get("metadata", {}),
|
|
},
|
|
"classes": f"node-{ntype}" + (" center" if key == raw.get("center") else ""),
|
|
}
|
|
)
|
|
|
|
for edge in raw.get("edges", []):
|
|
rel = edge.get("relation", "associated")
|
|
cy_edges.append(
|
|
{
|
|
"data": {
|
|
"id": f"{edge['from']}→{edge['to']}",
|
|
"source": edge["from"],
|
|
"target": edge["to"],
|
|
"label": rel,
|
|
"weight": edge.get("weight", 0.5),
|
|
"relation": rel,
|
|
},
|
|
"classes": f"edge-{rel}",
|
|
}
|
|
)
|
|
|
|
return {
|
|
"center": raw.get("center", ""),
|
|
"nodes": cy_nodes,
|
|
"edges": cy_edges,
|
|
"depth": raw.get("depth", 0),
|
|
"total_nodes": len(cy_nodes),
|
|
"total_edges": len(cy_edges),
|
|
"chain": req.chain,
|
|
"queried_at": time.time(),
|
|
}
|
|
|
|
|
|
@router.post("/fund-flow")
|
|
async def fund_flow_sankey(req: GraphRequest):
|
|
"""
|
|
Return fund flow data formatted for D3 Sankey diagram.
|
|
Shows creator → funders → LPs → sellers flow with amounts.
|
|
Falls back to cytoscape-compatible format if Sankey data is sparse.
|
|
"""
|
|
chain = req.chain.lower()
|
|
address = req.address.strip()
|
|
|
|
# Get fund flow data using existing visualizer
|
|
from app.scanners.fund_flow_visualizer import (
|
|
_build_flow_graph,
|
|
_fetch_evm_wallets,
|
|
_fetch_solana_wallets,
|
|
)
|
|
|
|
if chain == "solana" or (not address.startswith("0x")):
|
|
wallets = await _fetch_solana_wallets(address)
|
|
else:
|
|
wallets = await _fetch_evm_wallets(address, chain)
|
|
|
|
nodes, edges, risk_flags = _build_flow_graph(wallets, address)
|
|
|
|
# ── Convert to D3 Sankey format ──────────────────────────
|
|
sankey_nodes = []
|
|
sankey_links = []
|
|
node_index: dict = {}
|
|
|
|
for n in nodes:
|
|
idx = len(sankey_nodes)
|
|
addr_key = n.address.lower()
|
|
node_index[addr_key] = idx
|
|
sankey_nodes.append(
|
|
{
|
|
"id": idx,
|
|
"name": n.address[:10] + "..." + n.address[-6:] if len(n.address) > 16 else n.address,
|
|
"address": n.address,
|
|
"role": n.role,
|
|
"amount_usd": n.amount_usd,
|
|
}
|
|
)
|
|
|
|
for e in edges:
|
|
from_key = e.from_addr.lower()
|
|
to_key = e.to_addr.lower()
|
|
if from_key in node_index and to_key in node_index:
|
|
sankey_links.append(
|
|
{
|
|
"source": node_index[from_key],
|
|
"target": node_index[to_key],
|
|
"value": max(e.amount_usd, 1.0),
|
|
}
|
|
)
|
|
|
|
# Also generate cytoscape nodes/edges for graph overlay
|
|
cy_nodes = []
|
|
cy_edges = []
|
|
ROLE_COLORS = {
|
|
"creator": "#9b59b6",
|
|
"funder": "#2ecc71",
|
|
"lp": "#f1c40f",
|
|
"seller": "#e74c3c",
|
|
"other": "#95a5a6",
|
|
}
|
|
ROLE_LABELS = {
|
|
"creator": "Creator/Deployer",
|
|
"funder": "Early Funder",
|
|
"lp": "LP Holder",
|
|
"seller": "Early Seller",
|
|
"other": "Holder",
|
|
}
|
|
|
|
for n in nodes:
|
|
short = n.address[:8] + "..." + n.address[-6:] if len(n.address) > 16 else n.address
|
|
cy_nodes.append(
|
|
{
|
|
"data": {
|
|
"id": n.address,
|
|
"label": f"{ROLE_LABELS.get(n.role, n.role)}\n{short}",
|
|
"type": n.role,
|
|
"color": ROLE_COLORS.get(n.role, "#95a5a6"),
|
|
"size": 40 if n.role == "creator" else 28,
|
|
"amount_usd": n.amount_usd,
|
|
},
|
|
"classes": f"flow-{n.role}",
|
|
}
|
|
)
|
|
|
|
for e in edges:
|
|
cy_edges.append(
|
|
{
|
|
"data": {
|
|
"id": f"flow-{e.from_addr[:8]}→{e.to_addr[:8]}",
|
|
"source": e.from_addr,
|
|
"target": e.to_addr,
|
|
"label": f"${e.amount_usd:,.0f}",
|
|
"weight": max(min(e.amount_usd / 1000, 1.0), 0.1),
|
|
"relation": "fund_flow",
|
|
},
|
|
"classes": "flow-edge",
|
|
}
|
|
)
|
|
|
|
return {
|
|
"address": address,
|
|
"chain": chain,
|
|
"sankey": {"nodes": sankey_nodes, "links": sankey_links},
|
|
"graph": {"nodes": cy_nodes, "edges": cy_edges},
|
|
"risk_flags": risk_flags,
|
|
"risk_score": len(risk_flags) * 15 + (30 if len(nodes) < 3 else 0),
|
|
"total_nodes": len(nodes),
|
|
"total_edges": len(edges),
|
|
"queried_at": time.time(),
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# TRACING HELPERS
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def _trace_solana(address: str) -> dict:
|
|
from app.caching_shield.data_fallback import DataQueryType, get_data_engine
|
|
|
|
engine = get_data_engine()
|
|
result = await engine.query(DataQueryType.SOLANA_FUNDING, address=address)
|
|
if result:
|
|
return {
|
|
"source": result.get("source", "unknown"),
|
|
"first_tx": result.get("first_tx", ""),
|
|
"balance_change_sol": result.get("balance_change", 0),
|
|
"total_value_usd": result.get("total_value", 0),
|
|
"signatures_found": result.get("signatures_found", 0),
|
|
"label": result.get("label", ""),
|
|
"is_cex": result.get("is_cex", False),
|
|
}
|
|
return {"source": "none", "error": "no data from any provider"}
|
|
|
|
|
|
async def _trace_evm(address: str, chain_id: int, max_depth: int) -> dict:
|
|
from app.caching_shield.funding_tracer import trace_funding_source
|
|
|
|
result = await trace_funding_source(address, chain_id, max_depth)
|
|
return {
|
|
"source_type": result.source_type,
|
|
"source_address": result.source_address,
|
|
"source_label": result.source_label,
|
|
"confidence": result.confidence,
|
|
"funding_tx": result.funding_tx_hash,
|
|
"funding_amount_eth": result.funding_amount_eth,
|
|
"hops": result.hops,
|
|
"errors": result.errors,
|
|
}
|