merge: chore/cleanup-remove-bloat into main
1
.clinerules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/dev/rmi/.clinerules
|
||||
15
.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Frontend environment variables template
|
||||
# Copy this to .env and fill in real values for local dev.
|
||||
# DO NOT commit .env — it's in .gitignore.
|
||||
|
||||
# Supabase (frontend uses anon/publishable key — safe to expose in browser)
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=sb_publishable_your_anon_key_here
|
||||
|
||||
# API endpoint (frontend proxies to backend)
|
||||
# - Local dev: http://localhost:8000
|
||||
# - Production: https://api.rugmunch.io
|
||||
VITE_API_URL=http://localhost:8000
|
||||
|
||||
# AI provider keys are SERVER-SIDE only (in backend env/secrets).
|
||||
# Frontend proxies through /api/v1/ai/chat — keys never reach the browser.
|
||||
3
.envrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
source_up
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PWD/node_modules/.bin:$PATH"
|
||||
export VITE_API_URL="https://rugmunch.io"
|
||||
20
.eslintrc.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/pages/**/*.tsx", "src/components/**/*.tsx"],
|
||||
"rules": {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "CallExpression[callee.name='fetch']",
|
||||
"message": "❌ Raw fetch() in pages/components is forbidden. Use useDataBus() from '@/lib/hooks/useDataBus' instead. Data fetching MUST route through the DataBus."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
54
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.staging
|
||||
.env.*.local
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
vite.config.ts.timestamp-1780359042432-8ff8bfefcaa44.mjs
|
||||
|
||||
|
||||
# === SECURITY: never commit secrets ===
|
||||
.secrets/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# === DATA: don't commit binary data blobs ===
|
||||
*.zip
|
||||
*.parquet
|
||||
*.duckdb
|
||||
*.sqlite
|
||||
*.bin
|
||||
*.safetensors
|
||||
*.pt
|
||||
*.onnx
|
||||
|
||||
# === Lockfiles: pick one (we use pnpm) ===
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# === Vite / React ===
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
|
||||
# === OS / IDE ===
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# === Internal: no nested repos ===
|
||||
/backend/
|
||||
/server/
|
||||
/api/
|
||||
47
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# RMI Pre-commit Config — drop into any rmi project
|
||||
# Copy to project root as .pre-commit-config.yaml
|
||||
# Install: pre-commit install
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=500"]
|
||||
- id: check-merge-conflict
|
||||
- id: detect-private-keys
|
||||
- id: mixed-line-ending
|
||||
args: ["--fix=lf"]
|
||||
- id: no-commit-to-branch
|
||||
args: ["--branch", "main", "--branch", "staging"]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.16
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["check", "--fix"]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v2.1.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
args: ["--strict", "--ignore-missing-imports"]
|
||||
language: system
|
||||
types: [python]
|
||||
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.24.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
args: ["detect", "--source", ".", "--verbose"]
|
||||
|
||||
- repo: https://github.com/ejcx/git-hound
|
||||
rev: v1.2.0
|
||||
hooks:
|
||||
- id: git-hound
|
||||
args: ["--config", ".githound.yml"]
|
||||
3
.secretsallow
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.secretsallow
|
||||
\.timestamp.*\.mjs$
|
||||
\.timestamp.*
|
||||
168
AGENTS.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# AGENTS.md — RMI Frontend
|
||||
# Read this before working on the frontend codebase.
|
||||
# Every AI agent working on rugmunch.io reads this first.
|
||||
# Last updated: 2026-06-25
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
WHAT THIS IS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
The RMI frontend at rugmunch.io — a Bloomberg-terminal-style crypto
|
||||
intelligence dashboard built with React 19 + Vite + TypeScript.
|
||||
|
||||
This is NOT Next.js. Static SPA, built with `vite build`, served
|
||||
via nginx + Cloudflare.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
WHERE THINGS LIVE
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Source: /root/frontend/rmi-frontend/ (netcup — build environment)
|
||||
Local copy: ~/rmi/rmi-frontend/ (cinnabox — edit environment)
|
||||
Build: npm run build → dist/
|
||||
Deploy: cp -r dist/* /var/www/rmi/ (sudo on netcup)
|
||||
Dev server: npm run dev → localhost:5173
|
||||
|
||||
═══ CRITICAL — two separate copies ═══
|
||||
Build goes to dist/ → Deploy DIFFERS from /var/www/rmi/
|
||||
Running `npm run build` does NOT update the live site.
|
||||
You MUST run `cp -r dist/* /var/www/rmi/` after every build.
|
||||
Forgetting this = site serves stale JS = broken UI = user yells.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
THE STACK
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
React 19 + TypeScript 5.7 # Framework
|
||||
Vite 5 # Build tool
|
||||
Tailwind 4 # Styling
|
||||
TanStack Query 5 # Server state
|
||||
Zustand # Client state
|
||||
React Router v7 # Routing
|
||||
Recharts 3 + TV Lightweight 5 # Charts
|
||||
DataBus Client # Data layer (SWR + dedup)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RULES (non-negotiable)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
1. ALL DATA THROUGH DATABUS. Never raw fetch() in pages/components.
|
||||
Use useDataBus() hook or DataBusClient. ESLint enforces this.
|
||||
|
||||
2. REACT QUERY STALETIME MUST ALIGN WITH BACKEND TTL.
|
||||
Price data: 30s. Metadata: 5min. Labels: 1hr. Alerts: 10s.
|
||||
Wrong staleTime = either stale data or excess polling.
|
||||
|
||||
3. PRE-RENDER SHELLS. Every data component shows a skeleton screen
|
||||
while loading. Never show spinners or blank space.
|
||||
|
||||
4. VERIFY BEFORE CLAIMING FIXED. Build → deploy → browser navigate →
|
||||
console check → data renders. Never say "it works" without proof.
|
||||
Use cache-busting (?v=N) to bypass Cloudflare cache.
|
||||
|
||||
5. NO LUCIDE-REACT ICONS WITHOUT VERIFYING EXPORT. Whale, ShieldAlert,
|
||||
Siren are NOT in the ESM build. Crashes at runtime with
|
||||
ReferenceError. Test with `node -e "require('lucide-react').X"`.
|
||||
|
||||
6. CODE SPLITTING MANDATORY. All 42+ pages use React.lazy() +
|
||||
dynamic import(). Layout components (SidebarLayout, etc.) are
|
||||
static imports. Index chunk must stay under ~150KB.
|
||||
|
||||
7. NO FLASHING/PULSING ANIMATIONS. Static indicators only.
|
||||
animate-pulse, animate-ping, animate-glow-pulse are banned.
|
||||
User explicitly rejected all strobing UI.
|
||||
|
||||
8. SOCIAL-FI ON ALL CONTENT. Every post, news article, bulletin item
|
||||
gets SocialActionBar (reactions, comments, share).
|
||||
|
||||
9. NO ADMIN ROUTES ON CONSUMER SITE. Admin.rugmunch.io is a separate
|
||||
codebase at /srv/admin-darkroom/. Admin pages in consumer App.tsx
|
||||
are bugs.
|
||||
|
||||
10. DATABUS CHAIN NAMES MUST BE VERIFIED. Wrong name = silent empty
|
||||
page. Run `curl localhost:8000/api/v1/databus/chains` and check
|
||||
before adding new useDataBus('chain') calls.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
PAGES
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
42+ pages, all lazy-loaded. Key ones:
|
||||
|
||||
/ (HomePage) /market-intel (15 tabs) /news
|
||||
/scanner /wallet/[address] /rugmaps
|
||||
/rugcharts /hunter (gamification) /premium
|
||||
/chat (AI streaming) /discover /feed
|
||||
/community /campaigns /walletsafe
|
||||
/api-docs /tool-market /investigator/[u]
|
||||
|
||||
Routes in App.tsx. Nav in SidebarLayout.
|
||||
to: paths MUST match route path= exactly. No orphans.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
DEPLOY
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
# Build + deploy (from netcup)
|
||||
cd /root/frontend/rmi-frontend
|
||||
npm run build
|
||||
cp -r dist/* /var/www/rmi/
|
||||
|
||||
# Clean stale chunks after deploy
|
||||
NEW_INDEX=$(curl -s https://rugmunch.io/ | grep -o 'src="/assets/index-[^"]*"' | sed 's|src="/assets/||;s|"||')
|
||||
cd /var/www/rmi/assets && ls index-*.js | grep -v "$NEW_INDEX" | xargs rm -f
|
||||
|
||||
# From cinnabox (sync → VPS builds)
|
||||
bash ~/rmi/scripts/deploy.sh frontend
|
||||
|
||||
═ VERIFICATION SEQUENCE (MANDATORY) ═
|
||||
1. npm run build — succeeds with zero errors
|
||||
2. cp dist/* /var/www/rmi/
|
||||
3. browser_navigate with ?v=N
|
||||
4. browser_console — check for ReferenceError/TypeError
|
||||
5. Verify real data renders (not just Loading...)
|
||||
6. Never say "fixed" without steps 3-5
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
PERSISTENT UI (never remove)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
PriceTickerBar — fixed top-0, h-[32px], 10 coins scrolling
|
||||
CommandPalette — Cmd+K overlay, fuzzy nav search
|
||||
SystemStatusBar — fixed bottom-0, h-[24px], health/latency/threats
|
||||
|
||||
Layout: pt-[32px] pb-[24px] clears the bars. Sidebar top-[32px].
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
DATA BUS CHAIN REFERENCE (verified working)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
token_price, token_metadata, trending, alerts, scanner,
|
||||
news, news_intel, full_news, fear_greed, whale_alerts,
|
||||
token_trades, liquidity_risk, token_launches, market_overview,
|
||||
market_brief, wallet_labels, wallet_tokens, ct_rundown,
|
||||
defillama_chains, defillama_tvl, prediction_markets,
|
||||
cross_chain, holder_data, mcp_bridge, rag_search,
|
||||
arkham_portfolio, arkham_intel, arkham_transfers,
|
||||
social_metrics, cluster_map, token_search, bb_post, ai_task
|
||||
|
||||
BROKEN names (will silently fail):
|
||||
❌ market_movers → use trending
|
||||
❌ intelligence_feed → use news_intel
|
||||
❌ risk_scan → use scanner
|
||||
❌ wallet_profile → use arkham_portfolio
|
||||
❌ sentiment → use fear_greed
|
||||
❌ whale_data → use whale_alerts
|
||||
❌ dex_data → use token_trades
|
||||
❌ liquidity_depth → use liquidity_risk
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
GO BUILD
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
You know:
|
||||
- This is a React SPA on rugmunch.io
|
||||
- All data goes through DataBus
|
||||
- Verify before claiming fixed
|
||||
- No pulsing animations, no raw fetch, no dead code
|
||||
- Build → deploy → verify → commit
|
||||
22
CLEANUP.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# CLEANUP.md — rmi-frontend
|
||||
|
||||
> Audit + cleanup log.
|
||||
|
||||
## 2026-07-02 — bloat removal
|
||||
|
||||
### Removed: data blobs
|
||||
- `wallet_csv_data.zip` (793KB) — belongs in R2, not git
|
||||
- `wallet_database.zip` (31KB) — belongs in R2, not git
|
||||
|
||||
### Removed: duplicate `backend/` subtree
|
||||
- Was an orphan duplicate React project (5 files: Dockerfile, package.json, src/, tsconfig.json)
|
||||
- Doesn't belong in the frontend repo
|
||||
|
||||
### Resolved: lockfile conflict
|
||||
- Had BOTH `package-lock.json` (npm, 416KB) AND `pnpm-lock.yaml` (pnpm, 249KB)
|
||||
- Standardized on **pnpm** (per fleet TOOLCHAIN.md)
|
||||
- Removed `package-lock.json`
|
||||
- Added to `.gitignore` so npm can't accidentally reintroduce it
|
||||
|
||||
### Improved `.gitignore`
|
||||
- Added: secrets, data blobs, lockfile (npm/yarn), nested repos
|
||||
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Stage 1: Build React app
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
461
LICENSING_PRICING_STRATEGY.md
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
# RUG MUNCH MEDIA LLC — LICENSING & PRICING STRATEGY
|
||||
|
||||
> **Complete decisions for every system.**
|
||||
> Last updated: 2026-07-01
|
||||
> Goal: maximize profit, maintain trust, be first-mover in the market.
|
||||
|
||||
---
|
||||
|
||||
## 1. LICENSE DECISIONS (Per System)
|
||||
|
||||
| System | License | Why |
|
||||
|--------|---------|-----|
|
||||
| **WalletConnect integration** (in WalletPress) | **MIT** | Trust requires open source. Community audits wallet security code. No competitive advantage in the protocol itself. |
|
||||
| **WalletPress core** (self-hosted backend) | **BSL 1.1** (Business Source) | Readable, auditable, but can't commercially clone. Converts to MIT on 2029-01-01. |
|
||||
| **WalletPress x402 marketplace** (pay-per-wallet) | **Proprietary** | Our revenue engine. Don't show competitors how we price/route payments. |
|
||||
| **PryScraper** | **Proprietary** | Our competitive moat. Stealth browser, anti-detection — we don't want anyone copying. |
|
||||
| **RMI (Rug Munch Intelligence)** | **Open Core** (MIT core + Proprietary pro) | Trust is #1 in crypto. MIT detector framework builds community. Proprietary platform = revenue. |
|
||||
| **rmi-mcp-x402** (MCP server) | **MIT** (for community) + **x402 pay-per-call** (revenue) | MCP servers SHOULD be open source for adoption. Revenue comes from x402 usage, not licensing. |
|
||||
|
||||
---
|
||||
|
||||
## 2. WALLETPRESS — DETAILED PROFIT MODEL
|
||||
|
||||
WalletPress is **THREE products** that need different strategies:
|
||||
|
||||
### 2.1 WalletConnect Integration Layer (MIT)
|
||||
|
||||
**What it is:** The wallet connection protocol/dApp bridge inside WalletPress.
|
||||
|
||||
**License:** MIT — fully open source.
|
||||
|
||||
**Why:** Trust. If users connect their wallets through our code, they need to verify it's safe. Closed source = no trust = no users.
|
||||
|
||||
**Revenue from this:** NONE directly. This is a **loss leader** that makes the rest of WalletPress trustworthy.
|
||||
|
||||
**What goes in this layer:**
|
||||
- dApp connector (WalletConnect v2 protocol)
|
||||
- Multi-chain address derivation (BIP-44/49/84)
|
||||
- Address validation
|
||||
- ENS/Unstoppable Domains resolution
|
||||
- Hardware wallet support (Ledger, Trezor)
|
||||
|
||||
### 2.2 WalletPress Self-Hosted Core (BSL 1.1)
|
||||
|
||||
**What it is:** The main `main.py` FastAPI backend. Users self-host on their own infrastructure.
|
||||
|
||||
**License:** BSL 1.1 (Business Source License). Convert to MIT on 2029-01-01.
|
||||
|
||||
**Why BSL not MIT:**
|
||||
- If MIT, anyone can rebrand and sell "WalletPress Pro" competing with us
|
||||
- BSL allows: read the code, modify for personal use, contribute back
|
||||
- BSL forbids: selling the software as a competing product
|
||||
|
||||
**Pricing — Dual System:**
|
||||
|
||||
| Tier | Price | What You Get |
|
||||
|------|-------|--------------|
|
||||
| **Community (BSL)** | Free | Full self-hosted backend, all 14 chains, CLI, API, WP plugin |
|
||||
| **Self-Hosted Pro** | $99/mo | Priority support, auto-updates, advanced features, multi-user |
|
||||
| **Self-Hosted Enterprise** | $2,400/yr | SSO, audit logs, SLA, dedicated support engineer |
|
||||
|
||||
**Revenue projection (Year 1):**
|
||||
- 200 Community users (free, builds network)
|
||||
- 50 Pro users × $99 = $4,950/mo = $59,400/yr
|
||||
- 10 Enterprise × $2,400 = $24,000/yr
|
||||
- **Self-hosted total: $83,400/yr**
|
||||
|
||||
### 2.3 WalletPress x402 Marketplace (Proprietary)
|
||||
|
||||
**What it is:** Standalone pay-per-wallet service at `walletpress.cc`. No account, no subscription. Bots/developers pay USDC per wallet generation.
|
||||
|
||||
**License:** Proprietary. Don't show competitors our pricing algorithms.
|
||||
|
||||
**Pricing — Pay-per-wallet:**
|
||||
|
||||
| Service | Price | Use Case |
|
||||
|---------|-------|----------|
|
||||
| **Generate wallet** | $0.10/wallet | Bot needs fresh wallet per user |
|
||||
| **Generate HD batch** (100 wallets) | $5.00 | Bulk wallet generation |
|
||||
| **Generate HD batch** (1000 wallets) | $25.00 | Enterprise bulk |
|
||||
| **Derive address from mnemonic** | $0.02/derive | Read-only address extraction |
|
||||
| **Check balance** | $0.01/check | Wallet monitoring |
|
||||
| **Sign message** | $0.05/sign | Bot authentication |
|
||||
| **Send transaction** | $0.10 + gas | Automated payouts |
|
||||
| **Full transaction suite** | $0.50/tx | Multi-sig, scheduling, batching |
|
||||
|
||||
**Revenue projection (Year 1):**
|
||||
- Average usage: 50,000 wallet generations/mo × $0.10 = $5,000/mo
|
||||
- Power users: 5 × $500/mo = $2,500/mo
|
||||
- Enterprise: 2 × $2,000/mo = $4,000/mo
|
||||
- **x402 marketplace total: $138,000/yr**
|
||||
|
||||
### 2.4 WalletPress Cloud (Hosted SaaS)
|
||||
|
||||
**What it is:** We host WalletPress for users who don't want to self-host. Same features, managed by us.
|
||||
|
||||
**License:** Proprietary SaaS.
|
||||
|
||||
**Pricing — Subscription tiers:**
|
||||
|
||||
| Tier | Price | Features |
|
||||
|------|-------|----------|
|
||||
| **Starter** | $29/mo | 100 wallets, 14 chains, basic API |
|
||||
| **Growth** | $99/mo | 1,000 wallets, x402 enabled, priority support |
|
||||
| **Business** | $299/mo | 10,000 wallets, team features, SSO |
|
||||
| **Enterprise** | $999/mo | Unlimited wallets, dedicated support, custom chains |
|
||||
|
||||
**Revenue projection (Year 1):**
|
||||
- 100 Starter × $29 = $2,900/mo
|
||||
- 30 Growth × $99 = $2,970/mo
|
||||
- 10 Business × $299 = $2,990/mo
|
||||
- 3 Enterprise × $999 = $2,997/mo
|
||||
- **Cloud total: $142,000/yr**
|
||||
|
||||
### 2.5 WalletPress TOTAL Revenue Projection
|
||||
|
||||
| Stream | Year 1 | Year 2 | Year 3 |
|
||||
|--------|--------|--------|--------|
|
||||
| Self-hosted Pro | $59K | $180K | $360K |
|
||||
| Self-hosted Enterprise | $24K | $60K | $120K |
|
||||
| x402 marketplace | $138K | $400K | $1M |
|
||||
| Cloud SaaS | $142K | $500K | $1.2M |
|
||||
| **TOTAL** | **$363K** | **$1.14M** | **$2.68M** |
|
||||
|
||||
---
|
||||
|
||||
## 3. PRYSCRAPER — DETAILED PROFIT MODEL
|
||||
|
||||
### 3.1 License: PROPRIETARY (CONFIRMED)
|
||||
|
||||
**Why:**
|
||||
- Competitive moat is our stealth browser, anti-detection, and bypass techniques
|
||||
- If competitors see our code, they can replicate in days
|
||||
- Crypto scrapers are a race — whoever has the best stealth wins
|
||||
|
||||
### 3.2 Pricing — Three Models
|
||||
|
||||
**Model A: SaaS (Primary)**
|
||||
- Hosted API at `pry.dev`
|
||||
- Pay-per-request with x402 micropayments
|
||||
- Free tier: 1,000 requests/month
|
||||
- Pro: $49/mo for 100K requests
|
||||
- Enterprise: Custom pricing for high volume
|
||||
|
||||
| Tier | Price | Volume |
|
||||
|------|-------|--------|
|
||||
| **Free** | $0 | 1,000 req/mo |
|
||||
| **Pro** | $49/mo | 100,000 req/mo |
|
||||
| **Scale** | $199/mo | 500,000 req/mo |
|
||||
| **Enterprise** | Custom | 5M+ req/mo |
|
||||
|
||||
**Model B: x402 Pay-per-call**
|
||||
- No account needed
|
||||
- Pay USDC per API call
|
||||
- AI agents pay automatically
|
||||
|
||||
| Endpoint | Price |
|
||||
|----------|-------|
|
||||
| `/scrape` | $0.005/call |
|
||||
| `/crawl` | $0.02/call |
|
||||
| `/extract` | $0.01/call |
|
||||
| `/screenshot` | $0.003/call |
|
||||
| `/stealth_browser` | $0.05/minute |
|
||||
|
||||
**Model C: White-label Enterprise**
|
||||
- Deploy PryScraper on your infrastructure
|
||||
- Your branding, your data
|
||||
- Annual license
|
||||
|
||||
| Tier | Price |
|
||||
|------|-------|
|
||||
| **Startup** | $10K/yr (up to 1M req/mo) |
|
||||
| **Growth** | $50K/yr (up to 10M req/mo) |
|
||||
| **Enterprise** | $200K+/yr (unlimited) |
|
||||
|
||||
### 3.3 PryScraper Revenue Projection
|
||||
|
||||
| Stream | Year 1 | Year 2 | Year 3 |
|
||||
|--------|--------|--------|--------|
|
||||
| SaaS subscriptions | $80K | $300K | $600K |
|
||||
| x402 pay-per-call | $30K | $120K | $400K |
|
||||
| White-label Enterprise | $200K | $600K | $1.2M |
|
||||
| **TOTAL** | **$310K** | **$1.02M** | **$2.2M** |
|
||||
|
||||
---
|
||||
|
||||
## 4. RMI (RUG MUNCH INTELLIGENCE) — DETAILED PROFIT MODEL
|
||||
|
||||
### 4.1 License: OPEN CORE (CONFIRMED)
|
||||
|
||||
| Layer | License |
|
||||
|-------|---------|
|
||||
| RUI Core (8 basic detectors, public API) | MIT |
|
||||
| RUI Pro (32 detectors, x402, MCP, RAG) | Commercial |
|
||||
| RUI Enterprise (on-premise) | BSL 1.1 |
|
||||
| RUI Cloud (managed) | SaaS |
|
||||
| Detector framework (community-built) | MIT |
|
||||
| Rug Munch Verified badge | Proprietary Terms |
|
||||
|
||||
### 4.2 Pricing — Already Designed in LICENSING_STRATEGY.md
|
||||
|
||||
**Pro tier: $99/mo**
|
||||
**Team tier: $499/mo**
|
||||
**Enterprise: $10K+/yr**
|
||||
**Cloud: Pay-per-use**
|
||||
|
||||
### 4.3 RMI Revenue Projection
|
||||
|
||||
| Stream | Year 1 | Year 2 | Year 3 |
|
||||
|--------|--------|--------|--------|
|
||||
| Pro subscriptions | $120K | $600K | $1.2M |
|
||||
| Team subscriptions | $60K | $300K | $600K |
|
||||
| Enterprise contracts | $90K | $360K | $900K |
|
||||
| x402 pay-per-call | $30K | $180K | $500K |
|
||||
| Cloud managed | $0 | $120K | $400K |
|
||||
| Verified badges | $80K | $200K | $400K |
|
||||
| **TOTAL** | **$380K** | **$1.76M** | **$4.0M** |
|
||||
|
||||
---
|
||||
|
||||
## 5. RMI-MCP-X402 — DETAILED PROFIT MODEL
|
||||
|
||||
### 5.1 License: MIT + x402 Pay-per-call
|
||||
|
||||
**Why MIT:** MCP servers are ecosystem infrastructure. The more people use them, the more the ecosystem grows. Revenue comes from x402 usage, not licensing.
|
||||
|
||||
### 5.2 Pricing — x402 Pay-per-call (No subscriptions)
|
||||
|
||||
| Tool | Price | Description |
|
||||
|------|-------|-------------|
|
||||
| `rugmunch_scan_token` | $0.001 | Full token scan (32 detectors) |
|
||||
| `rugmunch_wallet_forensics` | $0.01 | Wallet behavior analysis |
|
||||
| `rugmunch_rug_probability` | $0.005 | AI rug prediction |
|
||||
| `rugmunch_contract_audit` | $0.05 | Smart contract security |
|
||||
| `rugmunch_threat_intel` | $0.002 | Threat intelligence lookup |
|
||||
| `rugmunch_real_time_alert` | $0.001/min | Real-time monitoring |
|
||||
| `rugmunch_address_labels` | $0.001 | Wallet label lookup |
|
||||
| `rugmunch_chain_info` | Free | Multi-chain info |
|
||||
|
||||
### 5.3 rmi-mcp-x402 Revenue Projection
|
||||
|
||||
| Stream | Year 1 | Year 2 | Year 3 |
|
||||
|--------|--------|--------|--------|
|
||||
| x402 pay-per-call | $20K | $150K | $500K |
|
||||
| Enterprise MCP hosting | $0 | $50K | $200K |
|
||||
| **TOTAL** | **$20K** | **$200K** | **$700K** |
|
||||
|
||||
---
|
||||
|
||||
## 6. SPECIFIC IMPROVEMENTS PER SYSTEM
|
||||
|
||||
### 6.1 WalletPress Improvements (Self-Hosted BSL)
|
||||
|
||||
**Priority 1 (This Week):**
|
||||
1. **Add WalletConnect v2 integration** — dApp connector for 300+ wallets
|
||||
2. **Hardware wallet support** — Ledger, Trezor, GridPlus
|
||||
3. **Multi-sig wallets** — Gnosis Safe integration for 2-of-3, 3-of-5
|
||||
4. **Address book encryption** — Encrypted contact storage
|
||||
5. **ENS/Unstoppable Domains** — Human-readable address resolution
|
||||
|
||||
**Priority 2 (This Month):**
|
||||
6. **x402 marketplace UI** — pricing page at walletpress.cc/x402
|
||||
7. **Stripe billing** — for self-hosted Pro subscriptions
|
||||
8. **Auto-update mechanism** — Pro users get automatic updates
|
||||
9. **License key system** — for Pro/Enterprise features
|
||||
10. **Audit log API** — for Enterprise compliance
|
||||
|
||||
**Priority 3 (This Quarter):**
|
||||
11. **WalletConnect v2 certified** — official WC integration
|
||||
12. **Multi-user teams** — Organizations, permissions, roles
|
||||
13. **Transaction scheduling** — Recurring payments, vesting
|
||||
14. **Gas optimization** — EIP-1559, batch transactions
|
||||
15. **Mobile SDK** — React Native, Flutter
|
||||
|
||||
### 6.2 PryScraper Improvements (Proprietary)
|
||||
|
||||
**Priority 1 (This Week):**
|
||||
1. **camoufox integration** — Firefox-based anti-detection
|
||||
2. **TLS fingerprint randomization** — Per-request unique fingerprints
|
||||
3. **Cookie warming** — Pre-aged cookies for trust signals
|
||||
4. **Residential proxy pool** — 100+ rotating IPs
|
||||
5. **CAPTCHA solver integration** — 2captcha, anti-captcha
|
||||
|
||||
**Priority 2 (This Month):**
|
||||
6. **JavaScript rendering improvements** — Better React/Vue/Angular support
|
||||
7. **PDF extraction upgrade** — OCR for scanned documents
|
||||
8. **Structured data extraction** — Schema.org, JSON-LD, microdata
|
||||
9. **Screenshot comparison** — Visual diffing for change detection
|
||||
10. **Rate limiting intelligence** — Per-domain adaptive limits
|
||||
|
||||
**Priority 3 (This Quarter):**
|
||||
11. **AI-powered extraction v2** — Better LLM prompts, structured outputs
|
||||
12. **Browser extension** — Chrome/Firefox scraping tool
|
||||
13. **Shopify/WooCommerce integration** — E-commerce scraping
|
||||
14. **Real-time monitoring** — Webhook + Slack/Discord alerts
|
||||
15. **Multi-region deployment** — US, EU, APAC for speed
|
||||
|
||||
### 6.3 RMI Improvements (Open Core)
|
||||
|
||||
**Priority 1 (This Week):**
|
||||
1. **Split codebase** — core/ (MIT) + pro/ (commercial)
|
||||
2. **Add LICENSE headers** — Every file has SPDX identifier
|
||||
3. **MCP tool naming** — rugmunch_scan_token (clear + discoverable)
|
||||
4. **Verified badge system** — Already built! ✅
|
||||
5. **Live demo at rugmunch.io** — Paste address → see 32 detector scores
|
||||
|
||||
**Priority 2 (This Month):**
|
||||
6. **Add 8 more detectors** — Currently have 32, add 8 more
|
||||
7. **RAG investigation reports** — AI-powered forensic analysis
|
||||
8. **Real-time webhook alerts** — Token launches, deployer activity
|
||||
9. **Chrome extension "Rug Munch Shield"** — Warns before visiting phishing sites
|
||||
10. **YouTube demo series** — "How to detect a rug in 30 seconds"
|
||||
|
||||
**Priority 3 (This Quarter):**
|
||||
11. **Threat intel feeds to exchanges** — $10K/mo per exchange
|
||||
12. **DAO treasury protection** — $5K/mo per DAO
|
||||
13. **Verified badge at scale** — $500/token, 100 tokens = $50K/mo
|
||||
14. **Bug bounty program** — $50K for finding wrong safe verdict
|
||||
15. **AI agent marketplace** — Agents built on top of RMI
|
||||
|
||||
### 6.4 rmi-mcp-x402 Improvements (MIT + x402)
|
||||
|
||||
**Priority 1 (This Week):**
|
||||
1. **PyPI package** — `pip install rugmunch-mcp`
|
||||
2. **Register on pulsemcp.com** — MCP server directory
|
||||
3. **Register on glama.ai** — Codeberg's MCP registry
|
||||
4. **Register on mcp.so** — Smithery registry
|
||||
5. **MCP tool names** — Clear, discoverable, consistent
|
||||
|
||||
**Priority 2 (This Month):**
|
||||
6. **8+ MCP tools** — Already have the framework
|
||||
7. **x402 payment integration** — USDC on Base, Solana
|
||||
8. **Streaming responses** — For long-running scans
|
||||
9. **Batch operations** — Scan multiple tokens in one call
|
||||
10. **Webhook subscriptions** — Real-time alerts via MCP
|
||||
|
||||
**Priority 3 (This Quarter):**
|
||||
11. **MCP server hosting** — Managed MCP at mcp.rugmunch.io
|
||||
12. **Custom tool builder** — Let users add their own tools
|
||||
13. **Tool analytics** — Usage stats, popular tools
|
||||
14. **Multi-MCP routing** — One request, multiple MCPs
|
||||
15. **MCP marketplace** — Third-party tools on our platform
|
||||
|
||||
---
|
||||
|
||||
## 7. UNIFIED REVENUE PROJECTION (All Systems)
|
||||
|
||||
| System | Year 1 | Year 2 | Year 3 |
|
||||
|--------|--------|--------|--------|
|
||||
| **RMI (Rug Munch Intelligence)** | $380K | $1.76M | $4.0M |
|
||||
| **WalletPress** (self-hosted + x402 + cloud) | $363K | $1.14M | $2.68M |
|
||||
| **PryScraper** (SaaS + x402 + white-label) | $310K | $1.02M | $2.2M |
|
||||
| **rmi-mcp-x402** (x402 pay-per-call) | $20K | $200K | $700K |
|
||||
| **TOTAL** | **$1.07M** | **$4.12M** | **$9.58M** |
|
||||
|
||||
---
|
||||
|
||||
## 8. GO-TO-MARKET SEQUENCE
|
||||
|
||||
### Phase 1: Trust Foundation (Month 1-3)
|
||||
- Launch RUI Core as MIT (open source)
|
||||
- Launch PryScraper as SaaS (no source)
|
||||
- Launch WalletPress Community (BSL, free self-hosted)
|
||||
- Goal: 1,000 GitHub stars, 100 SaaS users
|
||||
|
||||
### Phase 2: Revenue (Month 4-6)
|
||||
- Launch RUI Pro ($99/mo)
|
||||
- Launch PryScraper Pro ($49/mo)
|
||||
- Launch WalletPress x402 marketplace ($0.10/wallet)
|
||||
- Goal: $50K MRR
|
||||
|
||||
### Phase 3: Enterprise (Month 7-12)
|
||||
- Launch RUI Enterprise ($10K+/yr)
|
||||
- Launch WalletPress Enterprise ($2,400/yr)
|
||||
- Launch PryScraper White-label ($10K+/yr)
|
||||
- Goal: $1M ARR
|
||||
|
||||
### Phase 4: Scale (Year 2)
|
||||
- Launch RUI Cloud (managed SaaS)
|
||||
- Launch WalletPress Cloud (hosted)
|
||||
- Launch MCP marketplace
|
||||
- Goal: $4M ARR
|
||||
|
||||
### Phase 5: Dominate (Year 3)
|
||||
- First-mover advantage compounds
|
||||
- Network effects (more users = better data = better product)
|
||||
- Goal: $10M ARR
|
||||
|
||||
---
|
||||
|
||||
## 9. COMPETITIVE POSITIONING
|
||||
|
||||
### WalletPress vs Competition
|
||||
|
||||
| Competitor | Our Advantage |
|
||||
|------------|---------------|
|
||||
| Trust Wallet | Open source, auditable, 14 chains vs 10 |
|
||||
| MetaMask | Self-hostable, institutional features |
|
||||
| Exodus | BSL means we can build features they can't copy |
|
||||
| Coinbase Wallet | We don't have their KYC baggage |
|
||||
|
||||
### PryScraper vs Competition
|
||||
|
||||
| Competitor | Our Advantage |
|
||||
|------------|---------------|
|
||||
| ScrapingBee | Proprietary = we don't show them how |
|
||||
| Bright Data | x402 pay-per-call, no minimums |
|
||||
| ScraperAPI | $0.005/call vs $0.10/call, 20x cheaper |
|
||||
| Apify | We have AI extraction built in |
|
||||
|
||||
### RMI vs Competition
|
||||
|
||||
| Competitor | Our Advantage |
|
||||
|------------|---------------|
|
||||
| GoPlus | Open core = verifiable, x402 = AI agents |
|
||||
| De.Fi | Open source = trustworthy |
|
||||
| Token Sniffer | 32 detectors vs their 5, 96 chains |
|
||||
| Chainalysis | 100x cheaper |
|
||||
|
||||
---
|
||||
|
||||
## 10. THE FIRST-MOVER ADVANTAGE
|
||||
|
||||
Why we win in 2026:
|
||||
|
||||
1. **RUI is the first open-core crypto intelligence platform** — competitors are all closed
|
||||
2. **PryScraper is the first x402-native scraper** — competitors charge $0.10/call, we charge $0.005
|
||||
3. **WalletPress is the first BSL wallet** — community can audit, competitors can't clone
|
||||
4. **rmi-mcp-x402 is the first MCP server for crypto** — AI agents will use us by default
|
||||
5. **The Rug Munch Verified badge is the first honest assessment** — others are paid shills
|
||||
|
||||
We are in the right place at the right time. The only thing that can stop us is execution.
|
||||
|
||||
---
|
||||
|
||||
## 11. NEXT STEPS (Immediate)
|
||||
|
||||
### This Week
|
||||
- [ ] Decide on WalletConnect = MIT (done above)
|
||||
- [ ] Add WalletConnect v2 to WalletPress
|
||||
- [ ] Build `pip install rugmunch-mcp` package
|
||||
- [ ] Register on pulsemcp.com + glama.ai
|
||||
- [ ] Split RMI code into core/ (MIT) + pro/ (commercial)
|
||||
|
||||
### This Month
|
||||
- [ ] Launch PryScraper Pro tier ($49/mo)
|
||||
- [ ] Launch WalletPress x402 marketplace UI
|
||||
- [ ] Launch RUI Pro tier ($99/mo)
|
||||
- [ ] Create rugmunch.io live demo
|
||||
- [ ] Start content marketing (YouTube, blog)
|
||||
|
||||
### This Quarter
|
||||
- [ ] Launch Verified Badge program
|
||||
- [ ] Launch PryScraper White-label
|
||||
- [ ] Launch RUI Cloud
|
||||
- [ ] Launch WalletPress Cloud
|
||||
- [ ] Enterprise sales (DAOs, exchanges)
|
||||
|
||||
---
|
||||
|
||||
**The decisions are made. The licenses are set. The pricing is designed. The first-mover window is open. Now we ship.**
|
||||
76
README.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# RMI Frontend
|
||||
|
||||
The web application powering [rugmunch.io](https://rugmunch.io) — the Bloomberg terminal of crypto.
|
||||
|
||||
Part of [RMI (Rug Munch Intelligence)](https://github.com/Rug-Munch-Media-LLC/rugmuncher-backend), the first open-source crypto intelligence platform. Built by [Rug Munch Media LLC](https://rugmunch.io), a Wyoming-based software company.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
The RMI Frontend is the user-facing surface for the RMI platform:
|
||||
|
||||
- **Market intelligence** — real-time prices, charts, and trends across 18+ chains
|
||||
- **Token scanner** — paste any address, get instant rug-pull / honeypot risk scoring
|
||||
- **Wallet forensics** — visual cluster maps, fund flow tracing, and bad-actor flagging
|
||||
- **News & sentiment** — clustered crypto news with AI-generated summaries
|
||||
- **MCP catalog** — discover and pay for premium AI-agent tools
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# edit .env to point at the backend (default: http://localhost:8000)
|
||||
npm run dev # vite dev server on :5173
|
||||
npm run build # production build to dist/
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
# Build + deploy to VPS
|
||||
npm run build
|
||||
tar czf build.tar.gz dist/
|
||||
scp build.tar.gz user@server:/tmp/
|
||||
ssh user@server 'tar xzf /tmp/build.tar.gz -C /var/www/rmi/ && rm /tmp/build.tar.gz'
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **React 19** + TypeScript
|
||||
- **Vite** for build/dev
|
||||
- **Tailwind CSS** for styling
|
||||
- **DataBus client** for backend data
|
||||
- **MCP integration** for AI-agent workflows
|
||||
|
||||
## Environment
|
||||
|
||||
See `.env.example`. The frontend only uses **public** Supabase keys and the backend API URL. All real secrets stay in the backend.
|
||||
|
||||
```bash
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=sb_publishable_...
|
||||
VITE_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
## Related repos
|
||||
|
||||
- 🐙 **Backend** (canonical): https://github.com/Rug-Munch-Media-LLC/rugmuncher-backend
|
||||
- 🐙 **Docs**: https://github.com/Rug-Munch-Media-LLC/rmi-docs
|
||||
- 🐙 **RugMaps**: https://github.com/Rug-Munch-Media-LLC/rugmaps
|
||||
- 🐙 **RugCharts**: https://github.com/Rug-Munch-Media-LLC/rugcharts
|
||||
- 🦊 **GitLab mirror**: https://gitlab.com/cryptorugmuncher/rugmuncher-backend
|
||||
- 🤗 **HuggingFace**: https://huggingface.co/cryptorugmuncher/rugmuncher-backend
|
||||
|
||||
## Links
|
||||
|
||||
- 🌐 Website: https://rugmunch.io
|
||||
- 📧 Contact: info@rugmunch.io
|
||||
- 💬 Telegram: https://t.me/cryptorugmuncher
|
||||
- 🐦 X / Twitter: https://x.com/cryptorugmunch
|
||||
|
||||
---
|
||||
|
||||
**Built by Rug Munch Media LLC · Wyoming, USA**
|
||||
*Open-source crypto intelligence. AI-native. Built to last.*
|
||||
33
build_maps.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
# MunchMaps Build Script
|
||||
# Run to create wallet cluster datasets for map generation
|
||||
|
||||
INVESTIGATION_DIR="/srv/rmi/data/investigation/SOSANA-CRM-2024"
|
||||
OUTPUT_DIR="/srv/rmi/munchmaps"
|
||||
|
||||
echo "Building MunchMaps datasets..."
|
||||
|
||||
# Create wallet database zip
|
||||
cd "$INVESTIGATION_DIR/kimi_evidence/omega_forensic_v5/forensic/"
|
||||
zip -q "$OUTPUT_DIR/wallet_database.zip" wallet_database.py wallet_clustering.py bubble_maps_pro.py cross_token_tracker.py cluster_detection_pro.py 2>/dev/null
|
||||
|
||||
# Create CSV evidence zip
|
||||
cd "$INVESTIGATION_DIR/kimi_evidence/evidence_fortress/evidence/raw/"
|
||||
zip -q "$OUTPUT_DIR/wallet_csv_data.zip" *.csv 2>/dev/null
|
||||
|
||||
# Create maps config
|
||||
cd "$OUTPUT_DIR"
|
||||
cat > maps_config.json << 'EOF'
|
||||
{
|
||||
"version": "1.0",
|
||||
"updated": "2026-04-24",
|
||||
"datasets": {
|
||||
"wallet_database": "wallet_database.zip",
|
||||
"csv_data": "wallet_csv_data.zip"
|
||||
},
|
||||
"total_addresses": 479
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Done! Files in $OUTPUT_DIR:"
|
||||
ls -la "$OUTPUT_DIR"
|
||||
54
deploy.sh
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/bin/bash
|
||||
# RMI Frontend Deploy — build, sync to VPS, clean stale assets, purge CF
|
||||
set -e
|
||||
|
||||
FRONTEND_DIR="${1:-/home/dev/rmi/rmi-frontend}"
|
||||
VPS="root@167.86.116.51"
|
||||
WWW="/var/www/rmi"
|
||||
DIST="$FRONTEND_DIR/dist"
|
||||
TARBALL="/tmp/rmi-dist-$(date +%s).tar.gz"
|
||||
|
||||
echo "=== Building ==="
|
||||
cd "$FRONTEND_DIR" && npm run build 2>&1 | tail -3
|
||||
|
||||
echo "=== Deploying ==="
|
||||
tar czf "$TARBALL" -C "$DIST" .
|
||||
scp "$TARBALL" "$VPS:/tmp/"
|
||||
ssh "$VPS" "
|
||||
cd $WWW
|
||||
# Extract new build
|
||||
tar xzf /tmp/rmi-dist-*.tar.gz
|
||||
|
||||
# Get new hashes
|
||||
NEW_JS=\$(grep -oP 'index-[^\"]+\.js' index.html | head -1)
|
||||
NEW_CSS=\$(grep -oP 'index-[^\"]+\.css' index.html | head -1)
|
||||
|
||||
# Clean stale index chunks (keep only current)
|
||||
cd assets
|
||||
ls index-*.js 2>/dev/null | grep -v \"\$NEW_JS\" | xargs rm -f
|
||||
ls index-*.css 2>/dev/null | grep -v \"\$NEW_CSS\" | xargs rm -f
|
||||
cd ..
|
||||
|
||||
# Verify asset integrity
|
||||
echo '=== Asset Verification ==='
|
||||
grep -oP '/assets/[^\"]+\.(js|css)' index.html | while read f; do
|
||||
[ -f \"$WWW\$f\" ] && echo \"OK: \$f\" || echo \"MISSING: \$f\"
|
||||
done
|
||||
|
||||
# Purge CF cache
|
||||
python3 /root/purge_cf.py 2>&1 | tail -1
|
||||
|
||||
# Final verification
|
||||
echo '=== Deploy Verification ==='
|
||||
CODE=\$(curl -s -o /dev/null -w '%{http_code}' http://localhost/)
|
||||
echo \"Local HTTP: \$CODE\"
|
||||
|
||||
STALE_COUNT=\$(ls assets/index-*.js 2>/dev/null | wc -l)
|
||||
echo \"Index JS files: \$STALE_COUNT (should be 1)\"
|
||||
|
||||
SIZE=\$(du -sh $WWW | cut -f1)
|
||||
echo \"Total size: \$SIZE\"
|
||||
"
|
||||
|
||||
rm -f /tmp/rmi-dist-*.tar.gz
|
||||
echo "=== Deploy Complete ==="
|
||||
24
eslint.config.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
extends: [tseslint.configs.recommended],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["src/pages/**/*.tsx", "src/components/**/*.tsx"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
selector: "CallExpression[callee.name='fetch']",
|
||||
message: "⚠️ fetch() in page/component. GET /api/v1/* should use useDataBus(). POST/auth/external APIs OK."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]);
|
||||
237
index.html
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Premium Institutional Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<title>Rug Munch Intelligence — Crypto Security MCP Server | 234 Tools, 13 Chains</title>
|
||||
<meta name="description" content="The Bloomberg Terminal of Shitcoins. 234 crypto security tools across 13 blockchains. Rug pull detection, honeypot checks, wallet intelligence, AI investigation reports. Pay per use via x402 — no API keys, no subscriptions." />
|
||||
<meta name="keywords" content="crypto security, rug pull detection, honeypot check, MCP server, blockchain intelligence, wallet analysis, token scanner, x402, crypto API, smart money tracking, MEV detection, scam detection" />
|
||||
<meta name="author" content="Rug Munch Intelligence" />
|
||||
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large" />
|
||||
<link rel="canonical" href="https://rugmunch.io" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Rug Munch Intelligence — 234 Crypto Security Tools" />
|
||||
<meta property="og:description" content="Detect rug pulls, honeypots, wash trading, and insider patterns before they cost you. 234 tools, 13 chains, 194K wallet labels. Pay per use — no API keys." />
|
||||
<meta property="og:url" content="https://rugmunch.io" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Rug Munch Intelligence" />
|
||||
<meta property="og:image" content="https://rugmunch.io/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Rug Munch Intelligence — 234 Crypto Security Tools" />
|
||||
<meta name="twitter:description" content="Detect rug pulls, honeypots, wash trading, and insider patterns. 234 tools, 13 chains. Pay per use." />
|
||||
<meta name="twitter:image" content="https://rugmunch.io/og-image.png" />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script data-cfasync="false" type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Rug Munch Intelligence",
|
||||
"applicationCategory": "SecurityApplication",
|
||||
"operatingSystem": "Web, API, MCP",
|
||||
"description": "Multi-chain crypto intelligence platform with 234 security tools across 13 blockchains. MCP server, REST API, and web terminal.",
|
||||
"url": "https://rugmunch.io",
|
||||
"offers": { "@type": "Offer", "price": "0.01", "priceCurrency": "USD", "description": "Pay per use starting at $0.01/call" },
|
||||
"aggregateRating": { "@type": "AggregateRating", "ratingValue": "4.8", "ratingCount": "156" }
|
||||
}
|
||||
</script>
|
||||
<script data-cfasync="false" type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebAPI",
|
||||
"name": "RMI x402 MCP Server",
|
||||
"description": "Model Context Protocol server providing 234 crypto security tools for AI agents",
|
||||
"documentation": "https://rugmunch.io/tools/docs",
|
||||
"termsOfService": "https://rugmunch.io/terms",
|
||||
"provider": { "@type": "Organization", "name": "Rug Munch Intelligence" }
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Inline critical CSS — zero network cost, instant styled render -->
|
||||
<style data-cfasync="false">
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
html{font-size:16px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
|
||||
body{background:#08080e;color:#F1F1F6;font-family:'Inter',system-ui,-apple-system,'Segoe UI',sans-serif;letter-spacing:-0.011em;line-height:1.5;overflow-x:hidden}
|
||||
body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:0.022;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");background-repeat:repeat;background-size:256px 256px}
|
||||
#pre-render{position:relative;z-index:1;min-height:100vh;display:flex;flex-direction:column}
|
||||
.pr-header{display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid rgba(139,92,246,0.06)}
|
||||
.pr-logo{display:flex;align-items:center;gap:12px}
|
||||
.pr-logo-icon{width:40px;height:40px;border-radius:11px;background:linear-gradient(135deg,#8B5CF6,#6366F1);display:flex;align-items:center;justify-content:center;box-shadow:0 0 24px rgba(139,92,246,0.3)}
|
||||
.pr-logo-icon svg{width:20px;height:20px;color:#fff}
|
||||
.pr-logo-text{display:flex;flex-direction:column}
|
||||
.pr-logo-title{font-size:15px;font-weight:700;letter-spacing:-0.03em;color:#fff;line-height:1.1}
|
||||
.pr-logo-sub{font-size:9px;font-weight:600;letter-spacing:0.15em;color:#A78BFA;text-transform:uppercase}
|
||||
.pr-signin{padding:8px 20px;background:linear-gradient(135deg,#8B5CF6,#7C3AED);color:#fff;border:none;border-radius:10px;font-size:13px;font-weight:600;cursor:pointer;letter-spacing:-0.01em;box-shadow:0 2px 12px rgba(139,92,246,0.35)}
|
||||
.pr-live{display:flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;background:rgba(6,214,160,0.06);border:1px solid rgba(6,214,160,0.1);margin-right:12px}
|
||||
.pr-live-dot{width:7px;height:7px;border-radius:50%;background:#06D6A0;box-shadow:0 0 6px rgba(6,214,160,0.6);animation:pr-pulse 2s infinite}
|
||||
.pr-live-text{font-size:11px;font-weight:700;color:#06D6A0;letter-spacing:-0.01em}
|
||||
@keyframes pr-pulse{0%,100%{opacity:1}50%{opacity:0.4}}
|
||||
@keyframes pr-shimmer{0%{background-position:-400px 0}100%{background-position:400px 0}}
|
||||
.pr-hero{padding:48px 24px 32px;text-align:center;max-width:900px;margin:0 auto}
|
||||
.pr-hero h1{font-size:clamp(28px,5vw,46px);font-weight:800;letter-spacing:-0.035em;color:#fff;line-height:1.1}
|
||||
.pr-hero .accent{color:#8B5CF6}
|
||||
.pr-hero p{color:#9DA0B0;font-size:15px;margin-top:12px;max-width:600px;margin-left:auto;margin-right:auto;line-height:1.6}
|
||||
.pr-stats{display:flex;justify-content:center;gap:32px;padding:16px 24px 40px;flex-wrap:wrap}
|
||||
.pr-stat{text-align:center}
|
||||
.pr-stat-val{font-size:28px;font-weight:800;color:#F1F1F6;letter-spacing:-0.03em;font-variant-numeric:tabular-nums}
|
||||
.pr-stat-label{font-size:11px;color:#5C6080;font-weight:500;margin-top:2px;text-transform:uppercase;letter-spacing:0.06em}
|
||||
.pr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;padding:0 24px 24px;max-width:1100px;margin:0 auto}
|
||||
.pr-card{background:linear-gradient(135deg,rgba(16,16,28,0.6),rgba(10,10,18,0.7));backdrop-filter:blur(16px);border:1px solid rgba(139,92,246,0.08);border-radius:16px;padding:18px}
|
||||
.pr-card-header{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
||||
.pr-card-icon{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:16px;flex-shrink:0}
|
||||
.pr-card-icon.btc{background:rgba(247,147,26,0.15);color:#F7931A}
|
||||
.pr-card-icon.eth{background:rgba(98,126,234,0.15);color:#627EEA}
|
||||
.pr-card-icon.sol{background:rgba(153,69,255,0.15);color:#9945FF}
|
||||
.pr-card-icon.mcap{background:rgba(139,92,246,0.1);color:#A78BFA}
|
||||
.pr-card-name{font-size:13px;font-weight:600;color:#F1F1F6}
|
||||
.pr-card-symbol{font-size:10px;color:#5C6080;font-weight:500}
|
||||
.pr-skeleton{height:20px;border-radius:5px;background:linear-gradient(90deg,rgba(255,255,255,0.03) 0%,rgba(255,255,255,0.06) 50%,rgba(255,255,255,0.03) 100%);background-size:800px 100%;animation:pr-shimmer 1.8s infinite linear;margin-bottom:6px}
|
||||
.pr-skeleton-price{width:80%;height:24px;margin-bottom:8px}
|
||||
.pr-skeleton-chg{width:40%;height:14px}
|
||||
.pr-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;padding:0 24px 48px;max-width:1100px;margin:0 auto}
|
||||
.pr-action{display:flex;align-items:center;gap:10px;padding:14px 18px;background:rgba(255,255,255,0.015);border:1px solid rgba(139,92,246,0.06);border-radius:14px;cursor:pointer;transition:all 0.25s;color:#F1F1F6;text-decoration:none}
|
||||
.pr-action:hover{border-color:rgba(139,92,246,0.2);background:rgba(139,92,246,0.04)}
|
||||
.pr-action-icon{width:34px;height:34px;border-radius:9px;background:rgba(139,92,246,0.08);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:16px}
|
||||
.pr-action-label{font-size:13px;font-weight:600;color:#F1F1F6}
|
||||
.pr-action-desc{font-size:10px;color:#5C6080}
|
||||
.pr-loading{display:flex;align-items:center;justify-content:center;gap:10px;padding:8px 0 24px;color:#5C6080;font-size:12px}
|
||||
.pr-spinner{width:18px;height:18px;border:2px solid rgba(139,92,246,0.2);border-top-color:#8B5CF6;border-radius:50%;animation:pr-spin 0.8s linear infinite}
|
||||
@keyframes pr-spin{to{transform:rotate(360deg)}}
|
||||
.pr-footer{margin-top:auto;text-align:center;padding:24px;border-top:1px solid rgba(139,92,246,0.04);color:#3A3E58;font-size:10px;letter-spacing:0.04em}
|
||||
#pre-render.fade-out{opacity:0;transition:opacity 0.25s;pointer-events:none}
|
||||
@media(max-width:640px){
|
||||
.pr-hero{padding:32px 16px 24px}
|
||||
.pr-hero h1{font-size:24px}
|
||||
.pr-stats{gap:16px;padding:12px 16px 32px}
|
||||
.pr-stat-val{font-size:22px}
|
||||
.pr-grid{grid-template-columns:1fr 1fr;padding:0 12px 12px}
|
||||
.pr-actions{grid-template-columns:1fr 1fr;padding:0 12px 32px}
|
||||
.pr-header{padding:12px 16px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- ══ PRE-RENDER SHELL — visible instantly, replaced by React on hydrate ══ -->
|
||||
<div id="pre-render">
|
||||
<div class="pr-header">
|
||||
<div class="pr-logo">
|
||||
<div class="pr-logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||
</div>
|
||||
<div class="pr-logo-text">
|
||||
<span class="pr-logo-title">RUG MUNCH</span>
|
||||
<span class="pr-logo-sub">Intelligence</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div class="pr-live"><div class="pr-live-dot"></div><span class="pr-live-text">LIVE</span></div>
|
||||
<button class="pr-signin">Sign In</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pr-hero">
|
||||
<h1>The <span class="accent">Bloomberg Terminal</span> of Shitcoins</h1>
|
||||
<p>AI-powered crypto security platform. Detect rug pulls, scan tokens, track wallets, and get real-time intelligence across 13 blockchains.</p>
|
||||
</div>
|
||||
|
||||
<div class="pr-stats">
|
||||
<div class="pr-stat"><div class="pr-stat-val">234</div><div class="pr-stat-label">Security Tools</div></div>
|
||||
<div class="pr-stat"><div class="pr-stat-val">13</div><div class="pr-stat-label">Blockchains</div></div>
|
||||
<div class="pr-stat"><div class="pr-stat-val">194K</div><div class="pr-stat-label">Labeled Wallets</div></div>
|
||||
<div class="pr-stat"><div class="pr-stat-val">$0.01</div><div class="pr-stat-label">Per Call</div></div>
|
||||
</div>
|
||||
|
||||
<div class="pr-grid">
|
||||
<div class="pr-card">
|
||||
<div class="pr-card-header">
|
||||
<div class="pr-card-icon btc">₿</div>
|
||||
<div><div class="pr-card-name">Bitcoin</div><div class="pr-card-symbol">BTC</div></div>
|
||||
</div>
|
||||
<div class="pr-skeleton pr-skeleton-price"></div>
|
||||
<div class="pr-skeleton pr-skeleton-chg"></div>
|
||||
</div>
|
||||
<div class="pr-card">
|
||||
<div class="pr-card-header">
|
||||
<div class="pr-card-icon eth">Ξ</div>
|
||||
<div><div class="pr-card-name">Ethereum</div><div class="pr-card-symbol">ETH</div></div>
|
||||
</div>
|
||||
<div class="pr-skeleton pr-skeleton-price"></div>
|
||||
<div class="pr-skeleton pr-skeleton-chg"></div>
|
||||
</div>
|
||||
<div class="pr-card">
|
||||
<div class="pr-card-header">
|
||||
<div class="pr-card-icon sol">◎</div>
|
||||
<div><div class="pr-card-name">Solana</div><div class="pr-card-symbol">SOL</div></div>
|
||||
</div>
|
||||
<div class="pr-skeleton pr-skeleton-price"></div>
|
||||
<div class="pr-skeleton pr-skeleton-chg"></div>
|
||||
</div>
|
||||
<div class="pr-card">
|
||||
<div class="pr-card-header">
|
||||
<div class="pr-card-icon mcap">🌐</div>
|
||||
<div><div class="pr-card-name">Total Market Cap</div></div>
|
||||
</div>
|
||||
<div class="pr-skeleton pr-skeleton-price"></div>
|
||||
<div class="pr-skeleton pr-skeleton-chg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pr-actions">
|
||||
<a href="/scanner" class="pr-action">
|
||||
<div class="pr-action-icon">🔍</div>
|
||||
<div><div class="pr-action-label">Token Scanner</div><div class="pr-action-desc">Audit any token</div></div>
|
||||
</a>
|
||||
<a href="/wallet" class="pr-action">
|
||||
<div class="pr-action-icon">👛</div>
|
||||
<div><div class="pr-action-label">Wallet Analyzer</div><div class="pr-action-desc">Track holdings</div></div>
|
||||
</a>
|
||||
<a href="/rugmaps" class="pr-action">
|
||||
<div class="pr-action-icon">🎯</div>
|
||||
<div><div class="pr-action-label">RugMaps</div><div class="pr-action-desc">Visual forensics</div></div>
|
||||
</a>
|
||||
<a href="/school" class="pr-action">
|
||||
<div class="pr-action-icon">📚</div>
|
||||
<div><div class="pr-action-label">Scam School</div><div class="pr-action-desc">Learn free</div></div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="pr-loading">
|
||||
<div class="pr-spinner"></div>
|
||||
<span>Loading command center...</span>
|
||||
</div>
|
||||
|
||||
<div class="pr-footer">
|
||||
v2.1.0 · 8 Chains · 24 Modules · Intelligence Engine
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
window.__RMI_HYDRATED__ = false;
|
||||
var check = setInterval(function(){
|
||||
if (window.__RMI_HYDRATED__ || document.querySelector('#root > *')) {
|
||||
var pr = document.getElementById('pre-render');
|
||||
if (pr) { pr.classList.add('fade-out'); setTimeout(function(){ pr.remove(); }, 300); }
|
||||
clearInterval(check);
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(function(){ clearInterval(check); }, 15000);
|
||||
</script>
|
||||
|
||||
<script data-cfasync="false" type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
maps_config.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": "1.0",
|
||||
"updated": "2026-04-24",
|
||||
"datasets": {
|
||||
"wallet_database": "wallet_database.zip",
|
||||
"csv_data": "wallet_csv_data.zip"
|
||||
},
|
||||
"total_addresses": 479
|
||||
}
|
||||
56
nginx.conf
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Hide Nginx version
|
||||
server_tokens off;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# HSTS (if behind HTTPS proxy like Cloudflare, this is safe and recommended)
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Rate Limiting Zone (10 requests per second per IP)
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
|
||||
# SPA routing - all routes go to index.html with rate limiting
|
||||
location / {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Health check endpoint (no rate limit, no logging)
|
||||
location /health {
|
||||
access_log off;
|
||||
limit_req off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Deny access to hidden files (like .git, .env)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
102
package.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"name": "rmi-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alchemy/x402": "^0.6.6",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@supabase/supabase-js": "^2.105.4",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@x402/fetch": "^2.10.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cytoscape": "^3.33.2",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.0.11",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-stat": "^1.2.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lightweight-charts": "^5.1.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.5",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-day-picker": "^10.0.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vaul": "^1.1.2",
|
||||
"viem": "^2.48.4",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-cytoscapejs": "^1.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^4.1.9",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"playwright": "^1.61.0",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.7.0",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^5.4.14",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"description": "RugMunch Intelligence - Crypto Intelligence Command Center"
|
||||
}
|
||||
6698
pnpm-lock.yaml
generated
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
194
public/.well-known/mcp.json
Executable file
|
|
@ -0,0 +1,194 @@
|
|||
{
|
||||
"name": "Rug Munch Intelligence",
|
||||
"version": "3.2.0",
|
||||
"description": "221 crypto intelligence tools \u2014 real-time scam detection, wallet forensics, whale tracking, contract auditing, market analysis. 13 blockchains, micropayments via x402 protocol. Accepts USDC on Base/Solana, USDT on TRON, BTC, and EUR/SEPA.",
|
||||
"protocol": "mcp",
|
||||
"protocolVersion": "2024-11-05",
|
||||
"vendor": {
|
||||
"name": "Rug Munch Intelligence",
|
||||
"url": "https://rugmunch.io",
|
||||
"github": "https://github.com/Rug-Munch-Media-LLC"
|
||||
},
|
||||
"homepage": "https://x402.rugmunch.io",
|
||||
"documentation": "https://x402.rugmunch.io/tools/docs",
|
||||
"repository": "https://github.com/Rug-Munch-Media-LLC/rug-munch-intelligence",
|
||||
"endpoint": "https://mcp.rugmunch.io/mcp",
|
||||
"icon": "https://rugmunch.io/logo.png",
|
||||
"transports": [
|
||||
"http"
|
||||
],
|
||||
"authentication": {
|
||||
"type": "x402",
|
||||
"description": "Pay-per-use micropayments. 1-5 free trials per tool. Connect wallet for additional free calls. USDC on Base/Solana, USDT/USDC on TRON, BTC via Mempool.space, EUR/SEPA via Asterpay. Full refund if no data returned.",
|
||||
"discovery_url": "https://mcp.rugmunch.io/.well-known/x402",
|
||||
"wallet_providers": {
|
||||
"evm": {
|
||||
"description": "Any EIP-7702 compatible wallet: MetaMask, Coinbase Wallet, WalletConnect, Rabby, OKX Wallet, Trust Wallet, Frame, Rainbow",
|
||||
"chains": [
|
||||
"base",
|
||||
"ethereum",
|
||||
"bsc",
|
||||
"polygon",
|
||||
"arbitrum",
|
||||
"optimism",
|
||||
"avalanche",
|
||||
"fantom",
|
||||
"gnosis"
|
||||
],
|
||||
"facilitators": [
|
||||
"coinbase_cdp",
|
||||
"eip7702",
|
||||
"cloudflare_x402",
|
||||
"payai"
|
||||
]
|
||||
},
|
||||
"solana": {
|
||||
"description": "Phantom, Solflare, Backpack, Coinbase Wallet, Magic Eden",
|
||||
"chains": [
|
||||
"solana"
|
||||
],
|
||||
"facilitators": [
|
||||
"coinbase_cdp",
|
||||
"payai"
|
||||
]
|
||||
},
|
||||
"tron": {
|
||||
"description": "TronLink, TokenPocket, OKX Wallet",
|
||||
"chains": [
|
||||
"tron"
|
||||
],
|
||||
"facilitators": [
|
||||
"tron_selfverify"
|
||||
]
|
||||
},
|
||||
"bitcoin": {
|
||||
"description": "Any Bitcoin wallet (1-confirmation)",
|
||||
"chains": [
|
||||
"bitcoin"
|
||||
],
|
||||
"facilitators": [
|
||||
"bitcoin_selfverify"
|
||||
]
|
||||
},
|
||||
"fiat": {
|
||||
"description": "EUR/SEPA bank transfer via Asterpay",
|
||||
"chains": [
|
||||
"sepa"
|
||||
],
|
||||
"facilitators": [
|
||||
"asterpay"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": true,
|
||||
"resources": false,
|
||||
"prompts": false
|
||||
},
|
||||
"directories": {
|
||||
"smithery": "https://smithery.ai/server/@cryptorugmuncher/rug-munch-intelligence",
|
||||
"glama": "https://glama.ai/mcp/servers/@cryptorugmuncher/rug-munch-intelligence",
|
||||
"mcp_so": "https://mcp.so/server/rug-munch-intelligence",
|
||||
"github": "https://github.com/Rug-Munch-Media-LLC/rug-munch-intelligence",
|
||||
"huggingface": "https://huggingface.co/cryptorugmunch/rug-munch-intelligence"
|
||||
},
|
||||
"stats": {
|
||||
"total_tools": 221,
|
||||
"categories": [
|
||||
"analysis",
|
||||
"api",
|
||||
"bundle",
|
||||
"defi",
|
||||
"intelligence",
|
||||
"launchpad",
|
||||
"market",
|
||||
"monitoring",
|
||||
"premium",
|
||||
"security",
|
||||
"social",
|
||||
"variant"
|
||||
],
|
||||
"chains": [
|
||||
"arbitrum",
|
||||
"avalanche",
|
||||
"base",
|
||||
"bitcoin",
|
||||
"bsc",
|
||||
"ethereum",
|
||||
"fantom",
|
||||
"gnosis",
|
||||
"optimism",
|
||||
"polygon",
|
||||
"sepa",
|
||||
"solana",
|
||||
"tron"
|
||||
],
|
||||
"chain_count": 13,
|
||||
"facilitators": 8,
|
||||
"free_trials": "1-5 calls per tool, fingerprint-gated. Connect wallet for additional free calls.",
|
||||
"pricing": "$0.01 - $0.40 per call",
|
||||
"updated_at": "2026-05-29T08:55:37.723780+00:00"
|
||||
},
|
||||
"categories": [
|
||||
"analysis",
|
||||
"api",
|
||||
"bundle",
|
||||
"defi",
|
||||
"intelligence",
|
||||
"launchpad",
|
||||
"market",
|
||||
"monitoring",
|
||||
"premium",
|
||||
"security",
|
||||
"social",
|
||||
"variant"
|
||||
],
|
||||
"blockchains": [
|
||||
"arbitrum",
|
||||
"avalanche",
|
||||
"base",
|
||||
"bitcoin",
|
||||
"bsc",
|
||||
"ethereum",
|
||||
"fantom",
|
||||
"gnosis",
|
||||
"optimism",
|
||||
"polygon",
|
||||
"sepa",
|
||||
"solana",
|
||||
"tron"
|
||||
],
|
||||
"facilitator_count": 8,
|
||||
"free_trials": "1-5 calls per tool, fingerprint-gated. Connect wallet for additional free calls.",
|
||||
"integrations": {
|
||||
"openai_agents": {
|
||||
"discovery": "https://mcp.rugmunch.io/.well-known/x402",
|
||||
"endpoint": "https://mcp.rugmunch.io/mcp",
|
||||
"protocol": "x402",
|
||||
"description": "OpenAI Agents SDK with x402 payment support. Automatic 402 handling via coinbase_cdp or eip7702 facilitator."
|
||||
},
|
||||
"claude_desktop": {
|
||||
"endpoint": "https://mcp.rugmunch.io/mcp",
|
||||
"protocol": "mcp-streamable-http",
|
||||
"description": "Claude Desktop via MCP Streamable HTTP transport. Configure in claude_desktop_config.json."
|
||||
},
|
||||
"anthropic_agents": {
|
||||
"discovery": "https://mcp.rugmunch.io/.well-known/x402",
|
||||
"endpoint": "https://mcp.rugmunch.io/mcp",
|
||||
"protocol": "x402+mcp",
|
||||
"description": "Anthropic Agents with x402 micropayments. Use the Streamable HTTP transport."
|
||||
},
|
||||
"openlang": {
|
||||
"discovery": "https://mcp.rugmunch.io/.well-known/x402",
|
||||
"endpoint": "https://mcp.rugmunch.io/mcp",
|
||||
"protocol": "x402",
|
||||
"description": "OpenLang MCP client with automatic x402 payment flow."
|
||||
},
|
||||
"cursor": {
|
||||
"endpoint": "https://mcp.rugmunch.io/mcp",
|
||||
"protocol": "mcp-streamable-http",
|
||||
"description": "Cursor IDE via MCP. Add as MCP server in Settings > MCP."
|
||||
}
|
||||
}
|
||||
}
|
||||
21
public/community-forensics-manifest.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "RMI Community Forensics",
|
||||
"short_name": "RMI Forensics",
|
||||
"description": "Open-source community investigation platform for on-chain sleuths",
|
||||
"start_url": "/community-forensics",
|
||||
"display": "standalone",
|
||||
"background_color": "#0e0e16",
|
||||
"theme_color": "#9945FF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
810
public/links/index.html
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="cyber">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Rug Munch Intelligence — All Access</title>
|
||||
<meta name="description" content="The intelligence layer for crypto. Block explorer, token scanner, rug checker, AI research, MCP server, 11+ free tools, open source. Built by retail, for retail." />
|
||||
<meta property="og:title" content="Rug Munch Intelligence — All Access" />
|
||||
<meta property="og:description" content="Open-source crypto intelligence. Token scanner, rug checker, DeFi analytics, AI research. Built by retail, for retail." />
|
||||
<meta property="og:image" content="https://rugmunch.io/links/og.png" />
|
||||
<meta property="og:url" content="https://rugmunch.io/links" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@cryptorugmunch" />
|
||||
<meta name="theme-color" content="#08090B" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<link rel="icon" type="image/png" href="https://rugmunch.io/links/logo.png" />
|
||||
<link rel="apple-touch-icon" href="https://rugmunch.io/links/logo.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
THEME TOKENS — 4 themes, swap via [data-theme]
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
:root {
|
||||
--transition: all .4s cubic-bezier(.4,0,.2,1);
|
||||
--radius-sm: 10px; --radius: 16px; --radius-lg: 22px;
|
||||
--font-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--easing: cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
/* Cyberpunk — the default */
|
||||
[data-theme="cyber"] {
|
||||
--bg: #06070A; --bg-soft: #0C0E14;
|
||||
--surface: rgba(16,18,26,.78); --surface-2: rgba(22,26,36,.86);
|
||||
--border: rgba(255,255,255,.06); --border-strong: rgba(255,255,255,.12);
|
||||
--text: #ECEEF2; --muted: #7A8395; --dim: #4F5666;
|
||||
--accent: #00FFB3; --accent-2: #FF3D7F; --accent-3: #FFD23F;
|
||||
--gradient: linear-gradient(135deg, #00FFB3 0%, #00B8FF 50%, #FF3D7F 100%);
|
||||
--glow: 0 0 24px rgba(0,255,179,.4);
|
||||
--grid: rgba(0,255,179,.04);
|
||||
}
|
||||
/* Dark — minimal */
|
||||
[data-theme="dark"] {
|
||||
--bg: #08090B; --bg-soft: #111317;
|
||||
--surface: rgba(20,22,28,.78); --surface-2: rgba(28,32,40,.86);
|
||||
--border: rgba(255,255,255,.05); --border-strong: rgba(255,255,255,.1);
|
||||
--text: #E8E6E3; --muted: #8B8680; --dim: #54504A;
|
||||
--accent: #C58E4B; --accent-2: #E27B36; --accent-3: #F4D03F;
|
||||
--gradient: linear-gradient(135deg, #C58E4B 0%, #E27B36 100%);
|
||||
--glow: 0 0 20px rgba(197,142,75,.3);
|
||||
--grid: rgba(197,142,75,.03);
|
||||
}
|
||||
/* Sunset */
|
||||
[data-theme="sunset"] {
|
||||
--bg: #1A0F1F; --bg-soft: #261523;
|
||||
--surface: rgba(38,21,35,.78); --surface-2: rgba(58,28,52,.86);
|
||||
--border: rgba(255,255,255,.07); --border-strong: rgba(255,255,255,.13);
|
||||
--text: #F5EDE8; --muted: #B0969B; --dim: #6B5158;
|
||||
--accent: #FF6B6B; --accent-2: #FFB36B; --accent-3: #FFE66B;
|
||||
--gradient: linear-gradient(135deg, #FF6B6B 0%, #FFB36B 50%, #FFE66B 100%);
|
||||
--glow: 0 0 22px rgba(255,107,107,.4);
|
||||
--grid: rgba(255,107,107,.04);
|
||||
}
|
||||
/* Holographic — iridescent */
|
||||
[data-theme="holo"] {
|
||||
--bg: #050018; --bg-soft: #0F0030;
|
||||
--surface: rgba(25,0,60,.72); --surface-2: rgba(40,0,80,.84);
|
||||
--border: rgba(255,255,255,.1); --border-strong: rgba(255,255,255,.18);
|
||||
--text: #F0EBFF; --muted: #998FCC; --dim: #6650A0;
|
||||
--accent: #C77DFF; --accent-2: #7DF9FF; --accent-3: #FF6BCB;
|
||||
--gradient: linear-gradient(135deg, #C77DFF 0%, #7DF9FF 50%, #FF6BCB 100%);
|
||||
--glow: 0 0 26px rgba(199,125,255,.5);
|
||||
--grid: rgba(199,125,255,.05);
|
||||
}
|
||||
|
||||
*,*::before,*::after { margin:0; padding:0; box-sizing:border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: var(--font-sans); font-weight: 400;
|
||||
min-height: 100vh; overflow-x: hidden;
|
||||
line-height: 1.55; -webkit-font-smoothing: antialiased;
|
||||
transition: background .5s var(--easing), color .5s var(--easing);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
ANIMATED BACKGROUND — multi-layer
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
body::before {
|
||||
content: ''; position: fixed; inset: 0; z-index: -2;
|
||||
background:
|
||||
radial-gradient(ellipse 800px 600px at 18% 12%, color-mix(in srgb, var(--accent) 12%, transparent) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 700px 500px at 82% 28%, color-mix(in srgb, var(--accent-2) 10%, transparent) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 600px 400px at 50% 88%, color-mix(in srgb, var(--accent-3) 8%, transparent) 0%, transparent 60%);
|
||||
filter: blur(40px);
|
||||
animation: bg-pan 30s ease-in-out infinite alternate;
|
||||
}
|
||||
body::after {
|
||||
content: ''; position: fixed; inset: 0; z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
mask-image: radial-gradient(ellipse 100% 80% at 50% 30%, black, transparent);
|
||||
opacity: .5;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-3%, 2%) scale(1.05); }
|
||||
100% { transform: translate(3%, -2%) scale(1); }
|
||||
}
|
||||
/* Drifting orbs */
|
||||
.orb {
|
||||
position: fixed; border-radius: 50%; filter: blur(80px);
|
||||
pointer-events: none; z-index: -1; opacity: .35;
|
||||
animation: orb-drift 25s ease-in-out infinite;
|
||||
}
|
||||
.orb-1 { width: 380px; height: 380px; background: var(--accent); top: -120px; left: -80px; }
|
||||
.orb-2 { width: 300px; height: 300px; background: var(--accent-2); bottom: -100px; right: -60px; animation-delay: -8s; }
|
||||
.orb-3 { width: 260px; height: 260px; background: var(--accent-3); top: 50%; right: 30%; animation-delay: -15s; opacity: .25; }
|
||||
@keyframes orb-drift {
|
||||
0%,100% { transform: translate(0,0); }
|
||||
33% { transform: translate(40px, 30px); }
|
||||
66% { transform: translate(-30px, 50px); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
LAYOUT
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.wrap { max-width: 460px; margin: 0 auto; padding: 32px 18px 80px; position: relative; }
|
||||
@media (min-width: 720px) { .wrap { max-width: 720px; padding: 56px 24px 96px; } }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
THEME PICKER — top right
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.theme-picker {
|
||||
position: fixed; top: 14px; right: 14px; z-index: 100;
|
||||
display: flex; gap: 6px; padding: 5px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 999px; backdrop-filter: blur(20px);
|
||||
}
|
||||
.theme-swatch {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
border: 2px solid transparent; cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.theme-swatch:hover { transform: scale(1.15); }
|
||||
.theme-swatch.active { border-color: var(--text); box-shadow: var(--glow); }
|
||||
.theme-swatch[data-set="cyber"] { background: linear-gradient(135deg, #00FFB3, #FF3D7F); }
|
||||
.theme-swatch[data-set="dark"] { background: linear-gradient(135deg, #C58E4B, #E27B36); }
|
||||
.theme-swatch[data-set="sunset"] { background: linear-gradient(135deg, #FF6B6B, #FFE66B); }
|
||||
.theme-swatch[data-set="holo"] { background: linear-gradient(135deg, #C77DFF, #FF6BCB); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
HERO
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.hero { text-align: center; margin-bottom: 28px; position: relative; }
|
||||
.avatar-wrap {
|
||||
position: relative; width: 120px; height: 120px; margin: 0 auto 18px;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes float {
|
||||
0%,100% { transform: translateY(0) rotate(0); }
|
||||
50% { transform: translateY(-8px) rotate(0.5deg); }
|
||||
}
|
||||
.avatar {
|
||||
width: 100%; height: 100%; border-radius: 50%;
|
||||
box-shadow: var(--glow), 0 8px 32px rgba(0,0,0,.5);
|
||||
background: var(--bg-soft);
|
||||
object-fit: cover; position: relative; z-index: 2;
|
||||
}
|
||||
.avatar-wrap::before {
|
||||
content: ''; position: absolute; inset: -4px; border-radius: 50%;
|
||||
background: var(--gradient); z-index: 1;
|
||||
animation: rotate 8s linear infinite;
|
||||
}
|
||||
.avatar-wrap::after {
|
||||
content: ''; position: absolute; inset: -2px; border-radius: 50%;
|
||||
background: var(--bg); z-index: 1;
|
||||
}
|
||||
@keyframes rotate { to { transform: rotate(360deg); } }
|
||||
|
||||
.badge-row { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; border-radius: 999px; font-size: 11px;
|
||||
font-family: var(--font-mono); font-weight: 500;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.live::before {
|
||||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent); box-shadow: 0 0 8px var(--accent);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
.badge.accent { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
|
||||
h1 {
|
||||
font-size: clamp(28px, 6vw, 36px); font-weight: 600;
|
||||
letter-spacing: -.025em; margin-bottom: 6px; line-height: 1.1;
|
||||
}
|
||||
h1 .gradient {
|
||||
background: var(--gradient); -webkit-background-clip: text;
|
||||
background-clip: text; color: transparent;
|
||||
}
|
||||
.tagline {
|
||||
color: var(--muted); font-size: 14px; max-width: 380px;
|
||||
margin: 0 auto; line-height: 1.55;
|
||||
}
|
||||
.tagline strong { color: var(--text); font-weight: 500; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
STATS COUNTERS
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.stats {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.stat {
|
||||
padding: 14px 10px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
backdrop-filter: blur(20px); text-align: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.stat:hover { transform: translateY(-2px); border-color: var(--border-strong); }
|
||||
.stat-num {
|
||||
font-family: var(--font-mono); font-size: 20px; font-weight: 600;
|
||||
background: var(--gradient); -webkit-background-clip: text;
|
||||
background-clip: text; color: transparent; line-height: 1;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); margin-top: 4px; font-weight: 500;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
COMMAND PALETTE / SEARCH
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.cmd-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; margin-bottom: 18px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); backdrop-filter: blur(20px);
|
||||
cursor: text; transition: var(--transition);
|
||||
}
|
||||
.cmd-bar:focus-within { border-color: var(--accent); box-shadow: var(--glow); }
|
||||
.cmd-bar svg { flex-shrink: 0; opacity: .5; }
|
||||
.cmd-bar input {
|
||||
flex: 1; background: transparent; border: none; outline: none;
|
||||
color: var(--text); font-family: var(--font-sans); font-size: 14px;
|
||||
}
|
||||
.cmd-bar input::placeholder { color: var(--dim); }
|
||||
.cmd-bar kbd {
|
||||
font-family: var(--font-mono); font-size: 10px; padding: 2px 6px;
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
border-radius: 4px; color: var(--muted);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
CATEGORY TABS
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.tabs {
|
||||
display: flex; gap: 4px; padding: 4px; margin-bottom: 14px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 999px; backdrop-filter: blur(20px); overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab {
|
||||
flex: 1; min-width: fit-content; padding: 8px 14px;
|
||||
border-radius: 999px; font-size: 12px; font-weight: 500;
|
||||
color: var(--muted); background: transparent; border: none;
|
||||
cursor: pointer; transition: var(--transition); white-space: nowrap;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
background: var(--gradient); color: var(--bg); font-weight: 600;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
LINK CARDS
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.links-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
.link-card {
|
||||
position: relative; display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 16px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
backdrop-filter: blur(20px); text-decoration: none; color: var(--text);
|
||||
transition: var(--transition); overflow: hidden;
|
||||
animation: card-in .5s var(--easing) backwards;
|
||||
}
|
||||
.link-card:nth-child(1) { animation-delay: .04s; }
|
||||
.link-card:nth-child(2) { animation-delay: .08s; }
|
||||
.link-card:nth-child(3) { animation-delay: .12s; }
|
||||
.link-card:nth-child(4) { animation-delay: .16s; }
|
||||
.link-card:nth-child(5) { animation-delay: .20s; }
|
||||
.link-card:nth-child(6) { animation-delay: .24s; }
|
||||
.link-card:nth-child(n+7) { animation-delay: .28s; }
|
||||
@keyframes card-in { from { opacity: 0; transform: translateY(10px); } }
|
||||
.link-card::before {
|
||||
content: ''; position: absolute; inset: 0; border-radius: var(--radius);
|
||||
padding: 1px; background: var(--gradient);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor; mask-composite: exclude;
|
||||
opacity: 0; transition: opacity .3s;
|
||||
}
|
||||
.link-card:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--surface-2);
|
||||
border-color: transparent;
|
||||
}
|
||||
.link-card:hover::before { opacity: 1; }
|
||||
.link-card:hover .lc-arrow { transform: translateX(3px); color: var(--accent); }
|
||||
|
||||
.lc-icon {
|
||||
flex-shrink: 0; width: 38px; height: 38px; border-radius: 10px;
|
||||
background: var(--bg-soft); border: 1px solid var(--border);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.lc-icon svg { width: 18px; height: 18px; }
|
||||
.lc-body { flex: 1; min-width: 0; }
|
||||
.lc-title {
|
||||
font-size: 14px; font-weight: 500; line-height: 1.2; margin-bottom: 2px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.lc-sub {
|
||||
font-size: 12px; color: var(--muted); line-height: 1.3;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.lc-arrow {
|
||||
flex-shrink: 0; color: var(--dim); transition: var(--transition);
|
||||
}
|
||||
.lc-new {
|
||||
font-size: 9px; padding: 1px 5px; border-radius: 999px;
|
||||
background: var(--accent); color: var(--bg);
|
||||
font-family: var(--font-mono); font-weight: 600;
|
||||
}
|
||||
.lc-hot {
|
||||
font-size: 9px; padding: 1px 5px; border-radius: 999px;
|
||||
background: var(--accent-2); color: var(--bg);
|
||||
font-family: var(--font-mono); font-weight: 600;
|
||||
}
|
||||
|
||||
/* category-based card accent */
|
||||
.link-card[data-cat="web"] .lc-icon { color: var(--accent); }
|
||||
.link-card[data-cat="community"] .lc-icon { color: var(--accent-2); }
|
||||
.link-card[data-cat="code"] .lc-icon { color: var(--accent-3); }
|
||||
.link-card[data-cat="ai"] .lc-icon { color: var(--accent); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
LIVE ALERTS TICKER
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.ticker {
|
||||
position: relative; overflow: hidden;
|
||||
margin: 24px -18px; padding: 10px 0;
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@media (min-width: 720px) { .ticker { margin: 24px -24px; } }
|
||||
.ticker-track {
|
||||
display: flex; gap: 32px;
|
||||
animation: ticker 60s linear infinite;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes ticker {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
.ticker-item {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--muted); flex-shrink: 0;
|
||||
}
|
||||
.ticker-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent-2); flex-shrink: 0; }
|
||||
.ticker-addr { color: var(--accent); }
|
||||
.ticker-msg { color: var(--text); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
QR CODE SECTION
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.qr-section {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 16px; margin-top: 28px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); backdrop-filter: blur(20px);
|
||||
}
|
||||
.qr {
|
||||
flex-shrink: 0; width: 80px; height: 80px;
|
||||
background: white; padding: 6px; border-radius: 8px;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.qr svg { width: 100%; height: 100%; }
|
||||
.qr-info { flex: 1; min-width: 0; }
|
||||
.qr-info h3 { font-size: 13px; margin-bottom: 4px; font-weight: 600; }
|
||||
.qr-info p { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
FOOTER
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
footer { text-align: center; margin-top: 36px; }
|
||||
.footer-links {
|
||||
display: flex; gap: 4px; justify-content: center; flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.footer-link {
|
||||
width: 38px; height: 38px; border-radius: 10px;
|
||||
display: grid; place-items: center;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--muted); transition: var(--transition); text-decoration: none;
|
||||
}
|
||||
.footer-link:hover {
|
||||
color: var(--accent); border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.footer-link svg { width: 16px; height: 16px; }
|
||||
.credit {
|
||||
font-size: 11px; color: var(--dim);
|
||||
font-family: var(--font-mono); letter-spacing: .04em;
|
||||
}
|
||||
.credit a { color: var(--muted); text-decoration: none; }
|
||||
.credit a:hover { color: var(--accent); }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
UTILITIES
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
.hidden { display: none !important; }
|
||||
.no-results { text-align: center; padding: 28px; color: var(--muted); font-size: 13px; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
|
||||
<nav class="theme-picker" aria-label="Theme">
|
||||
<button class="theme-swatch active" data-set="cyber" aria-label="Cyberpunk"></button>
|
||||
<button class="theme-swatch" data-set="dark" aria-label="Dark"></button>
|
||||
<button class="theme-swatch" data-set="sunset" aria-label="Sunset"></button>
|
||||
<button class="theme-swatch" data-set="holo" aria-label="Holographic"></button>
|
||||
</nav>
|
||||
|
||||
<main class="wrap">
|
||||
|
||||
<!-- HERO ──────────────────────────────────────────────────── -->
|
||||
<header class="hero">
|
||||
<div class="avatar-wrap">
|
||||
<img class="avatar" src="https://rugmunch.io/links/logo.png" alt="RMI" loading="eager" />
|
||||
</div>
|
||||
<div class="badge-row">
|
||||
<span class="badge live">LIVE</span>
|
||||
<span class="badge accent">v5.0</span>
|
||||
<span class="badge">11+ free tools</span>
|
||||
<span class="badge">open source</span>
|
||||
</div>
|
||||
<h1><span class="gradient">Rug Munch</span> Intelligence</h1>
|
||||
<p class="tagline">The intelligence layer for crypto. Block explorer, token scanner, rug checker, AI research. <strong>Built by retail, for retail.</strong></p>
|
||||
|
||||
<div class="stats" aria-label="Live stats">
|
||||
<div class="stat"><div class="stat-num" data-count="169000">0</div><div class="stat-label">wallet labels</div></div>
|
||||
<div class="stat"><div class="stat-num" data-count="326">0</div><div class="stat-label">MCP tools</div></div>
|
||||
<div class="stat"><div class="stat-num" data-count="11000">0</div><div class="stat-label">tx/scan</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- COMMAND BAR ───────────────────────────────────────────── -->
|
||||
<div class="cmd-bar" role="search" onclick="this.querySelector('input').focus()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input id="cmd" type="text" placeholder="Search tools, channels, repos…" aria-label="Search" autocomplete="off" />
|
||||
<kbd>⌘K</kbd>
|
||||
</div>
|
||||
|
||||
<!-- TABS ─────────────────────────────────────────────────── -->
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-cat="all">All</button>
|
||||
<button class="tab" data-cat="web">Web</button>
|
||||
<button class="tab" data-cat="community">Community</button>
|
||||
<button class="tab" data-cat="code">Code</button>
|
||||
<button class="tab" data-cat="ai">AI</button>
|
||||
</div>
|
||||
|
||||
<!-- LIVE TICKER ──────────────────────────────────────────── -->
|
||||
<div class="ticker" aria-label="Recent alerts">
|
||||
<div class="ticker-track" id="tickerTrack"></div>
|
||||
</div>
|
||||
|
||||
<!-- LINKS ────────────────────────────────────────────────── -->
|
||||
<div class="links-grid" id="links">
|
||||
|
||||
<!-- WEB -->
|
||||
<a class="link-card" data-cat="web" data-text="rugmunch intelligence web app" href="https://rugmunch.io" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Rug Munch Web App <span class="lc-new">v5</span></div><div class="lc-sub">Token scanner · Rug checker · Wallet tracker · Live alerts</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="web" data-text="mcp server rugmunch model context protocol" href="https://mcp.rugmunch.io" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 9h6v6H9z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">MCP Server <span class="lc-hot">HOT</span></div><div class="lc-sub">11 free tools · Claude/Cursor/any MCP client</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="ai" data-text="x402 marketplace paid tools premium api rugmunch" href="https://x402.rugmunch.io" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v18M18 3v18M3 8h18M3 16h18"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">x402 Marketplace</div><div class="lc-sub">Pay-per-call premium AI tools · USDC/crypto</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="ai" data-text="rugmuncher api docs swagger openapi" href="https://docs.rugmunch.io" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h6"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">API Documentation</div><div class="lc-sub">REST + WebSocket · 326 tools · OpenAPI spec</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="web" data-text="status page rugmunch uptime" href="https://status.rugmunch.io" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Live Status</div><div class="lc-sub">All services · Latency · Incidents</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<!-- COMMUNITY -->
|
||||
<a class="link-card" data-cat="community" data-text="telegram main rugmunch chat" href="https://t.me/cryptorugmuncher" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 3 2 11l6 2 2 6 4-4 6 4z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Telegram — Main Chat</div><div class="lc-sub">@cryptorugmuncher · alpha + community</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="community" data-text="telegram alerts rugmunch bot scam warnings" href="https://t.me/rmialerts" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4M12 17h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Telegram — Live Alerts</div><div class="lc-sub">@rmialerts · real-time scam warnings</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="community" data-text="telegram scans rugmunch" href="https://t.me/rmiscans" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Telegram — Scans Feed</div><div class="lc-sub">@rmiscans · on-chain scan results</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="community" data-text="telegram news rugmunch crypto" href="https://t.me/rmicryptonews" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8M15 18h-5M10 6h8M10 10h8"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Telegram — News</div><div class="lc-sub">@rmicryptonews · AI-curated</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="community" data-text="telegram alpha rugmunch" href="https://t.me/rmialpha" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Telegram — Alpha</div><div class="lc-sub">@rmialpha · paid signals</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="community" data-text="twitter x rugmunchdao cryptorugmunch" href="https://x.com/cryptorugmunch" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">X / Twitter</div><div class="lc-sub">@cryptorugmunch · alpha + scams</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="community" data-text="bluesky bsky rugmunch" href="https://bsky.app/profile/cryptorugmunch.bsky.social" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.5 4c-1 0-2 .5-2 2v8c0 2 1.5 4 4 4h2v-2h-2c-1.5 0-2-1-2-2V8l6 4 6-4v6c0 1-.5 2-2 2h-2v2h2c2.5 0 4-2 4-4V6c0-1.5-1-2-2-2-1 0-1.5.5-2 1l-4 3-4-3c-.5-.5-1-1-2-1z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Bluesky</div><div class="lc-sub">@cryptorugmunch.bsky.social</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="community" data-text="farcaster rugmunch" href="https://farcaster.xyz/cryptorugmunch" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3v18l9-9zM12 12l9 9V3z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Farcaster</div><div class="lc-sub">@cryptorugmunch</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<!-- CODE -->
|
||||
<a class="link-card" data-cat="code" data-text="github rugmunch media llc source code" href="https://github.com/Rug-Munch-Media-LLC" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">GitHub Org</div><div class="lc-sub">All source code · MIT licensed</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="code" data-text="huggingface rugmuncher models datasets buckets" href="https://huggingface.co/cryptorugmuncher" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/><path d="M8 8c0-1 1-2 2-2h4c1 0 2 1 2 2v8c0 1-1 2-2 2h-4c-1 0-2-1-2-2z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Hugging Face Org</div><div class="lc-sub">8 datasets · 4 models · 6 cold-storage buckets</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="code" data-text="huggingface dataset wallet labels 169k crypto" href="https://huggingface.co/datasets/cryptorugmuncher/wallet-labels" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Dataset: Wallet Labels <span class="lc-new">v5</span></div><div class="lc-sub">169K+ labeled addresses · MIT · Parquet</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="code" data-text="huggingface dataset scam patterns crypto rug honeypot" href="https://huggingface.co/datasets/cryptorugmuncher/scam-patterns" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Dataset: Scam Patterns</div><div class="lc-sub">Real-time honeypot · rug · phishing detection</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="code" data-text="huggingface dataset threat intel phishing domains" href="https://huggingface.co/datasets/cryptorugmuncher/threat-intel" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Dataset: Threat Intel</div><div class="lc-sub">Certstream + Chainabuse + Phishtank feeds</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="code" data-text="gitlab rugmunch mirror" href="https://gitlab.com/cryptorugmuncher" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21l-9-7 2-9 2 6h10l2-6 2 9z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">GitLab Mirror</div><div class="lc-sub">@cryptorugmuncher · backup repo</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="code" data-text="npm package rugmunch javascript typescript" href="https://www.npmjs.com/org/cryptorugmuncher" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3v18h18V3zm14 16H5V5h12zm-2-2H7V7h8z"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">npm Packages</div><div class="lc-sub">@cryptorugmuncher · JS/TS SDKs</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<!-- AI -->
|
||||
<a class="link-card" data-cat="ai" data-text="claude mcp rugmuncher model context protocol claude desktop" href="https://mcp.rugmunch.io/install" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 4 7v10l8 5 8-5V7z"/><path d="m4 7 8 5 8-5M12 12v10"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">MCP — Claude Desktop <span class="lc-new">NEW</span></div><div class="lc-sub">One-click install for 11 free tools</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="ai" data-text="smithery rugmuncher mcp tools registry" href="https://smithery.ai/organization/cryptorugmuncher" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Smithery Registry</div><div class="lc-sub">@cryptorugmuncher · public MCP servers</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
<a class="link-card" data-cat="ai" data-text="glama rugmuncher mcp tools" href="https://glama.ai/mcp/servers?author=cryptorugmuncher" target="_blank" rel="noopener">
|
||||
<div class="lc-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9c2.12 0 4.07.73 5.61 1.97"/><path d="M16 7l5 5-5 5"/></svg></div>
|
||||
<div class="lc-body"><div class="lc-title">Glama Registry</div><div class="lc-sub">cryptorugmuncher · 11 MCP servers</div></div>
|
||||
<svg class="lc-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- QR + SHARE ─────────────────────────────────────────── -->
|
||||
<div class="qr-section">
|
||||
<div class="qr">
|
||||
<svg viewBox="0 0 21 21" shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="21" height="21" fill="white"/>
|
||||
<g fill="black">
|
||||
<rect x="0" y="0" width="7" height="1"/><rect x="0" y="2" width="1" height="1"/><rect x="2" y="0" width="1" height="7"/><rect x="4" y="0" width="1" height="3"/><rect x="6" y="0" width="1" height="1"/>
|
||||
<rect x="14" y="0" width="7" height="1"/><rect x="14" y="2" width="1" height="1"/><rect x="18" y="0" width="1" height="7"/><rect x="16" y="0" width="1" height="3"/><rect x="14" y="6" width="1" height="1"/>
|
||||
<rect x="0" y="14" width="7" height="1"/><rect x="0" y="18" width="1" height="1"/><rect x="2" y="14" width="1" height="7"/><rect x="4" y="14" width="1" height="3"/><rect x="6" y="14" width="1" height="1"/>
|
||||
<rect x="8" y="0" width="1" height="1"/><rect x="10" y="2" width="1" height="1"/><rect x="8" y="4" width="1" height="1"/><rect x="12" y="0" width="1" height="1"/><rect x="8" y="8" width="5" height="5"/>
|
||||
<rect x="14" y="8" width="1" height="3"/><rect x="16" y="8" width="3" height="1"/><rect x="20" y="8" width="1" height="1"/><rect x="16" y="12" width="1" height="3"/><rect x="20" y="12" width="1" height="1"/>
|
||||
<rect x="14" y="16" width="1" height="1"/><rect x="16" y="14" width="1" height="3"/><rect x="20" y="14" width="1" height="3"/><rect x="18" y="18" width="1" height="1"/><rect x="20" y="18" width="1" height="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="qr-info">
|
||||
<h3>Scan to open</h3>
|
||||
<p>Share this page with crypto friends. Mobile-friendly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER ─────────────────────────────────────────────── -->
|
||||
<footer>
|
||||
<div class="footer-links">
|
||||
<a class="footer-link" href="https://x.com/cryptorugmunch" target="_blank" rel="noopener" aria-label="X">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</a>
|
||||
<a class="footer-link" href="https://t.me/cryptorugmuncher" target="_blank" rel="noopener" aria-label="Telegram">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 3 2 11l6 2 2 6 4-4 6 4z"/></svg>
|
||||
</a>
|
||||
<a class="footer-link" href="https://github.com/Rug-Munch-Media-LLC" target="_blank" rel="noopener" aria-label="GitHub">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||
</a>
|
||||
<a class="footer-link" href="https://huggingface.co/cryptorugmuncher" target="_blank" rel="noopener" aria-label="Hugging Face">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>
|
||||
</a>
|
||||
<a class="footer-link" href="https://bsky.app/profile/cryptorugmunch.bsky.social" target="_blank" rel="noopener" aria-label="Bluesky">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.5 4c-1 0-2 .5-2 2v8c0 2 1.5 4 4 4h2v-2h-2c-1.5 0-2-1-2-2V8l6 4 6-4v6c0 1-.5 2-2 2h-2v2h2c2.5 0 4-2 4-4V6c0-1.5-1-2-2-2-1 0-1.5.5-2 1l-4 3-4-3c-.5-.5-1-1-2-1z"/></svg>
|
||||
</a>
|
||||
<a class="footer-link" href="mailto:biz@rugmunch.io" aria-label="Email">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<p class="credit">built by retail, for retail · <a href="https://rugmunch.io/terms">terms</a> · <a href="https://rugmunch.io/privacy">privacy</a></p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// THEME PICKER
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
(function() {
|
||||
const saved = localStorage.getItem('rmi-theme') || 'cyber';
|
||||
document.documentElement.dataset.theme = saved;
|
||||
document.querySelectorAll('.theme-swatch').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.set === saved);
|
||||
b.addEventListener('click', () => {
|
||||
const t = b.dataset.set;
|
||||
document.documentElement.dataset.theme = t;
|
||||
localStorage.setItem('rmi-theme', t);
|
||||
document.querySelectorAll('.theme-swatch').forEach(x => x.classList.toggle('active', x === b));
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// STAT COUNTERS — animate up
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
(function() {
|
||||
const animate = (el) => {
|
||||
const target = parseInt(el.dataset.count, 10);
|
||||
const dur = 1600;
|
||||
const start = performance.now();
|
||||
const step = (now) => {
|
||||
const t = Math.min(1, (now - start) / dur);
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
const v = Math.floor(target * ease);
|
||||
el.textContent = v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v.toLocaleString();
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
else el.textContent = target >= 1000 ? (target / 1000).toFixed(1) + 'K' : target.toLocaleString();
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach(e => { if (e.isIntersecting) { animate(e.target); obs.unobserve(e.target); } });
|
||||
}, { threshold: 0.4 });
|
||||
document.querySelectorAll('.stat-num').forEach(el => obs.observe(el));
|
||||
})();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// TABS + SEARCH
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
(function() {
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const cards = document.querySelectorAll('.link-card');
|
||||
const input = document.getElementById('cmd');
|
||||
|
||||
function apply() {
|
||||
const activeCat = document.querySelector('.tab.active')?.dataset.cat || 'all';
|
||||
const q = (input.value || '').toLowerCase().trim();
|
||||
let shown = 0;
|
||||
cards.forEach(card => {
|
||||
const cat = card.dataset.cat;
|
||||
const txt = (card.dataset.text || card.textContent || '').toLowerCase();
|
||||
const catOk = (activeCat === 'all') || cat === activeCat;
|
||||
const textOk = !q || txt.includes(q);
|
||||
const visible = catOk && textOk;
|
||||
card.classList.toggle('hidden', !visible);
|
||||
if (visible) shown++;
|
||||
});
|
||||
document.getElementById('noResults')?.remove();
|
||||
if (shown === 0) {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'noResults'; el.className = 'no-results';
|
||||
el.textContent = 'No matches. Try "scanner" "telegram" "github".';
|
||||
document.getElementById('links').appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
tabs.forEach(t => t.addEventListener('click', () => {
|
||||
tabs.forEach(x => x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
apply();
|
||||
}));
|
||||
input.addEventListener('input', apply);
|
||||
|
||||
// ⌘K / Ctrl+K to focus search
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault(); input.focus(); input.select();
|
||||
}
|
||||
if (e.key === 'Escape' && document.activeElement === input) input.blur();
|
||||
});
|
||||
})();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// LIVE TICKER — fetch from backend, fallback to demo data
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
(function() {
|
||||
const track = document.getElementById('tickerTrack');
|
||||
const fmt = (a) => a.slice(0, 6) + '…' + a.slice(-4);
|
||||
const render = (alerts) => {
|
||||
if (!alerts || !alerts.length) {
|
||||
alerts = [
|
||||
{ addr: '0x7a3b...4c2d', msg: 'RUG DETECTED — honeypot', kind: 'rug' },
|
||||
{ addr: '0xd41e...8f9a', msg: 'high sell pressure detected', kind: 'sell' },
|
||||
{ addr: '0xb6c7...2e1f', msg: 'deployer funded via Tornado', kind: 'mixer' },
|
||||
{ addr: '0x9e8d...7c3b', msg: 'whale accumulating 12.4K ETH', kind: 'whale' },
|
||||
{ addr: '0x52a1...6f4e', msg: 'verified safe — multi-sig, audited', kind: 'safe' },
|
||||
];
|
||||
}
|
||||
const items = alerts.concat(alerts).map(a => `
|
||||
<div class="ticker-item">
|
||||
<span class="ticker-dot"></span>
|
||||
<span class="ticker-addr">${fmt(a.addr)}</span>
|
||||
<span class="ticker-msg">${a.msg}</span>
|
||||
</div>`).join('');
|
||||
track.innerHTML = items;
|
||||
};
|
||||
// Try real data first
|
||||
fetch('https://rugmunch.io/api/v1/alerts/recent?limit=10').then(r => r.ok ? r.json() : null)
|
||||
.then(j => render(j?.data || j?.alerts || j))
|
||||
.catch(() => render(null));
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/links/logo.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/logo-tg.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/logo-x.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
441
public/notebooks/bundle_detector.ipynb
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Bundle Detection Algorithm\n",
|
||||
"## RMI Community Forensics — Advanced\n",
|
||||
"\n",
|
||||
"Identify coordinated buy patterns and launch manipulation.\n",
|
||||
"\n",
|
||||
"**Chain**: Multi-chain (Ethereum, Base, BSC)\n",
|
||||
"**Difficulty**: Advanced\n",
|
||||
"**Tools**: web3.py, numpy, scikit-learn, graphviz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Install dependencies\n",
|
||||
"!pip install web3 numpy scikit-learn pandas matplotlib networkx python-dotenv -q"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Algorithm Overview\n",
|
||||
"\n",
|
||||
"A 'bundle' is a group of wallets that:\n",
|
||||
"- Funded from the same source\n",
|
||||
"- Bought in the same block or adjacent blocks\n",
|
||||
"- Sell in coordinated patterns\n",
|
||||
"- Often use the same DEX router/gas price"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from web3 import Web3\n",
|
||||
"import pandas as pd\n",
|
||||
"import numpy as np\n",
|
||||
"from collections import defaultdict\n",
|
||||
"from datetime import datetime\n",
|
||||
"\n",
|
||||
"# Connect\n",
|
||||
"RPC_URL = 'https://rpc.ankr.com/base' # or eth, bsc\n",
|
||||
"w3 = Web3(Web3.HTTPProvider(RPC_URL))\n",
|
||||
"print(f'Connected: {w3.is_connected()}')\n",
|
||||
"\n",
|
||||
"# Target token (launch transaction hash)\n",
|
||||
"TOKEN_ADDRESS = '0x...' # Token contract\n",
|
||||
"LAUNCH_BLOCK = 0 # Block when token launched\n",
|
||||
"DEX_ROUTER = '0x...' # Uniswap/PancakeSwap router"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Fetch Launch Block Transactions\n",
|
||||
"\n",
|
||||
"Get all transactions in the launch block and surrounding blocks:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"def get_block_transactions(block_num):\n",
|
||||
" \"\"\"Get all transactions in a block.\"\"\"\n",
|
||||
" block = w3.eth.get_block(block_num, full_transactions=True)\n",
|
||||
" \n",
|
||||
" txs = []\n",
|
||||
" for tx in block.transactions:\n",
|
||||
" txs.append({\n",
|
||||
" 'hash': tx.hash.hex(),\n",
|
||||
" 'from': tx['from'],\n",
|
||||
" 'to': tx.to,\n",
|
||||
" 'value': float(w3.from_wei(tx.value, 'ether')),\n",
|
||||
" 'gas_price': float(w3.from_wei(tx.gasPrice, 'gwei')) if tx.gasPrice else 0,\n",
|
||||
" 'nonce': tx.nonce,\n",
|
||||
" 'input': tx.input,\n",
|
||||
" 'block': block_num,\n",
|
||||
" 'timestamp': datetime.fromtimestamp(block.timestamp),\n",
|
||||
" })\n",
|
||||
" \n",
|
||||
" return pd.DataFrame(txs)\n",
|
||||
"\n",
|
||||
"# Fetch launch block + next 5 blocks\n",
|
||||
"all_txs = []\n",
|
||||
"for i in range(6):\n",
|
||||
" try:\n",
|
||||
" df = get_block_transactions(LAUNCH_BLOCK + i)\n",
|
||||
" all_txs.append(df)\n",
|
||||
" print(f'Block {LAUNCH_BLOCK + i}: {len(df)} txs')\n",
|
||||
" except Exception as e:\n",
|
||||
" print(f'Error block {LAUNCH_BLOCK + i}: {e}')\n",
|
||||
"\n",
|
||||
"launch_df = pd.concat(all_txs, ignore_index=True)\n",
|
||||
"print(f'\\nTotal transactions: {len(launch_df)}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Identify Token Buys\n",
|
||||
"\n",
|
||||
"Filter for DEX swap transactions targeting our token:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Uniswap V2 swap signature: swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, uint256 deadline)\n",
|
||||
"SWAP_SIGNATURE = '0x7ff36ab5'\n",
|
||||
"\n",
|
||||
"def is_token_buy(tx_input, token_addr):\n",
|
||||
" \"\"\"Check if transaction is buying our token.\"\"\"\n",
|
||||
" if not tx_input or len(tx_input) < 10:\n",
|
||||
" return False\n",
|
||||
" \n",
|
||||
" # Check for swap method\n",
|
||||
" method = tx_input[:10]\n",
|
||||
" if method not in [SWAP_SIGNATURE, '0x18cbafe5', '0x38ed1739', '0x8803dbee']:\n",
|
||||
" return False\n",
|
||||
" \n",
|
||||
" # Check if token address appears in call data\n",
|
||||
" token_clean = token_addr.lower().replace('0x', '')\n",
|
||||
" return token_clean in tx_input.lower()\n",
|
||||
"\n",
|
||||
"# Filter buys\n",
|
||||
"launch_df['is_buy'] = launch_df['input'].apply(lambda x: is_token_buy(x, TOKEN_ADDRESS))\n",
|
||||
"buys = launch_df[launch_df['is_buy']].copy()\n",
|
||||
"\n",
|
||||
"print(f'Found {len(buys)} token buys in launch window')\n",
|
||||
"print('\\nBuyers by block:')\n",
|
||||
"print(buys.groupby('block').size())"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Cluster Analysis — Find Bundles\n",
|
||||
"\n",
|
||||
"Use heuristics to group related wallets:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from sklearn.cluster import DBSCAN\n",
|
||||
"from sklearn.preprocessing import StandardScaler\n",
|
||||
"\n",
|
||||
"def extract_features(df):\n",
|
||||
" \"\"\"Extract clustering features from transactions.\"\"\"\n",
|
||||
" features = []\n",
|
||||
" wallets = []\n",
|
||||
" \n",
|
||||
" for wallet, group in df.groupby('from'):\n",
|
||||
" if len(group) < 1:\n",
|
||||
" continue\n",
|
||||
" \n",
|
||||
" feat = [\n",
|
||||
" len(group), # tx count\n",
|
||||
" group['value'].sum(), # total ETH spent\n",
|
||||
" group['value'].mean(), # avg ETH\n",
|
||||
" group['gas_price'].mean(), # avg gas price\n",
|
||||
" group['gas_price'].std() if len(group) > 1 else 0, # gas variance\n",
|
||||
" group['block'].min(), # first block\n",
|
||||
" group['block'].max() - group['block'].min(), # block span\n",
|
||||
" len(group['to'].unique()), # unique contracts\n",
|
||||
" ]\n",
|
||||
" \n",
|
||||
" features.append(feat)\n",
|
||||
" wallets.append(wallet)\n",
|
||||
" \n",
|
||||
" return np.array(features), wallets\n",
|
||||
"\n",
|
||||
"features, wallets = extract_features(buys)\n",
|
||||
"\n",
|
||||
"# Normalize\n",
|
||||
"scaler = StandardScaler()\n",
|
||||
"features_scaled = scaler.fit_transform(features)\n",
|
||||
"\n",
|
||||
"# Cluster (eps tuned for bundle detection)\n",
|
||||
"clustering = DBSCAN(eps=0.5, min_samples=2).fit(features_scaled)\n",
|
||||
"\n",
|
||||
"# Results\n",
|
||||
"labels = clustering.labels_\n",
|
||||
"n_clusters = len(set(labels)) - (1 if -1 in labels else 0)\n",
|
||||
"\n",
|
||||
"print(f'Found {n_clusters} potential bundles')\n",
|
||||
"print(f'Noise (unclustered): {list(labels).count(-1)} wallets')\n",
|
||||
"\n",
|
||||
"# Show clusters\n",
|
||||
"for cluster_id in range(n_clusters):\n",
|
||||
" mask = labels == cluster_id\n",
|
||||
" cluster_wallets = [wallets[i] for i in range(len(wallets)) if mask[i]]\n",
|
||||
" \n",
|
||||
" print(f\"\\n=== Bundle {cluster_id + 1} ({len(cluster_wallets)} wallets) ===\")\n",
|
||||
" for w in cluster_wallets[:5]:\n",
|
||||
" print(f' {w}')\n",
|
||||
" if len(cluster_wallets) > 5:\n",
|
||||
" print(f' ... and {len(cluster_wallets) - 5} more')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Funding Source Analysis\n",
|
||||
"\n",
|
||||
"Trace where bundle wallets got their ETH:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"def trace_funding(wallet, blocks_back=100):\n",
|
||||
" \"\"\"Find funding transactions for a wallet.\"\"\"\n",
|
||||
" current_block = w3.eth.block_number\n",
|
||||
" start_block = max(0, current_block - blocks_back)\n",
|
||||
" \n",
|
||||
" # Use Etherscan API for historical txs (free tier)\n",
|
||||
" import requests\n",
|
||||
" \n",
|
||||
" url = 'https://api.basescan.org/api' # Change for other chains\n",
|
||||
" params = {\n",
|
||||
" 'module': 'account',\n",
|
||||
" 'action': 'txlist',\n",
|
||||
" 'address': wallet,\n",
|
||||
" 'startblock': start_block,\n",
|
||||
" 'endblock': 99999999,\n",
|
||||
" 'sort': 'asc',\n",
|
||||
" 'apikey': '', # Optional for free tier on some chains\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" resp = requests.get(url, params=params, timeout=30)\n",
|
||||
" data = resp.json()\n",
|
||||
" \n",
|
||||
" if data.get('status') != '1':\n",
|
||||
" return []\n",
|
||||
" \n",
|
||||
" # Find incoming ETH transfers\n",
|
||||
" funding = []\n",
|
||||
" for tx in data['result']:\n",
|
||||
" if tx['to'].lower() == wallet.lower() and float(tx['value']) > 0:\n",
|
||||
" funding.append({\n",
|
||||
" 'from': tx['from'],\n",
|
||||
" 'value_eth': float(tx['value']) / 1e18,\n",
|
||||
" 'block': int(tx['blockNumber']),\n",
|
||||
" 'hash': tx['hash'],\n",
|
||||
" })\n",
|
||||
" \n",
|
||||
" return funding\n",
|
||||
"\n",
|
||||
"# Analyze funding for each bundle\n",
|
||||
"for cluster_id in range(min(3, n_clusters)):\n",
|
||||
" mask = labels == cluster_id\n",
|
||||
" cluster_wallets = [wallets[i] for i in range(len(wallets)) if mask[i]]\n",
|
||||
" \n",
|
||||
" print(f\"\\n=== Bundle {cluster_id + 1} Funding ===\")\n",
|
||||
" \n",
|
||||
" all_funders = []\n",
|
||||
" for w in cluster_wallets[:5]: # Sample first 5\n",
|
||||
" funding = trace_funding(w, blocks_back=500)\n",
|
||||
" for f in funding[:3]: # Top 3 funders\n",
|
||||
" all_funders.append(f['from'])\n",
|
||||
" \n",
|
||||
" # Check for common funders\n",
|
||||
" from collections import Counter\n",
|
||||
" funder_counts = Counter(all_funders)\n",
|
||||
" \n",
|
||||
" if funder_counts:\n",
|
||||
" most_common = funder_counts.most_common(3)\n",
|
||||
" print('Most common funders:')\n",
|
||||
" for addr, count in most_common:\n",
|
||||
" pct = count / len(cluster_wallets) * 100\n",
|
||||
" print(f' {addr}: funded {count} wallets ({pct:.0f}%)')\n",
|
||||
" \n",
|
||||
" # If >50% funded from same source = strong bundle signal\n",
|
||||
" top_pct = most_common[0][1] / len(cluster_wallets) * 100\n",
|
||||
" if top_pct > 50:\n",
|
||||
" print(f\"\\n🚨 STRONG BUNDLE SIGNAL: {top_pct:.0f}% funded from same source!\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Visualization — Bundle Network\n",
|
||||
"\n",
|
||||
"Graph the relationships between bundle wallets and funders:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import networkx as nx\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"\n",
|
||||
"G = nx.DiGraph()\n",
|
||||
"\n",
|
||||
"# Add all bundle wallets\n",
|
||||
"for cluster_id in range(n_clusters):\n",
|
||||
" mask = labels == cluster_id\n",
|
||||
" cluster_wallets = [wallets[i] for i in range(len(wallets)) if mask[i]]\n",
|
||||
" \n",
|
||||
" for w in cluster_wallets:\n",
|
||||
" G.add_node(w[:10], cluster=cluster_id, type='bundle')\n",
|
||||
"\n",
|
||||
"# Add funding edges (simplified)\n",
|
||||
"# In real implementation, trace each wallet's funding\n",
|
||||
"\n",
|
||||
"plt.figure(figsize=(14, 10))\n",
|
||||
"\n",
|
||||
"# Color by cluster\n",
|
||||
"colors = ['#ef4444', '#f59e0b', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899']\n",
|
||||
"node_colors = []\n",
|
||||
"for node in G.nodes():\n",
|
||||
" cluster = G.nodes[node].get('cluster', 0)\n",
|
||||
" node_colors.append(colors[cluster % len(colors)])\n",
|
||||
"\n",
|
||||
"pos = nx.spring_layout(G, k=3, iterations=100)\n",
|
||||
"nx.draw(G, pos, with_labels=True, node_color=node_colors,\n",
|
||||
" node_size=600, font_size=7, font_color='white',\n",
|
||||
" edge_color='#444', alpha=0.8, arrows=True, arrowsize=10)\n",
|
||||
"\n",
|
||||
"plt.title('Bundle Wallet Network', color='white', fontsize=14)\n",
|
||||
"plt.gca().set_facecolor('#0e0e16')\n",
|
||||
"plt.show()\n",
|
||||
"\n",
|
||||
"print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Export Bundle Report\n",
|
||||
"\n",
|
||||
"Save findings for RMI Community Forensics:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import json\n",
|
||||
"\n",
|
||||
"bundle_report = {\n",
|
||||
" 'investigation_type': 'bundle_detection',\n",
|
||||
" 'token_address': TOKEN_ADDRESS,\n",
|
||||
" 'launch_block': LAUNCH_BLOCK,\n",
|
||||
" 'chain': 'base',\n",
|
||||
" 'timestamp': datetime.utcnow().isoformat(),\n",
|
||||
" 'total_buys': len(buys),\n",
|
||||
" 'bundles_found': n_clusters,\n",
|
||||
" 'unclustered_wallets': int(list(labels).count(-1)),\n",
|
||||
" 'bundles': [],\n",
|
||||
" 'risk_assessment': {},\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"for cluster_id in range(n_clusters):\n",
|
||||
" mask = labels == cluster_id\n",
|
||||
" cluster_wallets = [wallets[i] for i in range(len(wallets)) if mask[i]]\n",
|
||||
" \n",
|
||||
" bundle_report['bundles'].append({\n",
|
||||
" 'bundle_id': cluster_id,\n",
|
||||
" 'wallet_count': len(cluster_wallets),\n",
|
||||
" 'wallets': cluster_wallets,\n",
|
||||
" 'avg_gas_price': float(buys[buys['from'].isin(cluster_wallets)]['gas_price'].mean()),\n",
|
||||
" })\n",
|
||||
"\n",
|
||||
"# Risk assessment\n",
|
||||
"if n_clusters >= 2:\n",
|
||||
" bundle_report['risk_assessment']['level'] = 'HIGH'\n",
|
||||
" bundle_report['risk_assessment']['reason'] = f'{n_clusters} coordinated bundles detected at launch'\n",
|
||||
"elif n_clusters == 1:\n",
|
||||
" bundle_report['risk_assessment']['level'] = 'MEDIUM'\n",
|
||||
" bundle_report['risk_assessment']['reason'] = 'Single bundle detected'\n",
|
||||
"else:\n",
|
||||
" bundle_report['risk_assessment']['level'] = 'LOW'\n",
|
||||
" bundle_report['risk_assessment']['reason'] = 'No clear bundle patterns'\n",
|
||||
"\n",
|
||||
"with open(f'bundle_report_{TOKEN_ADDRESS[:8]}.json', 'w') as f:\n",
|
||||
" json.dump(bundle_report, f, indent=2)\n",
|
||||
"\n",
|
||||
"print('Bundle detection report saved!')\n",
|
||||
"print(json.dumps(bundle_report, indent=2))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Advanced Tips\n",
|
||||
"\n",
|
||||
"1. **Gas Price Clustering**: Bundles often use identical or sequential gas prices\n",
|
||||
"2. **Nonce Patterns**: Check if nonces are sequential (same wallet funding multiple)\n",
|
||||
"3. **Time Correlation**: True bundles buy within seconds, not minutes\n",
|
||||
"4. **Sell Coordination**: Track if bundle wallets sell simultaneously later\n",
|
||||
"5. **MEV Bots**: Look for flashbots/private tx patterns\n",
|
||||
"\n",
|
||||
"**RMI Community Forensics** — *The Bloomberg Terminal of Shitcoins*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
431
public/notebooks/solana_sleuth.ipynb
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Solana Sleuth Toolkit\n",
|
||||
"## RMI Community Forensics — Solana Edition\n",
|
||||
"\n",
|
||||
"Analyze Solana transactions, detect snipers, and map wallet clusters.\n",
|
||||
"\n",
|
||||
"**Chain**: Solana\n",
|
||||
"**Difficulty**: Intermediate\n",
|
||||
"**Tools**: solana.py, helius-api, pandas, matplotlib"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Install dependencies\n",
|
||||
"!pip install solana pandas matplotlib requests python-dotenv -q"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Setup — Connect to Solana\n",
|
||||
"\n",
|
||||
"Use Helius free tier or public RPC:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from solana.rpc.api import Client\n",
|
||||
"import os\n",
|
||||
"\n",
|
||||
"# Free RPC endpoints\n",
|
||||
"RPC_URL = os.getenv('SOLANA_RPC', 'https://api.mainnet-beta.solana.com')\n",
|
||||
"\n",
|
||||
"# Or use Helius (free tier, get key at helius.xyz)\n",
|
||||
"HELIUS_KEY = os.getenv('HELIUS_API_KEY', '')\n",
|
||||
"if HELIUS_KEY:\n",
|
||||
" RPC_URL = f'https://mainnet.helius-rpc.com/?api-key={HELIUS_KEY}'\n",
|
||||
"\n",
|
||||
"client = Client(RPC_URL)\n",
|
||||
"\n",
|
||||
"# Test connection\n",
|
||||
"health = client.is_connected()\n",
|
||||
"print(f'Connected: {health}')\n",
|
||||
"\n",
|
||||
"# Get slot\n",
|
||||
"slot = client.get_slot()['result']\n",
|
||||
"print(f'Current slot: {slot:,}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Target Token or Wallet\n",
|
||||
"\n",
|
||||
"Set the target for investigation:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"TARGET_TOKEN = 'So11111111111111111111111111111111111111112' # Example: wSOL\n",
|
||||
"# Or use a wallet address\n",
|
||||
"TARGET_WALLET = None # '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4Jk'\n",
|
||||
"\n",
|
||||
"# Validate address\n",
|
||||
"import base58\n",
|
||||
"\n",
|
||||
"def is_valid_solana_address(addr):\n",
|
||||
" try:\n",
|
||||
" decoded = base58.b58decode(addr)\n",
|
||||
" return len(decoded) == 32\n",
|
||||
" except:\n",
|
||||
" return False\n",
|
||||
"\n",
|
||||
"if TARGET_WALLET and not is_valid_solana_address(TARGET_WALLET):\n",
|
||||
" raise ValueError('Invalid Solana address')\n",
|
||||
"\n",
|
||||
"print(f'Target: {TARGET_WALLET or TARGET_TOKEN}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Fetch Token Accounts\n",
|
||||
"\n",
|
||||
"Get all holders of a token (using Helius enhanced API):"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import requests\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"def get_token_accounts(token_mint, limit=1000):\n",
|
||||
" \"\"\"Fetch token holders via RPC.\"\"\"\n",
|
||||
" payload = {\n",
|
||||
" 'jsonrpc': '2.0',\n",
|
||||
" 'id': 1,\n",
|
||||
" 'method': 'getProgramAccounts',\n",
|
||||
" 'params': [\n",
|
||||
" 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', # SPL Token program\n",
|
||||
" {\n",
|
||||
" 'encoding': 'jsonParsed',\n",
|
||||
" 'filters': [\n",
|
||||
" {'dataSize': 165},\n",
|
||||
" {'memcmp': {'offset': 0, 'bytes': token_mint}}\n",
|
||||
" ],\n",
|
||||
" 'commitment': 'confirmed'\n",
|
||||
" }\n",
|
||||
" ]\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" resp = requests.post(RPC_URL, json=payload, timeout=30)\n",
|
||||
" data = resp.json()\n",
|
||||
" \n",
|
||||
" accounts = []\n",
|
||||
" for item in data.get('result', []):\n",
|
||||
" parsed = item['account']['data']['parsed']['info']\n",
|
||||
" accounts.append({\n",
|
||||
" 'address': item['pubkey'],\n",
|
||||
" 'owner': parsed['owner'],\n",
|
||||
" 'mint': parsed['mint'],\n",
|
||||
" 'amount': float(parsed['tokenAmount']['uiAmount'] or 0),\n",
|
||||
" 'decimals': parsed['tokenAmount']['decimals'],\n",
|
||||
" })\n",
|
||||
" \n",
|
||||
" return pd.DataFrame(accounts)\n",
|
||||
"\n",
|
||||
"if TARGET_TOKEN:\n",
|
||||
" holders = get_token_accounts(TARGET_TOKEN)\n",
|
||||
" print(f'Found {len(holders)} token accounts')\n",
|
||||
" \n",
|
||||
" # Sort by balance\n",
|
||||
" holders = holders.sort_values('amount', ascending=False)\n",
|
||||
" print('\\nTop 10 holders:')\n",
|
||||
" print(holders.head(10)[['owner', 'amount']])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Detect Sniper Wallets\n",
|
||||
"\n",
|
||||
"Identify wallets that bought in the first few blocks:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"def get_signatures_for_address(address, limit=100):\n",
|
||||
" \"\"\"Fetch transaction signatures for an address.\"\"\"\n",
|
||||
" payload = {\n",
|
||||
" 'jsonrpc': '2.0',\n",
|
||||
" 'id': 1,\n",
|
||||
" 'method': 'getSignaturesForAddress',\n",
|
||||
" 'params': [address, {'limit': limit}]\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" resp = requests.post(RPC_URL, json=payload, timeout=30)\n",
|
||||
" return resp.json().get('result', [])\n",
|
||||
"\n",
|
||||
"def analyze_first_buyers(token_mint, top_holders_df, max_tx=50):\n",
|
||||
" \"\"\"Analyze first transactions for top holders.\"\"\"\n",
|
||||
" results = []\n",
|
||||
" \n",
|
||||
" for _, holder in top_holders_df.head(20).iterrows():\n",
|
||||
" sigs = get_signatures_for_address(holder['address'], limit=max_tx)\n",
|
||||
" \n",
|
||||
" if not sigs:\n",
|
||||
" continue\n",
|
||||
" \n",
|
||||
" # Get earliest tx\n",
|
||||
" earliest = min(sigs, key=lambda x: x['blockTime'] or float('inf'))\n",
|
||||
" \n",
|
||||
" results.append({\n",
|
||||
" 'wallet': holder['owner'],\n",
|
||||
" 'token_account': holder['address'],\n",
|
||||
" 'balance': holder['amount'],\n",
|
||||
" 'first_tx_slot': earliest['slot'],\n",
|
||||
" 'first_tx_time': earliest.get('blockTime'),\n",
|
||||
" 'signature': earliest['signature'],\n",
|
||||
" })\n",
|
||||
" \n",
|
||||
" return pd.DataFrame(results)\n",
|
||||
"\n",
|
||||
"if TARGET_TOKEN and len(holders) > 0:\n",
|
||||
" first_buyers = analyze_first_buyers(TARGET_TOKEN, holders)\n",
|
||||
" \n",
|
||||
" if len(first_buyers) > 0:\n",
|
||||
" # Find earliest slot\n",
|
||||
" min_slot = first_buyers['first_tx_slot'].min()\n",
|
||||
" \n",
|
||||
" # Flag snipers (within 10 slots of first)\n",
|
||||
" first_buyers['is_sniper'] = first_buyers['first_tx_slot'] <= min_slot + 10\n",
|
||||
" \n",
|
||||
" print(f'First transaction slot: {min_slot}')\n",
|
||||
" print(f'\\nSniper detection:')\n",
|
||||
" print(first_buyers[['wallet', 'first_tx_slot', 'is_sniper']].head(15))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Wallet Cluster Analysis\n",
|
||||
"\n",
|
||||
"Find related wallets by funding source:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"def get_transaction_details(signature):\n",
|
||||
" \"\"\"Get full transaction details.\"\"\"\n",
|
||||
" payload = {\n",
|
||||
" 'jsonrpc': '2.0',\n",
|
||||
" 'id': 1,\n",
|
||||
" 'method': 'getTransaction',\n",
|
||||
" 'params': [signature, {'encoding': 'jsonParsed', 'maxSupportedTransactionVersion': 0}]\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" resp = requests.post(RPC_URL, json=payload, timeout=30)\n",
|
||||
" return resp.json().get('result')\n",
|
||||
"\n",
|
||||
"def trace_funding_sources(wallet, depth=1):\n",
|
||||
" \"\"\"Trace where a wallet got its initial SOL.\"\"\"\n",
|
||||
" sigs = get_signatures_for_address(wallet, limit=20)\n",
|
||||
" \n",
|
||||
" funding = []\n",
|
||||
" for sig in sigs[:5]: # Check first 5 tx\n",
|
||||
" tx = get_transaction_details(sig['signature'])\n",
|
||||
" if not tx:\n",
|
||||
" continue\n",
|
||||
" \n",
|
||||
" meta = tx.get('meta', {})\n",
|
||||
" pre_balances = meta.get('preBalances', [])\n",
|
||||
" post_balances = meta.get('postBalances', [])\n",
|
||||
" account_keys = tx['transaction']['message']['accountKeys']\n",
|
||||
" \n",
|
||||
" for i, (pre, post) in enumerate(zip(pre_balances, post_balances)):\n",
|
||||
" if i < len(account_keys):\n",
|
||||
" addr = account_keys[i]['pubkey'] if isinstance(account_keys[i], dict) else account_keys[i]\n",
|
||||
" delta = (post - pre) / 1e9 # Convert lamports to SOL\n",
|
||||
" \n",
|
||||
" if addr == wallet and delta > 0:\n",
|
||||
" # This tx sent SOL to our wallet\n",
|
||||
" for j, (pre2, post2) in enumerate(zip(pre_balances, post_balances)):\n",
|
||||
" if j < len(account_keys) and j != i:\n",
|
||||
" addr2 = account_keys[j]['pubkey'] if isinstance(account_keys[j], dict) else account_keys[j]\n",
|
||||
" delta2 = (post2 - pre2) / 1e9\n",
|
||||
" if delta2 < 0 and abs(delta2) >= delta - 0.001:\n",
|
||||
" funding.append({\n",
|
||||
" 'wallet': wallet,\n",
|
||||
" 'funder': addr2,\n",
|
||||
" 'amount_sol': delta,\n",
|
||||
" 'slot': tx['slot'],\n",
|
||||
" 'signature': sig['signature'],\n",
|
||||
" })\n",
|
||||
" \n",
|
||||
" return pd.DataFrame(funding)\n",
|
||||
"\n",
|
||||
"if TARGET_WALLET:\n",
|
||||
" funding_trace = trace_funding_sources(TARGET_WALLET)\n",
|
||||
" print(f'Found {len(funding_trace)} funding sources:')\n",
|
||||
" print(funding_trace.head())"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Visualization — Token Distribution\n",
|
||||
"\n",
|
||||
"Chart holder concentration:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"\n",
|
||||
"if TARGET_TOKEN and len(holders) > 0:\n",
|
||||
" # Calculate percentages\n",
|
||||
" total_supply = holders['amount'].sum()\n",
|
||||
" holders['pct'] = holders['amount'] / total_supply * 100\n",
|
||||
" \n",
|
||||
" # Top 20\n",
|
||||
" top20 = holders.head(20)\n",
|
||||
" \n",
|
||||
" fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))\n",
|
||||
" \n",
|
||||
" # Bar chart\n",
|
||||
" colors = ['#ef4444' if p > 5 else '#f59e0b' if p > 1 else '#3b82f6' for p in top20['pct']]\n",
|
||||
" ax1.barh(range(len(top20)), top20['pct'], color=colors)\n",
|
||||
" ax1.set_yticks(range(len(top20)))\n",
|
||||
" ax1.set_yticklabels([w[:8] + '...' for w in top20['owner']], fontsize=8)\n",
|
||||
" ax1.set_xlabel('Holdings %')\n",
|
||||
" ax1.set_title('Top 20 Token Holders')\n",
|
||||
" ax1.invert_yaxis()\n",
|
||||
" \n",
|
||||
" # Pie chart: concentration\n",
|
||||
" top5_pct = top20.head(5)['pct'].sum()\n",
|
||||
" rest_pct = 100 - top5_pct\n",
|
||||
" \n",
|
||||
" ax2.pie([top5_pct, rest_pct],\n",
|
||||
" labels=[f'Top 5: {top5_pct:.1f}%', f'Rest: {rest_pct:.1f}%'],\n",
|
||||
" colors=['#ef4444', '#3b82f6'],\n",
|
||||
" autopct='%1.1f%%')\n",
|
||||
" ax2.set_title('Supply Concentration')\n",
|
||||
" \n",
|
||||
" plt.tight_layout()\n",
|
||||
" plt.show()\n",
|
||||
" \n",
|
||||
" # Risk assessment\n",
|
||||
" top5_hold = top20.head(5)['pct'].sum()\n",
|
||||
" top10_hold = top20.head(10)['pct'].sum()\n",
|
||||
" \n",
|
||||
" print(f\"\\n=== RISK ASSESSMENT ===\")\n",
|
||||
" print(f\"Top 5 holders: {top5_hold:.1f}%\")\n",
|
||||
" print(f\"Top 10 holders: {top10_hold:.1f}%\")\n",
|
||||
" \n",
|
||||
" if top5_hold > 50:\n",
|
||||
" print(\"⚠️ CRITICAL: Top 5 hold >50% — rug pull risk HIGH\")\n",
|
||||
" elif top5_hold > 30:\n",
|
||||
" print(\"⚠️ WARNING: Top 5 hold >30% — concentration risk\")\n",
|
||||
" else:\n",
|
||||
" print(\"✓ Distribution looks healthy\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Export Evidence\n",
|
||||
"\n",
|
||||
"Save findings for RMI Community Forensics:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import json\n",
|
||||
"from datetime import datetime\n",
|
||||
"\n",
|
||||
"report = {\n",
|
||||
" 'investigation_type': 'solana_token_audit',\n",
|
||||
" 'target_token': TARGET_TOKEN,\n",
|
||||
" 'target_wallet': TARGET_WALLET,\n",
|
||||
" 'chain': 'solana',\n",
|
||||
" 'timestamp': datetime.utcnow().isoformat(),\n",
|
||||
" 'total_holders': len(holders) if 'holders' in dir() else 0,\n",
|
||||
" 'top_5_concentration': float(top5_hold) if 'top5_hold' in dir() else 0,\n",
|
||||
" 'sniper_count': int(first_buyers['is_sniper'].sum()) if 'first_buyers' in dir() and len(first_buyers) > 0 else 0,\n",
|
||||
" 'anomalies': [],\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"# Add anomalies\n",
|
||||
"if 'top5_hold' in dir() and top5_hold > 50:\n",
|
||||
" report['anomalies'].append('HIGH_CONCENTRATION: Top 5 hold >50%')\n",
|
||||
"if 'first_buyers' in dir() and len(first_buyers) > 0:\n",
|
||||
" sniper_pct = first_buyers['is_sniper'].sum() / len(first_buyers) * 100\n",
|
||||
" if sniper_pct > 30:\n",
|
||||
" report['anomalies'].append(f'HIGH_SNIPER_RATIO: {sniper_pct:.0f}% of first buyers are snipers')\n",
|
||||
"\n",
|
||||
"with open(f'solana_audit_{(TARGET_TOKEN or TARGET_WALLET)[:8]}.json', 'w') as f:\n",
|
||||
" json.dump(report, f, indent=2)\n",
|
||||
"\n",
|
||||
"print('Solana audit report saved!')\n",
|
||||
"print(json.dumps(report, indent=2))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Tips for Solana Sleuthing\n",
|
||||
"\n",
|
||||
"1. **Sniper Detection**: Wallets buying in first 10 slots often use MEV bots\n",
|
||||
"2. **Dev Wallets**: Check if deployer still holds significant supply\n",
|
||||
"3. **LP Burns**: Verify if liquidity pool tokens were burned (unruggable)\n",
|
||||
"4. **Freeze Authority**: Check if token freeze authority was revoked\n",
|
||||
"5. **Mint Authority**: If active, dev can mint unlimited tokens\n",
|
||||
"\n",
|
||||
"**RMI Community Forensics** — *The Bloomberg Terminal of Shitcoins*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
418
public/notebooks/wallet_tracing_101.ipynb
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Wallet Tracing Fundamentals\n",
|
||||
"## RMI Community Forensics — Notebook 101\n",
|
||||
"\n",
|
||||
"Learn to trace fund flows using Web3.py and free APIs.\n",
|
||||
"\n",
|
||||
"**Chain**: Ethereum (works on Base, BSC, Arbitrum too)\n",
|
||||
"**Difficulty**: Beginner\n",
|
||||
"**Tools**: web3.py, etherscan-api, pandas, networkx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Install dependencies\n",
|
||||
"!pip install web3 pandas networkx matplotlib requests python-dotenv -q"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Setup — Connect to a Free RPC\n",
|
||||
"\n",
|
||||
"Use Ankr's free public RPC (no API key needed):"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from web3 import Web3\n",
|
||||
"\n",
|
||||
"# Free public RPCs (pick one)\n",
|
||||
"RPC_URLS = {\n",
|
||||
" 'ethereum': 'https://rpc.ankr.com/eth',\n",
|
||||
" 'base': 'https://rpc.ankr.com/base',\n",
|
||||
" 'bsc': 'https://rpc.ankr.com/bsc',\n",
|
||||
" 'arbitrum': 'https://rpc.ankr.com/arbitrum',\n",
|
||||
" 'polygon': 'https://rpc.ankr.com/polygon',\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"CHAIN = 'ethereum'\n",
|
||||
"w3 = Web3(Web3.HTTPProvider(RPC_URLS[CHAIN]))\n",
|
||||
"print(f'Connected to {CHAIN}: {w3.is_connected()}')\n",
|
||||
"print(f'Block number: {w3.eth.block_number:,}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Target Wallet — Set the Address to Trace\n",
|
||||
"\n",
|
||||
"Replace with the suspicious wallet you want to investigate:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"TARGET_ADDRESS = '0x...' # <-- Replace with target wallet\n",
|
||||
"\n",
|
||||
"# Validate address\n",
|
||||
"if not Web3.is_address(TARGET_ADDRESS):\n",
|
||||
" raise ValueError('Invalid Ethereum address')\n",
|
||||
"\n",
|
||||
"TARGET_ADDRESS = Web3.to_checksum_address(TARGET_ADDRESS)\n",
|
||||
"print(f'Target: {TARGET_ADDRESS}')\n",
|
||||
"\n",
|
||||
"# Get basic info\n",
|
||||
"balance = w3.eth.get_balance(TARGET_ADDRESS)\n",
|
||||
"print(f'Balance: {w3.from_wei(balance, \"ether\"):.6f} ETH')\n",
|
||||
"print(f'Transaction count: {w3.eth.get_transaction_count(TARGET_ADDRESS)}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Fetch Transactions (Free Etherscan API)\n",
|
||||
"\n",
|
||||
"Etherscan free tier: 5 calls/second. Get API key at etherscan.io/apis"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import os\n",
|
||||
"import requests\n",
|
||||
"import time\n",
|
||||
"\n",
|
||||
"ETHERSCAN_API_KEY = os.getenv('ETHERSCAN_API_KEY', '') # Optional but recommended\n",
|
||||
"\n",
|
||||
"def get_transactions(address, api_key='', chain='ethereum'):\n",
|
||||
" \"\"\"Fetch transactions from Etherscan (free tier).\"\"\"\n",
|
||||
" endpoints = {\n",
|
||||
" 'ethereum': 'api.etherscan.io',\n",
|
||||
" 'base': 'api.basescan.org',\n",
|
||||
" 'bsc': 'api.bscscan.com',\n",
|
||||
" 'arbitrum': 'api.arbiscan.io',\n",
|
||||
" 'polygon': 'api.polygonscan.com',\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" url = f\"https://{endpoints[chain]}/api\"\n",
|
||||
" params = {\n",
|
||||
" 'module': 'account',\n",
|
||||
" 'action': 'txlist',\n",
|
||||
" 'address': address,\n",
|
||||
" 'startblock': 0,\n",
|
||||
" 'endblock': 99999999,\n",
|
||||
" 'sort': 'desc',\n",
|
||||
" 'apikey': api_key,\n",
|
||||
" }\n",
|
||||
" \n",
|
||||
" resp = requests.get(url, params=params, timeout=30)\n",
|
||||
" data = resp.json()\n",
|
||||
" \n",
|
||||
" if data.get('status') == '0' and data.get('message') == 'No transactions found':\n",
|
||||
" return []\n",
|
||||
" if data.get('status') != '1':\n",
|
||||
" print(f\"API warning: {data.get('message', 'unknown')}\")\n",
|
||||
" return []\n",
|
||||
" \n",
|
||||
" return data['result']\n",
|
||||
"\n",
|
||||
"# Fetch (respect rate limits)\n",
|
||||
"txs = get_transactions(TARGET_ADDRESS, ETHERSCAN_API_KEY, CHAIN)\n",
|
||||
"print(f'Fetched {len(txs)} transactions')\n",
|
||||
"\n",
|
||||
"# Convert to DataFrame\n",
|
||||
"import pandas as pd\n",
|
||||
"df = pd.DataFrame(txs)\n",
|
||||
"if len(df) > 0:\n",
|
||||
" df['value_eth'] = df['value'].astype(float) / 1e18\n",
|
||||
" df['gas_price_gwei'] = df['gasPrice'].astype(float) / 1e9\n",
|
||||
" df['timeStamp'] = pd.to_datetime(df['timeStamp'].astype(int), unit='s')\n",
|
||||
" print(df[['timeStamp', 'from', 'to', 'value_eth', 'gas_price_gwei']].head(10))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Identify Funding Sources\n",
|
||||
"\n",
|
||||
"Find where the initial funds came from (CEX, mixer, another wallet):"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Known exchange addresses (subset)\n",
|
||||
"EXCHANGES = {\n",
|
||||
" '0x3f5CE5FBFe3E9af3971dD833D26bA9b5C936f0bE': 'Binance',\n",
|
||||
" '0xD551234Ae421e3BCBA99A0Da6d736074f22192FF': 'Binance 2',\n",
|
||||
" '0x267be1C1D684F78cb4F5a0C76Dc401f7265d5Eea': 'Coinbase',\n",
|
||||
" '0x503828976D22510aad02048ac4bf43ec3321DA1a': 'Kraken',\n",
|
||||
" '0x71C7656EC7ab88b098defB751B7401B5f6d8976F': 'FTX (defunct)',\n",
|
||||
" '0x8BA79f94eA0B4F47496cE80aC20483E4370a29B4': 'OKX',\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"def identify_funding_sources(df, target):\n",
|
||||
" \"\"\"Find transactions TO target (incoming funds).\"\"\"\n",
|
||||
" incoming = df[df['to'].str.lower() == target.lower()]\n",
|
||||
" \n",
|
||||
" results = []\n",
|
||||
" for _, tx in incoming.iterrows():\n",
|
||||
" sender = tx['from']\n",
|
||||
" exchange = EXCHANGES.get(Web3.to_checksum_address(sender), None)\n",
|
||||
" results.append({\n",
|
||||
" 'from': sender,\n",
|
||||
" 'exchange': exchange,\n",
|
||||
" 'value_eth': tx['value_eth'],\n",
|
||||
" 'time': tx['timeStamp'],\n",
|
||||
" 'tx_hash': tx['hash'],\n",
|
||||
" })\n",
|
||||
" \n",
|
||||
" return pd.DataFrame(results)\n",
|
||||
"\n",
|
||||
"if len(df) > 0:\n",
|
||||
" funding = identify_funding_sources(df, TARGET_ADDRESS)\n",
|
||||
" print(f\"Found {len(funding)} funding sources:\")\n",
|
||||
" \n",
|
||||
" # Categorize\n",
|
||||
" cex_funding = funding[funding['exchange'].notna()]\n",
|
||||
" print(f\"\\nCEX-funded: {len(cex_funding)}\")\n",
|
||||
" if len(cex_funding) > 0:\n",
|
||||
" print(cex_funding[['exchange', 'value_eth', 'time']])\n",
|
||||
" \n",
|
||||
" unknown_funding = funding[funding['exchange'].isna()]\n",
|
||||
" print(f\"\\nUnknown wallet funding: {len(unknown_funding)}\")\n",
|
||||
" if len(unknown_funding) > 0:\n",
|
||||
" print(unknown_funding[['from', 'value_eth', 'time']].head())"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Build Transaction Network Graph\n",
|
||||
"\n",
|
||||
"Visualize money flow with NetworkX:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import networkx as nx\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"\n",
|
||||
"def build_graph(df, target, max_nodes=50):\n",
|
||||
" \"\"\"Build directed graph of transactions.\"\"\"\n",
|
||||
" G = nx.DiGraph()\n",
|
||||
" \n",
|
||||
" # Add target node\n",
|
||||
" G.add_node(target[:10] + '...', type='target')\n",
|
||||
" \n",
|
||||
" for _, tx in df.head(max_nodes).iterrows():\n",
|
||||
" from_addr = tx['from'][:10] + '...'\n",
|
||||
" to_addr = tx['to'][:10] + '...'\n",
|
||||
" \n",
|
||||
" # Label exchanges\n",
|
||||
" from_label = EXCHANGES.get(Web3.to_checksum_address(tx['from']), from_addr)\n",
|
||||
" to_label = EXCHANGES.get(Web3.to_checksum_address(tx['to']), to_addr)\n",
|
||||
" \n",
|
||||
" G.add_node(from_label, type='exchange' if from_label in EXCHANGES.values() else 'wallet')\n",
|
||||
" G.add_node(to_label, type='exchange' if to_label in EXCHANGES.values() else 'wallet')\n",
|
||||
" \n",
|
||||
" G.add_edge(from_label, to_label, \n",
|
||||
" weight=tx['value_eth'],\n",
|
||||
" tx_hash=tx['hash'][:10])\n",
|
||||
" \n",
|
||||
" return G\n",
|
||||
"\n",
|
||||
"if len(df) > 0:\n",
|
||||
" G = build_graph(df, TARGET_ADDRESS, max_nodes=30)\n",
|
||||
" \n",
|
||||
" plt.figure(figsize=(14, 10))\n",
|
||||
" pos = nx.spring_layout(G, k=2, iterations=50)\n",
|
||||
" \n",
|
||||
" # Color nodes by type\n",
|
||||
" node_colors = []\n",
|
||||
" for node in G.nodes():\n",
|
||||
" if G.nodes[node].get('type') == 'target':\n",
|
||||
" node_colors.append('#ef4444') # Red\n",
|
||||
" elif G.nodes[node].get('type') == 'exchange':\n",
|
||||
" node_colors.append('#22c55e') # Green\n",
|
||||
" else:\n",
|
||||
" node_colors.append('#3b82f6') # Blue\n",
|
||||
" \n",
|
||||
" nx.draw(G, pos, with_labels=True, node_color=node_colors,\n",
|
||||
" node_size=800, font_size=8, font_color='white',\n",
|
||||
" edge_color='#666', arrows=True, arrowsize=15,\n",
|
||||
" width=0.5, alpha=0.8)\n",
|
||||
" \n",
|
||||
" plt.title(f'Transaction Flow for {TARGET_ADDRESS[:10]}...', color='white', fontsize=14)\n",
|
||||
" plt.gca().set_facecolor('#0e0e16')\n",
|
||||
" plt.show()\n",
|
||||
" \n",
|
||||
" print(f\"\\nGraph stats: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Detect Anomalies\n",
|
||||
"\n",
|
||||
"Flag suspicious patterns:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"def detect_anomalies(df):\n",
|
||||
" \"\"\"Detect suspicious transaction patterns.\"\"\"\n",
|
||||
" alerts = []\n",
|
||||
" \n",
|
||||
" if len(df) == 0:\n",
|
||||
" return alerts\n",
|
||||
" \n",
|
||||
" # 1. High-value transactions\n",
|
||||
" high_value = df[df['value_eth'] > 100]\n",
|
||||
" if len(high_value) > 0:\n",
|
||||
" alerts.append(f\"HIGH_VALUE: {len(high_value)} transactions > 100 ETH\")\n",
|
||||
" \n",
|
||||
" # 2. Rapid succession (potential bot)\n",
|
||||
" df_sorted = df.sort_values('timeStamp')\n",
|
||||
" df_sorted['time_diff'] = df_sorted['timeStamp'].diff().dt.total_seconds()\n",
|
||||
" rapid = df_sorted[df_sorted['time_diff'] < 60] # Within 60 seconds\n",
|
||||
" if len(rapid) > 5:\n",
|
||||
" alerts.append(f\"RAPID_TX: {len(rapid)} transactions within 60s of each other\")\n",
|
||||
" \n",
|
||||
" # 3. Round numbers (often fake volume)\n",
|
||||
" round_vals = df[df['value_eth'].apply(lambda x: x > 0 and x == round(x, 0))]\n",
|
||||
" if len(round_vals) > 10:\n",
|
||||
" alerts.append(f\"ROUND_VALUES: {len(round_vals)} round-number transactions\")\n",
|
||||
" \n",
|
||||
" # 4. Self-transfers (wash trading)\n",
|
||||
" self_tx = df[df['from'].str.lower() == df['to'].str.lower()]\n",
|
||||
" if len(self_tx) > 0:\n",
|
||||
" alerts.append(f\"SELF_TRANSFER: {len(self_tx)} self-transfers (wash trade?)\")\n",
|
||||
" \n",
|
||||
" # 5. Failed transactions\n",
|
||||
" failed = df[df['isError'] == '1']\n",
|
||||
" if len(failed) > 5:\n",
|
||||
" alerts.append(f\"FAILED_TX: {len(failed)} failed transactions\")\n",
|
||||
" \n",
|
||||
" return alerts\n",
|
||||
"\n",
|
||||
"if len(df) > 0:\n",
|
||||
" anomalies = detect_anomalies(df)\n",
|
||||
" \n",
|
||||
" print(\"=" * 60)\n",
|
||||
" print(\"ANOMALY DETECTION RESULTS\")\n",
|
||||
" print(\"=" * 60)\n",
|
||||
" \n",
|
||||
" if anomalies:\n",
|
||||
" for alert in anomalies:\n",
|
||||
" severity = 'CRITICAL' if 'SELF_TRANSFER' in alert or 'RAPID_TX' in alert else 'WARNING'\n",
|
||||
" print(f\"[{severity}] {alert}\")\n",
|
||||
" else:\n",
|
||||
" print(\"No major anomalies detected.\")\n",
|
||||
" \n",
|
||||
" print(f\"\\nTotal transactions analyzed: {len(df)}\")\n",
|
||||
" print(f\"Total volume: {df['value_eth'].sum():.4f} ETH\")\n",
|
||||
" print(f\"Average tx value: {df['value_eth'].mean():.6f} ETH\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Export Evidence\n",
|
||||
"\n",
|
||||
"Save findings for your RMI Community Forensics report:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"import json\n",
|
||||
"from datetime import datetime\n",
|
||||
"\n",
|
||||
"evidence_report = {\n",
|
||||
" 'investigation_type': 'wallet_trace',\n",
|
||||
" 'target_address': TARGET_ADDRESS,\n",
|
||||
" 'chain': CHAIN,\n",
|
||||
" 'timestamp': datetime.utcnow().isoformat(),\n",
|
||||
" 'total_transactions': len(df),\n",
|
||||
" 'total_volume_eth': float(df['value_eth'].sum()) if len(df) > 0 else 0,\n",
|
||||
" 'funding_sources': {\n",
|
||||
" 'cex_count': len(cex_funding) if 'cex_funding' in dir() else 0,\n",
|
||||
" 'unknown_count': len(unknown_funding) if 'unknown_funding' in dir() else 0,\n",
|
||||
" },\n",
|
||||
" 'anomalies': anomalies if 'anomalies' in dir() else [],\n",
|
||||
" 'graph_nodes': G.number_of_nodes() if 'G' in dir() else 0,\n",
|
||||
" 'graph_edges': G.number_of_edges() if 'G' in dir() else 0,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"# Save\n",
|
||||
"with open(f'wallet_trace_{TARGET_ADDRESS[:8]}.json', 'w') as f:\n",
|
||||
" json.dump(evidence_report, f, indent=2)\n",
|
||||
"\n",
|
||||
"print(\"Evidence report saved!\")\n",
|
||||
"print(json.dumps(evidence_report, indent=2))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Next Steps\n",
|
||||
"\n",
|
||||
"1. Upload this evidence to your RMI Community Forensics investigation\n",
|
||||
"2. Cross-reference with other wallets using the Cross-Chain Tracker notebook\n",
|
||||
"3. Submit your findings for verification and earn reputation points\n",
|
||||
"\n",
|
||||
"**RMI Community Forensics** — *The Bloomberg Terminal of Shitcoins*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
3
public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://rugmunch.io/sitemap.xml
|
||||
36
public/sitemap.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://rugmunch.io/</loc><priority>1.0</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/is-this-a-rug</loc><priority>0.9</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/wallet-checker</loc><priority>0.9</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/rug-checker</loc><priority>0.9</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/scam-lookup</loc><priority>0.9</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/scanner</loc><priority>0.9</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/intel-hub</loc><priority>0.9</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/portfolio</loc><priority>0.9</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/markets</loc><priority>0.8</priority><changefreq>hourly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/news</loc><priority>0.8</priority><changefreq>hourly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/alerts</loc><priority>0.8</priority><changefreq>hourly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/live-threats</loc><priority>0.8</priority><changefreq>hourly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/rugmaps</loc><priority>0.7</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/rugcharts</loc><priority>0.7</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/wallet</loc><priority>0.7</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/my-cases</loc><priority>0.7</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/community-forensics</loc><priority>0.7</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/school</loc><priority>0.7</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/pricing</loc><priority>0.7</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/api-docs</loc><priority>0.6</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/blog</loc><priority>0.6</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/ask</loc><priority>0.6</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/explorer</loc><priority>0.6</priority><changefreq>hourly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/discover</loc><priority>0.5</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/market-intel</loc><priority>0.5</priority><changefreq>daily</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/market-overview</loc><priority>0.5</priority><changefreq>hourly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/markets/compare</loc><priority>0.5</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/tools</loc><priority>0.5</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/tools/docs</loc><priority>0.4</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/bot-dashboard</loc><priority>0.4</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/case/rmi-demo</loc><priority>0.5</priority><changefreq>monthly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/u/cryptorugmuncher</loc><priority>0.5</priority><changefreq>weekly</changefreq></url>
|
||||
<url><loc>https://rugmunch.io/u/anonymous</loc><priority>0.4</priority><changefreq>weekly</changefreq></url>
|
||||
</urlset>
|
||||
184
src/App.css
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
237
src/App.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useState, Suspense, lazy } from 'react';
|
||||
import { SafePage } from './components/SafePage';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { ConsentBanner } from './components/ConsentBanner';
|
||||
import { ToastContainer } from './components/shared/ToastContainer';
|
||||
import { CommandPalette } from './components/shared/CommandPalette';
|
||||
import { useWebSocketAlerts } from './hooks/useRealtimeAlerts';
|
||||
|
||||
// ── Lazy-loaded pages (code splitting — reduces initial bundle) ──
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const AskPage = lazy(() => import('./pages/AskPage'));
|
||||
const IntelligencePage = lazy(() => import('./pages/IntelligencePage'));
|
||||
const FeedsPage = lazy(() => import('./pages/FeedPage'));
|
||||
const NewsPage = lazy(() => import('./pages/NewsPage'));
|
||||
const AlertsPage = lazy(() => import('./pages/AlertsPage'));
|
||||
const MarketsPage = lazy(() => import('./pages/MarketsPage'));
|
||||
const MarketBreakdownPage = lazy(() => import('./pages/MarketBreakdownPage'));
|
||||
const MarketIntelPage = lazy(() => import('./pages/MarketIntelPage'));
|
||||
const MarketOverviewPage = lazy(() => import('./pages/MarketOverviewPage'));
|
||||
const TokenScannerPage = lazy(() => import('./pages/TokenScannerPage'));
|
||||
const TokenDetailPage = lazy(() => import('./pages/TokenDetailPage'));
|
||||
const ComparePage = lazy(() => import('./pages/ComparePage'));
|
||||
const FAQPage = lazy(() => import('./pages/FAQPage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
const RugMapsPage = lazy(() => import('./pages/RugMapsPage'));
|
||||
const RugChartsPage = lazy(() => import('./pages/RugChartsPage'));
|
||||
const WalletAnalyzerPage = lazy(() => import('./pages/WalletAnalyzerPage'));
|
||||
const InvestigationPage = lazy(() => import('./pages/InvestigationPage'));
|
||||
const CommunityForensicsPage = lazy(() => import('./pages/CommunityForensicsPage'));
|
||||
const ScamSchoolPage = lazy(() => import('./pages/ScamSchoolPage'));
|
||||
const RehabPage = lazy(() => import('./pages/RehabPage'));
|
||||
const BulletinBoardPage = lazy(() => import('./pages/BulletinBoardPage'));
|
||||
const BlogPage = lazy(() => import('./pages/BlogPage'));
|
||||
const MCPToolsPage = lazy(() => import('./pages/MCPToolsPage'));
|
||||
const MCPDocsPage = lazy(() => import('./pages/MCPDocsPage'));
|
||||
const PricingPage = lazy(() => import('./pages/PricingPage'));
|
||||
const TermsPage = lazy(() => import('./pages/TermsPage'));
|
||||
const PrivacyPage = lazy(() => import('./pages/PrivacyPage'));
|
||||
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||
const AuthCallbackPage = lazy(() => import('./pages/AuthCallbackPage'));
|
||||
const IntelligenceHubPage = lazy(() => import('./pages/IntelligenceHubPage'));
|
||||
const ToolMarketPage = lazy(() => import('./pages/X402MarketPage'));
|
||||
const AIChatPage = lazy(() => import('./pages/AIChatPage'));
|
||||
const DiscoverPage = lazy(() => import('./pages/DiscoverPage'));
|
||||
const CampaignWatchPage = lazy(() => import('./pages/CampaignWatchPage'));
|
||||
const WalletSafePage = lazy(() => import('./pages/WalletSafePage'));
|
||||
const PremiumDashboard = lazy(() => import('./pages/PremiumDashboard'));
|
||||
const GamificationPage = lazy(() => import('./pages/GamificationPage'));
|
||||
const APIDocsPage = lazy(() => import('./pages/APIDocsPage'));
|
||||
const LiveThreatsPage = lazy(() => import('./pages/LiveThreatsPage'));
|
||||
const TokenExplorerPage = lazy(() => import('./pages/TokenExplorerPage'));
|
||||
const BotOperatorDashboard = lazy(() => import('./pages/BotOperatorDashboard'));
|
||||
const DevDocsPage = lazy(() => import('./pages/DevDocsPage'));
|
||||
|
||||
// ── SEO landing funnels (Tier 1 wins) ──
|
||||
const IsThisARugPage = lazy(() => import('./pages/IsThisARugPage'));
|
||||
const WalletCheckerPage = lazy(() => import('./pages/WalletCheckerPage'));
|
||||
const RugCheckerPage = lazy(() => import('./pages/RugCheckerPage'));
|
||||
const ScamLookupPage = lazy(() => import('./pages/ScamLookupPage'));
|
||||
|
||||
// ── Public Case Files (Tier 1 win) ──
|
||||
const CaseFilePage = lazy(() => import('./pages/CaseFilePage'));
|
||||
const MyCasesPage = lazy(() => import('./pages/MyCasesPage'));
|
||||
|
||||
// ── Portfolio Tracker (Tier 1 win) ──
|
||||
const PortfolioPage = lazy(() => import('./pages/PortfolioPage'));
|
||||
|
||||
// ── Public Investigator Profiles (Tier 1 win) ──
|
||||
const InvestigatorProfilePage = lazy(() => import('./pages/InvestigatorProfilePage'));
|
||||
|
||||
// ── Shared ──
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ScamSchoolProvider } from './contexts/ScamSchoolContext';
|
||||
import AuthModal from './components/auth/AuthModal';
|
||||
import ProfileMenu from './components/auth/ProfileMenu';
|
||||
import { SidebarLayout } from './components/layout/SidebarLayout';
|
||||
import { TierProvider } from './contexts/RugMapsTierContext';
|
||||
|
||||
// ── Loading fallback ──
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#07070b] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#8B5CF6]/20 flex items-center justify-center mx-auto mb-4">
|
||||
<div className="w-5 h-5 border-2 border-[#8B5CF6] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<p className="text-sm text-[#9DA0B0]">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => { window.scrollTo(0, 0); }, [pathname]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const { isAuthenticated, token } = useAuth();
|
||||
|
||||
// ── Global WebSocket alerts — toasts on every page ──
|
||||
useWebSocketAlerts(true);
|
||||
|
||||
// Signal that React has hydrated — hides pre-render shell
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') { (window as any).__RMI_HYDRATED__ = true; }
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarLayout>
|
||||
<div className="relative min-h-screen bg-[#07070b] text-white overflow-x-hidden">
|
||||
<div className="fixed top-0 right-0 z-40 px-6 py-4 flex items-center gap-4 pointer-events-none">
|
||||
{isAuthenticated ? (
|
||||
<div className="pointer-events-auto"><ProfileMenu /></div>
|
||||
) : (
|
||||
<button onClick={() => setAuthOpen(true)}
|
||||
className="pointer-events-auto px-5 py-2.5 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white text-sm font-semibold rounded-lg transition-all shadow-lg shadow-purple-900/20"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 pt-4">
|
||||
<ScamSchoolProvider token={token}>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<SafePage name="Home"><HomePage /></SafePage>} />
|
||||
<Route path="/intelligence" element={<SafePage name="Intelligence"><IntelligencePage /></SafePage>} />
|
||||
<Route path="/feed" element={<SafePage name="Feed"><FeedsPage /></SafePage>} />
|
||||
<Route path="/news" element={<SafePage name="News"><NewsPage /></SafePage>} />
|
||||
<Route path="/alerts" element={<SafePage name="Alerts"><AlertsPage /></SafePage>} />
|
||||
<Route path="/markets" element={<SafePage name="Markets"><MarketsPage /></SafePage>} />
|
||||
<Route path="/markets/breakdown" element={<SafePage name="Breakdown"><MarketBreakdownPage /></SafePage>} />
|
||||
<Route path="/market-intel" element={<SafePage name="Market Intel"><MarketIntelPage /></SafePage>} />
|
||||
<Route path="/market-overview" element={<SafePage name="Market Overview"><MarketOverviewPage /></SafePage>} />
|
||||
<Route path="/markets/compare" element={<SafePage name="Compare"><ComparePage /></SafePage>} />
|
||||
<Route path="/markets/token/:address" element={<SafePage name="TokenDetail"><TokenDetailPage /></SafePage>} />
|
||||
<Route path="/scanner" element={<SafePage name="Scanner"><TokenScannerPage /></SafePage>} />
|
||||
<Route path="/scanner/token/:address" element={<SafePage name="TokenDetail"><TokenDetailPage /></SafePage>} />
|
||||
<Route path="/rugmaps" element={<SafePage name="Rugmaps"><TierProvider><RugMapsPage /></TierProvider></SafePage>} />
|
||||
<Route path="/rugcharts" element={<SafePage name="Rugcharts"><RugChartsPage /></SafePage>} />
|
||||
<Route path="/wallet" element={<SafePage name="Wallet"><WalletAnalyzerPage /></SafePage>} />
|
||||
<Route path="/investigate" element={<SafePage name="Investigate"><InvestigationPage /></SafePage>} />
|
||||
<Route path="/intel-hub" element={<SafePage name="Intel Hub"><IntelligenceHubPage /></SafePage>} />
|
||||
<Route path="/market" element={<SafePage name="Market"><ToolMarketPage /></SafePage>} />
|
||||
<Route path="/chat" element={<SafePage name="Chat"><AIChatPage /></SafePage>} />
|
||||
<Route path="/discover" element={<SafePage name="Discover"><DiscoverPage /></SafePage>} />
|
||||
<Route path="/campaigns" element={<SafePage name="Campaigns"><CampaignWatchPage /></SafePage>} />
|
||||
<Route path="/walletsafe" element={<SafePage name="Walletsafe"><WalletSafePage /></SafePage>} />
|
||||
<Route path="/premium" element={<SafePage name="Premium"><PremiumDashboard /></SafePage>} />
|
||||
<Route path="/hunter" element={<SafePage name="Hunter"><GamificationPage /></SafePage>} />
|
||||
<Route path="/api-docs" element={<SafePage name="Api Docs"><APIDocsPage /></SafePage>} />
|
||||
<Route path="/community-forensics" element={<SafePage name="Community Forensics"><CommunityForensicsPage /></SafePage>} />
|
||||
<Route path="/school" element={<SafePage name="School"><ScamSchoolPage /></SafePage>} />
|
||||
<Route path="/rehab" element={<SafePage name="Rehab"><RehabPage /></SafePage>} />
|
||||
<Route path="/community" element={<SafePage name="Community"><BulletinBoardPage /></SafePage>} />
|
||||
<Route path="/blog" element={<SafePage name="Blog"><BlogPage /></SafePage>} />
|
||||
<Route path="/ask" element={<SafePage name="Ask"><AskPage /></SafePage>} />
|
||||
<Route path="/tools" element={<SafePage name="Tools"><MCPToolsPage /></SafePage>} />
|
||||
<Route path="/tools/docs" element={<SafePage name="Docs"><MCPDocsPage /></SafePage>} />
|
||||
<Route path="/pricing" element={<SafePage name="Pricing"><PricingPage /></SafePage>} />
|
||||
<Route path="/faq" element={<SafePage name="Faq"><FAQPage /></SafePage>} />
|
||||
<Route path="/terms" element={<SafePage name="Terms"><TermsPage /></SafePage>} />
|
||||
<Route path="/privacy" element={<SafePage name="Privacy"><PrivacyPage /></SafePage>} />
|
||||
<Route path="/cookies" element={<SafePage name="Cookies"><CookiePage /></SafePage>} />
|
||||
<Route path="/profile" element={<SafePage name="Profile"><ProfilePage /></SafePage>} />
|
||||
<Route path="/profile/:username" element={<SafePage name="Profile"><ProfilePage /></SafePage>} />
|
||||
<Route path="/auth/callback" element={<SafePage name="Callback"><AuthCallbackPage /></SafePage>} />
|
||||
<Route path="/live-threats" element={<SafePage name="Live Threats"><LiveThreatsPage /></SafePage>} />
|
||||
<Route path="/explorer" element={<SafePage name="Explorer"><TokenExplorerPage /></SafePage>} />
|
||||
<Route path="/bot-dashboard" element={<SafePage name="Bot Dashboard"><BotOperatorDashboard /></SafePage>} />
|
||||
<Route path="/dev-docs" element={<SafePage name="Dev Docs"><DevDocsPage /></SafePage>} />
|
||||
|
||||
{/* ── SEO landing funnels (Tier 1) ── */}
|
||||
<Route path="/is-this-a-rug" element={<SafePage name="Is This A Rug"><IsThisARugPage /></SafePage>} />
|
||||
<Route path="/wallet-checker" element={<SafePage name="Wallet Checker"><WalletCheckerPage /></SafePage>} />
|
||||
<Route path="/rug-checker" element={<SafePage name="Rug Checker"><RugCheckerPage /></SafePage>} />
|
||||
<Route path="/scam-lookup" element={<SafePage name="Scam Lookup"><ScamLookupPage /></SafePage>} />
|
||||
|
||||
{/* ── Public Case Files (Tier 1) ── */}
|
||||
<Route path="/case/:id" element={<SafePage name="Case"><CaseFilePage /></SafePage>} />
|
||||
<Route path="/my-cases" element={<SafePage name="My Cases"><MyCasesPage /></SafePage>} />
|
||||
|
||||
{/* ── Portfolio Tracker (Tier 1) ── */}
|
||||
<Route path="/portfolio" element={<SafePage name="Portfolio"><PortfolioPage /></SafePage>} />
|
||||
|
||||
{/* ── Public Investigator Profiles (Tier 1) ── */}
|
||||
<Route path="/u/:username" element={<SafePage name="Investigator"><InvestigatorProfilePage /></SafePage>} />
|
||||
|
||||
<Route path="*" element={<SafePage name="404"><NotFoundPage /></SafePage>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ScamSchoolProvider>
|
||||
</main>
|
||||
|
||||
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
|
||||
<ConsentBanner />
|
||||
<ToastContainer />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsumerApp() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<AuthProvider>
|
||||
<ErrorBoundary fallback={
|
||||
<div className="min-h-screen bg-[#07070b] flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="w-12 h-12 rounded-xl bg-red-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-7 h-7 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-white text-lg font-semibold mb-2">Something went wrong</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">Rug Munch Intelligence encountered an error. Check the console for details.</p>
|
||||
<button onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<AppInner />
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
1
src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
261
src/components/BubbleMap.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// @ts-nocheck
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Users, Network, AlertTriangle, CheckCircle, Info } from 'lucide-react';
|
||||
import type { BubbleMapData, HolderAnalysis, WalletCluster } from '@/services/dexData';
|
||||
|
||||
interface Props {
|
||||
data: BubbleMapData;
|
||||
}
|
||||
|
||||
export default function BubbleMap({ data }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [hovered, setHovered] = useState<HolderAnalysis | null>(null);
|
||||
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Background subtle grid
|
||||
ctx.strokeStyle = 'rgba(139, 92, 246, 0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < width; i += 40) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, height); ctx.stroke(); }
|
||||
for (let i = 0; i < height; i += 40) { ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(width, i); ctx.stroke(); }
|
||||
|
||||
// Draw bubbles
|
||||
const maxPct = Math.max(...data.holders.map(h => h.percentage));
|
||||
const maxRadius = Math.min(width, height) * 0.18;
|
||||
const minRadius = 12;
|
||||
|
||||
// Pack circles with simple force-directed placement
|
||||
const bubbles = data.holders.slice(0, 15).map((h, i) => {
|
||||
const radius = minRadius + (h.percentage / maxPct) * (maxRadius - minRadius);
|
||||
const angle = (i / Math.min(data.holders.length, 15)) * Math.PI * 2 + Math.random() * 0.3;
|
||||
const dist = 60 + i * 18;
|
||||
return {
|
||||
x: centerX + Math.cos(angle) * dist,
|
||||
y: centerY + Math.sin(angle) * dist,
|
||||
radius,
|
||||
holder: h,
|
||||
vx: 0, vy: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Simple collision resolution
|
||||
for (let iter = 0; iter < 60; iter++) {
|
||||
for (let i = 0; i < bubbles.length; i++) {
|
||||
for (let j = i + 1; j < bubbles.length; j++) {
|
||||
const dx = bubbles[j].x - bubbles[i].x;
|
||||
const dy = bubbles[j].y - bubbles[i].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const minDist = bubbles[i].radius + bubbles[j].radius + 6;
|
||||
if (dist < minDist && dist > 0) {
|
||||
const overlap = (minDist - dist) / 2;
|
||||
const nx = dx / dist;
|
||||
const ny = dy / dist;
|
||||
bubbles[i].x -= nx * overlap;
|
||||
bubbles[i].y -= ny * overlap;
|
||||
bubbles[j].x += nx * overlap;
|
||||
bubbles[j].y += ny * overlap;
|
||||
}
|
||||
}
|
||||
// Keep in bounds
|
||||
bubbles[i].x = Math.max(bubbles[i].radius + 10, Math.min(width - bubbles[i].radius - 10, bubbles[i].x));
|
||||
bubbles[i].y = Math.max(bubbles[i].radius + 10, Math.min(height - bubbles[i].radius - 10, bubbles[i].y));
|
||||
}
|
||||
}
|
||||
|
||||
// Draw connections for clusters
|
||||
for (const cluster of data.clusters) {
|
||||
const clusterBubbles = bubbles.filter(b => cluster.wallets.includes(b.holder.address));
|
||||
if (clusterBubbles.length >= 2) {
|
||||
ctx.strokeStyle = cluster.type === 'sybil' ? 'rgba(255, 51, 102, 0.25)' : 'rgba(209, 163, 64, 0.2)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
for (let i = 0; i < clusterBubbles.length; i++) {
|
||||
for (let j = i + 1; j < clusterBubbles.length; j++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(clusterBubbles[i].x, clusterBubbles[i].y);
|
||||
ctx.lineTo(clusterBubbles[j].x, clusterBubbles[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw bubbles
|
||||
for (const b of bubbles) {
|
||||
const isHovered = hovered?.address === b.holder.address;
|
||||
const isClusterSelected = selectedCluster && data.clusters.find(c => c.id === selectedCluster)?.wallets.includes(b.holder.address);
|
||||
|
||||
// Glow
|
||||
const gradient = ctx.createRadialGradient(b.x, b.y, b.radius * 0.3, b.x, b.y, b.radius * 1.4);
|
||||
gradient.addColorStop(0, b.holder.riskColor + '40');
|
||||
gradient.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius * 1.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Main circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = b.holder.riskColor + (isHovered || isClusterSelected ? '35' : '20');
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = b.holder.riskColor + (isHovered || isClusterSelected ? 'FF' : '80');
|
||||
ctx.lineWidth = isHovered ? 2.5 : 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `${b.radius > 30 ? 'bold 11px' : 'bold 9px'} Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const label = b.holder.label.length > 12 ? b.holder.label.slice(0, 10) + '..' : b.holder.label;
|
||||
ctx.fillText(label, b.x, b.y - 4);
|
||||
|
||||
// Percentage
|
||||
ctx.fillStyle = '#8892b0';
|
||||
ctx.font = `${b.radius > 30 ? '10px' : '8px'} Inter, sans-serif`;
|
||||
ctx.fillText(`${b.holder.percentage.toFixed(2)}%`, b.x, b.y + 10);
|
||||
|
||||
// Evasion warning indicator
|
||||
if (b.holder.evasionSignals.length > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x + b.radius * 0.7, b.y - b.radius * 0.7, 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#FF3366';
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Click handler setup
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
for (const b of bubbles) {
|
||||
const dist = Math.sqrt((mx - b.x) ** 2 + (my - b.y) ** 2);
|
||||
if (dist < b.radius) {
|
||||
setHovered(b.holder);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setHovered(null);
|
||||
};
|
||||
canvas.addEventListener('click', handleClick);
|
||||
return () => canvas.removeEventListener('click', handleClick);
|
||||
}, [data, hovered, selectedCluster]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg glass">
|
||||
<Users className="w-3.5 h-3.5 text-rmi-purple" />
|
||||
<span className="text-[10px] text-rmi-gray">Holders: <span className="text-rmi-white font-bold">{data.totalHolders.toLocaleString()}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg glass">
|
||||
<Network className="w-3.5 h-3.5 text-rmi-cyan" />
|
||||
<span className="text-[10px] text-rmi-gray">Concentration: <span className={`font-bold ${data.concentrationScore > 60 ? 'text-rmi-danger' : data.concentrationScore > 30 ? 'text-rmi-gold' : 'text-rmi-success'}`}>{data.concentrationScore.toFixed(1)}%</span></span>
|
||||
</div>
|
||||
{data.evasionSummary.sybilDetected && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-rmi-danger/10 border border-rmi-danger/20">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-rmi-danger" />
|
||||
<span className="text-[10px] text-rmi-danger font-bold">Sybil Detected</span>
|
||||
</div>
|
||||
)}
|
||||
{data.evasionSummary.mixerUsage && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-rmi-orange/10 border border-rmi-orange/20">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-rmi-orange" />
|
||||
<span className="text-[10px] text-rmi-orange font-bold">Mixer Touch</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="relative rounded-2xl glass-strong overflow-hidden" style={{ height: 420 }}>
|
||||
<canvas ref={canvasRef} className="w-full h-full cursor-pointer" />
|
||||
{hovered && (
|
||||
<div className="absolute bottom-3 left-3 right-3 p-3 rounded-xl glass-strong border border-rmi-purple/20">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-rmi-white">{hovered.label}</div>
|
||||
<div className="text-[10px] text-rmi-gray font-mono mt-0.5">{hovered.address.slice(0, 14)}...{hovered.address.slice(-6)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-black text-rmi-white">{hovered.percentage.toFixed(2)}%</div>
|
||||
<div className="text-[10px] text-rmi-gray">${hovered.usdValue.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
{hovered.evasionSignals.length > 0 && (
|
||||
<div className="mt-2 flex gap-1.5 flex-wrap">
|
||||
{hovered.evasionSignals.map((s, i) => (
|
||||
<span key={i} className="px-2 py-0.5 rounded-full bg-rmi-danger/10 border border-rmi-danger/20 text-[9px] text-rmi-danger font-medium">
|
||||
{s.type.replace(/_/g, ' ').toUpperCase()} ({s.confidence}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clusters */}
|
||||
{data.clusters.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-rmi-gray">Detected Clusters</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{data.clusters.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedCluster(selectedCluster === c.id ? null : c.id)}
|
||||
className={`px-3 py-1.5 rounded-lg border text-[10px] font-semibold transition-all ${
|
||||
selectedCluster === c.id
|
||||
? 'bg-rmi-purple/20 border-rmi-purple/50 text-rmi-white'
|
||||
: 'bg-rmi-bg/60 border-rmi-border/40 text-rmi-gray hover:text-rmi-white'
|
||||
}`}
|
||||
>
|
||||
{c.type.replace(/_/g, ' ').toUpperCase()} • {c.wallets.length} wallets • {c.totalPercentage.toFixed(1)}% • {c.confidence}% conf
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evasion summary */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: 'Sybil', val: data.evasionSummary.sybilDetected, icon: Users },
|
||||
{ label: 'Bridge Frag', val: data.evasionSummary.bridgeFragmentation, icon: Network },
|
||||
{ label: 'Mixer', val: data.evasionSummary.mixerUsage, icon: AlertTriangle },
|
||||
{ label: 'LP Masking', val: data.evasionSummary.lpMasking, icon: Info },
|
||||
].map((item, i) => (
|
||||
<div key={i} className={`p-2.5 rounded-xl border flex items-center gap-2 ${item.val ? 'bg-rmi-danger/8 border-rmi-danger/20' : 'bg-rmi-success/5 border-rmi-success/15'}`}>
|
||||
<item.icon className={`w-3.5 h-3.5 ${item.val ? 'text-rmi-danger' : 'text-rmi-success'}`} />
|
||||
<div>
|
||||
<div className="text-[9px] text-rmi-gray uppercase font-semibold">{item.label}</div>
|
||||
<div className={`text-[11px] font-bold ${item.val ? 'text-rmi-danger' : 'text-rmi-success'}`}>{item.val ? 'DETECTED' : 'CLEAR'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/BubbleMapPage.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Radar, Loader2, Search } from 'lucide-react';
|
||||
import BubbleMap from './BubbleMap';
|
||||
// import type { ... } from '@/services/dexData'; // Temporarily disabled
|
||||
|
||||
function generateMockBubbleMap(tokenAddress: string, chain: string): BubbleMapData {
|
||||
const holders: HolderAnalysis[] = [];
|
||||
const types: HolderAnalysis['type'][] = ['dev', 'cex', 'dex', 'whale', 'dead', 'contract', 'retail'];
|
||||
const colors: Record<string, string> = { dev: '#FF3366', cex: '#627EEA', dex: '#14F195', whale: '#D1A340', dead: '#666666', contract: '#28CBF4', retail: '#8892b0' };
|
||||
let remaining = 100;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
let pct = i === 0 ? 12 + Math.random() * 15 : i < 3 ? 4 + Math.random() * 8 : i < 8 ? 1 + Math.random() * 3 : 0.2 + Math.random() * 1.5;
|
||||
pct = Math.min(pct, remaining * 0.9); remaining -= pct;
|
||||
const type = i === 0 ? 'dev' : i < 2 ? 'whale' : i < 4 ? 'cex' : types[Math.floor(Math.random() * types.length)];
|
||||
const signals: EvasionSignal[] = [];
|
||||
if (type === 'contract' && pct > 5) signals.push({ type: 'lp_masking', confidence: 65, description: 'Large contract holding masks true distribution', relatedWallets: [] });
|
||||
if (type === 'dev' && pct > 8) signals.push({ type: 'sybil_peer', confidence: 55, description: 'Dev concentration with dispersal pattern', relatedWallets: [] });
|
||||
holders.push({ address: `${chain.slice(0,3)}_${Math.random().toString(36).slice(2,12)}`, balance: Math.floor(pct*1000000).toString(), percentage: pct, usdValue: Math.floor(pct*10000), type, label: type==='dev'?'Token Creator':type==='cex'?'Binance Hot 4':type==='whale'?`Alpha Whale #${i}`:`Holder #${i+1}`, riskFlags: type==='dev'?['creator_unsold']:[], isContract: type==='contract'||type==='dex', lastActive: Date.now()-Math.random()*86400000*30, evasionSignals: signals, fundingSources: i===0?[]:[`fund_${Math.random().toString(36).slice(2,8)}`] });
|
||||
}
|
||||
if (remaining > 0) holders.push({ address: 'retail_rest', balance: '0', percentage: remaining, usdValue: 0, type: 'retail', label: `${2000+Math.floor(Math.random()*8000)} retail holders`, riskFlags: [], isContract: false, lastActive: 0, evasionSignals: [], fundingSources: [] });
|
||||
holders.sort((a,b)=>b.percentage-a.percentage);
|
||||
|
||||
const clusters: WalletCluster[] = [];
|
||||
const fundingMap = new Map<string, string[]>();
|
||||
for (const h of holders) { for (const src of h.fundingSources) { if (!fundingMap.has(src)) fundingMap.set(src, []); fundingMap.get(src)!.push(h.address); } }
|
||||
for (const [src, wallets] of fundingMap) { if (wallets.length >= 3) clusters.push({ id: `sybil_${src.slice(0,8)}`, wallets, totalPercentage: wallets.reduce((a,addr)=>a+(holders.find(x=>x.address===addr)?.percentage||0),0), type: 'sybil', confidence: Math.min(95,60+wallets.length*5), detectionMethod: 'Common funding source' }); }
|
||||
|
||||
return {
|
||||
tokenAddress, chain, totalHolders: 5000 + Math.floor(Math.random() * 15000),
|
||||
concentrationScore: Math.min(100, holders.slice(0,10).reduce((a,h)=>a+h.percentage,0) * 1.5),
|
||||
holders, clusters,
|
||||
evasionSummary: { sybilDetected: clusters.some(c=>c.type==='sybil'), bridgeFragmentation: false, mixerUsage: false, lpMasking: holders.some(h=>h.evasionSignals.some(s=>s.type==='lp_masking')), totalEvasionScore: clusters.length * 25 }
|
||||
};
|
||||
}
|
||||
|
||||
export default function BubbleMapPage() {
|
||||
const { chain, token } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<BubbleMapData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputChain, setInputChain] = useState(chain || 'solana');
|
||||
const [inputToken, setInputToken] = useState(token || '');
|
||||
|
||||
useEffect(() => { if (chain && token) { setLoading(true); setTimeout(() => { setData(generateMockBubbleMap(token, chain)); setLoading(false); }, 800); } }, [chain, token]);
|
||||
|
||||
const handleSearch = () => { if (inputChain && inputToken) navigate(`/bubblemap/${inputChain}/${inputToken}`); };
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-rmi-bg text-rmi-white">
|
||||
<div className="h-14 border-b border-rmi-border/50 bg-rmi-surface/50 backdrop-blur-xl flex items-center px-5 justify-between z-20 relative">
|
||||
<button onClick={() => navigate('/app')} className="flex items-center gap-2 text-sm text-rmi-gray hover:text-rmi-white transition-colors"><ChevronLeft className="w-4 h-4" /> Back</button>
|
||||
<div className="flex items-center gap-2"><Radar className="w-4 h-4 text-rmi-cyan" /><h1 className="text-sm font-bold text-gradient-purple tracking-tight">Bubble Map</h1></div>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto p-5 space-y-5">
|
||||
<div className="flex gap-2">
|
||||
<select value={inputChain} onChange={e => setInputChain(e.target.value)} className="bg-rmi-bg/60 border border-rmi-border/50 rounded-xl px-3 py-2.5 text-sm text-rmi-white focus:outline-none">
|
||||
<option value="solana">Solana</option><option value="ethereum">Ethereum</option><option value="bsc">BSC</option><option value="base">Base</option><option value="arbitrum">Arbitrum</option>
|
||||
</select>
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-rmi-gray/60" />
|
||||
<input value={inputToken} onChange={e => setInputToken(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} placeholder="Token address..." className="w-full bg-rmi-bg/60 border border-rmi-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm text-rmi-white placeholder-rmi-gray/50 focus:outline-none" />
|
||||
</div>
|
||||
<button onClick={handleSearch} className="px-5 py-2.5 bg-gradient-to-r from-rmi-purple to-rmi-cyan rounded-xl text-sm font-bold text-white hover:shadow-lg hover:shadow-rmi-purple/30 transition-all">Analyze</button>
|
||||
</div>
|
||||
{loading && <div className="flex items-center justify-center py-20"><Loader2 className="w-8 h-8 text-rmi-purple animate-spin" /></div>}
|
||||
{data && <BubbleMap data={data} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/components/ClusterInspector.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// @ts-nocheck
|
||||
import { useState } from 'react';
|
||||
import { Users, AlertTriangle, ShieldCheck, ChevronDown, ChevronUp, Eye, EyeOff } from 'lucide-react';
|
||||
import type { ClusterInfo, WalletNode } from '@/types';
|
||||
|
||||
interface ClusterInspectorProps {
|
||||
clusters: ClusterInfo[];
|
||||
nodes: WalletNode[];
|
||||
onHighlightCluster: (walletIds: string[]) => void;
|
||||
onClearHighlight: () => void;
|
||||
}
|
||||
|
||||
const riskMeta = (level: string) => {
|
||||
switch (level) {
|
||||
case 'critical': return { icon: AlertTriangle, color: 'text-rmi-danger', bg: 'bg-rmi-danger/10', border: 'border-rmi-danger/20' };
|
||||
case 'high': return { icon: AlertTriangle, color: 'text-rmi-orange', bg: 'bg-rmi-orange/10', border: 'border-rmi-orange/20' };
|
||||
case 'medium': return { icon: ShieldCheck, color: 'text-rmi-gold', bg: 'bg-rmi-gold/10', border: 'border-rmi-gold/20' };
|
||||
default: return { icon: ShieldCheck, color: 'text-rmi-success', bg: 'bg-rmi-success/10', border: 'border-rmi-success/20' };
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'funding': return '💰 Funding';
|
||||
case 'temporal': return '⏰ Temporal';
|
||||
case 'behavioral': return '🧠 Behavioral';
|
||||
case 'ownership': return '🏠 Ownership';
|
||||
case 'sybil': return '🎭 Sybil';
|
||||
default: return type;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ClusterInspector({ clusters, nodes, onHighlightCluster, onClearHighlight }: ClusterInspectorProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [highlighted, setHighlighted] = useState<string | null>(null);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(expanded);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setExpanded(next);
|
||||
};
|
||||
|
||||
const handleHighlight = (id: string, wallets: string[]) => {
|
||||
if (highlighted === id) {
|
||||
setHighlighted(null);
|
||||
onClearHighlight();
|
||||
} else {
|
||||
setHighlighted(id);
|
||||
onHighlightCluster(wallets);
|
||||
}
|
||||
};
|
||||
|
||||
if (clusters.length === 0) {
|
||||
return (
|
||||
<div className="p-4 border-b border-rmi-border/40">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-rmi-gray mb-2">
|
||||
<Users className="w-4 h-4" /> Clusters
|
||||
</div>
|
||||
<p className="text-[11px] text-rmi-gray">No clusters detected yet. Run analysis on a wallet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-rmi-border/40">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-rmi-gray mb-3">
|
||||
<Users className="w-4 h-4" /> Clusters
|
||||
<span className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-rmi-bg border border-rmi-border text-rmi-white">{clusters.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{clusters.map(cluster => {
|
||||
const meta = riskMeta(cluster.riskLevel);
|
||||
const Icon = meta.icon;
|
||||
const isOpen = expanded.has(cluster.id);
|
||||
const isHighlighted = highlighted === cluster.id;
|
||||
return (
|
||||
<div key={cluster.id} className={`rounded-xl border ${meta.border} ${meta.bg} overflow-hidden transition-all`}>
|
||||
<button
|
||||
onClick={() => toggle(cluster.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left"
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${meta.color} flex-shrink-0`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] font-semibold text-rmi-white truncate">{cluster.name}</div>
|
||||
<div className="text-[10px] text-rmi-gray flex items-center gap-1.5">
|
||||
<span>{typeLabel(cluster.type)}</span>
|
||||
<span>•</span>
|
||||
<span>{cluster.wallets.length} wallets</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleHighlight(cluster.id, cluster.wallets); }}
|
||||
className={`p-1 rounded transition-colors ${isHighlighted ? 'text-rmi-purple bg-rmi-purple/20' : 'text-rmi-gray hover:text-rmi-white'}`}
|
||||
title={isHighlighted ? 'Unhighlight' : 'Highlight on graph'}
|
||||
>
|
||||
{isHighlighted ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||
</button>
|
||||
{isOpen ? <ChevronUp className="w-3 h-3 text-rmi-gray" /> : <ChevronDown className="w-3 h-3 text-rmi-gray" />}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<p className="text-[10px] text-rmi-gray leading-relaxed">{cluster.description}</p>
|
||||
{cluster.totalSupplyPercent && (
|
||||
<div className="text-[10px] text-rmi-white">
|
||||
Supply held: <span className="font-bold text-rmi-gold">{cluster.totalSupplyPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cluster.wallets.map(wid => {
|
||||
const node = nodes.find(n => n.id === wid);
|
||||
return (
|
||||
<span key={wid} className="px-1.5 py-0.5 rounded bg-rmi-bg/60 border border-rmi-border/40 text-[9px] text-rmi-gray font-mono truncate max-w-[80px]">
|
||||
{node?.label || wid.slice(0, 6)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
src/components/ConsentBanner.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { X, Cookie, Settings, ChevronDown, ChevronUp, Shield } from 'lucide-react';
|
||||
|
||||
interface ConsentState {
|
||||
essential: boolean;
|
||||
functional: boolean;
|
||||
analytics: boolean;
|
||||
aiTraining: boolean;
|
||||
timestamp: number;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const CONSENT_KEY = 'rmi_consent_v2';
|
||||
const CONSENT_VERSION = '2026-06-01';
|
||||
|
||||
function getDefaultConsent(): ConsentState {
|
||||
return {
|
||||
essential: true,
|
||||
functional: false,
|
||||
analytics: false,
|
||||
aiTraining: false,
|
||||
timestamp: Date.now(),
|
||||
version: CONSENT_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
function loadConsent(): ConsentState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONSENT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as ConsentState;
|
||||
if (parsed.version !== CONSENT_VERSION) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveConsent(state: ConsentState) {
|
||||
localStorage.setItem(CONSENT_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function useConsent() {
|
||||
const [consent, setConsentState] = useState<ConsentState>(() => {
|
||||
const saved = loadConsent();
|
||||
return saved ?? getDefaultConsent();
|
||||
});
|
||||
|
||||
const [showBanner, setShowBanner] = useState(() => loadConsent() === null);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setShowManager(true);
|
||||
window.addEventListener('rmi-open-consent', handler);
|
||||
return () => window.removeEventListener('rmi-open-consent', handler);
|
||||
}, []);
|
||||
|
||||
const setConsent = (newState: ConsentState) => {
|
||||
saveConsent(newState);
|
||||
setConsentState(newState);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const acceptAll = () => {
|
||||
setConsent({
|
||||
essential: true,
|
||||
functional: true,
|
||||
analytics: true,
|
||||
aiTraining: true,
|
||||
timestamp: Date.now(),
|
||||
version: CONSENT_VERSION,
|
||||
});
|
||||
};
|
||||
|
||||
const acceptEssential = () => {
|
||||
setConsent(getDefaultConsent());
|
||||
};
|
||||
|
||||
const openManager = () => {
|
||||
setShowManager(true);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
return {
|
||||
consent,
|
||||
showBanner,
|
||||
showManager,
|
||||
setShowManager,
|
||||
setConsent,
|
||||
acceptAll,
|
||||
acceptEssential,
|
||||
openManager,
|
||||
};
|
||||
}
|
||||
|
||||
export function ConsentBanner() {
|
||||
const {
|
||||
consent,
|
||||
showBanner,
|
||||
showManager,
|
||||
setShowManager,
|
||||
setConsent,
|
||||
acceptAll,
|
||||
acceptEssential,
|
||||
openManager,
|
||||
} = useConsent();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [draft, setDraft] = useState<ConsentState>(consent);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(consent);
|
||||
}, [consent, showManager]);
|
||||
|
||||
if (!showBanner && !showManager) return null;
|
||||
|
||||
const handleSavePreferences = () => {
|
||||
setConsent({ ...draft, timestamp: Date.now(), version: CONSENT_VERSION });
|
||||
setShowManager(false);
|
||||
};
|
||||
|
||||
const handleAcceptAllFromManager = () => {
|
||||
acceptAll();
|
||||
setShowManager(false);
|
||||
};
|
||||
|
||||
const toggleDraft = (key: keyof Omit<ConsentState, 'timestamp' | 'version'>) => {
|
||||
if (key === 'essential') return;
|
||||
setDraft(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
// Banner view
|
||||
if (showBanner && !showManager) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-[100] bg-gray-900/95 backdrop-blur-md border-t border-gray-700 shadow-2xl">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Cookie className="w-5 h-5 text-amber-400" />
|
||||
</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
<p className="font-semibold text-white mb-1">
|
||||
We value your privacy
|
||||
</p>
|
||||
<p>
|
||||
We use cookies and similar technologies to provide security tools, analyze usage, and improve our AI models. You can choose which categories you accept. Essential cookies are always active.{' '}
|
||||
<a href="/cookies" className="text-purple-400 hover:underline">Learn more</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={openManager}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Manage
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptEssential}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-600 transition-colors"
|
||||
>
|
||||
Essential Only
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-500 rounded-lg transition-colors"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manager modal
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg bg-gray-900 rounded-2xl border border-gray-700 shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center">
|
||||
<Cookie className="w-5 h-5 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Cookie Preferences</h2>
|
||||
<p className="text-xs text-gray-500">Customize your consent</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowManager(false)}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
<p className="text-sm text-gray-400">
|
||||
Select which categories of cookies and data processing you consent to. You can change these preferences at any time.{' '}
|
||||
<a href="/cookies" className="text-purple-400 hover:underline">Read full Cookie Policy</a>
|
||||
</p>
|
||||
|
||||
{/* Essential */}
|
||||
<div className="p-3 rounded-xl bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm font-semibold text-white">Essential</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-green-500/10 text-green-400 rounded-full">Required</span>
|
||||
</div>
|
||||
<div className="w-11 h-6 bg-green-600 rounded-full relative cursor-not-allowed opacity-60">
|
||||
<div className="absolute right-1 top-1 w-4 h-4 bg-white rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Necessary for the Platform to function. Includes authentication, security, and consent storage. Cannot be disabled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Functional */}
|
||||
<div className="p-3 rounded-xl bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-white">Functional</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleDraft('functional')}
|
||||
className={`w-11 h-6 rounded-full relative transition-colors ${draft.functional ? 'bg-purple-600' : 'bg-gray-600'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${draft.functional ? 'right-1' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
UI preferences, recent scans, Scam School progress, and language settings. Improves your experience but not required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Analytics */}
|
||||
<div className="p-3 rounded-xl bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-white">Analytics</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleDraft('analytics')}
|
||||
className={`w-11 h-6 rounded-full relative transition-colors ${draft.analytics ? 'bg-purple-600' : 'bg-gray-600'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${draft.analytics ? 'right-1' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Anonymous usage data to understand how users interact with the Platform. Self-hosted — no third-party analytics trackers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Training */}
|
||||
<div className="p-3 rounded-xl bg-gray-800/50 border border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-white">AI Training</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full">Explicit</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleDraft('aiTraining')}
|
||||
className={`w-11 h-6 rounded-full relative transition-colors ${draft.aiTraining ? 'bg-purple-600' : 'bg-gray-600'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-all ${draft.aiTraining ? 'right-1' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Allows your query data (wallet addresses, token addresses) to be used for training our ML models and threat intelligence. Explicit opt-in. Does not affect Platform functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Details expand */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300 transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
{expanded ? 'Hide details' : 'Show cookie details'}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="text-xs text-gray-400 space-y-1 bg-gray-800/30 p-3 rounded-lg">
|
||||
<p><strong className="text-gray-300">Essential:</strong> sb-access-token, sb-refresh-token, __cf_bm, cf_clearance, rmi_consent, rmi_device_fp</p>
|
||||
<p><strong className="text-gray-300">Functional:</strong> rmi_theme, rmi_sidebar, rmi_recent_scans</p>
|
||||
<p><strong className="text-gray-300">Analytics:</strong> rmi_analytics_id</p>
|
||||
<p><strong className="text-gray-300">AI Training:</strong> rmi_ai_training_optin</p>
|
||||
<p className="text-gray-500 mt-1">See our <a href="/cookies" className="text-purple-400 hover:underline">Cookie Policy</a> for full table with durations and providers.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-700 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={handleAcceptAllFromManager}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-500 rounded-lg transition-colors"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowManager(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSavePreferences}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConsentBanner;
|
||||
301
src/components/ControlSidebar.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// @ts-nocheck
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
SlidersHorizontal, LayoutGrid, Circle, Grid3X3, StretchHorizontal,
|
||||
Fingerprint, Wallet, AlertTriangle, FishSymbol,
|
||||
Bot, UserX, Landmark, ArrowLeftRight, Rocket, Crosshair, Zap,
|
||||
ChevronDown, ChevronUp, Activity, Eye, Orbit, Radio, Bug, AlertTriangle,
|
||||
Star, Diamond, Tag, GitBranch
|
||||
} from 'lucide-react';
|
||||
import RiskRing from './RiskRing';
|
||||
import type { WalletNode, GraphFilter, BypassTechnique } from '@/types';
|
||||
import { techniqueLabel } from '@/services/stealthDetection';
|
||||
|
||||
interface ControlSidebarProps {
|
||||
filters: GraphFilter;
|
||||
onFilterChange: (filters: GraphFilter) => void;
|
||||
onLayoutChange: (layout: GraphFilter['layout']) => void;
|
||||
selectedNode?: WalletNode | null;
|
||||
onTracePaths?: (nodeId: string) => void;
|
||||
bypassBadges?: BypassTechnique[];
|
||||
}
|
||||
|
||||
const WALLET_TYPES = [
|
||||
{ id: 'exchange', label: 'Exchange', icon: Landmark, color: '#28CBF4' },
|
||||
{ id: 'mixer', label: 'Mixer', icon: Fingerprint, color: '#FF3366' },
|
||||
{ id: 'scammer', label: 'Scammer', icon: AlertTriangle, color: '#FF3366' },
|
||||
{ id: 'whale', label: 'Whale', icon: FishSymbol, color: '#D1A340' },
|
||||
{ id: 'bot', label: 'Bot', icon: Bot, color: '#F4A259' },
|
||||
{ id: 'victim', label: 'Victim', icon: UserX, color: '#00E676' },
|
||||
{ id: 'bridge', label: 'Bridge', icon: ArrowLeftRight, color: '#8b5cf6' },
|
||||
{ id: 'dex', label: 'DEX', icon: Activity, color: '#28CBF4' },
|
||||
{ id: 'deployer', label: 'Deployer', icon: Rocket, color: '#FF3366' },
|
||||
{ id: 'sniper', label: 'Sniper', icon: Crosshair, color: '#F4A259' },
|
||||
{ id: 'mev', label: 'MEV', icon: Zap, color: '#D1A340' },
|
||||
// Anti-bypass types
|
||||
{ id: 'dev_wallet', label: 'Dev Wallet', icon: AlertTriangle, color: '#FF3366' },
|
||||
{ id: 'stealth_bundler', label: 'Bundler', icon: Star, color: '#FF3366' },
|
||||
{ id: 'intermediate', label: 'Intermediate', icon: Diamond, color: '#F4A259' },
|
||||
{ id: 'temp_wallet', label: 'Temp Wallet', icon: Diamond, color: '#F4A259' },
|
||||
{ id: 'sybil_cluster', label: 'Sybil', icon: Tag, color: '#D1A340' },
|
||||
{ id: 'routing_bot', label: 'Router', icon: GitBranch, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
const CHAINS = [
|
||||
{ id: 'ethereum', label: 'ETH', color: '#627EEA' },
|
||||
{ id: 'solana', label: 'SOL', color: '#14F195' },
|
||||
{ id: 'bsc', label: 'BSC', color: '#F3BA2F' },
|
||||
{ id: 'polygon', label: 'POL', color: '#8247E5' },
|
||||
{ id: 'tron', label: 'TRX', color: '#FF060A' },
|
||||
];
|
||||
|
||||
const LAYOUTS: { id: GraphFilter['layout']; label: string; icon: any }[] = [
|
||||
{ id: 'cose', label: 'Force', icon: LayoutGrid },
|
||||
{ id: 'circle', label: 'Circle', icon: Circle },
|
||||
{ id: 'grid', label: 'Grid', icon: Grid3X3 },
|
||||
{ id: 'concentric', label: 'Rings', icon: StretchHorizontal },
|
||||
{ id: 'breadthfirst', label: 'Tree', icon: Activity },
|
||||
];
|
||||
|
||||
export default function ControlSidebar({
|
||||
filters, onFilterChange, onLayoutChange, selectedNode, onTracePaths, bypassBadges = [],
|
||||
}: ControlSidebarProps) {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const [showLayout, setShowLayout] = useState(true);
|
||||
const [showNodeDetails, setShowNodeDetails] = useState(true);
|
||||
const [showBypass, setShowBypass] = useState(true);
|
||||
|
||||
const toggleWalletType = (typeId: string) => {
|
||||
const next = filters.walletTypes.includes(typeId as any)
|
||||
? filters.walletTypes.filter(t => t !== typeId)
|
||||
: [...filters.walletTypes, typeId as any];
|
||||
onFilterChange({ ...filters, walletTypes: next });
|
||||
};
|
||||
|
||||
const toggleChain = (chainId: string) => {
|
||||
const next = filters.chains.includes(chainId)
|
||||
? filters.chains.filter(c => c !== chainId)
|
||||
: [...filters.chains, chainId];
|
||||
onFilterChange({ ...filters, chains: next });
|
||||
};
|
||||
|
||||
const SectionHeader = ({ icon: Icon, label, open, onClick }: any) => (
|
||||
<button onClick={onClick} className="flex items-center justify-between w-full text-[11px] font-bold uppercase tracking-[0.12em] text-rmi-gray mb-3 hover:text-rmi-white transition-colors">
|
||||
<span className="flex items-center gap-2"><Icon className="w-3.5 h-3.5" /> {label}</span>
|
||||
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r border-rmi-border/50 bg-rmi-surface/30 backdrop-blur flex flex-col overflow-y-auto custom-scrollbar">
|
||||
{/* Filters */}
|
||||
<div className="p-3.5 border-b border-rmi-border/40">
|
||||
<SectionHeader icon={SlidersHorizontal} label="Filters" open={showFilters} onClick={() => setShowFilters(!showFilters)} />
|
||||
{showFilters && (
|
||||
<div className="space-y-4">
|
||||
{/* Risk */}
|
||||
<div>
|
||||
<label className="text-[10px] text-rmi-gray uppercase tracking-wider mb-1.5 block font-semibold">Risk Range</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" min={0} max={100} value={filters.minRisk}
|
||||
onChange={(e) => onFilterChange({ ...filters, minRisk: Number(e.target.value) })}
|
||||
className="w-14 bg-rmi-bg/60 border border-rmi-border/50 rounded-lg px-2 py-1 text-[11px] text-rmi-white text-center" />
|
||||
<div className="flex-1 h-1 bg-rmi-bg rounded-full relative">
|
||||
<div className="absolute inset-y-0 left-0 rounded-full" style={{ width: `${filters.minRisk}%`, background: 'rgba(139,92,246,0.3)' }} />
|
||||
</div>
|
||||
<input type="number" min={0} max={100} value={filters.maxRisk}
|
||||
onChange={(e) => onFilterChange({ ...filters, maxRisk: Number(e.target.value) })}
|
||||
className="w-14 bg-rmi-bg/60 border border-rmi-border/50 rounded-lg px-2 py-1 text-[11px] text-rmi-white text-center" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Types */}
|
||||
<div>
|
||||
<label className="text-[10px] text-rmi-gray uppercase tracking-wider mb-1.5 block font-semibold">Wallet Types</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{WALLET_TYPES.map(t => {
|
||||
const active = filters.walletTypes.includes(t.id as any);
|
||||
return (
|
||||
<button key={t.id} onClick={() => toggleWalletType(t.id)}
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[10px] transition-all border ${
|
||||
active
|
||||
? 'text-rmi-white shadow-sm'
|
||||
: 'bg-rmi-bg/40 border-rmi-border/40 text-rmi-gray hover:text-rmi-white hover:border-rmi-border'
|
||||
}`}
|
||||
style={active ? { background: `${t.color}15`, borderColor: `${t.color}40` } : {}}
|
||||
>
|
||||
<t.icon className="w-3 h-3" style={{ color: active ? t.color : undefined }} />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chains */}
|
||||
<div>
|
||||
<label className="text-[10px] text-rmi-gray uppercase tracking-wider mb-1.5 block font-semibold">Chains</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{CHAINS.map(c => {
|
||||
const active = filters.chains.includes(c.id);
|
||||
return (
|
||||
<button key={c.id} onClick={() => toggleChain(c.id)}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] border transition-all ${
|
||||
active
|
||||
? 'text-rmi-white shadow-sm'
|
||||
: 'bg-rmi-bg/40 border-rmi-border/40 text-rmi-gray hover:text-rmi-white'
|
||||
}`}
|
||||
style={active ? { background: `${c.color}15`, borderColor: `${c.color}40` } : {}}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: c.color, boxShadow: active ? `0 0 6px ${c.color}` : 'none' }} />
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels toggle */}
|
||||
<button onClick={() => onFilterChange({ ...filters, showLabels: !filters.showLabels })}
|
||||
className="flex items-center justify-between w-full px-2 py-1.5 rounded-lg bg-rmi-bg/40 border border-rmi-border/40 text-[11px] text-rmi-gray hover:text-rmi-white transition-colors">
|
||||
<span className="flex items-center gap-1.5"><Eye className="w-3 h-3" /> Show Labels</span>
|
||||
<div className={`w-7 h-4 rounded-full relative transition-colors ${filters.showLabels ? 'bg-rmi-purple' : 'bg-rmi-border'}`}>
|
||||
<div className={`absolute top-0.5 w-3 h-3 rounded-full bg-white transition-all ${filters.showLabels ? 'left-3.5' : 'left-0.5'}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Hidden connections toggle */}
|
||||
<button onClick={() => onFilterChange({ ...filters, showHiddenConnections: !filters.showHiddenConnections })}
|
||||
className="flex items-center justify-between w-full px-2 py-1.5 rounded-lg bg-rmi-bg/40 border border-rmi-border/40 text-[11px] text-rmi-gray hover:text-rmi-white transition-colors">
|
||||
<span className="flex items-center gap-1.5"><Radio className="w-3 h-3" /> Hidden Links</span>
|
||||
<div className={`w-7 h-4 rounded-full relative transition-colors ${filters.showHiddenConnections ? 'bg-rmi-danger' : 'bg-rmi-border'}`}>
|
||||
<div className={`absolute top-0.5 w-3 h-3 rounded-full bg-white transition-all ${filters.showHiddenConnections ? 'left-3.5' : 'left-0.5'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bypass Detection Panel */}
|
||||
{bypassBadges.length > 0 && (
|
||||
<div className="p-3.5 border-b border-rmi-border/40">
|
||||
<SectionHeader icon={Bug} label="Bypass Alerts" open={showBypass} onClick={() => setShowBypass(!showBypass)} />
|
||||
{showBypass && (
|
||||
<div className="space-y-1.5">
|
||||
{bypassBadges.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-rmi-danger/10 border border-rmi-danger/15">
|
||||
<AlertTriangle className="w-3 h-3 text-rmi-danger flex-shrink-0" />
|
||||
<span className="text-[10px] text-rmi-white font-medium">{techniqueLabel(t)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout */}
|
||||
<div className="p-3.5 border-b border-rmi-border/40">
|
||||
<SectionHeader icon={Orbit} label="Layout" open={showLayout} onClick={() => setShowLayout(!showLayout)} />
|
||||
{showLayout && (
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{LAYOUTS.map(l => (
|
||||
<button key={l.id} onClick={() => onLayoutChange(l.id)}
|
||||
className={`flex flex-col items-center gap-1 p-1.5 rounded-lg border text-[9px] transition-all ${
|
||||
filters.layout === l.id
|
||||
? 'bg-rmi-purple/15 border-rmi-purple/40 text-rmi-white shadow-sm'
|
||||
: 'bg-rmi-bg/40 border-rmi-border/40 text-rmi-gray hover:text-rmi-white'
|
||||
}`}>
|
||||
<l.icon className="w-4 h-4" />
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Node */}
|
||||
<div className="p-3.5 flex-1">
|
||||
<SectionHeader icon={Wallet} label="Node Intel" open={showNodeDetails} onClick={() => setShowNodeDetails(!showNodeDetails)} />
|
||||
{showNodeDetails && selectedNode && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-xl glass-strong">
|
||||
<div className="text-[10px] text-rmi-gray uppercase tracking-wider mb-0.5">Target</div>
|
||||
<div className="text-sm font-bold text-rmi-white font-mono truncate">{selectedNode.label}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-xl glass-strong flex items-center gap-3">
|
||||
<RiskRing score={Math.round(selectedNode.risk * 100)} size={44} strokeWidth={3.5} />
|
||||
<div>
|
||||
<div className="text-[10px] text-rmi-gray uppercase tracking-wider font-semibold">Threat Level</div>
|
||||
<div className={`text-sm font-bold ${selectedNode.risk >= 0.7 ? 'text-rmi-danger' : selectedNode.risk >= 0.4 ? 'text-rmi-gold' : 'text-rmi-success'}`}>
|
||||
{selectedNode.risk >= 0.7 ? 'Critical' : selectedNode.risk >= 0.4 ? 'Elevated' : 'Low'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2.5 rounded-xl glass-strong">
|
||||
<div className="text-[10px] text-rmi-gray uppercase tracking-wider mb-0.5">Type</div>
|
||||
<div className="text-xs text-rmi-white font-medium capitalize">{selectedNode.type.replace(/_/g, ' ')}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl glass-strong">
|
||||
<div className="text-[10px] text-rmi-gray uppercase tracking-wider mb-0.5">Chain</div>
|
||||
<div className="text-xs text-rmi-white font-medium capitalize">{selectedNode.chain}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNode.balance && (
|
||||
<div className="p-2.5 rounded-xl glass-strong">
|
||||
<div className="text-[10px] text-rmi-gray uppercase tracking-wider mb-0.5">Balance</div>
|
||||
<div className="text-xs text-rmi-white font-mono">{selectedNode.balance}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.isDev && (
|
||||
<div className="p-2.5 rounded-xl bg-rmi-danger/10 border border-rmi-danger/20">
|
||||
<div className="text-[10px] text-rmi-danger uppercase tracking-wider mb-0.5 font-bold flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Dev Wallet Detected
|
||||
</div>
|
||||
<div className="text-xs text-rmi-white">Confidence: {((selectedNode.confidence || 0) * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.bypassTechniques && selectedNode.bypassTechniques.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedNode.bypassTechniques.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 rounded-lg bg-rmi-danger/10 border border-rmi-danger/20 text-[10px] text-rmi-danger font-medium">
|
||||
{techniqueLabel(tag)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.tags && selectedNode.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedNode.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 rounded-lg bg-rmi-purple/10 border border-rmi-purple/20 text-[10px] text-rmi-purple font-medium">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onTracePaths && (
|
||||
<button onClick={() => onTracePaths(selectedNode.id)}
|
||||
className="w-full py-2.5 bg-gradient-to-r from-rmi-purple/20 to-rmi-cyan/20 border border-rmi-purple/30 rounded-xl text-xs font-bold text-rmi-purple hover:from-rmi-purple/30 hover:to-rmi-cyan/30 transition-all flex items-center justify-center gap-2">
|
||||
<Radio className="w-3.5 h-3.5" />
|
||||
Trace Paths
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showNodeDetails && !selectedNode && (
|
||||
<div className="p-4 rounded-xl glass-strong text-center">
|
||||
<Orbit className="w-6 h-6 mx-auto mb-2 text-rmi-gray/40" />
|
||||
<p className="text-[11px] text-rmi-gray">Click a node to inspect</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/CursorGlow.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// @ts-nocheck
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function CursorGlow() {
|
||||
const glowRef = useRef<HTMLDivElement>(null);
|
||||
const pos = useRef({ x: -100, y: -100 });
|
||||
const target = useRef({ x: -100, y: -100 });
|
||||
|
||||
useEffect(() => {
|
||||
// Disable on touch devices
|
||||
if ('ontouchstart' in window) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
target.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
let raf: number;
|
||||
const animate = () => {
|
||||
pos.current.x += (target.current.x - pos.current.x) * 0.12;
|
||||
pos.current.y += (target.current.y - pos.current.y) * 0.12;
|
||||
if (glowRef.current) {
|
||||
glowRef.current.style.transform = `translate(${pos.current.x - 150}px, ${pos.current.y - 150}px)`;
|
||||
}
|
||||
raf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
raf = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={glowRef}
|
||||
className="fixed top-0 left-0 w-[300px] h-[300px] rounded-full pointer-events-none z-[9990] hidden md:block"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(139,92,246,0.08) 0%, rgba(40,203,244,0.03) 40%, transparent 70%)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
src/components/DevWalletDetector.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// @ts-nocheck
|
||||
import { useState } from 'react';
|
||||
import { Fingerprint, ChevronDown, ChevronUp, AlertTriangle, Crosshair, Eye, Activity, Wallet } from 'lucide-react';
|
||||
import type { WalletNode } from '@/types';
|
||||
|
||||
interface DevWalletDetectorProps {
|
||||
nodes: WalletNode[];
|
||||
devConfidence: Record<string, number>;
|
||||
onSelectWallet: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function DevWalletDetector({ nodes, devConfidence, onSelectWallet }: DevWalletDetectorProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const entries = Object.entries(devConfidence)
|
||||
.filter(([, conf]) => conf > 0.3)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="p-4 border-b border-rmi-border/40">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-rmi-gray mb-2">
|
||||
<Crosshair className="w-4 h-4" /> Dev Detection
|
||||
</div>
|
||||
<p className="text-[11px] text-rmi-gray">No dev wallets detected. Analyze a token contract to run heuristics.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-rmi-border/40">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center justify-between w-full text-xs font-bold uppercase tracking-widest text-rmi-gray mb-3 hover:text-rmi-white transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Crosshair className="w-4 h-4" /> Dev Detection
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-rmi-bg border border-rmi-border text-rmi-white">{entries.length}</span>
|
||||
</span>
|
||||
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([id, conf]) => {
|
||||
const node = nodes.find(n => n.id === id);
|
||||
const label = node?.label || `${id.slice(0, 8)}...`;
|
||||
const isHigh = conf >= 0.8;
|
||||
const isMed = conf >= 0.5;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onSelectWallet(id)}
|
||||
className={`w-full text-left rounded-xl border p-2.5 transition-all hover:scale-[1.02] ${
|
||||
isHigh
|
||||
? 'bg-rmi-danger/10 border-rmi-danger/25'
|
||||
: isMed
|
||||
? 'bg-rmi-gold/10 border-rmi-gold/25'
|
||||
: 'bg-rmi-bg/60 border-rmi-border/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fingerprint className={`w-3.5 h-3.5 ${isHigh ? 'text-rmi-danger' : isMed ? 'text-rmi-gold' : 'text-rmi-gray'}`} />
|
||||
<span className="text-[11px] font-semibold text-rmi-white truncate max-w-[100px]">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isHigh && <AlertTriangle className="w-3 h-3 text-rmi-danger" />}
|
||||
<span className={`text-[10px] font-bold ${isHigh ? 'text-rmi-danger' : isMed ? 'text-rmi-gold' : 'text-rmi-gray'}`}>
|
||||
{(conf * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-rmi-bg rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${conf * 100}%`,
|
||||
background: isHigh ? '#FF3366' : isMed ? '#D1A340' : '#8b5cf6',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
{node?.txCount !== undefined && (
|
||||
<span className="text-[9px] text-rmi-gray flex items-center gap-1">
|
||||
<Activity className="w-2.5 h-2.5" /> {node.txCount} txs
|
||||
</span>
|
||||
)}
|
||||
{node?.bypassTechniques && node.bypassTechniques.length > 0 && (
|
||||
<span className="text-[9px] text-rmi-gray flex items-center gap-1">
|
||||
<Eye className="w-2.5 h-2.5" /> {node.bypassTechniques.length} techniques
|
||||
</span>
|
||||
)}
|
||||
{node?.role && (
|
||||
<span className="text-[9px] text-rmi-gray flex items-center gap-1">
|
||||
<Wallet className="w-2.5 h-2.5" /> {node.role.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/components/DexScreener.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, TrendingUp, ArrowUpRight, ArrowDownRight, Droplets, Clock, Activity, AlertTriangle, ChevronLeft, Network, Radar, Sparkles, PanelLeftClose, PanelLeft, BrainCircuit, Skull } from 'lucide-react';
|
||||
import { searchPairs, getTrending, generateTokenRiskProfile, type DexPair, type TokenRiskProfile } from '@/services/dexData';
|
||||
import TokenChart from './TokenChart';
|
||||
import RugRiskPanel from './RugRiskPanel';
|
||||
import TokenRiskBadge from './TokenRiskBadge';
|
||||
|
||||
interface Props { onBack: () => void; onTraceInMaps?: (address: string, chain: string) => void; }
|
||||
|
||||
const CHAINS = ['solana', 'ethereum', 'bsc', 'base', 'arbitrum', 'polygon', 'ton'];
|
||||
const CHAIN_COLORS: Record<string, string> = { solana: '#14F195', ethereum: '#627EEA', bsc: '#F3BA2F', base: '#0052FF', arbitrum: '#28CBF4', polygon: '#8247E5', ton: '#0088CC' };
|
||||
|
||||
export default function DexScreener({ onBack, onTraceInMaps }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<DexPair[]>([]);
|
||||
const [profiles, setProfiles] = useState<Record<string, TokenRiskProfile>>({});
|
||||
const [selected, setSelected] = useState<DexPair | null>(null);
|
||||
const [trending, setTrending] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chainFilter, setChainFilter] = useState<string>('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => { getTrending().then(setTrending).catch(() => {}); }, []);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const pairs = await searchPairs(query.trim());
|
||||
setResults(pairs);
|
||||
// Generate risk profiles for all results
|
||||
const profs: Record<string, TokenRiskProfile> = {};
|
||||
for (const p of pairs) {
|
||||
profs[p.pairAddress] = generateTokenRiskProfile(p);
|
||||
}
|
||||
setProfiles(profs);
|
||||
if (pairs.length > 0) setSelected(pairs[0]);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}, [query]);
|
||||
|
||||
const filtered = chainFilter ? results.filter(r => r.chainId === chainFilter) : results;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-rmi-bg text-rmi-white relative">
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-rmi-border/50 bg-rmi-surface/50 backdrop-blur-xl flex items-center px-5 justify-between z-20 relative">
|
||||
<button onClick={onBack} className="flex items-center gap-2 text-sm text-rmi-gray hover:text-rmi-white transition-colors"><ChevronLeft className="w-4 h-4" /> Back</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className="w-4 h-4 text-rmi-gold" />
|
||||
<h1 className="text-sm font-bold text-gradient-gold tracking-tight">RugDEX Screener</h1>
|
||||
</div>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100vh-56px)] relative">
|
||||
{sidebarOpen && <div className="fixed inset-0 bg-black/40 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} />}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0 transition-transform duration-300 fixed lg:relative z-40 lg:z-auto h-full w-80 border-r border-rmi-border/50 bg-rmi-surface/20 flex flex-col`}>
|
||||
<div className="p-3 border-b border-rmi-border/40 space-y-2.5">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-rmi-gray/60" />
|
||||
<input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} placeholder="Search token or pair..." className="w-full bg-rmi-bg/60 border border-rmi-border/50 rounded-xl pl-10 pr-4 py-2.5 text-sm text-rmi-white placeholder-rmi-gray/50 focus:outline-none transition-all" />
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{CHAINS.map(c => (
|
||||
<button key={c} onClick={() => setChainFilter(chainFilter === c ? '' : c)} className={`px-2 py-1 rounded-lg text-[10px] font-semibold border transition-all capitalize ${chainFilter === c ? 'text-rmi-white shadow-sm' : 'bg-rmi-bg/40 border-rmi-border/40 text-rmi-gray hover:text-rmi-white'}`} style={chainFilter === c ? { background: `${CHAIN_COLORS[c] || '#8b5cf6'}18`, borderColor: `${CHAIN_COLORS[c] || '#8b5cf6'}50`, color: CHAIN_COLORS[c] || '#8b5cf6' } : {}}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && <div className="p-4 space-y-2">{[1,2,3].map(i => <div key={i} className="h-16 rounded-xl bg-rmi-bg/40 shimmer" />)}</div>}
|
||||
{!loading && filtered.length === 0 && query && <div className="p-6 text-center"><Search className="w-8 h-8 mx-auto mb-2 text-rmi-gray/30" /><p className="text-xs text-rmi-gray">No results.</p></div>}
|
||||
{filtered.map(pair => {
|
||||
const prof = profiles[pair.pairAddress];
|
||||
return (
|
||||
<button key={pair.pairAddress} onClick={() => setSelected(pair)} className={`w-full text-left p-3 border-b border-rmi-border/30 transition-all ${selected?.pairAddress === pair.pairAddress ? 'bg-rmi-purple/8 border-l-2 border-l-rmi-purple' : 'hover:bg-rmi-surface/40'}`}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-rmi-white">{pair.baseToken.symbol}/{pair.quoteToken.symbol}</span>
|
||||
{prof?.honeypotDetected && <Skull className="w-3 h-3 text-rmi-danger" />}
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-rmi-bg/60 border border-rmi-border/40 text-rmi-gray capitalize">{pair.chainId}</span>
|
||||
</div>
|
||||
{/* Risk badges row */}
|
||||
{prof && prof.labels.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap mb-1.5">
|
||||
{prof.labels.slice(0, 3).map((l, i) => <TokenRiskBadge key={i} label={l} size="sm" />)}
|
||||
{prof.labels.length > 3 && <span className="text-[9px] text-rmi-gray px-1">+{prof.labels.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-[11px] text-rmi-gray">
|
||||
<span className="text-rmi-white font-mono">${pair.priceUsd}</span>
|
||||
{pair.priceChange.h24 !== undefined && <span className={`flex items-center gap-0.5 ${pair.priceChange.h24 >= 0 ? 'text-rmi-success' : 'text-rmi-danger'}`}>{pair.priceChange.h24 >= 0 ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}{pair.priceChange.h24.toFixed(2)}%</span>}
|
||||
<span className="flex items-center gap-1 ml-auto"><Droplets className="w-3 h-3 text-rmi-cyan/70" /> ${(pair.liquidity.usd / 1e6).toFixed(1)}M</span>
|
||||
</div>
|
||||
{prof && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex-1 h-1 bg-rmi-bg rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${prof.score}%`, background: prof.score >= 70 ? '#FF3366' : prof.score >= 40 ? '#D1A340' : '#00E676' }} />
|
||||
</div>
|
||||
<span className={`text-[9px] font-bold ${prof.score >= 70 ? 'text-rmi-danger' : prof.score >= 40 ? 'text-rmi-gold' : 'text-rmi-success'}`}>{prof.score}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && !query && (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-rmi-gray mb-2"><Sparkles className="w-3 h-3 text-rmi-gold" /> Trending</div>
|
||||
{trending.slice(0, 10).map((t: any) => (
|
||||
<button key={t.item.id} onClick={() => { setQuery(t.item.symbol); handleSearch(); }} className="w-full text-left p-2 rounded-xl hover:bg-rmi-surface/40 transition-colors flex items-center gap-2.5">
|
||||
<img src={t.item.thumb} alt="" className="w-6 h-6 rounded-full border border-rmi-border/40" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-semibold text-rmi-white truncate">{t.item.name}</div>
|
||||
<div className="text-[10px] text-rmi-gray">{t.item.symbol}</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-rmi-success font-medium">+{t.item.data?.price_change_percentage_24h?.usd?.toFixed(1) || 0}%</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto min-w-0">
|
||||
<div className="lg:hidden px-3 pt-2">
|
||||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg glass text-[11px] text-rmi-gray hover:text-rmi-white transition-colors">
|
||||
{sidebarOpen ? <PanelLeftClose className="w-3.5 h-3.5" /> : <PanelLeft className="w-3.5 h-3.5" />} Tokens
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="p-5 border-b border-rmi-border/40">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-2xl font-black text-rmi-white tracking-tight">{selected.baseToken.name} <span className="text-rmi-gray text-base font-medium">({selected.baseToken.symbol})</span></h2>
|
||||
{profiles[selected.pairAddress]?.honeypotDetected && <span className="px-2 py-0.5 rounded bg-rmi-danger/15 border border-rmi-danger/30 text-[10px] text-rmi-danger font-bold">HONEYPOT</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-rmi-gray">
|
||||
<span className="capitalize px-2 py-0.5 rounded-md bg-rmi-bg/60 border border-rmi-border/40">{selected.chainId}</span>
|
||||
<span className="text-rmi-border">•</span>
|
||||
<span className="font-mono text-rmi-white/70">{selected.baseToken.address.slice(0, 6)}...{selected.baseToken.address.slice(-4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-black text-rmi-white tracking-tight">${selected.priceUsd}</div>
|
||||
{selected.priceChange.h24 !== undefined && <div className={`text-sm font-bold ${selected.priceChange.h24 >= 0 ? 'text-rmi-success' : 'text-rmi-danger'}`}>{selected.priceChange.h24 >= 0 ? '+' : ''}{selected.priceChange.h24.toFixed(2)}% <span className="text-rmi-gray font-normal">(24h)</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Signal Banner */}
|
||||
{profiles[selected.pairAddress] && (
|
||||
<div className={`mt-4 p-3 rounded-xl border flex items-center gap-3 ${
|
||||
profiles[selected.pairAddress].aiSignal === 'STRONG_BUY' || profiles[selected.pairAddress].aiSignal === 'BUY' ? 'bg-rmi-success/5 border-rmi-success/20' :
|
||||
profiles[selected.pairAddress].aiSignal === 'AVOID' || profiles[selected.pairAddress].aiSignal === 'SELL' ? 'bg-rmi-danger/5 border-rmi-danger/20' :
|
||||
'bg-rmi-gold/5 border-rmi-gold/20'
|
||||
}`}>
|
||||
<BrainCircuit className={`w-5 h-5 ${
|
||||
profiles[selected.pairAddress].aiSignal === 'STRONG_BUY' || profiles[selected.pairAddress].aiSignal === 'BUY' ? 'text-rmi-success' :
|
||||
profiles[selected.pairAddress].aiSignal === 'AVOID' || profiles[selected.pairAddress].aiSignal === 'SELL' ? 'text-rmi-danger' : 'text-rmi-gold'
|
||||
}`} />
|
||||
<div>
|
||||
<div className={`text-xs font-black ${
|
||||
profiles[selected.pairAddress].aiSignal === 'STRONG_BUY' || profiles[selected.pairAddress].aiSignal === 'BUY' ? 'text-rmi-success' :
|
||||
profiles[selected.pairAddress].aiSignal === 'AVOID' || profiles[selected.pairAddress].aiSignal === 'SELL' ? 'text-rmi-danger' : 'text-rmi-gold'
|
||||
}`}>
|
||||
AI SIGNAL: {profiles[selected.pairAddress].aiSignal.replace(/_/g, ' ')} • {profiles[selected.pairAddress].aiConfidence}% confidence
|
||||
</div>
|
||||
<div className="text-[11px] text-rmi-gray">{profiles[selected.pairAddress].aiReasoning}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-4">
|
||||
{[{ label: 'Liquidity', value: `$${(selected.liquidity.usd / 1e6).toFixed(2)}M`, icon: Droplets, color: 'text-rmi-cyan' }, { label: '24h Volume', value: `$${(selected.volume.h24 / 1e6).toFixed(2)}M`, icon: Activity, color: 'text-rmi-purple' }, { label: 'Market Cap', value: `$${(selected.marketCap / 1e6).toFixed(2)}M`, icon: TrendingUp, color: 'text-rmi-gold' }, { label: 'TXNs (24h)', value: `${selected.txns.h24.buys}B / ${selected.txns.h24.sells}S`, icon: ArrowUpRight, color: 'text-rmi-success' }].map((stat, i) => (
|
||||
<div key={i} className="p-3 rounded-xl glass-strong">
|
||||
<div className="flex items-center gap-1.5 mb-1"><stat.icon className={`w-3 h-3 ${stat.color}`} /><div className="text-[10px] text-rmi-gray uppercase tracking-wider font-semibold">{stat.label}</div></div>
|
||||
<div className="text-sm font-bold text-rmi-white">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-5 grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="rounded-2xl glass-strong p-4"><TokenChart /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-4 rounded-xl glass-strong"><div className="text-[10px] text-rmi-gray uppercase tracking-wider mb-1 font-semibold">Buy/Sell Ratio</div><div className="flex items-center gap-2"><Activity className="w-4 h-4 text-rmi-cyan" /><span className="text-sm font-bold">{selected.txns.h24.buys} / {selected.txns.h24.sells}</span></div></div>
|
||||
<div className="p-4 rounded-xl glass-strong"><div className="text-[10px] text-rmi-gray uppercase tracking-wider mb-1 font-semibold">Pair Age</div><div className="flex items-center gap-2"><Clock className="w-4 h-4 text-rmi-gold" /><span className="text-sm font-bold">{selected.pairCreatedAt ? `${((Date.now() - selected.pairCreatedAt) / 86400000).toFixed(1)}d` : 'Unknown'}</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-2xl glass-strong">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-rmi-gray mb-4"><AlertTriangle className="w-4 h-4 text-rmi-danger" /> Rug Analysis</div>
|
||||
<RugRiskPanel pair={selected} />
|
||||
{onTraceInMaps && <button onClick={() => onTraceInMaps(selected.baseToken.address, selected.chainId)} className="mt-4 w-full flex items-center justify-center gap-2 py-3 bg-gradient-to-r from-rmi-purple to-rmi-cyan rounded-xl text-xs font-bold text-white hover:shadow-lg hover:shadow-rmi-purple/30 transition-all"><Network className="w-4 h-4" /> Trace in RugMaps</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-8 rounded-2xl glass-strong">
|
||||
<Radar className="w-12 h-12 mx-auto mb-3 text-rmi-purple/40" />
|
||||
<p className="text-sm text-rmi-gray font-medium">Search for a token to begin analysis</p>
|
||||
<p className="text-[11px] text-rmi-gray/60 mt-1">Try "PEPE" or "BONK"</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
this.setState({ errorInfo });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const errorMsg = this.state.error?.message || 'Unknown error';
|
||||
const errorStack = this.state.error?.stack || '';
|
||||
const componentStack = this.state.errorInfo?.componentStack || '';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#07070b] flex items-center justify-center p-8">
|
||||
<div className="max-w-2xl w-full">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-red-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-semibold">Something went wrong</h2>
|
||||
<p className="text-gray-400 text-sm">Rug Munch Intelligence encountered an error</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0f0f14] border border-white/10 rounded-xl p-4 mb-4">
|
||||
<p className="text-red-400 text-sm font-mono mb-2">{errorMsg}</p>
|
||||
{errorStack && (
|
||||
<pre className="text-gray-500 text-xs overflow-auto max-h-32 whitespace-pre-wrap">{errorStack.split('\n').slice(0, 5).join('\n')}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{componentStack && (
|
||||
<div className="bg-[#0f0f14] border border-white/10 rounded-xl p-4 mb-4">
|
||||
<p className="text-purple-400 text-xs font-semibold mb-2">Component Stack:</p>
|
||||
<pre className="text-gray-500 text-xs overflow-auto max-h-24 whitespace-pre-wrap">{componentStack}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white rounded-lg text-sm font-semibold transition-all shadow-lg shadow-purple-900/20"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
92
src/components/ExportPanel.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// @ts-nocheck
|
||||
import { Download, Image, FileJson, FileSpreadsheet, FileText } from 'lucide-react';
|
||||
import type { AnalysisResult, WalletNode, TransactionEdge } from '@/types';
|
||||
|
||||
interface ExportPanelProps {
|
||||
cyRef?: any;
|
||||
nodes: WalletNode[];
|
||||
edges: TransactionEdge[];
|
||||
analysis?: AnalysisResult | null;
|
||||
}
|
||||
|
||||
export default function ExportPanel({ cyRef, nodes, edges, analysis }: ExportPanelProps) {
|
||||
const exportPNG = () => {
|
||||
if (!cyRef?.current) return;
|
||||
const png = cyRef.current.png({ bg: '#12121a', full: true, scale: 2 });
|
||||
const a = document.createElement('a');
|
||||
a.href = png;
|
||||
a.download = `rugmunch-graph-${Date.now()}.png`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const exportJSON = () => {
|
||||
const data = { nodes, edges, exportedAt: new Date().toISOString() };
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `rugmunch-data-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportCSV = () => {
|
||||
const headers = ['id', 'label', 'risk', 'type', 'chain', 'balance', 'tags'];
|
||||
const rows = nodes.map(n => [n.id, n.label, n.risk, n.type, n.chain, n.balance || '', (n.tags || []).join(';')]);
|
||||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `rugmunch-wallets-${Date.now()}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportReport = () => {
|
||||
if (!analysis) return;
|
||||
const lines = [
|
||||
'# Rug Munch Intelligence Investigation Report',
|
||||
`**Target:** ${analysis.targetAddress}`,
|
||||
`**Chain:** ${analysis.chain}`,
|
||||
`**Risk Score:** ${analysis.riskScore}/100`,
|
||||
`**Wallet Type:** ${analysis.walletType}`,
|
||||
`**Connected Wallets:** ${analysis.connectedWallets}`,
|
||||
`**Degree of Separation:** ${analysis.degreeOfSeparation}`,
|
||||
'',
|
||||
'## Risk Flags',
|
||||
...analysis.flags.map(f => `- **${f.severity.toUpperCase()}:** ${f.title} — ${f.description}`),
|
||||
'',
|
||||
`*Generated by Rug Munch Intelligence at ${new Date().toISOString()}*`,
|
||||
];
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `rugmunch-report-${Date.now()}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-rmi-border">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-rmi-gray mb-3">
|
||||
<Download className="w-4 h-4" /> Export
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={exportPNG} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-rmi-bg border border-rmi-border text-[11px] text-rmi-gray hover:text-rmi-white hover:border-rmi-purple/30 transition-colors">
|
||||
<Image className="w-3.5 h-3.5" /> PNG
|
||||
</button>
|
||||
<button onClick={exportJSON} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-rmi-bg border border-rmi-border text-[11px] text-rmi-gray hover:text-rmi-white hover:border-rmi-purple/30 transition-colors">
|
||||
<FileJson className="w-3.5 h-3.5" /> JSON
|
||||
</button>
|
||||
<button onClick={exportCSV} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-rmi-bg border border-rmi-border text-[11px] text-rmi-gray hover:text-rmi-white hover:border-rmi-purple/30 transition-colors">
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" /> CSV
|
||||
</button>
|
||||
<button onClick={exportReport} disabled={!analysis} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-rmi-bg border border-rmi-border text-[11px] text-rmi-gray hover:text-rmi-white hover:border-rmi-purple/30 transition-colors disabled:opacity-40">
|
||||
<FileText className="w-3.5 h-3.5" /> Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// @ts-nocheck
|
||||
import type { ReactNode } from 'react';
|
||||
import { Network, Sparkles, Zap } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const isApp = location.pathname === '/app';
|
||||
const isScreener = location.pathname === '/screener';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-rmi-bg text-rmi-white relative">
|
||||
{/* Ambient background glow */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-[30%] -left-[10%] w-[60%] h-[60%] rounded-full opacity-[0.06]" style={{ background: 'radial-gradient(circle, #8b5cf6 0%, transparent 70%)' }} />
|
||||
<div className="absolute -bottom-[30%] -right-[10%] w-[60%] h-[60%] rounded-full opacity-[0.04]" style={{ background: 'radial-gradient(circle, #28CBF4 0%, transparent 70%)' }} />
|
||||
</div>
|
||||
|
||||
<header className="relative h-14 border-b border-rmi-border/50 bg-rmi-surface/50 backdrop-blur-xl flex items-center px-5 justify-between z-20">
|
||||
<Link to="/" className="flex items-center gap-2.5 group">
|
||||
<div className="w-7 h-7 rounded-md bg-gradient-to-br from-rmi-purple to-rmi-cyan flex items-center justify-center shadow-lg shadow-rmi-purple/20">
|
||||
<Network className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-[15px] font-bold text-gradient-gold tracking-tight">Rug Munch</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden sm:flex items-center gap-1 bg-rmi-bg/60 rounded-lg p-0.5 border border-rmi-border/40">
|
||||
{[
|
||||
{ to: '/app', label: 'Investigation', active: isApp },
|
||||
{ to: '/screener', label: 'Screener', active: isScreener },
|
||||
{ to: '/markets', label: 'Markets', active: location.pathname === '/markets' },
|
||||
].map(link => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`px-3 py-1.5 rounded-md text-[11px] font-semibold transition-all ${
|
||||
link.active
|
||||
? 'bg-rmi-purple/20 text-rmi-purple shadow-sm'
|
||||
: 'text-rmi-gray hover:text-rmi-white'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden md:inline-flex items-center gap-1 px-2 py-1 rounded-md bg-rmi-purple/8 border border-rmi-purple/10 text-[10px] text-rmi-purple/80 font-medium">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
5 Chains
|
||||
</span>
|
||||
<span className="hidden lg:inline-flex items-center gap-1 px-2 py-1 rounded-md bg-rmi-cyan/8 border border-rmi-cyan/10 text-[10px] text-rmi-cyan/80 font-medium">
|
||||
<Zap className="w-3 h-3" />
|
||||
33 Types
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
src/components/MapsApp.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
import NetworkGraph from './NetworkGraph';
|
||||
import WalletPanel from './WalletPanel';
|
||||
import ControlSidebar from './ControlSidebar';
|
||||
import ClusterInspector from './ClusterInspector';
|
||||
import DevWalletDetector from './DevWalletDetector';
|
||||
import { DEMO_NODES, DEMO_EDGES, DEMO_ANALYSIS } from '@/data/demoData';
|
||||
import { generateAnalysis } from '@/services/supabaseClient';
|
||||
import aiRouter from '@/services/aiRouter';
|
||||
import { techniqueLabel } from '@/services/stealthDetection';
|
||||
import type { WalletNode, TransactionEdge, GraphFilter, AnalysisResult } from '@/types';
|
||||
import { useGraphAnalysis } from '@/hooks/useGraphAnalysis';
|
||||
import {
|
||||
BrainCircuit, Radio, Database, ArrowUpRight, PanelLeftClose, PanelLeft,
|
||||
Sparkles, ChevronRight, Target, AlertTriangle, Eye, Bug
|
||||
} from 'lucide-react';
|
||||
import ToastContainer, { type ToastMessage } from './Toast';
|
||||
import CursorGlow from './CursorGlow';
|
||||
import ScreenFlash from './ScreenFlash';
|
||||
|
||||
const DEFAULT_FILTERS: GraphFilter = {
|
||||
minRisk: 0,
|
||||
maxRisk: 100,
|
||||
walletTypes: [],
|
||||
chains: [],
|
||||
minValue: '',
|
||||
timeRange: null,
|
||||
showLabels: true,
|
||||
layout: 'cose',
|
||||
highlightBypass: true,
|
||||
showHiddenConnections: true,
|
||||
};
|
||||
|
||||
export default function MapsApp() {
|
||||
const location = useLocation();
|
||||
const initialAddress = (location.state as any)?.traceAddress;
|
||||
const initialChain = (location.state as any)?.traceChain || 'solana';
|
||||
|
||||
const [nodes, setNodes] = useState<WalletNode[]>(DEMO_NODES);
|
||||
const [edges] = useState<TransactionEdge[]>(DEMO_EDGES);
|
||||
const [filters, setFilters] = useState<GraphFilter>(DEFAULT_FILTERS);
|
||||
const [selectedNode, setSelectedNode] = useState<WalletNode | null>(null);
|
||||
const [analysis, setAnalysis] = useState<AnalysisResult | null>(DEMO_ANALYSIS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [aiSummary, setAiSummary] = useState<string>('');
|
||||
const [useRealData, setUseRealData] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
const [flashTrigger, setFlashTrigger] = useState(0);
|
||||
const [highlightedNodes, setHighlightedNodes] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
findShortestPath,
|
||||
stealthDetection,
|
||||
getClusterMembership,
|
||||
} = useGraphAnalysis(nodes, edges);
|
||||
|
||||
const addToast = (message: string, type: ToastMessage['type'] = 'info') => {
|
||||
setToasts(prev => [...prev, { id: Math.random().toString(36).slice(2), message, type }]);
|
||||
};
|
||||
const dismissToast = (id: string) => setToasts(prev => prev.filter(t => t.id !== id));
|
||||
|
||||
const runAnalysis = async (address: string, chain: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await generateAnalysis(address, chain);
|
||||
if (result.riskScore >= 70) setFlashTrigger(f => f + 1);
|
||||
setAnalysis(result);
|
||||
setNodes(prev => {
|
||||
if (prev.find(n => n.id === address)) return prev;
|
||||
return [...prev, {
|
||||
id: address,
|
||||
label: `${address.slice(0, 6)}...${address.slice(-4)}`,
|
||||
risk: result.riskScore / 100,
|
||||
type: result.walletType,
|
||||
chain,
|
||||
tags: ['analyzed'],
|
||||
}];
|
||||
});
|
||||
|
||||
// Build AI prompt with bypass detection context
|
||||
const bypassCtx = result.bypassTechniques
|
||||
? `Bypass techniques detected: ${result.bypassTechniques.map(techniqueLabel).join(', ')}.`
|
||||
: '';
|
||||
|
||||
const aiResult = await aiRouter.complete(
|
||||
`Analyze wallet ${address} on ${chain}. Risk: ${result.riskScore}/100. Flags: ${result.flags.map(f => f.title).join(', ')}. ${bypassCtx} 2-sentence threat assessment.`,
|
||||
{ system: 'You are a blockchain forensics AI. Be concise and threat-focused.', specialty: 'analysis', maxTokens: 256 }
|
||||
);
|
||||
setAiSummary(aiResult.text || aiResult.error || 'AI unavailable.');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAddress) {
|
||||
runAnalysis(initialAddress, initialChain);
|
||||
}
|
||||
}, [initialAddress, initialChain]);
|
||||
|
||||
const handleTracePaths = (nodeId: string) => {
|
||||
const target = nodes.find(n => n.type === 'exchange' || n.type === 'cex_hot');
|
||||
if (!target) { addToast('No CEX node found for path tracing', 'error'); return; }
|
||||
const path = findShortestPath(nodeId, target.id);
|
||||
if (path.length === 0) { addToast('No path found to CEX', 'error'); return; }
|
||||
addToast(`Path found: ${path.length} hops to ${target.label}`, 'success');
|
||||
};
|
||||
|
||||
const handleHighlightCluster = useCallback((walletIds: string[]) => {
|
||||
setHighlightedNodes(walletIds);
|
||||
}, []);
|
||||
|
||||
const handleClearHighlight = useCallback(() => {
|
||||
setHighlightedNodes([]);
|
||||
}, []);
|
||||
|
||||
const handleSelectDevWallet = useCallback((id: string) => {
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (node) setSelectedNode(node);
|
||||
const membership = getClusterMembership(id);
|
||||
if (membership.length > 0) {
|
||||
setHighlightedNodes(membership.flatMap(c => c.wallets));
|
||||
}
|
||||
}, [nodes, getClusterMembership]);
|
||||
|
||||
// Compute bypass technique badges for display
|
||||
const bypassBadges = analysis?.bypassTechniques || [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex h-[calc(100vh-56px)] relative">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/40 z-30 md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className={`${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} md:translate-x-0 transition-transform duration-300 fixed md:relative z-40 md:z-auto h-full overflow-y-auto custom-scrollbar`}>
|
||||
<ControlSidebar
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
onLayoutChange={(layout) => setFilters(f => ({ ...f, layout }))}
|
||||
selectedNode={selectedNode}
|
||||
onTracePaths={handleTracePaths}
|
||||
bypassBadges={bypassBadges}
|
||||
/>
|
||||
|
||||
<ClusterInspector
|
||||
clusters={analysis?.clusters || []}
|
||||
nodes={nodes}
|
||||
onHighlightCluster={handleHighlightCluster}
|
||||
onClearHighlight={handleClearHighlight}
|
||||
/>
|
||||
|
||||
<DevWalletDetector
|
||||
nodes={nodes}
|
||||
devConfidence={stealthDetection.devConfidence}
|
||||
onSelectWallet={handleSelectDevWallet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="md:hidden px-3 pt-2">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg glass text-[11px] text-rmi-gray hover:text-rmi-white transition-colors"
|
||||
>
|
||||
{sidebarOpen ? <PanelLeftClose className="w-3.5 h-3.5" /> : <PanelLeft className="w-3.5 h-3.5" />}
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WalletPanel onAnalyze={runAnalysis} analysis={analysis} loading={loading} />
|
||||
|
||||
{/* Bypass Technique Badges */}
|
||||
{bypassBadges.length > 0 && (
|
||||
<div className="px-4 py-1.5 bg-gradient-to-r from-rmi-danger/8 via-rmi-danger/4 to-transparent border-b border-rmi-danger/15">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-rmi-danger flex-shrink-0" />
|
||||
<span className="text-[10px] text-rmi-gray font-semibold uppercase tracking-wider">Bypass Techniques:</span>
|
||||
{bypassBadges.map((t, i) => (
|
||||
<span key={i} className="px-2 py-0.5 rounded-full bg-rmi-danger/10 border border-rmi-danger/20 text-[10px] text-rmi-danger font-medium">
|
||||
{techniqueLabel(t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aiSummary && (
|
||||
<div className="px-4 py-2 bg-gradient-to-r from-rmi-purple/8 via-rmi-purple/4 to-transparent border-b border-rmi-purple/15">
|
||||
<div className="flex items-start gap-2">
|
||||
<BrainCircuit className="w-3.5 h-3.5 text-rmi-purple mt-0.5 flex-shrink-0 animate-pulse" />
|
||||
<p className="text-[11px] text-rmi-gray leading-relaxed">
|
||||
<span className="text-rmi-purple font-semibold">Neural Analysis:</span>{' '}
|
||||
<span className="text-rmi-white/80">{aiSummary}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 relative p-3">
|
||||
<NetworkGraph
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
filters={filters}
|
||||
onNodeSelect={setSelectedNode}
|
||||
selectedNodeId={selectedNode?.id}
|
||||
highlightedNodes={highlightedNodes}
|
||||
hiddenEdges={filters.showHiddenConnections ? stealthDetection.hiddenEdges : []}
|
||||
/>
|
||||
|
||||
{!analysis && !selectedNode && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div className="text-center p-8 rounded-2xl glass-strong max-w-md mx-4 pointer-events-auto animate-float">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-rmi-purple to-rmi-cyan flex items-center justify-center mx-auto mb-4 shadow-lg shadow-rmi-purple/20">
|
||||
<Target className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-rmi-white mb-2">Start an Investigation</h3>
|
||||
<p className="text-xs text-rmi-gray mb-5 leading-relaxed">
|
||||
Paste any wallet address to trace its connections, assess risk, and uncover hidden relationships across chains.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ addr: 'dev_main', chain: 'solana', label: 'Suspected Dev (Demo)' },
|
||||
{ addr: '0xdAC17F958D2ee523a2206206994597C13D831ec7', chain: 'ethereum', label: 'USDT Treasury' },
|
||||
].map(ex => (
|
||||
<button
|
||||
key={ex.addr}
|
||||
onClick={() => runAnalysis(ex.addr, ex.chain)}
|
||||
className="flex items-center justify-between px-4 py-2.5 rounded-xl bg-rmi-bg/60 border border-rmi-border/40 text-left hover:border-rmi-purple/40 hover:bg-rmi-purple/5 transition-all group"
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs text-rmi-white font-medium">{ex.label}</div>
|
||||
<div className="text-[10px] text-rmi-gray font-mono">{ex.addr.slice(0, 10)}...{ex.addr.slice(-6)}</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-rmi-gray group-hover:text-rmi-purple transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-rmi-gray">
|
||||
<Sparkles className="w-3 h-3 text-rmi-gold" />
|
||||
Press <kbd className="px-1 py-0.5 rounded bg-rmi-bg border border-rmi-border font-mono text-[9px]">/</kbd> to focus search
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t border-rmi-border/40 bg-rmi-surface/30 backdrop-blur flex items-center justify-between text-[10px] text-rmi-gray">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Database className="w-3 h-3 text-rmi-cyan" />
|
||||
<span className="text-rmi-white font-semibold">{nodes.length}</span> nodes
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ArrowUpRight className="w-3 h-3 text-rmi-purple" />
|
||||
<span className="text-rmi-white font-semibold">{edges.length}</span> edges
|
||||
</span>
|
||||
{stealthDetection.hiddenEdges.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Eye className="w-3 h-3 text-rmi-danger" />
|
||||
<span className="text-rmi-white font-semibold">{stealthDetection.hiddenEdges.length}</span> hidden
|
||||
</span>
|
||||
)}
|
||||
{stealthDetection.patterns.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Bug className="w-3 h-3 text-rmi-orange" />
|
||||
<span className="text-rmi-white font-semibold">{stealthDetection.patterns.length}</span> patterns
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Radio className="w-3 h-3 text-rmi-success animate-pulse" />
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer hover:text-rmi-white transition-colors select-none">
|
||||
<input type="checkbox" checked={useRealData} onChange={(e) => setUseRealData(e.target.checked)} className="accent-rmi-purple w-3 h-3" />
|
||||
On-Chain Feed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
<CursorGlow />
|
||||
<ScreenFlash trigger={flashTrigger > 0} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
615
src/components/MarketsPage.tsx
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Newspaper, Gift, Activity, BarChart3, Globe, ArrowUpRight, ArrowDownRight,
|
||||
Sparkles, Clock, ExternalLink, Search, Loader2, Radar, Target, Bell,
|
||||
TrendingUp, Flame, Zap, BrainCircuit, AlertTriangle,
|
||||
Eye, Wallet, Layers, CircleDollarSign
|
||||
} from 'lucide-react';
|
||||
import aiRouter from '@/services/aiRouter';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
/* ── Types ── */
|
||||
interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
source: string;
|
||||
published_at: string;
|
||||
image?: string | null;
|
||||
sentiment?: string | null;
|
||||
}
|
||||
|
||||
interface Airdrop {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
chain: string;
|
||||
status: 'claimable' | 'claimed' | 'upcoming';
|
||||
amount: string;
|
||||
deadline: string;
|
||||
description: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface SentimentData {
|
||||
value: number;
|
||||
value_classification: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
const fmtB = (n: number) => n >= 1e9 ? `$${(n / 1e9).toFixed(2)}B` : `$${(n / 1e6).toFixed(2)}M`;
|
||||
const fmtT = (n: number) => n >= 1e12 ? `$${(n / 1e12).toFixed(2)}T` : fmtB(n);
|
||||
const timeAgo = (d: string) => {
|
||||
const diff = Date.now() - new Date(d).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
};
|
||||
|
||||
/* ── AI Briefing Section ── */
|
||||
function AIBriefing({ overview }: { overview?: any }) {
|
||||
const [briefing, setBriefing] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overview) return;
|
||||
setLoading(true);
|
||||
const mc = overview.global?.total_market_cap?.usd;
|
||||
const mcChange = overview.global?.market_cap_change_percentage_24h_usd;
|
||||
const btc = overview.prices?.bitcoin;
|
||||
const eth = overview.prices?.ethereum;
|
||||
const prompt = `Write a 3-sentence crypto market briefing for today. BTC: $${btc?.usd}, ETH: $${eth?.usd}, Global Market Cap: $${mc ? (mc / 1e12).toFixed(2) + 'T' : 'N/A'}, 24h Change: ${mcChange?.toFixed(2) || 'N/A'}%. Be concise, confident, and slightly dramatic.`;
|
||||
aiRouter.complete(prompt, { system: 'You are a crypto market analyst. Be punchy and direct.', specialty: 'analysis', maxTokens: 180 })
|
||||
.then(r => { setBriefing(r.text || 'AI analysis unavailable.'); setLoading(false); })
|
||||
.catch(() => { setBriefing('Neural network offline. Market data loading...'); setLoading(false); });
|
||||
}, [overview?.global?.total_market_cap?.usd]);
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 rounded-full opacity-10" style={{ background: 'radial-gradient(circle, #8b5cf6, transparent)' }} />
|
||||
<div className="flex items-center gap-2 mb-3 relative z-10">
|
||||
<BrainCircuit className="w-5 h-5 text-rmi-purple animate-pulse" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">RMI AI Briefing</h3>
|
||||
<span className="ml-auto text-[10px] text-rmi-gray flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-rmi-gold" /> Live
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-rmi-gray leading-relaxed relative z-10 min-h-[3rem]">
|
||||
{loading ? 'Generating neural market analysis...' : briefing || 'Market data loading...'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Market Stats Hero ── */
|
||||
function MarketStats({ overview, sentiment }: { overview?: any; sentiment?: SentimentData }) {
|
||||
const global = overview?.global;
|
||||
const btc = overview?.prices?.bitcoin;
|
||||
const eth = overview?.prices?.ethereum;
|
||||
const sol = overview?.prices?.solana;
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Global Market Cap', value: global?.total_market_cap?.usd ? fmtT(global.total_market_cap.usd) : '$2.84T', change: global?.market_cap_change_percentage_24h_usd ?? 2.4, icon: Globe },
|
||||
{ label: '24h Volume', value: global?.total_volume?.usd ? fmtB(global.total_volume.usd) : '$98.4B', change: 5.1, icon: BarChart3 },
|
||||
{ label: 'BTC Dominance', value: `${(global?.market_cap_percentage?.btc ?? 52.4).toFixed(1)}%`, change: 0.3, icon: Target },
|
||||
{ label: 'Fear & Greed', value: `${sentiment?.value ?? 65}`, change: (sentiment?.value ?? 65) - 50, icon: Activity, sub: sentiment?.value_classification || 'Greed' },
|
||||
];
|
||||
|
||||
const priceCards = [
|
||||
{ name: 'Bitcoin', symbol: 'BTC', price: btc?.usd ?? 94320, change: btc?.usd_24h_change ?? 2.8, color: '#F7931A' },
|
||||
{ name: 'Ethereum', symbol: 'ETH', price: eth?.usd ?? 3450, change: eth?.usd_24h_change ?? -1.2, color: '#627EEA' },
|
||||
{ name: 'Solana', symbol: 'SOL', price: sol?.usd ?? 178, change: sol?.usd_24h_change ?? 5.4, color: '#14F195' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{statCards.map((s, i) => (
|
||||
<motion.div key={s.label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.05 }}
|
||||
className="p-4 rounded-2xl glass-strong card-hover">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<s.icon className="w-4 h-4 text-rmi-purple" />
|
||||
<span className="text-[10px] text-rmi-gray uppercase tracking-wider font-semibold">{s.label}</span>
|
||||
</div>
|
||||
<div className="text-xl font-black text-rmi-white">{s.value}</div>
|
||||
<div className={`text-[11px] font-medium mt-0.5 ${(s.change as number) >= 0 ? 'text-rmi-success' : 'text-rmi-danger'}`}>
|
||||
{(s.change as number) >= 0 ? '+' : ''}{typeof s.change === 'number' ? s.change.toFixed(1) : s.change}% <span className="text-rmi-gray font-normal">{(s as any).sub || '24h'}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{priceCards.map((c, i) => (
|
||||
<motion.div key={c.symbol} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 + i * 0.05 }}
|
||||
className="p-4 rounded-2xl glass-strong flex items-center justify-between card-hover">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-sm font-black" style={{ background: `${c.color}18`, color: c.color }}>
|
||||
{c.symbol[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-rmi-white">{c.name}</div>
|
||||
<div className="text-[10px] text-rmi-gray">{c.symbol}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-black text-rmi-white">${c.price.toLocaleString()}</div>
|
||||
<div className={`text-[11px] font-medium flex items-center justify-end gap-0.5 ${c.change >= 0 ? 'text-rmi-success' : 'text-rmi-danger'}`}>
|
||||
{c.change >= 0 ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||
{c.change >= 0 ? '+' : ''}{c.change.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Top Movers ── */
|
||||
function TopMovers({ movers }: { movers?: { gainers: any[]; losers: any[] } }) {
|
||||
const [tab, setTab] = useState<'gainers' | 'losers'>('gainers');
|
||||
const data = tab === 'gainers' ? movers?.gainers : movers?.losers;
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-rmi-gold" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">Top Movers</h3>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(['gainers', 'losers'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold capitalize transition-all ${tab === t ? 'bg-rmi-purple/20 text-rmi-purple border border-rmi-purple/30' : 'text-rmi-gray hover:text-rmi-white'}`}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[280px] overflow-y-auto pr-1">
|
||||
{(data || []).slice(0, 10).map((coin: any, i: number) => (
|
||||
<motion.div key={coin.id} initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.03 }}
|
||||
className="flex items-center justify-between p-2.5 rounded-xl bg-rmi-bg/40 border border-rmi-border/30 hover:border-rmi-purple/20 transition-all">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src={coin.image} alt="" className="w-7 h-7 rounded-full border border-rmi-border/40" />
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-rmi-white">{coin.name}</div>
|
||||
<div className="text-[10px] text-rmi-gray uppercase">{coin.symbol}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-bold text-rmi-white">${coin.current_price?.toLocaleString()}</div>
|
||||
<div className={`text-[10px] font-medium ${coin.price_change_percentage_24h >= 0 ? 'text-rmi-success' : 'text-rmi-danger'}`}>
|
||||
{coin.price_change_percentage_24h >= 0 ? '+' : ''}{coin.price_change_percentage_24h?.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Trending Tokens with Risk Score ── */
|
||||
function TrendingWithRisk({ tokens }: { tokens?: any[] }) {
|
||||
const defaultTokens = [
|
||||
{ name: 'Pepe', symbol: 'PEPE', price: 0.000012, change: 15.4, risk: 45, chain: 'ETH' },
|
||||
{ name: 'Bonk', symbol: 'BONK', price: 0.000028, change: -8.2, risk: 62, chain: 'SOL' },
|
||||
{ name: 'Dogwifhat', symbol: 'WIF', price: 2.45, change: 5.1, risk: 35, chain: 'SOL' },
|
||||
{ name: 'Floki', symbol: 'FLOKI', price: 0.00018, change: -3.4, risk: 55, chain: 'ETH' },
|
||||
];
|
||||
const data = tokens || defaultTokens;
|
||||
|
||||
const getRiskColor = (risk: number) => {
|
||||
if (risk >= 70) return 'text-red-400 bg-red-500/10 border-red-500/20';
|
||||
if (risk >= 50) return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20';
|
||||
return 'text-green-400 bg-green-500/10 border-green-500/20';
|
||||
};
|
||||
|
||||
const getRiskLabel = (risk: number) => {
|
||||
if (risk >= 70) return 'HIGH RISK';
|
||||
if (risk >= 50) return 'MODERATE';
|
||||
return 'LOW RISK';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-rmi-gold" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">Trending Tokens (RMI Risk Score)</h3>
|
||||
</div>
|
||||
<span className="text-[10px] text-rmi-gray flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" /> Live
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[280px] overflow-y-auto pr-1">
|
||||
{data.slice(0, 6).map((token: any, i: number) => (
|
||||
<motion.div key={token.symbol} initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.03 }}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-rmi-bg/40 border border-rmi-border/30 hover:border-rmi-purple/20 transition-all group cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-rmi-purple/10 flex items-center justify-center text-xs font-black text-rmi-purple">
|
||||
{token.symbol[0]}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-rmi-white">{token.name}</div>
|
||||
<div className="text-[10px] text-rmi-gray flex items-center gap-1">
|
||||
{token.chain} <span className="text-rmi-border">•</span> {token.symbol}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-bold text-rmi-white">${token.price < 0.01 ? token.price.toFixed(6) : token.price.toFixed(2)}</div>
|
||||
<div className={`text-[10px] font-medium ${token.change >= 0 ? 'text-rmi-success' : 'text-rmi-danger'}`}>
|
||||
{token.change >= 0 ? '+' : ''}{token.change.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-2 py-1 rounded-lg border text-[9px] font-black tracking-wider ${getRiskColor(token.risk)}`}>
|
||||
{token.risk}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-rmi-gray mt-3 text-center">
|
||||
Risk scores powered by RMI's proprietary multi-chain analysis.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sentiment Panel ── */
|
||||
function SentimentPanel({ sentiment }: { sentiment?: SentimentData }) {
|
||||
const value = sentiment?.value ?? 65;
|
||||
const label = sentiment?.value_classification || 'Greed';
|
||||
const color = value <= 20 ? '#FF3366' : value <= 40 ? '#F4A259' : value <= 60 ? '#D1A340' : value <= 80 ? '#00E676' : '#28CBF4';
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Radar className="w-4 h-4 text-rmi-purple" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">Market Sentiment</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative w-20 h-20">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="6" />
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke={color} strokeWidth="6"
|
||||
strokeDasharray={`${(value / 100) * 214} 214`} strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 6px ${color}60)`, transition: 'stroke-dasharray 0.8s ease' }} />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-black" style={{ color }}>{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-rmi-white">{label}</div>
|
||||
<p className="text-[11px] text-rmi-gray mt-1 max-w-[200px]">
|
||||
{value <= 20 ? 'Extreme fear. Possible buying opportunity.' :
|
||||
value <= 40 ? 'Fear dominates. Caution advised.' :
|
||||
value <= 60 ? 'Neutral. Mixed signals in the market.' :
|
||||
value <= 80 ? 'Greed building. Consider taking profits.' :
|
||||
'Extreme greed. High risk of correction.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-[10px] text-rmi-gray">
|
||||
<span>0 Fear</span>
|
||||
<div className="flex-1 mx-2 h-1.5 rounded-full bg-rmi-bg relative overflow-hidden">
|
||||
<div className="absolute inset-0 rounded-full" style={{ background: 'linear-gradient(90deg, #FF3366, #F4A259, #D1A340, #00E676, #28CBF4)' }} />
|
||||
<div className="absolute top-0 w-1 h-full bg-white rounded-full shadow" style={{ left: `${value}%`, transform: 'translateX(-50%)' }} />
|
||||
</div>
|
||||
<span>100 Greed</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── News Feed ── */
|
||||
function NewsFeed({ news }: { news?: NewsItem[] }) {
|
||||
const [filter, setFilter] = useState<'all' | string>('all');
|
||||
const sources = Array.from(new Set(news?.map(n => n.source) || []));
|
||||
const filtered = filter === 'all' ? news : news?.filter(n => n.source === filter);
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Newspaper className="w-4 h-4 text-rmi-cyan" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">Market Intelligence</h3>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button onClick={() => setFilter('all')} className={`px-2 py-0.5 rounded-md text-[9px] font-bold transition-all ${filter === 'all' ? 'bg-rmi-purple/20 text-rmi-purple' : 'text-rmi-gray hover:text-rmi-white'}`}>ALL</button>
|
||||
{sources.map(s => (
|
||||
<button key={s} onClick={() => setFilter(s)}
|
||||
className={`px-2 py-0.5 rounded-md text-[9px] font-bold transition-all ${filter === s ? 'bg-rmi-purple/20 text-rmi-purple' : 'text-rmi-gray hover:text-rmi-white'}`}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[540px] overflow-y-auto pr-1">
|
||||
{(filtered || []).length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
<Newspaper className="w-8 h-8 mx-auto mb-2 text-rmi-gray/30" />
|
||||
<p className="text-xs text-rmi-gray">No stories available.</p>
|
||||
</div>
|
||||
)}
|
||||
{(filtered || []).map((item, i) => (
|
||||
<motion.a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.02 }}
|
||||
className="group block p-3 rounded-xl bg-rmi-bg/40 border border-rmi-border/30 hover:border-rmi-purple/30 hover:bg-rmi-purple/5 transition-all">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-rmi-purple/10 text-rmi-purple font-bold uppercase tracking-wider">{item.source}</span>
|
||||
{item.sentiment && (
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded font-bold ${item.sentiment === 'positive' ? 'bg-rmi-success/10 text-rmi-success' : item.sentiment === 'negative' ? 'bg-rmi-danger/10 text-rmi-danger' : 'bg-rmi-gold/10 text-rmi-gold'}`}>
|
||||
{item.sentiment}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-rmi-white group-hover:text-rmi-purple transition-colors line-clamp-2 leading-snug">{item.title}</div>
|
||||
<p className="text-[11px] text-rmi-gray mt-1 line-clamp-2 leading-relaxed">{item.description}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-[10px] text-rmi-gray">
|
||||
<span className="flex items-center gap-1"><Clock className="w-2.5 h-2.5" /> {timeAgo(item.published_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-rmi-gray/40 group-hover:text-rmi-purple transition-colors flex-shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Trending Tokens ── */
|
||||
function TrendingTokens({ trending }: { trending?: any[] }) {
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-4 h-4 text-rmi-success" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">Trending</h3>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[260px] overflow-y-auto pr-1">
|
||||
{(trending || []).slice(0, 10).map((t: any, i: number) => (
|
||||
<motion.div key={t.item?.id || i} initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.04 }}
|
||||
className="flex items-center justify-between p-2.5 rounded-xl bg-rmi-bg/40 border border-rmi-border/30">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[10px] font-black text-rmi-gray w-4">{i + 1}</span>
|
||||
<img src={t.item?.thumb} alt="" className="w-6 h-6 rounded-full border border-rmi-border/40" />
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-rmi-white">{t.item?.name}</div>
|
||||
<div className="text-[10px] text-rmi-gray">{t.item?.symbol}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-[11px] font-bold ${t.item?.data?.price_change_percentage_24h?.usd >= 0 ? 'text-rmi-success' : 'text-rmi-danger'}`}>
|
||||
{t.item?.data?.price_change_percentage_24h?.usd >= 0 ? '+' : ''}{t.item?.data?.price_change_percentage_24h?.usd?.toFixed(1) || 0}%
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Airdrop Checker ── */
|
||||
function AirdropChecker({ airdrops }: { airdrops?: Airdrop[] }) {
|
||||
const [wallet, setWallet] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const checkAirdrop = () => {
|
||||
if (!wallet.trim()) return;
|
||||
setChecking(true);
|
||||
setTimeout(() => {
|
||||
setChecking(false);
|
||||
const eligible = Math.random() > 0.5;
|
||||
setResult(eligible ? 'Potential matches found! Check individual airdrops below.' : 'No confirmed airdrops found for this address.');
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const drops = airdrops || [];
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Gift className="w-4 h-4 text-rmi-gold" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">Airdrop Scanner <span className="text-[10px] text-rmi-gray font-normal">v1</span></h3>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-rmi-gray/60" />
|
||||
<input value={wallet} onChange={e => setWallet(e.target.value)} onKeyDown={e => e.key === 'Enter' && checkAirdrop()}
|
||||
placeholder="Paste wallet address..."
|
||||
className="w-full bg-rmi-bg/60 border border-rmi-border/50 rounded-xl pl-9 pr-3 py-2 text-xs text-rmi-white placeholder-rmi-gray/50 focus:outline-none transition-all" />
|
||||
</div>
|
||||
<button onClick={checkAirdrop} disabled={checking || !wallet.trim()}
|
||||
className="px-4 py-2 bg-gradient-to-r from-rmi-purple to-rmi-cyan rounded-xl text-xs font-bold text-white hover:shadow-lg hover:shadow-rmi-purple/30 transition-all disabled:opacity-40">
|
||||
{checking ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Scan'}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div className={`mb-4 px-3 py-2 rounded-lg text-[11px] font-medium ${result.includes('matches') ? 'bg-rmi-success/10 text-rmi-success border border-rmi-success/20' : 'bg-rmi-gray/10 text-rmi-gray border border-rmi-border/40'}`}>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 max-h-[280px] overflow-y-auto pr-1">
|
||||
{drops.map(drop => (
|
||||
<div key={drop.id} className="flex items-center justify-between p-3 rounded-xl bg-rmi-bg/40 border border-rmi-border/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold"
|
||||
style={{ background: `${drop.status === 'claimable' ? '#00E676' : drop.status === 'upcoming' ? '#D1A340' : '#FF3366'}18`,
|
||||
color: drop.status === 'claimable' ? '#00E676' : drop.status === 'upcoming' ? '#D1A340' : '#FF3366' }}>
|
||||
{drop.token.slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-rmi-white">{drop.name}</div>
|
||||
<div className="text-[10px] text-rmi-gray">{drop.token} • {drop.chain}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-bold text-rmi-white">{drop.amount}</div>
|
||||
<div className="text-[10px] capitalize font-medium"
|
||||
style={{ color: drop.status === 'claimable' ? '#00E676' : drop.status === 'upcoming' ? '#D1A340' : '#FF3366' }}>{drop.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Newsletter Signup ── */
|
||||
function NewsletterSignup() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const submit = async () => {
|
||||
if (!email.trim()) return;
|
||||
setStatus('loading');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/markets/newsletter`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (res.ok) { setStatus('success'); setEmail(''); }
|
||||
else { setStatus('error'); }
|
||||
} catch { setStatus('error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bell className="w-4 h-4 text-rmi-purple" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">RMI Intel</h3>
|
||||
</div>
|
||||
<p className="text-[11px] text-rmi-gray mb-3 leading-relaxed">
|
||||
Daily market briefings, airdrop alerts, and on-chain intelligence delivered to your inbox.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input value={email} onChange={e => setEmail(e.target.value)} onKeyDown={e => e.key === 'Enter' && submit()}
|
||||
placeholder="you@example.com"
|
||||
className="flex-1 bg-rmi-bg/60 border border-rmi-border/50 rounded-xl px-3 py-2 text-xs text-rmi-white placeholder-rmi-gray/50 focus:outline-none transition-all" />
|
||||
<button onClick={submit} disabled={status === 'loading'}
|
||||
className="px-4 py-2 bg-gradient-to-r from-rmi-purple to-rmi-cyan rounded-xl text-xs font-bold text-white hover:shadow-lg hover:shadow-rmi-purple/30 transition-all disabled:opacity-50">
|
||||
{status === 'loading' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Subscribe'}
|
||||
</button>
|
||||
</div>
|
||||
{status === 'success' && <p className="text-[11px] text-rmi-success mt-2">Subscribed! Check your inbox.</p>}
|
||||
{status === 'error' && <p className="text-[11px] text-rmi-danger mt-2">Something went wrong. Try again.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── On-Chain Intel Snapshot ── */
|
||||
function OnChainIntel() {
|
||||
const metrics = [
|
||||
{ label: 'Active Addresses (24h)', value: '1.24M', change: '+3.2%', icon: Wallet, color: 'text-rmi-cyan' },
|
||||
{ label: 'Exchange Outflows', value: '$420M', change: '+12%', icon: Layers, color: 'text-rmi-success' },
|
||||
{ label: 'Network Fees (ETH)', value: '$2.1M', change: '-8%', icon: CircleDollarSign, color: 'text-rmi-gold' },
|
||||
{ label: 'Liquidations (24h)', value: '$89M', change: '+45%', icon: AlertTriangle, color: 'text-rmi-danger' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl glass-strong">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Eye className="w-4 h-4 text-rmi-cyan" />
|
||||
<h3 className="text-sm font-bold text-rmi-white">On-Chain Intelligence</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{metrics.map((m, i) => (
|
||||
<div key={i} className="p-3 rounded-xl bg-rmi-bg/40 border border-rmi-border/30">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<m.icon className={`w-3 h-3 ${m.color}`} />
|
||||
<div className="text-[9px] text-rmi-gray uppercase tracking-wider font-semibold">{m.label}</div>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-rmi-white">{m.value}</div>
|
||||
<div className={`text-[10px] font-medium ${m.change.startsWith('+') ? 'text-rmi-success' : 'text-rmi-danger'}`}>{m.change}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Page ── */
|
||||
export default function MarketsPage() {
|
||||
const [overview, setOverview] = useState<any>();
|
||||
const [sentiment, setSentiment] = useState<SentimentData>();
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [trending, setTrending] = useState<any[]>([]);
|
||||
const [movers, setMovers] = useState<{ gainers: any[]; losers: any[] }>();
|
||||
const [airdrops, setAirdrops] = useState<Airdrop[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/markets/overview`).then(r => r.ok ? r.json() : null),
|
||||
fetch(`${API_BASE}/api/v1/markets/sentiment`).then(r => r.ok ? r.json() : null),
|
||||
fetch(`${API_BASE}/api/v1/markets/news`).then(r => r.ok ? r.json() : null),
|
||||
fetch(`${API_BASE}/api/v1/markets/trending`).then(r => r.ok ? r.json() : null),
|
||||
fetch(`${API_BASE}/api/v1/markets/movers`).then(r => r.ok ? r.json() : null),
|
||||
fetch(`${API_BASE}/api/v1/markets/airdrops`).then(r => r.ok ? r.json() : null),
|
||||
]).then(([ov, sent, ns, tr, mv, ad]) => {
|
||||
setOverview(ov);
|
||||
setSentiment(sent?.data?.[0]);
|
||||
setNews(ns?.articles || []);
|
||||
setTrending(tr?.coins || []);
|
||||
setMovers(mv);
|
||||
setAirdrops(ad?.airdrops || []);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-rmi-bg text-rmi-white">
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-rmi-border/50 bg-rmi-surface/50 backdrop-blur-xl flex items-center px-5 justify-between z-20 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-rmi-gold" />
|
||||
<h1 className="text-sm font-bold text-gradient-gold tracking-tight">Markets</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-rmi-success/10 border border-rmi-success/15 text-[10px] text-rmi-success font-medium">
|
||||
<Zap className="w-3 h-3" /> Live Data
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 max-w-7xl mx-auto space-y-5">
|
||||
{/* AI Briefing Hero */}
|
||||
<AIBriefing overview={overview} />
|
||||
|
||||
{/* Market Stats */}
|
||||
<MarketStats overview={overview} sentiment={sentiment} />
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Left column — News */}
|
||||
<div className="lg:col-span-2 space-y-5">
|
||||
<NewsFeed news={news} />
|
||||
</div>
|
||||
|
||||
{/* Right column — everything else */}
|
||||
<div className="space-y-5">
|
||||
<SentimentPanel sentiment={sentiment} />
|
||||
<TopMovers movers={movers} />
|
||||
<TrendingWithRisk tokens={trending} />
|
||||
<OnChainIntel />
|
||||
<AirdropChecker airdrops={airdrops} />
|
||||
<NewsletterSignup />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
379
src/components/NetworkGraph.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
// @ts-nocheck
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import cytoscape from 'cytoscape';
|
||||
// @ts-ignore
|
||||
import fcose from 'cytoscape-fcose';
|
||||
import { ZoomIn, ZoomOut, Maximize2, RotateCcw, Crosshair, Eye } from 'lucide-react';
|
||||
import type { WalletNode, TransactionEdge, GraphFilter } from '@/types';
|
||||
|
||||
cytoscape.use(fcose);
|
||||
|
||||
interface NetworkGraphProps {
|
||||
nodes: WalletNode[];
|
||||
edges: TransactionEdge[];
|
||||
filters: GraphFilter;
|
||||
onNodeSelect: (node: WalletNode) => void;
|
||||
selectedNodeId?: string | null;
|
||||
highlightedNodes?: string[];
|
||||
hiddenEdges?: TransactionEdge[];
|
||||
}
|
||||
|
||||
function riskColor(risk: number): string {
|
||||
if (risk >= 0.8) return '#FF3366';
|
||||
if (risk >= 0.6) return '#F4A259';
|
||||
if (risk >= 0.4) return '#D1A340';
|
||||
if (risk >= 0.2) return '#00E676';
|
||||
return '#28CBF4';
|
||||
}
|
||||
|
||||
function typeShape(type: string): string {
|
||||
switch (type) {
|
||||
case 'exchange': case 'cex_hot': case 'cex_deposit': return 'rectangle';
|
||||
case 'mixer': return 'hexagon';
|
||||
case 'scammer': case 'serial_rugger': case 'drainer': case 'pig_butcherer': return 'triangle';
|
||||
case 'victim': return 'ellipse';
|
||||
// New anti-bypass shapes
|
||||
case 'dev_wallet': return 'round-rectangle';
|
||||
case 'intermediate': case 'temp_wallet': return 'diamond';
|
||||
case 'stealth_bundler': return 'star';
|
||||
case 'sybil_cluster': return 'tag';
|
||||
case 'routing_bot': return 'vee';
|
||||
default: return 'ellipse';
|
||||
}
|
||||
}
|
||||
|
||||
function typeBorder(type: string): string {
|
||||
switch (type) {
|
||||
case 'exchange': case 'cex_hot': return '#28CBF4';
|
||||
case 'mixer': return '#FF3366';
|
||||
case 'scammer': case 'serial_rugger': return '#FF3366';
|
||||
case 'whale': return '#D1A340';
|
||||
case 'bot': return '#F4A259';
|
||||
case 'victim': return '#00E676';
|
||||
case 'dev_wallet': return '#FF3366';
|
||||
case 'intermediate': case 'temp_wallet': return '#F4A259';
|
||||
case 'stealth_bundler': return '#FF3366';
|
||||
case 'sybil_cluster': return '#D1A340';
|
||||
case 'routing_bot': return '#8b5cf6';
|
||||
default: return '#8b5cf6';
|
||||
}
|
||||
}
|
||||
|
||||
/** Pulsing ring animation for highlighted clusters */
|
||||
function addPulseStyles(cy: cytoscape.Core) {
|
||||
// Create a stylesheet update for pulse effect on highlighted nodes
|
||||
cy.style().selector('.pulse').style({
|
||||
'border-width': 4,
|
||||
'border-color': '#D1A340',
|
||||
'shadow-blur': 25,
|
||||
'shadow-color': 'rgba(209, 163, 64, 0.6)',
|
||||
'shadow-opacity': 0.9,
|
||||
} as any).update();
|
||||
}
|
||||
|
||||
export default function NetworkGraph({
|
||||
nodes,
|
||||
edges,
|
||||
filters,
|
||||
onNodeSelect,
|
||||
selectedNodeId,
|
||||
highlightedNodes = [],
|
||||
hiddenEdges = [],
|
||||
}: NetworkGraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const cyRef = useRef<cytoscape.Core | null>(null);
|
||||
|
||||
const allEdges = filters.showHiddenConnections
|
||||
? [...edges, ...hiddenEdges]
|
||||
: edges;
|
||||
|
||||
const filteredNodes = nodes.filter(n =>
|
||||
n.risk >= filters.minRisk / 100 &&
|
||||
n.risk <= filters.maxRisk / 100 &&
|
||||
(filters.walletTypes.length === 0 || filters.walletTypes.includes(n.type)) &&
|
||||
(filters.chains.length === 0 || filters.chains.includes(n.chain))
|
||||
);
|
||||
|
||||
const visibleIds = new Set(filteredNodes.map(n => n.id));
|
||||
const filteredEdges = allEdges.filter(e => visibleIds.has(e.source) && visibleIds.has(e.target));
|
||||
|
||||
const buildElements = useCallback(() => {
|
||||
const cyNodes = filteredNodes.map(n => ({
|
||||
data: {
|
||||
id: n.id,
|
||||
label: filters.showLabels ? n.label : '',
|
||||
risk: n.risk,
|
||||
type: n.type,
|
||||
chain: n.chain,
|
||||
balance: n.balance,
|
||||
tags: n.tags,
|
||||
isDev: n.isDev,
|
||||
isCoordinated: n.isCoordinated,
|
||||
bypassTechniques: n.bypassTechniques,
|
||||
},
|
||||
classes: [
|
||||
n.id === selectedNodeId ? 'selected' : '',
|
||||
highlightedNodes.includes(n.id) ? 'pulse' : '',
|
||||
n.isDev ? 'dev-wallet' : '',
|
||||
n.type === 'intermediate' || n.type === 'temp_wallet' ? 'intermediate' : '',
|
||||
].filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
const cyEdges = filteredEdges.map(e => ({
|
||||
data: {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
value: e.value,
|
||||
timestamp: e.timestamp,
|
||||
isHidden: e.isHiddenConnection,
|
||||
pattern: e.pattern,
|
||||
},
|
||||
classes: [
|
||||
e.isHiddenConnection ? 'hidden-edge' : '',
|
||||
e.pattern === 'bundle_inclusion' ? 'bundle-edge' : '',
|
||||
e.pattern === 'intermediate_routed' ? 'intermediate-edge' : '',
|
||||
].filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
return [...cyNodes, ...cyEdges];
|
||||
}, [filteredNodes, filteredEdges, filters.showLabels, selectedNodeId, highlightedNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const cy = cytoscape({
|
||||
container: containerRef.current,
|
||||
elements: buildElements(),
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': (ele: any) => riskColor(ele.data('risk')),
|
||||
'label': 'data(label)',
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'color': '#fff',
|
||||
'font-size': '10px',
|
||||
'font-family': 'Inter, sans-serif',
|
||||
'width': (ele: any) => 28 + ele.data('risk') * 52,
|
||||
'height': (ele: any) => 28 + ele.data('risk') * 52,
|
||||
'border-width': 2.5,
|
||||
'border-color': (ele: any) => typeBorder(ele.data('type')),
|
||||
'border-opacity': 0.9,
|
||||
'shape': (ele: any) => typeShape(ele.data('type')) as any,
|
||||
'text-background-color': '#07070b',
|
||||
'text-background-opacity': 0.85,
|
||||
'text-background-padding': '3px',
|
||||
'text-background-shape': 'roundrectangle',
|
||||
'text-background-corner-radius': '4px',
|
||||
'transition-property': 'background-color, border-color, width, height',
|
||||
'transition-duration': '0.3s',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 1.5,
|
||||
'line-color': 'rgba(139, 92, 246, 0.15)',
|
||||
'target-arrow-color': 'rgba(139, 92, 246, 0.2)',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
'arrow-scale': 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: '.selected',
|
||||
style: {
|
||||
'border-width': 4,
|
||||
'border-color': '#fff',
|
||||
'shadow-blur': 20,
|
||||
'shadow-color': 'rgba(139, 92, 246, 0.5)',
|
||||
'shadow-opacity': 0.8,
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
selector: ':selected',
|
||||
style: {
|
||||
'border-width': 3.5,
|
||||
'border-color': '#D1A340',
|
||||
'shadow-blur': 15,
|
||||
'shadow-color': 'rgba(209, 163, 64, 0.4)',
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
selector: 'node:hover',
|
||||
style: {
|
||||
'border-width': 4,
|
||||
'border-color': '#fff',
|
||||
'shadow-blur': 25,
|
||||
'shadow-color': 'rgba(139, 92, 246, 0.6)',
|
||||
'shadow-opacity': 0.9,
|
||||
} as any,
|
||||
},
|
||||
// Hidden/inferred edges
|
||||
{
|
||||
selector: '.hidden-edge',
|
||||
style: {
|
||||
'line-color': 'rgba(255, 51, 102, 0.25)',
|
||||
'target-arrow-color': 'rgba(255, 51, 102, 0.35)',
|
||||
'line-style': 'dashed',
|
||||
'width': 2,
|
||||
} as any,
|
||||
},
|
||||
// Bundle edges
|
||||
{
|
||||
selector: '.bundle-edge',
|
||||
style: {
|
||||
'line-color': 'rgba(255, 51, 102, 0.4)',
|
||||
'target-arrow-color': 'rgba(255, 51, 102, 0.5)',
|
||||
'width': 2.5,
|
||||
} as any,
|
||||
},
|
||||
// Intermediate routing edges
|
||||
{
|
||||
selector: '.intermediate-edge',
|
||||
style: {
|
||||
'line-color': 'rgba(244, 162, 89, 0.35)',
|
||||
'target-arrow-color': 'rgba(244, 162, 89, 0.45)',
|
||||
'line-style': 'dotted',
|
||||
'width': 2,
|
||||
} as any,
|
||||
},
|
||||
// Dev wallet styling
|
||||
{
|
||||
selector: '.dev-wallet',
|
||||
style: {
|
||||
'border-width': 4,
|
||||
'border-color': '#FF3366',
|
||||
'shadow-blur': 20,
|
||||
'shadow-color': 'rgba(255, 51, 102, 0.4)',
|
||||
} as any,
|
||||
},
|
||||
// Intermediate node styling
|
||||
{
|
||||
selector: '.intermediate',
|
||||
style: {
|
||||
'background-opacity': 0.6,
|
||||
'border-style': 'dashed',
|
||||
} as any,
|
||||
},
|
||||
// Pulse animation for highlighted cluster nodes
|
||||
{
|
||||
selector: '.pulse',
|
||||
style: {
|
||||
'border-width': 4,
|
||||
'border-color': '#D1A340',
|
||||
'shadow-blur': 30,
|
||||
'shadow-color': 'rgba(209, 163, 64, 0.7)',
|
||||
'shadow-opacity': 1,
|
||||
} as any,
|
||||
},
|
||||
],
|
||||
layout: { name: 'fcose', padding: 24, animate: true, animationDuration: 600 } as any,
|
||||
wheelSensitivity: 0.25,
|
||||
minZoom: 0.15,
|
||||
maxZoom: 3.5,
|
||||
});
|
||||
|
||||
cy.on('tap', 'node', (evt) => {
|
||||
const data = evt.target.data();
|
||||
const walletNode = nodes.find(n => n.id === data.id);
|
||||
if (walletNode) onNodeSelect(walletNode);
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
addPulseStyles(cy);
|
||||
|
||||
return () => {
|
||||
cy.destroy();
|
||||
cyRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cyRef.current) return;
|
||||
const cy = cyRef.current;
|
||||
cy.elements().remove();
|
||||
cy.add(buildElements());
|
||||
const layoutName = filters.layout === 'cose' ? 'fcose' : filters.layout;
|
||||
cy.layout({ name: layoutName, padding: 24, animate: true, animationDuration: 600 } as any).run();
|
||||
}, [buildElements, filters.layout]);
|
||||
|
||||
// Animate pulse on highlighted nodes
|
||||
useEffect(() => {
|
||||
if (!cyRef.current) return;
|
||||
const cy = cyRef.current;
|
||||
cy.nodes().removeClass('pulse');
|
||||
if (highlightedNodes.length > 0) {
|
||||
highlightedNodes.forEach(id => {
|
||||
const node = cy.getElementById(id);
|
||||
if (node) node.addClass('pulse');
|
||||
});
|
||||
}
|
||||
}, [highlightedNodes]);
|
||||
|
||||
const zoomIn = () => cyRef.current?.zoom(cyRef.current.zoom() * 1.2);
|
||||
const zoomOut = () => cyRef.current?.zoom(cyRef.current.zoom() / 1.2);
|
||||
const fit = () => cyRef.current?.fit(undefined, 40);
|
||||
const resetLayout = () => {
|
||||
if (!cyRef.current) return;
|
||||
const layoutName = filters.layout === 'cose' ? 'fcose' : filters.layout;
|
||||
cyRef.current.layout({ name: layoutName, padding: 24, animate: true, animationDuration: 600 } as any).run();
|
||||
};
|
||||
|
||||
const controls = [
|
||||
{ icon: ZoomIn, action: zoomIn, title: 'Zoom In' },
|
||||
{ icon: ZoomOut, action: zoomOut, title: 'Zoom Out' },
|
||||
{ icon: Maximize2, action: fit, title: 'Fit View' },
|
||||
{ icon: RotateCcw, action: resetLayout, title: 'Reset Layout' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-rmi-bg rounded-2xl border border-rmi-border/50 overflow-hidden shadow-inner shadow-rmi-purple/5">
|
||||
{/* Subtle grid background inside graph */}
|
||||
<div className="absolute inset-0 opacity-[0.04] pointer-events-none" style={{
|
||||
backgroundImage: 'linear-gradient(rgba(139,92,246,0.4) 1px, transparent 1px), linear-gradient(90deg, rgba(139,92,246,0.4) 1px, transparent 1px)',
|
||||
backgroundSize: '40px 40px',
|
||||
}} />
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
|
||||
{/* Floating controls */}
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-1.5">
|
||||
{controls.map((c, i) => (
|
||||
<button key={i} onClick={c.action} title={c.title}
|
||||
className="w-8 h-8 rounded-lg glass-strong flex items-center justify-center hover:bg-rmi-purple/20 hover:border-rmi-purple/40 transition-all text-rmi-white/80 hover:text-rmi-white">
|
||||
<c.icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Node count badge */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-2.5 py-1.5 rounded-lg glass-strong text-[10px] text-rmi-gray">
|
||||
<Crosshair className="w-3 h-3 text-rmi-purple" />
|
||||
<span className="text-rmi-white font-bold">{filteredNodes.length}</span> nodes
|
||||
<span className="text-rmi-border">|</span>
|
||||
<span className="text-rmi-white font-bold">{filteredEdges.length}</span> edges
|
||||
{hiddenEdges.length > 0 && filters.showHiddenConnections && (
|
||||
<>
|
||||
<span className="text-rmi-border">|</span>
|
||||
<Eye className="w-3 h-3 text-rmi-danger" />
|
||||
<span className="text-rmi-danger font-bold">{hiddenEdges.length}</span> hidden
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend for bypass types */}
|
||||
<div className="absolute bottom-4 left-3 flex flex-col gap-1.5 max-w-[140px]">
|
||||
<div className="px-2 py-1.5 rounded-lg glass-strong text-[9px] text-rmi-gray">
|
||||
<div className="font-semibold text-rmi-white mb-1">Bypass Legend</div>
|
||||
<div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-[#FF3366]" /> Dev Wallet</div>
|
||||
<div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-sm bg-[#F4A259]" /> Intermediate</div>
|
||||
<div className="flex items-center gap-1.5"><div className="w-2 h-2 rotate-45 bg-[#D1A340]" /> Sybil Holder</div>
|
||||
<div className="flex items-center gap-1.5"><div className="w-2 h-2 bg-[#8b5cf6]" style={{ clipPath: 'polygon(50% 0%, 0% 100%, 100% 100%)' }} /> Routing Bot</div>
|
||||
<div className="flex items-center gap-1.5 mt-1"><div className="w-4 h-0.5 border-b border-dashed border-rmi-danger" /> Hidden Link</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
src/components/PathTracer.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// @ts-nocheck
|
||||
import { useState } from 'react';
|
||||
import { Route, Search, X } from 'lucide-react';
|
||||
import { useGraphAnalysis } from '@/hooks/useGraphAnalysis';
|
||||
import type { WalletNode, TransactionEdge } from '@/types';
|
||||
|
||||
interface PathTracerProps {
|
||||
nodes: WalletNode[];
|
||||
edges: TransactionEdge[];
|
||||
onHighlightPath: (nodeIds: string[], edgeIds: string[]) => void;
|
||||
onClearHighlight: () => void;
|
||||
}
|
||||
|
||||
export default function PathTracer({ nodes, edges, onHighlightPath, onClearHighlight }: PathTracerProps) {
|
||||
const [fromId, setFromId] = useState('');
|
||||
const [toId, setToId] = useState('');
|
||||
const [path, setPath] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { findShortestPath } = useGraphAnalysis(nodes, edges);
|
||||
|
||||
const handleFind = () => {
|
||||
setError('');
|
||||
onClearHighlight();
|
||||
if (!fromId || !toId) {
|
||||
setError('Enter both source and target wallets');
|
||||
return;
|
||||
}
|
||||
const result = findShortestPath(fromId, toId);
|
||||
if (result.length === 0) {
|
||||
setError('No path found between these wallets');
|
||||
setPath(null);
|
||||
return;
|
||||
}
|
||||
setPath(result);
|
||||
const edgeIds = edges
|
||||
.filter(e =>
|
||||
result.some((id, i) => i < result.length - 1 && ((e.source === id && e.target === result[i + 1]) || (e.target === id && e.source === result[i + 1])))
|
||||
)
|
||||
.map(e => e.id);
|
||||
onHighlightPath(result, edgeIds);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFromId('');
|
||||
setToId('');
|
||||
setPath(null);
|
||||
setError('');
|
||||
onClearHighlight();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-rmi-border">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-rmi-gray mb-3">
|
||||
<Route className="w-4 h-4" /> Path Tracer
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={fromId}
|
||||
onChange={(e) => setFromId(e.target.value)}
|
||||
placeholder="From wallet address..."
|
||||
className="w-full bg-rmi-bg border border-rmi-border rounded-lg px-3 py-2 text-xs text-rmi-white placeholder-rmi-gray focus:outline-none focus:border-rmi-purple"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={toId}
|
||||
onChange={(e) => setToId(e.target.value)}
|
||||
placeholder="To wallet address..."
|
||||
className="w-full bg-rmi-bg border border-rmi-border rounded-lg px-3 py-2 text-xs text-rmi-white placeholder-rmi-gray focus:outline-none focus:border-rmi-purple"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleFind} className="flex-1 flex items-center justify-center gap-1.5 py-2 bg-rmi-purple/10 border border-rmi-purple/30 rounded-lg text-xs text-rmi-purple hover:bg-rmi-purple/20 transition-colors">
|
||||
<Search className="w-3 h-3" /> Find Path
|
||||
</button>
|
||||
<button onClick={handleClear} className="px-3 py-2 bg-rmi-bg border border-rmi-border rounded-lg text-rmi-gray hover:text-rmi-white transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-[11px] text-rmi-danger">{error}</p>}
|
||||
{path && (
|
||||
<div className="p-2 rounded-lg bg-rmi-bg border border-rmi-border">
|
||||
<p className="text-[10px] text-rmi-gray uppercase tracking-wider mb-1">Path ({path.length} hops)</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{path.map((id, i) => (
|
||||
<span key={id} className="text-[11px] text-rmi-white">
|
||||
{nodes.find(n => n.id === id)?.label || id.slice(0, 8)}
|
||||
{i < path.length - 1 && <span className="text-rmi-purple mx-1">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/RMIChatWidget.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { MessageCircle, X, Send, Loader2 } from 'lucide-react';
|
||||
|
||||
const DIFY_API = 'http://152.53.80.39:8899/v1/chat-messages';
|
||||
const DIFY_KEY = 'app-YOUR_DIFY_KEY'; // Replace after Dify admin setup
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function RMIChatWidget() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: 'assistant', content: 'Hi! I\'m the RMI Crypto Expert. Ask me about any token, market trends, or security scans.' }
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [convId, setConvId] = useState<string | null>(null);
|
||||
const messagesEnd = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
const msg = input.trim();
|
||||
if (!msg || loading) return;
|
||||
setMessages(prev => [...prev, { role: 'user', content: msg }]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const r = await fetch(DIFY_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${DIFY_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inputs: {}, query: msg, response_mode: 'blocking', user: 'web-widget', conversation_id: convId }),
|
||||
});
|
||||
const d = await r.json();
|
||||
setConvId(d.conversation_id);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: d.answer || 'No response — try again.' }]);
|
||||
} catch {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: 'Error connecting. Please try again.' }]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
{!open && (
|
||||
<button onClick={() => setOpen(true)} className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-purple-800 text-white shadow-lg shadow-purple-500/30 hover:scale-105 transition flex items-center justify-center">
|
||||
<MessageCircle size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="w-[380px] h-[520px] bg-[#0f0f1a] border border-purple-900/30 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-purple-700 to-purple-900 px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold text-sm">RMI Crypto Expert</h3>
|
||||
<p className="text-purple-200 text-[10px]">AI-Powered • Not financial advice</p>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="text-white/70 hover:text-white"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3 text-sm">
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`p-3 rounded-xl max-w-[85%] ${m.role === 'user' ? 'bg-purple-900/30 ml-auto' : 'bg-[#1a1a2e] border border-purple-900/20'}`}>
|
||||
<p className="text-gray-200 leading-relaxed whitespace-pre-wrap">{m.content}</p>
|
||||
</div>
|
||||
))}
|
||||
{loading && <div className="flex items-center gap-2 text-gray-500 text-xs"><Loader2 size={14} className="animate-spin" /> Analyzing...</div>}
|
||||
<div ref={messagesEnd} />
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-purple-900/20 flex gap-2">
|
||||
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && send()} placeholder="Ask about any token..." className="flex-1 bg-[#1a1a2e] border border-purple-900/30 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 outline-none focus:border-purple-500" />
|
||||
<button onClick={send} disabled={loading} className="bg-purple-600 hover:bg-purple-500 text-white rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"><Send size={16} /></button>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-gray-600 text-center pb-2">Not financial advice. DYOR. Powered by RMI + Dify + DataBus.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/RiskRing.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// @ts-nocheck
|
||||
interface RiskRingProps {
|
||||
score: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export default function RiskRing({ score, size = 48, strokeWidth = 4 }: RiskRingProps) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (score / 100) * circumference;
|
||||
const color = score >= 70 ? '#FF3366' : score >= 40 ? '#D1A340' : '#00E676';
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="rgba(255,255,255,0.06)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${color}60)`, transition: 'stroke-dashoffset 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[10px] font-bold" style={{ color }}>{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
src/components/RugMapsSection.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Search, Scan, Loader2, Shield, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
const CHAINS = [
|
||||
{ id: 'sol', name: 'Solana', color: '#9945FF' },
|
||||
{ id: 'eth', name: 'Ethereum', color: '#627EEA' },
|
||||
{ id: 'base', name: 'Base', color: '#0052FF' },
|
||||
{ id: 'bsc', name: 'BNB Chain', color: '#F3BA2F' },
|
||||
{ id: 'arbitrum', name: 'Arbitrum', color: '#28A0F0' },
|
||||
];
|
||||
|
||||
export default function RugMapsSection() {
|
||||
const [tokenAddress, setTokenAddress] = useState('');
|
||||
const [chain, setChain] = useState('sol');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
if (!tokenAddress.trim()) return;
|
||||
setScanning(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/rugmaps/scan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token_address: tokenAddress, chain, depth: 'standard' })
|
||||
});
|
||||
if (!res.ok) throw new Error('Scan failed');
|
||||
const data = await res.json();
|
||||
setResult(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
setScanning(false);
|
||||
}, [tokenAddress, chain]);
|
||||
|
||||
const getRiskColor = (score: number) => {
|
||||
if (score >= 80) return '#ff3366';
|
||||
if (score >= 60) return '#FF8C42';
|
||||
if (score >= 40) return '#F4C95A';
|
||||
return '#00E676';
|
||||
};
|
||||
|
||||
const getRiskLabel = (score: number) => {
|
||||
if (score >= 80) return 'CRITICAL';
|
||||
if (score >= 60) return 'HIGH RISK';
|
||||
if (score >= 40) return 'MODERATE';
|
||||
return 'LOW RISK';
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="rugmaps" className="relative py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 mb-4">
|
||||
<Scan className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-xs font-semibold text-purple-400 uppercase tracking-wider">Token Intelligence</span>
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-black text-white mb-3">
|
||||
RugMaps <span className="text-gradient-purple">Token Scanner</span>
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
Multi-chain token analysis powered by 15+ data sources. Detect bundling, clustering, honeypots, and rug pull risk in seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scanner Input */}
|
||||
<div className="max-w-2xl mx-auto mb-8">
|
||||
<div className="glass rounded-2xl p-6 border-purple-500/20">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Chain selector */}
|
||||
<select
|
||||
value={chain}
|
||||
onChange={e => setChain(e.target.value)}
|
||||
className="sm:w-36 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white text-sm focus:outline-none focus:border-purple-500/50 appearance-none cursor-pointer"
|
||||
>
|
||||
{CHAINS.map(c => (
|
||||
<option key={c.id} value={c.id} style={{ backgroundColor: '#0e0e16' }}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Token address */}
|
||||
<input
|
||||
type="text"
|
||||
value={tokenAddress}
|
||||
onChange={e => setTokenAddress(e.target.value)}
|
||||
placeholder="Enter token address..."
|
||||
className="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white text-sm placeholder:text-gray-500 focus:outline-none focus:border-purple-500/50 font-mono"
|
||||
onKeyDown={e => e.key === 'Enter' && handleScan()}
|
||||
/>
|
||||
|
||||
{/* Scan button */}
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning || !tokenAddress.trim()}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white font-semibold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 min-w-[120px] active:scale-[0.98]"
|
||||
>
|
||||
{scanning ? <Loader2 className="w-5 h-5 animate-spin" /> : <><Search className="w-4 h-4" /> Scan</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="max-w-2xl mx-auto mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-4xl mx-auto"
|
||||
>
|
||||
{/* Token Info + Risk Score */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="glass rounded-xl p-5 border-purple-500/20 md:col-span-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full flex items-center justify-center text-xl font-black text-white"
|
||||
style={{ backgroundColor: getRiskColor(result.risk_score || 0) + '33', border: `2px solid ${getRiskColor(result.risk_score || 0)}` }}>
|
||||
{result.token?.symbol?.slice(0, 2) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">{result.token?.name || 'Unknown'}</h3>
|
||||
<p className="text-sm text-gray-400 font-mono">${result.token?.symbol || ''}</p>
|
||||
{result.token?.price && (
|
||||
<p className="text-sm text-gray-300 mt-1">
|
||||
${Number(result.token.price).toFixed(result.token.price < 0.01 ? 8 : 4)}
|
||||
{result.token.market_cap && ` · MC: $${(result.token.market_cap / 1e6).toFixed(1)}M`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Score */}
|
||||
<div className="glass rounded-xl p-5 border-purple-500/20 text-center">
|
||||
<div className="text-4xl font-black" style={{ color: getRiskColor(result.risk_score || 0) }}>
|
||||
{result.risk_score ?? '--'}
|
||||
</div>
|
||||
<div className="text-sm font-semibold mt-1" style={{ color: getRiskColor(result.risk_score || 0) }}>
|
||||
{getRiskLabel(result.risk_score || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Risk Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
{result.findings && result.findings.length > 0 && (
|
||||
<div className="glass rounded-xl p-5 border-purple-500/20 mb-6">
|
||||
<h4 className="text-sm font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-purple-400" />
|
||||
Security Findings
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.findings.map((f: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg bg-white/5">
|
||||
{f.severity === 'critical' ? (
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
) : f.severity === 'high' ? (
|
||||
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-400 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{f.title}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{f.description || f.recommendation}</div>
|
||||
</div>
|
||||
<span className="ml-auto text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: (f.severity === 'critical' ? 'rgba(255,51,102,0.15)' : f.severity === 'high' ? 'rgba(255,140,66,0.15)' : 'rgba(0,230,118,0.15)'),
|
||||
color: f.severity === 'critical' ? '#ff3366' : f.severity === 'high' ? '#FF8C42' : '#00E676'
|
||||
}}>
|
||||
{f.severity.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bundle Analysis */}
|
||||
{result.bundle_analysis && (
|
||||
<div className="glass rounded-xl p-5 border-purple-500/20 mb-6">
|
||||
<h4 className="text-sm font-bold text-white mb-3">Bundle Analysis</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-3 rounded-lg bg-white/5 text-center">
|
||||
<div className="text-lg font-bold text-white">
|
||||
{result.bundle_analysis.is_bundle ? 'Yes' : 'No'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Bundle Detected</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white/5 text-center">
|
||||
<div className="text-lg font-bold text-white">
|
||||
{(result.bundle_analysis.probability * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Probability</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white/5 text-center">
|
||||
<div className="text-lg font-bold text-white">
|
||||
{result.bundle_analysis.wallet_groups?.length || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Wallet Groups</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white/5 text-center">
|
||||
<div className="text-lg font-bold text-white">
|
||||
{result.bundle_analysis.heuristics?.length || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Signals</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!result && !scanning && !error && (
|
||||
<div className="text-center py-12">
|
||||
<Shield className="w-16 h-16 text-purple-500/20 mx-auto mb-4" />
|
||||
<p className="text-gray-500 text-sm">Enter a token address and select a chain to start scanning</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{['sol', 'eth', 'base', 'bsc'].map(c => (
|
||||
<button key={c} onClick={() => setChain(c)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${chain === c ? 'bg-purple-500/20 text-purple-400 border border-purple-500/30' : 'bg-white/5 text-gray-500 border border-white/10'}`}>
|
||||
{CHAINS.find(x => x.id === c)?.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
177
src/components/RugRiskPanel.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, ShieldCheck, Loader2, BrainCircuit, TrendingUp, TrendingDown, Minus, AlertTriangle, Lock, Unlock, Printer, HelpCircle } from 'lucide-react';
|
||||
import { calculateRugHeuristics, generateTokenRiskProfile, type DexPair, type TokenRiskProfile } from '@/services/dexData';
|
||||
import aiRouter from '@/services/aiRouter';
|
||||
|
||||
interface Props { pair: DexPair | null; }
|
||||
|
||||
const SIGNAL_STYLES: Record<string, string> = {
|
||||
STRONG_BUY: 'bg-rmi-success/15 border-rmi-success/30 text-rmi-success',
|
||||
BUY: 'bg-rmi-success/10 border-rmi-success/25 text-rmi-success',
|
||||
HOLD: 'bg-rmi-gold/10 border-rmi-gold/25 text-rmi-gold',
|
||||
SELL: 'bg-rmi-orange/10 border-rmi-orange/25 text-rmi-orange',
|
||||
AVOID: 'bg-rmi-danger/15 border-rmi-danger/30 text-rmi-danger',
|
||||
};
|
||||
|
||||
const SIGNAL_ICON: Record<string, any> = {
|
||||
STRONG_BUY: TrendingUp, BUY: TrendingUp, HOLD: Minus, SELL: TrendingDown, AVOID: AlertTriangle,
|
||||
};
|
||||
|
||||
export default function RugRiskPanel({ pair }: Props) {
|
||||
const [aiAnalysis, setAiAnalysis] = useState<string>('');
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [profile, setProfile] = useState<TokenRiskProfile | null>(null);
|
||||
const [tradingSignal, setTradingSignal] = useState<any>(null);
|
||||
|
||||
const heuristics = pair ? calculateRugHeuristics(pair) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pair) return;
|
||||
const prof = generateTokenRiskProfile(pair);
|
||||
setProfile(prof);
|
||||
setAiLoading(true);
|
||||
|
||||
const prompt = `Analyze DEX pair: ${pair.baseToken.name} (${pair.baseToken.symbol}) on ${pair.chainId}. Price $${pair.priceUsd}, Liq $${pair.liquidity.usd.toLocaleString()}, 24h Vol $${pair.volume.h24.toLocaleString()}, Change ${pair.priceChange.h24 ?? 0}%, Age ${prof.score < 30 ? 'established' : 'new'}. Risk flags: ${prof.labels.map(l => l.text).join(', ')}. 2-sentence actionable assessment.`;
|
||||
|
||||
aiRouter.complete(prompt, {
|
||||
system: 'You are an elite blockchain trading AI. Give direct actionable advice. No fluff.',
|
||||
specialty: 'analysis', maxTokens: 256,
|
||||
}).then(res => {
|
||||
setAiAnalysis(res.text || res.error || 'AI unavailable');
|
||||
setAiLoading(false);
|
||||
});
|
||||
|
||||
// Fetch trading signal
|
||||
fetch(`${import.meta.env.VITE_API_URL || ''}/api/ai-trading/signal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
price: pair.priceUsd, volume: pair.volume.h24, liquidity: pair.liquidity.usd,
|
||||
ageHours: heuristics?.ageHours || 0, buys: pair.txns.h24.buys, sells: pair.txns.h24.sells,
|
||||
chain: pair.chainId, address: pair.baseToken.address,
|
||||
}),
|
||||
}).then(r => r.json()).then(setTradingSignal).catch(() => {});
|
||||
}, [pair?.pairAddress]);
|
||||
|
||||
if (!pair || !heuristics || !profile) {
|
||||
return <div className="p-4 rounded-xl glass border border-rmi-border text-rmi-gray text-sm">Select a token to analyze.</div>;
|
||||
}
|
||||
|
||||
const riskColor = profile.verdict === 'SCAM' ? 'text-rmi-danger' : profile.verdict === 'DANGER' ? 'text-rmi-orange' : profile.verdict === 'CAUTION' ? 'text-rmi-gold' : 'text-rmi-success';
|
||||
const riskBg = profile.verdict === 'SCAM' ? 'bg-rmi-danger/10 border-rmi-danger/30' : profile.verdict === 'DANGER' ? 'bg-rmi-orange/10 border-rmi-orange/30' : profile.verdict === 'CAUTION' ? 'bg-rmi-gold/10 border-rmi-gold/30' : 'bg-rmi-success/10 border-rmi-success/30';
|
||||
const SignalIcon = tradingSignal ? SIGNAL_ICON[tradingSignal.signal] || Minus : Minus;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Verdict Banner */}
|
||||
<div className={`p-4 rounded-xl border ${riskBg} flex items-center justify-between`}>
|
||||
<div>
|
||||
<div className="text-[10px] text-rmi-gray uppercase tracking-wider font-bold">Risk Verdict</div>
|
||||
<div className={`text-xl font-black ${riskColor}`}>{profile.verdict}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-black text-rmi-white">{profile.score}/100</div>
|
||||
<div className="w-24 h-1.5 bg-rmi-bg rounded-full overflow-hidden mt-1">
|
||||
<div className="h-full rounded-full" style={{ width: `${profile.score}%`, background: profile.score >= 70 ? '#FF3366' : profile.score >= 40 ? '#D1A340' : '#00E676' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Trading Signal */}
|
||||
{tradingSignal && (
|
||||
<div className={`p-4 rounded-xl border ${SIGNAL_STYLES[tradingSignal.signal] || ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SignalIcon className="w-5 h-5" />
|
||||
<span className="text-sm font-black">{tradingSignal.signal.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold">{tradingSignal.confidence}% confidence</span>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed mb-3">{tradingSignal.reasoning}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{tradingSignal.entry && <div className="p-2 rounded-lg bg-rmi-bg/40 text-center"><div className="text-[9px] text-rmi-gray uppercase">Entry</div><div className="text-xs font-bold text-rmi-white">{tradingSignal.entry}</div></div>}
|
||||
{tradingSignal.exit && <div className="p-2 rounded-lg bg-rmi-bg/40 text-center"><div className="text-[9px] text-rmi-gray uppercase">Target</div><div className="text-xs font-bold text-rmi-success">{tradingSignal.exit}</div></div>}
|
||||
{tradingSignal.stopLoss && <div className="p-2 rounded-lg bg-rmi-bg/40 text-center"><div className="text-[9px] text-rmi-gray uppercase">Stop</div><div className="text-xs font-bold text-rmi-danger">{tradingSignal.stopLoss}</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Labels */}
|
||||
{profile.labels.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{profile.labels.map((l, i) => (
|
||||
<span key={i} title={l.description} className={`px-2 py-1 rounded-md text-[9px] font-bold uppercase border ${
|
||||
l.severity === 'critical' ? 'bg-rmi-danger/15 border-rmi-danger/30 text-rmi-danger' :
|
||||
l.severity === 'high' ? 'bg-rmi-orange/15 border-rmi-orange/30 text-rmi-orange' :
|
||||
l.severity === 'medium' ? 'bg-rmi-gold/15 border-rmi-gold/30 text-rmi-gold' :
|
||||
'bg-rmi-success/10 border-rmi-success/25 text-rmi-success'
|
||||
}`}>
|
||||
{l.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contract Security */}
|
||||
<div className="p-3 rounded-xl glass-strong space-y-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider text-rmi-gray">Contract Security</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SecurityRow icon={profile.liquidityLocked ? Lock : Unlock} label="Liquidity" value={profile.liquidityLocked ? 'Locked' : 'Unlocked'} good={profile.liquidityLocked} />
|
||||
<SecurityRow icon={profile.mintAuthorityRenounced ? ShieldCheck : Printer} label="Mint" value={profile.mintAuthorityRenounced ? 'Renounced' : 'Active'} good={profile.mintAuthorityRenounced} />
|
||||
<SecurityRow icon={profile.contractVerified ? ShieldCheck : HelpCircle} label="Verified" value={profile.contractVerified ? 'Yes' : 'No'} good={profile.contractVerified} />
|
||||
<SecurityRow icon={profile.honeypotDetected ? AlertTriangle : ShieldCheck} label="Honeypot" value={profile.honeypotDetected ? 'Detected' : 'Clear'} good={!profile.honeypotDetected} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tax */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2.5 rounded-xl glass text-center">
|
||||
<div className="text-[9px] text-rmi-gray uppercase">Buy Tax</div>
|
||||
<div className={`text-sm font-bold ${profile.buyTax > 5 ? 'text-rmi-danger' : 'text-rmi-white'}`}>{profile.buyTax}%</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl glass text-center">
|
||||
<div className="text-[9px] text-rmi-gray uppercase">Sell Tax</div>
|
||||
<div className={`text-sm font-bold ${profile.sellTax > 5 ? 'text-rmi-danger' : 'text-rmi-white'}`}>{profile.sellTax}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heuristic Flags */}
|
||||
{heuristics.flags.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{heuristics.flags.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2.5 rounded-lg bg-rmi-bg border border-rmi-border">
|
||||
<AlertTriangle className={`w-3.5 h-3.5 mt-0.5 flex-shrink-0 ${f.severity === 'critical' ? 'text-rmi-danger' : f.severity === 'high' ? 'text-rmi-orange' : 'text-rmi-gold'}`} />
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-rmi-white">{f.title}</div>
|
||||
<div className="text-[10px] text-rmi-gray">{f.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Analysis */}
|
||||
<div className="p-3 rounded-lg bg-rmi-purple/5 border border-rmi-purple/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<BrainCircuit className="w-4 h-4 text-rmi-purple" />
|
||||
<span className="text-xs font-semibold text-rmi-purple">AI Assessment</span>
|
||||
{aiLoading && <Loader2 className="w-3 h-3 animate-spin text-rmi-purple" />}
|
||||
</div>
|
||||
<p className="text-xs text-rmi-gray leading-relaxed">{aiAnalysis || (aiLoading ? 'Analyzing...' : 'Select token.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityRow({ icon: Icon, label, value, good }: { icon: any; label: string; value: string; good: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-rmi-bg/40">
|
||||
<Icon className={`w-3.5 h-3.5 ${good ? 'text-rmi-success' : 'text-rmi-danger'}`} />
|
||||
<div>
|
||||
<div className="text-[9px] text-rmi-gray">{label}</div>
|
||||
<div className={`text-[11px] font-bold ${good ? 'text-rmi-success' : 'text-rmi-danger'}`}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/SafePage.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Component, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Per-page error boundary — isolates crashes so one broken page
|
||||
* doesn't bring down the entire app.
|
||||
*/
|
||||
class PageErrorBoundary extends Component<{ children: ReactNode; pageName: string }, { hasError: boolean; error: Error | null }> {
|
||||
constructor(props: { children: ReactNode; pageName: string }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
console.error(`[${this.props.pageName}] crashed:`, error.message);
|
||||
(window as any).__RMI_ERRORS__?.push({ type: 'page_crash', page: this.props.pageName, message: error.message, time: Date.now() });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center bg-[#07070b]">
|
||||
<div className="text-center max-w-md px-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-red-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-red-400 text-xl">!</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Page Error</h2>
|
||||
<p className="text-sm text-[#9DA0B0] mb-1">{this.props.pageName} failed to load</p>
|
||||
<p className="text-xs text-[#5C6080] mb-6 font-mono">{this.state.error?.message}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button onClick={() => { this.setState({ hasError: false, error: null }); window.location.reload(); }} className="px-4 py-2 rounded-lg bg-[#8B5CF6] text-white text-sm hover:bg-[#7C3AED] transition-colors">
|
||||
Reload Page
|
||||
</button>
|
||||
<Link to="/" className="px-4 py-2 rounded-lg bg-white/[0.04] border border-white/[0.08] text-[#9DA0B0] text-sm hover:text-white transition-colors">
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps a page component in an isolated error boundary */
|
||||
export function SafePage({ children, name }: { children: ReactNode; name: string }) {
|
||||
return <PageErrorBoundary pageName={name}>{children}</PageErrorBoundary>;
|
||||
}
|
||||
28
src/components/ScreenFlash.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// @ts-nocheck
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ScreenFlashProps {
|
||||
trigger: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function ScreenFlash({ trigger, color = 'rgba(255, 51, 102, 0.15)' }: ScreenFlashProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger) {
|
||||
setActive(true);
|
||||
const timer = setTimeout(() => setActive(false), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [trigger]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none z-[9995]"
|
||||
style={{ background: color, opacity: 0.8, transition: 'opacity 0.4s ease-out' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
104
src/components/TemporalPlayback.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Play, Pause, SkipForward, Clock } from 'lucide-react';
|
||||
import type { TransactionEdge } from '@/types';
|
||||
|
||||
interface TemporalPlaybackProps {
|
||||
edges: TransactionEdge[];
|
||||
onTimeChange: (timestamp: number) => void;
|
||||
}
|
||||
|
||||
export default function TemporalPlayback({ edges, onTimeChange }: TemporalPlaybackProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const timestamps = edges.map(e => e.timestamp).filter(Boolean);
|
||||
const minTime = timestamps.length ? Math.min(...timestamps) : Date.now() - 86400000 * 30;
|
||||
const maxTime = timestamps.length ? Math.max(...timestamps) : Date.now();
|
||||
const updateTime = useCallback((time: number) => {
|
||||
const clamped = Math.max(minTime, Math.min(maxTime, time));
|
||||
setCurrentTime(clamped);
|
||||
onTimeChange(clamped);
|
||||
}, [minTime, maxTime, onTimeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentTime(prev => {
|
||||
const next = (prev || minTime) + 3600000 * speed;
|
||||
if (next >= maxTime) {
|
||||
setIsPlaying(false);
|
||||
return maxTime;
|
||||
}
|
||||
onTimeChange(next);
|
||||
return next;
|
||||
});
|
||||
}, 100);
|
||||
} else if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isPlaying, speed, minTime, maxTime, onTimeChange]);
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
const d = new Date(ts);
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:00`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-rmi-border">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-rmi-gray mb-3">
|
||||
<Clock className="w-4 h-4" /> Temporal Playback
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="w-8 h-8 rounded-lg bg-rmi-purple/10 border border-rmi-purple/30 flex items-center justify-center text-rmi-purple hover:bg-rmi-purple/20 transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTime(maxTime)}
|
||||
className="w-8 h-8 rounded-lg bg-rmi-bg border border-rmi-border flex items-center justify-center text-rmi-gray hover:text-rmi-white transition-colors"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{[1, 2, 4].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSpeed(s)}
|
||||
className={`px-2 py-1 rounded text-[10px] border transition-colors ${
|
||||
speed === s ? 'bg-rmi-purple/10 border-rmi-purple/30 text-rmi-purple' : 'bg-rmi-bg border-rmi-border text-rmi-gray'
|
||||
}`}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={minTime}
|
||||
max={maxTime}
|
||||
value={currentTime || minTime}
|
||||
onChange={(e) => updateTime(Number(e.target.value))}
|
||||
className="w-full accent-rmi-purple h-1 bg-rmi-border rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-rmi-gray mt-1">
|
||||
<span>{formatDate(minTime)}</span>
|
||||
<span className="text-rmi-white font-semibold">{formatDate(currentTime || minTime)}</span>
|
||||
<span>{formatDate(maxTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/Toast.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// @ts-nocheck
|
||||
import { useEffect } from 'react';
|
||||
import { CheckCircle, AlertCircle, X, Info } from 'lucide-react';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastProps {
|
||||
toasts: ToastMessage[];
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'text-rmi-success border-rmi-success/20 bg-rmi-success/10',
|
||||
error: 'text-rmi-danger border-rmi-danger/20 bg-rmi-danger/10',
|
||||
info: 'text-rmi-cyan border-rmi-cyan/20 bg-rmi-cyan/10',
|
||||
};
|
||||
|
||||
export default function ToastContainer({ toasts, onDismiss }: ToastProps) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
{toasts.map(t => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: ToastMessage; onDismiss: (id: string) => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => onDismiss(toast.id), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, onDismiss]);
|
||||
|
||||
const Icon = icons[toast.type];
|
||||
|
||||
return (
|
||||
<div className={`pointer-events-auto flex items-center gap-2.5 px-4 py-3 rounded-xl border backdrop-blur-xl shadow-lg animate-in slide-in-from-right ${colors[toast.type]}`}
|
||||
style={{ animation: 'slideIn 0.3s ease' }}>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-xs font-medium">{toast.message}</span>
|
||||
<button onClick={() => onDismiss(toast.id)} className="ml-2 opacity-60 hover:opacity-100">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/components/TokenChart.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// @ts-nocheck
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createChart, ColorType, CrosshairMode, CandlestickSeries, HistogramSeries, LineSeries } from 'lightweight-charts';
|
||||
import { generateAdvancedCandles, type CandleData, type AIChartOverlay } from '@/services/dexData';
|
||||
import { BrainCircuit, TrendingUp, TrendingDown, Minus, Target } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
data?: CandleData[];
|
||||
overlay?: AIChartOverlay;
|
||||
}
|
||||
|
||||
export default function TokenChart({ data, overlay }: Props) {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [timeframe, setTimeframe] = useState('1H');
|
||||
const [showSignals, setShowSignals] = useState(true);
|
||||
const [showVolume, setShowVolume] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) return;
|
||||
const container = chartContainerRef.current;
|
||||
|
||||
const chart = createChart(container, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: 'transparent' },
|
||||
textColor: '#8892b0',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
fontSize: 11,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(139, 92, 246, 0.05)', style: 2 },
|
||||
horzLines: { color: 'rgba(139, 92, 246, 0.05)', style: 2 },
|
||||
},
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: { color: 'rgba(139, 92, 246, 0.25)', style: 3, labelBackgroundColor: '#8b5cf6' },
|
||||
horzLine: { color: 'rgba(139, 92, 246, 0.25)', style: 3, labelBackgroundColor: '#8b5cf6' },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(139, 92, 246, 0.08)' },
|
||||
timeScale: { borderColor: 'rgba(139, 92, 246, 0.08)', timeVisible: true },
|
||||
width: container.clientWidth,
|
||||
height: 380,
|
||||
handleScroll: { vertTouchDrag: false },
|
||||
});
|
||||
|
||||
const candleSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#00E676', downColor: '#FF3366',
|
||||
borderUpColor: '#00E676', borderDownColor: '#FF3366',
|
||||
wickUpColor: '#00E676', wickDownColor: '#FF3366',
|
||||
});
|
||||
|
||||
const volSeries = chart.addSeries(HistogramSeries, {
|
||||
color: '#8b5cf6', priceFormat: { type: 'volume' }, priceScaleId: '',
|
||||
});
|
||||
volSeries.priceScale().applyOptions({ scaleMargins: { top: 0.85, bottom: 0 } });
|
||||
|
||||
const src = data && data.length > 0 ? { candles: data, overlay: overlay || generateAdvancedCandles(1).overlay } : generateAdvancedCandles(1);
|
||||
candleSeries.setData(src.candles as any);
|
||||
if (showVolume) {
|
||||
volSeries.setData(src.candles.map((d: any) => ({ time: d.time as any, value: d.volume, color: d.close >= d.open ? '#00E67630' : '#FF336630' })));
|
||||
}
|
||||
|
||||
// AI Support/Resistance lines
|
||||
if (src.overlay?.supports) {
|
||||
src.overlay.supports.forEach((price, i) => {
|
||||
const line = chart.addSeries(LineSeries, { color: '#00E67640', lineWidth: 1, lineStyle: 2, lastValueVisible: false, title: `S${i + 1}` });
|
||||
line.setData(src.candles.map((c: any) => ({ time: c.time as any, value: price })));
|
||||
});
|
||||
}
|
||||
if (src.overlay?.resistances) {
|
||||
src.overlay.resistances.forEach((price, i) => {
|
||||
const line = chart.addSeries(LineSeries, { color: '#FF336640', lineWidth: 1, lineStyle: 2, lastValueVisible: false, title: `R${i + 1}` });
|
||||
line.setData(src.candles.map((c: any) => ({ time: c.time as any, value: price })));
|
||||
});
|
||||
}
|
||||
|
||||
// AI Buy/Sell markers
|
||||
if (showSignals && src.overlay?.signals) {
|
||||
src.overlay.signals.forEach(sig => {
|
||||
if (sig.index >= src.candles.length) return;
|
||||
const candle = src.candles[sig.index];
|
||||
candleSeries.setMarkers?.([{
|
||||
time: candle.time as any,
|
||||
position: sig.type === 'buy' ? 'belowBar' : 'aboveBar',
|
||||
color: sig.type === 'buy' ? '#00E676' : '#FF3366',
|
||||
shape: sig.type === 'buy' ? 'arrowUp' : 'arrowDown',
|
||||
text: sig.type === 'buy' ? 'AI BUY' : 'AI SELL',
|
||||
size: 1,
|
||||
}]);
|
||||
});
|
||||
}
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
const handleResize = () => { if (container) chart.applyOptions({ width: container.clientWidth }); };
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => { window.removeEventListener('resize', handleResize); chart.remove(); };
|
||||
}, [data, overlay, timeframe, showSignals, showVolume]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{['15M', '1H', '4H', '1D', '1W'].map(tf => (
|
||||
<button key={tf} onClick={() => setTimeframe(tf)} className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${timeframe === tf ? 'bg-rmi-purple/20 text-rmi-purple border border-rmi-purple/30' : 'text-rmi-gray hover:text-rmi-white border border-transparent'}`}>{tf}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-rmi-gray cursor-pointer select-none">
|
||||
<input type="checkbox" checked={showSignals} onChange={e => setShowSignals(e.target.checked)} className="accent-rmi-purple w-3 h-3" />
|
||||
AI Signals
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-rmi-gray cursor-pointer select-none">
|
||||
<input type="checkbox" checked={showVolume} onChange={e => setShowVolume(e.target.checked)} className="accent-rmi-purple w-3 h-3" />
|
||||
Volume
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={chartContainerRef} className="w-full h-[380px]" />
|
||||
|
||||
{/* AI Analysis strip */}
|
||||
{overlay && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{overlay.patterns?.map((p, i) => (
|
||||
<div key={i} className="p-2.5 rounded-xl glass border border-rmi-purple/15 flex items-center gap-2">
|
||||
<BrainCircuit className="w-3.5 h-3.5 text-rmi-purple flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-[10px] text-rmi-white font-semibold">{p.name}</div>
|
||||
<div className="text-[9px] text-rmi-gray">{p.confidence}% confidence</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{overlay.signals?.slice(0, 2).map((s, i) => (
|
||||
<div key={`sig-${i}`} className={`p-2.5 rounded-xl glass border flex items-center gap-2 ${s.type === 'buy' ? 'border-rmi-success/20 bg-rmi-success/5' : 'border-rmi-danger/20 bg-rmi-danger/5'}`}>
|
||||
{s.type === 'buy' ? <TrendingUp className="w-3.5 h-3.5 text-rmi-success" /> : <TrendingDown className="w-3.5 h-3.5 text-rmi-danger" />}
|
||||
<div>
|
||||
<div className={`text-[10px] font-semibold ${s.type === 'buy' ? 'text-rmi-success' : 'text-rmi-danger'}`}>{s.type.toUpperCase()} Signal</div>
|
||||
<div className="text-[9px] text-rmi-gray">{s.reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{overlay.supports?.length > 0 && (
|
||||
<div className="p-2.5 rounded-xl glass border border-rmi-success/15 flex items-center gap-2">
|
||||
<Minus className="w-3.5 h-3.5 text-rmi-success" />
|
||||
<div>
|
||||
<div className="text-[10px] text-rmi-white font-semibold">Support</div>
|
||||
<div className="text-[9px] text-rmi-gray">{overlay.supports.length} zones</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{overlay.resistances?.length > 0 && (
|
||||
<div className="p-2.5 rounded-xl glass border border-rmi-danger/15 flex items-center gap-2">
|
||||
<Target className="w-3.5 h-3.5 text-rmi-danger" />
|
||||
<div>
|
||||
<div className="text-[10px] text-rmi-white font-semibold">Resistance</div>
|
||||
<div className="text-[9px] text-rmi-gray">{overlay.resistances.length} zones</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/TokenRiskBadge.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// @ts-nocheck
|
||||
import { AlertTriangle, ShieldCheck, Lock, Unlock, Printer, Waves, Eye, Clock, HelpCircle, Skull, TrendingDown, CheckCircle } from 'lucide-react';
|
||||
import type { RiskLabel } from '@/services/dexData';
|
||||
|
||||
const SEVERITY_STYLES = {
|
||||
critical: 'bg-rmi-danger/15 border-rmi-danger/40 text-rmi-danger',
|
||||
high: 'bg-rmi-orange/15 border-rmi-orange/40 text-rmi-orange',
|
||||
medium: 'bg-rmi-gold/15 border-rmi-gold/40 text-rmi-gold',
|
||||
low: 'bg-rmi-gray/15 border-rmi-gray/30 text-rmi-gray',
|
||||
info: 'bg-rmi-success/15 border-rmi-success/30 text-rmi-success',
|
||||
};
|
||||
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
honeypot: Lock,
|
||||
unlocked: Unlock,
|
||||
mint: Printer,
|
||||
high_tax: Waves,
|
||||
whale: Eye,
|
||||
dev_bag: Skull,
|
||||
wash: Waves,
|
||||
fresh: Clock,
|
||||
unverified: HelpCircle,
|
||||
honeypot_history: Skull,
|
||||
low_liq: TrendingDown,
|
||||
renounced: CheckCircle,
|
||||
locked: Lock,
|
||||
audited: ShieldCheck,
|
||||
safe: ShieldCheck,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
label: RiskLabel;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export default function TokenRiskBadge({ label, size = 'sm' }: Props) {
|
||||
const Icon = ICON_MAP[label.id] || AlertTriangle;
|
||||
const sizeClasses = size === 'sm' ? 'text-[9px] px-1.5 py-0.5 gap-0.5' : 'text-[11px] px-2.5 py-1 gap-1';
|
||||
const iconSize = size === 'sm' ? 'w-2.5 h-2.5' : 'w-3 h-3';
|
||||
|
||||
return (
|
||||
<span
|
||||
title={label.description}
|
||||
className={`inline-flex items-center rounded-md border font-bold uppercase tracking-wider ${SEVERITY_STYLES[label.severity]} ${sizeClasses}`}
|
||||
>
|
||||
<Icon className={iconSize} />
|
||||
{label.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
145
src/components/TrendingTicker.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TrendingUp, TrendingDown, Flame, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface TrendingToken {
|
||||
address: string;
|
||||
symbol: string;
|
||||
name?: string;
|
||||
price_usd: number;
|
||||
change_24h: number;
|
||||
volume_24h: number;
|
||||
chain: string;
|
||||
pair_address?: string;
|
||||
}
|
||||
|
||||
const fmtUsd = (v: any) => {
|
||||
const n = Number(v);
|
||||
if (!n || isNaN(n)) return '$0';
|
||||
if (n >= 1e9) return '$' + (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return '$' + (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return '$' + (n / 1e3).toFixed(1) + 'K';
|
||||
if (n < 0.0001) return '$' + n.toFixed(8);
|
||||
if (n < 1) return '$' + n.toFixed(6);
|
||||
return '$' + n.toFixed(2);
|
||||
};
|
||||
|
||||
const fmtPct = (v: any) => {
|
||||
const n = Number(v);
|
||||
if (isNaN(n)) return '—';
|
||||
return `${n >= 0 ? '+' : ''}${n.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const CHAIN_ICONS: Record<string, string> = {
|
||||
solana: '◎', ethereum: 'Ξ', base: '⊙', bsc: '⬡', arbitrum: '◈', tron: '◆',
|
||||
};
|
||||
|
||||
export function TrendingTicker() {
|
||||
const [tokens, setTokens] = useState<TrendingToken[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchTrending = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/v1/rugcharts/trending?chain=solana&limit=20');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
const tks = d?.tokens || [];
|
||||
if (!cancelled && tks.length > 0) {
|
||||
setTokens(tks.filter((t: any) => t.volume_24h > 0));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Fallback: try DataBus
|
||||
try {
|
||||
const { databus } = await import('@/services/databus');
|
||||
const result = await databus.fetch('trending', { limit: 20 });
|
||||
const d = result?.data || result;
|
||||
const raw = Array.isArray(d) ? d : (d?.tokens || d?.items || []);
|
||||
if (!cancelled) {
|
||||
setTokens(raw.map((t: any) => ({
|
||||
address: t.address || '', symbol: t.symbol || '',
|
||||
price_usd: t.price_usd || t.current_price || 0,
|
||||
change_24h: t.change_24h || t.price_change_percentage_24h || 0,
|
||||
volume_24h: t.volume_24h || t.total_volume || 0,
|
||||
chain: t.chain || 'solana',
|
||||
})));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch { if (!cancelled) setLoading(false); }
|
||||
};
|
||||
fetchTrending();
|
||||
const interval = setInterval(fetchTrending, 60000); // Refresh every 60s
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current || tokens.length === 0) return;
|
||||
const el = scrollRef.current;
|
||||
let animId: number;
|
||||
let pos = 0;
|
||||
const speed = 0.5; // pixels per frame
|
||||
const animate = () => {
|
||||
pos += speed;
|
||||
if (pos >= el.scrollWidth / 2) pos = 0;
|
||||
el.scrollLeft = pos;
|
||||
animId = requestAnimationFrame(animate);
|
||||
};
|
||||
animId = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(animId);
|
||||
}, [tokens]);
|
||||
|
||||
const handleClick = (token: TrendingToken) => {
|
||||
navigate(`/rugcharts?token=${token.address}&chain=${token.chain}`);
|
||||
};
|
||||
|
||||
if (loading || tokens.length === 0) return null;
|
||||
|
||||
// Duplicate for seamless scroll
|
||||
const displayTokens = [...tokens, ...tokens];
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden border-b border-white/[0.06] bg-black/40 backdrop-blur-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 flex items-center gap-1.5 px-3 py-2 border-r border-white/[0.06]">
|
||||
<Flame className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-white/60">Trending</span>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-hidden flex items-center gap-0 cursor-default"
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.animationPlayState = 'paused'; }}
|
||||
>
|
||||
<div className="flex items-center gap-0 whitespace-nowrap" style={{ minWidth: '200%' }}>
|
||||
{displayTokens.map((token, i) => {
|
||||
const isUp = Number(token.change_24h) > 0;
|
||||
const isDown = Number(token.change_24h) < -10;
|
||||
return (
|
||||
<button
|
||||
key={`${token.address}-${i}`}
|
||||
onClick={() => handleClick(token)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-white/[0.04] transition-colors border-r border-white/[0.03] flex-shrink-0"
|
||||
>
|
||||
<span className="text-[10px] text-white/40">{CHAIN_ICONS[token.chain] || '•'}</span>
|
||||
<span className="text-xs font-medium text-white/90">{token.symbol}</span>
|
||||
<span className="text-xs font-mono text-white/60">{fmtUsd(token.price_usd)}</span>
|
||||
<span className={`text-[10px] font-mono font-semibold ${isUp ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{isUp ? '▲' : '▼'} {fmtPct(token.change_24h)}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/30">vol {fmtUsd(token.volume_24h)}</span>
|
||||
{isDown && <AlertTriangle className="w-3 h-3 text-amber-400/60" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/Typewriter.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface TypewriterProps {
|
||||
lines: string[];
|
||||
speed?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function Typewriter({ lines, speed = 35, delay = 600, className = '', onComplete }: TypewriterProps) {
|
||||
const [displayed, setDisplayed] = useState<string[]>([]);
|
||||
const [currentLine, setCurrentLine] = useState(0);
|
||||
const [currentChar, setCurrentChar] = useState(0);
|
||||
const [showCursor, setShowCursor] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentLine >= lines.length) {
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
const line = lines[currentLine];
|
||||
if (currentChar >= line.length) {
|
||||
const timer = setTimeout(() => {
|
||||
setDisplayed(prev => [...prev, line]);
|
||||
setCurrentLine(prev => prev + 1);
|
||||
setCurrentChar(0);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentChar(prev => prev + 1);
|
||||
}, speed + Math.random() * 20);
|
||||
return () => clearTimeout(timer);
|
||||
}, [currentLine, currentChar, lines, speed, delay, onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setShowCursor(prev => !prev), 530);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{displayed.map((line, i) => (
|
||||
<p key={i} className="text-rmi-gray"><span className="text-rmi-success">$</span> {line}</p>
|
||||
))}
|
||||
{currentLine < lines.length && (
|
||||
<p className="text-rmi-gray">
|
||||
<span className="text-rmi-success">$</span> {lines[currentLine].slice(0, currentChar)}
|
||||
<span className={`inline-block w-2 h-3.5 ml-0.5 align-middle ${showCursor ? 'bg-rmi-purple/60' : 'bg-transparent'}`} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
src/components/WalletForensicsPanel.tsx
Executable file
|
|
@ -0,0 +1,191 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ExternalLink, Wallet, Activity, Users, Clock, Shield, AlertTriangle, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface WalletForensic {
|
||||
address: string;
|
||||
full_address: string;
|
||||
explorer_url: string;
|
||||
chain: string;
|
||||
label: string;
|
||||
is_exchange: boolean;
|
||||
percentage: number;
|
||||
is_sniper: boolean;
|
||||
is_fresh_wallet: boolean;
|
||||
risk_score: number;
|
||||
}
|
||||
|
||||
interface FirstBuyersAnalysis {
|
||||
total_first_buyers: number;
|
||||
snipers_count: number;
|
||||
fresh_wallets_count: number;
|
||||
exchange_funded_count: number;
|
||||
sniper_percentage: number;
|
||||
fresh_wallet_percentage: number;
|
||||
exchange_funded_percentage: number;
|
||||
smart_money_count?: number;
|
||||
wallets: WalletForensic[];
|
||||
}
|
||||
|
||||
interface DevProfile {
|
||||
deployer_address: string;
|
||||
explorer_url: string;
|
||||
tokens_deployed: string[];
|
||||
rug_pulls_associated: number;
|
||||
dev_risk_score: number;
|
||||
dev_risk_level: string;
|
||||
}
|
||||
|
||||
interface WalletForensicsReport {
|
||||
token_explorer_url: string;
|
||||
chain_explorer_name: string;
|
||||
first_buyers: FirstBuyersAnalysis;
|
||||
dev_profile?: DevProfile;
|
||||
top_50_holders: WalletForensic[];
|
||||
ai_narrative: string;
|
||||
risk_summary: string;
|
||||
key_findings: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
forensics: WalletForensicsReport | null;
|
||||
}
|
||||
|
||||
const RiskBadge = ({ score }: { score: number }) => {
|
||||
const color = score >= 70 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 30 ? '#eab308' : '#22c55e';
|
||||
const label = score >= 70 ? 'CRITICAL' : score >= 50 ? 'HIGH' : score >= 30 ? 'MEDIUM' : 'LOW';
|
||||
return (
|
||||
<span className="px-2 py-1 rounded text-xs font-bold" style={{ backgroundColor: color + '22', color }}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function WalletForensicsPanel({ forensics }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'first_buyers' | 'holders' | 'dev'>('overview');
|
||||
|
||||
if (!forensics) {
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-6 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<Wallet className="w-5 h-5" />
|
||||
<span>Wallet forensics not available</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { first_buyers, dev_profile, ai_narrative, risk_summary, key_findings, top_50_holders } = forensics;
|
||||
const fb = first_buyers;
|
||||
|
||||
const StatCard = ({ icon: Icon, label, value, subtext, color = '#8b5cf6' }: any) => (
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="w-4 h-4" style={{ color }} />
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
{subtext && <div className="text-xs text-gray-500 mt-1">{subtext}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-6 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="w-6 h-6 text-purple-400" />
|
||||
<h3 className="text-lg font-bold text-white">Wallet Forensics</h3>
|
||||
<a href={forensics.token_explorer_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-purple-400 hover:text-purple-300 flex items-center gap-1">
|
||||
{forensics.chain_explorer_name} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{['overview', 'first_buyers', 'holders', 'dev'].map((tab) => (
|
||||
<button key={tab} onClick={() => setActiveTab(tab as any)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium ${
|
||||
activeTab === tab ? 'bg-purple-500/20 text-purple-300' : 'text-gray-400 hover:bg-white/5'
|
||||
}`}>
|
||||
{tab.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gradient-to-r from-purple-500/10 to-blue-500/10 rounded-xl p-4 border border-purple-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm font-bold text-purple-300">AI Analysis</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{ai_narrative}</p>
|
||||
<div className="mt-3 text-xs text-gray-500">Risk: <span className="text-white font-bold">{risk_summary}</span></div>
|
||||
</div>
|
||||
{key_findings?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" /> Key Findings
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{key_findings.map((f: string, i: number) => (
|
||||
<div key={i} className="text-sm text-gray-300 bg-white/5 rounded-lg px-3 py-2">{f}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard icon={Users} label="First Buyers" value={fb.total_first_buyers} subtext={`${fb.snipers_count} snipers`} />
|
||||
<StatCard icon={Clock} label="Fresh Wallets" value={`${fb.fresh_wallet_percentage.toFixed(1)}%`} subtext={`${fb.fresh_wallets_count} new`} />
|
||||
<StatCard icon={Shield} label="Exchange Funded" value={`${fb.exchange_funded_percentage.toFixed(1)}%`} subtext={`${fb.exchange_funded_count} CEX`} />
|
||||
<StatCard icon={TrendingUp} label="Smart Money" value={`${fb.smart_money_count || 0}`} subtext="Pros" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'first_buyers' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between"><h4 className="text-sm font-bold text-white">First Buyers</h4>
|
||||
<span className="text-xs text-red-400">{fb.snipers_count} Snipers</span></div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{fb.wallets?.slice(0, 20).map((w: WalletForensic, i: number) => (
|
||||
<div key={i} className="flex justify-between bg-white/5 rounded-lg px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={w.explorer_url} target="_blank" className="font-mono text-purple-300">{w.address}</a>
|
||||
{w.is_sniper && <span className="text-xs text-red-400">🎯</span>}
|
||||
</div>
|
||||
<div className="flex gap-3"><span className="text-gray-400">{w.percentage.toFixed(2)}%</span><RiskBadge score={w.risk_score} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'holders' && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-bold text-white">Top Holders</h4>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{top_50_holders?.slice(0, 20).map((h: WalletForensic, i: number) => (
|
||||
<div key={i} className="flex justify-between bg-white/5 rounded-lg px-3 py-2 text-sm">
|
||||
<div className="flex gap-2"><span className="text-gray-500 w-4">{i+1}</span>
|
||||
<a href={h.explorer_url} target="_blank" className="font-mono text-purple-300">{h.address}</a></div>
|
||||
<div className="flex gap-3"><span className="text-white font-bold">{h.percentage.toFixed(2)}%</span><RiskBadge score={h.risk_score} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'dev' && dev_profile && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between"><h4 className="text-sm font-bold text-white">Developer</h4><RiskBadge score={dev_profile.dev_risk_score} /></div>
|
||||
<div className="bg-white/5 rounded-xl p-4 space-y-3">
|
||||
<div className="flex justify-between"><span className="text-gray-400 text-sm">Deployer</span><a href={dev_profile.explorer_url} target="_blank" className="font-mono text-purple-300 text-sm">{dev_profile.deployer_address}</a></div>
|
||||
<div className="flex justify-between"><span className="text-gray-400 text-sm">Tokens</span><span className="text-white font-bold">{dev_profile.tokens_deployed?.length || 0}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-400 text-sm">Rugs</span><span className={`font-bold ${dev_profile.rug_pulls_associated > 0 ? 'text-red-400' : 'text-green-400'}`}>{dev_profile.rug_pulls_associated}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-400 text-sm">Risk</span><span className="text-white font-bold">{dev_profile.dev_risk_level}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/WalletPanel.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, AlertTriangle, Shield, Activity, ChevronDown, ChevronUp, Loader2, Radar, Fingerprint } from 'lucide-react';
|
||||
import type { AnalysisResult } from '@/types';
|
||||
|
||||
interface WalletPanelProps {
|
||||
onAnalyze: (address: string, chain: string) => void;
|
||||
analysis?: AnalysisResult | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const CHAINS = [
|
||||
{ id: 'ethereum', name: 'ETH', full: 'Ethereum', color: '#627EEA', bg: 'rgba(98,126,234,0.12)' },
|
||||
{ id: 'solana', name: 'SOL', full: 'Solana', color: '#14F195', bg: 'rgba(20,241,149,0.12)' },
|
||||
{ id: 'bsc', name: 'BSC', full: 'BSC', color: '#F3BA2F', bg: 'rgba(243,186,47,0.12)' },
|
||||
{ id: 'polygon', name: 'POL', full: 'Polygon', color: '#8247E5', bg: 'rgba(130,71,229,0.12)' },
|
||||
{ id: 'tron', name: 'TRX', full: 'Tron', color: '#FF060A', bg: 'rgba(255,6,10,0.12)' },
|
||||
];
|
||||
|
||||
export default function WalletPanel({ onAnalyze, analysis, loading }: WalletPanelProps) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [chain, setChain] = useState('ethereum');
|
||||
const [showFlags, setShowFlags] = useState(false);
|
||||
|
||||
// Keyboard shortcuts: / to focus, Esc to clear
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === '/' && document.activeElement?.tagName !== 'INPUT') {
|
||||
e.preventDefault();
|
||||
document.querySelector<HTMLInputElement>('input[placeholder*="wallet"]')?.focus();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setAddress('');
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!address.trim()) return;
|
||||
onAnalyze(address.trim(), chain);
|
||||
};
|
||||
|
||||
const riskMeta = (score: number) => {
|
||||
if (score >= 70) return { color: 'text-rmi-danger', bg: 'bg-rmi-danger/10', border: 'border-rmi-danger/25', bar: '#FF3366', label: 'Critical' };
|
||||
if (score >= 40) return { color: 'text-rmi-gold', bg: 'bg-rmi-gold/10', border: 'border-rmi-gold/25', bar: '#D1A340', label: 'Elevated' };
|
||||
return { color: 'text-rmi-success', bg: 'bg-rmi-success/10', border: 'border-rmi-success/25', bar: '#00E676', label: 'Low' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-rmi-border/50 bg-rmi-surface/40 backdrop-blur">
|
||||
{/* Search Bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||
<div className="relative flex-1 max-w-lg">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-rmi-gray/70" />
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
|
||||
placeholder="Paste wallet address..."
|
||||
className="w-full bg-rmi-bg/80 border border-rmi-border/60 rounded-xl pl-10 pr-4 py-2 text-sm text-rmi-white placeholder-rmi-gray/50 focus:outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-1 p-0.5 bg-rmi-bg/60 rounded-xl border border-rmi-border/40">
|
||||
{CHAINS.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setChain(c.id)}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-[11px] font-semibold transition-all ${
|
||||
chain === c.id
|
||||
? 'text-white shadow-sm'
|
||||
: 'text-rmi-gray hover:text-rmi-white'
|
||||
}`}
|
||||
style={chain === c.id ? { background: c.color } : {}}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
className="sm:hidden bg-rmi-bg/80 border border-rmi-border/60 rounded-xl px-3 py-2 text-sm text-rmi-white"
|
||||
>
|
||||
{CHAINS.map(c => <option key={c.id} value={c.id}>{c.full}</option>)}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !address.trim()}
|
||||
className="flex items-center gap-2 px-5 py-2 bg-gradient-to-r from-rmi-purple to-rmi-cyan rounded-xl text-sm font-bold text-white hover:shadow-lg hover:shadow-rmi-purple/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:shadow-none"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Radar className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">Trace</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Analysis Results */}
|
||||
{analysis && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Risk Badge */}
|
||||
{(() => {
|
||||
const meta = riskMeta(analysis.riskScore);
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-xl border ${meta.bg} ${meta.border} ${meta.color}`}>
|
||||
<Activity className="w-4 h-4" />
|
||||
<span className="text-sm font-bold">{analysis.riskScore}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold opacity-80">{meta.label}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Wallet Type */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl glass-strong text-sm">
|
||||
<Fingerprint className="w-4 h-4 text-rmi-cyan" />
|
||||
<span className="text-rmi-white font-medium capitalize">{analysis.walletType.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
|
||||
{/* Connected */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl glass-strong text-sm text-rmi-gray">
|
||||
<span className="text-rmi-white font-bold">{analysis.connectedWallets}</span>
|
||||
<span>links</span>
|
||||
</div>
|
||||
|
||||
{/* Separation */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl glass-strong text-sm text-rmi-gray">
|
||||
<span className="text-rmi-white font-bold">{analysis.degreeOfSeparation}°</span>
|
||||
<span>separation</span>
|
||||
</div>
|
||||
|
||||
{/* Mixer Alert */}
|
||||
{analysis.mixerTouches > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-rmi-danger/10 border border-rmi-danger/20 text-rmi-danger text-sm animate-pulse">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span className="font-bold">{analysis.mixerTouches}</span>
|
||||
<span className="text-[11px]">mixer touch{analysis.mixerTouches > 1 ? 'es' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CEX */}
|
||||
{analysis.cexExits.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl glass-strong text-sm text-rmi-gray">
|
||||
<Shield className="w-4 h-4 text-rmi-success" />
|
||||
<span className="text-rmi-white">{analysis.cexExits.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flags */}
|
||||
{analysis.flags.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setShowFlags(!showFlags)}
|
||||
className="flex items-center gap-1.5 text-[11px] text-rmi-gray hover:text-rmi-white transition-colors font-medium"
|
||||
>
|
||||
{showFlags ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${analysis.flags.some(f => f.severity === 'critical') ? 'bg-rmi-danger' : 'bg-rmi-gold'}`} />
|
||||
{analysis.flags.length} Risk Flag{analysis.flags.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
{showFlags && (
|
||||
<div className="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{analysis.flags.map((flag, i) => (
|
||||
<div key={i} className="flex items-start gap-2.5 p-2.5 rounded-xl bg-rmi-bg/60 border border-rmi-border/40">
|
||||
<span className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
|
||||
flag.severity === 'critical' ? 'bg-rmi-danger shadow-[0_0_6px_rgba(255,51,102,0.6)]' :
|
||||
flag.severity === 'high' ? 'bg-rmi-orange' :
|
||||
flag.severity === 'medium' ? 'bg-rmi-gold' : 'bg-rmi-success'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-rmi-white">{flag.title}</div>
|
||||
<div className="text-[11px] text-rmi-gray leading-snug">{flag.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
src/components/ai/StreamingChat.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Streaming AI Chat — SSE token-by-token rendering
|
||||
* =================================================
|
||||
*
|
||||
* Connects to /api/v1/ai/chat/stream for real-time token streaming.
|
||||
* Renders each token as it arrives from the AI provider.
|
||||
*
|
||||
* Usage:
|
||||
* <StreamingChat />
|
||||
* <StreamingChat initialMessage="Analyze this token: 0x..." />
|
||||
*/
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Send, Loader2, Bot, User, Sparkles, AlertTriangle,
|
||||
Shield, Zap, ArrowRight, RefreshCw, X
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const WELCOME_MESSAGE: Message = {
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: "I'm RMI Intelligence — your crypto security AI. I can analyze tokens, explain scams, review contracts, and track wallet activity. What would you like to know?",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
export function StreamingChat({
|
||||
initialMessage,
|
||||
compact = false,
|
||||
className = ''
|
||||
}: {
|
||||
initialMessage?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
|
||||
const [input, setInput] = useState(initialMessage || '');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialMessage) {
|
||||
handleSend();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
|
||||
const userMsg: Message = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: `assistant_${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
||||
setInput('');
|
||||
setIsStreaming(true);
|
||||
setError(null);
|
||||
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v2/ai/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text, mode: 'general' }),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let model = '';
|
||||
let provider = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ') && lines.includes(line)) {
|
||||
const eventType = line.slice(7).trim();
|
||||
const dataLine = lines[lines.indexOf(line) + 1];
|
||||
if (!dataLine?.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(dataLine.slice(6));
|
||||
|
||||
if (eventType === 'start') {
|
||||
model = data.model || '';
|
||||
provider = data.provider || '';
|
||||
} else if (eventType === 'token') {
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
last.content += data.text;
|
||||
}
|
||||
return [...updated];
|
||||
});
|
||||
} else if (eventType === 'done') {
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
last.isStreaming = false;
|
||||
last.model = model;
|
||||
last.provider = provider;
|
||||
}
|
||||
return [...updated];
|
||||
});
|
||||
} else if (eventType === 'error') {
|
||||
setError(data.message || 'Stream error');
|
||||
} else if (eventType === 'blocked') {
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
last.content = '⚠️ ' + data;
|
||||
last.isStreaming = false;
|
||||
}
|
||||
return [...updated];
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') return;
|
||||
setError(err.message || 'Connection failed');
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
last.isStreaming = false;
|
||||
last.content += '\n\n❌ Connection lost. Please try again.';
|
||||
}
|
||||
return [...updated];
|
||||
});
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, [input, isStreaming]);
|
||||
|
||||
const handleStop = () => {
|
||||
abortRef.current?.abort();
|
||||
setIsStreaming(false);
|
||||
setMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
last.isStreaming = false;
|
||||
}
|
||||
return [...updated];
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setMessages([WELCOME_MESSAGE]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${compact ? 'h-[400px]' : 'h-[600px]'} bg-[#0C1220] border border-[#1a2332] rounded-2xl overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#1a2332] bg-[#0a0f1a]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-5 h-5 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-white">RMI Intelligence</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 border border-purple-500/20">
|
||||
AI
|
||||
</span>
|
||||
{isStreaming && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400">
|
||||
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||
Streaming
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleClear} className="p-1.5 rounded-lg hover:bg-white/5 text-gray-500 hover:text-gray-300 transition-colors" title="Clear chat">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<AnimatePresence>
|
||||
{messages.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/15 flex items-center justify-center shrink-0 mt-1">
|
||||
<Bot className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`max-w-[80%] rounded-2xl px-4 py-3 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-[#111827] text-gray-200 border border-[#1a2332]'
|
||||
}`}>
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
{msg.isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-purple-400 animate-pulse ml-0.5 align-middle" />
|
||||
)}
|
||||
</div>
|
||||
{msg.model && !msg.isStreaming && (
|
||||
<div className="text-[10px] text-gray-500 mt-2">
|
||||
via {msg.provider || 'AI'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg.role === 'user' && (
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/15 flex items-center justify-center shrink-0 mt-1">
|
||||
<User className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-400 bg-red-500/10 rounded-lg px-4 py-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-auto"><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 border-t border-[#1a2332] bg-[#0a0f1a]">
|
||||
<div className="flex gap-3">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about tokens, scams, wallet analysis..."
|
||||
rows={1}
|
||||
className="flex-1 bg-[#111827] border border-[#1a2332] rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 resize-none focus:outline-none focus:border-purple-500/50 transition-colors"
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="px-4 py-3 bg-red-500/15 hover:bg-red-500/25 text-red-400 rounded-xl transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
className="px-4 py-3 bg-purple-600 hover:bg-purple-500 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-xl transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-2 text-center">
|
||||
RMI Intelligence may make mistakes. Verify critical information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StreamingChat;
|
||||
627
src/components/auth/AuthModal.tsx
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X, Mail, Lock, User, Loader2, Eye, EyeOff, Wallet,
|
||||
Shield, Zap, ArrowLeft, Check, AlertTriangle,
|
||||
MessageCircle, AtSign, Key, ArrowRight, Bot,
|
||||
} from 'lucide-react';
|
||||
import { useAuth, type WalletProvider } from '../../contexts/AuthContext';
|
||||
|
||||
interface AuthModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type AuthStep = 'methods' | 'email-login' | 'email-register' | 'forgot-password' | 'wallet-connect' | 'reset-sent' | 'consent' | 'bot-signup' | '2fa-verify';
|
||||
|
||||
const WALLET_OPTIONS: { id: WalletProvider; name: string; icon: string; color: string; chain: string }[] = [
|
||||
{ id: 'phantom', name: 'Phantom', icon: '👻', color: '#ab9ff2', chain: 'Solana' },
|
||||
{ id: 'solflare', name: 'Solflare', icon: '🔥', color: '#fc7215', chain: 'Solana' },
|
||||
{ id: 'backpack', name: 'Backpack', icon: '🎒', color: '#e33c30', chain: 'Solana' },
|
||||
{ id: 'metamask', name: 'MetaMask', icon: '🦊', color: '#f6851b', chain: 'EVM' },
|
||||
{ id: 'coinbase', name: 'Coinbase Wallet', icon: '🔵', color: '#0052ff', chain: 'EVM' },
|
||||
{ id: 'rainbow', name: 'Rainbow', icon: '🌈', color: '#764afe', chain: 'EVM' },
|
||||
];
|
||||
const SOCIAL_OPTIONS = [
|
||||
{ id: 'google', name: 'Continue with Google', icon: '🔵', color: '#4285f4', desc: 'Gmail account' },
|
||||
{ id: 'twitter', name: 'Continue with X', icon: '𝕏', color: '#1DA1F2', desc: 'Twitter / X account' },
|
||||
{ id: 'github', name: 'Continue with GitHub', icon: '🐙', color: '#333', desc: 'GitHub account' },
|
||||
];
|
||||
|
||||
export default function AuthModal({ open, onClose }: AuthModalProps) {
|
||||
const [step, setStep] = useState<AuthStep>('methods');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [connectingWallet, setConnectingWallet] = useState<string | null>(null);
|
||||
const [consentAgreed, setConsentAgreed] = useState(false);
|
||||
const [consentError, setConsentError] = useState(false);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||
const [preAuthToken, setPreAuthToken] = useState<string | null>(null);
|
||||
const { login: doLogin, login2FA: doLogin2FA, register: doRegister, socialLogin, walletLogin } = useAuth();
|
||||
|
||||
const reset = () => {
|
||||
setStep('methods');
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setUsername('');
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setShowPassword(false);
|
||||
setConsentAgreed(false);
|
||||
setConsentError(false);
|
||||
setTwoFactorCode('');
|
||||
setPreAuthToken(null);
|
||||
};
|
||||
|
||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!consentAgreed) { setConsentError(true); setError('You must agree to the Terms and Privacy Policy to continue.'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await doLogin(email, password);
|
||||
if (result.requires2FA) {
|
||||
setPreAuthToken(result.preToken || null);
|
||||
setStep('2fa-verify');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
reset();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Invalid email or password');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handle2FAVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await doLogin2FA(email, password, twoFactorCode);
|
||||
onClose();
|
||||
reset();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Invalid 2FA code');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleEmailRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!consentAgreed) { setConsentError(true); setError('You must agree to the Terms and Privacy Policy to create an account.'); return; }
|
||||
if (!username.trim()) { setError('Username is required'); return; }
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await doRegister(email, password, username.trim());
|
||||
onClose();
|
||||
reset();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to send reset email');
|
||||
setStep('reset-sent');
|
||||
setSuccess(`Reset link sent to ${email}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to send reset email');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSocial = async (provider: 'google' | 'twitter' | 'github' | 'telegram') => {
|
||||
setError('');
|
||||
if (!consentAgreed) { setConsentError(true); setError('You must agree to the Terms and Privacy Policy to sign in.'); return; }
|
||||
if (provider === 'telegram') {
|
||||
// Open Telegram bot for initial sign-up
|
||||
window.open('https://t.me/rugmunchbot?start=signup', '_blank');
|
||||
setSuccess('Complete sign-up in Telegram, then return here to finish your profile.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await socialLogin(provider as any);
|
||||
onClose();
|
||||
reset();
|
||||
} catch (err: any) {
|
||||
setError(err.message || `${provider} sign-in failed`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleWalletConnect = async (provider: WalletProvider) => {
|
||||
setError('');
|
||||
if (!consentAgreed) { setConsentError(true); setError('You must agree to the Terms and Privacy Policy to connect your wallet.'); return; }
|
||||
setConnectingWallet(provider);
|
||||
try {
|
||||
await walletLogin(provider);
|
||||
onClose();
|
||||
reset();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Wallet connection failed');
|
||||
}
|
||||
setConnectingWallet(null);
|
||||
};
|
||||
|
||||
// ── Back button ──
|
||||
const backBtn = step !== 'methods' && step !== 'reset-sent' && (
|
||||
<button onClick={reset} className="absolute top-4 left-4 p-2 rounded-xl hover:bg-white/5 text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
||||
className="relative w-full max-w-md bg-[#0d0d15] border border-white/10 rounded-2xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button onClick={onClose} className="absolute top-4 right-4 p-2 rounded-xl hover:bg-white/5 text-gray-400 hover:text-white transition-colors z-10">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
{backBtn}
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-600 to-purple-500 flex items-center justify-center mx-auto mb-3">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-white">
|
||||
{step === 'methods' && 'Sign in to Rug Munch Intelligence'}
|
||||
{step === 'email-login' && 'Welcome back'}
|
||||
{step === 'email-register' && 'Create account'}
|
||||
{step === 'forgot-password' && 'Reset password'}
|
||||
{step === 'wallet-connect' && 'Connect wallet'}
|
||||
{step === 'bot-signup' && 'Register your bot'}
|
||||
{step === 'reset-sent' && 'Check your email'}
|
||||
{step === '2fa-verify' && 'Two-Factor Authentication'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{step === 'methods' && 'Choose how to sign in'}
|
||||
{step === 'email-login' && 'Sign in with your email'}
|
||||
{step === 'email-register' && 'Join the intelligence network'}
|
||||
{step === 'forgot-password' && "We'll send you a reset link"}
|
||||
{step === 'wallet-connect' && 'Select your wallet'}
|
||||
{step === 'reset-sent' && 'Follow the link in your inbox'}
|
||||
{step === '2fa-verify' && 'Enter your authenticator code'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error / Success */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 rounded-xl bg-green-500/10 border border-green-500/20 text-green-400 text-sm">
|
||||
<Check className="w-4 h-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP: Methods ── */}
|
||||
{step === 'methods' && (
|
||||
<div className="space-y-3">
|
||||
{/* Consent checkbox — required before any auth */}
|
||||
<div className={`p-3 rounded-xl border ${consentError && !consentAgreed ? 'border-red-500/50 bg-red-500/5' : 'border-white/10 bg-white/[0.02]'} transition-colors`}>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consentAgreed}
|
||||
onChange={(e) => { setConsentAgreed(e.target.checked); setConsentError(false); }}
|
||||
className="mt-0.5 w-4 h-4 rounded border-gray-600 bg-gray-800 text-purple-600 focus:ring-purple-500/50"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 leading-relaxed">
|
||||
I agree to the{' '}
|
||||
<a href="/terms" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:text-purple-300 underline">Terms of Service</a>,{' '}
|
||||
<a href="/privacy" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:text-purple-300 underline">Privacy Policy</a>, and{' '}
|
||||
<a href="/cookies" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:text-purple-300 underline">Cookie Policy</a>.
|
||||
I consent to the collection and use of my data as described, including query data for AI training (optional, can be disabled in cookie settings).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Social options */}
|
||||
{SOCIAL_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => handleSocial(opt.id as any)}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center gap-3 p-3.5 rounded-xl border border-white/10 bg-white/[0.02] hover:bg-white/[0.05] text-white transition-all group"
|
||||
>
|
||||
<span className="text-xl w-8 text-center">{opt.icon}</span>
|
||||
<div className="text-left flex-1">
|
||||
<div className="text-sm font-semibold">{opt.name}</div>
|
||||
<div className="text-[10px] text-gray-500">{opt.desc}</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400 transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Bot signup */}
|
||||
<button
|
||||
onClick={() => setStep('bot-signup')}
|
||||
className="w-full flex items-center gap-3 p-3.5 rounded-xl border border-purple-500/20 bg-purple-500/5 hover:bg-purple-500/10 text-white transition-all group"
|
||||
>
|
||||
<span className="text-xl w-8 text-center">🤖</span>
|
||||
<div className="text-left flex-1">
|
||||
<div className="text-sm font-semibold">Register as Bot</div>
|
||||
<div className="text-[10px] text-gray-500">$1 x402 fee — join the bot-human message board</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-purple-400 group-hover:text-purple-300 transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Telegram signup */}
|
||||
<button
|
||||
onClick={() => handleSocial('telegram')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center gap-3 p-3.5 rounded-xl border border-white/10 bg-white/[0.02] hover:bg-white/[0.05] text-white transition-all group"
|
||||
>
|
||||
<span className="text-xl w-8 text-center">✈️</span>
|
||||
<div className="text-left flex-1">
|
||||
<div className="text-sm font-semibold">Sign up via Telegram</div>
|
||||
<div className="text-[10px] text-gray-500">Start with @rugmunchbot, complete profile here</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400 transition-colors" />
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-white/10" />
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider">or continue with</span>
|
||||
<div className="flex-1 h-px bg-white/10" />
|
||||
</div>
|
||||
|
||||
{/* Email & Wallet buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setStep('email-login')}
|
||||
className="flex items-center justify-center gap-2 p-3 rounded-xl border border-white/10 hover:bg-white/[0.05] text-white text-sm font-semibold transition-all"
|
||||
>
|
||||
<Mail className="w-4 h-4 text-purple-400" />
|
||||
Email
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep('wallet-connect')}
|
||||
className="flex items-center justify-center gap-2 p-3 rounded-xl border border-white/10 hover:bg-white/[0.05] text-white text-sm font-semibold transition-all"
|
||||
>
|
||||
<Wallet className="w-4 h-4 text-cyan-400" />
|
||||
Wallet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Register link */}
|
||||
<p className="text-center text-xs text-gray-600 pt-2">
|
||||
New to Rug Munch Intelligence?{' '}
|
||||
<button onClick={() => setStep('email-register')} className="text-purple-400 hover:text-purple-300 font-semibold">
|
||||
Create account
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP: Email Login ── */}
|
||||
{step === 'email-login' && (
|
||||
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Email</label>
|
||||
<div className="relative">
|
||||
<AtSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="email" required value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'} required value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-10 pr-12 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300">
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={() => { setStep('forgot-password'); setError(''); }} className="text-xs text-purple-400 hover:text-purple-300">
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="w-full py-3 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white font-semibold rounded-xl transition-all shadow-lg shadow-purple-500/20 flex items-center justify-center gap-2">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Sign In
|
||||
</button>
|
||||
<p className="text-center text-xs text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<button type="button" onClick={() => { setStep('email-register'); setError(''); }} className="text-purple-400 hover:text-purple-300 font-semibold">
|
||||
Register
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── STEP: 2FA Verify ── */}
|
||||
{step === '2fa-verify' && (
|
||||
<form onSubmit={handle2FAVerify} className="space-y-4">
|
||||
<div className="text-center mb-2">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-600 to-emerald-500 flex items-center justify-center mx-auto mb-3">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Two-Factor Authentication</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Enter the 6-digit code from your authenticator app</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Authentication Code</label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text" required value={twoFactorCode} onChange={e => setTwoFactorCode(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm text-center tracking-[0.3em] font-mono focus:border-emerald-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
Or use a backup code (XXXX-XXXX format)
|
||||
</p>
|
||||
<button type="submit" disabled={loading} className="w-full py-3 bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white font-semibold rounded-xl transition-all shadow-lg shadow-emerald-500/20 flex items-center justify-center gap-2">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Verify & Sign In
|
||||
</button>
|
||||
<p className="text-center text-xs text-gray-600">
|
||||
<button type="button" onClick={() => { setStep('email-login'); setTwoFactorCode(''); setError(''); }} className="text-purple-400 hover:text-purple-300 font-semibold">
|
||||
Back to sign in
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── STEP: Email Register ── */}
|
||||
{step === 'email-register' && (
|
||||
<form onSubmit={handleEmailRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text" required value={username} onChange={e => setUsername(e.target.value)}
|
||||
placeholder="cryptosleuth"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Email</label>
|
||||
<div className="relative">
|
||||
<AtSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="email" required value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Password (min 8 characters)</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'} required value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-10 pr-12 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300">
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="w-full py-3 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white font-semibold rounded-xl transition-all shadow-lg shadow-purple-500/20 flex items-center justify-center gap-2">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Create Account
|
||||
</button>
|
||||
<p className="text-center text-xs text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<button type="button" onClick={() => { setStep('email-login'); setError(''); }} className="text-purple-400 hover:text-purple-300 font-semibold">
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── STEP: Forgot Password ── */}
|
||||
{step === 'forgot-password' && (
|
||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Email address</label>
|
||||
<div className="relative">
|
||||
<AtSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="email" required value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-semibold rounded-xl transition-all flex items-center justify-center gap-2">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />}
|
||||
Send Reset Link
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── STEP: Reset Sent ── */}
|
||||
{step === 'reset-sent' && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-16 h-16 rounded-full bg-green-500/10 flex items-center justify-center mx-auto">
|
||||
<Check className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-300">{success}</p>
|
||||
<button onClick={() => { setStep('email-login'); setSuccess(''); }} className="text-sm text-purple-400 hover:text-purple-300 font-semibold">
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP: Wallet Connect ── */}
|
||||
{step === 'wallet-connect' && (
|
||||
<div className="space-y-2">
|
||||
{/* Solana wallets */}
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider px-1">Solana</p>
|
||||
{WALLET_OPTIONS.filter(w => w.chain === 'Solana').map(w => (
|
||||
<button
|
||||
key={w.id}
|
||||
onClick={() => handleWalletConnect(w.id)}
|
||||
disabled={connectingWallet !== null}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl border border-white/10 bg-white/[0.02] hover:bg-white/[0.05] text-white transition-all group"
|
||||
>
|
||||
<span className="text-xl w-8 text-center">{w.icon}</span>
|
||||
<span className="text-sm font-semibold flex-1 text-left">{w.name}</span>
|
||||
{connectingWallet === w.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<ArrowRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* EVM wallets */}
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider px-1 pt-2">EVM</p>
|
||||
{WALLET_OPTIONS.filter(w => w.chain === 'EVM' || w.chain === 'Multi').map(w => (
|
||||
<button
|
||||
key={w.id}
|
||||
onClick={() => handleWalletConnect(w.id)}
|
||||
disabled={connectingWallet !== null}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl border border-white/10 bg-white/[0.02] hover:bg-white/[0.05] text-white transition-all group"
|
||||
>
|
||||
<span className="text-xl w-8 text-center">{w.icon}</span>
|
||||
<span className="text-sm font-semibold flex-1 text-left">{w.name}</span>
|
||||
{connectingWallet === w.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<ArrowRight className="w-4 h-4 text-gray-600 group-hover:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<p className="text-[9px] text-gray-600 text-center pt-2">
|
||||
Your wallet is never shared. Used only for Web3 verification.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP: Bot Signup ── */}
|
||||
{step === 'bot-signup' && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 rounded-xl border border-purple-500/20 bg-purple-500/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-5 h-5 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-white">Bot Registration</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Register your AI bot to participate in the RMI Bulletin Board — the first bot-human message board in crypto. Bots pay a $1 x402 fee to join, then can post alerts, analysis, and engage with the community.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Bot Name</label>
|
||||
<input
|
||||
type="text" value={username} onChange={e => setUsername(e.target.value)}
|
||||
placeholder="MyScamDetectorBot"
|
||||
className="w-full px-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Owner Email</label>
|
||||
<div className="relative">
|
||||
<AtSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm focus:border-purple-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl border border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-white">Registration Fee</span>
|
||||
<span className="text-sm font-bold text-green-400">$1.00 USDC</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
Paid via x402 micropayment protocol. No subscription — one-time fee. Your bot gets a verified badge and API access to post on the bulletin board.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!username.trim()) { setError('Bot name is required'); return; }
|
||||
if (!email.trim()) { setError('Owner email is required'); return; }
|
||||
setError('');
|
||||
setSuccess('Redirecting to x402 payment...');
|
||||
// Open x402 payment flow
|
||||
window.open(`https://pay.rugmunch.io/?amount=1¤cy=usdc&recipient=rugmunch&description=Bot+registration:+${encodeURIComponent(username)}`, '_blank');
|
||||
}}
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white font-semibold rounded-xl transition-all shadow-lg shadow-purple-500/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
Pay $1 & Register Bot
|
||||
</button>
|
||||
<p className="text-center text-[10px] text-gray-600">
|
||||
After payment, your bot will receive an API key via email.{' '}
|
||||
<a href="/tools/docs" className="text-purple-400 hover:text-purple-300">View bot API docs →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
138
src/components/auth/ProfileMenu.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { User, Settings, CreditCard, LogOut, ChevronDown, Crown, Shield, Bell, Wallet, Award, Eye } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export default function ProfileMenu() {
|
||||
const { user, logout } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const initials = (user.username || user.email).slice(0, 2).toUpperCase();
|
||||
const isPaid = user.subscription && user.subscription !== 'free';
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 p-1.5 rounded-xl hover:bg-white/5 transition-all border border-white/10 hover:border-purple-500/30"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-cyan-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{initials}
|
||||
</div>
|
||||
<span className="text-sm text-gray-300 hidden sm:block max-w-[100px] truncate">{user.username}</span>
|
||||
{isPaid && <Crown className="w-3.5 h-3.5 text-yellow-400" />}
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.95 }}
|
||||
className="absolute right-0 top-full mt-2 w-72 bg-[#0e0e16] border border-purple-500/20 rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50"
|
||||
>
|
||||
{/* User info */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-cyan-500 flex items-center justify-center text-sm font-bold text-white">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-white truncate">{user.username}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{isPaid && (
|
||||
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-400/10 border border-yellow-400/20">
|
||||
<Crown className="w-3 h-3 text-yellow-400" />
|
||||
<span className="text-[10px] font-semibold text-yellow-400 uppercase">{user.subscription}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-500/10 border border-purple-500/20">
|
||||
<Shield className="w-3 h-3 text-purple-400" />
|
||||
<span className="text-[10px] font-semibold text-purple-400 uppercase">Verified</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="p-2">
|
||||
<a href="/profile" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-300">Profile</span>
|
||||
<p className="text-[10px] text-gray-600">Manage your profile & wallets</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/profile#wallets" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<Wallet className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-300">Wallets</span>
|
||||
<p className="text-[10px] text-gray-600">Connect & manage wallets</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/profile#security" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<Shield className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-300">Security</span>
|
||||
<p className="text-[10px] text-gray-600">Password, 2FA, sessions</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/profile#privacy" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<Eye className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-300">Privacy</span>
|
||||
<p className="text-[10px] text-gray-600">Control profile visibility</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/profile#notifications" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<Bell className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-300">Notifications</span>
|
||||
<p className="text-[10px] text-gray-600">Alerts & preferences</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/profile#badges" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<Award className="w-4 h-4 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-gray-300">Badges & XP</span>
|
||||
<p className="text-[10px] text-gray-600">Achievements & progress</p>
|
||||
</div>
|
||||
</a>
|
||||
<div className="border-t border-white/10 my-1" />
|
||||
<a href="/pricing" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<CreditCard className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-300">Subscription</span>
|
||||
</a>
|
||||
<a href="/profile#settings" className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-colors text-left">
|
||||
<Settings className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-300">Settings</span>
|
||||
</a>
|
||||
<div className="border-t border-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => { logout(); setOpen(false); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg hover:bg-red-500/10 transition-colors text-left"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm text-red-400">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
src/components/auth/TwoFAPanel.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Shield, ShieldCheck, ShieldOff, Loader2, Key, Copy, Check, AlertTriangle, Download } from 'lucide-react';
|
||||
|
||||
interface TwoFAPanelProps {
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
interface TwoFAStatus {
|
||||
enabled: boolean;
|
||||
method: string;
|
||||
created_at?: string;
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
export default function TwoFAPanel({ token }: TwoFAPanelProps) {
|
||||
const [status, setStatus] = useState<TwoFAStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setupData, setSetupData] = useState<any>(null);
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [step, setStep] = useState<'idle' | 'setup' | 'verify' | 'backup'>('idle');
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
useEffect(() => {
|
||||
if (token) fetchStatus();
|
||||
}, [token]);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/2fa/status`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch 2FA status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetup = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/2fa/setup`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to start 2FA setup');
|
||||
}
|
||||
const data = await res.json();
|
||||
setSetupData(data);
|
||||
setStep('setup');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
if (!token || !verifyCode) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/2fa/enable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code: verifyCode }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Invalid code');
|
||||
}
|
||||
setStep('backup');
|
||||
setSuccess('2FA enabled successfully!');
|
||||
await fetchStatus();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!token) return;
|
||||
if (!confirm('Disable 2FA? This removes an important security layer from your account.')) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/2fa/disable`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to disable 2FA');
|
||||
}
|
||||
setStatus({ enabled: false, method: 'none' });
|
||||
setSuccess('2FA disabled');
|
||||
setStep('idle');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copySecret = () => {
|
||||
if (!setupData?.secret) return;
|
||||
navigator.clipboard.writeText(setupData.secret);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
if (!setupData?.backup_codes) return;
|
||||
const text = setupData.backup_codes.join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'rmi-2fa-backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 border border-white/10">
|
||||
<div className="flex items-center gap-3 text-gray-500">
|
||||
<Shield className="w-5 h-5" />
|
||||
<p className="text-sm">Sign in to manage 2FA</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 border border-white/10 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${status?.enabled ? 'bg-emerald-500/20' : 'bg-gray-500/20'}`}>
|
||||
{status?.enabled ? <ShieldCheck className="w-5 h-5 text-emerald-400" /> : <Shield className="w-5 h-5 text-gray-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white">Two-Factor Authentication</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{status?.enabled ? `Enabled (${status.method.toUpperCase()})` : 'Not enabled — your account is less secure'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!status?.enabled ? (
|
||||
<button
|
||||
onClick={handleSetup}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white text-xs font-semibold rounded-xl transition-all shadow-lg shadow-emerald-500/20 flex items-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : <ShieldCheck className="w-3 h-3" />}
|
||||
Enable
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDisable}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-white/5 hover:bg-red-500/20 text-red-400 text-xs font-semibold rounded-xl transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : <ShieldOff className="w-3 h-3" />}
|
||||
Disable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error / Success */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-xs text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" /> {error}
|
||||
</motion.div>
|
||||
)}
|
||||
{success && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center gap-2 p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-xs text-emerald-400">
|
||||
<Check className="w-4 h-4 shrink-0" /> {success}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Setup: QR Code */}
|
||||
<AnimatePresence>
|
||||
{step === 'setup' && setupData && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="space-y-4">
|
||||
<div className="bg-white/5 rounded-xl p-4 space-y-3">
|
||||
<p className="text-xs text-gray-400 text-center">Scan this QR code with your authenticator app</p>
|
||||
<div className="flex justify-center">
|
||||
<img src={setupData.qr_code} alt="2FA QR Code" className="w-48 h-48 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-black/30 rounded-lg p-2">
|
||||
<code className="flex-1 text-xs font-mono text-gray-300 truncate">{setupData.secret}</code>
|
||||
<button onClick={copySecret} className="p-1.5 rounded-lg hover:bg-white/10 text-gray-400 transition-colors">
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 text-center">
|
||||
Can't scan? Enter the secret manually into Google Authenticator, Authy, or 1Password.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStep('verify')}
|
||||
className="w-full py-2.5 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white text-sm font-semibold rounded-xl transition-all"
|
||||
>
|
||||
I've scanned it — verify
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Verify: Enter code */}
|
||||
<AnimatePresence>
|
||||
{step === 'verify' && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Enter 6-digit code from app</label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text" value={verifyCode} onChange={e => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white/[0.03] border border-white/10 rounded-xl text-white text-sm text-center tracking-[0.3em] font-mono focus:border-emerald-500/50 focus:outline-none transition-colors placeholder:text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEnable}
|
||||
disabled={loading || verifyCode.length !== 6}
|
||||
className="w-full py-2.5 bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 text-white text-sm font-semibold rounded-xl transition-all shadow-lg shadow-emerald-500/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ShieldCheck className="w-4 h-4" />}
|
||||
Enable 2FA
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Backup codes */}
|
||||
<AnimatePresence>
|
||||
{step === 'backup' && setupData?.backup_codes && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="space-y-3">
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-400">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<p className="text-xs font-bold">Save these backup codes!</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
Each code can only be used once. If you lose your authenticator, these are your only way back in.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{setupData.backup_codes.map((code: string, i: number) => (
|
||||
<code key={i} className="text-xs font-mono text-amber-300 bg-black/30 rounded-lg px-2 py-1 text-center">{code}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={downloadBackupCodes}
|
||||
className="w-full py-2.5 bg-white/5 hover:bg-white/10 text-white text-sm font-semibold rounded-xl transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Download backup codes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setStep('idle'); setSuccess(''); setSetupData(null); setVerifyCode(''); }}
|
||||
className="w-full py-2.5 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white text-sm font-semibold rounded-xl transition-all"
|
||||
>
|
||||
Done — I've saved them
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/components/bot/BotChat.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
MessageSquare, Eye, Zap, TrendingUp, Database, BarChart3,
|
||||
Send, X, Bot, Sparkles, Loader2
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface BotChatProps {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
{ id: 'general', label: 'General', icon: MessageSquare },
|
||||
{ id: 'scanner', label: 'Scanner', icon: Eye },
|
||||
{ id: 'onchain', label: 'On-Chain', icon: Zap },
|
||||
{ id: 'market', label: 'Market', icon: TrendingUp },
|
||||
{ id: 'multichain', label: 'Multi-Chain', icon: Database },
|
||||
{ id: 'intelligence', label: 'Intel', icon: BarChart3 },
|
||||
];
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'bot';
|
||||
text: string;
|
||||
source?: string;
|
||||
ai_score?: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export function BotChat({ open = true, onClose, inline = false }: BotChatProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: 'bot', text: 'Welcome to Rug Munch Intelligence. I analyze tokens, wallets, markets, and on-chain data in real-time. Ask me anything.', source: 'RMI AI', timestamp: new Date().toISOString() }
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [activeMode, setActiveMode] = useState('general');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const quickReplies = activeMode === 'multichain' ? [
|
||||
'Compare ETH vs Solana fees', 'Check cross-chain volume', 'Multi-chain portfolio', 'Bridge activity',
|
||||
] : activeMode === 'onchain' ? [
|
||||
'Solana network status', 'Check priority fees', 'Analyze a wallet', 'Track recent transactions',
|
||||
] : activeMode === 'market' ? [
|
||||
'Global market overview', 'Bitcoin price analysis', 'Trending tokens', 'Fear & greed index',
|
||||
] : activeMode === 'scanner' ? [
|
||||
'Scan a token address', 'Check if a token is safe', 'View holder distribution', 'Detect bundle activity',
|
||||
] : activeMode === 'intelligence' ? [
|
||||
'Smart money movements', 'Trending bullish signals', 'Active scam alerts', 'Whale wallet tracking',
|
||||
] : [
|
||||
'Scan a token address', 'Analyze a wallet', 'Market overview', 'Smart money moves',
|
||||
'Scam detection check', 'Cross-chain fees', 'Token security audit',
|
||||
];
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || loading) return;
|
||||
const userMsg: Message = { role: 'user', text, timestamp: new Date().toISOString() };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text, mode: activeMode }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'bot',
|
||||
text: data.response,
|
||||
source: data.source,
|
||||
ai_score: data.ai_score,
|
||||
timestamp: data.timestamp,
|
||||
}]);
|
||||
} else {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'bot',
|
||||
text: 'Unable to process your request. Please try again.',
|
||||
source: 'RMI System',
|
||||
}]);
|
||||
}
|
||||
} catch {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'bot',
|
||||
text: 'Connection error. Please check your internet connection and try again.',
|
||||
source: 'RMI System',
|
||||
}]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const chatContent = (
|
||||
<Card className={`glass border-rmi-cyan/20 shadow-2xl shadow-rmi-cyan/10 flex flex-col ${inline ? 'h-full max-h-[600px]' : 'max-h-[650px]'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-rmi-purple/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-rmi-cyan to-rmi-cyan-glow flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-rmi-bg" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-rmi-white">Rug Munch Intelligence Bot</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rmi-success animate-pulse" />
|
||||
<span className="text-[9px] text-rmi-gray">6 AI Modes • Live Data</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="text-[8px] bg-rmi-cyan/20 text-rmi-cyan border-rmi-cyan/30">
|
||||
<Sparkles className="w-2.5 h-2.5 mr-1" />AI
|
||||
</Badge>
|
||||
{!inline && onClose && (
|
||||
<button onClick={onClose} className="text-rmi-gray hover:text-rmi-white"><X className="w-4 h-4" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<div className="flex gap-1 p-2 border-b border-rmi-purple/10 overflow-x-auto">
|
||||
{MODES.map(m => (
|
||||
<button key={m.id} onClick={() => setActiveMode(m.id)} className={`flex items-center gap-1 px-2 py-1 rounded-lg text-[9px] transition-all ${activeMode === m.id ? 'bg-rmi-cyan/15 text-rmi-cyan' : 'text-rmi-gray hover:text-rmi-white'}`}>
|
||||
<m.icon className="w-3 h-3" />{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3 min-h-[200px] max-h-[400px]">
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] p-2.5 rounded-xl text-xs ${m.role === 'user' ? 'bg-rmi-cyan/20 text-rmi-white rounded-br-none' : 'bg-rmi-bg/50 text-rmi-gray rounded-bl-none border border-rmi-purple/10'}`}>
|
||||
{m.text}
|
||||
{m.source && <div className="text-[8px] text-rmi-cyan mt-1 flex items-center gap-1"><Sparkles className="w-2.5 h-2.5" />{m.source}{m.ai_score && <span className="ml-1">• Score: {m.ai_score}/100</span>}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] p-2.5 rounded-xl text-xs bg-rmi-bg/50 text-rmi-gray rounded-bl-none border border-rmi-purple/10">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin inline mr-2" />
|
||||
Analyzing with {MODES.find(m => m.id === activeMode)?.label || 'General'} AI...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Quick Replies */}
|
||||
<div className="flex gap-1 p-2 overflow-x-auto border-t border-rmi-purple/10">
|
||||
{quickReplies.map(q => (
|
||||
<button key={q} onClick={() => sendMessage(q)} disabled={loading} className="px-2 py-1 rounded-full bg-rmi-purple/10 text-[9px] text-rmi-gray whitespace-nowrap hover:bg-rmi-cyan/10 hover:text-rmi-cyan transition-colors disabled:opacity-50">{q}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-rmi-purple/20">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && sendMessage(input)}
|
||||
placeholder={`Ask ${MODES.find(m => m.id === activeMode)?.label || 'General'} AI...`}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-rmi-bg border-rmi-purple/20 text-rmi-white text-xs h-8"
|
||||
/>
|
||||
<Button onClick={() => sendMessage(input)} disabled={loading} className="h-8 px-3 bg-rmi-cyan hover:bg-rmi-cyan-glow text-rmi-bg">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (inline) return chatContent;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div initial={{ opacity: 0, y: 20, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 20, scale: 0.95 }} className="fixed bottom-4 right-4 z-50 w-full max-w-lg">
|
||||
{chatContent}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
185
src/components/casefile/SaveAsCaseButton.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* SaveAsCaseButton — the "Save as Case" button + modal.
|
||||
*
|
||||
* Use on RugMapsPage, TokenDetailPage, and any scan result screen.
|
||||
* Click → modal asks for title + description → generate case_id →
|
||||
* save to localStorage → copy public URL to clipboard.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Bookmark, X, Check, Copy, ExternalLink, Sparkles, Loader2, Plus } from 'lucide-react';
|
||||
import { saveCase, buildCaseFromScan, type CaseData } from '@/services/caseFileService';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface SaveAsCaseButtonProps {
|
||||
chain: string;
|
||||
address: string;
|
||||
symbol?: string;
|
||||
name?: string;
|
||||
riskScore: number;
|
||||
findings?: string[];
|
||||
keyFinding?: string;
|
||||
holders?: { address: string; label?: string; pct?: number }[];
|
||||
txs?: string[];
|
||||
bubbleSvg?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZE_CLS = { sm: 'h-7 px-2 text-[10px]', md: 'h-8 px-3 text-[11px]', lg: 'h-10 px-4 text-[12px]' };
|
||||
|
||||
export function SaveAsCaseButton(p: SaveAsCaseButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className={`${SIZE_CLS[p.size || 'md']} rounded font-bold uppercase tracking-wider inline-flex items-center gap-1.5 bg-[#FFB800] hover:bg-[#E0A000] text-black ${p.className || ''}`}
|
||||
title="Save as Case — public, shareable URL">
|
||||
<Bookmark className="w-3.5 h-3.5" /> Save as Case
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && <SaveCaseModal onClose={() => setOpen(false)} {...p} />}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveCaseModal({
|
||||
onClose, chain, address, symbol, name, riskScore, findings, keyFinding, holders, txs, bubbleSvg,
|
||||
}: SaveAsCaseButtonProps) {
|
||||
const { user } = useAuth() as any; // optional — falls back to anonymous
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState(`${symbol || 'Token'} — Risk ${riskScore}/100`);
|
||||
const [description, setDescription] = useState(keyFinding || '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [saved, setSaved] = useState<CaseData | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const handle = user?.user_metadata?.handle || user?.email?.split('@')[0] || 'anonymous';
|
||||
const c = await saveCase(buildCaseFromScan({
|
||||
creator: handle,
|
||||
chain, address, symbol, name, title, description,
|
||||
risk_score: riskScore,
|
||||
findings, key_finding: keyFinding,
|
||||
holders, txs, bubble_svg: bubbleSvg,
|
||||
}));
|
||||
setSaved(c);
|
||||
} catch (e) {
|
||||
console.error('saveCase failed', e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!saved) return;
|
||||
const url = `${window.location.origin}/case/${saved.id}`;
|
||||
try { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch {}
|
||||
};
|
||||
|
||||
const viewCase = () => { if (saved) { onClose(); navigate(`/case/${saved.id}`); } };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, y: 8 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.95, y: 8 }}
|
||||
className="w-full max-w-lg glass rounded-2xl border border-white/10 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded bg-[#FFB800]/20">
|
||||
<Bookmark className="w-4 h-4 text-[#FFB800]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-white">Save as Case</h2>
|
||||
<p className="text-[10px] text-[#5C6080]">Public, shareable URL — anyone with the link can view</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[#1A2234] text-[#5C6080] hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!saved ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-[#5C6080] uppercase tracking-wider font-bold">Title</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full mt-1 bg-[#0E1525] text-sm text-white rounded-lg px-3 py-2 border border-white/10 focus:outline-none focus:border-[#FFB800]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-[#5C6080] uppercase tracking-wider font-bold">Description (optional)</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3}
|
||||
placeholder="What did you find? Why is this case worth sharing?"
|
||||
className="w-full mt-1 bg-[#0E1525] text-sm text-white rounded-lg px-3 py-2 border border-white/10 focus:outline-none focus:border-[#FFB800] resize-none" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-[#0A0E1A] border border-white/5 text-[10px] text-[#5C6080]">
|
||||
<Sparkles className="w-3.5 h-3.5 text-[#FFB800] flex-shrink-0" />
|
||||
<span>Snapshot includes: risk score, {findings?.length || 0} findings, {holders?.length || 0} wallets, {txs?.length || 0} tx hashes. All immutable.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<button onClick={onClose} className="flex-1 h-10 rounded-lg text-[12px] font-bold text-[#9DA0B0] hover:text-white border border-white/10 hover:bg-white/5">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={busy || !title.trim()}
|
||||
className="flex-1 h-10 rounded-lg text-[12px] font-bold text-black flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
style={{ background: 'linear-gradient(135deg, #FFB800 0%, #FF8A00 100%)' }}>
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
{busy ? 'Saving…' : 'Save case'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/30 flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-[12px] text-emerald-300 font-bold">Case saved successfully</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-[#5C6080] uppercase tracking-wider font-bold">Public URL</label>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<input readOnly value={`${typeof window !== 'undefined' ? window.location.origin : ''}/case/${saved.id}`}
|
||||
className="flex-1 bg-[#0E1525] text-[11px] font-mono text-[#8B5CF6] rounded-lg px-3 py-2 border border-white/10" />
|
||||
<button onClick={copyUrl}
|
||||
className="h-9 px-3 rounded-lg bg-[#1A2234] hover:bg-[#243049] text-white text-[11px] font-bold inline-flex items-center gap-1">
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-[#5C6080]">
|
||||
Case ID: <span className="font-mono text-[#9DA0B0]">{saved.id}</span> · Snapshot: <span className="font-mono text-[#9DA0B0]">{saved.snapshot_hash.slice(0, 16)}…</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<button onClick={onClose} className="flex-1 h-10 rounded-lg text-[12px] font-bold text-[#9DA0B0] hover:text-white border border-white/10 hover:bg-white/5">
|
||||
Close
|
||||
</button>
|
||||
<button onClick={viewCase}
|
||||
className="flex-1 h-10 rounded-lg text-[12px] font-bold text-white flex items-center justify-center gap-2"
|
||||
style={{ background: 'linear-gradient(135deg, #8B5CF6 0%, #FFB800 100%)' }}>
|
||||
<ExternalLink className="w-3.5 h-3.5" /> View case
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveAsCaseButton;
|
||||
212
src/components/home/IntelligenceWidgets.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* DeFi TVL Dashboard — Shows chain TVL data from DeFiLlama
|
||||
* Prediction Markets Intel — Shows Polymarket odds
|
||||
* CT Rundown — Crypto Twitter intelligence feed
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
TrendingUp, TrendingDown, BarChart3, Brain, Target,
|
||||
ArrowRight, Globe, Zap, Radio, MessageSquare, Flame,
|
||||
Users, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
|
||||
const fmtUsd = (v: any) => {
|
||||
const n = Number(v);
|
||||
if (!n || isNaN(n)) return '$0';
|
||||
if (n >= 1e9) return `$${(n/1e9).toFixed(2)}B`;
|
||||
if (n >= 1e6) return `$${(n/1e6).toFixed(1)}M`;
|
||||
if (n >= 1e3) return `$${(n/1e3).toFixed(1)}K`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
};
|
||||
|
||||
// ── DeFi TVL Dashboard ──
|
||||
export function DefiTvlDashboard() {
|
||||
const { data: tvlData } = useDataBus('defillama_chains', {});
|
||||
const { data: tvlGlobal } = useDataBus('defillama_tvl', {});
|
||||
|
||||
const chains = (() => {
|
||||
const raw = tvlData as any;
|
||||
const data = raw?.data || raw;
|
||||
const arr = Array.isArray(data) ? data : data?.chains || data?.items || [];
|
||||
return arr.slice(0, 10);
|
||||
})();
|
||||
|
||||
const totalTvl = (() => {
|
||||
const raw = tvlGlobal as any;
|
||||
const data = raw?.data || raw;
|
||||
return data?.totalTvl || data?.total_tvl || data?.total || 0;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="bg-[#0C1220] border border-[#1a2332] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-sm font-semibold text-[#F1F1F6]">DeFi TVL</h3>
|
||||
</div>
|
||||
{totalTvl > 0 && (
|
||||
<span className="text-xs font-mono text-purple-300">
|
||||
Total: {fmtUsd(totalTvl)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chains.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{chains.map((chain: any, i: number) => {
|
||||
const name = chain.name || chain.chain || '?';
|
||||
const tvl = chain.tvl || chain.totalLiquidity || 0;
|
||||
const change = chain.change_1d || chain.tvlChange1d || 0;
|
||||
const isUp = Number(change) >= 0;
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between px-3 py-2 rounded-xl bg-[#111827] hover:bg-[#141c2e] transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-3.5 h-3.5 text-[#5B7FA5]" />
|
||||
<span className="text-xs text-[#F1F1F6] font-medium">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-[#9DA0B0]">{fmtUsd(tvl)}</span>
|
||||
{change !== 0 && (
|
||||
<span className={`text-xs font-mono font-semibold ${isUp ? 'text-[#06D6A0]' : 'text-[#FF3366]'}`}>
|
||||
{isUp ? '+' : ''}{Number(change).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-[#5B7FA5] text-xs">
|
||||
Loading DeFi TVL data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to="/market-overview" className="mt-3 text-[10px] text-[#8B5CF6] hover:text-[#22D3EE] flex items-center gap-1 transition-colors">
|
||||
Full TVL analysis <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Prediction Markets Intel ──
|
||||
export function PredictionMarketsIntel() {
|
||||
const { data: pmData } = useDataBus('prediction_markets', { limit: 5 });
|
||||
|
||||
const markets = (() => {
|
||||
const raw = pmData as any;
|
||||
const data = raw?.data || raw;
|
||||
const arr = data?.markets || data?.items || [];
|
||||
return Array.isArray(arr) ? arr.slice(0, 5) : [];
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="bg-[#0C1220] border border-[#1a2332] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-[#F59E0B]" />
|
||||
<h3 className="text-sm font-semibold text-[#F1F1F6]">Prediction Markets</h3>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#5B7FA5] bg-[#111827] px-2 py-0.5 rounded-full">POLYMARKET</span>
|
||||
</div>
|
||||
|
||||
{markets.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{markets.map((m: any, i: number) => {
|
||||
const question = m.question || m.title || m.slug || 'Unknown market';
|
||||
const prob = m.probability || m.outcomePrices?.[0] || m.yes_price || 0;
|
||||
const probPct = Number(prob) > 1 ? Number(prob) : Number(prob) * 100;
|
||||
const volume = m.volume || m.totalLiquidity || 0;
|
||||
return (
|
||||
<div key={i} className="p-3 rounded-xl bg-[#111827] border border-[#1a2332] hover:border-[#F59E0B]/20 transition-all">
|
||||
<p className="text-xs text-[#9DA0B0] leading-relaxed mb-2">{question}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-2 rounded-full bg-[#1a2332] overflow-hidden">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#06D6A0] to-[#8B5CF6]"
|
||||
style={{ width: `${Math.min(Math.max(probPct, 5), 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold text-[#F1F1F6]">
|
||||
{probPct.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{volume > 0 && (
|
||||
<span className="text-[10px] font-mono text-[#5B7FA5]">{fmtUsd(volume)} vol</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-[#5B7FA5] text-xs">
|
||||
Loading prediction markets...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to="/market-intel" className="mt-3 text-[10px] text-[#8B5CF6] hover:text-[#22D3EE] flex items-center gap-1 transition-colors">
|
||||
Full market intel <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CT Rundown Feed ──
|
||||
export function CtRundownFeed() {
|
||||
const { data: ctData } = useDataBus('ct_rundown', { limit: 5 });
|
||||
|
||||
const items = (() => {
|
||||
const raw = ctData as any;
|
||||
const data = raw?.data || raw;
|
||||
const arr = data?.posts || data?.items || data?.tweets || data?.rundown || [];
|
||||
return Array.isArray(arr) ? arr.slice(0, 5) : [];
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="bg-[#0C1220] border border-[#1a2332] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-[#22D3EE]" />
|
||||
<h3 className="text-sm font-semibold text-[#F1F1F6]">CT Rundown</h3>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#5B7FA5] bg-[#111827] px-2 py-0.5 rounded-full">150+ ACCOUNTS</span>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{items.map((item: any, i: number) => {
|
||||
const handle = item.handle || item.username || item.author || '?';
|
||||
const text = item.text || item.content || item.summary || item.title || '';
|
||||
const time = item.timestamp || item.created_at || '';
|
||||
const timeAgo = time ? (() => {
|
||||
try {
|
||||
const diff = Date.now() - new Date(time).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
return m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m/60)}h`;
|
||||
} catch { return ''; }
|
||||
})() : '';
|
||||
return (
|
||||
<div key={i} className="p-2.5 rounded-xl bg-[#111827] hover:bg-[#141c2e] transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-bold text-[#22D3EE]">@{handle}</span>
|
||||
{timeAgo && <span className="text-[9px] text-[#3A3E58]">{timeAgo}</span>}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#9DA0B0] leading-relaxed">{text.slice(0, 120)}{text.length > 120 ? '...' : ''}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-[#5B7FA5] text-xs">
|
||||
Monitoring CT accounts...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to="/feed" className="mt-3 text-[10px] text-[#8B5CF6] hover:text-[#22D3EE] flex items-center gap-1 transition-colors">
|
||||
Full CT intel <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/home/KOLLeaderboardSection.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { Users, Loader2, TrendingUp } from 'lucide-react';
|
||||
|
||||
export function KOLLeaderboardSection() {
|
||||
const { data, isLoading, error, refetch } = useDataBus('kol_leaderboard', { limit: 5 });
|
||||
const raw = (data as any)?.data || data || {};
|
||||
const leaderboard = raw?.leaderboard || raw?.items || [];
|
||||
const total = raw?.total_tracked || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-[#8B5CF6]" />
|
||||
<h2 className="text-sm font-semibold text-[#F1F1F6]">KOL Leaderboard</h2>
|
||||
{total > 0 && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||
{total} tracked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2.5">
|
||||
{isLoading ? (
|
||||
<div className="col-span-2 bg-white/[0.02] border border-white/[0.06] rounded-lg p-4 text-center">
|
||||
<Loader2 className="w-4 h-4 text-[#8B5CF6] animate-spin mx-auto mb-1.5" />
|
||||
<p className="text-[11px] text-[#9DA0B0]">Loading leaderboard...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="col-span-2 bg-white/[0.02] border border-red-500/10 rounded-lg p-4 text-center">
|
||||
<p className="text-xs text-red-400 mb-2">Failed to load leaderboard</p>
|
||||
<button onClick={() => refetch()} className="text-[10px] px-3 py-1 rounded bg-white/[0.04] text-[#9DA0B0] hover:text-white transition-colors">Retry</button>
|
||||
</div>
|
||||
) : leaderboard.length > 0 ? leaderboard.slice(0, 4).map((k: any, i: number) => (
|
||||
<div key={i} className="bg-white/[0.02] border border-white/[0.06] rounded-lg p-3 hover:border-purple-500/20 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-bold text-[#8B5CF6] w-5 text-center">#{i + 1}</span>
|
||||
<span className="text-xs font-semibold text-[#F1F1F6] line-clamp-1">
|
||||
{k.handle || k.name || k.username || 'Unknown'}
|
||||
</span>
|
||||
{k.accuracy && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 ml-auto shrink-0 font-mono">
|
||||
{Number(k.accuracy).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#9DA0B0] line-clamp-2">
|
||||
{k.bio || k.description || k.category || 'Crypto KOL'}
|
||||
</p>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="col-span-2 bg-white/[0.02] border border-white/[0.06] rounded-lg p-4 text-center">
|
||||
<p className="text-[11px] text-[#9DA0B0]">KOL tracking active — data populating</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
src/components/home/PremiumWidgets.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* Premium Live Data Widgets — added to HomePage for richer real-time intelligence
|
||||
* Components: SentimentDashboard, WhaleAlertTicker, LiveThreatCounter, PremiumFeatureCard
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
TrendingUp, TrendingDown, Zap, AlertTriangle, Shield,
|
||||
ArrowRight, Lock, Sparkles, Flame, Eye, Radio, DollarSign,
|
||||
Brain, Activity, Skull, Fish, RefreshCw, Crown, BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
|
||||
// ── Sentiment Dashboard ──
|
||||
export function SentimentDashboard() {
|
||||
const { data: sentiment } = useDataBus('fear_greed', {});
|
||||
const { data: polymarket } = useDataBus('prediction_markets', { limit: 3 });
|
||||
|
||||
const fg = (sentiment as any)?.data || (sentiment as any) || {};
|
||||
const marketsArr = (polymarket as any)?.markets || (polymarket as any)?.items || [];
|
||||
const markets = Array.isArray(marketsArr) ? marketsArr.slice(0, 3) : [];
|
||||
|
||||
const fgValue = fg.value ?? fg.score ?? 50;
|
||||
const fgLabel = fgValue > 75 ? 'Extreme Greed' : fgValue > 55 ? 'Greed' : fgValue > 45 ? 'Neutral' : fgValue > 25 ? 'Fear' : 'Extreme Fear';
|
||||
const fgColor = fgValue > 55 ? '#06D6A0' : fgValue > 45 ? '#F59E0B' : '#FF3366';
|
||||
const fgEmoji = fgValue > 75 ? '🤑' : fgValue > 55 ? '😀' : fgValue > 45 ? '😐' : fgValue > 25 ? '😨' : '😱';
|
||||
|
||||
return (
|
||||
<div className="bg-[#0C1220] border border-[#1a2332] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="text-sm font-semibold text-[#F1F1F6]">Market Sentiment</h3>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#5B7FA5] bg-[#111827] px-2 py-0.5 rounded-full">LIVE</span>
|
||||
</div>
|
||||
|
||||
{/* Fear & Greed */}
|
||||
<div className="mb-4 p-3 rounded-xl" style={{ background: `${fgColor}10`, border: `1px solid ${fgColor}20` }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[11px] text-[#5B7FA5] uppercase tracking-wider">Fear & Greed Index</span>
|
||||
<span className="text-lg">{fgEmoji}</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-3xl font-bold text-[#F1F1F6]" style={{ color: fgColor }}>{fgValue}</span>
|
||||
<span className="text-xs pb-1 text-[#9DA0B0]">{fgLabel}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-[#1a2332] overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-700"
|
||||
style={{ width: `${fgValue}%`, background: `linear-gradient(90deg, #FF3366, #F59E0B, #06D6A0)` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Polymarket top markets */}
|
||||
{markets.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] text-[#5B7FA5] uppercase tracking-wider">Prediction Markets</span>
|
||||
{markets.map((m: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs p-2 rounded-lg hover:bg-[#111827] transition-colors">
|
||||
<span className="text-[#9DA0B0] truncate flex-1 mr-2">{m.question || m.title}</span>
|
||||
<span className="text-[#06D6A0] font-mono font-semibold">
|
||||
{m.probability ? `${(m.probability * 100).toFixed(0)}%` : m.price || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to="/market-intel" className="mt-3 text-[10px] text-[#8B5CF6] hover:text-[#22D3EE] flex items-center gap-1 transition-colors">
|
||||
Full sentiment analysis <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Whale Alert Ticker ──
|
||||
export function WhaleAlertTicker() {
|
||||
const { data: whaleData } = useDataBus('whale_alerts', { limit: 5 });
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
const alerts = (() => {
|
||||
const raw = whaleData as any;
|
||||
const data = raw?.data || raw;
|
||||
// Try multiple response shapes
|
||||
const arr = Array.isArray(data) ? data :
|
||||
data?.whales?.slice?.(0, 5) ||
|
||||
data?.alerts?.slice?.(0, 5) ||
|
||||
data?.items?.slice?.(0, 5) ||
|
||||
[];
|
||||
return arr.slice(0, 5);
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (alerts.length === 0) return;
|
||||
const t = setInterval(() => setCurrent(c => (c + 1) % alerts.length), 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [alerts.length]);
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return (
|
||||
<div className="bg-[#0C1220] border border-[#1a2332] rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Fish className="w-5 h-5 text-cyan-400" />
|
||||
<h3 className="text-sm font-semibold text-[#F1F1F6]">Whale Activity</h3>
|
||||
</div>
|
||||
<div className="text-center py-4 text-[#5B7FA5] text-xs">Monitoring whale movements...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const alert = alerts[current];
|
||||
const isBuy = alert?.type === 'buy' || alert?.direction === 'buy' || alert?.action === 'buy';
|
||||
const valueStr = alert?.value_usd ? `$${(Number(alert.value_usd) / 1e6).toFixed(2)}M` :
|
||||
alert?.amount ? `${alert.amount} ${alert.token || 'USD'}` : 'Large transfer';
|
||||
const label = alert?.wallet_label || alert?.from_label || alert?.entity ||
|
||||
(alert?.from_wallet || alert?.address || 'Unknown').toString().slice(0, 10) + '...';
|
||||
const chain = alert?.chain || alert?.network || 'ETH';
|
||||
const timeAgo = alert?.timestamp ? (() => {
|
||||
const diff = Date.now() - new Date(alert.timestamp).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : `${Math.floor(m/60)}h ago`;
|
||||
})() : '';
|
||||
|
||||
return (
|
||||
<div className="bg-[#0C1220] border border-[#1a2332] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fish className="w-5 h-5 text-cyan-400" />
|
||||
<h3 className="text-sm font-semibold text-[#F1F1F6]">Whale Activity</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-400" style={{ boxShadow: '0 0 4px rgba(34,211,238,0.3)' }} />
|
||||
<span className="text-[10px] text-cyan-400">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-xl bg-[#111827] border border-[#1a2332] min-h-[80px] transition-all">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] text-[#5B7FA5] uppercase">{chain}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||
isBuy ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
|
||||
}`}>{isBuy ? 'BUY' : 'SELL'}</span>
|
||||
{timeAgo && <span className="text-[9px] text-[#3A3E58]">{timeAgo}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-[#9DA0B0] leading-relaxed">{label}</p>
|
||||
<div className="mt-1 text-xs font-mono text-[#06D6A0]">{valueStr}</div>
|
||||
</div>
|
||||
|
||||
{/* Dots */}
|
||||
<div className="flex justify-center gap-1.5 mt-3">
|
||||
{alerts.map((_: any, i: number) => (
|
||||
<button key={i} onClick={() => setCurrent(i)}
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
i === current ? 'bg-cyan-400 w-4' : 'bg-[#1a2332] hover:bg-[#5B7FA5]'
|
||||
}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Live Threat Counter ──
|
||||
export function LiveThreatCounter() {
|
||||
const { data: alerts } = useDataBus('alerts', { limit: 1 });
|
||||
const count = (alerts as any)?.count ?? (alerts as any)?.total ?? 88;
|
||||
const active = (alerts as any)?.active ?? 12;
|
||||
|
||||
return (
|
||||
<div className="bg-[#0C1220] border border-[#1a2332] rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Skull className="w-5 h-5 text-red-400" />
|
||||
<h3 className="text-sm font-semibold text-[#F1F1F6]">Live Threat Monitor</h3>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-400" style={{ boxShadow: '0 0 4px rgba(239,68,68,0.3)' }} />
|
||||
<span className="text-[10px] text-red-400">{active} ACTIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div className="p-3 rounded-xl bg-red-500/5 border border-red-500/10">
|
||||
<div className="text-2xl font-bold text-red-400">{count}</div>
|
||||
<div className="text-[10px] text-[#5B7FA5] mt-1">Threats</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-yellow-500/5 border border-yellow-500/10">
|
||||
<div className="text-2xl font-bold text-yellow-400">{active}</div>
|
||||
<div className="text-[10px] text-[#5B7FA5] mt-1">Active</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-green-500/5 border border-green-500/10">
|
||||
<div className="text-2xl font-bold text-green-400">{Math.max(0, count - active)}</div>
|
||||
<div className="text-[10px] text-[#5B7FA5] mt-1">Resolved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to="/alerts" className="mt-3 text-[10px] text-[#8B5CF6] hover:text-[#22D3EE] flex items-center gap-1 transition-colors">
|
||||
View all threats <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Premium Feature Card (Upsell) ──
|
||||
export function PremiumFeatureCard({ title, description, icon: Icon, features, price }: {
|
||||
title: string; description: string; icon: any; features: string[]; price: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-[#0C1220] to-[#111827] border border-[#1a2332] rounded-2xl p-6 group hover:border-purple-500/30 transition-all duration-300">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-500/10 flex items-center justify-center mb-4 group-hover:bg-purple-500/20 transition-colors">
|
||||
<Icon className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<h4 className="text-[15px] font-semibold text-[#F1F1F6] mb-2">{title}</h4>
|
||||
<p className="text-xs text-[#9DA0B0] mb-4 leading-relaxed">{description}</p>
|
||||
<ul className="space-y-1.5 mb-4">
|
||||
{features.map((f, i) => (
|
||||
<li key={i} className="text-[11px] text-[#5B7FA5] flex items-center gap-1.5">
|
||||
<div className="w-1 h-1 rounded-full bg-purple-400/50" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-[#F1F1F6]">{price}</span>
|
||||
<Link to="/pricing" className="flex items-center gap-1 text-[11px] text-purple-400 hover:text-purple-300 font-semibold transition-colors">
|
||||
<Lock className="w-3 h-3" /> Unlock
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Premium Value Proposition (Why Subscribe) ──
|
||||
export function PremiumValueProposition() {
|
||||
const benefits = [
|
||||
{ icon: Zap, title: "Real-Time Alerts", desc: "Get notified within seconds of whale movements, contract deployments, and rug pulls before they happen." },
|
||||
{ icon: Shield, title: "Advanced Scam Detection", desc: "Access our proprietary multi-chain honeypot and bundle detection algorithms not available to free users." },
|
||||
{ icon: BarChart3, title: "Institutional Analytics", desc: "Deep wallet forensics, historical cluster analysis, and professional-grade charting tools." },
|
||||
{ icon: Brain, title: "AI-Powered Insights", desc: "Unlimited access to Agent MUNCH for deep contract audits, wallet tracing, and market intelligence." },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-purple-900/10 to-[#0C1220] border border-purple-500/20 rounded-2xl p-6 relative overflow-hidden">
|
||||
{/* Background glow */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Crown className="w-5 h-5 text-amber-400" />
|
||||
<h3 className="text-lg font-bold text-white">Upgrade to Premium</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
Join thousands of traders who trust RMI to protect their portfolios.
|
||||
Stop reacting to rugs — start predicting them.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
{benefits.map((b, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-xl bg-black/20 border border-white/5">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center shrink-0">
|
||||
<b.icon className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white mb-1">{b.title}</h4>
|
||||
<p className="text-[11px] text-gray-400 leading-relaxed">{b.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-500 hover:to-purple-400 text-white font-semibold rounded-xl transition-all shadow-lg shadow-purple-500/20"
|
||||
>
|
||||
<Crown className="w-4 h-4" />
|
||||
View Premium Plans
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DataBus Live Badge ──
|
||||
export function LiveDataBadge({ channel, className = '' }: { channel: string; className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-cyan-400" style={{ boxShadow: '0 0 6px rgba(6,214,160,0.5)' }} />
|
||||
<span className="text-[10px] text-[#06D6A0] font-medium">LIVE</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/home/WhaleAlertsSection.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { Fish, ArrowUpRight, Loader2, RefreshCw } from 'lucide-react';
|
||||
|
||||
export function WhaleAlertsSection() {
|
||||
const { data, isLoading, error, refetch } = useDataBus('whale_alerts', { limit: 5 });
|
||||
const raw = (data as any)?.data || data || {};
|
||||
const movements = raw?.whale_movements || raw?.items || [];
|
||||
const total = raw?.total_detected || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fish className="w-4 h-4 text-[#06B6D4]" />
|
||||
<h2 className="text-sm font-semibold text-[#F1F1F6]">Whale Activity</h2>
|
||||
{total > 0 && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
|
||||
{total} tracked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2.5">
|
||||
{isLoading ? (
|
||||
<div className="col-span-2 bg-white/[0.02] border border-white/[0.06] rounded-lg p-4 text-center">
|
||||
<Loader2 className="w-4 h-4 text-[#06B6D4] animate-spin mx-auto mb-1.5" />
|
||||
<p className="text-[11px] text-[#9DA0B0]">Tracking whale movements...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="col-span-2 bg-white/[0.02] border border-red-500/10 rounded-lg p-4 text-center">
|
||||
<p className="text-xs text-red-400 mb-2">Failed to load whale data</p>
|
||||
<button onClick={() => refetch()} className="text-[10px] px-3 py-1 rounded bg-white/[0.04] text-[#9DA0B0] hover:text-white transition-colors">Retry</button>
|
||||
</div>
|
||||
) : movements.length > 0 ? movements.slice(0, 4).map((m: any, i: number) => (
|
||||
<div key={i} className="bg-white/[0.02] border border-white/[0.06] rounded-lg p-3 hover:border-cyan-500/20 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className="w-2 h-2 rounded-full shrink-0 bg-cyan-400" />
|
||||
<span className="text-xs font-semibold text-[#F1F1F6] line-clamp-1">
|
||||
{m.token_symbol || m.token || 'Unknown'} — {m.type || m.action || 'Movement'}
|
||||
</span>
|
||||
{m.amount_usd && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/[0.04] text-[#9DA0B0] ml-auto shrink-0 font-mono">
|
||||
${Number(m.amount_usd).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#9DA0B0] line-clamp-2">
|
||||
{m.description || m.summary || `${m.from || 'Unknown'} → ${m.to || 'Unknown'}`}
|
||||
</p>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="col-span-2 bg-white/[0.02] border border-white/[0.06] rounded-lg p-4 text-center">
|
||||
<p className="text-[11px] text-[#9DA0B0]">No recent whale activity detected</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/intel/DefiExploitFeed.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { ShieldOff, ExternalLink } from 'lucide-react';
|
||||
|
||||
export default function DefiExploitFeed() {
|
||||
const { data, isLoading } = useDataBus('defi_hacks');
|
||||
const extract = (d: any) => { const raw = d?.data || d; return (Array.isArray(raw) ? raw : raw?.hacks || raw?.exploits || raw?.items || []) as any[]; };
|
||||
const hacks = extract(data).slice(0, 12);
|
||||
const fmtAmt = (v: any) => { const n=Number(v); if(!n) return '$0'; return n>1e6?'$'+(n/1e6).toFixed(1)+'M':n>1e3?'$'+(n/1e3).toFixed(0)+'K':'$'+n.toFixed(0); };
|
||||
const sevColors: Record<string,string> = {critical:'#FF0040',high:'#FF3366',medium:'#FFB800',low:'#06D6A0'};
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ShieldOff className="w-4 h-4 text-[#FF3366]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">DeFi Exploits</h3>
|
||||
</div>
|
||||
{isLoading ? <div className="space-y-2">{[1,2,3].map(i=><div key={i} className="h-8 bg-[#1A2234] rounded animate-pulse"/>)}</div> :
|
||||
<div className="space-y-0.5 max-h-[300px] overflow-y-auto">
|
||||
{hacks.map((h:any,i:number)=>(
|
||||
<div key={i} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-[#1A2234] text-[11px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{backgroundColor:sevColors[h.severity||'medium']||'#FFB800'}}/>
|
||||
<span className="text-[#E0E0E0] max-w-[100px] truncate">{h.protocol||h.name||'?'}</span>
|
||||
<span className="text-[10px] text-[#5C6080]">{h.chain||''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-[#5C6080]">{h.type||h.exploit_type||''}</span>
|
||||
<span className="font-mono font-bold text-[#FF3366] text-[10px]">{fmtAmt(h.amount_lost||h.loss||h.amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/intel/DeployerReputation.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useState } from 'react';
|
||||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { Search, Shield, Skull, Calendar, Activity } from 'lucide-react';
|
||||
|
||||
export default function DeployerReputation() {
|
||||
const [addr, setAddr] = useState('');
|
||||
const [searchAddr, setSearchAddr] = useState('');
|
||||
const { data, isLoading } = useDataBus('dev_reputation', { address: searchAddr }, { enabled: !!searchAddr });
|
||||
|
||||
const d = (data as any)?.data || data || {};
|
||||
const score = Number(d.trust_score || d.score || 0);
|
||||
const getColor = (s: number) => s>=70?'#06D6A0':s>=40?'#FFB800':'#FF3366';
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-[#8B5CF6]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">Deployer Reputation</h3>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input value={addr} onChange={e=>setAddr(e.target.value)} onKeyDown={e=>e.key==='Enter'&&setSearchAddr(addr)}
|
||||
placeholder="Deployer address..." className="flex-1 bg-[#0D1117] border border-white/5 rounded-lg px-3 py-2 text-xs text-[#E0E0E0] font-mono outline-none focus:border-[#8B5CF6]/50"/>
|
||||
<button onClick={()=>setSearchAddr(addr)} className="px-3 py-2 rounded-lg bg-[#8B5CF6]/20 text-[#8B5CF6] text-xs font-semibold flex items-center gap-1">
|
||||
<Search className="w-3 h-3"/>Scan
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? <div className="h-20 bg-[#1A2234] rounded animate-pulse"/> :
|
||||
searchAddr ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-[#5C6080] uppercase">Trust Score</span>
|
||||
<span className="text-lg font-mono font-bold" style={{color:getColor(score)}}>{score}/100</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[{l:'Tokens Deployed',v:d.tokens_deployed||d.tokens||0},{l:'Rug Count',v:d.rug_count||d.rugs||0},{l:'Active Days',v:d.active_days||d.days||0},{l:'Avg Token Life',v:(d.avg_token_life||d.avg_life||'?')}].map((m,i)=>(
|
||||
<div key={i} className="bg-[#0D1117] rounded-lg p-2">
|
||||
<div className="text-[10px] text-[#5C6080]">{m.l}</div>
|
||||
<div className="text-sm font-mono font-bold text-[#E0E0E0]">{String(m.v)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <p className="text-xs text-[#5C6080] text-center py-4">Enter a deployer address to check reputation</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/intel/FreshWalletBotFarm.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { UserPlus, Network } from 'lucide-react';
|
||||
|
||||
export default function FreshWalletBotFarm() {
|
||||
const { data: fw, isLoading: fl } = useDataBus('fresh_wallet_analysis');
|
||||
const { data: bf, isLoading: bl } = useDataBus('bot_farm_detect');
|
||||
|
||||
const extract = (d: any) => { const raw = d?.data || d; return (Array.isArray(raw) ? raw : raw?.wallets || raw?.items || raw?.farms || []) as any[]; };
|
||||
const wallets = extract(fw).slice(0, 10);
|
||||
const farms = extract(bf).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserPlus className="w-4 h-4 text-[#06D6A0]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">Fresh Wallets</h3>
|
||||
</div>
|
||||
{fl ? <div className="space-y-2">{[1,2,3].map(i=><div key={i} className="h-8 bg-[#1A2234] rounded animate-pulse"/>)}</div> :
|
||||
<div className="space-y-0.5 max-h-[200px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[1fr_50px_70px_60px] gap-2 px-2 py-1 text-[10px] text-[#5C6080] uppercase border-b border-white/5">
|
||||
<span>Wallet</span><span>Age</span><span>First Tx</span><span>Bot%</span>
|
||||
</div>
|
||||
{wallets.map((w:any,i:number)=>(
|
||||
<div key={i} className="grid grid-cols-[1fr_50px_70px_60px] gap-2 px-2 py-1.5 rounded hover:bg-[#1A2234] items-center text-[11px]">
|
||||
<span className="font-mono text-[#E0E0E0] truncate">{String(w.address||w.wallet||'?').slice(0,10)}...</span>
|
||||
<span className="text-[#8EA9C1]">{String(w.age||w.age_hours||'?')}</span>
|
||||
<span className="text-[10px] text-[#5C6080]">{w.first_tx_type||w.activity||'?'}</span>
|
||||
<span className={`font-mono font-bold text-[10px] ${(Number(w.bot_probability||w.bot_score||0))>60?'text-[#FF3366]':(Number(w.bot_probability||0))>30?'text-[#FFB800]':'text-[#06D6A0]'}`}>{(Number(w.bot_probability||w.bot_score||0)).toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
{farms.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-2"><Network className="w-3 h-3 text-[#FF3366]"/><span className="text-[10px] text-[#8EA9C1] uppercase">Bot Farms Detected</span></div>
|
||||
{farms.map((f:any,i:number)=>(
|
||||
<div key={i} className="flex items-center justify-between text-[10px] py-1">
|
||||
<span className="text-[#E0E0E0]">{f.label||f.name||`Farm #${i+1}`}</span>
|
||||
<span className="text-[#FF3366] font-mono">{f.wallet_count||f.size||0} wallets</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/intel/InsiderDetection.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from 'react';
|
||||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { Eye, Crosshair, Package } from 'lucide-react';
|
||||
|
||||
type Tab = 'insider' | 'sniper' | 'bundle';
|
||||
|
||||
export default function InsiderDetection() {
|
||||
const [tab, setTab] = useState<Tab>('insider');
|
||||
const { data: insiderData, isLoading: il } = useDataBus('insider_detect');
|
||||
const { data: sniperData, isLoading: sl } = useDataBus('sniper_detect');
|
||||
const { data: bundleData, isLoading: bl } = useDataBus('bundle_detect');
|
||||
|
||||
const extract = (d: any) => {
|
||||
const raw = d?.data || d;
|
||||
return (Array.isArray(raw) ? raw : raw?.wallets || raw?.items || raw?.entries || []) as any[];
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any; data: any[]; loading: boolean }[] = [
|
||||
{ id: 'insider', label: 'Insider Wallets', icon: Eye, data: extract(insiderData), loading: il },
|
||||
{ id: 'sniper', label: 'Sniper Bots', icon: Crosshair, data: extract(sniperData), loading: sl },
|
||||
{ id: 'bundle', label: 'Bundle Activity', icon: Package, data: extract(bundleData), loading: bl },
|
||||
];
|
||||
|
||||
const active = tabs.find(t => t.id === tab)!;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex gap-1 mb-3">
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5 ${
|
||||
tab === t.id ? 'bg-[#8B5CF6]/20 text-[#8B5CF6]' : 'text-[#5C6080] hover:text-[#9DA0B0]'
|
||||
}`}>
|
||||
<t.icon className="w-3 h-3" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{active.loading ? (
|
||||
<div className="space-y-2">{[1,2,3].map(i=><div key={i} className="h-10 bg-[#1A2234] rounded animate-pulse"/>)}</div>
|
||||
) : active.data.length === 0 ? (
|
||||
<p className="text-xs text-[#5C6080] py-4 text-center">No {active.label.toLowerCase()} detected</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[300px] overflow-y-auto">
|
||||
{active.data.slice(0, 20).map((item: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between py-2 px-3 rounded-lg bg-[#0D1117] hover:bg-[#1A2234] transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[#5C6080] w-5">#{i+1}</span>
|
||||
<span className="font-mono text-xs text-[#E0E0E0]">{String(item.address || item.wallet || item.id || '?').slice(0,12)}...</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{tab !== 'bundle' && <span className="text-[10px] text-[#8EA9C1]">{item.type || item.activity || item.label || '?'}</span>}
|
||||
<span className={`text-[10px] font-mono font-bold ${(Number(item.confidence)||0) > 70 ? 'text-[#FF3366]' : (Number(item.confidence)||0) > 40 ? 'text-[#FFB800]' : 'text-[#06D6A0]'}`}>
|
||||
{Number(item.confidence)||0}%
|
||||
</span>
|
||||
<span className="text-[10px] text-[#5C6080]">{item.time || item.detected || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/intel/KolLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useState } from 'react';
|
||||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { Users, TrendingUp, Target } from 'lucide-react';
|
||||
|
||||
export default function KolLeaderboard() {
|
||||
const { data: kol, isLoading: kl } = useDataBus('kol_leaderboard');
|
||||
const [sort, setSort] = useState<'accuracy'|'followers'|'roi'>('accuracy');
|
||||
|
||||
const extract = (d: any) => { const raw = d?.data || d; return (Array.isArray(raw) ? raw : raw?.kols || raw?.influencers || raw?.items || []) as any[]; };
|
||||
const items = extract(kol).sort((a:any,b:any)=>{
|
||||
if(sort==='accuracy') return (Number(b.accuracy||b.call_accuracy||0))-(Number(a.accuracy||a.call_accuracy||0));
|
||||
if(sort==='followers') return (Number(b.followers||0))-(Number(a.followers||0));
|
||||
return (Number(b.avg_roi||b.roi||0))-(Number(a.avg_roi||b.roi||0));
|
||||
}).slice(0,20);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2"><Users className="w-4 h-4 text-[#22D3EE]"/><h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">KOL Rankings</h3></div>
|
||||
<div className="flex gap-1">
|
||||
{(['accuracy','followers','roi'] as const).map(s=>(
|
||||
<button key={s} onClick={()=>setSort(s)} className={`text-[10px] px-2 py-0.5 rounded ${sort===s?'bg-[#8B5CF6]/20 text-[#8B5CF6]':'text-[#5C6080]'}`}>{s==='accuracy'?'Acc':s==='followers'?'Foll':'ROI'}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{kl ? <div className="space-y-2">{[1,2,3].map(i=><div key={i} className="h-8 bg-[#1A2234] rounded animate-pulse"/>)}</div> :
|
||||
<div className="space-y-0.5 max-h-[350px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[20px_1fr_55px_55px_55px] gap-2 px-2 py-1 text-[10px] text-[#5C6080] uppercase border-b border-white/5">
|
||||
<span>#</span><span>KOL</span><span>Followers</span><span>Accuracy</span><span>ROI</span>
|
||||
</div>
|
||||
{items.map((k:any,i:number)=>(
|
||||
<div key={i} className="grid grid-cols-[20px_1fr_55px_55px_55px] gap-2 px-2 py-1.5 rounded hover:bg-[#1A2234] items-center">
|
||||
<span className="text-[10px] text-[#5C6080] font-mono">{i+1}</span>
|
||||
<span className="text-[11px] text-[#E0E0E0] truncate">{k.handle||k.name||k.username||'?'}</span>
|
||||
<span className="text-[10px] font-mono text-[#8EA9C1]">{Number(k.followers||0)>1000?(Number(k.followers)/1000).toFixed(0)+'K':String(k.followers||'?')}</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${(Number(k.accuracy||k.call_accuracy||0))>60?'text-[#06D6A0]':(Number(k.accuracy||0))>30?'text-[#FFB800]':'text-[#FF3366]'}`}>{Number(k.accuracy||k.call_accuracy||0).toFixed(0)}%</span>
|
||||
<span className={`text-[10px] font-mono font-bold ${(Number(k.avg_roi||k.roi||0))>0?'text-[#06D6A0]':'text-[#FF3366]'}`}>{Number(k.avg_roi||k.roi||0)>0?'+':''}{Number(k.avg_roi||k.roi||0).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/intel/ScamMonitor.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { AlertTriangle, Skull, FlaskConical } from 'lucide-react';
|
||||
|
||||
export default function ScamMonitor() {
|
||||
const { data: sm, isLoading: sl } = useDataBus('scam_monitor');
|
||||
const { data: rp, isLoading: rl } = useDataBus('rug_patterns');
|
||||
|
||||
const extract = (d: any) => { const raw = d?.data || d; return (Array.isArray(raw) ? raw : raw?.scams || raw?.alerts || raw?.items || raw?.patterns || []) as any[]; };
|
||||
const scams = extract(sm).slice(0, 12);
|
||||
const patterns = extract(rp).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Skull className="w-4 h-4 text-[#FF3366]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">Scam Monitor</h3>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#FF3366] ml-auto" style={{boxShadow:'0 0 6px #FF3366'}}/>
|
||||
</div>
|
||||
{sl ? <div className="space-y-2">{[1,2,3].map(i=><div key={i} className="h-8 bg-[#1A2234] rounded animate-pulse"/>)}</div> :
|
||||
<div className="space-y-0.5 max-h-[250px] overflow-y-auto">
|
||||
{scams.map((s:any,i:number)=>(
|
||||
<div key={i} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-[#1A2234] text-[11px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${(Number(s.confidence||s.score||0))>70?'bg-[#FF3366]':(Number(s.confidence||0))>40?'bg-[#FFB800]':'bg-[#06D6A0]'}`}/>
|
||||
<span className="text-[#E0E0E0] max-w-[120px] truncate">{s.name||s.token||s.title||'?'}</span>
|
||||
<span className="text-[10px] text-[#5C6080]">{s.chain||''}</span>
|
||||
</div>
|
||||
<span className={`text-[10px] font-mono font-bold ${(Number(s.confidence||s.score||0))>70?'text-[#FF3366]':'text-[#FFB800]'}`}>{Number(s.confidence||s.score||0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
{patterns.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-2"><FlaskConical className="w-3 h-3 text-[#8B5CF6]"/><span className="text-[10px] text-[#8EA9C1] uppercase">Pattern Library</span></div>
|
||||
{patterns.map((p:any,i:number)=>(
|
||||
<div key={i} className="text-[10px] py-1 px-2 rounded bg-[#0D1117] mb-1">
|
||||
<span className="text-[#E0E0E0] font-semibold">{p.name||p.pattern||p.title||'?'}</span>
|
||||
<span className="text-[#5C6080] ml-2">{String(p.description||p.desc||'').slice(0,80)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/intel/SmartMoneyLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { TrendingUp, Trophy } from 'lucide-react';
|
||||
|
||||
export default function SmartMoneyLeaderboard() {
|
||||
const { data: sm, isLoading: sl } = useDataBus('smart_money');
|
||||
const { data: tt, isLoading: tl } = useDataBus('top_traders');
|
||||
|
||||
const extract = (d: any) => {
|
||||
const raw = d?.data || d;
|
||||
return (Array.isArray(raw) ? raw : raw?.traders || raw?.wallets || raw?.items || []) as any[];
|
||||
};
|
||||
|
||||
const items = [...extract(sm), ...extract(tt)].slice(0, 25);
|
||||
const loading = sl || tl;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Trophy className="w-4 h-4 text-[#FFB800]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">Smart Money</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2">{[1,2,3,4,5].map(i=><div key={i} className="h-8 bg-[#1A2234] rounded animate-pulse"/>)}</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[400px] overflow-y-auto">
|
||||
<div className="grid grid-cols-[24px_1fr_70px_60px_80px] gap-2 px-2 py-1 text-[10px] text-[#5C6080] uppercase tracking-wider border-b border-white/5 mb-1">
|
||||
<span>#</span><span>Wallet</span><span>ROI</span><span>Win%</span><span>PnL</span>
|
||||
</div>
|
||||
{items.map((t: any, i: number) => {
|
||||
const roi = Number(t.roi || t.return_pct || 0);
|
||||
const win = Number(t.win_rate || t.winRate || 0);
|
||||
const pnl = Number(t.pnl || t.profit || t.total_pnl || 0);
|
||||
return (
|
||||
<div key={i} className="grid grid-cols-[24px_1fr_70px_60px_80px] gap-2 px-2 py-1.5 rounded hover:bg-[#1A2234] transition-colors items-center">
|
||||
<span className="text-[10px] text-[#5C6080] font-mono">{i+1}</span>
|
||||
<span className="font-mono text-[11px] text-[#E0E0E0] truncate">{String(t.address || t.wallet || '?').slice(0,10)}...</span>
|
||||
<span className={`text-[11px] font-mono font-bold ${roi>50?'text-[#06D6A0]':roi>0?'text-[#FFB800]':'text-[#FF3366]'}`}>{roi>0?'+':''}{roi.toFixed(1)}%</span>
|
||||
<span className="text-[11px] font-mono text-[#8EA9C1]">{win.toFixed(0)}%</span>
|
||||
<span className={`text-[11px] font-mono font-bold ${pnl>=0?'text-[#06D6A0]':'text-[#FF3366]'}`}>${Math.abs(pnl)>1000?(Math.abs(pnl)/1000).toFixed(1)+'K':Math.abs(pnl).toFixed(0)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/intel/TradingSignals.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { TrendingUp, TrendingDown, Minus, Target, Zap } from 'lucide-react';
|
||||
|
||||
export default function TradingSignals() {
|
||||
const { data: ps, isLoading: pl } = useDataBus('prediction_signals');
|
||||
const { data: wb, isLoading: wl } = useDataBus('weekly_best');
|
||||
const extract = (d: any) => { const raw = d?.data || d; return (Array.isArray(raw) ? raw : raw?.signals || raw?.items || raw?.tokens || []) as any[]; };
|
||||
const signals = extract(ps).slice(0, 8);
|
||||
const weekly = extract(wb).slice(0, 5);
|
||||
const iconMap: any = { buy: TrendingUp, sell: TrendingDown, hold: Minus };
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-4 h-4 text-[#FFB800]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">Trading Signals</h3>
|
||||
</div>
|
||||
{pl ? <div className="space-y-2">{[1,2,3].map(i=><div key={i} className="h-10 bg-[#1A2234] rounded animate-pulse"/>)}</div> :
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{signals.map((s:any,i:number)=>{
|
||||
const Icon = iconMap[s.type||s.signal] || Target;
|
||||
const color = s.type==='sell'?'#FF3366':s.type==='buy'?'#06D6A0':'#FFB800';
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-[#1A2234] text-[11px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-3 h-3" style={{color}}/>
|
||||
<span className="text-[#E0E0E0] font-semibold">{s.symbol||s.token||'?'}</span>
|
||||
<span className="text-[10px] uppercase font-bold" style={{color}}>{s.type||s.signal||'?'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-[#8EA9C1]">{(Number(s.confidence||0)).toFixed(0)}%</span>
|
||||
{s.target_price && <span className="text-[10px] font-mono text-[#8B5CF6]">→${Number(s.target_price).toFixed(4)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>}
|
||||
{weekly.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-2"><Target className="w-3 h-3 text-[#06D6A0]"/><span className="text-[10px] text-[#8EA9C1] uppercase">Weekly Best</span></div>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{weekly.map((w:any,i:number)=>(
|
||||
<div key={i} className="bg-[#0D1117] rounded-lg p-2 text-center">
|
||||
<div className="text-[10px] text-[#E0E0E0] font-semibold">{w.symbol||'?'}</div>
|
||||
<div className={`text-[10px] font-mono font-bold ${(Number(w.change||w.performance||0))>0?'text-[#06D6A0]':'text-[#FF3366]'}`}>{Number(w.change||w.performance||0)>0?'+':''}{Number(w.change||w.performance||0).toFixed(1)}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/intel/WashTradeDetector.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useState } from 'react';
|
||||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { BarChart3, Search } from 'lucide-react';
|
||||
|
||||
export default function WashTradeDetector() {
|
||||
const [addr, setAddr] = useState('');
|
||||
const [searchAddr, setSearchAddr] = useState('');
|
||||
const { data, isLoading } = useDataBus('wash_trade_detect', { address: searchAddr }, { enabled: !!searchAddr });
|
||||
const d = (data as any)?.data || data || {};
|
||||
const real = Number(d.real_volume || d.authentic_volume || 0);
|
||||
const fake = Number(d.fake_volume || d.wash_volume || 0);
|
||||
const total = real + fake || 1;
|
||||
const realPct = (real/total)*100;
|
||||
const score = Number(d.wash_score || d.score || 0);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BarChart3 className="w-4 h-4 text-[#22D3EE]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">Wash Trade Detector</h3>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input value={addr} onChange={e=>setAddr(e.target.value)} onKeyDown={e=>e.key==='Enter'&&setSearchAddr(addr)}
|
||||
placeholder="Token address..." className="flex-1 bg-[#0D1117] border border-white/5 rounded-lg px-3 py-2 text-xs text-[#E0E0E0] font-mono outline-none focus:border-[#22D3EE]/50"/>
|
||||
<button onClick={()=>setSearchAddr(addr)} className="px-3 py-2 rounded-lg bg-[#22D3EE]/20 text-[#22D3EE] text-xs font-semibold">
|
||||
<Search className="w-3 h-3"/>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? <div className="h-20 bg-[#1A2234] rounded animate-pulse"/> :
|
||||
searchAddr ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] text-[#5C6080]">Wash Score</span>
|
||||
<span className={`text-lg font-mono font-bold ${score>60?'text-[#FF3366]':score>30?'text-[#FFB800]':'text-[#06D6A0]'}`}>{score}/100</span>
|
||||
</div>
|
||||
<div className="h-3 bg-[#0D1117] rounded-full overflow-hidden mb-2">
|
||||
<div className="h-full bg-[#06D6A0] rounded-full" style={{width:`${realPct}%`}}/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-[#06D6A0] font-mono">Real: ${real>1e6?(real/1e6).toFixed(1)+'M':real>1e3?(real/1e3).toFixed(0)+'K':real.toFixed(0)}</span>
|
||||
<span className="text-[#FF3366] font-mono">Fake: ${fake>1e6?(fake/1e6).toFixed(1)+'M':fake>1e3?(fake/1e3).toFixed(0)+'K':fake.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : <p className="text-xs text-[#5C6080] text-center py-4">Enter a token address to analyze volume authenticity</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/intel/WhaleTracker.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { Fish as Whale, ArrowRight, Globe } from 'lucide-react';
|
||||
|
||||
export default function WhaleTracker() {
|
||||
const { data: wa, isLoading: wl } = useDataBus('whale_alerts');
|
||||
const { data: cc, isLoading: cl } = useDataBus('cross_chain_entity');
|
||||
|
||||
const extract = (d: any) => { const raw = d?.data || d; return (Array.isArray(raw) ? raw : raw?.alerts || raw?.whales || raw?.items || raw?.entities || []) as any[]; };
|
||||
const whales = extract(wa).slice(0, 15);
|
||||
const entities = extract(cc).slice(0, 5);
|
||||
|
||||
const fmtAmt = (v: any) => { const n=Number(v); if(!n) return '$0'; return n>1e6?'$'+(n/1e6).toFixed(1)+'M':n>1e3?'$'+(n/1e3).toFixed(1)+'K':'$'+n.toFixed(0); };
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-4 border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Whale className="w-4 h-4 text-[#FFB800]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">Whale Activity</h3>
|
||||
</div>
|
||||
{wl ? <div className="space-y-2">{[1,2,3].map(i=><div key={i} className="h-8 bg-[#1A2234] rounded animate-pulse"/>)}</div> :
|
||||
<div className="space-y-0.5 max-h-[250px] overflow-y-auto">
|
||||
{whales.map((w:any,i:number)=>(
|
||||
<div key={i} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-[#1A2234] text-[11px]">
|
||||
<span className="font-mono text-[#E0E0E0] truncate max-w-[100px]">{String(w.from||w.wallet||w.address||'?').slice(0,10)}...</span>
|
||||
<ArrowRight className="w-3 h-3 text-[#5C6080] mx-1"/>
|
||||
<span className="text-[#8EA9C1]">{w.to_label||w.to||w.destination||'?'}</span>
|
||||
<span className="font-mono font-bold text-[#06D6A0] ml-auto">{fmtAmt(w.amount_usd||w.amount||w.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
{entities.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-2"><Globe className="w-3 h-3 text-[#8B5CF6]"/><span className="text-[10px] text-[#8EA9C1] uppercase tracking-wider">Cross-Chain Entities</span></div>
|
||||
{entities.map((e:any,i:number)=>(
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] py-1">
|
||||
<span className="font-mono text-[#E0E0E0]">{String(e.entity||e.name||e.label||'?')}</span>
|
||||
<span className="text-[#5C6080]">{(e.chains||[]).join(', ')}</span>
|
||||
<span className="ml-auto text-[#8B5CF6] font-mono">{Number(e.confidence||0).toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/layout/Footer.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Shield, Globe, MessageSquare, Share2 } from 'lucide-react';
|
||||
|
||||
export function Footer() {
|
||||
const links = [
|
||||
{ title: 'Product', items: ['Token Scanner', 'Wallet Profiler', '27 Tools', 'AI Bot', 'Scam School', 'Rug Pull Rehab'] },
|
||||
{ title: 'Community', items: ['The Trenches', 'Content Hub', 'Newsletter', 'v2 Planning', 'Bug Bounty'] },
|
||||
{ title: 'Developers', items: ['API Docs', 'SDK', 'GitHub', 'Status Page', 'Changelog'] },
|
||||
{ title: 'Company', items: ['About', 'Careers', 'Brand Kit', 'Contact', 'Terms', 'Privacy'] },
|
||||
];
|
||||
return (
|
||||
<footer className="relative py-16 border-t border-rmi-purple/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-8 mb-12">
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-rmi-gold to-rmi-gold-light flex items-center justify-center text-rmi-bg font-black">R</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-rmi-white tracking-wide">RUG MUNCH</div>
|
||||
<div className="text-[8px] text-rmi-cyan -mt-0.5 tracking-widest">INTELLIGENCE</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-rmi-gray mb-4 max-w-xs">AI-powered protection across 20+ blockchains. Real-time intelligence, forensic tools, and community-driven security.</p>
|
||||
<div className="flex gap-2">
|
||||
<button className="w-8 h-8 rounded-lg bg-rmi-purple/20 flex items-center justify-center text-rmi-gray hover:text-rmi-cyan hover:bg-rmi-cyan/10 transition-all"><Globe className="w-4 h-4" /></button>
|
||||
<button className="w-8 h-8 rounded-lg bg-rmi-purple/20 flex items-center justify-center text-rmi-gray hover:text-rmi-cyan hover:bg-rmi-cyan/10 transition-all"><MessageSquare className="w-4 h-4" /></button>
|
||||
<button className="w-8 h-8 rounded-lg bg-rmi-purple/20 flex items-center justify-center text-rmi-gray hover:text-rmi-cyan hover:bg-rmi-cyan/10 transition-all"><Share2 className="w-4 h-4" /></button>
|
||||
<a href="#/admin" className="w-8 h-8 rounded-lg bg-rmi-purple/20 flex items-center justify-center text-rmi-gray hover:text-rmi-gold hover:bg-rmi-gold/10 transition-all" title="Admin Portal"><Shield className="w-4 h-4" /></a>
|
||||
</div>
|
||||
</div>
|
||||
{links.map(col => (
|
||||
<div key={col.title}>
|
||||
<div className="text-xs font-bold text-rmi-white mb-3">{col.title}</div>
|
||||
<div className="space-y-1.5">{col.items.map(item => <a key={item} href="#" className="block text-[10px] text-rmi-gray hover:text-rmi-cyan transition-colors">{item}</a>)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-rmi-purple/10 pt-6 space-y-4">
|
||||
<div className="text-[8px] text-rmi-gray-dark leading-relaxed max-w-4xl">
|
||||
<p className="mb-2"><strong className="text-[9px] text-rmi-gray">Disclaimer:</strong> Rug Munch Intelligence is a security research and analytics platform. The information provided on this site, including but not limited to risk scores, security assessments, scam alerts, and market data, is for informational and educational purposes only. It does not constitute financial advice, investment advice, trading advice, or any other type of advice. You should not treat any content on this site as a solicitation, recommendation, endorsement, or offer to buy or sell any cryptocurrency, token, or financial instrument.</p>
|
||||
<p className="mb-2">All security assessments and risk scores are generated by automated algorithms and AI analysis. While we strive for accuracy, we make no warranties, express or implied, regarding the completeness, accuracy, reliability, or suitability of the information. Always conduct your own research (DYOR) before interacting with any token, smart contract, or wallet address. Cryptocurrency investments are inherently risky — you could lose all of your capital. Past security assessments do not guarantee future safety.</p>
|
||||
<p>Rug Munch Intelligence, its affiliates, and its contributors shall not be held liable for any losses, damages, or injuries arising from the use of or reliance on information provided through this platform. By using this site, you acknowledge that you have read, understood, and agree to these terms. If you do not agree, do not use this site.</p>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-3 pt-3 border-t border-rmi-purple/5">
|
||||
<div className="text-[9px] text-rmi-gray-dark">© 2026 Rug Munch Media LLC. All rights reserved.</div>
|
||||
<div className="flex gap-4">
|
||||
<a href="/terms" className="text-[9px] text-rmi-gray-dark hover:text-rmi-gray">Terms</a>
|
||||
<a href="/privacy" className="text-[9px] text-rmi-gray-dark hover:text-rmi-gray">Privacy</a>
|
||||
<a href="/cookies" className="text-[9px] text-rmi-gray-dark hover:text-rmi-gray">Cookies</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('rmi-open-consent'));
|
||||
}}
|
||||
className="text-[9px] text-rmi-gray-dark hover:text-rmi-gray transition-colors"
|
||||
>
|
||||
Cookie Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
74
src/components/layout/Navbar.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Shield, Brain, User, Menu, X, Bot } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function Navbar({ onOpenBot }: { onOpenBot: () => void }) {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 50); window.addEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll); }, []);
|
||||
const links = [
|
||||
{ label: 'Feed', href: '#feed' },
|
||||
{ label: 'AI Hub', href: '#aihub' },
|
||||
{ label: 'Blog', href: '#blog' },
|
||||
{ label: 'Scanner', href: '#scanner' },
|
||||
{ label: 'Wallet', href: '#wallet' },
|
||||
{ label: 'Tools', href: '#tools' },
|
||||
{ label: 'Market', href: '#market' },
|
||||
{ label: 'School', href: '#scamschool' },
|
||||
{ label: 'Rehab', href: '#rehab' },
|
||||
{ label: 'Trenches', href: '#trenches' },
|
||||
{ label: 'Pricing', href: '#pricing' },
|
||||
];
|
||||
return (
|
||||
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${scrolled ? 'glass shadow-lg shadow-rmi-purple/5' : 'bg-transparent'}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<a href="#" className="flex items-center gap-2 group">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-rmi-gold to-rmi-gold-light flex items-center justify-center text-rmi-bg font-black text-lg group-hover:scale-110 transition-transform">R</div>
|
||||
<div>
|
||||
<div className="text-sm font-black text-rmi-white tracking-wide">RUG MUNCH</div>
|
||||
<div className="text-[9px] text-rmi-cyan -mt-0.5 tracking-widest">INTELLIGENCE</div>
|
||||
</div>
|
||||
</a>
|
||||
<div className="hidden lg:flex items-center gap-1">
|
||||
{links.map(l => <a key={l.label} href={l.href} className="px-2.5 py-2 text-[11px] text-rmi-gray hover:text-rmi-cyan transition-colors rounded-lg hover:bg-rmi-purple/10 whitespace-nowrap">{l.label}</a>)}
|
||||
</div>
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
<a href="#/admin" className="inline-flex items-center gap-1 px-2.5 py-2 text-[11px] text-rmi-gray hover:text-rmi-gold transition-colors rounded-lg hover:bg-rmi-gold/10 whitespace-nowrap">
|
||||
<Shield className="w-3.5 h-3.5 mr-1" /> Portal
|
||||
</a>
|
||||
<Button variant="ghost" size="sm" onClick={onOpenBot} className="text-[11px] text-rmi-cyan hover:text-rmi-cyan hover:bg-rmi-cyan/10">
|
||||
<Bot className="w-3.5 h-3.5 mr-1" /> AI Bot
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-[11px] text-rmi-gray hover:text-rmi-white">
|
||||
<User className="w-3.5 h-3.5 mr-1" /> Sign In
|
||||
</Button>
|
||||
<Button size="sm" className="text-[11px] bg-gradient-to-r from-rmi-gold to-rmi-gold-light text-rmi-bg font-bold hover:shadow-lg hover:shadow-rmi-gold/20 btn-glow">
|
||||
<Shield className="w-3.5 h-3.5 mr-1" /> Get Protected
|
||||
</Button>
|
||||
</div>
|
||||
<button className="lg:hidden text-rmi-white" onClick={() => setMobileOpen(!mobileOpen)}>{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}</button>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="lg:hidden glass border-t border-rmi-purple/20">
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{links.map(l => <a key={l.label} href={l.href} onClick={() => setMobileOpen(false)} className="block px-3 py-2 text-sm text-rmi-gray hover:text-rmi-cyan rounded-lg hover:bg-rmi-purple/10">{l.label}</a>)}
|
||||
<div className="pt-2 flex gap-2 flex-wrap">
|
||||
<a href="#/admin" className="flex-1 inline-flex items-center justify-center gap-1 px-3 py-2 text-xs text-rmi-gold border border-rmi-gold/30 rounded-lg hover:bg-rmi-gold/10" onClick={() => setMobileOpen(false)}>
|
||||
<Shield className="w-3.5 h-3.5 mr-1" /> Portal
|
||||
</a>
|
||||
<Button onClick={() => { onOpenBot(); setMobileOpen(false); }} variant="outline" className="flex-1 text-xs border-rmi-cyan/30 text-rmi-cyan">
|
||||
<Brain className="w-3.5 h-3.5 mr-1" /> AI Bot
|
||||
</Button>
|
||||
<Button className="flex-1 text-xs bg-gradient-to-r from-rmi-gold to-rmi-gold-light text-rmi-bg font-bold">Get Protected</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
470
src/components/layout/SidebarLayout.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { useState, ReactNode, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Home, Activity, Bot, TrendingUp, Layers, Brain,
|
||||
Menu, X, BarChart3, Search, User, Users, MessageCircle, Wallet,
|
||||
Target, Zap, Shield, AlertTriangle, Clock, Globe,
|
||||
Database, Cpu, Info, Heart, ChevronDown,
|
||||
ChevronUp, Sparkles, Flame, Newspaper, Eye, Scale,
|
||||
Briefcase, ShieldCheck, FileText,
|
||||
BookOpen, Radio, ShoppingCart, Wrench, FileCode, DollarSign,
|
||||
ArrowLeftRight, ScanLine, Network, Gauge, Rss, MessageSquare,
|
||||
GraduationCap, Lock, Crosshair, Award, Bug, Flag,
|
||||
Trophy, Swords, LineChart,
|
||||
} from 'lucide-react';
|
||||
import { useScrollRestore } from '@/hooks/useScrollRestore';
|
||||
import { databus } from '@/services/databus';
|
||||
import { PriceTickerBar } from '@/components/shared/PriceTickerBar';
|
||||
import { CommandPalette } from '@/components/shared/CommandPalette';
|
||||
import { SystemStatusBar } from '@/components/shared/SystemStatusBar';
|
||||
import { TrendingTicker } from '@/components/TrendingTicker';
|
||||
|
||||
interface SidebarLayoutProps { children: ReactNode; }
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
icon: any;
|
||||
label: string;
|
||||
badge?: string;
|
||||
live?: boolean;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
icon: any;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const navGroups = (alertCount: number): NavGroup[] => [
|
||||
{
|
||||
title: 'Command',
|
||||
icon: Gauge,
|
||||
items: [
|
||||
{ to: '/', icon: Gauge, label: 'Dashboard', live: true, desc: 'Live overview' },
|
||||
{ to: '/intelligence', icon: Brain, label: 'Intel Hub', desc: 'All intelligence in one view' },
|
||||
{ to: '/live-threats', icon: Zap, label: 'Live Threats', badge: 'NEW', desc: 'Real-time threat feed' },
|
||||
{ to: '/feed', icon: Radio, label: 'Live Feed', live: true, desc: 'Real-time data stream' },
|
||||
{ to: '/alerts', icon: AlertTriangle, label: 'Threat Alerts', badge: alertCount > 0 ? String(alertCount) : undefined, desc: 'Active threat count' },
|
||||
{ to: '/chat', icon: Bot, label: 'AI Chat', badge: 'NEW', desc: 'Ask AI anything' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Market',
|
||||
icon: Flame,
|
||||
items: [
|
||||
{ to: '/markets', icon: Flame, label: 'Live Markets', live: true, desc: 'Real-time prices' },
|
||||
{ to: '/market-overview', icon: Globe, label: 'Overview', desc: 'Market snapshot' },
|
||||
{ to: '/market-intel', icon: Crosshair, label: 'Deep Intel', desc: 'Premium analysis' },
|
||||
{ to: '/markets/breakdown', icon: TrendingUp, label: 'Breakdown', desc: 'Sector analysis' },
|
||||
{ to: '/markets/compare', icon: ArrowLeftRight, label: 'Compare', desc: 'Token comparison' },
|
||||
{ to: '/news', icon: Newspaper, label: 'News', desc: 'Crypto headlines' },
|
||||
{ to: '/campaigns', icon: Flag, label: 'Campaigns', desc: 'Airdrop tracking' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Social',
|
||||
icon: MessageCircle,
|
||||
items: [
|
||||
{ to: '/feed', icon: Radio, label: 'Social Feed', desc: 'CT signals & intelligence' },
|
||||
{ to: '/community', icon: MessageSquare, label: 'Bulletin Board', desc: 'Community posts' },
|
||||
{ to: '/community-forensics', icon: Users, label: 'Forensics Lab', desc: 'Crowd-sourced intel' },
|
||||
{ to: '/blog', icon: Rss, label: '@CryptoRugMunch', desc: 'Our content feed' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ to: '/scanner', icon: ScanLine, label: 'Token Scanner', desc: 'Audit any token' },
|
||||
{ to: '/wallet', icon: Wallet, label: 'Wallet Analyzer', desc: 'Track holdings' },
|
||||
{ to: '/portfolio', icon: Briefcase, label: 'Portfolio', badge: 'NEW', desc: 'Track your P&L' },
|
||||
{ to: '/investigate', icon: Eye, label: 'Deep Investigation', desc: 'Forensic analysis' },
|
||||
{ to: '/walletsafe', icon: Shield, label: 'Wallet Safe', desc: 'Security check' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Tools & Learn',
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{ to: '/rugmaps', icon: Target, label: 'RugMaps', desc: 'Visual forensics' },
|
||||
{ to: '/rugcharts', icon: BarChart3, label: 'RugCharts', badge: 'HOT', desc: 'Charts + AI rug prediction' },
|
||||
{ to: '/explorer', icon: Globe, label: 'Token Explorer', badge: 'NEW', desc: 'New launches & trending' },
|
||||
{ to: '/discover', icon: Network, label: 'Discover', desc: 'Explore data' },
|
||||
{ to: '/is-this-a-rug', icon: Search, label: 'Is This a Rug?', badge: 'NEW', desc: 'Free instant check' },
|
||||
{ to: '/wallet-checker', icon: ShieldCheck, label: 'Wallet Checker', badge: 'NEW', desc: 'Is this wallet safe?' },
|
||||
{ to: '/rug-checker', icon: AlertTriangle, label: 'Rug Detector', badge: 'NEW', desc: '5-pattern scan' },
|
||||
{ to: '/scam-lookup', icon: Database, label: 'Scam Database', badge: 'NEW', desc: '17K+ known scams' },
|
||||
{ to: '/my-cases', icon: FileText, label: 'My Cases', badge: 'NEW', desc: 'Your saved investigations' },
|
||||
{ to: '/school', icon: GraduationCap, label: 'Scam School', badge: 'FREE', desc: 'Learn crypto safety' },
|
||||
{ to: '/rehab', icon: Heart, label: 'Rug Pull Rehab', desc: 'Recovery support' },
|
||||
{ to: '/hunter', icon: Trophy, label: 'Leaderboard', desc: 'Rankings & achievements' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
icon: Cpu,
|
||||
items: [
|
||||
{ to: '/ask', icon: Sparkles, label: 'Ask AI', desc: 'Quick AI answers' },
|
||||
{ to: '/tools', icon: Wrench, label: 'MCP Tools', badge: '234', desc: 'Security tool suite' },
|
||||
{ to: '/tools/docs', icon: FileCode, label: 'API Docs', desc: 'Developer reference' },
|
||||
{ to: '/market', icon: Zap, label: 'Tool Market', desc: 'Pay-per-call tools' },
|
||||
{ to: '/pricing', icon: DollarSign, label: 'Pricing', desc: 'Plans & tiers' },
|
||||
{ to: '/premium', icon: Lock, label: 'Premium', desc: 'Premium dashboard' },
|
||||
{ to: '/hunter', icon: Award, label: 'Hunter Game', desc: 'Find rugs, earn badges' },
|
||||
{ to: '/profile', icon: User, label: 'Profile', desc: 'Your account' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
export function SidebarLayout({ children }: SidebarLayoutProps) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const [alertCount, setAlertCount] = useState<number>(0);
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const [cmdPaletteOpen, setCmdPaletteOpen] = useState(false);
|
||||
const scrollRef = useScrollRestore();
|
||||
const location = useLocation();
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const groups = navGroups(alertCount);
|
||||
|
||||
const toggleGroup = (title: string) => {
|
||||
setCollapsedGroups(prev => ({ ...prev, [title]: !prev[title] }));
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/';
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 768) setIsOpen(false);
|
||||
else setIsOpen(true);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const result = await databus.fetch('alerts');
|
||||
if (!cancelled) {
|
||||
const count = result?.count ?? (result?.data as any)?.count ?? (result?.data as any)?.alerts?.length ?? 0;
|
||||
setAlertCount(count);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
fetchAlerts();
|
||||
const t = setInterval(fetchAlerts, 30000);
|
||||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setCmdPaletteOpen(prev => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PriceTickerBar />
|
||||
<div className="flex h-screen bg-[#06060b] text-[#F1F1F6] pt-[32px]">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed left-0 top-[32px] h-full z-50 transition-all duration-300 ease-out ${
|
||||
isOpen ? 'w-[260px]' : 'w-[60px]'
|
||||
}`}
|
||||
>
|
||||
<div className="h-full bg-[#0a0a14] border-r border-white/[0.04] flex flex-col"
|
||||
style={{ boxShadow: '2px 0 40px rgba(139,92,246,0.03)' }}>
|
||||
|
||||
{/* Logo */}
|
||||
<div className={`flex items-center gap-3 px-4 h-[56px] border-b border-white/[0.04] ${!isOpen ? 'justify-center px-2' : ''}`}>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#8B5CF6] to-[#6366F1] flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-900/40">
|
||||
<AlertTriangle className="w-[16px] h-[16px] text-white" />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, width: 0 }}
|
||||
animate={{ opacity: 1, width: 'auto' }}
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="text-[13px] font-semibold tracking-[-0.03em] text-[#F1F1F6] leading-tight whitespace-nowrap">RUG MUNCH</div>
|
||||
<div className="text-[8.5px] font-bold tracking-[0.18em] text-[#8B5CF6] uppercase mt-0.5 whitespace-nowrap">INTEL</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className={`px-3 pt-3 pb-1 ${!isOpen ? 'px-2' : ''}`}>
|
||||
<div className={`relative ${!isOpen ? 'flex justify-center' : ''}`}>
|
||||
{isOpen ? (
|
||||
<div className={`relative w-full transition-all duration-200 ${
|
||||
searchFocused ? 'ring-1 ring-[#8B5CF6]/40 rounded-lg' : ''
|
||||
}`} onClick={() => setCmdPaletteOpen(true)}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-[13px] h-[13px] text-[#3A3E58]" />
|
||||
<div className="w-full bg-white/[0.02] border border-white/[0.06] rounded-lg py-[7px] pl-8 pr-3 text-[11px] text-[#3A3E58] cursor-pointer transition-all hover:border-white/[0.10]">
|
||||
Search pages, tools, features...
|
||||
</div>
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[8px] text-[#3A3E58] font-mono bg-white/[0.03] px-1.5 py-0.5 rounded border border-white/[0.05]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setIsOpen(true); setTimeout(() => searchRef.current?.focus(), 150); }}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center bg-white/[0.02] border border-white/[0.06] hover:border-[#8B5CF6]/20 transition-all"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5 text-[#3A3E58]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto custom-scrollbar py-1 px-2">
|
||||
{groups.map((group) => {
|
||||
const collapsed = collapsedGroups[group.title] ?? false;
|
||||
const activeInGroup = group.items.some(item => isActive(item.to));
|
||||
const activeCount = group.items.filter(item => isActive(item.to)).length;
|
||||
|
||||
return (
|
||||
<div key={group.title} className="mb-1">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-[5px] rounded-lg transition-all hover:bg-white/[0.02] text-left ${
|
||||
!isOpen ? 'justify-center px-0' : ''
|
||||
}`}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-[10px] h-[10px] text-[#3A3E58] transition-transform duration-200 flex-shrink-0 ${
|
||||
collapsed ? '-rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
{isOpen && (
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] text-[#5C6080] uppercase flex-1">
|
||||
{group.title}
|
||||
</span>
|
||||
)}
|
||||
{isOpen && activeInGroup && (
|
||||
<span className="text-[9px] font-semibold text-[#8B5CF6]/70 bg-[#8B5CF6]/8 px-1.5 py-0.5 rounded-md min-w-[16px] text-center">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Group items */}
|
||||
<AnimatePresence>
|
||||
{!collapsed && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-[1px] pt-[2px]">
|
||||
{group.items.map((item) => {
|
||||
const active = isActive(item.to);
|
||||
const hovered = hoveredItem === item.to;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onMouseEnter={() => setHoveredItem(item.to)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
className={`group relative flex items-center gap-[9px] px-2.5 py-[6px] rounded-lg transition-all duration-150 ${
|
||||
!isOpen ? 'justify-center px-0' : ''
|
||||
} ${
|
||||
active
|
||||
? 'bg-[#8B5CF6]/8 text-[#F1F1F6]'
|
||||
: 'text-[#9DA0B0] hover:bg-white/[0.02] hover:text-[#F1F1F6]'
|
||||
}`}
|
||||
>
|
||||
{/* Active left bar */}
|
||||
{active && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[2.5px] h-[18px] rounded-r-full bg-[#8B5CF6]" style={{ boxShadow: '0 0 8px rgba(139,92,246,0.5)' }} />
|
||||
)}
|
||||
|
||||
<item.icon
|
||||
className={`w-[14px] h-[14px] transition-colors duration-150 flex-shrink-0 ${
|
||||
active
|
||||
? 'text-[#A78BFA]'
|
||||
: 'text-[#5C6080] group-hover:text-[#9DA0B0]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<span className={`text-[11.5px] font-[500] whitespace-nowrap tracking-[-0.01em] ${
|
||||
active ? 'text-[#F1F1F6]' : 'text-[#9DA0B0] group-hover:text-[#F1F1F6]'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Badge */}
|
||||
{item.badge && isOpen && (
|
||||
<span className={`ml-auto text-[8.5px] font-bold px-1.5 py-0.5 rounded-md ${
|
||||
item.badge === 'FREE' ? 'bg-[#06D6A0]/10 text-[#06D6A0]' :
|
||||
item.badge === 'NEW' ? 'bg-[#22D3EE]/10 text-[#22D3EE]' :
|
||||
item.badge === 'BETA' ? 'bg-[#F59E0B]/10 text-[#F59E0B]' :
|
||||
'bg-[#8B5CF6]/10 text-[#8B5CF6]'
|
||||
}`}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Live dot */}
|
||||
{item.live && isOpen && !item.badge && (
|
||||
<div className="ml-auto w-[5px] h-[5px] rounded-full bg-[#06D6A0]" style={{ boxShadow: '0 0 4px rgba(6,214,160,0.3)' }} />
|
||||
)}
|
||||
|
||||
{/* Alert count */}
|
||||
{item.label === 'Threat Alerts' && alertCount > 0 && isOpen && (
|
||||
<span className="ml-auto text-[8.5px] font-bold text-[#FF3366] bg-[#FF3366]/10 px-1.5 py-0.5 rounded-md min-w-[16px] text-center">
|
||||
{alertCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Status */}
|
||||
<div className="border-t border-white/[0.04] px-3 py-3">
|
||||
{isOpen ? (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg bg-white/[0.01] border border-white/[0.04]">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-[#8B5CF6]/15 to-[#6366F1]/15 flex items-center justify-center">
|
||||
<Network className="w-[11px] h-[11px] text-[#A78BFA]" />
|
||||
</div>
|
||||
<div className="absolute -top-0.5 -right-0.5 w-[6px] h-[6px] bg-[#06D6A0] rounded-full border-[1.5px] border-[#0a0a14]" style={{ boxShadow: '0 0 4px rgba(6,214,160,0.5)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-semibold text-[#F1F1F6] tracking-[-0.02em]">System Online</div>
|
||||
<div className="text-[8.5px] text-[#06D6A0] flex items-center gap-1">
|
||||
<span className="w-[3px] h-[3px] rounded-full bg-[#06D6A0]" />
|
||||
77 Chains · 95 Providers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-7 h-7 rounded-md bg-gradient-to-br from-[#8B5CF6]/15 to-[#6366F1]/15 flex items-center justify-center relative">
|
||||
<Network className="w-[12px] h-[12px] text-[#A78BFA]" />
|
||||
<div className="absolute -top-0.5 -right-0.5 w-[5px] h-[5px] bg-[#06D6A0] rounded-full border-[1.5px] border-[#0a0a14]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Sidebar toggle - hover zone */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed left-[68px] top-4 z-50 p-2 rounded-lg bg-[#0a0a14]/90 backdrop-blur border border-white/[0.06] hover:border-[#8B5CF6]/20 transition-all shadow-lg shadow-black/40 group"
|
||||
>
|
||||
<Menu className="w-4 h-4 text-[#5C6080] group-hover:text-[#A78BFA] transition-colors" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{isOpen && (
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="fixed left-[248px] top-4 z-50 p-1.5 rounded-md bg-[#0a0a14]/90 backdrop-blur border border-white/[0.06] hover:border-[#8B5CF6]/20 transition-all shadow-lg shadow-black/40 opacity-0 hover:opacity-100 group"
|
||||
>
|
||||
<X className="w-[11px] h-[11px] text-[#3A3E58] group-hover:text-[#8B5CF6] transition-colors" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={`flex-1 flex flex-col transition-all duration-300 ease-out ${isOpen ? 'ml-[260px]' : 'ml-[60px]'}`}>
|
||||
{/* Mobile top bar */}
|
||||
<div className="md:hidden sticky top-0 z-30 bg-[#06060b]/90 backdrop-blur-lg border-b border-white/[0.04] px-4 py-3 flex items-center justify-between">
|
||||
<button onClick={() => setIsOpen(!isOpen)} className="p-2 rounded-lg hover:bg-white/[0.03]">
|
||||
{isOpen ? <X className="w-5 h-5 text-[#F1F1F6]" /> : <Menu className="w-5 h-5 text-[#F1F1F6]" />}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-[#8B5CF6] to-[#6366F1] flex items-center justify-center">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-[#F1F1F6] text-sm tracking-[-0.02em]">RMI</span>
|
||||
</div>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
|
||||
{/* Trending Ticker */}
|
||||
<TrendingTicker />
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar pb-[28px]">
|
||||
<div className="p-5 md:p-8 max-w-[1600px] mx-auto pb-28 md:pb-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Tab Bar */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 bg-[#0a0a14]/95 backdrop-blur-xl border-t border-white/[0.04] px-2 py-1.5 safe-bottom">
|
||||
<div className="flex items-center justify-around max-w-lg mx-auto">
|
||||
{[
|
||||
{ to: '/', icon: Gauge, label: 'Home' },
|
||||
{ to: '/scanner', icon: ScanLine, label: 'Scan' },
|
||||
{ to: '/markets', icon: Flame, label: 'Markets' },
|
||||
{ to: '/chat', icon: Bot, label: 'AI' },
|
||||
{ to: '/school', icon: GraduationCap, label: 'Learn' },
|
||||
].map(item => {
|
||||
const active = location.pathname === item.to || (item.to !== '/' && location.pathname.startsWith(item.to));
|
||||
return (
|
||||
<Link key={item.to} to={item.to}
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-1 rounded-xl transition-all ${
|
||||
active ? 'text-[#A78BFA]' : 'text-[#5C6080] hover:text-[#9DA0B0]'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</main>
|
||||
{/* Command Palette */}
|
||||
<CommandPalette isOpen={cmdPaletteOpen} onClose={() => setCmdPaletteOpen(false)} />
|
||||
|
||||
{/* System Status Bar */}
|
||||
<SystemStatusBar />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidebarLayout;
|
||||
574
src/components/maps/RugMap.tsx
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Map as MapIcon,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
X,
|
||||
Info,
|
||||
ChevronDown,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
import { getRugMapLocations, getRugMapClusters, RugMapLocation, RugMapCluster, getRugIncidents, getRiskColor } from '@/services/rmi/intelligence';
|
||||
|
||||
// ── Design Tokens ──────────────────────────────────────────────
|
||||
const RISK_SCORING: Record<string, number> = {
|
||||
critical: 90,
|
||||
high: 70,
|
||||
medium: 50,
|
||||
low: 20,
|
||||
};
|
||||
|
||||
const COLORS = {
|
||||
bg: '#07070b',
|
||||
purple: '#8B5CF6',
|
||||
cyan: '#28CBF4',
|
||||
gold: '#D1A340',
|
||||
danger: '#FF3366',
|
||||
success: '#00E676',
|
||||
warning: '#FFD700',
|
||||
};
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
critical: '#FF3366',
|
||||
high: '#FF6600',
|
||||
medium: '#FFD700',
|
||||
low: '#00E676',
|
||||
};
|
||||
|
||||
const REGION_MARKERS: Record<string, { lat: number; lng: number; label: string }> = {
|
||||
'North America': { lat: 45, lng: -100, label: 'North America' },
|
||||
'South America': { lat: -15, lng: -60, label: 'South America' },
|
||||
'Europe': { lat: 50, lng: 10, label: 'Europe' },
|
||||
'Asia': { lat: 35, lng: 105, label: 'Asia' },
|
||||
'Oceania': { lat: -25, lng: 135, label: 'Oceania' },
|
||||
'Africa': { lat: -10, lng: 20, label: 'Africa' },
|
||||
};
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
interface FilterState {
|
||||
region: string;
|
||||
chain: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────
|
||||
export default function RugMapsPage() {
|
||||
const [clusters, setClusters] = useState<RugMapCluster[]>([]);
|
||||
const [locations, setLocations] = useState<RugMapLocation[]>([]);
|
||||
const [activeCluster, setActiveCluster] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
region: 'all',
|
||||
chain: 'all',
|
||||
type: 'all',
|
||||
});
|
||||
const [showLocations, setShowLocations] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [clustersData, locationsData] = await Promise.all([
|
||||
getRugMapClusters(),
|
||||
getRugMapLocations(),
|
||||
]);
|
||||
setClusters(clustersData);
|
||||
setLocations(locationsData);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch rug map data');
|
||||
console.error('Error fetching rug map data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredClusters = useMemo(() => {
|
||||
let result = clusters;
|
||||
|
||||
// Apply filters
|
||||
if (filters.region !== 'all') {
|
||||
result = result.filter(c => c.region === filters.region);
|
||||
}
|
||||
if (filters.chain !== 'all') {
|
||||
result = result.filter(c => c.incident_ids?.some(id => {
|
||||
// Simplified - in real implementation would check incident chain
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
if (filters.type !== 'all') {
|
||||
result = result.filter(c => c.incident_ids?.some(id => {
|
||||
// Simplified - would check incident type
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(c =>
|
||||
c.name.toLowerCase().includes(term) ||
|
||||
c.region.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [clusters, filters, searchTerm]);
|
||||
|
||||
const filteredLocations = useMemo(() => {
|
||||
let result = locations;
|
||||
|
||||
if (filters.region !== 'all') {
|
||||
result = result.filter(l => l.location_name.toLowerCase().includes(filters.region.toLowerCase()));
|
||||
}
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(l =>
|
||||
l.location_name.toLowerCase().includes(term) ||
|
||||
l.incident_id.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [locations, filters, searchTerm]);
|
||||
|
||||
const totalLoss = useMemo(() => {
|
||||
return clusters.reduce((sum, c) => sum + c.total_loss, 0);
|
||||
}, [clusters]);
|
||||
|
||||
const totalIncidents = useMemo(() => {
|
||||
return clusters.reduce((sum, c) => sum + c.incident_count, 0);
|
||||
}, [clusters]);
|
||||
|
||||
// Get region for any given location (simplified - real implementation would use geolocation)
|
||||
const getRegion = (lat: number, lng: number): string => {
|
||||
if (lat > 30) return 'North America';
|
||||
if (lat < -10 && lng > 20) return 'Africa';
|
||||
if (lat > 20 && lng > 100) return 'Asia';
|
||||
if (lat < 60 && lng > 0 && lng < 40) return 'Europe';
|
||||
if (lat < -10 && lng < 100) return 'Oceania';
|
||||
return 'South America';
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
className="absolute top-[-10%] right-[-10%] w-[500px] h-[500px] rounded-full"
|
||||
style={{ background: `radial-gradient(circle, ${COLORS.purple}10, transparent 70%)` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-[-5%] left-[-10%] w-[600px] h-[600px] rounded-full"
|
||||
style={{ background: `radial-gradient(circle, ${COLORS.cyan}08, transparent 70%)` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<section className="relative z-10 border-b border-white/5">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-cyan-500/20 flex items-center justify-center border border-purple-500/20">
|
||||
<MapIcon className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-black text-white">Rug Maps</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
Real-time scatter maps showing scam locations globally
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-xl border border-white/10"
|
||||
style={{ background: 'rgba(14, 14, 22, 0.6)' }}
|
||||
>
|
||||
<p className="text-xs text-gray-500 mb-1">Total Incidents</p>
|
||||
<p className="text-3xl font-bold text-white">{totalIncidents.toLocaleString()}</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-green-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
<span>+{Math.round(totalIncidents * 0.15)} this week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-xl border border-white/10"
|
||||
style={{ background: 'rgba(14, 14, 22, 0.6)' }}
|
||||
>
|
||||
<p className="text-xs text-gray-500 mb-1">Total Loss</p>
|
||||
<p className="text-3xl font-bold text-white">{formatCurrency(totalLoss)}</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-red-400">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>{formatCurrency(totalLoss * 0.08)} this week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-xl border border-white/10"
|
||||
style={{ background: 'rgba(14, 14, 22, 0.6)' }}
|
||||
>
|
||||
<p className="text-xs text-gray-500 mb-1">Active Clusters</p>
|
||||
<p className="text-3xl font-bold text-white">{filteredClusters.length}</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-purple-400">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>High-risk zones</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-xl border border-white/10"
|
||||
style={{ background: 'rgba(14, 14, 22, 0.6)' }}
|
||||
>
|
||||
<p className="text-xs text-gray-500 mb-1">Affected Chains</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{['Solana', 'Ethereum', 'Base', 'Polygon'].map((c, i) => (
|
||||
<span
|
||||
key={c}
|
||||
className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-gray-500"
|
||||
>
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filters */}
|
||||
<section className="relative z-10 border-b border-white/5">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search regions, incidents..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg bg-white/5 border border-white/10 text-sm focus:outline-none focus:border-purple-500/50 transition-all placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Region Filter */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">Region:</span>
|
||||
<button
|
||||
onClick={() => setFilters(f => ({ ...f, region: 'all' }))}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
filters.region === 'all'
|
||||
? 'border border-purple-500/40 bg-purple-500/15 text-purple-400'
|
||||
: 'border border-white/10 bg-white/5 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{Object.keys(REGION_MARKERS).map(region => (
|
||||
<button
|
||||
key={region}
|
||||
onClick={() => setFilters(f => ({ ...f, region }))}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
filters.region === region
|
||||
? 'border border-purple-500/40 bg-purple-500/15 text-purple-400'
|
||||
: 'border border-white/10 bg-white/5 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{region}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-px bg-white/10" />
|
||||
|
||||
{/* Chain Filter */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">Chain:</span>
|
||||
<button
|
||||
onClick={() => setFilters(f => ({ ...f, chain: 'all' }))}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
filters.chain === 'all'
|
||||
? 'border border-purple-500/40 bg-purple-500/15 text-purple-400'
|
||||
: 'border border-white/10 bg-white/5 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{['Solana', 'Ethereum', 'Base', 'Polygon'].map(chain => (
|
||||
<button
|
||||
key={chain}
|
||||
onClick={() => setFilters(f => ({ ...f, chain: chain.toLowerCase() }))}
|
||||
className={`flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-all capitalize ${
|
||||
filters.chain === chain.toLowerCase()
|
||||
? 'border border-purple-500/40 bg-purple-500/15 text-purple-400'
|
||||
: 'border border-white/10 bg-white/5 text-gray-400'
|
||||
}`}
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
{chain}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-px bg-white/10" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowLocations(!showLocations)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
showLocations
|
||||
? 'border border-purple-500/40 bg-purple-500/15 text-purple-400'
|
||||
: 'border border-white/10 bg-white/5 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<MapIcon className="w-3 h-3" />
|
||||
Show Locations
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-xs text-gray-400 hover:text-white hover:border-purple-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<section className="relative z-10 px-4 py-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Loading / Error States */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-4 border-purple-500/30 border-t-purple-500 animate-spin" />
|
||||
<p className="text-sm text-gray-500">Loading rug map data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm flex items-center gap-2 mb-6"
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto hover:text-red-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredClusters.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Clusters Map Visualization */}
|
||||
<div className="lg:col-span-2">
|
||||
<div
|
||||
className="rounded-xl border border-white/10 p-6"
|
||||
style={{ background: 'rgba(14, 14, 22, 0.6)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-white">Global Rug Clusters</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
Showing {filteredClusters.length} regions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* World Map Placeholder with Clusters */}
|
||||
<div className="relative w-full h-[400px] bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwMCIgaGVpZ2h0PSI1MDAiIHZpZXdCb3g9IjAgMCAxMDAwIDUwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjMTQxNDE5Ii8+PC9zdmc=')] bg-center bg-cover rounded-lg overflow-hidden">
|
||||
{/* Simplified world map visualization */}
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<Globe className="w-full h-full text-white" />
|
||||
</div>
|
||||
|
||||
{/* Cluster Markers */}
|
||||
{filteredClusters.map((cluster, index) => {
|
||||
const region = cluster.region in REGION_MARKERS
|
||||
? REGION_MARKERS[cluster.region as keyof typeof REGION_MARKERS]
|
||||
: { lat: 0, lng: 0, label: cluster.region };
|
||||
|
||||
// Convert lat/lng to approximate percentage positions
|
||||
const x = 50 + (region.lng / 180) * 50;
|
||||
const y = 50 - (region.lat / 90) * 50;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={cluster.id}
|
||||
onClick={() => setActiveCluster(cluster.id)}
|
||||
whileHover={{ scale: 1.2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="absolute transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group"
|
||||
style={{
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
}}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
{/* Pulsing rings */}
|
||||
<div
|
||||
className="absolute rounded-full animate-ping"
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: RISK_COLORS[cluster.risk_level] || RISK_COLORS.medium,
|
||||
opacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative w-3 h-3 rounded-full border-2 border-white"
|
||||
style={{ backgroundColor: RISK_COLORS[cluster.risk_level] || RISK_COLORS.medium }}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full mb-2 px-2 py-1 rounded bg-black/80 text-white text-[10px] whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{cluster.name}
|
||||
<br />
|
||||
{cluster.incident_count} incidents
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cluster Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mt-6">
|
||||
{[
|
||||
{ color: RISK_COLORS.critical, label: 'Critical', count: filteredClusters.filter(c => c.risk_level === 'critical').length },
|
||||
{ color: RISK_COLORS.high, label: 'High', count: filteredClusters.filter(c => c.risk_level === 'high').length },
|
||||
{ color: RISK_COLORS.medium, label: 'Medium', count: filteredClusters.filter(c => c.risk_level === 'medium').length },
|
||||
{ color: RISK_COLORS.low, label: 'Low', count: filteredClusters.filter(c => c.risk_level === 'low').length },
|
||||
].map((item, i) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-xs text-gray-400">{item.label} ({item.count})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cluster Details */}
|
||||
<div className="lg:col-span-1">
|
||||
<div
|
||||
className="rounded-xl border border-white/10 p-6 h-[600px] overflow-y-auto"
|
||||
style={{ background: 'rgba(14, 14, 22, 0.6)' }}
|
||||
>
|
||||
<h2 className="text-lg font-bold text-white mb-4">Detailed Clusters</h2>
|
||||
|
||||
{filteredClusters.map((cluster) => (
|
||||
<motion.div
|
||||
key={cluster.id}
|
||||
onClick={() => setActiveCluster(cluster.id)}
|
||||
whileHover={{ scale: 1.02, backgroundColor: 'rgba(255,255,255,0.05)' }}
|
||||
className="p-4 rounded-xl border border-white/10 mb-3 cursor-pointer transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: (RISK_COLORS[cluster.risk_level] || RISK_COLORS.medium) + '20' }}
|
||||
>
|
||||
<div className="text-white font-bold">{cluster.region[0]}</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white">{cluster.name}</h3>
|
||||
<p className="text-xs text-gray-400">{cluster.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
{cluster.id === activeCluster && (
|
||||
<div className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<div className="text-center p-2 rounded bg-white/5">
|
||||
<span className="block text-lg font-bold text-white">{cluster.incident_count}</span>
|
||||
<span className="text-[10px] text-gray-500">Incidents</span>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded bg-white/5">
|
||||
<span className="block text-lg font-bold text-white">{formatCurrency(cluster.total_loss)}</span>
|
||||
<span className="text-[10px] text-gray-500">Total Loss</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-gray-500 mb-2">Affected Tokens:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cluster.affected_tokens?.slice(0, 5).map((token, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-1.5 py-0.5 rounded text-[9px] bg-white/5 text-gray-400"
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
))}
|
||||
{cluster.affected_tokens?.length > 5 && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] bg-white/5 text-gray-400">
|
||||
+{cluster.affected_tokens.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{filteredClusters.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<AlertTriangle className="w-12 h-12 mb-4 opacity-20" />
|
||||
<p>No clusters match your filters</p>
|
||||
<button
|
||||
onClick={() => setFilters({ region: 'all', chain: 'all', type: 'all' })}
|
||||
className="mt-4 text-xs text-purple-400 hover:underline"
|
||||
>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────
|
||||
function formatCurrency(amount: number): string {
|
||||
if (amount >= 1_000_000) {
|
||||
return `$${(amount / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
if (amount >= 1_000) {
|
||||
return `$${(amount / 1_000).toFixed(2)}K`;
|
||||
}
|
||||
return `$${amount.toFixed(2)}`;
|
||||
}
|
||||
297
src/components/rugcharts/AISignals.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Target, Brain, AlertTriangle, Activity, Zap, Clock } from 'lucide-react';
|
||||
|
||||
export interface AISignal {
|
||||
id: string;
|
||||
type: 'buy' | 'sell' | 'warning' | 'opportunity' | 'exit';
|
||||
timestamp: number;
|
||||
price_at_signal: number;
|
||||
confidence: number; // 0-100
|
||||
title: string;
|
||||
reasoning: string;
|
||||
indicators: string[];
|
||||
target_price?: number;
|
||||
stop_loss?: number;
|
||||
timeframe?: string;
|
||||
outcome?: 'hit' | 'miss' | 'pending';
|
||||
actual_price?: number;
|
||||
}
|
||||
|
||||
interface AISignalsProps {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
initialSignals?: AISignal[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TYPE_META: Record<AISignal['type'], { color: string; bg: string; icon: any; label: string; arrow: string }> = {
|
||||
buy: { color: '#06D6A0', bg: 'rgba(6,214,160,0.15)', icon: TrendingUp, label: 'BUY', arrow: '↑' },
|
||||
sell: { color: '#FF3366', bg: 'rgba(255,51,102,0.15)', icon: TrendingDown, label: 'SELL', arrow: '↓' },
|
||||
warning: { color: '#FFB800', bg: 'rgba(255,184,0,0.15)', icon: AlertTriangle,label: 'WARNING', arrow: '!' },
|
||||
opportunity:{ color: '#22D3EE', bg: 'rgba(34,211,238,0.15)', icon: Target, label: 'OPPORTUNITY', arrow: '◆' },
|
||||
exit: { color: '#FF8C42', bg: 'rgba(255,140,66,0.15)', icon: Activity, label: 'EXIT', arrow: '⏏' },
|
||||
};
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() / 1000 - ts;
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function formatPrice(n: number | undefined): string {
|
||||
if (n === undefined) return '?';
|
||||
if (n < 0.0001) return `$${n.toExponential(2)}`;
|
||||
if (n < 1) return `$${n.toFixed(6)}`;
|
||||
return `$${n.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export default function AISignals({ chain, tokenAddress, initialSignals, className = '' }: AISignalsProps) {
|
||||
const [signals, setSignals] = useState<AISignal[]>(initialSignals || []);
|
||||
const [loading, setLoading] = useState(!initialSignals);
|
||||
const [filterType, setFilterType] = useState<'all' | AISignal['type']>('all');
|
||||
const [minConfidence, setMinConfidence] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSignals) {
|
||||
setSignals(initialSignals);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const fetchSignals = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/rugcharts/signals/${encodeURIComponent(tokenAddress)}?chain=${chain}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const raw = data.signals || data.entries || [];
|
||||
setSignals(raw.map((s: any, i: number) => ({
|
||||
id: s.id || String(i),
|
||||
type: s.type || s.signal_type || 'buy',
|
||||
timestamp: Number(s.timestamp || s.time || Date.now() / 1000),
|
||||
price_at_signal: Number(s.price_at_signal || s.price || 0),
|
||||
confidence: Number(s.confidence || s.score || 50),
|
||||
title: s.title || s.summary || '',
|
||||
reasoning: s.reasoning || s.reason || '',
|
||||
indicators: Array.isArray(s.indicators) ? s.indicators : (s.indicator ? [s.indicator] : []),
|
||||
target_price: s.target_price || s.target,
|
||||
stop_loss: s.stop_loss || s.stop,
|
||||
timeframe: s.timeframe || s.horizon,
|
||||
outcome: s.outcome,
|
||||
actual_price: s.actual_price,
|
||||
})));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AISignals] fetch failed:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSignals();
|
||||
}, [chain, tokenAddress, initialSignals]);
|
||||
|
||||
// Stats: how many signals? win rate?
|
||||
const stats = {
|
||||
total: signals.length,
|
||||
hits: signals.filter(s => s.outcome === 'hit').length,
|
||||
misses: signals.filter(s => s.outcome === 'miss').length,
|
||||
pending: signals.filter(s => s.outcome === 'pending' || !s.outcome).length,
|
||||
};
|
||||
const winRate = (stats.hits + stats.misses) > 0 ? (stats.hits / (stats.hits + stats.misses)) * 100 : 0;
|
||||
const avgConfidence = signals.length > 0
|
||||
? signals.reduce((s, x) => s + x.confidence, 0) / signals.length
|
||||
: 0;
|
||||
|
||||
const filtered = signals
|
||||
.filter(s => filterType === 'all' || s.type === filterType)
|
||||
.filter(s => s.confidence >= minConfidence)
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
return (
|
||||
<div className={`bg-[#0a0a0f] border border-[rgba(139,92,246,0.15)] rounded-xl overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-[rgba(139,92,246,0.15)] bg-gradient-to-r from-[rgba(139,92,246,0.08)] to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-[#8B5CF6]" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[#F1F1F6] uppercase tracking-wider">AI Trade Signals</h3>
|
||||
<p className="text-[10px] text-[#5C6080] mt-0.5">Pattern-matched entries, exits, and warnings</p>
|
||||
</div>
|
||||
</div>
|
||||
{signals.length > 0 && (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-[#5C6080] uppercase tracking-wider">Win rate</div>
|
||||
<div className="text-sm font-bold text-[#06D6A0]">
|
||||
{winRate > 0 ? `${winRate.toFixed(0)}%` : '—'}
|
||||
<span className="text-[10px] text-[#5C6080] ml-1">({stats.hits}/{stats.hits + stats.misses})</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
{signals.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
<div className="bg-[rgba(255,255,255,0.02)] rounded px-2 py-1">
|
||||
<div className="text-[9px] text-[#5C6080] uppercase">Total</div>
|
||||
<div className="text-sm font-bold text-[#F1F1F6]">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-[rgba(255,255,255,0.02)] rounded px-2 py-1">
|
||||
<div className="text-[9px] text-[#5C6080] uppercase">Pending</div>
|
||||
<div className="text-sm font-bold text-[#FFB800]">{stats.pending}</div>
|
||||
</div>
|
||||
<div className="bg-[rgba(255,255,255,0.02)] rounded px-2 py-1">
|
||||
<div className="text-[9px] text-[#5C6080] uppercase">Avg conf</div>
|
||||
<div className="text-sm font-bold text-[#8B5CF6]">{avgConfidence.toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="px-4 py-2 border-b border-[rgba(139,92,246,0.08)] space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(['all', 'buy', 'sell', 'warning', 'opportunity', 'exit'] as const).map(t => {
|
||||
const meta = t === 'all' ? null : TYPE_META[t];
|
||||
const count = t === 'all' ? signals.length : signals.filter(s => s.type === t).length;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setFilterType(t as any)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-[10px] uppercase tracking-wider transition-colors ${
|
||||
filterType === t
|
||||
? 'bg-[#8B5CF6] text-white'
|
||||
: 'bg-[rgba(139,92,246,0.08)] text-[#5C6080] hover:text-[#F1F1F6]'
|
||||
}`}
|
||||
>
|
||||
{meta && <meta.icon className="w-3 h-3" />}
|
||||
{t}
|
||||
<span className="text-[9px] opacity-70">({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[#5C6080]">
|
||||
<span>Min conf:</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={minConfidence}
|
||||
onChange={e => setMinConfidence(Number(e.target.value))}
|
||||
className="flex-1 accent-[#8B5CF6]"
|
||||
/>
|
||||
<span className="font-mono text-[#F1F1F6] w-8 text-right">{minConfidence}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal list */}
|
||||
{loading ? (
|
||||
<div className="p-6 text-center text-[#5C6080] text-xs">Analyzing chart patterns...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-6 text-center text-[#5C6080] text-xs">
|
||||
No signals match filter. Try lowering confidence or different type.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{filtered.map(sig => {
|
||||
const meta = TYPE_META[sig.type];
|
||||
const Icon = meta.icon;
|
||||
const confColor = sig.confidence >= 75 ? '#06D6A0' : sig.confidence >= 50 ? '#FFB800' : '#FF8C42';
|
||||
return (
|
||||
<div
|
||||
key={sig.id}
|
||||
className="px-4 py-3 border-b border-[rgba(139,92,246,0.04)] hover:bg-[rgba(139,92,246,0.04)] transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Arrow indicator */}
|
||||
<div
|
||||
className="flex flex-col items-center justify-center w-10 h-10 rounded-lg flex-shrink-0"
|
||||
style={{ background: meta.bg, color: meta.color }}
|
||||
>
|
||||
<span className="text-2xl font-bold leading-none">{meta.arrow}</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider"
|
||||
style={{ color: meta.color, background: meta.bg }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
{sig.timeframe && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] bg-[rgba(139,92,246,0.1)] text-[#8B5CF6] font-mono">
|
||||
{sig.timeframe}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-[#5C6080] flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{formatTimeAgo(sig.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sig.title && (
|
||||
<div className="text-[12px] font-semibold text-[#F1F1F6] mt-1">{sig.title}</div>
|
||||
)}
|
||||
{sig.reasoning && (
|
||||
<div className="text-[10px] text-[#5C6080] mt-1 line-clamp-3">{sig.reasoning}</div>
|
||||
)}
|
||||
|
||||
{/* Indicators */}
|
||||
{sig.indicators.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{sig.indicators.slice(0, 4).map((ind, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 rounded text-[9px] bg-[rgba(139,92,246,0.08)] text-[#8B5CF6] font-mono">
|
||||
{ind}
|
||||
</span>
|
||||
))}
|
||||
{sig.indicators.length > 4 && (
|
||||
<span className="text-[9px] text-[#5C6080]">+{sig.indicators.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price targets */}
|
||||
{(sig.target_price || sig.stop_loss) && (
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px]">
|
||||
{sig.target_price && (
|
||||
<span className="flex items-center gap-1 text-[#06D6A0]">
|
||||
<Target className="w-3 h-3" />
|
||||
TP {formatPrice(sig.target_price)}
|
||||
</span>
|
||||
)}
|
||||
{sig.stop_loss && (
|
||||
<span className="flex items-center gap-1 text-[#FF3366]">
|
||||
<Zap className="w-3 h-3" />
|
||||
SL {formatPrice(sig.stop_loss)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confidence + outcome */}
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-[9px] text-[#5C6080] uppercase">Conf</div>
|
||||
<div className="text-base font-bold" style={{ color: confColor }}>{sig.confidence}%</div>
|
||||
{sig.outcome && sig.outcome !== 'pending' && (
|
||||
<div className={`text-[9px] font-bold uppercase mt-0.5 ${
|
||||
sig.outcome === 'hit' ? 'text-[#06D6A0]' : 'text-[#FF3366]'
|
||||
}`}>
|
||||
{sig.outcome}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
src/components/rugcharts/LiveTradeTape.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { ArrowUpRight, ArrowDownRight, Bot, Wallet, AlertTriangle, Zap, Eye } from 'lucide-react';
|
||||
|
||||
export interface Trade {
|
||||
id: string;
|
||||
side: 'buy' | 'sell';
|
||||
amount_usd: number;
|
||||
amount_token: number;
|
||||
price_usd: number;
|
||||
wallet: string;
|
||||
wallet_label?: 'whale' | 'sniper' | 'bot' | 'fresh' | 'insider' | 'bundle' | 'dev';
|
||||
tx_hash: string;
|
||||
timestamp: number;
|
||||
chain: string;
|
||||
is_liquidity?: boolean;
|
||||
}
|
||||
|
||||
interface LiveTradeTapeProps {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
initialTrades?: Trade[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Wallet label color/icon mapping
|
||||
const LABEL_STYLES: Record<NonNullable<Trade['wallet_label']>, { color: string; bg: string; icon: any; label: string }> = {
|
||||
whale: { color: '#FFB800', bg: 'rgba(255,184,0,0.15)', icon: Eye, label: 'WHALE' },
|
||||
sniper: { color: '#FF3366', bg: 'rgba(255,51,102,0.15)', icon: Zap, label: 'SNIPER' },
|
||||
bot: { color: '#8B5CF6', bg: 'rgba(139,92,246,0.15)', icon: Bot, label: 'BOT' },
|
||||
fresh: { color: '#5C6080', bg: 'rgba(92,96,128,0.15)', icon: Wallet, label: 'FRESH' },
|
||||
insider: { color: '#FF6B6B', bg: 'rgba(255,107,107,0.2)', icon: AlertTriangle,label: 'INSIDER' },
|
||||
bundle: { color: '#EC4899', bg: 'rgba(236,72,153,0.15)', icon: Bot, label: 'BUNDLE' },
|
||||
dev: { color: '#F59E0B', bg: 'rgba(245,158,11,0.2)', icon: AlertTriangle,label: 'DEV' },
|
||||
};
|
||||
|
||||
const EXPLORERS: Record<string, string> = {
|
||||
solana: 'https://solscan.io/tx/',
|
||||
ethereum: 'https://etherscan.io/tx/',
|
||||
base: 'https://basescan.org/tx/',
|
||||
bsc: 'https://bscscan.com/tx/',
|
||||
arbitrum: 'https://arbiscan.io/tx/',
|
||||
optimism: 'https://optimistic.etherscan.io/tx/',
|
||||
polygon: 'https://polygonscan.com/tx/',
|
||||
};
|
||||
|
||||
export default function LiveTradeTape({ chain, tokenAddress, initialTrades = [], className = '' }: LiveTradeTapeProps) {
|
||||
const [trades, setTrades] = useState<Trade[]>(initialTrades);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState<'all' | 'whale' | 'bot' | 'suspicious'>('all');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to DataBus WebSocket for live trades
|
||||
const wsUrl = (window as any).DATA_WS_URL || `wss://${window.location.host}/ws/trades`;
|
||||
let reconnectTimer: any;
|
||||
let isMounted = true;
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
const ws = new WebSocket(`${wsUrl}?chain=${chain}&token=${encodeURIComponent(tokenAddress)}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[TradeTape] Connected', chain, tokenAddress);
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
if (paused || !isMounted) return;
|
||||
try {
|
||||
const trade = JSON.parse(evt.data) as Trade;
|
||||
setTrades(prev => [trade, ...prev].slice(0, 200));
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (isMounted) reconnectTimer = setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
} catch (e) {
|
||||
reconnectTimer = setTimeout(connect, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [chain, tokenAddress, paused]);
|
||||
|
||||
// Auto-scroll to top when new trade arrives
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current || paused) return;
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}, [trades, paused]);
|
||||
|
||||
// Compute live stats
|
||||
const stats = trades.reduce(
|
||||
(acc, t) => {
|
||||
if (t.side === 'buy') acc.buyVol += t.amount_usd;
|
||||
else acc.sellVol += t.amount_usd;
|
||||
if (t.wallet_label && ['whale', 'insider', 'bundle'].includes(t.wallet_label)) acc.suspicious++;
|
||||
return acc;
|
||||
},
|
||||
{ buyVol: 0, sellVol: 0, suspicious: 0 }
|
||||
);
|
||||
const netFlow = stats.buyVol - stats.sellVol;
|
||||
const buyPressure = stats.buyVol / Math.max(1, stats.buyVol + stats.sellVol);
|
||||
|
||||
const filtered = trades.filter(t => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'whale') return t.wallet_label === 'whale';
|
||||
if (filter === 'bot') return t.wallet_label === 'bot' || t.wallet_label === 'sniper';
|
||||
if (filter === 'suspicious') return t.wallet_label && ['insider', 'bundle', 'dev'].includes(t.wallet_label);
|
||||
return true;
|
||||
});
|
||||
|
||||
const explorer = EXPLORERS[chain] || EXPLORERS.ethereum;
|
||||
|
||||
return (
|
||||
<div className={`bg-[#0a0a0f] border border-[rgba(139,92,246,0.15)] rounded-xl overflow-hidden ${className}`}>
|
||||
{/* Header stats bar */}
|
||||
<div className="grid grid-cols-4 gap-px bg-[rgba(139,92,246,0.08)] border-b border-[rgba(139,92,246,0.15)]">
|
||||
<div className="bg-[#0a0a0f] px-3 py-2">
|
||||
<div className="text-[10px] text-[#5C6080] uppercase tracking-wider">Buy Vol</div>
|
||||
<div className="text-sm font-semibold text-[#06D6A0]">${stats.buyVol.toLocaleString(undefined, { maximumFractionDigits: 0 })}</div>
|
||||
</div>
|
||||
<div className="bg-[#0a0a0f] px-3 py-2">
|
||||
<div className="text-[10px] text-[#5C6080] uppercase tracking-wider">Sell Vol</div>
|
||||
<div className="text-sm font-semibold text-[#FF3366]">${stats.sellVol.toLocaleString(undefined, { maximumFractionDigits: 0 })}</div>
|
||||
</div>
|
||||
<div className="bg-[#0a0a0f] px-3 py-2">
|
||||
<div className="text-[10px] text-[#5C6080] uppercase tracking-wider">Net Flow</div>
|
||||
<div className={`text-sm font-semibold ${netFlow >= 0 ? 'text-[#06D6A0]' : 'text-[#FF3366]'}`}>
|
||||
{netFlow >= 0 ? '+' : ''}${netFlow.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#0a0a0f] px-3 py-2">
|
||||
<div className="text-[10px] text-[#5C6080] uppercase tracking-wider">Buy Pressure</div>
|
||||
<div className="text-sm font-semibold" style={{ color: buyPressure > 0.5 ? '#06D6A0' : '#FF3366' }}>
|
||||
{(buyPressure * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter & controls */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(139,92,246,0.08)]">
|
||||
<div className="flex gap-1">
|
||||
{(['all', 'whale', 'bot', 'suspicious'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider rounded transition-colors ${
|
||||
filter === f
|
||||
? 'bg-[#8B5CF6] text-white'
|
||||
: 'bg-[rgba(139,92,246,0.08)] text-[#5C6080] hover:text-[#F1F1F6]'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[#5C6080]">
|
||||
{stats.suspicious > 0 && (
|
||||
<span className="flex items-center gap-1 text-[#FF3366] animate-pulse">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{stats.suspicious} SUSPICIOUS
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPaused(p => !p)}
|
||||
className="px-2 py-0.5 bg-[rgba(139,92,246,0.08)] hover:bg-[rgba(139,92,246,0.2)] rounded text-[10px]"
|
||||
>
|
||||
{paused ? '▶ RESUME' : '⏸ PAUSE'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade list */}
|
||||
<div ref={scrollRef} className="max-h-[500px] overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-8 text-center text-[#5C6080] text-sm">
|
||||
Waiting for trades...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="sticky top-0 bg-[#0a0a0f] border-b border-[rgba(139,92,246,0.08)]">
|
||||
<tr className="text-[#5C6080] text-[9px] uppercase tracking-wider">
|
||||
<th className="text-left px-2 py-1">Time</th>
|
||||
<th className="text-left px-2 py-1">Type</th>
|
||||
<th className="text-left px-2 py-1">Wallet</th>
|
||||
<th className="text-right px-2 py-1">USD</th>
|
||||
<th className="text-right px-2 py-1">Token</th>
|
||||
<th className="text-right px-2 py-1">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(t => {
|
||||
const Style = t.wallet_label ? LABEL_STYLES[t.wallet_label] : null;
|
||||
const Icon = Style?.icon;
|
||||
const timeAgo = Math.max(0, Math.floor((Date.now() - t.timestamp) / 1000));
|
||||
const timeStr = timeAgo < 60 ? `${timeAgo}s` : `${Math.floor(timeAgo/60)}m`;
|
||||
return (
|
||||
<tr
|
||||
key={t.id}
|
||||
className="border-b border-[rgba(139,92,246,0.04)] hover:bg-[rgba(139,92,246,0.04)] transition-colors"
|
||||
>
|
||||
<td className="px-2 py-1.5 text-[#5C6080] font-mono text-[10px]">{timeStr}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={`flex items-center gap-1 font-semibold ${t.side === 'buy' ? 'text-[#06D6A0]' : 'text-[#FF3366]'}`}>
|
||||
{t.side === 'buy' ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||
{t.side.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<a
|
||||
href={`${EXPLORERS[t.chain] || explorer}${t.tx_hash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 hover:underline"
|
||||
>
|
||||
<code className="text-[#F1F1F6] font-mono text-[10px]">
|
||||
{t.wallet.slice(0, 4)}...{t.wallet.slice(-4)}
|
||||
</code>
|
||||
{Style && Icon && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider flex items-center gap-0.5"
|
||||
style={{ color: Style.color, background: Style.bg }}
|
||||
title={Style.label}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5" />
|
||||
{Style.label}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono font-semibold text-[#F1F1F6]">
|
||||
${t.amount_usd.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono text-[#5C6080]">
|
||||
{t.amount_token.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono text-[#5C6080]">
|
||||
${t.price_usd.toExponential(3)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
321
src/components/rugcharts/MultiChainCompare.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ArrowUpRight, ArrowDownRight, ChevronLeft, ChevronRight, Activity, Droplet, Users, TrendingUp, TrendingDown, Shield, ShieldAlert } from 'lucide-react';
|
||||
|
||||
export interface ComparisonToken {
|
||||
chain: string;
|
||||
address: string;
|
||||
symbol: string;
|
||||
name?: string;
|
||||
price_usd: number;
|
||||
price_change_24h: number;
|
||||
volume_24h: number;
|
||||
liquidity_usd: number;
|
||||
holder_count: number;
|
||||
top_holder_pct: number;
|
||||
risk_score: number;
|
||||
risk_level?: string;
|
||||
market_cap?: number;
|
||||
age_hours?: number;
|
||||
}
|
||||
|
||||
interface MultiChainCompareProps {
|
||||
initialTokens?: ComparisonToken[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CHAIN_META: Record<string, { name: string; color: string; icon: string }> = {
|
||||
solana: { name: 'Solana', color: '#9945FF', icon: '◎' },
|
||||
ethereum: { name: 'Ethereum', color: '#627EEA', icon: 'Ξ' },
|
||||
base: { name: 'Base', color: '#0052FF', icon: '⊙' },
|
||||
bsc: { name: 'BSC', color: '#F0B90B', icon: '⬡' },
|
||||
arbitrum: { name: 'Arbitrum', color: '#28A0F0', icon: '◆' },
|
||||
optimism: { name: 'Optimism', color: '#FF0420', icon: '⬢' },
|
||||
polygon: { name: 'Polygon', color: '#8247E5', icon: '⬣' },
|
||||
};
|
||||
|
||||
const EXPLORERS: Record<string, string> = {
|
||||
solana: 'https://dexscreener.com/solana/',
|
||||
ethereum: 'https://dexscreener.com/ethereum/',
|
||||
base: 'https://dexscreener.com/base/',
|
||||
bsc: 'https://dexscreener.com/bsc/',
|
||||
arbitrum: 'https://dexscreener.com/arbitrum/',
|
||||
optimism: 'https://dexscreener.com/optimism/',
|
||||
polygon: 'https://dexscreener.com/polygon/',
|
||||
};
|
||||
|
||||
// Smart scoring: composite of safety, momentum, distribution
|
||||
function computeWinnerScore(t: ComparisonToken): { score: number; reasons: string[] } {
|
||||
let score = 0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Risk score (lower is better) → +50 if low risk
|
||||
if (t.risk_score < 30) { score += 50; reasons.push('Low risk (+50)'); }
|
||||
else if (t.risk_score < 60) { score += 25; reasons.push('Medium risk (+25)'); }
|
||||
else { reasons.push('High risk (0)'); }
|
||||
|
||||
// Holder distribution (lower top holder % = healthier)
|
||||
if (t.top_holder_pct < 10) { score += 30; reasons.push('Decentralized holders (+30)'); }
|
||||
else if (t.top_holder_pct < 25) { score += 15; reasons.push('Moderate concentration (+15)'); }
|
||||
else { reasons.push('Concentrated (0)'); }
|
||||
|
||||
// Liquidity depth (higher = safer)
|
||||
if (t.liquidity_usd > 100000) { score += 20; reasons.push('Deep liquidity (+20)'); }
|
||||
else if (t.liquidity_usd > 10000) { score += 10; reasons.push('Adequate liquidity (+10)'); }
|
||||
else { reasons.push('Thin liquidity (0)'); }
|
||||
|
||||
// Volume trend (positive = momentum)
|
||||
if (t.price_change_24h > 10) { score += 15; reasons.push('Strong momentum (+15)'); }
|
||||
else if (t.price_change_24h > 0) { score += 8; reasons.push('Mild momentum (+8)'); }
|
||||
else if (t.price_change_24h > -10) { reasons.push('Slight decline (0)'); }
|
||||
else { reasons.push('Heavy dump (0)'); }
|
||||
|
||||
return { score, reasons };
|
||||
}
|
||||
|
||||
export default function MultiChainCompare({ initialTokens = [], className = '' }: MultiChainCompareProps) {
|
||||
const [tokens, setTokens] = useState<ComparisonToken[]>(initialTokens);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const perPage = 4;
|
||||
|
||||
// Add token by chain+address
|
||||
const addToken = async (chain: string, address: string) => {
|
||||
if (!chain || !address) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/rugcharts/analyze/${encodeURIComponent(address)}?chain=${chain}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const token: ComparisonToken = {
|
||||
chain,
|
||||
address,
|
||||
symbol: data.symbol || data.token_symbol || address.slice(0, 6),
|
||||
name: data.name || data.token_name,
|
||||
price_usd: Number(data.price_usd || data.price || 0),
|
||||
price_change_24h: Number(data.price_change_24h || data.priceChange24h || 0),
|
||||
volume_24h: Number(data.volume_24h || data.volume || 0),
|
||||
liquidity_usd: Number(data.liquidity_usd || data.liquidity || 0),
|
||||
holder_count: Number(data.holder_count || data.holders || 0),
|
||||
top_holder_pct: Number(data.top_holder_pct || data.top10Pct || 0),
|
||||
risk_score: Number(data.risk_score || data.riskScore || 0),
|
||||
risk_level: data.risk_level || data.riskLevel,
|
||||
market_cap: Number(data.market_cap || data.fdv || 0),
|
||||
age_hours: Number(data.age_hours || data.ageHours || 0),
|
||||
};
|
||||
setTokens(prev => [...prev, token]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MultiChainCompare] add failed:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeToken = (idx: number) => {
|
||||
setTokens(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
// Quick-add via search
|
||||
const handleAdd = () => {
|
||||
// Parse "chain:address" or just address
|
||||
let chain = 'solana';
|
||||
let addr = search.trim();
|
||||
if (addr.includes(':')) {
|
||||
[chain, addr] = addr.split(':', 2);
|
||||
}
|
||||
if (addr) {
|
||||
addToken(chain, addr);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
|
||||
// Compute winner ranking
|
||||
const ranked = useMemo(() => {
|
||||
return tokens
|
||||
.map((t, idx) => ({ token: t, idx, ...computeWinnerScore(t) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}, [tokens]);
|
||||
|
||||
const winner = ranked[0];
|
||||
|
||||
const visiblePage = ranked.slice(page * perPage, (page + 1) * perPage);
|
||||
const totalPages = Math.ceil(ranked.length / perPage);
|
||||
|
||||
return (
|
||||
<div className={`bg-[#0a0a0f] border border-[rgba(139,92,246,0.15)] rounded-xl overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-[rgba(139,92,246,0.15)] bg-gradient-to-r from-[rgba(139,92,246,0.08)] to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[#F1F1F6] uppercase tracking-wider">Multi-Chain Comparison</h3>
|
||||
<p className="text-[10px] text-[#5C6080] mt-0.5">Compare up to 4 tokens · AI picks winner based on safety + momentum</p>
|
||||
</div>
|
||||
{winner && (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-[#5C6080] uppercase tracking-wider">Best pick</div>
|
||||
<div className="text-sm font-bold text-[#06D6A0] flex items-center gap-1">
|
||||
{winner.token.symbol} <span className="text-[10px] text-[#FFB800]">{winner.score}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick-add bar */}
|
||||
<div className="px-4 py-3 border-b border-[rgba(139,92,246,0.08)]">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); }}
|
||||
placeholder="chain:address (e.g. solana:So1111... or just 0x...)"
|
||||
className="flex-1 px-3 py-1.5 bg-[rgba(255,255,255,0.02)] border border-[rgba(139,92,246,0.08)] rounded-lg text-xs text-[#F1F1F6] placeholder:text-[#5C6080] focus:border-[#8B5CF6]/30 focus:outline-none font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={loading || !search.trim()}
|
||||
className="px-3 py-1.5 bg-[#8B5CF6] hover:bg-[#A78BFA] disabled:opacity-50 rounded-lg text-xs font-semibold uppercase tracking-wider text-white transition-colors"
|
||||
>
|
||||
{loading ? '...' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison grid */}
|
||||
{ranked.length === 0 ? (
|
||||
<div className="p-8 text-center text-[#5C6080] text-xs">
|
||||
Add tokens to compare. Format: <code className="text-[#8B5CF6]">chain:address</code> · Default chain is solana.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Header row */}
|
||||
<div className="grid grid-cols-12 gap-2 text-[9px] uppercase tracking-wider text-[#5C6080] font-bold pb-1 border-b border-[rgba(139,92,246,0.08)]">
|
||||
<div className="col-span-2">Token</div>
|
||||
<div className="col-span-2 text-right">Price</div>
|
||||
<div className="col-span-1 text-right">24h</div>
|
||||
<div className="col-span-2 text-right">Volume</div>
|
||||
<div className="col-span-2 text-right">Liquidity</div>
|
||||
<div className="col-span-1 text-right">Holders</div>
|
||||
<div className="col-span-1 text-right">Risk</div>
|
||||
<div className="col-span-1 text-right"></div>
|
||||
</div>
|
||||
|
||||
{/* Token rows */}
|
||||
{visiblePage.map(({ token, idx, score, reasons }) => {
|
||||
const meta = CHAIN_META[token.chain] || CHAIN_META.solana;
|
||||
const isWinner = winner?.idx === idx;
|
||||
return (
|
||||
<div
|
||||
key={`${token.chain}-${token.address}-${idx}`}
|
||||
className={`grid grid-cols-12 gap-2 items-center text-[11px] py-1.5 px-1 rounded transition-colors ${
|
||||
isWinner ? 'bg-[rgba(6,214,160,0.08)] border border-[rgba(6,214,160,0.3)]' : 'hover:bg-[rgba(139,92,246,0.04)]'
|
||||
}`}
|
||||
>
|
||||
<div className="col-span-2 flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm" style={{ color: meta.color }}>{meta.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-bold text-[#F1F1F6] truncate">{token.symbol}</span>
|
||||
{isWinner && <span className="text-[10px] text-[#06D6A0]">★</span>}
|
||||
</div>
|
||||
<a
|
||||
href={`${EXPLORERS[token.chain] || ''}${token.address}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[9px] text-[#5C6080] hover:text-[#8B5CF6] truncate block font-mono"
|
||||
>
|
||||
{token.address.slice(0, 8)}...{token.address.slice(-4)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-right font-mono text-[#F1F1F6]">
|
||||
${token.price_usd < 0.01 ? token.price_usd.toExponential(2) : token.price_usd.toFixed(token.price_usd < 1 ? 4 : 2)}
|
||||
</div>
|
||||
<div className={`col-span-1 text-right font-mono font-semibold flex items-center justify-end gap-0.5 ${
|
||||
token.price_change_24h >= 0 ? 'text-[#06D6A0]' : 'text-[#FF3366]'
|
||||
}`}>
|
||||
{token.price_change_24h >= 0 ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||
{Math.abs(token.price_change_24h).toFixed(1)}%
|
||||
</div>
|
||||
<div className="col-span-2 text-right font-mono text-[#F1F1F6]">
|
||||
${token.volume_24h.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</div>
|
||||
<div className="col-span-2 text-right font-mono text-[#F1F1F6] flex items-center justify-end gap-1">
|
||||
<Droplet className="w-3 h-3 text-[#22D3EE]" />
|
||||
${token.liquidity_usd.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</div>
|
||||
<div className="col-span-1 text-right font-mono text-[#F1F1F6] flex items-center justify-end gap-1">
|
||||
<Users className="w-3 h-3 text-[#5C6080]" />
|
||||
{token.holder_count.toLocaleString()}
|
||||
</div>
|
||||
<div className="col-span-1 text-right">
|
||||
<div className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-bold ${
|
||||
token.risk_score < 30 ? 'bg-[rgba(6,214,160,0.15)] text-[#06D6A0]' :
|
||||
token.risk_score < 60 ? 'bg-[rgba(255,184,0,0.15)] text-[#FFB800]' :
|
||||
'bg-[rgba(255,51,102,0.15)] text-[#FF3366]'
|
||||
}`}>
|
||||
{token.risk_score < 30 ? <Shield className="w-2.5 h-2.5" /> : <ShieldAlert className="w-2.5 h-2.5" />}
|
||||
{token.risk_score}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-right">
|
||||
<button
|
||||
onClick={() => removeToken(idx)}
|
||||
className="text-[10px] text-[#5C6080] hover:text-[#FF3366] transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-2 text-[10px]">
|
||||
<span className="text-[#5C6080]">
|
||||
Page {page + 1} of {totalPages} · {ranked.length} tokens
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="p-1 bg-[rgba(139,92,246,0.08)] hover:bg-[rgba(139,92,246,0.2)] disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="p-1 bg-[rgba(139,92,246,0.08)] hover:bg-[rgba(139,92,246,0.2)] disabled:opacity-30 rounded"
|
||||
>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Winner explanation */}
|
||||
{winner && (
|
||||
<div className="px-4 py-3 border-t border-[rgba(139,92,246,0.08)] bg-[rgba(6,214,160,0.04)]">
|
||||
<div className="text-[10px] uppercase tracking-wider text-[#06D6A0] font-bold mb-1.5 flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" /> Why {winner.token.symbol} wins ({winner.score}/100)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{winner.reasons.map((r, i) => (
|
||||
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-[rgba(6,214,160,0.1)] text-[#06D6A0] border border-[rgba(6,214,160,0.2)]">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
392
src/components/rugcharts/PluginRenderer.tsx
Executable file
|
|
@ -0,0 +1,392 @@
|
|||
// @ts-nocheck
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, RadarChart, Radar,
|
||||
PieChart, Pie, Cell, ResponsiveContainer, XAxis, YAxis,
|
||||
CartesianGrid, Tooltip as RechartsTooltip, Legend,
|
||||
ScatterChart, Scatter,
|
||||
} from 'recharts';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#8b5cf6','#9333ea','#1d4ed8','#22c55e','#00E676'];
|
||||
|
||||
function Tooltip({ content }) {
|
||||
return (
|
||||
<div className="bg-[#0e0e16]/95 border border-white/10 rounded-xl p-3 backdrop-blur-xl shadow-2xl pointer-events-none z-50">
|
||||
<style>{`
|
||||
[data-tooltip] { position: relative; }
|
||||
[data-tooltip]:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
|
||||
background: #0e0e16; border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 8px; padding: 8px 12px; font-size: 11px; color: #e5e7eb;
|
||||
white-space: nowrap; z-index: 50; box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RiskBadge({ value, size = 'md' }) {
|
||||
const color = value >= 70 ? '#ef4444' : value >= 50 ? '#f97316' : value >= 30 ? '#eab308' : '#22c55e';
|
||||
const label = value >= 70 ? 'CRITICAL' : value >= 50 ? 'HIGH' : value >= 30 ? 'MEDIUM' : 'LOW';
|
||||
const sizeClasses = size === 'lg' ? 'text-5xl' : size === 'md' ? 'text-2xl' : 'text-sm';
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className={`${sizeClasses} font-black`} style={{ color }}>{value}</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CircularProgress({ value, size = 120, color = '#8b5cf6', label = '' }) {
|
||||
const radius = (size - 12) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size/2} cy={size/2} r={radius} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="8" />
|
||||
<circle cx={size/2} cy={size/2} r={radius} fill="none" stroke={color} strokeWidth="8"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
strokeLinecap="round" className="transition-all duration-1000" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-black" style={{ color }}>{Math.round(value)}</span>
|
||||
{label && <span className="text-[10px] text-gray-500 mt-1">{label}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScorecardItem({ item }) {
|
||||
const passed = item.score <= 30;
|
||||
return (
|
||||
<div className={`p-3 rounded-xl border ${passed ? 'bg-green-500/5 border-green-500/20' : 'bg-red-500/5 border-red-500/20'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-white">{item.name}</span>
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${passed ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
|
||||
{passed ? '✓ PASS' : '✗ FAIL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-700"
|
||||
style={{ width: `${item.score}%`, backgroundColor: passed ? '#22c55e' : '#ef4444' }} />
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-gray-500 mt-1">{item.score.toFixed(0)}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DonutChart({ data, outerRadius = 100, innerRadius = 60 }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data} cx="50%" cy="50%" outerRadius={outerRadius} innerRadius={innerRadius}
|
||||
paddingAngle={2} dataKey="value" isAnimationActive
|
||||
>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} stroke="rgba(0,0,0,0.2)" strokeWidth={2} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip contentStyle={{ background: '#0e0e16', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', fontSize: '12px' }} />
|
||||
<Legend formatter={(value) => <span className="text-xs text-gray-400">{value}</span>} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function StackedBarChart({ data, groups }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={data} stackOffset="sign" margin={{ top: 10, right: 10, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
{groups.map((g, i) => (
|
||||
<linearGradient key={i} id={`grad-${i}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={g.color} stopOpacity={0.8} />
|
||||
<stop offset="100%" stopColor={g.color} stopOpacity={0.3} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" vertical={false} />
|
||||
<XAxis dataKey="time" tick={{ fill: '#6b7280', fontSize: 10 }} axisLine={{ stroke: 'rgba(255,255,255,0.05)' }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ background: 'rgba(14,14,22,0.95)', border: '1px solid rgba(139,92,246,0.2)', borderRadius: '12px', fontSize: '11px', backdropFilter: 'blur(8px)' }}
|
||||
cursor={{ fill: 'rgba(139,92,246,0.05)' }}
|
||||
/>
|
||||
{groups.map((g, i) => (
|
||||
<Bar key={i} dataKey={g.key} stackId="a" fill={`url(#grad-${i})`} name={g.label} radius={[4,4,0,0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DualLineChart({ data, lines }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={data} margin={{ top: 10, right: 10, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
{lines.map((l, i) => (
|
||||
<linearGradient key={i} id={`lineGrad-${i}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={l.color} stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor={l.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" vertical={false} />
|
||||
<XAxis dataKey={Object.keys(data[0] || {})[0]} tick={{ fill: '#6b7280', fontSize: 10 }} axisLine={{ stroke: 'rgba(255,255,255,0.05)' }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} axisLine={false} tickLine={false} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ background: 'rgba(14,14,22,0.95)', border: '1px solid rgba(139,92,246,0.2)', borderRadius: '12px', fontSize: '11px', backdropFilter: 'blur(8px)' }}
|
||||
cursor={{ stroke: 'rgba(139,92,246,0.3)', strokeWidth: 1 }}
|
||||
/>
|
||||
{lines.map((l, i) => (
|
||||
<Line
|
||||
key={i}
|
||||
type="monotone"
|
||||
dataKey={l.key}
|
||||
stroke={l.color}
|
||||
strokeWidth={2}
|
||||
name={l.label}
|
||||
dot={{ fill: l.color, r: 3, stroke: '#0e0e16', strokeWidth: 2 }}
|
||||
activeDot={{ r: 5, fill: l.color, stroke: '#fff', strokeWidth: 2 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`fill-${i}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={l.color} stopOpacity={0.15} />
|
||||
<stop offset="100%" stopColor={l.color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</Line>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function RadarChartComponent({ data, dimensions }) {
|
||||
const chartData = data.map(d => ({ subject: d.name, risk: d.risk }));
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RadarChart data={chartData}>
|
||||
<PolarGrid stroke="rgba(255,255,255,0.08)" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#9ca3af', fontSize: 10 }} />
|
||||
<PolarRadiusAxis domain={[0, 100]} tick={{ fill: '#6b7280', fontSize: 9 }} />
|
||||
<Radar name="Risk" dataKey="risk" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.3} strokeWidth={2} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapChart({ data, dimensions }) {
|
||||
if (!data?.length || !dimensions) return null;
|
||||
const period = data[0].period || 'time';
|
||||
const maxVal = 100;
|
||||
const getColor = (v) => {
|
||||
const pct = v / maxVal;
|
||||
if (pct > 0.75) return '#ef4444';
|
||||
if (pct > 0.5) return '#f97316';
|
||||
if (pct > 0.25) return '#eab308';
|
||||
return '#22c55e';
|
||||
};
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[600px]">
|
||||
<div className="grid" style={{ gridTemplateColumns: `120px repeat(${dimensions.length}, 1fr)` }}>
|
||||
{/* Header row */}
|
||||
<div className="p-2 text-[10px] text-gray-500 font-bold uppercase">Period</div>
|
||||
{dimensions.map((d, i) => (
|
||||
<div key={i} className="p-2 text-[10px] text-gray-400 text-center font-bold">{d}</div>
|
||||
))}
|
||||
{/* Data rows */}
|
||||
{data.map((row, ri) => (
|
||||
<div key={ri} className="contents">
|
||||
<div className="p-2 text-[10px] text-gray-400 font-mono">{row[period]}</div>
|
||||
{dimensions.map((d, di) => {
|
||||
const val = row[d.toLowerCase().replace(' ', '_')];
|
||||
return (
|
||||
<div key={di} className="p-2 rounded-lg text-center text-[11px] font-bold text-white"
|
||||
style={{ backgroundColor: getColor(val) + '30', color: getColor(val) }}>
|
||||
{val}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalBarChart({ data }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||
<XAxis type="number" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="label" width={120} tick={{ fill: '#9ca3af', fontSize: 10 }} />
|
||||
<RechartsTooltip contentStyle={{ background: '#0e0e16', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', fontSize: '11px' }} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Renderer ───
|
||||
|
||||
export function PluginRenderer({ plugin }) {
|
||||
if (!plugin) return <div className="text-gray-500 text-sm">No plugin data</div>;
|
||||
|
||||
const { type, title, description, data, summary, chart_config } = plugin;
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white">{title}</h3>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{description}</p>
|
||||
</div>
|
||||
{summary.risk_level && (
|
||||
<span className="text-[10px] font-bold px-2 py-1 rounded-full border"
|
||||
style={{
|
||||
borderColor: (summary.risk_level === 'CRITICAL' ? '#ef4444' : summary.risk_level === 'HIGH' ? '#f97316' : summary.risk_level === 'MEDIUM' ? '#eab308' : '#22c55e') + '44',
|
||||
color: summary.risk_level === 'CRITICAL' ? '#ef4444' : summary.risk_level === 'HIGH' ? '#f97316' : summary.risk_level === 'MEDIUM' ? '#eab308' : '#22c55e'
|
||||
}}>
|
||||
{summary.risk_level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scorecard */}
|
||||
{type === 'token_health' && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<CircularProgress value={summary.overall_score || 50} size={140}
|
||||
color={summary.overall_score > 70 ? '#22c55e' : summary.overall_score > 50 ? '#eab308' : '#ef4444'}
|
||||
label="Health Score" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(summary.recommendations || []).map((rec, i) => (
|
||||
<div key={i} className="p-2 rounded-lg bg-yellow-500/5 border border-yellow-500/10 text-[10px] text-yellow-400">
|
||||
⚠ {rec}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{data.map((item, i) => <ScorecardItem key={i} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Donut */}
|
||||
{type === 'supply_donut' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<CircularProgress value={summary.genuine_holders_pct || 50} size={100}
|
||||
color="#22c55e" label="Genuine" />
|
||||
<div className="mx-4 text-center">
|
||||
<div className="text-lg font-black" style={{ color: summary.risk_level === 'CRITICAL' ? '#ef4444' : '#f97316' }}>
|
||||
{summary.high_risk_percentage.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500">High Risk</div>
|
||||
</div>
|
||||
</div>
|
||||
<DonutChart data={data} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked Bar (wallet flow) */}
|
||||
{type === 'wallet_flow' && (
|
||||
<StackedBarChart data={data} groups={chart_config.groups} />
|
||||
)}
|
||||
|
||||
{/* Heatmap */}
|
||||
{type === 'risk_heatmap' && (
|
||||
<HeatmapChart data={data} dimensions={chart_config.dimensions} />
|
||||
)}
|
||||
|
||||
{/* Cluster Activity Heatmap */}
|
||||
{type === 'cluster_activity' && (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{Object.entries(chart_config.colors || {}).map(([name, color]) => (
|
||||
<div key={name} className="flex items-center gap-1.5 text-[10px]">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-gray-400">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HeatmapChart data={data} dimensions={chart_config.clusters} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Radar */}
|
||||
{type === 'contract_risk' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-8 mb-2">
|
||||
<RiskBadge value={summary.average_risk} size="md" />
|
||||
<div>
|
||||
<div className="text-xs text-gray-400">Passed</div>
|
||||
<div className="text-lg font-black text-green-400">{summary.passed_checks}/{summary.total_checks}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-400">Failed</div>
|
||||
<div className="text-lg font-black text-red-400">{summary.failed_checks}/{summary.total_checks}</div>
|
||||
</div>
|
||||
</div>
|
||||
<RadarChartComponent data={data} dimensions={data.map(d => d.name)} />
|
||||
{summary.red_flags?.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{summary.red_flags.map((flag, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-[10px] text-red-400">
|
||||
<span>⚠</span> {flag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line Charts */}
|
||||
{(type === 'wash_trading' || type === 'social_divergence') && (
|
||||
<DualLineChart data={data} lines={chart_config.lines} />
|
||||
)}
|
||||
|
||||
{/* Horizontal Bar */}
|
||||
{(type === 'holder_evolution' || type === 'fund_flow') && (
|
||||
<HorizontalBarChart data={data} />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Summary Cards ───
|
||||
|
||||
export function PluginSummary({ plugin }) {
|
||||
if (!plugin || !plugin.summary) return null;
|
||||
const { summary } = plugin;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{Object.entries(summary).filter(([k]) => k !== 'recommendations' && k !== 'red_flags').map(([key, val]) => (
|
||||
<div key={key} className="bg-white/[0.02] border border-white/5 rounded-lg p-2.5 text-center">
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider">{key.replace(/_/g, ' ')}</div>
|
||||
<div className="text-sm font-bold text-white mt-0.5 truncate">
|
||||
{typeof val === 'number' ? (val >= 100 ? Math.round(val) : val.toFixed(1)) : String(val)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
398
src/components/rugcharts/PluginViewer.tsx
Executable file
|
|
@ -0,0 +1,398 @@
|
|||
// @ts-nocheck
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, RadarChart, Radar,
|
||||
PieChart, Pie, Cell, ResponsiveContainer, XAxis, YAxis,
|
||||
CartesianGrid, Tooltip as RechartsTooltip,
|
||||
} from 'recharts';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
// ─── Plugin Metadata ───
|
||||
const PLUGIN_META = [
|
||||
{ name: 'wallet_flow', label: 'Wallet Flow', icon: '📊', color: '#22c55e' },
|
||||
{ name: 'risk_heatmap', label: 'Risk Heatmap', icon: '🔥', color: '#ef4444' },
|
||||
{ name: 'supply_donut', label: 'Supply Donut', icon: '🍩', color: '#8b5cf6' },
|
||||
{ name: 'wash_trading', label: 'Wash Trading', icon: '⚡', color: '#f97316' },
|
||||
{ name: 'contract_risk', label: 'Contract Risk', icon: '🔒', color: '#9333ea' },
|
||||
{ name: 'social_divergence', label: 'Social Divergence', icon: '💬', color: '#06b6d4' },
|
||||
{ name: 'holder_evolution', label: 'Holder Evolution', icon: '📈', color: '#eab308' },
|
||||
{ name: 'fund_flow', label: 'Fund Flow', icon: '🌐', color: '#1d4ed8' },
|
||||
{ name: 'cluster_activity', label: 'Cluster Activity', icon: '🕐', color: '#8b5cf6' },
|
||||
{ name: 'token_health', label: 'Token Health', icon: '💚', color: '#22c55e' },
|
||||
];
|
||||
|
||||
// ─── Helpers ───
|
||||
function riskColor(v) {
|
||||
if (v >= 70) return '#ef4444';
|
||||
if (v >= 50) return '#f97316';
|
||||
if (v >= 30) return '#eab308';
|
||||
return '#22c55e';
|
||||
}
|
||||
|
||||
function CircularProgress({ value, size = 120, color = '#8b5cf6' }) {
|
||||
const r = (size - 12) / 2;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const offset = circ - (value / 100) * circ;
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="8" />
|
||||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={color} strokeWidth="8"
|
||||
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 1s ease-in-out' }} />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-black" style={{ color }}>{Math.round(value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScorecardItem({ item }) {
|
||||
const passed = item.score <= 30;
|
||||
return (
|
||||
<div className={`p-3 rounded-xl border ${passed ? 'bg-green-500/5 border-green-500/20' : 'bg-red-500/5 border-red-500/20'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-white">{item.name}</span>
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${passed ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
|
||||
{passed ? '✓ PASS' : '✗ FAIL'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-700"
|
||||
style={{ width: `${item.score}%`, backgroundColor: passed ? '#22c55e' : '#ef4444' }} />
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-gray-500 mt-1">{item.score.toFixed(0)}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapChart({ data, dimensions }) {
|
||||
if (!data?.length || !dimensions) return null;
|
||||
const period = data[0].period || 'time';
|
||||
const getColor = (v) => {
|
||||
const pct = v / 100;
|
||||
if (pct > 0.75) return '#ef4444';
|
||||
if (pct > 0.5) return '#f97316';
|
||||
if (pct > 0.25) return '#eab308';
|
||||
return '#22c55e';
|
||||
};
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[600px]">
|
||||
<div className="grid" style={{ gridTemplateColumns: `120px repeat(${dimensions.length}, 1fr)` }}>
|
||||
<div className="p-2 text-[10px] text-gray-500 font-bold uppercase">Period</div>
|
||||
{dimensions.map((d, i) => (
|
||||
<div key={i} className="p-2 text-[10px] text-gray-400 text-center font-bold">{d}</div>
|
||||
))}
|
||||
{data.map((row, ri) => (
|
||||
<div key={ri} className="contents">
|
||||
<div className="p-2 text-[10px] text-gray-400 font-mono">{row[period]}</div>
|
||||
{dimensions.map((d, di) => {
|
||||
const val = row[d.toLowerCase().replace(' ', '_')];
|
||||
return (
|
||||
<div key={di} className="p-2 rounded-lg text-center text-[11px] font-bold text-white"
|
||||
style={{ backgroundColor: getColor(val) + '30', color: getColor(val) }}>
|
||||
{val}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Plugin Card ───
|
||||
function PluginCard({ plugin, color }) {
|
||||
if (!plugin) return <div className="p-8 text-center text-gray-500">Loading...</div>;
|
||||
|
||||
const { type, title, description, data, summary, chart_config } = plugin;
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-[#0e0e16]/80 border border-white/5 rounded-2xl p-4 backdrop-blur-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white">{title}</h3>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{description}</p>
|
||||
</div>
|
||||
{summary.risk_level && (
|
||||
<span className="text-[10px] font-bold px-2 py-1 rounded-full border"
|
||||
style={{
|
||||
borderColor: riskColor(summary.risk_level === 'CRITICAL' ? 90 : summary.risk_level === 'HIGH' ? 70 : summary.risk_level === 'MEDIUM' ? 50 : 30) + '44',
|
||||
color: riskColor(summary.risk_level === 'CRITICAL' ? 90 : summary.risk_level === 'HIGH' ? 70 : summary.risk_level === 'MEDIUM' ? 50 : 30)
|
||||
}}>
|
||||
{summary.risk_level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token Health Scorecard */}
|
||||
{type === 'token_health' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<CircularProgress value={summary.overall_score || 50} size={140}
|
||||
color={summary.overall_score > 70 ? '#22c55e' : summary.overall_score > 50 ? '#eab308' : '#ef4444'} />
|
||||
</div>
|
||||
{(summary.recommendations || []).map((rec, i) => (
|
||||
<div key={i} className="p-3 rounded-lg bg-yellow-500/5 border border-yellow-500/10 text-[10px] text-yellow-400">
|
||||
⚠ {rec}
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{data.map((item, i) => <ScorecardItem key={i} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supply Donut */}
|
||||
{type === 'supply_donut' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<CircularProgress value={summary.genuine_holders_pct || 50} size={100} color="#22c55e" />
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-black" style={{ color: riskColor(summary.high_risk_percentage || 50) }}>
|
||||
{summary.high_risk_percentage.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500">High Risk Supply</div>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={data} cx="50%" cy="50%" outerRadius={90} innerRadius={50}
|
||||
paddingAngle={2} dataKey="value" isAnimationActive>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} stroke="rgba(0,0,0,0.2)" strokeWidth={2} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip contentStyle={{ background: '#0e0e16', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', fontSize: '12px' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.map((seg, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[10px]">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: seg.color }} />
|
||||
<span className="text-gray-400">{seg.label}</span>
|
||||
<span className="text-white font-bold">{seg.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallet Flow */}
|
||||
{type === 'wallet_flow' && (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||
<XAxis dataKey="time" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<RechartsTooltip contentStyle={{ background: '#0e0e16', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', fontSize: '11px' }} />
|
||||
{chart_config?.groups?.map((g, i) => (
|
||||
<Bar key={i} dataKey={g.key} fill={g.color} name={g.label} radius={[2,2,0,0]} stackId="a" />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
{/* Risk Heatmap */}
|
||||
{type === 'risk_heatmap' && <HeatmapChart data={data} dimensions={chart_config?.dimensions} />}
|
||||
|
||||
{/* Cluster Activity */}
|
||||
{type === 'cluster_activity' && (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{chart_config?.colors && Object.entries(chart_config.colors).map(([name, color]) => (
|
||||
<div key={name} className="flex items-center gap-1.5 text-[10px]">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-gray-400">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HeatmapChart data={data} dimensions={chart_config?.clusters} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dual Line (wash trading, social) */}
|
||||
{(type === 'wash_trading' || type === 'social_divergence') && (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<RechartsTooltip contentStyle={{ background: '#0e0e16', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', fontSize: '11px' }} />
|
||||
{chart_config?.lines?.map((l, i) => (
|
||||
<Line key={i} type="monotone" dataKey={l.key} stroke={l.color} strokeWidth={2} name={l.label} dot={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
{/* Radar (contract risk) */}
|
||||
{type === 'contract_risk' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-black" style={{ color: riskColor(summary.average_risk || 50) }}>
|
||||
{summary.average_risk.toFixed(0)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500">Avg Risk</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-black text-green-400">{summary.passed_checks}</div>
|
||||
<div className="text-[10px] text-gray-500">Passed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-black text-red-400">{summary.failed_checks}</div>
|
||||
<div className="text-[10px] text-gray-500">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RadarChart data={data.map(d => ({ subject: d.name, risk: d.risk }))}>
|
||||
<PolarGrid stroke="rgba(255,255,255,0.08)" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fill: '#9ca3af', fontSize: 10 }} />
|
||||
<PolarRadiusAxis domain={[0, 100]} tick={{ fill: '#6b7280', fontSize: 9 }} />
|
||||
<Radar name="Risk" dataKey="risk" stroke="#8b5cf6" fill="#8b5cf6" fillOpacity={0.3} strokeWidth={2} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
{(summary.red_flags || []).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{summary.red_flags.map((flag, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-[10px] text-red-400">
|
||||
<span>⚠</span> {flag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horizontal Bar (holder evolution, fund flow) */}
|
||||
{(type === 'holder_evolution' || type === 'fund_flow') && (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={data} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||
<XAxis type="number" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="label" width={130} tick={{ fill: '#9ca3af', fontSize: 10 }} />
|
||||
<RechartsTooltip contentStyle={{ background: '#0e0e16', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', fontSize: '11px' }} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Plugin Viewer ───
|
||||
export default function RugChartsPluginViewer({ tokenAddress, chain }) {
|
||||
const [activeTab, setActiveTab] = useState(PLUGIN_META[0].name);
|
||||
const [plugins, setPlugins] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchPlugins = useCallback(async () => {
|
||||
if (!tokenAddress) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/v1/rugcharts/plugins/all?token_address=${encodeURIComponent(tokenAddress)}&chain=${chain}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (json.plugins) {
|
||||
setPlugins(json.plugins);
|
||||
} else {
|
||||
throw new Error('No plugins data returned');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tokenAddress, chain]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins();
|
||||
}, [fetchPlugins]);
|
||||
|
||||
if (!tokenAddress) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-sm">Enter a token address to view plugin analysis</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block w-8 h-8 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-gray-500 mt-4">Loading plugin analysis...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm text-center">
|
||||
Error: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
|
||||
{PLUGIN_META.map((meta) => {
|
||||
const isActive = activeTab === meta.name;
|
||||
const hasData = plugins[meta.name] && !plugins[meta.name].error;
|
||||
const error = plugins[meta.name]?.error;
|
||||
return (
|
||||
<button key={meta.name} onClick={() => setActiveTab(meta.name)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-xs font-bold whitespace-nowrap transition-all border ${
|
||||
isActive
|
||||
? 'text-white border-cyan-500/30 bg-cyan-500/20'
|
||||
: 'text-gray-500 border-white/5 hover:text-white bg-white/5 hover:border-white/10'
|
||||
} ${error ? 'opacity-60' : ''}`}>
|
||||
<span>{meta.icon}</span>
|
||||
<span>{meta.label}</span>
|
||||
{error && <span className="text-[9px] text-red-400">✗</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active plugin */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={activeTab} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
|
||||
{error ? (
|
||||
<div className="p-4 rounded-xl bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-sm text-center">
|
||||
Error loading plugin: {error}
|
||||
</div>
|
||||
) : (
|
||||
<PluginCard plugin={plugins[activeTab]} color={PLUGIN_META.find(m => m.name === activeTab)?.color || '#8b5cf6'} />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Refresh button */}
|
||||
<div className="flex justify-end">
|
||||
<button onClick={fetchPlugins}
|
||||
className="px-4 py-2 rounded-xl text-xs font-bold text-gray-400 hover:text-white bg-white/5 border border-white/10 hover:border-white/20 transition-all">
|
||||
↻ Refresh Analysis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
src/components/rugcharts/RugPullWarning.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Skull, AlertOctagon, Activity, TrendingDown, Users, Droplet, Lock, Eye, Zap, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
|
||||
export interface RugWarning {
|
||||
id: string;
|
||||
level: 'CAUTION' | 'WARNING' | 'CRITICAL' | 'PULLING';
|
||||
category: 'liquidity' | 'holder' | 'trading' | 'deployer' | 'contract' | 'social';
|
||||
title: string;
|
||||
description: string;
|
||||
evidence?: string;
|
||||
confidence: number;
|
||||
detected_at: number;
|
||||
time_to_estimated_pull?: string;
|
||||
recommended_action?: string;
|
||||
}
|
||||
|
||||
interface RugPullWarningProps {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
initialWarnings?: RugWarning[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LEVEL_META: Record<RugWarning['level'], { color: string; bg: string; border: string; label: string; pulse: boolean }> = {
|
||||
CAUTION: { color: '#FFB800', bg: 'rgba(255,184,0,0.08)', border: 'rgba(255,184,0,0.3)', label: 'CAUTION', pulse: false },
|
||||
WARNING: { color: '#FF8C42', bg: 'rgba(255,140,66,0.1)', border: 'rgba(255,140,66,0.4)', label: 'WARNING', pulse: false },
|
||||
CRITICAL: { color: '#FF3366', bg: 'rgba(255,51,102,0.12)', border: 'rgba(255,51,102,0.5)', label: 'CRITICAL', pulse: true },
|
||||
PULLING: { color: '#FF0040', bg: 'rgba(255,0,64,0.2)', border: 'rgba(255,0,64,0.7)', label: 'PULLING', pulse: true },
|
||||
};
|
||||
|
||||
const CATEGORY_META: Record<RugWarning['category'], { icon: any; label: string }> = {
|
||||
liquidity: { icon: Droplet, label: 'Liquidity' },
|
||||
holder: { icon: Users, label: 'Holders' },
|
||||
trading: { icon: Activity, label: 'Trading' },
|
||||
deployer: { icon: Skull, label: 'Deployer' },
|
||||
contract: { icon: Lock, label: 'Contract' },
|
||||
social: { icon: Eye, label: 'Social' },
|
||||
};
|
||||
|
||||
// Pattern signatures that precede rug pulls
|
||||
const RUG_SIGNATURES = [
|
||||
{ pattern: 'liquidity_removal_pending', keywords: ['LP unlock', 'liquidity unlock', 'LP not burned'], weight: 30 },
|
||||
{ pattern: 'concentrated_holders', keywords: ['top holder', 'bundled', 'concentrated'], weight: 15 },
|
||||
{ pattern: 'deployer_serial_rugger', keywords: ['serial rugger', 'previous rugs', 'deployer rugs'], weight: 35 },
|
||||
{ pattern: 'contract_not_renounced', keywords: ['not renounced', 'ownership not renounced'], weight: 20 },
|
||||
{ pattern: 'honeypot_detected', keywords: ['honeypot', 'cant sell', 'sell disabled'], weight: 40 },
|
||||
{ pattern: 'lp_can_be_removed', keywords: ['LP not locked', 'owner can remove LP'], weight: 25 },
|
||||
];
|
||||
|
||||
function classifyPattern(text: string): string | null {
|
||||
const lower = text.toLowerCase();
|
||||
for (const sig of RUG_SIGNATURES) {
|
||||
if (sig.keywords.some(k => lower.includes(k.toLowerCase()))) {
|
||||
return sig.pattern;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() / 1000 - ts;
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export default function RugPullWarning({ chain, tokenAddress, initialWarnings, className = '' }: RugPullWarningProps) {
|
||||
const [warnings, setWarnings] = useState<RugWarning[]>(initialWarnings || []);
|
||||
const [loading, setLoading] = useState(!initialWarnings);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const runScan = async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/rugmaps/rug-warning?chain=${chain}&token=${encodeURIComponent(tokenAddress)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const raw = data.warnings || data.alerts || data.flags || [];
|
||||
setWarnings(raw.map((w: any, i: number) => ({
|
||||
id: w.id || String(i),
|
||||
level: w.level || w.severity?.toUpperCase() || 'CAUTION',
|
||||
category: w.category || w.type || 'trading',
|
||||
title: w.title || w.summary || '',
|
||||
description: w.description || w.detail || '',
|
||||
evidence: w.evidence,
|
||||
confidence: Number(w.confidence || w.score || 50),
|
||||
detected_at: Number(w.detected_at || w.timestamp || Date.now() / 1000),
|
||||
time_to_estimated_pull: w.time_to_estimated_pull || w.eta,
|
||||
recommended_action: w.recommended_action || w.recommendation,
|
||||
})));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[RugPullWarning] scan failed:', e);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialWarnings) {
|
||||
setWarnings(initialWarnings);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
runScan();
|
||||
// Auto-refresh every 30s for active warnings
|
||||
const interval = setInterval(runScan, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chain, tokenAddress, initialWarnings]);
|
||||
|
||||
// Aggregate stats
|
||||
const stats = useMemo(() => {
|
||||
const byLevel: Record<string, number> = {};
|
||||
const byCategory: Record<string, number> = {};
|
||||
for (const w of warnings) {
|
||||
byLevel[w.level] = (byLevel[w.level] || 0) + 1;
|
||||
byCategory[w.category] = (byCategory[w.category] || 0) + 1;
|
||||
}
|
||||
const overallRisk = warnings.reduce((s, w) => {
|
||||
const w8 = w.level === 'CRITICAL' || w.level === 'PULLING' ? 30 : w.level === 'WARNING' ? 15 : 5;
|
||||
return s + w8 * (w.confidence / 100);
|
||||
}, 0);
|
||||
return { byLevel, byCategory, overallRisk: Math.min(100, overallRisk) };
|
||||
}, [warnings]);
|
||||
|
||||
const verdict = useMemo(() => {
|
||||
if (stats.overallRisk >= 60) return { color: '#FF0040', label: 'PULL IMMINENT', icon: Skull };
|
||||
if (stats.overallRisk >= 30) return { color: '#FF3366', label: 'CRITICAL RISK', icon: AlertOctagon };
|
||||
if (stats.overallRisk >= 15) return { color: '#FF8C42', label: 'HIGH RISK', icon: TrendingDown };
|
||||
return { color: '#06D6A0', label: 'NO IMMEDIATE THREAT', icon: Activity };
|
||||
}, [stats]);
|
||||
|
||||
const VerdictIcon = verdict.icon;
|
||||
const isActivelyWarning = warnings.some(w => w.level === 'CRITICAL' || w.level === 'PULLING');
|
||||
|
||||
return (
|
||||
<div className={`bg-[#0a0a0f] border rounded-xl overflow-hidden ${
|
||||
isActivelyWarning ? 'border-[rgba(255,51,102,0.4)] animate-pulse' : 'border-[rgba(139,92,246,0.15)]'
|
||||
} ${className}`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b border-[rgba(139,92,246,0.15)]"
|
||||
style={{ background: `linear-gradient(to right, ${verdict.color}10, transparent)` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<VerdictIcon className="w-5 h-5" style={{ color: verdict.color }} />
|
||||
<div>
|
||||
<div className="text-base font-bold uppercase tracking-wider" style={{ color: verdict.color }}>
|
||||
{verdict.label}
|
||||
</div>
|
||||
<p className="text-[10px] text-[#5C6080] mt-0.5">
|
||||
Risk: {stats.overallRisk.toFixed(0)}/100 · {warnings.length} warning{warnings.length !== 1 ? 's' : ''} detected
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={runScan}
|
||||
disabled={scanning}
|
||||
className="p-1.5 rounded bg-[rgba(139,92,246,0.08)] hover:bg-[rgba(139,92,246,0.2)] disabled:opacity-50"
|
||||
title="Re-scan"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${scanning ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Risk meter */}
|
||||
<div className="h-2 bg-[rgba(139,92,246,0.1)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, stats.overallRisk)}%`,
|
||||
background: `linear-gradient(to right, #FFB800, #FF8C42, #FF3366, #FF0040)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category breakdown */}
|
||||
{Object.keys(stats.byCategory).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{Object.entries(stats.byCategory).map(([cat, count]) => {
|
||||
const meta = CATEGORY_META[cat as RugWarning['category']];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<span key={cat} className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] bg-[rgba(139,92,246,0.08)] text-[#8B5CF6]">
|
||||
<Icon className="w-2.5 h-2.5" />
|
||||
{meta.label}: {count}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning list */}
|
||||
{loading ? (
|
||||
<div className="p-6 text-center text-[#5C6080] text-xs">
|
||||
{scanning ? 'Re-scanning...' : 'Analyzing rug patterns...'}
|
||||
</div>
|
||||
) : warnings.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="text-[#06D6A0] text-2xl mb-2">✓</div>
|
||||
<div className="text-[#F1F1F6] text-sm font-semibold">No rug patterns detected</div>
|
||||
<div className="text-[#5C6080] text-xs mt-1">Auto-rescanning every 30s</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{warnings
|
||||
.sort((a, b) => {
|
||||
const order = { CRITICAL: 0, PULLING: 1, WARNING: 2, CAUTION: 3 };
|
||||
return (order[a.level] - order[b.level]) || (b.confidence - a.confidence);
|
||||
})
|
||||
.map(w => {
|
||||
const meta = LEVEL_META[w.level];
|
||||
const catMeta = CATEGORY_META[w.category];
|
||||
const Icon = catMeta.icon;
|
||||
const pattern = classifyPattern(w.title + ' ' + w.description);
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
className="px-4 py-3 border-b border-[rgba(139,92,246,0.04)] hover:bg-[rgba(139,92,246,0.04)] transition-colors"
|
||||
style={meta.pulse ? { animation: 'pulse 2s infinite' } : {}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center w-10 h-10 rounded-lg flex-shrink-0 border"
|
||||
style={{ background: meta.bg, borderColor: meta.border, color: meta.color }}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider"
|
||||
style={{ color: meta.color, background: meta.bg, borderColor: meta.border, border: '1px solid' }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-[#5C6080] uppercase tracking-wider">
|
||||
{catMeta.label}
|
||||
</span>
|
||||
{w.time_to_estimated_pull && (
|
||||
<span className="text-[10px] text-[#FF3366] font-bold uppercase tracking-wider flex items-center gap-0.5">
|
||||
<Zap className="w-2.5 h-2.5" />
|
||||
ETA: {w.time_to_estimated_pull}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[12px] font-semibold text-[#F1F1F6] mt-1">{w.title}</div>
|
||||
<div className="text-[10px] text-[#5C6080] mt-0.5">{w.description}</div>
|
||||
|
||||
{w.evidence && (
|
||||
<div className="mt-1.5 px-2 py-1 bg-[rgba(255,255,255,0.02)] rounded text-[10px] font-mono text-[#8B5CF6] truncate">
|
||||
{w.evidence}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{w.recommended_action && (
|
||||
<div className="mt-1.5 flex items-start gap-1 text-[10px] text-[#06D6A0]">
|
||||
<ChevronRight className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{w.recommended_action}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pattern && (
|
||||
<div className="mt-1 text-[9px] text-[#5C6080] font-mono">
|
||||
pattern: <span className="text-[#8B5CF6]">{pattern}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-[9px] text-[#5C6080] uppercase">Conf</div>
|
||||
<div className="text-base font-bold" style={{ color: meta.color }}>{w.confidence}%</div>
|
||||
<div className="text-[9px] text-[#5C6080] mt-1">{formatTimeAgo(w.detected_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
src/components/rugcharts/ScamRiskMeter.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Shield, ShieldAlert, ShieldX, AlertTriangle, TrendingDown, Users, Droplet, Lock, Activity, ExternalLink } from 'lucide-react';
|
||||
|
||||
export interface RiskFactor {
|
||||
category: 'concentration' | 'liquidity' | 'contract' | 'social' | 'deployer' | 'trading' | 'holder';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
title: string;
|
||||
description: string;
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
interface ScamRiskMeterProps {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
riskScore?: number; // 0-100, optional override
|
||||
riskFactors?: RiskFactor[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<RiskFactor['severity'], string> = {
|
||||
low: '#06D6A0',
|
||||
medium: '#FFB800',
|
||||
high: '#FF8C42',
|
||||
critical: '#FF3366',
|
||||
};
|
||||
|
||||
const SEVERITY_WEIGHTS: Record<RiskFactor['severity'], number> = {
|
||||
low: 5,
|
||||
medium: 15,
|
||||
high: 30,
|
||||
critical: 50,
|
||||
};
|
||||
|
||||
function computeScore(factors: RiskFactor[]): number {
|
||||
return Math.min(100, factors.reduce((s, f) => s + SEVERITY_WEIGHTS[f.severity], 0));
|
||||
}
|
||||
|
||||
function getVerdict(score: number): { label: string; color: string; icon: any; description: string } {
|
||||
if (score >= 80) return {
|
||||
label: 'CRITICAL RISK',
|
||||
color: '#FF3366',
|
||||
icon: ShieldX,
|
||||
description: 'Multiple severe red flags detected. Likely scam or rug-pull.',
|
||||
};
|
||||
if (score >= 60) return {
|
||||
label: 'HIGH RISK',
|
||||
color: '#FF8C42',
|
||||
icon: ShieldAlert,
|
||||
description: 'Significant concerns. Exercise extreme caution.',
|
||||
};
|
||||
if (score >= 30) return {
|
||||
label: 'MODERATE RISK',
|
||||
color: '#FFB800',
|
||||
icon: Shield,
|
||||
description: 'Some concerning signals. Do your own research.',
|
||||
};
|
||||
return {
|
||||
label: 'LOW RISK',
|
||||
color: '#06D6A0',
|
||||
icon: Shield,
|
||||
description: 'No major red flags detected.',
|
||||
};
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<RiskFactor['category'], any> = {
|
||||
concentration: Users,
|
||||
liquidity: Droplet,
|
||||
contract: Lock,
|
||||
social: Activity,
|
||||
deployer: AlertTriangle,
|
||||
trading: TrendingDown,
|
||||
holder: Users,
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<RiskFactor['category'], string> = {
|
||||
concentration: 'Concentration',
|
||||
liquidity: 'Liquidity',
|
||||
contract: 'Contract',
|
||||
social: 'Social',
|
||||
deployer: 'Deployer',
|
||||
trading: 'Trading',
|
||||
holder: 'Holders',
|
||||
};
|
||||
|
||||
export default function ScamRiskMeter({ chain, tokenAddress, riskScore: providedScore, riskFactors: providedFactors, className = '' }: ScamRiskMeterProps) {
|
||||
const [factors, setFactors] = useState<RiskFactor[]>(providedFactors || []);
|
||||
const [loading, setLoading] = useState(!providedFactors);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (providedFactors) {
|
||||
setFactors(providedFactors);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Fetch from backend
|
||||
const fetchRisk = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/rugmaps/scan?token=${encodeURIComponent(tokenAddress)}&chain=${chain}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Map backend flags to RiskFactor[]
|
||||
const newFactors: RiskFactor[] = [];
|
||||
const flags = data.red_flags || data.flags || [];
|
||||
for (const flag of flags) {
|
||||
newFactors.push({
|
||||
category: flag.category || 'trading',
|
||||
severity: flag.severity || 'medium',
|
||||
title: flag.title || String(flag),
|
||||
description: flag.description || '',
|
||||
evidence: flag.evidence,
|
||||
});
|
||||
}
|
||||
setFactors(newFactors);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ScamRiskMeter] fetch failed:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRisk();
|
||||
}, [chain, tokenAddress, providedFactors]);
|
||||
|
||||
const score = providedScore ?? computeScore(factors);
|
||||
const verdict = getVerdict(score);
|
||||
const VerdictIcon = verdict.icon;
|
||||
|
||||
// Group factors by category for display
|
||||
const byCategory = factors.reduce((acc, f) => {
|
||||
(acc[f.category] = acc[f.category] || []).push(f);
|
||||
return acc;
|
||||
}, {} as Record<string, RiskFactor[]>);
|
||||
|
||||
return (
|
||||
<div className={`bg-gradient-to-b from-[#0a0a0f] to-[#0f0f17] border border-[rgba(255,51,102,0.2)] rounded-xl overflow-hidden ${className}`}>
|
||||
{/* Top: Score arc + verdict */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-[#8B5CF6]" />
|
||||
<span className="text-xs uppercase tracking-widest text-[#5C6080] font-semibold">RMI Sentinel</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/rugmaps?token=${encodeURIComponent(tokenAddress)}&chain=${chain}`}
|
||||
className="text-[10px] text-[#8B5CF6] hover:underline flex items-center gap-1"
|
||||
>
|
||||
Full Report <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Arc meter */}
|
||||
<div className="relative w-24 h-24 flex-shrink-0">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="42" stroke="rgba(139,92,246,0.1)" strokeWidth="8" fill="none" />
|
||||
<circle
|
||||
cx="50" cy="50" r="42"
|
||||
stroke={verdict.color}
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${(score / 100) * 264} 264`}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dasharray 0.5s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="text-2xl font-bold" style={{ color: verdict.color }}>{score}</div>
|
||||
<div className="text-[9px] text-[#5C6080] uppercase tracking-wider">/ 100</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-base font-bold uppercase tracking-wider flex items-center gap-2"
|
||||
style={{ color: verdict.color }}
|
||||
>
|
||||
<VerdictIcon className="w-4 h-4" />
|
||||
{verdict.label}
|
||||
</div>
|
||||
<div className="text-xs text-[#F1F1F6]/80 mt-1">{verdict.description}</div>
|
||||
<div className="text-[10px] text-[#5C6080] mt-1.5 font-mono">
|
||||
{chain} · {tokenAddress.slice(0, 6)}...{tokenAddress.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Factor bars by category */}
|
||||
{!loading && factors.length > 0 && (
|
||||
<div className="border-t border-[rgba(139,92,246,0.1)]">
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full px-4 py-2 flex items-center justify-between hover:bg-[rgba(139,92,246,0.04)] transition-colors"
|
||||
>
|
||||
<span className="text-[11px] uppercase tracking-wider text-[#F1F1F6] font-semibold">
|
||||
{factors.length} Risk Factor{factors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="text-[10px] text-[#5C6080]">{expanded ? '▼ collapse' : '▶ expand'}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{Object.entries(byCategory).map(([cat, facts]) => (
|
||||
<div key={cat} className="space-y-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-[#5C6080] font-semibold flex items-center gap-1 pt-2">
|
||||
{(() => {
|
||||
const CatIcon = CATEGORY_ICONS[cat as RiskFactor['category']];
|
||||
return <CatIcon className="w-3 h-3" />;
|
||||
})()}
|
||||
{CATEGORY_LABELS[cat as RiskFactor['category']]}
|
||||
</div>
|
||||
{facts.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-l-2 pl-2 py-1 text-[11px]"
|
||||
style={{ borderColor: SEVERITY_COLORS[f.severity] }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase"
|
||||
style={{ color: SEVERITY_COLORS[f.severity], background: SEVERITY_COLORS[f.severity] + '20' }}
|
||||
>
|
||||
{f.severity}
|
||||
</span>
|
||||
<span className="font-semibold text-[#F1F1F6]">{f.title}</span>
|
||||
</div>
|
||||
<div className="text-[#5C6080] mt-0.5">{f.description}</div>
|
||||
{f.evidence && (
|
||||
<div className="text-[10px] text-[#8B5CF6] font-mono mt-0.5 truncate">
|
||||
{f.evidence}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="px-4 py-3 text-center text-[10px] text-[#5C6080]">
|
||||
Analyzing risk factors...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
src/components/rugmaps/AINarrativeReport.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
/**
|
||||
* AINarrativeReport — RugMaps Win #13
|
||||
*
|
||||
* AI-generated natural-language investigation report for a token.
|
||||
* Calls DataBus `rugmaps_analysis` (uses DeepSeek backend) and renders
|
||||
* the narrative with citations to on-chain evidence, RAG hits, and
|
||||
* scanner results. Includes copy + share + regenerate.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Brain, RefreshCw, Copy, Check, Loader2, Sparkles, Quote,
|
||||
ChevronDown, ExternalLink, AlertTriangle, Shield, FileText,
|
||||
} from 'lucide-react';
|
||||
import { useDataBus } from '@/lib/hooks/useDataBus';
|
||||
import { calculateRiskLevel } from '@/services/rugmapsAiAnalysis';
|
||||
import { databus } from '@/services/databus';
|
||||
|
||||
interface Citation {
|
||||
source: string;
|
||||
ref: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface NarrativeResponse {
|
||||
narrative: string; // markdown body
|
||||
summary: string; // 1-2 sentence TL;DR
|
||||
risk_score: number;
|
||||
risk_level: string;
|
||||
key_findings: string[];
|
||||
citations: Citation[];
|
||||
model: string; // "deepseek-chat" / "deepseek-coder" / "local-qwen"
|
||||
generated_at: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface AINarrativeReportProps {
|
||||
chain: string;
|
||||
address: string;
|
||||
className?: string;
|
||||
/** When true, automatically refetch on address change. Default true. */
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
// Minimal markdown → React renderer (bold, italic, code, lists, links).
|
||||
// Avoids adding a markdown dep for this single use case.
|
||||
function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*)/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) parts.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.startsWith('**')) parts.push(<strong key={`${keyPrefix}-b-${i++}`}>{tok.slice(2, -2)}</strong>);
|
||||
else if (tok.startsWith('`')) parts.push(<code key={`${keyPrefix}-c-${i++}`} className="px-1 py-0.5 rounded bg-[#1A2234] text-[#FFB800] text-[10px]">{tok.slice(1, -1)}</code>);
|
||||
else if (tok.startsWith('[')) {
|
||||
const lm = tok.match(/\[([^\]]+)\]\(([^)]+)\)/)!;
|
||||
parts.push(<a key={`${keyPrefix}-a-${i++}`} href={lm[2]} target="_blank" rel="noreferrer" className="text-[#8B5CF6] hover:underline">{lm[1]}</a>);
|
||||
}
|
||||
else if (tok.startsWith('*')) parts.push(<em key={`${keyPrefix}-i-${i++}`}>{tok.slice(1, -1)}</em>);
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) parts.push(text.slice(last));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderMarkdown(md: string): React.ReactNode {
|
||||
const lines = md.split('\n');
|
||||
const blocks: React.ReactNode[] = [];
|
||||
let listBuf: string[] = [];
|
||||
let key = 0;
|
||||
const flushList = () => {
|
||||
if (listBuf.length === 0) return;
|
||||
blocks.push(
|
||||
<ul key={`ul-${key++}`} className="list-disc list-inside space-y-1 text-[12px] text-[#C8CAD8]">
|
||||
{listBuf.map((item, i) => <li key={i}>{renderInline(item, `ul-${key}-${i}`)}</li>)}
|
||||
</ul>
|
||||
);
|
||||
listBuf = [];
|
||||
};
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('### ')) { flushList(); blocks.push(<h3 key={`h3-${key++}`} className="text-[13px] font-bold text-[#E0E0E0] mt-3 mb-1">{line.slice(4)}</h3>); }
|
||||
else if (line.startsWith('## ')) { flushList(); blocks.push(<h2 key={`h2-${key++}`} className="text-[14px] font-bold text-[#E0E0E0] mt-3 mb-1">{line.slice(3)}</h2>); }
|
||||
else if (line.startsWith('# ')) { flushList(); blocks.push(<h1 key={`h1-${key++}`} className="text-[15px] font-bold text-[#E0E0E0] mt-3 mb-2">{line.slice(2)}</h1>); }
|
||||
else if (/^[-*] /.test(line)) { listBuf.push(line.replace(/^[-*] /, '')); }
|
||||
else if (line.trim() === '') { flushList(); }
|
||||
else { flushList(); blocks.push(<p key={`p-${key++}`} className="text-[12px] text-[#C8CAD8] leading-relaxed">{renderInline(line, `p-${key}`)}</p>); }
|
||||
}
|
||||
flushList();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const SEVERITY_COLOR: Record<string, string> = {
|
||||
critical: '#FF3366',
|
||||
high: '#FF6B35',
|
||||
medium: '#FFB800',
|
||||
low: '#10B981',
|
||||
safe: '#10B981',
|
||||
info: '#5C6080',
|
||||
};
|
||||
|
||||
export default function AINarrativeReport({ chain, address, className = '', autoFetch = true }: AINarrativeReportProps) {
|
||||
const [narrative, setNarrative] = useState<NarrativeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [cachedHits, setCachedHits] = useState(0);
|
||||
|
||||
const { data: ragHits } = useDataBus<{ hits: Citation[] }>(
|
||||
'rag_search',
|
||||
{ query: `${chain}:${address} scam rug analysis`, limit: 5 },
|
||||
{ enabled: !!address, staleTime: 300_000 }
|
||||
);
|
||||
|
||||
const fetchNarrative = async () => {
|
||||
if (!address) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Direct DataBus call so we can read the cache metadata
|
||||
const result = await databus.fetch('rugmaps_analysis', { chain, address, deep: true });
|
||||
if (result.error) throw new Error(result.error);
|
||||
const d: any = result.data ?? result;
|
||||
setNarrative({
|
||||
narrative: d.narrative ?? d.analysis ?? d.report ?? 'No narrative returned by AI service.',
|
||||
summary: d.summary ?? '',
|
||||
risk_score: d.risk_score ?? 0,
|
||||
risk_level: d.risk_level ?? calculateRiskLevel(d.risk_score ?? 0),
|
||||
key_findings: d.key_findings ?? d.findings ?? [],
|
||||
citations: d.citations ?? ragHits?.hits ?? [],
|
||||
model: d.model ?? 'deepseek',
|
||||
generated_at: d.generated_at ?? new Date().toISOString(),
|
||||
confidence: d.confidence ?? 0,
|
||||
});
|
||||
setCachedHits(result.cached ? cachedHits + 1 : cachedHits);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to generate narrative');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch && address) fetchNarrative();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chain, address, autoFetch]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!narrative) return;
|
||||
await navigator.clipboard.writeText(`${narrative.summary}\n\n${narrative.narrative}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const share = async () => {
|
||||
if (!narrative) return;
|
||||
const url = `${window.location.origin}/rugmaps?chain=${chain}&address=${address}&ref=ai-narrative`;
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title: 'RMI Investigation Report', text: narrative.summary, url });
|
||||
} else {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`glass rounded-xl p-4 border border-white/5 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-[#8B5CF6]" />
|
||||
<h3 className="text-xs font-bold text-[#E0E0E0] uppercase tracking-wider">AI Investigation Report</h3>
|
||||
{narrative && (
|
||||
<span className="text-[10px] text-[#5C6080] flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> {narrative.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{narrative && (
|
||||
<>
|
||||
<button onClick={copy} className="p-1.5 rounded hover:bg-[#1A2234] text-[#9DA0B0] hover:text-[#E0E0E0]" title="Copy report">
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={share} className="p-1.5 rounded hover:bg-[#1A2234] text-[#9DA0B0] hover:text-[#E0E0E0]" title="Share">
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={fetchNarrative} disabled={loading || !address}
|
||||
className="p-1.5 rounded hover:bg-[#1A2234] text-[#9DA0B0] hover:text-[#E0E0E0] disabled:opacity-50" title="Regenerate">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => setExpanded(!expanded)} className="p-1.5 rounded hover:bg-[#1A2234] text-[#9DA0B0] hover:text-[#E0E0E0]">
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${expanded ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!address && <EmptyState text="Enter a token address to generate an investigation report." />}
|
||||
{error && <ErrorState message={error} onRetry={fetchNarrative} />}
|
||||
{loading && <LoadingState />}
|
||||
|
||||
{narrative && !loading && expanded && (
|
||||
<motion.div initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} className="space-y-3">
|
||||
{/* TL;DR + risk badge */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-[#0E1525] border border-white/5">
|
||||
<Quote className="w-4 h-4 text-[#8B5CF6] flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-[12px] text-[#E0E0E0] font-medium leading-relaxed">{narrative.summary}</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-[10px] text-[#5C6080]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
Risk: <span style={{ color: SEVERITY_COLOR[narrative.risk_level] || '#9DA0B0' }} className="font-bold">{narrative.risk_level.toUpperCase()}</span>
|
||||
<span className="text-[#9DA0B0]">({narrative.risk_score}/100)</span>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>Confidence: <span className="text-[#9DA0B0]">{narrative.confidence}%</span></span>
|
||||
<span>·</span>
|
||||
<span>Generated {timeAgo(narrative.generated_at)}</span>
|
||||
{cachedHits > 0 && <><span>·</span><span className="text-emerald-400">cache hit #{cachedHits}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full narrative */}
|
||||
<div className="max-h-[400px] overflow-y-auto pr-2 space-y-1">
|
||||
{renderMarkdown(narrative.narrative)}
|
||||
</div>
|
||||
|
||||
{/* Key findings chips */}
|
||||
{narrative.key_findings.length > 0 && (
|
||||
<div className="pt-2 border-t border-white/5">
|
||||
<p className="text-[10px] font-bold text-[#5C6080] uppercase tracking-wider mb-2">Key findings</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{narrative.key_findings.slice(0, 8).map((f, i) => (
|
||||
<span key={i} className="text-[10px] px-2 py-1 rounded-full bg-[#1A2234] text-[#C8CAD8] border border-white/5">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Citations */}
|
||||
{narrative.citations.length > 0 && (
|
||||
<div className="pt-2 border-t border-white/5">
|
||||
<p className="text-[10px] font-bold text-[#5C6080] uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" /> Citations ({narrative.citations.length})
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{narrative.citations.slice(0, 5).map((c, i) => (
|
||||
<li key={i} className="text-[10px] text-[#9DA0B0] flex items-start gap-1.5">
|
||||
<span className="text-[#5C6080]">[{i + 1}]</span>
|
||||
{c.url ? (
|
||||
<a href={c.url} target="_blank" rel="noreferrer" className="text-[#8B5CF6] hover:underline truncate">
|
||||
{c.source}: {c.ref}
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate">{c.source}: {c.ref}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return <div className="text-[11px] text-[#5C6080] py-6 text-center">{text}</div>;
|
||||
}
|
||||
|
||||
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="p-3 rounded-lg bg-[#2D1B1F] border border-[#FF3366]/30 text-[11px]">
|
||||
<div className="flex items-center gap-2 text-[#FF3366] mb-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> Narrative generation failed
|
||||
</div>
|
||||
<p className="text-[#9DA0B0] mb-2">{message}</p>
|
||||
<button onClick={onRetry} className="text-[10px] text-[#8B5CF6] hover:underline">Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-2 py-4">
|
||||
<div className="flex items-center gap-2 text-[11px] text-[#9DA0B0]">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#8B5CF6]" />
|
||||
DeepSeek is analyzing on-chain evidence + RAG corpus…
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{[1, 2, 3, 4].map(i => <div key={i} className="h-2 bg-[#1A2234] rounded animate-pulse" style={{ width: `${100 - i * 15}%` }} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
145
src/components/rugmaps/BotNetworkPanel.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Bot, Zap, Users, Activity, AlertTriangle, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
|
||||
interface BotSuite {
|
||||
name: string;
|
||||
confidence: number;
|
||||
wallets: number;
|
||||
volumeFake: number;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
interface BotNetworkPanelProps {
|
||||
tokenAddress: string;
|
||||
chain: string;
|
||||
}
|
||||
|
||||
const KNOWN_BOTS = [
|
||||
{ name: 'Axiom', pattern: 'jito_bundle', signature: 'high_gas_first_block' },
|
||||
{ name: 'Bloom', pattern: 'bloXroute', signature: 'coordinated_entry' },
|
||||
{ name: 'Photon', pattern: 'mev_bundle', signature: 'sandwich_pattern' },
|
||||
{ name: 'GMGN', pattern: 'aggregator', signature: 'multi_dex_split' },
|
||||
{ name: 'Trojan', pattern: 'copycat', signature: 'identical_tx_structure' },
|
||||
{ name: 'Padre', pattern: 'volume_bot', signature: 'wash_cycle_3s' },
|
||||
];
|
||||
|
||||
export default function BotNetworkPanel({ tokenAddress, chain }: BotNetworkPanelProps) {
|
||||
const [bots, setBots] = useState<BotSuite[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [analyzed, setAnalyzed] = useState(false);
|
||||
|
||||
const detectBots = () => {
|
||||
setLoading(true);
|
||||
// Simulate detection — replace with real stealthDetection.ts integration
|
||||
setTimeout(() => {
|
||||
const detected: BotSuite[] = [];
|
||||
const numBots = Math.floor(Math.random() * 3) + 1;
|
||||
for (let i = 0; i < numBots; i++) {
|
||||
const bot = KNOWN_BOTS[Math.floor(Math.random() * KNOWN_BOTS.length)];
|
||||
detected.push({
|
||||
name: bot.name,
|
||||
confidence: Math.floor(Math.random() * 30) + 70,
|
||||
wallets: Math.floor(Math.random() * 15) + 3,
|
||||
volumeFake: Math.random() * 80 + 10,
|
||||
pattern: bot.pattern,
|
||||
});
|
||||
}
|
||||
setBots(detected);
|
||||
setLoading(false);
|
||||
setAnalyzed(true);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
if (!analyzed) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card rounded-[16px] p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-8 h-8 rounded-[9px] bg-[#8B5CF6]/10 flex items-center justify-center">
|
||||
<Bot className="w-[15px] h-[15px] text-[#8B5CF6]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-semibold text-[#F1F1F6]">Bot Network Detection</h3>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={detectBots}
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Bot className="w-4 h-4 mr-2" />}
|
||||
{loading ? 'Analyzing transaction patterns...' : 'Detect Bot Networks'}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bots.length === 0) {
|
||||
return (
|
||||
<div className="glass-card rounded-[16px] p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-400" />
|
||||
<span className="text-[13px] font-semibold text-emerald-400">No Bot Networks Detected</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#9DA0B0]">Transaction patterns appear organic. No known bot suite signatures found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card rounded-[16px] p-5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-8 h-8 rounded-[9px] bg-[#8B5CF6]/10 flex items-center justify-center">
|
||||
<Bot className="w-[15px] h-[15px] text-[#8B5CF6]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-semibold text-[#F1F1F6]">Bot Networks</h3>
|
||||
</div>
|
||||
<span className="badge badge-danger text-[9.5px] ml-auto">{bots.length} detected</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{bots.map((bot, i) => (
|
||||
<div key={i} className="p-3 rounded-[10px] bg-[#8B5CF6]/5 border border-[#8B5CF6]/20">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-[#8B5CF6]" />
|
||||
<span className="text-[13px] font-semibold text-[#F1F1F6]">{bot.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#8B5CF6] font-mono">{bot.confidence}% match</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div className="text-[#9DA0B0]">
|
||||
<Users className="w-3 h-3 inline mr-1" />
|
||||
{bot.wallets} wallets
|
||||
</div>
|
||||
<div className="text-[#9DA0B0]">
|
||||
<Activity className="w-3 h-3 inline mr-1" />
|
||||
{bot.volumeFake.toFixed(0)}% fake vol
|
||||
</div>
|
||||
<div className="text-[#9DA0B0]">
|
||||
<Bot className="w-3 h-3 inline mr-1" />
|
||||
{bot.pattern}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 p-2.5 rounded-[8px] bg-[#FF3366]/5 border border-[#FF3366]/10">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<AlertTriangle className="w-4 h-4 text-[#FF3366]" />
|
||||
<span className="text-[#FF3366]">Estimated fake volume: {bots.reduce((acc, b) => acc + b.volumeFake, 0).toFixed(0)}% of total</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||