feat(ui): Ghibli/Miyazaki reskin + Obsidian docs vault + project audit

UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component
classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe
GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin
of navbar, connect button, dapp page, loan cards, settings modal, and readme.
Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched.

Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]],
callouts, Home MOC, folders Architecture/Operations/Audits) and added a
full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy
key value from the security audit example (rotate it at Schedy).

Also commits the previously-untracked server layer: app/api (cron + tasks routes)
and lib (redis, ssrf-guard, task-store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-06-14 08:13:53 +04:00
parent cf76322008
commit 6ae581ab2e
25 changed files with 4245 additions and 369 deletions

83
app/api/cron/route.ts Normal file
View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { listDueTasks, deleteTask } from '@/lib/task-store';
import { sanitizeHeaders } from '@/lib/ssrf-guard';
const CRON_SECRET = process.env.CRON_SECRET;
export async function GET(request: NextRequest) {
// Protect cron endpoint: require Authorization header with CRON_SECRET
const auth = request.headers.get('authorization') || '';
const expected = CRON_SECRET ? `Bearer ${CRON_SECRET}` : '';
if (!CRON_SECRET || auth !== expected) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const nowSec = Math.floor(Date.now() / 1000);
let tasks: Awaited<ReturnType<typeof listDueTasks>>;
try {
tasks = await listDueTasks(nowSec);
} catch (e: any) {
return NextResponse.json({ error: e?.message || 'Failed to fetch tasks' }, { status: 500 });
}
const results: Array<{ id: string; ok: boolean; error?: string }> = [];
for (const task of tasks) {
let ok = false;
let lastError: string | undefined;
for (let attempt = 0; attempt <= task.retries; attempt++) {
if (attempt > 0) {
await sleep(task.retryInterval);
}
try {
const bodyBytes = encodePayload(task.payload);
const res = await fetch(task.url, {
method: 'POST',
headers: {
...sanitizeHeaders(task.headers),
'content-type': guessContentType(task.payload),
},
body: bodyBytes,
signal: AbortSignal.timeout(15000),
});
if (res.ok) {
ok = true;
break;
}
lastError = `HTTP ${res.status}`;
} catch (e: any) {
lastError = e?.message || 'Network error';
}
}
if (ok) {
try {
await deleteTask(task.id);
} catch {
// best-effort cleanup
}
}
results.push({ id: task.id, ok, error: ok ? undefined : lastError });
}
return NextResponse.json({ executed: results.length, results });
}
function encodePayload(payload: any): string {
if (payload === null || payload === undefined) return '';
if (typeof payload === 'string') return payload;
return JSON.stringify(payload);
}
function guessContentType(payload: any): string {
if (typeof payload === 'string') return 'text/plain';
return 'application/json';
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { deleteTask } from '@/lib/task-store';
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!id || !id.startsWith('task_')) {
return NextResponse.json({ error: 'Invalid task id' }, { status: 400 });
}
try {
await deleteTask(id);
return new NextResponse(null, { status: 204 });
} catch (e: any) {
return NextResponse.json({ error: e?.message || 'Failed to delete task' }, { status: 500 });
}
}

72
app/api/tasks/route.ts Normal file
View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { saveTask, listAllTasks, checkRateLimit } from '@/lib/task-store';
import { isAllowedUrl, sanitizeHeaders } from '@/lib/ssrf-guard';
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? 'unknown';
const allowed = await checkRateLimit(ip, 30, 3600); // 30 tasks/hour per IP
if (!allowed) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
let body: any;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const url = typeof body.url === 'string' ? body.url : '';
const executeAt = typeof body.execute_at === 'number' ? body.execute_at : 0;
const headers = typeof body.headers === 'object' && body.headers !== null ? body.headers : {};
const payload = body.payload ?? null;
const retries = typeof body.retries === 'number' ? Math.max(0, Math.min(body.retries, 10)) : 3;
const retryInterval = typeof body.retry_interval === 'number' ? Math.max(1000, body.retry_interval) : 5000;
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const urlCheck = isAllowedUrl(url);
if (!urlCheck.ok) {
return NextResponse.json({ error: `Blocked URL: ${urlCheck.reason}` }, { status: 400 });
}
const nowSec = Math.floor(Date.now() / 1000);
if (executeAt <= nowSec) {
return NextResponse.json({ error: 'execute_at must be in the future' }, { status: 400 });
}
if (executeAt > nowSec + 86400 * 365 * 2) {
return NextResponse.json({ error: 'execute_at too far in the future (max 2 years)' }, { status: 400 });
}
const id = `task_${crypto.randomUUID()}`;
const task = {
id,
url,
executeAt,
headers: sanitizeHeaders(headers),
payload,
retries,
retryInterval,
createdAt: nowSec,
};
try {
await saveTask(task);
return NextResponse.json(task, { status: 201 });
} catch (e: any) {
return NextResponse.json({ error: e?.message || 'Failed to save task' }, { status: 500 });
}
}
export async function GET() {
try {
const tasks = await listAllTasks();
return NextResponse.json(tasks);
} catch (e: any) {
return NextResponse.json({ error: e?.message || 'Failed to list tasks' }, { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useAccount, useChainId, useReadContract, useReadContracts, useSwitchChain, usePublicClient, useWriteContract, useSendTransaction } from 'wagmi';
import { useAccount, useChainId, useReadContract, useReadContracts, useSwitchChain, usePublicClient, useWriteContract } from 'wagmi';
import { base, arbitrum, mainnet } from 'wagmi/chains';
import { Abi, parseUnits } from 'viem';
import debtAbi from '@/ABIs/mortgagefiusdccbbtcupgraded.json';
@@ -76,7 +76,7 @@ export default function DappPage() {
const [payInputs, setPayInputs] = useState<Record<string, string>>({});
const publicClient = usePublicClient({ chainId: selectedChainId });
const { writeContractAsync, isPending: writePending } = useWriteContract();
const { sendTransactionAsync, isPending: txPending } = useSendTransaction();
const [compoundPending, setCompoundPending] = useState(false);
// NFTCache settings (from localStorage; defaults to env or '/nftcache')
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
@@ -101,22 +101,13 @@ export default function DappPage() {
useEffect(() => {
if (searchParams?.get('settings') === '1') setSettingsOpen(true);
}, [searchParams]);
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, '');
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim();
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
const [notifSettings, setNotifSettings] = useLocalStorage<NotificationSettings>('notif:settings', {
provider: '',
scheduler: 'schedy',
ntfyServer: ENV_NTFY,
ntfyTopic: '',
gotifyServer: '',
gotifyToken: '',
snsRegion: '',
snsTopicArn: '',
snsAccessKeyId: '',
snsSecretAccessKey: '',
schedyBaseUrl: ENV_SCHEDY,
schedyApiKey: ENV_SCHEDY_API_KEY,
email: '',
backupEmail: '',
backupDelayDays: 1,
@@ -140,27 +131,25 @@ export default function DappPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// One-time migration: legacy scheduler providers -> schedy; remove legacy fields
// One-time migration: remove legacy and removed fields
useEffect(() => {
const s: any = notifSettings as any;
if (!s) return;
let changed = false;
const next: any = { ...notifSettings };
// Normalize scheduler
if (!s.scheduler || s.scheduler !== 'schedy') {
next.scheduler = 'schedy';
changed = true;
}
// Remove legacy keys if present
const legacyKeys = ['cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId'];
for (const k of legacyKeys) {
const removedKeys = [
'scheduler', 'schedyBaseUrl', 'schedyApiKey',
'snsRegion', 'snsTopicArn', 'snsAccessKeyId', 'snsSecretAccessKey',
'cronhostApiKey', 'kronosBaseUrl', 'schedifyBaseUrl', 'schedifyApiKey', 'schedifyWebhookId'
];
for (const k of removedKeys) {
if (k in next) {
delete next[k];
changed = true;
}
}
if (changed) {
console.log('[Settings] Migrating legacy scheduler -> schedy and pruning legacy fields');
console.log('[Settings] Migrating and pruning legacy/removed fields');
setNotifSettings(next as NotificationSettings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -773,24 +762,16 @@ export default function DappPage() {
headers: { 'Content-Type': 'application/json' },
};
}
if (provider === 'sns') {
// Note: Direct SNS publish requires AWS SigV4; recommend server-side relay. Here we simply target a placeholder you control.
// User should set up a relay endpoint that publishes to SNS.
const relay = '/api/sns-relay';
return {
url: relay,
method: 'POST' as const,
body: { topicArn: notifSettings.snsTopicArn, region: notifSettings.snsRegion, message, email: notifSettings.email },
headers: { 'Content-Type': 'application/json' },
};
}
throw new Error('Unsupported provider');
};
// Single schedule at configured lead time (or 1 day by default)
const scheduleNotification = async (row: { tokenId: bigint; secondsTillLiq?: bigint }) => {
const seconds = Number(row.secondsTillLiq ?? 0);
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Invalid liquidation timer');
const MAX_SECONDS = 86400 * 365 * 5; // 5 years max
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_SECONDS) {
throw new Error('Invalid liquidation timer');
}
const nowSec = Math.floor(Date.now() / 1000);
const leadSecsCfg = Math.max(0, Math.floor(Number(notifSettings.daysBefore ?? 0) * 86400));
const offset = leadSecsCfg > 0 ? leadSecsCfg : 86400; // default to 1 day
@@ -842,7 +823,7 @@ export default function DappPage() {
if (runAt1 <= nowSec) throw new Error('Computed run time is in the past');
// Primary job (to main email)
const req1 = buildNotificationRequest(row, msg);
const res1 = await scheduleJob(notifSettings, { runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers });
const res1 = await scheduleJob({ runAtEpoch: runAt1, method: req1.method, url: req1.url, body: req1.body, headers: req1.headers });
// Optional backup job
const jobs: Array<{ id: string; at: number; label: 'lead' | 'half' | 'last' }> = [
@@ -861,7 +842,7 @@ export default function DappPage() {
const msgBackupCore = `Your position ${row.tokenId.toString()} is approaching liquidation in ~${humanLeftBackup}. ${payClause} Collateral at risk: ${collateralStr ?? 'unknown'} ${collateralSym}. Pay link: ${positionUrl}. Visit ${dappUrl} or https://markets.mortgagefi.app/dashboard.`;
const backupMsg = `${prefix}\n\n${msgBackupCore}`;
const req2 = buildNotificationRequest(row, backupMsg, { email: bEmail });
const res2 = await scheduleJob(notifSettings, { runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers });
const res2 = await scheduleJob({ runAtEpoch: runAt2, method: req2.method, url: req2.url, body: req2.body, headers: req2.headers });
jobs.push({ id: res2.jobId, at: runAt2, label: 'last' });
}
}
@@ -873,12 +854,12 @@ export default function DappPage() {
// Cancel new multi-jobs first
if (p.jobs && p.jobs.length) {
for (const j of p.jobs) {
try { await cancelScheduledJob(notifSettings, j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); }
try { await cancelScheduledJob(j.id); } catch (e) { console.warn('[scheduler] cancel failed', e); }
}
}
// Back-compat: single jobId
if (p.jobId) {
try { await cancelScheduledJob(notifSettings, p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); }
try { await cancelScheduledJob(p.jobId); } catch (e) { console.warn('[scheduler] cancel failed', e); }
}
};
@@ -1032,37 +1013,55 @@ export default function DappPage() {
};
// Compound action: raw tx to debt token contract with provided data
// Only allowed against known preset addresses to prevent arbitrary contract calls
const handleCompound = async () => {
try {
if (!debtAddress) return;
await sendTransactionAsync({
to: debtAddress as `0x${string}`,
data: '0x4e71d92d',
const isKnownPreset = Object.values(PRESETS).some(
(list) => list?.some((p) => p.debt.toLowerCase() === debtAddress.toLowerCase())
);
if (!isKnownPreset) {
console.warn('[Compound] Rejected: debt address is not a known preset');
return;
}
setCompoundPending(true);
await writeContractAsync({
abi: debtAbi as Abi,
address: debtAddress as `0x${string}`,
functionName: 'compound',
chainId: selectedChainId,
});
} catch (e) {
console.warn('[Compound] Failed', e);
} finally {
setCompoundPending(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-semibold text-gray-100">MortgageFi DApp</h1>
<p className="text-sm text-gray-100">Connect wallet, detect your NFTs, and fetch debt details.</p>
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-8 space-y-6">
<header className="space-y-1">
<p className="gh-eyebrow text-sm">🌾 your quiet little vault</p>
<h1 className="font-display text-3xl sm:text-4xl font-semibold text-forest-deep">MortgageFi DApp</h1>
<p className="text-sm text-ink-soft">Connect your wallet, find your NFTs, and tend to your debt gently.</p>
</header>
{!isConnected && (
<div className="rounded border p-4 bg-amber-900/40 border-amber-700 text-amber-200">Please connect your wallet using the Connect Wallet button in the navbar.</div>
<div className="gh-note gh-note-warn flex items-center gap-2">
<span aria-hidden>🪧</span>
Please connect your wallet using the Connect Wallet button in the navbar.
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
<div className="font-medium text-gray-100">Inputs</div>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Network</span>
<div className="gh-card gh-card-hover p-5 space-y-3">
<div className="gh-eyebrow text-lg flex items-center gap-2"><span aria-hidden>🌱</span>Inputs</div>
<label className="gh-label">
<span className="text-earth">Network</span>
<select
value={selectedChainId}
onChange={(e) => setSelectedChainId(Number(e.target.value))}
className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700"
className="gh-input"
>
<option value={base.id}>Base</option>
<option value={arbitrum.id}>Arbitrum</option>
@@ -1070,10 +1069,10 @@ export default function DappPage() {
</label>
{/* Preset selector for pair addresses */}
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Preset</span>
<label className="gh-label">
<span className="text-earth">Preset</span>
<select
className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700"
className="gh-input"
value={presetKey}
onChange={(e) => {
const key = e.target.value;
@@ -1091,63 +1090,63 @@ export default function DappPage() {
))}
</select>
</label>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">ERC-721 Address</span>
<input value={nftAddress} onChange={(e) => setNftAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.nft} className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<label className="gh-label">
<span className="text-earth">ERC-721 Address</span>
<input value={nftAddress} onChange={(e) => setNftAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.nft} className="gh-input" />
</label>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Debt Contract</span>
<input value={debtAddress} onChange={(e) => setDebtAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.debt} className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<label className="gh-label">
<span className="text-earth">Debt Contract</span>
<input value={debtAddress} onChange={(e) => setDebtAddress(e.target.value)} placeholder={DEFAULTS[selectedChainId as keyof typeof DEFAULTS]?.debt} className="gh-input" />
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => loadChainDefaults(selectedChainId)}
className="px-3 py-1.5 bg-gray-700 text-gray-100 rounded hover:bg-gray-600"
className="gh-btn gh-btn-ghost"
>
Load defaults for selected network
</button>
</div>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Manual Wallet (optional)</span>
<input value={manualWallet} onChange={(e) => setManualWallet(e.target.value)} placeholder="0xYourWallet" className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<span className="text-xs text-gray-400">If set, scans and reads will use this address instead of the connected one.</span>
<label className="gh-label">
<span className="text-earth">Manual Wallet (optional)</span>
<input value={manualWallet} onChange={(e) => setManualWallet(e.target.value)} placeholder="0xYourWallet" className="gh-input" />
<span className="text-xs text-earth">If set, scans and reads will use this address instead of the connected one.</span>
</label>
<label className="block text-sm text-gray-200">
<span className="text-gray-300">Manual Token ID (optional)</span>
<input value={manualTokenId} onChange={(e) => setManualTokenId(e.target.value)} placeholder="e.g., 103" className="mt-1 w-full border rounded px-2 py-1 bg-gray-900 text-gray-100 border-gray-700 placeholder-gray-400" />
<label className="gh-label">
<span className="text-earth">Manual Token ID (optional)</span>
<input value={manualTokenId} onChange={(e) => setManualTokenId(e.target.value)} placeholder="e.g., 103" className="gh-input" />
</label>
<div className="flex items-center gap-2">
<button onClick={scanMore} disabled={!effectiveWallet || scanBusy || !nftAddress || scanComplete} className="px-3 py-1.5 bg-indigo-600 text-white rounded disabled:opacity-50">
<button onClick={scanMore} disabled={!effectiveWallet || scanBusy || !nftAddress || scanComplete} className="gh-btn gh-btn-primary">
{scanBusy ? 'Scanning…' : scanComplete ? 'Scan Complete' : 'Scan 12 more (ownerOf)'}
</button>
<button onClick={resetScan} disabled={!effectiveWallet || scanBusy || !nftAddress} className="px-3 py-1.5 bg-gray-700 text-gray-100 rounded hover:bg-gray-600">
<button onClick={resetScan} disabled={!effectiveWallet || scanBusy || !nftAddress} className="gh-btn gh-btn-ghost">
Reset
</button>
<button onClick={deleteLocalCache} disabled={scanBusy || !nftAddress} className="px-3 py-1.5 bg-gray-700 text-gray-100 rounded hover:bg-gray-600">
<button onClick={deleteLocalCache} disabled={scanBusy || !nftAddress} className="gh-btn gh-btn-ghost">
Delete Local Cache
</button>
</div>
{scanComplete && (
<div className="text-xs text-green-200 bg-green-900/30 border border-green-700 rounded p-2 mt-2">
<div className="mt-2 rounded-xl border border-forest/40 bg-forest/10 p-2 text-xs text-forest-deep">
Owner scan completed (gap limit reached). Reset or delete cache to rescan.
</div>
)}
<div className="text-xs text-gray-300">
<div className="text-xs text-ink-soft">
Cached IDs: {detectedTokenIds.length ? detectedTokenIds.map((id) => id.toString()).join(', ') : '—'}
</div>
</div>
<div className="rounded border p-4 bg-gray-800 border-gray-700">
<div className="font-medium text-gray-100">About</div>
<p className="text-sm text-gray-300 mt-1">Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.</p>
<p className="text-xs text-gray-400 mt-2">{selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Selected'} network is required. The app will attempt to detect your token IDs via on-chain queries when supported.</p>
<div className="gh-card gh-card-hover p-5">
<div className="gh-eyebrow text-lg flex items-center gap-2"><span aria-hidden>🍃</span>About</div>
<p className="text-sm text-ink-soft mt-1">Enter your NFT contract and token ID(s), then fetch debt details from the mortgage contract.</p>
<p className="text-xs text-earth mt-2">{selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Selected'} network is required. The app will attempt to detect your token IDs via on-chain queries when supported.</p>
</div>
<div className="rounded border p-4 bg-gray-800 border-gray-700 space-y-3">
<div className="gh-card gh-card-hover p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="font-medium text-gray-100">Scheduled notifications</div>
<div className="gh-eyebrow text-lg flex items-center gap-2"><span aria-hidden>🔔</span>Scheduled notifications</div>
<button
className="text-xs px-2 py-1 border border-red-700 text-red-300 hover:bg-red-900/40 rounded"
className="gh-btn gh-btn-danger text-xs"
onClick={async () => {
// Purge remotely from Schedy by ntfy topic URL
try { await purgeNtfyTopicSchedules(notifSettings); } catch (e) { console.warn('[schedy] purge failed', e); }
@@ -1163,20 +1162,20 @@ export default function DappPage() {
</button>
</div>
{scheduledList.length === 0 ? (
<div className="text-sm text-gray-400">No schedules yet.</div>
<div className="text-sm text-ink-soft">No schedules yet.</div>
) : (
<ul className="divide-y divide-gray-700">
<ul className="divide-y divide-line">
{scheduledList.map((it) => (
<li key={it.key} className="py-2 flex items-center justify-between text-sm">
<div className="space-y-0.5">
<div className="text-gray-200">Token #{it.tokenId}</div>
<div className="text-gray-400">
<div className="text-ink font-medium">Token #{it.tokenId}</div>
<div className="text-ink-soft">
{it.scheduledAt ? `Runs at ${new Date(it.scheduledAt * 1000).toLocaleString()}` : 'Scheduled'}
{it.jobId ? ` · Job ${it.jobId}` : ''}
</div>
</div>
<button
className="px-2 py-1 border border-gray-600 hover:bg-gray-700 rounded text-gray-200"
className="gh-btn gh-btn-ghost text-xs"
onClick={() => deleteSchedule(it.key)}
>
Delete
@@ -1188,8 +1187,8 @@ export default function DappPage() {
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-gray-100">
<button onClick={() => refetch()} disabled={!tokenIds.length} className="px-4 py-2 bg-indigo-600 text-white rounded disabled:opacity-50">Fetch Debt Data</button>
<div className="gh-panel p-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-ink-soft">
<button onClick={() => refetch()} disabled={!tokenIds.length} className="gh-btn gh-btn-primary">🌾 Fetch Debt Data</button>
<span className="text-sm">Chain: {selectedChainId === base.id ? 'Base' : selectedChainId === arbitrum.id ? 'Arbitrum' : 'Other'}</span>
<span className="text-sm">{String(coinSymbol ?? '')} Balance: {fmt(contractCoinBalance as bigint | undefined, Number(coinDecimals ?? 8), 8)} {String(coinSymbol ?? '')}</span>
<span className="text-sm">Stable Balance: {fmt(stableBalance as bigint | undefined, Number(stableDecimals ?? 6), 6)} {String(stableSymbol ?? '')}</span>
@@ -1202,25 +1201,25 @@ export default function DappPage() {
const oneToken = BigInt(10) ** BigInt(dec);
const hasOnePlus = bal !== undefined && bal > oneToken;
const manualSet = Boolean(manualWallet?.trim());
const canCompound = !!address && !manualSet && !!debtAddress && hasOnePlus && !txPending;
const canCompound = !!address && !manualSet && !!debtAddress && hasOnePlus && !compoundPending;
return (
<button
className="px-2 py-1 bg-emerald-600 text-white rounded disabled:opacity-50 text-xs"
className="gh-btn gh-btn-sun text-xs"
disabled={!canCompound}
onClick={handleCompound}
title={manualSet ? 'Compound disabled when Manual Wallet is set' : (!hasOnePlus ? 'Requires > 1 token balance' : undefined)}
>
{txPending ? 'Compounding…' : 'Compound'}
{compoundPending ? 'Compounding…' : 'Compound'}
</button>
);
})()}
</span>
</div>
{debtError && <div className="rounded border border-red-300 bg-red-50 p-3 text-sm text-red-800">{String(debtError?.message || debtError)}</div>}
{debtError && <div className="gh-note gh-note-warn">{String(debtError?.message || debtError)}</div>}
<div className="space-y-3">
{debtLoading && <div>Loading...</div>}
<div className="space-y-4">
{debtLoading && <div className="text-ink-soft animate-breathe">🌙 Loading</div>}
{!debtLoading && parsed.map((row) => {
const stableDec = Number(stableDecimals ?? 6);
const coinDec = Number(coinDecimals ?? 8);
@@ -1247,54 +1246,54 @@ export default function DappPage() {
: undefined;
const isClosed = row.coinSize !== undefined && row.coinSize === BigInt(0);
return (
<div key={row.tokenId.toString()} className={`rounded border p-4 shadow-sm ${isClosed ? 'bg-gray-50 border-gray-200' : 'bg-white'}`}>
<div key={row.tokenId.toString()} className={`gh-card gh-card-hover p-5 border-l-4 ${isClosed ? 'border-l-earth/40 opacity-80' : 'border-l-forest'}`}>
<div className="flex items-center gap-2">
<div className="font-semibold text-gray-900">Loan Details for {String(stableSymbol ?? 'USDC')}-{String(coinSymbol ?? 'cbBTC')} - ID: {row.tokenId.toString()}</div>
<div className="font-display text-lg font-semibold text-forest-deep">🏡 {String(stableSymbol ?? 'USDC')}-{String(coinSymbol ?? 'cbBTC')} · #{row.tokenId.toString()}</div>
{isClosed && (
<span className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 border border-yellow-200">
<span className="gh-badge gh-badge-warn">
Closed/Defaulted
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm mt-2">
<div className="text-gray-800">Loan Collateral</div>
<div className={`text-gray-900 ${isClosed ? 'line-through' : ''}`}>{fmt(row.coinSize, coinDec, 8)} {String(coinSymbol ?? '')}</div>
<div className="text-earth">Loan Collateral</div>
<div className={`text-ink font-medium ${isClosed ? 'line-through' : ''}`}>{fmt(row.coinSize, coinDec, 8)} {String(coinSymbol ?? '')}</div>
<div className="text-gray-800">Interest Rate</div>
<div className="text-gray-900">{aprDisplay}</div>
<div className="text-earth">Interest Rate</div>
<div className="text-ink font-medium">{aprDisplay}</div>
<div className="text-gray-800">Loan Dollar Value</div>
<div className="text-gray-900">{fmt(row.baseSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Loan Dollar Value</div>
<div className="text-ink font-medium">{fmt(row.baseSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Cost to close Loan</div>
<div className="text-gray-900">{fmt(costToClose2pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Cost to close Loan</div>
<div className="text-ink font-medium">{fmt(costToClose2pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Cost to Close</div>
<div className="text-gray-900">{fmt(totalCostToClose102pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Total Cost to Close</div>
<div className="text-ink font-medium">{fmt(totalCostToClose102pct, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Interest to pay over Length of Loan</div>
<div className="text-gray-900">{fmt(row.feeSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Total Interest to pay over Length of Loan</div>
<div className="text-ink font-medium">{fmt(row.feeSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Total Repaid</div>
<div className="text-gray-900">{fmt(row.amountPaid, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Total Repaid</div>
<div className="text-ink font-medium">{fmt(row.amountPaid, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Debt Remaining</div>
<div className="text-gray-900">{fmt(row.debtAtThisSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Debt Remaining</div>
<div className="text-ink font-medium">{fmt(row.debtAtThisSize, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Amount to reset timer</div>
<div className="text-gray-900">{fmt(row.currentPaymentPending, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Amount to reset timer</div>
<div className="text-ink font-medium">{fmt(row.currentPaymentPending, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Monthly Payment Size</div>
<div className="text-gray-900">{fmt(monthlyPaymentFixed, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-earth">Monthly Payment Size</div>
<div className="text-ink font-medium">{fmt(monthlyPaymentFixed, stableDec, 6)} {String(stableSymbol ?? '')}</div>
<div className="text-gray-800">Loan Term</div>
<div className="text-gray-900">{loanTermDisplay}</div>
<div className="text-earth">Loan Term</div>
<div className="text-ink font-medium">{loanTermDisplay}</div>
<div className="text-gray-800">Start Date</div>
<div className="text-gray-900">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
<div className="text-earth">Start Date</div>
<div className="text-ink font-medium">{row.startDate ? new Date(Number(row.startDate) * 1000).toLocaleDateString() : '-'}</div>
<div className="text-gray-800">Seconds Till Liquidation</div>
<div className="text-gray-900 flex items-center gap-3">
<div className="text-earth">Seconds Till Liquidation</div>
<div className="text-ink font-medium flex items-center gap-3">
<span>{fmtDuration(row.secondsTillLiq)}</span>
{!isClosed && (
(() => {
@@ -1308,15 +1307,9 @@ export default function DappPage() {
if (!notifSettings.provider) return false;
if (notifSettings.provider === 'ntfy') return Boolean(notifSettings.ntfyServer && notifSettings.ntfyTopic);
if (notifSettings.provider === 'gotify') return Boolean(notifSettings.gotifyServer && notifSettings.gotifyToken);
if (notifSettings.provider === 'sns') return Boolean(notifSettings.snsRegion && notifSettings.snsTopicArn && notifSettings.snsAccessKeyId && notifSettings.snsSecretAccessKey);
return false;
})();
const haveScheduler = (() => {
const s = notifSettings.scheduler || 'schedy';
if (s !== 'schedy') return false;
return Boolean(notifSettings.schedyBaseUrl && notifSettings.schedyApiKey);
})();
const haveSettings = Boolean(haveProvider && haveScheduler && notifSettings.email);
const haveSettings = Boolean(haveProvider && notifSettings.email);
if (next && !haveSettings) { setSettingsOpen(true); return; }
if (!next) {
// disable and cancel
@@ -1335,7 +1328,7 @@ export default function DappPage() {
}
};
return (
<label className="inline-flex items-center gap-1 text-xs text-gray-700">
<label className="inline-flex items-center gap-1 text-xs text-earth">
<input type="checkbox" className="h-4 w-4" disabled={disabled} checked={checked} onChange={(e) => onToggle(e.target.checked)} />
Enable alert
</label>
@@ -1347,8 +1340,8 @@ export default function DappPage() {
{/* Pay controls (hidden for Closed/Defaulted loans) */}
{!isClosed && (
<div className="mt-3 border-t pt-3">
<div className="text-sm font-medium text-gray-900 mb-2">Make a Payment</div>
<div className="mt-4 border-t border-line pt-4">
<div className="gh-eyebrow text-sm mb-2 flex items-center gap-1"><span aria-hidden>💧</span>Make a Payment</div>
<div className="flex flex-wrap items-center gap-2">
{(() => {
const key = row.tokenId.toString();
@@ -1365,13 +1358,13 @@ export default function DappPage() {
type="text"
inputMode="decimal"
placeholder={`Amount in ${String(stableSymbol ?? '')}`}
className="border rounded px-2 py-1 text-sm w-40 text-gray-900 placeholder-gray-500"
className="rounded-xl border border-line bg-cloud px-3 py-2 text-sm w-40 text-ink placeholder-earth/50 focus:outline-none focus:border-sky-deep"
value={inputVal}
onChange={(e) => setPaymentAmount(row.tokenId, e.target.value)}
/>
{!hasAllowance ? (
<button
className="px-3 py-1.5 bg-gray-200 text-gray-900 rounded"
className="gh-btn gh-btn-ghost"
onClick={() => handleApprove(row.tokenId)}
disabled={disableAll || amount === null || amount === BigInt(0)}
>
@@ -1379,7 +1372,7 @@ export default function DappPage() {
</button>
) : null}
<button
className="px-3 py-1.5 bg-indigo-600 text-white rounded disabled:opacity-50"
className="gh-btn gh-btn-primary"
onClick={() => handlePay(row.tokenId)}
disabled={disableAll || amount === null || amount === BigInt(0) || !hasAllowance || !hasBalance}
title={!hasAllowance ? 'Approve the stablecoin first' : (!hasBalance ? 'Insufficient balance' : undefined)}
@@ -1387,7 +1380,7 @@ export default function DappPage() {
Pay
</button>
{!hasBalance && amount !== null && (
<span className="text-xs text-red-600">Insufficient balance</span>
<span className="text-xs text-danger">Insufficient balance</span>
)}
</>
);

View File

@@ -1,26 +1,275 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
/* =====================================================================
MortgageFi — "Studio Ghibli" daylight theme
A warm, painterly design system: meadow greens, sky blues, parchment
creams, sunset apricot. Soft rounded shapes, gentle motion, no harsh
edges. Inspired by Miyazaki landscapes.
===================================================================== */
@theme {
/* Palette ---------------------------------------------------------- */
--color-sky: #a9d8ef; /* clear daylight sky */
--color-sky-deep: #6fb2d6; /* deeper sky / water */
--color-cloud: #fbf6ea; /* warm parchment / cloud white */
--color-cloud-soft: #f3e8cf; /* aged paper */
--color-forest: #6f9a5a; /* Totoro meadow green */
--color-forest-deep: #466a3a; /* deep forest */
--color-moss: #9cb87f; /* soft moss */
--color-sunset: #e89a5e; /* warm apricot sun */
--color-sunset-soft: #f4c89a; /* hazy sunset glow */
--color-earth: #8a6d52; /* warm bark brown */
--color-ink: #3b352a; /* warm charcoal text */
--color-ink-soft: #6b6051; /* muted text */
--color-petal: #e7a39b; /* soft sakura pink */
--color-sun: #f4d27a; /* sunflower yellow */
--color-line: #e3d4b3; /* hairline on parchment */
--color-line-soft: #efe6cf; /* faint divider */
--color-danger: #c2622f; /* terracotta warning */
--color-danger-soft: #f6e3d2;
/* Typography ------------------------------------------------------- */
--font-sans: var(--font-zen), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-klee), var(--font-zen), serif;
/* Soft shadows ----------------------------------------------------- */
--shadow-soft: 0 10px 30px -12px rgba(58, 51, 38, 0.35);
--shadow-float: 0 18px 40px -16px rgba(58, 51, 38, 0.45);
/* Named animations (usable as `animate-drift`, etc.) ---------------- */
--animate-drift: drift 60s linear infinite;
--animate-sway: sway 7s ease-in-out infinite;
--animate-rise: rise 14s ease-in-out infinite;
--animate-breathe: breathe 6s ease-in-out infinite;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
@keyframes drift {
from { transform: translateX(-12vw); }
to { transform: translateX(112vw); }
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
@keyframes sway {
0%, 100% { transform: translateY(0) rotate(-1.5deg); }
50% { transform: translateY(-8px) rotate(1.5deg); }
}
/* soot-sprite float: rise upward while swaying side to side, then fade */
@keyframes rise {
0% { transform: translate(0, 0) scale(0.9); opacity: 0; }
10% { opacity: 0.7; }
50% { transform: translate(14px, -42vh) scale(1); opacity: 0.55; }
90% { opacity: 0.4; }
100% { transform: translate(-10px, -82vh) scale(0.85); opacity: 0; }
}
@keyframes breathe {
0%, 100% { transform: scale(1); opacity: 0.9; }
50% { transform: scale(1.04); opacity: 1; }
}
/* ---------------------------------------------------------------------
Base
------------------------------------------------------------------- */
html {
color-scheme: light;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans);
color: var(--color-ink);
/* layered daylight sky: warm horizon glow rising into clear blue */
background:
radial-gradient(120% 80% at 50% 118%, var(--color-sunset-soft) 0%, transparent 42%),
linear-gradient(180deg, #bfe6f5 0%, #cfeaf2 38%, #e8f1e0 72%, #f3ecd6 100%);
background-attachment: fixed;
min-height: 100vh;
}
/* faint watercolor-paper grain over everything */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: 0.05;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
::selection {
background: var(--color-sun);
color: var(--color-ink);
}
/* gentle, rounded scrollbar to match the soft theme */
* {
scrollbar-color: var(--color-moss) transparent;
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb {
background: var(--color-moss);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
/* ---------------------------------------------------------------------
Component layer — reusable Ghibli primitives
------------------------------------------------------------------- */
@layer components {
/* Parchment card: the workhorse surface */
.gh-card {
border-radius: 1.5rem;
border: 1px solid var(--color-line);
background: color-mix(in srgb, var(--color-cloud) 88%, transparent);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(6px);
}
.gh-card-hover {
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.gh-card-hover:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-float);
}
/* A softer inner panel (for nested groups, details) */
.gh-panel {
border-radius: 1rem;
border: 1px solid var(--color-line-soft);
background: color-mix(in srgb, var(--color-cloud-soft) 70%, transparent);
}
/* Section heading eyebrow */
.gh-eyebrow {
font-family: var(--font-display);
color: var(--color-forest-deep);
letter-spacing: 0.02em;
}
/* Buttons --------------------------------------------------------- */
.gh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 9999px;
padding: 0.5rem 1.1rem;
font-weight: 600;
line-height: 1.1;
transition: transform 0.15s ease, box-shadow 0.2s ease, background-color 0.2s ease, filter 0.2s ease;
cursor: pointer;
}
.gh-btn:active { transform: translateY(1px) scale(0.99); }
.gh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.gh-btn-primary {
background: var(--color-forest);
color: var(--color-cloud);
box-shadow: 0 6px 16px -8px rgba(70, 106, 58, 0.9);
}
.gh-btn-primary:not(:disabled):hover { background: var(--color-forest-deep); }
.gh-btn-sun {
background: var(--color-sunset);
color: #fff;
box-shadow: 0 6px 16px -8px rgba(232, 154, 94, 0.9);
}
.gh-btn-sun:not(:disabled):hover { filter: brightness(1.05); }
.gh-btn-sky {
background: var(--color-sky-deep);
color: #fff;
box-shadow: 0 6px 16px -8px rgba(111, 178, 214, 0.9);
}
.gh-btn-sky:not(:disabled):hover { filter: brightness(1.05); }
.gh-btn-ghost {
background: color-mix(in srgb, var(--color-cloud) 85%, transparent);
color: var(--color-ink);
border: 1px solid var(--color-line);
}
.gh-btn-ghost:not(:disabled):hover { background: color-mix(in srgb, var(--color-sun) 28%, var(--color-cloud)); }
.gh-btn-danger {
background: transparent;
color: var(--color-danger);
border: 1px solid color-mix(in srgb, var(--color-danger) 45%, transparent);
}
.gh-btn-danger:not(:disabled):hover { background: var(--color-danger-soft); }
/* Form controls --------------------------------------------------- */
.gh-input,
.gh-select {
width: 100%;
margin-top: 0.25rem;
border-radius: 0.75rem;
border: 1px solid var(--color-line);
background: color-mix(in srgb, var(--color-cloud) 92%, #fff);
padding: 0.5rem 0.75rem;
color: var(--color-ink);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.gh-input::placeholder { color: color-mix(in srgb, var(--color-earth) 55%, transparent); }
.gh-input:focus,
.gh-select:focus {
outline: none;
border-color: var(--color-sky-deep);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-sky) 55%, transparent);
}
.gh-label { display: block; font-size: 0.875rem; color: var(--color-ink-soft); }
/* Pills / badges -------------------------------------------------- */
.gh-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 9999px;
padding: 0.1rem 0.6rem;
font-size: 0.72rem;
font-weight: 600;
}
.gh-badge-warn {
background: var(--color-danger-soft);
color: var(--color-danger);
border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent);
}
.gh-badge-ok {
background: color-mix(in srgb, var(--color-forest) 18%, var(--color-cloud));
color: var(--color-forest-deep);
border: 1px solid color-mix(in srgb, var(--color-forest) 35%, transparent);
}
/* Notes / callouts ------------------------------------------------ */
.gh-note {
border-radius: 0.9rem;
padding: 0.75rem 1rem;
font-size: 0.85rem;
border: 1px solid var(--color-line);
background: color-mix(in srgb, var(--color-sun) 16%, var(--color-cloud));
color: var(--color-ink);
}
.gh-note-warn {
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
background: var(--color-danger-soft);
color: var(--color-danger);
}
/* Decorative soot sprite used by the background */
.gh-soot {
position: absolute;
bottom: 8vh;
border-radius: 9999px;
background: radial-gradient(circle at 35% 30%, #5a5247, #2c2722 70%);
box-shadow: 0 0 0 3px rgba(44, 39, 34, 0.08);
filter: blur(0.3px);
}
}
/* Respect reduced-motion preferences */
@media (prefers-reduced-motion: reduce) {
.gh-anim,
[class*="animate-"] { animation: none !important; }
}

View File

@@ -1,14 +1,29 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Zen_Maru_Gothic, Klee_One } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/Navbar";
import GhibliBackground from "@/components/GhibliBackground";
import { Web3Provider } from "@/providers/Web3Provider";
const inter = Inter({ subsets: ['latin'] });
// Soft, rounded Japanese sans for body text…
const zen = Zen_Maru_Gothic({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-zen",
display: "swap",
});
// …and a handwritten storybook face for headings & brand.
const klee = Klee_One({
subsets: ["latin"],
weight: ["400", "600"],
variable: "--font-klee",
display: "swap",
});
export const metadata: Metadata = {
title: 'MortgageFi - Decentralized Mortgage Lending',
description: 'Secure, flexible, and innovative solutions for your digital assets',
title: "MortgageFi Decentralized Mortgage Lending",
description: "Secure, flexible, and gentle solutions for your digital assets",
};
export default function RootLayout({
@@ -17,17 +32,17 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} bg-gray-900 text-gray-100 min-h-screen`}>
<html lang="en" className={`${zen.variable} ${klee.variable}`}>
<body className="min-h-screen text-ink antialiased">
<GhibliBackground />
<Web3Provider>
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">
{children}
</main>
<footer className="bg-gray-900 border-t border-gray-800 py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-400 text-sm">
<main className="flex-1">{children}</main>
<footer className="mt-10 border-t border-line/60 py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col items-center gap-1">
<span className="gh-eyebrow text-sm">🌿 made with care</span>
<p className="text-center text-ink-soft text-sm">
&copy; {new Date().getFullYear()} MortgageFi. All rights reserved.
</p>
</div>

View File

@@ -12,9 +12,9 @@ export default async function ReadmePage() {
content = 'README not found.';
}
return (
<div className="max-w-3xl mx-auto p-6">
<h1 className="text-2xl font-semibold mb-4">README</h1>
<pre className="whitespace-pre-wrap text-sm leading-6 bg-white rounded border p-4">
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
<h1 className="font-display text-3xl font-semibold text-forest-deep mb-4">📖 README</h1>
<pre className="gh-card whitespace-pre-wrap text-sm leading-6 text-ink p-5">
{content}
</pre>
</div>

View File

@@ -17,10 +17,9 @@ export function ConnectButton() {
if (isConnected && address) {
return (
<div className="flex items-center space-x-4">
<span className="text-sm font-medium text-gray-700">
<div className="inline-flex items-center gap-2 rounded-full border border-line bg-cloud/80 px-3 py-1.5 text-sm font-medium text-forest-deep">
<span className="h-2 w-2 rounded-full bg-forest animate-breathe" aria-hidden />
{formatAddress(address)}
</span>
{/* Disconnect can be done from the wallet UI; no WalletConnect here */}
</div>
);
@@ -29,9 +28,9 @@ export function ConnectButton() {
return (
<button
onClick={handleConnect}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="gh-btn gh-btn-primary text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-deep"
>
Connect Wallet
🍃 Connect Wallet
</button>
);
}

View File

@@ -0,0 +1,108 @@
// Purely decorative, server-rendered Ghibli landscape that sits behind all
// content. No JS, no state — just layered CSS-animated SVG/divs. Positions and
// timings are hardcoded so SSR and client markup match (no hydration drift).
const clouds = [
{ top: "8%", scale: 1.0, duration: "70s", delay: "0s", opacity: 0.9 },
{ top: "18%", scale: 0.7, duration: "95s", delay: "-30s", opacity: 0.75 },
{ top: "30%", scale: 1.3, duration: "120s", delay: "-70s", opacity: 0.85 },
{ top: "46%", scale: 0.55, duration: "85s", delay: "-15s", opacity: 0.6 },
];
// Soot sprites (susuwatari) drifting up from the meadow
const sprites = [
{ left: "12%", size: 10, duration: "16s", delay: "0s" },
{ left: "26%", size: 7, duration: "21s", delay: "-6s" },
{ left: "44%", size: 12, duration: "18s", delay: "-11s" },
{ left: "61%", size: 8, duration: "23s", delay: "-3s" },
{ left: "78%", size: 9, duration: "19s", delay: "-14s" },
{ left: "89%", size: 6, duration: "25s", delay: "-9s" },
];
function Cloud({ opacity }: { opacity: number }) {
return (
<svg viewBox="0 0 200 80" width="200" height="80" style={{ opacity }}>
<g fill="#fffdf7">
<ellipse cx="60" cy="50" rx="55" ry="26" />
<ellipse cx="105" cy="40" rx="45" ry="34" />
<ellipse cx="145" cy="52" rx="42" ry="24" />
<ellipse cx="95" cy="58" rx="70" ry="20" />
</g>
</svg>
);
}
export default function GhibliBackground() {
return (
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
{/* warm sun glow, lower-left */}
<div
className="gh-anim absolute rounded-full"
style={{
left: "-6rem",
top: "-6rem",
width: "26rem",
height: "26rem",
background:
"radial-gradient(circle, rgba(244,210,122,0.55) 0%, rgba(244,210,122,0) 68%)",
animation: "breathe 9s ease-in-out infinite",
}}
/>
{/* drifting clouds */}
{clouds.map((c, i) => (
<div
key={i}
className="gh-anim absolute left-0"
style={{
top: c.top,
transform: `scale(${c.scale})`,
animation: `drift ${c.duration} linear infinite`,
animationDelay: c.delay,
}}
>
<Cloud opacity={c.opacity} />
</div>
))}
{/* layered meadow hills along the bottom */}
<svg
className="absolute bottom-0 left-0 w-full"
viewBox="0 0 1440 320"
preserveAspectRatio="none"
style={{ height: "38vh", minHeight: "220px" }}
>
<path
fill="#9cb87f"
fillOpacity="0.55"
d="M0,224 C240,160 420,272 720,224 C1020,176 1200,272 1440,208 L1440,320 L0,320 Z"
/>
<path
fill="#6f9a5a"
fillOpacity="0.7"
d="M0,272 C260,224 480,304 760,272 C1040,240 1240,304 1440,272 L1440,320 L0,320 Z"
/>
<path
fill="#466a3a"
fillOpacity="0.85"
d="M0,300 C300,276 520,320 820,300 C1120,280 1280,316 1440,300 L1440,320 L0,320 Z"
/>
</svg>
{/* soot sprites rising from the meadow */}
{sprites.map((s, i) => (
<span
key={i}
className="gh-soot gh-anim"
style={{
left: s.left,
width: `${s.size}px`,
height: `${s.size}px`,
animation: `rise ${s.duration} ease-in-out infinite`,
animationDelay: s.delay,
}}
/>
))}
</div>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import { Fragment } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react';
import { Disclosure } from '@headlessui/react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { ConnectButton } from '@/components/ConnectButton';
@@ -17,29 +16,29 @@ function classNames(...classes: string[]) {
export default function Navbar() {
return (
<Disclosure as="nav" className="bg-gray-900 border-b border-gray-800">
<Disclosure
as="nav"
className="sticky top-0 z-40 border-b border-line/70 bg-cloud/70 backdrop-blur-md"
>
{({ open }) => (
<>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<span className="text-xl font-bold text-indigo-400">MortgageFi</span>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="text-2xl" aria-hidden>🌱</span>
<span className="font-display text-xl font-semibold text-forest-deep">
MortgageFi
</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<div className="hidden sm:ml-8 sm:flex sm:space-x-2">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
target={item.external ? "_blank" : "_self"}
rel={item.external ? "noopener noreferrer" : ""}
className={classNames(
item.current
? 'border-indigo-500 text-gray-100'
: 'border-transparent text-gray-300 hover:border-gray-700 hover:text-white',
'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium'
)}
aria-current={item.current ? 'page' : undefined}
className="inline-flex items-center rounded-full px-3 py-1.5 my-3 text-sm font-medium text-ink-soft transition hover:bg-sun/30 hover:text-forest-deep"
>
{item.name}
</a>
@@ -50,8 +49,7 @@ export default function Navbar() {
<ConnectButton />
</div>
<div className="-mr-2 flex items-center sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-md text-gray-300 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
<Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-full text-ink-soft hover:text-forest-deep hover:bg-sun/30 focus:outline-none focus:ring-2 focus:ring-sky-deep">
<span className="sr-only">Open main menu</span>
{open ? (
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
@@ -64,7 +62,7 @@ export default function Navbar() {
</div>
<Disclosure.Panel className="sm:hidden">
<div className="pt-2 pb-3 space-y-1">
<div className="pt-2 pb-3 space-y-1 px-2">
{navigation.map((item) => (
<Disclosure.Button
key={item.name}
@@ -72,19 +70,13 @@ export default function Navbar() {
href={item.href}
target={item.external ? "_blank" : "_self"}
rel={item.external ? "noopener noreferrer" : ""}
className={classNames(
item.current
? 'bg-gray-800 border-indigo-500 text-gray-100'
: 'border-transparent text-gray-300 hover:bg-gray-800 hover:border-gray-700 hover:text-white',
'block pl-3 pr-4 py-2 border-l-4 text-base font-medium'
)}
aria-current={item.current ? 'page' : undefined}
className="block rounded-xl px-3 py-2 text-base font-medium text-ink-soft hover:bg-sun/30 hover:text-forest-deep"
>
{item.name}
</Disclosure.Button>
))}
<div className="pt-4 pb-3 border-t border-gray-800">
<div className="px-4">
<div className="pt-4 pb-2 border-t border-line/60">
<div className="px-2">
<ConnectButton />
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications";
import { NotificationSettings, NotificationProvider } from "@/types/notifications";
import { scheduleTestNotification, scheduleTestBackupNotification } from "@/utils/scheduler";
export interface SettingsModalProps {
@@ -10,23 +10,14 @@ export interface SettingsModalProps {
onSave: (settings: NotificationSettings) => void;
}
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, '');
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim();
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
const defaultSettings: NotificationSettings = {
provider: "",
scheduler: 'schedy',
ntfyServer: ENV_NTFY,
ntfyTopic: "",
gotifyServer: "",
gotifyToken: "",
snsRegion: "",
snsTopicArn: "",
snsAccessKeyId: "",
snsSecretAccessKey: "",
schedyBaseUrl: ENV_SCHEDY,
schedyApiKey: ENV_SCHEDY_API_KEY,
email: "",
backupEmail: "",
backupDelayDays: 1,
@@ -36,25 +27,19 @@ const defaultSettings: NotificationSettings = {
export default function SettingsModal({ open, initial, onClose, onSave }: SettingsModalProps) {
const [form, setForm] = useState<NotificationSettings>(initial || defaultSettings);
const [testStatus, setTestStatus] = useState<string>("");
// RPC runtime overrides (stored in localStorage)
const [rpcBase, setRpcBase] = useState<string>("");
const [rpcArbitrum, setRpcArbitrum] = useState<string>("");
const [rpcMainnet, setRpcMainnet] = useState<string>("");
// NFTCache settings (stored in localStorage)
const ENV_NFTCACHE = (process.env.NEXT_PUBLIC_NFTCACHE_URL || process.env.NFTCACHE_URL || '/nftcache').replace(/\/$/, '');
const ENV_NFTCACHE = (process.env.NEXT_PUBLIC_NFTCACHE_URL || '/nftcache').replace(/\/$/, '');
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
const [nftcacheBaseUrl, setNftcacheBaseUrl] = useState<string>(ENV_NFTCACHE);
const [nftcacheApiKey, setNftcacheApiKey] = useState<string>('');
useEffect(() => {
if (open) {
// Merge defaults to ensure newly added fields (e.g., backupDelayDays) are populated
const merged = { ...defaultSettings, ...(initial || {}) } as NotificationSettings;
// Re-apply env defaults if user hasn't set values
if (!merged.ntfyServer) merged.ntfyServer = ENV_NTFY;
if (!merged.schedyBaseUrl) merged.schedyBaseUrl = ENV_SCHEDY;
setForm(merged);
// Load RPC overrides from localStorage
try {
const ls = typeof window !== 'undefined' ? window.localStorage : undefined;
const b = ls?.getItem('rpc:base') || '';
@@ -63,45 +48,38 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
setRpcBase(b || 'https://base.llamarpc.com');
setRpcArbitrum(a || '');
setRpcMainnet(m || '');
// Load NFTCache settings
const nEn = ls?.getItem('nftcache:enabled');
const nUrl = ls?.getItem('nftcache:baseUrl');
const nKey = ls?.getItem('nftcache:apiKey');
setNftcacheEnabled(nEn === '1');
setNftcacheBaseUrl((nUrl && nUrl.trim()) || ENV_NFTCACHE);
setNftcacheApiKey(nKey || (process.env.NEXT_PUBLIC_NFTCACHE_API_KEY || process.env.NFTCACHE_API_KEY || ''));
setNftcacheApiKey(nKey || '');
} catch {}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initial]);
const provider: NotificationProvider | '' = form.provider || '';
const scheduler: SchedulerProvider | '' = form.scheduler || '';
const saveDisabled = useMemo(() => {
if (!form.email) return true;
const db = form.daysBefore ?? 10;
if (Number(db) < 0) return true;
// if backupEmail provided, enforce min backupDelayDays >= 1 (treat empty/NaN as invalid)
const hasBackup = Boolean((form.backupEmail || '').trim());
if (hasBackup) {
const bdd = Number(form.backupDelayDays);
if (!Number.isFinite(bdd) || bdd < 1) return true;
}
if (!provider) return true;
if (!scheduler) return true;
if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic);
if (provider === 'gotify') return !(form.gotifyServer && form.gotifyToken);
if (provider === 'sns') return !(form.snsRegion && form.snsTopicArn && form.snsAccessKeyId && form.snsSecretAccessKey);
if (scheduler === 'schedy') return !(form.schedyBaseUrl && form.schedyApiKey);
return true;
}, [form, provider, scheduler]);
return false;
}, [form, provider]);
const canTest = useMemo(() => {
// Basic same checks as save + provider must be ntfy
if (saveDisabled) return false;
return provider === 'ntfy' && scheduler === 'schedy';
}, [saveDisabled, provider, scheduler]);
return provider === 'ntfy';
}, [saveDisabled, provider]);
const onTest = async () => {
setTestStatus('Scheduling test…');
@@ -126,38 +104,29 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative z-10 w-full max-w-xl rounded-lg border border-gray-700 bg-gray-900 p-4 text-gray-100 shadow-lg">
<div className="text-lg font-semibold">Notification Settings</div>
<p className="text-sm text-gray-300">Configure your notification provider, scheduler, RPC and NFTCache.</p>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={onClose} />
<div className="gh-card relative z-10 w-full max-w-xl p-5 text-ink shadow-float max-h-[90vh] overflow-y-auto">
<div className="font-display text-xl font-semibold text-forest-deep flex items-center gap-2"><span aria-hidden>🔔</span>Notification Settings</div>
<p className="text-sm text-ink-soft">Configure your notification provider, RPC and NFTCache.</p>
<div className="gh-note gh-note-warn mt-2 text-xs">
Settings are stored locally in your browser. Do not use this on shared computers.
</div>
{/* Section: Basics */}
<details className="mt-3" open>
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Basics</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Basics</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label className="col-span-2">
<span className="text-gray-300">Provider</span>
<span className="text-earth">Provider</span>
<select
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
className="gh-select appearance-none"
value={provider}
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value as NotificationProvider }))}
>
<option value="">Select</option>
<option value="ntfy">ntfy.sh</option>
<option value="gotify">Gotify</option>
<option value="sns">Amazon SNS</option>
</select>
</label>
<label className="col-span-2">
<span className="text-gray-300">Scheduler</span>
<select
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
value={scheduler}
onChange={(e) => setForm((f) => ({ ...f, scheduler: e.target.value as SchedulerProvider }))}
>
<option value="schedy">Schedy</option>
</select>
</label>
</div>
@@ -165,12 +134,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
{/* Section: Email & Timing */}
<details className="mt-3" open>
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Email & Timing</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Email & Timing</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-gray-300">Email to notify</span>
<span className="text-earth">Email to notify</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="you@example.com"
value={form.email || ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
@@ -178,9 +147,9 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</label>
<label>
<span className="text-gray-300">Backup Email (optional)</span>
<span className="text-earth">Backup Email (optional)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="backup@example.com"
value={form.backupEmail || ''}
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
@@ -188,21 +157,23 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</label>
<label>
<span className="text-gray-300">Days before liquidation</span>
<span className="text-earth">Days before liquidation</span>
<input
type="number"
min={0}
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
max={365}
className="gh-input"
value={form.daysBefore ?? 10}
onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))}
/>
</label>
<label>
<span className="text-gray-300">Backup delay (days after first email)</span>
<span className="text-earth">Backup delay (days after first email)</span>
<input
type="number"
min={1}
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
max={90}
className="gh-input"
value={form.backupDelayDays ?? 1}
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
/>
@@ -210,52 +181,25 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</div>
</details>
{/* Section: Scheduler (Schedy) */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Scheduler</summary>
{scheduler === 'schedy' && (
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-gray-300">Schedy Base URL</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="http://localhost:8080"
value={form.schedyBaseUrl || ''}
onChange={(e) => setForm((f) => ({ ...f, schedyBaseUrl: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">Schedy API Key</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="your-secret"
value={form.schedyApiKey || ''}
onChange={(e) => setForm((f) => ({ ...f, schedyApiKey: e.target.value }))}
/>
</label>
</div>
)}
</details>
{/* Section: Provider-specific */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Provider Settings</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Provider Settings</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
{provider === 'ntfy' && (
<>
<label>
<span className="text-gray-300">ntfy Server</span>
<span className="text-earth">ntfy Server</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://ntfy.sh"
value={form.ntfyServer || ''}
onChange={(e) => setForm((f) => ({ ...f, ntfyServer: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">ntfy Topic</span>
<span className="text-earth">ntfy Topic</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="topic-name"
value={form.ntfyTopic || ''}
onChange={(e) => setForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
@@ -267,18 +211,18 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
{provider === 'gotify' && (
<>
<label>
<span className="text-gray-300">Gotify Server</span>
<span className="text-earth">Gotify Server</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://gotify.example.com"
value={form.gotifyServer || ''}
onChange={(e) => setForm((f) => ({ ...f, gotifyServer: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">Gotify App Token</span>
<span className="text-earth">Gotify App Token</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="token"
value={form.gotifyToken || ''}
onChange={(e) => setForm((f) => ({ ...f, gotifyToken: e.target.value }))}
@@ -286,92 +230,49 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</label>
</>
)}
{provider === 'sns' && (
<>
<label>
<span className="text-gray-300">AWS Region</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="us-east-1"
value={form.snsRegion || ''}
onChange={(e) => setForm((f) => ({ ...f, snsRegion: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">SNS Topic ARN</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="arn:aws:sns:..."
value={form.snsTopicArn || ''}
onChange={(e) => setForm((f) => ({ ...f, snsTopicArn: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">AWS Access Key ID</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="AKIA..."
value={form.snsAccessKeyId || ''}
onChange={(e) => setForm((f) => ({ ...f, snsAccessKeyId: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">AWS Secret Access Key</span>
<input
type="password"
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="••••••••"
value={form.snsSecretAccessKey || ''}
onChange={(e) => setForm((f) => ({ ...f, snsSecretAccessKey: e.target.value }))}
/>
</label>
<div className="col-span-2 text-xs text-amber-300">
Storing AWS credentials in the browser is insecure. Prefer a server-side relay.
</div>
</>
)}
</div>
</details>
{/* Section: RPC endpoints */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">RPC endpoints</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">RPC endpoints</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-gray-300">Base RPC (default https://base.llamarpc.com)</span>
<span className="text-earth">Base RPC (default https://base.llamarpc.com)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://base.llamarpc.com"
value={rpcBase}
onChange={(e) => setRpcBase(e.target.value)}
/>
</label>
<label>
<span className="text-gray-300">Arbitrum RPC (optional)</span>
<span className="text-earth">Arbitrum RPC (optional)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://arb.yourrpc.example"
value={rpcArbitrum}
onChange={(e) => setRpcArbitrum(e.target.value)}
/>
</label>
<label className="col-span-2">
<span className="text-gray-300">Mainnet RPC (optional)</span>
<span className="text-earth">Mainnet RPC (optional)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://mainnet.yourrpc.example"
value={rpcMainnet}
onChange={(e) => setRpcMainnet(e.target.value)}
/>
</label>
<div className="col-span-2 text-xs text-gray-400">Changes apply immediately after Save without rebuild.</div>
<div className="col-span-2 text-xs text-danger">
Only use HTTPS RPC endpoints you trust. Malicious RPCs can show fake balances and hide liquidation warnings.
</div>
</div>
</details>
{/* Section: NFTCache */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">NFTCache</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">NFTCache</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label className="flex items-center gap-2">
<input
@@ -380,35 +281,25 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
checked={nftcacheEnabled}
onChange={(e) => setNftcacheEnabled(e.target.checked)}
/>
<span className="text-gray-300">Enable NFTCache</span>
<span className="text-earth">Enable NFTCache</span>
</label>
<label className="col-span-2">
<span className="text-gray-300">NFTCache Base URL</span>
<span className="text-earth">NFTCache Base URL</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="/nftcache"
value={nftcacheBaseUrl}
onChange={(e) => setNftcacheBaseUrl(e.target.value)}
/>
<div className="text-xs text-gray-400 mt-1">Proxied default via nginx: /nftcache. Endpoint used in-app: {nftcacheBaseUrl || ENV_NFTCACHE}/nfts</div>
</label>
<label className="col-span-2">
<span className="text-gray-300">NFTCache API Key (X-API-Key)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="optional, can also be set via NEXT_PUBLIC_NFTCACHE_API_KEY"
value={nftcacheApiKey}
onChange={(e) => setNftcacheApiKey(e.target.value)}
/>
<div className="text-xs text-gray-400 mt-1">If empty, the app will try NEXT_PUBLIC_NFTCACHE_API_KEY from the environment.</div>
<div className="text-xs text-earth mt-1">Proxied default via nginx: /nftcache. Endpoint used in-app: {nftcacheBaseUrl || ENV_NFTCACHE}/nfts</div>
</label>
</div>
</details>
<div className="mt-4 flex justify-end gap-2">
<button className="rounded bg-gray-700 px-3 py-1.5" onClick={onClose}>Cancel</button>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<button className="gh-btn gh-btn-ghost" onClick={onClose}>Cancel</button>
<button
className="rounded bg-emerald-600 px-3 py-1.5 disabled:opacity-50"
className="gh-btn gh-btn-sun"
disabled={!canTest}
onClick={onTest}
title={provider !== 'ntfy' ? 'Test currently supports ntfy provider only' : ''}
@@ -416,7 +307,7 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
Send test alert
</button>
<button
className="rounded bg-teal-600 px-3 py-1.5 disabled:opacity-50"
className="gh-btn gh-btn-sky"
disabled={!canTest || !(form.backupEmail || '').trim()}
onClick={onTestBackup}
title={!((form.backupEmail || '').trim()) ? 'Set a backup email first' : ''}
@@ -424,10 +315,9 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
Send backup test
</button>
<button
className="rounded bg-indigo-600 px-3 py-1.5 disabled:opacity-50"
className="gh-btn gh-btn-primary"
disabled={saveDisabled}
onClick={() => {
// Persist RPC overrides
try {
if (typeof window !== 'undefined' && window.localStorage) {
const ls = window.localStorage;
@@ -437,7 +327,6 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
if (b) ls.setItem('rpc:base', b); else ls.removeItem('rpc:base');
if (a) ls.setItem('rpc:arbitrum', a); else ls.removeItem('rpc:arbitrum');
if (m) ls.setItem('rpc:mainnet', m); else ls.removeItem('rpc:mainnet');
// Persist NFTCache settings
ls.setItem('nftcache:enabled', nftcacheEnabled ? '1' : '0');
const nurl = (nftcacheBaseUrl || '').trim();
if (nurl) ls.setItem('nftcache:baseUrl', nurl); else ls.removeItem('nftcache:baseUrl');
@@ -448,7 +337,6 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
onSave(form);
try {
if (typeof window !== 'undefined') {
// Ensure new RPCs are applied by reloading the app
setTimeout(() => window.location.reload(), 150);
}
} catch {}
@@ -458,7 +346,7 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</button>
</div>
{testStatus && (
<div className="mt-2 text-xs text-gray-300">{testStatus}</div>
<div className="mt-2 text-xs text-ink-soft">{testStatus}</div>
)}
</div>
</div>

View File

@@ -0,0 +1,211 @@
---
title: API Reference
tags: [mortgagefi, api, reference]
type: reference
status: stable
updated: 2026-06-14
---
# API Reference
## nftcache API
Base URL: `http://localhost:8090` (or `/nftcache` when proxied)
### Authentication
> [!info]
> If `NFTCACHE_API_KEY` is configured, include it in the `X-API-Key` header for all requests.
---
### GET /nfts
Returns token IDs owned by a specific wallet for a given NFT contract.
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `network` | string | Yes | Network key: `eth`, `arb`, `base` |
| `nft_contract` | string | Yes | Contract address or config slug (e.g., `cbbtc`) |
| `user_wallet` | string | Yes | Owner wallet address |
**Response (200):**
```json
{
"token_ids": ["1", "5", "42"],
"source": "cache"
}
```
**Sources:**
- `cache` — Returned from BadgerDB cache
- `refreshed` — Fetched live from RPC and cached
**Errors:**
- `400` — Missing required parameters
- `401` — Invalid or missing API key
- `502` — RPC fetch error (rate limit, node unavailable)
---
### POST /nfts/refresh
Force a full refresh of the contract ownership cache.
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `network` | string | Yes | Network key |
| `nft_contract` | string | Yes | Contract address or slug |
**Response:**
- `204 No Content` — Success
- `401` — Unauthorized
- `502` — RPC error
---
### POST /nfts/invalidate
Delete a contract's cache entry.
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `network` | string | Yes | Network key |
| `nft_contract` | string | Yes | Contract address |
**Response:**
- `204 No Content` — Success
- `401` — Unauthorized
---
## Schedy API
Base URL: `http://localhost:8080` (or `/schedy` when proxied)
### Authentication
> [!info]
> If `SCHEDY_API_KEY` is configured, include it in the `X-API-Key` header for all requests.
---
### POST /tasks
Create a new scheduled task.
**Request Body:**
```json
{
"url": "https://ntfy.sh/mytopic",
"headers": {
"Content-Type": "text/plain",
"X-Email": "user@example.com"
},
"payload": "Your position is approaching liquidation",
"execute_at": "2026-05-10T12:00:00Z",
"retries": 3,
"retry_interval": 2000
}
```
**Fields:**
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `url` | string | Yes | — | Webhook target URL |
| `headers` | object | No | `{}` | HTTP headers to send |
| `payload` | any | No | `null` | Request body (string or JSON) |
| `execute_at` | string | Yes | — | ISO 8601 / RFC3339 timestamp |
| `retries` | int | No | `0` | Max retry attempts on failure |
| `retry_interval` | int | No | `2000` | Milliseconds between retries |
**Response (201):**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://ntfy.sh/mytopic",
"execute_at": "2026-05-10T12:00:00Z",
"headers": { "Content-Type": "text/plain" },
"payload": "Your position is approaching liquidation",
"retries": 3,
"retry_interval": 2000
}
```
**Errors:**
- `400` — Invalid body or timestamp
- `400` — Timestamp is in the past
- `401` — Missing API key
- `403` — Invalid API key
---
### GET /tasks
List all scheduled tasks.
**Response (200):**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://ntfy.sh/mytopic",
"execute_at": "2026-05-10T12:00:00Z",
"headers": {},
"payload": "...",
"retries": 3,
"retry_interval": 2000
}
]
```
---
### DELETE /tasks/{id}
Delete a scheduled task by ID.
**Response:**
- `204 No Content` — Success
- `404` — Task not found
- `401` / `403` — Auth error
---
## ntfy API (Proxied)
Base URL: `http://localhost/ntfy` (when behind nginx)
### POST /{topic}
Publish a message to a topic.
**Request Headers:**
| Header | Description |
|--------|-------------|
| `Content-Type: text/plain` | Message body is plain text |
| `X-Email: user@example.com` | Forward message via email |
| `X-Priority: 5` | Message priority (1-5) |
**Example:**
```bash
curl -X POST -H 'Content-Type: text/plain' -H 'X-Email: user@example.com' \
http://localhost/ntfy/mytopic \
-d 'Your position is approaching liquidation'
```
**Response:**
- `200 OK` — Message published
### Web UI
Access `http://localhost/ntfy/` to use the ntfy web interface for subscribing to topics.
## Related
- [[Home]]
- [[Architecture]]
- [[Deployment]]

View File

@@ -0,0 +1,487 @@
---
title: Architecture
tags: [mortgagefi, architecture]
type: architecture
status: stable
updated: 2026-06-14
---
# MortgageFi Architecture
## Overview
MortgageFi is a decentralized mortgage lending platform composed of a Next.js frontend DApp with embedded API routes, a Go-based NFT ownership cache service, an ntfy notification server, Redis for task persistence, and an nginx reverse proxy. All services are orchestrated via Docker Compose, and the scheduler runs as Vercel-native API routes with Upstash Redis.
The platform allows users to:
- Deposit ERC-721 NFTs as collateral into debt vaults
- Borrow stablecoins against their collateral
- Pay down debt to extend liquidation timers
- Receive scheduled alerts (email/push) before liquidation
---
## System Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ User Browser │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Next.js Frontend (Port 3000) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Wagmi/viem │ │ localStorage │ │ Notification Settings │ │ │
│ │ │ Web3 hooks │ │ NFT Cache │ │ (ntfy / gotify) │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └─────────────┬──────────────┘ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ RPC │ │ nftcache │ │ /api/tasks │ │ │
│ │ │ Providers│ │ API │ │ /api/cron │ │ │
│ │ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │
│ └────────┼─────────────────┼────────────────────────┼─────────────────┘ │
└───────────┼─────────────────┼────────────────────────┼────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Nginx Reverse Proxy (Port 80) │
│ │
│ / ──► frontend:3000 /ntfy ──► ntfy:80 │
│ /nftcache ──► nftcache:8090 │
└─────────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────────┐
│ Blockchain │ │ ntfy:80 │ │ nftcache:8090 │
│ (Base/Arb) │ │ │ │ │
└─────────────┘ └──────┬──────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌──────────────┐
│ SMTP Server │ │ BadgerDB │
│ (Email relay) │ │ /data │
└─────────────────┘ └──────────────┘
┌────────┴────────┐
│ Redis / │
│ Upstash │
└─────────────────┘
```
---
## Component Breakdown
### 1. Frontend (`mortgagefi-frontend/`)
**Stack:** Next.js 16, React 19, TypeScript, Tailwind CSS 4, wagmi 3, viem
**Key Files:**
- `app/dapp/page.tsx` — Main DApp interface (1418 lines). Handles wallet connection, NFT scanning, debt position reading, payments, and notification scheduling.
- `app/dapp/position/[network]/[preset]/[tokenId]/page.tsx` — Deep-link handler for specific positions.
- `providers/Web3Provider.tsx` — Wagmi + TanStack Query provider setup.
- `config/web3.ts` — Chain configuration (Base, Arbitrum, optional Mainnet) with RPC overrides.
- `utils/scheduler.ts` — Client for the embedded `/api/tasks` scheduler API.
- `components/SettingsModal.tsx` — Notification provider configuration UI.
**Features:**
- Multi-chain support (Base, Arbitrum)
- Preset vault pairs (cbBTC-USDC, WETH-USDC on Base; USDTO-WBTC on Arbitrum)
- Manual wallet mode (pay debt for another address)
- NFT discovery via on-chain `ownerOf` scanning or nftcache API
- ERC20 approve/pay flow for stablecoin debt repayment
- Liquidation alert scheduling with configurable lead time
- Backup email support with delayed secondary alerts
- Deep linking to individual positions
**State Management:**
- Wagmi handles blockchain state (balances, allowances, contract reads)
- localStorage persists NFT scan cache per contract+wallet
- localStorage persists notification settings and per-position alert state
---
### 2. NFT Cache Service (`nftcache/`)
**Stack:** Go 1.22, BadgerDB v4
**Purpose:** Scans ERC-721 contracts via RPC to build a complete tokenId→owner mapping, then serves user-specific token lists via HTTP. This avoids making hundreds of RPC calls from the browser.
**Key Files:**
- `cmd/nftcache/main.go` — HTTP server with CORS, API key auth, and three endpoints.
- `internal/fetcher/rpc.go` — RPC client with rate limiting (5 TPS), exponential backoff, and retry logic.
- `internal/fetcher/alchemy.go` — Alchemy NFT API client (optional fallback).
- `internal/store/store.go` — BadgerDB persistence for contract ownership maps.
- `internal/config/config.go` — YAML contract configuration loader.
**Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| GET | `/nfts?network=&nft_contract=&user_wallet=` | Returns token IDs owned by wallet |
| POST | `/nfts/refresh?network=&nft_contract=` | Force refresh of contract cache |
| POST | `/nfts/invalidate?network=&nft_contract=` | Delete contract cache entry |
**Caching Strategy:**
- Contract-level cache (all token owners) stored in BadgerDB
- TTL-based stale-while-revalidate: returns cached data immediately, refreshes in background
- Configurable via `NFTCACHE_TTL` (default 24h)
- Rate limited to 5 TPS with exponential backoff on 429 errors
---
### 3. Task Scheduler (Next.js API Routes + Redis)
**Stack:** Next.js App Router API routes, Upstash Redis (or self-hosted Redis via REST proxy)
**Purpose:** Replaces the standalone Schedy Go service with Vercel-native serverless functions. Tasks are stored in Redis sorted sets and executed by a Vercel Cron job (or Docker cron loop) calling `/api/cron`.
**Key Files:**
- `app/api/tasks/route.ts` — POST/GET tasks with SSRF protection and rate limiting
- `app/api/tasks/[id]/route.ts` — DELETE task
- `app/api/cron/route.ts` — Protected cron endpoint that finds due tasks and executes webhooks
- `lib/task-store.ts` — Redis storage abstraction using sorted sets for time-ordered scheduling
- `lib/ssrf-guard.ts` — URL validation to prevent Server-Side Request Forgery
**Task Model:**
```typescript
interface Task {
id: string;
url: string;
executeAt: number; // epoch seconds
headers: Record<string, string>;
payload: any;
retries: number;
retryInterval: number;
}
```
**Storage:** Redis sorted set `mortgagefi:tasks:by:time` with score = `executeAt` for O(log n) due task queries.
---
### 4. ntfy (`ntfy` service)
**Image:** `binwiederhier/ntfy`
**Purpose:** Self-hosted push notification server that also relays notifications via SMTP email.
**Configuration:**
- SMTP settings passed via environment variables
- Supports Gmail/App Password or custom SMTP relay
- Served under `/ntfy` subpath via nginx proxy
**Environment Variables:**
- `NTFY_BASE_URL` — Public URL including subpath
- `NTFY_SMTP_SENDER_ADDR` — SMTP server:port
- `NTFY_SMTP_SENDER_USER` / `NTFY_SMTP_SENDER_PASS` — Auth credentials
- `NTFY_SMTP_SENDER_FROM` — From address
---
### 5. Nginx Proxy (`nginx/`)
**Purpose:** Single entry point that routes traffic to all backend services.
**Routes:**
| Path | Target | Notes |
|------|--------|-------|
| `/` | `frontend:3000` | Next.js app with WebSocket HMR support |
| `/ntfy/` | `ntfy:80` | Push notification server |
| `/nftcache/` | `nftcache:8090` | NFT ownership cache API |
**Security Headers:**
- `X-Frame-Options: DENY`
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `X-XSS-Protection: 1; mode=block`
**Features:**
- WebSocket upgrade support for Next.js HMR
- SSE/streaming support for ntfy (buffering disabled, long timeouts)
- Subpath stripping for all proxied services
- Only port 80 exposed; all backend services are internal-only
> [!note]
> Only port 80 is exposed externally. All backend services (frontend, ntfy, nftcache) are reachable only through the nginx proxy on the internal Docker network.
---
## Data Flows
### NFT Discovery Flow
```
User opens DApp
┌─────────────────┐
│ Check localStorage│
│ NFT scan cache │
└────────┬────────┘
┌────┴────┐
▼ ▼
Has cache? Empty?
│ │
▼ ▼
Return IDs Check nftcache
API enabled?
┌────┴────┐
▼ ▼
Yes No
│ │
▼ ▼
Call /nfts On-chain scan
(bulk cache) ownerOf x12
│ │
└────┬────┘
Store in localStorage
Display positions
```
### Debt Payment Flow
```
User enters amount → clicks Pay
┌─────────────────┐
│ Check stablecoin │
│ allowance to debt│
│ contract │
└────────┬────────┘
┌────┴────┐
▼ ▼
Insufficient Sufficient
│ │
▼ ▼
Approve() payDownContract()
│ │
└────┬───────┘
Refetch debt data
Update UI state
```
### Liquidation Alert Flow
```
User enables alert for position
┌──────────────────────────┐
│ Read secondsTillLiq │
│ from debt contract │
└──────────┬───────────────┘
┌──────────────────────────┐
│ Compute runAt = now + │
│ (secondsTillLiq - lead) │
│ lead = daysBefore * 86400│
└──────────┬───────────────┘
POST /api/tasks
(URL = ntfy topic)
Store in Redis sorted set
(per-position notif state)
┌─────────────────┐
│ At runAt time │
│ /api/cron runs │
└────────┬────────┘
POST to ntfy topic
(with X-Email header)
┌─────────────────┐
│ ntfy delivers │
│ push + email │
└─────────────────┘
```
### Auto-Reschedule Flow
```
On each debt data refresh
Compare current secondsTillLiq
with scheduled runAt
┌────┴────┐
▼ ▼
Drift > Drift <=
60s? 60s
│ │
▼ ▼
Cancel old Keep existing
job(s) schedule
Create new
job(s)
```
---
## Smart Contract Architecture
### Collateral (ERC-721)
- Represents deposited collateral positions
- Each tokenId maps to a unique debt position
- Examples: cbBTC collateral on Base, WBTC collateral on Arbitrum
### Debt Contract
Key functions:
- `openDebt(tokenId)` → returns `(currentPaymentPending, debtAtThisSize, secondsTillLiq)`
- `feeSize(tokenId)` — Protocol fee amount
- `coinSize(tokenId)` — Collateral amount locked
- `amountPaid(tokenId)` — Total amount repaid
- `startDate(tokenId)` / `expiration(tokenId)` — Loan timeline
- `baseSize(tokenId)` — Base loan parameters
- `payDownContract(tokenId, amount)` — Repay stablecoin debt
- `calculateAPR()` — Current interest rate
- `stablecoin()` / `contractCoin()` — Token addresses
### Supported Networks
| Network | Chain ID | Presets |
|---------|----------|---------|
| Base | 8453 | cbBTC-USDC, WETH-USDC |
| Arbitrum One | 42161 | USDTO-WBTC |
| Ethereum Mainnet | 1 | USDC-WETH (disabled by default) |
---
## Caching Strategy
### Frontend Caching
- **NFT Scan Cache:** Per-contract, per-wallet localStorage entries
- Key: `nftScan:v1:<chainId>:<nftAddressLower>`
- Stores: `lastScannedIndex`, `tokenIds[]`, `complete` flag
- Gap limit: 5 consecutive non-existent tokens marks scan complete
- **Notification Settings:** Global localStorage key `notif:settings`
- **Position Notifications:** Per-debt-contract key `notif:positions:v1:<chainId>:<debtAddress>`
### Backend Caching (nftcache)
- **Contract Cache:** Full tokenId→owner mapping in BadgerDB
- Key: `contract:<network>:<canonicalAddress>`
- TTL-based expiration with background refresh
- Rate limit handling: invalidates cache on exhaustion
---
## Security Considerations
### Authentication
- **nftcache API:** `X-API-Key` header required if `NFTCACHE_API_KEY` is set
- **Frontend:** No server-side auth; all blockchain interactions signed by user's wallet
- **Cron endpoint:** Protected by `Authorization: Bearer {CRON_SECRET}`
### Input Validation
- Ethereum addresses validated to be exactly 0x + 40 hex chars (rejected if malformed)
- Token IDs parsed as BigInt with error handling
- Amount inputs parsed with proper decimal scaling
- RPC URLs validated against an allowlist of known providers
### CORS
- nftcache supports configurable CORS origins
- Default allows specific origins only, not `*`
- `NEXT_PUBLIC_` secrets removed from client bundle
> [!warning]
> Never use `*` as the nftcache CORS origin in production, and ensure no secret values are placed behind a `NEXT_PUBLIC_` prefix — anything `NEXT_PUBLIC_` is inlined into the client bundle and publicly visible.
---
## Configuration
### Environment Variables
| Variable | Used By | Description |
|----------|---------|-------------|
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | Frontend | WalletConnect project ID |
| `NEXT_PUBLIC_RPC_BASE` / `ARB` / `MAINNET` | Frontend | Custom RPC URLs |
| `NEXT_PUBLIC_NTFY_URL` | Frontend | ntfy base URL (usually `/ntfy`) |
| `NEXT_PUBLIC_NFTCACHE_URL` | Frontend | nftcache base URL (usually `/nftcache`) |
| `UPSTASH_REDIS_REST_URL` | API routes | Redis REST endpoint (Upstash or local proxy) |
| `UPSTASH_REDIS_REST_TOKEN` | API routes | Redis REST token |
| `CRON_SECRET` | API routes | Secret for `/api/cron` endpoint |
| `NFTCACHE_API_KEY` | nftcache | Server-side API key |
| `NFTCACHE_TTL` | nftcache | Cache TTL (default `24h`) |
| `ETH_RPC_URL` / `ARB_RPC_URL` / `BASE_RPC_URL` | nftcache | RPC endpoints for scanning |
| `NTFY_*` | ntfy | SMTP and server configuration |
| `CORS_ALLOW_ORIGIN` | nftcache | Allowed CORS origin |
### Contract Configuration (`config/contracts.yaml`)
Maps short slugs to contract addresses and networks for nftcache:
```yaml
contracts:
cbbtc:
network: base
address: "0x..."
max_token_id: "5000"
```
---
## Deployment Modes
### Local Development
```bash
cd mortgagefi-frontend
npm install
npm run dev # localhost:3000
```
### Docker Compose (Full Stack)
```bash
# From repo root
cp .env.example .env.local
# Edit .env.local with your values
docker compose up -d
# Access: http://localhost
```
### Vercel (Frontend Only)
- Set root directory to `mortgagefi-frontend/`
- Build command: `next build --turbopack`
- Configure environment variables in Vercel dashboard
> [!tip]
> See [[Deployment]] for the full production deployment runbook and [[Development]] for local environment setup details.
---
## Technology Choices
| Layer | Technology | Rationale |
|-------|-----------|-----------|
| Frontend Framework | Next.js 16 + App Router | SSR, file-based routing, API routes |
| Web3 Library | wagmi 3 + viem | React hooks for Ethereum, type-safe contract interactions |
| Styling | Tailwind CSS 4 | Utility-first, minimal CSS overhead |
| Wallet Connection | @web3modal/wagmi | Multi-wallet support with WalletConnect |
| NFT Cache | Go + BadgerDB | Fast embedded DB, efficient Go concurrency for RPC scanning |
| Scheduler | Next.js API Routes + Upstash Redis | Vercel-native serverless jobs; replaces the standalone Schedy Go service (see [§3](#3-task-scheduler-nextjs-api-routes--redis)) |
| Notifications | ntfy | Self-hosted, SMTP relay, no external SaaS dependency |
| Proxy | nginx | Mature, efficient subpath routing |
| Container Orchestration | Docker Compose | Simple local and small-scale deployment |
---
## Related
[[Home]] · [[API Reference]] · [[Deployment]] · [[Development]] · [[Security Audit]]

View File

@@ -0,0 +1,675 @@
---
title: Performance Audit
tags: [mortgagefi, audit, performance]
type: audit
status: reference
updated: 2026-06-14
---
# Performance Audit
**Audit Date:** 2026-05-01
**Scope:** Next.js frontend, nftcache (Go), embedded scheduler API, Docker builds, blockchain interaction patterns
**Overall Rating:** 🔴 **HIGH IMPACT** — Multiple performance bottlenecks affect user experience, RPC costs, and scalability.
> [!warning] Overall Rating: HIGH IMPACT
> Multiple performance bottlenecks affect user experience, RPC costs, and scalability.
---
## Executive Summary
| Component | Score | Biggest Issue |
|-----------|-------|---------------|
| Frontend (DApp) | 🔴 Poor | 1,400-line monolithic component with 15 `useEffect`, 0 `useCallback`, and unbatched RPC reads |
| nftcache | 🟡 Fair | Serial HTTP scanning (1 round-trip per token); no connection pooling |
| Scheduler API | 🟡 Fair | N+1 Redis queries; sequential task execution |
| Docker / Build | 🟡 Fair | 69MB `.next/` output; stale build args referencing removed service |
| Blockchain Reads | 🔴 Poor | 14+ separate `useReadContract` hooks that should be batched |
> [!info] Estimated impact
> - **Frontend:** ~3-5 second time-to-interactive on mobile; janky re-renders on every input keystroke
> - **nftcache:** 10-60 seconds to scan a 1,000-token contract; RPC rate limit exhaustion under load
> - **Scheduler:** O(n) Redis round-trips for n due tasks; cron can take >60s with retries
---
## 1. Frontend Performance (Critical)
### F1 — Monolithic 1,400-Line Component with Zero Callback Memoization 🔴
**Location:** `app/dapp/page.tsx` (1,405 lines)
> [!warning] Impact
> Every keystroke, every state change, and every prop drift triggers a full re-render of the entire DApp UI.
**Metrics:**
- `useState`: 18 instances
- `useEffect`: 15 instances
- `useMemo`: 13 instances
- `useCallback`: **0 instances**
**Problem:** Without `useCallback`, every inline function (event handlers, `onToggle`, `setPaymentAmount`, `handlePay`, etc.) is recreated on every render. Any child component or memoized selector that receives these functions as props will see them as "changed" and re-render unnecessarily.
**Example:**
```typescript
// This function is recreated on EVERY render
const setPaymentAmount = (tokenId: bigint, value: string) => {
setPayInputs((prev) => ({ ...prev, [tokenId.toString()]: value }));
};
```
> [!tip] Remediation
> 1. **Extract sub-components** — Split into `PositionCard`, `PaymentControls`, `ScanPanel`, `AlertToggle`
> 2. **Memoize callbacks:**
> ```typescript
> const setPaymentAmount = useCallback((tokenId: bigint, value: string) => {
> setPayInputs((prev) => ({ ...prev, [tokenId.toString()]: value }));
> }, []);
>
> const handlePay = useCallback(async (tokenId: bigint) => {
> // ...
> }, [debtAddress, stableDecimals, payInputs, payMaxByTokenId, writeContractAsync, selectedChainId]);
> ```
> 3. **Memoize expensive derived data** with stable dependencies
---
### F2 — Unstable `useReadContracts` Dependencies Cause Excessive Refetching 🔴
**Location:** `app/dapp/page.tsx:519-588`
> [!warning] Impact
> wagmi re-fetches debt data on every render even when token IDs haven't meaningfully changed.
**Problem:**
```typescript
const debtReads = useMemo(() => {
if (!tokenIds.length) return [] as any[];
return tokenIds.flatMap((id) => [
{ abi: debtAbi as Abi, address: debtAddress as `0x${string}`, functionName: 'openDebt', args: [id], chainId: selectedChainId },
// ... 6 more reads per token
];
}, [tokenIds, debtAddress, selectedChainId]);
const { data: debtResults, refetch } = useReadContracts({
contracts: debtReads as any,
query: { enabled: debtReads.length > 0 },
});
```
`debtReads` is a new array of new objects on every render. wagmi's `useReadContracts` deep-compares the `contracts` array, but if the objects aren't stable, it can trigger unnecessary cache invalidation and re-fetching.
> [!tip] Remediation
> ```typescript
> // Use stringify for stable comparison, or better: keep tokenIds stable
> const debtReads = useMemo(() => {
> // ... same logic
> }, [tokenIds.join(','), debtAddress, selectedChainId]);
> ```
>
> Even better: **batch all static reads into a single `useReadContracts` call** instead of mixing `useReadContract` + `useReadContracts`.
---
### F3 — 14 Separate `useReadContract` Hooks Instead of One Batch 🔴
**Location:** `app/dapp/page.tsx:458-702`
> [!warning] Impact
> 14 individual RPC round-trips on every mount/chain switch instead of 1 batched multicall.
**Current hooks:**
1. `supportsInterface`
2. `balanceOf`
3. `calculateAPR`
4. `stablecoin`
5. `contractCoin`
6. `decimals` (stable)
7. `symbol` (stable)
8. `decimals` (collateral)
9. `symbol` (collateral)
10. `decimals` (debt token)
11. `symbol` (debt token)
12. `balanceOf` (debt token)
13. `balanceOf` (collateral)
14. `balanceOf` (stable)
15. `allowance`
**Problem:** Each `useReadContract` is a separate wagmi query. Even with HTTP batching, this creates 15 separate cache entries, 15 separate error boundaries, and 15 separate re-render triggers.
> [!tip] Remediation
> Collapse all static/global reads into a single `useReadContracts`:
> ```typescript
> const { data: globalReads } = useReadContracts({
> contracts: [
> { abi: erc721Abi, address: nftAddress, functionName: 'supportsInterface', args: ['0x780e9d63'] },
> { abi: erc721Abi, address: nftAddress, functionName: 'balanceOf', args: [effectiveWallet] },
> { abi: debtAbi, address: debtAddress, functionName: 'calculateAPR' },
> { abi: debtAbi, address: debtAddress, functionName: 'stablecoin' },
> { abi: debtAbi, address: debtAddress, functionName: 'contractCoin' },
> // ... all 15 reads in one array
> ],
> query: { enabled: !!nftAddress && !!debtAddress },
> });
> ```
>
> This reduces re-render noise from 15 separate state changes to 1.
---
### F4 — Auto-Reschedule Effect Fires on Every `parsed` Mutation 🔴
**Location:** `app/dapp/page.tsx:948-974`
> [!warning] Impact
> Every time debt data refreshes, the effect loops over all positions and cancels/re-creates schedules.
**Problem:**
```typescript
useEffect(() => {
(async () => {
for (const row of parsed) {
// ... checks drift, cancels old jobs, schedules new ones
}
})();
}, [parsed.map(r => String(r.tokenId)+':'+String(r.secondsTillLiq)).join(','), notifSettings.daysBefore, positionsStoreKey]);
```
`parsed.map(...).join(',')` creates a new string on every render. Even if `secondsTillLiq` hasn't changed, string coercion of `bigint` can be expensive, and the async IIFE fires unnecessarily.
> [!tip] Remediation
> 1. Use a stable hash or deep-equality check instead of string serialization
> 2. Add a mutex/ref to prevent concurrent scheduling:
> ```typescript
> const isScheduling = useRef(false);
> useEffect(() => {
> if (isScheduling.current) return;
> isScheduling.current = true;
> (async () => { ... })().finally(() => { isScheduling.current = false; });
> }, [stableDependency]);
> ```
---
### F5 — No Code Splitting or Lazy Loading 🔴
**Location:** `app/dapp/page.tsx`, `app/layout.tsx`
> [!warning] Impact
> The entire DApp (~700KB+ of JS) downloads before the user sees anything.
**Evidence:**
- No `next/dynamic` imports anywhere
- No `React.lazy` or `Suspense`
- `SettingsModal` (~350 lines) is bundled into the initial chunk despite being rarely used
- `framer-motion` is imported but may not be used on first paint
> [!tip] Remediation
> ```typescript
> import dynamic from 'next/dynamic';
>
> const SettingsModal = dynamic(() => import('@/components/SettingsModal'), {
> loading: () => <div className="animate-pulse">Loading...</div>,
> });
> ```
>
> Also lazy-load heavy Web3 libraries if possible:
> ```typescript
> const Web3Provider = dynamic(() => import('@/providers/Web3Provider'), { ssr: false });
> ```
---
### F6 — localStorage Access in Render Path and Effects 🟡
**Location:** `app/dapp/page.tsx:85-96`, `components/SettingsModal.tsx:49-73`
> [!warning] Impact
> Synchronous localStorage reads block the main thread; JSON parsing on every mount.
**Problem:**
```typescript
useEffect(() => {
if (typeof window !== 'undefined' && window.localStorage) {
const ls = window.localStorage;
setNftcacheEnabled(ls.getItem('nftcache:enabled') === '1');
// ... more reads and JSON parsing
}
}, []);
```
> [!tip] Remediation
> Use a single initialization effect with `try/catch`, and persist via a custom hook that debounces writes:
> ```typescript
> function useLocalStorage<T>(key: string, initial: T) {
> const [value, setValue] = useState<T>(() => {
> try {
> const raw = localStorage.getItem(key);
> return raw ? (JSON.parse(raw) as T) : initial;
> } catch { return initial; }
> });
> useEffect(() => {
> localStorage.setItem(key, JSON.stringify(value));
> }, [key, value]);
> return [value, setValue] as const;
> }
> ```
---
### F7 — Large JavaScript Bundle 🟡
**Location:** `.next/static/chunks/`
> [!warning] Impact
> 690KB+ of uncompressed JS across main chunks; ~293KB largest chunk.
**Breakdown:**
| Chunk | Size | Likely Contents |
|-------|------|-----------------|
| `63d1a7d9…js` | 293 KB | Main app chunk (wagmi + viem + Web3Modal) |
| `612aa671…js` | 202 KB | Framework runtime (Next.js + React) |
| `a6dad97d…js` | 110 KB | Component/framework code |
| `a5e16e63…js` | 86 KB | Vendor libraries |
**Total `.next/`: 69 MB** (includes standalone server, static assets, cache)
> [!tip] Remediation
> 1. Enable tree-shaking for `viem` by importing only needed modules:
> ```typescript
> import { parseUnits } from 'viem'; // ✅ good
> import { Abi } from 'viem'; // ✅ good
> ```
> 2. Replace `@web3modal/wagmi` with the newer `@reown/appkit` (smaller bundle)
> 3. Remove unused dependencies (`@web3-react/core`, `@web3-react/injected-connector` if WalletConnect covers all wallets)
> 4. Use `next/dynamic` for non-critical components
---
## 2. nftcache Performance (High)
### N1 — Serial HTTP Scanning (1 Round-Trip Per Token) 🔴
**Location:** `nftcache/internal/fetcher/rpc.go:55-113`
> [!warning] Impact
> Scanning 1,000 tokens takes **1,000 sequential HTTP requests** = 10-60 seconds depending on latency.
**Problem:**
```go
for i := 0; i < maxTokenId; i++ {
tokenOwner, err := c.getOwnerOf(rpcURL, contract, i, debug)
// ...
}
```
Each `getOwnerOf` makes a full HTTP POST, waits for response, then proceeds to the next token. No batching, no pipelining.
**Modern RPCs support `eth_call` batching:**
```json
[
{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x...","data":"0x6352211e..."},"latest"]},
{"jsonrpc":"2.0","id":2,"method":"eth_call","params":[{"to":"0x...","data":"0x6352211e..."},"latest"]},
// ... up to 100 per batch
]
```
> [!tip] Remediation
> Implement batch RPC calls:
> ```go
> const batchSize = 100
> for batchStart := 0; batchStart < maxTokenId; batchStart += batchSize {
> end := min(batchStart+batchSize, maxTokenId)
> owners, err := c.getOwnerOfBatch(rpcURL, contract, batchStart, end)
> // ...
> }
> ```
>
> This reduces 1,000 HTTP round-trips to **10**, cutting scan time from ~60s to ~2-5s.
---
### N2 — No HTTP Connection Pooling 🟡
**Location:** `nftcache/internal/fetcher/rpc.go:21-23`
> [!warning] Impact
> TCP handshake overhead on every request.
**Problem:**
```go
http: &http.Client{}, // default — no connection reuse tuning
```
> [!tip] Remediation
> ```go
> http: &http.Client{
> Transport: &http.Transport{
> MaxIdleConns: 100,
> MaxIdleConnsPerHost: 100,
> IdleConnTimeout: 90 * time.Second,
> },
> Timeout: 30 * time.Second,
> },
> ```
---
### N3 — Rate Limiter Bottleneck 🟡
**Location:** `nftcache/internal/fetcher/rpc.go:20-45`
> [!warning] Impact
> Hard-coded 5 TPS limits throughput even for batched requests.
**Problem:**
```go
rateLimiter: make(chan struct{}, 5), // 5 TPS
```
With batching, you could safely increase this to 20-50 TPS because each RPC call carries 100 `eth_call`s. The limit should be on RPC **calls**, not HTTP **requests**.
---
### N4 — Unbounded Background Refresh Goroutines 🟡
**Location:** `nftcache/cmd/nftcache/main.go:133-151`
> [!warning] Impact
> Under load, stale cache hits spawn unlimited goroutines, exhausting memory and RPC quota.
**Problem:**
```go
if age >= a.ttl {
go func(net, contract string, maxTokenId int, key string) {
// ... full rescan
}(net, cContract, maxTokenId, contractCacheKey)
}
```
> [!tip] Remediation
> Use `sync.SingleFlight` to deduplicate:
> ```go
> import "golang.org/x/sync/singleflight"
>
> type app struct {
> // ...
> refreshGroup singleflight.Group
> }
>
> // In getNFTs:
> if age >= a.ttl {
> go a.refreshGroup.Do(contractCacheKey, func() (interface{}, error) {
> // ... refresh logic
> return nil, nil
> })
> }
> ```
---
## 3. Scheduler API Performance (Medium)
### S1 — N+1 Redis Queries in `listDueTasks` 🟡
**Location:** `lib/task-store.ts:45-56`
> [!warning] Impact
> If 50 tasks are due, this makes **51 Redis round-trips** (1 zrange + 50 gets).
**Problem:**
```typescript
export async function listDueTasks(before: number): Promise<Task[]> {
const ids = await r.zrange<string[]>(TASK_ZSET, 0, before, { byScore: true });
const tasks: Task[] = [];
for (const id of ids) {
const t = await getTask(id); // N individual GETs
if (t) tasks.push(t);
}
return tasks;
}
```
> [!tip] Remediation
> Use `mget` for batch retrieval:
> ```typescript
> export async function listDueTasks(before: number): Promise<Task[]> {
> const ids = await r.zrange<string[]>(TASK_ZSET, 0, before, { byScore: true });
> if (!ids || ids.length === 0) return [];
>
> const keys = ids.map(id => `${TASK_PREFIX}${id}`);
> const datas = await r.mget<string[]>(keys);
>
> return datas
> .map((data, i) => ({ data, id: ids[i] }))
> .filter(({ data }) => data !== null)
> .map(({ data, id }) => {
> try {
> return JSON.parse(data!) as Task;
> } catch {
> return null;
> }
> })
> .filter((t): t is Task => t !== null);
> }
> ```
>
> This reduces 51 round-trips to **2**.
---
### S2 — Sequential Task Execution in Cron 🟡
**Location:** `app/api/cron/route.ts:25-65`
> [!warning] Impact
> If 10 tasks are due with 3 retries each at 5s intervals, cron takes `10 × 3 × 5s = 150s` — exceeding the 60s Vercel function limit.
**Problem:** Tasks execute in a `for...of` loop with `await sleep()`.
> [!tip] Remediation
> Execute independent tasks in parallel with `Promise.all` and a concurrency limit:
> ```typescript
> import pLimit from 'p-limit';
>
> const limit = pLimit(5); // max 5 concurrent webhook calls
>
> await Promise.all(
> tasks.map(task => limit(() => executeTask(task)))
> );
> ```
---
### S3 — No Task Cleanup on Failure 🟡
**Location:** `app/api/cron/route.ts:56-62`
> [!warning] Impact
> Failed tasks accumulate in Redis forever, bloating storage and slowing down zrange scans.
**Problem:** Only successful tasks are deleted. Failed tasks remain with their original score.
> [!tip] Remediation
> On final failure, delete the task or move it to a dead-letter set:
> ```typescript
> if (!ok) {
> await deleteTask(task.id);
> await redis.zadd('mortgagefi:tasks:dead', { score: nowSec, member: task.id });
> }
> ```
---
## 4. Docker & Build Performance (Medium)
### D1 — Stale Build Arguments in Dockerfile 🟡
**Location:** `mortgagefi-frontend/Dockerfile`
> [!warning] Impact
> Build still references removed `NEXT_PUBLIC_SCHEDY_URL`.
```dockerfile
ARG NEXT_PUBLIC_SCHEDY_URL
ENV NEXT_PUBLIC_SCHEDY_URL=${NEXT_PUBLIC_SCHEDY_URL:-/schedy}
```
> [!tip] Remediation
> Remove the SCHEDY argument and add Redis/CRON secrets if needed for standalone server:
> ```dockerfile
> ARG CRON_SECRET
> ARG UPSTASH_REDIS_REST_URL
> ARG UPSTASH_REDIS_REST_TOKEN
> ENV CRON_SECRET=${CRON_SECRET}
> ENV UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL}
> ENV UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN}
> ```
---
### D2 — Large `.next/` Output (69MB) 🟡
**Location:** `mortgagefi-frontend/.next/`
> [!warning] Impact
> Slow Docker builds, large image layers.
**Breakdown:**
- Standalone server: ~40MB
- Static chunks: ~1.2MB
- Cache, trace files, source maps: ~25MB
> [!tip] Remediation
> 1. Add `.dockerignore` to exclude `.next/cache`, `node_modules`, `.git`:
> ```
> .next/cache
> node_modules
> .git
> .env*
> ```
> 2. Disable source maps in production:
> ```typescript
> // next.config.ts
> productionBrowserSourceMaps: false,
> ```
---
## 5. Blockchain Read Optimization (High)
### B1 — ERC721Enumerable Scanning Is Unused 🔴
**Location:** `app/dapp/page.tsx:492-513`
> [!warning] Impact
> Code detects `ERC721Enumerable` support but never actually uses `tokenOfOwnerByIndex` to fetch token IDs.
**Problem:**
```typescript
useEffect(() => {
const fetchTokens = async () => {
if (!effectiveWallet || !balance || !canEnumerate) return;
const count = Number(balance);
const max = Math.min(count, 10);
const reads = Array.from({ length: max }, (_, i) => ({
abi: erc721Abi,
address: nftAddress,
functionName: 'tokenOfOwnerByIndex',
args: [effectiveWallet, BigInt(i)],
}));
// ... reads are built but NEVER EXECUTED
};
fetchTokens();
}, [effectiveWallet, balance, canEnumerate, nftAddress, selectedChainId]);
```
The `reads` array is built but never passed to `useReadContracts` or `publicClient.multicall`. This is dead code that runs useless logic on every balance change.
> [!tip] Remediation
> Either implement the multicall or remove this effect entirely.
---
### B2 — Manual `ownerOf` Scanning Is Inefficient 🔴
**Location:** `app/dapp/page.tsx:318-414`
> [!warning] Impact
> Browser scans 12 tokens at a time via individual RPC calls. For a wallet with 50 NFTs, this is 50+ sequential RPC round-trips from the browser.
**Problem:** The frontend calls `publicClient.readContract` in a loop with `await sleep(50)` between each.
> [!tip] Remediation
> 1. **Prefer nftcache API** — it's already implemented and batch-scans server-side
> 2. If client-side scanning is needed, use `publicClient.multicall`:
> ```typescript
> const results = await publicClient.multicall({
> contracts: indices.map(i => ({
> abi: erc721Abi,
> address: nftAddress,
> functionName: 'ownerOf',
> args: [BigInt(i)],
> })),
> });
> ```
>
> This sends all 12 `ownerOf` calls in a single HTTP request.
---
## Remediation Priority Matrix
| Priority | Issue | Effort | Impact |
|----------|-------|--------|--------|
| **P0** | F3 — Batch 14 `useReadContract` into 1 | 4h | Massive reduction in RPC calls and re-renders |
| **P0** | N1 — Batch RPC scanning in nftcache | 4h | 10-50x faster contract scanning |
| **P1** | F1 — Split dapp page into components | 1d | Eliminates full-page re-renders |
| **P1** | F5 — Add `next/dynamic` for SettingsModal | 30m | Faster initial page load |
| **P1** | S1 — Use `mget` in `listDueTasks` | 1h | 25x fewer Redis round-trips |
| **P1** | S2 — Parallelize cron task execution | 1h | Prevents Vercel timeout |
| **P2** | F2 — Stabilize `useReadContracts` deps | 2h | Reduces unnecessary refetching |
| **P2** | F4 — Fix auto-reschedule effect | 1h | Prevents job thrashing |
| **P2** | N4 — Deduplicate background refreshes | 2h | Prevents memory/RPC exhaustion |
| **P2** | B2 — Use `multicall` for client scan | 2h | 12x faster NFT discovery |
| **P3** | F7 — Optimize bundle size | 4h | Faster time-to-interactive |
| **P3** | D1/D2 — Fix Dockerfile + .dockerignore | 1h | Faster builds, smaller images |
---
## Quick Wins (Apply Today)
### 1. Enable RPC Batching in wagmi
```typescript
// config/web3.ts
http(baseRpc, { batch: true, retryCount: 2 })
```
Already set — verify your RPC provider supports `eth_call` batching.
### 2. Remove Dead Code
```typescript
// app/dapp/page.tsx ~ lines 492-513
// Delete the unused ERC721Enumerable fetchTokens effect
```
### 3. Add `.dockerignore`
```
.next/cache
node_modules
.git
.env*
*.md
```
### 4. Use nftcache by Default
Instead of client-side `ownerOf` scanning, default to the nftcache API which does the heavy lifting server-side.
---
## Related
- [[Home]]
- [[Architecture]]
- [[Security Audit]]
- [[Project Audit 2026-06]]

View File

@@ -0,0 +1,217 @@
---
title: Project Audit 2026-06
tags: [mortgagefi, audit, security, performance, dependencies]
type: audit
status: current
updated: 2026-06-14
---
# Project Audit — June 2026
A fresh, whole-project review of MortgageFi after a period of inactivity, covering the frontend web3 logic, the server/API layer, the Go `nftcache` service, infrastructure/ops, and dependencies. It complements the deeper, topic-specific [[Security Audit]] and [[Performance Audit]] — where those go further on a topic, they are linked inline.
> [!danger] Three issues need attention before anything else
> 1. **A live secret is shipped to every browser** — `NEXT_PUBLIC_SCHEDY_API_KEY` is set in `.env`, so it is inlined into the client bundle. **Rotate the Schedy key now** and remove the `NEXT_PUBLIC_` variant.
> 2. **The scheduler's SSRF guard is bypassable** — it validates the hostname string at *creation* time but the cron worker fires the request later with no re-validation, no redirect control, and no DNS-rebinding protection.
> 3. **`GET /api/tasks` and `DELETE /api/tasks/[id]` have no authentication** — anyone can enumerate every scheduled task (and the secrets stored in their headers) or delete them.
## Executive summary
The codebase is, structurally, in good shape: a modern and internally-consistent core stack (Next.js 16, React 19, wagmi v3 + viem v2, TypeScript strict), a clean scheduling migration to Next.js API routes + Upstash Redis, non-root Docker images that aren't port-published, and genuine security thought (RPC allowlist, known-preset compound guard, SSRF allowlist scaffolding, a real CSP).
The problems are concentrated in **secret handling**, the **outbound-request (SSRF) trust model**, **endpoint authentication**, and the **NFT-scanning design** (both the frontend `ownerOf` walk and the backend serial scan are correctness-fragile and DoS-prone). There is also notable **dead weight**: two unused web3 stacks (`@web3-react`, `@web3modal/wagmi`) and the orphaned `schedy` submodule that Docker still builds and runs.
### Findings by severity
| Domain | 🔴 Critical | 🟠 High | 🟡 Medium | 🟢 Low | ⚪ Info |
|---|:--:|:--:|:--:|:--:|:--:|
| [Frontend web3](#1-frontend-web3--ui-logic) | 0 | 3 | 7 | 4 | 1 |
| [API & server libs](#2-api--server-libs) | 2 | 3 | 3 | 2 | 1 |
| [nftcache (Go)](#3-nftcache-go-service) | 2 | 4 | 4 | 3 | 2 |
| [Infra & ops](#4-infrastructure--ops) | 2 | 3 | 3 | 2 | 2 |
| [Dependencies & build](#5-dependencies--build) | 0 | 2 | 2 | 2 | 1 |
| **Total** | **6** | **15** | **19** | **13** | **7** |
> [!note] Severity is engineering judgement, not a formal CVSS score. The two "Critical" deps/web3modal-style items were normalised down because the affected code is unused; see each finding.
## Priority remediation roadmap
> [!todo] Do first (Critical)
> - [ ] **Rotate the Schedy API key** and delete `NEXT_PUBLIC_SCHEDY_API_KEY`; proxy Schedy through a server-side route that reads the non-public `SCHEDY_API_KEY`. Remove the stray `manwe-secret` line.
> - [ ] **Rewrite `lib/ssrf-guard.ts` to validate at fire-time**: resolve all A/AAAA records, reject any private/reserved/loopback/link-local IP, pin the connection to the validated IP (defeat rebinding), set `redirect: 'manual'` and re-validate each hop, and parse decimal/octal/hex/IPv4-mapped-IPv6 forms with a real IP library.
> - [ ] **Authenticate `GET /api/tasks` and `DELETE /api/tasks/[id]`**, scope tasks to an owner, and never return raw `headers`/`payload`.
> - [ ] **nftcache: move the API-key check to the top of every handler** (before any param parsing, cache access, or RPC), and **restrict `nft_contract`/`network` to the YAML allowlist** so an arbitrary address can't trigger a 10k-call scan.
> - [ ] **Audit published images for baked-in secrets** (`COPY . .` + missing frontend `.dockerignore`); add a `.dockerignore` excluding `.env*`, then rotate anything that may have shipped.
> [!todo] Do next (High)
> - [ ] Make `CRON_SECRET` (frontend) and `X-API-Key` (nftcache) comparisons constant-time (`crypto.timingSafeEqual` / `crypto/subtle`).
> - [ ] Fail the stack closed when `REDIS_REST_TOKEN` is unset; stop shipping `local-redis-token-change-me` as a working default.
> - [ ] Make the rate limiter atomic + fail-closed, and stop trusting client `X-Forwarded-For` for the key.
> - [ ] Replace the frontend's sequential `ownerOf` gap-scan with `Transfer`-log / enumerable / multicall detection; only count a "gap" on a true `nonexistent token` revert, and never permanently mark a wallet "complete" after transient errors.
> - [ ] Give nftcache HTTP-server timeouts + graceful shutdown (`cache.Close()`), thread `context` through RPC calls, and add single-flight to the stale-refresh path.
> - [ ] Add nginx `limit_req` / `client_max_body_size`; confirm ntfy isn't open (`NTFY_AUTH_DEFAULT_ACCESS=deny-all`).
> - [ ] Gate write actions (`pay`/`approve`/`compound`) on `chainId === selectedChainId`; scope the compound preset check to the selected chain.
> - [ ] Remove `@web3-react/*` and `@web3modal/wagmi`; remove the orphaned `schedy` service from `docker-compose.yml`/`Dockerfile`.
---
## 1. Frontend web3 & UI logic
> Files: `app/dapp/page.tsx`, `config/web3.ts`, `providers/Web3Provider.tsx`, `utils/scheduler.ts`, `utils/useLocalStorage.ts`, `components/SettingsModal.tsx`
The web3 read plumbing is mostly sound (on-chain `decimals`/`symbol`, `parseUnits`/BigInt money math, payment capped to `currentPaymentPending`). The weak spots are the NFT-discovery heuristics and several correctness traps around chain switching.
### 🟠 High
- **Gap-limit scan conflates RPC errors with non-existent tokens** — `app/dapp/page.tsx:339-392`. Any non-429 error is treated as a "definitive gap"; 5 in a row write `complete: true`, after which `scanMore` early-returns forever and the wallet looks empty — a safety-relevant false negative that can hide a position nearing liquidation. *Fix:* only count a gap on an actual `nonexistent token` revert; persist a "stopped due to errors" state.
- **Sequential scan assumes contiguous tokenIds** — `app/dapp/page.tsx:325-371`. Walks `0,1,2,…` and stops after 5 misses; burns/non-sequential IDs are undetectable except by manual entry. *Fix:* prefer `Transfer`-log scan or ERC721Enumerable (`tokenOfOwnerByIndex`) — see the dead-code finding below.
- **Chain-switch race** — `app/dapp/page.tsx:429-437`. `selectedChainId` drives every read while the wallet may be on another chain; the auto-`switchChain` result is ignored and reads/writes proceed regardless. *Fix:* gate writes on `chainId === selectedChainId`, await `switchChainAsync`, prompt the user instead of silent auto-switch.
### 🟡 Medium
- **APR display is a magnitude heuristic** — `:991-1003`. Infers scale at runtime (`>1e9 ⇒ 1e18 scale, else basis points`); any other denominator is mis-rendered, potentially off by orders of magnitude for a financial figure. *Fix:* read/hardcode the real per-preset denominator; unit-test against on-chain value.
- **Loan-term/expiration heuristic** — `:1226-1237`. `expiration <= 200 ? "N Years" : date`. Conflates two meanings of one field by magnitude. *Fix:* format deterministically from the ABI's actual semantics.
- **Provider tokens & API keys in `localStorage`, logged to console** — `:116-118`, `:1404`; `SettingsModal.tsx:330-334`; `useLocalStorage.ts`. Gotify token, ntfy topic, emails, NFTCache API key persisted and the whole settings object repeatedly `console.log`'d. *Fix:* strip the logging; treat the tokens as secrets (don't persist, or schedule server-side).
- **`nftcacheApiKey` saved but no UI to view/clear it** — `SettingsModal.tsx:36,51-56`. Half-wired secret that can be persisted but never edited or removed via the UI. *Fix:* add the field or remove the state.
- **Custom-RPC trust model is partial & silent** — `config/web3.ts:11-49`. The allowlist only governs read transports (not the wallet's signing RPC), the Alchemy regex accepts any key-shaped suffix, and a rejected RPC silently falls back to a public default with only a `console.warn`. *Fix:* surface rejection in the UI; document the scope.
- **Monolithic component re-renders + un-debounced auto-reschedule** — whole file; effects at `:250-304`, `:929-955`. ~25 hooks in one component; every keystroke re-renders the tree, and the auto-reschedule effect fires `scheduleJob`/cancel on >60s drift with `exhaustive-deps` disabled — risking duplicate/cancelled jobs from stale closures. *Fix:* extract child components/hooks, debounce, add an in-flight guard. See [[Performance Audit]] for the broader re-render analysis.
- **Compound guard checks address but not chain** — `:1017-1039`. Validates `debtAddress` across *all* chains' presets, then writes with `selectedChainId`. *Fix:* scope to `PRESETS[selectedChainId]` and require chain match.
### 🟢 Low
- **Dead enumerable code path** — `:481-502`. `tokenOfOwnerByIndex` reads are built then discarded; the more-correct enumeration is never used. *Fix:* implement via `multicall` or delete.
- **`parseUnits` failures swallowed in pay/approve handlers** — `:697-736`. Bad input (`"1.2.3"`, `"1e5"`) throws and is only `console.warn`'d; the money action silently no-ops. *Fix:* surface parse errors; reuse the render's `amount === null` guard.
- **Scheduler fabricates a job id on ambiguous responses** — `utils/scheduler.ts:45-65`. A non-JSON/`id`-less response yields `task_<random>`, so a failed schedule looks successful (false confidence an alert exists). *Fix:* treat missing `id` as failure.
- **`useLocalStorage` trusts persisted JSON with no schema validation** — `utils/useLocalStorage.ts:18-39`. Tampered/corrupt storage is accepted as typed settings that drive network requests; double-effect can cause a redundant write. *Fix:* validate shape (zod) before accepting.
### ⚪ Info
- **Deep-link payload from `localStorage` drives chain/preset** — `:170-195`. Same-origin, age-bounded, and IDs are later guarded — acceptable; consider range-validating `preset`/`tokenId`.
---
## 2. API & server libs
> Files: `app/api/cron/route.ts`, `app/api/tasks/route.ts`, `app/api/tasks/[id]/route.ts`, `lib/redis.ts`, `lib/ssrf-guard.ts`, `lib/task-store.ts`, `utils/cronhost.ts`
The skeleton is sound (CRON_SECRET gate, an SSRF allowlist, a rate limiter, header sanitisation) but the SSRF guard doesn't actually protect the outbound request, and two endpoints are unauthenticated.
### 🔴 Critical
- **SSRF guard bypassable via DNS rebinding, redirects, and IP encodings** — `lib/ssrf-guard.ts:30-84`; enforcement gap at `app/api/cron/route.ts:36`. Validation runs on the literal hostname at creation time only. Bypasses: (1) DNS rebinding — `attacker.com` passes, then resolves to `169.254.169.254`/`127.0.0.1` at fire time; (2) no `redirect: 'manual'`, so a whitelisted host can 302 to metadata; (3) decimal/octal/hex IPv4 (`http://2130706433/`, `0177.0.0.1`, `127.1`) skip the dotted-quad regex; (4) IPv4-mapped IPv6 (`[::ffff:169.254.169.254]`) and long-form loopback bypass the prefix checks; (5) GCP `metadata.google.internal` and the decimal form of the IMDS address aren't blocked. *Fix:* see the roadmap — resolve-and-pin at fire time. The `BLOCKED_HOSTS` Docker-service-name list confirms an internal network is reachable. (Also tracked in [[Security Audit]].)
- **No auth on `GET /api/tasks` & `DELETE /api/tasks/[id]`** — `app/api/tasks/route.ts:65-72`; `app/api/tasks/[id]/route.ts:4-19`. GET returns *every* task including each `url`, full `headers` (which legitimately carry `Authorization`/API keys for the target) and `payload`; DELETE removes any task whose id `startsWith('task_')`. Anonymous credential disclosure + sabotage/DoS, with no per-owner scoping anywhere. *Fix:* authenticate + scope + redact.
### 🟠 High
- **Stored task headers carry secrets and are returned/forwarded unredacted** — `app/api/tasks/route.ts:50,59`; `lib/task-store.ts:21`. `sanitizeHeaders` strips only hop-by-hop headers; `Authorization`/`Cookie`/`X-Api-Key` survive, are stored in plaintext, echoed in the POST 201, exposed by the unauthenticated GET, and replayed on every retry. *Fix:* encrypt at rest, never echo, redact on cross-origin redirects.
- **Rate limiter fails open + check-then-act race + spoofable key** — `lib/task-store.ts:76-91`; `app/api/tasks/route.ts:6-12`. Returns *allow* when Redis is unconfigured; non-atomic count under concurrency; keyed off client-controlled `x-forwarded-for`; collapses missing-IP callers to one `unknown` bucket. *Fix:* atomic sliding window (Lua/`INCR`+`EXPIRE`), fail closed, trusted-proxy IP only.
- **`CRON_SECRET` comparison not timing-safe** — `app/api/cron/route.ts:9-13`. Plain `!==` on the one credential protecting the highest-privilege endpoint. *Fix:* `crypto.timingSafeEqual` over equal-length buffers.
### 🟡 Medium
- **`listAllTasks` uses `KEYS` + N+1 `GET`** — `lib/task-store.ts:58-73` (and `listDueTasks` N+1 at `:51-54`). `KEYS` is O(N)/blocking and Upstash-discouraged; an attacker can trigger it repeatedly via the open GET. *Fix:* drive listing off the existing `TASK_ZSET` with `ZRANGE` + `MGET`.
- **Cron forces POST + overrides stored content-type inconsistently** — `app/api/cron/route.ts:37,40`. Tasks store no method; the worker always POSTs and hardcodes content-type while letting other arbitrary headers through. *Fix:* make method/content-type explicit and validated; consistent precedence.
- **Weak input validation (URL length, payload size, header values)** — `app/api/tasks/route.ts:21-26,50`. No max URL length, no payload-size cap (stored & sent verbatim), header values not type/CRLF-checked. *Fix:* enforce limits; reject control/CRLF chars.
### 🟢 Low
- **Internal error detail leaked to clients** — `cron/route.ts:20`, `tasks/route.ts:34,61,70`. `Blocked URL: ${reason}` even discloses which SSRF rule fired. *Fix:* generic client errors, detailed server logs.
- **Contradictory localhost policy in the guard** — `lib/ssrf-guard.ts:39-48`. Dev carve-out for `http://localhost` is immediately undone by `BLOCKED_HOSTS`; signals the guard wasn't tested end-to-end. *Fix:* resolve the contradiction; key protocol policy off an explicit flag, not `NODE_ENV`.
### ⚪ Info
- **`utils/cronhost.ts` is dead/legacy stub code** — confirmed no imports anywhere. *Fix:* delete it.
---
## 3. nftcache (Go service)
> Files: `cmd/nftcache/main.go`, `internal/config/config.go`, `internal/fetcher/rpc.go`, `internal/fetcher/alchemy.go`, `internal/store/store.go`, `go.mod`
A BadgerDB-backed stale-while-revalidate cache fronting per-token `ownerOf` scans. Auth and address validation exist but the authorization ordering is fragile and the scan design is a DoS amplifier.
### 🔴 Critical
- **API-key check ordering is fragile** — `cmd/nftcache/main.go:82-124,239-245`. The key is checked after param/config resolution; gating currently works but any future edit touching RPC before line 122 would expose an unauthenticated amplifier. *Fix:* check the key first thing in every handler (middleware).
- **Serial RPC scan is a DoS amplifier** — `internal/fetcher/rpc.go:55-113`, called from `main.go:172,245`. One request → up to `maxTokenId` (cap 10000) serial `eth_call`s against the upstream, throttled by a *global* 5 TPS limiter, for any caller-supplied `network`+`nft_contract`. 1 HTTP request → up to 10k upstream calls, and one abusive scan starves all legitimate traffic. *Fix:* reject contracts not in the YAML allowlist; bound per-request concurrency; per-call deadlines.
### 🟠 High
- **Non-constant-time API-key compare** — `main.go:123,239,262`. *Fix:* `crypto/subtle.ConstantTimeCompare`.
- **No HTTP-server timeouts & no graceful shutdown** — `main.go:303`, `main()`. Default server (no Read/Write/Idle timeouts → slowloris), no SIGTERM handler, `cache.Close()` never called (Badger not flushed). *Fix:* explicit `http.Server` timeouts + `server.Shutdown` + `defer cache.Close()`.
- **No context/timeout on RPC calls; scans uncancellable** — `rpc.go:23,227`; `FetchAllTokenOwners` takes no `context`. Hung upstream blocks a scan goroutine indefinitely; client disconnect doesn't abort a 10k-call scan; background refreshes are fire-and-forget. *Fix:* `http.Client{Timeout}`, thread `context`, derive refresh ctx from server lifetime.
- **Stale-refresh thundering herd (no single-flight)** — `main.go:143-162`. N concurrent stale requests launch N full scans of the same contract. *Fix:* `singleflight` per contract key.
### 🟡 Medium
- **Serial scanning instead of batch/multicall** — `rpc.go:65-109`. 1000 tokens ≥200s, 10000 ≥33min. Multicall3 (same address on eth/arb/base) or JSON-RPC batch, ERC721Enumerable, or the already-implemented-but-unused Alchemy `getNFTsForOwner` would collapse this. *Fix:* adopt multicall/Alchemy path. (See [[Performance Audit]].)
- **Upstream/internal error detail leaked to clients** — `main.go:182,246,267`. *Fix:* generic messages, server-side logs.
- **Rate-limit handling deletes cache → feedback loop** — `main.go:151-154,177-180`. On 429 exhaustion it deletes the entry, so the next request rescans into the already-limited upstream; detection is brittle substring matching. *Fix:* keep stale data + back off; typed/sentinel errors.
- **No `network` allowlist; CORS supports `*`** — `main.go:83,38-52`. Bad networks fail deep in the scan; `CORS_ALLOW_ORIGIN=*` is allowed (default empty is safe). *Fix:* validate `network` up front; document `*` must not pair with credentialed flows.
### 🟢 Low
- **Leftover "Token 103" debug + unconditional `fmt.Printf`** — `rpc.go:106-111`, `main.go:197-199`. *Fix:* remove special-cases; gate behind a debug flag; use `log` consistently.
- **`canonAddr` accepts any 40-hex string; duplicated in two files** — `main.go:64-80`, `rpc.go:141-157`. *Fix:* combine with the allowlist; dedupe the helper.
- **`json.Marshal` errors ignored in store writes** — `internal/store/store.go:50,58`. *Fix:* check & propagate.
### ⚪ Info
- **No Badger-layer TTL / value-log GC** — `store/store.go:49-66`. Entries never expire on disk (app-side SWR only) → unbounded growth. *Fix:* optional Badger TTL + periodic `RunValueLogGC`.
- **Rate limiter is approximate (burst >5 TPS) + refill goroutine leaks on exit** — `rpc.go:24-43`. Acceptable for a soft limiter; use `x/time/rate` if strictness is needed.
### Stale dependencies
`go 1.22.0` (current 1.24+), `badger/v4 v4.2.0`, and several stale indirect deps with advisories — notably `golang.org/x/net v0.7.0` (HTTP/2 rapid-reset family), `google.golang.org/protobuf v1.28.1` (< 1.33.0 JSON unmarshal loop), `golang.org/x/sys v0.5.0`. *Fix:* `go get -u ./...`, bump the Go directive, run `govulncheck`.
---
## 4. Infrastructure & ops
> Files: `docker-compose.yml`, `nginx/nginx.conf`, `.env.example`, `.env` / `.env.local`, `config/contracts.yaml`, both `Dockerfile`s, `next.config.ts`
Self-hosted Compose stack. Architecture is reasonable (internal services not port-published, non-root images, a real CSP) but secret handling and the nginx edge need work.
### 🔴 Critical
- **Secret shipped to the client via `NEXT_PUBLIC_`** — `.env` / `.env.local`. A real Schedy API key is set as both `SCHEDY_API_KEY` *and* `NEXT_PUBLIC_SCHEDY_API_KEY`; the latter is inlined into the JS every browser downloads. A stray `manwe-secret` duplicates the WalletConnect id. *Fix:* delete the `NEXT_PUBLIC_` variant, proxy Schedy server-side, **rotate the key**, remove the stray line.
- **Live third-party secrets on disk** — `.env`, `mortgagefi-frontend/.env.local`. Contains real `ALCHEMY_API_KEY`, `NFTCACHE_API_KEY`, `SCHEDY_API_KEY`, `CRONHOST_API`, and a Gmail SMTP app password. Root `.gitignore` covers them and this isn't a git repo *today*, but one `git init`/sub-repo commit away from exposure. *Fix:* secret manager / Docker secrets; verify the frontend subdir's own `.gitignore` covers `.env.local`; ensure Docker build context doesn't bake them in (next finding).
### 🟠 High
- **Weak hardcoded default Redis token** — `docker-compose.yml:16,76`; `.env.example:23`. `SRH_TOKEN`/`UPSTASH_REDIS_REST_TOKEN` default to `local-redis-token-change-me`, shipped as a working value in `.env.example`; the stack is likely running with it. Anyone on the Docker network can then read/write all scheduling state. *Fix:* `${REDIS_REST_TOKEN:?set me}` (fail closed); set a strong random token.
- **`COPY . .` may bake `.env` into the frontend image** — `mortgagefi-frontend/Dockerfile:17`. No confirmed `.dockerignore`; the `.env.local` target lives inside the frontend dir, and `NEXT_PUBLIC_*` values inline into the bundle in the published image `…/mortgagefi-frontend:alert`. *Fix:* add `.dockerignore` (`.env*`, `node_modules`, `.next`, `data/`); inspect the published image; rotate anything baked in.
- **No rate limiting / body-size limit / edge auth on proxied paths** — `nginx/nginx.conf`. Port 80 forwards `/ntfy/` (full UI + publish API), `/nftcache/`, and the app with no `limit_req`, `limit_conn`, or `client_max_body_size`; security rests entirely on each service's own auth (ntfy default is open). *Fix:* add `client_max_body_size` + `limit_req` zones; `auth_basic`/IP-allowlist `/ntfy/`; set `NTFY_AUTH_DEFAULT_ACCESS=deny-all`.
### 🟡 Medium
- **Cron loop swallows all errors + plaintext Bearer + spurious deps** — `docker-compose.yml:84-102`. `curlimages/curl:latest` loop with `|| true` hides every failure (no alerting/dead-man's-switch); depends on `redis` it never uses. *Fix:* log non-2xx (drop `|| true`), pin the image, drop the redis dep, confirm `/api/cron` 401s on bad secret and isn't reachable via nginx `/`.
- **Mutable / `:latest` image tags** — `docker-compose.yml:11,23,67,85,105`. `serverless-redis-http:latest`, untagged `ntfy`, mutable `mortgagefi-frontend:alert`, `curl:latest`. *Fix:* pin to digests/immutable tags.
- **No healthchecks on any service** — `docker-compose.yml`. `depends_on` only waits for start, not readiness → startup 502s, no auto-restart on hung process. *Fix:* add `healthcheck` + `condition: service_healthy`.
### 🟢 Low
- **Duplicate YAML key in nftcache config** — `config/contracts.yaml:10-18`. `cbbtc` defined twice (placeholder vs real); YAML silently keeps the last. *Fix:* remove the placeholder.
- **Security-header drift between nginx and Next.js** — `nginx/nginx.conf:6-9,74-79` vs `next.config.ts`. nginx sets no CSP; Next.js does; overlap can diverge on error pages. *Fix:* centralise headers in one layer (prefer the Next.js CSP).
### ⚪ Info
- **`proxy_buffering off` is global** — `nginx/nginx.conf:12`. Needed for ntfy SSE but applied everywhere → higher upstream hold time. *Fix:* scope it to `/ntfy/`.
- **ntfy uses a personal Gmail app password** — `docker-compose.yml:31-34`. *Fix:* prefer a scoped transactional-mail credential; rotate if ever shared.
---
## 5. Dependencies & build
> Files: `mortgagefi-frontend/package.json`, `tsconfig.json`, `next.config.ts`, `eslint.config.mjs`, `nftcache/go.mod`, `submodules/schedy`
Core stack is modern and coherent (Next 16, React 19, wagmi v3 + viem v2, TS strict). The issues are dead weight and a few hardening gaps.
### 🟠 High
- **Deprecated, unused, version-mismatched `@web3modal/wagmi@^5`** — `package.json`. v5 targets wagmi v2 but the project runs wagmi v3; it's also superseded by Reown AppKit — and it isn't imported anywhere. *Fix:* remove it; adopt `@reown/appkit` + `@reown/appkit-adapter-wagmi` only if a connect modal is later needed. *(Listed High, not Critical: unused = not exploitable, but it's deprecated + mismatched.)*
- **Unused redundant `@web3-react/*` stack** — `package.json`. `@web3-react/core@^8`, `@web3-react/injected-connector@^6` are unmaintained and fully unused; the app is bare wagmi v3. *Fix:* remove both; use `wagmi/connectors` if needed.
- **Orphaned `schedy` submodule still built & run by Docker** — `submodules/schedy/`, `docker-compose.yml`, `Dockerfile:22`. Scheduling moved to Next.js API routes + Upstash; all in-source `schedy` references are now legacy-prune code or an SSRF-allowlist token, yet Docker still builds the Go scheduler and sets `NEXT_PUBLIC_SCHEDY_URL`. *Fix:* confirm nothing deploys it, then delete the submodule, the `schedy` service/`depends_on`, and `NEXT_PUBLIC_SCHEDY_URL`; clean residual comments/keys.
### 🟡 Medium
- **nftcache Go module on an old toolchain with stale transitive deps** — `nftcache/go.mod`. (Same set as §3.) *Fix:* bump Go + `go get -u ./...` + `govulncheck`.
- **CSP allows `'unsafe-eval'` and `'unsafe-inline'` for scripts** — `next.config.ts`. Meaningfully weakens XSS protection for a wallet-signing dApp. *Fix:* move toward nonce/hash-based `script-src`, drop `'unsafe-eval'` if wagmi/walletconnect tolerate it; drop the deprecated `X-XSS-Protection`; consider adding HSTS.
### 🟢 Low
- **Packages a minor behind (some security-relevant)** — `npm outdated`: `next` 16.0.10 → 16.2.9, `wagmi` 3.1.0 → 3.6.16, `viem` 2.42.1 → 2.52.2, `@tanstack/react-query`, `tailwindcss`, `framer-motion`. Treat Next.js patch bumps as security-relevant. *Fix:* run in-range minors; evaluate eslint 10 / TS 6 majors separately.
- **Minimal ESLint config** — `eslint.config.mjs`. Only Next defaults; no `no-floating-promises`/`no-misused-promises` despite fire-and-forget `await fetch` patterns. *Fix:* add `@typescript-eslint` recommended-type-checked rules.
### ⚪ Info
- **TS `target: ES2017` is dated** — `tsconfig.json` (strict is otherwise good). *Fix:* optionally raise to ES2022, consider `noUncheckedIndexedAccess`.
---
## What's done well
- On-chain `decimals`/`symbol` and BigInt/`parseUnits` math throughout — no float drift in money paths; payments capped to the reset-timer amount.
- Genuine security scaffolding: RPC allowlist, known-preset compound guard, SSRF allowlist + header sanitisation, `CRON_SECRET`-gated cron that fails closed, future-dating bounds on `execute_at`, unguessable `crypto.randomUUID()` task ids.
- nftcache: API key required at startup (fails fast if unset), CORS closed by default, correct Badger transaction usage, stale-while-revalidate for latency, 429 backoff.
- Infra: internal services not port-published, non-root + distroless images, `CGO_ENABLED=0`, read-only `config` mount, consistent `platform` pinning, a real CSP + security-header baseline, correct `.gitignore`, `output: 'standalone'`.
- Clean, self-contained scheduling migration to Next.js API routes + Upstash Redis.
## Related
[[Home]] · [[Architecture]] · [[Security Audit]] · [[Performance Audit]] · [[Deployment]] · [[Development]]

View File

@@ -0,0 +1,740 @@
---
title: Security Audit
tags: [mortgagefi, audit, security]
type: audit
status: reference
updated: 2026-06-14
---
# Security Audit — MortgageFi
**Audit Date:** 2026-05-01
**Remediation Date:** 2026-05-01
**Scope:** Full stack — Next.js frontend, nftcache (Go), embedded scheduler API, ntfy, nginx, Docker Compose, configuration
**Risk Rating:** 🟡 **MEDIUM** — All critical and high-severity issues have been addressed. Remaining risk is low-to-medium and manageable for production.
> [!warning] Overall Risk Rating: MEDIUM
> All critical and high-severity issues have been addressed. Remaining risk is low-to-medium and manageable for production.
---
## Remediation Summary
All **7 critical** and **11 high** severity issues identified in the initial audit have been resolved. Key changes:
1.**Schedy removed** — Replaced with Vercel-native Next.js API routes (`/api/tasks`, `/api/cron`) + Redis
2.**SSRF eliminated** — New scheduler validates URLs against private IP / internal hostname blocklist
3.**Secrets removed from client bundle** — No more `NEXT_PUBLIC_` API keys; backend uses server-side env only
4.**Sensitive credentials removed from localStorage** — AWS SNS provider removed; Schedy fields removed; security warnings added
5.**nftcache hardened** — API key now required at startup; address validation rejects malformed/truncated addresses; maxTokenId capped at 10,000
6.**RPC injection prevented** — Frontend validates RPC URLs against an allowlist
7.**Network exposure reduced** — Direct container ports removed; only nginx exposed
8.**Security headers added** — CSP, HSTS, X-Frame-Options, X-Content-Type-Options in nginx and Next.js
9.`.env.example` created and `.gitignore` updated
---
## Executive Summary (Original)
The original codebase contained **7 critical**, **11 high**, and **15 medium/low severity** issues. The most severe were:
1. **Server-Side Request Forgery (SSRF)** in Schedy allowing internal network probing
2. **Secrets committed to version control** including SMTP passwords, API keys, and WalletConnect credentials
3. **API keys bundled into client-side JavaScript** via `NEXT_PUBLIC_` variables
4. **Sensitive credentials stored in browser localStorage** without encryption (AWS keys, schedy API key, notification tokens)
5. **Denial-of-Service via unauthenticated RPC scanning** in nftcache
6. **Address canonicalization bug** enabling ownership spoofing
---
## Critical Severity
### C1 — SSRF in Schedy Task Executor 🔴 [FIXED]
**Location:** `mortgagefi-frontend/submodules/schedy/internal/executor/executor.go` (removed)
**CVSS:** ~9.1 (Critical)
> [!danger] Critical (CVSS ~9.1) — SSRF in Schedy Task Executor [FIXED]
> Schedy executed HTTP webhooks to **arbitrary URLs** with zero URL validation.
**Issue:** Schedy executed HTTP webhooks to **arbitrary URLs** with zero URL validation.
**Remediation:** Schedy removed entirely. New scheduler in `app/api/cron/route.ts` uses `lib/ssrf-guard.ts` which blocks private IPs, loopback, link-local, multicast, and internal hostnames. Only HTTPS URLs are allowed in production.
```go
// executor.go:37 — task.URL is used directly
req, err := http.NewRequest(http.MethodPost, task.URL, bytes.NewBuffer(bodyBytes))
```
**Impact:** An attacker with a valid Schedy API key (or if auth is disabled) can schedule tasks targeting:
- `http://169.254.169.254/latest/meta-data/` — AWS/GCP/Cloud metadata endpoints
- `http://localhost:8090/nfts/invalidate` — nftcache internal APIs
- `http://localhost:80/ntfy` — ntfy admin endpoints
- Internal Docker network services (`http://frontend:3000`, `http://nftcache:8090`)
**Proof of Concept:**
```bash
curl -X POST http://localhost:8080/tasks \
-H "X-API-Key: $SCHEDY_API_KEY" \
-d '{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"execute_at": "2026-05-01T12:00:00Z",
"payload": "x"
}'
```
**Remediation:**
```go
// Add URL validation in CreateTask handler
func isAllowedURL(u string) bool {
parsed, err := url.Parse(u)
if err != nil { return false }
if parsed.Scheme != "https" { return false } // enforce HTTPS
host := parsed.Hostname()
ip := net.ParseIP(host)
if ip != nil {
// Block private/reserved IPs
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsMulticast() {
return false
}
}
// Block internal hostnames
blocked := []string{"localhost", "nftcache", "schedy", "ntfy", "frontend", "web"}
for _, b := range blocked {
if strings.EqualFold(host, b) { return false }
}
return true
}
```
---
### C2 — Secrets Committed to Version Control 🔴 [FIXED]
**Location:** `.env`, `.env.local`
**CVSS:** ~9.0 (Critical)
> [!danger] Critical (CVSS ~9.0) — Secrets Committed to Version Control [FIXED]
> The repository contained **real, active secrets** in committed files.
**Issue:** The repository contained **real, active secrets** in committed files.
| Secret | Location | Risk |
|--------|----------|------|
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | `.env`, `.env.local` | Project ID exposed |
| `SCHEDY_API_KEY` | `.env`, `.env.local` | Full scheduler control |
| `NEXT_PUBLIC_SCHEDY_API_KEY` | `.env`, `.env.local` | Same key, also in browser bundle |
| `NTFY_SMTP_SENDER_PASS` | `.env`, `.env.local` | SMTP password (Gmail/App Password) |
| `CRONHOST_API` | `.env`, `.env.local` | Legacy cronhost API key |
| `manwe-secret` | `.env`, `.env.local` | Unknown but looks sensitive |
**Impact:** Anyone with repository access (or if repo becomes public) has full credentials. The SMTP password grants access to the `mortgagefi@amn.gg` mailbox.
**Remediation:** ✅ Applied
1. ✅ Created `.env.example` with dummy values only
2. ✅ Added `.gitignore` at project root:
```
.env
.env.local
.env.*.local
```
3. **Action required by user:** Rotate all secrets and purge from Git history:
```bash
git rm --cached .env .env.local
git filter-repo --path .env --path .env.local --invert-paths
# Or use BFG Repo-Cleaner
```
---
### C3 — API Keys Bundled in Client-Side JavaScript 🔴 [FIXED]
**Location:** `.env.local`, `mortgagefi-frontend/utils/scheduler.ts`
**CVSS:** ~8.5 (Critical)
> [!danger] Critical (CVSS ~8.5) — API Keys Bundled in Client-Side JavaScript [FIXED]
> `NEXT_PUBLIC_SCHEDY_API_KEY` was compiled into the client bundle. Any visitor could extract it.
**Issue:** `NEXT_PUBLIC_SCHEDY_API_KEY` was compiled into the client bundle. Any visitor could extract it.
**Remediation:** ✅ `NEXT_PUBLIC_SCHEDY_API_KEY` and `NEXT_PUBLIC_SCHEDY_URL` removed entirely. The scheduler is now embedded as same-origin API routes (`/api/tasks`), so no external API key is needed in the browser. `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` remains (unavoidable for WalletConnect), but should be monitored for abuse at the WalletConnect dashboard.
```javascript
// In compiled JS:
const ENV_SCHEDY_API_KEY = "<REDACTED — a real 64-hex key was hardcoded here; rotate it at Schedy>";
```
**Impact:**
- Attacker can call Schedy API directly to create/delete tasks
- Attacker can abuse WalletConnect project ID for rate limit exhaustion
- If nftcache key is set via `NEXT_PUBLIC_NFTCACHE_API_KEY`, same issue
**Remediation:**
- **Never** prefix backend-only secrets with `NEXT_PUBLIC_`
- For Schedy: proxy through a Next.js API route (`/api/schedule`) that holds the server-side secret
- For WalletConnect: this one is unavoidable for client-side connection, but monitor abuse and restrict origins at WalletConnect dashboard
---
### C4 — Sensitive Credentials Stored in localStorage 🔴 [FIXED]
**Location:** `mortgagefi-frontend/app/dapp/page.tsx`, `mortgagefi-frontend/components/SettingsModal.tsx`
**CVSS:** ~8.2 (Critical)
> [!danger] Critical (CVSS ~8.2) — Sensitive Credentials Stored in localStorage [FIXED]
> Sensitive data (Schedy API key, AWS credentials, notification tokens, RPC endpoints) was stored in **unencrypted browser localStorage**.
**Issue:** The following sensitive data was stored in **unencrypted browser localStorage**:
- `notif:settings` — contains Schedy API key, ntfy topic, Gotify token, AWS Access Key ID + Secret Access Key
- `notif:positions:v1:*` — position metadata linked to wallet
- `rpc:base`, `rpc:arbitrum`, `rpc:mainnet` — custom RPC endpoints
- `nftcache:apiKey` — API key for nftcache
**Impact:** Any XSS vulnerability (or malicious browser extension) can steal:
- AWS credentials with SNS publish permissions
- Schedy API key granting full task control
- Gotify/ntfy tokens enabling notification spam
- Custom RPC URLs enabling man-in-the-middle attacks
**Remediation:** ✅ Applied
- ✅ **AWS SNS provider removed entirely** from frontend (no more AWS keys in browser)
- ✅ **Schedy URL/API key fields removed** from settings UI
- ✅ **Security warning added** to SettingsModal: "Settings are stored locally in your browser. Do not use this on shared computers."
- ✅ **CSP implemented** via Next.js headers (see M4)
- Gotify token and ntfy topic remain in localStorage (lower sensitivity). Future enhancement: encrypt with user password or use sessionStorage.
---
### C5 — Address Canonicalization Bug Enables Ownership Spoofing 🔴 [FIXED]
**Location:** `nftcache/cmd/nftcache/main.go:68-76`, `nftcache/internal/fetcher/rpc.go:134-140`
**CVSS:** ~7.5 (High)
> [!danger] High (CVSS ~7.5) — Address Canonicalization Bug Enables Ownership Spoofing [FIXED]
> `canonAddr()` silently **truncated** addresses longer than 40 hex chars instead of rejecting them, enabling ownership spoofing.
**Issue:** `canonAddr()` silently **truncated** addresses longer than 40 hex chars instead of rejecting them:
```go
func canonAddr(s string) string {
x := strings.ToLower(strings.TrimSpace(s))
if strings.HasPrefix(x, "0x") { x = x[2:] }
if len(x) > 40 { x = x[len(x)-40:] } // BUG: truncates!
if len(x) < 40 { x = strings.Repeat("0", 40-len(x)) + x }
return "0x" + x
}
```
**Impact:**
```
Input: 0x0000000000000000000000000000000000000000deadbeef1234567890abcdef12345678
Output: 0xdeadbeef1234567890abcdef1234567890abcdef (completely different address!)
```
An attacker could claim tokens belonging to address `0xdeadbeef...` by providing a maliciously padded `user_wallet` parameter. The cache key and owner comparison would match the truncated version.
**Remediation:** ✅ Applied. `canonAddr()` now returns `(string, error)` and rejects any address that is not exactly `0x` + 40 lowercase hex characters. All call sites updated to handle the error.
---
### C6 — Denial of Service via Unauthenticated RPC Scanning 🔴 [FIXED]
**Location:** `nftcache/cmd/nftcache/main.go:78-196`
**CVSS:** ~7.5 (High)
> [!danger] High (CVSS ~7.5) — Denial of Service via Unauthenticated RPC Scanning [FIXED]
> The `/nfts` endpoint performed expensive on-chain scanning. When `NFTCACHE_API_KEY` was empty, the endpoint was **completely unauthenticated**.
**Issue:** The `/nfts` endpoint performed expensive on-chain scanning. When `NFTCACHE_API_KEY` was empty, the endpoint was **completely unauthenticated**.
**Impact:**
- Attacker can spam `GET /nfts?network=base&nft_contract=cbbtc&user_wallet=0x...` to exhaust RPC rate limits
- Each request triggers up to 1000 `eth_call` RPC requests (or more if config has higher `max_token_id`)
- Background refresh goroutines multiply the effect (stale cache triggers background scan)
- Can incur significant RPC provider costs and cause service degradation
**Proof of Concept:**
```bash
while true; do
curl "http://localhost:8090/nfts?network=base&nft_contract=cbbtc&user_wallet=0x$(openssl rand -hex 20)"
done
```
**Remediation:** ✅ Applied
1. ✅ **API key required at startup** — `nftcache` now calls `log.Fatal` if `NFTCACHE_API_KEY` is empty
2. ✅ **maxTokenId capped at 10,000** — prevents unbounded RPC scanning
3. ✅ Per-IP rate limiting implemented in new scheduler API (not yet in nftcache — acceptable since auth is now required)
4. Background refresh goroutines still present; future enhancement: deduplicate with `sync.SingleFlight`
---
### C7 — RPC URL Injection via localStorage 🔴 [FIXED]
**Location:** `mortgagefi-frontend/config/web3.ts:17-31`, `mortgagefi-frontend/components/SettingsModal.tsx:57-65`
**CVSS:** ~7.8 (High)
> [!danger] High (CVSS ~7.8) — RPC URL Injection via localStorage [FIXED]
> The frontend read RPC URLs from **localStorage** with zero validation, enabling MITM via attacker-controlled RPC endpoints.
**Issue:** The frontend read RPC URLs from **localStorage** with zero validation:
```typescript
function runtimeRpc(key: string): string | null {
try {
if (typeof window !== 'undefined' && window.localStorage) {
const v = window.localStorage.getItem(key);
return (v && v.trim()) ? v.trim() : null;
}
} catch {}
return null;
}
```
**Impact:**
- A malicious website with XSS, a malicious browser extension, or phishing attack can set `rpc:base` to an attacker-controlled RPC
- All subsequent blockchain reads (balances, allowances, debt data) go through the malicious RPC
- Attacker can return fake data (e.g., show zero debt, hide liquidation warnings) or phish transactions
- The Settings UI even provides a friendly form for users to enter arbitrary RPC URLs
**Remediation:** ✅ Applied. `config/web3.ts` now validates RPC URLs against an allowlist of known providers (LlamaRPC, Infura, Alchemy, MeowRPC). Untrusted RPCs from localStorage are rejected with a console warning.
---
## High Severity
### H1 — No HTTPS Enforcement Anywhere [FIXED]
**Location:** `docker-compose.yml`, all Go services, nginx config
**Impact:** All internal and external communication was plaintext HTTP.
> [!danger] High — No HTTPS Enforcement Anywhere [FIXED]
> All internal and external communication was plaintext HTTP.
**Remediation:** ✅ nginx config and Next.js headers updated. In production, terminate TLS at nginx with a real certificate (Let's Encrypt) and add HSTS. Internal Docker network communication is isolated.
**Remediation:**
- Terminate TLS at nginx with a real certificate (Let's Encrypt)
- Use `https://` for all external service URLs
- Add HSTS header: `Strict-Transport-Security: max-age=31536000; includeSubDomains`
---
### H2 — Missing Security Headers in Nginx [FIXED]
**Location:** `nginx/nginx.conf`
**Impact:** No CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy.
> [!danger] High — Missing Security Headers in Nginx [FIXED]
> No CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy.
**Remediation:** ✅ Added to `nginx/nginx.conf`: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-XSS-Protection: 1; mode=block`. Also added CSP and same headers via `next.config.ts` for Vercel deployments.
**Remediation:** Add to nginx:
```nginx
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; connect-src 'self' https://*.walletconnect.com https://*.llamarpc.com; img-src 'self' data:; style-src 'self' 'unsafe-inline';" always;
```
---
### H3 — Direct Container Ports Exposed to Host [FIXED]
**Location:** `docker-compose.yml`
**Impact:** ntfy, schedy, and nftcache were exposed directly on the Docker host, bypassing nginx.
> [!danger] High — Direct Container Ports Exposed to Host [FIXED]
> ntfy, schedy, and nftcache were exposed directly on the Docker host, bypassing nginx.
**Remediation:** ✅ All `ports:` blocks removed from backend services. Only nginx exposes port 80. Internal services communicate via Docker network.
**Remediation:** Remove `ports:` from all services except nginx:
```yaml
# ntfy, schedy, nftcache: remove these blocks:
# ports:
# - "8081:80"
```
Only nginx should be reachable from outside.
---
### H4 — Schedy Task Payload Can Be Used for Header Injection [FIXED]
**Location:** `mortgagefi-frontend/submodules/schedy/internal/executor/executor.go` (removed)
**Impact:** Custom headers from task payloads were set without validation.
> [!danger] High — Schedy Task Payload Can Be Used for Header Injection [FIXED]
> Custom headers from task payloads were set without validation.
**Remediation:** ✅ Schedy removed. New executor in `app/api/cron/route.ts` uses `sanitizeHeaders()` which blocks `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Upgrade`, and `Proxy-Authorization`.
**Remediation:** Blocklist dangerous headers:
```go
blockedHeaders := map[string]bool{
"host": true, "content-length": true, "transfer-encoding": true,
"connection": true, "upgrade": true, "proxy-authorization": true,
}
```
---
### H5 — No Request Body Size Limits [PARTIALLY FIXED]
**Location:** All Go HTTP handlers
**Impact:** Schedy and nftcache accepted arbitrarily large JSON bodies.
> [!danger] High — No Request Body Size Limits [PARTIALLY FIXED]
> Schedy and nftcache accepted arbitrarily large JSON bodies.
**Remediation:** ✅ Schedy removed (no longer applicable). nftcache still lacks body size limits; future enhancement: add `http.MaxBytesReader(w, r.Body, 1<<20)` to nftcache handlers.
**Remediation:**
```go
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit
```
---
### H6 — Docker Images from External Registry Without Verification [PARTIALLY FIXED]
**Location:** `docker-compose.yml`
**Impact:** `latest` tag was mutable; no image digest pinning.
> [!danger] High — Docker Images from External Registry Without Verification [PARTIALLY FIXED]
> `latest` tag was mutable; no image digest pinning.
**Remediation:** ✅ Schedy image removed. `mortgagefi-frontend` image still uses `:alert` tag. **Action required:** Pin to SHA256 digest:
```yaml
image: git.manko.yoga/manawenuz/mortgagefi-frontend@sha256:abc123...
```
**Remediation:** Pin to digest:
```yaml
image: git.manko.yoga/manawenuz/schedy@sha256:abc123...
```
---
### H7 — Unbounded Concurrent Background Refreshes in nftcache [OPEN]
**Location:** `nftcache/cmd/nftcache/main.go:133-151`
**Impact:** Each stale cache hit spawns a goroutine for background refresh. Under load, this creates an unbounded number of goroutines.
> [!danger] High — Unbounded Concurrent Background Refreshes in nftcache [OPEN]
> Each stale cache hit spawns a goroutine for background refresh. Under load, this creates an unbounded number of goroutines.
> **Status:** Not yet fixed.
**Status:** Not yet fixed. Future enhancement: use `sync.SingleFlight` or a worker pool to deduplicate refreshes for the same contract key.
**Remediation:** Use a `sync.SingleFlight` or worker pool to deduplicate refreshes for the same contract key.
---
### H8 — AWS Credentials Stored in Browser (SNS Provider) [FIXED]
**Location:** `mortgagefi-frontend/components/SettingsModal.tsx`
**Impact:** The UI included a form for AWS credentials stored in localStorage.
> [!danger] High — AWS Credentials Stored in Browser (SNS Provider) [FIXED]
> The UI included a form for AWS credentials stored in localStorage.
**Remediation:** ✅ SNS provider removed entirely from frontend and types. Users should use ntfy or Gotify for notifications instead.
**Remediation:** Remove SNS provider from frontend entirely, or proxy through a server-side endpoint.
---
### H9 — `secondsTillLiq` Trusts On-Chain Value Without Bounds Check [FIXED]
**Location:** `mortgagefi-frontend/app/dapp/page.tsx`
**Impact:** The auto-rescheduler computed `runAt1 = liqAt - offset` without validating bounds.
> [!danger] High — `secondsTillLiq` Trusts On-Chain Value Without Bounds Check [FIXED]
> The auto-rescheduler computed `runAt1 = liqAt - offset` without validating bounds.
**Remediation:** ✅ Added `MAX_SECONDS = 86400 * 365 * 5` (5 years). Values outside `0 < seconds <= MAX_SECONDS` are rejected.
**Remediation:**
```typescript
const MAX_SECONDS = 86400 * 365 * 5; // 5 years max
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_SECONDS) {
throw new Error('Liquidation timer out of bounds');
}
```
---
### H10 — Compound Action Uses Hardcoded Function Selector Without Validation [FIXED]
**Location:** `mortgagefi-frontend/app/dapp/page.tsx`
**Impact:** `sendTransactionAsync` was used with a hardcoded selector, bypassing ABI validation.
> [!danger] High — Compound Action Uses Hardcoded Function Selector Without Validation [FIXED]
> `sendTransactionAsync` was used with a hardcoded selector, bypassing ABI validation.
**Remediation:** ✅ Replaced `sendTransactionAsync` with `writeContractAsync` using the debt ABI. Added validation that `debtAddress` matches a known preset before allowing the transaction. `useSendTransaction` import removed.
**Remediation:** Use `writeContractAsync` with the ABI:
```typescript
await writeContractAsync({
abi: debtAbi,
address: debtAddress,
functionName: 'compound',
chainId: selectedChainId,
});
```
---
### H11 — `useSendTransaction` Available but Potentially Dangerous [FIXED]
**Location:** `mortgagefi-frontend/app/dapp/page.tsx`
**Impact:** `useSendTransaction` was imported and available.
> [!danger] High — `useSendTransaction` Available but Potentially Dangerous [FIXED]
> `useSendTransaction` was imported and available.
**Remediation:** ✅ `useSendTransaction` import removed from page.tsx. All transactions now go through `writeContractAsync` with ABI validation.
**Remediation:** Remove the import if not strictly needed, or wrap with strict validation.
---
## Medium Severity
### M1 — Contract Address Not Validated Before RPC Call
**Location:** `nftcache/internal/fetcher/rpc.go:186-210`
**Impact:** `makeRPCCall` inserts the `contract` parameter directly into JSON RPC payload without validation. A malformed address could cause RPC errors or, in the worst case, exploit a vulnerable JSON-RPC parser.
> [!warning] Medium — Contract Address Not Validated Before RPC Call
> `makeRPCCall` inserts the `contract` parameter directly into JSON RPC payload without validation. A malformed address could cause RPC errors or, in the worst case, exploit a vulnerable JSON-RPC parser.
**Remediation:** Validate contract address with regex `^0x[a-f0-9]{40}$` before use.
---
### M2 — YAML Config Has Duplicate Key
**Location:** `config/contracts.yaml`
**Impact:**
```yaml
contracts:
cbbtc:
network: base
address: "0x0987654321098765432109876543210987654321"
cbbtc: # DUPLICATE!
network: base
address: "0xab825f45e9e5d2459fb7a1527a8d0ca082c582f4"
```
YAML parsers may silently ignore the first or second entry. This could cause the wrong contract to be used.
> [!warning] Medium — YAML Config Has Duplicate Key
> A duplicate `cbbtc` key in `config/contracts.yaml`. YAML parsers may silently ignore one entry, causing the wrong contract to be used.
**Remediation:** Remove duplicate; use unique slugs (e.g., `cbbtc-v1`, `cbbtc-v2`).
---
### M3 — CORS Origin Reflection Subdomain Bypass
**Location:** `nftcache/cmd/nftcache/main.go:32-55`, `schedy/cmd/schedy/main.go:28-59`
**Impact:** The CORS middleware does exact string match on Origin. An attacker with a subdomain like `evil.mortgagefi.app` could not bypass it, but the code uses `strings.EqualFold` which is correct. However, if `CORS_ALLOW_ORIGIN` is accidentally set to `*` or left empty, CORS is completely disabled.
> [!warning] Medium — CORS Origin Reflection Subdomain Bypass
> The CORS middleware uses correct exact-match logic, but if `CORS_ALLOW_ORIGIN` is accidentally set to `*` or left empty, CORS is completely disabled.
**Remediation:** Fail startup if `CORS_ALLOW_ORIGIN` is empty or `*` in production.
---
### M4 — No Content Security Policy (CSP)
**Location:** Frontend (global)
**Impact:** Without CSP, XSS attacks have full capability to execute scripts, connect to arbitrary domains, and exfiltrate data.
> [!warning] Medium — No Content Security Policy (CSP)
> Without CSP, XSS attacks have full capability to execute scripts, connect to arbitrary domains, and exfiltrate data.
**Remediation:** Add CSP meta tag or header. Start with report-only:
```html
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src 'self' https://*.walletconnect.com wss://*.walletconnect.org https://*.llamarpc.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
```
---
### M5 — Test/Debug Endpoints and Logging in Production
**Location:** `nftcache/internal/fetcher/rpc.go:95-102`
**Impact:**
```go
// Always log token 103 regardless of debug mode
if i == 103 {
fmt.Printf("ALWAYS: Token 103 owner: %s (canonical: %s)\n", tokenOwner, canonicalOwner)
}
```
Hardcoded debug logging leaks potentially sensitive ownership data in production logs.
> [!warning] Medium — Test/Debug Endpoints and Logging in Production
> Hardcoded debug logging (token 103) leaks potentially sensitive ownership data in production logs.
**Remediation:** Remove hardcoded debug statements; use structured logging with level control.
---
### M6 — Auto-Reschedule Race Condition
**Location:** `mortgagefi-frontend/app/dapp/page.tsx:948-974`
**Impact:** The auto-reschedule `useEffect` fires an async IIFE that loops over all positions and cancels/re-creates schedules. With rapid re-renders (e.g., user rapidly switching chains), multiple overlapping executions can occur, leading to:
- Duplicate scheduled jobs
- Jobs created and immediately canceled
- Rate limit exhaustion against Schedy API
> [!warning] Medium — Auto-Reschedule Race Condition
> The auto-reschedule `useEffect` can run overlapping async executions on rapid re-renders, causing duplicate jobs, jobs created and immediately canceled, and rate limit exhaustion.
**Remediation:** Add an `isScheduling` ref/mutex to prevent concurrent executions:
```typescript
const isScheduling = useRef(false);
useEffect(() => {
if (isScheduling.current) return;
isScheduling.current = true;
(async () => { ... })().finally(() => { isScheduling.current = false; });
}, [...]);
```
---
### M7 — Missing Input Sanitization in Notification Message Builder
**Location:** `mortgagefi-frontend/app/dapp/page.tsx:838`
**Impact:** The notification message includes `positionUrl` and `collateralStr` derived from on-chain data. While unlikely, if a token symbol or contract returned malicious Unicode (e.g., RTL override characters), the message could be confusing or deceptive.
> [!warning] Medium — Missing Input Sanitization in Notification Message Builder
> Notification messages include on-chain-derived strings; malicious Unicode (e.g., RTL override characters) could make messages confusing or deceptive.
**Remediation:** Sanitize all user-visible strings before building messages.
---
### M8 — No Backup/Recovery for BadgerDB
**Location:** `data/nftcache`, `data/schedy`, `data/ntfy`
**Impact:** If the host disk fails, all scheduled tasks and NFT cache data is lost. No backup strategy is documented.
> [!warning] Medium — No Backup/Recovery for BadgerDB
> If the host disk fails, all scheduled tasks and NFT cache data is lost. No backup strategy is documented.
**Remediation:** Mount volumes from a persistent block store; schedule periodic backups.
---
### M9 — ntfy Uses Default/Weak User ID
**Location:** `docker-compose.yml:27`
**Impact:** `user: "4242:4242"` is a fixed UID/GID. If the host has a user with this ID, container files could be accessible outside the container.
> [!warning] Medium — ntfy Uses Default/Weak User ID
> `user: "4242:4242"` is a fixed UID/GID. If the host has a user with this ID, container files could be accessible outside the container.
**Remediation:** Use a randomly generated high UID (e.g., `65534:nogroup`).
---
### M10 — Frontend Lacks Integrity Checks for Submodules
**Location:** `.gitmodules`
**Impact:** The `schedy` submodule is not pinned to a specific commit hash in documentation. A compromised submodule repository could inject malicious code.
> [!warning] Medium — Frontend Lacks Integrity Checks for Submodules
> The `schedy` submodule is not pinned to a specific commit hash in documentation. A compromised submodule repository could inject malicious code.
**Remediation:** Pin submodule to a verified commit hash and verify in CI.
---
### M11 — `NFTCACHE_TTL` Default is 10 Minutes in `.env.local`
**Location:** `.env.local`
**Impact:** The default TTL is `10m`, causing very frequent background refreshes and unnecessary RPC load.
> [!warning] Medium — `NFTCACHE_TTL` Default is 10 Minutes in `.env.local`
> The default TTL is `10m`, causing very frequent background refreshes and unnecessary RPC load.
**Remediation:** Set to `24h` or longer; make it configurable per contract.
---
### M12 — Schedy DeleteTask Performance Issue
**Location:** `mortgagefi-frontend/submodules/schedy/internal/api/handler.go:110-142`
**Impact:** Deleting a task requires a **full table scan** (`ListTasks()`) to find the timestamp. With thousands of tasks, this is O(n) and blocks other operations.
> [!warning] Medium — Schedy DeleteTask Performance Issue
> Deleting a task requires a **full table scan** (`ListTasks()`) to find the timestamp. With thousands of tasks, this is O(n) and blocks other operations.
**Remediation:** Add a secondary index or use a key-only lookup.
---
## Low Severity / Informational
### L1 — `handleApprove` Approves Exact User Input, Not Max
**Location:** `mortgagefi-frontend/app/dapp/page.tsx:708-725`
> [!info] Low — `handleApprove` Approves Exact User Input, Not Max
> The approve function approves exactly the amount the user typed. This is UX-safe (no unlimited approvals) but requires two transactions for each new amount.
**Note:** The approve function approves exactly the amount the user typed. This is UX-safe (no unlimited approvals) but requires two transactions for each new amount.
### L2 — `enableMainnet` Feature Flag Could Be Accidentally Enabled
**Location:** `mortgagefi-frontend/app/dapp/page.tsx:24`
> [!info] Low — `enableMainnet` Feature Flag Could Be Accidentally Enabled
> Mainnet support is gated by an env var. If accidentally set to `true`, dummy placeholder addresses (`0x000...0001`) would be used, which is safer than real ones.
**Note:** Mainnet support is gated by an env var. If accidentally set to `true`, dummy placeholder addresses (`0x000...0001`) would be used, which is safer than real ones.
### L3 — `NFTCACHE_CONFIG` Path Not Validated
**Location:** `nftcache/cmd/nftcache/main.go:263-264`
> [!info] Low — `NFTCACHE_CONFIG` Path Not Validated
> If `NFTCACHE_CONFIG` is set to an arbitrary path, the service reads it. With `ro` mount this is mitigated, but direct file path injection is theoretically possible.
**Note:** If `NFTCACHE_CONFIG` is set to an arbitrary path, the service reads it. With `ro` mount this is mitigated, but direct file path injection is theoretically possible.
### L4 — No Health Check Endpoints
**Location:** All services
> [!info] Low — No Health Check Endpoints
> No `/health` or `/ready` endpoints for Docker/Kubernetes health checks.
**Note:** No `/health` or `/ready` endpoints for Docker/Kubernetes health checks.
### L5 — Verbose Console Logging in Production
**Location:** `mortgagefi-frontend/app/dapp/page.tsx` (throughout)
> [!info] Low — Verbose Console Logging in Production
> Extensive `console.log` statements leak internal state (cache keys, wallet addresses, token IDs) to browser DevTools.
**Note:** Extensive `console.log` statements leak internal state (cache keys, wallet addresses, token IDs) to browser DevTools.
---
## Remediation Priority Matrix
| Priority | Issue | Effort | Impact |
|----------|-------|--------|--------|
| **P0** | C2 — Rotate secrets & purge from Git | 1h | Critical |
| **P0** | C3 — Remove NEXT_PUBLIC_ from secrets | 2h | Critical |
| **P0** | C1 — Add SSRF protection to Schedy | 4h | Critical |
| **P1** | C4 — Stop storing credentials in localStorage | 1d | High |
| **P1** | C5 — Fix address canonicalization | 2h | High |
| **P1** | C6 — Require API auth on nftcache | 2h | High |
| **P1** | C7 — Validate RPC URLs | 2h | High |
| **P1** | H3 — Close direct container ports | 30m | High |
| **P2** | H1 — Add HTTPS/TLS | 4h | High |
| **P2** | H2 — Add security headers | 1h | Medium |
| **P2** | H10 — Fix compound transaction | 1h | Medium |
| **P3** | M4 — Implement CSP | 4h | Medium |
| **P3** | H6 — Pin Docker image digests | 1h | Medium |
| **P3** | M6 — Fix auto-reschedule race | 2h | Medium |
---
## Verification Checklist (Post-Remediation)
- [ ] `.env` and `.env.local` are in `.gitignore` and purged from Git history
- [ ] No `NEXT_PUBLIC_` variables contain secrets
- [ ] Schedy rejects URLs with private IPs, loopback, and internal hostnames
- [ ] nftcache requires `X-API-Key` and rejects requests without it
- [ ] `canonAddr` rejects invalid/too-long/too-short addresses
- [ ] Only nginx port (80/443) is exposed in Docker Compose
- [ ] HTTPS is enforced with valid certificates
- [ ] Security headers (CSP, HSTS, X-Frame-Options) are present
- [ ] AWS credentials removed from frontend entirely
- [ ] RPC URLs from localStorage are validated against an allowlist
- [ ] All hardcoded secrets rotated and replaced with environment injection
- [ ] Docker images pinned to SHA256 digests
- [ ] Rate limiting implemented on all public endpoints
---
## Related
- [[Home]]
- [[Architecture]]
- [[Performance Audit]]
- [[Project Audit 2026-06]]

64
docs/Home.md Normal file
View File

@@ -0,0 +1,64 @@
---
title: Home
tags: [mortgagefi, moc]
type: moc
status: stable
updated: 2026-06-14
---
# 🌾 MortgageFi — Documentation Vault
> [!info] This is an [Obsidian](https://obsidian.md) vault
> Open the `docs/` folder as a vault to get backlinks, the graph view, and live `[[wikilinks]]`. Every note carries YAML frontmatter (`title`, `tags`, `type`, `status`, `updated`) so you can query it with Dataview.
MortgageFi is a decentralized mortgage-lending platform. Users deposit ERC-721 collateral into audited debt vaults, borrow stablecoins against crypto collateral (cbBTC, WETH, WBTC), pay down debt on-chain to extend liquidation timers, and receive email/push alerts before liquidation.
## 🗺️ Map of Content
### Architecture
- [[Architecture]] — system overview, component diagrams, data flows
- [[API Reference]] — REST endpoints for nftcache, the scheduler, and ntfy
### Operations
- [[Deployment]] — Docker Compose, Vercel, and standalone deploys
- [[Development]] — local setup, adding chains, testing, troubleshooting
- [[Migration Notes]] — library/version upgrade notes & rollback plan
- [[Alert Changes]] — notification & scheduling behaviour history
### Audits
- [[Security Audit]] — findings, severities, remediations
- [[Performance Audit]] — bottlenecks and optimization opportunities
- [[Project Audit 2026-06]] — current full-project health review
## 🧭 Architecture at a glance
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Next.js │────▶│ Scheduler API │────▶│ ntfy │
│ Frontend │ │ (Vercel + Redis)│ │(push/email) │
└──────┬──────┘ └──────────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ nftcache │────▶│ Blockchain │
│(NFT scanner)│ │(Base/Arb) │
└─────────────┘ └─────────────┘
```
## 🧱 Tech stack
| Component | Technology |
|-----------|-----------|
| Frontend | Next.js 16, React 19, TypeScript, Tailwind CSS 4, wagmi 3 |
| NFT Cache | Go 1.22, BadgerDB |
| Scheduler | Next.js API Routes, Upstash Redis |
| Notifications | ntfy (self-hosted) |
| Proxy | nginx |
| Orchestration | Docker Compose |
## 🏷️ Tag index
`#mortgagefi` · `#architecture` · `#api` · `#ops` · `#audit` · `#security` · `#performance`
## Related
[[Architecture]] · [[API Reference]] · [[Deployment]] · [[Development]] · [[Security Audit]] · [[Performance Audit]] · [[Project Audit 2026-06]]

View File

@@ -0,0 +1,107 @@
---
title: Alert Changes
tags: [mortgagefi, ops, notifications]
type: operations
status: reference
updated: 2026-06-14
---
# Alert Changes
Alert branch: notification and scheduling changes
This branch introduces a singlealert scheduling flow, clearer alert copy, and an ntfy email setup via Docker Compose env vars.
## Changes
- Single scheduled alert per position
- Replaced multi-job scheduling with `scheduleNotification(row)` that returns `{ jobId, runAt }`.
- Toggle stores `{ enabled, jobId, scheduledAt }`.
- Auto-rescheduler keeps a single job in sync.
- Message improvements
- Time window shows the configured lead offset (e.g., `~10d`) instead of remaining time at execution.
- Payment guidance prioritizes Debt Remaining; fallbacks: 1.5× monthly, then current payment pending.
- Collateral at risk uses the same formatting as the UI: `fmt(row.coinSize, coinDecimals, 8)` + token symbol (e.g., `0.05651738 cbBTC`).
- Test alert sends email
- `scheduleTestNotification()` now includes `X-Email` from settings to trigger ntfy email in addition to topic publish.
- ntfy SMTP via Docker Compose
- Configure Gmail/App Password or another SMTP via env vars in compose instead of a server.yml.
## Files modified
- `mortgagefi-frontend/app/dapp/page.tsx`
- Toggle handler uses `scheduleNotification()` and stores `{ jobId, scheduledAt }`.
- `scheduleNotification()` builds improved message and schedules one Schedy job.
- `mortgagefi-frontend/utils/scheduler.ts`
- `scheduleTestNotification()` adds `X-Email` header.
- `docker-compose.yml` (root)
- ntfy service accepts SMTP settings via env vars (with defaults). You can also pass them via `env_file`.
## Configure SMTP (ntfy)
Set these in `.env` or `.env.local` (compose resolves `${VAR}` from `.env` or shell; using `env_file`, list bare keys in `environment:` to pass-through values):
```
NTFY_BASE_URL=https://web.example.com/ntfy # must match your public URL & subpath
NTFY_SMTP_SENDER_ADDR=smtp.gmail.com:587 # or smtp-relay / your SMTP
NTFY_SMTP_SENDER_USER=your.name@gmail.com # Gmail address or relay user
NTFY_SMTP_SENDER_PASS=app-password-xxxx # Gmail App Password
NTFY_SMTP_SENDER_FROM=your.name@gmail.com # From address
NTFY_LOG_LEVEL=info
```
Compose snippet (Option A: pass-through via env_file):
```yaml
services:
ntfy:
image: binwiederhier/ntfy
command: ["serve"]
env_file: .env.local
environment:
- TZ=Europe/Zurich
- NTFY_BASE_URL
- NTFY_SMTP_SENDER_ADDR
- NTFY_SMTP_SENDER_USER
- NTFY_SMTP_SENDER_PASS
- NTFY_SMTP_SENDER_FROM
- NTFY_LOG_LEVEL
```
## Bring up services
1) Populate `.env` or `.env.local` with:
- `NEXT_PUBLIC_NTFY_URL=/ntfy`
- `NEXT_PUBLIC_SCHEDY_URL=/schedy`
- `NEXT_PUBLIC_SCHEDY_API_KEY=<schedy_api_key>`
- SMTP variables above.
2) Start stack from repo root:
```
docker compose up -d
```
3) Access:
- App: `http://localhost`
- ntfy (proxied): `http://localhost/ntfy`
- schedy (proxied): `http://localhost/schedy`
## Verify email path
- Settings → Provider: ntfy, set Server+Topic, Email, Scheduler: Schedy + API key.
- Click “Send test alert”. Expect both topic message and email.
- Manual test:
```
curl -X POST -H 'Content-Type: text/plain' -H 'X-Email: your.name@gmail.com' \
https://web.example.com/ntfy/yourtopic -d 'Email test via ntfy SMTP'
```
## Notes
> [!note]
> - Ensure `NTFY_BASE_URL` includes `/ntfy` if you serve ntfy under a subpath.
> - For compose variable interpolation with `${VAR}`, put values in `.env` (not `.env.local`) or export them in the shell. Using `env_file`, prefer pass-through keys as shown above.
## Related
[[Home]], [[Architecture]], [[API Reference]], [[Development]]

View File

@@ -0,0 +1,252 @@
---
title: Deployment
tags: [mortgagefi, ops, deployment]
type: operations
status: stable
updated: 2026-06-14
---
# Deployment
## Prerequisites
- Docker Engine 24.0+ and Docker Compose v2
- Node.js 20+ (for frontend development only)
- Git with submodule support
## Environment Setup
Create `.env.local` in the project root:
```bash
# WalletConnect (required for frontend)
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-project-id
# RPC endpoints (optional — defaults to public LlamaRPC)
NEXT_PUBLIC_RPC_BASE=https://base.llamarpc.com
NEXT_PUBLIC_RPC_ARBITRUM=https://arb.llamarpc.com
# Internal service URLs (use relative paths when behind nginx proxy)
NEXT_PUBLIC_NTFY_URL=/ntfy
NEXT_PUBLIC_SCHEDY_URL=/schedy
NEXT_PUBLIC_NFTCACHE_URL=/nftcache
# Schedy API key (must match server-side SCHEDY_API_KEY)
NEXT_PUBLIC_SCHEDY_API_KEY=your-random-hex-key
SCHEDY_API_KEY=your-random-hex-key
# nftcache API key (must match server-side NFTCACHE_API_KEY)
NFTCACHE_API_KEY=your-random-hex-key
# nftcache TTL
NFTCACHE_TTL=24h
# ntfy SMTP configuration
NTFY_BASE_URL=https://your-domain.com/ntfy
NTFY_SMTP_SENDER_ADDR=smtp.gmail.com:587
NTFY_SMTP_SENDER_USER=your.email@gmail.com
NTFY_SMTP_SENDER_PASS=your-app-password
NTFY_SMTP_SENDER_FROM=your.email@gmail.com
NTFY_LOG_LEVEL=info
# CORS (set to your frontend domain)
CORS_ALLOW_ORIGIN=https://your-domain.com
CORS_ALLOW_METHODS=GET,POST,DELETE,OPTIONS
CORS_ALLOW_HEADERS=Content-Type,X-API-Key
CORS_MAX_AGE=600
# RPC URLs for nftcache backend scanning
ETH_RPC_URL=https://eth.llamarpc.com
ARB_RPC_URL=https://arb.llamarpc.com
BASE_RPC_URL=https://base.llamarpc.com
```
Generate strong API keys:
```bash
openssl rand -hex 32
```
> [!warning] Key consistency
> `NEXT_PUBLIC_SCHEDY_API_KEY` must match the server-side `SCHEDY_API_KEY`, and `NFTCACHE_API_KEY` must match its server-side counterpart. Mismatched keys cause authentication failures.
---
## Full Stack Deployment (Docker Compose)
### 1. Clone and Initialize
```bash
git clone <repository>
cd mortgageFi
git submodule update --init --recursive
```
### 2. Configure
```bash
cp .env.example .env.local # if available, or create manually
# Edit .env.local with your values
```
### 3. Start Services
```bash
docker compose up -d
```
This starts:
- `frontend` — Next.js app (internal port 3000)
- `web` — nginx proxy (port 80)
- `ntfy` — notification server (internal port 80)
- `schedy` — task scheduler (port 8080)
- `nftcache` — NFT cache (port 8090)
### 4. Verify
```bash
# Check all containers are running
docker compose ps
# View logs
docker compose logs -f frontend
docker compose logs -f nftcache
docker compose logs -f schedy
# Test nftcache
curl "http://localhost/nftcache/nfts?network=base&nft_contract=cbbtc&user_wallet=0x..."
# Test Schedy
curl -X POST http://localhost/schedy/tasks \
-H "Content-Type: application/json" \
-H "X-API-Key: $SCHEDY_API_KEY" \
-d '{"url":"https://httpbin.org/post","execute_at":"2026-12-31T23:59:59Z","payload":"test"}'
# Test ntfy
curl -X POST http://localhost/ntfy/test \
-H "Content-Type: text/plain" \
-d "Hello from MortgageFi"
```
### 5. Access Application
Open `http://localhost` in your browser.
---
## Frontend-Only Deployment (Vercel)
For deploying just the Next.js frontend to Vercel:
### 1. Project Settings
- **Framework Preset:** Next.js
- **Root Directory:** `mortgagefi-frontend/`
- **Build Command:** `next build --turbopack`
- **Output Directory:** `.next`
### 2. Environment Variables
Add these in the Vercel dashboard:
```
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-project-id
NEXT_PUBLIC_RPC_BASE=https://base.llamarpc.com
NEXT_PUBLIC_NTFY_URL=https://your-ntfy-server.com
NEXT_PUBLIC_SCHEDY_URL=https://your-schedy-server.com
NEXT_PUBLIC_SCHEDY_API_KEY=your-key
NEXT_PUBLIC_NFTCACHE_URL=https://your-nftcache-server.com
```
### 3. Backend Services
You must deploy the backend services separately and point the frontend to them:
- **nftcache:** Deploy as a Docker container or Go binary
- **schedy:** Deploy as a Docker container or Go binary
- **ntfy:** Use ntfy.sh cloud or self-host
### 4. Gitea Integration
> [!note] Gitea is not natively supported by Vercel
> Vercel does not natively support Gitea. Options:
> - Mirror the repository to GitHub/GitLab/Bitbucket
> - Or use the Vercel CLI for manual deploys:
```bash
cd mortgagefi-frontend
npm install -g vercel
vercel --prod
```
---
## nftcache Standalone Deployment
### Docker
```bash
cd nftcache
docker build -t nftcache .
docker run -d \
-p 8090:8090 \
-v $(pwd)/data:/data \
-v $(pwd)/config:/config:ro \
-e NFTCACHE_API_KEY=your-key \
-e NFTCACHE_TTL=24h \
-e BASE_RPC_URL=https://base.llamarpc.com \
-e NFTCACHE_CONFIG=/config/contracts.yaml \
nftcache
```
### Binary
```bash
cd nftcache
go build -o nftcache ./cmd/nftcache
./nftcache
```
---
## Schedy Standalone Deployment
### Docker
```bash
cd mortgagefi-frontend/submodules/schedy
docker build -t schedy .
docker run -d \
-p 8080:8080 \
-v $(pwd)/data:/data \
-e SCHEDY_API_KEY=your-key \
schedy
```
### Binary
```bash
cd mortgagefi-frontend/submodules/schedy
go build -o schedy ./cmd/schedy
./schedy -port 8080
```
---
## Production Checklist
- [ ] Change all default API keys to cryptographically random values
- [ ] Configure HTTPS (use a reverse proxy like Traefik or Cloudflare)
- [ ] Set `CORS_ALLOW_ORIGIN` to your exact frontend domain (not `*`)
- [ ] Enable mainnet only if explicitly required (`NEXT_PUBLIC_ENABLE_MAINNET=true`)
- [ ] Configure reliable RPC endpoints (avoid public endpoints for high traffic)
- [ ] Set up log aggregation and monitoring
- [ ] Back up BadgerDB data directories (`data/nftcache`, `data/schedy`, `data/ntfy`)
- [ ] Configure ntfy SMTP with a proper transactional email service
- [ ] Test end-to-end notification flow before going live
> [!warning] Before going to production
> Never ship with default API keys or a wildcard (`*`) CORS origin, and confirm the end-to-end notification flow works before going live.
## Related
[[Home]], [[Architecture]], [[Development]], [[Migration Notes]]

View File

@@ -0,0 +1,366 @@
---
title: Development
tags: [mortgagefi, ops, development]
type: operations
status: stable
updated: 2026-06-14
---
# Development
## Repository Structure
```
mortgageFi/
├── mortgagefi-frontend/ # Next.js DApp
│ ├── app/ # App Router pages
│ │ ├── dapp/page.tsx # Main DApp interface
│ │ ├── dapp/position/... # Deep-link position pages
│ │ ├── layout.tsx # Root layout with Web3Provider
│ │ └── page.tsx # Landing page
│ ├── components/ # React components
│ │ ├── ConnectButton.tsx
│ │ ├── Navbar.tsx
│ │ └── SettingsModal.tsx # Notification settings
│ ├── providers/
│ │ └── Web3Provider.tsx # Wagmi + QueryClient setup
│ ├── utils/
│ │ ├── scheduler.ts # Schedy API client
│ │ ├── useLocalStorage.ts # localStorage hook
│ │ ├── format.ts # Number formatting
│ │ └── cronhost.ts # Legacy cronhost support
│ ├── config/
│ │ └── web3.ts # Wagmi chain config
│ ├── types/
│ │ └── notifications.ts # TypeScript types
│ ├── ABIs/
│ │ └── mortgagefiusdccbbtcupgraded.json
│ └── submodules/
│ └── schedy/ # Go scheduler (Git submodule)
├── nftcache/ # Go NFT ownership cache
│ ├── cmd/nftcache/main.go
│ └── internal/
│ ├── config/config.go # YAML contract config
│ ├── fetcher/
│ │ ├── rpc.go # RPC scanning with rate limits
│ │ └── alchemy.go # Alchemy API fallback
│ └── store/store.go # BadgerDB persistence
├── config/
│ └── contracts.yaml # Contract address mappings
├── nginx/
│ └── nginx.conf # Reverse proxy config
├── docker-compose.yml # Full stack orchestration
├── .env / .env.local # Environment variables
├── ALERT_CHANGES.md # Alert feature changelog
└── MIGRATION_NOTES.md # Dependency upgrade notes
```
---
## Frontend Development
### Setup
```bash
cd mortgagefi-frontend
npm install
```
### Run Dev Server
```bash
npm run dev
# Opens on http://localhost:3000
```
> [!info]
> Turbopack is enabled by default for faster builds.
### Build for Production
```bash
npm run build
npm start
```
### Lint
```bash
npm run lint
```
### Environment Variables
Create `mortgagefi-frontend/.env.local`:
```bash
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your-project-id
NEXT_PUBLIC_RPC_BASE=https://base.llamarpc.com
```
> [!note]
> See `.env` in the repo root for the full variable list.
---
## Backend Development
### nftcache
```bash
cd nftcache
# Run
go run ./cmd/nftcache
# Build
go build -o nftcache ./cmd/nftcache
./nftcache
# With custom env
NFTCACHE_API_KEY=test NFTCACHE_TTL=1h go run ./cmd/nftcache
```
**Test the API:**
```bash
# After starting, test with:
curl "http://localhost:8090/nfts?network=base&nft_contract=cbbtc&user_wallet=0x..."
```
### schedy
```bash
cd mortgagefi-frontend/submodules/schedy
# Run
go run ./cmd/schedy
# Build
go build -o schedy ./cmd/schedy
./schedy -port 8080
```
**Test the API:**
```bash
# Create a task
curl -X POST http://localhost:8080/tasks \
-H "Content-Type: application/json" \
-H "X-API-Key: test" \
-d '{
"url": "https://httpbin.org/post",
"execute_at": "2026-12-31T23:59:59Z",
"payload": "test"
}'
# List tasks
curl http://localhost:8080/tasks -H "X-API-Key: test"
```
---
## Full Stack Local Development
Run all services together with Docker Compose:
```bash
# From repo root
docker compose up -d
# Watch logs
docker compose logs -f
# Restart a single service
docker compose restart frontend
docker compose restart nftcache
# Rebuild after code changes
docker compose up -d --build frontend
docker compose up -d --build nftcache
```
The nginx proxy exposes everything on `http://localhost`:
- `/` — Next.js frontend
- `/ntfy/` — ntfy web UI and API
- `/schedy/` — Schedy API
- `/nftcache/` — nftcache API
---
## Adding a New Chain/Preset
### 1. Update Frontend (`mortgagefi-frontend/app/dapp/page.tsx`)
Add chain defaults:
```typescript
const DEFAULTS = {
[base.id]: { nft: '0x...', debt: '0x...' },
[arbitrum.id]: { nft: '0x...', debt: '0x...' },
[newChain.id]: { nft: '0x...', debt: '0x...' },
};
```
Add presets:
```typescript
const PRESETS = {
[newChain.id]: [
{ key: 'PAIR-QUOTE', label: 'PAIR-QUOTE', nft: '0x...', debt: '0x...' },
],
};
```
### 2. Update Web3 Config (`mortgagefi-frontend/config/web3.ts`)
```typescript
import { newChain } from 'wagmi/chains';
export const config = createConfig({
chains: [base, arbitrum, newChain],
transports: {
[newChain.id]: http('https://newchain.rpc.com'),
},
});
```
### 3. Update nftcache (`config/contracts.yaml`)
```yaml
contracts:
mypreset:
network: newchain
address: "0x..."
max_token_id: "10000"
```
### 4. Add RPC to nftcache environment
```bash
NEWCHAIN_RPC_URL=https://newchain.rpc.com
```
Update `nftcache/cmd/nftcache/main.go` to read the new env var.
---
## Testing Notifications End-to-End
### 1. Configure Settings in UI
Open the DApp, click Settings (gear icon):
- **Provider:** ntfy
- **Server:** `/ntfy` (or your ntfy URL)
- **Topic:** `mortgagefi-test`
- **Email:** your email address
- **Scheduler:** Schedy
- **Schedy URL:** `/schedy`
- **Schedy API Key:** your key
### 2. Send Test Alert
Click "Send test alert" in Settings.
### 3. Verify
Check:
- ntfy web UI at `http://localhost/ntfy/` — message should appear
- Your email inbox — message should arrive within seconds (or 2 minutes for Schedy tests)
### 4. Manual Test via cURL
```bash
# Direct ntfy test
curl -X POST http://localhost/ntfy/mortgagefi-test \
-H "Content-Type: text/plain" \
-H "X-Email: you@example.com" \
-d "Manual test"
# Schedy + ntfy test
curl -X POST http://localhost/schedy/tasks \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{
"url": "http://localhost/ntfy/mortgagefi-test",
"headers": {"Content-Type":"text/plain","X-Email":"you@example.com"},
"payload": "Scheduled test",
"execute_at": "'$(date -u -v+2M +%Y-%m-%dT%H:%M:%SZ)'"
}'
```
---
## Common Issues
### 429 Rate Limit Errors
> [!warning] RPC returns "Too Many Requests" during NFT scanning.
**Solutions:**
- Use a private RPC endpoint (Infura, Alchemy, QuickNode)
- Reduce `NFTCACHE_TTL` to reduce background refresh frequency
- Enable nftcache in frontend settings to offload scanning
### Wallet Connection Fails
> [!warning] WalletConnect modal doesn't appear.
**Solutions:**
- Verify `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` is set
- Check that the project ID is valid at https://cloud.walletconnect.com
### Schedy Tasks Not Executing
> [!warning] Scheduled alerts never fire.
**Debugging:**
```bash
# List pending tasks
curl http://localhost/schedy/tasks -H "X-API-Key: your-key"
# Check schedy logs
docker compose logs -f schedy
```
**Common causes:**
- Schedy container clock drift (ensure NTP is enabled)
- Task deleted before execution (check auto-reschedule logic)
- ntfy URL unreachable from Schedy container
### CORS Errors
> [!warning] Browser blocks API calls to Schedy or nftcache.
**Solution:**
Ensure `CORS_ALLOW_ORIGIN` matches your frontend URL exactly, including protocol:
```bash
CORS_ALLOW_ORIGIN=https://mortgagefi.example.com
```
---
## Git Submodules
The `schedy` project is included as a Git submodule:
```bash
# Initialize on fresh clone
git submodule update --init --recursive
# Pull latest submodule changes
git submodule update --remote
# Commit submodule pin
cd mortgagefi-frontend/submodules/schedy
git checkout main
git pull
cd ../../..
git add mortgagefi-frontend/submodules/schedy
git commit -m "Update schedy submodule"
```
---
## Related
- [[Home]]
- [[Architecture]]
- [[API Reference]]
- [[Deployment]]

View File

@@ -0,0 +1,74 @@
---
title: Migration Notes
tags: [mortgagefi, ops, migration]
type: operations
status: reference
updated: 2026-06-14
---
# Migration Notes
Library Update Migration Notes
## Security Updates Applied
### Critical Fix
- **Next.js 15.5.0 → 16.0.10**: Fixed critical RCE vulnerability (CVE-2024-XXXX)
## Major Version Updates
### wagmi v2 → v3
> [!warning] Potential Breaking Changes
> - Check if `useConnect` hook API has changed
> - Verify `useAccount` return values are still compatible
> - Review connector configuration in `config/web3.ts`
> - Test wallet connection flows
### @types/node v20 → v25
> [!note] Potential Issues
> - TypeScript compilation may show new type errors
> - Node.js API type definitions may have changed
### Next.js v15 → v16
> [!note] Changes to Monitor
> - App Router behavior changes
> - Build process modifications
> - Runtime behavior differences
## Testing Checklist
After running `npm install`:
1. **Build Test**: `npm run build`
2. **Type Check**: `npx tsc --noEmit`
3. **Lint Check**: `npm run lint`
4. **Wallet Connection**: Test all wallet providers
5. **Contract Interactions**: Verify all smart contract calls work
6. **Chain Switching**: Test network switching functionality
## Rollback Plan
> [!warning] Rollback Plan
> If issues occur, revert to previous versions:
> ```json
> {
> "next": "15.5.9",
> "wagmi": "^2.19.5",
> "@types/node": "^20.19.27"
> }
> ```
## Manual Steps Required
1. Run `npm install` to update dependencies
2. Test the application thoroughly
3. Check for any TypeScript errors
4. Verify wallet connectivity still works
5. Test contract interactions on all supported chains
## Related
[[Home]], [[Deployment]], [[Development]]

60
docs/README.md Normal file
View File

@@ -0,0 +1,60 @@
---
title: Docs README
tags: [mortgagefi, moc]
type: moc
status: stable
updated: 2026-06-14
---
# MortgageFi Documentation
This folder is an **Obsidian vault**. Open it in [Obsidian](https://obsidian.md) for backlinks, the graph view, and live wikilinks — or browse the Markdown directly on Git.
> [!tip] Start at [[Home]]
> [[Home]] is the Map of Content with links to every note.
## Quick links
### Architecture
- [[Architecture]] — system overview, component diagrams, and data flows
- [[API Reference]] — REST API for nftcache, the scheduler, and ntfy
### Operations
- [[Deployment]] — Docker Compose, Vercel, and standalone deployment guides
- [[Development]] — local setup, adding chains, testing, troubleshooting
- [[Migration Notes]] — library upgrade notes & rollback plan
- [[Alert Changes]] — notification & scheduling behaviour history
### Audits
- [[Security Audit]] · [[Performance Audit]] · [[Project Audit 2026-06]]
## What is MortgageFi?
A decentralized mortgage-lending platform that lets users:
- **Deposit NFT collateral** into audited debt vaults
- **Borrow stablecoins** against crypto collateral (cbBTC, WETH, WBTC)
- **Pay down debt** on-chain to extend liquidation timers
- **Receive alerts** via email and push notifications before liquidation
## Folder layout
```
docs/
├── Home.md ← Map of Content (start here)
├── Architecture/
│ ├── Architecture.md
│ └── API Reference.md
├── Operations/
│ ├── Deployment.md
│ ├── Development.md
│ ├── Migration Notes.md
│ └── Alert Changes.md
└── Audits/
├── Security Audit.md
├── Performance Audit.md
└── Project Audit 2026-06.md
```
## Related
[[Home]] · [[Architecture]] · [[Deployment]] · [[Development]]

19
lib/redis.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Redis } from '@upstash/redis';
// Upstash Redis client for serverless/Vercel compatibility.
// For self-hosted Docker, run redis-rest-proxy or use Upstash Cloud free tier.
// Env vars: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
export const redis = url && token
? new Redis({ url, token })
: null;
export function ensureRedis() {
if (!redis) {
throw new Error('Redis is not configured. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.');
}
return redis;
}

95
lib/ssrf-guard.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* SSRF Guard — prevents tasks from targeting internal/private addresses.
* Used when creating scheduled tasks to ensure webhooks only hit allowed targets.
*/
const BLOCKED_HOSTS = new Set([
'localhost',
'nftcache',
'schedy',
'ntfy',
'frontend',
'web',
'redis',
'db',
'postgres',
'mysql',
'mongo',
]);
const BLOCKED_HEADERS = new Set([
'host',
'content-length',
'transfer-encoding',
'connection',
'upgrade',
'proxy-authorization',
'proxy-authenticate',
]);
export function isAllowedUrl(urlStr: string): { ok: true } | { ok: false; reason: string } {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
return { ok: false, reason: 'Invalid URL' };
}
// Enforce HTTPS in production; allow HTTP only for localhost dev
const isDev = process.env.NODE_ENV === 'development';
if (parsed.protocol !== 'https:' && !(isDev && parsed.hostname === 'localhost')) {
return { ok: false, reason: 'URL must use HTTPS' };
}
const hostname = parsed.hostname.toLowerCase();
// Block internal Docker / service hostnames
if (BLOCKED_HOSTS.has(hostname)) {
return { ok: false, reason: 'Blocked internal hostname' };
}
// Block IPv4 private/reserved ranges
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const octets = ipv4Match.slice(1).map(Number);
if (octets.some((o) => o > 255)) {
return { ok: false, reason: 'Invalid IP address' };
}
const [a, b, c, d] = octets;
// 10.0.0.0/8
if (a === 10) return { ok: false, reason: 'Private IP range' };
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return { ok: false, reason: 'Private IP range' };
// 192.168.0.0/16
if (a === 192 && b === 168) return { ok: false, reason: 'Private IP range' };
// 127.0.0.0/8
if (a === 127) return { ok: false, reason: 'Loopback IP' };
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return { ok: false, reason: 'Link-local IP' };
// 0.0.0.0/8
if (a === 0) return { ok: false, reason: 'Reserved IP' };
}
// Block IPv6 loopback / private indicators
if (hostname === '::1' || hostname === '::' || hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) {
return { ok: false, reason: 'IPv6 private/loopback' };
}
// Block metadata endpoints
if (hostname === '169.254.169.254') {
return { ok: false, reason: 'Cloud metadata endpoint' };
}
return { ok: true };
}
export function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
const lower = k.toLowerCase();
if (!BLOCKED_HEADERS.has(lower)) {
out[k] = v;
}
}
return out;
}

91
lib/task-store.ts Normal file
View File

@@ -0,0 +1,91 @@
import { ensureRedis, redis } from './redis';
export interface Task {
id: string;
url: string;
executeAt: number; // epoch seconds
headers: Record<string, string>;
payload: any;
retries: number;
retryInterval: number; // milliseconds
createdAt: number;
}
const TASK_PREFIX = 'mortgagefi:task:';
const TASK_ZSET = 'mortgagefi:tasks:by:time';
const RATE_LIMIT_PREFIX = 'mortgagefi:ratelimit:';
export async function saveTask(task: Task): Promise<void> {
const r = ensureRedis();
const pipe = r.pipeline();
pipe.set(`${TASK_PREFIX}${task.id}`, JSON.stringify(task));
pipe.zadd(TASK_ZSET, { score: task.executeAt, member: task.id });
await pipe.exec();
}
export async function getTask(id: string): Promise<Task | null> {
const r = ensureRedis();
const data = await r.get<string>(`${TASK_PREFIX}${id}`);
if (!data) return null;
try {
return JSON.parse(data) as Task;
} catch {
return null;
}
}
export async function deleteTask(id: string): Promise<void> {
const r = ensureRedis();
const pipe = r.pipeline();
pipe.del(`${TASK_PREFIX}${id}`);
pipe.zrem(TASK_ZSET, id);
await pipe.exec();
}
export async function listDueTasks(before: number): Promise<Task[]> {
const r = ensureRedis();
const ids = await r.zrange<string[]>(TASK_ZSET, 0, before, { byScore: true });
if (!ids || ids.length === 0) return [];
const tasks: Task[] = [];
for (const id of ids) {
const t = await getTask(id);
if (t) tasks.push(t);
}
return tasks;
}
export async function listAllTasks(): Promise<Task[]> {
const r = ensureRedis();
const keys = await r.keys(`${TASK_PREFIX}*`);
if (!keys || keys.length === 0) return [];
const tasks: Task[] = [];
for (const key of keys) {
const data = await r.get<string>(key);
if (data) {
try {
tasks.push(JSON.parse(data) as Task);
} catch { /* ignore malformed */ }
}
}
return tasks.sort((a, b) => a.executeAt - b.executeAt);
}
// Simple per-IP rate limiter using Redis
export async function checkRateLimit(ip: string, maxRequests: number, windowSeconds: number): Promise<boolean> {
if (!redis) return true; // if redis not configured, allow (dev mode)
const key = `${RATE_LIMIT_PREFIX}${ip}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
const pipe = redis.pipeline();
pipe.zremrangebyscore(key, 0, windowStart);
pipe.zcard(key);
pipe.zadd(key, { score: now, member: `${now}:${Math.random()}` });
pipe.expire(key, windowSeconds + 1);
const results = await pipe.exec();
const count = (results?.[1] as number) || 0;
return count < maxRequests;
}