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,