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.
"The app deployed, so we are done." For SE2 builds, shipping includes UX correctness, metadata, RPC reliability, contract verification, and branding cleanup.
"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.
"SE2 defaults are fine in production." Default README/footer/title/favicon and default RPC fallbacks are template scaffolding, not production decisions.
"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.
Give this to a fresh agent after the dApp is built. The reviewer should:
app/, components/, contracts/)Open the app with NO wallet connected.
This is the most common AI agent mistake. Every stock LLM writes a <p>Please connect your wallet</p> instead of rendering <RainbowKitCustomConnectButton />.
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:
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.useChainId() === targetNetwork.id (or equivalent); mismatch renders a useSwitchChain-driven "Switch to [Chain]" button in the same slot as the primary CTA.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: two gaps, both allow double-approve.
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.
Fix requires TWO states:
approvalSubmitting — set at top of handler, cleared in finally {} (covers click→hash gap)approveCooldown — set after await resolves, cleared after 4s + refetch (covers confirm→cache gap)const [approvalSubmitting, setApprovalSubmitting] = useState(false);
const [approveCooldown, setApproveCooldown] = useState(false);
const handleApprove = async () => {
if (approvalSubmitting || approveCooldown) return;
setApprovalSubmitting(true);
try {
await approveWrite({ functionName: "approve", args: [spender, amount] });
setApproveCooldown(true);
setTimeout(() => { setApproveCooldown(false); refetchAllowance(); }, 4000);
} catch (e) {
notifyError("Approval failed");
} finally {
setApprovalSubmitting(false); // must be finally — releases on rejection too
}
};
<button disabled={isPending || approvalSubmitting || approveCooldown}>
disabled only reads isPending or only approveCooldownapprovalSubmitting state, or it's not cleared in finally {}disabled={isPending || approvalSubmitting || approveCooldown} with both states managed correctlyAI agents treat the scaffold as sacred and leave all default branding in place.
<Address/> component (blockie, ENS, copy, explorer link)Agents display the connected wallet address but forget to show the contract the user is interacting with.
<AddressInput/>EVERY input that accepts an Ethereum address must use <AddressInput/>, not a plain <input type="text">.
<input type="text" placeholder="0x..." value={addr} onChange={e => setAddr(e.target.value)} /><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.
Agents never add USD values unprompted. Check every place a token or ETH amount is displayed, including inputs.
images: ["/thumbnail.jpg"] — relative path, breaks unfurling everywhereimages: ["https://yourdomain.com/thumbnail.jpg"] — absolute production URLQuick check:
grep -n "og:image\|images:" packages/nextjs/app/layout.tsx
Open packages/nextjs/scaffold.config.ts:
pollingInterval: 30000 (default — makes the UI feel broken, 30 second update lag)pollingInterval: 3000process.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-limitedrpcOverrides uses process.env.NEXT_PUBLIC_* variables AND the env var is confirmed set on the hosting platformservices/web3/wagmiConfig.tsx still includes bare http() fallback transport (silently hits public RPCs in parallel, causing rate limits)http() fallback removed; only intended configured transports remainVerify 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
externalContracts.ts RegistrationScaffold 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.
packages/nextjs/contracts/externalContracts.tsdeployedContracts.ts manually edited to add external contractsexternalContracts.ts with correct chain, address, and ABIExample:
export default {
8453: {
USDC: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
abi: [...],
},
},
} as const;
Never edit deployedContracts.ts directly. It is regenerated on deploy.
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.
bg-[#0a0a0a], bg-black, bg-zinc-900, etc.)SwitchTheme toggle is present in the header but the page ignores data-theme entirelybg-base-100, bg-base-200, text-base-contentdata-theme="dark" on <html> AND the <SwitchTheme/> component is removed from the headerThe fix:
// ✅ CORRECT — responds to light/dark toggle and prefers-color-scheme
<div className="min-h-screen bg-base-200 text-base-content">
Phantom is NOT in the SE2 default wallet list. A lot of users have Phantom — if it's missing, they can't connect.
phantomWallet is in wagmiConnectors.tsxRainbowKit 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?
window.location.href = "rainbow://" called before writeContractAsync() — navigates away and the TX never firesPattern: 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:
window.location.href before the write callwindow.ethereum exists — means you're already in the wallet's in-app browserconnector.id alone won't tell you which walletrainbow:// — not rainbow://dapp/... which reloads the pageAfter 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.
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.
loading Class Is WrongAI 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.
className={... isPending ? "loading" : ""} on a button<span className="loading loading-spinner loading-sm" /> inside the button--radius-field)SE2 DaisyUI theme defaults to --radius-field: 9999rem, which creates pill-shaped textareas/selects and often clips content.
--radius-field: 9999rem remains in packages/nextjs/styles/globals.css--radius-field is changed to 0.5rem (or similar) in both light and dark theme blocksFix in theme (not per component):
/* In BOTH @plugin "daisyui/theme" blocks */
--radius-field: 0.5rem;
Do not patch this by sprinkling rounded-* utility classes per input; fix it once at theme level.
Report each as PASS or FAIL:
approvalSubmitting (click→hash), approveCooldown (confirm→cache refresh) — both states required, both on the disabled prop<Address/><AddressInput/> — no raw <input type="text"> for addresses--radius-field in globals.css changed from 9999rem to 0.5rem (or similar) — no pill-shaped textareasbg-base-200 text-base-content (or data-theme="dark" forced + <SwitchTheme/> removed)<span className="loading loading-spinner loading-sm" /> — NOT className="... loading" on the button itselfsetTimeout(openWallet, 2000))connector.idwindow.ethereum exists (in-app browser)