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

20 KiB
Raw Permalink Blame History

title, tags, type, status, updated
title tags type status updated
Performance Audit
mortgagefi
audit
performance
audit reference 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:

// 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:
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]);
  1. 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:

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

// 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:

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:

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

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:

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:

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:

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

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:

[
  {"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:

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:

http: &http.Client{}, // default — no connection reuse tuning

[!tip] Remediation

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:

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_calls. 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:

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:

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:

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:

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:

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:

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.

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:

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:
    // 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:

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

// config/web3.ts
http(baseRpc, { batch: true, retryCount: 2 })

Already set — verify your RPC provider supports eth_call batching.

2. Remove Dead Code

// 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.