本文是EVM内部原理系列文章的第三部分,主要讲解了区块链开发者应该如何利用EVM的debug工具来调试智能合约,包括如何使用Foundry、Hardhat、Tenderly等工具进行交易的追踪和调试,如何理解debug_traceCall,以及如何通过Foundry脚本来调试交易。通过学习EVM的trace,开发者可以更好地理解合约的执行过程,从而更高效地进行bug查找、gas优化和开发流程管理。
这是多部分系列文章:“每个区块链开发者应该知道的 EVM 内部原理”的第三篇也是最后一篇文章。 在这篇文章中,我们将逐步介绍 EVM 执行交易时实际发生的情况。我们将解开函数调用的内部跟踪,探索失败是如何冒泡的,并学习当出现问题时如何解释 EVM 的行为。这篇文章全部关于理解 EVM 在运行时如何处理你的代码,从堆栈移动到存储写入和 Gas 记账。
我们将探索:
1. EVM 开发者工具
2. 理解
debug_traceCall
3. 使用 Foundry 进行本地部署:从设置到合约
4. 跟踪和调试成功的交易
5. 跟踪和调试失败的交易
6. 使用 Foundry 脚本跟踪和调试交易
7. 总结:为什么理解 Traces 能让你成为更好的开发者
在本文结束时,你将知道如何读取和解释原始 EVM trace,包括堆栈移动、存储写入和失败点。这个基础将使你更有能力编写、调试和推理智能合约,达到工具和基础设施的水平。
在我们深入研究原始 EVM 内部原理之前,值得研究一下实际的开发者工具如何在实践中使用 traces,以及这些 traces 有助于解决哪些问题。
如今,大多数智能合约工具都依赖于 traces,以便在低级别检查交易:它们如何执行,在哪里失败,进行了哪些内部调用,以及状态如何被修改。无论你是在运行测试、调试 reverts,还是在部署后分析合约,你几乎总是依赖于幕后的 trace 数据。
一个 trace 显示:
让我们看看生态系统中一些最流行的工具如何使用 EVM traces:
所有这些工具都依赖于 EVM traces,但它们以不同的方式使用它们。
在本文的其余部分中,我们将使用 Foundry,它在本地、可编写脚本的环境中为我们提供了完整的 trace 可见性,非常适合理解 EVM 的实际工作方式。
debug_traceCall
要检查 EVM 实际如何执行交易,我们依赖于 debug
命名空间下的特殊 JSON-RPC 方法。这些方法不是标准以太坊 JSON-RPC API 的一部分,它们通常仅在 完整节点(如 Geth 或 Erigon,将在后面介绍)或本地开发节点(如 Foundry 使用的 Anvil)上可用。
两个最有用的方法是:
debug_traceTransaction
—— 通过哈希跟踪已挖出的交易debug_traceCall
—— 模拟交易而不发送它,并返回完整的 tracedebug_traceCall
: 它的作用
此 RPC 调用使我们可以模拟交易在给定状态下的执行方式,并为我们提供每个 EVM 步骤的详细 trace,包括:
pc
)处执行的 opcode这是我们将用来详细跟踪交易的工具,而无需实际挖掘它或修改区块链状态。
为什么 debug_traceCall
对开发者有用
与仅为你提供返回值的 eth_call
(将在后面介绍)不同,debug_traceCall
告诉你 返回值是如何计算的 以及 EVM 在内部执行的操作。这使其对于以下方面非常宝贵:
现在让我们使用 Foundry 跟踪一个真实的交易,其中包含一个稍微复杂的结构,包括 uint256
和动态 string
。这将展示 EVM 不仅处理 calldata 解码和存储写入,还处理动态内存引用和编码偏移量。
pragma solidity ^0.8.12;
contract Storage {
struct my_storage_struct {
uint256 number;
string owner;
}
my_storage_struct my_storage;
function store(my_storage_struct calldata new_storage) public {
if (new_storage.number > 100) {
revert("Number too large");
}
my_storage = new_storage;
}
function retrieve() public view returns (my_storage_struct memory){
return my_storage;
}
}
在我们可以跟踪任何内容之前,让我们从零开始设置 Foundry,部署合约并在本地模拟交易。
本演练假定你具有基本的命令行经验并已安装 git
。
步骤 1:安装 Foundry
Foundry 提供了一个快速的 CLI,用于测试、部署智能合约并与之交互。使用以下命令安装它:
curl -L https://foundry.paradigm.xyz | bash
foundryup
这将安装 forge
(测试运行器)、cast
(交易 + RPC CLI)和 anvil
(本地 EVM 节点)
forge --version
cast --version
anvil --version
步骤 2:初始化一个新项目
mkdir evm-trace-demo
cd evm-trace-demo
forge init
这为你提供了:
src/
文件夹test/
文件夹foundry.toml
配置文件步骤 3:编写合约
在 src/Storage.sol
中,粘贴上面的合约。
步骤 4:编译合约
运行 forge build
。你应该看不到任何错误,并且会生成一个 out/Storage.sol/Storage.json
ABI 工件。
步骤 5:启动本地节点
通过运行以下命令启动 Foundry 的本地 EVM Anvil:
anvil
这将启动一个本地的、与 fork 兼容的链,并为你提供 10 个预先出资的测试帐户,它们的私钥以相同的顺序列出。
注意: 记下打印的第一个私钥和地址,我们将使用它们进行部署
并且你会看到我们新 fork 的链正在 http://localhost:8545
上运行
步骤 6:部署合约
使用 forge
部署合约:
forge create src/Storage.sol:Storage \
--rpc-url http://localhost:8545 \
--private-key <your-key> \
--broadcast
输出应如下所示:
[⠊] Compiling...
[⠒] Compiling 1 files with Solc 0.8.30
[⠢] Solc 0.8.30 finished in 37.11ms
Compiler run successful!
Deployer: <your-address>
Deployed to: <the-address-of-the-smart-contract>
Transaction hash: <some-tx-hash>
注意: <your-key>
是上一个终端中打印的密钥之一。
没有 --braodcast
forge 将只进行 dry-run,而不会部署在 Anvil 上。
这完成了你的本地设置。此时,你拥有:
接下来,我们将模拟对 store()
函数的调用,并逐步探索 EVM 如何处理该调用。
让我们将所有理论付诸实践。在本节中,我们将为我们的 store((uint256,string))
函数编写一个测试,使用 Foundry 运行它,检查 trace 输出,并了解如何使用 CLI 和原始 trace 数据确认交易成功。
编写测试
我们将从创建一个使用有效输入调用该函数的测试开始。在部署合约并运行本地节点后,让我们模拟一个成功存储数据的交易,并跟踪 EVM 在运行时如何精确地处理它。我们将使用 Foundry 的 cast
CLI 对调用进行编码,将其发送到我们的本地节点,并使用 debug_traceCall
检查执行的每个步骤。
让我们将此测试添加到 test/Storage.t.sol
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.12;
import "forge-std/Test.sol";
import "../src/Storage.sol";
contract StorageTest is Test {
Storage public storageContract;
function setUp() public {
storageContract = new Storage();
}
function testStoreStruct() public {
Storage.my_storage_struct memory input = Storage.my_storage_struct({
number: 25,
owner: "bob"
});
storageContract.store(input);
}
}
注意: setUp()
函数是一种特殊的 Foundry 方法,它在每次测试之抢跑。在这里,它部署了一个新的 Storage
合约实例。
使用以下命令在基本视图中运行测试:
forge test --match-test testStoreStruct -vvvvv
预期的输出将是:
使用 --debug
运行以查看 EVM Trace
这是 EVM 的交互式原始执行流程。它显示:
应该看起来像这样:
从 Trace 确认成功
如果我们只能访问原始 trace opcodes,我们仍然可以验证调用的成功:
我们的 store()
函数采用一个结构 (uint256 number, string owner)
。让我们分别使用值 25
和 "bob"
调用它。
正如我们从之前的系列中记得的那样,我们需要编码我们的合约方法和参数,让我们探索如何使用 Anvil 完成:
cast calldata "store((uint256,string))" "(25,"bob")"
// Output should be: 0xddd356b30000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000003626f620000000000000000000000000000000000000000000000000000000000
现在当我们有了编码的数据,我们将模拟智能合约 store
方法:
cast rpc debug_traceCall \
'{"to":"<your-contract-address>", "data":"0xddd356b30000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001900000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000003626f620000000000000000000000000000000000000000000000000000000000"}' \
latest | jq '.' > trace.json
注意: 确保你的系统上已安装
jq
,以便漂亮地打印 trace 输出。
你将看到这样的输出:
{
"failed": false,
"gas": 52080,
"returnValue": "",
...
}
failed: false
STOP
或 RETURN
这意味着该函数已干净地退出。
在这个智能合约示例中,大于 100 的数字将导致智能合约失败。因此,如果我们调用 store((101, "too much"))
,它应该 revert。
添加失败测试
使用另一个测试方法更新测试文件:test/Storage.t.sol
:
function testStoreStructReverts() public {
Storage.my_storage_struct memory input = Storage.my_storage_struct({
number: 101,
owner: "too much"
});
storageContract.store(input);
}
在不期望 Revert 的情况下运行它
forge test --match-test testStoreStructReverts
输出将是:
为了使测试通过(并且仍然跟踪它),请使用 Foundry 的 作弊码:
function testStoreStructReverts() public {
Storage.my_storage_struct memory input = Storage.my_storage_struct({
number: 101,
owner: "too much"
});
vm.expectRevert("Number too large");
storageContract.store(input);
}
输出将是:
可以在 debug
模式下运行此程序以逐步查看执行情况
从原始 Trace 确认失败
执行与之前显示的数字 101 相同的过程,并将此文件保存到 trace_fail.json
将输出下一个文件:
{
"failed": true,
"gas": 23190,
"returnValue": "08c379a0..." // ABI 编码的错误字符串
...
}
failed: true
REVERT
这意味着该函数失败了。
在我们之前的测试中,我们使用了 forge test --debug
来跟踪交易。虽然这可以让你逐步执行测试函数,但它不会像我们要检查的 store()
函数那样逐步执行合约逻辑本身。
可以通过 forge test
、forge script
和 cast run
访问 Foundry 的调试器。在本节中,我们将重点介绍 forge script
,它允许你逐步执行内部合约逻辑,否则这些逻辑将隐藏在测试包装器中。
什么是 Forge 脚本
在 Foundry 中,Forge 脚本 是一种使用 Solidity 本身编写部署或交互逻辑的方式,而不是使用诸如 forge create
之类的 CLI 命令或诸如 Hardhat 之类的外部 JavaScript 工具。
你可以将其视为:
一个小型 Solidity 程序,可让你部署合约、调用函数或模拟交易,所有这些都在 run()
函数中完成。
默认情况下,通过调用名为 run
的函数来执行脚本
它类似于你使用其他语言编写的脚本,但你不是用 JavaScript 或 Python 编写它们,而是直接用 Solidity 编写它们。
调试脚本
当你使用脚本时,Foundry 会像完整交易一样执行你的代码。这意味着每个函数调用(包括你的合约逻辑)都是顶级执行的一部分,你可以按 opcode 逐步执行它。
让我们编写一个脚本并将其保存在 script/DebugStore.s.sol
中
pragma solidity ^0.8.12;
import "forge-std/Script.sol";
import "../src/Storage.sol";
contract DebugStore is Script {
function run() external {
Storage s = new Storage();
s.store(Storage.my_storage_struct({ number: 101, owner: "bob" }));
}
}
要调试该脚本,我们将在终端中运行:
forge script script/DebugStore.s.sol:DebugStore \
--fork-url http://localhost:8545 \
--debug
注意:确保
anvil
fork 在不同的终端中运行并公开 url:http://localhost:8545
你现在将进入一个交互式 opcode 调试器,你可以在其中:
store()
在后台的工作方式CALLDATALOAD
)、条件检查 ( GT
) 和存储写入 ( SSTORE
)示例:
在这里,我们可以看到内部合约将 revert,因为我们传递的数字 > 100
注意:我们可以在调用
store
方法之前像之前在测试中所做的那样添加vm.expectRevert(...)
学习如何读取 EVM traces 可能感觉很底层,但它会很快带来回报。
revert
发生在何处以及存储是否已更改。SSTORE
、CALLDATACOPY
等),并了解编译器在幕后生成的内容。掌握 EVM traces 可以为你提供巨大的优势。你不再猜测你的合约做了什么,而是像 EVM 一样,逐条指令地观察它的展开。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!