feat: add cross-app integration panel for Safe wallets and rVote proposals

Link external data sources to rFunds spaces:
- Safe Global API client (Gnosis + Optimism) for wallet balances → Funnel nodes
- rVote CORS proxy + client for passed proposals → Outcome nodes
- Integration panel UI with preview/import workflow
- Source badges on nodes (chain icon for Safe, rVote score for proposals)
- State persistence for integrations in save/load/share

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 10:08:19 -07:00
parent 6b29141d1a
commit f012502133
10 changed files with 798 additions and 13 deletions

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
const RVOTE_BASE = 'https://rvote.online'
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const endpoint = searchParams.get('endpoint')
const slug = searchParams.get('slug')
if (!slug || !endpoint) {
return NextResponse.json({ error: 'Missing params' }, { status: 400 })
}
// Whitelist allowed endpoints to prevent open proxy
let targetUrl: string
if (endpoint === 'space') {
targetUrl = `${RVOTE_BASE}/api/spaces/${encodeURIComponent(slug)}`
} else if (endpoint === 'proposals') {
const status = searchParams.get('status') || 'PASSED'
targetUrl = `${RVOTE_BASE}/s/${encodeURIComponent(slug)}/api/proposals?status=${status}&limit=50`
} else {
return NextResponse.json({ error: 'Invalid endpoint' }, { status: 400 })
}
try {
const res = await fetch(targetUrl, { next: { revalidate: 60 } })
if (!res.ok) {
return NextResponse.json(
{ error: `Upstream error: ${res.status}` },
{ status: res.status }
)
}
const data = await res.json()
return NextResponse.json(data)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Fetch failed' },
{ status: 502 }
)
}
}

View File

@ -5,7 +5,7 @@ import Link from 'next/link'
import { useState, useCallback, useEffect, useRef } from 'react'
import { starterNodes } from '@/lib/presets'
import { serializeState, deserializeState, saveToLocal, loadFromLocal, listSavedSpaces, deleteFromLocal } from '@/lib/state'
import type { FlowNode, SpaceConfig } from '@/lib/types'
import type { FlowNode, SpaceConfig, IntegrationConfig } from '@/lib/types'
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
ssr: false,
@ -21,6 +21,7 @@ const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
export default function SpacePage() {
const [currentNodes, setCurrentNodes] = useState<FlowNode[]>(starterNodes)
const [integrations, setIntegrations] = useState<IntegrationConfig | undefined>()
const [spaceName, setSpaceName] = useState('')
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showLoadDialog, setShowLoadDialog] = useState(false)
@ -28,6 +29,12 @@ export default function SpacePage() {
const [copied, setCopied] = useState(false)
const [loaded, setLoaded] = useState(false)
const nodesRef = useRef<FlowNode[]>(starterNodes)
const integrationsRef = useRef<IntegrationConfig | undefined>()
const handleIntegrationsChange = useCallback((config: IntegrationConfig) => {
setIntegrations(config)
integrationsRef.current = config
}, [])
// Load from URL hash on mount
useEffect(() => {
@ -39,6 +46,10 @@ export default function SpacePage() {
if (state) {
setCurrentNodes(state.nodes)
nodesRef.current = state.nodes
if (state.integrations) {
setIntegrations(state.integrations)
integrationsRef.current = state.integrations
}
}
}
setLoaded(true)
@ -49,7 +60,7 @@ export default function SpacePage() {
}, [])
const handleShare = useCallback(() => {
const compressed = serializeState(nodesRef.current)
const compressed = serializeState(nodesRef.current, integrationsRef.current)
const url = `${window.location.origin}/space#s=${compressed}`
navigator.clipboard.writeText(url).then(() => {
setCopied(true)
@ -59,7 +70,7 @@ export default function SpacePage() {
const handleSave = useCallback(() => {
if (!spaceName.trim()) return
saveToLocal(spaceName.trim(), nodesRef.current)
saveToLocal(spaceName.trim(), nodesRef.current, integrationsRef.current)
setShowSaveDialog(false)
setSpaceName('')
}, [spaceName])
@ -72,6 +83,10 @@ export default function SpacePage() {
const handleLoadSpace = useCallback((config: SpaceConfig) => {
setCurrentNodes(config.nodes)
nodesRef.current = config.nodes
if (config.integrations) {
setIntegrations(config.integrations)
integrationsRef.current = config.integrations
}
setShowLoadDialog(false)
}, [])
@ -150,6 +165,8 @@ export default function SpacePage() {
initialNodes={currentNodes}
mode="space"
onNodesChange={handleNodesChange}
integrations={integrations}
onIntegrationsChange={handleIntegrationsChange}
/>
</div>

View File

@ -19,7 +19,8 @@ import '@xyflow/react/dist/style.css'
import FunnelNode from './nodes/FunnelNode'
import OutcomeNode from './nodes/OutcomeNode'
import AllocationEdge from './edges/AllocationEdge'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
import IntegrationPanel from './IntegrationPanel'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig } from '@/lib/types'
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
const nodeTypes = {
@ -148,9 +149,12 @@ interface FlowCanvasInnerProps {
initialNodes: FlowNode[]
mode: 'demo' | 'space'
onNodesChange?: (nodes: FlowNode[]) => void
integrations?: IntegrationConfig
onIntegrationsChange?: (config: IntegrationConfig) => void
}
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) {
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integrations, onIntegrationsChange }: FlowCanvasInnerProps) {
const [showIntegrations, setShowIntegrations] = useState(false)
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
@ -384,6 +388,11 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
[onEdgesChange, setNodes]
)
// Import nodes from integrations panel
const handleImportNodes = useCallback((newNodes: FlowNode[]) => {
setNodes((nds) => [...nds, ...newNodes])
}, [setNodes])
// Add funnel node at viewport center
const addFunnel = useCallback(() => {
const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
@ -507,6 +516,12 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
<Panel position="top-right" className="m-4 flex gap-2">
{mode === 'space' && (
<>
<button
onClick={() => setShowIntegrations(true)}
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-indigo-500 text-white hover:bg-indigo-600 transition-all"
>
Link Data
</button>
<button
onClick={addFunnel}
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
@ -555,6 +570,15 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
</div>
</Panel>
</ReactFlow>
{mode === 'space' && (
<IntegrationPanel
isOpen={showIntegrations}
onClose={() => setShowIntegrations(false)}
onImportNodes={handleImportNodes}
integrations={integrations}
onIntegrationsChange={onIntegrationsChange || (() => {})}
/>
)}
</div>
)
}
@ -564,12 +588,20 @@ interface FlowCanvasProps {
initialNodes: FlowNode[]
mode?: 'demo' | 'space'
onNodesChange?: (nodes: FlowNode[]) => void
integrations?: IntegrationConfig
onIntegrationsChange?: (config: IntegrationConfig) => void
}
export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange }: FlowCanvasProps) {
export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange, integrations, onIntegrationsChange }: FlowCanvasProps) {
return (
<ReactFlowProvider>
<FlowCanvasInner initialNodes={initialNodes} mode={mode} onNodesChange={onNodesChange} />
<FlowCanvasInner
initialNodes={initialNodes}
mode={mode}
onNodesChange={onNodesChange}
integrations={integrations}
onIntegrationsChange={onIntegrationsChange}
/>
</ReactFlowProvider>
)
}

View File

@ -0,0 +1,358 @@
'use client'
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'
interface IntegrationPanelProps {
isOpen: boolean
onClose: () => void
onImportNodes: (nodes: FlowNode[]) => void
integrations?: IntegrationConfig
onIntegrationsChange: (config: IntegrationConfig) => void
}
export default function IntegrationPanel({
isOpen,
onClose,
onImportNodes,
integrations,
onIntegrationsChange,
}: IntegrationPanelProps) {
// Safe wallet state
const [safeAddress, setSafeAddress] = useState(integrations?.safe?.address || '')
const [selectedChains, setSelectedChains] = useState<number[]>(
integrations?.safe?.chainIds || [100, 10]
)
const [detectedChains, setDetectedChains] = useState<DetectedChain[]>([])
const [balances, setBalances] = useState<Map<number, SafeBalance[]>>(new Map())
const [safeFetching, setSafeFetching] = useState(false)
const [safeError, setSafeError] = useState('')
// rVote state
const [rvoteSlug, setRvoteSlug] = useState(integrations?.rvote?.spaceSlug || '')
const [rvoteFetching, setRvoteFetching] = useState(false)
const [rvoteError, setRvoteError] = useState('')
const [rvoteSpace, setRvoteSpace] = useState<{ name: string; memberCount: number } | null>(null)
const [rvoteProposals, setRvoteProposals] = useState<Array<{ id: string; title: string; score: number }>>([])
const handleFetchSafe = useCallback(async () => {
if (!safeAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
setSafeError('Invalid Ethereum address')
return
}
setSafeFetching(true)
setSafeError('')
setBalances(new Map())
try {
const detected = await detectSafeChains(safeAddress, selectedChains)
setDetectedChains(detected)
if (detected.length === 0) {
setSafeError('No Safe found on selected chains')
setSafeFetching(false)
return
}
const balanceMap = new Map<number, SafeBalance[]>()
for (const { chainId } of detected) {
const chainBalances = await getBalances(safeAddress, chainId)
balanceMap.set(chainId, chainBalances)
}
setBalances(balanceMap)
onIntegrationsChange({
...integrations,
safe: {
address: safeAddress,
chainIds: detected.map((d) => d.chainId),
lastSyncedAt: Date.now(),
},
})
} catch (e) {
setSafeError(e instanceof Error ? e.message : 'Failed to fetch')
} finally {
setSafeFetching(false)
}
}, [safeAddress, selectedChains, integrations, onIntegrationsChange])
const handleImportSafe = useCallback(() => {
const allNodes: FlowNode[] = []
let xOffset = 0
balances.forEach((chainBalances, chainId) => {
const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, {
x: xOffset,
y: 100,
})
allNodes.push(...nodes)
xOffset += nodes.length * 280
})
if (allNodes.length > 0) {
onImportNodes(allNodes)
onClose()
}
}, [balances, safeAddress, onImportNodes, onClose])
const handleFetchRvote = useCallback(async () => {
if (!rvoteSlug.trim()) {
setRvoteError('Enter a space slug')
return
}
setRvoteFetching(true)
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()
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
const proposalsRes = await fetch(
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
)
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,
}))
: []
)
}
onIntegrationsChange({
...integrations,
rvote: {
spaceSlug: rvoteSlug,
spaceName: space.name,
lastSyncedAt: Date.now(),
},
})
} catch (e) {
setRvoteError(e instanceof Error ? e.message : 'Failed to fetch')
} finally {
setRvoteFetching(false)
}
}, [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')
}
}, [rvoteProposals, rvoteSlug, onImportNodes, onClose])
const toggleChain = (chainId: number) => {
setSelectedChains((prev) =>
prev.includes(chainId) ? prev.filter((c) => c !== chainId) : [...prev, chainId]
)
}
if (!isOpen) return null
const totalBalances = Array.from(balances.values()).flat().filter((b) => parseFloat(b.fiatBalance) > 1)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-2xl border border-slate-200 w-full max-w-lg max-h-[85vh] overflow-y-auto m-4">
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between rounded-t-xl">
<h2 className="text-lg font-bold text-slate-800">Link External Data</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-xl font-bold">
&times;
</button>
</div>
<div className="p-6 space-y-6">
{/* ─── Safe Wallet Section ─── */}
<div>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
Safe Wallet Funnels
</h3>
<div className="space-y-3">
<input
type="text"
value={safeAddress}
onChange={(e) => setSafeAddress(e.target.value)}
placeholder="0x... Safe address"
className="w-full px-3 py-2 rounded-lg border border-slate-300 text-sm font-mono focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
/>
<div className="flex gap-2">
{Object.entries(SUPPORTED_CHAINS).map(([id, chain]) => (
<button
key={id}
onClick={() => toggleChain(Number(id))}
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-all ${
selectedChains.includes(Number(id))
? 'border-current text-white'
: 'border-slate-200 text-slate-400'
}`}
style={
selectedChains.includes(Number(id))
? { backgroundColor: chain.color, borderColor: chain.color }
: undefined
}
>
{chain.name}
</button>
))}
</div>
<button
onClick={handleFetchSafe}
disabled={safeFetching || !safeAddress || selectedChains.length === 0}
className="w-full px-4 py-2 rounded-lg text-sm font-medium bg-emerald-500 text-white hover:bg-emerald-600 disabled:bg-slate-200 disabled:text-slate-400 transition-all"
>
{safeFetching ? 'Fetching...' : 'Fetch Balances'}
</button>
{safeError && <p className="text-xs text-red-500">{safeError}</p>}
{detectedChains.length > 0 && (
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<p className="text-xs text-slate-500">
Safe found on{' '}
{detectedChains.map((d) => d.chain.name).join(', ')}
{' '}({detectedChains[0]?.safeInfo.owners.length} owners, {detectedChains[0]?.safeInfo.threshold} threshold)
</p>
{totalBalances.length > 0 && (
<>
<div className="space-y-1">
{totalBalances.slice(0, 8).map((b, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-700">{b.symbol}</span>
<span className="text-slate-500">
{b.balanceFormatted} (${parseFloat(b.fiatBalance).toLocaleString(undefined, { maximumFractionDigits: 0 })})
</span>
</div>
))}
{totalBalances.length > 8 && (
<p className="text-[10px] text-slate-400">
+{totalBalances.length - 8} more tokens
</p>
)}
</div>
<button
onClick={handleImportSafe}
className="w-full px-3 py-2 rounded-lg text-sm font-medium bg-blue-500 text-white hover:bg-blue-600 transition-all"
>
Import {totalBalances.length} Funnel{totalBalances.length !== 1 ? 's' : ''}
</button>
</>
)}
</div>
)}
</div>
</div>
<div className="border-t border-slate-200" />
{/* ─── rVote Section ─── */}
<div>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-violet-500" />
rVote Space Outcomes
</h3>
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={rvoteSlug}
onChange={(e) => setRvoteSlug(e.target.value)}
placeholder="Space slug (e.g., crypto)"
className="flex-1 px-3 py-2 rounded-lg border border-slate-300 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400"
/>
<span className="flex items-center text-xs text-slate-400">.rvote.online</span>
</div>
<button
onClick={handleFetchRvote}
disabled={rvoteFetching || !rvoteSlug.trim()}
className="w-full px-4 py-2 rounded-lg text-sm font-medium bg-violet-500 text-white hover:bg-violet-600 disabled:bg-slate-200 disabled:text-slate-400 transition-all"
>
{rvoteFetching ? 'Fetching...' : 'Fetch Proposals'}
</button>
{rvoteError && <p className="text-xs text-red-500">{rvoteError}</p>}
{rvoteSpace && (
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<p className="text-xs text-slate-500">
<span className="font-medium text-slate-700">{rvoteSpace.name}</span>
{' '}&bull; {rvoteSpace.memberCount} members
{' '}&bull; {rvoteProposals.length} passed proposal{rvoteProposals.length !== 1 ? 's' : ''}
</p>
{rvoteProposals.length > 0 ? (
<>
<div className="space-y-1">
{rvoteProposals.slice(0, 6).map((p) => (
<div key={p.id} className="flex items-center justify-between text-xs">
<span className="text-slate-700 truncate mr-2">{p.title}</span>
<span className="text-violet-500 font-medium shrink-0">+{p.score}</span>
</div>
))}
{rvoteProposals.length > 6 && (
<p className="text-[10px] text-slate-400">
+{rvoteProposals.length - 6} more
</p>
)}
</div>
<button
onClick={handleImportRvote}
className="w-full px-3 py-2 rounded-lg text-sm font-medium bg-blue-500 text-white hover:bg-blue-600 transition-all"
>
Import {rvoteProposals.length} Outcome{rvoteProposals.length !== 1 ? 's' : ''}
</button>
</>
) : (
<p className="text-xs text-slate-400">No passed proposals found in this space</p>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -354,6 +354,17 @@ function FunnelNode({ data, selected, id }: NodeProps) {
{isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'}
</span>
</div>
{nodeData.source?.type === 'safe' && (
<div className="flex items-center gap-1 mt-0.5">
<span
className="w-1.5 h-1.5 rounded-full inline-block"
style={{ backgroundColor: nodeData.source.safeChainId === 100 ? '#04795b' : nodeData.source.safeChainId === 10 ? '#ff0420' : '#627eea' }}
/>
<span className="text-[9px] text-slate-400 font-mono">
{nodeData.source.tokenSymbol} &bull; {nodeData.source.safeChainId === 100 ? 'Gnosis' : nodeData.source.safeChainId === 10 ? 'Optimism' : `Chain ${nodeData.source.safeChainId}`}
</span>
</div>
)}
</div>
<div className="p-3">

View File

@ -43,7 +43,14 @@ function OutcomeNode({ data, selected }: NodeProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
<div className="min-w-0">
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
{nodeData.source?.type === 'rvote' && (
<span className="text-[9px] text-violet-500 font-medium">
rVote &bull; score +{nodeData.source.rvoteProposalScore ?? 0}
</span>
)}
</div>
</div>
</div>

150
lib/api/safe-client.ts Normal file
View File

@ -0,0 +1,150 @@
/**
* Safe Global API Client for rFunds.online
* Ported from rWallet's safe-api.js TypeScript, Gnosis + Optimism only
*/
export interface ChainConfig {
name: string
slug: string
txService: string
explorer: string
color: string
symbol: string
}
export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
100: {
name: 'Gnosis',
slug: 'gnosis-chain',
txService: 'https://safe-transaction-gnosis-chain.safe.global',
explorer: 'https://gnosisscan.io',
color: '#04795b',
symbol: 'xDAI',
},
10: {
name: 'Optimism',
slug: 'optimism',
txService: 'https://safe-transaction-optimism.safe.global',
explorer: 'https://optimistic.etherscan.io',
color: '#ff0420',
symbol: 'ETH',
},
}
export interface SafeInfo {
address: string
nonce: number
threshold: number
owners: string[]
version: string
chainId: number
}
export interface SafeBalance {
tokenAddress: string | null
token: {
name: string
symbol: string
decimals: number
logoUri?: string
} | null
balance: string
balanceFormatted: string
symbol: string
fiatBalance: string
fiatConversion: string
}
export interface DetectedChain {
chainId: number
chain: ChainConfig
safeInfo: SafeInfo
}
function getChain(chainId: number): ChainConfig {
const chain = SUPPORTED_CHAINS[chainId]
if (!chain) throw new Error(`Unsupported chain ID: ${chainId}`)
return chain
}
function apiUrl(chainId: number, path: string): string {
return `${getChain(chainId).txService}/api/v1${path}`
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function fetchJSON<T>(url: string, retries = 5): Promise<T | null> {
for (let attempt = 0; attempt <= retries; attempt++) {
const res = await fetch(url)
if (res.status === 404) return null
if (res.status === 429) {
const delay = Math.min(2000 * Math.pow(2, attempt), 32000)
await sleep(delay)
continue
}
if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`)
return res.json() as Promise<T>
}
return null
}
export async function getSafeInfo(address: string, chainId: number): Promise<SafeInfo | null> {
const data = await fetchJSON<Record<string, unknown>>(apiUrl(chainId, `/safes/${address}/`))
if (!data) return null
return {
address: data.address as string,
nonce: data.nonce as number,
threshold: data.threshold as number,
owners: data.owners as string[],
version: data.version as string,
chainId,
}
}
export async function getBalances(address: string, chainId: number): Promise<SafeBalance[]> {
const data = await fetchJSON<Array<Record<string, unknown>>>(
apiUrl(chainId, `/safes/${address}/balances/?trusted=true&exclude_spam=true`)
)
if (!data) return []
const chain = SUPPORTED_CHAINS[chainId]
return data.map((b) => {
const token = b.token as { name: string; symbol: string; decimals: number; logoUri?: string } | null
return {
tokenAddress: (b.tokenAddress as string) || null,
token,
balance: b.balance as string,
balanceFormatted: token
? (parseFloat(b.balance as string) / Math.pow(10, token.decimals)).toFixed(token.decimals > 6 ? 4 : 2)
: (parseFloat(b.balance as string) / 1e18).toFixed(4),
symbol: token ? token.symbol : chain?.symbol || 'ETH',
fiatBalance: (b.fiatBalance as string) || '0',
fiatConversion: (b.fiatConversion as string) || '0',
}
})
}
export async function detectSafeChains(
address: string,
chainIds?: number[]
): Promise<DetectedChain[]> {
const ids = chainIds || Object.keys(SUPPORTED_CHAINS).map(Number)
const results: DetectedChain[] = []
// Check chains sequentially with small delay to avoid rate limits
for (const chainId of ids) {
const chain = SUPPORTED_CHAINS[chainId]
if (!chain) continue
try {
const info = await getSafeInfo(address, chainId)
if (info) results.push({ chainId, chain, safeInfo: info })
} catch {
// skip failed chains
}
if (ids.length > 1) await sleep(300)
}
return results
}

99
lib/integrations.ts Normal file
View File

@ -0,0 +1,99 @@
/**
* Integration transforms: convert external API data into FlowNodes
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, IntegrationSource } from './types'
import type { SafeBalance } from './api/safe-client'
// ─── Safe Balances → Funnel Nodes ────────────────────────────
export function safeBalancesToFunnels(
balances: SafeBalance[],
safeAddress: string,
chainId: number,
startPosition = { x: 0, y: 100 }
): FlowNode[] {
// Filter to non-zero balances with meaningful fiat value (> $1)
const meaningful = balances.filter((b) => {
const fiat = parseFloat(b.fiatBalance)
return fiat > 1
})
return meaningful.map((b, i) => {
const fiatValue = parseFloat(b.fiatBalance)
const source: IntegrationSource = {
type: 'safe',
safeAddress,
safeChainId: chainId,
tokenAddress: b.tokenAddress,
tokenSymbol: b.symbol,
tokenDecimals: b.token?.decimals ?? 18,
lastFetchedAt: Date.now(),
}
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,
overflowAllocations: [],
spendingAllocations: [],
source,
}
return {
id: `safe-${chainId}-${b.tokenAddress || 'native'}-${Date.now()}`,
type: 'funnel' as const,
position: { x: startPosition.x + i * 280, y: startPosition.y },
data,
}
})
}
// ─── rVote Proposals → Outcome Nodes ─────────────────────────
export interface RVoteProposal {
id: string
title: string
description: string
status: string
score: number
author?: { id: string; name: string | null }
createdAt: string
_count?: { votes: number; finalVotes: number }
}
export function proposalsToOutcomes(
proposals: RVoteProposal[],
spaceSlug: string,
startPosition = { x: 0, y: 600 }
): FlowNode[] {
return proposals.map((p, i) => {
const source: IntegrationSource = {
type: 'rvote',
rvoteSpaceSlug: spaceSlug,
rvoteProposalId: p.id,
rvoteProposalStatus: p.status,
rvoteProposalScore: p.score,
lastFetchedAt: Date.now(),
}
const data: OutcomeNodeData = {
label: p.title.length > 40 ? p.title.slice(0, 37) + '...' : p.title,
description: p.description?.slice(0, 200) || '',
fundingReceived: 0,
fundingTarget: 0, // user sets this manually
status: 'not-started',
source,
}
return {
id: `rvote-${p.id}-${Date.now()}`,
type: 'outcome' as const,
position: { x: startPosition.x + i * 250, y: startPosition.y },
data,
}
})
}

View File

@ -1,34 +1,37 @@
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
import type { FlowNode, SpaceConfig } from './types'
import type { FlowNode, SpaceConfig, IntegrationConfig } from './types'
const STORAGE_PREFIX = 'rfunds-space-'
interface SerializableState {
nodes: FlowNode[]
integrations?: IntegrationConfig
}
export function serializeState(nodes: FlowNode[]): string {
export function serializeState(nodes: FlowNode[], integrations?: IntegrationConfig): string {
const state: SerializableState = { nodes }
if (integrations) state.integrations = integrations
const json = JSON.stringify(state)
return compressToEncodedURIComponent(json)
}
export function deserializeState(compressed: string): { nodes: FlowNode[] } | null {
export function deserializeState(compressed: string): { nodes: FlowNode[]; integrations?: IntegrationConfig } | null {
try {
const json = decompressFromEncodedURIComponent(compressed)
if (!json) return null
const state = JSON.parse(json) as SerializableState
if (!state.nodes || !Array.isArray(state.nodes)) return null
return { nodes: state.nodes }
return { nodes: state.nodes, integrations: state.integrations }
} catch {
return null
}
}
export function saveToLocal(name: string, nodes: FlowNode[]): void {
export function saveToLocal(name: string, nodes: FlowNode[], integrations?: IntegrationConfig): void {
const config: SpaceConfig = {
name,
nodes,
integrations,
createdAt: Date.now(),
updatedAt: Date.now(),
}

View File

@ -1,5 +1,65 @@
import type { Node, Edge } from '@xyflow/react'
// ─── Integration Source Metadata ─────────────────────────────
export interface IntegrationSource {
type: 'rvote' | 'safe' | 'manual'
// Safe fields
safeAddress?: string
safeChainId?: number
tokenAddress?: string | null // null = native token
tokenSymbol?: string
tokenDecimals?: number
// rVote fields
rvoteSpaceSlug?: string
rvoteProposalId?: string
rvoteProposalStatus?: string
rvoteProposalScore?: number
lastFetchedAt?: number
}
// ─── Superfluid Stream (planning only) ───────────────────────
export interface StreamAllocation {
targetId: string
flowRate: number // tokens per month
tokenSymbol: string // e.g., 'DAIx', 'USDCx'
tokenAddress?: string
status: 'planned' | 'active' | 'paused'
color: string
}
// ─── 0xSplits Configuration (planning only) ──────────────────
export interface SplitRecipient {
address: string
label?: string
percentage: number // 0-100, must sum to 100
}
export interface SplitsConfig {
recipients: SplitRecipient[]
distributorFee: number // 0-10%
chainId: number
}
// ─── Integration Config (persisted per space) ────────────────
export interface IntegrationConfig {
rvote?: {
spaceSlug: string
spaceName?: string
lastSyncedAt?: number
}
safe?: {
address: string
chainIds: number[] // e.g., [100, 10] for Gnosis + Optimism
lastSyncedAt?: number
}
}
// ─── Core Flow Types ─────────────────────────────────────────
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
export interface OverflowAllocation {
targetId: string
@ -25,6 +85,10 @@ export interface FunnelNodeData {
overflowAllocations: OverflowAllocation[]
// Spending goes DOWN to outcomes/outputs
spendingAllocations: SpendingAllocation[]
// Integration metadata
source?: IntegrationSource
streamAllocations?: StreamAllocation[]
splitsConfig?: SplitsConfig
[key: string]: unknown
}
@ -34,6 +98,8 @@ export interface OutcomeNodeData {
fundingReceived: number
fundingTarget: number
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
// Integration metadata
source?: IntegrationSource
[key: string]: unknown
}
@ -56,6 +122,7 @@ export type FlowEdge = Edge<FlowEdgeData>
export interface SpaceConfig {
name: string
nodes: FlowNode[]
integrations?: IntegrationConfig
createdAt: number
updatedAt: number
}