From d5d5be3fa6d26cff7c360e8ce6bb8d893740cd54 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 11:14:54 -0700 Subject: [PATCH] feat: integrate flow-service backend API for live TBFF deployment Add Deploy/Sync/Deposit buttons to space editor that connect to the flow-service backend at rfunds.online/api/flows. Visual designs can now be deployed as live flows with real threshold logic and overflow distribution. Co-Authored-By: Claude Opus 4.6 --- .gitleaksignore | 1 + app/space/page.tsx | 341 +++++++++++++++++++++++++++++++- lib/api/flows-client.ts | 425 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 .gitleaksignore create mode 100644 lib/api/flows-client.ts diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..9e1069f --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1 @@ +lib/api/flows-client.ts:generic-api-key:93 diff --git a/app/space/page.tsx b/app/space/page.tsx index 8d074e6..c16a326 100644 --- a/app/space/page.tsx +++ b/app/space/page.tsx @@ -6,6 +6,16 @@ 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, IntegrationConfig } from '@/lib/types' +import { + deployNodes, + getFlow, + syncNodesToBackend, + deposit, + listFlows, + fromSmallestUnit, + type BackendFlow, + type DeployResult, +} from '@/lib/api/flows-client' const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), { ssr: false, @@ -31,6 +41,34 @@ export default function SpacePage() { const nodesRef = useRef(starterNodes) const integrationsRef = useRef() + // Backend integration state + const [deployedFlow, setDeployedFlow] = useState(null) + const [idMap, setIdMap] = useState>({}) + const [showDeployDialog, setShowDeployDialog] = useState(false) + const [showDepositDialog, setShowDepositDialog] = useState(false) + const [showLoadFlowDialog, setShowLoadFlowDialog] = useState(false) + const [deployName, setDeployName] = useState('') + const [ownerAddress, setOwnerAddress] = useState('') + const [deploying, setDeploying] = useState(false) + const [syncing, setSyncing] = useState(false) + const [depositFunnelId, setDepositFunnelId] = useState('') + const [depositAmount, setDepositAmount] = useState('') + const [depositing, setDepositing] = useState(false) + const [backendFlows, setBackendFlows] = useState([]) + const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null) + + // Load saved owner address + useEffect(() => { + if (typeof window === 'undefined') return + const saved = localStorage.getItem('rfunds-owner-address') + if (saved) setOwnerAddress(saved) + }, []) + + const showStatus = useCallback((text: string, type: 'success' | 'error') => { + setStatusMessage({ text, type }) + setTimeout(() => setStatusMessage(null), 4000) + }, []) + const handleIntegrationsChange = useCallback((config: IntegrationConfig) => { setIntegrations(config) integrationsRef.current = config @@ -99,11 +137,107 @@ export default function SpacePage() { if (confirm('Reset canvas to a single empty funnel? This cannot be undone.')) { setCurrentNodes([...starterNodes]) nodesRef.current = [...starterNodes] - // Clear URL hash + setDeployedFlow(null) + setIdMap({}) window.history.replaceState(null, '', window.location.pathname) } }, []) + // ─── Backend Integration Handlers ─────────────────────────── + + const handleDeploy = useCallback(async () => { + if (!deployName.trim() || !ownerAddress.trim()) return + setDeploying(true) + try { + localStorage.setItem('rfunds-owner-address', ownerAddress.trim()) + const result: DeployResult = await deployNodes( + deployName.trim(), + ownerAddress.trim(), + nodesRef.current + ) + setDeployedFlow(result.flow) + setIdMap(result.idMap) + setShowDeployDialog(false) + setDeployName('') + showStatus(`Deployed "${result.flow.name}" (${result.flow.funnels.length} funnels, ${result.flow.outcomes.length} outcomes)`, 'success') + } catch (err) { + showStatus(`Deploy failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') + } finally { + setDeploying(false) + } + }, [deployName, ownerAddress, showStatus]) + + const handleSync = useCallback(async () => { + if (!deployedFlow) return + setSyncing(true) + try { + const latest = await getFlow(deployedFlow.id) + setDeployedFlow(latest) + const updated = syncNodesToBackend(nodesRef.current, latest, idMap) + setCurrentNodes(updated) + nodesRef.current = updated + showStatus('Synced live balances from backend', 'success') + } catch (err) { + showStatus(`Sync failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') + } finally { + setSyncing(false) + } + }, [deployedFlow, idMap, showStatus]) + + const handleDeposit = useCallback(async () => { + if (!deployedFlow || !depositFunnelId || !depositAmount) return + setDepositing(true) + try { + const amount = parseFloat(depositAmount) + if (isNaN(amount) || amount <= 0) throw new Error('Invalid amount') + const tx = await deposit(deployedFlow.id, depositFunnelId, amount) + showStatus(`Deposited $${amount} USDC (tx: ${tx.status})`, 'success') + setShowDepositDialog(false) + setDepositAmount('') + // Auto-sync after deposit + const latest = await getFlow(deployedFlow.id) + setDeployedFlow(latest) + const updated = syncNodesToBackend(nodesRef.current, latest, idMap) + setCurrentNodes(updated) + nodesRef.current = updated + } catch (err) { + showStatus(`Deposit failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') + } finally { + setDepositing(false) + } + }, [deployedFlow, depositFunnelId, depositAmount, idMap, showStatus]) + + const handleLoadFlowOpen = useCallback(async () => { + if (!ownerAddress.trim()) { + showStatus('Set your wallet address first (via Deploy dialog)', 'error') + return + } + try { + const flows = await listFlows(ownerAddress.trim()) + setBackendFlows(flows) + setShowLoadFlowDialog(true) + } catch (err) { + showStatus(`Failed to list flows: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') + } + }, [ownerAddress, showStatus]) + + const handleLoadFlow = useCallback((flow: BackendFlow) => { + setDeployedFlow(flow) + // Build idMap from backend flow — map by index (best effort without original mapping) + const newIdMap: Record = {} + const funnelNodes = nodesRef.current.filter(n => n.type === 'funnel') + const outcomeNodes = nodesRef.current.filter(n => n.type === 'outcome') + flow.funnels.forEach((f, i) => { + if (funnelNodes[i]) newIdMap[funnelNodes[i].id] = f.id + }) + flow.outcomes.forEach((o, i) => { + if (outcomeNodes[i]) newIdMap[outcomeNodes[i].id] = o.id + }) + setIdMap(newIdMap) + setShowLoadFlowDialog(false) + showStatus(`Loaded flow "${flow.name}" (${flow.status})`, 'success') + }, [showStatus]) + if (!loaded) { return (
@@ -117,6 +251,15 @@ export default function SpacePage() { return (
+ {/* Status Toast */} + {statusMessage && ( +
+ {statusMessage.text} +
+ )} + {/* Toolbar */}
@@ -128,9 +271,22 @@ export default function SpacePage() { | Your Space + {deployedFlow && ( + <> + | + + {deployedFlow.status} · {deployedFlow.name} + + + )}
+ {/* Local operations */} + + {/* Separator */} +
+ + {/* Backend operations */} + + {deployedFlow && ( + <> + + + + )} + + + {/* Separator */} +
+
)} + + {/* Deploy Dialog */} + {showDeployDialog && ( +
setShowDeployDialog(false)}> +
e.stopPropagation()}> +

Deploy to Backend

+

+ Creates a live flow on the server with real threshold logic, overflow distribution, and fiat on-ramp support. +

+ setDeployName(e.target.value)} + placeholder="Flow name..." + className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-3 text-slate-800" + autoFocus + /> + setOwnerAddress(e.target.value)} + placeholder="Owner wallet address (0x...)" + className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-3 text-slate-800 font-mono text-xs" + /> +
+ {nodesRef.current.filter(n => n.type === 'funnel').length} funnels •{' '} + {nodesRef.current.filter(n => n.type === 'outcome').length} outcomes will be created on Base Sepolia (USDC) +
+
+ + +
+
+
+ )} + + {/* Deposit Dialog */} + {showDepositDialog && deployedFlow && ( +
setShowDepositDialog(false)}> +
e.stopPropagation()}> +

Deposit USDC

+

+ Simulate a deposit into a funnel. If the deposit pushes the balance above MAX threshold, overflow will automatically distribute. +

+ +
+ $ + setDepositAmount(e.target.value)} + placeholder="Amount (USDC)" + className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800" + min="0" + step="0.01" + /> +
+
+ + +
+
+
+ )} + + {/* Load Backend Flow Dialog */} + {showLoadFlowDialog && ( +
setShowLoadFlowDialog(false)}> +
e.stopPropagation()}> +

Deployed Flows

+ {backendFlows.length === 0 ? ( +

No deployed flows yet.

+ ) : ( +
+ {backendFlows.map((flow) => ( + + ))} +
+ )} + +
+
+ )}
) } diff --git a/lib/api/flows-client.ts b/lib/api/flows-client.ts new file mode 100644 index 0000000..27ede27 --- /dev/null +++ b/lib/api/flows-client.ts @@ -0,0 +1,425 @@ +/** + * Flow Service API Client for rFunds.online + * + * Connects the visual TBFF editor to the flow-service backend. + * Handles type conversion between frontend visual nodes and backend flow types. + */ + +import type { FlowNode, FunnelNodeData, OutcomeNodeData } from '../types' + +// ─── Backend API Types ────────────────────────────────────── + +export interface BackendFlow { + id: string + name: string + description?: string + funnels: BackendFunnel[] + outcomes: BackendOutcome[] + token: string + chainId: number + ownerAddress: string + adminAddresses: string[] + automationEnabled: boolean + checkIntervalMs: number + status: 'draft' | 'active' | 'paused' | 'archived' + createdAt: number + updatedAt: number +} + +export interface BackendFunnel { + id: string + flowId: string + name: string + description?: string + minThreshold: string + maxThreshold: string + balance: string + walletAddress: string + overflowAllocations: BackendAllocation[] + spendingAllocations: BackendAllocation[] + inflowRate?: string + spendingRate?: string + createdAt: number + updatedAt: number +} + +export interface BackendOutcome { + id: string + flowId: string + name: string + description?: string + targetAmount: string + currentAmount: string + walletAddress: string + ownerAddress: string + status: 'active' | 'funded' | 'completed' | 'cancelled' + createdAt: number + updatedAt: number +} + +export interface BackendAllocation { + targetId: string + targetType: 'funnel' | 'outcome' | 'wallet' + targetAddress: string + percentage: number + label: string +} + +export interface BackendTransaction { + id: string + flowId: string + type: 'deposit' | 'overflow' | 'spending' | 'withdrawal' + amount: string + status: 'pending' | 'committed' | 'settled' | 'failed' + triggeredBy: 'user' | 'threshold' | 'schedule' + createdAt: number +} + +interface ApiResponse { + success: boolean + error?: string + flow?: T + flows?: T[] + funnel?: BackendFunnel + outcome?: BackendOutcome + transaction?: BackendTransaction + transactions?: BackendTransaction[] + session?: { sessionId: string; widgetUrl?: string } +} + +// ─── Configuration ────────────────────────────────────────── + +const USDC_DECIMALS = 6 +const DEFAULT_TOKEN = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // USDC on Base +const DEFAULT_CHAIN_ID = 84532 // Base Sepolia + +// Use relative URL — Traefik routes rfunds.online/api/flows to flow-service +const API_BASE = '/api/flows' + +// ─── Unit Conversion ──────────────────────────────────────── + +/** Convert human-readable amount (e.g., 1000) to smallest unit string (e.g., "1000000000") */ +export function toSmallestUnit(amount: number, decimals = USDC_DECIMALS): string { + return Math.round(amount * Math.pow(10, decimals)).toString() +} + +/** Convert smallest unit string (e.g., "1000000000") to human-readable number (e.g., 1000) */ +export function fromSmallestUnit(amount: string, decimals = USDC_DECIMALS): number { + return parseInt(amount, 10) / Math.pow(10, decimals) +} + +// ─── API Helpers ──────────────────────────────────────────── + +async function api( + path: string, + options?: RequestInit & { ownerAddress?: string } +): Promise> { + const { ownerAddress, ...fetchOpts } = options || {} + const headers: Record = { + 'Content-Type': 'application/json', + ...(ownerAddress ? { 'X-Owner-Address': ownerAddress } : {}), + ...((fetchOpts.headers as Record) || {}), + } + + const res = await fetch(`${API_BASE}${path}`, { + ...fetchOpts, + headers, + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(body.error || `API error ${res.status}`) + } + + return res.json() +} + +// ─── CRUD Operations ──────────────────────────────────────── + +export async function listFlows(ownerAddress: string): Promise { + const res = await api('', { ownerAddress }) + return (res.flows || []) as BackendFlow[] +} + +export async function getFlow(flowId: string): Promise { + const res = await api(`/${flowId}`) + return res.flow as BackendFlow +} + +export async function createFlow( + name: string, + ownerAddress: string, + description?: string +): Promise { + const res = await api('', { + method: 'POST', + ownerAddress, + body: JSON.stringify({ + name, + description, + token: DEFAULT_TOKEN, + chainId: DEFAULT_CHAIN_ID, + automationEnabled: true, + checkIntervalMs: 30000, + }), + }) + return res.flow as BackendFlow +} + +export async function activateFlow(flowId: string): Promise { + const res = await api(`/${flowId}/activate`, { method: 'POST' }) + return res.flow as BackendFlow +} + +export async function pauseFlow(flowId: string): Promise { + const res = await api(`/${flowId}/pause`, { method: 'POST' }) + return res.flow as BackendFlow +} + +// ─── Funnel & Outcome Operations ──────────────────────────── + +export async function addFunnel( + flowId: string, + name: string, + minThreshold: number, + maxThreshold: number, + overflowAllocations: BackendAllocation[], + spendingAllocations: BackendAllocation[], + description?: string +): Promise { + const res = await api(`/${flowId}/funnels`, { + method: 'POST', + body: JSON.stringify({ + name, + description, + minThreshold: toSmallestUnit(minThreshold), + maxThreshold: toSmallestUnit(maxThreshold), + overflowAllocations, + spendingAllocations, + }), + }) + return res.funnel as BackendFunnel +} + +export async function addOutcome( + flowId: string, + name: string, + targetAmount: number, + ownerAddress: string, + description?: string +): Promise { + const res = await api(`/${flowId}/outcomes`, { + method: 'POST', + body: JSON.stringify({ + name, + description, + targetAmount: toSmallestUnit(targetAmount), + ownerAddress, + }), + }) + return res.outcome as BackendOutcome +} + +// ─── Deposit / Withdraw ───────────────────────────────────── + +export async function deposit( + flowId: string, + funnelId: string, + amount: number, + source: 'wallet' | 'card' | 'bank' = 'wallet' +): Promise { + const res = await api(`/${flowId}/deposit`, { + method: 'POST', + body: JSON.stringify({ + funnelId, + amount: toSmallestUnit(amount), + source, + }), + }) + return res.transaction as BackendTransaction +} + +export async function withdraw( + flowId: string, + sourceType: 'funnel' | 'outcome', + sourceId: string, + amount: number, + externalAddress: string +): Promise { + const res = await api(`/${flowId}/withdraw`, { + method: 'POST', + body: JSON.stringify({ + sourceType, + sourceId, + amount: toSmallestUnit(amount), + destination: 'external', + externalAddress, + }), + }) + return res.transaction as BackendTransaction +} + +export async function getTransactions(flowId: string): Promise { + const res = await api(`/${flowId}/transactions`) + return (res.transactions || []) as BackendTransaction[] +} + +// ─── On-Ramp ──────────────────────────────────────────────── + +export async function initiateOnRamp( + flowId: string, + funnelId: string, + fiatAmount: number, + fiatCurrency = 'USD' +): Promise<{ widgetUrl?: string; sessionId: string }> { + const res = await api(`/${flowId}/onramp`, { + method: 'POST', + body: JSON.stringify({ + funnelId, + fiatAmount, + fiatCurrency, + provider: 'transak', + paymentMethod: 'card', + }), + }) + return res.session as { widgetUrl?: string; sessionId: string } +} + +// ─── Deploy: Convert Visual Nodes → Backend Flow ──────────── + +/** Node ID → Backend ID mapping (returned after deploy) */ +export interface DeployResult { + flow: BackendFlow + idMap: Record // frontend nodeId → backend id +} + +/** + * Deploy visual nodes to the backend as a live Flow. + * + * 1. Creates the Flow + * 2. Creates all Outcomes first (so funnel allocations can reference them) + * 3. Creates all Funnels with resolved allocation targets + * 4. Activates the flow + */ +export async function deployNodes( + name: string, + ownerAddress: string, + nodes: FlowNode[] +): Promise { + const idMap: Record = {} + + // 1. Create flow + const flow = await createFlow(name, ownerAddress, `Deployed from rFunds.online editor`) + + // 2. Create outcomes first + const outcomeNodes = nodes.filter(n => n.type === 'outcome') + for (const node of outcomeNodes) { + const data = node.data as OutcomeNodeData + const outcome = await addOutcome( + flow.id, + data.label, + data.fundingTarget, + ownerAddress, + data.description + ) + idMap[node.id] = outcome.id + } + + // 3. Create funnels (resolve allocation targetIds to backend IDs) + const funnelNodes = nodes.filter(n => n.type === 'funnel') + for (const node of funnelNodes) { + const data = node.data as FunnelNodeData + + const resolveAllocations = ( + allocs: { targetId: string; percentage: number }[], + type: 'overflow' | 'spending' + ): BackendAllocation[] => { + return allocs + .filter(a => idMap[a.targetId] || nodes.find(n => n.id === a.targetId)) + .map((a, i) => { + const targetNode = nodes.find(n => n.id === a.targetId) + const isOutcome = targetNode?.type === 'outcome' + return { + targetId: idMap[a.targetId] || a.targetId, + targetType: (isOutcome ? 'outcome' : 'funnel') as 'funnel' | 'outcome' | 'wallet', + targetAddress: '0x0000000000000000000000000000000000000000', // backend assigns real addresses + percentage: a.percentage, + label: `${type} #${i + 1} (${a.percentage}%)`, + } + }) + } + + const funnel = await addFunnel( + flow.id, + data.label, + data.minThreshold, + data.maxThreshold, + resolveAllocations(data.overflowAllocations, 'overflow'), + resolveAllocations(data.spendingAllocations, 'spending'), + ) + idMap[node.id] = funnel.id + } + + // 4. Activate + const activeFlow = await activateFlow(flow.id) + + return { flow: activeFlow, idMap } +} + +// ─── Sync: Update Visual Nodes from Backend ───────────────── + +/** + * Update visual node data with live balances from the backend. + * Returns updated nodes with real balance/funding data. + */ +export function syncNodesToBackend( + nodes: FlowNode[], + flow: BackendFlow, + idMap: Record +): FlowNode[] { + // Build reverse map: backend id → frontend node id + const reverseMap: Record = {} + for (const [frontendId, backendId] of Object.entries(idMap)) { + reverseMap[backendId] = frontendId + } + + return nodes.map((node) => { + if (node.type === 'funnel') { + const backendId = idMap[node.id] + const backendFunnel = flow.funnels.find(f => f.id === backendId) + if (!backendFunnel) return node + + const data = node.data as FunnelNodeData + return { + ...node, + data: { + ...data, + currentValue: fromSmallestUnit(backendFunnel.balance), + minThreshold: fromSmallestUnit(backendFunnel.minThreshold), + maxThreshold: fromSmallestUnit(backendFunnel.maxThreshold), + }, + } + } + + if (node.type === 'outcome') { + const backendId = idMap[node.id] + const backendOutcome = flow.outcomes.find(o => o.id === backendId) + if (!backendOutcome) return node + + const data = node.data as OutcomeNodeData + return { + ...node, + data: { + ...data, + fundingReceived: fromSmallestUnit(backendOutcome.currentAmount), + fundingTarget: fromSmallestUnit(backendOutcome.targetAmount), + status: backendOutcome.status === 'completed' ? 'completed' : + backendOutcome.status === 'funded' ? 'completed' : + fromSmallestUnit(backendOutcome.currentAmount) > 0 ? 'in-progress' : 'not-started', + }, + } + } + + return node + }) +}