调用,预编译和编译器到底是怎么工作的
<!-- more -->
写于2024年3月15日 作者 tincho — 阅读时间8分钟
用Solidity写了5年程序,我以为我知道调用(calls)是如何工作的。但这一切在我遇到一段L2中的不可能的Solidity代码时发生了改变。
我遇到了一段代码它应该是不能运行的。如果我对Solidity的了解都是正确的,那么我遇到的这个合约就不应该正常运行。但不知什么原因,事实并非如此。
测试显示没有错误。 测试网已经运行了好几周。这个系统经过了多次安全审查。这样一个损坏的代码不应该已经被报告并修复吗? 甚至另一个更流行的 L2 也使用类似的代码。
我所看到的一切都与我对Solidity外部调用的了解相矛盾。我会错得这么离谱吗?
我的debug技巧让我失败了。这里面有很多令人感动的事情。如果你曾经尝试调试一个交易,这个交易使用预部署,调用自定义预编译,这个预编译对L2的自定义版本的geth中的内容进行 ABI解码,而该版本派生了另一个L2的代码,你就会明白我的感受。
怀疑演变成绝望。盲目信仰的诱惑愈演愈烈。但我不会屈服!幸运的是,我只用了几个小时就完成了突然的启发、理解到解脱的过程。
有些人在宗教书籍中发现了揭示真相的真理。 有些人则在机场休息室浏览自助书籍。而我在 C++文件的第2718行找到了它。
外部调用(external call)的Solidity语法如下所示:
pragma solidity ^0.8.0;
interface ISomeInterface {
function foo() external;
}
contract Example {
function callAccount(address account) external {
ISomeInterface(account).foo();
}
}
使用外部调用的示例合约
如果你编译这个合约,会弹出没有包含许可证标识符(license identifier)的警告,你会看到这个字节码
...
CALL
...
用solc编译后的EVM字节码0.8.15
不出所料,编译器将Solidity高级调用转换为CALL
操作码。你觉得过于简单?好吧,让我们深入一点。
那些处理Solidity超过一个去中心化金融夏天(defi summer)的人知道编译器包括安全检查。
在CALL
之前,编译器放置字节码来验证调用的目标是否有代码。它放置了一个EXTCODESIZE
,包括在CALL
之前到达REVERT
的必要逻辑,以防目标的EXTCODESIZE
为0。
EXTCODESIZE
...
REVERT
...
CALL
使用solc编译后更准确的字节码0.8.15
但是,即使是一个在2021年夏天去中心化金融后半段开始并从那以后一直在编写Solidity,为下一个牛市做好准备的开发人员也知道这一点。他们可能已经在字节码中看到了它,或者,更准确地说,可能已经在Solidity文档4中发现了它:
由于EVM认为对不存在的合约的调用总是成功的,Solidity在执行外部调用时使用
extcodesize
操作码进行额外检查。这确保了即将被调用的合约要么实际存在(它包含代码),要么引发异常。 我对上述内容深信不疑。以至于当我第一次看到这样的代码时,我很难相信:
pragma solidity ^0.8.0;
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
contract Example {
// Function to execute a custom precompile
function doSomething() external {
// [...]
IPrecompile(customPrecompileAddress).foo();
}
}
在深入研究之前,我们先熟悉一下一些概念。
预编译是没有存储字节码但可以执行代码的EVM帐户。它们的执行代码存储在节点本身中。通常你会发现它们在可能地址的最低范围内。
要执行预编译,您需要调用它所在的地址。例如,ecRecovery
是地址为“0x00…01”的EVM的一个预编译。
让我们看看它的代码:
cast code 0x0000000000000000000000000000000000000001
0x
它没有EVM字节码。它的实际代码在节点中。
虽然以太坊有自己的预编译,但没有什么可以阻止L2将新编译包含到其节点中。这可能是增强EVM功能的强大方式。
预编译没有EVM字节码。我认为Solidity不允许对没有字节码的帐户进行高级调用。它会在调用之前恢复。
因此,要调用预编译,我会使用Solidity低级调用(对地址而不是合约实例进行操作的调用)。正如文档所解释的那样,这种调用不包括EXTCODESIZE
。
例如,要在0x04调用预编译:
// Call precompile at address 0x04
(, bytes memory returndata) = address(4).call(somedata)
标准的EVM预编译非常简单,因此用这种方式调用它们也很简单。你发送一些原始数据字节,它们执行一些计算,并返回一组带有结果的原始字节。
Solc确实有内置函数来调用一些(但不是全部)预编译,例如ecRecovery
。只是为了让你不用编写低级调用。但这在这里是无关紧要的。
L2的预编译可能比EVM中的“标准”编译更复杂。它们可能在单个预编译中包含不同的_functions_
。例如,可能有一个预编译实现了我们之前看到的接口:
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
因此,假设预编译可以以某种方式处理它(我们稍后会看到一个示例),你可以使用以下内容调用它的foo
函数:
(, bytes memory returndata) = address(customPrecompileAddress).call(abi.encodeWithSelector(IPrecompile.foo.selector));
uint256 result = abi.decode(returndata, (uint256));
但不是像这样的调用
uint256 result = IPrecompile(precompileAddress).foo();
那会失败的。我告诉你。我读到的文档是这么说的,我们之前看到了EXTCODESIZE
检查。
不要坚持了,这是行不通的。
哈哈,我只是开个玩笑。高级调用也有效。为了理解背后的原因,首先我们需要创建一个自定义预编译,然后做一些测试,最后检查solc是如何在后台工作的。
让我们首先在go-ethereum的“core/vm/contracts.go”文件中创建一个自定义预编译。 💡 有更聪明的方法可以将一组复杂的自定义预编译添加到EVM。这是一个更实际的例子,研究ArbOS是如何做到的。
我将创建的预编译根据foo
和bar
的函数选择器检查输入字节。当foo
的选择器匹配时,它返回数字43。当bar
的选择器匹配时,它不返回任何内容。
type myPrecompile struct{}
func (p *myPrecompile) RequiredGas(_ []byte) uint64 {
return 0
}
func (p *myPrecompile) Run(input []byte) ([]byte, error) {
if len(input) < 4 {
return nil, errors.New("short input")
}
if input[0] == 0xC2 && input[1] == 0x98 && input[2] == 0x55 && input[3] == 0x78 { // function selector of `foo()`
return common.LeftPadBytes([]byte{43}, 32), nil
} else if input[0] == 0xFE && input[1] == 0xBB && input[2] == 0x0F && input[3] == 0x7E { // function selector of `bar()
return nil, nil
} else {
return nil, errors.New("bad input")
}
}
预编译会在'0x0b'地址:
var PrecompiledContractsCancun = map[common.Address]PrecompiledContract{
// [...]
common.BytesToAddress([]byte{0x0b}): &myPrecompile{},
}
然后构建go-ethereum('make geth')并在开发模式下运行它('./build/bin/geth--dev--http')。
使用cast验证预编译是否有效:
cast call 0x000000000000000000000000000000000000000b "foo()"
0x000000000000000000000000000000000000000000000000000000000000002b
cast call 0x000000000000000000000000000000000000000b "bar()"
0x
cast call 0x000000000000000000000000000000000000000b
Error:
(code: -32000, message: short input, data: None)
cast call 0x000000000000000000000000000000000000000b "somefunction()"
Error:
(code: -32000, message: bad input, data: None)
快速测试从cast调用新的预编译
都准备好了!现在让我们转向Solidity。
是时候调用我在地址“0x0b”新创建的预编译foo
函数了。
我将使用一个高级调用。据我所知,这应该不起作用。它应该在触发调用之前恢复,因为编译器包含的EXTCODESIZE
检查将为“0x0b”地址返回0,因此在字节码中到达REVERT
。
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
contract PrecompileCaller {
function callFoo() external {
// This call to `foo` should revert
uint256 result = IPrecompile(address(0x0b)).foo();
require(result == 43, "Unexpected result");
}
}
测试对预编译的高级调用的示例合约
这是一个简单的Hardhat测试来执行它:
describe("PrecompileCaller", function () {
let precompileCaller;
before(async function () {
const PrecompileCallerFactory = await ethers.getContractFactory("PrecompileCaller");
precompileCaller = await PrecompileCallerFactory.deploy();
});
it("Calls foo", async function () {
await precompileCaller.callFoo();
});
});
$ yarn hardhat test --network localhost
PrecompileCaller
✔ Calls foo
1 passing (224ms)
怎么回事? 这应该是不能运行的 🤔
让我们看看。如果调用foo
有效,那么调用bar
也应该有效。我将在合约中添加一些代码来调用预编译的bar
函数。
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
contract PrecompileCaller {
// Somehow this works
function callFoo() external {
uint256 result = IPrecompile(address(0x0b)).foo();
require(result == 43, "Unexpected result");
}
// If calling `foo` works, this should also work
function callBar() external {
IPrecompile(address(0x0b)).bar();
}
}
扩展的Hardhat测试现在如下所示:
const { expect } = require("chai");
describe("PrecompileCaller", function () {
let precompileCaller;
before(async function () {
const PrecompileCallerFactory = await ethers.getContractFactory("PrecompileCaller");
precompileCaller = await PrecompileCallerFactory.deploy();
});
it("Calls foo", async function () {
// This works (doesn't revert)
await precompileCaller.callFoo();
});
it("Calls bar", async function () {
// This should also work. Does it?
await precompileCaller.callBar();
});
});
$ yarn hardhat test --network localhost
PrecompileCaller
✔ Calls foo
1) Calls bar
1 passing (252ms)
1 failing
1) PrecompileCaller
Calls bar:
ProviderError: execution reverted
糟糕。
看到了吗?我告诉过你。在写了那么多年代码后,我不知道调用是如何工作的。这是Solidity代码:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
contract PrecompileCaller {
// Somehow this works
function callFoo() external {
uint256 result = IPrecompile(address(0x0b)).foo();
require(result == 43, "Unexpected result");
}
// Somehow this doesn't work
function callBar() external {
IPrecompile(address(0x0b)).bar();
}
}
我们现在是处于简单模式。在这个例子中,这两个函数有一个明显的区别。真实的案例更难,我不太能理解。
这里的区别在于返回值(returns)。声明的返回值可能与这一切有关吗?
我了解到Solidity不总是在高级调用中包含EXTCODESIZE
检查。
让我们分析一下“PrecompileCaller”合约的函数callFoo
和callBar
生成的Yul代码。
对于callFoo
:
function fun_callFoo_32() {
// ...
let _3 := call(gas(), expr_21_address, 0, _1, sub(_2, _1), _1, 32)
对于 callBar
:
function fun_callBar_45() {
// ...
if iszero(extcodesize(expr_41_address)) { revert_error_0cc013b6b3b6beabea4e3a74a6d380f0df81852ca99887912475e1f66b2a2c20() }
// ...
let _8 := call(gas(), expr_41_address, 0, _6, sub(_7, _6), _6, 0)
在callFoo
中,编译器在调用前没有包含EXTCODESIZE
检查。与它在callBar
中所做的相反。它为什么要这样做?
答案隐藏在C++文件的第2718和2719行中。
如果我们期望返回数据,我们不需要检查extcodesize,因为如果没有代码,调用将返回空数据并且ABI解码器将恢复。 这是什么意思?
还记得我在Solidity中使用的interface
吗:
interface IPrecompile {
function foo() external returns (uint256);
function bar() external;
}
根据这个定义,编译器期望foo
返回一些东西(“uint256”)。 因此,它不会在调用之前进行EXTCODESIZE
检查!
Solc假设目标没有代码,实际上无论如何都不会返回数据,因此将无返回数据的 ABI 解码作为返回类型(“uint256”)将会失败。 因此,它可能会在调用之前跳过代码大小检查。
更让我困惑的是,编译器并不总是这样。 当需要返回数据时,跳过外部调用的代码大小检查在 0.8.10 中引入的。 这意味着这至少是在2年前。 我想我发现得太晚了?
即使在写完这篇文章后,我仍然认为文档不完整且过时。但事实并非如此。我亲爱的matta发现这种特殊行为在另一节中有记录,但我没有读过🤦
该文档还有改进的空间。 所以我们提出了一个小PR,让它们更清晰、更一致。
我希望我现在可以说我知道Solidity调用是如何工作的了。但也许转角处会有新的惊喜在等着我。
没关系,免费的。我们也不发垃圾邮件。我懒得发垃圾邮件。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!