死磕以太坊源码分析之EVM如何调用ABI编码的外部方法
配合以下代码进行阅读:https://github.com/blockchainGuide/
写文不易,给个小关注,有什么问题可以指出,便于大家交流学习。
前言
abi是什么?
前面我们认识到的是智能合约直接在EVM上的表示方式,但是,比如我想用java端程序去访问智能合约的某个方法,难道让java开发人员琢磨透汇编和二进制的表示,再去对接?
这明显是不可能的,为此abi产生了。这是一个通用可读的json格式的数据,任何别的客户端开发人员或者别的以太坊节点只要指定要调用的方法,通过abi将其解析为字节码并传递给evm,evm来计算处理该字节码并返回结果给前端。abi就起到这么一个作用,类似于传统的客户端和服务器端地址好交互规则,比如json格式的数据,然后进行交互。
在本系列的上一篇文章中我们看到了Solidity
是如何在EVM存储器中表示复杂数据结构的。但是如果无法交互,数据就是没有意义的。智能合约就是数据和外界的中间体。
在这篇文章中我们将会看到Solidity
和EVM
可以让外部程序来调用合约的方法并改变它的状态。
“外部程序”不限于DApp/JavaScript
。任何可以使用HTTP RPC
与以太坊节点通信的程序,都可以通过创建一个交易与部署在区块链上的任何合约进行交互。
创建一个交易就像发送一个HTTP
请求。Web
的服务器会接收你的HTTP
请求,然后改变数据库。交易会被网络接收,底层的区块链会扩展到包含改变的状态。
交易对于智能合约就像HTTP
请求对于Web
服务器。
合约交易
让我们来看一下将状态变量设置在0x1
位置上的交易。我们想要交互的合约有一个对变量a
的设置者和获取者:
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…上查看该交易。
交易的input data是:
0xee919d500000000000000000000000000000000000000000000000000000000000000001
对于EVM而言,这只是36字节的元数据。它对元数据不会进行处理,会直接将元数据作为calldata
传递给智能合约。如果智能合约是个Solidity程序,那么它会将这些输入字节解释为方法调用,并为setA(1)
执行适当的汇编代码。
输入数据可以分成两个子部分:
1 2 3 4
| # 方法选择器(4字节) 0xee919d5 #第一个参数(32字节) 00000000000000000000000000000000000000000000000000000000000000001
|
前面的4个字节是方法选择器,剩下的输入数据是方法的参数,32个字节的块。在这个例子中,只有一个参数,值是0x1
。
方法选择器是方法签名的 kecccak256 哈希值。在这个例子中方法的签名是setA(uint256)
,也就是方法名称和参数的类型。
让我们用Python来计算方法选择器。首先,哈希方法签名:
然后获取哈希值的前4字节:
1 2
| > sha3("setA(uint256)")[0:4].hex() 'ee919d50'
|
应用二进制接口(ABI)
对于EVM而言,交易的输入数据(calldata
)只是一个字节序列。EVM内部不支持调用方法。
智能合约可以选择通过以结构化的方式处理输入数据来模拟方法调用,就像前面所说的那样。
如果EVM上的所有语言都同意相同的方式解释输入数据,那么它们就可以很容易进行交互。 合约应用二进制接口(ABI)指定了一个通用的编码模式。
我们已经看到了ABI是如何编码一个简单的方法调用,例如SetA(1)
。在后面章节中我们将会看到方法调用和更复杂的参数是如何编码的。
调用一个获取者
如果你调用的方法改变了状态,那么整个网络必须要同意。这就需要有交易,并消耗gas。
一个获取者如getA()
不会改变任何东西。我们可以将方法调用发送到本地的以太坊节点,而不用请求整个网络来执行计算。一个eth_call
RPC请求可以允许你在本地模拟交易。这对于只读方法或gas使用评估比较有帮助。
一个eth_call
就像一个缓存的HTTP GET请求。
- 它不改变全球的共识状态
- 本地区块链(“缓存”)可能会有点稍微过时
制作一个eth_call
来调用 getA
方法,通过返回值来获取状态a
。首先,计算方法选择器:
1 2
| >>> sha3("getA()")[0:4].hex() 'd46300fd'
|
由于没有参数,输入数据就只有方法选择器了。我们可以发送一个eth_call
请求给任意的以太坊节点。对于这个例子,我们依然将请求发送给 infura.io的公共以太坊节点:
1
| $ curl -X POST \-H "Content-Type: application/json" \"[https://rinkeby.infura.io/YOUR_INFURA_TOKEN](https://rinkeby.infura.io/YOUR_INFURA_TOKEN)" \--data '{"jsonrpc": "2.0","id": 1,"method": "eth_call","params": [{"to": "0x62650ae5c5777d1660cc17fcd4f48f6a66b9a4c2","data": "0xd46300fd"},"latest"]}'
|
根据ABI,该字节应该会解释为0x1
数值。
外部方法调用的汇编
现在来看看编译的合约是如何处理源输入数据的,并以此来制作一个方法调用。思考一个定义了setA(uint256)
的合约:
1 2 3 4 5 6 7 8
| pragma solidity ^0.4.11; contract C { uint256 a; 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: 0x0 dup2 swap1 sstore tag_5: pop jump auxdata: 0xa165627a7a7230582016353b5ec133c89560dea787de20e25e96284d67a632e9df74dd981cc4db7a0a0029 }
|
这里有两个样板代码与此讨论是无关的,但是仅供参考:
- 最上面的
mstore(0x40, 0x60)
为sha3哈希保留了内存中的前64个字节。不管合约是否需要,这个都会存在的。
- 最下面的
auxdata
用来验证发布的源码与部署的字节码是否相同的。这个是可选择的,但是嵌入到了编译器中
将剩下的汇编代码分成两个部分,这样容易分析一点:
- 匹配选择器并跳掉方法处
- 加载参数、执行方法,并从方法返回
首先,匹配选择器的注释汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
0xee919d50 dup2 eq tag_2 jumpi
tag_1: 0x0 dup1 revert
tag_2: ...
|
除了开始从调用数据里面加载4字节时的位转移,其他的都是非常清晰明朗的。为了清晰可见,给出了汇编逻辑的低级伪代码:
1 2 3 4 5 6
| methodSelector = calldata[0:4] if methodSelector == "0xee919d50": goto tag_2 else: revert
|
实际方法调用的注释汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| tag_2: tag_3 calldataload(0x4) jump(tag_4) tag_4: 0x0 dup2 swap1 sstore tag_5: pop jump tag_3: stop
|
在进入方法体之前,汇编代码做了两件事情:
- 保存了一个位置,方法调用之后返回此位置
- 从调用数据里面加载参数到栈中
低级的伪代码:
1 2 3 4 5 6 7 8 9 10 11
| @returnTo = tag_3 tag_2: @arg1 = calldata[4:4+32] tag_4: sstore(0x0, @arg1) tag_5 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 else: revert @returnTo = tag_3 tag_2: @arg1 = calldata[4:36] tag_4: sstore(0x0, @arg1) tag_5 jump(@returnTo) tag_3: stop
|
有趣的小细节:revert
的操作码是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
| and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff)
0x9cdcf9b dup2 eq tag_2 jumpi
dup1 0xee919d50 eq tag_3 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为复杂方法调用进行编码
对于一个方法调用,交易输入数据的前4个字节总是方法选择器。跟在后面的32字节块就是方法参数。 ABI编码规范显示了更加复杂的参数类型是如何被编码的,但是阅读起来非常的痛苦。
另一个学习ABI编码的方式是使用 pyethereum的ABI编码函数 来研究不同数据类型是如何编码的。我们会从简单的例子开始,然后建立更复杂的类型。
首先,导出encode_abi
函数:
1
| from ethereum.abi import encode_abi
|
对于一个有3个uint256
类型参数的方法(例如foo(uint256 a, uint256 b, uint256 c)
),编码参数只是简单的依次对uint256
数值进行编码:
1 2 3 4 5 6 7
| # 第一个数组列出了参数的类型 # 第二个数组列出了参数的值 > 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()
0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000002 0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000004 0000000000000000000000000000000000000000000000000000000000000005 0000000000000000000000000000000000000000000000000000000000000006
|
ABI为动态数组编码
ABI介绍了一种间接的编码动态数组的方法,遵循一个叫做头尾编码的模式。
该模式其实就是动态数组的元素被打包到交易的调用数据尾部,参数(“头”)会被引用到调用数据里,这里就是数组元素。
如果我们调用的方法有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()
0000000000000000000000000000000000000000000000000000000000000060
00000000000000000000000000000000000000000000000000000000000000e0
0000000000000000000000000000000000000000000000000000000000000160
0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000a1 00000000000000000000000000000000000000000000000000000000000000a2 00000000000000000000000000000000000000000000000000000000000000a3
0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000b1 00000000000000000000000000000000000000000000000000000000000000b2 00000000000000000000000000000000000000000000000000000000000000b3
0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000c1 00000000000000000000000000000000000000000000000000000000000000c2 00000000000000000000000000000000000000000000000000000000000000c3
|
HEAD
部分有32字节参数,指出TAIL
部分的位置,TAIL
部分包含了3个动态数组的实际数据。
举个例子,第一个参数是0x60
,指出调用数据的第96个(0x60
)字节。如果你看一下第96个字节,它是数组的开始地方。前32字节是长度,后面跟着的是3个元素。
混合动态和静态参数是可能的。这里有个(static
,dynamic
,static
)参数。静态参数按原样编码,而第二个动态数组的数据放到了尾部:
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()
000000000000000000000000000000000000000000000000000000000000aaaa
0000000000000000000000000000000000000000000000000000000000000060
000000000000000000000000000000000000000000000000000000000000bbbb
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()
0000000000000000000000000000000000000000000000000000000000000060
00000000000000000000000000000000000000000000000000000000000000a0
00000000000000000000000000000000000000000000000000000000000000e0
0000000000000000000000000000000000000000000000000000000000000004 6161616100000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000004 6262626200000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000004 6363636300000000000000000000000000000000000000000000000000000000
|
对于每个字符串/字节数组,前面的32字节是编码长度,后面跟着才是字符串/字节数组的内容。
如果字符串大于32字节,那么多个32字节块就会被使用:
1 2 3 4 5 6 7 8 9 10 11 12
| ethereum.abi.encode_abi( ["string"], ["a" * (32+16)] ).hex()
0000000000000000000000000000000000000000000000000000000000000020
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()
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000003 0000000000000000000000000000000000000000000000000000000000000060 00000000000000000000000000000000000000000000000000000000000000e0 0000000000000000000000000000000000000000000000000000000000000160
0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000a1 00000000000000000000000000000000000000000000000000000000000000a2 00000000000000000000000000000000000000000000000000000000000000a3
0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000b1 00000000000000000000000000000000000000000000000000000000000000b2 00000000000000000000000000000000000000000000000000000000000000b3
0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000c1 00000000000000000000000000000000000000000000000000000000000000c2 00000000000000000000000000000000000000000000000000000000000000c3
|
Gas成本和ABI编码设计
为什么ABI将方法选择器截断到4个字节?如果我们不使用sha256的整个32字节,会不会不幸的碰到不同方法发生冲突的情况? 如果这个截断是为了节省成本,那么为什么在用更多的0来进行填充时,而仅仅只为了节省方法选择器中的28字节而截断呢?
这种设计看起来互相矛盾……直到我们考虑到一个交易的gas成本。
- 每笔交易需要支付 21000 gas
- 每笔交易的0字节或代码需要支付 4 gas
- 每笔交易的非0字节或代码需要支付 68 gas
啊哈!0要便宜17倍,0填充现在看起来没有那么不合理了。
方法选择器是一个加密哈希值,是个伪随机。一个随机的字符串倾向于拥有很多的非0字节,因为每个字节只有0.3%(1/255)的概率是0。
0x1
填充到32字节成本是192 gas
4*31 (0字节) + 68 (1个非0字节)
- sha256可能有32个非0字节,成本大概2176 gas
32 * 68
- sha256截断到4字节,成本大概272 gas
32*4
ABI展示了另外一个底层设计的奇特例子,通过gas成本结构进行激励。
负整数….
一般使用叫做 补码的方式来表达负整数。int8
类型-1
的数值编码会都是1。1111 1111
。
ABI用1来填充负整数,所以-1
会被填充为:
1
| ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
越大的负整数(-1
大于-2
)1越多,会花费相当多的gas。
总结
与智能合约交互,你需要发送原始字节。它会进行一些计算,可能会改变自己的状态,然后会返回给你原始字节。方法调用实际上不存在,这是ABI创造的集体假象。
ABI被指定为一个低级格式,但是在功能上更像一个跨语言RPC框架的序列化格式。
我们可以在DApp和Web App的架构层面之间进行类比:
翻译自 https://medium.com/@hayeah/diving-into-the-ethereum-vm-part-2-storage-layout-bc5349cb11b7