跳到文章正文
04 / 合约

Foundry 工程化与测试

从 NFT 合约出发,练习构建、Solidity 测试、调试与部署。

约 22 分钟阅读
第 4 课:Foundry 工程化与测试
本文为作者已发布教程的站内版本。在 X 查看原文与讨论

学习目标: 使用 Foundry 完成开发、测试、调试与部署。

上一课

本课目录

  • 1. Foundry简介
  • 2. 为什么选择Foundry
  • 3. Foundry核心组件
  • 4. 安装与配置
  • 5. 开发实战:NFT项目
  • 6. 测试与调试
  • 7. 合约部署与验证
  • 8. 链上交互
  • 9. 安全研究应用
  • 10. 常见问题与最佳实践
  • 结语

1. Foundry简介

Foundry官网

Foundry是一个基于Rust编写的智能合约开发工具包,专为以太坊和EVM兼容链设计。它提供了一套完整的开发、测试、部署工具链。

主要特点

  • 高性能:基于Rust实现,执行速度远超传统工具
  • 原生Solidity测试:直接用Solidity编写测试代码
  • 丰富的调试功能:提供详细的执行跟踪和状态信息
  • 模块化设计:包含多个专用工具

2. 为什么选择Foundry

2.1 五大核心优势

1. 用Solidity编写测试

传统工具如Truffle使用JavaScript,Brownie使用Python编写测试,存在以下问题:

  • 语言抽象层增加复杂性
  • JavaScript需要BigNumber库处理大数运算
  • 学习成本高,需要掌握多种语言

Foundry直接用Solidity编写测试,保持语言一致性。

2. 模糊测试(Fuzzing)功能

自动生成随机输入参数,发现开发者未考虑到的边界情况:

function testFuzz_AddNumbers(uint256 x) public {
    // Foundry会自动生成各种x值进行测试
    uint256 result = myContract.addNumber(x);
    // 测试逻辑
}

3. VM状态覆盖

可以修改区块链状态进行测试:

  • 模拟成为合约owner
  • 直接修改代币余额
  • 改变区块时间和高度

4. 实时网络状态测试

支持fork主网状态进行测试:

vm.createSelectFork("mainnet", blockNumber);

5. 详细调试信息

提供丰富的日志和执行跟踪,帮助快速定位问题。


3. Foundry核心组件

3.1 Forge

主要的构建和测试工具,功能包括:

  • 项目创建和管理
  • 合约编译
  • 测试执行
  • 合约部署
  • 代码覆盖率分析

3.2 Cast

命令行RPC交互工具,支持:

  • 发送交易
  • 调用合约函数
  • 查询链上数据
  • ABI编解码
  • 单位转换

3.3 Anvil

本地测试网节点,类似Ganache但性能更优。

3.4 Chisel

Solidity REPL环境,可以快速测试代码片段。


4. 安装与配置

4.1 安装Foundry

安装Foundry:

curl -L https://foundry.paradigm.xyz | bash
foundryup

4.2 验证安装

forge --version
cast --version
anvil --version
chisel --version

4.3 更新Foundry

foundryup

4.4 项目初始化

forge init my-project
cd my-project

4.5 项目结构

my-project/
├── lib/                # 依赖库
├── src/               # 合约源码
├── test/              # 测试文件
├── script/            # 部署脚本
├── foundry.toml       # 配置文件
└── remappings.txt     # 路径映射

4.6 配置文件详解

foundry.toml配置示例:

https://www.getfoundry.sh/config

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.19"

[rpc_endpoints]
mainnet = "https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY"
goerli = "https://eth-goerli.alchemyapi.io/v2/YOUR-API-KEY"

[fuzz]
runs = 1000

[invariant]
runs = 256
depth = 15

5. 开发实战:NFT项目

5.1 创建项目

forge init nft-project
cd nft-project

5.2 安装依赖

forge install OpenZeppelin/openzeppelin-contracts
forge install transmissions11/solmate

5.3 编写NFT合约

创建src/NFT.sol:

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

import {ERC721} from "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol";
import {Strings} from "openzeppelin-contracts/contracts/utils/Strings.sol";

contract NFT is ERC721, Ownable {
    using Strings for uint256;

    uint256 public constant MINT_PRICE = 0.08 ether;
    uint256 public constant MAX_SUPPLY = 1000;
    uint256 public totalSupply;
    string private _baseTokenURI;

    constructor(
        string memory name,
        string memory symbol,
        string memory baseURI,
        address initialOwner
    ) ERC721(name, symbol) Ownable(initialOwner) {
        _baseTokenURI = baseURI;
    }

    function mintTo(address recipient) public payable returns (uint256) {
        require(msg.value == MINT_PRICE, "Insufficient payment");
        require(totalSupply < MAX_SUPPLY, "Max supply reached");

        uint256 tokenId = totalSupply;
        totalSupply++;

        _safeMint(recipient, tokenId);
        return tokenId;
    }

    function withdrawPayments() public onlyOwner {
        (bool success, ) = payable(owner()).call{value: address(this).balance}("");
        require(success, "Transfer failed");
    }

    function _baseURI() internal view override returns (string memory) {
        return _baseTokenURI;
    }
}

5.4 编译合约

forge build

6. 测试与调试

6.1 编写测试合约

创建test/NFT.t.sol:

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

// 导入 Foundry 测试框架
import "forge-std/Test.sol";
// 导入被测试的 NFT 合约
import "../src/NFT.sol";

/**
 * @title NFTTest
 * @dev NFT 合约的测试套件
 * @notice 继承 Foundry 的 Test 合约以获得测试功能和 cheat codes
 */
contract NFTTest is Test {
    // 被测试的 NFT 合约实例
    NFT public nft;

    // 测试用地址:合约所有者
    address public owner = address(0x1);
    // 测试用地址:普通用户
    address public user = address(0x2);

    /**
     * @dev 测试设置函数,在每个测试函数执行前自动调用
     * @notice 初始化测试环境,部署 NFT 合约
     */
    function setUp() public {
        // 使用 cheat code 模拟 owner 地址发起交易
        vm.prank(owner);
        // 部署 NFT 合约,设置名称、符号和基础 URI
        nft = new NFT("Test NFT", "TNFT", "https://example.com/");
    }

    /**
     * @dev 测试成功铸造 NFT 的场景
     * @notice 验证用户支付正确金额后能成功铸造 NFT
     */
    function testMintSuccess() public {
        // 模拟用户地址发起交易
        vm.prank(user);
        // 给用户地址分配 1 ether 余额
        vm.deal(user, 1 ether);

        // 用户支付 0.08 ether 铸造 NFT 给自己
        uint256 tokenId = nft.mintTo{value: 0.08 ether}(user);

        // 断言:验证 NFT 的所有者是用户
        assertEq(nft.ownerOf(tokenId), user);
        // 断言:验证总供应量为 1
        assertEq(nft.totalSupply(), 1);
    }

    /**
     * @dev 测试支付不足时铸造失败的场景
     * @notice testFail 前缀表示期望这个测试会失败(revert)
     * @notice 验证支付金额不足时交易会回滚
     */
    function test_RevertWhen_MintInsufficientPayment() public {
        // 模拟用户地址发起交易
        vm.deal(user, 1 ether);
        vm.prank(user);
        vm.expectRevert("Insufficient payment");
        // 尝试用 0.01 ether(不足)铸造 NFT,期望失败
        nft.mintTo{value: 0.01 ether}(user);
    }

    /**
     * @dev 测试只有所有者可以提取资金的场景
     * @notice 验证访问控制和资金提取功能
     */
    function testWithdrawOnlyOwner() public {
        // === 第一步:生成收入 ===
        // 模拟用户铸造 NFT 以产生合约收入
        vm.prank(user);
        vm.deal(user, 1 ether);
        nft.mintTo{value: 0.08 ether}(user);

        // === 第二步:提取资金 ===
        // 只有 owner 可以提取合约中的资金
        vm.prank(owner);
        nft.withdrawPayments();

        // === 验证结果 ===
        // 断言:合约余额应该为 0(资金已被提取)
        assertEq(address(nft).balance, 0);
    }

    /**
     * @dev 模糊测试:测试非正确价格的所有情况
     * @param price 随机生成的价格参数
     * @notice 使用模糊测试验证只有正确价格才能铸造成功
     */
    function testFuzz_MintPrice(uint256 price) public {
        // 假设条件:排除正确的铸造价格 0.08 ether
        vm.assume(price != 0.08 ether);

        // 模拟用户交易
        vm.prank(user);
        // 给用户分配对应金额的余额
        vm.deal(user, price);

        // 期望交易回滚,并显示特定错误消息
        vm.expectRevert("Insufficient payment");
        // 尝试用错误的价格铸造 NFT
        nft.mintTo{value: price}(user);
    }
}


6.2 运行测试

# 基本测试
forge test

# 详细输出
forge test -vvv

# 运行特定测试
forge test --match-test testMintSuccess

6.3 模糊测试示例

添加一个容易出错的函数到NFT合约:

function addNumbers(uint256 x) public pure returns (uint256) {
    return x + 1000;
}

测试函数:

function testFuzz_AddNumbers(uint256 x) public {
    uint256 result = nft.addNumbers(x);
    assertGt(result, x); // 这会在溢出时失败
}

6.4 调试功能

使用forge debug调试失败的交易:

forge debug --target-contract NFT --sig "mintTo(address)" 0x123...

6.5 Gas报告

forge test --gas-report

7. 合约部署与验证

7.1 部署到测试网

首先设置环境变量:

export PRIVATE_KEY=0x...
export GOERLI_RPC_URL=https://eth-goerli.alchemyapi.io/v2/YOUR-API-KEY

部署合约:

forge create --rpc-url $GOERLI_RPC_URL \
  --private-key $PRIVATE_KEY \
  --constructor-args "My NFT" "MNFT" "https://api.example.com/metadata/" \
  src/NFT.sol:NFT

7.2 验证合约代码

forge verify-contract \
  --chain-id 5 \
  --num-of-optimizations 200 \
  --watch \
  --constructor-args $(cast abi-encode "constructor(string,string,string)" "My NFT" "MNFT" "https://api.example.com/metadata/") \
  --etherscan-api-key $ETHERSCAN_API_KEY \
  0x... \
  src/NFT.sol:NFT

7.3 使用脚本部署

创建script/Deploy.s.sol:

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

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

contract DeployScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployerPrivateKey);

        NFT nft = new NFT(
            "Scripted NFT",
            "SNFT",
            "https://api.example.com/metadata/"
        );

        console.log("NFT deployed to:", address(nft));

        vm.stopBroadcast();
    }
}

运行脚本:

forge script script/Deploy.s.sol:DeployScript --rpc-url $GOERLI_RPC_URL --broadcast

8. 链上交互

8.1 使用Cast进行链上交互

查询合约状态

# 查询总供应量
cast call $CONTRACT_ADDRESS "totalSupply()" --rpc-url $GOERLI_RPC_URL

# 查询NFT所有者
cast call $CONTRACT_ADDRESS "ownerOf(uint256)" 0 --rpc-url $GOERLI_RPC_URL

发送交易

# Mint NFT
cast send $CONTRACT_ADDRESS \
  "mintTo(address)" $USER_ADDRESS \
  --value 0.08ether \
  --private-key $PRIVATE_KEY \
  --rpc-url $GOERLI_RPC_URL

8.2 Fork主网进行测试

在测试中fork主网:

function testForkMainnet() public {
    // Fork主网到特定区块
    vm.createSelectFork("mainnet", 17_000_000);

    // 现在可以与主网合约交互
    IERC20 usdc = IERC20(0xA0b86a33E6441e5f3e8b8e5e5e5e5e5e5e5e5e5);
    uint256 balance = usdc.balanceOf(0x...);

    console.log("USDC Balance:", balance);
}

8.3 使用Anvil本地节点

启动本地节点:

anvil --fork-url $MAINNET_RPC_URL --fork-block-number 17000000

9. 安全研究应用

9.1 事件复现示例

以下是一个安全事件复现的示例结构:

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

import "forge-std/Test.sol";

interface IVulnerableContract {
    function vulnerableFunction(bytes32 root) external returns (bool);
}

contract ExploitTest is Test {
    IVulnerableContract target;

    function setUp() public {
        // Fork到攻击发生前的区块
        vm.createSelectFork("mainnet", 16_800_000);
        target = IVulnerableContract(0x...);
    }

    function testExploit() public {
        console.log("Balance before:", address(this).balance);

        // 复现攻击逻辑
        bool result = target.vulnerableFunction(bytes32(0));
        assertTrue(result);

        console.log("Balance after:", address(this).balance);
    }
}

9.2 使用Cheat Codes

常用的cheat codes:

// 改变调用者
vm.prank(address);

// 改变时间
vm.warp(timestamp);

// 改变区块高度
vm.roll(blockNumber);

// 设置余额
vm.deal(address, amount);

// 修改存储
vm.store(address, slot, value);

// 期望回滚
vm.expectRevert("Error message");

9.3 高级调试技巧

使用vm.getCode部署旧合约:

function deployOldContract() internal {
    bytes memory bytecode = vm.getCode("OldContract.sol:OldContract");
    bytes memory initcode = abi.encodePacked(bytecode, abi.encode(arg1, arg2));

    address deployed;
    assembly {
        deployed := create2(0, add(initcode, 0x20), mload(initcode), salt)
    }

    // 或者直接设置到特定地址
    vm.etch(targetAddress, bytecode);
}

10. 常见问题与最佳实践

10.1 常见问题

编译错误

  • 检查Solidity版本兼容性
  • 确认依赖库正确安装
  • 查看remappings.txt配置

测试失败

  • 使用-vvv参数获取详细日志
  • 检查gas限制设置
  • 验证测试逻辑和断言

部署问题

  • 确认私钥和RPC URL正确
  • 检查账户余额是否足够
  • 验证构造函数参数

10.2 最佳实践

项目结构

project/
├── src/
│   ├── interfaces/
│   ├── libraries/
│   └── contracts/
├── test/
│   ├── unit/
│   ├── integration/
│   └── fuzz/
├── script/
└── lib/

测试策略

  1. 单元测试:测试单个函数
  2. 集成测试:测试合约交互
  3. 模糊测试:发现边界情况
  4. 不变量测试:验证系统属性

安全考虑

  • 使用环境变量存储敏感信息
  • 在测试网充分测试后再部署主网
  • 定期更新依赖库
  • 进行代码审计

10.3 性能优化

编译优化

[profile.default]
optimizer = true
optimizer_runs = 200

测试优化

[fuzz]
runs = 10000
max_test_rejects = 65536

10.4 与其他工具集成

与Hardhat集成

Foundry可以与现有Hardhat项目集成,保持兼容性。

CI/CD集成

# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1
      - name: Run tests
        run: forge test

结语

Foundry是一个功能强大的智能合约开发工具,特别适合:

  • 需要高性能测试的项目
  • 安全研究和漏洞分析
  • 复杂的DeFi协议开发
  • 需要精确控制测试环境的场景

通过本教程,你应该已经掌握了Foundry的核心功能和使用方法。建议在实际项目中多加练习,逐步熟悉各种高级功能。

推荐资源

🛠️

关于作者

,现居湖北武汉,拥有 10 多年经验的全栈开发工程师,分享 AI 编程、Web3 与独立开发实践。

X 原文