Ethereum Wingman

SkillProductivity

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.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Ethereum Wingman skill

What this skill tells your AI

The instructions your AI receives, as published by austintgriffith/ethereum-wingman in skills/ethereum-wingman/SKILL.md and read by ahel’s review.

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 worth

Where 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 30000

Monitor 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: true on 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
  • pollingInterval is 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 .tsx files for raw address strings (should use <Address/>)
  • Scan for shared isLoading state across multiple buttons
  • Scan for missing disabled props on transaction buttons
  • Verify scaffold.config.ts has rpcOverrides and pollingInterval: 3000
  • Verify layout.tsx has OG/Twitter meta with absolute URLs
  • Verify no mainnet.base.org or 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 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:

  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 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 only
  • instrumentation.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.

🚨 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 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 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) → generates debug.html
  • trailingSlash: true → generates debug/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 / sessionStorage usage at import time
  • Hooks that assume browser environment (window, document)
  • SE2's block explorer pages (use 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
  };
}

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
45
Forks
19
Last commit
Feb 2026

ahel review

  • K1binfo
    installs-packages
  • K1binfo
    installs-packages (in AGENTS.md)

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Item type
skill
Key
ethereum-wingman-ethereum-wingman
Source
github.com/austintgriffith/ethereum-wingman