From f0125021339389825f77b7344d95ae0d967f69f9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 10:08:19 -0700 Subject: [PATCH] feat: add cross-app integration panel for Safe wallets and rVote proposals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/proxy/rvote/route.ts | 41 ++++ app/space/page.tsx | 23 +- components/FlowCanvas.tsx | 40 +++- components/IntegrationPanel.tsx | 358 +++++++++++++++++++++++++++++++ components/nodes/FunnelNode.tsx | 11 + components/nodes/OutcomeNode.tsx | 9 +- lib/api/safe-client.ts | 150 +++++++++++++ lib/integrations.ts | 99 +++++++++ lib/state.ts | 13 +- lib/types.ts | 67 ++++++ 10 files changed, 798 insertions(+), 13 deletions(-) create mode 100644 app/api/proxy/rvote/route.ts create mode 100644 components/IntegrationPanel.tsx create mode 100644 lib/api/safe-client.ts create mode 100644 lib/integrations.ts diff --git a/app/api/proxy/rvote/route.ts b/app/api/proxy/rvote/route.ts new file mode 100644 index 0000000..69b980f --- /dev/null +++ b/app/api/proxy/rvote/route.ts @@ -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 } + ) + } +} diff --git a/app/space/page.tsx b/app/space/page.tsx index 8a242ba..8d074e6 100644 --- a/app/space/page.tsx +++ b/app/space/page.tsx @@ -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(starterNodes) + const [integrations, setIntegrations] = useState() 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(starterNodes) + const integrationsRef = useRef() + + 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} /> diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 0aa6850..6e7f0c6 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -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 {mode === 'space' && ( <> + + + +
+ {/* ─── Safe Wallet Section ─── */} +
+

+ + Safe Wallet → Funnels +

+ +
+ 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" + /> + +
+ {Object.entries(SUPPORTED_CHAINS).map(([id, chain]) => ( + + ))} +
+ + + + {safeError &&

{safeError}

} + + {detectedChains.length > 0 && ( +
+

+ Safe found on{' '} + {detectedChains.map((d) => d.chain.name).join(', ')} + {' '}({detectedChains[0]?.safeInfo.owners.length} owners, {detectedChains[0]?.safeInfo.threshold} threshold) +

+ + {totalBalances.length > 0 && ( + <> +
+ {totalBalances.slice(0, 8).map((b, i) => ( +
+ {b.symbol} + + {b.balanceFormatted} (${parseFloat(b.fiatBalance).toLocaleString(undefined, { maximumFractionDigits: 0 })}) + +
+ ))} + {totalBalances.length > 8 && ( +

+ +{totalBalances.length - 8} more tokens +

+ )} +
+ + + + )} +
+ )} +
+
+ +
+ + {/* ─── rVote Section ─── */} +
+

+ + rVote Space → Outcomes +

+ +
+
+ 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" + /> + .rvote.online +
+ + + + {rvoteError &&

{rvoteError}

} + + {rvoteSpace && ( +
+

+ {rvoteSpace.name} + {' '}• {rvoteSpace.memberCount} members + {' '}• {rvoteProposals.length} passed proposal{rvoteProposals.length !== 1 ? 's' : ''} +

+ + {rvoteProposals.length > 0 ? ( + <> +
+ {rvoteProposals.slice(0, 6).map((p) => ( +
+ {p.title} + +{p.score} +
+ ))} + {rvoteProposals.length > 6 && ( +

+ +{rvoteProposals.length - 6} more +

+ )} +
+ + + + ) : ( +

No passed proposals found in this space

+ )} +
+ )} +
+
+
+
+ + ) +} diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx index 1be2c75..88628da 100644 --- a/components/nodes/FunnelNode.tsx +++ b/components/nodes/FunnelNode.tsx @@ -354,6 +354,17 @@ function FunnelNode({ data, selected, id }: NodeProps) { {isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'} + {nodeData.source?.type === 'safe' && ( +
+ + + {nodeData.source.tokenSymbol} • {nodeData.source.safeChainId === 100 ? 'Gnosis' : nodeData.source.safeChainId === 10 ? 'Optimism' : `Chain ${nodeData.source.safeChainId}`} + +
+ )}
diff --git a/components/nodes/OutcomeNode.tsx b/components/nodes/OutcomeNode.tsx index ef52501..fd25301 100644 --- a/components/nodes/OutcomeNode.tsx +++ b/components/nodes/OutcomeNode.tsx @@ -43,7 +43,14 @@ function OutcomeNode({ data, selected }: NodeProps) {
- {label} +
+ {label} + {nodeData.source?.type === 'rvote' && ( + + rVote • score +{nodeData.source.rvoteProposalScore ?? 0} + + )} +
diff --git a/lib/api/safe-client.ts b/lib/api/safe-client.ts new file mode 100644 index 0000000..b541874 --- /dev/null +++ b/lib/api/safe-client.ts @@ -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 = { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function fetchJSON(url: string, retries = 5): Promise { + 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 + } + return null +} + +export async function getSafeInfo(address: string, chainId: number): Promise { + const data = await fetchJSON>(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 { + const data = await fetchJSON>>( + 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 { + 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 +} diff --git a/lib/integrations.ts b/lib/integrations.ts new file mode 100644 index 0000000..d63160f --- /dev/null +++ b/lib/integrations.ts @@ -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, + } + }) +} diff --git a/lib/state.ts b/lib/state.ts index df4a230..ec7ad93 100644 --- a/lib/state.ts +++ b/lib/state.ts @@ -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(), } diff --git a/lib/types.ts b/lib/types.ts index d0e61ef..5e4e16c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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 export interface SpaceConfig { name: string nodes: FlowNode[] + integrations?: IntegrationConfig createdAt: number updatedAt: number }