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:
parent
057288209d
commit
7a1ffbe635
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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: "🔮" },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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">🤝</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">🧺</div>
|
||||
<h3>Distributed Tranches</h3>
|
||||
<p>One mortgage, many lenders. Each lender picks a term (2yr–30yr) and rate, funding a $1k–$5k slice of principal.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">📈</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">🔅</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 & 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> — 2yr, 5yr, 10yr, 15yr, 30yr tiers with tiered rates</li>
|
||||
<li><strong>Overpayment</strong> — extra principal, community fund contributions, or split</li>
|
||||
<li><strong>Pool reinvestment</strong> — idle capital earns Aave v3 supply APY until redeployed</li>
|
||||
<li><strong>Simulation modes</strong> — 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">📈</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">⚖</div>
|
||||
<h3>ERC-20 & 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">🌠</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">📡</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">🔥</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="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue