209 lines
7.8 KiB
TypeScript
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" },
|
|
],
|
|
};
|