fix: forward-fill wallet balances in chart aggregation for multi-wallet continuity

This commit is contained in:
Dione
2026-06-11 06:21:42 +00:00
parent fab41179d0
commit 4f5a28ae84

View File

@ -861,7 +861,21 @@ async function handleAddWallet(e) {
closeSidebar(); closeSidebar();
walletSyncState[address] = 'syncing'; walletSyncState[address] = 'syncing';
await _pollUntilSynced(address); /* Register the address with the backend for monitoring */
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/monitor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, chain, chain_id: CHAIN_IDS[chain] || 8453 })
});
if (!resp.ok) {
console.warn(`Backend registration returned ${resp.status} for ${address} — continuing with polling`);
}
} catch (err) {
console.warn(`Error registering wallet ${address} with backend:`, err);
}
await _pollUntilSynced(address, undefined, undefined, chain);
renderSidebar(); renderSidebar();
renderWalletPills(); renderWalletPills();
renderLedgerFilterWallets(); renderLedgerFilterWallets();
@ -934,7 +948,7 @@ async function handleVerifyWallet(address, chain, nickname) {
const result = await verifyOwnership(address, chain, nickname || w.nickname); const result = await verifyOwnership(address, chain, nickname || w.nickname);
if (result.success) { if (result.success) {
/* Fetch data now that wallet is verified */ /* Fetch data now that wallet is verified */
if (chain === 'base') fetchWalletAaveData(address); fetchWalletAaveData(address, chain);
} else { } else {
/* Remove unverified wallet on failure */ /* Remove unverified wallet on failure */
wm.removeWallet(address, chain); wm.removeWallet(address, chain);
@ -1047,8 +1061,9 @@ function renderLedgerFilterWallets() {
/* =================================================================== /* ===================================================================
Dynamic Data Fetching (Promise.all aggregation) Dynamic Data Fetching (Promise.all aggregation)
=================================================================== */ =================================================================== */
async function fetchWalletAaveData(address) { async function fetchWalletAaveData(address, chain) {
const endpoint = `/api/v1/portfolio/${address}/base/aave`; chain = chain || 'base';
const endpoint = `/api/v1/portfolio/${address}/${chain}/aave`;
try { try {
const resp = await fetch(`${API_BASE}${endpoint}`); const resp = await fetch(`${API_BASE}${endpoint}`);
if (resp.status === 400) { if (resp.status === 400) {
@ -1060,6 +1075,11 @@ async function fetchWalletAaveData(address) {
return; return;
} }
} }
if (resp.status === 404) {
walletSyncState[address] = 'pending';
renderWalletPills();
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[address] = 'synced';
@ -1073,11 +1093,12 @@ async function fetchWalletAaveData(address) {
} }
/* 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) { async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, chain) {
chain = chain || 'base';
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${address}/base/aave`); const resp = await fetch(`${API_BASE}/api/v1/portfolio/${address}/${chain}/aave`);
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) {
@ -1124,10 +1145,12 @@ async function fetchSelectedWalletsData() {
/* 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 = selectedAddr.map(async (addr) => {
const sync = walletSyncState[addr]; const sync = walletSyncState[addr];
if (sync === 'syncing') return null; /* Will retry later */ if (sync === 'syncing') return null;
if (sync === 'pending') return null; if (sync === 'pending') return null;
if (sync === 'synced' && addressSnapshots[addr]) return null; /* Cached */ if (sync === 'synced' && addressSnapshots[addr]) return null;
await fetchWalletAaveData(addr); const w = wm.getWallets().find(w => w.address === addr);
const chain = (w || {}).chain || 'base';
await fetchWalletAaveData(addr, chain);
}); });
await Promise.all(promises); await Promise.all(promises);
} }
@ -1217,31 +1240,37 @@ function getOldestTransactionDate() {
return oldestTs === Infinity ? Date.now() - 365 * 86400000 : oldestTs; return oldestTs === Infinity ? Date.now() - 365 * 86400000 : oldestTs;
} }
/* Build per-day cumulative BTC series from addressSnapshots */ /* Build per-day cumulative BTC series from addressSnapshots.
Forward-fills each wallet's balance for gap days so multi-wallet
aggregation is continuous. */
function buildCumulativeSeries(addresses) { function buildCumulativeSeries(addresses) {
const dayMap = {}; const allDates = {};
addresses.forEach(addr => { addresses.forEach(addr => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => { snaps.forEach(snap => {
const ts = new Date(snap.block_timestamp).getTime(); allDates[snap.block_timestamp.slice(0, 10)] = true;
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 sortedDates = Object.keys(allDates).sort();
const tsA = new Date(a.dateStr).getTime();
const tsB = new Date(b.dateStr).getTime(); const dayMap = {};
return tsA - tsB; addresses.forEach(addr => {
}); const snaps = addressSnapshots[addr] || [];
const byDate = new Map(); const dated = snaps.map(s => ({
sorted.forEach(d => { dateStr: s.block_timestamp.slice(0, 10),
if (!byDate.has(d.dateStr)) { btcAmt: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
byDate.set(d.dateStr, d); })).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
let balance = 0;
for (const ds of sortedDates) {
const match = dated.find(d => d.dateStr === ds);
if (match) balance = match.btcAmt;
if (!dayMap[ds]) dayMap[ds] = 0;
dayMap[ds] += balance;
} }
}); });
return Array.from(byDate.values()).map(d => [new Date(d.dateStr).getTime(), d.total]);
return sortedDates.map(dateStr => [new Date(dateStr).getTime(), dayMap[dateStr]]);
} }
function calculateAggregatedSeries() { function calculateAggregatedSeries() {
@ -1487,7 +1516,7 @@ function renderCombinedTable() {
=================================================================== */ =================================================================== */
async function fetchAllWalletData() { async function fetchAllWalletData() {
const verifiedWallets = wm.getVerifiedWallets() || []; const verifiedWallets = wm.getVerifiedWallets() || [];
const promises = verifiedWallets.map(async (w) => fetchWalletAaveData(w.address)); const promises = verifiedWallets.map(async (w) => fetchWalletAaveData(w.address, w.chain));
await Promise.all(promises); await Promise.all(promises);
const allSnaps = Object.values(addressSnapshots).flat(); const allSnaps = Object.values(addressSnapshots).flat();
if (allSnaps.length > 0) { if (allSnaps.length > 0) {
@ -1577,38 +1606,48 @@ function setupCumulCard(cutoff) {
function setupSatsCard(cutoff) { function setupSatsCard(cutoff) {
const DAY_MS = 86400000; const DAY_MS = 86400000;
/* Derive daily balances (latest snapshot per day) using UTC date strings */ /* Collect all dates across selected wallets, then forward-fill each wallet's balance */
const dailyBalances = {}; const allDates = {};
const selectedAddr = getSelectedAddresses() || []; const selectedAddr = getSelectedAddresses() || [];
selectedAddr.forEach(addr => { selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => { snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
const utcTs = new Date(snap.block_timestamp).getTime(); });
const dateStr = new Date(utcTs).toISOString().slice(0, 10); const sortedDates = Object.keys(allDates).sort();
const btcVal = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
if (!dailyBalances[dateStr] || utcTs > dailyBalances[dateStr].ts) { /* Per-wallet: forward-fill balance across all dates */
dailyBalances[dateStr] = { ts: utcTs, dateStr, btcVal }; const dailyBalances = {};
} else { selectedAddr.forEach(addr => {
dailyBalances[dateStr].btcVal += btcVal; const snaps = addressSnapshots[addr] || [];
} const dated = snaps.map(s => ({
}); dateStr: s.block_timestamp.slice(0, 10),
btcVal: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
let balance = 0;
for (const ds of sortedDates) {
const match = dated.find(d => d.dateStr === ds);
if (match) balance = match.btcVal;
if (!dailyBalances[ds]) dailyBalances[ds] = 0;
dailyBalances[ds] += balance;
}
}); });
/* Sort by date, compute daily delta (acquisition) */ /* Sort by date, compute daily delta (acquisition) */
const sortedDays = Object.values(dailyBalances).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
const dailyDeltas = {}; const dailyDeltas = {};
sortedDays.forEach((d, i) => { let prevTotal = 0;
const prevBalance = i > 0 ? sortedDays[i - 1].btcVal : 0; sortedDates.forEach(ds => {
dailyDeltas[d.dateStr] = d.btcVal - prevBalance; dailyDeltas[ds] = dailyBalances[ds] - prevTotal;
prevTotal = dailyBalances[ds];
}); });
/* Compute total sats acquired and days elapsed since first snapshot */ /* Compute total sats acquired and days elapsed since first snapshot */
const firstUTC = sortedDays.length > 0 ? Date.UTC(new Date(sortedDays[0].dateStr).getUTCFullYear(), new Date(sortedDays[0].dateStr).getUTCMonth(), new Date(sortedDays[0].dateStr).getUTCDate()) : Date.now(); const firstUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[0]).getUTCFullYear(), new Date(sortedDates[0]).getUTCMonth(), new Date(sortedDates[0]).getUTCDate()) : Date.now();
const lastUTC = sortedDays.length > 0 ? Date.UTC(new Date(sortedDays[sortedDays.length - 1].dateStr).getUTCFullYear(), new Date(sortedDays[sortedDays.length - 1].dateStr).getUTCMonth(), new Date(sortedDays[sortedDays.length - 1].dateStr).getUTCDate()) : Date.now(); const lastUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[sortedDates.length - 1]).getUTCFullYear(), new Date(sortedDates[sortedDates.length - 1]).getUTCMonth(), new Date(sortedDates[sortedDates.length - 1]).getUTCDate()) : Date.now();
const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS)); const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS));
const totalBtc = sortedDays[sortedDays.length - 1]?.btcVal || 0; const totalBtc = sortedDates.length > 0 ? dailyBalances[sortedDates[sortedDates.length - 1]] : 0;
const totalSats = Math.round(totalBtc * 1e8); const totalSats = Math.round(totalBtc * 1e8);
const avgSatsPerDay = Math.round(totalSats / daysElapsed); const avgSatsPerDay = Math.round(totalSats / daysElapsed);
/* Build continuous daily series: one data point per day */ /* Build continuous daily series: one data point per day */
const filteredSats = []; const filteredSats = [];
@ -1643,23 +1682,39 @@ function setupSatsCard(cutoff) {
function setupBreakdownCard(cutoff) { function setupBreakdownCard(cutoff) {
/* Derive from snapshots: cold wallet vs collateral */ /* Derive from snapshots: cold wallet vs collateral */
const dayMap = {}; const allDates = {};
const selectedAddr = getSelectedAddresses() || []; const selectedAddr = getSelectedAddresses() || [];
selectedAddr.forEach(addr => { selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || []; const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => { snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
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); const sortedDates = Object.keys(allDates).sort();
const totalSeries = entries.map(ts => {
return [ts, dayMap[ts].cold + dayMap[ts].collateral]; const dayMap = {};
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
const dated = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
cold: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC'),
collateral: getTokenAmount(s?.collateral?.cbBTC, 'cbBTC')
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
let cold = 0, coll = 0;
for (const ds of sortedDates) {
const match = dated.find(d => d.dateStr === ds);
if (match) { cold = match.cold; coll = match.collateral; }
if (!dayMap[ds]) dayMap[ds] = { cold: 0, collateral: 0 };
dayMap[ds].cold += cold;
dayMap[ds].collateral += coll;
}
});
const entries = sortedDates;
const totalSeries = entries.map(dateStr => {
return [new Date(dateStr).getTime(), dayMap[dateStr].cold + dayMap[dateStr].collateral];
}).filter(d => d[0] >= cutoff); }).filter(d => d[0] >= cutoff);
const aaveSeries = entries.map(ts => { const aaveSeries = entries.map(dateStr => {
return [ts, dayMap[ts].collateral]; return [new Date(dateStr).getTime(), dayMap[dateStr].collateral];
}).filter(d => d[0] >= cutoff); }).filter(d => d[0] >= cutoff);
const opts = { const opts = {
@ -1730,7 +1785,7 @@ document.querySelectorAll('.filter-checkbox').forEach(cb => {
=================================================================== */ =================================================================== */
async function pollSyncStatus() { async function pollSyncStatus() {
const selectedAddr = getSelectedAddresses(); const selectedAddr = getSelectedAddresses();
const syncing = selectedAddr.filter(a => walletSyncState[a] === 'syncing'); const syncing = selectedAddr.filter(a => walletSyncState[a] === 'syncing' || walletSyncState[a] === 'pending');
if (syncing.length === 0) return; if (syncing.length === 0) return;
const promises = syncing.map(async (addr) => { const promises = syncing.map(async (addr) => {
try { try {