Ethereum development tutor and builder for Scaffold-ETH 2 projects. Triggers on "build", "create", "dApp", "smart contract", "Solidity", "DeFi", "Ethereum", "web3", or any blockchain development task. ALWAYS uses fork mode to test against real protocol state.
Comprehensive Ethereum development guide for AI agents. Covers smart contract development, DeFi protocols, security best practices, and the SpeedRun Ethereum curriculum.
These rules are MANDATORY. Violations cause real bugs in production.
ALL CONTRACTS IN externalContracts.ts — Any contract you want to interact with (tokens, protocols, etc.) MUST be added to packages/nextjs/contracts/externalContracts.ts with its address and ABI. Read the file first — the pattern is self-evident.
SCAFFOLD HOOKS ONLY — NEVER RAW WAGMI — Always use useScaffoldReadContract and useScaffoldWriteContract, NEVER raw wagmi hooks like useWriteContract or useReadContract.
Why this matters: Scaffold hooks use useTransactor which waits for transaction confirmation (not just wallet signing). Raw wagmi's resolves the moment the user signs in MetaMask — BEFORE the tx is mined. This causes buttons to re-enable while transactions are still pending.
writeContractAsync// ❌ WRONG: Raw wagmi - resolves after signing, not confirmation
const { writeContractAsync } = useWriteContract();
await writeContractAsync({...}); // Returns immediately after MetaMask signs!
// ✅ CORRECT: Scaffold hooks - waits for tx to be mined
const { writeContractAsync } = useScaffoldWriteContract("MyContract");
await writeContractAsync({...}); // Waits for actual on-chain confirmation
STOP. Re-read the "Critical Gotchas" section below before writing or modifying ANY code that touches:
approve, allowance, transferFrom)transfer, safeTransfer, safeTransferFrom)This is not optional. The gotchas section exists because these are the exact mistakes that lose real money. Every time you think "I'll just quickly fix this" is exactly when you need to re-read it.
These are HARD RULES, not suggestions. A build is NOT done until all of these are satisfied. These rules have been learned the hard way. Do not skip them.
ANY button that triggers a blockchain transaction MUST:
// ✅ CORRECT: Separate loading state PER ACTION
const [isApproving, setIsApproving] = useState(false);
const [isStaking, setIsStaking] = useState(false);
<button
disabled={isApproving}
onClick={async () => {
setIsApproving(true);
try {
await writeContractAsync({ functionName: "approve", args: [...] });
} catch (e) {
console.error(e);
notification.error("Approval failed");
} finally {
setIsApproving(false);
}
}}
>
{isApproving ? "Approving..." : "Approve"}
</button>
❌ NEVER use a single shared isLoading for multiple buttons. Each button gets its own loading state. A shared state causes the WRONG loading text to appear when UI conditionally switches between buttons.
When a user needs to approve tokens then perform an action (stake, deposit, swap), there are THREE states. Show exactly ONE button at a time:
1. Wrong network? → "Switch to Base" button
2. Not enough approved? → "Approve" button
3. Enough approved? → "Stake" / "Deposit" / action button
// ALWAYS read allowance with a hook (auto-updates when tx confirms)
const { data: allowance } = useScaffoldReadContract({
contractName: "Token",
functionName: "allowance",
args: [address, contractAddress],
});
const needsApproval = !allowance || allowance < amount;
const wrongNetwork = chain?.id !== targetChainId;
{wrongNetwork ? (
<button onClick={switchNetwork} disabled={isSwitching}>
{isSwitching ? "Switching..." : "Switch to Base"}
</button>
) : needsApproval ? (
<button onClick={handleApprove} disabled={isApproving}>
{isApproving ? "Approving..." : "Approve $TOKEN"}
</button>
) : (
<button onClick={handleStake} disabled={isStaking}>
{isStaking ? "Staking..." : "Stake"}
</button>
)}
Critical: Always read allowance via a hook so UI updates automatically. Never rely on local state alone. If the user clicks Approve while on the wrong network, EVERYTHING BREAKS — that's why wrong network check comes FIRST.
<Address/>EVERY time you display an Ethereum address, use scaffold-eth's <Address/> component.
// ✅ CORRECT
import { Address } from "~~/components/scaffold-eth";
<Address address={userAddress} />
// ❌ WRONG — never render raw hex
<span>{userAddress}</span>
<p>0x1234...5678</p>
<Address/> handles ENS resolution, blockie avatars, copy-to-clipboard, truncation, and block explorer links. Raw hex is unacceptable.
<AddressInput/>EVERY time the user needs to enter an Ethereum address, use scaffold-eth's <AddressInput/> component.
// ✅ CORRECT
import { AddressInput } from "~~/components/scaffold-eth";
<AddressInput value={recipient} onChange={setRecipient} placeholder="Recipient address" />
// ❌ WRONG — never use a raw text input for addresses
<input type="text" value={recipient} onChange={e => setRecipient(e.target.value)} />
<AddressInput/> provides ENS resolution (type "vitalik.eth" → resolves to address), blockie avatar preview, validation, and paste handling. A raw input gives none of this.
The pair: <Address/> for DISPLAY, <AddressInput/> for INPUT. Always.
EVERY token or ETH amount displayed should include its USD value. EVERY token or ETH input should show a live USD preview.
// ✅ CORRECT — Display with USD
<span>1,000 TOKEN (~$4.20)</span>
<span>0.5 ETH (~$1,250.00)</span>
// ✅ CORRECT — Input with live USD preview
<input value={amount} onChange={...} />
<span className="text-sm text-gray-500">
≈ ${(parseFloat(amount || "0") * tokenPrice).toFixed(2)} USD
</span>
// ❌ WRONG — Amount with no USD context
<span>1,000 TOKEN</span> // User has no idea what this is worth
Where to get prices:
useNativeCurrencyPrice() or check the price display component in the bottom-left footer. It reads from mainnet Uniswap V2 WETH/DAI pool.https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS), on-chain Uniswap quoter, or Chainlink oracle if available.This applies to both display AND input:
DO NOT put the app name as an <h1> at the top of the page body. The header already displays the app name. Repeating it wastes space and looks amateur.
// ❌ WRONG — AI agents ALWAYS do this
<Header /> {/* Already shows "🦞 $TOKEN Hub" */}
<main>
<h1>🦞 $TOKEN Hub</h1> {/* DUPLICATE! Delete this. */}
<p>Buy, send, and track TOKEN on Base</p>
...
</main>
// ✅ CORRECT — Jump straight into content
<Header /> {/* Shows the app name */}
<main>
<div className="grid grid-cols-2 gap-4">
{/* Stats, balances, actions — no redundant title */}
</div>
</main>
The SE2 header component already handles the app title. Your page content should start with the actual UI — stats, forms, data — not repeat what's already visible at the top of the screen.
NEVER use public RPCs (mainnet.base.org, etc.) — they rate-limit and cause random failures.
In scaffold.config.ts, ALWAYS set:
rpcOverrides: {
[chains.base.id]: "https://base-mainnet.g.alchemy.com/v2/YOUR_KEY",
[chains.mainnet.id]: "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY",
},
pollingInterval: 3000, // 3 seconds, not the default 30000
Monitor RPC usage: Sensible = 1 request every 3 seconds. If you see 15+ requests/second, you have a bug:
watch: true on hooks that don't need itBEFORE deploying frontend to Vercel/production:
Open Graph / Twitter Cards (REQUIRED):
// In app/layout.tsx
export const metadata: Metadata = {
title: "Your App Name",
description: "Description of the app",
openGraph: {
title: "Your App Name",
description: "Description of the app",
images: [{ url: "https://YOUR-LIVE-DOMAIN.com/og-image.png" }],
},
twitter: {
card: "summary_large_image",
title: "Your App Name",
description: "Description of the app",
images: ["https://YOUR-LIVE-DOMAIN.com/og-image.png"],
},
};
⚠️ The OG image URL MUST be:
https://localhost, NOT relative path)Full checklist — EVERY item must pass:
summary_large_image)pollingInterval is 3000<Address/>A build is NOT done when the code compiles. A build is done when you've tested it like a real user.
After writing all code, run the QA check script or spawn a QA sub-agent:
.tsx files for raw address strings (should use <Address/>)isLoading state across multiple buttonsdisabled props on transaction buttonsscaffold.config.ts has rpcOverrides and pollingInterval: 3000layout.tsx has OG/Twitter meta with absolute URLsmainnet.base.org or other public RPCs in any fileforge test)You have a browser. You have a wallet. You have real money. USE THEM.
After deploying to Base (or fork), open the app and do a FULL walkthrough:
Only after ALL of this passes can you tell the user "it's done."
For bigger projects, spawn a sub-agent with a fresh context:
When a user wants to BUILD any Ethereum project, follow these steps:
Step 1: Create Project
npx create-eth@latest
# Select: foundry (recommended), target chain, project name
Step 2: Fix Polling Interval
Edit packages/nextjs/scaffold.config.ts and change:
pollingInterval: 30000, // Default: 30 seconds (way too slow!)