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>
676 lines
20 KiB
Markdown
676 lines
20 KiB
Markdown
---
|
||
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]]
|