如果你最近在关注 Solidity 开发,那大概率绕不开 Foundry 这个名字。作为一套用 Rust 写成的以太坊智能合约开发工具链,Foundry 从一问世就打出了“快”这张牌,而且它不是单纯地快一点,从我自己的实际体验来看,跑同一套测试,之前用 JavaScript 系工具可能要等四五秒才能出结果,Foundry 基本上秒出,体感差别非常明显。这篇文章我不会照着官方文档给你念一遍,而是按我自己从零上手到真正在几个项目里把 Foundry 用起来的完整路径,把那些文档里没写透、或者容易踩坑的地方都翻出来说说。
我默认看这篇文章的朋友对 Solidity 本身有一定基础,知道构造函数、事件、修饰器这些概念,但未必熟悉 Foundry 的工作方式。如果你完全没写过合约,建议先找一份 Solidity 入门教程把语言基础过一遍,再回来读会更顺畅。另外,Foundry 这个名字本身就带一点“铸造厂”的意思,用来比喻写代码到链上部署这条流水线,还挺贴切。
1. Foundry 是什么:一套用 Rust 打造的智能合约开发工具箱
1.1 先理解 Foundry 在解决什么问题
智能合约开发最大的痛点之一,是“改代码—跑测试—看结果”这个循环太慢。传统工具链大多基于 Node.js,启动要加载一堆依赖,编译要经过 JavaScript 层转译,测试跑起来还经常因为异步调用或者类型转换出一些莫名其妙的怪问题。Foundry 的设计目标很直接:要用更快的编译、更快地测试、更直接的链上交互,把开发者的核心迭代周期压缩下来。
它并不是一个新的智能合约语言,也不是新的底层虚拟机,而是一套围绕 Solidity 开发的“外围工具集”。你可以把合约本身理解成一辆需要试跑的赛车,Foundry 就是那个试车场、维修区和仪器仪表一体的工具箱。代码跑在哪个链上不重要,重要的是你能不能高效地验证它、调试它,然后把它送上线。
我刚切到 Foundry 的时候,最直观的感受就是“等待变少了”。过去写一个小的权限控制函数,写完测试跑一下,盯着终端转圈半分钟是家常便饭;现在同样的逻辑,基本刚回车结果就出来了,这些节省下来的碎片时间积累起来非常可观。
1.2 四个核心组件:forge、cast、anvil、chisel
Foundry 不是一个单一的程序,它是一个工具集。日常接触最多的四个成员分别是 forge、cast、anvil、chisel,每个负责一条完整链路里的不同环节:
- forge 是核心中的核心,处理项目初始化、依赖管理、编译、测试和部署这些主流程。
- cast 是一个命令行交互工具,专门用来对链发起调用,比如查余额、发送交易、解析合约数据。
- anvil 是本地开发节点,相当于在你电脑上跑一条迷你区块链,专供开发和测试使用。
- chisel 是一个 Solidity 交互式 REPL 环境,你可以像在 Python 终端里敲命令一样,快速验证某一段 Solidity 表达式的输出结果。
如果你之前用过 Hardhat,可以把 forge 理解为 Hardhat 本体,cast 对应 ethers.js 里那些链上交互脚本,anvil 对应 hardhat node,chisel 则像是一个专门为 Solidity 准备的 console。它不是要取代 Solidity,而是让整个外围工具链都围绕 Solidity 重新构建一次。
1.3 和 Hardhat/Truffle 这类工具的主要差异
我用了很长时间 Hardhat,所以切换时格外关注差异点。最大的区别在于测试语言。Hardhat 默认用 JavaScript 或 TypeScript 配合 ethers.js / waffle 写测试,而 Foundry 允许你直接用 Solidity 写测试。不要小看这个差异,它直接把心智负担砍掉了一半。
另一个差异是底层实现。Foundry 是 Rust 写的原生程序,启动速度快,增量编译做得好。Hardhat 跑在 Node.js 环境里,性能和启动速度天然吃亏。尤其是在合约多、依赖多的大型项目里,全量编译一次,两者耗时可能差一个数量级。
还有一个值得注意的点:Foundry 是三明治结构的“集成式”思路,编译、测试、部署、链上交互用一套工具链贯穿;而 Hardhat 生态更多是插拔式,需要装配各种插件来实现不同功能。集成的好处是开箱即用,不用为了一个功能去选择和调试插件;坏处是插件生态没有 JavaScript 世界那么丰富,某些特殊需求可能需要等官方更新或自己写代码绕过。
2. 我为什么从 Hardhat 换到 Foundry
2.1 速度快背后的工程原因
很多人问 Foundry 为什么快,其实答案没有多玄妙:底层用 Rust 实现,编译出来的原生程序启动开销小,执行效率高。但在工程层面,它还把一件事做得很细,就是增量编译。
传统工具在编译时会全量扫描所有源文件,哪怕你只改了一个常量,也要把整个项目的合约重编一遍。Foundry 则会对文件做依赖分析,只重新编译受影响的部分。在一个中型项目里,改了顶层库文件后,Hardhat 可能耗时十几秒,Foundry 经常两秒内就完成了。
如果你想验证这个结论,可以在项目里跑一下forge build,第一次会慢一些,因为要全量编译;第二次再跑,你会发现输出里明确写着“compiling 0 files”,基本是秒过。这种体验在长时间开发工作里帮助极其明显。
2.2 用 Solidity 写测试让心智负担骤减
传统模式下,合约开发和测试要维护两套语言。Solidity 里的 uint256 和 JavaScript 里的 BigNumber 不是一回事,测试脚本经常要在两种类型系统之间来回转换。遇到嵌套调用或者事件断言,还得小心翼翼地处理异步和上下文,出错时排查起来相当割裂。
Foundry 把测试写成一个 Solidity 合约,断言直接使用 assertEq、assertGt 这类内置方法,数据格式、类型系统、错误处理都和合约内部保持一致。我在实际项目里最大的感受是:写测试不再像“用另一种语言复述业务逻辑”,而是直接“用合约语言检查合约行为”,整个推理过程顺畅很多。
更重要的是,这降低了一些隐性 bug 的发生概率。类型转换错误、精度误差、异步时序问题,在 Solidity 测试里基本不会出现。测试代码和合约代码放一起维护,变量名、函数签名的对应关系也一目了然。
2.3 内置 fuzz 测试和 gas 报告的加成
Foundry 内置的模糊测试是另一个让我下定决定换工具的理由。只要测试函数名以 testFuzz 开头,并且接收参数,Foundry 就会自动为你生成大量随机输入来执行这个函数,用来挖那些边界条件下才会暴露的 bug。
这个能力以前在 JavaScript 测试生态里要么需要额外安装 fuzz 库,要么需要自己写随机数据生成器。Foundry 把它变成了内置能力,一行配置都不用改。配合--gas-report参数,你还能在跑测试的同时拿到每个函数的 gas 消耗估算,这对合约优化极有价值。
另外,因为 fuzz 测试本质是不断用随机数去跑你的函数,它对纯函数或可以形式化验证的逻辑特别有效。你只需要把不变量写清楚,剩下的交给工具暴力探测就行。
3. 从零搭建一个 Foundry 项目
3.1 安装 foundryup 并处理环境变量
安装 Foundry 的基本路径是通过 foundryup 这个安装器。在 macOS 或 Linux 环境下,打开终端执行:
curl -L https://foundry.paradigm.xyz | bash执行完成后,安装器会提示你把一些环境变量写入你的 shell 配置文件,比如~/.zshrc或~/.bashrc。这一步千万不要跳过,我见过有人不 source 就直接跑下面命令,结果一直提示命令找不到。
然后执行:
foundryup这个命令会自动拉取并安装最新版本的工具链,把forge、cast、anvil、chisel四个命令都装好。之后用forge --version确认版本信息。
这里有一个容易踩的坑:如果你以前装过旧版 Foundry,或者系统里有其他工具占用了forge这个名字,foundryup 之后可能还是旧版。这时候可以检查一下which forge,确认是否指向正确的安装路径。升级工具链本身最简单的方式就是重新跑一遍foundryup,它会自动覆盖旧版本。
3.2 初始化项目、认识目录结构
新建项目可以直接用forge init。我在一个空目录里执行:
mkdir my-foundry-demo && cd my-foundry-demo forge init .初始化完成后,目录下会自动生成几个重要的文件和文件夹。如果你之前用过 Hardhat,可以把src理解为contracts,test还是test,script对应scripts,lib则类似node_modules。
src/:存放合约源码。test/:存放测试合约,文件名惯例是.t.sol后缀。script/:存放部署或辅助脚本,后缀常为.s.sol。lib/:存放依赖库,默认自带forge-std。foundry.toml:项目核心配置文件。remappings.txt:依赖库别名映射。
我个人的习惯是,在src里按功能再分子目录,比如mocks、interfaces、libraries,这样项目一大了也不会乱。另外建议从一开始就用 Git 管理项目,因为lib目录里有很多依赖文件,如果不加忽略规则,提交一次会带进大量第三方代码。
3.3 读懂 foundry.toml 和 remappings.txt
foundry.toml默认内容不多,但几个关键字段值得搞清楚:
[profile.default] src = "src" out = "out" libs = ["lib"]src和out分别代表源码目录和编译输出目录,libs是依赖库的根目录。编译器版本也可以在这里锁定,比如加一行solc = "0.8.23",避免不同机器上因为编译器版本不同而产生不确定的编译结果。
还有一个字段optimizer_runs,控制优化器重复优化的次数,会影响部署后的合约 gas 消耗。部署到正式环境之前,我会把它调到 200 或更高。
remappings.txt则解决 import 路径问题。比如库里某个包的实际目录是lib/forge-std/src,你想在代码里直接写import "forge-std/Test.sol",就需要在remappings.txt里写清楚别名关系。默认初始化项目时已经配好了forge-std,但如果你手动添加了新依赖,这块常常是编译报错的源头。
4. 实操:写合约、写测试、跑测试
4.1 准备一个带状态和权限控制的示例合约
为了不走形式,我写一个简单但有代表性的合约,里面有状态变量、事件、状态变更和权限控制。把这些基础语法都覆盖到,用来演示测试非常合适。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Counter { uint256 public count; address public owner; event Incremented(uint256 newCount); constructor() { owner = msg.sender; } function increment() external { count += 1; emit Incremented(count); } function reset() external { if (msg.sender != owner) { revert("caller is not owner"); } count = 0; } }把它保存到src/Counter.sol。这个合约里有一个全局状态count,一个只有 owner 能调用的reset,以及一个会触发事件的increment。这三样东西基本覆盖了日常合约最常见的几种测试场景:查询状态、改变状态、验证事件、验证权限抛出异常。
4.2 编写基于 forge-std 的测试合约
在test目录下创建Counter.t.sol,写入测试代码:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Test, console2} from "forge-std/Test.sol"; import {Counter} from "../src/Counter.sol"; contract CounterTest is Test { Counter counter; function setUp() public { counter = new Counter(); } function test_InitialCountIsZero() public view { assertEq(counter.count(), 0); } function test_IncrementUpdatesCount() public { counter.increment(); assertEq(counter.count(), 1); counter.increment(); assertEq(counter.count(), 2); } function test_IncrementEmitsEvent() public { vm.expectEmit(true, true, true, true); emit Counter.Incremented(1); counter.increment(); } function test_ResetRevertsWhenNotOwner() public { vm.prank(address(0x123)); vm.expectRevert(bytes("caller is not owner")); counter.reset(); } }这里有两个概念很关键:setUp和vm。
setUp类似其他测试框架里的beforeEach,每条测试用例执行前都会先执行一次。在上面的例子里,每个测试函数都会先创建一份全新的Counter实例。
vm是 forge-std 集成的作弊码工具,用它可以在测试中模拟各种 EVM 行为。vm.prank表示下一次调用的msg.sender被模拟成指定地址;vm.expectRevert断言接下来这一次调用必须抛出指定异常,否则测试失败;vm.expectEmit用来断言事件是否按预期发出。
跑测试:
forge test输出大致是:
Running 4 tests for test/Counter.t.sol:CounterTest [PASS] test_InitialCountIsZero() [PASS] test_IncrementEmitsEvent() [PASS] test_IncrementUpdatesCount() [PASS] test_ResetRevertsWhenNotOwner() Suite result: ok. 4 tests passed; 0 failed; 0 skipped; finished in 12.62ms第一次看到这个毫秒级的输出时,我是有点惊讶的。以前同样规模的测试在旧工具里至少要等好几秒,这里真的就是一瞬间。
4.3 模糊测试和测试发散
Foundry 的 fuzz 测试用起来非常方便。只要测试函数名以testFuzz开头,并且带参数,Foundry 就会自动用随机输入反复执行这个函数。
比如给 Counter 合约加一个加法函数:
function add(uint256 a, uint256 b) external pure returns (uint256) { return a + b; }然后在测试合约里写:
function testFuzz_AddNeverOverflows(uint256 a, uint256 b) public { vm.assume(a < type(uint128).max); vm.assume(b < type(uint128).max); uint256 result = counter.add(a, b); assertEq(result, a + b); }vm.assume用来过滤输入,避免某些极端输入导致测试失去意义。这里的逻辑是:当a和b都小于2^128时,两个数相加一定不会超过uint256的最大值,所以期望add永远返回正常加法结果。Foundry 会默认跑大量随机样例,极大提高边界 bug 被发现的概率。
模糊测试对纯函数尤其有效,但在有状态函数上也能用,只是需要提前设计好不变量。比如你可以写“无论怎么调用 increment,count 永远等于调用次数”这类全局不变式,让 fuzz 去验证。
4.4 常用 vm cheatcode 速查与注意点
vm是 forge-std 里最强大的工具,下面列几个我高频使用的作弊码:
vm.prank(address):把下一次调用的msg.sender临时改成指定地址。vm.startPrank(address):从当前开始,之后所有调用的msg.sender都变成指定地址,直到vm.stopPrank()。vm.expectRevert(bytes):断言下一次调用必须 revert,且错误信息匹配。vm.expectEmit(...):断言下一个事件必须匹配当前 emit 的事件。vm.warp(uint256):修改当前区块时间戳。vm.roll(uint256):修改当前区块高度。vm.deal(address, uint256):给指定地址塞余额。vm.store(address, bytes32, bytes32):直接修改合约指定存储位置的原始数据,用来模拟一些极端状态。
一个非常容易踩的坑是顺序问题。vm.expectRevert必须写在触发 revert 的调用之前,它只能“预期下一次调用”。很多人先调用合约再写 expectRevert,结果测试报错说“test not expected to revert”,但实际问题只是顺序反了。
另外,vm.prank只影响下一次调用,如果你需要连续在某个地址身份下执行多次操作,用startPrank更合适。我自己曾在这种“只影响下一次”和“持续影响”的行为之间吃过亏,排查半天才发现是 prank 作用域的问题。
5. 部署与链上交互的完整流程
5.1 用 anvil 在本地启动一条开发链
部署之前,先在本地把链起来。一条命令就行:
anvil默认情况下,anvil 会在http://localhost:8545提供一个节点,同时会打印一组带测试 ETH 的账户和私钥。这些账户在本地链上有大量余额,专门用于测试。它还支持--port指定端口,--chain-id指定链 ID,比如:
anvil --port 8546 --chain-id 31338本地链的好处是交易即时确认,基本没有出块等待时间。智能合约开发和普通后端开发不一样,后端本地跑个 service 就能调,智能合约如果不部署到一条链上,很多逻辑是完全无法验证的。anvil 就是为这个环节量身定制的开发节点。
5.2 用 forge create 完成合约部署
环境就绪后,部署合约最直接的方式是forge create:
forge create src/Counter.sol:Counter \ --rpc-url http://localhost:8545 \ --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80其中--rpc-url指向链的 RPC 地址,--private-key是你的部署账户私钥。执行成功后,终端会打印合约地址、部署交易哈希、gas 费用等关键信息。
如果构造函数有参数,用--constructor-args传参。比如某个合约构造函数需要uint256初始值:
forge create src/Counter.sol:Counter \ --constructor-args 42 \ --rpc-url http://localhost:8545 \ --private-key <你的私钥>这里我想特别强调一点:本地开发用的默认私钥可以随便暴露,但真实测试网或主网环境下,不要把私钥直接写在命令行里。后面第 7 节我会专门讲安全相关的注意事项。
5.3 用 cast 读链上信息、发交易
合约部署完之后,日常交互就可以交给cast了。读函数用cast call,比如查一下刚部署合约的count:
cast call 0x合约地址 "count()" --rpc-url http://localhost:8545返回的是一串十六进制数据,可以用cast --to-dec把它转换成可读的十进制数字,或者直接利用cast call配合--abi等参数一步到位。写函数用cast send:
cast send 0x合约地址 "increment()" \ --rpc-url http://localhost:8545 \ --private-key <你的私钥>cast send会实际广播交易并等待上链,返回交易哈希、区块高度和 gas 消耗。遇到需要调用带参数函数的情况,可以写成:
cast send 0x合约地址 "transfer(address,uint256)" 0x目标地址 100 \ --rpc-url http://localhost:8545 \ --private-key <你的私钥>cast还有个很实用的功能cast abi-encode:当你需要准备一段 calldata 时,它可以直接帮你编码好,省去手工拼字节的麻烦。
5.4 更规范的部署:forge script 脚本系统
项目一旦复杂起来,部署就不只是一条命令的事了,可能需要先部署依赖合约,再初始化参数,再部署业务合约。forge script这个脚本系统就是为复杂部署流程准备的。
在script/目录下建一个部署脚本:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {Script} from "forge-std/Script.sol"; import {Counter} from "../src/Counter.sol"; contract DeployScript is Script { function run() external { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); vm.startBroadcast(deployerPrivateKey); Counter counter = new Counter(); vm.stopBroadcast(); console2.log("Counter deployed at:", address(counter)); } }然后执行:
forge script script/Deploy.s.sol:DeployScript \ --rpc-url http://localhost:8545 \ --broadcastvm.startBroadcast之后的所有交易会被真实广播到链上。脚本的好处是它可以按顺序调用多个合约,并且在脚本里直接写日志输出部署结果。以后要在测试网重放一遍部署,只需要把 RPC 地址切换成测试网地址即可。
6. 项目落地:在 CI 和团队协作中用上 Foundry
6.1 在 GitHub Actions 里跑测试
个人开发时“顺手”的工具,团队协作里一样能跑得很顺。把 Foundry 测试集成进 CI,最直接的方式是在 workflow 文件里安装 Foundry 后执行forge test:
name: CI on: push jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: foundry-rs/foundry-toolchain@v1 with: version: nightly - name: Install dependencies run: forge install - name: Run tests run: forge test --gas-report这样每次 push 都会自动触发全量测试。我在一个项目里把这条链路搭起来之后,明显感觉到团队合并代码时的安全感提高了,因为在合并之前就能通过 CI 跑完所有测试,不需要信任某个人本地环境“应该是好的”。
6.2 保持依赖一致性
Foundry 的依赖管理用forge install和forge update完成,它会直接拉 GitHub 仓库到lib目录。问题是,如果团队里有人拉了一个新版本依赖,而另一个人的lib没有同步,本地测试结果就可能对不上。
解决办法是:把lib提交到 Git 仓库,或者至少提交一份锁文件。临时项目可以不加,但长期项目我强烈建议把lib纳入版本管理。因为 Solidity 依赖本身变动频繁,一些第三方库的小版本更新可能在你看不到的地方破坏 API 兼容性。
另外,上文提到的remappings.txt也要随项目一起提交,避免不同机器上依赖路径不一致导致编译失败。
6.3 团队切换与渐进式采用
很多团队现在还是 Hardhat 和 Foundry 共存。这种情况并不冲突,因为 Foundry 是独立的工具链,你完全可以在一个项目里同时保留hardhat.config.ts和foundry.toml。一些依赖 Hardhat 插件的功能继续走 Hardhat,普通测试和部署用 Foundry。
不过我个人建议,如果你决定在新项目里全面采用 Foundry,就尽量在项目初期把测试全部写成 Solidity 测试。因为一旦老测试越来越多,新旧工具之间的心智切换成本会重新出现,反而失去了工具链统一带来的效率。
7. 高频问题与避坑记录
7.1 测试里的地址冲突与 vm 使用顺序
Foundry 会给测试合约和作弊码预留一些内部地址范围。如果你在测试里用vm.prank模拟了一个地址,而这个地址恰好落在预留范围内,就可能出现诡异的权限或余额问题。我的做法是:固定使用一个有辨识度的地址,比如address(0xABCD),并且把测试中常用的模拟地址统一维护在一个常量文件里,避免不同测试之间因为地址重合产生干扰。
vm.expectRevert和vm.prank的顺序也要注意。vm.prank只影响下一次调用,如果你希望某个地址身份连续执行多个操作,必须使用vm.startPrank,并以vm.stopPrank收尾。顺序错了,轻则测试失败,重则测出了一个错误的通过结果,反而更危险。
7.2 依赖和 remappings 导致编译失败
import "forge-std/Test.sol"这类路径之所以能生效,是因为remappings.txt里做了别名映射。如果你手动安装了一个新库,却忘了更新 remappings,编译时就会汇报找不到文件。
解决方式有两种。一种是用forge remappings自动生成当前依赖的映射关系,然后写进remappings.txt;另一种是直接让 Foundry 自动检测,在foundry.toml里设置:
auto_detect_remappings = true但我个人的习惯是手动维护 remappings,尤其是在依赖较复杂的项目里。自动检测有时候会选一个与你预期不同的路径,导致“为什么他那边能编译,我这边不行”的尴尬局面。
7.3 私钥安全与使用环境变量
forge create和cast send都支持--private-key参数,但这条命令会完整出现在 shell 历史记录里,存在被窃取的风险。本地开发可以用,但在测试网或主网环境,更安全的做法是使用环境变量:
export RPC_URL=https://your.rpc.endpoint export PRIVATE_KEY=your-private-key forge script script/Deploy.s.sol:DeployScript \ --rpc-url $RPC_URL \ --private-key $PRIVATE_KEY \ --broadcast也要警惕自己的代码仓库掉 token。很多人习惯把.env文件加入.gitignore,但如果某个文件里硬编码了私钥并提交上去,后果可能很严重。安全习惯越早养成越好,这不是危言耸听。
7.4 版本升级带来的兼容性变化
Foundry 迭代非常快,新版本经常调整函数签名或默认行为。遇到版本升级后突然冒出来一批编译错误,第一件事不是改代码,而是检查当前工具链版本:
forge --version然后跑一次foundryup,把工具链升到最新。很多兼容性问题在最新版本中已经修复。
另外,在查看教程或开源项目代码时,也要注意对方用的 Foundry 版本。半年前的项目和现在可能在某些 API 上已经有差异,直接照抄代码不一定能跑。如果你发现一份看起来没问题的代码在本地编译不通过,先看看它是基于哪个版本写的。
8. 我个人的一些实操体会
如果让我给 Foundry 一个整体的定位,我想说:它最大的价值不在于某一个单独功能,而在于把开发、测试、部署、链上交互这件事的“摩擦”降到了最低。以前写合约测试是一件有点痛苦的事,需要额外维护一套 JavaScript 测试代码;现在测试就是 Solidity,写起来自然,跑起来快,反馈也足够清晰。
我在几个项目实践里还发现一个额外好处:因为 fuzz 测试和 gas 报告都是内置的,代码评审时可以把“你跑过 fuzzified 测试吗”和“你清理过 gas 热点吗”当成默认要求,这比靠人肉 review 找问题要可靠得多。
最后再分享一个小习惯:现在我用 Foundry 建新项目时,第一步永远是先写一个“最简可运行”的合约和测试,确保forge test能秒过,再开始加复杂逻辑。这样后面所有改动都可以有一个干净的参照系,任何一次失败的测试都能快速定位到新加的代码上。这个小技巧帮我省下的调试时间,远比我当初花几分钟搭建基础项目所付出的成本要高得多。