rspace-online/modules/rlending/mod.ts

209 lines
7.8 KiB
TypeScript

/**
* 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" },
],
};