feat: Wallet X-Ray — Solana wallet analyser tool (v1.30.0)
- New tool: deep scan any Solana wallet address or connect wallet - Overview: SOL balance, portfolio value, wallet age, tx count, activity rating - Token Holdings: SPL token table with logos, prices, USD values via Jupiter - NFT Detection: 0-decimal single-supply token grid with Solscan links - Recent Transactions: last 20 sigs with status and timestamps - Wallet Score: composite A+ to F grading with colour-coded bars - Token-2022 support, RPC failover, Jupiter token list caching - URL param support (?address=) for direct scanning - Radar sweep loading animation, responsive mobile design - New LAB card with cyan theme
This commit is contained in:
parent
b5e7f114ba
commit
660bb842ec
5 changed files with 1746 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
<div class="deploy-card-url">jaeswift.xyz/jupiterswap</div>
|
||||
</a>
|
||||
|
||||
<a href="/walletxray/" class="deploy-card deploy-card--cyan">
|
||||
<div class="deploy-card-status" style="color: #14F195;">◆ EXPERIMENTAL</div>
|
||||
<div class="deploy-card-title">WALLET X-RAY</div>
|
||||
<div class="deploy-card-desc">Deep scan any Solana wallet. Token holdings, NFTs, transaction history, PnL analysis, and wallet scoring.</div>
|
||||
<div class="deploy-card-tags">
|
||||
<span class="deploy-tag" style="color: rgba(20, 241, 149, 0.8); background: rgba(20, 241, 149, 0.08); border-color: rgba(20, 241, 149, 0.2);">SOLANA</span>
|
||||
<span class="deploy-tag" style="color: rgba(20, 241, 149, 0.8); background: rgba(20, 241, 149, 0.08); border-color: rgba(20, 241, 149, 0.2);">ANALYSIS</span>
|
||||
<span class="deploy-tag" style="color: rgba(0, 255, 255, 0.8); background: rgba(0, 255, 255, 0.08); border-color: rgba(0, 255, 255, 0.2);">SCANNER</span>
|
||||
</div>
|
||||
<div class="deploy-card-url">jaeswift.xyz/walletxray</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
741
css/walletxray.css
Normal file
741
css/walletxray.css
Normal file
|
|
@ -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; }
|
||||
}
|
||||
796
js/walletxray.js
Normal file
796
js/walletxray.js
Normal file
|
|
@ -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 = `
|
||||
<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);
|
||||
}
|
||||
|
||||
})();
|
||||
169
walletxray/index.html
Normal file
169
walletxray/index.html
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>JAESWIFT // WALLET X-RAY</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/section.css">
|
||||
<link rel="stylesheet" href="/css/walletxray.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="scanline-overlay"></div>
|
||||
<div class="grid-bg"></div>
|
||||
|
||||
<nav class="nav-main" id="navbar">
|
||||
<div class="nav-container">
|
||||
<a href="/" class="nav-logo">
|
||||
<span class="logo-bracket">[</span> JAE <span class="logo-bracket">]</span>
|
||||
</a>
|
||||
<button class="nav-toggle" id="navToggle" aria-label="Menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<ul class="nav-menu" id="navMenu"></ul>
|
||||
<div class="nav-status">
|
||||
<span class="nav-clock" id="navClock"></span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="breadcrumb">
|
||||
<a href="/">HOME</a>
|
||||
<span class="separator">/</span>
|
||||
<a href="/armoury">ARMOURY</a>
|
||||
<span class="separator">/</span>
|
||||
<a href="/armoury/lab.html">LAB</a>
|
||||
<span class="separator">/</span>
|
||||
<span class="current">WALLET X-RAY</span>
|
||||
</div>
|
||||
|
||||
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
|
||||
<div class="section-header-label">LAB // SOLANA WALLET ANALYSIS</div>
|
||||
<h1 class="section-header-title">WALLET X-RAY</h1>
|
||||
<p class="section-header-sub">> Deep scan any Solana wallet — token holdings, NFTs, transactions, and wallet scoring.</p>
|
||||
</section>
|
||||
|
||||
<div class="wx-container">
|
||||
|
||||
<!-- Input Bar -->
|
||||
<div class="wx-input-bar">
|
||||
<input type="text" id="wx-address" placeholder="Paste any Solana wallet address..." spellcheck="false" autocomplete="off">
|
||||
<button class="wx-btn wx-btn-scan" id="wx-scan-btn">▼ SCAN</button>
|
||||
<span class="wx-or">OR</span>
|
||||
<button class="wx-btn wx-btn-wallet" id="wx-wallet-btn">CONNECT WALLET</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div class="wx-error-msg" id="wx-error"></div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
<div class="wx-loading" id="wx-loading">
|
||||
<div class="wx-radar">
|
||||
<div class="wx-radar-ring"></div>
|
||||
<div class="wx-radar-ring"></div>
|
||||
</div>
|
||||
<div class="wx-loading-text" id="wx-loading-text">SCANNING WALLET</div>
|
||||
<div class="wx-loading-sub" id="wx-loading-sub">Querying Solana mainnet...</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="wx-results" id="wx-results">
|
||||
|
||||
<!-- SECTION 1: OVERVIEW -->
|
||||
<div class="wx-panel">
|
||||
<div class="wx-panel-header">
|
||||
<span class="wx-panel-icon">■</span>
|
||||
<span class="wx-panel-label">OVERVIEW</span>
|
||||
<span class="wx-panel-count" id="wx-wallet-short"></span>
|
||||
</div>
|
||||
<div class="wx-panel-body">
|
||||
<div class="wx-overview-grid" id="wx-overview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: TOKEN HOLDINGS -->
|
||||
<div class="wx-panel">
|
||||
<div class="wx-panel-header">
|
||||
<span class="wx-panel-icon">◆</span>
|
||||
<span class="wx-panel-label">TOKEN HOLDINGS</span>
|
||||
<span class="wx-panel-count" id="wx-token-count"></span>
|
||||
</div>
|
||||
<div class="wx-panel-body" id="wx-tokens-body">
|
||||
<div class="wx-section-loading" id="wx-tokens-loading">
|
||||
<div class="wx-mini-spinner"></div>
|
||||
<span>Fetching token accounts...</span>
|
||||
</div>
|
||||
<div id="wx-tokens-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 3: NFTs -->
|
||||
<div class="wx-panel">
|
||||
<div class="wx-panel-header">
|
||||
<span class="wx-panel-icon">♣</span>
|
||||
<span class="wx-panel-label">NFTs</span>
|
||||
<span class="wx-panel-count" id="wx-nft-count"></span>
|
||||
</div>
|
||||
<div class="wx-panel-body" id="wx-nfts-body">
|
||||
<div class="wx-section-loading" id="wx-nfts-loading">
|
||||
<div class="wx-mini-spinner"></div>
|
||||
<span>Scanning NFT holdings...</span>
|
||||
</div>
|
||||
<div id="wx-nfts-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 4: RECENT TRANSACTIONS -->
|
||||
<div class="wx-panel">
|
||||
<div class="wx-panel-header">
|
||||
<span class="wx-panel-icon">▶</span>
|
||||
<span class="wx-panel-label">RECENT TRANSACTIONS</span>
|
||||
<span class="wx-panel-count" id="wx-tx-count"></span>
|
||||
</div>
|
||||
<div class="wx-panel-body" id="wx-tx-body">
|
||||
<div class="wx-section-loading" id="wx-tx-loading">
|
||||
<div class="wx-mini-spinner"></div>
|
||||
<span>Loading transaction history...</span>
|
||||
</div>
|
||||
<div id="wx-tx-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 5: WALLET SCORE -->
|
||||
<div class="wx-panel">
|
||||
<div class="wx-panel-header">
|
||||
<span class="wx-panel-icon">★</span>
|
||||
<span class="wx-panel-label">WALLET SCORE</span>
|
||||
</div>
|
||||
<div class="wx-panel-body" id="wx-score-body">
|
||||
<div class="wx-section-loading" id="wx-score-loading">
|
||||
<div class="wx-mini-spinner"></div>
|
||||
<span>Calculating wallet score...</span>
|
||||
</div>
|
||||
<div id="wx-score-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-container">
|
||||
<div class="footer-left">
|
||||
<span class="footer-logo">[JAE]</span>
|
||||
<span class="footer-copy">© 2026 JAESWIFT.XYZ</span>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<span class="footer-signal">SIGNAL ████<span class="signal-flicker">█</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/js/wallet-connect.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/clock.js"></script>
|
||||
<script src="/js/walletxray.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue