diff --git a/api/data/changelog.json b/api/data/changelog.json index 34eee4d..8a9954d 100644 --- a/api/data/changelog.json +++ b/api/data/changelog.json @@ -1,6 +1,27 @@ { "site": "jaeswift.xyz", "entries": [ + { + "version": "v1.30.0", + "date": "18/04/2026", + "title": "ARMOURY: Wallet X-Ray — Solana Wallet Analyser", + "category": "ARMOURY", + "changes": [ + "New tool: Wallet X-Ray — deep scan any Solana wallet address or connect your own wallet", + "Overview panel: SOL balance with live USD value, total portfolio value, wallet age, transaction count, and activity rating", + "Token Holdings: full SPL token table with logos, balances, live prices via Jupiter Price API, and USD values sorted by value", + "NFT Detection: identifies NFTs (0-decimal single-supply tokens) with image grid and Solscan links", + "Recent Transactions: last 20 transactions with signatures linked to Solscan, status indicators, and timestamps", + "Wallet Score: composite grading system (A+ to F) based on age, activity, diversity, and portfolio scores with colour-coded progress bars", + "Supports Token-2022 programme accounts alongside standard SPL Token programme", + "Rate-limit resilient: automatic RPC failover to backup endpoint with exponential backoff", + "Jupiter token list cached in sessionStorage for faster repeat scans", + "URL parameter support: ?address=... for direct wallet scanning via shared links", + "Wallet X-Ray card added to LAB page with cyan/turquoise theme", + "Military radar sweep loading animation during wallet scan", + "Fully responsive design — works on mobile and desktop" + ] + }, { "version": "v1.29.0", "date": "18/04/2026", diff --git a/armoury/lab.html b/armoury/lab.html index f3442af..bb66f84 100644 --- a/armoury/lab.html +++ b/armoury/lab.html @@ -49,6 +49,13 @@ border-left-color: #7B61FF; box-shadow: 0 4px 20px rgba(123, 97, 255, 0.15); } + .deploy-card--cyan { + border-left-color: rgba(20, 241, 149, 0.6); + } + .deploy-card--cyan:hover { + border-left-color: #14F195; + box-shadow: 0 4px 20px rgba(20, 241, 149, 0.15); + } .deploy-card-status { font-family: 'JetBrains Mono', monospace; font-size: 0.6rem; @@ -163,6 +170,18 @@
jaeswift.xyz/jupiterswap
+ + +
◆ EXPERIMENTAL
+
WALLET X-RAY
+
Deep scan any Solana wallet. Token holdings, NFTs, transaction history, PnL analysis, and wallet scoring.
+
+ SOLANA + ANALYSIS + SCANNER +
+
jaeswift.xyz/walletxray
+
diff --git a/css/walletxray.css b/css/walletxray.css new file mode 100644 index 0000000..267f74a --- /dev/null +++ b/css/walletxray.css @@ -0,0 +1,741 @@ +/* =================================================== + WALLET X-RAY — Solana Wallet Analyser + Military / CRT dark theme — #14F195 accent + =================================================== */ + +/* --- Container --- */ +.wx-container { + max-width: clamp(900px, 88vw, 1400px); + margin: 0 auto; + padding: 0 1.5rem 4rem; +} + +/* --- Input Bar --- */ +.wx-input-bar { + display: flex; + gap: 0.75rem; + align-items: stretch; + background: rgba(16, 16, 16, 0.9); + border: 1px solid var(--border); + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.wx-input-bar input { + flex: 1; + min-width: 200px; + background: rgba(10, 10, 10, 0.8); + border: 1px solid #333; + color: #d8d8d8; + font-family: var(--font-mono); + font-size: 0.75rem; + padding: 0.65rem 1rem; + letter-spacing: 0.5px; + outline: none; + transition: border-color 0.3s; +} + +.wx-input-bar input:focus { + border-color: #14F195; + box-shadow: 0 0 8px rgba(20, 241, 149, 0.15); +} + +.wx-input-bar input::placeholder { + color: #555; + letter-spacing: 1px; +} + +.wx-btn { + font-family: var(--font-display); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 2px; + padding: 0.65rem 1.5rem; + border: 1px solid; + cursor: pointer; + transition: all 0.25s ease; + white-space: nowrap; +} + +.wx-btn-scan { + background: rgba(20, 241, 149, 0.1); + border-color: rgba(20, 241, 149, 0.4); + color: #14F195; +} + +.wx-btn-scan:hover:not(:disabled) { + background: rgba(20, 241, 149, 0.2); + border-color: #14F195; + box-shadow: 0 0 15px rgba(20, 241, 149, 0.2); +} + +.wx-btn-scan:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.wx-btn-wallet { + background: rgba(255, 255, 255, 0.04); + border-color: #444; + color: #999; +} + +.wx-btn-wallet:hover { + border-color: #14F195; + color: #14F195; +} + +.wx-btn-wallet.connected { + border-color: rgba(20, 241, 149, 0.5); + color: #14F195; +} + +.wx-or { + font-family: var(--font-mono); + font-size: 0.6rem; + color: #555; + display: flex; + align-items: center; + letter-spacing: 2px; +} + +/* --- Loading Overlay --- */ +.wx-loading { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + gap: 1.5rem; +} + +.wx-loading.active { display: flex; } + +.wx-radar { + width: 120px; + height: 120px; + border-radius: 50%; + border: 2px solid rgba(20, 241, 149, 0.2); + position: relative; + overflow: hidden; +} + +.wx-radar::before { + content: ''; + position: absolute; + top: 50%; left: 50%; + width: 50%; height: 50%; + transform-origin: 0 0; + background: conic-gradient(from 0deg, transparent 0deg, rgba(20, 241, 149, 0.3) 40deg, transparent 80deg); + animation: wx-sweep 2s linear infinite; +} + +.wx-radar::after { + content: ''; + position: absolute; + top: 50%; left: 50%; + width: 6px; height: 6px; + background: #14F195; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 10px #14F195; +} + +.wx-radar-ring { + position: absolute; + border-radius: 50%; + border: 1px solid rgba(20, 241, 149, 0.1); +} + +.wx-radar-ring:nth-child(1) { width: 33%; height: 33%; top: 33.5%; left: 33.5%; } +.wx-radar-ring:nth-child(2) { width: 66%; height: 66%; top: 17%; left: 17%; } + +@keyframes wx-sweep { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.wx-loading-text { + font-family: var(--font-mono); + font-size: 0.7rem; + color: #14F195; + letter-spacing: 3px; + animation: wx-blink 1s step-end infinite; +} + +.wx-loading-sub { + font-family: var(--font-mono); + font-size: 0.6rem; + color: #666; + letter-spacing: 1px; +} + +@keyframes wx-blink { + 50% { opacity: 0.4; } +} + +/* --- Results Container --- */ +.wx-results { + display: none; +} + +.wx-results.active { display: block; } + +/* --- Section Panels --- */ +.wx-panel { + background: rgba(16, 16, 16, 0.85); + border: 1px solid var(--border); + margin-bottom: 1.5rem; + position: relative; + overflow: hidden; +} + +.wx-panel::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 100%; height: 2px; + background: linear-gradient(90deg, transparent, rgba(20, 241, 149, 0.4), transparent); +} + +.wx-panel-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.wx-panel-icon { + font-size: 0.9rem; + opacity: 0.7; +} + +.wx-panel-label { + font-family: var(--font-display); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 2px; + color: #14F195; +} + +.wx-panel-count { + font-family: var(--font-mono); + font-size: 0.55rem; + color: #666; + margin-left: auto; + letter-spacing: 1px; +} + +.wx-panel-body { + padding: 1.25rem; +} + +/* --- Overview Grid --- */ +.wx-overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +.wx-stat-card { + background: rgba(10, 10, 10, 0.6); + border: 1px solid #222; + padding: 1rem; + position: relative; +} + +.wx-stat-card::after { + content: ''; + position: absolute; + bottom: 0; left: 0; + width: 100%; height: 1px; + background: linear-gradient(90deg, transparent, rgba(20, 241, 149, 0.15), transparent); +} + +.wx-stat-label { + font-family: var(--font-mono); + font-size: 0.55rem; + color: #666; + letter-spacing: 2px; + margin-bottom: 0.5rem; +} + +.wx-stat-value { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 700; + color: #14F195; + letter-spacing: 1px; +} + +.wx-stat-value.sol { color: #14F195; } +.wx-stat-value.usd { color: #d0d0d0; } + +.wx-stat-sub { + font-family: var(--font-mono); + font-size: 0.6rem; + color: #555; + margin-top: 0.3rem; +} + +.wx-activity-badge { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 2px; + padding: 0.2rem 0.6rem; + border: 1px solid; +} + +.wx-activity-badge.active { + color: #14F195; + border-color: rgba(20, 241, 149, 0.4); + background: rgba(20, 241, 149, 0.08); +} + +.wx-activity-badge.dormant { + color: #c9a227; + border-color: rgba(201, 162, 39, 0.4); + background: rgba(201, 162, 39, 0.08); +} + +.wx-activity-badge.new { + color: #7B61FF; + border-color: rgba(123, 97, 255, 0.4); + background: rgba(123, 97, 255, 0.08); +} + +.wx-activity-badge.dead { + color: #ff4444; + border-color: rgba(255, 68, 68, 0.4); + background: rgba(255, 68, 68, 0.08); +} + +/* --- Token Table --- */ +.wx-token-table { + width: 100%; + border-collapse: collapse; +} + +.wx-token-table thead th { + font-family: var(--font-mono); + font-size: 0.55rem; + color: #666; + letter-spacing: 2px; + text-align: left; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.wx-token-table tbody tr { + border-bottom: 1px solid rgba(42, 42, 42, 0.4); + transition: background 0.2s; +} + +.wx-token-table tbody tr:hover { + background: rgba(20, 241, 149, 0.03); +} + +.wx-token-table td { + font-family: var(--font-mono); + font-size: 0.7rem; + color: #ccc; + padding: 0.65rem 0.75rem; + vertical-align: middle; +} + +.wx-token-row-info { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.wx-token-logo { + width: 28px; + height: 28px; + border-radius: 50%; + background: #222; + border: 1px solid #333; + object-fit: cover; + flex-shrink: 0; +} + +.wx-token-logo-placeholder { + width: 28px; + height: 28px; + border-radius: 50%; + background: #1a1a1a; + border: 1px solid #333; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.55rem; + color: #555; + flex-shrink: 0; +} + +.wx-token-name { + font-size: 0.7rem; + color: #ddd; + font-weight: 500; +} + +.wx-token-symbol { + font-size: 0.55rem; + color: #666; + letter-spacing: 1px; +} + +.wx-token-value { color: #14F195; } + +.wx-change-pos { color: #14F195; } +.wx-change-neg { color: #ff4444; } +.wx-change-zero { color: #555; } + +.wx-token-mint { + font-size: 0.55rem; + color: #444; + cursor: pointer; + transition: color 0.2s; +} + +.wx-token-mint:hover { color: #14F195; } + +/* --- NFT Grid --- */ +.wx-nft-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 1rem; +} + +.wx-nft-card { + background: rgba(10, 10, 10, 0.6); + border: 1px solid #222; + overflow: hidden; + transition: all 0.25s; +} + +.wx-nft-card:hover { + border-color: rgba(20, 241, 149, 0.4); + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(20, 241, 149, 0.1); +} + +.wx-nft-img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + background: #111; + display: block; +} + +.wx-nft-img-placeholder { + width: 100%; + aspect-ratio: 1; + background: #111; + display: flex; + align-items: center; + justify-content: center; + color: #333; + font-size: 1.5rem; +} + +.wx-nft-info { + padding: 0.6rem; +} + +.wx-nft-name { + font-family: var(--font-mono); + font-size: 0.6rem; + color: #ccc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wx-nft-collection { + font-family: var(--font-mono); + font-size: 0.5rem; + color: #555; + letter-spacing: 1px; + margin-top: 0.2rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Transaction List --- */ +.wx-tx-list { + display: flex; + flex-direction: column; +} + +.wx-tx-row { + display: grid; + grid-template-columns: 120px 1fr 100px 140px; + gap: 1rem; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid rgba(42, 42, 42, 0.3); + align-items: center; + transition: background 0.2s; +} + +.wx-tx-row:hover { background: rgba(20, 241, 149, 0.03); } + +.wx-tx-sig { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--link-color); + text-decoration: none; + letter-spacing: 0.5px; +} + +.wx-tx-sig:hover { color: #14F195; } + +.wx-tx-type { + font-family: var(--font-mono); + font-size: 0.6rem; + letter-spacing: 1px; +} + +.wx-tx-type.transfer { color: #14F195; } +.wx-tx-type.swap { color: #7B61FF; } +.wx-tx-type.mint { color: #F5A623; } +.wx-tx-type.unknown { color: #666; } + +.wx-tx-amount { + font-family: var(--font-mono); + font-size: 0.65rem; + color: #ccc; + text-align: right; +} + +.wx-tx-time { + font-family: var(--font-mono); + font-size: 0.6rem; + color: #555; + text-align: right; +} + +.wx-tx-header { + display: grid; + grid-template-columns: 120px 1fr 100px 140px; + gap: 1rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.wx-tx-header span { + font-family: var(--font-mono); + font-size: 0.5rem; + color: #555; + letter-spacing: 2px; +} + +.wx-tx-header span:nth-child(3), +.wx-tx-header span:nth-child(4) { text-align: right; } + +/* --- Wallet Score --- */ +.wx-score-container { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + align-items: center; +} + +.wx-grade-circle { + width: 160px; + height: 160px; + border-radius: 50%; + border: 3px solid; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 0 auto; + position: relative; +} + +.wx-grade-circle::before { + content: ''; + position: absolute; + inset: -8px; + border-radius: 50%; + border: 1px solid; + opacity: 0.2; +} + +.wx-grade-letter { + font-family: var(--font-display); + font-size: 2.5rem; + font-weight: 900; + letter-spacing: 2px; +} + +.wx-grade-label { + font-family: var(--font-mono); + font-size: 0.55rem; + letter-spacing: 2px; + opacity: 0.6; + margin-top: 0.2rem; +} + +.wx-grade-a { color: #14F195; border-color: rgba(20, 241, 149, 0.5); } +.wx-grade-a::before { border-color: #14F195; } +.wx-grade-b { color: #00cc33; border-color: rgba(0, 204, 51, 0.5); } +.wx-grade-b::before { border-color: #00cc33; } +.wx-grade-c { color: #c9a227; border-color: rgba(201, 162, 39, 0.5); } +.wx-grade-c::before { border-color: #c9a227; } +.wx-grade-d { color: #F5A623; border-color: rgba(245, 166, 35, 0.5); } +.wx-grade-d::before { border-color: #F5A623; } +.wx-grade-f { color: #ff4444; border-color: rgba(255, 68, 68, 0.5); } +.wx-grade-f::before { border-color: #ff4444; } + +.wx-score-bars { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.wx-score-item { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.wx-score-item-header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.wx-score-item-label { + font-family: var(--font-mono); + font-size: 0.6rem; + color: #999; + letter-spacing: 2px; +} + +.wx-score-item-val { + font-family: var(--font-display); + font-size: 0.7rem; + font-weight: 700; + color: #14F195; +} + +.wx-score-bar-track { + height: 6px; + background: #1a1a1a; + border: 1px solid #222; + position: relative; + overflow: hidden; +} + +.wx-score-bar-fill { + height: 100%; + transition: width 0.8s ease; + position: relative; +} + +.wx-score-bar-fill::after { + content: ''; + position: absolute; + right: 0; top: 0; + width: 2px; height: 100%; + background: inherit; + box-shadow: 0 0 8px currentColor; +} + +.wx-score-bar-fill.green { background: #14F195; color: #14F195; } +.wx-score-bar-fill.yellow { background: #c9a227; color: #c9a227; } +.wx-score-bar-fill.orange { background: #F5A623; color: #F5A623; } +.wx-score-bar-fill.red { background: #ff4444; color: #ff4444; } + +/* --- Empty / Error States --- */ +.wx-empty { + text-align: center; + padding: 2rem; + font-family: var(--font-mono); + font-size: 0.7rem; + color: #555; + letter-spacing: 1px; +} + +.wx-error-msg { + background: rgba(255, 68, 68, 0.05); + border: 1px solid rgba(255, 68, 68, 0.2); + color: #ff4444; + font-family: var(--font-mono); + font-size: 0.7rem; + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; + display: none; + letter-spacing: 0.5px; +} + +.wx-error-msg.active { display: block; } + +/* --- Section Loading Spinners --- */ +.wx-section-loading { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 0; +} + +.wx-mini-spinner { + width: 16px; + height: 16px; + border: 2px solid #222; + border-top-color: #14F195; + border-radius: 50%; + animation: wx-spin 0.8s linear infinite; +} + +@keyframes wx-spin { + to { transform: rotate(360deg); } +} + +.wx-section-loading span { + font-family: var(--font-mono); + font-size: 0.6rem; + color: #555; + letter-spacing: 1px; +} + +/* --- Responsive --- */ +@media (max-width: 768px) { + .wx-input-bar { flex-direction: column; } + .wx-or { justify-content: center; padding: 0.25rem 0; } + + .wx-overview-grid { grid-template-columns: repeat(2, 1fr); } + + .wx-score-container { + grid-template-columns: 1fr; + text-align: center; + } + + .wx-tx-row { + grid-template-columns: 1fr 1fr; + gap: 0.4rem; + } + .wx-tx-header { display: none; } + .wx-tx-amount, .wx-tx-time { text-align: left; } + + .wx-nft-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); } + + .wx-token-table { font-size: 0.6rem; } + .wx-token-table .wx-col-change, + .wx-token-table .wx-col-mint { display: none; } +} + +@media (max-width: 480px) { + .wx-overview-grid { grid-template-columns: 1fr; } + .wx-stat-value { font-size: 0.9rem; } + .wx-grade-circle { width: 120px; height: 120px; } + .wx-grade-letter { font-size: 2rem; } +} diff --git a/js/walletxray.js b/js/walletxray.js new file mode 100644 index 0000000..0340469 --- /dev/null +++ b/js/walletxray.js @@ -0,0 +1,796 @@ +/* ─── 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 = 'https://api.mainnet-beta.solana.com'; + const RPC_BACKUP = 'https://rpc.ankr.com/solana'; + 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; + 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 = 2) { + for (let i = 0; i <= retries; i++) { + try { + const url = i === 0 ? activeRpc : RPC_BACKUP; + 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) { + await sleep(1000 * (i + 1)); + continue; + } + const json = await res.json(); + if (json.error) { + if (json.error.code === -32429 || json.error.message?.includes('rate')) { + await sleep(1000 * (i + 1)); + continue; + } + throw new Error(json.error.message || 'RPC error'); + } + return json.result; + } catch (e) { + if (i === retries) throw e; + activeRpc = RPC_BACKUP; + await sleep(500); + } + } + } + + 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 += '
TOKENBALANCEPRICEVALUEMINT
+
+ ${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); + } + +})(); diff --git a/walletxray/index.html b/walletxray/index.html new file mode 100644 index 0000000..bb6b76f --- /dev/null +++ b/walletxray/index.html @@ -0,0 +1,169 @@ + + + + + + JAESWIFT // WALLET X-RAY + + + + + + + +
+
+ + + + + +
+
LAB // SOLANA WALLET ANALYSIS
+

WALLET X-RAY

+

> Deep scan any Solana wallet — token holdings, NFTs, transactions, and wallet scoring.

+
+ +
+ + +
+ + + OR + +
+ + +
+ + +
+
+
+
+
+
SCANNING WALLET
+
Querying Solana mainnet...
+
+ + +
+ + +
+
+ + OVERVIEW + +
+
+
+
+
+ + +
+
+ + TOKEN HOLDINGS + +
+
+
+
+ Fetching token accounts... +
+
+
+
+ + +
+
+ + NFTs + +
+
+
+
+ Scanning NFT holdings... +
+
+
+
+ + +
+
+ + RECENT TRANSACTIONS + +
+
+
+
+ Loading transaction history... +
+
+
+
+ + +
+
+ + WALLET SCORE +
+
+
+
+ Calculating wallet score... +
+
+
+
+ +
+
+ + + + + + + + +