Comparing base with foundry

Author

@0xinit

Stars

53

Repository

0xinit/cryptoskills

skills/base/SKILL.md

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.sender is the smart wallet address, not the EOA. If your contract checks tx.origin == msg.sender to 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

ParameterValue
Chain ID8453
CurrencyETH
RPC (public)https://mainnet.base.org
RPC (Coinbase)https://api.developer.coinbase.com/rpc/v1/base/$CDP_API_KEY
WebSocketwss://mainnet.base.org
Explorerhttps://basescan.org
Blockscouthttps://base.blockscout.com
Bridgehttps://bridge.base.org
Block time2 seconds
Finality~12 minutes (L1 finality)

Base Sepolia (Testnet)

ParameterValue
Chain ID84532
CurrencyETH
RPC (public)https://sepolia.base.org
RPC (Coinbase)https://api.developer.coinbase.com/rpc/v1/base-sepolia/$CDP_API_KEY
Explorerhttps://sepolia.basescan.org
Faucethttps://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

  1. User creates wallet via passkey (fingerprint, Face ID, or security key)
  2. A smart contract wallet is deployed on Base (counterfactual — deployed on first transaction)
  3. Transactions are signed with the passkey and submitted as UserOperations
  4. 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

  1. Create a project at portal.cdp.coinbase.com
  2. Enable the Paymaster service
  3. Configure a paymaster policy (which contracts/methods to sponsor)
  4. 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:

  1. Initiate withdrawal on L2 (sends message to L2ToL1MessagePasser)
  2. Wait for state root to be posted on L1 (~1 hour)
  3. Prove withdrawal on L1 (submit Merkle proof)
  4. Wait for challenge period (~7 days on mainnet)
  5. 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_estimateGas returns L2 gas only — add L1 data fee separately
  • viem's estimateTotalFee on OP Stack chains handles both components

Ecosystem

Major Base Protocols

ProtocolCategoryDescription
AerodromeDEXDominant DEX on Base (ve(3,3) model, Velodrome fork)
Uniswap V3DEXDeployed on Base with full feature set
Aave V3LendingLending/borrowing on Base
Compound IIILendingUSDC lending market
Extra FinanceYieldLeveraged yield farming
MoonwellLendingFork of Compound, native to Base
SeamlessLendingIntegrated lending protocol
BaseSwapDEXNative Base DEX
MorphoLendingPermissionless lending vaults

Key Token Addresses (Base Mainnet)

TokenAddress
WETH0x4200000000000000000000000000000000000006
USDC (native)0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
USDbC (bridged)0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6D
cbETH0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22
DAI0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb
AERO0x940181a94A35A4569E4529A3CDfB74e38FD98631

Last verified: 2025-04. Always verify onchain with cast code <address> --rpc-url https://mainnet.base.org before production use.

Key Differences from Other OP Stack Chains

AspectBaseOptimismOther OP Chains
Sequencer operatorCoinbaseOptimism FoundationVaries
Fee recipientCoinbase multisigOptimism FoundationVaries
Smart WalletNative Coinbase integrationNot defaultNot default
PaymasterCoinbase Developer PlatformThird-party onlyThird-party only
OnchainKitFull supportPartial (identity only)None
Superchain membershipYesYes (leader)Varies
Fault proofsStage 1 (planned)Stage 1Varies

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

Author

@0xinit

Stars

53

Repository

0xinit/cryptoskills

skills/foundry/SKILL.md

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 create for deployments → Use forge script with --broadcast. forge create is for quick one-offs only — scripts are reproducible, support multi-contract deployments, and generate broadcast artifacts for verification.
  • cast send returns datacast send submits a transaction and returns a tx receipt. Use cast call to 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 no assert(a == b) pattern — it compiles but gives zero debug info on failure.
  • vm.prank persistsvm.prank applies to the NEXT call only. Use vm.startPrank/vm.stopPrank for 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 -vvvv is overkill → Use -vv for logs/events, -vvv for execution traces on failures, -vvvv for full traces including setup. Start with -vv.
  • --rpc-url with raw URLs → Use foundry.toml [rpc_endpoints] section or --rpc-url $ENV_VAR. Never paste RPC URLs directly in commands.
  • Missing --via-ir for large contracts → If you hit "stack too deep", add via_ir = true in foundry.toml under [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

ErrorCauseFix
Stack too deepToo many local variables for the EVM stackAdd via_ir = true to foundry.toml
EvmError: OutOfGasFunction exceeds block gas limit in testIncrease gas limit: --gas-limit 30000000
CompilerError: File not foundMissing remapping for imported pathAdd remapping to foundry.toml remappings = [...]
Failed to get EIP-1559 feesRPC 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 nonceWait for pending txs or use --nonce <N>
script failed: transaction revertedOn-chain state differs from expectationRun without --broadcast first to debug, use -vvvv
contract exceeds 24576 bytesContract too large to deploySplit into libraries, optimize, or use the diamond pattern
forge test hangs on fork testsRPC rate limiting or slow responsesUse a dedicated RPC provider, add --fork-retry-backoff
permission denied: ffiFFI disabled by default (security)Add ffi = true in foundry.toml only if you trust all dependencies
Solc version mismatchpragma doesn't match configSet 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_KEY in commands.
  • Use --slow on mainnet to wait for confirmations between transactions.
  • Dry-run before broadcast — always run forge script without --broadcast first.
  • Pin fork block numbers--fork-block-number prevents tests from breaking when chain state changes.
  • Disable FFI unless neededffi = false prevents contracts from executing arbitrary shell commands during tests.
  • Review broadcast artifacts — check broadcast/*/run-latest.json before deploying to mainnet.
  • Use --verify on deploy — verified source code is essential for trust and debugging.
  • Store .env in .gitignore — never commit RPC URLs with API keys or private keys.

References

AI Skill Finder

Ask me what skills you need

What are you building?

Tell me what you're working on and I'll find the best agent skills for you.