jaeswift-website/js/walletxray.js

811 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ─── 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 = `
<div class="wx-stat-card">
<div class="wx-stat-label">SOL BALANCE</div>
<div class="wx-stat-value sol">${fmtSol(d.solBalance)}</div>
<div class="wx-stat-sub">${fmtUsd(d.solUsd)} @ ${fmtUsd(d.solPrice)}/SOL</div>
</div>
<div class="wx-stat-card">
<div class="wx-stat-label">PORTFOLIO VALUE</div>
<div class="wx-stat-value usd">${fmtUsd(d.totalPortfolio)}</div>
<div class="wx-stat-sub">${d.tokenCount} tokens + ${d.nftCount} NFTs</div>
</div>
<div class="wx-stat-card">
<div class="wx-stat-label">WALLET AGE</div>
<div class="wx-stat-value">${d.walletAge > 0 ? d.walletAge + 'd' : '—'}</div>
<div class="wx-stat-sub">${d.oldestTx ? 'Since ' + fmtDate(d.oldestTx) : 'No transactions found'}</div>
</div>
<div class="wx-stat-card">
<div class="wx-stat-label">TOTAL TRANSACTIONS</div>
<div class="wx-stat-value">${d.totalTxCount.toLocaleString()}</div>
<div class="wx-stat-sub">${d.walletAge > 0 ? (d.totalTxCount / d.walletAge).toFixed(1) + ' tx/day avg' : '—'}</div>
</div>
<div class="wx-stat-card">
<div class="wx-stat-label">ACTIVITY RATING</div>
<div class="wx-stat-value"><span class="wx-activity-badge ${d.activityClass}">${d.activityRating}</span></div>
</div>
`;
}
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 = '<div class="wx-empty">No SPL tokens found in this wallet.</div>';
return;
}
let html = `
<div style="overflow-x:auto">
<table class="wx-token-table">
<thead>
<tr>
<th>TOKEN</th>
<th>BALANCE</th>
<th>PRICE</th>
<th>VALUE</th>
<th class="wx-col-mint">MINT</th>
</tr>
</thead>
<tbody>
`;
for (const t of tokens) {
const logo = t.logoURI
? `<img class="wx-token-logo" src="${t.logoURI}" alt="${t.symbol}" loading="lazy" onerror="this.outerHTML='<div class=wx-token-logo-placeholder>?</div>'">`
: '<div class="wx-token-logo-placeholder">?</div>';
html += `
<tr>
<td>
<div class="wx-token-row-info">
${logo}
<div>
<div class="wx-token-name">${escHtml(t.name)}</div>
<div class="wx-token-symbol">${escHtml(t.symbol)}</div>
</div>
</div>
</td>
<td>${fmtNum(t.amount)}</td>
<td>${t.price > 0 ? fmtUsd(t.price) : '—'}</td>
<td class="wx-token-value">${t.usdValue > 0 ? fmtUsd(t.usdValue) : '—'}</td>
<td class="wx-col-mint">
<a href="${SOLSCAN_ACCT}${t.mint}" target="_blank" rel="noopener" class="wx-token-mint" title="${t.mint}">${truncAddr(t.mint)}</a>
</td>
</tr>
`;
}
html += '</tbody></table></div>';
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 = '<div class="wx-empty">No NFTs detected in this wallet.</div>';
return;
}
let html = '<div class="wx-nft-grid">';
for (const nft of nfts) {
const meta = tokenMap.get(nft.mint) || { name: truncAddr(nft.mint), symbol: 'NFT', logoURI: null };
const img = meta.logoURI
? `<img class="wx-nft-img" src="${meta.logoURI}" alt="${escHtml(meta.name)}" loading="lazy" onerror="this.outerHTML='<div class=wx-nft-img-placeholder>🖼</div>'">`
: '<div class="wx-nft-img-placeholder">🖼</div>';
html += `
<a href="${SOLSCAN_ACCT}${nft.mint}" target="_blank" rel="noopener" class="wx-nft-card">
${img}
<div class="wx-nft-info">
<div class="wx-nft-name">${escHtml(meta.name)}</div>
<div class="wx-nft-collection">${escHtml(meta.symbol)}</div>
</div>
</a>
`;
}
html += '</div>';
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 = '<div class="wx-empty">No recent transactions found.</div>';
return;
}
let html = `
<div class="wx-tx-header">
<span>SIGNATURE</span>
<span>TYPE</span>
<span>STATUS</span>
<span>TIME</span>
</div>
<div class="wx-tx-list">
`;
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 += `
<div class="wx-tx-row">
<a href="${SOLSCAN_TX}${signature}" target="_blank" rel="noopener" class="wx-tx-sig">${truncSig}</a>
<span class="wx-tx-type ${typeClass}">${type}</span>
<span class="wx-tx-amount ${statusClass}">${status}</span>
<span class="wx-tx-time">${time}</span>
</div>
`;
}
html += '</div>';
container.innerHTML = html;
}
function renderScore(d) {
const container = document.getElementById('wx-score-content');
// Calculate individual scores (0100)
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 = `
<div class="wx-score-container">
<div>
<div class="wx-grade-circle ${gradeClass}">
<div class="wx-grade-letter">${grade}</div>
<div class="wx-grade-label">OVERALL ${overall}/100</div>
</div>
</div>
<div class="wx-score-bars">
<div class="wx-score-item">
<div class="wx-score-item-header">
<span class="wx-score-item-label">AGE SCORE</span>
<span class="wx-score-item-val">${ageScore}/100</span>
</div>
<div class="wx-score-bar-track">
<div class="wx-score-bar-fill ${barColor(ageScore)}" style="width:${ageScore}%"></div>
</div>
</div>
<div class="wx-score-item">
<div class="wx-score-item-header">
<span class="wx-score-item-label">ACTIVITY SCORE</span>
<span class="wx-score-item-val">${activityScore}/100</span>
</div>
<div class="wx-score-bar-track">
<div class="wx-score-bar-fill ${barColor(activityScore)}" style="width:${activityScore}%"></div>
</div>
</div>
<div class="wx-score-item">
<div class="wx-score-item-header">
<span class="wx-score-item-label">DIVERSITY SCORE</span>
<span class="wx-score-item-val">${diversityScore}/100</span>
</div>
<div class="wx-score-bar-track">
<div class="wx-score-bar-fill ${barColor(diversityScore)}" style="width:${diversityScore}%"></div>
</div>
</div>
<div class="wx-score-item">
<div class="wx-score-item-header">
<span class="wx-score-item-label">PORTFOLIO SCORE</span>
<span class="wx-score-item-val">${wealthScore}/100</span>
</div>
<div class="wx-score-bar-track">
<div class="wx-score-bar-fill ${barColor(wealthScore)}" style="width:${wealthScore}%"></div>
</div>
</div>
</div>
</div>
`;
}
// ── HTML escape ──
function escHtml(str) {
if (!str) return '';
const el = document.createElement('span');
el.textContent = str;
return el.innerHTML;
}
// ══════════════════════════════════════════════════════════
// EVENT HANDLERS
// ═════════════════════════════════<E29590><E29590><EFBFBD>════════════════════════
// 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);
}
})();