Files
dione/index.html
Dione d27efd4b90 Fix wallet pill filters and drag-and-drop for multi-chain wallets
- Fix ReferenceError: _dragAddr → _dragKey in pill checkbox onchange handler,
  restoring the ability to toggle wallet filters in the nav-bar
- Replace toggleWalletPill with setWalletChecked(key, this.checked) so pill
  checkboxes directly update filter state, sync all checkboxes, and refresh
  charts (matching the ledger-header checkbox behavior)
- Drag-and-drop reorder: switch from plain addresses to compound keys
  ("addr:chain") in dropWalletReorder and setWalletOrder, fixing clones
  when dragging wallets that share an address on different chains
- Update inline fallback WalletManager with the same compound-key logic
2026-06-15 06:27:57 +00:00

2691 lines
129 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 CSV
</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>
<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 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-1">Wallet Address</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 transition font-mono">
</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">Uploading...</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 CSV</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>
<!-- 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"}};
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 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 */
if (['btc', 'bitcoin', 'solana'].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
=================================================================== */
function handleDeleteWallet(address, chain, idx) {
const key = walletKey(address, chain);
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);
const endpoint = `/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';
addressSnapshots[key] = snapshotsToDaily(events);
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';
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) {
const dec = TOKENS[symbol] ? TOKENS[symbol].decimals : 18;
return parseFloat(raw || '0') / Math.pow(10, dec);
}
function getTotalBTC(obj) {
let total = 0;
if (!obj) return 0;
for (const sym of Object.keys(TOKENS)) {
if (TOKENS[sym].priceSymbol === 'BTC') total += getTokenAmount(obj[sym], sym);
}
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 snaps = addressSnapshots[key] || [];
walletDated[key] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
btcAmt: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
})).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;
if (selectedWallets.length === 0) return;
/* Sum latest cbBTC balance across selected wallets */
selectedWallets.forEach(w => {
const key = walletKey(w.address, w.chain);
const snaps = addressSnapshots[key] || [];
if (snaps.length > 0) {
const latest = snaps[0];
currentNetHeld += getTotalBTC(latest?.wallet);
currentNetHeld += getTotalBTC(latest?.collateral);
}
});
/* Derive buy cost from snapshot deltas: positive BTC changes, oldest first */
selectedWallets.forEach(w => {
const key = walletKey(w.address, w.chain);
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) + getTotalBTC(snap?.collateral);
const delta = currentBtc - prevBtc;
if (delta > 0) {
const price = priceForToken('BTC', currentDate) || 0;
if (price > 0) currentBuyCost += delta * price;
currentBuyAmount += delta;
}
prevBtc = currentBtc;
});
});
}
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);
}
/* 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] = key.split(':');
(snaps || []).forEach(snap => {
unified.push({ ...snap, _walletAddress: key, _walletAddr: addr });
});
}
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);
/* Cold storage */
const coldItems = [];
let coldTotal = 0;
Object.entries(snap.wallet || {}).forEach(([sym, raw]) => {
const amt = getTokenAmount(raw, sym);
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);
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);
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 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 snaps = addressSnapshots[key] || [];
const dated = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
btcVal: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
})).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 snaps = addressSnapshots[key] || [];
walletDated[key] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
cold: getTotalBTC(s?.wallet),
collateral: getTotalBTC(s?.collateral)
})).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 resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/${w.chain}/aave`);
if (!resp.ok) continue;
const events = await resp.json();
if (events.length === 0) continue;
const newestTs = events.reduce((m, e) => (e.block_timestamp > m ? e.block_timestamp : m), events[0].block_timestamp);
const existing = addressSnapshots[key];
if (existing && existing.length > 0) {
if (newestTs > existing[0].block_timestamp) {
addressSnapshots[key] = snapshotsToDaily(events);
walletSyncState[key] = 'synced';
anyUpdated = true;
}
} else {
addressSnapshots[key] = snapshotsToDaily(events);
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 = '';
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 CSV';
renderImportColorPicker();
document.getElementById('import-csv-modal').classList.add('open');
}
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 */
async function handleImportCsv(e) {
e.preventDefault();
const nickname = document.getElementById('import-nickname').value.trim();
const addressInput = document.getElementById('import-address').value.trim();
const chain = 'base';
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;
}
if (!_importCsvFile) {
errEl.textContent = 'No CSV file selected.';
errEl.classList.remove('hidden');
return false;
}
errEl.classList.add('hidden');
progressEl.classList.remove('hidden');
submitBtn.disabled = true;
submitText.textContent = 'Processing...';
try {
/* Parse CSV */
const records = await parseCsvOnSelect(_importCsvFile);
_csvImportRecords = records;
if (records.length === 0) {
errEl.textContent = 'No valid records found in the CSV file.';
errEl.classList.remove('hidden');
progressEl.classList.add('hidden');
submitBtn.disabled = false;
submitText.textContent = 'Import CSV';
return false;
}
/* Resolve address: use input if valid, otherwise generate */
let address = addressInput.toLowerCase();
let addressWasAutoGenerated = false;
if (address && /^(0x)?[0-9a-f]{40}$/i.test(address)) {
if (!address.startsWith('0x')) address = '0x' + address;
/* Check duplicate */
const existing = wm.findWallet(address, chain);
if (existing) {
errEl.textContent = `Wallet ${address} already tracked on ${chain}.`;
errEl.classList.remove('hidden');
progressEl.classList.add('hidden');
submitBtn.disabled = false;
submitText.textContent = 'Import CSV';
return false;
}
} else {
/* Generate a synthetic address from nickname hash */
let hashArr;
if (crypto?.subtle) {
const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(nickname + Date.now()));
hashArr = new Uint8Array(hashBuf);
} else {
/* Fallback for non-secure contexts where crypto.subtle is unavailable */
let s = (nickname + Date.now()).split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0);
hashArr = new Uint8Array(32);
for (let i = 0; i < 32; i++) { s = ((s ^ s >> 15) * 2654435761) | 0; hashArr[i] = s % 256; }
}
address = '0x' + Array.from(hashArr.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join('');
document.getElementById('import-address').value = address;
addressWasAutoGenerated = true;
}
submitText.textContent = 'Uploading...';
/* Register wallet in state without verification (non-EVM flow) */
wm.addWallet(address, chain, nickname);
wm.verifyWallet(address, chain, '', { action: 'CSV Import (no-signature)', walletAddress: address });
/* POST to backend */
const payload = { records: records.map(r => ({ ...r })) };
const resp = await fetch(`${API_BASE}/api/v1/portfolio/import/${chain}/${address}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
const errBody = await resp.json().catch(() => ({}));
throw new Error(errBody?.detail || `HTTP ${resp.status}`);
}
const result = await resp.json();
const inserted = result.inserted || 0;
const skipped = result.skipped || 0;
/* Update frontend state */
const key = walletKey(address, chain);
if (result.ledger && Array.isArray(result.ledger)) {
addressSnapshots[key] = snapshotsToDaily(result.ledger);
} else {
addressSnapshots[key] = snapshotsToDaily(records.map(r => ({
block_timestamp: r.block_timestamp,
wallet: { [r.token_address]: r.amount },
collateral: {},
debt: {},
tx_hash: r.tx_hash || null,
})));
}
walletSyncState[key] = 'synced';
/* Assign color and mark synced */
setColorForWallet(address, chain, color);
walletSyncState[key] = 'synced';
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 CSV';
}
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>