diff --git a/js/safe-api.js b/js/safe-api.js index a7605c2..8ae1621 100644 --- a/js/safe-api.js +++ b/js/safe-api.js @@ -6,14 +6,15 @@ const SafeAPI = (() => { // ─── Chain Configuration ─────────────────────────────────────── + // Use direct Safe Global API URLs (api.safe.global) to avoid redirect overhead const CHAINS = { - 1: { name: 'Ethereum', slug: 'mainnet', txService: 'https://safe-transaction-mainnet.safe.global', explorer: 'https://etherscan.io', color: '#627eea', symbol: 'ETH' }, - 10: { name: 'Optimism', slug: 'optimism', txService: 'https://safe-transaction-optimism.safe.global', explorer: 'https://optimistic.etherscan.io', color: '#ff0420', symbol: 'ETH' }, - 100: { name: 'Gnosis', slug: 'gnosis-chain', txService: 'https://safe-transaction-gnosis-chain.safe.global', explorer: 'https://gnosisscan.io', color: '#04795b', symbol: 'xDAI' }, - 137: { name: 'Polygon', slug: 'polygon', txService: 'https://safe-transaction-polygon.safe.global', explorer: 'https://polygonscan.com', color: '#8247e5', symbol: 'POL' }, - 8453: { name: 'Base', slug: 'base', txService: 'https://safe-transaction-base.safe.global', explorer: 'https://basescan.org', color: '#0052ff', symbol: 'ETH' }, - 42161: { name: 'Arbitrum', slug: 'arbitrum', txService: 'https://safe-transaction-arbitrum.safe.global', explorer: 'https://arbiscan.io', color: '#28a0f0', symbol: 'ETH' }, - 43114: { name: 'Avalanche', slug: 'avalanche', txService: 'https://safe-transaction-avalanche.safe.global', explorer: 'https://snowtrace.io', color: '#e84142', symbol: 'AVAX' }, + 1: { name: 'Ethereum', slug: 'eth', txService: 'https://safe-transaction-mainnet.safe.global', explorer: 'https://etherscan.io', color: '#627eea', symbol: 'ETH' }, + 10: { name: 'Optimism', slug: 'oeth', txService: 'https://safe-transaction-optimism.safe.global', explorer: 'https://optimistic.etherscan.io', color: '#ff0420', symbol: 'ETH' }, + 100: { name: 'Gnosis', slug: 'gno', txService: 'https://safe-transaction-gnosis-chain.safe.global', explorer: 'https://gnosisscan.io', color: '#04795b', symbol: 'xDAI' }, + 137: { name: 'Polygon', slug: 'pol', txService: 'https://safe-transaction-polygon.safe.global', explorer: 'https://polygonscan.com', color: '#8247e5', symbol: 'POL' }, + 8453: { name: 'Base', slug: 'base', txService: 'https://safe-transaction-base.safe.global', explorer: 'https://basescan.org', color: '#0052ff', symbol: 'ETH' }, + 42161: { name: 'Arbitrum', slug: 'arb1', txService: 'https://safe-transaction-arbitrum.safe.global', explorer: 'https://arbiscan.io', color: '#28a0f0', symbol: 'ETH' }, + 43114: { name: 'Avalanche', slug: 'avax', txService: 'https://safe-transaction-avalanche.safe.global', explorer: 'https://snowtrace.io', color: '#e84142', symbol: 'AVAX' }, }; // ─── Helpers ─────────────────────────────────────────────────── @@ -120,7 +121,7 @@ const SafeAPI = (() => { } /** - * Fetch all multisig transactions (paginated) + * Fetch all multisig transactions (paginated, with inter-page delays) */ async function getAllMultisigTransactions(address, chainId, limit = 100) { const allTxs = []; @@ -131,13 +132,14 @@ const SafeAPI = (() => { if (!data || !data.results) break; allTxs.push(...data.results); url = data.next; - if (allTxs.length >= 1000) break; + if (allTxs.length >= 500) break; + if (url) await sleep(400); } return allTxs; } /** - * Fetch all incoming transfers (paginated) + * Fetch all incoming transfers (paginated, with inter-page delays) */ async function getAllIncomingTransfers(address, chainId, limit = 100) { const allTransfers = []; @@ -148,7 +150,8 @@ const SafeAPI = (() => { if (!data || !data.results) break; allTransfers.push(...data.results); url = data.next; - if (allTransfers.length >= 1000) break; + if (allTransfers.length >= 500) break; + if (url) await sleep(400); } return allTransfers; } @@ -172,37 +175,38 @@ const SafeAPI = (() => { /** * Detect which chains have a Safe deployed for this address. - * Uses concurrency pool of 3 to avoid global rate limits. + * Sequential with delays to avoid rate limits across the shared Safe API. * Returns array of { chainId, chain, safeInfo } */ async function detectSafeChains(address) { const entries = Object.entries(CHAINS); - const tasks = entries.map(([chainId, chain]) => async () => { + const results = []; + + for (const [chainId, chain] of entries) { try { const info = await getSafeInfo(address, parseInt(chainId)); - if (info) return { chainId: parseInt(chainId), chain, safeInfo: info }; + if (info) results.push({ chainId: parseInt(chainId), chain, safeInfo: info }); } catch (e) { - // skip + // skip failed chains } - return null; - }); + await sleep(500); + } - const results = await pooled(tasks, 3); - return results.filter(Boolean); + return results; } /** * Fetch comprehensive wallet data for a single chain. - * Sequential within the same chain with small delays. + * Sequential within the same chain with delays to respect rate limits. * Returns { chainId, info, balances, outgoing, incoming } */ async function fetchChainData(address, chainId) { const info = await getSafeInfo(address, chainId); - await sleep(300); + await sleep(600); const balances = await getBalances(address, chainId); - await sleep(300); + await sleep(600); const outgoing = await getAllMultisigTransactions(address, chainId); - await sleep(300); + await sleep(600); const incoming = await getAllIncomingTransfers(address, chainId); return { chainId, info, balances, outgoing, incoming }; @@ -210,23 +214,23 @@ const SafeAPI = (() => { /** * Fetch wallet data across all detected chains. - * Concurrency pool of 2 chains at a time — fast but gentle. + * Sequential to avoid overwhelming the Safe API rate limits. * Failures are non-fatal: failed chains are skipped. * Returns Map */ async function fetchAllChainsData(address, detectedChains) { const dataMap = new Map(); - const tasks = detectedChains.map(({ chainId }) => async () => { + for (const { chainId } of detectedChains) { try { const data = await fetchChainData(address, chainId); dataMap.set(chainId, data); } catch (e) { console.warn(`Failed to fetch chain ${chainId}, skipping:`, e.message); } - }); + await sleep(500); + } - await pooled(tasks, 2); return dataMap; } diff --git a/wallet-timeline-visualization.html b/wallet-timeline-visualization.html index dd5f65d..76a4d4f 100644 --- a/wallet-timeline-visualization.html +++ b/wallet-timeline-visualization.html @@ -358,8 +358,8 @@ maxBalance = d3.max(balanceData, d => d.balance) || 1; maxTx = d3.max(txs, d => d.usd) || 1; - balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([12, 100]); - flowScale = d3.scaleLinear().domain([0, maxBalance]).range([0, 100 - 12]); + balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([12, 120]); + flowScale = d3.scaleLinear().domain([0, maxBalance]).range([0, 120 - 12]); contentGroup = mainGroup.append('g').attr('clip-path', 'url(#chart-clip)').attr('class', 'content-group'); @@ -444,48 +444,68 @@ 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)); - // Flows - const flowHeight = 60; + // Sankey-proportional flows: width proportional to river width at that point + const flowHeight = 80; let prevBalance = 0; txs.forEach(tx => { const x = scale(tx.date); - const flowWidth = flowScale(tx.usd); - const halfWidth = Math.max(flowWidth / 2, 3); + // Compute balance before and after to get river width at this point + const balanceBefore = Math.max(0, prevBalance); if (tx.type === 'in') prevBalance += tx.usd; else prevBalance -= tx.usd; const balanceAfter = Math.max(0, prevBalance); + + // Sankey: flow width is proportional to river width * (tx.usd / balance) + const relevantBalance = tx.type === 'in' ? balanceAfter : balanceBefore; + const riverW = balanceScale(relevantBalance); + const proportion = relevantBalance > 0 ? Math.min(1, tx.usd / relevantBalance) : 0.5; + // River-end width = proportion of the river + const riverEndHalf = Math.max(4, (proportion * riverW) / 2); + // Far-end width = narrower (30% of river end for dramatic taper) + const farEndHalf = Math.max(2, riverEndHalf * 0.3); + const riverTopAfter = centerY - balanceScale(balanceAfter) / 2; const riverBottomAfter = centerY + balanceScale(balanceAfter) / 2; + const riverTopBefore = centerY - balanceScale(balanceBefore) / 2; + const riverBottomBefore = centerY + balanceScale(balanceBefore) / 2; if (tx.type === 'in') { + // Inflow: narrow at top (source), wide at river const endY = riverTopAfter; const startY = endY - flowHeight; - const midY = startY + flowHeight * 0.5; + const cpY1 = startY + flowHeight * 0.55; + const cpY2 = startY + flowHeight * 0.75; const path = d3.path(); - path.moveTo(x - halfWidth * 0.6, startY); - path.quadraticCurveTo(x - halfWidth * 1.1, midY, x - halfWidth, endY); - path.lineTo(x + halfWidth, endY); - path.quadraticCurveTo(x + halfWidth * 1.1, midY, x + halfWidth * 0.6, startY); + // 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); path.closePath(); contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#inflowGradient)') - .attr('class', 'flow-path').attr('opacity', 0.9) + .attr('class', 'flow-path').attr('opacity', 0.85) .on('mouseover', event => showTooltip(event, tx, prevBalance)) .on('mousemove', event => moveTooltip(event)) .on('mouseout', hideTooltip); } else { - const startY = riverBottomAfter; + // Outflow: wide at river, narrow at bottom + const startY = riverBottomBefore; const endY = startY + flowHeight; - const midY = startY + flowHeight * 0.5; + const cpY1 = startY + flowHeight * 0.25; + const cpY2 = startY + flowHeight * 0.45; const path = d3.path(); - path.moveTo(x - halfWidth, startY); - path.quadraticCurveTo(x - halfWidth * 1.1, midY, x - halfWidth * 0.6, endY); - path.lineTo(x + halfWidth * 0.6, endY); - path.quadraticCurveTo(x + halfWidth * 1.1, midY, x + halfWidth, startY); + // 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); path.closePath(); contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#outflowGradient)') - .attr('class', 'flow-path').attr('opacity', 0.9) + .attr('class', 'flow-path').attr('opacity', 0.85) .on('mouseover', event => showTooltip(event, tx, prevBalance)) .on('mousemove', event => moveTooltip(event)) .on('mouseout', hideTooltip);