{"id":47619,"plugin_id":"plugins_6abf1a2052dc8191b533c58511911ca4","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-10-24T13:33:56.266Z","digest":"cdee3f13a9b8b7e62feb001e9c5ea108b3dd01e9d2c2f83b00d62dd30144f126","against":null,"payload":{"description":"Pre-ship audit checklist for Ethereum dApps built with Scaffold-ETH 2. Give this to a separate reviewer agent (or fresh context) AFTER the build is complete. Use this skill whenever you are finalizing a dApp built with Scaffold-ETH 2.","included_files":[],"name":"qa","skill_md_contents":"---\nname: qa\ndescription: Pre-ship audit checklist for Ethereum dApps built with Scaffold-ETH 2. Give this to a separate reviewer agent (or fresh context) AFTER the build is complete. Use this skill whenever you are finalizing a dApp built with Scaffold-ETH 2.\n---\n\n# dApp QA — Pre-Ship Audit For Scaffold-ETH 2 Builds\n\n## What You Probably Got Wrong\n\n**\"The app deployed, so we are done.\"** For SE2 builds, shipping includes UX correctness, metadata, RPC reliability, contract verification, and branding cleanup.\n\n**\"The flow is obvious.\"** If Connect, Network, Approve, and Action are not strictly one-at-a-time with proper pending states, users will make duplicate or failing transactions.\n\n**\"SE2 defaults are fine in production.\"** Default README/footer/title/favicon and default RPC fallbacks are template scaffolding, not production decisions.\n\n**\"Pass means no console errors.\"** QA pass/fail here is behavioral and user-facing: real wallet flow, mobile deep-link behavior, readable errors, and trust signals must be validated.\n\nGive this to a fresh agent after the dApp is built. The reviewer should:\n\n1. Read the source code (`app/`, `components/`, `contracts/`)\n2. Open the app in a browser and click through every flow\n3. Check every item below — report PASS/FAIL, don't fix\n\nFetch `crops/SKILL.md` first and include a CROPS Review in the report. Simple apps can get a concise baseline review; apps with funds, approvals, custody, wallet permissions, L2/bridge flows, private user data, identity, stablecoins, admin powers, or hosted RPC/indexer/relayer/paymaster/frontend infrastructure need the full four-pillar review.\n\n---\n\n## 🚨 Critical: Wallet Flow — Button Not Text\n\nOpen the app with NO wallet connected.\n\n- ❌ **FAIL:** Text saying \"Connect your wallet to play\" / \"Please connect to continue\" / any paragraph telling the user to connect\n- ✅ **PASS:** A big, obvious Connect Wallet **button** is the primary UI element\n\n**This is the most common AI agent mistake.** Every stock LLM writes a `<p>Please connect your wallet</p>` instead of rendering `<RainbowKitCustomConnectButton />`.\n\n---\n\n## 🚨 Critical: Four-State Button Flow\n\nThe app must show exactly ONE primary button at a time, progressing through:\n\n```\n1. Not connected  → Connect Wallet button\n2. Wrong network  → Switch to [Chain] button\n3. Needs approval → Approve button\n4. Ready          → Action button (Stake/Deposit/Swap)\n```\n\nCheck specifically:\n- ❌ **FAIL:** Approve and Action buttons both visible simultaneously\n- ❌ **FAIL:** No network check — app tries to work on wrong chain and fails silently\n- ❌ **FAIL:** Main onchain CTA renders instead of a \"Switch to [Chain]\" button when the connected wallet is on the wrong network. SE-2's header `WrongNetworkDropdown` is **not sufficient** — the action button itself must become the switch CTA, or the user clicks Sign/Stake/Deposit on the wrong chain and eats a silent wagmi error.\n- ❌ **FAIL:** User can click Approve, sign in wallet, come back, and click Approve again while tx is pending\n- ✅ **PASS:** One button at a time. Approve button shows spinner, stays disabled until block confirms onchain. Then switches to the action button.\n- ✅ **PASS:** Action button's render path branches on `useChainId() === targetNetwork.id` (or equivalent); mismatch renders a `useSwitchChain`-driven \"Switch to [Chain]\" button in the **same slot** as the primary CTA.\n\n**In the code:** the button's `disabled` prop must be tied to `isPending` from `useScaffoldWriteContract`. Verify it uses `useScaffoldWriteContract` (waits for block confirmation), NOT raw wagmi `useWriteContract` (resolves on wallet signature):\n\n```\ngrep -rn \"useWriteContract\" packages/nextjs/\n```\nAny match outside scaffold-eth internals → bug.\n\n**Watch out: two gaps, both allow double-approve.**\n\n`isPending` from wagmi drops to `false` when the wallet returns the tx hash — not when the tx confirms. `writeContractAsync` is still awaiting confirmation. During that window `isPending = false` AND `approveCooldown = false` → button re-enables mid-flight.\n\nFix requires TWO states:\n- `approvalSubmitting` — set at top of handler, cleared in `finally {}` (covers click→hash gap)\n- `approveCooldown` — set after `await` resolves, cleared after 4s + refetch (covers confirm→cache gap)\n\n```tsx\nconst [approvalSubmitting, setApprovalSubmitting] = useState(false);\nconst [approveCooldown, setApproveCooldown] = useState(false);\n\nconst handleApprove = async () => {\n  if (approvalSubmitting || approveCooldown) return;\n  setApprovalSubmitting(true);\n  try {\n    await approveWrite({ functionName: \"approve\", args: [spender, amount] });\n    setApproveCooldown(true);\n    setTimeout(() => { setApproveCooldown(false); refetchAllowance(); }, 4000);\n  } catch (e) {\n    notifyError(\"Approval failed\");\n  } finally {\n    setApprovalSubmitting(false); // must be finally — releases on rejection too\n  }\n};\n\n<button disabled={isPending || approvalSubmitting || approveCooldown}>\n```\n\n- ❌ **FAIL:** Button `disabled` only reads `isPending` or only `approveCooldown`\n- ❌ **FAIL:** No `approvalSubmitting` state, or it's not cleared in `finally {}`\n- ✅ **PASS:** `disabled={isPending || approvalSubmitting || approveCooldown}` with both states managed correctly\n\n---\n\n## 🚨 Critical: SE2 Branding Removal\n\nAI agents treat the scaffold as sacred and leave all default branding in place.\n\n- [ ] **Footer:** Remove BuidlGuidl links, \"Built with 🏗️ SE2\", \"Fork me\" link, support links. Replace with project's own repo link or clean it out\n- [ ] **Tab title:** Must be the app name, NOT \"Scaffold-ETH 2\" or \"SE-2 App\" or \"App Name | Scaffold-ETH 2\"\n- [ ] **README:** Must describe THIS project. Not the SE2 template README. Remove \"Built with Scaffold-ETH 2\" sections and SE2 doc links\n- [ ] **Favicon:** Must not be the SE2 default\n\n---\n\n## Important: CROPS Review for Trust Assumptions\n\n- ❌ **FAIL:** No `CROPS Review` block in the QA report.\n- ❌ **FAIL:** The CROPS Review lists generic values but does not name who can block users, what data leaks, who controls funds/upgrades/recovery, and how users exit.\n- ❌ **FAIL:** The app has production-relevant trust assumptions (funds, approvals, custody, wallet permissions, L2/bridge, private data, identity, stablecoins, admin powers, hosted RPC/indexer/relayer/paymaster/frontend) but the CROPS Review is only a short gate and never uses `crops/SKILL.md` for the deep template.\n- ✅ **PASS:** A concrete `CROPS Review` block is present, names the chosen default, accepted compromises, and the user's escape path. Depth matches the app's trust assumptions.\n\n---\n\n## Important: Contract Address Display\n\n- ❌ **FAIL:** The deployed contract address appears nowhere on the page\n- ✅ **PASS:** Contract address displayed using `<Address/>` component (blockie, ENS, copy, explorer link)\n\nAgents display the connected wallet address but forget to show the contract the user is interacting with.\n\n---\n\n## Important: Address Input — Always `<AddressInput/>`\n\n**EVERY input that accepts an Ethereum address must use `<AddressInput/>`, not a plain `<input type=\"text\">`.**\n\n- ❌ **FAIL:** `<input type=\"text\" placeholder=\"0x...\" value={addr} onChange={e => setAddr(e.target.value)} />`\n- ✅ **PASS:** `<AddressInput value={addr} onChange={setAddr} placeholder=\"0x... or ENS name\" />`\n\n`<AddressInput/>` gives you ENS resolution (type \"vitalik.eth\" → resolves to address), blockie avatar preview, validation, and paste handling. A raw text input is unacceptable for address collection.\n\n**In SE2, it's in `@scaffold-ui/components`:**\n```typescript\nimport { AddressInput } from \"@scaffold-ui/components\";\n// or\nimport { AddressInput } from \"~~/components/scaffold-eth\"; // if re-exported\n```\n\n**Quick check:**\n```bash\ngrep -rn 'type=\"text\"' packages/nextjs/app/ | grep -i \"addr\\|owner\\|recip\\|0x\"\ngrep -rn 'placeholder=\"0x' packages/nextjs/app/\n```\nAny match → **FAIL**. Replace with `<AddressInput/>`.\n\nThe pair: `<Address/>` for **display**, `<AddressInput/>` for **input**. Always.\n\n---\n\n## Important: USD Values\n\n- ❌ **FAIL:** Token amounts shown as \"1,000 TOKEN\" or \"0.5 ETH\" with no dollar value\n- ✅ **PASS:** \"0.5 ETH (~$1,250)\" with USD conversion\n\nAgents never add USD values unprompted. Check every place a token or ETH amount is displayed, including inputs.\n\n---\n\n## Important: OG Image Must Be Absolute URL\n\n- ❌ **FAIL:** `images: [\"/thumbnail.jpg\"]` — relative path, breaks unfurling everywhere\n- ✅ **PASS:** `images: [\"https://yourdomain.com/thumbnail.jpg\"]` — absolute production URL\n\nQuick check:\n```\ngrep -n \"og:image\\|images:\" packages/nextjs/app/layout.tsx\n```\n\n---\n\n## Important: RPC & Polling Config\n\nOpen `packages/nextjs/scaffold.config.ts`:\n\n- ❌ **FAIL:** `pollingInterval: 30000` (default — makes the UI feel broken, 30 second update lag)\n- ✅ **PASS:** `pollingInterval: 3000`\n- ❌ **FAIL:** Using default Alchemy API key that ships with SE2\n- ❌ **FAIL:** Code references `process.env.NEXT_PUBLIC_*` but the variable isn't actually set in the deployment environment (Vercel/hosting). Falls back to public RPC like `mainnet.base.org` which is rate-limited\n- ✅ **PASS:** `rpcOverrides` uses `process.env.NEXT_PUBLIC_*` variables AND the env var is confirmed set on the hosting platform\n- ❌ **FAIL:** `services/web3/wagmiConfig.tsx` still includes bare `http()` fallback transport (silently hits public RPCs in parallel, causing rate limits)\n- ✅ **PASS:** Bare `http()` fallback removed; only intended configured transports remain\n\n**Verify the env var is set, not just referenced.** AI agents will change the code to use `process.env`, see the pattern matches PASS, and move on — without ever setting the actual variable on Vercel/hosting. Check:\n```bash\nvercel env ls | grep RPC\n```\n\n---\n\n## Important: SE2 `externalContracts.ts` Registration\n\nScaffold hooks only work with contracts registered in `deployedContracts.ts` (auto-generated) or `externalContracts.ts` (manual). If external contracts are not registered, frontend reads/writes silently fail.\n\n- ❌ **FAIL:** Frontend code references token/protocol contracts that are missing from `packages/nextjs/contracts/externalContracts.ts`\n- ❌ **FAIL:** `deployedContracts.ts` manually edited to add external contracts\n- ✅ **PASS:** All external contracts are defined in `externalContracts.ts` with correct chain, address, and ABI\n\nExample:\n```typescript\nexport default {\n  8453: {\n    USDC: {\n      address: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n      abi: [...],\n    },\n  },\n} as const;\n```\n\nNever edit `deployedContracts.ts` directly. It is regenerated on deploy.\n\n---\n\n## Important: Dark Mode — No Hardcoded Dark Backgrounds\n\nAI agents love the aesthetic of a dark UI and will hardcode it directly on the page wrapper:\n\n```tsx\n// ❌ FAIL — hardcoded black background, ignores system preference AND DaisyUI theme\n<div className=\"min-h-screen bg-[#0a0a0a] text-white\">\n```\n\nThis bypasses the entire DaisyUI theme system. Light-mode users get a black page. The `SwitchTheme` toggle in the SE2 header stops working. `prefers-color-scheme` is ignored.\n\n**Check for this pattern:**\n```bash\ngrep -rn 'bg-\\[#0\\|bg-black\\|bg-gray-9\\|bg-zinc-9\\|bg-neutral-9\\|bg-slate-9' packages/nextjs/app/\n```\nAny match on a root layout div or page wrapper → **FAIL**.\n\n- ❌ **FAIL:** Root page wrapper uses a hardcoded hex color or Tailwind dark bg class (`bg-[#0a0a0a]`, `bg-black`, `bg-zinc-900`, etc.)\n- ❌ **FAIL:** `SwitchTheme` toggle is present in the header but the page ignores `data-theme` entirely\n- ✅ **PASS:** All backgrounds use DaisyUI semantic variables — `bg-base-100`, `bg-base-200`, `text-base-content`\n- ✅ **PASS (dark-only exception):** Theme is explicitly forced via `data-theme=\"dark\"` on `<html>` **AND** the `<SwitchTheme/>` component is removed from the header\n\n**The fix:**\n```tsx\n// ✅ CORRECT — responds to light/dark toggle and prefers-color-scheme\n<div className=\"min-h-screen bg-base-200 text-base-content\">\n```\n\n---\n\n## Important: Phantom Wallet in RainbowKit\n\nPhantom is NOT in the SE2 default wallet list. A lot of users have Phantom — if it's missing, they can't connect.\n\n- ❌ **FAIL:** Phantom wallet not in the RainbowKit wallet list\n- ✅ **PASS:** `phantomWallet` is in `wagmiConnectors.tsx`\n\n---\n\n## Important: Mobile Deep Linking\n\n**RainbowKit v2 / WalletConnect v2 does NOT auto-deep-link to the wallet app.** It relies on push notifications instead, which are slow and unreliable. You must implement deep linking yourself.\n\nOn mobile, when a user taps a button that needs a signature, it must open their wallet app. Test this: open the app on a phone, connect a wallet via WalletConnect, tap an action button — does the wallet app open with the transaction ready to sign?\n\n- ❌ **FAIL:** Nothing happens, user has to manually switch to their wallet app\n- ❌ **FAIL:** Deep link fires BEFORE the transaction — user arrives at wallet with nothing to sign\n- ❌ **FAIL:** `window.location.href = \"rainbow://\"` called before `writeContractAsync()` — navigates away and the TX never fires\n- ❌ **FAIL:** It opens the wrong wallet (e.g. opens MetaMask when user connected with Rainbow)\n- ❌ **FAIL:** Deep links inside a wallet's in-app browser (unnecessary — you're already in the wallet)\n- ✅ **PASS:** Every transaction button fires the TX first, then deep links to the correct wallet app after a delay\n\n### How to implement it\n\n**Pattern: `writeAndOpen` helper.** Fire the write call first (sends the TX request over WalletConnect), then deep link after a delay to switch the user to their wallet:\n\n```typescript\nconst writeAndOpen = useCallback(\n  <T,>(writeFn: () => Promise<T>): Promise<T> => {\n    const promise = writeFn(); // Fire TX — does gas estimation + WC relay\n    setTimeout(openWallet, 2000); // Switch to wallet AFTER request is relayed\n    return promise;\n  },\n  [openWallet],\n);\n\n// Usage — wraps every write call:\nawait writeAndOpen(() => gameWrite({ functionName: \"click\", args: [...] }));\n```\n\n**Why 2 seconds?** `writeContractAsync` must estimate gas, encode calldata, and relay the signing request through WalletConnect's servers. 300ms is too fast — the wallet won't have received the request yet.\n\n**Detecting the wallet:** `connector.id` from wagmi says `\"walletConnect\"`, NOT `\"rainbow\"` or `\"metamask\"`. You must check multiple sources:\n\n```typescript\nconst openWallet = useCallback(() => {\n  if (typeof window === \"undefined\") return;\n  const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);\n  if (!isMobile || window.ethereum) return; // Skip if desktop or in-app browser\n\n  // Check connector, wagmi storage, AND WalletConnect session data\n  const allIds = [connector?.id, connector?.name,\n    localStorage.getItem(\"wagmi.recentConnectorId\")]\n    .filter(Boolean).join(\" \").toLowerCase();\n\n  let wcWallet = \"\";\n  try {\n    const wcKey = Object.keys(localStorage).find(k => k.startsWith(\"wc@2:client\"));\n    if (wcKey) wcWallet = (localStorage.getItem(wcKey) || \"\").toLowerCase();\n  } catch {}\n  const search = `${allIds} ${wcWallet}`;\n\n  const schemes: [string[], string][] = [\n    [[\"rainbow\"], \"rainbow://\"],\n    [[\"metamask\"], \"metamask://\"],\n    [[\"coinbase\", \"cbwallet\"], \"cbwallet://\"],\n    [[\"trust\"], \"trust://\"],\n    [[\"phantom\"], \"phantom://\"],\n  ];\n\n  for (const [keywords, scheme] of schemes) {\n    if (keywords.some(k => search.includes(k))) {\n      window.location.href = scheme;\n      return;\n    }\n  }\n}, [connector]);\n```\n\n**Key rules:**\n1. **Fire TX first, deep link second.** Never `window.location.href` before the write call\n2. **Skip deep link if `window.ethereum` exists** — means you're already in the wallet's in-app browser\n3. **Check WalletConnect session data** in localStorage — `connector.id` alone won't tell you which wallet\n4. **Use simple scheme URLs** like `rainbow://` — not `rainbow://dapp/...` which reloads the page\n5. **Wrap EVERY write call** — approve, action, claim, batch — not just the main one\n\n---\n\n## 🚨 Critical: Contract Verification on Block Explorer\n\nAfter deploying, every contract MUST be verified on the block explorer. Unverified contracts are a trust red flag — users can't read the source code, and it looks like you're hiding something.\n\n- ❌ **FAIL:** Block explorer shows \"Contract source code not verified\" for any deployed contract\n- ✅ **PASS:** All deployed contracts show verified source code with a green checkmark on the block explorer\n\n**How to check:** Take each contract address from `deployedContracts.ts`, open it on the block explorer (Etherscan, Basescan, Arbiscan, etc.), and look for the \"Contract\" tab with a ✅ checkmark. If it shows bytecode only — not verified.\n\n**How to fix (SE2):**\n```bash\nyarn verify --network mainnet   # or base, arbitrum, optimism, etc.\n```\n\n**How to fix (Foundry):**\n```bash\nforge verify-contract <ADDRESS> <CONTRACT> --chain <CHAIN_ID> --etherscan-api-key $ETHERSCAN_API_KEY\n```\n\nAI agents frequently skip verification because `yarn deploy` succeeds and they move on. Deployment is not done until verification passes.\n\n---\n\n## Important: Button Loading State — DaisyUI `loading` Class Is Wrong\n\nAI agents almost always implement button loading states incorrectly when using DaisyUI + SE2.\n\n**The mistake:** Adding `loading` as a class directly on a `btn`:\n\n```tsx\n// ❌ FAIL — DaisyUI's `loading` class on a `btn` replaces the entire button content\n// with a spinner that fills the full button. No text, misaligned, looks broken.\n<button className={`btn btn-primary ${isPending ? \"loading\" : \"\"}`}>\n  {isPending ? \"Approving...\" : \"Approve\"}\n</button>\n```\n\n**The fix:** Remove `loading` from the button class, add an inline `loading-spinner` span inside the button alongside the text:\n\n```tsx\n// ✅ PASS — small spinner inside the button, text visible next to it\n<button className=\"btn btn-primary\" disabled={isPending}>\n  {isPending && <span className=\"loading loading-spinner loading-sm mr-2\" />}\n  {isPending ? \"Approving...\" : \"Approve\"}\n</button>\n```\n\n**Check for this in code:**\n```bash\ngrep -rn '\"loading\"' packages/nextjs/app/\n```\nAny `\"loading\"` string in a button's className → **FAIL**.\n\n- ❌ **FAIL:** `className={... isPending ? \"loading\" : \"\"}` on a button\n- ✅ **PASS:** `<span className=\"loading loading-spinner loading-sm\" />` inside the button\n\n---\n\n## Important: SE2 Pill-Shaped Inputs (`--radius-field`)\n\nSE2 DaisyUI theme defaults to `--radius-field: 9999rem`, which creates pill-shaped textareas/selects and often clips content.\n\n- ❌ **FAIL:** `--radius-field: 9999rem` remains in `packages/nextjs/styles/globals.css`\n- ✅ **PASS:** `--radius-field` is changed to `0.5rem` (or similar) in both light and dark theme blocks\n\nFix in theme (not per component):\n```css\n/* In BOTH @plugin \"daisyui/theme\" blocks */\n--radius-field: 0.5rem;\n```\n\nDo not patch this by sprinkling `rounded-*` utility classes per input; fix it once at theme level.\n\n---\n\n## SE2 References\n\n- Docs: https://docs.scaffoldeth.io/\n- UI Components: https://ui.scaffoldeth.io/\n- SpeedRun Ethereum: https://speedrunethereum.com/\n\n---\n\n## Audit Summary\n\nReport each as PASS or FAIL:\n\n### Ship-Blocking\n- [ ] Wallet connection shows a BUTTON, not text\n- [ ] Wrong network shows a Switch button **in the primary CTA slot** (not only in the header dropdown)\n- [ ] One button at a time (Connect → Network → Approve → Action)\n- [ ] Approve button locked through full cycle: `approvalSubmitting` (click→hash), `approveCooldown` (confirm→cache refresh) — both states required, both on the `disabled` prop\n- [ ] Contracts verified on block explorer (Etherscan/Basescan/Arbiscan) — source code readable by anyone\n- [ ] CROPS Review present in the QA report: names the chosen default, accepted compromises, who can block users, what data leaks, who controls funds/upgrades/recovery, and the user's escape path\n- [ ] SE2 footer branding removed\n- [ ] SE2 tab title removed\n- [ ] SE2 README replaced\n\n### Should Fix\n- [ ] Contract address displayed with `<Address/>`\n- [ ] Every address input uses `<AddressInput/>` — no raw `<input type=\"text\">` for addresses\n- [ ] USD values next to all token/ETH amounts\n- [ ] OG image is absolute production URL\n- [ ] pollingInterval is 3000\n- [ ] RPC overrides set (not default SE2 key) AND env var confirmed set on hosting platform\n- [ ] Favicon updated from SE2 default\n- [ ] `--radius-field` in `globals.css` changed from `9999rem` to `0.5rem` (or similar) — no pill-shaped textareas\n- [ ] Every contract error mapped to a human-readable message — no silent catch blocks, no raw hex selectors\n- [ ] No hardcoded dark backgrounds — page wrapper uses `bg-base-200 text-base-content` (or `data-theme=\"dark\"` forced + `<SwitchTheme/>` removed)\n- [ ] Button loaders use inline `<span className=\"loading loading-spinner loading-sm\" />` — NOT `className=\"... loading\"` on the button itself\n- [ ] Phantom wallet in RainbowKit wallet list\n- [ ] Mobile: ALL transaction buttons deep link to wallet (fire TX first, then `setTimeout(openWallet, 2000)`)\n- [ ] Mobile: wallet detection checks WC session data, not just `connector.id`\n- [ ] Mobile: no deep link when `window.ethereum` exists (in-app browser)\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}