Best-runners / index.html
Julien355's picture
Upload 2 files
19f49c0 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sol Wallet Monitor</title>
<style>
:root{--bg:#0b0b0e;--panel:#14141a;--muted:#8b8b98;--text:#ffffff;--green:#2dd4bf;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa}
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:14px/1.4 system-ui,Segoe UI,Roboto,Helvetica,Arial}
.wrap{max-width:1200px;margin:0 auto;padding:20px}
.row{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end}
.card{background:var(--panel);border:1px solid #1f1f29;border-radius:12px;padding:14px}
.title{font-size:20px;font-weight:700;margin:0 0 6px}
label{font-size:12px;color:var(--muted);display:block;margin-bottom:6px}
input,select,button{height:36px;border-radius:8px;border:1px solid #2a2a35;background:#0f0f14;color:var(--text);padding:0 10px}
input[type="number"]{width:120px}
.btn{cursor:pointer} .btn.primary{background:#1f2937;border-color:#334155}
.btn.success{background:#064e3b;border-color:#115e59} .btn.warn{background:#4a2f00;border-color:#7c4a00}
.status{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;font-size:12px}
.s-disconnected{background:#2a2a35;color:#cbd5e1} .s-connected{background:#063f39;color:#34d399} .s-error{background:#3f1e1e;color:#fca5a5}
table{width:100%;border-collapse:collapse} th,td{padding:8px 10px;border-bottom:1px solid #23232f}
th{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;text-align:left}
.tag{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
.buy{background:rgba(45,212,191,.15);color:var(--green)} .sell{background:rgba(248,113,113,.15);color:var(--red)}
.muted{color:var(--muted)} .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.grow{flex:1} .right{margin-left:auto}
.pill{padding:4px 8px;border-radius:8px;background:#0f0f14;border:1px solid #2a2a35}
.small{font-size:12px}
</style>
</head>
<body>
<div class="wrap">
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px">
<div class="title">SOL Wallet Monitor</div>
<div id="statusBadge" class="status s-disconnected">Disconnected</div>
</div>
<div id="diagLine" class="small muted"></div>
<!-- Controls -->
<div class="card">
<div class="row">
<div>
<label>Solscan API Key (free plan)</label>
<input id="apiKey" placeholder="token: &lt;YOUR_API_KEY&gt;" style="width:320px" />
</div>
<!-- API URL -->
<div>
<label>Solscan Base URL</label>
<select id="baseUrl" style="width:220px">
<option value="https://pro-api.solscan.io">pro-api.solscan.io</option>
<option value="https://api.solscan.io">api.solscan.io</option>
</select>
</div>
<!-- API TEST -->
<div>
<label>Diagnostics</label>
<button id="diagBtn" class="btn">Network Test</button>
</div>
<div>
<label>Scan interval (seconds)</label>
<input id="scanInterval" type="number" min="10" value="60" />
</div>
<div>
<label>Sound alarm</label>
<select id="soundToggle">
<option value="on" selected>On</option>
<option value="off">Off</option>
</select>
</div>
<div class="right">
<button id="startBtn" class="btn success">Start</button>
<button id="stopBtn" class="btn warn" style="margin-left:8px">Stop</button>
</div>
</div>
<div class="row" style="margin-top:10px">
<div>
<label>Upload CSV of wallet addresses (one per line)</label>
<input id="csvFile" type="file" accept=".csv" />
</div>
<div>
<label>Wallets loaded</label>
<div id="walletCount" class="pill">0</div>
</div>
<div>
<label>Export matched events</label>
<button id="downloadCsvBtn" class="btn">Download CSV</button>
</div>
</div>
</div>
<!-- Filters -->
<div class="card" style="margin-top:12px">
<div class="row">
<div>
<label>Min new BUY order (SOL)</label>
<input id="minBuySol" type="number" step="0.01" value="1" />
</div>
<div>
<label>Min new SELL order (SOL)</label>
<input id="minSellSol" type="number" step="0.01" value="1" />
</div>
<div>
<label>Market cap min (USD)</label>
<input id="mcMin" type="number" step="1" value="0" />
</div>
<div>
<label>Market cap max (USD)</label>
<input id="mcMax" type="number" step="1" value="0" />
</div>
<div>
<label># wallets buying same coin (min)</label>
<input id="multiWalletMin" type="number" step="1" value="0" />
</div>
<div>
<label>Timeframe for same-coin buys (minutes)</label>
<input id="multiWalletWindow" type="number" step="1" value="0" />
</div>
</div>
<div class="small muted" style="margin-top:6px">Tip: market cap filtering requires token market data. If unavailable for a token, the filter is skipped unless min/max are set &gt; 0.</div>
</div>
<!-- Results table -->
<div class="card" style="margin-top:12px">
<div class="row" style="justify-content:space-between;align-items:center">
<div class="small muted">Transactions that meet your filters</div>
<div class="small muted">Now: <span id="now"></span> · Scans: <span id="scanCount">0</span></div>
</div>
<div style="overflow:auto;max-height:60vh;margin-top:8px">
<table>
<thead>
<tr>
<th>Time</th>
<th>Wallet</th>
<th>Action</th>
<th>Token</th>
<th>Mint</th>
<th>Volume</th>
<th>Market Cap</th>
<th>Tx</th>
</tr>
</thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
</div>
</div>
<script>
// =============== Storage & State ===============
const LS_ADDRS = 'solmon.addrs.v1';
const LS_APIKEY = 'solmon.apikey.v1';
const LS_LASTSIG = 'solmon.lastsig.v1';
let WALLET_ADDRESSES = loadAddrs();
let LAST_SIG = loadJSON(LS_LASTSIG, {}); // {address: lastSeenSignature}
let timerId = null;
let scanCounter = 0;
const multiWindowMap = new Map(); // tokenMint -> [{wallet, ts}]
const matchedEvents = []; // rows saved for CSV export
// =============== DOM helpers ===============
const $ = (id)=>document.getElementById(id);
const statusBadge = $('statusBadge');
$('walletCount').textContent = WALLET_ADDRESSES.length;
$('apiKey').value = localStorage.getItem(LS_APIKEY)||'';
$('now').textContent = new Date().toLocaleString();
setInterval(()=> $('now').textContent = new Date().toLocaleString(), 1000);
$('csvFile').addEventListener('change', onCsvUpload);
$('downloadCsvBtn').addEventListener('click', downloadCsv);
$('startBtn').addEventListener('click', start);
$('stopBtn').addEventListener('click', stop);
$('apiKey').addEventListener('change', e=> localStorage.setItem(LS_APIKEY, e.target.value.trim()));
// ---- Base URL + Diagnostics wiring ----
const LS_BASE = 'solmon.baseurl.v1';
const baseUrlSel = document.getElementById('baseUrl');
const diagLine = document.getElementById('diagLine');
baseUrlSel.value = localStorage.getItem(LS_BASE) || 'https://pro-api.solscan.io';
baseUrlSel.addEventListener('change', () => localStorage.setItem(LS_BASE, baseUrlSel.value));
document.getElementById('diagBtn').addEventListener('click', networkTest);
function note(msg){ if (diagLine) diagLine.textContent = msg; }
// -------- Public ping (no key) ----------
async function pingPublic(){
try{
const r = await fetch('https://public-api.solscan.io/chaininfo', { cache: 'no-store' });
return r.ok;
}catch{ return false; }
}
// -------- Auth check against selected base URL ----------
async function testProKey(apiKey, address){
const base = localStorage.getItem('solmon.baseurl.v1') || (document.getElementById('baseUrl')?.value || 'https://pro-api.solscan.io');
try{
const u = new URL('/v2.0/account/transactions', base);
u.searchParams.set('address', address);
u.searchParams.set('limit', '1');
const r = await fetch(u.toString(), { headers:{ accept:'application/json', token: apiKey } });
const body = await safeJson(r);
if (typeof note === 'function') note(`Auth check: ${r.status} ${r.statusText} @ ${base}${shorten(JSON.stringify(body))}`);
if (r.status === 401) return { ok:false, kind:'auth' };
if (r.status === 403) return { ok:false, kind:'forbidden' };
if (r.status === 429) return { ok:false, kind:'rate' };
if (!r.ok) return { ok:false, kind:'http' };
return { ok:true, kind:'ok' };
}catch(err){
// In browsers, CORS shows up as a TypeError
if (typeof note === 'function') note(`Auth check failed: ${String(err)}`);
return { ok:false, kind:'cors' };
}
}
// -------- List + detail using chosen base URL ----------
async function fetchTxList(address, apiKey){
const base = localStorage.getItem('solmon.baseurl.v1') || (document.getElementById('baseUrl')?.value || 'https://pro-api.solscan.io');
const u = new URL('/v2.0/account/transactions', base);
u.searchParams.set('address', address);
u.searchParams.set('limit', '5');
const r = await fetch(u.toString(), { headers:{ accept:'application/json', token: apiKey } });
if (r.status === 429) throw new Error('rate-limit');
if (!r.ok) throw new Error('txlist ' + r.status);
const j = await r.json();
return Array.isArray(j?.data) ? j.data : [];
}
async function fetchTxDetail(signature, apiKey){
const base = localStorage.getItem('solmon.baseurl.v1') || (document.getElementById('baseUrl')?.value || 'https://pro-api.solscan.io');
const u = new URL('/v2.0/transaction', base);
u.searchParams.set('tx', signature);
const r = await fetch(u.toString(), { headers:{ accept:'application/json', token: apiKey } });
if (r.status === 429) throw new Error('rate-limit');
if (!r.ok) throw new Error('txdetail ' + r.status);
const j = await r.json();
return j?.data || j;
}
// -------- Initialize badge with precise messages ----------
async function initializeStatus(){
setStatus('disconnected');
const ping = await pingPublic();
if(!ping){ setStatus('disconnected','Public endpoint not reachable'); return false; }
const key = $('apiKey').value.trim();
if(!key){ setStatus('disconnected','Enter API key'); return false; }
const probe = WALLET_ADDRESSES[0] || '11111111111111111111111111111111';
const res = await testProKey(key, probe);
if (res.ok) { setStatus('connected','OK'); return true; }
if (res.kind==='rate') { setStatus('rate','HTTP 429: rate limit'); return false; }
if (res.kind==='auth') { setStatus('error','HTTP 401: invalid key'); return false; }
if (res.kind==='forbidden'){ setStatus('error','HTTP 403: plan/origin not allowed'); return false; }
if (res.kind==='cors') { setStatus('error','CORS blocked — use a small proxy'); return false; }
setStatus('error','HTTP error during auth'); return false;
}
// -------- Diagnostics button ----------
async function networkTest(){
const key = $('apiKey').value.trim();
const base = localStorage.getItem('solmon.baseurl.v1') || (document.getElementById('baseUrl')?.value || 'https://pro-api.solscan.io');
if (typeof note === 'function') note('Testing network…');
try{
const u = new URL('/v2.0/account/transactions', base);
u.searchParams.set('address', WALLET_ADDRESSES[0] || '11111111111111111111111111111111');
u.searchParams.set('limit','1');
const r = await fetch(u.toString(), { headers:{ accept:'application/json', token: key } });
const body = await safeJson(r);
setStatus(r.ok ? 'connected' : (r.status===429 ? 'rate' : 'error'),
`Test: ${r.status} ${r.statusText} @ ${base}${shorten(JSON.stringify(body))}`);
}catch(err){
setStatus('error', 'Diagnostics: ' + String(err));
}
}
// Helpers for diagnostics text
async function safeJson(r){ try{ return await r.clone().json(); }catch{ return await r.text(); } }
function shorten(s){ return (s && s.length>240) ? (s.slice(0,240)+'…') : s; }
// =============== CSV Upload ===============
async function onCsvUpload(e){
const f = e.target.files && e.target.files[0];
if(!f) return;
const text = await f.text();
const addrs = extractAddressesFromCSV(text); // NEW
if (addrs.length === 0) {
alert("No valid Solana addresses found in the CSV.\nTip: ensure the 'Wallet Address' column contains base58 addresses.");
return;
}
// Cap at 200 addresses as requested
const capped = addrs.slice(0, 200);
WALLET_ADDRESSES = capped;
saveAddrs(capped);
$("walletCount").textContent = capped.length;
alert(`Loaded ${capped.length} wallet addresses${addrs.length > 200 ? " (trimmed to 200)" : ""}.`);
}
// --- Robust CSV address extraction that tolerates headers/quotes/delimiters ---
function extractAddressesFromCSV(csvText){
const src = String(csvText).replace(/\r\n?/g, "\n");
// Fast path: one-per-line without delimiters
if(!src.includes(",") && !src.includes(";") && !src.includes("\t")){
return Array.from(new Set(src.split("\n").map(cleanCell).filter(isBase58)));
}
// Minimal CSV parser (quote-aware), supports comma, semicolon or tab
const rows = [];
let i=0, field="", row=[], inQuotes=false;
const pushField=()=>{ row.push(field); field=""; };
const pushRow=()=>{ rows.push(row); row=[]; };
const isDelim = c => c === "," || c === ";" || c === "\t";
while(i < src.length){
const ch = src[i];
if(inQuotes){
if(ch === '"' && src[i+1] === '"'){ field += '"'; i+=2; continue; } // escaped quote
if(ch === '"'){ inQuotes=false; i++; continue; }
field += ch; i++; continue;
} else {
if(ch === '"'){ inQuotes=true; i++; continue; }
if(ch === "\n"){ pushField(); pushRow(); i++; continue; }
if(isDelim(ch)){ pushField(); i++; continue; }
field += ch; i++; continue;
}
}
// flush last field/row
pushField(); pushRow();
// Collect any cell that validates as a base58 address
const out = new Set();
for(const r of rows){
for(const c of r){
const v = cleanCell(c);
if(isBase58(v)) out.add(v);
}
}
return Array.from(out);
}
// Clean typical Excel/CSV artifacts
function cleanCell(v){
if(v==null) return "";
let s = String(v).trim();
// Excel sometimes exports ="ADDRESS" — unwrap
if(/^=\s*".*"$/.test(s)) s = s.replace(/^=\s*"|"$/g, "");
// Trim surrounding quotes
if(/^".*"$/.test(s)) s = s.replace(/^"|"$/g, "");
// Strip zero-width characters
s = s.replace(/[\u200B-\u200D\uFEFF]/g, "");
return s;
}
function isBase58(s){ return /^[1-9A-HJ-NP-Za-km-z]{26,60}$/.test(s||''); }
function saveAddrs(a){ localStorage.setItem(LS_ADDRS, JSON.stringify(a)); }
function loadAddrs(){ return loadJSON(LS_ADDRS, []); }
function loadJSON(k, d){ try{ const j=JSON.parse(localStorage.getItem(k)||'null'); return j??d; }catch{return d;} }
// =============== Status badge ===============
function setStatus(kind, msg){
if(kind==='connected'){ statusBadge.className='status s-connected'; statusBadge.textContent='Connected'; }
else if(kind==='rate'){ statusBadge.className='status s-error'; statusBadge.textContent='Rate Limited'; }
else if(kind==='error'){ statusBadge.className='status s-error'; statusBadge.textContent='Error'; }
else { statusBadge.className='status s-disconnected'; statusBadge.textContent='Disconnected'; }
if (msg) note(msg);
}
// =============== Audio alarm ===============
function beep(){ if($('soundToggle').value==='off') return; try{ const ctx=new (window.AudioContext||window.webkitAudioContext)(); const o=ctx.createOscillator(); const g=ctx.createGain(); o.type='sine'; o.frequency.value=880; o.connect(g); g.connect(ctx.destination); g.gain.setValueAtTime(0.001, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime+0.01); o.start(); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+0.25); o.stop(ctx.currentTime+0.26); }catch(e){} }
// =============== Networking (Solscan) ===============
async function pingPublic(){
try{ const r=await fetch('https://public-api.solscan.io/chaininfo'); return r.ok; }catch{ return false; }
}
async function testProKey(apiKey, address){
try{
const u=new URL('https://pro-api.solscan.io/v2.0/account/transactions');
u.searchParams.set('address', address); u.searchParams.set('limit','1');
const r=await fetch(u, {headers:{accept:'application/json', token: apiKey}});
if(r.status===401) return 'auth';
return r.ok? 'ok':'fail';
}catch{ return 'fail'; }
}
// Fetch tx list for address (returns array) — free plan: keep limits small
async function fetchTxList(address, apiKey){
const u=new URL('https://pro-api.solscan.io/v2.0/account/transactions');
u.searchParams.set('address', address); u.searchParams.set('limit','5');
const r = await fetch(u, {headers:{accept:'application/json', token: apiKey}});
if(!r.ok) throw new Error('txlist '+r.status);
const j=await r.json();
return Array.isArray(j?.data)? j.data : [];
}
// Fetch transaction detail by signature (best-effort; structure may vary)
async function fetchTxDetail(signature, apiKey){
const u = new URL('https://pro-api.solscan.io/v2.0/transaction');
u.searchParams.set('tx', signature);
const r = await fetch(u, {headers:{accept:'application/json', token: apiKey}});
if(!r.ok) throw new Error('txdetail '+r.status);
const j=await r.json();
return j?.data || j; // normalize
}
// Try to derive wallet net SOL delta and token transfers
function parseForWalletEvents(wallet, detail){
const events=[]; // {action, tokenSymbol, tokenMint, volumeSol, time, signature, wallet}
const sig = detail?.txHash || detail?.txhash || detail?.signature || '';
const blockTime = detail?.blockTime || detail?.block_time || detail?.blockTimeUnix || Date.now()/1000;
// compute net SOL change for wallet from balance changes if present
let solDelta = 0;
const bal = detail?.balanceChanges || detail?.balances || [];
try{
// expect objects like {address, change: {solChange, tokenChanges:[]}} or similar
for(const b of bal){
const addr = b.address || b.owner || b.account;
if(addr===wallet){
const lam = b?.change?.solChangeLamports ?? b?.solChangeLamports ?? (b?.solChange? Math.round(b.solChange*1e9):0);
if(Number.isFinite(lam)) solDelta += lam/1e9; // convert to SOL
}
}
}catch{}
// token transfers list (best-effort across possible shapes)
const transfers = detail?.tokenTransfers || detail?.parsedInstructions || detail?.token_transfers || [];
const addEvent = (action, sym, mint, volSOL)=>{
events.push({action, tokenSymbol: sym||'-', tokenMint: mint||'-', volumeSol: (typeof volSOL==='number'? volSOL:null), time: new Date(blockTime*1000).toISOString(), signature: sig, wallet});
};
// Heuristic: if wallet net SOL outflow (<0) and it received a token => BUY
// If net SOL inflow (>0) and it sent a token => SELL
let gotToken=false, sentToken=false, tokenSym='-', tokenMint='-';
try{
for(const t of transfers){
const src = t.src || t.source || t.from || t.owner || t.sender;
const dst = t.dst || t.destination || t.to || t.recipient;
const mint = t.tokenAddress || t.mint || t.token || t.tokenAddressHex || t.token_address;
const sym = t.tokenSymbol || t.symbol || t.token_symbol || t.ticker || '-';
if(dst===wallet){ gotToken=true; tokenSym=sym; tokenMint=mint; }
if(src===wallet){ sentToken=true; tokenSym=sym; tokenMint=mint; }
}
}catch{}
if(gotToken && solDelta<0){ addEvent('BUY', tokenSym, tokenMint, Math.abs(solDelta)); }
if(sentToken && solDelta>0){ addEvent('SELL', tokenSym, tokenMint, Math.abs(solDelta)); }
// Fallback: if no SOL delta parsed, still emit token-only events with null volume
if(events.length===0){
if(gotToken) addEvent('BUY', tokenSym, tokenMint, null);
if(sentToken) addEvent('SELL', tokenSym, tokenMint, null);
}
return events;
}
// Optional market cap lookup (best-effort, may not be available for all mints)
async function fetchMarketCapUSD(mint){
if(!mint || mint==='-') return null;
try{
const u=new URL('https://pro-api.solscan.io/v2.0/token/market');
u.searchParams.set('token', mint);
const r=await fetch(u, {headers:{accept:'application/json', token: localStorage.getItem(LS_APIKEY)||''}});
if(!r.ok) return null;
const j=await r.json();
const mc = j?.data?.market_cap || j?.data?.marketCap || j?.data?.fdv || null;
return typeof mc==='number'? mc : (typeof mc==='string'? Number(mc): null);
}catch{ return null; }
}
// =============== Scanner ===============
async function start(){
if(timerId){ clearInterval(timerId); timerId=null; }
const ok = await initializeStatus();
if(!ok){ alert('Solscan unreachable or API key invalid. You can still try Start, but requests may fail.'); }
const iv = Math.max(10, Number($('scanInterval').value||60));
timerId = setInterval(scanTick, iv*1000);
scanTick();
}
function stop(){ if(timerId){ clearInterval(timerId); timerId=null; setStatus('disconnected'); } }
async function initializeStatus(){
setStatus('disconnected');
const ping = await pingPublic();
if(!ping){ setStatus('disconnected'); return false; }
const key = $('apiKey').value.trim();
if(!key){ setStatus('disconnected'); return false; }
const probe = WALLET_ADDRESSES[0] || '11111111111111111111111111111111';
const t = await testProKey(key, probe);
if(t==='ok'){ setStatus('connected'); return true; }
if(t==='auth'){ setStatus('error'); return false; }
setStatus('error'); return false;
}
async function scanTick(){
scanCounter++; $('scanCount').textContent = String(scanCounter);
const key = $('apiKey').value.trim(); if(!key) return;
const minBuy = Number($('minBuySol').value||1);
const minSell = Number($('minSellSol').value||1);
const mcMin = Number($('mcMin').value||0);
const mcMax = Number($('mcMax').value||0);
const mwMin = Number($('multiWalletMin').value||0);
const mwWin = Number($('multiWalletWindow').value||0) * 60 * 1000; // ms
const body = $('resultsBody');
// Process wallets with small concurrency to respect free rate limits
const maxConcurrent = 4; let idx=0;
async function worker(){
while(idx < WALLET_ADDRESSES.length){
const addr = WALLET_ADDRESSES[idx++];
try{
const list = await fetchTxList(addr, key);
// Only new signatures since last scan
const last = LAST_SIG[addr];
let newer = list;
if(last){ const i = list.findIndex(t=> (t.txHash||t.tx_hash||t.signature) === last); if(i>0) newer = list.slice(0,i); }
if(list[0]) LAST_SIG[addr] = list[0].txHash||list[0].tx_hash||list[0].signature||list[0].hash||list[0].sign; // update pointer
for(const tx of newer){
const sig = tx.txHash||tx.tx_hash||tx.signature||tx.hash||tx.sign;
try{
const detail = await fetchTxDetail(sig, key);
const evs = parseForWalletEvents(addr, detail);
for(const ev of evs){
// market cap filter (optional)
let mc = null;
if((mcMin>0 || mcMax>0) && ev.tokenMint && ev.tokenMint!=='-'){
mc = await fetchMarketCapUSD(ev.tokenMint);
if(mcMin>0 && (mc==null || mc<mcMin)) continue;
if(mcMax>0 && (mc==null || mc>mcMax)) continue;
}
// order-size filter (SOL approximate)
if(ev.action==='BUY' && ev.volumeSol!=null && ev.volumeSol < minBuy) continue;
if(ev.action==='SELL' && ev.volumeSol!=null && ev.volumeSol < minSell) continue;
// multi-wallet within window
let passesMW=true;
if(mwMin>0 && mwWin>0 && ev.action==='BUY' && ev.tokenMint && ev.tokenMint!=='-'){
const now = Date.now();
const arr = multiWindowMap.get(ev.tokenMint) || [];
// keep only recent
const filtered = arr.filter(x=> now - x.ts <= mwWin);
filtered.push({wallet: ev.wallet, ts: now});
multiWindowMap.set(ev.tokenMint, filtered);
const distinct = new Set(filtered.map(x=>x.wallet));
passesMW = distinct.size >= mwMin;
}
if(!passesMW) continue;
// Render row
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="mono">${new Date(ev.time).toLocaleString()}</td>
<td class="mono">${ev.wallet.slice(0,6)}${ev.wallet.slice(-4)}</td>
<td>${ev.action==='BUY'? '<span class="tag buy">BUY</span>':'<span class="tag sell">SELL</span>'}</td>
<td>${ev.tokenSymbol||'-'}</td>
<td class="mono">${ev.tokenMint? ev.tokenMint.slice(0,6)+'…'+ev.tokenMint.slice(-4):'-'}</td>
<td>${ev.volumeSol!=null? ev.volumeSol.toFixed(4)+' SOL':'—'}</td>
<td>${typeof mc==='number'? ('$'+mc.toLocaleString()):'—'}</td>
<td class="mono"><a href="https://solscan.io/tx/${sig}" target="_blank">${String(sig).slice(0,8)}…</a></td>`;
body.prepend(tr);
// Save for CSV export
matchedEvents.push({time: new Date(ev.time).toLocaleString(), wallet: ev.wallet, action: ev.action, token: ev.tokenSymbol, mint: ev.tokenMint||'', volume_sol: ev.volumeSol, market_cap_usd: mc, signature: sig});
// Alarm
beep();
}
}catch(e){ /* ignore one tx failure */ }
}
}catch(e){ /* ignore one wallet failure */ }
}
}
await Promise.all(Array.from({length:Math.min(maxConcurrent, WALLET_ADDRESSES.length)}, ()=>worker()));
// persist last signatures
localStorage.setItem(LS_LASTSIG, JSON.stringify(LAST_SIG));
}
// =============== CSV Download ===============
function downloadCsv(){
if(matchedEvents.length===0){ alert('No matched events yet.'); return; }
const cols = ['time','wallet','action','token','mint','volume_sol','market_cap_usd','signature'];
const lines = [cols.join(',')].concat(
matchedEvents.map(r=> cols.map(c=> JSON.stringify(r[c]??'')).join(','))
);
const blob = new Blob([lines.join('\n')], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href=url; a.download='sol_wallet_monitor_matches.csv'; a.click();
URL.revokeObjectURL(url);
}
</script>
</body>
</html>