qa
dApp QA β Pre-Ship Audit
This skill is for review, not building. Give it to a fresh agent after the dApp is built. The reviewer should:
- Read the source code (
app/,components/,contracts/) - Open the app in a browser and click through every flow
- Check every item below β report PASS/FAIL, don't fix
π¨ Critical: Wallet Flow β Button Not Text
Open the app with NO wallet connected.
- β FAIL: Text saying "Connect your wallet to play" / "Please connect to continue" / any paragraph telling the user to connect
- β PASS: A big, obvious Connect Wallet button is the primary UI element
This is the most common AI agent mistake. Every stock LLM writes a <p>Please connect your wallet</p> instead of rendering <RainbowKitCustomConnectButton />.
π¨ Critical: Four-State Button Flow
The app must show exactly ONE primary button at a time, progressing through:
1. Not connected β Connect Wallet button
2. Wrong network β Switch to [Chain] button
3. Needs approval β Approve button
4. Ready β Action button (Stake/Deposit/Swap)
Check specifically:
- β FAIL: Approve and Action buttons both visible simultaneously
- β FAIL: No network check β app tries to work on wrong chain and fails silently
- β FAIL: User can click Approve, sign in wallet, come back, and click Approve again while tx is pending
- β PASS: One button at a time. Approve button shows spinner, stays disabled until block confirms onchain. Then switches to the action button.
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):
grep -rn "useWriteContract" packages/nextjs/
Any match outside scaffold-eth internals β bug.
Watch out: the post-submit allowance refresh gap. When writeContractAsync resolves, it returns the tx hash β but wagmi hasn't re-fetched the allowance yet. During this window isMining is false AND needsApproval is still true (stale cache) β so the Approve button reappears clickable. The fix: after the tx submits, hold the button disabled with a cooldown while the allowance re-fetches:
const [approveCooldown, setApproveCooldown] = useState(false);
const handleApprove = async () => {
await approveWrite({ functionName: "approve", args: [spender, amount] });
// Hold disabled while allowance re-fetches
setApproveCooldown(true);
setTimeout(() => setApproveCooldown(false), 4000);
};
// Button:
<button disabled={isMining || approveCooldown}>
{isMining || approveCooldown
? <><span className="loading loading-spinner loading-sm" /> Approving...</>
: "Approve"}
</button>
Cooldown timing: 4s works for most L2s (Base, Arb, Op). Mainnet may need 6-8s. Adjust based on network.
- β FAIL: Approve button becomes clickable again for a few seconds after the tx submits
- β PASS: Button stays locked through submission + cooldown, then switches to the action button
π¨ Critical: SE2 Branding Removal
AI agents treat the scaffold as sacred and leave all default branding in place.
- Footer: Remove BuidlGuidl links, "Built with ποΈ SE2", "Fork me" link, support links. Replace with project's own repo link or clean it out
- Tab title: Must be the app name, NOT "Scaffold-ETH 2" or "SE-2 App" or "App Name | Scaffold-ETH 2"
- README: Must describe THIS project. Not the SE2 template README. Remove "Built with Scaffold-ETH 2" sections and SE2 doc links
- Favicon: Must not be the SE2 default
Important: Contract Address Display
- β FAIL: The deployed contract address appears nowhere on the page
- β
PASS: Contract address displayed using
<Address/>component (blockie, ENS, copy, explorer link)
Agents display the connected wallet address but forget to show the contract the user is interacting with.
Important: Address Input β Always <AddressInput/>
EVERY input that accepts an Ethereum address must use <AddressInput/>, not a plain <input type="text">.
- β FAIL:
<input type="text" placeholder="0x..." value={addr} onChange={e => setAddr(e.target.value)} /> - β
PASS:
<AddressInput value={addr} onChange={setAddr} placeholder="0x... or ENS name" />
<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.
In SE2, it's in @scaffold-ui/components:
import { AddressInput } from "@scaffold-ui/components";
// or
import { AddressInput } from "~~/components/scaffold-eth"; // if re-exported
Quick check:
grep -rn 'type="text"' packages/nextjs/app/ | grep -i "addr\|owner\|recip\|0x"
grep -rn 'placeholder="0x' packages/nextjs/app/
Any match β FAIL. Replace with <AddressInput/>.
The pair: <Address/> for display, <AddressInput/> for input. Always.
Important: USD Values
- β FAIL: Token amounts shown as "1,000 TOKEN" or "0.5 ETH" with no dollar value
- β PASS: "0.5 ETH (~$1,250)" with USD conversion
Agents never add USD values unprompted. Check every place a token or ETH amount is displayed, including inputs.
Important: OG Image Must Be Absolute URL
- β FAIL:
images: ["/thumbnail.jpg"]β relative path, breaks unfurling everywhere - β
PASS:
images: ["https://yourdomain.com/thumbnail.jpg"]β absolute production URL
Quick check:
grep -n "og:image\|images:" packages/nextjs/app/layout.tsx
Important: RPC & Polling Config
Open packages/nextjs/scaffold.config.ts:
- β FAIL:
pollingInterval: 30000(default β makes the UI feel broken, 30 second update lag) - β
PASS:
pollingInterval: 3000 - β FAIL: Using default Alchemy API key that ships with SE2
- β 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 likemainnet.base.orgwhich is rate-limited - β
PASS:
rpcOverridesusesprocess.env.NEXT_PUBLIC_*variables AND the env var is confirmed set on the hosting platform
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:
vercel env ls | grep RPC
Important: Dark Mode β No Hardcoded Dark Backgrounds
AI agents love the aesthetic of a dark UI and will hardcode it directly on the page wrapper:
// β FAIL β hardcoded black background, ignores system preference AND DaisyUI theme
<div className="min-h-screen bg-[#0a0a0a] text-white">
This 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.
Check for this pattern:
grep -rn 'bg-\[#0\|bg-black\|bg-gray-9\|bg-zinc-9\|bg-neutral-9\|bg-slate-9' packages/nextjs/app/
Any match on a root layout div or page wrapper β FAIL.
- β FAIL: Root page wrapper uses a hardcoded hex color or Tailwind dark bg class (
bg-[#0a0a0a],bg-black,bg-zinc-900, etc.) - β FAIL:
SwitchThemetoggle is present in the header but the page ignoresdata-themeentirely - β
PASS: All backgrounds use DaisyUI semantic variables β
bg-base-100,bg-base-200,text-base-content - β
PASS (dark-only exception): Theme is explicitly forced via
data-theme="dark"on<html>AND the<SwitchTheme/>component is removed from the header
The fix:
// β
CORRECT β responds to light/dark toggle and prefers-color-scheme
<div className="min-h-screen bg-base-200 text-base-content">
Important: Phantom Wallet in RainbowKit
Phantom is NOT in the SE2 default wallet list. A lot of users have Phantom β if it's missing, they can't connect.
- β FAIL: Phantom wallet not in the RainbowKit wallet list
- β
PASS:
phantomWalletis inwagmiConnectors.tsx
Important: Mobile Deep Linking
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.
On 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?
- β FAIL: Nothing happens, user has to manually switch to their wallet app
- β FAIL: Deep link fires BEFORE the transaction β user arrives at wallet with nothing to sign
- β FAIL:
window.location.href = "rainbow://"called beforewriteContractAsync()β navigates away and the TX never fires - β FAIL: It opens the wrong wallet (e.g. opens MetaMask when user connected with Rainbow)
- β FAIL: Deep links inside a wallet's in-app browser (unnecessary β you're already in the wallet)
- β PASS: Every transaction button fires the TX first, then deep links to the correct wallet app after a delay
How to implement it
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:
const writeAndOpen = useCallback(
<T,>(writeFn: () => Promise<T>): Promise<T> => {
const promise = writeFn(); // Fire TX β does gas estimation + WC relay
setTimeout(openWallet, 2000); // Switch to wallet AFTER request is relayed
return promise;
},
[openWallet],
);
// Usage β wraps every write call:
await writeAndOpen(() => gameWrite({ functionName: "click", args: [...] }));
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.
Detecting the wallet: connector.id from wagmi says "walletConnect", NOT "rainbow" or "metamask". You must check multiple sources:
const openWallet = useCallback(() => {
if (typeof window === "undefined") return;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (!isMobile || window.ethereum) return; // Skip if desktop or in-app browser
// Check connector, wagmi storage, AND WalletConnect session data
const allIds = [connector?.id, connector?.name,
localStorage.getItem("wagmi.recentConnectorId")]
.filter(Boolean).join(" ").toLowerCase();
let wcWallet = "";
try {
const wcKey = Object.keys(localStorage).find(k => k.startsWith("wc@2:client"));
if (wcKey) wcWallet = (localStorage.getItem(wcKey) || "").toLowerCase();
} catch {}
const search = `${allIds} ${wcWallet}`;
const schemes: [string[], string][] = [
[["rainbow"], "rainbow://"],
[["metamask"], "metamask://"],
[["coinbase", "cbwallet"], "cbwallet://"],
[["trust"], "trust://"],
[["phantom"], "phantom://"],
];
for (const [keywords, scheme] of schemes) {
if (keywords.some(k => search.includes(k))) {
window.location.href = scheme;
return;
}
}
}, [connector]);
Key rules:
- Fire TX first, deep link second. Never
window.location.hrefbefore the write call - Skip deep link if
window.ethereumexists β means you're already in the wallet's in-app browser - Check WalletConnect session data in localStorage β
connector.idalone won't tell you which wallet - Use simple scheme URLs like
rainbow://β notrainbow://dapp/...which reloads the page - Wrap EVERY write call β approve, action, claim, batch β not just the main one
π¨ Critical: Contract Verification on Block Explorer
After 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.
- β FAIL: Block explorer shows "Contract source code not verified" for any deployed contract
- β PASS: All deployed contracts show verified source code with a green checkmark on the block explorer
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.
How to fix (SE2):
yarn verify --network mainnet # or base, arbitrum, optimism, etc.
How to fix (Foundry):
forge verify-contract <ADDRESS> <CONTRACT> --chain <CHAIN_ID> --etherscan-api-key $ETHERSCAN_API_KEY
AI agents frequently skip verification because yarn deploy succeeds and they move on. Deployment is not done until verification passes.
Important: Button Loading State β DaisyUI loading Class Is Wrong
AI agents almost always implement button loading states incorrectly when using DaisyUI + SE2.
The mistake: Adding loading as a class directly on a btn:
// β FAIL β DaisyUI's `loading` class on a `btn` replaces the entire button content
// with a spinner that fills the full button. No text, misaligned, looks broken.
<button className={`btn btn-primary ${isPending ? "loading" : ""}`}>
{isPending ? "Approving..." : "Approve"}
</button>
The fix: Remove loading from the button class, add an inline loading-spinner span inside the button alongside the text:
// β
PASS β small spinner inside the button, text visible next to it
<button className="btn btn-primary" disabled={isPending}>
{isPending && <span className="loading loading-spinner loading-sm mr-2" />}
{isPending ? "Approving..." : "Approve"}
</button>
Check for this in code:
grep -rn '"loading"' packages/nextjs/app/
Any "loading" string in a button's className β FAIL.
- β FAIL:
className={... isPending ? "loading" : ""}on a button - β
PASS:
<span className="loading loading-spinner loading-sm" />inside the button
Audit Summary
Report each as PASS or FAIL:
Ship-Blocking
- Wallet connection shows a BUTTON, not text
- Wrong network shows a Switch button
- One button at a time (Connect β Network β Approve β Action)
- Approve button disabled with spinner through block confirmation
- Contracts verified on block explorer (Etherscan/Basescan/Arbiscan) β source code readable by anyone
- SE2 footer branding removed
- SE2 tab title removed
- SE2 README replaced
Should Fix
- Contract address displayed with
<Address/> - Every address input uses
<AddressInput/>β no raw<input type="text">for addresses - USD values next to all token/ETH amounts
- OG image is absolute production URL
- pollingInterval is 3000
- RPC overrides set (not default SE2 key) AND env var confirmed set on hosting platform
- Favicon updated from SE2 default
-
--radius-fieldinglobals.csschanged from9999remto0.5rem(or similar) β no pill-shaped textareas - Every contract error mapped to a human-readable message β no silent catch blocks, no raw hex selectors
- No hardcoded dark backgrounds β page wrapper uses
bg-base-200 text-base-content(ordata-theme="dark"forced +<SwitchTheme/>removed) - Button loaders use inline
<span className="loading loading-spinner loading-sm" />β NOTclassName="... loading"on the button itself - Phantom wallet in RainbowKit wallet list
- Mobile: ALL transaction buttons deep link to wallet (fire TX first, then
setTimeout(openWallet, 2000)) - Mobile: wallet detection checks WC session data, not just
connector.id - Mobile: no deep link when
window.ethereumexists (in-app browser)