1090 lines
40 KiB
TypeScript
1090 lines
40 KiB
TypeScript
/**
|
|
* 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, MortgagePosition, ReinvestmentPosition } 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<string>(); // 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<FlowsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<FlowsDoc>(), '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<FlowsDoc>(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<FlowsDoc>(docId)!;
|
|
}
|
|
// Migrate v2 → v3: add mortgagePositions and reinvestmentPositions
|
|
if (doc.meta.version < 3) {
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'migrate to v3', (d) => {
|
|
if (!d.mortgagePositions) d.mortgagePositions = {} as any;
|
|
if (!d.reinvestmentPositions) d.reinvestmentPositions = {} as any;
|
|
d.meta.version = 3;
|
|
});
|
|
doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
|
|
}
|
|
// Migrate v3 → v4: add budget fields
|
|
if (doc.meta.version < 4) {
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'migrate to v4', (d) => {
|
|
if (!d.budgetSegments) d.budgetSegments = {} as any;
|
|
if (!d.budgetAllocations) d.budgetAllocations = {} as any;
|
|
if (!d.budgetTotalAmount) d.budgetTotalAmount = 0 as any;
|
|
d.meta.version = 4;
|
|
});
|
|
doc = _syncServer!.getDoc<FlowsDoc>(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<BoardDoc>(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<BoardDoc>(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<FlowsDoc>(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<FlowsDoc>(docId);
|
|
if (doc) {
|
|
const key = `${space}:${flowId}`;
|
|
if (doc.spaceFlows[key]) {
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'remove space flow', (d) => {
|
|
delete d.spaceFlows[key];
|
|
});
|
|
}
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── Mortgage API routes ─────────────────────────────────
|
|
|
|
// Aave v3 Pool on Base
|
|
const AAVE_V3_POOL_BASE = '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5';
|
|
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
const BASE_RPC = 'https://mainnet.base.org';
|
|
|
|
routes.get("/api/mortgage/rates", async (c) => {
|
|
try {
|
|
// getReserveData(address) selector = 0x35ea6a75
|
|
const calldata = '0x35ea6a75000000000000000000000000' + USDC_BASE.slice(2).toLowerCase();
|
|
const res = await fetch(BASE_RPC, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0', id: 1, method: 'eth_call',
|
|
params: [{ to: AAVE_V3_POOL_BASE, data: calldata }, 'latest'],
|
|
}),
|
|
});
|
|
const json = await res.json() as any;
|
|
if (json.error) throw new Error(json.error.message);
|
|
|
|
// currentLiquidityRate is the 2nd word (index 1) in the result tuple — 27 decimals (ray)
|
|
const resultHex = json.result as string;
|
|
// Each word is 32 bytes = 64 hex chars. Skip 0x prefix, word at index 2 (currentLiquidityRate)
|
|
const liquidityRateHex = '0x' + resultHex.slice(2 + 64 * 2, 2 + 64 * 3);
|
|
const liquidityRate = Number(BigInt(liquidityRateHex)) / 1e27;
|
|
// Convert ray rate to APY: ((1 + rate/SECONDS_PER_YEAR)^SECONDS_PER_YEAR - 1) * 100
|
|
const SECONDS_PER_YEAR = 31536000;
|
|
const apy = (Math.pow(1 + liquidityRate / SECONDS_PER_YEAR, SECONDS_PER_YEAR) - 1) * 100;
|
|
|
|
return c.json({
|
|
rates: [
|
|
{ protocol: 'Aave v3', chain: 'Base', asset: 'USDC', apy: Math.round(apy * 100) / 100, updatedAt: Date.now() },
|
|
],
|
|
});
|
|
} catch (err) {
|
|
console.error('[mortgage] Rate fetch failed:', err);
|
|
return c.json({
|
|
rates: [
|
|
{ protocol: 'Aave v3', chain: 'Base', asset: 'USDC', apy: null, updatedAt: Date.now(), error: 'Unavailable' },
|
|
],
|
|
});
|
|
}
|
|
});
|
|
|
|
routes.get("/api/mortgage/positions", async (c) => {
|
|
const space = c.req.query("space") || "demo";
|
|
const doc = ensureDoc(space);
|
|
return c.json(Object.values(doc.mortgagePositions || {}));
|
|
});
|
|
|
|
routes.post("/api/mortgage/positions", 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 body = await c.req.json() as Partial<MortgagePosition>;
|
|
if (!body.principal || !body.termMonths || !body.interestRate) {
|
|
return c.json({ error: "principal, termMonths, and interestRate required" }, 400);
|
|
}
|
|
|
|
const space = (body as any).space || "demo";
|
|
const docId = flowsDocId(space);
|
|
ensureDoc(space);
|
|
|
|
const id = crypto.randomUUID();
|
|
const monthlyRate = body.interestRate / 100 / 12;
|
|
const monthlyPayment = monthlyRate > 0
|
|
? body.principal * (monthlyRate * Math.pow(1 + monthlyRate, body.termMonths)) / (Math.pow(1 + monthlyRate, body.termMonths) - 1)
|
|
: body.principal / body.termMonths;
|
|
|
|
const position: MortgagePosition = {
|
|
id,
|
|
borrower: body.borrower || claims.sub,
|
|
borrowerDid: (claims as any).did || claims.sub,
|
|
principal: body.principal,
|
|
interestRate: body.interestRate,
|
|
termMonths: body.termMonths,
|
|
monthlyPayment: Math.round(monthlyPayment * 100) / 100,
|
|
startDate: Date.now(),
|
|
trustScore: body.trustScore || 0,
|
|
status: 'active',
|
|
collateralType: body.collateralType || 'trust-backed',
|
|
};
|
|
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'create mortgage position', (d) => {
|
|
d.mortgagePositions[id] = position as any;
|
|
});
|
|
|
|
return c.json(position, 201);
|
|
});
|
|
|
|
// ─── Budget API routes ───────────────────────────────────
|
|
|
|
routes.get("/api/budgets", async (c) => {
|
|
const space = c.req.query("space") || "demo";
|
|
const doc = ensureDoc(space);
|
|
|
|
const segments = Object.entries(doc.budgetSegments || {}).map(([id, s]) => ({
|
|
id, name: s.name, color: s.color, createdBy: s.createdBy,
|
|
}));
|
|
|
|
const allocations = Object.values(doc.budgetAllocations || {});
|
|
|
|
// Compute collective averages per segment
|
|
const collective: { segmentId: string; avgPercentage: number; participantCount: number }[] = [];
|
|
if (segments.length > 0 && allocations.length > 0) {
|
|
for (const seg of segments) {
|
|
const vals = allocations.map((a) => a.allocations[seg.id] || 0);
|
|
const avg = vals.reduce((s, v) => s + v, 0) / vals.length;
|
|
collective.push({ segmentId: seg.id, avgPercentage: Math.round(avg * 100) / 100, participantCount: vals.filter((v) => v > 0).length });
|
|
}
|
|
}
|
|
|
|
return c.json({
|
|
segments,
|
|
allocations,
|
|
collective,
|
|
totalAmount: doc.budgetTotalAmount || 0,
|
|
participantCount: allocations.length,
|
|
});
|
|
});
|
|
|
|
routes.post("/api/budgets/allocate", 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, allocations } = await c.req.json() as { space?: string; allocations?: Record<string, number> };
|
|
if (!allocations) return c.json({ error: "allocations required" }, 400);
|
|
|
|
const spaceSlug = space || "demo";
|
|
const docId = flowsDocId(spaceSlug);
|
|
ensureDoc(spaceSlug);
|
|
|
|
const did = (claims as any).did || claims.sub;
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'save budget allocation', (d) => {
|
|
d.budgetAllocations[did] = {
|
|
participantDid: did,
|
|
allocations: allocations as any,
|
|
updatedAt: Date.now(),
|
|
} as any;
|
|
});
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
routes.get("/api/budgets/segments", async (c) => {
|
|
const space = c.req.query("space") || "demo";
|
|
const doc = ensureDoc(space);
|
|
const segments = Object.entries(doc.budgetSegments || {}).map(([id, s]) => ({
|
|
id, name: s.name, color: s.color, createdBy: s.createdBy,
|
|
}));
|
|
return c.json(segments);
|
|
});
|
|
|
|
routes.post("/api/budgets/segments", 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, action, segmentId, name, color } = await c.req.json() as {
|
|
space?: string; action: 'add' | 'remove'; segmentId?: string; name?: string; color?: string;
|
|
};
|
|
|
|
const spaceSlug = space || "demo";
|
|
const docId = flowsDocId(spaceSlug);
|
|
ensureDoc(spaceSlug);
|
|
|
|
const did = (claims as any).did || claims.sub;
|
|
|
|
if (action === 'add') {
|
|
if (!name) return c.json({ error: "name required" }, 400);
|
|
const id = segmentId || crypto.randomUUID();
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'add budget segment', (d) => {
|
|
d.budgetSegments[id] = { name: name as any, color: (color || '#6366f1') as any, createdBy: did as any };
|
|
});
|
|
return c.json({ ok: true, id });
|
|
}
|
|
|
|
if (action === 'remove') {
|
|
if (!segmentId) return c.json({ error: "segmentId required" }, 400);
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'remove budget segment', (d) => {
|
|
delete d.budgetSegments[segmentId];
|
|
// Remove this segment from all allocations
|
|
for (const alloc of Object.values(d.budgetAllocations)) {
|
|
delete alloc.allocations[segmentId];
|
|
}
|
|
});
|
|
return c.json({ ok: true });
|
|
}
|
|
|
|
return c.json({ error: "action must be 'add' or 'remove'" }, 400);
|
|
});
|
|
|
|
// ─── Page routes ────────────────────────────────────────
|
|
|
|
const flowsScripts = `
|
|
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
|
|
<script type="module" src="/modules/rflows/folk-flows-app.js"></script>
|
|
<script type="module" src="/modules/rflows/folk-flow-river.js"></script>`;
|
|
|
|
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`;
|
|
|
|
// 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: `<folk-flows-app space="${spaceSlug}"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
|
|
scripts: flowsScripts,
|
|
styles: flowsStyles,
|
|
}));
|
|
});
|
|
|
|
// Mortgage sub-tab
|
|
routes.get("/mortgage", (c) => {
|
|
const spaceSlug = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `${spaceSlug} — Mortgage | rFlows | rSpace`,
|
|
moduleId: "rflows",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-flows-app space="${spaceSlug}" view="mortgage"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
|
|
scripts: flowsScripts,
|
|
styles: flowsStyles,
|
|
}));
|
|
});
|
|
|
|
// Budgets sub-tab
|
|
routes.get("/budgets", (c) => {
|
|
const spaceSlug = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `${spaceSlug} — rBudgets | rFlows | rSpace`,
|
|
moduleId: "rflows",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-flows-app space="${spaceSlug}" view="budgets"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
|
|
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: `<folk-flows-app space="${spaceSlug}" flow-id="${flowId}"></folk-flows-app>`,
|
|
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<FlowsDoc>(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<FlowsDoc>(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`);
|
|
}
|
|
|
|
// Seed mortgage demo positions if empty
|
|
if (Object.keys(doc.mortgagePositions || {}).length === 0) {
|
|
const docId = flowsDocId(space);
|
|
const now = Date.now();
|
|
const demoMortgages: MortgagePosition[] = [
|
|
{
|
|
id: crypto.randomUUID(), borrower: 'alice.eth', borrowerDid: 'did:key:alice123',
|
|
principal: 250000, interestRate: 4.2, termMonths: 360, monthlyPayment: 1222.95,
|
|
startDate: now - 86400000 * 120, trustScore: 92, status: 'active', collateralType: 'trust-backed',
|
|
},
|
|
{
|
|
id: crypto.randomUUID(), borrower: 'bob.base', borrowerDid: 'did:key:bob456',
|
|
principal: 180000, interestRate: 3.8, termMonths: 240, monthlyPayment: 1079.19,
|
|
startDate: now - 86400000 * 60, trustScore: 87, status: 'active', collateralType: 'hybrid',
|
|
},
|
|
{
|
|
id: crypto.randomUUID(), borrower: 'carol.eth', borrowerDid: 'did:key:carol789',
|
|
principal: 75000, interestRate: 5.1, termMonths: 120, monthlyPayment: 799.72,
|
|
startDate: now - 86400000 * 200, trustScore: 78, status: 'active', collateralType: 'trust-backed',
|
|
},
|
|
{
|
|
id: crypto.randomUUID(), borrower: 'dave.base', borrowerDid: 'did:key:dave012',
|
|
principal: 320000, interestRate: 3.5, termMonths: 360, monthlyPayment: 1436.94,
|
|
startDate: now - 86400000 * 30, trustScore: 95, status: 'pending', collateralType: 'asset-backed',
|
|
},
|
|
];
|
|
|
|
const demoReinvestments: ReinvestmentPosition[] = [
|
|
{ protocol: 'Aave v3', chain: 'Base', asset: 'USDC', deposited: 500000, currentValue: 512340, apy: 4.87, lastUpdated: now },
|
|
{ protocol: 'Morpho Blue', chain: 'Ethereum', asset: 'USDC', deposited: 200000, currentValue: 203120, apy: 3.12, lastUpdated: now },
|
|
];
|
|
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'seed mortgage demo', (d) => {
|
|
for (const m of demoMortgages) d.mortgagePositions[m.id] = m as any;
|
|
for (const r of demoReinvestments) {
|
|
const rid = `${r.protocol}:${r.chain}:${r.asset}`;
|
|
d.reinvestmentPositions[rid] = r as any;
|
|
}
|
|
});
|
|
console.log(`[Flows] Mortgage demo seeded for "${space}"`);
|
|
}
|
|
|
|
// Seed budget demo data if empty
|
|
if (Object.keys(doc.budgetSegments || {}).length === 0) {
|
|
const docId = flowsDocId(space);
|
|
const demoSegments: Record<string, { name: string; color: string; createdBy: string | null }> = {
|
|
'eng': { name: 'Engineering', color: '#3b82f6', createdBy: null },
|
|
'ops': { name: 'Operations', color: '#10b981', createdBy: null },
|
|
'mkt': { name: 'Marketing', color: '#f59e0b', createdBy: null },
|
|
'com': { name: 'Community', color: '#8b5cf6', createdBy: null },
|
|
'res': { name: 'Research', color: '#ef4444', createdBy: null },
|
|
};
|
|
|
|
const demoAllocations: Record<string, { participantDid: string; allocations: Record<string, number>; updatedAt: number }> = {
|
|
'did:key:alice123': { participantDid: 'did:key:alice123', allocations: { eng: 35, ops: 15, mkt: 20, com: 15, res: 15 }, updatedAt: Date.now() },
|
|
'did:key:bob456': { participantDid: 'did:key:bob456', allocations: { eng: 25, ops: 20, mkt: 10, com: 30, res: 15 }, updatedAt: Date.now() },
|
|
'did:key:carol789': { participantDid: 'did:key:carol789', allocations: { eng: 30, ops: 10, mkt: 25, com: 10, res: 25 }, updatedAt: Date.now() },
|
|
'did:key:dave012': { participantDid: 'did:key:dave012', allocations: { eng: 40, ops: 15, mkt: 15, com: 20, res: 10 }, updatedAt: Date.now() },
|
|
};
|
|
|
|
_syncServer!.changeDoc<FlowsDoc>(docId, 'seed budget demo', (d) => {
|
|
for (const [id, seg] of Object.entries(demoSegments)) d.budgetSegments[id] = seg as any;
|
|
for (const [did, alloc] of Object.entries(demoAllocations)) d.budgetAllocations[did] = alloc as any;
|
|
d.budgetTotalAmount = 500000 as any;
|
|
});
|
|
console.log(`[Flows] Budget demo seeded for "${space}"`);
|
|
}
|
|
}
|
|
|
|
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<FlowsDoc>(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: "rBudgets", icon: "💰", description: "Collective budget allocation with pie charts" },
|
|
],
|
|
subPageInfos: [
|
|
{
|
|
path: "budgets",
|
|
title: "rBudgets",
|
|
icon: "💰",
|
|
tagline: "rFlows Tool",
|
|
description: "Collective budget allocation where participants distribute funds across departments using pie charts. Aggregated view shows the group consensus.",
|
|
features: [
|
|
{ icon: "🥧", title: "Collective Pie Chart", text: "See the aggregated budget breakdown from all participants as a dynamic pie chart." },
|
|
{ icon: "🎚️", title: "Personal Sliders", text: "Adjust your own allocation across departments — sliders auto-normalize to 100%." },
|
|
{ icon: "👥", title: "Participant Tracking", text: "View how many people contributed and the consensus distribution." },
|
|
],
|
|
},
|
|
{
|
|
path: "mortgage",
|
|
title: "rMortgage",
|
|
icon: "🏠",
|
|
tagline: "rFlows Tool",
|
|
description: "Social trust-based mortgage lending with DeFi yield reinvestment. Track mortgage positions backed by community trust scores, and earn yield on idle pool capital via Aave and Morpho.",
|
|
features: [
|
|
{ icon: "🤝", title: "Trust-Backed Lending", text: "Mortgage positions backed by community trust scores instead of traditional credit." },
|
|
{ icon: "📊", title: "DeFi Reinvestment", text: "Idle pool capital reinvested into Aave v3 and Morpho Blue for passive yield." },
|
|
{ icon: "🧮", title: "Projection Calculator", text: "Model deposits and durations to forecast compound yield on pool capital." },
|
|
],
|
|
},
|
|
],
|
|
};
|