muthuk1's picture
feat: Helius real on-chain wallet analysis
9e22644
'use client'
import { cn, fmtUsd, shortAddr } from '@/lib/utils'
import { wallets } from '@/lib/mock-data'
import { RiskBadge } from '@/components/ui/RiskBadge'
import { RecoveryCard } from '@/components/ui/RecoveryCard'
import { useToast } from '@/components/ui/Toast'
import { Search, Flame, ExternalLink, ChevronRight, Zap, CheckCircle2, Loader2, Siren, Share2, AlertTriangle, Radio } from 'lucide-react'
import { useState, useCallback, useMemo } from 'react'
import type { ChurnRisk, Wallet } from '@/lib/types'
import { classifyWalletType } from '@/lib/agent-engine'
// --- Live Wallet Analyzer (Helius) ---
interface LiveResult {
address: string; score: number; risk: string; detectedSignals: string[]
signals: { daysInactive: number; protocols: string[]; currentStreak: number; volumeDropPct: number; totalTxLast30d: number; lastActiveDaysAgo: string }
torque: { fired: boolean; eventId?: string; eventName?: string }
}
function LiveAnalyzer() {
const [addr, setAddr] = useState('')
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<LiveResult | null>(null)
const [err, setErr] = useState<string | null>(null)
const [autoFire, setAutoFire] = useState(false)
const { fire: toast } = useToast()
const analyze = useCallback(async () => {
if (!addr.trim() || loading) return
setLoading(true); setErr(null); setResult(null)
try {
const res = await fetch('/api/helius/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: addr.trim(), autoFire }),
})
const data = await res.json()
if (!res.ok) { setErr(data.error); return }
setResult(data)
if (data.torque?.fired) {
toast({ type: 'event', title: `${data.torque.eventName}${addr.slice(0,8)}...`, body: data.torque.eventId?.slice(0,10) })
}
} catch (e: any) {
setErr(e.message)
} finally {
setLoading(false)
}
}, [addr, autoFire, loading, toast])
const RISK_CLR: Record<string, string> = { critical:'text-trading-down', high:'text-[#ff9500]', medium:'text-brand-yellow', low:'text-trading-up', safe:'text-muted' }
const RISK_BG: Record<string, string> = { critical:'border-trading-down/30 bg-trading-down/5', high:'border-[#ff9500]/30 bg-[#ff9500]/5', medium:'border-brand-yellow/30 bg-brand-yellow/5', low:'border-trading-up/30 bg-trading-up/5', safe:'border-hairline-dark bg-surface-elevated' }
return (
<div className="rounded-xl bg-surface-card border border-hairline-dark overflow-hidden">
<div className="px-5 py-4 border-b border-hairline-dark flex items-center gap-2">
<Radio className="w-4 h-4 text-trading-up" />
<h3 className="text-title-sm">Live Wallet Analyzer</h3>
<span className="text-[10px] px-2 py-0.5 rounded-pill bg-trading-up/10 text-trading-up font-semibold border border-trading-up/20">HELIUS</span>
<p className="text-caption text-muted ml-1">Enter any Solana address — real on-chain churn score</p>
</div>
<div className="p-5 space-y-4">
<div className="flex gap-2">
<input
value={addr} onChange={e => setAddr(e.target.value)}
onKeyDown={e => e.key === 'Enter' && analyze()}
placeholder="Enter Solana wallet address..."
className="flex-1 h-10 px-4 rounded-lg bg-surface-elevated border border-hairline-dark text-body-sm text-[#eaecef] placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-brand-yellow/50 font-mono"
/>
<label className="flex items-center gap-2 px-3 rounded-lg border border-hairline-dark bg-surface-elevated cursor-pointer select-none">
<input type="checkbox" checked={autoFire} onChange={e => setAutoFire(e.target.checked)} className="accent-brand-yellow" />
<span className="text-caption text-muted whitespace-nowrap">Auto-fire Torque</span>
</label>
<button onClick={analyze} disabled={loading || !addr.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-yellow text-ink text-button font-semibold hover:bg-brand-yellow-active transition disabled:opacity-50">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
{loading ? 'Scanning...' : 'Analyze'}
</button>
</div>
{err && <p className="text-body-sm text-trading-down px-1">Error: {err}</p>}
{result && (
<div className={cn('rounded-xl border p-4 space-y-3', RISK_BG[result.risk] || 'border-hairline-dark')}>
<div className="flex items-center justify-between">
<div>
<p className="font-mono text-body-sm text-[#eaecef]">{result.address.slice(0,8)}...{result.address.slice(-6)}</p>
<a href={`https://solscan.io/account/${result.address}`} target="_blank" rel="noreferrer" className="text-caption text-muted hover:text-brand-yellow transition flex items-center gap-1 mt-0.5">
<ExternalLink className="w-3 h-3" />View on Solscan
</a>
</div>
<div className="text-right">
<p className={cn('font-mono text-display-sm font-bold tabular-nums', RISK_CLR[result.risk])}>{result.score}</p>
<p className={cn('text-caption font-bold uppercase', RISK_CLR[result.risk])}>{result.risk}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="rounded-lg bg-surface-elevated p-2.5 text-center">
<p className="text-[10px] text-muted">Inactive</p>
<p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.lastActiveDaysAgo}</p>
</div>
<div className="rounded-lg bg-surface-elevated p-2.5 text-center">
<p className="text-[10px] text-muted">Streak</p>
<p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.currentStreak}d</p>
</div>
<div className="rounded-lg bg-surface-elevated p-2.5 text-center">
<p className="text-[10px] text-muted">Vol drop</p>
<p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.volumeDropPct}%</p>
</div>
<div className="rounded-lg bg-surface-elevated p-2.5 text-center">
<p className="text-[10px] text-muted">30d txs</p>
<p className="font-mono text-body-sm text-[#eaecef] mt-0.5">{result.signals.totalTxLast30d}</p>
</div>
</div>
{result.signals.protocols.length > 0 && (
<div className="flex flex-wrap gap-1">
{result.signals.protocols.map(p => <span key={p} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-elevated text-muted">{p}</span>)}
</div>
)}
{result.detectedSignals.length > 0 && (
<div className="space-y-1">
{result.detectedSignals.map((s, i) => <p key={i} className="text-caption text-muted flex items-center gap-1.5"><span className="text-trading-down"></span>{s}</p>)}
</div>
)}
{result.torque.fired && (
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-trading-up/10 border border-trading-up/30">
<CheckCircle2 className="w-4 h-4 text-trading-up flex-shrink-0" />
<div>
<p className="text-caption text-trading-up font-semibold">{result.torque.eventName} fired via Torque</p>
<p className="font-mono text-[10px] text-muted">{result.torque.eventId}</p>
</div>
</div>
)}
</div>
)}
</div>
</div>
)
}
type Filter = ChurnRisk | 'all'
const filters: Filter[] = ['all', 'critical', 'high', 'medium', 'low', 'safe']
const EVENT_MAP: Record<ChurnRisk, string> = {
critical: 'churn_risk_high', high: 'churn_risk_high',
medium: 'churn_risk_medium', low: 'inactivity_detected', safe: 'streak_maintained',
}
const ACTION_LABEL: Record<ChurnRisk, string> = {
critical: 'Send Gift', high: 'Enter Raffle', medium: 'Activate Rebate',
low: 'Flag Inactive', safe: 'Reward Streak',
}
const RISK_CAN_INTERVENE: ChurnRisk[] = ['critical', 'high', 'medium']
// Wallets with savedCount > 0 have prior rescues — show recovery card option
const RESCUED_WALLETS = new Set(wallets.filter(w => w.savedCount > 0).map(w => w.address))
// Pre-churn: "low" risk wallets with streak ≤ 2 and recent inactivity — early warning
function isPreChurnWarning(w: Wallet): boolean {
const daysMatch = w.lastActive.match(/(\d+)d/)
const days = daysMatch ? parseInt(daysMatch[1]) : 0
const wtype = classifyWalletType(w.protocols)
const threshold = wtype === 'trader' ? 3 : wtype === 'lp' ? 5 : 7
return w.churnRisk === 'low' && days >= threshold && w.streak <= 2
}
export default function WalletsPage() {
const { fire: toast } = useToast()
const [f, setF] = useState<Filter>('all')
const [q, setQ] = useState('')
const [sort, setSort] = useState<'risk' | 'volume' | 'streak'>('risk')
const [firing, setFiring] = useState<string | null>(null)
const [fired, setFired] = useState<Map<string, string>>(new Map())
const [bulkFiring, setBulkFiring] = useState(false)
const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null)
const [recoveryWallet, setRecoveryWallet] = useState<Wallet | null>(null)
const preChurnCount = useMemo(() => wallets.filter(isPreChurnWarning).length, [])
const list = wallets
.filter(w => f === 'all' || w.churnRisk === f)
.filter(w => !q || w.address.toLowerCase().includes(q.toLowerCase()))
.sort((a, b) => sort === 'risk' ? b.riskScore - a.riskScore : sort === 'volume' ? b.totalVolume - a.totalVolume : b.streak - a.streak)
const criticalUnfired = wallets.filter(w =>
(w.churnRisk === 'critical' || w.churnRisk === 'high') && !fired.has(w.address)
)
const intervene = useCallback(async (w: Wallet) => {
if (firing || fired.has(w.address)) return
setFiring(w.address)
try {
const res = await fetch('/api/torque/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wallet: w.address,
eventName: EVENT_MAP[w.churnRisk],
data: { risk: w.churnRisk, score: w.riskScore, detectedBy: 'flowstate-dashboard' },
risk: w.churnRisk,
score: w.riskScore,
}),
})
const data = await res.json()
const eventId = data.eventId || 'sent'
setFired(prev => new Map(prev).set(w.address, eventId))
toast({
type: 'event',
title: `${ACTION_LABEL[w.churnRisk]}${shortAddr(w.address)}`,
body: `${EVENT_MAP[w.churnRisk]} · ${eventId.slice(0, 10)}`,
})
} catch {
setFired(prev => new Map(prev).set(w.address, 'error'))
toast({ type: 'error', title: 'Event failed', body: w.address.slice(0, 12) })
} finally {
setFiring(null)
}
}, [firing, fired, toast])
const bulkRescue = useCallback(async () => {
if (bulkFiring || criticalUnfired.length === 0) return
setBulkFiring(true)
setBulkProgress({ done: 0, total: criticalUnfired.length })
const targets = criticalUnfired.map(w => ({
wallet: w.address,
eventName: EVENT_MAP[w.churnRisk],
risk: w.churnRisk,
score: w.riskScore,
}))
try {
const res = await fetch('/api/torque/bulk-fire', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targets }),
})
const data = await res.json()
if (data.details) {
const newFired = new Map(fired)
data.details.forEach((d: any, i: number) => {
if (d.success) newFired.set(criticalUnfired[i].address, d.eventId || 'sent')
})
setFired(newFired)
setBulkProgress({ done: data.fired, total: data.total })
toast({
type: data.fired > 0 ? 'success' : 'error',
title: `Bulk rescue: ${data.fired}/${data.total} fired`,
body: data.fired > 0 ? 'All critical wallets targeted via Torque' : data.details[0]?.error,
})
}
} catch (e: any) {
toast({ type: 'error', title: 'Bulk fire failed', body: e.message })
} finally {
setBulkFiring(false)
setTimeout(() => setBulkProgress(null), 3000)
}
}, [bulkFiring, criticalUnfired, fired, toast])
return (
<div className="p-6 space-y-6">
{recoveryWallet && (
<RecoveryCard
wallet={recoveryWallet.address}
savedCount={recoveryWallet.savedCount}
daysInactive={parseInt(recoveryWallet.lastActive.match(/(\d+)/)?.[1] || '8')}
campaignName={recoveryWallet.churnRisk === 'critical' || recoveryWallet.churnRisk === 'high' ? 'Anti-Churn Gift Drop' : 'Streak Multiplier Rebate'}
onClose={() => setRecoveryWallet(null)}
/>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-display-sm text-[#eaecef]">Wallets</h1>
<p className="text-body-md text-muted mt-1">Monitor wallet health, churn risk & activity patterns</p>
</div>
<div className="flex items-center gap-3">
{preChurnCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-brand-yellow/10 border border-brand-yellow/30">
<AlertTriangle className="w-4 h-4 text-brand-yellow" />
<span className="text-caption text-brand-yellow font-semibold">{preChurnCount} pre-churn</span>
</div>
)}
{fired.size > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-trading-up/10 border border-trading-up/30">
<CheckCircle2 className="w-4 h-4 text-trading-up" />
<span className="text-caption text-trading-up font-semibold">{fired.size} events sent</span>
</div>
)}
{criticalUnfired.length > 0 && (
<button
onClick={bulkRescue}
disabled={bulkFiring}
className="flex items-center gap-2 px-4 py-2 rounded-md bg-trading-down/10 border border-trading-down/40 text-trading-down text-button font-semibold hover:bg-trading-down/20 transition disabled:opacity-50"
>
{bulkFiring ? <Loader2 className="w-4 h-4 animate-spin" /> : <Siren className="w-4 h-4" />}
{bulkProgress
? `${bulkProgress.done}/${bulkProgress.total} fired`
: bulkFiring ? 'Rescuing...'
: `Rescue All Critical (${criticalUnfired.length})`}
</button>
)}
</div>
</div>
<LiveAnalyzer />
<div className="grid grid-cols-2 lg:grid-cols-6 gap-3">
{filters.map(r => (
<button key={r} onClick={() => setF(r)} className={cn('rounded-xl border p-3 text-center transition-all', f === r ? 'border-brand-yellow/40 bg-brand-yellow/5' : 'border-hairline-dark bg-surface-card hover:border-brand-yellow/20')}>
<span className="text-caption text-muted capitalize">{r}</span>
<p className="font-mono text-title-md text-[#eaecef] tabular-nums mt-0.5">{r === 'all' ? wallets.length : wallets.filter(w => w.churnRisk === r).length}</p>
</button>
))}
</div>
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input type="text" placeholder="Search wallet..." value={q} onChange={e => setQ(e.target.value)} className="w-full h-9 pl-9 pr-4 rounded-lg bg-surface-card border border-hairline-dark text-body-sm placeholder:text-muted focus:outline-none focus:ring-1 focus:ring-brand-yellow/50" />
</div>
<div className="flex items-center gap-1 p-1 rounded-lg bg-surface-card border border-hairline-dark">
{(['risk', 'volume', 'streak'] as const).map(s => <button key={s} onClick={() => setSort(s)} className={cn('px-3 py-1.5 rounded-md text-caption transition capitalize', sort === s ? 'bg-brand-yellow text-ink font-semibold' : 'text-muted hover:text-[#eaecef]')}>{s}</button>)}
</div>
</div>
<div className="rounded-xl bg-surface-card border border-hairline-dark overflow-hidden">
<table className="w-full"><thead><tr className="border-b border-hairline-dark bg-surface-elevated/50">
<th className="text-left text-caption text-muted uppercase tracking-wider px-5 py-3">Wallet</th>
<th className="text-center text-caption text-muted uppercase tracking-wider px-5 py-3">Risk</th>
<th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Score</th>
<th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Volume</th>
<th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Streak</th>
<th className="text-center text-caption text-muted uppercase tracking-wider px-5 py-3">Protocols</th>
<th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Last Active</th>
<th className="text-right text-caption text-muted uppercase tracking-wider px-5 py-3">Action</th>
</tr></thead><tbody>{list.map(w => {
const isFiring = firing === w.address
const eventId = fired.get(w.address)
const canIntervene = RISK_CAN_INTERVENE.includes(w.churnRisk)
const preChurn = isPreChurnWarning(w)
const rescued = RESCUED_WALLETS.has(w.address)
const wtype = classifyWalletType(w.protocols)
return (
<tr key={w.address} className={cn('border-b border-hairline-dark/50 hover:bg-surface-hover transition-colors group', preChurn && 'bg-brand-yellow/3')}>
<td className="px-5 py-4">
<div className="flex items-center gap-2">
<span className="font-mono text-body-md text-[#eaecef]">{shortAddr(w.address)}</span>
<a href={`https://solscan.io/account/${w.address}`} target="_blank" rel="noreferrer">
<ExternalLink className="w-3 h-3 text-muted opacity-0 group-hover:opacity-100 transition" />
</a>
{preChurn && <span className="text-[9px] font-bold uppercase px-1.5 py-0.5 rounded bg-brand-yellow/15 text-brand-yellow border border-brand-yellow/30">⚡ pre-churn</span>}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted capitalize">{wtype}</span>
{eventId && eventId !== 'error' && (
<p className="font-mono text-[10px] text-trading-up">✓ {eventId.slice(0, 10)}...</p>
)}
</div>
</td>
<td className="px-5 py-4 text-center"><RiskBadge risk={w.churnRisk} /></td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-16 h-1.5 rounded-full bg-surface-elevated overflow-hidden">
<div className={cn('h-full rounded-full transition-all duration-700', w.riskScore >= 80 ? 'bg-trading-down' : w.riskScore >= 60 ? 'bg-[#ff9500]' : w.riskScore >= 40 ? 'bg-brand-yellow' : w.riskScore >= 20 ? 'bg-trading-up' : 'bg-brand-turquoise')} style={{ width: w.riskScore + '%' }} />
</div>
<span className="font-mono text-num-sm text-muted tabular-nums w-8 text-right">{w.riskScore}</span>
</div>
</td>
<td className="px-5 py-4 text-right font-mono text-num-sm text-[#eaecef] tabular-nums">{fmtUsd(w.totalVolume)}</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1">
<Flame className={cn('w-3.5 h-3.5', w.streak >= 30 ? 'text-brand-yellow' : w.streak >= 7 ? 'text-trading-up' : w.streak === 0 ? 'text-trading-down' : 'text-muted')} />
<span className="font-mono text-num-sm tabular-nums">{w.streak}d</span>
</div>
</td>
<td className="px-5 py-4">
<div className="flex flex-wrap justify-center gap-1">
{w.protocols.slice(0, 3).map(p => <span key={p} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-elevated text-muted">{p}</span>)}
{w.protocols.length > 3 && <span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-elevated text-muted">+{w.protocols.length - 3}</span>}
</div>
</td>
<td className="px-5 py-4 text-right text-body-sm text-muted">{w.lastActive}</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1.5">
{rescued && (
<button
onClick={() => setRecoveryWallet(w)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-trading-up/10 border border-trading-up/30 text-trading-up text-[10px] font-semibold hover:bg-trading-up/20 transition"
title="Share recovery card"
>
<Share2 className="w-3 h-3" />
</button>
)}
{canIntervene && (
eventId ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-trading-up">
<CheckCircle2 className="w-3 h-3" />Sent
</span>
) : (
<button
onClick={() => intervene(w)}
disabled={!!firing}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-brand-yellow/10 border border-brand-yellow/30 text-brand-yellow text-[11px] font-semibold hover:bg-brand-yellow/20 transition disabled:opacity-40"
>
{isFiring ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
{isFiring ? 'Firing...' : ACTION_LABEL[w.churnRisk]}
</button>
)
)}
{!canIntervene && !rescued && <ChevronRight className="w-4 h-4 text-muted opacity-0 group-hover:opacity-100 transition ml-auto" />}
</div>
</td>
</tr>
)
})}</tbody></table>
</div>
</div>
)
}