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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 10:32:07 +00:00
parent e683175c65
commit 0afb85e9f7
6 changed files with 415 additions and 135 deletions

View File

@ -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; }
}

View File

@ -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() {
<div className="bg-slate-800 rounded-xl shadow-2xl p-6 w-96 border border-slate-700" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-white mb-1">Connect Safe Treasury</h3>
<p className="text-xs text-slate-400 mb-4">
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.
</p>
<input
type="text"

View File

@ -54,9 +54,13 @@ interface WaterfallLayout {
label: string
percentage: number
x: number
xSource: number
yStart: number
yEnd: number
width: number
riverEndWidth: number
farEndWidth: number
direction: 'inflow' | 'outflow'
color: string
flowAmount: number
}
@ -76,10 +80,11 @@ interface BranchLayout {
// ─── Constants ───────────────────────────────────────────
const LAYER_HEIGHT = 160
const WATERFALL_HEIGHT = 100
const WATERFALL_HEIGHT = 120
const GAP = 40
const MIN_RIVER_WIDTH = 24
const MAX_RIVER_WIDTH = 100
const MIN_WATERFALL_WIDTH = 4
const SEGMENT_LENGTH = 200
const POOL_WIDTH = 100
const POOL_HEIGHT = 60
@ -98,6 +103,34 @@ const COLORS = {
textMuted: '#94a3b8',
}
// ─── Helpers ────────────────────────────────────────────
/** Distribute widths proportionally with a minimum floor */
function distributeWidths(
percentages: number[],
totalAvailable: number,
minWidth: number
): number[] {
const totalPct = percentages.reduce((s, p) => 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<string, number>()
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<number, FlowNode[]>()
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<number, { minX: number; maxX: number }>()
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<string, { sourceNodeId: string; allocIndex: number; flowAmount: number; percentage: number }[]>()
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 (
<g>
{/* Main water stream */}
<defs>
<linearGradient id={`wf-grad-${wf.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={wf.color} stopOpacity="0.9" />
<stop offset="50%" stopColor={wf.color} stopOpacity="0.6" />
<stop offset="100%" stopColor={wf.color} stopOpacity="0.3" />
</linearGradient>
<clipPath id={`wf-clip-${wf.id}`}>
<rect x={wf.x - wf.width / 2} y={wf.yStart} width={wf.width} height={height} />
<clipPath id={clipId}>
<path d={shapePath} />
</clipPath>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={wf.color} stopOpacity={isInflow ? 0.85 : 0.5} />
<stop offset="50%" stopColor={wf.color} stopOpacity={0.65} />
<stop offset="100%" stopColor={wf.color} stopOpacity={isInflow ? 0.35 : 0.85} />
</linearGradient>
</defs>
{/* Glow behind */}
<rect
x={wf.x - wf.width / 2 - 3}
y={wf.yStart}
width={wf.width + 6}
height={height}
rx={wf.width / 2}
fill={wf.color}
opacity={0.12}
/>
{/* Glow behind shape */}
<path d={shapePath} fill={wf.color} opacity={0.08} />
{/* Animated water strips */}
<g clipPath={`url(#wf-clip-${wf.id})`}>
{/* Main filled shape */}
<path d={shapePath} fill={`url(#${gradId})`} />
{/* Animated water strips inside the shape */}
<g clipPath={`url(#${clipId})`}>
{[0, 1, 2].map(i => (
<rect
key={i}
x={wf.x - wf.width / 2 + 1}
x={pathMinX}
y={wf.yStart - height}
width={wf.width - 2}
width={pathMaxW}
height={height}
fill={`url(#wf-grad-${wf.id})`}
rx={2}
fill={wf.color}
opacity={0.12}
style={{
animation: `waterFlow ${1.2 + i * 0.3}s linear infinite`,
animation: `waterFlow ${1.4 + i * 0.3}s linear infinite`,
animationDelay: `${i * -0.4}s`,
}}
/>
))}
</g>
{/* Side mist lines */}
<line
x1={wf.x - wf.width / 2 - 1}
y1={wf.yStart}
x2={wf.x - wf.width / 2 - 1}
y2={wf.yEnd}
{/* Edge highlight lines along the bezier edges */}
<path
d={`M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}`}
fill="none"
stroke={wf.color}
strokeWidth={1}
opacity={0.3}
strokeDasharray="4 6"
style={{ animation: `riverCurrent 1s linear infinite` }}
style={{ animation: 'riverCurrent 1s linear infinite' }}
/>
<line
x1={wf.x + wf.width / 2 + 1}
y1={wf.yStart}
x2={wf.x + wf.width / 2 + 1}
y2={wf.yEnd}
<path
d={`M ${tr} ${wf.yStart} C ${tr} ${cpY1}, ${br} ${cpY2}, ${br} ${wf.yEnd}`}
fill="none"
stroke={wf.color}
strokeWidth={1}
opacity={0.3}
strokeDasharray="4 6"
style={{ animation: `riverCurrent 1s linear infinite` }}
style={{ animation: 'riverCurrent 1s linear infinite' }}
/>
{/* 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 (
<circle
key={`drop-${wf.id}-${i}`}
cx={wf.x + dx}
cy={wf.yEnd - 10}
r={1.5}
fill={wf.color}
opacity={0.6}
style={{
animation: `droplet ${dur}s ease-in infinite`,
animationDelay: `${delay}s`,
}}
/>
)
})}
{/* Splash ripples at bottom */}
{/* Merge ripples at river junction */}
{[0, 1, 2].map(i => (
<circle
key={`ripple-${wf.id}-${i}`}
cx={wf.x}
cy={wf.yEnd}
r={2}
<ellipse
key={`merge-${wf.id}-${i}`}
cx={rippleCx}
cy={rippleCy}
rx={2}
ry={1}
fill="none"
stroke={wf.color}
strokeWidth={1}
style={{
animation: `ripple 2s ease-out infinite`,
animation: `mergeSplash 2s ease-out infinite`,
animationDelay: `${i * 0.6}s`,
}}
/>
))}
{/* 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 (
<circle
key={`drop-${wf.id}-${i}`}
cx={narrowCx + dx}
cy={narrowCy + (isInflow ? -5 : 5)}
r={1.2}
fill={wf.color}
opacity={0.5}
style={{
animation: `droplet ${1 + Math.random() * 1.5}s ease-in infinite`,
animationDelay: `${Math.random() * 2}s`,
}}
/>
)
})}
{/* Label */}
<text
x={wf.x}
x={(topCx + bottomCx) / 2}
y={wf.yStart + height / 2}
textAnchor="middle"
fill={COLORS.text}
@ -522,7 +652,6 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
const gradColors = status === 'overflow' ? COLORS.riverOverflow :
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
const fillPercent = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100)
const thresholdMinY = y + riverWidth * 0.85
const thresholdMaxY = y + riverWidth * 0.15
@ -689,7 +818,7 @@ function OverflowBranch({ branch }: { branch: BranchLayout }) {
strokeDasharray="10 5"
style={{ animation: `riverCurrent 1.5s linear infinite` }}
/>
{/* Flow direction arrows */}
{/* Flow direction label */}
<text
x={midX}
y={midY - width / 2 - 4}
@ -927,9 +1056,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
<SourceBox key={s.id} source={s} />
))}
{/* Layer 2: Source waterfalls (flowing into river) */}
{layout.sourceWaterfalls.map((wf, i) => (
<WaterfallStream key={wf.id} wf={wf} index={i} />
{/* Layer 2: Source waterfalls (sankey inflows pouring into river) */}
{layout.sourceWaterfalls.map(wf => (
<SankeyWaterfall key={wf.id} wf={wf} />
))}
{/* Layer 3: River segments */}
@ -942,9 +1071,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
<OverflowBranch key={`overflow-${i}`} branch={b} />
))}
{/* Layer 5: Spending waterfalls (flowing out) */}
{layout.spendingWaterfalls.map((wf, i) => (
<WaterfallStream key={wf.id} wf={wf} index={i} />
{/* Layer 5: Spending waterfalls (sankey outflows pouring out) */}
{layout.spendingWaterfalls.map(wf => (
<SankeyWaterfall key={wf.id} wf={wf} />
))}
{/* Layer 6: Outcome pools */}

View File

@ -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,
})

View File

@ -13,6 +13,22 @@ export interface ChainConfig {
}
export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
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<number, ChainConfig> = {
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<SafeTransfer[]> {
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<SafeTransfer[]> {
const data = await fetchJSON<{ results: Array<Record<string, unknown>> }>(
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<TransferSummary> {
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,
}
}

View File

@ -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,