返回 SC 笔记
SC Day 29

Solidity/Foundry fork 测试/fuzz 测试/gas 报告/脚本部署

### 一、Fork 测试

2026-04-29
第二阶段:框架实战 (Day 25-30)
Foundryfork测试fuzz测试gas优化部署

日期: 2026-04-29 方向: Solidity / Foundry 阶段: 第二阶段:框架实战 (Day 25-30) 标签: #Foundry #fork测试 #fuzz测试 #gas优化 #部署


今日目标

  1. 掌握 Foundry fork 测试 — 在本地模拟真实主网环境
  2. 学会 fuzz 测试 — 自动化发现边界 bug
  3. 使用 forge snapshot 做 Gas 分析
  4. 编写 forge script 实现自动化部署和验证
  5. 理解何时用 fork 测试 vs 单元测试

核心概念

一、Fork 测试

Fork 测试让你在本地"复制"主网状态,然后在上面运行测试。这意味着你可以:

  • 与真实的 Uniswap、Aave 等合约交互
  • 测试你的合约与已部署协议的集成
  • 模拟真实的 DeFi 场景

1.1 工作原理

Fork 测试流程:
1. Foundry 连接到 RPC 节点 (Alchemy/Infura)
2. 按需从主网拉取状态 (storage, code)
3. 在本地 EVM 中模拟执行
4. 可以用 cheatcodes 修改状态 (时间、余额等)
5. 所有修改只在本地,不影响主网

1.2 配置方式

# foundry.toml
[rpc_endpoints]
mainnet = "${ETH_RPC_URL}"
arbitrum = "https://arb1.arbitrum.io/rpc"
sepolia = "https://rpc.sepolia.org"
# 命令行指定
forge test --fork-url https://eth.llamarpc.com

# 使用配置的端点名
forge test --fork-url mainnet

# 指定区块号 (可重复的测试)
forge test --fork-url mainnet --fork-block-number 18000000

1.3 Fork 测试 vs 单元测试

维度单元测试Fork 测试
速度极快 (<1s)较慢 (首次 5-30s, 缓存后 1-5s)
环境空白 EVM主网状态副本
依赖无外部依赖需要 RPC 端点
确定性100% 确定固定区块号才确定
用途测试合约内部逻辑测试与外部协议的集成
成本免费RPC 调用(可能有限制)

最佳实践

  • 大部分测试用单元测试(快速、确定性)
  • 集成测试和关键路径用 fork 测试
  • Fork 测试固定区块号,保证可重复

二、Fuzz 测试

Fuzz 测试(模糊测试)是自动化安全测试的核心工具。Foundry 内置 fuzzer,不需要额外工具。

2.1 工作原理

Fuzz 测试流程:
1. 测试函数声明参数 (如 function test_xxx(uint256 amount))
2. Foundry 自动生成随机输入 (默认 256 轮)
3. 每轮用不同的随机值调用测试函数
4. 如果任何一轮失败,报告失败的输入
5. 自动收缩 (shrinking) — 找到最小的失败输入

2.2 基础 Fuzz 测试

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

import "forge-std/Test.sol";

contract FuzzBasicTest is Test {
    // 参数会被 fuzzer 自动填充随机值
    function test_FuzzAddition(uint128 a, uint128 b) public pure {
        // uint128 防止溢出 (a + b <= type(uint256).max)
        uint256 result = uint256(a) + uint256(b);
        assertGe(result, uint256(a));
        assertGe(result, uint256(b));
    }

    // 布尔条件作为 fuzz 判断
    function test_FuzzCannotExceedMax(uint256 amount) public pure {
        // vm.assume — 过滤不满足条件的输入
        // 注意: 过度使用 assume 会导致测试效率低下
        vm.assume(amount > 0);
        vm.assume(amount <= 1000000 * 1e18);

        uint256 fee = (amount * 30) / 10000; // 0.3%
        assertLe(fee, amount);
    }

    // bound — 比 assume 更好的方式限制范围
    function test_FuzzWithBound(uint256 rawAmount) public pure {
        // bound(value, min, max) — 将值映射到 [min, max] 范围
        uint256 amount = bound(rawAmount, 1, 1000000 * 1e18);

        uint256 fee = (amount * 30) / 10000;
        assertLe(fee, amount);
        assertGt(amount, 0);
    }
}

2.3 配置 Fuzz 参数

# foundry.toml
[fuzz]
runs = 256            # 默认 fuzz 轮数
max_test_rejects = 65536  # 最大 reject 次数 (assume 失败)
seed = "0x1234"       # 固定随机种子 (可重复)
dictionary_weight = 40  # 从现有值中采样的权重

[profile.ci.fuzz]
runs = 10000          # CI 中跑更多轮

三、Gas 分析

3.1 Gas 报告

# 运行测试并生成 Gas 报告
forge test --gas-report

# 输出类似:
# | src/MomoToken.sol:MomoToken contract |                 |       |        |       |         |
# |---------------------------------------|-----------------|-------|--------|-------|---------|
# | Deployment Cost                       | Deployment Size |       |        |       |         |
# | 1234567                               | 6789            |       |        |       |         |
# | Function Name                         | min             | avg   | median | max   | # calls |
# | transfer                              | 34521           | 45678 | 51234  | 51234 | 10      |
# | approve                               | 24312           | 24312 | 24312  | 24312 | 5       |

3.2 Gas 快照

# 生成快照文件 .gas-snapshot
forge snapshot

# 与之前的快照对比
forge snapshot --diff

# 检查 Gas 变化是否超过阈值 (CI 用)
forge snapshot --check --tolerance 5  # 允许 5% 波动

3.3 在测试中断言 Gas

function test_TransferGasCost() public {
    // 先给 alice 一些 token (初始化存储槽)
    vm.prank(owner);
    token.transfer(alice, 1000);

    // 测量第二次 transfer 的 Gas (非首次,不含存储初始化)
    uint256 gasBefore = gasleft();
    vm.prank(owner);
    token.transfer(alice, 100);
    uint256 gasUsed = gasBefore - gasleft();

    // 断言 Gas 在预期范围内
    assertLt(gasUsed, 40000, "Transfer too expensive");
    console.log("Transfer gas:", gasUsed);
}

四、脚本部署 (forge script)

forge script 是 Foundry 的部署和交互工具,比 Hardhat 的 deploy 脚本更强大。

4.1 脚本 vs 测试

维度Test (.t.sol)Script (.s.sol)
继承TestScript
运行forge testforge script
用途验证行为执行链上操作
状态模拟环境真实/模拟链
cheatcodes全部可用部分可用

代码实战

Fork 测试 — 读取 Uniswap V3 价格

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

import "forge-std/Test.sol";

// Uniswap V3 Pool 接口 (只需要用到的函数)
interface IUniswapV3Pool {
    function token0() external view returns (address);
    function token1() external view returns (address);
    function fee() external view returns (uint24);
    function slot0() external view returns (
        uint160 sqrtPriceX96,
        int24 tick,
        uint16 observationIndex,
        uint16 observationCardinality,
        uint16 observationCardinalityNext,
        uint8 feeProtocol,
        bool unlocked
    );
    function liquidity() external view returns (uint128);
}

interface IERC20Metadata {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function decimals() external view returns (uint8);
    function balanceOf(address account) external view returns (uint256);
    function totalSupply() external view returns (uint256);
}

/**
 * @title UniswapForkTest
 * @notice 在 fork 的主网上读取 Uniswap V3 的真实数据
 */
contract UniswapForkTest is Test {
    // 主网地址
    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
    address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7;

    // Uniswap V3 WETH/USDC 0.3% 池
    address constant WETH_USDC_POOL = 0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8;

    // Uniswap V3 WETH/USDC 0.05% 池
    address constant WETH_USDC_005_POOL = 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640;

    IUniswapV3Pool pool;
    IUniswapV3Pool pool005;

    function setUp() public {
        // 需要用 --fork-url 运行此测试
        // 或在 foundry.toml 中配置
        pool = IUniswapV3Pool(WETH_USDC_POOL);
        pool005 = IUniswapV3Pool(WETH_USDC_005_POOL);
    }

    function test_ReadPoolState() public view {
        // 读取池子的基础信息
        address token0 = pool.token0();
        address token1 = pool.token1();
        uint24 fee = pool.fee();

        console.log("=== WETH/USDC 0.3% Pool ===");
        console.log("Token0:", token0);
        console.log("Token1:", token1);
        console.log("Fee:", fee);

        // 验证 token 地址正确
        // 注意: token0 < token1 (地址排序)
        assertTrue(token0 == USDC || token0 == WETH);
        assertEq(fee, 3000); // 0.3% = 3000
    }

    function test_ReadCurrentPrice() public view {
        (uint160 sqrtPriceX96, int24 tick, , , , , ) = pool.slot0();

        console.log("sqrtPriceX96:", sqrtPriceX96);
        console.log("Current tick:", uint256(uint24(tick)));

        // sqrtPriceX96 是 price 的平方根 * 2^96
        // price = (sqrtPriceX96 / 2^96)^2
        // 这个价格是 token1/token0 的比值

        // 大致计算 ETH 价格
        // 因为 USDC 有 6 位小数, WETH 有 18 位小数
        // 实际计算需要考虑 decimals 差异
        uint256 price = uint256(sqrtPriceX96);
        assertGt(price, 0, "Price should be > 0");
    }

    function test_ReadLiquidity() public view {
        uint128 liquidity = pool.liquidity();
        console.log("Active liquidity:", liquidity);
        assertGt(liquidity, 0, "Liquidity should be > 0");
    }

    function test_ComparePoolLiquidity() public view {
        uint128 liq_030 = pool.liquidity();
        uint128 liq_005 = pool005.liquidity();

        console.log("0.3% pool liquidity:", liq_030);
        console.log("0.05% pool liquidity:", liq_005);

        // 0.05% 池通常流动性更大 (主要交易发生在此)
        // 但这取决于具体时间点
    }

    function test_ReadTokenInfo() public view {
        IERC20Metadata weth = IERC20Metadata(WETH);
        IERC20Metadata usdc = IERC20Metadata(USDC);

        console.log("WETH name:", weth.name());
        console.log("WETH symbol:", weth.symbol());
        console.log("WETH decimals:", weth.decimals());
        console.log("WETH totalSupply:", weth.totalSupply());

        console.log("USDC name:", usdc.name());
        console.log("USDC symbol:", usdc.symbol());
        console.log("USDC decimals:", usdc.decimals());

        assertEq(weth.decimals(), 18);
        assertEq(usdc.decimals(), 6);
    }

    function test_WhaleBalance() public view {
        // 查看一个已知的巨鲸地址的 USDC 余额
        address binance = 0x28C6c06298d514Db089934071355E5743bf21d60;
        IERC20Metadata usdc = IERC20Metadata(USDC);

        uint256 balance = usdc.balanceOf(binance);
        console.log("Binance USDC balance:", balance / 1e6, "USDC");

        // Binance 通常持有大量 USDC
        // 注意: 具体数值取决于 fork 的区块号
    }

    // 使用 vm.deal 和 vm.prank 模拟操作
    function test_SimulateEthTransfer() public {
        address alice = makeAddr("alice");
        address bob = makeAddr("bob");

        vm.deal(alice, 100 ether);

        vm.prank(alice);
        payable(bob).transfer(1 ether);

        assertEq(bob.balance, 1 ether);
        assertEq(alice.balance, 99 ether);
    }
}

Fuzz 测试 — ERC20 属性验证

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

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

contract MomoTokenFuzzTest is Test {
    MomoToken public token;
    address public owner;

    uint256 constant INITIAL_SUPPLY = 100_000_000 * 1e18;

    function setUp() public {
        owner = makeAddr("owner");
        vm.prank(owner);
        token = new MomoToken(owner, INITIAL_SUPPLY);
    }

    // ============ Fuzz 测试 ============

    /// @notice transfer 不应改变 totalSupply
    function test_Fuzz_TransferPreservesTotalSupply(address to, uint256 amount) public {
        // 过滤无效输入
        vm.assume(to != address(0));
        vm.assume(to != owner);
        amount = bound(amount, 0, INITIAL_SUPPLY);

        uint256 supplyBefore = token.totalSupply();

        vm.prank(owner);
        token.transfer(to, amount);

        assertEq(token.totalSupply(), supplyBefore, "totalSupply changed after transfer");
    }

    /// @notice transfer 后双方余额之和不变
    function test_Fuzz_TransferBalanceInvariant(uint256 amount) public {
        address alice = makeAddr("alice");
        amount = bound(amount, 0, INITIAL_SUPPLY);

        uint256 ownerBefore = token.balanceOf(owner);
        uint256 aliceBefore = token.balanceOf(alice);

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

        uint256 ownerAfter = token.balanceOf(owner);
        uint256 aliceAfter = token.balanceOf(alice);

        assertEq(
            ownerBefore + aliceBefore,
            ownerAfter + aliceAfter,
            "Balance sum changed"
        );
    }

    /// @notice approve 后 allowance 等于设定值
    function test_Fuzz_ApproveAllowance(address spender, uint256 amount) public {
        vm.assume(spender != address(0));

        vm.prank(owner);
        token.approve(spender, amount);

        assertEq(token.allowance(owner, spender), amount);
    }

    /// @notice transferFrom 不应超过 allowance
    function test_Fuzz_TransferFromRespectAllowance(uint256 approveAmount, uint256 transferAmount) public {
        address alice = makeAddr("alice");
        address bob = makeAddr("bob");

        approveAmount = bound(approveAmount, 0, INITIAL_SUPPLY);
        transferAmount = bound(transferAmount, 0, INITIAL_SUPPLY);

        vm.prank(owner);
        token.approve(alice, approveAmount);

        if (transferAmount > approveAmount) {
            vm.prank(alice);
            vm.expectRevert();
            token.transferFrom(owner, bob, transferAmount);
        } else {
            vm.prank(alice);
            token.transferFrom(owner, bob, transferAmount);
            assertEq(token.balanceOf(bob), transferAmount);
        }
    }

    /// @notice mint 不应超过 MAX_SUPPLY
    function test_Fuzz_MintCannotExceedMaxSupply(uint256 amount) public {
        uint256 maxMint = token.MAX_SUPPLY() - token.totalSupply();
        address alice = makeAddr("alice");

        if (amount > maxMint) {
            vm.prank(owner);
            vm.expectRevert("Exceeds max supply");
            token.mint(alice, amount);
        } else {
            vm.prank(owner);
            token.mint(alice, amount);
            assertLe(token.totalSupply(), token.MAX_SUPPLY());
        }
    }

    /// @notice burn 后 totalSupply 应减少
    function test_Fuzz_BurnReducesSupply(uint256 amount) public {
        amount = bound(amount, 0, INITIAL_SUPPLY);

        uint256 supplyBefore = token.totalSupply();

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

        assertEq(token.totalSupply(), supplyBefore - amount);
    }
}

部署脚本

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

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

/**
 * @title DeployMomoToken
 * @notice 部署 MomoToken 到指定网络
 *
 * 使用方法:
 * # 本地测试 (anvil)
 * forge script script/DeployMomoToken.s.sol --rpc-url http://localhost:8545 --broadcast
 *
 * # Sepolia 测试网
 * forge script script/DeployMomoToken.s.sol --rpc-url $SEPOLIA_RPC --broadcast --verify
 *
 * # 模拟运行 (不广播)
 * forge script script/DeployMomoToken.s.sol --rpc-url $SEPOLIA_RPC
 */
contract DeployMomoToken is Script {
    // 部署参数
    uint256 constant INITIAL_SUPPLY = 100_000_000 * 1e18;

    function run() external {
        // 获取部署者
        uint256 deployerKey = vm.envUint("PRIVATE_KEY");
        address deployer = vm.addr(deployerKey);

        console.log("========================================");
        console.log("Deploying MomoToken");
        console.log("========================================");
        console.log("Deployer:", deployer);
        console.log("Balance:", deployer.balance);
        console.log("Initial Supply:", INITIAL_SUPPLY / 1e18, "MOMO");
        console.log("Chain ID:", block.chainid);
        console.log("========================================");

        // 开始广播 (真正发送交易)
        vm.startBroadcast(deployerKey);

        MomoToken token = new MomoToken(deployer, INITIAL_SUPPLY);

        vm.stopBroadcast();

        // 验证部署
        console.log("");
        console.log("Deployment successful!");
        console.log("MomoToken:", address(token));
        console.log("Name:", token.name());
        console.log("Symbol:", token.symbol());
        console.log("Total Supply:", token.totalSupply() / 1e18, "MOMO");
        console.log("Owner:", token.owner());
        console.log("========================================");
    }
}

运行命令汇总

# Fork 测试 (需要 RPC URL)
forge test --match-contract UniswapForkTest --fork-url https://eth.llamarpc.com -vvv

# Fuzz 测试
forge test --match-contract MomoTokenFuzzTest -vv

# Fuzz 测试 (更多轮)
forge test --match-contract MomoTokenFuzzTest --fuzz-runs 10000

# Gas 报告
forge test --gas-report

# Gas 快照
forge snapshot
forge snapshot --diff

# 部署到 anvil
anvil &  # 先启动本地节点
forge script script/DeployMomoToken.s.sol \
  --rpc-url http://localhost:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  --broadcast

# 部署到 Sepolia (带合约验证)
forge script script/DeployMomoToken.s.sol \
  --rpc-url $SEPOLIA_RPC \
  --broadcast \
  --verify \
  --etherscan-api-key $ETHERSCAN_KEY \
  -vvvv

关键要点总结

测试策略金字塔

        /\
       /  \     Fork Integration Tests (慢, 高保真)
      /----\    - 与真实协议交互
     /      \   - 关键资金路径
    /--------\  Fuzz Tests (中速, 自动化)
   /          \ - 属性验证
  /            \- 边界发现
 /--------------\ Unit Tests (快, 基础覆盖)
/                \ - 函数行为
/------------------\ - 权限控制

Fuzz 测试核心属性

属性示例说明
总量守恒transfer 不改变 totalSupply最重要的不变量
余额一致发送者减少 = 接收者增加资金安全
权限有效allowance 限制 transferFrom授权安全
上限保护mint 不超过 MAX_SUPPLY供应量控制
不可回滚成功的操作确实修改了状态状态一致性

部署流程

1. forge script ... (模拟运行, 不加 --broadcast)
2. 检查输出, 确认部署参数正确
3. forge script ... --broadcast (真正部署)
4. forge verify-contract ... (验证合约源码)
5. 在 Etherscan 上确认验证成功

常见误区

  1. Fork 测试不固定区块号: 每次运行结果可能不同,用 --fork-block-number 固定
  2. Fuzz 中过度使用 vm.assume: 大量 reject 导致测试效率低,用 bound() 替代
  3. 误以为 fuzz 256 轮就够了: 生产环境(尤其审计)至少 10000 轮
  4. 部署脚本忘记 vm.startBroadcast: 所有链上操作必须在 broadcast 块内
  5. Fork 测试没有缓存: 首次运行慢是正常的,Foundry 会缓存 RPC 响应

面试关联

面试题本课关联
"如何测试你的合约与 Uniswap 的集成?"Fork 测试 + 主网状态
"什么是 Fuzz 测试?为什么重要?"自动化属性验证,发现人类想不到的边界
"如何监控合约的 Gas 消耗变化?"forge snapshot + CI 对比
"如何安全地部署合约?"forge script 模拟 -> 广播 -> 验证
"ERC20 最重要的不变量是什么?"totalSupply = sum(balances),transfer 守恒
"fork 测试和单元测试如何配合?"单元测试覆盖逻辑,fork 测试覆盖集成

参考资源