Files
mortgagefi-helper/docs/Audits/Performance Audit.md
Siavash Sameni 6ae581ab2e 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>
2026-06-14 08:13:53 +04:00

676 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]]