From f197a20d0fb8e8c12c3d4def904b57b2b956ca53 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 12:50:59 -0700 Subject: [PATCH] fix: improve balance river accuracy, scaling, and waterfall flow direction Switch to all-transactions API endpoint for proper tokenInfo with correct decimals (fixes ERC20 amounts like USDC/6 decimals being treated as 18). Fix flow scale to use maxTx instead of maxBalance so individual flows are visible. Change waterfall flows from vertical to diagonal down-right curves. Co-Authored-By: Claude Opus 4.6 --- js/safe-api.js | 39 +++++++++++-- wallet-timeline-visualization.html | 90 ++++++++++++++++++------------ 2 files changed, 87 insertions(+), 42 deletions(-) diff --git a/js/safe-api.js b/js/safe-api.js index 8ae1621..5f6def5 100644 --- a/js/safe-api.js +++ b/js/safe-api.js @@ -157,7 +157,8 @@ const SafeAPI = (() => { } /** - * Fetch all-transactions (combines multisig + module + incoming) + * Fetch all-transactions (combines multisig + module + incoming, with inter-page delays) + * Returns enriched transactions with transfers[] containing proper tokenInfo. */ async function getAllTransactions(address, chainId, limit = 100) { const allTxs = []; @@ -168,7 +169,8 @@ const SafeAPI = (() => { if (!data || !data.results) break; allTxs.push(...data.results); url = data.next; - if (allTxs.length >= 1000) break; + if (allTxs.length >= 3000) break; + if (url) await sleep(400); } return allTxs; } @@ -197,7 +199,7 @@ const SafeAPI = (() => { /** * Fetch comprehensive wallet data for a single chain. - * Sequential within the same chain with delays to respect rate limits. + * Uses all-transactions endpoint for enriched transfer data with proper tokenInfo. * Returns { chainId, info, balances, outgoing, incoming } */ async function fetchChainData(address, chainId) { @@ -205,9 +207,34 @@ const SafeAPI = (() => { await sleep(600); const balances = await getBalances(address, chainId); await sleep(600); - const outgoing = await getAllMultisigTransactions(address, chainId); - await sleep(600); - const incoming = await getAllIncomingTransfers(address, chainId); + + // all-transactions includes transfers[] with proper tokenInfo (decimals, symbol) + // This is more accurate than parsing dataDecoded which loses decimal info + const allTxs = await getAllTransactions(address, chainId); + + const addrLower = address.toLowerCase(); + const outgoing = []; + const incoming = []; + + for (const tx of allTxs) { + // Collect multisig transactions as outgoing (they have transfers[] with tokenInfo) + if (tx.txType === 'MULTISIG_TRANSACTION') { + outgoing.push(tx); + } + + // Extract incoming transfers from all transaction transfer events + if (tx.transfers) { + for (const t of tx.transfers) { + if (t.to?.toLowerCase() === addrLower && + t.from?.toLowerCase() !== addrLower) { + incoming.push({ + ...t, + executionDate: t.executionDate || tx.executionDate, + }); + } + } + } + } return { chainId, info, balances, outgoing, incoming }; } diff --git a/wallet-timeline-visualization.html b/wallet-timeline-visualization.html index 76a4d4f..d48511d 100644 --- a/wallet-timeline-visualization.html +++ b/wallet-timeline-visualization.html @@ -162,7 +162,7 @@ let currentFilter = { chain: 'all', token: 'all' }; let currentZoomTransform = d3.zoomIdentity; let svgElement, mainGroup, xScale, xAxisGroup, contentGroup; - let txs, balanceData, maxBalance, maxTx, balanceScale, flowScale; + let txs, balanceData, maxBalance, maxTx, balanceScale; let margin, width, height, centerY, timeExtent, timePadding; async function loadWallet(address) { @@ -315,19 +315,17 @@ const defs = mainGroup.append('defs'); - const inflowGradient = defs.append('linearGradient').attr('id', 'inflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%'); - inflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.3); - inflowGradient.append('stop').attr('offset', '30%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.8); - inflowGradient.append('stop').attr('offset', '60%').attr('stop-color', '#22d3ee').attr('stop-opacity', 0.9); - inflowGradient.append('stop').attr('offset', '85%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.95); + const inflowGradient = defs.append('linearGradient').attr('id', 'inflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '100%').attr('y2', '100%'); + inflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.4); + inflowGradient.append('stop').attr('offset', '40%').attr('stop-color', '#4ade80').attr('stop-opacity', 0.85); + inflowGradient.append('stop').attr('offset', '70%').attr('stop-color', '#22d3ee').attr('stop-opacity', 0.9); inflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#00d4ff').attr('stop-opacity', 1); - const outflowGradient = defs.append('linearGradient').attr('id', 'outflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%'); + const outflowGradient = defs.append('linearGradient').attr('id', 'outflowGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '100%').attr('y2', '100%'); outflowGradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff').attr('stop-opacity', 1); - outflowGradient.append('stop').attr('offset', '15%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.95); - outflowGradient.append('stop').attr('offset', '40%').attr('stop-color', '#f472b6').attr('stop-opacity', 0.9); - outflowGradient.append('stop').attr('offset', '70%').attr('stop-color', '#f87171').attr('stop-opacity', 0.8); - outflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#f87171').attr('stop-opacity', 0.3); + outflowGradient.append('stop').attr('offset', '30%').attr('stop-color', '#f472b6').attr('stop-opacity', 0.9); + outflowGradient.append('stop').attr('offset', '60%').attr('stop-color', '#f87171').attr('stop-opacity', 0.85); + outflowGradient.append('stop').attr('offset', '100%').attr('stop-color', '#f87171').attr('stop-opacity', 0.4); const riverGradient = defs.append('linearGradient').attr('id', 'riverGradient').attr('x1', '0%').attr('y1', '0%').attr('x2', '0%').attr('y2', '100%'); riverGradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff').attr('stop-opacity', 0.9); @@ -358,8 +356,7 @@ maxBalance = d3.max(balanceData, d => d.balance) || 1; maxTx = d3.max(txs, d => d.usd) || 1; - balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([12, 120]); - flowScale = d3.scaleLinear().domain([0, maxBalance]).range([0, 120 - 12]); + balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([8, 120]); contentGroup = mainGroup.append('g').attr('clip-path', 'url(#chart-clip)').attr('class', 'content-group'); @@ -397,10 +394,10 @@ if (currentZoomTransform !== d3.zoomIdentity) svgElement.call(zoom.transform, currentZoomTransform); window.currentZoom = zoom; - mainGroup.append('text').attr('x', width / 2).attr('y', -60).attr('text-anchor', 'middle') - .attr('fill', '#4ade80').attr('font-size', '14px').attr('font-weight', 'bold').text('INFLOWS'); - mainGroup.append('text').attr('x', width / 2).attr('y', height + 65).attr('text-anchor', 'middle') - .attr('fill', '#f87171').attr('font-size', '14px').attr('font-weight', 'bold').text('OUTFLOWS'); + mainGroup.append('text').attr('x', 30).attr('y', -60).attr('text-anchor', 'start') + .attr('fill', '#4ade80').attr('font-size', '13px').attr('font-weight', 'bold').attr('opacity', 0.8).text('INFLOWS ↘'); + mainGroup.append('text').attr('x', width - 30).attr('y', height + 65).attr('text-anchor', 'end') + .attr('fill', '#f87171').attr('font-size', '13px').attr('font-weight', 'bold').attr('opacity', 0.8).text('↘ OUTFLOWS'); } function zoomed(event) { @@ -444,8 +441,9 @@ contentGroup.append('path').datum(balanceData).attr('fill', 'none').attr('stroke', 'rgba(0,0,0,0.2)').attr('stroke-width', 1) .attr('d', d3.line().x(d => scale(d.date)).y(d => centerY + balanceScale(d.balance) / 2).curve(smoothCurve)); - // Sankey-proportional flows: width proportional to river width at that point + // Diagonal waterfall flows with sankey-proportional widths const flowHeight = 80; + const xOffset = flowHeight * 0.7; // horizontal displacement for diagonal let prevBalance = 0; txs.forEach(tx => { @@ -472,18 +470,28 @@ const riverBottomBefore = centerY + balanceScale(balanceBefore) / 2; if (tx.type === 'in') { - // Inflow: narrow at top (source), wide at river + // Inflow waterfall: originates above-left, flows diagonally down-right into river const endY = riverTopAfter; const startY = endY - flowHeight; - const cpY1 = startY + flowHeight * 0.55; - const cpY2 = startY + flowHeight * 0.75; + const startX = x - xOffset; + const endX = x; + const path = d3.path(); - // Left edge: narrow at top, flares at bottom - path.moveTo(x - farEndHalf, startY); - path.bezierCurveTo(x - farEndHalf, cpY1, x - riverEndHalf, cpY2, x - riverEndHalf, endY); - path.lineTo(x + riverEndHalf, endY); - // Right edge: flares at bottom, narrow at top - path.bezierCurveTo(x + riverEndHalf, cpY2, x + farEndHalf, cpY1, x + farEndHalf, startY); + // Left edge: narrow at top-left, curves down-right to river + path.moveTo(startX - farEndHalf, startY); + path.bezierCurveTo( + startX - farEndHalf, startY + flowHeight * 0.55, + endX - riverEndHalf, endY - flowHeight * 0.45, + endX - riverEndHalf, endY + ); + // Bottom: full proportional width at river surface + path.lineTo(endX + riverEndHalf, endY); + // Right edge: curve back up-left to narrow top + path.bezierCurveTo( + endX + riverEndHalf, endY - flowHeight * 0.45, + startX + farEndHalf, startY + flowHeight * 0.55, + startX + farEndHalf, startY + ); path.closePath(); contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#inflowGradient)') .attr('class', 'flow-path').attr('opacity', 0.85) @@ -491,18 +499,28 @@ .on('mousemove', event => moveTooltip(event)) .on('mouseout', hideTooltip); } else { - // Outflow: wide at river, narrow at bottom + // Outflow waterfall: exits river bottom, flows diagonally down-right away const startY = riverBottomBefore; const endY = startY + flowHeight; - const cpY1 = startY + flowHeight * 0.25; - const cpY2 = startY + flowHeight * 0.45; + const startX = x; + const endX = x + xOffset; + const path = d3.path(); - // Left edge: wide at top (river), narrows at bottom - path.moveTo(x - riverEndHalf, startY); - path.bezierCurveTo(x - riverEndHalf, cpY1, x - farEndHalf, cpY2, x - farEndHalf, endY); - path.lineTo(x + farEndHalf, endY); - // Right edge: narrows at bottom, wide at top - path.bezierCurveTo(x + farEndHalf, cpY2, x + riverEndHalf, cpY1, x + riverEndHalf, startY); + // Left edge: wide at river, curves down-right to narrow end + path.moveTo(startX - riverEndHalf, startY); + path.bezierCurveTo( + startX - riverEndHalf, startY + flowHeight * 0.45, + endX - farEndHalf, endY - flowHeight * 0.55, + endX - farEndHalf, endY + ); + // Bottom: narrow end + path.lineTo(endX + farEndHalf, endY); + // Right edge: curve back up to river + path.bezierCurveTo( + endX + farEndHalf, endY - flowHeight * 0.55, + startX + riverEndHalf, startY + flowHeight * 0.45, + startX + riverEndHalf, startY + ); path.closePath(); contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#outflowGradient)') .attr('class', 'flow-path').attr('opacity', 0.85)