Fix wallet pill filters and drag-and-drop for multi-chain wallets

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

View File

@ -477,19 +477,20 @@ window.WalletManager = WalletManager;
this._persist(); this._persist();
return { success: true }; return { success: true };
} }
setWalletOrder(addresses) { setWalletOrder(keys) {
const addrSet = this._wallets.map(w => w.address); const keySet = this._wallets.map(w => w.address + ':' + w.chain);
const ordered = []; const ordered = [];
for (const addr of addresses) { for (const k of keys) {
if (addrSet.includes(addr)) { if (keySet.includes(k)) {
const w = this._wallets.find(x => x.address === addr); const [addr, chain] = k.split(':');
if (w) ordered.push(w); const w = this._wallets.find(x => x.address === addr && x.chain === chain);
} if (w) ordered.push(w);
} }
const remaining = this._wallets.filter(w => !ordered.includes(w)); }
this._wallets = [...ordered, ...remaining]; const remaining = this._wallets.filter(w => !ordered.includes(w));
this._persist(); this._wallets = [...ordered, ...remaining];
} this._persist();
}
}; };
} }
})(); })();
@ -501,7 +502,12 @@ const orangeBrandColor = '#FF7A00';
const blueBrandColor = '#3b82f6'; const blueBrandColor = '#3b82f6';
const API_BASE = window.location.origin; 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 WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF'];
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}}; const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}, "USDCn": {"decimals": 6, "priceSymbol": "USDC"}, "wHYPE": {"decimals": 18, "priceSymbol": "HYPE"}};
function symbolDisplay(s) { return s === 'USDCn' ? 'USDC' : s; }
/* Compound key for wallet identity: "{address}:{chain}" */
function walletKey(address, chain) { return address + ':' + chain; }
function walletKeyFromAddress(address) { return walletKey(address, 'base'); }
/* =================================================================== /* ===================================================================
App State App State
@ -536,38 +542,39 @@ let _csvImportRecords = [];
/* Assigned color index for dynamic wallets */ /* Assigned color index for dynamic wallets */
let _colorIdx = 0; let _colorIdx = 0;
function getColorForWallet(address) { function getColorForWallet(address, chain) {
const c = localStorage.getItem('cbbtc_color_' + address); const key = walletKey(address, chain || 'base');
const c = localStorage.getItem('cbbtc_color_' + key);
if (c) return c; if (c) return c;
const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length]; const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length];
_colorIdx++; _colorIdx++;
localStorage.setItem('cbbtc_color_' + address, color); localStorage.setItem('cbbtc_color_' + key, color);
return color; return color;
} }
function setColorForWallet(address, color) { function setColorForWallet(address, chain, color) {
localStorage.setItem('cbbtc_color_' + address, color); localStorage.setItem('cbbtc_color_' + walletKey(address, chain || 'base'), color);
renderAll(); renderAll();
} }
function cycleWalletColor(address) { function cycleWalletColor(address, chain) {
const next = getNextColor(address); const next = getNextColor(address, chain);
setColorForWallet(address, next); setColorForWallet(address, chain, next);
} }
function getNextColor(address) { function getNextColor(address, chain) {
const current = getColorForWallet(address); const current = getColorForWallet(address, chain);
const idx = WALLET_COLORS.indexOf(current); const idx = WALLET_COLORS.indexOf(current);
const nextIdx = (idx + 1) % WALLET_COLORS.length; const nextIdx = (idx + 1) % WALLET_COLORS.length;
return WALLET_COLORS[nextIdx]; return WALLET_COLORS[nextIdx];
} }
/* Color Picker */ /* Color Picker */
let _colorPickerTargetAddr = null; let _colorPickerTarget = null; /* {address, chain} */
function openColorPicker(event, address) { function openColorPicker(event, address, chain) {
event.stopPropagation(); event.stopPropagation();
_colorPickerTargetAddr = address; _colorPickerTarget = { address, chain };
const overlay = document.getElementById('color-picker-overlay'); const overlay = document.getElementById('color-picker-overlay');
const popup = document.getElementById('color-picker-popup'); const popup = document.getElementById('color-picker-popup');
@ -578,7 +585,7 @@ function openColorPicker(event, address) {
newPopup.className = 'color-picker-popup'; newPopup.className = 'color-picker-popup';
newPopup.style.left = event.clientX + 'px'; newPopup.style.left = event.clientX + 'px';
newPopup.style.top = event.clientY + 'px'; newPopup.style.top = event.clientY + 'px';
const currentColor = getColorForWallet(address); const currentColor = getColorForWallet(address, chain);
WALLET_COLORS.forEach((c, i) => { WALLET_COLORS.forEach((c, i) => {
const swatch = document.createElement('div'); const swatch = document.createElement('div');
swatch.className = 'color-picker-swatch' + (c.toLowerCase() === currentColor.toLowerCase() ? ' selected' : ''); swatch.className = 'color-picker-swatch' + (c.toLowerCase() === currentColor.toLowerCase() ? ' selected' : '');
@ -586,7 +593,7 @@ function openColorPicker(event, address) {
swatch.style.background = c; swatch.style.background = c;
swatch.addEventListener('click', (e) => { swatch.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
selectColor(c); if (_colorPickerTarget) setColorForWallet(_colorPickerTarget.address, _colorPickerTarget.chain, c);
closeColorPicker(); closeColorPicker();
}); });
newPopup.appendChild(swatch); newPopup.appendChild(swatch);
@ -611,21 +618,25 @@ function selectColor(color) {
} }
} }
/* Wallet filter state — stores UNCHECKED addresses. Empty = all checked. */ /* Wallet filter state — stores UNCHECKED compound keys. Empty = all checked. */
function getWalletFilter() { function getWalletFilter() {
return JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]'); const raw = JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]');
/* Migrate old plain-address entries to compound keys */
return raw.filter(k => k.includes(':')).concat(
raw.filter(k => !k.includes(':')).map(k => walletKeyFromAddress(k))
);
} }
function isWalletInLedger(address) { function isWalletInLedger(address, chain) {
return !getWalletFilter().includes(address); /* unchecked not in list => checked */ return !getWalletFilter().includes(walletKey(address, chain || 'base'));
} }
function setWalletChecked(address, checked) { function setWalletChecked(key, checked) {
let unchecked = getWalletFilter(); let unchecked = getWalletFilter();
if (!checked) { if (!checked) {
if (!unchecked.includes(address)) unchecked.push(address); if (!unchecked.includes(key)) unchecked.push(key);
} else { } else {
const idx = unchecked.indexOf(address); const idx = unchecked.indexOf(key);
if (idx >= 0) unchecked.splice(idx, 1); if (idx >= 0) unchecked.splice(idx, 1);
} }
@ -639,20 +650,21 @@ function setWalletChecked(address, checked) {
updateDashboard(); updateDashboard();
} }
function toggleWalletFilter(address) { function toggleWalletFilter(key) {
setWalletChecked(address, !isWalletInLedger(address)); setWalletChecked(key, !getWalletFilter().includes(key));
} }
function syncWalletCheckboxes(address, checked) { function syncWalletCheckboxes(key, checked) {
document.querySelectorAll(`.wallet-filter-toggle[data-address="${address}"]`).forEach(cb => { document.querySelectorAll(`.wallet-filter-toggle[data-key="${key}"]`).forEach(cb => {
cb.checked = checked; cb.checked = checked;
}); });
} }
function syncAllWalletCheckboxes() { function syncAllWalletCheckboxes() {
wm.getWallets().forEach(w => { wm.getWallets().forEach(w => {
const checked = isWalletInLedger(w.address); const key = walletKey(w.address, w.chain);
document.querySelectorAll(`.wallet-filter-toggle[data-address="${w.address}"]`).forEach(cb => { const checked = isWalletInLedger(w.address, w.chain);
document.querySelectorAll(`.wallet-filter-toggle[data-key="${key}"]`).forEach(cb => {
cb.checked = checked; cb.checked = checked;
}); });
}); });
@ -688,9 +700,9 @@ function renderSidebar() {
} }
let html = ''; let html = '';
wallets.forEach((w, i) => { wallets.forEach((w, i) => {
const color = getColorForWallet(w.address); const color = getColorForWallet(w.address, w.chain);
const shortAddr = w.address.slice(0, 6) + '...' + w.address.slice(-4); const shortAddr = w.address.slice(0, 6) + '...' + w.address.slice(-4);
const syncState = walletSyncState[w.address]; const syncState = walletSyncState[walletKey(w.address, w.chain)];
const syncBadge = syncState === 'syncing' || syncState === 'pending' const syncBadge = syncState === 'syncing' || syncState === 'pending'
? `<span class="text-yellow-400 text-[10px] ml-2">⏳ Syncing</span>` ? `<span class="text-yellow-400 text-[10px] ml-2">⏳ Syncing</span>`
: ''; : '';
@ -737,7 +749,7 @@ function handleRenameNickname(input) {
=================================================================== */ =================================================================== */
let _addWalletSelectedColor = null; let _addWalletSelectedColor = null;
let _addWalletSelectedPlatform = 'aave'; let _addWalletSelectedPlatform = 'aave';
const HYPERLIQUIDE_CHAINS = ['hyperliquide']; const HYPEREVM_CHAINS = ['hyperevm'];
function openAddWalletModal() { function openAddWalletModal() {
if (!window.ethereum) { if (!window.ethereum) {
@ -776,7 +788,7 @@ function openAddWalletModal() {
} }
function autoPickColor() { function autoPickColor() {
const usedColors = wm.getWallets().map(w => getColorForWallet(w.address)); const usedColors = wm.getWallets().map(w => getColorForWallet(w.address, w.chain));
for (const c of WALLET_COLORS) { for (const c of WALLET_COLORS) {
if (!usedColors.includes(c)) return c; if (!usedColors.includes(c)) return c;
} }
@ -804,9 +816,9 @@ function renderLendingPlatformSelector(chainName) {
const container = document.getElementById('lending-platform-selector'); const container = document.getElementById('lending-platform-selector');
container.innerHTML = ''; container.innerHTML = '';
const isHyperliquide = HYPERLIQUIDE_CHAINS.includes(chainName); const isHyperEVM = HYPEREVM_CHAINS.includes(chainName);
if (isHyperliquide) { if (isHyperEVM) {
const platform = 'hyperlend'; const platform = 'hyperlend';
const logoUrl = 'https://app.hyperlend.finance/assets/header-logo-CiRKYBzy.svg'; const logoUrl = 'https://app.hyperlend.finance/assets/header-logo-CiRKYBzy.svg';
const color = '#caeae5'; const color = '#caeae5';
@ -840,7 +852,7 @@ function renderLendingPlatformSelector(chainName) {
} }
/* Reverse map: chainId (decimal) → chain name */ /* Reverse map: chainId (decimal) → chain name */
const CHAIN_IDS_REVERSED = { '1': 'ethereum', '8453': 'base', '42161': 'arbitrum', '10': 'optimism', '137': 'polygon', '43114': 'avalanche', '56': 'bsc', '250': 'fantom', '999': 'hyperliquide' }; const CHAIN_IDS_REVERSED = { '1': 'ethereum', '8453': 'base', '42161': 'arbitrum', '10': 'optimism', '137': 'polygon', '43114': 'avalanche', '56': 'bsc', '250': 'fantom', '999': 'hyperevm' };
function fetchWalletAddress() { function fetchWalletAddress() {
if (!window.ethereum) { if (!window.ethereum) {
@ -940,7 +952,7 @@ async function handleAddWallet(e) {
const addResult = wm.addWallet(address, autoChain, nickname, lendingPlatform); const addResult = wm.addWallet(address, autoChain, nickname, lendingPlatform);
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; } 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 }); wm.verifyWallet(address, autoChain, '', { action: 'Auto-verified (non-verification chain)', walletAddress: address });
walletSyncState[address] = 'synced'; walletSyncState[walletKey(address, autoChain)] = 'synced';
closeAddWalletModal(); closeAddWalletModal();
closeSidebar(); closeSidebar();
renderAll(); renderAll();
@ -962,7 +974,7 @@ async function handleAddWallet(e) {
closeAddWalletModal(); closeAddWalletModal();
closeSidebar(); closeSidebar();
walletSyncState[address] = 'syncing'; walletSyncState[walletKey(address, chain)] = 'syncing';
/* Register the address with the backend for monitoring */ /* Register the address with the backend for monitoring */
try { try {
@ -990,7 +1002,7 @@ async function handleAddWallet(e) {
EIP-712 Verification EIP-712 Verification
=================================================================== */ =================================================================== */
/* Chain name → numeric chainId for EIP-712 domain */ /* 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: 999 }; const CHAIN_IDS = { ethereum: 1, base: 8453, arbitrum: 42161, optimism: 10, polygon: 137, avalanche: 43114, bsc: 56, fantom: 250, hyperevm: 999 };
const VERIFY_TYPES = { const VERIFY_TYPES = {
VerifyTracking: [ VerifyTracking: [
{ name: 'action', type: 'string' }, { name: 'action', type: 'string' },
@ -1064,12 +1076,13 @@ async function handleVerifyWallet(address, chain, nickname) {
Delete Wallet Delete Wallet
=================================================================== */ =================================================================== */
function handleDeleteWallet(address, chain, idx) { function handleDeleteWallet(address, chain, idx) {
const key = walletKey(address, chain);
wm.removeWallet(address, chain); wm.removeWallet(address, chain);
delete walletSyncState[address]; delete walletSyncState[key];
delete addressSnapshots[address]; delete addressSnapshots[key];
/* Remove from unchecked list */ /* Remove from unchecked list */
let unchecked = getWalletFilter(); let unchecked = getWalletFilter();
const ui = unchecked.indexOf(address); const ui = unchecked.indexOf(key);
if (ui >= 0) unchecked.splice(ui, 1); if (ui >= 0) unchecked.splice(ui, 1);
if (unchecked.length === 0) { if (unchecked.length === 0) {
localStorage.removeItem('cbbtc_ledger_wallets'); localStorage.removeItem('cbbtc_ledger_wallets');
@ -1100,17 +1113,18 @@ function renderWalletPills() {
} }
let html = ''; let html = '';
wallets.forEach((w) => { wallets.forEach((w) => {
const color = getColorForWallet(w.address); const color = getColorForWallet(w.address, w.chain);
const syncState = walletSyncState[w.address]; const syncState = walletSyncState[walletKey(w.address, w.chain)];
const isSyncing = syncState === 'syncing' || syncState === 'pending'; const isSyncing = syncState === 'syncing' || syncState === 'pending';
const syncIndicator = isSyncing const syncIndicator = isSyncing
? ' <span class="text-[9px] text-yellow-400 animate-pulse">⏳</span>' ? ' <span class="text-[9px] text-yellow-400 animate-pulse">⏳</span>'
: ''; : '';
const nick = w.nickname || 'Wallet'; const nick = w.nickname || 'Wallet';
const isActive = isWalletInLedger(w.address); const isActive = isWalletInLedger(w.address, w.chain);
const platformBadge = getPlatformBadge(w.lendingPlatform); const platformBadge = getPlatformBadge(w.lendingPlatform);
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 + '" draggable="true">' + const key = walletKey(w.address, w.chain);
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-address="' + w.address + '" ' + (isActive ? 'checked' : '') + ' onchange="if(!_dragAddr){event.stopPropagation();toggleWalletPill(\'' + 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-key="' + key + '" data-address="' + w.address + '" data-chain="' + w.chain + '" draggable="true">' +
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-key="' + key + '" ' + (isActive ? 'checked' : '') + ' onchange="if(!_dragKey){event.stopPropagation();setWalletChecked(\'' + key + '\', this.checked)}">' +
'<div class="flex flex-col">' + '<div class="flex flex-col">' +
'<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider leading-tight">' + nick + syncIndicator + platformBadge + '</span>' + '<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider leading-tight">' + nick + syncIndicator + platformBadge + '</span>' +
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' + '<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
@ -1129,16 +1143,16 @@ function toggleWalletPill(address) {
/* =================================================================== /* ===================================================================
Drag Reorder (wallet pills) Drag Reorder (wallet pills)
=================================================================== */ =================================================================== */
let _dragAddr = null; let _dragKey = null;
let _dropTargetAddr = null; let _dropTargetKey = null;
let _dropBefore = true; let _dropBefore = true;
function startPillDrag(e) { function startPillDrag(e) {
if (!e.currentTarget.dataset.address) return; if (!e.currentTarget.dataset.key) return;
_dragAddr = e.currentTarget.dataset.address; _dragKey = e.currentTarget.dataset.key;
if (e.dataTransfer) { if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', _dragAddr); e.dataTransfer.setData('text/plain', _dragKey);
} }
e.stopPropagation(); e.stopPropagation();
e.currentTarget.classList.add('pill-dragging'); e.currentTarget.classList.add('pill-dragging');
@ -1150,36 +1164,36 @@ function endPillDrag(e) {
} }
function cleanupDrag() { function cleanupDrag() {
_dragAddr = null; _dragKey = null;
_dropTargetAddr = null; _dropTargetKey = null;
_dropBefore = true; _dropBefore = true;
document.querySelectorAll('.pill-dragging').forEach(el => el.classList.remove('pill-dragging')); document.querySelectorAll('.pill-dragging').forEach(el => el.classList.remove('pill-dragging'));
document.querySelectorAll('.drag-insert-line').forEach(el => el.remove()); document.querySelectorAll('.drag-insert-line').forEach(el => el.remove());
} }
function dropWalletReorder(e, targetAddress) { function dropWalletReorder(e, targetKey) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!_dragAddr) return cleanupDrag(); if (!_dragKey) return cleanupDrag();
const wallets = wm.getWallets(); const wallets = wm.getWallets();
const addrs = wallets.map(w => w.address); const keys = wallets.map(w => walletKey(w.address, w.chain));
const dragIdx = addrs.indexOf(_dragAddr); const dragIdx = keys.indexOf(_dragKey);
if (dragIdx === -1) return cleanupDrag(); if (dragIdx === -1) return cleanupDrag();
if (targetAddress && targetAddress !== _dragAddr) { if (targetKey && targetKey !== _dragKey) {
const targetIdx = addrs.indexOf(targetAddress); const targetIdx = keys.indexOf(targetKey);
if (targetIdx !== -1) { if (targetIdx !== -1) {
const moved = addrs.splice(dragIdx, 1)[0]; const moved = keys.splice(dragIdx, 1)[0];
const insertIdx = _dropBefore ? targetIdx : targetIdx + 1; const insertIdx = _dropBefore ? targetIdx : targetIdx + 1;
addrs.splice(insertIdx, 0, moved); keys.splice(insertIdx, 0, moved);
wm.setWalletOrder(addrs); wm.setWalletOrder(keys);
renderAll(); renderAll();
} }
} else if (!_dropTargetAddr) { } else if (!_dropTargetKey) {
const moved = addrs.splice(dragIdx, 1)[0]; const moved = keys.splice(dragIdx, 1)[0];
addrs.push(moved); keys.push(moved);
wm.setWalletOrder(addrs); wm.setWalletOrder(keys);
renderAll(); renderAll();
} }
cleanupDrag(); cleanupDrag();
@ -1193,24 +1207,24 @@ function setupPillDragListeners(container) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
_dropTargetAddr = pill.dataset.address; _dropTargetKey = pill.dataset.key;
const rect = pill.getBoundingClientRect(); const rect = pill.getBoundingClientRect();
_dropBefore = e.clientX < rect.left + rect.width / 2; _dropBefore = e.clientX < rect.left + rect.width / 2;
}); });
pill.addEventListener('dragleave', () => { document.querySelector('.drag-insert-line')?.remove(); }); pill.addEventListener('dragleave', () => { document.querySelector('.drag-insert-line')?.remove(); });
pill.addEventListener('drop', (e) => { dropWalletReorder(e, pill.dataset.address); }); pill.addEventListener('drop', (e) => { dropWalletReorder(e, pill.dataset.key); });
}); });
} }
const pillsContainer = document.getElementById('wallet-pills'); const pillsContainer = document.getElementById('wallet-pills');
pillsContainer.addEventListener('dragover', (e) => { pillsContainer.addEventListener('dragover', (e) => {
if (!_dragAddr) return; if (!_dragKey) return;
document.querySelector('.drag-insert-line')?.remove(); document.querySelector('.drag-insert-line')?.remove();
const children = Array.from(pillsContainer.children).filter(c => c.classList && c.classList.contains('pill-btn')); const children = Array.from(pillsContainer.children).filter(c => c.classList && c.classList.contains('pill-btn'));
for (const child of children) { for (const child of children) {
if (child.dataset.address === _dragAddr) continue; if (child.dataset.key === _dragKey) continue;
const rect = child.getBoundingClientRect(); const rect = child.getBoundingClientRect();
if (e.clientX < rect.left + rect.width / 2) { if (e.clientX < rect.left + rect.width / 2) {
const line = document.createElement('div'); const line = document.createElement('div');
@ -1222,16 +1236,16 @@ pillsContainer.addEventListener('dragover', (e) => {
}); });
pillsContainer.addEventListener('drop', (e) => { pillsContainer.addEventListener('drop', (e) => {
if (!_dragAddr || e.target.classList.contains('pill-btn')) return; if (!_dragKey || e.target.classList.contains('pill-btn')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const wallets = wm.getWallets(); const wallets = wm.getWallets();
const addrs = wallets.map(w => w.address); const keys = wallets.map(w => walletKey(w.address, w.chain));
const dragIdx = addrs.indexOf(_dragAddr); const dragIdx = keys.indexOf(_dragKey);
if (dragIdx !== -1) { if (dragIdx !== -1) {
const moved = addrs.splice(dragIdx, 1)[0]; const moved = keys.splice(dragIdx, 1)[0];
addrs.push(moved); keys.push(moved);
wm.setWalletOrder(addrs); wm.setWalletOrder(keys);
renderAll(); renderAll();
} }
cleanupDrag(); cleanupDrag();
@ -1240,14 +1254,8 @@ pillsContainer.addEventListener('drop', (e) => {
function getSelectedAddresses() { function getSelectedAddresses() {
const unchecked = getWalletFilter(); const unchecked = getWalletFilter();
const wallets = wm.getWallets(); const wallets = wm.getWallets();
if (unchecked.length === 0) return wallets.map(w => w.address); const filtered = wallets.filter(w => !unchecked.includes(walletKey(w.address, w.chain)));
return wallets.filter(w => !unchecked.includes(w.address)).map(w => w.address); return filtered.map(w => ({ address: w.address, chain: w.chain }));
}
function syncWalletCheckboxes(address, checked) {
document.querySelectorAll(`.wallet-filter-toggle[data-address="${address}"]`).forEach(cb => {
cb.checked = checked;
});
} }
/* =================================================================== /* ===================================================================
@ -1258,17 +1266,18 @@ function renderLedgerFilterWallets() {
const container = document.getElementById('ledger-filter-wallets'); const container = document.getElementById('ledger-filter-wallets');
let html = ''; let html = '';
wallets.forEach(w => { wallets.forEach(w => {
const color = getColorForWallet(w.address); const color = getColorForWallet(w.address, w.chain);
const checked = isWalletInLedger(w.address); const key = walletKey(w.address, w.chain);
const checked = isWalletInLedger(w.address, w.chain);
html += `<label class="filter-item" title="Show ${w.nickname || w.address}"> 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' : ''}> <input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ${color};" data-key="${key}" ${checked ? 'checked' : ''}>
</label>`; </label>`;
}); });
container.innerHTML = html; container.innerHTML = html;
container.querySelectorAll('.wallet-filter-toggle').forEach(cb => { container.querySelectorAll('.wallet-filter-toggle').forEach(cb => {
cb.addEventListener('change', () => { cb.addEventListener('change', () => {
setWalletChecked(cb.dataset.address, cb.checked); setWalletChecked(cb.dataset.key, cb.checked);
}); });
}); });
} }
@ -1278,6 +1287,7 @@ function renderLedgerFilterWallets() {
=================================================================== */ =================================================================== */
async function fetchWalletAaveData(address, chain) { async function fetchWalletAaveData(address, chain) {
chain = chain || 'base'; chain = chain || 'base';
const key = walletKey(address, chain);
const endpoint = `/api/v1/portfolio/${address}/${chain}/aave`; const endpoint = `/api/v1/portfolio/${address}/${chain}/aave`;
try { try {
const resp = await fetch(`${API_BASE}${endpoint}`); const resp = await fetch(`${API_BASE}${endpoint}`);
@ -1285,24 +1295,24 @@ async function fetchWalletAaveData(address, chain) {
const body = await resp.json().catch(() => ({})); const body = await resp.json().catch(() => ({}));
const details = (body?.detail || body?.details || '').toString().toUpperCase(); const details = (body?.detail || body?.details || '').toString().toUpperCase();
if (details.includes('PENDING') || details.includes('SYNCING')) { if (details.includes('PENDING') || details.includes('SYNCING')) {
walletSyncState[address] = 'syncing'; walletSyncState[key] = 'syncing';
renderWalletPills(); renderWalletPills();
return; return;
} }
} }
if (resp.status === 404) { if (resp.status === 404) {
walletSyncState[address] = 'pending'; walletSyncState[key] = 'pending';
renderWalletPills(); renderWalletPills();
return; return;
} }
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const events = await resp.json(); const events = await resp.json();
walletSyncState[address] = 'synced'; walletSyncState[key] = 'synced';
addressSnapshots[address] = snapshotsToDaily(events); addressSnapshots[key] = snapshotsToDaily(events);
renderWalletPills(); renderWalletPills();
} catch (err) { } catch (err) {
console.error(`Failed to fetch data for ${address}:`, err); console.error(`Failed to fetch data for ${key}:`, err);
walletSyncState[address] = 'pending'; walletSyncState[key] = 'pending';
renderWalletPills(); renderWalletPills();
} }
} }
@ -1310,6 +1320,7 @@ async function fetchWalletAaveData(address, chain) {
/* Poll until wallet data is synchronized, up to ~60s */ /* Poll until wallet data is synchronized, up to ~60s */
async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, chain) { async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, chain) {
chain = chain || 'base'; chain = chain || 'base';
const key = walletKey(address, chain);
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
@ -1317,8 +1328,8 @@ async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, c
if (resp.ok) { if (resp.ok) {
const events = await resp.json(); const events = await resp.json();
if (Array.isArray(events) && events.length > 0) { if (Array.isArray(events) && events.length > 0) {
walletSyncState[address] = 'synced'; walletSyncState[key] = 'synced';
addressSnapshots[address] = snapshotsToDaily(events); addressSnapshots[key] = snapshotsToDaily(events);
renderWalletPills(); renderWalletPills();
return true; return true;
} }
@ -1327,22 +1338,22 @@ async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, c
await new Promise(r => setTimeout(r, intervalMs)); await new Promise(r => setTimeout(r, intervalMs));
} }
/* Timeout — mark as synced anyway so UI is not blocked */ /* Timeout — mark as synced anyway so UI is not blocked */
walletSyncState[address] = 'syncing'; walletSyncState[key] = 'syncing';
renderWalletPills(); renderWalletPills();
return false; return false;
} }
/* Fetch price history for the new wallet's data range, then render */ /* Fetch price history for the new wallet's data range, then render */
async function _fetchPricesAndRender() { async function _fetchPricesAndRender() {
const selectedAddr = getSelectedAddresses(); const selectedWallets = getSelectedAddresses();
if (selectedAddr.length === 0) { if (selectedWallets.length === 0) {
renderCombinedTable(); renderCombinedTable();
return; return;
} }
/* Find oldest transaction to determine price range */ /* Find oldest transaction to determine price range */
let oldestTs = Date.now(); let oldestTs = Date.now();
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
snaps.forEach(s => { snaps.forEach(s => {
const t = new Date(s.block_timestamp).getTime(); const t = new Date(s.block_timestamp).getTime();
if (t < oldestTs) oldestTs = t; if (t < oldestTs) oldestTs = t;
@ -1354,18 +1365,17 @@ async function _fetchPricesAndRender() {
} }
async function fetchSelectedWalletsData() { async function fetchSelectedWalletsData() {
const selectedAddr = getSelectedAddresses(); const selectedWallets = getSelectedAddresses();
if (selectedAddr.length === 0) return; if (selectedWallets.length === 0) return;
/* Only fetch for wallets that are synced or syncing */ /* Only fetch for wallets that are synced or syncing */
const promises = selectedAddr.map(async (addr) => { const promises = selectedWallets.map(async (wallet) => {
const sync = walletSyncState[addr]; const key = walletKey(wallet.address, wallet.chain);
const sync = walletSyncState[key];
if (sync === 'syncing') return null; if (sync === 'syncing') return null;
if (sync === 'pending') return null; if (sync === 'pending') return null;
if (sync === 'synced' && addressSnapshots[addr]) return null; if (sync === 'synced' && addressSnapshots[key]) return null;
const w = wm.getWallets().find(w => w.address === addr); await fetchWalletAaveData(wallet.address, wallet.chain);
const chain = (w || {}).chain || 'base';
await fetchWalletAaveData(addr, chain);
}); });
await Promise.all(promises); await Promise.all(promises);
} }
@ -1451,11 +1461,11 @@ function snapshotsToDaily(events) {
Dashboard Update Dashboard Update
=================================================================== */ =================================================================== */
function getOldestTransactionDate() { function getOldestTransactionDate() {
const selectedAddr = getSelectedAddresses(); const selectedWallets = getSelectedAddresses();
if (selectedAddr.length === 0) return Date.now() - 365 * 86400000; if (selectedWallets.length === 0) return Date.now() - 365 * 86400000;
let oldestTs = Infinity; let oldestTs = Infinity;
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
snaps.forEach(snap => { snaps.forEach(snap => {
const sTs = new Date(snap.block_timestamp).getTime(); const sTs = new Date(snap.block_timestamp).getTime();
if (sTs < oldestTs) oldestTs = sTs; if (sTs < oldestTs) oldestTs = sTs;
@ -1466,16 +1476,17 @@ function getOldestTransactionDate() {
/* Build per-day cumulative BTC series from addressSnapshots. /* Build per-day cumulative BTC series from addressSnapshots.
Returns both aggregated total and per-wallet series. */ Returns both aggregated total and per-wallet series. */
function buildCumulativeSeries(addresses) { function buildCumulativeSeries(wallets) {
const allDates = {}; const allDates = {};
addresses.forEach(addr => { const keys = wallets.map(w => walletKey(w.address, w.chain));
const snaps = addressSnapshots[addr] || []; keys.forEach(key => {
const snaps = addressSnapshots[key] || [];
snaps.forEach(snap => { snaps.forEach(snap => {
allDates[snap.block_timestamp.slice(0, 10)] = true; allDates[snap.block_timestamp.slice(0, 10)] = true;
}); });
}); });
const snapshotDates = Object.keys(allDates).sort(); const snapshotDates = Object.keys(allDates).sort();
if (snapshotDates.length === 0) return { total: [], perWallet: [] }; if (snapshotDates.length === 0) return { total: [], perWallet: {} };
/* Build per-wallet forward-filled balance for every calendar day */ /* Build per-wallet forward-filled balance for every calendar day */
const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate()); const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate());
@ -1484,9 +1495,9 @@ function buildCumulativeSeries(addresses) {
/* Pre-compute per-wallet dated arrays (oldest-first) */ /* Pre-compute per-wallet dated arrays (oldest-first) */
const walletDated = {}; const walletDated = {};
addresses.forEach(addr => { keys.forEach(key => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[key] || [];
walletDated[addr] = snaps.map(s => ({ walletDated[key] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10), dateStr: s.block_timestamp.slice(0, 10),
btcAmt: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral) btcAmt: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr)); })).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
@ -1495,20 +1506,20 @@ function buildCumulativeSeries(addresses) {
/* Walk every calendar day, forward-fill each wallet, aggregate */ /* Walk every calendar day, forward-fill each wallet, aggregate */
const totalResult = []; const totalResult = [];
const perWalletResult = {}; const perWalletResult = {};
addresses.forEach(addr => { perWalletResult[addr] = []; }); keys.forEach(key => { perWalletResult[key] = []; });
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) { for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10); const ds = new Date(t).toISOString().slice(0, 10);
let dayTotal = 0; let dayTotal = 0;
addresses.forEach(addr => { keys.forEach(key => {
const dated = walletDated[addr]; const dated = walletDated[key];
let balance = 0; let balance = 0;
for (const d of dated) { for (const d of dated) {
if (d.dateStr <= ds) balance = d.btcAmt; if (d.dateStr <= ds) balance = d.btcAmt;
else break; else break;
} }
dayTotal += balance; dayTotal += balance;
perWalletResult[addr].push([t, balance]); perWalletResult[key].push([t, balance]);
}); });
totalResult.push([t, dayTotal]); totalResult.push([t, dayTotal]);
} }
@ -1517,31 +1528,32 @@ function buildCumulativeSeries(addresses) {
} }
function calculateAggregatedSeries() { function calculateAggregatedSeries() {
const selectedAddr = getSelectedAddresses(); const selectedWallets = getSelectedAddresses();
currentBuyCost = 0; currentBuyCost = 0;
currentBuyAmount = 0; currentBuyAmount = 0;
if (selectedAddr.length === 0) { if (selectedWallets.length === 0) {
currentAggregatedSeries = []; currentAggregatedSeries = [];
currentWalletSeries = []; currentWalletSeries = {};
return; return;
} }
const result = buildCumulativeSeries(selectedAddr); const result = buildCumulativeSeries(selectedWallets);
currentAggregatedSeries = result.total; currentAggregatedSeries = result.total;
currentWalletSeries = result.perWallet; currentWalletSeries = result.perWallet;
} }
function calculateCurrentHoldings() { function calculateCurrentHoldings() {
const selectedAddr = getSelectedAddresses(); const selectedWallets = getSelectedAddresses();
currentNetHeld = 0; currentNetHeld = 0;
currentBuyCost = 0; currentBuyCost = 0;
currentBuyAmount = 0; currentBuyAmount = 0;
if (selectedAddr.length === 0) return; if (selectedWallets.length === 0) return;
/* Sum latest cbBTC balance across selected wallets */ /* Sum latest cbBTC balance across selected wallets */
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const snaps = addressSnapshots[addr] || []; const key = walletKey(w.address, w.chain);
const snaps = addressSnapshots[key] || [];
if (snaps.length > 0) { if (snaps.length > 0) {
const latest = snaps[0]; const latest = snaps[0];
currentNetHeld += getTotalBTC(latest?.wallet); currentNetHeld += getTotalBTC(latest?.wallet);
@ -1550,8 +1562,9 @@ function calculateCurrentHoldings() {
}); });
/* Derive buy cost from snapshot deltas: positive BTC changes, oldest first */ /* Derive buy cost from snapshot deltas: positive BTC changes, oldest first */
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const snaps = addressSnapshots[addr] || []; const key = walletKey(w.address, w.chain);
const snaps = addressSnapshots[key] || [];
if (snaps.length === 0) return; if (snaps.length === 0) return;
let prevBtc = 0; let prevBtc = 0;
snaps.slice().reverse().forEach(snap => { snaps.slice().reverse().forEach(snap => {
@ -1591,9 +1604,9 @@ function updateDashboard() {
} }
/* Filter transaction table */ /* Filter transaction table */
const selectedAddr = getSelectedAddresses(); const selectedWallets = getSelectedAddresses();
const activeTypes = Array.from(document.querySelectorAll('.filter-checkbox:checked')).map(cb => cb.dataset.filter); 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); const ledgerWallets = Array.from(document.querySelectorAll('.wallet-filter-toggle:checked')).map(cb => cb.dataset.key || cb.dataset.address);
let visibleCount = 0; let visibleCount = 0;
document.querySelectorAll('#transaction-table tbody tr').forEach(row => { document.querySelectorAll('#transaction-table tbody tr').forEach(row => {
@ -1644,15 +1657,17 @@ function renderCombinedTable() {
const walletNickMap = {}; const walletNickMap = {};
const wallets = wm.getWallets(); const wallets = wm.getWallets();
wallets.forEach(w => { wallets.forEach(w => {
walletColorMap[w.address] = getColorForWallet(w.address); const key = walletKey(w.address, w.chain);
walletNickMap[w.address] = w.nickname || 'Wallet'; walletColorMap[key] = getColorForWallet(w.address, w.chain);
walletNickMap[key] = w.nickname || 'Wallet';
}); });
/* Build unified snapshot list from dynamic sources only */ /* Build unified snapshot list from dynamic sources only */
const unified = []; const unified = [];
for (const [addr, snaps] of Object.entries(addressSnapshots)) { for (const [key, snaps] of Object.entries(addressSnapshots)) {
const [addr] = key.split(':');
(snaps || []).forEach(snap => { (snaps || []).forEach(snap => {
unified.push({ ...snap, _walletAddress: addr }); unified.push({ ...snap, _walletAddress: key, _walletAddr: addr });
}); });
} }
@ -1686,7 +1701,7 @@ function renderCombinedTable() {
const usd = amt * price; const usd = amt * price;
coldTotal += usd; coldTotal += usd;
coldItems.push({ coldItems.push({
symbol: sym, amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2), symbol: symbolDisplay(sym), amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2),
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0', 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 usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
}); });
@ -1701,7 +1716,7 @@ function renderCombinedTable() {
const usd = amt * price; const usd = amt * price;
collateralTotal += usd; collateralTotal += usd;
collateralItems.push({ collateralItems.push({
symbol: sym, amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(4), symbol: symbolDisplay(sym), amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(4),
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0', 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 usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
}); });
@ -1716,7 +1731,7 @@ function renderCombinedTable() {
const usd = amt * price; const usd = amt * price;
borrowTotal += usd; borrowTotal += usd;
borrowItems.push({ borrowItems.push({
symbol: sym, amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2), symbol: symbolDisplay(sym), amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2),
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0', 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 usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
}); });
@ -1845,13 +1860,13 @@ function updateBtcUI(price, percent) {
} }
function setupCumulCard(cutoff) { function setupCumulCard(cutoff) {
const selectedAddr = getSelectedAddresses(); const selectedWallets = getSelectedAddresses();
const wallets = wm.getWallets(); const wallets = wm.getWallets();
const series = selectedAddr.map(addr => { const series = selectedWallets.map(w => {
const w = wallets.find(x => x.address === addr); const wm = wallets.find(x => x.address === w.address && x.chain === w.chain);
const nickname = w ? (w.nickname || addr.slice(0, 6)) : addr.slice(0, 6); const nickname = wm ? (wm.nickname || w.address.slice(0, 6)) : w.address.slice(0, 6);
const ws = currentWalletSeries[addr] || []; const ws = currentWalletSeries[walletKey(w.address, w.chain)] || [];
const filtered = ws.filter(d => d[0] >= cutoff); const filtered = ws.filter(d => d[0] >= cutoff);
/* Extend to today */ /* Extend to today */
if (filtered.length > 0) { if (filtered.length > 0) {
@ -1864,7 +1879,7 @@ function setupCumulCard(cutoff) {
return { name: nickname, data: filtered }; return { name: nickname, data: filtered };
}); });
const colors = selectedAddr.map(addr => getColorForWallet(addr)); const colors = selectedWallets.map(w => getColorForWallet(w.address, w.chain));
const gradientStops = colors.map(c => [ const gradientStops = colors.map(c => [
{ offset: 0, color: c, opacity: 0.35 }, { offset: 0, color: c, opacity: 0.35 },
@ -1921,9 +1936,9 @@ function setupSatsCard(cutoff) {
const DAY_MS = 86400000; const DAY_MS = 86400000;
/* Collect all dates across selected wallets, then forward-fill each wallet's balance */ /* Collect all dates across selected wallets, then forward-fill each wallet's balance */
const allDates = {}; const allDates = {};
const selectedAddr = getSelectedAddresses() || []; const selectedWallets = getSelectedAddresses() || [];
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; }); snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
}); });
const sortedDates = Object.keys(allDates).sort(); const sortedDates = Object.keys(allDates).sort();
@ -1931,9 +1946,10 @@ function setupSatsCard(cutoff) {
const wallets = wm.getWallets(); const wallets = wm.getWallets();
/* Per-wallet forward-fill */ /* Per-wallet forward-fill */
const walletDailyBalances = {}; const walletDailyBalances = {};
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
walletDailyBalances[addr] = {}; const key = walletKey(w.address, w.chain);
const snaps = addressSnapshots[addr] || []; walletDailyBalances[key] = {};
const snaps = addressSnapshots[key] || [];
const dated = snaps.map(s => ({ const dated = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10), dateStr: s.block_timestamp.slice(0, 10),
btcVal: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral) btcVal: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
@ -1942,18 +1958,19 @@ function setupSatsCard(cutoff) {
for (const ds of sortedDates) { for (const ds of sortedDates) {
const match = dated.find(d => d.dateStr === ds); const match = dated.find(d => d.dateStr === ds);
if (match) balance = match.btcVal; if (match) balance = match.btcVal;
walletDailyBalances[addr][ds] = balance; walletDailyBalances[key][ds] = balance;
} }
}); });
/* Per-wallet daily deltas */ /* Per-wallet daily deltas */
const walletDailyDeltas = {}; const walletDailyDeltas = {};
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
walletDailyDeltas[addr] = {}; const key = walletKey(w.address, w.chain);
walletDailyDeltas[key] = {};
let prev = 0; let prev = 0;
for (const ds of sortedDates) { for (const ds of sortedDates) {
const b = walletDailyBalances[addr][ds] || 0; const b = walletDailyBalances[key][ds] || 0;
walletDailyDeltas[addr][ds] = b - prev; walletDailyDeltas[key][ds] = b - prev;
prev = b; prev = b;
} }
}); });
@ -1964,14 +1981,15 @@ function setupSatsCard(cutoff) {
const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS)); const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS));
/* Per-wallet series: running avg sats/day */ /* Per-wallet series: running avg sats/day */
const series = selectedAddr.map(addr => { const series = selectedWallets.map(w => {
const w = wallets.find(x => x.address === addr); const key = walletKey(w.address, w.chain);
const nickname = w ? (w.nickname || addr.slice(0, 6)) : addr.slice(0, 6); const wm = wallets.find(x => x.address === w.address && x.chain === w.chain);
const nickname = wm ? (wm.nickname || w.address.slice(0, 6)) : w.address.slice(0, 6);
const data = []; const data = [];
let runningTotalBtc = 0; let runningTotalBtc = 0;
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) { for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10); const ds = new Date(t).toISOString().slice(0, 10);
runningTotalBtc += walletDailyDeltas[addr][ds] || 0; runningTotalBtc += walletDailyDeltas[key][ds] || 0;
const daysSoFar = Math.max(1, Math.ceil((t - firstUTC) / DAY_MS) + 1); const daysSoFar = Math.max(1, Math.ceil((t - firstUTC) / DAY_MS) + 1);
const runningAvgSats = Math.round((runningTotalBtc * 1e8) / daysSoFar); const runningAvgSats = Math.round((runningTotalBtc * 1e8) / daysSoFar);
if (t >= cutoff) { if (t >= cutoff) {
@ -1983,13 +2001,13 @@ function setupSatsCard(cutoff) {
const latestTotal = sortedDates.length > 0 ? (() => { const latestTotal = sortedDates.length > 0 ? (() => {
let s = 0; let s = 0;
selectedAddr.forEach(addr => { s += walletDailyBalances[addr][sortedDates[sortedDates.length - 1]] || 0; }); selectedWallets.forEach(w => { s += walletDailyBalances[walletKey(w.address, w.chain)][sortedDates[sortedDates.length - 1]] || 0; });
return s; return s;
})() : 0; })() : 0;
const avgSatsPerDay = Math.round((latestTotal * 1e8) / daysElapsed); const avgSatsPerDay = Math.round((latestTotal * 1e8) / daysElapsed);
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(avgSatsPerDay); document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(avgSatsPerDay);
const colors = selectedAddr.map(addr => getColorForWallet(addr)); const colors = selectedWallets.map(w => getColorForWallet(w.address, w.chain));
const gradientStops = colors.map(c => [ const gradientStops = colors.map(c => [
{ offset: 0, color: c, opacity: 0.35 }, { offset: 0, color: c, opacity: 0.35 },
{ offset: 100, color: c, opacity: 0 } { offset: 100, color: c, opacity: 0 }
@ -2037,9 +2055,9 @@ function setupSatsCard(cutoff) {
function setupBreakdownCard(cutoff) { function setupBreakdownCard(cutoff) {
/* Derive from snapshots: cold wallet vs collateral */ /* Derive from snapshots: cold wallet vs collateral */
const allDates = {}; const allDates = {};
const selectedAddr = getSelectedAddresses() || []; const selectedWallets = getSelectedAddresses() || [];
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[walletKey(w.address, w.chain)] || [];
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; }); snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
}); });
const snapshotDates = Object.keys(allDates).sort(); const snapshotDates = Object.keys(allDates).sort();
@ -2062,9 +2080,10 @@ function setupBreakdownCard(cutoff) {
/* Pre-compute per-wallet dated arrays (oldest-first) */ /* Pre-compute per-wallet dated arrays (oldest-first) */
const walletDated = {}; const walletDated = {};
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const snaps = addressSnapshots[addr] || []; const key = walletKey(w.address, w.chain);
walletDated[addr] = snaps.map(s => ({ const snaps = addressSnapshots[key] || [];
walletDated[key] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10), dateStr: s.block_timestamp.slice(0, 10),
cold: getTotalBTC(s?.wallet), cold: getTotalBTC(s?.wallet),
collateral: getTotalBTC(s?.collateral) collateral: getTotalBTC(s?.collateral)
@ -2077,8 +2096,9 @@ function setupBreakdownCard(cutoff) {
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) { for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10); const ds = new Date(t).toISOString().slice(0, 10);
let dayCold = 0, dayColl = 0; let dayCold = 0, dayColl = 0;
selectedAddr.forEach(addr => { selectedWallets.forEach(w => {
const dated = walletDated[addr]; const key = walletKey(w.address, w.chain);
const dated = walletDated[key];
let c = 0, co = 0; let c = 0, co = 0;
for (const d of dated) { for (const d of dated) {
if (d.dateStr <= ds) { c = d.cold; co = d.collateral; } if (d.dateStr <= ds) { c = d.cold; co = d.collateral; }
@ -2164,27 +2184,31 @@ async function pollVerifiedWallets() {
if (now - lastFetchMs < 30000) return; if (now - lastFetchMs < 30000) return;
lastFetchMs = now; lastFetchMs = now;
const verified = wm.getVerifiedWallets() || []; const verified = wm.getVerifiedWallets() || [];
let anyUpdated = false;
for (const w of verified) { for (const w of verified) {
try { try {
const key = walletKey(w.address, w.chain);
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/${w.chain}/aave`); const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/${w.chain}/aave`);
if (!resp.ok) continue; if (!resp.ok) continue;
const events = await resp.json(); const events = await resp.json();
const newestTs = events[events.length - 1].block_timestamp; if (events.length === 0) continue;
const existing = addressSnapshots[w.address]; const newestTs = events.reduce((m, e) => (e.block_timestamp > m ? e.block_timestamp : m), events[0].block_timestamp);
const existing = addressSnapshots[key];
if (existing && existing.length > 0) { if (existing && existing.length > 0) {
if (newestTs > existing[0].block_timestamp) { if (newestTs > existing[0].block_timestamp) {
addressSnapshots[w.address] = snapshotsToDaily(events); addressSnapshots[key] = snapshotsToDaily(events);
walletSyncState[w.address] = 'synced'; walletSyncState[key] = 'synced';
anyUpdated = true;
} }
} else { } else {
addressSnapshots[w.address] = snapshotsToDaily(events); addressSnapshots[key] = snapshotsToDaily(events);
walletSyncState[w.address] = 'synced'; walletSyncState[key] = 'synced';
anyUpdated = true;
} }
} catch (err) { } catch (err) {
console.error("Poll update failed for " + w.address, err); console.error("Poll update failed for " + key, err);
} }
} }
const anyUpdated = Object.keys(addressSnapshots).length > 0;
if (anyUpdated) { if (anyUpdated) {
await refreshPrices(); await refreshPrices();
renderCombinedTable(); renderCombinedTable();
@ -2434,10 +2458,11 @@ async function handleImportCsv(e) {
const skipped = result.skipped || 0; const skipped = result.skipped || 0;
/* Update frontend state */ /* Update frontend state */
const key = walletKey(address, chain);
if (result.ledger && Array.isArray(result.ledger)) { if (result.ledger && Array.isArray(result.ledger)) {
addressSnapshots[address] = snapshotsToDaily(result.ledger); addressSnapshots[key] = snapshotsToDaily(result.ledger);
} else { } else {
addressSnapshots[address] = snapshotsToDaily(records.map(r => ({ addressSnapshots[key] = snapshotsToDaily(records.map(r => ({
block_timestamp: r.block_timestamp, block_timestamp: r.block_timestamp,
wallet: { [r.token_address]: r.amount }, wallet: { [r.token_address]: r.amount },
collateral: {}, collateral: {},
@ -2445,11 +2470,11 @@ async function handleImportCsv(e) {
tx_hash: r.tx_hash || null, tx_hash: r.tx_hash || null,
}))); })));
} }
walletSyncState[address] = 'synced'; walletSyncState[key] = 'synced';
/* Assign color and mark synced */ /* Assign color and mark synced */
setColorForWallet(address, color); setColorForWallet(address, chain, color);
walletSyncState[address] = 'synced'; walletSyncState[key] = 'synced';
showToast( showToast(
'Import Complete', 'Import Complete',

View File

@ -39,7 +39,7 @@ const LS_KEY = 'tracked_wallets';
const VALID_CHAINS = new Set([ const VALID_CHAINS = new Set([
'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism', 'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism',
'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc', 'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc',
'hyperliquide', 'hyperevm',
]); ]);
/* Regex patterns for address validation per chain */ /* Regex patterns for address validation per chain */
@ -53,7 +53,7 @@ const ADDRESS_PATTERNS = {
avalanche: /^(0x)?[0-9a-fA-F]{40}$/i, avalanche: /^(0x)?[0-9a-fA-F]{40}$/i,
bsc: /^(0x)?[0-9a-fA-F]{40}$/i, bsc: /^(0x)?[0-9a-fA-F]{40}$/i,
fantom: /^(0x)?[0-9a-fA-F]{40}$/i, fantom: /^(0x)?[0-9a-fA-F]{40}$/i,
hyperliquide: /^(0x)?[0-9a-fA-F]{40}$/i, hyperevm: /^(0x)?[0-9a-fA-F]{40}$/i,
/* Bitcoin — bech32 or legacy pubkey hash */ /* Bitcoin — bech32 or legacy pubkey hash */
bitcoin: /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/, bitcoin: /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/,
@ -363,17 +363,18 @@ export class WalletManager {
} }
/** /**
* Reorder wallets to match the given address sequence. * Reorder wallets to match the given compound-key sequence ("addr:chain").
* Addresses listed first are moved to front; remaining keep their original relative order. * Keys listed first are moved to front; remaining keep their original relative order.
* Only recognized addresses are kept in the new order. * Also accepts plain addresses for backward compatibility.
* @param {string[]} addresses * @param {string[]} keys
*/ */
setWalletOrder(addresses) { setWalletOrder(keys) {
const addrSet = this._wallets.map(w => w.address); const keySet = this._wallets.map(w => `${w.address}:${w.chain}`);
const ordered = []; const ordered = [];
for (const addr of addresses) { for (const k of keys) {
if (addrSet.includes(addr)) { if (keySet.includes(k)) {
const w = this._wallets.find(x => x.address === addr); const [addr, chain] = k.split(':');
const w = this._wallets.find(x => x.address === addr && x.chain === chain);
if (w) ordered.push(w); if (w) ordered.push(w);
} }
} }