Enforce verified-only wallet monitoring

- Restore persistent verification: wallets.js loadWallets() now preserves
  isVerified, signature, messageData from localStorage (was reset to false).
  Only restores verification when messageData is present (anti-tampering).
- Add WalletManager.getVerifiedWallets() for EVM-only verification gating.
- Remove all hardcoded embedded data (walletCumulData, walletsMetadata,
  walletCosts, walletBuys, dailySatsData, aaveCumulData, totalCumulData).
- Derive all chart data dynamically from API snapshots.
- Rewrite calculateAggregatedSeries, calculateCurrentHoldings, setupSatsCard,
  setupBreakdownCard to source from addressSnapshots.
- Replace fetchAaveSnapshots with fetchAllWalletData (verified wallets only).
- Replace pollAaveUpdate with pollVerifiedWallets polling loop.
- Add inline EIP-712 verification (verifyOwnership, handleVerifyWallet,
  handleRevokeVerification) with MetaMask integration.
- Sidebar shows verified/unverified badges per wallet with verify/revoke buttons.
- Non-EVM wallets (btc, solana, bitcoin) auto-verify on add (trust-based).
- Update fallback WalletManager class identically with persistent verification.
This commit is contained in:
Dione
2026-06-10 08:22:39 +00:00
parent c573e58e0f
commit 5ab9cb4b5c
2 changed files with 313 additions and 168 deletions

View File

@ -331,7 +331,8 @@ window.WalletManager = WalletManager;
const raw = localStorage.getItem('cbbtc_tracked_wallets');
this._wallets = raw ? JSON.parse(raw) : [];
if (!Array.isArray(this._wallets)) this._wallets = [];
this._wallets = this._wallets.filter(w => w && w.address && w.chain).map(w => ({ address: String(w.address), chain: String(w.chain).toLowerCase(), nickname: String(w.nickname||''), isVerified: false, signature: null, messageData: null }));
this._wallets = this._wallets.filter(w => w && w.address && w.chain).map(w => ({ address: String(w.address), chain: String(w.chain).toLowerCase(), nickname: String(w.nickname||''), isVerified: !!(w.isVerified && w.messageData), signature: w.signature ? String(w.signature) : null, messageData: w.messageData ? w.messageData : null }));
this.getVerifiedWallets = function() { return this._wallets.filter(w => ['btc','bitcoin','solana'].includes(w.chain) ? true : w.isVerified).map(w => ({...w})); };
} catch(e) { this._wallets = []; }
return this._wallets;
}
@ -356,13 +357,31 @@ window.WalletManager = WalletManager;
return { success: true, removedAddress: address };
}
findWallet(address, chain) { return this._wallets.find(w => w.address === address && w.chain === chain); }
renameWallet(address, chain, nickname) {
renameWallet(address, chain, nickname) {
const w = this.findWallet(address, chain);
if (!w) return;
w.nickname = String(nickname).trim();
this._persist();
}
};
}
verifyWallet(address, chain, signature, messageData) {
const w = this.findWallet(address, chain);
if (!w) return { success: false, error: 'Wallet not found' };
w.isVerified = true;
w.signature = String(signature);
w.messageData = messageData || null;
this._persist();
return { success: true };
}
revokeVerification(address, chain) {
const w = this.findWallet(address, chain);
if (!w) return { success: false, error: 'Wallet not found' };
w.isVerified = false;
w.signature = null;
w.messageData = null;
this._persist();
return { success: true };
}
};
}
})();
@ -375,16 +394,6 @@ const API_BASE = window.location.origin;
const WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF'];
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
/* Embedded data for hardcoded wallets */
const walletCumulData = {"penguin": [[1732057200000, 0.0], [1732143600000, 0.0], [1732230000000, 0.0], [1732316400000, 0.0], [1732402800000, 0.0], [1732489200000, 0.0], [1732575600000, 0.0], [1732662000000, 0.0], [1732748400000, 0.0], [1732834800000, 0.0], [1732921200000, 0.00045671], [1733007600000, 0.00068314], [1733094000000, 0.00103581], [1733180400000, 0.00154653], [1733266800000, 0.00203502], [1733353200000, 0.00245059], [1733439600000, 0.0027401], [1733526000000, 0.0027401], [1733612400000, 0.0027401], [1733698800000, 0.0027401], [1733785200000, 0.00331277], [1733871600000, 0.00331277], [1733958000000, 0.00352929], [1734044400000, 0.00352929], [1734130800000, 0.00437782], [1734217200000, 0.00437782], [1734303600000, 0.00437782], [1734390000000, 0.00437782], [1734476400000, 0.00437782], [1734562800000, 0.00437782], [1734649200000, 0.00437782], [1734735600000, 0.00437782], [1734822000000, 0.00483383], [1734908400000, 0.00483383], [1734994800000, 0.00483383], [1735081200000, 0.00483383], [1735167600000, 0.00483383], [1735254000000, 0.00483383], [1735340400000, 0.00688418], [1735426800000, 0.00688418], [1735513200000, 0.00688418], [1735599600000, 0.00688418], [1735686000000, 0.00688418], [1735772400000, 0.00688418], [1735858800000, 0.00688418], [1735945200000, 0.00688418], [1736031600000, 0.00688418], [1736118000000, 0.00688418], [1736204400000, 0.00688418], [1736290800000, 0.00753678], [1736377200000, 0.00753678], [1736463600000, 0.00825444], [1736550000000, 0.00825444], [1736636400000, 0.00825444], [1736722800000, 0.0084866], [1736809200000, 0.0084866], [1736895600000, 0.0084866], [1736982000000, 0.0084866], [1737068400000, 0.0084866], [1737154800000, 0.0084866], [1737241200000, 0.0084866], [1737327600000, 0.0084866], [1737414000000, 0.0084866], [1737500400000, 0.0084866], [1737586800000, 0.0088232], [1737673200000, 0.0088232], [1737759600000, 0.0088232], [1737846000000, 0.0088232], [1737932400000, 0.00947181], [1738018800000, 0.00947181], [1738105200000, 0.00987744]], "nano": [[1732057200000, 0.0], [1732143600000, 0.0], [1732230000000, 0.0], [1732316400000, 0.0], [1732402800000, 0.0], [1732489200000, 0.0], [1732575600000, 0.0], [1732662000000, 0.0], [1732748400000, 0.0], [1732834800000, 0.0], [1732921200000, 0.00045671], [1733007600000, 0.00068314]]};
const walletsMetadata = {"nano": {"id": "nano", "address": "0x3837ea82a38daa985ee613e69f72adbe12d0aa50", "name": "Base Wallet", "color": "#58a6ff", "chain": "base"}, "penguin": {"id": "penguin", "address": "0x0c1a4a060e119f981412e323104d1c134d413dba", "name": "Base Wallet", "color": "#f7931a", "chain": "base"}, "cold": {"id": "cold", "address": "bc1qhwsm859uy7aec2h4rj3e00p2vg8nzy8je8duqz", "name": "BTC Cold Storage", "color": "#f7931a", "chain": "btc"}};
const walletCosts = {"nano": 57143.88957364997, "penguin": 28214.47038528, "cold": 3528.97661289};
const walletBuys = {"nano": 0.5830959900000001, "penguin": 0.29296042999999994, "cold": 0.07518186};
const dailySatsData = [{"ts": 1732057200000, "rolling_sats": 0, "btc_val": 0, "price": 94287, "days_since": -10, "cumul_btc": 0}, {"ts": 1732143600000, "rolling_sats": 0, "btc_val": 0, "price": 98317, "days_since": -9, "cumul_btc": 0}, {"ts": 1732230000000, "rolling_sats": 0, "btc_val": 0, "price": 98892, "days_since": -8, "cumul_btc": 0}, {"ts": 1732316400000, "rolling_sats": 0, "btc_val": 0, "price": 97672, "days_since": -7, "cumul_btc": 0}, {"ts": 1732402800000, "rolling_sats": 0, "btc_val": 0, "price": 97900, "days_since": -6, "cumul_btc": 0}, {"ts": 1732489200000, "rolling_sats": 0, "btc_val": 0, "price": 93010, "days_since": -5, "cumul_btc": 0}, {"ts": 1732575600000, "rolling_sats": 0, "btc_val": 0, "price": 91965, "days_since": -4, "cumul_btc": 0}, {"ts": 1732662000000, "rolling_sats": 0, "btc_val": 0, "price": 95863, "days_since": -3, "cumul_btc": 0}, {"ts": 1732748400000, "rolling_sats": 0, "btc_val": 0, "price": 95644, "days_since": -2, "cumul_btc": 0}, {"ts": 1732834800000, "rolling_sats": 0, "btc_val": 0, "price": 97460, "days_since": -1, "cumul_btc": 0}, {"ts": 1732921200000, "rolling_sats": 4152, "btc_val": 0.00004152, "price": 96408, "days_since": 0, "cumul_btc": 0.00045671}, {"ts": 1733007600000, "rolling_sats": 5693, "btc_val": 0.00005693, "price": 97185, "days_since": 1, "cumul_btc": 0.00068314}, {"ts": 1733094000000, "rolling_sats": 7968, "btc_val": 0.00007968, "price": 95841, "days_since": 2, "cumul_btc": 0.00103581}, {"ts": 1733180400000, "rolling_sats": 11047, "btc_val": 0.00011047, "price": 95850, "days_since": 3, "cumul_btc": 0.00154653}, {"ts": 1733266800000, "rolling_sats": 13567, "btc_val": 0.00013567, "price": 98587, "days_since": 4, "cumul_btc": 0.00203502}, {"ts": 1733353200000, "rolling_sats": 15316, "btc_val": 0.00015316, "price": 96946, "days_since": 5, "cumul_btc": 0.00245059}, {"ts": 1733439600000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 96537, "days_since": 6, "cumul_btc": 0.0027401}, {"ts": 1733526000000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 96201, "days_since": 7, "cumul_btc": 0.0027401}, {"ts": 1733612400000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 95811, "days_since": 8, "cumul_btc": 0.0027401}, {"ts": 1733698800000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 97183, "days_since": 9, "cumul_btc": 0.0027401}, {"ts": 1733785200000, "rolling_sats": 19612, "btc_val": 0.00019612, "price": 96685, "days_since": 10, "cumul_btc": 0.00331277}, {"ts": 1733871600000, "rolling_sats": 19612, "btc_val": 0.00019612, "price": 97491, "days_since": 11, "cumul_btc": 0.00331277}, {"ts": 1733958000000, "rolling_sats": 20414, "btc_val": 0.00020414, "price": 96537, "days_since": 12, "cumul_btc": 0.00352929}, {"ts": 1734044400000, "rolling_sats": 20414, "btc_val": 0.00020414, "price": 96685, "days_since": 13, "cumul_btc": 0.00352929}, {"ts": 1734130800000, "rolling_sats": 22742, "btc_val": 0.00022742, "price": 97163, "days_since": 14, "cumul_btc": 0.00437782}, {"ts": 1734217200000, "rolling_sats": 22742, "btc_val": 0.00022742, "price": 97000, "days_since": 15, "cumul_btc": 0.00437782}];
const aaveCumulData = [[1732057200000, 0], [1732143600000, 0], [1732230000000, 0], [1732316400000, 0], [1732402800000, 0], [1732489200000, 0], [1732575600000, 0], [1732662000000, 0], [1732748400000, 0], [1732834800000, 0], [1732921200000, 0], [1733007600000, 0], [1733094000000, 0], [1733180400000, 0], [1733266800000, 0], [1733353200000, 0]];
const directCumulData = [[1732057200000, 0], [1732143600000, 0], [1732230000000, 0], [1732316400000, 0], [1732402800000, 0], [1732489200000, 0], [1732575600000, 0], [1732662000000, 0], [1732748400000, 0], [1732834800000, 0], [1732921200000, 0.00045671], [1733007600000, 0.00068314]];
const totalCumulData = [[1732057200000, 0], [1732143600000, 0], [1732230000000, 0], [1732316400000, 0], [1732402800000, 0], [1732489200000, 0], [1732575600000, 0], [1732662000000, 0], [1732748400000, 0], [1732834800000, 0], [1732921200000, 0.00045671], [1733007600000, 0.00068314]];
/* ===================================================================
App State
=================================================================== */
@ -401,7 +410,6 @@ let tickIntervalId;
/* Aave data */
let aavePriceMap = {};
let allAaveSnapshots = [];
let lastFetchMs = 0;
let todayBtcPrice = null;
@ -411,21 +419,11 @@ const walletSyncState = {};
/* Per-address snapshot data, keyed by address */
const addressSnapshots = {};
/* Which embedded wallet IDs each tracked address maps to */
function findEmbeddedId(address) {
for (const [id, meta] of Object.entries(walletsMetadata)) {
if (meta.address.toLowerCase() === address.toLowerCase()) return id;
}
return null;
}
/* Assigned color index for dynamic wallets */
let _colorIdx = 0;
function getColorForWallet(address) {
const c = localStorage.getItem('cbbtc_color_' + address);
if (c) return c;
const embedded = findEmbeddedId(address);
if (embedded && walletsMetadata[embedded].color) return walletsMetadata[embedded].color;
const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length];
_colorIdx++;
localStorage.setItem('cbbtc_color_' + address, color);
@ -582,16 +580,27 @@ function renderSidebar() {
? `<span class="text-yellow-400 text-[10px] ml-2">⏳ Syncing</span>`
: '';
const nick = w.nickname || shortAddr;
const isEVM = !['btc', 'bitcoin', 'solana'].includes(w.chain);
const isVerified = w.isVerified;
const verifyBadge = isEVM
? (isVerified ? '<span class="text-green-400 text-[10px] ml-2">✓ Verified</span>' : '<span class="text-red-400 text-[10px] ml-2">⚠ Unverified</span>')
: '<span class="text-blue-400 text-[10px] ml-2">🔒 No-Sig</span>';
const verifyBtn = isEVM && !isVerified
? '<button onclick="handleVerifyWallet(\'' + w.address + '\',\'' + w.chain + '\',\'' + nick + '\')" class="text-[10px] bg-[#FF7A00]/20 hover:bg-[#FF7A00]/40 text-[#FF7A00] font-bold px-2 py-0.5 rounded transition" title="Verify ownership via MetaMask">Verify</button>'
: '';
const revokeBtn = isVerified && isEVM
? '<button onclick="handleRevokeVerification(\'' + w.address + '\',\'' + w.chain + '\')" class="text-[10px] bg-red-500/20 hover:bg-red-500/40 text-red-400 font-bold px-2 py-0.5 rounded transition" title="Revoke verification">Revoke</button>'
: '';
html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' +
'<div class="flex items-center gap-2.5 min-w-0 flex-1">' +
'<div onclick="openColorPicker(event, \'' + w.address + '\')" class="wallet-color-dot" style="--wallet-color:' + color + ';background:' + color + ';" title="Click to change color"></div>' +
'<div class="min-w-0 flex-1">' +
'<input class="rename-nick-input bg-transparent text-sm font-medium text-white truncate border border-transparent focus:border-[#FF7A00]/50 rounded px-1 outline-none transition block w-full" value="' + nick + '" data-addr="' + w.address + '" data-chain="' + w.chain + '" onblur="handleRenameNickname(this)" onkeydown="if(event.key===\'Enter\')this.blur()">' +
'<div class="text-[10px] text-gray-500 font-mono truncate">' + shortAddr + '</div>' +
'<div class="text-[10px] text-gray-500 font-mono truncate">' + shortAddr + ' <span class="text-gray-600">' + w.chain + '</span>' + verifyBadge + '</div>' +
'</div>' +
'</div>' +
'<div class="flex items-center gap-1 flex-shrink-0">' +
syncBadge +
verifyBtn + revokeBtn + syncBadge +
'<button onclick="handleDeleteWallet(\'' + w.address + '\',\'' + w.chain + '\',' + i + ')" class="text-gray-600 hover:text-red-400 transition" title="Remove wallet">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>' +
@ -627,17 +636,42 @@ function closeAddWalletModal() {
async function handleAddWallet(e) {
e.preventDefault();
const address = document.getElementById('input-address').value.trim();
const address = document.getElementById('input-address').value.trim().toLowerCase();
const nickname = document.getElementById('input-nickname').value.trim();
const errEl = document.getElementById('add-wallet-error');
if (!address) { errEl.textContent = 'Address is required'; errEl.classList.remove('hidden'); return false; }
if (!/^(0x)?[0-9a-fA-F]{40}$/i.test(address)) { errEl.textContent = 'Invalid EVM address format'; errEl.classList.remove('hidden'); return false; }
/* Register in WalletManager */
/* Detect chain from address format */
let chain = 'base';
const isEVM = /^(0x)?[0-9a-fA-F]{40}$/i.test(address);
const isBTC = /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/i.test(address);
// if (!isEVM && !isBTC) { errEl.textContent = 'Invalid address format'; errEl.classList.remove('hidden'); return false; }
if (!isEVM && !isBTC) chain = 'btc';
/* Auto-verify non-EVM wallets */
if (!isEVM && isBTC) {
const addResult = wm.addWallet(address, 'btc', nickname);
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; }
wm.verifyWallet(address, 'btc', '', { action: 'Auto-verified (non-verification chain)', walletAddress: address });
walletSyncState[address] = 'synced';
closeAddWalletModal();
renderAll();
return false;
}
/* Register EVM wallet */
const addResult = wm.addWallet(address, 'base', nickname);
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; }
/* Attempt EIP-712 verification if MetaMask is available */
const verifyResult = await verifyOwnership(address, nickname);
if (!verifyResult.success) {
console.log("Verification not performed:", verifyResult.error);
/* Wallet saved but unverified — user can verify from sidebar */
}
/* POST to FastAPI backend to start monitoring */
try {
const resp = await fetch('http://localhost:8000/api/v1/portfolio/monitor', {
@ -646,12 +680,10 @@ async function handleAddWallet(e) {
body: JSON.stringify({ address: address, chain: 'base', chain_id: 8453 })
});
if (!resp.ok) {
errEl.textContent = `Backend returned ${resp.status}. Wallet saved locally.`;
errEl.classList.remove('hidden');
console.log(`Backend returned ${resp.status}`);
}
} catch (err) {
errEl.textContent = 'Backend unavailable. Wallet saved locally.';
errEl.classList.remove('hidden');
console.error('Backend unavailable', err);
}
/* Mark as syncing initially */
@ -668,6 +700,81 @@ async function handleAddWallet(e) {
return false;
}
/* ===================================================================
EIP-712 Verification
=================================================================== */
const EIP712_DOMAIN = { name: "Anonymous Wallet Tracker", version: "1", chainId: 1 };
const VERIFY_TYPES = {
VerifyTracking: [
{ name: 'action', type: 'string' },
{ name: 'walletAddress', type: 'address' },
{ name: 'timestamp', type: 'uint256' }
]
};
async function verifyOwnership(address, nickname) {
if (!window.ethereum) {
return { success: false, error: 'No web3 provider' };
}
const message = {
action: 'Verify Ownership for Live Tracking',
walletAddress: address,
timestamp: Math.floor(Date.now() / 1000).toString()
};
const typedData = {
domain: EIP712_DOMAIN,
types: { EIP712Domain: [{ name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }], VerifyTracking: VERIFY_TYPES.VerifyTracking },
primaryType: 'VerifyTracking',
message
};
try {
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
if (!accounts || accounts.length === 0) return { success: false, error: 'No account' };
/* Require that the connected signer matches the added wallet */
if (accounts[0].toLowerCase() !== address) {
return { success: false, error: 'Connected wallet does not match added address' };
}
const sig = await window.ethereum.request({
method: 'eth_signTypedData_v4',
params: [accounts[0], JSON.stringify(typedData)]
});
wm.verifyWallet(address, 'base', sig, typedData.message);
wm.renameWallet(address, 'base', nickname);
return { success: true };
} catch (err) {
return { success: false, error: err?.message || 'Signature failed' };
}
}
async function handleVerifyWallet(address, chain, nickname) {
const w = wm.findWallet(address, chain);
if (!w) return;
if (w.isVerified) return;
/* Non-EVM auto-verify */
if (['btc', 'bitcoin', 'solana'].includes(chain)) {
wm.verifyWallet(address, chain, '', { action: 'Auto-verified (non-verification chain)', walletAddress: address });
renderAll();
return;
}
const result = await verifyOwnership(address, nickname || w.nickname);
if (result.success) {
/* Fetch data now that wallet is verified */
if (chain === 'base') fetchWalletAaveData(address);
} else {
alert('Verification failed: ' + result.error);
}
renderAll();
}
function handleRevokeVerification(address, chain) {
wm.revokeVerification(address, chain);
renderAll();
}
/* ===================================================================
Delete Wallet
=================================================================== */
@ -805,8 +912,6 @@ async function fetchSelectedWalletsData() {
/* ===================================================================
Chart Helpers
=================================================================== */
const penguinAddress = '0x0c1a4a060e119f981412e323104d1c134d413dba';
function getTokenAmount(raw, symbol) {
const dec = TOKENS[symbol] ? TOKENS[symbol].decimals : 18;
return parseFloat(raw || '0') / Math.pow(10, dec);
@ -886,45 +991,46 @@ function getOldestTransactionDate() {
if (sTs < oldestTs) oldestTs = sTs;
});
});
if (selectedAddr.map(a => a.toLowerCase()).includes(penguinAddress.toLowerCase())) {
allAaveSnapshots.forEach(snap => {
const sTs = new Date(snap.block_timestamp).getTime();
if (sTs < oldestTs) oldestTs = sTs;
});
}
return oldestTs === Infinity ? Date.now() - 365 * 86400000 : oldestTs;
}
/* Build per-day cumulative BTC series from addressSnapshots */
function buildCumulativeSeries(addresses) {
const dayMap = {};
addresses.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => {
const ts = new Date(snap.block_timestamp).getTime();
const dateStr = snap.block_timestamp.slice(0, 10);
const btcAmt = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
if (!dayMap[ts]) dayMap[ts] = { total: 0, dateStr };
dayMap[ts].total += btcAmt;
});
});
const sorted = Object.values(dayMap).sort((a, b) => {
const tsA = new Date(a.dateStr).getTime();
const tsB = new Date(b.dateStr).getTime();
return tsA - tsB;
});
let cumul = 0;
return sorted.map(d => {
cumul += d.total;
const dateStr = d.dateStr;
return [new Date(dateStr).getTime(), cumul];
});
}
function calculateAggregatedSeries() {
const selectedAddr = getSelectedAddresses();
const activeWalletIds = selectedAddr.map(a => findEmbeddedId(a)).filter(Boolean);
const uniqueIds = [...new Set(activeWalletIds)];
currentBuyCost = 0;
currentBuyAmount = 0;
if (uniqueIds.length === 0) {
if (selectedAddr.length === 0) {
currentAggregatedSeries = [];
currentNetHeld = 0;
return;
}
const firstWid = uniqueIds[0];
const baseSeries = walletCumulData[firstWid] || [];
const result = baseSeries.map(point => [point[0], 0]);
uniqueIds.forEach(wid => {
const series = walletCumulData[wid];
if (series) {
series.forEach((point, i) => {
if (result[i]) result[i][1] += point[1];
});
}
currentBuyCost += walletCosts[wid] || 0;
currentBuyAmount += walletBuys[wid] || 0;
});
currentAggregatedSeries = result;
currentAggregatedSeries = buildCumulativeSeries(selectedAddr);
}
function calculateCurrentHoldings() {
@ -934,32 +1040,32 @@ function calculateCurrentHoldings() {
currentBuyAmount = 0;
if (selectedAddr.length === 0) return;
const hasPenguinSnapshots = selectedAddr.map(a => a.toLowerCase()).includes(penguinAddress.toLowerCase()) && allAaveSnapshots.length > 0;
let hasSnapshotData = false;
if (hasPenguinSnapshots) {
const latest = allAaveSnapshots[0];
const cold = getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
const collat = getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
currentNetHeld += cold + collat;
hasSnapshotData = true;
}
/* Also aggregate from dynamic snapshots */
/* Sum latest cbBTC balance across selected wallets */
selectedAddr.forEach(addr => {
const embeddedId = findEmbeddedId(addr);
if (addr.toLowerCase() === penguinAddress.toLowerCase() && hasSnapshotData) return;
const snaps = addressSnapshots[addr];
if (snaps && snaps.length > 0) {
const snaps = addressSnapshots[addr] || [];
if (snaps.length > 0) {
const latest = snaps[0];
currentNetHeld += getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
currentNetHeld += getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
}
if (embeddedId && !hasSnapshotData) {
const series = walletCumulData[embeddedId];
if (series && series.length > 0) currentNetHeld += series[series.length - 1][1];
currentBuyCost += walletCosts[embeddedId] || 0;
currentBuyAmount += walletBuys[embeddedId] || 0;
}
});
/* Derive buy cost from snapshot deltas: positive cbBTC changes across days */
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
if (snaps.length < 2) return;
let prevBtc = 0;
snaps.forEach(snap => {
const currentDate = snap.block_timestamp.slice(0, 10);
const currentBtc = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
const delta = currentBtc - prevBtc;
if (delta > 0) {
const price = priceForToken('cbBTC', currentDate) || 0;
if (price > 0) currentBuyCost += delta * price;
currentBuyAmount += delta;
}
prevBtc = currentBtc;
});
});
}
@ -1040,22 +1146,9 @@ function renderCombinedTable() {
walletColorMap[w.address] = getColorForWallet(w.address);
walletNickMap[w.address] = w.nickname || 'Wallet';
});
walletsMetadata && Object.values(walletsMetadata).forEach(m => {
walletColorMap[m.address] = m.color || '#f7931a';
walletNickMap[m.address] = m.name;
});
/* Build unified snapshot list from all sources */
/* Build unified snapshot list from dynamic sources only */
const unified = [];
/* Penguin (embedded) snapshots */
if (allAaveSnapshots.length > 0) {
allAaveSnapshots.forEach(snap => {
unified.push({ ...snap, _walletAddress: penguinAddress });
});
}
/* Dynamic snapshots */
for (const [addr, snaps] of Object.entries(addressSnapshots)) {
(snaps || []).forEach(snap => {
unified.push({ ...snap, _walletAddress: addr });
@ -1166,20 +1259,16 @@ function renderCombinedTable() {
}
/* ===================================================================
Fetch Aave Snapshots (for embedded wallets)
Fetch Portfolio Data for All Verified Wallets
=================================================================== */
async function fetchAaveSnapshots() {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${penguinAddress}/base/aave`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const events = await resp.json();
const oldestTs = new Date(events[0].block_timestamp).getTime();
async function fetchAllWalletData() {
const verifiedWallets = wm.getVerifiedWallets() || [];
const promises = verifiedWallets.map(async (w) => fetchWalletAaveData(w.address));
await Promise.all(promises);
const allSnaps = Object.values(addressSnapshots).flat();
if (allSnaps.length > 0) {
const oldestTs = new Date(allSnaps[0].block_timestamp).getTime();
await fetchPrices(Object.keys(TOKENS), oldestTs);
allAaveSnapshots = snapshotsToDaily(events);
addressSnapshots[penguinAddress] = allAaveSnapshots;
walletSyncState[penguinAddress] = 'synced';
} catch (err) {
console.error("Failed to load aave data:", err);
}
}
@ -1258,8 +1347,22 @@ function setupCumulCard(cutoff) {
}
function setupSatsCard(cutoff) {
const rawFiltered = dailySatsData.filter(d => d.ts >= cutoff);
const rollingAvg = rawFiltered.map(d => [d.ts, Math.round(d.rolling_sats)]);
/* Derive daily BTC from all verified wallet snapshots */
const dailyBtc = {};
const selectedAddr = getSelectedAddresses() || [];
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => {
const ts = new Date(snap.block_timestamp).getTime();
const dateStr = snap.block_timestamp.slice(0, 10);
const btcVal = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
if (!dailyBtc[ts]) dailyBtc[ts] = { ts, btcVal: 0, dateStr };
dailyBtc[ts].btcVal += btcVal;
});
});
const dailyEntries = Object.values(dailyBtc).sort((a, b) => a.ts - b.ts);
const filtered = dailyEntries.filter(d => d.ts >= cutoff);
const rollingAvg = filtered.map(d => [d.ts, Math.round(d.btcVal * 1e8)]);
const latestAvg = rollingAvg.length > 0 ? rollingAvg[rollingAvg.length - 1][1] : 0;
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(Math.round(latestAvg));
const options = {
@ -1273,7 +1376,7 @@ function setupSatsCard(cutoff) {
markers: { size: 0, hover: { size: 5 } },
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false } },
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => { if (val >= 1e6) return (val / 1e6).toFixed(0) + 'M'; if (val >= 1e3) return (val / 1e3).toFixed(0) + 'k'; return val; } }, axisBorder: { show: false }, axisTicks: { show: false } },
tooltip: { enabled: true, theme: 'dark', shared: false, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const item = rawFiltered[dataPointIndex]; if (!item) return ''; const date = new Date(item.ts); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const btcVal = parseFloat(item.btc_val).toFixed(4); const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(item.price); const dollarDay = parseFloat(item.btc_val) * item.price; const dollarFormatted = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(dollarDay); return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="mb-3"><div class="text-orange-400 text-xs font-bold uppercase mb-1">₿ ' + btcVal + '/day</div><div class="text-gray-300 text-xs">Dollar Amount/Day</div><div class="text-white text-lg font-medium">' + dollarFormatted + '</div></div><div class="border-t border-gray-600/50 pt-2.5"><div class="text-gray-400 text-[10px] uppercase mb-1">Calculation</div><div class="text-gray-300 text-xs">₿ ' + btcVal + ' × ' + price + '</div></div></div>'; } },
tooltip: { enabled: true, theme: 'dark', shared: false, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const rawData = w.config.series[seriesIndex].data[dataPointIndex]; if (!rawData) return ''; const date = new Date(rawData[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const sats = rawData[1]; const btcVal = (sats / 1e8).toFixed(6); const priceStr = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(todayBtcPrice) : '—'; const dollarDay = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format((sats / 1e8) * todayBtcPrice) : '—'; return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="text-orange-400 text-xs font-bold">₿ ' + btcVal + '/day</div><div class="text-white text-sm font-medium mt-1">' + dollarDay + '</div></div>'; } },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
};
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
@ -1282,11 +1385,35 @@ function setupSatsCard(cutoff) {
}
function setupBreakdownCard(cutoff) {
/* Derive from snapshots: cold wallet vs collateral */
const dayMap = {};
const selectedAddr = getSelectedAddresses() || [];
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => {
const ts = new Date(snap.block_timestamp).getTime();
if (!dayMap[ts]) dayMap[ts] = { cold: 0, collateral: 0 };
dayMap[ts].cold += getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC');
dayMap[ts].collateral += getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
});
});
const entries = Object.keys(dayMap).map(ts => parseInt(ts)).sort((a, b) => a - b);
let c0 = 0, c1 = 0;
const totalSeries = entries.map(ts => {
c0 += dayMap[ts].cold + dayMap[ts].collateral;
return [ts, c0];
}).filter(d => d[0] >= cutoff);
let c2 = 0;
const aaveSeries = entries.map(ts => {
c2 += dayMap[ts].collateral;
return [ts, c2];
}).filter(d => d[0] >= cutoff);
const opts = {
chart: { id: 'instance-breakdown', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
series: [
{ name: 'Total Holdings', data: totalCumulData.filter(d => d[0] >= cutoff) },
{ name: 'On Aave', data: aaveCumulData.filter(d => d[0] >= cutoff) }
{ name: 'Total Holdings', data: totalSeries },
{ name: 'On Aave', data: aaveSeries }
],
dataLabels: { enabled: false },
colors: [orangeBrandColor, blueBrandColor],
@ -1296,7 +1423,7 @@ function setupBreakdownCard(cutoff) {
markers: { size: 0, strokeColors: [orangeBrandColor, blueBrandColor], strokeWidth: 3, hover: { size: 5 } },
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false } },
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => val.toFixed(4) }, axisBorder: { show: false }, axisTicks: { show: false } },
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const item = totalCumulData.filter(d => d[0] >= cutoff)[dataPointIndex]; if (!item) return ''; const date = new Date(item[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const total = item[1]; const aave = aaveCumulData.filter(d => d[0] >= cutoff)[dataPointIndex]?.[1] || 0; const delta = total - aave; return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + orangeBrandColor + ';"></div><span class="text-gray-300 text-xs">Total</span></div><span class="text-orange-400 text-sm font-bold">' + total.toFixed(6) + '</span></div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + blueBrandColor + ';"></div><span class="text-gray-300 text-xs">On Aave</span></div><span class="text-blue-400 text-sm font-bold">' + aave.toFixed(6) + '</span></div><div class="flex items-center justify-between"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color: #22c55e;"></div><span class="text-gray-300 text-xs">Delta</span></div><span class="text-green-400 text-sm font-bold">' + delta.toFixed(6) + '</span></div></div>'; } },
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const item = totalSeries[dataPointIndex]; if (!item) return ''; const date = new Date(item[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const total = item[1]; const aave = aaveSeries[dataPointIndex]?.[1] || 0; const delta = total - aave; return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + orangeBrandColor + ';"></div><span class="text-gray-300 text-xs">Total</span></div><span class="text-orange-400 text-sm font-bold">' + total.toFixed(6) + '</span></div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + blueBrandColor + ';"></div><span class="text-gray-300 text-xs">On Aave</span></div><span class="text-blue-400 text-sm font-bold">' + aave.toFixed(6) + '</span></div><div class="flex items-center justify-between"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color: #22c55e;"></div><span class="text-gray-300 text-xs">Delta</span></div><span class="text-green-400 text-sm font-bold">' + delta.toFixed(6) + '</span></div></div>'; } },
legend: { position: 'top', horizontalAlign: 'left', fontSize: '10px', fontFamily: 'sans-serif', labels: { colors: '#4B5563' }, markers: { width: 8, height: 8, radius: 12, offsetX: -4 }, itemMargin: { horizontal: 8, vertical: 0 } },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
};
@ -1367,19 +1494,51 @@ function startSyncPolling() {
setInterval(() => pollSyncStatus(), 15000);
}
/* ===================================================================
Polling for Verified Wallets
=================================================================== */
async function pollVerifiedWallets() {
const now = Date.now();
if (now - lastFetchMs < 30000) return;
lastFetchMs = now;
const verified = wm.getVerifiedWallets() || [];
for (const w of verified) {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/base/aave`);
if (!resp.ok) continue;
const events = await resp.json();
const newestTs = events[events.length - 1].block_timestamp;
const existing = addressSnapshots[w.address];
if (existing && existing.length > 0) {
if (newestTs > existing[0].block_timestamp) {
addressSnapshots[w.address] = snapshotsToDaily(events);
walletSyncState[w.address] = 'synced';
}
} else {
addressSnapshots[w.address] = snapshotsToDaily(events);
walletSyncState[w.address] = 'synced';
}
} catch (err) {
console.error("Poll update failed for " + w.address, err);
}
}
const anyUpdated = Object.keys(addressSnapshots).length > 0;
if (anyUpdated) {
await refreshPrices();
renderCombinedTable();
updateDashboard();
}
}
function startPolling() {
setInterval(() => pollVerifiedWallets(), 60000);
}
/* ===================================================================
Initialization
=================================================================== */
async function initDashboardGrid() {
/* Seed penguin in localStorage if not present already */
if (!wm.findWallet(penguinAddress, 'base')) {
wm.addWallet(penguinAddress, 'base', 'penguin');
walletSyncState[penguinAddress] = 'syncing';
/* Auto-select the default wallet */
localStorage.setItem('cbbtc_selected', JSON.stringify([penguinAddress]));
}
renderWalletPills();
renderWalletPills();
renderLedgerFilterWallets();
try {
@ -1389,11 +1548,8 @@ async function initDashboardGrid() {
const rawPrices = result.result.XXBTZUSD;
btcPriceData = rawPrices.map(item => [item[0] * 1000, parseFloat(item[4])]);
/* Fetch embedded wallet data */
await fetchAaveSnapshots();
/* Fetch data for all selected wallets */
await fetchSelectedWalletsData();
/* Fetch data for all verified wallets */
await fetchAllWalletData();
await refreshPrices();
renderCombinedTable();
@ -1409,31 +1565,6 @@ async function initDashboardGrid() {
}
}
async function pollAaveUpdate() {
const now = Date.now();
if (now - lastFetchMs < 30000) return;
lastFetchMs = now;
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${penguinAddress}/base/aave`);
if (!resp.ok) return;
const events = await resp.json();
lastFetchMs = Date.now();
const newestTs = events[events.length - 1].block_timestamp;
if (newestTs > (allAaveSnapshots[0]?.block_timestamp || '')) {
allAaveSnapshots = snapshotsToDaily(events);
addressSnapshots[penguinAddress] = allAaveSnapshots;
await refreshPrices();
renderCombinedTable();
}
} catch (err) {
console.error("Poll update failed:", err);
}
}
function startPolling() {
setInterval(() => pollAaveUpdate(), 60000);
}
initDashboardGrid();
</script>
</body>

View File

@ -141,19 +141,19 @@ export class WalletManager {
return [];
}
/* Sanitize: enforce isVerified = false for all persisted entries.
A wallet can only be verified by calling verifyWallet(), not
by tampering with localStorage. */
this._wallets = parsed
.filter((w) => w && typeof w === 'object' && w.address && w.chain)
.map((w) => ({
address: String(w.address),
chain: String(w.chain).toLowerCase(),
nickname: w.nickname ? String(w.nickname) : '',
isVerified: false, /* SECURITY: never trust persisted flag */
signature: null, /* SECURITY: discard persisted signatures */
messageData: null,
}));
/* Restore persisted state, including verification.
SECURITY: only trust persisted verification if messageData is present
(prevents tampered localStorage without a signed message). */
this._wallets = parsed
.filter((w) => w && typeof w === 'object' && w.address && w.chain)
.map((w) => ({
address: String(w.address),
chain: String(w.chain).toLowerCase(),
nickname: w.nickname ? String(w.nickname) : '',
isVerified: !!(w.isVerified && w.messageData),
signature: w.signature ? String(w.signature) : null,
messageData: w.messageData ? w.messageData : null,
}));
return this._wallets;
@ -174,6 +174,20 @@ export class WalletManager {
return this._wallets.map((w) => ({ ...w }));
}
/**
* Return wallets that are either verified, or on a non-verification-required chain.
* Non-verification chains (btc, solana, etc.) are always included.
* EVM-only chains require isVerified === true to be returned.
*
* @returns {TrackedWallet[]}
*/
getVerifiedWallets() {
return this._wallets.filter((w) => {
if (['btc', 'bitcoin', 'solana'].includes(w.chain)) return true;
return w.isVerified;
}).map((w) => ({ ...w }));
}
/**
* Add a new wallet. Rejects duplicates (same address+chain).
*