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

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]]