Three-phase build system for Scaffold-ETH 2 dApps. Localhost to testnet to production. When to use Scaffold hooks vs raw wagmi, auto-generated types, and common agent mistakes.
You are building a dApp with Scaffold-ETH 2. There are exactly three phases. You do not skip phases. You do not deploy to mainnet from localhost. You follow the pipeline.
You used raw wagmi hooks. useWriteContract from wagmi resolves its promise when the transaction is SUBMITTED, not when it is CONFIRMED. Your UI says "Success!" while the tx is still pending. Scaffold-ETH 2 hooks (useScaffoldWriteContract, useScaffoldReadContract) wait for confirmation and handle errors. Use them.
You deployed contracts before the UI worked locally. Now you are debugging contract logic AND network issues AND gas problems simultaneously. Phase 1 exists to isolate contract+UI bugs from deployment bugs.
You hardcoded contract addresses. Scaffold-ETH 2 auto-generates deployment files with addresses and ABIs. If you are manually pasting addresses into your frontend, you are fighting the framework.
You committed your deployer private key. It is now on GitHub forever. You need to rotate that key immediately and use environment variables going forward.
You ran yarn chain instead of yarn fork. Without a fork, your local chain has no USDC, no Uniswap, no Aave, no real token balances. If your dApp interacts with ANY existing protocol, you need a fork.
Goal: Get everything working locally. Zero gas costs. Instant feedback. Fix all logic bugs here.
For a standalone dApp with no external protocol dependencies:
yarn chain
For any dApp that interacts with existing protocols (Uniswap, Aave, Chainlink, any ERC-20):
yarn fork --network mainnet
# or
yarn fork --network base
# or
yarn fork --network optimism
This starts Anvil with a fork of the target chain. All protocol state is available locally. You get 10 pre-funded accounts with 10,000 ETH each.
yarn deploy
This runs your deploy scripts in packages/hardhat/deploy/ (or packages/foundry/script/). It generates:
deployedContracts.ts — auto-generated TypeScript with addresses and ABIsNever manually edit deployedContracts.ts. It is regenerated on every deploy.
yarn start
Frontend runs at http://localhost:3000. Hot reload is enabled. Changes to contracts require re-deploy (yarn deploy) but the frontend picks up new ABIs automatically.
This is the single most important rule in this entire skill.
WRONG — raw wagmi:
import { useWriteContract } from "wagmi";
const { writeContract } = useWriteContract();
const handleClick = async () => {
// THIS RESOLVES WHEN TX IS SUBMITTED, NOT CONFIRMED
// Your UI will show success while the tx might still revert
await writeContract({
address: "0x...", // hardcoded — breaks on redeploy
abi: [...], // manually pasted — goes stale
functionName: "mint",
args: [amount],
});
setSuccess(true); // WRONG — tx might not be confirmed yet
};
RIGHT — Scaffold hooks:
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
const { writeContractAsync, isMining } = useScaffoldWriteContract("MyToken");
const handleClick = async () => {
// THIS RESOLVES WHEN TX IS CONFIRMED
// Address and ABI come from auto-generated deployedContracts
await writeContractAsync({
functionName: "mint",
args: [amount],
});
// Safe to update UI — tx is confirmed
setSuccess(true);
};
Reading contract data:
// WRONG
const { data } = useReadContract({
address: "0x...",
abi: [...],
functionName: "balanceOf",
args: [userAddress],
});
// RIGHT
const { data: balance } = useScaffoldReadContract({
contractName: "MyToken",
functionName: "balanceOf",
args: [userAddress],
});
Writing with value (sending ETH):
const { writeContractAsync } = useScaffoldWriteContract("MyContract");
await writeContractAsync({
functionName: "deposit",
value: parseEther("0.1"),
});
Watching events:
import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth";
const { data: events } = useScaffoldEventHistory({
contractName: "MyToken",
eventName: "Transfer",
fromBlock: 0n,
filters: { to: userAddress },
});
When you deploy, Scaffold-ETH 2 generates full TypeScript types for your contracts. This means:
functionName is autocompleted — typos are caught at compile timeargs are type-checked — wrong argument types are caught at compile timecontractName must match a deployed contract — non-existent contracts are caught at compile timeIf you bypass Scaffold hooks and use raw wagmi, you lose ALL of this type safety.
Goal: Deploy contracts to a real chain. Test with real gas. Verify on block explorer. Frontend still runs locally for fast iteration.
In packages/hardhat/hardhat.config.ts:
const config: HardhatUserConfig = {
networks: {
base: {
url: process.env.BASE_RPC_URL || "https://mainnet.base.org",
accounts: [process.env.DEPLOYER_PRIVATE_KEY!],
},
},
};
CRITICAL: Use environment variables for private keys and RPC URLs.
# .env (NEVER commit this file)
DEPLOYER_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
BASE_RPC_URL=https://base-mainnet.g.alchemy.com/v2/YOUR_KEY
Verify .gitignore includes:
.env
.env.local
.env.production
yarn deploy --network base
This updates deployedContracts.ts with the new addresses on the target chain. The frontend automatically switches to using these addresses when you connect to the correct network.
yarn verify --network base
This submits your source code to Basescan (or Etherscan, etc.). Verified contracts show the "Contract" tab with readable source code. Users can interact directly through the explorer.
If automatic verification fails:
npx hardhat verify --network base CONTRACT_ADDRESS "constructor_arg_1" "constructor_arg_2"
Connect your wallet to the target chain. Execute every flow:
Pay attention to:
deployedContracts.ts updated with live addressesGoal: Ship the frontend. Real users can access your dApp.
See the frontend-playbook skill for detailed IPFS deployment instructions. The critical points:
# Clean build is MANDATORY
rm -rf packages/nextjs/.next packages/nextjs/out
# Build for static export
yarn build
# Deploy to IPFS via BuidlGuidl
yarn ipfs
CRITICAL: Set trailingSlash: true in next.config.js or every route except / returns 404 on IPFS.
# From the monorepo root
vercel --prod
Configure in Vercel dashboard:
packages/nextjsyarn build
yarn start
Run behind nginx or similar reverse proxy with SSL.
After deploying the frontend:
/)Agent deploys directly to testnet. Every contract change costs gas and takes 15+ seconds to confirm. Debugging is 10x slower.
Fix: Always start with yarn chain or yarn fork. Get everything working locally first.
useWriteContract Instead of useScaffoldWriteContractThe raw wagmi hook resolves on tx submission. The Scaffold hook resolves on tx confirmation. This difference causes:
Fix: Search your codebase for useWriteContract, useReadContract, useContractRead, useContractWrite. Replace ALL of them with Scaffold equivalents.
Agent copies the ABI from Etherscan and pastes it into the frontend. When the contract is redeployed, the ABI goes stale.
Fix: The ABI lives in deployedContracts.ts. Scaffold hooks read it automatically. You never need to manually handle ABIs.
# Check if secrets are in git history
git log --all --oneline -S "PRIVATE_KEY" -- "*.ts" "*.tsx" "*.js" "*.env"
If this returns results, the key is compromised. Rotate it immediately. Even if you delete the file, the key lives in git history forever.
Fix: Use .env files. Add them to .gitignore BEFORE creating them. Use process.env.VARIABLE_NAME in config files.
Unverified contracts are black boxes on block explorers. Users cannot read the code. This destroys trust.
Fix: Run yarn verify --network <chain> immediately after deployment. If it fails, debug until it passes.
Agent deploys contracts to mainnet, deploys frontend to Vercel, and THEN discovers the approval flow is broken with real tokens.
Fix: Phase 2 exists specifically to catch these issues. Test every flow with real gas before deploying the frontend.
| Task | Hook | Key Prop |
|---|---|---|
| Read contract | useScaffoldReadContract | contractName, functionName, args |
| Write contract | useScaffoldWriteContract | Returns writeContractAsync, isMining |
| Send ETH with call | useScaffoldWriteContract | Add value: parseEther("0.1") |
| Watch events | useScaffoldEventHistory | eventName, fromBlock, filters |
| Contract address | useDeployedContractInfo | Returns data.address, data.abi |
| Network info | useTargetNetwork | Returns targetNetwork with chain config |
packages/
├── hardhat/ # or foundry/
│ ├── contracts/ # Your Solidity contracts
│ ├── deploy/ # Deploy scripts (numbered: 00_deploy_X.ts)
│ └── hardhat.config.ts # Network configuration
├── nextjs/
│ ├── app/ # Next.js app router pages
│ ├── components/ # React components
│ ├── hooks/ # Custom hooks (Scaffold hooks are here)
│ ├── contracts/
│ │ └── deployedContracts.ts # AUTO-GENERATED — do not edit
│ └── scaffold.config.ts # Chain selection, polling intervals
└── package.json # Monorepo root
The three phases are not optional. They are the difference between a dApp that works and one that fails in production. Follow the pipeline.