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:
parent
d642b856a9
commit
600e9080d0
|
|
@ -11,7 +11,7 @@
|
|||
* 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 { TourEngine } from "../../../shared/tour-engine";
|
||||
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||
|
|
@ -41,7 +41,7 @@ interface Transaction {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
type View = "landing" | "detail";
|
||||
type View = "landing" | "detail" | "mortgage";
|
||||
|
||||
interface NodeAnalyticsStats {
|
||||
totalInflow: number;
|
||||
|
|
@ -155,6 +155,19 @@ class FolkFlowsApp extends HTMLElement {
|
|||
private flowManagerOpen = false;
|
||||
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
|
||||
private _tour!: TourEngine;
|
||||
private static readonly TOUR_STEPS = [
|
||||
|
|
@ -187,8 +200,14 @@ class FolkFlowsApp extends HTMLElement {
|
|||
new MutationObserver(() => this._syncTheme())
|
||||
.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
|
||||
// Canvas-first: always open in detail (canvas) view
|
||||
this.view = "detail";
|
||||
// Read view attribute, default to canvas (detail) view
|
||||
const viewAttr = this.getAttribute("view");
|
||||
this.view = viewAttr === "mortgage" ? "mortgage" : "detail";
|
||||
|
||||
if (this.view === "mortgage") {
|
||||
this.loadMortgageData();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isDemo) {
|
||||
// Demo/anon: load from localStorage or demoNodes
|
||||
|
|
@ -586,6 +605,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
}
|
||||
|
||||
private renderView(): string {
|
||||
if (this.view === "mortgage") return this.renderMortgageTab();
|
||||
if (this.view === "detail") return this.renderDetail();
|
||||
return this.renderLanding();
|
||||
}
|
||||
|
|
@ -5119,6 +5139,38 @@ class FolkFlowsApp extends HTMLElement {
|
|||
// Create flow button (landing page, auth-gated)
|
||||
const createBtn = this.shadow.querySelector('[data-action="create-flow"]');
|
||||
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() {
|
||||
|
|
@ -5186,6 +5238,409 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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;">← 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)} · ${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)} → ${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)} · ${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;">×</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)}% · Total lender capital available: ${this.fmtUsd(totalAvailable)} · 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 {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
|
|
|
|||
|
|
@ -107,6 +107,32 @@ export interface FlowNode {
|
|||
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 ─────────────────────────────────
|
||||
|
||||
export type PortDirection = "in" | "out";
|
||||
|
|
|
|||
|
|
@ -18,7 +18,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 } from './lib/types';
|
||||
import type { OutcomeNodeData, MortgagePosition, ReinvestmentPosition } from './lib/types';
|
||||
import { getAvailableProviders, getProvider, getDefaultProvider } from './lib/onramp-registry';
|
||||
import type { OnrampProviderId } from './lib/onramp-provider';
|
||||
import { PimlicoClient } from './lib/pimlico';
|
||||
|
|
@ -53,6 +53,15 @@ function ensureDoc(space: string): FlowsDoc {
|
|||
});
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -560,6 +569,101 @@ 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 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 ────────────────────────────────────────
|
||||
|
||||
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
|
||||
routes.get("/flow/:flowId", (c) => {
|
||||
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`);
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
|
|
@ -755,5 +916,17 @@ export const flowsModule: RSpaceModule = {
|
|||
{ 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." },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import type { DocSchema } from '../../shared/local-first/document';
|
||||
import type { FlowNode } from './lib/types';
|
||||
import type { FlowNode, MortgagePosition, ReinvestmentPosition } from './lib/types';
|
||||
|
||||
// ── Document types ──
|
||||
|
||||
|
|
@ -41,6 +41,8 @@ export interface FlowsDoc {
|
|||
spaceFlows: Record<string, SpaceFlow>;
|
||||
canvasFlows: Record<string, CanvasFlow>;
|
||||
activeFlowId: string;
|
||||
mortgagePositions: Record<string, MortgagePosition>;
|
||||
reinvestmentPositions: Record<string, ReinvestmentPosition>;
|
||||
}
|
||||
|
||||
// ── Schema registration ──
|
||||
|
|
@ -48,23 +50,27 @@ export interface FlowsDoc {
|
|||
export const flowsSchema: DocSchema<FlowsDoc> = {
|
||||
module: 'flows',
|
||||
collection: 'data',
|
||||
version: 2,
|
||||
version: 3,
|
||||
init: (): FlowsDoc => ({
|
||||
meta: {
|
||||
module: 'flows',
|
||||
collection: 'data',
|
||||
version: 2,
|
||||
version: 3,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
spaceFlows: {},
|
||||
canvasFlows: {},
|
||||
activeFlowId: '',
|
||||
mortgagePositions: {},
|
||||
reinvestmentPositions: {},
|
||||
}),
|
||||
migrate: (doc: any, _fromVersion: number) => {
|
||||
if (!doc.canvasFlows) doc.canvasFlows = {};
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue