Spaces:
No application file
No application file
| <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: <YOUR_API_KEY>" 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 > 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> | |