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 = `
+
+
+
+
+ | TOKEN |
+ BALANCE |
+ PRICE |
+ VALUE |
+ MINT |
+
+
+
+ `;
+
+ for (const t of tokens) {
+ const logo = t.logoURI
+ ? `
`
+ : '?
';
+
+ html += `
+
+
+
+ ${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)}
+ |
+
+ `;
+ }
+
+ html += '
';
+ 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 = '';
+ 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 = `
+
+
+ `;
+
+ 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 += `
+
+ `;
+ }
+
+ 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
+
+
+
+
+ `;
+ }
+
+ // ── 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OR
+
+
+
+
+
+
+
+
+
+
SCANNING WALLET
+
Querying Solana mainnet...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Fetching token accounts...
+
+
+
+
+
+
+
+
+
+
+
+
Scanning NFT holdings...
+
+
+
+
+
+
+
+
+
+
+
+
Loading transaction history...
+
+
+
+
+
+
+
+
+
+
+
+
Calculating wallet score...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+