From 600e9080d087913f5dbec0c0e20b2315e325ba06 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Mar 2026 01:22:53 -0700 Subject: [PATCH] feat(rflows): add rMortgage sub-tab with trust-backed lending & DeFi reinvestment Social mortgage lending tracker at /mortgage with pool overview, active positions table, lender detail vessel visualization, borrower options panel (monthly-budget-constrained with lender fill bars), live Aave v3 rates on Base, reinvestment tracker, and yield projection calculator. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flows-app.ts | 463 +++++++++++++++++++- modules/rflows/lib/types.ts | 26 ++ modules/rflows/mod.ts | 175 +++++++- modules/rflows/schemas.ts | 14 +- 4 files changed, 669 insertions(+), 9 deletions(-) diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 5ddb76d..8d4795e 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -11,7 +11,7 @@ * mode — "demo" to use hardcoded demo data (no API) */ -import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types"; +import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation, MortgagePosition, ReinvestmentPosition } from "../lib/types"; import { PORT_DEFS, deriveThresholds } from "../lib/types"; import { TourEngine } from "../../../shared/tour-engine"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; @@ -41,7 +41,7 @@ interface Transaction { description?: string; } -type View = "landing" | "detail"; +type View = "landing" | "detail" | "mortgage"; interface NodeAnalyticsStats { totalInflow: number; @@ -155,6 +155,19 @@ class FolkFlowsApp extends HTMLElement { private flowManagerOpen = false; private _lfcUnsub: (() => void) | null = null; + // Mortgage state + private mortgagePositions: MortgagePosition[] = []; + private reinvestmentPositions: ReinvestmentPosition[] = []; + private liveRates: { protocol: string; chain: string; asset: string; apy: number | null; error?: string; updatedAt: number }[] = []; + private selectedLenderId: string | null = null; + private projCalcAmount = 10000; + private projCalcMonths = 12; + private projCalcApy = 4.5; + + // Borrower options state + private borrowerMonthlyBudget = 1500; + private borrowerOptionsVisible = false; + // Tour engine private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -187,8 +200,14 @@ class FolkFlowsApp extends HTMLElement { new MutationObserver(() => this._syncTheme()) .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); - // Canvas-first: always open in detail (canvas) view - this.view = "detail"; + // Read view attribute, default to canvas (detail) view + const viewAttr = this.getAttribute("view"); + this.view = viewAttr === "mortgage" ? "mortgage" : "detail"; + + if (this.view === "mortgage") { + this.loadMortgageData(); + return; + } if (this.isDemo) { // Demo/anon: load from localStorage or demoNodes @@ -586,6 +605,7 @@ class FolkFlowsApp extends HTMLElement { } private renderView(): string { + if (this.view === "mortgage") return this.renderMortgageTab(); if (this.view === "detail") return this.renderDetail(); return this.renderLanding(); } @@ -5119,6 +5139,38 @@ class FolkFlowsApp extends HTMLElement { // Create flow button (landing page, auth-gated) const createBtn = this.shadow.querySelector('[data-action="create-flow"]'); createBtn?.addEventListener("click", () => this.handleCreateFlow()); + + // Mortgage tab listeners + if (this.view === "mortgage") { + this.shadow.querySelectorAll('.mortgage-row').forEach(row => { + row.addEventListener('click', () => { + const id = (row as HTMLElement).dataset.mortgageId || null; + this.selectedLenderId = this.selectedLenderId === id ? null : id; + this.render(); + }); + }); + + this.shadow.querySelector('[data-action="close-lender"]')?.addEventListener('click', () => { + this.selectedLenderId = null; + this.render(); + }); + + this.shadow.querySelector('[data-action="update-borrower"]')?.addEventListener('click', () => { + const budgetEl = this.shadow.querySelector('[data-borrower="budget"]') as HTMLInputElement; + if (budgetEl) this.borrowerMonthlyBudget = Math.max(parseFloat(budgetEl.value) || 100, 100); + this.render(); + }); + + this.shadow.querySelector('[data-action="calc-projection"]')?.addEventListener('click', () => { + const amtEl = this.shadow.querySelector('[data-proj="amount"]') as HTMLInputElement; + const apyEl = this.shadow.querySelector('[data-proj="apy"]') as HTMLInputElement; + const moEl = this.shadow.querySelector('[data-proj="months"]') as HTMLInputElement; + if (amtEl) this.projCalcAmount = parseFloat(amtEl.value) || 0; + if (apyEl) this.projCalcApy = parseFloat(apyEl.value) || 0; + if (moEl) this.projCalcMonths = parseInt(moEl.value) || 0; + this.render(); + }); + } } private cleanupCanvas() { @@ -5186,6 +5238,409 @@ class FolkFlowsApp extends HTMLElement { this._tour.start(); } + // ─── Mortgage tab ─────────────────────────────────────── + + private async loadMortgageData() { + this.loading = true; + this.render(); + + const base = this.getApiBase(); + try { + const [posRes, rateRes] = await Promise.all([ + fetch(`${base}/api/mortgage/positions?space=${encodeURIComponent(this.space)}`), + fetch(`${base}/api/mortgage/rates`), + ]); + if (posRes.ok) this.mortgagePositions = await posRes.json(); + if (rateRes.ok) { + const data = await rateRes.json(); + this.liveRates = data.rates || []; + } + } catch (err) { + console.warn('[rMortgage] Failed to load data:', err); + } + + // If no positions from API (demo mode), use hardcoded demo data + if (this.mortgagePositions.length === 0) { + const now = Date.now(); + this.mortgagePositions = [ + { id: '1', 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: '2', 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: '3', 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: '4', 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' }, + ]; + this.reinvestmentPositions = [ + { 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 }, + ]; + } + + this.loading = false; + this.render(); + } + + private renderMortgageTab(): string { + if (this.loading) return '
Loading mortgage data...
'; + + const totalPool = this.mortgagePositions.reduce((s, m) => s + m.principal, 0); + const deployed = this.reinvestmentPositions.reduce((s, r) => s + r.deposited, 0); + const currentYieldValue = this.reinvestmentPositions.reduce((s, r) => s + (r.currentValue - r.deposited), 0); + const avgApy = this.reinvestmentPositions.length > 0 + ? this.reinvestmentPositions.reduce((s, r) => s + r.apy, 0) / this.reinvestmentPositions.length + : 0; + + const selectedLender = this.selectedLenderId + ? this.mortgagePositions.find(m => m.id === this.selectedLenderId) || null + : null; + + return ` +
+
+ ← Back to Flows +

rMortgage

+ BETA +
+ + +
+ ${this.renderPoolCard('Total Pool', this.fmtUsd(totalPool), 'Aggregate mortgage capital', '#3b82f6')} + ${this.renderPoolCard('Deployed to DeFi', this.fmtUsd(deployed), 'Idle capital reinvested', '#10b981')} + ${this.renderPoolCard('Yield Earned', this.fmtUsd(currentYieldValue), 'From reinvestment positions', '#f59e0b')} + ${this.renderPoolCard('Avg APY', avgApy.toFixed(2) + '%', 'Weighted pool return', '#8b5cf6')} +
+ + +
+

Active Mortgages

+
+ + + + + + + + + + + + + + ${this.mortgagePositions.map(m => this.renderMortgageRow(m)).join('')} + +
BorrowerPrincipalRateTermMonthlyTrustStatus
+
+
+ + ${selectedLender ? this.renderLenderDetail(selectedLender) : ''} + + + ${this.renderBorrowerOptions()} + + +
+
+

Reinvestment Positions

+ ${this.reinvestmentPositions.map(r => ` +
+
+
${this.esc(r.protocol)}
+
${this.esc(r.chain)} · ${this.esc(r.asset)}
+
+
+
${r.apy.toFixed(2)}% APY
+
${this.fmtUsd(r.deposited)} → ${this.fmtUsd(r.currentValue)}
+
+
+ `).join('')} + ${this.reinvestmentPositions.length === 0 ? '
No reinvestment positions yet
' : ''} +
+ + +
+

Live DeFi Rates

+ ${this.liveRates.map(r => ` +
+
+
${this.esc(r.protocol)}
+
${this.esc(r.chain)} · ${this.esc(r.asset)}
+
+
+ ${r.apy !== null + ? `
${r.apy.toFixed(2)}% APY
` + : `
${r.error || 'Unavailable'}
` + } +
+
+ `).join('')} + ${this.liveRates.length === 0 ? '
Fetching rates...
' : ''} +
+
+ + +
+

Yield Projection Calculator

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ ${this.renderProjection()} +
+
+
`; + } + + private renderPoolCard(title: string, value: string, subtitle: string, color: string): string { + return ` +
+
${title}
+
${value}
+
${subtitle}
+
`; + } + + private renderMortgageRow(m: MortgagePosition): string { + const statusColors: Record = { active: '#10b981', 'paid-off': '#3b82f6', defaulted: '#ef4444', pending: '#f59e0b' }; + const trustColor = m.trustScore >= 90 ? '#10b981' : m.trustScore >= 75 ? '#f59e0b' : '#ef4444'; + const isSelected = this.selectedLenderId === m.id; + return ` + + ${this.esc(m.borrower)} + ${this.fmtUsd(m.principal)} + ${m.interestRate}% + ${m.termMonths}mo + ${this.fmtUsd(m.monthlyPayment)} + ${m.trustScore} + ${m.status} + `; + } + + private renderLenderDetail(m: MortgagePosition): string { + // Calculate repayment progress + const monthsElapsed = Math.floor((Date.now() - m.startDate) / (86400000 * 30)); + const totalPaid = m.monthlyPayment * monthsElapsed; + const interestPaid = totalPaid - (totalPaid * m.principal / (m.monthlyPayment * m.termMonths)); + const principalRepaid = Math.min(totalPaid - interestPaid, m.principal); + const remaining = Math.max(m.principal - principalRepaid, 0); + + // Simulate reinvestment of idle capital from this position + const idleCapital = principalRepaid * 0.6; // 60% of repaid principal goes to reinvestment + const reinvestApy = this.reinvestmentPositions.length > 0 + ? this.reinvestmentPositions.reduce((s, r) => s + r.apy, 0) / this.reinvestmentPositions.length + : 4.0; + const reinvestEarnings = idleCapital * (reinvestApy / 100) * (monthsElapsed / 12); + const loanEarnings = interestPaid; + + // Vessel proportions + const vesselTotal = m.principal; + const repaidPct = (principalRepaid / vesselTotal) * 100; + const reinvestedPct = (idleCapital / vesselTotal) * 100; + const emptyPct = Math.max(100 - repaidPct - reinvestedPct, 0); + + return ` +
+
+

Lender Pool: ${this.esc(m.borrower)}

+ +
+ +
+ +
+ + + + + + + + + + + ${repaidPct > 10 ? `${Math.round(repaidPct)}% Repaid` : ''} + ${reinvestedPct > 10 ? `${Math.round(reinvestedPct)}% Reinvested` : ''} + +
Pool: ${this.fmtUsd(vesselTotal)}
+
+ + +
+
+
Repaid Principal
+
${this.fmtUsd(principalRepaid)}
+
+
+
Reinvested Capital
+
${this.fmtUsd(idleCapital)}
+
+
+
Loan Interest Earned
+
${this.fmtUsd(loanEarnings)}
+
+
+
Reinvestment Yield
+
${this.fmtUsd(reinvestEarnings)}
+
+
+
Total Earnings
+
${this.fmtUsd(loanEarnings + reinvestEarnings)}
+
${monthsElapsed} months elapsed of ${m.termMonths}mo term
+
+
+
+
`; + } + + private renderBorrowerOptions(): string { + const termOptions = [60, 120, 180, 240, 300, 360]; // 5yr, 10yr, 15yr, 20yr, 25yr, 30yr + const budget = this.borrowerMonthlyBudget; + + // Build lender pool: each active lender has available capital (simulated from repaid principal) + const lenders = this.mortgagePositions + .filter(m => m.status === 'active') + .map(m => { + const monthsElapsed = Math.floor((Date.now() - m.startDate) / (86400000 * 30)); + const totalPaid = m.monthlyPayment * monthsElapsed; + const principalFraction = m.principal / (m.monthlyPayment * m.termMonths); + const principalRepaid = Math.min(totalPaid * principalFraction, m.principal); + // Available = repaid principal that can be re-lent + const available = Math.max(principalRepaid * 0.8, m.principal * 0.15); // At least 15% of pool + return { id: m.id, name: m.borrower, available: Math.round(available), trustScore: m.trustScore }; + }) + .sort((a, b) => b.trustScore - a.trustScore); // highest trust first + + const totalAvailable = lenders.reduce((s, l) => s + l.available, 0); + + // For each term, compute max principal borrower can afford at a blended rate + const avgRate = this.mortgagePositions.length > 0 + ? this.mortgagePositions.reduce((s, m) => s + m.interestRate, 0) / this.mortgagePositions.length + : 4.0; + + const options = termOptions.map(months => { + const monthlyRate = avgRate / 100 / 12; + // PV of annuity: principal = payment * ((1 - (1+r)^-n) / r) + const maxPrincipal = monthlyRate > 0 + ? budget * (1 - Math.pow(1 + monthlyRate, -months)) / monthlyRate + : budget * months; + const principal = Math.min(Math.round(maxPrincipal), totalAvailable); + const actualMonthly = monthlyRate > 0 + ? principal * (monthlyRate * Math.pow(1 + monthlyRate, months)) / (Math.pow(1 + monthlyRate, months) - 1) + : principal / months; + + // Fill from lenders in order + let remaining = principal; + const fills: { name: string; amount: number; pct: number; color: string }[] = []; + const fillColors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4']; + for (let i = 0; i < lenders.length && remaining > 0; i++) { + const contribution = Math.min(lenders[i].available, remaining); + if (contribution <= 0) continue; + fills.push({ + name: lenders[i].name, + amount: contribution, + pct: principal > 0 ? (contribution / principal) * 100 : 0, + color: fillColors[i % fillColors.length], + }); + remaining -= contribution; + } + const funded = principal - remaining; + const fundedPct = principal > 0 ? (funded / principal) * 100 : 0; + + return { months, principal, actualMonthly: Math.round(actualMonthly * 100) / 100, rate: avgRate, fills, funded, fundedPct }; + }); + + return ` +
+
+

Borrower Options

+
+ +
+ $ + + /mo +
+ +
+
+
+ Avg pool rate: ${avgRate.toFixed(1)}% · Total lender capital available: ${this.fmtUsd(totalAvailable)} · Lenders fill loans in trust-score order +
+
+ ${options.map(o => ` +
+
+
+ ${o.months / 12}yr + ${o.months} months @ ${o.rate.toFixed(1)}% +
+
+ ${this.fmtUsd(o.principal)} + ${this.fmtUsd(o.actualMonthly)}/mo +
+
+ +
+
+ ${o.fills.map(f => ` +
+ ${f.pct > 12 ? f.name : ''} +
+ `).join('')} + ${o.fundedPct < 100 ? `
${Math.round(100 - o.fundedPct)}% unfunded
` : ''} +
+
+
+ ${o.fills.map(f => ` + + + ${this.esc(f.name)} ${this.fmtUsd(f.amount)} + + `).join('')} +
+
+ `).join('')} +
+
`; + } + + private renderProjection(): string { + const monthlyRate = this.projCalcApy / 100 / 12; + const finalValue = this.projCalcAmount * Math.pow(1 + monthlyRate, this.projCalcMonths); + const earned = finalValue - this.projCalcAmount; + return ` +
+
+
Initial Deposit
+
${this.fmtUsd(this.projCalcAmount)}
+
+
+
Projected Value
+
${this.fmtUsd(finalValue)}
+
+
+
Yield Earned
+
${this.fmtUsd(earned)}
+
+
`; + } + + private fmtUsd(v: number): string { + return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + } + private esc(s: string): string { return s .replace(/&/g, "&") diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 9ab42ad..263de9d 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -107,6 +107,32 @@ export interface FlowNode { data: FunnelNodeData | OutcomeNodeData | SourceNodeData; } +// ─── Mortgage types ────────────────────────────────── + +export interface MortgagePosition { + id: string; + borrower: string; + borrowerDid: string; + principal: number; + interestRate: number; + termMonths: number; + monthlyPayment: number; + startDate: number; + trustScore: number; + status: "active" | "paid-off" | "defaulted" | "pending"; + collateralType: "trust-backed" | "asset-backed" | "hybrid"; +} + +export interface ReinvestmentPosition { + protocol: string; + chain: string; + asset: string; + deposited: number; + currentValue: number; + apy: number; + lastUpdated: number; +} + // ─── Port definitions ───────────────────────────────── export type PortDirection = "in" | "out"; diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 335c468..0102128 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -18,7 +18,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 } from './lib/types'; +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'; @@ -53,6 +53,15 @@ function ensureDoc(space: string): FlowsDoc { }); doc = _syncServer!.getDoc(docId)!; } + // Migrate v2 → v3: add mortgagePositions and reinvestmentPositions + if (doc.meta.version < 3) { + _syncServer!.changeDoc(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(docId)!; + } return doc; } @@ -560,6 +569,101 @@ 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 verifyEncryptIDToken(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); +}); + // ─── Page routes ──────────────────────────────────────── const flowsScripts = ` @@ -584,6 +688,21 @@ routes.get("/", (c) => { })); }); +// 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: ``, + scripts: flowsScripts, + styles: flowsStyles, + })); +}); + // Flow detail — specific flow from API routes.get("/flow/:flowId", (c) => { const spaceSlug = c.req.param("space") || "demo"; @@ -642,6 +761,48 @@ 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}"`); + } } export const flowsModule: RSpaceModule = { @@ -755,5 +916,17 @@ export const flowsModule: RSpaceModule = { { icon: "🎯", title: "Outcome Tracking", text: "Define funding outcomes and monitor how capital reaches its destination." }, ], }, + { + 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." }, + ], + }, ], }; diff --git a/modules/rflows/schemas.ts b/modules/rflows/schemas.ts index b6b8f1e..7cba8a3 100644 --- a/modules/rflows/schemas.ts +++ b/modules/rflows/schemas.ts @@ -9,7 +9,7 @@ */ import type { DocSchema } from '../../shared/local-first/document'; -import type { FlowNode } from './lib/types'; +import type { FlowNode, MortgagePosition, ReinvestmentPosition } from './lib/types'; // ── Document types ── @@ -41,6 +41,8 @@ export interface FlowsDoc { spaceFlows: Record; canvasFlows: Record; activeFlowId: string; + mortgagePositions: Record; + reinvestmentPositions: Record; } // ── Schema registration ── @@ -48,23 +50,27 @@ export interface FlowsDoc { export const flowsSchema: DocSchema = { module: 'flows', collection: 'data', - version: 2, + version: 3, init: (): FlowsDoc => ({ meta: { module: 'flows', collection: 'data', - version: 2, + version: 3, spaceSlug: '', createdAt: Date.now(), }, spaceFlows: {}, canvasFlows: {}, activeFlowId: '', + mortgagePositions: {}, + reinvestmentPositions: {}, }), migrate: (doc: any, _fromVersion: number) => { if (!doc.canvasFlows) doc.canvasFlows = {}; if (!doc.activeFlowId) doc.activeFlowId = ''; - doc.meta.version = 2; + if (!doc.mortgagePositions) doc.mortgagePositions = {}; + if (!doc.reinvestmentPositions) doc.reinvestmentPositions = {}; + doc.meta.version = 3; return doc; }, };