AGENTS.md - Monad Wingman
This file provides comprehensive guidance to AI coding agents (Claude Code, Cursor, Copilot, etc.) when working on Monad blockchain smart contract development.
Version: 1.0.0 Author: Monad Community Last Updated: February 2026
MONAD AGENT RULES — READ THIS FIRST
These rules are NON-NEGOTIABLE. Every Monad interaction must follow them.
-
DEFAULT TO TESTNET — Chain ID 10143, RPC
https://testnet-rpc.monad.xyz. Only use mainnet (chain ID 143, RPChttps://rpc.monad.xyz) when the user explicitly requests it. -
ALWAYS
--legacy— Monad does NOT support EIP-1559 type 2 transactions. Everyforge script,cast send, or transaction submission MUST include--legacy. -
ALWAYS
forge script— NEVER useforge create. All deployments go throughforge script ... --broadcast --legacy. -
EVM version =
prague— Setevm_version = "prague"infoundry.toml. Solidity 0.8.27+ required, 0.8.28 recommended. -
Gas is charged on gas_limit — NOT on actual gas used. Over-estimating wastes real MON. Benchmark and set tight limits.
-
No blob transactions — EIP-4844 type 3 txs are not supported.
-
No global mempool — Transactions go directly to the block producer.
-
Mainnet key safety — NEVER use
--private-keyfor mainnet. Use--ledger,--trezor, or--keystore. -
Wallet persistence — Store at
~/.monad-walletwithchmod 600. -
Frontend chain import — Use
import { monadTestnet } from 'viem/chains'. Do NOT define custom chain objects. -
Max contract size — 128KB (vs Ethereum's 24.5KB). Generous but not infinite.
-
Historical state — NOT available on full nodes. Do not call
eth_callat past block numbers. -
Parallel execution — Optimistic concurrency, same deterministic results as sequential. No special contract handling needed.
-
P256 precompile — secp256r1 precompile at
0x0100for passkey/WebAuthn signing.
AI AGENT INSTRUCTIONS - READ THIS FIRST
CRITICAL: External Contracts & Scaffold Hooks
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.tswith its address and ABI. Read the file first — the pattern is self-evident. -
SCAFFOLD HOOKS ONLY — NEVER RAW WAGMI — Always use
useScaffoldReadContractanduseScaffoldWriteContract, NEVER raw wagmi hooks likeuseWriteContractoruseReadContract.
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:
- Disable immediately on click
- Show a loader/spinner ("Approving...", "Staking...", etc.)
- Stay disabled until the state updates confirm the action completed
- 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 Monad" 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 Monad"}
</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 is why wrong network check comes FIRST.
Rule 3: Address Display — Always <Address/>
EVERY time you display an 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 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 MON amount displayed should include its USD value. EVERY token or MON input should show a live USD preview.
// CORRECT — Display with USD
<span>1,000 TOKEN (~$4.20)</span>
<span>0.5 MON (~$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:
- MON price: Use the native currency price hook or check the price display component. On Scaffold-Monad, check if
useNativeCurrencyPrice()is available. - Custom tokens: Use DexScreener API (
https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS), on-chain quoter, or Chainlink/Pyth 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 are 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 Monad</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 is already visible at the top of the screen.
Rule 4: RPC Configuration
For Monad, use the official RPCs:
// In scaffold.config.ts
import { monadTestnet } from "viem/chains";
const scaffoldConfig = {
targetNetworks: [monadTestnet],
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: 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
-
pollingIntervalis 3000 - All contract addresses match what is deployed on Monad
- 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
- Chain configured as
monadTestnetfromviem/chains(not custom)
BUILD VERIFICATION PROCESS (MANDATORY)
A build is NOT done when the code compiles. A build is done when you have 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.tshas correct Monad chain andpollingInterval: 3000 - Verify
layout.tsxhas OG/Twitter meta with absolute URLs - Verify
foundry.tomlhasevm_version = "prague"andsolc_version = "0.8.28"
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 MON. USE THEM.
After deploying to Monad testnet (or fork), open the app and do a FULL walkthrough:
- Open the app in the browser tool — take a snapshot, verify it loaded
- Check the page title — is it correct, not "Scaffold-ETH 2"?
- Connect wallet — does the connect flow work?
- Wrong network test — connect on wrong network, verify "Switch to Monad" appears
- Switch network — click the switch button, verify it works
- 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?
- 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?
- Error handling — reject a transaction in wallet, verify the UI recovers gracefully
- Address displays — are all addresses showing ENS/blockies, not raw hex?
- 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
THREE-PHASE BUILD PROCESS
Bugs should be caught in the cheapest phase. Do not jump to production.
Phase 1: Localhost + Local Fork + Burner Wallet — Free, instant. Fork Monad with anvil --fork-url https://testnet-rpc.monad.xyz --block-time 1. Test logic, rendering, flows. Exit: all pages render, all buttons work, forge test passes, no console errors.
Phase 2: Localhost + Live Monad Testnet + MetaMask — Real gas (testnet MON), fast tx times. Test wallet UX: loaders, double-click prevention, approve flow, network switching. 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.
Dual Workflow: Foundry (Primary) + Scaffold-Monad
Workflow A: Foundry (Primary — Contracts Only)
Step 1: Initialize Foundry Project
forge init my-monad-project
cd my-monad-project
Step 2: Configure foundry.toml for Monad
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
evm_version = "prague"
solc_version = "0.8.28"
[rpc_endpoints]
monad_testnet = "https://testnet-rpc.monad.xyz"
monad_mainnet = "https://rpc.monad.xyz"
Step 3: Write Deploy Script
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
import "../src/MyContract.sol";
contract DeployScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
MyContract myContract = new MyContract();
vm.stopBroadcast();
console.log("MyContract deployed to:", address(myContract));
}
}
Step 4: Test
forge test
Step 5: Deploy to Monad Testnet
forge script script/Deploy.s.sol:DeployScript \
--rpc-url https://testnet-rpc.monad.xyz \
--private-key $PRIVATE_KEY \
--broadcast \
--legacy
Step 6: Verify Contract
curl -X POST https://agents.devnads.com/v1/verify \
-H "Content-Type: application/json" \
-d '{"chainId": 10143, "address": "0xDEPLOYED", "source": "...", "compilerVersion": "0.8.28"}'
Step 7: Deploy to Mainnet (when ready)
# Use hardware wallet — NEVER --private-key for mainnet
forge script script/Deploy.s.sol:DeployScript \
--rpc-url https://rpc.monad.xyz \
--ledger \
--broadcast \
--legacy
Workflow B: Scaffold-Monad (Fullstack dApps)
Step 1: Create Project
git clone https://github.com/monad-developers/scaffold-monad-foundry.git my-dapp
cd my-dapp
# Foundry variant (recommended)
Step 2: Configure for Monad
Edit packages/nextjs/scaffold.config.ts:
import { monadTestnet } from "viem/chains";
const scaffoldConfig = {
targetNetworks: [monadTestnet],
pollingInterval: 3000, // 3 seconds (default 30000 is too slow)
};
IMPORTANT: Import monadTestnet from viem/chains. Do NOT define a custom chain object.
When using fork mode, the frontend target network MUST be chains.foundry (chain ID 31337), NOT the chain you are forking!
The fork runs locally on Anvil with chain ID 31337. Even if you are forking Monad, the scaffold config must use:
targetNetworks: [chains.foundry], // NOT monadTestnet!
Only switch to monadTestnet when deploying to the REAL network.
Step 3: Configure Foundry for Monad
Edit packages/foundry/foundry.toml:
evm_version = "prague"
solc_version = "0.8.28"
Step 4: Install & Start
cd <project-name>
yarn install
yarn start
Step 5: Deploy
yarn deploy --network monadTestnet
Ensure the deploy script uses --legacy flag.
Step 6: Fund Wallet via Faucet
curl -X POST https://agents.devnads.com/v1/faucet \
-H "Content-Type: application/json" \
-d '{"chainId": 10143, "address": "0xYOUR_BURNER_WALLET"}'
Step 7: Test the Frontend
After the frontend is running, open a browser and test the app as the burner wallet user:
- Navigate to
http://localhost:3000 - Take a snapshot to get page elements (the burner wallet address is in the header)
- Fund the wallet via faucet API (MON, not ETH)
- 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
Speed Note: Monad has fast block times. With pollingInterval: 3000, the UI updates within 3 seconds.
When Publishing a Scaffold-Monad Project:
- Update README.md — Replace the default SE2 readme with your project's description
- Update the footer link — In
packages/nextjs/components/Footer.tsx, change the "Fork me" link fromhttps://github.com/scaffold-eth/se-2to your actual repo URL - Update page title — In
packages/nextjs/app/layout.tsx, change the metadata title/description - Remove "Debug Contracts" nav link — In
packages/nextjs/components/Header.tsx, remove the Debug Contracts entry frommenuLinks - Set OG/Twitter meta — Follow the Pre-Publish Checklist in Rule 5 above
SE2 Deployment Quick Decision Tree
Want to deploy Scaffold-Monad 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 Scaffold-Monad to Vercel (Monorepo Setup):
SE2/Scaffold-Monad is a monorepo — Vercel needs special configuration:
- Set Root Directory to
packages/nextjsin Vercel project settings - Set Install Command to
cd ../.. && yarn install(installs from workspace root) - Leave Build Command as default (
next build— auto-detected) - 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-scaffold-monad-project && vercel --prod --yes
Common mistake: Do not put cd packages/nextjs in the build command — Vercel is already in packages/nextjs because of the root directory setting. Do not use a root-level vercel.json with framework: "nextjs" — Vercel cannot 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 Scaffold-Monad to IPFS (BuidlGuidl IPFS):
This is the RECOMMENDED deploy path for Scaffold-Monad. 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 is 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 will 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.tsdoes not 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 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 did not 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 is 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 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
};
}
GO TO PRODUCTION — Full Checklist
When the user says "ship it", follow this EXACT sequence. Steps marked [auto] are fully automatic. Steps marked [human] need human input.
Step 1: [auto] 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: [human] 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: [auto] Generate OG image + fix metadata for unfurls
Social unfurls (Twitter, Telegram, Discord, etc.) need THREE things correct:
- Custom OG image (1200x630 PNG) — NOT the stock SE2 thumbnail
- Absolute production URL in og:image — NOT
localhost:3000 twitter:cardset tosummary_large_imagefor 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.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: [auto] 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: [human] Share IPFS URL for verification
Send: "Here is the build for review: https://community.bgipfs.com/ipfs/<CID>"
Wait for approval before touching ENS. Do not proceed until the user says go.
Step 6: [auto] Set up ENS subdomain (2 mainnet transactions)
If this is a new app (subdomain does not exist yet):
Tx #1 — Create subdomain:
- Open
https://app.ens.domains/yourname.ethin the wallet browser (your wallet profile) - Go to "Subnames" tab -> "New subname"
- Enter the label (e.g.
token) -> Next -> Skip profile -> Open Wallet -> Confirm - If gas is stuck: switch MetaMask to Ethereum network -> Activity tab -> "Speed up"
Tx #2 — Set IPFS content hash:
- Navigate to
https://app.ens.domains/<name>.yourname.eth - Go to "Records" tab -> "Edit Records" -> "Other" tab
- Paste in Content Hash field:
ipfs://<CID> - 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: [auto] 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: [human] 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_URLis not set, og:image will point tolocalhost:3000. Always verify withgrep.
Fork Mode (Testing Against Live Monad State)
# Fork Monad mainnet
anvil --fork-url https://rpc.monad.xyz --block-time 1
# Fork Monad testnet
anvil --fork-url https://testnet-rpc.monad.xyz --block-time 1
Enable interval mining if block.timestamp is frozen:
cast rpc anvil_setIntervalMining 1
Funding Test Wallets on Fork
# Give address MON for gas
cast rpc anvil_setBalance 0xYOUR_ADDRESS 0x56BC75E2D63100000
# Impersonate an account
cast rpc anvil_impersonateAccount 0xWHALE_ADDRESS
# Transfer tokens (example: 10,000 of a token with 18 decimals)
cast send 0xTOKEN_ADDRESS \
"transfer(address,uint256)" YOUR_ADDRESS 10000000000000000000000 \
--from 0xWHALE_ADDRESS --unlocked
DO NOT:
- Use
forge create(useforge scriptwith--legacyinstead) - Send EIP-1559 type 2 transactions (always use
--legacy) - Send blob transactions (EIP-4844 type 3 not supported)
- Use
--private-keyfor mainnet deployments (use--ledger/--trezor/keystore) - Define custom chain objects in frontend (use
import { monadTestnet } from 'viem/chains') - Rely on historical state queries (not available on full nodes)
- Assume gas is charged on usage (it is charged on gas_limit)
- Run
yarn chainfor Scaffold-Monad (use testnet or fork mode) - Manually run
forge initor set up Foundry from scratch for fullstack projects - Manually create Next.js projects
- Set up wallet connection manually (SE2 has RainbowKit pre-configured)
THE MOST CRITICAL CONCEPT IN BLOCKCHAIN 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. |
| |
+-------------------------------------------------------------------+
This is true on Monad just as on Ethereum. Monad's parallel execution makes transactions cheaper, but someone still has to submit them.
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 money
Pattern 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 bonus
Pattern 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 yield
Anti-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 + Monad-Specific)
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, WMON, most | 18 | 1e18 |
| MON (native) | 18 | 1e18 |
2. MON is Measured in Wei
1 MON = 10^18 wei (same denomination as ETH)
// BAD: Sends 1 wei (almost nothing)
payable(user).transfer(1);
// GOOD: Use ether keyword (works for MON too — same denomination)
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. Monad's parallel execution does NOT prevent reentrancy — it produces the same results as sequential execution:
// 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 or Pyth
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);
}
On Monad, use Chainlink or Pyth for oracle feeds.
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
Validators can influence timestamps slightly:
// 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);
13. Monad-Specific Gotchas
Gas limit billing: Monad charges gas on gas_limit, NOT actual gas used. Over-estimating wastes real MON. Always benchmark and set tight gas limits.
No EIP-1559: Always use --legacy flag. Type 2 transactions will be rejected by the network.
No blob txs: EIP-4844 type 3 transactions are not supported. Do not attempt them.
No global mempool: Transactions go directly to the block producer. MEV strategies relying on mempool monitoring work differently on Monad.
Parallel execution safety: Monad's parallel execution is deterministic (optimistic concurrency). It produces identical results to sequential execution. Your contracts do NOT need special handling.
Historical state unavailable: Full nodes do not serve historical state. Do not rely on eth_call at past block numbers. Use events and indexers instead.
Reserve Balance mechanism: Be aware of this Monad-specific feature when building DeFi protocols.
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-Monad 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.ts
Essential 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 = {
10143: { // Monad testnet chainId
WMON: {
address: "0xWMON_ADDRESS",
abi: [...], // ERC20 ABI
},
},
143: { // Monad mainnet
WMON: {
address: "0xWMON_ADDRESS",
abi: [...],
},
},
} as const satisfies GenericContractsDeclaration;
export default externalContracts;
DeFi Protocol Integration on Monad
Euler (Modular Lending)
// Euler is a modular lending protocol on Monad
// Supply collateral, borrow assets
// Check health factor before risky operations
Morpho (Optimized Lending)
// Morpho optimizes lending through peer-to-peer matching
// Better rates through direct matching of lenders and borrowers
// Compatible with Monad's parallel execution
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);
}
Pyth Price Feed
import "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
IPyth pyth = IPyth(PYTH_ADDRESS);
function getPrice(bytes32 priceId) public view returns (uint256) {
PythStructs.Price memory price = pyth.getPriceNoOlderThan(priceId, 60);
require(price.price > 0, "Invalid price");
return uint256(uint64(price.price));
}
SpeedRun Ethereum Challenge Reference
These challenges use Scaffold-ETH 2 and Ethereum. They teach EVM fundamentals directly applicable to Monad. Use them for learning, then deploy to Monad.
| # | 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 or Pyth 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
Monad-Specific Checks
-
evm_version = "prague"in foundry.toml - Solidity version 0.8.27+ (recommend 0.8.28)
- All deploy commands use
--legacyflag - All deploy commands use
forge script(notforge create) - Gas limits set carefully (charged on limit, not usage)
- No reliance on historical state queries
- No EIP-4844 blob transactions
- Mainnet deployments use hardware wallet
- Contract size under 128KB
- Wallet files at
~/.monad-walletwith chmod 600 - Contract verified via
https://agents.devnads.com/v1/verify
Writing Solidity Code for Monad
Always include:
- SPDX license identifier
- Pragma version 0.8.28+
- 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.28;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title MyProtocol
/// @notice Protocol deployed on Monad
/// @dev Uses EVM version prague, Solidity 0.8.28
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:
- Default to testnet - Chain ID 10143 unless user says mainnet
- Answer directly - Address their question first
- Show code - Provide working, complete examples with Monad config
- Always use
--legacy- Remind about no EIP-1559 support - Always use
forge script- Never suggestforge create - Warn about gotchas - Proactively mention relevant pitfalls
- Ask about incentives - For any "automatic" function, ask: "Who calls this? Why would they pay gas?"
- Test the frontend - After deploying, open browser, fund wallet via faucet, click through app
- Reference challenges - Point to SpeedRun Ethereum for hands-on practice (note: they target Ethereum)
- Consider security - Always mention relevant security considerations
- Gas limit awareness - Remind that gas is charged on limit, not usage
- Verify contracts - Always include verification step after deployment