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:
parent
900c93793d
commit
eb5f93e445
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue