/* ─── Wallet X-Ray — Solana Wallet Analyser ───────────────── Full client-side scan: SOL balance, SPL tokens, NFTs, transactions, and wallet scoring. ─────────────────────────────────────────────────────────── */ (function () { 'use strict'; // ── Config ── const RPC_ENDPOINTS = [ 'https://api.mainnet-beta.solana.com', 'https://solana-rpc.publicnode.com', 'https://solana-mainnet.g.alchemy.com/v2/demo', 'https://mainnet.helius-rpc.com/?api-key=7a3f0b8e-9c2d-4a5b-8f1e-2c6d8b9a4e7f' ]; const JUP_PRICE = 'https://price.jup.ag/v6/price'; const JUP_TOKEN_LIST = 'https://token.jup.ag/all'; const SOLSCAN_TX = 'https://solscan.io/tx/'; const SOLSCAN_ACCT = 'https://solscan.io/account/'; const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; const TOKEN_2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; const SOL_MINT = 'So11111111111111111111111111111111111111112'; const LAMPORTS = 1e9; // ── DOM refs ── const $addr = document.getElementById('wx-address'); const $scanBtn = document.getElementById('wx-scan-btn'); const $walBtn = document.getElementById('wx-wallet-btn'); const $error = document.getElementById('wx-error'); const $loading = document.getElementById('wx-loading'); const $loadTxt = document.getElementById('wx-loading-text'); const $loadSub = document.getElementById('wx-loading-sub'); const $results = document.getElementById('wx-results'); // ── State ── let tokenListCache = null; let tokenMapCache = null; // mint → {name, symbol, logoURI, decimals} let activeRpc = RPC_ENDPOINTS[0]; let rpcIndex = 0; let scanning = false; // ── Helpers ── function truncAddr(a) { if (!a || a.length < 12) return a || ''; return a.slice(0, 4) + '...' + a.slice(-4); } function fmtUsd(n) { if (n == null || isNaN(n)) return '$—'; if (n >= 1e6) return '$' + (n / 1e6).toFixed(2) + 'M'; if (n >= 1e3) return '$' + (n / 1e3).toFixed(2) + 'K'; return '$' + n.toFixed(2); } function fmtSol(n) { if (n == null || isNaN(n)) return '—'; return n.toFixed(4) + ' SOL'; } function fmtNum(n) { if (n == null || isNaN(n)) return '—'; return n.toLocaleString('en-US', { maximumFractionDigits: 4 }); } function fmtDate(ts) { if (!ts) return '—'; const d = new Date(ts * 1000); return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); } function fmtTime(ts) { if (!ts) return '—'; const d = new Date(ts * 1000); return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); } function daysBetween(ts1, ts2) { return Math.floor(Math.abs(ts2 - ts1) / 86400); } function isValidSolAddress(addr) { return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr); } function showError(msg) { $error.textContent = '✖ ' + msg; $error.classList.add('active'); } function hideError() { $error.classList.remove('active'); $error.textContent = ''; } function setLoading(show, text, sub) { if (show) { $loading.classList.add('active'); $results.classList.remove('active'); if (text) $loadTxt.textContent = text; if (sub) $loadSub.textContent = sub; } else { $loading.classList.remove('active'); } } function sectionLoading(id, show) { const el = document.getElementById(id); if (el) el.style.display = show ? 'flex' : 'none'; } // ── RPC Call ── async function rpc(method, params, retries = 4) { let lastError; for (let i = 0; i <= retries; i++) { const url = RPC_ENDPOINTS[(rpcIndex + i) % RPC_ENDPOINTS.length]; try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); if (res.status === 429 || res.status === 403) { lastError = new Error('Rate limited (HTTP ' + res.status + ')'); await sleep(800 * (i + 1)); continue; } if (!res.ok) { lastError = new Error('HTTP ' + res.status); continue; } const json = await res.json(); if (json.error) { if (json.error.code === -32429 || (json.error.message && json.error.message.includes('rate'))) { lastError = new Error(json.error.message); await sleep(800 * (i + 1)); continue; } throw new Error(json.error.message || 'RPC error'); } // Success — remember this working RPC rpcIndex = (rpcIndex + i) % RPC_ENDPOINTS.length; activeRpc = RPC_ENDPOINTS[rpcIndex]; return json.result; } catch (e) { lastError = e; if (i < retries) await sleep(500); } } throw lastError || new Error('All RPC endpoints failed'); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // ── Token List ── async function loadTokenList() { if (tokenMapCache) return tokenMapCache; // Try sessionStorage first try { const cached = sessionStorage.getItem('wx_token_list'); if (cached) { const parsed = JSON.parse(cached); if (parsed._ts && Date.now() - parsed._ts < 3600000) { tokenListCache = parsed.list; tokenMapCache = buildTokenMap(tokenListCache); return tokenMapCache; } } } catch (e) { /* ignore */ } try { const res = await fetch(JUP_TOKEN_LIST); tokenListCache = await res.json(); tokenMapCache = buildTokenMap(tokenListCache); try { sessionStorage.setItem('wx_token_list', JSON.stringify({ _ts: Date.now(), list: tokenListCache.slice(0, 5000) // Store top tokens only })); } catch (e) { /* storage full */ } return tokenMapCache; } catch (e) { console.warn('[WX] Failed to load token list:', e); tokenMapCache = new Map(); return tokenMapCache; } } function buildTokenMap(list) { const map = new Map(); if (!Array.isArray(list)) return map; for (const t of list) { if (t.address) { map.set(t.address, { name: t.name || 'Unknown', symbol: t.symbol || '???', logoURI: t.logoURI || null, decimals: t.decimals ?? 0 }); } } return map; } // ── Jupiter Prices ── async function fetchPrices(mints) { if (!mints || mints.length === 0) return {}; const prices = {}; // Batch in groups of 100 const batches = []; for (let i = 0; i < mints.length; i += 100) { batches.push(mints.slice(i, i + 100)); } for (const batch of batches) { try { const ids = batch.join(','); const res = await fetch(JUP_PRICE + '?ids=' + ids); const json = await res.json(); if (json.data) { for (const [mint, info] of Object.entries(json.data)) { prices[mint] = info.price || 0; } } } catch (e) { console.warn('[WX] Price fetch error:', e); } if (batches.length > 1) await sleep(300); } return prices; } // ══════════════════════════════════════════════════════════ // MAIN SCAN // ══════════════════════════════════════════════════════════ async function scan(address) { if (scanning) return; if (!isValidSolAddress(address)) { showError('Invalid Solana address. Check the format and try again.'); return; } scanning = true; hideError(); setLoading(true, 'SCANNING WALLET', 'Querying Solana mainnet...'); $scanBtn.disabled = true; // Reset result sections document.getElementById('wx-overview').innerHTML = ''; document.getElementById('wx-tokens-content').innerHTML = ''; document.getElementById('wx-nfts-content').innerHTML = ''; document.getElementById('wx-tx-content').innerHTML = ''; document.getElementById('wx-score-content').innerHTML = ''; document.getElementById('wx-wallet-short').textContent = truncAddr(address); document.getElementById('wx-token-count').textContent = ''; document.getElementById('wx-nft-count').textContent = ''; document.getElementById('wx-tx-count').textContent = ''; try { // Load token list in background const tokenMapPromise = loadTokenList(); // ── Step 1: SOL Balance ── setLoading(true, 'READING SOL BALANCE', 'getBalance...'); const balResult = await rpc('getBalance', [address, { commitment: 'confirmed' }]); const solBalance = (balResult?.value || 0) / LAMPORTS; // ── Step 2: Token Accounts ── setLoading(true, 'SCANNING TOKEN ACCOUNTS', 'getTokenAccountsByOwner...'); const tokenAccounts = await rpc('getTokenAccountsByOwner', [ address, { programId: TOKEN_PROGRAM }, { encoding: 'jsonParsed' } ]); // Also check Token-2022 let token2022Accounts = null; try { token2022Accounts = await rpc('getTokenAccountsByOwner', [ address, { programId: TOKEN_2022 }, { encoding: 'jsonParsed' } ]); } catch (e) { /* ignore */ } const allAccounts = [ ...(tokenAccounts?.value || []), ...(token2022Accounts?.value || []) ]; // Parse token accounts const tokens = []; const nftCandidates = []; for (const acct of allAccounts) { const info = acct.account?.data?.parsed?.info; if (!info) continue; const mint = info.mint; const amount = parseFloat(info.tokenAmount?.uiAmountString || '0'); const decimals = info.tokenAmount?.decimals || 0; if (amount === 0) continue; // Skip zero balances if (decimals === 0 && amount === 1) { nftCandidates.push({ mint, amount }); } else { tokens.push({ mint, amount, decimals }); } } // ── Step 3: Fetch Prices ── setLoading(true, 'FETCHING PRICES', 'Querying Jupiter Price API...'); const tokenMap = await tokenMapPromise; const priceMints = [SOL_MINT, ...tokens.map(t => t.mint)]; const prices = await fetchPrices(priceMints); const solPrice = prices[SOL_MINT] || 0; // Enrich tokens with metadata and prices const enrichedTokens = tokens.map(t => { const meta = tokenMap.get(t.mint) || { name: 'Unknown Token', symbol: '???', logoURI: null }; const price = prices[t.mint] || 0; const usdValue = t.amount * price; return { ...t, ...meta, price, usdValue }; }).sort((a, b) => b.usdValue - a.usdValue); // ── Step 4: Transactions ── setLoading(true, 'LOADING TRANSACTIONS', 'getSignaturesForAddress...'); let signatures = []; try { signatures = await rpc('getSignaturesForAddress', [ address, { limit: 20, commitment: 'confirmed' } ]) || []; } catch (e) { console.warn('[WX] Failed to fetch signatures:', e); } // ── Step 5: Calculate wallet age ── let oldestTx = null; let walletAge = 0; let totalTxCount = 0; try { // Get oldest signature for age const oldestSigs = await rpc('getSignaturesForAddress', [ address, { limit: 1, commitment: 'confirmed', before: undefined } ]); // Try to get a rough total count — fetch the last page // We'll use the first tx time and recent tx to estimate if (signatures.length > 0) { const newest = signatures[0]?.blockTime || 0; // Try to find oldest let lastSig = signatures[signatures.length - 1]?.signature; let oldest = signatures[signatures.length - 1]?.blockTime || 0; let fetchMore = true; let pages = 0; totalTxCount = signatures.length; while (fetchMore && pages < 50) { try { const more = await rpc('getSignaturesForAddress', [ address, { limit: 1000, before: lastSig, commitment: 'confirmed' } ]); if (!more || more.length === 0) { fetchMore = false; } else { totalTxCount += more.length; lastSig = more[more.length - 1].signature; oldest = more[more.length - 1].blockTime || oldest; if (more.length < 1000) fetchMore = false; } pages++; await sleep(200); } catch (e) { fetchMore = false; } } oldestTx = oldest; if (oldest) { walletAge = daysBetween(oldest, Date.now() / 1000); } } } catch (e) { console.warn('[WX] Error getting wallet age:', e); if (signatures.length > 0) { oldestTx = signatures[signatures.length - 1]?.blockTime; totalTxCount = signatures.length; if (oldestTx) walletAge = daysBetween(oldestTx, Date.now() / 1000); } } // ── RENDER EVERYTHING ── setLoading(false); $results.classList.add('active'); // Calculate portfolio total const solUsd = solBalance * solPrice; const tokenUsd = enrichedTokens.reduce((sum, t) => sum + t.usdValue, 0); const totalPortfolio = solUsd + tokenUsd; // Activity rating let activityRating = 'DORMANT'; let activityClass = 'dormant'; if (walletAge < 30) { activityRating = 'NEW'; activityClass = 'new'; } else if (totalTxCount > 500 && walletAge > 0 && (totalTxCount / walletAge) > 0.5) { activityRating = 'ACTIVE'; activityClass = 'active'; } else if (totalTxCount > 100) { activityRating = 'MODERATE'; activityClass = 'active'; } else if (totalTxCount < 10 && walletAge > 180) { activityRating = 'DEAD'; activityClass = 'dead'; } // ── Render Overview ── renderOverview({ solBalance, solPrice, solUsd, totalPortfolio, walletAge, oldestTx, totalTxCount, activityRating, activityClass, tokenCount: enrichedTokens.length, nftCount: nftCandidates.length }); // ── Render Tokens ── sectionLoading('wx-tokens-loading', false); renderTokens(enrichedTokens); // ── Render NFTs ── sectionLoading('wx-nfts-loading', false); renderNfts(nftCandidates, tokenMap); // ── Render Transactions ── sectionLoading('wx-tx-loading', false); renderTransactions(signatures, address); // ── Render Wallet Score ── sectionLoading('wx-score-loading', false); renderScore({ walletAge, totalTxCount, tokenCount: enrichedTokens.length, nftCount: nftCandidates.length, solBalance, totalPortfolio }); } catch (err) { setLoading(false); showError('Scan failed: ' + (err.message || 'Unknown error. The RPC may be rate-limited — try again in a few seconds.')); console.error('[WX] Scan error:', err); } finally { scanning = false; $scanBtn.disabled = false; } } // ══════════════════════════════════════════════════════════ // RENDERERS // ══════════════════════════════════════════════════════════ function renderOverview(d) { const container = document.getElementById('wx-overview'); container.innerHTML = `
SOL BALANCE
${fmtSol(d.solBalance)}
${fmtUsd(d.solUsd)} @ ${fmtUsd(d.solPrice)}/SOL
PORTFOLIO VALUE
${fmtUsd(d.totalPortfolio)}
${d.tokenCount} tokens + ${d.nftCount} NFTs
WALLET AGE
${d.walletAge > 0 ? d.walletAge + 'd' : '—'}
${d.oldestTx ? 'Since ' + fmtDate(d.oldestTx) : 'No transactions found'}
TOTAL TRANSACTIONS
${d.totalTxCount.toLocaleString()}
${d.walletAge > 0 ? (d.totalTxCount / d.walletAge).toFixed(1) + ' tx/day avg' : '—'}
ACTIVITY RATING
${d.activityRating}
`; } function renderTokens(tokens) { const container = document.getElementById('wx-tokens-content'); const countEl = document.getElementById('wx-token-count'); countEl.textContent = tokens.length + ' TOKEN' + (tokens.length !== 1 ? 'S' : ''); if (tokens.length === 0) { container.innerHTML = '
No SPL tokens found in this wallet.
'; return; } let html = `
`; for (const t of tokens) { const logo = t.logoURI ? `` : '
?
'; html += ` `; } html += '
TOKEN BALANCE PRICE VALUE MINT
${logo}
${escHtml(t.name)}
${escHtml(t.symbol)}
${fmtNum(t.amount)} ${t.price > 0 ? fmtUsd(t.price) : '—'} ${t.usdValue > 0 ? fmtUsd(t.usdValue) : '—'} ${truncAddr(t.mint)}
'; container.innerHTML = html; } function renderNfts(nfts, tokenMap) { const container = document.getElementById('wx-nfts-content'); const countEl = document.getElementById('wx-nft-count'); countEl.textContent = nfts.length + ' NFT' + (nfts.length !== 1 ? 'S' : ''); if (nfts.length === 0) { container.innerHTML = '
No NFTs detected in this wallet.
'; return; } let html = '
'; for (const nft of nfts) { const meta = tokenMap.get(nft.mint) || { name: truncAddr(nft.mint), symbol: 'NFT', logoURI: null }; const img = meta.logoURI ? `${escHtml(meta.name)}` : '
🖼
'; html += ` ${img}
${escHtml(meta.name)}
${escHtml(meta.symbol)}
`; } html += '
'; container.innerHTML = html; } function renderTransactions(sigs, address) { const container = document.getElementById('wx-tx-content'); const countEl = document.getElementById('wx-tx-count'); countEl.textContent = 'LAST ' + sigs.length; if (sigs.length === 0) { container.innerHTML = '
No recent transactions found.
'; return; } let html = `
SIGNATURE TYPE STATUS TIME
`; for (const sig of sigs) { const signature = sig.signature || ''; const truncSig = signature.slice(0, 8) + '...' + signature.slice(-4); const time = fmtTime(sig.blockTime); const err = sig.err; const status = err ? 'FAILED' : 'SUCCESS'; const statusClass = err ? 'wx-change-neg' : 'wx-change-pos'; // Classify based on memo and other signals let type = 'UNKNOWN'; let typeClass = 'unknown'; if (sig.memo) { type = 'MEMO'; typeClass = 'transfer'; } // We'll use a basic heuristic — without fetching full tx details // (to avoid rate limiting), classify by slot patterns if (!err) { type = 'TRANSACTION'; typeClass = 'transfer'; } html += `
${truncSig} ${type} ${status} ${time}
`; } html += '
'; container.innerHTML = html; } function renderScore(d) { const container = document.getElementById('wx-score-content'); // Calculate individual scores (0–100) const ageScore = Math.min(100, Math.floor((d.walletAge / 730) * 100)); // 2 years = 100 const activityScore = Math.min(100, Math.floor((d.totalTxCount / 1000) * 100)); // 1000 tx = 100 const diversityScore = Math.min(100, Math.floor(((d.tokenCount + d.nftCount) / 50) * 100)); // 50 assets = 100 const wealthScore = Math.min(100, Math.floor((d.totalPortfolio / 10000) * 100)); // $10k = 100 // Overall const overall = Math.round((ageScore * 0.25) + (activityScore * 0.30) + (diversityScore * 0.20) + (wealthScore * 0.25)); // Grade let grade, gradeClass; if (overall >= 85) { grade = 'A+'; gradeClass = 'wx-grade-a'; } else if (overall >= 70) { grade = 'A'; gradeClass = 'wx-grade-a'; } else if (overall >= 55) { grade = 'B'; gradeClass = 'wx-grade-b'; } else if (overall >= 40) { grade = 'C'; gradeClass = 'wx-grade-c'; } else if (overall >= 25) { grade = 'D'; gradeClass = 'wx-grade-d'; } else { grade = 'F'; gradeClass = 'wx-grade-f'; } function barColor(val) { if (val >= 70) return 'green'; if (val >= 45) return 'yellow'; if (val >= 25) return 'orange'; return 'red'; } container.innerHTML = `
${grade}
OVERALL ${overall}/100
AGE SCORE ${ageScore}/100
ACTIVITY SCORE ${activityScore}/100
DIVERSITY SCORE ${diversityScore}/100
PORTFOLIO SCORE ${wealthScore}/100
`; } // ── HTML escape ── function escHtml(str) { if (!str) return ''; const el = document.createElement('span'); el.textContent = str; return el.innerHTML; } // ══════════════════════════════════════════════════════════ // EVENT HANDLERS // ═════════════════════════════════���════════════════════════ // Scan button $scanBtn.addEventListener('click', () => { const addr = $addr.value.trim(); if (addr) scan(addr); else showError('Please enter a Solana wallet address or connect your wallet.'); }); // Enter key $addr.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); $scanBtn.click(); } }); // Connect wallet button $walBtn.addEventListener('click', async () => { if (!window.solWallet) { showError('No wallet detected. Install Phantom, Solflare, or another Solana wallet.'); return; } if (window.solWallet.connected && window.solWallet.address) { // Already connected — use address $addr.value = window.solWallet.address; scan(window.solWallet.address); return; } // Show wallet picker const available = window.solWallet.getAvailableWallets(); if (available.length === 0) { showError('No Solana wallet extensions detected. Install Phantom or Solflare.'); return; } // If only one wallet, connect directly if (available.length === 1) { try { const result = await window.solWallet.connect(available[0]); $addr.value = result.address; $walBtn.textContent = window.solWallet.truncAddr(result.address); $walBtn.classList.add('connected'); scan(result.address); } catch (err) { showError('Wallet connection failed: ' + (err.message || 'User rejected')); } return; } // Multiple wallets — connect first available (nav handles picker) try { const result = await window.solWallet.connect(available[0]); $addr.value = result.address; $walBtn.textContent = window.solWallet.truncAddr(result.address); $walBtn.classList.add('connected'); scan(result.address); } catch (err) { showError('Wallet connection failed: ' + (err.message || 'User rejected')); } }); // Listen for wallet connection from nav window.addEventListener('wallet-connected', (e) => { const addr = e.detail?.address; if (addr) { $addr.value = addr; $walBtn.textContent = truncAddr(addr); $walBtn.classList.add('connected'); } }); window.addEventListener('wallet-disconnected', () => { $walBtn.textContent = 'CONNECT WALLET'; $walBtn.classList.remove('connected'); }); // Auto-populate if wallet already connected if (window.solWallet?.connected && window.solWallet.address) { $addr.value = window.solWallet.address; $walBtn.textContent = truncAddr(window.solWallet.address); $walBtn.classList.add('connected'); } // Check URL params for address const urlParams = new URLSearchParams(window.location.search); const paramAddr = urlParams.get('address') || urlParams.get('wallet'); if (paramAddr && isValidSolAddress(paramAddr)) { $addr.value = paramAddr; // Auto-scan after slight delay setTimeout(() => scan(paramAddr), 500); } })();