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 `
+
+
+
+
+
+ ${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
+
+
+
+
+ | Borrower |
+ Principal |
+ Rate |
+ Term |
+ Monthly |
+ Trust |
+ Status |
+
+
+
+ ${this.mortgagePositions.map(m => this.renderMortgageRow(m)).join('')}
+
+
+
+
+
+ ${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)}
+
+
+
+
+
+
+
+
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;
},
};