skills/ethereum-wingman/SKILL.md
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.
npx skillsauth add austintgriffith/ethereum-wingman ethereum-wingmanInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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 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
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!)
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 1
Without 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 deploy
Step 6: Start Frontend
yarn start
Step 7: Test the Frontend
After the frontend is running, open a browser and test the app:
http://localhost:3000Use the cursor-browser-extension MCP tools for browser automation.
See tools/testing/frontend-testing.md for detailed workflows.
packages/nextjs/components/Footer.tsx, change the "Fork me" link from https://github.com/scaffold-eth/se-2 to your actual repo URLpackages/nextjs/app/layout.tsx, change the metadata title/descriptionpackages/nextjs/components/Header.tsx, remove the Debug Contracts entry from menuLinksWant 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)
SE2 is a monorepo — Vercel needs special configuration:
packages/nextjs in Vercel project settingscd ../.. && yarn install (installs from workspace root)next build — auto-detected).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:
NODE_OPTIONS=--max-old-space-size=7168yarn ipfs)vercel --prebuilt (build locally, deploy output to Vercel)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 out
Or 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.ts polyfill runs in the main process onlyinstrumentation.ts doesn't run in the build worker--require injects 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.
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 out
How 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 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) → generates debug.htmltrailingSlash: true → generates debug/index.htmlIPFS 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 / sessionStorage usage at import timewindow, document)localStorage at import time — rename to _blockexplorer-disabled if 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 404
The 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
};
}
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
yarn start) one last timeStep 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:
localhost:3000twitter:card set to summary_large_image for large previewGenerate 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.png
Fix 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:
https://app.ens.domains/yourname.eth in the wallet browser (your wallet profile)token) → Next → Skip profile → Open Wallet → ConfirmTx #2 — Set IPFS content hash:
https://app.ens.domains/<name>.yourname.ethipfs://<CID>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 localhost
Step 8: 👤 Report to the user
Send: "Live at https://<name>.yourname.eth.limo — unfurl metadata set, ENS content hash confirmed on-chain."
⚠️ Known gotchas:
thumbnail.png and thumbnail.jpg. ALWAYS replace both before production.NEXT_PUBLIC_PRODUCTION_URL isn't set, og:image will point to localhost:3000. Always verify with grep.yarn chain (use yarn fork --network <chain> instead!)forge init or set up Foundry from scratchyarn 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 state
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 fundingNOTHING 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":
Always ask: "Who calls this function? Why would they pay gas?"
If you can't answer this, your function won't get called.
// 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);
}
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:
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);
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;
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.
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);
}
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);
}
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
packages/
├── foundry/ # Smart contracts
│ ├── contracts/ # Your Solidity files
│ └── script/ # Deploy scripts
└── nextjs/
├── app/ # React pages
└── contracts/ # Generated ABIs + externalContracts.ts
// 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,
});
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 |
Before deployment, verify:
When helping developers:
yarn fork, never yarn chaindevelopment
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.
development
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
development
Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
development
Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.