- Restore persistent verification: wallets.js loadWallets() now preserves isVerified, signature, messageData from localStorage (was reset to false). Only restores verification when messageData is present (anti-tampering). - Add WalletManager.getVerifiedWallets() for EVM-only verification gating. - Remove all hardcoded embedded data (walletCumulData, walletsMetadata, walletCosts, walletBuys, dailySatsData, aaveCumulData, totalCumulData). - Derive all chart data dynamically from API snapshots. - Rewrite calculateAggregatedSeries, calculateCurrentHoldings, setupSatsCard, setupBreakdownCard to source from addressSnapshots. - Replace fetchAaveSnapshots with fetchAllWalletData (verified wallets only). - Replace pollAaveUpdate with pollVerifiedWallets polling loop. - Add inline EIP-712 verification (verifyOwnership, handleVerifyWallet, handleRevokeVerification) with MetaMask integration. - Sidebar shows verified/unverified badges per wallet with verify/revoke buttons. - Non-EVM wallets (btc, solana, bitcoin) auto-verify on add (trust-based). - Update fallback WalletManager class identically with persistent verification.
1572 lines
79 KiB
HTML
1572 lines
79 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>cbBTC Treasury Dashboard</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||
<script type="module">
|
||
import { WalletManager } from './wallets.js';
|
||
window.WalletManager = WalletManager;
|
||
</script>
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background-color: #05070B; }
|
||
|
||
/* Sidebar */
|
||
.sidebar-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
|
||
.sidebar-overlay.open { opacity: 1; pointer-events: auto; }
|
||
.sidebar-panel { position: fixed; top: 0; right: 0; width: 320px; height: 100vh; background: #090D14; border-left: 1px solid #1A1F2C; z-index: 50; transform: translateX(100%); transition: transform 0.3s; overflow-y: auto; }
|
||
.sidebar-panel.open { transform: translateX(0); }
|
||
|
||
/* Modal */
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 60; display: none; align-items: center; justify-content: center; }
|
||
.modal-overlay.open { display: flex; }
|
||
.modal-box { background: #090D14; border: 1px solid #1A1F2C; border-radius: 16px; width: 400px; max-width: 90vw; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
|
||
|
||
.apexcharts-tooltip { background: #111827 !important; border: 1px solid #1F2937 !important; border-radius: 12px !important; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5) !important; padding: 10px 16px !important; color: white !important; }
|
||
.apexcharts-xaxistooltip { display: none !important; }
|
||
.apexcharts-marker { stroke-width: 3px !important; fill: #111827 !important; }
|
||
|
||
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; }
|
||
.badge-buy { background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(31, 111, 235, 0.3); }
|
||
.badge-sell { background: rgba(248, 81, 73, 0.2); color: #ff7b72; border: 1px solid rgba(248, 81, 73, 0.3); }
|
||
.badge-aavein { background: rgba(137, 87, 229, 0.2); color: #bc8cff; border: 1px solid rgba(137, 87, 229, 0.3); }
|
||
.badge-aaveout { background: rgba(210, 153, 34, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.3); }
|
||
|
||
.filter-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; width: fit-content; }
|
||
.filter-item { display: flex; align-items: center; padding: 4px; background: rgba(255, 255, 255, 0.03); border-radius: 4px; transition: all 0.2s; cursor: pointer; }
|
||
.filter-item:hover { background: rgba(255, 255, 255, 0.08); }
|
||
.filter-checkbox { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
|
||
.filter-checkbox:checked { border-color: transparent; }
|
||
.filter-checkbox.buy:checked { background: #58a6ff; box-shadow: 0 0 8px rgba(88, 166, 255, 0.4); }
|
||
.filter-checkbox.sell:checked { background: #ff7b72; box-shadow: 0 0 8px rgba(255, 123, 114, 0.4); }
|
||
.filter-checkbox.aavein:checked { background: #bc8cff; box-shadow: 0 0 8px rgba(188, 140, 255, 0.4); }
|
||
.filter-checkbox.aaveout:checked { background: #d29922; box-shadow: 0 0 8px rgba(210, 153, 34, 0.4); }
|
||
|
||
.wallet-filter-toggle { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
|
||
.wallet-filter-toggle:checked { background: var(--wallet-color); border-color: transparent; box-shadow: 0 0 8px color-mix(in srgb, var(--wallet-color) 40%, transparent); }
|
||
|
||
.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; }
|
||
</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]">
|
||
<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>
|
||
</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="0x..." 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 font-mono" required>
|
||
</div>
|
||
<div class="mb-6">
|
||
<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 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>
|
||
|
||
<!-- 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;">30D AVG</span>
|
||
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Daily Acquisition</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 }));
|
||
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 };
|
||
}
|
||
revokeVerification(address, chain) {
|
||
const w = this.findWallet(address, chain);
|
||
if (!w) return { success: false, error: 'Wallet not found' };
|
||
w.isVerified = false;
|
||
w.signature = null;
|
||
w.messageData = null;
|
||
this._persist();
|
||
return { success: true };
|
||
}
|
||
};
|
||
}
|
||
})();
|
||
|
||
/* ===================================================================
|
||
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"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
|
||
|
||
/* ===================================================================
|
||
App State
|
||
=================================================================== */
|
||
const wm = new WalletManager();
|
||
wm.loadWallets();
|
||
|
||
let currentAggregatedSeries = [];
|
||
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 = {};
|
||
|
||
/* Assigned color index for dynamic wallets */
|
||
let _colorIdx = 0;
|
||
function getColorForWallet(address) {
|
||
const c = localStorage.getItem('cbbtc_color_' + address);
|
||
if (c) return c;
|
||
const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length];
|
||
_colorIdx++;
|
||
localStorage.setItem('cbbtc_color_' + address, color);
|
||
return color;
|
||
}
|
||
|
||
function setColorForWallet(address, color) {
|
||
localStorage.setItem('cbbtc_color_' + address, color);
|
||
renderAll();
|
||
}
|
||
|
||
function cycleWalletColor(address) {
|
||
const next = getNextColor(address);
|
||
setColorForWallet(address, next);
|
||
}
|
||
|
||
function getNextColor(address) {
|
||
const current = getColorForWallet(address);
|
||
const idx = WALLET_COLORS.indexOf(current);
|
||
const nextIdx = (idx + 1) % WALLET_COLORS.length;
|
||
return WALLET_COLORS[nextIdx];
|
||
}
|
||
|
||
/* Color Picker */
|
||
let _colorPickerTargetAddr = null;
|
||
|
||
function openColorPicker(event, address) {
|
||
event.stopPropagation();
|
||
_colorPickerTargetAddr = address;
|
||
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);
|
||
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();
|
||
selectColor(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 addresses. Empty = all checked. */
|
||
function getWalletFilter() {
|
||
return JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]');
|
||
}
|
||
function isWalletInLedger(address) {
|
||
return !getWalletFilter().includes(address); /* unchecked not in list => checked */
|
||
}
|
||
|
||
function setWalletChecked(address, checked) {
|
||
let unchecked = getWalletFilter();
|
||
|
||
if (!checked) {
|
||
if (!unchecked.includes(address)) unchecked.push(address);
|
||
} else {
|
||
const idx = unchecked.indexOf(address);
|
||
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(address) {
|
||
setWalletChecked(address, !isWalletInLedger(address));
|
||
}
|
||
|
||
function syncWalletCheckboxes(address, checked) {
|
||
document.querySelectorAll(`.wallet-filter-toggle[data-address="${address}"]`).forEach(cb => {
|
||
cb.checked = checked;
|
||
});
|
||
}
|
||
|
||
function syncAllWalletCheckboxes() {
|
||
wm.getWallets().forEach(w => {
|
||
const checked = isWalletInLedger(w.address);
|
||
document.querySelectorAll(`.wallet-filter-toggle[data-address="${w.address}"]`).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);
|
||
const shortAddr = w.address.slice(0, 6) + '...' + w.address.slice(-4);
|
||
const syncState = walletSyncState[w.address];
|
||
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>'
|
||
: '';
|
||
const revokeBtn = isVerified && isEVM
|
||
? '<button onclick="handleRevokeVerification(\'' + w.address + '\',\'' + w.chain + '\')" class="text-[10px] bg-red-500/20 hover:bg-red-500/40 text-red-400 font-bold px-2 py-0.5 rounded transition" title="Revoke verification">Revoke</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 + revokeBtn + 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
|
||
=================================================================== */
|
||
function openAddWalletModal() {
|
||
document.getElementById('add-wallet-modal').classList.add('open');
|
||
document.getElementById('add-wallet-error').classList.add('hidden');
|
||
document.getElementById('input-address').value = '';
|
||
document.getElementById('input-nickname').value = '';
|
||
}
|
||
|
||
function closeAddWalletModal() {
|
||
document.getElementById('add-wallet-modal').classList.remove('open');
|
||
}
|
||
|
||
async function handleAddWallet(e) {
|
||
e.preventDefault();
|
||
const address = document.getElementById('input-address').value.trim().toLowerCase();
|
||
const nickname = document.getElementById('input-nickname').value.trim();
|
||
const errEl = document.getElementById('add-wallet-error');
|
||
|
||
if (!address) { errEl.textContent = 'Address is required'; errEl.classList.remove('hidden'); return false; }
|
||
|
||
/* Detect chain from address format */
|
||
let chain = 'base';
|
||
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);
|
||
// if (!isEVM && !isBTC) { errEl.textContent = 'Invalid address format'; errEl.classList.remove('hidden'); return false; }
|
||
|
||
if (!isEVM && !isBTC) chain = 'btc';
|
||
|
||
/* Auto-verify non-EVM wallets */
|
||
if (!isEVM && isBTC) {
|
||
const addResult = wm.addWallet(address, 'btc', nickname);
|
||
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; }
|
||
wm.verifyWallet(address, 'btc', '', { action: 'Auto-verified (non-verification chain)', walletAddress: address });
|
||
walletSyncState[address] = 'synced';
|
||
closeAddWalletModal();
|
||
renderAll();
|
||
return false;
|
||
}
|
||
|
||
/* Register EVM wallet */
|
||
const addResult = wm.addWallet(address, 'base', nickname);
|
||
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; }
|
||
|
||
/* Attempt EIP-712 verification if MetaMask is available */
|
||
const verifyResult = await verifyOwnership(address, nickname);
|
||
if (!verifyResult.success) {
|
||
console.log("Verification not performed:", verifyResult.error);
|
||
/* Wallet saved but unverified — user can verify from sidebar */
|
||
}
|
||
|
||
/* POST to FastAPI backend to start monitoring */
|
||
try {
|
||
const resp = await fetch('http://localhost:8000/api/v1/portfolio/monitor', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ address: address, chain: 'base', chain_id: 8453 })
|
||
});
|
||
if (!resp.ok) {
|
||
console.log(`Backend returned ${resp.status}`);
|
||
}
|
||
} catch (err) {
|
||
console.error('Backend unavailable', err);
|
||
}
|
||
|
||
/* Mark as syncing initially */
|
||
walletSyncState[address] = 'syncing';
|
||
|
||
closeAddWalletModal();
|
||
renderSidebar();
|
||
renderWalletPills();
|
||
renderLedgerFilterWallets();
|
||
|
||
/* Kick off a fetch for this new wallet */
|
||
fetchWalletAaveData(address);
|
||
updateDashboard();
|
||
return false;
|
||
}
|
||
|
||
/* ===================================================================
|
||
EIP-712 Verification
|
||
=================================================================== */
|
||
const EIP712_DOMAIN = { name: "Anonymous Wallet Tracker", version: "1", chainId: 1 };
|
||
const VERIFY_TYPES = {
|
||
VerifyTracking: [
|
||
{ name: 'action', type: 'string' },
|
||
{ name: 'walletAddress', type: 'address' },
|
||
{ name: 'timestamp', type: 'uint256' }
|
||
]
|
||
};
|
||
|
||
async function verifyOwnership(address, 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 typedData = {
|
||
domain: EIP712_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, 'base', sig, typedData.message);
|
||
wm.renameWallet(address, 'base', 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, nickname || w.nickname);
|
||
if (result.success) {
|
||
/* Fetch data now that wallet is verified */
|
||
if (chain === 'base') fetchWalletAaveData(address);
|
||
} else {
|
||
alert('Verification failed: ' + result.error);
|
||
}
|
||
renderAll();
|
||
}
|
||
|
||
function handleRevokeVerification(address, chain) {
|
||
wm.revokeVerification(address, chain);
|
||
renderAll();
|
||
}
|
||
|
||
/* ===================================================================
|
||
Delete Wallet
|
||
=================================================================== */
|
||
function handleDeleteWallet(address, chain, idx) {
|
||
wm.removeWallet(address, chain);
|
||
delete walletSyncState[address];
|
||
delete addressSnapshots[address];
|
||
/* Remove from unchecked list */
|
||
let unchecked = getWalletFilter();
|
||
const ui = unchecked.indexOf(address);
|
||
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 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);
|
||
const syncState = walletSyncState[w.address];
|
||
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);
|
||
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-address="' + w.address + '">' +
|
||
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-address="' + w.address + '" ' + (isActive ? 'checked' : '') + ' onchange="event.stopPropagation();toggleWalletPill(\'' + w.address + '\')">' +
|
||
'<div class="flex flex-col">' +
|
||
'<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider leading-tight">' + nick + syncIndicator + '</span>' +
|
||
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
|
||
'</div>' +
|
||
'</label>';
|
||
});
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function toggleWalletPill(address) {
|
||
toggleWalletFilter(address);
|
||
renderLedgerFilterWallets();
|
||
}
|
||
|
||
function getSelectedAddresses() {
|
||
const unchecked = getWalletFilter();
|
||
const wallets = wm.getWallets();
|
||
if (unchecked.length === 0) return wallets.map(w => w.address);
|
||
return wallets.filter(w => !unchecked.includes(w.address)).map(w => w.address);
|
||
}
|
||
|
||
function syncWalletCheckboxes(address, checked) {
|
||
document.querySelectorAll(`.wallet-filter-toggle[data-address="${address}"]`).forEach(cb => {
|
||
cb.checked = checked;
|
||
});
|
||
}
|
||
|
||
/* ===================================================================
|
||
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);
|
||
const checked = isWalletInLedger(w.address);
|
||
html += `<label class="filter-item" title="Show ${w.nickname || w.address}">
|
||
<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ${color};" data-address="${w.address}" ${checked ? 'checked' : ''}>
|
||
</label>`;
|
||
});
|
||
container.innerHTML = html;
|
||
|
||
container.querySelectorAll('.wallet-filter-toggle').forEach(cb => {
|
||
cb.addEventListener('change', () => {
|
||
setWalletChecked(cb.dataset.address, cb.checked);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ===================================================================
|
||
Dynamic Data Fetching (Promise.all aggregation)
|
||
=================================================================== */
|
||
async function fetchWalletAaveData(address) {
|
||
const endpoint = `/api/v1/portfolio/${address}/avve`;
|
||
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[address] = 'syncing';
|
||
renderWalletPills();
|
||
return;
|
||
}
|
||
}
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const events = await resp.json();
|
||
walletSyncState[address] = 'synced';
|
||
addressSnapshots[address] = snapshotsToDaily(events);
|
||
renderWalletPills();
|
||
} catch (err) {
|
||
console.error(`Failed to fetch data for ${address}:`, err);
|
||
walletSyncState[address] = 'pending';
|
||
renderWalletPills();
|
||
}
|
||
}
|
||
|
||
async function fetchSelectedWalletsData() {
|
||
const selectedAddr = getSelectedAddresses();
|
||
if (selectedAddr.length === 0) return;
|
||
|
||
/* Only fetch for wallets that are synced or syncing */
|
||
const promises = selectedAddr.map(async (addr) => {
|
||
const sync = walletSyncState[addr];
|
||
if (sync === 'syncing') return null; /* Will retry later */
|
||
if (sync === 'pending') return null;
|
||
if (sync === 'synced' && addressSnapshots[addr]) return null; /* Cached */
|
||
await fetchWalletAaveData(addr);
|
||
});
|
||
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 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 selectedAddr = getSelectedAddresses();
|
||
if (selectedAddr.length === 0) return Date.now() - 365 * 86400000;
|
||
let oldestTs = Infinity;
|
||
selectedAddr.forEach(addr => {
|
||
const snaps = addressSnapshots[addr] || [];
|
||
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 */
|
||
function buildCumulativeSeries(addresses) {
|
||
const dayMap = {};
|
||
addresses.forEach(addr => {
|
||
const snaps = addressSnapshots[addr] || [];
|
||
snaps.forEach(snap => {
|
||
const ts = new Date(snap.block_timestamp).getTime();
|
||
const dateStr = snap.block_timestamp.slice(0, 10);
|
||
const btcAmt = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
|
||
if (!dayMap[ts]) dayMap[ts] = { total: 0, dateStr };
|
||
dayMap[ts].total += btcAmt;
|
||
});
|
||
});
|
||
const sorted = Object.values(dayMap).sort((a, b) => {
|
||
const tsA = new Date(a.dateStr).getTime();
|
||
const tsB = new Date(b.dateStr).getTime();
|
||
return tsA - tsB;
|
||
});
|
||
let cumul = 0;
|
||
return sorted.map(d => {
|
||
cumul += d.total;
|
||
const dateStr = d.dateStr;
|
||
return [new Date(dateStr).getTime(), cumul];
|
||
});
|
||
}
|
||
|
||
function calculateAggregatedSeries() {
|
||
const selectedAddr = getSelectedAddresses();
|
||
currentBuyCost = 0;
|
||
currentBuyAmount = 0;
|
||
|
||
if (selectedAddr.length === 0) {
|
||
currentAggregatedSeries = [];
|
||
return;
|
||
}
|
||
|
||
currentAggregatedSeries = buildCumulativeSeries(selectedAddr);
|
||
}
|
||
|
||
function calculateCurrentHoldings() {
|
||
const selectedAddr = getSelectedAddresses();
|
||
currentNetHeld = 0;
|
||
currentBuyCost = 0;
|
||
currentBuyAmount = 0;
|
||
if (selectedAddr.length === 0) return;
|
||
|
||
/* Sum latest cbBTC balance across selected wallets */
|
||
selectedAddr.forEach(addr => {
|
||
const snaps = addressSnapshots[addr] || [];
|
||
if (snaps.length > 0) {
|
||
const latest = snaps[0];
|
||
currentNetHeld += getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
|
||
currentNetHeld += getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
|
||
}
|
||
});
|
||
|
||
/* Derive buy cost from snapshot deltas: positive cbBTC changes across days */
|
||
selectedAddr.forEach(addr => {
|
||
const snaps = addressSnapshots[addr] || [];
|
||
if (snaps.length < 2) return;
|
||
let prevBtc = 0;
|
||
snaps.forEach(snap => {
|
||
const currentDate = snap.block_timestamp.slice(0, 10);
|
||
const currentBtc = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
|
||
const delta = currentBtc - prevBtc;
|
||
if (delta > 0) {
|
||
const price = priceForToken('cbBTC', currentDate) || 0;
|
||
if (price > 0) currentBuyCost += delta * price;
|
||
currentBuyAmount += delta;
|
||
}
|
||
prevBtc = currentBtc;
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateDashboard() {
|
||
calculateAggregatedSeries();
|
||
calculateCurrentHoldings();
|
||
|
||
if (window.cumulChart) {
|
||
window.cumulChart.updateSeries([{ data: currentAggregatedSeries }]);
|
||
}
|
||
|
||
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 selectedAddr = 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.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 => {
|
||
walletColorMap[w.address] = getColorForWallet(w.address);
|
||
walletNickMap[w.address] = w.nickname || 'Wallet';
|
||
});
|
||
|
||
/* Build unified snapshot list from dynamic sources only */
|
||
const unified = [];
|
||
for (const [addr, snaps] of Object.entries(addressSnapshots)) {
|
||
(snaps || []).forEach(snap => {
|
||
unified.push({ ...snap, _walletAddress: 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: 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: 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: 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));
|
||
await Promise.all(promises);
|
||
const allSnaps = Object.values(addressSnapshots).flat();
|
||
if (allSnaps.length > 0) {
|
||
const oldestTs = new Date(allSnaps[0].block_timestamp).getTime();
|
||
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 } },
|
||
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 filteredData = currentAggregatedSeries.filter(d => d[0] >= cutoff);
|
||
const options = getBaseChartOptions('instance-cumul', filteredData, orangeBrandColor, false);
|
||
const chart = new ApexCharts(document.querySelector("#chart-container-cumul"), options);
|
||
chart.render();
|
||
window.cumulChart = chart;
|
||
}
|
||
|
||
function setupSatsCard(cutoff) {
|
||
/* Derive daily BTC from all verified wallet snapshots */
|
||
const dailyBtc = {};
|
||
const selectedAddr = getSelectedAddresses() || [];
|
||
selectedAddr.forEach(addr => {
|
||
const snaps = addressSnapshots[addr] || [];
|
||
snaps.forEach(snap => {
|
||
const ts = new Date(snap.block_timestamp).getTime();
|
||
const dateStr = snap.block_timestamp.slice(0, 10);
|
||
const btcVal = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
|
||
if (!dailyBtc[ts]) dailyBtc[ts] = { ts, btcVal: 0, dateStr };
|
||
dailyBtc[ts].btcVal += btcVal;
|
||
});
|
||
});
|
||
const dailyEntries = Object.values(dailyBtc).sort((a, b) => a.ts - b.ts);
|
||
const filtered = dailyEntries.filter(d => d.ts >= cutoff);
|
||
const rollingAvg = filtered.map(d => [d.ts, Math.round(d.btcVal * 1e8)]);
|
||
const latestAvg = rollingAvg.length > 0 ? rollingAvg[rollingAvg.length - 1][1] : 0;
|
||
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(Math.round(latestAvg));
|
||
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 } },
|
||
series: [{ name: 'Avg Sats/Day', data: rollingAvg }],
|
||
dataLabels: { enabled: false },
|
||
colors: [orangeBrandColor],
|
||
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 }] } },
|
||
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 } },
|
||
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: 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 sats = rawData[1]; const btcVal = (sats / 1e8).toFixed(6); const priceStr = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(todayBtcPrice) : '—'; const dollarDay = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format((sats / 1e8) * todayBtcPrice) : '—'; 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="text-orange-400 text-xs font-bold">₿ ' + btcVal + '/day</div><div class="text-white text-sm font-medium mt-1">' + dollarDay + '</div></div>'; } },
|
||
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 dayMap = {};
|
||
const selectedAddr = getSelectedAddresses() || [];
|
||
selectedAddr.forEach(addr => {
|
||
const snaps = addressSnapshots[addr] || [];
|
||
snaps.forEach(snap => {
|
||
const ts = new Date(snap.block_timestamp).getTime();
|
||
if (!dayMap[ts]) dayMap[ts] = { cold: 0, collateral: 0 };
|
||
dayMap[ts].cold += getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC');
|
||
dayMap[ts].collateral += getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
|
||
});
|
||
});
|
||
const entries = Object.keys(dayMap).map(ts => parseInt(ts)).sort((a, b) => a - b);
|
||
let c0 = 0, c1 = 0;
|
||
const totalSeries = entries.map(ts => {
|
||
c0 += dayMap[ts].cold + dayMap[ts].collateral;
|
||
return [ts, c0];
|
||
}).filter(d => d[0] >= cutoff);
|
||
let c2 = 0;
|
||
const aaveSeries = entries.map(ts => {
|
||
c2 += dayMap[ts].collateral;
|
||
return [ts, c2];
|
||
}).filter(d => d[0] >= cutoff);
|
||
|
||
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 } },
|
||
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);
|
||
}
|
||
|
||
/* ===================================================================
|
||
Global Event Listeners
|
||
=================================================================== */
|
||
document.querySelectorAll('.filter-checkbox').forEach(cb => {
|
||
cb.addEventListener('change', () => updateDashboard());
|
||
});
|
||
|
||
/* ===================================================================
|
||
Sync Polling
|
||
=================================================================== */
|
||
async function pollSyncStatus() {
|
||
const selectedAddr = getSelectedAddresses();
|
||
const syncing = selectedAddr.filter(a => walletSyncState[a] === 'syncing');
|
||
if (syncing.length === 0) return;
|
||
const promises = syncing.map(async (addr) => {
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${addr}/avve`);
|
||
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[addr] = 'syncing';
|
||
} else {
|
||
walletSyncState[addr] = 'synced';
|
||
}
|
||
return;
|
||
}
|
||
if (!resp.ok) return;
|
||
const events = await resp.json();
|
||
walletSyncState[addr] = 'synced';
|
||
addressSnapshots[addr] = snapshotsToDaily(events);
|
||
renderCombinedTable();
|
||
updateDashboard();
|
||
} catch (err) {
|
||
console.error('Sync poll failed for ' + addr, err);
|
||
}
|
||
});
|
||
await Promise.all(promises);
|
||
renderWalletPills();
|
||
}
|
||
|
||
function startSyncPolling() {
|
||
setInterval(() => pollSyncStatus(), 15000);
|
||
}
|
||
|
||
/* ===================================================================
|
||
Polling for Verified Wallets
|
||
=================================================================== */
|
||
async function pollVerifiedWallets() {
|
||
const now = Date.now();
|
||
if (now - lastFetchMs < 30000) return;
|
||
lastFetchMs = now;
|
||
const verified = wm.getVerifiedWallets() || [];
|
||
for (const w of verified) {
|
||
try {
|
||
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/base/aave`);
|
||
if (!resp.ok) continue;
|
||
const events = await resp.json();
|
||
const newestTs = events[events.length - 1].block_timestamp;
|
||
const existing = addressSnapshots[w.address];
|
||
if (existing && existing.length > 0) {
|
||
if (newestTs > existing[0].block_timestamp) {
|
||
addressSnapshots[w.address] = snapshotsToDaily(events);
|
||
walletSyncState[w.address] = 'synced';
|
||
}
|
||
} else {
|
||
addressSnapshots[w.address] = snapshotsToDaily(events);
|
||
walletSyncState[w.address] = 'synced';
|
||
}
|
||
} catch (err) {
|
||
console.error("Poll update failed for " + w.address, err);
|
||
}
|
||
}
|
||
const anyUpdated = Object.keys(addressSnapshots).length > 0;
|
||
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 */
|
||
await fetchAllWalletData();
|
||
|
||
await refreshPrices();
|
||
renderCombinedTable();
|
||
|
||
const cutoff = getOldestTransactionDate();
|
||
currentCutoffMs = cutoff;
|
||
refreshAllCharts(cutoff);
|
||
|
||
startSyncPolling();
|
||
startPolling();
|
||
} catch (error) {
|
||
console.error("Dashboard error:", error);
|
||
}
|
||
}
|
||
|
||
initDashboardGrid();
|
||
</script>
|
||
</body>
|
||
</html>
|