Pārlūkot izejas kodu

feat: Solana DEX swap flow visualization dashboard

Real-time visualization of Solana DEX swap activity using force-directed
graph with Canvas rendering. Features include:

- Helius WebSocket + Enhanced Transactions API for real-time swap data
- D3.js force graph with radial layout around SOL center node
- SQLite persistence with 24h retention and automatic pruning
- Multi-timeframe support (5m/30m/1h/12h/24h)
- Top 100 tokens by USD volume, top 30 pairs
- AMM source filtering (Raydium, Orca, Jupiter, Meteora, etc.)
- SOL price via Jupiter API with 10-min cache
- Token metadata resolution via Helius DAS API
- Server-Sent Events for real-time streaming to client
- Docker support for deployment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zhangchunrui 2 nedēļas atpakaļ
vecāks
revīzija
2f52c0b1a5

+ 9 - 0
.dockerignore

@@ -0,0 +1,9 @@
+node_modules
+.next
+.env*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+.git
+.claude

+ 5 - 0
.gitignore

@@ -39,3 +39,8 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 next-env.d.ts
+
+# SQLite database
+*.db
+*.db-wal
+*.db-shm

+ 114 - 0
AGENTS.md

@@ -0,0 +1,114 @@
+# AGENTS.md - Solana Swap Flow
+
+## Project Overview
+
+Real-time Solana DEX swap flow visualization dashboard. Displays a force-directed network graph showing token trading flows across Solana DEXes (Pump, Raydium, Meteora, Orca, Jupiter), powered by Helius API.
+
+## Architecture
+
+```
+Helius WebSocket (logsSubscribe for 9 DEX programs)
+  → Collect tx signatures from logsNotification
+  → Batch parse via Helius Enhanced Transactions API (100/batch, 3 concurrent, every 1.5s)
+  → Extract SwapEvent { signature, timestamp, source, tokenIn, tokenOut }
+  → Emit to listeners:
+      ├── SSE endpoint → browser EventSource → Zustand store → D3 force graph
+      └── DB writer → SQLite (swaps.db, 24h retention)
+```
+
+## Tech Stack
+
+| Technology | Purpose |
+|---|---|
+| Next.js 16 (App Router) + TypeScript | Framework |
+| D3.js (d3-force) + Canvas | Force-directed network graph rendering |
+| Tailwind CSS v4 | Dark theme styling |
+| Zustand | Client state management |
+| better-sqlite3 | Persistent swap storage (24h retention) |
+| ws | Server-side WebSocket to Helius RPC |
+| lru-cache | Token metadata caching |
+
+## Key Data Flow
+
+1. **WebSocket** (`src/lib/helius/websocket.ts`): Connects to Helius RPC, subscribes to DEX program logs, collects transaction signatures. Includes ping/pong heartbeat (30s) and 60s silence auto-reconnect.
+
+2. **Batch Parser** (`src/lib/helius/batchParser.ts`): Queues signatures, processes 3x100 batches every 1.5s via Helius Enhanced Transactions REST API. Caps pending queue at 3000.
+
+3. **Swap Parser** (`src/lib/helius/parseSwap.ts`): Extracts SwapEvent from Helius parsed transaction response (events.swap field).
+
+4. **DB Writer** (`src/lib/db/writer.ts`): Registers as batch parser listener, writes swap batches to SQLite transactionally. Prune job runs every 5 minutes.
+
+5. **SSE Endpoint** (`src/app/api/stream/route.ts`): Streams real-time swaps to browser via Server-Sent Events. Supports AMM source filtering.
+
+6. **REST API** (`src/app/api/swaps/route.ts`): Returns historical swaps from SQLite for selected timeframe (5m/30m/1h/12h/24h).
+
+7. **Client Store** (`src/lib/store.ts`): Zustand store with swapBuffer, aggregation, graph nodes/links, timeframe/filter state.
+
+8. **Aggregator** (`src/lib/graph/aggregator.ts`): Rolling window aggregation. Computes per-token inflow/outflow/netFlow, top pairs by volume. Limits graph to top 100 tokens by volume.
+
+9. **Graph Builder** (`src/lib/graph/graphBuilder.ts`): Converts aggregated data to GraphNode[] + GraphLink[] with log-scale sizing, green/red coloring by net flow.
+
+10. **Force Graph Engine** (`src/components/ForceGraph/ForceGraphEngine.ts`): D3 force simulation with Canvas rendering. SOL fixed at center. Supports zoom/pan/drag/click/hover.
+
+## Project Structure
+
+```
+src/
+├── app/
+│   ├── layout.tsx                    # Root layout, dark theme, Geist Mono font
+│   ├── page.tsx                      # Renders <Dashboard />
+│   ├── globals.css                   # Tailwind v4 CSS config
+│   └── api/
+│       ├── stream/route.ts           # SSE endpoint (real-time swaps)
+│       ├── swaps/route.ts            # REST endpoint (historical from SQLite)
+│       └── token/route.ts            # Token metadata (Helius DAS API)
+├── components/
+│   ├── Dashboard.tsx                 # Main layout + historical fetch effect
+│   ├── Header.tsx                    # Status, stats, timeframe selector
+│   ├── TimeframeSelector.tsx         # 5m/30m/1h/12h/24h buttons
+│   ├── AMMFilter.tsx                 # Pump/Raydium/Meteora/Orca/Jupiter toggle
+│   ├── ForceGraph/
+│   │   ├── ForceGraph.tsx            # React canvas wrapper
+│   │   └── ForceGraphEngine.ts       # D3 force simulation + Canvas render
+│   └── Sidebar/
+│       ├── TopPairs.tsx              # Top 30 trading pairs by volume
+│       └── TokenDetail.tsx           # Token info card on click
+├── hooks/
+│   ├── useSwapStream.ts             # Client SSE hook + auto metadata fetch
+│   └── useTokenMetadata.ts          # Single token metadata fetch
+└── lib/
+    ├── store.ts                     # Zustand store
+    ├── timeframes.ts                # Timeframe type + config
+    ├── utils.ts                     # Formatters
+    ├── db/
+    │   ├── index.ts                 # SQLite singleton (WAL mode, prepared stmts)
+    │   └── writer.ts                # Batch parser → DB writer
+    ├── graph/
+    │   ├── aggregator.ts            # Rolling window aggregation (top 100 tokens)
+    │   └── graphBuilder.ts          # Aggregated data → graph nodes/links
+    └── helius/
+        ├── constants.ts             # DEX program IDs, API URLs, known tokens
+        ├── websocket.ts             # Helius WebSocket (logsSubscribe)
+        ├── batchParser.ts           # Batch signature parser
+        ├── parseSwap.ts             # Helius tx → SwapEvent
+        └── tokenMetadata.ts         # DAS API getAsset + LRU cache
+
+## Environment Variables
+
+- `HELIUS_API_KEY` — Required. Helius API key for WebSocket and REST API access.
+
+## Development
+
+```bash
+npm install
+cp .env.example .env.local  # Set HELIUS_API_KEY
+npm run dev                  # Starts on port 3456
+```
+
+## Important Patterns
+
+- **globalThis singleton**: `websocket.ts`, `batchParser.ts`, `db/index.ts` all use `globalThis` to persist state across Next.js HMR reloads.
+- **Canvas rendering**: D3 force graph uses HTML Canvas (not SVG) for performance with 100+ nodes.
+- **Top 100 filter**: Aggregator limits displayed tokens to top 100 by total volume. SOL always included.
+- **WAL mode SQLite**: Allows concurrent reads (REST API) while writes (batch parser) happen.
+```

+ 41 - 0
Dockerfile

@@ -0,0 +1,41 @@
+FROM node:20-slim AS base
+
+# Install build tools for native addons (better-sqlite3)
+RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Install dependencies
+COPY package.json package-lock.json* ./
+RUN npm ci
+
+# Copy source
+COPY . .
+
+# Build
+RUN npm run build
+
+# --- Production stage ---
+FROM node:20-slim AS runner
+
+RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+# Copy built app
+COPY --from=base /app/package.json ./
+COPY --from=base /app/package-lock.json* ./
+COPY --from=base /app/next.config.ts ./
+COPY --from=base /app/.next ./.next
+COPY --from=base /app/public ./public
+COPY --from=base /app/node_modules ./node_modules
+
+# Data directory for SQLite
+RUN mkdir -p /app/data
+
+EXPOSE 3000
+
+CMD ["npm", "start"]

+ 86 - 20
README.md

@@ -1,36 +1,102 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# Solana Swap Flow
 
-## Getting Started
+Real-time Solana DEX swap flow visualization dashboard. Displays a force-directed network graph showing where volume is moving, what tokens are gaining traction, and swap density across Solana DEXes.
 
-First, run the development server:
+- **Green** = net inflow (more buys)
+- **Red** = net outflow (more sells)
+- **Line weight** = swap density
+- **Bubble size** = trading volume
+
+Powered by [Helius](https://helius.dev) WebSocket + Enhanced Transactions API.
+
+## Features
+
+- Real-time force-directed graph with SOL as center hub
+- Top 100 tokens by trading volume displayed
+- Top 30 trading pairs sorted by volume
+- Multi-timeframe: 5 min / 30 min / 1 hour / 12 hours / 24 hours
+- AMM filter: Pump / Raydium / Meteora / Orca / Jupiter
+- SQLite persistence with 24-hour data retention
+- Token metadata resolution via Helius DAS API
+- Click token for details (price, supply, market cap, inflow/outflow)
+- Drag, zoom, pan interactions
+- Auto-reconnect WebSocket with heartbeat monitoring
+
+## Prerequisites
+
+- Node.js 18+
+- [Helius API Key](https://dev.helius.xyz) (free tier works)
+
+## Quick Start
 
 ```bash
+# Install dependencies
+npm install
+
+# Configure environment
+cp .env.example .env.local
+# Edit .env.local and set your HELIUS_API_KEY
+
+# Start development server
 npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
 ```
 
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Open [http://localhost:3000](http://localhost:3000).
+
+## Environment Variables
+
+| Variable | Required | Description |
+|---|---|---|
+| `HELIUS_API_KEY` | Yes | Helius API key for Solana RPC, WebSocket, and token metadata |
+
+Create a `.env.local` file:
+
+```
+HELIUS_API_KEY=your_helius_api_key_here
+```
+
+## Docker
 
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+```bash
+# Build and run
+docker compose up -d
 
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+# View logs
+docker compose logs -f
 
-## Learn More
+# Stop
+docker compose down
+```
 
-To learn more about Next.js, take a look at the following resources:
+The SQLite database is persisted in a Docker volume (`solana-flow-data`).
 
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+## Tech Stack
+
+| Technology | Purpose |
+|---|---|
+| Next.js 16 (App Router) | Framework |
+| D3.js + Canvas | Force-directed graph rendering |
+| Tailwind CSS v4 | Styling |
+| Zustand | Client state |
+| better-sqlite3 | Persistent storage (24h) |
+| ws | WebSocket client |
+
+## Architecture
+
+```
+Helius WebSocket (logsSubscribe x 9 DEX programs)
+  -> Batch parse signatures (3x100 every 1.5s)
+  -> SwapEvent extraction
+  -> SSE stream to browser + SQLite persistence
+  -> Zustand store -> D3 force graph (Canvas)
+```
 
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+## Data Sources
 
-## Deploy on Vercel
+- **Real-time swaps**: Standard Solana WebSocket `logsSubscribe` with DEX program mentions, parsed via Helius Enhanced Transactions API
+- **Token metadata**: Helius DAS API `getAsset` with LRU cache
+- **No Enhanced WebSocket required** (works on Helius free/developer plan)
 
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+## License
 
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+MIT

+ 14 - 0
docker-compose.yml

@@ -0,0 +1,14 @@
+services:
+  solana-flow:
+    build: .
+    ports:
+      - "3000:3000"
+    environment:
+      - HELIUS_API_KEY=${HELIUS_API_KEY}
+      - DATA_DIR=/app/data
+    volumes:
+      - solana-flow-data:/app/data
+    restart: unless-stopped
+
+volumes:
+  solana-flow-data:

+ 1 - 1
next.config.ts

@@ -1,7 +1,7 @@
 import type { NextConfig } from "next";
 
 const nextConfig: NextConfig = {
-  /* config options here */
+  serverExternalPackages: ['better-sqlite3'],
 };
 
 export default nextConfig;

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 923 - 43
package-lock.json


+ 9 - 1
package.json

@@ -8,15 +8,23 @@
     "start": "next start"
   },
   "dependencies": {
+    "@types/better-sqlite3": "^7.6.13",
+    "better-sqlite3": "^12.6.2",
+    "d3": "^7.9.0",
+    "lru-cache": "^11.2.6",
     "next": "16.1.6",
     "react": "19.2.3",
-    "react-dom": "19.2.3"
+    "react-dom": "19.2.3",
+    "ws": "^8.19.0",
+    "zustand": "^5.0.11"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4",
+    "@types/d3": "^7.4.3",
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react-dom": "^19",
+    "@types/ws": "^8.18.1",
     "tailwindcss": "^4",
     "typescript": "^5"
   }

BIN
public/solana.jpg


+ 7 - 0
src/app/api/sol-price/route.ts

@@ -0,0 +1,7 @@
+import { NextResponse } from 'next/server';
+import { getSolPrice } from '@/lib/helius/solPrice';
+
+export async function GET() {
+  const price = await getSolPrice();
+  return NextResponse.json({ price });
+}

+ 69 - 0
src/app/api/stream/route.ts

@@ -0,0 +1,69 @@
+import { initHeliusStream, isMock, onConnectionStatus, onMockSwapBatch } from '@/lib/helius/websocket';
+import { onSwapBatch } from '@/lib/helius/batchParser';
+import { initDbWriter } from '@/lib/db/writer';
+import type { SwapEvent } from '@/lib/helius/parseSwap';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: Request) {
+  const url = new URL(req.url);
+  const filter = url.searchParams.get('filter') || 'All';
+
+  // Initialize the Helius stream and DB writer (singletons)
+  initHeliusStream();
+  initDbWriter();
+
+  const encoder = new TextEncoder();
+
+  const stream = new ReadableStream({
+    start(controller) {
+      const send = (event: string, data: unknown) => {
+        try {
+          controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
+        } catch {
+          // Stream closed
+        }
+      };
+
+      // Send connection status
+      const unsubStatus = onConnectionStatus((connected) => {
+        send('status', { connected });
+      });
+
+      // Listen for swap batches
+      let unsubSwaps: (() => void) | null = null;
+
+      const handleSwaps = (swaps: SwapEvent[]) => {
+        const filtered = filter === 'All'
+          ? swaps
+          : swaps.filter((s) => s.source === filter);
+
+        if (filtered.length > 0) {
+          send('swaps', filtered);
+        }
+      };
+
+      if (isMock()) {
+        unsubSwaps = onMockSwapBatch(handleSwaps);
+      } else {
+        unsubSwaps = onSwapBatch(handleSwaps);
+      }
+
+      // Cleanup on abort
+      req.signal.addEventListener('abort', () => {
+        unsubStatus();
+        if (unsubSwaps) unsubSwaps();
+        try { controller.close(); } catch {}
+      });
+    },
+  });
+
+  return new Response(stream, {
+    headers: {
+      'Content-Type': 'text/event-stream',
+      'Cache-Control': 'no-cache, no-transform',
+      Connection: 'keep-alive',
+      'X-Accel-Buffering': 'no',
+    },
+  });
+}

+ 31 - 0
src/app/api/swaps/route.ts

@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server';
+import { querySwaps, getSwapCount } from '@/lib/db';
+import { TIMEFRAME_CONFIG, type Timeframe } from '@/lib/timeframes';
+import type { AMMSource } from '@/lib/helius/constants';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: Request) {
+  const url = new URL(req.url);
+  const timeframe = (url.searchParams.get('timeframe') || '5m') as Timeframe;
+  const filter = (url.searchParams.get('filter') || 'All') as AMMSource;
+
+  const config = TIMEFRAME_CONFIG[timeframe];
+  if (!config) {
+    return NextResponse.json(
+      { error: `Invalid timeframe. Valid: ${Object.keys(TIMEFRAME_CONFIG).join(', ')}` },
+      { status: 400 }
+    );
+  }
+
+  const sinceMs = Date.now() - config.ms;
+  const swaps = querySwaps(sinceMs, filter);
+
+  return NextResponse.json({
+    timeframe,
+    filter,
+    count: swaps.length,
+    totalInDb: getSwapCount(),
+    swaps,
+  });
+}

+ 14 - 0
src/app/api/token/route.ts

@@ -0,0 +1,14 @@
+import { getTokenMetadata } from '@/lib/helius/tokenMetadata';
+import { NextResponse } from 'next/server';
+
+export async function GET(req: Request) {
+  const url = new URL(req.url);
+  const mint = url.searchParams.get('mint');
+
+  if (!mint) {
+    return NextResponse.json({ error: 'mint parameter required' }, { status: 400 });
+  }
+
+  const metadata = await getTokenMetadata(mint);
+  return NextResponse.json(metadata);
+}

+ 21 - 11
src/app/globals.css

@@ -1,26 +1,36 @@
 @import "tailwindcss";
 
 :root {
-  --background: #ffffff;
-  --foreground: #171717;
+  --background: #06060a;
+  --foreground: #ededed;
 }
 
 @theme inline {
   --color-background: var(--background);
   --color-foreground: var(--foreground);
-  --font-sans: var(--font-geist-sans);
   --font-mono: var(--font-geist-mono);
 }
 
-@media (prefers-color-scheme: dark) {
-  :root {
-    --background: #0a0a0a;
-    --foreground: #ededed;
-  }
-}
-
 body {
   background: var(--background);
   color: var(--foreground);
-  font-family: Arial, Helvetica, sans-serif;
+  font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
+  overflow: hidden;
+}
+
+::-webkit-scrollbar {
+  width: 4px;
+}
+
+::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 2px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: rgba(255, 255, 255, 0.2);
 }

+ 5 - 12
src/app/layout.tsx

@@ -1,20 +1,15 @@
 import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Geist_Mono } from "next/font/google";
 import "./globals.css";
 
-const geistSans = Geist({
-  variable: "--font-geist-sans",
-  subsets: ["latin"],
-});
-
 const geistMono = Geist_Mono({
   variable: "--font-geist-mono",
   subsets: ["latin"],
 });
 
 export const metadata: Metadata = {
-  title: "Create Next App",
-  description: "Generated by create next app",
+  title: "Solana Swap Flow",
+  description: "Real-time DEX swap flow visualization powered by Helius",
 };
 
 export default function RootLayout({
@@ -23,10 +18,8 @@ export default function RootLayout({
   children: React.ReactNode;
 }>) {
   return (
-    <html lang="en">
-      <body
-        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
-      >
+    <html lang="en" className="dark">
+      <body className={`${geistMono.variable} antialiased`}>
         {children}
       </body>
     </html>

+ 2 - 62
src/app/page.tsx

@@ -1,65 +1,5 @@
-import Image from "next/image";
+import { Dashboard } from '@/components/Dashboard';
 
 export default function Home() {
-  return (
-    <div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
-      <main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
-        <Image
-          className="dark:invert"
-          src="/next.svg"
-          alt="Next.js logo"
-          width={100}
-          height={20}
-          priority
-        />
-        <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
-          <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
-            To get started, edit the page.tsx file.
-          </h1>
-          <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
-            Looking for a starting point or more instructions? Head over to{" "}
-            <a
-              href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Templates
-            </a>{" "}
-            or the{" "}
-            <a
-              href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Learning
-            </a>{" "}
-            center.
-          </p>
-        </div>
-        <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
-          <a
-            className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
-            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <Image
-              className="dark:invert"
-              src="/vercel.svg"
-              alt="Vercel logomark"
-              width={16}
-              height={16}
-            />
-            Deploy Now
-          </a>
-          <a
-            className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
-            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            Documentation
-          </a>
-        </div>
-      </main>
-    </div>
-  );
+  return <Dashboard />;
 }

+ 43 - 0
src/components/AMMFilter.tsx

@@ -0,0 +1,43 @@
+'use client';
+
+import { useDashboardStore } from '@/lib/store';
+import type { AMMSource } from '@/lib/helius/constants';
+
+const FILTERS: { label: string; value: AMMSource; color: string }[] = [
+  { label: 'All', value: 'All', color: 'border-white/30 text-white/60' },
+  { label: 'Pump', value: 'Pump', color: 'border-green-500/50 text-green-400' },
+  { label: 'Raydium', value: 'Raydium', color: 'border-blue-500/50 text-blue-400' },
+  { label: 'Meteora', value: 'Meteora', color: 'border-yellow-500/50 text-yellow-400' },
+  { label: 'Orca', value: 'Orca', color: 'border-purple-500/50 text-purple-400' },
+  { label: 'Jupiter', value: 'Jupiter', color: 'border-orange-500/50 text-orange-400' },
+];
+
+export function AMMFilter() {
+  const activeFilter = useDashboardStore((s) => s.activeFilter);
+  const setFilter = useDashboardStore((s) => s.setFilter);
+
+  return (
+    <div className="flex items-center gap-3 px-4 py-2">
+      <span className="text-[10px] text-white/30 uppercase tracking-widest">AMM Filter</span>
+      <div className="flex gap-1.5">
+        {FILTERS.map((f) => {
+          const isActive = activeFilter === f.value;
+          return (
+            <button
+              key={f.value}
+              onClick={() => setFilter(f.value)}
+              className={`
+                px-2.5 py-1 text-xs rounded border transition-all
+                ${f.color}
+                ${isActive ? 'bg-white/10 border-current' : 'bg-transparent border-white/5 !text-white/30 hover:bg-white/5'}
+              `}
+            >
+              <span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${isActive ? 'bg-current' : 'bg-white/20'}`} />
+              {f.label}
+            </button>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 89 - 0
src/components/Dashboard.tsx

@@ -0,0 +1,89 @@
+'use client';
+
+import { useRef, useEffect } from 'react';
+import { Header } from './Header';
+import { ForceGraph } from './ForceGraph/ForceGraph';
+import { TopPairs } from './Sidebar/TopPairs';
+import { TokenDetail } from './Sidebar/TokenDetail';
+import { AMMFilter } from './AMMFilter';
+import { useSwapStream } from '@/hooks/useSwapStream';
+import { ForceGraphEngine } from './ForceGraph/ForceGraphEngine';
+import { useDashboardStore } from '@/lib/store';
+import type { SwapEvent } from '@/lib/helius/parseSwap';
+
+export function Dashboard() {
+  useSwapStream();
+  const engineRef = useRef<ForceGraphEngine | null>(null);
+
+  const activeTimeframe = useDashboardStore((s) => s.activeTimeframe);
+  const activeFilter = useDashboardStore((s) => s.activeFilter);
+  const setSwaps = useDashboardStore((s) => s.setSwaps);
+  const setSolPrice = useDashboardStore((s) => s.setSolPrice);
+
+  // Fetch SOL price on mount
+  useEffect(() => {
+    async function fetchPrice() {
+      try {
+        const res = await fetch('/api/sol-price');
+        const data = await res.json();
+        if (data.price > 0) setSolPrice(data.price);
+      } catch { /* keep fallback */ }
+    }
+    fetchPrice();
+    const interval = setInterval(fetchPrice, 10 * 60 * 1000); // refresh every 10min
+    return () => clearInterval(interval);
+  }, [setSolPrice]);
+
+  // Fetch historical data when timeframe or filter changes
+  useEffect(() => {
+    const controller = new AbortController();
+
+    async function fetchHistorical() {
+      try {
+        const res = await fetch(
+          `/api/swaps?timeframe=${activeTimeframe}&filter=${activeFilter}`,
+          { signal: controller.signal }
+        );
+        if (!res.ok) return;
+        const data = await res.json();
+        if (!controller.signal.aborted) {
+          setSwaps(data.swaps as SwapEvent[]);
+        }
+      } catch (err: any) {
+        if (err?.name !== 'AbortError') {
+          console.error('[historical] Fetch failed:', err);
+        }
+      }
+    }
+
+    fetchHistorical();
+
+    return () => { controller.abort(); };
+  }, [activeTimeframe, activeFilter, setSwaps]);
+
+  return (
+    <div className="h-screen w-screen flex flex-col bg-[#06060a] text-white overflow-hidden">
+      {/* Header */}
+      <Header />
+
+      {/* Main content */}
+      <div className="flex-1 flex min-h-0">
+        {/* Graph area */}
+        <div className="flex-1 relative">
+          <ForceGraph />
+        </div>
+
+        {/* Right sidebar */}
+        <div className="w-[300px] border-l border-white/5 p-3 flex flex-col gap-2 overflow-y-auto">
+          <TopPairs />
+          <TokenDetail />
+        </div>
+      </div>
+
+      {/* Bottom filter bar */}
+      <div className="border-t border-white/5">
+        <AMMFilter />
+      </div>
+    </div>
+  );
+}

+ 47 - 0
src/components/ForceGraph/ForceGraph.tsx

@@ -0,0 +1,47 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { ForceGraphEngine } from './ForceGraphEngine';
+import { useDashboardStore } from '@/lib/store';
+
+export function ForceGraph() {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const engineRef = useRef<ForceGraphEngine | null>(null);
+  const nodes = useDashboardStore((s) => s.nodes);
+  const links = useDashboardStore((s) => s.links);
+  const setSelectedToken = useDashboardStore((s) => s.setSelectedToken);
+
+  useEffect(() => {
+    if (!canvasRef.current) return;
+
+    engineRef.current = new ForceGraphEngine(canvasRef.current, {
+      onNodeClick: (id) => setSelectedToken(id),
+    });
+
+    return () => {
+      engineRef.current?.destroy();
+      engineRef.current = null;
+    };
+  }, [setSelectedToken]);
+
+  useEffect(() => {
+    if (engineRef.current && nodes.length > 0) {
+      engineRef.current.updateData(nodes, links);
+    }
+  }, [nodes, links]);
+
+  return (
+    <div className="relative w-full h-full">
+      <canvas
+        ref={canvasRef}
+        className="w-full h-full"
+        style={{ display: 'block' }}
+      />
+    </div>
+  );
+}
+
+export function useResetGraph() {
+  const engineRef = useRef<ForceGraphEngine | null>(null);
+  return () => engineRef.current?.resetView();
+}

+ 444 - 0
src/components/ForceGraph/ForceGraphEngine.ts

@@ -0,0 +1,444 @@
+import * as d3 from 'd3';
+import type { GraphNode, GraphLink } from '@/lib/graph/graphBuilder';
+import { SOL_MINT } from '@/lib/helius/constants';
+
+interface SimNode extends GraphNode {
+  x: number;
+  y: number;
+  vx: number;
+  vy: number;
+}
+
+interface SimLink {
+  source: SimNode;
+  target: SimNode;
+  swapCount: number;
+  volume: number;
+  width: number;
+}
+
+interface EngineOptions {
+  onNodeClick?: (id: string) => void;
+  onNodeHover?: (id: string | null) => void;
+}
+
+export class ForceGraphEngine {
+  private canvas: HTMLCanvasElement;
+  private ctx: CanvasRenderingContext2D;
+  private simulation: d3.Simulation<SimNode, SimLink>;
+  private nodes: SimNode[] = [];
+  private links: SimLink[] = [];
+  private transform: d3.ZoomTransform = d3.zoomIdentity;
+  private width = 0;
+  private height = 0;
+  private hoveredNode: SimNode | null = null;
+  private draggedNode: SimNode | null = null;
+  private options: EngineOptions;
+  private animFrameId = 0;
+  private dpr = 1;
+  private solImage: HTMLImageElement | null = null;
+  private solImageLoaded = false;
+
+  constructor(canvas: HTMLCanvasElement, options: EngineOptions = {}) {
+    this.canvas = canvas;
+    this.ctx = canvas.getContext('2d')!;
+    this.options = options;
+    this.dpr = window.devicePixelRatio || 1;
+
+    this.loadSolImage();
+    this.resize();
+
+    const radialRadius = Math.min(this.width, this.height) * 0.42;
+
+    this.simulation = d3.forceSimulation<SimNode, SimLink>()
+      .force('link', d3.forceLink<SimNode, SimLink>().id((d) => d.id).distance(radialRadius).strength(0.01))
+      .force('charge', d3.forceManyBody<SimNode>().strength((d) => d.id === SOL_MINT ? 0 : -80).distanceMax(400))
+      .force('collision', d3.forceCollide<SimNode>().radius((d) => d.radius + 6).strength(0.7))
+      .force('radial', d3.forceRadial<SimNode>(
+        (d) => d.id === SOL_MINT ? 0 : radialRadius,
+        this.width / 2,
+        this.height / 2
+      ).strength(1.2))
+      .alphaDecay(0.05)
+      .velocityDecay(0.6)
+      .on('tick', () => this.render());
+
+    this.setupInteractions();
+    this.startRenderLoop();
+
+    window.addEventListener('resize', this.handleResize);
+  }
+
+  private loadSolImage() {
+    const img = new Image();
+    img.crossOrigin = 'anonymous';
+    img.onload = () => {
+      this.solImage = img;
+      this.solImageLoaded = true;
+      this.render();
+    };
+    img.src = '/solana.jpg';
+  }
+
+  private handleResize = () => {
+    this.resize();
+    const radialRadius = Math.min(this.width, this.height) * 0.42;
+    this.simulation
+      .force('radial', d3.forceRadial<SimNode>(
+        (d) => d.id === SOL_MINT ? 0 : radialRadius,
+        this.width / 2,
+        this.height / 2
+      ).strength(1.2));
+    this.simulation.alpha(0.1).restart();
+  };
+
+  private resize() {
+    const rect = this.canvas.parentElement?.getBoundingClientRect();
+    if (!rect) return;
+    this.width = rect.width;
+    this.height = rect.height;
+    this.canvas.width = this.width * this.dpr;
+    this.canvas.height = this.height * this.dpr;
+    this.canvas.style.width = `${this.width}px`;
+    this.canvas.style.height = `${this.height}px`;
+    this.ctx.scale(this.dpr, this.dpr);
+  }
+
+  private setupInteractions() {
+    const canvas = d3.select(this.canvas);
+
+    // Zoom & pan
+    const zoom = d3.zoom<HTMLCanvasElement, unknown>()
+      .scaleExtent([0.1, 8])
+      .on('zoom', (event) => {
+        this.transform = event.transform;
+        this.render();
+      });
+
+    canvas.call(zoom);
+
+    // Drag
+    const drag = d3.drag<HTMLCanvasElement, unknown>()
+      .subject((event) => {
+        const [x, y] = this.screenToWorld(event.x, event.y);
+        return this.findNode(x, y);
+      })
+      .on('start', (event) => {
+        if (!event.subject) return;
+        this.draggedNode = event.subject as SimNode;
+        this.simulation.alphaTarget(0.3).restart();
+        this.draggedNode.fx = this.draggedNode.x;
+        this.draggedNode.fy = this.draggedNode.y;
+      })
+      .on('drag', (event) => {
+        if (!this.draggedNode) return;
+        const [x, y] = this.screenToWorld(event.x, event.y);
+        this.draggedNode.fx = x;
+        this.draggedNode.fy = y;
+      })
+      .on('end', (event) => {
+        if (!this.draggedNode) return;
+        this.simulation.alphaTarget(0);
+        // Keep SOL fixed at center, release others
+        if (this.draggedNode.id !== SOL_MINT) {
+          this.draggedNode.fx = null;
+          this.draggedNode.fy = null;
+        }
+        this.draggedNode = null;
+      });
+
+    canvas.call(drag as any);
+
+    // Click
+    this.canvas.addEventListener('click', (e) => {
+      const rect = this.canvas.getBoundingClientRect();
+      const [x, y] = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
+      const node = this.findNode(x, y);
+      if (node && this.options.onNodeClick) {
+        this.options.onNodeClick(node.id);
+      }
+    });
+
+    // Hover
+    this.canvas.addEventListener('mousemove', (e) => {
+      const rect = this.canvas.getBoundingClientRect();
+      const [x, y] = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
+      const node = this.findNode(x, y);
+      if (node !== this.hoveredNode) {
+        this.hoveredNode = node;
+        this.canvas.style.cursor = node ? 'pointer' : 'grab';
+        if (this.options.onNodeHover) {
+          this.options.onNodeHover(node?.id || null);
+        }
+        this.render();
+      }
+    });
+  }
+
+  private screenToWorld(sx: number, sy: number): [number, number] {
+    return [
+      (sx - this.transform.x) / this.transform.k,
+      (sy - this.transform.y) / this.transform.k,
+    ];
+  }
+
+  private findNode(x: number, y: number): SimNode | null {
+    // Search in reverse order (top-most first)
+    for (let i = this.nodes.length - 1; i >= 0; i--) {
+      const node = this.nodes[i];
+      const dx = x - node.x;
+      const dy = y - node.y;
+      if (dx * dx + dy * dy < (node.radius + 5) * (node.radius + 5)) {
+        return node;
+      }
+    }
+    return null;
+  }
+
+  private startRenderLoop() {
+    const loop = () => {
+      this.animFrameId = requestAnimationFrame(loop);
+    };
+    loop();
+  }
+
+  private drawNode(ctx: CanvasRenderingContext2D, node: SimNode) {
+    if (node.x === undefined || node.y === undefined) return;
+
+    const isHovered = this.hoveredNode?.id === node.id;
+    const isSol = node.id === SOL_MINT;
+
+    // Outer glow
+    const glowRadius = node.radius * (isSol ? 2.5 : 2);
+    const gradient = ctx.createRadialGradient(
+      node.x, node.y, node.radius * 0.8,
+      node.x, node.y, glowRadius
+    );
+    gradient.addColorStop(0, node.color + (isHovered ? '40' : '20'));
+    gradient.addColorStop(1, 'transparent');
+    ctx.beginPath();
+    ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
+    ctx.fillStyle = gradient;
+    ctx.fill();
+
+    // SOL node: draw icon image if loaded
+    if (isSol && this.solImageLoaded && this.solImage) {
+      ctx.save();
+      ctx.beginPath();
+      ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
+      ctx.clip();
+      ctx.drawImage(
+        this.solImage,
+        node.x - node.radius,
+        node.y - node.radius,
+        node.radius * 2,
+        node.radius * 2
+      );
+      ctx.restore();
+
+      // Border ring around SOL icon
+      ctx.beginPath();
+      ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
+      ctx.strokeStyle = isHovered ? '#ffffff' : '#b380ff';
+      ctx.lineWidth = isHovered ? 3 : 2.5;
+      ctx.stroke();
+    } else {
+      // Opacity scales with swap activity: more swaps = more opaque (40 to aa)
+      const activity = Math.min(1, node.swapCount / 50);
+      const fillAlpha = isHovered ? 'cc' : Math.round(0x40 + activity * 0x6a).toString(16).padStart(2, '0');
+      const borderWidth = isHovered ? 3 : 1.2 + activity * 1.8;
+
+      // Node fill
+      ctx.beginPath();
+      ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
+      ctx.fillStyle = node.color + fillAlpha;
+      ctx.fill();
+
+      // Border ring — thicker for more active tokens
+      ctx.beginPath();
+      ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
+      ctx.strokeStyle = isHovered ? '#ffffff' : node.color;
+      ctx.lineWidth = borderWidth;
+      ctx.stroke();
+
+      // Inner highlight ring for larger nodes
+      if (node.radius > 12) {
+        ctx.beginPath();
+        ctx.arc(node.x, node.y, node.radius * 0.55, 0, Math.PI * 2);
+        ctx.strokeStyle = node.color + '30';
+        ctx.lineWidth = 0.8;
+        ctx.stroke();
+      }
+    }
+
+    // Label — inside the circle, skip for SOL (icon is enough)
+    if (!isSol) {
+      // Fit text inside circle: shrink font until it fits
+      const maxWidth = node.radius * 1.6;
+      let fontSize = Math.max(7, Math.min(11, node.radius * 0.7));
+      ctx.font = `${isHovered ? 'bold ' : ''}${fontSize}px ui-monospace, monospace`;
+      let text = node.symbol;
+      let metrics = ctx.measureText(text);
+
+      // Truncate if still too wide
+      if (metrics.width > maxWidth && text.length > 4) {
+        text = text.slice(0, 4) + '…';
+        metrics = ctx.measureText(text);
+      }
+
+      ctx.textAlign = 'center';
+      ctx.textBaseline = 'middle';
+      ctx.fillStyle = isHovered ? '#ffffff' : 'rgba(255, 255, 255, 0.9)';
+      ctx.fillText(text, node.x, node.y);
+    }
+  }
+
+  private render() {
+    const ctx = this.ctx;
+    const w = this.width;
+    const h = this.height;
+
+    ctx.save();
+    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+    ctx.clearRect(0, 0, w, h);
+
+    // Background
+    ctx.fillStyle = '#06060a';
+    ctx.fillRect(0, 0, w, h);
+
+    ctx.translate(this.transform.x, this.transform.y);
+    ctx.scale(this.transform.k, this.transform.k);
+
+    // Draw links
+    for (const link of this.links) {
+      const source = link.source;
+      const target = link.target;
+      if (!source.x || !target.x) continue;
+
+      const isHovered = this.hoveredNode &&
+        (source.id === this.hoveredNode.id || target.id === this.hoveredNode.id);
+
+      ctx.beginPath();
+      ctx.moveTo(source.x, source.y);
+      ctx.lineTo(target.x, target.y);
+
+      if (isHovered) {
+        ctx.strokeStyle = 'rgba(100, 220, 255, 0.5)';
+        ctx.lineWidth = link.width + 1;
+      } else {
+        // Tint links by the dominant flow direction
+        const sourceNet = source.netFlow;
+        const targetNet = target.netFlow;
+        const avgNet = (sourceNet + targetNet) / 2;
+        const alpha = Math.min(0.25, 0.08 + link.width * 0.03);
+        ctx.strokeStyle = avgNet >= 0
+          ? `rgba(34, 197, 94, ${alpha})`   // green tint
+          : `rgba(239, 68, 68, ${alpha})`;   // red tint
+        ctx.lineWidth = link.width;
+      }
+      ctx.stroke();
+    }
+
+    // Draw nodes — SOL rendered last so it's always on top
+    let solNode: SimNode | null = null;
+    for (const node of this.nodes) {
+      if (node.id === SOL_MINT) { solNode = node; continue; }
+      this.drawNode(ctx, node);
+    }
+    if (solNode) this.drawNode(ctx, solNode);
+
+    ctx.restore();
+  }
+
+  updateData(newNodes: GraphNode[], newLinks: GraphLink[]) {
+    // Build id→existing node map
+    const existingMap = new Map<string, SimNode>();
+    for (const n of this.nodes) {
+      existingMap.set(n.id, n);
+    }
+
+    // Update or create nodes
+    const updatedNodes: SimNode[] = [];
+    for (const n of newNodes) {
+      const existing = existingMap.get(n.id);
+      if (existing) {
+        // Update data, keep position
+        existing.symbol = n.symbol;
+        existing.inflow = n.inflow;
+        existing.outflow = n.outflow;
+        existing.netFlow = n.netFlow;
+        existing.swapCount = n.swapCount;
+        existing.radius = n.radius;
+        existing.color = n.color;
+        updatedNodes.push(existing);
+      } else {
+        // New node - position near center or near a connected node
+        const simNode: SimNode = {
+          ...n,
+          x: n.x ?? this.width / 2 + (Math.random() - 0.5) * 100,
+          y: n.y ?? this.height / 2 + (Math.random() - 0.5) * 100,
+          vx: 0,
+          vy: 0,
+        };
+
+        // Fix SOL at center
+        if (n.id === SOL_MINT) {
+          simNode.fx = this.width / 2;
+          simNode.fy = this.height / 2;
+        }
+
+        updatedNodes.push(simNode);
+      }
+    }
+
+    this.nodes = updatedNodes;
+
+    // Build links
+    const nodeMap = new Map(this.nodes.map((n) => [n.id, n]));
+    this.links = newLinks
+      .filter((l) => nodeMap.has(l.source as any) && nodeMap.has(l.target as any))
+      .map((l) => ({
+        source: nodeMap.get(l.source as string)!,
+        target: nodeMap.get(l.target as string)!,
+        swapCount: l.swapCount,
+        volume: l.volume,
+        width: l.width,
+      }));
+
+    // Track if new nodes were added
+    const hasNewNodes = updatedNodes.length !== existingMap.size ||
+      updatedNodes.some((n) => !existingMap.has(n.id));
+
+    // Update simulation
+    this.simulation.nodes(this.nodes);
+    (this.simulation.force('link') as d3.ForceLink<SimNode, SimLink>)
+      .links(this.links);
+
+    if (hasNewNodes) {
+      // Strong reheat only when graph structure changes
+      this.simulation.alpha(0.3).restart();
+    } else if (this.simulation.alpha() < 0.05) {
+      // Minimal nudge for data-only updates, just re-render
+      this.render();
+    }
+  }
+
+  resetView() {
+    this.transform = d3.zoomIdentity;
+    // Release all fixed positions except SOL
+    for (const node of this.nodes) {
+      if (node.id !== SOL_MINT) {
+        node.fx = null;
+        node.fy = null;
+      }
+    }
+    this.simulation.alpha(0.5).restart();
+    this.render();
+  }
+
+  destroy() {
+    window.removeEventListener('resize', this.handleResize);
+    cancelAnimationFrame(this.animFrameId);
+    this.simulation.stop();
+  }
+}

+ 56 - 0
src/components/Header.tsx

@@ -0,0 +1,56 @@
+'use client';
+
+import { useDashboardStore } from '@/lib/store';
+import { TIMEFRAME_CONFIG } from '@/lib/timeframes';
+import { TimeframeSelector } from './TimeframeSelector';
+
+interface HeaderProps {
+  onReset?: () => void;
+}
+
+export function Header({ onReset }: HeaderProps) {
+  const isConnected = useDashboardStore((s) => s.isConnected);
+  const totalSwaps = useDashboardStore((s) => s.totalSwaps);
+  const uniqueTokens = useDashboardStore((s) => s.uniqueTokens);
+  const activeTimeframe = useDashboardStore((s) => s.activeTimeframe);
+
+  return (
+    <div className="flex items-start justify-between px-4 pt-3 pb-2">
+      <div className="space-y-1">
+        <div className="flex items-center gap-2">
+          <span
+            className={`inline-block w-2 h-2 rounded-full ${
+              isConnected ? 'bg-green-400 animate-pulse' : 'bg-red-500'
+            }`}
+          />
+          <span className="text-sm font-medium text-white/80">
+            {isConnected ? 'Streaming' : 'Disconnected'}
+          </span>
+        </div>
+        <div className="text-xs text-white/50 font-mono">
+          {totalSwaps.toLocaleString()} swaps | {uniqueTokens} tokens | {TIMEFRAME_CONFIG[activeTimeframe].label} window
+        </div>
+        <div className="text-[10px] text-white/30">
+          Click token for details | Drag to move | Scroll to zoom
+        </div>
+        <div className="flex items-center gap-2 mt-1">
+          <button
+            onClick={onReset}
+            className="px-3 py-1 text-xs text-white/60 border border-white/10 rounded hover:bg-white/5 transition-colors"
+          >
+            Reset Chart
+          </button>
+          <TimeframeSelector />
+        </div>
+      </div>
+      <div className="text-right">
+        <h1 className="text-xl font-bold tracking-wider text-white/90">
+          SOLANA SWAP FLOW
+        </h1>
+        <p className="text-[11px] text-white/40 mt-0.5">
+          Real-time DEX visualization via Helius
+        </p>
+      </div>
+    </div>
+  );
+}

+ 109 - 0
src/components/Sidebar/TokenDetail.tsx

@@ -0,0 +1,109 @@
+'use client';
+
+import { useDashboardStore } from '@/lib/store';
+import { useTokenMetadata } from '@/hooks/useTokenMetadata';
+import { formatNumber, formatUSD, truncateAddress } from '@/lib/utils';
+
+export function TokenDetail() {
+  const selectedToken = useDashboardStore((s) => s.selectedToken);
+  const setSelectedToken = useDashboardStore((s) => s.setSelectedToken);
+  const nodes = useDashboardStore((s) => s.nodes);
+  const meta = useTokenMetadata(selectedToken);
+
+  if (!selectedToken) return null;
+
+  const node = nodes.find((n) => n.id === selectedToken);
+  if (!node) return null;
+
+  return (
+    <div className="border-t border-white/10 pt-3 mt-3">
+      <div className="flex items-center justify-between mb-3">
+        <div className="flex items-center gap-2">
+          {meta?.imageUrl && (
+            <img
+              src={meta.imageUrl}
+              alt={meta.symbol}
+              className="w-8 h-8 rounded-full bg-white/10"
+            />
+          )}
+          <div>
+            <h4 className="text-sm font-bold text-white">
+              {meta?.name || node.symbol}
+            </h4>
+            <p className="text-[10px] text-white/40">
+              {meta?.symbol || node.symbol}
+            </p>
+          </div>
+        </div>
+        <button
+          onClick={() => setSelectedToken(null)}
+          className="text-white/30 hover:text-white/60 text-lg leading-none"
+        >
+          &times;
+        </button>
+      </div>
+
+      <div className="space-y-1.5 text-xs">
+        {meta?.price !== undefined && (
+          <Row label="Price" value={formatUSD(meta.price)} />
+        )}
+        {meta?.supply !== undefined && (
+          <Row label="Supply" value={formatNumber(meta.supply)} />
+        )}
+        {meta?.marketCap !== undefined && (
+          <Row label="Market Cap" value={formatUSD(meta.marketCap)} />
+        )}
+        {meta?.decimals !== undefined && (
+          <Row label="Decimals" value={String(meta.decimals)} />
+        )}
+        <Row label="Swaps (2min)" value={String(node.swapCount)} />
+        <Row
+          label="Inflow Volume"
+          value={formatNumber(node.inflow)}
+          valueClass="text-green-400"
+        />
+        <Row
+          label="Outflow Volume"
+          value={formatNumber(node.outflow)}
+          valueClass="text-red-400"
+        />
+        <Row
+          label="Net Volume"
+          value={formatNumber(Math.abs(node.netFlow))}
+          valueClass={node.netFlow >= 0 ? 'text-green-400' : 'text-red-400'}
+          prefix={node.netFlow >= 0 ? '+' : '-'}
+        />
+        <div className="pt-1">
+          <Row
+            label="Address"
+            value={truncateAddress(selectedToken, 6)}
+            mono
+          />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function Row({
+  label,
+  value,
+  valueClass = 'text-white/80',
+  prefix = '',
+  mono = false,
+}: {
+  label: string;
+  value: string;
+  valueClass?: string;
+  prefix?: string;
+  mono?: boolean;
+}) {
+  return (
+    <div className="flex justify-between items-center">
+      <span className="text-white/40">{label}</span>
+      <span className={`${valueClass} ${mono ? 'font-mono' : ''}`}>
+        {prefix}{value}
+      </span>
+    </div>
+  );
+}

+ 89 - 0
src/components/Sidebar/TopPairs.tsx

@@ -0,0 +1,89 @@
+'use client';
+
+import { useState } from 'react';
+import { useDashboardStore } from '@/lib/store';
+import { SOL_MINT, KNOWN_TOKENS } from '@/lib/helius/constants';
+import { TIMEFRAME_CONFIG } from '@/lib/timeframes';
+
+type SortBy = 'count' | 'volume';
+
+function getSymbol(mint: string, tokenMeta: Record<string, { symbol: string }>): string {
+  if (tokenMeta[mint]) return tokenMeta[mint].symbol;
+  if (KNOWN_TOKENS[mint]) return KNOWN_TOKENS[mint].symbol;
+  return mint.slice(0, 6) + '...';
+}
+
+function formatVolume(v: number): string {
+  if (v >= 1e9) return (v / 1e9).toFixed(1) + 'B';
+  if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
+  if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K';
+  return v.toFixed(0);
+}
+
+export function TopPairs() {
+  const topPairs = useDashboardStore((s) => s.topPairs);
+  const tokenMeta = useDashboardStore((s) => s.tokenMeta);
+  const setSelectedToken = useDashboardStore((s) => s.setSelectedToken);
+  const activeTimeframe = useDashboardStore((s) => s.activeTimeframe);
+  const [sortBy, setSortBy] = useState<SortBy>('count');
+
+  const sorted = [...topPairs].sort((a, b) =>
+    sortBy === 'count' ? b.swapCount - a.swapCount : b.volumeUsd - a.volumeUsd
+  );
+
+  return (
+    <div className="flex flex-col gap-1">
+      <div className="flex items-center justify-between px-1 mb-1">
+        <h3 className="text-xs font-bold text-white/60 tracking-widest">
+          TOP PAIRS ({TIMEFRAME_CONFIG[activeTimeframe].label.toUpperCase()})
+        </h3>
+        <div className="flex gap-0.5 bg-white/5 rounded p-0.5">
+          <button
+            className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
+              sortBy === 'count' ? 'bg-white/15 text-white' : 'text-white/40 hover:text-white/60'
+            }`}
+            onClick={() => setSortBy('count')}
+          >
+            Txns
+          </button>
+          <button
+            className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
+              sortBy === 'volume' ? 'bg-white/15 text-white' : 'text-white/40 hover:text-white/60'
+            }`}
+            onClick={() => setSortBy('volume')}
+          >
+            Vol
+          </button>
+        </div>
+      </div>
+      {sorted.length === 0 && (
+        <p className="text-xs text-white/30 px-1">Waiting for data...</p>
+      )}
+      {sorted.map((pair, i) => {
+        const symA = getSymbol(pair.mintA, tokenMeta);
+        const symB = getSymbol(pair.mintB, tokenMeta);
+        return (
+          <div
+            key={`${pair.mintA}-${pair.mintB}`}
+            className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-white/5 cursor-pointer text-xs"
+            onClick={() => setSelectedToken(pair.mintA === SOL_MINT ? pair.mintB : pair.mintA)}
+          >
+            <span className="text-white/30 w-4 text-right font-mono">{i + 1}</span>
+            <span className="flex items-center gap-1 flex-1 min-w-0">
+              <span className="px-1.5 py-0.5 rounded bg-white/10 text-white/80 font-mono truncate text-[11px]">
+                {symA}
+              </span>
+              <span className="text-white/30">-</span>
+              <span className="px-1.5 py-0.5 rounded bg-white/10 text-white/80 font-mono truncate text-[11px]">
+                {symB}
+              </span>
+            </span>
+            <span className="text-white/40 font-mono text-[10px]">
+              {sortBy === 'count' ? `${pair.swapCount}x` : `$${formatVolume(pair.volumeUsd)}`}
+            </span>
+          </div>
+        );
+      })}
+    </div>
+  );
+}

+ 34 - 0
src/components/TimeframeSelector.tsx

@@ -0,0 +1,34 @@
+'use client';
+
+import { useDashboardStore } from '@/lib/store';
+import { TIMEFRAME_CONFIG, type Timeframe } from '@/lib/timeframes';
+
+const TIMEFRAMES = Object.entries(TIMEFRAME_CONFIG) as [Timeframe, { label: string; ms: number }][];
+
+export function TimeframeSelector() {
+  const activeTimeframe = useDashboardStore((s) => s.activeTimeframe);
+  const setTimeframe = useDashboardStore((s) => s.setTimeframe);
+
+  return (
+    <div className="flex items-center gap-1.5">
+      <span className="text-[10px] text-white/30 uppercase tracking-widest mr-1">Window</span>
+      {TIMEFRAMES.map(([key, config]) => {
+        const isActive = activeTimeframe === key;
+        return (
+          <button
+            key={key}
+            onClick={() => setTimeframe(key)}
+            className={`
+              px-2 py-1 text-xs rounded border transition-all
+              ${isActive
+                ? 'bg-white/10 border-white/30 text-white/80'
+                : 'bg-transparent border-white/5 text-white/30 hover:bg-white/5'}
+            `}
+          >
+            {config.label}
+          </button>
+        );
+      })}
+    </div>
+  );
+}

+ 77 - 0
src/hooks/useSwapStream.ts

@@ -0,0 +1,77 @@
+'use client';
+
+import { useEffect, useRef, useCallback } from 'react';
+import { useDashboardStore } from '@/lib/store';
+import type { SwapEvent } from '@/lib/helius/parseSwap';
+import type { TokenMetadata } from '@/lib/helius/tokenMetadata';
+
+// Track which mints we've already requested metadata for
+const requestedMints = new Set<string>();
+
+export function useSwapStream() {
+  const addSwaps = useDashboardStore((s) => s.addSwaps);
+  const setConnected = useDashboardStore((s) => s.setConnected);
+  const pruneOld = useDashboardStore((s) => s.pruneOld);
+  const activeFilter = useDashboardStore((s) => s.activeFilter);
+  const setTokenMeta = useDashboardStore((s) => s.setTokenMeta);
+  const tokenMeta = useDashboardStore((s) => s.tokenMeta);
+  const eventSourceRef = useRef<EventSource | null>(null);
+
+  const fetchMeta = useCallback(async (mint: string) => {
+    if (requestedMints.has(mint)) return;
+    requestedMints.add(mint);
+    try {
+      const res = await fetch(`/api/token?mint=${mint}`);
+      if (res.ok) {
+        const data: TokenMetadata = await res.json();
+        setTokenMeta(mint, data);
+      }
+    } catch {}
+  }, [setTokenMeta]);
+
+  useEffect(() => {
+    if (eventSourceRef.current) {
+      eventSourceRef.current.close();
+    }
+
+    const url = `/api/stream?filter=${activeFilter}`;
+    const es = new EventSource(url);
+    eventSourceRef.current = es;
+
+    es.addEventListener('status', (e) => {
+      const data = JSON.parse(e.data);
+      setConnected(data.connected);
+    });
+
+    es.addEventListener('swaps', (e) => {
+      const swaps: SwapEvent[] = JSON.parse(e.data);
+      addSwaps(swaps);
+
+      // Fetch metadata for new mints
+      const newMints = new Set<string>();
+      for (const swap of swaps) {
+        if (!requestedMints.has(swap.tokenIn.mint)) newMints.add(swap.tokenIn.mint);
+        if (!requestedMints.has(swap.tokenOut.mint)) newMints.add(swap.tokenOut.mint);
+      }
+      // Batch fetch (limit to 5 concurrent to avoid rate limits)
+      const mintsToFetch = Array.from(newMints).slice(0, 5);
+      for (const mint of mintsToFetch) {
+        fetchMeta(mint);
+      }
+    });
+
+    es.onerror = () => {
+      setConnected(false);
+    };
+
+    return () => {
+      es.close();
+    };
+  }, [activeFilter, addSwaps, setConnected, fetchMeta]);
+
+  // Prune old swaps every second
+  useEffect(() => {
+    const interval = setInterval(pruneOld, 1000);
+    return () => clearInterval(interval);
+  }, [pruneOld]);
+}

+ 23 - 0
src/hooks/useTokenMetadata.ts

@@ -0,0 +1,23 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useDashboardStore } from '@/lib/store';
+import type { TokenMetadata } from '@/lib/helius/tokenMetadata';
+
+export function useTokenMetadata(mint: string | null) {
+  const meta = useDashboardStore((s) => (mint ? s.tokenMeta[mint] : null));
+  const setTokenMeta = useDashboardStore((s) => s.setTokenMeta);
+
+  useEffect(() => {
+    if (!mint || meta) return;
+
+    fetch(`/api/token?mint=${mint}`)
+      .then((r) => r.json())
+      .then((data: TokenMetadata) => {
+        setTokenMeta(mint, data);
+      })
+      .catch(console.error);
+  }, [mint, meta, setTokenMeta]);
+
+  return meta;
+}

+ 145 - 0
src/lib/db/index.ts

@@ -0,0 +1,145 @@
+import Database from 'better-sqlite3';
+import path from 'path';
+import type { SwapEvent } from '../helius/parseSwap';
+import type { AMMSource } from '../helius/constants';
+
+const DATA_DIR = process.env.DATA_DIR || process.cwd();
+const DB_PATH = path.join(DATA_DIR, 'swaps.db');
+const RETENTION_MS = 24 * 60 * 60 * 1000; // 24 hours
+const PRUNE_INTERVAL_MS = 5 * 60 * 1000;  // 5 minutes
+
+// Singleton via globalThis (same pattern as batchParser.ts / websocket.ts)
+const g = globalThis as any;
+if (!g.__swapDb) {
+  const db = new Database(DB_PATH);
+
+  // Performance pragmas
+  db.pragma('journal_mode = WAL');
+  db.pragma('synchronous = NORMAL');
+  db.pragma('cache_size = -64000'); // 64MB
+  db.pragma('temp_store = MEMORY');
+
+  db.exec(`
+    CREATE TABLE IF NOT EXISTS swaps (
+      signature TEXT PRIMARY KEY,
+      timestamp INTEGER NOT NULL,
+      source TEXT NOT NULL,
+      token_in_mint TEXT NOT NULL,
+      token_in_amount REAL NOT NULL,
+      token_out_mint TEXT NOT NULL,
+      token_out_amount REAL NOT NULL
+    )
+  `);
+
+  db.exec(`
+    CREATE INDEX IF NOT EXISTS idx_swaps_timestamp ON swaps(timestamp);
+    CREATE INDEX IF NOT EXISTS idx_swaps_ts_source ON swaps(timestamp, source);
+  `);
+
+  g.__swapDb = {
+    db,
+    pruneIntervalId: null as ReturnType<typeof setInterval> | null,
+    insertStmt: db.prepare(`
+      INSERT OR IGNORE INTO swaps
+        (signature, timestamp, source, token_in_mint, token_in_amount, token_out_mint, token_out_amount)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
+    `),
+    queryStmt: db.prepare(`
+      SELECT signature, timestamp, source,
+             token_in_mint, token_in_amount,
+             token_out_mint, token_out_amount
+      FROM swaps
+      WHERE timestamp >= ?
+      ORDER BY timestamp ASC
+    `),
+    queryWithSourceStmt: db.prepare(`
+      SELECT signature, timestamp, source,
+             token_in_mint, token_in_amount,
+             token_out_mint, token_out_amount
+      FROM swaps
+      WHERE timestamp >= ? AND source = ?
+      ORDER BY timestamp ASC
+    `),
+    pruneStmt: db.prepare(`DELETE FROM swaps WHERE timestamp < ?`),
+    countStmt: db.prepare(`SELECT COUNT(*) as count FROM swaps`),
+  };
+
+  console.log('[db] SQLite database initialized at', DB_PATH);
+}
+
+const state = g.__swapDb;
+
+// --- Write ---
+
+export function insertSwapBatch(swaps: SwapEvent[]): void {
+  const insertMany = state.db.transaction((items: SwapEvent[]) => {
+    for (const swap of items) {
+      state.insertStmt.run(
+        swap.signature,
+        swap.timestamp,
+        swap.source,
+        swap.tokenIn.mint,
+        swap.tokenIn.amount,
+        swap.tokenOut.mint,
+        swap.tokenOut.amount
+      );
+    }
+  });
+  insertMany(swaps);
+}
+
+// --- Read ---
+
+interface SwapRow {
+  signature: string;
+  timestamp: number;
+  source: string;
+  token_in_mint: string;
+  token_in_amount: number;
+  token_out_mint: string;
+  token_out_amount: number;
+}
+
+function rowToSwapEvent(row: SwapRow): SwapEvent {
+  return {
+    signature: row.signature,
+    timestamp: row.timestamp,
+    source: row.source as SwapEvent['source'],
+    tokenIn: { mint: row.token_in_mint, amount: row.token_in_amount },
+    tokenOut: { mint: row.token_out_mint, amount: row.token_out_amount },
+  };
+}
+
+export function querySwaps(sinceMs: number, source?: AMMSource): SwapEvent[] {
+  let rows: SwapRow[];
+  if (source && source !== ('All' as any)) {
+    rows = state.queryWithSourceStmt.all(sinceMs, source) as SwapRow[];
+  } else {
+    rows = state.queryStmt.all(sinceMs) as SwapRow[];
+  }
+  return rows.map(rowToSwapEvent);
+}
+
+// --- Retention ---
+
+export function pruneOldSwaps(): number {
+  const cutoff = Date.now() - RETENTION_MS;
+  const result = state.pruneStmt.run(cutoff);
+  return result.changes;
+}
+
+export function startPruneJob(): void {
+  if (state.pruneIntervalId) return;
+  pruneOldSwaps();
+  state.pruneIntervalId = setInterval(() => {
+    const deleted = pruneOldSwaps();
+    if (deleted > 0) {
+      console.log(`[db] Pruned ${deleted} swaps older than 24h`);
+    }
+  }, PRUNE_INTERVAL_MS);
+}
+
+export function getSwapCount(): number {
+  const row = state.countStmt.get() as { count: number };
+  return row.count;
+}

+ 28 - 0
src/lib/db/writer.ts

@@ -0,0 +1,28 @@
+import { onSwapBatch } from '../helius/batchParser';
+import { isMock, onMockSwapBatch } from '../helius/websocket';
+import { insertSwapBatch, startPruneJob } from './index';
+import type { SwapEvent } from '../helius/parseSwap';
+
+const g = globalThis as any;
+
+export function initDbWriter(): void {
+  if (g.__dbWriterInitialized) return;
+  g.__dbWriterInitialized = true;
+
+  const handleBatch = (swaps: SwapEvent[]) => {
+    try {
+      insertSwapBatch(swaps);
+    } catch (err: any) {
+      console.error('[db-writer] Insert failed:', err?.message || err);
+    }
+  };
+
+  if (isMock()) {
+    onMockSwapBatch(handleBatch);
+  } else {
+    onSwapBatch(handleBatch);
+  }
+
+  startPruneJob();
+  console.log('[db-writer] Database writer initialized');
+}

+ 138 - 0
src/lib/graph/aggregator.ts

@@ -0,0 +1,138 @@
+import type { SwapEvent } from '../helius/parseSwap';
+import type { AMMSource } from '../helius/constants';
+import { ROLLING_WINDOW_MS, SOL_MINT, USDC_MINT } from '../helius/constants';
+
+const HUB_MINTS = new Set([SOL_MINT, USDC_MINT]);
+
+export interface AggregatedToken {
+  mint: string;
+  inflow: number;   // volume flowing into this token (buys)
+  outflow: number;  // volume flowing out of this token (sells)
+  netFlow: number;
+  swapCount: number;
+  volumeUsd: number; // USD volume for this token
+}
+
+export interface TokenPair {
+  mintA: string;
+  mintB: string;
+  swapCount: number;
+  volume: number;
+  volumeUsd: number;
+}
+
+export interface AggregationResult {
+  tokens: Map<string, AggregatedToken>;
+  pairs: TokenPair[];
+  totalSwaps: number;
+  uniqueTokens: number;
+}
+
+const MAX_DISPLAY_TOKENS = 100;
+const MAX_DISPLAY_PAIRS = 30;
+
+// Compute USD value for a swap: use the SOL or USDC side
+function swapUsdValue(swap: SwapEvent, solPrice: number): number {
+  const { tokenIn, tokenOut } = swap;
+  // If one side is USDC, that amount IS USD
+  if (tokenIn.mint === USDC_MINT) return tokenIn.amount;
+  if (tokenOut.mint === USDC_MINT) return tokenOut.amount;
+  // If one side is SOL, multiply by SOL price
+  if (tokenIn.mint === SOL_MINT) return tokenIn.amount * solPrice;
+  if (tokenOut.mint === SOL_MINT) return tokenOut.amount * solPrice;
+  return 0;
+}
+
+export function aggregateSwaps(
+  swaps: SwapEvent[],
+  filter: AMMSource = 'All',
+  windowMs?: number,
+  solPrice: number = 130
+): AggregationResult {
+  const now = Date.now();
+  const windowStart = now - (windowMs ?? ROLLING_WINDOW_MS);
+
+  const tokenMap = new Map<string, AggregatedToken>();
+  const pairMap = new Map<string, TokenPair>();
+
+  const filtered = swaps.filter((s) => {
+    if (s.timestamp < windowStart) return false;
+    if (filter !== 'All' && s.source !== filter) return false;
+    // Only keep swaps involving SOL or USDC on at least one side
+    if (!HUB_MINTS.has(s.tokenIn.mint) && !HUB_MINTS.has(s.tokenOut.mint)) return false;
+    return true;
+  });
+
+  for (const swap of filtered) {
+    const { tokenIn, tokenOut } = swap;
+    const usdValue = swapUsdValue(swap, solPrice);
+
+    // Token In: outflow (user is selling this token)
+    if (!tokenMap.has(tokenIn.mint)) {
+      tokenMap.set(tokenIn.mint, { mint: tokenIn.mint, inflow: 0, outflow: 0, netFlow: 0, swapCount: 0, volumeUsd: 0 });
+    }
+    const inToken = tokenMap.get(tokenIn.mint)!;
+    inToken.outflow += tokenIn.amount;
+    inToken.swapCount++;
+    inToken.volumeUsd += usdValue;
+
+    // Token Out: inflow (user is buying this token)
+    if (!tokenMap.has(tokenOut.mint)) {
+      tokenMap.set(tokenOut.mint, { mint: tokenOut.mint, inflow: 0, outflow: 0, netFlow: 0, swapCount: 0, volumeUsd: 0 });
+    }
+    const outToken = tokenMap.get(tokenOut.mint)!;
+    outToken.inflow += tokenOut.amount;
+    outToken.swapCount++;
+    outToken.volumeUsd += usdValue;
+
+    // Update net flows
+    inToken.netFlow = inToken.inflow - inToken.outflow;
+    outToken.netFlow = outToken.inflow - outToken.outflow;
+
+    // Pair tracking
+    const [a, b] = [tokenIn.mint, tokenOut.mint].sort();
+    const pairKey = `${a}:${b}`;
+    if (!pairMap.has(pairKey)) {
+      pairMap.set(pairKey, { mintA: a, mintB: b, swapCount: 0, volume: 0, volumeUsd: 0 });
+    }
+    const pair = pairMap.get(pairKey)!;
+    pair.swapCount++;
+    pair.volume += Math.max(tokenIn.amount, tokenOut.amount);
+    pair.volumeUsd += usdValue;
+  }
+
+  // Top 100 tokens by USD volume — SOL always included
+  if (tokenMap.size > MAX_DISPLAY_TOKENS) {
+    const sorted = Array.from(tokenMap.entries())
+      .filter(([mint]) => mint !== SOL_MINT)
+      .sort((a, b) => b[1].volumeUsd - a[1].volumeUsd);
+
+    const keepMints = new Set<string>([SOL_MINT]);
+    for (let i = 0; i < MAX_DISPLAY_TOKENS - 1 && i < sorted.length; i++) {
+      keepMints.add(sorted[i][0]);
+    }
+
+    for (const mint of tokenMap.keys()) {
+      if (!keepMints.has(mint)) {
+        tokenMap.delete(mint);
+      }
+    }
+  }
+
+  // Top pairs sorted by swap count descending
+  const pairs = Array.from(pairMap.values())
+    .sort((a, b) => b.swapCount - a.swapCount)
+    .slice(0, MAX_DISPLAY_PAIRS);
+
+  return {
+    tokens: tokenMap,
+    pairs,
+    totalSwaps: filtered.length,
+    uniqueTokens: tokenMap.size,
+  };
+}
+
+export function pruneSwapBuffer(swaps: SwapEvent[], windowMs?: number): SwapEvent[] {
+  const cutoff = Date.now() - (windowMs ?? ROLLING_WINDOW_MS);
+  return swaps.filter((s) => s.timestamp >= cutoff);
+}

+ 174 - 0
src/lib/graph/graphBuilder.ts

@@ -0,0 +1,174 @@
+import { SOL_MINT, USDC_MINT } from '../helius/constants';
+import type { AggregationResult } from './aggregator';
+
+export interface GraphNode {
+  id: string;
+  symbol: string;
+  inflow: number;
+  outflow: number;
+  netFlow: number;
+  swapCount: number;
+  radius: number;
+  color: string;
+  x?: number;
+  y?: number;
+  vx?: number;
+  vy?: number;
+  fx?: number | null;
+  fy?: number | null;
+}
+
+export interface GraphLink {
+  source: string;
+  target: string;
+  swapCount: number;
+  volume: number;
+  width: number;
+}
+
+const MIN_RADIUS = 3;
+const MAX_RADIUS = 35;
+const SOL_RADIUS = 30;
+const USDC_MIN_RADIUS = 12;
+const MIN_LINK_WIDTH = 0.5;
+const LINK_WIDTH_FACTOR = 1.0;
+
+// Distinct color palette for non-hub tokens
+const TOKEN_COLORS = [
+  '#3b82f6', // blue
+  '#f59e0b', // amber
+  '#8b5cf6', // violet
+  '#06b6d4', // cyan
+  '#f97316', // orange
+  '#ec4899', // pink
+  '#14b8a6', // teal
+  '#a855f7', // purple
+  '#eab308', // yellow
+  '#6366f1', // indigo
+  '#ef4444', // red
+  '#22c55e', // green
+  '#e879f9', // fuchsia
+  '#2dd4bf', // emerald
+  '#fb923c', // light-orange
+  '#818cf8', // light-indigo
+  '#34d399', // light-green
+  '#fbbf24', // light-amber
+  '#c084fc', // light-purple
+  '#67e8f9', // light-cyan
+];
+
+// Deterministic color from mint address
+function hashMintColor(mint: string): string {
+  let hash = 0;
+  for (let i = 0; i < mint.length; i++) {
+    hash = ((hash << 5) - hash + mint.charCodeAt(i)) | 0;
+  }
+  return TOKEN_COLORS[Math.abs(hash) % TOKEN_COLORS.length];
+}
+
+// Known token symbols for mock/common tokens
+const MINT_SYMBOLS: Record<string, string> = {
+  [SOL_MINT]: 'SOL',
+  'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': 'USDC',
+  'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': 'USDT',
+  'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263': 'BONK',
+  'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm': 'WIF',
+  'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': 'JUP',
+  '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R': 'RAY',
+  'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE': 'ORCA',
+  'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3': 'PYTH',
+  'rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof': 'RENDER',
+  'hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux': 'HNT',
+  'mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So': 'mSOL',
+  'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn': 'JITO',
+};
+
+let symbolCache: Record<string, string> = { ...MINT_SYMBOLS };
+
+export function setSymbolForMint(mint: string, symbol: string) {
+  symbolCache[mint] = symbol;
+}
+
+function getSymbol(mint: string): string {
+  return symbolCache[mint] || mint.slice(0, 4) + '...';
+}
+
+export function buildGraph(
+  agg: AggregationResult,
+  existingNodes?: Map<string, GraphNode>
+): { nodes: GraphNode[]; links: GraphLink[] } {
+  const nodes: GraphNode[] = [];
+  const links: GraphLink[] = [];
+
+  // Find max USD volume for relative sizing
+  let maxVolumeUsd = 1;
+  for (const token of agg.tokens.values()) {
+    if (token.mint !== SOL_MINT && token.volumeUsd > maxVolumeUsd) {
+      maxVolumeUsd = token.volumeUsd;
+    }
+  }
+
+  // Build nodes from aggregated tokens
+  for (const [mint, token] of agg.tokens) {
+    const isSol = mint === SOL_MINT;
+    const isUsdc = mint === USDC_MINT;
+
+    // Radius: relative to max USD volume using power scale
+    let radius: number;
+    if (isSol) {
+      radius = SOL_RADIUS;
+    } else {
+      const ratio = token.volumeUsd / maxVolumeUsd; // 0..1
+      const scaled = Math.pow(ratio, 0.35); // power < 1 spreads small values apart
+      radius = MIN_RADIUS + scaled * (MAX_RADIUS - MIN_RADIUS);
+      if (isUsdc) radius = Math.max(USDC_MIN_RADIUS, radius);
+    }
+
+    // Color: SOL = gold, USDC = bright blue, others = palette color
+    let color: string;
+    if (isSol) {
+      color = '#b380ff'; // purple for SOL (icon will overlay)
+    } else if (isUsdc) {
+      color = '#2775ca'; // USDC brand blue
+    } else {
+      color = hashMintColor(mint);
+    }
+
+    const existing = existingNodes?.get(mint);
+
+    const node: GraphNode = {
+      id: mint,
+      symbol: getSymbol(mint),
+      inflow: token.inflow,
+      outflow: token.outflow,
+      netFlow: token.netFlow,
+      swapCount: token.swapCount,
+      radius,
+      color,
+      x: existing?.x,
+      y: existing?.y,
+      vx: existing?.vx,
+      vy: existing?.vy,
+      fx: isSol ? undefined : existing?.fx,
+      fy: isSol ? undefined : existing?.fy,
+    };
+
+    nodes.push(node);
+  }
+
+  // Build links from pairs
+  for (const pair of agg.pairs) {
+    // Only include if both nodes exist
+    if (agg.tokens.has(pair.mintA) && agg.tokens.has(pair.mintB)) {
+      links.push({
+        source: pair.mintA,
+        target: pair.mintB,
+        swapCount: pair.swapCount,
+        volume: pair.volume,
+        width: Math.max(MIN_LINK_WIDTH, MIN_LINK_WIDTH + Math.log(1 + pair.swapCount) * LINK_WIDTH_FACTOR),
+      });
+    }
+  }
+
+  return { nodes, links };
+}

+ 160 - 0
src/lib/helius/batchParser.ts

@@ -0,0 +1,160 @@
+import { HELIUS_REST_URL } from './constants';
+import { parseHeliusTx, type SwapEvent } from './parseSwap';
+
+// Use globalThis to survive HMR in Next.js dev mode
+const g = globalThis as any;
+if (!g.__batchParser) {
+  g.__batchParser = {
+    pendingSignatures: new Set<string>(),
+    processedSignatures: new Set<string>(),
+    swapListeners: [] as Array<(swaps: SwapEvent[]) => void>,
+    intervalId: null as ReturnType<typeof setInterval> | null,
+    consecutiveErrors: 0,
+    processFn: null as (() => Promise<void>) | null,
+  };
+}
+
+const state = g.__batchParser;
+const BATCH_SIZE = 100;           // Helius supports up to 100
+const BATCHES_PER_TICK = 3;       // Process up to 3 batches per interval
+const MAX_PENDING = 3000;         // Cap pending queue to stay current
+const MAX_PROCESSED_CACHE = 10000;
+
+export function addSignature(signature: string) {
+  if (!state.processedSignatures.has(signature)) {
+    state.pendingSignatures.add(signature);
+  }
+
+  // If pending queue is too large, drop oldest to stay current
+  if (state.pendingSignatures.size > MAX_PENDING) {
+    const toDrop = state.pendingSignatures.size - MAX_PENDING;
+    const iter = state.pendingSignatures.values();
+    for (let i = 0; i < toDrop; i++) {
+      const v = iter.next().value;
+      if (v) {
+        state.pendingSignatures.delete(v);
+        state.processedSignatures.add(v);
+      }
+    }
+  }
+}
+
+export function onSwapBatch(listener: (swaps: SwapEvent[]) => void) {
+  state.swapListeners.push(listener);
+  return () => {
+    state.swapListeners = state.swapListeners.filter((l: any) => l !== listener);
+  };
+}
+
+async function fetchAndParseBatch(batch: string[]): Promise<SwapEvent[]> {
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), 10000);
+
+  try {
+    const res = await fetch(HELIUS_REST_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ transactions: batch }),
+      signal: controller.signal,
+    });
+
+    clearTimeout(timeout);
+
+    if (!res.ok) {
+      state.consecutiveErrors++;
+      console.error(`[batch] HTTP ${res.status}`);
+      return [];
+    }
+
+    const parsed = await res.json();
+    const swaps: SwapEvent[] = [];
+    for (const tx of parsed) {
+      const swap = parseHeliusTx(tx);
+      if (swap) swaps.push(swap);
+    }
+
+    state.consecutiveErrors = 0;
+    return swaps;
+  } catch (err: any) {
+    clearTimeout(timeout);
+    state.consecutiveErrors++;
+    if (err?.name !== 'AbortError') {
+      console.error(`[batch] Error (${state.consecutiveErrors}):`, err?.message || err);
+    }
+    return [];
+  }
+}
+
+async function processBatch() {
+  if (state.pendingSignatures.size === 0) return;
+
+  console.log(`[batch] pending=${state.pendingSignatures.size} errors=${state.consecutiveErrors} listeners=${state.swapListeners.length}`);
+
+  if (state.consecutiveErrors > 5) {
+    // Drop pending to avoid buildup during errors
+    const toDrop = Math.min(state.pendingSignatures.size, 200);
+    const iter = state.pendingSignatures.values();
+    for (let i = 0; i < toDrop; i++) {
+      const v = iter.next().value;
+      if (v) {
+        state.pendingSignatures.delete(v);
+        state.processedSignatures.add(v);
+      }
+    }
+    state.consecutiveErrors = Math.max(0, state.consecutiveErrors - 1);
+    return;
+  }
+
+  // Process up to BATCHES_PER_TICK batches concurrently
+  const batchCount = Math.min(BATCHES_PER_TICK, Math.ceil(state.pendingSignatures.size / BATCH_SIZE));
+  const batches: string[][] = [];
+
+  for (let b = 0; b < batchCount; b++) {
+    const batch = Array.from(state.pendingSignatures).slice(0, BATCH_SIZE);
+    if (batch.length === 0) break;
+    for (const sig of batch) {
+      state.pendingSignatures.delete(sig);
+      state.processedSignatures.add(sig);
+    }
+    batches.push(batch);
+  }
+
+  // Trim processed cache
+  if (state.processedSignatures.size > MAX_PROCESSED_CACHE) {
+    const iter = state.processedSignatures.values();
+    for (let i = 0; i < 2000; i++) {
+      const v = iter.next().value;
+      if (v) state.processedSignatures.delete(v);
+    }
+  }
+
+  // Fire all batch requests concurrently
+  const results = await Promise.all(batches.map(fetchAndParseBatch));
+  const allSwaps = results.flat();
+
+  console.log(`[batch] fetched=${batches.length}x${BATCH_SIZE} swaps=${allSwaps.length}`);
+
+  if (allSwaps.length > 0) {
+    for (const listener of state.swapListeners) {
+      try { listener(allSwaps); } catch {}
+    }
+  }
+}
+
+// Store current processBatch on globalThis so interval always calls latest version
+state.processFn = processBatch;
+
+export function startBatchParser(intervalMs = 1500) {
+  state.processFn = processBatch;
+
+  if (state.intervalId) return;
+  state.intervalId = setInterval(() => state.processFn(), intervalMs);
+  console.log(`[batch] Parser started (interval: ${intervalMs}ms, batch: ${BATCH_SIZE}x${BATCHES_PER_TICK})`);
+}
+
+export function stopBatchParser() {
+  if (state.intervalId) {
+    clearInterval(state.intervalId);
+    state.intervalId = null;
+  }
+}

+ 47 - 0
src/lib/helius/constants.ts

@@ -0,0 +1,47 @@
+export const SOL_MINT = 'So11111111111111111111111111111111111111112';
+export const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
+
+export const DEX_PROGRAMS: Record<string, string> = {
+  PUMP_FUN: '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P',
+  PUMP_SWAP: 'pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA',
+  RAYDIUM_AMM_V4: '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8',
+  RAYDIUM_CPMM: 'CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C',
+  RAYDIUM_CLMM: 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK',
+  METEORA_DLMM: 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo',
+  METEORA_DAMM_V2: 'cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG',
+  ORCA_WHIRLPOOL: 'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc',
+  JUPITER_V6: 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4',
+};
+
+export type AMMSource = 'All' | 'Pump' | 'Raydium' | 'Meteora' | 'Orca' | 'Jupiter';
+
+const PROGRAM_TO_AMM: Record<string, AMMSource> = {
+  [DEX_PROGRAMS.PUMP_FUN]: 'Pump',
+  [DEX_PROGRAMS.PUMP_SWAP]: 'Pump',
+  [DEX_PROGRAMS.RAYDIUM_AMM_V4]: 'Raydium',
+  [DEX_PROGRAMS.RAYDIUM_CPMM]: 'Raydium',
+  [DEX_PROGRAMS.RAYDIUM_CLMM]: 'Raydium',
+  [DEX_PROGRAMS.METEORA_DLMM]: 'Meteora',
+  [DEX_PROGRAMS.METEORA_DAMM_V2]: 'Meteora',
+  [DEX_PROGRAMS.ORCA_WHIRLPOOL]: 'Orca',
+  [DEX_PROGRAMS.JUPITER_V6]: 'Jupiter',
+};
+
+export function programToAMM(programId: string): AMMSource {
+  return PROGRAM_TO_AMM[programId] || 'Jupiter';
+}
+
+export const KNOWN_TOKENS: Record<string, { symbol: string; name: string; decimals: number }> = {
+  [SOL_MINT]: { symbol: 'SOL', name: 'Wrapped SOL', decimals: 9 },
+  'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': { symbol: 'USDC', name: 'USD Coin', decimals: 6 },
+  'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB': { symbol: 'USDT', name: 'Tether USD', decimals: 6 },
+  'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263': { symbol: 'BONK', name: 'Bonk', decimals: 5 },
+  'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN': { symbol: 'JUP', name: 'Jupiter', decimals: 6 },
+};
+
+export const HELIUS_WS_URL = `wss://mainnet.helius-rpc.com/?api-key=${process.env.HELIUS_API_KEY}`;
+export const HELIUS_REST_URL = `https://api-mainnet.helius-rpc.com/v0/transactions?api-key=${process.env.HELIUS_API_KEY}`;
+export const HELIUS_RPC_URL = `https://mainnet.helius-rpc.com/?api-key=${process.env.HELIUS_API_KEY}`;
+
+export const ROLLING_WINDOW_MS = 2 * 60 * 1000; // 2 minutes
+export const BATCH_PARSE_INTERVAL_MS = 1500; // 1.5 seconds

+ 101 - 0
src/lib/helius/parseSwap.ts

@@ -0,0 +1,101 @@
+import { programToAMM, SOL_MINT, type AMMSource } from './constants';
+
+export interface SwapEvent {
+  signature: string;
+  timestamp: number;
+  source: AMMSource;
+  tokenIn: { mint: string; amount: number };
+  tokenOut: { mint: string; amount: number };
+}
+
+interface HeliusParsedTx {
+  signature: string;
+  timestamp: number;
+  type: string;
+  source: string;
+  description: string;
+  accountData: Array<{
+    account: string;
+    nativeBalanceChange: number;
+    tokenBalanceChanges: Array<{
+      mint: string;
+      rawTokenAmount: { tokenAmount: string; decimals: number };
+      userAccount: string;
+    }>;
+  }>;
+  events: {
+    swap?: {
+      nativeInput?: { account: string; amount: string };
+      nativeOutput?: { account: string; amount: string };
+      tokenInputs: Array<{ mint: string; rawTokenAmount: { tokenAmount: string; decimals: number }; userAccount: string }>;
+      tokenOutputs: Array<{ mint: string; rawTokenAmount: { tokenAmount: string; decimals: number }; userAccount: string }>;
+      innerSwaps?: Array<{
+        programInfo: { source: string; account: string };
+        tokenInputs: Array<{ mint: string; rawTokenAmount: { tokenAmount: string; decimals: number } }>;
+        tokenOutputs: Array<{ mint: string; rawTokenAmount: { tokenAmount: string; decimals: number } }>;
+        nativeInput?: { account: string; amount: string };
+        nativeOutput?: { account: string; amount: string };
+      }>;
+    };
+  };
+}
+
+export function parseHeliusTx(tx: HeliusParsedTx): SwapEvent | null {
+  if (tx.type !== 'SWAP' || !tx.events?.swap) return null;
+
+  const swap = tx.events.swap;
+  let tokenInMint: string | null = null;
+  let tokenInAmount = 0;
+  let tokenOutMint: string | null = null;
+  let tokenOutAmount = 0;
+
+  // Native SOL input
+  if (swap.nativeInput && Number(swap.nativeInput.amount) > 0) {
+    tokenInMint = SOL_MINT;
+    tokenInAmount = Number(swap.nativeInput.amount) / 1e9;
+  }
+
+  // Token inputs
+  if (swap.tokenInputs?.length > 0) {
+    const input = swap.tokenInputs[0];
+    tokenInMint = input.mint;
+    tokenInAmount = Number(input.rawTokenAmount.tokenAmount) / Math.pow(10, input.rawTokenAmount.decimals);
+  }
+
+  // Native SOL output
+  if (swap.nativeOutput && Number(swap.nativeOutput.amount) > 0) {
+    tokenOutMint = SOL_MINT;
+    tokenOutAmount = Number(swap.nativeOutput.amount) / 1e9;
+  }
+
+  // Token outputs
+  if (swap.tokenOutputs?.length > 0) {
+    const output = swap.tokenOutputs[0];
+    tokenOutMint = output.mint;
+    tokenOutAmount = Number(output.rawTokenAmount.tokenAmount) / Math.pow(10, output.rawTokenAmount.decimals);
+  }
+
+  if (!tokenInMint || !tokenOutMint) return null;
+
+  // Determine AMM source from inner swaps or tx source
+  let source: AMMSource = 'Jupiter';
+  if (swap.innerSwaps?.length) {
+    const programId = swap.innerSwaps[0].programInfo?.account;
+    if (programId) source = programToAMM(programId);
+  } else if (tx.source) {
+    const srcLower = tx.source.toLowerCase();
+    if (srcLower.includes('pump')) source = 'Pump';
+    else if (srcLower.includes('raydium')) source = 'Raydium';
+    else if (srcLower.includes('meteora')) source = 'Meteora';
+    else if (srcLower.includes('orca') || srcLower.includes('whirlpool')) source = 'Orca';
+    else if (srcLower.includes('jupiter')) source = 'Jupiter';
+  }
+
+  return {
+    signature: tx.signature,
+    timestamp: tx.timestamp * 1000,
+    source,
+    tokenIn: { mint: tokenInMint, amount: tokenInAmount },
+    tokenOut: { mint: tokenOutMint, amount: tokenOutAmount },
+  };
+}

+ 37 - 0
src/lib/helius/solPrice.ts

@@ -0,0 +1,37 @@
+// Fetch SOL/USD price from Jupiter Price API, cached for 30s
+let cachedPrice = 0;
+let lastFetch = 0;
+const CACHE_MS = 10 * 60 * 1000; // 10 minutes
+
+export async function getSolPrice(): Promise<number> {
+  const now = Date.now();
+  if (cachedPrice > 0 && now - lastFetch < CACHE_MS) return cachedPrice;
+
+  try {
+    const res = await fetch(
+      'https://api.jup.ag/price/v2?ids=So11111111111111111111111111111111111111112',
+      { signal: AbortSignal.timeout(5000) }
+    );
+    const data = await res.json();
+    const price = Number(data?.data?.['So11111111111111111111111111111111111111112']?.price);
+    if (price > 0) {
+      cachedPrice = price;
+      lastFetch = now;
+    }
+  } catch {
+    // Keep stale price on failure
+  }
+
+  return cachedPrice || 130; // fallback ~$130
+}
+
+// Synchronous access to last known price (for client-side use)
+let clientSolPrice = 130;
+
+export function setSolPrice(price: number) {
+  if (price > 0) clientSolPrice = price;
+}
+
+export function getClientSolPrice(): number {
+  return clientSolPrice;
+}

+ 109 - 0
src/lib/helius/tokenMetadata.ts

@@ -0,0 +1,109 @@
+import { LRUCache } from 'lru-cache';
+import { HELIUS_RPC_URL, KNOWN_TOKENS } from './constants';
+
+export interface TokenMetadata {
+  mint: string;
+  symbol: string;
+  name: string;
+  decimals: number;
+  imageUrl?: string;
+  price?: number;
+  supply?: number;
+  marketCap?: number;
+}
+
+const cache = new LRUCache<string, TokenMetadata>({
+  max: 5000,
+  ttl: 10 * 60 * 1000, // 10 min
+});
+
+// Pre-seed known tokens
+for (const [mint, meta] of Object.entries(KNOWN_TOKENS)) {
+  cache.set(mint, { mint, ...meta });
+}
+
+export async function getTokenMetadata(mint: string): Promise<TokenMetadata> {
+  const cached = cache.get(mint);
+  if (cached) return cached;
+
+  try {
+    const res = await fetch(HELIUS_RPC_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        jsonrpc: '2.0',
+        id: 1,
+        method: 'getAsset',
+        params: {
+          id: mint,
+          displayOptions: { showFungible: true },
+        },
+      }),
+    });
+
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
+    const data = await res.json();
+    const asset = data.result;
+
+    if (!asset) {
+      const fallback: TokenMetadata = {
+        mint,
+        symbol: mint.slice(0, 4) + '...',
+        name: 'Unknown Token',
+        decimals: 9,
+      };
+      cache.set(mint, fallback);
+      return fallback;
+    }
+
+    const meta: TokenMetadata = {
+      mint,
+      symbol: asset.content?.metadata?.symbol || mint.slice(0, 4) + '...',
+      name: asset.content?.metadata?.name || 'Unknown Token',
+      decimals: asset.token_info?.decimals ?? 9,
+      imageUrl: asset.content?.links?.image || asset.content?.files?.[0]?.uri,
+      price: asset.token_info?.price_info?.price_per_token,
+      supply: asset.token_info?.supply ? Number(asset.token_info.supply) / Math.pow(10, asset.token_info.decimals ?? 9) : undefined,
+      marketCap: asset.token_info?.price_info?.total_price,
+    };
+
+    cache.set(mint, meta);
+    return meta;
+  } catch (err) {
+    console.error(`Failed to fetch metadata for ${mint}:`, err);
+    const fallback: TokenMetadata = {
+      mint,
+      symbol: mint.slice(0, 4) + '...',
+      name: 'Unknown Token',
+      decimals: 9,
+    };
+    cache.set(mint, fallback);
+    return fallback;
+  }
+}
+
+export async function getTokenMetadataBatch(mints: string[]): Promise<Map<string, TokenMetadata>> {
+  const result = new Map<string, TokenMetadata>();
+  const toFetch: string[] = [];
+
+  for (const mint of mints) {
+    const cached = cache.get(mint);
+    if (cached) {
+      result.set(mint, cached);
+    } else {
+      toFetch.push(mint);
+    }
+  }
+
+  if (toFetch.length > 0) {
+    // Fetch individually (getAssetBatch may not be available on all plans)
+    await Promise.all(
+      toFetch.slice(0, 20).map(async (mint) => {
+        const meta = await getTokenMetadata(mint);
+        result.set(mint, meta);
+      })
+    );
+  }
+
+  return result;
+}

+ 256 - 0
src/lib/helius/websocket.ts

@@ -0,0 +1,256 @@
+import WebSocket from 'ws';
+import { DEX_PROGRAMS, HELIUS_WS_URL, SOL_MINT } from './constants';
+import { addSignature, onSwapBatch, startBatchParser } from './batchParser';
+import type { SwapEvent } from './parseSwap';
+
+// Use globalThis to survive HMR in Next.js dev mode
+const g = globalThis as any;
+if (!g.__heliusWs) {
+  g.__heliusWs = {
+    ws: null as WebSocket | null,
+    reconnectTimer: null as ReturnType<typeof setTimeout> | null,
+    reconnectAttempts: 0,
+    subscriptionIds: [] as number[],
+    isConnected: false,
+    statusListeners: [] as Array<(connected: boolean) => void>,
+    mockSwapListeners: [] as Array<(swaps: SwapEvent[]) => void>,
+    mockInterval: null as ReturnType<typeof setInterval> | null,
+    initialized: false,
+    isMockMode: false,
+    pingInterval: null as ReturnType<typeof setInterval> | null,
+    lastMessageAt: 0,
+    messageCount: 0,
+    signatureCount: 0,
+  };
+}
+
+const state = g.__heliusWs;
+
+export function onConnectionStatus(listener: (connected: boolean) => void) {
+  state.statusListeners.push(listener);
+  listener(state.isConnected);
+  return () => {
+    state.statusListeners = state.statusListeners.filter((l: any) => l !== listener);
+  };
+}
+
+function setConnected(v: boolean) {
+  state.isConnected = v;
+  for (const l of state.statusListeners) {
+    try { l(v); } catch {}
+  }
+}
+
+function stopPingInterval() {
+  if (state.pingInterval) {
+    clearInterval(state.pingInterval);
+    state.pingInterval = null;
+  }
+}
+
+function startPingInterval() {
+  stopPingInterval();
+  state.pingInterval = setInterval(() => {
+    if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
+      console.log(`[ws-health] WS not open (readyState=${state.ws?.readyState}), forcing reconnect`);
+      stopPingInterval();
+      forceReconnect();
+      return;
+    }
+
+    // Check if we've received any messages recently (60s timeout)
+    const silentMs = Date.now() - state.lastMessageAt;
+    if (state.lastMessageAt > 0 && silentMs > 60000) {
+      console.log(`[ws-health] No messages for ${Math.round(silentMs / 1000)}s, forcing reconnect`);
+      stopPingInterval();
+      forceReconnect();
+      return;
+    }
+
+    // Send ping
+    try {
+      state.ws.ping();
+    } catch (err: any) {
+      console.error('[ws-health] Ping failed:', err?.message);
+      stopPingInterval();
+      forceReconnect();
+    }
+
+    console.log(`[ws-health] ok msgs=${state.messageCount} sigs=${state.signatureCount} subs=${state.subscriptionIds.length} silent=${Math.round(silentMs / 1000)}s`);
+  }, 30000);
+}
+
+function forceReconnect() {
+  if (state.ws) {
+    try { state.ws.close(); } catch {}
+    state.ws = null;
+  }
+  setConnected(false);
+  state.subscriptionIds = [];
+  // Clear any existing reconnect timer
+  if (state.reconnectTimer) {
+    clearTimeout(state.reconnectTimer);
+    state.reconnectTimer = null;
+  }
+  state.reconnectAttempts = 0;
+  connect();
+}
+
+function connect() {
+  if (state.ws) {
+    try { state.ws.close(); } catch {}
+  }
+
+  console.log('[ws] Connecting to Helius WebSocket...');
+  state.ws = new WebSocket(HELIUS_WS_URL);
+  state.messageCount = 0;
+  state.signatureCount = 0;
+
+  state.ws.on('open', () => {
+    console.log('[ws] Connected');
+    state.reconnectAttempts = 0;
+    state.lastMessageAt = Date.now();
+    setConnected(true);
+    subscribeToPrograms();
+    startPingInterval();
+  });
+
+  state.ws.on('message', (data: WebSocket.Data) => {
+    state.lastMessageAt = Date.now();
+    state.messageCount++;
+
+    try {
+      const msg = JSON.parse(data.toString());
+
+      if (msg.result !== undefined && typeof msg.result === 'number') {
+        state.subscriptionIds.push(msg.result);
+        console.log(`[ws] Subscription confirmed: id=${msg.result} (total=${state.subscriptionIds.length})`);
+        return;
+      }
+
+      if (msg.method === 'logsNotification') {
+        const signature = msg.params?.result?.value?.signature;
+        const hasErr = msg.params?.result?.value?.err;
+        if (signature && !hasErr) {
+          state.signatureCount++;
+          addSignature(signature);
+        }
+      }
+    } catch (err) {
+      console.error('[ws] Message parse error:', err);
+    }
+  });
+
+  state.ws.on('close', (code: number, reason: Buffer) => {
+    console.log(`[ws] Disconnected (code=${code}, reason=${reason?.toString() || 'none'})`);
+    stopPingInterval();
+    setConnected(false);
+    state.subscriptionIds = [];
+    scheduleReconnect();
+  });
+
+  state.ws.on('error', (err: any) => {
+    console.error('[ws] Error:', err.message);
+  });
+
+  state.ws.on('pong', () => {
+    // Pong received - connection is alive
+  });
+}
+
+function subscribeToPrograms() {
+  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
+  const programs = Object.values(DEX_PROGRAMS);
+  let id = 1;
+  for (const program of programs) {
+    state.ws.send(JSON.stringify({
+      jsonrpc: '2.0',
+      id: id++,
+      method: 'logsSubscribe',
+      params: [{ mentions: [program] }, { commitment: 'confirmed' }],
+    }));
+  }
+  console.log(`Subscribed to ${programs.length} DEX programs`);
+}
+
+function scheduleReconnect() {
+  if (state.reconnectTimer) return;
+  const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000);
+  state.reconnectAttempts++;
+  console.log(`Reconnecting in ${delay}ms (attempt ${state.reconnectAttempts})`);
+  state.reconnectTimer = setTimeout(() => {
+    state.reconnectTimer = null;
+    connect();
+  }, delay);
+}
+
+export function onMockSwapBatch(listener: (swaps: SwapEvent[]) => void) {
+  state.mockSwapListeners.push(listener);
+  return () => {
+    state.mockSwapListeners = state.mockSwapListeners.filter((l: any) => l !== listener);
+  };
+}
+
+function startMockStream() {
+  if (state.mockInterval) return;
+
+  const mockTokens = [
+    'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
+    'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm',
+    'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
+    '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R',
+    'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE',
+    'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
+    'rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof',
+    'hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux',
+    'mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So',
+    'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn',
+  ];
+  const sources: SwapEvent['source'][] = ['Pump', 'Raydium', 'Meteora', 'Orca', 'Jupiter'];
+
+  setConnected(true);
+
+  state.mockInterval = setInterval(() => {
+    const count = Math.floor(Math.random() * 8) + 2;
+    const swaps: SwapEvent[] = [];
+    for (let i = 0; i < count; i++) {
+      const tokenMint = mockTokens[Math.floor(Math.random() * mockTokens.length)];
+      const isBuy = Math.random() > 0.45;
+      const tokenAmount = Math.random() * 100000 + 100;
+      const solAmount = Math.random() * 50 + 0.1;
+      swaps.push({
+        signature: `mock_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
+        timestamp: Date.now(),
+        source: sources[Math.floor(Math.random() * sources.length)],
+        tokenIn: isBuy
+          ? { mint: SOL_MINT, amount: solAmount }
+          : { mint: tokenMint, amount: tokenAmount },
+        tokenOut: isBuy
+          ? { mint: tokenMint, amount: tokenAmount }
+          : { mint: SOL_MINT, amount: solAmount },
+      });
+    }
+    for (const listener of state.mockSwapListeners) {
+      try { listener(swaps); } catch {}
+    }
+  }, 1200);
+}
+
+export function isMock() {
+  return state.isMockMode;
+}
+
+export function initHeliusStream() {
+  if (state.initialized) return;
+  state.initialized = true;
+
+  if (!process.env.HELIUS_API_KEY || process.env.HELIUS_API_KEY === 'your_helius_api_key_here') {
+    console.warn('HELIUS_API_KEY not set - using mock data mode');
+    state.isMockMode = true;
+    startMockStream();
+    return;
+  }
+
+  connect();
+  startBatchParser();
+}

+ 140 - 0
src/lib/store.ts

@@ -0,0 +1,140 @@
+'use client';
+
+import { create } from 'zustand';
+import type { SwapEvent } from './helius/parseSwap';
+import type { AMMSource } from './helius/constants';
+import type { GraphNode, GraphLink } from './graph/graphBuilder';
+import type { TokenPair } from './graph/aggregator';
+import { aggregateSwaps, pruneSwapBuffer } from './graph/aggregator';
+import { buildGraph, setSymbolForMint } from './graph/graphBuilder';
+import type { TokenMetadata } from './helius/tokenMetadata';
+import { type Timeframe, TIMEFRAME_CONFIG, DEFAULT_TIMEFRAME } from './timeframes';
+
+interface DashboardState {
+  // Connection
+  isConnected: boolean;
+  setConnected: (v: boolean) => void;
+
+  // Swap data
+  swapBuffer: SwapEvent[];
+  addSwaps: (swaps: SwapEvent[]) => void;
+  setSwaps: (swaps: SwapEvent[]) => void;
+  pruneOld: () => void;
+
+  // Aggregated
+  totalSwaps: number;
+  uniqueTokens: number;
+  topPairs: TokenPair[];
+
+  // Graph
+  nodes: GraphNode[];
+  links: GraphLink[];
+
+  // Filters
+  activeFilter: AMMSource;
+  setFilter: (f: AMMSource) => void;
+
+  // Timeframe
+  activeTimeframe: Timeframe;
+  setTimeframe: (t: Timeframe) => void;
+
+  // Selected token
+  selectedToken: string | null;
+  setSelectedToken: (mint: string | null) => void;
+
+  // Token metadata cache (client-side)
+  tokenMeta: Record<string, TokenMetadata>;
+  setTokenMeta: (mint: string, meta: TokenMetadata) => void;
+
+  // SOL price
+  solPrice: number;
+  setSolPrice: (p: number) => void;
+
+  // Recompute graph
+  recompute: () => void;
+}
+
+// Track node positions across recomputes
+let nodePositionCache = new Map<string, GraphNode>();
+
+export const useDashboardStore = create<DashboardState>((set, get) => ({
+  isConnected: false,
+  setConnected: (v) => set({ isConnected: v }),
+
+  swapBuffer: [],
+  addSwaps: (swaps) => {
+    set((state) => ({ swapBuffer: [...state.swapBuffer, ...swaps] }));
+    const meta = get().tokenMeta;
+    for (const swap of swaps) {
+      if (meta[swap.tokenIn.mint]) setSymbolForMint(swap.tokenIn.mint, meta[swap.tokenIn.mint].symbol);
+      if (meta[swap.tokenOut.mint]) setSymbolForMint(swap.tokenOut.mint, meta[swap.tokenOut.mint].symbol);
+    }
+    get().recompute();
+  },
+
+  setSwaps: (swaps) => {
+    set({ swapBuffer: swaps });
+    get().recompute();
+  },
+
+  pruneOld: () => {
+    const windowMs = TIMEFRAME_CONFIG[get().activeTimeframe].ms;
+    set((state) => ({ swapBuffer: pruneSwapBuffer(state.swapBuffer, windowMs) }));
+    get().recompute();
+  },
+
+  totalSwaps: 0,
+  uniqueTokens: 0,
+  topPairs: [],
+
+  nodes: [],
+  links: [],
+
+  activeFilter: 'All',
+  setFilter: (f) => {
+    set({ activeFilter: f });
+    get().recompute();
+  },
+
+  activeTimeframe: DEFAULT_TIMEFRAME,
+  setTimeframe: (t) => {
+    set({ activeTimeframe: t });
+    // Historical fetch is triggered by Dashboard useEffect
+  },
+
+  selectedToken: null,
+  setSelectedToken: (mint) => set({ selectedToken: mint }),
+
+  tokenMeta: {},
+  setTokenMeta: (mint, meta) => {
+    set((state) => ({ tokenMeta: { ...state.tokenMeta, [mint]: meta } }));
+    setSymbolForMint(mint, meta.symbol);
+    get().recompute();
+  },
+
+  solPrice: 130,
+  setSolPrice: (p) => {
+    if (p > 0) set({ solPrice: p });
+  },
+
+  recompute: () => {
+    const { swapBuffer, activeFilter, activeTimeframe, solPrice, nodes: oldNodes } = get();
+    const windowMs = TIMEFRAME_CONFIG[activeTimeframe].ms;
+    const agg = aggregateSwaps(swapBuffer, activeFilter, windowMs, solPrice);
+
+    // Cache current positions
+    for (const node of oldNodes) {
+      nodePositionCache.set(node.id, node);
+    }
+
+    const { nodes, links } = buildGraph(agg, nodePositionCache);
+
+    set({
+      nodes,
+      links,
+      totalSwaps: agg.totalSwaps,
+      uniqueTokens: agg.uniqueTokens,
+      topPairs: agg.pairs,
+    });
+  },
+}));

+ 11 - 0
src/lib/timeframes.ts

@@ -0,0 +1,11 @@
+export type Timeframe = '5m' | '30m' | '1h' | '12h' | '24h';
+
+export const TIMEFRAME_CONFIG: Record<Timeframe, { label: string; ms: number }> = {
+  '5m':  { label: '5 Min',     ms: 5 * 60 * 1000 },
+  '30m': { label: '30 Min',    ms: 30 * 60 * 1000 },
+  '1h':  { label: '1 Hour',    ms: 60 * 60 * 1000 },
+  '12h': { label: '12 Hours',  ms: 12 * 60 * 60 * 1000 },
+  '24h': { label: '24 Hours',  ms: 24 * 60 * 60 * 1000 },
+};
+
+export const DEFAULT_TIMEFRAME: Timeframe = '5m';

+ 17 - 0
src/lib/utils.ts

@@ -0,0 +1,17 @@
+export function formatNumber(n: number): string {
+  if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
+  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
+  if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
+  if (n >= 1) return n.toFixed(1);
+  return n.toFixed(4);
+}
+
+export function formatUSD(n: number): string {
+  if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
+  if (n >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
+  return `$${n.toFixed(2)}`;
+}
+
+export function truncateAddress(addr: string, chars = 4): string {
+  return `${addr.slice(0, chars)}...${addr.slice(-chars)}`;
+}

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels