Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

11 changed files with 55 additions and 2072 deletions

View File

@ -18,48 +18,3 @@ body {
text-wrap: balance; text-wrap: balance;
} }
} }
/* ─── Budget River Animations ─────────────────────────── */
@keyframes waterFlow {
0% { transform: translateY(-100%); }
100% { transform: translateY(100%); }
}
@keyframes riverCurrent {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -24; }
}
@keyframes droplet {
0% { transform: translateY(0) scale(1); opacity: 0.8; }
80% { opacity: 0.4; }
100% { transform: translateY(50px) scale(0.2); opacity: 0; }
}
@keyframes ripple {
0% { r: 2; opacity: 0.6; }
100% { r: 14; opacity: 0; }
}
@keyframes waveFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes poolWave {
0% { d: path("M0,8 Q15,4 30,8 T60,8 T90,8 V20 H0 Z"); }
50% { d: path("M0,8 Q15,12 30,8 T60,8 T90,8 V20 H0 Z"); }
100% { d: path("M0,8 Q15,4 30,8 T60,8 T90,8 V20 H0 Z"); }
}
@keyframes shimmer {
0% { opacity: 0.3; }
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

@ -14,13 +14,13 @@ const geistMono = localFont({
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
title: '(you)rFunds — Threshold-Based Flow Funding', title: 'rFunds - Threshold-Based Flow Funding',
description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms. Create interconnected funding funnels with overflow routing and outcome tracking.', description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms. Create interconnected funding funnels with overflow routing and outcome tracking.',
icons: { icons: {
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>", icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>",
}, },
openGraph: { openGraph: {
title: '(you)rFunds — Threshold-Based Flow Funding', title: 'rFunds - Threshold-Based Flow Funding',
description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms.', description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms.',
type: 'website', type: 'website',
url: 'https://rfunds.online', url: 'https://rfunds.online',

View File

@ -1,374 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useState, useCallback, useEffect } from 'react'
import { demoNodes } from '@/lib/presets'
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 { computeSystemSufficiency } from '@/lib/simulation'
import type { FlowNode, FunnelNodeData } from '@/lib/types'
import type { BackendFlow } from '@/lib/api/flows-client'
const BudgetRiver = dynamic(() => import('@/components/BudgetRiver'), {
ssr: false,
loading: () => (
<div className="w-full h-full flex items-center justify-center bg-slate-900">
<div className="flex items-center gap-3">
<div className="w-8 h-8 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin" />
<span className="text-slate-400">Loading river view...</span>
</div>
</div>
),
})
function EnoughnessPill({ nodes }: { nodes: FlowNode[] }) {
const score = Math.round(computeSystemSufficiency(nodes) * 100)
const color = score >= 90 ? 'bg-amber-500/30 text-amber-300' :
score >= 60 ? 'bg-emerald-600/30 text-emerald-300' :
score >= 30 ? 'bg-amber-600/30 text-amber-300' :
'bg-red-600/30 text-red-300'
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>
Enoughness: {score}%
</span>
)
}
export default function RiverPage() {
const [mode, setMode] = useState<'demo' | 'live'>('demo')
const [nodes, setNodes] = useState<FlowNode[]>(demoNodes)
const [isSimulating, setIsSimulating] = useState(true)
const [showConnect, setShowConnect] = useState(false)
const [safeAddress, setSafeAddress] = useState('')
const [connecting, setConnecting] = useState(false)
const [connectedChains, setConnectedChains] = useState<string[]>([])
const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
const [deployedFlow, setDeployedFlow] = useState<BackendFlow | null>(null)
// Load saved address
useEffect(() => {
if (typeof window === 'undefined') return
const saved = localStorage.getItem('rfunds-owner-address')
if (saved) setSafeAddress(saved)
}, [])
const showStatus = useCallback((text: string, type: 'success' | 'error') => {
setStatusMessage({ text, type })
setTimeout(() => setStatusMessage(null), 4000)
}, [])
const handleConnect = useCallback(async () => {
if (!safeAddress.trim()) return
setConnecting(true)
try {
localStorage.setItem('rfunds-owner-address', safeAddress.trim())
const detected = await detectSafeChains(safeAddress.trim())
if (detected.length === 0) {
showStatus('No Safe found on supported chains (Ethereum, Base, Polygon, Arbitrum, Optimism, Gnosis)', 'error')
setConnecting(false)
return
}
const chainNames = detected.map(d => d.chain.name)
setConnectedChains(chainNames)
// Fetch balances + transfer history from all detected chains
const allFunnelNodes: FlowNode[] = []
for (const chain of detected) {
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)
}
if (allFunnelNodes.length === 0) {
showStatus('Safe found but no token balances > $1', 'error')
setConnecting(false)
return
}
setNodes(allFunnelNodes)
setMode('live')
setIsSimulating(false)
setShowConnect(false)
showStatus(`Connected: ${allFunnelNodes.length} tokens across ${chainNames.join(', ')}`, 'success')
} catch (err) {
showStatus(`Connection failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
} finally {
setConnecting(false)
}
}, [safeAddress, showStatus])
const handleLoadFlow = useCallback(async () => {
if (!safeAddress.trim()) {
showStatus('Enter a wallet address first', 'error')
return
}
try {
const flows = await listFlows(safeAddress.trim())
if (flows.length === 0) {
showStatus('No deployed flows found for this address', 'error')
return
}
// Load the most recent active flow
const activeFlow = flows.find(f => f.status === 'active') || flows[0]
const flow = await getFlow(activeFlow.id)
setDeployedFlow(flow)
// Convert backend flow to visual nodes
const funnelNodes: FlowNode[] = flow.funnels.map((f, i) => ({
id: f.id,
type: 'funnel' as const,
position: { x: i * 280, y: 100 },
data: {
label: f.name,
currentValue: fromSmallestUnit(f.balance),
minThreshold: fromSmallestUnit(f.minThreshold),
maxThreshold: fromSmallestUnit(f.maxThreshold),
maxCapacity: fromSmallestUnit(f.maxThreshold) * 1.5,
inflowRate: f.inflowRate ? fromSmallestUnit(f.inflowRate) : 0,
overflowAllocations: f.overflowAllocations.map((a, j) => ({
targetId: a.targetId,
percentage: a.percentage,
color: ['#f59e0b', '#ef4444', '#f97316'][j % 3],
})),
spendingAllocations: f.spendingAllocations.map((a, j) => ({
targetId: a.targetId,
percentage: a.percentage,
color: ['#3b82f6', '#8b5cf6', '#ec4899'][j % 3],
})),
} as FunnelNodeData,
}))
const outcomeNodes: FlowNode[] = flow.outcomes.map((o, i) => ({
id: o.id,
type: 'outcome' as const,
position: { x: i * 250, y: 600 },
data: {
label: o.name,
description: o.description || '',
fundingReceived: fromSmallestUnit(o.currentAmount),
fundingTarget: fromSmallestUnit(o.targetAmount),
status: o.status === 'completed' ? 'completed' as const :
fromSmallestUnit(o.currentAmount) > 0 ? 'in-progress' as const : 'not-started' as const,
},
}))
setNodes([...funnelNodes, ...outcomeNodes])
setMode('live')
setIsSimulating(false)
showStatus(`Loaded flow "${flow.name}" (${flow.funnels.length} funnels, ${flow.outcomes.length} outcomes)`, 'success')
} catch (err) {
showStatus(`Failed to load flow: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
}
}, [safeAddress, showStatus])
// Auto-refresh in live mode
useEffect(() => {
if (mode !== 'live' || !deployedFlow) return
const interval = setInterval(async () => {
try {
const latest = await getFlow(deployedFlow.id)
setDeployedFlow(latest)
setNodes(prev => syncNodesToBackend(prev, latest, Object.fromEntries(
prev.map(n => [n.id, n.id])
)))
} catch {
// Silent refresh failure
}
}, 30000)
return () => clearInterval(interval)
}, [mode, deployedFlow])
const handleSwitchToDemo = useCallback(() => {
setNodes(demoNodes)
setMode('demo')
setIsSimulating(true)
setDeployedFlow(null)
setConnectedChains([])
}, [])
return (
<main className="h-screen w-screen flex flex-col bg-slate-900">
{/* Status Toast */}
{statusMessage && (
<div className={`fixed top-16 left-1/2 -translate-x-1/2 z-[60] px-4 py-2 rounded-lg shadow-lg text-sm font-medium ${
statusMessage.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'
}`}>
{statusMessage.text}
</div>
)}
{/* Banner */}
<div className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between text-sm flex-shrink-0 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<div className="w-6 h-6 bg-gradient-to-br from-amber-400 to-emerald-500 rounded flex items-center justify-center font-bold text-slate-900 text-[10px]">
rF
</div>
<span className="font-medium">rFunds</span>
</Link>
<span className="text-slate-500">|</span>
<span className="text-cyan-300 font-medium">Budget River</span>
<span className="text-slate-500">|</span>
<EnoughnessPill nodes={nodes} />
{mode === 'live' && connectedChains.length > 0 && (
<>
<span className="text-slate-500">|</span>
<span className="text-xs text-emerald-400">
{connectedChains.join(' + ')}
</span>
</>
)}
{deployedFlow && (
<>
<span className="text-slate-500">|</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
deployedFlow.status === 'active' ? 'bg-emerald-600/30 text-emerald-300' :
'bg-amber-600/30 text-amber-300'
}`}>
{deployedFlow.status} &middot; {deployedFlow.name}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Link
href="/tbff"
className="px-3 py-1 text-slate-400 hover:text-white text-xs transition-colors"
>
Canvas View
</Link>
<Link
href="/space"
className="px-3 py-1 text-slate-400 hover:text-white text-xs transition-colors"
>
Editor
</Link>
<div className="w-px h-5 bg-slate-600 mx-1" />
{mode === 'live' ? (
<button
onClick={handleSwitchToDemo}
className="px-3 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs font-medium transition-colors"
>
Demo
</button>
) : (
<button
onClick={() => setShowConnect(true)}
className="px-3 py-1 bg-cyan-600 hover:bg-cyan-500 rounded text-xs font-medium transition-colors"
>
Connect Safe
</button>
)}
<button
onClick={() => setIsSimulating(!isSimulating)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
isSimulating
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
}`}
>
{isSimulating ? 'Pause' : 'Simulate'}
</button>
</div>
</div>
{/* River Canvas */}
<div className="flex-1 overflow-auto">
<BudgetRiver nodes={nodes} isSimulating={isSimulating} />
</div>
{/* Connect Safe Dialog */}
{showConnect && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowConnect(false)}>
<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 across Ethereum, Base, Polygon, Arbitrum, Optimism, and Gnosis.
</p>
<input
type="text"
value={safeAddress}
onChange={e => setSafeAddress(e.target.value)}
placeholder="Safe address (0x...)"
className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm text-white font-mono text-xs mb-3 focus:border-cyan-500 focus:outline-none"
autoFocus
onKeyDown={e => e.key === 'Enter' && handleConnect()}
/>
<div className="flex gap-2 mb-3">
<button
onClick={handleConnect}
disabled={!safeAddress.trim() || connecting}
className="flex-1 px-4 py-2 bg-cyan-600 text-white rounded-lg text-sm font-medium hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{connecting ? 'Connecting...' : 'Connect Safe'}
</button>
<button
onClick={handleLoadFlow}
disabled={!safeAddress.trim()}
className="px-4 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hover:bg-violet-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Load Flow
</button>
</div>
<button
onClick={() => setShowConnect(false)}
className="w-full px-4 py-2 text-slate-400 hover:text-white text-sm border border-slate-600 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Legend */}
<div className="absolute bottom-4 left-4 bg-slate-800/90 backdrop-blur-sm rounded-lg border border-slate-700/50 p-3 text-xs">
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Legend</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-emerald-500" />
<span className="text-slate-400">Source / Inflow</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-1.5 rounded bg-cyan-500" />
<span className="text-slate-400">River (healthy)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-1.5 rounded bg-amber-500" />
<span className="text-slate-400">Overflow branch</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-violet-500" />
<span className="text-slate-400">Spending outflow</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-md border border-blue-500 bg-blue-500/30" />
<span className="text-slate-400">Outcome pool</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-1.5 rounded bg-amber-400" style={{ boxShadow: '0 0 6px #fbbf24' }} />
<span className="text-slate-400">Sufficient (golden)</span>
</div>
</div>
</div>
</main>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -197,7 +197,7 @@ function generateEdges(
}) })
}) })
// Stream edges (Superfluid planning) // Stream edges (Superfluid visual planning)
data.streamAllocations?.forEach((stream) => { data.streamAllocations?.forEach((stream) => {
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e' const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
edges.push({ edges.push({
@ -228,48 +228,6 @@ function generateEdges(
}) })
}) })
// Source node edges
nodes.forEach((node) => {
if (node.type !== 'source') return
const data = node.data as SourceNodeData
const rate = data.flowRate || 1
const allocCount = data.targetAllocations?.length ?? 0
data.targetAllocations?.forEach((alloc) => {
const flowValue = (alloc.percentage / 100) * rate
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
edges.push({
id: `source-${node.id}-${alloc.targetId}`,
source: node.id,
target: alloc.targetId,
sourceHandle: 'source-out',
animated: true,
style: {
stroke: alloc.color || '#10b981',
strokeWidth,
opacity: 0.8,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: alloc.color || '#10b981',
width: 16,
height: 16,
},
data: {
allocation: alloc.percentage,
color: alloc.color || '#10b981',
edgeType: 'spending' as const,
sourceId: node.id,
targetId: alloc.targetId,
siblingCount: allocCount,
onAdjust,
},
type: 'allocation',
})
})
})
return edges return edges
} }
@ -286,7 +244,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes) const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[]) const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
const [isSimulating, setIsSimulating] = useState(mode === 'demo') const [isSimulating, setIsSimulating] = useState(mode === 'demo')
const [panelsCollapsed, setPanelsCollapsed] = useState(false)
const [connectingFrom, setConnectingFrom] = useState<ConnectingFrom | null>(null) const [connectingFrom, setConnectingFrom] = useState<ConnectingFrom | null>(null)
const edgesRef = useRef(edges) const edgesRef = useRef(edges)
edgesRef.current = edges edgesRef.current = edges
@ -770,64 +727,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
]) ])
}, [setNodes, screenToFlowPosition]) }, [setNodes, screenToFlowPosition])
// Handle node deletion — clean up references in other nodes
const onNodesDelete = useCallback((deletedNodes: FlowNode[]) => {
const deletedIds = new Set(deletedNodes.map(n => n.id))
setNodes((nds) => nds.map((node) => {
if (node.type === 'source') {
const data = node.data as SourceNodeData
const filtered = data.targetAllocations.filter(a => !deletedIds.has(a.targetId))
if (filtered.length === data.targetAllocations.length) return node
const total = filtered.reduce((s, a) => s + a.percentage, 0)
return {
...node,
data: {
...data,
targetAllocations: total > 0
? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
: filtered,
},
}
}
if (node.type === 'funnel') {
const data = node.data as FunnelNodeData
let changed = false
let overflow = data.overflowAllocations || []
const filteredOverflow = overflow.filter(a => !deletedIds.has(a.targetId))
if (filteredOverflow.length !== overflow.length) {
changed = true
const total = filteredOverflow.reduce((s, a) => s + a.percentage, 0)
overflow = total > 0
? filteredOverflow.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
: filteredOverflow
}
let spending = data.spendingAllocations || []
const filteredSpending = spending.filter(a => !deletedIds.has(a.targetId))
if (filteredSpending.length !== spending.length) {
changed = true
const total = filteredSpending.reduce((s, a) => s + a.percentage, 0)
spending = total > 0
? filteredSpending.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
: filteredSpending
}
if (!changed) return node
return {
...node,
data: {
...data,
overflowAllocations: overflow,
spendingAllocations: spending,
},
}
}
return node
}))
}, [setNodes])
// Simulation — real flow logic (inflow → overflow → spending → outcomes) // Simulation — real flow logic (inflow → overflow → spending → outcomes)
useEffect(() => { useEffect(() => {
if (!isSimulating) return if (!isSimulating) return
@ -839,12 +738,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
return () => clearInterval(interval) return () => clearInterval(interval)
}, [isSimulating, setNodes]) }, [isSimulating, setNodes])
// Auto-collapse title & legend panels after 5 seconds
useEffect(() => {
const timer = setTimeout(() => setPanelsCollapsed(true), 5000)
return () => clearTimeout(timer)
}, [])
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
<ConnectionContext.Provider value={connectingFrom}> <ConnectionContext.Provider value={connectingFrom}>
@ -853,7 +746,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
edges={edges} edges={edges}
onNodesChange={onNodesChangeHandler} onNodesChange={onNodesChangeHandler}
onEdgesChange={handleEdgesChange} onEdgesChange={handleEdgesChange}
onNodesDelete={onNodesDelete}
onConnect={onConnect} onConnect={onConnect}
onReconnect={onReconnect} onReconnect={onReconnect}
onConnectStart={onConnectStart} onConnectStart={onConnectStart}
@ -861,7 +753,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
edgesReconnectable={true} edgesReconnectable={true}
deleteKeyCode={['Backspace', 'Delete']}
fitView fitView
fitViewOptions={{ padding: 0.15 }} fitViewOptions={{ padding: 0.15 }}
minZoom={0.005} minZoom={0.005}
@ -878,30 +769,16 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" /> <Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
{/* Title Panel */} {/* Title Panel */}
<Panel position="top-left" className="m-4"> <Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
<div <h1 className="text-lg font-bold text-slate-800">Threshold-Based Flow Funding</h1>
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden" <p className="text-xs text-slate-500 mt-1">
onClick={() => setPanelsCollapsed((c) => !c)} <span className="text-emerald-600">Inflows</span> (top) &bull;
> <span className="text-amber-600 ml-1">Overflow</span> (sides) &bull;
<div className="px-4 py-2 flex items-center gap-2"> <span className="text-blue-600 ml-1">Spending</span> (bottom)
<h1 className="text-sm font-bold text-slate-800">TBFF</h1>
<svg className={`w-3 h-3 text-slate-400 transition-transform duration-300 ${panelsCollapsed ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{!panelsCollapsed && (
<div className="px-4 pb-3 border-t border-slate-100 pt-2">
<p className="text-xs text-slate-500">
<span className="text-emerald-600">Sources</span> &rarr;
<span className="text-amber-600 ml-1">Funnels</span> &rarr;
<span className="text-pink-600 ml-1">Outcomes</span>
</p> </p>
<p className="text-[10px] text-slate-400 mt-1"> <p className="text-[10px] text-slate-400 mt-1">
Drag handles to connect &bull; Double-click to edit &bull; Select + Delete to remove Drag handles to connect &bull; Double-click funnels to edit &bull; Select + Delete to remove edges
</p> </p>
</div>
)}
</div>
</Panel> </Panel>
{/* Top-right Controls */} {/* Top-right Controls */}
@ -947,60 +824,33 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
</Panel> </Panel>
{/* Legend */} {/* Legend */}
<Panel position="bottom-left" className="m-4"> <Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
<div <div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden"
onClick={() => setPanelsCollapsed((c) => !c)}
>
<div className="px-3 py-2 flex items-center gap-2">
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide">Legend</div>
{panelsCollapsed && (
<div className="flex items-center gap-1 ml-1">
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
<div className="w-2 h-2 rounded-sm bg-amber-500" />
<div className="w-2 h-2 rounded-sm bg-pink-500" />
</div>
)}
<svg className={`w-3 h-3 text-slate-400 transition-transform duration-300 ${panelsCollapsed ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</div>
{!panelsCollapsed && (
<div className="px-3 pb-3 border-t border-slate-100 pt-2">
<div className="space-y-1.5 text-xs"> <div className="space-y-1.5 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-emerald-500" /> <div className="w-3 h-3 rounded-full bg-emerald-500 border border-emerald-700" />
<span className="text-slate-600">Source (funding origin)</span> <span className="text-slate-600">Sources</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-amber-500" /> <div className="w-3 h-3 rounded-full bg-emerald-500" />
<span className="text-slate-600">Funnel (threshold pool)</span> <span className="text-slate-600">Inflows (top)</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-pink-500" /> <div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-slate-600">Outcome (deliverable)</span>
</div>
<div className="border-t border-slate-200 pt-1.5 mt-1.5 space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-emerald-500" />
<span className="text-slate-600">Source flow</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-amber-500" />
<span className="text-slate-600">Overflow (sides)</span> <span className="text-slate-600">Overflow (sides)</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-blue-500" /> <div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-slate-600">Spending (down)</span> <span className="text-slate-600">Spending (bottom)</span>
</div> </div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 border border-dashed border-green-700" />
<span className="text-slate-600">Stream (Superfluid)</span>
</div> </div>
<div className="border-t border-slate-200 pt-1.5 mt-1.5"> <div className="border-t border-slate-200 pt-1.5 mt-1.5">
<span className="text-[10px] text-slate-400">Edge width = relative flow &bull; Select + Delete to remove</span> <span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
</div> </div>
</div> </div>
</div>
)}
</div>
</Panel> </Panel>
</ReactFlow> </ReactFlow>
</ConnectionContext.Provider> </ConnectionContext.Provider>

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, undefined, { const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, {
x: xOffset, x: xOffset,
y: 100, y: 100,
}) })

View File

@ -13,22 +13,6 @@ 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',
@ -37,28 +21,12 @@ export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
color: '#04795b', color: '#04795b',
symbol: 'xDAI', symbol: 'xDAI',
}, },
137: { 10: {
name: 'Polygon', name: 'Optimism',
slug: 'polygon', slug: 'optimism',
txService: 'https://safe-transaction-polygon.safe.global', txService: 'https://safe-transaction-optimism.safe.global',
explorer: 'https://polygonscan.com', explorer: 'https://optimistic.etherscan.io',
color: '#8247e5', color: '#ff0420',
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',
}, },
} }
@ -180,99 +148,3 @@ 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, TransferSummary } from './api/safe-client' import type { SafeBalance } from './api/safe-client'
// ─── Safe Balances → Funnel Nodes ──────────────────────────── // ─── Safe Balances → Funnel Nodes ────────────────────────────
@ -11,7 +11,6 @@ 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)
@ -32,26 +31,13 @@ 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, inflowRate: 0,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [], spendingAllocations: [],
source, source,

View File

@ -34,8 +34,6 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 70000, maxThreshold: 70000,
maxCapacity: 100000, maxCapacity: 100000,
inflowRate: 1000, inflowRate: 1000,
sufficientThreshold: 60000,
dynamicOverflow: true,
overflowAllocations: [ overflowAllocations: [
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] }, { targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] }, { targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
@ -58,7 +56,6 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 50000, maxThreshold: 50000,
maxCapacity: 70000, maxCapacity: 70000,
inflowRate: 400, inflowRate: 400,
sufficientThreshold: 42000,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [ spendingAllocations: [
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] }, { targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
@ -78,7 +75,6 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 45000, maxThreshold: 45000,
maxCapacity: 60000, maxCapacity: 60000,
inflowRate: 350, inflowRate: 350,
sufficientThreshold: 38000,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [ spendingAllocations: [
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] }, { targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
@ -97,7 +93,6 @@ export const demoNodes: FlowNode[] = [
maxThreshold: 60000, maxThreshold: 60000,
maxCapacity: 80000, maxCapacity: 80000,
inflowRate: 250, inflowRate: 250,
sufficientThreshold: 50000,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [ spendingAllocations: [
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] }, { targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },

View File

@ -3,12 +3,9 @@
* *
* Replaces the random-noise simulation with actual flow logic: * Replaces the random-noise simulation with actual flow logic:
* inflow overflow distribution spending drain outcome accumulation * inflow overflow distribution spending drain outcome accumulation
*
* Sufficiency layer: funnels can declare a sufficientThreshold and dynamicOverflow.
* When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight.
*/ */
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from './types' import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
export interface SimulationConfig { export interface SimulationConfig {
tickDivisor: number // inflowRate divided by this per tick tickDivisor: number // inflowRate divided by this per tick
@ -24,85 +21,6 @@ export const DEFAULT_CONFIG: SimulationConfig = {
spendingRateCritical: 0.1, spendingRateCritical: 0.1,
} }
// ─── Sufficiency helpers ────────────────────────────────────
export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
const threshold = data.sufficientThreshold ?? data.maxThreshold
if (data.currentValue >= data.maxCapacity) return 'abundant'
if (data.currentValue >= threshold) return 'sufficient'
return 'seeking'
}
/**
* Compute need-weights for a set of overflow target IDs.
* For funnels: need = max(0, 1 - currentValue / sufficientThreshold)
* For outcomes: need = max(0, 1 - fundingReceived / fundingTarget)
* Returns a Map of targetId percentage (normalized to 100).
*/
export function computeNeedWeights(
targetIds: string[],
allNodes: FlowNode[],
): Map<string, number> {
const nodeMap = new Map(allNodes.map(n => [n.id, n]))
const needs = new Map<string, number>()
for (const tid of targetIds) {
const node = nodeMap.get(tid)
if (!node) { needs.set(tid, 0); continue }
if (node.type === 'funnel') {
const d = node.data as FunnelNodeData
const threshold = d.sufficientThreshold ?? d.maxThreshold
const need = Math.max(0, 1 - d.currentValue / (threshold || 1))
needs.set(tid, need)
} else if (node.type === 'outcome') {
const d = node.data as OutcomeNodeData
const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1))
needs.set(tid, need)
} else {
needs.set(tid, 0)
}
}
// Normalize to percentages summing to 100
const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0)
const weights = new Map<string, number>()
if (totalNeed === 0) {
// Equal distribution when all targets are satisfied
const equal = targetIds.length > 0 ? 100 / targetIds.length : 0
targetIds.forEach(id => weights.set(id, equal))
} else {
needs.forEach((need, id) => {
weights.set(id, (need / totalNeed) * 100)
})
}
return weights
}
/**
* Compute system-wide sufficiency score (0-1).
* Averages fill ratios of all funnels and progress ratios of all outcomes.
*/
export function computeSystemSufficiency(nodes: FlowNode[]): number {
let sum = 0
let count = 0
for (const node of nodes) {
if (node.type === 'funnel') {
const d = node.data as FunnelNodeData
const threshold = d.sufficientThreshold ?? d.maxThreshold
sum += Math.min(1, d.currentValue / (threshold || 1))
count++
} else if (node.type === 'outcome') {
const d = node.data as OutcomeNodeData
sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1))
count++
}
}
return count > 0 ? sum / count : 0
}
export function simulateTick( export function simulateTick(
nodes: FlowNode[], nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG, config: SimulationConfig = DEFAULT_CONFIG,
@ -148,21 +66,6 @@ export function simulateTick(
// 4. Distribute overflow when above maxThreshold // 4. Distribute overflow when above maxThreshold
if (value > data.maxThreshold && data.overflowAllocations.length > 0) { if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
const excess = value - data.maxThreshold const excess = value - data.maxThreshold
if (data.dynamicOverflow) {
// Dynamic overflow: route by need-weight instead of fixed percentages
const targetIds = data.overflowAllocations.map(a => a.targetId)
const needWeights = computeNeedWeights(targetIds, nodes)
for (const alloc of data.overflowAllocations) {
const weight = needWeights.get(alloc.targetId) ?? 0
const share = excess * (weight / 100)
overflowIncoming.set(
alloc.targetId,
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
)
}
} else {
// Fixed-percentage overflow (existing behavior)
for (const alloc of data.overflowAllocations) { for (const alloc of data.overflowAllocations) {
const share = excess * (alloc.percentage / 100) const share = excess * (alloc.percentage / 100)
overflowIncoming.set( overflowIncoming.set(
@ -170,7 +73,6 @@ export function simulateTick(
(overflowIncoming.get(alloc.targetId) ?? 0) + share, (overflowIncoming.get(alloc.targetId) ?? 0) + share,
) )
} }
}
value = data.maxThreshold value = data.maxThreshold
} }

View File

@ -89,7 +89,7 @@ export interface FundingSource {
export interface SourceAllocation { export interface SourceAllocation {
targetId: string targetId: string
percentage: number // 0-100 percentage: number
color: string color: string
} }
@ -122,7 +122,6 @@ export interface OutcomePhase {
tasks: PhaseTask[] tasks: PhaseTask[]
} }
// ─── Core Flow Types ───────────────────────────────────────── // ─── Core Flow Types ─────────────────────────────────────────
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold // Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
@ -139,8 +138,6 @@ export interface SpendingAllocation {
color: string color: string
} }
export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant'
export interface FunnelNodeData { export interface FunnelNodeData {
label: string label: string
currentValue: number currentValue: number
@ -148,9 +145,6 @@ export interface FunnelNodeData {
maxThreshold: number maxThreshold: number
maxCapacity: number maxCapacity: number
inflowRate: number inflowRate: number
// Sufficiency layer
sufficientThreshold?: number // level at which funnel has "enough" (defaults to maxThreshold)
dynamicOverflow?: boolean // when true, overflow routes by need instead of fixed %
// Overflow goes SIDEWAYS to other funnels // Overflow goes SIDEWAYS to other funnels
overflowAllocations: OverflowAllocation[] overflowAllocations: OverflowAllocation[]
// Spending goes DOWN to outcomes/outputs // Spending goes DOWN to outcomes/outputs