
Ethereum Wingman
- 391 installs
- 45 repo stars
- Updated February 4, 2026
- austintgriffith/ethereum-wingman
ethereum-wingman is an agent skill that teaches and assists developers building Ethereum dApps with Solidity patterns, Scaffold-ETH 2 tooling, SpeedRun Ethereum challenges, DeFi integrations, and smart-contract security
About
ethereum-wingman is a BuidlGuidl agent skill (skill.json v1.0.0) that acts as an Ethereum development tutor and build assistant for Solidity, dApps, and wallet integration. It packages 12 SpeedRun Ethereum challenge TLDR modules, ERC-20/721/1155/4626 standard guides, DeFi protocol docs for Uniswap, Aave, and Compound, and seven documented critical gotchas including reentrancy and oracle manipulation. Four prompt modes—tutor, review, debug, and build—plus helper scripts like init-project.sh and check-gotchas.sh guide Scaffold-ETH 2 scaffolding, contract review, and deployment decisions. Developers invoke it when learning on-chain patterns, auditing withdrawal functions, or scaffolding staking, token, and NFT projects with AI assistance.
- Solidity and dApp guidance
- Smart contract integration patterns
- Wallet and RPC wiring
- Ethereum tooling recommendations
- Deployment and testing support
Ethereum Wingman by the numbers
- 391 all-time installs (skills.sh)
- Ranked #31 of 479 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/austintgriffith/ethereum-wingman --skill ethereum-wingmanAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 391 |
|---|---|
| repo stars | ★ 45 |
| Last updated | February 4, 2026 |
| Repository | austintgriffith/ethereum-wingman ↗ |
How do you scaffold a secure Ethereum dApp?
Accelerate Ethereum smart contract, dApp, and wallet integration work with guided patterns for Solidity, tooling, testing, and deployment decisions.
Who is it for?
Developers learning or building Ethereum smart contracts who want Scaffold-ETH 2 scaffolding, SpeedRun Ethereum guidance, and security gotcha checks in one skill.
Skip if: Developers working on non-EVM chains or production mainnet deployments without additional professional audit coverage should not rely on this skill alone.
When should I use this skill?
User asks to build a Solidity dApp, explain ERC-20 approve flows, debug transaction reverts, or review contracts for reentrancy and oracle risks.
What you get
Scaffold-ETH 2 project layout, Solidity contract patterns, security review notes, and SpeedRun Ethereum challenge references.
- Scaffold-ETH 2 project
- Solidity contract templates
- security review checklist
By the numbers
- Packages 12 SpeedRun Ethereum challenge TLDR modules
- Documents 7 critical Ethereum development gotchas in the README
- skill.json version 1.0.0 lists 6 capabilities and 4 prompt modes
Files
Ethereum Wingman
Comprehensive Ethereum development guide for AI agents. Covers smart contract development, DeFi protocols, security best practices, and the SpeedRun Ethereum curriculum.
---
AI AGENT INSTRUCTIONS - READ THIS FIRST
🚫 CRITICAL: External Contracts & Scaffold Hooks
These rules are MANDATORY. Violations cause real bugs in production.
1. 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.
2. 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 writeContractAsync resolves the moment the user signs in MetaMask — BEFORE the tx is mined. This causes buttons to re-enable while transactions are still pending.
// ❌ 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🚨 BEFORE ANY TOKEN/APPROVAL/SECURITY CODE CHANGE
STOP. Re-read the "Critical Gotchas" section below before writing or modifying ANY code that touches:
- Token approvals (
approve,allowance,transferFrom) - Token transfers (
transfer,safeTransfer,safeTransferFrom) - Access control or permissions
- Price calculations or oracle usage
- Vault deposits/withdrawals
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.
---
🚨 FRONTEND UX RULES (MANDATORY)
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.
Rule 1: Every Onchain Button — Loader + Disable
ANY button that triggers a blockchain transaction MUST: 1. Disable immediately on click 2. Show a loader/spinner ("Approving...", "Staking...", etc.) 3. Stay disabled until the state updates confirm the action completed 4. Show success/error feedback when done
// ✅ 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.
Rule 2: Three-Button Flow — Network → Approve → Action
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.
Rule 3: Address Display — Always <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.
Rule 3b: Address Input — Always <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.
Rule 3c: USD Values — Show Dollar Amounts Everywhere
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 worthWhere to get prices:
- ETH price: SE2 has a built-in hook —
useNativeCurrencyPrice()or check the price display component in the bottom-left footer. It reads from mainnet Uniswap V2 WETH/DAI pool. - Custom tokens: Use DexScreener API (
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:
- Displaying a balance? Show USD next to it.
- User entering an amount to send/stake/swap? Show live USD preview below the input.
- Transaction confirmation? Show USD value of what they're about to do.
Rule 3d: No Duplicate Titles — Header IS the Title
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.
Rule 4: RPC Configuration — ALWAYS Alchemy
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 30000Monitor RPC usage: Sensible = 1 request every 3 seconds. If you see 15+ requests/second, you have a bug:
- Hooks re-rendering in loops
- Duplicate hook calls
- Missing dependency arrays
watch: trueon hooks that don't need it
Rule 5: Pre-Publish Checklist
BEFORE 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:
- Absolute URL starting with
https:// - The LIVE production domain (NOT
localhost, NOT relative path) - NOT an environment variable that could be unset or localhost
- Actually reachable (test by visiting the URL in a browser)
Full checklist — EVERY item must pass:
- [ ] OG image URL is absolute, live production domain
- [ ] OG title and description set (not default SE2 text)
- [ ] Twitter card type set (
summary_large_image) - [ ] Favicon updated from SE2 default
- [ ] README updated from SE2 default
- [ ] Footer "Fork me" link → your actual repo (not SE2)
- [ ] Browser tab title is correct
- [ ] RPC overrides set to Alchemy
- [ ]
pollingIntervalis 3000 - [ ] All contract addresses match what's deployed
- [ ] No hardcoded testnet/localhost values in production code
- [ ] Every address display uses
<Address/> - [ ] Every onchain button has its own loader + disabled state
- [ ] Approve flow has network check → approve → action pattern
---
🧪 BUILD VERIFICATION PROCESS (MANDATORY)
A build is NOT done when the code compiles. A build is done when you've tested it like a real user.
Phase 1: Code QA (Automated)
After writing all code, run the QA check script or spawn a QA sub-agent:
- Scan all
.tsxfiles for raw address strings (should use<Address/>) - Scan for shared
isLoadingstate across multiple buttons - Scan for missing
disabledprops on transaction buttons - Verify
scaffold.config.tshasrpcOverridesandpollingInterval: 3000 - Verify
layout.tsxhas OG/Twitter meta with absolute URLs - Verify no
mainnet.base.orgor other public RPCs in any file
Phase 2: Smart Contract Testing
- Write and run Foundry tests (
forge test) - Test edge cases: zero amounts, max amounts, unauthorized callers
- Test the full user flow in the contract (approve → action → verify state)
Phase 3: Browser Testing (THE REAL 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:
1. Open the app in the browser tool — take a snapshot, verify it loaded 2. Check the page title — is it correct, not "Scaffold-ETH 2"? 3. Connect wallet — does the connect flow work? 4. Wrong network test — connect on wrong network, verify "Switch to Base" appears 5. Switch network — click the switch button, verify it works 6. Approve flow — if the app needs token approval:
- Verify "Approve" button shows when allowance is insufficient
- Click Approve — does the button disable? Does it show "Approving..."?
- Wait for tx — does the button come back? Does the UI update to show the action button?
7. Main action — click the primary action (stake, deposit, mint, etc.):
- Does the button disable and show a loader?
- Does the transaction go through?
- Does the UI update after confirmation?
- Does the balance/state change reflect correctly?
8. Error handling — reject a transaction in wallet, verify the UI recovers gracefully 9. Address displays — are all addresses showing ENS/blockies, not raw hex? 10. Share the URL — check that the OG unfurl looks correct (image, title, description)
Only after ALL of this passes can you tell the user "it's done."
Phase 4: QA Sub-Agent Review (For Complex Builds)
For bigger projects, spawn a sub-agent with a fresh context:
- Give it the repo path and deployed URL
- It reads all frontend code against the rules above
- It opens the browser and clicks through independently
- It reports issues back before shipping
---
Default Stack: Scaffold-ETH 2 with Fork Mode
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 nameStep 2: Fix Polling Interval
Edit packages/nextjs/scaffold.config.ts and change:
pollingInterval: 30000, // Default: 30 seconds (way too slow!)to:
pollingInterval: 3000, // 3 seconds (much better for development)Step 3: Install & Fork a Live Network
cd <project-name>
yarn install
yarn fork --network base # or mainnet, arbitrum, optimism, polygon⚠️ IMPORTANT: When using fork mode, the frontend target network MUST be `chains.foundry` (chain ID 31337), NOT the chain you're forking!
The fork runs locally on Anvil with chain ID 31337. Even if you're forking Base, Arbitrum, etc., the scaffold config must use:
targetNetworks: [chains.foundry], // NOT chains.base!Only switch to chains.base (or other chain) when deploying to the REAL network.
Step 4: Enable Auto Block Mining (REQUIRED!)
# In a new terminal, enable interval mining (1 block/second)
cast rpc anvil_setIntervalMining 1Without this, block.timestamp stays FROZEN and time-dependent logic breaks!
Optional: Make it permanent by editing packages/foundry/package.json to add --block-time 1 to the fork script.
Step 5: Deploy to Local Fork (FREE!)
yarn deployStep 6: Start Frontend
yarn startStep 7: Test the Frontend
After the frontend is running, open a browser and test the app:
1. Navigate to http://localhost:3000 2. Take a snapshot to get page elements (burner wallet address is in header) 3. Click the faucet to fund the burner wallet with ETH 4. Transfer tokens from whales if needed (use burner address from page) 5. Click through the app to verify functionality
Use the cursor-browser-extension MCP tools for browser automation. See tools/testing/frontend-testing.md for detailed workflows.
When Publishing a Scaffold-ETH 2 Project:
1. Update README.md — Replace the default SE2 readme with your project's description 2. Update the footer link — In packages/nextjs/components/Footer.tsx, change the "Fork me" link from https://github.com/scaffold-eth/se-2 to your actual repo URL 3. Update page title — In packages/nextjs/app/layout.tsx, change the metadata title/description 4. Remove "Debug Contracts" nav link — In packages/nextjs/components/Header.tsx, remove the Debug Contracts entry from menuLinks 5. Set OG/Twitter meta — Follow the Pre-Publish Checklist in Rule 5 above
🚀 SE2 Deployment Quick Decision Tree
Want to deploy SE2 to production?
│
├─ IPFS (recommended) ──→ yarn ipfs (local build, no memory limits)
│ └─ Fails with "localStorage.getItem is not a function"?
│ └─ Add NODE_OPTIONS="--require ./polyfill-localstorage.cjs"
│ (Node 25+ has broken localStorage — see below)
│
├─ Vercel ──→ Set rootDirectory=packages/nextjs, installCommand="cd ../.. && yarn install"
│ ├─ Fails with "No Next.js version detected"?
│ │ └─ Root Directory not set — fix via Vercel API or dashboard
│ ├─ Fails with "cd packages/nextjs: No such file or directory"?
│ │ └─ Build command still has "cd packages/nextjs" — clear it (root dir handles this)
│ └─ Fails with OOM / exit code 129?
│ └─ Build machine can't handle SE2 monorepo — use IPFS instead or vercel --prebuilt
│
└─ Any path: "TypeError: localStorage.getItem is not a function"
└─ Node 25+ bug. Use --require polyfill (see IPFS section below)Deploying SE2 to Vercel (Monorepo Setup):
SE2 is a monorepo — Vercel needs special configuration:
1. Set Root Directory to packages/nextjs in Vercel project settings 2. Set Install Command to cd ../.. && yarn install (installs from workspace root) 3. Leave Build Command as default (next build — auto-detected) 4. Leave Output Directory as default (.next)
Via Vercel API:
curl -X PATCH "https://api.vercel.com/v9/projects/PROJECT_ID" \
-H "Authorization: Bearer $VERCEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"rootDirectory": "packages/nextjs", "installCommand": "cd ../.. && yarn install"}'Via CLI (after linking):
cd your-se2-project && vercel --prod --yes⚠️ Common mistake: Don't put cd packages/nextjs in the build command — Vercel is already in packages/nextjs because of the root directory setting. Don't use a root-level vercel.json with framework: "nextjs" — Vercel can't find Next.js in the root package.json and fails.
⚠️ Vercel OOM (Out of Memory): SE2's full monorepo install (foundry + nextjs + all deps) can exceed Vercel's 8GB build memory. If build fails with "Out of Memory" / exit code 129:
- Option A: Add env var
NODE_OPTIONS=--max-old-space-size=7168 - Option B (recommended): Build locally and push to IPFS instead (
yarn ipfs) - Option C: Use
vercel --prebuilt(build locally, deploy output to Vercel)
Deploying SE2 to IPFS (BuidlGuidl IPFS):
This is the RECOMMENDED deploy path for SE2. Avoids Vercel's memory limits entirely.
cd packages/nextjs
NODE_OPTIONS="--require ./polyfill-localstorage.cjs" NEXT_PUBLIC_IPFS_BUILD=true NEXT_PUBLIC_IGNORE_BUILD_ERROR=true yarn build
yarn bgipfs upload config init -u https://upload.bgipfs.com -k "$BGIPFS_API_KEY"
yarn bgipfs upload outOr use the built-in script (if it includes the polyfill):
yarn ipfs⚠️ CRITICAL: Node 25+ localStorage Bug
Node.js 25+ ships a built-in localStorage object that's MISSING standard WebStorage API methods (getItem, setItem, etc.). This breaks next-themes, RainbowKit, and any library that calls localStorage.getItem() during static page generation (SSG/export).
Error you'll see:
TypeError: localStorage.getItem is not a function
Error occurred prerendering page "/_not-found"The fix: Create polyfill-localstorage.cjs in packages/nextjs/:
// Polyfill localStorage for Node 25+ static export builds
if (typeof globalThis.localStorage !== "undefined" && typeof globalThis.localStorage.getItem !== "function") {
const store = new Map();
globalThis.localStorage = {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
key: (index) => [...store.keys()][index] ?? null,
get length() { return store.size; },
};
}Then prefix the build with: NODE_OPTIONS="--require ./polyfill-localstorage.cjs"
Why `--require` and not `instrumentation.ts` or `next.config.ts`?
next.config.tspolyfill runs in the main process onlyinstrumentation.tsdoesn't run in the build worker--requireinjects into EVERY Node process, including build workers ✅
Why this happens: The polyfill is needed because Next.js spawns a separate build worker process for prerendering static pages. That worker inherits NODE_OPTIONS, so --require is the only way to guarantee the polyfill runs before any library code.
⚠️ blockexplorer pages: SE2's built-in block explorer uses localStorage at import time and will also fail during static export. Either disable it (rename app/blockexplorer to app/_blockexplorer-disabled) or ensure the polyfill is active.
🚨 STALE BUILD / STALE DEPLOY — THE #1 IPFS FOOTGUN
Problem: You edit page.tsx, then give the user the OLD IPFS URL from a previous deploy. The code changes are in the source but the out/ directory still contains the old build. This has happened MULTIPLE TIMES.
Root cause: The build step (yarn build) produces out/. If you edit source files AFTER building but BEFORE deploying, the deploy uploads stale output. Or worse — you skip rebuilding entirely and just re-upload the old out/.
MANDATORY: After ANY code change, ALWAYS do the full cycle:
# 1. Delete old build artifacts (prevents any caching)
rm -rf .next out
# 2. Rebuild from scratch
NODE_OPTIONS="--require ./polyfill-localstorage.cjs" NEXT_PUBLIC_IPFS_BUILD=true NEXT_PUBLIC_IGNORE_BUILD_ERROR=true yarn build
# 3. VERIFY the new build has your changes (spot-check the JS bundle)
grep -l "YOUR_UNIQUE_STRING" out/_next/static/chunks/app/*.js
# 4. Only THEN upload
yarn bgipfs upload outHow to detect a stale deploy:
# Compare timestamps — source must be OLDER than out/
stat -f '%Sm' app/page.tsx # source modified time
stat -f '%Sm' out/ # build output time
# If source is NEWER than out/ → BUILD IS STALE, rebuild first!The CID is your proof: If the IPFS CID didn't change after a deploy, you deployed the same content. A real code change ALWAYS produces a new CID.
🚨 IPFS ROUTING — WHY ROUTES BREAK AND HOW TO FIX
IPFS gateways serve static files. There's no server to handle routing. Three things MUST be true for routes like /debug to work:
1. `output: "export"` in next.config.ts Without this, Next.js builds for server rendering — no static HTML files are generated, so IPFS has nothing to serve.
2. `trailingSlash: true` in next.config.ts (CRITICAL) This is the #1 reason routes break on IPFS:
trailingSlash: false(default) → generatesdebug.htmltrailingSlash: true→ generatesdebug/index.html
IPFS gateways resolve directories to index.html automatically, but they do NOT resolve bare filenames. So /debug → looks for directory debug/ → finds index.html ✅. Without trailing slash, /debug → no directory, no file match → 404 ❌.
3. Routes must survive static export prerendering During yarn build with output: "export", Next.js prerenders every page to HTML. If a page crashes during prerender (e.g., hooks that need browser APIs, localStorage.getItem is not a function), that route gets SKIPPED — no HTML file is generated, and it 404s on IPFS.
Common prerender killers:
localStorage/sessionStorageusage at import time- Hooks that assume browser environment (
window,document) - SE2's block explorer pages (use
localStorageat import time — rename to_blockexplorer-disabledif not needed)
How to verify routes after build:
# Check that out/ has a directory + index.html for each route
ls out/*/index.html
# Should show: out/debug/index.html, out/other-route/index.html, etc.
# Verify specific route
curl -s -o /dev/null -w "%{http_code}" -L "https://YOUR_GATEWAY/ipfs/CID/debug/"
# Should return 200, not 404The complete IPFS-safe next.config.ts pattern:
const isIpfs = process.env.NEXT_PUBLIC_IPFS_BUILD === "true";
if (isIpfs) {
nextConfig.output = "export"; // static HTML generation
nextConfig.trailingSlash = true; // route/index.html (IPFS needs this!)
nextConfig.images = {
unoptimized: true, // no image optimization server on IPFS
};
}🚀 GO TO PRODUCTION — Full Checklist
When the user says "ship it", follow this EXACT sequence. Steps marked 🤖 are fully automatic. Steps marked 👤 need human input.
---
Step 1: 🤖 Final code review
- Verify all feedback is incorporated in source code
- Test locally (
yarn start) one last time - Check for common issues: duplicate h1, missing AddressInput, raw text inputs
Step 2: 👤 Ask the user what domain they want Ask: "What subdomain do you want for this? e.g. `token.yourname.eth` → `token.yourname.eth.limo`" Save the answer — it determines the production URL for metadata + ENS setup.
Step 3: 🤖 Generate OG image + fix metadata for unfurls
Social unfurls (Twitter, Telegram, Discord, etc.) need THREE things correct: 1. Custom OG image (1200x630 PNG) — NOT the stock SE2 thumbnail 2. Absolute production URL in og:image — NOT localhost:3000 3. `twitter:card` set to `summary_large_image` for large preview
Generate the OG image (public/thumbnail.png, 1200x630):
# Use PIL/Pillow to create a branded 1200x630 OG image with:
# - App name and tagline
# - Production URL (name.yourname.eth.limo)
# - Dark background, clean layout, accent colors
# Save to: packages/nextjs/public/thumbnail.pngFix metadata baseUrl — ensure utils/scaffold-eth/getMetadata.ts supports NEXT_PUBLIC_PRODUCTION_URL:
const baseUrl = process.env.NEXT_PUBLIC_PRODUCTION_URL
? process.env.NEXT_PUBLIC_PRODUCTION_URL
: process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: `http://localhost:${process.env.PORT || 3000}`;If this env var pattern is already in the file, skip this step.
Step 4: 🤖 Clean build + IPFS deploy
cd packages/nextjs
rm -rf .next out
NEXT_PUBLIC_PRODUCTION_URL="https://<name>.yourname.eth.limo" \
NODE_OPTIONS="--require ./polyfill-localstorage.cjs" \
NEXT_PUBLIC_IPFS_BUILD=true NEXT_PUBLIC_IGNORE_BUILD_ERROR=true \
yarn build
# VERIFY (all 3 must pass before uploading):
ls out/*/index.html # routes exist
grep 'og:image' out/index.html # NOT localhost
stat -f '%Sm' app/page.tsx && stat -f '%Sm' out/ # source older than build
# Upload:
yarn bgipfs upload out
# Save the CID!Step 5: 👤 Share IPFS URL for verification Send: "Here's the build for review: `https://community.bgipfs.com/ipfs/<CID>`" Wait for approval before touching ENS. Don't proceed until the user says go.
Step 6: 🤖 Set up ENS subdomain (2 mainnet transactions)
If this is a new app (subdomain doesn't exist yet):
Tx #1 — Create subdomain: 1. Open https://app.ens.domains/yourname.eth in the wallet browser (your wallet profile) 2. Go to "Subnames" tab → "New subname" 3. Enter the label (e.g. token) → Next → Skip profile → Open Wallet → Confirm 4. If gas is stuck: switch MetaMask to Ethereum network → Activity tab → "Speed up"
Tx #2 — Set IPFS content hash: 1. Navigate to https://app.ens.domains/<name>.yourname.eth 2. Go to "Records" tab → "Edit Records" → "Other" tab 3. Paste in Content Hash field: ipfs://<CID> 4. Save → Open Wallet → Confirm in MetaMask
If this is an update to an existing app: skip Tx #1, only do Tx #2 (update the content hash).
Step 7: 🤖 Verify everything
# 1. ENS content hash matches (on-chain)
RESOLVER=$(cast call 0x00000000000C2e074eC69A0dFb2997BA6C7d2e1e \
"resolver(bytes32)(address)" $(cast namehash <name>.yourname.eth) \
--rpc-url https://eth-mainnet.g.alchemy.com/v2/<KEY>)
cast call $RESOLVER "contenthash(bytes32)(bytes)" \
$(cast namehash <name>.yourname.eth) --rpc-url <RPC>
# 2. .limo gateway responds (may take a few minutes for cache)
curl -s -o /dev/null -w "%{http_code}" -L "https://<name>.yourname.eth.limo"
# 3. OG metadata correct
curl -s -L "https://<name>.yourname.eth.limo" | grep 'og:image'
# Should show the production URL, NOT localhostStep 8: 👤 Report to the user Send: "Live at `https://<name>.yourname.eth.limo` — unfurl metadata set, ENS content hash confirmed on-chain."
---
⚠️ Known gotchas:
- MetaMask gas: ENS app sometimes suggests 0.2 gwei — mainnet needs more. Use "Speed up" if stuck.
- .limo caching: Gateway caches content for ~5-15 min. On-chain hash updates immediately but .limo may serve stale content briefly.
- Stock thumbnail: SE2 ships a default
thumbnail.pngandthumbnail.jpg. ALWAYS replace both before production. - localhost in metadata: If
NEXT_PUBLIC_PRODUCTION_URLisn't set, og:image will point tolocalhost:3000. Always verify withgrep.
DO NOT:
- Run
yarn chain(useyarn fork --network <chain>instead!) - Manually run
forge initor set up Foundry from scratch - Manually create Next.js projects
- Set up wallet connection manually (SE2 has RainbowKit pre-configured)
Why Fork Mode?
yarn chain (WRONG) yarn fork --network base (CORRECT)
└─ Empty local chain └─ Fork of real Base mainnet
└─ No protocols └─ Uniswap, Aave, etc. available
└─ No tokens └─ Real USDC, WETH exist
└─ Testing in isolation └─ Test against REAL stateAddress Data Available
Token, protocol, and whale addresses are in data/addresses/:
tokens.json- WETH, USDC, DAI, etc. per chainprotocols.json- Uniswap, Aave, Chainlink per chainwhales.json- Large token holders for test funding
---
THE MOST CRITICAL CONCEPT
NOTHING IS AUTOMATIC ON ETHEREUM.
Smart contracts cannot execute themselves. There is no cron job, no scheduler, no background process. For EVERY function that "needs to happen":
1. Make it callable by ANYONE (not just admin) 2. Give callers a REASON (profit, reward, their own interest) 3. Make the incentive SUFFICIENT to cover gas + profit
Always ask: "Who calls this function? Why would they pay gas?"
If you can't answer this, your function won't get called.
Examples of Proper Incentive Design
// LIQUIDATIONS: Caller gets bonus collateral
function liquidate(address user) external {
require(getHealthFactor(user) < 1e18, "Healthy");
uint256 bonus = collateral * 5 / 100; // 5% bonus
collateralToken.transfer(msg.sender, collateral + bonus);
}
// YIELD HARVESTING: Caller gets % of harvest
function harvest() external {
uint256 yield = protocol.claimRewards();
uint256 callerReward = yield / 100; // 1%
token.transfer(msg.sender, callerReward);
}
// CLAIMS: User wants their own tokens
function claimRewards() external {
uint256 reward = pendingRewards[msg.sender];
pendingRewards[msg.sender] = 0;
token.transfer(msg.sender, reward);
}---
Critical Gotchas (Memorize These)
1. Token Decimals Vary
USDC = 6 decimals, not 18!
// BAD: Assumes 18 decimals - transfers 1 TRILLION USDC!
uint256 oneToken = 1e18;
// GOOD: Check decimals
uint256 oneToken = 10 ** token.decimals();Common decimals:
- USDC, USDT: 6 decimals
- WBTC: 8 decimals
- Most tokens (DAI, WETH): 18 decimals
2. ERC-20 Approve Pattern Required
Contracts cannot pull tokens directly. Two-step process:
// Step 1: User approves
token.approve(spenderContract, amount);
// Step 2: Contract pulls tokens
token.transferFrom(user, address(this), amount);Never use infinite approvals:
// DANGEROUS
token.approve(spender, type(uint256).max);
// SAFE
token.approve(spender, exactAmount);3. No Floating Point in Solidity
Use basis points (1 bp = 0.01%):
// BAD: This equals 0
uint256 fivePercent = 5 / 100;
// GOOD: Basis points
uint256 FEE_BPS = 500; // 5% = 500 basis points
uint256 fee = (amount * FEE_BPS) / 10000;4. Reentrancy Attacks
External calls can call back into your contract:
// SAFE: Checks-Effects-Interactions pattern
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
balances[msg.sender] = 0; // Effect BEFORE interaction
(bool success,) = msg.sender.call{value: bal}("");
require(success);
}Always use OpenZeppelin's ReentrancyGuard.
5. Never Use DEX Spot Prices as Oracles
Flash loans can manipulate spot prices instantly:
// SAFE: Use Chainlink
function getPrice() internal view returns (uint256) {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, "Stale");
require(price > 0, "Invalid");
return uint256(price);
}6. Vault Inflation Attack
First depositor can steal funds via share manipulation:
// Mitigation: Virtual offset
function convertToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(totalSupply() + 1e3, totalAssets() + 1);
}7. Use SafeERC20
Some tokens (USDT) don't return bool on transfer:
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(to, amount); // Handles non-standard tokens---
Scaffold-ETH 2 Development
Project Structure
packages/
├── foundry/ # Smart contracts
│ ├── contracts/ # Your Solidity files
│ └── script/ # Deploy scripts
└── nextjs/
├── app/ # React pages
└── contracts/ # Generated ABIs + externalContracts.tsEssential Hooks
// Read contract data
const { data } = useScaffoldReadContract({
contractName: "YourContract",
functionName: "greeting",
});
// Write to contract
const { writeContractAsync } = useScaffoldWriteContract("YourContract");
// Watch events
useScaffoldEventHistory({
contractName: "YourContract",
eventName: "Transfer",
fromBlock: 0n,
});---
SpeedRun Ethereum Challenges
Reference these for hands-on learning:
| Challenge | Concept | Key Lesson |
|---|---|---|
| 0: Simple NFT | ERC-721 | Minting, metadata, tokenURI |
| 1: Staking | Coordination | Deadlines, escrow, thresholds |
| 2: Token Vendor | ERC-20 | Approve pattern, buy/sell |
| 3: Dice Game | Randomness | On-chain randomness is insecure |
| 4: DEX | AMM | x*y=k formula, slippage |
| 5: Oracles | Price Feeds | Chainlink, manipulation resistance |
| 6: Lending | Collateral | Health factor, liquidation incentives |
| 7: Stablecoins | Pegging | CDP, over-collateralization |
| 8: Prediction Markets | Resolution | Outcome determination |
| 9: ZK Voting | Privacy | Zero-knowledge proofs |
| 10: Multisig | Signatures | Threshold approval |
| 11: SVG NFT | On-chain Art | Generative, base64 encoding |
---
DeFi Protocol Patterns
Uniswap (AMM)
- Constant product formula: x * y = k
- Slippage protection required
- LP tokens represent pool share
Aave (Lending)
- Supply collateral, borrow assets
- Health factor = collateral value / debt value
- Liquidation when health factor < 1
ERC-4626 (Tokenized Vaults)
- Standard interface for yield-bearing vaults
- deposit/withdraw with share accounting
- Protect against inflation attacks
---
Security Review Checklist
Before deployment, verify:
- [ ] Access control on all admin functions
- [ ] Reentrancy protection (CEI + nonReentrant)
- [ ] Token decimal handling correct
- [ ] Oracle manipulation resistant
- [ ] Integer overflow handled (0.8+ or SafeMath)
- [ ] Return values checked (SafeERC20)
- [ ] Input validation present
- [ ] Events emitted for state changes
- [ ] Incentives designed for maintenance functions
- [ ] NO infinite approvals (use exact amounts, NEVER type(uint256).max)
---
Response Guidelines
When helping developers:
1. Follow the fork workflow - Always use yarn fork, never yarn chain 2. Answer directly - Address their question first 3. Show code - Provide working examples 4. Warn about gotchas - Proactively mention relevant pitfalls 5. Reference challenges - Point to SpeedRun Ethereum for practice 6. Ask about incentives - For any "automatic" function, ask who calls it and why
AGENTS.md - Ethereum Wingman
This file provides comprehensive guidance to AI coding agents (Claude Code, Cursor, Copilot, etc.) when working on Ethereum smart contract development.
Version: 2.0.0 Author: BuidlGuidl Last Updated: January 2026
---
AI AGENT INSTRUCTIONS - READ THIS FIRST
Default Stack: Scaffold-ETH 2 with Fork Mode
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 nameStep 2: Fix Polling Interval
Edit packages/nextjs/scaffold.config.ts and change:
pollingInterval: 30000, // Default: 30 seconds (way too slow!)to:
pollingInterval: 3000, // 3 seconds (much better for development)Step 3: Install & Fork a Live Network
cd <project-name>
yarn install
yarn fork --network base # or mainnet, arbitrum, optimism, polygonStep 4: Enable Auto Block Mining (REQUIRED!)
# In a new terminal, enable interval mining (1 block/second)
cast rpc anvil_setIntervalMining 1Without this, block.timestamp stays FROZEN at the fork point and time-dependent logic breaks (deadlines, vesting, staking periods, oracle staleness checks).
Optional: Make it permanent by editing packages/foundry/package.json to add --block-time 1 to the fork script.
Step 5: Deploy to Local Fork (FREE!)
yarn deployStep 6: Start Frontend
yarn startStep 7: Test the Frontend
After the frontend is running, open a browser and test the app as the burner wallet user:
1. Navigate to http://localhost:3000 2. Take a snapshot to get page elements (the burner wallet address is in the header) 3. Click the faucet to fund the burner wallet with ETH 4. Transfer tokens from whales if needed (use burner address from page) 5. Click through the app to verify functionality
Use the cursor-browser-extension MCP tools:
browser_navigate- Open the app URLbrowser_snapshot- Get element refs for clickingbrowser_click- Click buttons (faucet, buy, stake, etc.)browser_type- Enter values into input fieldsbrowser_wait_for- Wait for transaction confirmation
See tools/testing/frontend-testing.md for detailed workflows.
Speed Note: This is a fullstack app - browser testing is the primary test method. On a local fork, transactions confirm instantly. With pollingInterval: 3000, the UI updates within 3 seconds. Don't wait 20-30 seconds between clicks - each action takes just a few seconds total.
DO NOT:
- Run
yarn chain(useyarn fork --network <chain>instead - gives you real protocol state!) - Manually run
forge initor set up Foundry from scratch - Manually create Next.js projects
- Set up wallet connection manually (SE2 has RainbowKit pre-configured)
- Create custom deploy scripts (use SE2's deploy system)
Why Fork Mode?
yarn chain (WRONG) yarn fork --network base (CORRECT)
└─ Empty local chain └─ Fork of real Base mainnet
└─ No protocols └─ Uniswap, Aave, etc. all available
└─ No tokens └─ Real USDC, WETH balances exist
└─ Testing in isolation └─ Test against REAL protocol state
└─ Can't integrate DeFi └─ Full DeFi composabilityAuto Block Mining (Covered in Step 4)
Step 4 above is REQUIRED. Without interval mining, block.timestamp stays frozen at the fork point.
Alternative: Start Anvil directly with --block-time flag:
anvil --fork-url $RPC_URL --block-time 1Address Data Available
Token, protocol, and whale addresses are in data/addresses/:
tokens.json- WETH, USDC, DAI, etc. per chainprotocols.json- Uniswap, Aave, Chainlink, etc. per chainwhales.json- Addresses with large token balances for testing
Funding Test Wallets on Fork
# Give whale ETH for gas
cast rpc anvil_setBalance 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb 0x56BC75E2D63100000
# Impersonate Morpho Blue (USDC whale on Base)
cast rpc anvil_impersonateAccount 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb
# Transfer 10,000 USDC (6 decimals)
cast send 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 \
"transfer(address,uint256)" YOUR_ADDRESS 10000000000 \
--from 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb --unlocked---
🚨 FRONTEND UX RULES (MANDATORY)
These are hard rules. A build is NOT done until all are satisfied.
Rule 1: Every Onchain Button — Loader + Disable
ANY button triggering a blockchain tx MUST disable on click and show a loader. Each button gets its own loading state — NEVER share a single isLoading across multiple buttons (causes wrong text on wrong button when UI switches).
const [isApproving, setIsApproving] = useState(false);
<button disabled={isApproving} onClick={async () => {
setIsApproving(true);
try { await writeContractAsync({ functionName: "approve", args: [...] }); }
catch (e) { notification.error("Failed"); }
finally { setIsApproving(false); }
}}>
{isApproving ? "Approving..." : "Approve"}
</button>Rule 2: Three-Button Flow — Network → Approve → Action
For approve-then-action patterns, show exactly ONE button: wrong network → "Switch to Base" | not enough approved → "Approve" | approved → "Stake/Deposit". Always read allowance via a hook (auto-updates). Network check comes FIRST — approve on wrong network breaks everything.
Rule 3: Address Display — Always <Address/>
Every Ethereum address must use scaffold-eth's <Address/> component. Never render raw hex.
Rule 4: RPC — Never Public RPCs
Always configure rpcOverrides in scaffold.config.ts with reliable RPCs (Alchemy, Infura, etc.). Public RPCs (mainnet.base.org) rate-limit. Monitor polling: ~1 req/3sec is correct, 15+/sec means a bug.
Rule 5: Pre-Publish Checklist
Before deploying to production: OG/Twitter meta with absolute live URL for images (not localhost/relative/env var), correct page title, updated favicon, footer link to your repo, README updated, no hardcoded localhost/testnet values.
See tools/testing/frontend-qa-checklist.md for full browser test protocols.
---
🔄 THREE-PHASE BUILD PROCESS
Bugs should be caught in the cheapest phase. Don't jump to production.
Phase 1: Localhost + Local Chain + Burner Wallet — Free, instant. Test logic, rendering, flows. Exit: all pages render, all buttons work, forge test passes, no console errors.
Phase 2: Localhost + Live L2 + MetaMask — Real gas, 2-3sec tx times. Test wallet UX: loaders, double-click prevention, approve flow, network switching, RPC stability. Exit: every button has its own loader, approve flow works, reject recovers gracefully.
Phase 3: Live Frontend + Live Chain — Highest cost, slowest loop. Test unfurls, no localhost artifacts, production env. Exit: OG unfurl works, all Phase 2 criteria pass on live URL.
Golden rule: Every bug found in Phase 3 means Phase 1 or 2 testing failed.
See tools/testing/frontend-qa-checklist.md for detailed exit criteria and browser test protocols per phase.
---
THE MOST CRITICAL CONCEPT IN ETHEREUM DEVELOPMENT
┌─────────────────────────────────────────────────────────────────┐
│ │
│ SMART CONTRACTS CANNOT EXECUTE THEMSELVES. │
│ │
│ There is no cron job. No scheduler. No background process. │
│ Nothing happens unless an EOA sends a transaction. │
│ │
│ Your job as a builder: │
│ 1. Expose functions that ANYONE can call │
│ 2. Design INCENTIVES so someone WANTS to call them │
│ 3. Make it PROFITABLE to keep your protocol running │
│ │
│ If no one has a reason to call your function, it won't run. │
│ │
└─────────────────────────────────────────────────────────────────┘The Question You Must Always Ask
"WHO CALLS THIS FUNCTION? WHY WOULD THEY PAY GAS?"
Incentive Design Patterns
Pattern 1: Natural User Interest
// Users WANT to claim their rewards
function claimRewards() external {
uint256 reward = pendingRewards[msg.sender];
require(reward > 0, "No rewards");
pendingRewards[msg.sender] = 0;
rewardToken.transfer(msg.sender, reward);
}
// Will be called: Yes, users want their moneyPattern 2: Caller Rewards (Keeper Incentives)
// LIQUIDATION: Caller gets bonus for liquidating unhealthy positions
function liquidate(address user) external {
require(getHealthFactor(user) < 1e18, "Position healthy");
uint256 debt = userDebt[user];
uint256 collateral = userCollateral[user];
debtToken.transferFrom(msg.sender, address(this), debt);
// Liquidator gets collateral + 5% BONUS
uint256 bonus = (collateral * 500) / 10000;
collateralToken.transfer(msg.sender, collateral + bonus);
userDebt[user] = 0;
userCollateral[user] = 0;
}
// Incentive: Liquidator profits from the bonusPattern 3: Yield Harvesting
// Caller gets a cut for triggering harvest
function harvest() external {
uint256 yield = externalProtocol.claimRewards();
uint256 callerReward = yield / 100; // 1%
rewardToken.transfer(msg.sender, callerReward);
rewardToken.transfer(address(vault), yield - callerReward);
}
// Incentive: Caller gets 1% of harvested yieldAnti-Patterns to Avoid
// BAD: This will NEVER run automatically!
function dailyDistribution() external {
require(block.timestamp >= lastDistribution + 1 days);
// This sits here forever if no one calls it
}
// BAD: Why would anyone pay gas?
function updateGlobalState() external {
globalCounter++;
// Nobody will call this. Gas costs money.
}
// BAD: Single point of failure
function processExpiredPositions() external onlyOwner {
// What if admin goes offline? Protocol stops working!
}---
Critical Gotchas (12 Must-Know Rules)
1. Token Decimals Vary
USDC = 6 decimals, not 18!
// BAD: Assumes 18 decimals - transfers 1 TRILLION USDC!
uint256 oneToken = 1e18;
token.transfer(user, oneToken);
// GOOD: Check decimals
uint256 oneToken = 10 ** token.decimals();
token.transfer(user, oneToken);| Token | Decimals | 1 Token = |
|---|---|---|
| USDC, USDT | 6 | 1,000,000 |
| WBTC | 8 | 100,000,000 |
| DAI, WETH, most | 18 | 1e18 |
2. ETH is Measured in Wei
1 ETH = 10^18 wei
// BAD: Sends 1 wei (almost nothing)
payable(user).transfer(1);
// GOOD: Use ether keyword
payable(user).transfer(1 ether);
payable(user).transfer(0.1 ether);3. ERC-20 Approve Pattern Required
Contracts cannot pull tokens without approval!
// Two-step process:
// 1. User calls: token.approve(spender, amount)
// 2. Spender calls: token.transferFrom(user, recipient, amount)
// DANGEROUS: Allows draining all tokens
token.approve(spender, type(uint256).max);
// SAFE: Approve exact amount
token.approve(spender, exactAmount);4. Solidity Has No Floating Point
Use basis points (1 bp = 0.01%):
// BAD: This equals 0, not 0.05
uint256 fivePercent = 5 / 100;
// GOOD: Basis points
uint256 FEE_BPS = 500; // 5% = 500 basis points
uint256 fee = (amount * FEE_BPS) / 10000;
// GOOD: Multiply before divide
uint256 fee = (amount * 5) / 100;5. Reentrancy Attacks
External calls can call back into your contract:
// VULNERABLE
function withdraw() external {
uint256 bal = balances[msg.sender];
(bool success,) = msg.sender.call{value: bal}("");
balances[msg.sender] = 0; // Too late! Already re-entered
}
// SAFE: Checks-Effects-Interactions pattern
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
balances[msg.sender] = 0; // Effect BEFORE interaction
(bool success,) = msg.sender.call{value: bal}("");
require(success);
}Always use OpenZeppelin's ReentrancyGuard.
6. Never Use DEX Spot Prices as Oracles
Flash loans can manipulate spot prices instantly:
// VULNERABLE: Flash loan attack
function getPrice() internal view returns (uint256) {
return dex.getSpotPrice();
}
// SAFE: Use Chainlink
function getPrice() internal view returns (uint256) {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, "Stale");
require(price > 0, "Invalid");
return uint256(price);
}7. Vault Inflation Attack (First Depositor)
First depositor can manipulate share price to steal from later depositors:
// ATTACK:
// 1. Deposit 1 wei -> get 1 share
// 2. Donate 10000 tokens directly
// 3. Share price = 10001 / 1 = 10001 per share
// 4. Victim deposits 9999 -> gets 0 shares
// 5. Attacker redeems 1 share -> gets all 20000 tokens
// Mitigation: Virtual offset
function convertToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(totalSupply() + 1e3, totalAssets() + 1);
}8. Access Control Missing
Anyone can call unprotected functions:
// VULNERABLE: Anyone can withdraw
function withdrawAll() external {
payable(msg.sender).transfer(address(this).balance);
}
// SAFE: Owner only
function withdrawAll() external onlyOwner {
payable(owner).transfer(address(this).balance);
}9. Integer Overflow (Pre-0.8)
Solidity 0.8+ has built-in checks, but watch for unchecked blocks:
// 0.8+ DANGEROUS if using unchecked
unchecked {
uint8 x = 255;
x += 1; // x = 0 (overflow!)
}10. Unchecked Return Values
Some tokens (USDT) don't return bool on transfer:
// VULNERABLE: USDT doesn't return bool
bool success = token.transfer(to, amount);
// SAFE: Use SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(to, amount);11. Timestamp Dependence
Miners can manipulate timestamps by ~15 seconds:
// VULNERABLE for precise timing
require(block.timestamp == exactTime);
// OK for approximate timing (hours/days)
require(block.timestamp >= deadline);12. tx.origin Authentication
Never use for access control:
// VULNERABLE: Phishing attack
require(tx.origin == owner);
// SAFE: Use msg.sender
require(msg.sender == owner);---
Historical Hacks: Lessons Learned
The DAO Hack (2016) - $50M
Vulnerability: Reentrancy attack Lesson: Always update state BEFORE external calls
bZx Flash Loan (2020) - ~$1M
Vulnerability: DEX spot price as oracle Lesson: NEVER use spot DEX prices for anything valuable
Nomad Bridge (2022) - $190M
Vulnerability: Zero root accepted as valid Lesson: Always validate against zero values explicitly
Wormhole (2022) - $326M
Vulnerability: Deprecated function with incomplete verification Lesson: Remove deprecated code completely
---
Scaffold-ETH 2 Development
Project Structure
packages/
├── foundry/ # Smart contracts (recommended)
│ ├── contracts/ # Your Solidity files
│ ├── script/ # Deploy scripts
│ └── test/ # Forge tests
└── nextjs/
├── app/ # React pages
├── components/ # UI components
└── contracts/ # Generated ABIs + externalContracts.tsEssential Hooks
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
// Read contract data
const { data: greeting } = useScaffoldReadContract({
contractName: "YourContract",
functionName: "greeting",
});
// Write to contract
const { writeContractAsync } = useScaffoldWriteContract("YourContract");
await writeContractAsync({
functionName: "setGreeting",
args: ["Hello!"],
});
// Watch events
useScaffoldEventHistory({
contractName: "YourContract",
eventName: "GreetingChange",
fromBlock: 0n,
});
// Get deployed contract info
const { data: contractInfo } = useDeployedContractInfo("YourContract");Adding External Contracts
Edit packages/nextjs/contracts/externalContracts.ts:
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
const externalContracts = {
31337: { // Local fork chainId
USDC: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
abi: [...], // ERC20 ABI
},
},
8453: { // Base mainnet (for production)
USDC: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
abi: [...],
},
},
} as const satisfies GenericContractsDeclaration;
export default externalContracts;---
DeFi Protocol Integration
Uniswap V3 Swapping
import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol";
function swapExactInput(uint256 amountIn) external returns (uint256) {
IERC20(tokenIn).approve(address(swapRouter), amountIn);
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
tokenIn: DAI,
tokenOut: WETH,
fee: 3000, // 0.3%
recipient: msg.sender,
deadline: block.timestamp,
amountIn: amountIn,
amountOutMinimum: expectedOut * 995 / 1000, // 0.5% slippage
sqrtPriceLimitX96: 0
});
return swapRouter.exactInputSingle(params);
}Aave V3 Supply and Borrow
import "@aave/v3-core/contracts/interfaces/IPool.sol";
// Supply collateral
IERC20(asset).approve(address(pool), amount);
pool.supply(asset, amount, address(this), 0);
// Borrow against collateral
// interestRateMode: 2 = variable rate
pool.borrow(borrowAsset, borrowAmount, 2, 0, address(this));
// Check health factor before risky operations
(,,,,,uint256 healthFactor) = pool.getUserAccountData(user);
require(healthFactor > 1.1e18, "Too risky");Chainlink Price Feed
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
AggregatorV3Interface priceFeed = AggregatorV3Interface(PRICE_FEED_ADDRESS);
function getLatestPrice() public view returns (uint256) {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, "Stale price");
require(price > 0, "Invalid price");
return uint256(price);
}---
SpeedRun Ethereum Challenge Reference
| # | Challenge | Key Concept | Critical Lesson |
|---|---|---|---|
| 0 | Simple NFT | ERC-721 | tokenURI, metadata, minting |
| 1 | Staking | Coordination | Deadlines, thresholds, escrow |
| 2 | Token Vendor | ERC-20 | approve pattern, buy/sell |
| 3 | Dice Game | Randomness | On-chain random is predictable |
| 4 | DEX | AMM | x*y=k, slippage, liquidity |
| 5 | Oracles | Price Feeds | Chainlink, manipulation |
| 6 | Lending | Collateral | Health factor, liquidation |
| 7 | Stablecoins | Pegging | CDP, collateral ratio |
| 8 | Prediction Markets | Resolution | Outcome determination |
| 9 | ZK Voting | Privacy | Zero-knowledge proofs |
| 10 | Multisig | Signatures | Threshold approval |
| 11 | SVG NFT | On-chain Art | Generative, base64 |
---
Security Review Checklist
Before any deployment, verify:
Access Control
- [ ] All admin functions have proper modifiers
- [ ] No function uses tx.origin for auth
- [ ] Initialize functions can only be called once
Reentrancy
- [ ] CEI pattern followed (Checks-Effects-Interactions)
- [ ] ReentrancyGuard on functions with external calls
- [ ] No state changes after external calls
Token Handling
- [ ] Token decimals checked (not assumed 18)
- [ ] SafeERC20 used for transfers
- [ ] No infinite approvals
- [ ] Approval race condition handled
Math & Oracles
- [ ] Multiply before divide
- [ ] Basis points used for percentages
- [ ] Chainlink used (not DEX spot price)
- [ ] Staleness check on oracle data
Protocol Safety
- [ ] Vault inflation attack mitigated
- [ ] Flash loan resistance considered
- [ ] Input validation present
- [ ] Events emitted for state changes
Maintenance
- [ ] Functions have caller incentives
- [ ] No admin-only critical functions
- [ ] Emergency pause capability
---
Writing Solidity Code
Always include:
- SPDX license identifier
- Pragma version 0.8.x+
- OpenZeppelin imports for standard patterns
- NatSpec documentation for public functions
- Events for state changes
- Access control on admin functions
- Input validation (zero checks, bounds)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title MyProtocol
/// @notice Description of what this contract does
/// @dev Implementation details
contract MyProtocol is ReentrancyGuard, Ownable {
using SafeERC20 for IERC20;
/// @notice Emitted when user deposits
event Deposit(address indexed user, uint256 amount);
/// @notice Deposit tokens into the protocol
/// @param amount Amount to deposit
function deposit(uint256 amount) external nonReentrant {
require(amount > 0, "Amount must be > 0");
// Effects before interactions
balances[msg.sender] += amount;
// Safe token transfer
token.safeTransferFrom(msg.sender, address(this), amount);
emit Deposit(msg.sender, amount);
}
}---
Response Guidelines for AI Agents
When helping developers:
1. Follow the fork workflow - Always use yarn fork --network <chain>, never yarn chain 2. Answer directly - Address their question first 3. Show code - Provide working, complete examples 4. Warn about gotchas - Proactively mention relevant pitfalls 5. Ask about incentives - For any "automatic" function, ask: "Who calls this? Why would they pay gas?" 6. Test the frontend - After deploying, open browser, fund burner wallet, click through app 7. Reference challenges - Point to SpeedRun Ethereum for hands-on practice 8. Consider security - Always mention relevant security considerations 9. Use address data - Reference data/addresses/ for token/protocol addresses
{
"version": "1.0.0",
"organization": "BuidlGuidl",
"date": "January 2026",
"abstract": "Comprehensive Ethereum development guide for AI agents. Covers smart contract development, DeFi protocol integration (Uniswap, Aave), security best practices, and the SpeedRun Ethereum curriculum. Emphasizes the critical concept that 'nothing is automatic on Ethereum' - every function needs incentive design. Contains 12 critical gotchas, historical hack lessons, and production-ready code patterns.",
"keywords": [
"ethereum",
"solidity",
"web3",
"defi",
"smart-contracts",
"scaffold-eth",
"speedrun-ethereum",
"security",
"erc20",
"erc721",
"uniswap",
"aave",
"chainlink"
],
"capabilities": [
"ethereum-fundamentals",
"solidity-development",
"defi-protocol-integration",
"security-auditing",
"scaffold-eth-tooling",
"code-review"
],
"references": [
"https://speedrunethereum.com/",
"https://scaffoldeth.io/",
"https://docs.scaffoldeth.io/",
"https://docs.openzeppelin.com/",
"https://docs.uniswap.org/",
"https://docs.aave.com/",
"https://docs.chain.link/",
"https://rekt.news/leaderboard/"
]
}
Ethereum Wingman
A comprehensive Ethereum development skill for AI coding agents. Provides security warnings, DeFi protocol guidance, and the critical gotchas that prevent costly mistakes.
Installation
For Cursor Users
Important: Open your project in Cursor first, then run the install command.
# 1. Open your project folder in Cursor
# 2. Then run in the terminal:
npx skills add austintgriffith/ethereum-wingmanWhy this order? Cursor indexes skills when the project opens. Installing before Cursor is open may not be detected until you reload.
For Other Agents (Claude Code, Codex, etc.)
npx skills add austintgriffith/ethereum-wingmanWhat It Does
This skill enhances AI agents with deep knowledge of:
- SpeedRun Ethereum Challenges - Hands-on learning curriculum
- Scaffold-ETH 2 Tooling - Full-stack dApp development
- DeFi Protocol Integration - Uniswap, Aave, Chainlink patterns
- Security Best Practices - Critical gotchas and historical hacks
The Most Important Concept
🚨 NOTHING IS AUTOMATIC ON ETHEREUM 🚨
Smart contracts cannot execute themselves. For any function that "needs to happen":
1. Make it callable by ANYONE 2. Give callers a REASON (profit, reward) 3. Make the incentive SUFFICIENT
Always ask: "Who calls this function? Why would they pay gas?"
Critical Gotchas
1. Token Decimals - USDC has 6 decimals, not 18! 2. Approve Pattern - Required for ERC-20 token transfers 3. Reentrancy - Use CEI pattern + ReentrancyGuard 4. Oracle Security - Never use DEX spot prices 5. No Floats - Use basis points (500 = 5%) 6. Vault Inflation - Protect first depositors
Trigger Phrases
The skill activates when you mention:
- "build a dApp"
- "create smart contract"
- "help with Solidity"
- "SpeedRun Ethereum"
- Any Ethereum/DeFi development task
Scripts
Initialize Project
bash scripts/init-project.sh my-dapp baseCheck for Gotchas
bash scripts/check-gotchas.sh ./contractsMCP Integration
For the full experience with eth-mcp tools:
- Project scaffolding:
stack_init,stack_start - Address lookup:
addresses_getToken,addresses_getProtocol - DeFi data:
defi_getYields,defi_compareYields - Education:
education_getChecklist,education_getCriticalLessons
Resources
License
MIT License - Use freely for learning and building.
Automation, Incentives & Keepers
THE MOST IMPORTANT CONCEPT IN ETHEREUM DEVELOPMENT
┌─────────────────────────────────────────────────────────────────┐
│ 🚨 CRITICAL INSIGHT FOR NEW BUILDERS 🚨 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SMART CONTRACTS CANNOT EXECUTE THEMSELVES. │
│ │
│ There is no cron job. No scheduler. No background process. │
│ Nothing happens unless an EOA sends a transaction. │
│ │
│ Your job as a builder: │
│ 1. Expose functions that ANYONE can call │
│ 2. Design INCENTIVES so someone WANTS to call them │
│ 3. Make it PROFITABLE to keep your protocol running │
│ │
│ If no one has a reason to call your function, it won't run. │
│ │
└─────────────────────────────────────────────────────────────────┘The Reactive Nature of Ethereum
Unlike traditional servers that can run scheduled tasks, Ethereum is purely reactive:
Traditional Web App:
┌─────────────────────────────────────────────────────────────────┐
│ Server runs cron job at midnight → Process subscriptions │
│ Timer triggers every hour → Check for expired items │
│ Background worker → Process queue automatically │
└─────────────────────────────────────────────────────────────────┘
Ethereum Smart Contract:
┌─────────────────────────────────────────────────────────────────┐
│ Contract sits dormant... │
│ ...waiting... │
│ ...nothing happens... │
│ Someone sends transaction → Code executes → Back to dormant │
└─────────────────────────────────────────────────────────────────┘Every single state change requires: 1. An EOA (wallet) to initiate a transaction 2. Gas to be paid for execution 3. Someone to decide it's worth calling
The Question You Must Always Ask
"WHO CALLS THIS FUNCTION? WHY WOULD THEY?"
// You write this function:
function checkAndDistributeRewards() external {
if (block.timestamp >= rewardTime) {
// distribute rewards
}
}
// Ask yourself:
// 1. Who will call this?
// 2. Why would they pay gas to call it?
// 3. What do they get in return?
// 4. What happens if NO ONE calls it?Incentive Design Patterns
Pattern 1: Natural User Interest
The simplest case - users call functions because they want the outcome.
// Users WANT to claim their rewards
function claimRewards() external {
uint256 reward = pendingRewards[msg.sender];
require(reward > 0, "No rewards");
pendingRewards[msg.sender] = 0;
rewardToken.transfer(msg.sender, reward);
}
// ✅ Incentive: User gets tokens they're owed
// ✅ Will be called: Yes, users want their money// Users WANT to withdraw their deposits
function withdraw(uint256 amount) external {
require(deposits[msg.sender] >= amount);
deposits[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
// ✅ Incentive: User gets their money back
// ✅ Will be called: Yes, when users need fundsPattern 2: Caller Rewards (Keeper Incentives)
Pay the caller for performing necessary maintenance.
// LIQUIDATION: Caller gets bonus for liquidating unhealthy positions
function liquidate(address user) external {
require(getHealthFactor(user) < 1e18, "Position healthy");
uint256 debt = userDebt[user];
uint256 collateral = userCollateral[user];
// Liquidator pays the debt
debtToken.transferFrom(msg.sender, address(this), debt);
// Liquidator gets collateral + 5% BONUS
uint256 bonus = (collateral * 500) / 10000;
collateralToken.transfer(msg.sender, collateral + bonus);
// Clear user's position
userDebt[user] = 0;
userCollateral[user] = 0;
}
// ✅ Incentive: Liquidator profits from the bonus
// ✅ Will be called: Yes, bots compete to liquidate// YIELD HARVESTING: Caller gets a cut for triggering harvest
function harvest() external {
uint256 yield = externalProtocol.claimRewards();
// Give caller 1% for triggering harvest
uint256 callerReward = yield / 100;
rewardToken.transfer(msg.sender, callerReward);
// Rest goes to vault
rewardToken.transfer(address(vault), yield - callerReward);
}
// ✅ Incentive: Caller gets 1% of harvested yield
// ✅ Will be called: Yes, profitable for harvestersPattern 3: MEV Opportunities
Searchers will call functions if there's extractable value.
// ARBITRAGE: Price difference creates opportunity
function rebalance() external {
uint256 ourPrice = getOurPrice();
uint256 marketPrice = getMarketPrice();
if (ourPrice < marketPrice) {
// Buy from us, sell on market
// Arbitrageur profits from difference
}
}
// ✅ Incentive: Arbitrage profit
// ✅ Will be called: Yes, MEV bots are always watchingPattern 4: Conditional Execution with Rewards
// Execute user's order when price target hit
struct Order {
address user;
uint256 targetPrice;
uint256 amount;
uint256 reward; // Bounty for executor
}
mapping(uint256 => Order) public orders;
function executeOrder(uint256 orderId) external {
Order memory order = orders[orderId];
uint256 currentPrice = oracle.getPrice();
require(currentPrice >= order.targetPrice, "Price not reached");
// Execute the trade
_executeTrade(order.user, order.amount);
// Pay the executor their reward
payable(msg.sender).transfer(order.reward);
delete orders[orderId];
}
// ✅ Incentive: Executor earns the reward bounty
// ✅ Will be called: Yes, when price target hitReal-World Examples
DeFi Liquidations (Aave, Compound, MakerDAO)
How it works:
1. User borrows $80 against $100 collateral (80% LTV)
2. Collateral value drops to $90
3. Position is now undercollateralized
4. ANYONE can call liquidate()
5. Liquidator pays debt, gets collateral + 5-10% bonus
6. Liquidators run bots 24/7 competing for these opportunities
Why it works:
- Liquidators profit from the bonus
- Competition ensures quick liquidation
- Protocol stays solvent
- No central entity neededChainlink Keepers / Gelato / Keep3r
Problem: Your contract needs regular maintenance
Solution: Pay a decentralized network of keepers
function checkUpkeep(bytes calldata) external view returns (bool, bytes memory) {
// Return true if work needs to be done
return (shouldHarvest(), "");
}
function performUpkeep(bytes calldata) external {
require(shouldHarvest(), "Not needed");
_harvest();
// Keeper network pays gas, gets compensated by you
}Yield Optimizer Auto-Compounding
Protocol: Beefy Finance, Yearn
Every X hours, someone needs to:
1. Claim farming rewards
2. Swap to base asset
3. Reinvest into pool
Incentive: Caller gets 0.5-1% of harvested rewards
Result: Bots compete to compound, users get auto-compoundingAnti-Patterns: What NOT To Do
❌ Expecting Automatic Execution
// BAD: This will NEVER run automatically!
function dailyDistribution() external {
require(block.timestamp >= lastDistribution + 1 days);
// This sits here forever if no one calls it
}❌ No Incentive to Call
// BAD: Why would anyone pay gas to call this?
function updateGlobalState() external {
// Updates state that doesn't benefit caller
globalCounter++;
}
// Nobody will call this. Gas costs money.❌ Admin-Only Critical Functions
// BAD: Single point of failure
function processExpiredPositions() external onlyOwner {
// What if admin goes offline?
// What if admin key is lost?
// Protocol stops working!
}
// GOOD: Anyone can call with proper incentives
function processExpiredPosition(uint256 positionId) external {
require(positions[positionId].expiry < block.timestamp);
// Process and reward caller
}Designing For Automation: A Checklist
When building any function that "needs to happen":
□ Can ANYONE call this function? (not just owner/admin)
□ Is there a clear INCENTIVE for the caller?
- Direct payment/reward?
- MEV opportunity?
- Natural user interest?
□ Is the incentive SUFFICIENT to cover gas + profit?
- On L1 mainnet, gas is expensive
- On L2, gas is cheap but still not free
□ What happens if NO ONE calls for hours/days?
- Does the protocol break?
- Do users lose money?
- Is there a fallback?
□ Could this be integrated with Chainlink Keepers/Gelato?
- For critical maintenance functions
- More reliable than hoping someone callsThe Mental Model
Think of your smart contract as a vending machine:
Vending Machine:
- Sits there doing nothing
- Someone puts in money, presses button
- Dispenses item
- Goes back to doing nothing
Smart Contract:
- Sits there doing nothing
- Someone sends transaction with gas
- Executes code
- Goes back to doing nothing
KEY INSIGHT:
The vending machine doesn't restock itself.
Your contract doesn't maintain itself.
SOMEONE must do it, and they need a reason to.Summary
┌─────────────────────────────────────────────────────────────────┐
│ GOLDEN RULE OF ETHEREUM DEVELOPMENT │
├─────────────────────────────────────────────────────────────────┤
│ │
│ For every function that "needs to happen": │
│ │
│ 1. Make it callable by ANYONE │
│ 2. Give callers a REASON to call (profit, reward, their stuff) │
│ 3. Make the incentive SUFFICIENT │
│ │
│ If you can't answer "who calls this and why?" │
│ ...your function won't get called. │
│ │
└─────────────────────────────────────────────────────────────────┘Critical Ethereum Development Gotchas
These are the most important gotchas that cause major bugs and exploits. Every Ethereum developer must understand these.
---
1. Token Decimals Vary
CRITICAL: Not all tokens have 18 decimals!
USDC, USDT: 6 decimals → 1 USDC = 1,000,000
WBTC: 8 decimals → 1 WBTC = 100,000,000
DAI: 18 decimals → 1 DAI = 1,000,000,000,000,000,000
Most tokens: 18 decimals → 1 TOKEN = 1e18Verified: USDC, USDT, WBTC, DAI decimals confirmed via Etherscan token pages (Jan 2026)
The Bug
// BAD: Assumes 18 decimals
uint256 oneToken = 1e18;
token.transfer(user, oneToken); // Transfers 1 trillion USDC!
// GOOD: Check decimals
uint256 oneToken = 10 ** token.decimals();
token.transfer(user, oneToken);Real Impact
- Protocols have lost millions by assuming 18 decimals
- Always call
token.decimals()before calculations
---
2. ETH is Measured in Wei
CRITICAL: 1 ETH = 10^18 wei
// BAD: Sends 1 wei (almost nothing)
payable(user).transfer(1);
// GOOD: Use ether keyword or explicit conversion
payable(user).transfer(1 ether);
payable(user).transfer(1e18);Common Mistake
// Sending "100 ETH" but actually 100 wei
function tip() external payable {
require(msg.value >= 100, "Min 100"); // This is 100 wei!
}
// Correct
function tip() external payable {
require(msg.value >= 0.1 ether, "Min 0.1 ETH");
}---
3. ERC-20 Approve Pattern Required
CRITICAL: Contracts cannot pull tokens without approval!
Two-step process:
1. User calls token.approve(spender, amount)
2. Spender calls token.transferFrom(user, ..., amount)Never Use Infinite Approvals
// DANGEROUS: Allows draining all tokens
token.approve(spender, type(uint256).max);
// SAFE: Approve exact amount needed
token.approve(spender, exactAmount);Approval Race Condition
// If changing approval from 100 to 50:
// Attacker can: spend 100, wait for tx, spend 50 more
// Safe pattern: Reset to 0 first
token.approve(spender, 0);
token.approve(spender, newAmount);---
4. Solidity Has No Floating Point
CRITICAL: No decimals, no floats, only integers!
// BAD: This is 0, not 0.05
uint256 fivePercent = 5 / 100;
// GOOD: Use basis points (1 bp = 0.01%)
uint256 fivePercentBps = 500; // 5% = 500 basis points
uint256 fee = (amount * fivePercentBps) / 10000;
// GOOD: Multiply before divide
uint256 fee = (amount * 5) / 100;Precision Loss
// BAD: Loses precision
uint256 result = a / b * c;
// GOOD: Multiply first
uint256 result = (a * c) / b;---
5. Nothing is Automatic
CRITICAL: Smart contracts cannot execute themselves!
No cron jobs, no timers, no automatic triggers.
Someone must call the function and pay gas.Who Calls Your Function?
// This won't run automatically at deadline!
function checkDeadline() external {
if (block.timestamp >= deadline) {
// Execute...
}
}Design Incentives
// Give callers a reason to call
function liquidate(address user) external {
// Liquidator gets bonus collateral
uint256 bonus = collateral * 5 / 100;
collateral.transfer(msg.sender, debt + bonus);
}
// Or rely on natural interest
function claimRewards() external {
// Users want their rewards
uint256 reward = calculateReward(msg.sender);
rewardToken.transfer(msg.sender, reward);
}---
6. Reentrancy Attacks
CRITICAL: External calls can call back into your contract!
// VULNERABLE
function withdraw() external {
uint256 balance = balances[msg.sender];
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
balances[msg.sender] = 0; // Too late! Attacker already re-entered
}
// SAFE: Checks-Effects-Interactions
function withdraw() external {
uint256 balance = balances[msg.sender];
balances[msg.sender] = 0; // Effect BEFORE interaction
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
}Use ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Safe is ReentrancyGuard {
function withdraw() external nonReentrant {
// Protected from reentrancy
}
}---
7. Never Use DEX Spot Prices as Oracles
CRITICAL: Flash loans can manipulate spot prices instantly!
// VULNERABLE: Can be manipulated with flash loan
function getPrice() internal view returns (uint256) {
return dex.getSpotPrice(); // Manipulable!
}
// SAFE: Use Chainlink
function getPrice() internal view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, "Stale");
require(price > 0, "Invalid");
return uint256(price);
}Attack Pattern
1. Flash loan 1M ETH
2. Swap on DEX → crash price
3. Borrow against "cheap" collateral
4. Swap back → restore price
5. Repay flash loan, keep profit---
8. Vault Inflation Attack (First Depositor)
CRITICAL: First depositor can manipulate share price!
// ATTACK:
// 1. Deposit 1 wei → get 1 share
// 2. Donate 10000 tokens directly (not through deposit)
// 3. Share price = 10001 / 1 = 10001 per share
// 4. Victim deposits 9999 → gets 0 shares (rounded down)
// 5. Attacker redeems 1 share → gets all 20000 tokensMitigations
// Option 1: Virtual offset
function convertToShares(uint256 assets) public view returns (uint256) {
return assets.mulDiv(totalSupply() + 1e3, totalAssets() + 1);
}
// Option 2: Dead shares
constructor() {
_mint(address(0), 1000); // Burn initial shares
}
// Option 3: Minimum deposit
function deposit(uint256 assets) external {
require(assets >= MIN_DEPOSIT, "Too small");
}---
9. Access Control Missing
CRITICAL: Anyone can call unprotected functions!
// VULNERABLE: Anyone can withdraw
function withdrawAll() external {
payable(msg.sender).transfer(address(this).balance);
}
// SAFE: Owner only
function withdrawAll() external onlyOwner {
payable(owner).transfer(address(this).balance);
}Common Mistakes
- Forgetting
onlyOwneron admin functions - Using
tx.origininstead ofmsg.sender - Not checking caller in callbacks
---
10. Integer Overflow (Pre-0.8)
NOTE: Solidity 0.8+ has built-in overflow checks, but watch for unchecked blocks!
// Pre-0.8 VULNERABLE
uint8 x = 255;
x += 1; // x = 0 (overflow!)
// 0.8+ SAFE (reverts)
uint8 x = 255;
x += 1; // Reverts!
// 0.8+ DANGEROUS if using unchecked
unchecked {
uint8 x = 255;
x += 1; // x = 0 again!
}---
11. Unchecked Return Values
CRITICAL: Some tokens don't return bool on transfer!
// VULNERABLE: USDT doesn't return bool
bool success = token.transfer(to, amount); // Might not compile or return false
// SAFE: Use SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
token.safeTransfer(to, amount); // Handles non-standard tokens---
12. Timestamp Dependence
CRITICAL: Miners can manipulate timestamps by ~15 seconds!
// VULNERABLE for precise timing
require(block.timestamp == exactTime); // Miner can manipulate
// SAFE for approximate timing (hours/days)
require(block.timestamp >= deadline); // OK for deadlinesDon't Use For
- Randomness
- Precise scheduling
- High-value time-sensitive operations
OK For
- Lockup periods (days/weeks)
- General deadlines
- Time-weighted averages
---
Quick Reference Checklist
- [ ] Check token decimals before calculations
- [ ] Handle ETH in wei (use
1 ethersyntax) - [ ] Approve exact amounts, never infinite
- [ ] Multiply before divide for precision
- [ ] Design incentives for function callers
- [ ] Use CEI pattern + ReentrancyGuard
- [ ] Use Chainlink, not DEX spot prices
- [ ] Protect vaults from inflation attacks
- [ ] Add access control to admin functions
- [ ] Use SafeERC20 for token transfers
- [ ] Don't rely on precise timestamps
Historical Hacks: Teachable Moments
Learning from past exploits is essential for building secure protocols. Each hack here is a lesson in what NOT to do.
---
The DAO Hack (2016) - $50M
Verified: Amount confirmed via Wikipedia - 3.6M ETH (~$50M at time) (Jan 2026)
What Happened
The first major Ethereum exploit. Attacker drained ~$50M (3.6 million ETH, about 1/3 of the 11.5M ETH in The DAO) using a reentrancy attack.
The Vulnerable Code
// Simplified vulnerable pattern
function withdraw() external {
uint256 balance = balances[msg.sender];
// External call BEFORE state update
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
// State updated AFTER - attacker already re-entered!
balances[msg.sender] = 0;
}The Attack
contract Attacker {
DAO public dao;
function attack() external {
dao.withdraw();
}
// This gets called when DAO sends ETH
receive() external payable {
if (address(dao).balance > 0) {
dao.withdraw(); // Re-enter before balance zeroed
}
}
}The Fix: Checks-Effects-Interactions
function withdraw() external {
uint256 balance = balances[msg.sender];
// Effect BEFORE interaction
balances[msg.sender] = 0;
// Interaction AFTER effects
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
}Lesson
- Always update state BEFORE external calls
- Use ReentrancyGuard for all functions with external calls
- The Ethereum community hard-forked to reverse this hack
---
bZx Flash Loan Attack (2020) - ~$1M
Verified: Amounts confirmed via rekt.news - Two attacks: $298K + $645K (~$943K total) (Jan 2026)
What Happened
Attacker used flash loans to manipulate oracle prices and borrow against artificially inflated collateral. This was one of the first flash loan exploits.
The Attack Flow
1. Flash loan 10,000 ETH
2. Deposit 5,000 ETH as collateral on bZx
3. Short ETH on bZx (borrow + sell)
4. Use remaining 5,000 ETH to crash ETH price on Uniswap
5. bZx uses Uniswap spot price as oracle
6. Short position now massively profitable
7. Close short, repay flash loan, keep profitThe Vulnerable Pattern
// NEVER DO THIS
function getPrice() internal view returns (uint256) {
// Using DEX spot price as oracle
(uint112 reserve0, uint112 reserve1, ) = uniswapPair.getReserves();
return (reserve1 * 1e18) / reserve0;
}The Fix: Use Decentralized Oracles
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
function getPrice() internal view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 3600, "Stale price");
require(price > 0, "Invalid price");
return uint256(price);
}Lesson
- NEVER use spot DEX prices for anything valuable
- Flash loans make any single-block manipulation possible
- Use Chainlink or TWAPs (Time-Weighted Average Prices)
---
Nomad Bridge Hack (2022) - $190M
Verified: Amount confirmed via rekt.news leaderboard (Jan 2026)
What Happened
A routine upgrade introduced a bug that allowed anyone to drain the bridge by copying successful transactions.
The Bug
After an upgrade, the zero hash 0x00 was marked as a valid root in the Merkle tree verification.
// The problematic change
function process(bytes memory _message) external {
bytes32 _messageHash = keccak256(_message);
// BUG: acceptableRoot[0x00] was true after upgrade!
require(acceptableRoot[messages[_messageHash]], "Invalid root");
// Process withdrawal...
}The Attack
1. Find a successful bridge transaction
2. Copy it, change only the recipient address
3. Submit - the zero root was accepted as valid
4. Repeat for any asset in the bridge
5. Others saw the technique and joined the "looting"The Lesson
// Proper Merkle verification
function process(bytes32 _messageHash, bytes32 _root, bytes32[] memory _proof) external {
// Verify root is in the set of accepted roots
require(acceptableRoot[_root], "Unknown root");
require(_root != bytes32(0), "Invalid root"); // Explicit zero check
// Verify the proof
require(MerkleProof.verify(_proof, _root, _messageHash), "Invalid proof");
}Lesson
- Always validate against zero values explicitly
- Test upgrade paths thoroughly
- Bridge contracts are high-value targets
---
Alchemix Incident (2021) - $6.5M
Verified: Amount confirmed via rekt.news - ~2700 ETH (~$6.5M) (Jan 2026)
What Happened
A bug in the alETH vault caused the protocol to assign zero debt to users, allowing them to withdraw their collateral while keeping their borrowed alETH. This was NOT a precision error - it was a logic bug that incorrectly cleared user debt.
The Vulnerable Pattern
// Simplified vulnerable calculation
function calculateReward(address user) internal view returns (uint256) {
// Division truncation compounds over many operations
uint256 reward = (userBalance * rewardRate) / totalBalance;
return reward * multiplier / divisor; // More precision loss
}The Fix: Proper Fixed-Point Math
// Use high precision (e.g., 1e18 scale)
uint256 constant PRECISION = 1e18;
function calculateReward(address user) internal view returns (uint256) {
// Scale up for precision
uint256 scaledReward = (userBalance * rewardRate * PRECISION) / totalBalance;
// Scale back down at the end
return scaledReward / PRECISION;
}Lessons
- Always multiply before dividing
- Use high-precision intermediate calculations
- Round in favor of the protocol, not users
- Test edge cases with real numbers
---
Cream Finance (2021) - $130M
Verified: Amount confirmed via rekt.news leaderboard (Jan 2026)
What Happened
Attacker exploited price oracle manipulation combined with flash loans across multiple DeFi protocols.
The Attack Pattern
1. Flash loan massive amounts
2. Manipulate token price on lending platform
3. Borrow against inflated collateral
4. Let the position become undercollateralized
5. Liquidate yourself, repay flash loanVulnerable Oracle Pattern
// BAD: Single-source price
function getPrice(address token) external view returns (uint256) {
return singleDEX.getPrice(token);
}
// BETTER: Multi-source with sanity checks
function getPrice(address token) external view returns (uint256) {
uint256 chainlinkPrice = chainlinkFeed.getPrice(token);
uint256 twapPrice = uniswapTwap.consult(token, 30 minutes);
// Sanity check: prices should be within 5%
require(
chainlinkPrice * 95 / 100 <= twapPrice &&
twapPrice <= chainlinkPrice * 105 / 100,
"Price deviation"
);
return chainlinkPrice;
}Lessons
- Use multiple oracle sources
- Implement price deviation checks
- Add cooldown periods for large operations
- Flash loan resistance requires multi-block delays
---
Poly Network (2021) - $611M
Verified: Amount confirmed via rekt.news leaderboard (Jan 2026)
What Happened
Attacker found they could call privileged functions by crafting specific cross-chain messages.
The Bug
// Vulnerable: No validation of who is calling privileged function
function _executeCrossChainTx(
bytes memory _method,
bytes memory _args
) internal {
// This could call ANY function, including changing the keeper!
(bool success, ) = address(this).call(abi.encodePacked(_method, _args));
}The Attack
Attacker crafted a message that called the function to change the privileged signer to their own address.
The Fix
// SAFE: Whitelist allowed functions
mapping(bytes4 => bool) public allowedFunctions;
function _executeCrossChainTx(bytes memory _method, bytes memory _args) internal {
bytes4 selector = bytes4(keccak256(_method));
require(allowedFunctions[selector], "Function not allowed");
// Never allow calling admin functions
require(selector != this.changeOwner.selector, "Cannot change owner");
(bool success, ) = address(this).call(abi.encodePacked(_method, _args));
}Lessons
- Whitelist allowed operations, don't blacklist
- Never allow arbitrary function calls
- Cross-chain messaging requires extreme care
- Admin functions need multiple layers of protection
---
Wormhole (2022) - $326M
Verified: Amount confirmed via rekt.news leaderboard (Jan 2026)
What Happened
Attacker bypassed signature verification to mint unbacked wrapped tokens.
The Bug
A deprecated function was still accessible that didn't properly verify signatures.
// The deprecated but still callable function
function complete_transfer(bytes memory vaa) external {
// Missing: Proper guardian signature verification
// The old verification was incomplete
}Lessons
- Remove deprecated code completely
- Multiple audit checkpoints for bridges
- Signature verification is critical infrastructure
- One bug in a bridge = catastrophic loss
---
Common Attack Patterns Summary
| Pattern | Example Hacks | Prevention |
|---|---|---|
| Reentrancy | The DAO | CEI pattern, ReentrancyGuard |
| Oracle Manipulation | bZx, Cream | Chainlink, TWAPs, multi-oracle |
| Access Control | Poly Network | Proper modifiers, whitelist functions |
| Flash Loan Attacks | bZx, Cream | Multi-block delays, oracle protection |
| Precision/Rounding | Alchemix | Multiply first, high precision math |
| Bridge Exploits | Nomad, Wormhole | Multiple audits, gradual rollout |
---
What to Do Before Mainnet
1. Multiple Audits: Different teams catch different bugs 2. Bug Bounty: Incentivize white hats 3. Gradual Rollout: Cap initial TVL 4. Monitoring: Real-time alerts for anomalies 5. Emergency Pause: Ability to stop if exploit detected 6. Insurance: Consider coverage for users
#!/bin/bash
set -e
# Ethereum Wingman: Check Solidity files for common gotchas
# Usage: bash check-gotchas.sh [path]
SEARCH_PATH="${1:-.}"
echo "🔍 Ethereum Wingman: Scanning for common gotchas..." >&2
echo " Path: $SEARCH_PATH" >&2
echo "" >&2
ISSUES_FOUND=0
# Check for potential infinite approvals
echo "Checking for infinite approvals (type(uint256).max)..." >&2
INFINITE_APPROVALS=$(grep -rn "type(uint256).max" "$SEARCH_PATH" --include="*.sol" 2>/dev/null || true)
if [ -n "$INFINITE_APPROVALS" ]; then
echo "⚠️ POTENTIAL ISSUE: Infinite approvals found:" >&2
echo "$INFINITE_APPROVALS" >&2
echo "" >&2
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
# Check for tx.origin usage
echo "Checking for tx.origin usage..." >&2
TX_ORIGIN=$(grep -rn "tx.origin" "$SEARCH_PATH" --include="*.sol" 2>/dev/null || true)
if [ -n "$TX_ORIGIN" ]; then
echo "⚠️ POTENTIAL ISSUE: tx.origin found (phishing vulnerability):" >&2
echo "$TX_ORIGIN" >&2
echo "" >&2
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
# Check for hardcoded decimals (1e18 patterns without context)
echo "Checking for hardcoded decimals assumptions..." >&2
HARDCODED_DECIMALS=$(grep -rn "1e18\|10\*\*18" "$SEARCH_PATH" --include="*.sol" 2>/dev/null | head -20 || true)
if [ -n "$HARDCODED_DECIMALS" ]; then
echo "ℹ️ NOTE: Hardcoded 1e18 found - verify these aren't decimal assumptions:" >&2
echo "$HARDCODED_DECIMALS" >&2
echo "" >&2
fi
# Check for state changes after external calls
echo "Checking for external calls..." >&2
EXTERNAL_CALLS=$(grep -rn "\.call{" "$SEARCH_PATH" --include="*.sol" 2>/dev/null || true)
if [ -n "$EXTERNAL_CALLS" ]; then
echo "ℹ️ NOTE: External calls found - verify CEI pattern:" >&2
echo "$EXTERNAL_CALLS" >&2
echo "" >&2
fi
# Check for missing ReentrancyGuard
echo "Checking for ReentrancyGuard usage..." >&2
HAS_EXTERNAL_CALLS=$(grep -l "\.call{" "$SEARCH_PATH" --include="*.sol" -r 2>/dev/null || true)
if [ -n "$HAS_EXTERNAL_CALLS" ]; then
for file in $HAS_EXTERNAL_CALLS; do
if ! grep -q "nonReentrant\|ReentrancyGuard" "$file" 2>/dev/null; then
echo "⚠️ POTENTIAL ISSUE: $file has external calls but no ReentrancyGuard" >&2
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
done
fi
# Check for getReserves (DEX spot price usage)
echo "Checking for DEX spot price usage..." >&2
DEX_PRICES=$(grep -rn "getReserves\|getSpotPrice" "$SEARCH_PATH" --include="*.sol" 2>/dev/null || true)
if [ -n "$DEX_PRICES" ]; then
echo "⚠️ POTENTIAL ISSUE: DEX spot price usage found (flash loan vulnerable):" >&2
echo "$DEX_PRICES" >&2
echo "" >&2
ISSUES_FOUND=$((ISSUES_FOUND + 1))
fi
echo "─────────────────────────────────────────" >&2
if [ $ISSUES_FOUND -eq 0 ]; then
echo "✅ No obvious gotchas found!" >&2
else
echo "⚠️ Found $ISSUES_FOUND potential issues to review" >&2
fi
echo "" >&2
# Output JSON for machine parsing
echo "{\"issues_found\": $ISSUES_FOUND, \"path\": \"$SEARCH_PATH\"}"
#!/bin/bash
set -e
# Ethereum Wingman: Initialize Scaffold-ETH 2 Project
# Usage: bash init-project.sh [project-name] [chain]
PROJECT_NAME="${1:-my-dapp}"
CHAIN="${2:-base}"
echo "🏗️ Ethereum Wingman: Initializing Scaffold-ETH 2 Project" >&2
echo " Project: $PROJECT_NAME" >&2
echo " Target Chain: $CHAIN" >&2
echo "" >&2
# Check if npx is available
if ! command -v npx &> /dev/null; then
echo "❌ Error: npx not found. Please install Node.js 18+" >&2
exit 1
fi
# Check if directory already exists
if [ -d "$PROJECT_NAME" ]; then
echo "❌ Error: Directory '$PROJECT_NAME' already exists" >&2
exit 1
fi
# Create Scaffold-ETH 2 project
echo "📦 Creating Scaffold-ETH 2 project..." >&2
npx create-eth@latest --project "$PROJECT_NAME" --skip-install
cd "$PROJECT_NAME"
echo "" >&2
echo "✅ Project created successfully!" >&2
echo "" >&2
echo "📋 Next steps:" >&2
echo " 1. cd $PROJECT_NAME" >&2
echo " 2. yarn install" >&2
echo " 3. yarn chain # Terminal 1: Start local blockchain" >&2
echo " 4. yarn deploy # Terminal 2: Deploy contracts" >&2
echo " 5. yarn start # Terminal 3: Start frontend" >&2
echo "" >&2
echo "🔀 To fork $CHAIN:" >&2
echo " yarn fork --network $CHAIN" >&2
echo "" >&2
echo "📚 Remember the critical gotchas:" >&2
echo " • USDC has 6 decimals, not 18!" >&2
echo " • Always use the approve pattern for ERC-20" >&2
echo " • Use Chainlink oracles, never DEX spot prices" >&2
echo " • Design incentives: Who calls your function? Why?" >&2
# Output JSON for machine parsing
echo "{\"status\": \"success\", \"project\": \"$PROJECT_NAME\", \"chain\": \"$CHAIN\", \"path\": \"$(pwd)\"}"
#!/bin/bash
# Setup script for Cursor users
# Run this after: npx skills add austintgriffith/ethereum-wingman
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
PROJECT_ROOT="$(pwd)"
# Check if we're in a project with the skill installed
if [ -f ".agents/skills/ethereum-wingman/AGENTS.md" ]; then
AGENTS_FILE=".agents/skills/ethereum-wingman/AGENTS.md"
elif [ -f "$SKILL_DIR/AGENTS.md" ]; then
AGENTS_FILE="$SKILL_DIR/AGENTS.md"
else
echo "❌ Error: Could not find ethereum-wingman skill"
echo " Run: npx skills add austintgriffith/ethereum-wingman"
exit 1
fi
# Create symlink to .cursorrules
if [ -f ".cursorrules" ]; then
echo "⚠️ .cursorrules already exists"
read -p " Overwrite? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo " Skipped."
exit 0
fi
rm .cursorrules
fi
# Create symlink (so it auto-updates with skill)
ln -sf "$AGENTS_FILE" .cursorrules
echo "✅ Created .cursorrules -> $AGENTS_FILE"
echo ""
echo "🚀 Cursor is now configured with ethereum-wingman!"
echo " Restart Cursor or reload the window to apply."
Related skills
How it compares
Use ethereum-wingman for guided Ethereum learning and Scaffold-ETH scaffolding rather than generic Solidity snippets without security context or challenge-based progression.
FAQ
What does ethereum-wingman include for learning Ethereum?
ethereum-wingman includes TLDR modules for all 12 SpeedRun Ethereum challenges, Scaffold-ETH 2 tooling docs, DeFi protocol references, ERC standard guides, and a gotchas knowledge base. skill.json v1.0.0 lists six capabilities from Solidity development to security auditing.
How do you install ethereum-wingman?
ethereum-wingman installs with npx skills add austintgriffith/ethereum-wingman for Cursor, Claude Code, Codex, and OpenCode. The repo also ships .cursorrules and CLAUDE.md integration files plus init-project.sh and check-gotchas.sh helper scripts.