- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
366 lines
18 KiB
Python
366 lines
18 KiB
Python
"""
|
|
WalletSafe Frontend - Entity explorer, wallet search, risk page.
|
|
===============================================================
|
|
Served at /walletsafe/ as a single-page app.
|
|
Hits /api/v1/wallet/* endpoints directly via fetch().
|
|
"""
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
router = APIRouter(tags=["walletsafe"])
|
|
|
|
WALLETSAFE_HTML = """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WalletSafe - Wallet Intelligence Explorer</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0a0a0f;
|
|
--card: #12121a;
|
|
--border: #1e1e2e;
|
|
--text: #e0e0e8;
|
|
--muted: #8888a0;
|
|
--accent: #6c5ce7;
|
|
--danger: #ff4757;
|
|
--warning: #ffa502;
|
|
--safe: #2ed573;
|
|
--info: #1e90ff;
|
|
}
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { background:var(--bg); color:var(--text); font-family:'Inter',system-ui,sans-serif; min-height:100vh; }
|
|
|
|
nav { background:var(--card); border-bottom:1px solid var(--border); padding:12px 24px; display:flex; align-items:center; gap:24px; }
|
|
nav .logo { font-size:20px; font-weight:700; color:var(--accent); letter-spacing:-0.5px; }
|
|
nav .tab { color:var(--muted); cursor:pointer; padding:6px 14px; border-radius:6px; font-size:14px; transition:all .2s; }
|
|
nav .tab:hover { color:var(--text); background:var(--border); }
|
|
nav .tab.active { color:var(--accent); background:rgba(108,92,231,0.15); }
|
|
|
|
.container { max-width:1200px; margin:0 auto; padding:24px; }
|
|
|
|
/* Search bar */
|
|
.search-bar { display:flex; gap:12px; margin-bottom:24px; }
|
|
.search-bar input { flex:1; padding:12px 16px; background:var(--card); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:15px; outline:none; }
|
|
.search-bar input:focus { border-color:var(--accent); }
|
|
.search-bar select { padding:10px 12px; background:var(--card); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:14px; }
|
|
.search-bar button { padding:10px 24px; background:var(--accent); color:#fff; border:none; border-radius:8px; font-size:15px; cursor:pointer; font-weight:600; }
|
|
.search-bar button:hover { opacity:0.9; }
|
|
|
|
/* Cards */
|
|
.card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-bottom:16px; }
|
|
.card h3 { font-size:16px; color:var(--muted); margin-bottom:12px; text-transform:uppercase; letter-spacing:0.5px; }
|
|
|
|
/* Risk gauge */
|
|
.risk-gauge { display:flex; align-items:center; gap:16px; margin:16px 0; }
|
|
.risk-score { font-size:48px; font-weight:800; line-height:1; }
|
|
.risk-label { font-size:18px; font-weight:600; padding:4px 12px; border-radius:6px; }
|
|
.risk-minimal { color:var(--safe); background:rgba(46,213,115,0.1); }
|
|
.risk-low { color:#7bed9f; background:rgba(123,237,159,0.1); }
|
|
.risk-medium { color:var(--warning); background:rgba(255,165,2,0.1); }
|
|
.risk-high { color:#ff6348; background:rgba(255,99,72,0.1); }
|
|
.risk-critical { color:var(--danger); background:rgba(255,71,87,0.15); }
|
|
.risk-unknown { color:var(--muted); background:rgba(136,136,160,0.1); }
|
|
|
|
/* Risk bar */
|
|
.risk-bar { height:8px; background:var(--border); border-radius:4px; overflow:hidden; margin-top:8px; }
|
|
.risk-bar-fill { height:100%; border-radius:4px; transition:width .5s ease; }
|
|
|
|
/* Profile grid */
|
|
.profile-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
|
@media(max-width:768px) { .profile-grid { grid-template-columns:1fr; } }
|
|
.profile-field { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border); }
|
|
.profile-field:last-child { border:none; }
|
|
.profile-field .label { color:var(--muted); font-size:13px; }
|
|
.profile-field .value { font-weight:500; font-size:14px; word-break:break-all; }
|
|
|
|
/* Tags */
|
|
.tag { display:inline-block; padding:3px 8px; border-radius:4px; font-size:12px; margin:2px; }
|
|
.tag-exchange { background:rgba(30,144,255,0.15); color:var(--info); }
|
|
.tag-scam { background:rgba(255,71,87,0.15); color:var(--danger); }
|
|
.tag-sanctioned { background:rgba(255,71,87,0.25); color:var(--danger); }
|
|
.tag-defi { background:rgba(108,92,231,0.15); color:var(--accent); }
|
|
.tag-bridge { background:rgba(255,165,2,0.15); color:var(--warning); }
|
|
.tag-mev { background:rgba(255,165,2,0.15); color:var(--warning); }
|
|
.tag-deployer { background:rgba(46,213,115,0.15); color:var(--safe); }
|
|
.tag-wallet { background:rgba(136,136,160,0.15); color:var(--muted); }
|
|
.tag-unknown { background:rgba(136,136,160,0.1); color:var(--muted); }
|
|
|
|
/* Wallet list */
|
|
.wallet-item { display:flex; align-items:center; gap:12px; padding:10px 0; border-bottom:1px solid var(--border); }
|
|
.wallet-item:last-child { border:none; }
|
|
.wallet-addr { font-family:monospace; font-size:13px; color:var(--info); cursor:pointer; }
|
|
.wallet-addr:hover { text-decoration:underline; }
|
|
.wallet-chain { font-size:11px; padding:2px 6px; border-radius:3px; background:rgba(108,92,231,0.15); color:var(--accent); }
|
|
|
|
/* Deployer history */
|
|
.deploy-item { padding:10px 0; border-bottom:1px solid var(--border); font-size:13px; }
|
|
.deploy-item:last-child { border:none; }
|
|
.deploy-token { font-family:monospace; color:var(--info); }
|
|
|
|
/* Loading state */
|
|
.loading { display:flex; align-items:center; justify-content:center; padding:60px; color:var(--muted); }
|
|
.spinner { width:24px; height:24px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; margin-right:12px; }
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
|
|
/* Empty state */
|
|
.empty { text-align:center; padding:60px; color:var(--muted); }
|
|
.empty h2 { font-size:20px; margin-bottom:8px; color:var(--text); }
|
|
|
|
/* Error */
|
|
.error { background:rgba(255,71,87,0.1); border:1px solid rgba(255,71,87,0.3); border-radius:8px; padding:16px; color:var(--danger); margin-bottom:16px; }
|
|
|
|
/* Stats bar */
|
|
.stats-bar { display:flex; gap:24px; margin-bottom:24px; flex-wrap:wrap; }
|
|
.stat-item { display:flex; flex-direction:column; }
|
|
.stat-value { font-size:28px; font-weight:700; }
|
|
.stat-label { font-size:12px; color:var(--muted); text-transform:uppercase; }
|
|
|
|
/* Entity graph placeholder */
|
|
.entity-graph { min-height:200px; display:flex; flex-wrap:wrap; gap:8px; padding:12px; }
|
|
.entity-node { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:8px 12px; font-size:12px; cursor:pointer; transition:all .2s; }
|
|
.entity-node:hover { border-color:var(--accent); }
|
|
.entity-node.center { border-color:var(--accent); background:rgba(108,92,231,0.1); }
|
|
.entity-node .addr { font-family:monospace; color:var(--info); }
|
|
.entity-node .method { color:var(--muted); font-size:10px; margin-top:2px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<nav>
|
|
<div class="logo">WalletSafe</div>
|
|
<div class="tab active" data-tab="search" onclick="switchTab('search')">Search</div>
|
|
<div class="tab" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</div>
|
|
</nav>
|
|
|
|
<!-- Search Tab -->
|
|
<div id="tab-search" class="container">
|
|
<div class="search-bar">
|
|
<input id="search-input" type="text" placeholder="Enter wallet address (0x..., Solana...)" onkeydown="if(event.key==='Enter')searchWallet()">
|
|
<select id="chain-select">
|
|
<option value="solana">Solana</option>
|
|
<option value="ethereum">Ethereum</option>
|
|
<option value="bsc">BSC</option>
|
|
<option value="polygon">Polygon</option>
|
|
<option value="arbitrum">Arbitrum</option>
|
|
<option value="base">Base</option>
|
|
</select>
|
|
<button onclick="searchWallet()">Analyze</button>
|
|
</div>
|
|
|
|
<div id="search-results"></div>
|
|
<div id="empty-state" class="empty">
|
|
<h2>Wallet Intelligence Explorer</h2>
|
|
<p>Enter a wallet address to see risk scores, entity clusters, labels, and deployer history</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dashboard Tab -->
|
|
<div id="tab-dashboard" class="container" style="display:none">
|
|
<div class="stats-bar" id="dashboard-stats"></div>
|
|
<div class="profile-grid" id="dashboard-content"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = '/api/v1/wallet';
|
|
let currentTab = 'search';
|
|
|
|
function switchTab(tab) {
|
|
currentTab = tab;
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
|
document.getElementById('tab-search').style.display = tab === 'search' ? 'block' : 'none';
|
|
document.getElementById('tab-dashboard').style.display = tab === 'dashboard' ? 'block' : 'none';
|
|
if (tab === 'dashboard') loadDashboard();
|
|
}
|
|
|
|
async function api(endpoint, opts = {}) {
|
|
try {
|
|
const r = await fetch(API + endpoint, opts);
|
|
return await r.json();
|
|
} catch(e) {
|
|
console.error('API error:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function searchWallet() {
|
|
const addr = document.getElementById('search-input').value.trim();
|
|
const chain = document.getElementById('chain-select').value;
|
|
if (!addr) return;
|
|
|
|
const results = document.getElementById('search-results');
|
|
const empty = document.getElementById('empty-state');
|
|
empty.style.display = 'none';
|
|
results.innerHTML = '<div class="loading"><div class="spinner"></div>Analyzing wallet...</div>';
|
|
|
|
const profile = await api(`/${addr}/profile?chain=${chain}`);
|
|
if (!profile) {
|
|
results.innerHTML = '<div class="error">Failed to fetch wallet profile. Check the address and try again.</div>';
|
|
return;
|
|
}
|
|
|
|
results.innerHTML = renderProfile(profile, chain);
|
|
}
|
|
|
|
function renderProfile(p, chain) {
|
|
const score = p.risk_score || 0;
|
|
const level = p.risk_level || 'unknown';
|
|
const scoreColor = riskColor(score);
|
|
const barColor = riskBarColor(level);
|
|
|
|
let html = '';
|
|
|
|
// Risk Card
|
|
html += `<div class="card">
|
|
<h3>Risk Assessment</h3>
|
|
<div class="risk-gauge">
|
|
<div class="risk-score" style="color:${scoreColor}">${Math.round(score)}</div>
|
|
<div>
|
|
<span class="risk-label risk-${level}">${level.toUpperCase()}</span>
|
|
<div class="risk-bar" style="width:200px;margin-top:8px">
|
|
<div class="risk-bar-fill" style="width:${score}%;background:${barColor}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${p.scam_associations?.length ? `<div style="margin-top:12px"><strong>Scam Associations:</strong> ${p.scam_associations.map(s => `<span class="tag tag-scam">${s.type} (${s.source})</span>`).join(' ')}</div>` : ''}
|
|
</div>`;
|
|
|
|
// Profile Info
|
|
html += `<div class="profile-grid">
|
|
<div class="card">
|
|
<h3>Wallet Identity</h3>
|
|
<div class="profile-field"><span class="label">Address</span><span class="value" style="font-family:monospace;font-size:12px">${p.address}</span></div>
|
|
<div class="profile-field"><span class="label">Chain</span><span class="value">${p.chain}</span></div>
|
|
<div class="profile-field"><span class="label">Entity</span><span class="value">${p.entity_label || p.entity_id?.slice(0,16) + '...' || 'Unknown'}</span></div>
|
|
<div class="profile-field"><span class="label">Category</span><span class="value"><span class="tag tag-${p.entity_category || 'unknown'}">${p.entity_category || 'unknown'}</span></span></div>
|
|
<div class="profile-field"><span class="label">Confidence</span><span class="value">${(p.confidence * 100).toFixed(0)}%</span></div>
|
|
${p.total_transactions ? `<div class="profile-field"><span class="label">Transactions</span><span class="value">${p.total_transactions.toLocaleString()}</span></div>` : ''}
|
|
${p.total_volume ? `<div class="profile-field"><span class="label">Volume</span><span class="value">$${p.total_volume.toLocaleString()}</span></div>` : ''}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Labels (${p.labels?.length || 0})</h3>
|
|
${p.labels?.length ? p.labels.map(l => `<div class="profile-field"><span class="label">${l.source}</span><span class="value"><span class="tag tag-${l.category || 'unknown'}">${l.label || l.category}</span></span></div>`).join('') : '<div style="color:var(--muted);padding:20px;text-align:center">No labels found</div>'}
|
|
</div>
|
|
</div>`;
|
|
|
|
// Linked Wallets / Entity
|
|
if (p.linked_wallets?.length) {
|
|
html += `<div class="card">
|
|
<h3>Linked Wallets (${p.linked_wallets.length})</h3>
|
|
<div class="entity-graph">
|
|
<div class="entity-node center">
|
|
<div class="addr">${p.address.slice(0,10)}...${p.address.slice(-6)}</div>
|
|
<div class="method">primary</div>
|
|
</div>
|
|
${p.linked_wallets.map(w => `<div class="entity-node" onclick="drillDown('${w.address}','${w.chain}')">
|
|
<div class="addr">${w.address.slice(0,10)}...${w.address.slice(-6)}</div>
|
|
<div class="method">${w.heuristic || w.method || 'linked'} · ${w.chain} · ${(w.confidence * 100).toFixed(0)}%</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// Cross-chain
|
|
if (p.cross_chain_presence?.length) {
|
|
html += `<div class="card"><h3>Cross-Chain Presence</h3>
|
|
${p.cross_chain_presence.map(b => `<div class="wallet-item">
|
|
<span class="wallet-addr" onclick="drillDown('${b.dest_address}','${b.dest_chain}')">${b.dest_address.slice(0,12)}...${b.dest_address.slice(-6)}</span>
|
|
<span class="wallet-chain">${b.dest_chain}</span>
|
|
<span style="color:var(--muted);font-size:12px">via ${b.bridge_name}</span>
|
|
</div>`).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
// Deployer History
|
|
if (p.deployer_history?.length) {
|
|
html += `<div class="card"><h3>Deployer History (${p.deployer_history.length})</h3>
|
|
${p.deployer_history.map(d => `<div class="deploy-item">
|
|
<span class="deploy-token">${d.token_symbol || d.token_address?.slice(0,12)}</span>
|
|
<span style="margin-left:8px">${d.token_name || ''}</span>
|
|
${d.is_scam_related ? '<span class="tag tag-scam" style="margin-left:8px">SCAM</span>' : ''}
|
|
<span style="float:right;color:var(--muted)">${d.deployed_at ? new Date(d.deployed_at).toLocaleDateString() : ''} · ${d.outcome || 'unknown'}</span>
|
|
</div>`).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
function drillDown(addr, chain) {
|
|
document.getElementById('search-input').value = addr;
|
|
document.getElementById('chain-select').value = chain;
|
|
searchWallet();
|
|
}
|
|
|
|
function riskColor(score) {
|
|
if (score >= 80) return 'var(--danger)';
|
|
if (score >= 60) return '#ff6348';
|
|
if (score >= 40) return 'var(--warning)';
|
|
if (score >= 20) return '#7bed9f';
|
|
return 'var(--safe)';
|
|
}
|
|
|
|
function riskBarColor(level) {
|
|
const m = {critical:'var(--danger)',high:'#ff6348',medium:'var(--warning)',low:'#7bed9f',minimal:'var(--safe)',unknown:'var(--muted)'};
|
|
return m[level] || 'var(--muted)';
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
const stats = document.getElementById('dashboard-stats');
|
|
const content = document.getElementById('dashboard-content');
|
|
stats.innerHTML = '<div class="loading"><div class="spinner"></div>Loading...</div>';
|
|
|
|
const [health, labelStats] = await Promise.all([
|
|
api('/health'),
|
|
api('/labels/stats'),
|
|
]);
|
|
|
|
let statsHtml = '';
|
|
if (health) {
|
|
statsHtml += `
|
|
<div class="stat-item"><span class="stat-value" style="color:${health.storage?.redis ? 'var(--safe)' : 'var(--danger)'}">${health.storage?.redis ? 'Online' : 'Offline'}</span><span class="stat-label">Redis</span></div>
|
|
<div class="stat-item"><span class="stat-value" style="color:${health.storage?.clickhouse ? 'var(--safe)' : 'var(--warning)'}">${health.storage?.clickhouse ? 'Online' : 'Offline'}</span><span class="stat-label">ClickHouse</span></div>
|
|
<div class="stat-item"><span class="stat-value">${health.clustering?.wallets || 0}</span><span class="stat-label">Cluster Nodes</span></div>
|
|
`;
|
|
}
|
|
if (labelStats) {
|
|
const c = labelStats.last_counts || {};
|
|
statsHtml += `
|
|
<div class="stat-item"><span class="stat-value">${c.total || 0}</span><span class="stat-label">Total Labels</span></div>
|
|
<div class="stat-item"><span class="stat-value">${c.ofac || 0}</span><span class="stat-label">OFAC Sanctions</span></div>
|
|
<div class="stat-item"><span class="stat-value">${c.etherscan_combined || 0}</span><span class="stat-label">Etherscan Labels</span></div>
|
|
<div class="stat-item"><span class="stat-value">${c.solana_cex || 0}</span><span class="stat-label">Solana CEX</span></div>
|
|
`;
|
|
}
|
|
stats.innerHTML = statsHtml;
|
|
|
|
// Show recent high-risk wallets from Redis scan
|
|
content.innerHTML = `
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h3>System Health</h3>
|
|
<div class="profile-field"><span class="label">Status</span><span class="value">${health?.status === 'ok' ? '<span style="color:var(--safe)">Operational</span>' : '<span style="color:var(--warning)">Degraded</span>'}</span></div>
|
|
<div class="profile-field"><span class="label">Redis</span><span class="value">${health?.storage?.redis ? '<span style="color:var(--safe)">Connected</span>' : '<span style="color:var(--danger)">Disconnected</span>'}</span></div>
|
|
<div class="profile-field"><span class="label">ClickHouse</span><span class="value">${health?.storage?.clickhouse ? '<span style="color:var(--safe)">Connected</span>' : '<span style="color:var(--warning)">Unavailable (Redis-only)</span>'}</span></div>
|
|
<div class="profile-field"><span class="label">Clustering Heuristics</span><span class="value">${health?.clustering?.heuristics || 6}</span></div>
|
|
<div class="profile-field"><span class="label">Label Sources</span><span class="value">9 (6 static + 3 live)</span></div>
|
|
<div class="profile-field"><span class="label">Label Import</span><span class="value">${labelStats?.loading ? '<span style="color:var(--warning)">Loading...</span>' : '<span style="color:var(--safe)">Complete</span>'}</span></div>
|
|
</div>
|
|
`;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@router.get("/walletsafe/")
|
|
async def walletsafe_root():
|
|
"""Serve the WalletSafe frontend."""
|
|
return HTMLResponse(content=WALLETSAFE_HTML)
|
|
|
|
|
|
@router.get("/walletsafe")
|
|
async def walletsafe_no_slash():
|
|
"""Redirect to /walletsafe/."""
|
|
return HTMLResponse(content=WALLETSAFE_HTML)
|