【翻译】 深入理解以太坊虚拟机 - 如何解释一个智能合约的方法调用

原文:https://medium.com/@hayeah/how-to-decipher-a-smart-contract-method-call-8ee980311603
译者:中山大学数学学院(珠海)林学渊
大二时给量子做的翻译,转载注明出处,谢谢

如何解释一个智能合约的方法调用


在本系列的前几篇文章中,我们已经看到了 Solidity 如何在 EVM 存储中表示复杂的数据结构。但是如果没有办法与数据交互,数据就毫无用处。智能合约是数据与外部世界交互的中介。

在本文中,我们将看到 Solidity 和 EVM 如何使外部程序能够调用合约的方法并使其状态发生变化。

“外部程序” 不限于 DApp / JavaScript。 任何可以使用 HTTP RPC 与以太坊节点进行通信的程序都可以通过创建交易来与部署在区块链上的任何合约进行交互。

创建一个交易就像创建一个 HTTP 请求。 Web 服务器可以接受你的 HTTP 请求并更改数据库。同理,网络将接受一个交易,然后底层区块链扩展以包括状态的改变。

交易对于智能合约来说就像是 HTTP 请求对于 Web 服务。

如果对 EVM 汇编和 Solidity 的数据表示不熟悉,请参阅本系列以前的文章以了解更多信息:

合约交易

我们来看一个将状态变量设置为 0x1 的交易。与之交互的合约有变量 a 的一个 setter 和一个 getter:

1
2
3
4
5
6
7
8
9
10
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….处查看此交易

交易的输入数据是:

1
0xee919d500000000000000000000000000000000000000000000000000000000000000001

对 EVM 来说,这仅仅是 36 个字节的原始数据。将它作为 calldata 传给未经处理的智能合约。如果智能合约是一个 Solidity 程序,那么它将这些输入字节解释为一个方法调用,并为 setA(1) 执行相应的汇编代码。

输入数据可以分解为两个子部分:

1
2
3
4
# 方法选择器 (4 bytes)
0xee919d5
# 第一个参数 (32 bytes)
00000000000000000000000000000000000000000000000000000000000000001

前四个字节是方法选择器,其余部分是 32 字节块的方法参数。在这个例子里只有 1 个参数,值 0x1

方法选择器是方法签名的 kecccak256 哈希。在这个例子里,方法签名是 setA(uint256) ,它是方法的名称和参数的类型。

我们用 Python 来计算一下方法选择器。首先,散列方法签名:

1
2
3
4
# 安装 pyethereum https://github.com/ethereum/pyethereum/#installation
> from ethereum.utils import sha3
> sha3("setA(uint256)").hex()
'ee919d50445cd9f463621849366a537968fe1ce096894b0d0c001528383d4769'

然后获取哈希的前4个字节:

1
2
> sha3("setA(uint256)")[0:4].hex()
'ee919d50'

应用程序二进制接口(ABI)

就 EVM 而言,交易的输入数据(calldata)只是一个字节序列。EVM 没有内置的方法调用的支持。

智能合约可以选择通过结构化方式来处理输入数据以模拟方法调用,如前一节所述。

如果 EVM 上的语言对输入数据的解释达成一致,那么它们可以很容易地进行交互。合约应用程序二进制接口(ABI)指定了一种通用的编码方案。

我们已经知道 ABI 如何编码一个简单的方法调用,如 setA(1)。在后面的章节中,我们将看到有更复杂参数的方法调用是如何编码的。

调用 Getter

如果调用的方法改变了状态,那么整个网络都必须同意。这将需要一笔交易,并且会耗费 gas。

getA() 这样的 getter 方法不会改变任何东西。我们可以将方法调用发送到本地以太坊节点,而不是要求整个网络进行计算。 eth_call RPC 请求允许在本地模拟交易。这对只读方法或 gas 的使用评估非常有用。

eth_call 就像缓存的 HTTP GET请求。

  • 它不会改变全局共识状态。
  • 本地区块链(“cache”)可能有点过时。

让我们发起一个 eth_call 调用 getA 方法,返回状态 a

首先,计算出方法选择器:

1
2
>>> sha3("getA()")[0:4].hex()
'd46300fd'

由于没有参数,输入数据本身就是方法选择器。 我们可以向任意一个以太坊节点发送 eth_call 请求。 在这个例子中,我们会将请求发送到由 infura.io 托管的公共以太坊节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ 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 执行计算并返回原始字节:

1
2
3
4
5
{
"jsonrpc":"2.0",
"id":1,
"result":"0x0000000000000000000000000000000000000000000000000000000000000001"
}

根据 ABI,这些字节应该被解释为值 0x1

用于外部方法调用的汇编代码

现在我们来看编译后的合约如何处理原始输入数据来进行方法调用的。 考虑定义了 setA(uint256) 的合约:

1
2
3
4
5
6
7
8
pragma solidity ^0.4.11;
contract C {
uint256 a;
// 注意: `payable` 让汇编代码简单一点
function setA(uint256 _a) payable {
a = _a;
}
}

编译:

1
solc --bin --asm --optimize call.sol

被调用方法的汇编代码位于 sub_0 下的合约主体中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 // out
auxdata: 0xa165627a7a7230582016353b5ec133c89560dea787de20e25e96284d67a632e9df74dd981cc4db7a0a0029
}

有两段与本次讨论无关的样板代码:

  • mstore(0x40,0x60) 位于顶部,用于保存内存中 sha3 哈希的前 64 个字节。无论合同是否需要,它总是存在的。
  • auxdata 位于最底部,用于验证发布的源代码与部署的字节码是否相同。 这是可选的,但可以编译到编译器中。

让我们将剩余的汇编代码分成两部分以便于分析:

  1. 匹配选择器并跳转到方法。
  2. 加载参数,执行方法和从方法返回。

首先,用于匹配选择器的汇编代码如下(带注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 加载前 4 个字节作为方法选择器
and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
// 如果选择器匹配到 `0xee919d50`, 跳转到 setA
0xee919d50
dup2
eq
tag_2
jumpi

// 没有匹配到的方法. 失败 & 返回.
tag_1:
0x0
dup1
revert
// setA 方法的主体
tag_2:
...

很直接,除了在开始从调用数据里加载 4 个字节的位交换。

为了清楚起见,低层次伪代码中的汇编逻辑如下所示:

1
2
3
4
5
6
methodSelector = calldata[0:4]
if methodSelector == "0xee919d50":
goto tag_2 // 跳转到 setA
else:
// 没有匹配到的方法. 失败 & 返回.
revert

实际方法调用的汇编代码(带注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// setA
tag_2:
// 方法调用后返回的位置
tag_3
// 加载参数 (参数值为 0x1,0x4是地址,0x0~0x3是方法选择器).
calldataload(0x4)
// 执行
jump(tag_4)
tag_4:
// sstore(0x0, 0x1)
0x0
dup2
swap1
sstore
tag_5:
pop
// 程序结束, 跳转到 tag_3 停止
jump
tag_3:
// 程序结束
stop

在进入方法主体之前,汇编代码做了两件事:

  1. 保存方法调用后返回的位置。
  2. 将来自调用数据的参数加载到堆栈上。

低层次的伪代码:

1
2
3
4
5
6
7
8
9
10
11
// 保存方法调用后返回的位置.
@returnTo = tag_3
tag_2: // setA
// 将调用数据的参数加载到堆栈中.
@arg1 = calldata[4:4+32]
tag_4: // a = _a
sstore(0x0, @arg1)
tag_5 // return
jump(@returnTo)
tag_3:
stop

将两部分组合在一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
methodSelector = calldata[0:4]
if methodSelector == "0xee919d50":
goto tag_2 // 跳转到 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

有趣的花絮:返回的操作码是 fd。 但是你在黄皮书里找不到它的规范,或者在代码中实现。 实际上,fd 并不存在! 这是一个无效的操作。 当 EVM 遇到无效操作时,它会因为副作用而放弃并恢复状态。

处理多个方法

Solidity 编译器如何为具有多个方法的合同生成汇编代码?

1
2
3
4
5
6
7
8
9
10
11
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 分支一个一个接上去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// methodSelector = calldata[0:4]
and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
// if methodSelector == 0x9cdcf9b
0x9cdcf9b
dup2
eq
tag_2 // SetB
jumpi
// elsif methodSelector == 0xee919d50
dup1
0xee919d50
eq
tag_3 // SetA
jumpi

伪代码:

1
2
3
4
5
6
7
8
methodSelector = calldata[0:4]
if methodSelector == "0x9cdcf9b":
goto tag_2
elsif methodSelector == "0xee919d50":
goto tag_3
else:
// 没有匹配的方法. 失败.
revert

用于复杂方法调用的 ABI 编码

别担心零。没事的。

对于方法调用,交易的输入数据前四个字节总是方法选择器。 然后方法参数以 32 个字节的块为单位。 ABI编码规范详细说明了复杂类型参数是如何编码的,但读取会非常痛苦。

学习 ABI 编码的另一个策略是使用 pyethereum 的 ABI 编码函数来研究如何对不同数据类型进行编码。 我们将从简单的案例开始,并构建更复杂的类型。

首先,导入 encode_abi 函数:

1
from ethereum.abi import encode_abi

对于有三个 uint256 参数的方法(例如 foo(uint256 a,uint256 b,uint256 c)),编码的参数就是一个接一个的 uint256 数字:

1
2
3
4
5
6
# 第一个数组列出参数类型
# 第二个数组列出参数值
> encode_abi(["uint256", "uint256", "uint256"],[1, 2, 3]).hex()
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

小于 32 个字节的数据类型填充为32个字节:

1
2
3
4
> encode_abi(["int8", "uint32", "uint64"],[1, 2, 3]).hex()
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003

对于定长数组,元素也是 32 字节的块(如果需要,填充 0 ),依次排列:

1
2
3
4
5
6
7
8
9
10
11
12
> encode_abi(
["int8[3]", "int256[3]"],
[[1, 2, 3], [4, 5, 6]]
).hex()
// int8[3]. 用 0 填充到 32 bytes.
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
// int256[3].
0000000000000000000000000000000000000000000000000000000000000004
0000000000000000000000000000000000000000000000000000000000000005
0000000000000000000000000000000000000000000000000000000000000006

动态数组的 ABI 编码

ABI 引入了一个间接层来对动态数组进行编码,遵循称为头尾编码的方案。

这个想法是,动态数组的元素封装在交易的 calldata 的尾部。 参数(“头部”)是对数组元素所在的 calldata 的引用。

如果我们调用一个含 3 个动态数组的方法,则参数会像这样编码(为了清晰起见添加了注释和换行符):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
> encode_abi(
["uint256[]", "uint256[]", "uint256[]"],
[[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]
).hex()
/************* 头部 (32*3 bytes) *************/
// arg1: 查看位置 0x60 寻找数组数据
0000000000000000000000000000000000000000000000000000000000000060
// arg2: 查看位置 0xe0 寻找数组数据
00000000000000000000000000000000000000000000000000000000000000e0
// arg3: 查看位置 0x160 寻找数组数据
0000000000000000000000000000000000000000000000000000000000000160
/************* 尾部 (128**3 bytes) *************/
// 位置 0x60. arg1 的数据.
// 长度后跟元素
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000a1
00000000000000000000000000000000000000000000000000000000000000a2
00000000000000000000000000000000000000000000000000000000000000a3
// 位置 0xe0. arg2 的数据.
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3
// 位置 0x160. arg3 的数据.
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000c1
00000000000000000000000000000000000000000000000000000000000000c2
00000000000000000000000000000000000000000000000000000000000000c3

所以头部有三个 32 字节的参数,指向尾部的位置,它包含三个动态数组的实际数据。

例如,第一个参数是 0x60,指向 calldata 的第96个(0x60)字节。 如果你看第 96 个字节,它是一个数组的开始。 前 32 个字节是长度,后面是三个元素。

可以混合动态和静态参数。 这里有一个 (静态, 动态, 静态) 参数的例子。 静态参数按原样编码,而第二个动态数组的数据放置在尾部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
> encode_abi(
["uint256", "uint256[]", "uint256"],
[0xaaaa, [0xb1, 0xb2, 0xb3], 0xbbbb]
).hex()
/************* 头部 (32*3 bytes) *************/
// arg1: 0xaaaa
000000000000000000000000000000000000000000000000000000000000aaaa
// arg2: 查看数组数据的位置 0x60
0000000000000000000000000000000000000000000000000000000000000060
// arg3: 0xbbbb
000000000000000000000000000000000000000000000000000000000000bbbb
/************* 尾部 (128 bytes) *************/
// 位置 0x60. arg2 的数据.
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3

很多零,但没关系。

编码字节

字符串和字节数组也用头尾编码。唯一的区别是这些字节以 32 字节的块形式紧密打包,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> 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 字节的块:

1
2
3
4
5
6
7
8
9
10
11
// encode 48 bytes of string data
ethereum.abi.encode_abi(
["string"],
["a" * (32+16)]
).hex()

0000000000000000000000000000000000000000000000000000000000000020
// 字符串长度是 0x30 (48)
0000000000000000000000000000000000000000000000000000000000000030
6161616161616161616161616161616161616161616161616161616161616161
6161616161616161616161616161616100000000000000000000000000000000

嵌套数组

嵌套数组,每个嵌套有一个间接寻址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
> encode_abi(
["uint256[][]"],
[[[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]]
).hex()
// arg1: 外层数组位于 0x20.
0000000000000000000000000000000000000000000000000000000000000020
// 0x20. 每个元素都是一个内部数组的位置。
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000060
00000000000000000000000000000000000000000000000000000000000000e0
0000000000000000000000000000000000000000000000000000000000000160
// array[0] at 0x60
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000a1
00000000000000000000000000000000000000000000000000000000000000a2
00000000000000000000000000000000000000000000000000000000000000a3
// array[1] at 0xe0
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3
// array[2] at 0x160
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000c1
00000000000000000000000000000000000000000000000000000000000000c2
00000000000000000000000000000000000000000000000000000000000000c3

呀,很多零。

gas 成本 & ABI 编码设计

为什么 ABI 将方法选择器截断为只有 4 个字节? 如果不用 sha256 的全部 32 个字节,那么对于不同的方法是否会出现碰撞? 如果截断是为了节省成本,为什么还要在方法选择器中节省 28 字节却让它浪费更多的零填充字节?

这两个设计选择似乎是矛盾的……直到我们考虑交易的 gas 成本。

  • 21000 支付给每笔交易
  • 4 支付给交易的每个 0 字节数据或代码
  • 68 支付给交易的每个非零数据或代码

啊哈! 零字节便宜17倍,所以零填充不是那么糟糕。

方法选择器是一个加密哈希,它是伪随机的。一个随机字符串往往会有大部分非零字节,因为每个字节只有 0.3%(1/255)的可能性为 0。

  • 0x1 填充为 32 字节,成本 192 gas

    4 * 31 (零字节) + 68 (1个非零字节)

  • sha256 很可能有 32 个非零字节,成本约为 2176 gas

    32 * 68

  • sha256 截断为 4 个字节,成本约为 272 gas

    32 * 4

ABI 展示了另一个由 gas 成本结构激励的低级设计的例子。

负整数…

负整数通常使用称为补码的方案来表示。int8 的 -1 编码全是1 1111 1111

ABI 使用 1 来填充负整数,所以 -1 会填充为:

1
2
// 16进制表示
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

小负数主要是 1,因此耗费大量 gas。

¯\_(ツ)_/¯

小结

要与智能合约交互,需要向其发送原始字节。它执行一些计算,可能会改变它自己的状态,然后返回原始字节。方法调用实际上不存在。 这是 ABI 创造的集体幻想。

ABI被指定为低级格式,但在功能上它更像是跨语言 RPC 框架的序列化格式。

我们可以在 DApp 和 Web App 的架构层之间进行类比:

  • 区块链就像支持数据库。
  • 合约就像一个 web 服务。
  • 交易就像一个请求。
  • ABI 是数据交换格式,如协议缓冲区

如果你喜欢这篇文章,你应该在Twitter @hayeah上关注我。

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器