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优化 #部署
今日目标
- 掌握 Foundry fork 测试 — 在本地模拟真实主网环境
- 学会 fuzz 测试 — 自动化发现边界 bug
- 使用 forge snapshot 做 Gas 分析
- 编写 forge script 实现自动化部署和验证
- 理解何时用 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) |
|---|---|---|
| 继承 | Test | Script |
| 运行 | forge test | forge 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 上确认验证成功
常见误区
- Fork 测试不固定区块号: 每次运行结果可能不同,用
--fork-block-number固定 - Fuzz 中过度使用
vm.assume: 大量 reject 导致测试效率低,用bound()替代 - 误以为 fuzz 256 轮就够了: 生产环境(尤其审计)至少 10000 轮
- 部署脚本忘记
vm.startBroadcast: 所有链上操作必须在 broadcast 块内 - Fork 测试没有缓存: 首次运行慢是正常的,Foundry 会缓存 RPC 响应
面试关联
| 面试题 | 本课关联 |
|---|---|
| "如何测试你的合约与 Uniswap 的集成?" | Fork 测试 + 主网状态 |
| "什么是 Fuzz 测试?为什么重要?" | 自动化属性验证,发现人类想不到的边界 |
| "如何监控合约的 Gas 消耗变化?" | forge snapshot + CI 对比 |
| "如何安全地部署合约?" | forge script 模拟 -> 广播 -> 验证 |
| "ERC20 最重要的不变量是什么?" | totalSupply = sum(balances),transfer 守恒 |
| "fork 测试和单元测试如何配合?" | 单元测试覆盖逻辑,fork 测试覆盖集成 |