<!doctype html>
<html lang="en" data-theme="void">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Solwatch — Solana memecoin auditor</title>
<style>
:root {
--bg:#080b10; --panel:#0e141c; --panel2:#121a24; --line:#1d2a38; --line2:#16202c;
--ink:#e8eef5; --muted:#8ea3b8; --dim:#5a6b7d;
--cyan:#39d0d8; --cyan2:#2aa7b0; --cyan-dim:rgba(57,208,216,.14);
--good:#4ade80; --fair:#a3e635; --warn:#fbbf24; --danger:#f87171; --crit:#ef4444;
--shadow:0 8px 30px rgba(0,0,0,.4);
--mono:ui-monospace,'JetBrains Mono','Cascadia Code',Menlo,Consolas,monospace;
--sans:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
}
* { box-sizing:border-box; }
html,body { height:100%; }
body { margin:0; color:var(--ink); font:15px/1.5 var(--sans); -webkit-font-smoothing:antialiased;
background:
radial-gradient(1400px 800px at 75% -10%, #0d1620 0%, transparent 60%),
var(--bg); zoom: 1.15; }
body::before { content:''; position:fixed; inset:0; pointer-events:none; opacity:.5; z-index:0;
background:
linear-gradient(var(--line2) 1px, transparent 1px) 0 0/100% 48px,
linear-gradient(90deg, var(--line2) 1px, transparent 1px) 0 0/48px 100%;
mask-image:radial-gradient(1200px 700px at 50% 0%, rgba(0,0,0,.5), transparent 75%); }
a { color:var(--cyan); text-decoration:none; }
a:hover { text-decoration:underline; }
.wrap { position:relative; z-index:1; width:100%; margin:0 auto; padding:18px clamp(14px,2.5vw,36px) 70px; }
.topbar { display:flex; align-items:center; gap:14px; flex-wrap:wrap; padding:10px 14px;
background:var(--panel); border:1px solid var(--line); border-radius:12px; margin-bottom:14px; }
.brand { display:flex; align-items:center; gap:10px; font-weight:800; letter-spacing:.4px; white-space:nowrap; }
.brand .logo { font-size:20px; }
.brand b { color:var(--cyan); }
.brand .chain { font:12px var(--mono); color:var(--dim); border:1px solid var(--line); padding:2px 8px; border-radius:20px; }
.searchbar { display:flex; gap:8px; flex:1; min-width:260px; }
.searchbar input { flex:1; min-width:180px; background:var(--bg); border:1px solid var(--line); color:var(--ink);
padding:9px 12px; border-radius:9px; font:14px var(--mono); outline:none; }
.searchbar input:focus-visible { border-color:var(--cyan2); box-shadow:0 0 0 2px var(--cyan-dim); }
button { background:linear-gradient(180deg,var(--cyan),var(--cyan2)); color:#04252a; border:0; font-weight:700;
padding:9px 16px; border-radius:9px; cursor:pointer; font-size:14px; font-family:var(--sans); }
button.ghost { background:var(--panel2); color:var(--muted); border:1px solid var(--line); font-weight:600; }
button:disabled { opacity:.5; cursor:wait; }
button:focus-visible { outline:2px solid var(--cyan); outline-offset:2px; }
button:active { transform:scale(.98); }
.chips { display:flex; gap:8px; flex-wrap:wrap; margin:0 0 14px; }
.chip { background:var(--panel); border:1px solid var(--line); color:var(--muted); padding:5px 12px; border-radius:20px;
font:12px var(--mono); cursor:pointer; }
.chip:hover { border-color:var(--cyan2); color:var(--ink); }
.hud { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px 18px;
min-width:0; position:relative; overflow:hidden; }
.panel > h3 { margin:0 0 12px; font:700 12px var(--sans); text-transform:uppercase; letter-spacing:1.4px; color:var(--cyan);
display:flex; align-items:center; gap:8px; }
.panel > h3::after { content:''; flex:1; height:1px; background:linear-gradient(90deg,var(--line),transparent); }
.panel .sub { color:var(--dim); font-size:14px; margin:-8px 0 10px; }
.c12{grid-column:span 12} .c8{grid-column:span 8} .c6{grid-column:span 6} .c4{grid-column:span 4}
@media (max-width:1100px){ .c8,.c6,.c4{grid-column:span 12} .c6.keep{grid-column:span 6} }
@media (max-width:700px){ .c6.keep{grid-column:span 12} }
.head { display:flex; align-items:center; gap:clamp(16px,3vw,34px); flex-wrap:wrap; }
.ringbox { position:relative; width:118px; height:118px; flex:none; }
.ringbox canvas { width:118px; height:118px; display:block; }
.ringbox .rv { position:absolute; inset:0; display:grid; place-items:center; text-align:center; }
.ringbox .rv b { display:block; font:800 30px var(--mono); line-height:1; }
.ringbox .rv span { font:11.5px var(--sans); color:var(--dim); text-transform:uppercase; letter-spacing:1px; }
.idc { flex:1; min-width:230px; }
.sym { font-size:25px; font-weight:800; letter-spacing:.3px; }
.nm { color:var(--muted); font-size:14px; }
.addr { display:flex; align-items:center; gap:8px; font:12px var(--mono); color:var(--muted); margin-top:6px; flex-wrap:wrap; }
.addr code { word-break:break-all; }
.iconbtn { background:none; border:1px solid var(--line); color:var(--muted); border-radius:6px; padding:2px 8px;
font:11.5px var(--mono); cursor:pointer; }
.iconbtn:hover { color:var(--ink); border-color:var(--cyan2); }
.verdict { padding:10px 20px; border-radius:11px; font-weight:800; font-size:16px; letter-spacing:1px; flex:none;
border:1px solid; background:var(--panel2); text-shadow:0 0 18px currentColor; }
.scanned { font:11.5px var(--mono); color:var(--dim); width:100%; margin-top:2px; }
.warnstrip { margin-top:10px; padding:8px 12px; border:1px solid rgba(251,191,36,.35); background:rgba(251,191,36,.07);
border-radius:9px; color:var(--warn); font-size:13px; width:100%; }
.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(148px,1fr)); gap:10px; }
.tile { background:var(--panel2); border:1px solid var(--line); border-radius:11px; padding:10px 12px 12px; min-width:0; }
.tile .l { font:11.5px var(--sans); text-transform:uppercase; letter-spacing:1px; color:var(--dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.tile .v { font:700 18px var(--mono); margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.tile .bar { height:3px; border-radius:2px; background:var(--line); margin-top:8px; overflow:hidden; }
.tile .bar i { display:block; height:100%; border-radius:2px; }
.sevchips { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px; }
.sevchip { font:12px var(--mono); padding:4px 10px; border-radius:16px; border:1px solid var(--line);
background:var(--panel2); color:var(--muted); cursor:pointer; user-select:none; }
.sevchip[data-on="1"] { color:var(--ink); border-color:currentColor; }
.flaglist { max-height:430px; overflow-y:auto; padding-right:4px; }
.flag { display:flex; gap:11px; padding:9px 10px; border-radius:9px; border-left:3px solid var(--line); margin-bottom:6px;
background:var(--panel2); }
.flag .fico { flex:none; width:18px; text-align:center; font-size:14px; padding-top:1px; }
.flag .ft { font-weight:700; font-size:14px; }
.flag .fw { float:right; font:11px var(--mono); color:var(--dim); margin-left:8px; }
.flag .fd { color:var(--muted); font-size:14px; line-height:1.45; }
.vizrow { display:flex; gap:18px; align-items:center; flex-wrap:wrap; }
.kv { display:flex; flex-wrap:wrap; gap:6px 20px; color:var(--muted); font-size:14px; }
.kv span b { color:var(--ink); font-family:var(--mono); }
.hbar { margin:6px 0 2px; }
.hbar .row { display:flex; align-items:center; gap:10px; margin-bottom:5px; }
.hbar .lb { width:124px; flex:none; font:11.5px var(--mono); color:var(--muted); text-align:right; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.hbar .tr { flex:1; height:14px; background:var(--panel2); border-radius:4px; overflow:hidden; display:flex; }
.hbar .tr i { height:100%; }
.hbar .vl { width:62px; flex:none; font:12px var(--mono); color:var(--ink); }
.stack { display:flex; height:26px; border-radius:7px; overflow:hidden; background:var(--panel2); border:1px solid var(--line); }
.stack i { height:100%; border-right:2px solid var(--panel); min-width:2px; cursor:default; transition:filter .15s; }
.stack i:hover { filter:brightness(1.35); }
.legend { display:flex; gap:5px 16px; flex-wrap:wrap; margin-top:8px; font:12px var(--mono); color:var(--muted); }
.legend .sw { display:inline-block; width:9px; height:9px; border-radius:2px; margin-right:5px; vertical-align:-1px; }
table.tb { width:100%; border-collapse:collapse; font:13px var(--mono); }
table.tb th { text-align:left; color:var(--dim); font:11.5px var(--sans); text-transform:uppercase; letter-spacing:.8px;
padding:4px 8px; border-bottom:1px solid var(--line); }
table.tb td { padding:5px 8px; border-bottom:1px solid var(--line2); }
.checkgrid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; }
.check { display:flex; gap:9px; align-items:center; background:var(--panel2); border:1px solid var(--line);
border-radius:9px; padding:8px 11px; font-size:13px; }
.check .st { font-size:14px; }
.check .cl { color:var(--muted); }
.check b { display:block; font-size:14px; }
.dfn { display:inline-block; font:11.5px var(--mono); border:1px solid var(--line); color:var(--warn);
border-radius:5px; padding:1px 7px; margin:2px 3px 0 0; }
.donutbox { display:flex; gap:16px; align-items:center; }
.donutbox canvas { width:132px; height:132px; flex:none; }
canvas.radar { width:100%; height:250px; display:block; }
.cltable-dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:6px; vertical-align:-1px; }
.cltoggles { display:flex; gap:12px; flex-wrap:wrap; margin:6px 0 10px; font:12px var(--mono); color:var(--muted); }
.cltoggles label { cursor:pointer; user-select:none; display:flex; align-items:center; gap:5px; }
.clwrap { display:flex; gap:12px; flex-wrap:wrap; }
.clcanvasbox { flex:1; min-width:320px; position:relative; }
.clcanvas { width:100%; height:460px; background:
radial-gradient(700px 400px at 50% 40%, rgba(57,208,216,.06), transparent 70%), #04070c;
border:1px solid var(--line); border-radius:12px; cursor:grab; touch-action:none; }
.clexpand { position:absolute; top:8px; right:8px; z-index:5; background:rgba(4,7,12,.85); color:var(--muted);
border:1px solid var(--line); border-radius:8px; padding:3px 9px; font-size:13px; font-weight:600; cursor:pointer; }
.clexpand:hover { color:var(--ink); border-color:var(--cyan2); }
.clwrap.clfull { position:fixed; inset:0; z-index:2147483000; background:#04070c; padding:14px; margin:0;
flex-wrap:nowrap; gap:14px; box-sizing:border-box; }
.clwrap.clfull .clcanvasbox { height:auto; align-self:stretch; display:flex; }
.clwrap.clfull .clcanvas { height:auto; flex:1; border-color:rgba(57,208,216,.25); }
.clwrap.clfull .clpanel { max-height:none; height:auto; align-self:stretch; width:300px; }
@media (max-width:700px) { .clwrap.clfull .clpanel { display:none; } }
.clpanel { width:260px; flex:none; background:var(--panel2); border:1px solid var(--line); border-radius:12px;
padding:12px; font-size:13px; min-height:120px; max-height:460px; overflow-y:auto; }
.clpanel .pa { font:12px var(--mono); word-break:break-all; color:var(--cyan); }
.clpanel .pk { color:var(--dim); margin-top:8px; font:11.5px var(--sans); text-transform:uppercase; letter-spacing:.8px; }
.clpanel .pv { color:var(--ink); font-family:var(--mono); font-size:13px; }
.clleg { color:var(--dim); font-size:14px; margin-top:6px; }
#tip { position:fixed; z-index:50; pointer-events:none; background:#050a0f; border:1px solid var(--cyan2);
color:var(--ink); font:12px var(--mono); padding:6px 9px; border-radius:7px; box-shadow:var(--shadow);
display:none; max-width:280px; }
.foot { color:var(--dim); font-size:13px; padding:14px 4px 0; }
.empty { color:var(--muted); text-align:center; padding:60px 20px; }
.spinner { display:inline-block; width:15px; height:15px; border:2px solid var(--line); border-top-color:var(--cyan);
border-radius:50%; animation:spin .7s linear infinite; vertical-align:-3px; }
@keyframes spin { to { transform:rotate(360deg); } }
.feed { margin-top:22px; }
.feed h2 { font:700 12px var(--sans); color:var(--muted); text-transform:uppercase; letter-spacing:1.2px; margin:0 0 10px; }
.lrow { display:flex; justify-content:space-between; gap:10px; padding:9px 14px; background:var(--panel); border:1px solid var(--line);
border-radius:10px; margin-bottom:7px; font:13px var(--mono); cursor:pointer; }
.lrow:hover { border-color:var(--cyan2); }
.lrow .a { color:var(--cyan); }
.reveal { opacity:0; translate:0 10px; animation:reveal .45s ease-out forwards; }
@keyframes reveal { to { opacity:1; translate:0 0; } }
@media (prefers-reduced-motion:reduce){ *,*::before,*::after{ animation-duration:.01ms!important; animation-delay:0ms!important; transition-duration:.01ms!important; } }
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="brand"><span class="logo">☀️</span><span><b>SOLWATCH</b></span><span class="chain">SOLANA</span></div>
<div class="searchbar">
<input id="addr" placeholder="token mint address on Solana" spellcheck="false" aria-label="token address" />
<button id="go">Audit</button>
<button class="ghost" id="scan">New launches</button>
</div>
</div>
<div class="chips" id="chips"></div>
<div id="out"><div class="empty">Enter a token mint, tap a reference token, or pull the latest launchpad launches.</div></div>
<div class="feed" id="reports"></div>
<div class="feed" id="feed"></div>
</div>
<div id="tip" role="tooltip"></div>
<script>
'use strict';
const REF = [
['BONK','DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263'],
['WIF','EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm'],
['JUP','JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN'],
];
const VCOLOR = { AVOID:'var(--crit)', HIGH_RISK:'var(--danger)', CAUTION:'var(--warn)', FAIR:'var(--fair)', LOW_RISK:'var(--good)', UNKNOWN:'var(--muted)' };
const VHEX = { AVOID:'#ef4444', HIGH_RISK:'#f87171', CAUTION:'#fbbf24', FAIR:'#a3e635', LOW_RISK:'#4ade80', UNKNOWN:'#8ea3b8' };
const SEV = { critical:['🛑','var(--crit)'], danger:['🔴','var(--danger)'], warning:['⚠️','var(--warn)'], info:['ℹ️','var(--muted)'], good:['✅','var(--good)'] };
const SEVORD = ['critical','danger','warning','info','good'];
const CLCOLORS = ['hsl(184 70% 62%)','hsl(0 70% 62%)','hsl(218 70% 62%)','hsl(44 70% 62%)','hsl(254 70% 62%)','hsl(142 70% 62%)','hsl(324 70% 62%)','hsl(26 70% 62%)'];
const clA = (c,a) => c.replace(')',' / '+a+')');
const EXPLORER = 'https://solscan.io/account/';
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const usd = n => n==null ? '—' : '$'+Number(n).toLocaleString('en-US',{maximumFractionDigits:0});
const pct = n => n==null ? '—' : (Math.round(n*100)/100)+'%';
const el = h => { const d=document.createElement('div'); d.innerHTML=h; return d.firstElementChild; };
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
const shortA = a => a && a.length>14 ? a.slice(0,8)+'…'+a.slice(-4) : (a||'');
const css = v => getComputedStyle(document.body).getPropertyValue('--'+v).trim() || v;
const tip = document.getElementById('tip');
function tipShow(html, x, y){ tip.innerHTML=html; tip.style.display='block';
const r=tip.getBoundingClientRect();
tip.style.left=Math.min(x+14, innerWidth-r.width-8)+'px';
tip.style.top=Math.min(y+14, innerHeight-r.height-8)+'px'; }
function tipHide(){ tip.style.display='none'; }
function hookTips(root){
root.querySelectorAll('[data-tip]').forEach(n => {
n.addEventListener('pointermove', ev => tipShow(n.dataset.tip, ev.clientX, ev.clientY));
n.addEventListener('pointerleave', tipHide);
});
}
function prep(cv){ const dpr=devicePixelRatio||1, r=cv.getBoundingClientRect();
cv.width=r.width*dpr; cv.height=r.height*dpr;
const ctx=cv.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); return [ctx, r.width, r.height]; }
const chips = document.getElementById('chips');
REF.forEach(([s,a]) => { const c=el('<div class="chip">'+s+'</div>'); c.onclick=()=>{ document.getElementById('addr').value=a; audit(a); }; chips.appendChild(c); });
async function audit(a) {
const out = document.getElementById('out');
out.innerHTML = '<div class="empty"><span class="spinner"></span> Auditing '+esc(a.slice(0,10))+'… (live chain — full pass, ~40-70s)</div>';
try {
const r = await fetch('/api/audit?address='+encodeURIComponent(a)).then(x=>x.json());
if (r && r.__rokha_pending) { out.innerHTML='<div class="empty">⧗ Sent to Rokha — running on your allowance; this panel refreshes with the result.</div>'; return; }
if (r.error) { out.innerHTML='<div class="empty">Error: '+esc(r.error)+'</div>'; return; }
out.innerHTML='';
out.appendChild(renderHud(r));
} catch(e) { out.innerHTML='<div class="empty">Error: '+esc(e.message)+'</div>'; }
}
function renderHud(r) {
const g = VHEX[r.verdict]||VHEX.UNKNOWN;
const s = r.sections||{};
const root = el('<div class="hud"></div>');
root.appendChild(headerPanel(r, g));
root.appendChild(tilesPanel(r, s));
const radar = panel('c4','Risk surface','deduction weight per axis — full ring = hard-fail territory');
const radarCv = el('<canvas class="radar" aria-label="risk radar"></canvas>');
radar.appendChild(radarCv); root.appendChild(radar);
root.appendChild(findingsPanel(r));
const bu = s.bundles;
if (bu) root.appendChild(launchPanel(bu, r.token));
if (s.holders) root.appendChild(holdersPanel(s.holders, s.cluster));
if (s.liquidity) root.appendChild(liquidityPanel(s.liquidity, s.market));
root.appendChild(authoritiesPanel(s.mint, s.deployer, s.copycats));
const cl = s.cluster;
if (cl && cl.schema) { try { root.appendChild(clusterPanel(cl)); } catch(e){ console.warn('cluster render', e); } }
root.appendChild(el('<div class="c12 foot">Scanned '+esc(r.scannedAt||'')+' · Solwatch · real on-chain data · a snapshot of the chain at scan time — flags and score move as the chain moves (e.g. a revoked authority deletes its flag) · not financial advice'+
(r.rokhaUrl?' · <a href="'+esc(r.rokhaUrl)+'" target="_blank" rel="noopener">view in Rokha ↗</a>':'')+'</div>'));
requestAnimationFrame(() => {
drawRing(root.querySelector('.ringcv'), r.score, g);
drawRadar(radarCv, radarAxes(r));
root.querySelectorAll('canvas.donut').forEach(cv => drawDonut(cv));
hookTips(root);
});
let i=0; root.querySelectorAll('.panel').forEach(p => { p.classList.add('reveal'); p.style.animationDelay=Math.min(240,i++*40)+'ms'; });
return root;
}
function panel(span, title, sub){ const p=el('<div class="panel '+span+'"></div>');
p.appendChild(el('<h3>'+esc(title)+'</h3>'));
if (sub) p.appendChild(el('<div class="sub">'+esc(sub)+'</div>'));
return p; }
function headerPanel(r, g){
const t=r.token||{};
const p=el('<div class="panel c12"></div>');
p.style.boxShadow='inset 0 1px 0 '+g+'33';
const head=el('<div class="head"></div>');
head.appendChild(el('<div class="ringbox"><canvas class="ringcv" width="118" height="118"></canvas>'+
'<div class="rv"><div><b style="color:'+g+'">'+r.score+'</b><span>/ 100</span></div></div></div>'));
head.appendChild(el('<div class="idc"><div class="sym">$'+esc(t.symbol||'?')+'</div><div class="nm">'+esc(t.name||'unknown')+'</div>'+
'<div class="addr"><code>'+esc(r.address)+'</code>'+
'<button class="iconbtn" data-copy="'+esc(r.address)+'">copy</button>'+
'<a href="'+EXPLORER+encodeURIComponent(r.address)+'" target="_blank" rel="noopener">explorer ↗</a></div></div>'));
head.appendChild(el('<div class="verdict" style="color:'+g+';border-color:'+g+'">'+esc((r.verdict||'').replace('_',' '))+'</div>'));
if ((r.warnings||[]).length)
head.appendChild(el('<div class="warnstrip">⚠ '+r.warnings.map(esc).join(' · ')+' — affected checks did NOT run; unknown ≠ safe.</div>'));
p.appendChild(head);
p.querySelectorAll('[data-copy]').forEach(b => b.onclick = async () => {
try { await navigator.clipboard.writeText(b.dataset.copy); b.textContent='copied'; setTimeout(()=>b.textContent='copy',1200); } catch(_){}
});
return p;
}
function tone(t){ return t==='bad'?css('danger'):t==='warn'?css('warn'):t==='ok'?css('good'):css('cyan'); }
function tilesPanel(r, s){
const t=r.token||{}, bu=s.bundles||{}, lq=s.liquidity||{}, ho=s.holders||{}, mk=s.market||{}, ec=(s.cluster||{}).effective_concentration||{};
const p=panel('c12','Instruments');
const grid=el('<div class="tiles"></div>');
const add=(l,v,frac,tn,tipTxt)=>{ if(v==null||v==='—')return;
grid.appendChild(el('<div class="tile"'+(tipTxt?' data-tip="'+esc(tipTxt)+'"':'')+'><div class="l">'+l+'</div><div class="v">'+v+'</div>'+
(frac!=null?'<div class="bar"><i style="width:'+Math.min(100,Math.max(2,frac))+'%;background:'+tone(tn)+'"></i></div>':'')+'</div>')); };
add('Market cap', usd(t.marketCapUsd));
add('24h volume', usd(t.volume24hUsd));
add('Holders', t.holdersCount!=null?t.holdersCount.toLocaleString():null);
add('Liquidity', lq.totalLiquidityUsd!=null?usd(lq.totalLiquidityUsd):null, null, null);
if (lq.liqToFdvPct!=null) add('Liq / FDV', pct(lq.liqToFdvPct), Math.min(100,lq.liqToFdvPct*2), lq.liqToFdvPct<2?'bad':lq.liqToFdvPct<8?'warn':'ok', 'liquidity as a share of fully-diluted value — low = the price is mostly on paper');
if (bu.analyzed && bu.pctSupplySniped!=null) add('Supply sniped', pct(bu.pctSupplySniped), bu.pctSupplySniped, bu.pctSupplySniped>=40?'bad':bu.pctSupplySniped>=15?'warn':'ok', 'share of supply bought in the launch window');
if (bu.analyzed && bu.pctDevAllocation!=null) add('Dev allocation', pct(bu.pctDevAllocation), bu.pctDevAllocation, bu.pctDevAllocation>=20?'bad':bu.pctDevAllocation>=5?'warn':'ok', 'supply the dev wallet took at launch');
if (mk.organicScore!=null) add('Organic score', mk.organicScore+(mk.organicScoreLabel?' ('+mk.organicScoreLabel+')':''), mk.organicScore, mk.organicScore<30?'bad':mk.organicScore<60?'warn':'ok', "Jupiter's bot-vs-organic volume heuristic");
if (bu.analyzed && bu.pctSnipersStillHold!=null) add('Snipers hold', pct(bu.pctSnipersStillHold), bu.pctSnipersStillHold, bu.pctSnipersStillHold>=20?'bad':bu.pctSnipersStillHold>=5?'warn':'ok', 'what the launch snipers STILL hold now');
if (ho.top10Pct!=null) add('Top-10 wallets', pct(ho.top10Pct), ho.top10Pct, ho.top10Pct>=60?'bad':ho.top10Pct>=30?'warn':'ok', 'raw per-wallet concentration (pools/burn excluded)');
if (ec.top10_clustered_pct!=null) add('Top-10 entities', pct(ec.top10_clustered_pct), ec.top10_clustered_pct, ec.top10_clustered_pct>=60?'bad':ec.top10_clustered_pct>=30?'warn':'ok', 'concentration after merging linked wallets into entities');
p.appendChild(grid);
return p;
}
function drawRing(cv, score, color){
if (!cv) return;
const [ctx,w,h]=prep(cv); const cx=w/2, cy=h/2, R=w/2-7;
const target=(Math.max(0,Math.min(100,score))/100)*Math.PI*2;
function frame(a){
ctx.clearRect(0,0,w,h);
ctx.lineWidth=8; ctx.lineCap='round';
ctx.strokeStyle='#182430';
ctx.beginPath(); ctx.arc(cx,cy,R,0,7); ctx.stroke();
ctx.strokeStyle='#1d2a38'; ctx.lineWidth=1;
for(let i=0;i<20;i++){ const t=-Math.PI/2+i*Math.PI/10;
ctx.beginPath(); ctx.moveTo(cx+Math.cos(t)*(R-8),cy+Math.sin(t)*(R-8));
ctx.lineTo(cx+Math.cos(t)*(R-12),cy+Math.sin(t)*(R-12)); ctx.stroke(); }
ctx.lineWidth=8; ctx.strokeStyle=color;
ctx.shadowColor=color; ctx.shadowBlur=12;
ctx.beginPath(); ctx.arc(cx,cy,R,-Math.PI/2,-Math.PI/2+a); ctx.stroke();
ctx.shadowBlur=0;
}
if (REDUCED) { frame(target); return; }
const t0=performance.now();
(function step(now){ const k=Math.min(1,(now-t0)/700); frame(target*(1-Math.pow(1-k,3)));
if(k<1) requestAnimationFrame(step); })(t0);
}
function radarAxes(r){
const AX=[['Authorities',/freeze|mint_auth|authority|metadata|mutable/],
['Token-2022',/2022|transfer_fee|transfer_hook|permanent_delegate|non_transferable|extension/],
['Holders',/top10|whale|holders/],
['Liquidity',/liq|no_pair|socials|organic|bot/],
['Launch',/bundle|snip|dev_|alloc|serial|deployer|launch|fresh/],
['Clusters',/cluster|entity|jito/]];
const axes=AX.map(([n])=>({label:n,v:0,flags:[]}));
(r.flags||[]).forEach(f=>{ if(f.severity==='good'||f.severity==='info'&&f.weight<=0) return;
const i=AX.findIndex(([,re])=>re.test(f.id));
const a=axes[i<0?4:i]; a.flags.push(f.title);
a.v = f.hardFail||f.hard_fail ? 100 : Math.min(100, a.v + f.weight*2.2); });
return axes;
}
function drawRadar(cv, axes){
if (!cv) return;
const [ctx,w,h]=prep(cv); const cx=w/2, cy=h/2+6, R=Math.min(w,h)/2-34, N=axes.length;
const pt=(i,k)=>{ const a=-Math.PI/2+i*2*Math.PI/N; return [cx+Math.cos(a)*R*k, cy+Math.sin(a)*R*k]; };
ctx.clearRect(0,0,w,h);
ctx.strokeStyle='#1d2a38'; ctx.lineWidth=1;
[0.25,0.5,0.75,1].forEach(k=>{ ctx.beginPath();
for(let i=0;i<=N;i++){ const [x,y]=pt(i%N,k); i?ctx.lineTo(x,y):ctx.moveTo(x,y); } ctx.stroke(); });
for(let i=0;i<N;i++){ const [x,y]=pt(i,1); ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(x,y); ctx.stroke(); }
ctx.beginPath();
axes.forEach((a,i)=>{ const [x,y]=pt(i,Math.max(.04,a.v/100)); i?ctx.lineTo(x,y):ctx.moveTo(x,y); });
ctx.closePath();
ctx.fillStyle='rgba(57,208,216,.16)'; ctx.fill();
ctx.strokeStyle='#39d0d8'; ctx.lineWidth=2; ctx.stroke();
axes.forEach((a,i)=>{ const [x,y]=pt(i,Math.max(.04,a.v/100));
ctx.beginPath(); ctx.arc(x,y,3.5,0,7);
ctx.fillStyle=a.v>=100?'#ef4444':a.v>=50?'#fbbf24':'#39d0d8'; ctx.fill(); });
ctx.font='12px ui-monospace,monospace'; ctx.fillStyle='#8ea3b8'; ctx.textAlign='center';
axes.forEach((a,i)=>{ const [x,y]=pt(i,1.17); ctx.fillText(a.label.toUpperCase(),x,y+3); });
cv.onpointermove = ev => { const r2=cv.getBoundingClientRect(); const mx=ev.clientX-r2.left,my=ev.clientY-r2.top;
let best=-1,bd=1e9;
axes.forEach((a,i)=>{ const [x,y]=pt(i,Math.max(.04,a.v/100)); const d=(mx-x)**2+(my-y)**2; if(d<bd){bd=d;best=i;} });
if (bd<400){ const a=axes[best];
tipShow('<b>'+a.label+'</b> · risk '+Math.round(a.v)+'/100'+(a.flags.length?'<br>'+a.flags.slice(0,4).map(esc).join('<br>'):'<br>no findings'), ev.clientX, ev.clientY);
} else tipHide(); };
cv.onpointerleave = tipHide;
}
function findingsPanel(r){
const p=panel('c8','Findings','click a severity to filter');
const flags=r.flags||[];
const counts={}; flags.forEach(f=>counts[f.severity]=(counts[f.severity]||0)+1);
const on=Object.fromEntries(SEVORD.map(s2=>[s2,true]));
const chips2=el('<div class="sevchips"></div>');
SEVORD.forEach(sv=>{ if(!counts[sv])return;
const [ico,col]=SEV[sv];
const c=el('<span class="sevchip" data-on="1" style="color:'+col+'">'+ico+' '+sv+' <b>'+counts[sv]+'</b></span>');
c.onclick=()=>{ on[sv]=!on[sv]; c.dataset.on=on[sv]?'1':'0'; c.style.color=on[sv]?col:''; draw(); };
chips2.appendChild(c); });
p.appendChild(chips2);
const list=el('<div class="flaglist"></div>'); p.appendChild(list);
function draw(){
list.innerHTML='';
flags.filter(f=>on[f.severity]).forEach(f=>{
const [ico,col]=SEV[f.severity]||['•','var(--muted)'];
list.appendChild(el('<div class="flag" style="border-left-color:'+col+'"><div class="fico">'+ico+'</div><div style="min-width:0">'+
'<div class="ft" style="color:'+col+'">'+esc(f.title)+
((f.hardFail||f.hard_fail)?' <span class="fw">HARD-CAP</span>':'<span class="fw">-'+f.weight+'</span>')+'</div>'+
'<div class="fd">'+esc(f.detail)+'</div></div></div>'));
});
if (!list.children.length) list.appendChild(el('<div class="fd" style="color:var(--dim);padding:8px">nothing at the selected severities</div>'));
}
draw();
return p;
}
function drawDonut(cv){
const segs=JSON.parse(cv.dataset.segs||'[]'); if(!segs.length) return;
const [ctx,w,h]=prep(cv); const cx=w/2,cy=h/2,R=w/2-6,r0=R*0.62;
let a=-Math.PI/2; const total=segs.reduce((t,s)=>t+s.v,0)||1;
segs.forEach(s2=>{ const sw=s2.v/total*Math.PI*2;
ctx.beginPath(); ctx.arc(cx,cy,R,a,a+sw); ctx.arc(cx,cy,r0,a+sw,a,true); ctx.closePath();
ctx.fillStyle=s2.c; ctx.fill();
ctx.strokeStyle='#0e141c'; ctx.lineWidth=2; ctx.stroke();
s2.a0=a; s2.a1=a+sw; a+=sw; });
ctx.fillStyle='#e8eef5'; ctx.font='700 16px ui-monospace,monospace'; ctx.textAlign='center';
ctx.fillText(cv.dataset.center||'', cx, cy+1);
ctx.font='11px ui-sans-serif,system-ui'; ctx.fillStyle='#5a6b7d';
ctx.fillText((cv.dataset.centersub||'').toUpperCase(), cx, cy+16);
cv.onpointermove=ev=>{ const rb=cv.getBoundingClientRect(); const mx=ev.clientX-rb.left-cx,my=ev.clientY-rb.top-cy;
const d=Math.hypot(mx,my); if(d<r0||d>R){ tipHide(); return; }
let ang=Math.atan2(my,mx); if(ang<-Math.PI/2) ang+=Math.PI*2;
const s2=segs.find(x=>ang>=x.a0&&ang<x.a1);
if(s2) tipShow('<b>'+esc(s2.l)+'</b> · '+pct(s2.v), ev.clientX, ev.clientY); else tipHide(); };
cv.onpointerleave=tipHide;
}
function launchPanel(bu, tok){
const p=panel('c6 keep','Launch forensics', bu&&bu.note?bu.note:null);
if (bu && bu.analyzed && bu.pctSupplySniped!=null){
const sniped=bu.pctSupplySniped||0, held=Math.min(sniped,bu.pctSnipersStillHold||0);
const box=el('<div class="donutbox"></div>');
box.appendChild(el('<canvas class="donut" data-center="'+pct(sniped)+'" data-centersub="sniped" data-segs=\''+JSON.stringify([
{l:'still held by snipers', v:held, c:'#f87171'},
{l:'sniped, since sold', v:Math.max(0,sniped-held), c:'#a87f10'},
{l:'organic / later supply', v:Math.max(0,100-sniped), c:'#1d2a38'},
]).replace(/'/g,''')+'\'></canvas>'));
const kv=el('<div class="kv" style="flex-direction:column;gap:7px">'+
'<span>Snipers <b>'+(bu.snipers??'—')+'</b></span>'+
'<span>Same-slot buyers <b>'+(bu.sameSlotBuyers??'—')+'</b></span>'+
'<span>Fresh-wallet ratio <b>'+(bu.freshWalletRatio==null?'—':pct(bu.freshWalletRatio*100))+'</b></span>'+
'<span>Dev allocation <b>'+(bu.pctDevAllocation==null?'—':pct(bu.pctDevAllocation))+'</b></span>'+
'<span>Launch slot <b>'+(bu.launchSlot??'—')+'</b></span>'+
(tok&&tok.launchpad?'<span>Launchpad <b>'+esc(tok.launchpad)+'</b></span>':'')+'</div>');
box.appendChild(kv); p.appendChild(box);
const sn=bu.topSnipers||[];
if (sn.length){
const hb=el('<div class="hbar" style="margin-top:12px"></div>');
const mx=Math.max(...sn.map(x=>x.pct||0), 0.01);
sn.slice(0,6).forEach(x=>{
const addr=x.owner||x.address||'';
const stillW=Math.min(100,(x.heldPct||0)/mx*100), soldW=Math.max(0,((x.pct||0)-(x.heldPct||0))/mx*100);
hb.appendChild(el('<div class="row" data-tip="'+esc(shortA(addr))+' — bought '+pct(x.pct)+', still holds '+pct(x.heldPct)+(x.freshWallet?' · fresh wallet':'')+(x.sameSlot?' · same-slot':'')+'">'+
'<span class="lb">'+esc(shortA(addr))+(x.freshWallet?' ✦':'')+(x.sameSlot?' ⚡':'')+'</span>'+
'<span class="tr"><i style="width:'+stillW+'%;background:#f87171"></i><i style="width:'+soldW+'%;background:#a87f10"></i></span>'+
'<span class="vl">'+pct(x.pct)+'</span></div>'));
});
p.appendChild(el('<div class="sub" style="margin:12px 0 2px">top snipers — <span style="color:#f87171">still holding</span> vs <span style="color:#a87f10">sold</span> · ✦ fresh wallet · ⚡ same-slot</div>'));
p.appendChild(hb);
}
const tl=bu.timeline||[];
if (tl.length){
const bySlot={};
tl.forEach(x=>{ const k=x.slotOffset??0; bySlot[k]=(bySlot[k]||0)+(x.pct||0); });
const slots=Object.keys(bySlot).map(Number).sort((a,b)=>a-b).slice(0,12);
const mx2=Math.max(...slots.map(k=>bySlot[k]),0.01);
const hb2=el('<div class="hbar"></div>');
slots.forEach(k=>{ hb2.appendChild(el('<div class="row" data-tip="slot +'+k+' — '+pct(bySlot[k])+' of supply bought ('+tl.filter(x=>(x.slotOffset??0)===k).length+' buyers)">'+
'<span class="lb">slot +'+k+'</span>'+
'<span class="tr"><i style="width:'+Math.min(100,bySlot[k]/mx2*100)+'%;background:'+(k===0?'#c74e4e':'#3f74c9')+'"></i></span>'+
'<span class="vl">'+pct(bySlot[k])+'</span></div>')); });
p.appendChild(el('<div class="sub" style="margin:12px 0 2px">buy timeline — supply bought per slot after launch (<span style="color:#c74e4e">slot 0</span> = the bundle signature)</div>'));
p.appendChild(hb2);
}
} else if (bu) {
p.appendChild(el('<div class="fd" style="color:var(--muted)">'+esc(bu.note||'Bundle scan did not run — unknown, not clean.')+'</div>'));
}
return p;
}
function holdersPanel(ho, cl){
const p=panel('c6 keep','Holder concentration','top wallets, LP/burn/protocol reserves excluded — hover a segment');
const top=(ho.top||[]).slice(0,10);
if (top.length){
const stack=el('<div class="stack" role="img" aria-label="top holder share"></div>');
let sum=0;
top.forEach((h2,i)=>{ sum+=h2.pct||0;
stack.appendChild(el('<i style="width:'+Math.max(1,h2.pct||0)+'%;background:'+CLCOLORS[i%CLCOLORS.length]+'" data-tip="'+esc(shortA(h2.address))+(h2.isContract?' (contract)':'')+' — '+pct(h2.pct)+'"></i>'));
});
stack.appendChild(el('<i style="width:'+Math.max(0,100-sum)+'%;background:#121a24" data-tip="everyone else — '+pct(Math.max(0,100-sum))+'"></i>'));
p.appendChild(stack);
p.appendChild(el('<div class="legend"><span>top-10 combined <b style="color:var(--ink)">'+pct(ho.top10Pct)+'</b></span>'+
'<span>tracked holders <b style="color:var(--ink)">'+(ho.holderCount??'—')+'</b></span></div>'));
} else {
p.appendChild(el('<div class="fd" style="color:var(--muted)">holder data unavailable this run — unknown, not clean</div>'));
}
const ec=(cl||{}).effective_concentration;
if (ec && (ec.top10_raw_pct!=null||ec.top10_clustered_pct!=null)){
const hb=el('<div class="hbar" style="margin-top:14px"></div>');
const mx=Math.max(ec.top10_raw_pct||0, ec.top10_clustered_pct||0, 1);
[['raw top-10', ec.top10_raw_pct, '#3f74c9'],
['entity top-10', ec.top10_clustered_pct, '#c74e4e'],
['largest cluster', ec.largest_cluster_pct, '#a87f10']].forEach(([l,v,c])=>{
if (v==null) return;
hb.appendChild(el('<div class="row" data-tip="'+l+' — '+pct(v)+'"><span class="lb">'+l+'</span>'+
'<span class="tr"><i style="width:'+Math.min(100,v/mx*100)+'%;background:'+c+'"></i></span><span class="vl">'+pct(v)+'</span></div>'));
});
p.appendChild(el('<div class="sub" style="margin:12px 0 2px">anti-evasion view — same supply, wallets merged into entities'+(ec.gini!=null?' · gini '+ec.gini:'')+'</div>'));
p.appendChild(hb);
}
return p;
}
function liquidityPanel(lq, mk){
const p=panel('c6 keep','Liquidity & market');
p.appendChild(el('<div class="kv"><span>Total <b>'+usd(lq.totalLiquidityUsd)+'</b></span>'+
'<span>FDV <b>'+usd(lq.fdvUsd)+'</b></span>'+
'<span>Liq/FDV <b>'+pct(lq.liqToFdvPct)+'</b></span>'+
'<span>Socials <b>'+(lq.hasSocials?'yes':'none')+'</b></span>'+
(mk&&mk.organicScore!=null?'<span>Organic score <b>'+mk.organicScore+' ('+esc(mk.organicScoreLabel||'')+')</b></span>':'')+
(mk&&(mk.tags||[]).length?'<span>Tags <b>'+mk.tags.map(esc).join(', ')+'</b></span>':'')+'</div>'));
const pairs=lq.pairs||[];
if (pairs.length){
const rows=pairs.slice(0,6).map(x=>'<tr><td><b>'+esc(x.dex||'?')+'</b>'+((x.labels||[]).length?' <span style="color:var(--dim)">'+esc(x.labels[0])+'</span>':'')+'</td><td>'+esc(x.quoteSymbol||'')+'</td>'+
'<td>'+usd(x.liquidityUsd)+'</td><td>'+(x.ageHours==null?'—':(x.ageHours<48?x.ageHours+'h':Math.round(x.ageHours/24)+'d'))+'</td></tr>').join('');
p.appendChild(el('<table class="tb" style="margin-top:12px"><thead><tr><th>venue</th><th>pair</th><th>liquidity</th><th>age</th></tr></thead><tbody>'+rows+'</tbody></table>'));
}
return p;
}
const DANGER_EXT = { transferFeeConfig:'transfer fee', transferHook:'transfer hook', permanentDelegate:'permanent delegate', nonTransferable:'non-transferable', defaultAccountState:'default acct state', confidentialTransferMint:'confidential' };
function authoritiesPanel(mint, dep, copy){
const p=panel('c6 keep','Authorities & token program','the Solana rug levers — revoked = safe, present = the holder can act');
const grid=el('<div class="checkgrid"></div>');
const add=(label,ok,txt,bad)=>{ grid.appendChild(el('<div class="check"><span class="st">'+(ok==null?'◌':ok?'✅':(bad?'🛑':'⚠️'))+'</span>'+
'<span class="cl"><b style="color:'+(ok==null?'var(--dim)':ok?'var(--good)':(bad?'var(--crit)':'var(--warn)'))+'">'+esc(txt)+'</b>'+label+'</span></div>')); };
if (mint){
add('freeze authority', mint.freezeAuthority==null, mint.freezeAuthority==null?'revoked':shortA(mint.freezeAuthority), mint.freezeAuthority!=null);
add('mint authority', mint.mintAuthority==null, mint.mintAuthority==null?'revoked':shortA(mint.mintAuthority), mint.mintAuthority!=null);
add('token program', mint.isToken2022!==true, mint.isToken2022?'Token-2022':'SPL Token');
const md=mint.metadata||{};
add('metadata', md.isMutable===false, md.isMutable===false?'immutable':md.isMutable===true?'MUTABLE':'unknown');
}
p.appendChild(grid);
const exts=(mint&&mint.extensions)||[];
if (mint&&mint.isToken2022){
if (exts.length) p.appendChild(el('<div style="margin-top:10px"><span class="sub">Token-2022 extensions on this mint:</span><br>'+
exts.map(e=>'<span class="dfn">'+esc(DANGER_EXT[e]||e)+'</span>').join('')+'</div>'));
else p.appendChild(el('<div class="sub" style="margin-top:10px">Token-2022 mint with no honeypot-class extensions detected.</div>'));
}
if (dep){
p.appendChild(el('<div class="kv" style="margin-top:12px">'+
(dep.dev?'<span>Dev wallet <b><a href="'+EXPLORER+encodeURIComponent(dep.dev)+'" target="_blank" rel="noopener">'+shortA(dep.dev)+'</a></b></span>':'')+
(dep.walletAgeDays!=null?'<span>Wallet age <b>'+dep.walletAgeDays+'d</b></span>':'')+
(dep.walletTxCount!=null?'<span>Wallet txs <b>'+dep.walletTxCount+'</b></span>':'')+
(dep.tokensLaunched!=null?'<span>Tokens launched <b>'+dep.tokensLaunched+'</b></span>':'')+
(dep.devBalancePct!=null?'<span>Dev holds <b>'+pct(dep.devBalancePct)+'</b></span>':'')+
'</div>'));
}
if ((copy||[]).length) p.appendChild(el('<div class="sub" style="margin-top:10px">⚠ '+copy.length+' same-symbol copycat token(s) — verify the mint you hold.</div>'));
return p;
}
function clusterPanel(cl){
const root=panel('c12','Wallet clusters — who is really one entity','bubble size ∝ √supply share · color = cluster · gold ring = deployer · dashed = funding · solid = transfer · dotted = same-slot co-buy · bold gold = Jito-bundle PROOF · gray = no proven link, or CEX/pool/infra (never merged)');
const ec = cl.effective_concentration || null;
const scope = cl.scope || {};
let kv='<div class="kv" style="margin-bottom:10px">';
if (ec) kv+='<span>Top-10 raw <b>'+pct(ec.top10_raw_pct)+'</b></span><span>Top-10 clustered <b>'+pct(ec.top10_clustered_pct)+'</b></span><span>Largest cluster <b>'+pct(ec.largest_cluster_pct)+'</b></span>'+(ec.gini!=null?'<span>Gini <b>'+ec.gini+'</b></span>':'');
kv+='<span>Coverage <b>'+esc(scope.coverage||'?')+'</b></span></div>';
root.appendChild(el(kv));
if (scope.note) root.appendChild(el('<div class="clleg">'+esc(scope.note)+'</div>'));
const clusters = cl.clusters || [];
const listed = new Set(clusters.map(c => c.id));
if (clusters.length) {
const rows = clusters.map(c =>
'<tr><td><span class="cltable-dot" style="background:'+CLCOLORS[(c.id-1)%CLCOLORS.length]+'"></span>#'+c.id+'</td><td>'+c.wallets.length+'</td><td>'+pct(c.pct_combined)+'</td><td>'+(c.pct_bought!=null?pct(c.pct_bought):'—')+'</td><td>'+esc(c.reason)+'</td><td>'+(c.proof?'<b style="color:#fbbf24">⚡ Jito-PROVEN</b>':'—')+'</td><td style="font-family:var(--sans)">'+esc(c.note||'')+'</td></tr>').join('');
root.appendChild(el('<table class="tb" style="margin:8px 0 14px"><thead><tr><th>cluster</th><th>wallets</th><th>holds</th><th>bought</th><th>evidence</th><th>proof</th><th>note</th></tr></thead><tbody>'+rows+'</tbody></table>'));
} else if (!(cl.linked_groups||[]).length) {
root.appendChild(el('<div class="clleg">No linked wallet clusters found among the analyzed set.</div>'));
}
const lgs = cl.linked_groups || [];
if (lgs.length) {
root.appendChild(el('<div class="clleg" style="margin-top:6px"><b>Linked below the merge bar</b> — shared evidence, not enough to prove one entity (top '+lgs.length+'):</div>'));
const lrows = lgs.map((g,i) =>
'<tr><td>~'+(i+1)+'</td><td>'+(g.wallets||[]).length+'</td><td>'+pct(g.pct_combined)+'</td><td>'+esc(g.evidence||'mixed')+'</td><td style="font-family:var(--sans)">'+esc(g.note||'')+'</td></tr>').join('');
root.appendChild(el('<table class="tb" style="margin:6px 0 12px"><thead><tr><th>lead</th><th>wallets</th><th>holds</th><th>evidence</th><th>note</th></tr></thead><tbody>'+lrows+'</tbody></table>'));
}
const nodes = (cl.nodes||[]).map(n => Object.assign({}, n));
const edges = (cl.edges||[]).filter(e => e.from !== e.to);
if (!nodes.length) return root;
const kinds = [...new Set(edges.map(e => e.kind))];
const tog = el('<div class="cltoggles"></div>');
const show = { stop:true };
kinds.forEach(k => { show[k] = true; });
kinds.concat(['stop']).forEach(k => {
const lab = el('<label><input type="checkbox" checked /> '+(k==='stop'?'CEX / hub nodes':k.replace('_',' ')+' edges')+'</label>');
lab.querySelector('input').onchange = ev => { show[k] = ev.target.checked; draw(); };
tog.appendChild(lab);
});
root.appendChild(tog);
const wrap = el('<div class="clwrap"><div class="clcanvasbox"><canvas class="clcanvas"></canvas></div><div class="clpanel"><div class="clleg">Click a bubble for wallet details. Drag to pan, wheel to zoom, drag bubbles to untangle.</div></div></div>');
root.appendChild(wrap);
const canvas = wrap.querySelector('canvas'), panelEl = wrap.querySelector('.clpanel');
const expBtn = el('<button class="clexpand" title="Expand the map (Esc restores)">⛶ expand</button>');
wrap.querySelector('.clcanvasbox').appendChild(expBtn);
let mapHome = null;
const setFull = on => {
if (on && !mapHome) { mapHome = { parent: wrap.parentNode, next: wrap.nextSibling }; document.body.appendChild(wrap); }
else if (!on && mapHome) { mapHome.parent.insertBefore(wrap, mapHome.next); mapHome = null; }
wrap.classList.toggle('clfull', on);
expBtn.textContent = on ? '✕ close' : '⛶ expand';
document.documentElement.style.overflow = on ? 'hidden' : '';
const refit = () => { resize(); fitView(); draw(); };
requestAnimationFrame(() => { refit(); requestAnimationFrame(refit); });
};
function fitView() {
const r = canvas.getBoundingClientRect();
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
nodes.forEach(n => {
if (!visible(n)) return;
const rad = R(n);
minX = Math.min(minX, n.x - rad); maxX = Math.max(maxX, n.x + rad);
minY = Math.min(minY, n.y - rad); maxY = Math.max(maxY, n.y + rad);
});
if (minX > maxX) return;
const pad = 46, w = Math.max(maxX - minX, 1), h = Math.max(maxY - minY, 1);
scale = Math.min((r.width - pad * 2) / w, (r.height - pad * 2) / h, 2.6);
ox = (r.width - w * scale) / 2 - minX * scale;
oy = (r.height - h * scale) / 2 - minY * scale;
}
window.addEventListener('resize', () => { if (wrap.classList.contains('clfull')) { resize(); draw(); } });
expBtn.onclick = () => setFull(!wrap.classList.contains('clfull'));
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && wrap.isConnected && wrap.classList.contains('clfull')) setFull(false); });
const idx = {}; nodes.forEach((n,i) => idx[n.address]=i);
const links = edges.map(e => ({s:idx[e.from], t:idx[e.to], e})).filter(l => l.s!=null && l.t!=null);
const isStop = n => (n.roles||[]).some(r => r==='cex') || (n.label && n.label.length && /exchange|bridge|hot wallet|cex/i.test(n.label));
const isInfra = n => (n.roles||[]).some(r => r==='pool'||r==='burn'||r==='locker');
const R = n => Math.max(5, 4 + Math.sqrt(Math.max(n.pct||0, n.bought_pct||0)) * 9);
const W = 900, H = 560;
nodes.forEach((n,i) => {
const c = n.cluster || 0;
const a = i * 2.399963;
const cx = W/2 + (c ? Math.cos(c*2.1)*W/4 : 0), cy = H/2 + (c ? Math.sin(c*2.1)*H/4 : 0);
n.x = cx + Math.cos(a) * (60 + 8*Math.sqrt(i)); n.y = cy + Math.sin(a) * (40 + 6*Math.sqrt(i));
n.vx = 0; n.vy = 0;
});
let alpha = 1;
function tick() {
for (let a2=0; a2<nodes.length; a2++) for (let b=a2+1; b<nodes.length; b++) {
const A=nodes[a2], B=nodes[b];
let dx=B.x-A.x, dy=B.y-A.y, d2=dx*dx+dy*dy||1, d=Math.sqrt(d2);
const rep = 900/d2;
dx/=d; dy/=d;
A.vx -= dx*rep; A.vy -= dy*rep; B.vx += dx*rep; B.vy += dy*rep;
const overlap = R(A)+R(B)+2-d;
if (overlap>0) { A.vx -= dx*overlap*0.35; A.vy -= dy*overlap*0.35; B.vx += dx*overlap*0.35; B.vy += dy*overlap*0.35; }
}
links.forEach(l => {
const A=nodes[l.s], B=nodes[l.t];
let dx=B.x-A.x, dy=B.y-A.y, d=Math.sqrt(dx*dx+dy*dy)||1;
const rest = 70, f=(d-rest)/d*0.04;
A.vx += dx*f; A.vy += dy*f; B.vx -= dx*f; B.vy -= dy*f;
});
nodes.forEach(n => {
n.vx += (W/2-n.x)*0.002; n.vy += (H/2-n.y)*0.002;
if (!n.pinned) { n.x += n.vx*alpha; n.y += n.vy*alpha; }
n.vx *= 0.6; n.vy *= 0.6;
});
alpha = Math.max(alpha*0.985, 0.03);
}
let scale=0.9, ox=30, oy=20, sel=null;
const ctx = canvas.getContext('2d');
function resize() {
const r = canvas.getBoundingClientRect(), dpr = window.devicePixelRatio||1;
canvas.width = r.width*dpr; canvas.height = r.height*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
}
function visible(n){ return (show.stop || !isStop(n)); }
function draw() {
const r = canvas.getBoundingClientRect();
ctx.clearRect(0,0,r.width,r.height);
ctx.save(); ctx.translate(ox,oy); ctx.scale(scale,scale);
links.forEach(l => {
const A=nodes[l.s], B=nodes[l.t];
if (!show[l.e.kind] || !visible(A) || !visible(B)) return;
ctx.beginPath();
ctx.setLineDash(l.e.kind==='funding'?[6,4]:(l.e.kind==='same_block'||l.e.kind==='same_slot')?[2,4]:[]);
ctx.lineWidth = l.e.kind==='bundle'?2.5:1.1;
ctx.strokeStyle = l.e.kind==='bundle' ? 'rgba(251,191,36,0.75)' : 'rgba(142,163,184,0.38)';
ctx.moveTo(A.x,A.y); ctx.lineTo(B.x,B.y); ctx.stroke();
ctx.setLineDash([]);
});
nodes.forEach(n => {
if (!visible(n)) return;
const rad = R(n);
const col = (n.cluster && listed.has(n.cluster)) ? CLCOLORS[(n.cluster-1)%CLCOLORS.length] : (isStop(n)||isInfra(n)) ? 'hsl(210 6% 34%)' : 'hsl(210 5% 46%)';
const hot = sel===n;
const g = ctx.createRadialGradient(n.x-rad*0.35, n.y-rad*0.45, rad*0.1, n.x, n.y, rad);
g.addColorStop(0, clA(col, hot?0.5:0.34)); g.addColorStop(1, clA(col, hot?0.26:0.14));
ctx.beginPath(); ctx.arc(n.x,n.y,rad,0,7);
ctx.shadowColor = clA(col, hot?0.55:0.28); ctx.shadowBlur = hot?14:8;
ctx.fillStyle = g; ctx.fill(); ctx.shadowBlur = 0;
ctx.lineWidth = 1.5; ctx.strokeStyle = clA(col, hot?1:0.85); ctx.stroke();
if ((n.roles||[]).includes('dev')) { ctx.lineWidth=2; ctx.strokeStyle='#fbbf24'; ctx.stroke(); }
if (hot) { ctx.lineWidth=2; ctx.strokeStyle='#e8eef5'; ctx.stroke(); }
if (rad>9 && (n.pct>0.5 || (n.bought_pct||0)>0.5)) {
ctx.fillStyle='#e8eef5'; ctx.font='11px ui-monospace,monospace'; ctx.textAlign='center';
ctx.fillText((n.pct||n.bought_pct||0).toFixed(1)+'%', n.x, n.y+3);
}
});
ctx.restore();
}
function world(px,py){ return {x:(px-ox)/scale, y:(py-oy)/scale}; }
function hit(px,py){ const w=world(px,py); for (let i=nodes.length-1;i>=0;i--){ const n=nodes[i]; if (!visible(n)) continue; const dx=w.x-n.x, dy=w.y-n.y; if (dx*dx+dy*dy <= (R(n)+2)*(R(n)+2)) return n; } return null; }
let dragN=null, panning=false, lx=0, ly=0, moved=false;
canvas.addEventListener('pointerdown', ev => {
const r=canvas.getBoundingClientRect(); lx=ev.clientX-r.left; ly=ev.clientY-r.top; moved=false;
dragN = hit(lx,ly); panning = !dragN; canvas.setPointerCapture(ev.pointerId);
});
canvas.addEventListener('pointermove', ev => {
const r=canvas.getBoundingClientRect(); const px=ev.clientX-r.left, py=ev.clientY-r.top;
if (dragN) { const w=world(px,py); dragN.x=w.x; dragN.y=w.y; dragN.pinned=true; alpha=Math.max(alpha,0.25); moved=true; }
else if (panning) { ox+=px-lx; oy+=py-ly; moved = moved || Math.abs(px-lx)+Math.abs(py-ly) > 3; }
else { const n=hit(px,py); canvas.style.cursor = n ? 'pointer' : 'grab'; }
lx=px; ly=py; if (dragN||panning) draw();
});
canvas.addEventListener('pointerup', ev => {
if (!moved) { sel = hit(lx,ly); showPanel(sel); }
if (dragN) dragN.pinned=false;
dragN=null; panning=false; draw();
});
canvas.addEventListener('wheel', ev => {
ev.preventDefault();
const r=canvas.getBoundingClientRect(); const px=ev.clientX-r.left, py=ev.clientY-r.top;
const w=world(px,py); const f = ev.deltaY<0 ? 1.15 : 0.87;
scale = Math.min(6, Math.max(0.2, scale*f));
ox = px - w.x*scale; oy = py - w.y*scale; draw();
}, {passive:false});
function showPanel(n) {
if (!n) { panelEl.innerHTML = '<div class="clleg">Click a bubble for wallet details.</div>'; return; }
const deg = links.filter(l => nodes[l.s]===n || nodes[l.t]===n);
const rows = deg.slice(0,12).map(l => {
const other = nodes[l.s]===n ? nodes[l.t] : nodes[l.s];
return '<div class="pv">'+esc(l.e.kind)+' ↔ '+esc(other.address.slice(0,10))+'…'+(l.e.amount?' ('+esc(l.e.amount)+')':'')+'</div>';
}).join('');
panelEl.innerHTML =
'<div class="pa">'+esc(n.address)+'</div>'+
'<div class="pk">roles</div><div class="pv">'+esc((n.roles||[]).join(', ')||'—')+(n.fresh?' · fresh wallet':'')+'</div>'+
(n.label?'<div class="pk">label</div><div class="pv">'+esc(n.label)+'</div>':'')+
'<div class="pk">holds now</div><div class="pv">'+(n.pct??0)+'%</div>'+
(n.bought_pct!=null?'<div class="pk">bought at launch</div><div class="pv">'+n.bought_pct+'%</div>':'')+
(n.still_holds_pct!=null?'<div class="pk">still holds</div><div class="pv">'+n.still_holds_pct+'%</div>':'')+
(n.first_funder?'<div class="pk">first funded by</div><div class="pv" style="word-break:break-all">'+esc(n.first_funder)+'</div>':'')+
(n.cluster?'<div class="pk">cluster</div><div class="pv">#'+n.cluster+'</div>':'')+
(deg.length?'<div class="pk">edges ('+deg.length+')</div>'+rows:'')+
'<div class="pk"><a href="'+EXPLORER+encodeURIComponent(n.address)+'" target="_blank" rel="noopener">view on explorer ↗</a></div>';
}
requestAnimationFrame(function loop(){
resize();
let frames=0;
(function step(){ tick(); draw(); if (++frames<240 || alpha>0.04) requestAnimationFrame(step); })();
});
return root;
}
async function scan() {
const feed = document.getElementById('feed');
if (typeof __AUDIT !== 'undefined' && window.parent !== window) {
window.parent.postMessage({ rokha: 'app_action', action: 'scan', input: 'scan' }, '*');
feed.innerHTML='<div class="empty">⧗ Sent to Rokha — scanning new launches for real; this panel refreshes with the result.</div>';
return;
}
feed.innerHTML = '<h2><span class="spinner"></span> Loading recent launches…</h2>';
try {
const r = await fetch('/api/scan').then(x=>x.json());
const launches = (r && r.launches) || [];
const rows = launches.slice(0,25).map(l =>
'<div class="lrow" data-a="'+l.mint+'"><span><b>$'+esc(l.symbol||'?')+'</b> <span class="a">'+l.mint.slice(0,12)+'…</span></span>'+
'<span>'+(l.launchpad?esc(l.launchpad)+' · ':'')+'holders '+(l.holderCount??'—')+' · mcap '+usd(l.mcapUsd)+'</span></div>').join('');
feed.innerHTML = '<h2>Fresh launches ('+launches.length+')</h2>'+(rows||'<div class="empty">none</div>');
feed.querySelectorAll('.lrow').forEach(row => row.onclick=()=>{ const a=row.dataset.a; document.getElementById('addr').value=a; audit(a); window.scrollTo({top:0,behavior:'smooth'}); });
} catch(e) { feed.innerHTML='<h2>Error: '+esc(e.message)+'</h2>'; }
}
async function loadReports() {
const box = document.getElementById('reports');
try {
const list = await fetch('/api/reports').then(x=>x.json());
if (!Array.isArray(list) || !list.length) { box.innerHTML=''; return; }
const rows = list.slice(0,20).map(r =>
'<div class="lrow" data-a="'+r.address+'"><span><span style="color:'+(VCOLOR[r.verdict]||'var(--muted)')+';font-weight:700">'+r.score+'</span> · $'+esc(r.symbol||'?')+' <span class="a">'+r.address.slice(0,12)+'…</span></span><span>'+esc((r.verdict||'').replace("_"," "))+' · '+esc((r.scannedAt||'').slice(0,16).replace("T"," "))+'</span></div>').join('');
box.innerHTML = '<h2>Saved audit reports ('+list.length+')</h2>'+rows;
box.querySelectorAll('.lrow').forEach(row => row.onclick=()=>{ const a=row.dataset.a; document.getElementById('addr').value=a; audit(a); window.scrollTo({top:0,behavior:'smooth'}); });
} catch(e) { box.innerHTML=''; }
}
document.getElementById('go').onclick = () => { const a=document.getElementById('addr').value.trim(); if(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(a)) audit(a); };
document.getElementById('addr').addEventListener('keydown', e => { if(e.key==='Enter') document.getElementById('go').click(); });
document.getElementById('scan').onclick = scan;
const _audit = audit;
audit = async (a) => { await _audit(a); loadReports(); };
loadReports();
</script>
</body>
</html>