1. 为什么明明有测试网,我还是要把 Anvil 当成主力本地链
做智能合约开发前期,很多人会直接怼着测试网写代码:部署上去,调用,出问题,看区块浏览器。这个流程不是不行,只是太慢了。测试网一个区块要等好几秒,交易池还不稳定,更麻烦的是状态一旦被你自己玩乱了,没有人帮你“回到五分钟前”。如果只是验一个函数逻辑,把时间浪费在等待和重置上,非常不划算。
Anvil 的出现基本解决了这个尴尬。它是 Foundry 工具链里带的一个本地节点实现,启动之后会给你一个完全在自己机器上运行的以太坊模拟环境。它的核心卖点不是“模拟”,而是“可控”。你可以指定链 ID、端口、账户数量、每次出块的间隔、fork 哪条链的第几个区块,甚至可以在运行中用 RPC 方法直接改某个账户的余额、往某个 slot 写值、替换一段合约字节码。这种控制力在测试网几乎拿不到,但在 Anvil 上就是家常便饭。
我平时最喜欢拿它做的几件事:
- 启动一个本地 RPC,让 MetaMask 之类的钱包直接连到
http://127.0.0.1:8545,然后像操作真实链一样操作测试合约。 - 在本地分叉一条公链的指定高度,把线上协议的状态搬到本地,用假交易验证清算、迁移、升级这类高风险操作。
- 把 Anvil 嵌进 CI 脚本,每次跑测试时启动全新实例,测完直接杀掉,环境永远干净。
有人可能会说 Hardhat 网络也能做这些事情。确实,Hardhat 网络也有类似能力,但它和 Hardhat 生态绑定得更深,很多控制逻辑是依赖插件或者测试框架驱动的。Anvil 则是一个独立的 RPC 服务,你用 curl、cast、web3.py、ethers.js、钱包、跑测试脚本,都可以连它。它是一个更纯粹的“节点替代品”,而且启动速度快到可以忽略不计。
如果你刚开始接触 Foundry,我也建议直接把 Anvil 当成你理解“链”的沙盘。本地链不会因为你的错误操作造成任何实际损失,却能让所有以太坊交互动作变得清晰可见。接下来的内容,我会从第一次启动开始讲,逐步把账户管理、分叉、时间控制、自动化和故障排查这些进阶用法都过一遍。
2. 第一次启动 Anvil:先读懂默认输出,再决定要改什么
2.1 前提:装好 Foundry,确认 anvil 命令存在
Anvil 随 Foundry 套件一起分发,所以第一步不是单独装 Anvil,而是先装 Foundry。安装成功后,你会得到forge、cast、anvil、chisel几个命令。如果已经装过,定期跑一下foundryup把它更新到最新版本,毕竟 Anvil 的功能迭代很快,老版本可能不支持某些新 RPC 参数。
安装完成后,先确认版本:
anvil --version能正常输出版本号,就说明环境没问题。接下来直接运行:
anvil没有额外参数,它就会在本地启动一个默认配置的链。看到类似下面的输出,就说明已经跑起来了:
Available Accounts ================== (0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH) (1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH) Private Keys ================== (0) 0xac0974bec39a17e36ba4a6b4d238ff9bacbe5... (1) 0x59c6995e998f97a5a0044966f0945389dc9e86...同时还会显示监听地址、链 ID、区块时间和 gas 价格等信息。这些默认值你不用刻意背,但一定要知道它们各自是什么意思,因为你后续百分之百会改其中几个。
2.2 默认参数背后的含义:为什么是 8545 和 31337
默认监听地址是127.0.0.1:8545。8545 是 JSON-RPC 常用的默认端口,很多以太坊客户端都默认暴露在这里,所以钱包、浏览器插件、脚本库基本都能直接兼容。127.0.0.1则意味着只允许本机访问,不会暴露给局域网,这个安全设置是合理的。
默认链 ID 是31337。31337 是编程圈里的一个梗,同时也被约定成很多本地开发链的默认值,主要为了防止你误把本地测试交易发到主网。实际使用中我建议不要完全依赖这个默认值,尤其是在做多链多实例的自动化项目时,一定要用--chain-id显式指定,不然两个本地节点容易混淆。
默认会创建 10 个账户,每个账户有 10000 ETH。助记词是固定的,就是test test ... junk那一串,从这条助记词派生出来的私钥在开发社区里几乎是公开的。所以请时刻记住一个原则:这些私钥只能用于本地测试和测试网,绝不要给它们充真实资产。一旦有人拿到私钥,他就能在测试网上花你的测试币,虽然不值得心疼,但账户被污染后排查问题会浪费时间。
2.3 用 cast 发第一笔交易,验证链真的在跑
光有输出还不够,最好实际发一笔交易确认 RPC 服务没问题。打开另一个终端,先看当前区块高度:
cast block-number --rpc-url http://127.0.0.1:8545然后准备一个接收地址,把 1 个 ETH 发过去:
cast send \ --rpc-url http://127.0.0.1:8545 \ --private-key 0xac0974bec39a17e36ba4a6b4d238ff9bacbe5... \ --value 1ether \ 0x70997970C51812dc3A010C7d01b50e0d17dc79C8Anvil 默认采用自动挖矿模式,也就是每收到一笔交易就立刻打包出块。所以你发送后几乎立刻能看到交易 receipt,不需要像真实链一样等待好几秒。
这一步跑通后,你就拥有了一个最基础的本地链。但要让它真正成为好用的开发环境,还需要掌握下面这些启动参数。
3. 几个直接影响开发体验的启动参数,建议一次配明白
3.1 账户数量、余额和助记词:按场景定制身份池
默认的 10 个账户在多数场景下够用,但如果你要模拟“多方博弈”的复杂系统,比如一个合约里同时有管理员、用户、清算人、预言机角色,那么 10 个账户可能不够。启动时可以加:
anvil --accounts 20 --balance 500--accounts控制派生多少个测试账户,--balance控制每个账户有多少 ETH。这两个参数组合起来,基本能模拟一个多角色丰富的开发环境。
如果你不想用默认助记词,也可以用自己指定的助记词:
anvil --mnemonic "my test mnemonic words here"这样做的好处是:你的脚本里固定某个助记词,后续即使重启 Anvil,同一序号地址也保持一致。对于某些需要把地址写死在配置文件里的集成测试,这个能力非常关键。
还有一个容易忽略的参数是--derivation-path。默认使用以太坊标准的m/44'/60'/0'/0/0路径派生账户。如果你要测试其他生态工具或者自定义钱包逻辑,可能需要调整派生路径。不熟悉的用户建议先保持默认,等真的遇到地址对不上时再动。
3.2 区块时间:自动挖矿、间隔出块和手动挖矿
默认情况下 Anvil 走的是“每笔交易立即出块”的自动挖矿模式。优点是真快,适合单步调试;缺点是无法模拟真实链上那种“交易排队等待打包”的体验。
如果想让链更接近真实网络,可以改成固定间隔出块:
anvil --block-time 12这个参数会让 Anvil 每 12 秒产出一个区块。交易进入交易池后,要等到下一个出块瞬间才会被打包。对时间敏感型合约的测试很有用,比如拍卖、锁仓、投票窗口这类场景。
如果你希望完全手动控制出块时机,还可以先启动一个自动挖矿的节点,然后在运行中切换:
cast rpc anvil_setAutomine false --rpc-url http://127.0.0.1:8545之后交易不会被自动打包,你想让链前进时再手动挖一个块:
cast rpc anvil_mine --rpc-url http://127.0.0.1:8545这个“手动出块”模式对理解以太坊里 txpool 和区块打包的关系非常有帮助,也能让合约测试中的时间推进更符合直觉。
3.3 链 ID、端口与日志级别:多开实例不打架
当你同时跑两个 Anvil 实例时,默认端口就会冲突,因为大家都在监听 8545。最直接的解决办法是:
anvil --port 8545 --chain-id 31337 anvil --port 8546 --chain-id 31338端口用于区分 RPC 服务,链 ID 用于区分网络身份。如果你在同一个端口上换了链 ID,钱包可能还是会保留旧缓存,导致显示错乱,所以链接钱包时最好还是用不同端口。
日志方面,默认情况下 Anvil 会把每次 RPC 请求、每个区块的创建信息都打印出来。平时调试没问题,但到了 CI 或者自动化脚本里,这些日志会淹没真正的错误信息。这时候可以加--silent:
anvil --silent日志安静了,但你依然可以通过 RPC 请求来获取全部信息。这个参数我基本每次在服务器上跑 Anvil 都会加。
3.4 区块 gas 上限、EVM 版本与 base fee
真实网络里的 gas 上限不是无限大,Anvil 默认给的区块 gas limit 也有限制。如果你部署一个特别大的合约,或者批量执行复杂操作,可能遇到gas limit exceeded。这时可以调大:
anvil --gas-limit 1000000000--hardfork用来指定 EVM 版本。默认情况下 Anvil 会选择当前 Foundry 内置的最新 EVM 版本。但你如果 fork 一条老链上的旧合约,或要验证某些历史分叉下的行为差异,建议显式指定:
anvil --hardfork shanghai不要小看这个参数。不同 hardfork 对交易格式、gas 计费、操作码行为都有影响。同样一段字节码,在旧版本上可能能跑,在新版本上会 revert。测试时必须明确自己到底想模拟哪个时代的链。
还有--block-base-fee-per-gas,用于设定每个区块的基础费。默认值一般不会影响普通测试,但如果你做 gas 费用相关的合约逻辑,最好手动设置成固定值,否则每次出块费用变化,容易干扰断言。
4. 主网分叉模式:把线上状态搬到本地再大胆折腾
4.1 分叉到底解决了什么问题
很多时候我们测试的不是一个孤立合约,而是一个需要和既有协议交互的复杂场景,比如想调用一个已经部署在公链上的稳定币合约,或者验证某个 DeFi 协议的清算流程。重新在本地部署全套依赖,既不现实也浪费时间。Anvil 的分叉模式可以直接把某条链在某一个区块高度上的状态“借”过来,让你在本地自由交易。
这个能力在真实开发中特别有用。想象你要写一个清算机器人的脚本,但无法直接在公链上跑测试,因为你不想真的花钱,也不想影响线上状态。分叉之后,你在 Anvil 上的所有交易都只修改本地状态,公链完全不受影响。
4.2 基础分叉参数与推荐组合
最简单的分叉启动方式:
anvil --fork-url https://your-rpc-provider.example/rpc不加其他参数时,Anvil 会使用该 RPC 提供的最新区块作为分叉点。问题在于“最新”是动态的,不同时间启动会得到不同状态,测试可复现性就很差。所以配上区块高度:
anvil \ --fork-url https://your-rpc-provider.example/rpc \ --fork-block-number 18000000固定高度之后,只要 RPC 数据没变,每次启动拿到的链上状态就是一致的。这能让你的集成测试变得稳定可重复。
还有一个组合建议:分叉后显式指定本地链 ID。
anvil \ --fork-url https://your-rpc-provider.example/rpc \ --fork-block-number 18000000 \ --chain-id 31337有些场景下,Anvil 检测到分叉网络后会默认沿用那个网络的链 ID,这会让钱包误判当前网络。手动设置成本地 ID,能避免很多“我明明连的是本地,钱包却提示网络错误”的问题。
4.3 分叉请求慢或失败时的重试控制
分叉本地链并不是把整个链下载到本地,而是按需从 RPC 服务商拉取状态。这意味着你执行一笔交易时,Anvil 可能需要去远端查很多账户状态和合约代码。如果 RPC 服务不稳定,启动和运行都会出问题。
Anvil 提供了两个有用的参数:
anvil \ --fork-url https://your-rpc-provider.example/rpc \ --fork-retry-count 10 \ --fork-timeout 1000--fork-retry-count控制每个失败请求的重试次数。默认次数不高,在公网 RPC 限流时很容易失败。调大到 10~15 会有明显改善。--fork-timeout是每次请求的超时毫秒数,如果 RPC 服务很慢,可以适当调大。
注意:这些参数不能根治慢 RPC,它们只是提升容错。如果某个 RPC 服务商经常超时,最好换一个质量稳定的节点提供商,或者自己起一个缓存服务做中间层。
4.4 状态缓存与状态导出:让分叉测试更快更稳
Anvil 会把分叉过程中读取到的远端状态缓存到本地目录,默认在用户目录下的.foundry/anvil/cache里。缓存逻辑通常是按链 ID 和区块高度区分的,所以重新启动相同分叉点时,很多状态不需要再次拉取,速度和稳定性都会明显提升。
如果你不仅想缓存分叉状态,还想把当前整个人工的测试状态保存下来,Anvil 也提供了anvil_dumpState和anvil_loadState两个 RPC 方法。操作方法是:
cast rpc anvil_dumpState --rpc-url http://127.0.0.1:8545 > state.json cast rpc anvil_loadState "$(cat state.json)" --rpc-url http://127.0.0.1:8545注意anvil_loadState的参数是一个 JSON 字符串,所以通过cat传入时要小心转义。这个功能适合你在本地做了一系列复杂状态修改后,想把这个状态保存下来,下次直接恢复继续测试。
4.5 分叉环境的本质提醒
分叉模式下,你在 Anvil 上可以调用公链合约的任何函数,但这些函数执行时读取的状态是本地缓存的快照。你无法真正把一笔交易提交回公链,也无法影响真实合约。这种“假公链”最后会让你产生一种错觉:好像线上就是这样的。但只要公链状态再往前走了若干块,你的分叉高度和真实链就已经不一样了。
所以对时间敏感的逻辑,最好明确注明是基于某个历史区块的模拟结果,不要直接拿它去预测未来线上会发生什么。
5. 进阶玩法:控制时间、扮演任意账户、人为制造异常状态
5.1 把链上时钟拨到你想要的时刻
很多合约逻辑依赖区块时间戳,比如锁仓期结束、拍卖截止。Anvil 虽然默认会在出块时自动更新时间,但它也给了你两个手动方法:anvil_setNextBlockTimestamp和anvil_mine。
先设置下一个区块的时间戳,然后手动挖矿:
cast rpc anvil_setNextBlockTimestamp 1750000000 --rpc-url http://127.0.0.1:8545 cast rpc anvil_mine --rpc-url http://127.0.0.1:8545这里的时间戳是 Unix 时间戳,单位秒。设置一次只会影响下一个块。如果有多个块需要设置不同时间,可以循环调用。这个方案比单纯等待真实时间要高效得多,而且完全可控。
还有一点值得提醒:anvil_setNextBlockTimestamp设置的时间戳不能小于当前区块时间戳,否则调用会失败。如果你想让链的时间倒退,没有专门的 RPC 方法,通常只能重置整个链到更早的状态。
5.2 不签名下交易:扮演任意链上角色
分叉公链之后,你经常会想模拟某个持有大量资产的账户发起交易,但你不可能拿到它的私钥。Anvil 提供anvil_impersonateAccount,让你临时“扮演”这个地址。
流程是这样的:
cast rpc anvil_impersonateAccount 0xYourTargetAddress --rpc-url http://127.0.0.1:8545之后构造一笔eth_sendTransaction,直接把from字段设成这个地址。因为 Anvil 默认允许已 impersonate 的地址发交易而不校验签名,所以交易会照常打包。
模拟完要记得解除扮演:
cast rpc anvil_stopImpersonatingAccount 0xYourTargetAddress --rpc-url http://127.0.0.1:8545我一般会提醒团队成员:不要只 impersonate 不停止。因为如果你后来在真实网络上测试,误以为某地址可以被随意借用,很容易踩坑。Anvil 的宽松只在本地环境有意义。
5.3 直接修改链上数据:余额、合约字节码、存储插槽
Anvil 的面板级 RPC 方法里,最有杀伤力的是anvil_setBalance、anvil_setCode、anvil_setStorageAt。它们可以在运行时直接修改链上世界状态。
比如你想给一个账户充币:
cast rpc anvil_setBalance 0xYourAddress 0xDE0B6B3A7640000 --rpc-url http://127.0.0.1:8545这里余额的单位是 wei,上面的十六进制数对应的就是 1 ETH。如果你想给某个合约替换成自己的测试字节码,可以用anvil_setCode。
这些方法非常适合制造极端条件。比如测试清算逻辑时,你可以专门把某个用户的抵押率压到阈值以下,而不是辛辛苦苦去构造一笔导致价格波动的交易。再比如测试合约状态错误时,可以手动往某个 storage slot 里写一个不合理的值,观察合约是否优雅处理。
但用这些方法要谨慎:直接改状态有时会绕过正常业务逻辑,测出来的结果可能不符合真实用户会走的路径。所以这类“暴力修改”更适合用来做异常路径的补充验证,而不是代替常规集成测试。
6. 把 Anvil 跑进脚本和 CI:从手工调试到自动化执行
6.1 用配置文件代替一长串命令行参数
启动参数一多,命令行就会变得很乱。Anvil 支持通过配置文件启动,把链 ID、账户数、端口、分叉地址、区块时间全部写进 JSON 文件里。
命令行的写法:
anvil --config anvil.json配置文件的字段命名,基本就是把 CLI 长参数里的--去掉,并且用-连接单词形成 JSON 键。例如:
{ "chain-id": 31337, "port": 8545, "accounts": 10, "balance": "1000", "block-time": 1, "fork-url": "https://your-rpc-provider.example/rpc", "fork-block-number": 18000000 }这个文件的好处是:链配置可以纳入代码仓库的版本管理,团队所有成员用同一份配置启动,减少“明明我这边没问题,你那边就不行”的争论。
需要注意,配置文件可能包含 RPC 地址等敏感信息。如果该 RPC 地址有访问控制或计费限制,不要把这份文件提交到公开仓库,或者至少把关键字段改写成环境变量占位符。
6.2 在 CI 里安全地管理 Anvil 生命周期
自动化测试里最常见的错误是:启动 Anvil 后忘记清理,导致下一个测试任务端口冲突。更规范的做法是在脚本里用固定的启动 PID 和退出钩子管理生命周期。
一个比较稳妥的 bash 模式是这样的:
anvil --port 8545 --silent & ANVIL_PID=$! trap 'kill $ANVIL_PID' EXIT # 等待 RPC 服务可用 for i in $(seq 1 30); do cast rpc eth_blockNumber --rpc-url http://127.0.0.1:8545 && break sleep 0.5 done # 跑你的测试命令 forge test --fork-url http://127.0.0.1:8545这段脚本里有两个关键点:一是--silent让 Anvil 不刷屏,二是trap确保脚本退出时杀掉进程。如果没有 trap,CI 节点上会残留一堆监听 8545 的 Anvil 僵尸进程,次数多了整个 CI 环境都会变成一团乱麻。
等待 RPC 服务可用的循环也需要有。Anvil 启动虽然快,但进程起来和 RPC 端口开放之间还有微小的时间差。如果测试命令立刻请求端口,偶尔第一次会连接失败。
6.3 多实例并行时的端口分配建议
如果 CI 里需要并行跑多个测试任务,最简单的方式是每个任务分配不同端口。可以借助环境变量:
anvil --port $((8545 + $CI_NODE_INDEX)) \ --chain-id $((31337 + $CI_NODE_INDEX))这里 I 是任务编号。端口和链 ID 同步偏移,可以避免所有任务共享同一个默认值造成冲突。很多区块链项目测试量大,把 Anvil 并行化之后,整个回归测试的耗时能成倍下降。
如果不想手动管理端口,也可以用系统随机空闲端口,但那样需要在测试进程之间传递端口号,复杂度会提高。对大多数场景来说,固定偏移已经足够。
7. 使用中途最常见的几个坑和排查思路
7.1 端口占用导致起不来
Anvil 启动时提示Address already in use,几乎都是前一个实例没有退出。排查方法先看端口被谁占用:
lsof -i :8545确认进程 PID 后,再判断要不要直接结束它。在开发机上可以:
kill -9 <PID>但在分享的 CI 机器上,最好先确认这个进程不是别人的任务再清理。这也是为什么我一直强调用trap管理生命周期。
7.2 分叉交易执行结果和线上不一致
分叉模式最大的坑是“我模拟的结果怎么跟线上不一样”。原因通常有几个:
- 分叉高度太旧:线上状态已经推进,分叉快照是一个历史时刻,结果当然有差异。
- hardfork 版本不一致:本地默认 EVM 版本和线上链实际版本不一致。
- 远端 RPC 返回的数据被规范化过:某些 RPC 服务商会拦截并修改部分字段,导致 Anvil 拿到的不是完整原始状态。
排查时先固定--fork-block-number和--hardfork,再逐步缩小差异范围。一般来说,固定高度加上固定 RPC 服务商,能排除大部分变量。
7.3 gas 估算失败或交易一直 revert
分叉环境中,Anvil 的 gas 估算会尝试执行交易,真实模拟当前状态。但如果你改了存储或哈希,可能会导致一些完全不合理的中间状态,让估算失败。
如果我遇到这类问题,第一反应不是找合约 bug,而是先检查是不是自己通过anvil_setStorageAt或anvil_setBalance改出了矛盾状态。调试时可以用cast call逐段模拟,或者打开 Anvil 的 trace 看具体 revert 原因:
cast call --trace-local ...如果还看不清,可以把这个状态导出到新的 Anvil 实例,用不同的区块 gas 上限再试。高 gas limit 虽然不能解决所有问题,至少能排除“本来 gas 不够”的干扰。
7.4 分离“业务 bug”和“链环境异常”
我有个习惯:只要 Anvil 上跑出了不符合预期的结果,先重启一个干净实例复现一遍。如果干净实例复现不出来,那说明是你的历史状态污染了测试环境。如果干净实例也能复现,那就是合约业务逻辑本身存在 bug,或者参数配置不合理。
Anvil 的优点就在于重启成本极低。多利用这一点,能省去很多无效排查时间。永远不要让同一个实例长时间运行来“省事”,本地链不存在保存状态的必要性,随时重开才是最清爽的开发方式。
7.5 几个值得记下来的 RPC 控制方法
最后整理一份我日常使用频率最高的 Anvil RPC 方法清单,方便你查阅备用:
| 方法 | 作用 | 常见场景 |
|---|---|---|
anvil_mine | 手动挖块 | 配合时间测试 |
anvil_setNextBlockTimestamp | 设置下一块时间戳 | 模拟锁仓/拍卖窗口 |
anvil_impersonateAccount | 扮演任意地址 | 以大户/管理员身份发交易 |
anvil_stopImpersonatingAccount | 解除扮演 | 防止误用 |
anvil_setBalance | 修改地址余额 | 快速生成测试资金 |
anvil_setCode | 替换地址代码 | 模拟恶意合约 |
anvil_setStorageAt | 修改存储槽 | 构造极端合约状态 |
anvil_dumpState | 导出当前状态 | 保存复杂测试环境 |
anvil_loadState | 加载状态 | 恢复上次现场 |
anvil_reset | 重置链状态 | 回到分叉点/初始状态 |
anvil_setAutomine | 开启或关闭自动出块 | 切换挖矿模式 |
这些方法不是替代 Forge 的 cheatcode,而是专门面向链层面的控制。两者结合,几乎可以把开发环境里的所有不确定因素全部抹平。
就我个人经验而言,Anvil 的价值不在于它有多复杂,而在于它把“本地链”这个概念变成了一个真正可编程、可重置、可注入故障的开发工具。与其去背诵每个参数,不如先上手跑一次anvil,然后用anvil --help把所有参数都翻一遍。等你有过几次因为环境不一致而排查到深夜的经历,你就会明白:为了让测试环境完全可复现,显式指定--chain-id、--fork-block-number和--block-time,永远是值得的。