/** * Flows module — budget flows, river visualization, and treasury management. * * Proxies flow-service API calls and serves the FlowRiver visualization. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import type { RSpaceModule } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import { getTransakEnv, getTransakWebhookSecret } from "../../shared/transak"; import type { SyncServer } from '../../server/local-first/sync-server'; import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow, type CanvasFlow } from './schemas'; import { demoNodes } from './lib/presets'; import { OpenfortProvider } from './lib/openfort'; import { boardDocId, createTaskItem } from '../rtasks/schemas'; import type { BoardDoc } from '../rtasks/schemas'; import type { OutcomeNodeData } from './lib/types'; import { getAvailableProviders, getProvider, getDefaultProvider } from './lib/onramp-registry'; import type { OnrampProviderId } from './lib/onramp-provider'; import { PimlicoClient } from './lib/pimlico'; let _syncServer: SyncServer | null = null; let _openfort: OpenfortProvider | null = null; let _pimlico: PimlicoClient | null = null; const _completedOutcomes = new Set(); // space:outcomeId — dedup for watcher const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; function ensureDoc(space: string): FlowsDoc { const docId = flowsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init', (d) => { const init = flowsSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.spaceFlows = {}; d.canvasFlows = {} as any; d.activeFlowId = ''; }); _syncServer!.setDoc(docId, doc); } // Migrate v1 → v2: add canvasFlows and activeFlowId if (!doc.canvasFlows || doc.meta.version < 2) { _syncServer!.changeDoc(docId, 'migrate to v2', (d) => { if (!d.canvasFlows) d.canvasFlows = {} as any; if (!d.activeFlowId) d.activeFlowId = '' as any; d.meta.version = 2; }); doc = _syncServer!.getDoc(docId)!; } return doc; } /** * Create a DONE task in rTasks when an rFlows outcome is completed. * Deduplicates by checking for existing `ref:rflows:outcome:{id}` in the board doc. */ function createTaskForOutcome(space: string, outcomeId: string, label: string) { if (!_syncServer) return; const boardId = `${space}-bcrg`; const docId = boardDocId(space, boardId); // Ensure the board doc exists let doc = _syncServer.getDoc(docId); if (!doc) return; // BCRG board not seeded yet // Check for duplicate — look for ref:rflows:outcome:{outcomeId} const refTag = `ref:rflows:outcome:${outcomeId}`; for (const t of Object.values(doc.tasks)) { if (t.description?.includes(refTag)) return; // already exists } const taskId = crypto.randomUUID(); _syncServer.changeDoc(docId, `Auto-create task for outcome ${outcomeId}`, (d) => { d.tasks[taskId] = createTaskItem(taskId, space, label, { status: 'DONE', priority: 'MEDIUM', description: `${refTag} — Auto-created from rFlows outcome completion`, labels: ['rflows', 'bcrg'], createdBy: 'did:system:rflows-watcher', }); }); console.log(`[rflows] Auto-created DONE task for outcome "${outcomeId}" in space "${space}"`); } const routes = new Hono(); // ─── Flow Service API proxy ───────────────────────────── // These proxy to the payment-flow backend so the frontend // can call them from the same origin. routes.get("/api/flows", async (c) => { const owner = c.req.header("X-Owner-Address") || ""; const space = c.req.query("space") || ""; // If space filter provided, get flow IDs from Automerge doc if (space) { const doc = ensureDoc(space); const flowIds = Object.values(doc.spaceFlows).map((sf) => sf.flowId); if (flowIds.length === 0) return c.json([]); const flows = await Promise.all( flowIds.map(async (fid) => { try { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${fid}`); if (res.ok) return await res.json(); } catch {} return null; }), ); return c.json(flows.filter(Boolean)); } const res = await fetch(`${FLOW_SERVICE_URL}/api/flows?owner=${encodeURIComponent(owner)}`); return c.json(await res.json(), res.status as any); }); routes.get("/api/flows/:flowId", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}`); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows", async (c) => { // Auth-gated: require EncryptID token const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows`, { method: "POST", headers: { "Content-Type": "application/json", "X-Owner-Address": claims.sub, }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/deposit", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/deposit`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/withdraw", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/withdraw`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/activate", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/activate`, { method: "POST" }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/pause", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/pause`, { method: "POST" }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/funnels", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/funnels`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.post("/api/flows/:flowId/outcomes", async (c) => { const body = await c.req.text(); const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/outcomes`, { method: "POST", headers: { "Content-Type": "application/json" }, body, }); return c.json(await res.json(), res.status as any); }); routes.get("/api/flows/:flowId/transactions", async (c) => { const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/transactions`); return c.json(await res.json(), res.status as any); }); // ─── User on-ramp (email → wallet → widget) ───────────── routes.post("/api/flows/user-onramp", async (c) => { try { const { email, fiatAmount, fiatCurrency, returnUrl, provider: reqProvider } = await c.req.json(); if (!email || !fiatAmount || !fiatCurrency) { return c.json({ error: "email, fiatAmount, and fiatCurrency are required" }, 400); } if (!_openfort) return c.json({ error: "Openfort not configured" }, 503); // Resolve on-ramp provider: use requested, else first available const onramp = reqProvider ? getProvider(reqProvider as OnrampProviderId) : getDefaultProvider(); if (!onramp) return c.json({ error: "No on-ramp provider available" }, 503); // 1. Find or create Openfort smart wallet for this user (one wallet per email) const wallet = await _openfort.findOrCreateWallet(email, { type: 'user-onramp', email, }); const sessionId = crypto.randomUUID(); // 2. Create on-ramp session via provider const { widgetUrl, provider } = await onramp.createSession({ walletAddress: wallet.address, email, fiatAmount, fiatCurrency, sessionId, returnUrl, hostname: new URL(c.req.url).hostname, }); console.log(`[rflows] On-ramp session created: provider=${provider} session=${sessionId} wallet=${wallet.address}`); // Non-fatal side-effect: create fund claim → sends email via EncryptID const encryptidServiceKey = process.env.ENCRYPTID_SERVICE_KEY; if (encryptidServiceKey) { const encryptidUrl = process.env.ENCRYPTID_URL || 'https://auth.rspace.online'; try { await fetch(`${encryptidUrl}/api/internal/fund-claims`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Service-Key': encryptidServiceKey, }, body: JSON.stringify({ email, walletAddress: wallet.address, openfortPlayerId: wallet.playerId, fiatAmount: String(fiatAmount), fiatCurrency, sessionId, provider, }), }); console.log(`[rflows] Fund claim created for ${email}`); } catch (err) { console.error('[rflows] Failed to create fund claim:', err); } } return c.json({ success: true, sessionId, widgetUrl, walletAddress: wallet.address, provider, isNewUser: true, }); } catch (err) { console.error("[rflows] user-onramp failed:", err); let message: string; if (err instanceof Error) message = err.message; else if (err && typeof err === 'object') message = JSON.stringify(err); else message = String(err); return c.json({ error: message }, 500); } }); // ─── On-ramp config ────────────────────────────────────── routes.get("/api/onramp/config", (c) => { const available = getAvailableProviders(); return c.json({ provider: available[0]?.id || null, available, }); }); // Legacy endpoint — keep for backwards compat routes.get("/api/transak/config", (c) => { return c.json({ provider: "transak", environment: getTransakEnv(), }); }); routes.post("/api/transak/webhook", async (c) => { let body: any; try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); } // HMAC verification — if webhook secret is set, validate signature const webhookSecret = getTransakWebhookSecret(); if (webhookSecret) { const signature = c.req.header("x-transak-signature") || ""; const { createHmac } = await import("crypto"); // Re-serialize for HMAC (Transak signs the raw JSON body) const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex"); if (signature !== expected) { console.error("[Transak] Invalid webhook signature"); return c.json({ error: "Invalid signature" }, 401); } } const { webhookData } = body; // Ack non-completion events (Transak sends multiple status updates) if (!webhookData || webhookData.status !== "COMPLETED") { return c.json({ ok: true }); } const { partnerOrderId, cryptoAmount, cryptocurrency, network } = webhookData; if (!partnerOrderId || cryptocurrency !== "USDC" || !network?.toLowerCase().includes("base")) { return c.json({ error: "Invalid webhook data" }, 400); } // partnerOrderId format: "flowId:funnelId" or "flowId" (uses env default) const [flowId, funnelId] = partnerOrderId.split(":"); if (!flowId) return c.json({ error: "Missing flowId in partnerOrderId" }, 400); const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || ""; if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 400); // Convert crypto amount to USDC units (6 decimals) const amountUnits = Math.round(parseFloat(cryptoAmount) * 1e6).toString(); const depositUrl = `${FLOW_SERVICE_URL}/api/flows/${flowId}/deposit`; const res = await fetch(depositUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: amountUnits, source: "card", funnelId: resolvedFunnelId, }), }); if (!res.ok) { console.error(`[Transak] Deposit failed: ${await res.text()}`); return c.json({ error: "Deposit failed" }, 500); } console.log(`[Transak] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`); return c.json({ ok: true }); }); // ─── ERC-4337 UserOperation routes (Pimlico bundler) ───── const ENTRY_POINT = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'; // v0.6 routes.post("/api/flows/submit-userop", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503); const userOp = await c.req.json(); try { const prepared = await _pimlico.prepareUserOperation(userOp, ENTRY_POINT); return c.json({ success: true, userOp: prepared, entryPoint: ENTRY_POINT }); } catch (err) { console.error("[pimlico] prepare failed:", err); const msg = err instanceof Error ? err.message : String(err); return c.json({ error: msg }, 500); } }); routes.post("/api/flows/send-userop", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503); const { userOp } = await c.req.json(); if (!userOp) return c.json({ error: "userOp required" }, 400); try { const hash = await _pimlico.sendUserOperation(userOp, ENTRY_POINT); return c.json({ success: true, userOpHash: hash }); } catch (err) { console.error("[pimlico] send failed:", err); const msg = err instanceof Error ? err.message : String(err); return c.json({ error: msg }, 500); } }); routes.get("/api/flows/userop/:hash", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_pimlico) return c.json({ error: "Pimlico bundler not configured" }, 503); const hash = c.req.param("hash"); try { const receipt = await _pimlico.getUserOperationReceipt(hash); return c.json({ receipt }); } catch (err) { console.error("[pimlico] receipt failed:", err); const msg = err instanceof Error ? err.message : String(err); return c.json({ error: msg }, 500); } }); // ─── Coinbase webhook ──────────────────────────────────── routes.post("/api/coinbase/webhook", async (c) => { let body: any; try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); } // HMAC verification const webhookSecret = process.env.COINBASE_WEBHOOK_SECRET; if (webhookSecret) { const signature = c.req.header("x-cc-webhook-signature") || ""; const { createHmac } = await import("crypto"); const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex"); if (signature !== expected) { console.error("[Coinbase] Invalid webhook signature"); return c.json({ error: "Invalid signature" }, 401); } } const { event } = body; if (!event || event.type !== "charge:confirmed") return c.json({ ok: true }); const metadata = event.data?.metadata || {}; const { flowId, funnelId } = metadata; const pricing = event.data?.pricing?.local; if (!flowId || !pricing) return c.json({ error: "Missing flowId or pricing" }, 400); const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || ""; if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 400); const amountUnits = Math.round(parseFloat(pricing.amount) * 1e6).toString(); const depositUrl = `${FLOW_SERVICE_URL}/api/flows/${flowId}/deposit`; const res = await fetch(depositUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: amountUnits, source: "card", funnelId: resolvedFunnelId }), }); if (!res.ok) { console.error(`[Coinbase] Deposit failed: ${await res.text()}`); return c.json({ error: "Deposit failed" }, 500); } console.log(`[Coinbase] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`); return c.json({ ok: true }); }); // ─── Ramp Network webhook ──────────────────────────────── routes.post("/api/ramp/webhook", async (c) => { let body: any; try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); } // HMAC verification const webhookSecret = process.env.RAMP_WEBHOOK_SECRET; if (webhookSecret) { const signature = c.req.header("x-body-signature") || ""; const { createHmac } = await import("crypto"); const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex"); if (signature !== expected) { console.error("[Ramp] Invalid webhook signature"); return c.json({ error: "Invalid signature" }, 401); } } if (body.type !== "RELEASED" || body.asset?.symbol !== "USDC") return c.json({ ok: true }); const purchaseViewToken = body.purchaseViewToken || ""; // Ramp uses receiverAddress metadata or custom purchase field for flowId const flowId = body.metadata?.flowId || body.flowId; const funnelId = body.metadata?.funnelId || body.funnelId; if (!flowId) return c.json({ error: "Missing flowId" }, 400); const resolvedFunnelId = funnelId || process.env.FUNNEL_ID || ""; if (!resolvedFunnelId) return c.json({ error: "Missing funnelId" }, 400); const amountUnits = Math.round(parseFloat(body.cryptoAmount || "0") * 1e6).toString(); const depositUrl = `${FLOW_SERVICE_URL}/api/flows/${flowId}/deposit`; const res = await fetch(depositUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: amountUnits, source: "card", funnelId: resolvedFunnelId }), }); if (!res.ok) { console.error(`[Ramp] Deposit failed: ${await res.text()}`); return c.json({ error: "Deposit failed" }, 500); } console.log(`[Ramp] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`); return c.json({ ok: true }); }); // ─── Space-flow association endpoints ──────────────────── routes.post("/api/space-flows", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const { space, flowId } = await c.req.json(); if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400); const docId = flowsDocId(space); ensureDoc(space); _syncServer!.changeDoc(docId, 'add space flow', (d) => { const key = `${space}:${flowId}`; if (!d.spaceFlows[key]) { d.spaceFlows[key] = { id: key, spaceSlug: space, flowId, addedBy: claims.sub, createdAt: Date.now() }; } }); return c.json({ ok: true }); }); routes.delete("/api/space-flows/:flowId", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const flowId = c.req.param("flowId"); const space = c.req.query("space") || ""; if (!space) return c.json({ error: "space query param required" }, 400); const docId = flowsDocId(space); const doc = _syncServer!.getDoc(docId); if (doc) { const key = `${space}:${flowId}`; if (doc.spaceFlows[key]) { _syncServer!.changeDoc(docId, 'remove space flow', (d) => { delete d.spaceFlows[key]; }); } } return c.json({ ok: true }); }); // ─── Page routes ──────────────────────────────────────── const flowsScripts = ` `; const flowsStyles = ``; // Landing page (also serves demo via centralized /demo → space="demo" rewrite) routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${spaceSlug} — Flows | rSpace`, moduleId: "rflows", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: flowsScripts, styles: flowsStyles, })); }); // Flow detail — specific flow from API routes.get("/flow/:flowId", (c) => { const spaceSlug = c.req.param("space") || "demo"; const flowId = c.req.param("flowId"); return c.html(renderShell({ title: `Flow — rFlows | rSpace`, moduleId: "rflows", spaceSlug, modules: getModuleInfoList(), theme: "dark", styles: flowsStyles, body: ``, scripts: flowsScripts, })); }); // ── Seed template data ── function seedTemplateFlows(space: string) { if (!_syncServer) return; const doc = ensureDoc(space); // Seed SpaceFlow association if empty if (Object.keys(doc.spaceFlows).length === 0) { const docId = flowsDocId(space); const now = Date.now(); const flowId = crypto.randomUUID(); _syncServer.changeDoc(docId, 'seed template flow', (d) => { d.spaceFlows[flowId] = { id: flowId, spaceSlug: space, flowId: 'demo', addedBy: 'did:demo:seed', createdAt: now, }; }); } // Seed a canvas flow with demoNodes if none exist if (Object.keys(doc.canvasFlows || {}).length === 0) { const docId = flowsDocId(space); const now = Date.now(); const canvasFlowId = crypto.randomUUID(); const seedFlow: CanvasFlow = { id: canvasFlowId, name: 'BCRG Community Flow', nodes: demoNodes.map((n) => ({ ...n, data: { ...n.data } })), createdAt: now, updatedAt: now, createdBy: 'did:demo:seed', }; _syncServer.changeDoc(docId, 'seed canvas flow', (d) => { d.canvasFlows[canvasFlowId] = seedFlow as any; d.activeFlowId = canvasFlowId as any; }); console.log(`[Flows] Template seeded for "${space}": 1 canvas flow + association`); } } export const flowsModule: RSpaceModule = { id: "rflows", name: "rFlows", icon: "🌊", description: "Budget flows, river visualization, and treasury management", publicWrite: true, scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [{ pattern: '{space}:flows:data', description: 'Space flow associations', init: flowsSchema.init }], routes, landingPage: renderLanding, seedTemplate: seedTemplateFlows, async onInit(ctx) { _syncServer = ctx.syncServer; if (process.env.OPENFORT_API_KEY && process.env.OPENFORT_PUBLISHABLE_KEY) { _openfort = new OpenfortProvider({ apiKey: process.env.OPENFORT_API_KEY, publishableKey: process.env.OPENFORT_PUBLISHABLE_KEY, chainId: 8453, // Base mainnet — hardcoded to avoid testnet misconfiguration }); console.log('[rflows] Openfort provider initialized'); } if (process.env.PIMLICO_API_KEY) { _pimlico = new PimlicoClient({ apiKey: process.env.PIMLICO_API_KEY, chainId: 8453, // Base mainnet }); console.log('[rflows] Pimlico bundler initialized'); } // Log available on-ramp providers const onrampProviders = getAvailableProviders(); console.log(`[rflows] On-ramp providers: ${onrampProviders.map((p) => p.id).join(', ') || 'none'}`) // Watch for completed outcomes in flow docs → auto-create DONE tasks _syncServer.registerWatcher(':flows:data', (docId, doc) => { try { const flowsDoc = doc as FlowsDoc; if (!flowsDoc.canvasFlows) return; // Extract space slug from docId (format: {space}:flows:data) const space = docId.split(':flows:data')[0]; if (!space) return; for (const flow of Object.values(flowsDoc.canvasFlows)) { if (!flow.nodes) continue; for (const node of flow.nodes) { if (node.type !== 'outcome') continue; const data = node.data as OutcomeNodeData; if (data.status !== 'completed') continue; const key = `${space}:${node.id}`; if (_completedOutcomes.has(key)) continue; _completedOutcomes.add(key); createTaskForOutcome(space, node.id, data.label); } } } catch {} }); // Pre-populate _completedOutcomes from existing docs to avoid duplicates on restart for (const id of _syncServer.getDocIds()) { if (!id.includes(':flows:data')) continue; const doc = _syncServer.getDoc(id); if (!doc?.canvasFlows) continue; const space = id.split(':flows:data')[0]; for (const flow of Object.values(doc.canvasFlows)) { if (!flow.nodes) continue; for (const node of flow.nodes) { if (node.type === 'outcome' && (node.data as OutcomeNodeData).status === 'completed') { _completedOutcomes.add(`${space}:${node.id}`); } } } } }, standaloneDomain: "rflows.online", feeds: [ { id: "treasury-flows", name: "Treasury Flows", kind: "economic", description: "Budget flow states, deposits, withdrawals, and funnel allocations", filterable: true, }, { id: "transactions", name: "Transaction Stream", kind: "economic", description: "Real-time deposit and withdrawal events", }, ], acceptsFeeds: ["governance", "data"], outputPaths: [ { path: "budgets", name: "Budgets", icon: "💰", description: "Budget allocations and funnels" }, { path: "flows", name: "Flows", icon: "🌊", description: "Revenue and resource flow visualizations" }, ], subPageInfos: [ { path: "flow", title: "Flow Viewer", icon: "🌊", tagline: "rFlows Tool", description: "Visualize a single budget flow — deposits, withdrawals, funnel allocations, and real-time balance. Drill into transactions and manage outcomes.", features: [ { icon: "📈", title: "River Visualization", text: "See funds flow through funnels and outcomes as an animated river diagram." }, { icon: "💸", title: "Deposits & Withdrawals", text: "Track every transaction with full history and on-chain verification." }, { icon: "🎯", title: "Outcome Tracking", text: "Define funding outcomes and monitor how capital reaches its destination." }, ], }, ], };