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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 11:14:54 -07:00
parent 8c3baed4b8
commit d5d5be3fa6
3 changed files with 766 additions and 1 deletions

1
.gitleaksignore Normal file
View File

@ -0,0 +1 @@
lib/api/flows-client.ts:generic-api-key:93

View File

@ -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<FlowNode[]>(starterNodes)
const integrationsRef = useRef<IntegrationConfig | undefined>()
// Backend integration state
const [deployedFlow, setDeployedFlow] = useState<BackendFlow | null>(null)
const [idMap, setIdMap] = useState<Record<string, string>>({})
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<BackendFlow[]>([])
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<string, string> = {}
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 (
<div className="h-screen w-screen flex items-center justify-center bg-slate-50">
@ -117,6 +251,15 @@ export default function SpacePage() {
return (
<main className="h-screen w-screen flex flex-col">
{/* 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 transition-all ${
statusMessage.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'
}`}>
{statusMessage.text}
</div>
)}
{/* Toolbar */}
<div className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between text-sm flex-shrink-0">
<div className="flex items-center gap-3">
@ -128,9 +271,22 @@ export default function SpacePage() {
</Link>
<span className="text-slate-500">|</span>
<span className="text-slate-300">Your Space</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' :
deployedFlow.status === 'paused' ? 'bg-amber-600/30 text-amber-300' :
'bg-slate-600/30 text-slate-400'
}`}>
{deployedFlow.status} &middot; {deployedFlow.name}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{/* Local operations */}
<button
onClick={() => setShowSaveDialog(true)}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-xs font-medium transition-colors"
@ -149,6 +305,50 @@ export default function SpacePage() {
>
{copied ? 'Copied!' : 'Share'}
</button>
{/* Separator */}
<div className="w-px h-5 bg-slate-600 mx-1" />
{/* Backend operations */}
<button
onClick={() => setShowDeployDialog(true)}
className="px-3 py-1 bg-violet-600 hover:bg-violet-500 rounded text-xs font-medium transition-colors"
>
Deploy
</button>
{deployedFlow && (
<>
<button
onClick={handleSync}
disabled={syncing}
className="px-3 py-1 bg-cyan-600 hover:bg-cyan-500 rounded text-xs font-medium transition-colors disabled:opacity-50"
>
{syncing ? 'Syncing...' : 'Sync'}
</button>
<button
onClick={() => {
if (deployedFlow.funnels.length > 0) {
setDepositFunnelId(deployedFlow.funnels[0].id)
}
setShowDepositDialog(true)
}}
className="px-3 py-1 bg-green-600 hover:bg-green-500 rounded text-xs font-medium transition-colors"
>
Deposit
</button>
</>
)}
<button
onClick={handleLoadFlowOpen}
className="px-3 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs font-medium transition-colors"
title="Load a previously deployed flow"
>
Flows
</button>
{/* Separator */}
<div className="w-px h-5 bg-slate-600 mx-1" />
<button
onClick={handleReset}
className="px-3 py-1 bg-slate-700 hover:bg-red-600 rounded text-xs font-medium transition-colors"
@ -249,6 +449,145 @@ export default function SpacePage() {
</div>
</div>
)}
{/* Deploy Dialog */}
{showDeployDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowDeployDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-96" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-1">Deploy to Backend</h3>
<p className="text-xs text-slate-500 mb-4">
Creates a live flow on the server with real threshold logic, overflow distribution, and fiat on-ramp support.
</p>
<input
type="text"
value={deployName}
onChange={e => 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
/>
<input
type="text"
value={ownerAddress}
onChange={e => 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"
/>
<div className="text-[10px] text-slate-400 mb-4">
{nodesRef.current.filter(n => n.type === 'funnel').length} funnels &bull;{' '}
{nodesRef.current.filter(n => n.type === 'outcome').length} outcomes will be created on Base Sepolia (USDC)
</div>
<div className="flex gap-2">
<button
onClick={handleDeploy}
disabled={!deployName.trim() || !ownerAddress.trim() || deploying}
className="flex-1 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"
>
{deploying ? 'Deploying...' : 'Deploy'}
</button>
<button
onClick={() => setShowDeployDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Deposit Dialog */}
{showDepositDialog && deployedFlow && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowDepositDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-96" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-1">Deposit USDC</h3>
<p className="text-xs text-slate-500 mb-4">
Simulate a deposit into a funnel. If the deposit pushes the balance above MAX threshold, overflow will automatically distribute.
</p>
<select
value={depositFunnelId}
onChange={e => setDepositFunnelId(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-3 text-slate-800"
>
{deployedFlow.funnels.map(f => (
<option key={f.id} value={f.id}>
{f.name} (balance: ${fromSmallestUnit(f.balance).toLocaleString()})
</option>
))}
</select>
<div className="relative mb-4">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">$</span>
<input
type="number"
value={depositAmount}
onChange={e => 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"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleDeposit}
disabled={!depositAmount || depositing}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{depositing ? 'Depositing...' : 'Deposit'}
</button>
<button
onClick={() => setShowDepositDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Load Backend Flow Dialog */}
{showLoadFlowDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowLoadFlowDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-96 max-h-[60vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-4">Deployed Flows</h3>
{backendFlows.length === 0 ? (
<p className="text-sm text-slate-500 py-4 text-center">No deployed flows yet.</p>
) : (
<div className="space-y-2">
{backendFlows.map((flow) => (
<button
key={flow.id}
onClick={() => handleLoadFlow(flow)}
className="w-full text-left p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-slate-800">{flow.name}</div>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
flow.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
flow.status === 'paused' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-600'
}`}>
{flow.status}
</span>
</div>
<div className="text-[10px] text-slate-500 mt-1">
{flow.funnels.length} funnels &bull; {flow.outcomes.length} outcomes &bull;{' '}
{new Date(flow.updatedAt).toLocaleDateString()}
</div>
</button>
))}
</div>
)}
<button
onClick={() => setShowLoadFlowDialog(false)}
className="w-full mt-4 px-4 py-2 text-slate-600 hover:text-slate-800 text-sm border border-slate-200 rounded-lg"
>
Close
</button>
</div>
</div>
)}
</main>
)
}

425
lib/api/flows-client.ts Normal file
View File

@ -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<T = unknown> {
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<T = BackendFlow>(
path: string,
options?: RequestInit & { ownerAddress?: string }
): Promise<ApiResponse<T>> {
const { ownerAddress, ...fetchOpts } = options || {}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(ownerAddress ? { 'X-Owner-Address': ownerAddress } : {}),
...((fetchOpts.headers as Record<string, string>) || {}),
}
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<BackendFlow[]> {
const res = await api<BackendFlow>('', { ownerAddress })
return (res.flows || []) as BackendFlow[]
}
export async function getFlow(flowId: string): Promise<BackendFlow> {
const res = await api<BackendFlow>(`/${flowId}`)
return res.flow as BackendFlow
}
export async function createFlow(
name: string,
ownerAddress: string,
description?: string
): Promise<BackendFlow> {
const res = await api<BackendFlow>('', {
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<BackendFlow> {
const res = await api<BackendFlow>(`/${flowId}/activate`, { method: 'POST' })
return res.flow as BackendFlow
}
export async function pauseFlow(flowId: string): Promise<BackendFlow> {
const res = await api<BackendFlow>(`/${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<BackendFunnel> {
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<BackendOutcome> {
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<BackendTransaction> {
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<BackendTransaction> {
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<BackendTransaction[]> {
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<string, string> // 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<DeployResult> {
const idMap: Record<string, string> = {}
// 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<string, string>
): FlowNode[] {
// Build reverse map: backend id → frontend node id
const reverseMap: Record<string, string> = {}
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
})
}