返回 SC 笔记
SC Day 25

Solidity/Foundry 环境搭建 + forge init/build/test + 项目结构

### 一、Foundry 是什么?

2026-04-25
第二阶段:框架实战 (Day 25-30)
Foundryforge测试开发环境Solidity

日期: 2026-04-25 方向: Solidity / Foundry 阶段: 第二阶段:框架实战 (Day 25-30) 标签: #Foundry #forge #测试 #开发环境 #Solidity


今日目标

  1. 安装和配置 Foundry 工具链
  2. 理解 Foundry 项目结构(src/test/script/lib)
  3. 掌握 foundry.toml 核心配置
  4. 将 ERC20 项目迁移到 Foundry
  5. 编写第一个 Foundry 测试(setUp + test 函数)
  6. 对比 Foundry 和 Hardhat 的差异

核心概念

一、Foundry 是什么?

Foundry 是用 Rust 编写的 Solidity 开发框架,2022 年由 Paradigm 推出,已成为 DeFi 开发的事实标准。

1.1 Foundry 工具链

工具功能等价于
forge编译、测试、部署Hardhat compile/test
cast链上交互 CLIethers.js 脚本
anvil本地测试节点Hardhat Network / Ganache
chiselSolidity REPL-

1.2 为什么从 Hardhat 迁移到 Foundry?

维度HardhatFoundry
语言JavaScript/TypeScriptSolidity (测试也用 Solidity!)
速度慢 (Node.js)极快 (Rust)
测试JS 测试 + ethers.jsSolidity 测试 + cheatcodes
Fuzz 测试需要额外工具内置
Fork 测试支持但较慢支持且极快
Gas 报告插件内置 forge snapshot
依赖管理npmgit submodules
学习曲线JS 开发者友好需要熟悉 Solidity
社区趋势老牌,插件生态丰富DeFi 团队首选
适用场景前端集成、脚本合约开发、审计

总结: 大多数 DeFi 协议(Uniswap V4, Aave V3, OpenSea)已迁移到 Foundry。但 Hardhat 在前端集成场景仍有优势。

二、安装 Foundry

2.1 安装步骤

# Linux / macOS / WSL
curl -L https://foundry.paradigm.xyz | bash

# 安装/更新到最新版本
foundryup

# 验证安装
forge --version
cast --version
anvil --version
chisel --version

2.2 Windows 注意事项

Windows 用户推荐使用 WSL2 或 Git Bash。Foundry 原生支持 Windows,但部分功能在 WSL 下更稳定。

# Windows (PowerShell)
# 安装 foundryup
curl -L https://win.foundry.paradigm.xyz | bash

# 或使用 cargo 安装
cargo install --git https://github.com/foundry-rs/foundry --profile release forge cast anvil chisel

三、项目结构

3.1 forge init

# 创建新项目
forge init my-project
cd my-project

# 项目结构:
my-project/
├── foundry.toml          # 配置文件 (类似 hardhat.config.ts)
├── src/                   # 合约源代码
│   └── Counter.sol        # 示例合约
├── test/                  # 测试文件
│   └── Counter.t.sol      # 示例测试 (约定: .t.sol)
├── script/                # 部署/交互脚本
│   └── Counter.s.sol      # 示例脚本 (约定: .s.sol)
├── lib/                   # 依赖 (git submodules)
│   └── forge-std/         # 标准库 (自动安装)
├── .github/               # CI 配置
├── .gitmodules            # git 子模块配置
└── remappings.txt         # import 路径映射

文件命名约定

  • *.sol — 合约源代码(放在 src/
  • *.t.sol — 测试文件(放在 test/
  • *.s.sol — 脚本文件(放在 script/

3.2 foundry.toml 配置

[profile.default]
# 编译配置
src = "src"                     # 源代码目录
out = "out"                     # 编译输出目录
libs = ["lib"]                  # 依赖目录
test = "test"                   # 测试目录
script = "script"               # 脚本目录

# Solidity 编译器配置
solc_version = "0.8.20"         # 指定 solc 版本
optimizer = true                # 启用优化器
optimizer_runs = 200            # 优化轮数 (越大越优化运行时Gas,越小越优化部署Gas)
via_ir = false                  # 是否使用 IR pipeline

# EVM 配置
evm_version = "paris"           # EVM 版本

# 测试配置
fuzz = { runs = 256 }           # fuzz 测试轮数
verbosity = 2                   # 日志详细度 (0-5)

# 格式化
[fmt]
line_length = 100               # 行宽
tab_width = 4                   # 缩进
bracket_spacing = true          # 花括号空格

# 不同环境的配置
[profile.ci]
fuzz = { runs = 10000 }         # CI 环境跑更多 fuzz 轮数

[profile.lite]
optimizer = false               # 开发时不优化,编译更快

# RPC 端点 (用于 fork 测试)
[rpc_endpoints]
mainnet = "${ETH_RPC_URL}"
sepolia = "https://rpc.sepolia.org"
arbitrum = "https://arb1.arbitrum.io/rpc"

3.3 依赖管理

# 安装 OpenZeppelin
forge install OpenZeppelin/openzeppelin-contracts

# 安装指定版本
forge install OpenZeppelin/openzeppelin-contracts@v5.0.0

# 安装其他库
forge install transmissions11/solmate
forge install Uniswap/v3-core

# 更新依赖
forge update

# 删除依赖
forge remove openzeppelin-contracts

安装后需要配置 remappings,让 Solidity import 能正确解析:

# 生成 remappings
forge remappings > remappings.txt

remappings.txt 内容:

@openzeppelin/=lib/openzeppelin-contracts/
@solmate/=lib/solmate/src/
forge-std/=lib/forge-std/src/

四、核心命令

# 编译
forge build                      # 编译所有合约
forge build --sizes              # 显示合约大小
forge build --force              # 强制重新编译

# 测试
forge test                       # 运行所有测试
forge test -vvvv                 # 详细输出 (显示每个操作码)
forge test --match-test testXxx  # 只运行匹配的测试
forge test --match-contract Xxx  # 只运行匹配的合约
forge test --gas-report          # 显示 Gas 报告
forge test --fork-url $RPC       # Fork 主网测试

# Gas 快照
forge snapshot                   # 生成 gas 快照
forge snapshot --diff            # 与上次快照对比

# 本地节点
anvil                            # 启动本地节点 (默认 8545 端口)
anvil --fork-url $RPC            # Fork 主网

# 链上交互
cast call $ADDR "balanceOf(address)" $USER    # 只读调用
cast send $ADDR "transfer(address,uint256)" $TO $AMT  # 发送交易
cast balance $ADDR                             # 查看 ETH 余额
cast to-dec 0x1a                              # 十六进制转十进制
cast abi-encode "foo(uint256,bool)" 123 true  # ABI 编码

# 格式化
forge fmt                        # 格式化代码
forge fmt --check                # 检查格式

代码实战

将 ERC20 迁移到 Foundry 项目

Step 1: 项目初始化

forge init momo-token
cd momo-token
forge install OpenZeppelin/openzeppelin-contracts

Step 2: 合约 (src/MomoToken.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title MomoToken
 * @notice 学习用 ERC20 代币,包含基础功能 + Permit + 铸造上限
 */
contract MomoToken is ERC20, ERC20Burnable, ERC20Permit, Ownable {
    uint256 public constant MAX_SUPPLY = 1_000_000_000 * 1e18; // 10亿枚

    event Minted(address indexed to, uint256 amount);

    constructor(
        address initialOwner,
        uint256 initialSupply
    )
        ERC20("Momo Token", "MOMO")
        ERC20Permit("Momo Token")
        Ownable(initialOwner)
    {
        require(initialSupply <= MAX_SUPPLY, "Exceeds max supply");
        _mint(initialOwner, initialSupply);
    }

    /// @notice Owner 可以铸造新代币(不超过 MAX_SUPPLY)
    function mint(address to, uint256 amount) external onlyOwner {
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        _mint(to, amount);
        emit Minted(to, amount);
    }
}

Step 3: 测试 (test/MomoToken.t.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/MomoToken.sol";

/**
 * @title MomoTokenTest
 * @notice MomoToken 的完整测试套件
 * @dev 演示 Foundry 测试的核心模式
 */
contract MomoTokenTest is Test {
    // ============ 状态变量 ============

    MomoToken public token;

    // 测试地址 (Foundry 的 makeAddr 创建标签化地址)
    address public owner;
    address public alice;
    address public bob;

    uint256 public constant INITIAL_SUPPLY = 100_000_000 * 1e18; // 1亿

    // ============ setUp: 每个测试函数执行前都会调用 ============

    function setUp() public {
        // 创建有标签的地址 (在 trace 中更易读)
        owner = makeAddr("owner");
        alice = makeAddr("alice");
        bob = makeAddr("bob");

        // 以 owner 身份部署
        vm.prank(owner);
        token = new MomoToken(owner, INITIAL_SUPPLY);

        // 给测试账户一些 ETH
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
    }

    // ============ 部署测试 ============

    function test_InitialState() public view {
        assertEq(token.name(), "Momo Token");
        assertEq(token.symbol(), "MOMO");
        assertEq(token.decimals(), 18);
        assertEq(token.totalSupply(), INITIAL_SUPPLY);
        assertEq(token.balanceOf(owner), INITIAL_SUPPLY);
        assertEq(token.owner(), owner);
    }

    function test_MaxSupply() public view {
        assertEq(token.MAX_SUPPLY(), 1_000_000_000 * 1e18);
    }

    // ============ Transfer 测试 ============

    function test_Transfer() public {
        uint256 amount = 1000 * 1e18;

        // owner 转给 alice
        vm.prank(owner);
        bool success = token.transfer(alice, amount);

        assertTrue(success);
        assertEq(token.balanceOf(alice), amount);
        assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
    }

    function test_TransferEmitsEvent() public {
        uint256 amount = 500 * 1e18;

        // 期望发出 Transfer 事件
        // 参数: checkTopic1, checkTopic2, checkTopic3, checkData
        vm.expectEmit(true, true, false, true);
        emit IERC20.Transfer(owner, alice, amount);

        vm.prank(owner);
        token.transfer(alice, amount);
    }

    function test_RevertTransferInsufficientBalance() public {
        uint256 amount = INITIAL_SUPPLY + 1;

        // 期望 revert
        vm.prank(owner);
        vm.expectRevert();
        token.transfer(alice, amount);
    }

    function test_RevertTransferToZeroAddress() public {
        vm.prank(owner);
        vm.expectRevert();
        token.transfer(address(0), 100);
    }

    // ============ Approve + TransferFrom 测试 ============

    function test_Approve() public {
        uint256 amount = 1000 * 1e18;

        vm.prank(owner);
        bool success = token.approve(alice, amount);

        assertTrue(success);
        assertEq(token.allowance(owner, alice), amount);
    }

    function test_TransferFrom() public {
        uint256 amount = 1000 * 1e18;

        // owner approve alice
        vm.prank(owner);
        token.approve(alice, amount);

        // alice 从 owner 转给 bob
        vm.prank(alice);
        token.transferFrom(owner, bob, amount);

        assertEq(token.balanceOf(bob), amount);
        assertEq(token.balanceOf(owner), INITIAL_SUPPLY - amount);
        assertEq(token.allowance(owner, alice), 0); // allowance 用完
    }

    function test_RevertTransferFromInsufficientAllowance() public {
        vm.prank(owner);
        token.approve(alice, 100);

        vm.prank(alice);
        vm.expectRevert();
        token.transferFrom(owner, bob, 200); // 超过 allowance
    }

    // ============ Mint 测试 ============

    function test_MintByOwner() public {
        uint256 mintAmount = 50_000_000 * 1e18;

        vm.prank(owner);
        token.mint(alice, mintAmount);

        assertEq(token.balanceOf(alice), mintAmount);
        assertEq(token.totalSupply(), INITIAL_SUPPLY + mintAmount);
    }

    function test_RevertMintExceedsMaxSupply() public {
        uint256 tooMuch = token.MAX_SUPPLY() - INITIAL_SUPPLY + 1;

        vm.prank(owner);
        vm.expectRevert("Exceeds max supply");
        token.mint(alice, tooMuch);
    }

    function test_RevertMintByNonOwner() public {
        vm.prank(alice);
        vm.expectRevert();
        token.mint(alice, 100);
    }

    // ============ Burn 测试 ============

    function test_Burn() public {
        uint256 burnAmount = 1000 * 1e18;

        vm.prank(owner);
        token.burn(burnAmount);

        assertEq(token.balanceOf(owner), INITIAL_SUPPLY - burnAmount);
        assertEq(token.totalSupply(), INITIAL_SUPPLY - burnAmount);
    }

    // ============ 时间操纵测试 ============

    function test_TimestampManipulation() public view {
        // vm.warp 设置 block.timestamp
        // vm.roll 设置 block.number
        uint256 currentTime = block.timestamp;
        uint256 currentBlock = block.number;

        // 验证初始状态
        assertGt(currentTime, 0);
        assertGt(currentBlock, 0);
    }
}

Step 4: 部署脚本 (script/Deploy.s.sol)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import "../src/MomoToken.sol";

contract DeployScript is Script {
    function run() external {
        // 从环境变量获取私钥
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        address deployer = vm.addr(deployerPrivateKey);

        console.log("Deployer:", deployer);
        console.log("Balance:", deployer.balance);

        vm.startBroadcast(deployerPrivateKey);

        MomoToken token = new MomoToken(
            deployer,
            100_000_000 * 1e18 // 1亿初始供应
        );

        console.log("MomoToken deployed at:", address(token));

        vm.stopBroadcast();
    }
}

Step 5: 运行

# 编译
forge build

# 运行测试
forge test

# 详细输出
forge test -vvv

# 单个测试
forge test --match-test test_Transfer -vvvv

# Gas 报告
forge test --gas-report

# 部署到本地 (先启动 anvil)
# 终端1: anvil
# 终端2:
forge script script/Deploy.s.sol --rpc-url http://localhost:8545 --broadcast

# 部署到 Sepolia
forge script script/Deploy.s.sol \
  --rpc-url $SEPOLIA_RPC \
  --broadcast \
  --verify \
  --etherscan-api-key $ETHERSCAN_KEY

关键要点总结

Foundry 测试核心模式

模式代码说明
setUpfunction setUp() public每个测试前执行
test_function test_Xxx()测试函数前缀
testFail_function testFail_Xxx()期望 revert 的测试
vm.prankvm.prank(addr)下一次调用以 addr 身份执行
vm.dealvm.deal(addr, amount)设置 ETH 余额
vm.expectRevertvm.expectRevert("msg")期望下一次调用 revert
vm.expectEmitvm.expectEmit(t1,t2,t3,d)期望发出事件
makeAddrmakeAddr("label")创建有标签的地址
console.logconsole.log("msg", val)调试输出

项目结构最佳实践

project/
├── src/
│   ├── Token.sol           # 主合约
│   ├── interfaces/         # 接口定义
│   │   └── IToken.sol
│   └── libraries/          # 库
│       └── MathLib.sol
├── test/
│   ├── Token.t.sol         # 单元测试
│   ├── integration/        # 集成测试
│   └── invariant/          # 不变量测试
├── script/
│   ├── Deploy.s.sol        # 部署脚本
│   └── Interact.s.sol      # 交互脚本
└── foundry.toml

常见误区

  1. 测试函数不以 test 开头: Foundry 不会识别为测试,也不会报错(静默跳过)
  2. 忘记在 setUp 中部署合约: 每个 test 函数都是独立的,需要 setUp 初始化
  3. remappings 配置错误: 安装依赖后记得运行 forge remappings > remappings.txt
  4. 混淆 vm.prankvm.startPrank: prank 只影响下一次调用,startPrank 影响后续所有调用直到 stopPrank
  5. 在测试中使用 Hardhat 的 expect(...).to.be.reverted: Foundry 测试是 Solidity,不是 JS

面试关联

面试题本课关联
"你用什么工具开发 Solidity?"Foundry,因为测试速度快、fuzz 内置、DeFi 主流
"如何测试智能合约?"setUp + test_ 函数 + cheatcodes + fork 测试
"Foundry 和 Hardhat 的区别?"语言、速度、fuzz 支持、社区趋势
"如何保证合约部署的确定性?"forge script + CREATE2 + 验证
"如何自动化合约部署?"forge script + CI/CD

参考资源