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; } 50% { opacity: 0.7; }
100% { opacity: 0.3; } 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 Link from 'next/link'
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import { demoNodes } from '@/lib/presets' 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 { safeBalancesToFunnels } from '@/lib/integrations'
import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client' import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client'
import type { FlowNode, FunnelNodeData } from '@/lib/types' import type { FlowNode, FunnelNodeData } from '@/lib/types'
@ -52,7 +52,7 @@ export default function RiverPage() {
localStorage.setItem('rfunds-owner-address', safeAddress.trim()) localStorage.setItem('rfunds-owner-address', safeAddress.trim())
const detected = await detectSafeChains(safeAddress.trim()) const detected = await detectSafeChains(safeAddress.trim())
if (detected.length === 0) { 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) setConnecting(false)
return return
} }
@ -60,14 +60,18 @@ export default function RiverPage() {
const chainNames = detected.map(d => d.chain.name) const chainNames = detected.map(d => d.chain.name)
setConnectedChains(chainNames) setConnectedChains(chainNames)
// Fetch balances from all detected chains and create funnel nodes // Fetch balances + transfer history from all detected chains
const allFunnelNodes: FlowNode[] = [] const allFunnelNodes: FlowNode[] = []
for (const chain of detected) { 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( const funnels = safeBalancesToFunnels(
balances, balances,
safeAddress.trim(), safeAddress.trim(),
chain.chainId, chain.chainId,
transferSummary,
{ x: allFunnelNodes.length * 280, y: 100 } { x: allFunnelNodes.length * 280, y: 100 }
) )
allFunnelNodes.push(...funnels) 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()}> <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> <h3 className="text-lg font-bold text-white mb-1">Connect Safe Treasury</h3>
<p className="text-xs text-slate-400 mb-4"> <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> </p>
<input <input
type="text" type="text"

View File

@ -54,9 +54,13 @@ interface WaterfallLayout {
label: string label: string
percentage: number percentage: number
x: number x: number
xSource: number
yStart: number yStart: number
yEnd: number yEnd: number
width: number width: number
riverEndWidth: number
farEndWidth: number
direction: 'inflow' | 'outflow'
color: string color: string
flowAmount: number flowAmount: number
} }
@ -76,10 +80,11 @@ interface BranchLayout {
// ─── Constants ─────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────
const LAYER_HEIGHT = 160 const LAYER_HEIGHT = 160
const WATERFALL_HEIGHT = 100 const WATERFALL_HEIGHT = 120
const GAP = 40 const GAP = 40
const MIN_RIVER_WIDTH = 24 const MIN_RIVER_WIDTH = 24
const MAX_RIVER_WIDTH = 100 const MAX_RIVER_WIDTH = 100
const MIN_WATERFALL_WIDTH = 4
const SEGMENT_LENGTH = 200 const SEGMENT_LENGTH = 200
const POOL_WIDTH = 100 const POOL_WIDTH = 100
const POOL_HEIGHT = 60 const POOL_HEIGHT = 60
@ -98,6 +103,34 @@ const COLORS = {
textMuted: '#94a3b8', 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 ─────────────────────────────────────── // ─── Layout Engine ───────────────────────────────────────
function computeLayout(nodes: FlowNode[]): RiverLayout { 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 // 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 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>() const funnelLayers = new Map<string, number>()
rootFunnels.forEach(n => funnelLayers.set(n.id, 0)) rootFunnels.forEach(n => funnelLayers.set(n.id, 0))
// BFS to assign layers to overflow children
const queue = [...rootFunnels] const queue = [...rootFunnels]
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift()! 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 allFlowRates = funnelNodes.map(n => (n.data as FunnelNodeData).inflowRate || 1)
const maxFlowRate = Math.max(...allFlowRates, 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 funnels by layer, center each layer
// Group by layer, center each layer
const layerGroups = new Map<number, FlowNode[]>() const layerGroups = new Map<number, FlowNode[]>()
funnelNodes.forEach(n => { funnelNodes.forEach(n => {
const layer = funnelLayers.get(n.id) ?? 0 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 funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP
const funnelLayouts: FunnelLayout[] = [] const funnelLayouts: FunnelLayout[] = []
const layerXRanges = new Map<number, { minX: number; maxX: number }>()
for (let layer = 0; layer <= maxLayer; layer++) { for (let layer = 0; layer <= maxLayer; layer++) {
const layerNodes = layerGroups.get(layer) || [] const layerNodes = layerGroups.get(layer) || []
@ -190,11 +217,6 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
layer, layer,
status, 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[] = [] const sourceWaterfalls: WaterfallLayout[] = []
sourceNodes.forEach(sn => { sourceNodes.forEach(sn => {
const data = sn.data as SourceNodeData const data = sn.data as SourceNodeData
const sourceLayout = sourceLayouts.find(s => s.id === sn.id) const sourceLayout = sourceLayouts.find(s => s.id === sn.id)
if (!sourceLayout) return if (!sourceLayout) return
data.targetAllocations?.forEach((alloc, i) => { data.targetAllocations?.forEach((alloc, allocIdx) => {
const targetLayout = funnelLayouts.find(f => f.id === alloc.targetId) const targetLayout = funnelLayouts.find(f => f.id === alloc.targetId)
if (!targetLayout) return if (!targetLayout) return
const flowAmount = (alloc.percentage / 100) * data.flowRate 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({ sourceWaterfalls.push({
id: `src-wf-${sn.id}-${alloc.targetId}`, id: `src-wf-${sn.id}-${alloc.targetId}`,
@ -232,17 +291,21 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
targetId: alloc.targetId, targetId: alloc.targetId,
label: `${alloc.percentage}%`, label: `${alloc.percentage}%`,
percentage: 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, yStart: sourceLayout.y + SOURCE_HEIGHT,
yEnd: targetLayout.y, yEnd: targetLayout.y,
width, width: riverEndWidth,
riverEndWidth,
farEndWidth,
direction: 'inflow',
color: COLORS.sourceWaterfall, color: COLORS.sourceWaterfall,
flowAmount, 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) { if (sourceNodes.length === 0) {
rootFunnels.forEach(rn => { rootFunnels.forEach(rn => {
const data = rn.data as FunnelNodeData const data = rn.data as FunnelNodeData
@ -250,6 +313,9 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
const layout = funnelLayouts.find(f => f.id === rn.id) const layout = funnelLayouts.find(f => f.id === rn.id)
if (!layout) return if (!layout) return
const riverEndWidth = layout.riverWidth
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4)
sourceWaterfalls.push({ sourceWaterfalls.push({
id: `implicit-wf-${rn.id}`, id: `implicit-wf-${rn.id}`,
sourceId: 'implicit', sourceId: 'implicit',
@ -257,28 +323,32 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
label: `$${Math.floor(data.inflowRate)}/mo`, label: `$${Math.floor(data.inflowRate)}/mo`,
percentage: 100, percentage: 100,
x: layout.x + layout.segmentLength / 2, x: layout.x + layout.segmentLength / 2,
xSource: layout.x + layout.segmentLength / 2,
yStart: GAP, yStart: GAP,
yEnd: layout.y, yEnd: layout.y,
width: Math.max(8, (data.inflowRate / maxFlowRate) * 30), width: riverEndWidth,
riverEndWidth,
farEndWidth,
direction: 'inflow',
color: COLORS.sourceWaterfall, color: COLORS.sourceWaterfall,
flowAmount: data.inflowRate, flowAmount: data.inflowRate,
}) })
}) })
} }
// Overflow branches (funnel → child funnel) // ─── Overflow branches (sankey-proportional) ──────
const overflowBranches: BranchLayout[] = [] const overflowBranches: BranchLayout[] = []
funnelNodes.forEach(n => { funnelNodes.forEach(n => {
const data = n.data as FunnelNodeData const data = n.data as FunnelNodeData
const parentLayout = funnelLayouts.find(f => f.id === n.id) const parentLayout = funnelLayouts.find(f => f.id === n.id)
if (!parentLayout) return if (!parentLayout) return
data.overflowAllocations?.forEach((alloc, i) => { data.overflowAllocations?.forEach((alloc) => {
const childLayout = funnelLayouts.find(f => f.id === alloc.targetId) const childLayout = funnelLayouts.find(f => f.id === alloc.targetId)
if (!childLayout) return if (!childLayout) return
const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth)
const width = Math.max(4, (alloc.percentage / 100) * parentLayout.riverWidth * 0.6)
overflowBranches.push({ overflowBranches.push({
sourceId: n.id, 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 outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT
const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP
const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => { 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[] = [] const spendingWaterfalls: WaterfallLayout[] = []
funnelNodes.forEach(n => { funnelNodes.forEach(n => {
const data = n.data as FunnelNodeData const data = n.data as FunnelNodeData
const parentLayout = funnelLayouts.find(f => f.id === n.id) const parentLayout = funnelLayouts.find(f => f.id === n.id)
if (!parentLayout) return 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) const outcomeLayout = outcomeLayouts.find(o => o.id === alloc.targetId)
if (!outcomeLayout) return if (!outcomeLayout) return
const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1) const riverEndWidth = riverEndWidths[i]
const width = Math.max(4, (alloc.percentage / 100) * 24) 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({ spendingWaterfalls.push({
id: `spend-wf-${n.id}-${alloc.targetId}`, id: `spend-wf-${n.id}-${alloc.targetId}`,
@ -334,13 +429,16 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
targetId: alloc.targetId, targetId: alloc.targetId,
label: `${alloc.percentage}%`, label: `${alloc.percentage}%`,
percentage: alloc.percentage, percentage: alloc.percentage,
x: parentLayout.x + parentLayout.segmentLength / 2 + x: riverCenterX,
(i - ((data.spendingAllocations?.length || 1) - 1) / 2) * 24, xSource: poolCenterX,
yStart: parentLayout.y + parentLayout.riverWidth + 4, yStart: parentLayout.y + parentLayout.riverWidth + 4,
yEnd: outcomeLayout.y, yEnd: outcomeLayout.y,
width, width: riverEndWidth,
riverEndWidth,
farEndWidth,
direction: 'outflow',
color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], 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 const padding = 80
// Shift everything so minX starts at padding // Shift everything so minX starts at padding
const offsetX = -minX + padding const offsetXGlobal = -minX + padding
const offsetY = padding const offsetYGlobal = padding
// Apply offsets // Apply offsets
funnelLayouts.forEach(f => { f.x += offsetX; f.y += offsetY }) funnelLayouts.forEach(f => { f.x += offsetXGlobal; f.y += offsetYGlobal })
outcomeLayouts.forEach(o => { o.x += offsetX; o.y += offsetY }) outcomeLayouts.forEach(o => { o.x += offsetXGlobal; o.y += offsetYGlobal })
sourceLayouts.forEach(s => { s.x += offsetX; s.y += offsetY }) sourceLayouts.forEach(s => { s.x += offsetXGlobal; s.y += offsetYGlobal })
sourceWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) sourceWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal })
overflowBranches.forEach(b => { b.x1 += offsetX; b.y1 += offsetY; b.x2 += offsetX; b.y2 += offsetY }) overflowBranches.forEach(b => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal })
spendingWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY }) spendingWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal })
return { return {
sources: sourceLayouts, sources: sourceLayouts,
@ -385,125 +483,157 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
overflowBranches, overflowBranches,
spendingWaterfalls, spendingWaterfalls,
width: maxX - minX + padding * 2, width: maxX - minX + padding * 2,
height: maxY + offsetY + padding, height: maxY + offsetYGlobal + padding,
} }
} }
// ─── SVG Sub-components ────────────────────────────────── // ─── 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 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 ( return (
<g> <g>
{/* Main water stream */}
<defs> <defs>
<linearGradient id={`wf-grad-${wf.id}`} x1="0" y1="0" x2="0" y2="1"> <clipPath id={clipId}>
<stop offset="0%" stopColor={wf.color} stopOpacity="0.9" /> <path d={shapePath} />
<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> </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> </defs>
{/* Glow behind */} {/* Glow behind shape */}
<rect <path d={shapePath} fill={wf.color} opacity={0.08} />
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}
/>
{/* Animated water strips */} {/* Main filled shape */}
<g clipPath={`url(#wf-clip-${wf.id})`}> <path d={shapePath} fill={`url(#${gradId})`} />
{/* Animated water strips inside the shape */}
<g clipPath={`url(#${clipId})`}>
{[0, 1, 2].map(i => ( {[0, 1, 2].map(i => (
<rect <rect
key={i} key={i}
x={wf.x - wf.width / 2 + 1} x={pathMinX}
y={wf.yStart - height} y={wf.yStart - height}
width={wf.width - 2} width={pathMaxW}
height={height} height={height}
fill={`url(#wf-grad-${wf.id})`} fill={wf.color}
rx={2} opacity={0.12}
style={{ 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`, animationDelay: `${i * -0.4}s`,
}} }}
/> />
))} ))}
</g> </g>
{/* Side mist lines */} {/* Edge highlight lines along the bezier edges */}
<line <path
x1={wf.x - wf.width / 2 - 1} d={`M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}`}
y1={wf.yStart} fill="none"
x2={wf.x - wf.width / 2 - 1}
y2={wf.yEnd}
stroke={wf.color} stroke={wf.color}
strokeWidth={1} strokeWidth={1}
opacity={0.3} opacity={0.3}
strokeDasharray="4 6" strokeDasharray="4 6"
style={{ animation: `riverCurrent 1s linear infinite` }} style={{ animation: 'riverCurrent 1s linear infinite' }}
/> />
<line <path
x1={wf.x + wf.width / 2 + 1} d={`M ${tr} ${wf.yStart} C ${tr} ${cpY1}, ${br} ${cpY2}, ${br} ${wf.yEnd}`}
y1={wf.yStart} fill="none"
x2={wf.x + wf.width / 2 + 1}
y2={wf.yEnd}
stroke={wf.color} stroke={wf.color}
strokeWidth={1} strokeWidth={1}
opacity={0.3} opacity={0.3}
strokeDasharray="4 6" strokeDasharray="4 6"
style={{ animation: `riverCurrent 1s linear infinite` }} style={{ animation: 'riverCurrent 1s linear infinite' }}
/> />
{/* Droplets */} {/* Merge ripples at river junction */}
{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 */}
{[0, 1, 2].map(i => ( {[0, 1, 2].map(i => (
<circle <ellipse
key={`ripple-${wf.id}-${i}`} key={`merge-${wf.id}-${i}`}
cx={wf.x} cx={rippleCx}
cy={wf.yEnd} cy={rippleCy}
r={2} rx={2}
ry={1}
fill="none" fill="none"
stroke={wf.color} stroke={wf.color}
strokeWidth={1} strokeWidth={1}
style={{ style={{
animation: `ripple 2s ease-out infinite`, animation: `mergeSplash 2s ease-out infinite`,
animationDelay: `${i * 0.6}s`, 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 */} {/* Label */}
<text <text
x={wf.x} x={(topCx + bottomCx) / 2}
y={wf.yStart + height / 2} y={wf.yStart + height / 2}
textAnchor="middle" textAnchor="middle"
fill={COLORS.text} fill={COLORS.text}
@ -522,7 +652,6 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
const gradColors = status === 'overflow' ? COLORS.riverOverflow : const gradColors = status === 'overflow' ? COLORS.riverOverflow :
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
const fillPercent = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100)
const thresholdMinY = y + riverWidth * 0.85 const thresholdMinY = y + riverWidth * 0.85
const thresholdMaxY = y + riverWidth * 0.15 const thresholdMaxY = y + riverWidth * 0.15
@ -689,7 +818,7 @@ function OverflowBranch({ branch }: { branch: BranchLayout }) {
strokeDasharray="10 5" strokeDasharray="10 5"
style={{ animation: `riverCurrent 1.5s linear infinite` }} style={{ animation: `riverCurrent 1.5s linear infinite` }}
/> />
{/* Flow direction arrows */} {/* Flow direction label */}
<text <text
x={midX} x={midX}
y={midY - width / 2 - 4} y={midY - width / 2 - 4}
@ -927,9 +1056,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
<SourceBox key={s.id} source={s} /> <SourceBox key={s.id} source={s} />
))} ))}
{/* Layer 2: Source waterfalls (flowing into river) */} {/* Layer 2: Source waterfalls (sankey inflows pouring into river) */}
{layout.sourceWaterfalls.map((wf, i) => ( {layout.sourceWaterfalls.map(wf => (
<WaterfallStream key={wf.id} wf={wf} index={i} /> <SankeyWaterfall key={wf.id} wf={wf} />
))} ))}
{/* Layer 3: River segments */} {/* Layer 3: River segments */}
@ -942,9 +1071,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
<OverflowBranch key={`overflow-${i}`} branch={b} /> <OverflowBranch key={`overflow-${i}`} branch={b} />
))} ))}
{/* Layer 5: Spending waterfalls (flowing out) */} {/* Layer 5: Spending waterfalls (sankey outflows pouring out) */}
{layout.spendingWaterfalls.map((wf, i) => ( {layout.spendingWaterfalls.map(wf => (
<WaterfallStream key={wf.id} wf={wf} index={i} /> <SankeyWaterfall key={wf.id} wf={wf} />
))} ))}
{/* Layer 6: Outcome pools */} {/* Layer 6: Outcome pools */}

View File

@ -88,7 +88,7 @@ export default function IntegrationPanel({
let xOffset = 0 let xOffset = 0
balances.forEach((chainBalances, chainId) => { balances.forEach((chainBalances, chainId) => {
const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, { const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, undefined, {
x: xOffset, x: xOffset,
y: 100, y: 100,
}) })

View File

@ -13,6 +13,22 @@ export interface ChainConfig {
} }
export const SUPPORTED_CHAINS: Record<number, 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: { 100: {
name: 'Gnosis', name: 'Gnosis',
slug: 'gnosis-chain', slug: 'gnosis-chain',
@ -21,12 +37,28 @@ export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
color: '#04795b', color: '#04795b',
symbol: 'xDAI', symbol: 'xDAI',
}, },
10: { 137: {
name: 'Optimism', name: 'Polygon',
slug: 'optimism', slug: 'polygon',
txService: 'https://safe-transaction-optimism.safe.global', txService: 'https://safe-transaction-polygon.safe.global',
explorer: 'https://optimistic.etherscan.io', explorer: 'https://polygonscan.com',
color: '#ff0420', 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', symbol: 'ETH',
}, },
} }
@ -148,3 +180,99 @@ export async function detectSafeChains(
return results 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 { 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 ──────────────────────────── // ─── Safe Balances → Funnel Nodes ────────────────────────────
@ -11,6 +11,7 @@ export function safeBalancesToFunnels(
balances: SafeBalance[], balances: SafeBalance[],
safeAddress: string, safeAddress: string,
chainId: number, chainId: number,
transferSummary?: TransferSummary,
startPosition = { x: 0, y: 100 } startPosition = { x: 0, y: 100 }
): FlowNode[] { ): FlowNode[] {
// Filter to non-zero balances with meaningful fiat value (> $1) // Filter to non-zero balances with meaningful fiat value (> $1)
@ -31,13 +32,26 @@ export function safeBalancesToFunnels(
lastFetchedAt: Date.now(), 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 = { const data: FunnelNodeData = {
label: `${b.symbol} Treasury`, label: `${b.symbol} Treasury`,
currentValue: fiatValue, currentValue: fiatValue,
minThreshold: Math.round(fiatValue * 0.2), minThreshold: Math.round(fiatValue * 0.2),
maxThreshold: Math.round(fiatValue * 0.8), maxThreshold: Math.round(fiatValue * 0.8),
maxCapacity: Math.round(fiatValue * 1.5), maxCapacity: Math.round(fiatValue * 1.5),
inflowRate: 0, inflowRate,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [], spendingAllocations: [],
source, source,