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,