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 = (() => {
|
const SafeAPI = (() => {
|
||||||
// ─── Chain Configuration ───────────────────────────────────────
|
// ─── Chain Configuration ───────────────────────────────────────
|
||||||
|
// Use direct Safe Global API URLs (api.safe.global) to avoid redirect overhead
|
||||||
const CHAINS = {
|
const CHAINS = {
|
||||||
1: { name: 'Ethereum', slug: 'mainnet', txService: 'https://safe-transaction-mainnet.safe.global', explorer: 'https://etherscan.io', color: '#627eea', symbol: 'ETH' },
|
1: { name: 'Ethereum', slug: 'eth', 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' },
|
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: 'gnosis-chain', txService: 'https://safe-transaction-gnosis-chain.safe.global', explorer: 'https://gnosisscan.io', color: '#04795b', symbol: 'xDAI' },
|
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: 'polygon', txService: 'https://safe-transaction-polygon.safe.global', explorer: 'https://polygonscan.com', color: '#8247e5', symbol: 'POL' },
|
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' },
|
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' },
|
42161: { name: 'Arbitrum', slug: 'arb1', 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' },
|
43114: { name: 'Avalanche', slug: 'avax', txService: 'https://safe-transaction-avalanche.safe.global', explorer: 'https://snowtrace.io', color: '#e84142', symbol: 'AVAX' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────
|
// ─── 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) {
|
async function getAllMultisigTransactions(address, chainId, limit = 100) {
|
||||||
const allTxs = [];
|
const allTxs = [];
|
||||||
|
|
@ -131,13 +132,14 @@ const SafeAPI = (() => {
|
||||||
if (!data || !data.results) break;
|
if (!data || !data.results) break;
|
||||||
allTxs.push(...data.results);
|
allTxs.push(...data.results);
|
||||||
url = data.next;
|
url = data.next;
|
||||||
if (allTxs.length >= 1000) break;
|
if (allTxs.length >= 500) break;
|
||||||
|
if (url) await sleep(400);
|
||||||
}
|
}
|
||||||
return allTxs;
|
return allTxs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all incoming transfers (paginated)
|
* Fetch all incoming transfers (paginated, with inter-page delays)
|
||||||
*/
|
*/
|
||||||
async function getAllIncomingTransfers(address, chainId, limit = 100) {
|
async function getAllIncomingTransfers(address, chainId, limit = 100) {
|
||||||
const allTransfers = [];
|
const allTransfers = [];
|
||||||
|
|
@ -148,7 +150,8 @@ const SafeAPI = (() => {
|
||||||
if (!data || !data.results) break;
|
if (!data || !data.results) break;
|
||||||
allTransfers.push(...data.results);
|
allTransfers.push(...data.results);
|
||||||
url = data.next;
|
url = data.next;
|
||||||
if (allTransfers.length >= 1000) break;
|
if (allTransfers.length >= 500) break;
|
||||||
|
if (url) await sleep(400);
|
||||||
}
|
}
|
||||||
return allTransfers;
|
return allTransfers;
|
||||||
}
|
}
|
||||||
|
|
@ -172,37 +175,38 @@ const SafeAPI = (() => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect which chains have a Safe deployed for this address.
|
* 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 }
|
* Returns array of { chainId, chain, safeInfo }
|
||||||
*/
|
*/
|
||||||
async function detectSafeChains(address) {
|
async function detectSafeChains(address) {
|
||||||
const entries = Object.entries(CHAINS);
|
const entries = Object.entries(CHAINS);
|
||||||
const tasks = entries.map(([chainId, chain]) => async () => {
|
const results = [];
|
||||||
|
|
||||||
|
for (const [chainId, chain] of entries) {
|
||||||
try {
|
try {
|
||||||
const info = await getSafeInfo(address, parseInt(chainId));
|
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) {
|
} catch (e) {
|
||||||
// skip
|
// skip failed chains
|
||||||
}
|
}
|
||||||
return null;
|
await sleep(500);
|
||||||
});
|
}
|
||||||
|
|
||||||
const results = await pooled(tasks, 3);
|
return results;
|
||||||
return results.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch comprehensive wallet data for a single chain.
|
* 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 }
|
* Returns { chainId, info, balances, outgoing, incoming }
|
||||||
*/
|
*/
|
||||||
async function fetchChainData(address, chainId) {
|
async function fetchChainData(address, chainId) {
|
||||||
const info = await getSafeInfo(address, chainId);
|
const info = await getSafeInfo(address, chainId);
|
||||||
await sleep(300);
|
await sleep(600);
|
||||||
const balances = await getBalances(address, chainId);
|
const balances = await getBalances(address, chainId);
|
||||||
await sleep(300);
|
await sleep(600);
|
||||||
const outgoing = await getAllMultisigTransactions(address, chainId);
|
const outgoing = await getAllMultisigTransactions(address, chainId);
|
||||||
await sleep(300);
|
await sleep(600);
|
||||||
const incoming = await getAllIncomingTransfers(address, chainId);
|
const incoming = await getAllIncomingTransfers(address, chainId);
|
||||||
|
|
||||||
return { chainId, info, balances, outgoing, incoming };
|
return { chainId, info, balances, outgoing, incoming };
|
||||||
|
|
@ -210,23 +214,23 @@ const SafeAPI = (() => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch wallet data across all detected chains.
|
* 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.
|
* Failures are non-fatal: failed chains are skipped.
|
||||||
* Returns Map<chainId, chainData>
|
* Returns Map<chainId, chainData>
|
||||||
*/
|
*/
|
||||||
async function fetchAllChainsData(address, detectedChains) {
|
async function fetchAllChainsData(address, detectedChains) {
|
||||||
const dataMap = new Map();
|
const dataMap = new Map();
|
||||||
|
|
||||||
const tasks = detectedChains.map(({ chainId }) => async () => {
|
for (const { chainId } of detectedChains) {
|
||||||
try {
|
try {
|
||||||
const data = await fetchChainData(address, chainId);
|
const data = await fetchChainData(address, chainId);
|
||||||
dataMap.set(chainId, data);
|
dataMap.set(chainId, data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Failed to fetch chain ${chainId}, skipping:`, e.message);
|
console.warn(`Failed to fetch chain ${chainId}, skipping:`, e.message);
|
||||||
}
|
}
|
||||||
});
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
await pooled(tasks, 2);
|
|
||||||
return dataMap;
|
return dataMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -358,8 +358,8 @@
|
||||||
maxBalance = d3.max(balanceData, d => d.balance) || 1;
|
maxBalance = d3.max(balanceData, d => d.balance) || 1;
|
||||||
maxTx = d3.max(txs, d => d.usd) || 1;
|
maxTx = d3.max(txs, d => d.usd) || 1;
|
||||||
|
|
||||||
balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([12, 100]);
|
balanceScale = d3.scaleLinear().domain([0, maxBalance]).range([12, 120]);
|
||||||
flowScale = d3.scaleLinear().domain([0, maxBalance]).range([0, 100 - 12]);
|
flowScale = d3.scaleLinear().domain([0, maxBalance]).range([0, 120 - 12]);
|
||||||
|
|
||||||
contentGroup = mainGroup.append('g').attr('clip-path', 'url(#chart-clip)').attr('class', 'content-group');
|
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)
|
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));
|
.attr('d', d3.line().x(d => scale(d.date)).y(d => centerY + balanceScale(d.balance) / 2).curve(smoothCurve));
|
||||||
|
|
||||||
// Flows
|
// Sankey-proportional flows: width proportional to river width at that point
|
||||||
const flowHeight = 60;
|
const flowHeight = 80;
|
||||||
let prevBalance = 0;
|
let prevBalance = 0;
|
||||||
|
|
||||||
txs.forEach(tx => {
|
txs.forEach(tx => {
|
||||||
const x = scale(tx.date);
|
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;
|
if (tx.type === 'in') prevBalance += tx.usd;
|
||||||
else prevBalance -= tx.usd;
|
else prevBalance -= tx.usd;
|
||||||
const balanceAfter = Math.max(0, prevBalance);
|
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 riverTopAfter = centerY - balanceScale(balanceAfter) / 2;
|
||||||
const riverBottomAfter = 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') {
|
if (tx.type === 'in') {
|
||||||
|
// Inflow: narrow at top (source), wide at river
|
||||||
const endY = riverTopAfter;
|
const endY = riverTopAfter;
|
||||||
const startY = endY - flowHeight;
|
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();
|
const path = d3.path();
|
||||||
path.moveTo(x - halfWidth * 0.6, startY);
|
// Left edge: narrow at top, flares at bottom
|
||||||
path.quadraticCurveTo(x - halfWidth * 1.1, midY, x - halfWidth, endY);
|
path.moveTo(x - farEndHalf, startY);
|
||||||
path.lineTo(x + halfWidth, endY);
|
path.bezierCurveTo(x - farEndHalf, cpY1, x - riverEndHalf, cpY2, x - riverEndHalf, endY);
|
||||||
path.quadraticCurveTo(x + halfWidth * 1.1, midY, x + halfWidth * 0.6, startY);
|
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();
|
path.closePath();
|
||||||
contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#inflowGradient)')
|
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('mouseover', event => showTooltip(event, tx, prevBalance))
|
||||||
.on('mousemove', event => moveTooltip(event))
|
.on('mousemove', event => moveTooltip(event))
|
||||||
.on('mouseout', hideTooltip);
|
.on('mouseout', hideTooltip);
|
||||||
} else {
|
} else {
|
||||||
const startY = riverBottomAfter;
|
// Outflow: wide at river, narrow at bottom
|
||||||
|
const startY = riverBottomBefore;
|
||||||
const endY = startY + flowHeight;
|
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();
|
const path = d3.path();
|
||||||
path.moveTo(x - halfWidth, startY);
|
// Left edge: wide at top (river), narrows at bottom
|
||||||
path.quadraticCurveTo(x - halfWidth * 1.1, midY, x - halfWidth * 0.6, endY);
|
path.moveTo(x - riverEndHalf, startY);
|
||||||
path.lineTo(x + halfWidth * 0.6, endY);
|
path.bezierCurveTo(x - riverEndHalf, cpY1, x - farEndHalf, cpY2, x - farEndHalf, endY);
|
||||||
path.quadraticCurveTo(x + halfWidth * 1.1, midY, x + halfWidth, startY);
|
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();
|
path.closePath();
|
||||||
contentGroup.append('path').attr('d', path.toString()).attr('fill', 'url(#outflowGradient)')
|
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('mouseover', event => showTooltip(event, tx, prevBalance))
|
||||||
.on('mousemove', event => moveTooltip(event))
|
.on('mousemove', event => moveTooltip(event))
|
||||||
.on('mouseout', hideTooltip);
|
.on('mouseout', hideTooltip);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue