Files
dione/index.html

1723 lines
86 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); }
.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="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-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>
<!-- 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 }));
/* 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 };
}
};
}
})();
/* ===================================================================
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>'
: '';
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;
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();
renderAddWalletColorPicker();
window.ethereum.request({ method: 'eth_chainId' })
.then(chainIdHex => {
const chainName = CHAIN_IDS_REVERSED[chainIdHex] || 'base';
document.getElementById('input-chain-display').value = chainName.charAt(0).toUpperCase() + chainName.slice(1);
document.getElementById('input-chain-hidden').value = chainName;
});
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));
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);
});
}
/* Reverse map: chainId → chain name */
const CHAIN_IDS_REVERSED = { '1': 'ethereum', '8453': 'base', '42161': 'arbitrum', '10': 'optimism', '137': 'polygon', '43114': 'avalanche', '56': 'bsc', '250': 'fantom', '998': 'hyperliquide' };
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(chainIdHex => {
const chainName = CHAIN_IDS_REVERSED[chainIdHex] || 'base';
document.getElementById('input-chain-display').value = chainName.charAt(0).toUpperCase() + chainName.slice(1);
document.getElementById('input-chain-hidden').value = chainName;
});
/* Auto-assign a color not used by existing wallets */
_addWalletSelectedColor = autoPickColor();
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 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' });
chain = CHAIN_IDS_REVERSED[chainIdHex] || 'base';
document.getElementById('input-chain-hidden').value = chain;
document.getElementById('input-chain-display').value = chain.charAt(0).toUpperCase() + chain.slice(1);
_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);
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[address] = 'synced';
closeAddWalletModal();
closeSidebar();
renderAll();
return false;
}
/* Register EVM wallet first so verifyOwnership can find it */
const addResult = wm.addWallet(address, chain, nickname);
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[address] = 'syncing';
await _pollUntilSynced(address);
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, hyperliquide: 998 };
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 */
if (chain === 'base') fetchWalletAaveData(address);
} else {
/* Remove unverified wallet on failure */
wm.removeWallet(address, chain);
alert('Verification failed: ' + result.error);
}
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}/base/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[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();
}
}
/* Poll until wallet data is synchronized, up to ~60s */
async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${address}/base/aave`);
if (resp.ok) {
const events = await resp.json();
if (Array.isArray(events) && events.length > 0) {
walletSyncState[address] = 'synced';
addressSnapshots[address] = 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[address] = 'syncing';
renderWalletPills();
return false;
}
/* Fetch price history for the new wallet's data range, then render */
async function _fetchPricesAndRender() {
const selectedAddr = getSelectedAddresses();
if (selectedAddr.length === 0) {
renderCombinedTable();
return;
}
/* Find oldest transaction to determine price range */
let oldestTs = Date.now();
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
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 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}/base/aave`);
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>