diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx
index 6e7f0c6..0238fbb 100644
--- a/components/FlowCanvas.tsx
+++ b/components/FlowCanvas.tsx
@@ -19,8 +19,9 @@ import '@xyflow/react/dist/style.css'
import FunnelNode from './nodes/FunnelNode'
import OutcomeNode from './nodes/OutcomeNode'
import AllocationEdge from './edges/AllocationEdge'
+import StreamEdge from './edges/StreamEdge'
import IntegrationPanel from './IntegrationPanel'
-import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig } from '@/lib/types'
+import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
const nodeTypes = {
@@ -30,6 +31,7 @@ const nodeTypes = {
const edgeTypes = {
allocation: AllocationEdge,
+ stream: StreamEdge,
}
// Generate edges with proportional Sankey-style widths
@@ -140,6 +142,36 @@ function generateEdges(
type: 'allocation',
})
})
+
+ // Stream edges (Superfluid visual planning)
+ data.streamAllocations?.forEach((stream) => {
+ const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
+ edges.push({
+ id: `stream-${node.id}-${stream.targetId}`,
+ source: node.id,
+ target: stream.targetId,
+ sourceHandle: 'stream-out',
+ animated: false,
+ style: {
+ stroke: statusColor,
+ strokeWidth: 3,
+ },
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: statusColor,
+ width: 14,
+ height: 14,
+ },
+ data: {
+ flowRate: stream.flowRate,
+ tokenSymbol: stream.tokenSymbol,
+ status: stream.status,
+ sourceId: node.id,
+ targetId: stream.targetId,
+ },
+ type: 'stream',
+ })
+ })
})
return edges
@@ -230,6 +262,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
id: n.id,
overflow: d.overflowAllocations,
spending: d.spendingAllocations,
+ streams: d.streamAllocations,
rate: d.inflowRate,
}
})
@@ -247,8 +280,9 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const isOverflow = params.sourceHandle?.startsWith('outflow')
const isSpending = params.sourceHandle === 'spending-out'
+ const isStream = params.sourceHandle === 'stream-out'
- if (!isOverflow && !isSpending) return
+ if (!isOverflow && !isSpending && !isStream) return
setNodes((nds) => nds.map((node) => {
if (node.id !== params.source || node.type !== 'funnel') return node
@@ -276,7 +310,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
],
},
}
- } else {
+ } else if (isSpending) {
const existing = data.spendingAllocations || []
if (existing.some(a => a.targetId === params.target)) return node
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
@@ -298,6 +332,27 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
],
},
}
+ } else if (isStream) {
+ const existing = data.streamAllocations || []
+ if (existing.some(s => s.targetId === params.target)) return node
+ return {
+ ...node,
+ data: {
+ ...data,
+ streamAllocations: [
+ ...existing,
+ {
+ targetId: params.target!,
+ flowRate: 100,
+ tokenSymbol: 'DAIx',
+ status: 'planned' as const,
+ color: '#22c55e',
+ },
+ ],
+ },
+ }
+ } else {
+ return node
}
}))
},
@@ -306,13 +361,31 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const onReconnect = useCallback(
(oldEdge: FlowEdge, newConnection: Connection) => {
- const edgeData = oldEdge.data
+ const edgeData = oldEdge.data as AllocationEdgeData | undefined
if (!edgeData || !newConnection.target) return
const oldTargetId = oldEdge.target
const newTargetId = newConnection.target
if (oldTargetId === newTargetId) return
+ // Stream edges: reconnect stream allocation
+ if (oldEdge.type === 'stream') {
+ setNodes((nds) => nds.map((node) => {
+ if (node.id !== oldEdge.source || node.type !== 'funnel') return node
+ const data = node.data as FunnelNodeData
+ return {
+ ...node,
+ data: {
+ ...data,
+ streamAllocations: (data.streamAllocations || []).map(s =>
+ s.targetId === oldTargetId ? { ...s, targetId: newTargetId } : s
+ ),
+ },
+ }
+ }))
+ return
+ }
+
setNodes((nds) => nds.map((node) => {
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
@@ -353,7 +426,19 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
if (node.id !== edge.source || node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
- if (edge.data!.edgeType === 'overflow') {
+ // Stream edge removal
+ if (edge.type === 'stream') {
+ return {
+ ...node,
+ data: {
+ ...data,
+ streamAllocations: (data.streamAllocations || []).filter(s => s.targetId !== edge.target),
+ },
+ }
+ }
+
+ const allocData = edge.data as AllocationEdgeData
+ if (allocData.edgeType === 'overflow') {
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
const total = filtered.reduce((s, a) => s + a.percentage, 0)
return {
@@ -564,6 +649,10 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
Spending (bottom)
+
+
+
Stream (Superfluid)
+
Edge width = relative flow amount
diff --git a/components/IntegrationPanel.tsx b/components/IntegrationPanel.tsx
index cfeed9b..370cdb3 100644
--- a/components/IntegrationPanel.tsx
+++ b/components/IntegrationPanel.tsx
@@ -4,7 +4,9 @@ import { useState, useCallback } from 'react'
import type { FlowNode, IntegrationConfig } from '@/lib/types'
import { SUPPORTED_CHAINS, getBalances, detectSafeChains } from '@/lib/api/safe-client'
import type { SafeBalance, DetectedChain } from '@/lib/api/safe-client'
-import { safeBalancesToFunnels } from '@/lib/integrations'
+import { safeBalancesToFunnels, proposalsToOutcomes } from '@/lib/integrations'
+import { fetchSpace, fetchPassedProposals } from '@/lib/api/rvote-client'
+import type { RVoteProposal } from '@/lib/api/rvote-client'
interface IntegrationPanelProps {
isOpen: boolean
@@ -37,6 +39,7 @@ export default function IntegrationPanel({
const [rvoteError, setRvoteError] = useState('')
const [rvoteSpace, setRvoteSpace] = useState<{ name: string; memberCount: number } | null>(null)
const [rvoteProposals, setRvoteProposals] = useState>([])
+ const [fetchedProposals, setFetchedProposals] = useState([])
const handleFetchSafe = useCallback(async () => {
if (!safeAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
@@ -109,32 +112,14 @@ export default function IntegrationPanel({
setRvoteError('')
try {
- // Fetch via proxy to avoid CORS
- const spaceRes = await fetch(`/api/proxy/rvote?endpoint=space&slug=${encodeURIComponent(rvoteSlug)}`)
- if (!spaceRes.ok) {
- setRvoteError(spaceRes.status === 404 ? 'Space not found' : 'Failed to fetch space')
- setRvoteFetching(false)
- return
- }
- const space = await spaceRes.json()
+ const space = await fetchSpace(rvoteSlug)
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
- const proposalsRes = await fetch(
- `/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
+ const proposals = await fetchPassedProposals(rvoteSlug)
+ setRvoteProposals(
+ proposals.map((p) => ({ id: p.id, title: p.title, score: p.score }))
)
- if (proposalsRes.ok) {
- const data = await proposalsRes.json()
- const proposals = data.proposals || data.results || data || []
- setRvoteProposals(
- Array.isArray(proposals)
- ? proposals.map((p: { id: string; title: string; score: number }) => ({
- id: p.id,
- title: p.title,
- score: p.score,
- }))
- : []
- )
- }
+ setFetchedProposals(proposals)
onIntegrationsChange({
...integrations,
@@ -151,29 +136,14 @@ export default function IntegrationPanel({
}
}, [rvoteSlug, integrations, onIntegrationsChange])
- const handleImportRvote = useCallback(async () => {
- if (rvoteProposals.length === 0) return
-
- // Re-fetch full proposal data for import
- try {
- const res = await fetch(
- `/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
- )
- if (!res.ok) return
- const data = await res.json()
- const proposals = data.proposals || data.results || data || []
- if (!Array.isArray(proposals)) return
-
- const { proposalsToOutcomes } = await import('@/lib/integrations')
- const nodes = proposalsToOutcomes(proposals, rvoteSlug)
- if (nodes.length > 0) {
- onImportNodes(nodes)
- onClose()
- }
- } catch {
- setRvoteError('Failed to import proposals')
+ const handleImportRvote = useCallback(() => {
+ if (fetchedProposals.length === 0) return
+ const nodes = proposalsToOutcomes(fetchedProposals, rvoteSlug)
+ if (nodes.length > 0) {
+ onImportNodes(nodes)
+ onClose()
}
- }, [rvoteProposals, rvoteSlug, onImportNodes, onClose])
+ }, [fetchedProposals, rvoteSlug, onImportNodes, onClose])
const toggleChain = (chainId: number) => {
setSelectedChains((prev) =>
diff --git a/components/SplitsView.tsx b/components/SplitsView.tsx
new file mode 100644
index 0000000..8776e3e
--- /dev/null
+++ b/components/SplitsView.tsx
@@ -0,0 +1,107 @@
+'use client'
+
+import { useCallback, useState } from 'react'
+import type { SplitsConfig } from '@/lib/types'
+
+interface SplitsViewProps {
+ config: SplitsConfig
+}
+
+const CHAIN_NAMES: Record = {
+ 100: 'Gnosis',
+ 10: 'Optimism',
+ 1: 'Ethereum',
+}
+
+function truncateAddress(addr: string): string {
+ return `${addr.slice(0, 6)}...${addr.slice(-4)}`
+}
+
+const BAR_COLORS = [
+ '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981',
+ '#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#f97316',
+]
+
+export default function SplitsView({ config }: SplitsViewProps) {
+ const [copied, setCopied] = useState(false)
+
+ const handleExport = useCallback(() => {
+ const exportData = {
+ type: '0xSplits',
+ chainId: config.chainId,
+ distributorFee: config.distributorFee,
+ recipients: config.recipients.map((r) => ({
+ address: r.address,
+ percentAllocation: r.percentage * 10000, // 0xSplits uses basis points (ppm)
+ })),
+ }
+ navigator.clipboard.writeText(JSON.stringify(exportData, null, 2)).then(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ })
+ }, [config])
+
+ const totalPct = config.recipients.reduce((s, r) => s + r.percentage, 0)
+ const chainName = CHAIN_NAMES[config.chainId] || `Chain ${config.chainId}`
+
+ return (
+
+
+
+
+ 0xSplits Config
+
+
+ {chainName}
+
+
+
+ Fee: {config.distributorFee}%
+
+
+
+ {/* Combined percentage bar */}
+
+ {config.recipients.map((r, i) => (
+
+ ))}
+
+
+ {/* Recipient list */}
+
+ {config.recipients.map((r, i) => (
+
+
+
+ {r.label || truncateAddress(r.address)}
+
+
{r.percentage}%
+
+ ))}
+
+
+ {totalPct !== 100 && (
+
+ Total is {totalPct}% (should be 100%)
+
+ )}
+
+
+
+ )
+}
diff --git a/components/edges/AllocationEdge.tsx b/components/edges/AllocationEdge.tsx
index 3645bf0..6b086c0 100644
--- a/components/edges/AllocationEdge.tsx
+++ b/components/edges/AllocationEdge.tsx
@@ -7,7 +7,7 @@ import {
BaseEdge,
type EdgeProps,
} from '@xyflow/react'
-import type { FlowEdgeData } from '@/lib/types'
+import type { AllocationEdgeData } from '@/lib/types'
export default function AllocationEdge({
id,
@@ -21,7 +21,7 @@ export default function AllocationEdge({
style,
markerEnd,
}: EdgeProps) {
- const edgeData = data as FlowEdgeData | undefined
+ const edgeData = data as AllocationEdgeData | undefined
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
diff --git a/components/edges/StreamEdge.tsx b/components/edges/StreamEdge.tsx
new file mode 100644
index 0000000..b17f731
--- /dev/null
+++ b/components/edges/StreamEdge.tsx
@@ -0,0 +1,125 @@
+'use client'
+
+import {
+ getSmoothStepPath,
+ EdgeLabelRenderer,
+ BaseEdge,
+ type EdgeProps,
+} from '@xyflow/react'
+import type { StreamEdgeData } from '@/lib/types'
+
+const STATUS_COLORS: Record = {
+ planned: { stroke: '#22c55e', bg: 'rgba(34,197,94,0.12)', text: '#16a34a', label: 'Planned' },
+ active: { stroke: '#3b82f6', bg: 'rgba(59,130,246,0.12)', text: '#2563eb', label: 'Active' },
+ paused: { stroke: '#f97316', bg: 'rgba(249,115,22,0.12)', text: '#ea580c', label: 'Paused' },
+}
+
+export default function StreamEdge({
+ id,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ data,
+ markerEnd,
+}: EdgeProps) {
+ const edgeData = data as StreamEdgeData | undefined
+ const status = edgeData?.status ?? 'planned'
+ const flowRate = edgeData?.flowRate ?? 0
+ const tokenSymbol = edgeData?.tokenSymbol ?? 'DAIx'
+ const colors = STATUS_COLORS[status] || STATUS_COLORS.planned
+
+ const [edgePath, labelX, labelY] = getSmoothStepPath({
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ })
+
+ // Animated dashed line to simulate flowing tokens
+ const isPaused = status === 'paused'
+
+ return (
+ <>
+ {/* Background glow path */}
+
+ {/* Main path with animated dash */}
+
+ {/* Inject animation keyframes */}
+
+
+
+
+
+ {/* Flow rate label */}
+
+ {flowRate.toLocaleString()} {tokenSymbol}/mo
+
+ {/* Status pill */}
+
+ {colors.label}
+
+
+
+ >
+ )
+}
diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx
index 88628da..57a5bbc 100644
--- a/components/nodes/FunnelNode.tsx
+++ b/components/nodes/FunnelNode.tsx
@@ -4,6 +4,7 @@ import { memo, useState, useCallback, useRef, useEffect } from 'react'
import { Handle, Position, useReactFlow } from '@xyflow/react'
import type { NodeProps } from '@xyflow/react'
import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types'
+import SplitsView from '../SplitsView'
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
@@ -597,6 +598,15 @@ function FunnelNode({ data, selected, id }: NodeProps) {
id="spending-out"
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
/>
+
+ {/* Stream handle (Superfluid) - right side, lower */}
+
{isEditing && (
@@ -826,6 +836,36 @@ function FunnelNode({ data, selected, id }: NodeProps) {
+ {nodeData.splitsConfig && (
+
+
+
+ )}
+
+ {nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && (
+
+
+ Superfluid Streams
+
+
+ {nodeData.streamAllocations.map((stream, i) => (
+
+
+
+ {stream.targetId}
+
+
+ {stream.flowRate.toLocaleString()} {stream.tokenSymbol}/mo
+
+
+ ))}
+
+
+ )}
+
Drag pie slices to adjust • Click + to add new items
diff --git a/lib/api/rvote-client.ts b/lib/api/rvote-client.ts
new file mode 100644
index 0000000..ac170be
--- /dev/null
+++ b/lib/api/rvote-client.ts
@@ -0,0 +1,37 @@
+export interface RVoteSpace {
+ name: string
+ slug: string
+ _count?: { members: number }
+}
+
+export interface RVoteProposal {
+ id: string
+ title: string
+ description?: string
+ score: number
+ status: string
+ createdAt?: string
+}
+
+const PROXY_BASE = '/api/proxy/rvote'
+
+export async function fetchSpace(slug: string): Promise {
+ const res = await fetch(`${PROXY_BASE}?endpoint=space&slug=${encodeURIComponent(slug)}`)
+ if (!res.ok) {
+ if (res.status === 404) throw new Error('Space not found')
+ throw new Error(`Failed to fetch space: ${res.status}`)
+ }
+ return res.json()
+}
+
+export async function fetchPassedProposals(slug: string): Promise {
+ const res = await fetch(
+ `${PROXY_BASE}?endpoint=proposals&slug=${encodeURIComponent(slug)}&status=PASSED`
+ )
+ if (!res.ok) {
+ throw new Error(`Failed to fetch proposals: ${res.status}`)
+ }
+ const data = await res.json()
+ const proposals = data.proposals || data.results || data || []
+ return Array.isArray(proposals) ? proposals : []
+}
diff --git a/lib/integrations.ts b/lib/integrations.ts
index d63160f..4609e25 100644
--- a/lib/integrations.ts
+++ b/lib/integrations.ts
@@ -57,11 +57,11 @@ export function safeBalancesToFunnels(
export interface RVoteProposal {
id: string
title: string
- description: string
+ description?: string
status: string
score: number
author?: { id: string; name: string | null }
- createdAt: string
+ createdAt?: string
_count?: { votes: number; finalVotes: number }
}
diff --git a/lib/types.ts b/lib/types.ts
index 5e4e16c..8effba1 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -105,7 +105,7 @@ export interface OutcomeNodeData {
export type FlowNode = Node
-export interface FlowEdgeData {
+export interface AllocationEdgeData {
allocation: number // percentage 0-100
color: string
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
@@ -116,6 +116,17 @@ export interface FlowEdgeData {
[key: string]: unknown
}
+export interface StreamEdgeData {
+ flowRate: number
+ tokenSymbol: string
+ status: 'planned' | 'active' | 'paused'
+ sourceId: string
+ targetId: string
+ [key: string]: unknown
+}
+
+export type FlowEdgeData = AllocationEdgeData | StreamEdgeData
+
export type FlowEdge = Edge
// Serializable space config (no xyflow internals)