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
396
app/entity_graph.py
Normal file
396
app/entity_graph.py
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
"""
|
||||
Entity Graph Visualization — Interactive Fund Flow for RugMaps + Scans
|
||||
=======================================================================
|
||||
|
||||
Generates interactive entity relationship graphs showing wallet connections,
|
||||
fund flows, and cluster relationships. SVG output for embedding in scan results
|
||||
and RugMaps pages.
|
||||
|
||||
Premium feature: Visual forensics that sells in screenshots.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("entity.graph")
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphNode:
|
||||
id: str
|
||||
label: str
|
||||
node_type: str = "wallet" # "wallet", "token", "contract", "entity", "cex"
|
||||
risk_score: float = 0.0
|
||||
chain: str = ""
|
||||
size: int = 20
|
||||
color: str = "#8B5CF6"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphEdge:
|
||||
source: str
|
||||
target: str
|
||||
edge_type: str = "transfer" # "transfer", "deploy", "fund", "cluster", "counterparty"
|
||||
value_usd: float = 0.0
|
||||
tx_count: int = 1
|
||||
color: str = "#4B5563"
|
||||
width: float = 1.0
|
||||
|
||||
|
||||
def build_entity_graph(
|
||||
center_address: str,
|
||||
chain: str = "ethereum",
|
||||
transactions: list[dict] | None = None,
|
||||
labels: dict[str, Any] | None = None,
|
||||
cluster_members: list[str] | None = None,
|
||||
depth: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an entity relationship graph from wallet data.
|
||||
|
||||
Returns graph data suitable for visualization (SVG or Canvas).
|
||||
"""
|
||||
nodes: dict[str, GraphNode] = {}
|
||||
edges: list[GraphEdge] = []
|
||||
|
||||
# ── Center node ──
|
||||
center_id = f"{chain}:{center_address.lower()}"
|
||||
nodes[center_id] = GraphNode(
|
||||
id=center_id,
|
||||
label=_shorten_addr(center_address),
|
||||
node_type="wallet",
|
||||
chain=chain,
|
||||
size=30,
|
||||
color="#22D3EE", # Cyan for center
|
||||
metadata={"address": center_address, "chain": chain},
|
||||
)
|
||||
|
||||
# ── Process transactions ──
|
||||
txs = transactions or []
|
||||
counterparties = set()
|
||||
|
||||
for tx in txs[:200]: # Cap at 200 transactions
|
||||
if not isinstance(tx, dict):
|
||||
continue
|
||||
|
||||
counterparty = tx.get("counterparty") or tx.get("to") or tx.get("from")
|
||||
if not counterparty or counterparty.lower() == center_address.lower():
|
||||
continue
|
||||
|
||||
cp_id = f"{tx.get('chain', chain)}:{counterparty.lower()}"
|
||||
counterparties.add(cp_id)
|
||||
|
||||
value = float(tx.get("value_usd", 0) or 0)
|
||||
tx_type = tx.get("type", "transfer")
|
||||
is_outgoing = tx.get("direction") == "out" or tx.get("from", "").lower() == center_address.lower()
|
||||
|
||||
# Add counterparty node if new
|
||||
if cp_id not in nodes:
|
||||
# Determine node type and color
|
||||
node_type, color = _classify_node(counterparty, labels or {}, tx)
|
||||
nodes[cp_id] = GraphNode(
|
||||
id=cp_id,
|
||||
label=_shorten_addr(counterparty),
|
||||
node_type=node_type,
|
||||
chain=tx.get("chain", chain),
|
||||
size=18 if node_type == "wallet" else 22,
|
||||
color=color,
|
||||
metadata={"address": counterparty},
|
||||
)
|
||||
|
||||
# Add edge
|
||||
edge_color = "#EF4444" if is_outgoing else "#22C55E" # Red=out, Green=in
|
||||
edge_width = min(max(value / 10000, 1), 6) if value > 0 else 1
|
||||
|
||||
edges.append(
|
||||
GraphEdge(
|
||||
source=center_id if is_outgoing else cp_id,
|
||||
target=cp_id if is_outgoing else center_id,
|
||||
edge_type=tx_type,
|
||||
value_usd=value,
|
||||
tx_count=1,
|
||||
color=edge_color,
|
||||
width=edge_width,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Add cluster members (if available) ──
|
||||
if cluster_members:
|
||||
cluster_color = "#F59E0B" # Amber for cluster
|
||||
for member in cluster_members[:20]:
|
||||
if member.lower() == center_address.lower():
|
||||
continue
|
||||
member_id = f"{chain}:{member.lower()}"
|
||||
if member_id not in nodes:
|
||||
nodes[member_id] = GraphNode(
|
||||
id=member_id,
|
||||
label=_shorten_addr(member),
|
||||
node_type="cluster_member",
|
||||
color=cluster_color,
|
||||
size=16,
|
||||
)
|
||||
edges.append(
|
||||
GraphEdge(
|
||||
source=center_id,
|
||||
target=member_id,
|
||||
edge_type="cluster",
|
||||
color=cluster_color,
|
||||
width=1.5,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Add label-based nodes (CEX, scams, etc.) ──
|
||||
if labels:
|
||||
label_data = labels.get(center_address, labels)
|
||||
if isinstance(label_data, dict):
|
||||
label_type = label_data.get("label_type") or label_data.get("type", "")
|
||||
entity = label_data.get("entity") or label_data.get("name", "")
|
||||
|
||||
if label_type in ("cex", "exchange"):
|
||||
nodes[center_id].node_type = "cex"
|
||||
nodes[center_id].color = "#F59E0B" # Amber
|
||||
nodes[center_id].label = entity or "Exchange"
|
||||
elif label_type in ("scam", "phish", "hack"):
|
||||
nodes[center_id].node_type = "scam"
|
||||
nodes[center_id].color = "#EF4444" # Red
|
||||
nodes[center_id].risk_score = 90
|
||||
nodes[center_id].label = entity or "Scam"
|
||||
elif label_type in ("mixer", "tumbler"):
|
||||
nodes[center_id].node_type = "mixer"
|
||||
nodes[center_id].color = "#8B5CF6" # Purple
|
||||
nodes[center_id].risk_score = 70
|
||||
|
||||
# ── Risk-based coloring ──
|
||||
for node in nodes.values():
|
||||
if node.risk_score >= 80:
|
||||
node.color = "#EF4444" # Red
|
||||
elif node.risk_score >= 60:
|
||||
node.color = "#F97316" # Orange
|
||||
elif node.risk_score >= 40:
|
||||
node.color = "#EAB308" # Yellow
|
||||
|
||||
# ── Convert to graph format ──
|
||||
return {
|
||||
"center": {"id": center_id, "address": center_address, "chain": chain},
|
||||
"nodes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"label": n.label,
|
||||
"type": n.node_type,
|
||||
"chain": n.chain,
|
||||
"risk_score": n.risk_score,
|
||||
"size": n.size,
|
||||
"color": n.color,
|
||||
"metadata": n.metadata,
|
||||
}
|
||||
for n in nodes.values()
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source": e.source,
|
||||
"target": e.target,
|
||||
"type": e.edge_type,
|
||||
"value_usd": e.value_usd,
|
||||
"tx_count": e.tx_count,
|
||||
"color": e.color,
|
||||
"width": e.width,
|
||||
}
|
||||
for e in edges
|
||||
],
|
||||
"stats": {
|
||||
"total_nodes": len(nodes),
|
||||
"total_edges": len(edges),
|
||||
"total_value_usd": sum(e.value_usd for e in edges),
|
||||
"entity_types": list({n.node_type for n in nodes.values()}),
|
||||
"risk_distribution": {
|
||||
"high": sum(1 for n in nodes.values() if n.risk_score >= 60),
|
||||
"medium": sum(1 for n in nodes.values() if 30 <= n.risk_score < 60),
|
||||
"low": sum(1 for n in nodes.values() if n.risk_score < 30),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_svg(graph_data: dict[str, Any], width: int = 800, height: int = 600) -> str:
|
||||
"""Generate an SVG visualization of the entity graph.
|
||||
|
||||
Uses a simple force-directed layout algorithm.
|
||||
"""
|
||||
import math
|
||||
|
||||
nodes = graph_data.get("nodes", [])
|
||||
edges = graph_data.get("edges", [])
|
||||
center = graph_data.get("center", {})
|
||||
center_id = center.get("id", "")
|
||||
|
||||
if not nodes:
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200"><text x="400" y="100" text-anchor="middle" fill="#6B7280">No graph data</text></svg>'
|
||||
|
||||
# Simple radial layout: center in middle, others in concentric circles
|
||||
cx, cy = width // 2, height // 2
|
||||
|
||||
# Separate center from others
|
||||
other_nodes = [n for n in nodes if n["id"] != center_id]
|
||||
center_node = next((n for n in nodes if n["id"] == center_id), None)
|
||||
|
||||
# Arrange other nodes in circles
|
||||
positions = {}
|
||||
if center_node:
|
||||
positions[center_id] = (cx, cy)
|
||||
|
||||
rings = [
|
||||
(150, 0.0), # radius, rotation offset
|
||||
(250, 0.3),
|
||||
(350, 0.15),
|
||||
]
|
||||
|
||||
node_idx = 0
|
||||
for ring_radius, rotation in rings:
|
||||
nodes_in_ring = other_nodes[node_idx : node_idx + max(8, len(other_nodes) // len(rings))]
|
||||
n = len(nodes_in_ring)
|
||||
for i, node in enumerate(nodes_in_ring):
|
||||
angle = (2 * math.pi * i / max(n, 1)) + rotation
|
||||
x = cx + ring_radius * math.cos(angle)
|
||||
y = cy + ring_radius * math.sin(angle)
|
||||
positions[node["id"]] = (x, y)
|
||||
node_idx += n
|
||||
if node_idx >= len(other_nodes):
|
||||
break
|
||||
|
||||
# Build SVG
|
||||
svg_parts = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" style="background:#07070b;font-family:monospace">',
|
||||
"<defs>",
|
||||
'<filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>',
|
||||
'<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#EF4444"/></marker>',
|
||||
'<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#22C55E"/></marker>',
|
||||
"</defs>",
|
||||
# Background grid
|
||||
'<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1a2332" stroke-width="0.5"/></pattern>',
|
||||
f'<rect width="{width}" height="{height}" fill="url(#grid)"/>',
|
||||
]
|
||||
|
||||
# Draw edges
|
||||
for edge in edges:
|
||||
src_pos = positions.get(edge["source"])
|
||||
tgt_pos = positions.get(edge["target"])
|
||||
if not src_pos or not tgt_pos:
|
||||
continue
|
||||
|
||||
x1, y1 = src_pos
|
||||
x2, y2 = tgt_pos
|
||||
|
||||
# Curve edges slightly
|
||||
mid_x = (x1 + x2) / 2
|
||||
mid_y = (y1 + y2) / 2 - 30
|
||||
|
||||
edge_color = edge.get("color", "#4B5563")
|
||||
edge_width = edge.get("width", 1)
|
||||
marker_attr = 'marker-end="url(#arrow-red)"' if edge["type"] == "transfer" else ""
|
||||
|
||||
svg_parts.append(
|
||||
f'<path d="M{x1},{y1} Q{mid_x},{mid_y} {x2},{y2}" '
|
||||
f'stroke="{edge_color}" stroke-width="{edge_width}" fill="none" '
|
||||
f'opacity="0.4" {marker_attr}/>'
|
||||
)
|
||||
|
||||
# Draw nodes
|
||||
for node in nodes:
|
||||
pos = positions.get(node["id"])
|
||||
if not pos:
|
||||
continue
|
||||
|
||||
x, y = pos
|
||||
node_color = node.get("color", "#8B5CF6")
|
||||
node_size = node.get("size", 20)
|
||||
node_type = node.get("type", "wallet")
|
||||
|
||||
# Different shapes by type
|
||||
if node_type == "scam" or node.get("risk_score", 0) >= 80:
|
||||
# Diamond for high-risk
|
||||
svg_parts.append(
|
||||
f'<polygon points="{x},{y - node_size} {x + node_size},{y} {x},{y + node_size} {x - node_size},{y}" '
|
||||
f'fill="{node_color}" fill-opacity="0.2" stroke="{node_color}" stroke-width="2" filter="url(#glow)"/>'
|
||||
)
|
||||
elif node_type == "cex" or node_type == "exchange":
|
||||
# Square for exchanges
|
||||
svg_parts.append(
|
||||
f'<rect x="{x - node_size}" y="{y - node_size}" width="{node_size * 2}" height="{node_size * 2}" '
|
||||
f'rx="4" fill="{node_color}" fill-opacity="0.2" stroke="{node_color}" stroke-width="2"/>'
|
||||
)
|
||||
else:
|
||||
# Circle for wallets
|
||||
svg_parts.append(
|
||||
f'<circle cx="{x}" cy="{y}" r="{node_size}" '
|
||||
f'fill="{node_color}" fill-opacity="0.2" stroke="{node_color}" stroke-width="2"/>'
|
||||
)
|
||||
|
||||
# Label
|
||||
label = node.get("label", "")[:12]
|
||||
svg_parts.append(
|
||||
f'<text x="{x}" y="{y + node_size + 14}" text-anchor="middle" fill="#9CA3AF" font-size="10">{label}</text>'
|
||||
)
|
||||
|
||||
# Center node highlight
|
||||
if node["id"] == center_id:
|
||||
svg_parts.append(
|
||||
f'<circle cx="{x}" cy="{y}" r="{node_size + 4}" '
|
||||
f'fill="none" stroke="#22D3EE" stroke-width="2" stroke-dasharray="4,2" opacity="0.5"/>'
|
||||
)
|
||||
|
||||
# Legend
|
||||
legend_x = 20
|
||||
legend_y = height - 80
|
||||
legend_items = [
|
||||
("#EF4444", "High Risk/Scam"),
|
||||
("#F97316", "Medium Risk"),
|
||||
("#22C55E", "Incoming"),
|
||||
("#EF4444", "Outgoing"),
|
||||
("#F59E0B", "Exchange/CEX"),
|
||||
("#8B5CF6", "Wallet"),
|
||||
]
|
||||
for i, (color, label) in enumerate(legend_items):
|
||||
lx = legend_x + (i % 3) * 130
|
||||
ly = legend_y + (i // 3) * 20
|
||||
svg_parts.append(f'<circle cx="{lx}" cy="{ly}" r="5" fill="{color}"/>')
|
||||
svg_parts.append(f'<text x="{lx + 12}" y="{ly + 4}" fill="#6B7280" font-size="10">{label}</text>')
|
||||
|
||||
svg_parts.append("</svg>")
|
||||
return "\n".join(svg_parts)
|
||||
|
||||
|
||||
def _classify_node(address: str, labels: dict[str, Any], tx: dict) -> tuple[str, str]:
|
||||
"""Classify a counterparty node by type and color."""
|
||||
addr_lower = address.lower()
|
||||
|
||||
# Check labels
|
||||
label_info = labels.get(address, labels.get(addr_lower, {}))
|
||||
if isinstance(label_info, dict):
|
||||
label_type = label_info.get("label_type", label_info.get("type", ""))
|
||||
if label_type in ("cex", "exchange"):
|
||||
return ("cex", "#F59E0B")
|
||||
if label_type in ("scam", "phish", "hack", "exploit"):
|
||||
return ("scam", "#EF4444")
|
||||
if label_type in ("mixer", "tumbler", "tornado"):
|
||||
return ("mixer", "#8B5CF6")
|
||||
if label_type in ("defi", "protocol"):
|
||||
return ("protocol", "#06B6D4")
|
||||
|
||||
# Check transaction patterns
|
||||
tx_type = tx.get("type", "")
|
||||
if tx_type == "contract_creation" or tx_type == "deploy":
|
||||
return ("contract", "#10B981")
|
||||
if tx_type == "swap":
|
||||
return ("dex", "#06B6D4")
|
||||
|
||||
return ("wallet", "#8B5CF6")
|
||||
|
||||
|
||||
def _shorten_addr(addr: str) -> str:
|
||||
"""Shorten address for display."""
|
||||
if not addr:
|
||||
return "?"
|
||||
if len(addr) <= 12:
|
||||
return addr
|
||||
return f"{addr[:6]}...{addr[-4:]}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue