From 0afb85e9f7a05509d42c47c6e682b84192f45efb Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 10:32:07 +0000 Subject: [PATCH] feat: sankey-proportional waterfalls + multi-chain Safe support Rewrite budget river waterfalls with bezier-curved tapered shapes where width encodes flow magnitude (inflows flare into river, outflows taper out). Add Ethereum, Base, Polygon, Arbitrum to Safe chain detection. Fetch real transaction history for live inflow rates. Co-Authored-By: Claude Opus 4.6 --- app/globals.css | 5 + app/river/page.tsx | 14 +- components/BudgetRiver.tsx | 371 +++++++++++++++++++++----------- components/IntegrationPanel.tsx | 2 +- lib/api/safe-client.ts | 140 +++++++++++- lib/integrations.ts | 18 +- 6 files changed, 415 insertions(+), 135 deletions(-) diff --git a/app/globals.css b/app/globals.css index d901bdf..b986fed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -65,3 +65,8 @@ body { 50% { opacity: 0.7; } 100% { opacity: 0.3; } } + +@keyframes mergeSplash { + 0% { rx: 2; ry: 1; opacity: 0.5; } + 100% { rx: 20; ry: 4; opacity: 0; } +} diff --git a/app/river/page.tsx b/app/river/page.tsx index f264d68..af0d05c 100644 --- a/app/river/page.tsx +++ b/app/river/page.tsx @@ -4,7 +4,7 @@ import dynamic from 'next/dynamic' import Link from 'next/link' import { useState, useCallback, useEffect } from 'react' import { demoNodes } from '@/lib/presets' -import { detectSafeChains, getBalances } from '@/lib/api/safe-client' +import { detectSafeChains, getBalances, computeTransferSummary } from '@/lib/api/safe-client' import { safeBalancesToFunnels } from '@/lib/integrations' import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client' import type { FlowNode, FunnelNodeData } from '@/lib/types' @@ -52,7 +52,7 @@ export default function RiverPage() { localStorage.setItem('rfunds-owner-address', safeAddress.trim()) const detected = await detectSafeChains(safeAddress.trim()) if (detected.length === 0) { - showStatus('No Safe found on supported chains (Gnosis, Optimism)', 'error') + showStatus('No Safe found on supported chains (Ethereum, Base, Polygon, Arbitrum, Optimism, Gnosis)', 'error') setConnecting(false) return } @@ -60,14 +60,18 @@ export default function RiverPage() { const chainNames = detected.map(d => d.chain.name) setConnectedChains(chainNames) - // Fetch balances from all detected chains and create funnel nodes + // Fetch balances + transfer history from all detected chains const allFunnelNodes: FlowNode[] = [] for (const chain of detected) { - const balances = await getBalances(safeAddress.trim(), chain.chainId) + const [balances, transferSummary] = await Promise.all([ + getBalances(safeAddress.trim(), chain.chainId), + computeTransferSummary(safeAddress.trim(), chain.chainId), + ]) const funnels = safeBalancesToFunnels( balances, safeAddress.trim(), chain.chainId, + transferSummary, { x: allFunnelNodes.length * 280, y: 100 } ) allFunnelNodes.push(...funnels) @@ -282,7 +286,7 @@ export default function RiverPage() {
e.stopPropagation()}>

Connect Safe Treasury

- Enter a Safe address to load real token balances from Gnosis and Optimism chains. + Enter a Safe address to load real token balances across Ethereum, Base, Polygon, Arbitrum, Optimism, and Gnosis.

s + p, 0) + if (totalPct === 0) return percentages.map(() => minWidth) + + let widths = percentages.map(p => (p / totalPct) * totalAvailable) + + // Enforce minimums: bump small ones up, proportionally reduce large ones + const belowMin = widths.filter(w => w < minWidth) + if (belowMin.length > 0 && belowMin.length < widths.length) { + const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0) + const aboveMinTotal = widths.filter(w => w >= minWidth).reduce((s, w) => s + w, 0) + + widths = widths.map(w => { + if (w < minWidth) return minWidth + return Math.max(minWidth, w - (w / aboveMinTotal) * deficit) + }) + } + + return widths +} + // ─── Layout Engine ─────────────────────────────────────── function computeLayout(nodes: FlowNode[]): RiverLayout { @@ -121,15 +154,12 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { }) // Root funnels = funnels that are NOT overflow targets of other funnels - // (but may be source targets) const rootFunnels = funnelNodes.filter(n => !overflowTargets.has(n.id)) - const childFunnels = funnelNodes.filter(n => overflowTargets.has(n.id)) - // Assign layers + // Assign layers via BFS const funnelLayers = new Map() rootFunnels.forEach(n => funnelLayers.set(n.id, 0)) - // BFS to assign layers to overflow children const queue = [...rootFunnels] while (queue.length > 0) { const current = queue.shift()! @@ -144,13 +174,11 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { }) } - // Find max flow rate for normalization + // Find max flow rate for implicit waterfall normalization const allFlowRates = funnelNodes.map(n => (n.data as FunnelNodeData).inflowRate || 1) const maxFlowRate = Math.max(...allFlowRates, 1) - const maxValue = Math.max(...funnelNodes.map(n => (n.data as FunnelNodeData).currentValue || 1), 1) - // Compute funnel layouts - // Group by layer, center each layer + // Group funnels by layer, center each layer const layerGroups = new Map() funnelNodes.forEach(n => { const layer = funnelLayers.get(n.id) ?? 0 @@ -163,7 +191,6 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP const funnelLayouts: FunnelLayout[] = [] - const layerXRanges = new Map() for (let layer = 0; layer <= maxLayer; layer++) { const layerNodes = layerGroups.get(layer) || [] @@ -190,11 +217,6 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { layer, status, }) - - const range = layerXRanges.get(layer) || { minX: Infinity, maxX: -Infinity } - range.minX = Math.min(range.minX, x) - range.maxX = Math.max(range.maxX, x + SEGMENT_LENGTH) - layerXRanges.set(layer, range) }) } @@ -212,19 +234,56 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { } }) - // Source waterfalls (source → root funnel) + // ─── Source waterfalls (sankey-proportional inflows) ────── + + // First pass: collect all inflows per funnel to compute shares + const inflowsByFunnel = new Map() + + sourceNodes.forEach(sn => { + const data = sn.data as SourceNodeData + data.targetAllocations?.forEach((alloc, i) => { + const flowAmount = (alloc.percentage / 100) * data.flowRate + if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []) + inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage }) + }) + }) + const sourceWaterfalls: WaterfallLayout[] = [] + sourceNodes.forEach(sn => { const data = sn.data as SourceNodeData const sourceLayout = sourceLayouts.find(s => s.id === sn.id) if (!sourceLayout) return - data.targetAllocations?.forEach((alloc, i) => { + data.targetAllocations?.forEach((alloc, allocIdx) => { const targetLayout = funnelLayouts.find(f => f.id === alloc.targetId) if (!targetLayout) return const flowAmount = (alloc.percentage / 100) * data.flowRate - const width = Math.max(6, (flowAmount / Math.max(data.flowRate, 1)) * 30) + const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [] + const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0) + + // Sankey width: proportional share of the target river width + const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1 + const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth) + + // Far end width: proportional share of source box width + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8) + + // Position along river top edge: distribute side by side + const myIndex = allInflowsToTarget.findIndex(i => i.sourceNodeId === sn.id && i.allocIndex === allocIdx) + const inflowWidths = distributeWidths( + allInflowsToTarget.map(i => i.flowAmount), + targetLayout.segmentLength * 0.7, + MIN_WATERFALL_WIDTH + ) + const startX = targetLayout.x + targetLayout.segmentLength * 0.15 + let offsetX = 0 + for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k] + const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2 + + // Source center x + const sourceCenterX = sourceLayout.x + sourceLayout.width / 2 sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, @@ -232,17 +291,21 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, - x: sourceLayout.x + sourceLayout.width / 2 + (i - (data.targetAllocations.length - 1) / 2) * 30, + x: riverCenterX, + xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, - width, + width: riverEndWidth, + riverEndWidth, + farEndWidth, + direction: 'inflow', color: COLORS.sourceWaterfall, flowAmount, }) }) }) - // If no source nodes, create implicit waterfalls for root funnels with inflowRate + // Implicit waterfalls for root funnels with inflowRate but no source nodes if (sourceNodes.length === 0) { rootFunnels.forEach(rn => { const data = rn.data as FunnelNodeData @@ -250,6 +313,9 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { const layout = funnelLayouts.find(f => f.id === rn.id) if (!layout) return + const riverEndWidth = layout.riverWidth + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4) + sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: 'implicit', @@ -257,28 +323,32 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, + xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, - width: Math.max(8, (data.inflowRate / maxFlowRate) * 30), + width: riverEndWidth, + riverEndWidth, + farEndWidth, + direction: 'inflow', color: COLORS.sourceWaterfall, flowAmount: data.inflowRate, }) }) } - // Overflow branches (funnel → child funnel) + // ─── Overflow branches (sankey-proportional) ────── + const overflowBranches: BranchLayout[] = [] funnelNodes.forEach(n => { const data = n.data as FunnelNodeData const parentLayout = funnelLayouts.find(f => f.id === n.id) if (!parentLayout) return - data.overflowAllocations?.forEach((alloc, i) => { + data.overflowAllocations?.forEach((alloc) => { const childLayout = funnelLayouts.find(f => f.id === alloc.targetId) if (!childLayout) return - const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) - const width = Math.max(4, (alloc.percentage / 100) * parentLayout.riverWidth * 0.6) + const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth) overflowBranches.push({ sourceId: n.id, @@ -294,7 +364,8 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { }) }) - // Outcome layouts + // ─── Outcome layouts ────── + const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => { @@ -314,19 +385,43 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { } }) - // Spending waterfalls (funnel → outcome) + // ─── Spending waterfalls (sankey-proportional outflows) ────── + const spendingWaterfalls: WaterfallLayout[] = [] funnelNodes.forEach(n => { const data = n.data as FunnelNodeData const parentLayout = funnelLayouts.find(f => f.id === n.id) if (!parentLayout) return - data.spendingAllocations?.forEach((alloc, i) => { + const allocations = data.spendingAllocations || [] + if (allocations.length === 0) return + + // Distribute outflows along the river bottom edge + const percentages = allocations.map(a => a.percentage) + const slotWidths = distributeWidths( + percentages, + parentLayout.segmentLength * 0.7, + MIN_WATERFALL_WIDTH + ) + const riverEndWidths = distributeWidths( + percentages, + parentLayout.riverWidth, + MIN_WATERFALL_WIDTH + ) + const startX = parentLayout.x + parentLayout.segmentLength * 0.15 + + let offsetX = 0 + allocations.forEach((alloc, i) => { const outcomeLayout = outcomeLayouts.find(o => o.id === alloc.targetId) if (!outcomeLayout) return - const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) - const width = Math.max(4, (alloc.percentage / 100) * 24) + const riverEndWidth = riverEndWidths[i] + const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6) + + const riverCenterX = startX + offsetX + slotWidths[i] / 2 + offsetX += slotWidths[i] + + const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2 spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, @@ -334,13 +429,16 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, - x: parentLayout.x + parentLayout.segmentLength / 2 + - (i - ((data.spendingAllocations?.length || 1) - 1) / 2) * 24, + x: riverCenterX, + xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, - width, + width: riverEndWidth, + riverEndWidth, + farEndWidth, + direction: 'outflow', color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], - flowAmount, + flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1), }) }) }) @@ -366,16 +464,16 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { const padding = 80 // Shift everything so minX starts at padding - const offsetX = -minX + padding - const offsetY = padding + const offsetXGlobal = -minX + padding + const offsetYGlobal = padding // Apply offsets - funnelLayouts.forEach(f => { f.x += offsetX; f.y += offsetY }) - outcomeLayouts.forEach(o => { o.x += offsetX; o.y += offsetY }) - sourceLayouts.forEach(s => { s.x += offsetX; s.y += offsetY }) - sourceWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) - overflowBranches.forEach(b => { b.x1 += offsetX; b.y1 += offsetY; b.x2 += offsetX; b.y2 += offsetY }) - spendingWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) + funnelLayouts.forEach(f => { f.x += offsetXGlobal; f.y += offsetYGlobal }) + outcomeLayouts.forEach(o => { o.x += offsetXGlobal; o.y += offsetYGlobal }) + sourceLayouts.forEach(s => { s.x += offsetXGlobal; s.y += offsetYGlobal }) + sourceWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal }) + overflowBranches.forEach(b => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal }) + spendingWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal }) return { sources: sourceLayouts, @@ -385,125 +483,157 @@ function computeLayout(nodes: FlowNode[]): RiverLayout { overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, - height: maxY + offsetY + padding, + height: maxY + offsetYGlobal + padding, } } // ─── SVG Sub-components ────────────────────────────────── -function WaterfallStream({ wf, index }: { wf: WaterfallLayout; index: number }) { +function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) { + const isInflow = wf.direction === 'inflow' const height = wf.yEnd - wf.yStart - const numDroplets = Math.max(3, Math.min(8, Math.floor(wf.width / 3))) + if (height <= 0) return null + + // For inflows: narrow at top (source), wide at bottom (river) + // For outflows: wide at top (river), narrow at bottom (pool) + const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth + const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth + const topCx = isInflow ? wf.xSource : wf.x + const bottomCx = isInflow ? wf.x : wf.xSource + + // Bezier control points for the taper curve + // Inflow: narrow for 55%, then flares in last 45% + // Outflow: wide for first 20%, then tapers + const cpFrac1 = isInflow ? 0.55 : 0.2 + const cpFrac2 = isInflow ? 0.75 : 0.45 + const cpY1 = wf.yStart + height * cpFrac1 + const cpY2 = wf.yStart + height * cpFrac2 + + const tl = topCx - topWidth / 2 + const tr = topCx + topWidth / 2 + const bl = bottomCx - bottomWidth / 2 + const br = bottomCx + bottomWidth / 2 + + const shapePath = [ + `M ${tl} ${wf.yStart}`, + `C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}`, + `L ${br} ${wf.yEnd}`, + `C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart}`, + `Z` + ].join(' ') + + const clipId = `sankey-clip-${wf.id}` + const gradId = `sankey-grad-${wf.id}` + + // Ripple at the wide (river) end + const rippleCx = isInflow ? bottomCx : topCx + const rippleCy = isInflow ? wf.yEnd : wf.yStart + + // Bounding box for animated strips + const pathMinX = Math.min(tl, bl) - 5 + const pathMaxW = Math.max(topWidth, bottomWidth) + 10 return ( - {/* Main water stream */} - - - - - - - + + + + + + + - {/* Glow behind */} - + {/* Glow behind shape */} + - {/* Animated water strips */} - + {/* Main filled shape */} + + + {/* Animated water strips inside the shape */} + {[0, 1, 2].map(i => ( ))} - {/* Side mist lines */} - - - {/* Droplets */} - {Array.from({ length: numDroplets }, (_, i) => { - const dx = (Math.random() - 0.5) * (wf.width + 10) - const delay = Math.random() * 2 - const dur = 1 + Math.random() * 1.5 - return ( - - ) - })} - - {/* Splash ripples at bottom */} + {/* Merge ripples at river junction */} {[0, 1, 2].map(i => ( - ))} + {/* Droplets at narrow end */} + {Array.from({ length: Math.max(2, Math.min(5, Math.floor(wf.riverEndWidth / 8))) }, (_, i) => { + const narrowCx = isInflow ? topCx : bottomCx + const narrowCy = isInflow ? wf.yStart : wf.yEnd + const narrowW = isInflow ? topWidth : bottomWidth + const dx = (Math.random() - 0.5) * narrowW + return ( + + ) + })} + {/* Label */} - {/* Flow direction arrows */} + {/* Flow direction label */} ))} - {/* Layer 2: Source waterfalls (flowing into river) */} - {layout.sourceWaterfalls.map((wf, i) => ( - + {/* Layer 2: Source waterfalls (sankey inflows pouring into river) */} + {layout.sourceWaterfalls.map(wf => ( + ))} {/* Layer 3: River segments */} @@ -942,9 +1071,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver ))} - {/* Layer 5: Spending waterfalls (flowing out) */} - {layout.spendingWaterfalls.map((wf, i) => ( - + {/* Layer 5: Spending waterfalls (sankey outflows pouring out) */} + {layout.spendingWaterfalls.map(wf => ( + ))} {/* Layer 6: Outcome pools */} diff --git a/components/IntegrationPanel.tsx b/components/IntegrationPanel.tsx index 370cdb3..2e97e9d 100644 --- a/components/IntegrationPanel.tsx +++ b/components/IntegrationPanel.tsx @@ -88,7 +88,7 @@ export default function IntegrationPanel({ let xOffset = 0 balances.forEach((chainBalances, chainId) => { - const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, { + const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, undefined, { x: xOffset, y: 100, }) diff --git a/lib/api/safe-client.ts b/lib/api/safe-client.ts index b541874..8ef6e46 100644 --- a/lib/api/safe-client.ts +++ b/lib/api/safe-client.ts @@ -13,6 +13,22 @@ export interface ChainConfig { } export const SUPPORTED_CHAINS: Record = { + 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', @@ -21,12 +37,28 @@ export const SUPPORTED_CHAINS: Record = { color: '#04795b', symbol: 'xDAI', }, - 10: { - name: 'Optimism', - slug: 'optimism', - txService: 'https://safe-transaction-optimism.safe.global', - explorer: 'https://optimistic.etherscan.io', - color: '#ff0420', + 137: { + name: 'Polygon', + slug: 'polygon', + txService: 'https://safe-transaction-polygon.safe.global', + explorer: 'https://polygonscan.com', + color: '#8247e5', + symbol: 'MATIC', + }, + 8453: { + name: 'Base', + slug: 'base', + txService: 'https://safe-transaction-base.safe.global', + explorer: 'https://basescan.org', + color: '#0052ff', + symbol: 'ETH', + }, + 42161: { + name: 'Arbitrum One', + slug: 'arbitrum', + txService: 'https://safe-transaction-arbitrum.safe.global', + explorer: 'https://arbiscan.io', + color: '#28a0f0', symbol: 'ETH', }, } @@ -148,3 +180,99 @@ export async function detectSafeChains( return results } + +// ─── Transfer History ────────────────────────────────────── + +export interface SafeTransfer { + type: 'ETHER_TRANSFER' | 'ERC20_TRANSFER' + executionDate: string + transactionHash: string + to: string + from: string + value: string + tokenAddress: string | null + tokenInfo?: { + name: string + symbol: string + decimals: number + } +} + +export interface TransferSummary { + chainId: number + totalInflow30d: number + totalOutflow30d: number + inflowRate: number + outflowRate: number + incomingTransfers: SafeTransfer[] + outgoingTransfers: SafeTransfer[] +} + +export async function getIncomingTransfers( + address: string, + chainId: number, + limit = 100 +): Promise { + const data = await fetchJSON<{ results: SafeTransfer[] }>( + apiUrl(chainId, `/safes/${address}/incoming-transfers/?limit=${limit}&executed=true`) + ) + return data?.results || [] +} + +export async function getOutgoingTransfers( + address: string, + chainId: number, + limit = 100 +): Promise { + const data = await fetchJSON<{ results: Array> }>( + apiUrl(chainId, `/safes/${address}/multisig-transactions/?limit=${limit}&executed=true`) + ) + if (!data?.results) return [] + + return data.results + .filter(tx => tx.value && parseInt(tx.value as string, 10) > 0) + .map(tx => ({ + type: (tx.dataDecoded ? 'ERC20_TRANSFER' : 'ETHER_TRANSFER') as SafeTransfer['type'], + executionDate: (tx.executionDate as string) || '', + transactionHash: (tx.transactionHash as string) || '', + to: (tx.to as string) || '', + from: address, + value: (tx.value as string) || '0', + tokenAddress: null, + tokenInfo: undefined, + })) +} + +export async function computeTransferSummary( + address: string, + chainId: number +): Promise { + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + + const [incoming, outgoing] = await Promise.all([ + getIncomingTransfers(address, chainId), + getOutgoingTransfers(address, chainId), + ]) + + const recentIncoming = incoming.filter(t => new Date(t.executionDate) >= thirtyDaysAgo) + const recentOutgoing = outgoing.filter(t => new Date(t.executionDate) >= thirtyDaysAgo) + + const sumTransfers = (transfers: SafeTransfer[]) => + transfers.reduce((sum, t) => { + const decimals = t.tokenInfo?.decimals ?? 18 + return sum + parseFloat(t.value) / Math.pow(10, decimals) + }, 0) + + const totalIn = sumTransfers(recentIncoming) + const totalOut = sumTransfers(recentOutgoing) + + return { + chainId, + totalInflow30d: totalIn, + totalOutflow30d: totalOut, + inflowRate: totalIn, + outflowRate: totalOut, + incomingTransfers: recentIncoming, + outgoingTransfers: recentOutgoing, + } +} diff --git a/lib/integrations.ts b/lib/integrations.ts index 4609e25..b38aadf 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -3,7 +3,7 @@ */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, IntegrationSource } from './types' -import type { SafeBalance } from './api/safe-client' +import type { SafeBalance, TransferSummary } from './api/safe-client' // ─── Safe Balances → Funnel Nodes ──────────────────────────── @@ -11,6 +11,7 @@ export function safeBalancesToFunnels( balances: SafeBalance[], safeAddress: string, chainId: number, + transferSummary?: TransferSummary, startPosition = { x: 0, y: 100 } ): FlowNode[] { // Filter to non-zero balances with meaningful fiat value (> $1) @@ -31,13 +32,26 @@ export function safeBalancesToFunnels( lastFetchedAt: Date.now(), } + // Compute per-token inflow rate from transfer summary + let inflowRate = 0 + if (transferSummary) { + const tokenTransfers = transferSummary.incomingTransfers.filter(t => { + if (b.tokenAddress === null) return t.tokenAddress === null + return t.tokenAddress?.toLowerCase() === b.tokenAddress?.toLowerCase() + }) + inflowRate = tokenTransfers.reduce((sum, t) => { + const decimals = t.tokenInfo?.decimals ?? (b.token?.decimals ?? 18) + return sum + parseFloat(t.value) / Math.pow(10, decimals) + }, 0) + } + const data: FunnelNodeData = { label: `${b.symbol} Treasury`, currentValue: fiatValue, minThreshold: Math.round(fiatValue * 0.2), maxThreshold: Math.round(fiatValue * 0.8), maxCapacity: Math.round(fiatValue * 1.5), - inflowRate: 0, + inflowRate, overflowAllocations: [], spendingAllocations: [], source,