ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

WTF-Solidity 工具系列 07:Forge Std 标准库实战指南 —— 从安装、核心组件到基于 Foundry 的 Solidity 测试开发

2026/9/15 18:03:54 拓冰建站 浏览量
WTF-Solidity 工具系列 07:Forge Std 标准库实战指南 —— 从安装、核心组件到基于 Foundry 的 Solidity 测试开发 WTF-Solidity 工具系列 07Forge Std 标准库实战指南 —— 从安装、核心组件到基于 Foundry 的 Solidity 测试开发【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity导读本文是 WTF-Solidity 极简入门教程工具系列中关于 Foundry 与 Forge Std 的实战指南。全文以仓库内 Languages/pt-br/Topics/Tools/TOOL07_Foundry 教程及随附的 hello_wtf 工程为骨架系统讲解 Forge Std 的安装、stdError/stdStorage/stdCheats/Std Assertions/console.log等核心组件的原理与用法并延伸到 Foundry 的forge、cast、anvil三大工具链。读完本文你将能用纯 Solidity 完成测试编写、合约部署、链上数据查询与本地节点调试摆脱对 JavaScript 测试栈的依赖。一、Forge Std 是什么Forge StdForge Standard Library是一组面向forge与foundry的实用合约集合。它的核心价值在于通过封装forge提供的 cheatcodes作弊码让测试代码更简洁、可读性更强从而改善 cheatcode 的使用体验。在本文对应的仓库中完整源码位于 Languages/pt-br/Topics/Tools/TOOL07_Foundry/hello_wtf/lib/forge-std/src其中 Test.sol 是测试入口它通过import ds-test/test.sol继承了 DSTest形成abstract contract Test is DSTest, Script的继承结构将断言、脚本与 cheatcode 能力统一暴露给测试合约。二、安装 Forge Std在任意 Foundry 项目中安装 Forge Std 只需一条命令forge install foundry-rs/forge-std该命令会把 forge-std 以 git submodule 的形式拉取到lib/forge-std目录。这一通过 git submodule 管理依赖的方式正是 Foundry 与 Hardhatnpm 管理在依赖管理上的最大差异之一。在 hello_wtf 工程中forge-std 正是以lib/forge-std目录存在其自身也以 submodule 方式引入了ds-test位于 Languages/pt-br/Topics/Tools/TOOL07_Foundry/hello_wtf/lib/forge-std/lib/ds-test。你也可以用 npm 安装其他依赖如 OpenZeppelin然后在 foundry.toml 中把node_modules加入libs[profile.default] src src out out libs [lib,node_modules] solc 0.8.34在hello_wtf工程中测试合约 Test.t.sol 正是通过import {IERC20} from openzeppelin/contracts/token/ERC20/IERC20.sol来使用 npm 安装的 OpenZeppelin 依赖。三、核心组件详解Forge Std 的核心组件分布在 src 目录中下文逐一展开。3.1 stdError内建错误码助手stdError是为错误与 revert 场景设计的辅助合约尤其适合配合 cheatcodeexpectRevert使用因为它集中提供了 Solidity 编译器内建的全部错误编码。在 Test.sol 源码中可以看到它的完整定义全部基于Panic(uint256)的 ABI 编码library stdError { bytes public constant assertionError abi.encodeWithSignature(Panic(uint256), 0x01); bytes public constant arithmeticError abi.encodeWithSignature(Panic(uint256), 0x11); bytes public constant divisionError abi.encodeWithSignature(Panic(uint256), 0x12); bytes public constant enumConversionError abi.encodeWithSignature(Panic(uint256), 0x21); bytes public constant encodeStorageError abi.encodeWithSignature(Panic(uint256), 0x22); bytes public constant popError abi.encodeWithSignature(Panic(uint256), 0x31); bytes public constant indexOOBError abi.encodeWithSignature(Panic(uint256), 0x32); bytes public constant memOverflowError abi.encodeWithSignature(Panic(uint256), 0x41); bytes public constant zeroVarError abi.encodeWithSignature(Panic(uint256), 0x51); }用法示例验证算术错误import forge-std/Test.sol; contract TestContract is Test { ErrorsTest test; function setUp() public { test new ErrorsTest(); } function testExpectArithmetic() public { vm.expectRevert(stdError.arithmeticError); test.arithmeticError(10); } } contract ErrorsTest { function arithmeticError(uint256 a) public { uint256 a a - 100; // 下溢触发 Panic(0x11) } }3.2 stdStorage存储槽定位与写入stdStorage是基于 cheatcoderecord与accesses的一层抽象它可以在无需了解存储布局的情况下自动查找并写入与某个状态变量对应的存储槽。其核心数据结构与查找逻辑定义在 Test.solStdStorage结构体记录了目标地址_target、函数选择器_sig、查找深度_depth和键数组_keysfind()通过record()记录一次函数调用过程中的所有SLOAD/SSTORE若某个槽只被访问过一次则直接返回否则按depth迭代校验。从源码注释可以确认其 slot 定位规则普通变量bytes32(uint256(uint))mappingkeccak256(abi.encode(key, uint(slot)))嵌套 mappingkeccak256(abi.encode(key1, keccak256(abi.encode(key0, uint(slot)))))mapping 中的 struct基础 mapping 槽位加字段深度偏移。主要 API方法作用.target(address)设置目标合约地址.sig(func())或.sig(func.selector)通过函数选择器指定要定位的变量.with_key(...)指定 mapping 的 key.depth(n)指定 struct 字段深度0 表示第 0 个字段.find()查找并返回槽位.checked_write(value)安全写入槽位打包存储的变量会报错除非该槽未初始化一个必须注意的限制是可以找到打包packed存储变量的槽位但不能安全地写入它——除非该变量尚未初始化值为bytes32(0)否则执行会抛出错误。完整示例查找、写入、mapping、struct、隐藏槽位import forge-std/Test.sol; contract TestContract is Test { using stdStorage for StdStorage; Storage test; function setUp() public { test new Storage(); } // 查找普通变量槽位 function testFindExists() public { uint256 slot stdstore.target(address(test)).sig(exists()).find(); assertEq(slot, 0); } // 写入普通变量槽位 function testWriteExists() public { stdstore.target(address(test)).sig(exists()).checked_write(100); assertEq(test.exists(), 100); } // 支持基于 assembly 的任意存储位置 function testFindHidden() public { uint256 slot stdstore.target(address(test)).sig(test.hidden.selector).find(); assertEq(slot, uint256(keccak256(my.random.var))); } // mapping 需要传入 key function testFindMapping() public { uint256 slot stdstore .target(address(test)) .sig(test.map_addr.selector) .with_key(address(this)) .find(); assertEq(uint(vm.load(address(test), bytes32(slot))), 1); } // struct 使用 depth 指定字段 function testFindStruct() public { uint256 slot_for_a_field stdstore .target(address(test)) .sig(test.basicStruct.selector) .depth(0) .find(); uint256 slot_for_b_field stdstore .target(address(test)) .sig(test.basicStruct.selector) .depth(1) .find(); assertEq(uint(vm.load(address(test), bytes32(slot_for_a_field))), 1); assertEq(uint(vm.load(address(test), bytes32(slot_for_b_field))), 2); } } contract Storage { struct UnpackedStruct { uint256 a; // 深度 0 uint256 b; // 深度 1 } constructor() { map_addr[msg.sender] 1; } uint256 public exists 1; mapping(address uint256) public map_addr; mapping(address UnpackedStruct) public map_struct; mapping(address mapping(address uint256)) public deep_map; mapping(address mapping(address UnpackedStruct)) public deep_map_struct; UnpackedStruct public basicStruct UnpackedStruct({a: 1, b: 2}); function hidden() public view returns (bytes32 t) { bytes32 slot keccak256(my.random.var); assembly { t : sload(slot) } } }3.3 stdCheats面向开发者的 cheatcode 包装stdCheats主要包装了prank系列作弊码。由于安全原因prank并不会给目标地址预置 ETH因此 Forge Std 提供了更便捷的组合函数hoax(address)给地址充 ETH 并执行单次prank适用于预期有余额的地址注意会覆盖已有余额hoax(address, amount)指定 ETH 数量的重载startHoax(address)/startHoax(address, amount)充 ETH 并执行startPrank直到调用vm.stopPrank()才结束。使用规则地址已有 ETH 时只用prank只想改余额用deal两者都要用hoax。contract StdCheatsTest is Test { Bar test; function setUp() public { test new Bar(); } function testHoax() public { hoax(address(1337)); test.bar{value: 100}(address(1337)); hoax(address(1337), 1); test.bar{value: 1}(address(1337)); } function testStartHoax() public { startHoax(address(1337)); test.bar{value: 100}(address(1337)); test.bar{value: 100}(address(1337)); vm.stopPrank(); test.bar(address(this)); } } contract Bar { function bar(address expectedSender) public payable { require(msg.sender expectedSender, !prank); } }3.4 Std Assertions扩展 DSTest 断言Std Assertions在 DSTest 自带的断言函数之上做了扩展是 Test.sol 直接带给测试合约的断言能力。配合assertEq、assertTrue等函数可以写出如 Counter.t.sol 中assertEq(counter.number(), 1)这样简洁的测试断言。3.5 console.logSolidity 调试日志console.log的使用方式与 Hardhat 一致推荐使用console2.sol因为它能在 Forge 的 traces 中展示解码后的日志。导入方式有两种// 间接导入通过 Test.sol 引入 import forge-std/Test.sol; // 或直接导入 import forge-std/console2.sol; console2.log(someValue);如果需要与 Hardhat 保持兼容则使用标准的console.sol。注意由于console.sol存在一个 bug使用uint256或int256类型的日志在 Forge traces 中无法被正确解码因此优先使用console2.sol。在 Counter.t.sol 与 Test.t.sol 中都能看到大量console2.log与emit log的实际用法。四、在 Foundry 工程中使用 Forge Std4.1 工程目录结构forge init初始化的工程以 hello_wtf 为例默认结构如下. ├── foundry.toml # Foundry 包配置文件 ├── lib # 依赖库目录git submodule │ └── forge-std # Forge Std 基础依赖 ├── script # 部署脚本目录 │ └── Counter.s.sol # 示例部署脚本 ├── src # 合约业务逻辑源码 │ └── Counter.sol # 示例合约 └── test # 测试用例目录 ├── Counter.t.sol └── Test.t.sol其中 foundry.toml 的libs [lib,node_modules]表明该工程同时使用 git submodule 与 npm 两种依赖来源。4.2 业务合约src以 src/Counter.sol 为例它持有一个公共uint256 number状态变量提供setNumber与increment两个函数是测试与部署脚本共同作用的最小业务对象。4.3 部署脚本scriptFoundry 的部署脚本本身就是一个 Solidity 合约。看 script/Counter.s.solimport forge-std/Script.sol; import ../src/Counter.sol; contract CounterScript is Script { function setUp() public {} function run() public { vm.startBroadcast(); // 开始记录合约创建与调用 Counter c new Counter(); vm.stopBroadcast(); // 结束记录并广播交易 } }脚本继承自forge-std/Script.sol通过run()函数执行配合vm.startBroadcast()/vm.stopBroadcast()控制交易广播范围。执行命令forge script script/Counter.s.sol:CounterScript4.4 测试合约testtest/Counter.t.sol 展示了最经典的测试模式import forge-std/Test.sol; import ../src/Counter.sol; contract CounterTest is Test { Counter public counter; function setUp() public { counter new Counter(); counter.setNumber(0); } // 普通测试递增后断言 function testIncrement() public { counter.increment(); assertEq(counter.number(), 1); } // fuzz 测试forge 会传入不同 uint256 x function testSetNumber(uint256 x) public { counter.setNumber(x); assertEq(counter.number(), x); } }测试合约继承TestsetUp()在每个测试前执行初始化test开头的函数会被自动识别为测试用例。带参数的测试函数如testSetNumber(uint256 x)会被当作 fuzz 测试由 forge 自动生成多组入参执行。4.5 构建与运行测试# 构建 forge build # 运行测试 forge test # 高冗余日志显示失败测试的堆栈跟踪 forge test -vvv # 更高冗余显示所有测试的堆栈 forge test -vvvv # 实时监听模式 forge build -w forge test -vvv -w注意console2.log的日志输出需要-vv及以上的冗余级别才会显示。forge test的输出中fuzz 测试会显示执行次数、平均 gasμ与中位数 gas~例如[PASS] testSetNumber(uint256) (execuções: 256, μ: 27609, ~: 28387)五、Cheatcodes 实战状态操纵在 Test.t.sol 中可以找到一组典型的 cheatcode 应用改变block.timestampvm.warp(1000)改变msg.sendervm.prank(address)单次生效与vm.startPrank(address)/vm.stopPrank()多次生效可模拟管理员账户修改账户余额vm.deal(alice, 1 ether)也可通过deal(address(dai), alice, 1 ether)修改大多数 ERC20 代币余额见testCheatCode事件断言vm.expectEmit(true, true, true, true)配合emit声明与调用被测试函数见testEmit读取环境变量vm.envAddress(DAI)、vm.envString(ETH_RPC_URL)等见setUp与testCodeFork主网分叉vm.createFork(rpc)vm.selectFork(mainnet)直接在本地分叉主网状态进行测试见testCodeFork。六、与 Forge Std 配套的 Foundry 工具链6.1 cast链上交互Cast 是与 RPC 节点交互的命令行工具可以实现 Etherscan 的常用功能# 查询区块高度与区块信息 cast block-number --rpc-url$RPC cast block 15769241 --rpc-url$RPC # 查询交易与回执 cast tx HASH --rpc-url$RPC cast receipt HASH --rpc-url$RPC # 查询交易日志 cast receipt HASH logs --rpc-url$RPC # 解码函数选择器从 Ethereum Signature Database 查询 cast 4byte 0x38ed1739 # 由函数签名计算选择器Keccak-256 前 4 字节 cast sig swapExactTokensForTokens(uint256,uint256,address[],address,uint256) # 解码交易 calldata cast pretty-calldata CALLDATA # 本地重放链上交易观察内部调用与 gas cast run TXHASH --rpc-url$RPC账户管理# 生成新账户 cast wallet new # 生成带密码的 keystore 账户 cast wallet new ~/Downloads # 签名与验签 cast wallet sign MESSAGE -i cast wallet verify --address ADDRESS MESSAGE SIGNATURE合约交互# 获取并下载合约源码用于验证/分析 cast etherscan-source CONTRACT_ADDRESS --etherscan-api-keyKEY cast etherscan-source $WETH -d ~/Downloads # 调用只读方法可加返回格式解码 cast call $WETH balanceOf(address)(uint256) ADDRESS --rpc-url$RPC # 由 ABI 生成 Solidity 接口 cast interface ./weth.abi编码转换工具cast --to-hex、cast --to-dec、cast --to-unit、cast --to-wei、cast --to-rlp、cast --from-rlp。环境变量小技巧设置ETH_RPC_URL后无需每次加--rpc-url设置ETHERSCAN_API_KEY后无需每次加--etherscan-api-key命令追加--json可得到格式化 JSON 输出。6.2 anvil本地节点Anvil 是本地以太坊节点类似 Hardhat 与 Ganache且支持主网分叉anvil anvil --accountsNUM --balanceNUM anvil --mnemonicMNEMONIC anvil --fork-url$RPC --fork-block-numberBLOCK其 RPC 方法命名对应关系anvil_*→hardhat_*常用方法如anvil_impersonateAccount模拟账户、anvil_setStorageAt直接改写存储。6.3 forge高级技巧# gas 报告与快照 forge test --gas-report forge snapshot forge snapshot --diff # 与快照对比检查 gas 是否下降 # 查看合约 ABI、bytecode 等元信息 forge inspect CONTRATO CAMPO # 交互式调试器 forge script script/Counter.s.sol --debug forge run --debug七、依赖安装与配置补充# 用 forge 安装依赖git submodule 方式 forge install user/repotag # 用 npm 安装依赖 npm init -y npm i openzeppelin/contracts安装后若使用 npm 依赖需在foundry.toml的libs中声明node_modules见本文第二节。此外forge-std 自身的 foundry.toml 中配置了fs_permissions [{ access read-write, path ./}]用于允许测试脚本在工程目录内读写文件系统。八、小结Forge Std 是 Foundry 测试体验的关键一环stdError让expectRevert断言内建错误更精确stdStorage让存储槽定位与写入不再依赖手动布局计算stdCheats让prank与余额设置一步到位Std Assertions与console2.log则分别强化了断言与调试。配合forge构建/测试/部署、cast链上查询与交互、anvil本地节点与分叉三大工具开发者可以用纯 Solidity 完成从编写、测试到部署、链上调试的完整闭环省去学习 JavaScript 测试栈的时间成本把更多精力投入到 Solidity 实战练习本身。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考