feat(rlending): extract mortgage simulator + /api/mortgage from rFlows

- New modules/rlending/ with moved folk-mortgage-simulator, mortgage-engine,
  mortgage-types; page route at / renders the simulator
- /api/mortgage/rates + /api/mortgage/positions (GET+POST) moved to rLending;
  Aave v3 live rate lookup unchanged
- FlowsDoc schema stays in rFlows (mortgagePositions + reinvestmentPositions fields);
  rLending reads/writes via imported ensureFlowsDoc + flowsDocId + FlowsDoc types
- rFlows: export ensureFlowsDoc (renamed from ensureDoc); drop mortgage API routes,
  /mortgage page route, mortgageScripts, seed block, subPageInfos mortgage entry
- folk-flows-app: mortgage fetches now target /rlending/api/mortgage/* via a derived
  lendingBase (keeps the rPool dashboard in rFlows working unchanged)
- Display: rLending (🏦) added to module-display, rstack-app-switcher (Commerce),
  rstack-tab-bar (Funding & Commerce), e2e/fixtures/module-list
- Vite: folk-mortgage-simulator build repointed to modules/rlending/ with aliases
  resolving to modules/rlending/lib/mortgage-{types,engine}
This commit is contained in:
Jeff Emmett 2026-04-18 14:42:36 -04:00
parent 057288209d
commit 7a1ffbe635
13 changed files with 387 additions and 189 deletions

View File

@ -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" },

View File

@ -26,6 +26,7 @@ export const MODULE_META: Record<string, ModuleDisplayMeta> = {
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: "🔮" },

View File

@ -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) {

View File

@ -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<string>(); // 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<FlowsDoc>(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<FlowsDoc>(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<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 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<FlowsDoc>(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<string>();
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 = `
<script type="module" src="/modules/rflows/folk-flows-app.js?v=7"></script>
<script type="module" src="/modules/rflows/folk-flow-river.js?v=4"></script>`;
const mortgageScripts = `
<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-mortgage-simulator.js?v=1"></script>`;
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`;
// 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: `<folk-mortgage-simulator space="${spaceSlug}"></folk-mortgage-simulator>`,
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<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);
@ -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<FlowsDoc>(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' },

147
modules/rlending/landing.ts Normal file
View File

@ -0,0 +1,147 @@
/**
* rLending landing page trust-based distributed mortgages.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline">rLending</span>
<h1 class="rl-heading">Mortgages at the speed of trust.</h1>
<p class="rl-subtitle">Distributed lending, community-funded tranches.</p>
<p class="rl-subtext">
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.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rlending" class="rl-cta-primary" id="ml-primary">Open Simulator</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
<!-- Features -->
<section class="rl-section">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">What rLending Handles</h2>
<div class="rl-grid-4">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#129309;</div>
<h3>Trust-Backed Lending</h3>
<p>Underwriting from your community, not a bureau. Trust scores from rNetwork relationships and rCred reputation replace FICO.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#129530;</div>
<h3>Distributed Tranches</h3>
<p>One mortgage, many lenders. Each lender picks a term (2yr30yr) and rate, funding a $1k$5k slice of principal.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128200;</div>
<h3>Reinvestment Loops</h3>
<p>Repayments auto-roll into new tranches or external DeFi yield (Aave v3, Morpho Blue). Compounds while borrowers pay down principal.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128261;</div>
<h3>Secondary Market</h3>
<p>Lenders can list their tranche for sale at any time. Buyers step in, premium is set by supply and demand.</p>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
<div class="rl-grid-3">
<div class="rl-step">
<span class="rl-step__num">1</span>
<h3>Borrower Posts Terms</h3>
<p>Property value, down payment, term, rate. The simulator splits the principal into configurable tranches and tiers.</p>
</div>
<div class="rl-step">
<span class="rl-step__num">2</span>
<h3>Community Funds the Tranches</h3>
<p>Lenders in the space claim tranches that match their risk tolerance and time horizon. Trust from your network gates access.</p>
</div>
<div class="rl-step">
<span class="rl-step__num">3</span>
<h3>Payments Flow &amp; Compound</h3>
<p>Monthly payments amortize each tranche. Reinvestment rules auto-deploy idle capital to new tranches or Aave/Morpho.</p>
</div>
</div>
</div>
</section>
<!-- Pool Economics -->
<section class="rl-section">
<div class="rl-container">
<div class="rl-grid-2">
<div>
<h2 class="rl-heading">Pool economics, not banks</h2>
<p class="rl-subtext" style="margin-bottom:1.5rem">
Every dollar stays in the community. Interest that would have left as bank profit now recycles as yield for neighbors.
</p>
<ul class="rl-check-list">
<li><strong>Variable terms</strong> &mdash; 2yr, 5yr, 10yr, 15yr, 30yr tiers with tiered rates</li>
<li><strong>Overpayment</strong> &mdash; extra principal, community fund contributions, or split</li>
<li><strong>Pool reinvestment</strong> &mdash; idle capital earns Aave v3 supply APY until redeployed</li>
<li><strong>Simulation modes</strong> &mdash; mycelial, flow, grid, lender, borrower views</li>
</ul>
</div>
<div class="rl-card rl-card--center" style="display:flex;align-items:center;justify-content:center">
<div>
<div class="rl-icon-box" style="margin:0 auto 1rem">&#128200;</div>
<h3>Aave v3 + Morpho Blue</h3>
<p>Live pool reinvestment.<br>APY updates on every block.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Built on Open Source -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rLending.</p>
<div class="rl-grid-4">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#9878;</div>
<h3>ERC-20 &amp; Aave</h3>
<p>Open stablecoin rails. Supply to Aave v3 for idle pool capital, borrow against positions for leverage.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127776;</div>
<h3>rNetwork Trust Graph</h3>
<p>Underwriting draws on your community's trust edges, not an opaque credit score.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128225;</div>
<h3>Automerge CRDT</h3>
<p>Positions and tranches sync live across lenders. No central ledger, no single point of failure.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128293;</div>
<h3>Hono</h3>
<p>Ultrafast API layer. Lightweight and edge-ready.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Model a mortgage your whole block can fund.</h2>
<p class="rl-subtext">Run the simulator and see how your community can replace the bank.</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rlending" class="rl-cta-primary">Open Simulator</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
</section>
<div class="rl-back">
<a href="/">&larr; Back to rSpace</a>
</div>`;
}

208
modules/rlending/mod.ts Normal file
View File

@ -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<MortgagePosition> & { 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<FlowsDoc>(docId, "create mortgage position", (d) => {
d.mortgagePositions[id] = position as any;
});
return c.json(position, 201);
});
// ── Page routes ──
const lendingScripts = `
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
<script type="module" src="/modules/rlending/folk-mortgage-simulator.js?v=1"></script>`;
// 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: `<folk-mortgage-simulator space="${spaceSlug}"></folk-mortgage-simulator>`,
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<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(`[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" },
],
};

View File

@ -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);

View File

@ -52,6 +52,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
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<string, string> = {
rauctions: "Commerce",
rcart: "Commerce",
rpayments: "Commerce",
rlending: "Commerce",
rexchange: "Commerce",
rflows: "Commerce",
rwallet: "Commerce",

View File

@ -45,6 +45,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
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<string, string> = {
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",

View File

@ -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",
},