merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
946
static/admin.html
Normal file
946
static/admin.html
Normal file
|
|
@ -0,0 +1,946 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RMI Darkroom — Admin Control Panel</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f; --bg2: #12121a; --bg3: #1a1a2e; --bg4: #242438;
|
||||
--fg: #e0e0e0; --fg2: #a0a0b0; --accent: #00d4ff; --accent2: #ff6b35;
|
||||
--green: #00ff88; --red: #ff4444; --yellow: #ffcc00; --purple: #b388ff;
|
||||
--border: #2a2a40; --shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
--radius: 8px; --font: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:var(--font); background:var(--bg); color:var(--fg); min-height:100vh; }
|
||||
|
||||
/* Login */
|
||||
.login-screen { display:flex; align-items:center; justify-content:center; min-height:100vh; background:linear-gradient(135deg, var(--bg) 0%, var(--bg3) 100%); }
|
||||
.login-box { background:var(--bg2); padding:40px; border-radius:16px; border:1px solid var(--border); width:100%; max-width:420px; box-shadow:var(--shadow); }
|
||||
.login-box h1 { font-size:28px; margin-bottom:8px; color:var(--accent); }
|
||||
.login-box p { color:var(--fg2); margin-bottom:24px; font-size:14px; }
|
||||
.login-box input { width:100%; padding:12px 16px; margin-bottom:12px; background:var(--bg3); border:1px solid var(--border); border-radius:var(--radius); color:var(--fg); font-size:15px; }
|
||||
.login-box input:focus { outline:none; border-color:var(--accent); }
|
||||
.login-box button { width:100%; padding:14px; background:var(--accent); color:var(--bg); border:none; border-radius:var(--radius); font-size:16px; font-weight:600; cursor:pointer; transition:opacity 0.2s; }
|
||||
.login-box button:hover { opacity:0.9; }
|
||||
.login-box .error { color:var(--red); font-size:13px; margin-top:8px; }
|
||||
|
||||
/* Layout */
|
||||
.app { display:none; }
|
||||
.app.active { display:flex; min-height:100vh; }
|
||||
.sidebar { width:260px; background:var(--bg2); border-right:1px solid var(--border); display:flex; flex-direction:column; position:fixed; height:100vh; overflow-y:auto; z-index:100; }
|
||||
.sidebar-header { padding:20px; border-bottom:1px solid var(--border); }
|
||||
.sidebar-header h2 { font-size:18px; color:var(--accent); }
|
||||
.sidebar-header span { font-size:12px; color:var(--fg2); }
|
||||
.sidebar-nav { flex:1; padding:12px; }
|
||||
.nav-item { display:flex; align-items:center; gap:12px; padding:10px 14px; border-radius:var(--radius); cursor:pointer; transition:all 0.2s; color:var(--fg2); font-size:14px; margin-bottom:2px; }
|
||||
.nav-item:hover { background:var(--bg3); color:var(--fg); }
|
||||
.nav-item.active { background:var(--bg3); color:var(--accent); border-left:3px solid var(--accent); }
|
||||
.nav-item svg { width:18px; height:18px; flex-shrink:0; }
|
||||
.nav-section { font-size:11px; text-transform:uppercase; color:var(--fg2); padding:16px 14px 8px; letter-spacing:1px; }
|
||||
.sidebar-footer { padding:16px; border-top:1px solid var(--border); font-size:12px; color:var(--fg2); }
|
||||
.sidebar-footer button { width:100%; padding:8px; margin-top:8px; background:var(--bg3); border:1px solid var(--border); color:var(--fg2); border-radius:var(--radius); cursor:pointer; }
|
||||
|
||||
.main { flex:1; margin-left:260px; min-height:100vh; }
|
||||
.topbar { display:flex; align-items:center; justify-content:space-between; padding:16px 24px; background:var(--bg2); border-bottom:1px solid var(--border); position:sticky; top:0; z-index:50; }
|
||||
.topbar h1 { font-size:20px; font-weight:500; }
|
||||
.topbar-right { display:flex; align-items:center; gap:16px; }
|
||||
.topbar .badge { padding:4px 12px; border-radius:20px; font-size:12px; font-weight:500; }
|
||||
.badge-admin { background:var(--accent); color:var(--bg); }
|
||||
.badge-super { background:var(--purple); color:var(--bg); }
|
||||
.badge-mod { background:var(--yellow); color:var(--bg); }
|
||||
|
||||
.content { padding:24px; }
|
||||
|
||||
/* Cards */
|
||||
.card-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:16px; margin-bottom:24px; }
|
||||
.stat-card { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:20px; }
|
||||
.stat-card h3 { font-size:12px; color:var(--fg2); text-transform:uppercase; letter-spacing:1px; margin-bottom:8px; }
|
||||
.stat-card .value { font-size:32px; font-weight:700; color:var(--accent); }
|
||||
.stat-card .change { font-size:13px; color:var(--green); margin-top:4px; }
|
||||
.stat-card .change.down { color:var(--red); }
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; }
|
||||
.table-wrap table { width:100%; border-collapse:collapse; }
|
||||
.table-wrap th { text-align:left; padding:12px 16px; font-size:12px; text-transform:uppercase; color:var(--fg2); background:var(--bg3); border-bottom:1px solid var(--border); }
|
||||
.table-wrap td { padding:12px 16px; border-bottom:1px solid var(--border); font-size:14px; }
|
||||
.table-wrap tr:hover td { background:var(--bg3); }
|
||||
.table-wrap .status { padding:4px 10px; border-radius:12px; font-size:11px; font-weight:500; }
|
||||
.status-published { background:rgba(0,255,136,0.15); color:var(--green); }
|
||||
.status-draft { background:rgba(255,204,0,0.15); color:var(--yellow); }
|
||||
.status-archived { background:rgba(255,68,68,0.15); color:var(--red); }
|
||||
.status-review { background:rgba(0,212,255,0.15); color:var(--accent); }
|
||||
.status-scheduled { background:rgba(179,136,255,0.15); color:var(--purple); }
|
||||
|
||||
/* Buttons */
|
||||
.btn { padding:8px 16px; border-radius:var(--radius); border:none; font-size:13px; cursor:pointer; transition:opacity 0.2s; font-weight:500; }
|
||||
.btn-primary { background:var(--accent); color:var(--bg); }
|
||||
.btn-danger { background:var(--red); color:#fff; }
|
||||
.btn-secondary { background:var(--bg3); color:var(--fg); border:1px solid var(--border); }
|
||||
.btn-sm { padding:4px 12px; font-size:12px; }
|
||||
.btn:hover { opacity:0.85; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom:16px; }
|
||||
.form-group label { display:block; font-size:13px; color:var(--fg2); margin-bottom:6px; }
|
||||
.form-group input, .form-group textarea, .form-group select { width:100%; padding:10px 14px; background:var(--bg3); border:1px solid var(--border); border-radius:var(--radius); color:var(--fg); font-size:14px; font-family:inherit; }
|
||||
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { outline:none; border-color:var(--accent); }
|
||||
.form-group textarea { min-height:120px; resize:vertical; }
|
||||
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display:flex; gap:4px; margin-bottom:20px; border-bottom:1px solid var(--border); }
|
||||
.tab { padding:10px 20px; cursor:pointer; font-size:14px; color:var(--fg2); border-bottom:2px solid transparent; transition:all 0.2s; }
|
||||
.tab:hover { color:var(--fg); }
|
||||
.tab.active { color:var(--accent); border-bottom-color:var(--accent); }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display:flex; gap:12px; margin-bottom:20px; }
|
||||
.search-bar input { flex:1; padding:10px 16px; background:var(--bg3); border:1px solid var(--border); border-radius:var(--radius); color:var(--fg); }
|
||||
.search-bar input:focus { outline:none; border-color:var(--accent); }
|
||||
|
||||
/* Modals */
|
||||
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:200; align-items:center; justify-content:center; }
|
||||
.modal-overlay.active { display:flex; }
|
||||
.modal { background:var(--bg2); border:1px solid var(--border); border-radius:16px; width:90%; max-width:700px; max-height:90vh; overflow-y:auto; box-shadow:var(--shadow); }
|
||||
.modal-header { padding:20px 24px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; }
|
||||
.modal-header h2 { font-size:18px; }
|
||||
.modal-body { padding:24px; }
|
||||
.modal-footer { padding:16px 24px; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:12px; }
|
||||
|
||||
/* Charts placeholder */
|
||||
.chart-container { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:20px; height:300px; display:flex; align-items:center; justify-content:center; color:var(--fg2); }
|
||||
|
||||
/* Toast */
|
||||
.toast { position:fixed; bottom:24px; right:24px; padding:12px 20px; border-radius:var(--radius); color:#fff; font-size:14px; z-index:300; transform:translateY(100px); opacity:0; transition:all 0.3s; }
|
||||
.toast.show { transform:translateY(0); opacity:1; }
|
||||
.toast-success { background:var(--green); color:var(--bg); }
|
||||
.toast-error { background:var(--red); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width:768px) {
|
||||
.sidebar { width:60px; }
|
||||
.sidebar .nav-item span, .sidebar-header span, .nav-section { display:none; }
|
||||
.main { margin-left:60px; }
|
||||
.form-row { grid-template-columns:1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- LOGIN SCREEN -->
|
||||
<div class="login-screen" id="loginScreen">
|
||||
<div class="login-box">
|
||||
<h1>Darkroom</h1>
|
||||
<p>RugMunch Intelligence — Admin Control Panel</p>
|
||||
<input type="email" id="loginEmail" placeholder="Admin email" value="">
|
||||
<input type="password" id="loginPassword" placeholder="Password">
|
||||
<input type="text" id="loginTOTP" placeholder="2FA code (if enabled)" style="display:none;">
|
||||
<button onclick="doLogin()">Sign In</button>
|
||||
<div class="error" id="loginError"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- APP -->
|
||||
<div class="app" id="app">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Darkroom</h2>
|
||||
<span id="adminRoleBadge">ADMIN</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section">Overview</div>
|
||||
<div class="nav-item active" onclick="navTo('dashboard')" data-page="dashboard">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
<span>Dashboard</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('system')" data-page="system">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span>System Health</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('analytics')" data-page="analytics">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>
|
||||
<span>Analytics</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">Users & Security</div>
|
||||
<div class="nav-item" onclick="navTo('users')" data-page="users">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></svg>
|
||||
<span>User Management</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('security')" data-page="security">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<span>Security Center</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('audit')" data-page="audit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<span>Audit Logs</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('admins')" data-page="admins">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<span>Admin Accounts</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">Content</div>
|
||||
<div class="nav-item" onclick="navTo('bulletin')" data-page="bulletin">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><path d="M22 6l-10 7L2 6"/></svg>
|
||||
<span>Bulletin Board</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('announcements')" data-page="announcements">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span>Announcements</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">Platform</div>
|
||||
<div class="nav-item" onclick="navTo('tokens')" data-page="tokens">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<span>Token Deployer</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('airdrop')" data-page="airdrop">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||
<span>Airdrop Manager</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('financial')" data-page="financial">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
<span>Financial</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('apikeys')" data-page="apikeys">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
<span>API Keys</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('webhooks')" data-page="webhooks">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span>Webhooks</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="navTo('config')" data-page="config">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.68 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
<span>Configuration</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div id="adminEmail">admin@rugmunch.io</div>
|
||||
<button onclick="doLogout()">Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<h1 id="pageTitle">Dashboard</h1>
|
||||
<div class="topbar-right">
|
||||
<span class="badge badge-admin" id="roleBadge">ADMIN</span>
|
||||
<span id="currentTime" style="font-size:13px;color:var(--fg2);"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content" id="mainContent">
|
||||
<!-- Content injected by JS -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- MODALS -->
|
||||
<div class="modal-overlay" id="modalOverlay">
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Modal</h2>
|
||||
<button class="btn btn-secondary btn-sm" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
<div class="modal-footer" id="modalFooter"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOAST -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
let session = null;
|
||||
let admin = null;
|
||||
let currentPage = 'dashboard';
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────
|
||||
async function doLogin() {
|
||||
const email = document.getElementById('loginEmail').value;
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
const totp = document.getElementById('loginTOTP').value;
|
||||
const errorEl = document.getElementById('loginError');
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({email, password, totp_code: totp || undefined})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Login failed');
|
||||
|
||||
session = data.session_id;
|
||||
admin = data.admin;
|
||||
localStorage.setItem('admin_session', session);
|
||||
showApp();
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
localStorage.removeItem('admin_session');
|
||||
session = null;
|
||||
admin = null;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
const saved = localStorage.getItem('admin_session');
|
||||
if (!saved) return;
|
||||
session = saved;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/auth/me`, {
|
||||
headers: {'X-Admin-Session': session}
|
||||
});
|
||||
if (!res.ok) throw new Error('Session expired');
|
||||
const data = await res.json();
|
||||
admin = data.admin;
|
||||
showApp();
|
||||
} catch (e) {
|
||||
localStorage.removeItem('admin_session');
|
||||
}
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById('loginScreen').style.display = 'none';
|
||||
document.getElementById('app').classList.add('active');
|
||||
document.getElementById('adminEmail').textContent = admin?.email || 'Admin';
|
||||
document.getElementById('roleBadge').textContent = (admin?.role || 'ADMIN').toUpperCase();
|
||||
document.getElementById('adminRoleBadge').textContent = (admin?.role || 'ADMIN').toUpperCase();
|
||||
navTo('dashboard');
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────
|
||||
function navTo(page) {
|
||||
currentPage = page;
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.querySelector(`[data-page="${page}"]`)?.classList.add('active');
|
||||
document.getElementById('pageTitle').textContent = page.charAt(0).toUpperCase() + page.slice(1);
|
||||
|
||||
const content = document.getElementById('mainContent');
|
||||
content.innerHTML = '';
|
||||
|
||||
switch(page) {
|
||||
case 'dashboard': renderDashboard(content); break;
|
||||
case 'system': renderSystem(content); break;
|
||||
case 'analytics': renderAnalytics(content); break;
|
||||
case 'users': renderUsers(content); break;
|
||||
case 'security': renderSecurity(content); break;
|
||||
case 'audit': renderAudit(content); break;
|
||||
case 'admins': renderAdmins(content); break;
|
||||
case 'bulletin': renderBulletin(content); break;
|
||||
case 'announcements': renderAnnouncements(content); break;
|
||||
case 'tokens': window.location.href = '/darkroom'; break;
|
||||
case 'airdrop': window.location.href = '/darkroom'; break;
|
||||
case 'financial': renderFinancial(content); break;
|
||||
case 'apikeys': renderAPIKeys(content); break;
|
||||
case 'webhooks': renderWebhooks(content); break;
|
||||
case 'config': renderConfig(content); break;
|
||||
default: renderDashboard(content);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────
|
||||
async function renderDashboard(el) {
|
||||
el.innerHTML = `
|
||||
<div class="card-grid">
|
||||
<div class="stat-card"><h3>Total Users</h3><div class="value" id="dashUsers">-</div><div class="change">+12% this week</div></div>
|
||||
<div class="stat-card"><h3>Active Sessions</h3><div class="value" id="dashSessions">-</div></div>
|
||||
<div class="stat-card"><h3>Blocked IPs</h3><div class="value" id="dashBlocked">-</div></div>
|
||||
<div class="stat-card"><h3>System Health</h3><div class="value" id="dashHealth" style="color:var(--green)">OK</div></div>
|
||||
<div class="stat-card"><h3>Token Deployments</h3><div class="value" id="dashTokens">-</div></div>
|
||||
<div class="stat-card"><h3>X402 Revenue</h3><div class="value" id="dashRevenue">$0</div></div>
|
||||
</div>
|
||||
<div class="chart-container">Charts & Analytics — Coming Soon</div>
|
||||
`;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/dashboard`, {headers:{'X-Admin-Session':session}});
|
||||
if(res.ok) {
|
||||
const data = await res.json();
|
||||
document.getElementById('dashUsers').textContent = data.stats?.total_users || 0;
|
||||
document.getElementById('dashSessions').textContent = data.stats?.active_sessions || 0;
|
||||
document.getElementById('dashBlocked').textContent = data.stats?.blocked_ips || 0;
|
||||
document.getElementById('dashTokens').textContent = data.stats?.total_deployments || 0;
|
||||
const health = data.health?.cpu?.percent || 0;
|
||||
document.getElementById('dashHealth').textContent = health > 80 ? 'HIGH' : 'OK';
|
||||
document.getElementById('dashHealth').style.color = health > 80 ? 'var(--red)' : 'var(--green)';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── System Health ────────────────────────────────────────────
|
||||
async function renderSystem(el) {
|
||||
el.innerHTML = `<div class="chart-container">Loading system health...</div>`;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/system/health`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
el.innerHTML = `
|
||||
<div class="card-grid">
|
||||
<div class="stat-card"><h3>CPU Usage</h3><div class="value">${data.cpu?.percent || 0}%</div><div class="change">${data.cpu?.count} cores</div></div>
|
||||
<div class="stat-card"><h3>Memory</h3><div class="value">${data.memory?.percent || 0}%</div><div class="change">${data.memory?.used_gb}GB / ${data.memory?.total_gb}GB</div></div>
|
||||
<div class="stat-card"><h3>Disk</h3><div class="value">${data.disk?.percent || 0}%</div><div class="change">${data.disk?.free_gb}GB free</div></div>
|
||||
<div class="stat-card"><h3>Backend Memory</h3><div class="value">${data.backend?.memory_mb || 0}MB</div><div class="change">${data.backend?.threads} threads</div></div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr><th>Service</th><th>Status</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Backend</td><td><span class="status status-published">Running</span></td><td>PID ${data.backend?.pid}</td></tr>
|
||||
<tr><td>Redis</td><td><span class="status ${data.services?.redis?.status==='connected'?'status-published':'status-archived'}">${data.services?.redis?.status}</span></td><td>${data.services?.redis?.version || ''}</td></tr>
|
||||
<tr><td>Supabase</td><td><span class="status ${data.services?.supabase?.status==='connected'?'status-published':'status-archived'}">${data.services?.supabase?.status}</span></td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} catch(e) { el.innerHTML = `<div class="error">Failed to load: ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────
|
||||
async function renderUsers(el) {
|
||||
el.innerHTML = `
|
||||
<div class="search-bar">
|
||||
<input type="text" id="userSearch" placeholder="Search users..." onkeyup="if(event.key==='Enter')loadUsers()">
|
||||
<button class="btn btn-primary" onclick="loadUsers()">Search</button>
|
||||
</div>
|
||||
<div class="table-wrap" id="usersTable"><table><thead><tr><th>Email</th><th>Tier</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead><tbody><tr><td colspan="5" style="text-align:center;color:var(--fg2)">Loading...</td></tr></tbody></table></div>
|
||||
`;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const search = document.getElementById('userSearch')?.value || '';
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/users?limit=100&search=${encodeURIComponent(search)}`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.users?.map(u => `
|
||||
<tr>
|
||||
<td>${u.email}</td>
|
||||
<td><span class="status ${u.tier==='FREE'?'status-draft':'status-published'}">${u.tier}</span></td>
|
||||
<td>${u.role || 'USER'}</td>
|
||||
<td>${new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary" onclick="banUser('${u.id}', ${!u.banned})">${u.banned ? 'Unban' : 'Ban'}</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="showUserTier('${u.id}', '${u.tier}')">Tier</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="5" style="text-align:center">No users</td></tr>';
|
||||
document.getElementById('usersTable').innerHTML = `<table><thead><tr><th>Email</th><th>Tier</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function banUser(userId, banned) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/backend/users/${userId}/ban`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({banned})
|
||||
});
|
||||
showToast(banned ? 'User banned' : 'User unbanned', 'success');
|
||||
loadUsers();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Security ─────────────────────────────────────────────────
|
||||
async function renderSecurity(el) {
|
||||
el.innerHTML = `
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="showSecurityTab('blocked')">Blocked IPs</div>
|
||||
<div class="tab" onclick="showSecurityTab('threats')">Threats</div>
|
||||
</div>
|
||||
<div id="securityContent">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="blockIP" placeholder="IP to block">
|
||||
<input type="text" id="blockReason" placeholder="Reason" style="flex:2">
|
||||
<button class="btn btn-danger" onclick="blockIP()">Block IP</button>
|
||||
</div>
|
||||
<div class="table-wrap" id="blockedTable">Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
loadBlockedIPs();
|
||||
}
|
||||
|
||||
async function loadBlockedIPs() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/security/blocked-ips`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.blocked_ips?.map(ip => `
|
||||
<tr><td>${ip.ip}</td><td>${ip.reason}</td><td>${new Date(ip.blocked_at).toLocaleString()}</td><td>${ip.duration_hours}h</td>
|
||||
<td><button class="btn btn-sm btn-secondary" onclick="unblockIP('${ip.ip}')">Unblock</button></td></tr>
|
||||
`).join('') || '<tr><td colspan="5" style="text-align:center">No blocked IPs</td></tr>';
|
||||
document.getElementById('blockedTable').innerHTML = `<table><thead><tr><th>IP</th><th>Reason</th><th>Blocked At</th><th>Duration</th><th>Actions</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function blockIP() {
|
||||
const ip = document.getElementById('blockIP').value;
|
||||
const reason = document.getElementById('blockReason').value;
|
||||
if(!ip) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/backend/security/block-ip`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ip, reason, duration_hours: 24})
|
||||
});
|
||||
showToast('IP blocked', 'success');
|
||||
loadBlockedIPs();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unblockIP(ip) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/backend/security/unblock-ip`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ip})
|
||||
});
|
||||
showToast('IP unblocked', 'success');
|
||||
loadBlockedIPs();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function showSecurityTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
if(tab === 'blocked') loadBlockedIPs();
|
||||
}
|
||||
|
||||
// ── Audit Logs ───────────────────────────────────────────────
|
||||
async function renderAudit(el) {
|
||||
el.innerHTML = `
|
||||
<div class="search-bar">
|
||||
<input type="text" id="auditAction" placeholder="Action filter">
|
||||
<button class="btn btn-primary" onclick="loadAudit()">Refresh</button>
|
||||
</div>
|
||||
<div class="table-wrap" id="auditTable">Loading...</div>
|
||||
`;
|
||||
loadAudit();
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/security/audit-logs?limit=100`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.logs?.map(l => `
|
||||
<tr><td>${new Date(l.timestamp).toLocaleString()}</td><td>${l.admin_email}</td><td>${l.action}</td>
|
||||
<td>${l.resource_type}</td><td><span class="status ${l.status==='success'?'status-published':'status-archived'}">${l.status}</span></td></tr>
|
||||
`).join('') || '<tr><td colspan="5" style="text-align:center">No logs</td></tr>';
|
||||
document.getElementById('auditTable').innerHTML = `<table><thead><tr><th>Time</th><th>Admin</th><th>Action</th><th>Resource</th><th>Status</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Admins ───────────────────────────────────────────────────
|
||||
async function renderAdmins(el) {
|
||||
el.innerHTML = `
|
||||
<div style="margin-bottom:16px"><button class="btn btn-primary" onclick="showCreateAdmin()">+ Create Admin</button></div>
|
||||
<div class="table-wrap" id="adminsTable">Loading...</div>
|
||||
`;
|
||||
loadAdmins();
|
||||
}
|
||||
|
||||
async function loadAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/admins`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.admins?.map(a => `
|
||||
<tr><td>${a.email}</td><td><span class="badge badge-${a.role}">${a.role}</span></td>
|
||||
<td>${a.is_active ? 'Active' : 'Inactive'}</td><td>${a.last_login ? new Date(a.last_login).toLocaleString() : 'Never'}</td>
|
||||
<td><button class="btn btn-sm btn-secondary" onclick="resetAdminPassword('${a.id}')">Reset PW</button></td></tr>
|
||||
`).join('') || '<tr><td colspan="5" style="text-align:center">No admins</td></tr>';
|
||||
document.getElementById('adminsTable').innerHTML = `<table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th>Actions</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function showCreateAdmin() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Admin';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="form-group"><label>Email</label><input type="email" id="newAdminEmail"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="newAdminPassword"></div>
|
||||
<div class="form-group"><label>Role</label><select id="newAdminRole"><option value="viewer">Viewer</option><option value="moderator">Moderator</option><option value="admin">Admin</option><option value="superadmin">Superadmin</option></select></div>
|
||||
`;
|
||||
document.getElementById('modalFooter').innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="createAdmin()">Create</button>
|
||||
`;
|
||||
document.getElementById('modalOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
async function createAdmin() {
|
||||
const email = document.getElementById('newAdminEmail').value;
|
||||
const password = document.getElementById('newAdminPassword').value;
|
||||
const role = document.getElementById('newAdminRole').value;
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/backend/admins`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({email, password, role})
|
||||
});
|
||||
closeModal();
|
||||
showToast('Admin created', 'success');
|
||||
loadAdmins();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── BULLETIN BOARD ───────────────────────────────────────────
|
||||
async function renderBulletin(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="showBulletinTab('all')">All Posts</div>
|
||||
<div class="tab" onclick="showBulletinTab('published')">Published</div>
|
||||
<div class="tab" onclick="showBulletinTab('draft')">Drafts</div>
|
||||
<div class="tab" onclick="showBulletinTab('scheduled')">Scheduled</div>
|
||||
<div class="tab" onclick="showBulletinTab('archived')">Archived</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="showCreatePost()">+ New Post</button>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="postSearch" placeholder="Search posts..." onkeyup="if(event.key==='Enter')loadPosts()">
|
||||
<select id="postCategory" onchange="loadPosts()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="news">News</option>
|
||||
<option value="alert">Alert</option>
|
||||
<option value="update">Update</option>
|
||||
<option value="promo">Promo</option>
|
||||
<option value="system">System</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="announcement">Announcement</option>
|
||||
<option value="tutorial">Tutorial</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="loadPosts()">Search</button>
|
||||
</div>
|
||||
<div class="table-wrap" id="postsTable">Loading...</div>
|
||||
`;
|
||||
loadPosts();
|
||||
}
|
||||
|
||||
let currentBulletinTab = 'all';
|
||||
|
||||
function showBulletinTab(tab) {
|
||||
currentBulletinTab = tab;
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
loadPosts();
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
const search = document.getElementById('postSearch')?.value || '';
|
||||
const category = document.getElementById('postCategory')?.value || '';
|
||||
const status = currentBulletinTab === 'all' ? '' : currentBulletinTab;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/bulletin/posts?limit=50&search=${encodeURIComponent(search)}&category=${category}&status=${status}&sort_by=created_at&sort_order=desc`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.posts?.map(p => `
|
||||
<tr>
|
||||
<td><strong>${p.title}</strong><br><small style="color:var(--fg2)">${p.summary?.substring(0,80)}...</small></td>
|
||||
<td><span class="status status-${p.status}">${p.status}</span></td>
|
||||
<td><span class="badge" style="background:var(--bg3);color:var(--fg2)">${p.category}</span></td>
|
||||
<td>${p.pinned ? 'Pinned' : '-'}</td>
|
||||
<td>${p.author_name}</td>
|
||||
<td>${new Date(p.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
${p.status === 'draft' || p.status === 'review' || p.status === 'scheduled' ? `<button class="btn btn-sm btn-primary" onclick="publishPost('${p.post_id}')">Publish</button>` : ''}
|
||||
<button class="btn btn-sm btn-secondary" onclick="editPost('${p.post_id}')">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deletePost('${p.post_id}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="7" style="text-align:center">No posts</td></tr>';
|
||||
document.getElementById('postsTable').innerHTML = `<table><thead><tr><th>Title</th><th>Status</th><th>Category</th><th>Pin</th><th>Author</th><th>Date</th><th>Actions</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) { document.getElementById('postsTable').innerHTML = `<div class="error">Error: ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
function showCreatePost() {
|
||||
document.getElementById('modalTitle').textContent = 'New Bulletin Post';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="form-group"><label>Title</label><input type="text" id="postTitle" maxlength="200"></div>
|
||||
<div class="form-group"><label>Content</label><textarea id="postContent"></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Category</label><select id="postCat"><option value="news">News</option><option value="alert">Alert</option><option value="update">Update</option><option value="promo">Promo</option><option value="system">System</option><option value="community">Community</option><option value="announcement">Announcement</option><option value="tutorial">Tutorial</option></select></div>
|
||||
<div class="form-group"><label>Priority</label><select id="postPriority"><option value="low">Low</option><option value="normal" selected>Normal</option><option value="high">High</option><option value="critical">Critical</option></select></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Status</label><select id="postStatus"><option value="draft" selected>Draft</option><option value="published">Published</option><option value="scheduled">Scheduled</option></select></div>
|
||||
<div class="form-group"><label>Target Audience</label><select id="postAudience"><option value="all">All</option><option value="free">Free</option><option value="premium">Premium</option><option value="pro">Pro</option><option value="enterprise">Enterprise</option><option value="admins">Admins</option></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Tags (comma separated)</label><input type="text" id="postTags"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Schedule At (optional)</label><input type="datetime-local" id="postSchedule"></div>
|
||||
<div class="form-group"><label>Expires At (optional)</label><input type="datetime-local" id="postExpires"></div>
|
||||
</div>
|
||||
<div class="form-group"><label><input type="checkbox" id="postPinned"> Pin this post</label></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="postComments"> Allow comments</label></div>
|
||||
`;
|
||||
document.getElementById('modalFooter').innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="createPost()">Create Post</button>
|
||||
`;
|
||||
document.getElementById('modalOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
async function createPost() {
|
||||
const body = {
|
||||
title: document.getElementById('postTitle').value,
|
||||
content: document.getElementById('postContent').value,
|
||||
category: document.getElementById('postCat').value,
|
||||
priority: document.getElementById('postPriority').value,
|
||||
target_audience: document.getElementById('postAudience').value,
|
||||
status: document.getElementById('postStatus').value,
|
||||
tags: document.getElementById('postTags').value.split(',').map(t => t.trim()).filter(t => t),
|
||||
scheduled_at: document.getElementById('postSchedule').value || null,
|
||||
expires_at: document.getElementById('postExpires').value || null,
|
||||
pinned: document.getElementById('postPinned').checked,
|
||||
allow_comments: document.getElementById('postComments').checked,
|
||||
};
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/bulletin/posts`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
closeModal();
|
||||
showToast('Post created', 'success');
|
||||
loadPosts();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function publishPost(postId) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/bulletin/posts/${postId}/publish`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session}
|
||||
});
|
||||
showToast('Post published', 'success');
|
||||
loadPosts();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePost(postId) {
|
||||
if(!confirm('Archive this post?')) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/bulletin/posts/${postId}`, {
|
||||
method: 'DELETE', headers: {'X-Admin-Session': session}
|
||||
});
|
||||
showToast('Post archived', 'success');
|
||||
loadPosts();
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Announcements (simplified) ────────────────────────────────
|
||||
async function renderAnnouncements(el) {
|
||||
el.innerHTML = `
|
||||
<div style="margin-bottom:16px"><button class="btn btn-primary" onclick="showCreateAnnouncement()">+ New Announcement</button></div>
|
||||
<div class="table-wrap" id="announcementsTable">Loading...</div>
|
||||
`;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/content/announcements?active_only=false`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.announcements?.map(a => `
|
||||
<tr><td><strong>${a.title}</strong></td><td>${a.type}</td><td>${a.target_audience}</td>
|
||||
<td>${new Date(a.created_at).toLocaleDateString()}</td><td>${a.active ? 'Active' : 'Inactive'}</td></tr>
|
||||
`).join('') || '<tr><td colspan="5" style="text-align:center">No announcements</td></tr>';
|
||||
document.getElementById('announcementsTable').innerHTML = `<table><thead><tr><th>Title</th><th>Type</th><th>Audience</th><th>Created</th><th>Status</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function showCreateAnnouncement() {
|
||||
document.getElementById('modalTitle').textContent = 'New Announcement';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="form-group"><label>Title</label><input type="text" id="annTitle"></div>
|
||||
<div class="form-group"><label>Content</label><textarea id="annContent"></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Type</label><select id="annType"><option value="info">Info</option><option value="warning">Warning</option><option value="critical">Critical</option><option value="update">Update</option></select></div>
|
||||
<div class="form-group"><label>Audience</label><select id="annAudience"><option value="all">All</option><option value="users">Users</option><option value="admins">Admins</option><option value="premium">Premium</option></select></div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('modalFooter').innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="createAnnouncement()">Create</button>
|
||||
`;
|
||||
document.getElementById('modalOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
async function createAnnouncement() {
|
||||
const body = {
|
||||
title: document.getElementById('annTitle').value,
|
||||
content: document.getElementById('annContent').value,
|
||||
type: document.getElementById('annType').value,
|
||||
target_audience: document.getElementById('annAudience').value,
|
||||
};
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/backend/content/announcements`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
closeModal();
|
||||
showToast('Announcement created', 'success');
|
||||
navTo('announcements');
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Financial ────────────────────────────────────────────────
|
||||
async function renderFinancial(el) {
|
||||
el.innerHTML = `
|
||||
<div class="card-grid">
|
||||
<div class="stat-card"><h3>X402 Revenue (30d)</h3><div class="value">$0</div></div>
|
||||
<div class="stat-card"><h3>Subscriptions</h3><div class="value">$0</div></div>
|
||||
<div class="stat-card"><h3>API Usage</h3><div class="value">$0</div></div>
|
||||
<div class="stat-card"><h3>Total Revenue</h3><div class="value">$0</div></div>
|
||||
</div>
|
||||
<div class="chart-container">Financial charts — Coming Soon</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── API Keys ────────────────────────────────────────────────
|
||||
async function renderAPIKeys(el) {
|
||||
el.innerHTML = `
|
||||
<div style="margin-bottom:16px"><button class="btn btn-primary" onclick="showCreateAPIKey()">+ New API Key</button></div>
|
||||
<div class="table-wrap" id="apiKeysTable">Loading...</div>
|
||||
`;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/api-keys`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.api_keys?.map(k => `
|
||||
<tr><td>${k.name}</td><td><code>${k.key}</code></td><td>${k.scopes?.join(', ') || 'all'}</td>
|
||||
<td>${k.active ? 'Active' : 'Revoked'}</td><td>${new Date(k.created_at).toLocaleDateString()}</td>
|
||||
<td><button class="btn btn-sm btn-danger" onclick="revokeKey('${k.id}')">Revoke</button></td></tr>
|
||||
`).join('') || '<tr><td colspan="6" style="text-align:center">No API keys</td></tr>';
|
||||
document.getElementById('apiKeysTable').innerHTML = `<table><thead><tr><th>Name</th><th>Key</th><th>Scopes</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function showCreateAPIKey() {
|
||||
document.getElementById('modalTitle').textContent = 'New API Key';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="form-group"><label>Name</label><input type="text" id="keyName"></div>
|
||||
<div class="form-group"><label>Scopes (comma separated)</label><input type="text" id="keyScopes" placeholder="read, write, deploy"></div>
|
||||
<div class="form-group"><label>Expires (days)</label><input type="number" id="keyExpires" value="30"></div>
|
||||
`;
|
||||
document.getElementById('modalFooter').innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="createAPIKey()">Create</button>
|
||||
`;
|
||||
document.getElementById('modalOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
async function createAPIKey() {
|
||||
const body = {
|
||||
name: document.getElementById('keyName').value,
|
||||
scopes: document.getElementById('keyScopes').value.split(',').map(s => s.trim()).filter(s => s),
|
||||
expires_days: parseInt(document.getElementById('keyExpires').value) || 30,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/api-keys`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
closeModal();
|
||||
if(data.api_key) {
|
||||
document.getElementById('modalTitle').textContent = 'API Key Created';
|
||||
document.getElementById('modalBody').innerHTML = `<div style="background:var(--bg3);padding:16px;border-radius:8px;"><p style="color:var(--fg2);margin-bottom:8px;">Copy this key now — it won't be shown again:</p><code style="font-size:16px;word-break:break-all;">${data.api_key}</code></div>`;
|
||||
document.getElementById('modalFooter').innerHTML = `<button class="btn btn-primary" onclick="closeModal();navTo('apikeys');">Done</button>`;
|
||||
document.getElementById('modalOverlay').classList.add('active');
|
||||
}
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function revokeKey(keyId) {
|
||||
if(!confirm('Revoke this API key?')) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/backend/api-keys/${keyId}/revoke`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session}
|
||||
});
|
||||
showToast('Key revoked', 'success');
|
||||
navTo('apikeys');
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Webhooks ─────────────────────────────────────────────────
|
||||
async function renderWebhooks(el) {
|
||||
el.innerHTML = `
|
||||
<div style="margin-bottom:16px"><button class="btn btn-primary" onclick="showCreateWebhook()">+ New Webhook</button></div>
|
||||
<div class="table-wrap" id="webhooksTable">Loading...</div>
|
||||
`;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/backend/webhooks`, {headers:{'X-Admin-Session':session}});
|
||||
const data = await res.json();
|
||||
const tbody = data.webhooks?.map(w => `
|
||||
<tr><td>${w.url}</td><td>${w.events?.join(', ')}</td><td>${w.active ? 'Active' : 'Inactive'}</td>
|
||||
<td>${new Date(w.created_at).toLocaleDateString()}</td></tr>
|
||||
`).join('') || '<tr><td colspan="4" style="text-align:center">No webhooks</td></tr>';
|
||||
document.getElementById('webhooksTable').innerHTML = `<table><thead><tr><th>URL</th><th>Events</th><th>Status</th><th>Created</th></tr></thead><tbody>${tbody}</tbody></table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function showCreateWebhook() {
|
||||
document.getElementById('modalTitle').textContent = 'New Webhook';
|
||||
document.getElementById('modalBody').innerHTML = `
|
||||
<div class="form-group"><label>URL</label><input type="url" id="whUrl"></div>
|
||||
<div class="form-group"><label>Events (comma separated)</label><input type="text" id="whEvents" placeholder="token.deploy, user.signup, payment.received"></div>
|
||||
<div class="form-group"><label>Secret</label><input type="text" id="whSecret"></div>
|
||||
`;
|
||||
document.getElementById('modalFooter').innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="createWebhook()">Create</button>
|
||||
`;
|
||||
document.getElementById('modalOverlay').classList.add('active');
|
||||
}
|
||||
|
||||
async function createWebhook() {
|
||||
const body = {
|
||||
url: document.getElementById('whUrl').value,
|
||||
events: document.getElementById('whEvents').value.split(',').map(s => s.trim()).filter(s => s),
|
||||
secret: document.getElementById('whSecret').value,
|
||||
};
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/backend/webhooks`, {
|
||||
method: 'POST', headers: {'X-Admin-Session': session, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
closeModal();
|
||||
showToast('Webhook created', 'success');
|
||||
navTo('webhooks');
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────
|
||||
async function renderConfig(el) {
|
||||
el.innerHTML = `
|
||||
<div class="table-wrap">
|
||||
<table><thead><tr><th>Key</th><th>Value</th><th>Category</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>ENVIRONMENT</td><td>production</td><td>general</td><td><button class="btn btn-sm btn-secondary">Edit</button></td></tr>
|
||||
<tr><td>BACKEND_PORT</td><td>8000</td><td>general</td><td><button class="btn btn-sm btn-secondary">Edit</button></td></tr>
|
||||
<tr><td>REDIS_HOST</td><td>localhost</td><td>database</td><td><button class="btn btn-sm btn-secondary">Edit</button></td></tr>
|
||||
<tr><td>X402_ENABLED</td><td>true</td><td>payments</td><td><button class="btn btn-sm btn-secondary">Edit</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────
|
||||
function closeModal() {
|
||||
document.getElementById('modalOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
function showToast(msg, type='success') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast toast-${type} show`;
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
function updateTime() {
|
||||
document.getElementById('currentTime').textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
setInterval(updateTime, 1000);
|
||||
updateTime();
|
||||
|
||||
// Init
|
||||
checkSession();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
110
static/earnings.html
Normal file
110
static/earnings.html
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RMI Earnings Dashboard</title>
|
||||
<style>
|
||||
:root{--bg:#0a0a0f;--fg:#e0e0e0;--accent:#6c5ce7;--green:#27ae60;--card:#14141f;--border:#2a2a3a;--muted:#888}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--fg);font-family:system-ui,sans-serif}
|
||||
header{background:var(--card);border-bottom:1px solid var(--border);padding:16px 24px;display:flex;align-items:center;gap:16px}
|
||||
.logo{font-size:20px;font-weight:700;color:var(--accent)}
|
||||
.badge{background:var(--accent);color:#fff;padding:2px 8px;border-radius:12px;font-size:11px}
|
||||
main{max-width:1100px;margin:0 auto;padding:24px}
|
||||
h1{font-size:24px;margin-bottom:8px}
|
||||
.sub{color:var(--muted);margin-bottom:24px;font-size:14px}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;margin-bottom:24px}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px}
|
||||
.card-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
|
||||
.card-value{font-size:28px;font-weight:700}
|
||||
.card-value.green{color:var(--green)}
|
||||
.card-sub{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.table-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;margin-bottom:16px}
|
||||
.table-card h3{font-size:14px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;color:var(--muted);font-weight:500;padding:8px 12px;border-bottom:1px solid var(--border);font-size:11px;text-transform:uppercase}
|
||||
td{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,0.03)}
|
||||
tr:hover{background:rgba(255,255,255,0.02)}
|
||||
.addr{font-family:monospace;font-size:11px;color:var(--accent)}
|
||||
.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.refresh{background:var(--accent);color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px}
|
||||
.refresh:hover{opacity:.9}
|
||||
.bar{height:6px;background:var(--accent);border-radius:3px;margin-top:4px}
|
||||
.bar-bg{background:#1a1a2e;border-radius:3px}
|
||||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><span class="logo">RMI</span><span>Earnings Dashboard</span><span class="badge">Revenue</span></header>
|
||||
<main>
|
||||
<h1>Revenue Dashboard</h1>
|
||||
<p class="sub">x402 payment tracking across all chains and facilitators. <button class="refresh" onclick="load()">Refresh</button></p>
|
||||
|
||||
<div class="cards" id="summary"></div>
|
||||
<div class="row2">
|
||||
<div class="table-card"><h3>Wallets</h3><div id="wallets"></div></div>
|
||||
<div class="table-card"><h3>By Chain</h3><div id="byChain"></div></div>
|
||||
</div>
|
||||
<div class="table-card"><h3>Top Tools</h3><div id="byTool"></div></div>
|
||||
<div class="table-card"><h3>Recent Activity</h3><div id="recent"></div></div>
|
||||
</main>
|
||||
<script>
|
||||
async function load(){
|
||||
document.getElementById('summary').innerHTML='<div class="card"><span class="spinner"></span> Loading...</div>';
|
||||
try{
|
||||
const r=await fetch('/mcp/earnings');
|
||||
const d=await r.json();
|
||||
const w=d.wallet_balances||{};
|
||||
const s=d.revenue_by_source||{};
|
||||
|
||||
// Summary cards
|
||||
document.getElementById('summary').innerHTML=`
|
||||
<div class="card"><div class="card-label">Total Tracked</div><div class="card-value green">$${(w.total_usd||0).toFixed(2)}</div><div class="card-sub">Across all payment wallets</div></div>
|
||||
<div class="card"><div class="card-label">Revenue Events</div><div class="card-value">${s.events||0}</div><div class="card-sub">Tracked tool calls</div></div>
|
||||
<div class="card"><div class="card-label">Revenue Total</div><div class="card-value green">$${(s.total||0).toFixed(4)}</div><div class="card-sub">From tracked events</div></div>
|
||||
<div class="card"><div class="card-label">Payment Wallets</div><div class="card-value">${Object.keys(w).filter(k=>k!=='total_usd'&&k!=='updated').length}</div><div class="card-sub">${Object.keys(w).filter(k=>k!=='total_usd'&&k!=='updated').join(', ')}</div></div>`;
|
||||
|
||||
// Wallets table
|
||||
let wh='<table><tr><th>Chain</th><th>Address</th><th>Balance</th><th>USD</th><th>TXs</th></tr>';
|
||||
for(const [k,v] of Object.entries(w)){
|
||||
if(k==='total_usd'||k==='updated') continue;
|
||||
wh+=`<tr><td>${v.chain}</td><td class="addr">${v.address}</td><td>${v.balance?.toFixed(4)||0}</td><td>$${v.balance_usd?.toFixed(2)||0}</td><td>${v.transactions||0}</td></tr>`;
|
||||
}
|
||||
wh+='</table>';
|
||||
document.getElementById('wallets').innerHTML=wh;
|
||||
|
||||
// By chain
|
||||
let ch='<table><tr><th>Chain</th><th>Revenue</th><th>Share</th></tr>';
|
||||
for(const [k,v] of Object.entries(s.by_chain||{})){
|
||||
const pct=s.total?((v/s.total)*100).toFixed(1):'0';
|
||||
ch+=`<tr><td>${k}</td><td>$${v.toFixed(4)}</td><td><div class="bar-bg"><div class="bar" style="width:${pct}%"></div></div>${pct}%</td></tr>`;
|
||||
}
|
||||
ch+='</table>';
|
||||
document.getElementById('byChain').innerHTML=ch;
|
||||
|
||||
// By tool
|
||||
let tt='<table><tr><th>Tool</th><th>Revenue</th><th>Share</th></tr>';
|
||||
for(const [k,v] of Object.entries(s.by_tool||{})){
|
||||
const pct=s.total?((v/s.total)*100).toFixed(1):'0';
|
||||
tt+=`<tr><td>${k}</td><td>$${v.toFixed(4)}</td><td><div class="bar-bg"><div class="bar" style="width:${pct}%"></div></div>${pct}%</td></tr>`;
|
||||
}
|
||||
tt+='</table>';
|
||||
document.getElementById('byTool').innerHTML=tt;
|
||||
|
||||
// Recent
|
||||
let rec='<table><tr><th>Time</th><th>Tool</th><th>Amount</th><th>Chain</th></tr>';
|
||||
for(const e of (d.earnings_history||[]).reverse()){
|
||||
rec+=`<tr><td>${e.timestamp?.slice(11,19)||''}</td><td>${e.tool}</td><td>$${e.amount_usd?.toFixed(4)}</td><td>${e.chain}</td></tr>`;
|
||||
}
|
||||
rec+='</table>';
|
||||
document.getElementById('recent').innerHTML=rec;
|
||||
}catch(e){
|
||||
document.getElementById('summary').innerHTML=`<div class="card"><div class="card-label">Error</div><div>${e.message}</div></div>`;
|
||||
}
|
||||
}
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
152
static/investigate.html
Normal file
152
static/investigate.html
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RMI Investigative Framework</title>
|
||||
<style>
|
||||
:root{--bg:#0a0a0f;--fg:#e0e0e0;--accent:#6c5ce7;--danger:#e74c3c;--card:#14141f;--border:#2a2a3a;--muted:#888;--success:#27ae60}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--fg);font-family:system-ui,sans-serif}
|
||||
header{background:var(--card);border-bottom:1px solid var(--border);padding:16px 24px;display:flex;align-items:center;gap:16px}
|
||||
.logo{font-size:20px;font-weight:700;color:var(--accent)}
|
||||
.badge{background:var(--accent);color:#fff;padding:2px 8px;border-radius:12px;font-size:11px}
|
||||
main{max-width:960px;margin:0 auto;padding:32px 24px}
|
||||
h1{font-size:28px;margin-bottom:8px}
|
||||
.sub{color:var(--muted);margin-bottom:24px}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;margin-bottom:24px}
|
||||
label{display:block;font-size:13px;color:var(--muted);margin-bottom:6px;font-weight:500}
|
||||
input,select{width:100%;padding:12px;background:#1a1a2e;border:1px solid var(--border);border-radius:8px;color:var(--fg);font-size:15px}
|
||||
.row{display:flex;gap:12px}.row>*{flex:1}
|
||||
button{padding:12px 28px;background:var(--accent);color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer}
|
||||
button:hover{opacity:.9}button:disabled{opacity:.4;cursor:not-allowed}
|
||||
.result{animation:fadeIn .3s ease}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||||
.tag{display:inline-block;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:600;margin:2px}
|
||||
.tag-cex{background:#27ae60;color:#fff}.tag-dex{background:#2980b9;color:#fff}
|
||||
.tag-mixer{background:#e74c3c;color:#fff}.tag-bridge{background:#8e44ad;color:#fff}
|
||||
.tag-eoa{background:#7f8c8d;color:#fff}.tag-contract{background:#d35400;color:#fff}
|
||||
.tag-solana{background:#9945FF;color:#fff}
|
||||
.hop{padding:12px;background:#1a1a2e;border-radius:8px;margin:8px 0;border-left:3px solid var(--accent)}
|
||||
.addr{font-family:monospace;font-size:13px;color:var(--accent);word-break:break-all}
|
||||
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;margin-right:8px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.stats{display:flex;gap:16px;flex-wrap:wrap}
|
||||
.stat{background:#1a1a2e;padding:12px 16px;border-radius:8px}
|
||||
.stat-value{font-size:20px;font-weight:700}.stat-label{font-size:11px;color:var(--muted)}
|
||||
.source-badge{font-size:10px;background:#2a2a3a;padding:2px 6px;border-radius:4px;margin-left:8px;color:var(--muted)}
|
||||
.fallback-notice{background:#1a1a0e;border:1px solid #f39c12;padding:12px;border-radius:8px;font-size:13px;color:#f39c12;margin-top:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><span class="logo">RMI</span><span>Investigative Framework</span><span class="badge">Solana + EVM</span></header>
|
||||
<main>
|
||||
<h1>Trace Crypto Funding Sources</h1>
|
||||
<p class="sub">Trace wallets across Solana and 9 EVM chains. Multi-provider fallback ensures you never run out of data.</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div><label>Wallet Address</label><input type="text" id="address" placeholder="0x... or Solana address" value="vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg"></div>
|
||||
<div style="max-width:220px">
|
||||
<label>Chain</label>
|
||||
<select id="chain" onchange="updatePlaceholder()">
|
||||
<option value="solana">Solana</option>
|
||||
<option value="ethereum">Ethereum</option><option value="bsc">BSC</option>
|
||||
<option value="polygon">Polygon</option><option value="base">Base</option>
|
||||
<option value="arbitrum">Arbitrum</option><option value="optimism">Optimism</option>
|
||||
<option value="avalanche">Avalanche</option><option value="fantom">Fantom</option><option value="gnosis">Gnosis</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:12px">
|
||||
<button onclick="trace()" id="traceBtn">Trace Funding</button>
|
||||
<button onclick="scan()" id="scanBtn" style="background:#2a2a3a">Full Investigation</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="result"></div><div id="error"></div>
|
||||
</main>
|
||||
<script>
|
||||
function updatePlaceholder(){
|
||||
const chain=document.getElementById('chain').value;
|
||||
const inp=document.getElementById('address');
|
||||
if(chain==='solana'){inp.placeholder='Solana address...';if(!inp.value.startsWith('0x'))inp.value=''}
|
||||
else{inp.placeholder='0x...'}
|
||||
}
|
||||
|
||||
async function api(path,body){
|
||||
const btns=document.querySelectorAll('button');btns.forEach(b=>b.disabled=true);
|
||||
document.getElementById('error').innerHTML='';
|
||||
document.getElementById('result').innerHTML='<p><span class="spinner"></span> Querying all providers...</p>';
|
||||
try{
|
||||
const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const d=await r.json();
|
||||
if(!r.ok)throw new Error(d.detail||'Request failed');
|
||||
return d;
|
||||
}catch(e){
|
||||
document.getElementById('error').innerHTML=`<div style="background:#2c1010;border:1px solid var(--danger);border-radius:8px;padding:16px;color:#e74c3c">${e.message}</div>`;
|
||||
document.getElementById('result').innerHTML='';
|
||||
}finally{btns.forEach(b=>b.disabled=false)}
|
||||
}
|
||||
|
||||
function tagClass(t){const m={cex:'tag-cex',dex:'tag-dex',mixer:'tag-mixer',bridge:'tag-bridge',eoa:'tag-eoa',contract:'tag-contract'};return m[t]||''}
|
||||
|
||||
async function trace(){
|
||||
const chain=document.getElementById('chain').value;
|
||||
const data=await api('/api/v1/investigate/trace',{address:document.getElementById('address').value.trim(),chain});
|
||||
if(!data)return;
|
||||
|
||||
if(chain==='solana'){
|
||||
document.getElementById('result').innerHTML=`
|
||||
<div class="card result"><h3>Solana Wallet Trace</h3>
|
||||
<div class="stats" style="margin-top:12px">
|
||||
<div class="stat"><div class="stat-value">${data.first_tx?data.first_tx.slice(0,10)+'...':'N/A'}</div><div class="stat-label">First TX</div></div>
|
||||
<div class="stat"><div class="stat-value">${(data.balance_change_sol||0).toFixed(4)}</div><div class="stat-label">SOL Change</div></div>
|
||||
<div class="stat"><div class="stat-value">${data.total_value_usd?('$'+data.total_value_usd.toFixed(2)):'N/A'}</div><div class="stat-label">Portfolio Value</div></div>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<span class="tag tag-solana">Solana</span>
|
||||
<span class="source-badge">via ${data.source||'?'}</span>
|
||||
${data.label?`<span class="tag" style="background:#2a2a3a">${data.label}</span>`:''}
|
||||
${data.is_cex?'<span class="tag tag-cex">CEX</span>':''}
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
document.getElementById('result').innerHTML=`
|
||||
<div class="card result"><h3>EVM Funding Trace</h3>
|
||||
<div class="stats" style="margin-top:12px">
|
||||
<div class="stat"><div class="stat-value">${(data.source_type||'?').toUpperCase()}</div><div class="stat-label">Source Type</div></div>
|
||||
<div class="stat"><div class="stat-value">${data.confidence||0}%</div><div class="stat-label">Confidence</div></div>
|
||||
<div class="stat"><div class="stat-value">${(data.funding_amount_eth||0).toFixed(6)}</div><div class="stat-label">ETH</div></div>
|
||||
<div class="stat"><div class="stat-value">${(data.hops||[]).length}</div><div class="stat-label">Hops</div></div>
|
||||
</div>
|
||||
${data.source_address?`<div style="margin-top:12px"><label>Source</label><div class="addr">${data.source_address}</div><span class="tag ${tagClass(data.source_type)}">${data.source_type}</span></div>`:''}
|
||||
${(data.hops||[]).map((h,i)=>`<div class="hop"><strong>Hop ${h.depth}</strong> ← <span class="addr">${h.address}</span> <span style="color:var(--muted)">${(h.amount_eth||0).toFixed(6)} ETH</span></div>`).join('')}
|
||||
${data.errors?.length?`<div class="fallback-notice">⚠ Fallback used — some providers unavailable</div>`:''}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function scan(){
|
||||
const chain=document.getElementById('chain').value;
|
||||
const data=await api('/api/v1/investigate/scan',{address:document.getElementById('address').value.trim(),chain});
|
||||
if(!data)return;
|
||||
|
||||
const tr=data.trace||{};
|
||||
const risk=data.risk||{};
|
||||
const labels=data.labels||[];
|
||||
|
||||
document.getElementById('result').innerHTML=`
|
||||
<div class="card result"><h3>Full Investigation — ${data.chain.toUpperCase()}</h3>
|
||||
<div class="stats" style="margin-top:12px">
|
||||
<div class="stat"><div class="stat-value">${(tr.source_type||tr.source||'?').toUpperCase()}</div><div class="stat-label">Source</div></div>
|
||||
<div class="stat"><div class="stat-value">${tr.confidence||0}%</div><div class="stat-label">Confidence</div></div>
|
||||
${risk?`<div class="stat"><div class="stat-value" style="color:${risk.is_honeypot?'var(--danger)':'var(--success)'}">${risk.is_honeypot?'DANGER':'SAFE'}</div><div class="stat-label">Risk</div></div>`:''}
|
||||
<div class="stat"><div class="stat-value">${labels.length}</div><div class="stat-label">Labels</div></div>
|
||||
</div>
|
||||
${risk?.risks?.length?`<div style="margin-top:8px">${risk.risks.map(r=>`<span class="tag" style="background:#2c1010;color:var(--danger)">${r}</span>`).join(' ')}</div>`:''}
|
||||
${labels.map(l=>`<div class="hop"><span class="addr">${(l.address||'').slice(0,16)}...</span> → ${l.label}</div>`).join('')}
|
||||
<div class="fallback-notice">🔍 Data sourced from ${tr.source||'multiple providers'} with automatic fallback</div>
|
||||
</div>`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
267
static/news.html
Normal file
267
static/news.html
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RMI News Network — Live Crypto Intelligence</title>
|
||||
<style>
|
||||
:root{--bg:#06060f;--fg:#e8e8f0;--accent:#7c6ff7;--green:#2ecc71;--red:#e74c3c;--card:#0f0f1d;--border:#1e1e35;--muted:#888;--yellow:#f1c40f;--orange:#e67e22;--surface:#121224;--glow:rgba(124,111,247,.15);--cyan:#06b6d4}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif}
|
||||
header{background:var(--card);border-bottom:1px solid var(--border);padding:10px 24px;display:flex;align-items:center;gap:12px;position:sticky;top:0;z-index:100}
|
||||
.logo{font-size:20px;font-weight:800}.logo span:first-child{color:var(--accent)}.logo span:last-child{color:var(--fg)}
|
||||
nav{display:flex;gap:4px}
|
||||
nav a{color:var(--muted);text-decoration:none;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:500}
|
||||
nav a:hover,nav a.active{color:var(--fg);background:rgba(255,255,255,.06)}
|
||||
nav a.active{color:var(--accent)}
|
||||
.market-ticker{display:flex;gap:16px;margin-left:auto;font-size:12px}
|
||||
.ticker-item{display:flex;align-items:center;gap:4px}
|
||||
.ticker-price{font-weight:700;font-family:monospace}
|
||||
.ticker-change{font-size:11px}
|
||||
main{max-width:1500px;margin:0 auto;padding:20px;display:grid;grid-template-columns:240px 1fr 340px;gap:20px}
|
||||
.sidebar{position:sticky;top:70px;height:fit-content;max-height:calc(100vh-90px);overflow-y:auto}
|
||||
.section-title{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:2px;margin:16px 0 8px;font-weight:700}
|
||||
.filter-btn{display:block;width:100%;text-align:left;padding:7px 12px;border-radius:6px;color:var(--muted);font-size:12px;cursor:pointer;border:none;background:none;margin:1px 0}
|
||||
.filter-btn:hover{background:var(--surface);color:var(--fg)}
|
||||
.filter-btn.active{background:var(--accent);color:#fff;font-weight:600}
|
||||
.panel{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px;margin-bottom:12px}
|
||||
.panel h4{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
|
||||
.fear-greed-display{text-align:center;padding:12px 0}
|
||||
.fg-value{font-size:42px;font-weight:800;line-height:1}
|
||||
.fg-label{font-size:13px;margin-top:4px}
|
||||
.fg-bar{height:8px;border-radius:4px;margin-top:6px;background:linear-gradient(90deg,#ff0044,#ff8800,#ffd700,#88ff00,#00ff88)}
|
||||
.fg-marker{width:4px;height:14px;border-radius:2px;background:#fff;position:relative;top:-11px}
|
||||
.price-row{display:flex;justify-content:space-between;padding:5px 0;font-size:12px;border-bottom:1px solid rgba(255,255,255,.03)}
|
||||
.price-row:last-child{border:none}
|
||||
.price-name{color:var(--muted)}
|
||||
.price-value{font-weight:600;font-family:monospace}
|
||||
.article-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:8px;transition:.15s;cursor:pointer}
|
||||
.article-card:hover{border-color:var(--accent);box-shadow:0 4px 20px var(--glow)}
|
||||
.article-source{font-size:10px;color:var(--accent);font-weight:600;margin-bottom:4px}
|
||||
.article-title{font-size:15px;font-weight:700;line-height:1.4;margin-bottom:6px}
|
||||
.article-title a{color:var(--fg);text-decoration:none}
|
||||
.article-meta{font-size:10px;color:var(--muted)}
|
||||
.article-card.high-impact{border-left:3px solid var(--red)}
|
||||
.article-card.medium-impact{border-left:3px solid var(--orange)}
|
||||
.prediction-item{padding:7px 0;border-bottom:1px solid rgba(255,255,255,.03);font-size:11px}
|
||||
.prediction-item:last-child{border:none}
|
||||
.prediction-title{color:var(--fg);margin-bottom:2px}
|
||||
.prediction-volume{color:var(--muted);font-size:10px}
|
||||
.loading{text-align:center;padding:40px;color:var(--muted)}
|
||||
.spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.brief-bar{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:16px;font-size:12px;color:var(--muted)}
|
||||
.brief-bar b{color:var(--fg)}
|
||||
.source-badge{display:inline-block;padding:2px 6px;border-radius:3px;font-size:9px;background:rgba(6,182,212,.1);color:var(--cyan);margin-right:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo"><span>RMI</span><span> News</span></div>
|
||||
<nav>
|
||||
<a href="/rugcharts">RugCharts</a>
|
||||
<a href="/rugmaps">RugMaps</a>
|
||||
<a href="/news" class="active">News</a>
|
||||
</nav>
|
||||
<div class="market-ticker" id="tickerBar">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Left Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="panel">
|
||||
<h4>😨 Fear & Greed</h4>
|
||||
<div id="fearGreedWidget"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h4>💰 Live Prices</h4>
|
||||
<div id="pricesWidget"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h4>🔥 Trending</h4>
|
||||
<div id="trendingWidget"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Filters</div>
|
||||
<button class="filter-btn active" onclick="filterNews('all')">All News</button>
|
||||
<button class="filter-btn" onclick="filterNews('bitcoin')">Bitcoin</button>
|
||||
<button class="filter-btn" onclick="filterNews('ethereum')">Ethereum</button>
|
||||
<button class="filter-btn" onclick="filterNews('defi')">DeFi</button>
|
||||
<button class="filter-btn" onclick="filterNews('regulation')">Regulation</button>
|
||||
<button class="filter-btn" onclick="filterNews('security')">Security</button>
|
||||
</aside>
|
||||
|
||||
<!-- Main Feed -->
|
||||
<div>
|
||||
<div class="brief-bar" id="briefBar"><div class="spinner"></div> Loading market data...</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap" id="tabBar">
|
||||
<button class="sort-btn active" onclick="switchTab('all')">📰 All News</button>
|
||||
<button class="sort-btn" onclick="switchTab('ct')">𝕏 CT Rundown</button>
|
||||
<button class="sort-btn" onclick="switchTab('weekly')">⭐ Weekly Best</button>
|
||||
<button class="sort-btn" onclick="switchTab('academic')">📚 Academic</button>
|
||||
<button class="sort-btn" onclick="switchTab('social')">💬 Social</button>
|
||||
</div>
|
||||
<div id="articles"><div class="loading"><div class="spinner"></div> Loading news...</div></div>
|
||||
<div id="ctRundown" style="display:none"></div>
|
||||
<div id="weeklyContent" style="display:none"></div>
|
||||
<div id="academicContent" style="display:none"></div>
|
||||
<div id="socialContent" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="panel">
|
||||
<h4>🎲 Prediction Markets</h4>
|
||||
<div id="polyWidget"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h4>📡 Data Sources</h4>
|
||||
<div style="font-size:11px;color:var(--muted);line-height:1.6">
|
||||
<span class="source-badge">CG</span> CoinGecko<br>
|
||||
<span class="source-badge">FG</span> Fear & Greed<br>
|
||||
<span class="source-badge">PM</span> Polymarket<br>
|
||||
<span class="source-badge">RSS</span> 200+ RSS Feeds<br>
|
||||
<span class="source-badge">DB</span> DataBus (56 chains)
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const DB = '/api/v1/databus';
|
||||
let allArticles = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
async function loadAll() {
|
||||
// Load market data and news in parallel
|
||||
const [prices, fearGreed, trending, polymarket, brief, newsData] = await Promise.all([
|
||||
fetch(DB+'/market/prices').then(r=>r.json()).catch(()=>null),
|
||||
fetch(DB+'/market/fear-greed').then(r=>r.json()).catch(()=>null),
|
||||
fetch(DB+'/market/trending').then(r=>r.json()).catch(()=>null),
|
||||
fetch(DB+'/market/prediction-markets').then(r=>r.json()).catch(()=>null),
|
||||
fetch(DB+'/market/brief').then(r=>r.json()).catch(()=>null),
|
||||
fetch(DB+'/news/full?limit=30').then(r=>r.json()).catch(()=>null),
|
||||
]);
|
||||
|
||||
// Ticker bar
|
||||
if (prices?.prices) {
|
||||
let html = '';
|
||||
for (const [coin, data] of Object.entries(prices.prices)) {
|
||||
const change = data.usd_24h_change || 0;
|
||||
const color = change < -3 ? 'var(--red)' : change < 0 ? 'var(--orange)' : 'var(--green)';
|
||||
html += `<div class="ticker-item">
|
||||
<span style="color:var(--muted)">${coin.toUpperCase().slice(0,4)}</span>
|
||||
<span class="ticker-price">$${(data.usd||0).toLocaleString()}</span>
|
||||
<span class="ticker-change" style="color:${color}">${change>=0?'+':''}${change.toFixed(1)}%</span>
|
||||
</div>`;
|
||||
}
|
||||
document.getElementById('tickerBar').innerHTML = html;
|
||||
}
|
||||
|
||||
// Fear & Greed
|
||||
if (fearGreed) {
|
||||
const v = fearGreed.value || 50;
|
||||
document.getElementById('fearGreedWidget').innerHTML = `
|
||||
<div class="fear-greed-display">
|
||||
<div class="fg-value" style="color:${fearGreed.color}">${v}</div>
|
||||
<div class="fg-label">${fearGreed.classification}</div>
|
||||
<div class="fg-bar"><div class="fg-marker" style="left:${v}%"></div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Prices
|
||||
if (prices?.prices) {
|
||||
let html = '';
|
||||
for (const [coin, data] of Object.entries(prices.prices)) {
|
||||
const change = data.usd_24h_change || 0;
|
||||
const color = change < 0 ? 'var(--red)' : 'var(--green)';
|
||||
html += `<div class="price-row">
|
||||
<span class="price-name">${coin.charAt(0).toUpperCase()+coin.slice(1)}</span>
|
||||
<span class="price-value">$${(data.usd||0).toLocaleString()} <span style="color:${color};font-size:10px">${change>=0?'+':''}${change.toFixed(1)}%</span></span>
|
||||
</div>`;
|
||||
}
|
||||
document.getElementById('pricesWidget').innerHTML = html;
|
||||
}
|
||||
|
||||
// Trending
|
||||
if (trending?.trending) {
|
||||
document.getElementById('trendingWidget').innerHTML = trending.trending.slice(0,8).map(t =>
|
||||
`<div class="price-row">
|
||||
<span class="price-name">${t.name}</span>
|
||||
<span style="font-size:10px;color:var(--muted)">${t.symbol} #${t.market_cap_rank||'?'}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Polymarket
|
||||
if (polymarket?.markets?.length) {
|
||||
document.getElementById('polyWidget').innerHTML = polymarket.markets.map(m =>
|
||||
`<div class="prediction-item">
|
||||
<div class="prediction-title">${m.title.slice(0,80)}</div>
|
||||
<div class="prediction-volume">Vol: $${(m.volume||0).toLocaleString()}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Brief bar
|
||||
if (brief?.brief || newsData?.market_brief?.brief) {
|
||||
const b = brief?.brief || newsData?.market_brief?.brief || '';
|
||||
document.getElementById('briefBar').innerHTML = `<b>Market:</b> ${b}`;
|
||||
}
|
||||
|
||||
// News articles
|
||||
const newsContent = newsData?.headlines;
|
||||
if (newsContent?.articles) {
|
||||
allArticles = newsContent.articles;
|
||||
} else {
|
||||
// Fallback to existing news endpoint
|
||||
try {
|
||||
const fallback = await fetch('/news/daily-data').then(r=>r.json());
|
||||
allArticles = fallback?.articles || [];
|
||||
} catch(e) { allArticles = []; }
|
||||
}
|
||||
renderArticles();
|
||||
}
|
||||
|
||||
function filterNews(cat) {
|
||||
currentFilter = cat;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
renderArticles();
|
||||
}
|
||||
|
||||
function renderArticles() {
|
||||
let filtered = allArticles;
|
||||
if (currentFilter !== 'all') {
|
||||
filtered = allArticles.filter(a => {
|
||||
const text = (a.title + ' ' + (a.source||'') + ' ' + (a.category||'')).toLowerCase();
|
||||
return text.includes(currentFilter.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
if (!filtered.length) {
|
||||
document.getElementById('articles').innerHTML = '<div class="loading">No articles found. Try a different filter.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('articles').innerHTML = filtered.map(a => {
|
||||
const impact = a.impact || (a.category === 'security' ? 'high' : 'medium');
|
||||
return `<div class="article-card ${impact}-impact">
|
||||
<div class="article-source">${a.source||'Unknown'} · ${a.category||'General'} · ${a.published||''}</div>
|
||||
<div class="article-title"><a href="${a.url||'#'}" target="_blank">${a.title||'Untitled'}</a></div>
|
||||
<div class="article-meta">${a.summary||a.description||''.slice(0,150)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
loadAll();
|
||||
// Refresh every 2 minutes
|
||||
setInterval(loadAll, 120000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
433
static/rugcharts.html
Normal file
433
static/rugcharts.html
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RugCharts — Token Intelligence</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f; --panel: #12121a; --border: #1e1e2e;
|
||||
--text: #e0e0e0; --dim: #8888aa; --accent: #7c3aed;
|
||||
--green: #00ff88; --yellow: #ffd700; --orange: #ff8800; --red: #ff0044;
|
||||
--safe: #00ff88; --low: #88ff00; --medium: #ffd700; --high: #ff8800; --critical: #ff0044;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
|
||||
header { background: var(--panel); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 100; }
|
||||
.logo { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
|
||||
.logo span:first-child { color: var(--accent); } .logo span:last-child { color: var(--text); }
|
||||
.search-bar { flex: 1; max-width: 600px; display: flex; gap: 8px; }
|
||||
.search-bar input { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; color: var(--text); font-size: 14px; outline: none; }
|
||||
.search-bar input:focus { border-color: var(--accent); }
|
||||
.search-bar select { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: var(--text); font-size: 14px; }
|
||||
.search-bar button { background: var(--accent); color: white; border: none; border-radius: 8px; padding: 10px 24px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.search-bar button:hover { opacity: 0.9; }
|
||||
.stats-bar { background: var(--panel); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; gap: 24px; font-size: 13px; color: var(--dim); }
|
||||
.stats-bar b { color: var(--text); }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.verdict { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 24px; display: none; }
|
||||
.verdict.active { display: block; }
|
||||
.verdict-header { display: flex; align-items: center; gap: 16px; margin-bottom: 12px; }
|
||||
.risk-badge { padding: 6px 16px; border-radius: 20px; font-weight: 700; font-size: 14px; color: #000; }
|
||||
.risk-badge.SAFE { background: var(--safe); }
|
||||
.risk-badge.LOW { background: var(--low); }
|
||||
.risk-badge.MEDIUM { background: var(--medium); }
|
||||
.risk-badge.HIGH { background: var(--orange); }
|
||||
.risk-badge.CRITICAL,.risk-badge.DANGER { background: var(--red); color: #fff; }
|
||||
.risk-score { font-size: 48px; font-weight: 800; line-height: 1; }
|
||||
.verdict-text { font-size: 16px; color: var(--dim); margin-top: 4px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 20px; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
|
||||
.panel-title { font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--dim); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.panel-title .icon { font-size: 18px; }
|
||||
.metric-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 13px; }
|
||||
.metric-row:last-child { border-bottom: none; }
|
||||
.metric-label { color: var(--dim); }
|
||||
.metric-value { font-weight: 600; }
|
||||
.metric-value.green { color: var(--green); }
|
||||
.metric-value.yellow { color: var(--yellow); }
|
||||
.metric-value.orange { color: var(--orange); }
|
||||
.metric-value.red { color: var(--red); }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin: 2px; }
|
||||
.tag.pass { background: rgba(0,255,136,0.15); color: var(--green); }
|
||||
.tag.fail { background: rgba(255,0,68,0.15); color: var(--red); }
|
||||
.tag.warn { background: rgba(255,136,0,0.15); color: var(--orange); }
|
||||
.entity-badge { background: rgba(124,58,237,0.15); color: var(--accent); padding: 4px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; }
|
||||
.loading { text-align: center; padding: 40px; color: var(--dim); }
|
||||
.spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty { color: var(--dim); font-size: 13px; padding: 20px; text-align: center; }
|
||||
.progress-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-top: 4px; }
|
||||
.progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
||||
.wallet-list { max-height: 200px; overflow-y: auto; }
|
||||
.wallet-item { display: flex; justify-content: space-between; padding: 6px 0; font-size: 12px; border-bottom: 1px solid rgba(255,255,255,0.03); font-family: monospace; }
|
||||
.launch-feed { max-height: 300px; overflow-y: auto; }
|
||||
.launch-item { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 12px; display: flex; justify-content: space-between; align-items: center; }
|
||||
footer { text-align: center; padding: 24px; color: var(--dim); font-size: 12px; }
|
||||
footer span { color: var(--accent); }
|
||||
.error-panel { background: rgba(255,0,68,0.1); border: 1px solid var(--red); border-radius: 8px; padding: 12px; color: var(--red); font-size: 13px; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo"><span>Rug</span><span>Charts</span></div>
|
||||
<nav style="display:flex;gap:4px">
|
||||
<a href="/rugcharts" style="color:var(--text);text-decoration:none;padding:6px 14px;border-radius:6px;font-size:13px;font-weight:500;background:rgba(255,255,255,0.06)">RugCharts</a>
|
||||
<a href="/rugmaps" style="color:var(--dim);text-decoration:none;padding:6px 14px;border-radius:6px;font-size:13px;font-weight:500">RugMaps</a>
|
||||
<a href="/news" style="color:var(--dim);text-decoration:none;padding:6px 14px;border-radius:6px;font-size:13px;font-weight:500">News</a>
|
||||
</nav>
|
||||
<div class="search-bar">
|
||||
<input id="tokenInput" type="text" placeholder="Paste token address (Solana or EVM)..." autofocus>
|
||||
<select id="chainSelect">
|
||||
<option value="solana">Solana</option>
|
||||
<option value="ethereum">Ethereum</option>
|
||||
<option value="base">Base</option>
|
||||
<option value="bsc">BSC</option>
|
||||
<option value="arbitrum">Arbitrum</option>
|
||||
<option value="polygon">Polygon</option>
|
||||
</select>
|
||||
<button onclick="scanToken()">Scan</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats-bar" id="statsBar">
|
||||
<span>DataBus: <b id="chainCount">...</b> chains</span>
|
||||
<span>Cache: <b id="cacheStatus">checking...</b></span>
|
||||
<span>Powered by <b>Arkham · Helius · GoPlus · RAG</b></span>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="error-panel" id="errorPanel"></div>
|
||||
|
||||
<!-- VERDICT BANNER -->
|
||||
<div class="verdict" id="verdictBanner">
|
||||
<div class="verdict-header">
|
||||
<div class="risk-score" id="riskScore">--</div>
|
||||
<div>
|
||||
<div class="risk-badge" id="riskBadge">SCAN A TOKEN</div>
|
||||
<div class="verdict-text" id="verdictText"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="grid" id="mainGrid">
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">🛡️</span> Security Scan</div>
|
||||
<div id="securityPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">👥</span> Holder Health</div>
|
||||
<div id="holderPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">💧</span> Liquidity Risk</div>
|
||||
<div id="liquidityPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">📊</span> Volume Authenticity</div>
|
||||
<div id="volumePanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">🔍</span> Rug Patterns</div>
|
||||
<div id="rugPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">👤</span> Developer Reputation</div>
|
||||
<div id="devPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">🔗</span> Cross-Chain Entity</div>
|
||||
<div id="entityPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">🐋</span> Whale Alerts</div>
|
||||
<div id="whalePanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">🕵️</span> Insider Detection</div>
|
||||
<div id="insiderPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">🚀</span> Token Launches (Live)</div>
|
||||
<div id="launchPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title"><span class="icon">💰</span> Smart Money</div>
|
||||
<div id="smartMoneyPanel" class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>RugCharts · 49 DataBus Chains · <span>No one else shows this data</span></footer>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1/databus';
|
||||
|
||||
async function apiGet(path) {
|
||||
try { const r = await fetch(API + path); return r.ok ? r.json() : null; }
|
||||
catch(e) { return null; }
|
||||
}
|
||||
|
||||
function riskColor(level) {
|
||||
const m = {SAFE:'var(--green)',LOW:'var(--low)','LOW RISK':'var(--low)',
|
||||
MEDIUM:'var(--yellow)','MEDIUM RISK':'var(--yellow)',
|
||||
HIGH:'var(--orange)','HIGH RISK':'var(--orange)',
|
||||
CRITICAL:'var(--red)',DANGER:'var(--red)',EXTREME:'var(--red)','NO_HOLDERS':'var(--dim)'};
|
||||
return m[level] || 'var(--dim)';
|
||||
}
|
||||
|
||||
function riskBadgeClass(level) {
|
||||
const m = {SAFE:'SAFE',LOW:'LOW','LOW RISK':'LOW',MEDIUM:'MEDIUM','MEDIUM RISK':'MEDIUM',
|
||||
HIGH:'HIGH','HIGH RISK':'HIGH',CRITICAL:'CRITICAL',DANGER:'CRITICAL'};
|
||||
return m[level] || 'MEDIUM';
|
||||
}
|
||||
|
||||
function renderSecurity(data) {
|
||||
if (!data) return '<div class="empty">No security data available</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Risk Score</span><span class="metric-value" style="color:${riskColor(data.band)}">${data.score || '--'}/100</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Risk Band</span><span class="metric-value" style="color:${riskColor(data.band)}">${data.band || '--'}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Checks Run</span><span class="metric-value">${data.checks_run || 0}</span></div>`;
|
||||
if (data.highlights && data.highlights.length) {
|
||||
html += '<div style="margin-top:12px">';
|
||||
data.highlights.forEach(h => {
|
||||
const cls = h.startsWith('FAIL') ? 'fail' : 'warn';
|
||||
html += `<span class="tag ${cls}">${h}</span> `;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderHolders(data) {
|
||||
if (!data) return '<div class="empty">No holder data</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Total Holders</span><span class="metric-value">${(data.total||0).toLocaleString()}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Gini Coefficient</span><span class="metric-value" style="color:${(data.gini||0)>0.6?'var(--red)':(data.gini||0)>0.4?'var(--yellow)':'var(--green)'}">${(data.gini||0).toFixed(3)}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Top 10 Concentration</span><span class="metric-value">${data.top10_pct||0}%</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Top Wallet Dominance</span><span class="metric-value" style="color:${(data.top1_pct||0)>50?'var(--red)':'var(--dim)'}">${data.top1_pct||0}%</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Whales (>1%)</span><span class="metric-value">${data.whales||0}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Decentralization</span><span class="metric-value" style="color:${(data.decentralization||0)>60?'var(--green)':(data.decentralization||0)>30?'var(--yellow)':'var(--red)'}">${data.decentralization||0}/100</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Risk</span><span class="metric-value" style="color:${riskColor(data.risk)}">${data.risk||'--'}</span></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderLiquidity(data) {
|
||||
if (!data) return '<div class="empty">No liquidity data</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Concentration Risk</span><span class="metric-value" style="color:${riskColor(data.concentration_risk?.toUpperCase())}">${data.concentration_risk||'unknown'}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Risk Score</span><span class="metric-value" style="color:${riskColor(data.risk_level)}">${data.risk_score||50}/100</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">LP Holders</span><span class="metric-value">${data.lp_holders||0}</span></div>`;
|
||||
if (data.lp_holders_list && data.lp_holders_list.length) {
|
||||
html += '<div style="margin-top:8px;font-size:11px;color:var(--dim)">Top LP Holders:</div>';
|
||||
data.lp_holders_list.slice(0,3).forEach(h => {
|
||||
html += `<div class="wallet-item"><span>${h.address||'...'}</span><span>${h.share_pct||0}% ${h.entity ? '· '+h.entity : ''}</span></div>`;
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderVolume(data) {
|
||||
if (!data) return '<div class="empty">No volume data</div>';
|
||||
let html = '';
|
||||
const fake = data.fake_volume_pct || 0;
|
||||
const auth = data.authentic_score || 100;
|
||||
const fc = fake > 50 ? 'var(--red)' : fake > 20 ? 'var(--yellow)' : 'var(--green)';
|
||||
html += `<div class="metric-row"><span class="metric-label">Fake Volume</span><span class="metric-value" style="color:${fc}">${fake}%</span></div>`;
|
||||
html += `<div class="progress-bar"><div class="progress-fill" style="width:${fake}%;background:${fc}"></div></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Authentic Score</span><span class="metric-value" style="color:${auth>80?'var(--green)':auth>50?'var(--yellow)':'var(--red)'}">${auth}/100</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Risk Level</span><span class="metric-value" style="color:${riskColor(data.risk_level)}">${data.risk_level||'--'}</span></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderRugPatterns(data) {
|
||||
if (!data) return '<div class="empty">No pattern data</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Pattern Matches</span><span class="metric-value">${data.total_matches||0}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Overall Risk</span><span class="metric-value" style="color:${riskColor(data.overall_rug_risk||data.overall_risk)}">${data.overall_rug_risk||data.overall_risk||'--'}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Top Match</span><span class="metric-value">${data.top_match||'None'}</span></div>`;
|
||||
if (data.top_score) html += `<div class="metric-row"><span class="metric-label">Match Score</span><span class="metric-value" style="color:${(data.top_score||0)>60?'var(--red)':'var(--yellow)'}">${data.top_score}/100</span></div>`;
|
||||
if (data.pattern_matches && data.pattern_matches.length) {
|
||||
data.pattern_matches.slice(0,3).forEach(m => {
|
||||
html += `<div style="margin-top:4px;font-size:11px"><span class="tag ${m.severity==='CRITICAL'?'fail':'warn'}">${m.pattern_name}</span> ${m.match_score}%</div>`;
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDev(data) {
|
||||
if (!data) return '<div class="empty">No developer data</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Tokens Deployed</span><span class="metric-value">${data.tokens_deployed||'unknown'}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Activity Span</span><span class="metric-value">${data.activity_span_days||0} days</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Risk Level</span><span class="metric-value" style="color:${riskColor(data.risk_level)}">${data.risk_level||'--'}</span></div>`;
|
||||
if (data.known_entity) html += `<div class="metric-row"><span class="metric-label">Entity</span><span class="entity-badge">${data.known_entity}</span></div>`;
|
||||
if (data.entity_type) html += `<div class="metric-row"><span class="metric-label">Type</span><span class="metric-value">${data.entity_type}</span></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderEntity(data) {
|
||||
if (!data || !data.entity) return '<div class="empty">No entity data (requires Arkham)</div>';
|
||||
let html = '';
|
||||
const e = data.entity;
|
||||
if (e.entity_name) html += `<div class="entity-badge" style="font-size:16px;margin-bottom:8px">${e.entity_name}</div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Type</span><span class="metric-value">${e.entity_type||'--'}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Chain</span><span class="metric-value">${e.primary_chain||'--'}</span></div>`;
|
||||
if (e.label) html += `<div class="metric-row"><span class="metric-label">Label</span><span class="metric-value">${e.label}</span></div>`;
|
||||
if (e.is_contract) html += `<div class="metric-row"><span class="metric-label">Contract</span><span class="metric-value">Yes</span></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderWhales(data) {
|
||||
if (!data) return '<div class="empty">No whale activity</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Movements Detected</span><span class="metric-value">${data.total_detected||0}</span></div>`;
|
||||
if (data.whale_movements && data.whale_movements.length) {
|
||||
html += '<div class="wallet-list">';
|
||||
data.whale_movements.slice(0,8).forEach(w => {
|
||||
html += `<div class="wallet-item"><span>${w.wallet||'...'}</span><span style="color:var(--orange)">${w.amount||0}</span></div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderInsider(data) {
|
||||
if (!data) return '<div class="empty">No insider signals</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Signals Found</span><span class="metric-value">${data.total_signals||0}</span></div>`;
|
||||
html += `<div class="metric-row"><span class="metric-label">Confidence</span><span class="metric-value" style="color:${data.confidence==='HIGH'?'var(--red)':'var(--yellow)'}">${data.confidence||'--'}</span></div>`;
|
||||
if (data.insider_signals && data.insider_signals.length) {
|
||||
data.insider_signals.slice(0,3).forEach(s => {
|
||||
html += `<div style="margin-top:6px;font-size:11px"><span class="tag ${s.signal_strength==='HIGH'?'fail':'warn'}">${s.signal_strength}</span> ${s.spike_multiplier}x volume spike</div>`;
|
||||
});
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderLaunches(data) {
|
||||
if (!data) return '<div class="empty">No launch data</div>';
|
||||
let html = '';
|
||||
const rd = data.risk_distribution || {};
|
||||
html += `<div class="metric-row"><span class="metric-label">New Tokens</span><span class="metric-value">${data.total_detected||0}</span></div>`;
|
||||
if (rd.CRITICAL) html += `<div class="metric-row"><span class="metric-label">Critical Risk</span><span class="metric-value" style="color:var(--red)">${rd.CRITICAL}</span></div>`;
|
||||
if (rd.HIGH) html += `<div class="metric-row"><span class="metric-label">High Risk</span><span class="metric-value" style="color:var(--orange)">${rd.HIGH}</span></div>`;
|
||||
if (data.new_tokens && data.new_tokens.length) {
|
||||
html += '<div class="launch-feed">';
|
||||
data.new_tokens.slice(0,10).forEach(t => {
|
||||
html += `<div class="launch-item"><span style="font-family:monospace;font-size:11px">${t.signature||'...'}</span><span class="tag ${t.risk_level==='CRITICAL'?'fail':t.risk_level==='HIGH'?'warn':'pass'}">${t.risk_level} · ${t.age_minutes}m</span></div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSmartMoney(data) {
|
||||
if (!data) return '<div class="empty">No smart money data</div>';
|
||||
let html = '';
|
||||
html += `<div class="metric-row"><span class="metric-label">Tracked Wallets</span><span class="metric-value">${data.total_tracked||0}</span></div>`;
|
||||
if (data.smart_money_wallets && data.smart_money_wallets.length) {
|
||||
html += '<div class="wallet-list">';
|
||||
data.smart_money_wallets.slice(0,8).forEach(w => {
|
||||
const label = w.entity ? ` · ${w.entity}` : '';
|
||||
html += `<div class="wallet-item"><span>${(w.address||'').slice(0,12)}...</span><span>$${(w.estimated_usd||0).toLocaleString()}${label}</span></div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
async function scanToken() {
|
||||
const addr = document.getElementById('tokenInput').value.trim();
|
||||
const chain = document.getElementById('chainSelect').value;
|
||||
if (!addr) return;
|
||||
|
||||
// Show loading
|
||||
document.querySelectorAll('.panel > div:last-child').forEach(el => {
|
||||
if (el.id && el.id.endsWith('Panel')) el.innerHTML = '<div class="spinner"></div>';
|
||||
});
|
||||
document.getElementById('verdictBanner').classList.remove('active');
|
||||
document.getElementById('errorPanel').style.display = 'none';
|
||||
|
||||
// Fire all endpoints in parallel
|
||||
const token = encodeURIComponent(addr);
|
||||
const [report, security, holders, liquidity, volume, rug, dev, entity, whales, insider, launches, smart] =
|
||||
await Promise.all([
|
||||
apiGet(`/premium/token-report/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/security-scan/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/holder-health/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/liquidity-risk/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/volume-authenticity/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/rug-patterns/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/dev-reputation/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/cross-chain/${token}`),
|
||||
apiGet(`/premium/whale-alerts/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/insider-detection/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/token-launches?chain=${chain}&limit=20`),
|
||||
apiGet(`/premium/smart-money?chain=${chain}`),
|
||||
]);
|
||||
|
||||
// Render verdict
|
||||
const v = document.getElementById('verdictBanner');
|
||||
v.classList.add('active');
|
||||
const or = report?.overall_risk || {};
|
||||
document.getElementById('riskScore').textContent = or.score || '--';
|
||||
document.getElementById('riskScore').style.color = riskColor(or.level);
|
||||
const badge = document.getElementById('riskBadge');
|
||||
badge.textContent = or.level || 'UNKNOWN';
|
||||
badge.className = 'risk-badge ' + riskBadgeClass(or.level);
|
||||
document.getElementById('verdictText').textContent = report?.quick_verdict || '';
|
||||
|
||||
// Render panels
|
||||
document.getElementById('securityPanel').innerHTML = renderSecurity(report?.sections?.security || security);
|
||||
document.getElementById('holderPanel').innerHTML = renderHolders(report?.sections?.holders || holders);
|
||||
document.getElementById('liquidityPanel').innerHTML = renderLiquidity(report?.sections?.liquidity || liquidity);
|
||||
document.getElementById('volumePanel').innerHTML = renderVolume(report?.sections?.volume || volume);
|
||||
document.getElementById('rugPanel').innerHTML = renderRugPatterns(report?.sections?.rug_patterns || rug);
|
||||
document.getElementById('devPanel').innerHTML = renderDev(report?.sections?.developer || dev);
|
||||
document.getElementById('entityPanel').innerHTML = renderEntity(report?.sections?.entity || entity);
|
||||
document.getElementById('whalePanel').innerHTML = renderWhales(whales);
|
||||
document.getElementById('insiderPanel').innerHTML = renderInsider(insider);
|
||||
document.getElementById('launchPanel').innerHTML = renderLaunches(launches);
|
||||
document.getElementById('smartMoneyPanel').innerHTML = renderSmartMoney(smart);
|
||||
|
||||
// Error handling
|
||||
if (!report && !security && !holders) {
|
||||
document.getElementById('errorPanel').style.display = 'block';
|
||||
document.getElementById('errorPanel').textContent = 'Could not fetch token data. Check the address and chain.';
|
||||
}
|
||||
|
||||
// Update URL
|
||||
history.pushState({}, '', `?chain=${chain}&token=${addr}`);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Load stats
|
||||
try {
|
||||
const health = await fetch(API + '/health').then(r => r.json());
|
||||
document.getElementById('chainCount').textContent = health?.chains || '49';
|
||||
document.getElementById('cacheStatus').textContent = health?.cache_hits ? `${health.cache_hits} hits` : 'live';
|
||||
} catch(e) {}
|
||||
|
||||
// Check URL params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
const chain = params.get('chain');
|
||||
if (token) {
|
||||
document.getElementById('tokenInput').value = token;
|
||||
if (chain) document.getElementById('chainSelect').value = chain;
|
||||
scanToken();
|
||||
}
|
||||
|
||||
// Enter key
|
||||
document.getElementById('tokenInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') scanToken();
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
529
static/rugmaps.html
Normal file
529
static/rugmaps.html
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RugMaps — Wallet Cluster Intelligence</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f; --panel: #12121a; --border: #1e1e2e;
|
||||
--text: #e0e0e0; --dim: #8888aa; --accent: #7c3aed; --accent2: #06b6d4;
|
||||
--green: #00ff88; --yellow: #ffd700; --orange: #ff8800; --red: #ff0044;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; min-height: 100vh; overflow-x: hidden; }
|
||||
header { background: var(--panel); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 100; }
|
||||
.logo { font-size: 20px; font-weight: 800; letter-spacing: -0.5px; white-space: nowrap; }
|
||||
.logo span:first-child { color: var(--accent2); } .logo span:last-child { color: var(--text); }
|
||||
nav { display: flex; gap: 4px; }
|
||||
nav a { color: var(--dim); text-decoration: none; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 500; }
|
||||
nav a:hover, nav a.active { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||
nav a.active { color: var(--accent2); }
|
||||
.search-bar { flex: 1; max-width: 500px; display: flex; gap: 8px; margin-left: auto; }
|
||||
.search-bar input { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px; color: var(--text); font-size: 13px; outline: none; }
|
||||
.search-bar input:focus { border-color: var(--accent2); }
|
||||
.search-bar select { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: var(--text); font-size: 13px; }
|
||||
.search-bar button { background: var(--accent2); color: #000; border: none; border-radius: 8px; padding: 8px 18px; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.search-bar button:hover { opacity: 0.9; }
|
||||
.container { display: flex; height: calc(100vh - 57px); }
|
||||
#graphArea { flex: 1; position: relative; background: radial-gradient(ellipse at center, #12122a 0%, var(--bg) 70%); }
|
||||
#graphArea canvas { position: absolute; top: 0; left: 0; }
|
||||
#sidebar { width: 380px; background: var(--panel); border-left: 1px solid var(--border); overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.sidebar-section { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
|
||||
.sidebar-title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--dim); margin-bottom: 10px; }
|
||||
.stat { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
|
||||
.stat-label { color: var(--dim); }
|
||||
.stat-value { font-weight: 600; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; margin: 1px; }
|
||||
.tag.high { background: rgba(255,0,68,0.15); color: var(--red); }
|
||||
.tag.medium { background: rgba(255,136,0,0.15); color: var(--orange); }
|
||||
.tag.low { background: rgba(0,255,136,0.15); color: var(--green); }
|
||||
.tag.entity { background: rgba(6,182,212,0.15); color: var(--accent2); }
|
||||
.bundle-list { max-height: 250px; overflow-y: auto; }
|
||||
.bundle-item { padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.04); cursor: pointer; border-radius: 4px; margin-bottom: 4px; font-size: 12px; }
|
||||
.bundle-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.bundle-item.selected { background: rgba(6,182,212,0.1); border: 1px solid var(--accent2); }
|
||||
.wallet-badge { font-family: monospace; font-size: 11px; color: var(--dim); }
|
||||
.legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 11px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 4px; }
|
||||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.tooltip { position: absolute; background: var(--panel); border: 1px solid var(--accent2); border-radius: 8px; padding: 10px 14px; font-size: 12px; pointer-events: none; display: none; z-index: 1000; max-width: 280px; }
|
||||
.tooltip .addr { font-family: monospace; font-size: 11px; color: var(--accent2); margin-bottom: 4px; }
|
||||
.tooltip .entity { color: var(--green); font-weight: 600; }
|
||||
.loading-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); text-align: center; color: var(--dim); }
|
||||
.spinner { display: inline-block; width: 32px; height: 32px; border: 3px solid var(--border); border-top-color: var(--accent2); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty-state { text-align: center; color: var(--dim); padding: 40px; }
|
||||
.empty-state h2 { color: var(--text); margin-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo"><span>Rug</span><span>Maps</span></div>
|
||||
<nav>
|
||||
<a href="/rugcharts">RugCharts</a>
|
||||
<a href="/rugmaps" class="active">RugMaps</a>
|
||||
<a href="/news">News</a>
|
||||
</nav>
|
||||
<div class="search-bar">
|
||||
<input id="tokenInput" type="text" placeholder="Token address for cluster analysis..." autofocus>
|
||||
<select id="chainSelect">
|
||||
<option value="solana">Solana</option>
|
||||
<option value="ethereum">Ethereum</option>
|
||||
<option value="base">Base</option>
|
||||
<option value="bsc">BSC</option>
|
||||
</select>
|
||||
<button onclick="scanToken()">Map</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div id="graphArea">
|
||||
<canvas id="graphCanvas"></canvas>
|
||||
<div class="loading-overlay" id="loadingOverlay" style="display:none">
|
||||
<div class="spinner"></div>
|
||||
<div style="margin-top:12px" id="loadingText">Mapping wallet clusters...</div>
|
||||
</div>
|
||||
<div class="empty-state" id="emptyState">
|
||||
<h2>RugMaps — Wallet Cluster Intelligence</h2>
|
||||
<p>Paste a token address to visualize wallet clusters,<br>bundle detection, and entity relationships.</p>
|
||||
<p style="margin-top:8px;font-size:12px;color:var(--dim)">Powered by DataBus: bundle_detect + cluster_map + Arkham</p>
|
||||
</div>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</div>
|
||||
|
||||
<div id="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">📊 Cluster Overview</div>
|
||||
<div id="overviewStats">
|
||||
<div class="stat"><span class="stat-label">Scanning...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">🔗 Wallet Bundles</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div>High Risk</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:var(--orange)"></div>Medium</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:var(--accent2)"></div>Entity</div>
|
||||
<div class="legend-item"><div class="legend-dot" style="background:var(--dim)"></div>Wallet</div>
|
||||
</div>
|
||||
<div class="bundle-list" id="bundleList"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">🏷️ Selected Wallet</div>
|
||||
<div id="walletDetail" style="font-size:12px;color:var(--dim)">Click a node for details</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1/databus';
|
||||
|
||||
let nodes = [], edges = [], bundles = [];
|
||||
let highlightedBundle = null;
|
||||
|
||||
async function apiGet(path) {
|
||||
try { const r = await fetch(API + path); return r.ok ? r.json() : null; }
|
||||
catch(e) { return null; }
|
||||
}
|
||||
|
||||
async function scanToken() {
|
||||
const addr = document.getElementById('tokenInput').value.trim();
|
||||
const chain = document.getElementById('chainSelect').value;
|
||||
if (!addr) return;
|
||||
|
||||
document.getElementById('emptyState').style.display = 'none';
|
||||
document.getElementById('loadingOverlay').style.display = 'block';
|
||||
document.getElementById('loadingText').textContent = 'Detecting wallet bundles...';
|
||||
|
||||
const token = encodeURIComponent(addr);
|
||||
|
||||
// Fetch both bundle detection and cluster map from DataBus
|
||||
const [bundleData, clusterData] = await Promise.all([
|
||||
apiGet(`/premium/bundles/${token}?chain=${chain}`),
|
||||
apiGet(`/premium/clusters/${token}?chain=${chain}&depth=3`),
|
||||
]);
|
||||
|
||||
bundles = bundleData?.bundles || [];
|
||||
const clusterNodes = clusterData?.nodes || [];
|
||||
const clusterEdges = clusterData?.edges || [];
|
||||
|
||||
document.getElementById('loadingText').textContent = 'Building graph...';
|
||||
|
||||
// Build graph from cluster data
|
||||
nodes = [];
|
||||
edges = [];
|
||||
const nodeMap = new Map();
|
||||
const bundleWallets = new Set();
|
||||
|
||||
// Mark bundle wallets
|
||||
for (const b of bundles) {
|
||||
for (const w of (b.wallets || [])) {
|
||||
bundleWallets.add(w);
|
||||
}
|
||||
}
|
||||
|
||||
// Create nodes
|
||||
for (const n of clusterNodes) {
|
||||
const inBundle = bundleWallets.has(n.id);
|
||||
const hasEntity = n.entity && n.entity.length > 0;
|
||||
nodes.push({
|
||||
id: n.id,
|
||||
entity: n.entity || '',
|
||||
type: hasEntity ? 'entity' : (inBundle ? 'bundle' : 'wallet'),
|
||||
depth: n.depth || 0,
|
||||
x: Math.random() * 600 + 100,
|
||||
y: Math.random() * 400 + 100,
|
||||
vx: 0, vy: 0,
|
||||
});
|
||||
nodeMap.set(n.id, nodes[nodes.length - 1]);
|
||||
}
|
||||
|
||||
// Add bundle wallets not in cluster
|
||||
for (const w of bundleWallets) {
|
||||
if (!nodeMap.has(w)) {
|
||||
nodes.push({
|
||||
id: w,
|
||||
entity: '',
|
||||
type: 'bundle',
|
||||
depth: 0,
|
||||
x: Math.random() * 600 + 100,
|
||||
y: Math.random() * 400 + 100,
|
||||
vx: 0, vy: 0,
|
||||
});
|
||||
nodeMap.set(w, nodes[nodes.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create edges
|
||||
for (const e of clusterEdges) {
|
||||
if (nodeMap.has(e.from) && nodeMap.has(e.to)) {
|
||||
edges.push({ from: e.from, to: e.to, value: e.value || 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Add bundle edges
|
||||
for (const b of bundles) {
|
||||
const wallets = b.wallets || [];
|
||||
for (let i = 0; i < wallets.length; i++) {
|
||||
for (let j = i + 1; j < wallets.length; j++) {
|
||||
if (nodeMap.has(wallets[i]) && nodeMap.has(wallets[j])) {
|
||||
const existingEdge = edges.find(e =>
|
||||
(e.from === wallets[i] && e.to === wallets[j]) ||
|
||||
(e.from === wallets[j] && e.to === wallets[i]));
|
||||
if (!existingEdge) {
|
||||
edges.push({ from: wallets[i], to: wallets[j], value: 2, bundle: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
|
||||
// Update sidebar
|
||||
updateSidebar(bundleData, clusterData);
|
||||
|
||||
// Render graph
|
||||
highlightedBundle = null;
|
||||
renderGraph();
|
||||
|
||||
history.pushState({}, '', `?chain=${chain}&token=${addr}`);
|
||||
}
|
||||
|
||||
function updateSidebar(bundleData, clusterData) {
|
||||
const stats = document.getElementById('overviewStats');
|
||||
const totalNodes = nodes.length;
|
||||
const bundleNodes = nodes.filter(n => n.type === 'bundle').length;
|
||||
const entityNodes = nodes.filter(n => n.type === 'entity').length;
|
||||
const totalBundles = bundles.length;
|
||||
const maxBundle = bundles.reduce((m, b) => Math.max(m, (b.wallets||[]).length), 0);
|
||||
|
||||
stats.innerHTML = `
|
||||
<div class="stat"><span class="stat-label">Total Wallets</span><span class="stat-value">${totalNodes}</span></div>
|
||||
<div class="stat"><span class="stat-label">Clusters Found</span><span class="stat-value">${totalBundles}</span></div>
|
||||
<div class="stat"><span class="stat-label">Bundle Wallets</span><span class="stat-value" style="color:${bundleNodes>0?'var(--orange)':'var(--dim)'}">${bundleNodes}</span></div>
|
||||
<div class="stat"><span class="stat-label">Known Entities</span><span class="stat-value" style="color:var(--accent2)">${entityNodes}</span></div>
|
||||
<div class="stat"><span class="stat-label">Largest Bundle</span><span class="stat-value">${maxBundle} wallets</span></div>
|
||||
<div class="stat"><span class="stat-label">Total Edges</span><span class="stat-value">${edges.length}</span></div>
|
||||
<div class="stat"><span class="stat-label">Source</span><span class="stat-value" style="color:var(--accent2)">${clusterData?.source || 'databus'}</span></div>
|
||||
`;
|
||||
|
||||
// Bundle list
|
||||
const bl = document.getElementById('bundleList');
|
||||
if (!bundles.length) {
|
||||
bl.innerHTML = '<div style="color:var(--dim);font-size:12px;padding:8px">No coordinated bundles detected. Wallets appear independent.</div>';
|
||||
} else {
|
||||
bl.innerHTML = bundles.map((b, i) => `
|
||||
<div class="bundle-item" onclick="highlightBundle(${i})" id="bundle-${i}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>Bundle #${i+1}</span>
|
||||
<span class="tag ${b.risk_level==='HIGH'?'high':'medium'}">${b.risk_level||'MEDIUM'}</span>
|
||||
</div>
|
||||
<div class="wallet-badge">${(b.wallets||[]).length} wallets · score: ${(b.coordination_score||0).toFixed(1)}</div>
|
||||
${b.pattern ? `<div style="color:var(--dim);font-size:10px">${b.pattern}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function highlightBundle(idx) {
|
||||
highlightedBundle = highlightedBundle === idx ? null : idx;
|
||||
document.querySelectorAll('.bundle-item').forEach(el => el.classList.remove('selected'));
|
||||
if (highlightedBundle !== null) {
|
||||
document.getElementById(`bundle-${idx}`)?.classList.add('selected');
|
||||
}
|
||||
renderGraph();
|
||||
}
|
||||
|
||||
// ── Force-Directed Graph ──────────────────────────────────────────
|
||||
const canvas = document.getElementById('graphCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let animationId;
|
||||
|
||||
function resize() {
|
||||
const area = document.getElementById('graphArea');
|
||||
canvas.width = area.clientWidth;
|
||||
canvas.height = area.clientHeight;
|
||||
}
|
||||
window.addEventListener('resize', () => { resize(); renderGraph(); });
|
||||
resize();
|
||||
|
||||
function nodeColor(node) {
|
||||
if (node.type === 'entity') return '#06b6d4';
|
||||
if (node.type === 'bundle') return '#ff8800';
|
||||
return '#555577';
|
||||
}
|
||||
|
||||
function nodeRadius(node) {
|
||||
if (node.type === 'entity') return 8;
|
||||
if (node.type === 'bundle') return 6;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function renderGraph() {
|
||||
if (!nodes.length) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulation
|
||||
const iterations = 50;
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
const repulsion = 5000;
|
||||
const attraction = 0.005;
|
||||
const damping = 0.85;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
// Repulsion
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const dx = nodes[i].x - nodes[j].x;
|
||||
const dy = nodes[i].y - nodes[j].y;
|
||||
const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
|
||||
const force = repulsion / (dist * dist);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
nodes[i].vx += fx;
|
||||
nodes[i].vy += fy;
|
||||
nodes[j].vx -= fx;
|
||||
nodes[j].vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction (edges)
|
||||
for (const e of edges) {
|
||||
const from = nodes.find(n => n.id === e.from);
|
||||
const to = nodes.find(n => n.id === e.to);
|
||||
if (!from || !to) continue;
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const dist = Math.max(1, Math.sqrt(dx * dx + dy * dy));
|
||||
const force = dist * attraction * (e.value || 1);
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
from.vx += fx;
|
||||
from.vy += fy;
|
||||
to.vx -= fx;
|
||||
to.vy -= fy;
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const n of nodes) {
|
||||
n.vx += (centerX - n.x) * 0.001;
|
||||
n.vy += (centerY - n.y) * 0.001;
|
||||
}
|
||||
|
||||
// Apply velocity
|
||||
for (const n of nodes) {
|
||||
n.x += n.vx * damping;
|
||||
n.y += n.vy * damping;
|
||||
n.vx *= damping;
|
||||
n.vy *= damping;
|
||||
|
||||
// Bounds
|
||||
n.x = Math.max(20, Math.min(canvas.width - 20, n.x));
|
||||
n.y = Math.max(20, Math.min(canvas.height - 20, n.y));
|
||||
}
|
||||
}
|
||||
|
||||
// Draw
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Highlighted bundle highlight area
|
||||
if (highlightedBundle !== null && bundles[highlightedBundle]) {
|
||||
const bw = bundles[highlightedBundle].wallets || [];
|
||||
const bundleNodes = nodes.filter(n => bw.includes(n.id));
|
||||
if (bundleNodes.length) {
|
||||
const cx = bundleNodes.reduce((s, n) => s + n.x, 0) / bundleNodes.length;
|
||||
const cy = bundleNodes.reduce((s, n) => s + n.y, 0) / bundleNodes.length;
|
||||
const maxR = Math.max(...bundleNodes.map(n =>
|
||||
Math.sqrt((n.x-cx)**2 + (n.y-cy)**2))) + 30;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, maxR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,136,0,0.08)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,136,0,0.3)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Edges
|
||||
for (const e of edges) {
|
||||
const from = nodes.find(n => n.id === e.from);
|
||||
const to = nodes.find(n => n.id === e.to);
|
||||
if (!from || !to) continue;
|
||||
|
||||
const isBundleEdge = e.bundle;
|
||||
const isHighlighted = highlightedBundle !== null && bundles[highlightedBundle] &&
|
||||
bundles[highlightedBundle].wallets?.includes(e.from) &&
|
||||
bundles[highlightedBundle].wallets?.includes(e.to);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.strokeStyle = isHighlighted ? 'rgba(255,136,0,0.6)' :
|
||||
isBundleEdge ? 'rgba(255,136,0,0.2)' : 'rgba(255,255,255,0.06)';
|
||||
ctx.lineWidth = isHighlighted ? 2 : (isBundleEdge ? 1.5 : 0.5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Nodes
|
||||
for (const n of nodes) {
|
||||
const isHighlighted = highlightedBundle !== null && bundles[highlightedBundle] &&
|
||||
bundles[highlightedBundle].wallets?.includes(n.id);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, nodeRadius(n) * (isHighlighted ? 1.5 : 1), 0, Math.PI * 2);
|
||||
ctx.fillStyle = isHighlighted ? '#ff8800' : nodeColor(n);
|
||||
ctx.fill();
|
||||
|
||||
if (isHighlighted || n.type === 'entity') {
|
||||
ctx.strokeStyle = isHighlighted ? '#fff' : 'rgba(6,182,212,0.5)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Entity label
|
||||
if (n.entity && (n.type === 'entity' || isHighlighted)) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '10px -apple-system, sans-serif';
|
||||
ctx.fillText(n.entity.slice(0, 12), n.x + 10, n.y + 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interaction ───────────────────────────────────────────────────
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
let closest = null;
|
||||
let closestDist = 25;
|
||||
|
||||
for (const n of nodes) {
|
||||
const dx = n.x - mx;
|
||||
const dy = n.y - my;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = n;
|
||||
}
|
||||
}
|
||||
|
||||
if (closest) {
|
||||
canvas.style.cursor = 'pointer';
|
||||
tooltip.style.display = 'block';
|
||||
tooltip.style.left = (e.clientX - rect.left + 15) + 'px';
|
||||
tooltip.style.top = (e.clientY - rect.top - 10) + 'px';
|
||||
tooltip.innerHTML = `
|
||||
<div class="addr">${closest.id.slice(0,8)}...${closest.id.slice(-4)}</div>
|
||||
${closest.entity ? `<div class="entity">${closest.entity}</div>` : ''}
|
||||
<div style="color:var(--dim)">Type: ${closest.type} · Depth: ${closest.depth}</div>
|
||||
`;
|
||||
|
||||
document.getElementById('walletDetail').innerHTML = `
|
||||
<div style="font-family:monospace;color:var(--accent2);margin-bottom:6px">${closest.id}</div>
|
||||
${closest.entity ? `<div class="tag entity">${closest.entity}</div>` : ''}
|
||||
<div class="stat"><span class="stat-label">Type</span><span class="stat-value">${closest.type}</span></div>
|
||||
<div class="stat"><span class="stat-label">Depth</span><span class="stat-value">${closest.depth}</span></div>
|
||||
<div class="stat"><span class="stat-label">Connections</span><span class="stat-value">${edges.filter(e => e.from===closest.id || e.to===closest.id).length}</span></div>
|
||||
`;
|
||||
} else {
|
||||
canvas.style.cursor = 'default';
|
||||
tooltip.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
for (const n of nodes) {
|
||||
const dx = n.x - mx;
|
||||
const dy = n.y - my;
|
||||
if (Math.sqrt(dx*dx + dy*dy) < nodeRadius(n) + 5) {
|
||||
// Find which bundle this wallet belongs to
|
||||
for (let i = 0; i < bundles.length; i++) {
|
||||
if ((bundles[i].wallets||[]).includes(n.id)) {
|
||||
highlightBundle(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If not in a bundle, clear highlight
|
||||
highlightedBundle = null;
|
||||
document.querySelectorAll('.bundle-item').forEach(el => el.classList.remove('selected'));
|
||||
renderGraph();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
const chain = params.get('chain');
|
||||
if (token) {
|
||||
document.getElementById('tokenInput').value = token;
|
||||
if (chain) document.getElementById('chainSelect').value = chain;
|
||||
scanToken();
|
||||
}
|
||||
|
||||
document.getElementById('tokenInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') scanToken();
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
121
static/rundown.html
Normal file
121
static/rundown.html
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RMI Daily Market Rundown</title>
|
||||
<style>
|
||||
:root{--bg:#0a0a0f;--fg:#e0e0e0;--accent:#6c5ce7;--green:#27ae60;--red:#e74c3c;--card:#14141f;--border:#2a2a3a;--muted:#888;--yellow:#f39c12;--purple:#9945FF}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--fg);font-family:system-ui,sans-serif}
|
||||
header{background:var(--card);border-bottom:1px solid var(--border);padding:16px 24px;display:flex;align-items:center;gap:16px;position:sticky;top:0;z-index:10}
|
||||
.logo{font-size:20px;font-weight:700;color:var(--accent)}
|
||||
main{max-width:1300px;margin:0 auto;padding:24px;display:grid;grid-template-columns:240px 1fr 300px;gap:20px}
|
||||
.sidebar{position:sticky;top:80px;height:fit-content}
|
||||
.sidebar h3{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin:16px 0 8px 0}
|
||||
.cat-link{display:block;padding:7px 10px;border-radius:6px;color:var(--fg);text-decoration:none;font-size:13px;margin:2px 0;cursor:pointer}
|
||||
.cat-link:hover,.cat-link.active{background:var(--accent);color:#fff}
|
||||
.data-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:12px}
|
||||
.data-card h4{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
|
||||
.data-value{font-size:24px;font-weight:700;margin:4px 0}
|
||||
.data-label{font-size:11px;color:var(--muted)}
|
||||
.data-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid rgba(255,255,255,0.03)}
|
||||
.ai-summary{background:linear-gradient(135deg,#1a1a3e,#2a1a4e);border:1px solid var(--accent);border-radius:12px;padding:20px;margin-bottom:20px}
|
||||
.ai-summary h2{font-size:20px;margin-bottom:4px;color:var(--accent)}
|
||||
.ai-badge{background:var(--accent);color:#fff;padding:2px 8px;border-radius:6px;font-size:10px;display:inline-block;margin-bottom:10px}
|
||||
.ai-summary p{line-height:1.6;color:#c0c0d0;font-size:13px}
|
||||
.article{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:10px;transition:border-color .2s}
|
||||
.article:hover{border-color:var(--accent)}
|
||||
.article-source{font-size:10px;color:var(--muted);margin-bottom:4px}
|
||||
.article-title{font-size:14px;font-weight:600;margin-bottom:6px}
|
||||
.article-title a{color:var(--fg);text-decoration:none}
|
||||
.article-title a:hover{color:var(--accent)}
|
||||
.article-summary{font-size:12px;color:#c0c0d0;line-height:1.4;margin-bottom:8px}
|
||||
.article-meta{display:flex;gap:12px;align-items:center;font-size:11px;color:var(--muted)}
|
||||
.tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;margin-right:3px}
|
||||
.tag-bull{background:rgba(39,174,96,.15);color:var(--green)}
|
||||
.tag-bear{background:rgba(231,76,60,.15);color:var(--red)}
|
||||
.tag-neutral{background:rgba(136,136,136,.15);color:var(--muted)}
|
||||
.tag-cat{background:rgba(108,92,231,.15);color:var(--accent)}
|
||||
.tag-alert{background:rgba(231,76,60,.2);color:var(--red)}
|
||||
.vote-btn{background:none;border:1px solid var(--border);color:var(--muted);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:11px}
|
||||
.vote-btn:hover{background:var(--accent);color:#fff}
|
||||
.fg-bar{height:6px;border-radius:3px;margin-top:6px;background:linear-gradient(90deg,var(--red),var(--yellow),var(--green))}
|
||||
.fg-marker{width:4px;height:14px;background:white;border-radius:2px;position:relative}
|
||||
.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><span class="logo">RMI</span><span>Daily Market Rundown</span><span class="badge">AI-Powered</span></header>
|
||||
<main>
|
||||
<aside class="sidebar">
|
||||
<input style="width:100%;padding:8px 10px;background:#1a1a2e;border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:13px;margin-bottom:12px" placeholder="Search..." id="search" oninput="loadArticles()">
|
||||
<h3>Categories</h3><div id="categories"></div>
|
||||
<h3>Sentiment</h3><div id="sentiments"></div>
|
||||
</aside>
|
||||
|
||||
<section id="feed">
|
||||
<div id="summary"></div>
|
||||
<div id="articles"><span class="spinner"></span> Loading...</div>
|
||||
</section>
|
||||
|
||||
<aside id="data-panel">
|
||||
<div class="data-card"><h4>Fear & Greed</h4><div id="fg"></div></div>
|
||||
<div class="data-card"><h4>Price Action</h4><div id="prices"></div></div>
|
||||
<div class="data-card"><h4>Security Alerts</h4><div id="alerts"></div></div>
|
||||
<div class="data-card"><h4>Prediction Markets</h4><div id="pm"></div></div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let activeCat='All',activeSent='';
|
||||
|
||||
async function load(){
|
||||
document.getElementById('summary').innerHTML='<div class="ai-summary"><span class="spinner"></span> Generating AI market analysis...</div>';
|
||||
try{
|
||||
const s=await fetch('/mcp/news/summary').then(r=>r.json());
|
||||
document.getElementById('summary').innerHTML=`<div class="ai-summary"><span class="ai-badge">AI Analysis</span><h2>Daily Market Rundown</h2><p>${s.summary}</p></div>`;
|
||||
}catch(e){document.getElementById('summary').innerHTML='';}
|
||||
|
||||
try{const c=await fetch('/mcp/news/categories').then(r=>r.json());
|
||||
document.getElementById('categories').innerHTML=c.categories.map(c=>`<span class="cat-link ${c.name===activeCat?'active':''}" onclick="setCat('${c.name}')">${c.icon} ${c.name}</span>`).join('');
|
||||
document.getElementById('sentiments').innerHTML=['bullish','bearish','neutral'].map(s=>`<span class="cat-link ${s===activeSent?'active':''}" onclick="setSent('${s}')">${s==='bullish'?'🟢':s==='bearish'?'🔴':'⚪'} ${s}</span>`).join('');}catch(e){}
|
||||
|
||||
loadArticles();
|
||||
loadDataPanel();
|
||||
}
|
||||
|
||||
async function loadDataPanel(){
|
||||
try{
|
||||
const d=await fetch('/mcp/daily-data').then(r=>r.json());
|
||||
const fg=d.fear_greed||{};
|
||||
document.getElementById('fg').innerHTML=`<div class="data-value" style="color:${fg.value>60?'var(--green)':fg.value<40?'var(--red)':'var(--yellow)'}">${fg.value||50}</div><div class="data-label">${fg.classification||'Neutral'}</div><div class="fg-bar"><div style="width:${fg.value||50}%;height:6px;background:white;border-radius:3px"></div></div>`;
|
||||
|
||||
const pa=d.price_action||{};
|
||||
let priceHTML='';
|
||||
for(const [k,v] of Object.entries(pa.major_assets||{})){priceHTML+=`<div class="data-row"><span>${k.toUpperCase()}</span><span>\$${(v.price||0).toFixed(2)}</span></div>`;}
|
||||
document.getElementById('prices').innerHTML=priceHTML||'<span style="font-size:12px;color:var(--muted)">Loading prices...</span>';
|
||||
|
||||
const sa=d.security_alerts||{};
|
||||
document.getElementById('alerts').innerHTML=sa.count?sa.alerts.slice(0,3).map(a=>`<div style="font-size:11px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.03)"><span class="tag tag-alert">${a.type}</span> ${a.project||a.amount_lost||''}</div>`).join(''):'<span style="font-size:12px;color:var(--muted)">No major alerts today</span>';
|
||||
|
||||
const pm=d.prediction_markets||{};
|
||||
document.getElementById('pm').innerHTML=pm.top_markets?pm.top_markets.slice(0,3).map(m=>`<div style="font-size:11px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.03)">${(m.title||'').slice(0,50)}</div>`).join(''):'<span style="font-size:12px;color:var(--muted)">Loading markets...</span>';
|
||||
}catch(e){console.log(e)}
|
||||
}
|
||||
|
||||
async function loadArticles(){
|
||||
document.getElementById('articles').innerHTML='<span class="spinner"></span> Loading...';
|
||||
try{
|
||||
const params=new URLSearchParams({limit:50});if(activeCat!=='All')params.set('category',activeCat);if(activeSent)params.set('sentiment',activeSent);
|
||||
const r=await fetch('/mcp/news?'+params).then(r=>r.json());
|
||||
document.getElementById('articles').innerHTML=r.articles.map(a=>`<div class="article"><div class="article-source">${a.source} · ${a.published?.slice(0,16)||''}</div><div class="article-title"><a href="${a.url}" target="_blank">${a.title}</a></div><div class="article-summary">${a.summary||''}</div><div class="article-meta"><span class="tag tag-${a.sentiment_label?.slice(0,4)||'neutral'}">${a.sentiment_label||'neutral'}</span><span class="tag tag-cat">${a.category}</span><button class="vote-btn" onclick="vote('${a.id}','up')">▲</button><span>${a.votes_up-a.votes_down}</span><button class="vote-btn" onclick="vote('${a.id}','down')">▼</button></div></div>`).join('')||'<p>No articles found.</p>';
|
||||
}catch(e){}
|
||||
}
|
||||
function setCat(c){activeCat=c;loadArticles();}
|
||||
function setSent(s){activeSent=activeSent===s?'':s;loadArticles();}
|
||||
async function vote(id,dir){await fetch('/mcp/news/vote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({article_id:id,direction:dir})});loadArticles();}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
105
static/x402-api-docs.md
Normal file
105
static/x402-api-docs.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# RMI x402 API — Crypto Intelligence Micropayments
|
||||
|
||||
> **210 tools across 13 chains. Pay per call with USDC on any chain.**
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Discover Available Tools
|
||||
|
||||
```bash
|
||||
curl https://rugmunch.io/.well-known/x402
|
||||
```
|
||||
|
||||
Returns the full x402 discovery document with all tools, prices, supported chains, and facilitators.
|
||||
|
||||
### 2. Browse the Catalog
|
||||
|
||||
```bash
|
||||
curl https://rugmunch.io/api/v1/x402/tools-catalog
|
||||
```
|
||||
|
||||
### 3. Make a Paid Request
|
||||
|
||||
```bash
|
||||
# Example: Check if a token is a honeypot (Base, $0.05 USDC)
|
||||
curl -X POST https://rugmunch.io/api/v1/x402-tools/honeypot_check \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Pay: <your-signed-payment>" \
|
||||
-d '{"chain": "base", "address": "0x..."}'
|
||||
```
|
||||
|
||||
See the [x402 protocol spec](https://x402.org) for payment header details.
|
||||
|
||||
## Supported Chains
|
||||
|
||||
| Chain | USDC Contract | Facilitators |
|
||||
|-------|-------------|--------------|
|
||||
| **Base** | `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` | Coinbase CDP, PayAI, EIP-7702 |
|
||||
| **Ethereum** | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | Primev, PayAI, EIP-7702 |
|
||||
| **Solana** | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | PayAI |
|
||||
| **Arbitrum** | `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` | EIP-7702 |
|
||||
| **Optimism** | `0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85` | EIP-7702 |
|
||||
| **Polygon** | `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` | EIP-7702 |
|
||||
| **BSC** | `0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d` | EIP-7702 |
|
||||
| **Avalanche** | `0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E` | EIP-7702 |
|
||||
| **Fantom** | `0x04068DA6C83AFCFA0e13ba15A6696662335D5B75` | EIP-7702 |
|
||||
| **Gnosis** | `0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83` | EIP-7702 |
|
||||
| **TRON** | USDT/USDC/USDD | Self-verified (fee-free) |
|
||||
| **Bitcoin** | BTC | Self-verified (fee-free, 1-conf) |
|
||||
| **SEPA/EUR** | Fiat EUR off-ramp | AsterPay |
|
||||
|
||||
## Tool Categories
|
||||
|
||||
| Category | Tools | Price Range |
|
||||
|----------|-------|-------------|
|
||||
| **Security** | Honeypot Check, Rug Pull Predictor, Audit, MEV Protection | $0.01–$0.50 |
|
||||
| **Intelligence** | OSINT Identity Hunt, Insider Analysis, Whale Tracking | $0.01–$0.25 |
|
||||
| **Trading** | Smart Money Alpha, Sniper Alert, Sentiment | $0.01–$0.10 |
|
||||
| **Forensics** | Deep Scan, Deployer History, Wallet Dossier | $0.05–$0.25 |
|
||||
| **DeFi** | Yield Scanner, Liquidity Depth, Impermanent Loss | $0.01–$0.10 |
|
||||
| **NFT** | Wash Trading Detection, Floor Analytics | $0.10 |
|
||||
| **Social** | Twitter Profile/Timeline/Search, Discord Alpha | $0.01 |
|
||||
|
||||
## Free Trials
|
||||
|
||||
Every tool offers free trials without a wallet:
|
||||
- **No wallet**: 1 free call per tool
|
||||
- **Connected wallet**: 3 free calls per standard tool, 1 per premium tool
|
||||
|
||||
## Facilitators
|
||||
|
||||
| Facilitator | Settlement | Fee | Chains |
|
||||
|-------------|-----------|-----|--------|
|
||||
| **Coinbase CDP** | Instant | Free | Base, Polygon, Arbitrum, Solana |
|
||||
| **Primev (mev-commit)** | Pre-confirmed | Free | Ethereum |
|
||||
| **EIP-7702** | Instant | Free | BSC, Polygon, Avalanche, Fantom, Gnosis, Arbitrum, Optimism, Base |
|
||||
| **PayAI** | Deferred | Small fee | Base, Solana |
|
||||
| **TRON Self-Verify** | Instant | Free | TRON |
|
||||
| **Bitcoin Self-Verify** | 1-conf | Free | Bitcoin |
|
||||
| **AsterPay** | SEPA off-ramp | Fee | EUR fiats |
|
||||
|
||||
## Payment Wallets
|
||||
|
||||
- **EVM**: `0x1E3AC01d0fdb976179790BDD02823196A92705C9`
|
||||
- **Solana**: `Gix4P9AmwcZRGzr2hCEME5m2QAvY86dBfm8c7e7MpFzv`
|
||||
- **TRON**: `TY27YaMiiXdJ6JcjnLdfvoACxT16V6vu5q`
|
||||
- **Bitcoin**: `17PiUt1iqfAfM7o7abHaR3GUZaNHBpn7Fz`
|
||||
|
||||
## Refund Policy
|
||||
|
||||
Full refund if a tool returns no data. Request within 48h via `POST /api/v1/x402/refund` with your transaction hash.
|
||||
|
||||
## Protocol Compliance
|
||||
|
||||
- [x] x402 v2 discovery at `/.well-known/x402`
|
||||
- [x] HTTP 402 status for unpaid requests
|
||||
- [x] Multiple facilitator support
|
||||
- [x] Free trial tier
|
||||
- [x] Refund endpoint
|
||||
|
||||
## Links
|
||||
|
||||
- **Discovery**: https://rugmunch.io/.well-known/x402
|
||||
- **API Docs**: https://rugmunch.io/docs
|
||||
- **Health**: https://rugmunch.io/health
|
||||
- **GitHub**: https://github.com/Rug-Munch-Media-LLC
|
||||
Loading…
Add table
Add a link
Reference in a new issue