Compare commits

...

22 Commits
dev ... main

Author SHA1 Message Date
Jeff Emmett d999695913 merge: dev into main — source nodes + tiered outcome phases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:39:47 -08:00
Jeff Emmett 1d675ba666 Merge branch 'main' of ssh://gitea.jeffemmett.com:223/jeffemmett/rfunds-online 2026-02-27 12:55:07 -08:00
Jeff Emmett 016728bef0 merge: dev into main — TBFF canvas iteration with infinite zoom, trackpad pan, flow indicators
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:54:04 -08:00
Jeff Emmett 50fbb7808c brand: add (you)r* prefix to landing page title
Reinforces the r-suite "your tools" philosophy across all rApp landing pages.
Also normalizes title separators to em-dash (—) for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 04:28:50 +00:00
Jeff Emmett 8b2282a615 Merge branch 'dev' 2026-02-25 14:31:23 -08:00
Jeff Emmett c623a6a600 Merge branch 'dev' 2026-02-25 14:22:29 -08:00
Jeff Emmett 3ca157a588 Merge branch 'dev'
# Conflicts:
#	app/layout.tsx
#	app/page.tsx
#	components/AppSwitcher.tsx
#	docker-compose.yml
2026-02-25 13:37:24 -08:00
Jeff Emmett 5f04086606 feat: add space subdomain routing and ownership support
- Traefik wildcard HostRegexp for <space>.r*.online subdomains
- Middleware subdomain extraction and path rewriting
- Provision endpoint with owner_did acknowledgement
- Registry enforces space ownership via EncryptID JWT

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:19:47 -08:00
Jeff Emmett 5b22245076 Standardize emojis across header, favicons, and ecosystem links
Canonical emoji set for consistency:
- 🕸️ rNetwork (was 🌐), ⚖️ rChoices (was 🔀), 📋 rWork (was 💼)
- 🔐 rIdentity (was 🔑), 📖 rPubs (was 📰), 💸 rFunds (was 💰)
- 💰 rWallet (was 💼 in footer), 📬 rInbox (was ✉️)
- 💭 rForum (was 💬, differentiates from rChats)
- 📢 rSocials (was 📱), 🔨 rAuctions (was 🏛️ in footer)
- 🎬 rTube (was 📹 in footer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:57:07 -08:00
Jeff Emmett 07a9d48f71 Add 💰 emoji favicon to rFunds
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:39:51 -08:00
Jeff Emmett 1c9130aa2a Add internal provision endpoint for rSpace Registry
Simple acknowledge endpoint — no DB to provision. Returns ok status
when called by the registry during space activation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:30:14 -08:00
Jeff Emmett f5df44b7a5 Merge remote-tracking branch 'gitea/main' 2026-02-25 00:06:13 -08:00
Jeff Emmett 63164a00ad feat: add demo space auth bypass in middleware
Skip EncryptID token check for demo subdomains when
ENCRYPTID_DEMO_SPACES env var is set. Uses isDemoRequest()
from @encryptid/sdk v0.2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:06:07 -08:00
Jeff Emmett f4629a48c2 feat: standardize Header, EcosystemFooter, and shared components
Move nav to shared Header in layout.tsx, remove inline header/footer
from page.tsx. Add UserMenu, SpaceSwitcher, and updated AppSwitcher.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:04:29 -08:00
Jeff Emmett b7308ffabc fix: landing page improvements and SpaceSwitcher domain support
- SpaceSwitcher auto-derives domain from window.location
- Fix landing page issues (redirect bug, duplicate footers,
  broken links, missing content)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:32:47 -08:00
Jeff Emmett ce1804298a feat: add wildcard subdomain routing for rSpace spaces
All registered spaces (e.g. demo.rfunds-online.online) now route to this
app at Traefik priority 100. Bare domain stays at priority 120.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:08:31 +01:00
Jeff Emmett d97adce1cd Merge branch 'main' of ssh://gitea.jeffemmett.com:223/jeffemmett/rfunds-online
# Conflicts:
#	app/page.tsx
2026-02-24 19:20:45 -08:00
Jeff Emmett 4264ac9be2 feat: add dynamic enoughness layer to Budget River
Funnels can now declare a sufficientThreshold and dynamicOverflow.
When dynamicOverflow is true, surplus routes to the most underfunded
targets by need-weight instead of fixed percentages. Visual layer adds
golden glow on sufficient funnels, sufficiency progress bar, ENOUGH
status pill, and a system-wide Enoughness Score badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:38:23 +00:00
Jeff Emmett 15b9ba62a6 Add rData analytics tracking and ecosystem footer link
- Inject rdata.online/collect.js tracking script in layout
- Add rData link to ecosystem footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:57:08 +00:00
Jeff Emmett 0afb85e9f7 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>
2026-02-18 10:32:07 +00:00
Jeff Emmett e683175c65 feat: add Budget River waterfall visualization view
New /river page showing funding flows as an animated river with waterfalls
cascading in (inflows) and out (spending). SVG-based with CSS animations
for water effects, ripples, and current lines. Supports demo mode with
simulated data and live mode via Safe Global API for real on-chain balances.
Also includes source node type and landing page nav links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:02:49 +00:00
Jeff Emmett cff46bb0fc feat: standardize ecosystem footer with all 16 r-suite apps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:46:01 +00:00
11 changed files with 2072 additions and 55 deletions

View File

@ -18,3 +18,48 @@ body {
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 = {
title: 'rFunds - Threshold-Based Flow Funding',
title: '(you)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.',
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>",
},
openGraph: {
title: 'rFunds - Threshold-Based Flow Funding',
title: '(you)rFunds — Threshold-Based Flow Funding',
description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms.',
type: 'website',
url: 'https://rfunds.online',

374
app/river/page.tsx Normal file
View File

@ -0,0 +1,374 @@
'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>
)
}

1197
components/BudgetRiver.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -197,7 +197,7 @@ function generateEdges(
})
})
// Stream edges (Superfluid visual planning)
// Stream edges (Superfluid planning)
data.streamAllocations?.forEach((stream) => {
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
edges.push({
@ -228,6 +228,48 @@ 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
}
@ -244,6 +286,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
const [panelsCollapsed, setPanelsCollapsed] = useState(false)
const [connectingFrom, setConnectingFrom] = useState<ConnectingFrom | null>(null)
const edgesRef = useRef(edges)
edgesRef.current = edges
@ -727,6 +770,64 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
])
}, [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)
useEffect(() => {
if (!isSimulating) return
@ -738,6 +839,12 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
return () => clearInterval(interval)
}, [isSimulating, setNodes])
// Auto-collapse title & legend panels after 5 seconds
useEffect(() => {
const timer = setTimeout(() => setPanelsCollapsed(true), 5000)
return () => clearTimeout(timer)
}, [])
return (
<div className="w-full h-full">
<ConnectionContext.Provider value={connectingFrom}>
@ -746,6 +853,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
edges={edges}
onNodesChange={onNodesChangeHandler}
onEdgesChange={handleEdgesChange}
onNodesDelete={onNodesDelete}
onConnect={onConnect}
onReconnect={onReconnect}
onConnectStart={onConnectStart}
@ -753,6 +861,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
edgesReconnectable={true}
deleteKeyCode={['Backspace', 'Delete']}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.005}
@ -769,16 +878,30 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
{/* Title Panel */}
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
<h1 className="text-lg font-bold text-slate-800">Threshold-Based Flow Funding</h1>
<p className="text-xs text-slate-500 mt-1">
<span className="text-emerald-600">Inflows</span> (top) &bull;
<span className="text-amber-600 ml-1">Overflow</span> (sides) &bull;
<span className="text-blue-600 ml-1">Spending</span> (bottom)
</p>
<p className="text-[10px] text-slate-400 mt-1">
Drag handles to connect &bull; Double-click funnels to edit &bull; Select + Delete to remove edges
</p>
<Panel position="top-left" className="m-4">
<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-4 py-2 flex items-center gap-2">
<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 className="text-[10px] text-slate-400 mt-1">
Drag handles to connect &bull; Double-click to edit &bull; Select + Delete to remove
</p>
</div>
)}
</div>
</Panel>
{/* Top-right Controls */}
@ -824,32 +947,59 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
</Panel>
{/* Legend */}
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500 border border-emerald-700" />
<span className="text-slate-600">Sources</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500" />
<span className="text-slate-600">Inflows (top)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-slate-600">Overflow (sides)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-slate-600">Spending (bottom)</span>
</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 className="border-t border-slate-200 pt-1.5 mt-1.5">
<span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
<Panel position="bottom-left" className="m-4">
<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="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-emerald-500" />
<span className="text-slate-600">Source (funding origin)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-amber-500" />
<span className="text-slate-600">Funnel (threshold pool)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-pink-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>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-0.5 bg-blue-500" />
<span className="text-slate-600">Spending (down)</span>
</div>
</div>
<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>
</div>
</div>
</div>
)}
</div>
</Panel>
</ReactFlow>

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,

View File

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

View File

@ -3,9 +3,12 @@
*
* Replaces the random-noise simulation with actual flow logic:
* 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 } from './types'
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from './types'
export interface SimulationConfig {
tickDivisor: number // inflowRate divided by this per tick
@ -21,6 +24,85 @@ export const DEFAULT_CONFIG: SimulationConfig = {
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(
nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG,
@ -66,12 +148,28 @@ export function simulateTick(
// 4. Distribute overflow when above maxThreshold
if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
const excess = value - data.maxThreshold
for (const alloc of data.overflowAllocations) {
const share = excess * (alloc.percentage / 100)
overflowIncoming.set(
alloc.targetId,
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
)
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) {
const share = excess * (alloc.percentage / 100)
overflowIncoming.set(
alloc.targetId,
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
)
}
}
value = data.maxThreshold
}

View File

@ -89,7 +89,7 @@ export interface FundingSource {
export interface SourceAllocation {
targetId: string
percentage: number
percentage: number // 0-100
color: string
}
@ -122,6 +122,7 @@ export interface OutcomePhase {
tasks: PhaseTask[]
}
// ─── Core Flow Types ─────────────────────────────────────────
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
@ -138,6 +139,8 @@ export interface SpendingAllocation {
color: string
}
export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant'
export interface FunnelNodeData {
label: string
currentValue: number
@ -145,6 +148,9 @@ export interface FunnelNodeData {
maxThreshold: number
maxCapacity: 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
overflowAllocations: OverflowAllocation[]
// Spending goes DOWN to outcomes/outputs