From 8854cfe5b1a8c15219d4d7e3503dbff6251cf0a6 Mon Sep 17 00:00:00 2001 From: rindicomfort Date: Tue, 23 Jun 2026 21:07:52 +0100 Subject: [PATCH] feat: implement liquid staking delegation flow graph visualization (#62) - Add DelegationNode and DelegationEdge types - Implement liquidStakingService with fallback API calls and live WS updates simulation - Implement Sugiyama layered layout with barycenter heuristic cross-edge minimization and label padding - Add WebGL Three.js-based DelegationFlowGraph with tube-rendered edges and animated flow particles - Add DelegationDetailPanel slide-out with historical cumulative stake SVG area chart - Add tabbed StakingDashboard page and routing integration Closes #62 --- app/staking/page.tsx | 7 + src/components/canvas/DelegationFlowGraph.tsx | 722 ++++++++++++++++++ .../validators/DelegationDetailPanel.tsx | 300 ++++++++ src/hooks/useDelegationFlow.ts | 218 ++++++ src/pages/staking/StakingDashboard.tsx | 190 +++++ src/services/liquidStakingService.ts | 320 ++++++++ src/types/delegation.ts | 34 + src/utils/delegationGraphLayout.ts | 158 ++++ 8 files changed, 1949 insertions(+) create mode 100644 app/staking/page.tsx create mode 100644 src/components/canvas/DelegationFlowGraph.tsx create mode 100644 src/components/validators/DelegationDetailPanel.tsx create mode 100644 src/hooks/useDelegationFlow.ts create mode 100644 src/pages/staking/StakingDashboard.tsx create mode 100644 src/services/liquidStakingService.ts create mode 100644 src/types/delegation.ts create mode 100644 src/utils/delegationGraphLayout.ts diff --git a/app/staking/page.tsx b/app/staking/page.tsx new file mode 100644 index 0000000..9a2d7b1 --- /dev/null +++ b/app/staking/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import StakingDashboard from '@/src/pages/staking/StakingDashboard'; + +export default function StakingPage() { + return ; +} diff --git a/src/components/canvas/DelegationFlowGraph.tsx b/src/components/canvas/DelegationFlowGraph.tsx new file mode 100644 index 0000000..3737f7b --- /dev/null +++ b/src/components/canvas/DelegationFlowGraph.tsx @@ -0,0 +1,722 @@ +'use client'; + +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import * as THREE from 'three'; +import { CubicBezierCurve3, CatmullRomCurve3 } from 'three'; +import { useDelegationFlow } from '@/src/hooks/useDelegationFlow'; +import { layoutDelegationGraph } from '@/src/utils/delegationGraphLayout'; +import { DelegationNode, DelegationEdge, ValidatorStatus } from '@/src/types/delegation'; +import DelegationDetailPanel from '@/src/components/validators/DelegationDetailPanel'; + +interface TooltipState { + visible: boolean; + x: number; + y: number; + title: string; + type: string; + details: string[]; +} + +export default function DelegationFlowGraph() { + const { + nodes, + edges, + allNodes, + loading, + error, + filters, + timeBounds, + selectedNodeId, + setProtocolFilter, + setMinAmountFilter, + setTimeRangeFilter, + setValidatorStatusFilter, + setSelectedNodeId + } = useDelegationFlow(); + + const containerRef = useRef(null); + const canvasRef = useRef(null); + + // UI state + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + title: '', + type: '', + details: [] + }); + + const [wsPulse, setWsPulse] = useState(false); + + // References for the Three.js scene + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const rendererRef = useRef(null); + const animationFrameRef = useRef(null); + + // Track coordinates and physics of nodes in memory + const nodePhysicsRef = useRef>(new Map()); + + // Interactivity refs + const mouseRef = useRef(new THREE.Vector2()); + const draggedNodeIdRef = useRef(null); + const isMouseDownRef = useRef(false); + + // Store meshes to update them in the animation loop + const nodeMeshesRef = useRef>(new Map()); + const edgeMeshesRef = useRef>(new Map()); + const particlesRef = useRef<{ mesh: THREE.Mesh; curve: CubicBezierCurve3; t: number; speed: number }[]>([]); + + // Trigger pulse effect when edges length changes (new real-time event) + useEffect(() => { + if (edges.length > 0) { + setWsPulse(true); + const timer = setTimeout(() => setWsPulse(false), 800); + return () => clearTimeout(timer); + } + }, [edges.length]); + + // Unique protocols list for dropdown + const protocolsList = useMemo(() => { + return allNodes.filter((n) => n.type === 'protocol'); + }, [allNodes]); + + // Map to find Y coordinate for node types + const getLayerY = (type: string, height: number) => { + const margin = 80; + if (type === 'delegator') return margin; + if (type === 'protocol') return height / 2; + return height - margin; + }; + + // Helper: compute connected stake for a node + const getNodeStake = (nodeId: string, nodeType: string) => { + const connectedEdges = edges.filter((e) => e.source === nodeId || e.target === nodeId); + if (nodeType === 'delegator') { + return connectedEdges.reduce((sum, e) => sum + (e.type === 'deposit' ? e.amount : 0), 0); + } else if (nodeType === 'protocol') { + return connectedEdges.reduce((sum, e) => sum + (e.type === 'delegate' ? e.amount : 0), 0); + } else { + return connectedEdges.reduce((sum, e) => sum + (e.type === 'delegate' ? e.amount : 0), 0); + } + }; + + // Setup Three.js Scene + useEffect(() => { + if (!canvasRef.current || !containerRef.current) return; + + const width = containerRef.current.clientWidth || 800; + const height = containerRef.current.clientHeight || 500; + + // 1. Create Scene & Camera + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0b1329); // Rich deep dark blue + sceneRef.current = scene; + + // Orthographic camera mapping screen pixels directly + const camera = new THREE.OrthographicCamera(0, width, 0, height, 1, 1000); + camera.position.z = 100; + cameraRef.current = camera; + + // 2. Create Renderer + const renderer = new THREE.WebGLRenderer({ + canvas: canvasRef.current, + antialias: true, + alpha: false + }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + rendererRef.current = renderer; + + // 3. Add ambient lighting for 3D look + const ambientLight = new THREE.AmbientLight(0xffffff, 0.8); + scene.add(ambientLight); + + const dirLight = new THREE.DirectionalLight(0xffffff, 0.6); + dirLight.position.set(0, 0, 100); + scene.add(dirLight); + + // Resize handler + const resizeObserver = new ResizeObserver((entries) => { + if (!entries || entries.length === 0) return; + const { width: newW, height: newH } = entries[0].contentRect; + if (newW === 0 || newH === 0) return; + + renderer.setSize(newW, newH); + if (cameraRef.current) { + cameraRef.current.right = newW; + cameraRef.current.bottom = newH; + cameraRef.current.updateProjectionMatrix(); + } + }); + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + renderer.dispose(); + }; + }, []); + + // Update layout and reconstruct meshes when nodes/edges/size changes + useEffect(() => { + const scene = sceneRef.current; + const renderer = rendererRef.current; + if (!scene || !renderer || !containerRef.current) return; + + const width = containerRef.current.clientWidth || 800; + const height = containerRef.current.clientHeight || 500; + + // 1. Calculate new layout target coordinates + const options = { + width, + height, + nodePaddingFactor: 8, + sideMargin: 80, + topBottomMargin: 60, + iterations: 4 + }; + const layouted = layoutDelegationGraph(nodes, edges, options); + + // 2. Update physics/position map + const newPhysics = new Map(); + layouted.forEach((node) => { + const stake = getNodeStake(node.id, node.type); + const radius = 10 + Math.log10(stake + 1) * 6; // Radius proportional to stake + + let color = 0x3b82f6; // delegator: blue + if (node.type === 'protocol') color = 0xa855f7; // protocol: purple + else if (node.type === 'validator') { + if (node.metadata.status === 'exiting') color = 0xf97316; // orange + else if (node.metadata.status === 'slashed') color = 0xef4444; // red + else color = 0x22c55e; // active validator: green + } + + // Preserve previous positions if node already existed to animate transitions smoothly + const prev = nodePhysicsRef.current.get(node.id); + newPhysics.set(node.id, { + id: node.id, + x: prev ? prev.x : (node.x ?? width / 2), + y: prev ? prev.y : (node.y ?? getLayerY(node.type, height)), + targetX: node.x ?? width / 2, + targetY: node.y ?? getLayerY(node.type, height), + vx: 0, + vy: 0, + radius, + type: node.type, + label: node.label, + color + }); + }); + nodePhysicsRef.current = newPhysics; + + // 3. Clear previous meshes from scene + nodeMeshesRef.current.forEach((m) => scene.remove(m)); + nodeMeshesRef.current.clear(); + + edgeMeshesRef.current.forEach((item) => scene.remove(item.mesh)); + edgeMeshesRef.current.clear(); + + particlesRef.current.forEach((p) => scene.remove(p.mesh)); + particlesRef.current.length = 0; + + // 4. Create Node Meshes + nodePhysicsRef.current.forEach((nodePhys) => { + // Circle mesh for node + const geometry = new THREE.CircleGeometry(nodePhys.radius, 32); + const material = new THREE.MeshBasicMaterial({ + color: nodePhys.color, + side: THREE.DoubleSide + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.position.set(nodePhys.x, nodePhys.y, 1); + mesh.userData = { id: nodePhys.id }; + scene.add(mesh); + nodeMeshesRef.current.set(nodePhys.id, mesh); + + // Slashed tag + if (nodePhys.type === 'validator' && nodePhys.id.includes('slashed')) { + const borderGeo = new THREE.RingGeometry(nodePhys.radius + 1, nodePhys.radius + 3, 32); + const borderMat = new THREE.MeshBasicMaterial({ color: 0xef4444, side: THREE.DoubleSide }); + const borderMesh = new THREE.Mesh(borderGeo, borderMat); + mesh.add(borderMesh); + } + }); + + // 5. Create Edges (Tubes along Bezier curves) + edges.forEach((edge) => { + const sourcePhys = nodePhysicsRef.current.get(edge.source); + const targetPhys = nodePhysicsRef.current.get(edge.target); + if (!sourcePhys || !targetPhys) return; + + // Create initial Bezier Curve + const start = new THREE.Vector3(sourcePhys.x, sourcePhys.y, 0); + const end = new THREE.Vector3(targetPhys.x, targetPhys.y, 0); + const midY = start.y + (end.y - start.y) * 0.5; + const cp1 = new THREE.Vector3(start.x, midY, 0); + const cp2 = new THREE.Vector3(end.x, midY, 0); + const curve = new CubicBezierCurve3(start, cp1, cp2, end); + + // Width proportional to amount (log scale: width = 2 + 8 * log10(amount_eth)) + const widthPx = Math.max(2, Math.min(50, 2 + 8 * Math.log10(edge.amount))); + const tubeRadius = widthPx / 2; + + const pathPoints = curve.getPoints(20); + const tubeGeo = new THREE.TubeGeometry( + new CatmullRomCurve3(pathPoints), + 20, + tubeRadius, + 6, + false + ); + + let edgeColor = 0x3b82f6; // blue + if (edge.type === 'delegate') edgeColor = 0xa855f7; // purple + else if (edge.type === 'rewards') edgeColor = 0x22c55e; // green + else if (edge.type === 'distributions') edgeColor = 0xf43f5e; // rose + + const tubeMat = new THREE.MeshBasicMaterial({ + color: edgeColor, + transparent: true, + opacity: 0.25, + depthWrite: false + }); + const tubeMesh = new THREE.Mesh(tubeGeo, tubeMat); + scene.add(tubeMesh); + + edgeMeshesRef.current.set(`${edge.source}->${edge.target}-${edge.type}`, { + mesh: tubeMesh, + curve, + amount: edge.amount, + type: edge.type + }); + + // 6. Create particles along the curve (for flow direction) + const numParticles = Math.min(3, Math.max(1, Math.floor(Math.log10(edge.amount + 1)))); + for (let p = 0; p < numParticles; p++) { + const particleGeo = new THREE.SphereGeometry(Math.max(2.5, tubeRadius * 0.8), 8, 8); + const particleMat = new THREE.MeshBasicMaterial({ + color: 0xffffff, // glowing white particle + }); + const particleMesh = new THREE.Mesh(particleGeo, particleMat); + particleMesh.position.copy(curve.getPointAt(0)); + scene.add(particleMesh); + + particlesRef.current.push({ + mesh: particleMesh, + curve, + t: p * (1.0 / numParticles), // space out particles along path + speed: 0.15 + Math.random() * 0.1 + }); + } + }); + + }, [nodes, edges]); + + // Main animation/render loop + useEffect(() => { + let lastTime = performance.now(); + + const animate = () => { + const scene = sceneRef.current; + const camera = cameraRef.current; + const renderer = rendererRef.current; + if (!scene || !camera || !renderer) return; + + const now = performance.now(); + const delta = (now - lastTime) / 1000; + lastTime = now; + + const physics = nodePhysicsRef.current; + const meshes = nodeMeshesRef.current; + + // 1. Physics update: spring node positions towards their target layout coordinates + physics.forEach((node) => { + if (draggedNodeIdRef.current === node.id) { + // Node follows the mouse (dragged) + // Limit drag Y slightly based on layer coordinate for the elastic effect + const layerY = getLayerY(node.type, renderer.domElement.clientHeight); + node.y = layerY + (mouseRef.current.y - layerY) * 0.25; // elastic buffer + node.x = mouseRef.current.x; + } else { + // Spring forces pulling node back to target layout positions + const dx = node.targetX - node.x; + const dy = node.targetY - node.y; + + // Simple spring formula + const k = 10.0; // spring constant + const ax = dx * k; + const ay = dy * k; + + node.vx += ax * delta; + node.vy += ay * delta; + + // Damping + node.vx *= 0.75; + node.vy *= 0.75; + + node.x += node.vx; + node.y += node.vy; + } + + // Update corresponding mesh position + const mesh = meshes.get(node.id); + if (mesh) { + mesh.position.set(node.x, node.y, 1); + } + }); + + // 2. Re-route curves for moving/dragged nodes + edgeMeshesRef.current.forEach((edgeItem, key) => { + const [srcTarget] = key.split('-'); + const [sourceId, targetId] = srcTarget.split('->'); + + const sourcePhys = physics.get(sourceId); + const targetPhys = physics.get(targetId); + + if (sourcePhys && targetPhys) { + // Recalculate curve points + const start = new THREE.Vector3(sourcePhys.x, sourcePhys.y, 0); + const end = new THREE.Vector3(targetPhys.x, targetPhys.y, 0); + const midY = start.y + (end.y - start.y) * 0.5; + + edgeItem.curve.v0.copy(start); + edgeItem.curve.v1.set(start.x, midY, 0); + edgeItem.curve.v2.set(end.x, midY, 0); + edgeItem.curve.v3.copy(end); + + // Rebuild tube geometry dynamically if it's moving + if (draggedNodeIdRef.current === sourceId || draggedNodeIdRef.current === targetId || Math.abs(sourcePhys.vx) > 0.05 || Math.abs(targetPhys.vx) > 0.05) { + const widthPx = Math.max(2, Math.min(50, 2 + 8 * Math.log10(edgeItem.amount))); + const tubeRadius = widthPx / 2; + const pathPoints = edgeItem.curve.getPoints(12); + + edgeItem.mesh.geometry.dispose(); + edgeItem.mesh.geometry = new THREE.TubeGeometry( + new CatmullRomCurve3(pathPoints), + 12, + tubeRadius, + 4, + false + ); + } + } + }); + + // 3. Animate particles along the curves + particlesRef.current.forEach((particle) => { + particle.t += particle.speed * delta; + if (particle.t > 1.0) particle.t = 0; + const pos = particle.curve.getPointAt(particle.t); + particle.mesh.position.copy(pos); + }); + + // 4. Render + renderer.render(scene, camera); + + animationFrameRef.current = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + // Raycasting helper to detect clicked/hovered nodes + const raycast = (clientX: number, clientY: number): string | null => { + const canvas = canvasRef.current; + const camera = cameraRef.current; + if (!canvas || !camera) return null; + + const rect = canvas.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * 2 - 1; + const y = -((clientY - rect.top) / rect.height) * 2 + 1; + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(x, y), camera); + + const meshes = Array.from(nodeMeshesRef.current.values()); + const intersects = raycaster.intersectObjects(meshes); + + if (intersects.length > 0) { + return intersects[0].object.userData.id; + } + return null; + }; + + // Convert pixel coordinates to 3D/Screen space coordinates for dragged nodes + const updateMouseCoordinates = (clientX: number, clientY: number) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + mouseRef.current.set(x, y); + }; + + // Mouse / Touch Handlers + const handleMouseDown = (e: React.MouseEvent) => { + isMouseDownRef.current = true; + updateMouseCoordinates(e.clientX, e.clientY); + + const hitId = raycast(e.clientX, e.clientY); + if (hitId) { + draggedNodeIdRef.current = hitId; + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + updateMouseCoordinates(e.clientX, e.clientY); + + // Show tooltip if hovering a node + const hoverId = raycast(e.clientX, e.clientY); + if (hoverId) { + const node = nodes.find((n) => n.id === hoverId); + if (node) { + const stake = getNodeStake(node.id, node.type); + const details = [ + `Total Stake: ${stake.toLocaleString()} ETH`, + ]; + if (node.type === 'validator') { + details.push(`Status: ${node.metadata.status?.toUpperCase()}`); + details.push(`APR: ${node.metadata.apr}%`); + } else if (node.type === 'protocol') { + details.push(`APR: ${node.metadata.apr}%`); + details.push(`Delegators: ${node.metadata.delegatorCount}`); + } + + const rect = canvasRef.current!.getBoundingClientRect(); + setTooltip({ + visible: true, + x: e.clientX - rect.left + 15, + y: e.clientY - rect.top + 15, + title: node.label, + type: node.type.toUpperCase(), + details + }); + document.body.style.cursor = 'pointer'; + return; + } + } + + if (tooltip.visible) { + setTooltip((t) => ({ ...t, visible: false })); + } + document.body.style.cursor = 'default'; + }; + + const handleMouseUp = (e: React.MouseEvent) => { + // If we just clicked a node without dragging it, open the details + if (draggedNodeIdRef.current) { + const nodePhys = nodePhysicsRef.current.get(draggedNodeIdRef.current); + if (nodePhys) { + const dist = Math.sqrt( + Math.pow(nodePhys.x - nodePhys.targetX, 2) + Math.pow(nodePhys.y - nodePhys.targetY, 2) + ); + // If dragged distance is very small, treat as click/drill-down + if (dist < 5) { + setSelectedNodeId(draggedNodeIdRef.current); + } + } + } + + draggedNodeIdRef.current = null; + isMouseDownRef.current = false; + }; + + const handleMouseLeave = () => { + draggedNodeIdRef.current = null; + isMouseDownRef.current = false; + setTooltip((t) => ({ ...t, visible: false })); + }; + + // Helper formatting for filters time range + const formatDate = (ts: number) => { + return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit' }); + }; + + return ( +
+ + {/* Dynamic Glassmorphism Header / Control Overlay */} +
+
+
+ + + +
+
+

Delegation Flow

+

Liquid Staking protocol relationships & flow visualizer

+
+
+ + {/* WebSocket live status indicator */} +
+ + Live WS Stream +
+
+ + {/* Main Canvas Area */} +
+ + + {/* Render interactive text labels over nodes using HTML overlays for sharp CSS rendering */} +
+ {Array.from(nodePhysicsRef.current.values()).map((nodePhys) => ( +
+ {nodePhys.label} +
+ ))} +
+ + {/* Hover Tooltip */} + {tooltip.visible && ( +
+
+ {tooltip.title} + {tooltip.type} +
+
+ {tooltip.details.map((d, i) => ( +
{d}
+ ))} +
+
+ )} +
+ + {/* Floating Filters Footer Panel */} +
+ {/* Protocol Filter */} +
+ + +
+ + {/* Min Amount Slider */} +
+
+ + {filters.minAmount.toFixed(1)} ETH +
+ setMinAmountFilter(Number(e.target.value))} + className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-slate-700 accent-purple-500" + /> +
+ + {/* Time Range Date Picker/Slider */} +
+
+ + {formatDate(filters.timeRange[0])} +
+ setTimeRangeFilter([Number(e.target.value), timeBounds[1]])} + className="h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-slate-700 accent-blue-500" + /> +
+ + {/* Validator Status Filter */} +
+ +
+ {(['all', 'active', 'exiting', 'slashed'] as const).map((status) => ( + + ))} +
+
+
+ + {/* Slide-out detail panel */} + {selectedNodeId && ( + setSelectedNodeId(null)} + /> + )} +
+ ); +} diff --git a/src/components/validators/DelegationDetailPanel.tsx b/src/components/validators/DelegationDetailPanel.tsx new file mode 100644 index 0000000..bddd65e --- /dev/null +++ b/src/components/validators/DelegationDetailPanel.tsx @@ -0,0 +1,300 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { DelegationNode, DelegationEdge } from '@/src/types/delegation'; + +interface DelegationDetailPanelProps { + nodeId: string | null; + nodes: DelegationNode[]; + edges: DelegationEdge[]; + onClose: () => void; +} + +export default function DelegationDetailPanel({ + nodeId, + nodes, + edges, + onClose +}: DelegationDetailPanelProps) { + const node = useMemo(() => { + if (!nodeId) return null; + return nodes.find((n) => n.id === nodeId) || null; + }, [nodeId, nodes]); + + // Calculate connected edges + const connectedEdges = useMemo(() => { + if (!nodeId) return []; + return edges.filter((e) => e.source === nodeId || e.target === nodeId); + }, [nodeId, edges]); + + // Compute key stats + const stats = useMemo(() => { + if (!node) return { totalStake: 0, delegatorCount: 0, apr: 0, avgSize: 0 }; + + const depositIn = connectedEdges.filter((e) => e.target === node.id && e.type === 'deposit'); + const delegateOut = connectedEdges.filter((e) => e.source === node.id && e.type === 'delegate'); + const delegateIn = connectedEdges.filter((e) => e.target === node.id && e.type === 'delegate'); + + let totalStake = 0; + let delegatorCount = 0; + const apr = node.metadata.apr || 0; + + if (node.type === 'delegator') { + totalStake = connectedEdges + .filter((e) => e.source === node.id && e.type === 'deposit') + .reduce((sum, e) => sum + e.amount, 0); + delegatorCount = 1; + } else if (node.type === 'protocol') { + totalStake = delegateOut.reduce((sum, e) => sum + e.amount, 0); + delegatorCount = depositIn.length; + } else { + // Validator + totalStake = delegateIn.reduce((sum, e) => sum + e.amount, 0); + delegatorCount = node.metadata.delegatorCount || depositIn.length; + } + + const avgSize = delegatorCount > 0 ? totalStake / delegatorCount : 0; + + return { + totalStake: Math.round(totalStake * 10) / 10, + delegatorCount, + apr, + avgSize: Math.round(avgSize * 10) / 10 + }; + }, [node, connectedEdges]); + + // Top delegation edges (largest amounts in/out) + const topEdges = useMemo(() => { + if (!nodeId) return []; + return [...connectedEdges] + .sort((a, b) => b.amount - a.amount) + .slice(0, 5) + .map((edge) => { + const isOutbound = edge.source === nodeId; + const counterpartId = isOutbound ? edge.target : edge.source; + const counterpartNode = nodes.find((n) => n.id === counterpartId); + return { + id: `${edge.source}-${edge.target}-${edge.type}-${edge.timestamp}`, + counterpartName: counterpartNode ? counterpartNode.label : counterpartId, + amount: edge.amount, + type: edge.type, + isOutbound + }; + }); + }, [nodeId, connectedEdges, nodes]); + + // Historical delegation chart coordinates (SVG Area Chart) + const chartData = useMemo(() => { + if (connectedEdges.length === 0) return null; + + // Filter deposit or delegate edges to plot stake increase history + const historyEdges = connectedEdges + .filter((e) => e.type === 'deposit' || e.type === 'delegate') + .sort((a, b) => a.timestamp - b.timestamp); + + if (historyEdges.length === 0) return null; + + let cumulative = 0; + const points = historyEdges.map((e) => { + cumulative += e.amount; + return { + timestamp: e.timestamp, + value: cumulative + }; + }); + + const timestamps = points.map((p) => p.timestamp); + const values = points.map((p) => p.value); + + const minX = Math.min(...timestamps); + const maxX = Math.max(...timestamps); + const minY = 0; + const maxY = Math.max(...values) * 1.1 || 10; + + // Map to SVG coordinates: viewBox 0 0 300 120 + const svgW = 300; + const svgH = 120; + const padding = 15; + + const mapX = (t: number) => { + if (maxX === minX) return svgW / 2; + return padding + ((t - minX) / (maxX - minX)) * (svgW - 2 * padding); + }; + + const mapY = (val: number) => { + return svgH - padding - (val / maxY) * (svgH - 2 * padding); + }; + + const svgPoints = points.map((p) => ({ + x: mapX(p.timestamp), + y: mapY(p.value), + date: new Date(p.timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), + val: Math.round(p.value) + })); + + // Area Path + let areaPath = ''; + let linePath = ''; + + if (svgPoints.length > 0) { + linePath = `M ${svgPoints[0].x} ${svgPoints[0].y}`; + svgPoints.slice(1).forEach((pt) => { + linePath += ` L ${pt.x} ${pt.y}`; + }); + + areaPath = `${linePath} L ${svgPoints[svgPoints.length - 1].x} ${svgH - padding} L ${svgPoints[0].x} ${svgH - padding} Z`; + } + + return { + points: svgPoints, + areaPath, + linePath, + maxY + }; + }, [connectedEdges]); + + if (!node) return null; + + return ( +
+ + {/* Panel Header */} +
+
+
+ + {node.type} + + {node.type === 'validator' && ( + + {node.metadata.status} + + )} +
+

{node.label}

+ {node.id} +
+ +
+ + {/* Metrics Grid */} +
+
+

Total Stake

+

{stats.totalStake.toLocaleString()} ETH

+
+
+

+ {node.type === 'delegator' ? 'Destinations' : node.type === 'protocol' ? 'Depositors' : 'Delegators'} +

+

{stats.delegatorCount.toLocaleString()}

+
+
+

Current APR

+

{stats.apr > 0 ? `${stats.apr}%` : 'N/A'}

+
+
+

Avg Delegation

+

{stats.avgSize.toLocaleString()} ETH

+
+
+ + {/* Area Chart Section */} +
+

Historical Stake (Cumulative)

+ {chartData && chartData.points.length > 0 ? ( +
+ + + + + + + + + {/* Gridlines */} + + + + + {/* Filled Area */} + + + {/* Line */} + + + {/* Data points */} + {chartData.points.map((pt, i) => ( + + + {/* Show dates/values at start and end points */} + {(i === 0 || i === chartData.points.length - 1) && ( + + {pt.val} ETH + + )} + + ))} + + {/* X Axis label line */} + + +
+ {chartData.points[0].date} + {chartData.points[chartData.points.length - 1].date} +
+
+ ) : ( +

No stake history available.

+ )} +
+ + {/* Top Connections List */} +
+

Top Connections

+ {topEdges.length > 0 ? ( +
+ {topEdges.map((edge) => ( +
+
+ {edge.counterpartName} + + {edge.isOutbound ? 'Outbound' : 'Inbound'} • {edge.type} + +
+
+ {edge.amount.toLocaleString()} ETH +
+
+ ))} +
+ ) : ( +

No connections found.

+ )} +
+ +
+ ); +} diff --git a/src/hooks/useDelegationFlow.ts b/src/hooks/useDelegationFlow.ts new file mode 100644 index 0000000..42e4386 --- /dev/null +++ b/src/hooks/useDelegationFlow.ts @@ -0,0 +1,218 @@ +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { DelegationNode, DelegationEdge, ValidatorStatus } from '@/src/types/delegation'; +import { liquidStakingService, WSEvent } from '@/src/services/liquidStakingService'; + +export interface UseDelegationFlowFilters { + selectedProtocol: string; // 'all' or protocol ID + minAmount: number; // minimum amount to display + timeRange: [number, number]; // [startTimestamp, endTimestamp] + validatorStatus: ValidatorStatus | 'all'; +} + +export function useDelegationFlow() { + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Time bounds of dataset + const [timeBounds, setTimeBounds] = useState<[number, number]>([ + Date.now() - 30 * 24 * 3600 * 1000, + Date.now() + ]); + + const [filters, setFilters] = useState({ + selectedProtocol: 'all', + minAmount: 0, + timeRange: [Date.now() - 30 * 24 * 3600 * 1000, Date.now()], + validatorStatus: 'all' + }); + + const [selectedNodeId, setSelectedNodeId] = useState(null); + + // Fetch initial graph data + useEffect(() => { + let active = true; + + async function loadData() { + try { + setLoading(true); + const data = await liquidStakingService.fetchAllGraphData(); + if (!active) return; + + setNodes(data.nodes); + setEdges(data.edges); + + // Compute initial time bounds from data + if (data.edges.length > 0) { + const timestamps = data.edges.map((e) => e.timestamp); + const minT = Math.min(...timestamps); + const maxT = Math.max(...timestamps); + setTimeBounds([minT, maxT]); + setFilters((f) => ({ + ...f, + timeRange: [minT, maxT] + })); + } + } catch (err) { + if (active) { + setError(err instanceof Error ? err.message : 'Failed to fetch delegation data'); + } + } finally { + if (active) setLoading(false); + } + } + + loadData(); + + return () => { + active = false; + }; + }, []); + + // Handle WebSocket / real-time updates incrementally + useEffect(() => { + if (loading) return; + + const unsubscribe = liquidStakingService.subscribeToUpdates((event: WSEvent) => { + if (event.type === 'new_delegation') { + // Incrementally add node if it doesn't exist + if (event.node) { + setNodes((prevNodes) => { + if (prevNodes.some((n) => n.id === event.node!.id)) return prevNodes; + return [...prevNodes, event.node!]; + }); + } + // Incrementally add edge + setEdges((prevEdges) => [...prevEdges, event.edge]); + + // Dynamically adjust time bounds + setTimeBounds((prev) => { + const newMax = Math.max(prev[1], event.edge.timestamp); + const newMin = Math.min(prev[0], event.edge.timestamp); + return [newMin, newMax]; + }); + + } else if (event.type === 'reward') { + // Incrementally add reward edge + setEdges((prevEdges) => [...prevEdges, event.edge]); + + // Dynamically adjust time bounds + setTimeBounds((prev) => { + const newMax = Math.max(prev[1], event.edge.timestamp); + const newMin = Math.min(prev[0], event.edge.timestamp); + return [newMin, newMax]; + }); + + } else if (event.type === 'unstake') { + // Decrease amount on the edge or remove it if 0 + setEdges((prevEdges) => { + return prevEdges + .map((edge) => { + if (edge.source === event.source && edge.target === event.target && edge.type === 'deposit') { + const nextAmount = Math.max(0, edge.amount - event.amount); + return { ...edge, amount: nextAmount }; + } + return edge; + }) + .filter((edge) => edge.amount > 0.01); + }); + } + }); + + return () => unsubscribe(); + }, [loading]); + + // Setters for filters + const setProtocolFilter = useCallback((protocolId: string) => { + setFilters((f) => ({ ...f, selectedProtocol: protocolId })); + }, []); + + const setMinAmountFilter = useCallback((amount: number) => { + setFilters((f) => ({ ...f, minAmount: amount })); + }, []); + + const setTimeRangeFilter = useCallback((range: [number, number]) => { + setFilters((f) => ({ ...f, timeRange: range })); + }, []); + + const setValidatorStatusFilter = useCallback((status: ValidatorStatus | 'all') => { + setFilters((f) => ({ ...f, validatorStatus: status })); + }, []); + + // Compute filtered edges and nodes + const filteredData = useMemo(() => { + // 1. Filter edges + const filteredEdges = edges.filter((edge) => { + // Filter by min amount + if (edge.amount < filters.minAmount) return false; + + // Filter by time range + if (edge.timestamp < filters.timeRange[0] || edge.timestamp > filters.timeRange[1]) return false; + + // Filter by selected protocol + if (filters.selectedProtocol !== 'all') { + // Edge must connect to the selected protocol + if (edge.source !== filters.selectedProtocol && edge.target !== filters.selectedProtocol) { + return false; + } + } + + return true; + }); + + // 2. Filter nodes + const activeNodeIds = new Set(); + filteredEdges.forEach((e) => { + activeNodeIds.add(e.source); + activeNodeIds.add(e.target); + }); + + const filteredNodes = nodes.filter((node) => { + // Always include selected protocol node + if (filters.selectedProtocol !== 'all' && node.id === filters.selectedProtocol) { + return true; + } + + // Filter validator by status + if (node.type === 'validator') { + if (filters.validatorStatus !== 'all' && node.metadata.status !== filters.validatorStatus) { + return false; + } + } + + // Remove orphaned nodes (except when we are not filtering, but usually we only want connected nodes in the graph view) + return activeNodeIds.has(node.id); + }); + + return { + nodes: filteredNodes, + edges: filteredEdges + }; + }, [nodes, edges, filters]); + + // Find selected node details + const selectedNode = useMemo(() => { + if (!selectedNodeId) return null; + return nodes.find((n) => n.id === selectedNodeId) || null; + }, [nodes, selectedNodeId]); + + return { + nodes: filteredData.nodes, + edges: filteredData.edges, + allNodes: nodes, // raw node list for filters + loading, + error, + filters, + timeBounds, + selectedNodeId, + selectedNode, + setProtocolFilter, + setMinAmountFilter, + setTimeRangeFilter, + setValidatorStatusFilter, + setSelectedNodeId + }; +} diff --git a/src/pages/staking/StakingDashboard.tsx b/src/pages/staking/StakingDashboard.tsx new file mode 100644 index 0000000..8842268 --- /dev/null +++ b/src/pages/staking/StakingDashboard.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import Head from 'next/head'; +import DelegationFlowGraph from '@/src/components/canvas/DelegationFlowGraph'; + +type TabType = 'overview' | 'flow' | 'validators'; + +export default function StakingDashboard() { + const [activeTab, setActiveTab] = useState('flow'); + + return ( + <> + + Staking Dashboard | VeriNode + + + +
+ {/* Top Navbar */} +
+
+
+
+ V +
+ + VeriNode Staking + +
+ + {/* Navigation Tabs */} + + + {/* Right-side Wallet Status placeholder */} +
+ + Soroban Connected +
+
+
+ + {/* Dashboard Content */} +
+ {activeTab === 'flow' && ( +
+ +
+ )} + + {activeTab === 'overview' && ( +
+ {/* Staking Summary Cards */} +
+
+ Total Value Locked +

270,701.5 ETH

+ ▲ +4.2% (24h) +
+
+ Average Staking APR +

3.93%

+ Net protocol average +
+
+ Active Validators +

3 / 5

+ 1 Exiting • 1 Slashed +
+
+ Connected Holders +

7,465

+ ▲ +12 today +
+
+ + {/* Protocol breakdown table */} +
+

Supported Liquid Staking Protocols

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProtocolTotal DepositedStaking APRDepositorsStatus
Lido Finance (stETH)154,200.5 ETH3.8%4,210 + Active +
Rocket Pool (rETH)82,400.2 ETH4.1%2,315 + Active +
Swell Network (swETH)34,100.8 ETH3.9%940 + Active +
+
+
+
+ )} + + {activeTab === 'validators' && ( +
+ {/* Validator Node Performance list */} +
+
+

Node Operators & Validators

+ Monitoring 5 nodes +
+
+ {[ + { name: 'P2P Validator', status: 'active', stake: '95,000 ETH', apr: '4.2%', color: 'border-emerald-500/20 text-emerald-400 bg-emerald-500/10' }, + { name: 'Chorus One', status: 'active', stake: '72,000 ETH', apr: '4.0%', color: 'border-emerald-500/20 text-emerald-400 bg-emerald-500/10' }, + { name: 'Figment', status: 'active', stake: '88,000 ETH', apr: '3.9%', color: 'border-emerald-500/20 text-emerald-400 bg-emerald-500/10' }, + { name: 'Sigma Operator', status: 'exiting', stake: '12,000 ETH', apr: '1.5%', color: 'border-orange-500/20 text-orange-400 bg-orange-500/10' }, + { name: 'Alpha Node', status: 'slashed', stake: '3,700 ETH', apr: '0.0%', color: 'border-red-500/20 text-red-400 bg-red-500/10' }, + ].map((val) => ( +
+
+
+ {val.name[0]} +
+
+

{val.name}

+ Managed stake: {val.stake} +
+
+ +
+
+ APR + {val.apr} +
+
+ Status + + {val.status} + +
+
+
+ ))} +
+
+
+ )} +
+
+ + ); +} diff --git a/src/services/liquidStakingService.ts b/src/services/liquidStakingService.ts new file mode 100644 index 0000000..03aaffd --- /dev/null +++ b/src/services/liquidStakingService.ts @@ -0,0 +1,320 @@ +import { DelegationNode, DelegationEdge } from '@/src/types/delegation'; + +// Mock data generator for fallback and demonstration +const MOCK_PROTOCOLS: DelegationNode[] = [ + { + id: 'proto-lido', + type: 'protocol', + label: 'Lido Finance (stETH)', + metadata: { apr: 3.8, delegatorCount: 4210, totalStake: 154200.5 } + }, + { + id: 'proto-rocketpool', + type: 'protocol', + label: 'Rocket Pool (rETH)', + metadata: { apr: 4.1, delegatorCount: 2315, totalStake: 82400.2 } + }, + { + id: 'proto-swell', + type: 'protocol', + label: 'Swell Network (swETH)', + metadata: { apr: 3.9, delegatorCount: 940, totalStake: 34100.8 } + } +]; + +const MOCK_VALIDATORS: DelegationNode[] = [ + { + id: 'val-p2p', + type: 'validator', + label: 'P2P Validator', + metadata: { status: 'active', apr: 4.2, delegatorCount: 1500, totalStake: 95000.0 } + }, + { + id: 'val-chorus', + type: 'validator', + label: 'Chorus One', + metadata: { status: 'active', apr: 4.0, delegatorCount: 1200, totalStake: 72000.0 } + }, + { + id: 'val-figment', + type: 'validator', + label: 'Figment', + metadata: { status: 'active', apr: 3.9, delegatorCount: 1800, totalStake: 88000.0 } + }, + { + id: 'val-exiting', + type: 'validator', + label: 'Sigma Operator', + metadata: { status: 'exiting', apr: 1.5, delegatorCount: 300, totalStake: 12000.0 } + }, + { + id: 'val-slashed', + type: 'validator', + label: 'Alpha Node (Slashed)', + metadata: { status: 'slashed', apr: 0.0, delegatorCount: 45, totalStake: 3700.0 } + } +]; + +// Initial mock delegators +const MOCK_DELEGATORS: DelegationNode[] = Array.from({ length: 15 }, (_, i) => ({ + id: `del-user-${i + 1}`, + type: 'delegator', + label: `Holder ${String(i + 1).padStart(3, '0')}`, + metadata: { totalStake: Math.round((Math.random() * 50 + 5) * 10) / 10 } +})); + +const generateInitialMockEdges = (): DelegationEdge[] => { + const edges: DelegationEdge[] = []; + const now = Date.now(); + + // 1. Delegators -> Protocols (deposits) + MOCK_DELEGATORS.forEach((del) => { + // Deposit into 1 or 2 protocols + const activeProtos = [...MOCK_PROTOCOLS]; + const numDeposits = Math.random() > 0.6 ? 2 : 1; + for (let d = 0; d < numDeposits; d++) { + const idx = Math.floor(Math.random() * activeProtos.length); + const proto = activeProtos.splice(idx, 1)[0]; + const amount = Math.round((del.metadata.totalStake! / numDeposits) * 100) / 100; + edges.push({ + source: del.id, + target: proto.id, + amount, + type: 'deposit', + timestamp: now - Math.floor(Math.random() * 30 * 24 * 3600 * 1000) + }); + } + }); + + // 2. Protocols -> Validators (delegate) + // Let Lido delegate to P2P, Chorus, Figment, and Exiting + edges.push({ source: 'proto-lido', target: 'val-p2p', amount: 65000, type: 'delegate', timestamp: now - 20 * 24 * 3600 * 1000 }); + edges.push({ source: 'proto-lido', target: 'val-chorus', amount: 45200, type: 'delegate', timestamp: now - 18 * 24 * 3600 * 1000 }); + edges.push({ source: 'proto-lido', target: 'val-figment', amount: 40000, type: 'delegate', timestamp: now - 15 * 24 * 3600 * 1000 }); + edges.push({ source: 'proto-lido', target: 'val-slashed', amount: 4000, type: 'delegate', timestamp: now - 25 * 24 * 3600 * 1000 }); + + // Rocket Pool delegates to Figment, Chorus, and exiting + edges.push({ source: 'proto-rocketpool', target: 'val-figment', amount: 48000, type: 'delegate', timestamp: now - 12 * 24 * 3600 * 1000 }); + edges.push({ source: 'proto-rocketpool', target: 'val-chorus', amount: 26800, type: 'delegate', timestamp: now - 10 * 24 * 3600 * 1000 }); + edges.push({ source: 'proto-rocketpool', target: 'val-exiting', amount: 7600, type: 'delegate', timestamp: now - 14 * 24 * 3600 * 1000 }); + + // Swell delegates to P2P and Chorus + edges.push({ source: 'proto-swell', target: 'val-p2p', amount: 30000, type: 'delegate', timestamp: now - 5 * 24 * 3600 * 1000 }); + edges.push({ source: 'proto-swell', target: 'val-chorus', amount: 4100, type: 'delegate', timestamp: now - 8 * 24 * 3600 * 1000 }); + + // 3. Validators -> Protocols (rewards) + MOCK_VALIDATORS.forEach((val) => { + // Find who delegates to this validator + if (val.metadata.status !== 'slashed') { + const parentEdges = edges.filter(e => e.target === val.id && e.type === 'delegate'); + parentEdges.forEach(pe => { + edges.push({ + source: val.id, + target: pe.source, + amount: Math.round(pe.amount * (val.metadata.apr! / 100) * 0.1 * 100) / 100, // mock rewards + type: 'rewards', + timestamp: now - Math.floor(Math.random() * 24 * 3600 * 1000) + }); + }); + } + }); + + // 4. Protocols -> Delegators (distributions) + MOCK_DELEGATORS.forEach((del) => { + const parentEdges = edges.filter(e => e.source === del.id && e.type === 'deposit'); + parentEdges.forEach(pe => { + const proto = MOCK_PROTOCOLS.find(p => p.id === pe.target); + if (proto) { + edges.push({ + source: pe.target, + target: del.id, + amount: Math.round(pe.amount * (proto.metadata.apr! / 100) * 0.1 * 100) / 100, + type: 'distributions', + timestamp: now - Math.floor(Math.random() * 24 * 3600 * 1000) + }); + } + }); + }); + + return edges; +}; + +let activeMockEdges = generateInitialMockEdges(); +let activeMockDelegators = [...MOCK_DELEGATORS]; + +export interface GraphData { + nodes: DelegationNode[]; + edges: DelegationEdge[]; +} + +export type WSEvent = + | { type: 'new_delegation'; edge: DelegationEdge; node?: DelegationNode } + | { type: 'unstake'; source: string; target: string; amount: number } + | { type: 'reward'; edge: DelegationEdge }; + +class LiquidStakingService { + private wsSubscribers: Set<(event: WSEvent) => void> = new Set(); + private wsInterval: NodeJS.Timeout | null = null; + + async fetchProtocols(): Promise { + try { + const res = await fetch('/protocols'); + if (res.ok) return await res.json(); + } catch (e) { + console.warn('Failed to fetch protocols from backend, using mock data.', e); + } + return MOCK_PROTOCOLS; + } + + async fetchValidators(): Promise { + try { + const res = await fetch('/validators/stake'); + if (res.ok) return await res.json(); + } catch (e) { + console.warn('Failed to fetch validators from backend, using mock data.', e); + } + return MOCK_VALIDATORS; + } + + async fetchDelegations(): Promise { + try { + const res = await fetch('/delegations'); + if (res.ok) return await res.json(); + } catch (e) { + console.warn('Failed to fetch delegations from backend, using mock data.', e); + } + return activeMockEdges; + } + + async fetchAllGraphData(): Promise { + const [protocols, validators, edges] = await Promise.all([ + this.fetchProtocols(), + this.fetchValidators(), + this.fetchDelegations() + ]); + + // Gather all unique delegator nodes connected by edges + const delegatorIds = new Set(); + edges.forEach((edge) => { + if (edge.source.startsWith('del-')) delegatorIds.add(edge.source); + if (edge.target.startsWith('del-')) delegatorIds.add(edge.target); + }); + + const delegatorNodes = activeMockDelegators.filter((d) => delegatorIds.has(d.id)); + + return { + nodes: [...delegatorNodes, ...protocols, ...validators], + edges + }; + } + + subscribeToUpdates(onEvent: (event: WSEvent) => void): () => void { + this.wsSubscribers.add(onEvent); + + // Start simulation if not already running + if (!this.wsInterval) { + this.startWsSimulation(); + } + + return () => { + this.wsSubscribers.delete(onEvent); + if (this.wsSubscribers.size === 0 && this.wsInterval) { + clearInterval(this.wsInterval); + this.wsInterval = null; + } + }; + } + + private startWsSimulation() { + this.wsInterval = setInterval(() => { + if (this.wsSubscribers.size === 0) return; + + const rand = Math.random(); + const now = Date.now(); + + if (rand < 0.4) { + // 1. Generate new delegation (depositing) + const newDelId = `del-user-${activeMockDelegators.length + 1}`; + const amount = Math.round((Math.random() * 40 + 5) * 10) / 10; + const newDelegator: DelegationNode = { + id: newDelId, + type: 'delegator', + label: `Holder ${String(activeMockDelegators.length + 1).padStart(3, '0')}`, + metadata: { totalStake: amount } + }; + activeMockDelegators.push(newDelegator); + + const targetProto = MOCK_PROTOCOLS[Math.floor(Math.random() * MOCK_PROTOCOLS.length)]; + const edge: DelegationEdge = { + source: newDelId, + target: targetProto.id, + amount, + type: 'deposit', + timestamp: now + }; + activeMockEdges.push(edge); + + const event: WSEvent = { + type: 'new_delegation', + edge, + node: newDelegator + }; + this.notifySubscribers(event); + + } else if (rand < 0.7) { + // 2. Generate reward distribution (Validator -> Protocol) + const validator = MOCK_VALIDATORS[Math.floor(Math.random() * MOCK_VALIDATORS.length)]; + if (validator.metadata.status !== 'slashed') { + // Find matching protocol + const targetProto = MOCK_PROTOCOLS[Math.floor(Math.random() * MOCK_PROTOCOLS.length)]; + const rewardAmount = Math.round((Math.random() * 5 + 0.1) * 100) / 100; + const edge: DelegationEdge = { + source: validator.id, + target: targetProto.id, + amount: rewardAmount, + type: 'rewards', + timestamp: now + }; + activeMockEdges.push(edge); + + const event: WSEvent = { + type: 'reward', + edge + }; + this.notifySubscribers(event); + } + } else { + // 3. Unstake event (decrease delegation) + // Find a deposit edge + const depositEdges = activeMockEdges.filter(e => e.type === 'deposit'); + if (depositEdges.length > 0) { + const edgeToUnstake = depositEdges[Math.floor(Math.random() * depositEdges.length)]; + const unstakeAmount = Math.round((edgeToUnstake.amount * 0.25) * 100) / 100; // Unstake 25% + + if (unstakeAmount > 0.1) { + edgeToUnstake.amount -= unstakeAmount; + const event: WSEvent = { + type: 'unstake', + source: edgeToUnstake.source, + target: edgeToUnstake.target, + amount: unstakeAmount + }; + this.notifySubscribers(event); + } + } + } + }, 6000); // Trigger every 6 seconds + } + + private notifySubscribers(event: WSEvent) { + this.wsSubscribers.forEach((sub) => { + try { + sub(event); + } catch (err) { + console.error('Subscriber error in LiquidStakingService', err); + } + }); + } +} + +export const liquidStakingService = new LiquidStakingService(); diff --git a/src/types/delegation.ts b/src/types/delegation.ts new file mode 100644 index 0000000..b623413 --- /dev/null +++ b/src/types/delegation.ts @@ -0,0 +1,34 @@ +export type NodeType = 'delegator' | 'protocol' | 'validator'; +export type EdgeType = 'deposit' | 'delegate' | 'rewards' | 'distributions'; +export type ValidatorStatus = 'active' | 'exiting' | 'slashed'; + +export interface DelegationNode { + id: string; + type: NodeType; + label: string; + metadata: { + status?: ValidatorStatus; + apr?: number; + delegatorCount?: number; + totalStake?: number; + [key: string]: any; + }; + // Optional layout/rendering properties + x?: number; + y?: number; +} + +export interface DelegationEdge { + source: string; + target: string; + amount: number; // in ETH or protocol token + type: EdgeType; + timestamp: number; // Unix timestamp in ms +} + +export interface StakingMetrics { + totalStake: number; + activeValidators: number; + averageApr: number; + totalDelegators: number; +} diff --git a/src/utils/delegationGraphLayout.ts b/src/utils/delegationGraphLayout.ts new file mode 100644 index 0000000..370b4c9 --- /dev/null +++ b/src/utils/delegationGraphLayout.ts @@ -0,0 +1,158 @@ +import { DelegationNode, DelegationEdge } from '@/src/types/delegation'; + +export interface LayoutOptions { + width: number; + height: number; + nodePaddingFactor?: number; // scaling factor for label padding + sideMargin?: number; + topBottomMargin?: number; + iterations?: number; +} + +export function layoutDelegationGraph( + nodes: DelegationNode[], + edges: DelegationEdge[], + options: LayoutOptions +): DelegationNode[] { + const { + width, + height, + nodePaddingFactor = 6, + sideMargin = 60, + topBottomMargin = 80, + iterations = 3 + } = options; + + if (nodes.length === 0) return []; + + // 1. Layer Assignment + const l0 = nodes.filter((n) => n.type === 'delegator'); + const l1 = nodes.filter((n) => n.type === 'protocol'); + const l2 = nodes.filter((n) => n.type === 'validator'); + + // Helper to get connected nodes in undirected fashion + const getNeighbors = (nodeId: string): string[] => { + const neighbors = new Set(); + edges.forEach((edge) => { + if (edge.source === nodeId) neighbors.add(edge.target); + if (edge.target === nodeId) neighbors.add(edge.source); + }); + return Array.from(neighbors); + }; + + // 2. Barycenter Heuristic for Cross-Edge Minimization + // We will run a few iterations to optimize the order of nodes within layers + let l0Order = [...l0]; + let l1Order = [...l1]; + let l2Order = [...l2]; + + for (let iter = 0; iter < iterations; iter++) { + // Forward Pass: L0 -> L1 -> L2 + // Optimize L1 based on L0 + l1Order = sortLayerByBarycenter(l1Order, l0Order, getNeighbors); + // Optimize L2 based on L1 + l2Order = sortLayerByBarycenter(l2Order, l1Order, getNeighbors); + + // Backward Pass: L2 -> L1 -> L0 + // Optimize L1 based on L2 + l1Order = sortLayerByBarycenter(l1Order, l2Order, getNeighbors); + // Optimize L0 based on L1 + l0Order = sortLayerByBarycenter(l0Order, l1Order, getNeighbors); + } + + // 3. Node Positioning + // Calculate Y coordinates + const y0 = topBottomMargin; + const y1 = height / 2; + const y2 = height - topBottomMargin; + + const positionedNodes: DelegationNode[] = []; + + const positionLayer = (layerNodes: DelegationNode[], y: number) => { + const N = layerNodes.length; + if (N === 0) return; + + if (N === 1) { + positionedNodes.push({ + ...layerNodes[0], + x: width / 2, + y + }); + return; + } + + // Compute padding proportional to label length + const paddings = layerNodes.map((n) => n.label.length * nodePaddingFactor); + const totalPadding = paddings.reduce((sum, p) => sum + p, 0); + + const availWidth = width - 2 * sideMargin; + const remainingWidth = availWidth - totalPadding; + + if (remainingWidth > 0) { + // Space nodes proportionally including label-length padding + const gap = remainingWidth / (N - 1); + let currentX = sideMargin; + + layerNodes.forEach((node, idx) => { + const nodeWidth = paddings[idx]; + // Center of the node padding block + const x = currentX + nodeWidth / 2; + positionedNodes.push({ + ...node, + x, + y + }); + currentX += nodeWidth + gap; + }); + } else { + // Fallback: If not enough width, distribute centers evenly + const step = availWidth / (N - 1); + layerNodes.forEach((node, idx) => { + positionedNodes.push({ + ...node, + x: sideMargin + idx * step, + y + }); + }); + } + }; + + positionLayer(l0Order, y0); + positionLayer(l1Order, y1); + positionLayer(l2Order, y2); + + return positionedNodes; +} + +// Computes the barycenter of each node in targetLayer relative to sourceLayer, and sorts targetLayer +function sortLayerByBarycenter( + targetLayer: DelegationNode[], + sourceLayer: DelegationNode[], + getNeighbors: (id: string) => string[] +): DelegationNode[] { + const sourcePositionMap = new Map(); + sourceLayer.forEach((node, idx) => { + sourcePositionMap.set(node.id, idx); + }); + + const getBarycenterValue = (node: DelegationNode): number => { + const neighbors = getNeighbors(node.id); + const sourceNeighbors = neighbors.filter((nid) => sourcePositionMap.has(nid)); + + if (sourceNeighbors.length === 0) return 0; + + const sumPositions = sourceNeighbors.reduce((sum, nid) => { + return sum + sourcePositionMap.get(nid)!; + }, 0); + + return sumPositions / sourceNeighbors.length; + }; + + // Sort nodes based on their barycenter values + return [...targetLayer].sort((a, b) => { + const baryA = getBarycenterValue(a); + const baryB = getBarycenterValue(b); + if (baryA !== baryB) return baryA - baryB; + return a.id.localeCompare(b.id); // Tie breaker + }); +}