fix: sequential Safe API calls + sankey-proportional waterfalls

Fix 429 rate limiting by making chain detection and data fetching
sequential with delays. Update waterfall viz to use bezier curves with
widths proportional to river width at entry/exit points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 10:32:09 +00:00
parent 900c93793d
commit eb5f93e445
2 changed files with 70 additions and 46 deletions

View File

@ -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<chainId, chainData>
*/
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;
}

View File

@ -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);