Comparing base with foundry
base
View full →Author
@0xinit
Stars
53
Repository
0xinit/cryptoskills
Base L2 Development
Base is Coinbase's L2 built on Optimism's OP Stack. It shares Ethereum security, uses ETH as gas, and settles to L1. If you can deploy to Ethereum, you can deploy to Base with zero code changes — just point at a different RPC.
What You Probably Got Wrong
- Base is NOT a separate EVM — It is an OP Stack rollup. Same opcodes, same Solidity compiler, same tooling. There is nothing "Base-specific" about contract development. If your contract works on Ethereum or Optimism, it works on Base.
- Gas has two components — L2 execution gas (cheap, ~0.001 gwei base fee) plus L1 data fee (variable, depends on Ethereum blob fees). The L1 data fee is the dominant cost for most transactions. After EIP-4844 (Dencun), L1 data costs dropped ~100x.
- msg.sender works differently with Smart Wallet — Coinbase Smart Wallet is a smart contract account (ERC-4337).
msg.senderis the smart wallet address, not the EOA. If your contract checkstx.origin == msg.senderto block contracts, it will block Smart Wallet users. - OnchainKit is NOT a wallet library — It is a React component library for building Base-native UIs. It handles identity resolution, transaction submission, token swaps, and Farcaster frames. You still need wagmi/viem for the underlying wallet connection.
- Paymaster does NOT mean free transactions — A paymaster sponsors gas on behalf of users. Someone still pays. You configure a Coinbase Developer Platform paymaster policy that defines which contracts and methods are sponsored, with spend limits.
- Block time is 2 seconds — Not 12 seconds like Ethereum L1. Plan your polling and confirmation logic accordingly.
- Basescan and Blockscout are both available — Basescan (Etherscan-based) is the primary explorer. Blockscout is the alternative. Contract verification works on both but uses different APIs.
Quick Start
# Add Base to Foundry project
forge create src/Counter.sol:Counter \
--rpc-url https://mainnet.base.org \
--private-key $PRIVATE_KEY
# Check balance
cast balance $ADDRESS --rpc-url https://mainnet.base.org
# Deploy to testnet
forge script script/Deploy.s.sol \
--rpc-url https://sepolia.base.org \
--broadcast --verify \
--etherscan-api-key $BASESCAN_API_KEY
import { createPublicClient, createWalletClient, http } from 'viem';
import { base, baseSepolia } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
const publicClient = createPublicClient({
chain: base,
transport: http(),
});
const walletClient = createWalletClient({
chain: base,
transport: http(),
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
});
const blockNumber = await publicClient.getBlockNumber();
Chain Configuration
Base Mainnet
| Parameter | Value |
|---|---|
| Chain ID | 8453 |
| Currency | ETH |
| RPC (public) | https://mainnet.base.org |
| RPC (Coinbase) | https://api.developer.coinbase.com/rpc/v1/base/$CDP_API_KEY |
| WebSocket | wss://mainnet.base.org |
| Explorer | https://basescan.org |
| Blockscout | https://base.blockscout.com |
| Bridge | https://bridge.base.org |
| Block time | 2 seconds |
| Finality | ~12 minutes (L1 finality) |
Base Sepolia (Testnet)
| Parameter | Value |
|---|---|
| Chain ID | 84532 |
| Currency | ETH |
| RPC (public) | https://sepolia.base.org |
| RPC (Coinbase) | https://api.developer.coinbase.com/rpc/v1/base-sepolia/$CDP_API_KEY |
| Explorer | https://sepolia.basescan.org |
| Faucet | https://www.coinbase.com/faucets/base-ethereum-goerli-faucet |
Viem Chain Definitions
viem includes Base chains out of the box:
import { base, baseSepolia } from 'viem/chains';
// base.id === 8453
// baseSepolia.id === 84532
Hardhat Network Config
// hardhat.config.ts
const config: HardhatUserConfig = {
networks: {
base: {
url: process.env.BASE_RPC_URL || 'https://mainnet.base.org',
accounts: [process.env.PRIVATE_KEY!],
chainId: 8453,
},
baseSepolia: {
url: process.env.BASE_SEPOLIA_RPC_URL || 'https://sepolia.base.org',
accounts: [process.env.PRIVATE_KEY!],
chainId: 84532,
},
},
etherscan: {
apiKey: {
base: process.env.BASESCAN_API_KEY!,
baseSepolia: process.env.BASESCAN_API_KEY!,
},
customChains: [
{
network: 'base',
chainId: 8453,
urls: {
apiURL: 'https://api.basescan.org/api',
browserURL: 'https://basescan.org',
},
},
{
network: 'baseSepolia',
chainId: 84532,
urls: {
apiURL: 'https://api-sepolia.basescan.org/api',
browserURL: 'https://sepolia.basescan.org',
},
},
],
},
};
Foundry Config
# foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
[rpc_endpoints]
base = "https://mainnet.base.org"
base_sepolia = "https://sepolia.base.org"
[etherscan]
base = { key = "${BASESCAN_API_KEY}", url = "https://api.basescan.org/api", chain = 8453 }
base_sepolia = { key = "${BASESCAN_API_KEY}", url = "https://api-sepolia.basescan.org/api", chain = 84532 }
Deployment
Base uses the OP Stack. Deployment is identical to Ethereum — no special compiler flags, no custom precompiles for basic use cases.
Foundry
# Deploy to Base Sepolia
forge script script/Deploy.s.sol:DeployScript \
--rpc-url https://sepolia.base.org \
--broadcast \
--verify \
--etherscan-api-key $BASESCAN_API_KEY
# Deploy to Base Mainnet
forge script script/Deploy.s.sol:DeployScript \
--rpc-url https://mainnet.base.org \
--broadcast \
--verify \
--etherscan-api-key $BASESCAN_API_KEY \
--slow
Verification on Basescan
# Verify after deployment
forge verify-contract $CONTRACT_ADDRESS src/MyContract.sol:MyContract \
--chain base \
--etherscan-api-key $BASESCAN_API_KEY
# With constructor args
forge verify-contract $CONTRACT_ADDRESS src/MyContract.sol:MyContract \
--chain base \
--etherscan-api-key $BASESCAN_API_KEY \
--constructor-args $(cast abi-encode "constructor(address,uint256)" $TOKEN_ADDR 1000)
# Verify on Blockscout (no API key needed)
forge verify-contract $CONTRACT_ADDRESS src/MyContract.sol:MyContract \
--chain base \
--verifier blockscout \
--verifier-url https://base.blockscout.com/api/
OnchainKit
OnchainKit is Coinbase's React component library for building onchain apps on Base. It provides ready-made components for identity, transactions, swaps, wallets, and Farcaster frames.
Installation
npm install @coinbase/onchainkit
# Peer dependencies
npm install viem wagmi @tanstack/react-query
Provider Setup
import { OnchainKitProvider } from '@coinbase/onchainkit';
import { base } from 'viem/chains';
function App({ children }: { children: React.ReactNode }) {
return (
<OnchainKitProvider
apiKey={process.env.NEXT_PUBLIC_CDP_API_KEY}
chain={base}
>
{children}
</OnchainKitProvider>
);
}
Identity Components
Resolve onchain identity (ENS, Basenames, Farcaster):
import {
Identity,
Name,
Avatar,
Badge,
Address,
} from '@coinbase/onchainkit/identity';
function UserProfile({ address }: { address: `0x${string}` }) {
return (
<Identity address={address} schemaId="0x...">
<Avatar />
<Name>
<Badge />
</Name>
<Address />
</Identity>
);
}
Transaction Component
Submit transactions with built-in status tracking:
import { Transaction, TransactionButton, TransactionStatus } from '@coinbase/onchainkit/transaction';
import { baseSepolia } from 'viem/chains';
const contracts = [
{
address: '0xContractAddress' as `0x${string}`,
abi: contractAbi,
functionName: 'mint',
args: [1n],
},
];
function MintButton() {
return (
<Transaction
chainId={baseSepolia.id}
contracts={contracts}
onStatus={(status) => console.log('tx status:', status)}
>
<TransactionButton text="Mint NFT" />
<TransactionStatus />
</Transaction>
);
}
Swap Component
Token swaps powered by 0x/Uniswap:
import { Swap, SwapAmountInput, SwapButton, SwapToggle } from '@coinbase/onchainkit/swap';
import type { Token } from '@coinbase/onchainkit/token';
const ETH: Token = {
name: 'Ethereum',
address: '',
symbol: 'ETH',
decimals: 18,
image: 'https://...',
chainId: 8453,
};
const USDC: Token = {
name: 'USD Coin',
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
symbol: 'USDC',
decimals: 6,
image: 'https://...',
chainId: 8453,
};
function SwapWidget() {
return (
<Swap>
<SwapAmountInput label="Sell" token={ETH} type="from" />
<SwapToggle />
<SwapAmountInput label="Buy" token={USDC} type="to" />
<SwapButton />
</Swap>
);
}
Wallet Component
import {
ConnectWallet,
Wallet,
WalletDropdown,
WalletDropdownDisconnect,
} from '@coinbase/onchainkit/wallet';
function WalletConnect() {
return (
<Wallet>
<ConnectWallet>
<Avatar />
<Name />
</ConnectWallet>
<WalletDropdown>
<Identity />
<WalletDropdownDisconnect />
</WalletDropdown>
</Wallet>
);
}
Smart Wallet
Coinbase Smart Wallet is a smart contract wallet using passkeys (WebAuthn) for authentication. It follows ERC-4337 (account abstraction) so users do not need a seed phrase or browser extension.
How It Works
- User creates wallet via passkey (fingerprint, Face ID, or security key)
- A smart contract wallet is deployed on Base (counterfactual — deployed on first transaction)
- Transactions are signed with the passkey and submitted as UserOperations
- Compatible with ERC-4337 bundlers and paymasters
wagmi + Smart Wallet
import { http, createConfig } from 'wagmi';
import { base, baseSepolia } from 'wagmi/chains';
import { coinbaseWallet } from 'wagmi/connectors';
export const config = createConfig({
chains: [base, baseSepolia],
connectors: [
coinbaseWallet({
appName: 'My Base App',
// preference: 'smartWalletOnly' forces smart wallet (no extension)
preference: 'smartWalletOnly',
}),
],
transports: {
[base.id]: http(),
[baseSepolia.id]: http(),
},
});
Sending Transactions from Smart Wallet
import { useWriteContract } from 'wagmi';
function MintWithSmartWallet() {
const { writeContract } = useWriteContract();
const handleMint = () => {
writeContract({
address: '0xContractAddress',
abi: contractAbi,
functionName: 'mint',
args: [1n],
});
};
return <button onClick={handleMint}>Mint</button>;
}
Batch Transactions (EIP-5792)
Smart Wallet supports wallet_sendCalls for atomic batching:
import { useWriteContracts } from 'wagmi/experimental';
function BatchMint() {
const { writeContracts } = useWriteContracts();
const handleBatch = () => {
writeContracts({
contracts: [
{
address: '0xTokenAddress',
abi: erc20Abi,
functionName: 'approve',
args: ['0xSpender', 1000000n],
},
{
address: '0xSpender',
abi: spenderAbi,
functionName: 'deposit',
args: [1000000n],
},
],
});
};
return <button onClick={handleBatch}>Approve + Deposit</button>;
}
Paymaster (Gasless Transactions)
A paymaster sponsors gas fees so users pay zero gas. Coinbase Developer Platform provides a paymaster service for Base.
Setup
- Create a project at portal.cdp.coinbase.com
- Enable the Paymaster service
- Configure a paymaster policy (which contracts/methods to sponsor)
- Get your paymaster URL:
https://api.developer.coinbase.com/rpc/v1/base/$CDP_API_KEY
wagmi + Paymaster (EIP-5792)
import { useWriteContracts } from 'wagmi/experimental';
import { base } from 'viem/chains';
const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.NEXT_PUBLIC_CDP_API_KEY}`;
function SponsoredMint() {
const { writeContracts } = useWriteContracts();
const handleMint = () => {
writeContracts({
contracts: [
{
address: '0xContractAddress',
abi: contractAbi,
functionName: 'mint',
args: [1n],
},
],
capabilities: {
paymasterService: {
url: PAYMASTER_URL,
},
},
});
};
return <button onClick={handleMint}>Mint (Gasless)</button>;
}
Viem + Paymaster (Direct UserOperation)
import { createPublicClient, http, encodeFunctionData } from 'viem';
import { base } from 'viem/chains';
const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${process.env.CDP_API_KEY}`;
async function sponsoredCall(
smartWalletAddress: `0x${string}`,
target: `0x${string}`,
calldata: `0x${string}`
) {
const client = createPublicClient({
chain: base,
transport: http(),
});
// pm_getPaymasterStubData returns gas estimates for the UserOp
const paymasterResponse = await fetch(PAYMASTER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'pm_getPaymasterStubData',
params: [
{
sender: smartWalletAddress,
callData: calldata,
callGasLimit: '0x0',
verificationGasLimit: '0x0',
preVerificationGas: '0x0',
maxFeePerGas: '0x0',
maxPriorityFeePerGas: '0x0',
},
// EntryPoint v0.7
'0x0000000071727De22E5E9d8BAf0edAc6f37da032',
`0x${base.id.toString(16)}`,
],
}),
});
return paymasterResponse.json();
}
Paymaster Policy
Configure which transactions are sponsored in the Coinbase Developer Platform dashboard:
- Contract allowlist — Only sponsor calls to specific contract addresses
- Method allowlist — Only sponsor specific function selectors
- Spend limits — Max gas per user per day/week/month
- Rate limits — Max sponsored txs per user per time window
Bridging
Base uses the OP Stack standard bridge. ETH and ERC-20 tokens can be bridged between Ethereum L1 and Base L2.
Bridge ETH to Base (L1 -> L2)
import { createWalletClient, http, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
// OptimismPortal on L1 (Ethereum mainnet)
const OPTIMISM_PORTAL = '0x49048044D57e1C92A77f79988d21Fa8fAF36f97B' as const;
const walletClient = createWalletClient({
chain: mainnet,
transport: http(),
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
});
// Deposit ETH to Base — sends to OptimismPortal with value
const hash = await walletClient.sendTransaction({
to: OPTIMISM_PORTAL,
value: parseEther('0.1'),
// depositTransaction calldata for simple ETH transfer
data: '0x',
});
// ETH arrives on Base in ~1-5 minutes
Bridge ETH from Base (L2 -> L1)
L2-to-L1 withdrawals follow the OP Stack withdrawal flow:
- Initiate withdrawal on L2 (sends message to L2ToL1MessagePasser)
- Wait for state root to be posted on L1 (~1 hour)
- Prove withdrawal on L1 (submit Merkle proof)
- Wait for challenge period (~7 days on mainnet)
- Finalize withdrawal on L1
import { createPublicClient, createWalletClient, http, parseEther } from 'viem';
import { base } from 'viem/chains';
// L2ToL1MessagePasser predeploy on Base
const L2_TO_L1_MESSAGE_PASSER = '0x4200000000000000000000000000000000000016' as const;
const l2WalletClient = createWalletClient({
chain: base,
transport: http(),
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
});
// Step 1: Initiate withdrawal on L2
const hash = await l2WalletClient.writeContract({
address: L2_TO_L1_MESSAGE_PASSER,
abi: [
{
name: 'initiateWithdrawal',
type: 'function',
stateMutability: 'payable',
inputs: [
{ name: '_target', type: 'address' },
{ name: '_gasLimit', type: 'uint256' },
{ name: '_data', type: 'bytes' },
],
outputs: [],
},
],
functionName: 'initiateWithdrawal',
args: [
l2WalletClient.account.address, // send to self on L1
100_000n, // L1 gas limit for the relay
'0x',
],
value: parseEther('0.1'),
});
// Steps 2-4 require waiting and L1 transactions (use the Base Bridge UI or OP SDK)
Gas Model
Base follows the OP Stack gas model: L2 execution fee + L1 data fee.
L2 Execution Fee
Same as Ethereum: gasUsed * baseFee. Base uses EIP-1559 with a 2-second block time. Base fees are typically very low (0.001-0.01 gwei).
L1 Data Fee
Every L2 transaction is posted to Ethereum L1 as calldata or blobs. The L1 data fee covers this cost.
After EIP-4844 (Dencun upgrade, March 2024), L1 data is posted as blobs, reducing costs ~100x.
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';
// GasPriceOracle predeploy
const GAS_PRICE_ORACLE = '0x420000000000000000000000000000000000000F' as const;
const gasPriceOracleAbi = [
{
name: 'getL1Fee',
type: 'function',
stateMutability: 'view',
inputs: [{ name: '_data', type: 'bytes' }],
outputs: [{ name: '', type: 'uint256' }],
},
{
name: 'baseFeeScalar',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'uint32' }],
},
{
name: 'blobBaseFeeScalar',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'uint32' }],
},
] as const;
const client = createPublicClient({ chain: base, transport: http() });
// Estimate L1 data fee for a transaction
const l1Fee = await client.readContract({
address: GAS_PRICE_ORACLE,
abi: gasPriceOracleAbi,
functionName: 'getL1Fee',
args: ['0x...serializedTxData'],
});
Gas Estimation Tips
- Total cost = L2 execution fee + L1 data fee
- L1 data fee dominates for simple transactions
- Minimize calldata size to reduce L1 costs (pack structs, use shorter data)
eth_estimateGasreturns L2 gas only — add L1 data fee separately- viem's
estimateTotalFeeon OP Stack chains handles both components
Ecosystem
Major Base Protocols
| Protocol | Category | Description |
|---|---|---|
| Aerodrome | DEX | Dominant DEX on Base (ve(3,3) model, Velodrome fork) |
| Uniswap V3 | DEX | Deployed on Base with full feature set |
| Aave V3 | Lending | Lending/borrowing on Base |
| Compound III | Lending | USDC lending market |
| Extra Finance | Yield | Leveraged yield farming |
| Moonwell | Lending | Fork of Compound, native to Base |
| Seamless | Lending | Integrated lending protocol |
| BaseSwap | DEX | Native Base DEX |
| Morpho | Lending | Permissionless lending vaults |
Key Token Addresses (Base Mainnet)
| Token | Address |
|---|---|
| WETH | 0x4200000000000000000000000000000000000006 |
| USDC (native) | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| USDbC (bridged) | 0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6D |
| cbETH | 0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22 |
| DAI | 0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb |
| AERO | 0x940181a94A35A4569E4529A3CDfB74e38FD98631 |
Last verified: 2025-04. Always verify onchain with
cast code <address> --rpc-url https://mainnet.base.orgbefore production use.
Key Differences from Other OP Stack Chains
| Aspect | Base | Optimism | Other OP Chains |
|---|---|---|---|
| Sequencer operator | Coinbase | Optimism Foundation | Varies |
| Fee recipient | Coinbase multisig | Optimism Foundation | Varies |
| Smart Wallet | Native Coinbase integration | Not default | Not default |
| Paymaster | Coinbase Developer Platform | Third-party only | Third-party only |
| OnchainKit | Full support | Partial (identity only) | None |
| Superchain membership | Yes | Yes (leader) | Varies |
| Fault proofs | Stage 1 (planned) | Stage 1 | Varies |
Useful Commands
# Check L1 data fee for a tx
cast call 0x420000000000000000000000000000000000000F \
"getL1Fee(bytes)(uint256)" 0x... \
--rpc-url https://mainnet.base.org
# Read GasPriceOracle scalars
cast call 0x420000000000000000000000000000000000000F \
"baseFeeScalar()(uint32)" \
--rpc-url https://mainnet.base.org
# Check if address is a contract (smart wallet check)
cast code $ADDRESS --rpc-url https://mainnet.base.org
# Get current L2 base fee
cast base-fee --rpc-url https://mainnet.base.org
# Decode L1 batch data
cast call 0x4200000000000000000000000000000000000015 \
"l1BlockNumber()(uint64)" \
--rpc-url https://mainnet.base.org
References
- Base Docs: https://docs.base.org
- OnchainKit: https://onchainkit.xyz
- Coinbase Developer Platform: https://portal.cdp.coinbase.com
- Basescan: https://basescan.org
- Base Bridge: https://bridge.base.org
- OP Stack Docs: https://docs.optimism.io
- ERC-4337 Spec: https://eips.ethereum.org/EIPS/eip-4337
- EIP-5792 (wallet_sendCalls): https://eips.ethereum.org/EIPS/eip-5792
foundry
View full →Author
@0xinit
Stars
53
Repository
0xinit/cryptoskills
Foundry
Foundry is the standard Solidity development toolkit. It compiles, tests, deploys, and interacts with smart contracts — all from the command line, all in Solidity (no JavaScript test wrappers). Four binaries: forge (build/test/deploy), cast (chain interaction), anvil (local EVM node), chisel (Solidity REPL).
What You Probably Got Wrong
LLMs have stale training data. These are the most common mistakes.
forge createfor deployments → Useforge scriptwith--broadcast.forge createis for quick one-offs only — scripts are reproducible, support multi-contract deployments, and generate broadcast artifacts for verification.cast sendreturns data →cast sendsubmits a transaction and returns a tx receipt. Usecast callto read return values (simulates without sending). Mixing these up is the #1 cast mistake.- Old test assertion syntax → Foundry uses
assertEq,assertGt,assertLt,assertTrue,assertFalse. There is noassert(a == b)pattern — it compiles but gives zero debug info on failure. vm.prankpersists →vm.prankapplies to the NEXT call only. Usevm.startPrank/vm.stopPrankfor multiple calls from the same address.- Anvil is just Ganache → Anvil supports mainnet forking at specific blocks (
--fork-url+--fork-block-number), auto-impersonation, tracing, and mining modes. It's a full-featured local node. forge test -vvvvis overkill → Use-vvfor logs/events,-vvvfor execution traces on failures,-vvvvfor full traces including setup. Start with-vv.--rpc-urlwith raw URLs → Usefoundry.toml[rpc_endpoints]section or--rpc-url $ENV_VAR. Never paste RPC URLs directly in commands.- Missing
--via-irfor large contracts → If you hit "stack too deep", addvia_ir = trueinfoundry.tomlunder[profile.default]. This enables the IR-based compilation pipeline.
Quick Start
Installation
curl -L https://foundry.paradigm.xyz | bash
foundryup
Verify installation:
forge --version
cast --version
anvil --version
Initialize a Project
forge init my-project
cd my-project
This creates:
my-project/
├── foundry.toml # Project config
├── src/
│ └── Counter.sol # Source contracts
├── test/
│ └── Counter.t.sol # Test files (*.t.sol)
├── script/
│ └── Counter.s.sol # Deploy scripts (*.s.sol)
└── lib/ # Dependencies (git submodules)
Recommended foundry.toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.28"
optimizer = true
optimizer_runs = 200
ffi = false
fs_permissions = [{ access = "read", path = "./"}]
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
sepolia = "${SEPOLIA_RPC_URL}"
arbitrum = "${ARBITRUM_RPC_URL}"
base = "${BASE_RPC_URL}"
[etherscan]
mainnet = { key = "${ETHERSCAN_API_KEY}" }
sepolia = { key = "${ETHERSCAN_API_KEY}" }
arbitrum = { key = "${ARBISCAN_API_KEY}" }
base = { key = "${BASESCAN_API_KEY}" }
[fuzz]
runs = 256
max_test_rejects = 65536
[invariant]
runs = 256
depth = 15
fail_on_revert = false
Install Dependencies
forge install OpenZeppelin/openzeppelin-contracts
forge install transmissions11/solmate
Add remappings in foundry.toml:
remappings = [
"@openzeppelin/=lib/openzeppelin-contracts/",
"solmate/=lib/solmate/src/",
]
Core Commands
Build
forge build # Compile all contracts
forge build --sizes # Show contract sizes (24576 byte limit)
forge build --via-ir # Use IR pipeline (fixes stack too deep)
forge clean && forge build # Clean rebuild
Test
forge test # Run all tests
forge test --match-test testSwap # Run tests matching name
forge test --match-contract VaultTest # Run tests in matching contract
forge test --no-match-test testFork # Exclude tests matching name
forge test -vv # Show logs and assertion details
forge test -vvv # Show execution traces on failures
forge test -vvvv # Show full traces including setup
forge test --gas-report # Show gas usage per function
Cast — Read from Chain
# Call a view function (no transaction, free)
cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
"balanceOf(address)(uint256)" \
0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 \
--rpc-url $MAINNET_RPC_URL
# Decode calldata
cast 4byte-decode 0xa9059cbb000000000000000000000000...
# Get storage slot value
cast storage 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 0 --rpc-url $MAINNET_RPC_URL
# Check if address has code (is it a contract?)
cast code 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --rpc-url $MAINNET_RPC_URL
# ABI-encode function arguments
cast abi-encode "transfer(address,uint256)" 0xRecipient 1000000
# Compute function selector
cast sig "transfer(address,uint256)"
# Output: 0xa9059cbb
# Convert between units
cast to-wei 1.5 ether # 1500000000000000000
cast from-wei 1000000000000000000 # 1.000000000000000000
# Get current block number
cast block-number --rpc-url $MAINNET_RPC_URL
# Get transaction receipt
cast receipt 0xTX_HASH --rpc-url $MAINNET_RPC_URL
Cast — Write to Chain
# Send a transaction (costs gas, modifies state)
cast send 0xContractAddress \
"transfer(address,uint256)" \
0xRecipient 1000000 \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY
# Send ETH
cast send 0xRecipient \
--value 0.1ether \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY
Anvil — Local Node
# Start local node (default: http://localhost:8545)
anvil
# Fork mainnet at latest block
anvil --fork-url $MAINNET_RPC_URL
# Fork at specific block
anvil --fork-url $MAINNET_RPC_URL --fork-block-number 19000000
# Custom chain ID and port
anvil --chain-id 1337 --port 8546
# Auto-mine every 12 seconds (simulate real chain)
anvil --block-time 12
Anvil provides 10 funded accounts with 10000 ETH each. The default mnemonic is test test test test test test test test test test test junk.
Testing Patterns
Unit Test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Test} from "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
contract VaultTest is Test {
Vault vault;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
vault = new Vault();
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
function testDeposit() public {
vm.prank(alice);
vault.deposit{value: 1 ether}();
assertEq(vault.balanceOf(alice), 1 ether);
}
function testWithdrawRevertsIfInsufficientBalance() public {
vm.prank(alice);
vm.expectRevert(Vault.InsufficientBalance.selector);
vault.withdraw(1 ether);
}
}
Fuzz Test
Foundry auto-generates random inputs. The function parameter becomes the fuzz input.
function testFuzzDeposit(uint256 amount) public {
// Bound the fuzz input to a realistic range
amount = bound(amount, 0.01 ether, 100 ether);
vm.deal(alice, amount);
vm.prank(alice);
vault.deposit{value: amount}();
assertEq(vault.balanceOf(alice), amount);
}
Fork Test
Test against real mainnet state:
contract ForkTest is Test {
// USDC on Ethereum mainnet
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WHALE = 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503;
function setUp() public {
// Fork mainnet — set MAINNET_RPC_URL in foundry.toml [rpc_endpoints]
vm.createSelectFork("mainnet");
}
function testWhaleBalance() public view {
uint256 balance = IERC20(USDC).balanceOf(WHALE);
assertGt(balance, 1_000_000e6, "Whale should hold >1M USDC");
}
function testImpersonateWhale() public {
vm.prank(WHALE);
IERC20(USDC).transfer(alice, 1000e6);
assertEq(IERC20(USDC).balanceOf(alice), 1000e6);
}
}
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
}
Run fork tests:
forge test --match-contract ForkTest --fork-url $MAINNET_RPC_URL -vvv
Invariant Test
Stateful testing — Foundry calls random functions in random order and checks invariants hold:
contract VaultInvariantTest is Test {
Vault vault;
VaultHandler handler;
function setUp() public {
vault = new Vault();
handler = new VaultHandler(vault);
// Only call functions on the handler
targetContract(address(handler));
}
// Invariant: contract balance always equals sum of all deposits
function invariant_solvency() public view {
assertEq(
address(vault).balance,
handler.totalDeposited() - handler.totalWithdrawn()
);
}
}
contract VaultHandler is Test {
Vault vault;
uint256 public totalDeposited;
uint256 public totalWithdrawn;
constructor(Vault _vault) {
vault = _vault;
}
function deposit(uint256 amount) public {
amount = bound(amount, 0.01 ether, 10 ether);
vm.deal(msg.sender, amount);
vm.prank(msg.sender);
vault.deposit{value: amount}();
totalDeposited += amount;
}
function withdraw(uint256 amount) public {
uint256 balance = vault.balanceOf(msg.sender);
if (balance == 0) return;
amount = bound(amount, 1, balance);
vm.prank(msg.sender);
vault.withdraw(amount);
totalWithdrawn += amount;
}
}
Essential Cheatcodes
// Identity
address alice = makeAddr("alice"); // Deterministic address from label
(address signer, uint256 pk) = makeAddrAndKey("signer"); // Address + private key
// Impersonation
vm.prank(alice); // Next call as alice
vm.startPrank(alice); // All calls as alice until stopPrank
vm.stopPrank();
// Balances
vm.deal(alice, 10 ether); // Set ETH balance
deal(address(token), alice, 1000e18); // Set ERC20 balance (stdcheats)
// Time
vm.warp(block.timestamp + 1 days); // Set block.timestamp
vm.roll(block.number + 100); // Set block.number
skip(3600); // Advance timestamp by seconds
rewind(3600); // Rewind timestamp
// Expect revert
vm.expectRevert(); // Next call must revert
vm.expectRevert("Insufficient balance"); // Revert with message
vm.expectRevert(Vault.InsufficientBalance.selector); // Revert with custom error
vm.expectRevert(
abi.encodeWithSelector(Vault.AmountTooLarge.selector, 100)
); // Custom error with args
// Expect event emission
vm.expectEmit(true, true, false, true); // checkTopic1, checkTopic2, checkTopic3, checkData
emit Transfer(alice, bob, 100); // The expected event
vault.transfer(bob, 100); // The call that should emit
// Snapshots — save and restore EVM state
uint256 snapshot = vm.snapshotState();
// ... modify state ...
vm.revertToState(snapshot); // Restore to snapshot
// Labels — improve trace readability
vm.label(address(vault), "Vault");
vm.label(alice, "Alice");
// Environment variables
string memory rpcUrl = vm.envString("RPC_URL");
uint256 pk = vm.envUint("PRIVATE_KEY");
Deployment
Deployment Script
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {Script, console} from "forge-std/Script.sol";
import {Vault} from "../src/Vault.sol";
contract DeployVault is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
Vault vault = new Vault();
console.log("Vault deployed to:", address(vault));
vm.stopBroadcast();
}
}
Deploy Commands
# Dry run (simulate without broadcasting)
forge script script/DeployVault.s.sol --rpc-url sepolia -vvvv
# Deploy to testnet
forge script script/DeployVault.s.sol \
--rpc-url sepolia \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
-vvvv
# Deploy to mainnet (use --slow for reliability)
forge script script/DeployVault.s.sol \
--rpc-url mainnet \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY \
--slow \
-vvvv
The --slow flag waits for each transaction to be confirmed before sending the next. Always use it on mainnet.
Broadcast artifacts are saved to broadcast/DeployVault.s.sol/<chainId>/run-latest.json. These contain deployed addresses and transaction hashes.
Verify an Already-Deployed Contract
forge verify-contract 0xDeployedAddress \
src/Vault.sol:Vault \
--chain sepolia \
--etherscan-api-key $ETHERSCAN_API_KEY
# With constructor arguments
forge verify-contract 0xDeployedAddress \
src/Vault.sol:Vault \
--chain mainnet \
--etherscan-api-key $ETHERSCAN_API_KEY \
--constructor-args $(cast abi-encode "constructor(address,uint256)" 0xOwner 1000)
Advanced
Gas Snapshots
Capture gas usage per test for regression tracking:
forge snapshot # Create .gas-snapshot file
forge snapshot --check # Compare against existing snapshot
forge snapshot --diff # Show diff against existing snapshot
Code Coverage
forge coverage # Summary table
forge coverage --report lcov # Generate lcov report
To view in a browser:
forge coverage --report lcov
genhtml lcov.info -o coverage --branch-coverage
open coverage/index.html
Contract Inspection
# View ABI
forge inspect src/Vault.sol:Vault abi
# View storage layout
forge inspect src/Vault.sol:Vault storage-layout
# View creation bytecode
forge inspect src/Vault.sol:Vault bytecode
# View function selectors (method IDs)
forge inspect src/Vault.sol:Vault methods
Gas Optimization with forge inspect
# Check contract size (24576 byte limit for deployment)
forge build --sizes
# Compare optimizer runs impact
forge build --optimizer-runs 200
forge build --optimizer-runs 10000
Debug a Transaction
# Interactive debugger for a failed test
forge test --match-test testDeposit --debug
# Debug a mainnet transaction
cast run 0xTX_HASH --rpc-url $MAINNET_RPC_URL
Chisel — Solidity REPL
chisel
# Inside chisel:
# !help — show commands
# uint256 x = 42; — declare variables
# x + 8 — evaluate expressions
# !source — show current session source
Error Handling
| Error | Cause | Fix |
|---|---|---|
Stack too deep | Too many local variables for the EVM stack | Add via_ir = true to foundry.toml |
EvmError: OutOfGas | Function exceeds block gas limit in test | Increase gas limit: --gas-limit 30000000 |
CompilerError: File not found | Missing remapping for imported path | Add remapping to foundry.toml remappings = [...] |
Failed to get EIP-1559 fees | RPC doesn't support EIP-1559 (some L2s) | Add --legacy flag to forge script or cast send |
(code: -32000, message: nonce too low) | Pending tx or wrong nonce | Wait for pending txs or use --nonce <N> |
script failed: transaction reverted | On-chain state differs from expectation | Run without --broadcast first to debug, use -vvvv |
contract exceeds 24576 bytes | Contract too large to deploy | Split into libraries, optimize, or use the diamond pattern |
forge test hangs on fork tests | RPC rate limiting or slow responses | Use a dedicated RPC provider, add --fork-retry-backoff |
permission denied: ffi | FFI disabled by default (security) | Add ffi = true in foundry.toml only if you trust all dependencies |
Solc version mismatch | pragma doesn't match config | Set solc in foundry.toml or use auto_detect_solc = true |
Security
- Never hardcode private keys in scripts or CLI commands. Use environment variables:
vm.envUint("PRIVATE_KEY")in scripts,--private-key $PRIVATE_KEYin commands. - Use
--slowon mainnet to wait for confirmations between transactions. - Dry-run before broadcast — always run
forge scriptwithout--broadcastfirst. - Pin fork block numbers —
--fork-block-numberprevents tests from breaking when chain state changes. - Disable FFI unless needed —
ffi = falseprevents contracts from executing arbitrary shell commands during tests. - Review broadcast artifacts — check
broadcast/*/run-latest.jsonbefore deploying to mainnet. - Use
--verifyon deploy — verified source code is essential for trust and debugging. - Store
.envin.gitignore— never commit RPC URLs with API keys or private keys.
References
- Foundry Book — official documentation
- Foundry GitHub — source code and issues
- forge-std Reference — standard library (Test, Script, cheatcodes)
- Cheatcodes Reference — full list of
vm.*cheatcodes - Cast Reference — all cast subcommands
- Foundry Best Practices — project structure and patterns