diff --git a/e2e/fixtures/module-list.ts b/e2e/fixtures/module-list.ts index e8abb653..eaa99be0 100644 --- a/e2e/fixtures/module-list.ts +++ b/e2e/fixtures/module-list.ts @@ -15,6 +15,7 @@ export const MODULES: ModuleEntry[] = [ { id: "rpubs", name: "rPubs", primarySelector: "folk-pubs-editor" }, { id: "rcart", name: "rCart", primarySelector: "folk-cart-shop" }, { id: "rpayments", name: "rPayments", primarySelector: "folk-payments-dashboard" }, + { id: "rlending", name: "rLending", primarySelector: "folk-mortgage-simulator" }, { id: "rswag", name: "rSwag", primarySelector: "folk-swag-designer" }, { id: "rchoices", name: "rChoices", primarySelector: "folk-choices-dashboard" }, { id: "rflows", name: "rFlows", primarySelector: "folk-flows-app" }, diff --git a/lib/module-display.ts b/lib/module-display.ts index d8e5416d..49b8e409 100644 --- a/lib/module-display.ts +++ b/lib/module-display.ts @@ -26,6 +26,7 @@ export const MODULE_META: Record = { rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" }, rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" }, rpayments: { badge: "rPa", color: "#86efac", name: "rPayments", icon: "💳" }, + rlending: { badge: "rLe", color: "#fcd34d", name: "rLending", icon: "🏦" }, rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" }, rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" }, rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" }, diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index e624b327..72c68952 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -6976,10 +6976,12 @@ class FolkFlowsApp extends HTMLElement { this.render(); const base = this.getApiBase(); + // Mortgage routes live in rLending — derive a sibling base by swapping /rflows → /rlending + const lendingBase = base.replace(/\/rflows(?=$|\/)/, '/rlending') || '/rlending'; try { const [posRes, rateRes] = await Promise.all([ - fetch(`${base}/api/mortgage/positions?space=${encodeURIComponent(this.space)}`), - fetch(`${base}/api/mortgage/rates`), + fetch(`${lendingBase}/api/mortgage/positions?space=${encodeURIComponent(this.space)}`), + fetch(`${lendingBase}/api/mortgage/rates`), ]); if (posRes.ok) this.mortgagePositions = await posRes.json(); if (rateRes.ok) { diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 382d033e..0a73de0b 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -19,7 +19,7 @@ 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 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'; @@ -33,7 +33,7 @@ const _completedOutcomes = new Set(); // space:outcomeId — dedup for w const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; -function ensureDoc(space: string): FlowsDoc { +export function ensureFlowsDoc(space: string): FlowsDoc { const docId = flowsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { @@ -152,7 +152,7 @@ routes.get("/api/flows", async (c) => { // If space filter provided, get flow IDs from Automerge doc if (space) { - const doc = ensureDoc(space); + const doc = ensureFlowsDoc(space); const flowIds = Object.values(doc.spaceFlows).map((sf) => sf.flowId); if (flowIds.length === 0) return c.json([]); @@ -578,7 +578,7 @@ routes.post("/api/space-flows", async (c) => { if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400); const docId = flowsDocId(space); - ensureDoc(space); + ensureFlowsDoc(space); _syncServer!.changeDoc(docId, 'add space flow', (d) => { const key = `${space}:${flowId}`; if (!d.spaceFlows[key]) { @@ -611,106 +611,12 @@ routes.delete("/api/space-flows/:flowId", async (c) => { 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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json() as Partial; - 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(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 doc = ensureFlowsDoc(space); const segments = Object.entries(doc.budgetSegments || {}).map(([id, s]) => ({ id, name: s.name, color: s.color, createdBy: s.createdBy, @@ -749,7 +655,7 @@ routes.post("/api/budgets/allocate", async (c) => { const spaceSlug = space || "demo"; const docId = flowsDocId(spaceSlug); - ensureDoc(spaceSlug); + ensureFlowsDoc(spaceSlug); const did = (claims as any).did || claims.sub; _syncServer!.changeDoc(docId, 'save budget allocation', (d) => { @@ -765,7 +671,7 @@ routes.post("/api/budgets/allocate", async (c) => { routes.get("/api/budgets/segments", async (c) => { const space = c.req.query("space") || "demo"; - const doc = ensureDoc(space); + const doc = ensureFlowsDoc(space); const segments = Object.entries(doc.budgetSegments || {}).map(([id, s]) => ({ id, name: s.name, color: s.color, createdBy: s.createdBy, })); @@ -785,7 +691,7 @@ routes.post("/api/budgets/segments", async (c) => { const spaceSlug = space || "demo"; const docId = flowsDocId(spaceSlug); - ensureDoc(spaceSlug); + ensureFlowsDoc(spaceSlug); const did = (claims as any).did || claims.sub; @@ -835,7 +741,7 @@ routes.get("/api/flows/outcome-tasks", async (c) => { if (!space || !outcomeId) return c.json({ error: "space and outcomeId required" }, 400); if (!_syncServer) return c.json({ error: "Not initialized" }, 503); - const doc = ensureDoc(space); + const doc = ensureFlowsDoc(space); const found = findOutcomeNode(doc, outcomeId); if (!found) return c.json({ error: "Outcome not found" }, 404); @@ -881,7 +787,7 @@ routes.post("/api/flows/outcome-tasks/link", async (c) => { const { space, outcomeId, boardId, taskId } = await c.req.json(); if (!space || !outcomeId || !boardId || !taskId) return c.json({ error: "space, outcomeId, boardId, taskId required" }, 400); - const doc = ensureDoc(space); + const doc = ensureFlowsDoc(space); const found = findOutcomeNode(doc, outcomeId); if (!found) return c.json({ error: "Outcome not found" }, 404); @@ -947,7 +853,7 @@ routes.post("/api/flows/outcome-tasks/create", async (c) => { const { space, outcomeId, title, boardId: reqBoardId } = await c.req.json(); if (!space || !outcomeId) return c.json({ error: "space, outcomeId required" }, 400); - const doc = ensureDoc(space); + const doc = ensureFlowsDoc(space); const found = findOutcomeNode(doc, outcomeId); if (!found) return c.json({ error: "Outcome not found" }, 404); @@ -1006,7 +912,7 @@ routes.get("/api/flows/board-tasks", async (c) => { // Get already-linked task IDs for this outcome const linkedRefs = new Set(); if (outcomeId) { - const doc = ensureDoc(space); + const doc = ensureFlowsDoc(space); const found = findOutcomeNode(doc, outcomeId); if (found?.data.linkedTaskIds) { for (const ref of found.data.linkedTaskIds) linkedRefs.add(ref); @@ -1033,10 +939,6 @@ const flowsScripts = ` `; -const mortgageScripts = ` - - `; - const flowsStyles = ``; // Landing page (also serves demo via centralized /demo → space="demo" rewrite) @@ -1054,20 +956,6 @@ routes.get("/", (c) => { })); }); -// Mortgage sub-tab — full distributed mortgage simulator -routes.get("/mortgage", (c) => { - const spaceSlug = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `${spaceSlug} — rMortgage | rFlows | rSpace`, - moduleId: "rflows", - spaceSlug, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: mortgageScripts, - styles: flowsStyles, - })); -}); // Budgets sub-tab routes.get("/budgets", (c) => { @@ -1104,7 +992,7 @@ routes.get("/flow/:flowId", (c) => { function seedTemplateFlows(space: string) { if (!_syncServer) return; - const doc = ensureDoc(space); + const doc = ensureFlowsDoc(space); // Seed SpaceFlow association if empty if (Object.keys(doc.spaceFlows).length === 0) { @@ -1143,48 +1031,6 @@ function seedTemplateFlows(space: string) { 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(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); @@ -1253,7 +1099,7 @@ routes.post("/api/flows/drips/import", async (c) => { const newNodes = mapDripsToFlowNodes(state, { originX: 400, originY: 100 }); const docId = flowsDocId(space); - ensureDoc(space); + ensureFlowsDoc(space); _syncServer!.changeDoc(docId, 'import drips nodes', (d) => { const flow = d.canvasFlows[flowId]; @@ -1482,18 +1328,6 @@ export const flowsModule: RSpaceModule = { { 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." }, - ], - }, ], onboardingActions: [ { label: "Create a Budget", icon: "🌊", description: "Set up a community funding flow", type: 'create', href: '/{space}/rflows' }, diff --git a/modules/rflows/components/folk-mortgage-simulator.ts b/modules/rlending/components/folk-mortgage-simulator.ts similarity index 100% rename from modules/rflows/components/folk-mortgage-simulator.ts rename to modules/rlending/components/folk-mortgage-simulator.ts diff --git a/modules/rlending/landing.ts b/modules/rlending/landing.ts new file mode 100644 index 00000000..1d1210a7 --- /dev/null +++ b/modules/rlending/landing.ts @@ -0,0 +1,147 @@ +/** + * rLending landing page — trust-based distributed mortgages. + */ +export function renderLanding(): string { + return ` + +
+ rLending +

Mortgages at the speed of trust.

+

Distributed lending, community-funded tranches.

+

+ A 30-year mortgage split into 80 tranches of $5k, each funded by a neighbor, backed by + social trust instead of a credit score. Reinvestment loops turn idle capital into yield. + A secondary market lets lenders exit when they need to. +

+ +
+ + +
+
+

What rLending Handles

+
+
+
🤝
+

Trust-Backed Lending

+

Underwriting from your community, not a bureau. Trust scores from rNetwork relationships and rCred reputation replace FICO.

+
+
+
🧺
+

Distributed Tranches

+

One mortgage, many lenders. Each lender picks a term (2yr–30yr) and rate, funding a $1k–$5k slice of principal.

+
+
+
📈
+

Reinvestment Loops

+

Repayments auto-roll into new tranches or external DeFi yield (Aave v3, Morpho Blue). Compounds while borrowers pay down principal.

+
+
+
🔅
+

Secondary Market

+

Lenders can list their tranche for sale at any time. Buyers step in, premium is set by supply and demand.

+
+
+
+
+ + +
+
+

How It Works

+
+
+ 1 +

Borrower Posts Terms

+

Property value, down payment, term, rate. The simulator splits the principal into configurable tranches and tiers.

+
+
+ 2 +

Community Funds the Tranches

+

Lenders in the space claim tranches that match their risk tolerance and time horizon. Trust from your network gates access.

+
+
+ 3 +

Payments Flow & Compound

+

Monthly payments amortize each tranche. Reinvestment rules auto-deploy idle capital to new tranches or Aave/Morpho.

+
+
+
+
+ + +
+
+
+
+

Pool economics, not banks

+

+ Every dollar stays in the community. Interest that would have left as bank profit now recycles as yield for neighbors. +

+
    +
  • Variable terms — 2yr, 5yr, 10yr, 15yr, 30yr tiers with tiered rates
  • +
  • Overpayment — extra principal, community fund contributions, or split
  • +
  • Pool reinvestment — idle capital earns Aave v3 supply APY until redeployed
  • +
  • Simulation modes — mycelial, flow, grid, lender, borrower views
  • +
+
+
+
+
📈
+

Aave v3 + Morpho Blue

+

Live pool reinvestment.
APY updates on every block.

+
+
+
+
+
+ + +
+
+

Built on Open Source

+

The libraries and tools that power rLending.

+
+
+
+

ERC-20 & Aave

+

Open stablecoin rails. Supply to Aave v3 for idle pool capital, borrow against positions for leverage.

+
+
+
🌠
+

rNetwork Trust Graph

+

Underwriting draws on your community's trust edges, not an opaque credit score.

+
+
+
📡
+

Automerge CRDT

+

Positions and tranches sync live across lenders. No central ledger, no single point of failure.

+
+
+
🔥
+

Hono

+

Ultrafast API layer. Lightweight and edge-ready.

+
+
+
+
+ + +
+
+

Model a mortgage your whole block can fund.

+

Run the simulator and see how your community can replace the bank.

+ +
+
+ +`; +} diff --git a/modules/rflows/lib/mortgage-engine.ts b/modules/rlending/lib/mortgage-engine.ts similarity index 100% rename from modules/rflows/lib/mortgage-engine.ts rename to modules/rlending/lib/mortgage-engine.ts diff --git a/modules/rflows/lib/mortgage-types.ts b/modules/rlending/lib/mortgage-types.ts similarity index 100% rename from modules/rflows/lib/mortgage-types.ts rename to modules/rlending/lib/mortgage-types.ts diff --git a/modules/rlending/mod.ts b/modules/rlending/mod.ts new file mode 100644 index 00000000..8fcd6190 --- /dev/null +++ b/modules/rlending/mod.ts @@ -0,0 +1,208 @@ +/** + * rLending module — trust-based distributed mortgage lending. + * + * Extracted from rFlows. Uses the shared FlowsDoc (mortgagePositions + + * reinvestmentPositions fields) so the rPool dashboard in rFlows keeps + * working without a data migration. + */ + +import { Hono } from "hono"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { verifyToken, extractToken } from "../../server/auth"; +import { renderLanding } from "./landing"; +import type { SyncServer } from "../../server/local-first/sync-server"; +import { flowsDocId, type FlowsDoc } from "../rflows/schemas"; +import type { MortgagePosition, ReinvestmentPosition } from "../rflows/lib/types"; +import { ensureFlowsDoc } from "../rflows/mod"; + +let _syncServer: SyncServer | null = null; + +// ── Aave v3 Pool on Base (for live lending rate lookup) ── + +const AAVE_V3_POOL_BASE = "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5"; +const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; +const BASE_RPC = "https://mainnet.base.org"; + +// ── Routes ── + +const routes = new Hono(); + +// GET /api/mortgage/rates — Live Aave v3 USDC supply APY on Base +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 3rd word in the result tuple (27 decimals / ray) + const resultHex = json.result as string; + const liquidityRateHex = "0x" + resultHex.slice(2 + 64 * 2, 2 + 64 * 3); + const liquidityRate = Number(BigInt(liquidityRateHex)) / 1e27; + 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("[rlending] Rate fetch failed:", err); + return c.json({ + rates: [ + { protocol: "Aave v3", chain: "Base", asset: "USDC", apy: null, updatedAt: Date.now(), error: "Unavailable" }, + ], + }); + } +}); + +// GET /api/mortgage/positions — List mortgage positions for a space +routes.get("/api/mortgage/positions", async (c) => { + const space = c.req.query("space") || "demo"; + const doc = ensureFlowsDoc(space); + return c.json(Object.values(doc.mortgagePositions || {})); +}); + +// POST /api/mortgage/positions — Create a new mortgage position (auth required) +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 verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json() as Partial & { space?: string }; + if (!body.principal || !body.termMonths || !body.interestRate) { + return c.json({ error: "principal, termMonths, and interestRate required" }, 400); + } + + const space = body.space || "demo"; + const docId = flowsDocId(space); + ensureFlowsDoc(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(docId, "create mortgage position", (d) => { + d.mortgagePositions[id] = position as any; + }); + + return c.json(position, 201); +}); + +// ── Page routes ── + +const lendingScripts = ` + + `; + +// GET / — Mortgage simulator dashboard (landing for the module) +routes.get("/", (c) => { + const spaceSlug = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${spaceSlug} — rLending | rSpace`, + moduleId: "rlending", + spaceSlug, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: lendingScripts, + })); +}); + +// ── Seed demo data ── + +function seedTemplateLending(space: string) { + if (!_syncServer) return; + const doc = ensureFlowsDoc(space); + if (Object.keys(doc.mortgagePositions || {}).length > 0) return; + + 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(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(`[rlending] Mortgage demo seeded for "${space}"`); +} + +// ── Module definition ── + +export const lendingModule: RSpaceModule = { + id: "rlending", + name: "rLending", + icon: "🏦", + description: "Trust-based distributed mortgages with DeFi reinvestment", + scoping: { defaultScope: "space", userConfigurable: false }, + routes, + landingPage: renderLanding, + seedTemplate: seedTemplateLending, + async onInit(ctx) { + _syncServer = ctx.syncServer; + }, + outputPaths: [ + { path: "", name: "Simulator", icon: "🧮", description: "Distributed mortgage simulator with tranches, reinvestment, and secondary market" }, + ], + onboardingActions: [ + { label: "Open Mortgage Simulator", icon: "🏦", description: "Model community-funded mortgages with variable terms and tranches", type: "create", href: "/{space}/rlending" }, + ], +}; diff --git a/server/index.ts b/server/index.ts index 07368f80..ad6f97f6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -57,6 +57,7 @@ import { booksModule } from "../modules/rbooks/mod"; import { pubsModule } from "../modules/rpubs/mod"; import { cartModule } from "../modules/rcart/mod"; import { paymentsModule } from "../modules/rpayments/mod"; +import { lendingModule } from "../modules/rlending/mod"; import { swagModule } from "../modules/rswag/mod"; import { choicesModule } from "../modules/rchoices/mod"; import { flowsModule } from "../modules/rflows/mod"; @@ -154,6 +155,7 @@ registerModule(canvasModule); registerModule(pubsModule); registerModule(cartModule); registerModule(paymentsModule); +registerModule(lendingModule); registerModule(swagModule); registerModule(choicesModule); registerModule(flowsModule); diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 66dcd7d2..063529da 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -52,6 +52,7 @@ const MODULE_BADGES: Record = { rwallet: { badge: "r💰", color: "#fde047" }, // yellow-300 rcart: { badge: "r🛒", color: "#fdba74" }, // orange-300 rpayments: { badge: "r💳", color: "#86efac" }, // green-300 + rlending: { badge: "r🏦", color: "#fcd34d" }, // amber-300 rauctions: { badge: "r🎭", color: "#fca5a5" }, // red-300 // Govern rgov: { badge: "r⚖️", color: "#94a3b8" }, // slate-400 @@ -106,6 +107,7 @@ const MODULE_CATEGORIES: Record = { rauctions: "Commerce", rcart: "Commerce", rpayments: "Commerce", + rlending: "Commerce", rexchange: "Commerce", rflows: "Commerce", rwallet: "Commerce", diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 2fdeb1fb..d9ade584 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -45,6 +45,7 @@ const MODULE_BADGES: Record = { rwallet: { badge: "r💰", color: "#fde047" }, rcart: { badge: "r🛒", color: "#fdba74" }, rpayments: { badge: "r💳", color: "#86efac" }, + rlending: { badge: "r🏦", color: "#fcd34d" }, rauctions: { badge: "r🏛", color: "#fca5a5" }, rtube: { badge: "r🎬", color: "#f9a8d4" }, rphotos: { badge: "r📸", color: "#f9a8d4" }, @@ -67,7 +68,7 @@ const MODULE_CATEGORIES: Record = { rcal: "Planning", rtrips: "Planning", rmaps: "Planning", rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating", rchoices: "Deciding", rvote: "Deciding", - rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rpayments: "Funding & Commerce", rauctions: "Funding & Commerce", + rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rpayments: "Funding & Commerce", rlending: "Funding & Commerce", rauctions: "Funding & Commerce", rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing", rdata: "Observing", rtasks: "Tasks & Productivity", diff --git a/vite.config.ts b/vite.config.ts index f2140f3d..f48f73fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -474,21 +474,21 @@ export default defineConfig({ }, }); - // Build mortgage simulator component + // Build mortgage simulator component (lives in rLending) await wasmBuild({ configFile: false, - root: resolve(__dirname, "modules/rflows/components"), + root: resolve(__dirname, "modules/rlending/components"), resolve: { alias: { - "../lib/mortgage-types": resolve(__dirname, "modules/rflows/lib/mortgage-types.ts"), - "../lib/mortgage-engine": resolve(__dirname, "modules/rflows/lib/mortgage-engine.ts"), + "../lib/mortgage-types": resolve(__dirname, "modules/rlending/lib/mortgage-types.ts"), + "../lib/mortgage-engine": resolve(__dirname, "modules/rlending/lib/mortgage-engine.ts"), }, }, build: { emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/rflows"), + outDir: resolve(__dirname, "dist/modules/rlending"), lib: { - entry: resolve(__dirname, "modules/rflows/components/folk-mortgage-simulator.ts"), + entry: resolve(__dirname, "modules/rlending/components/folk-mortgage-simulator.ts"), formats: ["es"], fileName: () => "folk-mortgage-simulator.js", },