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

View File

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