死磕以太坊源码分析之EVM如何调用ABI编码的外部方法-LMLPHP

前言

abi是什么?
前面我们认识到的是智能合约直接在EVM上的表示方式,但是,比如我想用java端程序去访问智能合约的某个方法,难道让java开发人员琢磨透汇编和二进制的表示,再去对接?
这明显是不可能的,为此abi产生了。这是一个通用可读的json格式的数据,任何别的客户端开发人员或者别的以太坊节点只要指定要调用的方法,通过abi将其解析为字节码并传递给evm,evm来计算处理该字节码并返回结果给前端。abi就起到这么一个作用,类似于传统的客户端和服务器端地址好交互规则,比如json格式的数据,然后进行交互。

在本系列的上一篇文章中我们看到了Solidity是如何在EVM存储器中表示复杂数据结构的。但是如果无法交互,数据就是没有意义的。智能合约就是数据和外界的中间体。

在这篇文章中我们将会看到SolidityEVM可以让外部程序来调用合约的方法并改变它的状态。

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

创建一个交易就像发送一个HTTP请求。Web的服务器会接收你的HTTP请求,然后改变数据库。交易会被网络接收,底层的区块链会扩展到包含改变的状态。

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

合约交易

让我们来看一下将状态变量设置在0x1位置上的交易。我们想要交互的合约有一个对变量a的设置者和获取者:

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…上查看该交易。

交易的输出数据是:

对于EVM而言,这只是36字节的元数据。它对元数据不会进行处理,会直接将元数据作为calldata传递给智能合约。如果智能合约是个Solidity程序,那么它会将这些输入字节解释为方法调用,并为setA(1)执行适当的汇编代码。

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

# 方法选择器(4字节)
0xee919d5
#第一个参数(32字节)
00000000000000000000000000000000000000000000000000000000000000001

前面的4个字节是方法选择器,剩下的输入数据是方法的参数,32个字节的块。在这个例子中,只有一个参数,值是0x1

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

让我们用Python来计算方法选择器。首先,哈希方法签名:


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

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

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

应用二进制接口(ABI)

对于EVM而言,交易的输入数据(calldata)只是一个字节序列。EVM内部不支持调用方法。

智能合约可以选择通过以结构化的方式处理输入数据来模拟方法调用,就像前面所说的那样。

如果EVM上的所有语言都同意相同的方式解释输入数据,那么它们就可以很容易进行交互。 合约应用二进制接口(ABI)指定了一个通用的编码模式。

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

调用一个获取者

如果你调用的方法改变了状态,那么整个网络必须要同意。这就需要有交易,并消耗gas。

一个获取者如getA()不会改变任何东西。我们可以将方法调用发送到本地的以太坊节点,而不用请求整个网络来执行计算。一个eth_callRPC请求可以允许你在本地模拟交易。这对于只读方法或gas使用评估比较有帮助。

一个eth_call就像一个缓存的HTTP GET请求。

  • 它不改变全球的共识状态
  • 本地区块链(“缓存”)可能会有点稍微过时

制作一个eth_call来调用 getA方法,通过返回值来获取状态a。首先,计算方法选择器:

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

由于没有参数,输入数据就只有方法选择器了。我们可以发送一个eth_call请求给任意的以太坊节点。对于这个例子,我们依然将请求发送给 infura.io的公共以太坊节点:

$ 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)的合约:

pragma solidity ^0.4.11;
contract C {
  uint256 a;
  // 注意: `payable` 让汇编简单一点点
  function setA(uint256 _a) payable {
    a = _a;
  }
}

编译:

solc --bin --asm --optimize 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 // 跳出
auxdata: 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

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

  1. 保存了一个位置,方法调用之后返回此位置
  2. 从调用数据里面加载参数到栈中

低级的伪代码:

// 保存位置,方法调用结束后返回此位置
@returnTo = tag_3
tag_2: // setA
  // 从调用数据里面加载参数到栈中
  @arg1 = calldata[4:4+32]
tag_4: // a = _a
  sstore(0x0, @arg1)
tag_5 // 返回
  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 // 返回
  jump(@returnTo)
tag_3:
  stop

处理多个方法

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)
// if methodSelector == 0x9cdcf9b
0x9cdcf9b
dup2
eq
tag_2 // SetB
jumpi
// elsif 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:
  // Cannot find a matching method. Fail.
  revert

ABI为复杂方法调用进行编码

对于一个方法调用,交易输入数据的前4个字节总是方法选择器。跟在后面的32字节块就是方法参数。 ABI编码规范显示了更加复杂的参数类型是如何被编码的,但是阅读起来非常的痛苦。

另一个学习ABI编码的方式是使用 pyethereum的ABI编码函数 来研究不同数据类型是如何编码的。我们会从简单的例子开始,然后建立更复杂的类型。

首先,导出encode_abi函数:

from ethereum.abi import encode_abi

对于一个有3个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字节的块(如果必要的话会填充0),依次排列:

> encode_abi(
   ["int8[3]", "int256[3]"],
   [[1, 2, 3], [4, 5, 6]]
).hex()
// int8[3]. Zero-padded to 32 bytes.
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
// int256[3].
0000000000000000000000000000000000000000000000000000000000000004
0000000000000000000000000000000000000000000000000000000000000005
0000000000000000000000000000000000000000000000000000000000000006

ABI为动态数组编码

ABI介绍了一种间接的编码动态数组的方法,遵循一个叫做头尾编码的模式。

该模式其实就是动态数组的元素被打包到交易的调用数据尾部,参数(“头”)会被引用到调用数据里,这里就是数组元素。

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

> encode_abi(
  ["uint256[]", "uint256[]", "uint256[]"],
  [[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]
).hex()
/************* HEAD (32*3 bytes) *************/
// 参数1: 数组数据在0x60位置
0000000000000000000000000000000000000000000000000000000000000060
// 参数2:数组数据在0xe0位置
00000000000000000000000000000000000000000000000000000000000000e0
// 参数3: 数组数据在0x160位置
0000000000000000000000000000000000000000000000000000000000000160
/************* TAIL (128**3 bytes) *************/
//  0x60位置。参数1的数据
// 长度后跟这元素
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000a1
00000000000000000000000000000000000000000000000000000000000000a2
00000000000000000000000000000000000000000000000000000000000000a3
// 0xe0位置。参数2的数据
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3
//0x160位置。参数3的数据
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000c1
00000000000000000000000000000000000000000000000000000000000000c2
00000000000000000000000000000000000000000000000000000000000000c3

HEAD部分有32字节参数,指出TAIL部分的位置,TAIL部分包含了3个动态数组的实际数据。

举个例子,第一个参数是0x60,指出调用数据的第96个(0x60)字节。如果你看一下第96个字节,它是数组的开始地方。前32字节是长度,后面跟着的是3个元素。

混合动态和静态参数是可能的。这里有个(staticdynamicstatic)参数。静态参数按原样编码,而第二个动态数组的数据放到了尾部:

> encode_abi(
  ["uint256", "uint256[]", "uint256"],
  [0xaaaa, [0xb1, 0xb2, 0xb3], 0xbbbb]
).hex()
/************* HEAD (32*3 bytes) *************/
// 参数1: 0xaaaa
000000000000000000000000000000000000000000000000000000000000aaaa
// 参数2:数组数据在0x60位置
0000000000000000000000000000000000000000000000000000000000000060
// 参数3: 0xbbbb
000000000000000000000000000000000000000000000000000000000000bbbb
/************* TAIL (128 bytes) *************/
// 0x60位置。参数2的数据
0000000000000000000000000000000000000000000000000000000000000003
00000000000000000000000000000000000000000000000000000000000000b1
00000000000000000000000000000000000000000000000000000000000000b2
00000000000000000000000000000000000000000000000000000000000000b3

编码字节数组

字符串和字节数组同样是头尾编码。唯一的区别是字节数组会被紧密的打包成一个32字节的块,就像:

> encode_abi(
  ["string", "string", "string"],
  ["aaaa", "bbbb", "cccc"]
).hex()
// 参数1: 字符串数据在0x60位置
0000000000000000000000000000000000000000000000000000000000000060
// 参数2:字符串数据在0xa0位置
00000000000000000000000000000000000000000000000000000000000000a0
// 参数3:字符串数据在0xe0位置
00000000000000000000000000000000000000000000000000000000000000e0
// 0x60 (96)。 参数1的数据
0000000000000000000000000000000000000000000000000000000000000004
6161616100000000000000000000000000000000000000000000000000000000
// 0xa0 (160)。参数2的数据
0000000000000000000000000000000000000000000000000000000000000004
6262626200000000000000000000000000000000000000000000000000000000
// 0xe0 (224)。参数3的数据
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()
//参数1:外层数组在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

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会被填充为:

ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

越大的负整数(-1大于-2)1越多,会花费相当多的gas。

总结

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

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

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

  • 区块链就是一个备份数据库
  • 合约就像web服务器
  • 交易就像请求
  • ABI是数据交换格式,就像Protocol Buffer
02-25 16:20