深入以太坊虚拟机 Part4 — 智能合约外部方法调用
原文:How To Decipher A Smart Contract Method Call | by Howard | Sep 18, 2017
在本系列的前几篇文章中,我们已经了解了 Solidity 如何表示 EVM 存储中的复杂数据结构。但是,如果无法与之交互,数据将毫无用处。智能合约是数据与外界之间的中介。
在本文中,我们将了解 Solidity 和 EVM 如何使外部程序能够调用合约的方法并导致其状态发生变化。
“外部程序”不限于 DApp/JavaScript。任何可以使用 HTTP RPC 与以太坊节点通信的程序都可以通过创建交易与部署在区块链上的任何合约进行交互。
创建一个交易就像发出一个 HTTP 请求。Web 服务器将接受您的 HTTP 请求并对数据库进行更改。交易会被网络接受,并且底层区块链扩展到包括状态变化。
交易之于智能合约就像 HTTP 请求之于 Web 服务一样。
如果对 EVM 汇编和 Solidity 数据表示不熟悉,请参阅本系列之前的文章以了解更多信息:
Contract Transaction
让我们看一个将状态变量设置为 0x1
的交易。我们要与之交互的合约具有变量 a
的 setter 和 getter:
该合约部署在测试网络 Rinkeby 上。随意使用地址 0x62650ae5… 的 Etherscan 检查它。
我创建了一个调用 setA(1)
的交易。在地址 0x7db471e5… 处检查此交易。
交易的输入数据为:
对于 EVM,这只是 36 字节的原始数据。它作为 calldata
未经处理传递给智能合约。如果智能合约是一个 Solidity 程序,那么它将这些输入字节解释为一个方法调用,并为 setA(1)
执行适当的汇编代码。
输入数据可以分解为两个子部分:
前四个字节是方法选择器(method selector)。其余的输入数据是 32 字节的块的方法参数。在这个例子中,只有 1 个参数,即值 0x1
。
方法选择器是方法签名的 kecccak256 哈希。在这个例子中,方法签名是 setA(uint256)
,它是方法的名称及其参数的类型。
让我们用 Python 计算方法选择器。首先,哈希方法签名:
然后取哈希的前 4 个字节:
注意:每个字节由 Python 十六进制字符串中的 2 个字符表示
The Application Binary Interface (ABI)
就 EVM 而言,交易的输入数据(calldata
)只是一个字节序列。 EVM 没有对调用方法的内置支持。
智能合约可以选择通过结构化方式处理输入数据来模拟方法调用,如上一节所示。
如果 EVM 上的语言都同意如何解释输入数据,那么它们可以轻松地相互操作。合约应用程序二进制接口 (ABI) 指定了一个通用的编码方案。
我们已经看到了 ABI 如何编码像 setA(1)
这样的简单方法调用。在后面的部分中,我们将看到如何对具有更复杂参数的方法调用进行编码。
Calling A Getter
如果你调用的方法改变了状态,那么整个网络都必须同意。这将需要交易,并且会花费你的 gas。
像 getA()
这样的 getter 方法不会改变任何东西。我们可以将方法调用发送到本地以太坊节点,而不是要求整个网络进行计算。eth_call
RPC 请求允许您在本地模拟交易。这对于只读方法或 gas 费使用估计很有用。
eth_call
类似于缓存的 HTTP GET 请求。
- 它不会改变全球共识状态。
- 本地区块链(“缓存”)可能稍稍过时。
让我们使用 eth_call
来调用 getA
方法,得到状态 a
作为返回。首先,计算方法选择器:
由于没有参数,输入数据本身就是方法选择器。我们可以向任何以太坊节点发送 eth_call
请求。在本例中,我们将请求发送到 infura.io 托管的公共以太坊节点:
EVM 执行计算并返回原始字节作为结果:
根据 ABI,字节应该被解释为值 0x1
。
Assembly For External Method Calling
现在让我们看看编译后的合约如何处理原始输入数据以进行方法调用。考虑一个定义了 setA(uint256)
的合约:
编译:
被调用方法的汇编代码在合约主体中,组织在 sub_0
下:
有两段样板代码与本次讨论无关,但仅供参考(FYI):
- 最顶部的
mstore(0x40, 0x60)
保留内存中的前 64 字节用于 sha3 哈希。无论合约是否需要,这始终存在。
- 最底部的
auxdata
用于验证发布的源代码与部署的字节码是否相同。这是可选的,但已包含在编译器中。
让我们将剩余的汇编代码分成两部分以便于分析:
- 匹配选择器并跳转到方法。
- 加载参数,执行方法,并从方法返回。
首先,用于匹配选择器的带注释汇编:
除了在开始时从 call data 中加载 4 个字节的 bit-shuffling 外,都很简单。为清楚起见,低级伪代码中的汇编逻辑如下:
实际方法调用的注释汇编:
在进入方法部分之前,汇编做了两件事:
- 保存方法调用后返回的位置。
- 将 call data 中的参数加载到堆栈上。
在低级伪代码中:
将两个部分结合在一起:
Fun trivia:revert 的操作码是 fd
。但是您不会在黄皮书中找到它的规范,也不会在代码中找到它的实现。事实上,fd
并不真实存在!这是一个无效的操作。当 EVM 遇到无效操作时,它会放弃并恢复状态作为副作用 (revert state as a side-effect)。
Handling Multiple Methods
Solidity 编译器如何为具有多种方法的合约生成汇编代码?
简单。只是一个接一个的更多的 if-else
分支:
在伪代码中:
ABI Encoding For Complex Method Calls
对于方法调用,交易输入数据的前四个字节始终是方法选择器。然后方法参数以 32 字节为单位跟在后面。 ABI 编码规范 详细说明了如何对更复杂类型的参数进行编码,但阅读起来可能会非常痛苦。
学习 ABI 编码的另一个策略是使用 pyethereum 的 ABI 编码函数 来研究不同类型的数据是如何编码的。我们将从简单的案例开始,然后构建更复杂的类型。
首先,导入 encode_abi
函数:
对于具有三个 uint256 参数的方法(例如 foo(uint256 a, uint256 b, uint256 c)
),编码参数只是一个接一个的 uint256 数字:
小于 32 字节的类型被填充到 32 字节:
对于固定大小的数组,元素也是 32 字节的块(必要时填充零),一个接一个地放置:
ABI Encoding for Dynamic Arrays
ABI 引入了一个间接层(layer of indirection)来编码动态数组,遵循称为 头尾编码(head-tail encoding) 的方案。
这个思想是动态数组的元素被打包在交易 calldata 的尾部。参数(“head”)是对数组元素所在的 calldata 的引用。
如果我们调用具有 3 个动态数组的方法,则参数编码如下(为清楚起见添加了注释和换行符):
所以 head
有三个 32 字节的参数,指向尾部的位置,尾部包含三个动态数组的实际数据。
例如,第一个参数是 0x60
,指向 calldata 的第 96(0x60
)个字节。如果查看第 96 个字节,它是数组的开头。前 32 个字节是长度,后跟三个元素。
可以混合使用动态和静态参数。这是一个带有(static
、dynamic
、static
)参数的示例。静态参数按原样编码,而第二个动态数组的数据放在尾部:
有很多零,但没关系。
Encoding Bytes
字符串和字节数组也是头尾编码的。唯一的区别是字节被紧密地打包成 32 个字节的块,如下所示:
对于每个字符串/字节数组,前 32 个字节编码了长度,紧跟着是字节。
如果字符串大于 32 字节,则使用多个 32 字节块:
Nested Arrays
嵌套数组的每个嵌套都有一个间接寻址。
是的,有很多零。
Gas Cost & ABI Encoding Design
为什么 ABI 将方法选择器截断为仅 4 个字节?如果我们不使用 sha256 的全部 32 个字节,不同的方法是否会发生不幸的冲突?如果截断是为了节省成本,那么如果使用零填充浪费了更多字节,为什么还要在方法选择器中节省 28 个字节呢?
这两种设计选择似乎是矛盾的……直到我们考虑交易的 gas 费用。
- 每笔交易支付 21000。
- 交易的每个零字节数据或代码需要支付 4。
- 交易的每个非零字节数据或代码需要支付 68。
零值便宜 17 倍,因此零填充并不像看起来那么糟糕。
方法选择器是一个加密哈希,它是伪随机的。随机字符串往往具有大部分非零字节,因为每个字节只有 0.3% (1/255) 的机会为 0。
0x1
填充到 32 字节需要 192 gas。(4 * 31 + 68)
- sha256 可能有 32 个非零字节,这大约需要 2176 gas。(32 * 68)
- sha256 被截断为 4 个字节将花费大约 272 gas。(32 * 4)
ABI 展示了另一个受 gas 费用结构激励的古怪低级设计示例。
Negative Integers…
负整数通常使用称为二进制补码的方案表示。 int8 编码类型的值 -1
将全部为 1 1111 1111
。
ABI 用 1 填充负整数,因此 -1
将被填充为:
小的负数大部分是 1,这会花费你很多 gas。
¯_(ツ)_/¯
Conclusion
要与智能合约交互,您需要向其发送原始字节。它会进行一些计算,可能会改变自己的状态,然后向您发送原始字节作为返回。方法调用实际上并不存在。这是 ABI 创造的集体幻觉(collective illusion)。
ABI 被指定为低级格式,但在功能上它更像是跨语言 RPC 框架的序列化格式。
我们可以在 DApp 和 Web App 的架构层之间进行类比:
- 区块链就像背后的数据库。
- 合约就像一个网络服务。
- 交易就像一个请求。
- ABI 是数据交换格式,类似于协议缓冲区。
Other Parts
在这一系列文章中,我翻译了 Howard 的 Diving Into The Ethereum VM 系列文章。译文链接如下: