- Fix pollVerifiedWallets: route import wallets to /wallet endpoint and reconstruct running-balance ledger - Fix generateRandomAddress: append 'IMPORT' suffix to prevent fake EVM addresses - Add import chain: CSV upload via multipart, IMPORT_ONLY wallet registration - Add HYPE summary cards with holdings, PnL, avg buy price, total invested - Add BTC and SATS to TOKENS config - Add backend DELETE on wallet removal (skip for import wallets) - Fix chain name: hyperliquide → hyperevm (chainId 999) - Add HSTS and security headers to nginx config
2859 lines
138 KiB
HTML
2859 lines
138 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>cbBTC Treasury Dashboard</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||
<script type="module">
|
||
import { WalletManager } from './wallets.js';
|
||
window.WalletManager = WalletManager;
|
||
</script>
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background-color: #05070B; }
|
||
|
||
/* Sidebar */
|
||
.sidebar-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
|
||
.sidebar-overlay.open { opacity: 1; pointer-events: auto; }
|
||
.sidebar-panel { position: fixed; top: 0; right: 0; width: 320px; height: 100vh; background: #090D14; border-left: 1px solid #1A1F2C; z-index: 50; transform: translateX(100%); transition: transform 0.3s; overflow-y: auto; }
|
||
.sidebar-panel.open { transform: translateX(0); }
|
||
|
||
/* Modal */
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 60; display: none; align-items: center; justify-content: center; }
|
||
.modal-overlay.open { display: flex; }
|
||
.modal-box { background: #090D14; border: 1px solid #1A1F2C; border-radius: 16px; width: 400px; max-width: 90vw; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
|
||
|
||
.apexcharts-tooltip { background: #111827 !important; border: 1px solid #1F2937 !important; border-radius: 12px !important; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5) !important; padding: 10px 16px !important; color: white !important; }
|
||
.apexcharts-xaxistooltip { display: none !important; }
|
||
.apexcharts-marker { stroke-width: 3px !important; fill: #111827 !important; }
|
||
|
||
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; }
|
||
.badge-buy { background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(31, 111, 235, 0.3); }
|
||
.badge-sell { background: rgba(248, 81, 73, 0.2); color: #ff7b72; border: 1px solid rgba(248, 81, 73, 0.3); }
|
||
.badge-aavein { background: rgba(137, 87, 229, 0.2); color: #bc8cff; border: 1px solid rgba(137, 87, 229, 0.3); }
|
||
.badge-aaveout { background: rgba(210, 153, 34, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.3); }
|
||
|
||
.filter-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; width: fit-content; }
|
||
.filter-item { display: flex; align-items: center; padding: 4px; background: rgba(255, 255, 255, 0.03); border-radius: 4px; transition: all 0.2s; cursor: pointer; }
|
||
.filter-item:hover { background: rgba(255, 255, 255, 0.08); }
|
||
.filter-checkbox { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
|
||
.filter-checkbox:checked { border-color: transparent; }
|
||
.filter-checkbox.buy:checked { background: #58a6ff; box-shadow: 0 0 8px rgba(88, 166, 255, 0.4); }
|
||
.filter-checkbox.sell:checked { background: #ff7b72; box-shadow: 0 0 8px rgba(255, 123, 114, 0.4); }
|
||
.filter-checkbox.aavein:checked { background: #bc8cff; box-shadow: 0 0 8px rgba(188, 140, 255, 0.4); }
|
||
.filter-checkbox.aaveout:checked { background: #d29922; box-shadow: 0 0 8px rgba(210, 153, 34, 0.4); }
|
||
|
||
.wallet-filter-toggle { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
|
||
.wallet-filter-toggle:checked { background: var(--wallet-color); border-color: transparent; box-shadow: 0 0 8px color-mix(in srgb, var(--wallet-color) 40%, transparent); }
|
||
|
||
.platform-checkbox { appearance: none; width: 14px; height: 14px; border-radius: 3px; border: 1.5px solid rgba(255,255,255,0.15); cursor: pointer; position: relative; transition: all 0.2s; flex-shrink: 0; }
|
||
.platform-checkbox:checked { border-color: transparent; }
|
||
.platform-checkbox.platform-aave:checked { background: #9896FF; box-shadow: 0 0 10px rgba(152, 150, 255, 0.5); }
|
||
.platform-checkbox.platform-hyperlend:checked { background: #caeae5; box-shadow: 0 0 10px rgba(202, 234, 229, 0.5); }
|
||
|
||
.wallet-color-dot {
|
||
width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
|
||
flex-shrink: 0; transition: transform 0.15s, box-shadow 0.15s;
|
||
}
|
||
.wallet-color-dot:hover {
|
||
transform: scale(1.25); box-shadow: 0 0 10px var(--wallet-color);
|
||
}
|
||
|
||
.color-picker-overlay {
|
||
position: fixed; inset: 0; z-index: 70; display: none; background: rgba(0,0,0,0.5);
|
||
}
|
||
.color-picker-overlay.open { display: block; }
|
||
.color-picker-popup {
|
||
position: absolute; background: #090D14; border: 1px solid #1A1F2C;
|
||
border-radius: 12px; padding: 12px; box-shadow: 0 16px 40px rgba(0,0,0,0.7);
|
||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
|
||
z-index: 80; width: 210px;
|
||
}
|
||
.color-picker-swatch {
|
||
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
|
||
transition: transform 0.15s, box-shadow 0.15s;
|
||
border: 2px solid transparent;
|
||
}
|
||
.color-picker-swatch:hover {
|
||
transform: scale(1.2); border-color: rgba(255,255,255,0.3);
|
||
box-shadow: 0 0 12px var(--swatch-color);
|
||
}
|
||
.color-picker-swatch.selected {
|
||
border-color: #fff; box-shadow: 0 0 14px var(--swatch-color);
|
||
}
|
||
|
||
.ledger-header { display: flex; align-items: center; }
|
||
.ledger-title-col { width: 260px; flex-shrink: 0; }
|
||
.col-timestamp { width: 140px; flex-shrink: 0; }
|
||
.col-action { width: 150px; flex-shrink: 0; }
|
||
.col-wallet { width: 120px; flex-shrink: 0; }
|
||
.col-value { width: auto; }
|
||
|
||
.tooltip-cell { position: relative; }
|
||
.tooltip-cell .tooltip-box {
|
||
display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%);
|
||
z-index: 100; background: #111827; border: 1px solid #374151; border-radius: 8px;
|
||
padding: 10px 14px; box-shadow: 0 10px 25px rgba(0,0,0,0.6);
|
||
min-width: 200px; font-size: 12px; white-space: nowrap;
|
||
}
|
||
.tooltip-cell:hover .tooltip-box { display: block; }
|
||
.tooltip-cell .tooltip-box::after {
|
||
content: ''; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
|
||
border: 6px solid; border-color: #374151 transparent transparent transparent;
|
||
}
|
||
.tooltip-row { display: flex; justify-content: space-between; gap: 16px; padding: 2px 0; }
|
||
.tooltip-row.total { border-top: 1px solid #374151; margin-top: 6px; padding-top: 6px; font-weight: 700; }
|
||
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: #05070B; }
|
||
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: #374151; }
|
||
|
||
/* Draggable pills */
|
||
#wallet-pills { display: flex; align-items: center; gap: 6px; }
|
||
#wallet-pills .pill-btn { user-select: none; position: relative; }
|
||
#wallet-pills .pill-btn.dragging { opacity: 0.3; transform: scale(0.95); }
|
||
#wallet-pills .pill-btn.pill-dragging { opacity: 0.3; transform: scale(0.95); }
|
||
.drag-insert-line {
|
||
width: 3px; min-height: 32px; background: #FF7A00;
|
||
border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px #FF7A00;
|
||
position: absolute; left: -3px; top: 0;
|
||
}
|
||
|
||
/* Toast notifications */
|
||
.toast-box {
|
||
background: #090D14; border: 1px solid #1A1F2C; border-radius: 10px;
|
||
padding: 12px 18px; min-width: 280px; box-shadow: 0 12px 32px rgba(0,0,0,0.7);
|
||
display: flex; align-items: flex-start; gap: 10px;
|
||
animation: toast-in 0.3s ease-out;
|
||
}
|
||
.toast-box.toast-exit { animation: toast-out 0.25s ease-in forwards; }
|
||
@keyframes toast-in { from { opacity: 0; transform: translateY(12px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||
@keyframes toast-out { to { opacity: 0; transform: translateY(8px) scale(0.95); } }
|
||
.toast-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
|
||
.toast-body { flex: 1; }
|
||
.toast-title { font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 2px; }
|
||
.toast-message { font-size: 12px; color: #9CA3AF; line-height: 1.4; }
|
||
.toast-progress { height: 3px; background: #1F2937; border-radius: 2px; margin-top: 8px; overflow: hidden; }
|
||
.toast-progress-bar { height: 100%; border-radius: 2px; animation: toast-progress-shrink 4s linear forwards; }
|
||
@keyframes toast-progress-shrink { to { width: 0%; } }
|
||
|
||
/* Upload button spinner */
|
||
.import-btn-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,122,0,0.2); border-top-color: #FF7A00; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
</head>
|
||
<body class="p-4 md:p-8 min-h-screen text-white">
|
||
|
||
<!-- Sidebar Overlay -->
|
||
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()"></div>
|
||
|
||
<!-- Sidebar Panel -->
|
||
<div id="sidebar-panel" class="sidebar-panel">
|
||
<div class="p-4 border-b border-[#1A1F2C] flex items-center justify-between">
|
||
<h2 class="text-sm font-bold text-gray-400 uppercase tracking-widest">Wallet Management</h2>
|
||
<button onclick="closeSidebar()" class="text-gray-500 hover:text-white transition">✕</button>
|
||
</div>
|
||
<div id="sidebar-wallet-list" class="p-4 space-y-3"></div>
|
||
<div class="p-4 border-t border-[#1A1F2C] space-y-3">
|
||
<button onclick="openAddWalletModal()" class="w-full bg-[#FF7A00]/10 border border-[#FF7A00]/30 text-[#FF7A00] hover:bg-[#FF7A00]/20 px-4 py-3 rounded-lg font-bold text-sm transition flex items-center justify-center gap-2">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
Add Wallet
|
||
</button>
|
||
<button onclick="openImportCsvModal()" class="w-full bg-[#3b82f6]/10 border border-[#3b82f6]/30 text-[#58a6ff] hover:bg-[#3b82f6]/20 px-4 py-3 rounded-lg font-bold text-sm transition flex items-center justify-center gap-2">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||
Import History
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Wallet Modal -->
|
||
<div id="add-wallet-modal" class="modal-overlay" onclick="if(event.target===this)closeAddWalletModal()">
|
||
<div class="modal-box">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h3 class="text-lg font-bold text-white">Add Wallet</h3>
|
||
<button onclick="closeAddWalletModal()" class="text-gray-500 hover:text-white text-xl leading-none transition">✕</button>
|
||
</div>
|
||
<form id="add-wallet-form" onsubmit="return handleAddWallet(event)">
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Address</label>
|
||
<input id="input-address" type="text" placeholder="Connect wallet..." class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 transition font-mono" readonly>
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Chain</label>
|
||
<input id="input-chain-display" type="text" placeholder="—" class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 transition" readonly>
|
||
<input id="input-chain-hidden" type="hidden" value="base">
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Lending Platform</label>
|
||
<div id="lending-platform-selector" class="flex flex-col gap-2"></div>
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Nickname</label>
|
||
<input id="input-nickname" type="text" placeholder="Whale Wallet A" class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#FF7A00]/50 transition" required>
|
||
</div>
|
||
<div class="mb-6">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Color</label>
|
||
<div id="add-wallet-color-picker" class="flex flex-wrap gap-2"></div>
|
||
</div>
|
||
<div id="add-wallet-error" class="hidden mb-3 text-red-400 text-xs font-medium"></div>
|
||
<button type="submit" class="w-full bg-[#FF7A00] hover:bg-[#FF7A00]/90 text-white font-bold px-4 py-3 rounded-lg transition">Monitor Wallet</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Color Picker Popup -->
|
||
<div id="color-picker-overlay" class="color-picker-overlay" onclick="closeColorPicker()"></div>
|
||
|
||
<!-- Import CSV Modal -->
|
||
<div id="import-csv-modal" class="modal-overlay" onclick="if(event.target===this)closeImportCsvModal()">
|
||
<div class="modal-box">
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h3 class="text-lg font-bold text-white">Import Transaction History</h3>
|
||
<button onclick="closeImportCsvModal()" class="text-gray-500 hover:text-white text-xl leading-none transition">✕</button>
|
||
</div>
|
||
<div class="text-[11px] text-gray-500 mb-4 leading-relaxed">
|
||
Imports transaction history via CSV upload. The wallet is registered as <span class="text-gray-400 font-mono">IMPORT_ONLY</span> and the CSV is sent directly to the backend for processing.
|
||
</div>
|
||
<form id="import-csv-form" onsubmit="return handleImportCsv(event)">
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Address <span class="text-red-400">*</span></label>
|
||
<input id="import-address" type="text" placeholder="0x000..." class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#58a6ff]/50 transition font-mono" required>
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Nickname <span class="text-red-400">*</span></label>
|
||
<input id="import-nickname" type="text" placeholder="Whale Wallet A" class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#58a6ff]/50 transition" required>
|
||
</div>
|
||
<div class="mb-4">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Color</label>
|
||
<div id="import-color-picker" class="flex flex-wrap gap-2"></div>
|
||
</div>
|
||
<div class="mb-6">
|
||
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">CSV File <span class="text-red-400">*</span></label>
|
||
<div id="import-file-display" class="bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-sm text-gray-500 cursor-pointer hover:border-[#58a6ff]/50 transition flex items-center gap-2" onclick="document.getElementById('csv-import-input').click()">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||
<span id="import-filename">Click to select a .csv file</span>
|
||
</div>
|
||
</div>
|
||
<div id="import-csv-error" class="hidden mb-3 text-red-400 text-xs font-medium"></div>
|
||
<div id="import-csv-progress" class="hidden mb-3">
|
||
<div class="text-[10px] font-bold text-gray-500 uppercase mb-1">Processing...</div>
|
||
<div class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-full h-2 overflow-hidden">
|
||
<div class="import-progress-bar h-full bg-[#58a6ff] rounded-full animate-pulse"></div>
|
||
</div>
|
||
</div>
|
||
<button type="submit" id="import-csv-submit" class="w-full bg-[#3b82f6] hover:bg-[#3b82f6]/90 text-white font-bold px-4 py-3 rounded-lg transition">
|
||
<span id="import-submit-text">Import</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast Notification -->
|
||
<div id="toast-container" class="fixed bottom-6 right-6 z-[90] flex flex-col gap-2"></div>
|
||
|
||
<!-- Hidden CSV File Input -->
|
||
<input id="csv-import-input" type="file" accept=".csv,text/csv" class="hidden" onchange="handleCsvFileSelect(event)">
|
||
|
||
<!-- Main Dashboard -->
|
||
<div class="max-w-7xl mx-auto">
|
||
<!-- Top Nav Bar -->
|
||
<div class="flex flex-col md:flex-row justify-between items-end mb-8 gap-4">
|
||
<div>
|
||
<h2 class="text-xs font-bold text-gray-500 mb-1 uppercase tracking-[0.2em]">Treasury Intelligence</h2>
|
||
<h1 class="text-3xl font-black text-white tracking-tight">cbBTC Acquisition <span class="text-[#FF7A00]">Core</span></h1>
|
||
</div>
|
||
<div class="flex flex-col items-end">
|
||
<div class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Tracked Wallets</div>
|
||
<div class="flex gap-2 items-center">
|
||
<div id="wallet-pills" class="flex gap-2"></div>
|
||
<button onclick="openSidebar()" class="p-2 bg-[#090D14] border border-[#1A1F2C]/60 rounded-lg hover:bg-[#1A1F2C]/40 transition flex-shrink-0" title="Wallet Management">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Summary Cards -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Current Holdings</div>
|
||
<div class="text-xl font-bold text-white"><span id="net-held-val">0.000000</span> <span class="text-xs font-medium text-gray-500">BTC</span></div>
|
||
<div class="text-xs font-bold text-green-400 mt-1" id="current-usd-val">$Loading...</div>
|
||
</div>
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Total PnL</div>
|
||
<div class="text-xl font-bold" id="total-pnl-val">$--.--</div>
|
||
<div class="text-xs font-bold mt-1" id="pnl-percent-val">--.--%</div>
|
||
</div>
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Avg Buy Price</div>
|
||
<div class="text-xl font-bold text-white" id="avg-buy-val">$93,444</div>
|
||
<div class="text-xs font-medium text-gray-500 mt-1">Cost Basis</div>
|
||
</div>
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Total Invested</div>
|
||
<div class="text-xl font-bold text-white" id="total-invested-val">$88,887.34</div>
|
||
<div class="text-xs font-medium text-gray-500 mt-1"><span id="tx-count-val">340</span> Transactions</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HYPE Summary Cards -->
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Current Holdings</div>
|
||
<div class="text-xl font-bold text-white"><span id="hype-held-val">0.00</span> <span class="text-xs font-medium text-gray-500">HYPE</span></div>
|
||
<div class="text-xs font-bold text-green-400 mt-1" id="hype-usd-val">$Loading...</div>
|
||
</div>
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Total PnL</div>
|
||
<div class="text-xl font-bold" id="hype-pnl-val">$--.--</div>
|
||
<div class="text-xs font-bold mt-1" id="hype-pnl-percent-val">--.--%</div>
|
||
</div>
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Avg Buy Price</div>
|
||
<div class="text-xl font-bold text-white" id="hype-avg-buy-val">$--.--</div>
|
||
<div class="text-xs font-medium text-gray-500 mt-1">Cost Basis</div>
|
||
</div>
|
||
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
|
||
<div class="text-[10px] font-bold text-[#caeae5]/60 uppercase tracking-wider mb-1">Total Invested</div>
|
||
<div class="text-xl font-bold text-white" id="hype-total-invested-val">$--.--</div>
|
||
<div class="text-xs font-medium text-gray-500 mt-1"><span id="hype-tx-count-val">0</span> Transactions</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts Grid -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<div class="flex items-center space-x-2.5 mb-1">
|
||
<span class="text-sm font-medium text-gray-400">BTC Price</span>
|
||
<div class="flex items-center space-x-1.5 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full">
|
||
<span class="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></span>
|
||
<span class="text-[10px] font-bold text-green-400 uppercase tracking-wide">Real-Time</span>
|
||
</div>
|
||
</div>
|
||
<h1 id="price-card-btc" class="text-3xl font-bold tracking-tight text-white">$Loading...</h1>
|
||
</div>
|
||
<div class="text-right">
|
||
<span id="return-card-btc" class="text-lg font-bold text-gray-400">--.--%</span>
|
||
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Since <span id="since-date">--</span></p>
|
||
</div>
|
||
</div>
|
||
<div class="w-full h-52 mt-4" id="chart-container-btc"></div>
|
||
</div>
|
||
|
||
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-400">BTC Treasury Growth</span>
|
||
<h1 class="text-3xl font-bold tracking-tight text-white"><span id="cumul-btc-val">0.000000</span> <span class="text-sm text-gray-500">BTC</span></h1>
|
||
</div>
|
||
<div class="text-right">
|
||
<span class="text-lg font-bold" style="color: #FF7A00;">NET HELD</span>
|
||
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Cumulative Acquisition</p>
|
||
</div>
|
||
</div>
|
||
<div class="w-full h-52 mt-4" id="chart-container-cumul"></div>
|
||
</div>
|
||
|
||
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-400">Avg BTC Acquired/Day</span>
|
||
<h1 class="text-3xl font-bold tracking-tight text-white"><span id="avg-sats-val">--</span> <span class="text-sm text-gray-500">sats</span></h1>
|
||
</div>
|
||
<div class="text-right">
|
||
<span class="text-lg font-bold" style="color: #FF7A00;">RUNNING AVG</span>
|
||
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Sats per Day</p>
|
||
</div>
|
||
</div>
|
||
<div class="w-full h-52 mt-4" id="chart-container-sats"></div>
|
||
</div>
|
||
|
||
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-400">Holdings Breakdown</span>
|
||
<h1 class="text-3xl font-bold tracking-tight text-white"><span id="breakdown-total-val">0.000000</span> <span class="text-sm text-gray-500">BTC</span></h1>
|
||
</div>
|
||
<div class="text-right">
|
||
<span class="text-lg font-bold" style="color: #58a6ff;">COMPOSITION</span>
|
||
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Direct + Aave</p>
|
||
</div>
|
||
</div>
|
||
<div class="w-full h-52 mt-4" id="chart-container-breakdown"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Transaction Ledger -->
|
||
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 shadow-2xl overflow-hidden">
|
||
<div class="p-6 border-b border-[#1A1F2C]/60">
|
||
<div class="ledger-header">
|
||
<div class="ledger-title-col">
|
||
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest whitespace-nowrap">Transaction Ledger</h3>
|
||
</div>
|
||
<div class="flex-1 flex">
|
||
<div class="col-action">
|
||
<div class="filter-grid">
|
||
<label class="filter-item" title="Show BUY transactions">
|
||
<input type="checkbox" class="filter-checkbox buy" data-filter="BUY" checked>
|
||
</label>
|
||
<label class="filter-item" title="Show SELL transactions">
|
||
<input type="checkbox" class="filter-checkbox sell" data-filter="SELL" checked>
|
||
</label>
|
||
<label class="filter-item" title="Show AAVE_IN transactions (to Aave)">
|
||
<input type="checkbox" class="filter-checkbox aavein" data-filter="AAVE_IN" checked>
|
||
</label>
|
||
<label class="filter-item" title="Show AAVE_OUT transactions (from Aave)">
|
||
<input type="checkbox" class="filter-checkbox aaveout" data-filter="AAVE_OUT" checked>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="col-wallet">
|
||
<div class="filter-grid" id="ledger-filter-wallets"></div>
|
||
</div>
|
||
</div>
|
||
<span class="text-[10px] font-medium text-gray-600 uppercase">Archive</span>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table id="transaction-table" class="w-full text-left border-collapse">
|
||
<thead>
|
||
<tr class="bg-[#05070B]/50 text-[10px] font-bold text-gray-500 uppercase tracking-wider">
|
||
<th class="px-6 py-4 col-timestamp">Timestamp</th>
|
||
<th class="px-6 py-4 col-wallet">Wallet</th>
|
||
<th class="px-6 py-4 col-value text-right">Cold Storage</th>
|
||
<th class="px-6 py-4 col-value text-right">Collateral</th>
|
||
<th class="px-6 py-4 col-value text-right">Borrows</th>
|
||
<th class="px-6 py-4">Hash</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="text-sm" id="table-body">
|
||
<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500 text-sm">Loading snapshots...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
/* ===================================================================
|
||
ESM WalletManager — loaded in <script type="module"> above.
|
||
Synchronize a fallback for classic <script> context so the
|
||
dashboard initialization below can use it.
|
||
=================================================================== */
|
||
(function(){
|
||
if (typeof window.WalletManager === 'undefined') {
|
||
/* If ESM module didn't fire (e.g. opened directly),
|
||
provide a lightweight localStorage helper. */
|
||
window.WalletManager = class {
|
||
constructor() { this._wallets = []; }
|
||
loadWallets() {
|
||
try {
|
||
const raw = localStorage.getItem('cbbtc_tracked_wallets');
|
||
this._wallets = raw ? JSON.parse(raw) : [];
|
||
if (!Array.isArray(this._wallets)) this._wallets = [];
|
||
this._wallets = this._wallets.filter(w => w && w.address && w.chain).map(w => ({ address: String(w.address), chain: String(w.chain).toLowerCase(), nickname: String(w.nickname||''), isVerified: !!(w.isVerified && w.messageData), signature: w.signature ? String(w.signature) : null, messageData: w.messageData ? w.messageData : null }));
|
||
/* Discard unverified wallets — keep only verified EVM or non-verification-chain wallets */
|
||
this._wallets = this._wallets.filter(w => ['btc','bitcoin','solana'].includes(w.chain) ? true : w.isVerified);
|
||
this._persist();
|
||
this.getVerifiedWallets = function() { return this._wallets.filter(w => ['btc','bitcoin','solana'].includes(w.chain) ? true : w.isVerified).map(w => ({...w})); };
|
||
} catch(e) { this._wallets = []; }
|
||
return this._wallets;
|
||
}
|
||
getWallets() { return this._wallets.map(w => ({...w})); }
|
||
_persist() { try { localStorage.setItem('cbbtc_tracked_wallets', JSON.stringify(this._wallets)); } catch(e){} }
|
||
addWallet(address, chain, nickname='') {
|
||
const addr = String(address).trim();
|
||
const ch = String(chain).toLowerCase();
|
||
const nm = String(nickname).trim();
|
||
if (/^(0x)?[0-9a-fA-F]{40}$/i.test(addr) && (this._wallets.some(w => w.address===addr && w.chain===ch))) return { success: false, error: 'Wallet already tracked' };
|
||
if (!/^(0x)?[0-9a-fA-F]{40}$/i.test(addr)) return { success: false, error: 'Invalid address' };
|
||
const wallet = { address: addr, chain: ch, nickname: nm, isVerified: false, signature: null, messageData: null };
|
||
this._wallets.push(wallet);
|
||
this._persist();
|
||
return { success: true, wallet };
|
||
}
|
||
removeWallet(address, chain) {
|
||
const idx = this._wallets.findIndex(w => w.address === address && w.chain === chain);
|
||
if (idx === -1) return { success: false, error: 'Wallet not found' };
|
||
this._wallets.splice(idx, 1);
|
||
this._persist();
|
||
return { success: true, removedAddress: address };
|
||
}
|
||
findWallet(address, chain) { return this._wallets.find(w => w.address === address && w.chain === chain); }
|
||
renameWallet(address, chain, nickname) {
|
||
const w = this.findWallet(address, chain);
|
||
if (!w) return;
|
||
w.nickname = String(nickname).trim();
|
||
this._persist();
|
||
}
|
||
verifyWallet(address, chain, signature, messageData) {
|
||
const w = this.findWallet(address, chain);
|
||
if (!w) return { success: false, error: 'Wallet not found' };
|
||
w.isVerified = true;
|
||
w.signature = String(signature);
|
||
w.messageData = messageData || null;
|
||
this._persist();
|
||
return { success: true };
|
||
}
|
||
setWalletOrder(keys) {
|
||
const keySet = this._wallets.map(w => w.address + ':' + w.chain);
|
||
const ordered = [];
|
||
for (const k of keys) {
|
||
if (keySet.includes(k)) {
|
||
const [addr, chain] = k.split(':');
|
||
const w = this._wallets.find(x => x.address === addr && x.chain === chain);
|
||
if (w) ordered.push(w);
|
||
}
|
||
}
|
||
const remaining = this._wallets.filter(w => !ordered.includes(w));
|
||
this._wallets = [...ordered, ...remaining];
|
||
this._persist();
|
||
}
|
||
};
|
||
}
|
||
})();
|
||
|
||
/* ===================================================================
|
||
Constants
|
||
=================================================================== */
|
||
const orangeBrandColor = '#FF7A00';
|
||
const blueBrandColor = '#3b82f6';
|
||
const API_BASE = window.location.origin;
|
||
const WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF'];
|
||
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}, "USDCn": {"decimals": 6, "priceSymbol": "USDC"}, "wHYPE": {"decimals": 18, "priceSymbol": "HYPE"}, "BTC": {"decimals": 8, "priceSymbol": "BTC"}, "SATS": {"decimals": 0, "priceSymbol": "BTC"}};
|
||
function symbolDisplay(s) { return s === 'USDCn' ? 'USDC' : s; }
|
||
|
||
/* Compound key for wallet identity: "{address}:{chain}" */
|
||
function walletKey(address, chain) { return address + ':' + chain; }
|
||
function walletKeyFromAddress(address) { return walletKey(address, 'base'); }
|
||
|
||
/* ===================================================================
|
||
App State
|
||
=================================================================== */
|
||
const wm = new WalletManager();
|
||
wm.loadWallets();
|
||
|
||
let currentAggregatedSeries = [];
|
||
let currentWalletSeries = [];
|
||
let currentNetHeld = 0;
|
||
let currentBuyCost = 0;
|
||
let currentBuyAmount = 0;
|
||
let currentHypeHeld = 0;
|
||
let currentHypeBuyCost = 0;
|
||
let currentHypeBuyAmount = 0;
|
||
let currentHypeTxCount = 0;
|
||
let btcPriceData = [];
|
||
let currentCutoffMs;
|
||
let tickIntervalId;
|
||
|
||
/* Aave data */
|
||
let aavePriceMap = {};
|
||
let lastFetchMs = 0;
|
||
let todayBtcPrice = null;
|
||
|
||
/* Sync state per address: 'synced' | 'syncing' | 'pending' | undefined */
|
||
const walletSyncState = {};
|
||
|
||
/* Per-address snapshot data, keyed by address */
|
||
const addressSnapshots = {};
|
||
|
||
/* Cached CSV import context: parsed records, selected address, chain */
|
||
let _csvImportRecords = [];
|
||
|
||
/* Assigned color index for dynamic wallets */
|
||
|
||
/* Assigned color index for dynamic wallets */
|
||
let _colorIdx = 0;
|
||
function getColorForWallet(address, chain) {
|
||
const key = walletKey(address, chain || 'base');
|
||
const c = localStorage.getItem('cbbtc_color_' + key);
|
||
if (c) return c;
|
||
const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length];
|
||
_colorIdx++;
|
||
localStorage.setItem('cbbtc_color_' + key, color);
|
||
return color;
|
||
}
|
||
|
||
function setColorForWallet(address, chain, color) {
|
||
localStorage.setItem('cbbtc_color_' + walletKey(address, chain || 'base'), color);
|
||
renderAll();
|
||
}
|
||
|
||
function cycleWalletColor(address, chain) {
|
||
const next = getNextColor(address, chain);
|
||
setColorForWallet(address, chain, next);
|
||
}
|
||
|
||
function getNextColor(address, chain) {
|
||
const current = getColorForWallet(address, chain);
|
||
const idx = WALLET_COLORS.indexOf(current);
|
||
const nextIdx = (idx + 1) % WALLET_COLORS.length;
|
||
return WALLET_COLORS[nextIdx];
|
||
}
|
||
|
||
/* Color Picker */
|
||
let _colorPickerTarget = null; /* {address, chain} */
|
||
|
||
function openColorPicker(event, address, chain) {
|
||
event.stopPropagation();
|
||
_colorPickerTarget = { address, chain };
|
||
const overlay = document.getElementById('color-picker-overlay');
|
||
const popup = document.getElementById('color-picker-popup');
|
||
|
||
if (popup) popup.remove();
|
||
|
||
const newPopup = document.createElement('div');
|
||
newPopup.id = 'color-picker-popup';
|
||
newPopup.className = 'color-picker-popup';
|
||
newPopup.style.left = event.clientX + 'px';
|
||
newPopup.style.top = event.clientY + 'px';
|
||
const currentColor = getColorForWallet(address, chain);
|
||
WALLET_COLORS.forEach((c, i) => {
|
||
const swatch = document.createElement('div');
|
||
swatch.className = 'color-picker-swatch' + (c.toLowerCase() === currentColor.toLowerCase() ? ' selected' : '');
|
||
swatch.style.setProperty('--swatch-color', c);
|
||
swatch.style.background = c;
|
||
swatch.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (_colorPickerTarget) setColorForWallet(_colorPickerTarget.address, _colorPickerTarget.chain, c);
|
||
closeColorPicker();
|
||
});
|
||
newPopup.appendChild(swatch);
|
||
});
|
||
document.body.appendChild(newPopup);
|
||
const rect = newPopup.getBoundingClientRect();
|
||
if (rect.right > window.innerWidth) newPopup.style.left = (event.clientX - rect.width) + 'px';
|
||
if (rect.bottom > window.innerHeight) newPopup.style.top = (event.clientY - rect.height) + 'px';
|
||
overlay.classList.add('open');
|
||
}
|
||
|
||
function closeColorPicker() {
|
||
document.getElementById('color-picker-overlay').classList.remove('open');
|
||
const popup = document.getElementById('color-picker-popup');
|
||
if (popup) popup.remove();
|
||
_colorPickerTargetAddr = null;
|
||
}
|
||
|
||
function selectColor(color) {
|
||
if (_colorPickerTargetAddr) {
|
||
setColorForWallet(_colorPickerTargetAddr, color);
|
||
}
|
||
}
|
||
|
||
/* Wallet filter state — stores UNCHECKED compound keys. Empty = all checked. */
|
||
function getWalletFilter() {
|
||
const raw = JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]');
|
||
/* Migrate old plain-address entries to compound keys */
|
||
return raw.filter(k => k.includes(':')).concat(
|
||
raw.filter(k => !k.includes(':')).map(k => walletKeyFromAddress(k))
|
||
);
|
||
}
|
||
function isWalletInLedger(address, chain) {
|
||
return !getWalletFilter().includes(walletKey(address, chain || 'base'));
|
||
}
|
||
|
||
function setWalletChecked(key, checked) {
|
||
let unchecked = getWalletFilter();
|
||
|
||
if (!checked) {
|
||
if (!unchecked.includes(key)) unchecked.push(key);
|
||
} else {
|
||
const idx = unchecked.indexOf(key);
|
||
if (idx >= 0) unchecked.splice(idx, 1);
|
||
}
|
||
|
||
if (unchecked.length === 0) {
|
||
localStorage.removeItem('cbbtc_ledger_wallets');
|
||
} else {
|
||
localStorage.setItem('cbbtc_ledger_wallets', JSON.stringify(unchecked));
|
||
}
|
||
|
||
syncAllWalletCheckboxes();
|
||
updateDashboard();
|
||
}
|
||
|
||
function toggleWalletFilter(key) {
|
||
setWalletChecked(key, !getWalletFilter().includes(key));
|
||
}
|
||
|
||
function syncWalletCheckboxes(key, checked) {
|
||
document.querySelectorAll(`.wallet-filter-toggle[data-key="${key}"]`).forEach(cb => {
|
||
cb.checked = checked;
|
||
});
|
||
}
|
||
|
||
function syncAllWalletCheckboxes() {
|
||
wm.getWallets().forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const checked = isWalletInLedger(w.address, w.chain);
|
||
document.querySelectorAll(`.wallet-filter-toggle[data-key="${key}"]`).forEach(cb => {
|
||
cb.checked = checked;
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderAll() {
|
||
renderSidebar();
|
||
renderWalletPills();
|
||
renderLedgerFilterWallets();
|
||
updateDashboard();
|
||
}
|
||
|
||
/* ===================================================================
|
||
Sidebar
|
||
=================================================================== */
|
||
function openSidebar() {
|
||
document.getElementById('sidebar-overlay').classList.add('open');
|
||
document.getElementById('sidebar-panel').classList.add('open');
|
||
renderSidebar();
|
||
}
|
||
|
||
function closeSidebar() {
|
||
document.getElementById('sidebar-overlay').classList.remove('open');
|
||
document.getElementById('sidebar-panel').classList.remove('open');
|
||
}
|
||
|
||
function renderSidebar() {
|
||
const wallets = wm.getWallets();
|
||
const container = document.getElementById('sidebar-wallet-list');
|
||
if (wallets.length === 0) {
|
||
container.innerHTML = '<div class="text-gray-600 text-xs text-center py-6">No wallets tracked yet.<br>Add one to get started.</div>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
wallets.forEach((w, i) => {
|
||
const color = getColorForWallet(w.address, w.chain);
|
||
const shortAddr = w.address.slice(0, 6) + '...' + w.address.slice(-4);
|
||
const syncState = walletSyncState[walletKey(w.address, w.chain)];
|
||
const syncBadge = syncState === 'syncing' || syncState === 'pending'
|
||
? `<span class="text-yellow-400 text-[10px] ml-2">⏳ Syncing</span>`
|
||
: '';
|
||
const nick = w.nickname || shortAddr;
|
||
const isEVM = !['btc', 'bitcoin', 'solana'].includes(w.chain);
|
||
const isVerified = w.isVerified;
|
||
const verifyBadge = isEVM
|
||
? (isVerified ? '<span class="text-green-400 text-[10px] ml-2">✓ Verified</span>' : '<span class="text-red-400 text-[10px] ml-2">⚠ Unverified</span>')
|
||
: '<span class="text-blue-400 text-[10px] ml-2">🔒 No-Sig</span>';
|
||
const verifyBtn = isEVM && !isVerified
|
||
? '<button onclick="handleVerifyWallet(\'' + w.address + '\',\'' + w.chain + '\',\'' + nick + '\')" class="text-[10px] bg-[#FF7A00]/20 hover:bg-[#FF7A00]/40 text-[#FF7A00] font-bold px-2 py-0.5 rounded transition" title="Verify ownership via MetaMask">Verify</button>'
|
||
: '';
|
||
html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' +
|
||
'<div class="flex items-center gap-2.5 min-w-0 flex-1">' +
|
||
'<div onclick="openColorPicker(event, \'' + w.address + '\')" class="wallet-color-dot" style="--wallet-color:' + color + ';background:' + color + ';" title="Click to change color"></div>' +
|
||
'<div class="min-w-0 flex-1">' +
|
||
'<input class="rename-nick-input bg-transparent text-sm font-medium text-white truncate border border-transparent focus:border-[#FF7A00]/50 rounded px-1 outline-none transition block w-full" value="' + nick + '" data-addr="' + w.address + '" data-chain="' + w.chain + '" onblur="handleRenameNickname(this)" onkeydown="if(event.key===\'Enter\')this.blur()">' +
|
||
'<div class="text-[10px] text-gray-500 font-mono truncate">' + shortAddr + ' <span class="text-gray-600">' + w.chain + '</span>' + verifyBadge + '</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="flex items-center gap-1 flex-shrink-0">' +
|
||
verifyBtn + syncBadge +
|
||
'<button onclick="handleDeleteWallet(\'' + w.address + '\',\'' + w.chain + '\',' + i + ')" class="text-gray-600 hover:text-red-400 transition" title="Remove wallet">' +
|
||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||
'<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>' +
|
||
'</svg>' +
|
||
'</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
});
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function handleRenameNickname(input) {
|
||
var addr = input.dataset.addr;
|
||
var chain = input.dataset.chain;
|
||
var nick = input.value.trim();
|
||
wm.renameWallet(addr, chain, nick);
|
||
renderWalletPills();
|
||
}
|
||
|
||
/* ===================================================================
|
||
Add Wallet Modal
|
||
=================================================================== */
|
||
let _addWalletSelectedColor = null;
|
||
let _addWalletSelectedPlatform = 'aave';
|
||
const HYPEREVM_CHAINS = ['hyperevm'];
|
||
|
||
function openAddWalletModal() {
|
||
if (!window.ethereum) {
|
||
alert('No web3 provider found. Install a wallet extension (MetaMask, Rabby, etc.).');
|
||
return;
|
||
}
|
||
/* Request connection first, then show modal with prefilled data */
|
||
window.ethereum.request({ method: 'eth_requestAccounts' })
|
||
.then(accounts => {
|
||
if (!accounts || accounts.length === 0) return;
|
||
|
||
document.getElementById('input-address').value = accounts[0];
|
||
document.getElementById('input-nickname').value = '';
|
||
document.getElementById('input-chain-display').value = '';
|
||
document.getElementById('input-chain-hidden').value = 'base';
|
||
_addWalletSelectedColor = autoPickColor();
|
||
_addWalletSelectedPlatform = 'aave';
|
||
renderAddWalletColorPicker();
|
||
|
||
window.ethereum.request({ method: 'eth_chainId' })
|
||
.then(rawChainId => {
|
||
const chainIdDecimal = parseInt(rawChainId, 16).toString();
|
||
const chainName = CHAIN_IDS_REVERSED[chainIdDecimal];
|
||
const displayText = chainName ? chainName.charAt(0).toUpperCase() + chainName.slice(1) : `Unknown (${chainIdDecimal})`;
|
||
document.getElementById('input-chain-display').value = displayText;
|
||
document.getElementById('input-chain-hidden').value = chainName || 'unknown';
|
||
renderLendingPlatformSelector(chainName || null);
|
||
});
|
||
|
||
document.getElementById('add-wallet-error').classList.add('hidden');
|
||
document.getElementById('add-wallet-modal').classList.add('open');
|
||
})
|
||
.catch(() => {
|
||
alert('Wallet connection rejected.');
|
||
});
|
||
}
|
||
|
||
function autoPickColor() {
|
||
const usedColors = wm.getWallets().map(w => getColorForWallet(w.address, w.chain));
|
||
for (const c of WALLET_COLORS) {
|
||
if (!usedColors.includes(c)) return c;
|
||
}
|
||
return '#F7931A';
|
||
}
|
||
|
||
function renderAddWalletColorPicker() {
|
||
const container = document.getElementById('add-wallet-color-picker');
|
||
container.innerHTML = '';
|
||
if (!_addWalletSelectedColor) _addWalletSelectedColor = autoPickColor();
|
||
WALLET_COLORS.forEach((c) => {
|
||
const swatch = document.createElement('div');
|
||
swatch.className = 'color-picker-swatch' + (_addWalletSelectedColor.toLowerCase() === c.toLowerCase() ? ' selected' : '');
|
||
swatch.style.setProperty('--swatch-color', c);
|
||
swatch.style.background = c;
|
||
swatch.addEventListener('click', () => {
|
||
_addWalletSelectedColor = c;
|
||
renderAddWalletColorPicker();
|
||
});
|
||
container.appendChild(swatch);
|
||
});
|
||
}
|
||
|
||
function renderLendingPlatformSelector(chainName) {
|
||
const container = document.getElementById('lending-platform-selector');
|
||
container.innerHTML = '';
|
||
|
||
const isHyperEVM = HYPEREVM_CHAINS.includes(chainName);
|
||
|
||
if (isHyperEVM) {
|
||
const platform = 'hyperlend';
|
||
const logoUrl = 'https://app.hyperlend.finance/assets/header-logo-CiRKYBzy.svg';
|
||
const color = '#caeae5';
|
||
const label = 'HyperLend';
|
||
const cbClass = 'platform-hyperlend';
|
||
|
||
const item = document.createElement('label');
|
||
item.className = 'flex items-center gap-3 bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 cursor-pointer transition';
|
||
item.innerHTML =
|
||
'<input type="checkbox" class="platform-checkbox ' + cbClass + '" checked disabled>' +
|
||
'<img src="' + logoUrl + '" alt="' + label + '" class="h-4 w-auto" onerror="this.outerHTML=\'' + label + '\'">' +
|
||
'<span class="text-xs font-medium text-gray-300">' + label + '</span>';
|
||
container.appendChild(item);
|
||
_addWalletSelectedPlatform = 'hyperlend';
|
||
} else {
|
||
const platform = 'aave';
|
||
const logoUrl = 'https://app.aave.com/aave-com-logo-header.svg';
|
||
const color = '#9896FF';
|
||
const label = 'Aave';
|
||
const cbClass = 'platform-aave';
|
||
|
||
const item = document.createElement('label');
|
||
item.className = 'flex items-center gap-3 bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 cursor-pointer transition';
|
||
item.innerHTML =
|
||
'<input type="checkbox" class="platform-checkbox ' + cbClass + '" checked disabled>' +
|
||
'<img src="' + logoUrl + '" alt="' + label + '" class="h-4 w-auto" onerror="this.outerHTML=\'' + label + '\'">' +
|
||
'<span class="text-xs font-medium text-gray-300">' + label + '</span>';
|
||
container.appendChild(item);
|
||
_addWalletSelectedPlatform = 'aave';
|
||
}
|
||
}
|
||
|
||
/* Reverse map: chainId (decimal) → chain name */
|
||
const CHAIN_IDS_REVERSED = { '1': 'ethereum', '8453': 'base', '42161': 'arbitrum', '10': 'optimism', '137': 'polygon', '43114': 'avalanche', '56': 'bsc', '250': 'fantom', '999': 'hyperevm' };
|
||
|
||
function fetchWalletAddress() {
|
||
if (!window.ethereum) {
|
||
document.getElementById('add-wallet-error').textContent = 'No web3 provider found';
|
||
document.getElementById('add-wallet-error').classList.remove('hidden');
|
||
return;
|
||
}
|
||
window.ethereum.request({ method: 'eth_requestAccounts' })
|
||
.then(accounts => {
|
||
if (accounts && accounts.length > 0) {
|
||
document.getElementById('input-address').value = accounts[0];
|
||
document.getElementById('add-wallet-error').classList.add('hidden');
|
||
|
||
/* Auto-detect chain from wallet */
|
||
window.ethereum.request({ method: 'eth_chainId' })
|
||
.then(rawChainId => {
|
||
const chainIdDecimal = parseInt(rawChainId, 16).toString();
|
||
const chainName = CHAIN_IDS_REVERSED[chainIdDecimal];
|
||
const displayText = chainName ? chainName.charAt(0).toUpperCase() + chainName.slice(1) : `Unknown (${chainIdDecimal})`;
|
||
document.getElementById('input-chain-display').value = displayText;
|
||
document.getElementById('input-chain-hidden').value = chainName || 'unknown';
|
||
renderLendingPlatformSelector(chainName || null);
|
||
});
|
||
|
||
/* Auto-assign a color not used by existing wallets */
|
||
_addWalletSelectedColor = autoPickColor();
|
||
_addWalletSelectedPlatform = 'aave';
|
||
renderAddWalletColorPicker();
|
||
}
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('add-wallet-error').textContent = 'Connection rejected';
|
||
document.getElementById('add-wallet-error').classList.remove('hidden');
|
||
});
|
||
}
|
||
|
||
function closeAddWalletModal() {
|
||
document.getElementById('add-wallet-modal').classList.remove('open');
|
||
}
|
||
|
||
async function handleAddWallet(e) {
|
||
e.preventDefault();
|
||
let address = document.getElementById('input-address').value.trim().toLowerCase();
|
||
let chain = document.getElementById('input-chain-hidden').value || 'base';
|
||
const nickname = document.getElementById('input-nickname').value.trim();
|
||
const lendingPlatform = _addWalletSelectedPlatform || 'aave';
|
||
const errEl = document.getElementById('add-wallet-error');
|
||
|
||
/* If no address, connect wallet first */
|
||
if (!address) {
|
||
if (!window.ethereum) {
|
||
errEl.textContent = 'No web3 provider found. Install a wallet extension.';
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
try {
|
||
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
||
if (!accounts || accounts.length === 0) {
|
||
errEl.textContent = 'No account returned';
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
address = accounts[0].toLowerCase();
|
||
document.getElementById('input-address').value = accounts[0];
|
||
|
||
/* Auto-detect chain */
|
||
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
|
||
const chainIdDecimal = parseInt(chainIdHex, 16).toString();
|
||
const resolvedChain = CHAIN_IDS_REVERSED[chainIdDecimal];
|
||
chain = resolvedChain || 'unknown';
|
||
document.getElementById('input-chain-hidden').value = chain;
|
||
document.getElementById('input-chain-display').value = resolvedChain ? resolvedChain.charAt(0).toUpperCase() + resolvedChain.slice(1) : `Unknown (${chainIdDecimal})`;
|
||
renderLendingPlatformSelector(resolvedChain || null);
|
||
|
||
_addWalletSelectedColor = autoPickColor();
|
||
renderAddWalletColorPicker();
|
||
} catch (err) {
|
||
errEl.textContent = 'Connection rejected';
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/* Assign color */
|
||
const color = _addWalletSelectedColor || autoPickColor();
|
||
setColorForWallet(address, color);
|
||
|
||
const isEVM = /^(0x)?[0-9a-fA-F]{40}$/i.test(address);
|
||
const isBTC = /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/i.test(address);
|
||
const isSolana = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address);
|
||
|
||
if (!isEVM && !isBTC && !isSolana) { errEl.textContent = 'Invalid address format'; errEl.classList.remove('hidden'); return false; }
|
||
|
||
/* Auto-verify non-EVM wallets */
|
||
if (!isEVM) {
|
||
const autoChain = isSolana ? 'solana' : 'btc';
|
||
const addResult = wm.addWallet(address, autoChain, nickname, lendingPlatform);
|
||
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; }
|
||
wm.verifyWallet(address, autoChain, '', { action: 'Auto-verified (non-verification chain)', walletAddress: address });
|
||
walletSyncState[walletKey(address, autoChain)] = 'synced';
|
||
closeAddWalletModal();
|
||
closeSidebar();
|
||
renderAll();
|
||
return false;
|
||
}
|
||
|
||
/* Register EVM wallet first so verifyOwnership can find it */
|
||
const addResult = wm.addWallet(address, chain, nickname, lendingPlatform);
|
||
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; }
|
||
|
||
/* One-shot: verify or remove */
|
||
const verifyResult = await verifyOwnership(address, chain, nickname);
|
||
if (!verifyResult.success) {
|
||
wm.removeWallet(address, chain);
|
||
errEl.textContent = verifyResult.error;
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
|
||
closeAddWalletModal();
|
||
closeSidebar();
|
||
walletSyncState[walletKey(address, chain)] = 'syncing';
|
||
|
||
/* Register the address with the backend for monitoring */
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/api/v1/portfolio/monitor`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ address, chain, chain_id: CHAIN_IDS[chain] || 8453 })
|
||
});
|
||
if (!resp.ok) {
|
||
console.warn(`Backend registration returned ${resp.status} for ${address} — continuing with polling`);
|
||
}
|
||
} catch (err) {
|
||
console.warn(`Error registering wallet ${address} with backend:`, err);
|
||
}
|
||
|
||
await _pollUntilSynced(address, undefined, undefined, chain);
|
||
renderSidebar();
|
||
renderWalletPills();
|
||
renderLedgerFilterWallets();
|
||
await _fetchPricesAndRender();
|
||
return false;
|
||
}
|
||
|
||
/* ===================================================================
|
||
EIP-712 Verification
|
||
=================================================================== */
|
||
/* Chain name → numeric chainId for EIP-712 domain */
|
||
const CHAIN_IDS = { ethereum: 1, base: 8453, arbitrum: 42161, optimism: 10, polygon: 137, avalanche: 43114, bsc: 56, fantom: 250, hyperevm: 999 };
|
||
const VERIFY_TYPES = {
|
||
VerifyTracking: [
|
||
{ name: 'action', type: 'string' },
|
||
{ name: 'walletAddress', type: 'address' },
|
||
{ name: 'timestamp', type: 'uint256' }
|
||
]
|
||
};
|
||
|
||
async function verifyOwnership(address, chain, nickname) {
|
||
if (!window.ethereum) {
|
||
return { success: false, error: 'No web3 provider' };
|
||
}
|
||
const message = {
|
||
action: 'Verify Ownership for Live Tracking',
|
||
walletAddress: address,
|
||
timestamp: Math.floor(Date.now() / 1000).toString()
|
||
};
|
||
const domain = { name: 'Anonymous Wallet Tracker', version: '1', chainId: CHAIN_IDS[chain] || 1 };
|
||
const typedData = {
|
||
domain,
|
||
types: { EIP712Domain: [{ name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }], VerifyTracking: VERIFY_TYPES.VerifyTracking },
|
||
primaryType: 'VerifyTracking',
|
||
message
|
||
};
|
||
try {
|
||
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
||
if (!accounts || accounts.length === 0) return { success: false, error: 'No account' };
|
||
|
||
/* Require that the connected signer matches the added wallet */
|
||
if (accounts[0].toLowerCase() !== address) {
|
||
return { success: false, error: 'Connected wallet does not match added address' };
|
||
}
|
||
|
||
const sig = await window.ethereum.request({
|
||
method: 'eth_signTypedData_v4',
|
||
params: [accounts[0], JSON.stringify(typedData)]
|
||
});
|
||
wm.verifyWallet(address, chain, sig, typedData.message);
|
||
wm.renameWallet(address, chain, nickname);
|
||
return { success: true };
|
||
} catch (err) {
|
||
return { success: false, error: err?.message || 'Signature failed' };
|
||
}
|
||
}
|
||
|
||
async function handleVerifyWallet(address, chain, nickname) {
|
||
const w = wm.findWallet(address, chain);
|
||
if (!w) return;
|
||
if (w.isVerified) return;
|
||
|
||
/* Non-EVM auto-verify (includes import chain) */
|
||
if (['btc', 'bitcoin', 'solana', 'import'].includes(chain)) {
|
||
wm.verifyWallet(address, chain, '', { action: 'Auto-verified (non-verification chain)', walletAddress: address });
|
||
renderAll();
|
||
return;
|
||
}
|
||
|
||
const result = await verifyOwnership(address, chain, nickname || w.nickname);
|
||
if (result.success) {
|
||
/* Fetch data now that wallet is verified */
|
||
fetchWalletAaveData(address, chain);
|
||
} else {
|
||
/* Remove unverified wallet on failure */
|
||
wm.removeWallet(address, chain);
|
||
alert('Verification failed: ' + result.error);
|
||
}
|
||
renderAll();
|
||
}
|
||
|
||
/* ===================================================================
|
||
Delete Wallet
|
||
=================================================================== */
|
||
async function handleDeleteWallet(address, chain, idx) {
|
||
const key = walletKey(address, chain);
|
||
|
||
/* Remove from backend DB (CASCADE deletes all associated data) — skip for import wallets */
|
||
if (chain !== 'import') {
|
||
try {
|
||
await fetch(`${API_BASE}/api/v1/portfolio/${address}/${chain}`, { method: 'DELETE' });
|
||
} catch (err) {
|
||
console.error(`Failed to remove ${key} from backend:`, err);
|
||
}
|
||
}
|
||
|
||
wm.removeWallet(address, chain);
|
||
delete walletSyncState[key];
|
||
delete addressSnapshots[key];
|
||
/* Remove from unchecked list */
|
||
let unchecked = getWalletFilter();
|
||
const ui = unchecked.indexOf(key);
|
||
if (ui >= 0) unchecked.splice(ui, 1);
|
||
if (unchecked.length === 0) {
|
||
localStorage.removeItem('cbbtc_ledger_wallets');
|
||
} else {
|
||
localStorage.setItem('cbbtc_ledger_wallets', JSON.stringify(unchecked));
|
||
}
|
||
renderAll();
|
||
}
|
||
|
||
/* ===================================================================
|
||
Wallet Pills (main dashboard selection)
|
||
=================================================================== */
|
||
function getPlatformBadge(platform) {
|
||
if (!platform) return '';
|
||
const p = (platform || 'aave').toLowerCase();
|
||
if (p === 'hyperlend') {
|
||
return '<span class="inline-block w-2 h-2 rounded-sm bg-[#caeae5] ml-1"></span>';
|
||
}
|
||
return '<span class="inline-block w-2 h-2 rounded-sm bg-[#9896FF] ml-1"></span>';
|
||
}
|
||
|
||
function renderWalletPills() {
|
||
const wallets = wm.getWallets();
|
||
const container = document.getElementById('wallet-pills');
|
||
if (wallets.length === 0) {
|
||
container.innerHTML = '<span class="text-xs text-gray-600 italic">No wallets added. Open the sidebar to add one.</span>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
wallets.forEach((w) => {
|
||
const color = getColorForWallet(w.address, w.chain);
|
||
const syncState = walletSyncState[walletKey(w.address, w.chain)];
|
||
const isSyncing = syncState === 'syncing' || syncState === 'pending';
|
||
const syncIndicator = isSyncing
|
||
? ' <span class="text-[9px] text-yellow-400 animate-pulse">⏳</span>'
|
||
: '';
|
||
const nick = w.nickname || 'Wallet';
|
||
const isActive = isWalletInLedger(w.address, w.chain);
|
||
const platformBadge = getPlatformBadge(w.lendingPlatform);
|
||
const key = walletKey(w.address, w.chain);
|
||
html += '<label class="pill-btn flex items-center gap-2 bg-[#090D14] border border-[#1A1F2C]/60 px-3 py-1.5 rounded-lg transition-all cursor-pointer select-none" data-key="' + key + '" data-address="' + w.address + '" data-chain="' + w.chain + '" draggable="true">' +
|
||
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-key="' + key + '" ' + (isActive ? 'checked' : '') + ' onchange="if(!_dragKey){event.stopPropagation();setWalletChecked(\'' + key + '\', this.checked)}">' +
|
||
'<div class="flex flex-col">' +
|
||
'<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider leading-tight">' + nick + syncIndicator + platformBadge + '</span>' +
|
||
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
|
||
'</div>' +
|
||
'</label>';
|
||
});
|
||
container.innerHTML = html;
|
||
setupPillDragListeners(container);
|
||
}
|
||
|
||
function toggleWalletPill(address) {
|
||
toggleWalletFilter(address);
|
||
renderLedgerFilterWallets();
|
||
}
|
||
|
||
/* ===================================================================
|
||
Drag Reorder (wallet pills)
|
||
=================================================================== */
|
||
let _dragKey = null;
|
||
let _dropTargetKey = null;
|
||
let _dropBefore = true;
|
||
|
||
function startPillDrag(e) {
|
||
if (!e.currentTarget.dataset.key) return;
|
||
_dragKey = e.currentTarget.dataset.key;
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', _dragKey);
|
||
}
|
||
e.stopPropagation();
|
||
e.currentTarget.classList.add('pill-dragging');
|
||
}
|
||
|
||
function endPillDrag(e) {
|
||
e.currentTarget.classList.remove('pill-dragging');
|
||
cleanupDrag();
|
||
}
|
||
|
||
function cleanupDrag() {
|
||
_dragKey = null;
|
||
_dropTargetKey = null;
|
||
_dropBefore = true;
|
||
document.querySelectorAll('.pill-dragging').forEach(el => el.classList.remove('pill-dragging'));
|
||
document.querySelectorAll('.drag-insert-line').forEach(el => el.remove());
|
||
}
|
||
|
||
function dropWalletReorder(e, targetKey) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (!_dragKey) return cleanupDrag();
|
||
|
||
const wallets = wm.getWallets();
|
||
const keys = wallets.map(w => walletKey(w.address, w.chain));
|
||
const dragIdx = keys.indexOf(_dragKey);
|
||
if (dragIdx === -1) return cleanupDrag();
|
||
|
||
if (targetKey && targetKey !== _dragKey) {
|
||
const targetIdx = keys.indexOf(targetKey);
|
||
if (targetIdx !== -1) {
|
||
const moved = keys.splice(dragIdx, 1)[0];
|
||
const insertIdx = _dropBefore ? targetIdx : targetIdx + 1;
|
||
keys.splice(insertIdx, 0, moved);
|
||
wm.setWalletOrder(keys);
|
||
renderAll();
|
||
}
|
||
} else if (!_dropTargetKey) {
|
||
const moved = keys.splice(dragIdx, 1)[0];
|
||
keys.push(moved);
|
||
wm.setWalletOrder(keys);
|
||
renderAll();
|
||
}
|
||
cleanupDrag();
|
||
}
|
||
|
||
function setupPillDragListeners(container) {
|
||
container.querySelectorAll('.pill-btn').forEach(pill => {
|
||
pill.addEventListener('dragstart', startPillDrag);
|
||
pill.addEventListener('dragend', endPillDrag);
|
||
pill.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
_dropTargetKey = pill.dataset.key;
|
||
const rect = pill.getBoundingClientRect();
|
||
_dropBefore = e.clientX < rect.left + rect.width / 2;
|
||
});
|
||
pill.addEventListener('dragleave', () => { document.querySelector('.drag-insert-line')?.remove(); });
|
||
pill.addEventListener('drop', (e) => { dropWalletReorder(e, pill.dataset.key); });
|
||
});
|
||
}
|
||
|
||
const pillsContainer = document.getElementById('wallet-pills');
|
||
pillsContainer.addEventListener('dragover', (e) => {
|
||
if (!_dragKey) return;
|
||
|
||
document.querySelector('.drag-insert-line')?.remove();
|
||
|
||
const children = Array.from(pillsContainer.children).filter(c => c.classList && c.classList.contains('pill-btn'));
|
||
for (const child of children) {
|
||
if (child.dataset.key === _dragKey) continue;
|
||
const rect = child.getBoundingClientRect();
|
||
if (e.clientX < rect.left + rect.width / 2) {
|
||
const line = document.createElement('div');
|
||
line.className = 'drag-insert-line';
|
||
pillsContainer.insertBefore(line, child);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
pillsContainer.addEventListener('drop', (e) => {
|
||
if (!_dragKey || e.target.classList.contains('pill-btn')) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const wallets = wm.getWallets();
|
||
const keys = wallets.map(w => walletKey(w.address, w.chain));
|
||
const dragIdx = keys.indexOf(_dragKey);
|
||
if (dragIdx !== -1) {
|
||
const moved = keys.splice(dragIdx, 1)[0];
|
||
keys.push(moved);
|
||
wm.setWalletOrder(keys);
|
||
renderAll();
|
||
}
|
||
cleanupDrag();
|
||
});
|
||
|
||
function getSelectedAddresses() {
|
||
const unchecked = getWalletFilter();
|
||
const wallets = wm.getWallets();
|
||
const filtered = wallets.filter(w => !unchecked.includes(walletKey(w.address, w.chain)));
|
||
return filtered.map(w => ({ address: w.address, chain: w.chain }));
|
||
}
|
||
|
||
/* ===================================================================
|
||
Ledger filter wallet checkboxes
|
||
=================================================================== */
|
||
function renderLedgerFilterWallets() {
|
||
const wallets = wm.getWallets();
|
||
const container = document.getElementById('ledger-filter-wallets');
|
||
let html = '';
|
||
wallets.forEach(w => {
|
||
const color = getColorForWallet(w.address, w.chain);
|
||
const key = walletKey(w.address, w.chain);
|
||
const checked = isWalletInLedger(w.address, w.chain);
|
||
html += `<label class="filter-item" title="Show ${w.nickname || w.address}">
|
||
<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ${color};" data-key="${key}" ${checked ? 'checked' : ''}>
|
||
</label>`;
|
||
});
|
||
container.innerHTML = html;
|
||
|
||
container.querySelectorAll('.wallet-filter-toggle').forEach(cb => {
|
||
cb.addEventListener('change', () => {
|
||
setWalletChecked(cb.dataset.key, cb.checked);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ===================================================================
|
||
Dynamic Data Fetching (Promise.all aggregation)
|
||
=================================================================== */
|
||
async function fetchWalletAaveData(address, chain) {
|
||
chain = chain || 'base';
|
||
const key = walletKey(address, chain);
|
||
/* Import wallets use the /wallet ledger endpoint, not /aave */
|
||
const endpoint = chain === 'import'
|
||
? `/api/v1/portfolio/${address}/${chain}/wallet`
|
||
: `/api/v1/portfolio/${address}/${chain}/aave`;
|
||
try {
|
||
const resp = await fetch(`${API_BASE}${endpoint}`);
|
||
if (resp.status === 400) {
|
||
const body = await resp.json().catch(() => ({}));
|
||
const details = (body?.detail || body?.details || '').toString().toUpperCase();
|
||
if (details.includes('PENDING') || details.includes('SYNCING')) {
|
||
walletSyncState[key] = 'syncing';
|
||
renderWalletPills();
|
||
return;
|
||
}
|
||
}
|
||
if (resp.status === 404) {
|
||
walletSyncState[key] = 'pending';
|
||
renderWalletPills();
|
||
return;
|
||
}
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const events = await resp.json();
|
||
walletSyncState[key] = 'synced';
|
||
let ledger = events;
|
||
/* Import wallets: reconstruct running-balance ledger from raw DB records */
|
||
if (chain === 'import' && events.length && events[0].token_address !== undefined) {
|
||
const sorted = [...events].sort((a, b) => new Date(a.block_timestamp) - new Date(b.block_timestamp));
|
||
const balances = {};
|
||
ledger = sorted.map(r => {
|
||
const tk = r.token_address;
|
||
const decs = TOKENS[tk] ? TOKENS[tk].decimals : 0;
|
||
const amt = (parseFloat(r.amount) || 0) / Math.pow(10, decs);
|
||
balances[tk] = (balances[tk] || 0) + (r.direction === 'IN' ? amt : -amt);
|
||
return {
|
||
tx_hash: r.tx_hash || '0x0',
|
||
block_timestamp: r.block_timestamp,
|
||
wallet: { [tk]: balances[tk].toFixed(18) },
|
||
collateral: {},
|
||
debt: {}
|
||
};
|
||
});
|
||
}
|
||
addressSnapshots[key] = snapshotsToDaily(ledger);
|
||
renderWalletPills();
|
||
} catch (err) {
|
||
console.error(`Failed to fetch data for ${key}:`, err);
|
||
walletSyncState[key] = 'pending';
|
||
renderWalletPills();
|
||
}
|
||
}
|
||
|
||
/* Poll until wallet data is synchronized, up to ~60s */
|
||
async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, chain) {
|
||
chain = chain || 'base';
|
||
/* IMPORT_ONLY wallets don't poll — they're synced immediately */
|
||
if (chain === 'import') return true;
|
||
const key = walletKey(address, chain);
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${address}/${chain}/aave`);
|
||
if (resp.ok) {
|
||
const events = await resp.json();
|
||
if (Array.isArray(events) && events.length > 0) {
|
||
walletSyncState[key] = 'synced';
|
||
addressSnapshots[key] = snapshotsToDaily(events);
|
||
renderWalletPills();
|
||
return true;
|
||
}
|
||
}
|
||
} catch (err) { /* ignore, will retry */ }
|
||
await new Promise(r => setTimeout(r, intervalMs));
|
||
}
|
||
/* Timeout — mark as synced anyway so UI is not blocked */
|
||
walletSyncState[key] = 'syncing';
|
||
renderWalletPills();
|
||
return false;
|
||
}
|
||
|
||
/* Fetch price history for the new wallet's data range, then render */
|
||
async function _fetchPricesAndRender() {
|
||
const selectedWallets = getSelectedAddresses();
|
||
if (selectedWallets.length === 0) {
|
||
renderCombinedTable();
|
||
return;
|
||
}
|
||
/* Find oldest transaction to determine price range */
|
||
let oldestTs = Date.now();
|
||
selectedWallets.forEach(w => {
|
||
const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
|
||
snaps.forEach(s => {
|
||
const t = new Date(s.block_timestamp).getTime();
|
||
if (t < oldestTs) oldestTs = t;
|
||
});
|
||
});
|
||
await fetchPrices(Object.keys(TOKENS), oldestTs);
|
||
renderCombinedTable();
|
||
updateDashboard();
|
||
}
|
||
|
||
async function fetchSelectedWalletsData() {
|
||
const selectedWallets = getSelectedAddresses();
|
||
if (selectedWallets.length === 0) return;
|
||
|
||
/* Only fetch for wallets that are synced or syncing */
|
||
const promises = selectedWallets.map(async (wallet) => {
|
||
const key = walletKey(wallet.address, wallet.chain);
|
||
const sync = walletSyncState[key];
|
||
if (sync === 'syncing') return null;
|
||
if (sync === 'pending') return null;
|
||
if (sync === 'synced' && addressSnapshots[key]) return null;
|
||
await fetchWalletAaveData(wallet.address, wallet.chain);
|
||
});
|
||
await Promise.all(promises);
|
||
}
|
||
|
||
/* ===================================================================
|
||
Chart Helpers
|
||
=================================================================== */
|
||
function getTokenAmount(raw, symbol, isImport) {
|
||
if (isImport) return parseFloat(raw || '0');
|
||
const dec = TOKENS[symbol] ? TOKENS[symbol].decimals : 18;
|
||
return parseFloat(raw || '0') / Math.pow(10, dec);
|
||
}
|
||
|
||
function getTotalBTC(obj, isImport) {
|
||
let total = 0;
|
||
if (!obj) return 0;
|
||
for (const sym of Object.keys(TOKENS)) {
|
||
if (TOKENS[sym].priceSymbol === 'BTC') total += getTokenAmount(obj[sym], sym, isImport);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
function getTotalHYPE(obj, isImport) {
|
||
let total = 0;
|
||
if (!obj) return 0;
|
||
for (const sym of Object.keys(TOKENS)) {
|
||
if (TOKENS[sym].priceSymbol === 'HYPE') total += getTokenAmount(obj[sym], sym, isImport);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
function priceForToken(symbol, dateStr) {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
|
||
if (dateStr === today && ps === 'BTC' && todayBtcPrice) return todayBtcPrice;
|
||
if (!aavePriceMap[ps]) return null;
|
||
if (aavePriceMap[ps][dateStr]) return parseFloat(aavePriceMap[ps][dateStr]);
|
||
const arr = Object.keys(aavePriceMap[ps]).sort();
|
||
for (let i = arr.length - 1; i >= 0; i--) {
|
||
if (arr[i] <= dateStr) return parseFloat(aavePriceMap[ps][arr[i]]);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function fetchPrices(symbols, oldestDateMs) {
|
||
try {
|
||
const today = new Date();
|
||
const oldest = new Date(oldestDateMs);
|
||
const range = Math.max(2, Math.ceil((today - oldest) / 86400000) + 3);
|
||
const priceSymbols = [...new Set(symbols.map(s => TOKENS[s] ? TOKENS[s].priceSymbol : s))];
|
||
const promises = priceSymbols.map(ps => fetch(`${API_BASE}/api/v1/prices/${ps}/history?range=${range}`).then(r => r.json()));
|
||
const results = await Promise.all(promises);
|
||
priceSymbols.forEach((ps, i) => {
|
||
aavePriceMap[ps] = {};
|
||
results[i].forEach(item => { aavePriceMap[ps][item.date] = item.close; });
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to load prices:", err);
|
||
}
|
||
}
|
||
|
||
async function refreshPrices() {
|
||
try {
|
||
const priceSymbols = Object.values(TOKENS).map(t => t.priceSymbol);
|
||
const uniqueSymbols = [...new Set(priceSymbols)];
|
||
const promises = uniqueSymbols.map(ps => fetch(`${API_BASE}/api/v1/prices/${ps}/history?range=2`).then(r => r.json()));
|
||
const results = await Promise.all(promises);
|
||
uniqueSymbols.forEach((ps, i) => {
|
||
if (!aavePriceMap[ps]) aavePriceMap[ps] = {};
|
||
results[i].forEach(item => { aavePriceMap[ps][item.date] = item.close; });
|
||
});
|
||
if (btcPriceData.length > 0) {
|
||
todayBtcPrice = btcPriceData[btcPriceData.length - 1][1];
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to refresh prices:", err);
|
||
}
|
||
}
|
||
|
||
function snapshotsToDaily(events) {
|
||
const byDay = {};
|
||
events.forEach(evt => {
|
||
const ts = new Date(evt.block_timestamp).getTime();
|
||
const dateStr = evt.block_timestamp.slice(0, 10);
|
||
if (!byDay[dateStr] || ts > byDay[dateStr].block_ts) {
|
||
byDay[dateStr] = {...evt, block_ts: ts};
|
||
}
|
||
});
|
||
return Object.values(byDay).sort((a, b) => b.block_ts - a.block_ts);
|
||
}
|
||
|
||
/* ===================================================================
|
||
Dashboard Update
|
||
=================================================================== */
|
||
function getOldestTransactionDate() {
|
||
const selectedWallets = getSelectedAddresses();
|
||
if (selectedWallets.length === 0) return Date.now() - 365 * 86400000;
|
||
let oldestTs = Infinity;
|
||
selectedWallets.forEach(w => {
|
||
const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
|
||
snaps.forEach(snap => {
|
||
const sTs = new Date(snap.block_timestamp).getTime();
|
||
if (sTs < oldestTs) oldestTs = sTs;
|
||
});
|
||
});
|
||
return oldestTs === Infinity ? Date.now() - 365 * 86400000 : oldestTs;
|
||
}
|
||
|
||
/* Build per-day cumulative BTC series from addressSnapshots.
|
||
Returns both aggregated total and per-wallet series. */
|
||
function buildCumulativeSeries(wallets) {
|
||
const allDates = {};
|
||
const keys = wallets.map(w => walletKey(w.address, w.chain));
|
||
keys.forEach(key => {
|
||
const snaps = addressSnapshots[key] || [];
|
||
snaps.forEach(snap => {
|
||
allDates[snap.block_timestamp.slice(0, 10)] = true;
|
||
});
|
||
});
|
||
const snapshotDates = Object.keys(allDates).sort();
|
||
if (snapshotDates.length === 0) return { total: [], perWallet: {} };
|
||
|
||
/* Build per-wallet forward-filled balance for every calendar day */
|
||
const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate());
|
||
const lastUTC = Date.UTC(new Date(snapshotDates[snapshotDates.length - 1]).getUTCFullYear(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCMonth(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCDate());
|
||
const DAY_MS = 86400000;
|
||
|
||
/* Pre-compute per-wallet dated arrays (oldest-first) */
|
||
const walletDated = {};
|
||
keys.forEach(key => {
|
||
const chain = key.split(':')[1];
|
||
const isImport = chain === 'import';
|
||
const snaps = addressSnapshots[key] || [];
|
||
walletDated[key] = snaps.map(s => ({
|
||
dateStr: s.block_timestamp.slice(0, 10),
|
||
btcAmt: getTotalBTC(s?.wallet, isImport) + getTotalBTC(s?.collateral, isImport)
|
||
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
||
});
|
||
|
||
/* Walk every calendar day, forward-fill each wallet, aggregate */
|
||
const totalResult = [];
|
||
const perWalletResult = {};
|
||
keys.forEach(key => { perWalletResult[key] = []; });
|
||
|
||
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
|
||
const ds = new Date(t).toISOString().slice(0, 10);
|
||
let dayTotal = 0;
|
||
keys.forEach(key => {
|
||
const dated = walletDated[key];
|
||
let balance = 0;
|
||
for (const d of dated) {
|
||
if (d.dateStr <= ds) balance = d.btcAmt;
|
||
else break;
|
||
}
|
||
dayTotal += balance;
|
||
perWalletResult[key].push([t, balance]);
|
||
});
|
||
totalResult.push([t, dayTotal]);
|
||
}
|
||
|
||
return { total: totalResult, perWallet: perWalletResult };
|
||
}
|
||
|
||
function calculateAggregatedSeries() {
|
||
const selectedWallets = getSelectedAddresses();
|
||
currentBuyCost = 0;
|
||
currentBuyAmount = 0;
|
||
|
||
if (selectedWallets.length === 0) {
|
||
currentAggregatedSeries = [];
|
||
currentWalletSeries = {};
|
||
return;
|
||
}
|
||
|
||
const result = buildCumulativeSeries(selectedWallets);
|
||
currentAggregatedSeries = result.total;
|
||
currentWalletSeries = result.perWallet;
|
||
}
|
||
|
||
function calculateCurrentHoldings() {
|
||
const selectedWallets = getSelectedAddresses();
|
||
currentNetHeld = 0;
|
||
currentBuyCost = 0;
|
||
currentBuyAmount = 0;
|
||
currentHypeHeld = 0;
|
||
currentHypeBuyCost = 0;
|
||
currentHypeBuyAmount = 0;
|
||
currentHypeTxCount = 0;
|
||
if (selectedWallets.length === 0) return;
|
||
|
||
/* Sum latest cbBTC balance across selected wallets */
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const isImport = w.chain === 'import';
|
||
const snaps = addressSnapshots[key] || [];
|
||
if (snaps.length > 0) {
|
||
const latest = snaps[0];
|
||
currentNetHeld += getTotalBTC(latest?.wallet, isImport);
|
||
currentNetHeld += getTotalBTC(latest?.collateral, isImport);
|
||
}
|
||
});
|
||
|
||
/* Derive buy cost from snapshot deltas: positive BTC changes, oldest first */
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const isImport = w.chain === 'import';
|
||
const snaps = addressSnapshots[key] || [];
|
||
if (snaps.length === 0) return;
|
||
let prevBtc = 0;
|
||
snaps.slice().reverse().forEach(snap => {
|
||
const currentDate = snap.block_timestamp.slice(0, 10);
|
||
const currentBtc = getTotalBTC(snap?.wallet, isImport) + getTotalBTC(snap?.collateral, isImport);
|
||
const delta = currentBtc - prevBtc;
|
||
if (delta > 0) {
|
||
const price = priceForToken('BTC', currentDate) || 0;
|
||
if (price > 0) currentBuyCost += delta * price;
|
||
currentBuyAmount += delta;
|
||
}
|
||
prevBtc = currentBtc;
|
||
});
|
||
});
|
||
|
||
/* HYPE holdings — current */
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const isImport = w.chain === 'import';
|
||
const snaps = addressSnapshots[key] || [];
|
||
if (snaps.length > 0) {
|
||
const latest = snaps[0];
|
||
currentHypeHeld += getTotalHYPE(latest?.wallet, isImport);
|
||
currentHypeHeld += getTotalHYPE(latest?.collateral, isImport);
|
||
}
|
||
});
|
||
|
||
/* Derive HYPE buy cost from snapshot deltas: positive HYPE changes, oldest first */
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const isImport = w.chain === 'import';
|
||
const snaps = addressSnapshots[key] || [];
|
||
if (snaps.length === 0) return;
|
||
let prevHype = 0;
|
||
snaps.slice().reverse().forEach(snap => {
|
||
const currentDate = snap.block_timestamp.slice(0, 10);
|
||
const currentHype = getTotalHYPE(snap?.wallet, isImport) + getTotalHYPE(snap?.collateral, isImport);
|
||
const delta = currentHype - prevHype;
|
||
if (delta > 0) {
|
||
const price = priceForToken('HYPE', currentDate) || 0;
|
||
if (price > 0) currentHypeBuyCost += delta * price;
|
||
currentHypeBuyAmount += delta;
|
||
currentHypeTxCount++;
|
||
}
|
||
prevHype = currentHype;
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateDashboard() {
|
||
calculateAggregatedSeries();
|
||
calculateCurrentHoldings();
|
||
|
||
if (window.cumulChart) {
|
||
window.cumulChart.destroy();
|
||
window.cumulChart = null;
|
||
refreshAllCharts(currentCutoffMs);
|
||
}
|
||
|
||
document.getElementById('net-held-val').innerText = currentNetHeld.toFixed(6);
|
||
document.getElementById('cumul-btc-val').innerText = currentNetHeld.toFixed(6);
|
||
document.getElementById('breakdown-total-val').innerText = currentNetHeld.toFixed(6);
|
||
|
||
const avgBuy = currentBuyAmount > 0 ? currentBuyCost / currentBuyAmount : 0;
|
||
document.getElementById('avg-buy-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(avgBuy);
|
||
document.getElementById('total-invested-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentBuyCost);
|
||
|
||
if (btcPriceData.length > 0) {
|
||
updateBtcUI(btcPriceData[btcPriceData.length - 1][1], 0);
|
||
}
|
||
|
||
updateHypeUI();
|
||
|
||
/* Filter transaction table */
|
||
const selectedWallets = getSelectedAddresses();
|
||
const activeTypes = Array.from(document.querySelectorAll('.filter-checkbox:checked')).map(cb => cb.dataset.filter);
|
||
const ledgerWallets = Array.from(document.querySelectorAll('.wallet-filter-toggle:checked')).map(cb => cb.dataset.key || cb.dataset.address);
|
||
|
||
let visibleCount = 0;
|
||
document.querySelectorAll('#transaction-table tbody tr').forEach(row => {
|
||
const wAddr = row.dataset.wallet;
|
||
const actionBadge = row.querySelector('.badge');
|
||
let typeMatch = true;
|
||
if (actionBadge) {
|
||
const actionType = actionBadge.dataset.type;
|
||
typeMatch = activeTypes.includes(actionType);
|
||
}
|
||
const walletMatch = ledgerWallets.includes(wAddr);
|
||
const isVisible = walletMatch && typeMatch;
|
||
row.classList.toggle('hidden', !isVisible);
|
||
if (isVisible) visibleCount++;
|
||
});
|
||
document.getElementById('tx-count-val').innerText = visibleCount;
|
||
|
||
const newCutoff = getOldestTransactionDate();
|
||
if (newCutoff !== currentCutoffMs) {
|
||
if (btcPriceData.length > 0) refreshAllCharts(newCutoff);
|
||
}
|
||
}
|
||
|
||
/* ===================================================================
|
||
Render Transaction Table
|
||
=================================================================== */
|
||
function fmtUsd(v) {
|
||
if (v == null || isNaN(v)) return '—';
|
||
if (v >= 1000) return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v);
|
||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(v);
|
||
}
|
||
|
||
function tokenTooltipHTML(items) {
|
||
let html = '';
|
||
let total = 0;
|
||
items.forEach(it => {
|
||
if (it.usd >= 5) {
|
||
html += `<div class="tooltip-row"><span class="text-gray-400">${it.symbol}</span><span class="text-white">${it.amountStr} × ${it.priceStr}</span><span class="text-gray-300">${it.usdStr}</span></div>`;
|
||
total += it.usd;
|
||
}
|
||
});
|
||
html += `<div class="tooltip-row total"><span class="text-gray-400">Total</span><span class="text-orange-400">${new Intl.NumberFormat('en-US',{style:'currency','currency':'USD','maximumFractionDigits':0}).format(total)}</span></div>`;
|
||
return html;
|
||
}
|
||
|
||
function renderCombinedTable() {
|
||
const walletColorMap = {};
|
||
const walletNickMap = {};
|
||
const wallets = wm.getWallets();
|
||
wallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
walletColorMap[key] = getColorForWallet(w.address, w.chain);
|
||
walletNickMap[key] = w.nickname || 'Wallet';
|
||
});
|
||
|
||
/* Build unified snapshot list from dynamic sources only */
|
||
const unified = [];
|
||
for (const [key, snaps] of Object.entries(addressSnapshots)) {
|
||
const [addr, chain] = key.split(':');
|
||
const isImport = chain === 'import';
|
||
(snaps || []).forEach(snap => {
|
||
unified.push({ ...snap, _walletAddress: key, _walletAddr: addr, _walletChain: chain, _isImport: isImport });
|
||
});
|
||
}
|
||
|
||
unified.sort((a, b) => new Date(b.block_timestamp) - new Date(a.block_timestamp));
|
||
|
||
const tbody = document.querySelector('#table-body');
|
||
if (unified.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500 text-sm">No snapshots found. Add wallets in the sidebar.</td></tr>';
|
||
updateDashboard();
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
unified.forEach(snap => {
|
||
const ts = new Date(snap.block_timestamp);
|
||
const dateStr = snap.block_timestamp.slice(0, 10);
|
||
const timeStr = ts.toTimeString().slice(0, 5);
|
||
const shortHash = snap.tx_hash ? snap.tx_hash.slice(0, 10) + '...' : '—';
|
||
const hashLink = snap.tx_hash ? `https://basescan.org/tx/${snap.tx_hash}` : '#';
|
||
const walletAddr = snap._walletAddress;
|
||
const color = walletColorMap[walletAddr] || '#f7931a';
|
||
const nick = walletNickMap[walletAddr] || walletAddr.slice(0, 8);
|
||
|
||
const isImport = snap._isImport;
|
||
/* Cold storage */
|
||
const coldItems = [];
|
||
let coldTotal = 0;
|
||
Object.entries(snap.wallet || {}).forEach(([sym, raw]) => {
|
||
const amt = getTokenAmount(raw, sym, isImport);
|
||
if (amt <= 0) return;
|
||
const price = priceForToken(sym, dateStr) || 0;
|
||
const usd = amt * price;
|
||
coldTotal += usd;
|
||
coldItems.push({
|
||
symbol: symbolDisplay(sym), amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2),
|
||
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0',
|
||
usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
|
||
});
|
||
});
|
||
|
||
const collateralItems = [];
|
||
let collateralTotal = 0;
|
||
Object.entries(snap.collateral || {}).forEach(([sym, raw]) => {
|
||
const amt = getTokenAmount(raw, sym, isImport);
|
||
if (amt <= 0) return;
|
||
const price = priceForToken(sym, dateStr) || 0;
|
||
const usd = amt * price;
|
||
collateralTotal += usd;
|
||
collateralItems.push({
|
||
symbol: symbolDisplay(sym), amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(4),
|
||
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0',
|
||
usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
|
||
});
|
||
});
|
||
|
||
const borrowItems = [];
|
||
let borrowTotal = 0;
|
||
Object.entries(snap.debt || {}).forEach(([sym, raw]) => {
|
||
const amt = getTokenAmount(raw, sym, isImport);
|
||
if (amt <= 0) return;
|
||
const price = priceForToken(sym, dateStr) || 0;
|
||
const usd = amt * price;
|
||
borrowTotal += usd;
|
||
borrowItems.push({
|
||
symbol: symbolDisplay(sym), amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2),
|
||
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0',
|
||
usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
|
||
});
|
||
});
|
||
|
||
/* Determine action type for filtering */
|
||
let actionType = 'AAVE_IN';
|
||
if (borrowTotal > 0 && (coldTotal === 0 || collateralTotal === 0)) actionType = 'AAVE_OUT';
|
||
else if (collateralTotal > 0) actionType = 'AAVE_IN';
|
||
|
||
const badgeClass = actionType === 'AAVE_IN' ? 'badge-aavein' : 'badge-aaveout';
|
||
const coldTooltip = tokenTooltipHTML(coldItems);
|
||
const collTooltip = tokenTooltipHTML(collateralItems);
|
||
const borrowTooltip = tokenTooltipHTML(borrowItems);
|
||
|
||
html += `<tr class="border-b border-[#1A1F2C]/30 hover:bg-[#1A1F2C]/20 transition-colors" data-wallet="${walletAddr}">
|
||
<td class="px-6 py-4 col-timestamp font-medium text-gray-400 whitespace-nowrap">${dateStr}<br><span class="text-[10px] text-gray-600">${timeStr}</span></td>
|
||
<td class="px-6 py-4 col-wallet">
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-3 h-3 rounded-sm" style="background-color: ${color}; box-shadow: 0 0 8px ${color}66;"></div>
|
||
<span class="text-xs font-medium text-gray-300">${nick}</span>
|
||
</div>
|
||
</td>
|
||
<td class="px-6 py-4 col-value text-right tooltip-cell">
|
||
<span class="font-mono font-bold text-white">${fmtUsd(coldTotal)}</span>
|
||
${coldItems.length > 0 ? `<div class="tooltip-box">${coldTooltip}</div>` : ''}
|
||
</td>
|
||
<td class="px-6 py-4 col-value text-right tooltip-cell">
|
||
<span class="font-mono font-bold text-green-400">${fmtUsd(collateralTotal)}</span>
|
||
${collateralItems.length > 0 ? `<div class="tooltip-box">${collTooltip}</div>` : ''}
|
||
</td>
|
||
<td class="px-6 py-4 col-value text-right tooltip-cell">
|
||
<span class="font-mono font-bold ${borrowTotal > 0 ? 'text-red-400' : 'text-gray-600'}">${fmtUsd(borrowTotal)}</span>
|
||
${borrowItems.length > 0 ? `<div class="tooltip-box">${borrowTooltip}</div>` : ''}
|
||
</td>
|
||
<td class="px-6 py-4"><a href="${hashLink}" target="_blank" class="text-[#FF7A00] opacity-60 hover:opacity-100 transition-opacity font-mono text-xs">${shortHash}</a></td>
|
||
</tr>`;
|
||
});
|
||
tbody.innerHTML = html;
|
||
updateDashboard();
|
||
}
|
||
|
||
/* ===================================================================
|
||
Fetch Portfolio Data for All Verified Wallets
|
||
=================================================================== */
|
||
async function fetchAllWalletData() {
|
||
const verifiedWallets = wm.getVerifiedWallets() || [];
|
||
const promises = verifiedWallets.map(async (w) => fetchWalletAaveData(w.address, w.chain));
|
||
await Promise.all(promises);
|
||
const allSnaps = Object.values(addressSnapshots).flat();
|
||
if (allSnaps.length > 0) {
|
||
/* snapshots are sorted newest-first; find the oldest */
|
||
let oldestTs = Infinity;
|
||
allSnaps.forEach(s => {
|
||
const t = new Date(s.block_timestamp).getTime();
|
||
if (t < oldestTs) oldestTs = t;
|
||
});
|
||
await fetchPrices(Object.keys(TOKENS), oldestTs);
|
||
}
|
||
}
|
||
|
||
/* ===================================================================
|
||
Chart Setup
|
||
=================================================================== */
|
||
function getBaseChartOptions(chartId, dataset, color, isBTC) {
|
||
return {
|
||
chart: { id: chartId, type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
|
||
series: [{ name: isBTC ? 'BTC Price' : 'Net Held', data: dataset }],
|
||
dataLabels: { enabled: false },
|
||
colors: [color],
|
||
stroke: { curve: 'smooth', width: 2 },
|
||
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [{ offset: 0, color: color, opacity: 0.16 }, { offset: 100, color: color, opacity: 0 }] } },
|
||
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||
markers: { size: 0, hover: { size: 5 } },
|
||
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false }, minTickInterval: 86400000 },
|
||
yaxis: { opposite: true, min: (min) => isBTC ? min * 0.98 : min * 0.95, max: (max) => isBTC ? max * 1.02 : max * 1.05, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: function(val) { if (isBTC) return (val / 1000).toFixed(0) + 'k'; return val.toFixed(4); } }, axisBorder: { show: false }, axisTicks: { show: false } },
|
||
tooltip: { enabled: true, theme: 'dark', shared: false, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const rawData = w.config.series[seriesIndex].data[dataPointIndex]; if (!rawData) return ''; const date = new Date(rawData[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const valFormatted = isBTC ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(rawData[1]) : rawData[1].toFixed(6) + ' BTC'; return '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div><div class="text-white text-sm font-bold mt-0.5">' + valFormatted + '</div></div>'; } },
|
||
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
|
||
};
|
||
}
|
||
|
||
function refreshAllCharts(cutoffMs) {
|
||
currentCutoffMs = cutoffMs;
|
||
document.getElementById('since-date').innerText = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }).format(new Date(cutoffMs));
|
||
if (tickIntervalId) clearInterval(tickIntervalId);
|
||
if (window.btcChart) window.btcChart.destroy();
|
||
if (window.cumulChart) window.cumulChart.destroy();
|
||
if (window.satsChart) window.satsChart.destroy();
|
||
if (window.breakdownChart) window.breakdownChart.destroy();
|
||
const filtered = btcPriceData.filter(d => d[0] >= cutoffMs);
|
||
if (filtered.length > 0) setupBtcCard(filtered);
|
||
setupCumulCard(cutoffMs);
|
||
setupSatsCard(cutoffMs);
|
||
setupBreakdownCard(cutoffMs);
|
||
startTickUpdates(btcPriceData.filter(d => d[0] >= cutoffMs));
|
||
}
|
||
|
||
function setupBtcCard(data) {
|
||
const initialPrice = data[0][1];
|
||
const currentPrice = data[data.length - 1][1];
|
||
const percentChange = ((currentPrice - initialPrice) / initialPrice) * 100;
|
||
updateBtcUI(currentPrice, percentChange);
|
||
const options = getBaseChartOptions('instance-btc', data, orangeBrandColor, true);
|
||
const chart = new ApexCharts(document.querySelector("#chart-container-btc"), options);
|
||
chart.render();
|
||
window.btcChart = chart;
|
||
}
|
||
|
||
function updateBtcUI(price, percent) {
|
||
document.getElementById('price-card-btc').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price);
|
||
if (percent !== 0) {
|
||
const retEl = document.getElementById('return-card-btc');
|
||
retEl.innerText = `${percent >= 0 ? '+' : ''}${percent.toFixed(1)}%`;
|
||
retEl.className = `text-lg font-bold ${percent >= 0 ? 'text-green-400' : 'text-red-400'}`;
|
||
}
|
||
const currentUsdVal = currentNetHeld * price;
|
||
const totalPnl = currentUsdVal - currentBuyCost;
|
||
const pnlPercent = currentBuyCost > 0 ? (totalPnl / currentBuyCost) * 100 : 0;
|
||
document.getElementById('current-usd-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentUsdVal);
|
||
const pnlEl = document.getElementById('total-pnl-val');
|
||
pnlEl.innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(totalPnl);
|
||
pnlEl.className = `text-xl font-bold ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`;
|
||
const pnlPctEl = document.getElementById('pnl-percent-val');
|
||
pnlPctEl.innerText = `${totalPnl >= 0 ? '+' : ''}${pnlPercent.toFixed(2)}%`;
|
||
pnlPctEl.className = `text-xs font-bold mt-1 ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`;
|
||
}
|
||
|
||
function updateHypeUI() {
|
||
const hypePrice = priceForToken('HYPE', new Date().toISOString().slice(0, 10)) || 0;
|
||
document.getElementById('hype-held-val').innerText = currentHypeHeld.toFixed(4);
|
||
const currentUsdVal = currentHypeHeld * hypePrice;
|
||
document.getElementById('hype-usd-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentUsdVal);
|
||
const totalPnl = currentUsdVal - currentHypeBuyCost;
|
||
const pnlPercent = currentHypeBuyCost > 0 ? (totalPnl / currentHypeBuyCost) * 100 : 0;
|
||
const pnlEl = document.getElementById('hype-pnl-val');
|
||
pnlEl.innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(totalPnl);
|
||
pnlEl.className = `text-xl font-bold ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`;
|
||
const pnlPctEl = document.getElementById('hype-pnl-percent-val');
|
||
pnlPctEl.innerText = `${totalPnl >= 0 ? '+' : ''}${pnlPercent.toFixed(2)}%`;
|
||
pnlPctEl.className = `text-xs font-bold mt-1 ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`;
|
||
const avgBuyHype = currentHypeBuyAmount > 0 ? currentHypeBuyCost / currentHypeBuyAmount : 0;
|
||
document.getElementById('hype-avg-buy-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(avgBuyHype);
|
||
document.getElementById('hype-total-invested-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentHypeBuyCost);
|
||
document.getElementById('hype-tx-count-val').innerText = currentHypeTxCount;
|
||
}
|
||
|
||
function setupCumulCard(cutoff) {
|
||
const selectedWallets = getSelectedAddresses();
|
||
const wallets = wm.getWallets();
|
||
|
||
const series = selectedWallets.map(w => {
|
||
const wm = wallets.find(x => x.address === w.address && x.chain === w.chain);
|
||
const nickname = wm ? (wm.nickname || w.address.slice(0, 6)) : w.address.slice(0, 6);
|
||
const ws = currentWalletSeries[walletKey(w.address, w.chain)] || [];
|
||
const filtered = ws.filter(d => d[0] >= cutoff);
|
||
/* Extend to today */
|
||
if (filtered.length > 0) {
|
||
const lastPt = filtered[filtered.length - 1];
|
||
const todayUTC = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
|
||
if (lastPt[0] < todayUTC) {
|
||
filtered.push([todayUTC, lastPt[1]]);
|
||
}
|
||
}
|
||
return { name: nickname, data: filtered };
|
||
});
|
||
|
||
const colors = selectedWallets.map(w => getColorForWallet(w.address, w.chain));
|
||
|
||
const gradientStops = colors.map(c => [
|
||
{ offset: 0, color: c, opacity: 0.35 },
|
||
{ offset: 100, color: c, opacity: 0 }
|
||
]);
|
||
|
||
const filteredTotal = currentAggregatedSeries.filter(d => d[0] >= cutoff);
|
||
let yMin = 0, yMax = 0;
|
||
if (filteredTotal.length > 0) {
|
||
const vals = filteredTotal.map(d => d[1]);
|
||
yMin = Math.min(...vals) * 0.95;
|
||
yMax = Math.max(...vals) * 1.05;
|
||
}
|
||
|
||
const options = {
|
||
chart: { id: 'instance-cumul', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false }, stacked: true },
|
||
stackType: 'normal',
|
||
series: series,
|
||
dataLabels: { enabled: false },
|
||
colors: colors,
|
||
stroke: { curve: 'smooth', width: 2 },
|
||
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: gradientStops } },
|
||
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||
markers: { size: 0, hover: { size: 5 } },
|
||
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false }, minTickInterval: 86400000 },
|
||
yaxis: { opposite: true, min: yMin, max: yMax, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => val.toFixed(4) }, axisBorder: { show: false }, axisTicks: { show: false } },
|
||
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) {
|
||
const rawData = w.config.series[0].data[dataPointIndex];
|
||
if (!rawData) return '';
|
||
const date = new Date(rawData[0]);
|
||
const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||
let html = '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div>';
|
||
let total = 0;
|
||
w.config.series.forEach((s, i) => {
|
||
const pt = s.data[dataPointIndex];
|
||
if (pt) {
|
||
total += pt[1];
|
||
html += '<div class="flex items-center justify-between text-xs mt-1"><div class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full" style="background:' + colors[i] + ';"></div><span class="text-gray-300">' + s.name + '</span></div><span class="text-white font-bold">' + pt[1].toFixed(6) + '</span></div>';
|
||
}
|
||
});
|
||
html += '<div class="border-t border-gray-600 mt-1 pt-1 flex items-center justify-between text-xs"><span class="text-gray-300">Total</span><span class="text-white font-bold">' + total.toFixed(6) + ' BTC</span></div>';
|
||
html += '</div>';
|
||
return html;
|
||
} },
|
||
legend: { show: false },
|
||
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
|
||
};
|
||
const chart = new ApexCharts(document.querySelector("#chart-container-cumul"), options);
|
||
chart.render();
|
||
window.cumulChart = chart;
|
||
}
|
||
|
||
function setupSatsCard(cutoff) {
|
||
const DAY_MS = 86400000;
|
||
/* Collect all dates across selected wallets, then forward-fill each wallet's balance */
|
||
const allDates = {};
|
||
const selectedWallets = getSelectedAddresses() || [];
|
||
selectedWallets.forEach(w => {
|
||
const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
|
||
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
|
||
});
|
||
const sortedDates = Object.keys(allDates).sort();
|
||
|
||
const wallets = wm.getWallets();
|
||
/* Per-wallet forward-fill */
|
||
const walletDailyBalances = {};
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
walletDailyBalances[key] = {};
|
||
const isImport = w.chain === 'import';
|
||
const snaps = addressSnapshots[key] || [];
|
||
const dated = snaps.map(s => ({
|
||
dateStr: s.block_timestamp.slice(0, 10),
|
||
btcVal: getTotalBTC(s?.wallet, isImport) + getTotalBTC(s?.collateral, isImport)
|
||
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
||
let balance = 0;
|
||
for (const ds of sortedDates) {
|
||
const match = dated.find(d => d.dateStr === ds);
|
||
if (match) balance = match.btcVal;
|
||
walletDailyBalances[key][ds] = balance;
|
||
}
|
||
});
|
||
|
||
/* Per-wallet daily deltas */
|
||
const walletDailyDeltas = {};
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
walletDailyDeltas[key] = {};
|
||
let prev = 0;
|
||
for (const ds of sortedDates) {
|
||
const b = walletDailyBalances[key][ds] || 0;
|
||
walletDailyDeltas[key][ds] = b - prev;
|
||
prev = b;
|
||
}
|
||
});
|
||
|
||
const todayUTCs = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
|
||
const firstUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[0]).getUTCFullYear(), new Date(sortedDates[0]).getUTCMonth(), new Date(sortedDates[0]).getUTCDate()) : Date.now();
|
||
const lastUTC = sortedDates.length > 0 ? Math.max(Date.UTC(new Date(sortedDates[sortedDates.length - 1]).getUTCFullYear(), new Date(sortedDates[sortedDates.length - 1]).getUTCMonth(), new Date(sortedDates[sortedDates.length - 1]).getUTCDate()), todayUTCs) : Date.now();
|
||
const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS));
|
||
|
||
/* Per-wallet series: running avg sats/day */
|
||
const series = selectedWallets.map(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const wm = wallets.find(x => x.address === w.address && x.chain === w.chain);
|
||
const nickname = wm ? (wm.nickname || w.address.slice(0, 6)) : w.address.slice(0, 6);
|
||
const data = [];
|
||
let runningTotalBtc = 0;
|
||
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
|
||
const ds = new Date(t).toISOString().slice(0, 10);
|
||
runningTotalBtc += walletDailyDeltas[key][ds] || 0;
|
||
const daysSoFar = Math.max(1, Math.ceil((t - firstUTC) / DAY_MS) + 1);
|
||
const runningAvgSats = Math.round((runningTotalBtc * 1e8) / daysSoFar);
|
||
if (t >= cutoff) {
|
||
data.push([t, runningAvgSats]);
|
||
}
|
||
}
|
||
return { name: nickname, data };
|
||
});
|
||
|
||
const latestTotal = sortedDates.length > 0 ? (() => {
|
||
let s = 0;
|
||
selectedWallets.forEach(w => { s += walletDailyBalances[walletKey(w.address, w.chain)][sortedDates[sortedDates.length - 1]] || 0; });
|
||
return s;
|
||
})() : 0;
|
||
const avgSatsPerDay = Math.round((latestTotal * 1e8) / daysElapsed);
|
||
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(avgSatsPerDay);
|
||
|
||
const colors = selectedWallets.map(w => getColorForWallet(w.address, w.chain));
|
||
const gradientStops = colors.map(c => [
|
||
{ offset: 0, color: c, opacity: 0.35 },
|
||
{ offset: 100, color: c, opacity: 0 }
|
||
]);
|
||
|
||
const options = {
|
||
chart: { id: 'instance-sats', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false }, stacked: true },
|
||
stackType: 'normal',
|
||
series: series,
|
||
dataLabels: { enabled: false },
|
||
colors: colors,
|
||
stroke: { curve: 'smooth', width: 2 },
|
||
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: gradientStops } },
|
||
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||
markers: { size: 0, hover: { size: 5 } },
|
||
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false }, minTickInterval: 86400000 },
|
||
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => { if (val >= 1e6) return (val / 1e6).toFixed(0) + 'M'; if (val >= 1e3) return (val / 1e3).toFixed(0) + 'k'; return val; } }, axisBorder: { show: false }, axisTicks: { show: false } },
|
||
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) {
|
||
const rawData = w.config.series[0].data[dataPointIndex];
|
||
if (!rawData) return '';
|
||
const date = new Date(rawData[0]);
|
||
const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||
let html = '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div>';
|
||
let totalSats = 0;
|
||
w.config.series.forEach((s, i) => {
|
||
const pt = s.data[dataPointIndex];
|
||
if (pt) {
|
||
totalSats += pt[1];
|
||
const btcVal = (pt[1] / 1e8).toFixed(6);
|
||
html += '<div class="flex items-center justify-between text-xs mt-1"><div class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full" style="background:' + colors[i] + ';"></div><span class="text-gray-300">' + s.name + '</span></div><span class="text-white font-bold">\u20BF ' + btcVal + '</span></div>';
|
||
}
|
||
});
|
||
html += '<div class="border-t border-gray-600 mt-1 pt-1 flex items-center justify-between text-xs"><span class="text-gray-300">Total</span><span class="text-white font-bold">' + new Intl.NumberFormat('en-US').format(totalSats) + ' sats</span></div>';
|
||
html += '</div>';
|
||
return html;
|
||
} },
|
||
legend: { show: false },
|
||
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
|
||
};
|
||
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
|
||
chart.render();
|
||
window.satsChart = chart;
|
||
}
|
||
|
||
function setupBreakdownCard(cutoff) {
|
||
/* Derive from snapshots: cold wallet vs collateral */
|
||
const allDates = {};
|
||
const selectedWallets = getSelectedAddresses() || [];
|
||
selectedWallets.forEach(w => {
|
||
const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
|
||
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
|
||
});
|
||
const snapshotDates = Object.keys(allDates).sort();
|
||
if (snapshotDates.length === 0) {
|
||
const opts = {
|
||
chart: { id: 'instance-breakdown', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, animations: { enabled: false } },
|
||
series: [{ name: 'Total Holdings', data: [] }, { name: 'On Aave', data: [] }],
|
||
colors: [orangeBrandColor, blueBrandColor]
|
||
};
|
||
const chart = new ApexCharts(document.querySelector("#chart-container-breakdown"), opts);
|
||
chart.render();
|
||
window.breakdownChart = chart;
|
||
return;
|
||
}
|
||
|
||
const todayUTCb = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
|
||
const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate());
|
||
const lastUTC = Math.max(Date.UTC(new Date(snapshotDates[snapshotDates.length - 1]).getUTCFullYear(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCMonth(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCDate()), todayUTCb);
|
||
const DAY_MS = 86400000;
|
||
|
||
/* Pre-compute per-wallet dated arrays (oldest-first) */
|
||
const walletDated = {};
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const isImport = w.chain === 'import';
|
||
const snaps = addressSnapshots[key] || [];
|
||
walletDated[key] = snaps.map(s => ({
|
||
dateStr: s.block_timestamp.slice(0, 10),
|
||
cold: getTotalBTC(s?.wallet, isImport),
|
||
collateral: getTotalBTC(s?.collateral, isImport)
|
||
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
||
});
|
||
|
||
/* Walk every calendar day, forward-fill each wallet, aggregate */
|
||
const totalSeries = [];
|
||
const aaveSeries = [];
|
||
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
|
||
const ds = new Date(t).toISOString().slice(0, 10);
|
||
let dayCold = 0, dayColl = 0;
|
||
selectedWallets.forEach(w => {
|
||
const key = walletKey(w.address, w.chain);
|
||
const dated = walletDated[key];
|
||
let c = 0, co = 0;
|
||
for (const d of dated) {
|
||
if (d.dateStr <= ds) { c = d.cold; co = d.collateral; }
|
||
else break;
|
||
}
|
||
dayCold += c;
|
||
dayColl += co;
|
||
});
|
||
if (t >= cutoff) {
|
||
totalSeries.push([t, dayCold + dayColl]);
|
||
aaveSeries.push([t, dayColl]);
|
||
}
|
||
}
|
||
|
||
const opts = {
|
||
chart: { id: 'instance-breakdown', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
|
||
series: [
|
||
{ name: 'Total Holdings', data: totalSeries },
|
||
{ name: 'On Aave', data: aaveSeries }
|
||
],
|
||
dataLabels: { enabled: false },
|
||
colors: [orangeBrandColor, blueBrandColor],
|
||
stroke: { curve: 'smooth', width: 2 },
|
||
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [[{ offset: 0, color: orangeBrandColor, opacity: 0.16 }, { offset: 100, color: orangeBrandColor, opacity: 0 }], [{ offset: 0, color: blueBrandColor, opacity: 0.16 }, { offset: 100, color: blueBrandColor, opacity: 0 }]] } },
|
||
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||
markers: { size: 0, strokeColors: [orangeBrandColor, blueBrandColor], strokeWidth: 3, hover: { size: 5 } },
|
||
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false }, minTickInterval: 86400000 },
|
||
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => val.toFixed(4) }, axisBorder: { show: false }, axisTicks: { show: false } },
|
||
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const item = totalSeries[dataPointIndex]; if (!item) return ''; const date = new Date(item[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const total = item[1]; const aave = aaveSeries[dataPointIndex]?.[1] || 0; const delta = total - aave; return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + orangeBrandColor + ';"></div><span class="text-gray-300 text-xs">Total</span></div><span class="text-orange-400 text-sm font-bold">' + total.toFixed(6) + '</span></div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + blueBrandColor + ';"></div><span class="text-gray-300 text-xs">On Aave</span></div><span class="text-blue-400 text-sm font-bold">' + aave.toFixed(6) + '</span></div><div class="flex items-center justify-between"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color: #22c55e;"></div><span class="text-gray-300 text-xs">Delta</span></div><span class="text-green-400 text-sm font-bold">' + delta.toFixed(6) + '</span></div></div>'; } },
|
||
legend: { position: 'top', horizontalAlign: 'left', fontSize: '10px', fontFamily: 'sans-serif', labels: { colors: '#4B5563' }, markers: { width: 8, height: 8, radius: 12, offsetX: -4 }, itemMargin: { horizontal: 8, vertical: 0 } },
|
||
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
|
||
};
|
||
const chart = new ApexCharts(document.querySelector("#chart-container-breakdown"), opts);
|
||
chart.render();
|
||
window.breakdownChart = chart;
|
||
}
|
||
|
||
function startTickUpdates(data) {
|
||
tickIntervalId = setInterval(() => {
|
||
const tickShift = (Math.random() * 16) - 8;
|
||
let currentVal = data[data.length - 1][1] + tickShift;
|
||
data[data.length - 1][1] = parseFloat(currentVal.toFixed(2));
|
||
const initialPrice = data[0][1];
|
||
const percentChange = ((currentVal - initialPrice) / initialPrice) * 100;
|
||
updateBtcUI(currentVal, percentChange);
|
||
todayBtcPrice = currentVal;
|
||
if (window.btcChart) {
|
||
const currentXMin = window.btcChart.w.globals.minX;
|
||
const currentXMax = window.btcChart.w.globals.maxX;
|
||
window.btcChart.updateOptions({ series: [{ data: data }], xaxis: { min: currentXMin, max: currentXMax } }, false, false, false);
|
||
}
|
||
}, 2500);
|
||
}
|
||
|
||
/* ===================================================================
|
||
Double-click to reset zoom
|
||
=================================================================== */
|
||
function resetChartZoom(chartInstance) {
|
||
if (chartInstance) {
|
||
chartInstance.updateOptions({
|
||
xaxis: { min: undefined, max: undefined }
|
||
}, false, false, true);
|
||
}
|
||
}
|
||
|
||
document.getElementById('chart-container-btc').addEventListener('dblclick', () => resetChartZoom(window.btcChart));
|
||
document.getElementById('chart-container-cumul').addEventListener('dblclick', () => resetChartZoom(window.cumulChart));
|
||
document.getElementById('chart-container-sats').addEventListener('dblclick', () => resetChartZoom(window.satsChart));
|
||
document.getElementById('chart-container-breakdown').addEventListener('dblclick', () => resetChartZoom(window.breakdownChart));
|
||
|
||
/* ===================================================================
|
||
Global Event Listeners
|
||
=================================================================== */
|
||
document.querySelectorAll('.filter-checkbox').forEach(cb => {
|
||
cb.addEventListener('change', () => updateDashboard());
|
||
});
|
||
|
||
/* ===================================================================
|
||
Polling for Verified Wallets
|
||
=================================================================== */
|
||
async function pollVerifiedWallets() {
|
||
const now = Date.now();
|
||
if (now - lastFetchMs < 30000) return;
|
||
lastFetchMs = now;
|
||
const verified = wm.getVerifiedWallets() || [];
|
||
let anyUpdated = false;
|
||
for (const w of verified) {
|
||
try {
|
||
const key = walletKey(w.address, w.chain);
|
||
const endpoint = w.chain === 'import'
|
||
? `/api/v1/portfolio/${w.address}/${w.chain}/wallet`
|
||
: `/api/v1/portfolio/${w.address}/${w.chain}/aave`;
|
||
const resp = await fetch(`${API_BASE}${endpoint}`);
|
||
if (!resp.ok) continue;
|
||
const events = await resp.json();
|
||
if (events.length === 0) continue;
|
||
let ledger = events;
|
||
if (w.chain === 'import' && events.length && events[0].token_address !== undefined) {
|
||
const sorted = [...events].sort((a, b) => new Date(a.block_timestamp) - new Date(b.block_timestamp));
|
||
const balances = {};
|
||
ledger = sorted.map(r => {
|
||
const tk = r.token_address;
|
||
const decs = TOKENS[tk] ? TOKENS[tk].decimals : 0;
|
||
const amt = (parseFloat(r.amount) || 0) / Math.pow(10, decs);
|
||
balances[tk] = (balances[tk] || 0) + (r.direction === 'IN' ? amt : -amt);
|
||
return {
|
||
tx_hash: r.tx_hash || '0x0',
|
||
block_timestamp: r.block_timestamp,
|
||
wallet: { [tk]: balances[tk].toFixed(18) },
|
||
collateral: {},
|
||
debt: {}
|
||
};
|
||
});
|
||
}
|
||
const newestTs = ledger.reduce((m, e) => (e.block_timestamp > m ? e.block_timestamp : m), ledger[0].block_timestamp);
|
||
const existing = addressSnapshots[key];
|
||
if (existing && existing.length > 0) {
|
||
if (newestTs > existing[0].block_timestamp) {
|
||
addressSnapshots[key] = snapshotsToDaily(ledger);
|
||
walletSyncState[key] = 'synced';
|
||
anyUpdated = true;
|
||
}
|
||
} else {
|
||
addressSnapshots[key] = snapshotsToDaily(ledger);
|
||
walletSyncState[key] = 'synced';
|
||
anyUpdated = true;
|
||
}
|
||
} catch (err) {
|
||
console.error("Poll update failed for " + key, err);
|
||
}
|
||
}
|
||
if (anyUpdated) {
|
||
await refreshPrices();
|
||
renderCombinedTable();
|
||
updateDashboard();
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
setInterval(() => pollVerifiedWallets(), 60000);
|
||
}
|
||
|
||
/* ===================================================================
|
||
Initialization
|
||
=================================================================== */
|
||
async function initDashboardGrid() {
|
||
renderWalletPills();
|
||
renderLedgerFilterWallets();
|
||
|
||
try {
|
||
/* Fetch BTC price */
|
||
const response = await fetch('https://api.kraken.com/0/public/OHLC?pair=XBTUSD&interval=1440');
|
||
const result = await response.json();
|
||
const rawPrices = result.result.XXBTZUSD;
|
||
btcPriceData = rawPrices.map(item => [item[0] * 1000, parseFloat(item[4])]);
|
||
|
||
/* Fetch data for all verified wallets (already fetches full-range prices) */
|
||
await fetchAllWalletData();
|
||
|
||
renderCombinedTable();
|
||
|
||
const cutoff = getOldestTransactionDate();
|
||
currentCutoffMs = cutoff;
|
||
refreshAllCharts(cutoff);
|
||
|
||
startPolling();
|
||
} catch (error) {
|
||
console.error("Dashboard error:", error);
|
||
}
|
||
}
|
||
|
||
initDashboardGrid();
|
||
|
||
/* ===================================================================
|
||
Toast Notification System
|
||
=================================================================== */
|
||
function showToast(title, message, type = 'info', duration = 4000) {
|
||
const container = document.getElementById('toast-container');
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast-box';
|
||
|
||
let icon = 'ℹ';
|
||
let barColor = '#5B7FFF';
|
||
if (type === 'success') { icon = '✓'; barColor = '#22c55e'; }
|
||
else if (type === 'error') { icon = '✕'; barColor = '#ef4444'; }
|
||
else if (type === 'warning') { icon = '⚠'; barColor = '#eab308'; }
|
||
|
||
toast.innerHTML =
|
||
'<div class="toast-icon">' + icon + '</div>' +
|
||
'<div class="toast-body">' +
|
||
'<div class="toast-title">' + title + '</div>' +
|
||
'<div class="toast-message">' + message + '</div>' +
|
||
'<div class="toast-progress"><div class="toast-progress-bar" style="width:100%;background:' + barColor + ';"></div></div>' +
|
||
'</div>';
|
||
|
||
container.appendChild(toast);
|
||
|
||
setTimeout(() => {
|
||
toast.classList.add('toast-exit');
|
||
setTimeout(() => toast.remove(), 260);
|
||
}, duration);
|
||
}
|
||
|
||
/* ===================================================================
|
||
CSV Import — Modal + Form Flow
|
||
=================================================================== */
|
||
/* Stash selected file while modal is open */
|
||
let _importCsvFile = null;
|
||
let _importSelectedColor = null;
|
||
|
||
function renderImportColorPicker() {
|
||
const container = document.getElementById('import-color-picker');
|
||
container.innerHTML = '';
|
||
if (!_importSelectedColor) _importSelectedColor = autoPickColor();
|
||
WALLET_COLORS.forEach((c) => {
|
||
const swatch = document.createElement('div');
|
||
swatch.className = 'color-picker-swatch' + (_importSelectedColor.toLowerCase() === c.toLowerCase() ? ' selected' : '');
|
||
swatch.style.setProperty('--swatch-color', c);
|
||
swatch.style.background = c;
|
||
swatch.addEventListener('click', () => {
|
||
_importSelectedColor = c;
|
||
renderImportColorPicker();
|
||
});
|
||
container.appendChild(swatch);
|
||
});
|
||
}
|
||
|
||
function openImportCsvModal() {
|
||
_importCsvFile = null;
|
||
_csvImportRecords = [];
|
||
_importSelectedColor = autoPickColor();
|
||
document.getElementById('import-nickname').value = '';
|
||
document.getElementById('import-address').value = generateRandomAddress();
|
||
document.getElementById('import-filename').textContent = 'Click to select a .csv file';
|
||
document.getElementById('import-file-display').classList.remove('border-green-500/50');
|
||
document.getElementById('import-file-display').classList.add('border-[#1A1F2C]');
|
||
document.getElementById('import-csv-error').classList.add('hidden');
|
||
document.getElementById('import-csv-progress').classList.add('hidden');
|
||
document.getElementById('import-csv-submit').disabled = false;
|
||
document.getElementById('import-submit-text').textContent = 'Import';
|
||
renderImportColorPicker();
|
||
document.getElementById('import-csv-modal').classList.add('open');
|
||
}
|
||
|
||
function generateRandomAddress() {
|
||
const buf = new Uint8Array(18);
|
||
crypto.getRandomValues(buf);
|
||
return '0x' + Array.from(buf).map(b => b.toString(16).padStart(2, '0')).join('') + '494d504f5254';
|
||
}
|
||
|
||
function closeImportCsvModal() {
|
||
document.getElementById('import-csv-modal').classList.remove('open');
|
||
_importCsvFile = null;
|
||
_csvImportRecords = [];
|
||
}
|
||
|
||
/* Called when hidden file input gets a file — store it, show name */
|
||
function handleCsvFileSelect(event) {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
_importCsvFile = file;
|
||
document.getElementById('import-filename').textContent = file.name;
|
||
document.getElementById('import-file-display').classList.remove('border-[#1A1F2C]');
|
||
document.getElementById('import-file-display').classList.add('border-green-500/50');
|
||
}
|
||
}
|
||
|
||
/* Parse the CSV on file select to validate early */
|
||
function parseCsvOnSelect(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const records = parseCsvAndMap(e.target.result);
|
||
resolve(records);
|
||
} catch (err) {
|
||
reject(err);
|
||
}
|
||
};
|
||
reader.onerror = reject;
|
||
reader.readAsText(file);
|
||
});
|
||
}
|
||
|
||
/* Submit handler — Step 1: register IMPORT_ONLY wallet, Step 2: upload CSV */
|
||
async function handleImportCsv(e) {
|
||
e.preventDefault();
|
||
|
||
const nickname = document.getElementById('import-nickname').value.trim();
|
||
let address = document.getElementById('import-address').value.trim().toLowerCase();
|
||
const chain = 'import';
|
||
const color = _importSelectedColor || autoPickColor();
|
||
const errEl = document.getElementById('import-csv-error');
|
||
const progressEl = document.getElementById('import-csv-progress');
|
||
const submitBtn = document.getElementById('import-csv-submit');
|
||
const submitText = document.getElementById('import-submit-text');
|
||
|
||
/* Validate */
|
||
if (!nickname) {
|
||
errEl.textContent = 'Nickname is required.';
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
/* Accept EVM (0x...), BTC Bech32 (bc1...), BTC legacy (1.../3...), or Solana */
|
||
const evmRe = /^(0x)?[0-9a-f]{40}$/i;
|
||
const btcBech32Re = /^bc1[a-z0-9]{25,62}$/i;
|
||
const btcLegacyRe = /^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$/;
|
||
const solanaRe = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
||
if (!address || !(evmRe.test(address) || btcBech32Re.test(address) || btcLegacyRe.test(address) || solanaRe.test(address))) {
|
||
errEl.textContent = 'Valid address required (EVM 0x-prefixed, BTC bc1/1/3-prefixed, or Solana base58).';
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
/* Normalize EVM addresses — non-EVM addresses pass through as-is */
|
||
if (evmRe.test(address) && !address.startsWith('0x')) address = '0x' + address;
|
||
if (!_importCsvFile) {
|
||
errEl.textContent = 'No CSV file selected.';
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
|
||
/* Check address not already tracked on import chain */
|
||
const existing = wm.findWallet(address, chain);
|
||
if (existing) {
|
||
errEl.textContent = `Wallet ${address} already tracked on import chain.`;
|
||
errEl.classList.remove('hidden');
|
||
return false;
|
||
}
|
||
|
||
errEl.classList.add('hidden');
|
||
progressEl.classList.remove('hidden');
|
||
submitBtn.disabled = true;
|
||
submitText.textContent = 'Parsing CSV...';
|
||
|
||
/* Parse CSV into memory for client-side ledger calculation */
|
||
const fileContent = _importCsvFile
|
||
? await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => resolve(e.target.result);
|
||
reader.onerror = reject;
|
||
reader.readAsText(_importCsvFile);
|
||
})
|
||
: '';
|
||
_csvImportRecords = parseCsvAndMap(fileContent);
|
||
let hasRecords = _csvImportRecords.length > 0;
|
||
|
||
if (!hasRecords) {
|
||
errEl.textContent = 'No valid records found in the CSV.';
|
||
errEl.classList.remove('hidden');
|
||
submitBtn.disabled = false;
|
||
submitText.textContent = 'Import';
|
||
return false;
|
||
}
|
||
|
||
submitText.textContent = 'Registering wallet...';
|
||
|
||
try {
|
||
/* Step 1: Register IMPORT_ONLY wallet */
|
||
const registerResp = await fetch(`${API_BASE}/api/v1/portfolio/monitor`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ address, chain: 'import', chain_id: 0 })
|
||
});
|
||
|
||
if (!registerResp.ok && registerResp.status !== 409) {
|
||
const errBody = await registerResp.json().catch(() => ({}));
|
||
throw new Error(errBody?.detail || `Registration failed (HTTP ${registerResp.status})`);
|
||
}
|
||
|
||
submitText.textContent = 'Uploading CSV...';
|
||
|
||
/* Step 2: Upload CSV via multipart endpoint */
|
||
const formData = new FormData();
|
||
formData.append('file', _importCsvFile);
|
||
|
||
const uploadResp = await fetch(
|
||
`${API_BASE}/api/v1/portfolio/import/csv/${chain}/${address}?skip_ledger=true`,
|
||
{
|
||
method: 'POST',
|
||
body: formData
|
||
}
|
||
);
|
||
|
||
if (!uploadResp.ok) {
|
||
const errBody = await uploadResp.json().catch(() => ({}));
|
||
const errMsg = errBody?.errors
|
||
? errBody.errors.join('\n')
|
||
: errBody?.detail || `Upload failed (HTTP ${uploadResp.status})`;
|
||
throw new Error(errMsg);
|
||
}
|
||
|
||
const result = await uploadResp.json();
|
||
const inserted = result.inserted || 0;
|
||
const skipped = result.skipped || 0;
|
||
|
||
/* Register wallet in local state */
|
||
wm.addWallet(address, 'import', nickname);
|
||
wm.verifyWallet(address, 'import', '', { action: 'CSV Import (IMPORT_ONLY)', walletAddress: address });
|
||
setColorForWallet(address, 'import', color);
|
||
|
||
/* Step 3: Fetch computed ledger from backend */
|
||
submitText.textContent = 'Loading ledger...';
|
||
await fetchWalletAaveData(address, 'import');
|
||
|
||
showToast(
|
||
'Import Complete',
|
||
`${inserted} record${inserted !== 1 ? 's' : ''} added` + (skipped > 0 ? `, ${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped` : ''),
|
||
'success',
|
||
5000
|
||
);
|
||
|
||
closeImportCsvModal();
|
||
closeSidebar();
|
||
renderAll();
|
||
|
||
/* Re-fetch prices for new date range and re-render */
|
||
await _fetchPricesAndRender();
|
||
|
||
} catch (err) {
|
||
showToast('Import Failed', err.message || 'Network error', 'error');
|
||
progressEl.classList.add('hidden');
|
||
submitBtn.disabled = false;
|
||
submitText.textContent = 'Import';
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/* ===================================================================
|
||
CSV Parsing Engine — Zero Dependency
|
||
=================================================================== */
|
||
/* Column header aliases → canonical snake_case field names */
|
||
const CSV_FIELD_MAP = {
|
||
'tx_hash': 'tx_hash',
|
||
'transaction_hash': 'tx_hash',
|
||
'transactionhash': 'tx_hash',
|
||
'txhash': 'tx_hash',
|
||
'block_number': 'block_number',
|
||
'blocknumber': 'block_number',
|
||
'blockno': 'block_number',
|
||
'block_timestamp': 'block_timestamp',
|
||
'blocktimestamp': 'block_timestamp',
|
||
'timestamp': 'block_timestamp',
|
||
'time': 'block_timestamp',
|
||
'token_address': 'token_address',
|
||
'tokenaddress': 'token_address',
|
||
'token_contract': 'token_address',
|
||
'contract_address': 'token_address',
|
||
'amount': 'amount',
|
||
'raw_amount': 'amount',
|
||
'wei': 'amount',
|
||
'direction': 'direction',
|
||
'flow': 'direction',
|
||
'type': 'direction',
|
||
'from_address': 'from_address',
|
||
'fromaddress': 'from_address',
|
||
'from': 'from_address',
|
||
'to_address': 'to_address',
|
||
'toaddress': 'to_address',
|
||
'to': 'to_address',
|
||
};
|
||
|
||
/* Canonical fields */
|
||
const REQUIRED_CSV_FIELDS = ['block_timestamp', 'token_address', 'amount', 'direction'];
|
||
|
||
/**
|
||
* Parse CSV text and map columns to ImportTransferRecord fields.
|
||
* Supports quoted fields, commas inside quotes, and CRLF/LF line endings.
|
||
*/
|
||
function parseCsvAndMap(csvText) {
|
||
/* Split into rows — respect quoted fields with embedded newlines */
|
||
const rows = splitCsvRows(csvText);
|
||
if (rows.length < 2) return [];
|
||
|
||
/* Parse header row → normalize to snake_case */
|
||
const header = rows[0].map(h => {
|
||
const trimmed = h.trim().toLowerCase().replace(/\s+/g, '_').replace(/^-+|-+$/g, '');
|
||
return CSV_FIELD_MAP[trimmed] || trimmed;
|
||
});
|
||
|
||
/* Validate required fields */
|
||
const missing = REQUIRED_CSV_FIELDS.filter(f => !header.includes(f));
|
||
if (missing.length > 0) {
|
||
showToast('Import Failed', `CSV missing required columns: ${missing.join(', ')}`, 'error');
|
||
return [];
|
||
}
|
||
|
||
const colIdx = {};
|
||
header.forEach((f, i) => { colIdx[f] = i; });
|
||
|
||
const records = [];
|
||
|
||
for (let r = 1; r < rows.length; r++) {
|
||
const row = rows[r];
|
||
if (row.length < header.length) continue; /* skip malformed row */
|
||
|
||
/* Skip empty rows */
|
||
if (row.every(c => c.trim() === '')) continue;
|
||
|
||
const blockTimestamp = row[colIdx['block_timestamp']].trim();
|
||
const tokenAddress = row[colIdx['token_address']].trim();
|
||
const amountRaw = row[colIdx['amount']].trim();
|
||
const directionRaw = row[colIdx['direction']].trim().toUpperCase();
|
||
|
||
/* Validate required fields */
|
||
if (!blockTimestamp || !tokenAddress || !amountRaw || !directionRaw) continue;
|
||
|
||
/* Validate direction */
|
||
if (directionRaw !== 'IN' && directionRaw !== 'OUT') continue;
|
||
|
||
/* Validate ISO timestamp format */
|
||
if (isNaN(Date.parse(blockTimestamp))) continue;
|
||
|
||
const record = {
|
||
block_timestamp: blockTimestamp,
|
||
token_address: tokenAddress,
|
||
amount: amountRaw,
|
||
direction: directionRaw,
|
||
};
|
||
|
||
/* Optional fields */
|
||
const txHashRaw = row[colIdx['tx_hash']];
|
||
if (txHashRaw && txHashRaw.trim()) {
|
||
record.tx_hash = txHashRaw.trim();
|
||
}
|
||
|
||
const blockNumRaw = row[colIdx['block_number']];
|
||
if (blockNumRaw && blockNumRaw.trim()) {
|
||
const parsed = parseInt(blockNumRaw.trim(), 10);
|
||
if (!isNaN(parsed)) {
|
||
record.block_number = parsed;
|
||
}
|
||
}
|
||
|
||
const fromAddrRaw = row[colIdx['from_address']];
|
||
if (fromAddrRaw && fromAddrRaw.trim()) {
|
||
record.from_address = fromAddrRaw.trim();
|
||
}
|
||
|
||
const toAddrRaw = row[colIdx['to_address']];
|
||
if (toAddrRaw && toAddrRaw.trim()) {
|
||
record.to_address = toAddrRaw.trim();
|
||
}
|
||
|
||
records.push(record);
|
||
}
|
||
|
||
return records;
|
||
}
|
||
|
||
/**
|
||
* Split CSV into rows, respecting quoted fields and embedded newlines.
|
||
* Returns array of arrays (rows → cells).
|
||
*/
|
||
function splitCsvRows(text) {
|
||
const rows = [];
|
||
let current = [];
|
||
let field = '';
|
||
let inQuotes = false;
|
||
let i = 0;
|
||
|
||
while (i < text.length) {
|
||
const ch = text[i];
|
||
|
||
if (inQuotes) {
|
||
if (ch === '"') {
|
||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||
field += '"';
|
||
i += 2;
|
||
} else {
|
||
inQuotes = false;
|
||
i++;
|
||
}
|
||
} else {
|
||
field += ch;
|
||
i++;
|
||
}
|
||
} else {
|
||
if (ch === '"') {
|
||
inQuotes = true;
|
||
i++;
|
||
} else if (ch === ',') {
|
||
current.push(field);
|
||
field = '';
|
||
i++;
|
||
} else if (ch === '\n' || ch === '\r') {
|
||
if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
|
||
i++;
|
||
}
|
||
current.push(field);
|
||
field = '';
|
||
if (current.length > 0 && !(current.length === 1 && current[0] === '')) {
|
||
rows.push(current);
|
||
}
|
||
current = [];
|
||
i++;
|
||
} else {
|
||
field += ch;
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Last field / row */
|
||
if (field !== '' || current.length > 0) {
|
||
current.push(field);
|
||
if (current.length > 0 && !(current.length === 1 && current[0] === '')) {
|
||
rows.push(current);
|
||
}
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|