本文深入探讨了以太坊虚拟机(EVM)与智能合约之间的交互,以及 Solidity 在处理外部程序调用合约方法时的角色和机制。文章详细介绍了交易的构建方式、ABI 编码、合约调用的底层汇编实现等核心概念,适合对 EVM 和 Solidity 有一定了解的开发者阅读。
在本系列的前几篇文章中,我们已经了解了 Solidity 如何在 EVM 存储中表示复杂的数据结构。但如果没有与之交互的方式,数据就是无用的。智能合约是数据与外部世界之间的中介。
在本文中,我们将探讨 Solidity 和 EVM 如何使外部程序调用合约的方法并导致其状态发生变化。
“外部程序”不仅限于 DApp/JavaScript。任何能够使用 HTTP RPC 与以太坊节点通信的程序,都可以通过创建交易与部署在区块链上的任何合约进行交互。
创建交易就像发起一个 HTTP 请求。Web 服务器会接受你的 HTTP 请求并对数据库进行更改。网络会接受交易,并扩展底层区块链以包含状态变化。
交易之于智能合约,就像 HTTP 请求之于 Web 服务。
如果你对 EVM 汇编和 Solidity 数据表示不熟悉,可以查看本系列的前几篇文章了解更多:
让我们来看一个将状态变量设置为 0x1
的交易。我们要与之交互的合约有一个针对变量 a
的 setter 和 getter:
pragma solidity ^0.4.11;contract C {
uint256 a; function setA(uint256 _a) {
a = _a;
} function getA() returns(uint256) {
return a;
}
}
该合约部署在 Rinkeby 测试网络上。你可以在 Etherscan 的地址 0x62650ae5… 上查看它。
我创建了一个调用 setA(1)
的交易。你可以在地址 0x7db471e5... 上查看该交易。
交易的输入数据是:
0xee919d500000000000000000000000000000000000000000000000000000000000000001
对于 EVM 来说,这只是 36 字节的原始数据。它作为 calldata
未经处理地传递给智能合约。如果智能合约是 Solidity 程序,那么它会将这些输入字节解释为方法调用,并执行 setA(1)
的相应汇编代码。
输入数据可以分为两部分:
## 方法选择器(4字节)
0xee919d5
## 第一个参数(32字节)
00000000000000000000000000000000000000000000000000000000000000001
前四个字节是方法选择器。剩下的输入数据是 32 字节块的方法参数。在这种情况下,只有一个参数,值 0x1
。
方法选择器是方法签名的 kecccak256 哈希值。在本例中,方法签名是 setA(uint256)
,即方法名称及其参数类型。
让我们在 Python 中计算方法选择器。首先,对方法签名进行哈希:
## Install pyethereum https://github.com/ethereum/pyethereum/#installation
> from ethereum.utils import sha3
> sha3("setA(uint256)").hex()
'ee919d50445cd9f463621849366a537968fe1ce096894b0d0c001528383d4769'
然后取哈希的前 4 个字节:
> sha3("setA(uint256)")[0:8].hex()
'ee919d50'
注意:在 Python 的十六进制字符串中,每个字节由 2 个字符表示
就 EVM 而言,交易的输入数据(calldata
)只是一串字节序列。EVM 没有内置支持调用方法。
智能合约可以选择通过结构化方式处理输入数据来模拟方法调用,如上一节所示。
如果 EVM 上的语言都同意如何解释输入数据,那么它们可以轻松地相互操作。合约应用二进制接口(ABI)指定了通用编码方案。
我们已经看到了 ABI 如何编码简单的方法调用,如 setA(1)
。在后几节中,我们将看到如何编码具有更复杂参数的方法调用。
如果调用的方法改变了状态,那么整个网络必须达成一致。这将需要一个交易,并消耗你的 gas。
像 getA()
这样的 getter 方法不会改变任何东西。与其让整个网络执行计算,我们可以将方法调用发送到本地的以太坊节点。eth_call
RPC 请求允许你在本地模拟交易。这对于只读方法或 gas 使用量估算非常有用。
eth_call
就像一个缓存的 HTTP GET 请求。
让我们发起一个 eth_call
来调用 getA
方法,返回状态 a
。首先,计算方法选择器:
>>> sha3("getA()")[0:8].hex()
'd46300fd'
由于没有参数,输入数据只是方法选择器本身。我们可以向任何以太坊节点发送 eth_call
请求。在本例中,我们将向 infura.io 托管的公共以太坊节点发送请求:
$ curl -X POST \
-H "Content-Type: application/json" \
"https://rinkeby.infura.io/YOUR_INFURA_TOKEN" \
--data '
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_call",
"params": [\
{\
"to": "0x62650ae5c5777d1660cc17fcd4f48f6a66b9a4c2",\
"data": "0xd46300fd"\
},\
"latest"\
]
}
'
EVM 执行计算并返回原始字节作为结果:
{
"jsonrpc":"2.0",
"id":1,
"result":"0x0000000000000000000000000000000000000000000000000000000000000001"
}
根据 ABI,这些字节应该被解释为值 0x1
。
现在让我们看看编译后的合约如何处理原始输入数据以进行方法调用。考虑一个定义了 setA(uint256)
的合约:
pragma solidity ^0.4.11;contract C {
uint256 a; // 注意:`payable` 使汇编更简单
function setA(uint256 _a) payable {
a = _a;
}
}
编译:
solc --bin --asm --优化 call.sol
方法调用的汇编代码在合约体中,组织在 sub_0
下:
sub_0: assembly {
mstore(0x40, 0x60)
and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
0xee919d50
dup2
eq
tag_2
jumpi
tag_1:
0x0
dup1
revert
tag_2:
tag_3
calldataload(0x4)
jump(tag_4)
tag_3:
stop
tag_4:
/* "call.sol":95:96 a */
0x0
/* "call.sol":95:101 a = _a */
dup2
swap1
sstore
tag_5:
pop
jump // outauxdata: 0xa165627a7a7230582016353b5ec133c89560dea787de20e25e96284d67a632e9df74dd981cc4db7a0a0029
}
有两段样板代码与本次讨论无关,但供你参考:
mstore(0x40, 0x60)
为 sha3 哈希保留了内存中的前 64 字节。无论合约是否需要,它始终存在。auxdata
用于验证发布的源代码是否与部署的字节码相同。这是可选的,但编译器会默认包含。让我们将剩余的汇编代码分为两部分以便分析:
首先,匹配选择器的带注释的汇编代码:
// 将前 4 个字节作为方法选择器加载
and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)// 如果选择器匹配 `0xee919d50`,跳转到 setA
0xee919d50
dup2
eq
tag_2
jumpi// 没有匹配的方法。失败并回滚。
tag_1:
0x0
dup1
revert// setA 的主体
tag_2:
...
除了开始时从调用数据中加载 4 个字节的位移操作外,其余的代码都比较直观。为了清晰起见,汇编代码的低级伪代码如下:
methodSelector = calldata[0:4]if methodSelector == "0xee919d50":
goto tag_2 // 跳转到 setA
else:
// 没有匹配的方法。失败并回滚。
revert
实际方法调用的带注释的汇编代码:
// setA
tag_2:
// 方法调用后跳转的位置
tag_3 // 加载第一个参数(值 0x1)。
calldataload(0x4) // 执行方法。
jump(tag_4)
tag_4:
// sstore(0x0, 0x1)
0x0
dup2
swap1
sstore
tag_5:
pop
// 程序结束,跳转到 tag_3 并停止
jump
tag_3:
// 程序结束
stop
在进入方法体之前,汇编代码做了两件事:
低级伪代码如下:
// 保存方法调用后返回的位置。
@returnTo = tag_3tag_2: // setA
// 从调用数据中加载参数到堆栈。
@arg1 = calldata[4:4+32]
tag_4: // a = _a
sstore(0x0, @arg1)
tag_5 // return
jump(@returnTo)
tag_3:
stop
将两部分结合在一起:
methodSelector = calldata[0:4]if methodSelector == "0xee919d50":
goto tag_2 // goto setA
else:
// 没有匹配的方法。失败。
revert@returnTo = tag_3
tag_2: // setA(uint256 _a)
@arg1 = calldata[4:36]
tag_4: // a = _a
sstore(0x0, @arg1)
tag_5 // return
jump(@returnTo)
tag_3:
stop
趣闻:revert 的操作码是
fd
。但你不会在黄皮书中找到它的规范,也不会在代码中找到它的实现。实际上,fd
并不存在!它是一个无效的操作码。当 EVM 遇到无效操作码时,它会停止并回滚状态作为副作用。
Solidity 编译器如何生成具有多个方法的合约的汇编代码?
pragma solidity ^0.4.11;contract C {
uint256 a;
uint256 b; function setA(uint256 _a) {
a = _a;
} function setB(uint256 _b) {
b = _b;
}
}
很简单。只是更多的 if-else
分支依次排列:
// methodSelector = calldata[0:4]
and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)// 如果 methodSelector == 0x9cdcf9b
0x9cdcf9b
dup2
eq
tag_2 // SetB
jumpi// 否则 if methodSelector == 0xee919d50
dup1
0xee919d50
eq
tag_3 // SetA
jumpi
伪代码如下:
methodSelector = calldata[0:4]if methodSelector == "0x9cdcf9b":
goto tag_2
elsif methodSelector == "0xee919d50":
goto tag_3
else:
// 找不到匹配的方法。失败。
revert
不用担心这些零。这很正常。
对于方法调用,交易输入数据的前四个字节始终是方法选择器。然后是 32 字节块的方法参数。ABI 编码规范详细介绍了如何编码更复杂类型的参数,但阅读起来可能会非常痛苦。
另一种学习 ABI 编码的策略是使用 pyethereum 的 ABI 编码函数来研究不同类型数据的编码方式。我们将从简单的案例开始,逐步构建更复杂的类型。
首先,导入 encode_abi
函数:
from ethereum.abi import encode_abi
对于具有三个 uint256 参数的方法(例如 foo(uint256 a, uint256 b, uint256 c)
),编码后的参数只是依次排列的 uint256 数字:
## 第一个数组列出参数的类型。
## 第二个数组列出参数值。
> encode_abi(["uint256", "uint256", "uint256"],[1, 2, 3]).hex()0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
小于 32 字节的类型会填充到 32 字节:
> encode_abi(["int8", "uint32", "uint64"],[1, 2, 3]).hex()0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
对于固定大小的数组,元素仍然是 32 字节块(必要时零填充),依次排列:
> encode_abi(
["int8[3]", "int256[3]"],
[[1, 2, 3], [4, 5, 6]]
).hex()// int8[3]。填充到 32 字节。
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003// int256[3]。
0000000000000000000000000000000000000000000000000000000000000004
0000000000000000000000000000000000000000000000000000000000000005
0000000000000000000000000000000000000000000000000000000000000006
ABI 引入了一层间接性来编码动态数组,遵循一种称为头尾编码的方案。
思路是将动态数组的元素打包在交易调用数据的尾部。参数(“头”)是对调用数据中数组元素位置的引用。
如果我们调用一个带有 3 个动态数组的方法,参数将按照以下方式编码(为清晰起见添加了注释和换行):
> encode_abi(
["uint256[]", "uint256[]", "uint256[]"],
[[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]
).hex()/************* 头 (32*3 字节) *************/
// arg1:在位置 0x60 查找数组数据
0000000000000000000000000000000000000000000000000000000000000060
// arg2:在位置 0xe0 查找数组数据
00000000000000000000000000000000000000000000000000000000000000e0
// arg3:在位置 0x160 查找数组数据
0000000000000000000000000000000000000000000000000000000000000160/************* 尾 (128**3 字节) *************/
// 位置 0x60。arg1 的数据。
// 长度后跟元素。
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000a1
00000000000000000000000000000000000000000000000000000000000000a2
00000000000000000000000000000000000000000000000000000000000000a3// 位置 0xe0。arg2 的数据。
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3// 位置 0x160。arg3 的数据。
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000c1
00000000000000000000000000000000000000000000000000000000000000c2
00000000000000000000000000000000000000000000000000000000000000c3
所以,头
部分有三个 32 字节的参数,指向 尾
部分中的位置,后者包含三个动态数组的实际数据。
例如,第一个参数是 0x60
,指向调用数据的第 96 字节(0x60
)。如果你查看第 96 字节,它是一个数组的开始。前 32 字节是长度,后跟三个元素。
可以混合动态和静态参数。以下是一个具有 (static, dynamic, static)
参数的示例。静态参数按原样编码,而第二个动态数组的数据放在尾部:
> encode_abi(
["uint256", "uint256[]", "uint256"],
[0xaaaa, [0xb1, 0xb2, 0xb3], 0xbbbb]
).hex()/************* 头 (32*3 字节) *************/
// arg1:0xaaaa
000000000000000000000000000000000000000000000000000000000000aaaa
// arg2:在位置 0x60 查找数组数据
0000000000000000000000000000000000000000000000000000000000000060
// arg3:0xbbbb
000000000000000000000000000000000000000000000000000000000000bbbb/************* 尾 (128 字节) *************/
// 位置 0x60。arg2 的数据。
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3
有很多零,但这很正常。
字符串和字节数组也采用头尾编码。唯一的区别是字节紧密打包在 32 字节块中,如下所示:
> encode_abi(
["string", "string", "string"],
["aaaa", "bbbb", "cccc"]
).hex()// arg1:在位置 0x60 查找字符串数据
0000000000000000000000000000000000000000000000000000000000000060
// arg2:在位置 0xa0 查找字符串数据
00000000000000000000000000000000000000000000000000000000000000a0
// arg3:在位置 0xe0 查找字符串数据
00000000000000000000000000000000000000000000000000000000000000e0// 0x60 (96)。arg1 的数据。
0000000000000000000000000000000000000000000000000000000000000004
6161616100000000000000000000000000000000000000000000000000000000// 0xa0 (160)。arg2 的数据。
0000000000000000000000000000000000000000000000000000000000000004
6262626200000000000000000000000000000000000000000000000000000000// 0xe0 (224)。arg3 的数据。
0000000000000000000000000000000000000000000000000000000000000004
6363636300000000000000000000000000000000000000000000000000000000
对于每个字符串/字节数组,前 32 字节编码长度,后跟字节。
如果字符串大于 32 字节,则使用多个 32 字节块:
// 编码 48 字节的字符串数据
ethereum.abi.encode_abi(
["string"],
["a" * (32+16)]
).hex()
0000000000000000000000000000000000000000000000000000000000000020// 字符串长度为 0x30 (48)
0000000000000000000000000000000000000000000000000000000000000030
6161616161616161616161616161616161616161616161616161616161616161
6161616161616161616161616161616100000000000000000000000000000000
嵌套数组每个嵌套层都有一个间接层。
> encode_abi(
["uint256[][]"],
[[[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]]
).hex()// arg1:外部数组位于位置 0x20。
0000000000000000000000000000000000000000000000000000000000000020// 0x20。每个元素是内部数组的位置。
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000060
00000000000000000000000000000000000000000000000000000000000000e0
0000000000000000000000000000000000000000000000000000000000000160// array[0] 在 0x60
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000a1
00000000000000000000000000000000000000000000000000000000000000a2
00000000000000000000000000000000000000000000000000000000000000a3// array[1] 在 0xe0
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3// array[2] 在 0x160
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000c1
00000000000000000000000000000000000000000000000000000000000000c2
00000000000000000000000000000000000000000000000000000000000000c3
确实,有很多零。
为什么 ABI 将方法选择器截断为仅 4 字节?如果不同方法使用完整的 32 字节 sha256,是否可能发生不幸的碰撞?如果截断是为了节省成本,为什么在方法选择器中仅节省 28 字节,而零填充浪费了更多的字节?
这两个设计选择似乎矛盾……直到我们考虑交易的 gas 成本。
啊哈!零字节的成本要低 17 倍,所以零填充并没有看起来那么糟糕。
方法选择器是密码学哈希,它是伪随机的。随机字符串往往大多是非零字节,因为每个字节只有 0.3%(1/255)的概率是 0。
0x1
填充到 32 字节,成本为 192 gas。4*31 (零字节) + 68 (1 非零字节)
32 * 68
32 * 4
ABI 展示了又一次因 gas 成本结构而导致的古怪低级设计。
负数通常使用二进制补码表示。类型为 int8 的值 -1
编码为全 1 1111 1111
。
ABI 用 1 填充负数,因此 -1
将填充为:
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
小的负数大多为 1,这会消耗你相当多的 gas。
¯\_(ツ)_/¯
要与智能合约交互,你需要发送原始字节给它。它会进行一些计算,可能会改变自己的状态,然后返回原始字节给你。实际上,方法调用并不存在。它是由 ABI 共同制造的一种幻觉。
ABI 像是一种低级格式的规范,但在功能上更像是跨语言 RPC 框架的序列化格式。
我们可以将 DApp 和 Web App 的架构层次进行类比:
如果你喜欢这篇文章,你应该在 Twitter 上关注我 @hayeah.
在本系列关于 EVM 的文章中,我写到:
要了解更多关于 Solidity 和 EVM 的内容,请订阅我的每周教程:
- 原文链接: medium.com/@hayeah/how-t...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!