810 lines
33 KiB
JavaScript
810 lines
33 KiB
JavaScript
/* ─── 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://mainnet.helius-rpc.com/?api-key=18509625-b17b-44ed-9ad9-489d3bf05f41',
|
||
'https://api.mainnet-beta.solana.com',
|
||
'https://solana-rpc.publicnode.com'
|
||
];
|
||
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 (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 = `
|
||
<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);
|
||
}
|
||
|
||
})();
|