feat(rflows): add rMortgage sub-tab with trust-backed lending & DeFi reinvestment

Social mortgage lending tracker at /mortgage with pool overview, active
positions table, lender detail vessel visualization, borrower options
panel (monthly-budget-constrained with lender fill bars), live Aave v3
rates on Base, reinvestment tracker, and yield projection calculator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 01:22:53 -07:00
parent d642b856a9
commit 600e9080d0
4 changed files with 669 additions and 9 deletions

View File

@ -11,7 +11,7 @@
* mode "demo" to use hardcoded demo data (no API) * 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 { PORT_DEFS, deriveThresholds } from "../lib/types";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
@ -41,7 +41,7 @@ interface Transaction {
description?: string; description?: string;
} }
type View = "landing" | "detail"; type View = "landing" | "detail" | "mortgage";
interface NodeAnalyticsStats { interface NodeAnalyticsStats {
totalInflow: number; totalInflow: number;
@ -155,6 +155,19 @@ class FolkFlowsApp extends HTMLElement {
private flowManagerOpen = false; private flowManagerOpen = false;
private _lfcUnsub: (() => void) | null = null; 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 // Tour engine
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -187,8 +200,14 @@ class FolkFlowsApp extends HTMLElement {
new MutationObserver(() => this._syncTheme()) new MutationObserver(() => this._syncTheme())
.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// Canvas-first: always open in detail (canvas) view // Read view attribute, default to canvas (detail) view
this.view = "detail"; const viewAttr = this.getAttribute("view");
this.view = viewAttr === "mortgage" ? "mortgage" : "detail";
if (this.view === "mortgage") {
this.loadMortgageData();
return;
}
if (this.isDemo) { if (this.isDemo) {
// Demo/anon: load from localStorage or demoNodes // Demo/anon: load from localStorage or demoNodes
@ -586,6 +605,7 @@ class FolkFlowsApp extends HTMLElement {
} }
private renderView(): string { private renderView(): string {
if (this.view === "mortgage") return this.renderMortgageTab();
if (this.view === "detail") return this.renderDetail(); if (this.view === "detail") return this.renderDetail();
return this.renderLanding(); return this.renderLanding();
} }
@ -5119,6 +5139,38 @@ class FolkFlowsApp extends HTMLElement {
// Create flow button (landing page, auth-gated) // Create flow button (landing page, auth-gated)
const createBtn = this.shadow.querySelector('[data-action="create-flow"]'); const createBtn = this.shadow.querySelector('[data-action="create-flow"]');
createBtn?.addEventListener("click", () => this.handleCreateFlow()); 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() { private cleanupCanvas() {
@ -5186,6 +5238,409 @@ class FolkFlowsApp extends HTMLElement {
this._tour.start(); 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 '<div class="flows-loading">Loading mortgage data...</div>';
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 `
<div class="mortgage-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
<a href="/${this.space === 'demo' ? 'demo/' : ''}rflows" style="color: var(--rs-text-secondary); text-decoration: none; font-size: 14px;">&larr; Back to Flows</a>
<h2 style="margin: 0; font-size: 24px; color: var(--rs-text-primary);">rMortgage</h2>
<span style="background: rgba(16,185,129,0.15); color: #10b981; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;">BETA</span>
</div>
<!-- Pool Summary Cards -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px;">
${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')}
</div>
<!-- Active Mortgages -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid var(--rs-border, #2a2a3e);">
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Active Mortgages</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e); color: var(--rs-text-secondary); text-align: left;">
<th style="padding: 8px 12px;">Borrower</th>
<th style="padding: 8px 12px;">Principal</th>
<th style="padding: 8px 12px;">Rate</th>
<th style="padding: 8px 12px;">Term</th>
<th style="padding: 8px 12px;">Monthly</th>
<th style="padding: 8px 12px;">Trust</th>
<th style="padding: 8px 12px;">Status</th>
</tr>
</thead>
<tbody>
${this.mortgagePositions.map(m => this.renderMortgageRow(m)).join('')}
</tbody>
</table>
</div>
</div>
${selectedLender ? this.renderLenderDetail(selectedLender) : ''}
<!-- Borrower Options -->
${this.renderBorrowerOptions()}
<!-- Reinvestment Tracker -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Reinvestment Positions</h3>
${this.reinvestmentPositions.map(r => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--rs-border, #2a2a3e);">
<div>
<div style="font-weight: 600; color: var(--rs-text-primary);">${this.esc(r.protocol)}</div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">${this.esc(r.chain)} &middot; ${this.esc(r.asset)}</div>
</div>
<div style="text-align: right;">
<div style="color: #10b981; font-weight: 600;">${r.apy.toFixed(2)}% APY</div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">${this.fmtUsd(r.deposited)} &rarr; ${this.fmtUsd(r.currentValue)}</div>
</div>
</div>
`).join('')}
${this.reinvestmentPositions.length === 0 ? '<div style="color: var(--rs-text-secondary); font-size: 14px;">No reinvestment positions yet</div>' : ''}
</div>
<!-- Live Rates -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Live DeFi Rates</h3>
${this.liveRates.map(r => `
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--rs-border, #2a2a3e);">
<div>
<div style="font-weight: 600; color: var(--rs-text-primary);">${this.esc(r.protocol)}</div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">${this.esc(r.chain)} &middot; ${this.esc(r.asset)}</div>
</div>
<div style="text-align: right;">
${r.apy !== null
? `<div style="color: #10b981; font-weight: 600;">${r.apy.toFixed(2)}% APY</div>`
: `<div style="color: var(--rs-text-secondary);">${r.error || 'Unavailable'}</div>`
}
</div>
</div>
`).join('')}
${this.liveRates.length === 0 ? '<div style="color: var(--rs-text-secondary); font-size: 14px;">Fetching rates...</div>' : ''}
</div>
</div>
<!-- Projection Calculator -->
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Yield Projection Calculator</h3>
<div style="display: grid; grid-template-columns: repeat(3, 1fr) auto; gap: 16px; align-items: end;">
<div>
<label style="display: block; font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 4px;">Deposit Amount (USDC)</label>
<input type="number" data-proj="amount" value="${this.projCalcAmount}" style="width: 100%; padding: 8px 12px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 8px; color: var(--rs-text-primary); font-size: 14px;">
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 4px;">APY (%)</label>
<input type="number" data-proj="apy" value="${this.projCalcApy}" step="0.1" style="width: 100%; padding: 8px 12px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 8px; color: var(--rs-text-primary); font-size: 14px;">
</div>
<div>
<label style="display: block; font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 4px;">Duration (months)</label>
<input type="number" data-proj="months" value="${this.projCalcMonths}" style="width: 100%; padding: 8px 12px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 8px; color: var(--rs-text-primary); font-size: 14px;">
</div>
<button data-action="calc-projection" style="padding: 8px 20px; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600;">Calculate</button>
</div>
<div id="projection-result" style="margin-top: 16px; padding: 16px; background: var(--rs-bg, #0f0f1a); border-radius: 8px; display: ${this.projCalcAmount > 0 ? 'block' : 'none'};">
${this.renderProjection()}
</div>
</div>
</div>`;
}
private renderPoolCard(title: string, value: string, subtitle: string, color: string): string {
return `
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e); border-top: 3px solid ${color};">
<div style="font-size: 12px; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">${title}</div>
<div style="font-size: 24px; font-weight: 700; color: var(--rs-text-primary); margin-bottom: 2px;">${value}</div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">${subtitle}</div>
</div>`;
}
private renderMortgageRow(m: MortgagePosition): string {
const statusColors: Record<string, string> = { 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 `
<tr data-mortgage-id="${m.id}" style="border-bottom: 1px solid var(--rs-border, #2a2a3e); cursor: pointer; background: ${isSelected ? 'rgba(59,130,246,0.1)' : 'transparent'};" class="mortgage-row">
<td style="padding: 10px 12px; color: var(--rs-text-primary); font-weight: 500;">${this.esc(m.borrower)}</td>
<td style="padding: 10px 12px; color: var(--rs-text-primary);">${this.fmtUsd(m.principal)}</td>
<td style="padding: 10px 12px; color: var(--rs-text-primary);">${m.interestRate}%</td>
<td style="padding: 10px 12px; color: var(--rs-text-secondary);">${m.termMonths}mo</td>
<td style="padding: 10px 12px; color: var(--rs-text-primary);">${this.fmtUsd(m.monthlyPayment)}</td>
<td style="padding: 10px 12px;"><span style="color: ${trustColor}; font-weight: 600;">${m.trustScore}</span></td>
<td style="padding: 10px 12px;"><span style="background: ${statusColors[m.status] || '#666'}22; color: ${statusColors[m.status] || '#666'}; padding: 2px 8px; border-radius: 8px; font-size: 12px; font-weight: 600;">${m.status}</span></td>
</tr>`;
}
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 `
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid #3b82f6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">Lender Pool: ${this.esc(m.borrower)}</h3>
<button data-action="close-lender" style="background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 18px;">&times;</button>
</div>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 24px;">
<!-- Vessel visualization -->
<div style="display: flex; flex-direction: column; align-items: center;">
<svg viewBox="0 0 120 180" width="120" height="180" style="margin-bottom: 8px;">
<!-- Vessel outline -->
<rect x="10" y="10" width="100" height="160" rx="12" fill="none" stroke="var(--rs-text-secondary)" stroke-width="2" opacity="0.3"/>
<!-- Empty (remaining principal) -->
<rect x="12" y="${12 + (repaidPct + reinvestedPct) * 1.56}" width="96" height="${emptyPct * 1.56}" rx="10" fill="rgba(255,255,255,0.05)"/>
<!-- Reinvested (green) -->
<rect x="12" y="${12 + repaidPct * 1.56}" width="96" height="${reinvestedPct * 1.56}" rx="${reinvestedPct > 0 ? 0 : 10}" fill="rgba(16,185,129,0.4)"/>
<!-- Repaid (blue) -->
<rect x="12" y="12" width="96" height="${repaidPct * 1.56}" rx="10" fill="rgba(59,130,246,0.4)"/>
<!-- Labels -->
${repaidPct > 10 ? `<text x="60" y="${12 + repaidPct * 0.78}" text-anchor="middle" fill="#60a5fa" font-size="11" font-weight="600">${Math.round(repaidPct)}% Repaid</text>` : ''}
${reinvestedPct > 10 ? `<text x="60" y="${12 + repaidPct * 1.56 + reinvestedPct * 0.78}" text-anchor="middle" fill="#10b981" font-size="11" font-weight="600">${Math.round(reinvestedPct)}% Reinvested</text>` : ''}
</svg>
<div style="font-size: 11px; color: var(--rs-text-secondary); text-align: center;">Pool: ${this.fmtUsd(vesselTotal)}</div>
</div>
<!-- Stats breakdown -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Repaid Principal</div>
<div style="font-size: 20px; font-weight: 700; color: #60a5fa;">${this.fmtUsd(principalRepaid)}</div>
</div>
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Reinvested Capital</div>
<div style="font-size: 20px; font-weight: 700; color: #10b981;">${this.fmtUsd(idleCapital)}</div>
</div>
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Loan Interest Earned</div>
<div style="font-size: 20px; font-weight: 700; color: #f59e0b;">${this.fmtUsd(loanEarnings)}</div>
</div>
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Reinvestment Yield</div>
<div style="font-size: 20px; font-weight: 700; color: #10b981;">${this.fmtUsd(reinvestEarnings)}</div>
</div>
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px; grid-column: span 2;">
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Total Earnings</div>
<div style="font-size: 24px; font-weight: 700; color: var(--rs-text-primary);">${this.fmtUsd(loanEarnings + reinvestEarnings)}</div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">${monthsElapsed} months elapsed of ${m.termMonths}mo term</div>
</div>
</div>
</div>
</div>`;
}
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 `
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid var(--rs-border, #2a2a3e);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">Borrower Options</h3>
<div style="display: flex; align-items: center; gap: 12px;">
<label style="font-size: 13px; color: var(--rs-text-secondary);">Max monthly payment:</label>
<div style="display: flex; align-items: center; gap: 4px;">
<span style="color: var(--rs-text-secondary);">$</span>
<input type="number" data-borrower="budget" value="${budget}" step="100" min="100"
style="width: 110px; padding: 6px 10px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 6px; color: var(--rs-text-primary); font-size: 14px; font-weight: 600;">
<span style="color: var(--rs-text-secondary); font-size: 13px;">/mo</span>
</div>
<button data-action="update-borrower" style="padding: 6px 14px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px;">Update</button>
</div>
</div>
<div style="font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 16px;">
Avg pool rate: ${avgRate.toFixed(1)}% &middot; Total lender capital available: ${this.fmtUsd(totalAvailable)} &middot; Lenders fill loans in trust-score order
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
${options.map(o => `
<div style="padding: 14px 16px; background: var(--rs-bg, #0f0f1a); border-radius: 10px; border: 1px solid var(--rs-border, #2a2a3e);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="display: flex; align-items: baseline; gap: 10px;">
<span style="font-size: 18px; font-weight: 700; color: var(--rs-text-primary);">${o.months / 12}yr</span>
<span style="font-size: 13px; color: var(--rs-text-secondary);">${o.months} months @ ${o.rate.toFixed(1)}%</span>
</div>
<div style="text-align: right;">
<span style="font-size: 18px; font-weight: 700; color: var(--rs-text-primary);">${this.fmtUsd(o.principal)}</span>
<span style="font-size: 12px; color: var(--rs-text-secondary); margin-left: 6px;">${this.fmtUsd(o.actualMonthly)}/mo</span>
</div>
</div>
<!-- Lender fill bar -->
<div style="position: relative; height: 28px; background: rgba(255,255,255,0.03); border-radius: 6px; overflow: hidden; margin-bottom: 6px;">
<div style="display: flex; height: 100%;">
${o.fills.map(f => `
<div style="width: ${f.pct}%; background: ${f.color}; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; font-weight: 600; white-space: nowrap; overflow: hidden; min-width: ${f.pct > 8 ? '0' : '0'}px;"
title="${f.name}: ${this.fmtUsd(f.amount)} (${Math.round(f.pct)}%)">
${f.pct > 12 ? f.name : ''}
</div>
`).join('')}
${o.fundedPct < 100 ? `<div style="flex: 1; display: flex; align-items: center; justify-content: center; font-size: 10px; color: var(--rs-text-secondary);">${Math.round(100 - o.fundedPct)}% unfunded</div>` : ''}
</div>
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
${o.fills.map(f => `
<span style="font-size: 11px; color: var(--rs-text-secondary); display: flex; align-items: center; gap: 4px;">
<span style="width: 8px; height: 8px; border-radius: 2px; background: ${f.color}; display: inline-block;"></span>
${this.esc(f.name)} ${this.fmtUsd(f.amount)}
</span>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>`;
}
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 `
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;">
<div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">Initial Deposit</div>
<div style="font-size: 20px; font-weight: 700; color: var(--rs-text-primary);">${this.fmtUsd(this.projCalcAmount)}</div>
</div>
<div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">Projected Value</div>
<div style="font-size: 20px; font-weight: 700; color: #10b981;">${this.fmtUsd(finalValue)}</div>
</div>
<div>
<div style="font-size: 12px; color: var(--rs-text-secondary);">Yield Earned</div>
<div style="font-size: 20px; font-weight: 700; color: #f59e0b;">${this.fmtUsd(earned)}</div>
</div>
</div>`;
}
private fmtUsd(v: number): string {
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
private esc(s: string): string { private esc(s: string): string {
return s return s
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")

View File

@ -107,6 +107,32 @@ export interface FlowNode {
data: FunnelNodeData | OutcomeNodeData | SourceNodeData; 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 ───────────────────────────────── // ─── Port definitions ─────────────────────────────────
export type PortDirection = "in" | "out"; export type PortDirection = "in" | "out";

View File

@ -18,7 +18,7 @@ import { demoNodes } from './lib/presets';
import { OpenfortProvider } from './lib/openfort'; import { OpenfortProvider } from './lib/openfort';
import { boardDocId, createTaskItem } from '../rtasks/schemas'; import { boardDocId, createTaskItem } from '../rtasks/schemas';
import type { BoardDoc } 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 { getAvailableProviders, getProvider, getDefaultProvider } from './lib/onramp-registry';
import type { OnrampProviderId } from './lib/onramp-provider'; import type { OnrampProviderId } from './lib/onramp-provider';
import { PimlicoClient } from './lib/pimlico'; import { PimlicoClient } from './lib/pimlico';
@ -53,6 +53,15 @@ function ensureDoc(space: string): FlowsDoc {
}); });
doc = _syncServer!.getDoc<FlowsDoc>(docId)!; doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
} }
// Migrate v2 → v3: add mortgagePositions and reinvestmentPositions
if (doc.meta.version < 3) {
_syncServer!.changeDoc<FlowsDoc>(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<FlowsDoc>(docId)!;
}
return doc; return doc;
} }
@ -560,6 +569,101 @@ routes.delete("/api/space-flows/:flowId", async (c) => {
return c.json({ ok: true }); 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<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);
});
// ─── Page routes ──────────────────────────────────────── // ─── Page routes ────────────────────────────────────────
const flowsScripts = ` 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: `<folk-flows-app space="${spaceSlug}" view="mortgage"${spaceSlug === "demo" ? ' mode="demo"' : ''}></folk-flows-app>`,
scripts: flowsScripts,
styles: flowsStyles,
}));
});
// Flow detail — specific flow from API // Flow detail — specific flow from API
routes.get("/flow/:flowId", (c) => { routes.get("/flow/:flowId", (c) => {
const spaceSlug = c.req.param("space") || "demo"; 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`); 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}"`);
}
} }
export const flowsModule: RSpaceModule = { 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." }, { 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." },
],
},
], ],
}; };

View File

@ -9,7 +9,7 @@
*/ */
import type { DocSchema } from '../../shared/local-first/document'; import type { DocSchema } from '../../shared/local-first/document';
import type { FlowNode } from './lib/types'; import type { FlowNode, MortgagePosition, ReinvestmentPosition } from './lib/types';
// ── Document types ── // ── Document types ──
@ -41,6 +41,8 @@ export interface FlowsDoc {
spaceFlows: Record<string, SpaceFlow>; spaceFlows: Record<string, SpaceFlow>;
canvasFlows: Record<string, CanvasFlow>; canvasFlows: Record<string, CanvasFlow>;
activeFlowId: string; activeFlowId: string;
mortgagePositions: Record<string, MortgagePosition>;
reinvestmentPositions: Record<string, ReinvestmentPosition>;
} }
// ── Schema registration ── // ── Schema registration ──
@ -48,23 +50,27 @@ export interface FlowsDoc {
export const flowsSchema: DocSchema<FlowsDoc> = { export const flowsSchema: DocSchema<FlowsDoc> = {
module: 'flows', module: 'flows',
collection: 'data', collection: 'data',
version: 2, version: 3,
init: (): FlowsDoc => ({ init: (): FlowsDoc => ({
meta: { meta: {
module: 'flows', module: 'flows',
collection: 'data', collection: 'data',
version: 2, version: 3,
spaceSlug: '', spaceSlug: '',
createdAt: Date.now(), createdAt: Date.now(),
}, },
spaceFlows: {}, spaceFlows: {},
canvasFlows: {}, canvasFlows: {},
activeFlowId: '', activeFlowId: '',
mortgagePositions: {},
reinvestmentPositions: {},
}), }),
migrate: (doc: any, _fromVersion: number) => { migrate: (doc: any, _fromVersion: number) => {
if (!doc.canvasFlows) doc.canvasFlows = {}; if (!doc.canvasFlows) doc.canvasFlows = {};
if (!doc.activeFlowId) doc.activeFlowId = ''; 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; return doc;
}, },
}; };